diff --git a/server/src/services/memories/helpersService.ts b/server/src/services/memories/helpersService.ts index 86383789..84a3fd31 100644 --- a/server/src/services/memories/helpersService.ts +++ b/server/src/services/memories/helpersService.ts @@ -43,6 +43,7 @@ export function handleServiceResult(res: Response, result: ServiceResult): export type Selection = { provider: string; asset_ids: string[]; + passphrase?: string; }; export type StatusResult = { diff --git a/server/src/services/memories/photoResolverService.ts b/server/src/services/memories/photoResolverService.ts index c077774f..942e8334 100644 --- a/server/src/services/memories/photoResolverService.ts +++ b/server/src/services/memories/photoResolverService.ts @@ -7,6 +7,7 @@ import { streamImmichAsset, getAssetInfo as getImmichAssetInfo } from './immichS import { streamSynologyAsset, getSynologyAssetInfo } from './synologyService'; import type { ServiceResult, AssetInfo } from './helpersService'; import { fail, success } from './helpersService'; +import { encrypt_api_key, decrypt_api_key } from '../apiKeyCrypto'; // ── Lookup / Register ──────────────────────────────────────────────────── @@ -14,15 +15,22 @@ export function getOrCreateTrekPhoto( provider: string, assetId: string, ownerId: number, + passphrase?: string, ): number { const existing = db.prepare( 'SELECT id FROM trek_photos WHERE provider = ? AND asset_id = ? AND owner_id = ?' ).get(provider, assetId, ownerId) as { id: number } | undefined; - if (existing) return existing.id; + if (existing) { + if (passphrase) { + db.prepare('UPDATE trek_photos SET passphrase = ? WHERE id = ? AND passphrase IS NULL') + .run(encrypt_api_key(passphrase), existing.id); + } + return existing.id; + } const res = db.prepare( - 'INSERT INTO trek_photos (provider, asset_id, owner_id) VALUES (?, ?, ?)' - ).run(provider, assetId, ownerId); + 'INSERT INTO trek_photos (provider, asset_id, owner_id, passphrase) VALUES (?, ?, ?, ?)' + ).run(provider, assetId, ownerId, passphrase ? encrypt_api_key(passphrase) : null); return Number(res.lastInsertRowid); } @@ -77,7 +85,8 @@ export async function streamPhoto( return; } case 'synologyphotos': { - await streamSynologyAsset(res, userId, photo.owner_id!, photo.asset_id!, kind); + const passphrase = photo.passphrase ? (decrypt_api_key(photo.passphrase) || undefined) : undefined; + await streamSynologyAsset(res, userId, photo.owner_id!, photo.asset_id!, kind, undefined, passphrase); return; } default: @@ -112,7 +121,8 @@ export async function getPhotoInfo( return success(result.data as AssetInfo); } case 'synologyphotos': { - return getSynologyAssetInfo(userId, photo.asset_id!, photo.owner_id!); + 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 index c908342e..1706d490 100644 --- a/server/src/services/memories/synologyService.ts +++ b/server/src/services/memories/synologyService.ts @@ -5,7 +5,7 @@ import { decrypt_api_key, encrypt_api_key, maybe_encrypt_api_key } from '../apiK import { safeFetch, SsrfBlockedError, checkSsrf } from '../../utils/ssrfGuard'; import { addTripPhotos } from './unifiedService'; import { - getAlbumIdFromLink, + getAlbumLinkForSync, updateSyncTimeForAlbumLink, Selection, ServiceResult, @@ -476,21 +476,16 @@ export async function listSynologyAlbums(userId: number): Promise> { +export async function getSynologyAlbumPhotos(userId: number, albumId: string, passphrase?: string): Promise> { const allItems: SynologyPhotoItem[] = []; const pageSize = 1000; let offset = 0; while (true) { - const result = await _requestSynologyApi<{ list: SynologyPhotoItem[] }>(userId, { - api: 'SYNO.Foto.Browse.Item', - method: 'list', - version: 1, - album_id: Number(albumId), - offset, - limit: pageSize, - additional: ['thumbnail'], - }); + 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); @@ -507,23 +502,21 @@ export async function getSynologyAlbumPhotos(userId: number, albumId: string): P } export async function syncSynologyAlbumLink(userId: number, tripId: string, linkId: string, sid: string): Promise> { - const response = getAlbumIdFromLink(tripId, linkId, userId); + const response = getAlbumLinkForSync(tripId, linkId, userId); if (!response.success) return response as ServiceResult; + const { albumId, passphrase } = response.data; + const allItems: SynologyPhotoItem[] = []; const pageSize = 1000; let offset = 0; while (true) { - const result = await _requestSynologyApi<{ list: SynologyPhotoItem[] }>(userId, { - api: 'SYNO.Foto.Browse.Item', - method: 'list', - version: 1, - album_id: Number(response.data), - offset, - limit: pageSize, - additional: ['thumbnail'], - }); + 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; @@ -536,9 +529,9 @@ export async function syncSynologyAlbumLink(userId: number, tripId: string, link const selection: Selection = { provider: SYNOLOGY_PROVIDER, asset_ids: allItems.map(item => String(item.additional?.thumbnail?.cache_key || '')).filter(id => id), + passphrase, }; - const result = await addTripPhotos(tripId, userId, true, [selection], sid, linkId); if (!result.success) return result as ServiceResult; @@ -582,16 +575,18 @@ export async function searchSynologyPhotos(userId: number, from?: string, to?: s }); } -export async function getSynologyAssetInfo(userId: number, photoId: string, targetUserId?: number): Promise> { +export async function getSynologyAssetInfo(userId: number, photoId: string, targetUserId?: number, passphrase?: string): Promise> { const parsedId = _splitPackedSynologyId(photoId); if (!parsedId) return fail('Invalid photo ID format', 400); - const result = await _requestSynologyApi<{ list: SynologyPhotoItem[] }>(targetUserId, { + 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; @@ -609,6 +604,8 @@ export async function streamSynologyAsset( targetUserId: number, photoId: string, kind: 'thumbnail' | 'original', + size?: string, + passphrase?: string, ): Promise { const parsedId = _splitPackedSynologyId(photoId); if (!parsedId) { @@ -634,6 +631,7 @@ export async function streamSynologyAsset( //size: 'sm' 240px| 'm' 320px| 'xl' 1280px| 'preview' ? + const resolvedSize = size || 'sm'; const params = kind === 'thumbnail' ? new URLSearchParams({ api: 'SYNO.Foto.Thumbnail', @@ -642,7 +640,7 @@ export async function streamSynologyAsset( mode: 'download', id: parsedId.id, type: 'unit', - size: 'sm', + size: resolvedSize, cache_key: parsedId.cacheKey, _sid: sid.data, }) @@ -654,6 +652,7 @@ export async function streamSynologyAsset( unit_id: `[${parsedId.id}]`, _sid: sid.data, }); + if (passphrase) params.append('passphrase', passphrase); const url = _buildSynologyEndpoint(synology_credentials.data.synology_url, params.toString()); await pipeAsset(url, response) diff --git a/server/src/services/memories/unifiedService.ts b/server/src/services/memories/unifiedService.ts index ebdfba21..888f2710 100644 --- a/server/src/services/memories/unifiedService.ts +++ b/server/src/services/memories/unifiedService.ts @@ -9,6 +9,7 @@ import { Selection, } from './helpersService'; import { getOrCreateTrekPhoto } from './photoResolverService'; +import { encrypt_api_key } from '../apiKeyCrypto'; function _providers(): Array<{id: string; enabled: boolean}> { @@ -104,13 +105,13 @@ export function listTripAlbumLinks(tripId: string, userId: number): ServiceResul //----------------------------------------------- // managing photos in trip -function _addTripPhoto(tripId: string, userId: number, provider: string, assetId: string, shared: boolean, albumLinkId?: string): ServiceResult { +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); + 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); @@ -147,7 +148,7 @@ export async function addTripPhotos( 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); + const result = _addTripPhoto(tripId, userId, selection.provider, assetId, shared, albumLinkId, selection.passphrase); if (!result.success) { return result as ServiceResult<{ added: number; shared: boolean }>; } @@ -222,7 +223,7 @@ export function removeTripPhoto( // ---------------------------------------------- // managing album links in trip -export function createTripAlbumLink(tripId: string, userId: number, providerRaw: unknown, albumIdRaw: unknown, albumNameRaw: unknown): ServiceResult { +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); @@ -246,9 +247,10 @@ export function createTripAlbumLink(tripId: string, userId: number, providerRaw: } 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) VALUES (?, ?, ?, ?, ?)' - ).run(tripId, userId, provider, albumId, albumName); + '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); diff --git a/server/tests/integration/memories-synology.test.ts b/server/tests/integration/memories-synology.test.ts index 020813a8..bc9ab0c5 100644 --- a/server/tests/integration/memories-synology.test.ts +++ b/server/tests/integration/memories-synology.test.ts @@ -704,6 +704,7 @@ describe('Synology auth checks', () => { // ── Album sync ──────────────────────────────────────────────────────────────── import { addAlbumLink } from '../helpers/factories'; +import { encrypt_api_key } from '../../src/services/apiKeyCrypto'; describe('Synology syncSynologyAlbumLink', () => { it('SYNO-050 — POST sync happy path: trip owner with album link saves photos to DB', async () => { @@ -765,6 +766,70 @@ describe('Synology syncSynologyAlbumLink', () => { it('SYNO-053 — POST sync without auth returns 401', async () => { expect((await request(app).post(`${SYNO}/trips/1/album-links/1/sync`)).status).toBe(401); }); + + it('SYNO-054 — POST sync with passphrase link: uses passphrase in item-list call and persists encrypted passphrase on trek_photos', async () => { + const { user } = createUser(testDb); + const trip = createTrip(testDb, user.id); + setSynologyCredentials(testDb, user.id, 'https://synology.example.com', 'admin', 'pass'); + testDb.prepare("UPDATE photo_providers SET enabled = 1 WHERE id = 'synologyphotos'").run(); + + // Insert a link with an encrypted passphrase directly into the DB. + const rawPassphrase = 'syno-share-pass-abc'; + const result = testDb.prepare( + 'INSERT INTO trip_album_links (trip_id, user_id, provider, album_id, album_name, passphrase) VALUES (?, ?, ?, ?, ?, ?)' + ).run(trip.id, user.id, 'synologyphotos', '99', 'Shared Album', encrypt_api_key(rawPassphrase)); + const link = testDb.prepare('SELECT * FROM trip_album_links WHERE id = ?').get(result.lastInsertRowid) as any; + + // Override safeFetch so browse-item only succeeds when called with the passphrase param. + vi.mocked(safeFetch).mockImplementation(async (url: any, init?: any) => { + const bodyParams = init?.body instanceof URLSearchParams + ? init.body + : new URLSearchParams(String(init?.body ?? '')); + const apiName = bodyParams.get('api') || (new URL(String(url)).searchParams.get('api') ?? ''); + + if (apiName === 'SYNO.API.Auth') { + return { ok: true, status: 200, headers: { get: () => 'application/json' }, json: async () => ({ success: true, data: { sid: 'fake-sid-054' } }), body: null } as any; + } + + if (apiName === 'SYNO.Foto.Browse.Item') { + // Only respond successfully when the passphrase param is present. + if (bodyParams.get('passphrase') !== rawPassphrase) { + return { ok: true, status: 200, headers: { get: () => 'application/json' }, json: async () => ({ success: false, error: { code: 105 } }), body: null } as any; + } + return { + ok: true, status: 200, + headers: { get: () => 'application/json' }, + json: async () => ({ + success: true, + data: { + list: [{ id: 201, filename: 'shared.jpg', filesize: 512000, time: 1717228800, additional: { thumbnail: { cache_key: '201_sharedkey' } } }], + }, + }), + body: null, + } as any; + } + + return Promise.reject(new Error(`SYNO-054: unexpected safeFetch call: api=${apiName}`)); + }); + + const res = await request(app) + .post(`${SYNO}/trips/${trip.id}/album-links/${link.id}/sync`) + .set('Cookie', authCookie(user.id)); + + expect(res.status).toBe(200); + expect(res.body.added).toBeGreaterThan(0); + + // The trek_photos row for the synced photo must have a non-null passphrase. + const photo = testDb.prepare(` + SELECT tkp.passphrase FROM trip_photos tp + JOIN trek_photos tkp ON tkp.id = tp.photo_id + WHERE tp.trip_id = ? AND tp.user_id = ? + LIMIT 1 + `).get(trip.id, user.id) as { passphrase: string | null } | undefined; + + expect(photo).toBeDefined(); + expect(photo!.passphrase).not.toBeNull(); + }); }); // ── Session retry logic ───────────────────────────────────────────────────────