diff --git a/client/src/api/authUrl.ts b/client/src/api/authUrl.ts index 9cdc541..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: 'download' | 'immich'): Promise { +export async function getAuthUrl(url: string, purpose: 'download'): 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 5b5357d..a466e26 100644 --- a/client/src/components/Memories/MemoriesPanel.tsx +++ b/client/src/components/Memories/MemoriesPanel.tsx @@ -1,16 +1,23 @@ import { useState, useEffect, useCallback } from 'react' +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 apiClient from '../../api/client' 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' -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(() => { let revoke = '' - fetchImageAsBlob(baseUrl).then(blobUrl => { + fetchImageAsBlob('/api' + baseUrl).then(blobUrl => { revoke = blobUrl setSrc(blobUrl) }) @@ -19,18 +26,22 @@ function ImmichImg({ baseUrl, style, loading }: { baseUrl: string; style?: React 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 { +interface Asset { id: string + provider: string takenAt: string city: string | null country: string | null @@ -50,6 +61,9 @@ 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) // Trip photos (saved selections) @@ -57,7 +71,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()) @@ -72,50 +86,102 @@ 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) + + //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/immich/trips/${tripId}/album-links`) + const res = await apiClient.get(buildUnifiedUrl('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(buildProviderUrl(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) => { + if (!selectedProvider) { + toast.error(t('memories.error.linkAlbum')) + return + } + try { - await apiClient.post(`/integrations/immich/trips/${tripId}/album-links`, { album_id: albumId, album_name: albumName }) + await apiClient.post(buildUnifiedUrl('album-links'), { + album_id: albumId, + album_name: albumName, + provider: selectedProvider, + }) 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(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')) } } const unlinkAlbum = async (linkId: number) => { try { - await apiClient.delete(`/integrations/immich/trips/${tripId}/album-links/${linkId}`) + await apiClient.delete(buildUnifiedUrl('album-links', linkId.toString())) await loadAlbumLinks() await loadPhotos() } 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(buildProviderUrl(targetProvider, 'album-link-sync', linkId.toString())) await loadAlbumLinks() await loadPhotos() } catch { toast.error(t('memories.error.syncAlbum')) } @@ -152,7 +218,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(buildUnifiedUrl('photos')) setTripPhotos(photosRes.data.photos || []) } catch { setTripPhotos([]) @@ -162,9 +228,37 @@ 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) + + 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) => { + 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() @@ -184,14 +278,35 @@ 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 provider = availableProviders.find(p => p.id === selectedProvider) + if (!provider) { + setPickerPhotos([]) + return + } + const res = await apiClient.post(buildProviderUrl(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')) @@ -217,8 +332,17 @@ export default function MemoriesPanel({ tripId, startDate, endDate }: MemoriesPa const executeAddPhotos = async () => { setShowConfirmShare(false) try { - await apiClient.post(`/integrations/immich/trips/${tripId}/photos`, { - asset_ids: [...selectedIds], + 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(buildUnifiedUrl('photos'), { + selections: [...groupedByProvider.entries()].map(([provider, asset_ids]) => ({ provider, asset_ids })), shared: true, }) setShowPicker(false) @@ -229,28 +353,38 @@ 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(buildUnifiedUrl('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(buildUnifiedUrl('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 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) @@ -290,10 +424,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(', ') })}

) @@ -301,22 +435,53 @@ 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 (

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

+
{albumsLoading ? ( @@ -368,7 +533,11 @@ export default function MemoriesPanel({ tripId, startDate, endDate }: MemoriesPa // ── Photo Picker Modal ──────────────────────────────────────────────────── if (showPicker) { - const alreadyAdded = new Set(tripPhotos.filter(p => p.user_id === currentUser?.id).map(p => p.immich_asset_id)) + const alreadyAdded = new Set( + tripPhotos + .filter(p => p.user_id === currentUser?.id) + .map(p => makePickerKey(p.provider, p.asset_id)) + ) return ( <> @@ -377,7 +546,7 @@ 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 && ( @@ -438,10 +610,17 @@ 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 - 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' @@ -459,11 +638,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', @@ -471,7 +651,7 @@ export default function MemoriesPanel({ tripId, startDate, endDate }: MemoriesPa outline: isSelected ? '3px solid var(--text-primary)' : 'none', outlineOffset: -3, }}> - {isSelected && (
{link.album_name} {link.username !== currentUser?.username && ({link.username})} - @@ -625,12 +805,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/i18n/translations/en.ts b/client/src/i18n/translations/en.ts index 36c35ae..6974c96 100644 --- a/client/src/i18n/translations/en.ts +++ b/client/src/i18n/translations/en.ts @@ -1385,11 +1385,12 @@ const en: Record = { // Photos / Immich 'memories.title': 'Photos', - 'memories.notConnected': 'Immich not connected', - 'memories.notConnectedHint': 'Connect your Immich instance in Settings to see your trip photos here.', + 'memories.notConnected': '{provider_name} not connected', + 'memories.notConnectedHint': 'Connect your {provider_name} instance in Settings to be able add photos to this trip.', + 'memories.notConnectedMultipleHint': 'Connect any of these photo providers: {provider_names} in Settings to be able add photos to this trip.', 'memories.noDates': 'Add dates to your trip to load photos.', 'memories.noPhotos': 'No photos found', - 'memories.noPhotosHint': 'No photos found in Immich for this trip\'s date range.', + 'memories.noPhotosHint': 'No photos found in {provider_name} for this trip\'s date range.', 'memories.photosFound': 'photos', 'memories.fromOthers': 'from others', 'memories.sharePhotos': 'Share photos', @@ -1397,23 +1398,31 @@ const en: Record = { 'memories.reviewTitle': 'Review your photos', 'memories.reviewHint': 'Click photos to exclude them from sharing.', 'memories.shareCount': 'Share {count} photos', - 'memories.immichUrl': 'Immich Server URL', - 'memories.immichApiKey': 'API Key', + //------------------------- + //todo section + 'memories.providerUrl': 'Server URL', + 'memories.providerApiKey': 'API Key', + 'memories.providerUsername': 'Username', + 'memories.providerPassword': 'Password', 'memories.testConnection': 'Test connection', 'memories.testFirst': 'Test connection first', 'memories.connected': 'Connected', 'memories.disconnected': 'Not connected', - 'memories.connectionSuccess': 'Connected to Immich', - 'memories.connectionError': 'Could not connect to Immich', - 'memories.saved': 'Immich settings saved', + 'memories.connectionSuccess': 'Connected to {provider_name}', + 'memories.connectionError': 'Could not connect to {provider_name}', + 'memories.saved': '{provider_name} settings saved', + 'memories.saveError': 'Could not save {provider_name} settings', + //------------------------ 'memories.addPhotos': 'Add photos', 'memories.linkAlbum': 'Link Album', - 'memories.selectAlbum': 'Select Immich Album', + 'memories.selectAlbum': 'Select {provider_name} Album', + 'memories.selectAlbumMultiple': 'Select Album', 'memories.noAlbums': 'No albums found', 'memories.syncAlbum': 'Sync album', 'memories.unlinkAlbum': 'Unlink album', 'memories.photos': 'photos', - 'memories.selectPhotos': 'Select photos from Immich', + 'memories.selectPhotos': 'Select photos from {provider_name}', + 'memories.selectPhotosMultiple': 'Select Photos', 'memories.selectHint': 'Tap photos to select them.', 'memories.selected': 'selected', 'memories.addSelected': 'Add {count} photos', diff --git a/client/src/pages/SettingsPage.tsx b/client/src/pages/SettingsPage.tsx index 57ea4e6..6805545 100644 --- a/client/src/pages/SettingsPage.tsx +++ b/client/src/pages/SettingsPage.tsx @@ -90,4 +90,4 @@ export default function SettingsPage(): React.ReactElement {
) -} +} \ No newline at end of file diff --git a/client/src/pages/TripPlannerPage.tsx b/client/src/pages/TripPlannerPage.tsx index 3f02c42..9f3593c 100644 --- a/client/src/pages/TripPlannerPage.tsx +++ b/client/src/pages/TripPlannerPage.tsx @@ -113,7 +113,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') + setEnabledAddons({ packing: !!map.packing, budget: !!map.budget, documents: !!map.documents, collab: !!map.collab, 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/app.ts b/server/src/app.ts index acf9a79..8355da4 100644 --- a/server/src/app.ts +++ b/server/src/app.ts @@ -34,11 +34,13 @@ import backupRoutes from './routes/backup'; 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'; import { Addon } from './types'; +import { getPhotoProviderConfig } from './services/memories/helpersService'; export function createApp(): express.Application { const app = express(); @@ -196,12 +198,67 @@ 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, sort_order + FROM photo_providers + WHERE enabled = 1 + ORDER BY sort_order, id + `).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 + 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: getPhotoProviderConfig(p.id), + 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); + //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); diff --git a/server/src/db/migrations.ts b/server/src/db/migrations.ts index f2148b5..bdd5ee1 100644 --- a/server/src/db/migrations.ts +++ b/server/src/db/migrations.ts @@ -518,6 +518,189 @@ 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)'); + } + } + }, + () => { + // 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, sort_order) + VALUES (?, ?, ?, ?, ?, ?) + ON CONFLICT(id) DO UPDATE SET + name = excluded.name, + description = excluded.description, + icon = excluded.icon, + enabled = excluded.enabled, + sort_order = excluded.sort_order + `).run( + 'synologyphotos', + 'Synology Photos', + 'Synology Photos integration with separate account settings', + 'Image', + 0, + 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', '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; + } + }, + () => { + // 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'); + }, + () => { + 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'); + }, () => { // Track which album link each photo was synced from try { db.exec("ALTER TABLE trip_photos ADD COLUMN album_link_id INTEGER REFERENCES trip_album_links(id) ON DELETE SET NULL DEFAULT NULL"); } catch (err: any) { if (!err.message?.includes('duplicate column name')) throw err; } @@ -550,6 +733,9 @@ function runMigrations(db: Database.Database): void { ); `); }, + () => { + try {db.exec('UPDATE addons SET enabled = 0 WHERE id = memories');} catch (err) {} + }, // Migration 69: Place region cache for sub-national Atlas regions () => { db.exec(` diff --git a/server/src/db/schema.ts b/server/src/db/schema.ts index 10508ad..eb2d72c 100644 --- a/server/src/db/schema.ts +++ b/server/src/db/schema.ts @@ -18,6 +18,12 @@ 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, + synology_sid TEXT, must_change_password INTEGER DEFAULT 0, created_at DATETIME DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME DEFAULT CURRENT_TIMESTAMP @@ -162,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, @@ -222,6 +229,30 @@ 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, + 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 7962ad2..fe02892 100644 --- a/server/src/db/seeds.ts +++ b/server/src/db/seeds.ts @@ -92,6 +92,39 @@ 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, + }, + { + id: 'synologyphotos', + name: 'Synology Photos', + description: 'Synology Photos integration with separate account settings', + icon: 'Image', + enabled: 0, + sort_order: 1, + }, + ]; + 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: 'providerUrl', 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: 'providerApiKey', 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: 'providerUrl', 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: 'providerUsername', 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: 'providerPassword', 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) { + 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/routes/auth.ts b/server/src/routes/auth.ts index 4436220..dd977df 100644 --- a/server/src/routes/auth.ts +++ b/server/src/routes/auth.ts @@ -317,9 +317,9 @@ 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 token = createResourceToken(authReq.user.id, req.body.purpose); + if (!token) return res.status(503).json({ error: 'Service unavailable' }); + res.json(token); }); export default router; diff --git a/server/src/routes/immich.ts b/server/src/routes/immich.ts index efdd2b9..4eef709 100644 --- a/server/src/routes/immich.ts +++ b/server/src/routes/immich.ts @@ -1,234 +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 { broadcast } from '../websocket'; import { AuthRequest } from '../types'; import { consumeEphemeralToken } from '../services/ephemeralTokens'; import { getClientIp } from '../services/auditLog'; import { - getConnectionSettings, - saveImmichSettings, - testConnection, - getConnectionStatus, - browseTimeline, - searchPhotos, - listTripPhotos, - addTripPhotos, - removeTripPhoto, - togglePhotoSharing, - getAssetInfo, - proxyThumbnail, - proxyOriginal, - isValidAssetId, - canAccessUserPhoto, - listAlbums, - listAlbumLinks, - createAlbumLink, - deleteAlbumLink, - syncAlbumAssets, -} from '../services/immichService'; + 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); + 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)); + 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 }); + 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)); + 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)); + 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 }); + 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 }); + 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) }); + 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; +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' }); - } + 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/notificationService').then(({ send }) => { - 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(() => {}); - }); - } + 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, (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.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, (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); +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, assetId)) { - 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); + 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, assetId)) { - 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); + 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, assetId)) { - 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); + 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 }); + 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) }); + 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 }); + 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 }); + 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 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 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; +export default router; \ No newline at end of file diff --git a/server/src/routes/memories/immich.ts b/server/src/routes/memories/immich.ts new file mode 100644 index 0000000..fa89258 --- /dev/null +++ b/server/src/routes/memories/immich.ts @@ -0,0 +1,148 @@ +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 { + getConnectionSettings, + saveImmichSettings, + testConnection, + getConnectionStatus, + browseTimeline, + searchPhotos, + proxyThumbnail, + proxyOriginal, + listAlbums, + syncAlbumAssets, + getAssetInfo, +} from '../../services/memories/immichService'; +import { 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 }); +}); + +// ── Asset Details ────────────────────────────────────────────────────────── + +router.get('/assets/:tripId/:assetId/:ownerId/info', authenticate, async (req: Request, res: Response) => { + const authReq = req as AuthRequest; + 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, Number(ownerId)); + if (result.error) return res.status(result.status!).json({ error: result.error }); + res.json(result.data); +}); + +// ── Proxy Immich Assets ──────────────────────────────────────────────────── + +router.get('/assets/:tripId/:assetId/:ownerId/thumbnail', authFromQuery, async (req: Request, res: Response) => { + const authReq = req as AuthRequest; + 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, 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/:tripId/:assetId/:ownerId/original', authFromQuery, async (req: Request, res: Response) => { + const authReq = req as AuthRequest; + 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, 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); +}); + +// ── 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.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 }); + if (result.added! > 0) { + broadcast(tripId, 'memories:updated', { userId: authReq.user.id }, req.headers['x-socket-id'] as string); + } +}); + +export default router; diff --git a/server/src/routes/memories/synology.ts b/server/src/routes/memories/synology.ts new file mode 100644 index 0000000..de6915a --- /dev/null +++ b/server/src/routes/memories/synology.ts @@ -0,0 +1,131 @@ +import express, { Request, Response } from 'express'; +import { authenticate } from '../../middleware/auth'; +import { AuthRequest } from '../../types'; +import { + getSynologySettings, + updateSynologySettings, + getSynologyStatus, + testSynologyConnection, + listSynologyAlbums, + syncSynologyAlbumLink, + searchSynologyPhotos, + getSynologyAssetInfo, + streamSynologyAsset, +} from '../../services/memories/synologyService'; +import { canAccessUserPhoto, handleServiceResult, fail, success } from '../../services/memories/helpersService'; + +const router = express.Router(); + +function _parseStringBodyField(value: unknown): string { + return String(value ?? '').trim(); +} + +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; + 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); + + if (!synology_url || !synology_username) { + handleServiceResult(res, fail('URL and username are required', 400)); + } + 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; + 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); + + if (!synology_url || !synology_username || !synology_password) { + 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)); + } +}); + +router.get('/albums', authenticate, async (req: Request, res: Response) => { + const authReq = req as AuthRequest; + 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; + const sid = req.headers['x-socket-id'] as string; + + handleServiceResult(res, await syncSynologyAlbumLink(authReq.user.id, tripId, linkId, sid)); +}); + +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, 100); + + 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) => { + const authReq = req as AuthRequest; + const { tripId, photoId, ownerId } = req.params; + + if (!canAccessUserPhoto(authReq.user.id, Number(ownerId), tripId, photoId, 'synologyphotos')) { + handleServiceResult(res, fail('You don\'t have access to this photo', 403)); + } + else { + handleServiceResult(res, await getSynologyAssetInfo(authReq.user.id, photoId, Number(ownerId))); + } +}); + +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; + + if (kind !== 'thumbnail' && kind !== 'original') { + handleServiceResult(res, fail('Invalid asset kind', 400)); + } + + if (!canAccessUserPhoto(authReq.user.id, Number(ownerId), tripId, photoId, 'synologyphotos')) { + 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)); + } + +}); + +export default router; diff --git a/server/src/routes/memories/unified.ts b/server/src/routes/memories/unified.ts new file mode 100644 index 0000000..569bb2b --- /dev/null +++ b/server/src/routes/memories/unified.ts @@ -0,0 +1,104 @@ +import express, { Request, Response } from 'express'; +import { authenticate } from '../../middleware/auth'; +import { AuthRequest } from '../../types'; +import { + listTripPhotos, + listTripAlbumLinks, + createTripAlbumLink, + removeAlbumLink, + addTripPhotos, + removeTripPhoto, + setTripPhotoSharing, +} from '../../services/memories/unifiedService'; +import immichRouter from './immich'; +import synologyRouter from './synology'; +import { Selection } from '../../services/memories/helpersService'; + +const router = express.Router(); + +router.use('/immich', immichRouter); +router.use('/synologyphotos', synologyRouter); + +//------------------------------------------------ +// routes for managing photos linked to trip + +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); + if ('error' in result) return res.status(result.error.status).json({ error: result.error.message }); + res.json({ photos: result.data }); +}); + +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; + 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, + selections, + sid, + ); + if ('error' in result) return res.status(result.error.status).json({ error: result.error.message }); + + res.json({ success: true, added: result.data.added }); +}); + +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( + tripId, + authReq.user.id, + req.body?.provider, + req.body?.asset_id, + req.body?.shared, + ); + if ('error' in result) return res.status(result.error.status).json({ error: result.error.message }); + res.json({ success: true }); +}); + +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); + 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('/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); + if ('error' in result) return res.status(result.error.status).json({ error: result.error.message }); + res.json({ links: result.data }); +}); + +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); + if ('error' in result) return res.status(result.error.status).json({ error: result.error.message }); + res.json({ success: true }); +}); + +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); + if ('error' in result) return res.status(result.error.status).json({ error: result.error.message }); + res.json({ success: true }); +}); + + + + +export default router; diff --git a/server/src/services/adminService.ts b/server/src/services/adminService.ts index 28d2f90..cdd8dba 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'; import { send as sendNotification } from './notificationService'; // ── Helpers ──────────────────────────────────────────────────────────────── @@ -493,17 +494,91 @@ export function isAddonEnabled(addonId: string): boolean { 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, sort_order + FROM photo_providers + ORDER BY sort_order, id + `).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 + 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: getPhotoProviderConfig(p.id), + 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; 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); + } + + 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; 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: getPhotoProviderConfig(updatedProvider.id), + 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/authService.ts b/server/src/services/authService.ts index ed36617..5cbb236 100644 --- a/server/src/services/authService.ts +++ b/server/src/services/authService.ts @@ -983,7 +983,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; diff --git a/server/src/services/memories/helpersService.ts b/server/src/services/memories/helpersService.ts new file mode 100644 index 0000000..2ef7cf4 --- /dev/null +++ b/server/src/services/memories/helpersService.ts @@ -0,0 +1,194 @@ +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 + +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: Error, fallbackMessage: string): ServiceError { + if (error && /unique|constraint/i.test(error.message)) { + return fail('Resource already exists', 409); + } + return fail(error.message, 500); +} + + +export function handleServiceResult(res: Response, result: ServiceResult): void { + if ('error' in result) { + res.status(result.error.status).json({ error: result.error.message }); + } + else { + res.json(result.data); + } +} + +// ---------------------------------------------- +// types used across memories services +export type Selection = { + provider: string; + asset_ids: string[]; +}; + +export type StatusResult = { + connected: true; + user: { name: string } +} | { + connected: false; + error: string +}; + +export type SyncAlbumResult = { + added: number; + total: number +}; + + +export type AlbumsList = { + albums: Array<{ id: string; albumName: string; assetCount: number }> +}; + +export type Asset = { + id: string; + takenAt: string; +}; + +export type AssetsList = { + 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; + 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 + +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); +} + +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); + 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); + } + } + 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/immichService.ts b/server/src/services/memories/immichService.ts similarity index 80% rename from server/src/services/immichService.ts rename to server/src/services/memories/immichService.ts index 99fec67..5f8138d 100644 --- a/server/src/services/immichService.ts +++ b/server/src/services/memories/immichService.ts @@ -1,7 +1,9 @@ -import { db, canAccessTrip } from '../db/database'; -import { maybe_encrypt_api_key, decrypt_api_key } from './apiKeyCrypto'; -import { checkSsrf } from '../utils/ssrfGuard'; -import { writeAudit } from './auditLog'; +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 ──────────────────────────────────────────────────────────── @@ -187,62 +189,9 @@ export async function searchPhotos( } } -// ── Trip Photos ──────────────────────────────────────────────────────────── - -export function listTripPhotos(tripId: string, userId: number) { - return db.prepare(` - SELECT tp.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.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, immich_asset_id, shared) VALUES (?, ?, ?, ?)' - ); - let added = 0; - for (const assetId of assetIds) { - const result = insert.run(tripId, userId, assetId, 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); -} - -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); -} // ── 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 rows = db.prepare(` - SELECT tp.trip_id FROM trip_photos tp - WHERE tp.immich_asset_id = ? AND tp.user_id = ? AND tp.shared = 1 - `).all(assetId, ownerUserId) as { trip_id: number }[]; - if (rows.length === 0) return false; - return rows.some(row => !!canAccessTrip(String(row.trip_id), requestingUserId)); -} export async function getAssetInfo( userId: number, @@ -387,8 +336,6 @@ export function createAlbumLink( } export function deleteAlbumLink(linkId: string, tripId: string, userId: number) { - db.prepare('DELETE FROM trip_photos WHERE album_link_id = ? AND trip_id = ? AND user_id = ?') - .run(linkId, tripId, userId); db.prepare('DELETE FROM trip_album_links WHERE id = ? AND trip_id = ? AND user_id = ?') .run(linkId, tripId, userId); } @@ -396,17 +343,17 @@ 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 link = db.prepare('SELECT * FROM trip_album_links WHERE id = ? AND trip_id = ? AND user_id = ?') - .get(linkId, tripId, userId) as any; - if (!link) 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/${link.immich_album_id}`, { + 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), }); @@ -414,18 +361,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, immich_asset_id, shared, album_link_id) VALUES (?, ?, ?, 1, ?)' - ); - let added = 0; - for (const asset of assets) { - const r = insert.run(tripId, userId, asset.id, linkId); - 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 result = await addTripPhotos(tripId, userId, true, [selection], sid, linkId); + if ('error' in result) return { error: result.error.message, status: result.error.status }; - return { success: true, added, total: assets.length }; + updateSyncTimeForAlbumLink(linkId); + + 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/memories/synologyService.ts b/server/src/services/memories/synologyService.ts new file mode 100644 index 0000000..ff44179 --- /dev/null +++ b/server/src/services/memories/synologyService.ts @@ -0,0 +1,503 @@ + +import { Response } from 'express'; +import { db } from '../../db/database'; +import { decrypt_api_key, encrypt_api_key, maybe_encrypt_api_key } from '../apiKeyCrypto'; +import { checkSsrf } from '../../utils/ssrfGuard'; +import { addTripPhotos } from './unifiedService'; +import { + getAlbumIdFromLink, + updateSyncTimeForAlbumLink, + Selection, + ServiceResult, + fail, + success, + handleServiceResult, + pipeAsset, + AlbumsList, + AssetsList, + StatusResult, + SyncAlbumResult, + AssetInfo +} from './helpersService'; + +const SYNOLOGY_PROVIDER = 'synologyphotos'; +const SYNOLOGY_ENDPOINT_PATH = '/photo/webapi/entry.cgi'; + +interface SynologyUserRecord { + synology_url?: string | null; + synology_username?: string | null; + synology_password?: string | null; + synology_sid?: string | null; +}; + +interface SynologyCredentials { + synology_url: string; + synology_username: string; + synology_password: string; +} + +interface SynologySettings { + synology_url: string; + synology_username: string; + connected: boolean; +} + +interface ApiCallParams { + api: string; + method: string; + version?: number; + [key: string]: unknown; +} + +interface SynologyApiResponse { + success: boolean; + data?: T; + error?: { code: number }; +} + + +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; + }; +} + + +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 fail('User not found', 404); + } + + const filtered: SynologyUserRecord = {}; + for (const column of columns) { + filtered[column] = row[column]; + } + + if (!filtered) { + return fail('Failed to read Synology user data', 500); + } + + return success(filtered); + } catch { + return fail('Failed to read Synology user data', 500); + } +} + +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, params: string): string { + const normalized = url.replace(/\/$/, '').match(/^https?:\/\//) ? url.replace(/\/$/, '') : `https://${url.replace(/\/$/, '')}`; + return `${normalized}${SYNOLOGY_ENDPOINT_PATH}?${params}`; +} + +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, `api=${body.get('api')}`); + const SsrfResult = await checkSsrf(endpoint); + if (!SsrfResult.allowed) { + return fail(SsrfResult.error, 400); + } + try { + 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 fail('Synology API request failed with status ' + resp.status, resp.status); + } + const response = await resp.json() as SynologyApiResponse; + return response.success ? success(response.data) : fail('Synology failed with code ' + response.error.code, response.error.code); + } + catch { + return fail('Failed to connect to Synology API', 500); + } + + +} + +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, + }); + + 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.success) { + return creds as ServiceResult; + } + + const session = await _getSynologySession(userId); + if (!session.success || !session.data) { + return session as ServiceResult; + } + + 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.data.synology_url, _buildSynologyFormBody({ ...params, _sid: retrySession.data })); + } + return result; +} + +function _normalizeSynologyPhotoInfo(item: SynologyPhotoItem): AssetInfo { + 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, + width: item.additional?.resolution?.width || null, + height: item.additional?.resolution?.height || null, + fileSize: item.filesize || null, + fileName: item.filename || null, + }; +} + + +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 }; +} + +async function _getSynologySession(userId: number): Promise> { + const cachedSid = _readSynologyUser(userId, ['synology_sid']); + if (cachedSid.success && cachedSid.data?.synology_sid) { + const decryptedSid = decrypt_api_key(cachedSid.data.synology_sid); + return success(decryptedSid); + } + + const creds = _getSynologyCredentials(userId); + if (!creds.success) { + return creds as ServiceResult; + } + + const resp = await _loginToSynology(creds.data.synology_url, creds.data.synology_username, creds.data.synology_password); + + if (!resp.success) { + return resp as ServiceResult; + } + + const encrypted = encrypt_api_key(resp.data); + db.prepare('UPDATE users SET synology_sid = ? WHERE id = ?').run(encrypted, userId); + return success(resp.data); +} + +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> { + + const ssrf = await checkSsrf(synologyUrl); + if (!ssrf.allowed) { + return fail(ssrf.error, 400); + } + + const result = _readSynologyUser(userId, ['synology_password']) + if (!result.success) return result as ServiceResult; + const existingEncryptedPassword = result.data?.synology_password || null; + + if (!synologyPassword && !existingEncryptedPassword) { + return fail('No stored password found. Please provide a password to save settings.', 400); + } + + 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 { + return fail('Failed to update Synology settings', 500); + } + + _clearSynologySID(userId); + return success("settings updated"); +} + +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 user = db.prepare('SELECT synology_username FROM users WHERE id = ?').get(userId) as { synology_username?: string } | undefined; + return success({ connected: true, user: { name: user?.synology_username || 'unknown user' } }); + } catch (err: unknown) { + return success({ connected: true, user: { name: 'unknown user' } }); + } +} + +export async function testSynologyConnection(synologyUrl: string, synologyUsername: string, synologyPassword: string): Promise> { + + const ssrf = await checkSsrf(synologyUrl); + if (!ssrf.allowed) { + return fail(ssrf.error, 400); + } + + 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> { + 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; + + const albums = (result.data.list || []).map((album: any) => ({ + id: String(album.id), + albumName: album.name || '', + assetCount: album.item_count || 0, + })); + + return success({ albums }); +} + + +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; + + 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(response.data), + offset, + limit: pageSize, + additional: ['thumbnail'], + }); + + if (!result.success) return result as ServiceResult; + + const items = result.data.list || []; + allItems.push(...items); + if (items.length < pageSize) break; + offset += pageSize; + } + + const selection: Selection = { + provider: SYNOLOGY_PROVIDER, + asset_ids: allItems.map(item => String(item.additional?.thumbnail?.cache_key || '')).filter(id => id), + }; + + + 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 }); +} + +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', + 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) return result as ServiceResult<{ assets: AssetInfo[]; total: number; hasMore: boolean }>; + + const allItems = result.data.list || []; + const total = allItems.length; + const assets = allItems.map(item => _normalizeSynologyPhotoInfo(item)); + + 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, { + api: 'SYNO.Foto.Browse.Item', + method: 'get', + version: 5, + 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; + + const metadata = result.data.list?.[0]; + if (!metadata) return fail('Photo not found', 404); + + const normalized = _normalizeSynologyPhotoInfo(metadata); + normalized.id = photoId; + 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_credentials = _getSynologyCredentials(targetUserId); + if (!synology_credentials.success) { + handleServiceResult(response, synology_credentials); + return; + } + + 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({ + api: 'SYNO.Foto.Thumbnail', + method: 'get', + version: '2', + mode: 'download', + id: parsedId.id, + type: 'unit', + size: size, + cache_key: parsedId.cacheKey, + _sid: sid.data, + }) + : new URLSearchParams({ + api: 'SYNO.Foto.Download', + method: 'download', + version: '2', + cache_key: parsedId.cacheKey, + unit_id: `[${parsedId.id}]`, + _sid: sid.data, + }); + + const url = _buildSynologyEndpoint(synology_credentials.data.synology_url, params.toString()); + await pipeAsset(url, response) +} + diff --git a/server/src/services/memories/unifiedService.ts b/server/src/services/memories/unifiedService.ts new file mode 100644 index 0000000..a836524 --- /dev/null +++ b/server/src/services/memories/unifiedService.ts @@ -0,0 +1,306 @@ +import { db, canAccessTrip } from '../../db/database'; +import { send } from '../notificationService'; +import { broadcast } from '../../websocket'; +import { + ServiceResult, + fail, + success, + mapDbError, + Selection, +} from './helpersService'; + + +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): 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) { + return fail('Trip not found or access denied', 404); + } + + try { + + const enabledProviders = _providers().filter(p => 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 + 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) + AND tp.provider IN (${enabledProviders.map(() => '?').join(',')}) + ORDER BY tp.added_at ASC + `).all(tripId, userId, ...enabledProviders); + + 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); + } + + + 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, + 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 = ? + AND tal.provider IN (${enabledProviders.map(() => '?').join(',')}) + ORDER BY tal.created_at ASC + `).all(tripId, ...enabledProviders); + + 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, albumLinkId?: string): ServiceResult { + const providerResult = _validProvider(provider); + if (!providerResult.success) { + return providerResult as ServiceResult; + } + 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( + tripId: string, + userId: number, + shared: boolean, + selections: Selection[], + sid: string, + albumLinkId?: 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); + } + + let added = 0; + for (const selection of selections) { + 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(); + 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 }); +} + + +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 fail('provider is required', 400); + } + if (!albumId) { + return fail('album_id required', 400); + } + + + const providerResult = _validProvider(provider); + if (!providerResult.success) { + return providerResult as ServiceResult; + } + + try { + 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); + + if (result.changes === 0) { + return fail('Album already linked', 409); + } + + return success(true); + } catch (error) { + return mapDbError(error, 'Failed to link album'); + } +} + +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); + } + + try { + 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'); + } +} + + +//----------------------------------------------- +// notifications helper + +async function _notifySharedTripPhotos( + tripId: string, + actorUserId: number, + added: number, +): Promise> { + if (added <= 0) return fail('No photos shared, skipping notifications', 200); + + try { + 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: 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); + } +}