From 5be2e9b26811013484a81e77797e4acb9790f220 Mon Sep 17 00:00:00 2001 From: Maurice Date: Thu, 2 Apr 2026 17:19:24 +0200 Subject: [PATCH 01/57] add Discord community badge to README --- README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/README.md b/README.md index 572d849..8248ee6 100644 --- a/README.md +++ b/README.md @@ -9,6 +9,7 @@

+ Discord License: AGPL v3 Docker Pulls GitHub Stars From 8e9f8784dc67f6f5a795b26fe139dc1e96e8470c Mon Sep 17 00:00:00 2001 From: Marek Maslowski Date: Fri, 3 Apr 2026 02:54:35 +0200 Subject: [PATCH 02/57] refactor(memories): generalize photo providers and decouple from immich --- client/src/api/authUrl.ts | 2 +- client/src/components/Admin/AddonManager.tsx | 133 +++++++-- .../src/components/Memories/MemoriesPanel.tsx | 195 +++++++++--- client/src/pages/SettingsPage.tsx | 279 +++++++++++++----- client/src/pages/TripPlannerPage.tsx | 4 +- client/src/store/addonStore.ts | 16 + server/src/db/migrations.ts | 114 +++++++ server/src/db/schema.ts | 25 ++ server/src/db/seeds.ts | 28 ++ server/src/index.ts | 69 ++++- server/src/routes/admin.ts | 87 +++++- server/src/routes/immich.ts | 168 ++++++----- server/src/routes/memories.ts | 182 ++++++++++++ 13 files changed, 1076 insertions(+), 226 deletions(-) create mode 100644 server/src/routes/memories.ts diff --git a/client/src/api/authUrl.ts b/client/src/api/authUrl.ts index 203ceb3..ed92729 100644 --- a/client/src/api/authUrl.ts +++ b/client/src/api/authUrl.ts @@ -1,4 +1,4 @@ -export async function getAuthUrl(url: string, purpose: 'download' | 'immich'): Promise { +export async function getAuthUrl(url: string, purpose: string): Promise { if (!url) return url try { const resp = await fetch('/api/auth/resource-token', { diff --git a/client/src/components/Admin/AddonManager.tsx b/client/src/components/Admin/AddonManager.tsx index 3050258..b45f9f0 100644 --- a/client/src/components/Admin/AddonManager.tsx +++ b/client/src/components/Admin/AddonManager.tsx @@ -15,7 +15,17 @@ interface Addon { name: string description: string icon: string + type: string enabled: boolean + config?: Record +} + +interface ProviderOption { + key: string + label: string + description: string + enabled: boolean + toggle: () => Promise } interface AddonIconProps { @@ -34,7 +44,7 @@ export default function AddonManager({ bagTrackingEnabled, onToggleBagTracking } const dark = dm === true || dm === 'dark' || (dm === 'auto' && window.matchMedia('(prefers-color-scheme: dark)').matches) const toast = useToast() const refreshGlobalAddons = useAddonStore(s => s.loadAddons) - const [addons, setAddons] = useState([]) + const [addons, setAddons] = useState([]) const [loading, setLoading] = useState(true) useEffect(() => { @@ -53,7 +63,7 @@ export default function AddonManager({ bagTrackingEnabled, onToggleBagTracking } } } - const handleToggle = async (addon) => { + const handleToggle = async (addon: Addon) => { const newEnabled = !addon.enabled // Optimistic update setAddons(prev => prev.map(a => a.id === addon.id ? { ...a, enabled: newEnabled } : a)) @@ -68,9 +78,44 @@ export default function AddonManager({ bagTrackingEnabled, onToggleBagTracking } } } + const isPhotoProviderAddon = (addon: Addon) => { + return addon.type === 'photo_provider' + } + + const isPhotosAddon = (addon: Addon) => { + const haystack = `${addon.id} ${addon.name} ${addon.description}`.toLowerCase() + return addon.type === 'trip' && (addon.icon === 'Image' || haystack.includes('photo') || haystack.includes('memories')) + } + + const handleTogglePhotoProvider = async (providerAddon: Addon) => { + const enableProvider = !providerAddon.enabled + const prev = addons + + setAddons(current => current.map(a => a.id === providerAddon.id ? { ...a, enabled: enableProvider } : a)) + + try { + await adminApi.updateAddon(providerAddon.id, { enabled: enableProvider }) + refreshGlobalAddons() + toast.success(t('admin.addons.toast.updated')) + } catch { + setAddons(prev) + toast.error(t('admin.addons.toast.error')) + } + } + const tripAddons = addons.filter(a => a.type === 'trip') const globalAddons = addons.filter(a => a.type === 'global') + const photoProviderAddons = addons.filter(isPhotoProviderAddon) const integrationAddons = addons.filter(a => a.type === 'integration') + const photosAddon = tripAddons.find(isPhotosAddon) + const providerOptions: ProviderOption[] = photoProviderAddons.map((provider) => ({ + key: provider.id, + label: provider.name, + description: provider.description, + enabled: provider.enabled, + toggle: () => handleTogglePhotoProvider(provider), + })) + const photosDerivedEnabled = providerOptions.some(p => p.enabled) if (loading) { return ( @@ -108,7 +153,42 @@ export default function AddonManager({ bagTrackingEnabled, onToggleBagTracking } {tripAddons.map(addon => (

- + + {photosAddon && addon.id === photosAddon.id && providerOptions.length > 0 && ( +
+
+ {providerOptions.map(provider => ( +
+
+
{provider.label}
+
{provider.description}
+
+
+ + {provider.enabled ? t('admin.addons.enabled') : t('admin.addons.disabled')} + + +
+
+ ))} +
+
+ )} {addon.id === 'packing' && addon.enabled && onToggleBagTracking && (
@@ -171,8 +251,10 @@ export default function AddonManager({ bagTrackingEnabled, onToggleBagTracking } interface AddonRowProps { addon: Addon - onToggle: (addonId: string) => void + onToggle: (addon: Addon) => void t: (key: string) => string + statusOverride?: boolean + hideToggle?: boolean } function getAddonLabel(t: (key: string) => string, addon: Addon): { name: string; description: string } { @@ -187,9 +269,12 @@ function getAddonLabel(t: (key: string) => string, addon: Addon): { name: string } } -function AddonRow({ addon, onToggle, t }: AddonRowProps) { +function AddonRow({ addon, onToggle, t, nameOverride, descriptionOverride, statusOverride, hideToggle }: AddonRowProps & { nameOverride?: string; descriptionOverride?: string }) { const isComingSoon = false const label = getAddonLabel(t, addon) + const displayName = nameOverride || label.name + const displayDescription = descriptionOverride || label.description + const enabledState = statusOverride ?? addon.enabled return (
{/* Icon */} @@ -200,7 +285,7 @@ function AddonRow({ addon, onToggle, t }: AddonRowProps) { {/* Info */}
- {label.name} + {displayName} {isComingSoon && ( Coming Soon @@ -210,28 +295,30 @@ function AddonRow({ addon, onToggle, t }: AddonRowProps) { {addon.type === 'global' ? t('admin.addons.type.global') : addon.type === 'integration' ? t('admin.addons.type.integration') : t('admin.addons.type.trip')}
-

{label.description}

+

{displayDescription}

{/* Toggle */}
- - {isComingSoon ? t('admin.addons.disabled') : addon.enabled ? t('admin.addons.enabled') : t('admin.addons.disabled')} + + {isComingSoon ? t('admin.addons.disabled') : enabledState ? t('admin.addons.enabled') : t('admin.addons.disabled')} - + {!hideToggle && ( + + )}
) diff --git a/client/src/components/Memories/MemoriesPanel.tsx b/client/src/components/Memories/MemoriesPanel.tsx index 9dd1ed4..b288487 100644 --- a/client/src/components/Memories/MemoriesPanel.tsx +++ b/client/src/components/Memories/MemoriesPanel.tsx @@ -1,27 +1,36 @@ import { useState, useEffect, useCallback } from 'react' import { Camera, Plus, Share2, EyeOff, Eye, X, Check, Search, ArrowUpDown, MapPin, Filter, Link2, RefreshCw, Unlink, FolderOpen } from 'lucide-react' -import apiClient from '../../api/client' +import apiClient, { addonsApi } from '../../api/client' import { useAuthStore } from '../../store/authStore' import { useTranslation } from '../../i18n' import { getAuthUrl } from '../../api/authUrl' import { useToast } from '../shared/Toast' -function ImmichImg({ baseUrl, style, loading }: { baseUrl: string; style?: React.CSSProperties; loading?: 'lazy' | 'eager' }) { +interface PhotoProvider { + id: string + name: string + icon?: string + config?: Record +} + +function ProviderImg({ baseUrl, provider, style, loading }: { baseUrl: string; provider: string; style?: React.CSSProperties; loading?: 'lazy' | 'eager' }) { const [src, setSrc] = useState('') useEffect(() => { - getAuthUrl(baseUrl, 'immich').then(setSrc) - }, [baseUrl]) + getAuthUrl(baseUrl, provider).then(setSrc).catch(() => {}) + }, [baseUrl, provider]) return src ? : null } // ── Types ─────────────────────────────────────────────────────────────────── interface TripPhoto { - immich_asset_id: string + asset_id: string + provider: string user_id: number username: string shared: number added_at: string + city?: string | null } interface ImmichAsset { @@ -45,6 +54,8 @@ export default function MemoriesPanel({ tripId, startDate, endDate }: MemoriesPa const currentUser = useAuthStore(s => s.user) const [connected, setConnected] = useState(false) + const [availableProviders, setAvailableProviders] = useState([]) + const [selectedProvider, setSelectedProvider] = useState('') const [loading, setLoading] = useState(true) // Trip photos (saved selections) @@ -67,49 +78,61 @@ export default function MemoriesPanel({ tripId, startDate, endDate }: MemoriesPa const [showAlbumPicker, setShowAlbumPicker] = useState(false) const [albums, setAlbums] = useState<{ id: string; albumName: string; assetCount: number }[]>([]) const [albumsLoading, setAlbumsLoading] = useState(false) - const [albumLinks, setAlbumLinks] = useState<{ id: number; immich_album_id: string; album_name: string; user_id: number; username: string; sync_enabled: number; last_synced_at: string | null }[]>([]) + const [albumLinks, setAlbumLinks] = useState<{ id: number; provider: string; album_id: string; album_name: string; user_id: number; username: string; sync_enabled: number; last_synced_at: string | null }[]>([]) const [syncing, setSyncing] = useState(null) + const pickerIntegrationBase = selectedProvider ? `/integrations/${selectedProvider}` : '' const loadAlbumLinks = async () => { try { - const res = await apiClient.get(`/integrations/immich/trips/${tripId}/album-links`) + const res = await apiClient.get(`/integrations/memories/trips/${tripId}/album-links`) setAlbumLinks(res.data.links || []) } catch { setAlbumLinks([]) } } - const openAlbumPicker = async () => { - setShowAlbumPicker(true) + const loadAlbums = async (provider: string = selectedProvider) => { + if (!provider) return setAlbumsLoading(true) try { - const res = await apiClient.get('/integrations/immich/albums') + const res = await apiClient.get(`/integrations/${provider}/albums`) setAlbums(res.data.albums || []) - } catch { setAlbums([]); toast.error(t('memories.error.loadAlbums')) } - finally { setAlbumsLoading(false) } + } catch { + setAlbums([]) + toast.error(t('memories.error.loadAlbums')) + } finally { + setAlbumsLoading(false) + } + } + + const openAlbumPicker = async () => { + setShowAlbumPicker(true) + await loadAlbums(selectedProvider) } const linkAlbum = async (albumId: string, albumName: string) => { try { - await apiClient.post(`/integrations/immich/trips/${tripId}/album-links`, { album_id: albumId, album_name: albumName }) + await apiClient.post(`${pickerIntegrationBase}/trips/${tripId}/album-links`, { album_id: albumId, album_name: albumName }) setShowAlbumPicker(false) await loadAlbumLinks() // Auto-sync after linking - const linksRes = await apiClient.get(`/integrations/immich/trips/${tripId}/album-links`) - const newLink = (linksRes.data.links || []).find((l: any) => l.immich_album_id === albumId) + const linksRes = await apiClient.get(`/integrations/memories/trips/${tripId}/album-links`) + const newLink = (linksRes.data.links || []).find((l: any) => l.album_id === albumId && l.provider === selectedProvider) if (newLink) await syncAlbum(newLink.id) } catch { toast.error(t('memories.error.linkAlbum')) } } const unlinkAlbum = async (linkId: number) => { try { - await apiClient.delete(`/integrations/immich/trips/${tripId}/album-links/${linkId}`) + await apiClient.delete(`/integrations/memories/trips/${tripId}/album-links/${linkId}`) loadAlbumLinks() } catch { toast.error(t('memories.error.unlinkAlbum')) } } - const syncAlbum = async (linkId: number) => { + const syncAlbum = async (linkId: number, provider?: string) => { + const targetProvider = provider || selectedProvider + if (!targetProvider) return setSyncing(linkId) try { - await apiClient.post(`/integrations/immich/trips/${tripId}/album-links/${linkId}/sync`) + await apiClient.post(`/integrations/${targetProvider}/trips/${tripId}/album-links/${linkId}/sync`) await loadAlbumLinks() await loadPhotos() } catch { toast.error(t('memories.error.syncAlbum')) } @@ -138,7 +161,7 @@ export default function MemoriesPanel({ tripId, startDate, endDate }: MemoriesPa const loadPhotos = async () => { try { - const photosRes = await apiClient.get(`/integrations/immich/trips/${tripId}/photos`) + const photosRes = await apiClient.get(`/integrations/memories/trips/${tripId}/photos`) setTripPhotos(photosRes.data.photos || []) } catch { setTripPhotos([]) @@ -148,9 +171,35 @@ export default function MemoriesPanel({ tripId, startDate, endDate }: MemoriesPa const loadInitial = async () => { setLoading(true) try { - const statusRes = await apiClient.get('/integrations/immich/status') - setConnected(statusRes.data.connected) + const addonsRes = await addonsApi.enabled().catch(() => ({ addons: [] as any[] })) + const enabledAddons = addonsRes?.addons || [] + const photoProviders = enabledAddons.filter((a: any) => a.type === 'photo_provider' && a.enabled) + + // Test connection status for each enabled provider + const statusResults = await Promise.all( + photoProviders.map(async (provider: any) => { + const statusUrl = (provider.config as Record)?.status_get as string + if (!statusUrl) return { provider, connected: false } + try { + const res = await apiClient.get(statusUrl) + return { provider, connected: !!res.data?.connected } + } catch { + return { provider, connected: false } + } + }) + ) + + const connectedProviders = statusResults + .filter(r => r.connected) + .map(r => ({ id: r.provider.id, name: r.provider.name, icon: r.provider.icon, config: r.provider.config })) + + setAvailableProviders(connectedProviders) + setConnected(connectedProviders.length > 0) + if (connectedProviders.length > 0 && !selectedProvider) { + setSelectedProvider(connectedProviders[0].id) + } } catch { + setAvailableProviders([]) setConnected(false) } await loadPhotos() @@ -170,10 +219,26 @@ export default function MemoriesPanel({ tripId, startDate, endDate }: MemoriesPa await loadPickerPhotos(!!(startDate && endDate)) } + useEffect(() => { + if (showPicker) { + loadPickerPhotos(pickerDateFilter) + } + }, [selectedProvider]) + + useEffect(() => { + loadAlbumLinks() + }, [tripId]) + + useEffect(() => { + if (showAlbumPicker) { + loadAlbums(selectedProvider) + } + }, [showAlbumPicker, selectedProvider, tripId]) + const loadPickerPhotos = async (useDate: boolean) => { setPickerLoading(true) try { - const res = await apiClient.post('/integrations/immich/search', { + const res = await apiClient.post(`${pickerIntegrationBase}/search`, { from: useDate && startDate ? startDate : undefined, to: useDate && endDate ? endDate : undefined, }) @@ -203,7 +268,8 @@ export default function MemoriesPanel({ tripId, startDate, endDate }: MemoriesPa const executeAddPhotos = async () => { setShowConfirmShare(false) try { - await apiClient.post(`/integrations/immich/trips/${tripId}/photos`, { + await apiClient.post(`/integrations/memories/trips/${tripId}/photos`, { + provider: selectedProvider, asset_ids: [...selectedIds], shared: true, }) @@ -214,28 +280,37 @@ export default function MemoriesPanel({ tripId, startDate, endDate }: MemoriesPa // ── Remove photo ────────────────────────────────────────────────────────── - const removePhoto = async (assetId: string) => { + const removePhoto = async (photo: TripPhoto) => { try { - await apiClient.delete(`/integrations/immich/trips/${tripId}/photos/${assetId}`) - setTripPhotos(prev => prev.filter(p => p.immich_asset_id !== assetId)) + await apiClient.delete(`/integrations/memories/trips/${tripId}/photos`, { + data: { + asset_id: photo.asset_id, + provider: photo.provider, + }, + }) + setTripPhotos(prev => prev.filter(p => !(p.provider === photo.provider && p.asset_id === photo.asset_id))) } catch { toast.error(t('memories.error.removePhoto')) } } // ── Toggle sharing ──────────────────────────────────────────────────────── - const toggleSharing = async (assetId: string, shared: boolean) => { + const toggleSharing = async (photo: TripPhoto, shared: boolean) => { try { - await apiClient.put(`/integrations/immich/trips/${tripId}/photos/${assetId}/sharing`, { shared }) + await apiClient.put(`/integrations/memories/trips/${tripId}/photos/sharing`, { + shared, + asset_id: photo.asset_id, + provider: photo.provider, + }) setTripPhotos(prev => prev.map(p => - p.immich_asset_id === assetId ? { ...p, shared: shared ? 1 : 0 } : p + p.provider === photo.provider && p.asset_id === photo.asset_id ? { ...p, shared: shared ? 1 : 0 } : p )) } catch { toast.error(t('memories.error.toggleSharing')) } } // ── Helpers ─────────────────────────────────────────────────────────────── - const thumbnailBaseUrl = (assetId: string, userId: number) => - `/api/integrations/immich/assets/${assetId}/thumbnail?userId=${userId}` + const thumbnailBaseUrl = (photo: TripPhoto) => + `/api/integrations/${photo.provider}/assets/${photo.asset_id}/thumbnail?userId=${photo.user_id}` const ownPhotos = tripPhotos.filter(p => p.user_id === currentUser?.id) const othersPhotos = tripPhotos.filter(p => p.user_id !== currentUser?.id && p.shared) @@ -286,10 +361,40 @@ export default function MemoriesPanel({ tripId, startDate, endDate }: MemoriesPa // ── Photo Picker Modal ──────────────────────────────────────────────────── + const ProviderTabs = () => { + if (availableProviders.length < 2) return null + return ( +
+ {availableProviders.map(provider => ( + + ))} +
+ ) + } + // ── Album Picker Modal ────────────────────────────────────────────────── if (showAlbumPicker) { - const linkedIds = new Set(albumLinks.map(l => l.immich_album_id)) + const linkedIds = new Set(albumLinks.map(l => l.album_id)) return (
@@ -297,6 +402,7 @@ export default function MemoriesPanel({ tripId, startDate, endDate }: MemoriesPa

{t('memories.selectAlbum')}

+ @@ -630,18 +741,18 @@ export default function MemoriesPanel({ tripId, startDate, endDate }: MemoriesPa {allVisible.map(photo => { const isOwn = photo.user_id === currentUser?.id return ( -
{ - setLightboxId(photo.immich_asset_id); setLightboxUserId(photo.user_id); setLightboxInfo(null) + setLightboxId(photo.asset_id); setLightboxUserId(photo.user_id); setLightboxInfo(null) setLightboxOriginalSrc('') - getAuthUrl(`/api/integrations/immich/assets/${photo.immich_asset_id}/original?userId=${photo.user_id}`, 'immich').then(setLightboxOriginalSrc) + getAuthUrl(`/api/integrations/${photo.provider}/assets/${photo.asset_id}/original?userId=${photo.user_id}`, photo.provider).then(setLightboxOriginalSrc).catch(() => {}) setLightboxInfoLoading(true) - apiClient.get(`/integrations/immich/assets/${photo.immich_asset_id}/info?userId=${photo.user_id}`) + apiClient.get(`/integrations/${photo.provider}/assets/${photo.asset_id}/info?userId=${photo.user_id}`) .then(r => setLightboxInfo(r.data)).catch(() => {}).finally(() => setLightboxInfoLoading(false)) }}> - {/* Other user's avatar */} @@ -672,7 +783,7 @@ export default function MemoriesPanel({ tripId, startDate, endDate }: MemoriesPa {isOwn && (
- - + + {connected && ( + + + {t('memories.connected')} + + )} +
+
+ + ) + } + // Map settings const [mapTileUrl, setMapTileUrl] = useState(settings.map_tile_url || '') const [defaultLat, setDefaultLat] = useState(settings.default_lat || 48.8566) @@ -673,45 +832,7 @@ export default function SettingsPage(): React.ReactElement { - {/* Immich — only when Memories addon is enabled */} - {memoriesEnabled && ( -
-
-
- - { setImmichUrl(e.target.value); setImmichTestPassed(false) }} - placeholder="https://immich.example.com" - className="w-full px-3 py-2.5 border border-slate-200 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-slate-300" /> -
-
- - { setImmichApiKey(e.target.value); setImmichTestPassed(false) }} - placeholder={immichConnected ? '••••••••' : 'API Key'} - className="w-full px-3 py-2.5 border border-slate-200 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-slate-300" /> -
-
- - - {immichConnected && ( - - - {t('memories.connected')} - - )} -
-
-
- )} + {activePhotoProviders.map(provider => renderPhotoProviderSection(provider as PhotoProviderAddon))} {/* MCP Configuration — only when MCP addon is enabled */} {mcpEnabled &&
diff --git a/client/src/pages/TripPlannerPage.tsx b/client/src/pages/TripPlannerPage.tsx index 47918bd..256714a 100644 --- a/client/src/pages/TripPlannerPage.tsx +++ b/client/src/pages/TripPlannerPage.tsx @@ -78,7 +78,9 @@ export default function TripPlannerPage(): React.ReactElement | null { addonsApi.enabled().then(data => { const map = {} data.addons.forEach(a => { map[a.id] = true }) - setEnabledAddons({ packing: !!map.packing, budget: !!map.budget, documents: !!map.documents, collab: !!map.collab, memories: !!map.memories }) + // Check if any photo provider is enabled (for memories tab to show) + const hasPhotoProviders = data.addons.some(a => a.type === 'photo_provider' && a.enabled) + setEnabledAddons({ packing: !!map.packing, budget: !!map.budget, documents: !!map.documents, collab: !!map.collab, memories: !!map.memories || hasPhotoProviders }) }).catch(() => {}) authApi.getAppConfig().then(config => { if (config.allowed_file_types) setAllowedFileTypes(config.allowed_file_types) diff --git a/client/src/store/addonStore.ts b/client/src/store/addonStore.ts index d0fce97..8ef6798 100644 --- a/client/src/store/addonStore.ts +++ b/client/src/store/addonStore.ts @@ -4,9 +4,22 @@ import { addonsApi } from '../api/client' interface Addon { id: string name: string + description?: string type: string icon: string enabled: boolean + config?: Record + fields?: Array<{ + key: string + label: string + input_type: string + placeholder?: string | null + required: boolean + secret: boolean + settings_key?: string | null + payload_key?: string | null + sort_order: number + }> } interface AddonState { @@ -30,6 +43,9 @@ export const useAddonStore = create((set, get) => ({ }, isEnabled: (id: string) => { + if (id === 'memories') { + return get().addons.some(a => a.type === 'photo_provider' && a.enabled) + } return get().addons.some(a => a.id === id && a.enabled) }, })) diff --git a/server/src/db/migrations.ts b/server/src/db/migrations.ts index c2f1271..8e01979 100644 --- a/server/src/db/migrations.ts +++ b/server/src/db/migrations.ts @@ -518,6 +518,120 @@ function runMigrations(db: Database.Database): void { CREATE INDEX IF NOT EXISTS idx_notifications_recipient_created ON notifications(recipient_id, created_at DESC); `); }, + () => { + // Normalize trip_photos to provider-based schema used by current routes + const tripPhotosExists = db.prepare("SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'trip_photos'").get(); + if (!tripPhotosExists) { + db.exec(` + CREATE TABLE trip_photos ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + trip_id INTEGER NOT NULL REFERENCES trips(id) ON DELETE CASCADE, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + asset_id TEXT NOT NULL, + provider TEXT NOT NULL DEFAULT 'immich', + shared INTEGER NOT NULL DEFAULT 1, + added_at DATETIME DEFAULT CURRENT_TIMESTAMP, + UNIQUE(trip_id, user_id, asset_id, provider) + ); + CREATE INDEX IF NOT EXISTS idx_trip_photos_trip ON trip_photos(trip_id); + `); + } else { + const columns = db.prepare("PRAGMA table_info('trip_photos')").all() as Array<{ name: string }>; + const names = new Set(columns.map(c => c.name)); + const assetSource = names.has('asset_id') ? 'asset_id' : (names.has('immich_asset_id') ? 'immich_asset_id' : null); + if (assetSource) { + const providerExpr = names.has('provider') + ? "CASE WHEN provider IS NULL OR provider = '' THEN 'immich' ELSE provider END" + : "'immich'"; + const sharedExpr = names.has('shared') ? 'COALESCE(shared, 1)' : '1'; + const addedAtExpr = names.has('added_at') ? 'COALESCE(added_at, CURRENT_TIMESTAMP)' : 'CURRENT_TIMESTAMP'; + + db.exec(` + CREATE TABLE trip_photos_new ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + trip_id INTEGER NOT NULL REFERENCES trips(id) ON DELETE CASCADE, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + asset_id TEXT NOT NULL, + provider TEXT NOT NULL DEFAULT 'immich', + shared INTEGER NOT NULL DEFAULT 1, + added_at DATETIME DEFAULT CURRENT_TIMESTAMP, + UNIQUE(trip_id, user_id, asset_id, provider) + ); + `); + + db.exec(` + INSERT OR IGNORE INTO trip_photos_new (trip_id, user_id, asset_id, provider, shared, added_at) + SELECT trip_id, user_id, ${assetSource}, ${providerExpr}, ${sharedExpr}, ${addedAtExpr} + FROM trip_photos + WHERE ${assetSource} IS NOT NULL AND TRIM(${assetSource}) != '' + `); + + db.exec('DROP TABLE trip_photos'); + db.exec('ALTER TABLE trip_photos_new RENAME TO trip_photos'); + db.exec('CREATE INDEX IF NOT EXISTS idx_trip_photos_trip ON trip_photos(trip_id)'); + } + } + }, + () => { + // Normalize trip_album_links to provider + album_id schema used by current routes + const linksExists = db.prepare("SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'trip_album_links'").get(); + if (!linksExists) { + db.exec(` + CREATE TABLE trip_album_links ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + trip_id INTEGER NOT NULL REFERENCES trips(id) ON DELETE CASCADE, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + provider TEXT NOT NULL, + album_id TEXT NOT NULL, + album_name TEXT NOT NULL DEFAULT '', + sync_enabled INTEGER NOT NULL DEFAULT 1, + last_synced_at DATETIME, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + UNIQUE(trip_id, user_id, provider, album_id) + ); + CREATE INDEX IF NOT EXISTS idx_trip_album_links_trip ON trip_album_links(trip_id); + `); + } else { + const columns = db.prepare("PRAGMA table_info('trip_album_links')").all() as Array<{ name: string }>; + const names = new Set(columns.map(c => c.name)); + const albumIdSource = names.has('album_id') ? 'album_id' : (names.has('immich_album_id') ? 'immich_album_id' : null); + if (albumIdSource) { + const providerExpr = names.has('provider') + ? "CASE WHEN provider IS NULL OR provider = '' THEN 'immich' ELSE provider END" + : "'immich'"; + const albumNameExpr = names.has('album_name') ? "COALESCE(album_name, '')" : "''"; + const syncEnabledExpr = names.has('sync_enabled') ? 'COALESCE(sync_enabled, 1)' : '1'; + const lastSyncedExpr = names.has('last_synced_at') ? 'last_synced_at' : 'NULL'; + const createdAtExpr = names.has('created_at') ? 'COALESCE(created_at, CURRENT_TIMESTAMP)' : 'CURRENT_TIMESTAMP'; + + db.exec(` + CREATE TABLE trip_album_links_new ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + trip_id INTEGER NOT NULL REFERENCES trips(id) ON DELETE CASCADE, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + provider TEXT NOT NULL, + album_id TEXT NOT NULL, + album_name TEXT NOT NULL DEFAULT '', + sync_enabled INTEGER NOT NULL DEFAULT 1, + last_synced_at DATETIME, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + UNIQUE(trip_id, user_id, provider, album_id) + ); + `); + + db.exec(` + INSERT OR IGNORE INTO trip_album_links_new (trip_id, user_id, provider, album_id, album_name, sync_enabled, last_synced_at, created_at) + SELECT trip_id, user_id, ${providerExpr}, ${albumIdSource}, ${albumNameExpr}, ${syncEnabledExpr}, ${lastSyncedExpr}, ${createdAtExpr} + FROM trip_album_links + WHERE ${albumIdSource} IS NOT NULL AND TRIM(${albumIdSource}) != '' + `); + + db.exec('DROP TABLE trip_album_links'); + db.exec('ALTER TABLE trip_album_links_new RENAME TO trip_album_links'); + db.exec('CREATE INDEX IF NOT EXISTS idx_trip_album_links_trip ON trip_album_links(trip_id)'); + } + } + }, ]; if (currentVersion < migrations.length) { diff --git a/server/src/db/schema.ts b/server/src/db/schema.ts index 8506253..8fe5739 100644 --- a/server/src/db/schema.ts +++ b/server/src/db/schema.ts @@ -222,6 +222,31 @@ function createTables(db: Database.Database): void { sort_order INTEGER DEFAULT 0 ); + CREATE TABLE IF NOT EXISTS photo_providers ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + description TEXT, + icon TEXT DEFAULT 'Image', + enabled INTEGER DEFAULT 0, + config TEXT DEFAULT '{}', + sort_order INTEGER DEFAULT 0 + ); + + CREATE TABLE IF NOT EXISTS photo_provider_fields ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + provider_id TEXT NOT NULL REFERENCES photo_providers(id) ON DELETE CASCADE, + field_key TEXT NOT NULL, + label TEXT NOT NULL, + input_type TEXT NOT NULL DEFAULT 'text', + placeholder TEXT, + required INTEGER DEFAULT 0, + secret INTEGER DEFAULT 0, + settings_key TEXT, + payload_key TEXT, + sort_order INTEGER DEFAULT 0, + UNIQUE(provider_id, field_key) + ); + -- Vacay addon tables CREATE TABLE IF NOT EXISTS vacay_plans ( id INTEGER PRIMARY KEY AUTOINCREMENT, diff --git a/server/src/db/seeds.ts b/server/src/db/seeds.ts index 8e0d9c6..f875794 100644 --- a/server/src/db/seeds.ts +++ b/server/src/db/seeds.ts @@ -92,6 +92,34 @@ function seedAddons(db: Database.Database): void { ]; const insertAddon = db.prepare('INSERT OR IGNORE INTO addons (id, name, description, type, icon, enabled, sort_order) VALUES (?, ?, ?, ?, ?, ?, ?)'); for (const a of defaultAddons) insertAddon.run(a.id, a.name, a.description, a.type, a.icon, a.enabled, a.sort_order); + + const providerRows = [ + { + id: 'immich', + name: 'Immich', + description: 'Immich photo provider', + icon: 'Image', + enabled: 0, + sort_order: 0, + config: JSON.stringify({ + settings_get: '/integrations/immich/settings', + settings_put: '/integrations/immich/settings', + status_get: '/integrations/immich/status', + test_post: '/integrations/immich/test', + }), + }, + ]; + const insertProvider = db.prepare('INSERT OR IGNORE INTO photo_providers (id, name, description, icon, enabled, config, sort_order) VALUES (?, ?, ?, ?, ?, ?, ?)'); + for (const p of providerRows) insertProvider.run(p.id, p.name, p.description, p.icon, p.enabled, p.config, p.sort_order); + + const providerFields = [ + { provider_id: 'immich', field_key: 'immich_url', label: 'Immich URL', input_type: 'url', placeholder: 'https://immich.example.com', required: 1, secret: 0, settings_key: 'immich_url', payload_key: 'immich_url', sort_order: 0 }, + { provider_id: 'immich', field_key: 'immich_api_key', label: 'API Key', input_type: 'password', placeholder: 'API Key', required: 1, secret: 1, settings_key: null, payload_key: 'immich_api_key', sort_order: 1 }, + ]; + const insertProviderField = db.prepare('INSERT OR IGNORE INTO photo_provider_fields (provider_id, field_key, label, input_type, placeholder, required, secret, settings_key, payload_key, sort_order) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)'); + for (const f of providerFields) { + insertProviderField.run(f.provider_id, f.field_key, f.label, f.input_type, f.placeholder, f.required, f.secret, f.settings_key, f.payload_key, f.sort_order); + } console.log('Default addons seeded'); } catch (err: unknown) { console.error('Error seeding addons:', err instanceof Error ? err.message : err); diff --git a/server/src/index.ts b/server/src/index.ts index 5508542..533d92c 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -207,8 +207,71 @@ import { authenticate as addonAuth } from './middleware/auth'; import {db as addonDb} from './db/database'; import { Addon } from './types'; app.get('/api/addons', addonAuth, (req: Request, res: Response) => { - const addons = addonDb.prepare('SELECT id, name, type, icon, enabled FROM addons WHERE enabled = 1 ORDER BY sort_order').all() as Pick[]; - res.json({ addons: addons.map(a => ({ ...a, enabled: !!a.enabled })) }); + const addons = addonDb.prepare('SELECT id, name, type, icon, enabled, config, sort_order FROM addons WHERE enabled = 1 ORDER BY sort_order').all() as Array & { sort_order: number }>; + const photoProviders = addonDb.prepare(` + SELECT id, name, description, icon, enabled, config, sort_order + FROM photo_providers + WHERE enabled = 1 + ORDER BY sort_order + `).all() as Array<{ id: string; name: string; description?: string | null; icon: string; enabled: number; config: string; sort_order: number }>; + const providerIds = photoProviders.map(p => p.id); + const providerFields = providerIds.length > 0 + ? addonDb.prepare(` + SELECT provider_id, field_key, label, input_type, placeholder, required, secret, settings_key, payload_key, sort_order + FROM photo_provider_fields + WHERE provider_id IN (${providerIds.map(() => '?').join(',')}) + ORDER BY sort_order, id + `).all(...providerIds) as Array<{ + provider_id: string; + field_key: string; + label: string; + input_type: string; + placeholder?: string | null; + required: number; + secret: number; + settings_key?: string | null; + payload_key?: string | null; + sort_order: number; + }> + : []; + const fieldsByProvider = new Map(); + for (const field of providerFields) { + const arr = fieldsByProvider.get(field.provider_id) || []; + arr.push(field); + fieldsByProvider.set(field.provider_id, arr); + } + + const combined = [ + ...addons, + ...photoProviders.map(p => ({ + id: p.id, + name: p.name, + type: 'photo_provider', + icon: p.icon, + enabled: p.enabled, + config: p.config, + fields: (fieldsByProvider.get(p.id) || []).map(f => ({ + key: f.field_key, + label: f.label, + input_type: f.input_type, + placeholder: f.placeholder || '', + required: !!f.required, + secret: !!f.secret, + settings_key: f.settings_key || null, + payload_key: f.payload_key || null, + sort_order: f.sort_order, + })), + sort_order: p.sort_order, + })), + ].sort((a, b) => a.sort_order - b.sort_order || a.id.localeCompare(b.id)); + + res.json({ + addons: combined.map(a => ({ + ...a, + enabled: !!a.enabled, + config: JSON.parse(a.config || '{}'), + })), + }); }); // Addon routes @@ -218,6 +281,8 @@ import atlasRoutes from './routes/atlas'; app.use('/api/addons/atlas', atlasRoutes); import immichRoutes from './routes/immich'; app.use('/api/integrations/immich', immichRoutes); +import memoriesRoutes from './routes/memories'; +app.use('/api/integrations/memories', memoriesRoutes); app.use('/api/maps', mapsRoutes); app.use('/api/weather', weatherRoutes); diff --git a/server/src/routes/admin.ts b/server/src/routes/admin.ts index 56a9136..8770f79 100644 --- a/server/src/routes/admin.ts +++ b/server/src/routes/admin.ts @@ -264,12 +264,91 @@ router.delete('/packing-templates/:templateId/items/:itemId', (req: Request, res // ── Addons ───────────────────────────────────────────────────────────────── router.get('/addons', (_req: Request, res: Response) => { - res.json({ addons: svc.listAddons() }); + const addons = db.prepare('SELECT * FROM addons ORDER BY sort_order, id').all() as Addon[]; + const providers = db.prepare(` + SELECT id, name, description, icon, enabled, config, sort_order + FROM photo_providers + ORDER BY sort_order, id + `).all() as Array<{ id: string; name: string; description?: string | null; icon: string; enabled: number; config: string; sort_order: number }>; + const fields = db.prepare(` + SELECT provider_id, field_key, label, input_type, placeholder, required, secret, settings_key, payload_key, sort_order + FROM photo_provider_fields + ORDER BY sort_order, id + `).all() as Array<{ + provider_id: string; + field_key: string; + label: string; + input_type: string; + placeholder?: string | null; + required: number; + secret: number; + settings_key?: string | null; + payload_key?: string | null; + sort_order: number; + }>; + const fieldsByProvider = new Map(); + for (const field of fields) { + const arr = fieldsByProvider.get(field.provider_id) || []; + arr.push(field); + fieldsByProvider.set(field.provider_id, arr); + } + + res.json({ + addons: [ + ...addons.map(a => ({ ...a, enabled: !!a.enabled, config: JSON.parse(a.config || '{}') })), + ...providers.map(p => ({ + id: p.id, + name: p.name, + description: p.description, + type: 'photo_provider', + icon: p.icon, + enabled: !!p.enabled, + config: JSON.parse(p.config || '{}'), + fields: (fieldsByProvider.get(p.id) || []).map(f => ({ + key: f.field_key, + label: f.label, + input_type: f.input_type, + placeholder: f.placeholder || '', + required: !!f.required, + secret: !!f.secret, + settings_key: f.settings_key || null, + payload_key: f.payload_key || null, + sort_order: f.sort_order, + })), + sort_order: p.sort_order, + })), + ], + }); }); router.put('/addons/:id', (req: Request, res: Response) => { - const result = svc.updateAddon(req.params.id, req.body); - if ('error' in result) return res.status(result.status!).json({ error: result.error }); + const addon = db.prepare('SELECT * FROM addons WHERE id = ?').get(req.params.id) as Addon | undefined; + const provider = db.prepare('SELECT * FROM photo_providers WHERE id = ?').get(req.params.id) as { id: string; name: string; description?: string | null; icon: string; enabled: number; config: string; sort_order: number } | undefined; + if (!addon && !provider) return res.status(404).json({ error: 'Addon not found' }); + const { enabled, config } = req.body; + if (addon) { + if (enabled !== undefined) db.prepare('UPDATE addons SET enabled = ? WHERE id = ?').run(enabled ? 1 : 0, req.params.id); + if (config !== undefined) db.prepare('UPDATE addons SET config = ? WHERE id = ?').run(JSON.stringify(config), req.params.id); + } else { + if (enabled !== undefined) db.prepare('UPDATE photo_providers SET enabled = ? WHERE id = ?').run(enabled ? 1 : 0, req.params.id); + if (config !== undefined) db.prepare('UPDATE photo_providers SET config = ? WHERE id = ?').run(JSON.stringify(config), req.params.id); + } + const updatedAddon = db.prepare('SELECT * FROM addons WHERE id = ?').get(req.params.id) as Addon | undefined; + const updatedProvider = db.prepare('SELECT * FROM photo_providers WHERE id = ?').get(req.params.id) as { id: string; name: string; description?: string | null; icon: string; enabled: number; config: string; sort_order: number } | undefined; + const updated = updatedAddon + ? { ...updatedAddon, enabled: !!updatedAddon.enabled, config: JSON.parse(updatedAddon.config || '{}') } + : updatedProvider + ? { + id: updatedProvider.id, + name: updatedProvider.name, + description: updatedProvider.description, + type: 'photo_provider', + icon: updatedProvider.icon, + enabled: !!updatedProvider.enabled, + config: JSON.parse(updatedProvider.config || '{}'), + sort_order: updatedProvider.sort_order, + } + : null; const authReq = req as AuthRequest; writeAudit({ userId: authReq.user.id, @@ -278,7 +357,7 @@ router.put('/addons/:id', (req: Request, res: Response) => { ip: getClientIp(req), details: result.auditDetails, }); - res.json({ addon: result.addon }); + res.json({ addon: updated }); }); // ── MCP Tokens ───────────────────────────────────────────────────────────── diff --git a/server/src/routes/immich.ts b/server/src/routes/immich.ts index 198b6e8..7ed3dd4 100644 --- a/server/src/routes/immich.ts +++ b/server/src/routes/immich.ts @@ -62,13 +62,41 @@ router.put('/settings', authenticate, async (req: Request, res: Response) => { router.get('/status', authenticate, async (req: Request, res: Response) => { const authReq = req as AuthRequest; - res.json(await getConnectionStatus(authReq.user.id)); + const creds = getImmichCredentials(authReq.user.id); + if (!creds) { + return res.json({ connected: false, error: 'Not configured' }); + } + try { + const resp = await fetch(`${creds.immich_url}/api/users/me`, { + headers: { 'x-api-key': creds.immich_api_key, 'Accept': 'application/json' }, + signal: AbortSignal.timeout(10000), + }); + if (!resp.ok) return res.json({ connected: false, error: `HTTP ${resp.status}` }); + const data = await resp.json() as { name?: string; email?: string }; + res.json({ connected: true, user: { name: data.name, email: data.email } }); + } catch (err: unknown) { + res.json({ connected: false, error: err instanceof Error ? err.message : 'Connection failed' }); + } }); +// Test connection with saved credentials router.post('/test', authenticate, async (req: Request, res: Response) => { - const { immich_url, immich_api_key } = req.body; - if (!immich_url || !immich_api_key) return res.json({ connected: false, error: 'URL and API key required' }); - res.json(await testConnection(immich_url, immich_api_key)); + const authReq = req as AuthRequest; + const creds = getImmichCredentials(authReq.user.id); + if (!creds) return res.json({ connected: false, error: 'No credentials configured' }); + const ssrf = await checkSsrf(creds.immich_url); + if (!ssrf.allowed) return res.json({ connected: false, error: ssrf.error ?? 'Invalid Immich URL' }); + try { + const resp = await fetch(`${creds.immich_url}/api/users/me`, { + headers: { 'x-api-key': creds.immich_api_key, 'Accept': 'application/json' }, + signal: AbortSignal.timeout(10000), + }); + if (!resp.ok) return res.json({ connected: false, error: `HTTP ${resp.status}` }); + const data = await resp.json() as { name?: string; email?: string }; + res.json({ connected: true, user: { name: data.name, email: data.email } }); + } catch (err: unknown) { + res.json({ connected: false, error: err instanceof Error ? err.message : 'Connection failed' }); + } }); // ── Browse Immich Library (for photo picker) ─────────────────────────────── @@ -88,55 +116,6 @@ router.post('/search', authenticate, async (req: Request, res: Response) => { res.json({ assets: result.assets }); }); -// ── Trip Photos (selected by user) ──────────────────────────────────────── - -router.get('/trips/:tripId/photos', authenticate, (req: Request, res: Response) => { - const authReq = req as AuthRequest; - const { tripId } = req.params; - if (!canAccessTrip(tripId, authReq.user.id)) return res.status(404).json({ error: 'Trip not found' }); - res.json({ photos: listTripPhotos(tripId, authReq.user.id) }); -}); - -router.post('/trips/:tripId/photos', authenticate, (req: Request, res: Response) => { - const authReq = req as AuthRequest; - const { tripId } = req.params; - if (!canAccessTrip(tripId, authReq.user.id)) return res.status(404).json({ error: 'Trip not found' }); - const { asset_ids, shared = true } = req.body; - - if (!Array.isArray(asset_ids) || asset_ids.length === 0) { - return res.status(400).json({ error: 'asset_ids required' }); - } - - const added = addTripPhotos(tripId, authReq.user.id, asset_ids, shared); - res.json({ success: true, added }); - broadcast(tripId, 'memories:updated', { userId: authReq.user.id }, req.headers['x-socket-id'] as string); - - // Notify trip members about shared photos - if (shared && added > 0) { - import('../services/notifications').then(({ notifyTripMembers }) => { - const tripInfo = db.prepare('SELECT title FROM trips WHERE id = ?').get(tripId) as { title: string } | undefined; - notifyTripMembers(Number(tripId), authReq.user.id, 'photos_shared', { trip: tripInfo?.title || 'Untitled', actor: authReq.user.email, count: String(added) }).catch(() => {}); - }); - } -}); - -router.delete('/trips/:tripId/photos/:assetId', authenticate, (req: Request, res: Response) => { - const authReq = req as AuthRequest; - if (!canAccessTrip(req.params.tripId, authReq.user.id)) return res.status(404).json({ error: 'Trip not found' }); - removeTripPhoto(req.params.tripId, authReq.user.id, req.params.assetId); - res.json({ success: true }); - broadcast(req.params.tripId, 'memories:updated', { userId: authReq.user.id }, req.headers['x-socket-id'] as string); -}); - -router.put('/trips/:tripId/photos/:assetId/sharing', authenticate, (req: Request, res: Response) => { - const authReq = req as AuthRequest; - if (!canAccessTrip(req.params.tripId, authReq.user.id)) return res.status(404).json({ error: 'Trip not found' }); - const { shared } = req.body; - togglePhotoSharing(req.params.tripId, authReq.user.id, req.params.assetId, shared); - res.json({ success: true }); - broadcast(req.params.tripId, 'memories:updated', { userId: authReq.user.id }, req.headers['x-socket-id'] as string); -}); - // ── Asset Details ────────────────────────────────────────────────────────── router.get('/assets/:assetId/info', authenticate, async (req: Request, res: Response) => { @@ -176,15 +155,27 @@ router.get('/assets/:assetId/original', authFromQuery, async (req: Request, res: router.get('/albums', authenticate, async (req: Request, res: Response) => { const authReq = req as AuthRequest; - const result = await listAlbums(authReq.user.id); - if (result.error) return res.status(result.status!).json({ error: result.error }); - res.json({ albums: result.albums }); -}); + const creds = getImmichCredentials(authReq.user.id); + if (!creds) return res.status(400).json({ error: 'Immich not configured' }); -router.get('/trips/:tripId/album-links', authenticate, (req: Request, res: Response) => { - const authReq = req as AuthRequest; - if (!canAccessTrip(req.params.tripId, authReq.user.id)) return res.status(404).json({ error: 'Trip not found' }); - res.json({ links: listAlbumLinks(req.params.tripId) }); + try { + const resp = await fetch(`${creds.immich_url}/api/albums`, { + headers: { 'x-api-key': creds.immich_api_key, 'Accept': 'application/json' }, + signal: AbortSignal.timeout(10000), + }); + if (!resp.ok) return res.status(resp.status).json({ error: 'Failed to fetch albums' }); + const albums = (await resp.json() as any[]).map((a: any) => ({ + id: a.id, + albumName: a.albumName, + assetCount: a.assetCount || 0, + startDate: a.startDate, + endDate: a.endDate, + albumThumbnailAssetId: a.albumThumbnailAssetId, + })); + res.json({ albums }); + } catch { + res.status(502).json({ error: 'Could not reach Immich' }); + } }); router.post('/trips/:tripId/album-links', authenticate, async (req: Request, res: Response) => { @@ -193,25 +184,54 @@ router.post('/trips/:tripId/album-links', authenticate, async (req: Request, res if (!canAccessTrip(tripId, authReq.user.id)) return res.status(404).json({ error: 'Trip not found' }); const { album_id, album_name } = req.body; if (!album_id) return res.status(400).json({ error: 'album_id required' }); - const result = createAlbumLink(tripId, authReq.user.id, album_id, album_name); - if (!result.success) return res.status(400).json({ error: result.error }); - res.json({ success: true }); -}); -router.delete('/trips/:tripId/album-links/:linkId', authenticate, (req: Request, res: Response) => { - const authReq = req as AuthRequest; - deleteAlbumLink(req.params.linkId, req.params.tripId, authReq.user.id); - res.json({ success: true }); + try { + db.prepare( + 'INSERT OR IGNORE INTO trip_album_links (trip_id, user_id, provider, album_id, album_name) VALUES (?, ?, ?, ?, ?)' + ).run(tripId, authReq.user.id, 'immich', album_id, album_name || ''); + res.json({ success: true }); + } catch (err: any) { + res.status(400).json({ error: 'Album already linked' }); + } }); router.post('/trips/:tripId/album-links/:linkId/sync', authenticate, async (req: Request, res: Response) => { const authReq = req as AuthRequest; const { tripId, linkId } = req.params; - const result = await syncAlbumAssets(tripId, linkId, authReq.user.id); - if (result.error) return res.status(result.status!).json({ error: result.error }); - res.json({ success: true, added: result.added, total: result.total }); - if (result.added! > 0) { - broadcast(tripId, 'memories:updated', { userId: authReq.user.id }, req.headers['x-socket-id'] as string); + + const link = db.prepare("SELECT * FROM trip_album_links WHERE id = ? AND trip_id = ? AND user_id = ? AND provider = 'immich'") + .get(linkId, tripId, authReq.user.id) as any; + if (!link) return res.status(404).json({ error: 'Album link not found' }); + + const creds = getImmichCredentials(authReq.user.id); + if (!creds) return res.status(400).json({ error: 'Immich not configured' }); + + try { + const resp = await fetch(`${creds.immich_url}/api/albums/${link.album_id}`, { + headers: { 'x-api-key': creds.immich_api_key, 'Accept': 'application/json' }, + signal: AbortSignal.timeout(15000), + }); + if (!resp.ok) return res.status(resp.status).json({ error: 'Failed to fetch album' }); + const albumData = await resp.json() as { assets?: any[] }; + const assets = (albumData.assets || []).filter((a: any) => a.type === 'IMAGE'); + + const insert = db.prepare( + "INSERT OR IGNORE INTO trip_photos (trip_id, user_id, asset_id, provider, shared) VALUES (?, ?, ?, 'immich', 1)" + ); + let added = 0; + for (const asset of assets) { + const r = insert.run(tripId, authReq.user.id, asset.id); + if (r.changes > 0) added++; + } + + db.prepare('UPDATE trip_album_links SET last_synced_at = CURRENT_TIMESTAMP WHERE id = ?').run(linkId); + + res.json({ success: true, added, total: assets.length }); + if (added > 0) { + broadcast(tripId, 'memories:updated', { userId: authReq.user.id }, req.headers['x-socket-id'] as string); + } + } catch { + res.status(502).json({ error: 'Could not reach Immich' }); } }); diff --git a/server/src/routes/memories.ts b/server/src/routes/memories.ts new file mode 100644 index 0000000..d84925d --- /dev/null +++ b/server/src/routes/memories.ts @@ -0,0 +1,182 @@ +import express, { Request, Response } from 'express'; +import { db, canAccessTrip } from '../db/database'; +import { authenticate } from '../middleware/auth'; +import { broadcast } from '../websocket'; +import { AuthRequest } from '../types'; + +const router = express.Router(); + + +router.get('/trips/:tripId/photos', authenticate, (req: Request, res: Response) => { + const authReq = req as AuthRequest; + const { tripId } = req.params; + + if (!canAccessTrip(tripId, authReq.user.id)) { + return res.status(404).json({ error: 'Trip not found' }); + } + + const photos = db.prepare(` + SELECT tp.asset_id, tp.provider, tp.user_id, tp.shared, tp.added_at, + u.username, u.avatar + FROM trip_photos tp + JOIN users u ON tp.user_id = u.id + WHERE tp.trip_id = ? + AND (tp.user_id = ? OR tp.shared = 1) + ORDER BY tp.added_at ASC + `).all(tripId, authReq.user.id) as any[]; + + res.json({ photos }); +}); + +router.get('/trips/:tripId/album-links', authenticate, (req: Request, res: Response) => { + const authReq = req as AuthRequest; + const { tripId } = req.params; + + if (!canAccessTrip(tripId, authReq.user.id)) { + return res.status(404).json({ error: 'Trip not found' }); + } + + const links = db.prepare(` + SELECT tal.id, + tal.trip_id, + tal.user_id, + tal.provider, + tal.album_id, + tal.album_name, + tal.sync_enabled, + tal.last_synced_at, + tal.created_at, + u.username + FROM trip_album_links tal + JOIN users u ON tal.user_id = u.id + WHERE tal.trip_id = ? + ORDER BY tal.created_at ASC + `).all(tripId); + + res.json({ links }); +}); + +router.delete('/trips/:tripId/album-links/:linkId', authenticate, (req: Request, res: Response) => { + const authReq = req as AuthRequest; + const { tripId, linkId } = req.params; + + if (!canAccessTrip(tripId, authReq.user.id)) { + return res.status(404).json({ error: 'Trip not found' }); + } + + db.prepare('DELETE FROM trip_album_links WHERE id = ? AND trip_id = ? AND user_id = ?') + .run(linkId, tripId, authReq.user.id); + + res.json({ success: true }); + broadcast(tripId, 'memories:updated', { userId: authReq.user.id }, req.headers['x-socket-id'] as string); +}); + +router.post('/trips/:tripId/photos', authenticate, (req: Request, res: Response) => { + const authReq = req as AuthRequest; + const { tripId } = req.params; + const provider = String(req.body?.provider || '').toLowerCase(); + const { shared = true } = req.body; + const assetIdsRaw = req.body?.asset_ids; + + if (!canAccessTrip(tripId, authReq.user.id)) { + return res.status(404).json({ error: 'Trip not found' }); + } + + if (!provider) { + return res.status(400).json({ error: 'provider is required' }); + } + + if (!Array.isArray(assetIdsRaw) || assetIdsRaw.length === 0) { + return res.status(400).json({ error: 'asset_ids required' }); + } + + const insert = db.prepare( + 'INSERT OR IGNORE INTO trip_photos (trip_id, user_id, asset_id, provider, shared) VALUES (?, ?, ?, ?, ?)' + ); + + let added = 0; + for (const raw of assetIdsRaw) { + const assetId = String(raw || '').trim(); + if (!assetId) continue; + const result = insert.run(tripId, authReq.user.id, assetId, provider, shared ? 1 : 0); + if (result.changes > 0) added++; + } + + res.json({ success: true, added }); + broadcast(tripId, 'memories:updated', { userId: authReq.user.id }, req.headers['x-socket-id'] as string); + + if (shared && added > 0) { + import('../services/notifications').then(({ notifyTripMembers }) => { + const tripInfo = db.prepare('SELECT title FROM trips WHERE id = ?').get(tripId) as { title: string } | undefined; + notifyTripMembers(Number(tripId), authReq.user.id, 'photos_shared', { + trip: tripInfo?.title || 'Untitled', + actor: authReq.user.username || authReq.user.email, + count: String(added), + }).catch(() => {}); + }); + } +}); + +router.delete('/trips/:tripId/photos', authenticate, (req: Request, res: Response) => { + const authReq = req as AuthRequest; + const { tripId } = req.params; + const provider = String(req.body?.provider || '').toLowerCase(); + const assetId = String(req.body?.asset_id || ''); + + if (!assetId) { + return res.status(400).json({ error: 'asset_id is required' }); + } + + if (!provider) { + return res.status(400).json({ error: 'provider is required' }); + } + + if (!canAccessTrip(tripId, authReq.user.id)) { + return res.status(404).json({ error: 'Trip not found' }); + } + + db.prepare(` + DELETE FROM trip_photos + WHERE trip_id = ? + AND user_id = ? + AND asset_id = ? + AND provider = ? + `).run(tripId, authReq.user.id, assetId, provider); + + res.json({ success: true }); + broadcast(tripId, 'memories:updated', { userId: authReq.user.id }, req.headers['x-socket-id'] as string); +}); + +router.put('/trips/:tripId/photos/sharing', authenticate, (req: Request, res: Response) => { + const authReq = req as AuthRequest; + const { tripId } = req.params; + const provider = String(req.body?.provider || '').toLowerCase(); + const assetId = String(req.body?.asset_id || ''); + const { shared } = req.body; + + if (!assetId) { + return res.status(400).json({ error: 'asset_id is required' }); + } + + if (!provider) { + return res.status(400).json({ error: 'provider is required' }); + } + + if (!canAccessTrip(tripId, authReq.user.id)) { + return res.status(404).json({ error: 'Trip not found' }); + } + + db.prepare(` + UPDATE trip_photos + SET shared = ? + WHERE trip_id = ? + AND user_id = ? + AND asset_id = ? + AND provider = ? + `).run(shared ? 1 : 0, tripId, authReq.user.id, assetId, provider); + + res.json({ success: true }); + broadcast(tripId, 'memories:updated', { userId: authReq.user.id }, req.headers['x-socket-id'] as string); +}); + +export default router; From 78a91ccb95873552d49b99302bbf38432f1b3854 Mon Sep 17 00:00:00 2001 From: Marek Maslowski Date: Fri, 3 Apr 2026 02:58:22 +0200 Subject: [PATCH 03/57] feat(integrations): add synology photos support --- server/src/db/migrations.ts | 59 +++ server/src/db/schema.ts | 4 + server/src/db/seeds.ts | 17 + server/src/index.ts | 2 + server/src/routes/auth.ts | 10 +- server/src/routes/synology.ts | 610 +++++++++++++++++++++++++ server/src/services/ephemeralTokens.ts | 1 + 7 files changed, 700 insertions(+), 3 deletions(-) create mode 100644 server/src/routes/synology.ts diff --git a/server/src/db/migrations.ts b/server/src/db/migrations.ts index 8e01979..d14d4a8 100644 --- a/server/src/db/migrations.ts +++ b/server/src/db/migrations.ts @@ -632,6 +632,65 @@ function runMigrations(db: Database.Database): void { } } }, + () => { + // Add Synology credential columns for existing databases + try { db.exec('ALTER TABLE users ADD COLUMN synology_url TEXT'); } catch (err: any) { if (!err.message?.includes('duplicate column name')) throw err; } + try { db.exec('ALTER TABLE users ADD COLUMN synology_username TEXT'); } catch (err: any) { if (!err.message?.includes('duplicate column name')) throw err; } + try { db.exec('ALTER TABLE users ADD COLUMN synology_password TEXT'); } catch (err: any) { if (!err.message?.includes('duplicate column name')) throw err; } + try { db.exec('ALTER TABLE users ADD COLUMN synology_sid TEXT'); } catch (err: any) { if (!err.message?.includes('duplicate column name')) throw err; } + }, + () => { + // Seed Synology Photos provider and fields in existing databases + try { + db.prepare(` + INSERT INTO photo_providers (id, name, description, icon, enabled, config, sort_order) + VALUES (?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(id) DO UPDATE SET + name = excluded.name, + description = excluded.description, + icon = excluded.icon, + enabled = excluded.enabled, + config = excluded.config, + sort_order = excluded.sort_order + `).run( + 'synologyphotos', + 'Synology Photos', + 'Synology Photos integration with separate account settings', + 'Image', + 0, + JSON.stringify({ + settings_get: '/integrations/synologyphotos/settings', + settings_put: '/integrations/synologyphotos/settings', + status_get: '/integrations/synologyphotos/status', + test_get: '/integrations/synologyphotos/status', + }), + 1, + ); + } catch (err: any) { + if (!err.message?.includes('no such table')) throw err; + } + try { + const insertField = db.prepare(` + INSERT INTO photo_provider_fields + (provider_id, field_key, label, input_type, placeholder, required, secret, settings_key, payload_key, sort_order) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(provider_id, field_key) DO UPDATE SET + label = excluded.label, + input_type = excluded.input_type, + placeholder = excluded.placeholder, + required = excluded.required, + secret = excluded.secret, + settings_key = excluded.settings_key, + payload_key = excluded.payload_key, + sort_order = excluded.sort_order + `); + insertField.run('synologyphotos', 'synology_url', 'Server URL', 'url', 'https://synology.example.com', 1, 0, 'synology_url', 'synology_url', 0); + insertField.run('synologyphotos', 'synology_username', 'Username', 'text', 'Username', 1, 0, 'synology_username', 'synology_username', 1); + insertField.run('synologyphotos', 'synology_password', 'Password', 'password', 'Password', 1, 1, null, 'synology_password', 2); + } catch (err: any) { + if (!err.message?.includes('no such table')) throw err; + } + }, ]; if (currentVersion < migrations.length) { diff --git a/server/src/db/schema.ts b/server/src/db/schema.ts index 8fe5739..9e243b6 100644 --- a/server/src/db/schema.ts +++ b/server/src/db/schema.ts @@ -18,6 +18,10 @@ function createTables(db: Database.Database): void { mfa_enabled INTEGER DEFAULT 0, mfa_secret TEXT, mfa_backup_codes TEXT, + synology_url TEXT, + synology_username TEXT, + synology_password TEXT, + synology_sid TEXT, must_change_password INTEGER DEFAULT 0, created_at DATETIME DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME DEFAULT CURRENT_TIMESTAMP diff --git a/server/src/db/seeds.ts b/server/src/db/seeds.ts index f875794..8035d21 100644 --- a/server/src/db/seeds.ts +++ b/server/src/db/seeds.ts @@ -108,6 +108,20 @@ function seedAddons(db: Database.Database): void { test_post: '/integrations/immich/test', }), }, + { + id: 'synologyphotos', + name: 'Synology Photos', + description: 'Synology Photos integration with separate account settings', + icon: 'Image', + enabled: 0, + sort_order: 1, + config: JSON.stringify({ + settings_get: '/integrations/synologyphotos/settings', + settings_put: '/integrations/synologyphotos/settings', + status_get: '/integrations/synologyphotos/status', + test_get: '/integrations/synologyphotos/status', + }), + }, ]; const insertProvider = db.prepare('INSERT OR IGNORE INTO photo_providers (id, name, description, icon, enabled, config, sort_order) VALUES (?, ?, ?, ?, ?, ?, ?)'); for (const p of providerRows) insertProvider.run(p.id, p.name, p.description, p.icon, p.enabled, p.config, p.sort_order); @@ -115,6 +129,9 @@ function seedAddons(db: Database.Database): void { const providerFields = [ { provider_id: 'immich', field_key: 'immich_url', label: 'Immich URL', input_type: 'url', placeholder: 'https://immich.example.com', required: 1, secret: 0, settings_key: 'immich_url', payload_key: 'immich_url', sort_order: 0 }, { provider_id: 'immich', field_key: 'immich_api_key', label: 'API Key', input_type: 'password', placeholder: 'API Key', required: 1, secret: 1, settings_key: null, payload_key: 'immich_api_key', sort_order: 1 }, + { provider_id: 'synologyphotos', field_key: 'synology_url', label: 'Server URL', input_type: 'url', placeholder: 'https://synology.example.com', required: 1, secret: 0, settings_key: 'synology_url', payload_key: 'synology_url', sort_order: 0 }, + { provider_id: 'synologyphotos', field_key: 'synology_username', label: 'Username', input_type: 'text', placeholder: 'Username', required: 1, secret: 0, settings_key: 'synology_username', payload_key: 'synology_username', sort_order: 1 }, + { provider_id: 'synologyphotos', field_key: 'synology_password', label: 'Password', input_type: 'password', placeholder: 'Password', required: 1, secret: 1, settings_key: null, payload_key: 'synology_password', sort_order: 2 }, ]; const insertProviderField = db.prepare('INSERT OR IGNORE INTO photo_provider_fields (provider_id, field_key, label, input_type, placeholder, required, secret, settings_key, payload_key, sort_order) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)'); for (const f of providerFields) { diff --git a/server/src/index.ts b/server/src/index.ts index 533d92c..d1d682d 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -281,6 +281,8 @@ import atlasRoutes from './routes/atlas'; app.use('/api/addons/atlas', atlasRoutes); import immichRoutes from './routes/immich'; app.use('/api/integrations/immich', immichRoutes); +const synologyRoutes = require('./routes/synology').default; +app.use('/api/integrations/synologyphotos', synologyRoutes); import memoriesRoutes from './routes/memories'; app.use('/api/integrations/memories', memoriesRoutes); diff --git a/server/src/routes/auth.ts b/server/src/routes/auth.ts index eb7463d..29aadd4 100644 --- a/server/src/routes/auth.ts +++ b/server/src/routes/auth.ts @@ -315,9 +315,13 @@ router.post('/ws-token', authenticate, (req: Request, res: Response) => { // Short-lived single-use token for direct resource URLs router.post('/resource-token', authenticate, (req: Request, res: Response) => { const authReq = req as AuthRequest; - const result = createResourceToken(authReq.user.id, req.body.purpose); - if (result.error) return res.status(result.status!).json({ error: result.error }); - res.json({ token: result.token }); + const { purpose } = req.body as { purpose?: string }; + if (purpose !== 'download' && purpose !== 'immich' && purpose !== 'synologyphotos') { + return res.status(400).json({ error: 'Invalid purpose' }); + } + const token = createEphemeralToken(authReq.user.id, purpose); + if (!token) return res.status(503).json({ error: 'Service unavailable' }); + res.json({ token }); }); export default router; diff --git a/server/src/routes/synology.ts b/server/src/routes/synology.ts new file mode 100644 index 0000000..9e98fcf --- /dev/null +++ b/server/src/routes/synology.ts @@ -0,0 +1,610 @@ +import express, { NextFunction, Request, Response } from 'express'; +import { Readable } from 'node:stream'; +import { pipeline } from 'node:stream/promises'; +import { db, canAccessTrip } from '../db/database'; +import { authenticate } from '../middleware/auth'; +import { broadcast } from '../websocket'; +import { AuthRequest } from '../types'; +import { maybe_encrypt_api_key, decrypt_api_key } from '../services/apiKeyCrypto'; +import { consumeEphemeralToken } from '../services/ephemeralTokens'; + +const router = express.Router(); + +function copyProxyHeaders(resp: Response, upstream: globalThis.Response, headerNames: string[]): void { + for (const headerName of headerNames) { + const value = upstream.headers.get(headerName); + if (value) { + resp.set(headerName, value); + } + } +} + +// Helper: Get Synology credentials from users table +function getSynologyCredentials(userId: number) { + try { + const user = db.prepare('SELECT synology_url, synology_username, synology_password FROM users WHERE id = ?').get(userId) as any; + if (!user?.synology_url || !user?.synology_username || !user?.synology_password) return null; + return { + synology_url: user.synology_url as string, + synology_username: user.synology_username as string, + synology_password: decrypt_api_key(user.synology_password) as string, + }; + } catch { + return null; + } +} + +// Helper: Get cached SID from settings or users table +function getCachedSynologySID(userId: number) { + try { + const row = db.prepare('SELECT synology_sid FROM users WHERE id = ?').get(userId) as any; + return row?.synology_sid || null; + } catch { + return null; + } +} + +// Helper: Cache SID in users table +function cacheSynologySID(userId: number, sid: string) { + try { + db.prepare('UPDATE users SET synology_sid = ? WHERE id = ?').run(sid, userId); + } catch (err) { + // Ignore if columns don't exist yet + } +} + +// Helper: Get authenticated session + +interface SynologySession { + success: boolean; + sid?: string; + error?: { code: number; message?: string }; +} + +async function getSynologySession(userId: number): Promise { + // Check for cached SID + const cachedSid = getCachedSynologySID(userId); + if (cachedSid) { + return { success: true, sid: cachedSid }; + } + + const creds = getSynologyCredentials(userId); + // Login with credentials + if (!creds) { + return { success: false, error: { code: 400, message: 'Invalid Synology credentials' } }; + } + const endpoint = prepareSynologyEndpoint(creds.synology_url); + + const body = new URLSearchParams({ + api: 'SYNO.API.Auth', + method: 'login', + version: '3', + account: creds.synology_username, + passwd: creds.synology_password, + }); + + const resp = await fetch(endpoint, { + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8', + }, + body, + signal: AbortSignal.timeout(30000), + }); + + if (!resp.ok) { + return { success: false, error: { code: resp.status, message: 'Failed to authenticate with Synology' } }; + } + + const data = await resp.json() as { success: boolean; data?: { sid?: string } }; + + if (data.success && data.data?.sid) { + const sid = data.data.sid; + cacheSynologySID(userId, sid); + return { success: true, sid }; + } + + return { success: false, error: { code: 500, message: 'Failed to get Synology session' } }; +} + +// Helper: Clear cached SID + +function clearSynologySID(userId: number): void { + try { + db.prepare('UPDATE users SET synology_sid = NULL WHERE id = ?').run(userId); + } catch { + // Ignore if columns don't exist yet + } +} + +interface ApiCallParams { + api: string; + method: string; + version?: number; + [key: string]: any; +} + +interface SynologyApiResponse { + success: boolean; + data?: T; + error?: { code: number, message?: string }; +} + +function prepareSynologyEndpoint(url: string): string { + url = url.replace(/\/$/, ''); + if (!/^https?:\/\//.test(url)) { + url = `https://${url}`; + } + return `${url}/photo/webapi/entry.cgi`; +} + +function splitPackedSynologyId(rawId: string): { id: string; cacheKey: string; assetId: string } { + const id = rawId.split('_')[0]; + return { id: id, cacheKey: rawId, assetId: rawId }; +} + +function transformSynologyPhoto(item: any): any { + const address = item.additional?.address || {}; + return { + id: item.additional?.thumbnail?.cache_key, + takenAt: item.time ? new Date(item.time * 1000).toISOString() : null, + city: address.city || null, + country: address.country || null, + }; +} + +async function callSynologyApi(userId: number, params: ApiCallParams): Promise> { + try { + const creds = getSynologyCredentials(userId); + if (!creds) { + return { success: false, error: { code: 400, message: 'Synology not configured' } }; + } + const endpoint = prepareSynologyEndpoint(creds.synology_url); + + + const body = new URLSearchParams(); + for (const [key, value] of Object.entries(params)) { + if (value === undefined || value === null) continue; + body.append(key, typeof value === 'object' ? JSON.stringify(value) : String(value)); + } + + const sid = await getSynologySession(userId); + if (!sid.success || !sid.sid) { + return { success: false, error: sid.error || { code: 500, message: 'Failed to get Synology session' } }; + } + body.append('_sid', sid.sid); + + const resp = await fetch(endpoint, { + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8', + }, + body, + signal: AbortSignal.timeout(30000), + }); + + + if (!resp.ok) { + const text = await resp.text(); + return { success: false, error: { code: resp.status, message: text } }; + } + + const result = await resp.json() as SynologyApiResponse; + if (!result.success && result.error?.code === 119) { + clearSynologySID(userId); + return callSynologyApi(userId, params); + } + return result; + } catch (err) { + return { success: false, error: { code: -1, message: err instanceof Error ? err.message : 'Unknown error' } }; + } +} + +// Settings +router.get('/settings', authenticate, (req: Request, res: Response) => { + const authReq = req as AuthRequest; + const creds = getSynologyCredentials(authReq.user.id); + res.json({ + synology_url: creds?.synology_url || '', + synology_username: creds?.synology_username || '', + connected: !!(creds?.synology_url && creds?.synology_username), + }); +}); + +router.put('/settings', authenticate, (req: Request, res: Response) => { + const authReq = req as AuthRequest; + const { synology_url, synology_username, synology_password } = req.body; + + const url = String(synology_url || '').trim(); + const username = String(synology_username || '').trim(); + const password = String(synology_password || '').trim(); + + if (!url || !username) { + return res.status(400).json({ error: 'URL and username are required' }); + } + + const existing = db.prepare('SELECT synology_password FROM users WHERE id = ?').get(authReq.user.id) as { synology_password?: string | null } | undefined; + const existingEncryptedPassword = existing?.synology_password || null; + + // First-time setup requires password; later updates may keep existing password. + if (!password && !existingEncryptedPassword) { + return res.status(400).json({ error: 'Password is required' }); + } + + try { + db.prepare('UPDATE users SET synology_url = ?, synology_username = ?, synology_password = ? WHERE id = ?').run( + url, + username, + password ? maybe_encrypt_api_key(password) : existingEncryptedPassword, + authReq.user.id + ); + } catch (err) { + return res.status(400).json({ error: 'Failed to save settings' }); + } + + clearSynologySID(authReq.user.id); + res.json({ success: true }); +}); + +// Status +router.get('/status', authenticate, async (req: Request, res: Response) => { + const authReq = req as AuthRequest; + + try { + const sid = await getSynologySession(authReq.user.id); + if (!sid.success || !sid.sid) { + return res.json({ connected: false, error: 'Authentication failed' }); + } + + const user = db.prepare('SELECT synology_username FROM users WHERE id = ?').get(authReq.user.id) as any; + res.json({ connected: true, user: { username: user.synology_username } }); + } catch (err: unknown) { + res.json({ connected: false, error: err instanceof Error ? err.message : 'Connection failed' }); + } +}); + +// Album linking parity with Immich +router.get('/albums', authenticate, async (req: Request, res: Response) => { + const authReq = req as AuthRequest; + try { + const result = await callSynologyApi<{ list: any[] }>(authReq.user.id, { + api: 'SYNO.Foto.Browse.Album', + method: 'list', + version: 4, + offset: 0, + limit: 100, + }); + + if (!result.success || !result.data) { + return res.status(502).json({ error: result.error?.message || 'Failed to fetch albums' }); + } + + const albums = (result.data.list || []).map((a: any) => ({ + id: String(a.id), + albumName: a.name || '', + assetCount: a.item_count || 0, + })); + + res.json({ albums }); + } catch (err: unknown) { + res.status(502).json({ error: err instanceof Error ? err.message : 'Could not reach Synology' }); + } +}); + +router.post('/trips/:tripId/album-links', authenticate, (req: Request, res: Response) => { + const authReq = req as AuthRequest; + const { tripId } = req.params; + if (!canAccessTrip(tripId, authReq.user.id)) return res.status(404).json({ error: 'Trip not found' }); + const { album_id, album_name } = req.body; + if (!album_id) return res.status(400).json({ error: 'album_id required' }); + + try { + db.prepare( + 'INSERT OR IGNORE INTO trip_album_links (trip_id, user_id, provider, album_id, album_name) VALUES (?, ?, ?, ?, ?)' + ).run(tripId, authReq.user.id, 'synologyphotos', String(album_id), album_name || ''); + res.json({ success: true }); + } catch { + res.status(400).json({ error: 'Album already linked' }); + } +}); + +router.post('/trips/:tripId/album-links/:linkId/sync', authenticate, async (req: Request, res: Response) => { + const authReq = req as AuthRequest; + const { tripId, linkId } = req.params; + + const link = db.prepare("SELECT * FROM trip_album_links WHERE id = ? AND trip_id = ? AND user_id = ? AND provider = 'synologyphotos'") + .get(linkId, tripId, authReq.user.id) as any; + if (!link) return res.status(404).json({ error: 'Album link not found' }); + + try { + const allItems: any[] = []; + const pageSize = 1000; + let offset = 0; + + while (true) { + const result = await callSynologyApi<{ list: any[] }>(authReq.user.id, { + api: 'SYNO.Foto.Browse.Item', + method: 'list', + version: 1, + album_id: Number(link.album_id), + offset, + limit: pageSize, + additional: ['thumbnail'], + }); + + if (!result.success || !result.data) { + return res.status(502).json({ error: result.error?.message || 'Failed to fetch album' }); + } + + const items = result.data.list || []; + allItems.push(...items); + if (items.length < pageSize) break; + offset += pageSize; + } + + const insert = db.prepare( + "INSERT OR IGNORE INTO trip_photos (trip_id, user_id, asset_id, provider, shared) VALUES (?, ?, ?, 'synologyphotos', 1)" + ); + + let added = 0; + for (const item of allItems) { + const transformed = transformSynologyPhoto(item); + const assetId = String(transformed?.id || '').trim(); + if (!assetId) continue; + const r = insert.run(tripId, authReq.user.id, assetId); + if (r.changes > 0) added++; + } + + db.prepare('UPDATE trip_album_links SET last_synced_at = CURRENT_TIMESTAMP WHERE id = ?').run(linkId); + + res.json({ success: true, added, total: allItems.length }); + if (added > 0) { + broadcast(tripId, 'memories:updated', { userId: authReq.user.id }, req.headers['x-socket-id'] as string); + } + } catch (err: unknown) { + res.status(502).json({ error: err instanceof Error ? err.message : 'Could not reach Synology' }); + } +}); + +// Search +router.post('/search', authenticate, async (req: Request, res: Response) => { + const authReq = req as AuthRequest; + let { from, to, offset = 0, limit = 300 } = req.body; + + try { + const params: any = { + api: 'SYNO.Foto.Search.Search', + method: 'list_item', + version: 1, + offset, + limit, + keyword: '.', + additional: ['thumbnail', 'address'], + }; + + if (from || to) { + if (from) { + params.start_time = Math.floor(new Date(from).getTime() / 1000); + } + if (to) { + params.end_time = Math.floor(new Date(to).getTime() / 1000) + 86400; // Include entire end day + } + } + + + const result = await callSynologyApi<{ list: any[]; total: number }>(authReq.user.id, params); + + if (!result.success || !result.data) { + return res.status(502).json({ error: result.error?.message || 'Failed to fetch album photos' }); + } + + const allItems = (result.data.list || []); + const total = allItems.length; + + const assets = allItems.map((item: any) => transformSynologyPhoto(item)); + + res.json({ + assets, + total, + hasMore: total == limit, + }); + } catch (err: unknown) { + res.status(502).json({ error: err instanceof Error ? err.message : 'Could not reach Synology' }); + } +}); + +// Proxy Synology Assets + +// Asset info endpoint (returns metadata, not image) +router.get('/assets/:photoId/info', authenticate, async (req: Request, res: Response) => { + const authReq = req as AuthRequest; + const { photoId } = req.params; + const parsedId = splitPackedSynologyId(photoId); + const { userId } = req.query; + + const targetUserId = userId ? Number(userId) : authReq.user.id; + + try { + const result = await callSynologyApi(targetUserId, { + api: 'SYNO.Foto.Browse.Item', + method: 'get', + version: 2, + id: Number(parsedId.id), + additional: ['thumbnail', 'resolution', 'exif', 'gps', 'address', 'orientation', 'description'], + }); + if (!result.success || !result.data) { + return res.status(404).json({ error: 'Photo not found' }); + } + + + const exif = result.data.additional?.exif || {}; + const address = result.data.additional?.address || {}; + const gps = result.data.additional?.gps || {}; + res.json({ + id: result.data.id, + takenAt: result.data.time ? new Date(result.data.time * 1000).toISOString() : null, + width: result.data.additional?.resolution?.width || null, + height: result.data.additional?.resolution?.height || null, + camera: exif.model || null, + lens: exif.lens_model || null, + focalLength: exif.focal_length ? `${exif.focal_length}mm` : null, + aperture: exif.f_number ? `f/${exif.f_number}` : null, + shutter: exif.exposure_time || null, + iso: exif.iso_speed_ratings || null, + city: address.city || null, + state: address.state || null, + country: address.country || null, + lat: gps.latitude || null, + lng: gps.longitude || null, + orientation: result.data.additional?.orientation || null, + description: result.data.additional?.description || null, + fileSize: result.data.filesize || null, + fileName: result.data.filename || null, + }); + } catch (err: unknown) { + res.status(502).json({ error: err instanceof Error ? err.message : 'Could not reach Synology'}); + } +}); + +// Middleware: Accept ephemeral token from query param for tags +function authFromQuery(req: Request, res: Response, next: NextFunction) { + const queryToken = req.query.token as string | undefined; + if (queryToken) { + const userId = consumeEphemeralToken(queryToken, 'synologyphotos'); + if (!userId) return res.status(401).send('Invalid or expired token'); + const user = db.prepare('SELECT id, username, email, role, mfa_enabled FROM users WHERE id = ?').get(userId) as any; + if (!user) return res.status(401).send('User not found'); + (req as AuthRequest).user = user; + return next(); + } + return (authenticate as any)(req, res, next); +} + +router.get('/assets/:photoId/thumbnail', authFromQuery, async (req: Request, res: Response) => { + const authReq = req as AuthRequest; + const { photoId } = req.params; + const parsedId = splitPackedSynologyId(photoId); + const { userId, cacheKey, size = 'sm' } = req.query; + + const targetUserId = userId ? Number(userId) : authReq.user.id; + + const creds = getSynologyCredentials(targetUserId); + if (!creds) { + return res.status(404).send('Not found'); + } + + try { + const sid = await getSynologySession(authReq.user.id); + if (!sid.success && !sid.sid) { + return res.status(401).send('Authentication failed'); + } + + let resolvedCacheKey = cacheKey ? String(cacheKey) : parsedId.cacheKey; + if (!resolvedCacheKey) { + const row = db.prepare(` + SELECT asset_id FROM trip_photos + WHERE user_id = ? AND (asset_id = ? OR asset_id = ? OR asset_id LIKE ? OR asset_id LIKE ?) + ORDER BY id DESC LIMIT 1 + `).get(targetUserId, parsedId.assetId, parsedId.id, `${parsedId.id}_%`, `${parsedId.id}::%`) as { asset_id?: string } | undefined; + const packed = row?.asset_id || ''; + if (packed) { + resolvedCacheKey = splitPackedSynologyId(packed).cacheKey; + } + } + if (!resolvedCacheKey) return res.status(404).send('Missing cache key for thumbnail'); + + const params = new URLSearchParams({ + api: 'SYNO.Foto.Thumbnail', + method: 'get', + version: '2', + mode: 'download', + id: String(parsedId.id), + type: 'unit', + size: String(size), + cache_key: resolvedCacheKey, + _sid: sid.sid, + }); + const url = prepareSynologyEndpoint(creds.synology_url) + '?' + params.toString(); + const resp = await fetch(url, { + signal: AbortSignal.timeout(30000), + }); + + if (!resp.ok) { + return res.status(resp.status).send('Failed'); + } + + res.status(resp.status); + copyProxyHeaders(res, resp, ['content-type', 'cache-control', 'content-length', 'content-disposition']); + res.set('Content-Type', resp.headers.get('content-type') || 'image/jpeg'); + res.set('Cache-Control', resp.headers.get('cache-control') || 'public, max-age=86400'); + + if (!resp.body) { + return res.end(); + } + + await pipeline(Readable.fromWeb(resp.body), res); + } catch (err: unknown) { + if (res.headersSent) { + return; + } + res.status(502).send('Proxy error: ' + (err instanceof Error ? err.message : String(err))); + } +}); + + +router.get('/assets/download', authFromQuery, async (req: Request, res: Response) => { + const authReq = req as AuthRequest; + const { userId, cacheKey, unitIds } = req.query; + + const targetUserId = userId ? Number(userId) : authReq.user.id; + + const creds = getSynologyCredentials(targetUserId); + if (!creds) { + return res.status(404).send('Not found'); + } + + try { + const sid = await getSynologySession(authReq.user.id); + if (!sid.success && !sid.sid) { + return res.status(401).send('Authentication failed'); + } + + const params = new URLSearchParams({ + api: 'SYNO.Foto.Download', + method: 'download', + version: '2', + cache_key: String(cacheKey), + unit_id: "[" + String(unitIds) + "]", + _sid: sid.sid, + }); + + const url = prepareSynologyEndpoint(creds.synology_url) + '?' + params.toString(); + const resp = await fetch(url, { + signal: AbortSignal.timeout(30000), + }); + + if (!resp.ok) { + const body = await resp.text(); + return res.status(resp.status).send('Failed: ' + body); + } + + res.status(resp.status); + copyProxyHeaders(res, resp, ['content-type', 'cache-control', 'content-length', 'content-disposition']); + res.set('Content-Type', resp.headers.get('content-type') || 'application/octet-stream'); + res.set('Cache-Control', resp.headers.get('cache-control') || 'public, max-age=86400'); + + if (!resp.body) { + return res.end(); + } + + await pipeline(Readable.fromWeb(resp.body), res); + } catch (err: unknown) { + if (res.headersSent) { + return; + } + res.status(502).send('Proxy error: ' + (err instanceof Error ? err.message : String(err))); + } +}); + + +export default router; diff --git a/server/src/services/ephemeralTokens.ts b/server/src/services/ephemeralTokens.ts index 0d1c12b..f880951 100644 --- a/server/src/services/ephemeralTokens.ts +++ b/server/src/services/ephemeralTokens.ts @@ -4,6 +4,7 @@ const TTL: Record = { ws: 30_000, download: 60_000, immich: 60_000, + synologyphotos: 60_000, }; const MAX_STORE_SIZE = 10_000; From f7c965bc6bfb900f0c392d5dc5277b2be60318c1 Mon Sep 17 00:00:00 2001 From: Marek Maslowski Date: Fri, 3 Apr 2026 03:08:15 +0200 Subject: [PATCH 04/57] returning test connectioon button to original intend --- server/src/db/migrations.ts | 2 +- server/src/db/seeds.ts | 2 +- server/src/routes/immich.ts | 15 ++++++------ server/src/routes/synology.ts | 44 +++++++++++++++++++++++++++++++++++ 4 files changed, 54 insertions(+), 9 deletions(-) diff --git a/server/src/db/migrations.ts b/server/src/db/migrations.ts index d14d4a8..20f160d 100644 --- a/server/src/db/migrations.ts +++ b/server/src/db/migrations.ts @@ -662,7 +662,7 @@ function runMigrations(db: Database.Database): void { settings_get: '/integrations/synologyphotos/settings', settings_put: '/integrations/synologyphotos/settings', status_get: '/integrations/synologyphotos/status', - test_get: '/integrations/synologyphotos/status', + test_post: '/integrations/synologyphotos/test', }), 1, ); diff --git a/server/src/db/seeds.ts b/server/src/db/seeds.ts index 8035d21..ef849d9 100644 --- a/server/src/db/seeds.ts +++ b/server/src/db/seeds.ts @@ -119,7 +119,7 @@ function seedAddons(db: Database.Database): void { settings_get: '/integrations/synologyphotos/settings', settings_put: '/integrations/synologyphotos/settings', status_get: '/integrations/synologyphotos/status', - test_get: '/integrations/synologyphotos/status', + test_post: '/integrations/synologyphotos/test', }), }, ]; diff --git a/server/src/routes/immich.ts b/server/src/routes/immich.ts index 7ed3dd4..dd02468 100644 --- a/server/src/routes/immich.ts +++ b/server/src/routes/immich.ts @@ -79,16 +79,17 @@ router.get('/status', authenticate, async (req: Request, res: Response) => { } }); -// Test connection with saved credentials +// Test connection with provided credentials only router.post('/test', authenticate, async (req: Request, res: Response) => { - const authReq = req as AuthRequest; - const creds = getImmichCredentials(authReq.user.id); - if (!creds) return res.json({ connected: false, error: 'No credentials configured' }); - const ssrf = await checkSsrf(creds.immich_url); + const { immich_url, immich_api_key } = req.body as { immich_url?: string; immich_api_key?: string }; + const url = String(immich_url || '').trim(); + const apiKey = String(immich_api_key || '').trim(); + if (!url || !apiKey) return res.json({ connected: false, error: 'URL and API key required' }); + const ssrf = await checkSsrf(url); if (!ssrf.allowed) return res.json({ connected: false, error: ssrf.error ?? 'Invalid Immich URL' }); try { - const resp = await fetch(`${creds.immich_url}/api/users/me`, { - headers: { 'x-api-key': creds.immich_api_key, 'Accept': 'application/json' }, + const resp = await fetch(`${url}/api/users/me`, { + headers: { 'x-api-key': apiKey, 'Accept': 'application/json' }, signal: AbortSignal.timeout(10000), }); if (!resp.ok) return res.json({ connected: false, error: `HTTP ${resp.status}` }); diff --git a/server/src/routes/synology.ts b/server/src/routes/synology.ts index 9e98fcf..c1e868e 100644 --- a/server/src/routes/synology.ts +++ b/server/src/routes/synology.ts @@ -7,6 +7,7 @@ import { broadcast } from '../websocket'; import { AuthRequest } from '../types'; import { maybe_encrypt_api_key, decrypt_api_key } from '../services/apiKeyCrypto'; import { consumeEphemeralToken } from '../services/ephemeralTokens'; +import { checkSsrf } from '../utils/ssrfGuard'; const router = express.Router(); @@ -263,6 +264,49 @@ router.get('/status', authenticate, async (req: Request, res: Response) => { } }); +// Test connection with provided credentials only +router.post('/test', authenticate, async (req: Request, res: Response) => { + const { synology_url, synology_username, synology_password } = req.body as { synology_url?: string; synology_username?: string; synology_password?: string }; + + const url = String(synology_url || '').trim(); + const username = String(synology_username || '').trim(); + const password = String(synology_password || '').trim(); + + if (!url || !username || !password) { + return res.json({ connected: false, error: 'URL, username, and password are required' }); + } + + const ssrf = await checkSsrf(url); + if (!ssrf.allowed) return res.json({ connected: false, error: ssrf.error ?? 'Invalid Synology URL' }); + + try { + const endpoint = prepareSynologyEndpoint(url); + const body = new URLSearchParams({ + api: 'SYNO.API.Auth', + method: 'login', + version: '3', + account: username, + passwd: password, + }); + + const resp = await fetch(endpoint, { + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8', + }, + body, + signal: AbortSignal.timeout(30000), + }); + + if (!resp.ok) return res.json({ connected: false, error: `HTTP ${resp.status}` }); + const data = await resp.json() as { success: boolean; data?: { sid?: string } }; + if (!data.success || !data.data?.sid) return res.json({ connected: false, error: 'Authentication failed' }); + return res.json({ connected: true, user: { username } }); + } catch (err: unknown) { + return res.json({ connected: false, error: err instanceof Error ? err.message : 'Connection failed' }); + } +}); + // Album linking parity with Immich router.get('/albums', authenticate, async (req: Request, res: Response) => { const authReq = req as AuthRequest; From 4b8cfc78b8a49017db210e9df2f803e4dbc39480 Mon Sep 17 00:00:00 2001 From: Marek Maslowski Date: Fri, 3 Apr 2026 03:20:45 +0200 Subject: [PATCH 05/57] fixing selection of photos from multiple sources at once --- .../src/components/Memories/MemoriesPanel.tsx | 45 +++++++++++++------ server/src/routes/memories.ts | 32 ++++++++----- 2 files changed, 52 insertions(+), 25 deletions(-) diff --git a/client/src/components/Memories/MemoriesPanel.tsx b/client/src/components/Memories/MemoriesPanel.tsx index b288487..8787dd7 100644 --- a/client/src/components/Memories/MemoriesPanel.tsx +++ b/client/src/components/Memories/MemoriesPanel.tsx @@ -33,11 +33,12 @@ interface TripPhoto { city?: string | null } -interface ImmichAsset { +interface Asset { id: string takenAt: string city: string | null country: string | null + provider: string } interface MemoriesPanelProps { @@ -63,7 +64,7 @@ export default function MemoriesPanel({ tripId, startDate, endDate }: MemoriesPa // Photo picker const [showPicker, setShowPicker] = useState(false) - const [pickerPhotos, setPickerPhotos] = useState([]) + const [pickerPhotos, setPickerPhotos] = useState([]) const [pickerLoading, setPickerLoading] = useState(false) const [selectedIds, setSelectedIds] = useState>(new Set()) @@ -238,11 +239,16 @@ export default function MemoriesPanel({ tripId, startDate, endDate }: MemoriesPa const loadPickerPhotos = async (useDate: boolean) => { setPickerLoading(true) try { - const res = await apiClient.post(`${pickerIntegrationBase}/search`, { + const provider = availableProviders.find(p => p.id === selectedProvider) + if (!provider) { + setPickerPhotos([]) + return + } + const res = await apiClient.post(`/integrations/${provider.id}/search`, { from: useDate && startDate ? startDate : undefined, to: useDate && endDate ? endDate : undefined, }) - setPickerPhotos(res.data.assets || []) + setPickerPhotos((res.data.assets || []).map((asset: Asset) => ({ ...asset, provider: provider.id }))) } catch { setPickerPhotos([]) toast.error(t('memories.error.loadPhotos')) @@ -268,9 +274,17 @@ export default function MemoriesPanel({ tripId, startDate, endDate }: MemoriesPa const executeAddPhotos = async () => { setShowConfirmShare(false) try { + const groupedByProvider = new Map() + for (const key of selectedIds) { + const [provider, assetId] = key.split('::') + if (!provider || !assetId) continue + const list = groupedByProvider.get(provider) || [] + list.push(assetId) + groupedByProvider.set(provider, list) + } + await apiClient.post(`/integrations/memories/trips/${tripId}/photos`, { - provider: selectedProvider, - asset_ids: [...selectedIds], + selections: [...groupedByProvider.entries()].map(([provider, asset_ids]) => ({ provider, asset_ids })), shared: true, }) setShowPicker(false) @@ -312,6 +326,8 @@ export default function MemoriesPanel({ tripId, startDate, endDate }: MemoriesPa const thumbnailBaseUrl = (photo: TripPhoto) => `/api/integrations/${photo.provider}/assets/${photo.asset_id}/thumbnail?userId=${photo.user_id}` + const makePickerKey = (provider: string, assetId: string): string => `${provider}::${assetId}` + const ownPhotos = tripPhotos.filter(p => p.user_id === currentUser?.id) const othersPhotos = tripPhotos.filter(p => p.user_id !== currentUser?.id && p.shared) const allVisibleRaw = [...ownPhotos, ...othersPhotos] @@ -461,8 +477,8 @@ export default function MemoriesPanel({ tripId, startDate, endDate }: MemoriesPa if (showPicker) { const alreadyAdded = new Set( tripPhotos - .filter(p => p.user_id === currentUser?.id && p.provider === selectedProvider) - .map(p => p.asset_id) + .filter(p => p.user_id === currentUser?.id) + .map(p => makePickerKey(p.provider, p.asset_id)) ) return ( @@ -537,7 +553,7 @@ export default function MemoriesPanel({ tripId, startDate, endDate }: MemoriesPa
) : (() => { // Group photos by month - const byMonth: Record = {} + const byMonth: Record = {} for (const asset of pickerPhotos) { const d = asset.takenAt ? new Date(asset.takenAt) : null const key = d ? `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}` : 'unknown' @@ -555,11 +571,12 @@ export default function MemoriesPanel({ tripId, startDate, endDate }: MemoriesPa
{byMonth[month].map(asset => { - const isSelected = selectedIds.has(asset.id) - const isAlready = alreadyAdded.has(asset.id) + const pickerKey = makePickerKey(asset.provider, asset.id) + const isSelected = selectedIds.has(pickerKey) + const isAlready = alreadyAdded.has(pickerKey) return ( -
!isAlready && togglePickerSelect(asset.id)} +
!isAlready && togglePickerSelect(pickerKey)} style={{ position: 'relative', aspectRatio: '1', borderRadius: 8, overflow: 'hidden', cursor: isAlready ? 'default' : 'pointer', @@ -567,7 +584,7 @@ export default function MemoriesPanel({ tripId, startDate, endDate }: MemoriesPa outline: isSelected ? '3px solid var(--text-primary)' : 'none', outlineOffset: -3, }}> - {isSelected && (
{ const authReq = req as AuthRequest; const { tripId } = req.params; - const provider = String(req.body?.provider || '').toLowerCase(); const { shared = true } = req.body; + const selectionsRaw = Array.isArray(req.body?.selections) ? req.body.selections : null; + const provider = String(req.body?.provider || '').toLowerCase(); const assetIdsRaw = req.body?.asset_ids; if (!canAccessTrip(tripId, authReq.user.id)) { return res.status(404).json({ error: 'Trip not found' }); } - if (!provider) { - return res.status(400).json({ error: 'provider is required' }); - } + const selections = selectionsRaw && selectionsRaw.length > 0 + ? selectionsRaw + .map((selection: any) => ({ + provider: String(selection?.provider || '').toLowerCase(), + asset_ids: Array.isArray(selection?.asset_ids) ? selection.asset_ids : [], + })) + .filter((selection: { provider: string; asset_ids: unknown[] }) => selection.provider && selection.asset_ids.length > 0) + : (provider && Array.isArray(assetIdsRaw) && assetIdsRaw.length > 0 + ? [{ provider, asset_ids: assetIdsRaw }] + : []); - if (!Array.isArray(assetIdsRaw) || assetIdsRaw.length === 0) { - return res.status(400).json({ error: 'asset_ids required' }); + if (selections.length === 0) { + return res.status(400).json({ error: 'selections required' }); } const insert = db.prepare( @@ -95,11 +103,13 @@ router.post('/trips/:tripId/photos', authenticate, (req: Request, res: Response) ); let added = 0; - for (const raw of assetIdsRaw) { - const assetId = String(raw || '').trim(); - if (!assetId) continue; - const result = insert.run(tripId, authReq.user.id, assetId, provider, shared ? 1 : 0); - if (result.changes > 0) added++; + for (const selection of selections) { + for (const raw of selection.asset_ids) { + const assetId = String(raw || '').trim(); + if (!assetId) continue; + const result = insert.run(tripId, authReq.user.id, assetId, selection.provider, shared ? 1 : 0); + if (result.changes > 0) added++; + } } res.json({ success: true, added }); From c4236d673741168538741ef084eba6c33441f2de Mon Sep 17 00:00:00 2001 From: Marek Maslowski Date: Fri, 3 Apr 2026 03:29:13 +0200 Subject: [PATCH 06/57] fixing path for asset in full res --- server/src/routes/synology.ts | 30 +++++++++--------------------- 1 file changed, 9 insertions(+), 21 deletions(-) diff --git a/server/src/routes/synology.ts b/server/src/routes/synology.ts index c1e868e..7312eaa 100644 --- a/server/src/routes/synology.ts +++ b/server/src/routes/synology.ts @@ -528,7 +528,7 @@ router.get('/assets/:photoId/thumbnail', authFromQuery, async (req: Request, res const authReq = req as AuthRequest; const { photoId } = req.params; const parsedId = splitPackedSynologyId(photoId); - const { userId, cacheKey, size = 'sm' } = req.query; + const { userId, size = 'sm' } = req.query; const targetUserId = userId ? Number(userId) : authReq.user.id; @@ -543,29 +543,15 @@ router.get('/assets/:photoId/thumbnail', authFromQuery, async (req: Request, res return res.status(401).send('Authentication failed'); } - let resolvedCacheKey = cacheKey ? String(cacheKey) : parsedId.cacheKey; - if (!resolvedCacheKey) { - const row = db.prepare(` - SELECT asset_id FROM trip_photos - WHERE user_id = ? AND (asset_id = ? OR asset_id = ? OR asset_id LIKE ? OR asset_id LIKE ?) - ORDER BY id DESC LIMIT 1 - `).get(targetUserId, parsedId.assetId, parsedId.id, `${parsedId.id}_%`, `${parsedId.id}::%`) as { asset_id?: string } | undefined; - const packed = row?.asset_id || ''; - if (packed) { - resolvedCacheKey = splitPackedSynologyId(packed).cacheKey; - } - } - if (!resolvedCacheKey) return res.status(404).send('Missing cache key for thumbnail'); - const params = new URLSearchParams({ api: 'SYNO.Foto.Thumbnail', method: 'get', version: '2', mode: 'download', - id: String(parsedId.id), + id: parsedId.id, type: 'unit', size: String(size), - cache_key: resolvedCacheKey, + cache_key: parsedId.cacheKey, _sid: sid.sid, }); const url = prepareSynologyEndpoint(creds.synology_url) + '?' + params.toString(); @@ -596,9 +582,11 @@ router.get('/assets/:photoId/thumbnail', authFromQuery, async (req: Request, res }); -router.get('/assets/download', authFromQuery, async (req: Request, res: Response) => { +router.get('/assets/:photoId/original', authFromQuery, async (req: Request, res: Response) => { const authReq = req as AuthRequest; - const { userId, cacheKey, unitIds } = req.query; + const { photoId } = req.params; + const parsedId = splitPackedSynologyId(photoId || ''); + const { userId} = req.query; const targetUserId = userId ? Number(userId) : authReq.user.id; @@ -617,8 +605,8 @@ router.get('/assets/download', authFromQuery, async (req: Request, res: Response api: 'SYNO.Foto.Download', method: 'download', version: '2', - cache_key: String(cacheKey), - unit_id: "[" + String(unitIds) + "]", + cache_key: parsedId.cacheKey, + unit_id: `[${parsedId.id}]`, _sid: sid.sid, }); From c20d0256c855bc795da6d2683147c28177d14e2e Mon Sep 17 00:00:00 2001 From: Marek Maslowski Date: Fri, 3 Apr 2026 03:47:44 +0200 Subject: [PATCH 07/57] fixing metada --- server/src/routes/synology.ts | 47 +++++++++++++++++++---------------- 1 file changed, 25 insertions(+), 22 deletions(-) diff --git a/server/src/routes/synology.ts b/server/src/routes/synology.ts index 7312eaa..71fed86 100644 --- a/server/src/routes/synology.ts +++ b/server/src/routes/synology.ts @@ -472,38 +472,41 @@ router.get('/assets/:photoId/info', authenticate, async (req: Request, res: Resp const result = await callSynologyApi(targetUserId, { api: 'SYNO.Foto.Browse.Item', method: 'get', - version: 2, - id: Number(parsedId.id), - additional: ['thumbnail', 'resolution', 'exif', 'gps', 'address', 'orientation', 'description'], + version: 5, + id: `[${parsedId.id}]`, + additional: ['resolution', 'exif', 'gps', 'address', 'orientation', 'description'], }); if (!result.success || !result.data) { return res.status(404).json({ error: 'Photo not found' }); } - - const exif = result.data.additional?.exif || {}; - const address = result.data.additional?.address || {}; - const gps = result.data.additional?.gps || {}; + const metadata = result.data.list[0]; + console.log(metadata); + const exif = metadata.additional?.exif || {}; + const address = metadata.additional?.address || {}; + const gps = metadata.additional?.gps || {}; res.json({ - id: result.data.id, - takenAt: result.data.time ? new Date(result.data.time * 1000).toISOString() : null, - width: result.data.additional?.resolution?.width || null, - height: result.data.additional?.resolution?.height || null, - camera: exif.model || null, - lens: exif.lens_model || null, - focalLength: exif.focal_length ? `${exif.focal_length}mm` : null, - aperture: exif.f_number ? `f/${exif.f_number}` : null, - shutter: exif.exposure_time || null, - iso: exif.iso_speed_ratings || null, + id: photoId, + takenAt: metadata.time ? new Date(metadata.time * 1000).toISOString() : null, city: address.city || null, - state: address.state || null, country: address.country || null, + state: address.state || null, + camera: exif.camera || null, + lens: exif.lens || null, + focalLength: exif.focal_length || null, + aperture: exif.aperture || null, + shutter: exif.exposure_time || null, + iso: exif.iso || null, lat: gps.latitude || null, lng: gps.longitude || null, - orientation: result.data.additional?.orientation || null, - description: result.data.additional?.description || null, - fileSize: result.data.filesize || null, - fileName: result.data.filename || null, + orientation: metadata.additional?.orientation || null, + description: metadata.additional?.description || null, + filename: metadata.filename || null, + filesize: metadata.filesize || null, + width: metadata.additional?.resolution?.width || null, + height: metadata.additional?.resolution?.height || null, + fileSize: metadata.filesize || null, + fileName: metadata.filename || null, }); } catch (err: unknown) { res.status(502).json({ error: err instanceof Error ? err.message : 'Could not reach Synology'}); From cf968969d0d4d5c91185453bfa3903b5c9f61973 Mon Sep 17 00:00:00 2001 From: Marek Maslowski Date: Fri, 3 Apr 2026 02:54:35 +0200 Subject: [PATCH 08/57] refactor(memories): generalize photo providers and decouple from immich --- client/src/api/authUrl.ts | 2 +- client/src/components/Admin/AddonManager.tsx | 133 +++++++-- .../src/components/Memories/MemoriesPanel.tsx | 195 +++++++++--- client/src/pages/SettingsPage.tsx | 279 +++++++++++++----- client/src/pages/TripPlannerPage.tsx | 4 +- client/src/store/addonStore.ts | 16 + server/src/db/migrations.ts | 114 +++++++ server/src/db/schema.ts | 25 ++ server/src/db/seeds.ts | 28 ++ server/src/index.ts | 69 ++++- server/src/routes/admin.ts | 7 +- server/src/routes/immich.ts | 2 - server/src/routes/memories.ts | 182 ++++++++++++ 13 files changed, 901 insertions(+), 155 deletions(-) create mode 100644 server/src/routes/memories.ts diff --git a/client/src/api/authUrl.ts b/client/src/api/authUrl.ts index 203ceb3..ed92729 100644 --- a/client/src/api/authUrl.ts +++ b/client/src/api/authUrl.ts @@ -1,4 +1,4 @@ -export async function getAuthUrl(url: string, purpose: 'download' | 'immich'): Promise { +export async function getAuthUrl(url: string, purpose: string): Promise { if (!url) return url try { const resp = await fetch('/api/auth/resource-token', { diff --git a/client/src/components/Admin/AddonManager.tsx b/client/src/components/Admin/AddonManager.tsx index 3050258..b45f9f0 100644 --- a/client/src/components/Admin/AddonManager.tsx +++ b/client/src/components/Admin/AddonManager.tsx @@ -15,7 +15,17 @@ interface Addon { name: string description: string icon: string + type: string enabled: boolean + config?: Record +} + +interface ProviderOption { + key: string + label: string + description: string + enabled: boolean + toggle: () => Promise } interface AddonIconProps { @@ -34,7 +44,7 @@ export default function AddonManager({ bagTrackingEnabled, onToggleBagTracking } const dark = dm === true || dm === 'dark' || (dm === 'auto' && window.matchMedia('(prefers-color-scheme: dark)').matches) const toast = useToast() const refreshGlobalAddons = useAddonStore(s => s.loadAddons) - const [addons, setAddons] = useState([]) + const [addons, setAddons] = useState([]) const [loading, setLoading] = useState(true) useEffect(() => { @@ -53,7 +63,7 @@ export default function AddonManager({ bagTrackingEnabled, onToggleBagTracking } } } - const handleToggle = async (addon) => { + const handleToggle = async (addon: Addon) => { const newEnabled = !addon.enabled // Optimistic update setAddons(prev => prev.map(a => a.id === addon.id ? { ...a, enabled: newEnabled } : a)) @@ -68,9 +78,44 @@ export default function AddonManager({ bagTrackingEnabled, onToggleBagTracking } } } + const isPhotoProviderAddon = (addon: Addon) => { + return addon.type === 'photo_provider' + } + + const isPhotosAddon = (addon: Addon) => { + const haystack = `${addon.id} ${addon.name} ${addon.description}`.toLowerCase() + return addon.type === 'trip' && (addon.icon === 'Image' || haystack.includes('photo') || haystack.includes('memories')) + } + + const handleTogglePhotoProvider = async (providerAddon: Addon) => { + const enableProvider = !providerAddon.enabled + const prev = addons + + setAddons(current => current.map(a => a.id === providerAddon.id ? { ...a, enabled: enableProvider } : a)) + + try { + await adminApi.updateAddon(providerAddon.id, { enabled: enableProvider }) + refreshGlobalAddons() + toast.success(t('admin.addons.toast.updated')) + } catch { + setAddons(prev) + toast.error(t('admin.addons.toast.error')) + } + } + const tripAddons = addons.filter(a => a.type === 'trip') const globalAddons = addons.filter(a => a.type === 'global') + const photoProviderAddons = addons.filter(isPhotoProviderAddon) const integrationAddons = addons.filter(a => a.type === 'integration') + const photosAddon = tripAddons.find(isPhotosAddon) + const providerOptions: ProviderOption[] = photoProviderAddons.map((provider) => ({ + key: provider.id, + label: provider.name, + description: provider.description, + enabled: provider.enabled, + toggle: () => handleTogglePhotoProvider(provider), + })) + const photosDerivedEnabled = providerOptions.some(p => p.enabled) if (loading) { return ( @@ -108,7 +153,42 @@ export default function AddonManager({ bagTrackingEnabled, onToggleBagTracking }
{tripAddons.map(addon => (
- + + {photosAddon && addon.id === photosAddon.id && providerOptions.length > 0 && ( +
+
+ {providerOptions.map(provider => ( +
+
+
{provider.label}
+
{provider.description}
+
+
+ + {provider.enabled ? t('admin.addons.enabled') : t('admin.addons.disabled')} + + +
+
+ ))} +
+
+ )} {addon.id === 'packing' && addon.enabled && onToggleBagTracking && (
@@ -171,8 +251,10 @@ export default function AddonManager({ bagTrackingEnabled, onToggleBagTracking } interface AddonRowProps { addon: Addon - onToggle: (addonId: string) => void + onToggle: (addon: Addon) => void t: (key: string) => string + statusOverride?: boolean + hideToggle?: boolean } function getAddonLabel(t: (key: string) => string, addon: Addon): { name: string; description: string } { @@ -187,9 +269,12 @@ function getAddonLabel(t: (key: string) => string, addon: Addon): { name: string } } -function AddonRow({ addon, onToggle, t }: AddonRowProps) { +function AddonRow({ addon, onToggle, t, nameOverride, descriptionOverride, statusOverride, hideToggle }: AddonRowProps & { nameOverride?: string; descriptionOverride?: string }) { const isComingSoon = false const label = getAddonLabel(t, addon) + const displayName = nameOverride || label.name + const displayDescription = descriptionOverride || label.description + const enabledState = statusOverride ?? addon.enabled return (
{/* Icon */} @@ -200,7 +285,7 @@ function AddonRow({ addon, onToggle, t }: AddonRowProps) { {/* Info */}
- {label.name} + {displayName} {isComingSoon && ( Coming Soon @@ -210,28 +295,30 @@ function AddonRow({ addon, onToggle, t }: AddonRowProps) { {addon.type === 'global' ? t('admin.addons.type.global') : addon.type === 'integration' ? t('admin.addons.type.integration') : t('admin.addons.type.trip')}
-

{label.description}

+

{displayDescription}

{/* Toggle */}
- - {isComingSoon ? t('admin.addons.disabled') : addon.enabled ? t('admin.addons.enabled') : t('admin.addons.disabled')} + + {isComingSoon ? t('admin.addons.disabled') : enabledState ? t('admin.addons.enabled') : t('admin.addons.disabled')} - + {!hideToggle && ( + + )}
) diff --git a/client/src/components/Memories/MemoriesPanel.tsx b/client/src/components/Memories/MemoriesPanel.tsx index 9dd1ed4..b288487 100644 --- a/client/src/components/Memories/MemoriesPanel.tsx +++ b/client/src/components/Memories/MemoriesPanel.tsx @@ -1,27 +1,36 @@ import { useState, useEffect, useCallback } from 'react' import { Camera, Plus, Share2, EyeOff, Eye, X, Check, Search, ArrowUpDown, MapPin, Filter, Link2, RefreshCw, Unlink, FolderOpen } from 'lucide-react' -import apiClient from '../../api/client' +import apiClient, { addonsApi } from '../../api/client' import { useAuthStore } from '../../store/authStore' import { useTranslation } from '../../i18n' import { getAuthUrl } from '../../api/authUrl' import { useToast } from '../shared/Toast' -function ImmichImg({ baseUrl, style, loading }: { baseUrl: string; style?: React.CSSProperties; loading?: 'lazy' | 'eager' }) { +interface PhotoProvider { + id: string + name: string + icon?: string + config?: Record +} + +function ProviderImg({ baseUrl, provider, style, loading }: { baseUrl: string; provider: string; style?: React.CSSProperties; loading?: 'lazy' | 'eager' }) { const [src, setSrc] = useState('') useEffect(() => { - getAuthUrl(baseUrl, 'immich').then(setSrc) - }, [baseUrl]) + getAuthUrl(baseUrl, provider).then(setSrc).catch(() => {}) + }, [baseUrl, provider]) return src ? : null } // ── Types ─────────────────────────────────────────────────────────────────── interface TripPhoto { - immich_asset_id: string + asset_id: string + provider: string user_id: number username: string shared: number added_at: string + city?: string | null } interface ImmichAsset { @@ -45,6 +54,8 @@ export default function MemoriesPanel({ tripId, startDate, endDate }: MemoriesPa const currentUser = useAuthStore(s => s.user) const [connected, setConnected] = useState(false) + const [availableProviders, setAvailableProviders] = useState([]) + const [selectedProvider, setSelectedProvider] = useState('') const [loading, setLoading] = useState(true) // Trip photos (saved selections) @@ -67,49 +78,61 @@ export default function MemoriesPanel({ tripId, startDate, endDate }: MemoriesPa const [showAlbumPicker, setShowAlbumPicker] = useState(false) const [albums, setAlbums] = useState<{ id: string; albumName: string; assetCount: number }[]>([]) const [albumsLoading, setAlbumsLoading] = useState(false) - const [albumLinks, setAlbumLinks] = useState<{ id: number; immich_album_id: string; album_name: string; user_id: number; username: string; sync_enabled: number; last_synced_at: string | null }[]>([]) + const [albumLinks, setAlbumLinks] = useState<{ id: number; provider: string; album_id: string; album_name: string; user_id: number; username: string; sync_enabled: number; last_synced_at: string | null }[]>([]) const [syncing, setSyncing] = useState(null) + const pickerIntegrationBase = selectedProvider ? `/integrations/${selectedProvider}` : '' const loadAlbumLinks = async () => { try { - const res = await apiClient.get(`/integrations/immich/trips/${tripId}/album-links`) + const res = await apiClient.get(`/integrations/memories/trips/${tripId}/album-links`) setAlbumLinks(res.data.links || []) } catch { setAlbumLinks([]) } } - const openAlbumPicker = async () => { - setShowAlbumPicker(true) + const loadAlbums = async (provider: string = selectedProvider) => { + if (!provider) return setAlbumsLoading(true) try { - const res = await apiClient.get('/integrations/immich/albums') + const res = await apiClient.get(`/integrations/${provider}/albums`) setAlbums(res.data.albums || []) - } catch { setAlbums([]); toast.error(t('memories.error.loadAlbums')) } - finally { setAlbumsLoading(false) } + } catch { + setAlbums([]) + toast.error(t('memories.error.loadAlbums')) + } finally { + setAlbumsLoading(false) + } + } + + const openAlbumPicker = async () => { + setShowAlbumPicker(true) + await loadAlbums(selectedProvider) } const linkAlbum = async (albumId: string, albumName: string) => { try { - await apiClient.post(`/integrations/immich/trips/${tripId}/album-links`, { album_id: albumId, album_name: albumName }) + await apiClient.post(`${pickerIntegrationBase}/trips/${tripId}/album-links`, { album_id: albumId, album_name: albumName }) setShowAlbumPicker(false) await loadAlbumLinks() // Auto-sync after linking - const linksRes = await apiClient.get(`/integrations/immich/trips/${tripId}/album-links`) - const newLink = (linksRes.data.links || []).find((l: any) => l.immich_album_id === albumId) + const linksRes = await apiClient.get(`/integrations/memories/trips/${tripId}/album-links`) + const newLink = (linksRes.data.links || []).find((l: any) => l.album_id === albumId && l.provider === selectedProvider) if (newLink) await syncAlbum(newLink.id) } catch { toast.error(t('memories.error.linkAlbum')) } } const unlinkAlbum = async (linkId: number) => { try { - await apiClient.delete(`/integrations/immich/trips/${tripId}/album-links/${linkId}`) + await apiClient.delete(`/integrations/memories/trips/${tripId}/album-links/${linkId}`) loadAlbumLinks() } catch { toast.error(t('memories.error.unlinkAlbum')) } } - const syncAlbum = async (linkId: number) => { + const syncAlbum = async (linkId: number, provider?: string) => { + const targetProvider = provider || selectedProvider + if (!targetProvider) return setSyncing(linkId) try { - await apiClient.post(`/integrations/immich/trips/${tripId}/album-links/${linkId}/sync`) + await apiClient.post(`/integrations/${targetProvider}/trips/${tripId}/album-links/${linkId}/sync`) await loadAlbumLinks() await loadPhotos() } catch { toast.error(t('memories.error.syncAlbum')) } @@ -138,7 +161,7 @@ export default function MemoriesPanel({ tripId, startDate, endDate }: MemoriesPa const loadPhotos = async () => { try { - const photosRes = await apiClient.get(`/integrations/immich/trips/${tripId}/photos`) + const photosRes = await apiClient.get(`/integrations/memories/trips/${tripId}/photos`) setTripPhotos(photosRes.data.photos || []) } catch { setTripPhotos([]) @@ -148,9 +171,35 @@ export default function MemoriesPanel({ tripId, startDate, endDate }: MemoriesPa const loadInitial = async () => { setLoading(true) try { - const statusRes = await apiClient.get('/integrations/immich/status') - setConnected(statusRes.data.connected) + const addonsRes = await addonsApi.enabled().catch(() => ({ addons: [] as any[] })) + const enabledAddons = addonsRes?.addons || [] + const photoProviders = enabledAddons.filter((a: any) => a.type === 'photo_provider' && a.enabled) + + // Test connection status for each enabled provider + const statusResults = await Promise.all( + photoProviders.map(async (provider: any) => { + const statusUrl = (provider.config as Record)?.status_get as string + if (!statusUrl) return { provider, connected: false } + try { + const res = await apiClient.get(statusUrl) + return { provider, connected: !!res.data?.connected } + } catch { + return { provider, connected: false } + } + }) + ) + + const connectedProviders = statusResults + .filter(r => r.connected) + .map(r => ({ id: r.provider.id, name: r.provider.name, icon: r.provider.icon, config: r.provider.config })) + + setAvailableProviders(connectedProviders) + setConnected(connectedProviders.length > 0) + if (connectedProviders.length > 0 && !selectedProvider) { + setSelectedProvider(connectedProviders[0].id) + } } catch { + setAvailableProviders([]) setConnected(false) } await loadPhotos() @@ -170,10 +219,26 @@ export default function MemoriesPanel({ tripId, startDate, endDate }: MemoriesPa await loadPickerPhotos(!!(startDate && endDate)) } + useEffect(() => { + if (showPicker) { + loadPickerPhotos(pickerDateFilter) + } + }, [selectedProvider]) + + useEffect(() => { + loadAlbumLinks() + }, [tripId]) + + useEffect(() => { + if (showAlbumPicker) { + loadAlbums(selectedProvider) + } + }, [showAlbumPicker, selectedProvider, tripId]) + const loadPickerPhotos = async (useDate: boolean) => { setPickerLoading(true) try { - const res = await apiClient.post('/integrations/immich/search', { + const res = await apiClient.post(`${pickerIntegrationBase}/search`, { from: useDate && startDate ? startDate : undefined, to: useDate && endDate ? endDate : undefined, }) @@ -203,7 +268,8 @@ export default function MemoriesPanel({ tripId, startDate, endDate }: MemoriesPa const executeAddPhotos = async () => { setShowConfirmShare(false) try { - await apiClient.post(`/integrations/immich/trips/${tripId}/photos`, { + await apiClient.post(`/integrations/memories/trips/${tripId}/photos`, { + provider: selectedProvider, asset_ids: [...selectedIds], shared: true, }) @@ -214,28 +280,37 @@ export default function MemoriesPanel({ tripId, startDate, endDate }: MemoriesPa // ── Remove photo ────────────────────────────────────────────────────────── - const removePhoto = async (assetId: string) => { + const removePhoto = async (photo: TripPhoto) => { try { - await apiClient.delete(`/integrations/immich/trips/${tripId}/photos/${assetId}`) - setTripPhotos(prev => prev.filter(p => p.immich_asset_id !== assetId)) + await apiClient.delete(`/integrations/memories/trips/${tripId}/photos`, { + data: { + asset_id: photo.asset_id, + provider: photo.provider, + }, + }) + setTripPhotos(prev => prev.filter(p => !(p.provider === photo.provider && p.asset_id === photo.asset_id))) } catch { toast.error(t('memories.error.removePhoto')) } } // ── Toggle sharing ──────────────────────────────────────────────────────── - const toggleSharing = async (assetId: string, shared: boolean) => { + const toggleSharing = async (photo: TripPhoto, shared: boolean) => { try { - await apiClient.put(`/integrations/immich/trips/${tripId}/photos/${assetId}/sharing`, { shared }) + await apiClient.put(`/integrations/memories/trips/${tripId}/photos/sharing`, { + shared, + asset_id: photo.asset_id, + provider: photo.provider, + }) setTripPhotos(prev => prev.map(p => - p.immich_asset_id === assetId ? { ...p, shared: shared ? 1 : 0 } : p + p.provider === photo.provider && p.asset_id === photo.asset_id ? { ...p, shared: shared ? 1 : 0 } : p )) } catch { toast.error(t('memories.error.toggleSharing')) } } // ── Helpers ─────────────────────────────────────────────────────────────── - const thumbnailBaseUrl = (assetId: string, userId: number) => - `/api/integrations/immich/assets/${assetId}/thumbnail?userId=${userId}` + const thumbnailBaseUrl = (photo: TripPhoto) => + `/api/integrations/${photo.provider}/assets/${photo.asset_id}/thumbnail?userId=${photo.user_id}` const ownPhotos = tripPhotos.filter(p => p.user_id === currentUser?.id) const othersPhotos = tripPhotos.filter(p => p.user_id !== currentUser?.id && p.shared) @@ -286,10 +361,40 @@ export default function MemoriesPanel({ tripId, startDate, endDate }: MemoriesPa // ── Photo Picker Modal ──────────────────────────────────────────────────── + const ProviderTabs = () => { + if (availableProviders.length < 2) return null + return ( +
+ {availableProviders.map(provider => ( + + ))} +
+ ) + } + // ── Album Picker Modal ────────────────────────────────────────────────── if (showAlbumPicker) { - const linkedIds = new Set(albumLinks.map(l => l.immich_album_id)) + const linkedIds = new Set(albumLinks.map(l => l.album_id)) return (
@@ -297,6 +402,7 @@ export default function MemoriesPanel({ tripId, startDate, endDate }: MemoriesPa

{t('memories.selectAlbum')}

+ @@ -630,18 +741,18 @@ export default function MemoriesPanel({ tripId, startDate, endDate }: MemoriesPa {allVisible.map(photo => { const isOwn = photo.user_id === currentUser?.id return ( -
{ - setLightboxId(photo.immich_asset_id); setLightboxUserId(photo.user_id); setLightboxInfo(null) + setLightboxId(photo.asset_id); setLightboxUserId(photo.user_id); setLightboxInfo(null) setLightboxOriginalSrc('') - getAuthUrl(`/api/integrations/immich/assets/${photo.immich_asset_id}/original?userId=${photo.user_id}`, 'immich').then(setLightboxOriginalSrc) + getAuthUrl(`/api/integrations/${photo.provider}/assets/${photo.asset_id}/original?userId=${photo.user_id}`, photo.provider).then(setLightboxOriginalSrc).catch(() => {}) setLightboxInfoLoading(true) - apiClient.get(`/integrations/immich/assets/${photo.immich_asset_id}/info?userId=${photo.user_id}`) + apiClient.get(`/integrations/${photo.provider}/assets/${photo.asset_id}/info?userId=${photo.user_id}`) .then(r => setLightboxInfo(r.data)).catch(() => {}).finally(() => setLightboxInfoLoading(false)) }}> - {/* Other user's avatar */} @@ -672,7 +783,7 @@ export default function MemoriesPanel({ tripId, startDate, endDate }: MemoriesPa {isOwn && (
- - + + {connected && ( + + + {t('memories.connected')} + + )} +
+
+ + ) + } + // Map settings const [mapTileUrl, setMapTileUrl] = useState(settings.map_tile_url || '') const [defaultLat, setDefaultLat] = useState(settings.default_lat || 48.8566) @@ -673,45 +832,7 @@ export default function SettingsPage(): React.ReactElement { - {/* Immich — only when Memories addon is enabled */} - {memoriesEnabled && ( -
-
-
- - { setImmichUrl(e.target.value); setImmichTestPassed(false) }} - placeholder="https://immich.example.com" - className="w-full px-3 py-2.5 border border-slate-200 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-slate-300" /> -
-
- - { setImmichApiKey(e.target.value); setImmichTestPassed(false) }} - placeholder={immichConnected ? '••••••••' : 'API Key'} - className="w-full px-3 py-2.5 border border-slate-200 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-slate-300" /> -
-
- - - {immichConnected && ( - - - {t('memories.connected')} - - )} -
-
-
- )} + {activePhotoProviders.map(provider => renderPhotoProviderSection(provider as PhotoProviderAddon))} {/* MCP Configuration — only when MCP addon is enabled */} {mcpEnabled &&
diff --git a/client/src/pages/TripPlannerPage.tsx b/client/src/pages/TripPlannerPage.tsx index 47918bd..256714a 100644 --- a/client/src/pages/TripPlannerPage.tsx +++ b/client/src/pages/TripPlannerPage.tsx @@ -78,7 +78,9 @@ export default function TripPlannerPage(): React.ReactElement | null { addonsApi.enabled().then(data => { const map = {} data.addons.forEach(a => { map[a.id] = true }) - setEnabledAddons({ packing: !!map.packing, budget: !!map.budget, documents: !!map.documents, collab: !!map.collab, memories: !!map.memories }) + // Check if any photo provider is enabled (for memories tab to show) + const hasPhotoProviders = data.addons.some(a => a.type === 'photo_provider' && a.enabled) + setEnabledAddons({ packing: !!map.packing, budget: !!map.budget, documents: !!map.documents, collab: !!map.collab, memories: !!map.memories || hasPhotoProviders }) }).catch(() => {}) authApi.getAppConfig().then(config => { if (config.allowed_file_types) setAllowedFileTypes(config.allowed_file_types) diff --git a/client/src/store/addonStore.ts b/client/src/store/addonStore.ts index d0fce97..8ef6798 100644 --- a/client/src/store/addonStore.ts +++ b/client/src/store/addonStore.ts @@ -4,9 +4,22 @@ import { addonsApi } from '../api/client' interface Addon { id: string name: string + description?: string type: string icon: string enabled: boolean + config?: Record + fields?: Array<{ + key: string + label: string + input_type: string + placeholder?: string | null + required: boolean + secret: boolean + settings_key?: string | null + payload_key?: string | null + sort_order: number + }> } interface AddonState { @@ -30,6 +43,9 @@ export const useAddonStore = create((set, get) => ({ }, isEnabled: (id: string) => { + if (id === 'memories') { + return get().addons.some(a => a.type === 'photo_provider' && a.enabled) + } return get().addons.some(a => a.id === id && a.enabled) }, })) diff --git a/server/src/db/migrations.ts b/server/src/db/migrations.ts index c2f1271..8e01979 100644 --- a/server/src/db/migrations.ts +++ b/server/src/db/migrations.ts @@ -518,6 +518,120 @@ function runMigrations(db: Database.Database): void { CREATE INDEX IF NOT EXISTS idx_notifications_recipient_created ON notifications(recipient_id, created_at DESC); `); }, + () => { + // Normalize trip_photos to provider-based schema used by current routes + const tripPhotosExists = db.prepare("SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'trip_photos'").get(); + if (!tripPhotosExists) { + db.exec(` + CREATE TABLE trip_photos ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + trip_id INTEGER NOT NULL REFERENCES trips(id) ON DELETE CASCADE, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + asset_id TEXT NOT NULL, + provider TEXT NOT NULL DEFAULT 'immich', + shared INTEGER NOT NULL DEFAULT 1, + added_at DATETIME DEFAULT CURRENT_TIMESTAMP, + UNIQUE(trip_id, user_id, asset_id, provider) + ); + CREATE INDEX IF NOT EXISTS idx_trip_photos_trip ON trip_photos(trip_id); + `); + } else { + const columns = db.prepare("PRAGMA table_info('trip_photos')").all() as Array<{ name: string }>; + const names = new Set(columns.map(c => c.name)); + const assetSource = names.has('asset_id') ? 'asset_id' : (names.has('immich_asset_id') ? 'immich_asset_id' : null); + if (assetSource) { + const providerExpr = names.has('provider') + ? "CASE WHEN provider IS NULL OR provider = '' THEN 'immich' ELSE provider END" + : "'immich'"; + const sharedExpr = names.has('shared') ? 'COALESCE(shared, 1)' : '1'; + const addedAtExpr = names.has('added_at') ? 'COALESCE(added_at, CURRENT_TIMESTAMP)' : 'CURRENT_TIMESTAMP'; + + db.exec(` + CREATE TABLE trip_photos_new ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + trip_id INTEGER NOT NULL REFERENCES trips(id) ON DELETE CASCADE, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + asset_id TEXT NOT NULL, + provider TEXT NOT NULL DEFAULT 'immich', + shared INTEGER NOT NULL DEFAULT 1, + added_at DATETIME DEFAULT CURRENT_TIMESTAMP, + UNIQUE(trip_id, user_id, asset_id, provider) + ); + `); + + db.exec(` + INSERT OR IGNORE INTO trip_photos_new (trip_id, user_id, asset_id, provider, shared, added_at) + SELECT trip_id, user_id, ${assetSource}, ${providerExpr}, ${sharedExpr}, ${addedAtExpr} + FROM trip_photos + WHERE ${assetSource} IS NOT NULL AND TRIM(${assetSource}) != '' + `); + + db.exec('DROP TABLE trip_photos'); + db.exec('ALTER TABLE trip_photos_new RENAME TO trip_photos'); + db.exec('CREATE INDEX IF NOT EXISTS idx_trip_photos_trip ON trip_photos(trip_id)'); + } + } + }, + () => { + // Normalize trip_album_links to provider + album_id schema used by current routes + const linksExists = db.prepare("SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'trip_album_links'").get(); + if (!linksExists) { + db.exec(` + CREATE TABLE trip_album_links ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + trip_id INTEGER NOT NULL REFERENCES trips(id) ON DELETE CASCADE, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + provider TEXT NOT NULL, + album_id TEXT NOT NULL, + album_name TEXT NOT NULL DEFAULT '', + sync_enabled INTEGER NOT NULL DEFAULT 1, + last_synced_at DATETIME, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + UNIQUE(trip_id, user_id, provider, album_id) + ); + CREATE INDEX IF NOT EXISTS idx_trip_album_links_trip ON trip_album_links(trip_id); + `); + } else { + const columns = db.prepare("PRAGMA table_info('trip_album_links')").all() as Array<{ name: string }>; + const names = new Set(columns.map(c => c.name)); + const albumIdSource = names.has('album_id') ? 'album_id' : (names.has('immich_album_id') ? 'immich_album_id' : null); + if (albumIdSource) { + const providerExpr = names.has('provider') + ? "CASE WHEN provider IS NULL OR provider = '' THEN 'immich' ELSE provider END" + : "'immich'"; + const albumNameExpr = names.has('album_name') ? "COALESCE(album_name, '')" : "''"; + const syncEnabledExpr = names.has('sync_enabled') ? 'COALESCE(sync_enabled, 1)' : '1'; + const lastSyncedExpr = names.has('last_synced_at') ? 'last_synced_at' : 'NULL'; + const createdAtExpr = names.has('created_at') ? 'COALESCE(created_at, CURRENT_TIMESTAMP)' : 'CURRENT_TIMESTAMP'; + + db.exec(` + CREATE TABLE trip_album_links_new ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + trip_id INTEGER NOT NULL REFERENCES trips(id) ON DELETE CASCADE, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + provider TEXT NOT NULL, + album_id TEXT NOT NULL, + album_name TEXT NOT NULL DEFAULT '', + sync_enabled INTEGER NOT NULL DEFAULT 1, + last_synced_at DATETIME, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + UNIQUE(trip_id, user_id, provider, album_id) + ); + `); + + db.exec(` + INSERT OR IGNORE INTO trip_album_links_new (trip_id, user_id, provider, album_id, album_name, sync_enabled, last_synced_at, created_at) + SELECT trip_id, user_id, ${providerExpr}, ${albumIdSource}, ${albumNameExpr}, ${syncEnabledExpr}, ${lastSyncedExpr}, ${createdAtExpr} + FROM trip_album_links + WHERE ${albumIdSource} IS NOT NULL AND TRIM(${albumIdSource}) != '' + `); + + db.exec('DROP TABLE trip_album_links'); + db.exec('ALTER TABLE trip_album_links_new RENAME TO trip_album_links'); + db.exec('CREATE INDEX IF NOT EXISTS idx_trip_album_links_trip ON trip_album_links(trip_id)'); + } + } + }, ]; if (currentVersion < migrations.length) { diff --git a/server/src/db/schema.ts b/server/src/db/schema.ts index 8506253..8fe5739 100644 --- a/server/src/db/schema.ts +++ b/server/src/db/schema.ts @@ -222,6 +222,31 @@ function createTables(db: Database.Database): void { sort_order INTEGER DEFAULT 0 ); + CREATE TABLE IF NOT EXISTS photo_providers ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + description TEXT, + icon TEXT DEFAULT 'Image', + enabled INTEGER DEFAULT 0, + config TEXT DEFAULT '{}', + sort_order INTEGER DEFAULT 0 + ); + + CREATE TABLE IF NOT EXISTS photo_provider_fields ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + provider_id TEXT NOT NULL REFERENCES photo_providers(id) ON DELETE CASCADE, + field_key TEXT NOT NULL, + label TEXT NOT NULL, + input_type TEXT NOT NULL DEFAULT 'text', + placeholder TEXT, + required INTEGER DEFAULT 0, + secret INTEGER DEFAULT 0, + settings_key TEXT, + payload_key TEXT, + sort_order INTEGER DEFAULT 0, + UNIQUE(provider_id, field_key) + ); + -- Vacay addon tables CREATE TABLE IF NOT EXISTS vacay_plans ( id INTEGER PRIMARY KEY AUTOINCREMENT, diff --git a/server/src/db/seeds.ts b/server/src/db/seeds.ts index 8e0d9c6..f875794 100644 --- a/server/src/db/seeds.ts +++ b/server/src/db/seeds.ts @@ -92,6 +92,34 @@ function seedAddons(db: Database.Database): void { ]; const insertAddon = db.prepare('INSERT OR IGNORE INTO addons (id, name, description, type, icon, enabled, sort_order) VALUES (?, ?, ?, ?, ?, ?, ?)'); for (const a of defaultAddons) insertAddon.run(a.id, a.name, a.description, a.type, a.icon, a.enabled, a.sort_order); + + const providerRows = [ + { + id: 'immich', + name: 'Immich', + description: 'Immich photo provider', + icon: 'Image', + enabled: 0, + sort_order: 0, + config: JSON.stringify({ + settings_get: '/integrations/immich/settings', + settings_put: '/integrations/immich/settings', + status_get: '/integrations/immich/status', + test_post: '/integrations/immich/test', + }), + }, + ]; + const insertProvider = db.prepare('INSERT OR IGNORE INTO photo_providers (id, name, description, icon, enabled, config, sort_order) VALUES (?, ?, ?, ?, ?, ?, ?)'); + for (const p of providerRows) insertProvider.run(p.id, p.name, p.description, p.icon, p.enabled, p.config, p.sort_order); + + const providerFields = [ + { provider_id: 'immich', field_key: 'immich_url', label: 'Immich URL', input_type: 'url', placeholder: 'https://immich.example.com', required: 1, secret: 0, settings_key: 'immich_url', payload_key: 'immich_url', sort_order: 0 }, + { provider_id: 'immich', field_key: 'immich_api_key', label: 'API Key', input_type: 'password', placeholder: 'API Key', required: 1, secret: 1, settings_key: null, payload_key: 'immich_api_key', sort_order: 1 }, + ]; + const insertProviderField = db.prepare('INSERT OR IGNORE INTO photo_provider_fields (provider_id, field_key, label, input_type, placeholder, required, secret, settings_key, payload_key, sort_order) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)'); + for (const f of providerFields) { + insertProviderField.run(f.provider_id, f.field_key, f.label, f.input_type, f.placeholder, f.required, f.secret, f.settings_key, f.payload_key, f.sort_order); + } console.log('Default addons seeded'); } catch (err: unknown) { console.error('Error seeding addons:', err instanceof Error ? err.message : err); diff --git a/server/src/index.ts b/server/src/index.ts index 5508542..533d92c 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -207,8 +207,71 @@ import { authenticate as addonAuth } from './middleware/auth'; import {db as addonDb} from './db/database'; import { Addon } from './types'; app.get('/api/addons', addonAuth, (req: Request, res: Response) => { - const addons = addonDb.prepare('SELECT id, name, type, icon, enabled FROM addons WHERE enabled = 1 ORDER BY sort_order').all() as Pick[]; - res.json({ addons: addons.map(a => ({ ...a, enabled: !!a.enabled })) }); + const addons = addonDb.prepare('SELECT id, name, type, icon, enabled, config, sort_order FROM addons WHERE enabled = 1 ORDER BY sort_order').all() as Array & { sort_order: number }>; + const photoProviders = addonDb.prepare(` + SELECT id, name, description, icon, enabled, config, sort_order + FROM photo_providers + WHERE enabled = 1 + ORDER BY sort_order + `).all() as Array<{ id: string; name: string; description?: string | null; icon: string; enabled: number; config: string; sort_order: number }>; + const providerIds = photoProviders.map(p => p.id); + const providerFields = providerIds.length > 0 + ? addonDb.prepare(` + SELECT provider_id, field_key, label, input_type, placeholder, required, secret, settings_key, payload_key, sort_order + FROM photo_provider_fields + WHERE provider_id IN (${providerIds.map(() => '?').join(',')}) + ORDER BY sort_order, id + `).all(...providerIds) as Array<{ + provider_id: string; + field_key: string; + label: string; + input_type: string; + placeholder?: string | null; + required: number; + secret: number; + settings_key?: string | null; + payload_key?: string | null; + sort_order: number; + }> + : []; + const fieldsByProvider = new Map(); + for (const field of providerFields) { + const arr = fieldsByProvider.get(field.provider_id) || []; + arr.push(field); + fieldsByProvider.set(field.provider_id, arr); + } + + const combined = [ + ...addons, + ...photoProviders.map(p => ({ + id: p.id, + name: p.name, + type: 'photo_provider', + icon: p.icon, + enabled: p.enabled, + config: p.config, + fields: (fieldsByProvider.get(p.id) || []).map(f => ({ + key: f.field_key, + label: f.label, + input_type: f.input_type, + placeholder: f.placeholder || '', + required: !!f.required, + secret: !!f.secret, + settings_key: f.settings_key || null, + payload_key: f.payload_key || null, + sort_order: f.sort_order, + })), + sort_order: p.sort_order, + })), + ].sort((a, b) => a.sort_order - b.sort_order || a.id.localeCompare(b.id)); + + res.json({ + addons: combined.map(a => ({ + ...a, + enabled: !!a.enabled, + config: JSON.parse(a.config || '{}'), + })), + }); }); // Addon routes @@ -218,6 +281,8 @@ import atlasRoutes from './routes/atlas'; app.use('/api/addons/atlas', atlasRoutes); import immichRoutes from './routes/immich'; app.use('/api/integrations/immich', immichRoutes); +import memoriesRoutes from './routes/memories'; +app.use('/api/integrations/memories', memoriesRoutes); app.use('/api/maps', mapsRoutes); app.use('/api/weather', weatherRoutes); diff --git a/server/src/routes/admin.ts b/server/src/routes/admin.ts index 56a9136..ac5a4f3 100644 --- a/server/src/routes/admin.ts +++ b/server/src/routes/admin.ts @@ -300,12 +300,9 @@ router.post('/rotate-jwt-secret', (req: Request, res: Response) => { if (result.error) return res.status(result.status!).json({ error: result.error }); const authReq = req as AuthRequest; writeAudit({ - user_id: authReq.user?.id ?? null, - username: authReq.user?.username ?? 'unknown', + userId: authReq.user?.id ?? null, action: 'admin.rotate_jwt_secret', - target_type: 'system', - target_id: null, - details: null, + resource: 'system', ip: getClientIp(req), }); res.json({ success: true }); diff --git a/server/src/routes/immich.ts b/server/src/routes/immich.ts index 198b6e8..12adbee 100644 --- a/server/src/routes/immich.ts +++ b/server/src/routes/immich.ts @@ -30,7 +30,6 @@ import { const router = express.Router(); // ── Dual auth middleware (JWT or ephemeral token for src) ───────────── - function authFromQuery(req: Request, res: Response, next: NextFunction) { const queryToken = req.query.token as string | undefined; if (queryToken) { @@ -186,7 +185,6 @@ router.get('/trips/:tripId/album-links', authenticate, (req: Request, res: Respo if (!canAccessTrip(req.params.tripId, authReq.user.id)) return res.status(404).json({ error: 'Trip not found' }); res.json({ links: listAlbumLinks(req.params.tripId) }); }); - router.post('/trips/:tripId/album-links', authenticate, async (req: Request, res: Response) => { const authReq = req as AuthRequest; const { tripId } = req.params; diff --git a/server/src/routes/memories.ts b/server/src/routes/memories.ts new file mode 100644 index 0000000..d84925d --- /dev/null +++ b/server/src/routes/memories.ts @@ -0,0 +1,182 @@ +import express, { Request, Response } from 'express'; +import { db, canAccessTrip } from '../db/database'; +import { authenticate } from '../middleware/auth'; +import { broadcast } from '../websocket'; +import { AuthRequest } from '../types'; + +const router = express.Router(); + + +router.get('/trips/:tripId/photos', authenticate, (req: Request, res: Response) => { + const authReq = req as AuthRequest; + const { tripId } = req.params; + + if (!canAccessTrip(tripId, authReq.user.id)) { + return res.status(404).json({ error: 'Trip not found' }); + } + + const photos = db.prepare(` + SELECT tp.asset_id, tp.provider, tp.user_id, tp.shared, tp.added_at, + u.username, u.avatar + FROM trip_photos tp + JOIN users u ON tp.user_id = u.id + WHERE tp.trip_id = ? + AND (tp.user_id = ? OR tp.shared = 1) + ORDER BY tp.added_at ASC + `).all(tripId, authReq.user.id) as any[]; + + res.json({ photos }); +}); + +router.get('/trips/:tripId/album-links', authenticate, (req: Request, res: Response) => { + const authReq = req as AuthRequest; + const { tripId } = req.params; + + if (!canAccessTrip(tripId, authReq.user.id)) { + return res.status(404).json({ error: 'Trip not found' }); + } + + const links = db.prepare(` + SELECT tal.id, + tal.trip_id, + tal.user_id, + tal.provider, + tal.album_id, + tal.album_name, + tal.sync_enabled, + tal.last_synced_at, + tal.created_at, + u.username + FROM trip_album_links tal + JOIN users u ON tal.user_id = u.id + WHERE tal.trip_id = ? + ORDER BY tal.created_at ASC + `).all(tripId); + + res.json({ links }); +}); + +router.delete('/trips/:tripId/album-links/:linkId', authenticate, (req: Request, res: Response) => { + const authReq = req as AuthRequest; + const { tripId, linkId } = req.params; + + if (!canAccessTrip(tripId, authReq.user.id)) { + return res.status(404).json({ error: 'Trip not found' }); + } + + db.prepare('DELETE FROM trip_album_links WHERE id = ? AND trip_id = ? AND user_id = ?') + .run(linkId, tripId, authReq.user.id); + + res.json({ success: true }); + broadcast(tripId, 'memories:updated', { userId: authReq.user.id }, req.headers['x-socket-id'] as string); +}); + +router.post('/trips/:tripId/photos', authenticate, (req: Request, res: Response) => { + const authReq = req as AuthRequest; + const { tripId } = req.params; + const provider = String(req.body?.provider || '').toLowerCase(); + const { shared = true } = req.body; + const assetIdsRaw = req.body?.asset_ids; + + if (!canAccessTrip(tripId, authReq.user.id)) { + return res.status(404).json({ error: 'Trip not found' }); + } + + if (!provider) { + return res.status(400).json({ error: 'provider is required' }); + } + + if (!Array.isArray(assetIdsRaw) || assetIdsRaw.length === 0) { + return res.status(400).json({ error: 'asset_ids required' }); + } + + const insert = db.prepare( + 'INSERT OR IGNORE INTO trip_photos (trip_id, user_id, asset_id, provider, shared) VALUES (?, ?, ?, ?, ?)' + ); + + let added = 0; + for (const raw of assetIdsRaw) { + const assetId = String(raw || '').trim(); + if (!assetId) continue; + const result = insert.run(tripId, authReq.user.id, assetId, provider, shared ? 1 : 0); + if (result.changes > 0) added++; + } + + res.json({ success: true, added }); + broadcast(tripId, 'memories:updated', { userId: authReq.user.id }, req.headers['x-socket-id'] as string); + + if (shared && added > 0) { + import('../services/notifications').then(({ notifyTripMembers }) => { + const tripInfo = db.prepare('SELECT title FROM trips WHERE id = ?').get(tripId) as { title: string } | undefined; + notifyTripMembers(Number(tripId), authReq.user.id, 'photos_shared', { + trip: tripInfo?.title || 'Untitled', + actor: authReq.user.username || authReq.user.email, + count: String(added), + }).catch(() => {}); + }); + } +}); + +router.delete('/trips/:tripId/photos', authenticate, (req: Request, res: Response) => { + const authReq = req as AuthRequest; + const { tripId } = req.params; + const provider = String(req.body?.provider || '').toLowerCase(); + const assetId = String(req.body?.asset_id || ''); + + if (!assetId) { + return res.status(400).json({ error: 'asset_id is required' }); + } + + if (!provider) { + return res.status(400).json({ error: 'provider is required' }); + } + + if (!canAccessTrip(tripId, authReq.user.id)) { + return res.status(404).json({ error: 'Trip not found' }); + } + + db.prepare(` + DELETE FROM trip_photos + WHERE trip_id = ? + AND user_id = ? + AND asset_id = ? + AND provider = ? + `).run(tripId, authReq.user.id, assetId, provider); + + res.json({ success: true }); + broadcast(tripId, 'memories:updated', { userId: authReq.user.id }, req.headers['x-socket-id'] as string); +}); + +router.put('/trips/:tripId/photos/sharing', authenticate, (req: Request, res: Response) => { + const authReq = req as AuthRequest; + const { tripId } = req.params; + const provider = String(req.body?.provider || '').toLowerCase(); + const assetId = String(req.body?.asset_id || ''); + const { shared } = req.body; + + if (!assetId) { + return res.status(400).json({ error: 'asset_id is required' }); + } + + if (!provider) { + return res.status(400).json({ error: 'provider is required' }); + } + + if (!canAccessTrip(tripId, authReq.user.id)) { + return res.status(404).json({ error: 'Trip not found' }); + } + + db.prepare(` + UPDATE trip_photos + SET shared = ? + WHERE trip_id = ? + AND user_id = ? + AND asset_id = ? + AND provider = ? + `).run(shared ? 1 : 0, tripId, authReq.user.id, assetId, provider); + + res.json({ success: true }); + broadcast(tripId, 'memories:updated', { userId: authReq.user.id }, req.headers['x-socket-id'] as string); +}); + +export default router; From 7a169d0596f30cee0a6ea017cf898e10eddc2817 Mon Sep 17 00:00:00 2001 From: Marek Maslowski Date: Fri, 3 Apr 2026 02:58:22 +0200 Subject: [PATCH 09/57] feat(integrations): add synology photos support --- server/src/db/migrations.ts | 59 +++ server/src/db/schema.ts | 4 + server/src/db/seeds.ts | 17 + server/src/index.ts | 2 + server/src/routes/synology.ts | 610 +++++++++++++++++++++++++ server/src/services/authService.ts | 2 +- server/src/services/ephemeralTokens.ts | 1 + 7 files changed, 694 insertions(+), 1 deletion(-) create mode 100644 server/src/routes/synology.ts diff --git a/server/src/db/migrations.ts b/server/src/db/migrations.ts index 8e01979..d14d4a8 100644 --- a/server/src/db/migrations.ts +++ b/server/src/db/migrations.ts @@ -632,6 +632,65 @@ function runMigrations(db: Database.Database): void { } } }, + () => { + // Add Synology credential columns for existing databases + try { db.exec('ALTER TABLE users ADD COLUMN synology_url TEXT'); } catch (err: any) { if (!err.message?.includes('duplicate column name')) throw err; } + try { db.exec('ALTER TABLE users ADD COLUMN synology_username TEXT'); } catch (err: any) { if (!err.message?.includes('duplicate column name')) throw err; } + try { db.exec('ALTER TABLE users ADD COLUMN synology_password TEXT'); } catch (err: any) { if (!err.message?.includes('duplicate column name')) throw err; } + try { db.exec('ALTER TABLE users ADD COLUMN synology_sid TEXT'); } catch (err: any) { if (!err.message?.includes('duplicate column name')) throw err; } + }, + () => { + // Seed Synology Photos provider and fields in existing databases + try { + db.prepare(` + INSERT INTO photo_providers (id, name, description, icon, enabled, config, sort_order) + VALUES (?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(id) DO UPDATE SET + name = excluded.name, + description = excluded.description, + icon = excluded.icon, + enabled = excluded.enabled, + config = excluded.config, + sort_order = excluded.sort_order + `).run( + 'synologyphotos', + 'Synology Photos', + 'Synology Photos integration with separate account settings', + 'Image', + 0, + JSON.stringify({ + settings_get: '/integrations/synologyphotos/settings', + settings_put: '/integrations/synologyphotos/settings', + status_get: '/integrations/synologyphotos/status', + test_get: '/integrations/synologyphotos/status', + }), + 1, + ); + } catch (err: any) { + if (!err.message?.includes('no such table')) throw err; + } + try { + const insertField = db.prepare(` + INSERT INTO photo_provider_fields + (provider_id, field_key, label, input_type, placeholder, required, secret, settings_key, payload_key, sort_order) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(provider_id, field_key) DO UPDATE SET + label = excluded.label, + input_type = excluded.input_type, + placeholder = excluded.placeholder, + required = excluded.required, + secret = excluded.secret, + settings_key = excluded.settings_key, + payload_key = excluded.payload_key, + sort_order = excluded.sort_order + `); + insertField.run('synologyphotos', 'synology_url', 'Server URL', 'url', 'https://synology.example.com', 1, 0, 'synology_url', 'synology_url', 0); + insertField.run('synologyphotos', 'synology_username', 'Username', 'text', 'Username', 1, 0, 'synology_username', 'synology_username', 1); + insertField.run('synologyphotos', 'synology_password', 'Password', 'password', 'Password', 1, 1, null, 'synology_password', 2); + } catch (err: any) { + if (!err.message?.includes('no such table')) throw err; + } + }, ]; if (currentVersion < migrations.length) { diff --git a/server/src/db/schema.ts b/server/src/db/schema.ts index 8fe5739..9e243b6 100644 --- a/server/src/db/schema.ts +++ b/server/src/db/schema.ts @@ -18,6 +18,10 @@ function createTables(db: Database.Database): void { mfa_enabled INTEGER DEFAULT 0, mfa_secret TEXT, mfa_backup_codes TEXT, + synology_url TEXT, + synology_username TEXT, + synology_password TEXT, + synology_sid TEXT, must_change_password INTEGER DEFAULT 0, created_at DATETIME DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME DEFAULT CURRENT_TIMESTAMP diff --git a/server/src/db/seeds.ts b/server/src/db/seeds.ts index f875794..8035d21 100644 --- a/server/src/db/seeds.ts +++ b/server/src/db/seeds.ts @@ -108,6 +108,20 @@ function seedAddons(db: Database.Database): void { test_post: '/integrations/immich/test', }), }, + { + id: 'synologyphotos', + name: 'Synology Photos', + description: 'Synology Photos integration with separate account settings', + icon: 'Image', + enabled: 0, + sort_order: 1, + config: JSON.stringify({ + settings_get: '/integrations/synologyphotos/settings', + settings_put: '/integrations/synologyphotos/settings', + status_get: '/integrations/synologyphotos/status', + test_get: '/integrations/synologyphotos/status', + }), + }, ]; const insertProvider = db.prepare('INSERT OR IGNORE INTO photo_providers (id, name, description, icon, enabled, config, sort_order) VALUES (?, ?, ?, ?, ?, ?, ?)'); for (const p of providerRows) insertProvider.run(p.id, p.name, p.description, p.icon, p.enabled, p.config, p.sort_order); @@ -115,6 +129,9 @@ function seedAddons(db: Database.Database): void { const providerFields = [ { provider_id: 'immich', field_key: 'immich_url', label: 'Immich URL', input_type: 'url', placeholder: 'https://immich.example.com', required: 1, secret: 0, settings_key: 'immich_url', payload_key: 'immich_url', sort_order: 0 }, { provider_id: 'immich', field_key: 'immich_api_key', label: 'API Key', input_type: 'password', placeholder: 'API Key', required: 1, secret: 1, settings_key: null, payload_key: 'immich_api_key', sort_order: 1 }, + { provider_id: 'synologyphotos', field_key: 'synology_url', label: 'Server URL', input_type: 'url', placeholder: 'https://synology.example.com', required: 1, secret: 0, settings_key: 'synology_url', payload_key: 'synology_url', sort_order: 0 }, + { provider_id: 'synologyphotos', field_key: 'synology_username', label: 'Username', input_type: 'text', placeholder: 'Username', required: 1, secret: 0, settings_key: 'synology_username', payload_key: 'synology_username', sort_order: 1 }, + { provider_id: 'synologyphotos', field_key: 'synology_password', label: 'Password', input_type: 'password', placeholder: 'Password', required: 1, secret: 1, settings_key: null, payload_key: 'synology_password', sort_order: 2 }, ]; const insertProviderField = db.prepare('INSERT OR IGNORE INTO photo_provider_fields (provider_id, field_key, label, input_type, placeholder, required, secret, settings_key, payload_key, sort_order) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)'); for (const f of providerFields) { diff --git a/server/src/index.ts b/server/src/index.ts index 533d92c..d1d682d 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -281,6 +281,8 @@ import atlasRoutes from './routes/atlas'; app.use('/api/addons/atlas', atlasRoutes); import immichRoutes from './routes/immich'; app.use('/api/integrations/immich', immichRoutes); +const synologyRoutes = require('./routes/synology').default; +app.use('/api/integrations/synologyphotos', synologyRoutes); import memoriesRoutes from './routes/memories'; app.use('/api/integrations/memories', memoriesRoutes); diff --git a/server/src/routes/synology.ts b/server/src/routes/synology.ts new file mode 100644 index 0000000..9e98fcf --- /dev/null +++ b/server/src/routes/synology.ts @@ -0,0 +1,610 @@ +import express, { NextFunction, Request, Response } from 'express'; +import { Readable } from 'node:stream'; +import { pipeline } from 'node:stream/promises'; +import { db, canAccessTrip } from '../db/database'; +import { authenticate } from '../middleware/auth'; +import { broadcast } from '../websocket'; +import { AuthRequest } from '../types'; +import { maybe_encrypt_api_key, decrypt_api_key } from '../services/apiKeyCrypto'; +import { consumeEphemeralToken } from '../services/ephemeralTokens'; + +const router = express.Router(); + +function copyProxyHeaders(resp: Response, upstream: globalThis.Response, headerNames: string[]): void { + for (const headerName of headerNames) { + const value = upstream.headers.get(headerName); + if (value) { + resp.set(headerName, value); + } + } +} + +// Helper: Get Synology credentials from users table +function getSynologyCredentials(userId: number) { + try { + const user = db.prepare('SELECT synology_url, synology_username, synology_password FROM users WHERE id = ?').get(userId) as any; + if (!user?.synology_url || !user?.synology_username || !user?.synology_password) return null; + return { + synology_url: user.synology_url as string, + synology_username: user.synology_username as string, + synology_password: decrypt_api_key(user.synology_password) as string, + }; + } catch { + return null; + } +} + +// Helper: Get cached SID from settings or users table +function getCachedSynologySID(userId: number) { + try { + const row = db.prepare('SELECT synology_sid FROM users WHERE id = ?').get(userId) as any; + return row?.synology_sid || null; + } catch { + return null; + } +} + +// Helper: Cache SID in users table +function cacheSynologySID(userId: number, sid: string) { + try { + db.prepare('UPDATE users SET synology_sid = ? WHERE id = ?').run(sid, userId); + } catch (err) { + // Ignore if columns don't exist yet + } +} + +// Helper: Get authenticated session + +interface SynologySession { + success: boolean; + sid?: string; + error?: { code: number; message?: string }; +} + +async function getSynologySession(userId: number): Promise { + // Check for cached SID + const cachedSid = getCachedSynologySID(userId); + if (cachedSid) { + return { success: true, sid: cachedSid }; + } + + const creds = getSynologyCredentials(userId); + // Login with credentials + if (!creds) { + return { success: false, error: { code: 400, message: 'Invalid Synology credentials' } }; + } + const endpoint = prepareSynologyEndpoint(creds.synology_url); + + const body = new URLSearchParams({ + api: 'SYNO.API.Auth', + method: 'login', + version: '3', + account: creds.synology_username, + passwd: creds.synology_password, + }); + + const resp = await fetch(endpoint, { + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8', + }, + body, + signal: AbortSignal.timeout(30000), + }); + + if (!resp.ok) { + return { success: false, error: { code: resp.status, message: 'Failed to authenticate with Synology' } }; + } + + const data = await resp.json() as { success: boolean; data?: { sid?: string } }; + + if (data.success && data.data?.sid) { + const sid = data.data.sid; + cacheSynologySID(userId, sid); + return { success: true, sid }; + } + + return { success: false, error: { code: 500, message: 'Failed to get Synology session' } }; +} + +// Helper: Clear cached SID + +function clearSynologySID(userId: number): void { + try { + db.prepare('UPDATE users SET synology_sid = NULL WHERE id = ?').run(userId); + } catch { + // Ignore if columns don't exist yet + } +} + +interface ApiCallParams { + api: string; + method: string; + version?: number; + [key: string]: any; +} + +interface SynologyApiResponse { + success: boolean; + data?: T; + error?: { code: number, message?: string }; +} + +function prepareSynologyEndpoint(url: string): string { + url = url.replace(/\/$/, ''); + if (!/^https?:\/\//.test(url)) { + url = `https://${url}`; + } + return `${url}/photo/webapi/entry.cgi`; +} + +function splitPackedSynologyId(rawId: string): { id: string; cacheKey: string; assetId: string } { + const id = rawId.split('_')[0]; + return { id: id, cacheKey: rawId, assetId: rawId }; +} + +function transformSynologyPhoto(item: any): any { + const address = item.additional?.address || {}; + return { + id: item.additional?.thumbnail?.cache_key, + takenAt: item.time ? new Date(item.time * 1000).toISOString() : null, + city: address.city || null, + country: address.country || null, + }; +} + +async function callSynologyApi(userId: number, params: ApiCallParams): Promise> { + try { + const creds = getSynologyCredentials(userId); + if (!creds) { + return { success: false, error: { code: 400, message: 'Synology not configured' } }; + } + const endpoint = prepareSynologyEndpoint(creds.synology_url); + + + const body = new URLSearchParams(); + for (const [key, value] of Object.entries(params)) { + if (value === undefined || value === null) continue; + body.append(key, typeof value === 'object' ? JSON.stringify(value) : String(value)); + } + + const sid = await getSynologySession(userId); + if (!sid.success || !sid.sid) { + return { success: false, error: sid.error || { code: 500, message: 'Failed to get Synology session' } }; + } + body.append('_sid', sid.sid); + + const resp = await fetch(endpoint, { + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8', + }, + body, + signal: AbortSignal.timeout(30000), + }); + + + if (!resp.ok) { + const text = await resp.text(); + return { success: false, error: { code: resp.status, message: text } }; + } + + const result = await resp.json() as SynologyApiResponse; + if (!result.success && result.error?.code === 119) { + clearSynologySID(userId); + return callSynologyApi(userId, params); + } + return result; + } catch (err) { + return { success: false, error: { code: -1, message: err instanceof Error ? err.message : 'Unknown error' } }; + } +} + +// Settings +router.get('/settings', authenticate, (req: Request, res: Response) => { + const authReq = req as AuthRequest; + const creds = getSynologyCredentials(authReq.user.id); + res.json({ + synology_url: creds?.synology_url || '', + synology_username: creds?.synology_username || '', + connected: !!(creds?.synology_url && creds?.synology_username), + }); +}); + +router.put('/settings', authenticate, (req: Request, res: Response) => { + const authReq = req as AuthRequest; + const { synology_url, synology_username, synology_password } = req.body; + + const url = String(synology_url || '').trim(); + const username = String(synology_username || '').trim(); + const password = String(synology_password || '').trim(); + + if (!url || !username) { + return res.status(400).json({ error: 'URL and username are required' }); + } + + const existing = db.prepare('SELECT synology_password FROM users WHERE id = ?').get(authReq.user.id) as { synology_password?: string | null } | undefined; + const existingEncryptedPassword = existing?.synology_password || null; + + // First-time setup requires password; later updates may keep existing password. + if (!password && !existingEncryptedPassword) { + return res.status(400).json({ error: 'Password is required' }); + } + + try { + db.prepare('UPDATE users SET synology_url = ?, synology_username = ?, synology_password = ? WHERE id = ?').run( + url, + username, + password ? maybe_encrypt_api_key(password) : existingEncryptedPassword, + authReq.user.id + ); + } catch (err) { + return res.status(400).json({ error: 'Failed to save settings' }); + } + + clearSynologySID(authReq.user.id); + res.json({ success: true }); +}); + +// Status +router.get('/status', authenticate, async (req: Request, res: Response) => { + const authReq = req as AuthRequest; + + try { + const sid = await getSynologySession(authReq.user.id); + if (!sid.success || !sid.sid) { + return res.json({ connected: false, error: 'Authentication failed' }); + } + + const user = db.prepare('SELECT synology_username FROM users WHERE id = ?').get(authReq.user.id) as any; + res.json({ connected: true, user: { username: user.synology_username } }); + } catch (err: unknown) { + res.json({ connected: false, error: err instanceof Error ? err.message : 'Connection failed' }); + } +}); + +// Album linking parity with Immich +router.get('/albums', authenticate, async (req: Request, res: Response) => { + const authReq = req as AuthRequest; + try { + const result = await callSynologyApi<{ list: any[] }>(authReq.user.id, { + api: 'SYNO.Foto.Browse.Album', + method: 'list', + version: 4, + offset: 0, + limit: 100, + }); + + if (!result.success || !result.data) { + return res.status(502).json({ error: result.error?.message || 'Failed to fetch albums' }); + } + + const albums = (result.data.list || []).map((a: any) => ({ + id: String(a.id), + albumName: a.name || '', + assetCount: a.item_count || 0, + })); + + res.json({ albums }); + } catch (err: unknown) { + res.status(502).json({ error: err instanceof Error ? err.message : 'Could not reach Synology' }); + } +}); + +router.post('/trips/:tripId/album-links', authenticate, (req: Request, res: Response) => { + const authReq = req as AuthRequest; + const { tripId } = req.params; + if (!canAccessTrip(tripId, authReq.user.id)) return res.status(404).json({ error: 'Trip not found' }); + const { album_id, album_name } = req.body; + if (!album_id) return res.status(400).json({ error: 'album_id required' }); + + try { + db.prepare( + 'INSERT OR IGNORE INTO trip_album_links (trip_id, user_id, provider, album_id, album_name) VALUES (?, ?, ?, ?, ?)' + ).run(tripId, authReq.user.id, 'synologyphotos', String(album_id), album_name || ''); + res.json({ success: true }); + } catch { + res.status(400).json({ error: 'Album already linked' }); + } +}); + +router.post('/trips/:tripId/album-links/:linkId/sync', authenticate, async (req: Request, res: Response) => { + const authReq = req as AuthRequest; + const { tripId, linkId } = req.params; + + const link = db.prepare("SELECT * FROM trip_album_links WHERE id = ? AND trip_id = ? AND user_id = ? AND provider = 'synologyphotos'") + .get(linkId, tripId, authReq.user.id) as any; + if (!link) return res.status(404).json({ error: 'Album link not found' }); + + try { + const allItems: any[] = []; + const pageSize = 1000; + let offset = 0; + + while (true) { + const result = await callSynologyApi<{ list: any[] }>(authReq.user.id, { + api: 'SYNO.Foto.Browse.Item', + method: 'list', + version: 1, + album_id: Number(link.album_id), + offset, + limit: pageSize, + additional: ['thumbnail'], + }); + + if (!result.success || !result.data) { + return res.status(502).json({ error: result.error?.message || 'Failed to fetch album' }); + } + + const items = result.data.list || []; + allItems.push(...items); + if (items.length < pageSize) break; + offset += pageSize; + } + + const insert = db.prepare( + "INSERT OR IGNORE INTO trip_photos (trip_id, user_id, asset_id, provider, shared) VALUES (?, ?, ?, 'synologyphotos', 1)" + ); + + let added = 0; + for (const item of allItems) { + const transformed = transformSynologyPhoto(item); + const assetId = String(transformed?.id || '').trim(); + if (!assetId) continue; + const r = insert.run(tripId, authReq.user.id, assetId); + if (r.changes > 0) added++; + } + + db.prepare('UPDATE trip_album_links SET last_synced_at = CURRENT_TIMESTAMP WHERE id = ?').run(linkId); + + res.json({ success: true, added, total: allItems.length }); + if (added > 0) { + broadcast(tripId, 'memories:updated', { userId: authReq.user.id }, req.headers['x-socket-id'] as string); + } + } catch (err: unknown) { + res.status(502).json({ error: err instanceof Error ? err.message : 'Could not reach Synology' }); + } +}); + +// Search +router.post('/search', authenticate, async (req: Request, res: Response) => { + const authReq = req as AuthRequest; + let { from, to, offset = 0, limit = 300 } = req.body; + + try { + const params: any = { + api: 'SYNO.Foto.Search.Search', + method: 'list_item', + version: 1, + offset, + limit, + keyword: '.', + additional: ['thumbnail', 'address'], + }; + + if (from || to) { + if (from) { + params.start_time = Math.floor(new Date(from).getTime() / 1000); + } + if (to) { + params.end_time = Math.floor(new Date(to).getTime() / 1000) + 86400; // Include entire end day + } + } + + + const result = await callSynologyApi<{ list: any[]; total: number }>(authReq.user.id, params); + + if (!result.success || !result.data) { + return res.status(502).json({ error: result.error?.message || 'Failed to fetch album photos' }); + } + + const allItems = (result.data.list || []); + const total = allItems.length; + + const assets = allItems.map((item: any) => transformSynologyPhoto(item)); + + res.json({ + assets, + total, + hasMore: total == limit, + }); + } catch (err: unknown) { + res.status(502).json({ error: err instanceof Error ? err.message : 'Could not reach Synology' }); + } +}); + +// Proxy Synology Assets + +// Asset info endpoint (returns metadata, not image) +router.get('/assets/:photoId/info', authenticate, async (req: Request, res: Response) => { + const authReq = req as AuthRequest; + const { photoId } = req.params; + const parsedId = splitPackedSynologyId(photoId); + const { userId } = req.query; + + const targetUserId = userId ? Number(userId) : authReq.user.id; + + try { + const result = await callSynologyApi(targetUserId, { + api: 'SYNO.Foto.Browse.Item', + method: 'get', + version: 2, + id: Number(parsedId.id), + additional: ['thumbnail', 'resolution', 'exif', 'gps', 'address', 'orientation', 'description'], + }); + if (!result.success || !result.data) { + return res.status(404).json({ error: 'Photo not found' }); + } + + + const exif = result.data.additional?.exif || {}; + const address = result.data.additional?.address || {}; + const gps = result.data.additional?.gps || {}; + res.json({ + id: result.data.id, + takenAt: result.data.time ? new Date(result.data.time * 1000).toISOString() : null, + width: result.data.additional?.resolution?.width || null, + height: result.data.additional?.resolution?.height || null, + camera: exif.model || null, + lens: exif.lens_model || null, + focalLength: exif.focal_length ? `${exif.focal_length}mm` : null, + aperture: exif.f_number ? `f/${exif.f_number}` : null, + shutter: exif.exposure_time || null, + iso: exif.iso_speed_ratings || null, + city: address.city || null, + state: address.state || null, + country: address.country || null, + lat: gps.latitude || null, + lng: gps.longitude || null, + orientation: result.data.additional?.orientation || null, + description: result.data.additional?.description || null, + fileSize: result.data.filesize || null, + fileName: result.data.filename || null, + }); + } catch (err: unknown) { + res.status(502).json({ error: err instanceof Error ? err.message : 'Could not reach Synology'}); + } +}); + +// Middleware: Accept ephemeral token from query param for tags +function authFromQuery(req: Request, res: Response, next: NextFunction) { + const queryToken = req.query.token as string | undefined; + if (queryToken) { + const userId = consumeEphemeralToken(queryToken, 'synologyphotos'); + if (!userId) return res.status(401).send('Invalid or expired token'); + const user = db.prepare('SELECT id, username, email, role, mfa_enabled FROM users WHERE id = ?').get(userId) as any; + if (!user) return res.status(401).send('User not found'); + (req as AuthRequest).user = user; + return next(); + } + return (authenticate as any)(req, res, next); +} + +router.get('/assets/:photoId/thumbnail', authFromQuery, async (req: Request, res: Response) => { + const authReq = req as AuthRequest; + const { photoId } = req.params; + const parsedId = splitPackedSynologyId(photoId); + const { userId, cacheKey, size = 'sm' } = req.query; + + const targetUserId = userId ? Number(userId) : authReq.user.id; + + const creds = getSynologyCredentials(targetUserId); + if (!creds) { + return res.status(404).send('Not found'); + } + + try { + const sid = await getSynologySession(authReq.user.id); + if (!sid.success && !sid.sid) { + return res.status(401).send('Authentication failed'); + } + + let resolvedCacheKey = cacheKey ? String(cacheKey) : parsedId.cacheKey; + if (!resolvedCacheKey) { + const row = db.prepare(` + SELECT asset_id FROM trip_photos + WHERE user_id = ? AND (asset_id = ? OR asset_id = ? OR asset_id LIKE ? OR asset_id LIKE ?) + ORDER BY id DESC LIMIT 1 + `).get(targetUserId, parsedId.assetId, parsedId.id, `${parsedId.id}_%`, `${parsedId.id}::%`) as { asset_id?: string } | undefined; + const packed = row?.asset_id || ''; + if (packed) { + resolvedCacheKey = splitPackedSynologyId(packed).cacheKey; + } + } + if (!resolvedCacheKey) return res.status(404).send('Missing cache key for thumbnail'); + + const params = new URLSearchParams({ + api: 'SYNO.Foto.Thumbnail', + method: 'get', + version: '2', + mode: 'download', + id: String(parsedId.id), + type: 'unit', + size: String(size), + cache_key: resolvedCacheKey, + _sid: sid.sid, + }); + const url = prepareSynologyEndpoint(creds.synology_url) + '?' + params.toString(); + const resp = await fetch(url, { + signal: AbortSignal.timeout(30000), + }); + + if (!resp.ok) { + return res.status(resp.status).send('Failed'); + } + + res.status(resp.status); + copyProxyHeaders(res, resp, ['content-type', 'cache-control', 'content-length', 'content-disposition']); + res.set('Content-Type', resp.headers.get('content-type') || 'image/jpeg'); + res.set('Cache-Control', resp.headers.get('cache-control') || 'public, max-age=86400'); + + if (!resp.body) { + return res.end(); + } + + await pipeline(Readable.fromWeb(resp.body), res); + } catch (err: unknown) { + if (res.headersSent) { + return; + } + res.status(502).send('Proxy error: ' + (err instanceof Error ? err.message : String(err))); + } +}); + + +router.get('/assets/download', authFromQuery, async (req: Request, res: Response) => { + const authReq = req as AuthRequest; + const { userId, cacheKey, unitIds } = req.query; + + const targetUserId = userId ? Number(userId) : authReq.user.id; + + const creds = getSynologyCredentials(targetUserId); + if (!creds) { + return res.status(404).send('Not found'); + } + + try { + const sid = await getSynologySession(authReq.user.id); + if (!sid.success && !sid.sid) { + return res.status(401).send('Authentication failed'); + } + + const params = new URLSearchParams({ + api: 'SYNO.Foto.Download', + method: 'download', + version: '2', + cache_key: String(cacheKey), + unit_id: "[" + String(unitIds) + "]", + _sid: sid.sid, + }); + + const url = prepareSynologyEndpoint(creds.synology_url) + '?' + params.toString(); + const resp = await fetch(url, { + signal: AbortSignal.timeout(30000), + }); + + if (!resp.ok) { + const body = await resp.text(); + return res.status(resp.status).send('Failed: ' + body); + } + + res.status(resp.status); + copyProxyHeaders(res, resp, ['content-type', 'cache-control', 'content-length', 'content-disposition']); + res.set('Content-Type', resp.headers.get('content-type') || 'application/octet-stream'); + res.set('Cache-Control', resp.headers.get('cache-control') || 'public, max-age=86400'); + + if (!resp.body) { + return res.end(); + } + + await pipeline(Readable.fromWeb(resp.body), res); + } catch (err: unknown) { + if (res.headersSent) { + return; + } + res.status(502).send('Proxy error: ' + (err instanceof Error ? err.message : String(err))); + } +}); + + +export default router; diff --git a/server/src/services/authService.ts b/server/src/services/authService.ts index c069645..d743519 100644 --- a/server/src/services/authService.ts +++ b/server/src/services/authService.ts @@ -981,7 +981,7 @@ export function createWsToken(userId: number): { error?: string; status?: number } export function createResourceToken(userId: number, purpose?: string): { error?: string; status?: number; token?: string } { - if (purpose !== 'download' && purpose !== 'immich') { + if (purpose !== 'download' && purpose !== 'immich' && purpose !== 'synologyphotos') { return { error: 'Invalid purpose', status: 400 }; } const token = createEphemeralToken(userId, purpose); diff --git a/server/src/services/ephemeralTokens.ts b/server/src/services/ephemeralTokens.ts index 0d1c12b..f880951 100644 --- a/server/src/services/ephemeralTokens.ts +++ b/server/src/services/ephemeralTokens.ts @@ -4,6 +4,7 @@ const TTL: Record = { ws: 30_000, download: 60_000, immich: 60_000, + synologyphotos: 60_000, }; const MAX_STORE_SIZE = 10_000; From a7d3f9fc06ffe67f0b7fd8865835c91dde075671 Mon Sep 17 00:00:00 2001 From: Marek Maslowski Date: Fri, 3 Apr 2026 03:08:15 +0200 Subject: [PATCH 10/57] returning test connectioon button to original intend --- server/src/db/migrations.ts | 2 +- server/src/db/seeds.ts | 2 +- server/src/routes/synology.ts | 44 +++++++++++++++++++++++++++++++++++ 3 files changed, 46 insertions(+), 2 deletions(-) diff --git a/server/src/db/migrations.ts b/server/src/db/migrations.ts index d14d4a8..20f160d 100644 --- a/server/src/db/migrations.ts +++ b/server/src/db/migrations.ts @@ -662,7 +662,7 @@ function runMigrations(db: Database.Database): void { settings_get: '/integrations/synologyphotos/settings', settings_put: '/integrations/synologyphotos/settings', status_get: '/integrations/synologyphotos/status', - test_get: '/integrations/synologyphotos/status', + test_post: '/integrations/synologyphotos/test', }), 1, ); diff --git a/server/src/db/seeds.ts b/server/src/db/seeds.ts index 8035d21..ef849d9 100644 --- a/server/src/db/seeds.ts +++ b/server/src/db/seeds.ts @@ -119,7 +119,7 @@ function seedAddons(db: Database.Database): void { settings_get: '/integrations/synologyphotos/settings', settings_put: '/integrations/synologyphotos/settings', status_get: '/integrations/synologyphotos/status', - test_get: '/integrations/synologyphotos/status', + test_post: '/integrations/synologyphotos/test', }), }, ]; diff --git a/server/src/routes/synology.ts b/server/src/routes/synology.ts index 9e98fcf..c1e868e 100644 --- a/server/src/routes/synology.ts +++ b/server/src/routes/synology.ts @@ -7,6 +7,7 @@ import { broadcast } from '../websocket'; import { AuthRequest } from '../types'; import { maybe_encrypt_api_key, decrypt_api_key } from '../services/apiKeyCrypto'; import { consumeEphemeralToken } from '../services/ephemeralTokens'; +import { checkSsrf } from '../utils/ssrfGuard'; const router = express.Router(); @@ -263,6 +264,49 @@ router.get('/status', authenticate, async (req: Request, res: Response) => { } }); +// Test connection with provided credentials only +router.post('/test', authenticate, async (req: Request, res: Response) => { + const { synology_url, synology_username, synology_password } = req.body as { synology_url?: string; synology_username?: string; synology_password?: string }; + + const url = String(synology_url || '').trim(); + const username = String(synology_username || '').trim(); + const password = String(synology_password || '').trim(); + + if (!url || !username || !password) { + return res.json({ connected: false, error: 'URL, username, and password are required' }); + } + + const ssrf = await checkSsrf(url); + if (!ssrf.allowed) return res.json({ connected: false, error: ssrf.error ?? 'Invalid Synology URL' }); + + try { + const endpoint = prepareSynologyEndpoint(url); + const body = new URLSearchParams({ + api: 'SYNO.API.Auth', + method: 'login', + version: '3', + account: username, + passwd: password, + }); + + const resp = await fetch(endpoint, { + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8', + }, + body, + signal: AbortSignal.timeout(30000), + }); + + if (!resp.ok) return res.json({ connected: false, error: `HTTP ${resp.status}` }); + const data = await resp.json() as { success: boolean; data?: { sid?: string } }; + if (!data.success || !data.data?.sid) return res.json({ connected: false, error: 'Authentication failed' }); + return res.json({ connected: true, user: { username } }); + } catch (err: unknown) { + return res.json({ connected: false, error: err instanceof Error ? err.message : 'Connection failed' }); + } +}); + // Album linking parity with Immich router.get('/albums', authenticate, async (req: Request, res: Response) => { const authReq = req as AuthRequest; From d418d85d024eae6974e772348d39d63f80c9e43d Mon Sep 17 00:00:00 2001 From: Marek Maslowski Date: Fri, 3 Apr 2026 03:20:45 +0200 Subject: [PATCH 11/57] fixing selection of photos from multiple sources at once --- .../src/components/Memories/MemoriesPanel.tsx | 45 +++++++++++++------ server/src/routes/memories.ts | 32 ++++++++----- 2 files changed, 52 insertions(+), 25 deletions(-) diff --git a/client/src/components/Memories/MemoriesPanel.tsx b/client/src/components/Memories/MemoriesPanel.tsx index b288487..8787dd7 100644 --- a/client/src/components/Memories/MemoriesPanel.tsx +++ b/client/src/components/Memories/MemoriesPanel.tsx @@ -33,11 +33,12 @@ interface TripPhoto { city?: string | null } -interface ImmichAsset { +interface Asset { id: string takenAt: string city: string | null country: string | null + provider: string } interface MemoriesPanelProps { @@ -63,7 +64,7 @@ export default function MemoriesPanel({ tripId, startDate, endDate }: MemoriesPa // Photo picker const [showPicker, setShowPicker] = useState(false) - const [pickerPhotos, setPickerPhotos] = useState([]) + const [pickerPhotos, setPickerPhotos] = useState([]) const [pickerLoading, setPickerLoading] = useState(false) const [selectedIds, setSelectedIds] = useState>(new Set()) @@ -238,11 +239,16 @@ export default function MemoriesPanel({ tripId, startDate, endDate }: MemoriesPa const loadPickerPhotos = async (useDate: boolean) => { setPickerLoading(true) try { - const res = await apiClient.post(`${pickerIntegrationBase}/search`, { + const provider = availableProviders.find(p => p.id === selectedProvider) + if (!provider) { + setPickerPhotos([]) + return + } + const res = await apiClient.post(`/integrations/${provider.id}/search`, { from: useDate && startDate ? startDate : undefined, to: useDate && endDate ? endDate : undefined, }) - setPickerPhotos(res.data.assets || []) + setPickerPhotos((res.data.assets || []).map((asset: Asset) => ({ ...asset, provider: provider.id }))) } catch { setPickerPhotos([]) toast.error(t('memories.error.loadPhotos')) @@ -268,9 +274,17 @@ export default function MemoriesPanel({ tripId, startDate, endDate }: MemoriesPa const executeAddPhotos = async () => { setShowConfirmShare(false) try { + const groupedByProvider = new Map() + for (const key of selectedIds) { + const [provider, assetId] = key.split('::') + if (!provider || !assetId) continue + const list = groupedByProvider.get(provider) || [] + list.push(assetId) + groupedByProvider.set(provider, list) + } + await apiClient.post(`/integrations/memories/trips/${tripId}/photos`, { - provider: selectedProvider, - asset_ids: [...selectedIds], + selections: [...groupedByProvider.entries()].map(([provider, asset_ids]) => ({ provider, asset_ids })), shared: true, }) setShowPicker(false) @@ -312,6 +326,8 @@ export default function MemoriesPanel({ tripId, startDate, endDate }: MemoriesPa const thumbnailBaseUrl = (photo: TripPhoto) => `/api/integrations/${photo.provider}/assets/${photo.asset_id}/thumbnail?userId=${photo.user_id}` + const makePickerKey = (provider: string, assetId: string): string => `${provider}::${assetId}` + const ownPhotos = tripPhotos.filter(p => p.user_id === currentUser?.id) const othersPhotos = tripPhotos.filter(p => p.user_id !== currentUser?.id && p.shared) const allVisibleRaw = [...ownPhotos, ...othersPhotos] @@ -461,8 +477,8 @@ export default function MemoriesPanel({ tripId, startDate, endDate }: MemoriesPa if (showPicker) { const alreadyAdded = new Set( tripPhotos - .filter(p => p.user_id === currentUser?.id && p.provider === selectedProvider) - .map(p => p.asset_id) + .filter(p => p.user_id === currentUser?.id) + .map(p => makePickerKey(p.provider, p.asset_id)) ) return ( @@ -537,7 +553,7 @@ export default function MemoriesPanel({ tripId, startDate, endDate }: MemoriesPa
) : (() => { // Group photos by month - const byMonth: Record = {} + const byMonth: Record = {} for (const asset of pickerPhotos) { const d = asset.takenAt ? new Date(asset.takenAt) : null const key = d ? `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}` : 'unknown' @@ -555,11 +571,12 @@ export default function MemoriesPanel({ tripId, startDate, endDate }: MemoriesPa
{byMonth[month].map(asset => { - const isSelected = selectedIds.has(asset.id) - const isAlready = alreadyAdded.has(asset.id) + const pickerKey = makePickerKey(asset.provider, asset.id) + const isSelected = selectedIds.has(pickerKey) + const isAlready = alreadyAdded.has(pickerKey) return ( -
!isAlready && togglePickerSelect(asset.id)} +
!isAlready && togglePickerSelect(pickerKey)} style={{ position: 'relative', aspectRatio: '1', borderRadius: 8, overflow: 'hidden', cursor: isAlready ? 'default' : 'pointer', @@ -567,7 +584,7 @@ export default function MemoriesPanel({ tripId, startDate, endDate }: MemoriesPa outline: isSelected ? '3px solid var(--text-primary)' : 'none', outlineOffset: -3, }}> - {isSelected && (
{ const authReq = req as AuthRequest; const { tripId } = req.params; - const provider = String(req.body?.provider || '').toLowerCase(); const { shared = true } = req.body; + const selectionsRaw = Array.isArray(req.body?.selections) ? req.body.selections : null; + const provider = String(req.body?.provider || '').toLowerCase(); const assetIdsRaw = req.body?.asset_ids; if (!canAccessTrip(tripId, authReq.user.id)) { return res.status(404).json({ error: 'Trip not found' }); } - if (!provider) { - return res.status(400).json({ error: 'provider is required' }); - } + const selections = selectionsRaw && selectionsRaw.length > 0 + ? selectionsRaw + .map((selection: any) => ({ + provider: String(selection?.provider || '').toLowerCase(), + asset_ids: Array.isArray(selection?.asset_ids) ? selection.asset_ids : [], + })) + .filter((selection: { provider: string; asset_ids: unknown[] }) => selection.provider && selection.asset_ids.length > 0) + : (provider && Array.isArray(assetIdsRaw) && assetIdsRaw.length > 0 + ? [{ provider, asset_ids: assetIdsRaw }] + : []); - if (!Array.isArray(assetIdsRaw) || assetIdsRaw.length === 0) { - return res.status(400).json({ error: 'asset_ids required' }); + if (selections.length === 0) { + return res.status(400).json({ error: 'selections required' }); } const insert = db.prepare( @@ -95,11 +103,13 @@ router.post('/trips/:tripId/photos', authenticate, (req: Request, res: Response) ); let added = 0; - for (const raw of assetIdsRaw) { - const assetId = String(raw || '').trim(); - if (!assetId) continue; - const result = insert.run(tripId, authReq.user.id, assetId, provider, shared ? 1 : 0); - if (result.changes > 0) added++; + for (const selection of selections) { + for (const raw of selection.asset_ids) { + const assetId = String(raw || '').trim(); + if (!assetId) continue; + const result = insert.run(tripId, authReq.user.id, assetId, selection.provider, shared ? 1 : 0); + if (result.changes > 0) added++; + } } res.json({ success: true, added }); From 1e27a62b537ba20a2201e15f76231f61af44686b Mon Sep 17 00:00:00 2001 From: Marek Maslowski Date: Fri, 3 Apr 2026 03:29:13 +0200 Subject: [PATCH 12/57] fixing path for asset in full res --- server/src/routes/synology.ts | 30 +++++++++--------------------- 1 file changed, 9 insertions(+), 21 deletions(-) diff --git a/server/src/routes/synology.ts b/server/src/routes/synology.ts index c1e868e..7312eaa 100644 --- a/server/src/routes/synology.ts +++ b/server/src/routes/synology.ts @@ -528,7 +528,7 @@ router.get('/assets/:photoId/thumbnail', authFromQuery, async (req: Request, res const authReq = req as AuthRequest; const { photoId } = req.params; const parsedId = splitPackedSynologyId(photoId); - const { userId, cacheKey, size = 'sm' } = req.query; + const { userId, size = 'sm' } = req.query; const targetUserId = userId ? Number(userId) : authReq.user.id; @@ -543,29 +543,15 @@ router.get('/assets/:photoId/thumbnail', authFromQuery, async (req: Request, res return res.status(401).send('Authentication failed'); } - let resolvedCacheKey = cacheKey ? String(cacheKey) : parsedId.cacheKey; - if (!resolvedCacheKey) { - const row = db.prepare(` - SELECT asset_id FROM trip_photos - WHERE user_id = ? AND (asset_id = ? OR asset_id = ? OR asset_id LIKE ? OR asset_id LIKE ?) - ORDER BY id DESC LIMIT 1 - `).get(targetUserId, parsedId.assetId, parsedId.id, `${parsedId.id}_%`, `${parsedId.id}::%`) as { asset_id?: string } | undefined; - const packed = row?.asset_id || ''; - if (packed) { - resolvedCacheKey = splitPackedSynologyId(packed).cacheKey; - } - } - if (!resolvedCacheKey) return res.status(404).send('Missing cache key for thumbnail'); - const params = new URLSearchParams({ api: 'SYNO.Foto.Thumbnail', method: 'get', version: '2', mode: 'download', - id: String(parsedId.id), + id: parsedId.id, type: 'unit', size: String(size), - cache_key: resolvedCacheKey, + cache_key: parsedId.cacheKey, _sid: sid.sid, }); const url = prepareSynologyEndpoint(creds.synology_url) + '?' + params.toString(); @@ -596,9 +582,11 @@ router.get('/assets/:photoId/thumbnail', authFromQuery, async (req: Request, res }); -router.get('/assets/download', authFromQuery, async (req: Request, res: Response) => { +router.get('/assets/:photoId/original', authFromQuery, async (req: Request, res: Response) => { const authReq = req as AuthRequest; - const { userId, cacheKey, unitIds } = req.query; + const { photoId } = req.params; + const parsedId = splitPackedSynologyId(photoId || ''); + const { userId} = req.query; const targetUserId = userId ? Number(userId) : authReq.user.id; @@ -617,8 +605,8 @@ router.get('/assets/download', authFromQuery, async (req: Request, res: Response api: 'SYNO.Foto.Download', method: 'download', version: '2', - cache_key: String(cacheKey), - unit_id: "[" + String(unitIds) + "]", + cache_key: parsedId.cacheKey, + unit_id: `[${parsedId.id}]`, _sid: sid.sid, }); From be03fffcae98e48965688ba02318582235f8e4ec Mon Sep 17 00:00:00 2001 From: Marek Maslowski Date: Fri, 3 Apr 2026 03:47:44 +0200 Subject: [PATCH 13/57] fixing metada --- server/src/routes/synology.ts | 47 +++++++++++++++++++---------------- 1 file changed, 25 insertions(+), 22 deletions(-) diff --git a/server/src/routes/synology.ts b/server/src/routes/synology.ts index 7312eaa..71fed86 100644 --- a/server/src/routes/synology.ts +++ b/server/src/routes/synology.ts @@ -472,38 +472,41 @@ router.get('/assets/:photoId/info', authenticate, async (req: Request, res: Resp const result = await callSynologyApi(targetUserId, { api: 'SYNO.Foto.Browse.Item', method: 'get', - version: 2, - id: Number(parsedId.id), - additional: ['thumbnail', 'resolution', 'exif', 'gps', 'address', 'orientation', 'description'], + version: 5, + id: `[${parsedId.id}]`, + additional: ['resolution', 'exif', 'gps', 'address', 'orientation', 'description'], }); if (!result.success || !result.data) { return res.status(404).json({ error: 'Photo not found' }); } - - const exif = result.data.additional?.exif || {}; - const address = result.data.additional?.address || {}; - const gps = result.data.additional?.gps || {}; + const metadata = result.data.list[0]; + console.log(metadata); + const exif = metadata.additional?.exif || {}; + const address = metadata.additional?.address || {}; + const gps = metadata.additional?.gps || {}; res.json({ - id: result.data.id, - takenAt: result.data.time ? new Date(result.data.time * 1000).toISOString() : null, - width: result.data.additional?.resolution?.width || null, - height: result.data.additional?.resolution?.height || null, - camera: exif.model || null, - lens: exif.lens_model || null, - focalLength: exif.focal_length ? `${exif.focal_length}mm` : null, - aperture: exif.f_number ? `f/${exif.f_number}` : null, - shutter: exif.exposure_time || null, - iso: exif.iso_speed_ratings || null, + id: photoId, + takenAt: metadata.time ? new Date(metadata.time * 1000).toISOString() : null, city: address.city || null, - state: address.state || null, country: address.country || null, + state: address.state || null, + camera: exif.camera || null, + lens: exif.lens || null, + focalLength: exif.focal_length || null, + aperture: exif.aperture || null, + shutter: exif.exposure_time || null, + iso: exif.iso || null, lat: gps.latitude || null, lng: gps.longitude || null, - orientation: result.data.additional?.orientation || null, - description: result.data.additional?.description || null, - fileSize: result.data.filesize || null, - fileName: result.data.filename || null, + orientation: metadata.additional?.orientation || null, + description: metadata.additional?.description || null, + filename: metadata.filename || null, + filesize: metadata.filesize || null, + width: metadata.additional?.resolution?.width || null, + height: metadata.additional?.resolution?.height || null, + fileSize: metadata.filesize || null, + fileName: metadata.filename || null, }); } catch (err: unknown) { res.status(502).json({ error: err instanceof Error ? err.message : 'Could not reach Synology'}); From b224f8b713efd18d4a6bac878d66708114c0b808 Mon Sep 17 00:00:00 2001 From: Marek Maslowski Date: Fri, 3 Apr 2026 12:19:00 +0200 Subject: [PATCH 14/57] fixing errors in migration --- server/src/services/adminService.ts | 89 +++++++++++++++++++++++++--- server/src/services/immichService.ts | 33 +++++------ 2 files changed, 97 insertions(+), 25 deletions(-) diff --git a/server/src/services/adminService.ts b/server/src/services/adminService.ts index ae42c92..f7df1f4 100644 --- a/server/src/services/adminService.ts +++ b/server/src/services/adminService.ts @@ -465,17 +465,92 @@ export function deleteTemplateItem(itemId: string) { export function listAddons() { const addons = db.prepare('SELECT * FROM addons ORDER BY sort_order, id').all() as Addon[]; - return addons.map(a => ({ ...a, enabled: !!a.enabled, config: JSON.parse(a.config || '{}') })); + const providers = db.prepare(` + SELECT id, name, description, icon, enabled, config, sort_order + FROM photo_providers + ORDER BY sort_order, id + `).all() as Array<{ id: string; name: string; description?: string | null; icon: string; enabled: number; config: string; sort_order: number }>; + const fields = db.prepare(` + SELECT provider_id, field_key, label, input_type, placeholder, required, secret, settings_key, payload_key, sort_order + FROM photo_provider_fields + ORDER BY sort_order, id + `).all() as Array<{ + provider_id: string; + field_key: string; + label: string; + input_type: string; + placeholder?: string | null; + required: number; + secret: number; + settings_key?: string | null; + payload_key?: string | null; + sort_order: number; + }>; + const fieldsByProvider = new Map(); + for (const field of fields) { + const arr = fieldsByProvider.get(field.provider_id) || []; + arr.push(field); + fieldsByProvider.set(field.provider_id, arr); + } + + return [ + ...addons.map(a => ({ ...a, enabled: !!a.enabled, config: JSON.parse(a.config || '{}') })), + ...providers.map(p => ({ + id: p.id, + name: p.name, + description: p.description, + type: 'photo_provider', + icon: p.icon, + enabled: !!p.enabled, + config: JSON.parse(p.config || '{}'), + fields: (fieldsByProvider.get(p.id) || []).map(f => ({ + key: f.field_key, + label: f.label, + input_type: f.input_type, + placeholder: f.placeholder || '', + required: !!f.required, + secret: !!f.secret, + settings_key: f.settings_key || null, + payload_key: f.payload_key || null, + sort_order: f.sort_order, + })), + sort_order: p.sort_order, + })), + ]; } export function updateAddon(id: string, data: { enabled?: boolean; config?: Record }) { - const addon = db.prepare('SELECT * FROM addons WHERE id = ?').get(id); - if (!addon) return { error: 'Addon not found', status: 404 }; - if (data.enabled !== undefined) db.prepare('UPDATE addons SET enabled = ? WHERE id = ?').run(data.enabled ? 1 : 0, id); - if (data.config !== undefined) db.prepare('UPDATE addons SET config = ? WHERE id = ?').run(JSON.stringify(data.config), id); - const updated = db.prepare('SELECT * FROM addons WHERE id = ?').get(id) as Addon; + const addon = db.prepare('SELECT * FROM addons WHERE id = ?').get(id) as Addon | undefined; + const provider = db.prepare('SELECT * FROM photo_providers WHERE id = ?').get(id) as { id: string; name: string; description?: string | null; icon: string; enabled: number; config: string; sort_order: number } | undefined; + if (!addon && !provider) return { error: 'Addon not found', status: 404 }; + + if (addon) { + if (data.enabled !== undefined) db.prepare('UPDATE addons SET enabled = ? WHERE id = ?').run(data.enabled ? 1 : 0, id); + if (data.config !== undefined) db.prepare('UPDATE addons SET config = ? WHERE id = ?').run(JSON.stringify(data.config), id); + } else { + if (data.enabled !== undefined) db.prepare('UPDATE photo_providers SET enabled = ? WHERE id = ?').run(data.enabled ? 1 : 0, id); + if (data.config !== undefined) db.prepare('UPDATE photo_providers SET config = ? WHERE id = ?').run(JSON.stringify(data.config), id); + } + + const updatedAddon = db.prepare('SELECT * FROM addons WHERE id = ?').get(id) as Addon | undefined; + const updatedProvider = db.prepare('SELECT * FROM photo_providers WHERE id = ?').get(id) as { id: string; name: string; description?: string | null; icon: string; enabled: number; config: string; sort_order: number } | undefined; + const updated = updatedAddon + ? { ...updatedAddon, enabled: !!updatedAddon.enabled, config: JSON.parse(updatedAddon.config || '{}') } + : updatedProvider + ? { + id: updatedProvider.id, + name: updatedProvider.name, + description: updatedProvider.description, + type: 'photo_provider', + icon: updatedProvider.icon, + enabled: !!updatedProvider.enabled, + config: JSON.parse(updatedProvider.config || '{}'), + sort_order: updatedProvider.sort_order, + } + : null; + return { - addon: { ...updated, enabled: !!updated.enabled, config: JSON.parse(updated.config || '{}') }, + addon: updated, auditDetails: { enabled: data.enabled !== undefined ? !!data.enabled : undefined, config_changed: data.config !== undefined }, }; } diff --git a/server/src/services/immichService.ts b/server/src/services/immichService.ts index 9dd3de5..4a3169f 100644 --- a/server/src/services/immichService.ts +++ b/server/src/services/immichService.ts @@ -175,11 +175,12 @@ export async function searchPhotos( export function listTripPhotos(tripId: string, userId: number) { return db.prepare(` - SELECT tp.immich_asset_id, tp.user_id, tp.shared, tp.added_at, + SELECT tp.asset_id AS immich_asset_id, tp.user_id, tp.shared, tp.added_at, u.username, u.avatar, u.immich_url FROM trip_photos tp JOIN users u ON tp.user_id = u.id WHERE tp.trip_id = ? + AND tp.provider = 'immich' AND (tp.user_id = ? OR tp.shared = 1) ORDER BY tp.added_at ASC `).all(tripId, userId); @@ -191,25 +192,23 @@ export function addTripPhotos( assetIds: string[], shared: boolean ): number { - const insert = db.prepare( - 'INSERT OR IGNORE INTO trip_photos (trip_id, user_id, immich_asset_id, shared) VALUES (?, ?, ?, ?)' - ); + const insert = db.prepare('INSERT OR IGNORE INTO trip_photos (trip_id, user_id, asset_id, provider, shared) VALUES (?, ?, ?, ?, ?)'); let added = 0; for (const assetId of assetIds) { - const result = insert.run(tripId, userId, assetId, shared ? 1 : 0); + const result = insert.run(tripId, userId, assetId, 'immich', shared ? 1 : 0); if (result.changes > 0) added++; } return added; } export function removeTripPhoto(tripId: string, userId: number, assetId: string) { - db.prepare('DELETE FROM trip_photos WHERE trip_id = ? AND user_id = ? AND immich_asset_id = ?') - .run(tripId, userId, assetId); + db.prepare('DELETE FROM trip_photos WHERE trip_id = ? AND user_id = ? AND asset_id = ? AND provider = ?') + .run(tripId, userId, assetId, 'immich'); } export function togglePhotoSharing(tripId: string, userId: number, assetId: string, shared: boolean) { - db.prepare('UPDATE trip_photos SET shared = ? WHERE trip_id = ? AND user_id = ? AND immich_asset_id = ?') - .run(shared ? 1 : 0, tripId, userId, assetId); + db.prepare('UPDATE trip_photos SET shared = ? WHERE trip_id = ? AND user_id = ? AND asset_id = ? AND provider = ?') + .run(shared ? 1 : 0, tripId, userId, assetId, 'immich'); } // ── Asset Info / Proxy ───────────────────────────────────────────────────── @@ -329,7 +328,7 @@ export function listAlbumLinks(tripId: string) { SELECT tal.*, u.username FROM trip_album_links tal JOIN users u ON tal.user_id = u.id - WHERE tal.trip_id = ? + WHERE tal.trip_id = ? AND tal.provider = 'immich' ORDER BY tal.created_at ASC `).all(tripId); } @@ -342,8 +341,8 @@ export function createAlbumLink( ): { success: boolean; error?: string } { try { db.prepare( - 'INSERT OR IGNORE INTO trip_album_links (trip_id, user_id, immich_album_id, album_name) VALUES (?, ?, ?, ?)' - ).run(tripId, userId, albumId, albumName || ''); + 'INSERT OR IGNORE INTO trip_album_links (trip_id, user_id, provider, album_id, album_name) VALUES (?, ?, ?, ?, ?)' + ).run(tripId, userId, 'immich', albumId, albumName || ''); return { success: true }; } catch { return { success: false, error: 'Album already linked' }; @@ -360,15 +359,15 @@ export async function syncAlbumAssets( linkId: string, userId: number ): Promise<{ success?: boolean; added?: number; total?: number; error?: string; status?: number }> { - const link = db.prepare('SELECT * FROM trip_album_links WHERE id = ? AND trip_id = ? AND user_id = ?') - .get(linkId, tripId, userId) as any; + const link = db.prepare('SELECT * FROM trip_album_links WHERE id = ? AND trip_id = ? AND user_id = ? AND provider = ?') + .get(linkId, tripId, userId, 'immich') as any; if (!link) return { error: 'Album link not found', status: 404 }; const creds = getImmichCredentials(userId); if (!creds) return { error: 'Immich not configured', status: 400 }; try { - const resp = await fetch(`${creds.immich_url}/api/albums/${link.immich_album_id}`, { + const resp = await fetch(`${creds.immich_url}/api/albums/${link.album_id}`, { headers: { 'x-api-key': creds.immich_api_key, 'Accept': 'application/json' }, signal: AbortSignal.timeout(15000), }); @@ -376,9 +375,7 @@ export async function syncAlbumAssets( const albumData = await resp.json() as { assets?: any[] }; const assets = (albumData.assets || []).filter((a: any) => a.type === 'IMAGE'); - const insert = db.prepare( - 'INSERT OR IGNORE INTO trip_photos (trip_id, user_id, immich_asset_id, shared) VALUES (?, ?, ?, 1)' - ); + const insert = db.prepare("INSERT OR IGNORE INTO trip_photos (trip_id, user_id, asset_id, provider, shared) VALUES (?, ?, ?, 'immich', 1)"); let added = 0; for (const asset of assets) { const r = insert.run(tripId, userId, asset.id); From b4741c31a910891996d151cd47a4b97afe6e541b Mon Sep 17 00:00:00 2001 From: Marek Maslowski Date: Fri, 3 Apr 2026 16:25:45 +0200 Subject: [PATCH 15/57] moving business logic for synology to separet file --- server/src/routes/admin.ts | 5 +- server/src/routes/synology.ts | 720 +++++-------------------- server/src/services/synologyService.ts | 651 ++++++++++++++++++++++ 3 files changed, 783 insertions(+), 593 deletions(-) create mode 100644 server/src/services/synologyService.ts diff --git a/server/src/routes/admin.ts b/server/src/routes/admin.ts index 8558fe6..86896ab 100644 --- a/server/src/routes/admin.ts +++ b/server/src/routes/admin.ts @@ -1,6 +1,7 @@ import express, { Request, Response } from 'express'; import { authenticate, adminOnly } from '../middleware/auth'; -import { AuthRequest } from '../types'; +import { db } from '../db/database'; +import { AuthRequest, Addon } from '../types'; import { writeAudit, getClientIp, logInfo } from '../services/auditLog'; import * as svc from '../services/adminService'; @@ -355,7 +356,7 @@ router.put('/addons/:id', (req: Request, res: Response) => { action: 'admin.addon_update', resource: String(req.params.id), ip: getClientIp(req), - details: result.auditDetails, + details: { enabled: req.body.enabled, config: req.body.config }, }); res.json({ addon: updated }); }); diff --git a/server/src/routes/synology.ts b/server/src/routes/synology.ts index 71fed86..65f4dfb 100644 --- a/server/src/routes/synology.ts +++ b/server/src/routes/synology.ts @@ -1,645 +1,183 @@ -import express, { NextFunction, Request, Response } from 'express'; -import { Readable } from 'node:stream'; -import { pipeline } from 'node:stream/promises'; -import { db, canAccessTrip } from '../db/database'; +import express, { Request, Response } from 'express'; import { authenticate } from '../middleware/auth'; import { broadcast } from '../websocket'; import { AuthRequest } from '../types'; -import { maybe_encrypt_api_key, decrypt_api_key } from '../services/apiKeyCrypto'; -import { consumeEphemeralToken } from '../services/ephemeralTokens'; -import { checkSsrf } from '../utils/ssrfGuard'; +import { + getSynologySettings, + updateSynologySettings, + getSynologyStatus, + testSynologyConnection, + listSynologyAlbums, + linkSynologyAlbum, + syncSynologyAlbumLink, + searchSynologyPhotos, + getSynologyAssetInfo, + pipeSynologyProxy, + synologyAuthFromQuery, + getSynologyTargetUserId, + streamSynologyAsset, + handleSynologyError, + SynologyServiceError, +} from '../services/synologyService'; const router = express.Router(); -function copyProxyHeaders(resp: Response, upstream: globalThis.Response, headerNames: string[]): void { - for (const headerName of headerNames) { - const value = upstream.headers.get(headerName); - if (value) { - resp.set(headerName, value); - } - } +function parseStringBodyField(value: unknown): string { + return String(value ?? '').trim(); } -// Helper: Get Synology credentials from users table -function getSynologyCredentials(userId: number) { - try { - const user = db.prepare('SELECT synology_url, synology_username, synology_password FROM users WHERE id = ?').get(userId) as any; - if (!user?.synology_url || !user?.synology_username || !user?.synology_password) return null; - return { - synology_url: user.synology_url as string, - synology_username: user.synology_username as string, - synology_password: decrypt_api_key(user.synology_password) as string, - }; - } catch { - return null; - } +function parseNumberBodyField(value: unknown, fallback: number): number { + const parsed = Number(value); + return Number.isFinite(parsed) ? parsed : fallback; } -// Helper: Get cached SID from settings or users table -function getCachedSynologySID(userId: number) { - try { - const row = db.prepare('SELECT synology_sid FROM users WHERE id = ?').get(userId) as any; - return row?.synology_sid || null; - } catch { - return null; - } -} - -// Helper: Cache SID in users table -function cacheSynologySID(userId: number, sid: string) { - try { - db.prepare('UPDATE users SET synology_sid = ? WHERE id = ?').run(sid, userId); - } catch (err) { - // Ignore if columns don't exist yet - } -} - -// Helper: Get authenticated session - -interface SynologySession { - success: boolean; - sid?: string; - error?: { code: number; message?: string }; -} - -async function getSynologySession(userId: number): Promise { - // Check for cached SID - const cachedSid = getCachedSynologySID(userId); - if (cachedSid) { - return { success: true, sid: cachedSid }; - } - - const creds = getSynologyCredentials(userId); - // Login with credentials - if (!creds) { - return { success: false, error: { code: 400, message: 'Invalid Synology credentials' } }; - } - const endpoint = prepareSynologyEndpoint(creds.synology_url); - - const body = new URLSearchParams({ - api: 'SYNO.API.Auth', - method: 'login', - version: '3', - account: creds.synology_username, - passwd: creds.synology_password, - }); - - const resp = await fetch(endpoint, { - method: 'POST', - headers: { - 'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8', - }, - body, - signal: AbortSignal.timeout(30000), - }); - - if (!resp.ok) { - return { success: false, error: { code: resp.status, message: 'Failed to authenticate with Synology' } }; - } - - const data = await resp.json() as { success: boolean; data?: { sid?: string } }; - - if (data.success && data.data?.sid) { - const sid = data.data.sid; - cacheSynologySID(userId, sid); - return { success: true, sid }; - } - - return { success: false, error: { code: 500, message: 'Failed to get Synology session' } }; -} - -// Helper: Clear cached SID - -function clearSynologySID(userId: number): void { - try { - db.prepare('UPDATE users SET synology_sid = NULL WHERE id = ?').run(userId); - } catch { - // Ignore if columns don't exist yet - } -} - -interface ApiCallParams { - api: string; - method: string; - version?: number; - [key: string]: any; -} - -interface SynologyApiResponse { - success: boolean; - data?: T; - error?: { code: number, message?: string }; -} - -function prepareSynologyEndpoint(url: string): string { - url = url.replace(/\/$/, ''); - if (!/^https?:\/\//.test(url)) { - url = `https://${url}`; - } - return `${url}/photo/webapi/entry.cgi`; -} - -function splitPackedSynologyId(rawId: string): { id: string; cacheKey: string; assetId: string } { - const id = rawId.split('_')[0]; - return { id: id, cacheKey: rawId, assetId: rawId }; -} - -function transformSynologyPhoto(item: any): any { - const address = item.additional?.address || {}; - return { - id: item.additional?.thumbnail?.cache_key, - takenAt: item.time ? new Date(item.time * 1000).toISOString() : null, - city: address.city || null, - country: address.country || null, - }; -} - -async function callSynologyApi(userId: number, params: ApiCallParams): Promise> { - try { - const creds = getSynologyCredentials(userId); - if (!creds) { - return { success: false, error: { code: 400, message: 'Synology not configured' } }; - } - const endpoint = prepareSynologyEndpoint(creds.synology_url); - - - const body = new URLSearchParams(); - for (const [key, value] of Object.entries(params)) { - if (value === undefined || value === null) continue; - body.append(key, typeof value === 'object' ? JSON.stringify(value) : String(value)); - } - - const sid = await getSynologySession(userId); - if (!sid.success || !sid.sid) { - return { success: false, error: sid.error || { code: 500, message: 'Failed to get Synology session' } }; - } - body.append('_sid', sid.sid); - - const resp = await fetch(endpoint, { - method: 'POST', - headers: { - 'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8', - }, - body, - signal: AbortSignal.timeout(30000), - }); - - - if (!resp.ok) { - const text = await resp.text(); - return { success: false, error: { code: resp.status, message: text } }; - } - - const result = await resp.json() as SynologyApiResponse; - if (!result.success && result.error?.code === 119) { - clearSynologySID(userId); - return callSynologyApi(userId, params); - } - return result; - } catch (err) { - return { success: false, error: { code: -1, message: err instanceof Error ? err.message : 'Unknown error' } }; - } -} - -// Settings router.get('/settings', authenticate, (req: Request, res: Response) => { - const authReq = req as AuthRequest; - const creds = getSynologyCredentials(authReq.user.id); - res.json({ - synology_url: creds?.synology_url || '', - synology_username: creds?.synology_username || '', - connected: !!(creds?.synology_url && creds?.synology_username), - }); + const authReq = req as AuthRequest; + res.json(getSynologySettings(authReq.user.id)); }); -router.put('/settings', authenticate, (req: Request, res: Response) => { - const authReq = req as AuthRequest; - const { synology_url, synology_username, synology_password } = req.body; +router.put('/settings', authenticate, async (req: Request, res: Response) => { + const authReq = req as AuthRequest; + const body = req.body as Record; + const synology_url = parseStringBodyField(body.synology_url); + const synology_username = parseStringBodyField(body.synology_username); + const synology_password = parseStringBodyField(body.synology_password); - const url = String(synology_url || '').trim(); - const username = String(synology_username || '').trim(); - const password = String(synology_password || '').trim(); + if (!synology_url || !synology_username) { + return handleSynologyError(res, new SynologyServiceError(400, 'URL and username are required'), 'Missing required fields'); + } - if (!url || !username) { - return res.status(400).json({ error: 'URL and username are required' }); - } - - const existing = db.prepare('SELECT synology_password FROM users WHERE id = ?').get(authReq.user.id) as { synology_password?: string | null } | undefined; - const existingEncryptedPassword = existing?.synology_password || null; - - // First-time setup requires password; later updates may keep existing password. - if (!password && !existingEncryptedPassword) { - return res.status(400).json({ error: 'Password is required' }); - } - - try { - db.prepare('UPDATE users SET synology_url = ?, synology_username = ?, synology_password = ? WHERE id = ?').run( - url, - username, - password ? maybe_encrypt_api_key(password) : existingEncryptedPassword, - authReq.user.id - ); - } catch (err) { - return res.status(400).json({ error: 'Failed to save settings' }); - } - - clearSynologySID(authReq.user.id); - res.json({ success: true }); + try { + await updateSynologySettings(authReq.user.id, synology_url, synology_username, synology_password); + res.json({ success: true }); + } catch (err: unknown) { + handleSynologyError(res, err, 'Failed to save settings'); + } }); -// Status router.get('/status', authenticate, async (req: Request, res: Response) => { - const authReq = req as AuthRequest; - - try { - const sid = await getSynologySession(authReq.user.id); - if (!sid.success || !sid.sid) { - return res.json({ connected: false, error: 'Authentication failed' }); - } - - const user = db.prepare('SELECT synology_username FROM users WHERE id = ?').get(authReq.user.id) as any; - res.json({ connected: true, user: { username: user.synology_username } }); - } catch (err: unknown) { - res.json({ connected: false, error: err instanceof Error ? err.message : 'Connection failed' }); - } + const authReq = req as AuthRequest; + res.json(await getSynologyStatus(authReq.user.id)); }); -// Test connection with provided credentials only router.post('/test', authenticate, async (req: Request, res: Response) => { - const { synology_url, synology_username, synology_password } = req.body as { synology_url?: string; synology_username?: string; synology_password?: string }; + const body = req.body as Record; + const synology_url = parseStringBodyField(body.synology_url); + const synology_username = parseStringBodyField(body.synology_username); + const synology_password = parseStringBodyField(body.synology_password); - const url = String(synology_url || '').trim(); - const username = String(synology_username || '').trim(); - const password = String(synology_password || '').trim(); - - if (!url || !username || !password) { - return res.json({ connected: false, error: 'URL, username, and password are required' }); - } - - const ssrf = await checkSsrf(url); - if (!ssrf.allowed) return res.json({ connected: false, error: ssrf.error ?? 'Invalid Synology URL' }); - - try { - const endpoint = prepareSynologyEndpoint(url); - const body = new URLSearchParams({ - api: 'SYNO.API.Auth', - method: 'login', - version: '3', - account: username, - passwd: password, - }); - - const resp = await fetch(endpoint, { - method: 'POST', - headers: { - 'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8', - }, - body, - signal: AbortSignal.timeout(30000), - }); - - if (!resp.ok) return res.json({ connected: false, error: `HTTP ${resp.status}` }); - const data = await resp.json() as { success: boolean; data?: { sid?: string } }; - if (!data.success || !data.data?.sid) return res.json({ connected: false, error: 'Authentication failed' }); - return res.json({ connected: true, user: { username } }); - } catch (err: unknown) { - return res.json({ connected: false, error: err instanceof Error ? err.message : 'Connection failed' }); - } -}); - -// Album linking parity with Immich -router.get('/albums', authenticate, async (req: Request, res: Response) => { - const authReq = req as AuthRequest; - try { - const result = await callSynologyApi<{ list: any[] }>(authReq.user.id, { - api: 'SYNO.Foto.Browse.Album', - method: 'list', - version: 4, - offset: 0, - limit: 100, - }); - - if (!result.success || !result.data) { - return res.status(502).json({ error: result.error?.message || 'Failed to fetch albums' }); + if (!synology_url || !synology_username || !synology_password) { + return handleSynologyError(res, new SynologyServiceError(400, 'URL, username and password are required'), 'Missing required fields'); } - const albums = (result.data.list || []).map((a: any) => ({ - id: String(a.id), - albumName: a.name || '', - assetCount: a.item_count || 0, - })); + res.json(await testSynologyConnection(synology_url, synology_username, synology_password)); +}); - res.json({ albums }); - } catch (err: unknown) { - res.status(502).json({ error: err instanceof Error ? err.message : 'Could not reach Synology' }); - } +router.get('/albums', authenticate, async (req: Request, res: Response) => { + const authReq = req as AuthRequest; + try { + res.json(await listSynologyAlbums(authReq.user.id)); + } catch (err: unknown) { + handleSynologyError(res, err, 'Could not reach Synology'); + } }); router.post('/trips/:tripId/album-links', authenticate, (req: Request, res: Response) => { - const authReq = req as AuthRequest; - const { tripId } = req.params; - if (!canAccessTrip(tripId, authReq.user.id)) return res.status(404).json({ error: 'Trip not found' }); - const { album_id, album_name } = req.body; - if (!album_id) return res.status(400).json({ error: 'album_id required' }); + const authReq = req as AuthRequest; + const { tripId } = req.params; + const body = req.body as Record; + const albumId = parseStringBodyField(body.album_id); + const albumName = parseStringBodyField(body.album_name); - try { - db.prepare( - 'INSERT OR IGNORE INTO trip_album_links (trip_id, user_id, provider, album_id, album_name) VALUES (?, ?, ?, ?, ?)' - ).run(tripId, authReq.user.id, 'synologyphotos', String(album_id), album_name || ''); - res.json({ success: true }); - } catch { - res.status(400).json({ error: 'Album already linked' }); - } + if (!albumId) { + return handleSynologyError(res, new SynologyServiceError(400, 'Album ID is required'), 'Missing required fields'); + } + + try { + linkSynologyAlbum(authReq.user.id, tripId, albumId, albumName || undefined); + res.json({ success: true }); + } catch (err: unknown) { + handleSynologyError(res, err, 'Failed to link album'); + } }); router.post('/trips/:tripId/album-links/:linkId/sync', authenticate, async (req: Request, res: Response) => { - const authReq = req as AuthRequest; - const { tripId, linkId } = req.params; + const authReq = req as AuthRequest; + const { tripId, linkId } = req.params; - const link = db.prepare("SELECT * FROM trip_album_links WHERE id = ? AND trip_id = ? AND user_id = ? AND provider = 'synologyphotos'") - .get(linkId, tripId, authReq.user.id) as any; - if (!link) return res.status(404).json({ error: 'Album link not found' }); - - try { - const allItems: any[] = []; - const pageSize = 1000; - let offset = 0; - - while (true) { - const result = await callSynologyApi<{ list: any[] }>(authReq.user.id, { - api: 'SYNO.Foto.Browse.Item', - method: 'list', - version: 1, - album_id: Number(link.album_id), - offset, - limit: pageSize, - additional: ['thumbnail'], - }); - - if (!result.success || !result.data) { - return res.status(502).json({ error: result.error?.message || 'Failed to fetch album' }); - } - - const items = result.data.list || []; - allItems.push(...items); - if (items.length < pageSize) break; - offset += pageSize; + try { + const result = await syncSynologyAlbumLink(authReq.user.id, tripId, linkId); + res.json({ success: true, ...result }); + if (result.added > 0) { + broadcast(tripId, 'memories:updated', { userId: authReq.user.id }, req.headers['x-socket-id'] as string); + } + } catch (err: unknown) { + handleSynologyError(res, err, 'Could not reach Synology'); } - - const insert = db.prepare( - "INSERT OR IGNORE INTO trip_photos (trip_id, user_id, asset_id, provider, shared) VALUES (?, ?, ?, 'synologyphotos', 1)" - ); - - let added = 0; - for (const item of allItems) { - const transformed = transformSynologyPhoto(item); - const assetId = String(transformed?.id || '').trim(); - if (!assetId) continue; - const r = insert.run(tripId, authReq.user.id, assetId); - if (r.changes > 0) added++; - } - - db.prepare('UPDATE trip_album_links SET last_synced_at = CURRENT_TIMESTAMP WHERE id = ?').run(linkId); - - res.json({ success: true, added, total: allItems.length }); - if (added > 0) { - broadcast(tripId, 'memories:updated', { userId: authReq.user.id }, req.headers['x-socket-id'] as string); - } - } catch (err: unknown) { - res.status(502).json({ error: err instanceof Error ? err.message : 'Could not reach Synology' }); - } }); -// Search router.post('/search', authenticate, async (req: Request, res: Response) => { - const authReq = req as AuthRequest; - let { from, to, offset = 0, limit = 300 } = req.body; + const authReq = req as AuthRequest; + const body = req.body as Record; + const from = parseStringBodyField(body.from); + const to = parseStringBodyField(body.to); + const offset = parseNumberBodyField(body.offset, 0); + const limit = parseNumberBodyField(body.limit, 300); - try { - const params: any = { - api: 'SYNO.Foto.Search.Search', - method: 'list_item', - version: 1, - offset, - limit, - keyword: '.', - additional: ['thumbnail', 'address'], - }; - - if (from || to) { - if (from) { - params.start_time = Math.floor(new Date(from).getTime() / 1000); - } - if (to) { - params.end_time = Math.floor(new Date(to).getTime() / 1000) + 86400; // Include entire end day - } + try { + const result = await searchSynologyPhotos( + authReq.user.id, + from || undefined, + to || undefined, + offset, + limit, + ); + res.json(result); + } catch (err: unknown) { + handleSynologyError(res, err, 'Could not reach Synology'); } - - - const result = await callSynologyApi<{ list: any[]; total: number }>(authReq.user.id, params); - - if (!result.success || !result.data) { - return res.status(502).json({ error: result.error?.message || 'Failed to fetch album photos' }); - } - - const allItems = (result.data.list || []); - const total = allItems.length; - - const assets = allItems.map((item: any) => transformSynologyPhoto(item)); - - res.json({ - assets, - total, - hasMore: total == limit, - }); - } catch (err: unknown) { - res.status(502).json({ error: err instanceof Error ? err.message : 'Could not reach Synology' }); - } }); -// Proxy Synology Assets - -// Asset info endpoint (returns metadata, not image) router.get('/assets/:photoId/info', authenticate, async (req: Request, res: Response) => { - const authReq = req as AuthRequest; - const { photoId } = req.params; - const parsedId = splitPackedSynologyId(photoId); - const { userId } = req.query; + const authReq = req as AuthRequest; + const { photoId } = req.params; - const targetUserId = userId ? Number(userId) : authReq.user.id; - - try { - const result = await callSynologyApi(targetUserId, { - api: 'SYNO.Foto.Browse.Item', - method: 'get', - version: 5, - id: `[${parsedId.id}]`, - additional: ['resolution', 'exif', 'gps', 'address', 'orientation', 'description'], - }); - if (!result.success || !result.data) { - return res.status(404).json({ error: 'Photo not found' }); + try { + res.json(await getSynologyAssetInfo(authReq.user.id, photoId, getSynologyTargetUserId(req))); + } catch (err: unknown) { + handleSynologyError(res, err, 'Could not reach Synology'); } - - const metadata = result.data.list[0]; - console.log(metadata); - const exif = metadata.additional?.exif || {}; - const address = metadata.additional?.address || {}; - const gps = metadata.additional?.gps || {}; - res.json({ - id: photoId, - takenAt: metadata.time ? new Date(metadata.time * 1000).toISOString() : null, - city: address.city || null, - country: address.country || null, - state: address.state || null, - camera: exif.camera || null, - lens: exif.lens || null, - focalLength: exif.focal_length || null, - aperture: exif.aperture || null, - shutter: exif.exposure_time || null, - iso: exif.iso || null, - lat: gps.latitude || null, - lng: gps.longitude || null, - orientation: metadata.additional?.orientation || null, - description: metadata.additional?.description || null, - filename: metadata.filename || null, - filesize: metadata.filesize || null, - width: metadata.additional?.resolution?.width || null, - height: metadata.additional?.resolution?.height || null, - fileSize: metadata.filesize || null, - fileName: metadata.filename || null, - }); - } catch (err: unknown) { - res.status(502).json({ error: err instanceof Error ? err.message : 'Could not reach Synology'}); - } }); -// Middleware: Accept ephemeral token from query param for tags -function authFromQuery(req: Request, res: Response, next: NextFunction) { - const queryToken = req.query.token as string | undefined; - if (queryToken) { - const userId = consumeEphemeralToken(queryToken, 'synologyphotos'); - if (!userId) return res.status(401).send('Invalid or expired token'); - const user = db.prepare('SELECT id, username, email, role, mfa_enabled FROM users WHERE id = ?').get(userId) as any; - if (!user) return res.status(401).send('User not found'); - (req as AuthRequest).user = user; - return next(); - } - return (authenticate as any)(req, res, next); -} +router.get('/assets/:photoId/thumbnail', synologyAuthFromQuery, async (req: Request, res: Response) => { + const authReq = req as AuthRequest; + const { photoId } = req.params; + const { size = 'sm' } = req.query; -router.get('/assets/:photoId/thumbnail', authFromQuery, async (req: Request, res: Response) => { - const authReq = req as AuthRequest; - const { photoId } = req.params; - const parsedId = splitPackedSynologyId(photoId); - const { userId, size = 'sm' } = req.query; - - const targetUserId = userId ? Number(userId) : authReq.user.id; - - const creds = getSynologyCredentials(targetUserId); - if (!creds) { - return res.status(404).send('Not found'); - } - - try { - const sid = await getSynologySession(authReq.user.id); - if (!sid.success && !sid.sid) { - return res.status(401).send('Authentication failed'); + try { + const proxy = await streamSynologyAsset(authReq.user.id, getSynologyTargetUserId(req), photoId, 'thumbnail', String(size)); + await pipeSynologyProxy(res, proxy); + } catch (err: unknown) { + if (res.headersSent) { + return; + } + handleSynologyError(res, err, 'Proxy error'); } - - const params = new URLSearchParams({ - api: 'SYNO.Foto.Thumbnail', - method: 'get', - version: '2', - mode: 'download', - id: parsedId.id, - type: 'unit', - size: String(size), - cache_key: parsedId.cacheKey, - _sid: sid.sid, - }); - const url = prepareSynologyEndpoint(creds.synology_url) + '?' + params.toString(); - const resp = await fetch(url, { - signal: AbortSignal.timeout(30000), - }); - - if (!resp.ok) { - return res.status(resp.status).send('Failed'); - } - - res.status(resp.status); - copyProxyHeaders(res, resp, ['content-type', 'cache-control', 'content-length', 'content-disposition']); - res.set('Content-Type', resp.headers.get('content-type') || 'image/jpeg'); - res.set('Cache-Control', resp.headers.get('cache-control') || 'public, max-age=86400'); - - if (!resp.body) { - return res.end(); - } - - await pipeline(Readable.fromWeb(resp.body), res); - } catch (err: unknown) { - if (res.headersSent) { - return; - } - res.status(502).send('Proxy error: ' + (err instanceof Error ? err.message : String(err))); - } }); +router.get('/assets/:photoId/original', synologyAuthFromQuery, async (req: Request, res: Response) => { + const authReq = req as AuthRequest; + const { photoId } = req.params; -router.get('/assets/:photoId/original', authFromQuery, async (req: Request, res: Response) => { - const authReq = req as AuthRequest; - const { photoId } = req.params; - const parsedId = splitPackedSynologyId(photoId || ''); - const { userId} = req.query; - - const targetUserId = userId ? Number(userId) : authReq.user.id; - - const creds = getSynologyCredentials(targetUserId); - if (!creds) { - return res.status(404).send('Not found'); - } - - try { - const sid = await getSynologySession(authReq.user.id); - if (!sid.success && !sid.sid) { - return res.status(401).send('Authentication failed'); + try { + const proxy = await streamSynologyAsset(authReq.user.id, getSynologyTargetUserId(req), photoId, 'original'); + await pipeSynologyProxy(res, proxy); + } catch (err: unknown) { + if (res.headersSent) { + return; + } + handleSynologyError(res, err, 'Proxy error'); } - - const params = new URLSearchParams({ - api: 'SYNO.Foto.Download', - method: 'download', - version: '2', - cache_key: parsedId.cacheKey, - unit_id: `[${parsedId.id}]`, - _sid: sid.sid, - }); - - const url = prepareSynologyEndpoint(creds.synology_url) + '?' + params.toString(); - const resp = await fetch(url, { - signal: AbortSignal.timeout(30000), - }); - - if (!resp.ok) { - const body = await resp.text(); - return res.status(resp.status).send('Failed: ' + body); - } - - res.status(resp.status); - copyProxyHeaders(res, resp, ['content-type', 'cache-control', 'content-length', 'content-disposition']); - res.set('Content-Type', resp.headers.get('content-type') || 'application/octet-stream'); - res.set('Cache-Control', resp.headers.get('cache-control') || 'public, max-age=86400'); - - if (!resp.body) { - return res.end(); - } - - await pipeline(Readable.fromWeb(resp.body), res); - } catch (err: unknown) { - if (res.headersSent) { - return; - } - res.status(502).send('Proxy error: ' + (err instanceof Error ? err.message : String(err))); - } }); - -export default router; +export default router; \ No newline at end of file diff --git a/server/src/services/synologyService.ts b/server/src/services/synologyService.ts new file mode 100644 index 0000000..1c25ef5 --- /dev/null +++ b/server/src/services/synologyService.ts @@ -0,0 +1,651 @@ +import { Readable } from 'node:stream'; +import { pipeline } from 'node:stream/promises'; +import { NextFunction, Request, Response as ExpressResponse } from 'express'; +import { db, canAccessTrip } from '../db/database'; +import { decrypt_api_key, maybe_encrypt_api_key } from './apiKeyCrypto'; +import { authenticate } from '../middleware/auth'; +import { AuthRequest } from '../types'; +import { consumeEphemeralToken } from './ephemeralTokens'; +import { checkSsrf } from '../utils/ssrfGuard'; +import { no } from 'zod/locales'; + +const SYNOLOGY_API_TIMEOUT_MS = 30000; +const SYNOLOGY_PROVIDER = 'synologyphotos'; +const SYNOLOGY_ENDPOINT_PATH = '/photo/webapi/entry.cgi'; +const SYNOLOGY_DEFAULT_THUMBNAIL_SIZE = 'sm'; + +interface SynologyCredentials { + synology_url: string; + synology_username: string; + synology_password: string; +} + +interface SynologySession { + success: boolean; + sid?: string; + error?: { code: number; message?: string }; +} + +interface ApiCallParams { + api: string; + method: string; + version?: number; + [key: string]: unknown; +} + +interface SynologyApiResponse { + success: boolean; + data?: T; + error?: { code: number; message?: string }; +} + +export class SynologyServiceError extends Error { + status: number; + + constructor(status: number, message: string) { + super(message); + this.status = status; + } +} + +export interface SynologySettings { + synology_url: string; + synology_username: string; + connected: boolean; +} + +export interface SynologyConnectionResult { + connected: boolean; + user?: { username: string }; + error?: string; +} + +export interface SynologyAlbumLinkInput { + album_id?: string | number; + album_name?: string; +} + +export interface SynologySearchInput { + from?: string; + to?: string; + offset?: number; + limit?: number; +} + +export interface SynologyProxyResult { + status: number; + headers: Record; + body: ReadableStream | null; +} + +interface SynologyPhotoInfo { + id: string; + takenAt: string | null; + city: string | null; + country: string | null; + state?: string | null; + camera?: string | null; + lens?: string | null; + focalLength?: string | number | null; + aperture?: string | number | null; + shutter?: string | number | null; + iso?: string | number | null; + lat?: number | null; + lng?: number | null; + orientation?: number | null; + description?: string | null; + filename?: string | null; + filesize?: number | null; + width?: number | null; + height?: number | null; + fileSize?: number | null; + fileName?: string | null; +} + +interface SynologyPhotoItem { + id?: string | number; + filename?: string; + filesize?: number; + time?: number; + item_count?: number; + name?: string; + additional?: { + thumbnail?: { cache_key?: string }; + address?: { city?: string; country?: string; state?: string }; + resolution?: { width?: number; height?: number }; + exif?: { + camera?: string; + lens?: string; + focal_length?: string | number; + aperture?: string | number; + exposure_time?: string | number; + iso?: string | number; + }; + gps?: { latitude?: number; longitude?: number }; + orientation?: number; + description?: string; + }; +} + +type SynologyUserRecord = { + synology_url?: string | null; + synology_username?: string | null; + synology_password?: string | null; + synology_sid?: string | null; +}; + +function readSynologyUser(userId: number, columns: string[]): SynologyUserRecord | null { + try { + + if (!columns) return null; + + const row = db.prepare(`SELECT synology_url, synology_username, synology_password, synology_sid FROM users WHERE id = ?`).get(userId) as SynologyUserRecord | undefined; + + if (!row) return null; + + const filtered: SynologyUserRecord = {}; + for (const column of columns) { + filtered[column] = row[column]; + } + + return filtered || null; + } catch { + return null; + } +} + +function getSynologyCredentials(userId: number): SynologyCredentials | null { + const user = readSynologyUser(userId, ['synology_url', 'synology_username', 'synology_password']); + if (!user?.synology_url || !user.synology_username || !user.synology_password) return null; + return { + synology_url: user.synology_url, + synology_username: user.synology_username, + synology_password: decrypt_api_key(user.synology_password) as string, + }; +} + + +function buildSynologyEndpoint(url: string): string { + const normalized = url.replace(/\/$/, '').match(/^https?:\/\//) ? url.replace(/\/$/, '') : `https://${url.replace(/\/$/, '')}`; + return `${normalized}${SYNOLOGY_ENDPOINT_PATH}`; +} + +function buildSynologyFormBody(params: ApiCallParams): URLSearchParams { + const body = new URLSearchParams(); + for (const [key, value] of Object.entries(params)) { + if (value === undefined || value === null) continue; + body.append(key, typeof value === 'object' ? JSON.stringify(value) : String(value)); + } + return body; +} + +async function fetchSynologyJson(url: string, body: URLSearchParams): Promise> { + const endpoint = buildSynologyEndpoint(url); + const resp = await fetch(endpoint, { + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8', + }, + body, + signal: AbortSignal.timeout(SYNOLOGY_API_TIMEOUT_MS), + }); + + if (!resp.ok) { + const text = await resp.text(); + return { success: false, error: { code: resp.status, message: text } }; + } + + return resp.json() as Promise>; +} + +async function loginToSynology(url: string, username: string, password: string): Promise> { + const body = new URLSearchParams({ + api: 'SYNO.API.Auth', + method: 'login', + version: '3', + account: username, + passwd: password, + }); + + return fetchSynologyJson<{ sid?: string }>(url, body); +} + +async function requestSynologyApi(userId: number, params: ApiCallParams): Promise> { + const creds = getSynologyCredentials(userId); + if (!creds) { + return { success: false, error: { code: 400, message: 'Synology not configured' } }; + } + + const session = await getSynologySession(userId); + if (!session.success || !session.sid) { + return { success: false, error: session.error || { code: 400, message: 'Failed to get Synology session' } }; + } + + const body = buildSynologyFormBody({ ...params, _sid: session.sid }); + const result = await fetchSynologyJson(creds.synology_url, body); + if (!result.success && result.error?.code === 119) { + clearSynologySID(userId); + const retrySession = await getSynologySession(userId); + if (!retrySession.success || !retrySession.sid) { + return { success: false, error: retrySession.error || { code: 400, message: 'Failed to get Synology session' } }; + } + return fetchSynologyJson(creds.synology_url, buildSynologyFormBody({ ...params, _sid: retrySession.sid })); + } + return result; +} + +async function requestSynologyStream(url: string): Promise { + return fetch(url, { + signal: AbortSignal.timeout(SYNOLOGY_API_TIMEOUT_MS), + }); +} + +function normalizeSynologyPhotoInfo(item: SynologyPhotoItem): SynologyPhotoInfo { + const address = item.additional?.address || {}; + const exif = item.additional?.exif || {}; + const gps = item.additional?.gps || {}; + + return { + id: String(item.additional?.thumbnail?.cache_key || ''), + takenAt: item.time ? new Date(item.time * 1000).toISOString() : null, + city: address.city || null, + country: address.country || null, + state: address.state || null, + camera: exif.camera || null, + lens: exif.lens || null, + focalLength: exif.focal_length || null, + aperture: exif.aperture || null, + shutter: exif.exposure_time || null, + iso: exif.iso || null, + lat: gps.latitude || null, + lng: gps.longitude || null, + orientation: item.additional?.orientation || null, + description: item.additional?.description || null, + filename: item.filename || null, + filesize: item.filesize || null, + width: item.additional?.resolution?.width || null, + height: item.additional?.resolution?.height || null, + fileSize: item.filesize || null, + fileName: item.filename || null, + }; +} + +export function synologyAuthFromQuery(req: Request, res: ExpressResponse, next: NextFunction) { + const queryToken = req.query.token as string | undefined; + if (queryToken) { + const userId = consumeEphemeralToken(queryToken, SYNOLOGY_PROVIDER); + if (!userId) return res.status(401).send('Invalid or expired token'); + const user = db.prepare('SELECT id, username, email, role, mfa_enabled FROM users WHERE id = ?').get(userId) as any; + if (!user) return res.status(401).send('User not found'); + (req as AuthRequest).user = user; + return next(); + } + return (authenticate as any)(req, res, next); +} + +export function getSynologyTargetUserId(req: Request): number { + const { userId } = req.query; + return Number(userId); +} + +export function handleSynologyError(res: ExpressResponse, err: unknown, fallbackMessage: string): ExpressResponse { + if (err instanceof SynologyServiceError) { + return res.status(err.status).json({ error: err.message }); + } + return res.status(502).json({ error: err instanceof Error ? err.message : fallbackMessage }); +} + +function cacheSynologySID(userId: number, sid: string): void { + db.prepare('UPDATE users SET synology_sid = ? WHERE id = ?').run(sid, userId); +} + +function clearSynologySID(userId: number): void { + db.prepare('UPDATE users SET synology_sid = NULL WHERE id = ?').run(userId); +} + +function splitPackedSynologyId(rawId: string): { id: string; cacheKey: string; assetId: string } { + const id = rawId.split('_')[0]; + return { id, cacheKey: rawId, assetId: rawId }; +} + +function canStreamSynologyAsset(requestingUserId: number, targetUserId: number, assetId: string): boolean { + if (requestingUserId === targetUserId) { + return true; + } + + const sharedAsset = db.prepare(` + SELECT 1 + FROM trip_photos + WHERE user_id = ? + AND asset_id = ? + AND provider = 'synologyphotos' + AND shared = 1 + LIMIT 1 + `).get(targetUserId, assetId); + + return !!sharedAsset; +} + +async function getSynologySession(userId: number): Promise { + const cachedSid = readSynologyUser(userId, ['synology_sid'])?.synology_sid || null; + if (cachedSid) { + return { success: true, sid: cachedSid }; + } + + const creds = getSynologyCredentials(userId); + if (!creds) { + return { success: false, error: { code: 400, message: 'Invalid Synology credentials' } }; + } + + const resp = await loginToSynology(creds.synology_url, creds.synology_username, creds.synology_password); + + if (!resp.success || !resp.data?.sid) { + return { success: false, error: resp.error || { code: 400, message: 'Failed to authenticate with Synology' } }; + } + + cacheSynologySID(userId, resp.data.sid); + return { success: true, sid: resp.data.sid }; +} + +export async function getSynologySettings(userId: number): Promise { + const creds = getSynologyCredentials(userId); + const session = await getSynologySession(userId); + return { + synology_url: creds?.synology_url || '', + synology_username: creds?.synology_username || '', + connected: session.success, + }; +} + +export async function updateSynologySettings(userId: number, synologyUrl: string, synologyUsername: string, synologyPassword?: string): Promise { + + const ssrf = await checkSsrf(synologyUrl); + if (!ssrf.allowed) { + throw new SynologyServiceError(400, ssrf.error ?? 'Invalid Synology URL'); + } + + const existingEncryptedPassword = readSynologyUser(userId, ['synology_password'])?.synology_password || null; + + if (!synologyPassword && !existingEncryptedPassword) { + throw new SynologyServiceError(400, 'No stored password found. Please provide a password to save settings.'); + } + + try { + db.prepare('UPDATE users SET synology_url = ?, synology_username = ?, synology_password = ? WHERE id = ?').run( + synologyUrl, + synologyUsername, + synologyPassword ? maybe_encrypt_api_key(synologyPassword) : existingEncryptedPassword, + userId, + ); + } catch { + throw new SynologyServiceError(400, 'Failed to save settings'); + } + + clearSynologySID(userId); + await getSynologySession(userId); +} + +export async function getSynologyStatus(userId: number): Promise { + try { + const sid = await getSynologySession(userId); + if (!sid.success || !sid.sid) { + return { connected: false, error: 'Authentication failed' }; + } + + const user = db.prepare('SELECT synology_username FROM users WHERE id = ?').get(userId) as { synology_username?: string } | undefined; + return { connected: true, user: { username: user?.synology_username || '' } }; + } catch (err: unknown) { + return { connected: false, error: err instanceof Error ? err.message : 'Connection failed' }; + } +} + +export async function testSynologyConnection(synologyUrl: string, synologyUsername: string, synologyPassword: string): Promise { + + const ssrf = await checkSsrf(synologyUrl); + if (!ssrf.allowed) { + return { connected: false, error: ssrf.error ?? 'Invalid Synology URL' }; + } + try { + const login = await loginToSynology(synologyUrl, synologyUsername, synologyPassword); + if (!login.success || !login.data?.sid) { + return { connected: false, error: login.error?.message || 'Authentication failed' }; + } + return { connected: true, user: { username: synologyUsername } }; + } catch (err: unknown) { + return { connected: false, error: err instanceof Error ? err.message : 'Connection failed' }; + } +} + +export async function listSynologyAlbums(userId: number): Promise<{ albums: Array<{ id: string; albumName: string; assetCount: number }> }> { + const result = await requestSynologyApi<{ list: SynologyPhotoItem[] }>(userId, { + api: 'SYNO.Foto.Browse.Album', + method: 'list', + version: 4, + offset: 0, + limit: 100, + }); + + if (!result.success || !result.data) { + throw new SynologyServiceError(result.error?.code || 500, result.error?.message || 'Failed to fetch albums'); + } + + const albums = (result.data.list || []).map((album: SynologyPhotoItem) => ({ + id: String(album.id), + albumName: album.name || '', + assetCount: album.item_count || 0, + })); + + return { albums }; +} + +export function linkSynologyAlbum(userId: number, tripId: string, albumId: string | number | undefined, albumName?: string): void { + if (!canAccessTrip(tripId, userId)) { + throw new SynologyServiceError(404, 'Trip not found'); + } + + if (!albumId) { + throw new SynologyServiceError(400, 'album_id required'); + } + + const changes = db.prepare( + 'INSERT OR IGNORE INTO trip_album_links (trip_id, user_id, provider, album_id, album_name) VALUES (?, ?, ?, ?, ?)' + ).run(tripId, userId, SYNOLOGY_PROVIDER, String(albumId), albumName || '').changes; + + if (changes === 0) { + throw new SynologyServiceError(400, 'Album already linked'); + } +} + +export async function syncSynologyAlbumLink(userId: number, tripId: string, linkId: string): Promise<{ added: number; total: number }> { + const link = db.prepare(`SELECT * FROM trip_album_links WHERE id = ? AND trip_id = ? AND user_id = ? AND provider = ?`) + .get(linkId, tripId, userId, SYNOLOGY_PROVIDER) as { album_id?: string | number } | undefined; + + if (!link) { + throw new SynologyServiceError(404, 'Album link not found'); + } + + const allItems: SynologyPhotoItem[] = []; + const pageSize = 1000; + let offset = 0; + + while (true) { + const result = await requestSynologyApi<{ list: SynologyPhotoItem[] }>(userId, { + api: 'SYNO.Foto.Browse.Item', + method: 'list', + version: 1, + album_id: Number(link.album_id), + offset, + limit: pageSize, + additional: ['thumbnail'], + }); + + if (!result.success || !result.data) { + throw new SynologyServiceError(502, result.error?.message || 'Failed to fetch album'); + } + + const items = result.data.list || []; + allItems.push(...items); + if (items.length < pageSize) break; + offset += pageSize; + } + + const insert = db.prepare( + "INSERT OR IGNORE INTO trip_photos (trip_id, user_id, asset_id, provider, shared) VALUES (?, ?, ?, 'synologyphotos', 1)" + ); + + let added = 0; + for (const item of allItems) { + const transformed = normalizeSynologyPhotoInfo(item); + const assetId = String(transformed?.id || '').trim(); + if (!assetId) continue; + const result = insert.run(tripId, userId, assetId); + if (result.changes > 0) added++; + } + + db.prepare('UPDATE trip_album_links SET last_synced_at = CURRENT_TIMESTAMP WHERE id = ?').run(linkId); + + return { added, total: allItems.length }; +} + +export async function searchSynologyPhotos(userId: number, from?: string, to?: string, offset = 0, limit = 300): Promise<{ assets: SynologyPhotoInfo[]; total: number; hasMore: boolean }> { + const params: ApiCallParams = { + api: 'SYNO.Foto.Search.Search', + method: 'list_item', + version: 1, + offset, + limit, + keyword: '.', + additional: ['thumbnail', 'address'], + }; + + if (from || to) { + if (from) { + params.start_time = Math.floor(new Date(from).getTime() / 1000); + } + if (to) { + params.end_time = Math.floor(new Date(to).getTime() / 1000) + 86400; //adding it as the next day 86400 seconds in day + } + } + + const result = await requestSynologyApi<{ list: SynologyPhotoItem[]; total: number }>(userId, params); + if (!result.success || !result.data) { + throw new SynologyServiceError(502, result.error?.message || 'Failed to fetch album photos'); + } + + const allItems = result.data.list || []; + const total = allItems.length; + const assets = allItems.map(item => normalizeSynologyPhotoInfo(item)); + + return { + assets, + total, + hasMore: total === limit, + }; +} + +export async function getSynologyAssetInfo(userId: number, photoId: string, targetUserId?: number): Promise { + if (!canStreamSynologyAsset(userId, targetUserId ?? userId, photoId)) { + throw new SynologyServiceError(403, 'Youd don\'t have access to this photo'); + } + const parsedId = splitPackedSynologyId(photoId); + const result = await requestSynologyApi<{ list: SynologyPhotoItem[] }>(targetUserId ?? userId, { + api: 'SYNO.Foto.Browse.Item', + method: 'get', + version: 5, + id: `[${parsedId.id}]`, + additional: ['resolution', 'exif', 'gps', 'address', 'orientation', 'description'], + }); + + if (!result.success || !result.data) { + throw new SynologyServiceError(404, 'Photo not found'); + } + + const metadata = result.data.list?.[0]; + if (!metadata) { + throw new SynologyServiceError(404, 'Photo not found'); + } + + const normalized = normalizeSynologyPhotoInfo(metadata); + normalized.id = photoId; + return normalized; +} + +export async function streamSynologyAsset( + userId: number, + targetUserId: number, + photoId: string, + kind: 'thumbnail' | 'original', + size?: string, +): Promise { + if (!canStreamSynologyAsset(userId, targetUserId, photoId)) { + throw new SynologyServiceError(403, 'Youd don\'t have access to this photo'); + } + + const parsedId = splitPackedSynologyId(photoId); + const synology_url = getSynologyCredentials(targetUserId).synology_url; + if (!synology_url) { + throw new SynologyServiceError(402, 'User not configured with Synology'); + } + + const sid = await getSynologySession(targetUserId); + if (!sid.success || !sid.sid) { + throw new SynologyServiceError(401, 'Authentication failed'); + } + + + + const params = kind === 'thumbnail' + ? new URLSearchParams({ + api: 'SYNO.Foto.Thumbnail', + method: 'get', + version: '2', + mode: 'download', + id: parsedId.id, + type: 'unit', + size: String(size || SYNOLOGY_DEFAULT_THUMBNAIL_SIZE), + cache_key: parsedId.cacheKey, + _sid: sid.sid, + }) + : new URLSearchParams({ + api: 'SYNO.Foto.Download', + method: 'download', + version: '2', + cache_key: parsedId.cacheKey, + unit_id: `[${parsedId.id}]`, + _sid: sid.sid, + }); + + const url = `${buildSynologyEndpoint(synology_url)}?${params.toString()}`; + const resp = await requestSynologyStream(url); + + if (!resp.ok) { + const body = kind === 'original' ? await resp.text() : 'Failed'; + throw new SynologyServiceError(resp.status, kind === 'original' ? `Failed: ${body}` : body); + } + + return { + status: resp.status, + headers: { + 'content-type': resp.headers.get('content-type') || (kind === 'thumbnail' ? 'image/jpeg' : 'application/octet-stream'), + 'cache-control': resp.headers.get('cache-control') || 'public, max-age=86400', + 'content-length': resp.headers.get('content-length'), + 'content-disposition': resp.headers.get('content-disposition'), + }, + body: resp.body, + }; +} + +export async function pipeSynologyProxy(response: ExpressResponse, proxy: SynologyProxyResult): Promise { + response.status(proxy.status); + if (proxy.headers['content-type']) response.set('Content-Type', proxy.headers['content-type'] as string); + if (proxy.headers['cache-control']) response.set('Cache-Control', proxy.headers['cache-control'] as string); + if (proxy.headers['content-length']) response.set('Content-Length', proxy.headers['content-length'] as string); + if (proxy.headers['content-disposition']) response.set('Content-Disposition', proxy.headers['content-disposition'] as string); + + if (!proxy.body) { + response.end(); + return; + } + + await pipeline(Readable.fromWeb(proxy.body), response); +} From 2ae9da315335f68bbb2be967b5ca0a0930aa8118 Mon Sep 17 00:00:00 2001 From: Marek Maslowski Date: Fri, 3 Apr 2026 16:25:58 +0200 Subject: [PATCH 16/57] fix for auth tokens --- server/src/routes/auth.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/server/src/routes/auth.ts b/server/src/routes/auth.ts index 29aadd4..0216e17 100644 --- a/server/src/routes/auth.ts +++ b/server/src/routes/auth.ts @@ -319,9 +319,9 @@ router.post('/resource-token', authenticate, (req: Request, res: Response) => { if (purpose !== 'download' && purpose !== 'immich' && purpose !== 'synologyphotos') { return res.status(400).json({ error: 'Invalid purpose' }); } - const token = createEphemeralToken(authReq.user.id, purpose); + const token = createResourceToken(authReq.user.id, purpose); if (!token) return res.status(503).json({ error: 'Service unavailable' }); - res.json({ token }); + res.json(token); }); export default router; From 8c7f8d6ad1e8892c05bf313c7589049724f13025 Mon Sep 17 00:00:00 2001 From: Marek Maslowski Date: Fri, 3 Apr 2026 16:37:21 +0200 Subject: [PATCH 17/57] fixing routes for immich --- server/src/routes/immich.ts | 123 ++++++------------------------------ 1 file changed, 20 insertions(+), 103 deletions(-) diff --git a/server/src/routes/immich.ts b/server/src/routes/immich.ts index 6c4363a..7021ec7 100644 --- a/server/src/routes/immich.ts +++ b/server/src/routes/immich.ts @@ -61,42 +61,13 @@ router.put('/settings', authenticate, async (req: Request, res: Response) => { router.get('/status', authenticate, async (req: Request, res: Response) => { const authReq = req as AuthRequest; - const creds = getImmichCredentials(authReq.user.id); - if (!creds) { - return res.json({ connected: false, error: 'Not configured' }); - } - try { - const resp = await fetch(`${creds.immich_url}/api/users/me`, { - headers: { 'x-api-key': creds.immich_api_key, 'Accept': 'application/json' }, - signal: AbortSignal.timeout(10000), - }); - if (!resp.ok) return res.json({ connected: false, error: `HTTP ${resp.status}` }); - const data = await resp.json() as { name?: string; email?: string }; - res.json({ connected: true, user: { name: data.name, email: data.email } }); - } catch (err: unknown) { - res.json({ connected: false, error: err instanceof Error ? err.message : 'Connection failed' }); - } + res.json(await getConnectionStatus(authReq.user.id)); }); -// Test connection with provided credentials only router.post('/test', authenticate, async (req: Request, res: Response) => { - const { immich_url, immich_api_key } = req.body as { immich_url?: string; immich_api_key?: string }; - const url = String(immich_url || '').trim(); - const apiKey = String(immich_api_key || '').trim(); - if (!url || !apiKey) return res.json({ connected: false, error: 'URL and API key required' }); - const ssrf = await checkSsrf(url); - if (!ssrf.allowed) return res.json({ connected: false, error: ssrf.error ?? 'Invalid Immich URL' }); - try { - const resp = await fetch(`${url}/api/users/me`, { - headers: { 'x-api-key': apiKey, 'Accept': 'application/json' }, - signal: AbortSignal.timeout(10000), - }); - if (!resp.ok) return res.json({ connected: false, error: `HTTP ${resp.status}` }); - const data = await resp.json() as { name?: string; email?: string }; - res.json({ connected: true, user: { name: data.name, email: data.email } }); - } catch (err: unknown) { - res.json({ connected: false, error: err instanceof Error ? err.message : 'Connection failed' }); - } + const { immich_url, immich_api_key } = req.body; + if (!immich_url || !immich_api_key) return res.json({ connected: false, error: 'URL and API key required' }); + res.json(await testConnection(immich_url, immich_api_key)); }); // ── Browse Immich Library (for photo picker) ─────────────────────────────── @@ -118,13 +89,11 @@ router.post('/search', authenticate, async (req: Request, res: Response) => { // ── Asset Details ────────────────────────────────────────────────────────── -router.get('/assets/:assetId/info', authenticate, async (req: Request, res: Response) => { +router.get('/trips/:tripId/photos', authenticate, (req: Request, res: Response) => { const authReq = req as AuthRequest; - const { assetId } = req.params; - if (!isValidAssetId(assetId)) return res.status(400).json({ error: 'Invalid asset ID' }); - const result = await getAssetInfo(authReq.user.id, assetId); - if (result.error) return res.status(result.status!).json({ error: result.error }); - res.json(result.data); + const { tripId } = req.params; + if (!canAccessTrip(tripId, authReq.user.id)) return res.status(404).json({ error: 'Trip not found' }); + res.json({ photos: listTripPhotos(tripId, authReq.user.id) }); }); // ── Proxy Immich Assets ──────────────────────────────────────────────────── @@ -155,82 +124,30 @@ router.get('/assets/:assetId/original', authFromQuery, async (req: Request, res: router.get('/albums', authenticate, async (req: Request, res: Response) => { const authReq = req as AuthRequest; - const creds = getImmichCredentials(authReq.user.id); - if (!creds) return res.status(400).json({ error: 'Immich not configured' }); - - try { - const resp = await fetch(`${creds.immich_url}/api/albums`, { - headers: { 'x-api-key': creds.immich_api_key, 'Accept': 'application/json' }, - signal: AbortSignal.timeout(10000), - }); - if (!resp.ok) return res.status(resp.status).json({ error: 'Failed to fetch albums' }); - const albums = (await resp.json() as any[]).map((a: any) => ({ - id: a.id, - albumName: a.albumName, - assetCount: a.assetCount || 0, - startDate: a.startDate, - endDate: a.endDate, - albumThumbnailAssetId: a.albumThumbnailAssetId, - })); - res.json({ albums }); - } catch { - res.status(502).json({ error: 'Could not reach Immich' }); - } + const result = await listAlbums(authReq.user.id); + if (result.error) return res.status(result.status!).json({ error: result.error }); + res.json({ albums: result.albums }); }); + router.post('/trips/:tripId/album-links', authenticate, async (req: Request, res: Response) => { const authReq = req as AuthRequest; const { tripId } = req.params; if (!canAccessTrip(tripId, authReq.user.id)) return res.status(404).json({ error: 'Trip not found' }); const { album_id, album_name } = req.body; if (!album_id) return res.status(400).json({ error: 'album_id required' }); - - try { - db.prepare( - 'INSERT OR IGNORE INTO trip_album_links (trip_id, user_id, provider, album_id, album_name) VALUES (?, ?, ?, ?, ?)' - ).run(tripId, authReq.user.id, 'immich', album_id, album_name || ''); - res.json({ success: true }); - } catch (err: any) { - res.status(400).json({ error: 'Album already linked' }); - } + const result = createAlbumLink(tripId, authReq.user.id, album_id, album_name); + if (!result.success) return res.status(400).json({ error: result.error }); + res.json({ success: true }); }); router.post('/trips/:tripId/album-links/:linkId/sync', authenticate, async (req: Request, res: Response) => { const authReq = req as AuthRequest; const { tripId, linkId } = req.params; - - const link = db.prepare("SELECT * FROM trip_album_links WHERE id = ? AND trip_id = ? AND user_id = ? AND provider = 'immich'") - .get(linkId, tripId, authReq.user.id) as any; - if (!link) return res.status(404).json({ error: 'Album link not found' }); - - const creds = getImmichCredentials(authReq.user.id); - if (!creds) return res.status(400).json({ error: 'Immich not configured' }); - - try { - const resp = await fetch(`${creds.immich_url}/api/albums/${link.album_id}`, { - headers: { 'x-api-key': creds.immich_api_key, 'Accept': 'application/json' }, - signal: AbortSignal.timeout(15000), - }); - if (!resp.ok) return res.status(resp.status).json({ error: 'Failed to fetch album' }); - const albumData = await resp.json() as { assets?: any[] }; - const assets = (albumData.assets || []).filter((a: any) => a.type === 'IMAGE'); - - const insert = db.prepare( - "INSERT OR IGNORE INTO trip_photos (trip_id, user_id, asset_id, provider, shared) VALUES (?, ?, ?, 'immich', 1)" - ); - let added = 0; - for (const asset of assets) { - const r = insert.run(tripId, authReq.user.id, asset.id); - if (r.changes > 0) added++; - } - - db.prepare('UPDATE trip_album_links SET last_synced_at = CURRENT_TIMESTAMP WHERE id = ?').run(linkId); - - res.json({ success: true, added, total: assets.length }); - if (added > 0) { - broadcast(tripId, 'memories:updated', { userId: authReq.user.id }, req.headers['x-socket-id'] as string); - } - } catch { - res.status(502).json({ error: 'Could not reach Immich' }); + const result = await syncAlbumAssets(tripId, linkId, authReq.user.id); + if (result.error) return res.status(result.status!).json({ error: result.error }); + res.json({ success: true, added: result.added, total: result.total }); + if (result.added! > 0) { + broadcast(tripId, 'memories:updated', { userId: authReq.user.id }, req.headers['x-socket-id'] as string); } }); From 21f87d9b91ed5f7c40f6b12188e2236e941a4cf6 Mon Sep 17 00:00:00 2001 From: Marek Maslowski Date: Fri, 3 Apr 2026 16:56:41 +0200 Subject: [PATCH 18/57] fixes after merge --- server/src/app.ts | 59 ++++++++++++++++++++++++++++++++++- server/src/routes/synology.ts | 8 +++-- 2 files changed, 64 insertions(+), 3 deletions(-) diff --git a/server/src/app.ts b/server/src/app.ts index c1c5ebc..13b7668 100644 --- a/server/src/app.ts +++ b/server/src/app.ts @@ -34,6 +34,8 @@ import oidcRoutes from './routes/oidc'; import vacayRoutes from './routes/vacay'; import atlasRoutes from './routes/atlas'; import immichRoutes from './routes/immich'; +import synologyRoutes from './routes/synology'; +import memoriesRoutes from './routes/memories'; import notificationRoutes from './routes/notifications'; import shareRoutes from './routes/share'; import { mcpHandler } from './mcp'; @@ -193,13 +195,68 @@ export function createApp(): express.Application { // Addons list endpoint app.get('/api/addons', authenticate, (_req: Request, res: Response) => { const addons = db.prepare('SELECT id, name, type, icon, enabled FROM addons WHERE enabled = 1 ORDER BY sort_order').all() as Pick[]; - res.json({ addons: addons.map(a => ({ ...a, enabled: !!a.enabled })) }); + const providers = db.prepare(` + SELECT id, name, icon, enabled, config, sort_order + FROM photo_providers + WHERE enabled = 1 + ORDER BY sort_order, id + `).all() as Array<{ id: string; name: string; icon: string; enabled: number; config: string; sort_order: number }>; + const fields = db.prepare(` + SELECT provider_id, field_key, label, input_type, placeholder, required, secret, settings_key, payload_key, sort_order + FROM photo_provider_fields + ORDER BY sort_order, id + `).all() as Array<{ + provider_id: string; + field_key: string; + label: string; + input_type: string; + placeholder?: string | null; + required: number; + secret: number; + settings_key?: string | null; + payload_key?: string | null; + sort_order: number; + }>; + + const fieldsByProvider = new Map(); + for (const field of fields) { + const arr = fieldsByProvider.get(field.provider_id) || []; + arr.push(field); + fieldsByProvider.set(field.provider_id, arr); + } + + res.json({ + addons: [ + ...addons.map(a => ({ ...a, enabled: !!a.enabled })), + ...providers.map(p => ({ + id: p.id, + name: p.name, + type: 'photo_provider', + icon: p.icon, + enabled: !!p.enabled, + config: JSON.parse(p.config || '{}'), + fields: (fieldsByProvider.get(p.id) || []).map(f => ({ + key: f.field_key, + label: f.label, + input_type: f.input_type, + placeholder: f.placeholder || '', + required: !!f.required, + secret: !!f.secret, + settings_key: f.settings_key || null, + payload_key: f.payload_key || null, + sort_order: f.sort_order, + })), + })), + ], + }); }); // Addon routes app.use('/api/addons/vacay', vacayRoutes); app.use('/api/addons/atlas', atlasRoutes); + app.use('/api/integrations/memories', memoriesRoutes); app.use('/api/integrations/immich', immichRoutes); + app.use('/api/integrations/synologyphotos', synologyRoutes); app.use('/api/maps', mapsRoutes); app.use('/api/weather', weatherRoutes); app.use('/api/settings', settingsRoutes); diff --git a/server/src/routes/synology.ts b/server/src/routes/synology.ts index 65f4dfb..9b26acc 100644 --- a/server/src/routes/synology.ts +++ b/server/src/routes/synology.ts @@ -31,9 +31,13 @@ function parseNumberBodyField(value: unknown, fallback: number): number { return Number.isFinite(parsed) ? parsed : fallback; } -router.get('/settings', authenticate, (req: Request, res: Response) => { +router.get('/settings', authenticate, async (req: Request, res: Response) => { const authReq = req as AuthRequest; - res.json(getSynologySettings(authReq.user.id)); + try { + res.json(await getSynologySettings(authReq.user.id)); + } catch (err: unknown) { + handleSynologyError(res, err, 'Failed to load settings'); + } }); router.put('/settings', authenticate, async (req: Request, res: Response) => { From fa25ff29bb6ce3447388b618c0a111c17a1e64df Mon Sep 17 00:00:00 2001 From: Marek Maslowski Date: Fri, 3 Apr 2026 17:02:53 +0200 Subject: [PATCH 19/57] moving memories bl --- server/src/routes/memories.ts | 185 ++++++----------------- server/src/services/memoriesService.ts | 199 +++++++++++++++++++++++++ 2 files changed, 242 insertions(+), 142 deletions(-) create mode 100644 server/src/services/memoriesService.ts diff --git a/server/src/routes/memories.ts b/server/src/routes/memories.ts index 6b510f3..6bd6e6f 100644 --- a/server/src/routes/memories.ts +++ b/server/src/routes/memories.ts @@ -1,8 +1,16 @@ import express, { Request, Response } from 'express'; -import { db, canAccessTrip } from '../db/database'; import { authenticate } from '../middleware/auth'; import { broadcast } from '../websocket'; import { AuthRequest } from '../types'; +import { + listTripPhotos, + listTripAlbumLinks, + removeAlbumLink, + addTripPhotos, + removeTripPhoto, + setTripPhotoSharing, + notifySharedTripPhotos, +} from '../services/memoriesService'; const router = express.Router(); @@ -10,63 +18,24 @@ const router = express.Router(); router.get('/trips/:tripId/photos', authenticate, (req: Request, res: Response) => { const authReq = req as AuthRequest; const { tripId } = req.params; - - if (!canAccessTrip(tripId, authReq.user.id)) { - return res.status(404).json({ error: 'Trip not found' }); - } - - const photos = db.prepare(` - SELECT tp.asset_id, tp.provider, tp.user_id, tp.shared, tp.added_at, - u.username, u.avatar - FROM trip_photos tp - JOIN users u ON tp.user_id = u.id - WHERE tp.trip_id = ? - AND (tp.user_id = ? OR tp.shared = 1) - ORDER BY tp.added_at ASC - `).all(tripId, authReq.user.id) as any[]; - - res.json({ photos }); + const result = listTripPhotos(tripId, authReq.user.id); + if ('error' in result) return res.status(result.status).json({ error: result.error }); + res.json({ photos: result.photos }); }); router.get('/trips/:tripId/album-links', authenticate, (req: Request, res: Response) => { const authReq = req as AuthRequest; const { tripId } = req.params; - - if (!canAccessTrip(tripId, authReq.user.id)) { - return res.status(404).json({ error: 'Trip not found' }); - } - - const links = db.prepare(` - SELECT tal.id, - tal.trip_id, - tal.user_id, - tal.provider, - tal.album_id, - tal.album_name, - tal.sync_enabled, - tal.last_synced_at, - tal.created_at, - u.username - FROM trip_album_links tal - JOIN users u ON tal.user_id = u.id - WHERE tal.trip_id = ? - ORDER BY tal.created_at ASC - `).all(tripId); - - res.json({ links }); + const result = listTripAlbumLinks(tripId, authReq.user.id); + if ('error' in result) return res.status(result.status).json({ error: result.error }); + res.json({ links: result.links }); }); router.delete('/trips/:tripId/album-links/:linkId', authenticate, (req: Request, res: Response) => { const authReq = req as AuthRequest; const { tripId, linkId } = req.params; - - if (!canAccessTrip(tripId, authReq.user.id)) { - return res.status(404).json({ error: 'Trip not found' }); - } - - db.prepare('DELETE FROM trip_album_links WHERE id = ? AND trip_id = ? AND user_id = ?') - .run(linkId, tripId, authReq.user.id); - + const result = removeAlbumLink(tripId, linkId, authReq.user.id); + if ('error' in result) return res.status(result.status).json({ error: result.error }); res.json({ success: true }); broadcast(tripId, 'memories:updated', { userId: authReq.user.id }, req.headers['x-socket-id'] as string); }); @@ -74,85 +43,34 @@ router.delete('/trips/:tripId/album-links/:linkId', authenticate, (req: Request, router.post('/trips/:tripId/photos', authenticate, (req: Request, res: Response) => { const authReq = req as AuthRequest; const { tripId } = req.params; - const { shared = true } = req.body; - const selectionsRaw = Array.isArray(req.body?.selections) ? req.body.selections : null; - const provider = String(req.body?.provider || '').toLowerCase(); - const assetIdsRaw = req.body?.asset_ids; - - if (!canAccessTrip(tripId, authReq.user.id)) { - return res.status(404).json({ error: 'Trip not found' }); - } - - const selections = selectionsRaw && selectionsRaw.length > 0 - ? selectionsRaw - .map((selection: any) => ({ - provider: String(selection?.provider || '').toLowerCase(), - asset_ids: Array.isArray(selection?.asset_ids) ? selection.asset_ids : [], - })) - .filter((selection: { provider: string; asset_ids: unknown[] }) => selection.provider && selection.asset_ids.length > 0) - : (provider && Array.isArray(assetIdsRaw) && assetIdsRaw.length > 0 - ? [{ provider, asset_ids: assetIdsRaw }] - : []); - - if (selections.length === 0) { - return res.status(400).json({ error: 'selections required' }); - } - - const insert = db.prepare( - 'INSERT OR IGNORE INTO trip_photos (trip_id, user_id, asset_id, provider, shared) VALUES (?, ?, ?, ?, ?)' + const result = addTripPhotos( + tripId, + authReq.user.id, + req.body?.shared, + req.body?.selections, + req.body?.provider, + req.body?.asset_ids, ); + if ('error' in result) return res.status(result.status).json({ error: result.error }); - let added = 0; - for (const selection of selections) { - for (const raw of selection.asset_ids) { - const assetId = String(raw || '').trim(); - if (!assetId) continue; - const result = insert.run(tripId, authReq.user.id, assetId, selection.provider, shared ? 1 : 0); - if (result.changes > 0) added++; - } - } - - res.json({ success: true, added }); + res.json({ success: true, added: result.added }); broadcast(tripId, 'memories:updated', { userId: authReq.user.id }, req.headers['x-socket-id'] as string); - if (shared && added > 0) { - import('../services/notifications').then(({ notifyTripMembers }) => { - const tripInfo = db.prepare('SELECT title FROM trips WHERE id = ?').get(tripId) as { title: string } | undefined; - notifyTripMembers(Number(tripId), authReq.user.id, 'photos_shared', { - trip: tripInfo?.title || 'Untitled', - actor: authReq.user.username || authReq.user.email, - count: String(added), - }).catch(() => {}); - }); + if (result.shared && result.added > 0) { + void notifySharedTripPhotos( + tripId, + authReq.user.id, + authReq.user.username || authReq.user.email, + result.added, + ).catch(() => {}); } }); router.delete('/trips/:tripId/photos', authenticate, (req: Request, res: Response) => { const authReq = req as AuthRequest; const { tripId } = req.params; - const provider = String(req.body?.provider || '').toLowerCase(); - const assetId = String(req.body?.asset_id || ''); - - if (!assetId) { - return res.status(400).json({ error: 'asset_id is required' }); - } - - if (!provider) { - return res.status(400).json({ error: 'provider is required' }); - } - - if (!canAccessTrip(tripId, authReq.user.id)) { - return res.status(404).json({ error: 'Trip not found' }); - } - - db.prepare(` - DELETE FROM trip_photos - WHERE trip_id = ? - AND user_id = ? - AND asset_id = ? - AND provider = ? - `).run(tripId, authReq.user.id, assetId, provider); - + const result = removeTripPhoto(tripId, authReq.user.id, req.body?.provider, req.body?.asset_id); + if ('error' in result) return res.status(result.status).json({ error: result.error }); res.json({ success: true }); broadcast(tripId, 'memories:updated', { userId: authReq.user.id }, req.headers['x-socket-id'] as string); }); @@ -160,31 +78,14 @@ router.delete('/trips/:tripId/photos', authenticate, (req: Request, res: Respons router.put('/trips/:tripId/photos/sharing', authenticate, (req: Request, res: Response) => { const authReq = req as AuthRequest; const { tripId } = req.params; - const provider = String(req.body?.provider || '').toLowerCase(); - const assetId = String(req.body?.asset_id || ''); - const { shared } = req.body; - - if (!assetId) { - return res.status(400).json({ error: 'asset_id is required' }); - } - - if (!provider) { - return res.status(400).json({ error: 'provider is required' }); - } - - if (!canAccessTrip(tripId, authReq.user.id)) { - return res.status(404).json({ error: 'Trip not found' }); - } - - db.prepare(` - UPDATE trip_photos - SET shared = ? - WHERE trip_id = ? - AND user_id = ? - AND asset_id = ? - AND provider = ? - `).run(shared ? 1 : 0, tripId, authReq.user.id, assetId, provider); - + const result = setTripPhotoSharing( + tripId, + authReq.user.id, + req.body?.provider, + req.body?.asset_id, + req.body?.shared, + ); + if ('error' in result) return res.status(result.status).json({ error: result.error }); res.json({ success: true }); broadcast(tripId, 'memories:updated', { userId: authReq.user.id }, req.headers['x-socket-id'] as string); }); diff --git a/server/src/services/memoriesService.ts b/server/src/services/memoriesService.ts new file mode 100644 index 0000000..eb8acc3 --- /dev/null +++ b/server/src/services/memoriesService.ts @@ -0,0 +1,199 @@ +import { db, canAccessTrip } from '../db/database'; +import { notifyTripMembers } from './notifications'; + +type ServiceError = { error: string; status: number }; + +function accessDeniedIfMissing(tripId: string, userId: number): ServiceError | null { + if (!canAccessTrip(tripId, userId)) { + return { error: 'Trip not found', status: 404 }; + } + return null; +} + +type Selection = { + provider: string; + asset_ids: unknown[]; +}; + +function normalizeSelections(selectionsRaw: unknown, providerRaw: unknown, assetIdsRaw: unknown): Selection[] { + const selectionsFromBody = Array.isArray(selectionsRaw) ? selectionsRaw : null; + const provider = String(providerRaw || '').toLowerCase(); + + if (selectionsFromBody && selectionsFromBody.length > 0) { + return selectionsFromBody + .map((selection: any) => ({ + provider: String(selection?.provider || '').toLowerCase(), + asset_ids: Array.isArray(selection?.asset_ids) ? selection.asset_ids : [], + })) + .filter((selection: Selection) => selection.provider && selection.asset_ids.length > 0); + } + + if (provider && Array.isArray(assetIdsRaw) && assetIdsRaw.length > 0) { + return [{ provider, asset_ids: assetIdsRaw }]; + } + + return []; +} + +export function listTripPhotos(tripId: string, userId: number): { photos: any[] } | ServiceError { + const denied = accessDeniedIfMissing(tripId, userId); + if (denied) return denied; + + const photos = db.prepare(` + SELECT tp.asset_id, tp.provider, tp.user_id, tp.shared, tp.added_at, + u.username, u.avatar + FROM trip_photos tp + JOIN users u ON tp.user_id = u.id + WHERE tp.trip_id = ? + AND (tp.user_id = ? OR tp.shared = 1) + ORDER BY tp.added_at ASC + `).all(tripId, userId) as any[]; + + return { photos }; +} + +export function listTripAlbumLinks(tripId: string, userId: number): { links: any[] } | ServiceError { + const denied = accessDeniedIfMissing(tripId, userId); + if (denied) return denied; + + const links = db.prepare(` + SELECT tal.id, + tal.trip_id, + tal.user_id, + tal.provider, + tal.album_id, + tal.album_name, + tal.sync_enabled, + tal.last_synced_at, + tal.created_at, + u.username + FROM trip_album_links tal + JOIN users u ON tal.user_id = u.id + WHERE tal.trip_id = ? + ORDER BY tal.created_at ASC + `).all(tripId); + + return { links }; +} + +export function removeAlbumLink(tripId: string, linkId: string, userId: number): { success: true } | ServiceError { + const denied = accessDeniedIfMissing(tripId, userId); + if (denied) return denied; + + db.prepare('DELETE FROM trip_album_links WHERE id = ? AND trip_id = ? AND user_id = ?') + .run(linkId, tripId, userId); + + return { success: true }; +} + +export function addTripPhotos( + tripId: string, + userId: number, + sharedRaw: unknown, + selectionsRaw: unknown, + providerRaw: unknown, + assetIdsRaw: unknown, +): { success: true; added: number; shared: boolean } | ServiceError { + const denied = accessDeniedIfMissing(tripId, userId); + if (denied) return denied; + + const shared = sharedRaw === undefined ? true : !!sharedRaw; + const selections = normalizeSelections(selectionsRaw, providerRaw, assetIdsRaw); + if (selections.length === 0) { + return { error: 'selections required', status: 400 }; + } + + const insert = db.prepare( + 'INSERT OR IGNORE INTO trip_photos (trip_id, user_id, asset_id, provider, shared) VALUES (?, ?, ?, ?, ?)' + ); + + let added = 0; + for (const selection of selections) { + for (const raw of selection.asset_ids) { + const assetId = String(raw || '').trim(); + if (!assetId) continue; + const result = insert.run(tripId, userId, assetId, selection.provider, shared ? 1 : 0); + if (result.changes > 0) added++; + } + } + + return { success: true, added, shared }; +} + +export function removeTripPhoto( + tripId: string, + userId: number, + providerRaw: unknown, + assetIdRaw: unknown, +): { success: true } | ServiceError { + const assetId = String(assetIdRaw || ''); + const provider = String(providerRaw || '').toLowerCase(); + + if (!assetId) { + return { error: 'asset_id is required', status: 400 }; + } + if (!provider) { + return { error: 'provider is required', status: 400 }; + } + + const denied = accessDeniedIfMissing(tripId, userId); + if (denied) return denied; + + db.prepare(` + DELETE FROM trip_photos + WHERE trip_id = ? + AND user_id = ? + AND asset_id = ? + AND provider = ? + `).run(tripId, userId, assetId, provider); + + return { success: true }; +} + +export function setTripPhotoSharing( + tripId: string, + userId: number, + providerRaw: unknown, + assetIdRaw: unknown, + sharedRaw: unknown, +): { success: true } | ServiceError { + const assetId = String(assetIdRaw || ''); + const provider = String(providerRaw || '').toLowerCase(); + + if (!assetId) { + return { error: 'asset_id is required', status: 400 }; + } + if (!provider) { + return { error: 'provider is required', status: 400 }; + } + + const denied = accessDeniedIfMissing(tripId, userId); + if (denied) return denied; + + db.prepare(` + UPDATE trip_photos + SET shared = ? + WHERE trip_id = ? + AND user_id = ? + AND asset_id = ? + AND provider = ? + `).run(sharedRaw ? 1 : 0, tripId, userId, assetId, provider); + + return { success: true }; +} + +export async function notifySharedTripPhotos( + tripId: string, + actorUserId: number, + actorName: string, + added: number, +): Promise { + if (added <= 0) return; + + const tripInfo = db.prepare('SELECT title FROM trips WHERE id = ?').get(tripId) as { title: string } | undefined; + await notifyTripMembers(Number(tripId), actorUserId, 'photos_shared', { + trip: tripInfo?.title || 'Untitled', + actor: actorName, + count: String(added), + }); +} From de4bdb4a99349b9c7d8a9b6b9282c0568a75e318 Mon Sep 17 00:00:00 2001 From: Marek Maslowski Date: Fri, 3 Apr 2026 17:10:18 +0200 Subject: [PATCH 20/57] fixing routes for asset details --- server/src/routes/immich.ts | 18 ++++----- server/src/services/immichService.ts | 55 +--------------------------- 2 files changed, 8 insertions(+), 65 deletions(-) diff --git a/server/src/routes/immich.ts b/server/src/routes/immich.ts index 7021ec7..31391f4 100644 --- a/server/src/routes/immich.ts +++ b/server/src/routes/immich.ts @@ -12,19 +12,13 @@ import { getConnectionStatus, browseTimeline, searchPhotos, - listTripPhotos, - addTripPhotos, - removeTripPhoto, - togglePhotoSharing, - getAssetInfo, proxyThumbnail, proxyOriginal, isValidAssetId, listAlbums, - listAlbumLinks, createAlbumLink, - deleteAlbumLink, syncAlbumAssets, + getAssetInfo, } from '../services/immichService'; const router = express.Router(); @@ -89,11 +83,13 @@ router.post('/search', authenticate, async (req: Request, res: Response) => { // ── Asset Details ────────────────────────────────────────────────────────── -router.get('/trips/:tripId/photos', authenticate, (req: Request, res: Response) => { +router.get('/assets/:assetId/info', authenticate, async (req: Request, res: Response) => { const authReq = req as AuthRequest; - const { tripId } = req.params; - if (!canAccessTrip(tripId, authReq.user.id)) return res.status(404).json({ error: 'Trip not found' }); - res.json({ photos: listTripPhotos(tripId, authReq.user.id) }); + const { assetId } = req.params; + if (!isValidAssetId(assetId)) return res.status(400).json({ error: 'Invalid asset ID' }); + const result = await getAssetInfo(authReq.user.id, assetId); + if (result.error) return res.status(result.status!).json({ error: result.error }); + res.json(result.data); }); // ── Proxy Immich Assets ──────────────────────────────────────────────────── diff --git a/server/src/services/immichService.ts b/server/src/services/immichService.ts index 4a3169f..eb563cd 100644 --- a/server/src/services/immichService.ts +++ b/server/src/services/immichService.ts @@ -1,4 +1,4 @@ -import { db, canAccessTrip } from '../db/database'; +import { db } from '../db/database'; import { maybe_encrypt_api_key, decrypt_api_key } from './apiKeyCrypto'; import { checkSsrf } from '../utils/ssrfGuard'; import { writeAudit } from './auditLog'; @@ -171,45 +171,6 @@ export async function searchPhotos( } } -// ── Trip Photos ──────────────────────────────────────────────────────────── - -export function listTripPhotos(tripId: string, userId: number) { - return db.prepare(` - SELECT tp.asset_id AS immich_asset_id, tp.user_id, tp.shared, tp.added_at, - u.username, u.avatar, u.immich_url - FROM trip_photos tp - JOIN users u ON tp.user_id = u.id - WHERE tp.trip_id = ? - AND tp.provider = 'immich' - AND (tp.user_id = ? OR tp.shared = 1) - ORDER BY tp.added_at ASC - `).all(tripId, userId); -} - -export function addTripPhotos( - tripId: string, - userId: number, - assetIds: string[], - shared: boolean -): number { - const insert = db.prepare('INSERT OR IGNORE INTO trip_photos (trip_id, user_id, asset_id, provider, shared) VALUES (?, ?, ?, ?, ?)'); - let added = 0; - for (const assetId of assetIds) { - const result = insert.run(tripId, userId, assetId, 'immich', shared ? 1 : 0); - if (result.changes > 0) added++; - } - return added; -} - -export function removeTripPhoto(tripId: string, userId: number, assetId: string) { - db.prepare('DELETE FROM trip_photos WHERE trip_id = ? AND user_id = ? AND asset_id = ? AND provider = ?') - .run(tripId, userId, assetId, 'immich'); -} - -export function togglePhotoSharing(tripId: string, userId: number, assetId: string, shared: boolean) { - db.prepare('UPDATE trip_photos SET shared = ? WHERE trip_id = ? AND user_id = ? AND asset_id = ? AND provider = ?') - .run(shared ? 1 : 0, tripId, userId, assetId, 'immich'); -} // ── Asset Info / Proxy ───────────────────────────────────────────────────── @@ -323,15 +284,6 @@ export async function listAlbums( } } -export function listAlbumLinks(tripId: string) { - return db.prepare(` - SELECT tal.*, u.username - FROM trip_album_links tal - JOIN users u ON tal.user_id = u.id - WHERE tal.trip_id = ? AND tal.provider = 'immich' - ORDER BY tal.created_at ASC - `).all(tripId); -} export function createAlbumLink( tripId: string, @@ -349,11 +301,6 @@ export function createAlbumLink( } } -export function deleteAlbumLink(linkId: string, tripId: string, userId: number) { - db.prepare('DELETE FROM trip_album_links WHERE id = ? AND trip_id = ? AND user_id = ?') - .run(linkId, tripId, userId); -} - export async function syncAlbumAssets( tripId: string, linkId: string, From 90af1332e85dde6fc28d5a6274ade26c4f40bf02 Mon Sep 17 00:00:00 2001 From: Marek Maslowski Date: Fri, 3 Apr 2026 17:25:25 +0200 Subject: [PATCH 21/57] moving linking album to common interface --- .../src/components/Memories/MemoriesPanel.tsx | 12 +++++-- server/src/routes/immich.ts | 12 ------- server/src/routes/memories.ts | 9 ++++++ server/src/routes/synology.ts | 19 ------------ server/src/services/immichService.ts | 16 ---------- server/src/services/memoriesService.ts | 31 +++++++++++++++++++ server/src/services/synologyService.ts | 17 ---------- 7 files changed, 50 insertions(+), 66 deletions(-) diff --git a/client/src/components/Memories/MemoriesPanel.tsx b/client/src/components/Memories/MemoriesPanel.tsx index 8787dd7..4614f6d 100644 --- a/client/src/components/Memories/MemoriesPanel.tsx +++ b/client/src/components/Memories/MemoriesPanel.tsx @@ -81,7 +81,6 @@ export default function MemoriesPanel({ tripId, startDate, endDate }: MemoriesPa const [albumsLoading, setAlbumsLoading] = useState(false) const [albumLinks, setAlbumLinks] = useState<{ id: number; provider: string; album_id: string; album_name: string; user_id: number; username: string; sync_enabled: number; last_synced_at: string | null }[]>([]) const [syncing, setSyncing] = useState(null) - const pickerIntegrationBase = selectedProvider ? `/integrations/${selectedProvider}` : '' const loadAlbumLinks = async () => { try { @@ -110,8 +109,17 @@ export default function MemoriesPanel({ tripId, startDate, endDate }: MemoriesPa } const linkAlbum = async (albumId: string, albumName: string) => { + if (!selectedProvider) { + toast.error(t('memories.error.linkAlbum')) + return + } + try { - await apiClient.post(`${pickerIntegrationBase}/trips/${tripId}/album-links`, { album_id: albumId, album_name: albumName }) + await apiClient.post(`/integrations/memories/trips/${tripId}/album-links`, { + album_id: albumId, + album_name: albumName, + provider: selectedProvider, + }) setShowAlbumPicker(false) await loadAlbumLinks() // Auto-sync after linking diff --git a/server/src/routes/immich.ts b/server/src/routes/immich.ts index 31391f4..4225367 100644 --- a/server/src/routes/immich.ts +++ b/server/src/routes/immich.ts @@ -16,7 +16,6 @@ import { proxyOriginal, isValidAssetId, listAlbums, - createAlbumLink, syncAlbumAssets, getAssetInfo, } from '../services/immichService'; @@ -125,17 +124,6 @@ router.get('/albums', authenticate, async (req: Request, res: Response) => { res.json({ albums: result.albums }); }); -router.post('/trips/:tripId/album-links', authenticate, async (req: Request, res: Response) => { - const authReq = req as AuthRequest; - const { tripId } = req.params; - if (!canAccessTrip(tripId, authReq.user.id)) return res.status(404).json({ error: 'Trip not found' }); - const { album_id, album_name } = req.body; - if (!album_id) return res.status(400).json({ error: 'album_id required' }); - const result = createAlbumLink(tripId, authReq.user.id, album_id, album_name); - if (!result.success) return res.status(400).json({ error: result.error }); - res.json({ success: true }); -}); - router.post('/trips/:tripId/album-links/:linkId/sync', authenticate, async (req: Request, res: Response) => { const authReq = req as AuthRequest; const { tripId, linkId } = req.params; diff --git a/server/src/routes/memories.ts b/server/src/routes/memories.ts index 6bd6e6f..5a6f1cf 100644 --- a/server/src/routes/memories.ts +++ b/server/src/routes/memories.ts @@ -5,6 +5,7 @@ import { AuthRequest } from '../types'; import { listTripPhotos, listTripAlbumLinks, + createTripAlbumLink, removeAlbumLink, addTripPhotos, removeTripPhoto, @@ -90,4 +91,12 @@ router.put('/trips/:tripId/photos/sharing', authenticate, (req: Request, res: Re broadcast(tripId, 'memories:updated', { userId: authReq.user.id }, req.headers['x-socket-id'] as string); }); +router.post('/trips/:tripId/album-links', authenticate, (req: Request, res: Response) => { + const authReq = req as AuthRequest; + const { tripId } = req.params; + const result = createTripAlbumLink(tripId, authReq.user.id, req.body?.provider, req.body?.album_id, req.body?.album_name); + if ('error' in result) return res.status(result.status).json({ error: result.error }); + res.json({ success: true }); +}); + export default router; diff --git a/server/src/routes/synology.ts b/server/src/routes/synology.ts index 9b26acc..f5f7203 100644 --- a/server/src/routes/synology.ts +++ b/server/src/routes/synology.ts @@ -86,25 +86,6 @@ router.get('/albums', authenticate, async (req: Request, res: Response) => { } }); -router.post('/trips/:tripId/album-links', authenticate, (req: Request, res: Response) => { - const authReq = req as AuthRequest; - const { tripId } = req.params; - const body = req.body as Record; - const albumId = parseStringBodyField(body.album_id); - const albumName = parseStringBodyField(body.album_name); - - if (!albumId) { - return handleSynologyError(res, new SynologyServiceError(400, 'Album ID is required'), 'Missing required fields'); - } - - try { - linkSynologyAlbum(authReq.user.id, tripId, albumId, albumName || undefined); - res.json({ success: true }); - } catch (err: unknown) { - handleSynologyError(res, err, 'Failed to link album'); - } -}); - router.post('/trips/:tripId/album-links/:linkId/sync', authenticate, async (req: Request, res: Response) => { const authReq = req as AuthRequest; const { tripId, linkId } = req.params; diff --git a/server/src/services/immichService.ts b/server/src/services/immichService.ts index eb563cd..9d8e9c3 100644 --- a/server/src/services/immichService.ts +++ b/server/src/services/immichService.ts @@ -285,22 +285,6 @@ export async function listAlbums( } -export function createAlbumLink( - tripId: string, - userId: number, - albumId: string, - albumName: string -): { success: boolean; error?: string } { - try { - db.prepare( - 'INSERT OR IGNORE INTO trip_album_links (trip_id, user_id, provider, album_id, album_name) VALUES (?, ?, ?, ?, ?)' - ).run(tripId, userId, 'immich', albumId, albumName || ''); - return { success: true }; - } catch { - return { success: false, error: 'Album already linked' }; - } -} - export async function syncAlbumAssets( tripId: string, linkId: string, diff --git a/server/src/services/memoriesService.ts b/server/src/services/memoriesService.ts index eb8acc3..5fdfbce 100644 --- a/server/src/services/memoriesService.ts +++ b/server/src/services/memoriesService.ts @@ -76,6 +76,37 @@ export function listTripAlbumLinks(tripId: string, userId: number): { links: any return { links }; } +export function createTripAlbumLink( + tripId: string, + userId: number, + providerRaw: unknown, + albumIdRaw: unknown, + albumNameRaw: unknown, +): { success: true } | ServiceError { + const denied = accessDeniedIfMissing(tripId, userId); + if (denied) return denied; + + const provider = String(providerRaw || '').toLowerCase(); + const albumId = String(albumIdRaw || '').trim(); + const albumName = String(albumNameRaw || '').trim(); + + if (!provider) { + return { error: 'provider is required', status: 400 }; + } + if (!albumId) { + return { error: 'album_id required', status: 400 }; + } + + try { + db.prepare( + 'INSERT OR IGNORE INTO trip_album_links (trip_id, user_id, provider, album_id, album_name) VALUES (?, ?, ?, ?, ?)' + ).run(tripId, userId, provider, albumId, albumName); + return { success: true }; + } catch { + return { error: 'Album already linked', status: 400 }; + } +} + export function removeAlbumLink(tripId: string, linkId: string, userId: number): { success: true } | ServiceError { const denied = accessDeniedIfMissing(tripId, userId); if (denied) return denied; diff --git a/server/src/services/synologyService.ts b/server/src/services/synologyService.ts index 1c25ef5..7b3182e 100644 --- a/server/src/services/synologyService.ts +++ b/server/src/services/synologyService.ts @@ -438,23 +438,6 @@ export async function listSynologyAlbums(userId: number): Promise<{ albums: Arra return { albums }; } -export function linkSynologyAlbum(userId: number, tripId: string, albumId: string | number | undefined, albumName?: string): void { - if (!canAccessTrip(tripId, userId)) { - throw new SynologyServiceError(404, 'Trip not found'); - } - - if (!albumId) { - throw new SynologyServiceError(400, 'album_id required'); - } - - const changes = db.prepare( - 'INSERT OR IGNORE INTO trip_album_links (trip_id, user_id, provider, album_id, album_name) VALUES (?, ?, ?, ?, ?)' - ).run(tripId, userId, SYNOLOGY_PROVIDER, String(albumId), albumName || '').changes; - - if (changes === 0) { - throw new SynologyServiceError(400, 'Album already linked'); - } -} export async function syncSynologyAlbumLink(userId: number, tripId: string, linkId: string): Promise<{ added: number; total: number }> { const link = db.prepare(`SELECT * FROM trip_album_links WHERE id = ? AND trip_id = ? AND user_id = ? AND provider = ?`) From 07546c47902278c0a3d012ee5640f264d8d362e5 Mon Sep 17 00:00:00 2001 From: Marek Maslowski <99432678+tiquis0290@users.noreply.github.com> Date: Fri, 3 Apr 2026 17:29:50 +0200 Subject: [PATCH 22/57] Refactor resource token creation logic Simplified token creation by directly using req.body.purpose. --- server/src/routes/auth.ts | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/server/src/routes/auth.ts b/server/src/routes/auth.ts index 3947cf7..dd977df 100644 --- a/server/src/routes/auth.ts +++ b/server/src/routes/auth.ts @@ -317,11 +317,7 @@ router.post('/ws-token', authenticate, (req: Request, res: Response) => { // Short-lived single-use token for direct resource URLs router.post('/resource-token', authenticate, (req: Request, res: Response) => { const authReq = req as AuthRequest; - const { purpose } = req.body as { purpose?: string }; - if (purpose !== 'download' && purpose !== 'immich' && purpose !== 'synologyphotos') { - return res.status(400).json({ error: 'Invalid purpose' }); - } - const token = createResourceToken(authReq.user.id, purpose); + const token = createResourceToken(authReq.user.id, req.body.purpose); if (!token) return res.status(503).json({ error: 'Service unavailable' }); res.json(token); }); From 61a5e42403a2cd9f9914902214a0c618240e381d Mon Sep 17 00:00:00 2001 From: Marek Maslowski <99432678+tiquis0290@users.noreply.github.com> Date: Fri, 3 Apr 2026 17:31:30 +0200 Subject: [PATCH 23/57] Fix export statement formatting in synology.ts --- server/src/routes/synology.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/server/src/routes/synology.ts b/server/src/routes/synology.ts index f5f7203..51ad572 100644 --- a/server/src/routes/synology.ts +++ b/server/src/routes/synology.ts @@ -165,4 +165,4 @@ router.get('/assets/:photoId/original', synologyAuthFromQuery, async (req: Reque } }); -export default router; \ No newline at end of file +export default router; From 69deaf99699107b3a07f51c061a203e0bd63becc Mon Sep 17 00:00:00 2001 From: Marek Maslowski Date: Fri, 3 Apr 2026 17:41:40 +0200 Subject: [PATCH 24/57] removing uneccessary login in admin.ts --- server/src/routes/admin.ts | 92 +++----------------------------------- 1 file changed, 6 insertions(+), 86 deletions(-) diff --git a/server/src/routes/admin.ts b/server/src/routes/admin.ts index 86896ab..530a967 100644 --- a/server/src/routes/admin.ts +++ b/server/src/routes/admin.ts @@ -1,7 +1,6 @@ import express, { Request, Response } from 'express'; import { authenticate, adminOnly } from '../middleware/auth'; -import { db } from '../db/database'; -import { AuthRequest, Addon } from '../types'; +import { AuthRequest } from '../types'; import { writeAudit, getClientIp, logInfo } from '../services/auditLog'; import * as svc from '../services/adminService'; @@ -265,100 +264,21 @@ router.delete('/packing-templates/:templateId/items/:itemId', (req: Request, res // ── Addons ───────────────────────────────────────────────────────────────── router.get('/addons', (_req: Request, res: Response) => { - const addons = db.prepare('SELECT * FROM addons ORDER BY sort_order, id').all() as Addon[]; - const providers = db.prepare(` - SELECT id, name, description, icon, enabled, config, sort_order - FROM photo_providers - ORDER BY sort_order, id - `).all() as Array<{ id: string; name: string; description?: string | null; icon: string; enabled: number; config: string; sort_order: number }>; - const fields = db.prepare(` - SELECT provider_id, field_key, label, input_type, placeholder, required, secret, settings_key, payload_key, sort_order - FROM photo_provider_fields - ORDER BY sort_order, id - `).all() as Array<{ - provider_id: string; - field_key: string; - label: string; - input_type: string; - placeholder?: string | null; - required: number; - secret: number; - settings_key?: string | null; - payload_key?: string | null; - sort_order: number; - }>; - const fieldsByProvider = new Map(); - for (const field of fields) { - const arr = fieldsByProvider.get(field.provider_id) || []; - arr.push(field); - fieldsByProvider.set(field.provider_id, arr); - } - - res.json({ - addons: [ - ...addons.map(a => ({ ...a, enabled: !!a.enabled, config: JSON.parse(a.config || '{}') })), - ...providers.map(p => ({ - id: p.id, - name: p.name, - description: p.description, - type: 'photo_provider', - icon: p.icon, - enabled: !!p.enabled, - config: JSON.parse(p.config || '{}'), - fields: (fieldsByProvider.get(p.id) || []).map(f => ({ - key: f.field_key, - label: f.label, - input_type: f.input_type, - placeholder: f.placeholder || '', - required: !!f.required, - secret: !!f.secret, - settings_key: f.settings_key || null, - payload_key: f.payload_key || null, - sort_order: f.sort_order, - })), - sort_order: p.sort_order, - })), - ], - }); + res.json({ addons: svc.listAddons() }); }); router.put('/addons/:id', (req: Request, res: Response) => { - const addon = db.prepare('SELECT * FROM addons WHERE id = ?').get(req.params.id) as Addon | undefined; - const provider = db.prepare('SELECT * FROM photo_providers WHERE id = ?').get(req.params.id) as { id: string; name: string; description?: string | null; icon: string; enabled: number; config: string; sort_order: number } | undefined; - if (!addon && !provider) return res.status(404).json({ error: 'Addon not found' }); - const { enabled, config } = req.body; - if (addon) { - if (enabled !== undefined) db.prepare('UPDATE addons SET enabled = ? WHERE id = ?').run(enabled ? 1 : 0, req.params.id); - if (config !== undefined) db.prepare('UPDATE addons SET config = ? WHERE id = ?').run(JSON.stringify(config), req.params.id); - } else { - if (enabled !== undefined) db.prepare('UPDATE photo_providers SET enabled = ? WHERE id = ?').run(enabled ? 1 : 0, req.params.id); - if (config !== undefined) db.prepare('UPDATE photo_providers SET config = ? WHERE id = ?').run(JSON.stringify(config), req.params.id); - } - const updatedAddon = db.prepare('SELECT * FROM addons WHERE id = ?').get(req.params.id) as Addon | undefined; - const updatedProvider = db.prepare('SELECT * FROM photo_providers WHERE id = ?').get(req.params.id) as { id: string; name: string; description?: string | null; icon: string; enabled: number; config: string; sort_order: number } | undefined; - const updated = updatedAddon - ? { ...updatedAddon, enabled: !!updatedAddon.enabled, config: JSON.parse(updatedAddon.config || '{}') } - : updatedProvider - ? { - id: updatedProvider.id, - name: updatedProvider.name, - description: updatedProvider.description, - type: 'photo_provider', - icon: updatedProvider.icon, - enabled: !!updatedProvider.enabled, - config: JSON.parse(updatedProvider.config || '{}'), - sort_order: updatedProvider.sort_order, - } - : null; + const result = svc.updateAddon(req.params.id, req.body || {}); + if ('error' in result) return res.status(result.status!).json({ error: result.error }); const authReq = req as AuthRequest; writeAudit({ userId: authReq.user.id, action: 'admin.addon_update', resource: String(req.params.id), ip: getClientIp(req), - details: { enabled: req.body.enabled, config: req.body.config }, + details: result.auditDetails, }); - res.json({ addon: updated }); + res.json({ addon: result.addon }); }); // ── MCP Tokens ───────────────────────────────────────────────────────────── From 66740887e7c4d2bf574de5f17e0bf8905a47d8a0 Mon Sep 17 00:00:00 2001 From: Marek Maslowski <99432678+tiquis0290@users.noreply.github.com> Date: Fri, 3 Apr 2026 17:46:00 +0200 Subject: [PATCH 25/57] returning admin file to orginal look --- server/src/routes/admin.ts | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/server/src/routes/admin.ts b/server/src/routes/admin.ts index 530a967..56a9136 100644 --- a/server/src/routes/admin.ts +++ b/server/src/routes/admin.ts @@ -268,7 +268,7 @@ router.get('/addons', (_req: Request, res: Response) => { }); router.put('/addons/:id', (req: Request, res: Response) => { - const result = svc.updateAddon(req.params.id, req.body || {}); + const result = svc.updateAddon(req.params.id, req.body); if ('error' in result) return res.status(result.status!).json({ error: result.error }); const authReq = req as AuthRequest; writeAudit({ @@ -300,9 +300,12 @@ router.post('/rotate-jwt-secret', (req: Request, res: Response) => { if (result.error) return res.status(result.status!).json({ error: result.error }); const authReq = req as AuthRequest; writeAudit({ - userId: authReq.user?.id ?? null, + user_id: authReq.user?.id ?? null, + username: authReq.user?.username ?? 'unknown', action: 'admin.rotate_jwt_secret', - resource: 'system', + target_type: 'system', + target_id: null, + details: null, ip: getClientIp(req), }); res.json({ success: true }); From 7d51eadf90309aa689d90f0d7f2a12dfd9eff693 Mon Sep 17 00:00:00 2001 From: Marek Maslowski Date: Fri, 3 Apr 2026 16:08:46 +0000 Subject: [PATCH 26/57] removing old function import --- server/src/routes/synology.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/server/src/routes/synology.ts b/server/src/routes/synology.ts index 51ad572..7dd44d8 100644 --- a/server/src/routes/synology.ts +++ b/server/src/routes/synology.ts @@ -8,7 +8,6 @@ import { getSynologyStatus, testSynologyConnection, listSynologyAlbums, - linkSynologyAlbum, syncSynologyAlbumLink, searchSynologyPhotos, getSynologyAssetInfo, From b6686a462fa0f77e31e0498d605b41470f377d0d Mon Sep 17 00:00:00 2001 From: Marek Maslowski Date: Fri, 3 Apr 2026 22:30:49 +0200 Subject: [PATCH 27/57] removing use of single sue auth tokens for assets --- server/src/routes/synology.ts | 5 ++--- server/src/services/synologyService.ts | 13 ------------- 2 files changed, 2 insertions(+), 16 deletions(-) diff --git a/server/src/routes/synology.ts b/server/src/routes/synology.ts index 7dd44d8..c01e49e 100644 --- a/server/src/routes/synology.ts +++ b/server/src/routes/synology.ts @@ -12,7 +12,6 @@ import { searchSynologyPhotos, getSynologyAssetInfo, pipeSynologyProxy, - synologyAuthFromQuery, getSynologyTargetUserId, streamSynologyAsset, handleSynologyError, @@ -133,7 +132,7 @@ router.get('/assets/:photoId/info', authenticate, async (req: Request, res: Resp } }); -router.get('/assets/:photoId/thumbnail', synologyAuthFromQuery, async (req: Request, res: Response) => { +router.get('/assets/:photoId/thumbnail', authenticate, async (req: Request, res: Response) => { const authReq = req as AuthRequest; const { photoId } = req.params; const { size = 'sm' } = req.query; @@ -149,7 +148,7 @@ router.get('/assets/:photoId/thumbnail', synologyAuthFromQuery, async (req: Requ } }); -router.get('/assets/:photoId/original', synologyAuthFromQuery, async (req: Request, res: Response) => { +router.get('/assets/:photoId/original', authenticate, async (req: Request, res: Response) => { const authReq = req as AuthRequest; const { photoId } = req.params; diff --git a/server/src/services/synologyService.ts b/server/src/services/synologyService.ts index 7b3182e..646c592 100644 --- a/server/src/services/synologyService.ts +++ b/server/src/services/synologyService.ts @@ -270,19 +270,6 @@ function normalizeSynologyPhotoInfo(item: SynologyPhotoItem): SynologyPhotoInfo }; } -export function synologyAuthFromQuery(req: Request, res: ExpressResponse, next: NextFunction) { - const queryToken = req.query.token as string | undefined; - if (queryToken) { - const userId = consumeEphemeralToken(queryToken, SYNOLOGY_PROVIDER); - if (!userId) return res.status(401).send('Invalid or expired token'); - const user = db.prepare('SELECT id, username, email, role, mfa_enabled FROM users WHERE id = ?').get(userId) as any; - if (!user) return res.status(401).send('User not found'); - (req as AuthRequest).user = user; - return next(); - } - return (authenticate as any)(req, res, next); -} - export function getSynologyTargetUserId(req: Request): number { const { userId } = req.query; return Number(userId); From 860739b28babe88807754d9c4f0fb92e8b1e72d3 Mon Sep 17 00:00:00 2001 From: Marek Maslowski Date: Sat, 4 Apr 2026 00:52:01 +0200 Subject: [PATCH 28/57] changing handling of rights for accesing assets --- .../src/components/Memories/MemoriesPanel.tsx | 8 ++-- server/src/routes/immich.ts | 42 ++++++++----------- server/src/routes/synology.ts | 32 +++++++++----- server/src/services/immichService.ts | 13 ------ server/src/services/memoriesService.ts | 28 +++++++++++++ server/src/services/synologyService.ts | 39 ++--------------- 6 files changed, 75 insertions(+), 87 deletions(-) diff --git a/client/src/components/Memories/MemoriesPanel.tsx b/client/src/components/Memories/MemoriesPanel.tsx index ad9be29..963f547 100644 --- a/client/src/components/Memories/MemoriesPanel.tsx +++ b/client/src/components/Memories/MemoriesPanel.tsx @@ -338,7 +338,7 @@ export default function MemoriesPanel({ tripId, startDate, endDate }: MemoriesPa // ── Helpers ─────────────────────────────────────────────────────────────── const thumbnailBaseUrl = (photo: TripPhoto) => - `/api/integrations/${photo.provider}/assets/${photo.asset_id}/thumbnail?userId=${photo.user_id}` + `/api/integrations/${photo.provider}/assets/${tripId}/${photo.asset_id}/${photo.user_id}/thumbnail` const makePickerKey = (provider: string, assetId: string): string => `${provider}::${assetId}` @@ -775,12 +775,12 @@ export default function MemoriesPanel({ tripId, startDate, endDate }: MemoriesPa
{ - setLightboxId(photo.immich_asset_id); setLightboxUserId(photo.user_id); setLightboxInfo(null) + setLightboxId(photo.asset_id); setLightboxUserId(photo.user_id); setLightboxInfo(null) if (lightboxOriginalSrc) URL.revokeObjectURL(lightboxOriginalSrc) setLightboxOriginalSrc('') - fetchImageAsBlob(`/api/integrations/immich/assets/${photo.immich_asset_id}/original?userId=${photo.user_id}`).then(setLightboxOriginalSrc) + fetchImageAsBlob(`/api/integrations/${photo.provider}/assets/${tripId}/${photo.asset_id}/${photo.user_id}/original`).then(setLightboxOriginalSrc) setLightboxInfoLoading(true) - apiClient.get(`/integrations/${photo.provider}/assets/${photo.asset_id}/info?userId=${photo.user_id}`) + apiClient.get(`/integrations/${photo.provider}/assets/${tripId}/${photo.asset_id}/${photo.user_id}/info`) .then(r => setLightboxInfo(r.data)).catch(() => {}).finally(() => setLightboxInfoLoading(false)) }}> diff --git a/server/src/routes/immich.ts b/server/src/routes/immich.ts index 4a166a1..012d7ef 100644 --- a/server/src/routes/immich.ts +++ b/server/src/routes/immich.ts @@ -15,11 +15,11 @@ import { proxyThumbnail, proxyOriginal, isValidAssetId, - canAccessUserPhoto, listAlbums, syncAlbumAssets, getAssetInfo, } from '../services/immichService'; +import { canAccessUserPhoto } from '../services/memoriesService'; const router = express.Router(); @@ -83,48 +83,42 @@ router.post('/search', authenticate, async (req: Request, res: Response) => { // ── Asset Details ────────────────────────────────────────────────────────── -router.get('/assets/:assetId/info', authenticate, async (req: Request, res: Response) => { +router.get('/assets/:tripId/:assetId/:ownerId/info', authenticate, async (req: Request, res: Response) => { const authReq = req as AuthRequest; - const { assetId } = req.params; - if (!isValidAssetId(assetId)) return res.status(400).json({ error: 'Invalid asset ID' }); - const queryUserId = req.query.userId ? Number(req.query.userId) : undefined; - const ownerUserId = queryUserId && queryUserId !== authReq.user.id ? queryUserId : undefined; - if (ownerUserId && !canAccessUserPhoto(authReq.user.id, ownerUserId, assetId)) { + const { tripId, assetId, ownerId } = req.params; + + if (!canAccessUserPhoto(authReq.user.id, Number(ownerId), tripId, assetId, 'immich')) { return res.status(403).json({ error: 'Forbidden' }); } - const result = await getAssetInfo(authReq.user.id, assetId, ownerUserId); + const result = await getAssetInfo(authReq.user.id, assetId, Number(ownerId)); if (result.error) return res.status(result.status!).json({ error: result.error }); res.json(result.data); }); // ── Proxy Immich Assets ──────────────────────────────────────────────────── -router.get('/assets/:assetId/thumbnail', authFromQuery, async (req: Request, res: Response) => { +router.get('/assets/:tripId/:assetId/:ownerId/thumbnail', authFromQuery, async (req: Request, res: Response) => { const authReq = req as AuthRequest; - const { assetId } = req.params; - if (!isValidAssetId(assetId)) return res.status(400).send('Invalid asset ID'); - const queryUserId = req.query.userId ? Number(req.query.userId) : undefined; - const ownerUserId = queryUserId && queryUserId !== authReq.user.id ? queryUserId : undefined; - if (ownerUserId && !canAccessUserPhoto(authReq.user.id, ownerUserId, assetId)) { - return res.status(403).send('Forbidden'); + const { tripId, assetId, ownerId } = req.params; + + if (!canAccessUserPhoto(authReq.user.id, Number(ownerId), tripId, assetId, 'immich')) { + return res.status(403).json({ error: 'Forbidden' }); } - const result = await proxyThumbnail(authReq.user.id, assetId, ownerUserId); + const result = await proxyThumbnail(authReq.user.id, assetId, Number(ownerId)); if (result.error) return res.status(result.status!).send(result.error); res.set('Content-Type', result.contentType!); res.set('Cache-Control', 'public, max-age=86400'); res.send(result.buffer); }); -router.get('/assets/:assetId/original', authFromQuery, async (req: Request, res: Response) => { +router.get('/assets/:tripId/:assetId/:ownerId/original', authFromQuery, async (req: Request, res: Response) => { const authReq = req as AuthRequest; - const { assetId } = req.params; - if (!isValidAssetId(assetId)) return res.status(400).send('Invalid asset ID'); - const queryUserId = req.query.userId ? Number(req.query.userId) : undefined; - const ownerUserId = queryUserId && queryUserId !== authReq.user.id ? queryUserId : undefined; - if (ownerUserId && !canAccessUserPhoto(authReq.user.id, ownerUserId, assetId)) { - return res.status(403).send('Forbidden'); + const { tripId, assetId, ownerId } = req.params; + + if (!canAccessUserPhoto(authReq.user.id, Number(ownerId), tripId, assetId, 'immich')) { + return res.status(403).json({ error: 'Forbidden' }); } - const result = await proxyOriginal(authReq.user.id, assetId, ownerUserId); + const result = await proxyOriginal(authReq.user.id, assetId, Number(ownerId)); if (result.error) return res.status(result.status!).send(result.error); res.set('Content-Type', result.contentType!); res.set('Cache-Control', 'public, max-age=86400'); diff --git a/server/src/routes/synology.ts b/server/src/routes/synology.ts index c01e49e..838a3de 100644 --- a/server/src/routes/synology.ts +++ b/server/src/routes/synology.ts @@ -12,11 +12,11 @@ import { searchSynologyPhotos, getSynologyAssetInfo, pipeSynologyProxy, - getSynologyTargetUserId, streamSynologyAsset, handleSynologyError, SynologyServiceError, } from '../services/synologyService'; +import { canAccessUserPhoto } from '../services/memoriesService'; const router = express.Router(); @@ -121,24 +121,32 @@ router.post('/search', authenticate, async (req: Request, res: Response) => { } }); -router.get('/assets/:photoId/info', authenticate, async (req: Request, res: Response) => { +router.get('/assets/:tripId/:photoId/:ownerId/info', authenticate, async (req: Request, res: Response) => { const authReq = req as AuthRequest; - const { photoId } = req.params; + const { tripId, photoId, ownerId } = req.params; + + if (!canAccessUserPhoto(authReq.user.id, Number(ownerId), tripId, photoId, 'synologyphotos')) { + return handleSynologyError(res, new SynologyServiceError(403, 'You don\'t have access to this photo'), 'Access denied'); + } try { - res.json(await getSynologyAssetInfo(authReq.user.id, photoId, getSynologyTargetUserId(req))); + res.json(await getSynologyAssetInfo(authReq.user.id, photoId, Number(ownerId))); } catch (err: unknown) { handleSynologyError(res, err, 'Could not reach Synology'); } }); -router.get('/assets/:photoId/thumbnail', authenticate, async (req: Request, res: Response) => { +router.get('/assets/:tripId/:photoId/:ownerId/thumbnail', authenticate, async (req: Request, res: Response) => { const authReq = req as AuthRequest; - const { photoId } = req.params; + const { tripId, photoId, ownerId } = req.params; const { size = 'sm' } = req.query; + if (!canAccessUserPhoto(authReq.user.id, Number(ownerId), tripId, photoId, 'synologyphotos')) { + return handleSynologyError(res, new SynologyServiceError(403, 'You don\'t have access to this photo'), 'Access denied'); + } + try { - const proxy = await streamSynologyAsset(authReq.user.id, getSynologyTargetUserId(req), photoId, 'thumbnail', String(size)); + const proxy = await streamSynologyAsset(authReq.user.id, Number(ownerId), photoId, 'thumbnail', String(size)); await pipeSynologyProxy(res, proxy); } catch (err: unknown) { if (res.headersSent) { @@ -148,12 +156,16 @@ router.get('/assets/:photoId/thumbnail', authenticate, async (req: Request, res: } }); -router.get('/assets/:photoId/original', authenticate, async (req: Request, res: Response) => { +router.get('/assets/:tripId/:photoId/:ownerId/original', authenticate, async (req: Request, res: Response) => { const authReq = req as AuthRequest; - const { photoId } = req.params; + const { tripId, photoId, ownerId } = req.params; + + if (!canAccessUserPhoto(authReq.user.id, Number(ownerId), tripId, photoId, 'synologyphotos')) { + return handleSynologyError(res, new SynologyServiceError(403, 'You don\'t have access to this photo'), 'Access denied'); + } try { - const proxy = await streamSynologyAsset(authReq.user.id, getSynologyTargetUserId(req), photoId, 'original'); + const proxy = await streamSynologyAsset(authReq.user.id, Number(ownerId), photoId, 'original'); await pipeSynologyProxy(res, proxy); } catch (err: unknown) { if (res.headersSent) { diff --git a/server/src/services/immichService.ts b/server/src/services/immichService.ts index 5515d9e..baef3bb 100644 --- a/server/src/services/immichService.ts +++ b/server/src/services/immichService.ts @@ -190,19 +190,6 @@ export async function searchPhotos( // ── Asset Info / Proxy ───────────────────────────────────────────────────── -/** - * Verify that requestingUserId can access a shared photo belonging to ownerUserId. - * The asset must be shared (shared=1) and the requesting user must be a member of - * the same trip that contains the photo. - */ -export function canAccessUserPhoto(requestingUserId: number, ownerUserId: number, assetId: string): boolean { - const row = db.prepare(` - SELECT tp.trip_id FROM trip_photos tp - WHERE tp.immich_asset_id = ? AND tp.user_id = ? AND tp.shared = 1 - `).get(assetId, ownerUserId) as { trip_id: number } | undefined; - if (!row) return false; - return !!canAccessTrip(String(row.trip_id), requestingUserId); -} export async function getAssetInfo( userId: number, diff --git a/server/src/services/memoriesService.ts b/server/src/services/memoriesService.ts index 5fdfbce..3886c8d 100644 --- a/server/src/services/memoriesService.ts +++ b/server/src/services/memoriesService.ts @@ -3,6 +3,34 @@ import { notifyTripMembers } from './notifications'; type ServiceError = { error: string; status: number }; + +/** + * Verify that requestingUserId can access a shared photo belonging to ownerUserId. + * The asset must be shared (shared=1) and the requesting user must be a member of + * the same trip that contains the photo. + */ +export function canAccessUserPhoto(requestingUserId: number, ownerUserId: number, tripId: string, assetId: string, provider: string): boolean { + if (requestingUserId === ownerUserId) { + return true; + } + const sharedAsset = db.prepare(` + SELECT 1 + FROM trip_photos + WHERE user_id = ? + AND asset_id = ? + AND provider = ? + AND trip_id = ? + AND shared = 1 + LIMIT 1 + `).get(ownerUserId, assetId, provider, tripId); + + if (!sharedAsset) { + return false; + } + return !!canAccessTrip(String(tripId), requestingUserId); +} + + function accessDeniedIfMissing(tripId: string, userId: number): ServiceError | null { if (!canAccessTrip(tripId, userId)) { return { error: 'Trip not found', status: 404 }; diff --git a/server/src/services/synologyService.ts b/server/src/services/synologyService.ts index 646c592..0cb3308 100644 --- a/server/src/services/synologyService.ts +++ b/server/src/services/synologyService.ts @@ -1,13 +1,9 @@ import { Readable } from 'node:stream'; import { pipeline } from 'node:stream/promises'; -import { NextFunction, Request, Response as ExpressResponse } from 'express'; -import { db, canAccessTrip } from '../db/database'; +import { Request, Response as ExpressResponse } from 'express'; +import { db } from '../db/database'; import { decrypt_api_key, maybe_encrypt_api_key } from './apiKeyCrypto'; -import { authenticate } from '../middleware/auth'; -import { AuthRequest } from '../types'; -import { consumeEphemeralToken } from './ephemeralTokens'; import { checkSsrf } from '../utils/ssrfGuard'; -import { no } from 'zod/locales'; const SYNOLOGY_API_TIMEOUT_MS = 30000; const SYNOLOGY_PROVIDER = 'synologyphotos'; @@ -270,11 +266,6 @@ function normalizeSynologyPhotoInfo(item: SynologyPhotoItem): SynologyPhotoInfo }; } -export function getSynologyTargetUserId(req: Request): number { - const { userId } = req.query; - return Number(userId); -} - export function handleSynologyError(res: ExpressResponse, err: unknown, fallbackMessage: string): ExpressResponse { if (err instanceof SynologyServiceError) { return res.status(err.status).json({ error: err.message }); @@ -295,23 +286,6 @@ function splitPackedSynologyId(rawId: string): { id: string; cacheKey: string; a return { id, cacheKey: rawId, assetId: rawId }; } -function canStreamSynologyAsset(requestingUserId: number, targetUserId: number, assetId: string): boolean { - if (requestingUserId === targetUserId) { - return true; - } - - const sharedAsset = db.prepare(` - SELECT 1 - FROM trip_photos - WHERE user_id = ? - AND asset_id = ? - AND provider = 'synologyphotos' - AND shared = 1 - LIMIT 1 - `).get(targetUserId, assetId); - - return !!sharedAsset; -} async function getSynologySession(userId: number): Promise { const cachedSid = readSynologyUser(userId, ['synology_sid'])?.synology_sid || null; @@ -514,9 +488,6 @@ export async function searchSynologyPhotos(userId: number, from?: string, to?: s } export async function getSynologyAssetInfo(userId: number, photoId: string, targetUserId?: number): Promise { - if (!canStreamSynologyAsset(userId, targetUserId ?? userId, photoId)) { - throw new SynologyServiceError(403, 'Youd don\'t have access to this photo'); - } const parsedId = splitPackedSynologyId(photoId); const result = await requestSynologyApi<{ list: SynologyPhotoItem[] }>(targetUserId ?? userId, { api: 'SYNO.Foto.Browse.Item', @@ -546,11 +517,7 @@ export async function streamSynologyAsset( photoId: string, kind: 'thumbnail' | 'original', size?: string, -): Promise { - if (!canStreamSynologyAsset(userId, targetUserId, photoId)) { - throw new SynologyServiceError(403, 'Youd don\'t have access to this photo'); - } - +): Promise { const parsedId = splitPackedSynologyId(photoId); const synology_url = getSynologyCredentials(targetUserId).synology_url; if (!synology_url) { From 1305a0750278fa6e1ab4070412c43b7d337fe833 Mon Sep 17 00:00:00 2001 From: Marek Maslowski Date: Sat, 4 Apr 2026 11:34:48 +0200 Subject: [PATCH 29/57] after changing routes i forgot to chang them in picker --- client/src/components/Memories/MemoriesPanel.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/client/src/components/Memories/MemoriesPanel.tsx b/client/src/components/Memories/MemoriesPanel.tsx index 963f547..d8d167d 100644 --- a/client/src/components/Memories/MemoriesPanel.tsx +++ b/client/src/components/Memories/MemoriesPanel.tsx @@ -598,7 +598,7 @@ export default function MemoriesPanel({ tripId, startDate, endDate }: MemoriesPa outline: isSelected ? '3px solid var(--text-primary)' : 'none', outlineOffset: -3, }}> - {isSelected && (
Date: Sat, 4 Apr 2026 12:22:22 +0200 Subject: [PATCH 30/57] adding helper functions for syncing albums --- server/src/routes/memories.ts | 10 +++-- server/src/services/immichService.ts | 25 +++++++------ server/src/services/memoriesService.ts | 52 +++++++++++++++++--------- server/src/services/synologyService.ts | 33 +++++++--------- 4 files changed, 68 insertions(+), 52 deletions(-) diff --git a/server/src/routes/memories.ts b/server/src/routes/memories.ts index 5a6f1cf..1b1fac3 100644 --- a/server/src/routes/memories.ts +++ b/server/src/routes/memories.ts @@ -11,6 +11,7 @@ import { removeTripPhoto, setTripPhotoSharing, notifySharedTripPhotos, + normalizeSelections, } from '../services/memoriesService'; const router = express.Router(); @@ -44,13 +45,14 @@ router.delete('/trips/:tripId/album-links/:linkId', authenticate, (req: Request, router.post('/trips/:tripId/photos', authenticate, (req: Request, res: Response) => { const authReq = req as AuthRequest; const { tripId } = req.params; + + const selections = normalizeSelections(req.body?.selections, req.body?.provider, req.body?.asset_ids); + const shared = req.body?.shared === undefined ? true : !!req.body?.shared; const result = addTripPhotos( tripId, authReq.user.id, - req.body?.shared, - req.body?.selections, - req.body?.provider, - req.body?.asset_ids, + shared, + selections, ); if ('error' in result) return res.status(result.status).json({ error: result.error }); diff --git a/server/src/services/immichService.ts b/server/src/services/immichService.ts index baef3bb..4550b2a 100644 --- a/server/src/services/immichService.ts +++ b/server/src/services/immichService.ts @@ -2,6 +2,7 @@ import { db } from '../db/database'; import { maybe_encrypt_api_key, decrypt_api_key } from './apiKeyCrypto'; import { checkSsrf } from '../utils/ssrfGuard'; import { writeAudit } from './auditLog'; +import { addTripPhotos, getAlbumIdFromLink, Selection, updateSyncTimeForAlbumLink } from './memoriesService'; // ── Credentials ──────────────────────────────────────────────────────────── @@ -313,15 +314,14 @@ export async function syncAlbumAssets( linkId: string, userId: number ): Promise<{ success?: boolean; added?: number; total?: number; error?: string; status?: number }> { - const link = db.prepare('SELECT * FROM trip_album_links WHERE id = ? AND trip_id = ? AND user_id = ? AND provider = ?') - .get(linkId, tripId, userId, 'immich') as any; - if (!link) return { error: 'Album link not found', status: 404 }; + const albumId = getAlbumIdFromLink(tripId, linkId, userId); + if (!albumId) return { error: 'Album link not found', status: 404 }; const creds = getImmichCredentials(userId); if (!creds) return { error: 'Immich not configured', status: 400 }; try { - const resp = await fetch(`${creds.immich_url}/api/albums/${link.album_id}`, { + const resp = await fetch(`${creds.immich_url}/api/albums/${albumId}`, { headers: { 'x-api-key': creds.immich_api_key, 'Accept': 'application/json' }, signal: AbortSignal.timeout(15000), }); @@ -329,16 +329,17 @@ export async function syncAlbumAssets( const albumData = await resp.json() as { assets?: any[] }; const assets = (albumData.assets || []).filter((a: any) => a.type === 'IMAGE'); - const insert = db.prepare("INSERT OR IGNORE INTO trip_photos (trip_id, user_id, asset_id, provider, shared) VALUES (?, ?, ?, 'immich', 1)"); - let added = 0; - for (const asset of assets) { - const r = insert.run(tripId, userId, asset.id); - if (r.changes > 0) added++; - } + const selection: Selection = { + provider: 'immich', + asset_ids: assets.map((a: any) => a.id), + }; - db.prepare('UPDATE trip_album_links SET last_synced_at = CURRENT_TIMESTAMP WHERE id = ?').run(linkId); + const addResult = addTripPhotos(tripId, userId, true, [selection]); + if ('error' in addResult) return { error: addResult.error, status: addResult.status }; - return { success: true, added, total: assets.length }; + updateSyncTimeForAlbumLink(linkId); + + return { success: true, added: addResult.added, total: assets.length }; } catch { return { error: 'Could not reach Immich', status: 502 }; } diff --git a/server/src/services/memoriesService.ts b/server/src/services/memoriesService.ts index 3886c8d..0470559 100644 --- a/server/src/services/memoriesService.ts +++ b/server/src/services/memoriesService.ts @@ -38,12 +38,14 @@ function accessDeniedIfMissing(tripId: string, userId: number): ServiceError | n return null; } -type Selection = { +export type Selection = { provider: string; - asset_ids: unknown[]; + asset_ids: string[]; }; -function normalizeSelections(selectionsRaw: unknown, providerRaw: unknown, assetIdsRaw: unknown): Selection[] { + +//fallback for old clients that don't send selections as an array of provider/asset_id groups +export function normalizeSelections(selectionsRaw: unknown, providerRaw: unknown, assetIdsRaw: unknown): Selection[] { const selectionsFromBody = Array.isArray(selectionsRaw) ? selectionsRaw : null; const provider = String(providerRaw || '').toLowerCase(); @@ -145,40 +147,54 @@ export function removeAlbumLink(tripId: string, linkId: string, userId: number): return { success: true }; } +function addTripPhoto(tripId: string, userId: number, provider: string, assetId: string, shared: boolean): boolean { + const result = db.prepare( + 'INSERT OR IGNORE INTO trip_photos (trip_id, user_id, asset_id, provider, shared) VALUES (?, ?, ?, ?, ?)' + ).run(tripId, userId, assetId, provider, shared ? 1 : 0); + return result.changes > 0; +} + export function addTripPhotos( tripId: string, userId: number, - sharedRaw: unknown, - selectionsRaw: unknown, - providerRaw: unknown, - assetIdsRaw: unknown, + shared: boolean, + selections: Selection[], ): { success: true; added: number; shared: boolean } | ServiceError { const denied = accessDeniedIfMissing(tripId, userId); if (denied) return denied; - const shared = sharedRaw === undefined ? true : !!sharedRaw; - const selections = normalizeSelections(selectionsRaw, providerRaw, assetIdsRaw); if (selections.length === 0) { - return { error: 'selections required', status: 400 }; + return { error: 'No photos selected', status: 400 }; } - const insert = db.prepare( - 'INSERT OR IGNORE INTO trip_photos (trip_id, user_id, asset_id, provider, shared) VALUES (?, ?, ?, ?, ?)' - ); - let added = 0; for (const selection of selections) { for (const raw of selection.asset_ids) { const assetId = String(raw || '').trim(); if (!assetId) continue; - const result = insert.run(tripId, userId, assetId, selection.provider, shared ? 1 : 0); - if (result.changes > 0) added++; + if (addTripPhoto(tripId, userId, selection.provider, assetId, shared)) { + added++; + } } } - return { success: true, added, shared }; } +export function getAlbumIdFromLink(tripId: string, linkId: string, userId: number): string { + const denied = accessDeniedIfMissing(tripId, userId); + if (denied) return null; + + const { album_id } = db.prepare('SELECT album_id FROM trip_album_links WHERE id = ? AND trip_id = ? AND user_id = ?').get(linkId, tripId, userId) as { album_id: string } | null; + + return album_id; +} + +export function updateSyncTimeForAlbumLink(linkId: string): void { + db.prepare('UPDATE trip_album_links SET last_synced_at = CURRENT_TIMESTAMP WHERE id = ?').run(linkId); +} + + + export function removeTripPhoto( tripId: string, userId: number, @@ -256,3 +272,5 @@ export async function notifySharedTripPhotos( count: String(added), }); } + + diff --git a/server/src/services/synologyService.ts b/server/src/services/synologyService.ts index 0cb3308..7a01c1c 100644 --- a/server/src/services/synologyService.ts +++ b/server/src/services/synologyService.ts @@ -1,9 +1,11 @@ import { Readable } from 'node:stream'; import { pipeline } from 'node:stream/promises'; -import { Request, Response as ExpressResponse } from 'express'; +import { Response as ExpressResponse } from 'express'; import { db } from '../db/database'; import { decrypt_api_key, maybe_encrypt_api_key } from './apiKeyCrypto'; import { checkSsrf } from '../utils/ssrfGuard'; +import { addTripPhotos, getAlbumIdFromLink, Selection, updateSyncTimeForAlbumLink } from './memoriesService'; +import { error } from 'node:console'; const SYNOLOGY_API_TIMEOUT_MS = 30000; const SYNOLOGY_PROVIDER = 'synologyphotos'; @@ -401,10 +403,8 @@ export async function listSynologyAlbums(userId: number): Promise<{ albums: Arra export async function syncSynologyAlbumLink(userId: number, tripId: string, linkId: string): Promise<{ added: number; total: number }> { - const link = db.prepare(`SELECT * FROM trip_album_links WHERE id = ? AND trip_id = ? AND user_id = ? AND provider = ?`) - .get(linkId, tripId, userId, SYNOLOGY_PROVIDER) as { album_id?: string | number } | undefined; - - if (!link) { + const albumId = getAlbumIdFromLink(tripId, linkId, userId); + if (!albumId) { throw new SynologyServiceError(404, 'Album link not found'); } @@ -417,7 +417,7 @@ export async function syncSynologyAlbumLink(userId: number, tripId: string, link api: 'SYNO.Foto.Browse.Item', method: 'list', version: 1, - album_id: Number(link.album_id), + album_id: Number(albumId), offset, limit: pageSize, additional: ['thumbnail'], @@ -433,22 +433,17 @@ export async function syncSynologyAlbumLink(userId: number, tripId: string, link offset += pageSize; } - const insert = db.prepare( - "INSERT OR IGNORE INTO trip_photos (trip_id, user_id, asset_id, provider, shared) VALUES (?, ?, ?, 'synologyphotos', 1)" - ); + const selection: Selection = { + provider: SYNOLOGY_PROVIDER, + asset_ids: allItems.map(item => String(item.additional?.thumbnail?.cache_key || '')).filter(id => id), + }; - let added = 0; - for (const item of allItems) { - const transformed = normalizeSynologyPhotoInfo(item); - const assetId = String(transformed?.id || '').trim(); - if (!assetId) continue; - const result = insert.run(tripId, userId, assetId); - if (result.changes > 0) added++; - } + const addResult = addTripPhotos(tripId, userId, true, [selection]); + if ('error' in addResult) throw new SynologyServiceError(addResult.status, addResult.error); - db.prepare('UPDATE trip_album_links SET last_synced_at = CURRENT_TIMESTAMP WHERE id = ?').run(linkId); + updateSyncTimeForAlbumLink(linkId); - return { added, total: allItems.length }; + return { added: addResult.added, total: allItems.length }; } export async function searchSynologyPhotos(userId: number, from?: string, to?: string, offset = 0, limit = 300): Promise<{ assets: SynologyPhotoInfo[]; total: number; hasMore: boolean }> { From 504713d920eb5f8b78fab3a4230409b23786e23f Mon Sep 17 00:00:00 2001 From: Marek Maslowski Date: Sat, 4 Apr 2026 13:36:12 +0200 Subject: [PATCH 31/57] change in hadnling return values from unified service --- server/src/routes/memories.ts | 96 +++--- server/src/services/immichService.ts | 13 +- server/src/services/memoriesService.ts | 450 +++++++++++++------------ server/src/services/synologyService.ts | 15 +- 4 files changed, 300 insertions(+), 274 deletions(-) diff --git a/server/src/routes/memories.ts b/server/src/routes/memories.ts index 1b1fac3..e60b9e1 100644 --- a/server/src/routes/memories.ts +++ b/server/src/routes/memories.ts @@ -1,6 +1,5 @@ import express, { Request, Response } from 'express'; import { authenticate } from '../middleware/auth'; -import { broadcast } from '../websocket'; import { AuthRequest } from '../types'; import { listTripPhotos, @@ -10,94 +9,85 @@ import { addTripPhotos, removeTripPhoto, setTripPhotoSharing, - notifySharedTripPhotos, - normalizeSelections, } from '../services/memoriesService'; const router = express.Router(); +//------------------------------------------------ +// routes for managing photos linked to trip router.get('/trips/:tripId/photos', authenticate, (req: Request, res: Response) => { const authReq = req as AuthRequest; const { tripId } = req.params; const result = listTripPhotos(tripId, authReq.user.id); - if ('error' in result) return res.status(result.status).json({ error: result.error }); - res.json({ photos: result.photos }); + if ('error' in result) return res.status(result.error.status).json({ error: result.error.message }); + res.json({ photos: result.data }); }); -router.get('/trips/:tripId/album-links', authenticate, (req: Request, res: Response) => { - const authReq = req as AuthRequest; - const { tripId } = req.params; - const result = listTripAlbumLinks(tripId, authReq.user.id); - if ('error' in result) return res.status(result.status).json({ error: result.error }); - res.json({ links: result.links }); -}); - -router.delete('/trips/:tripId/album-links/:linkId', authenticate, (req: Request, res: Response) => { - const authReq = req as AuthRequest; - const { tripId, linkId } = req.params; - const result = removeAlbumLink(tripId, linkId, authReq.user.id); - if ('error' in result) return res.status(result.status).json({ error: result.error }); - res.json({ success: true }); - broadcast(tripId, 'memories:updated', { userId: authReq.user.id }, req.headers['x-socket-id'] as string); -}); - -router.post('/trips/:tripId/photos', authenticate, (req: Request, res: Response) => { +router.post('/trips/:tripId/photos', authenticate, async (req: Request, res: Response) => { const authReq = req as AuthRequest; const { tripId } = req.params; + const sid = req.headers['x-socket-id'] as string; - const selections = normalizeSelections(req.body?.selections, req.body?.provider, req.body?.asset_ids); const shared = req.body?.shared === undefined ? true : !!req.body?.shared; - const result = addTripPhotos( + const result = await addTripPhotos( tripId, authReq.user.id, shared, - selections, + req.body?.selections || [], + sid, ); - if ('error' in result) return res.status(result.status).json({ error: result.error }); + if ('error' in result) return res.status(result.error.status).json({ error: result.error.message }); - res.json({ success: true, added: result.added }); - broadcast(tripId, 'memories:updated', { userId: authReq.user.id }, req.headers['x-socket-id'] as string); - - if (result.shared && result.added > 0) { - void notifySharedTripPhotos( - tripId, - authReq.user.id, - authReq.user.username || authReq.user.email, - result.added, - ).catch(() => {}); - } + res.json({ success: true, added: result.data.added }); }); -router.delete('/trips/:tripId/photos', authenticate, (req: Request, res: Response) => { +router.put('/trips/:tripId/photos/sharing', authenticate, async (req: Request, res: Response) => { const authReq = req as AuthRequest; const { tripId } = req.params; - const result = removeTripPhoto(tripId, authReq.user.id, req.body?.provider, req.body?.asset_id); - if ('error' in result) return res.status(result.status).json({ error: result.error }); - res.json({ success: true }); - broadcast(tripId, 'memories:updated', { userId: authReq.user.id }, req.headers['x-socket-id'] as string); -}); - -router.put('/trips/:tripId/photos/sharing', authenticate, (req: Request, res: Response) => { - const authReq = req as AuthRequest; - const { tripId } = req.params; - const result = setTripPhotoSharing( + const result = await setTripPhotoSharing( tripId, authReq.user.id, req.body?.provider, req.body?.asset_id, req.body?.shared, ); - if ('error' in result) return res.status(result.status).json({ error: result.error }); + if ('error' in result) return res.status(result.error.status).json({ error: result.error.message }); res.json({ success: true }); - broadcast(tripId, 'memories:updated', { userId: authReq.user.id }, req.headers['x-socket-id'] as string); }); -router.post('/trips/:tripId/album-links', authenticate, (req: Request, res: Response) => { +router.delete('/trips/:tripId/photos', authenticate, async (req: Request, res: Response) => { + const authReq = req as AuthRequest; + const { tripId } = req.params; + const result = await removeTripPhoto(tripId, authReq.user.id, req.body?.provider, req.body?.asset_id); + if ('error' in result) return res.status(result.error.status).json({ error: result.error.message }); + res.json({ success: true }); +}); + +//------------------------------ +// routes for managing album links + +router.get('/trips/:tripId/album-links', authenticate, (req: Request, res: Response) => { + const authReq = req as AuthRequest; + const { tripId } = req.params; + const result = listTripAlbumLinks(tripId, authReq.user.id); + if ('error' in result) return res.status(result.error.status).json({ error: result.error.message }); + res.json({ links: result.data }); +}); + +router.post('/trips/:tripId/album-links', authenticate, async (req: Request, res: Response) => { const authReq = req as AuthRequest; const { tripId } = req.params; const result = createTripAlbumLink(tripId, authReq.user.id, req.body?.provider, req.body?.album_id, req.body?.album_name); - if ('error' in result) return res.status(result.status).json({ error: result.error }); + if ('error' in result) return res.status(result.error.status).json({ error: result.error.message }); + res.json({ success: true }); +}); + +router.delete('/trips/:tripId/album-links/:linkId', authenticate, async (req: Request, res: Response) => { + const authReq = req as AuthRequest; + const { tripId, linkId } = req.params; + const result = removeAlbumLink(tripId, linkId, authReq.user.id); + if ('error' in result) return res.status(result.error.status).json({ error: result.error.message }); res.json({ success: true }); }); diff --git a/server/src/services/immichService.ts b/server/src/services/immichService.ts index 4550b2a..d4a353b 100644 --- a/server/src/services/immichService.ts +++ b/server/src/services/immichService.ts @@ -3,6 +3,7 @@ import { maybe_encrypt_api_key, decrypt_api_key } from './apiKeyCrypto'; import { checkSsrf } from '../utils/ssrfGuard'; import { writeAudit } from './auditLog'; import { addTripPhotos, getAlbumIdFromLink, Selection, updateSyncTimeForAlbumLink } from './memoriesService'; +import { error } from 'node:console'; // ── Credentials ──────────────────────────────────────────────────────────── @@ -314,14 +315,14 @@ export async function syncAlbumAssets( linkId: string, userId: number ): Promise<{ success?: boolean; added?: number; total?: number; error?: string; status?: number }> { - const albumId = getAlbumIdFromLink(tripId, linkId, userId); - if (!albumId) return { error: 'Album link not found', status: 404 }; + const response = getAlbumIdFromLink(tripId, linkId, userId); + if (!response.success) return { error: 'Album link not found', status: 404 }; const creds = getImmichCredentials(userId); if (!creds) return { error: 'Immich not configured', status: 400 }; try { - const resp = await fetch(`${creds.immich_url}/api/albums/${albumId}`, { + const resp = await fetch(`${creds.immich_url}/api/albums/${response.data}`, { headers: { 'x-api-key': creds.immich_api_key, 'Accept': 'application/json' }, signal: AbortSignal.timeout(15000), }); @@ -334,12 +335,12 @@ export async function syncAlbumAssets( asset_ids: assets.map((a: any) => a.id), }; - const addResult = addTripPhotos(tripId, userId, true, [selection]); - if ('error' in addResult) return { error: addResult.error, status: addResult.status }; + const result = await addTripPhotos(tripId, userId, true, [selection]); + if ('error' in result) return { error: result.error.message, status: result.error.status }; updateSyncTimeForAlbumLink(linkId); - return { success: true, added: addResult.added, total: assets.length }; + return { success: true, added: result.data.added, total: assets.length }; } catch { return { error: 'Could not reach Immich', status: 502 }; } diff --git a/server/src/services/memoriesService.ts b/server/src/services/memoriesService.ts index 0470559..03ac465 100644 --- a/server/src/services/memoriesService.ts +++ b/server/src/services/memoriesService.ts @@ -1,14 +1,29 @@ import { db, canAccessTrip } from '../db/database'; import { notifyTripMembers } from './notifications'; - -type ServiceError = { error: string; status: number }; +import { broadcast } from '../websocket'; -/** - * Verify that requestingUserId can access a shared photo belonging to ownerUserId. - * The asset must be shared (shared=1) and the requesting user must be a member of - * the same trip that contains the photo. - */ +type ServiceError = { success: false; error: { message: string; status: number } }; +type ServiceResult = { success: true; data: T } | ServiceError; + +function fail(error: string, status: number): ServiceError { + return { success: false, error: { message: error, status }}; +} + +function success(data: T): ServiceResult { + return { success: true, data: data }; +} + +function mapDbError(error: unknown, fallbackMessage: string): ServiceError { + if (error instanceof Error && /unique|constraint/i.test(error.message)) { + return fail('Resource already exists', 409); + } + return fail(fallbackMessage, 500); +} + +//----------------------------------------------- +//access check helper + export function canAccessUserPhoto(requestingUserId: number, ownerUserId: number, tripId: string, assetId: string, provider: string): boolean { if (requestingUserId === ownerUserId) { return true; @@ -27,15 +42,70 @@ export function canAccessUserPhoto(requestingUserId: number, ownerUserId: number if (!sharedAsset) { return false; } - return !!canAccessTrip(String(tripId), requestingUserId); + return !!canAccessTrip(tripId, requestingUserId); } - -function accessDeniedIfMissing(tripId: string, userId: number): ServiceError | null { - if (!canAccessTrip(tripId, userId)) { - return { error: 'Trip not found', status: 404 }; +export function listTripPhotos(tripId: string, userId: number): ServiceResult { + const access = canAccessTrip(tripId, userId); + if (!access) { + return fail('Trip not found or access denied', 404); } - return null; + + try { + const photos = db.prepare(` + SELECT tp.asset_id, tp.provider, tp.user_id, tp.shared, tp.added_at, + u.username, u.avatar + FROM trip_photos tp + JOIN users u ON tp.user_id = u.id + WHERE tp.trip_id = ? + AND (tp.user_id = ? OR tp.shared = 1) + ORDER BY tp.added_at ASC + `).all(tripId, userId) as any[]; + + return success(photos); + } catch (error) { + return mapDbError(error, 'Failed to list trip photos'); + } +} + +export function listTripAlbumLinks(tripId: string, userId: number): ServiceResult { + const access = canAccessTrip(tripId, userId); + if (!access) { + return fail('Trip not found or access denied', 404); + } + + try { + const links = db.prepare(` + SELECT tal.id, + tal.trip_id, + tal.user_id, + tal.provider, + tal.album_id, + tal.album_name, + tal.sync_enabled, + tal.last_synced_at, + tal.created_at, + u.username + FROM trip_album_links tal + JOIN users u ON tal.user_id = u.id + WHERE tal.trip_id = ? + ORDER BY tal.created_at ASC + `).all(tripId); + + return success(links); + } catch (error) { + return mapDbError(error, 'Failed to list trip album links'); + } +} + +//----------------------------------------------- +// managing photos in trip + +function _addTripPhoto(tripId: string, userId: number, provider: string, assetId: string, shared: boolean): boolean { + const result = db.prepare( + 'INSERT OR IGNORE INTO trip_photos (trip_id, user_id, asset_id, provider, shared) VALUES (?, ?, ?, ?, ?)' + ).run(tripId, userId, assetId, provider, shared ? 1 : 0); + return result.changes > 0; } export type Selection = { @@ -43,234 +113,198 @@ export type Selection = { asset_ids: string[]; }; - -//fallback for old clients that don't send selections as an array of provider/asset_id groups -export function normalizeSelections(selectionsRaw: unknown, providerRaw: unknown, assetIdsRaw: unknown): Selection[] { - const selectionsFromBody = Array.isArray(selectionsRaw) ? selectionsRaw : null; - const provider = String(providerRaw || '').toLowerCase(); - - if (selectionsFromBody && selectionsFromBody.length > 0) { - return selectionsFromBody - .map((selection: any) => ({ - provider: String(selection?.provider || '').toLowerCase(), - asset_ids: Array.isArray(selection?.asset_ids) ? selection.asset_ids : [], - })) - .filter((selection: Selection) => selection.provider && selection.asset_ids.length > 0); - } - - if (provider && Array.isArray(assetIdsRaw) && assetIdsRaw.length > 0) { - return [{ provider, asset_ids: assetIdsRaw }]; - } - - return []; -} - -export function listTripPhotos(tripId: string, userId: number): { photos: any[] } | ServiceError { - const denied = accessDeniedIfMissing(tripId, userId); - if (denied) return denied; - - const photos = db.prepare(` - SELECT tp.asset_id, tp.provider, tp.user_id, tp.shared, tp.added_at, - u.username, u.avatar - FROM trip_photos tp - JOIN users u ON tp.user_id = u.id - WHERE tp.trip_id = ? - AND (tp.user_id = ? OR tp.shared = 1) - ORDER BY tp.added_at ASC - `).all(tripId, userId) as any[]; - - return { photos }; -} - -export function listTripAlbumLinks(tripId: string, userId: number): { links: any[] } | ServiceError { - const denied = accessDeniedIfMissing(tripId, userId); - if (denied) return denied; - - const links = db.prepare(` - SELECT tal.id, - tal.trip_id, - tal.user_id, - tal.provider, - tal.album_id, - tal.album_name, - tal.sync_enabled, - tal.last_synced_at, - tal.created_at, - u.username - FROM trip_album_links tal - JOIN users u ON tal.user_id = u.id - WHERE tal.trip_id = ? - ORDER BY tal.created_at ASC - `).all(tripId); - - return { links }; -} - -export function createTripAlbumLink( +export async function addTripPhotos( tripId: string, userId: number, - providerRaw: unknown, - albumIdRaw: unknown, - albumNameRaw: unknown, -): { success: true } | ServiceError { - const denied = accessDeniedIfMissing(tripId, userId); - if (denied) return denied; + shared: boolean, + selections: Selection[], + sid?: string, +): Promise> { + const access = canAccessTrip(tripId, userId); + if (!access) { + return fail('Trip not found or access denied', 404); + } + + if (selections.length === 0) { + return fail('No photos selected', 400); + } + + try { + let added = 0; + for (const selection of selections) { + for (const raw of selection.asset_ids) { + const assetId = String(raw || '').trim(); + if (!assetId) continue; + if (_addTripPhoto(tripId, userId, selection.provider, assetId, shared)) { + added++; + } + } + } + + await _notifySharedTripPhotos(tripId, userId, added); + broadcast(tripId, 'memories:updated', { userId }, sid); + return success({ added, shared }); + } catch (error) { + return mapDbError(error, 'Failed to add trip photos'); + } +} + + +export async function setTripPhotoSharing( + tripId: string, + userId: number, + provider: string, + assetId: string, + shared: boolean, + sid?: string, +): Promise> { + const access = canAccessTrip(tripId, userId); + if (!access) { + return fail('Trip not found or access denied', 404); + } + + try { + db.prepare(` + UPDATE trip_photos + SET shared = ? + WHERE trip_id = ? + AND user_id = ? + AND asset_id = ? + AND provider = ? + `).run(shared ? 1 : 0, tripId, userId, assetId, provider); + + await _notifySharedTripPhotos(tripId, userId, 1); + broadcast(tripId, 'memories:updated', { userId }, sid); + return success(true); + } catch (error) { + return mapDbError(error, 'Failed to update photo sharing'); + } +} + + +export function removeTripPhoto( + tripId: string, + userId: number, + provider: string, + assetId: string, + sid?: string, +): ServiceResult { + const access = canAccessTrip(tripId, userId); + if (!access) { + return fail('Trip not found or access denied', 404); + } + + try { + db.prepare(` + DELETE FROM trip_photos + WHERE trip_id = ? + AND user_id = ? + AND asset_id = ? + AND provider = ? + `).run(tripId, userId, assetId, provider); + + broadcast(tripId, 'memories:updated', { userId }, sid); + + return success(true); + } catch (error) { + return mapDbError(error, 'Failed to remove trip photo'); + } +} + +// ---------------------------------------------- +// managing album links in trip + +export function createTripAlbumLink(tripId: string, userId: number, providerRaw: unknown, albumIdRaw: unknown, albumNameRaw: unknown): ServiceResult { + const access = canAccessTrip(tripId, userId); + if (!access) { + return fail('Trip not found or access denied', 404); + } const provider = String(providerRaw || '').toLowerCase(); const albumId = String(albumIdRaw || '').trim(); const albumName = String(albumNameRaw || '').trim(); if (!provider) { - return { error: 'provider is required', status: 400 }; + return fail('provider is required', 400); } if (!albumId) { - return { error: 'album_id required', status: 400 }; + return fail('album_id required', 400); } try { - db.prepare( + const result = db.prepare( 'INSERT OR IGNORE INTO trip_album_links (trip_id, user_id, provider, album_id, album_name) VALUES (?, ?, ?, ?, ?)' ).run(tripId, userId, provider, albumId, albumName); - return { success: true }; - } catch { - return { error: 'Album already linked', status: 400 }; - } -} -export function removeAlbumLink(tripId: string, linkId: string, userId: number): { success: true } | ServiceError { - const denied = accessDeniedIfMissing(tripId, userId); - if (denied) return denied; - - db.prepare('DELETE FROM trip_album_links WHERE id = ? AND trip_id = ? AND user_id = ?') - .run(linkId, tripId, userId); - - return { success: true }; -} - -function addTripPhoto(tripId: string, userId: number, provider: string, assetId: string, shared: boolean): boolean { - const result = db.prepare( - 'INSERT OR IGNORE INTO trip_photos (trip_id, user_id, asset_id, provider, shared) VALUES (?, ?, ?, ?, ?)' - ).run(tripId, userId, assetId, provider, shared ? 1 : 0); - return result.changes > 0; -} - -export function addTripPhotos( - tripId: string, - userId: number, - shared: boolean, - selections: Selection[], -): { success: true; added: number; shared: boolean } | ServiceError { - const denied = accessDeniedIfMissing(tripId, userId); - if (denied) return denied; - - if (selections.length === 0) { - return { error: 'No photos selected', status: 400 }; - } - - let added = 0; - for (const selection of selections) { - for (const raw of selection.asset_ids) { - const assetId = String(raw || '').trim(); - if (!assetId) continue; - if (addTripPhoto(tripId, userId, selection.provider, assetId, shared)) { - added++; - } + if (result.changes === 0) { + return fail('Album already linked', 409); } + + return success(true); + } catch (error) { + return mapDbError(error, 'Failed to link album'); } - return { success: true, added, shared }; } -export function getAlbumIdFromLink(tripId: string, linkId: string, userId: number): string { - const denied = accessDeniedIfMissing(tripId, userId); - if (denied) return null; +export function removeAlbumLink(tripId: string, linkId: string, userId: number): ServiceResult { + const access = canAccessTrip(tripId, userId); + if (!access) { + return fail('Trip not found or access denied', 404); + } - const { album_id } = db.prepare('SELECT album_id FROM trip_album_links WHERE id = ? AND trip_id = ? AND user_id = ?').get(linkId, tripId, userId) as { album_id: string } | null; + try { + db.prepare('DELETE FROM trip_album_links WHERE id = ? AND trip_id = ? AND user_id = ?') + .run(linkId, tripId, userId); - return album_id; + return success(true); + } catch (error) { + return mapDbError(error, 'Failed to remove album link'); + } +} + +//helpers for album link syncing + +export function getAlbumIdFromLink(tripId: string, linkId: string, userId: number): ServiceResult { + const access = canAccessTrip(tripId, userId); + if (!access) return fail('Trip not found or access denied', 404); + + try { + const row = db.prepare('SELECT album_id FROM trip_album_links WHERE id = ? AND trip_id = ? AND user_id = ?') + .get(linkId, tripId, userId) as { album_id: string } | null; + + return row ? success(row.album_id) : fail('Album link not found', 404); + } catch { + return fail('Failed to retrieve album link', 500); + } } export function updateSyncTimeForAlbumLink(linkId: string): void { db.prepare('UPDATE trip_album_links SET last_synced_at = CURRENT_TIMESTAMP WHERE id = ?').run(linkId); } +//----------------------------------------------- +// notifications helper - -export function removeTripPhoto( - tripId: string, - userId: number, - providerRaw: unknown, - assetIdRaw: unknown, -): { success: true } | ServiceError { - const assetId = String(assetIdRaw || ''); - const provider = String(providerRaw || '').toLowerCase(); - - if (!assetId) { - return { error: 'asset_id is required', status: 400 }; - } - if (!provider) { - return { error: 'provider is required', status: 400 }; - } - - const denied = accessDeniedIfMissing(tripId, userId); - if (denied) return denied; - - db.prepare(` - DELETE FROM trip_photos - WHERE trip_id = ? - AND user_id = ? - AND asset_id = ? - AND provider = ? - `).run(tripId, userId, assetId, provider); - - return { success: true }; -} - -export function setTripPhotoSharing( - tripId: string, - userId: number, - providerRaw: unknown, - assetIdRaw: unknown, - sharedRaw: unknown, -): { success: true } | ServiceError { - const assetId = String(assetIdRaw || ''); - const provider = String(providerRaw || '').toLowerCase(); - - if (!assetId) { - return { error: 'asset_id is required', status: 400 }; - } - if (!provider) { - return { error: 'provider is required', status: 400 }; - } - - const denied = accessDeniedIfMissing(tripId, userId); - if (denied) return denied; - - db.prepare(` - UPDATE trip_photos - SET shared = ? - WHERE trip_id = ? - AND user_id = ? - AND asset_id = ? - AND provider = ? - `).run(sharedRaw ? 1 : 0, tripId, userId, assetId, provider); - - return { success: true }; -} - -export async function notifySharedTripPhotos( +async function _notifySharedTripPhotos( tripId: string, actorUserId: number, - actorName: string, added: number, -): Promise { - if (added <= 0) return; +): Promise> { + if (added <= 0) return fail('No photos shared, skipping notifications', 200); - const tripInfo = db.prepare('SELECT title FROM trips WHERE id = ?').get(tripId) as { title: string } | undefined; - await notifyTripMembers(Number(tripId), actorUserId, 'photos_shared', { - trip: tripInfo?.title || 'Untitled', - actor: actorName, - count: String(added), - }); + try { + const actorRow = db.prepare('SELECT username FROM users WHERE id = ?').get(actorUserId) as { username: string | null }; + + const tripInfo = db.prepare('SELECT title FROM trips WHERE id = ?').get(tripId) as { title: string } | undefined; + await notifyTripMembers(Number(tripId), actorUserId, 'photos_shared', { + trip: tripInfo?.title || 'Untitled', + actor: actorRow?.username || 'Unknown', + count: String(added), + }); + return success(undefined); + } catch { + return fail('Failed to send notifications', 500); + } } diff --git a/server/src/services/synologyService.ts b/server/src/services/synologyService.ts index 7a01c1c..4f66fbd 100644 --- a/server/src/services/synologyService.ts +++ b/server/src/services/synologyService.ts @@ -6,6 +6,7 @@ import { decrypt_api_key, maybe_encrypt_api_key } from './apiKeyCrypto'; import { checkSsrf } from '../utils/ssrfGuard'; import { addTripPhotos, getAlbumIdFromLink, Selection, updateSyncTimeForAlbumLink } from './memoriesService'; import { error } from 'node:console'; +import { th } from 'zod/locales'; const SYNOLOGY_API_TIMEOUT_MS = 30000; const SYNOLOGY_PROVIDER = 'synologyphotos'; @@ -403,8 +404,8 @@ export async function listSynologyAlbums(userId: number): Promise<{ albums: Arra export async function syncSynologyAlbumLink(userId: number, tripId: string, linkId: string): Promise<{ added: number; total: number }> { - const albumId = getAlbumIdFromLink(tripId, linkId, userId); - if (!albumId) { + const response = getAlbumIdFromLink(tripId, linkId, userId); + if (!response.success) { throw new SynologyServiceError(404, 'Album link not found'); } @@ -417,7 +418,7 @@ export async function syncSynologyAlbumLink(userId: number, tripId: string, link api: 'SYNO.Foto.Browse.Item', method: 'list', version: 1, - album_id: Number(albumId), + album_id: Number(response.data), offset, limit: pageSize, additional: ['thumbnail'], @@ -438,12 +439,12 @@ export async function syncSynologyAlbumLink(userId: number, tripId: string, link asset_ids: allItems.map(item => String(item.additional?.thumbnail?.cache_key || '')).filter(id => id), }; - const addResult = addTripPhotos(tripId, userId, true, [selection]); - if ('error' in addResult) throw new SynologyServiceError(addResult.status, addResult.error); - updateSyncTimeForAlbumLink(linkId); - return { added: addResult.added, total: allItems.length }; + const result = await addTripPhotos(tripId, userId, true, [selection]); + if ('error' in result) throw new SynologyServiceError(result.error.status, result.error.message); + + return { added: result.data.added, total: allItems.length }; } export async function searchSynologyPhotos(userId: number, from?: string, to?: string, offset = 0, limit = 300): Promise<{ assets: SynologyPhotoInfo[]; total: number; hasMore: boolean }> { From bca82b3f8c3de1b00b7789adbe11d7f7c6764820 Mon Sep 17 00:00:00 2001 From: Marek Maslowski Date: Sat, 4 Apr 2026 14:01:51 +0200 Subject: [PATCH 32/57] changing routes and hierarchy of files for memories --- .../src/components/Memories/MemoriesPanel.tsx | 30 +++---- server/src/app.ts | 6 +- server/src/routes/{ => memories}/immich.ts | 17 ++-- server/src/routes/{ => memories}/synology.ts | 10 +-- .../{memories.ts => memories/unified.ts} | 28 ++++--- .../src/services/memories/helpersService.ts | 79 ++++++++++++++++++ .../services/{ => memories}/immichService.ts | 12 +-- .../{ => memories}/synologyService.ts | 11 ++- .../unifiedService.ts} | 81 +++---------------- 9 files changed, 147 insertions(+), 127 deletions(-) rename server/src/routes/{ => memories}/immich.ts (93%) rename server/src/routes/{ => memories}/synology.ts (96%) rename server/src/routes/{memories.ts => memories/unified.ts} (74%) create mode 100644 server/src/services/memories/helpersService.ts rename server/src/services/{ => memories}/immichService.ts (97%) rename server/src/services/{ => memories}/synologyService.ts (98%) rename server/src/services/{memoriesService.ts => memories/unifiedService.ts} (75%) diff --git a/client/src/components/Memories/MemoriesPanel.tsx b/client/src/components/Memories/MemoriesPanel.tsx index d8d167d..da9873e 100644 --- a/client/src/components/Memories/MemoriesPanel.tsx +++ b/client/src/components/Memories/MemoriesPanel.tsx @@ -89,7 +89,7 @@ export default function MemoriesPanel({ tripId, startDate, endDate }: MemoriesPa const loadAlbumLinks = async () => { try { - const res = await apiClient.get(`/integrations/memories/trips/${tripId}/album-links`) + const res = await apiClient.get(`/integrations/memories/unified/trips/${tripId}/album-links`) setAlbumLinks(res.data.links || []) } catch { setAlbumLinks([]) } } @@ -98,7 +98,7 @@ export default function MemoriesPanel({ tripId, startDate, endDate }: MemoriesPa if (!provider) return setAlbumsLoading(true) try { - const res = await apiClient.get(`/integrations/${provider}/albums`) + const res = await apiClient.get(`/integrations/memories/${provider}/albums`) setAlbums(res.data.albums || []) } catch { setAlbums([]) @@ -120,7 +120,7 @@ export default function MemoriesPanel({ tripId, startDate, endDate }: MemoriesPa } try { - await apiClient.post(`/integrations/memories/trips/${tripId}/album-links`, { + await apiClient.post(`/integrations/memories/unified/trips/${tripId}/album-links`, { album_id: albumId, album_name: albumName, provider: selectedProvider, @@ -128,7 +128,7 @@ export default function MemoriesPanel({ tripId, startDate, endDate }: MemoriesPa setShowAlbumPicker(false) await loadAlbumLinks() // Auto-sync after linking - const linksRes = await apiClient.get(`/integrations/memories/trips/${tripId}/album-links`) + const linksRes = await apiClient.get(`/integrations/memories/unified/trips/${tripId}/album-links`) const newLink = (linksRes.data.links || []).find((l: any) => l.album_id === albumId && l.provider === selectedProvider) if (newLink) await syncAlbum(newLink.id) } catch { toast.error(t('memories.error.linkAlbum')) } @@ -136,7 +136,7 @@ export default function MemoriesPanel({ tripId, startDate, endDate }: MemoriesPa const unlinkAlbum = async (linkId: number) => { try { - await apiClient.delete(`/integrations/memories/trips/${tripId}/album-links/${linkId}`) + await apiClient.delete(`/integrations/memories/unified/trips/${tripId}/album-links/${linkId}`) loadAlbumLinks() } catch { toast.error(t('memories.error.unlinkAlbum')) } } @@ -146,7 +146,7 @@ export default function MemoriesPanel({ tripId, startDate, endDate }: MemoriesPa if (!targetProvider) return setSyncing(linkId) try { - await apiClient.post(`/integrations/${targetProvider}/trips/${tripId}/album-links/${linkId}/sync`) + await apiClient.post(`/integrations/memories/${targetProvider}/trips/${tripId}/album-links/${linkId}/sync`) await loadAlbumLinks() await loadPhotos() } catch { toast.error(t('memories.error.syncAlbum')) } @@ -175,7 +175,7 @@ export default function MemoriesPanel({ tripId, startDate, endDate }: MemoriesPa const loadPhotos = async () => { try { - const photosRes = await apiClient.get(`/integrations/memories/trips/${tripId}/photos`) + const photosRes = await apiClient.get(`/integrations/memories/unified/trips/${tripId}/photos`) setTripPhotos(photosRes.data.photos || []) } catch { setTripPhotos([]) @@ -257,7 +257,7 @@ export default function MemoriesPanel({ tripId, startDate, endDate }: MemoriesPa setPickerPhotos([]) return } - const res = await apiClient.post(`/integrations/${provider.id}/search`, { + const res = await apiClient.post(`/integrations/memories/${provider.id}/search`, { from: useDate && startDate ? startDate : undefined, to: useDate && endDate ? endDate : undefined, }) @@ -296,7 +296,7 @@ export default function MemoriesPanel({ tripId, startDate, endDate }: MemoriesPa groupedByProvider.set(provider, list) } - await apiClient.post(`/integrations/memories/trips/${tripId}/photos`, { + await apiClient.post(`/integrations/memories/unified/trips/${tripId}/photos`, { selections: [...groupedByProvider.entries()].map(([provider, asset_ids]) => ({ provider, asset_ids })), shared: true, }) @@ -310,7 +310,7 @@ export default function MemoriesPanel({ tripId, startDate, endDate }: MemoriesPa const removePhoto = async (photo: TripPhoto) => { try { - await apiClient.delete(`/integrations/memories/trips/${tripId}/photos`, { + await apiClient.delete(`/integrations/memories/unified/trips/${tripId}/photos`, { data: { asset_id: photo.asset_id, provider: photo.provider, @@ -324,7 +324,7 @@ export default function MemoriesPanel({ tripId, startDate, endDate }: MemoriesPa const toggleSharing = async (photo: TripPhoto, shared: boolean) => { try { - await apiClient.put(`/integrations/memories/trips/${tripId}/photos/sharing`, { + await apiClient.put(`/integrations/memories/unified/trips/${tripId}/photos/sharing`, { shared, asset_id: photo.asset_id, provider: photo.provider, @@ -338,7 +338,7 @@ export default function MemoriesPanel({ tripId, startDate, endDate }: MemoriesPa // ── Helpers ─────────────────────────────────────────────────────────────── const thumbnailBaseUrl = (photo: TripPhoto) => - `/api/integrations/${photo.provider}/assets/${tripId}/${photo.asset_id}/${photo.user_id}/thumbnail` + `/api/integrations/memories/${photo.provider}/assets/${tripId}/${photo.asset_id}/${photo.user_id}/thumbnail` const makePickerKey = (provider: string, assetId: string): string => `${provider}::${assetId}` @@ -598,7 +598,7 @@ export default function MemoriesPanel({ tripId, startDate, endDate }: MemoriesPa outline: isSelected ? '3px solid var(--text-primary)' : 'none', outlineOffset: -3, }}> - {isSelected && (
setLightboxInfo(r.data)).catch(() => {}).finally(() => setLightboxInfoLoading(false)) }}> diff --git a/server/src/app.ts b/server/src/app.ts index 13b7668..60ddcb8 100644 --- a/server/src/app.ts +++ b/server/src/app.ts @@ -33,9 +33,7 @@ import backupRoutes from './routes/backup'; import oidcRoutes from './routes/oidc'; import vacayRoutes from './routes/vacay'; import atlasRoutes from './routes/atlas'; -import immichRoutes from './routes/immich'; -import synologyRoutes from './routes/synology'; -import memoriesRoutes from './routes/memories'; +import memoriesRoutes from './routes/memories/unified'; import notificationRoutes from './routes/notifications'; import shareRoutes from './routes/share'; import { mcpHandler } from './mcp'; @@ -255,8 +253,6 @@ export function createApp(): express.Application { app.use('/api/addons/vacay', vacayRoutes); app.use('/api/addons/atlas', atlasRoutes); app.use('/api/integrations/memories', memoriesRoutes); - app.use('/api/integrations/immich', immichRoutes); - app.use('/api/integrations/synologyphotos', synologyRoutes); app.use('/api/maps', mapsRoutes); app.use('/api/weather', weatherRoutes); app.use('/api/settings', settingsRoutes); diff --git a/server/src/routes/immich.ts b/server/src/routes/memories/immich.ts similarity index 93% rename from server/src/routes/immich.ts rename to server/src/routes/memories/immich.ts index 012d7ef..af9b4d0 100644 --- a/server/src/routes/immich.ts +++ b/server/src/routes/memories/immich.ts @@ -1,10 +1,10 @@ import express, { Request, Response, NextFunction } from 'express'; -import { db, canAccessTrip } from '../db/database'; -import { authenticate } from '../middleware/auth'; -import { broadcast } from '../websocket'; -import { AuthRequest } from '../types'; -import { consumeEphemeralToken } from '../services/ephemeralTokens'; -import { getClientIp } from '../services/auditLog'; +import { db, canAccessTrip } from '../../db/database'; +import { authenticate } from '../../middleware/auth'; +import { broadcast } from '../../websocket'; +import { AuthRequest } from '../../types'; +import { consumeEphemeralToken } from '../../services/ephemeralTokens'; +import { getClientIp } from '../../services/auditLog'; import { getConnectionSettings, saveImmichSettings, @@ -14,12 +14,11 @@ import { searchPhotos, proxyThumbnail, proxyOriginal, - isValidAssetId, listAlbums, syncAlbumAssets, getAssetInfo, -} from '../services/immichService'; -import { canAccessUserPhoto } from '../services/memoriesService'; +} from '../../services/memories/immichService'; +import { canAccessUserPhoto } from '../../services/memories/helpersService'; const router = express.Router(); diff --git a/server/src/routes/synology.ts b/server/src/routes/memories/synology.ts similarity index 96% rename from server/src/routes/synology.ts rename to server/src/routes/memories/synology.ts index 838a3de..cd3512d 100644 --- a/server/src/routes/synology.ts +++ b/server/src/routes/memories/synology.ts @@ -1,7 +1,7 @@ import express, { Request, Response } from 'express'; -import { authenticate } from '../middleware/auth'; -import { broadcast } from '../websocket'; -import { AuthRequest } from '../types'; +import { authenticate } from '../../middleware/auth'; +import { broadcast } from '../../websocket'; +import { AuthRequest } from '../../types'; import { getSynologySettings, updateSynologySettings, @@ -15,8 +15,8 @@ import { streamSynologyAsset, handleSynologyError, SynologyServiceError, -} from '../services/synologyService'; -import { canAccessUserPhoto } from '../services/memoriesService'; +} from '../../services/memories/synologyService'; +import { canAccessUserPhoto } from '../../services/memories/helpersService'; const router = express.Router(); diff --git a/server/src/routes/memories.ts b/server/src/routes/memories/unified.ts similarity index 74% rename from server/src/routes/memories.ts rename to server/src/routes/memories/unified.ts index e60b9e1..3e4561d 100644 --- a/server/src/routes/memories.ts +++ b/server/src/routes/memories/unified.ts @@ -1,6 +1,6 @@ import express, { Request, Response } from 'express'; -import { authenticate } from '../middleware/auth'; -import { AuthRequest } from '../types'; +import { authenticate } from '../../middleware/auth'; +import { AuthRequest } from '../../types'; import { listTripPhotos, listTripAlbumLinks, @@ -9,14 +9,19 @@ import { addTripPhotos, removeTripPhoto, setTripPhotoSharing, -} from '../services/memoriesService'; +} from '../../services/memories/unifiedService'; +import immichRouter from './immich'; +import synologyRouter from './synology'; const router = express.Router(); +router.use('/immich', immichRouter); +router.use('/synologyphotos', synologyRouter); + //------------------------------------------------ // routes for managing photos linked to trip -router.get('/trips/:tripId/photos', authenticate, (req: Request, res: Response) => { +router.get('/unified/trips/:tripId/photos', authenticate, (req: Request, res: Response) => { const authReq = req as AuthRequest; const { tripId } = req.params; const result = listTripPhotos(tripId, authReq.user.id); @@ -24,7 +29,7 @@ router.get('/trips/:tripId/photos', authenticate, (req: Request, res: Response) res.json({ photos: result.data }); }); -router.post('/trips/:tripId/photos', authenticate, async (req: Request, res: Response) => { +router.post('/unified/trips/:tripId/photos', authenticate, async (req: Request, res: Response) => { const authReq = req as AuthRequest; const { tripId } = req.params; const sid = req.headers['x-socket-id'] as string; @@ -42,7 +47,7 @@ router.post('/trips/:tripId/photos', authenticate, async (req: Request, res: Res res.json({ success: true, added: result.data.added }); }); -router.put('/trips/:tripId/photos/sharing', authenticate, async (req: Request, res: Response) => { +router.put('/unified/trips/:tripId/photos/sharing', authenticate, async (req: Request, res: Response) => { const authReq = req as AuthRequest; const { tripId } = req.params; const result = await setTripPhotoSharing( @@ -56,7 +61,7 @@ router.put('/trips/:tripId/photos/sharing', authenticate, async (req: Request, r res.json({ success: true }); }); -router.delete('/trips/:tripId/photos', authenticate, async (req: Request, res: Response) => { +router.delete('/unified/trips/:tripId/photos', authenticate, async (req: Request, res: Response) => { const authReq = req as AuthRequest; const { tripId } = req.params; const result = await removeTripPhoto(tripId, authReq.user.id, req.body?.provider, req.body?.asset_id); @@ -67,7 +72,7 @@ router.delete('/trips/:tripId/photos', authenticate, async (req: Request, res: R //------------------------------ // routes for managing album links -router.get('/trips/:tripId/album-links', authenticate, (req: Request, res: Response) => { +router.get('/unified/trips/:tripId/album-links', authenticate, (req: Request, res: Response) => { const authReq = req as AuthRequest; const { tripId } = req.params; const result = listTripAlbumLinks(tripId, authReq.user.id); @@ -75,7 +80,7 @@ router.get('/trips/:tripId/album-links', authenticate, (req: Request, res: Respo res.json({ links: result.data }); }); -router.post('/trips/:tripId/album-links', authenticate, async (req: Request, res: Response) => { +router.post('/unified/trips/:tripId/album-links', authenticate, async (req: Request, res: Response) => { const authReq = req as AuthRequest; const { tripId } = req.params; const result = createTripAlbumLink(tripId, authReq.user.id, req.body?.provider, req.body?.album_id, req.body?.album_name); @@ -83,7 +88,7 @@ router.post('/trips/:tripId/album-links', authenticate, async (req: Request, res res.json({ success: true }); }); -router.delete('/trips/:tripId/album-links/:linkId', authenticate, async (req: Request, res: Response) => { +router.delete('/unified/trips/:tripId/album-links/:linkId', authenticate, async (req: Request, res: Response) => { const authReq = req as AuthRequest; const { tripId, linkId } = req.params; const result = removeAlbumLink(tripId, linkId, authReq.user.id); @@ -91,4 +96,7 @@ router.delete('/trips/:tripId/album-links/:linkId', authenticate, async (req: Re res.json({ success: true }); }); + + + export default router; diff --git a/server/src/services/memories/helpersService.ts b/server/src/services/memories/helpersService.ts new file mode 100644 index 0000000..51c899a --- /dev/null +++ b/server/src/services/memories/helpersService.ts @@ -0,0 +1,79 @@ +import { canAccessTrip, db } from "../../db/database"; + +// helpers for handling return types + +type ServiceError = { success: false; error: { message: string; status: number } }; +export type ServiceResult = { success: true; data: T } | ServiceError; + + +export function fail(error: string, status: number): ServiceError { + return { success: false, error: { message: error, status } }; +} + + +export function success(data: T): ServiceResult { + return { success: true, data: data }; +} + + +export function mapDbError(error: unknown, fallbackMessage: string): ServiceError { + if (error instanceof Error && /unique|constraint/i.test(error.message)) { + return fail('Resource already exists', 409); + } + return fail(fallbackMessage, 500); +} + + +// ---------------------------------------------- +// types used across memories services +export type Selection = { + provider: string; + asset_ids: string[]; +}; + + +//----------------------------------------------- +//access check helper + +export function canAccessUserPhoto(requestingUserId: number, ownerUserId: number, tripId: string, assetId: string, provider: string): boolean { + if (requestingUserId === ownerUserId) { + return true; + } + const sharedAsset = db.prepare(` + SELECT 1 + FROM trip_photos + WHERE user_id = ? + AND asset_id = ? + AND provider = ? + AND trip_id = ? + AND shared = 1 + LIMIT 1 + `).get(ownerUserId, assetId, provider, tripId); + + if (!sharedAsset) { + return false; + } + return !!canAccessTrip(tripId, requestingUserId); +} + + +// ---------------------------------------------- +//helpers for album link syncing + +export function getAlbumIdFromLink(tripId: string, linkId: string, userId: number): ServiceResult { + const access = canAccessTrip(tripId, userId); + if (!access) return fail('Trip not found or access denied', 404); + + try { + const row = db.prepare('SELECT album_id FROM trip_album_links WHERE id = ? AND trip_id = ? AND user_id = ?') + .get(linkId, tripId, userId) as { album_id: string } | null; + + return row ? success(row.album_id) : fail('Album link not found', 404); + } catch { + return fail('Failed to retrieve album link', 500); + } +} + +export function updateSyncTimeForAlbumLink(linkId: string): void { + db.prepare('UPDATE trip_album_links SET last_synced_at = CURRENT_TIMESTAMP WHERE id = ?').run(linkId); +} diff --git a/server/src/services/immichService.ts b/server/src/services/memories/immichService.ts similarity index 97% rename from server/src/services/immichService.ts rename to server/src/services/memories/immichService.ts index d4a353b..47a9895 100644 --- a/server/src/services/immichService.ts +++ b/server/src/services/memories/immichService.ts @@ -1,9 +1,9 @@ -import { db } from '../db/database'; -import { maybe_encrypt_api_key, decrypt_api_key } from './apiKeyCrypto'; -import { checkSsrf } from '../utils/ssrfGuard'; -import { writeAudit } from './auditLog'; -import { addTripPhotos, getAlbumIdFromLink, Selection, updateSyncTimeForAlbumLink } from './memoriesService'; -import { error } from 'node:console'; +import { db } from '../../db/database'; +import { maybe_encrypt_api_key, decrypt_api_key } from '../apiKeyCrypto'; +import { checkSsrf } from '../../utils/ssrfGuard'; +import { writeAudit } from '../auditLog'; +import { addTripPhotos} from './unifiedService'; +import { getAlbumIdFromLink, updateSyncTimeForAlbumLink, Selection } from './helpersService'; // ── Credentials ──────────────────────────────────────────────────────────── diff --git a/server/src/services/synologyService.ts b/server/src/services/memories/synologyService.ts similarity index 98% rename from server/src/services/synologyService.ts rename to server/src/services/memories/synologyService.ts index 4f66fbd..c9c8b57 100644 --- a/server/src/services/synologyService.ts +++ b/server/src/services/memories/synologyService.ts @@ -1,12 +1,11 @@ import { Readable } from 'node:stream'; import { pipeline } from 'node:stream/promises'; import { Response as ExpressResponse } from 'express'; -import { db } from '../db/database'; -import { decrypt_api_key, maybe_encrypt_api_key } from './apiKeyCrypto'; -import { checkSsrf } from '../utils/ssrfGuard'; -import { addTripPhotos, getAlbumIdFromLink, Selection, updateSyncTimeForAlbumLink } from './memoriesService'; -import { error } from 'node:console'; -import { th } from 'zod/locales'; +import { db } from '../../db/database'; +import { decrypt_api_key, maybe_encrypt_api_key } from '../apiKeyCrypto'; +import { checkSsrf } from '../../utils/ssrfGuard'; +import { addTripPhotos} from './unifiedService'; +import { getAlbumIdFromLink, updateSyncTimeForAlbumLink, Selection } from './helpersService'; const SYNOLOGY_API_TIMEOUT_MS = 30000; const SYNOLOGY_PROVIDER = 'synologyphotos'; diff --git a/server/src/services/memoriesService.ts b/server/src/services/memories/unifiedService.ts similarity index 75% rename from server/src/services/memoriesService.ts rename to server/src/services/memories/unifiedService.ts index 03ac465..35d47eb 100644 --- a/server/src/services/memoriesService.ts +++ b/server/src/services/memories/unifiedService.ts @@ -1,49 +1,15 @@ -import { db, canAccessTrip } from '../db/database'; -import { notifyTripMembers } from './notifications'; -import { broadcast } from '../websocket'; +import { db, canAccessTrip } from '../../db/database'; +import { notifyTripMembers } from '../notifications'; +import { broadcast } from '../../websocket'; +import { + ServiceResult, + fail, + success, + mapDbError, + Selection, +} from './helpersService'; -type ServiceError = { success: false; error: { message: string; status: number } }; -type ServiceResult = { success: true; data: T } | ServiceError; - -function fail(error: string, status: number): ServiceError { - return { success: false, error: { message: error, status }}; -} - -function success(data: T): ServiceResult { - return { success: true, data: data }; -} - -function mapDbError(error: unknown, fallbackMessage: string): ServiceError { - if (error instanceof Error && /unique|constraint/i.test(error.message)) { - return fail('Resource already exists', 409); - } - return fail(fallbackMessage, 500); -} - -//----------------------------------------------- -//access check helper - -export function canAccessUserPhoto(requestingUserId: number, ownerUserId: number, tripId: string, assetId: string, provider: string): boolean { - if (requestingUserId === ownerUserId) { - return true; - } - const sharedAsset = db.prepare(` - SELECT 1 - FROM trip_photos - WHERE user_id = ? - AND asset_id = ? - AND provider = ? - AND trip_id = ? - AND shared = 1 - LIMIT 1 - `).get(ownerUserId, assetId, provider, tripId); - - if (!sharedAsset) { - return false; - } - return !!canAccessTrip(tripId, requestingUserId); -} export function listTripPhotos(tripId: string, userId: number): ServiceResult { const access = canAccessTrip(tripId, userId); @@ -108,11 +74,6 @@ function _addTripPhoto(tripId: string, userId: number, provider: string, assetId return result.changes > 0; } -export type Selection = { - provider: string; - asset_ids: string[]; -}; - export async function addTripPhotos( tripId: string, userId: number, @@ -181,7 +142,6 @@ export async function setTripPhotoSharing( } } - export function removeTripPhoto( tripId: string, userId: number, @@ -262,25 +222,6 @@ export function removeAlbumLink(tripId: string, linkId: string, userId: number): } } -//helpers for album link syncing - -export function getAlbumIdFromLink(tripId: string, linkId: string, userId: number): ServiceResult { - const access = canAccessTrip(tripId, userId); - if (!access) return fail('Trip not found or access denied', 404); - - try { - const row = db.prepare('SELECT album_id FROM trip_album_links WHERE id = ? AND trip_id = ? AND user_id = ?') - .get(linkId, tripId, userId) as { album_id: string } | null; - - return row ? success(row.album_id) : fail('Album link not found', 404); - } catch { - return fail('Failed to retrieve album link', 500); - } -} - -export function updateSyncTimeForAlbumLink(linkId: string): void { - db.prepare('UPDATE trip_album_links SET last_synced_at = CURRENT_TIMESTAMP WHERE id = ?').run(linkId); -} //----------------------------------------------- // notifications helper @@ -306,5 +247,3 @@ async function _notifySharedTripPhotos( return fail('Failed to send notifications', 500); } } - - From 877e1a09ccc6ceb5369809415fe7f71ceb7ef453 Mon Sep 17 00:00:00 2001 From: Marek Maslowski Date: Sat, 4 Apr 2026 14:20:52 +0200 Subject: [PATCH 33/57] removing the need of suplementing provider links in config --- server/src/app.ts | 7 ++++--- server/src/db/migrations.ts | 19 ++++++++++--------- server/src/db/schema.ts | 1 - server/src/db/seeds.ts | 16 ++-------------- server/src/services/adminService.ts | 14 +++++++------- .../src/services/memories/helpersService.ts | 19 +++++++++++++++++++ 6 files changed, 42 insertions(+), 34 deletions(-) diff --git a/server/src/app.ts b/server/src/app.ts index 60ddcb8..c46cfe7 100644 --- a/server/src/app.ts +++ b/server/src/app.ts @@ -38,6 +38,7 @@ import notificationRoutes from './routes/notifications'; import shareRoutes from './routes/share'; import { mcpHandler } from './mcp'; import { Addon } from './types'; +import { getPhotoProviderConfig } from './services/memories/helpersService'; export function createApp(): express.Application { const app = express(); @@ -194,11 +195,11 @@ export function createApp(): express.Application { app.get('/api/addons', authenticate, (_req: Request, res: Response) => { const addons = db.prepare('SELECT id, name, type, icon, enabled FROM addons WHERE enabled = 1 ORDER BY sort_order').all() as Pick[]; const providers = db.prepare(` - SELECT id, name, icon, enabled, config, sort_order + SELECT id, name, icon, enabled, sort_order FROM photo_providers WHERE enabled = 1 ORDER BY sort_order, id - `).all() as Array<{ id: string; name: string; icon: string; enabled: number; config: string; sort_order: number }>; + `).all() as Array<{ id: string; name: string; icon: string; enabled: number; sort_order: number }>; const fields = db.prepare(` SELECT provider_id, field_key, label, input_type, placeholder, required, secret, settings_key, payload_key, sort_order FROM photo_provider_fields @@ -232,7 +233,7 @@ export function createApp(): express.Application { type: 'photo_provider', icon: p.icon, enabled: !!p.enabled, - config: JSON.parse(p.config || '{}'), + config: getPhotoProviderConfig(p.id), fields: (fieldsByProvider.get(p.id) || []).map(f => ({ key: f.field_key, label: f.label, diff --git a/server/src/db/migrations.ts b/server/src/db/migrations.ts index 20f160d..aee415e 100644 --- a/server/src/db/migrations.ts +++ b/server/src/db/migrations.ts @@ -643,14 +643,13 @@ function runMigrations(db: Database.Database): void { // Seed Synology Photos provider and fields in existing databases try { db.prepare(` - INSERT INTO photo_providers (id, name, description, icon, enabled, config, sort_order) - VALUES (?, ?, ?, ?, ?, ?, ?) + INSERT INTO photo_providers (id, name, description, icon, enabled, sort_order) + VALUES (?, ?, ?, ?, ?, ?) ON CONFLICT(id) DO UPDATE SET name = excluded.name, description = excluded.description, icon = excluded.icon, enabled = excluded.enabled, - config = excluded.config, sort_order = excluded.sort_order `).run( 'synologyphotos', @@ -658,12 +657,6 @@ function runMigrations(db: Database.Database): void { 'Synology Photos integration with separate account settings', 'Image', 0, - JSON.stringify({ - settings_get: '/integrations/synologyphotos/settings', - settings_put: '/integrations/synologyphotos/settings', - status_get: '/integrations/synologyphotos/status', - test_post: '/integrations/synologyphotos/test', - }), 1, ); } catch (err: any) { @@ -691,6 +684,14 @@ function runMigrations(db: Database.Database): void { if (!err.message?.includes('no such table')) throw err; } }, + () => { + // Remove the stored config column from photo_providers now that it is generated from provider id. + const columns = db.prepare("PRAGMA table_info('photo_providers')").all() as Array<{ name: string }>; + const names = new Set(columns.map(c => c.name)); + if (!names.has('config')) return; + + db.exec('ALTER TABLE photo_providers DROP COLUMN config'); + }, ]; if (currentVersion < migrations.length) { diff --git a/server/src/db/schema.ts b/server/src/db/schema.ts index 9e243b6..e053df6 100644 --- a/server/src/db/schema.ts +++ b/server/src/db/schema.ts @@ -232,7 +232,6 @@ function createTables(db: Database.Database): void { description TEXT, icon TEXT DEFAULT 'Image', enabled INTEGER DEFAULT 0, - config TEXT DEFAULT '{}', sort_order INTEGER DEFAULT 0 ); diff --git a/server/src/db/seeds.ts b/server/src/db/seeds.ts index ef849d9..2e233f6 100644 --- a/server/src/db/seeds.ts +++ b/server/src/db/seeds.ts @@ -101,12 +101,6 @@ function seedAddons(db: Database.Database): void { icon: 'Image', enabled: 0, sort_order: 0, - config: JSON.stringify({ - settings_get: '/integrations/immich/settings', - settings_put: '/integrations/immich/settings', - status_get: '/integrations/immich/status', - test_post: '/integrations/immich/test', - }), }, { id: 'synologyphotos', @@ -115,16 +109,10 @@ function seedAddons(db: Database.Database): void { icon: 'Image', enabled: 0, sort_order: 1, - config: JSON.stringify({ - settings_get: '/integrations/synologyphotos/settings', - settings_put: '/integrations/synologyphotos/settings', - status_get: '/integrations/synologyphotos/status', - test_post: '/integrations/synologyphotos/test', - }), }, ]; - const insertProvider = db.prepare('INSERT OR IGNORE INTO photo_providers (id, name, description, icon, enabled, config, sort_order) VALUES (?, ?, ?, ?, ?, ?, ?)'); - for (const p of providerRows) insertProvider.run(p.id, p.name, p.description, p.icon, p.enabled, p.config, p.sort_order); + const insertProvider = db.prepare('INSERT OR IGNORE INTO photo_providers (id, name, description, icon, enabled, sort_order) VALUES (?, ?, ?, ?, ?, ?)'); + for (const p of providerRows) insertProvider.run(p.id, p.name, p.description, p.icon, p.enabled, p.sort_order); const providerFields = [ { provider_id: 'immich', field_key: 'immich_url', label: 'Immich URL', input_type: 'url', placeholder: 'https://immich.example.com', required: 1, secret: 0, settings_key: 'immich_url', payload_key: 'immich_url', sort_order: 0 }, diff --git a/server/src/services/adminService.ts b/server/src/services/adminService.ts index f7df1f4..167eaeb 100644 --- a/server/src/services/adminService.ts +++ b/server/src/services/adminService.ts @@ -9,6 +9,7 @@ import { maybe_encrypt_api_key, decrypt_api_key } from './apiKeyCrypto'; import { getAllPermissions, savePermissions as savePerms, PERMISSION_ACTIONS } from './permissions'; import { revokeUserSessions } from '../mcp'; import { validatePassword } from './passwordPolicy'; +import { getPhotoProviderConfig } from './memories/helpersService'; // ── Helpers ──────────────────────────────────────────────────────────────── @@ -466,10 +467,10 @@ export function deleteTemplateItem(itemId: string) { export function listAddons() { const addons = db.prepare('SELECT * FROM addons ORDER BY sort_order, id').all() as Addon[]; const providers = db.prepare(` - SELECT id, name, description, icon, enabled, config, sort_order + SELECT id, name, description, icon, enabled, sort_order FROM photo_providers ORDER BY sort_order, id - `).all() as Array<{ id: string; name: string; description?: string | null; icon: string; enabled: number; config: string; sort_order: number }>; + `).all() as Array<{ id: string; name: string; description?: string | null; icon: string; enabled: number; sort_order: number }>; const fields = db.prepare(` SELECT provider_id, field_key, label, input_type, placeholder, required, secret, settings_key, payload_key, sort_order FROM photo_provider_fields @@ -502,7 +503,7 @@ export function listAddons() { type: 'photo_provider', icon: p.icon, enabled: !!p.enabled, - config: JSON.parse(p.config || '{}'), + config: getPhotoProviderConfig(p.id), fields: (fieldsByProvider.get(p.id) || []).map(f => ({ key: f.field_key, label: f.label, @@ -521,7 +522,7 @@ export function listAddons() { export function updateAddon(id: string, data: { enabled?: boolean; config?: Record }) { const addon = db.prepare('SELECT * FROM addons WHERE id = ?').get(id) as Addon | undefined; - const provider = db.prepare('SELECT * FROM photo_providers WHERE id = ?').get(id) as { id: string; name: string; description?: string | null; icon: string; enabled: number; config: string; sort_order: number } | undefined; + const provider = db.prepare('SELECT * FROM photo_providers WHERE id = ?').get(id) as { id: string; name: string; description?: string | null; icon: string; enabled: number; sort_order: number } | undefined; if (!addon && !provider) return { error: 'Addon not found', status: 404 }; if (addon) { @@ -529,11 +530,10 @@ export function updateAddon(id: string, data: { enabled?: boolean; config?: Reco if (data.config !== undefined) db.prepare('UPDATE addons SET config = ? WHERE id = ?').run(JSON.stringify(data.config), id); } else { if (data.enabled !== undefined) db.prepare('UPDATE photo_providers SET enabled = ? WHERE id = ?').run(data.enabled ? 1 : 0, id); - if (data.config !== undefined) db.prepare('UPDATE photo_providers SET config = ? WHERE id = ?').run(JSON.stringify(data.config), id); } const updatedAddon = db.prepare('SELECT * FROM addons WHERE id = ?').get(id) as Addon | undefined; - const updatedProvider = db.prepare('SELECT * FROM photo_providers WHERE id = ?').get(id) as { id: string; name: string; description?: string | null; icon: string; enabled: number; config: string; sort_order: number } | undefined; + const updatedProvider = db.prepare('SELECT * FROM photo_providers WHERE id = ?').get(id) as { id: string; name: string; description?: string | null; icon: string; enabled: number; sort_order: number } | undefined; const updated = updatedAddon ? { ...updatedAddon, enabled: !!updatedAddon.enabled, config: JSON.parse(updatedAddon.config || '{}') } : updatedProvider @@ -544,7 +544,7 @@ export function updateAddon(id: string, data: { enabled?: boolean; config?: Reco type: 'photo_provider', icon: updatedProvider.icon, enabled: !!updatedProvider.enabled, - config: JSON.parse(updatedProvider.config || '{}'), + config: getPhotoProviderConfig(updatedProvider.id), sort_order: updatedProvider.sort_order, } : null; diff --git a/server/src/services/memories/helpersService.ts b/server/src/services/memories/helpersService.ts index 51c899a..465842f 100644 --- a/server/src/services/memories/helpersService.ts +++ b/server/src/services/memories/helpersService.ts @@ -32,6 +32,25 @@ export type Selection = { }; +//for loading routes to settings page, and validating which services user has connected +type PhotoProviderConfig = { + settings_get: string; + settings_put: string; + status_get: string; + test_post: string; +}; + + +export function getPhotoProviderConfig(providerId: string): PhotoProviderConfig { + const prefix = `/integrations/memories/${providerId}`; + return { + settings_get: `${prefix}/settings`, + settings_put: `${prefix}/settings`, + status_get: `${prefix}/status`, + test_post: `${prefix}/test`, + }; +} + //----------------------------------------------- //access check helper From 8c125738e8c76e029b81cc68432a2f09bfb997b5 Mon Sep 17 00:00:00 2001 From: Marek Maslowski Date: Sat, 4 Apr 2026 17:13:17 +0200 Subject: [PATCH 34/57] refactor of synology part 1 --- server/src/routes/memories/synology.ts | 140 +++---- .../src/services/memories/helpersService.ts | 54 +++ .../src/services/memories/synologyService.ts | 369 ++++++++---------- 3 files changed, 260 insertions(+), 303 deletions(-) diff --git a/server/src/routes/memories/synology.ts b/server/src/routes/memories/synology.ts index cd3512d..3bce3e4 100644 --- a/server/src/routes/memories/synology.ts +++ b/server/src/routes/memories/synology.ts @@ -1,6 +1,5 @@ import express, { Request, Response } from 'express'; import { authenticate } from '../../middleware/auth'; -import { broadcast } from '../../websocket'; import { AuthRequest } from '../../types'; import { getSynologySettings, @@ -11,114 +10,87 @@ import { syncSynologyAlbumLink, searchSynologyPhotos, getSynologyAssetInfo, - pipeSynologyProxy, streamSynologyAsset, - handleSynologyError, - SynologyServiceError, } from '../../services/memories/synologyService'; -import { canAccessUserPhoto } from '../../services/memories/helpersService'; +import { canAccessUserPhoto, handleServiceResult, fail } from '../../services/memories/helpersService'; const router = express.Router(); -function parseStringBodyField(value: unknown): string { +function _parseStringBodyField(value: unknown): string { return String(value ?? '').trim(); } -function parseNumberBodyField(value: unknown, fallback: number): number { +function _parseNumberBodyField(value: unknown, fallback: number): number { const parsed = Number(value); return Number.isFinite(parsed) ? parsed : fallback; } router.get('/settings', authenticate, async (req: Request, res: Response) => { const authReq = req as AuthRequest; - try { - res.json(await getSynologySettings(authReq.user.id)); - } catch (err: unknown) { - handleSynologyError(res, err, 'Failed to load settings'); - } + handleServiceResult(res, await getSynologySettings(authReq.user.id)); }); router.put('/settings', authenticate, async (req: Request, res: Response) => { const authReq = req as AuthRequest; const body = req.body as Record; - const synology_url = parseStringBodyField(body.synology_url); - const synology_username = parseStringBodyField(body.synology_username); - const synology_password = parseStringBodyField(body.synology_password); + const synology_url = _parseStringBodyField(body.synology_url); + const synology_username = _parseStringBodyField(body.synology_username); + const synology_password = _parseStringBodyField(body.synology_password); if (!synology_url || !synology_username) { - return handleSynologyError(res, new SynologyServiceError(400, 'URL and username are required'), 'Missing required fields'); + handleServiceResult(res, fail('URL and username are required', 400)); } - - try { - await updateSynologySettings(authReq.user.id, synology_url, synology_username, synology_password); - res.json({ success: true }); - } catch (err: unknown) { - handleSynologyError(res, err, 'Failed to save settings'); + else { + handleServiceResult(res, await updateSynologySettings(authReq.user.id, synology_url, synology_username, synology_password)); } }); router.get('/status', authenticate, async (req: Request, res: Response) => { const authReq = req as AuthRequest; - res.json(await getSynologyStatus(authReq.user.id)); + handleServiceResult(res, await getSynologyStatus(authReq.user.id)); }); router.post('/test', authenticate, async (req: Request, res: Response) => { const body = req.body as Record; - const synology_url = parseStringBodyField(body.synology_url); - const synology_username = parseStringBodyField(body.synology_username); - const synology_password = parseStringBodyField(body.synology_password); + const synology_url = _parseStringBodyField(body.synology_url); + const synology_username = _parseStringBodyField(body.synology_username); + const synology_password = _parseStringBodyField(body.synology_password); if (!synology_url || !synology_username || !synology_password) { - return handleSynologyError(res, new SynologyServiceError(400, 'URL, username and password are required'), 'Missing required fields'); + handleServiceResult(res, fail('URL, username, and password are required', 400)); + } + else{ + handleServiceResult(res, await testSynologyConnection(synology_url, synology_username, synology_password)); } - - res.json(await testSynologyConnection(synology_url, synology_username, synology_password)); }); router.get('/albums', authenticate, async (req: Request, res: Response) => { const authReq = req as AuthRequest; - try { - res.json(await listSynologyAlbums(authReq.user.id)); - } catch (err: unknown) { - handleSynologyError(res, err, 'Could not reach Synology'); - } + handleServiceResult(res, await listSynologyAlbums(authReq.user.id)); }); router.post('/trips/:tripId/album-links/:linkId/sync', authenticate, async (req: Request, res: Response) => { const authReq = req as AuthRequest; const { tripId, linkId } = req.params; - try { - const result = await syncSynologyAlbumLink(authReq.user.id, tripId, linkId); - res.json({ success: true, ...result }); - if (result.added > 0) { - broadcast(tripId, 'memories:updated', { userId: authReq.user.id }, req.headers['x-socket-id'] as string); - } - } catch (err: unknown) { - handleSynologyError(res, err, 'Could not reach Synology'); - } + handleServiceResult(res, await syncSynologyAlbumLink(authReq.user.id, tripId, linkId)); }); router.post('/search', authenticate, async (req: Request, res: Response) => { const authReq = req as AuthRequest; const body = req.body as Record; - const from = parseStringBodyField(body.from); - const to = parseStringBodyField(body.to); - const offset = parseNumberBodyField(body.offset, 0); - const limit = parseNumberBodyField(body.limit, 300); + const from = _parseStringBodyField(body.from); + const to = _parseStringBodyField(body.to); + const offset = _parseNumberBodyField(body.offset, 0); + const limit = _parseNumberBodyField(body.limit, 100); - try { - const result = await searchSynologyPhotos( - authReq.user.id, - from || undefined, - to || undefined, - offset, - limit, - ); - res.json(result); - } catch (err: unknown) { - handleSynologyError(res, err, 'Could not reach Synology'); - } + handleServiceResult(res, await searchSynologyPhotos( + authReq.user.id, + from || undefined, + to || undefined, + offset, + limit, + )); }); router.get('/assets/:tripId/:photoId/:ownerId/info', authenticate, async (req: Request, res: Response) => { @@ -126,53 +98,29 @@ router.get('/assets/:tripId/:photoId/:ownerId/info', authenticate, async (req: R const { tripId, photoId, ownerId } = req.params; if (!canAccessUserPhoto(authReq.user.id, Number(ownerId), tripId, photoId, 'synologyphotos')) { - return handleSynologyError(res, new SynologyServiceError(403, 'You don\'t have access to this photo'), 'Access denied'); + handleServiceResult(res, fail('You don\'t have access to this photo', 403)); } - - try { - res.json(await getSynologyAssetInfo(authReq.user.id, photoId, Number(ownerId))); - } catch (err: unknown) { - handleSynologyError(res, err, 'Could not reach Synology'); + else { + handleServiceResult(res, await getSynologyAssetInfo(authReq.user.id, photoId, Number(ownerId))); } }); -router.get('/assets/:tripId/:photoId/:ownerId/thumbnail', authenticate, async (req: Request, res: Response) => { +router.get('/assets/:tripId/:photoId/:ownerId/:kind', authenticate, async (req: Request, res: Response) => { const authReq = req as AuthRequest; - const { tripId, photoId, ownerId } = req.params; + const { tripId, photoId, ownerId, kind } = req.params; const { size = 'sm' } = req.query; + if (kind !== 'thumbnail' && kind !== 'original') { + handleServiceResult(res, fail('Invalid asset kind', 400)); + } + if (!canAccessUserPhoto(authReq.user.id, Number(ownerId), tripId, photoId, 'synologyphotos')) { - return handleSynologyError(res, new SynologyServiceError(403, 'You don\'t have access to this photo'), 'Access denied'); + handleServiceResult(res, fail('You don\'t have access to this photo', 403)); + } + else{ + await streamSynologyAsset(res, authReq.user.id, Number(ownerId), photoId, kind as 'thumbnail' | 'original', String(size)); } - try { - const proxy = await streamSynologyAsset(authReq.user.id, Number(ownerId), photoId, 'thumbnail', String(size)); - await pipeSynologyProxy(res, proxy); - } catch (err: unknown) { - if (res.headersSent) { - return; - } - handleSynologyError(res, err, 'Proxy error'); - } -}); - -router.get('/assets/:tripId/:photoId/:ownerId/original', authenticate, async (req: Request, res: Response) => { - const authReq = req as AuthRequest; - const { tripId, photoId, ownerId } = req.params; - - if (!canAccessUserPhoto(authReq.user.id, Number(ownerId), tripId, photoId, 'synologyphotos')) { - return handleSynologyError(res, new SynologyServiceError(403, 'You don\'t have access to this photo'), 'Access denied'); - } - - try { - const proxy = await streamSynologyAsset(authReq.user.id, Number(ownerId), photoId, 'original'); - await pipeSynologyProxy(res, proxy); - } catch (err: unknown) { - if (res.headersSent) { - return; - } - handleSynologyError(res, err, 'Proxy error'); - } }); export default router; diff --git a/server/src/services/memories/helpersService.ts b/server/src/services/memories/helpersService.ts index 465842f..59a673d 100644 --- a/server/src/services/memories/helpersService.ts +++ b/server/src/services/memories/helpersService.ts @@ -1,3 +1,6 @@ +import { Readable } from 'node:stream'; +import { pipeline } from 'node:stream/promises'; +import { Response } from 'express'; import { canAccessTrip, db } from "../../db/database"; // helpers for handling return types @@ -24,6 +27,16 @@ export function mapDbError(error: unknown, fallbackMessage: string): ServiceErro } +export function handleServiceResult(res: Response, result: ServiceResult): void { + if ('error' in result) { + res.status(result.error.status).json({ error: result.error.message }); + } + else { + console.log('Service result data:', result.data); + res.json(result.data); + } +} + // ---------------------------------------------- // types used across memories services export type Selection = { @@ -31,6 +44,29 @@ export type Selection = { asset_ids: string[]; }; +export type StatusResult = { + connected: true; + user: { name: string } +} | { + connected: false; + error: string +}; + +export type AlbumsList = { + albums: Array<{ id: string; albumName: string; assetCount: number }> +}; + +export type AssetInfo = { + id: string; + takenAt: string; +}; + +export type AssetsList = { + assets: AssetInfo[], + total: number, + hasMore: boolean +}; + //for loading routes to settings page, and validating which services user has connected type PhotoProviderConfig = { @@ -96,3 +132,21 @@ export function getAlbumIdFromLink(tripId: string, linkId: string, userId: numbe export function updateSyncTimeForAlbumLink(linkId: string): void { db.prepare('UPDATE trip_album_links SET last_synced_at = CURRENT_TIMESTAMP WHERE id = ?').run(linkId); } + +export async function pipeAsset(url: string, response: Response): Promise { + const resp = await fetch(url); + + response.status(resp.status); + if (resp.headers.get('content-type')) response.set('Content-Type', resp.headers.get('content-type') as string); + if (resp.headers.get('cache-control')) response.set('Cache-Control', resp.headers.get('cache-control') as string); + if (resp.headers.get('content-length')) response.set('Content-Length', resp.headers.get('content-length') as string); + if (resp.headers.get('content-disposition')) response.set('Content-Disposition', resp.headers.get('content-disposition') as string); + + if (!resp.body) { + response.end(); + } + else { + pipeline(Readable.fromWeb(resp.body), response); + } + +} \ No newline at end of file diff --git a/server/src/services/memories/synologyService.ts b/server/src/services/memories/synologyService.ts index c9c8b57..a444c20 100644 --- a/server/src/services/memories/synologyService.ts +++ b/server/src/services/memories/synologyService.ts @@ -1,11 +1,22 @@ -import { Readable } from 'node:stream'; -import { pipeline } from 'node:stream/promises'; -import { Response as ExpressResponse } from 'express'; + +import { Response } from 'express'; import { db } from '../../db/database'; import { decrypt_api_key, maybe_encrypt_api_key } from '../apiKeyCrypto'; import { checkSsrf } from '../../utils/ssrfGuard'; -import { addTripPhotos} from './unifiedService'; -import { getAlbumIdFromLink, updateSyncTimeForAlbumLink, Selection } from './helpersService'; +import { addTripPhotos } from './unifiedService'; +import { + getAlbumIdFromLink, + updateSyncTimeForAlbumLink, + Selection, + ServiceResult, + fail, + success, + handleServiceResult, + pipeAsset, + AlbumsList, + AssetsList, + StatusResult +} from './helpersService'; const SYNOLOGY_API_TIMEOUT_MS = 30000; const SYNOLOGY_PROVIDER = 'synologyphotos'; @@ -18,12 +29,6 @@ interface SynologyCredentials { synology_password: string; } -interface SynologySession { - success: boolean; - sid?: string; - error?: { code: number; message?: string }; -} - interface ApiCallParams { api: string; method: string; @@ -34,16 +39,7 @@ interface ApiCallParams { interface SynologyApiResponse { success: boolean; data?: T; - error?: { code: number; message?: string }; -} - -export class SynologyServiceError extends Error { - status: number; - - constructor(status: number, message: string) { - super(message); - this.status = status; - } + error?: { code: number }; } export interface SynologySettings { @@ -52,12 +48,6 @@ export interface SynologySettings { connected: boolean; } -export interface SynologyConnectionResult { - connected: boolean; - user?: { username: string }; - error?: string; -} - export interface SynologyAlbumLinkInput { album_id?: string | number; album_name?: string; @@ -132,43 +122,50 @@ type SynologyUserRecord = { synology_sid?: string | null; }; -function readSynologyUser(userId: number, columns: string[]): SynologyUserRecord | null { +function _readSynologyUser(userId: number, columns: string[]): ServiceResult { try { if (!columns) return null; const row = db.prepare(`SELECT synology_url, synology_username, synology_password, synology_sid FROM users WHERE id = ?`).get(userId) as SynologyUserRecord | undefined; - if (!row) return null; + if (!row) { + return fail('User not found', 404); + } const filtered: SynologyUserRecord = {}; for (const column of columns) { filtered[column] = row[column]; } - return filtered || null; + if (!filtered) { + return fail('Failed to read Synology user data', 500); + } + + return success(filtered); } catch { - return null; + return fail('Failed to read Synology user data', 500); } } -function getSynologyCredentials(userId: number): SynologyCredentials | null { - const user = readSynologyUser(userId, ['synology_url', 'synology_username', 'synology_password']); - if (!user?.synology_url || !user.synology_username || !user.synology_password) return null; - return { - synology_url: user.synology_url, - synology_username: user.synology_username, - synology_password: decrypt_api_key(user.synology_password) as string, - }; +function _getSynologyCredentials(userId: number): ServiceResult { + const user = _readSynologyUser(userId, ['synology_url', 'synology_username', 'synology_password']); + if (!user.success) return user as ServiceResult; + if (!user?.data.synology_url || !user.data.synology_username || !user.data.synology_password) return fail('Synology not configured', 400); + return success({ + synology_url: user.data.synology_url, + synology_username: user.data.synology_username, + synology_password: decrypt_api_key(user.data.synology_password) as string, + }); } -function buildSynologyEndpoint(url: string): string { +function _buildSynologyEndpoint(url: string): string { const normalized = url.replace(/\/$/, '').match(/^https?:\/\//) ? url.replace(/\/$/, '') : `https://${url.replace(/\/$/, '')}`; return `${normalized}${SYNOLOGY_ENDPOINT_PATH}`; } -function buildSynologyFormBody(params: ApiCallParams): URLSearchParams { +function _buildSynologyFormBody(params: ApiCallParams): URLSearchParams { const body = new URLSearchParams(); for (const [key, value] of Object.entries(params)) { if (value === undefined || value === null) continue; @@ -177,8 +174,8 @@ function buildSynologyFormBody(params: ApiCallParams): URLSearchParams { return body; } -async function fetchSynologyJson(url: string, body: URLSearchParams): Promise> { - const endpoint = buildSynologyEndpoint(url); +async function _fetchSynologyJson(url: string, body: URLSearchParams): Promise> { + const endpoint = _buildSynologyEndpoint(url); const resp = await fetch(endpoint, { method: 'POST', headers: { @@ -189,14 +186,14 @@ async function fetchSynologyJson(url: string, body: URLSearchParams): Promise }); if (!resp.ok) { - const text = await resp.text(); - return { success: false, error: { code: resp.status, message: text } }; + return fail('Synology API request failed with status ' + resp.status, resp.status); } - return resp.json() as Promise>; + const response = await resp.json() as SynologyApiResponse; + return response.success ? success(response.data) : fail('Synology failed with code ' + response.error.code, response.error.code); } -async function loginToSynology(url: string, username: string, password: string): Promise> { +async function _loginToSynology(url: string, username: string, password: string): Promise> { const body = new URLSearchParams({ api: 'SYNO.API.Auth', method: 'login', @@ -205,40 +202,43 @@ async function loginToSynology(url: string, username: string, password: string): passwd: password, }); - return fetchSynologyJson<{ sid?: string }>(url, body); + const result = await _fetchSynologyJson<{ sid?: string }>(url, body); + if (!result.success) { + return result as ServiceResult; + } + if (!result.data.sid) { + return fail('Failed to get session ID from Synology', 500); + } + return success(result.data.sid); + + } -async function requestSynologyApi(userId: number, params: ApiCallParams): Promise> { - const creds = getSynologyCredentials(userId); - if (!creds) { - return { success: false, error: { code: 400, message: 'Synology not configured' } }; +async function _requestSynologyApi(userId: number, params: ApiCallParams): Promise> { + const creds = _getSynologyCredentials(userId); + if (!creds.success) { + return creds as ServiceResult; } - const session = await getSynologySession(userId); - if (!session.success || !session.sid) { - return { success: false, error: session.error || { code: 400, message: 'Failed to get Synology session' } }; + const session = await _getSynologySession(userId); + if (!session.success || !session.data) { + return session as ServiceResult; } - const body = buildSynologyFormBody({ ...params, _sid: session.sid }); - const result = await fetchSynologyJson(creds.synology_url, body); - if (!result.success && result.error?.code === 119) { - clearSynologySID(userId); - const retrySession = await getSynologySession(userId); - if (!retrySession.success || !retrySession.sid) { - return { success: false, error: retrySession.error || { code: 400, message: 'Failed to get Synology session' } }; + const body = _buildSynologyFormBody({ ...params, _sid: session.data }); + const result = await _fetchSynologyJson(creds.data.synology_url, body); + if ('error' in result && result.error.status === 119) { + _clearSynologySID(userId); + const retrySession = await _getSynologySession(userId); + if (!retrySession.success || !retrySession.data) { + return session as ServiceResult; } - return fetchSynologyJson(creds.synology_url, buildSynologyFormBody({ ...params, _sid: retrySession.sid })); + return _fetchSynologyJson(creds.data.synology_url, _buildSynologyFormBody({ ...params, _sid: retrySession.data })); } return result; } -async function requestSynologyStream(url: string): Promise { - return fetch(url, { - signal: AbortSignal.timeout(SYNOLOGY_API_TIMEOUT_MS), - }); -} - -function normalizeSynologyPhotoInfo(item: SynologyPhotoItem): SynologyPhotoInfo { +function _normalizeSynologyPhotoInfo(item: SynologyPhotoItem): SynologyPhotoInfo { const address = item.additional?.address || {}; const exif = item.additional?.exif || {}; const gps = item.additional?.gps || {}; @@ -268,69 +268,65 @@ function normalizeSynologyPhotoInfo(item: SynologyPhotoItem): SynologyPhotoInfo }; } -export function handleSynologyError(res: ExpressResponse, err: unknown, fallbackMessage: string): ExpressResponse { - if (err instanceof SynologyServiceError) { - return res.status(err.status).json({ error: err.message }); - } - return res.status(502).json({ error: err instanceof Error ? err.message : fallbackMessage }); -} - -function cacheSynologySID(userId: number, sid: string): void { +function _cacheSynologySID(userId: number, sid: string): void { db.prepare('UPDATE users SET synology_sid = ? WHERE id = ?').run(sid, userId); } -function clearSynologySID(userId: number): void { +function _clearSynologySID(userId: number): void { db.prepare('UPDATE users SET synology_sid = NULL WHERE id = ?').run(userId); } -function splitPackedSynologyId(rawId: string): { id: string; cacheKey: string; assetId: string } { +function _splitPackedSynologyId(rawId: string): { id: string; cacheKey: string; assetId: string } { const id = rawId.split('_')[0]; return { id, cacheKey: rawId, assetId: rawId }; } -async function getSynologySession(userId: number): Promise { - const cachedSid = readSynologyUser(userId, ['synology_sid'])?.synology_sid || null; - if (cachedSid) { - return { success: true, sid: cachedSid }; - } - - const creds = getSynologyCredentials(userId); - if (!creds) { - return { success: false, error: { code: 400, message: 'Invalid Synology credentials' } }; +async function _getSynologySession(userId: number): Promise> { + const cachedSid = _readSynologyUser(userId, ['synology_sid']); + if (cachedSid.success && cachedSid.data?.synology_sid) { + return success(cachedSid.data.synology_sid); } - const resp = await loginToSynology(creds.synology_url, creds.synology_username, creds.synology_password); - - if (!resp.success || !resp.data?.sid) { - return { success: false, error: resp.error || { code: 400, message: 'Failed to authenticate with Synology' } }; + const creds = _getSynologyCredentials(userId); + if (!creds.success) { + return creds as ServiceResult; } - cacheSynologySID(userId, resp.data.sid); - return { success: true, sid: resp.data.sid }; + const resp = await _loginToSynology(creds.data.synology_url, creds.data.synology_username, creds.data.synology_password); + + if (!resp.success) { + return resp as ServiceResult; + } + + _cacheSynologySID(userId, resp.data); + return success(resp.data); } -export async function getSynologySettings(userId: number): Promise { - const creds = getSynologyCredentials(userId); - const session = await getSynologySession(userId); - return { - synology_url: creds?.synology_url || '', - synology_username: creds?.synology_username || '', +export async function getSynologySettings(userId: number): Promise> { + const creds = _getSynologyCredentials(userId); + if (!creds.success) return creds as ServiceResult; + const session = await _getSynologySession(userId); + return success({ + synology_url: creds.data.synology_url || '', + synology_username: creds.data.synology_username || '', connected: session.success, - }; + }); } -export async function updateSynologySettings(userId: number, synologyUrl: string, synologyUsername: string, synologyPassword?: string): Promise { +export async function updateSynologySettings(userId: number, synologyUrl: string, synologyUsername: string, synologyPassword?: string): Promise> { const ssrf = await checkSsrf(synologyUrl); if (!ssrf.allowed) { - throw new SynologyServiceError(400, ssrf.error ?? 'Invalid Synology URL'); + return fail(ssrf.error, 400); } - const existingEncryptedPassword = readSynologyUser(userId, ['synology_password'])?.synology_password || null; + const result = _readSynologyUser(userId, ['synology_password']) + if (!result.success) return result as ServiceResult; + const existingEncryptedPassword = result.data?.synology_password || null; if (!synologyPassword && !existingEncryptedPassword) { - throw new SynologyServiceError(400, 'No stored password found. Please provide a password to save settings.'); + return fail('No stored password found. Please provide a password to save settings.', 400); } try { @@ -341,79 +337,69 @@ export async function updateSynologySettings(userId: number, synologyUrl: string userId, ); } catch { - throw new SynologyServiceError(400, 'Failed to save settings'); + return fail('Failed to update Synology settings', 500); } - clearSynologySID(userId); - await getSynologySession(userId); + _clearSynologySID(userId); + return success("settings updated"); } -export async function getSynologyStatus(userId: number): Promise { +export async function getSynologyStatus(userId: number): Promise> { + const sid = await _getSynologySession(userId); + if ('error' in sid) return success({ connected: false, error: sid.error.status === 400 ? 'Invalid credentials' : sid.error.message }); + if (!sid.data) return success({ connected: false, error: 'Not connected to Synology' }); try { - const sid = await getSynologySession(userId); - if (!sid.success || !sid.sid) { - return { connected: false, error: 'Authentication failed' }; - } - const user = db.prepare('SELECT synology_username FROM users WHERE id = ?').get(userId) as { synology_username?: string } | undefined; - return { connected: true, user: { username: user?.synology_username || '' } }; + return success({ connected: true, user: { name: user?.synology_username || 'unknown user' } }); } catch (err: unknown) { - return { connected: false, error: err instanceof Error ? err.message : 'Connection failed' }; + return success({ connected: true, user: { name: 'unknown user' } }); } } -export async function testSynologyConnection(synologyUrl: string, synologyUsername: string, synologyPassword: string): Promise { +export async function testSynologyConnection(synologyUrl: string, synologyUsername: string, synologyPassword: string): Promise> { const ssrf = await checkSsrf(synologyUrl); if (!ssrf.allowed) { - return { connected: false, error: ssrf.error ?? 'Invalid Synology URL' }; + return fail(ssrf.error, 400); } - try { - const login = await loginToSynology(synologyUrl, synologyUsername, synologyPassword); - if (!login.success || !login.data?.sid) { - return { connected: false, error: login.error?.message || 'Authentication failed' }; - } - return { connected: true, user: { username: synologyUsername } }; - } catch (err: unknown) { - return { connected: false, error: err instanceof Error ? err.message : 'Connection failed' }; + + const resp = await _loginToSynology(synologyUrl, synologyUsername, synologyPassword); + if ('error' in resp) { + return success({ connected: false, error: resp.error.status === 400 ? 'Invalid credentials' : resp.error.message }); } + return success({ connected: true, user: { name: synologyUsername } }); } -export async function listSynologyAlbums(userId: number): Promise<{ albums: Array<{ id: string; albumName: string; assetCount: number }> }> { - const result = await requestSynologyApi<{ list: SynologyPhotoItem[] }>(userId, { +export async function listSynologyAlbums(userId: number): Promise> { + const result = await _requestSynologyApi<{ list: SynologyPhotoItem[] }>(userId, { api: 'SYNO.Foto.Browse.Album', method: 'list', version: 4, offset: 0, limit: 100, }); + if (!result.success) return result as ServiceResult; - if (!result.success || !result.data) { - throw new SynologyServiceError(result.error?.code || 500, result.error?.message || 'Failed to fetch albums'); - } - - const albums = (result.data.list || []).map((album: SynologyPhotoItem) => ({ + const albums = (result.data.list || []).map((album: any) => ({ id: String(album.id), albumName: album.name || '', assetCount: album.item_count || 0, })); - return { albums }; + return success({ albums }); } -export async function syncSynologyAlbumLink(userId: number, tripId: string, linkId: string): Promise<{ added: number; total: number }> { +export async function syncSynologyAlbumLink(userId: number, tripId: string, linkId: string): Promise> { const response = getAlbumIdFromLink(tripId, linkId, userId); - if (!response.success) { - throw new SynologyServiceError(404, 'Album link not found'); - } + if (!response.success) return response as ServiceResult<{ added: number; total: number }>; const allItems: SynologyPhotoItem[] = []; const pageSize = 1000; let offset = 0; while (true) { - const result = await requestSynologyApi<{ list: SynologyPhotoItem[] }>(userId, { + const result = await _requestSynologyApi<{ list: SynologyPhotoItem[] }>(userId, { api: 'SYNO.Foto.Browse.Item', method: 'list', version: 1, @@ -423,9 +409,7 @@ export async function syncSynologyAlbumLink(userId: number, tripId: string, link additional: ['thumbnail'], }); - if (!result.success || !result.data) { - throw new SynologyServiceError(502, result.error?.message || 'Failed to fetch album'); - } + if (!result.success) return result as ServiceResult<{ added: number; total: number }>; const items = result.data.list || []; allItems.push(...items); @@ -441,12 +425,12 @@ export async function syncSynologyAlbumLink(userId: number, tripId: string, link updateSyncTimeForAlbumLink(linkId); const result = await addTripPhotos(tripId, userId, true, [selection]); - if ('error' in result) throw new SynologyServiceError(result.error.status, result.error.message); + if (!result.success) return result as ServiceResult<{ added: number; total: number }>; - return { added: result.data.added, total: allItems.length }; + return success({ added: result.data.added, total: allItems.length }); } -export async function searchSynologyPhotos(userId: number, from?: string, to?: string, offset = 0, limit = 300): Promise<{ assets: SynologyPhotoInfo[]; total: number; hasMore: boolean }> { +export async function searchSynologyPhotos(userId: number, from?: string, to?: string, offset = 0, limit = 300): Promise> { const params: ApiCallParams = { api: 'SYNO.Foto.Search.Search', method: 'list_item', @@ -466,25 +450,23 @@ export async function searchSynologyPhotos(userId: number, from?: string, to?: s } } - const result = await requestSynologyApi<{ list: SynologyPhotoItem[]; total: number }>(userId, params); - if (!result.success || !result.data) { - throw new SynologyServiceError(502, result.error?.message || 'Failed to fetch album photos'); - } + const result = await _requestSynologyApi<{ list: SynologyPhotoItem[]; total: number }>(userId, params); + if (!result.success) return result as ServiceResult<{ assets: SynologyPhotoInfo[]; total: number; hasMore: boolean }>; const allItems = result.data.list || []; const total = allItems.length; - const assets = allItems.map(item => normalizeSynologyPhotoInfo(item)); + const assets = allItems.map(item => _normalizeSynologyPhotoInfo(item)); - return { + return success({ assets, total, hasMore: total === limit, - }; + }); } -export async function getSynologyAssetInfo(userId: number, photoId: string, targetUserId?: number): Promise { - const parsedId = splitPackedSynologyId(photoId); - const result = await requestSynologyApi<{ list: SynologyPhotoItem[] }>(targetUserId ?? userId, { +export async function getSynologyAssetInfo(userId: number, photoId: string, targetUserId?: number): Promise> { + const parsedId = _splitPackedSynologyId(photoId); + const result = await _requestSynologyApi<{ list: SynologyPhotoItem[] }>(targetUserId, { api: 'SYNO.Foto.Browse.Item', method: 'get', version: 5, @@ -492,39 +474,41 @@ export async function getSynologyAssetInfo(userId: number, photoId: string, targ additional: ['resolution', 'exif', 'gps', 'address', 'orientation', 'description'], }); - if (!result.success || !result.data) { - throw new SynologyServiceError(404, 'Photo not found'); - } + if (!result.success) return result as ServiceResult; const metadata = result.data.list?.[0]; - if (!metadata) { - throw new SynologyServiceError(404, 'Photo not found'); - } + if (!metadata) return fail('Photo not found', 404); - const normalized = normalizeSynologyPhotoInfo(metadata); + const normalized = _normalizeSynologyPhotoInfo(metadata); normalized.id = photoId; - return normalized; + return success(normalized); } export async function streamSynologyAsset( + response: Response, userId: number, targetUserId: number, photoId: string, kind: 'thumbnail' | 'original', size?: string, -): Promise { - const parsedId = splitPackedSynologyId(photoId); - const synology_url = getSynologyCredentials(targetUserId).synology_url; - if (!synology_url) { - throw new SynologyServiceError(402, 'User not configured with Synology'); +): Promise { + const parsedId = _splitPackedSynologyId(photoId); + + const synology_credentials = _getSynologyCredentials(targetUserId); + if (!synology_credentials.success) { + handleServiceResult(response, synology_credentials); + return; } - const sid = await getSynologySession(targetUserId); - if (!sid.success || !sid.sid) { - throw new SynologyServiceError(401, 'Authentication failed'); + const sid = await _getSynologySession(targetUserId); + if (!sid.success) { + handleServiceResult(response, sid); + return; + } + if (!sid.data) { + handleServiceResult(response, fail('Failed to retrieve session ID', 500)); + return; } - - const params = kind === 'thumbnail' ? new URLSearchParams({ @@ -536,7 +520,7 @@ export async function streamSynologyAsset( type: 'unit', size: String(size || SYNOLOGY_DEFAULT_THUMBNAIL_SIZE), cache_key: parsedId.cacheKey, - _sid: sid.sid, + _sid: sid.data, }) : new URLSearchParams({ api: 'SYNO.Foto.Download', @@ -544,40 +528,11 @@ export async function streamSynologyAsset( version: '2', cache_key: parsedId.cacheKey, unit_id: `[${parsedId.id}]`, - _sid: sid.sid, + _sid: sid.data, }); - const url = `${buildSynologyEndpoint(synology_url)}?${params.toString()}`; - const resp = await requestSynologyStream(url); + const url = `${_buildSynologyEndpoint(synology_credentials.data.synology_url)}?${params.toString()}`; - if (!resp.ok) { - const body = kind === 'original' ? await resp.text() : 'Failed'; - throw new SynologyServiceError(resp.status, kind === 'original' ? `Failed: ${body}` : body); - } - - return { - status: resp.status, - headers: { - 'content-type': resp.headers.get('content-type') || (kind === 'thumbnail' ? 'image/jpeg' : 'application/octet-stream'), - 'cache-control': resp.headers.get('cache-control') || 'public, max-age=86400', - 'content-length': resp.headers.get('content-length'), - 'content-disposition': resp.headers.get('content-disposition'), - }, - body: resp.body, - }; + await pipeAsset(url, response) } -export async function pipeSynologyProxy(response: ExpressResponse, proxy: SynologyProxyResult): Promise { - response.status(proxy.status); - if (proxy.headers['content-type']) response.set('Content-Type', proxy.headers['content-type'] as string); - if (proxy.headers['cache-control']) response.set('Cache-Control', proxy.headers['cache-control'] as string); - if (proxy.headers['content-length']) response.set('Content-Length', proxy.headers['content-length'] as string); - if (proxy.headers['content-disposition']) response.set('Content-Disposition', proxy.headers['content-disposition'] as string); - - if (!proxy.body) { - response.end(); - return; - } - - await pipeline(Readable.fromWeb(proxy.body), response); -} From 3d0249e076ae49cbca08e63559a78707116c84c8 Mon Sep 17 00:00:00 2001 From: Marek Maslowski Date: Sat, 4 Apr 2026 18:16:46 +0200 Subject: [PATCH 35/57] finishing refactor --- server/src/routes/memories/synology.ts | 2 +- .../src/services/memories/helpersService.ts | 64 ++++++++++--- .../src/services/memories/synologyService.ts | 96 ++++++------------- 3 files changed, 77 insertions(+), 85 deletions(-) diff --git a/server/src/routes/memories/synology.ts b/server/src/routes/memories/synology.ts index 3bce3e4..94ddd88 100644 --- a/server/src/routes/memories/synology.ts +++ b/server/src/routes/memories/synology.ts @@ -108,7 +108,7 @@ router.get('/assets/:tripId/:photoId/:ownerId/info', authenticate, async (req: R router.get('/assets/:tripId/:photoId/:ownerId/:kind', authenticate, async (req: Request, res: Response) => { const authReq = req as AuthRequest; const { tripId, photoId, ownerId, kind } = req.params; - const { size = 'sm' } = req.query; + const { size = "sm" } = req.query; if (kind !== 'thumbnail' && kind !== 'original') { handleServiceResult(res, fail('Invalid asset kind', 400)); diff --git a/server/src/services/memories/helpersService.ts b/server/src/services/memories/helpersService.ts index 59a673d..a349269 100644 --- a/server/src/services/memories/helpersService.ts +++ b/server/src/services/memories/helpersService.ts @@ -32,7 +32,6 @@ export function handleServiceResult(res: Response, result: ServiceResult): res.status(result.error.status).json({ error: result.error.message }); } else { - console.log('Service result data:', result.data); res.json(result.data); } } @@ -52,22 +51,51 @@ export type StatusResult = { error: string }; +export type SyncAlbumResult = { + added: number; + total: number +}; + + export type AlbumsList = { albums: Array<{ id: string; albumName: string; assetCount: number }> }; -export type AssetInfo = { +export type Asset = { id: string; takenAt: string; }; export type AssetsList = { - assets: AssetInfo[], + assets: Asset[], total: number, hasMore: boolean }; +export type AssetInfo = { + id: string; + takenAt: string | null; + city: string | null; + country: string | null; + state?: string | null; + camera?: string | null; + lens?: string | null; + focalLength?: string | number | null; + aperture?: string | number | null; + shutter?: string | number | null; + iso?: string | number | null; + lat?: number | null; + lng?: number | null; + orientation?: number | null; + description?: string | null; + width?: number | null; + height?: number | null; + fileSize?: number | null; + fileName?: string | null; +} + + //for loading routes to settings page, and validating which services user has connected type PhotoProviderConfig = { settings_get: string; @@ -134,19 +162,25 @@ export function updateSyncTimeForAlbumLink(linkId: string): void { } export async function pipeAsset(url: string, response: Response): Promise { - const resp = await fetch(url); - - response.status(resp.status); - if (resp.headers.get('content-type')) response.set('Content-Type', resp.headers.get('content-type') as string); - if (resp.headers.get('cache-control')) response.set('Cache-Control', resp.headers.get('cache-control') as string); - if (resp.headers.get('content-length')) response.set('Content-Length', resp.headers.get('content-length') as string); - if (resp.headers.get('content-disposition')) response.set('Content-Disposition', resp.headers.get('content-disposition') as string); - - if (!resp.body) { - response.end(); + try{ + const resp = await fetch(url); + + response.status(resp.status); + if (resp.headers.get('content-type')) response.set('Content-Type', resp.headers.get('content-type') as string); + if (resp.headers.get('cache-control')) response.set('Cache-Control', resp.headers.get('cache-control') as string); + if (resp.headers.get('content-length')) response.set('Content-Length', resp.headers.get('content-length') as string); + if (resp.headers.get('content-disposition')) response.set('Content-Disposition', resp.headers.get('content-disposition') as string); + + if (!resp.body) { + response.end(); + } + else { + pipeline(Readable.fromWeb(resp.body), response); + } } - else { - pipeline(Readable.fromWeb(resp.body), response); + catch (error) { + response.status(500).json({ error: 'Failed to fetch asset' }); + response.end(); } } \ No newline at end of file diff --git a/server/src/services/memories/synologyService.ts b/server/src/services/memories/synologyService.ts index a444c20..76b8916 100644 --- a/server/src/services/memories/synologyService.ts +++ b/server/src/services/memories/synologyService.ts @@ -15,13 +15,20 @@ import { pipeAsset, AlbumsList, AssetsList, - StatusResult + StatusResult, + SyncAlbumResult, + AssetInfo } from './helpersService'; -const SYNOLOGY_API_TIMEOUT_MS = 30000; const SYNOLOGY_PROVIDER = 'synologyphotos'; const SYNOLOGY_ENDPOINT_PATH = '/photo/webapi/entry.cgi'; -const SYNOLOGY_DEFAULT_THUMBNAIL_SIZE = 'sm'; + +interface SynologyUserRecord { + synology_url?: string | null; + synology_username?: string | null; + synology_password?: string | null; + synology_sid?: string | null; +}; interface SynologyCredentials { synology_url: string; @@ -29,6 +36,12 @@ interface SynologyCredentials { synology_password: string; } +interface SynologySettings { + synology_url: string; + synology_username: string; + connected: boolean; +} + interface ApiCallParams { api: string; method: string; @@ -42,53 +55,6 @@ interface SynologyApiResponse { error?: { code: number }; } -export interface SynologySettings { - synology_url: string; - synology_username: string; - connected: boolean; -} - -export interface SynologyAlbumLinkInput { - album_id?: string | number; - album_name?: string; -} - -export interface SynologySearchInput { - from?: string; - to?: string; - offset?: number; - limit?: number; -} - -export interface SynologyProxyResult { - status: number; - headers: Record; - body: ReadableStream | null; -} - -interface SynologyPhotoInfo { - id: string; - takenAt: string | null; - city: string | null; - country: string | null; - state?: string | null; - camera?: string | null; - lens?: string | null; - focalLength?: string | number | null; - aperture?: string | number | null; - shutter?: string | number | null; - iso?: string | number | null; - lat?: number | null; - lng?: number | null; - orientation?: number | null; - description?: string | null; - filename?: string | null; - filesize?: number | null; - width?: number | null; - height?: number | null; - fileSize?: number | null; - fileName?: string | null; -} interface SynologyPhotoItem { id?: string | number; @@ -115,12 +81,6 @@ interface SynologyPhotoItem { }; } -type SynologyUserRecord = { - synology_url?: string | null; - synology_username?: string | null; - synology_password?: string | null; - synology_sid?: string | null; -}; function _readSynologyUser(userId: number, columns: string[]): ServiceResult { try { @@ -182,7 +142,7 @@ async function _fetchSynologyJson(url: string, body: URLSearchParams): Promis 'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8', }, body, - signal: AbortSignal.timeout(SYNOLOGY_API_TIMEOUT_MS), + signal: AbortSignal.timeout(30000), }); if (!resp.ok) { @@ -238,7 +198,7 @@ async function _requestSynologyApi(userId: number, params: ApiCallParams): Pr return result; } -function _normalizeSynologyPhotoInfo(item: SynologyPhotoItem): SynologyPhotoInfo { +function _normalizeSynologyPhotoInfo(item: SynologyPhotoItem): AssetInfo { const address = item.additional?.address || {}; const exif = item.additional?.exif || {}; const gps = item.additional?.gps || {}; @@ -259,8 +219,6 @@ function _normalizeSynologyPhotoInfo(item: SynologyPhotoItem): SynologyPhotoInfo lng: gps.longitude || null, orientation: item.additional?.orientation || null, description: item.additional?.description || null, - filename: item.filename || null, - filesize: item.filesize || null, width: item.additional?.resolution?.width || null, height: item.additional?.resolution?.height || null, fileSize: item.filesize || null, @@ -390,9 +348,9 @@ export async function listSynologyAlbums(userId: number): Promise> { +export async function syncSynologyAlbumLink(userId: number, tripId: string, linkId: string): Promise> { const response = getAlbumIdFromLink(tripId, linkId, userId); - if (!response.success) return response as ServiceResult<{ added: number; total: number }>; + if (!response.success) return response as ServiceResult; const allItems: SynologyPhotoItem[] = []; const pageSize = 1000; @@ -409,7 +367,7 @@ export async function syncSynologyAlbumLink(userId: number, tripId: string, link additional: ['thumbnail'], }); - if (!result.success) return result as ServiceResult<{ added: number; total: number }>; + if (!result.success) return result as ServiceResult; const items = result.data.list || []; allItems.push(...items); @@ -425,7 +383,7 @@ export async function syncSynologyAlbumLink(userId: number, tripId: string, link updateSyncTimeForAlbumLink(linkId); const result = await addTripPhotos(tripId, userId, true, [selection]); - if (!result.success) return result as ServiceResult<{ added: number; total: number }>; + if (!result.success) return result as ServiceResult; return success({ added: result.data.added, total: allItems.length }); } @@ -451,7 +409,7 @@ export async function searchSynologyPhotos(userId: number, from?: string, to?: s } const result = await _requestSynologyApi<{ list: SynologyPhotoItem[]; total: number }>(userId, params); - if (!result.success) return result as ServiceResult<{ assets: SynologyPhotoInfo[]; total: number; hasMore: boolean }>; + if (!result.success) return result as ServiceResult<{ assets: AssetInfo[]; total: number; hasMore: boolean }>; const allItems = result.data.list || []; const total = allItems.length; @@ -464,17 +422,17 @@ export async function searchSynologyPhotos(userId: number, from?: string, to?: s }); } -export async function getSynologyAssetInfo(userId: number, photoId: string, targetUserId?: number): Promise> { +export async function getSynologyAssetInfo(userId: number, photoId: string, targetUserId?: number): Promise> { const parsedId = _splitPackedSynologyId(photoId); const result = await _requestSynologyApi<{ list: SynologyPhotoItem[] }>(targetUserId, { api: 'SYNO.Foto.Browse.Item', method: 'get', version: 5, - id: `[${parsedId.id}]`, + id: `[${Number(parsedId.id) + 1}]`, //for some reason synology wants id moved by one to get image info additional: ['resolution', 'exif', 'gps', 'address', 'orientation', 'description'], }); - if (!result.success) return result as ServiceResult; + if (!result.success) return result as ServiceResult; const metadata = result.data.list?.[0]; if (!metadata) return fail('Photo not found', 404); @@ -518,7 +476,7 @@ export async function streamSynologyAsset( mode: 'download', id: parsedId.id, type: 'unit', - size: String(size || SYNOLOGY_DEFAULT_THUMBNAIL_SIZE), + size: size, cache_key: parsedId.cacheKey, _sid: sid.data, }) From 9f0ec8199fa7595991388d04aa0dd6d6c553677f Mon Sep 17 00:00:00 2001 From: Marek Maslowski Date: Sat, 4 Apr 2026 18:28:44 +0200 Subject: [PATCH 36/57] fixing db errors message --- server/src/services/memories/helpersService.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/server/src/services/memories/helpersService.ts b/server/src/services/memories/helpersService.ts index a349269..161d7bd 100644 --- a/server/src/services/memories/helpersService.ts +++ b/server/src/services/memories/helpersService.ts @@ -19,11 +19,11 @@ export function success(data: T): ServiceResult { } -export function mapDbError(error: unknown, fallbackMessage: string): ServiceError { - if (error instanceof Error && /unique|constraint/i.test(error.message)) { +export function mapDbError(error: Error, fallbackMessage: string): ServiceError { + if (error && /unique|constraint/i.test(error.message)) { return fail('Resource already exists', 409); } - return fail(fallbackMessage, 500); + return fail(error.message, 500); } From 5b25c60b6288676690289696d8fc0abd80f0ae94 Mon Sep 17 00:00:00 2001 From: Marek Maslowski Date: Sat, 4 Apr 2026 18:56:27 +0200 Subject: [PATCH 37/57] fixing migrations --- server/src/db/migrations.ts | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/server/src/db/migrations.ts b/server/src/db/migrations.ts index aee415e..fd0be24 100644 --- a/server/src/db/migrations.ts +++ b/server/src/db/migrations.ts @@ -692,6 +692,18 @@ function runMigrations(db: Database.Database): void { db.exec('ALTER TABLE photo_providers DROP COLUMN config'); }, + () => { + db.exec('ALTER TABLE `trip_photos` RENAME COLUMN immich_asset_id TO asset_id'); + }, + () => { + db.exec('ALTER TABLE `trip_photos` ADD COLUMN provider TEXT NOT NULL DEFAULT "immich"'); + }, + () => { + db.exec('ALTER TABLE `trip_album_links` ADD COLUMN provider TEXT NOT NULL DEFAULT "immich"'); + }, + () => { + db.exec('ALTER TABLE `trip_album_links` RENAME COLUMN immich_album_id TO album_id'); + }, ]; if (currentVersion < migrations.length) { From ba4bfc693a66d7da26afa5d096039cf66f370f0e Mon Sep 17 00:00:00 2001 From: Marek Maslowski Date: Sat, 4 Apr 2026 19:14:45 +0200 Subject: [PATCH 38/57] fixing schemas and making migrations not crash --- server/src/db/migrations.ts | 9 +++------ server/src/db/schema.ts | 3 +++ 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/server/src/db/migrations.ts b/server/src/db/migrations.ts index fd0be24..c9fd4ea 100644 --- a/server/src/db/migrations.ts +++ b/server/src/db/migrations.ts @@ -693,15 +693,12 @@ function runMigrations(db: Database.Database): void { db.exec('ALTER TABLE photo_providers DROP COLUMN config'); }, () => { + const columns = db.prepare("PRAGMA table_info('trip_photos')").all() as Array<{ name: string }>; + const names = new Set(columns.map(c => c.name)); + if (names.has('asset_id') && !names.has('immich_asset_id')) return; db.exec('ALTER TABLE `trip_photos` RENAME COLUMN immich_asset_id TO asset_id'); - }, - () => { db.exec('ALTER TABLE `trip_photos` ADD COLUMN provider TEXT NOT NULL DEFAULT "immich"'); - }, - () => { db.exec('ALTER TABLE `trip_album_links` ADD COLUMN provider TEXT NOT NULL DEFAULT "immich"'); - }, - () => { db.exec('ALTER TABLE `trip_album_links` RENAME COLUMN immich_album_id TO album_id'); }, ]; diff --git a/server/src/db/schema.ts b/server/src/db/schema.ts index e053df6..f18c8d0 100644 --- a/server/src/db/schema.ts +++ b/server/src/db/schema.ts @@ -18,6 +18,8 @@ function createTables(db: Database.Database): void { mfa_enabled INTEGER DEFAULT 0, mfa_secret TEXT, mfa_backup_codes TEXT, + immich_url TEXT, + immich_access_token TEXT, synology_url TEXT, synology_username TEXT, synology_password TEXT, @@ -166,6 +168,7 @@ function createTables(db: Database.Database): void { place_id INTEGER REFERENCES places(id) ON DELETE SET NULL, assignment_id INTEGER REFERENCES day_assignments(id) ON DELETE SET NULL, title TEXT NOT NULL, + accommodation_id TEXT, reservation_time TEXT, reservation_end_time TEXT, location TEXT, From 2baf407809eb5d0c9eeedb1a3f3531eb0f74d12f Mon Sep 17 00:00:00 2001 From: Marek Maslowski Date: Sat, 4 Apr 2026 19:52:49 +0200 Subject: [PATCH 39/57] adding that deletion of album removes its items --- server/src/routes/memories/immich.ts | 3 ++- server/src/routes/memories/synology.ts | 3 ++- server/src/services/memories/immichService.ts | 5 +++-- server/src/services/memories/synologyService.ts | 11 ++++++----- server/src/services/memories/unifiedService.ts | 14 ++++++++------ 5 files changed, 21 insertions(+), 15 deletions(-) diff --git a/server/src/routes/memories/immich.ts b/server/src/routes/memories/immich.ts index af9b4d0..fa89258 100644 --- a/server/src/routes/memories/immich.ts +++ b/server/src/routes/memories/immich.ts @@ -136,7 +136,8 @@ router.get('/albums', authenticate, async (req: Request, res: Response) => { router.post('/trips/:tripId/album-links/:linkId/sync', authenticate, async (req: Request, res: Response) => { const authReq = req as AuthRequest; const { tripId, linkId } = req.params; - const result = await syncAlbumAssets(tripId, linkId, authReq.user.id); + const sid = req.headers['x-socket-id'] as string; + const result = await syncAlbumAssets(tripId, linkId, authReq.user.id, sid); if (result.error) return res.status(result.status!).json({ error: result.error }); res.json({ success: true, added: result.added, total: result.total }); if (result.added! > 0) { diff --git a/server/src/routes/memories/synology.ts b/server/src/routes/memories/synology.ts index 94ddd88..1c7eb4c 100644 --- a/server/src/routes/memories/synology.ts +++ b/server/src/routes/memories/synology.ts @@ -72,8 +72,9 @@ router.get('/albums', authenticate, async (req: Request, res: Response) => { router.post('/trips/:tripId/album-links/:linkId/sync', authenticate, async (req: Request, res: Response) => { const authReq = req as AuthRequest; const { tripId, linkId } = req.params; + const sid = req.headers['x-socket-id'] as string; - handleServiceResult(res, await syncSynologyAlbumLink(authReq.user.id, tripId, linkId)); + handleServiceResult(res, await syncSynologyAlbumLink(authReq.user.id, tripId, sid, linkId)); }); router.post('/search', authenticate, async (req: Request, res: Response) => { diff --git a/server/src/services/memories/immichService.ts b/server/src/services/memories/immichService.ts index da0dca0..5f8138d 100644 --- a/server/src/services/memories/immichService.ts +++ b/server/src/services/memories/immichService.ts @@ -343,7 +343,8 @@ export function deleteAlbumLink(linkId: string, tripId: string, userId: number) export async function syncAlbumAssets( tripId: string, linkId: string, - userId: number + userId: number, + sid: string, ): Promise<{ success?: boolean; added?: number; total?: number; error?: string; status?: number }> { const response = getAlbumIdFromLink(tripId, linkId, userId); if (!response.success) return { error: 'Album link not found', status: 404 }; @@ -365,7 +366,7 @@ export async function syncAlbumAssets( asset_ids: assets.map((a: any) => a.id), }; - const result = await addTripPhotos(tripId, userId, true, [selection]); + const result = await addTripPhotos(tripId, userId, true, [selection], sid, linkId); if ('error' in result) return { error: result.error.message, status: result.error.status }; updateSyncTimeForAlbumLink(linkId); diff --git a/server/src/services/memories/synologyService.ts b/server/src/services/memories/synologyService.ts index 76b8916..b9bdff2 100644 --- a/server/src/services/memories/synologyService.ts +++ b/server/src/services/memories/synologyService.ts @@ -348,7 +348,7 @@ export async function listSynologyAlbums(userId: number): Promise> { +export async function syncSynologyAlbumLink(userId: number, tripId: string, linkId: string, sid: string): Promise> { const response = getAlbumIdFromLink(tripId, linkId, userId); if (!response.success) return response as ServiceResult; @@ -380,11 +380,12 @@ export async function syncSynologyAlbumLink(userId: number, tripId: string, link asset_ids: allItems.map(item => String(item.additional?.thumbnail?.cache_key || '')).filter(id => id), }; - updateSyncTimeForAlbumLink(linkId); - - const result = await addTripPhotos(tripId, userId, true, [selection]); + + const result = await addTripPhotos(tripId, userId, true, [selection], sid, linkId); if (!result.success) return result as ServiceResult; - + + updateSyncTimeForAlbumLink(linkId); + return success({ added: result.data.added, total: allItems.length }); } diff --git a/server/src/services/memories/unifiedService.ts b/server/src/services/memories/unifiedService.ts index 35d47eb..b3918b8 100644 --- a/server/src/services/memories/unifiedService.ts +++ b/server/src/services/memories/unifiedService.ts @@ -67,10 +67,10 @@ export function listTripAlbumLinks(tripId: string, userId: number): ServiceResul //----------------------------------------------- // managing photos in trip -function _addTripPhoto(tripId: string, userId: number, provider: string, assetId: string, shared: boolean): boolean { +function _addTripPhoto(tripId: string, userId: number, provider: string, assetId: string, shared: boolean, albumLinkId?: string): boolean { const result = db.prepare( - 'INSERT OR IGNORE INTO trip_photos (trip_id, user_id, asset_id, provider, shared) VALUES (?, ?, ?, ?, ?)' - ).run(tripId, userId, assetId, provider, shared ? 1 : 0); + 'INSERT OR IGNORE INTO trip_photos (trip_id, user_id, asset_id, provider, shared, album_link_id) VALUES (?, ?, ?, ?, ?, ?)' + ).run(tripId, userId, assetId, provider, shared ? 1 : 0, albumLinkId || null); return result.changes > 0; } @@ -79,7 +79,8 @@ export async function addTripPhotos( userId: number, shared: boolean, selections: Selection[], - sid?: string, + sid: string, + albumLinkId?: string, ): Promise> { const access = canAccessTrip(tripId, userId); if (!access) { @@ -96,7 +97,7 @@ export async function addTripPhotos( for (const raw of selection.asset_ids) { const assetId = String(raw || '').trim(); if (!assetId) continue; - if (_addTripPhoto(tripId, userId, selection.provider, assetId, shared)) { + if (_addTripPhoto(tripId, userId, selection.provider, assetId, shared, albumLinkId)) { added++; } } @@ -213,9 +214,10 @@ export function removeAlbumLink(tripId: string, linkId: string, userId: number): } try { + db.prepare('DELETE FROM trip_photos WHERE trip_id = ? AND album_link_id = ?') + .run(tripId, linkId); db.prepare('DELETE FROM trip_album_links WHERE id = ? AND trip_id = ? AND user_id = ?') .run(linkId, tripId, userId); - return success(true); } catch (error) { return mapDbError(error, 'Failed to remove album link'); From 554a7d7530c0600b30768df62db22d5b27e14fe7 Mon Sep 17 00:00:00 2001 From: Marek Maslowski <99432678+tiquis0290@users.noreply.github.com> Date: Sat, 4 Apr 2026 19:56:02 +0200 Subject: [PATCH 40/57] changing back to download tokens are no longer used here --- client/src/api/authUrl.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/client/src/api/authUrl.ts b/client/src/api/authUrl.ts index 240d130..cebd2f9 100644 --- a/client/src/api/authUrl.ts +++ b/client/src/api/authUrl.ts @@ -1,4 +1,4 @@ -export async function getAuthUrl(url: string, purpose: string): Promise { +export async function getAuthUrl(url: string, purpose: 'download'): Promise { if (!url) return url try { const resp = await fetch('/api/auth/resource-token', { From 20709d23eefd579efd3b0fdb7ea3163f2173e501 Mon Sep 17 00:00:00 2001 From: Marek Maslowski Date: Sat, 4 Apr 2026 20:30:21 +0200 Subject: [PATCH 41/57] fixes based on comment (missing api compatability and translation keys) --- server/src/routes/memories/unified.ts | 4 +- .../src/services/memories/helpersService.ts | 8 ++ .../src/services/memories/synologyService.ts | 16 ++-- .../src/services/memories/unifiedService.ts | 76 +++++++++++++------ 4 files changed, 71 insertions(+), 33 deletions(-) diff --git a/server/src/routes/memories/unified.ts b/server/src/routes/memories/unified.ts index 3e4561d..569bb2b 100644 --- a/server/src/routes/memories/unified.ts +++ b/server/src/routes/memories/unified.ts @@ -12,6 +12,7 @@ import { } from '../../services/memories/unifiedService'; import immichRouter from './immich'; import synologyRouter from './synology'; +import { Selection } from '../../services/memories/helpersService'; const router = express.Router(); @@ -33,13 +34,14 @@ router.post('/unified/trips/:tripId/photos', authenticate, async (req: Request, const authReq = req as AuthRequest; const { tripId } = req.params; const sid = req.headers['x-socket-id'] as string; + const selections: Selection[] = Array.isArray(req.body?.selections) ? req.body.selections : []; const shared = req.body?.shared === undefined ? true : !!req.body?.shared; const result = await addTripPhotos( tripId, authReq.user.id, shared, - req.body?.selections || [], + selections, sid, ); if ('error' in result) return res.status(result.error.status).json({ error: result.error.message }); diff --git a/server/src/services/memories/helpersService.ts b/server/src/services/memories/helpersService.ts index 161d7bd..2ef7cf4 100644 --- a/server/src/services/memories/helpersService.ts +++ b/server/src/services/memories/helpersService.ts @@ -2,6 +2,7 @@ import { Readable } from 'node:stream'; import { pipeline } from 'node:stream/promises'; import { Response } from 'express'; import { canAccessTrip, db } from "../../db/database"; +import { checkSsrf } from '../../utils/ssrfGuard'; // helpers for handling return types @@ -163,6 +164,13 @@ export function updateSyncTimeForAlbumLink(linkId: string): void { export async function pipeAsset(url: string, response: Response): Promise { try{ + + const SsrfResult = await checkSsrf(url); + if (!SsrfResult.allowed) { + response.status(400).json({ error: SsrfResult.error }); + response.end(); + return; + } const resp = await fetch(url); response.status(resp.status); diff --git a/server/src/services/memories/synologyService.ts b/server/src/services/memories/synologyService.ts index b9bdff2..c754ea9 100644 --- a/server/src/services/memories/synologyService.ts +++ b/server/src/services/memories/synologyService.ts @@ -1,7 +1,7 @@ import { Response } from 'express'; import { db } from '../../db/database'; -import { decrypt_api_key, maybe_encrypt_api_key } from '../apiKeyCrypto'; +import { decrypt_api_key, encrypt_api_key, maybe_encrypt_api_key } from '../apiKeyCrypto'; import { checkSsrf } from '../../utils/ssrfGuard'; import { addTripPhotos } from './unifiedService'; import { @@ -136,6 +136,10 @@ function _buildSynologyFormBody(params: ApiCallParams): URLSearchParams { async function _fetchSynologyJson(url: string, body: URLSearchParams): Promise> { const endpoint = _buildSynologyEndpoint(url); + const SsrfResult = await checkSsrf(endpoint); + if (!SsrfResult.allowed) { + return fail(SsrfResult.error, 400); + } const resp = await fetch(endpoint, { method: 'POST', headers: { @@ -226,9 +230,6 @@ function _normalizeSynologyPhotoInfo(item: SynologyPhotoItem): AssetInfo { }; } -function _cacheSynologySID(userId: number, sid: string): void { - db.prepare('UPDATE users SET synology_sid = ? WHERE id = ?').run(sid, userId); -} function _clearSynologySID(userId: number): void { db.prepare('UPDATE users SET synology_sid = NULL WHERE id = ?').run(userId); @@ -239,11 +240,11 @@ function _splitPackedSynologyId(rawId: string): { id: string; cacheKey: string; return { id, cacheKey: rawId, assetId: rawId }; } - async function _getSynologySession(userId: number): Promise> { const cachedSid = _readSynologyUser(userId, ['synology_sid']); if (cachedSid.success && cachedSid.data?.synology_sid) { - return success(cachedSid.data.synology_sid); + const decryptedSid = decrypt_api_key(cachedSid.data.synology_sid); + return success(decryptedSid); } const creds = _getSynologyCredentials(userId); @@ -257,7 +258,8 @@ async function _getSynologySession(userId: number): Promise; } - _cacheSynologySID(userId, resp.data); + const encrypted = encrypt_api_key(resp.data); + db.prepare('UPDATE users SET synology_sid = ? WHERE id = ?').run(encrypted, userId); return success(resp.data); } diff --git a/server/src/services/memories/unifiedService.ts b/server/src/services/memories/unifiedService.ts index b3918b8..0885f30 100644 --- a/server/src/services/memories/unifiedService.ts +++ b/server/src/services/memories/unifiedService.ts @@ -9,8 +9,16 @@ import { mapDbError, Selection, } from './helpersService'; +import { ca } from 'zod/locales'; + + +function _validProvider(provider: string): boolean { + const validProviders = ['immich', 'synologyphotos']; + return validProviders.includes(provider.toLowerCase()); +} + export function listTripPhotos(tripId: string, userId: number): ServiceResult { const access = canAccessTrip(tripId, userId); if (!access) { @@ -67,11 +75,19 @@ export function listTripAlbumLinks(tripId: string, userId: number): ServiceResul //----------------------------------------------- // managing photos in trip -function _addTripPhoto(tripId: string, userId: number, provider: string, assetId: string, shared: boolean, albumLinkId?: string): boolean { - const result = db.prepare( - 'INSERT OR IGNORE INTO trip_photos (trip_id, user_id, asset_id, provider, shared, album_link_id) VALUES (?, ?, ?, ?, ?, ?)' - ).run(tripId, userId, assetId, provider, shared ? 1 : 0, albumLinkId || null); - return result.changes > 0; +function _addTripPhoto(tripId: string, userId: number, provider: string, assetId: string, shared: boolean, albumLinkId?: string): ServiceResult { + if (!_validProvider(provider)) { + return fail(`Provider: "${provider}" is not supported`, 400); + } + try { + const result = db.prepare( + 'INSERT OR IGNORE INTO trip_photos (trip_id, user_id, asset_id, provider, shared, album_link_id) VALUES (?, ?, ?, ?, ?, ?)' + ).run(tripId, userId, assetId, provider, shared ? 1 : 0, albumLinkId || null); + return success(result.changes > 0); + } + catch (error) { + return mapDbError(error, 'Failed to add photo to trip'); + } } export async function addTripPhotos( @@ -91,24 +107,27 @@ export async function addTripPhotos( return fail('No photos selected', 400); } - try { - let added = 0; - for (const selection of selections) { - for (const raw of selection.asset_ids) { - const assetId = String(raw || '').trim(); - if (!assetId) continue; - if (_addTripPhoto(tripId, userId, selection.provider, assetId, shared, albumLinkId)) { - added++; - } + let added = 0; + for (const selection of selections) { + if (!_validProvider(selection.provider)) { + return fail(`Provider: "${selection.provider}" is not supported`, 400); + } + for (const raw of selection.asset_ids) { + const assetId = String(raw || '').trim(); + if (!assetId) continue; + const result = _addTripPhoto(tripId, userId, selection.provider, assetId, shared, albumLinkId); + if (!result.success) { + return result as ServiceResult<{ added: number; shared: boolean }>; + } + if (result.data) { + added++; } } - - await _notifySharedTripPhotos(tripId, userId, added); - broadcast(tripId, 'memories:updated', { userId }, sid); - return success({ added, shared }); - } catch (error) { - return mapDbError(error, 'Failed to add trip photos'); } + + await _notifySharedTripPhotos(tripId, userId, added); + broadcast(tripId, 'memories:updated', { userId }, sid); + return success({ added, shared }); } @@ -163,7 +182,7 @@ export function removeTripPhoto( AND asset_id = ? AND provider = ? `).run(tripId, userId, assetId, provider); - + broadcast(tripId, 'memories:updated', { userId }, sid); return success(true); @@ -192,6 +211,11 @@ export function createTripAlbumLink(tripId: string, userId: number, providerRaw: return fail('album_id required', 400); } + + if (!_validProvider(provider)) { + return fail(`Provider: "${provider}" is not supported`, 400); + } + try { const result = db.prepare( 'INSERT OR IGNORE INTO trip_album_links (trip_id, user_id, provider, album_id, album_name) VALUES (?, ?, ?, ?, ?)' @@ -214,10 +238,12 @@ export function removeAlbumLink(tripId: string, linkId: string, userId: number): } try { - db.prepare('DELETE FROM trip_photos WHERE trip_id = ? AND album_link_id = ?') - .run(tripId, linkId); - db.prepare('DELETE FROM trip_album_links WHERE id = ? AND trip_id = ? AND user_id = ?') - .run(linkId, tripId, userId); + db.transaction(() => { + db.prepare('DELETE FROM trip_photos WHERE trip_id = ? AND album_link_id = ?') + .run(tripId, linkId); + db.prepare('DELETE FROM trip_album_links WHERE id = ? AND trip_id = ? AND user_id = ?') + .run(linkId, tripId, userId); + }); return success(true); } catch (error) { return mapDbError(error, 'Failed to remove album link'); From f8cf37a9bd8dc98050ec3a9798a20b94a8538be5 Mon Sep 17 00:00:00 2001 From: Marek Maslowski Date: Sat, 4 Apr 2026 20:50:45 +0200 Subject: [PATCH 42/57] adding checks when loading added photos/albums that the provider is enabled --- .../src/services/memories/unifiedService.ts | 58 ++++++++++++++----- 1 file changed, 45 insertions(+), 13 deletions(-) diff --git a/server/src/services/memories/unifiedService.ts b/server/src/services/memories/unifiedService.ts index 0885f30..8c96624 100644 --- a/server/src/services/memories/unifiedService.ts +++ b/server/src/services/memories/unifiedService.ts @@ -9,16 +9,29 @@ import { mapDbError, Selection, } from './helpersService'; -import { ca } from 'zod/locales'; +import { ca, fa } from 'zod/locales'; +function _providers(): Array<{id: string; enabled: boolean}> { + const rows = db.prepare('SELECT id, enabled FROM photo_providers').all() as Array<{id: string; enabled: number}>; + return rows.map(r => ({ id: r.id, enabled: r.enabled === 1 })); +} - -function _validProvider(provider: string): boolean { - const validProviders = ['immich', 'synologyphotos']; - return validProviders.includes(provider.toLowerCase()); +function _validProvider(provider: string): ServiceResult { + const providers = _providers(); + const found = providers.find(p => p.id === provider); + if (!found) { + return fail(`Provider: "${provider}" is not supported`, 400); + } + if (!found.enabled) { + return fail(`Provider: "${provider}" is not enabled, contact server administrator`, 400); + } + return success(provider); } + + + export function listTripPhotos(tripId: string, userId: number): ServiceResult { const access = canAccessTrip(tripId, userId); if (!access) { @@ -26,6 +39,13 @@ export function listTripPhotos(tripId: string, userId: number): ServiceResult p.enabled).map(p => p.id); + + if (enabledProviders.length === 0) { + return fail('No photo providers enabled', 400); + } + const photos = db.prepare(` SELECT tp.asset_id, tp.provider, tp.user_id, tp.shared, tp.added_at, u.username, u.avatar @@ -33,8 +53,9 @@ export function listTripPhotos(tripId: string, userId: number): ServiceResult '?').join(',')}) ORDER BY tp.added_at ASC - `).all(tripId, userId) as any[]; + `).all(tripId, userId, ...enabledProviders); return success(photos); } catch (error) { @@ -48,6 +69,13 @@ export function listTripAlbumLinks(tripId: string, userId: number): ServiceResul return fail('Trip not found or access denied', 404); } + + const enabledProviders = _providers().filter(p => p.enabled).map(p => p.id); + + if (enabledProviders.length === 0) { + return fail('No photo providers enabled', 400); + } + try { const links = db.prepare(` SELECT tal.id, @@ -63,8 +91,9 @@ export function listTripAlbumLinks(tripId: string, userId: number): ServiceResul FROM trip_album_links tal JOIN users u ON tal.user_id = u.id WHERE tal.trip_id = ? + AND tal.provider IN (${enabledProviders.map(() => '?').join(',')}) ORDER BY tal.created_at ASC - `).all(tripId); + `).all(tripId, ...enabledProviders); return success(links); } catch (error) { @@ -76,8 +105,9 @@ export function listTripAlbumLinks(tripId: string, userId: number): ServiceResul // managing photos in trip function _addTripPhoto(tripId: string, userId: number, provider: string, assetId: string, shared: boolean, albumLinkId?: string): ServiceResult { - if (!_validProvider(provider)) { - return fail(`Provider: "${provider}" is not supported`, 400); + const providerResult = _validProvider(provider); + if (!providerResult.success) { + return providerResult as ServiceResult; } try { const result = db.prepare( @@ -109,8 +139,9 @@ export async function addTripPhotos( let added = 0; for (const selection of selections) { - if (!_validProvider(selection.provider)) { - return fail(`Provider: "${selection.provider}" is not supported`, 400); + const providerResult = _validProvider(selection.provider); + if (!providerResult.success) { + return providerResult as ServiceResult<{ added: number; shared: boolean }>; } for (const raw of selection.asset_ids) { const assetId = String(raw || '').trim(); @@ -212,8 +243,9 @@ export function createTripAlbumLink(tripId: string, userId: number, providerRaw: } - if (!_validProvider(provider)) { - return fail(`Provider: "${provider}" is not supported`, 400); + const providerResult = _validProvider(provider); + if (!providerResult.success) { + return providerResult as ServiceResult; } try { From c9e3185ad00f341e4da93f9f473f10d3a036f1a0 Mon Sep 17 00:00:00 2001 From: Marek Maslowski Date: Sat, 4 Apr 2026 20:51:07 +0200 Subject: [PATCH 43/57] cleaning imports --- server/src/services/memories/unifiedService.ts | 2 -- 1 file changed, 2 deletions(-) diff --git a/server/src/services/memories/unifiedService.ts b/server/src/services/memories/unifiedService.ts index 8c96624..72fbcb8 100644 --- a/server/src/services/memories/unifiedService.ts +++ b/server/src/services/memories/unifiedService.ts @@ -1,7 +1,6 @@ import { db, canAccessTrip } from '../../db/database'; import { notifyTripMembers } from '../notifications'; import { broadcast } from '../../websocket'; - import { ServiceResult, fail, @@ -9,7 +8,6 @@ import { mapDbError, Selection, } from './helpersService'; -import { ca, fa } from 'zod/locales'; function _providers(): Array<{id: string; enabled: boolean}> { From 3413d3f77d423353f4b10072333b1e1b031cec2a Mon Sep 17 00:00:00 2001 From: Marek Maslowski Date: Sat, 4 Apr 2026 22:00:35 +0200 Subject: [PATCH 44/57] fixing labels in english --- .../src/components/Memories/MemoriesPanel.tsx | 29 ++++++++++++------- client/src/i18n/translations/en.ts | 29 ++++++++++++------- client/src/pages/SettingsPage.tsx | 12 ++++---- server/src/db/seeds.ts | 10 +++---- 4 files changed, 49 insertions(+), 31 deletions(-) diff --git a/client/src/components/Memories/MemoriesPanel.tsx b/client/src/components/Memories/MemoriesPanel.tsx index 5155c03..4c54dad 100644 --- a/client/src/components/Memories/MemoriesPanel.tsx +++ b/client/src/components/Memories/MemoriesPanel.tsx @@ -60,6 +60,7 @@ export default function MemoriesPanel({ tripId, startDate, endDate }: MemoriesPa const currentUser = useAuthStore(s => s.user) const [connected, setConnected] = useState(false) + const [enabledProviders, setEnabledProviders] = useState([]) const [availableProviders, setAvailableProviders] = useState([]) const [selectedProvider, setSelectedProvider] = useState('') const [loading, setLoading] = useState(true) @@ -198,6 +199,8 @@ export default function MemoriesPanel({ tripId, startDate, endDate }: MemoriesPa const enabledAddons = addonsRes?.addons || [] const photoProviders = enabledAddons.filter((a: any) => a.type === 'photo_provider' && a.enabled) + setEnabledProviders(photoProviders.map((a: any) => ({ id: a.id, name: a.name, icon: a.icon, config: a.config }))) + // Test connection status for each enabled provider const statusResults = await Promise.all( photoProviders.map(async (provider: any) => { @@ -389,10 +392,10 @@ export default function MemoriesPanel({ tripId, startDate, endDate }: MemoriesPa

- {t('memories.notConnected')} + {t('memories.notConnected', { provider_name: enabledProviders.length === 1 ? enabledProviders[0]?.name : 'Photo provider' })}

- {t('memories.notConnectedHint')} + {enabledProviders.length === 1 ? t('memories.notConnectedHint', { provider_name: enabledProviders[0]?.name }) : t('memories.notConnectedMultipleHint', { provider_names: enabledProviders.map(p => p.name).join(', ') })}

) @@ -439,14 +442,14 @@ export default function MemoriesPanel({ tripId, startDate, endDate }: MemoriesPa

- {t('memories.selectAlbum')} + {availableProviders.length > 1 ? t('memories.selectAlbumMultiple') : t('memories.selectAlbum', { provider_name: availableProviders.find(p => p.id === selectedProvider)?.name || 'Photo provider' })}

-
+
{albumsLoading ? ( @@ -511,9 +514,8 @@ export default function MemoriesPanel({ tripId, startDate, endDate }: MemoriesPa

- {t('memories.selectPhotos')} + {availableProviders.length > 1 ? t('memories.selectPhotosMultiple') : t('memories.selectPhotos', { provider_name: availableProviders.find(p => p.id === selectedProvider)?.name || 'Photo provider' })}

-
+
+ +
{/* Filter tabs */}
{startDate && endDate && ( @@ -573,6 +578,13 @@ export default function MemoriesPanel({ tripId, startDate, endDate }: MemoriesPa

{t('memories.noPhotos')}

+ { + pickerDateFilter && ( +

+ {t('memories.noPhotosHint', { provider_name: availableProviders.find(p => p.id === selectedProvider)?.name || 'Photo provider' })} +

+ ) + }
) : (() => { // Group photos by month @@ -761,12 +773,9 @@ export default function MemoriesPanel({ tripId, startDate, endDate }: MemoriesPa {allVisible.length === 0 ? (
-

+

{t('memories.noPhotos')}

-

- {t('memories.noPhotosHint')} -

- - {immichConnected && ( - - - {t('memories.connected')} - - )} -
-
- - )} - + {mcpEnabled && (
{/* Endpoint URL */} diff --git a/client/src/components/Settings/PhotoProvidersSection.tsx b/client/src/components/Settings/PhotoProvidersSection.tsx new file mode 100644 index 0000000..4c00e29 --- /dev/null +++ b/client/src/components/Settings/PhotoProvidersSection.tsx @@ -0,0 +1,248 @@ +import React, { useEffect, useMemo, useState } from 'react' +import { Camera, Save } from 'lucide-react' +import { useTranslation } from '../../i18n' +import { useToast } from '../../components/shared/Toast' +import apiClient from '../../api/client' +import { useAddonStore } from '../../store/addonStore' +import Section from './Section' + +interface ProviderField { + key: string + label: string + input_type: string + placeholder?: string | null + required: boolean + secret: boolean + settings_key?: string | null + payload_key?: string | null + sort_order: number +} + +interface PhotoProviderAddon { + id: string + name: string + type: string + enabled: boolean + config?: Record + fields?: ProviderField[] +} + +interface ProviderConfig { + settings_get?: string + settings_put?: string + status_get?: string + test_get?: string + test_post?: string +} + +const getProviderConfig = (provider: PhotoProviderAddon): ProviderConfig => { + const raw = provider.config || {} + return { + settings_get: typeof raw.settings_get === 'string' ? raw.settings_get : undefined, + settings_put: typeof raw.settings_put === 'string' ? raw.settings_put : undefined, + status_get: typeof raw.status_get === 'string' ? raw.status_get : undefined, + test_get: typeof raw.test_get === 'string' ? raw.test_get : undefined, + test_post: typeof raw.test_post === 'string' ? raw.test_post : undefined, + } +} + +const getProviderFields = (provider: PhotoProviderAddon): ProviderField[] => { + return [...(provider.fields || [])].sort((a, b) => a.sort_order - b.sort_order) +} + +export default function PhotoProvidersSection(): React.ReactElement { + const { t } = useTranslation() + const toast = useToast() + const { isEnabled: addonEnabled, addons } = useAddonStore() + const memoriesEnabled = addonEnabled('memories') + + const [saving, setSaving] = useState>({}) + const [providerValues, setProviderValues] = useState>>({}) + const [providerConnected, setProviderConnected] = useState>({}) + const [providerTesting, setProviderTesting] = useState>({}) + + const activePhotoProviders = useMemo( + () => addons.filter(a => a.type === 'photo_provider' && a.enabled) as PhotoProviderAddon[], + [addons], + ) + + const buildProviderPayload = (provider: PhotoProviderAddon): Record => { + const values = providerValues[provider.id] || {} + const payload: Record = {} + for (const field of getProviderFields(provider)) { + const payloadKey = field.payload_key || field.settings_key || field.key + const value = (values[field.key] || '').trim() + if (field.secret && !value) continue + payload[payloadKey] = value + } + return payload + } + + const refreshProviderConnection = async (provider: PhotoProviderAddon) => { + const cfg = getProviderConfig(provider) + const statusPath = cfg.status_get + if (!statusPath) return + try { + const res = await apiClient.get(statusPath) + setProviderConnected(prev => ({ ...prev, [provider.id]: !!res.data?.connected })) + } catch { + setProviderConnected(prev => ({ ...prev, [provider.id]: false })) + } + } + + const activeProviderSignature = useMemo( + () => activePhotoProviders.map(provider => provider.id).join('|'), + [activePhotoProviders], + ) + + useEffect(() => { + let isCancelled = false + + for (const provider of activePhotoProviders) { + const cfg = getProviderConfig(provider) + const fields = getProviderFields(provider) + + if (cfg.settings_get) { + apiClient.get(cfg.settings_get).then(res => { + if (isCancelled) return + + const nextValues: Record = {} + for (const field of fields) { + // Do not prefill secret fields; user can overwrite only when needed. + if (field.secret) continue + const sourceKey = field.settings_key || field.payload_key || field.key + const rawValue = (res.data as Record)[sourceKey] + nextValues[field.key] = typeof rawValue === 'string' ? rawValue : rawValue != null ? String(rawValue) : '' + } + setProviderValues(prev => ({ + ...prev, + [provider.id]: { ...(prev[provider.id] || {}), ...nextValues }, + })) + if (typeof res.data?.connected === 'boolean') { + setProviderConnected(prev => ({ ...prev, [provider.id]: !!res.data.connected })) + } + }).catch(() => { }) + } + + refreshProviderConnection(provider).catch(() => { }) + } + + return () => { + isCancelled = true + } + }, [activePhotoProviders, activeProviderSignature]) + + const handleProviderFieldChange = (providerId: string, key: string, value: string) => { + setProviderValues(prev => ({ + ...prev, + [providerId]: { ...(prev[providerId] || {}), [key]: value }, + })) + } + + const isProviderSaveDisabled = (provider: PhotoProviderAddon): boolean => { + const values = providerValues[provider.id] || {} + return getProviderFields(provider).some(field => { + if (!field.required) return false + return !(values[field.key] || '').trim() + }) + } + + const handleSaveProvider = async (provider: PhotoProviderAddon) => { + const cfg = getProviderConfig(provider) + if (!cfg.settings_put) return + setSaving(s => ({ ...s, [provider.id]: true })) + try { + await apiClient.put(cfg.settings_put, buildProviderPayload(provider)) + await refreshProviderConnection(provider) + toast.success(t('memories.saved', { provider_name: provider.name })) + } catch { + toast.error(t('memories.saveError', { provider_name: provider.name })) + } finally { + setSaving(s => ({ ...s, [provider.id]: false })) + } + } + + const handleTestProvider = async (provider: PhotoProviderAddon) => { + const cfg = getProviderConfig(provider) + const testPath = cfg.test_post || cfg.test_get || cfg.status_get + if (!testPath) return + setProviderTesting(prev => ({ ...prev, [provider.id]: true })) + try { + const payload = buildProviderPayload(provider) + const res = cfg.test_post ? await apiClient.post(testPath, payload) : await apiClient.get(testPath) + const ok = !!res.data?.connected + setProviderConnected(prev => ({ ...prev, [provider.id]: ok })) + if (ok) { + toast.success(t('memories.connectionSuccess', { provider_name: provider.name })) + } else { + toast.error(`${t('memories.connectionError', { provider_name: provider.name })} ${res.data?.error ? `: ${String(res.data.error)}` : ''}`) + } + } catch { + toast.error(t('memories.connectionError', { provider_name: provider.name })) + } finally { + setProviderTesting(prev => ({ ...prev, [provider.id]: false })) + } + } + + const renderPhotoProviderSection = (provider: PhotoProviderAddon): React.ReactElement => { + const fields = getProviderFields(provider) + const cfg = getProviderConfig(provider) + const values = providerValues[provider.id] || {} + const connected = !!providerConnected[provider.id] + const testing = !!providerTesting[provider.id] + const canSave = !!cfg.settings_put + const canTest = !!(cfg.test_post || cfg.test_get || cfg.status_get) + + return ( +
+
+ {fields.map(field => ( +
+ + handleProviderFieldChange(provider.id, field.key, e.target.value)} + placeholder={field.secret && connected && !(values[field.key] || '') ? '••••••••' : (field.placeholder || '')} + className="w-full px-3 py-2.5 border border-slate-200 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-slate-300" + /> +
+ ))} +
+ + + {connected && ( + + + {t('memories.connected')} + + )} +
+
+
+ ) + } + + if (!memoriesEnabled) { + return <> + } + + return <>{activePhotoProviders.map(provider => renderPhotoProviderSection(provider))} +} diff --git a/client/src/pages/SettingsPage.tsx b/client/src/pages/SettingsPage.tsx index 456ccb4..6805545 100644 --- a/client/src/pages/SettingsPage.tsx +++ b/client/src/pages/SettingsPage.tsx @@ -4,11 +4,6 @@ import { Settings } from 'lucide-react' import { useTranslation } from '../i18n' import { authApi } from '../api/client' import { useAddonStore } from '../store/addonStore' -import type { LucideIcon } from 'lucide-react' -import type { UserWithOidc } from '../types' -import { getApiErrorMessage } from '../types' -import { MapView } from '../components/Map/MapView' -import type { Place } from '../types' import Navbar from '../components/Layout/Navbar' import DisplaySettingsTab from '../components/Settings/DisplaySettingsTab' import MapSettingsTab from '../components/Settings/MapSettingsTab' @@ -17,588 +12,22 @@ import IntegrationsTab from '../components/Settings/IntegrationsTab' import AccountTab from '../components/Settings/AccountTab' import AboutTab from '../components/Settings/AboutTab' -interface MapPreset { - name: string - url: string -} - -const MFA_BACKUP_SESSION_KEY = 'trek_mfa_backup_codes_pending' -interface McpToken { - id: number - name: string - token_prefix: string - created_at: string - last_used_at: string | null -} - -interface ProviderField { - key: string - label: string - input_type: string - placeholder?: string | null - required: boolean - secret: boolean - settings_key?: string | null - payload_key?: string | null - sort_order: number -} - -interface PhotoProviderAddon { - id: string - name: string - type: string - enabled: boolean - config?: Record - fields?: ProviderField[] -} - -interface ProviderConfig { - settings_get?: string - settings_put?: string - status_get?: string - test_get?: string - test_post?: string -} - -const MAP_PRESETS: MapPreset[] = [ - { name: 'OpenStreetMap', url: 'https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png' }, - { name: 'OpenStreetMap DE', url: 'https://tile.openstreetmap.de/{z}/{x}/{y}.png' }, - { name: 'CartoDB Light', url: 'https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png' }, - { name: 'CartoDB Dark', url: 'https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png' }, - { name: 'Stadia Smooth', url: 'https://tiles.stadiamaps.com/tiles/alidade_smooth/{z}/{x}/{y}{r}.png' }, -] - -interface SectionProps { - title: string - icon: LucideIcon - children: React.ReactNode -} - -function Section({ title, icon: Icon, children }: SectionProps): React.ReactElement { - return ( -
-
- -

{title}

-
-
- {children} -
-
- ) -} - -function ToggleSwitch({ on, onToggle }: { on: boolean; onToggle: () => void }) { - return ( - - ) -} - -function NotificationPreferences({ t }: { t: any; memoriesEnabled: boolean }) { - const [notifChannel, setNotifChannel] = useState('none') - useEffect(() => { - authApi.getAppConfig?.().then((cfg: any) => { - if (cfg?.notification_channel) setNotifChannel(cfg.notification_channel) - }).catch(() => {}) - }, []) - - if (notifChannel === 'none') { - return ( -

- {t('settings.notificationsDisabled')} -

- ) - } - - const channelLabel = notifChannel === 'email' - ? (t('admin.notifications.email') || 'Email (SMTP)') - : (t('admin.notifications.webhook') || 'Webhook') - - return ( -
-
- - - {t('settings.notificationsActive')}: {channelLabel} - -
-

- {t('settings.notificationsManagedByAdmin')} -

-
- ) -} - export default function SettingsPage(): React.ReactElement { const { t } = useTranslation() const [searchParams] = useSearchParams() - const [showDeleteConfirm, setShowDeleteConfirm] = useState(false) - const avatarInputRef = React.useRef(null) - const { settings, updateSetting, updateSettings } = useSettingsStore() - const { isEnabled: addonEnabled, loadAddons, addons } = useAddonStore() - const { t, locale } = useTranslation() - const toast = useToast() - const navigate = useNavigate() + const { isEnabled: addonEnabled, loadAddons } = useAddonStore() const memoriesEnabled = addonEnabled('memories') const mcpEnabled = addonEnabled('mcp') - const [appVersion, setAppVersion] = useState(null) - useEffect(() => { - authApi.getAppConfig?.().then(c => setAppVersion(c?.version)).catch(() => {}) - }, []) - const activePhotoProviders = addons.filter(a => a.type === 'photo_provider' && a.enabled) - const [providerValues, setProviderValues] = useState>>({}) - const [providerConnected, setProviderConnected] = useState>({}) - const [providerTesting, setProviderTesting] = useState>({}) const hasIntegrations = memoriesEnabled || mcpEnabled + + const [appVersion, setAppVersion] = useState(null) const [activeTab, setActiveTab] = useState('display') useEffect(() => { loadAddons() authApi.getAppConfig?.().then(c => setAppVersion(c?.version)).catch(() => {}) }, []) - const getProviderConfig = (provider: PhotoProviderAddon): ProviderConfig => { - const raw = provider.config || {} - return { - settings_get: typeof raw.settings_get === 'string' ? raw.settings_get : undefined, - settings_put: typeof raw.settings_put === 'string' ? raw.settings_put : undefined, - status_get: typeof raw.status_get === 'string' ? raw.status_get : undefined, - test_get: typeof raw.test_get === 'string' ? raw.test_get : undefined, - test_post: typeof raw.test_post === 'string' ? raw.test_post : undefined, - } - } - - const getProviderFields = (provider: PhotoProviderAddon): ProviderField[] => { - return [...(provider.fields || [])].sort((a, b) => a.sort_order - b.sort_order) - } - - const buildProviderPayload = (provider: PhotoProviderAddon): Record => { - const values = providerValues[provider.id] || {} - const payload: Record = {} - for (const field of getProviderFields(provider)) { - const payloadKey = field.payload_key || field.settings_key || field.key - const value = (values[field.key] || '').trim() - if (field.secret && !value) continue - payload[payloadKey] = value - } - return payload - } - - const refreshProviderConnection = async (provider: PhotoProviderAddon) => { - const cfg = getProviderConfig(provider) - const statusPath = cfg.status_get - if (!statusPath) return - try { - const res = await apiClient.get(statusPath) - setProviderConnected(prev => ({ ...prev, [provider.id]: !!res.data?.connected })) - } catch { - setProviderConnected(prev => ({ ...prev, [provider.id]: false })) - } - } - - const activeProviderSignature = activePhotoProviders.map(p => p.id).join('|') - - useEffect(() => { - for (const provider of activePhotoProviders as PhotoProviderAddon[]) { - const cfg = getProviderConfig(provider) - const fields = getProviderFields(provider) - if (cfg.settings_get) { - apiClient.get(cfg.settings_get).then(res => { - const nextValues: Record = {} - for (const field of fields) { - // Don't populate secret fields into state - they should remain empty until user edits - if (field.secret) continue - const sourceKey = field.settings_key || field.payload_key || field.key - const rawValue = (res.data as Record)[sourceKey] - nextValues[field.key] = typeof rawValue === 'string' ? rawValue : rawValue != null ? String(rawValue) : '' - } - setProviderValues(prev => ({ - ...prev, - [provider.id]: { ...(prev[provider.id] || {}), ...nextValues }, - })) - if (typeof res.data?.connected === 'boolean') { - setProviderConnected(prev => ({ ...prev, [provider.id]: !!res.data.connected })) - } - }).catch(() => {}) - } - refreshProviderConnection(provider).catch(() => {}) - } - }, [activeProviderSignature]) - - const handleProviderFieldChange = (providerId: string, key: string, value: string) => { - setProviderValues(prev => ({ - ...prev, - [providerId]: { ...(prev[providerId] || {}), [key]: value }, - })) - } - - const isProviderSaveDisabled = (provider: PhotoProviderAddon): boolean => { - const values = providerValues[provider.id] || {} - return getProviderFields(provider).some(field => { - if (!field.required) return false - return !(values[field.key] || '').trim() - }) - } - - const handleSaveProvider = async (provider: PhotoProviderAddon) => { - const cfg = getProviderConfig(provider) - if (!cfg.settings_put) return - setSaving(s => ({ ...s, [provider.id]: true })) - try { - await apiClient.put(cfg.settings_put, buildProviderPayload(provider)) - await refreshProviderConnection(provider) - toast.success(t('memories.saved', { provider_name: provider.name })) - } catch { - toast.error(t('memories.saveError', { provider_name: provider.name })) - } finally { - setSaving(s => ({ ...s, [provider.id]: false })) - } - } - - const handleTestProvider = async (provider: PhotoProviderAddon) => { - const cfg = getProviderConfig(provider) - const testPath = cfg.test_post || cfg.test_get || cfg.status_get - if (!testPath) return - setProviderTesting(prev => ({ ...prev, [provider.id]: true })) - try { - const payload = buildProviderPayload(provider) - const res = cfg.test_post ? await apiClient.post(testPath, payload) : await apiClient.get(testPath) - const ok = !!res.data?.connected - setProviderConnected(prev => ({ ...prev, [provider.id]: ok })) - if (ok) { - toast.success(t('memories.connectionSuccess', { provider_name: provider.name })) - } else { - toast.error(`${t('memories.connectionError', { provider_name: provider.name })} ${res.data?.error ? `: ${String(res.data.error)}` : ''}`) - } - } catch { - toast.error(t('memories.connectionError', { provider_name: provider.name })) - } finally { - setProviderTesting(prev => ({ ...prev, [provider.id]: false })) - } - } - - // MCP tokens - const [mcpTokens, setMcpTokens] = useState([]) - const [mcpModalOpen, setMcpModalOpen] = useState(false) - const [mcpNewName, setMcpNewName] = useState('') - const [mcpCreatedToken, setMcpCreatedToken] = useState(null) - const [mcpCreating, setMcpCreating] = useState(false) - const [mcpDeleteId, setMcpDeleteId] = useState(null) - const [copiedKey, setCopiedKey] = useState(null) - - useEffect(() => { - authApi.mcpTokens.list().then(d => setMcpTokens(d.tokens || [])).catch(() => {}) - }, []) - - const handleCreateMcpToken = async () => { - if (!mcpNewName.trim()) return - setMcpCreating(true) - try { - const d = await authApi.mcpTokens.create(mcpNewName.trim()) - setMcpCreatedToken(d.token.raw_token) - setMcpNewName('') - setMcpTokens(prev => [{ id: d.token.id, name: d.token.name, token_prefix: d.token.token_prefix, created_at: d.token.created_at, last_used_at: null }, ...prev]) - } catch { - toast.error(t('settings.mcp.toast.createError')) - } finally { - setMcpCreating(false) - } - } - - const handleDeleteMcpToken = async (id: number) => { - try { - await authApi.mcpTokens.delete(id) - setMcpTokens(prev => prev.filter(tk => tk.id !== id)) - setMcpDeleteId(null) - toast.success(t('settings.mcp.toast.deleted')) - } catch { - toast.error(t('settings.mcp.toast.deleteError')) - } - } - - const handleCopy = (text: string, key: string) => { - navigator.clipboard.writeText(text).then(() => { - setCopiedKey(key) - setTimeout(() => setCopiedKey(null), 2000) - }) - } - - const mcpEndpoint = `${window.location.origin}/mcp` - const mcpJsonConfig = `{ - "mcpServers": { - "trek": { - "command": "npx", - "args": [ - "mcp-remote", - "${mcpEndpoint}", - "--header", - "Authorization: Bearer " - ] - } - } -}` - - const renderPhotoProviderSection = (provider: PhotoProviderAddon): React.ReactElement => { - const fields = getProviderFields(provider) - const cfg = getProviderConfig(provider) - const values = providerValues[provider.id] || {} - const connected = !!providerConnected[provider.id] - const testing = !!providerTesting[provider.id] - const canSave = !!cfg.settings_put - const canTest = !!(cfg.test_post || cfg.test_get || cfg.status_get) - - return ( -
-
- {fields.map(field => ( -
- - handleProviderFieldChange(provider.id, field.key, e.target.value)} - placeholder={field.secret && connected && !(values[field.key] || '') ? '••••••••' : (field.placeholder || '')} - className="w-full px-3 py-2.5 border border-slate-200 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-slate-300" - /> -
- ))} -
- - - {connected && ( - - - {t('memories.connected')} - - )} -
-
-
- ) - } - - // Map settings - const [mapTileUrl, setMapTileUrl] = useState(settings.map_tile_url || '') - const [defaultLat, setDefaultLat] = useState(settings.default_lat || 48.8566) - const [defaultLng, setDefaultLng] = useState(settings.default_lng || 2.3522) - const [defaultZoom, setDefaultZoom] = useState(settings.default_zoom || 10) - - const mapPlaces = useMemo(() => { - // Add center location to map places - let places: Place[] = [] - places.push({ - id: 1, - trip_id: 1, - name: "Default map center", - description: "", - lat: defaultLat as number, - lng: defaultLng as number, - address: "", - category_id: 0, - icon: null, - price: null, - image_url: null, - google_place_id: null, - osm_id: null, - route_geometry: null, - place_time: null, - end_time: null, - created_at: Date() - }); - return places - }, [defaultLat, defaultLng]) - - // Display - const [tempUnit, setTempUnit] = useState(settings.temperature_unit || 'celsius') - - // Account - const [username, setUsername] = useState(user?.username || '') - const [email, setEmail] = useState(user?.email || '') - const [currentPassword, setCurrentPassword] = useState('') - const [newPassword, setNewPassword] = useState('') - const [confirmPassword, setConfirmPassword] = useState('') - const [oidcOnlyMode, setOidcOnlyMode] = useState(false) - - useEffect(() => { - authApi.getAppConfig?.().then((config) => { - if (config?.oidc_only_mode) setOidcOnlyMode(true) - }).catch(() => {}) - }, []) - - const [mfaQr, setMfaQr] = useState(null) - const [mfaSecret, setMfaSecret] = useState(null) - const [mfaSetupCode, setMfaSetupCode] = useState('') - const [mfaDisablePwd, setMfaDisablePwd] = useState('') - const [mfaDisableCode, setMfaDisableCode] = useState('') - const [mfaLoading, setMfaLoading] = useState(false) - const mfaRequiredByPolicy = - !demoMode && - !user?.mfa_enabled && - (searchParams.get('mfa') === 'required' || appRequireMfa) - - const [backupCodes, setBackupCodes] = useState(null) - - const backupCodesText = backupCodes?.join('\n') || '' - - // Restore backup codes panel after refresh (loadUser silent fix + sessionStorage) - useEffect(() => { - if (!user?.mfa_enabled || backupCodes) return - try { - const raw = sessionStorage.getItem(MFA_BACKUP_SESSION_KEY) - if (!raw) return - const parsed = JSON.parse(raw) as unknown - if (Array.isArray(parsed) && parsed.length > 0 && parsed.every((x) => typeof x === 'string')) { - setBackupCodes(parsed) - } - } catch { - sessionStorage.removeItem(MFA_BACKUP_SESSION_KEY) - } - }, [user?.mfa_enabled, backupCodes]) - - const dismissBackupCodes = (): void => { - sessionStorage.removeItem(MFA_BACKUP_SESSION_KEY) - setBackupCodes(null) - } - - const copyBackupCodes = async (): Promise => { - if (!backupCodesText) return - try { - await navigator.clipboard.writeText(backupCodesText) - toast.success(t('settings.mfa.backupCopied')) - } catch { - toast.error(t('common.error')) - } - } - - const downloadBackupCodes = (): void => { - if (!backupCodesText) return - const blob = new Blob([backupCodesText + '\n'], { type: 'text/plain;charset=utf-8' }) - const url = URL.createObjectURL(blob) - const a = document.createElement('a') - a.href = url - a.download = 'trek-mfa-backup-codes.txt' - document.body.appendChild(a) - a.click() - a.remove() - URL.revokeObjectURL(url) - } - - const printBackupCodes = (): void => { - if (!backupCodesText) return - const html = `TREK MFA Backup Codes - -

TREK MFA Backup Codes

${new Date().toLocaleString()}

${backupCodesText}
` - const w = window.open('', '_blank', 'width=900,height=700') - if (!w) return - w.document.open() - w.document.write(html) - w.document.close() - w.focus() - w.print() - } - - useEffect(() => { - setMapTileUrl(settings.map_tile_url || '') - setDefaultLat(settings.default_lat || 48.8566) - setDefaultLng(settings.default_lng || 2.3522) - setDefaultZoom(settings.default_zoom || 10) - setTempUnit(settings.temperature_unit || 'celsius') - }, [settings]) - - useEffect(() => { - setUsername(user?.username || '') - setEmail(user?.email || '') - }, [user]) - - const saveMapSettings = async (): Promise => { - setSaving(s => ({ ...s, map: true })) - try { - await updateSettings({ - map_tile_url: mapTileUrl, - default_lat: parseFloat(String(defaultLat)), - default_lng: parseFloat(String(defaultLng)), - default_zoom: parseInt(String(defaultZoom)), - }) - toast.success(t('settings.toast.mapSaved')) - } catch (err: unknown) { - toast.error(err instanceof Error ? err.message : 'Error') - } finally { - setSaving(s => ({ ...s, map: false })) - } - } - - const saveDisplay = async (): Promise => { - setSaving(s => ({ ...s, display: true })) - try { - await updateSetting('temperature_unit', tempUnit) - toast.success(t('settings.toast.displaySaved')) - } catch (err: unknown) { - toast.error(err instanceof Error ? err.message : 'Error') - } finally { - setSaving(s => ({ ...s, display: false })) - } - } - - const handleAvatarUpload = async (e: React.ChangeEvent): Promise => { - const file = e.target.files?.[0] - if (!file) return - try { - await uploadAvatar(file) - toast.success(t('settings.avatarUploaded')) - } catch { - toast.error(t('settings.avatarError')) - } - if (avatarInputRef.current) avatarInputRef.current.value = '' - } - - const handleAvatarRemove = async (): Promise => { - try { - await deleteAvatar() - toast.success(t('settings.avatarRemoved')) - } catch { - toast.error(t('settings.avatarError')) - } - } - - const saveProfile = async (): Promise => { - setSaving(s => ({ ...s, profile: true })) - try { - await updateProfile({ username, email }) - toast.success(t('settings.toast.profileSaved')) - } catch (err: unknown) { - toast.error(err instanceof Error ? err.message : 'Error') - } finally { - setSaving(s => ({ ...s, profile: false })) - } - } // Auto-switch to account tab when MFA is required useEffect(() => { @@ -633,775 +62,6 @@ export default function SettingsPage(): React.ReactElement {
-
- - {/* Map settings */} -
-
- - { if (value) setMapTileUrl(value) }} - placeholder={t('settings.mapTemplatePlaceholder.select')} - options={MAP_PRESETS.map(p => ({ - value: p.url, - label: p.name, - }))} - size="sm" - style={{ marginBottom: 8 }} - /> - ) => setMapTileUrl(e.target.value)} - placeholder="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png" - className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm focus:ring-2 focus:ring-slate-400 focus:border-transparent" - /> -

{t('settings.mapDefaultHint')}

-
- -
-
- - ) => setDefaultLat(e.target.value)} - className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm focus:ring-2 focus:ring-slate-400 focus:border-transparent" - /> -
-
- - ) => setDefaultLng(e.target.value)} - className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm focus:ring-2 focus:ring-slate-400 focus:border-transparent" - /> -
-
- -
-
- -
-
- - -
- - {/* Display */} -
- {/* Dark Mode Toggle */} -
- -
- {[ - { value: 'light', label: t('settings.light'), icon: Sun }, - { value: 'dark', label: t('settings.dark'), icon: Moon }, - { value: 'auto', label: t('settings.auto'), icon: Monitor }, - ].map(opt => { - const current = settings.dark_mode - const isActive = current === opt.value || (opt.value === 'light' && current === false) || (opt.value === 'dark' && current === true) - return ( - - ) - })} -
-
- - {/* Sprache */} -
- -
- {SUPPORTED_LANGUAGES.map(opt => ( - - ))} -
-
- - {/* Temperature */} -
- -
- {[ - { value: 'celsius', label: '°C Celsius' }, - { value: 'fahrenheit', label: '°F Fahrenheit' }, - ].map(opt => ( - - ))} -
-
- - {/* Zeitformat */} -
- -
- {[ - { value: '24h', label: '24h (14:30)' }, - { value: '12h', label: '12h (2:30 PM)' }, - ].map(opt => ( - - ))} -
-
- {/* Route Calculation */} -
- -
- {[ - { value: true, label: t('settings.on') || 'On' }, - { value: false, label: t('settings.off') || 'Off' }, - ].map(opt => ( - - ))} -
-
- - {/* Blur Booking Codes */} -
- -
- {[ - { value: true, label: t('settings.on') || 'On' }, - { value: false, label: t('settings.off') || 'Off' }, - ].map(opt => ( - - ))} -
-
-
- - {/* Notifications */} -
- -
- - {activePhotoProviders.map(provider => renderPhotoProviderSection(provider as PhotoProviderAddon))} - - {/* MCP Configuration — only when MCP addon is enabled */} - {mcpEnabled &&
- {/* Endpoint URL */} -
- -
- - {mcpEndpoint} - - -
-
- - {/* JSON config box */} -
-
- - -
-
-                {mcpJsonConfig}
-              
-

{t('settings.mcp.clientConfigHint')}

-
- - {/* Token list */} -
-
- - -
- - {mcpTokens.length === 0 ? ( -

- {t('settings.mcp.noTokens')} -

- ) : ( -
- {mcpTokens.map((token, i) => ( -
-
-

{token.name}

-

- {token.token_prefix}... - {t('settings.mcp.tokenCreatedAt')} {new Date(token.created_at).toLocaleDateString(locale)} - {token.last_used_at && ( - · {t('settings.mcp.tokenUsedAt')} {new Date(token.last_used_at).toLocaleDateString(locale)} - )} -

-
- -
- ))} -
- )} -
-
} - - {/* Create MCP Token modal */} - {mcpModalOpen && ( -
{ if (e.target === e.currentTarget && !mcpCreatedToken) { setMcpModalOpen(false) } }}> -
- {!mcpCreatedToken ? ( - <> -

{t('settings.mcp.modal.createTitle')}

-
- - setMcpNewName(e.target.value)} - onKeyDown={e => e.key === 'Enter' && handleCreateMcpToken()} - placeholder={t('settings.mcp.modal.tokenNamePlaceholder')} - className="w-full px-3 py-2.5 border rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-indigo-300" - style={{ borderColor: 'var(--border-primary)', background: 'var(--bg-secondary)', color: 'var(--text-primary)' }} - autoFocus /> -
-
- - -
- - ) : ( - <> -

{t('settings.mcp.modal.createdTitle')}

-
- -

{t('settings.mcp.modal.createdWarning')}

-
-
-
-                        {mcpCreatedToken}
-                      
- -
-
- -
- - )} -
-
- )} - - {/* Delete MCP Token confirm */} - {mcpDeleteId !== null && ( -
{ if (e.target === e.currentTarget) setMcpDeleteId(null) }}> -
-

{t('settings.mcp.deleteTokenTitle')}

-

{t('settings.mcp.deleteTokenMessage')}

-
- - -
-
-
- )} - - {/* Account */} -
-
- - ) => setUsername(e.target.value)} - className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm focus:ring-2 focus:ring-slate-400 focus:border-transparent" - /> -
-
- - ) => setEmail(e.target.value)} - className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm focus:ring-2 focus:ring-slate-400 focus:border-transparent" - /> -
- - {/* Change Password */} - {!oidcOnlyMode && ( -
- -
- ) => setCurrentPassword(e.target.value)} - placeholder={t('settings.currentPassword')} - className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm focus:ring-2 focus:ring-slate-400 focus:border-transparent" - /> - ) => setNewPassword(e.target.value)} - placeholder={t('settings.newPassword')} - className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm focus:ring-2 focus:ring-slate-400 focus:border-transparent" - /> - ) => setConfirmPassword(e.target.value)} - placeholder={t('settings.confirmPassword')} - className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm focus:ring-2 focus:ring-slate-400 focus:border-transparent" - /> - -
-
- )} - - {/* MFA */} -
-
- -

{t('settings.mfa.title')}

-
-
- {mfaRequiredByPolicy && ( -
- -

{t('settings.mfa.requiredByPolicy')}

-
- )} -

{t('settings.mfa.description')}

- {demoMode ? ( -

{t('settings.mfa.demoBlocked')}

- ) : ( - <> -

- {user?.mfa_enabled ? t('settings.mfa.enabled') : t('settings.mfa.disabled')} -

- - {!user?.mfa_enabled && !mfaQr && ( - - )} - - {!user?.mfa_enabled && mfaQr && ( -
-

{t('settings.mfa.scanQr')}

- -
- - {mfaSecret} -
- setMfaSetupCode(e.target.value.replace(/\D/g, '').slice(0, 8))} - placeholder={t('settings.mfa.codePlaceholder')} - className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm" - /> -
- - -
-
- )} - - {user?.mfa_enabled && ( -
-

{t('settings.mfa.disableTitle')}

-

{t('settings.mfa.disableHint')}

- setMfaDisablePwd(e.target.value)} - placeholder={t('settings.currentPassword')} - className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm" - /> - setMfaDisableCode(e.target.value.replace(/\D/g, '').slice(0, 8))} - placeholder={t('settings.mfa.codePlaceholder')} - className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm" - /> - -
- )} - - {backupCodes && backupCodes.length > 0 && ( -
-

{t('settings.mfa.backupTitle')}

-

{t('settings.mfa.backupDescription')}

-
{backupCodesText}
-

{t('settings.mfa.backupWarning')}

-
- - - - -
-
- )} - - )} -
-
- -
-
- {user?.avatar_url ? ( - - ) : ( -
- {user?.username?.charAt(0).toUpperCase()} -
- )} - - - {user?.avatar_url && ( - - )} -
-
-
- - {user?.role === 'admin' ? <> {t('settings.roleAdmin')} : t('settings.roleUser')} - - {(user as UserWithOidc)?.oidc_issuer && ( - - SSO - - )} -
- {(user as UserWithOidc)?.oidc_issuer && ( -

- {t('settings.oidcLinked')} {(user as UserWithOidc).oidc_issuer!.replace('https://', '').replace(/\/+$/, '')} -

- )} -
-
- -
{/* Tab bar */}
{TABS.map(tab => ( @@ -1430,4 +90,4 @@ export default function SettingsPage(): React.ReactElement {
) -} +} \ No newline at end of file diff --git a/server/src/services/memories/unifiedService.ts b/server/src/services/memories/unifiedService.ts index 5743b79..f7d3d1b 100644 --- a/server/src/services/memories/unifiedService.ts +++ b/server/src/services/memories/unifiedService.ts @@ -1,5 +1,5 @@ import { db, canAccessTrip } from '../../db/database'; -import { notifyTripMembers } from '../notifications'; +import { send } from '../notificationService'; import { broadcast } from '../../websocket'; import { ServiceResult, @@ -290,17 +290,12 @@ async function _notifySharedTripPhotos( if (added <= 0) return fail('No photos shared, skipping notifications', 200); try { - const actorRow = db.prepare('SELECT username FROM users WHERE id = ?').get(actorUserId) as { username: string | null }; + const actorRow = db.prepare('SELECT username, email FROM users WHERE id = ?').get(actorUserId) as { username: string | null, email: string | null }; const tripInfo = db.prepare('SELECT title FROM trips WHERE id = ?').get(tripId) as { title: string } | undefined; - //send({ event: 'photos_shared', actorId: authReq.user.id, scope: 'trip', targetId: Number(tripId), params: { trip: tripInfo?.title || 'Untitled', actor: authReq.user.email, count: String(added), tripId: String(tripId) } }).catch(() => {}); - await notifyTripMembers(Number(tripId), actorUserId, 'photos_shared', { - trip: tripInfo?.title || 'Untitled', - actor: actorRow?.username || 'Unknown', - count: String(added), - }); + send({ event: 'photos_shared', actorId: actorUserId, scope: 'trip', targetId: Number(tripId), params: { trip: tripInfo?.title || 'Untitled', actor: actorRow?.email || 'Unknown', count: String(added), tripId: String(tripId) } }).catch(() => {}); return success(undefined); } catch { return fail('Failed to send notifications', 500); From 079964bec832ed3f49573b26249b652c547710d6 Mon Sep 17 00:00:00 2001 From: Marek Maslowski Date: Sun, 5 Apr 2026 11:50:34 +0200 Subject: [PATCH 51/57] making helper functions for building urls --- .../src/components/Memories/MemoriesPanel.tsx | 76 +++++++++++++------ 1 file changed, 54 insertions(+), 22 deletions(-) diff --git a/client/src/components/Memories/MemoriesPanel.tsx b/client/src/components/Memories/MemoriesPanel.tsx index 3d863ba..296c56b 100644 --- a/client/src/components/Memories/MemoriesPanel.tsx +++ b/client/src/components/Memories/MemoriesPanel.tsx @@ -3,7 +3,7 @@ import apiClient, { addonsApi } from '../../api/client' import { Camera, Plus, Share2, EyeOff, Eye, X, Check, Search, ArrowUpDown, MapPin, Filter, Link2, RefreshCw, Unlink, FolderOpen, Info, ChevronLeft, ChevronRight } from 'lucide-react' import { useAuthStore } from '../../store/authStore' import { useTranslation } from '../../i18n' -import { getAuthUrl, fetchImageAsBlob, clearImageQueue } from '../../api/authUrl' +import { fetchImageAsBlob, clearImageQueue } from '../../api/authUrl' import { useToast } from '../shared/Toast' interface PhotoProvider { @@ -26,6 +26,7 @@ function ProviderImg({ baseUrl, provider, style, loading }: { baseUrl: string; p return src ? : null } + // ── Types ─────────────────────────────────────────────────────────────────── interface TripPhoto { @@ -40,10 +41,10 @@ interface TripPhoto { interface Asset { id: string + provider: string takenAt: string city: string | null country: string | null - provider: string } interface MemoriesPanelProps { @@ -88,9 +89,41 @@ export default function MemoriesPanel({ tripId, startDate, endDate }: MemoriesPa const [albumLinks, setAlbumLinks] = useState<{ id: number; provider: string; album_id: string; album_name: string; user_id: number; username: string; sync_enabled: number; last_synced_at: string | null }[]>([]) const [syncing, setSyncing] = useState(null) + + //helpers for building urls + const ADDON_PREFIX = "/integrations/memories" + + function buildUnifiedUrl(endpoint: string, lastParam?:string,): string { + return `${ADDON_PREFIX}/unified/trips/${tripId}/${endpoint}${lastParam ? `/${lastParam}` : ''}`; + } + + function buildProviderUrl(provider: string, endpoint: string, item?: string): string { + if (endpoint === 'album-link-sync') { + endpoint = `trips/${tripId}/album-links/${item?.toString() || ''}/sync` + } + return `${ADDON_PREFIX}/${provider}/${endpoint}`; + } + + function buildProviderAssetUrl(photo: TripPhoto, what: string): string { + return `${ADDON_PREFIX}/${photo.provider}/assets/${tripId}/${photo.asset_id}/${photo.user_id}/${what}` + } + + function buildProviderAssetUrlFromAsset(asset: Asset, what: string, userId: number): string { + const photo: TripPhoto = { + asset_id: asset.id, + provider: asset.provider, + user_id: userId, + username: '', + shared: 0, + added_at: null + } + return buildProviderAssetUrl(photo, what) + } + + const loadAlbumLinks = async () => { try { - const res = await apiClient.get(`/integrations/memories/unified/trips/${tripId}/album-links`) + const res = await apiClient.get(buildUnifiedUrl('album-links')) setAlbumLinks(res.data.links || []) } catch { setAlbumLinks([]) } } @@ -99,7 +132,7 @@ export default function MemoriesPanel({ tripId, startDate, endDate }: MemoriesPa if (!provider) return setAlbumsLoading(true) try { - const res = await apiClient.get(`/integrations/memories/${provider}/albums`) + const res = await apiClient.get(buildProviderUrl(provider, 'albums')) setAlbums(res.data.albums || []) } catch { setAlbums([]) @@ -121,7 +154,7 @@ export default function MemoriesPanel({ tripId, startDate, endDate }: MemoriesPa } try { - await apiClient.post(`/integrations/memories/unified/trips/${tripId}/album-links`, { + await apiClient.post(buildUnifiedUrl('album-links'), { album_id: albumId, album_name: albumName, provider: selectedProvider, @@ -129,7 +162,7 @@ export default function MemoriesPanel({ tripId, startDate, endDate }: MemoriesPa setShowAlbumPicker(false) await loadAlbumLinks() // Auto-sync after linking - const linksRes = await apiClient.get(`/integrations/memories/unified/trips/${tripId}/album-links`) + const linksRes = await apiClient.get(buildUnifiedUrl('album-links')) const newLink = (linksRes.data.links || []).find((l: any) => l.album_id === albumId && l.provider === selectedProvider) if (newLink) await syncAlbum(newLink.id) } catch { toast.error(t('memories.error.linkAlbum')) } @@ -137,7 +170,7 @@ export default function MemoriesPanel({ tripId, startDate, endDate }: MemoriesPa const unlinkAlbum = async (linkId: number) => { try { - await apiClient.delete(`/integrations/memories/unified/trips/${tripId}/album-links/${linkId}`) + await apiClient.delete(buildUnifiedUrl('album-links', linkId.toString())) await loadAlbumLinks() await loadPhotos() } catch { toast.error(t('memories.error.unlinkAlbum')) } @@ -148,7 +181,7 @@ export default function MemoriesPanel({ tripId, startDate, endDate }: MemoriesPa if (!targetProvider) return setSyncing(linkId) try { - await apiClient.post(`/integrations/memories/${targetProvider}/trips/${tripId}/album-links/${linkId}/sync`) + await apiClient.post(buildProviderUrl(targetProvider, 'album-link-sync', linkId.toString())) await loadAlbumLinks() await loadPhotos() } catch { toast.error(t('memories.error.syncAlbum')) } @@ -185,7 +218,7 @@ export default function MemoriesPanel({ tripId, startDate, endDate }: MemoriesPa const loadPhotos = async () => { try { - const photosRes = await apiClient.get(`/integrations/memories/unified/trips/${tripId}/photos`) + const photosRes = await apiClient.get(buildUnifiedUrl('photos')) setTripPhotos(photosRes.data.photos || []) } catch { setTripPhotos([]) @@ -269,7 +302,7 @@ export default function MemoriesPanel({ tripId, startDate, endDate }: MemoriesPa setPickerPhotos([]) return } - const res = await apiClient.post(`/integrations/memories/${provider.id}/search`, { + const res = await apiClient.post(buildProviderUrl(provider.id, 'search'), { from: useDate && startDate ? startDate : undefined, to: useDate && endDate ? endDate : undefined, }) @@ -308,7 +341,7 @@ export default function MemoriesPanel({ tripId, startDate, endDate }: MemoriesPa groupedByProvider.set(provider, list) } - await apiClient.post(`/integrations/memories/unified/trips/${tripId}/photos`, { + await apiClient.post(buildUnifiedUrl('photos'), { selections: [...groupedByProvider.entries()].map(([provider, asset_ids]) => ({ provider, asset_ids })), shared: true, }) @@ -322,7 +355,7 @@ export default function MemoriesPanel({ tripId, startDate, endDate }: MemoriesPa const removePhoto = async (photo: TripPhoto) => { try { - await apiClient.delete(`/integrations/memories/unified/trips/${tripId}/photos`, { + await apiClient.delete(buildUnifiedUrl('photos'), { data: { asset_id: photo.asset_id, provider: photo.provider, @@ -336,7 +369,7 @@ export default function MemoriesPanel({ tripId, startDate, endDate }: MemoriesPa const toggleSharing = async (photo: TripPhoto, shared: boolean) => { try { - await apiClient.put(`/integrations/memories/unified/trips/${tripId}/photos/sharing`, { + await apiClient.put(buildUnifiedUrl('photos', 'sharing'), { shared, asset_id: photo.asset_id, provider: photo.provider, @@ -349,8 +382,7 @@ export default function MemoriesPanel({ tripId, startDate, endDate }: MemoriesPa // ── Helpers ─────────────────────────────────────────────────────────────── - const thumbnailBaseUrl = (photo: TripPhoto) => - `/api/integrations/memories/${photo.provider}/assets/${tripId}/${photo.asset_id}/${photo.user_id}/thumbnail` + const makePickerKey = (provider: string, assetId: string): string => `${provider}::${assetId}` @@ -619,7 +651,7 @@ export default function MemoriesPanel({ tripId, startDate, endDate }: MemoriesPa outline: isSelected ? '3px solid var(--text-primary)' : 'none', outlineOffset: -3, }}> - {isSelected && (
setLightboxInfo(r.data)).catch(() => {}).finally(() => setLightboxInfoLoading(false)) }}> - {/* Other user's avatar */} @@ -912,7 +944,7 @@ export default function MemoriesPanel({ tripId, startDate, endDate }: MemoriesPa setShowMobileInfo(false) } - const currentIdx = allVisible.findIndex(p => p.immich_asset_id === lightboxId) + const currentIdx = allVisible.findIndex(p => p.asset_id === lightboxId) const hasPrev = currentIdx > 0 const hasNext = currentIdx < allVisible.length - 1 const navigateTo = (idx: number) => { @@ -920,10 +952,10 @@ export default function MemoriesPanel({ tripId, startDate, endDate }: MemoriesPa if (!photo) return if (lightboxOriginalSrc) URL.revokeObjectURL(lightboxOriginalSrc) setLightboxOriginalSrc('') - setLightboxId(photo.immich_asset_id) + setLightboxId(photo.asset_id) setLightboxUserId(photo.user_id) setLightboxInfo(null) - fetchImageAsBlob(`/api/integrations/immich/assets/${photo.immich_asset_id}/original?userId=${photo.user_id}`).then(setLightboxOriginalSrc) + fetchImageAsBlob('/api' + buildProviderAssetUrl(photo, 'original')).then(setLightboxOriginalSrc) } const exifContent = lightboxInfo ? ( From 4a0d586768a9377d8d05273ab54a80d3ec08b590 Mon Sep 17 00:00:00 2001 From: Marek Maslowski Date: Sun, 5 Apr 2026 11:54:51 +0200 Subject: [PATCH 52/57] fix for not calling api route on fetch --- client/src/components/Memories/MemoriesPanel.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/client/src/components/Memories/MemoriesPanel.tsx b/client/src/components/Memories/MemoriesPanel.tsx index 296c56b..a466e26 100644 --- a/client/src/components/Memories/MemoriesPanel.tsx +++ b/client/src/components/Memories/MemoriesPanel.tsx @@ -17,7 +17,7 @@ function ProviderImg({ baseUrl, provider, style, loading }: { baseUrl: string; p const [src, setSrc] = useState('') useEffect(() => { let revoke = '' - fetchImageAsBlob(baseUrl).then(blobUrl => { + fetchImageAsBlob('/api' + baseUrl).then(blobUrl => { revoke = blobUrl setSrc(blobUrl) }) From 1236f3281d4977bbf8219c538033d0718de3b95d Mon Sep 17 00:00:00 2001 From: Marek Maslowski Date: Sun, 5 Apr 2026 12:17:43 +0200 Subject: [PATCH 53/57] adding old routes --- server/src/app.ts | 3 + server/src/routes/immich.ts | 270 ++++++++++++++++++++++++++++++++++++ 2 files changed, 273 insertions(+) create mode 100644 server/src/routes/immich.ts diff --git a/server/src/app.ts b/server/src/app.ts index bc36d97..8355da4 100644 --- a/server/src/app.ts +++ b/server/src/app.ts @@ -35,6 +35,7 @@ import oidcRoutes from './routes/oidc'; import vacayRoutes from './routes/vacay'; import atlasRoutes from './routes/atlas'; import memoriesRoutes from './routes/memories/unified'; +import immichRoutes from './routes/immich'; import notificationRoutes from './routes/notifications'; import shareRoutes from './routes/share'; import { mcpHandler } from './mcp'; @@ -257,6 +258,8 @@ export function createApp(): express.Application { app.use('/api/addons/vacay', vacayRoutes); app.use('/api/addons/atlas', atlasRoutes); app.use('/api/integrations/memories', memoriesRoutes); + //old routes for immich integration (will be removed eventually) + app.use('/api/integrations/immich', immichRoutes); app.use('/api/maps', mapsRoutes); app.use('/api/weather', weatherRoutes); app.use('/api/settings', settingsRoutes); diff --git a/server/src/routes/immich.ts b/server/src/routes/immich.ts new file mode 100644 index 0000000..4eef709 --- /dev/null +++ b/server/src/routes/immich.ts @@ -0,0 +1,270 @@ +//DEPRECATED - This route is no longer used use new routes +//DEPRECATED - This route is no longer used use new routes +//DEPRECATED - This route is no longer used use new routes +//DEPRECATED - This route is no longer used use new routes +//DEPRECATED - This route is no longer used use new routes +//DEPRECATED - This route is no longer used use new routes +//DEPRECATED - This route is no longer used use new routes +//DEPRECATED - This route is no longer used use new routes +//DEPRECATED - This route is no longer used use new routes +//DEPRECATED - This route is no longer used use new routes +//DEPRECATED - This route is no longer used use new routes +//DEPRECATED - This route is no longer used use new routes +//DEPRECATED - This route is no longer used use new routes +//DEPRECATED - This route is no longer used use new routes +//DEPRECATED - This route is no longer used use new routes +//DEPRECATED - This route is no longer used use new routes +//DEPRECATED - This route is no longer used use new routes +//DEPRECATED - This route is no longer used use new routes +//DEPRECATED - This route is no longer used use new routes +//DEPRECATED - This route is no longer used use new routes +//DEPRECATED - This route is no longer used use new routes +//DEPRECATED - This route is no longer used use new routes +//DEPRECATED - This route is no longer used use new routes +//DEPRECATED - This route is no longer used use new routes +//DEPRECATED - This route is no longer used use new routes +//DEPRECATED - This route is no longer used use new routes +//DEPRECATED - This route is no longer used use new routes +//DEPRECATED - This route is no longer used use new routes +//DEPRECATED - This route is no longer used use new routes +//DEPRECATED - This route is no longer used use new routes +//DEPRECATED - This route is no longer used use new routes +//DEPRECATED - This route is no longer used use new routes +//DEPRECATED - This route is no longer used use new routes +//DEPRECATED - This route is no longer used use new routes +//DEPRECATED - This route is no longer used use new routes +//DEPRECATED - This route is no longer used use new routes +//DEPRECATED - This route is no longer used use new routes +//DEPRECATED - This route is no longer used use new routes +//DEPRECATED - This route is no longer used use new routes +//DEPRECATED - This route is no longer used use new routes +//DEPRECATED - This route is no longer used use new routes +//DEPRECATED - This route is no longer used use new routes + + + + +import express, { Request, Response, NextFunction } from 'express'; +import { db, canAccessTrip } from '../db/database'; +import { authenticate } from '../middleware/auth'; +import { AuthRequest } from '../types'; +import { consumeEphemeralToken } from '../services/ephemeralTokens'; +import { getClientIp } from '../services/auditLog'; +import { + getConnectionSettings, + saveImmichSettings, + testConnection, + getConnectionStatus, + browseTimeline, + searchPhotos, + getAssetInfo, + proxyThumbnail, + proxyOriginal, + isValidAssetId, + listAlbums, + listAlbumLinks, + createAlbumLink, + deleteAlbumLink, + syncAlbumAssets, +} from '../services/memories/immichService'; +import { addTripPhotos, listTripPhotos, removeTripPhoto, setTripPhotoSharing } from '../services/memories/unifiedService'; +import { Selection, canAccessUserPhoto } from '../services/memories/helpersService'; + +const router = express.Router(); + +// ── Dual auth middleware (JWT or ephemeral token for src) ───────────── + +function authFromQuery(req: Request, res: Response, next: NextFunction) { + const queryToken = req.query.token as string | undefined; + if (queryToken) { + const userId = consumeEphemeralToken(queryToken, 'immich'); + if (!userId) return res.status(401).send('Invalid or expired token'); + const user = db.prepare('SELECT id, username, email, role, mfa_enabled FROM users WHERE id = ?').get(userId) as any; + if (!user) return res.status(401).send('User not found'); + (req as AuthRequest).user = user; + return next(); + } + return (authenticate as any)(req, res, next); +} + +// ── Immich Connection Settings ───────────────────────────────────────────── + +router.get('/settings', authenticate, (req: Request, res: Response) => { + const authReq = req as AuthRequest; + res.json(getConnectionSettings(authReq.user.id)); +}); + +router.put('/settings', authenticate, async (req: Request, res: Response) => { + const authReq = req as AuthRequest; + const { immich_url, immich_api_key } = req.body; + const result = await saveImmichSettings(authReq.user.id, immich_url, immich_api_key, getClientIp(req)); + if (!result.success) return res.status(400).json({ error: result.error }); + if (result.warning) return res.json({ success: true, warning: result.warning }); + res.json({ success: true }); +}); + +router.get('/status', authenticate, async (req: Request, res: Response) => { + const authReq = req as AuthRequest; + res.json(await getConnectionStatus(authReq.user.id)); +}); + +router.post('/test', authenticate, async (req: Request, res: Response) => { + const { immich_url, immich_api_key } = req.body; + if (!immich_url || !immich_api_key) return res.json({ connected: false, error: 'URL and API key required' }); + res.json(await testConnection(immich_url, immich_api_key)); +}); + +// ── Browse Immich Library (for photo picker) ─────────────────────────────── + +router.get('/browse', authenticate, async (req: Request, res: Response) => { + const authReq = req as AuthRequest; + const result = await browseTimeline(authReq.user.id); + if (result.error) return res.status(result.status!).json({ error: result.error }); + res.json({ buckets: result.buckets }); +}); + +router.post('/search', authenticate, async (req: Request, res: Response) => { + const authReq = req as AuthRequest; + const { from, to } = req.body; + const result = await searchPhotos(authReq.user.id, from, to); + if (result.error) return res.status(result.status!).json({ error: result.error }); + res.json({ assets: result.assets }); +}); + +// ── Trip Photos (selected by user) ──────────────────────────────────────── + +router.get('/trips/:tripId/photos', authenticate, (req: Request, res: Response) => { + const authReq = req as AuthRequest; + const { tripId } = req.params; + if (!canAccessTrip(tripId, authReq.user.id)) return res.status(404).json({ error: 'Trip not found' }); + res.json({ photos: listTripPhotos(tripId, authReq.user.id) }); +}); + +router.post('/trips/:tripId/photos', authenticate, async (req: Request, res: Response) => { + const authReq = req as AuthRequest; + const { tripId } = req.params; + const sid = req.headers['x-socket-id'] as string; + if (!canAccessTrip(tripId, authReq.user.id)) return res.status(404).json({ error: 'Trip not found' }); + const { asset_ids, shared = true } = req.body; + + if (!Array.isArray(asset_ids) || asset_ids.length === 0) { + return res.status(400).json({ error: 'asset_ids required' }); + } + + const selection: Selection = { + provider: 'immich', + asset_ids: asset_ids, + }; + const result = await addTripPhotos(tripId, authReq.user.id, shared, [selection], sid); + if ('error' in result) return res.status(result.error.status!).json({ error: result.error }); +}); + +router.delete('/trips/:tripId/photos/:assetId', authenticate, async (req: Request, res: Response) => { + const authReq = req as AuthRequest; + if (!canAccessTrip(req.params.tripId, authReq.user.id)) return res.status(404).json({ error: 'Trip not found' }); + const result = await removeTripPhoto(req.params.tripId, authReq.user.id,'immich', req.params.assetId); + if ('error' in result) return res.status(result.error.status!).json({ error: result.error }); + res.json({ success: true }); +}); + +router.put('/trips/:tripId/photos/:assetId/sharing', authenticate, async (req: Request, res: Response) => { + const authReq = req as AuthRequest; + if (!canAccessTrip(req.params.tripId, authReq.user.id)) return res.status(404).json({ error: 'Trip not found' }); + const { shared } = req.body; + const result = await setTripPhotoSharing(req.params.tripId, authReq.user.id, req.params.assetId, 'immich', shared); + if ('error' in result) return res.status(result.error.status!).json({ error: result.error }); + res.json({ success: true }); +}); + +// ── Asset Details ────────────────────────────────────────────────────────── + +router.get('/assets/:assetId/info', authenticate, async (req: Request, res: Response) => { + const authReq = req as AuthRequest; + const { assetId } = req.params; + if (!isValidAssetId(assetId)) return res.status(400).json({ error: 'Invalid asset ID' }); + const queryUserId = req.query.userId ? Number(req.query.userId) : undefined; + const ownerUserId = queryUserId && queryUserId !== authReq.user.id ? queryUserId : undefined; + if (ownerUserId && !canAccessUserPhoto(authReq.user.id, ownerUserId, req.params.tripId, assetId, 'immich')) { + return res.status(403).json({ error: 'Forbidden' }); + } + const result = await getAssetInfo(authReq.user.id, assetId, ownerUserId); + if (result.error) return res.status(result.status!).json({ error: result.error }); + res.json(result.data); +}); + +// ── Proxy Immich Assets ──────────────────────────────────────────────────── + +router.get('/assets/:assetId/thumbnail', authFromQuery, async (req: Request, res: Response) => { + const authReq = req as AuthRequest; + const { assetId } = req.params; + if (!isValidAssetId(assetId)) return res.status(400).send('Invalid asset ID'); + const queryUserId = req.query.userId ? Number(req.query.userId) : undefined; + const ownerUserId = queryUserId && queryUserId !== authReq.user.id ? queryUserId : undefined; + if (ownerUserId && !canAccessUserPhoto(authReq.user.id, ownerUserId, req.params.tripId, assetId, 'immich')) { + return res.status(403).send('Forbidden'); + } + const result = await proxyThumbnail(authReq.user.id, assetId, ownerUserId); + if (result.error) return res.status(result.status!).send(result.error); + res.set('Content-Type', result.contentType!); + res.set('Cache-Control', 'public, max-age=86400'); + res.send(result.buffer); +}); + +router.get('/assets/:assetId/original', authFromQuery, async (req: Request, res: Response) => { + const authReq = req as AuthRequest; + const { assetId } = req.params; + if (!isValidAssetId(assetId)) return res.status(400).send('Invalid asset ID'); + const queryUserId = req.query.userId ? Number(req.query.userId) : undefined; + const ownerUserId = queryUserId && queryUserId !== authReq.user.id ? queryUserId : undefined; + if (ownerUserId && !canAccessUserPhoto(authReq.user.id, ownerUserId, req.params.tripId, assetId, 'immich')) { + return res.status(403).send('Forbidden'); + } + const result = await proxyOriginal(authReq.user.id, assetId, ownerUserId); + if (result.error) return res.status(result.status!).send(result.error); + res.set('Content-Type', result.contentType!); + res.set('Cache-Control', 'public, max-age=86400'); + res.send(result.buffer); +}); + +// ── Album Linking ────────────────────────────────────────────────────────── + +router.get('/albums', authenticate, async (req: Request, res: Response) => { + const authReq = req as AuthRequest; + const result = await listAlbums(authReq.user.id); + if (result.error) return res.status(result.status!).json({ error: result.error }); + res.json({ albums: result.albums }); +}); + +router.get('/trips/:tripId/album-links', authenticate, (req: Request, res: Response) => { + const authReq = req as AuthRequest; + if (!canAccessTrip(req.params.tripId, authReq.user.id)) return res.status(404).json({ error: 'Trip not found' }); + res.json({ links: listAlbumLinks(req.params.tripId) }); +}); + +router.post('/trips/:tripId/album-links', authenticate, async (req: Request, res: Response) => { + const authReq = req as AuthRequest; + const { tripId } = req.params; + if (!canAccessTrip(tripId, authReq.user.id)) return res.status(404).json({ error: 'Trip not found' }); + const { album_id, album_name } = req.body; + if (!album_id) return res.status(400).json({ error: 'album_id required' }); + const result = createAlbumLink(tripId, authReq.user.id, album_id, album_name); + if (!result.success) return res.status(400).json({ error: result.error }); + res.json({ success: true }); +}); + +router.delete('/trips/:tripId/album-links/:linkId', authenticate, (req: Request, res: Response) => { + const authReq = req as AuthRequest; + deleteAlbumLink(req.params.linkId, req.params.tripId, authReq.user.id); + res.json({ success: true }); +}); + +router.post('/trips/:tripId/album-links/:linkId/sync', authenticate, async (req: Request, res: Response) => { + const authReq = req as AuthRequest; + const { tripId, linkId } = req.params; + const sid = req.headers['x-socket-id'] as string; + const result = await syncAlbumAssets(tripId, linkId, authReq.user.id, sid); + if (result.error) return res.status(result.status!).json({ error: result.error }); + res.json({ success: true, added: result.added, total: result.total }); +}); + +export default router; \ No newline at end of file From 74b3b0f9aee81c1ddb96f4bcab2d3e8152e68e34 Mon Sep 17 00:00:00 2001 From: Marek Maslowski Date: Sun, 5 Apr 2026 12:21:00 +0200 Subject: [PATCH 54/57] removing race conteset on delting album link --- server/src/services/memories/unifiedService.ts | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/server/src/services/memories/unifiedService.ts b/server/src/services/memories/unifiedService.ts index f7d3d1b..a836524 100644 --- a/server/src/services/memories/unifiedService.ts +++ b/server/src/services/memories/unifiedService.ts @@ -268,10 +268,13 @@ export function removeAlbumLink(tripId: string, linkId: string, userId: number): } try { - db.prepare('DELETE FROM trip_photos WHERE trip_id = ? AND album_link_id = ?') - .run(tripId, linkId); - db.prepare('DELETE FROM trip_album_links WHERE id = ? AND trip_id = ? AND user_id = ?') - .run(linkId, tripId, userId); + db.transaction(() => { + db.prepare('DELETE FROM trip_photos WHERE trip_id = ? AND album_link_id = ?') + .run(tripId, linkId); + db.prepare('DELETE FROM trip_album_links WHERE id = ? AND trip_id = ? AND user_id = ?') + .run(linkId, tripId, userId); + })(); + return success(true); } catch (error) { return mapDbError(error, 'Failed to remove album link'); From 51c4afd5f704b636ff7fc9f4c44d4c86619cbadd Mon Sep 17 00:00:00 2001 From: Marek Maslowski Date: Sun, 5 Apr 2026 14:26:14 +0200 Subject: [PATCH 55/57] fixing error on test connection without params --- server/src/routes/memories/synology.ts | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/server/src/routes/memories/synology.ts b/server/src/routes/memories/synology.ts index 4bd7497..de6915a 100644 --- a/server/src/routes/memories/synology.ts +++ b/server/src/routes/memories/synology.ts @@ -12,7 +12,7 @@ import { getSynologyAssetInfo, streamSynologyAsset, } from '../../services/memories/synologyService'; -import { canAccessUserPhoto, handleServiceResult, fail } from '../../services/memories/helpersService'; +import { canAccessUserPhoto, handleServiceResult, fail, success } from '../../services/memories/helpersService'; const router = express.Router(); @@ -57,7 +57,11 @@ router.post('/test', authenticate, async (req: Request, res: Response) => { const synology_password = _parseStringBodyField(body.synology_password); if (!synology_url || !synology_username || !synology_password) { - handleServiceResult(res, fail('URL, username, and password are required', 400)); + const missingFields: string[] = []; + if (!synology_url) missingFields.push('URL'); + if (!synology_username) missingFields.push('Username'); + if (!synology_password) missingFields.push('Password'); + handleServiceResult(res, success({ connected: false, error: `${missingFields.join(', ')} ${missingFields.length > 1 ? 'are' : 'is'} required` })); } else{ handleServiceResult(res, await testSynologyConnection(synology_url, synology_username, synology_password)); From 070b75b6be72bf127d14f2a8c719e8600cef4a81 Mon Sep 17 00:00:00 2001 From: Marek Maslowski Date: Sun, 5 Apr 2026 14:26:28 +0200 Subject: [PATCH 56/57] fixing loging in to synology --- server/src/services/memories/synologyService.ts | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/server/src/services/memories/synologyService.ts b/server/src/services/memories/synologyService.ts index b02944d..ff44179 100644 --- a/server/src/services/memories/synologyService.ts +++ b/server/src/services/memories/synologyService.ts @@ -120,9 +120,9 @@ function _getSynologyCredentials(userId: number): ServiceResult(url: string, body: URLSearchParams): Promise> { - const endpoint = _buildSynologyEndpoint(url); + const endpoint = _buildSynologyEndpoint(url, `api=${body.get('api')}`); const SsrfResult = await checkSsrf(endpoint); if (!SsrfResult.allowed) { return fail(SsrfResult.error, 400); @@ -497,8 +497,7 @@ export async function streamSynologyAsset( _sid: sid.data, }); - const url = `${_buildSynologyEndpoint(synology_credentials.data.synology_url)}?${params.toString()}`; - + const url = _buildSynologyEndpoint(synology_credentials.data.synology_url, params.toString()); await pipeAsset(url, response) } From 8b488efc8ef54ce70b1e89ec9c988cc0e764e1d2 Mon Sep 17 00:00:00 2001 From: Marek Maslowski Date: Sun, 5 Apr 2026 14:32:41 +0200 Subject: [PATCH 57/57] fixing migrations to change to correct label name --- server/src/db/migrations.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/server/src/db/migrations.ts b/server/src/db/migrations.ts index 1c3f7b3..bdd5ee1 100644 --- a/server/src/db/migrations.ts +++ b/server/src/db/migrations.ts @@ -677,9 +677,9 @@ function runMigrations(db: Database.Database): void { payload_key = excluded.payload_key, sort_order = excluded.sort_order `); - insertField.run('synologyphotos', 'synology_url', 'Server URL', 'url', 'https://synology.example.com', 1, 0, 'synology_url', 'synology_url', 0); - insertField.run('synologyphotos', 'synology_username', 'Username', 'text', 'Username', 1, 0, 'synology_username', 'synology_username', 1); - insertField.run('synologyphotos', 'synology_password', 'Password', 'password', 'Password', 1, 1, null, 'synology_password', 2); + insertField.run('synologyphotos', 'synology_url', 'providerUrl', 'url', 'https://synology.example.com', 1, 0, 'synology_url', 'synology_url', 0); + insertField.run('synologyphotos', 'synology_username', 'providerUsername', 'text', 'Username', 1, 0, 'synology_username', 'synology_username', 1); + insertField.run('synologyphotos', 'synology_password', 'providerPassword', 'password', 'Password', 1, 1, null, 'synology_password', 2); } catch (err: any) { if (!err.message?.includes('no such table')) throw err; }