mirror of
https://github.com/Freika/dawarich.git
synced 2026-01-13 18:51:38 -05:00
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:
parent
096a7a6ffa
commit
0edaa7e55b
41 changed files with 1573 additions and 103 deletions
|
|
@ -1 +1 @@
|
|||
0.37.2
|
||||
0.37.4
|
||||
|
|
|
|||
15
CHANGELOG.md
15
CHANGELOG.md
|
|
@ -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
|
|
@ -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
|
||||
|
|
|
|||
21
app/controllers/settings/emails_controller.rb
Normal file
21
app/controllers/settings/emails_controller.rb
Normal 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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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?
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
17
app/services/concerns/ssl_configurable.rb
Normal file
17
app/services/concerns/ssl_configurable.rb
Normal 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
|
||||
89
app/services/immich/connection_tester.rb
Normal file
89
app/services/immich/connection_tester.rb
Normal 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
|
||||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
24
app/services/immich/response_analyzer.rb
Normal file
24
app/services/immich/response_analyzer.rb
Normal 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
|
||||
44
app/services/immich/response_validator.rb
Normal file
44
app/services/immich/response_validator.rb
Normal 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
|
||||
41
app/services/photoprism/connection_tester.rb
Normal file
41
app/services/photoprism/connection_tester.rb
Normal 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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
16
app/services/photos/cache_cleaner.rb
Normal file
16
app/services/photos/cache_cleaner.rb
Normal 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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)}" %>
|
||||
|
|
|
|||
38
app/views/settings/emails/index.html.erb
Normal file
38
app/views/settings/emails/index.html.erb
Normal 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>
|
||||
|
|
@ -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 %>
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
|
|
|||
21
db/migrate/20260112192240_set_existing_users_to_map_v1.rb
Normal file
21
db/migrate/20260112192240_set_existing_users_to_map_v1.rb
Normal 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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
44
spec/services/concerns/ssl_configurable_spec.rb
Normal file
44
spec/services/concerns/ssl_configurable_spec.rb
Normal 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
|
||||
230
spec/services/immich/connection_tester_spec.rb
Normal file
230
spec/services/immich/connection_tester_spec.rb
Normal 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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
165
spec/services/immich/response_analyzer_spec.rb
Normal file
165
spec/services/immich/response_analyzer_spec.rb
Normal 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
|
||||
201
spec/services/immich/response_validator_spec.rb
Normal file
201
spec/services/immich/response_validator_spec.rb
Normal 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
|
||||
148
spec/services/photoprism/connection_tester_spec.rb
Normal file
148
spec/services/photoprism/connection_tester_spec.rb
Normal 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
|
||||
70
spec/services/photos/cache_cleaner_spec.rb
Normal file
70
spec/services/photos/cache_cleaner_spec.rb
Normal 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
|
||||
|
|
@ -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')
|
||||
|
|
|
|||
|
|
@ -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],
|
||||
|
|
|
|||
Loading…
Reference in a new issue