diff --git a/server/src/nest/addons/addons.service.ts b/server/src/nest/addons/addons.service.ts index 387260796..e96ecb8e3 100644 --- a/server/src/nest/addons/addons.service.ts +++ b/server/src/nest/addons/addons.service.ts @@ -1,7 +1,7 @@ import { Injectable } from '@nestjs/common'; import { DatabaseService } from '../database/database.service'; import type { Addon } from '../../types'; -import { getPhotoProviderConfig } from '../../services/memories/helpersService'; +import { getPhotoProviderConfig } from '../memories/memories.helpers'; /** * Thin wrapper around the enabled-addons + photo-provider read that the legacy diff --git a/server/src/nest/admin/admin.service.ts b/server/src/nest/admin/admin.service.ts index fbc3579f8..e4f6b1c42 100644 --- a/server/src/nest/admin/admin.service.ts +++ b/server/src/nest/admin/admin.service.ts @@ -18,7 +18,7 @@ import type { User, Addon } from '../../types'; import { maybe_encrypt_api_key, decrypt_api_key } from '../common/crypto/apiKeyCrypto'; import { avatarUrl } from '../common/avatarUrl'; import { prepareLlmAddonConfigForWrite, maskLlmAddonConfig } from '../llm-parse/llm-config'; -import { getPhotoProviderConfig } from '../../services/memories/helpersService'; +import { getPhotoProviderConfig } from '../memories/memories.helpers'; import { validatePassword } from '../common/passwordPolicy'; import { UserCleanupService } from '../auth/user-cleanup.service'; import { NotificationPreferencesService } from '../notifications/notification-preferences.service'; diff --git a/server/src/nest/journey/journey.module.ts b/server/src/nest/journey/journey.module.ts index 6d23abb2d..763f06ab8 100644 --- a/server/src/nest/journey/journey.module.ts +++ b/server/src/nest/journey/journey.module.ts @@ -3,9 +3,11 @@ import { JourneyController } from './journey.controller'; import { JourneyPublicController } from './journey-public.controller'; import { JourneyService } from './journey.service'; import { AddonsModule } from '../addons/addons.module'; +import { MemoriesModule } from '../memories/memories.module'; @Module({ - imports: [AddonsModule], + // MemoriesModule: the journey gallery streams provider assets and uploads to Immich. + imports: [AddonsModule, MemoriesModule], controllers: [JourneyController, JourneyPublicController], providers: [JourneyService], }) diff --git a/server/src/nest/journey/journey.service.ts b/server/src/nest/journey/journey.service.ts index a2fb0471b..3c415cbe4 100644 --- a/server/src/nest/journey/journey.service.ts +++ b/server/src/nest/journey/journey.service.ts @@ -2,8 +2,9 @@ import { Injectable } from '@nestjs/common'; import { DatabaseService } from '../database/database.service'; import * as svc from '../../services/journeyService'; import * as share from '../../services/journeyShareService'; -import { uploadToImmich, streamImmichAsset } from '../../services/memories/immichService'; -import { streamPhoto } from '../../services/memories/photoResolverService'; +import { ImmichService } from '../memories/immich.service'; +import { SynologyService } from '../memories/synology.service'; +import { PhotoResolverService } from '../memories/photo-resolver.service'; import { AddonsService } from '../addons/addons.service'; import { ADDON_IDS } from '../../addons'; import type { Response } from 'express'; @@ -19,6 +20,9 @@ export class JourneyService { constructor( private readonly dbs: DatabaseService, private readonly addons: AddonsService, + private readonly immich: ImmichService, + private readonly synology: SynologyService, + private readonly photoResolver: PhotoResolverService, ) {} private get db() { @@ -85,16 +89,17 @@ export class JourneyService { const prefs = this.db.prepare('SELECT immich_auto_upload FROM users WHERE id = ?').get(userId) as { immich_auto_upload?: number } | undefined; return !!prefs?.immich_auto_upload; } - uploadToImmich(userId: number, relativePath: string, originalName: string) { return uploadToImmich(userId, relativePath, originalName); } + uploadToImmich(userId: number, relativePath: string, originalName: string) { return this.immich.uploadToImmich(userId, relativePath, originalName); } // Public (share-token) access — no auth, validated by token. getPublicJourney(token: string) { return share.getPublicJourney(token); } validateShareTokenForPhoto(token: string, photoId: number) { return share.validateShareTokenForPhoto(token, photoId); } validateShareTokenForAsset(token: string, assetId: string) { return share.validateShareTokenForAsset(token, assetId); } - streamPhoto(res: Response, ownerId: number, photoId: number, kind: 'thumbnail' | 'original') { return streamPhoto(res, ownerId, photoId, kind); } - streamImmichAsset(res: Response, userId: number, assetId: string, kind: 'thumbnail' | 'original', ownerId: number) { return streamImmichAsset(res, userId, assetId, kind, ownerId); } - async streamSynologyAsset(res: Response, userId: number, ownerId: number, assetId: string, kind: 'thumbnail' | 'original') { - const { streamSynologyAsset } = await import('../../services/memories/synologyService'); - return streamSynologyAsset(res, userId, ownerId, assetId, kind); + streamPhoto(res: Response, ownerId: number, photoId: number, kind: 'thumbnail' | 'original') { return this.photoResolver.streamPhoto(res, ownerId, photoId, kind); } + streamImmichAsset(res: Response, userId: number, assetId: string, kind: 'thumbnail' | 'original', ownerId: number) { return this.immich.streamImmichAsset(res, userId, assetId, kind, ownerId); } + // Was a dynamic import(): the memories cluster had a real import cycle and + // this was the workaround. The cycle is gone, so it is a plain injection now. + streamSynologyAsset(res: Response, userId: number, ownerId: number, assetId: string, kind: 'thumbnail' | 'original') { + return this.synology.streamSynologyAsset(res, userId, ownerId, assetId, kind); } } diff --git a/server/src/nest/memories/immich.service.ts b/server/src/nest/memories/immich.service.ts new file mode 100644 index 000000000..0f930539b --- /dev/null +++ b/server/src/nest/memories/immich.service.ts @@ -0,0 +1,605 @@ +import { Injectable } from '@nestjs/common'; +import type { Response } from 'express'; +import path from 'path'; +import { maybe_encrypt_api_key, decrypt_api_key } from '../common/crypto/apiKeyCrypto'; +import { checkSsrf, safeFetch } from '../../utils/ssrfGuard'; +import { AuditService } from '../audit/audit.service'; +import { DatabaseService } from '../database/database.service'; +import { MemoriesAccessService } from './memories-access.service'; +import { pipeAsset, type Selection } from './memories.helpers'; +import { UPLOADS_ROOT } from './uploads-root'; + +const ALBUM_PAGE_SIZE = 1000; +const ALBUM_MAX_PAGES = 20; + +/** + * Immich photo provider: credentials, connection test, timeline/search browsing, + * album listing and the asset proxy. + * + * Folded 1:1 from services/memories/immichService.ts - every SQL statement, + * error string and status code is the one that shipped. + */ +@Injectable() +export class ImmichService { + constructor( + private readonly db: DatabaseService, + private readonly audit: AuditService, + private readonly access: MemoriesAccessService, + ) {} + + getImmichCredentials(userId: number) { + const user = this.db.prepare('SELECT immich_url, immich_api_key FROM users WHERE id = ?').get(userId) as any; + if (!user?.immich_url || !user?.immich_api_key) return null; + const apiKey = decrypt_api_key(user.immich_api_key); + if (!apiKey) return null; + return { immich_url: user.immich_url as string, immich_api_key: apiKey }; + } + + /** Validate that an asset ID is a safe UUID-like string (no path traversal). */ + isValidAssetId(id: string): boolean { + return /^[a-zA-Z0-9_-]+$/.test(id) && id.length <= 100; + } + + /** + * Hidden assets have no generated thumbnail — Immich's own enum documents + * `AssetVisibility.Hidden` as "Video part of the LivePhotos and MotionPhotos", + * and its UI never shows them standalone. Surfacing or persisting one yields a + * permanently broken tile, since nothing re-checks visibility on the render + * path (#1474). + * + * Load-bearing on Immich < 1.133, which predates the `visibility` field: those + * servers strip the `visibility: 'timeline'` search filter and never defaulted + * `isVisible`, so they return hidden assets and this is the only thing stopping + * them. They mark the same state as `isVisible: false`. + */ + private isVisibleAsset(a: any): boolean { + return a.visibility !== 'hidden' && a.isVisible !== false; + } + + // ── Connection Settings ──────────────────────────────────────────────────── + + getConnectionSettings(userId: number) { + const creds = this.getImmichCredentials(userId); + const prefs = this.db.prepare('SELECT immich_auto_upload FROM users WHERE id = ?').get(userId) as { immich_auto_upload?: number } | undefined; + return { + immich_url: creds?.immich_url || '', + connected: !!(creds?.immich_url && creds?.immich_api_key), + auto_upload: !!(prefs?.immich_auto_upload), + }; + } + + setImmichAutoUpload(userId: number, enabled: boolean): void { + this.db.prepare('UPDATE users SET immich_auto_upload = ? WHERE id = ?').run(enabled ? 1 : 0, userId); + } + + async saveImmichSettings( + userId: number, + immichUrl: string | undefined, + immichApiKey: string | undefined, + clientIp: string | null + ): Promise<{ success: boolean; warning?: string; error?: string }> { + if (immichUrl) { + const ssrf = await checkSsrf(immichUrl.trim()); + if (!ssrf.allowed) { + return { success: false, error: `Invalid Immich URL: ${ssrf.error}` }; + } + this.db.prepare('UPDATE users SET immich_url = ?, immich_api_key = ? WHERE id = ?').run( + immichUrl.trim(), + maybe_encrypt_api_key(immichApiKey), + userId + ); + if (ssrf.isPrivate) { + this.audit.writeAudit({ + userId, + action: 'immich.private_ip_configured', + ip: clientIp, + details: { immich_url: immichUrl.trim(), resolved_ip: ssrf.resolvedIp }, + }); + return { + success: true, + warning: `Immich URL resolves to a private IP address (${ssrf.resolvedIp}). Make sure this is intentional.`, + }; + } + } else { + this.db.prepare('UPDATE users SET immich_url = ?, immich_api_key = ? WHERE id = ?').run( + null, + maybe_encrypt_api_key(immichApiKey), + userId + ); + } + return { success: true }; + } + + // ── Connection Test / Status ─────────────────────────────────────────────── + + async testConnection( + immichUrl: string, + immichApiKey: string + ): Promise<{ connected: boolean; error?: string; user?: { name?: string; email?: string }; canonicalUrl?: string }> { + const ssrf = await checkSsrf(immichUrl); + if (!ssrf.allowed) return { connected: false, error: ssrf.error ?? 'Invalid Immich URL' }; + try { + const resp = await safeFetch(`${immichUrl}/api/users/me`, { + headers: { 'x-api-key': immichApiKey, 'Accept': 'application/json' }, + signal: AbortSignal.timeout(10000) as any, + }); + if (!resp.ok) return { connected: false, error: `HTTP ${resp.status}` }; + const data = await resp.json() as { name?: string; email?: string }; + + // Detect http → https upgrade only: same host/port, protocol changed to https + let canonicalUrl: string | undefined; + if (resp.url) { + const finalUrl = new URL(resp.url); + const inputUrl = new URL(immichUrl); + if ( + inputUrl.protocol === 'http:' && + finalUrl.protocol === 'https:' && + finalUrl.hostname === inputUrl.hostname && + finalUrl.port === inputUrl.port + ) { + canonicalUrl = finalUrl.origin; + } + } + + return { connected: true, user: { name: data.name, email: data.email }, canonicalUrl }; + } catch (err: unknown) { + return { connected: false, error: err instanceof Error ? err.message : 'Connection failed' }; + } + } + + async getConnectionStatus( + userId: number + ): Promise<{ connected: boolean; error?: string; user?: { name?: string; email?: string } }> { + const creds = this.getImmichCredentials(userId); + if (!creds) return { connected: false, error: 'Not configured' }; + try { + const resp = await safeFetch(`${creds.immich_url}/api/users/me`, { + headers: { 'x-api-key': creds.immich_api_key, 'Accept': 'application/json' }, + signal: AbortSignal.timeout(10000) as any, + }); + if (!resp.ok) return { connected: false, error: `HTTP ${resp.status}` }; + const data = await resp.json() as { name?: string; email?: string }; + return { connected: true, user: { name: data.name, email: data.email } }; + } catch (err: unknown) { + return { connected: false, error: err instanceof Error ? err.message : 'Connection failed' }; + } + } + + // ── Browse Timeline / Search ─────────────────────────────────────────────── + + async browseTimeline( + userId: number + ): Promise<{ buckets?: any; error?: string; status?: number }> { + const creds = this.getImmichCredentials(userId); + if (!creds) return { error: 'Immich not configured', status: 400 }; + + try { + const resp = await safeFetch(`${creds.immich_url}/api/timeline/buckets`, { + method: 'GET', + headers: { 'x-api-key': creds.immich_api_key, 'Accept': 'application/json' }, + signal: AbortSignal.timeout(15000) as any, + }); + if (!resp.ok) return { error: 'Failed to fetch from Immich', status: resp.status }; + const buckets = await resp.json(); + return { buckets }; + } catch { + return { error: 'Could not reach Immich', status: 502 }; + } + } + + async searchPhotos( + userId: number, + from?: string, + to?: string, + page: number = 1, + size: number = 50, + ): Promise<{ assets?: any[]; hasMore?: boolean; error?: string; status?: number }> { + const creds = this.getImmichCredentials(userId); + if (!creds) return { error: 'Immich not configured', status: 400 }; + + try { + const resp = await safeFetch(`${creds.immich_url}/api/search/metadata`, { + method: 'POST', + headers: { 'x-api-key': creds.immich_api_key, 'Content-Type': 'application/json' }, + body: JSON.stringify({ + takenAfter: from ? `${from}T00:00:00.000Z` : undefined, + takenBefore: to ? `${to}T23:59:59.999Z` : undefined, + // No type filter — surface videos alongside images (#823). + // Immich 1.133–1.144 defaulted metadata search to `timeline` visibility; + // v3 defaults to any visibility except `locked`, which is what started + // surfacing Live Photo motion parts as broken tiles (#1474). Ask for + // `timeline` explicitly so hidden assets never cross the wire and a full + // page stays a full page of renderable tiles. + // + // `visibility` only exists from 1.133.0. Older servers strip it — Immich + // validates with `whitelist: true` and no `forbidNonWhitelisted`, so an + // unknown property is dropped, never a 400 — and they never defaulted + // `isVisible` either, so they still return hidden assets. this.isVisibleAsset() + // below is the only guard on those versions. Do not remove it. + visibility: 'timeline', + withExif: true, + size, + page, + }), + signal: AbortSignal.timeout(15000) as any, + }); + if (!resp.ok) return { error: 'Search failed', status: resp.status }; + const data = await resp.json() as { assets?: { items?: any[] } }; + const items = data.assets?.items || []; + // Belt-and-braces: `visibility: 'timeline'` above should mean Immich never + // sends a hidden asset, but an older server that ignores the filter would + // otherwise render broken tiles. hasMore stays on the raw page length so + // pagination still advances past a filtered page. + const assets = items + .filter((a: unknown) => this.isVisibleAsset(a)) + .map((a: any) => ({ + id: a.id, + takenAt: a.fileCreatedAt || a.createdAt, + city: a.exifInfo?.city || null, + country: a.exifInfo?.country || null, + lat: typeof a.exifInfo?.latitude === 'number' ? a.exifInfo.latitude : null, + lng: typeof a.exifInfo?.longitude === 'number' ? a.exifInfo.longitude : null, + mediaType: a.type === 'VIDEO' ? 'video' : 'image', + })); + return { assets, hasMore: items.length >= size }; + } catch { + return { error: 'Could not reach Immich', status: 502 }; + } + } + + + // ── Asset Info / Proxy ───────────────────────────────────────────────────── + + + async getAssetInfo( + userId: number, + assetId: string, + ownerUserId?: number + ): Promise<{ data?: any; error?: string; status?: number }> { + const effectiveUserId = ownerUserId ?? userId; + const creds = this.getImmichCredentials(effectiveUserId); + if (!creds) return { error: 'Not found', status: 404 }; + + try { + const resp = await safeFetch(`${creds.immich_url}/api/assets/${assetId}`, { + headers: { 'x-api-key': creds.immich_api_key, 'Accept': 'application/json' }, + signal: AbortSignal.timeout(10000) as any, + }); + if (!resp.ok) return { error: 'Failed', status: resp.status }; + const asset = await resp.json() as any; + return { + data: { + id: asset.id, + takenAt: asset.fileCreatedAt || asset.createdAt, + width: asset.exifInfo?.exifImageWidth || null, + height: asset.exifInfo?.exifImageHeight || null, + camera: asset.exifInfo?.make && asset.exifInfo?.model ? `${asset.exifInfo.make} ${asset.exifInfo.model}` : null, + lens: asset.exifInfo?.lensModel || null, + focalLength: asset.exifInfo?.focalLength ? `${asset.exifInfo.focalLength}mm` : null, + aperture: asset.exifInfo?.fNumber ? `f/${asset.exifInfo.fNumber}` : null, + shutter: asset.exifInfo?.exposureTime || null, + iso: asset.exifInfo?.iso || null, + city: asset.exifInfo?.city || null, + state: asset.exifInfo?.state || null, + country: asset.exifInfo?.country || null, + lat: asset.exifInfo?.latitude || null, + lng: asset.exifInfo?.longitude || null, + fileSize: asset.exifInfo?.fileSizeInByte || null, + fileName: asset.originalFileName || null, + }, + }; + } catch { + return { error: 'Proxy error', status: 502 }; + } + } + + async fetchImmichThumbnailBytes( + userId: number, + assetId: string, + ownerUserId?: number + ): Promise<{ bytes: Buffer; contentType: string } | { error: string; status: number }> { + const effectiveUserId = ownerUserId ?? userId; + const creds = this.getImmichCredentials(effectiveUserId); + if (!creds) return { error: 'Not found', status: 404 }; + + const url = `${creds.immich_url}/api/assets/${assetId}/thumbnail?size=thumbnail`; + try { + const resp = await safeFetch(url, { + headers: { 'x-api-key': creds.immich_api_key }, + signal: AbortSignal.timeout(10000) as any, + }); + if (!resp.ok) return { error: 'Upstream error', status: resp.status }; + const contentType = resp.headers.get('content-type') || 'image/jpeg'; + const bytes = Buffer.from(await resp.arrayBuffer()); + return { bytes, contentType }; + } catch { + return { error: 'Proxy error', status: 502 }; + } + } + + async streamImmichAsset( + response: Response, + userId: number, + assetId: string, + kind: 'thumbnail' | 'original', + ownerUserId?: number, + opts?: { mediaType?: string | null; range?: string }, + ): Promise<{ error?: string; status?: number } | void> { + const effectiveUserId = ownerUserId ?? userId; + const creds = this.getImmichCredentials(effectiveUserId); + if (!creds) return { error: 'Not found', status: 404 }; + + const isVideo = opts?.mediaType === 'video'; + const headers: Record = { 'x-api-key': creds.immich_api_key }; + let url: string; + let timeout: number | undefined; + let cacheControl = 'public, max-age=86400'; + + if (kind === 'thumbnail') { + // Immich generates a poster thumbnail for video too. + url = `${creds.immich_url}/api/assets/${assetId}/thumbnail?size=thumbnail`; + timeout = 10000; + } else if (isVideo) { + // Transcoded, broadly-compatible MP4 with byte-range support; forward the + // viewer's Range so the player can seek. No abort timeout — video is a long + // streaming response, not a quick fetch (#823). + url = `${creds.immich_url}/api/assets/${assetId}/video/playback`; + if (opts?.range) headers['Range'] = opts.range; + cacheControl = 'private, max-age=3600'; + timeout = undefined; + } else { + url = `${creds.immich_url}/api/assets/${assetId}/thumbnail?size=fullsize`; + timeout = 30000; + } + + await pipeAsset(url, response, headers, timeout ? AbortSignal.timeout(timeout) : undefined, cacheControl); + } + + // ── Albums ────────────────────────────────────────────────────────────────── + + async listAlbums( + userId: number + ): Promise<{ albums?: any[]; error?: string; status?: number }> { + const creds = this.getImmichCredentials(userId); + if (!creds) return { error: 'Immich not configured', status: 400 }; + + try { + // Fetch both owned and shared albums + const [ownResp, sharedResp] = await Promise.all([ + safeFetch(`${creds.immich_url}/api/albums`, { + headers: { 'x-api-key': creds.immich_api_key, 'Accept': 'application/json' }, + signal: AbortSignal.timeout(10000) as any, + }), + safeFetch(`${creds.immich_url}/api/albums?shared=true`, { + headers: { 'x-api-key': creds.immich_api_key, 'Accept': 'application/json' }, + signal: AbortSignal.timeout(10000) as any, + }), + ]); + if (!ownResp.ok) return { error: 'Failed to fetch albums', status: ownResp.status }; + const ownAlbums = await ownResp.json() as any[]; + const sharedAlbums = sharedResp.ok ? await sharedResp.json() as any[] : []; + const seenIds = new Set(); + const allAlbums = [...ownAlbums, ...sharedAlbums].filter((a: any) => { + if (seenIds.has(a.id)) return false; + seenIds.add(a.id); + return true; + }); + const albums = allAlbums.map((a: any) => ({ + id: a.id, + albumName: a.albumName, + assetCount: a.assetCount || 0, + startDate: a.startDate, + endDate: a.endDate, + albumThumbnailAssetId: a.albumThumbnailAssetId, + shared: a.shared || a.sharedUsers?.length > 0, + })); + return { albums }; + } catch { + return { error: 'Could not reach Immich', status: 502 }; + } + } + + /** Immich caps `size` at 1000 for metadata search; a page cap bounds pathological albums. */ + + /** + * Fetch every asset in an album, across Immich v2 and v3. + * + * Immich v3 removed `assets` from `AlbumResponseDto`, so `GET /api/albums/:id` + * no longer carries album contents (#1492) and we fall back to an + * `albumIds`-filtered metadata search. + * + * We feature-detect on the presence of `assets` rather than switching on a + * probed server version. The two paths are NOT interchangeable: + * + * - Before v3, `searchMetadata` unconditionally scopes results to + * `[self, ...partners]` (`asset.ownerId = ANY(userIds)`), so an albumIds + * search against an album shared by a non-partner returns nothing. v3 added + * an albumIds branch that checks `AlbumRead` and skips that owner filter. + * - 1.133–1.144 also default `visibility` to `timeline`, dropping archived + * album assets. + * - `albumIds` only exists from 1.135.0, and Immich strips unknown properties + * rather than rejecting them (`whitelist: true`, no `forbidNonWhitelisted`). + * Searching an older server would therefore silently drop the album filter + * and return the user's ENTIRE library as the album's contents. + * + * Feature detection makes all three moot: `assets` is present on every version + * that has any of those problems (through 1.144.1) and absent only on v3, where + * the search path is correct. A version probe with a wrong boundary would not be. + */ + private async fetchAlbumAssets( + creds: { immich_url: string; immich_api_key: string }, + albumId: string, + ): Promise<{ assets?: any[]; status?: number }> { + const resp = await safeFetch(`${creds.immich_url}/api/albums/${albumId}`, { + headers: { 'x-api-key': creds.immich_api_key, 'Accept': 'application/json' }, + signal: AbortSignal.timeout(15000) as any, + }); + if (!resp.ok) return { status: resp.status }; + + const albumData = await resp.json() as { assets?: any[] }; + if (Array.isArray(albumData.assets)) return { assets: albumData.assets }; + + return this.fetchAlbumAssetsViaSearch(creds, albumId); + } + + /** + * Immich v3 album contents, via `POST /api/search/metadata` filtered by `albumIds`. + * + * `withExif` is required: it has no default and gates an inner join + * (`searchAssetBuilder`: `.$if(!!options.withExif, withExifInner)`), so without + * it Immich omits `exifInfo` entirely and every photo's city/country goes null. + */ + private async fetchAlbumAssetsViaSearch( + creds: { immich_url: string; immich_api_key: string }, + albumId: string, + ): Promise<{ assets?: any[]; status?: number }> { + const all: any[] = []; + + for (let page = 1; page <= ALBUM_MAX_PAGES; page++) { + const resp = await safeFetch(`${creds.immich_url}/api/search/metadata`, { + method: 'POST', + headers: { 'x-api-key': creds.immich_api_key, 'Content-Type': 'application/json' }, + body: JSON.stringify({ + albumIds: [albumId], + withExif: true, + withDeleted: false, + size: ALBUM_PAGE_SIZE, + page, + }), + signal: AbortSignal.timeout(15000) as any, + }); + if (!resp.ok) return { status: resp.status }; + + const data = await resp.json() as { assets?: { items?: any[] } }; + const items = data.assets?.items || []; + all.push(...items); + if (items.length < ALBUM_PAGE_SIZE) break; + } + + return { assets: all }; + } + + async getAlbumPhotos( + userId: number, + albumId: string, + ): Promise<{ assets?: any[]; error?: string; status?: number }> { + const creds = this.getImmichCredentials(userId); + if (!creds) return { error: 'Immich not configured', status: 400 }; + + try { + const result = await this.fetchAlbumAssets(creds, albumId); + if (!result.assets) return { error: 'Failed to fetch album', status: result.status }; + // Albums legitimately contain hidden assets on both Immich versions (the v2 + // album body and the v3 album search both return them), so filter here (#1474). + const assets = result.assets + .filter((a: unknown) => this.isVisibleAsset(a)) + .map((a: any) => ({ + id: a.id, + takenAt: a.fileCreatedAt || a.createdAt, + city: a.exifInfo?.city || null, + country: a.exifInfo?.country || null, + mediaType: a.type === 'VIDEO' ? 'video' : 'image', + })); + return { assets }; + } catch { + return { error: 'Could not reach Immich', status: 502 }; + } + } + + /** + * The Immich half of an album sync: resolve the link, fetch the album, and hand + * back the picked asset ids. + * + * Adding them to the trip stays with the unified service, which owns + * addTripPhotos. Splitting it there is what breaks the import cycle this module + * used to close (immich -> unified -> photoResolver -> immich): the provider now + * only ever points downhill. Every error string and status below is the one the + * combined function returned. + */ + async collectAlbumSelection( + tripId: string, + linkId: string, + userId: number, + ): Promise<{ selection: Selection; total: number } | { error: string; status: number }> { + const response = this.access.getAlbumIdFromLink(tripId, linkId, userId); + if (!response.success) return { error: 'Album link not found', status: 404 }; + + const creds = this.getImmichCredentials(userId); + if (!creds) return { error: 'Immich not configured', status: 400 }; + + try { + const albumResult = await this.fetchAlbumAssets(creds, response.data as string); + if (!albumResult.assets) return { error: 'Failed to fetch album', status: albumResult.status }; + // Hidden assets must never be persisted: the render path never re-checks + // visibility, so a stored hidden asset is a permanently broken tile (#1474). + const assets = albumResult.assets.filter((a: any) => a.type === 'IMAGE' && this.isVisibleAsset(a)); + + return { + selection: { provider: 'immich', asset_ids: assets.map((a: any) => a.id) }, + total: assets.length, + }; + } catch { + return { error: 'Could not reach Immich', status: 502 }; + } + } + + // ── Upload to Immich ────────────────────────────────────────────────────── + + async uploadToImmich(userId: number, filePath: string, fileName: string): Promise { + const creds = this.getImmichCredentials(userId); + if (!creds) return null; + + const fs = await import('node:fs'); + const path = await import('node:path'); + + const fullPath = path.join(UPLOADS_ROOT, filePath); + if (!fs.existsSync(fullPath)) return null; + + try { + const fileBuffer = fs.readFileSync(fullPath); + const boundary = '----ImmichUpload' + Date.now(); + const ext = path.extname(fileName).toLowerCase(); + const mimeTypes: Record = { + '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.png': 'image/png', + '.gif': 'image/gif', '.webp': 'image/webp', '.heic': 'image/heic', + }; + const contentType = mimeTypes[ext] || 'application/octet-stream'; + const now = new Date().toISOString(); + + const parts: Buffer[] = []; + const addField = (name: string, value: string) => { + parts.push(Buffer.from(`--${boundary}\r\nContent-Disposition: form-data; name="${name}"\r\n\r\n${value}\r\n`)); + }; + addField('deviceAssetId', `trek-${Date.now()}`); + addField('deviceId', 'TREK'); + addField('fileCreatedAt', now); + addField('fileModifiedAt', now); + + parts.push(Buffer.from( + `--${boundary}\r\nContent-Disposition: form-data; name="assetData"; filename="${fileName}"\r\nContent-Type: ${contentType}\r\n\r\n` + )); + parts.push(fileBuffer); + parts.push(Buffer.from(`\r\n--${boundary}--\r\n`)); + + const body = Buffer.concat(parts); + + const res = await safeFetch(`${creds.immich_url}/api/assets`, { + method: 'POST', + headers: { + 'x-api-key': creds.immich_api_key, + 'Content-Type': `multipart/form-data; boundary=${boundary}`, + 'Content-Length': String(body.length), + }, + body, + }); + + if (res.ok) { + const data = await res.json() as { id?: string }; + return data.id || null; + } + return null; + } catch { + return null; + } + } +} diff --git a/server/src/nest/memories/memories-access.service.ts b/server/src/nest/memories/memories-access.service.ts new file mode 100644 index 000000000..947246670 --- /dev/null +++ b/server/src/nest/memories/memories-access.service.ts @@ -0,0 +1,148 @@ +import { Injectable } from '@nestjs/common'; +import { decrypt_api_key } from '../common/crypto/apiKeyCrypto'; +import { DatabaseService } from '../database/database.service'; +import { fail, success, type ServiceResult } from './memories.helpers'; + +/** + * Who may see which photo, and the album-link lookups the provider syncs need. + * + * Split from the pure helpers because these are the only DB-backed ones. Both + * access checks answer for photos that live under a trip *or* a journey — a + * provider asset is reachable through either, which is why neither check can + * live in the trips or journey domain alone. + */ +@Injectable() +export class MemoriesAccessService { + constructor(private readonly db: DatabaseService) {} + + canAccessUserPhoto(requestingUserId: number, ownerUserId: number, tripId: string, assetId: string, provider: string): boolean { + if (requestingUserId === ownerUserId) { + return true; + } + + // Journey photos use tripId=0 — check journey_photos + journey_contributors + if (tripId === '0') { + const journeyPhoto = this.db.get<{ journey_id: number }>(` + SELECT gp.journey_id + FROM journey_photos gp + JOIN trek_photos tkp ON tkp.id = gp.photo_id + WHERE tkp.asset_id = ? + AND tkp.provider = ? + AND tkp.owner_id = ? + LIMIT 1 + `, assetId, provider, ownerUserId); + if (!journeyPhoto) return false; + + const access = this.db.get(` + SELECT 1 FROM journeys WHERE id = ? AND user_id = ? + UNION ALL + SELECT 1 FROM journey_contributors WHERE journey_id = ? AND user_id = ? + LIMIT 1 + `, journeyPhoto.journey_id, requestingUserId, journeyPhoto.journey_id, requestingUserId); + return !!access; + } + + // Regular trip photos — join through trek_photos + const sharedAsset = this.db.get(` + SELECT 1 + FROM trip_photos tp + JOIN trek_photos tkp ON tkp.id = tp.photo_id + WHERE tp.user_id = ? + AND tkp.asset_id = ? + AND tkp.provider = ? + AND tp.trip_id = ? + AND tp.shared = 1 + LIMIT 1 + `, ownerUserId, assetId, provider, tripId); + + if (!sharedAsset) { + return false; + } + return !!this.db.canAccessTrip(tripId, requestingUserId); + } + + // ── Unified photo access check (trek_photos based) ────────────────────── + + canAccessTrekPhoto(requestingUserId: number, trekPhotoId: number): boolean { + const photo = this.db.get<{ id: number; provider: string; owner_id: number | null }>('SELECT * FROM trek_photos WHERE id = ?', trekPhotoId); + if (!photo) return false; + + // Owner always has access + if (photo.owner_id === requestingUserId) return true; + + // Check trip_photos — is this photo shared in a trip the user has access to? + const tripAccess = this.db.get(` + SELECT 1 FROM trip_photos tp + WHERE tp.photo_id = ? + AND tp.shared = 1 + AND EXISTS ( + SELECT 1 FROM trip_members tm WHERE tm.trip_id = tp.trip_id AND tm.user_id = ? + UNION ALL + SELECT 1 FROM trips t WHERE t.id = tp.trip_id AND t.user_id = ? + ) + LIMIT 1 + `, trekPhotoId, requestingUserId, requestingUserId); + if (tripAccess) return true; + + // Check journey_photos — is this photo in a journey the user can access? + const journeyAccess = this.db.get(` + SELECT 1 FROM journey_photos gp + WHERE gp.photo_id = ? + AND EXISTS ( + SELECT 1 FROM journeys j WHERE j.id = gp.journey_id AND j.user_id = ? + UNION ALL + SELECT 1 FROM journey_contributors jc WHERE jc.journey_id = gp.journey_id AND jc.user_id = ? + ) + LIMIT 1 + `, trekPhotoId, requestingUserId, requestingUserId); + if (journeyAccess) return true; + + // Local photos without owner (uploaded files) — check if user has journey access + if (photo.provider === 'local' && !photo.owner_id) { + return !!journeyAccess; + } + + return false; + } + + // ── Album link syncing ────────────────────────────────────────────────── + + getAlbumIdFromLink(tripId: string, linkId: string, userId: number): ServiceResult { + const access = this.db.canAccessTrip(tripId, userId); + if (!access) return fail('Trip not found or access denied', 404); + + try { + const row = this.db.get<{ album_id: string }>( + 'SELECT album_id FROM trip_album_links WHERE id = ? AND trip_id = ? AND user_id = ?', + linkId, tripId, userId, + ); + + return row ? success(row.album_id) : fail('Album link not found', 404); + } catch { + return fail('Failed to retrieve album link', 500); + } + } + + getAlbumLinkForSync(tripId: string, linkId: string, userId: number): ServiceResult<{ albumId: string; passphrase?: string }> { + const access = this.db.canAccessTrip(tripId, userId); + if (!access) return fail('Trip not found or access denied', 404); + + try { + const row = this.db.get<{ album_id: string; passphrase: string | null }>( + 'SELECT album_id, passphrase FROM trip_album_links WHERE id = ? AND trip_id = ? AND user_id = ?', + linkId, tripId, userId, + ); + + if (!row) return fail('Album link not found', 404); + + const decrypted = row.passphrase ? decrypt_api_key(row.passphrase) ?? undefined : undefined; + return success({ albumId: row.album_id, passphrase: decrypted || undefined }); + } catch { + return fail('Failed to retrieve album link', 500); + } + } + + updateSyncTimeForAlbumLink(linkId: string): void { + this.db.run('UPDATE trip_album_links SET last_synced_at = CURRENT_TIMESTAMP WHERE id = ?', linkId); + } +} diff --git a/server/src/nest/memories/memories.helpers.ts b/server/src/nest/memories/memories.helpers.ts index 2b5ce00c4..8f23eb0a6 100644 --- a/server/src/nest/memories/memories.helpers.ts +++ b/server/src/nest/memories/memories.helpers.ts @@ -159,3 +159,27 @@ export async function pipeAsset(url: string, response: Response, headers?: Recor } } +// ── Route shape for the settings page ───────────────────────────────────── + +/** + * Where the client finds a provider's settings/status/test endpoints. Pure + * string building, so it stays here rather than on a service — the admin and + * addons surfaces both read it and neither should have to import the memories + * domain to do so. + */ +export interface 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`, + }; +} diff --git a/server/src/nest/memories/memories.module.ts b/server/src/nest/memories/memories.module.ts index 82a4640b2..c32ffc509 100644 --- a/server/src/nest/memories/memories.module.ts +++ b/server/src/nest/memories/memories.module.ts @@ -1,19 +1,48 @@ import { Module } from '@nestjs/common'; import { MemoriesService } from './memories.service'; +import { MemoriesAccessService } from './memories-access.service'; +import { ImmichService } from './immich.service'; +import { SynologyService } from './synology.service'; +import { UnifiedMemoriesService } from './unified-memories.service'; +import { PhotoResolverService } from './photo-resolver.service'; +import { ThumbnailService } from './thumbnail.service'; +import { TrekPhotoCacheService } from './trek-photo-cache.service'; import { UnifiedMemoriesController } from './unified.controller'; import { ImmichMemoriesController } from './immich.controller'; import { SynologyMemoriesController } from './synology.controller'; +import { AddonsModule } from '../addons/addons.module'; +import { AuditModule } from '../audit/audit.module'; +import { TrekPhotosModule } from '../photos/trek-photos.module'; +import { RealtimeModule } from '../realtime/realtime.module'; /** * Memories (photo-providers) domain — mounted at /api/integrations/memories. * - * Ports the legacy Express router (routes/memories/unified.ts, which composes - * immich.ts + synology.ts) to Nest, reusing services/memories/* unchanged. No - * module-level addon gate — enablement is per-provider-row inside the services, - * exactly as the legacy mount had it. + * No module-level addon gate: enablement is per-provider-row inside the + * services, exactly as the legacy mount had it. TrekPhotosModule supplies the + * trek_photos repository — storage lives there, provider dispatch here. + * + * RealtimeModule is imported explicitly even though it is @Global: an e2e + * TestingModule built around one domain does not get a global AppModule never + * loaded, and MemoriesService broadcasts. + * + * PhotoResolverService and MemoriesAccessService are exported because the + * /api/photos surface and the journey domain resolve provider assets through + * them. */ @Module({ + imports: [AddonsModule, AuditModule, TrekPhotosModule, RealtimeModule], controllers: [UnifiedMemoriesController, ImmichMemoriesController, SynologyMemoriesController], - providers: [MemoriesService], + providers: [ + MemoriesService, + MemoriesAccessService, + ImmichService, + SynologyService, + UnifiedMemoriesService, + PhotoResolverService, + ThumbnailService, + TrekPhotoCacheService, + ], + exports: [MemoriesAccessService, PhotoResolverService, ImmichService, SynologyService], }) export class MemoriesModule {} diff --git a/server/src/nest/memories/memories.service.ts b/server/src/nest/memories/memories.service.ts index 4995cdd01..432430c61 100644 --- a/server/src/nest/memories/memories.service.ts +++ b/server/src/nest/memories/memories.service.ts @@ -1,60 +1,32 @@ import { Injectable } from '@nestjs/common'; import type { Response } from 'express'; -import { - listTripPhotos, - listTripAlbumLinks, - createTripAlbumLink, - removeAlbumLink, - addTripPhotos, - syncImmichAlbum, - syncSynologyAlbum, - removeTripPhoto, - setTripPhotoSharing, -} from '../../services/memories/unifiedService'; -import { - getConnectionSettings, - saveImmichSettings, - setImmichAutoUpload, - testConnection, - getConnectionStatus, - browseTimeline, - searchPhotos, - streamImmichAsset, - listAlbums, - getAlbumPhotos, - getAssetInfo, - isValidAssetId, -} from '../../services/memories/immichService'; -import { - getSynologySettings, - updateSynologySettings, - getSynologyStatus, - testSynologyConnection, - listSynologyAlbums, - getSynologyAlbumPhotos, - searchSynologyPhotos, - getSynologyAssetInfo, - streamSynologyAsset, -} from '../../services/memories/synologyService'; -import { canAccessUserPhoto } from '../../services/memories/helpersService'; +import { ImmichService } from './immich.service'; +import { SynologyService } from './synology.service'; +import { UnifiedMemoriesService } from './unified-memories.service'; +import { MemoriesAccessService } from './memories-access.service'; import type { Selection } from './memories.helpers'; import type { TrekWsPayload, TrekWsTripEventName } from '@trek/shared'; import { RealtimeService } from '../realtime/realtime.service'; /** - * Thin Nest wrapper around the existing memories (photo-providers) services. - * Every method delegates to the legacy `services/memories/*` code unchanged so - * the provider logic, the per-provider access checks and the streaming helpers - * behave byte-identically to the legacy Express routers. No new business logic - * lives here. + * Controller-facing facade over the memories domain. Every method forwards to + * the provider that owns the work — UnifiedMemoriesService for the trip-side + * photo surface, ImmichService and SynologyService for the providers, + * MemoriesAccessService for the shared access checks. No business logic here. */ @Injectable() export class MemoriesService { - constructor(private readonly realtime: RealtimeService) {} + constructor( + private readonly realtime: RealtimeService, + private readonly unified: UnifiedMemoriesService, + private readonly immich: ImmichService, + private readonly synology: SynologyService, + private readonly access: MemoriesAccessService, + ) {} // ── Access check (reused by both provider asset routes) ────────────────── canAccessUserPhoto(requestingUserId: number, ownerUserId: number, tripId: string, assetId: string, provider: string): boolean { - return canAccessUserPhoto(requestingUserId, ownerUserId, tripId, assetId, provider); + return this.access.canAccessUserPhoto(requestingUserId, ownerUserId, tripId, assetId, provider); } broadcast(tripId: string, event: E, payload: TrekWsPayload, socketId?: string): void { @@ -63,124 +35,124 @@ export class MemoriesService { // ── Unified ────────────────────────────────────────────────────────────── listTripPhotos(tripId: string, userId: number) { - return listTripPhotos(tripId, userId); + return this.unified.listTripPhotos(tripId, userId); } addTripPhotos(tripId: string, userId: number, shared: boolean, selections: Selection[], sid: string) { - return addTripPhotos(tripId, userId, shared, selections, sid); + return this.unified.addTripPhotos(tripId, userId, shared, selections, sid); } setTripPhotoSharing(tripId: string, userId: number, photoId: number, shared: boolean) { - return setTripPhotoSharing(tripId, userId, photoId, shared); + return this.unified.setTripPhotoSharing(tripId, userId, photoId, shared); } removeTripPhoto(tripId: string, userId: number, photoId: number) { - return removeTripPhoto(tripId, userId, photoId); + return this.unified.removeTripPhoto(tripId, userId, photoId); } listTripAlbumLinks(tripId: string, userId: number) { - return listTripAlbumLinks(tripId, userId); + return this.unified.listTripAlbumLinks(tripId, userId); } createTripAlbumLink(tripId: string, userId: number, provider: unknown, albumId: unknown, albumName: unknown, passphrase?: string) { - return createTripAlbumLink(tripId, userId, provider, albumId, albumName, passphrase); + return this.unified.createTripAlbumLink(tripId, userId, provider, albumId, albumName, passphrase); } removeAlbumLink(tripId: string, linkId: string, userId: number) { - return removeAlbumLink(tripId, linkId, userId); + return this.unified.removeAlbumLink(tripId, linkId, userId); } // ── Immich ───────────────────────────────────────────────────────────────── immichGetConnectionSettings(userId: number) { - return getConnectionSettings(userId); + return this.immich.getConnectionSettings(userId); } immichSaveSettings(userId: number, immichUrl: string | undefined, immichApiKey: string | undefined, clientIp: string | null) { - return saveImmichSettings(userId, immichUrl, immichApiKey, clientIp); + return this.immich.saveImmichSettings(userId, immichUrl, immichApiKey, clientIp); } immichSetAutoUpload(userId: number, enabled: boolean): void { - setImmichAutoUpload(userId, enabled); + this.immich.setImmichAutoUpload(userId, enabled); } immichGetConnectionStatus(userId: number) { - return getConnectionStatus(userId); + return this.immich.getConnectionStatus(userId); } immichTestConnection(immichUrl: string, immichApiKey: string) { - return testConnection(immichUrl, immichApiKey); + return this.immich.testConnection(immichUrl, immichApiKey); } immichBrowseTimeline(userId: number) { - return browseTimeline(userId); + return this.immich.browseTimeline(userId); } immichSearchPhotos(userId: number, from: string | undefined, to: string | undefined, page: number, size: number) { - return searchPhotos(userId, from, to, page, size); + return this.immich.searchPhotos(userId, from, to, page, size); } immichIsValidAssetId(assetId: string): boolean { - return isValidAssetId(assetId); + return this.immich.isValidAssetId(assetId); } immichGetAssetInfo(userId: number, assetId: string, ownerId: number) { - return getAssetInfo(userId, assetId, ownerId); + return this.immich.getAssetInfo(userId, assetId, ownerId); } immichStreamAsset(res: Response, userId: number, assetId: string, kind: 'thumbnail' | 'original', ownerId: number) { - return streamImmichAsset(res, userId, assetId, kind, ownerId); + return this.immich.streamImmichAsset(res, userId, assetId, kind, ownerId); } immichListAlbums(userId: number) { - return listAlbums(userId); + return this.immich.listAlbums(userId); } immichGetAlbumPhotos(userId: number, albumId: string) { - return getAlbumPhotos(userId, albumId); + return this.immich.getAlbumPhotos(userId, albumId); } immichSyncAlbumAssets(tripId: string, linkId: string, userId: number, sid: string) { - return syncImmichAlbum(tripId, linkId, userId, sid); + return this.unified.syncImmichAlbum(tripId, linkId, userId, sid); } // ── Synology ──────────────────────────────────────────────────────────────── synologyGetSettings(userId: number) { - return getSynologySettings(userId); + return this.synology.getSynologySettings(userId); } synologyUpdateSettings(userId: number, url: string, username: string, password: string, skipSsl: boolean) { - return updateSynologySettings(userId, url, username, password, skipSsl); + return this.synology.updateSynologySettings(userId, url, username, password, skipSsl); } synologyGetStatus(userId: number) { - return getSynologyStatus(userId); + return this.synology.getSynologyStatus(userId); } synologyTestConnection(userId: number, url: string, username: string, password: string, otp: string, skipSsl: boolean) { - return testSynologyConnection(userId, url, username, password, otp, skipSsl); + return this.synology.testSynologyConnection(userId, url, username, password, otp, skipSsl); } synologyListAlbums(userId: number) { - return listSynologyAlbums(userId); + return this.synology.listSynologyAlbums(userId); } synologyGetAlbumPhotos(userId: number, albumId: string, passphrase?: string) { - return getSynologyAlbumPhotos(userId, albumId, passphrase); + return this.synology.getSynologyAlbumPhotos(userId, albumId, passphrase); } synologySyncAlbumLink(userId: number, tripId: string, linkId: string, sid: string) { - return syncSynologyAlbum(userId, tripId, linkId, sid); + return this.unified.syncSynologyAlbum(userId, tripId, linkId, sid); } synologySearchPhotos(userId: number, from: string | undefined, to: string | undefined, offset: number, limit: number) { - return searchSynologyPhotos(userId, from, to, offset, limit); + return this.synology.searchSynologyPhotos(userId, from, to, offset, limit); } synologyGetAssetInfo(userId: number, photoId: string, ownerId: number, passphrase?: string) { - return getSynologyAssetInfo(userId, photoId, ownerId, passphrase); + return this.synology.getSynologyAssetInfo(userId, photoId, ownerId, passphrase); } synologyStreamAsset(res: Response, userId: number, ownerId: number, photoId: string, kind: 'thumbnail' | 'original', size: string, passphrase?: string) { - return streamSynologyAsset(res, userId, ownerId, photoId, kind, size, passphrase); + return this.synology.streamSynologyAsset(res, userId, ownerId, photoId, kind, size, passphrase); } } diff --git a/server/src/nest/memories/photo-resolver.service.ts b/server/src/nest/memories/photo-resolver.service.ts new file mode 100644 index 000000000..751cf9a27 --- /dev/null +++ b/server/src/nest/memories/photo-resolver.service.ts @@ -0,0 +1,186 @@ +import { Injectable } from '@nestjs/common'; +import { Response } from 'express'; +import path from 'path'; +import fs from 'fs'; +import type { TrekPhoto } from '../../types'; +import { decrypt_api_key } from '../common/crypto/apiKeyCrypto'; +import { TrekPhotosRepository } from '../photos/trek-photos.repository'; +import { UPLOADS_ROOT } from './uploads-root'; +import { ImmichService } from './immich.service'; +import { SynologyService } from './synology.service'; +import { ThumbnailService } from './thumbnail.service'; +import { TrekPhotoCacheService } from './trek-photo-cache.service'; +import { fail, success, type AssetInfo, type ServiceResult } from './memories.helpers'; + +/** + * Resolves a stored trek_photo to bytes or metadata by asking whichever provider + * owns it. The storage half of the old photoResolverService lives in + * nest/photos/trek-photos.repository.ts; this is the dispatch half. + */ +@Injectable() +export class PhotoResolverService { + constructor( + private readonly photos: TrekPhotosRepository, + private readonly immich: ImmichService, + private readonly synology: SynologyService, + private readonly thumbnails: ThumbnailService, + private readonly cache: TrekPhotoCacheService, + ) {} + + // ── Streaming ──────────────────────────────────────────────────────────── + + private async streamCachedThumbnail( + res: Response, + photo: TrekPhoto, + fetchBytes: () => Promise<{ bytes: Buffer; contentType: string } | { error: string; status: number }>, + fallback: () => Promise, + ): Promise { + const key = this.cache.cacheKey(photo.provider!, photo.asset_id!, 'thumbnail', photo.owner_id!); + + if (this.cache.serveFresh(res, key)) return; + + const existing = this.cache.getInFlight(key); + if (existing) { + const bytes = await existing; + if (bytes && this.cache.serveFresh(res, key)) return; + await fallback(); + return; + } + + const promise = fetchBytes().then(async result => { + if ('error' in result) return null; + await this.cache.put(key, result.bytes, result.contentType); + return result.bytes; + }); + this.cache.setInFlight(key, promise); + + const bytes = await promise; + if (bytes && this.cache.serveFresh(res, key)) return; + await fallback(); + } + + async streamPhoto( + res: Response, + userId: number, + photoId: number, + kind: 'thumbnail' | 'original', + range?: string, + ): Promise { + const photo = this.photos.resolve(photoId); + if (!photo) { + res.status(404).json({ error: 'Photo not found' }); + return; + } + + if (photo.file_path) { + const uploadsRoot = UPLOADS_ROOT; + + if (kind === 'thumbnail') { + const isVideo = photo.media_type === 'video'; + let thumbRel = photo.thumbnail_path ?? null; + // Only raster images get a lazily-generated Jimp thumbnail; Jimp can't decode + // video, so a video relies on the poster captured at upload (#823). + if (!thumbRel && !isVideo) { + const result = await this.thumbnails.ensureLocalThumbnail(uploadsRoot, photo.file_path); + if (result) { + thumbRel = result.thumbnailRelPath; + this.photos.recordLocalThumbnail(photo.id, thumbRel, result.width, result.height); + } + } + if (thumbRel) { + const thumbAbs = path.join(uploadsRoot, thumbRel); + if (fs.existsSync(thumbAbs)) { + res.set('Cache-Control', 'public, max-age=86400, immutable'); + res.set('X-Content-Type-Options', 'nosniff'); + res.sendFile(thumbAbs); + return; + } + } + // A poster-less video must NOT fall through to streaming the whole file as a + // "thumbnail"; let the client render its own placeholder instead. + if (isVideo) { + res.status(404).json({ error: 'No poster available' }); + return; + } + // Images fall through to original if the thumbnail is unavailable. + } + + const localPath = path.join(uploadsRoot, photo.file_path); + if (fs.existsSync(localPath)) { + res.set('Cache-Control', 'public, max-age=86400'); + res.set('X-Content-Type-Options', 'nosniff'); + res.sendFile(localPath); + return; + } + } + + switch (photo.provider) { + case 'local': { + res.status(404).json({ error: 'File not found' }); + return; + } + case 'immich': { + if (kind === 'thumbnail') { + await this.streamCachedThumbnail( + res, photo, + () => this.immich.fetchImmichThumbnailBytes(userId, photo.asset_id!, photo.owner_id!), + () => this.immich.streamImmichAsset(res, userId, photo.asset_id!, kind, photo.owner_id!, { mediaType: photo.media_type }), + ); + return; + } + await this.immich.streamImmichAsset(res, userId, photo.asset_id!, kind, photo.owner_id!, { mediaType: photo.media_type, range }); + return; + } + case 'synologyphotos': { + const passphrase = photo.passphrase ? (decrypt_api_key(photo.passphrase) || undefined) : undefined; + if (kind === 'thumbnail') { + await this.streamCachedThumbnail( + res, photo, + () => this.synology.fetchSynologyThumbnailBytes(userId, photo.owner_id!, photo.asset_id!, passphrase), + () => this.synology.streamSynologyAsset(res, userId, photo.owner_id!, photo.asset_id!, kind, undefined, passphrase), + ); + return; + } + await this.synology.streamSynologyAsset(res, userId, photo.owner_id!, photo.asset_id!, kind, undefined, passphrase); + return; + } + default: + res.status(400).json({ error: `Unknown provider: ${photo.provider}` }); + } + } + + // ── Asset Info ──────────────────────────────────────────────────────────── + + async getPhotoInfo( + userId: number, + photoId: number, + ): Promise> { + const photo = this.photos.resolve(photoId); + if (!photo) return fail('Photo not found', 404); + + switch (photo.provider) { + case 'local': { + return success({ + id: String(photo.id), + takenAt: photo.created_at, + city: null, + country: null, + width: photo.width, + height: photo.height, + fileName: photo.file_path?.split('/').pop() || null, + } as AssetInfo); + } + case 'immich': { + const result = await this.immich.getAssetInfo(userId, photo.asset_id!, photo.owner_id!); + if (result.error) return fail(result.error, result.status || 500); + return success(result.data as AssetInfo); + } + case 'synologyphotos': { + const passphrase = photo.passphrase ? (decrypt_api_key(photo.passphrase) || undefined) : undefined; + return this.synology.getSynologyAssetInfo(userId, photo.asset_id!, photo.owner_id!, passphrase); + } + default: + return fail(`Unknown provider: ${photo.provider}`, 400); + } + } +} diff --git a/server/src/nest/memories/synology.service.ts b/server/src/nest/memories/synology.service.ts new file mode 100644 index 000000000..6d180d676 --- /dev/null +++ b/server/src/nest/memories/synology.service.ts @@ -0,0 +1,719 @@ +import { Injectable } from '@nestjs/common'; +import { Response } from 'express'; +import { decrypt_api_key, encrypt_api_key, maybe_encrypt_api_key } from '../common/crypto/apiKeyCrypto'; +import { safeFetch, SsrfBlockedError, checkSsrf } from '../../utils/ssrfGuard'; +import { send as sendNotification } from '../notifications/notifications.bridge'; +import { DatabaseService } from '../database/database.service'; +import { MemoriesAccessService } from './memories-access.service'; +import { + fail, + handleServiceResult, + success, + pipeAsset, + type AlbumsList, + type AssetInfo, + type AssetsList, + type Selection, + type ServiceResult, + type StatusResult, +} from './memories.helpers'; + +const SYNOLOGY_PROVIDER = 'synologyphotos'; +// Users provide the full base URL including the Photos app path (e.g. https://nas:5001/photo). +// The API endpoint is always at {base_url}/webapi/entry.cgi. +const SYNOLOGY_ENDPOINT_PATH = '/webapi/entry.cgi'; + +const SYNOLOGY_DEVICE_NAME = 'trek'; + +const SYNOLOGY_ERROR_MESSAGES: Record = { + 101: 'Missing API, method, or version parameter.', + 102: 'Requested API does not exist.', + 103: 'Requested method does not exist.', + 104: 'Requested API version is not supported.', + 105: 'Insufficient privilege.', + 106: 'Connection timeout.', + 107: 'Multiple logins blocked from this IP.', + 117: 'Manager privilege required.', + 119: 'Session is invalid or expired.', + 400: 'Invalid credentials.', + 401: 'Session expired or account disabled.', + 402: 'No permission to use this account.', + 403: 'Two-factor authentication code required.', + 404: 'Two-factor authentication failed.', + 406: 'Two-factor authentication is enforced for this account.', + 407: 'Maximum login attempts reached.', + 408: 'Password expired.', + 409: 'Remote password expired.', + 410: 'Password must be changed before login.', + 412: 'Guest account cannot log in.', + 413: 'OTP system files are corrupted.', + 414: 'Unable to log in.', + 416: 'Unable to log in.', + 417: 'OTP system is full.', + 498: 'System is upgrading.', + 499: 'System is not ready.', +}; + +interface SynologyUserRecord { + synology_url?: string | null; + synology_username?: string | null; + synology_password?: string | null; + synology_sid?: string | null; + synology_did?: string | null; + synology_skip_ssl?: number | null; +}; + +interface SynologyCredentials { + synology_url: string; + synology_username: string; + synology_password: string; + synology_skip_ssl: boolean; +} + +interface SynologySettings { + synology_url: string; + synology_username: string; + synology_skip_ssl: boolean; + 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; + }; +} + +/** + * Synology Photos provider: credentials and the SID session, album listing + * across personal/shared/shared-with-me sources, search, asset info and the + * asset proxy. + * + * Folded 1:1 from services/memories/synologyService.ts - every SQL statement, + * error code mapping and status is the one that shipped. + */ +@Injectable() +export class SynologyService { + constructor( + private readonly db: DatabaseService, + private readonly access: MemoriesAccessService, + ) {} + + private _readSynologyUser(userId: number, columns: string[]): ServiceResult { + try { + const row = this.db.prepare(`SELECT synology_url, synology_username, synology_password, synology_sid, synology_did, synology_skip_ssl 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]; + } + + return success(filtered); + } catch { + return fail('Failed to read Synology user data', 500); + } + } + + private _getSynologyCredentials(userId: number): ServiceResult { + const user = this._readSynologyUser(userId, ['synology_url', 'synology_username', 'synology_password', 'synology_skip_ssl']); + 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); + const password = decrypt_api_key(user.data.synology_password); + if (!password) return fail('Synology credentials corrupted', 500); + return success({ + synology_url: user.data.synology_url, + synology_username: user.data.synology_username, + synology_password: password, + synology_skip_ssl: user.data.synology_skip_ssl !== 0, + }); + } + + + private _buildSynologyEndpoint(url: string, params: string): string { + const normalized = url.replace(/\/$/, '').match(/^https?:\/\//) ? url.replace(/\/$/, '') : `https://${url.replace(/\/$/, '')}`; + return `${normalized}${SYNOLOGY_ENDPOINT_PATH}?${params}`; + } + + private _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; + } + + private async _fetchSynologyJson(url: string, body: URLSearchParams, skipSsl = true): Promise> { + const endpoint = this._buildSynologyEndpoint(url, `api=${body.get('api')}`); + try { + const resp = await safeFetch(endpoint, { + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8', + }, + body, + signal: AbortSignal.timeout(30000) as any, + }, { rejectUnauthorized: !skipSsl }); + if (!resp.ok) { + return fail('Synology API request failed with status ' + resp.status, resp.status); + } + const response = await resp.json() as SynologyApiResponse; + if (!response.success) { + const code = response.error.code; + const message = SYNOLOGY_ERROR_MESSAGES[code] ?? 'Synology API request failed (code ' + code + ')'; + // Preserve session error codes (106, 107, 119) for internal retry logic in _requestSynologyApi. + // All other Synology app-level codes are mapped to HTTP 400 — they are not HTTP status codes. + const httpStatus = [106, 107, 119].includes(code) ? code : 400; + return fail(message, httpStatus); + } + return success(response.data); + } catch (error) { + if (error instanceof SsrfBlockedError) { + return fail(error.message, 400); + } + return fail('Failed to connect to Synology API', 500); + } + } + + + private async _loginToSynology( + url: string, + username: string, + password: string, + opts: { otp?: string; deviceId?: string; skipSsl?: boolean } = {}, + ): Promise> { + const { otp, deviceId, skipSsl = false } = opts; + const body = new URLSearchParams({ + api: 'SYNO.API.Auth', + method: 'login', + version: '6', + account: username, + passwd: password, + format: 'sid', + client: 'browser', + device_name: SYNOLOGY_DEVICE_NAME, + }); + if (otp && otp.trim()) { + body.append('otp_code', otp.trim()); + body.append('enable_device_token', 'yes'); + } + if (deviceId) { + body.append('device_id', deviceId); + } + + const result = await this._fetchSynologyJson<{ sid?: string; did?: string }>(url, body, skipSsl); + if (!result.success) { + return result as ServiceResult<{ sid: string; did?: string }>; + } + if (!result.data.sid) { + return fail('Failed to get session ID from Synology', 500); + } + return success({ sid: result.data.sid, did: result.data.did }); + } + + private async _requestSynologyApi(userId: number, params: ApiCallParams): Promise> { + const creds = this._getSynologyCredentials(userId); + if (!creds.success) { + return creds as ServiceResult; + } + + const session = await this._getSynologySession(userId); + if (!session.success || !session.data) { + return session as ServiceResult; + } + + const skipSsl = creds.data.synology_skip_ssl; + const body = this._buildSynologyFormBody({ ...params, _sid: session.data }); + const result = await this._fetchSynologyJson(creds.data.synology_url, body, skipSsl); + // 106 = session timeout, 107 = duplicate login kicked us out, 119 = SID not found/invalid + if ('error' in result && [106, 107, 119].includes(result.error.status)) { + this._clearSynologySID(userId); + const retrySession = await this._getSynologySession(userId); + if (!retrySession.success || !retrySession.data) { + return retrySession as ServiceResult; + } + return this._fetchSynologyJson(creds.data.synology_url, this._buildSynologyFormBody({ ...params, _sid: retrySession.data }), skipSsl); + } + return result; + } + + private _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: typeof gps.latitude === 'number' ? gps.latitude : null, + lng: typeof gps.longitude === 'number' ? 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, + }; + } + + + private _clearSynologySID(userId: number): void { + this.db.prepare('UPDATE users SET synology_sid = NULL WHERE id = ?').run(userId); + } + + private _clearSynologySession(userId: number): void { + this.db.prepare('UPDATE users SET synology_sid = NULL, synology_did = NULL WHERE id = ?').run(userId); + } + + private _splitPackedSynologyId(rawId: string): { id: string; cacheKey: string; assetId: string } | null { + // cache_key format from Synology is "{unit_id}_{timestamp}", e.g. "40808_1633659236". + // The first segment must be a non-empty integer (the unit ID used for API calls). + if (!/^\d+_.+$/.test(rawId)) return null; + const id = rawId.split('_')[0]; + return { id, cacheKey: rawId, assetId: rawId }; + } + + private async _getSynologySession(userId: number): Promise> { + const cached = this._readSynologyUser(userId, ['synology_sid', 'synology_did']); + if (cached.success && cached.data?.synology_sid) { + const decryptedSid = decrypt_api_key(cached.data.synology_sid); + if (decryptedSid) return success(decryptedSid); + // Decryption failed (e.g. key rotation) — clear the stale SID and re-login + this._clearSynologySID(userId); + } + + const creds = this._getSynologyCredentials(userId); + if (!creds.success) { + return creds as ServiceResult; + } + + // Use stored device ID to skip OTP on re-login (trusted device flow) + const storedDid = cached.success && cached.data?.synology_did + ? (decrypt_api_key(cached.data.synology_did) || undefined) + : undefined; + + const resp = await this._loginToSynology(creds.data.synology_url, creds.data.synology_username, creds.data.synology_password, { + deviceId: storedDid, + skipSsl: creds.data.synology_skip_ssl, + }); + + if (!resp.success) { + return resp as ServiceResult; + } + + this.db.prepare('UPDATE users SET synology_sid = ? WHERE id = ?').run(encrypt_api_key(resp.data.sid), userId); + return success(resp.data.sid); + } + + async getSynologySettings(userId: number): Promise> { + const creds = this._getSynologyCredentials(userId); + if (!creds.success) return creds as ServiceResult; + const session = await this._getSynologySession(userId); + return success({ + synology_url: creds.data.synology_url || '', + synology_username: creds.data.synology_username || '', + synology_skip_ssl: creds.data.synology_skip_ssl, + connected: session.success, + }); + } + + async updateSynologySettings(userId: number, synologyUrl: string, synologyUsername: string, synologyPassword?: string, synologySkipSsl = false): Promise> { + + const ssrf = await checkSsrf(synologyUrl); + if (!ssrf.allowed) { + return fail(ssrf.error, 400); + } + + const result = this._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); + } + + // Only invalidate the session when the account itself changes (different URL or username). + // If the user just tested the connection, testSynologyConnection already stored a fresh + // sid + did — clearing them here would force an unnecessary re-login that may fail (MFA). + const existing = this._readSynologyUser(userId, ['synology_url', 'synology_username']); + const urlChanged = existing.success && existing.data.synology_url !== synologyUrl; + const userChanged = existing.success && existing.data.synology_username !== synologyUsername; + const sessionCleared = urlChanged || userChanged; + if (sessionCleared) { + this._clearSynologySession(userId); + // Fire-and-forget like unifiedService's photos_shared send — without the + // .catch an in-app insert failure became an unhandled rejection. + sendNotification({ + event: 'synology_session_cleared', + actorId: null, + params: {}, + scope: 'user', + targetId: userId, + }).catch(() => {}); + } + + try { + this.db.prepare('UPDATE users SET synology_url = ?, synology_username = ?, synology_password = ?, synology_skip_ssl = ? WHERE id = ?').run( + synologyUrl, + synologyUsername, + synologyPassword ? maybe_encrypt_api_key(synologyPassword) : existingEncryptedPassword, + synologySkipSsl ? 1 : 0, + userId, + ); + } catch { + return fail('Failed to update Synology settings', 500); + } + + return success('settings updated'); + } + + async getSynologyStatus(userId: number): Promise> { + const sid = await this._getSynologySession(userId); + if ('error' in sid) return success({ connected: false, error: sid.error.message }); + if (!sid.data) return success({ connected: false, error: 'Not connected to Synology' }); + try { + const user = this.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' } }); + } + } + + async testSynologyConnection(userId: number, synologyUrl: string, synologyUsername: string, synologyPassword: string, synologyOtp?: string, synologySkipSsl = false): Promise> { + + const ssrf = await checkSsrf(synologyUrl); + if (!ssrf.allowed) { + return fail(ssrf.error, 400); + } + + const resp = await this._loginToSynology(synologyUrl, synologyUsername, synologyPassword, { otp: synologyOtp, skipSsl: synologySkipSsl }); + if ('error' in resp) { + return success({ connected: false, error: resp.error.message }); + } + + // Persist the session so the OTP code is not required again on save. + // The did (device token) allows future re-logins without OTP. + this.db.prepare('UPDATE users SET synology_sid = ? WHERE id = ?').run(encrypt_api_key(resp.data.sid), userId); + if (resp.data.did) { + this.db.prepare('UPDATE users SET synology_did = ? WHERE id = ?').run(encrypt_api_key(resp.data.did), userId); + } + + return success({ connected: true, user: { name: synologyUsername } }); + } + + private async _fetchAllSynologyAlbums(userId: number, baseParams: ApiCallParams): Promise> { + const pageSize = 100; + const all: any[] = []; + let offset = 0; + while (true) { + const result = await this._requestSynologyApi<{ list: any[] }>(userId, { ...baseParams, offset, limit: pageSize }); + if (!result.success) return result as ServiceResult; + const items = result.data.list || []; + all.push(...items); + if (items.length < pageSize) break; + offset += pageSize; + } + return success(all); + } + + async listSynologyAlbums(userId: number): Promise> { + const [personal, shared, sharedWithMe] = await Promise.allSettled([ + this._fetchAllSynologyAlbums(userId, { api: 'SYNO.Foto.Browse.Album', method: 'list', version: 4 }), + this._fetchAllSynologyAlbums(userId, { api: 'SYNO.Foto.Browse.Album', method: 'list', version: 4, category: 'shared' }), + this._fetchAllSynologyAlbums(userId, { api: 'SYNO.Foto.Sharing.Misc', method: 'list_shared_with_me_album', version: 1, additional: ['thumbnail', 'sharing_info'] }), + ]); + + const map = new Map(); + + const addAlbums = (result: PromiseSettledResult>, extractPassphrase: (a: any) => string | undefined) => { + if (result.status === 'rejected') return; + const value = result.value; + if ('error' in value) { + console.warn('[Synology] album list partial failure:', value.error.message); + return; + } + for (const album of value.data ?? []) { + const id = String(album.id); + const passphrase = extractPassphrase(album); + map.set(id, { id, albumName: album.name || '', assetCount: album.item_count || 0, passphrase }); + } + }; + + addAlbums(personal, () => undefined); + addAlbums(shared, (a) => a.passphrase || undefined); + addAlbums(sharedWithMe, (a) => a.passphrase || a.sharing_info?.passphrase || undefined); + + if (map.size === 0 && personal.status === 'fulfilled' && !personal.value.success) { + return personal.value as ServiceResult; + } + + const albums = [...map.values()].sort((a, b) => a.albumName.localeCompare(b.albumName)); + return success({ albums }); + } + + + async getSynologyAlbumPhotos(userId: number, albumId: string, passphrase?: string): Promise> { + const allItems: SynologyPhotoItem[] = []; + const pageSize = 50; + let offset = 0; + + while (true) { + const params: ApiCallParams = passphrase + ? { api: 'SYNO.Foto.Browse.Item', method: 'list', version: 1, passphrase, offset, limit: pageSize, additional: ['thumbnail'] } + : { api: 'SYNO.Foto.Browse.Item', method: 'list', version: 1, album_id: Number(albumId), offset, limit: pageSize, additional: ['thumbnail'] }; + const result = await this._requestSynologyApi<{ list: SynologyPhotoItem[] }>(userId, params); + if (!result.success) return result as ServiceResult; + const items = result.data.list || []; + allItems.push(...items); + if (items.length < pageSize) break; + offset += pageSize; + } + + const assets = allItems.map(item => ({ + id: String(item.additional?.thumbnail?.cache_key || item.id || ''), + takenAt: item.time ? new Date(item.time * 1000).toISOString() : '', + })).filter(a => a.id); + + return success({ assets, total: assets.length, hasMore: false }); + } + + /** + * The Synology half of an album sync — see collectAlbumSelection in + * immichService for why the trip-side write lives in the unified service now. + */ + async collectSynologyAlbumSelection(userId: number, tripId: string, linkId: string): Promise> { + const response = this.access.getAlbumLinkForSync(tripId, linkId, userId); + if (!response.success) return response as ServiceResult<{ selection: Selection; total: number }>; + + const { albumId, passphrase } = response.data; + + const allItems: SynologyPhotoItem[] = []; + const pageSize = 50; + let offset = 0; + + while (true) { + const itemParams: ApiCallParams = passphrase + ? { api: 'SYNO.Foto.Browse.Item', method: 'list', version: 1, passphrase, offset, limit: pageSize, additional: ['thumbnail'] } + : { api: 'SYNO.Foto.Browse.Item', method: 'list', version: 1, album_id: Number(albumId), offset, limit: pageSize, additional: ['thumbnail'] }; + + const result = await this._requestSynologyApi<{ list: SynologyPhotoItem[] }>(userId, itemParams); + + if (!result.success) return result as ServiceResult<{ selection: Selection; total: number }>; + + 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), + passphrase, + }; + + return success({ selection, total: allItems.length }); + } + + async 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', 'gps'], + }; + + 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 + } + } + + // SYNO.Foto.Search.Search list_item does not return a total count — only data.list. + // hasMore is inferred: if we got a full page, there may be more. + const result = await this._requestSynologyApi<{ list: SynologyPhotoItem[] }>(userId, params); + if (!result.success) return result as ServiceResult; + + const allItems = result.data.list || []; + const assets = allItems.map(item => this._normalizeSynologyPhotoInfo(item)); + + return success({ + assets, + total: allItems.length, + hasMore: allItems.length === limit, + }); + } + + async getSynologyAssetInfo(userId: number, photoId: string, targetUserId: number, passphrase?: string): Promise> { + const parsedId = this._splitPackedSynologyId(photoId); + if (!parsedId) return fail('Invalid photo ID format', 400); + const infoParams: ApiCallParams = { + 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 (passphrase) infoParams.passphrase = passphrase; + const result = await this._requestSynologyApi<{ list: SynologyPhotoItem[] }>(targetUserId, infoParams); + + if (!result.success) return result as ServiceResult; + + const metadata = result.data.list?.[0]; + if (!metadata) return fail('Photo not found', 404); + + const normalized = this._normalizeSynologyPhotoInfo(metadata); + normalized.id = photoId; + return success(normalized); + } + + async fetchSynologyThumbnailBytes( + userId: number, + targetUserId: number, + photoId: string, + passphrase?: string, + ): Promise<{ bytes: Buffer; contentType: string } | { error: string; status: number }> { + const parsedId = this._splitPackedSynologyId(photoId); + if (!parsedId) return { error: 'Invalid photo ID format', status: 400 }; + + const synology_credentials = this._getSynologyCredentials(targetUserId); + if (!synology_credentials.success) return { error: 'Credentials error', status: 500 }; + + const sid = await this._getSynologySession(targetUserId); + if (!sid.success) return { error: 'Session error', status: 500 }; + if (!sid.data) return { error: 'Session ID missing', status: 500 }; + + const params = new URLSearchParams({ + api: 'SYNO.Foto.Thumbnail', + method: 'get', + version: '2', + mode: 'download', + id: parsedId.id, + type: 'unit', + // Match the uncached streamSynologyAsset default — 'sm' (240px) looked + // pixelated on retina. + size: 'm', + cache_key: parsedId.cacheKey, + _sid: sid.data, + }); + if (passphrase) params.append('passphrase', passphrase); + + const url = this._buildSynologyEndpoint(synology_credentials.data.synology_url, params.toString()); + try { + const resp = await safeFetch(url, undefined, { rejectUnauthorized: !synology_credentials.data.synology_skip_ssl }); + if (!resp.ok) return { error: 'Upstream error', status: resp.status }; + const contentType = resp.headers.get('content-type') || 'image/jpeg'; + const bytes = Buffer.from(await resp.arrayBuffer()); + return { bytes, contentType }; + } catch (error) { + console.error('fetchSynologyThumbnailBytes: upstream fetch failed:', error); + return { error: 'Proxy error', status: 502 }; + } + } + + async streamSynologyAsset( + response: Response, + userId: number, + targetUserId: number, + photoId: string, + kind: 'thumbnail' | 'original', + size?: string, + passphrase?: string, + ): Promise { + const parsedId = this._splitPackedSynologyId(photoId); + if (!parsedId) { + handleServiceResult(response, fail('Invalid photo ID format', 400)); + return; + } + + const synology_credentials = this._getSynologyCredentials(targetUserId); + if (!synology_credentials.success) { + handleServiceResult(response, synology_credentials); + return; + } + + const sid = await this._getSynologySession(targetUserId); + if (!sid.success) { + handleServiceResult(response, sid); + return; + } + if (!sid.data) { + handleServiceResult(response, fail('Failed to retrieve session ID', 500)); + return; + } + + + //size: 'sm' 240px| 'm' 320px| 'xl' 1280px| 'preview' ? + // Use Thumbnail API for both thumbnail and original — avoids serving raw HEIC files + // (original uses xl size to get a full-resolution JPEG-compatible render). + // Thumbnail default is 'm' (~320px) — 'sm' (240px) looked pixelated on + // the journey grid on retina screens. + const resolvedSize = kind === 'original' ? 'xl' : (size || 'm'); + const params = new URLSearchParams({ + api: 'SYNO.Foto.Thumbnail', + method: 'get', + version: '2', + mode: 'download', + id: parsedId.id, + type: 'unit', + size: resolvedSize, + cache_key: parsedId.cacheKey, + _sid: sid.data, + }); + if (passphrase) params.append('passphrase', passphrase); + + const url = this._buildSynologyEndpoint(synology_credentials.data.synology_url, params.toString()); + await pipeAsset(url, response, undefined, undefined, 'public, max-age=86400', { rejectUnauthorized: !synology_credentials.data.synology_skip_ssl }) + } +} diff --git a/server/src/nest/memories/thumbnail.service.ts b/server/src/nest/memories/thumbnail.service.ts new file mode 100644 index 000000000..d37197cee --- /dev/null +++ b/server/src/nest/memories/thumbnail.service.ts @@ -0,0 +1,60 @@ +import { Jimp } from 'jimp' +import path from 'path' +import fs from 'fs/promises' +import crypto from 'crypto' +import { Injectable } from '@nestjs/common' +import { ADDON_IDS } from '../../addons' +import { AddonsService } from '../addons/addons.service' + +const THUMB_MAX = 800 +const THUMB_QUALITY = 80 + +/** + * Downscaled JPEGs for locally uploaded journey photos. Gated on the journey + * addon, because that is the only surface that uploads them. + */ +@Injectable() +export class ThumbnailService { + constructor(private readonly addons: AddonsService) {} + + async ensureLocalThumbnail( + uploadsRoot: string, + originalRelPath: string, + ): Promise<{ thumbnailRelPath: string; width: number; height: number } | null> { + if (!this.addons.isAddonEnabled(ADDON_IDS.JOURNEY)) return null + + const originalAbs = path.join(uploadsRoot, originalRelPath) + try { await fs.access(originalAbs) } catch { return null } + + // Deterministic name so concurrent requests don't race on the same photo. + const hash = crypto.createHash('sha1').update(originalRelPath).digest('hex').slice(0, 16) + const thumbRel = `journey/thumbs/${hash}.jpg` + const thumbAbs = path.join(uploadsRoot, thumbRel) + + try { + const [srcStat, dstStat] = await Promise.all([ + fs.stat(originalAbs), + fs.stat(thumbAbs).catch(() => null), + ]) + if (dstStat && dstStat.mtimeMs >= srcStat.mtimeMs) { + const img = await Jimp.read(thumbAbs) + return { thumbnailRelPath: thumbRel, width: img.bitmap.width, height: img.bitmap.height } + } + + await fs.mkdir(path.dirname(thumbAbs), { recursive: true }) + + // Jimp auto-applies EXIF orientation on read, matching sharp's .rotate() behavior. + const img = await Jimp.read(originalAbs) + const { width: w, height: h } = img.bitmap + if (w > THUMB_MAX || h > THUMB_MAX) { + img.scaleToFit({ w: THUMB_MAX, h: THUMB_MAX }) + } + await img.write(thumbAbs as `${string}.jpg`, { quality: THUMB_QUALITY }) + + return { thumbnailRelPath: thumbRel, width: img.bitmap.width, height: img.bitmap.height } + } catch { + // Unsupported format, corrupt file, etc. — fall back to original in caller. + return null + } + } +} diff --git a/server/src/nest/memories/trek-photo-cache.service.ts b/server/src/nest/memories/trek-photo-cache.service.ts new file mode 100644 index 000000000..23e20097c --- /dev/null +++ b/server/src/nest/memories/trek-photo-cache.service.ts @@ -0,0 +1,112 @@ +import path from 'node:path'; +import fs from 'node:fs'; +import fsPromises from 'node:fs/promises'; +import crypto from 'node:crypto'; +import { Injectable } from '@nestjs/common'; +import { Response } from 'express'; +import { DatabaseService } from '../database/database.service'; +import { UPLOADS_ROOT } from './uploads-root'; + +const TREK_PHOTO_DIR = path.join(UPLOADS_ROOT, 'photos/trek'); +export const CACHE_TTL = 60 * 60 * 1000; // 1 hour + +/** + * In-flight fetches, module-scoped on purpose. + * + * This is the stampede guard: ten tiles asking for the same uncached asset must + * share one upstream fetch. The scheduler drives sweepExpired through a lazy + * require outside the container, so a second instance of this service exists by + * design - a per-instance Map would hand each of them a private one and the + * guard would be silently gone. Same reasoning as oauth/oauth.pending-codes.ts + * and the notification channel registry. + */ +const inFlight = new Map>(); + +function ensureDir(): void { + if (!fs.existsSync(TREK_PHOTO_DIR)) { + fs.mkdirSync(TREK_PHOTO_DIR, { recursive: true }); + } +} + +function cachedFilePath(key: string): string { + return path.join(TREK_PHOTO_DIR, `${key}.bin`); +} + +/** Disk + metadata cache for provider thumbnails and originals. */ +@Injectable() +export class TrekPhotoCacheService { + constructor(private readonly db: DatabaseService) {} + + cacheKey(provider: string, assetId: string, kind: string, ownerId: number): string { + return crypto.createHash('sha1').update(`${provider}:${assetId}:${kind}:${ownerId}`).digest('hex'); + } + + + + getFresh(key: string): { filePath: string; contentType: string } | null { + const row = this.db.get<{ content_type: string; fetched_at: number }>( + 'SELECT content_type, fetched_at FROM trek_photo_cache_meta WHERE cache_key = ?', key, + ); + + if (!row) return null; + + if (Date.now() - row.fetched_at >= CACHE_TTL) { + this.db.run('DELETE FROM trek_photo_cache_meta WHERE cache_key = ?', key); + return null; + } + + const fp = cachedFilePath(key); + if (!fs.existsSync(fp)) { + this.db.run('DELETE FROM trek_photo_cache_meta WHERE cache_key = ?', key); + return null; + } + + return { filePath: fp, contentType: row.content_type }; + } + + async put(key: string, bytes: Buffer, contentType: string): Promise { + ensureDir(); + const fp = cachedFilePath(key); + const tmp = fp + '.tmp'; + + await fsPromises.writeFile(tmp, bytes); + await fsPromises.rename(tmp, fp); + + this.db.run( + 'INSERT OR REPLACE INTO trek_photo_cache_meta (cache_key, content_type, fetched_at) VALUES (?, ?, ?)', + key, contentType, Date.now(), + ); + } + + serveFresh(res: Response, key: string): boolean { + const entry = this.getFresh(key); + if (!entry) return false; + + res.set('Content-Type', entry.contentType); + res.set('Cache-Control', 'public, max-age=3600'); + res.sendFile(entry.filePath); + return true; + } + + getInFlight(key: string): Promise | undefined { + return inFlight.get(key); + } + + setInFlight(key: string, promise: Promise): void { + inFlight.set(key, promise); + promise.finally(() => inFlight.delete(key)); + } + + sweepExpired(): void { + const cutoff = Date.now() - CACHE_TTL * 2; + const stale = this.db.all<{ cache_key: string }>( + 'SELECT cache_key FROM trek_photo_cache_meta WHERE fetched_at < ?', cutoff, + ); + + for (const row of stale) { + this.db.run('DELETE FROM trek_photo_cache_meta WHERE cache_key = ?', row.cache_key); + const fp = cachedFilePath(row.cache_key); + if (fs.existsSync(fp)) fs.unlinkSync(fp); + } + } +} diff --git a/server/src/nest/memories/unified-memories.service.ts b/server/src/nest/memories/unified-memories.service.ts new file mode 100644 index 000000000..69ad49196 --- /dev/null +++ b/server/src/nest/memories/unified-memories.service.ts @@ -0,0 +1,378 @@ +import { Injectable } from '@nestjs/common'; +import { broadcast } from '../../websocket'; +import { encrypt_api_key } from '../common/crypto/apiKeyCrypto'; +import { DatabaseService } from '../database/database.service'; +import { send } from '../notifications/notifications.bridge'; +import { TrekPhotosRepository } from '../photos/trek-photos.repository'; +import { ImmichService } from './immich.service'; +import { SynologyService } from './synology.service'; +import { MemoriesAccessService } from './memories-access.service'; +import { + fail, + success, + mapDbError, + type Selection, + type ServiceResult, + type SyncAlbumResult, +} from './memories.helpers'; + +/** + * The provider-agnostic trip photo surface: which photos a trip shows, which + * albums it is linked to, and the album sync that writes provider assets into + * the trip. The providers themselves never write here - that direction is what + * used to close the import cycle. + */ +@Injectable() +export class UnifiedMemoriesService { + constructor( + private readonly db: DatabaseService, + private readonly photos: TrekPhotosRepository, + private readonly immich: ImmichService, + private readonly synology: SynologyService, + private readonly access: MemoriesAccessService, + ) {} + + private _providers(): Array<{id: string; enabled: boolean}> { + const rows = this.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 })); + } + + private _validProvider(provider: string): ServiceResult { + const providers = this._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); + } + + + + + listTripPhotos(tripId: string, userId: number): ServiceResult { + const access = this.db.canAccessTrip(tripId, userId); + if (!access) { + return fail('Trip not found or access denied', 404); + } + + try { + + const enabledProviders = this._providers().filter(p => p.enabled).map(p => p.id); + + if (enabledProviders.length === 0) { + return fail('No photo providers enabled', 400); + } + + const photos = this.db.prepare(` + SELECT tp.photo_id, tkp.asset_id, tkp.provider, tp.user_id, tp.shared, tp.added_at, + u.username, u.avatar + FROM trip_photos tp + JOIN trek_photos tkp ON tkp.id = tp.photo_id + JOIN users u ON tp.user_id = u.id + WHERE tp.trip_id = ? + AND (tp.user_id = ? OR tp.shared = 1) + AND tkp.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'); + } + } + + listTripAlbumLinks(tripId: string, userId: number): ServiceResult { + const access = this.db.canAccessTrip(tripId, userId); + if (!access) { + return fail('Trip not found or access denied', 404); + } + + + const enabledProviders = this._providers().filter(p => p.enabled).map(p => p.id); + + if (enabledProviders.length === 0) { + return fail('No photo providers enabled', 400); + } + + try { + const links = this.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 + + private _addTripPhoto(tripId: string, userId: number, provider: string, assetId: string, shared: boolean, albumLinkId?: string, passphrase?: string): ServiceResult { + const providerResult = this._validProvider(provider); + if (!providerResult.success) { + return providerResult as ServiceResult; + } + try { + const photoId = this.photos.getOrCreate(provider, assetId, userId, passphrase); + const result = this.db.prepare( + 'INSERT OR IGNORE INTO trip_photos (trip_id, user_id, photo_id, shared, album_link_id) VALUES (?, ?, ?, ?, ?)' + ).run(tripId, userId, photoId, shared ? 1 : 0, albumLinkId || null); + return success(result.changes > 0); + } + catch (error) { + return mapDbError(error, 'Failed to add photo to trip'); + } + } + + async addTripPhotos( + tripId: string, + userId: number, + shared: boolean, + selections: Selection[], + sid: string, + albumLinkId?: string, + ): Promise> { + const access = this.db.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 = this._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 = this._addTripPhoto(tripId, userId, selection.provider, assetId, shared, albumLinkId, selection.passphrase); + if (!result.success) { + return result as ServiceResult<{ added: number; shared: boolean }>; + } + if (result.data) { + added++; + } + } + } + + await this._notifySharedTripPhotos(tripId, userId, added); + broadcast(tripId, 'memories:updated', { userId }, sid); + return success({ added, shared }); + } + + + async setTripPhotoSharing( + tripId: string, + userId: number, + photoId: number, + shared: boolean, + sid?: string, + ): Promise> { + const access = this.db.canAccessTrip(tripId, userId); + if (!access) { + return fail('Trip not found or access denied', 404); + } + + try { + this.db.prepare(` + UPDATE trip_photos + SET shared = ? + WHERE trip_id = ? + AND user_id = ? + AND photo_id = ? + `).run(shared ? 1 : 0, tripId, userId, photoId); + + await this._notifySharedTripPhotos(tripId, userId, 1); + broadcast(tripId, 'memories:updated', { userId }, sid); + return success(true); + } catch (error) { + return mapDbError(error, 'Failed to update photo sharing'); + } + } + + removeTripPhoto( + tripId: string, + userId: number, + photoId: number, + sid?: string, + ): ServiceResult { + const access = this.db.canAccessTrip(tripId, userId); + if (!access) { + return fail('Trip not found or access denied', 404); + } + + try { + this.db.prepare(` + DELETE FROM trip_photos + WHERE trip_id = ? + AND user_id = ? + AND photo_id = ? + `).run(tripId, userId, photoId); + + this.photos.deleteIfOrphan(photoId); + broadcast(tripId, 'memories:updated', { userId }, sid); + + return success(true); + } catch (error) { + return mapDbError(error, 'Failed to remove trip photo'); + } + } + + // ---------------------------------------------- + // managing album links in trip + + createTripAlbumLink(tripId: string, userId: number, providerRaw: unknown, albumIdRaw: unknown, albumNameRaw: unknown, passphrase?: string): ServiceResult { + const access = this.db.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 = this._validProvider(provider); + if (!providerResult.success) { + return providerResult as ServiceResult; + } + + try { + const encryptedPassphrase = passphrase ? encrypt_api_key(passphrase) : null; + const result = this.db.prepare( + 'INSERT OR IGNORE INTO trip_album_links (trip_id, user_id, provider, album_id, album_name, passphrase) VALUES (?, ?, ?, ?, ?, ?)' + ).run(tripId, userId, provider, albumId, albumName, encryptedPassphrase); + + if (result.changes === 0) { + return fail('Album already linked', 409); + } + + return success(true); + } catch (error) { + return mapDbError(error, 'Failed to link album'); + } + } + + removeAlbumLink(tripId: string, linkId: string, userId: number): ServiceResult { + const access = this.db.canAccessTrip(tripId, userId); + if (!access) { + return fail('Trip not found or access denied', 404); + } + + try { + const linkedPhotos = this.db.prepare('SELECT photo_id FROM trip_photos WHERE trip_id = ? AND album_link_id = ?') + .all(tripId, linkId) as Array<{ photo_id: number }>; + + this.db.transaction(() => { + this.db.prepare('DELETE FROM trip_photos WHERE trip_id = ? AND album_link_id = ?') + .run(tripId, linkId); + this.db.prepare('DELETE FROM trip_album_links WHERE id = ? AND trip_id = ? AND user_id = ?') + .run(linkId, tripId, userId); + }); + + for (const { photo_id } of linkedPhotos) { + this.photos.deleteIfOrphan(photo_id); + } + + return success(true); + } catch (error) { + return mapDbError(error, 'Failed to remove album link'); + } + } + + + //----------------------------------------------- + // notifications helper + + private async _notifySharedTripPhotos( + tripId: string, + actorUserId: number, + added: number, + ): Promise> { + if (added <= 0) return success(undefined); + + try { + const actorRow = this.db.prepare('SELECT username, email FROM users WHERE id = ?').get(actorUserId) as { username: string | null, email: string | null }; + + const tripInfo = this.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); + } + } + + // ── Album sync (orchestration) ──────────────────────────────────────────── + // + // The provider services collect the asset ids; adding them to the trip and + // stamping the link is this module's job, because addTripPhotos lives here. + // Keeping the write on this side is what lets immich/synology stop importing + // this module — the cycle immich -> unified -> photoResolver -> immich is gone. + // Both functions return exactly what the combined provider-side ones returned. + + async syncImmichAlbum( + tripId: string, + linkId: string, + userId: number, + sid: string, + ): Promise<{ success?: boolean; added?: number; total?: number; error?: string; status?: number }> { + const collected = await this.immich.collectAlbumSelection(tripId, linkId, userId); + if ('error' in collected) return { error: collected.error, status: collected.status }; + + const result = await this.addTripPhotos(tripId, userId, true, [collected.selection], sid, linkId); + if ('error' in result) return { error: result.error.message, status: result.error.status }; + + this.access.updateSyncTimeForAlbumLink(linkId); + + return { success: true, added: result.data.added, total: collected.total }; + } + + async syncSynologyAlbum( + userId: number, + tripId: string, + linkId: string, + sid: string, + ): Promise> { + const collected = await this.synology.collectSynologyAlbumSelection(userId, tripId, linkId); + if (!collected.success) return collected as ServiceResult; + + const result = await this.addTripPhotos(tripId, userId, true, [collected.data.selection], sid, linkId); + if (!result.success) return result as ServiceResult; + + this.access.updateSyncTimeForAlbumLink(linkId); + + return success({ added: result.data.added, total: collected.data.total }); + } +} diff --git a/server/src/nest/photos/photos.module.ts b/server/src/nest/photos/photos.module.ts index 88a7f567d..b05d5e3d5 100644 --- a/server/src/nest/photos/photos.module.ts +++ b/server/src/nest/photos/photos.module.ts @@ -1,17 +1,19 @@ import { Module } from '@nestjs/common'; import { PhotosController } from './photos.controller'; import { PhotosService } from './photos.service'; -import { TrekPhotosRepository } from './trek-photos.repository'; +import { TrekPhotosModule } from './trek-photos.module'; +import { MemoriesModule } from '../memories/memories.module'; /** - * The trek_photos store plus the /api/photos read surface. The repository is - * exported because the trip-photo and journey domains register rows in it - * without caring which provider the bytes come from — that dispatch stays in - * memories/. + * /api/photos — the trek_photo read surface. Access control and the byte + * fetching both come from the memories domain, which owns provider dispatch; + * the row store comes from TrekPhotosModule, which memories imports too. That + * shared leaf is what keeps this a straight line instead of a forwardRef. */ @Module({ + imports: [TrekPhotosModule, MemoriesModule], controllers: [PhotosController], - providers: [PhotosService, TrekPhotosRepository], - exports: [TrekPhotosRepository], + providers: [PhotosService], + exports: [TrekPhotosModule], }) export class PhotosModule {} diff --git a/server/src/nest/photos/photos.service.ts b/server/src/nest/photos/photos.service.ts index f45bf5582..4f01200a7 100644 --- a/server/src/nest/photos/photos.service.ts +++ b/server/src/nest/photos/photos.service.ts @@ -1,24 +1,29 @@ import { Injectable } from '@nestjs/common'; import type { Response } from 'express'; -import { streamPhoto, getPhotoInfo } from '../../services/memories/photoResolverService'; -import { canAccessTrekPhoto } from '../../services/memories/helpersService'; +import { MemoriesAccessService } from '../memories/memories-access.service'; +import { PhotoResolverService } from '../memories/photo-resolver.service'; /** - * Thin Nest wrapper around the existing photo resolver/helper services. Access - * control, streaming and the provider-specific info lookups reuse the legacy - * code unchanged. + * /api/photos — the trek_photo read surface. Access control comes from the + * memories domain (a photo is reachable through a trip or a journey), the bytes + * from the resolver, which asks whichever provider owns the asset. */ @Injectable() export class PhotosService { + constructor( + private readonly access: MemoriesAccessService, + private readonly resolver: PhotoResolverService, + ) {} + canAccess(userId: number, photoId: number): boolean { - return canAccessTrekPhoto(userId, photoId); + return this.access.canAccessTrekPhoto(userId, photoId); } stream(res: Response, userId: number, photoId: number, kind: 'thumbnail' | 'original', range?: string) { - return streamPhoto(res, userId, photoId, kind, range); + return this.resolver.streamPhoto(res, userId, photoId, kind, range); } info(userId: number, photoId: number) { - return getPhotoInfo(userId, photoId); + return this.resolver.getPhotoInfo(userId, photoId); } } diff --git a/server/src/nest/photos/trek-photos.module.ts b/server/src/nest/photos/trek-photos.module.ts new file mode 100644 index 000000000..f0551fd14 --- /dev/null +++ b/server/src/nest/photos/trek-photos.module.ts @@ -0,0 +1,18 @@ +import { Module } from '@nestjs/common'; +import { TrekPhotosRepository } from './trek-photos.repository'; + +/** + * The trek_photos store on its own, so both halves can have it without + * importing each other. + * + * Memories needs it to record a row when an album sync pulls provider assets + * in; the /api/photos read surface needs the memories resolver to fetch the + * bytes. That is a genuine mutual need, and a module this small breaks it + * without a forwardRef: PhotosModule -> MemoriesModule -> TrekPhotosModule is + * a straight line. + */ +@Module({ + providers: [TrekPhotosRepository], + exports: [TrekPhotosRepository], +}) +export class TrekPhotosModule {} diff --git a/server/src/nest/photos/trek-photos.repository.ts b/server/src/nest/photos/trek-photos.repository.ts index 718bff700..0fb1f96ae 100644 --- a/server/src/nest/photos/trek-photos.repository.ts +++ b/server/src/nest/photos/trek-photos.repository.ts @@ -78,6 +78,17 @@ export class TrekPhotosRepository { ); } + /** + * Stamp a generated local thumbnail onto the row. COALESCE keeps dimensions + * that were already known — a re-generated thumbnail must not blank them. + */ + recordLocalThumbnail(photoId: number, thumbnailPath: string, width: number, height: number): void { + this.db.run( + 'UPDATE trek_photos SET thumbnail_path = ?, width = COALESCE(width, ?), height = COALESCE(height, ?) WHERE id = ?', + thumbnailPath, width, height, photoId, + ); + } + /** * Drop the row once no trip and no journey references it. Local photos are * kept: their bytes are ours, and the file would outlive the row. diff --git a/server/src/scheduler.ts b/server/src/scheduler.ts index 6ddee2355..1f174a807 100644 --- a/server/src/scheduler.ts +++ b/server/src/scheduler.ts @@ -352,6 +352,25 @@ function startIdempotencyCleanup(): void { } // Trek photo cache cleanup: every 2 hours — evict disk files and DB rows past their 1h TTL +/** + * The cache sweep, reached from outside the container. + * + * Constructed per call rather than injected: the cron runs before/independently + * of any request, and the service holds no state of its own — the stampede + * guard it does own is a module-scoped Map, deliberately shared with the + * container instance. Required lazily so a boot without the nest graph (some + * tests) does not pull it in. + */ +function sweepTrekPhotoCache(): void { + // eslint-disable-next-line @typescript-eslint/no-require-imports + const { TrekPhotoCacheService } = require('./nest/memories/trek-photo-cache.service'); + // eslint-disable-next-line @typescript-eslint/no-require-imports + const { DatabaseService } = require('./nest/database/database.service'); + // eslint-disable-next-line @typescript-eslint/no-require-imports + const { db } = require('./db/database'); + new TrekPhotoCacheService(new DatabaseService(db)).sweepExpired(); +} + let trekPhotoCacheTask: ScheduledTask | null = null; function startTrekPhotoCacheCleanup(): void { @@ -359,14 +378,12 @@ function startTrekPhotoCacheCleanup(): void { // Run once immediately on startup to evict any entries left over from a previous run try { - const { sweepExpired } = require('./services/memories/trekPhotoCache'); - sweepExpired(); + sweepTrekPhotoCache(); } catch { /* cache dir may not exist yet — harmless */ } trekPhotoCacheTask = cron.schedule('0 */2 * * *', () => { try { - const { sweepExpired } = require('./services/memories/trekPhotoCache'); - sweepExpired(); + sweepTrekPhotoCache(); } catch (err: unknown) { logError(`Trek photo cache cleanup: ${err instanceof Error ? err.message : err}`); } diff --git a/server/src/services/memories/helpersService.ts b/server/src/services/memories/helpersService.ts deleted file mode 100644 index 5ab62f52b..000000000 --- a/server/src/services/memories/helpersService.ts +++ /dev/null @@ -1,159 +0,0 @@ -import { canAccessTrip, db } from "../../db/database"; -import { decrypt_api_key } from '../../nest/common/crypto/apiKeyCrypto'; -// The ServiceResult envelope, the asset shapes and pipeAsset moved to -// nest/memories/memories.helpers.ts — this file is down to the DB-backed -// access checks and album-link lookups, and folds with the providers. -import { fail, success, type ServiceResult } from '../../nest/memories/memories.helpers'; -export * from '../../nest/memories/memories.helpers'; - -//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; - } - - // Journey photos use tripId=0 — check journey_photos + journey_contributors - if (tripId === '0') { - const journeyPhoto = db.prepare(` - SELECT gp.journey_id - FROM journey_photos gp - JOIN trek_photos tkp ON tkp.id = gp.photo_id - WHERE tkp.asset_id = ? - AND tkp.provider = ? - AND tkp.owner_id = ? - LIMIT 1 - `).get(assetId, provider, ownerUserId) as { journey_id: number } | undefined; - if (!journeyPhoto) return false; - - const access = db.prepare(` - SELECT 1 FROM journeys WHERE id = ? AND user_id = ? - UNION ALL - SELECT 1 FROM journey_contributors WHERE journey_id = ? AND user_id = ? - LIMIT 1 - `).get(journeyPhoto.journey_id, requestingUserId, journeyPhoto.journey_id, requestingUserId); - return !!access; - } - - // Regular trip photos — join through trek_photos - const sharedAsset = db.prepare(` - SELECT 1 - FROM trip_photos tp - JOIN trek_photos tkp ON tkp.id = tp.photo_id - WHERE tp.user_id = ? - AND tkp.asset_id = ? - AND tkp.provider = ? - AND tp.trip_id = ? - AND tp.shared = 1 - LIMIT 1 - `).get(ownerUserId, assetId, provider, tripId); - - if (!sharedAsset) { - return false; - } - return !!canAccessTrip(tripId, requestingUserId); -} - - -// ── Unified photo access check (trek_photos based) ────────────────────── - -export function canAccessTrekPhoto(requestingUserId: number, trekPhotoId: number): boolean { - const photo = db.prepare('SELECT * FROM trek_photos WHERE id = ?').get(trekPhotoId) as { id: number; provider: string; owner_id: number | null } | undefined; - if (!photo) return false; - - // Owner always has access - if (photo.owner_id === requestingUserId) return true; - - // Check trip_photos — is this photo shared in a trip the user has access to? - const tripAccess = db.prepare(` - SELECT 1 FROM trip_photos tp - WHERE tp.photo_id = ? - AND tp.shared = 1 - AND EXISTS ( - SELECT 1 FROM trip_members tm WHERE tm.trip_id = tp.trip_id AND tm.user_id = ? - UNION ALL - SELECT 1 FROM trips t WHERE t.id = tp.trip_id AND t.user_id = ? - ) - LIMIT 1 - `).get(trekPhotoId, requestingUserId, requestingUserId); - if (tripAccess) return true; - - // Check journey_photos — is this photo in a journey the user can access? - const journeyAccess = db.prepare(` - SELECT 1 FROM journey_photos gp - WHERE gp.photo_id = ? - AND EXISTS ( - SELECT 1 FROM journeys j WHERE j.id = gp.journey_id AND j.user_id = ? - UNION ALL - SELECT 1 FROM journey_contributors jc WHERE jc.journey_id = gp.journey_id AND jc.user_id = ? - ) - LIMIT 1 - `).get(trekPhotoId, requestingUserId, requestingUserId); - if (journeyAccess) return true; - - // Local photos without owner (uploaded files) — check if user has journey access - if (photo.provider === 'local' && !photo.owner_id) { - return !!journeyAccess; - } - - return false; -} - - -// ---------------------------------------------- -//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 getAlbumLinkForSync(tripId: string, linkId: string, userId: number): ServiceResult<{ albumId: string; passphrase?: string }> { - const access = canAccessTrip(tripId, userId); - if (!access) return fail('Trip not found or access denied', 404); - - try { - const row = db.prepare('SELECT album_id, passphrase FROM trip_album_links WHERE id = ? AND trip_id = ? AND user_id = ?') - .get(linkId, tripId, userId) as { album_id: string; passphrase: string | null } | null; - - if (!row) return fail('Album link not found', 404); - - const decrypted = row.passphrase ? decrypt_api_key(row.passphrase) ?? undefined : undefined; - return success({ albumId: row.album_id, passphrase: decrypted || undefined }); - } catch { - return fail('Failed to retrieve album link', 500); - } -} - -export function updateSyncTimeForAlbumLink(linkId: string): void { - db.prepare('UPDATE trip_album_links SET last_synced_at = CURRENT_TIMESTAMP WHERE id = ?').run(linkId); -} diff --git a/server/src/services/memories/immichService.ts b/server/src/services/memories/immichService.ts deleted file mode 100644 index 955cb4119..000000000 --- a/server/src/services/memories/immichService.ts +++ /dev/null @@ -1,587 +0,0 @@ -import { Response } from 'express'; -import { db } from '../../db/database'; -import { maybe_encrypt_api_key, decrypt_api_key } from '../../nest/common/crypto/apiKeyCrypto'; -import { checkSsrf, safeFetch } from '../../utils/ssrfGuard'; -import { writeAudit } from '../../nest/audit/audit.bridge'; -import { getAlbumIdFromLink, updateSyncTimeForAlbumLink, Selection, pipeAsset } from './helpersService'; -import { UPLOADS_ROOT } from '../../nest/memories/uploads-root'; - -// ── Credentials ──────────────────────────────────────────────────────────── - -export function getImmichCredentials(userId: number) { - const user = db.prepare('SELECT immich_url, immich_api_key FROM users WHERE id = ?').get(userId) as any; - if (!user?.immich_url || !user?.immich_api_key) return null; - const apiKey = decrypt_api_key(user.immich_api_key); - if (!apiKey) return null; - return { immich_url: user.immich_url as string, immich_api_key: apiKey }; -} - -/** Validate that an asset ID is a safe UUID-like string (no path traversal). */ -export function isValidAssetId(id: string): boolean { - return /^[a-zA-Z0-9_-]+$/.test(id) && id.length <= 100; -} - -/** - * Hidden assets have no generated thumbnail — Immich's own enum documents - * `AssetVisibility.Hidden` as "Video part of the LivePhotos and MotionPhotos", - * and its UI never shows them standalone. Surfacing or persisting one yields a - * permanently broken tile, since nothing re-checks visibility on the render - * path (#1474). - * - * Load-bearing on Immich < 1.133, which predates the `visibility` field: those - * servers strip the `visibility: 'timeline'` search filter and never defaulted - * `isVisible`, so they return hidden assets and this is the only thing stopping - * them. They mark the same state as `isVisible: false`. - */ -function isVisibleAsset(a: any): boolean { - return a.visibility !== 'hidden' && a.isVisible !== false; -} - -// ── Connection Settings ──────────────────────────────────────────────────── - -export function getConnectionSettings(userId: number) { - const creds = getImmichCredentials(userId); - const prefs = db.prepare('SELECT immich_auto_upload FROM users WHERE id = ?').get(userId) as { immich_auto_upload?: number } | undefined; - return { - immich_url: creds?.immich_url || '', - connected: !!(creds?.immich_url && creds?.immich_api_key), - auto_upload: !!(prefs?.immich_auto_upload), - }; -} - -export function setImmichAutoUpload(userId: number, enabled: boolean): void { - db.prepare('UPDATE users SET immich_auto_upload = ? WHERE id = ?').run(enabled ? 1 : 0, userId); -} - -export async function saveImmichSettings( - userId: number, - immichUrl: string | undefined, - immichApiKey: string | undefined, - clientIp: string | null -): Promise<{ success: boolean; warning?: string; error?: string }> { - if (immichUrl) { - const ssrf = await checkSsrf(immichUrl.trim()); - if (!ssrf.allowed) { - return { success: false, error: `Invalid Immich URL: ${ssrf.error}` }; - } - db.prepare('UPDATE users SET immich_url = ?, immich_api_key = ? WHERE id = ?').run( - immichUrl.trim(), - maybe_encrypt_api_key(immichApiKey), - userId - ); - if (ssrf.isPrivate) { - writeAudit({ - userId, - action: 'immich.private_ip_configured', - ip: clientIp, - details: { immich_url: immichUrl.trim(), resolved_ip: ssrf.resolvedIp }, - }); - return { - success: true, - warning: `Immich URL resolves to a private IP address (${ssrf.resolvedIp}). Make sure this is intentional.`, - }; - } - } else { - db.prepare('UPDATE users SET immich_url = ?, immich_api_key = ? WHERE id = ?').run( - null, - maybe_encrypt_api_key(immichApiKey), - userId - ); - } - return { success: true }; -} - -// ── Connection Test / Status ─────────────────────────────────────────────── - -export async function testConnection( - immichUrl: string, - immichApiKey: string -): Promise<{ connected: boolean; error?: string; user?: { name?: string; email?: string }; canonicalUrl?: string }> { - const ssrf = await checkSsrf(immichUrl); - if (!ssrf.allowed) return { connected: false, error: ssrf.error ?? 'Invalid Immich URL' }; - try { - const resp = await safeFetch(`${immichUrl}/api/users/me`, { - headers: { 'x-api-key': immichApiKey, 'Accept': 'application/json' }, - signal: AbortSignal.timeout(10000) as any, - }); - if (!resp.ok) return { connected: false, error: `HTTP ${resp.status}` }; - const data = await resp.json() as { name?: string; email?: string }; - - // Detect http → https upgrade only: same host/port, protocol changed to https - let canonicalUrl: string | undefined; - if (resp.url) { - const finalUrl = new URL(resp.url); - const inputUrl = new URL(immichUrl); - if ( - inputUrl.protocol === 'http:' && - finalUrl.protocol === 'https:' && - finalUrl.hostname === inputUrl.hostname && - finalUrl.port === inputUrl.port - ) { - canonicalUrl = finalUrl.origin; - } - } - - return { connected: true, user: { name: data.name, email: data.email }, canonicalUrl }; - } catch (err: unknown) { - return { connected: false, error: err instanceof Error ? err.message : 'Connection failed' }; - } -} - -export async function getConnectionStatus( - userId: number -): Promise<{ connected: boolean; error?: string; user?: { name?: string; email?: string } }> { - const creds = getImmichCredentials(userId); - if (!creds) return { connected: false, error: 'Not configured' }; - try { - const resp = await safeFetch(`${creds.immich_url}/api/users/me`, { - headers: { 'x-api-key': creds.immich_api_key, 'Accept': 'application/json' }, - signal: AbortSignal.timeout(10000) as any, - }); - if (!resp.ok) return { connected: false, error: `HTTP ${resp.status}` }; - const data = await resp.json() as { name?: string; email?: string }; - return { connected: true, user: { name: data.name, email: data.email } }; - } catch (err: unknown) { - return { connected: false, error: err instanceof Error ? err.message : 'Connection failed' }; - } -} - -// ── Browse Timeline / Search ─────────────────────────────────────────────── - -export async function browseTimeline( - userId: number -): Promise<{ buckets?: any; error?: string; status?: number }> { - const creds = getImmichCredentials(userId); - if (!creds) return { error: 'Immich not configured', status: 400 }; - - try { - const resp = await safeFetch(`${creds.immich_url}/api/timeline/buckets`, { - method: 'GET', - headers: { 'x-api-key': creds.immich_api_key, 'Accept': 'application/json' }, - signal: AbortSignal.timeout(15000) as any, - }); - if (!resp.ok) return { error: 'Failed to fetch from Immich', status: resp.status }; - const buckets = await resp.json(); - return { buckets }; - } catch { - return { error: 'Could not reach Immich', status: 502 }; - } -} - -export async function searchPhotos( - userId: number, - from?: string, - to?: string, - page: number = 1, - size: number = 50, -): Promise<{ assets?: any[]; hasMore?: boolean; error?: string; status?: number }> { - const creds = getImmichCredentials(userId); - if (!creds) return { error: 'Immich not configured', status: 400 }; - - try { - const resp = await safeFetch(`${creds.immich_url}/api/search/metadata`, { - method: 'POST', - headers: { 'x-api-key': creds.immich_api_key, 'Content-Type': 'application/json' }, - body: JSON.stringify({ - takenAfter: from ? `${from}T00:00:00.000Z` : undefined, - takenBefore: to ? `${to}T23:59:59.999Z` : undefined, - // No type filter — surface videos alongside images (#823). - // Immich 1.133–1.144 defaulted metadata search to `timeline` visibility; - // v3 defaults to any visibility except `locked`, which is what started - // surfacing Live Photo motion parts as broken tiles (#1474). Ask for - // `timeline` explicitly so hidden assets never cross the wire and a full - // page stays a full page of renderable tiles. - // - // `visibility` only exists from 1.133.0. Older servers strip it — Immich - // validates with `whitelist: true` and no `forbidNonWhitelisted`, so an - // unknown property is dropped, never a 400 — and they never defaulted - // `isVisible` either, so they still return hidden assets. isVisibleAsset() - // below is the only guard on those versions. Do not remove it. - visibility: 'timeline', - withExif: true, - size, - page, - }), - signal: AbortSignal.timeout(15000) as any, - }); - if (!resp.ok) return { error: 'Search failed', status: resp.status }; - const data = await resp.json() as { assets?: { items?: any[] } }; - const items = data.assets?.items || []; - // Belt-and-braces: `visibility: 'timeline'` above should mean Immich never - // sends a hidden asset, but an older server that ignores the filter would - // otherwise render broken tiles. hasMore stays on the raw page length so - // pagination still advances past a filtered page. - const assets = items - .filter(isVisibleAsset) - .map((a: any) => ({ - id: a.id, - takenAt: a.fileCreatedAt || a.createdAt, - city: a.exifInfo?.city || null, - country: a.exifInfo?.country || null, - lat: typeof a.exifInfo?.latitude === 'number' ? a.exifInfo.latitude : null, - lng: typeof a.exifInfo?.longitude === 'number' ? a.exifInfo.longitude : null, - mediaType: a.type === 'VIDEO' ? 'video' : 'image', - })); - return { assets, hasMore: items.length >= size }; - } catch { - return { error: 'Could not reach Immich', status: 502 }; - } -} - - -// ── Asset Info / Proxy ───────────────────────────────────────────────────── - - -export async function getAssetInfo( - userId: number, - assetId: string, - ownerUserId?: number -): Promise<{ data?: any; error?: string; status?: number }> { - const effectiveUserId = ownerUserId ?? userId; - const creds = getImmichCredentials(effectiveUserId); - if (!creds) return { error: 'Not found', status: 404 }; - - try { - const resp = await safeFetch(`${creds.immich_url}/api/assets/${assetId}`, { - headers: { 'x-api-key': creds.immich_api_key, 'Accept': 'application/json' }, - signal: AbortSignal.timeout(10000) as any, - }); - if (!resp.ok) return { error: 'Failed', status: resp.status }; - const asset = await resp.json() as any; - return { - data: { - id: asset.id, - takenAt: asset.fileCreatedAt || asset.createdAt, - width: asset.exifInfo?.exifImageWidth || null, - height: asset.exifInfo?.exifImageHeight || null, - camera: asset.exifInfo?.make && asset.exifInfo?.model ? `${asset.exifInfo.make} ${asset.exifInfo.model}` : null, - lens: asset.exifInfo?.lensModel || null, - focalLength: asset.exifInfo?.focalLength ? `${asset.exifInfo.focalLength}mm` : null, - aperture: asset.exifInfo?.fNumber ? `f/${asset.exifInfo.fNumber}` : null, - shutter: asset.exifInfo?.exposureTime || null, - iso: asset.exifInfo?.iso || null, - city: asset.exifInfo?.city || null, - state: asset.exifInfo?.state || null, - country: asset.exifInfo?.country || null, - lat: asset.exifInfo?.latitude || null, - lng: asset.exifInfo?.longitude || null, - fileSize: asset.exifInfo?.fileSizeInByte || null, - fileName: asset.originalFileName || null, - }, - }; - } catch { - return { error: 'Proxy error', status: 502 }; - } -} - -export async function fetchImmichThumbnailBytes( - userId: number, - assetId: string, - ownerUserId?: number -): Promise<{ bytes: Buffer; contentType: string } | { error: string; status: number }> { - const effectiveUserId = ownerUserId ?? userId; - const creds = getImmichCredentials(effectiveUserId); - if (!creds) return { error: 'Not found', status: 404 }; - - const url = `${creds.immich_url}/api/assets/${assetId}/thumbnail?size=thumbnail`; - try { - const resp = await safeFetch(url, { - headers: { 'x-api-key': creds.immich_api_key }, - signal: AbortSignal.timeout(10000) as any, - }); - if (!resp.ok) return { error: 'Upstream error', status: resp.status }; - const contentType = resp.headers.get('content-type') || 'image/jpeg'; - const bytes = Buffer.from(await resp.arrayBuffer()); - return { bytes, contentType }; - } catch { - return { error: 'Proxy error', status: 502 }; - } -} - -export async function streamImmichAsset( - response: Response, - userId: number, - assetId: string, - kind: 'thumbnail' | 'original', - ownerUserId?: number, - opts?: { mediaType?: string | null; range?: string }, -): Promise<{ error?: string; status?: number } | void> { - const effectiveUserId = ownerUserId ?? userId; - const creds = getImmichCredentials(effectiveUserId); - if (!creds) return { error: 'Not found', status: 404 }; - - const isVideo = opts?.mediaType === 'video'; - const headers: Record = { 'x-api-key': creds.immich_api_key }; - let url: string; - let timeout: number | undefined; - let cacheControl = 'public, max-age=86400'; - - if (kind === 'thumbnail') { - // Immich generates a poster thumbnail for video too. - url = `${creds.immich_url}/api/assets/${assetId}/thumbnail?size=thumbnail`; - timeout = 10000; - } else if (isVideo) { - // Transcoded, broadly-compatible MP4 with byte-range support; forward the - // viewer's Range so the player can seek. No abort timeout — video is a long - // streaming response, not a quick fetch (#823). - url = `${creds.immich_url}/api/assets/${assetId}/video/playback`; - if (opts?.range) headers['Range'] = opts.range; - cacheControl = 'private, max-age=3600'; - timeout = undefined; - } else { - url = `${creds.immich_url}/api/assets/${assetId}/thumbnail?size=fullsize`; - timeout = 30000; - } - - await pipeAsset(url, response, headers, timeout ? AbortSignal.timeout(timeout) : undefined, cacheControl); -} - -// ── Albums ────────────────────────────────────────────────────────────────── - -export async function listAlbums( - userId: number -): Promise<{ albums?: any[]; error?: string; status?: number }> { - const creds = getImmichCredentials(userId); - if (!creds) return { error: 'Immich not configured', status: 400 }; - - try { - // Fetch both owned and shared albums - const [ownResp, sharedResp] = await Promise.all([ - safeFetch(`${creds.immich_url}/api/albums`, { - headers: { 'x-api-key': creds.immich_api_key, 'Accept': 'application/json' }, - signal: AbortSignal.timeout(10000) as any, - }), - safeFetch(`${creds.immich_url}/api/albums?shared=true`, { - headers: { 'x-api-key': creds.immich_api_key, 'Accept': 'application/json' }, - signal: AbortSignal.timeout(10000) as any, - }), - ]); - if (!ownResp.ok) return { error: 'Failed to fetch albums', status: ownResp.status }; - const ownAlbums = await ownResp.json() as any[]; - const sharedAlbums = sharedResp.ok ? await sharedResp.json() as any[] : []; - const seenIds = new Set(); - const allAlbums = [...ownAlbums, ...sharedAlbums].filter((a: any) => { - if (seenIds.has(a.id)) return false; - seenIds.add(a.id); - return true; - }); - const albums = allAlbums.map((a: any) => ({ - id: a.id, - albumName: a.albumName, - assetCount: a.assetCount || 0, - startDate: a.startDate, - endDate: a.endDate, - albumThumbnailAssetId: a.albumThumbnailAssetId, - shared: a.shared || a.sharedUsers?.length > 0, - })); - return { albums }; - } catch { - return { error: 'Could not reach Immich', status: 502 }; - } -} - -/** Immich caps `size` at 1000 for metadata search; a page cap bounds pathological albums. */ -const ALBUM_PAGE_SIZE = 1000; -const ALBUM_MAX_PAGES = 20; - -/** - * Fetch every asset in an album, across Immich v2 and v3. - * - * Immich v3 removed `assets` from `AlbumResponseDto`, so `GET /api/albums/:id` - * no longer carries album contents (#1492) and we fall back to an - * `albumIds`-filtered metadata search. - * - * We feature-detect on the presence of `assets` rather than switching on a - * probed server version. The two paths are NOT interchangeable: - * - * - Before v3, `searchMetadata` unconditionally scopes results to - * `[self, ...partners]` (`asset.ownerId = ANY(userIds)`), so an albumIds - * search against an album shared by a non-partner returns nothing. v3 added - * an albumIds branch that checks `AlbumRead` and skips that owner filter. - * - 1.133–1.144 also default `visibility` to `timeline`, dropping archived - * album assets. - * - `albumIds` only exists from 1.135.0, and Immich strips unknown properties - * rather than rejecting them (`whitelist: true`, no `forbidNonWhitelisted`). - * Searching an older server would therefore silently drop the album filter - * and return the user's ENTIRE library as the album's contents. - * - * Feature detection makes all three moot: `assets` is present on every version - * that has any of those problems (through 1.144.1) and absent only on v3, where - * the search path is correct. A version probe with a wrong boundary would not be. - */ -async function fetchAlbumAssets( - creds: { immich_url: string; immich_api_key: string }, - albumId: string, -): Promise<{ assets?: any[]; status?: number }> { - const resp = await safeFetch(`${creds.immich_url}/api/albums/${albumId}`, { - headers: { 'x-api-key': creds.immich_api_key, 'Accept': 'application/json' }, - signal: AbortSignal.timeout(15000) as any, - }); - if (!resp.ok) return { status: resp.status }; - - const albumData = await resp.json() as { assets?: any[] }; - if (Array.isArray(albumData.assets)) return { assets: albumData.assets }; - - return fetchAlbumAssetsViaSearch(creds, albumId); -} - -/** - * Immich v3 album contents, via `POST /api/search/metadata` filtered by `albumIds`. - * - * `withExif` is required: it has no default and gates an inner join - * (`searchAssetBuilder`: `.$if(!!options.withExif, withExifInner)`), so without - * it Immich omits `exifInfo` entirely and every photo's city/country goes null. - */ -async function fetchAlbumAssetsViaSearch( - creds: { immich_url: string; immich_api_key: string }, - albumId: string, -): Promise<{ assets?: any[]; status?: number }> { - const all: any[] = []; - - for (let page = 1; page <= ALBUM_MAX_PAGES; page++) { - const resp = await safeFetch(`${creds.immich_url}/api/search/metadata`, { - method: 'POST', - headers: { 'x-api-key': creds.immich_api_key, 'Content-Type': 'application/json' }, - body: JSON.stringify({ - albumIds: [albumId], - withExif: true, - withDeleted: false, - size: ALBUM_PAGE_SIZE, - page, - }), - signal: AbortSignal.timeout(15000) as any, - }); - if (!resp.ok) return { status: resp.status }; - - const data = await resp.json() as { assets?: { items?: any[] } }; - const items = data.assets?.items || []; - all.push(...items); - if (items.length < ALBUM_PAGE_SIZE) break; - } - - return { assets: all }; -} - -export async function getAlbumPhotos( - userId: number, - albumId: string, -): Promise<{ assets?: any[]; error?: string; status?: number }> { - const creds = getImmichCredentials(userId); - if (!creds) return { error: 'Immich not configured', status: 400 }; - - try { - const result = await fetchAlbumAssets(creds, albumId); - if (!result.assets) return { error: 'Failed to fetch album', status: result.status }; - // Albums legitimately contain hidden assets on both Immich versions (the v2 - // album body and the v3 album search both return them), so filter here (#1474). - const assets = result.assets - .filter(isVisibleAsset) - .map((a: any) => ({ - id: a.id, - takenAt: a.fileCreatedAt || a.createdAt, - city: a.exifInfo?.city || null, - country: a.exifInfo?.country || null, - mediaType: a.type === 'VIDEO' ? 'video' : 'image', - })); - return { assets }; - } catch { - return { error: 'Could not reach Immich', status: 502 }; - } -} - -/** - * The Immich half of an album sync: resolve the link, fetch the album, and hand - * back the picked asset ids. - * - * Adding them to the trip stays with the unified service, which owns - * addTripPhotos. Splitting it there is what breaks the import cycle this module - * used to close (immich -> unified -> photoResolver -> immich): the provider now - * only ever points downhill. Every error string and status below is the one the - * combined function returned. - */ -export async function collectAlbumSelection( - tripId: string, - linkId: string, - userId: number, -): Promise<{ selection: Selection; total: number } | { error: string; status: number }> { - 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 albumResult = await fetchAlbumAssets(creds, response.data as string); - if (!albumResult.assets) return { error: 'Failed to fetch album', status: albumResult.status }; - // Hidden assets must never be persisted: the render path never re-checks - // visibility, so a stored hidden asset is a permanently broken tile (#1474). - const assets = albumResult.assets.filter((a: any) => a.type === 'IMAGE' && isVisibleAsset(a)); - - return { - selection: { provider: 'immich', asset_ids: assets.map((a: any) => a.id) }, - total: assets.length, - }; - } catch { - return { error: 'Could not reach Immich', status: 502 }; - } -} - -// ── Upload to Immich ────────────────────────────────────────────────────── - -export async function uploadToImmich(userId: number, filePath: string, fileName: string): Promise { - const creds = getImmichCredentials(userId); - if (!creds) return null; - - const fs = await import('node:fs'); - const path = await import('node:path'); - - const fullPath = path.join(UPLOADS_ROOT, filePath); - if (!fs.existsSync(fullPath)) return null; - - try { - const fileBuffer = fs.readFileSync(fullPath); - const boundary = '----ImmichUpload' + Date.now(); - const ext = path.extname(fileName).toLowerCase(); - const mimeTypes: Record = { - '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.png': 'image/png', - '.gif': 'image/gif', '.webp': 'image/webp', '.heic': 'image/heic', - }; - const contentType = mimeTypes[ext] || 'application/octet-stream'; - const now = new Date().toISOString(); - - const parts: Buffer[] = []; - const addField = (name: string, value: string) => { - parts.push(Buffer.from(`--${boundary}\r\nContent-Disposition: form-data; name="${name}"\r\n\r\n${value}\r\n`)); - }; - addField('deviceAssetId', `trek-${Date.now()}`); - addField('deviceId', 'TREK'); - addField('fileCreatedAt', now); - addField('fileModifiedAt', now); - - parts.push(Buffer.from( - `--${boundary}\r\nContent-Disposition: form-data; name="assetData"; filename="${fileName}"\r\nContent-Type: ${contentType}\r\n\r\n` - )); - parts.push(fileBuffer); - parts.push(Buffer.from(`\r\n--${boundary}--\r\n`)); - - const body = Buffer.concat(parts); - - const res = await safeFetch(`${creds.immich_url}/api/assets`, { - method: 'POST', - headers: { - 'x-api-key': creds.immich_api_key, - 'Content-Type': `multipart/form-data; boundary=${boundary}`, - 'Content-Length': String(body.length), - }, - body, - }); - - if (res.ok) { - const data = await res.json() as { id?: string }; - return data.id || null; - } - return null; - } catch { - return null; - } -} diff --git a/server/src/services/memories/photoResolverService.ts b/server/src/services/memories/photoResolverService.ts deleted file mode 100644 index 7b598a2c2..000000000 --- a/server/src/services/memories/photoResolverService.ts +++ /dev/null @@ -1,175 +0,0 @@ -import { Response } from 'express'; -import path from 'path'; -import fs from 'fs'; -import { db } from '../../db/database'; -import type { TrekPhoto } from '../../types'; -// The trek_photos rows live in nest/photos now — this module is the provider -// dispatch half of what used to be one file. -import { resolveTrekPhoto } from '../../nest/photos/photos.bridge'; -import { UPLOADS_ROOT } from '../../nest/memories/uploads-root'; -import { streamImmichAsset, fetchImmichThumbnailBytes, getAssetInfo as getImmichAssetInfo } from './immichService'; -import { streamSynologyAsset, fetchSynologyThumbnailBytes, getSynologyAssetInfo } from './synologyService'; -import type { ServiceResult, AssetInfo } from './helpersService'; -import { fail, success } from './helpersService'; -import { decrypt_api_key } from '../../nest/common/crypto/apiKeyCrypto'; -import * as photoCache from './trekPhotoCache'; -import { ensureLocalThumbnail } from './thumbnailService'; - -// ── Streaming ──────────────────────────────────────────────────────────── - -async function streamCachedThumbnail( - res: Response, - photo: TrekPhoto, - fetchBytes: () => Promise<{ bytes: Buffer; contentType: string } | { error: string; status: number }>, - fallback: () => Promise, -): Promise { - const key = photoCache.cacheKey(photo.provider!, photo.asset_id!, 'thumbnail', photo.owner_id!); - - if (photoCache.serveFresh(res, key)) return; - - const existing = photoCache.getInFlight(key); - if (existing) { - const bytes = await existing; - if (bytes && photoCache.serveFresh(res, key)) return; - await fallback(); - return; - } - - const promise = fetchBytes().then(async result => { - if ('error' in result) return null; - await photoCache.put(key, result.bytes, result.contentType); - return result.bytes; - }); - photoCache.setInFlight(key, promise); - - const bytes = await promise; - if (bytes && photoCache.serveFresh(res, key)) return; - await fallback(); -} - -export async function streamPhoto( - res: Response, - userId: number, - photoId: number, - kind: 'thumbnail' | 'original', - range?: string, -): Promise { - const photo = resolveTrekPhoto(photoId); - if (!photo) { - res.status(404).json({ error: 'Photo not found' }); - return; - } - - if (photo.file_path) { - const uploadsRoot = UPLOADS_ROOT; - - if (kind === 'thumbnail') { - const isVideo = photo.media_type === 'video'; - let thumbRel = photo.thumbnail_path ?? null; - // Only raster images get a lazily-generated Jimp thumbnail; Jimp can't decode - // video, so a video relies on the poster captured at upload (#823). - if (!thumbRel && !isVideo) { - const result = await ensureLocalThumbnail(uploadsRoot, photo.file_path); - if (result) { - thumbRel = result.thumbnailRelPath; - db.prepare( - 'UPDATE trek_photos SET thumbnail_path = ?, width = COALESCE(width, ?), height = COALESCE(height, ?) WHERE id = ?' - ).run(thumbRel, result.width, result.height, photo.id); - } - } - if (thumbRel) { - const thumbAbs = path.join(uploadsRoot, thumbRel); - if (fs.existsSync(thumbAbs)) { - res.set('Cache-Control', 'public, max-age=86400, immutable'); - res.set('X-Content-Type-Options', 'nosniff'); - res.sendFile(thumbAbs); - return; - } - } - // A poster-less video must NOT fall through to streaming the whole file as a - // "thumbnail"; let the client render its own placeholder instead. - if (isVideo) { - res.status(404).json({ error: 'No poster available' }); - return; - } - // Images fall through to original if the thumbnail is unavailable. - } - - const localPath = path.join(uploadsRoot, photo.file_path); - if (fs.existsSync(localPath)) { - res.set('Cache-Control', 'public, max-age=86400'); - res.set('X-Content-Type-Options', 'nosniff'); - res.sendFile(localPath); - return; - } - } - - switch (photo.provider) { - case 'local': { - res.status(404).json({ error: 'File not found' }); - return; - } - case 'immich': { - if (kind === 'thumbnail') { - await streamCachedThumbnail( - res, photo, - () => fetchImmichThumbnailBytes(userId, photo.asset_id!, photo.owner_id!), - () => streamImmichAsset(res, userId, photo.asset_id!, kind, photo.owner_id!, { mediaType: photo.media_type }), - ); - return; - } - await streamImmichAsset(res, userId, photo.asset_id!, kind, photo.owner_id!, { mediaType: photo.media_type, range }); - return; - } - case 'synologyphotos': { - const passphrase = photo.passphrase ? (decrypt_api_key(photo.passphrase) || undefined) : undefined; - if (kind === 'thumbnail') { - await streamCachedThumbnail( - res, photo, - () => fetchSynologyThumbnailBytes(userId, photo.owner_id!, photo.asset_id!, passphrase), - () => streamSynologyAsset(res, userId, photo.owner_id!, photo.asset_id!, kind, undefined, passphrase), - ); - return; - } - await streamSynologyAsset(res, userId, photo.owner_id!, photo.asset_id!, kind, undefined, passphrase); - return; - } - default: - res.status(400).json({ error: `Unknown provider: ${photo.provider}` }); - } -} - -// ── Asset Info ──────────────────────────────────────────────────────────── - -export async function getPhotoInfo( - userId: number, - photoId: number, -): Promise> { - const photo = resolveTrekPhoto(photoId); - if (!photo) return fail('Photo not found', 404); - - switch (photo.provider) { - case 'local': { - return success({ - id: String(photo.id), - takenAt: photo.created_at, - city: null, - country: null, - width: photo.width, - height: photo.height, - fileName: photo.file_path?.split('/').pop() || null, - } as AssetInfo); - } - case 'immich': { - const result = await getImmichAssetInfo(userId, photo.asset_id!, photo.owner_id!); - if (result.error) return fail(result.error, result.status || 500); - return success(result.data as AssetInfo); - } - case 'synologyphotos': { - const passphrase = photo.passphrase ? (decrypt_api_key(photo.passphrase) || undefined) : undefined; - return getSynologyAssetInfo(userId, photo.asset_id!, photo.owner_id!, passphrase); - } - default: - return fail(`Unknown provider: ${photo.provider}`, 400); - } -} diff --git a/server/src/services/memories/synologyService.ts b/server/src/services/memories/synologyService.ts deleted file mode 100644 index b0228ea39..000000000 --- a/server/src/services/memories/synologyService.ts +++ /dev/null @@ -1,705 +0,0 @@ - -import { Response } from 'express'; -import { db } from '../../db/database'; -import { decrypt_api_key, encrypt_api_key, maybe_encrypt_api_key } from '../../nest/common/crypto/apiKeyCrypto'; -import { safeFetch, SsrfBlockedError, checkSsrf } from '../../utils/ssrfGuard'; -import { - getAlbumLinkForSync, - updateSyncTimeForAlbumLink, - Selection, - ServiceResult, - fail, - success, - handleServiceResult, - pipeAsset, - AlbumsList, - AssetsList, - StatusResult, - SyncAlbumResult, - AssetInfo -} from './helpersService'; -import { send as sendNotification } from '../../nest/notifications/notifications.bridge'; - -const SYNOLOGY_PROVIDER = 'synologyphotos'; -// Users provide the full base URL including the Photos app path (e.g. https://nas:5001/photo). -// The API endpoint is always at {base_url}/webapi/entry.cgi. -const SYNOLOGY_ENDPOINT_PATH = '/webapi/entry.cgi'; - -const SYNOLOGY_ERROR_MESSAGES: Record = { - 101: 'Missing API, method, or version parameter.', - 102: 'Requested API does not exist.', - 103: 'Requested method does not exist.', - 104: 'Requested API version is not supported.', - 105: 'Insufficient privilege.', - 106: 'Connection timeout.', - 107: 'Multiple logins blocked from this IP.', - 117: 'Manager privilege required.', - 119: 'Session is invalid or expired.', - 400: 'Invalid credentials.', - 401: 'Session expired or account disabled.', - 402: 'No permission to use this account.', - 403: 'Two-factor authentication code required.', - 404: 'Two-factor authentication failed.', - 406: 'Two-factor authentication is enforced for this account.', - 407: 'Maximum login attempts reached.', - 408: 'Password expired.', - 409: 'Remote password expired.', - 410: 'Password must be changed before login.', - 412: 'Guest account cannot log in.', - 413: 'OTP system files are corrupted.', - 414: 'Unable to log in.', - 416: 'Unable to log in.', - 417: 'OTP system is full.', - 498: 'System is upgrading.', - 499: 'System is not ready.', -}; - -interface SynologyUserRecord { - synology_url?: string | null; - synology_username?: string | null; - synology_password?: string | null; - synology_sid?: string | null; - synology_did?: string | null; - synology_skip_ssl?: number | null; -}; - -interface SynologyCredentials { - synology_url: string; - synology_username: string; - synology_password: string; - synology_skip_ssl: boolean; -} - -interface SynologySettings { - synology_url: string; - synology_username: string; - synology_skip_ssl: boolean; - 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 { - const row = db.prepare(`SELECT synology_url, synology_username, synology_password, synology_sid, synology_did, synology_skip_ssl 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]; - } - - 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', 'synology_skip_ssl']); - 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); - const password = decrypt_api_key(user.data.synology_password); - if (!password) return fail('Synology credentials corrupted', 500); - return success({ - synology_url: user.data.synology_url, - synology_username: user.data.synology_username, - synology_password: password, - synology_skip_ssl: user.data.synology_skip_ssl !== 0, - }); -} - - -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, skipSsl = true): Promise> { - const endpoint = _buildSynologyEndpoint(url, `api=${body.get('api')}`); - try { - const resp = await safeFetch(endpoint, { - method: 'POST', - headers: { - 'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8', - }, - body, - signal: AbortSignal.timeout(30000) as any, - }, { rejectUnauthorized: !skipSsl }); - if (!resp.ok) { - return fail('Synology API request failed with status ' + resp.status, resp.status); - } - const response = await resp.json() as SynologyApiResponse; - if (!response.success) { - const code = response.error.code; - const message = SYNOLOGY_ERROR_MESSAGES[code] ?? 'Synology API request failed (code ' + code + ')'; - // Preserve session error codes (106, 107, 119) for internal retry logic in _requestSynologyApi. - // All other Synology app-level codes are mapped to HTTP 400 — they are not HTTP status codes. - const httpStatus = [106, 107, 119].includes(code) ? code : 400; - return fail(message, httpStatus); - } - return success(response.data); - } catch (error) { - if (error instanceof SsrfBlockedError) { - return fail(error.message, 400); - } - return fail('Failed to connect to Synology API', 500); - } -} - -const SYNOLOGY_DEVICE_NAME = 'trek'; - -async function _loginToSynology( - url: string, - username: string, - password: string, - opts: { otp?: string; deviceId?: string; skipSsl?: boolean } = {}, -): Promise> { - const { otp, deviceId, skipSsl = false } = opts; - const body = new URLSearchParams({ - api: 'SYNO.API.Auth', - method: 'login', - version: '6', - account: username, - passwd: password, - format: 'sid', - client: 'browser', - device_name: SYNOLOGY_DEVICE_NAME, - }); - if (otp && otp.trim()) { - body.append('otp_code', otp.trim()); - body.append('enable_device_token', 'yes'); - } - if (deviceId) { - body.append('device_id', deviceId); - } - - const result = await _fetchSynologyJson<{ sid?: string; did?: string }>(url, body, skipSsl); - if (!result.success) { - return result as ServiceResult<{ sid: string; did?: string }>; - } - if (!result.data.sid) { - return fail('Failed to get session ID from Synology', 500); - } - return success({ sid: result.data.sid, did: result.data.did }); -} - -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 skipSsl = creds.data.synology_skip_ssl; - const body = _buildSynologyFormBody({ ...params, _sid: session.data }); - const result = await _fetchSynologyJson(creds.data.synology_url, body, skipSsl); - // 106 = session timeout, 107 = duplicate login kicked us out, 119 = SID not found/invalid - if ('error' in result && [106, 107, 119].includes(result.error.status)) { - _clearSynologySID(userId); - const retrySession = await _getSynologySession(userId); - if (!retrySession.success || !retrySession.data) { - return retrySession as ServiceResult; - } - return _fetchSynologyJson(creds.data.synology_url, _buildSynologyFormBody({ ...params, _sid: retrySession.data }), skipSsl); - } - 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: typeof gps.latitude === 'number' ? gps.latitude : null, - lng: typeof gps.longitude === 'number' ? 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 _clearSynologySession(userId: number): void { - db.prepare('UPDATE users SET synology_sid = NULL, synology_did = NULL WHERE id = ?').run(userId); -} - -function _splitPackedSynologyId(rawId: string): { id: string; cacheKey: string; assetId: string } | null { - // cache_key format from Synology is "{unit_id}_{timestamp}", e.g. "40808_1633659236". - // The first segment must be a non-empty integer (the unit ID used for API calls). - if (!/^\d+_.+$/.test(rawId)) return null; - const id = rawId.split('_')[0]; - return { id, cacheKey: rawId, assetId: rawId }; -} - -async function _getSynologySession(userId: number): Promise> { - const cached = _readSynologyUser(userId, ['synology_sid', 'synology_did']); - if (cached.success && cached.data?.synology_sid) { - const decryptedSid = decrypt_api_key(cached.data.synology_sid); - if (decryptedSid) return success(decryptedSid); - // Decryption failed (e.g. key rotation) — clear the stale SID and re-login - _clearSynologySID(userId); - } - - const creds = _getSynologyCredentials(userId); - if (!creds.success) { - return creds as ServiceResult; - } - - // Use stored device ID to skip OTP on re-login (trusted device flow) - const storedDid = cached.success && cached.data?.synology_did - ? (decrypt_api_key(cached.data.synology_did) || undefined) - : undefined; - - const resp = await _loginToSynology(creds.data.synology_url, creds.data.synology_username, creds.data.synology_password, { - deviceId: storedDid, - skipSsl: creds.data.synology_skip_ssl, - }); - - if (!resp.success) { - return resp as ServiceResult; - } - - db.prepare('UPDATE users SET synology_sid = ? WHERE id = ?').run(encrypt_api_key(resp.data.sid), userId); - return success(resp.data.sid); -} - -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 || '', - synology_skip_ssl: creds.data.synology_skip_ssl, - connected: session.success, - }); -} - -export async function updateSynologySettings(userId: number, synologyUrl: string, synologyUsername: string, synologyPassword?: string, synologySkipSsl = false): 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); - } - - // Only invalidate the session when the account itself changes (different URL or username). - // If the user just tested the connection, testSynologyConnection already stored a fresh - // sid + did — clearing them here would force an unnecessary re-login that may fail (MFA). - const existing = _readSynologyUser(userId, ['synology_url', 'synology_username']); - const urlChanged = existing.success && existing.data.synology_url !== synologyUrl; - const userChanged = existing.success && existing.data.synology_username !== synologyUsername; - const sessionCleared = urlChanged || userChanged; - if (sessionCleared) { - _clearSynologySession(userId); - // Fire-and-forget like unifiedService's photos_shared send — without the - // .catch an in-app insert failure became an unhandled rejection. - sendNotification({ - event: 'synology_session_cleared', - actorId: null, - params: {}, - scope: 'user', - targetId: userId, - }).catch(() => {}); - } - - try { - db.prepare('UPDATE users SET synology_url = ?, synology_username = ?, synology_password = ?, synology_skip_ssl = ? WHERE id = ?').run( - synologyUrl, - synologyUsername, - synologyPassword ? maybe_encrypt_api_key(synologyPassword) : existingEncryptedPassword, - synologySkipSsl ? 1 : 0, - userId, - ); - } catch { - return fail('Failed to update Synology settings', 500); - } - - 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.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(userId: number, synologyUrl: string, synologyUsername: string, synologyPassword: string, synologyOtp?: string, synologySkipSsl = false): Promise> { - - const ssrf = await checkSsrf(synologyUrl); - if (!ssrf.allowed) { - return fail(ssrf.error, 400); - } - - const resp = await _loginToSynology(synologyUrl, synologyUsername, synologyPassword, { otp: synologyOtp, skipSsl: synologySkipSsl }); - if ('error' in resp) { - return success({ connected: false, error: resp.error.message }); - } - - // Persist the session so the OTP code is not required again on save. - // The did (device token) allows future re-logins without OTP. - db.prepare('UPDATE users SET synology_sid = ? WHERE id = ?').run(encrypt_api_key(resp.data.sid), userId); - if (resp.data.did) { - db.prepare('UPDATE users SET synology_did = ? WHERE id = ?').run(encrypt_api_key(resp.data.did), userId); - } - - return success({ connected: true, user: { name: synologyUsername } }); -} - -async function _fetchAllSynologyAlbums(userId: number, baseParams: ApiCallParams): Promise> { - const pageSize = 100; - const all: any[] = []; - let offset = 0; - while (true) { - const result = await _requestSynologyApi<{ list: any[] }>(userId, { ...baseParams, offset, limit: pageSize }); - if (!result.success) return result as ServiceResult; - const items = result.data.list || []; - all.push(...items); - if (items.length < pageSize) break; - offset += pageSize; - } - return success(all); -} - -export async function listSynologyAlbums(userId: number): Promise> { - const [personal, shared, sharedWithMe] = await Promise.allSettled([ - _fetchAllSynologyAlbums(userId, { api: 'SYNO.Foto.Browse.Album', method: 'list', version: 4 }), - _fetchAllSynologyAlbums(userId, { api: 'SYNO.Foto.Browse.Album', method: 'list', version: 4, category: 'shared' }), - _fetchAllSynologyAlbums(userId, { api: 'SYNO.Foto.Sharing.Misc', method: 'list_shared_with_me_album', version: 1, additional: ['thumbnail', 'sharing_info'] }), - ]); - - const map = new Map(); - - const addAlbums = (result: PromiseSettledResult>, extractPassphrase: (a: any) => string | undefined) => { - if (result.status === 'rejected') return; - const value = result.value; - if ('error' in value) { - console.warn('[Synology] album list partial failure:', value.error.message); - return; - } - for (const album of value.data ?? []) { - const id = String(album.id); - const passphrase = extractPassphrase(album); - map.set(id, { id, albumName: album.name || '', assetCount: album.item_count || 0, passphrase }); - } - }; - - addAlbums(personal, () => undefined); - addAlbums(shared, (a) => a.passphrase || undefined); - addAlbums(sharedWithMe, (a) => a.passphrase || a.sharing_info?.passphrase || undefined); - - if (map.size === 0 && personal.status === 'fulfilled' && !personal.value.success) { - return personal.value as ServiceResult; - } - - const albums = [...map.values()].sort((a, b) => a.albumName.localeCompare(b.albumName)); - return success({ albums }); -} - - -export async function getSynologyAlbumPhotos(userId: number, albumId: string, passphrase?: string): Promise> { - const allItems: SynologyPhotoItem[] = []; - const pageSize = 50; - let offset = 0; - - while (true) { - const params: ApiCallParams = passphrase - ? { api: 'SYNO.Foto.Browse.Item', method: 'list', version: 1, passphrase, offset, limit: pageSize, additional: ['thumbnail'] } - : { api: 'SYNO.Foto.Browse.Item', method: 'list', version: 1, album_id: Number(albumId), offset, limit: pageSize, additional: ['thumbnail'] }; - const result = await _requestSynologyApi<{ list: SynologyPhotoItem[] }>(userId, params); - if (!result.success) return result as ServiceResult; - const items = result.data.list || []; - allItems.push(...items); - if (items.length < pageSize) break; - offset += pageSize; - } - - const assets = allItems.map(item => ({ - id: String(item.additional?.thumbnail?.cache_key || item.id || ''), - takenAt: item.time ? new Date(item.time * 1000).toISOString() : '', - })).filter(a => a.id); - - return success({ assets, total: assets.length, hasMore: false }); -} - -/** - * The Synology half of an album sync — see collectAlbumSelection in - * immichService for why the trip-side write lives in the unified service now. - */ -export async function collectSynologyAlbumSelection(userId: number, tripId: string, linkId: string): Promise> { - const response = getAlbumLinkForSync(tripId, linkId, userId); - if (!response.success) return response as ServiceResult<{ selection: Selection; total: number }>; - - const { albumId, passphrase } = response.data; - - const allItems: SynologyPhotoItem[] = []; - const pageSize = 50; - let offset = 0; - - while (true) { - const itemParams: ApiCallParams = passphrase - ? { api: 'SYNO.Foto.Browse.Item', method: 'list', version: 1, passphrase, offset, limit: pageSize, additional: ['thumbnail'] } - : { api: 'SYNO.Foto.Browse.Item', method: 'list', version: 1, album_id: Number(albumId), offset, limit: pageSize, additional: ['thumbnail'] }; - - const result = await _requestSynologyApi<{ list: SynologyPhotoItem[] }>(userId, itemParams); - - if (!result.success) return result as ServiceResult<{ selection: Selection; total: number }>; - - 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), - passphrase, - }; - - return success({ selection, 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', 'gps'], - }; - - 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 - } - } - - // SYNO.Foto.Search.Search list_item does not return a total count — only data.list. - // hasMore is inferred: if we got a full page, there may be more. - const result = await _requestSynologyApi<{ list: SynologyPhotoItem[] }>(userId, params); - if (!result.success) return result as ServiceResult; - - const allItems = result.data.list || []; - const assets = allItems.map(item => _normalizeSynologyPhotoInfo(item)); - - return success({ - assets, - total: allItems.length, - hasMore: allItems.length === limit, - }); -} - -export async function getSynologyAssetInfo(userId: number, photoId: string, targetUserId: number, passphrase?: string): Promise> { - const parsedId = _splitPackedSynologyId(photoId); - if (!parsedId) return fail('Invalid photo ID format', 400); - const infoParams: ApiCallParams = { - 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 (passphrase) infoParams.passphrase = passphrase; - const result = await _requestSynologyApi<{ list: SynologyPhotoItem[] }>(targetUserId, infoParams); - - 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 fetchSynologyThumbnailBytes( - userId: number, - targetUserId: number, - photoId: string, - passphrase?: string, -): Promise<{ bytes: Buffer; contentType: string } | { error: string; status: number }> { - const parsedId = _splitPackedSynologyId(photoId); - if (!parsedId) return { error: 'Invalid photo ID format', status: 400 }; - - const synology_credentials = _getSynologyCredentials(targetUserId); - if (!synology_credentials.success) return { error: 'Credentials error', status: 500 }; - - const sid = await _getSynologySession(targetUserId); - if (!sid.success) return { error: 'Session error', status: 500 }; - if (!sid.data) return { error: 'Session ID missing', status: 500 }; - - const params = new URLSearchParams({ - api: 'SYNO.Foto.Thumbnail', - method: 'get', - version: '2', - mode: 'download', - id: parsedId.id, - type: 'unit', - // Match the uncached streamSynologyAsset default — 'sm' (240px) looked - // pixelated on retina. - size: 'm', - cache_key: parsedId.cacheKey, - _sid: sid.data, - }); - if (passphrase) params.append('passphrase', passphrase); - - const url = _buildSynologyEndpoint(synology_credentials.data.synology_url, params.toString()); - try { - const resp = await safeFetch(url, undefined, { rejectUnauthorized: !synology_credentials.data.synology_skip_ssl }); - if (!resp.ok) return { error: 'Upstream error', status: resp.status }; - const contentType = resp.headers.get('content-type') || 'image/jpeg'; - const bytes = Buffer.from(await resp.arrayBuffer()); - return { bytes, contentType }; - } catch (error) { - console.error('fetchSynologyThumbnailBytes: upstream fetch failed:', error); - return { error: 'Proxy error', status: 502 }; - } -} - -export async function streamSynologyAsset( - response: Response, - userId: number, - targetUserId: number, - photoId: string, - kind: 'thumbnail' | 'original', - size?: string, - passphrase?: string, -): Promise { - const parsedId = _splitPackedSynologyId(photoId); - if (!parsedId) { - handleServiceResult(response, fail('Invalid photo ID format', 400)); - return; - } - - 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; - } - - - //size: 'sm' 240px| 'm' 320px| 'xl' 1280px| 'preview' ? - // Use Thumbnail API for both thumbnail and original — avoids serving raw HEIC files - // (original uses xl size to get a full-resolution JPEG-compatible render). - // Thumbnail default is 'm' (~320px) — 'sm' (240px) looked pixelated on - // the journey grid on retina screens. - const resolvedSize = kind === 'original' ? 'xl' : (size || 'm'); - const params = new URLSearchParams({ - api: 'SYNO.Foto.Thumbnail', - method: 'get', - version: '2', - mode: 'download', - id: parsedId.id, - type: 'unit', - size: resolvedSize, - cache_key: parsedId.cacheKey, - _sid: sid.data, - }); - if (passphrase) params.append('passphrase', passphrase); - - const url = _buildSynologyEndpoint(synology_credentials.data.synology_url, params.toString()); - await pipeAsset(url, response, undefined, undefined, 'public, max-age=86400', { rejectUnauthorized: !synology_credentials.data.synology_skip_ssl }) -} diff --git a/server/src/services/memories/thumbnailService.ts b/server/src/services/memories/thumbnailService.ts deleted file mode 100644 index 1c2ec786e..000000000 --- a/server/src/services/memories/thumbnailService.ts +++ /dev/null @@ -1,50 +0,0 @@ -import { Jimp } from 'jimp' -import path from 'path' -import fs from 'fs/promises' -import crypto from 'crypto' -import { isAddonEnabled } from '../../nest/addons/addons.bridge' -import { ADDON_IDS } from '../../addons' - -const THUMB_MAX = 800 -const THUMB_QUALITY = 80 - -export async function ensureLocalThumbnail( - uploadsRoot: string, - originalRelPath: string, -): Promise<{ thumbnailRelPath: string; width: number; height: number } | null> { - if (!isAddonEnabled(ADDON_IDS.JOURNEY)) return null - - const originalAbs = path.join(uploadsRoot, originalRelPath) - try { await fs.access(originalAbs) } catch { return null } - - // Deterministic name so concurrent requests don't race on the same photo. - const hash = crypto.createHash('sha1').update(originalRelPath).digest('hex').slice(0, 16) - const thumbRel = `journey/thumbs/${hash}.jpg` - const thumbAbs = path.join(uploadsRoot, thumbRel) - - try { - const [srcStat, dstStat] = await Promise.all([ - fs.stat(originalAbs), - fs.stat(thumbAbs).catch(() => null), - ]) - if (dstStat && dstStat.mtimeMs >= srcStat.mtimeMs) { - const img = await Jimp.read(thumbAbs) - return { thumbnailRelPath: thumbRel, width: img.bitmap.width, height: img.bitmap.height } - } - - await fs.mkdir(path.dirname(thumbAbs), { recursive: true }) - - // Jimp auto-applies EXIF orientation on read, matching sharp's .rotate() behavior. - const img = await Jimp.read(originalAbs) - const { width: w, height: h } = img.bitmap - if (w > THUMB_MAX || h > THUMB_MAX) { - img.scaleToFit({ w: THUMB_MAX, h: THUMB_MAX }) - } - await img.write(thumbAbs as `${string}.jpg`, { quality: THUMB_QUALITY }) - - return { thumbnailRelPath: thumbRel, width: img.bitmap.width, height: img.bitmap.height } - } catch { - // Unsupported format, corrupt file, etc. — fall back to original in caller. - return null - } -} diff --git a/server/src/services/memories/trekPhotoCache.ts b/server/src/services/memories/trekPhotoCache.ts deleted file mode 100644 index e1dae5a1b..000000000 --- a/server/src/services/memories/trekPhotoCache.ts +++ /dev/null @@ -1,92 +0,0 @@ -import path from 'node:path'; -import fs from 'node:fs'; -import fsPromises from 'node:fs/promises'; -import crypto from 'node:crypto'; -import { Response } from 'express'; -import { db } from '../../db/database'; -import { UPLOADS_ROOT } from '../../nest/memories/uploads-root'; - -const TREK_PHOTO_DIR = path.join(UPLOADS_ROOT, 'photos/trek'); -export const CACHE_TTL = 60 * 60 * 1000; // 1 hour - -const inFlight = new Map>(); - -export function cacheKey(provider: string, assetId: string, kind: string, ownerId: number): string { - return crypto.createHash('sha1').update(`${provider}:${assetId}:${kind}:${ownerId}`).digest('hex'); -} - -function ensureDir(): void { - if (!fs.existsSync(TREK_PHOTO_DIR)) { - fs.mkdirSync(TREK_PHOTO_DIR, { recursive: true }); - } -} - -function cachedFilePath(key: string): string { - return path.join(TREK_PHOTO_DIR, `${key}.bin`); -} - -export function getFresh(key: string): { filePath: string; contentType: string } | null { - const row = db.prepare( - 'SELECT content_type, fetched_at FROM trek_photo_cache_meta WHERE cache_key = ?' - ).get(key) as { content_type: string; fetched_at: number } | undefined; - - if (!row) return null; - - if (Date.now() - row.fetched_at >= CACHE_TTL) { - db.prepare('DELETE FROM trek_photo_cache_meta WHERE cache_key = ?').run(key); - return null; - } - - const fp = cachedFilePath(key); - if (!fs.existsSync(fp)) { - db.prepare('DELETE FROM trek_photo_cache_meta WHERE cache_key = ?').run(key); - return null; - } - - return { filePath: fp, contentType: row.content_type }; -} - -export async function put(key: string, bytes: Buffer, contentType: string): Promise { - ensureDir(); - const fp = cachedFilePath(key); - const tmp = fp + '.tmp'; - - await fsPromises.writeFile(tmp, bytes); - await fsPromises.rename(tmp, fp); - - db.prepare( - 'INSERT OR REPLACE INTO trek_photo_cache_meta (cache_key, content_type, fetched_at) VALUES (?, ?, ?)' - ).run(key, contentType, Date.now()); -} - -export function serveFresh(res: Response, key: string): boolean { - const entry = getFresh(key); - if (!entry) return false; - - res.set('Content-Type', entry.contentType); - res.set('Cache-Control', 'public, max-age=3600'); - res.sendFile(entry.filePath); - return true; -} - -export function getInFlight(key: string): Promise | undefined { - return inFlight.get(key); -} - -export function setInFlight(key: string, promise: Promise): void { - inFlight.set(key, promise); - promise.finally(() => inFlight.delete(key)); -} - -export function sweepExpired(): void { - const cutoff = Date.now() - CACHE_TTL * 2; - const stale = db.prepare( - 'SELECT cache_key FROM trek_photo_cache_meta WHERE fetched_at < ?' - ).all(cutoff) as { cache_key: string }[]; - - for (const row of stale) { - db.prepare('DELETE FROM trek_photo_cache_meta WHERE cache_key = ?').run(row.cache_key); - const fp = cachedFilePath(row.cache_key); - if (fs.existsSync(fp)) fs.unlinkSync(fp); - } -} diff --git a/server/src/services/memories/unifiedService.ts b/server/src/services/memories/unifiedService.ts deleted file mode 100644 index c92d26036..000000000 --- a/server/src/services/memories/unifiedService.ts +++ /dev/null @@ -1,361 +0,0 @@ -import { db, canAccessTrip } from '../../db/database'; -import { send } from '../../nest/notifications/notifications.bridge'; -import { broadcast } from '../../websocket'; -import { - ServiceResult, - SyncAlbumResult, - fail, - success, - mapDbError, - Selection, - updateSyncTimeForAlbumLink, -} from './helpersService'; -import { getOrCreateTrekPhoto, deleteTrekPhotoIfOrphan } from '../../nest/photos/photos.bridge'; -import { collectAlbumSelection } from './immichService'; -import { collectSynologyAlbumSelection } from './synologyService'; -import { encrypt_api_key } from '../../nest/common/crypto/apiKeyCrypto'; - - -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.photo_id, tkp.asset_id, tkp.provider, tp.user_id, tp.shared, tp.added_at, - u.username, u.avatar - FROM trip_photos tp - JOIN trek_photos tkp ON tkp.id = tp.photo_id - JOIN users u ON tp.user_id = u.id - WHERE tp.trip_id = ? - AND (tp.user_id = ? OR tp.shared = 1) - AND tkp.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, passphrase?: string): ServiceResult { - const providerResult = _validProvider(provider); - if (!providerResult.success) { - return providerResult as ServiceResult; - } - try { - const photoId = getOrCreateTrekPhoto(provider, assetId, userId, passphrase); - const result = db.prepare( - 'INSERT OR IGNORE INTO trip_photos (trip_id, user_id, photo_id, shared, album_link_id) VALUES (?, ?, ?, ?, ?)' - ).run(tripId, userId, photoId, 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, selection.passphrase); - 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, - photoId: number, - 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 photo_id = ? - `).run(shared ? 1 : 0, tripId, userId, photoId); - - 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, - photoId: number, - 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 photo_id = ? - `).run(tripId, userId, photoId); - - deleteTrekPhotoIfOrphan(photoId); - 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, passphrase?: string): 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 encryptedPassphrase = passphrase ? encrypt_api_key(passphrase) : null; - const result = db.prepare( - 'INSERT OR IGNORE INTO trip_album_links (trip_id, user_id, provider, album_id, album_name, passphrase) VALUES (?, ?, ?, ?, ?, ?)' - ).run(tripId, userId, provider, albumId, albumName, encryptedPassphrase); - - 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 { - const linkedPhotos = db.prepare('SELECT photo_id FROM trip_photos WHERE trip_id = ? AND album_link_id = ?') - .all(tripId, linkId) as Array<{ photo_id: number }>; - - 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); - })(); - - for (const { photo_id } of linkedPhotos) { - deleteTrekPhotoIfOrphan(photo_id); - } - - 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 success(undefined); - - 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); - } -} - -// ── Album sync (orchestration) ──────────────────────────────────────────── -// -// The provider services collect the asset ids; adding them to the trip and -// stamping the link is this module's job, because addTripPhotos lives here. -// Keeping the write on this side is what lets immich/synology stop importing -// this module — the cycle immich -> unified -> photoResolver -> immich is gone. -// Both functions return exactly what the combined provider-side ones returned. - -export async function syncImmichAlbum( - tripId: string, - linkId: string, - userId: number, - sid: string, -): Promise<{ success?: boolean; added?: number; total?: number; error?: string; status?: number }> { - const collected = await collectAlbumSelection(tripId, linkId, userId); - if ('error' in collected) return { error: collected.error, status: collected.status }; - - const result = await addTripPhotos(tripId, userId, true, [collected.selection], sid, linkId); - if ('error' in result) return { error: result.error.message, status: result.error.status }; - - updateSyncTimeForAlbumLink(linkId); - - return { success: true, added: result.data.added, total: collected.total }; -} - -export async function syncSynologyAlbum( - userId: number, - tripId: string, - linkId: string, - sid: string, -): Promise> { - const collected = await collectSynologyAlbumSelection(userId, tripId, linkId); - if (!collected.success) return collected as ServiceResult; - - const result = await addTripPhotos(tripId, userId, true, [collected.data.selection], sid, linkId); - if (!result.success) return result as ServiceResult; - - updateSyncTimeForAlbumLink(linkId); - - return success({ added: result.data.added, total: collected.data.total }); -} diff --git a/server/tests/e2e/addons.e2e.test.ts b/server/tests/e2e/addons.e2e.test.ts index 4b3e0ac4d..ae4610ace 100644 --- a/server/tests/e2e/addons.e2e.test.ts +++ b/server/tests/e2e/addons.e2e.test.ts @@ -36,7 +36,7 @@ vi.mock('../../src/db/database', () => ({ const { getPhotoProviderConfig } = vi.hoisted(() => ({ getPhotoProviderConfig: vi.fn(() => ({ url: 'https://immich.example' })), })); -vi.mock('../../src/services/memories/helpersService', () => ({ getPhotoProviderConfig })); +vi.mock('../../src/nest/memories/memories.helpers', () => ({ getPhotoProviderConfig })); import { AddonsModule } from '../../src/nest/addons/addons.module'; import { TrekExceptionFilter } from '../../src/nest/common/trek-exception.filter'; diff --git a/server/tests/e2e/files.e2e.test.ts b/server/tests/e2e/files.e2e.test.ts index f323012ab..3afb7a2b0 100644 --- a/server/tests/e2e/files.e2e.test.ts +++ b/server/tests/e2e/files.e2e.test.ts @@ -57,8 +57,19 @@ const { photoSvc, helperSvc } = vi.hoisted(() => ({ photoSvc: { streamPhoto: vi.fn(), getPhotoInfo: vi.fn(), resolveTrekPhoto: vi.fn() }, helperSvc: { canAccessTrekPhoto: vi.fn() }, })); -vi.mock('../../src/services/memories/photoResolverService', () => photoSvc); -vi.mock('../../src/services/memories/helpersService', () => helperSvc); +// Both are providers since the fold; PhotosModule resolves them through DI, so +// the stubs go on the prototypes rather than on a module path. +vi.mock('../../src/nest/memories/photo-resolver.service', async (importOriginal) => { + const actual = await importOriginal(); + actual.PhotoResolverService.prototype.streamPhoto = photoSvc.streamPhoto; + actual.PhotoResolverService.prototype.getPhotoInfo = photoSvc.getPhotoInfo; + return actual; +}); +vi.mock('../../src/nest/memories/memories-access.service', async (importOriginal) => { + const actual = await importOriginal(); + actual.MemoriesAccessService.prototype.canAccessTrekPhoto = helperSvc.canAccessTrekPhoto; + return actual; +}); import { DatabaseModule } from '../../src/nest/database/database.module'; import { RealtimeModule } from '../../src/nest/realtime/realtime.module'; diff --git a/server/tests/e2e/journey.e2e.test.ts b/server/tests/e2e/journey.e2e.test.ts index 845b3cd32..2ad32828b 100644 --- a/server/tests/e2e/journey.e2e.test.ts +++ b/server/tests/e2e/journey.e2e.test.ts @@ -30,8 +30,19 @@ const { isAddonEnabled } = vi.hoisted(() => ({ isAddonEnabled: vi.fn(() => true) // now come from the real files.constants; only the request-time app_settings // read is mocked, preserving the old '*'-allowlist semantics for the fixtures. vi.mock('../../src/nest/files/files.bridge', () => ({ getAllowedExtensions: () => '*' })); -vi.mock('../../src/services/memories/immichService', () => ({ uploadToImmich: vi.fn(), streamImmichAsset: vi.fn() })); -vi.mock('../../src/services/memories/photoResolverService', () => ({ streamPhoto: vi.fn() })); +// The memories providers are injected since the fold — stubbed on the prototype +// so JourneyModule still resolves them through DI. +vi.mock('../../src/nest/memories/immich.service', async (importOriginal) => { + const actual = await importOriginal(); + actual.ImmichService.prototype.uploadToImmich = vi.fn(); + actual.ImmichService.prototype.streamImmichAsset = vi.fn(); + return actual; +}); +vi.mock('../../src/nest/memories/photo-resolver.service', async (importOriginal) => { + const actual = await importOriginal(); + actual.PhotoResolverService.prototype.streamPhoto = vi.fn(); + return actual; +}); const { jsvc } = vi.hoisted(() => ({ jsvc: { listJourneys: vi.fn(), createJourney: vi.fn(), getJourneyFull: vi.fn() }, diff --git a/server/tests/e2e/memories.e2e.test.ts b/server/tests/e2e/memories.e2e.test.ts index de2697960..3822789d0 100644 --- a/server/tests/e2e/memories.e2e.test.ts +++ b/server/tests/e2e/memories.e2e.test.ts @@ -52,19 +52,16 @@ const { unified, immich, synology } = vi.hoisted(() => ({ streamSynologyAsset: vi.fn(), }, })); -vi.mock('../../src/services/memories/unifiedService', () => unified); -vi.mock('../../src/services/memories/immichService', () => immich); -vi.mock('../../src/services/memories/synologyService', () => synology); - +// The three provider services and the access check are injected since the fold, +// so they are overridden at the container instead of mocked by module path. const { canAccessUserPhoto } = vi.hoisted(() => ({ canAccessUserPhoto: vi.fn() })); -vi.mock('../../src/services/memories/helpersService', async () => { - const actual = await vi.importActual( - '../../src/services/memories/helpersService', - ); - return { ...actual, canAccessUserPhoto }; -}); +import { DatabaseModule } from '../../src/nest/database/database.module'; import { MemoriesModule } from '../../src/nest/memories/memories.module'; +import { UnifiedMemoriesService } from '../../src/nest/memories/unified-memories.service'; +import { ImmichService } from '../../src/nest/memories/immich.service'; +import { SynologyService } from '../../src/nest/memories/synology.service'; +import { MemoriesAccessService } from '../../src/nest/memories/memories-access.service'; import { RealtimeModule } from '../../src/nest/realtime/realtime.module'; import { TrekExceptionFilter } from '../../src/nest/common/trek-exception.filter'; @@ -78,7 +75,12 @@ describe('Memories e2e (real auth guard + temp SQLite)', () => { let app: Awaited>; async function build() { - const moduleRef = await Test.createTestingModule({ imports: [RealtimeModule, MemoriesModule] }).compile(); + const moduleRef = await Test.createTestingModule({ imports: [DatabaseModule, RealtimeModule, MemoriesModule] }) + .overrideProvider(UnifiedMemoriesService).useValue(unified) + .overrideProvider(ImmichService).useValue(immich) + .overrideProvider(SynologyService).useValue(synology) + .overrideProvider(MemoriesAccessService).useValue({ canAccessUserPhoto }) + .compile(); const nest = moduleRef.createNestApplication(); nest.use(cookieParser()); nest.useGlobalFilters(new TrekExceptionFilter()); diff --git a/server/tests/unit/nest/addons.service.test.ts b/server/tests/unit/nest/addons.service.test.ts index 44bb787e4..5b2ed4ab5 100644 --- a/server/tests/unit/nest/addons.service.test.ts +++ b/server/tests/unit/nest/addons.service.test.ts @@ -11,7 +11,7 @@ import { db as dbConn } from '../../../src/db/database'; import { DatabaseService } from '../../../src/nest/database/database.service'; const { getPhotoProviderConfig } = vi.hoisted(() => ({ getPhotoProviderConfig: vi.fn(() => ({})) })); -vi.mock('../../../src/services/memories/helpersService', () => ({ getPhotoProviderConfig })); +vi.mock('../../../src/nest/memories/memories.helpers', () => ({ getPhotoProviderConfig })); import { AddonsService } from '../../../src/nest/addons/addons.service'; diff --git a/server/tests/unit/nest/immich.service.test.ts b/server/tests/unit/nest/immich.service.test.ts new file mode 100644 index 000000000..5fa94ea9f --- /dev/null +++ b/server/tests/unit/nest/immich.service.test.ts @@ -0,0 +1,433 @@ +/** + * ImmichService — credentials, settings, connection, browsing and the asset + * proxy. + * + * The provider code came out of services/memories/ at ~61% branch coverage, + * because that tree is outside the gate: the integration suites drive it over + * HTTP and never reach most of the failure paths. These cases go at the service + * directly with safeFetch stubbed, so every "upstream said no" branch is pinned. + */ +import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest'; + +const { testDb, dbMock } = vi.hoisted(() => { + const Database = require('better-sqlite3'); + const db = new Database(':memory:'); + db.exec('PRAGMA journal_mode = WAL'); + return { + testDb: db, + dbMock: { db, closeDb: () => {}, reinitialize: () => {}, canAccessTrip: () => null, isOwner: () => false, getPlaceWithTags: () => null }, + }; +}); +vi.mock('../../../src/db/database', () => dbMock); +vi.mock('../../../src/config', () => ({ + JWT_SECRET: 'x'.repeat(40), + ENCRYPTION_KEY: 'a'.repeat(64), + updateJwtSecret: () => {}, +})); + +const { decryptMock } = vi.hoisted(() => ({ decryptMock: vi.fn((v: string) => v) })); +vi.mock('../../../src/nest/common/crypto/apiKeyCrypto', () => ({ + decrypt_api_key: decryptMock, + encrypt_api_key: (v: string) => v, + maybe_encrypt_api_key: (v: string) => v, +})); + +const { safeFetch, checkSsrf } = vi.hoisted(() => ({ safeFetch: vi.fn(), checkSsrf: vi.fn() })); +vi.mock('../../../src/utils/ssrfGuard', () => ({ + safeFetch, + checkSsrf, + createPinnedDispatcher: vi.fn(() => ({})), + SsrfBlockedError: class extends Error {}, +})); + +import { createTables } from '../../../src/db/schema'; +import { runMigrations } from '../../../src/db/migrations'; +import { DatabaseService } from '../../../src/nest/database/database.service'; +import { ImmichService } from '../../../src/nest/memories/immich.service'; +import type { AuditService } from '../../../src/nest/audit/audit.service'; +import type { MemoriesAccessService } from '../../../src/nest/memories/memories-access.service'; + +const audit = { writeAudit: vi.fn() }; +const access = { getAlbumIdFromLink: vi.fn() }; +const dbs = new DatabaseService(testDb); +const svc = new ImmichService(dbs, audit as unknown as AuditService, access as unknown as MemoriesAccessService); + +const USER = 1; + +function seedUser(id: number, url: string | null, key: string | null, autoUpload = 0): void { + testDb.prepare("INSERT OR REPLACE INTO users (id, username, email, password_hash, immich_url, immich_api_key, immich_auto_upload) VALUES (?, ?, ?, 'x', ?, ?, ?)") + .run(id, `u${id}`, `u${id}@example.test`, url, key, autoUpload); +} + +/** A Response-ish object with only what the service reads. */ +function upstream(opts: { ok?: boolean; status?: number; json?: unknown; url?: string; contentType?: string | null; body?: string }) { + return { + ok: opts.ok ?? true, + status: opts.status ?? 200, + url: opts.url ?? '', + headers: { get: (h: string) => (h === 'content-type' ? (opts.contentType ?? 'image/jpeg') : null) }, + json: async () => opts.json ?? {}, + arrayBuffer: async () => Buffer.from(opts.body ?? 'bytes'), + }; +} + +beforeAll(() => { + createTables(testDb); + runMigrations(testDb); +}); + +beforeEach(() => { + vi.clearAllMocks(); + decryptMock.mockImplementation((v: string) => v); + checkSsrf.mockResolvedValue({ allowed: true, isPrivate: false, resolvedIp: '1.2.3.4' }); + testDb.prepare('DELETE FROM users').run(); + seedUser(USER, 'https://immich.test', 'key-1'); +}); + +afterAll(() => testDb.close()); + +describe('getImmichCredentials', () => { + it('IMMICH-001: returns null when the user row is missing', () => { + expect(svc.getImmichCredentials(999)).toBeNull(); + }); + + it('IMMICH-002: returns null without a URL', () => { + seedUser(2, null, 'key'); + expect(svc.getImmichCredentials(2)).toBeNull(); + }); + + it('IMMICH-003: returns null without an API key', () => { + seedUser(3, 'https://immich.test', null); + expect(svc.getImmichCredentials(3)).toBeNull(); + }); + + it('IMMICH-004: returns null when the stored key cannot be decrypted', () => { + decryptMock.mockReturnValue(null); + expect(svc.getImmichCredentials(USER)).toBeNull(); + }); + + it('IMMICH-005: returns the decrypted pair otherwise', () => { + expect(svc.getImmichCredentials(USER)).toEqual({ immich_url: 'https://immich.test', immich_api_key: 'key-1' }); + }); +}); + +describe('isValidAssetId', () => { + it('IMMICH-006: accepts UUID-ish ids and rejects traversal or over-long input', () => { + expect(svc.isValidAssetId('abc-123_XYZ')).toBe(true); + expect(svc.isValidAssetId('../etc/passwd')).toBe(false); + expect(svc.isValidAssetId('a'.repeat(101))).toBe(false); + }); +}); + +describe('getConnectionSettings / setImmichAutoUpload', () => { + it('IMMICH-007: reports connected with the URL when configured', () => { + expect(svc.getConnectionSettings(USER)).toEqual({ immich_url: 'https://immich.test', connected: true, auto_upload: false }); + }); + + it('IMMICH-008: reports an empty URL and not connected when it is not', () => { + seedUser(4, null, null); + expect(svc.getConnectionSettings(4)).toEqual({ immich_url: '', connected: false, auto_upload: false }); + }); + + it('IMMICH-009: surfaces the auto-upload flag both ways', () => { + svc.setImmichAutoUpload(USER, true); + expect(svc.getConnectionSettings(USER).auto_upload).toBe(true); + svc.setImmichAutoUpload(USER, false); + expect(svc.getConnectionSettings(USER).auto_upload).toBe(false); + }); +}); + +describe('saveImmichSettings', () => { + it('IMMICH-010: refuses a URL the SSRF guard blocks, and writes nothing', async () => { + checkSsrf.mockResolvedValue({ allowed: false, error: 'blocked host' }); + + const result = await svc.saveImmichSettings(USER, 'http://169.254.169.254', 'k', null); + + expect(result).toEqual({ success: false, error: 'Invalid Immich URL: blocked host' }); + expect(svc.getImmichCredentials(USER)!.immich_url).toBe('https://immich.test'); + }); + + it('IMMICH-011: stores a trimmed URL and reports plain success', async () => { + const result = await svc.saveImmichSettings(USER, ' https://new.test ', 'k2', null); + + expect(result).toEqual({ success: true }); + expect(svc.getImmichCredentials(USER)).toEqual({ immich_url: 'https://new.test', immich_api_key: 'k2' }); + }); + + it('IMMICH-012: warns and audits when the URL resolves to a private IP', async () => { + checkSsrf.mockResolvedValue({ allowed: true, isPrivate: true, resolvedIp: '192.168.1.9' }); + + const result = await svc.saveImmichSettings(USER, 'http://nas.local', 'k', '5.6.7.8'); + + expect(result.success).toBe(true); + expect(result.warning).toContain('192.168.1.9'); + expect(audit.writeAudit).toHaveBeenCalledWith(expect.objectContaining({ action: 'immich.private_ip_configured', ip: '5.6.7.8' })); + }); + + it('IMMICH-013: an empty URL clears the connection without an SSRF check', async () => { + const result = await svc.saveImmichSettings(USER, undefined, undefined, null); + + expect(result).toEqual({ success: true }); + expect(checkSsrf).not.toHaveBeenCalled(); + expect(svc.getImmichCredentials(USER)).toBeNull(); + }); +}); + +describe('testConnection', () => { + it('IMMICH-014: refuses a blocked URL before any request', async () => { + checkSsrf.mockResolvedValue({ allowed: false, error: 'nope' }); + expect(await svc.testConnection('http://x', 'k')).toEqual({ connected: false, error: 'nope' }); + expect(safeFetch).not.toHaveBeenCalled(); + }); + + it('IMMICH-015: falls back to a generic message when the guard gives no reason', async () => { + checkSsrf.mockResolvedValue({ allowed: false }); + expect(await svc.testConnection('http://x', 'k')).toEqual({ connected: false, error: 'Invalid Immich URL' }); + }); + + it('IMMICH-016: reports the HTTP status when the server rejects the key', async () => { + safeFetch.mockResolvedValue(upstream({ ok: false, status: 401 })); + expect(await svc.testConnection('https://immich.test', 'bad')).toEqual({ connected: false, error: 'HTTP 401' }); + }); + + it('IMMICH-017: returns the user on success', async () => { + safeFetch.mockResolvedValue(upstream({ json: { name: 'Ada', email: 'ada@example.test' } })); + const result = await svc.testConnection('https://immich.test', 'k'); + expect(result.connected).toBe(true); + expect(result.user).toEqual({ name: 'Ada', email: 'ada@example.test' }); + }); + + it('IMMICH-018: reports the canonical URL after an http → https upgrade', async () => { + safeFetch.mockResolvedValue(upstream({ json: {}, url: 'https://immich.test/api/users/me' })); + const result = await svc.testConnection('http://immich.test', 'k'); + expect(result.canonicalUrl).toBe('https://immich.test'); + }); + + it('IMMICH-019: does NOT rewrite the URL when the host changed — that is a redirect elsewhere', async () => { + safeFetch.mockResolvedValue(upstream({ json: {}, url: 'https://evil.test/api/users/me' })); + const result = await svc.testConnection('http://immich.test', 'k'); + expect(result.canonicalUrl).toBeUndefined(); + }); + + it('IMMICH-020: reports the thrown message when the request fails', async () => { + safeFetch.mockRejectedValue(new Error('ECONNREFUSED')); + expect(await svc.testConnection('https://immich.test', 'k')).toEqual({ connected: false, error: 'ECONNREFUSED' }); + }); + + it('IMMICH-021: falls back to "Connection failed" for a non-Error rejection', async () => { + safeFetch.mockRejectedValue('boom'); + expect(await svc.testConnection('https://immich.test', 'k')).toEqual({ connected: false, error: 'Connection failed' }); + }); +}); + +describe('getConnectionStatus', () => { + it('IMMICH-022: says "Not configured" without credentials', async () => { + seedUser(5, null, null); + expect(await svc.getConnectionStatus(5)).toEqual({ connected: false, error: 'Not configured' }); + }); + + it('IMMICH-023: reports the HTTP status, the user, and a thrown failure', async () => { + safeFetch.mockResolvedValueOnce(upstream({ ok: false, status: 500 })); + expect(await svc.getConnectionStatus(USER)).toEqual({ connected: false, error: 'HTTP 500' }); + + safeFetch.mockResolvedValueOnce(upstream({ json: { name: 'Ada' } })); + expect((await svc.getConnectionStatus(USER)).user).toEqual({ name: 'Ada', email: undefined }); + + safeFetch.mockRejectedValueOnce(new Error('timeout')); + expect(await svc.getConnectionStatus(USER)).toEqual({ connected: false, error: 'timeout' }); + }); +}); + +describe('browseTimeline', () => { + it('IMMICH-024: 400s without credentials', async () => { + seedUser(6, null, null); + expect(await svc.browseTimeline(6)).toEqual({ error: 'Immich not configured', status: 400 }); + }); + + it('IMMICH-025: forwards the upstream status on failure and the buckets on success', async () => { + safeFetch.mockResolvedValueOnce(upstream({ ok: false, status: 503 })); + expect(await svc.browseTimeline(USER)).toEqual({ error: 'Failed to fetch from Immich', status: 503 }); + + safeFetch.mockResolvedValueOnce(upstream({ json: [{ count: 3 }] })); + expect(await svc.browseTimeline(USER)).toEqual({ buckets: [{ count: 3 }] }); + }); + + it('IMMICH-026: maps an unreachable server to 502', async () => { + safeFetch.mockRejectedValue(new Error('down')); + expect(await svc.browseTimeline(USER)).toEqual({ error: 'Could not reach Immich', status: 502 }); + }); +}); + +describe('searchPhotos', () => { + it('IMMICH-027: 400s without credentials and 502s when the server is unreachable', async () => { + seedUser(7, null, null); + expect(await svc.searchPhotos(7)).toEqual({ error: 'Immich not configured', status: 400 }); + + safeFetch.mockRejectedValue(new Error('down')); + expect(await svc.searchPhotos(USER)).toEqual({ error: 'Could not reach Immich', status: 502 }); + }); + + it('IMMICH-028: forwards the upstream status on a failed search', async () => { + safeFetch.mockResolvedValue(upstream({ ok: false, status: 400 })); + expect(await svc.searchPhotos(USER)).toEqual({ error: 'Search failed', status: 400 }); + }); + + it('IMMICH-029: drops hidden assets an older server still returns (#1474)', async () => { + safeFetch.mockResolvedValue(upstream({ + json: { assets: { items: [ + { id: 'ok', fileCreatedAt: '2026-01-01' }, + { id: 'hidden-flag', visibility: 'hidden' }, + { id: 'legacy-flag', isVisible: false }, + ] } }, + })); + + const result = await svc.searchPhotos(USER); + + expect(result.assets!.map(a => a.id)).toEqual(['ok']); + }); + + it('IMMICH-030: maps exif fields, falls back to createdAt, and marks videos', async () => { + safeFetch.mockResolvedValue(upstream({ + json: { assets: { items: [ + { id: 'a', createdAt: '2026-02-02', type: 'VIDEO', exifInfo: { city: 'Kyoto', country: 'JP', latitude: 35.0, longitude: 135.7 } }, + { id: 'b', fileCreatedAt: '2026-03-03', type: 'IMAGE', exifInfo: { latitude: 'nope' } }, + ] } }, + })); + + const [a, b] = (await svc.searchPhotos(USER)).assets!; + + expect(a).toMatchObject({ takenAt: '2026-02-02', city: 'Kyoto', country: 'JP', lat: 35.0, lng: 135.7, mediaType: 'video' }); + // A non-numeric coordinate becomes null rather than reaching the client as a string. + expect(b).toMatchObject({ takenAt: '2026-03-03', city: null, country: null, lat: null, lng: null, mediaType: 'image' }); + }); + + it('IMMICH-031: hasMore counts the raw page, not the filtered one', async () => { + // Otherwise a page that is entirely hidden assets would stop pagination dead. + safeFetch.mockResolvedValue(upstream({ + json: { assets: { items: [{ id: 'x', visibility: 'hidden' }, { id: 'y', visibility: 'hidden' }] } }, + })); + + const result = await svc.searchPhotos(USER, undefined, undefined, 1, 2); + + expect(result.assets).toEqual([]); + expect(result.hasMore).toBe(true); + }); + + it('IMMICH-032: turns a from/to pair into the upstream date bounds', async () => { + safeFetch.mockResolvedValue(upstream({ json: { assets: { items: [] } } })); + + await svc.searchPhotos(USER, '2026-01-01', '2026-01-31'); + + const body = JSON.parse((safeFetch.mock.calls[0][1] as { body: string }).body); + expect(body.takenAfter).toBe('2026-01-01T00:00:00.000Z'); + expect(body.takenBefore).toBe('2026-01-31T23:59:59.999Z'); + // Load-bearing on Immich >= 1.133: hidden assets must not cross the wire. + expect(body.visibility).toBe('timeline'); + }); +}); + +describe('getAssetInfo', () => { + it('IMMICH-033: 404s when the owner has no credentials', async () => { + seedUser(8, null, null); + expect(await svc.getAssetInfo(USER, 'a1', 8)).toEqual({ error: 'Not found', status: 404 }); + }); + + it('IMMICH-034: reads the OWNER\'s credentials, not the requester\'s', async () => { + seedUser(9, 'https://owner.test', 'owner-key'); + safeFetch.mockResolvedValue(upstream({ json: { id: 'a1' } })); + + await svc.getAssetInfo(USER, 'a1', 9); + + expect(safeFetch.mock.calls[0][0]).toContain('https://owner.test'); + expect((safeFetch.mock.calls[0][1] as { headers: Record }).headers['x-api-key']).toBe('owner-key'); + }); + + it('IMMICH-035: forwards the upstream status and maps a proxy failure to 502', async () => { + safeFetch.mockResolvedValueOnce(upstream({ ok: false, status: 404 })); + expect(await svc.getAssetInfo(USER, 'a1')).toEqual({ error: 'Failed', status: 404 }); + + safeFetch.mockRejectedValueOnce(new Error('down')); + expect(await svc.getAssetInfo(USER, 'a1')).toEqual({ error: 'Proxy error', status: 502 }); + }); + + it('IMMICH-036: composes camera/focal/aperture only when both halves are present', async () => { + safeFetch.mockResolvedValueOnce(upstream({ + json: { id: 'a1', fileCreatedAt: 'x', originalFileName: 'IMG.jpg', exifInfo: { make: 'Fuji', model: 'X100V', focalLength: 23, fNumber: 2, iso: 200 } }, + })); + const full = (await svc.getAssetInfo(USER, 'a1')).data; + expect(full).toMatchObject({ camera: 'Fuji X100V', focalLength: '23mm', aperture: 'f/2', iso: 200, fileName: 'IMG.jpg' }); + + safeFetch.mockResolvedValueOnce(upstream({ json: { id: 'a2', exifInfo: { make: 'Fuji' } } })); + const partial = (await svc.getAssetInfo(USER, 'a2')).data; + expect(partial.camera).toBeNull(); + expect(partial.focalLength).toBeNull(); + }); + + it('IMMICH-037: survives an asset with no exif block at all', async () => { + safeFetch.mockResolvedValue(upstream({ json: { id: 'bare' } })); + const data = (await svc.getAssetInfo(USER, 'bare')).data; + expect(data).toMatchObject({ id: 'bare', width: null, height: null, city: null, fileName: null }); + }); +}); + +describe('fetchImmichThumbnailBytes', () => { + it('IMMICH-038: 404s without credentials for the owner', async () => { + seedUser(10, null, null); + expect(await svc.fetchImmichThumbnailBytes(USER, 'a1', 10)).toEqual({ error: 'Not found', status: 404 }); + }); + + it('IMMICH-039: returns the bytes with the upstream content type', async () => { + safeFetch.mockResolvedValue(upstream({ contentType: 'image/webp', body: 'thumb' })); + const result = await svc.fetchImmichThumbnailBytes(USER, 'a1'); + expect(result).toEqual({ bytes: Buffer.from('thumb'), contentType: 'image/webp' }); + }); + + it('IMMICH-040: defaults the content type when the server omits it', async () => { + safeFetch.mockResolvedValue(upstream({ contentType: null, body: 'thumb' })); + const result = await svc.fetchImmichThumbnailBytes(USER, 'a1') as { contentType: string }; + expect(result.contentType).toBe('image/jpeg'); + }); + + it('IMMICH-041: forwards the upstream status and maps a throw to 502', async () => { + safeFetch.mockResolvedValueOnce(upstream({ ok: false, status: 403 })); + expect(await svc.fetchImmichThumbnailBytes(USER, 'a1')).toEqual({ error: 'Upstream error', status: 403 }); + + safeFetch.mockRejectedValueOnce(new Error('down')); + expect(await svc.fetchImmichThumbnailBytes(USER, 'a1')).toEqual({ error: 'Proxy error', status: 502 }); + }); +}); + +describe('collectAlbumSelection', () => { + it('IMMICH-042: 404s when the album link does not resolve', async () => { + access.getAlbumIdFromLink.mockReturnValue({ success: false }); + expect(await svc.collectAlbumSelection('1', 'l1', USER)).toEqual({ error: 'Album link not found', status: 404 }); + }); + + it('IMMICH-043: 400s when the user has no Immich credentials', async () => { + access.getAlbumIdFromLink.mockReturnValue({ success: true, data: 'album-1' }); + seedUser(11, null, null); + expect(await svc.collectAlbumSelection('1', 'l1', 11)).toEqual({ error: 'Immich not configured', status: 400 }); + }); + + it('IMMICH-044: returns only visible image assets, with the raw total', async () => { + access.getAlbumIdFromLink.mockReturnValue({ success: true, data: 'album-1' }); + safeFetch.mockResolvedValue(upstream({ + json: { assets: [ + { id: 'img', type: 'IMAGE' }, + { id: 'vid', type: 'VIDEO' }, + { id: 'hidden', type: 'IMAGE', visibility: 'hidden' }, + ] }, + })); + + const result = await svc.collectAlbumSelection('1', 'l1', USER) as { selection: { asset_ids: string[] }; total: number }; + + expect(result.selection.asset_ids).toEqual(['img']); + expect(result.total).toBe(1); + }); + + it('IMMICH-045: maps an unreachable server to 502', async () => { + access.getAlbumIdFromLink.mockReturnValue({ success: true, data: 'album-1' }); + safeFetch.mockRejectedValue(new Error('down')); + expect(await svc.collectAlbumSelection('1', 'l1', USER)).toEqual({ error: 'Could not reach Immich', status: 502 }); + }); +}); diff --git a/server/tests/unit/services/memoriesHelpers.test.ts b/server/tests/unit/nest/memories-access.service.test.ts similarity index 50% rename from server/tests/unit/services/memoriesHelpers.test.ts rename to server/tests/unit/nest/memories-access.service.test.ts index 128337f01..ecb42e5b2 100644 --- a/server/tests/unit/services/memoriesHelpers.test.ts +++ b/server/tests/unit/nest/memories-access.service.test.ts @@ -1,5 +1,7 @@ /** - * Unit tests for memories/helpersService — MEM-HELPERS-001 to MEM-HELPERS-020. + * Unit tests for the memories helpers and access checks — MEM-HELPERS-001 to 020. + * Moved with the fold: the pure half is nest/memories/memories.helpers.ts, the + * DB-backed half is MemoriesAccessService. * Covers mapDbError, getAlbumIdFromLink, pipeAsset error paths. */ import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest'; @@ -55,7 +57,12 @@ import { createTables } from '../../../src/db/schema'; import { runMigrations } from '../../../src/db/migrations'; import { resetTestDb } from '../../helpers/test-db'; import { createUser, createTrip } from '../../helpers/factories'; -import { mapDbError, getAlbumIdFromLink, pipeAsset } from '../../../src/services/memories/helpersService'; +import { mapDbError, pipeAsset } from '../../../src/nest/memories/memories.helpers'; +import { MemoriesAccessService } from '../../../src/nest/memories/memories-access.service'; +import { DatabaseService } from '../../../src/nest/database/database.service'; + +const access = new MemoriesAccessService(new DatabaseService(testDb)); +const getAlbumIdFromLink = access.getAlbumIdFromLink.bind(access); import { SsrfBlockedError } from '../../../src/utils/ssrfGuard'; beforeAll(() => { @@ -274,3 +281,175 @@ describe('pipeAsset fetch options (#1611)', () => { errorSpy.mockRestore(); }); }); + +// --------------------------------------------------------------------------- +// The access checks +// +// These decide who may fetch someone else's provider photo, and they had no +// cases at all — they sat outside the measured tree. A provider asset is +// reachable through a trip *or* a journey, which is why neither check can live +// in the trips or the journey domain alone, and why both need pinning here. +// --------------------------------------------------------------------------- + +/** A trek_photos row plus the trip_photos link that shares it. */ +function shareInTrip(tripId: number, ownerId: number, assetId: string, provider = 'immich', shared = 1): number { + const photoId = Number(testDb.prepare( + 'INSERT INTO trek_photos (provider, asset_id, owner_id) VALUES (?, ?, ?)' + ).run(provider, assetId, ownerId).lastInsertRowid); + testDb.prepare('INSERT INTO trip_photos (trip_id, photo_id, user_id, shared) VALUES (?, ?, ?, ?)') + .run(tripId, photoId, ownerId, shared); + return photoId; +} + +function makeJourney(userId: number): number { + return Number(testDb.prepare( + "INSERT INTO journeys (user_id, title, status, created_at, updated_at) VALUES (?, 'J', 'draft', 0, 0)" + ).run(userId).lastInsertRowid); +} + +describe('canAccessUserPhoto', () => { + it('MEM-ACCESS-001: the owner always passes, with no lookup needed', () => { + expect(access.canAccessUserPhoto(7, 7, '1', 'asset-1', 'immich')).toBe(true); + }); + + it('MEM-ACCESS-002: a trip member sees an asset shared into that trip', () => { + const { user: owner } = createUser(testDb); + const { user: member } = createUser(testDb, { username: 'member' }); + const trip = createTrip(testDb, owner.id); + testDb.prepare('INSERT INTO trip_members (trip_id, user_id) VALUES (?, ?)').run(trip.id, member.id); + shareInTrip(trip.id, owner.id, 'asset-shared'); + + expect(access.canAccessUserPhoto(member.id, owner.id, String(trip.id), 'asset-shared', 'immich')).toBe(true); + }); + + it('MEM-ACCESS-003: an unshared asset stays private even inside the same trip', () => { + const { user: owner } = createUser(testDb); + const { user: member } = createUser(testDb, { username: 'member' }); + const trip = createTrip(testDb, owner.id); + testDb.prepare('INSERT INTO trip_members (trip_id, user_id) VALUES (?, ?)').run(trip.id, member.id); + shareInTrip(trip.id, owner.id, 'asset-private', 'immich', 0); + + expect(access.canAccessUserPhoto(member.id, owner.id, String(trip.id), 'asset-private', 'immich')).toBe(false); + }); + + it('MEM-ACCESS-004: a stranger is refused even for a shared asset', () => { + const { user: owner } = createUser(testDb); + const { user: stranger } = createUser(testDb, { username: 'stranger' }); + const trip = createTrip(testDb, owner.id); + shareInTrip(trip.id, owner.id, 'asset-shared'); + + expect(access.canAccessUserPhoto(stranger.id, owner.id, String(trip.id), 'asset-shared', 'immich')).toBe(false); + }); + + it('MEM-ACCESS-005: the provider is part of the match — same asset id, other provider, no access', () => { + const { user: owner } = createUser(testDb); + const { user: member } = createUser(testDb, { username: 'member' }); + const trip = createTrip(testDb, owner.id); + testDb.prepare('INSERT INTO trip_members (trip_id, user_id) VALUES (?, ?)').run(trip.id, member.id); + shareInTrip(trip.id, owner.id, 'same-id', 'immich'); + + expect(access.canAccessUserPhoto(member.id, owner.id, String(trip.id), 'same-id', 'synologyphotos')).toBe(false); + }); + + it('MEM-ACCESS-006: tripId "0" routes through journeys — a contributor passes', () => { + const { user: owner } = createUser(testDb); + const { user: contributor } = createUser(testDb, { username: 'contrib' }); + const journeyId = makeJourney(owner.id); + const photoId = Number(testDb.prepare( + "INSERT INTO trek_photos (provider, asset_id, owner_id) VALUES ('immich', 'j-asset', ?)" + ).run(owner.id).lastInsertRowid); + testDb.prepare('INSERT INTO journey_photos (journey_id, photo_id, created_at) VALUES (?, ?, 0)').run(journeyId, photoId); + testDb.prepare("INSERT INTO journey_contributors (journey_id, user_id, role, added_at) VALUES (?, ?, 'editor', 0)").run(journeyId, contributor.id); + + expect(access.canAccessUserPhoto(contributor.id, owner.id, '0', 'j-asset', 'immich')).toBe(true); + }); + + it('MEM-ACCESS-007: tripId "0" refuses someone with no journey link', () => { + const { user: owner } = createUser(testDb); + const { user: stranger } = createUser(testDb, { username: 'stranger' }); + const journeyId = makeJourney(owner.id); + const photoId = Number(testDb.prepare( + "INSERT INTO trek_photos (provider, asset_id, owner_id) VALUES ('immich', 'j-asset-2', ?)" + ).run(owner.id).lastInsertRowid); + testDb.prepare('INSERT INTO journey_photos (journey_id, photo_id, created_at) VALUES (?, ?, 0)').run(journeyId, photoId); + + expect(access.canAccessUserPhoto(stranger.id, owner.id, '0', 'j-asset-2', 'immich')).toBe(false); + }); + + it('MEM-ACCESS-008: tripId "0" refuses an asset that is in no journey at all', () => { + const { user: owner } = createUser(testDb); + const { user: other } = createUser(testDb, { username: 'other' }); + expect(access.canAccessUserPhoto(other.id, owner.id, '0', 'not-in-any-journey', 'immich')).toBe(false); + }); +}); + +describe('canAccessTrekPhoto', () => { + it('MEM-ACCESS-010: an unknown photo id is refused, not treated as public', () => { + expect(access.canAccessTrekPhoto(1, 999999)).toBe(false); + }); + + it('MEM-ACCESS-011: the owner passes', () => { + const { user } = createUser(testDb); + const photoId = Number(testDb.prepare( + "INSERT INTO trek_photos (provider, asset_id, owner_id) VALUES ('immich', 'own', ?)" + ).run(user.id).lastInsertRowid); + + expect(access.canAccessTrekPhoto(user.id, photoId)).toBe(true); + }); + + it('MEM-ACCESS-012: a trip member passes for a shared photo, a stranger does not', () => { + const { user: owner } = createUser(testDb); + const { user: member } = createUser(testDb, { username: 'member' }); + const { user: stranger } = createUser(testDb, { username: 'stranger' }); + const trip = createTrip(testDb, owner.id); + testDb.prepare('INSERT INTO trip_members (trip_id, user_id) VALUES (?, ?)').run(trip.id, member.id); + const photoId = shareInTrip(trip.id, owner.id, 'trek-shared'); + + expect(access.canAccessTrekPhoto(member.id, photoId)).toBe(true); + expect(access.canAccessTrekPhoto(stranger.id, photoId)).toBe(false); + }); + + it('MEM-ACCESS-013: the trip owner passes without a trip_members row', () => { + const { user: owner } = createUser(testDb); + const { user: uploader } = createUser(testDb, { username: 'uploader' }); + const trip = createTrip(testDb, owner.id); + const photoId = shareInTrip(trip.id, uploader.id, 'owner-path'); + + expect(access.canAccessTrekPhoto(owner.id, photoId)).toBe(true); + }); + + it('MEM-ACCESS-014: a journey contributor passes', () => { + const { user: owner } = createUser(testDb); + const { user: contributor } = createUser(testDb, { username: 'contrib' }); + const journeyId = makeJourney(owner.id); + const photoId = Number(testDb.prepare( + "INSERT INTO trek_photos (provider, asset_id, owner_id) VALUES ('immich', 'j-trek', ?)" + ).run(owner.id).lastInsertRowid); + testDb.prepare('INSERT INTO journey_photos (journey_id, photo_id, created_at) VALUES (?, ?, 0)').run(journeyId, photoId); + testDb.prepare("INSERT INTO journey_contributors (journey_id, user_id, role, added_at) VALUES (?, ?, 'editor', 0)").run(journeyId, contributor.id); + + expect(access.canAccessTrekPhoto(contributor.id, photoId)).toBe(true); + }); + + it('MEM-ACCESS-015: an ownerless local upload is reachable only through its journey', () => { + const { user: owner } = createUser(testDb); + const { user: outsider } = createUser(testDb, { username: 'outsider' }); + const journeyId = makeJourney(owner.id); + const photoId = Number(testDb.prepare( + "INSERT INTO trek_photos (provider, file_path) VALUES ('local', 'journey/x.jpg')" + ).run().lastInsertRowid); + testDb.prepare('INSERT INTO journey_photos (journey_id, photo_id, created_at) VALUES (?, ?, 0)').run(journeyId, photoId); + + expect(access.canAccessTrekPhoto(owner.id, photoId)).toBe(true); + expect(access.canAccessTrekPhoto(outsider.id, photoId)).toBe(false); + }); + + it('MEM-ACCESS-016: an ownerless local upload in no journey is reachable by nobody', () => { + const { user } = createUser(testDb); + const photoId = Number(testDb.prepare( + "INSERT INTO trek_photos (provider, file_path) VALUES ('local', 'orphan.jpg')" + ).run().lastInsertRowid); + + expect(access.canAccessTrekPhoto(user.id, photoId)).toBe(false); + }); +}); diff --git a/server/tests/unit/nest/memories.service.test.ts b/server/tests/unit/nest/memories.service.test.ts index ad8f3b1c2..a8201c438 100644 --- a/server/tests/unit/nest/memories.service.test.ts +++ b/server/tests/unit/nest/memories.service.test.ts @@ -15,7 +15,6 @@ const unified = vi.hoisted(() => ({ removeTripPhoto: vi.fn(() => ({ data: {} })), setTripPhotoSharing: vi.fn(async () => ({ data: {} })), })); -vi.mock('../../../src/services/memories/unifiedService', () => unified); const immich = vi.hoisted(() => ({ getConnectionSettings: vi.fn(() => ({})), @@ -32,7 +31,6 @@ const immich = vi.hoisted(() => ({ getAssetInfo: vi.fn(async () => ({ data: {} })), isValidAssetId: vi.fn(() => true), })); -vi.mock('../../../src/services/memories/immichService', () => immich); const synology = vi.hoisted(() => ({ getSynologySettings: vi.fn(async () => ({ success: true, data: {} })), @@ -46,16 +44,18 @@ const synology = vi.hoisted(() => ({ getSynologyAssetInfo: vi.fn(async () => ({ success: true, data: {} })), streamSynologyAsset: vi.fn(async () => undefined), })); -vi.mock('../../../src/services/memories/synologyService', () => synology); const helpers = vi.hoisted(() => ({ canAccessUserPhoto: vi.fn(() => true) })); -vi.mock('../../../src/services/memories/helpersService', () => helpers); const ws = vi.hoisted(() => ({ broadcast: vi.fn() })); vi.mock('../../../src/websocket', () => ws); import { MemoriesService } from '../../../src/nest/memories/memories.service'; import { RealtimeService } from '../../../src/nest/realtime/realtime.service'; +import type { UnifiedMemoriesService } from '../../../src/nest/memories/unified-memories.service'; +import type { ImmichService } from '../../../src/nest/memories/immich.service'; +import type { SynologyService } from '../../../src/nest/memories/synology.service'; +import type { MemoriesAccessService } from '../../../src/nest/memories/memories-access.service'; const res = {} as import('express').Response; @@ -64,7 +64,13 @@ describe('MemoriesService (delegation wrapper over services/memories/*)', () => beforeEach(() => { vi.clearAllMocks(); - svc = new MemoriesService(new RealtimeService()); + svc = new MemoriesService( + new RealtimeService(), + unified as unknown as UnifiedMemoriesService, + immich as unknown as ImmichService, + synology as unknown as SynologyService, + helpers as unknown as MemoriesAccessService, + ); }); it('access check + broadcast forward verbatim', () => { diff --git a/server/tests/unit/nest/photo-resolver.service.test.ts b/server/tests/unit/nest/photo-resolver.service.test.ts new file mode 100644 index 000000000..6f4b15c78 --- /dev/null +++ b/server/tests/unit/nest/photo-resolver.service.test.ts @@ -0,0 +1,230 @@ +/** + * PhotoResolverService — the provider dispatch half of the old + * photoResolverService. + * + * It routes a stored trek_photo to bytes: local file, generated thumbnail, or + * the provider that owns the asset. None of this was measured before the fold + * (services/memories/ sits outside the coverage gate), so the branch that + * decides a poster-less video must 404 rather than stream the whole file as a + * "thumbnail" had no case at all. + */ +import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest'; + +const { testDb, dbMock } = vi.hoisted(() => { + const Database = require('better-sqlite3'); + const db = new Database(':memory:'); + db.exec('PRAGMA journal_mode = WAL'); + return { + testDb: db, + dbMock: { db, closeDb: () => {}, reinitialize: () => {}, canAccessTrip: () => null, isOwner: () => false, getPlaceWithTags: () => null }, + }; +}); +vi.mock('../../../src/db/database', () => dbMock); +vi.mock('../../../src/config', () => ({ + JWT_SECRET: 'x'.repeat(40), + ENCRYPTION_KEY: 'a'.repeat(64), + updateJwtSecret: () => {}, +})); +vi.mock('../../../src/nest/common/crypto/apiKeyCrypto', () => ({ + decrypt_api_key: (v: string) => v, + encrypt_api_key: (v: string) => v, + maybe_encrypt_api_key: (v: string) => v, +})); + +import { createTables } from '../../../src/db/schema'; +import { runMigrations } from '../../../src/db/migrations'; +import { DatabaseService } from '../../../src/nest/database/database.service'; +import { TrekPhotosRepository } from '../../../src/nest/photos/trek-photos.repository'; +import { PhotoResolverService } from '../../../src/nest/memories/photo-resolver.service'; +import type { ImmichService } from '../../../src/nest/memories/immich.service'; +import type { SynologyService } from '../../../src/nest/memories/synology.service'; +import type { ThumbnailService } from '../../../src/nest/memories/thumbnail.service'; +import type { TrekPhotoCacheService } from '../../../src/nest/memories/trek-photo-cache.service'; + +const immich = { + streamImmichAsset: vi.fn(), + fetchImmichThumbnailBytes: vi.fn(), + getAssetInfo: vi.fn(), +}; +const synology = { + streamSynologyAsset: vi.fn(), + fetchSynologyThumbnailBytes: vi.fn(), + getSynologyAssetInfo: vi.fn(), +}; +const thumbnails = { ensureLocalThumbnail: vi.fn() }; +// The cache has its own suite; here it is a switch for hit/miss/in-flight. +const cache = { + cacheKey: vi.fn(() => 'k'), + serveFresh: vi.fn(() => false), + getInFlight: vi.fn(() => undefined), + setInFlight: vi.fn(), + put: vi.fn(), +}; + +const dbs = new DatabaseService(testDb); +const repo = new TrekPhotosRepository(dbs); +const svc = new PhotoResolverService( + repo, + immich as unknown as ImmichService, + synology as unknown as SynologyService, + thumbnails as unknown as ThumbnailService, + cache as unknown as TrekPhotoCacheService, +); + +function makeRes() { + return { status: vi.fn().mockReturnThis(), json: vi.fn(), set: vi.fn(), sendFile: vi.fn(), end: vi.fn() }; +} + +/** Insert a trek_photos row directly so each case controls the exact shape. */ +function insertPhoto(cols: Record): number { + const keys = Object.keys(cols); + const res = testDb.prepare( + `INSERT INTO trek_photos (${keys.join(', ')}) VALUES (${keys.map(() => '?').join(', ')})` + ).run(...keys.map(k => cols[k] as never)); + return Number(res.lastInsertRowid); +} + +beforeAll(() => { + createTables(testDb); + runMigrations(testDb); +}); + +beforeEach(() => { + vi.clearAllMocks(); + // owner_id carries a FK to users; the cases pick fixed ids, so seed them. + testDb.prepare('DELETE FROM trek_photos').run(); + for (const id of [2, 3, 4, 5, 9]) { + testDb.prepare("INSERT OR IGNORE INTO users (id, username, email, password_hash) VALUES (?, ?, ?, 'x')") + .run(id, `u${id}`, `u${id}@example.test`); + } + cache.serveFresh.mockReturnValue(false); + cache.getInFlight.mockReturnValue(undefined); + cache.cacheKey.mockReturnValue('k'); +}); + +afterAll(() => testDb.close()); + +describe('streamPhoto — dispatch', () => { + it('RESOLVE-001: 404s a photo id that does not exist', async () => { + const res = makeRes(); + await svc.streamPhoto(res as never, 1, 999999, 'original'); + expect(res.status).toHaveBeenCalledWith(404); + expect(res.json).toHaveBeenCalledWith({ error: 'Photo not found' }); + }); + + it('RESOLVE-002: a local row whose file is gone answers "File not found", not a provider call', async () => { + const id = insertPhoto({ provider: 'local', file_path: 'nope/missing.jpg' }); + const res = makeRes(); + + await svc.streamPhoto(res as never, 1, id, 'original'); + + expect(res.status).toHaveBeenCalledWith(404); + expect(res.json).toHaveBeenCalledWith({ error: 'File not found' }); + expect(immich.streamImmichAsset).not.toHaveBeenCalled(); + expect(synology.streamSynologyAsset).not.toHaveBeenCalled(); + }); + + it('RESOLVE-003: a poster-less video 404s instead of streaming the whole file as a thumbnail', async () => { + // #823: falling through here would push a full video down a thumbnail request. + const id = insertPhoto({ provider: 'local', file_path: 'nope/clip.mp4', media_type: 'video' }); + const res = makeRes(); + + await svc.streamPhoto(res as never, 1, id, 'thumbnail'); + + expect(res.status).toHaveBeenCalledWith(404); + expect(res.json).toHaveBeenCalledWith({ error: 'No poster available' }); + expect(thumbnails.ensureLocalThumbnail).not.toHaveBeenCalled(); + }); + + it('RESOLVE-004: a local image with no thumbnail asks for one to be generated', async () => { + const id = insertPhoto({ provider: 'local', file_path: 'nope/photo.jpg' }); + thumbnails.ensureLocalThumbnail.mockResolvedValue(null); + const res = makeRes(); + + await svc.streamPhoto(res as never, 1, id, 'thumbnail'); + + expect(thumbnails.ensureLocalThumbnail).toHaveBeenCalledOnce(); + }); + + it('RESOLVE-005: a generated thumbnail is recorded on the row', async () => { + const id = insertPhoto({ provider: 'local', file_path: 'nope/photo.jpg' }); + thumbnails.ensureLocalThumbnail.mockResolvedValue({ thumbnailRelPath: 'journey/thumbs/abc.jpg', width: 800, height: 600 }); + const res = makeRes(); + + await svc.streamPhoto(res as never, 1, id, 'thumbnail'); + + const row = testDb.prepare('SELECT thumbnail_path, width, height FROM trek_photos WHERE id = ?').get(id) as { thumbnail_path: string; width: number; height: number }; + expect(row).toEqual({ thumbnail_path: 'journey/thumbs/abc.jpg', width: 800, height: 600 }); + }); + + it('RESOLVE-006: an immich original goes straight to the provider with the range header', async () => { + const id = insertPhoto({ provider: 'immich', asset_id: 'a1', owner_id: 5 }); + const res = makeRes(); + + await svc.streamPhoto(res as never, 3, id, 'original', 'bytes=0-99'); + + expect(immich.streamImmichAsset).toHaveBeenCalledWith(res, 3, 'a1', 'original', 5, expect.objectContaining({ range: 'bytes=0-99' })); + }); + + it('RESOLVE-007: an immich thumbnail served from cache never reaches the provider', async () => { + const id = insertPhoto({ provider: 'immich', asset_id: 'a1', owner_id: 5 }); + cache.serveFresh.mockReturnValue(true); + const res = makeRes(); + + await svc.streamPhoto(res as never, 3, id, 'thumbnail'); + + expect(immich.fetchImmichThumbnailBytes).not.toHaveBeenCalled(); + expect(immich.streamImmichAsset).not.toHaveBeenCalled(); + }); + + it('RESOLVE-008: a synology original forwards the decrypted passphrase', async () => { + const id = insertPhoto({ provider: 'synologyphotos', asset_id: 's1', owner_id: 9, passphrase: 'secret' }); + const res = makeRes(); + + await svc.streamPhoto(res as never, 4, id, 'original'); + + expect(synology.streamSynologyAsset).toHaveBeenCalledWith(res, 4, 9, 's1', 'original', undefined, 'secret'); + }); + + it('RESOLVE-009: a synology row without a passphrase forwards undefined, not an empty string', async () => { + const id = insertPhoto({ provider: 'synologyphotos', asset_id: 's2', owner_id: 9 }); + const res = makeRes(); + + await svc.streamPhoto(res as never, 4, id, 'original'); + + expect(synology.streamSynologyAsset).toHaveBeenCalledWith(res, 4, 9, 's2', 'original', undefined, undefined); + }); +}); + +describe('getPhotoInfo — dispatch', () => { + it('RESOLVE-010: rejects an unknown photo id', async () => { + const result = await svc.getPhotoInfo(1, 999999); + expect(result.success).toBe(false); + }); + + it('RESOLVE-011: asks immich for an immich asset', async () => { + const id = insertPhoto({ provider: 'immich', asset_id: 'a1', owner_id: 5 }); + immich.getAssetInfo.mockResolvedValue({ success: true, data: { id: 'a1' } }); + + await svc.getPhotoInfo(3, id); + + expect(immich.getAssetInfo).toHaveBeenCalled(); + expect(synology.getSynologyAssetInfo).not.toHaveBeenCalled(); + }); + + it('RESOLVE-012: asks synology for a synology asset', async () => { + const id = insertPhoto({ provider: 'synologyphotos', asset_id: 's1', owner_id: 9 }); + synology.getSynologyAssetInfo.mockResolvedValue({ success: true, data: { id: 's1' } }); + + await svc.getPhotoInfo(4, id); + + expect(synology.getSynologyAssetInfo).toHaveBeenCalled(); + expect(immich.getAssetInfo).not.toHaveBeenCalled(); + }); + + it('RESOLVE-013: an unknown provider is an error, not a crash', async () => { + const id = insertPhoto({ provider: 'picasa', asset_id: 'p1', owner_id: 2 }); + const result = await svc.getPhotoInfo(1, id); + expect(result.success).toBe(false); + }); +}); diff --git a/server/tests/unit/nest/thumbnail.service.test.ts b/server/tests/unit/nest/thumbnail.service.test.ts new file mode 100644 index 000000000..b5006e204 --- /dev/null +++ b/server/tests/unit/nest/thumbnail.service.test.ts @@ -0,0 +1,104 @@ +/** + * ThumbnailService — the downscaled JPEG for a locally uploaded journey photo. + * + * Untested before the fold, because it lived outside the measured tree. The + * cases below pin the three things that decide whether a gallery shows a + * picture or a broken tile: the addon gate, the "source is gone" bail-out, and + * the mtime check that avoids regenerating an up-to-date thumbnail. + */ +import { describe, it, expect, vi, beforeEach, afterAll } from 'vitest'; + +const { isAddonEnabled } = vi.hoisted(() => ({ isAddonEnabled: vi.fn(() => true) })); + +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { Jimp } from 'jimp'; +import { ThumbnailService } from '../../../src/nest/memories/thumbnail.service'; +import type { AddonsService } from '../../../src/nest/addons/addons.service'; + +const svc = new ThumbnailService({ isAddonEnabled } as unknown as AddonsService); +const root = fs.mkdtempSync(path.join(os.tmpdir(), 'trek-thumbs-')); + +/** A real 1200x900 JPEG — Jimp has to be able to decode it for the happy path. */ +async function writeSourceImage(rel: string): Promise { + const abs = path.join(root, rel); + fs.mkdirSync(path.dirname(abs), { recursive: true }); + const img = new Jimp({ width: 1200, height: 900, color: 0xff0000ff }); + await img.write(abs as `${string}.jpg`); +} + +beforeEach(() => { + vi.clearAllMocks(); + isAddonEnabled.mockReturnValue(true); +}); + +afterAll(() => { + fs.rmSync(root, { recursive: true, force: true }); +}); + +describe('ensureLocalThumbnail', () => { + it('THUMB-001: returns null when the journey addon is off, without touching the disk', async () => { + isAddonEnabled.mockReturnValue(false); + expect(await svc.ensureLocalThumbnail(root, 'anything.jpg')).toBeNull(); + }); + + it('THUMB-002: returns null when the source file does not exist', async () => { + expect(await svc.ensureLocalThumbnail(root, 'missing/nope.jpg')).toBeNull(); + }); + + it('THUMB-003: downscales an oversized image and reports the resulting size', async () => { + await writeSourceImage('journey/big.jpg'); + + const result = await svc.ensureLocalThumbnail(root, 'journey/big.jpg'); + + expect(result).not.toBeNull(); + expect(result!.thumbnailRelPath).toMatch(/^journey\/thumbs\/[0-9a-f]{16}\.jpg$/); + // 1200x900 fits into an 800 box as 800x600. + expect(Math.max(result!.width, result!.height)).toBeLessThanOrEqual(800); + expect(fs.existsSync(path.join(root, result!.thumbnailRelPath))).toBe(true); + }); + + it('THUMB-004: the path is deterministic, so concurrent requests cannot race on two names', async () => { + await writeSourceImage('journey/stable.jpg'); + + const first = await svc.ensureLocalThumbnail(root, 'journey/stable.jpg'); + const second = await svc.ensureLocalThumbnail(root, 'journey/stable.jpg'); + + expect(second!.thumbnailRelPath).toBe(first!.thumbnailRelPath); + }); + + it('THUMB-005: reuses an existing thumbnail that is newer than the source', async () => { + await writeSourceImage('journey/cached.jpg'); + const first = await svc.ensureLocalThumbnail(root, 'journey/cached.jpg'); + const thumbAbs = path.join(root, first!.thumbnailRelPath); + const mtimeBefore = fs.statSync(thumbAbs).mtimeMs; + + const second = await svc.ensureLocalThumbnail(root, 'journey/cached.jpg'); + + expect(second).toEqual(first); + expect(fs.statSync(thumbAbs).mtimeMs).toBe(mtimeBefore); + }); + + it('THUMB-006: regenerates when the source is newer than the thumbnail', async () => { + await writeSourceImage('journey/changed.jpg'); + const first = await svc.ensureLocalThumbnail(root, 'journey/changed.jpg'); + const thumbAbs = path.join(root, first!.thumbnailRelPath); + // Age the thumbnail past the source. + const old = new Date(Date.now() - 60_000); + fs.utimesSync(thumbAbs, old, old); + + const second = await svc.ensureLocalThumbnail(root, 'journey/changed.jpg'); + + expect(second!.thumbnailRelPath).toBe(first!.thumbnailRelPath); + expect(fs.statSync(thumbAbs).mtimeMs).toBeGreaterThan(old.getTime()); + }); + + it('THUMB-007: returns null for a file Jimp cannot decode instead of throwing', async () => { + const abs = path.join(root, 'journey/corrupt.jpg'); + fs.mkdirSync(path.dirname(abs), { recursive: true }); + fs.writeFileSync(abs, 'not an image at all'); + + expect(await svc.ensureLocalThumbnail(root, 'journey/corrupt.jpg')).toBeNull(); + }); +}); diff --git a/server/tests/unit/nest/trek-photo-cache.service.test.ts b/server/tests/unit/nest/trek-photo-cache.service.test.ts new file mode 100644 index 000000000..0c2b75e82 --- /dev/null +++ b/server/tests/unit/nest/trek-photo-cache.service.test.ts @@ -0,0 +1,185 @@ +/** + * TrekPhotoCacheService — the disk + metadata cache for provider assets. + * + * It had no suite of its own: it lived in services/memories/, which the + * coverage gate does not measure, so a cache that silently stopped evicting or + * stopped deduping concurrent fetches would not have failed anything. The + * stampede guard in particular is the reason the in-flight map is module-scoped + * rather than an instance field. + */ +import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest'; + +const { testDb, dbMock } = vi.hoisted(() => { + const Database = require('better-sqlite3'); + const db = new Database(':memory:'); + db.exec('PRAGMA journal_mode = WAL'); + return { + testDb: db, + dbMock: { db, closeDb: () => {}, reinitialize: () => {}, canAccessTrip: () => null, isOwner: () => false, getPlaceWithTags: () => null }, + }; +}); +vi.mock('../../../src/db/database', () => dbMock); + +import fs from 'node:fs'; +import path from 'node:path'; +import { createTables } from '../../../src/db/schema'; +import { runMigrations } from '../../../src/db/migrations'; +import { DatabaseService } from '../../../src/nest/database/database.service'; +import { TrekPhotoCacheService, CACHE_TTL } from '../../../src/nest/memories/trek-photo-cache.service'; +import { UPLOADS_ROOT } from '../../../src/nest/memories/uploads-root'; + +const svc = new TrekPhotoCacheService(new DatabaseService(testDb)); +const CACHE_DIR = path.join(UPLOADS_ROOT, 'photos/trek'); +const written: string[] = []; + +/** Unique per case so parallel files cannot collide on the shared cache dir. */ +function freshKey(label: string): string { + const key = svc.cacheKey('test', `${label}-${process.pid}-${written.length}`, 'thumbnail', 1); + written.push(key); + return key; +} + +beforeAll(() => { + createTables(testDb); + runMigrations(testDb); +}); + +beforeEach(() => { + testDb.prepare('DELETE FROM trek_photo_cache_meta').run(); +}); + +afterAll(() => { + for (const key of written) { + try { fs.unlinkSync(path.join(CACHE_DIR, `${key}.bin`)); } catch { /* never written */ } + } + testDb.close(); +}); + +describe('cacheKey', () => { + it('CACHE-001: is deterministic and separates provider, asset, kind and owner', () => { + const a = svc.cacheKey('immich', 'asset-1', 'thumbnail', 7); + expect(svc.cacheKey('immich', 'asset-1', 'thumbnail', 7)).toBe(a); + expect(svc.cacheKey('immich', 'asset-1', 'original', 7)).not.toBe(a); + expect(svc.cacheKey('immich', 'asset-1', 'thumbnail', 8)).not.toBe(a); + expect(svc.cacheKey('synologyphotos', 'asset-1', 'thumbnail', 7)).not.toBe(a); + }); +}); + +describe('put / getFresh', () => { + it('CACHE-002: a stored entry comes back with its content type and a real file', async () => { + const key = freshKey('hit'); + await svc.put(key, Buffer.from('jpegbytes'), 'image/jpeg'); + + const entry = svc.getFresh(key); + expect(entry).not.toBeNull(); + expect(entry!.contentType).toBe('image/jpeg'); + expect(fs.readFileSync(entry!.filePath).toString()).toBe('jpegbytes'); + }); + + it('CACHE-003: an unknown key is a miss, not an error', () => { + expect(svc.getFresh('no-such-key')).toBeNull(); + }); + + it('CACHE-004: an entry past its TTL is a miss and its metadata row is dropped', async () => { + const key = freshKey('stale'); + await svc.put(key, Buffer.from('old'), 'image/jpeg'); + testDb.prepare('UPDATE trek_photo_cache_meta SET fetched_at = ? WHERE cache_key = ?') + .run(Date.now() - CACHE_TTL - 1000, key); + + expect(svc.getFresh(key)).toBeNull(); + expect(testDb.prepare('SELECT 1 FROM trek_photo_cache_meta WHERE cache_key = ?').get(key)).toBeUndefined(); + }); + + it('CACHE-005: metadata without its file is a miss and the row is dropped', () => { + const key = 'orphan-meta-row'; + testDb.prepare('INSERT INTO trek_photo_cache_meta (cache_key, content_type, fetched_at) VALUES (?, ?, ?)') + .run(key, 'image/jpeg', Date.now()); + + expect(svc.getFresh(key)).toBeNull(); + expect(testDb.prepare('SELECT 1 FROM trek_photo_cache_meta WHERE cache_key = ?').get(key)).toBeUndefined(); + }); + + it('CACHE-006: writing the same key twice replaces the bytes rather than duplicating the row', async () => { + const key = freshKey('replace'); + await svc.put(key, Buffer.from('first'), 'image/jpeg'); + await svc.put(key, Buffer.from('second'), 'image/png'); + + const rows = testDb.prepare('SELECT content_type FROM trek_photo_cache_meta WHERE cache_key = ?').all(key); + expect(rows).toHaveLength(1); + expect(svc.getFresh(key)!.contentType).toBe('image/png'); + expect(fs.readFileSync(svc.getFresh(key)!.filePath).toString()).toBe('second'); + }); +}); + +describe('serveFresh', () => { + it('CACHE-007: sends the cached file with a one-hour cache header', async () => { + const key = freshKey('serve'); + await svc.put(key, Buffer.from('bytes'), 'image/webp'); + const res = { set: vi.fn(), sendFile: vi.fn() }; + + expect(svc.serveFresh(res as never, key)).toBe(true); + expect(res.set).toHaveBeenCalledWith('Content-Type', 'image/webp'); + expect(res.set).toHaveBeenCalledWith('Cache-Control', 'public, max-age=3600'); + expect(res.sendFile).toHaveBeenCalledOnce(); + }); + + it('CACHE-008: reports a miss without touching the response', () => { + const res = { set: vi.fn(), sendFile: vi.fn() }; + expect(svc.serveFresh(res as never, 'nothing-cached')).toBe(false); + expect(res.set).not.toHaveBeenCalled(); + expect(res.sendFile).not.toHaveBeenCalled(); + }); +}); + +describe('the stampede guard', () => { + it('CACHE-009: a second caller gets the first caller\'s in-flight promise', async () => { + const key = 'inflight-key'; + let resolveFetch!: (b: Buffer) => void; + const fetch = new Promise((resolve) => { resolveFetch = resolve as (b: Buffer) => void; }); + + svc.setInFlight(key, fetch); + expect(svc.getInFlight(key)).toBe(fetch); + + resolveFetch(Buffer.from('done')); + await fetch; + // Settling clears the slot, so the next request starts a fresh fetch. + await Promise.resolve(); + expect(svc.getInFlight(key)).toBeUndefined(); + }); + + it('CACHE-010: the map is shared across instances — the scheduler builds its own', () => { + // Load-bearing: a per-instance map would give the cron and the container + // separate guards and the dedup would be silently gone. + const other = new TrekPhotoCacheService(new DatabaseService(testDb)); + const promise = Promise.resolve(null); + svc.setInFlight('shared-key', promise); + expect(other.getInFlight('shared-key')).toBe(promise); + }); +}); + +describe('sweepExpired', () => { + it('CACHE-011: drops rows and files past twice the TTL and leaves fresh ones alone', async () => { + const staleKey = freshKey('sweep-stale'); + const freshKeyId = freshKey('sweep-fresh'); + await svc.put(staleKey, Buffer.from('old'), 'image/jpeg'); + await svc.put(freshKeyId, Buffer.from('new'), 'image/jpeg'); + testDb.prepare('UPDATE trek_photo_cache_meta SET fetched_at = ? WHERE cache_key = ?') + .run(Date.now() - CACHE_TTL * 2 - 1000, staleKey); + const staleFile = path.join(CACHE_DIR, `${staleKey}.bin`); + + svc.sweepExpired(); + + expect(testDb.prepare('SELECT 1 FROM trek_photo_cache_meta WHERE cache_key = ?').get(staleKey)).toBeUndefined(); + expect(fs.existsSync(staleFile)).toBe(false); + expect(testDb.prepare('SELECT 1 FROM trek_photo_cache_meta WHERE cache_key = ?').get(freshKeyId)).toBeDefined(); + }); + + it('CACHE-012: survives a metadata row whose file is already gone', () => { + const key = 'sweep-orphan'; + testDb.prepare('INSERT INTO trek_photo_cache_meta (cache_key, content_type, fetched_at) VALUES (?, ?, ?)') + .run(key, 'image/jpeg', Date.now() - CACHE_TTL * 3); + + expect(() => svc.sweepExpired()).not.toThrow(); + expect(testDb.prepare('SELECT 1 FROM trek_photo_cache_meta WHERE cache_key = ?').get(key)).toBeUndefined(); + }); +}); diff --git a/server/tests/unit/services/memoriesUnified.test.ts b/server/tests/unit/nest/unified-memories.service.test.ts similarity index 84% rename from server/tests/unit/services/memoriesUnified.test.ts rename to server/tests/unit/nest/unified-memories.service.test.ts index ed74f37f9..9432290ed 100644 --- a/server/tests/unit/services/memoriesUnified.test.ts +++ b/server/tests/unit/nest/unified-memories.service.test.ts @@ -1,5 +1,6 @@ /** - * Unit tests for memories/unifiedService — MEM-UNIFIED-001 to MEM-UNIFIED-010. + * Unit tests for UnifiedMemoriesService — MEM-UNIFIED-001 to MEM-UNIFIED-010. + * Moved 1:1 with the fold; the free functions became methods. * Covers error paths: access denied, disabled provider, no providers enabled. */ import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest'; @@ -44,15 +45,32 @@ import { createTables } from '../../../src/db/schema'; import { runMigrations } from '../../../src/db/migrations'; import { resetTestDb } from '../../helpers/test-db'; import { createUser, createTrip } from '../../helpers/factories'; -import { - listTripPhotos, - listTripAlbumLinks, - addTripPhotos, - setTripPhotoSharing, - removeTripPhoto, - createTripAlbumLink, - removeAlbumLink, -} from '../../../src/services/memories/unifiedService'; +import { UnifiedMemoriesService } from '../../../src/nest/memories/unified-memories.service'; +import { MemoriesAccessService } from '../../../src/nest/memories/memories-access.service'; +import { TrekPhotosRepository } from '../../../src/nest/photos/trek-photos.repository'; +import { DatabaseService } from '../../../src/nest/database/database.service'; +import type { ImmichService } from '../../../src/nest/memories/immich.service'; +import type { SynologyService } from '../../../src/nest/memories/synology.service'; + +// The album-sync paths are the providers' half and have their own suites; these +// cases never reach them, so stubs keep the graph small. +const dbs = new DatabaseService(testDb); +const svc = new UnifiedMemoriesService( + dbs, + new TrekPhotosRepository(dbs), + {} as ImmichService, + {} as SynologyService, + new MemoriesAccessService(dbs), +); + +// Legacy free-function names bound to the service, so the moved cases read as before. +const listTripPhotos = svc.listTripPhotos.bind(svc); +const listTripAlbumLinks = svc.listTripAlbumLinks.bind(svc); +const addTripPhotos = svc.addTripPhotos.bind(svc); +const setTripPhotoSharing = svc.setTripPhotoSharing.bind(svc); +const removeTripPhoto = svc.removeTripPhoto.bind(svc); +const createTripAlbumLink = svc.createTripAlbumLink.bind(svc); +const removeAlbumLink = svc.removeAlbumLink.bind(svc); beforeAll(() => { createTables(testDb);