diff --git a/.app_version b/.app_version index 0f1a7dfc..8570a3ae 100644 --- a/.app_version +++ b/.app_version @@ -1 +1 @@ -0.37.0 +0.37.2 diff --git a/CHANGELOG.md b/CHANGELOG.md index fe29bbb3..b0aff878 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,22 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](http://keepachangelog.com/) and this project adheres to [Semantic Versioning](http://semver.org/). +# [0.37.2] - 2026-01-03 + +## Fixed + +- Months are now correctly ordered (Jan-Dec) in the year-end digest chart instead of being sorted alphabetically. +- Time spent in a country and city is now calculated correctly for the year-end digest email. #2104 +- Updated Trix to fix a XSS vulnerability. #2102 +- Map v2 UI no longer blocks when Immich/Photoprism integration has a bad URL or is unreachable. Added 10-second timeout to photo API requests and improved error handling to prevent UI freezing during initial load. #2085 + +# [0.37.1] - 2025-12-30 + +## Fixed + +- The db migration preventing the app from starting. +- Raw data archive verifier now allows having points deleted from the db after archiving. + # [0.37.0] - 2025-12-30 ## Added diff --git a/Gemfile.lock b/Gemfile.lock index 7e4e9766..f17cc615 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -129,11 +129,11 @@ GEM rack-test (>= 0.6.3) regexp_parser (>= 1.5, < 3.0) xpath (~> 3.2) - chartkick (5.2.0) + chartkick (5.2.1) chunky_png (1.4.0) coderay (1.1.3) concurrent-ruby (1.3.6) - connection_pool (2.5.5) + connection_pool (3.0.2) crack (1.0.1) bigdecimal rexml @@ -215,7 +215,7 @@ GEM csv mini_mime (>= 1.0.0) multi_xml (>= 0.5.2) - i18n (1.14.7) + i18n (1.14.8) concurrent-ruby (~> 1.0) importmap-rails (2.2.2) actionpack (>= 6.0.0) @@ -227,7 +227,7 @@ GEM rdoc (>= 4.0.0) reline (>= 0.4.2) jmespath (1.6.2) - json (2.15.0) + json (2.18.0) json-jwt (1.17.0) activesupport (>= 4.2) aes_key_wrap @@ -273,7 +273,8 @@ GEM method_source (1.1.0) mini_mime (1.1.5) mini_portile2 (2.8.9) - minitest (5.26.2) + minitest (6.0.1) + prism (~> 1.5) msgpack (1.7.3) multi_json (1.15.0) multi_xml (0.8.0) @@ -356,7 +357,7 @@ GEM json yaml parallel (1.27.0) - parser (3.3.9.0) + parser (3.3.10.0) ast (~> 2.4.1) racc patience_diff (1.2.0) @@ -369,7 +370,7 @@ GEM pp (0.6.3) prettyprint prettyprint (0.2.0) - prism (1.5.1) + prism (1.7.0) prometheus_exporter (2.2.0) webrick pry (0.15.2) @@ -512,7 +513,7 @@ GEM rswag-ui (2.17.0) actionpack (>= 5.2, < 8.2) railties (>= 5.2, < 8.2) - rubocop (1.81.1) + rubocop (1.82.1) json (~> 2.3) language_server-protocol (~> 3.17.0.2) lint_roller (~> 1.1.0) @@ -520,20 +521,20 @@ GEM parser (>= 3.3.0.2) rainbow (>= 2.2.2, < 4.0) regexp_parser (>= 2.9.3, < 3.0) - rubocop-ast (>= 1.47.1, < 2.0) + rubocop-ast (>= 1.48.0, < 2.0) ruby-progressbar (~> 1.7) unicode-display_width (>= 2.4.0, < 4.0) - rubocop-ast (1.47.1) + rubocop-ast (1.49.0) parser (>= 3.3.7.2) - prism (~> 1.4) - rubocop-rails (2.33.4) + prism (~> 1.7) + rubocop-rails (2.34.2) activesupport (>= 4.2.0) lint_roller (~> 1.1) rack (>= 1.1) rubocop (>= 1.75.0, < 2.0) rubocop-ast (>= 1.44.0, < 2.0) ruby-progressbar (1.13.0) - rubyzip (3.2.0) + rubyzip (3.2.2) securerandom (0.4.1) selenium-webdriver (4.35.0) base64 (~> 0.2) @@ -613,7 +614,7 @@ GEM unicode (0.4.4.5) unicode-display_width (3.2.0) unicode-emoji (~> 4.1) - unicode-emoji (4.1.0) + unicode-emoji (4.2.0) uri (1.1.1) useragent (0.16.11) validate_url (1.0.15) diff --git a/app/javascript/controllers/maps/maplibre/data_loader.js b/app/javascript/controllers/maps/maplibre/data_loader.js index 165702e8..f4e266fb 100644 --- a/app/javascript/controllers/maps/maplibre/data_loader.js +++ b/app/javascript/controllers/maps/maplibre/data_loader.js @@ -56,22 +56,36 @@ export class DataLoader { } data.visitsGeoJSON = this.visitsToGeoJSON(data.visits) - // Fetch photos - try { - console.log('[Photos] Fetching photos from:', startDate, 'to', endDate) - data.photos = await this.api.fetchPhotos({ - start_at: startDate, - end_at: endDate - }) - console.log('[Photos] Fetched photos:', data.photos.length, 'photos') - console.log('[Photos] Sample photo:', data.photos[0]) - } catch (error) { - console.error('[Photos] Failed to fetch photos:', error) + // Fetch photos - only if photos layer is enabled and integration is configured + // Skip API call if photos are disabled to avoid blocking on failed integrations + if (this.settings.photosEnabled) { + try { + console.log('[Photos] Fetching photos from:', startDate, 'to', endDate) + // Use Promise.race to enforce a client-side timeout + const photosPromise = this.api.fetchPhotos({ + start_at: startDate, + end_at: endDate + }) + const timeoutPromise = new Promise((_, reject) => + setTimeout(() => reject(new Error('Photo fetch timeout')), 15000) // 15 second timeout + ) + + data.photos = await Promise.race([photosPromise, timeoutPromise]) + console.log('[Photos] Fetched photos:', data.photos.length, 'photos') + console.log('[Photos] Sample photo:', data.photos[0]) + } catch (error) { + console.warn('[Photos] Failed to fetch photos (non-blocking):', error.message) + data.photos = [] + } + } else { + console.log('[Photos] Photos layer disabled, skipping fetch') data.photos = [] } data.photosGeoJSON = this.photosToGeoJSON(data.photos) console.log('[Photos] Converted to GeoJSON:', data.photosGeoJSON.features.length, 'features') - console.log('[Photos] Sample feature:', data.photosGeoJSON.features[0]) + if (data.photosGeoJSON.features.length > 0) { + console.log('[Photos] Sample feature:', data.photosGeoJSON.features[0]) + } // Fetch areas try { diff --git a/app/services/immich/request_photos.rb b/app/services/immich/request_photos.rb index 0dfcbcd5..7018bbe3 100644 --- a/app/services/immich/request_photos.rb +++ b/app/services/immich/request_photos.rb @@ -31,7 +31,10 @@ class Immich::RequestPhotos while page <= max_pages response = JSON.parse( HTTParty.post( - immich_api_base_url, headers: headers, body: request_body(page) + immich_api_base_url, + headers: headers, + body: request_body(page), + timeout: 10 ).body ) Rails.logger.debug('==== IMMICH RESPONSE ====') @@ -46,6 +49,9 @@ class Immich::RequestPhotos end data.flatten + rescue HTTParty::Error, Net::OpenTimeout, Net::ReadTimeout => e + Rails.logger.error("Immich photo fetch failed: #{e.message}") + [] end def headers diff --git a/app/services/photoprism/request_photos.rb b/app/services/photoprism/request_photos.rb index 0f7fd93b..44005811 100644 --- a/app/services/photoprism/request_photos.rb +++ b/app/services/photoprism/request_photos.rb @@ -43,13 +43,17 @@ class Photoprism::RequestPhotos end data.flatten + rescue HTTParty::Error, Net::OpenTimeout, Net::ReadTimeout => e + Rails.logger.error("Photoprism photo fetch failed: #{e.message}") + [] end def fetch_page(offset) response = HTTParty.get( photoprism_api_base_url, headers: headers, - query: request_params(offset) + query: request_params(offset), + timeout: 10 ) if response.code != 200 diff --git a/app/services/points/raw_data/verifier.rb b/app/services/points/raw_data/verifier.rb index de42229f..2da7dfc2 100644 --- a/app/services/points/raw_data/verifier.rb +++ b/app/services/points/raw_data/verifier.rb @@ -110,18 +110,24 @@ module Points return { success: false, error: 'Point IDs checksum mismatch' } end - # 8. Verify all points still exist in database + # 8. Check which points still exist in database (informational only) existing_count = Point.where(id: point_ids).count if existing_count != point_ids.count - return { - success: false, - error: "Missing points in database: expected #{point_ids.count}, found #{existing_count}" - } + Rails.logger.info( + "Archive #{archive.id}: #{point_ids.count - existing_count} points no longer in database " \ + "(#{existing_count}/#{point_ids.count} remaining). This is OK if user deleted their data." + ) end - # 9. Verify archived raw_data matches current database raw_data - verification_result = verify_raw_data_matches(archived_data) - return verification_result unless verification_result[:success] + # 9. Verify archived raw_data matches current database raw_data (only for existing points) + if existing_count.positive? + verification_result = verify_raw_data_matches(archived_data) + return verification_result unless verification_result[:success] + else + Rails.logger.info( + "Archive #{archive.id}: Skipping raw_data verification - no points remain in database" + ) + end { success: true } end @@ -149,11 +155,18 @@ module Points point_ids_to_check = archived_data.keys.sample(100) end - mismatches = [] - found_points = 0 + # Filter to only check points that still exist in the database + existing_point_ids = Point.where(id: point_ids_to_check).pluck(:id) + + if existing_point_ids.empty? + # No points remain to verify, but that's OK + Rails.logger.info("No points remaining to verify raw_data matches") + return { success: true } + end - Point.where(id: point_ids_to_check).find_each do |point| - found_points += 1 + mismatches = [] + + Point.where(id: existing_point_ids).find_each do |point| archived_raw_data = archived_data[point.id] current_raw_data = point.raw_data @@ -167,14 +180,6 @@ module Points end end - # Check if we found all the points we were looking for - if found_points != point_ids_to_check.size - return { - success: false, - error: "Missing points during data verification: expected #{point_ids_to_check.size}, found #{found_points}" - } - end - if mismatches.any? return { success: false, diff --git a/app/services/users/digests/calculate_year.rb b/app/services/users/digests/calculate_year.rb index faea7d50..857effc9 100644 --- a/app/services/users/digests/calculate_year.rb +++ b/app/services/users/digests/calculate_year.rb @@ -88,35 +88,86 @@ module Users end def calculate_time_spent - country_time = Hash.new(0) + { + 'countries' => calculate_country_time_spent, + 'cities' => calculate_city_time_spent + } + end + + def calculate_country_time_spent + country_days = build_country_days_map + + # Convert days to minutes (days * 24 * 60) and return top 10 + country_days + .transform_values { |days| days.size * 24 * 60 } + .sort_by { |_, minutes| -minutes } + .first(10) + .map { |name, minutes| { 'name' => name, 'minutes' => minutes } } + end + + def build_country_days_map + year_points = fetch_year_points_with_country + country_days = Hash.new { |h, k| h[k] = Set.new } + + year_points.each do |point| + date = Time.zone.at(point.timestamp).to_date + country_days[point.country_name].add(date) + end + + country_days + end + + def fetch_year_points_with_country + start_of_year = Time.zone.local(year, 1, 1, 0, 0, 0) + end_of_year = start_of_year.end_of_year + + user.points + .without_raw_data + .where('timestamp >= ? AND timestamp <= ?', start_of_year.to_i, end_of_year.to_i) + .where.not(country_name: [nil, '']) + .select(:country_name, :timestamp) + end + + def calculate_city_time_spent + city_time = aggregate_city_time_from_monthly_stats + + city_time + .sort_by { |_, minutes| -minutes } + .first(10) + .map { |name, minutes| { 'name' => name, 'minutes' => minutes } } + end + + def aggregate_city_time_from_monthly_stats city_time = Hash.new(0) monthly_stats.each do |stat| - toponyms = stat.toponyms - next unless toponyms.is_a?(Array) - - toponyms.each do |toponym| - next unless toponym.is_a?(Hash) - - country = toponym['country'] - next unless toponym['cities'].is_a?(Array) - - toponym['cities'].each do |city| - next unless city.is_a?(Hash) - - stayed_for = city['stayed_for'].to_i - city_name = city['city'] - - country_time[country] += stayed_for if country.present? - city_time[city_name] += stayed_for if city_name.present? - end - end + process_stat_toponyms(stat, city_time) end - { - 'countries' => country_time.sort_by { |_, v| -v }.first(10).map { |name, minutes| { 'name' => name, 'minutes' => minutes } }, - 'cities' => city_time.sort_by { |_, v| -v }.first(10).map { |name, minutes| { 'name' => name, 'minutes' => minutes } } - } + city_time + end + + def process_stat_toponyms(stat, city_time) + toponyms = stat.toponyms + return unless toponyms.is_a?(Array) + + toponyms.each do |toponym| + process_toponym_cities(toponym, city_time) + end + end + + def process_toponym_cities(toponym, city_time) + return unless toponym.is_a?(Hash) + return unless toponym['cities'].is_a?(Array) + + toponym['cities'].each do |city| + next unless city.is_a?(Hash) + + stayed_for = city['stayed_for'].to_i + city_name = city['city'] + + city_time[city_name] += stayed_for if city_name.present? + end end def calculate_first_time_visits diff --git a/app/views/users/digests/public_year.html.erb b/app/views/users/digests/public_year.html.erb index ec07863b..4f56bbd9 100644 --- a/app/views/users/digests/public_year.html.erb +++ b/app/views/users/digests/public_year.html.erb @@ -79,7 +79,7 @@