Address number of photos related issues (#2152)

* Address number of photos related issues

* Fix minor stuff

* Update integrations page layout
This commit is contained in:
Evgenii Burmakin 2026-01-14 00:19:47 +01:00 committed by GitHub
parent 096a7a6ffa
commit 0edaa7e55b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
41 changed files with 1573 additions and 103 deletions

View file

@ -1 +1 @@
0.37.2
0.37.4

View file

@ -6,12 +6,25 @@ and this project adheres to [Semantic Versioning](http://semver.org/).
# [0.37.4] - Unreleased
## Added
- SSL certificate verification can now be disabled for Immich and Photoprism integrations to support self-signed certificates. A prominent security warning is displayed when this option is enabled. #1645
## Fixed
- Photo timestamps from Immich are now correctly parsed as UTC, fixing the double timezone offset bug where times were displayed incorrectly. #1752
- Trip photo grids now update immediately after photos are imported, instead of showing cached/stale results for up to 24 hours. #627 #988
- Immich API responses are now validated for content-type and JSON format before parsing, providing clear diagnostic error messages when the API returns unexpected responses. #698
- Response validator logs truncated response bodies (max 1000 chars) when JSON parsing fails, improving debugging capabilities.
- GeoJSON formatted points now have correct timestamp parsed from raw_data['properties']['date'] field.
- Reduce number of iterations during cache cleaning to improve performance.
# [0.37.3] - Unreleased
## Changed
- Map V2 is now the default map version for new users. Existing users will keep using Map V1 unless they change it in the settings.
- Email preferences moved to dedicated "Emails" tab in user settings for better organization.
# [0.37.3] - 2026-01-11
## Fixed

File diff suppressed because one or more lines are too long

View file

@ -5,11 +5,18 @@ class Api::V1::PhotosController < ApiController
before_action :check_source, only: %i[thumbnail]
def index
@photos = Rails.cache.fetch("photos_#{params[:start_date]}_#{params[:end_date]}", expires_in: 1.day) do
Photos::Search.new(current_api_user, start_date: params[:start_date], end_date: params[:end_date]).call
end
cache_key = "photos_#{current_api_user.id}_#{params[:start_date]}_#{params[:end_date]}"
cached_photos = Rails.cache.read(cache_key)
return render json: cached_photos, status: :ok unless cached_photos.nil?
search = Photos::Search.new(current_api_user, start_date: params[:start_date], end_date: params[:end_date])
@photos = search.call
Rails.cache.write(cache_key, @photos, expires_in: 30.minutes) if search.errors.blank?
render json: @photos, status: :ok
rescue StandardError => e
Rails.logger.error("Photo search failed: #{e.message}")
render json: { error: 'Failed to fetch photos' }, status: :bad_gateway
end
def thumbnail
@ -20,19 +27,30 @@ class Api::V1::PhotosController < ApiController
private
def fetch_cached_thumbnail(source)
Rails.cache.fetch("photo_thumbnail_#{params[:id]}", expires_in: 1.day) do
Photos::Thumbnail.new(current_api_user, source, params[:id]).call
end
cache_key = "photo_thumbnail_#{current_api_user.id}_#{source}_#{params[:id]}"
cached_response = Rails.cache.read(cache_key)
return cached_response if cached_response.present?
response = Photos::Thumbnail.new(current_api_user, source, params[:id]).call
Rails.cache.write(cache_key, response, expires_in: 30.minutes) if response.success?
response
end
def handle_thumbnail_response(response)
if response.success?
send_data(response.body, type: 'image/jpeg', disposition: 'inline', status: :ok)
else
render json: { error: 'Failed to fetch thumbnail' }, status: response.code
error_message = thumbnail_error(response)
render json: { error: error_message }, status: response.code
end
end
def thumbnail_error(response)
return Immich::ResponseAnalyzer.new(response).error_message if params[:source] == 'immich'
'Failed to fetch thumbnail'
end
def integration_configured?
current_api_user.immich_integration_configured? || current_api_user.photoprism_integration_configured?
end
@ -42,7 +60,7 @@ class Api::V1::PhotosController < ApiController
end
def check_source
unauthorized_integration unless params[:source] == 'immich' || params[:source] == 'photoprism'
unauthorized_integration unless %w[immich photoprism].include?(params[:source])
end
def unauthorized_integration

View file

@ -0,0 +1,21 @@
# frozen_string_literal: true
class Settings::EmailsController < ApplicationController
before_action :authenticate_user!
before_action :authenticate_active_user!, only: %i[update]
def index; end
def update
current_user.settings['digest_emails_enabled'] = email_settings_params[:digest_emails_enabled]
current_user.save!
redirect_to settings_emails_path, notice: 'Email settings updated'
end
private
def email_settings_params
params.require(:emails).permit(:digest_emails_enabled)
end
end

View file

@ -8,12 +8,46 @@ class SettingsController < ApplicationController
def update
existing_settings = current_user.safe_settings.settings
updated_settings = existing_settings.merge(settings_params)
current_user.update(settings: existing_settings.merge(settings_params))
immich_changed = integration_settings_changed?(existing_settings, updated_settings, %w[immich_url immich_api_key])
photoprism_changed = integration_settings_changed?(existing_settings, updated_settings,
%w[photoprism_url photoprism_api_key])
flash.now[:notice] = 'Settings updated'
unless current_user.update(settings: updated_settings)
return redirect_to settings_path, alert: 'Settings could not be updated'
end
redirect_to settings_path, notice: 'Settings updated'
notices = ['Settings updated']
alerts = []
if params[:refresh_photos_cache].present?
Photos::CacheCleaner.new(current_user).call
notices << 'Photo cache refreshed'
end
if immich_changed
result = Immich::ConnectionTester.new(
updated_settings['immich_url'],
updated_settings['immich_api_key'],
skip_ssl_verification: updated_settings['immich_skip_ssl_verification']
).call
result[:success] ? notices << result[:message] : alerts << result[:error]
end
if photoprism_changed
result = Photoprism::ConnectionTester.new(
updated_settings['photoprism_url'],
updated_settings['photoprism_api_key'],
skip_ssl_verification: updated_settings['photoprism_skip_ssl_verification']
).call
result[:success] ? notices << result[:message] : alerts << result[:error]
end
flash[:notice] = notices.join('. ')
flash[:alert] = alerts.join('. ') if alerts.any?
redirect_to settings_path
end
def theme
@ -30,12 +64,17 @@ class SettingsController < ApplicationController
private
def integration_settings_changed?(existing_settings, updated_settings, keys)
keys.any? { |key| existing_settings[key] != updated_settings[key] }
end
def settings_params
params.require(:settings).permit(
:meters_between_routes, :minutes_between_routes, :fog_of_war_meters,
:time_threshold_minutes, :merge_threshold_minutes, :route_opacity,
:immich_url, :immich_api_key, :photoprism_url, :photoprism_api_key,
:visits_suggestions_enabled, :digest_emails_enabled
:immich_url, :immich_api_key, :immich_skip_ssl_verification,
:photoprism_url, :photoprism_api_key, :photoprism_skip_ssl_verification,
:visits_suggestions_enabled
)
end
end

View file

@ -11,9 +11,7 @@ class TripsController < ApplicationController
end
def show
@photo_previews = Rails.cache.fetch("trip_photos_#{@trip.id}", expires_in: 1.day) do
@trip.photo_previews
end
@photo_previews = @trip.photo_previews
@photo_sources = @trip.photo_sources
return unless @trip.path.blank? || @trip.distance.blank? || @trip.visited_countries.blank?

View file

@ -144,9 +144,9 @@ module ApplicationHelper
end
def preferred_map_path
return map_v1_path unless user_signed_in?
return map_v2_path unless user_signed_in?
preferred_version = current_user.safe_settings.maps&.dig('preferred_version')
preferred_version == 'v2' ? map_v2_path : map_v1_path
preferred_version == 'v1' ? map_v1_path : map_v2_path
end
end

View file

@ -0,0 +1,17 @@
# frozen_string_literal: true
module SslConfigurable
extend ActiveSupport::Concern
private
def ssl_verification_enabled?(user, service_type)
setting_key = "#{service_type}_skip_ssl_verification"
# Return opposite of skip_ssl_verification (skip=true means verify=false)
!user.settings[setting_key]
end
def http_options_with_ssl(user, service_type, base_options = {})
base_options.merge(verify: ssl_verification_enabled?(user, service_type))
end
end

View file

@ -0,0 +1,89 @@
# frozen_string_literal: true
class Immich::ConnectionTester
attr_reader :url, :api_key, :skip_ssl_verification
def initialize(url, api_key, skip_ssl_verification: false)
@url = url
@api_key = api_key
@skip_ssl_verification = skip_ssl_verification
end
def call
return { success: false, error: 'Immich URL is missing' } if url.blank?
return { success: false, error: 'Immich API key is missing' } if api_key.blank?
test_connection
rescue HTTParty::Error, Net::OpenTimeout, Net::ReadTimeout, JSON::ParserError => e
{ success: false, error: "Immich connection failed: #{e.message}" }
end
private
def test_connection
response = search_metadata
return { success: false, error: "Immich connection failed: #{response.code}" } unless response.success?
asset_id = extract_asset_id(response.body)
return { success: true, message: 'Immich connection verified' } if asset_id.blank?
test_thumbnail_access(asset_id)
end
# rubocop:disable Metrics/MethodLength
def search_metadata
HTTParty.post(
"#{url}/api/search/metadata",
http_options_with_ssl({
headers: { 'x-api-key' => api_key, 'accept' => 'application/json' },
body: {
takenAfter: Time.current.beginning_of_day.iso8601,
size: 1,
page: 1,
order: 'asc',
withExif: true
},
timeout: 10
})
)
end
# rubocop:enable Metrics/MethodLength
def test_thumbnail_access(asset_id)
response = HTTParty.get(
"#{url}/api/assets/#{asset_id}/thumbnail?size=preview",
http_options_with_ssl({
headers: { 'x-api-key' => api_key, 'accept' => 'application/octet-stream' },
timeout: 10
})
)
return { success: true, message: 'Immich connection verified' } if response.success?
if missing_asset_view_permission?(response)
return { success: false, error: 'Immich API key missing permission: asset.view' }
end
{ success: false, error: "Immich thumbnail check failed: #{response.code}" }
end
def extract_asset_id(body)
result = Immich::ResponseValidator.validate_and_parse_body(body)
return nil unless result[:success]
result[:data].dig('assets', 'items', 0, 'id')
end
def missing_asset_view_permission?(response)
return false unless response.code.to_i == 403
result = Immich::ResponseValidator.validate_and_parse_body(response.body)
return false unless result[:success]
result[:data]['message']&.include?('asset.view') || false
end
def http_options_with_ssl(base_options = {})
base_options.merge(verify: !skip_ssl_verification)
end
end

View file

@ -12,7 +12,7 @@ class Immich::ImportGeodata
def call
immich_data = retrieve_immich_data
log_no_data and return if immich_data.empty?
return log_no_data if immich_data.blank?
immich_data_json = parse_immich_data(immich_data)
file_name = file_name(immich_data_json)
@ -56,7 +56,7 @@ class Immich::ImportGeodata
latitude: asset['exifInfo']['latitude'],
longitude: asset['exifInfo']['longitude'],
lonlat: "SRID=4326;POINT(#{asset['exifInfo']['longitude']} #{asset['exifInfo']['latitude']})",
timestamp: Time.zone.parse(asset['exifInfo']['dateTimeOriginal']).utc.to_i
timestamp: Time.iso8601(asset['exifInfo']['dateTimeOriginal']).utc.to_i
}
end

View file

@ -1,6 +1,8 @@
# frozen_string_literal: true
class Immich::RequestPhotos
include SslConfigurable
attr_reader :user, :immich_api_base_url, :immich_api_key, :start_date, :end_date
def initialize(user, start_date: '1970-01-01', end_date: nil)
@ -16,6 +18,7 @@ class Immich::RequestPhotos
raise ArgumentError, 'Immich URL is missing' if user.safe_settings.immich_url.blank?
data = retrieve_immich_data
return nil if data.nil?
time_framed_data(data)
end
@ -29,17 +32,26 @@ class Immich::RequestPhotos
# TODO: Handle pagination using nextPage
while page <= max_pages
response = JSON.parse(
HTTParty.post(
immich_api_base_url,
headers: headers,
body: request_body(page),
timeout: 10
).body
response = HTTParty.post(
immich_api_base_url,
http_options_with_ssl(
@user, :immich, {
headers: headers,
body: request_body(page),
timeout: 10
}
)
)
result = Immich::ResponseValidator.validate_and_parse(response)
unless result[:success]
Rails.logger.error("Immich photo fetch failed: #{result[:error]}")
return nil
end
Rails.logger.debug('==== IMMICH RESPONSE ====')
Rails.logger.debug(response)
items = response.dig('assets', 'items')
Rails.logger.debug(result[:data])
items = result[:data].dig('assets', 'items')
break if items.blank?
@ -51,7 +63,7 @@ class Immich::RequestPhotos
data.flatten
rescue HTTParty::Error, Net::OpenTimeout, Net::ReadTimeout => e
Rails.logger.error("Immich photo fetch failed: #{e.message}")
[]
nil
end
def headers
@ -63,7 +75,7 @@ class Immich::RequestPhotos
def request_body(page)
body = {
takenAfter: start_date,
takenAfter: normalize_date(start_date),
size: 1000,
page: page,
order: 'asc',
@ -72,13 +84,32 @@ class Immich::RequestPhotos
return body unless end_date
body.merge(takenBefore: end_date)
body.merge(takenBefore: normalize_date(end_date))
end
def time_framed_data(data)
start_time = parse_time(start_date)
end_time = parse_time(end_date)
return data unless start_time
data.select do |photo|
photo['localDateTime'] >= start_date &&
(end_date.nil? || photo['localDateTime'] <= end_date)
photo_time = parse_time(photo['localDateTime'])
next false unless photo_time
photo_time >= start_time && (end_time.nil? || photo_time <= end_time)
end
end
def normalize_date(value)
parsed = parse_time(value)
parsed ? parsed.iso8601 : value
end
def parse_time(value)
return if value.blank?
Time.iso8601(value.to_s)
rescue ArgumentError, TypeError
nil
end
end

View file

@ -0,0 +1,24 @@
# frozen_string_literal: true
class Immich::ResponseAnalyzer
attr_reader :response
def initialize(response)
@response = response
end
def permission_error?
return false unless response.code.to_i == 403
result = Immich::ResponseValidator.validate_and_parse_body(response.body)
return false unless result[:success]
result[:data]['message']&.include?('asset.view') || false
end
def error_message
return 'Immich API key missing permission: asset.view' if permission_error?
'Failed to fetch thumbnail'
end
end

View file

@ -0,0 +1,44 @@
# frozen_string_literal: true
module Immich
class ResponseValidator
def self.validate_and_parse(response, logger: Rails.logger)
return { success: false, error: "Request failed: #{response.code}" } unless response.success?
unless json_content_type?(response)
content_type = response.headers['content-type'] || response.headers['Content-Type'] || 'unknown'
logger.error("Immich returned non-JSON response: #{response.code} #{truncate_body(response.body)}")
return { success: false, error: "Expected JSON, got #{content_type}" }
end
parsed = JSON.parse(response.body)
{ success: true, data: parsed }
rescue JSON::ParserError => e
logger.error("Immich JSON parse error: #{e.message}")
logger.error("Response body: #{truncate_body(response.body)}")
{ success: false, error: "Invalid JSON response" }
end
def self.validate_and_parse_body(body_string, logger: Rails.logger)
return { success: false, error: "Invalid JSON" } if body_string.nil?
parsed = JSON.parse(body_string)
{ success: true, data: parsed }
rescue JSON::ParserError, TypeError => e
logger.error("JSON parse error: #{e.message}")
logger.error("Body: #{truncate_body(body_string)}")
{ success: false, error: "Invalid JSON" }
end
private_class_method def self.json_content_type?(response)
content_type = response.headers['content-type'] || response.headers['Content-Type'] || ''
content_type.include?('application/json')
end
private_class_method def self.truncate_body(body, max_length: 1000)
return '' if body.nil?
body.length > max_length ? "#{body[0...max_length]}... (truncated)" : body
end
end
end

View file

@ -0,0 +1,41 @@
# frozen_string_literal: true
class Photoprism::ConnectionTester
attr_reader :url, :api_key, :skip_ssl_verification
def initialize(url, api_key, skip_ssl_verification: false)
@url = url
@api_key = api_key
@skip_ssl_verification = skip_ssl_verification
end
def call
return { success: false, error: 'Photoprism URL is missing' } if url.blank?
return { success: false, error: 'Photoprism API key is missing' } if api_key.blank?
test_connection
rescue HTTParty::Error, Net::OpenTimeout, Net::ReadTimeout => e
{ success: false, error: "Photoprism connection failed: #{e.message}" }
end
private
def test_connection
response = HTTParty.get(
"#{url}/api/v1/photos",
http_options_with_ssl({
headers: { 'Authorization' => "Bearer #{api_key}", 'accept' => 'application/json' },
query: { count: 1, public: true },
timeout: 10
})
)
return { success: true, message: 'Photoprism connection verified' } if response.success?
{ success: false, error: "Photoprism connection failed: #{response.code}" }
end
def http_options_with_ssl(base_options = {})
base_options.merge(verify: !skip_ssl_verification)
end
end

View file

@ -5,6 +5,8 @@
# release of Photoprism.
class Photoprism::RequestPhotos
include SslConfigurable
attr_reader :user, :photoprism_api_base_url, :photoprism_api_key, :start_date, :end_date
def initialize(user, start_date: '1970-01-01', end_date: nil)
@ -51,9 +53,13 @@ class Photoprism::RequestPhotos
def fetch_page(offset)
response = HTTParty.get(
photoprism_api_base_url,
headers: headers,
query: request_params(offset),
timeout: 10
http_options_with_ssl(
@user, :photoprism, {
headers: headers,
query: request_params(offset),
timeout: 10
}
)
)
if response.code != 200
@ -93,6 +99,7 @@ class Photoprism::RequestPhotos
data.flatten.select do |photo|
taken_at = DateTime.parse(photo['TakenAtLocal'])
end_date ||= Time.current
taken_at.between?(start_date.to_datetime, end_date.to_datetime)
end
end

View file

@ -0,0 +1,16 @@
# frozen_string_literal: true
class Photos::CacheCleaner
attr_reader :user
def initialize(user)
@user = user
end
def call
return unless Rails.cache.respond_to?(:delete_matched)
Rails.cache.delete_matched("photos_#{user.id}_*")
Rails.cache.delete_matched("photo_thumbnail_#{user.id}_*")
end
end

View file

@ -1,19 +1,23 @@
# frozen_string_literal: true
class Photos::Search
attr_reader :user, :start_date, :end_date
attr_reader :user, :start_date, :end_date, :errors
def initialize(user, start_date: '1970-01-01', end_date: nil)
@user = user
@start_date = start_date
@end_date = end_date
@errors = []
end
def call
photos = []
photos << request_immich if user.immich_integration_configured?
photos << request_photoprism if user.photoprism_integration_configured?
immich_photos = request_immich if user.immich_integration_configured?
photoprism_photos = request_photoprism if user.photoprism_integration_configured?
photos << immich_photos if immich_photos.present?
photos << photoprism_photos if photoprism_photos.present?
photos.flatten.map { |photo| Api::PhotoSerializer.new(photo, photo[:source]).call }
end
@ -21,11 +25,17 @@ class Photos::Search
private
def request_immich
Immich::RequestPhotos.new(
assets = Immich::RequestPhotos.new(
user,
start_date: start_date,
end_date: end_date
).call.map { |asset| transform_asset(asset, 'immich') }.compact
).call
if assets.nil?
errors << :immich
return nil
end
assets.map { |asset| transform_asset(asset, 'immich') }.compact
end
def request_photoprism

View file

@ -1,6 +1,8 @@
# frozen_string_literal: true
class Photos::Thumbnail
include SslConfigurable
SUPPORTED_SOURCES = %w[immich photoprism].freeze
def initialize(user, source, id)
@ -13,7 +15,12 @@ class Photos::Thumbnail
raise ArgumentError, 'Photo source cannot be nil' if source.nil?
unsupported_source_error unless SUPPORTED_SOURCES.include?(source)
HTTParty.get(request_url, headers: headers)
HTTParty.get(
request_url,
http_options_with_ssl(@user, source_type, {
headers: headers
})
)
end
private
@ -55,4 +62,8 @@ class Photos::Thumbnail
def unsupported_source_error
raise ArgumentError, "Unsupported source: #{source}"
end
def source_type
source == 'immich' ? :immich : :photoprism
end
end

View file

@ -16,8 +16,10 @@ class Users::SafeSettings
'route_opacity' => 60,
'immich_url' => nil,
'immich_api_key' => nil,
'immich_skip_ssl_verification' => false,
'photoprism_url' => nil,
'photoprism_api_key' => nil,
'photoprism_skip_ssl_verification' => false,
'maps' => { 'distance_unit' => 'km' },
'visits_suggestions_enabled' => 'true',
'enabled_map_layers' => %w[Routes Heatmap],
@ -115,6 +117,14 @@ class Users::SafeSettings
settings['photoprism_api_key']
end
def immich_skip_ssl_verification
settings['immich_skip_ssl_verification']
end
def photoprism_skip_ssl_verification
settings['photoprism_skip_ssl_verification']
end
def maps
settings['maps']
end

View file

@ -1,6 +1,7 @@
<div class="tabs tabs-boxed mb-6">
<%= link_to 'Integrations', settings_path, role: 'tab', class: "tab tab-lg #{active_tab?(settings_path)}" %>
<%= link_to 'Map', settings_maps_path, role: 'tab', class: "tab tab-lg #{active_tab?(settings_maps_path)}" %>
<%= link_to 'Emails', settings_emails_path, role: 'tab', class: "tab tab-lg #{active_tab?(settings_emails_path)}" %>
<% if DawarichSettings.self_hosted? && current_user.admin? %>
<%= link_to 'Users', settings_users_path, role: 'tab', class: "tab tab-lg #{active_tab?(settings_users_path)}" %>
<%= link_to 'Background Jobs', settings_background_jobs_path, role: 'tab', class: "tab tab-lg #{active_tab?(settings_background_jobs_path)}" %>

View file

@ -0,0 +1,38 @@
<% content_for :title, 'Email Settings' %>
<div class="min-h-content w-full my-5">
<h1 class="text-3xl font-bold mb-6">Email Settings</h1>
<%= render 'settings/navigation' %>
<div class="card bg-base-200 shadow-xl">
<%= form_for :emails, url: settings_emails_path, method: :patch, data: { turbo_method: :patch, turbo: false } do |f| %>
<div class="card-body">
<div class="space-y-8 animate-fade-in">
<div>
<h2 class="text-2xl font-bold mb-4 flex items-center">
<%= icon 'mail', class: "text-primary mr-2" %> Email Preferences
</h2>
<div class="bg-base-100 p-5 rounded-lg shadow-sm space-y-4">
<div class="form-control">
<label class="label cursor-pointer justify-start gap-4">
<%= f.check_box :digest_emails_enabled,
checked: current_user.safe_settings.digest_emails_enabled?,
class: "toggle toggle-primary" %>
<div>
<span class="label-text font-medium">Year-End Digest Emails</span>
<p class="text-sm text-base-content/70 mt-1">
Receive an annual summary email on January 1st with your year in review
</p>
</div>
</label>
</div>
</div>
</div>
</div>
<div class="card-actions justify-end mt-6">
<%= f.submit 'Save changes', class: "btn btn-primary" %>
</div>
</div>
<% end %>
</div>
</div>

View file

@ -7,13 +7,10 @@
<div class="card bg-base-200 shadow-xl">
<%= form_for :settings, url: settings_path, method: :patch, data: { turbo_method: :patch, turbo: false } do |f| %>
<div class="card-body">
<div class="space-y-8 animate-fade-in">
<div class="space-y-8 lg:grid lg:grid-cols-2 lg:gap-6 lg:space-y-0 animate-fade-in">
<div>
<h2 class="text-2xl font-bold mb-4 flex items-center">
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-camera mr-2 text-primary">
<path d="M14.5 4h-5L7 7H4a2 2 0 0 0-2 2v9a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2V9a2 2 0 0 0-2-2h-3l-2.5-3z"></path>
<circle cx="12" cy="13" r="3"></circle>
</svg>Immich Integration
<%= icon 'camera', class: 'mr-2 text-primary' %> Immich Integration
</h2>
<div class="bg-base-100 p-5 rounded-lg shadow-sm space-y-4">
<div class="form-control w-full">
@ -30,19 +27,36 @@
<div class="relative">
<%= f.password_field :immich_api_key, value: current_user.safe_settings.immich_api_key, class: "input input-bordered w-full pr-10", placeholder: 'xxxxxxxxxxxxxx' %>
</div>
<span class="label-text-alt mt-1">Found in your Immich admin panel under API settings</span>
<span class="label-text-alt mt-1">Found in your Immich admin panel under API settings. Required permissions: <code class="text-xs">asset.read</code> and <code class="text-xs">asset.view</code>.</span>
</div>
<div class="form-control">
<label class="label cursor-pointer justify-start gap-3">
<%= f.check_box :immich_skip_ssl_verification,
checked: current_user.safe_settings.immich_skip_ssl_verification,
class: "toggle toggle-warning",
onchange: "document.getElementById('immich-ssl-warning').classList.toggle('hidden', !this.checked)" %>
<span class="label-text">Skip SSL certificate verification (self-signed certificates)</span>
</label>
<div id="immich-ssl-warning" class="alert alert-warning mt-2 <%= 'hidden' unless current_user.safe_settings.immich_skip_ssl_verification %>">
<%= icon 'triangle-alert'%>
<span>
<strong>Security Warning:</strong> Disabling SSL verification makes your connection vulnerable to man-in-the-middle attacks.
Only enable this for self-signed certificates you trust on your local network.
</span>
</div>
</div>
<div class="flex flex-wrap items-center gap-2">
<%= button_tag 'Refresh photo cache',
name: 'refresh_photos_cache',
value: '1',
class: 'btn btn-sm btn-outline' %>
<span class="label-text-alt">Clears cached photo metadata and thumbnails for all integrations.</span>
</div>
<%# <div class="flex justify-end">
<button class="btn btn-sm btn-outline">Test Connection</button>
</div> %>
</div>
</div>
<div>
<h2 class="text-2xl font-bold mb-4 flex items-center">
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-camera mr-2 text-primary">
<path d="M14.5 4h-5L7 7H4a2 2 0 0 0-2 2v9a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2V9a2 2 0 0 0-2-2h-3l-2.5-3z"></path>
<circle cx="12" cy="13" r="3"></circle>
</svg>Photoprism Integration
<%= icon 'camera', class: 'mr-2 text-primary' %> Photoprism Integration
</h2>
<div class="bg-base-100 p-5 rounded-lg shadow-sm space-y-4">
<div class="form-control w-full">
@ -63,35 +77,30 @@
</div>
<span class="label-text-alt mt-1">Found in your Photoprism settings under Library</span>
</div>
<div class="form-control">
<label class="label cursor-pointer justify-start gap-3">
<%= f.check_box :photoprism_skip_ssl_verification,
checked: current_user.safe_settings.photoprism_skip_ssl_verification,
class: "toggle toggle-warning",
onchange: "document.getElementById('photoprism-ssl-warning').classList.toggle('hidden', !this.checked)" %>
<span class="label-text">Skip SSL certificate verification (self-signed certificates)</span>
</label>
<div id="photoprism-ssl-warning" class="alert alert-warning mt-2 <%= 'hidden' unless current_user.safe_settings.photoprism_skip_ssl_verification %>">
<%= icon 'triangle-alert'%>
<span>
<strong>Security Warning:</strong> Disabling SSL verification makes your connection vulnerable to man-in-the-middle attacks.
Only enable this for self-signed certificates you trust on your local network.
</span>
</div>
</div>
<%# <div class="flex justify-end">
<button class="btn btn-sm btn-outline">Test Connection</button>
</div> %>
</div>
</div>
<div>
<h2 class="text-2xl font-bold mb-4 flex items-center">
<%= icon 'mail', class: "text-primary mr-2" %> Email Preferences
</h2>
<div class="bg-base-100 p-5 rounded-lg shadow-sm space-y-4">
<div class="form-control">
<label class="label cursor-pointer justify-start gap-4">
<%= f.check_box :digest_emails_enabled,
checked: current_user.safe_settings.digest_emails_enabled?,
class: "toggle toggle-primary" %>
<div>
<span class="label-text font-medium">Year-End Digest Emails</span>
<p class="text-sm text-base-content/70 mt-1">
Receive an annual summary email on January 1st with your year in review
</p>
</div>
</label>
</div>
</div>
</div>
<% unless DawarichSettings.self_hosted? || current_user.provider.blank? %>
<div>
<div class="lg:col-span-2">
<h2 class="text-2xl font-bold mb-4 flex items-center">
<%= icon 'link', class: "text-primary mr-1" %> Connected Accounts
</h2>
@ -105,7 +114,7 @@
<% end %>
</div>
<div class="card-actions justify-end mt-6">
<%= f.submit "Save changes", class: "btn btn-primary" %>
<%= f.submit "Save & Test Connection", class: "btn btn-primary" %>
</div>
</div>
<% end %>

View file

@ -90,12 +90,12 @@
</svg>Preferred Map Version </span>
<div class="flex items-center space-x-2">
<%= f.label :preferred_version_v1, 'V1 (Leaflet)', class: 'cursor-pointer' %>
<%= f.radio_button :preferred_version, 'v1', id: 'maps_preferred_version_v1', class: 'radio radio-primary ml-1 mr-4', checked: @maps['preferred_version'] != 'v2' %>
<%= f.radio_button :preferred_version, 'v1', id: 'maps_preferred_version_v1', class: 'radio radio-primary ml-1 mr-4', checked: @maps['preferred_version'] == 'v1' %>
<%= f.label :preferred_version_v2, 'V2 (MapLibre)', class: 'cursor-pointer' %>
<%= f.radio_button :preferred_version, 'v2', id: 'maps_preferred_version_v2', class: 'radio radio-primary ml-1', checked: @maps['preferred_version'] == 'v2' %>
<%= f.radio_button :preferred_version, 'v2', id: 'maps_preferred_version_v2', class: 'radio radio-primary ml-1', checked: @maps['preferred_version'] != 'v1' %>
</div>
</label>
<span class="label-text-alt mt-1">Choose which map version to use by default. V1 uses Leaflet, V2 uses MapLibre with enhanced features.</span>
<span class="label-text-alt mt-1">Choose which map version to use by default. V2 (MapLibre with enhanced features) is the default for new users, V1 (Leaflet) is available for compatibility.</span>
</div>
</div>
<div class="bg-base-100 p-5 rounded-lg shadow-sm">

View file

@ -22,7 +22,7 @@
<div class="w-full lg:w-1/12">
<div class="flex flex-col space-y-2">
<span class="tooltip" data-tip="<%= human_date(start_at - 1.day) %>">
<%= link_to map_path(start_at: start_at - 1.day, end_at: end_at - 1.day, import_id: params[:import_id]), class: "btn btn-sm border border-base-300 hover:btn-ghost w-full" do %>
<%= link_to map_path(start_at: (start_at - 1.day).iso8601, end_at: (end_at - 1.day).iso8601, import_id: params[:import_id]), class: "btn btn-sm border border-base-300 hover:btn-ghost w-full" do %>
<%= icon 'chevron-left' %>
<% end %>
</span>
@ -37,7 +37,7 @@
<div class="w-full lg:w-1/12">
<div class="flex flex-col space-y-2">
<span class="tooltip" data-tip="<%= human_date(start_at + 1.day) %>">
<%= link_to map_path(start_at: start_at + 1.day, end_at: end_at + 1.day, import_id: params[:import_id]), class: "btn btn-sm border border-base-300 hover:btn-ghost w-full" do %>
<%= link_to map_path(start_at: (start_at + 1.day).iso8601, end_at: (end_at + 1.day).iso8601, import_id: params[:import_id]), class: "btn btn-sm border border-base-300 hover:btn-ghost w-full" do %>
<%= icon 'chevron-right' %>
<% end %>
</span>
@ -51,18 +51,18 @@
<div class="w-full lg:w-1/12">
<div class="flex flex-col space-y-2 text-center">
<%= link_to "Today",
map_path(start_at: Time.current.beginning_of_day, end_at: Time.current.end_of_day, import_id: params[:import_id]),
map_path(start_at: Time.current.beginning_of_day.iso8601, end_at: Time.current.end_of_day.iso8601, import_id: params[:import_id]),
class: "btn btn-sm border border-base-300 hover:btn-ghost w-full" %>
</div>
</div>
<div class="w-full lg:w-2/12">
<div class="flex flex-col space-y-2 text-center">
<%= link_to "Last 7 days", map_path(start_at: 1.week.ago.beginning_of_day, end_at: Time.current.end_of_day, import_id: params[:import_id]), class: "btn btn-sm border border-base-300 hover:btn-ghost w-full" %>
<%= link_to "Last 7 days", map_path(start_at: 1.week.ago.beginning_of_day.iso8601, end_at: Time.current.end_of_day.iso8601, import_id: params[:import_id]), class: "btn btn-sm border border-base-300 hover:btn-ghost w-full" %>
</div>
</div>
<div class="w-full lg:w-2/12">
<div class="flex flex-col space-y-2 text-center">
<%= link_to "Last month", map_path(start_at: 1.month.ago.beginning_of_day, end_at: Time.current.end_of_day, import_id: params[:import_id]), class: "btn btn-sm border border-base-300 hover:btn-ghost w-full" %>
<%= link_to "Last month", map_path(start_at: 1.month.ago.beginning_of_day.iso8601, end_at: Time.current.end_of_day.iso8601, import_id: params[:import_id]), class: "btn btn-sm border border-base-300 hover:btn-ghost w-full" %>
</div>
</div>
</div>

View file

@ -22,7 +22,7 @@
<div class="w-full lg:w-1/12">
<div class="flex flex-col space-y-2">
<span class="tooltip" data-tip="<%= human_date(start_at - 1.day) %>">
<%= link_to map_v2_path(start_at: start_at - 1.day, end_at: end_at - 1.day, import_id: params[:import_id]), class: "btn btn-sm border border-base-300 hover:btn-ghost w-full" do %>
<%= link_to map_v2_path(start_at: (start_at - 1.day).iso8601, end_at: (end_at - 1.day).iso8601, import_id: params[:import_id]), class: "btn btn-sm border border-base-300 hover:btn-ghost w-full" do %>
<%= icon 'chevron-left' %>
<% end %>
</span>
@ -37,7 +37,7 @@
<div class="w-full lg:w-1/12">
<div class="flex flex-col space-y-2">
<span class="tooltip" data-tip="<%= human_date(start_at + 1.day) %>">
<%= link_to map_v2_path(start_at: start_at + 1.day, end_at: end_at + 1.day, import_id: params[:import_id]), class: "btn btn-sm border border-base-300 hover:btn-ghost w-full" do %>
<%= link_to map_v2_path(start_at: (start_at + 1.day).iso8601, end_at: (end_at + 1.day).iso8601, import_id: params[:import_id]), class: "btn btn-sm border border-base-300 hover:btn-ghost w-full" do %>
<%= icon 'chevron-right' %>
<% end %>
</span>
@ -51,18 +51,18 @@
<div class="w-full lg:w-1/12">
<div class="flex flex-col space-y-2 text-center">
<%= link_to "Today",
map_v2_path(start_at: Time.current.beginning_of_day, end_at: Time.current.end_of_day, import_id: params[:import_id]),
map_v2_path(start_at: Time.current.beginning_of_day.iso8601, end_at: Time.current.end_of_day.iso8601, import_id: params[:import_id]),
class: "btn btn-sm border border-base-300 hover:btn-ghost w-full" %>
</div>
</div>
<div class="w-full lg:w-2/12">
<div class="flex flex-col space-y-2 text-center">
<%= link_to "Last 7 days", map_v2_path(start_at: 1.week.ago.beginning_of_day, end_at: Time.current.end_of_day, import_id: params[:import_id]), class: "btn btn-sm border border-base-300 hover:btn-ghost w-full" %>
<%= link_to "Last 7 days", map_v2_path(start_at: 1.week.ago.beginning_of_day.iso8601, end_at: Time.current.end_of_day.iso8601, import_id: params[:import_id]), class: "btn btn-sm border border-base-300 hover:btn-ghost w-full" %>
</div>
</div>
<div class="w-full lg:w-2/12">
<div class="flex flex-col space-y-2 text-center">
<%= link_to "Last month", map_v2_path(start_at: 1.month.ago.beginning_of_day, end_at: Time.current.end_of_day, import_id: params[:import_id]), class: "btn btn-sm border border-base-300 hover:btn-ghost w-full" %>
<%= link_to "Last month", map_v2_path(start_at: 1.month.ago.beginning_of_day.iso8601, end_at: Time.current.end_of_day.iso8601, import_id: params[:import_id]), class: "btn btn-sm border border-base-300 hover:btn-ghost w-full" %>
</div>
</div>
</div>

View file

@ -46,6 +46,9 @@ Rails.application.routes.draw do
resources :maps, only: %i[index]
patch 'maps', to: 'maps#update'
resources :emails, only: %i[index]
patch 'emails', to: 'emails#update'
end
patch 'settings', to: 'settings#update'

View file

@ -0,0 +1,21 @@
# frozen_string_literal: true
class SetExistingUsersToMapV1 < ActiveRecord::Migration[8.0]
def up
User.find_each do |user|
next if user.settings.dig('maps', 'preferred_version') == 'v2'
user.settings['maps'] ||= {}
user.settings['maps']['preferred_version'] = 'v1'
user.save(validate: false)
end
end
def down
User.find_each do |user|
user.settings['maps']&.delete('preferred_version')
user.save(validate: false)
end
end
end

View file

@ -76,4 +76,69 @@ RSpec.describe ApplicationHelper, type: :helper do
end
end
end
describe '#preferred_map_path' do
context 'when user is not signed in' do
before do
allow(helper).to receive(:user_signed_in?).and_return(false)
end
it 'returns map_v2_path by default' do
expect(helper.preferred_map_path).to eq(helper.map_v2_path)
end
end
context 'when user is signed in' do
let(:user) { create(:user) }
before do
allow(helper).to receive(:user_signed_in?).and_return(true)
allow(helper).to receive(:current_user).and_return(user)
end
context 'when user has no preferred_version set' do
before do
user.settings['maps'] = { 'distance_unit' => 'km' }
user.save
end
it 'returns map_v2_path as the default' do
expect(helper.preferred_map_path).to eq(helper.map_v2_path)
end
end
context 'when user has preferred_version set to v1' do
before do
user.settings['maps'] = { 'preferred_version' => 'v1', 'distance_unit' => 'km' }
user.save
end
it 'returns map_v1_path' do
expect(helper.preferred_map_path).to eq(helper.map_v1_path)
end
end
context 'when user has preferred_version set to v2' do
before do
user.settings['maps'] = { 'preferred_version' => 'v2', 'distance_unit' => 'km' }
user.save
end
it 'returns map_v2_path' do
expect(helper.preferred_map_path).to eq(helper.map_v2_path)
end
end
context 'when user has no maps settings at all' do
before do
user.settings.delete('maps')
user.save
end
it 'returns map_v2_path as the default' do
expect(helper.preferred_map_path).to eq(helper.map_v2_path)
end
end
end
end
end

View file

@ -37,10 +37,13 @@ RSpec.describe 'Api::V1::Photos', type: :request do
end
context 'when the request is successful' do
let(:start_date) { '2024-01-01' }
let(:end_date) { '2024-01-02' }
before do
allow_any_instance_of(Photos::Search).to receive(:call).and_return(photo_data)
get '/api/v1/photos', params: { api_key: user.api_key }
get '/api/v1/photos', params: { api_key: user.api_key, start_date: start_date, end_date: end_date }
end
it 'returns http success' do
@ -51,6 +54,23 @@ RSpec.describe 'Api::V1::Photos', type: :request do
expect(JSON.parse(response.body)).to eq(photo_data)
end
end
context 'when cache is empty' do
let(:start_date) { '2024-01-01' }
let(:end_date) { '2024-01-02' }
before do
allow_any_instance_of(Photos::Search).to receive(:call).and_return(photo_data)
allow(Rails.cache).to receive(:read).and_return(nil)
end
it 'writes cached photos with 30 minute ttl' do
cache_key = "photos_#{user.id}_#{start_date}_#{end_date}"
expect(Rails.cache).to receive(:write).with(cache_key, photo_data, expires_in: 30.minutes)
get '/api/v1/photos', params: { api_key: user.api_key, start_date: start_date, end_date: end_date }
end
end
end
context 'when the integration is not configured' do

View file

@ -85,6 +85,68 @@ RSpec.describe 'Settings', type: :request do
expect(user.settings['minutes_between_routes']).to eq('10')
end
it 'refreshes cached photos when requested' do
Rails.cache.write("photos_#{user.id}_test", ['cached'])
Rails.cache.write("photo_thumbnail_#{user.id}_immich_test", 'thumb')
patch '/settings', params: params.merge(refresh_photos_cache: '1')
expect(Rails.cache.read("photos_#{user.id}_test")).to be_nil
expect(Rails.cache.read("photo_thumbnail_#{user.id}_immich_test")).to be_nil
end
context 'when immich settings change' do
let(:immich_url) { 'https://immich.test' }
let(:immich_api_key) { 'immich-key' }
let(:immich_response) do
{ 'assets' => { 'items' => [{ 'id' => 'asset-id' }] } }.to_json
end
before do
stub_request(:post, "#{immich_url}/api/search/metadata")
.to_return(status: 200, body: immich_response, headers: {})
stub_request(:get, "#{immich_url}/api/assets/asset-id/thumbnail?size=preview")
.to_return(status: 403, body: { message: 'Missing required permission: asset.view' }.to_json)
end
it 'reports missing asset.view permission' do
patch '/settings', params: {
settings: {
'immich_url' => immich_url,
'immich_api_key' => immich_api_key
}
}
expect(response).to redirect_to(settings_path)
follow_redirect!
expect(flash[:alert]).to include('asset.view')
end
end
context 'when photoprism settings change' do
let(:photoprism_url) { 'https://photoprism.test' }
let(:photoprism_api_key) { 'photoprism-key' }
before do
stub_request(:get, "#{photoprism_url}/api/v1/photos")
.with(query: hash_including({ 'count' => '1', 'public' => 'true' }))
.to_return(status: 200, body: [].to_json)
end
it 'verifies photoprism connection' do
patch '/settings', params: {
settings: {
'photoprism_url' => photoprism_url,
'photoprism_api_key' => photoprism_api_key
}
}
expect(response).to redirect_to(settings_path)
follow_redirect!
expect(flash[:notice]).to include('Photoprism connection verified')
end
end
context 'when user is inactive' do
before do
user.update(status: :inactive, active_until: 1.day.ago)

View file

@ -0,0 +1,44 @@
# frozen_string_literal: true
require 'rails_helper'
RSpec.describe SslConfigurable do
let(:test_class) do
Class.new do
include SslConfigurable
end
end
let(:instance) { test_class.new }
let(:user) { create(:user) }
describe '#ssl_verification_enabled?' do
it 'returns true when skip_ssl_verification is false' do
user.settings['immich_skip_ssl_verification'] = false
expect(instance.send(:ssl_verification_enabled?, user, :immich)).to be true
end
it 'returns false when skip_ssl_verification is true' do
user.settings['immich_skip_ssl_verification'] = true
expect(instance.send(:ssl_verification_enabled?, user, :immich)).to be false
end
it 'works with photoprism service type' do
user.settings['photoprism_skip_ssl_verification'] = true
expect(instance.send(:ssl_verification_enabled?, user, :photoprism)).to be false
end
end
describe '#http_options_with_ssl' do
it 'merges verify option with base options when verification is disabled' do
user.settings['immich_skip_ssl_verification'] = true
result = instance.send(:http_options_with_ssl, user, :immich, { timeout: 10 })
expect(result).to eq({ timeout: 10, verify: false })
end
it 'merges verify option with base options when verification is enabled' do
user.settings['immich_skip_ssl_verification'] = false
result = instance.send(:http_options_with_ssl, user, :immich, { timeout: 10 })
expect(result).to eq({ timeout: 10, verify: true })
end
end
end

View file

@ -0,0 +1,230 @@
# frozen_string_literal: true
require 'rails_helper'
RSpec.describe Immich::ConnectionTester do
subject(:service) { described_class.new(url, api_key) }
let(:url) { 'https://immich.example.com' }
let(:api_key) { 'test_api_key_123' }
describe '#call' do
context 'with missing URL' do
let(:url) { nil }
it 'returns error for missing URL' do
result = service.call
expect(result[:success]).to be false
expect(result[:error]).to eq('Immich URL is missing')
end
end
context 'with blank URL' do
let(:url) { '' }
it 'returns error for blank URL' do
result = service.call
expect(result[:success]).to be false
expect(result[:error]).to eq('Immich URL is missing')
end
end
context 'with missing API key' do
let(:api_key) { nil }
it 'returns error for missing API key' do
result = service.call
expect(result[:success]).to be false
expect(result[:error]).to eq('Immich API key is missing')
end
end
context 'with blank API key' do
let(:api_key) { '' }
it 'returns error for blank API key' do
result = service.call
expect(result[:success]).to be false
expect(result[:error]).to eq('Immich API key is missing')
end
end
context 'with successful connection' do
let(:metadata_response) do
instance_double(HTTParty::Response, success?: true, code: 200, body: metadata_body)
end
let(:metadata_body) do
{ 'assets' => { 'items' => [{ 'id' => 'asset-123' }] } }.to_json
end
let(:thumbnail_response) do
instance_double(HTTParty::Response, success?: true, code: 200)
end
before do
allow(HTTParty).to receive(:post).and_return(metadata_response)
allow(HTTParty).to receive(:get).and_return(thumbnail_response)
end
it 'returns success when both metadata and thumbnail requests succeed' do
result = service.call
expect(result[:success]).to be true
expect(result[:message]).to eq('Immich connection verified')
end
it 'makes POST request to metadata endpoint with correct parameters' do
expect(HTTParty).to receive(:post).with(
"#{url}/api/search/metadata",
hash_including(
headers: { 'x-api-key' => api_key, 'accept' => 'application/json' },
timeout: 10
)
)
service.call
end
it 'makes GET request to thumbnail endpoint with asset ID' do
expect(HTTParty).to receive(:get).with(
"#{url}/api/assets/asset-123/thumbnail?size=preview",
hash_including(
headers: { 'x-api-key' => api_key, 'accept' => 'application/octet-stream' },
timeout: 10
)
)
service.call
end
end
context 'when metadata request returns no assets' do
let(:metadata_response) do
instance_double(HTTParty::Response, success?: true, code: 200, body: empty_body)
end
let(:empty_body) { { 'assets' => { 'items' => [] } }.to_json }
before do
allow(HTTParty).to receive(:post).and_return(metadata_response)
end
it 'returns success without checking thumbnail' do
result = service.call
expect(result[:success]).to be true
expect(result[:message]).to eq('Immich connection verified')
end
it 'does not make thumbnail request' do
expect(HTTParty).not_to receive(:get)
service.call
end
end
context 'when metadata request fails' do
let(:metadata_response) do
instance_double(HTTParty::Response, success?: false, code: 401)
end
before do
allow(HTTParty).to receive(:post).and_return(metadata_response)
end
it 'returns error with status code' do
result = service.call
expect(result[:success]).to be false
expect(result[:error]).to eq('Immich connection failed: 401')
end
end
context 'when thumbnail request fails with 403 and asset.view permission error' do
let(:metadata_response) do
instance_double(HTTParty::Response, success?: true, code: 200, body: metadata_body)
end
let(:metadata_body) do
{ 'assets' => { 'items' => [{ 'id' => 'asset-123' }] } }.to_json
end
let(:thumbnail_response) do
instance_double(
HTTParty::Response,
success?: false,
code: 403,
body: { 'message' => 'Missing permission: asset.view' }.to_json
)
end
before do
allow(HTTParty).to receive(:post).and_return(metadata_response)
allow(HTTParty).to receive(:get).and_return(thumbnail_response)
end
it 'returns specific permission error' do
result = service.call
expect(result[:success]).to be false
expect(result[:error]).to eq('Immich API key missing permission: asset.view')
end
end
context 'when thumbnail request fails with other error' do
let(:metadata_response) do
instance_double(HTTParty::Response, success?: true, code: 200, body: metadata_body)
end
let(:metadata_body) do
{ 'assets' => { 'items' => [{ 'id' => 'asset-123' }] } }.to_json
end
let(:thumbnail_response) do
instance_double(HTTParty::Response, success?: false, code: 500)
end
before do
allow(HTTParty).to receive(:post).and_return(metadata_response)
allow(HTTParty).to receive(:get).and_return(thumbnail_response)
end
it 'returns thumbnail check failed error' do
result = service.call
expect(result[:success]).to be false
expect(result[:error]).to eq('Immich thumbnail check failed: 500')
end
end
context 'when network timeout occurs' do
before do
allow(HTTParty).to receive(:post).and_raise(Net::OpenTimeout)
end
it 'returns timeout error' do
result = service.call
expect(result[:success]).to be false
expect(result[:error]).to match(/Immich connection failed: /)
end
end
context 'when JSON parsing fails' do
let(:metadata_response) do
instance_double(HTTParty::Response, success?: true, code: 200, body: 'invalid json')
end
before do
allow(HTTParty).to receive(:post).and_return(metadata_response)
end
it 'handles JSON parse error gracefully' do
result = service.call
expect(result[:success]).to be true
expect(result[:message]).to eq('Immich connection verified')
end
end
context 'with malformed response body' do
let(:metadata_response) do
instance_double(HTTParty::Response, success?: true, code: 200, body: '{}')
end
before do
allow(HTTParty).to receive(:post).and_return(metadata_response)
end
it 'handles missing assets key gracefully' do
result = service.call
expect(result[:success]).to be true
expect(result[:message]).to eq('Immich connection verified')
end
end
end
end

View file

@ -81,7 +81,7 @@ RSpec.describe Immich::ImportGeodata do
stub_request(
:any,
'http://immich.app/api/search/metadata'
).to_return(status: 200, body: immich_data, headers: {})
).to_return(status: 200, body: immich_data, headers: { 'content-type' => 'application/json' })
end
it 'creates import' do

View file

@ -134,7 +134,7 @@ RSpec.describe Immich::RequestPhotos do
stub_request(
:any,
'http://immich.app/api/search/metadata'
).to_return(status: 200, body: mock_immich_data, headers: {})
).to_return(status: 200, body: mock_immich_data, headers: { 'content-type' => 'application/json' })
end
it 'returns images and videos' do

View file

@ -0,0 +1,165 @@
# frozen_string_literal: true
require 'rails_helper'
RSpec.describe Immich::ResponseAnalyzer do
subject(:analyzer) { described_class.new(response) }
describe '#permission_error?' do
context 'with 403 response containing asset.view permission error' do
let(:response) do
instance_double(
HTTParty::Response,
code: 403,
body: { 'message' => 'Missing permission: asset.view' }.to_json
)
end
it 'returns true' do
expect(analyzer.permission_error?).to be true
end
end
context 'with 403 response containing different permission error' do
let(:response) do
instance_double(
HTTParty::Response,
code: 403,
body: { 'message' => 'Missing permission: album.read' }.to_json
)
end
it 'returns false' do
expect(analyzer.permission_error?).to be false
end
end
context 'with 403 response but no asset.view in message' do
let(:response) do
instance_double(
HTTParty::Response,
code: 403,
body: { 'message' => 'Forbidden' }.to_json
)
end
it 'returns false' do
expect(analyzer.permission_error?).to be false
end
end
context 'with 403 response but malformed JSON' do
let(:response) do
instance_double(
HTTParty::Response,
code: 403,
body: 'invalid json'
)
end
it 'returns false' do
expect(analyzer.permission_error?).to be false
end
end
context 'with 403 response but no message field' do
let(:response) do
instance_double(
HTTParty::Response,
code: 403,
body: { 'error' => 'Forbidden' }.to_json
)
end
it 'returns false' do
expect(analyzer.permission_error?).to be false
end
end
context 'with non-403 response' do
let(:response) do
instance_double(
HTTParty::Response,
code: 401,
body: { 'message' => 'Unauthorized' }.to_json
)
end
it 'returns false' do
expect(analyzer.permission_error?).to be false
end
end
context 'with 200 success response' do
let(:response) do
instance_double(
HTTParty::Response,
code: 200,
body: { 'data' => 'some data' }.to_json
)
end
it 'returns false' do
expect(analyzer.permission_error?).to be false
end
end
context 'with string code instead of integer' do
let(:response) do
instance_double(
HTTParty::Response,
code: '403',
body: { 'message' => 'Missing permission: asset.view' }.to_json
)
end
it 'returns true' do
expect(analyzer.permission_error?).to be true
end
end
end
describe '#error_message' do
context 'when permission_error? is true' do
let(:response) do
instance_double(
HTTParty::Response,
code: 403,
body: { 'message' => 'Missing permission: asset.view' }.to_json
)
end
it 'returns specific permission error message' do
expect(analyzer.error_message).to eq('Immich API key missing permission: asset.view')
end
end
context 'when permission_error? is false' do
let(:response) do
instance_double(
HTTParty::Response,
code: 401,
body: { 'message' => 'Unauthorized' }.to_json
)
end
it 'returns generic error message' do
expect(analyzer.error_message).to eq('Failed to fetch thumbnail')
end
end
context 'with 500 error' do
let(:response) do
instance_double(
HTTParty::Response,
code: 500,
body: { 'error' => 'Internal Server Error' }.to_json
)
end
it 'returns generic error message' do
expect(analyzer.error_message).to eq('Failed to fetch thumbnail')
end
end
end
end

View file

@ -0,0 +1,201 @@
# frozen_string_literal: true
require 'rails_helper'
RSpec.describe Immich::ResponseValidator do
describe '.validate_and_parse' do
let(:logger) { instance_double(ActiveSupport::Logger) }
context 'with successful JSON response' do
let(:response) do
instance_double(
HTTParty::Response,
success?: true,
code: 200,
headers: { 'content-type' => 'application/json' },
body: { 'assets' => { 'items' => [] } }.to_json
)
end
it 'returns success with parsed data' do
result = described_class.validate_and_parse(response)
expect(result[:success]).to be true
expect(result[:data]).to eq({ 'assets' => { 'items' => [] } })
expect(result[:error]).to be_nil
end
end
context 'with failed HTTP status' do
let(:response) do
instance_double(
HTTParty::Response,
success?: false,
code: 401
)
end
it 'returns failure with status code' do
result = described_class.validate_and_parse(response)
expect(result[:success]).to be false
expect(result[:error]).to eq('Request failed: 401')
end
end
context 'with non-JSON content-type' do
let(:response) do
instance_double(
HTTParty::Response,
success?: true,
code: 200,
headers: { 'content-type' => 'text/html' },
body: '<html><body>Error</body></html>'
)
end
before do
allow(logger).to receive(:error)
end
it 'returns failure with content-type error' do
result = described_class.validate_and_parse(response, logger: logger)
expect(result[:success]).to be false
expect(result[:error]).to eq('Expected JSON, got text/html')
end
it 'logs the non-JSON response' do
expect(logger).to receive(:error).with(/Immich returned non-JSON response/)
described_class.validate_and_parse(response, logger: logger)
end
end
context 'with malformed JSON' do
let(:response) do
instance_double(
HTTParty::Response,
success?: true,
code: 200,
headers: { 'content-type' => 'application/json' },
body: '{"invalid": json}'
)
end
before do
allow(logger).to receive(:error)
end
it 'returns failure with parse error' do
result = described_class.validate_and_parse(response, logger: logger)
expect(result[:success]).to be false
expect(result[:error]).to eq('Invalid JSON response')
end
it 'logs the parse error and body' do
expect(logger).to receive(:error).with(/Immich JSON parse error/)
expect(logger).to receive(:error).with(/Response body:/)
described_class.validate_and_parse(response, logger: logger)
end
end
context 'with very large response body' do
let(:long_body) { 'x' * 2000 }
let(:response) do
instance_double(
HTTParty::Response,
success?: true,
code: 200,
headers: { 'content-type' => 'application/json' },
body: long_body
)
end
before do
allow(logger).to receive(:error)
end
it 'truncates the logged body' do
expect(logger).to receive(:error).with(/Immich JSON parse error/)
expect(logger).to receive(:error).with(/\(truncated\)/)
described_class.validate_and_parse(response, logger: logger)
end
end
context 'with case-insensitive content-type header' do
let(:response) do
instance_double(
HTTParty::Response,
success?: true,
code: 200,
headers: { 'Content-Type' => 'application/json; charset=utf-8' },
body: { 'data' => 'value' }.to_json
)
end
it 'accepts mixed case content-type' do
result = described_class.validate_and_parse(response)
expect(result[:success]).to be true
end
end
end
describe '.validate_and_parse_body' do
let(:logger) { instance_double(ActiveSupport::Logger) }
context 'with valid JSON string' do
let(:body) { { 'assets' => { 'items' => [{ 'id' => '123' }] } }.to_json }
it 'returns success with parsed data' do
result = described_class.validate_and_parse_body(body)
expect(result[:success]).to be true
expect(result[:data]['assets']['items'].first['id']).to eq('123')
end
end
context 'with malformed JSON string' do
let(:body) { '{"invalid": }' }
before do
allow(logger).to receive(:error)
end
it 'returns failure' do
result = described_class.validate_and_parse_body(body, logger: logger)
expect(result[:success]).to be false
expect(result[:error]).to eq('Invalid JSON')
end
it 'logs the error and body' do
expect(logger).to receive(:error).with(/JSON parse error/)
expect(logger).to receive(:error).with(/Body:/)
described_class.validate_and_parse_body(body, logger: logger)
end
end
context 'with nil body' do
it 'returns failure without logging' do
result = described_class.validate_and_parse_body(nil, logger: logger)
expect(result[:success]).to be false
expect(result[:error]).to eq('Invalid JSON')
end
it 'does not log error for nil body' do
expect(logger).not_to receive(:error)
described_class.validate_and_parse_body(nil, logger: logger)
end
end
context 'with long body string' do
let(:body) { 'x' * 2000 }
before do
allow(logger).to receive(:error)
end
it 'truncates logged body' do
expect(logger).to receive(:error).exactly(2).times do |message|
expect(message.length).to be < 1100 if message.include?('Body:')
end
described_class.validate_and_parse_body(body, logger: logger)
end
end
end
end

View file

@ -0,0 +1,148 @@
# frozen_string_literal: true
require 'rails_helper'
RSpec.describe Photoprism::ConnectionTester do
subject(:service) { described_class.new(url, api_key) }
let(:url) { 'https://photoprism.example.com' }
let(:api_key) { 'test_api_key_123' }
describe '#call' do
context 'with missing URL' do
let(:url) { nil }
it 'returns error for missing URL' do
result = service.call
expect(result[:success]).to be false
expect(result[:error]).to eq('Photoprism URL is missing')
end
end
context 'with blank URL' do
let(:url) { '' }
it 'returns error for blank URL' do
result = service.call
expect(result[:success]).to be false
expect(result[:error]).to eq('Photoprism URL is missing')
end
end
context 'with missing API key' do
let(:api_key) { nil }
it 'returns error for missing API key' do
result = service.call
expect(result[:success]).to be false
expect(result[:error]).to eq('Photoprism API key is missing')
end
end
context 'with blank API key' do
let(:api_key) { '' }
it 'returns error for blank API key' do
result = service.call
expect(result[:success]).to be false
expect(result[:error]).to eq('Photoprism API key is missing')
end
end
context 'with successful connection' do
let(:response) do
instance_double(HTTParty::Response, success?: true, code: 200)
end
before do
allow(HTTParty).to receive(:get).and_return(response)
end
it 'returns success' do
result = service.call
expect(result[:success]).to be true
expect(result[:message]).to eq('Photoprism connection verified')
end
it 'makes GET request with correct parameters' do
expect(HTTParty).to receive(:get).with(
"#{url}/api/v1/photos",
hash_including(
headers: { 'Authorization' => "Bearer #{api_key}", 'accept' => 'application/json' },
query: { count: 1, public: true },
timeout: 10
)
)
service.call
end
end
context 'when connection fails with 401' do
let(:response) do
instance_double(HTTParty::Response, success?: false, code: 401)
end
before do
allow(HTTParty).to receive(:get).and_return(response)
end
it 'returns error with status code' do
result = service.call
expect(result[:success]).to be false
expect(result[:error]).to eq('Photoprism connection failed: 401')
end
end
context 'when connection fails with 500' do
let(:response) do
instance_double(HTTParty::Response, success?: false, code: 500)
end
before do
allow(HTTParty).to receive(:get).and_return(response)
end
it 'returns error with status code' do
result = service.call
expect(result[:success]).to be false
expect(result[:error]).to eq('Photoprism connection failed: 500')
end
end
context 'when network timeout occurs' do
before do
allow(HTTParty).to receive(:get).and_raise(Net::OpenTimeout)
end
it 'returns timeout error' do
result = service.call
expect(result[:success]).to be false
expect(result[:error]).to match(/Photoprism connection failed: /)
end
end
context 'when read timeout occurs' do
before do
allow(HTTParty).to receive(:get).and_raise(Net::ReadTimeout)
end
it 'returns timeout error' do
result = service.call
expect(result[:success]).to be false
expect(result[:error]).to match(/Photoprism connection failed: /)
end
end
context 'when HTTParty error occurs' do
before do
allow(HTTParty).to receive(:get).and_raise(HTTParty::Error.new('Connection refused'))
end
it 'returns connection error' do
result = service.call
expect(result[:success]).to be false
expect(result[:error]).to match(/Photoprism connection failed: /)
end
end
end
end

View file

@ -0,0 +1,70 @@
# frozen_string_literal: true
require 'rails_helper'
RSpec.describe Photos::CacheCleaner do
subject(:service) { described_class.new(user) }
let(:user) { create(:user) }
describe '#call' do
context 'when cache supports delete_matched' do
before do
allow(Rails.cache).to receive(:respond_to?).and_return(true)
allow(Rails.cache).to receive(:delete_matched)
end
it 'deletes photo cache entries for the user' do
expect(Rails.cache).to receive(:delete_matched).with("photos_#{user.id}_*")
service.call
end
it 'deletes thumbnail cache entries for the user' do
expect(Rails.cache).to receive(:delete_matched).with("photo_thumbnail_#{user.id}_*")
service.call
end
it 'calls both delete operations' do
expect(Rails.cache).to receive(:delete_matched).twice
service.call
end
end
context 'when cache does not support delete_matched' do
let(:cache_without_delete_matched) { double('Cache') }
before do
stub_const('Rails', double(cache: cache_without_delete_matched))
allow(cache_without_delete_matched).to receive(:respond_to?).with(:delete_matched).and_return(false)
end
it 'does not attempt to delete cache entries' do
expect(cache_without_delete_matched).not_to receive(:delete_matched)
service.call
end
it 'does not raise an error' do
expect { service.call }.not_to raise_error
end
end
end
describe '.call' do
before do
allow(Rails.cache).to receive(:respond_to?).and_return(true)
allow(Rails.cache).to receive(:delete_matched)
end
it 'can be called as a class method' do
expect(Rails.cache).to receive(:delete_matched).twice
described_class.call(user)
end
it 'creates an instance and calls the instance method' do
instance = instance_double(described_class)
allow(described_class).to receive(:new).with(user).and_return(instance)
expect(instance).to receive(:call)
described_class.call(user)
end
end
end

View file

@ -24,13 +24,14 @@ RSpec.describe Photos::Thumbnail do
before do
allow(user).to receive(:settings).and_return(
'immich_url' => base_url,
'immich_api_key' => api_key
'immich_api_key' => api_key,
'immich_skip_ssl_verification' => false
)
end
it 'fetches thumbnail with correct parameters' do
expect(HTTParty).to receive(:get)
.with(expected_url, headers: expected_headers)
.with(expected_url, hash_including(headers: expected_headers, verify: true))
.and_return('thumbnail_data')
expect(subject).to eq('thumbnail_data')
@ -50,7 +51,8 @@ RSpec.describe Photos::Thumbnail do
before do
allow(user).to receive(:settings).and_return(
'photoprism_url' => base_url
'photoprism_url' => base_url,
'photoprism_skip_ssl_verification' => false
)
allow(Rails.cache).to receive(:read)
.with("#{Photoprism::CachePreviewToken::TOKEN_CACHE_KEY}_#{user.id}")
@ -59,7 +61,7 @@ RSpec.describe Photos::Thumbnail do
it 'fetches thumbnail with correct parameters' do
expect(HTTParty).to receive(:get)
.with(expected_url, headers: expected_headers)
.with(expected_url, hash_including(headers: expected_headers, verify: true))
.and_return('thumbnail_data')
expect(subject).to eq('thumbnail_data')

View file

@ -77,8 +77,10 @@ RSpec.describe Users::SafeSettings do
'route_opacity' => 80,
'immich_url' => 'https://immich.example.com',
'immich_api_key' => 'immich-key',
'immich_skip_ssl_verification' => false,
'photoprism_url' => 'https://photoprism.example.com',
'photoprism_api_key' => 'photoprism-key',
'photoprism_skip_ssl_verification' => false,
'maps' => { 'name' => 'custom', 'url' => 'https://custom.example.com' },
'visits_suggestions_enabled' => false,
'enabled_map_layers' => %w[Points Routes Areas Photos],