From 465b78411a95231174d54dc209dc4601937c6706 Mon Sep 17 00:00:00 2001 From: jubnl Date: Thu, 16 Apr 2026 19:49:08 +0200 Subject: [PATCH 1/4] fix(synology): resolve pagination offset using correct size before computing page offset MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The `size` → `limit` assignment was evaluated after `page * limit`, causing the offset to be computed using the hardcoded default (100) instead of the caller-supplied page size. Swapping the two `if` blocks ensures `limit` is resolved from `size` first so the offset is always `(page-1) * size`. Adds SYNO-025 and SYNO-026 integration tests that capture the raw Synology API body and assert `offset` and `limit` are forwarded correctly. --- server/src/routes/memories/synology.ts | 4 +- .../integration/memories-synology.test.ts | 77 +++++++++++++++++++ 2 files changed, 79 insertions(+), 2 deletions(-) diff --git a/server/src/routes/memories/synology.ts b/server/src/routes/memories/synology.ts index 5cfaa57b..52d4d5fe 100644 --- a/server/src/routes/memories/synology.ts +++ b/server/src/routes/memories/synology.ts @@ -100,8 +100,8 @@ router.post('/search', authenticate, async (req: Request, res: Response) => { const page = _parseNumberBodyField(body.page, 1) - 1; let limit = _parseNumberBodyField(body.limit, 100); const size = _parseNumberBodyField(body.size, 0); - if(page > 0) offset = page*limit; - if(size > 0) limit = size; + if (size > 0) limit = size; + if (page > 0) offset = page * limit; handleServiceResult(res, await searchSynologyPhotos( authReq.user.id, diff --git a/server/tests/integration/memories-synology.test.ts b/server/tests/integration/memories-synology.test.ts index ce390f82..001a1c27 100644 --- a/server/tests/integration/memories-synology.test.ts +++ b/server/tests/integration/memories-synology.test.ts @@ -843,6 +843,83 @@ describe('Synology searchSynologyPhotos date range', () => { }); }); +// ── Search pagination ───────────────────────────────────────────────────────── + +describe('Synology search pagination', () => { + it('SYNO-025 — POST /search with { page: 2, size: 50 } sends offset=50 and limit=50 to Synology API', async () => { + const { user } = createUser(testDb); + setSynologyCredentials(testDb, user.id, 'https://synology.example.com', 'admin', 'pass'); + + let capturedBody: URLSearchParams | null = null; + vi.mocked(safeFetch) + .mockResolvedValueOnce({ + // login + ok: true, status: 200, + headers: { get: () => 'application/json' }, + json: async () => ({ success: true, data: { sid: 'fake-sid' } }), + body: null, + } as any) + .mockImplementationOnce((_url: string, init?: any) => { + capturedBody = init?.body instanceof URLSearchParams + ? init.body + : new URLSearchParams(String(init?.body ?? '')); + return Promise.resolve({ + ok: true, status: 200, + headers: { get: () => 'application/json' }, + json: async () => ({ success: true, data: { list: [] } }), + body: null, + } as any); + }); + + const res = await request(app) + .post(`${SYNO}/search`) + .set('Cookie', authCookie(user.id)) + .send({ page: 2, size: 50 }); + + expect(res.status).toBe(200); + expect(capturedBody).not.toBeNull(); + // With the fix: limit=50 is resolved first, then offset = (2-1)*50 = 50 + expect(capturedBody!.get('offset')).toBe('50'); + expect(capturedBody!.get('limit')).toBe('50'); + }); + + it('SYNO-026 — POST /search with { page: 3, size: 25 } sends offset=50 and limit=25 to Synology API', async () => { + const { user } = createUser(testDb); + setSynologyCredentials(testDb, user.id, 'https://synology.example.com', 'admin', 'pass'); + + let capturedBody: URLSearchParams | null = null; + vi.mocked(safeFetch) + .mockResolvedValueOnce({ + ok: true, status: 200, + headers: { get: () => 'application/json' }, + json: async () => ({ success: true, data: { sid: 'fake-sid' } }), + body: null, + } as any) + .mockImplementationOnce((_url: string, init?: any) => { + capturedBody = init?.body instanceof URLSearchParams + ? init.body + : new URLSearchParams(String(init?.body ?? '')); + return Promise.resolve({ + ok: true, status: 200, + headers: { get: () => 'application/json' }, + json: async () => ({ success: true, data: { list: [] } }), + body: null, + } as any); + }); + + const res = await request(app) + .post(`${SYNO}/search`) + .set('Cookie', authCookie(user.id)) + .send({ page: 3, size: 25 }); + + expect(res.status).toBe(200); + expect(capturedBody).not.toBeNull(); + // page 3 → page index = 2 (after subtracting 1), offset = 2 * 25 = 50 + expect(capturedBody!.get('offset')).toBe('50'); + expect(capturedBody!.get('limit')).toBe('25'); + }); +}); + // ── SSRF catch branch in _fetchSynologyJson ──────────────────────────────────── describe('Synology SSRF blocked error handling', () => { From 8a6d1b2aaf9e30f3d755dfbab42ff2a2a8f4cf00 Mon Sep 17 00:00:00 2001 From: jubnl Date: Thu, 16 Apr 2026 19:56:10 +0200 Subject: [PATCH 2/4] feat(synology): merge personal, shared-out, and shared-with-me albums in listSynologyAlbums Fire all three Synology album sources in parallel via Promise.allSettled so a permissions failure on one source (e.g. SYNO.Foto.Sharing.Misc) never blocks personal album display. Deduplicate by album id (last-write-wins), propagate passphrase from shared/shared-with-me entries, and return the merged list sorted by albumName. Extends AlbumsList type to carry optional passphrase. Adds SYNO-027/028/029 integration tests; updates SYNO-060/061/081 to match the new multi-source call pattern. --- .../src/services/memories/helpersService.ts | 20 ++- .../src/services/memories/synologyService.ts | 50 ++++-- .../integration/memories-synology.test.ts | 155 +++++++++++++++++- 3 files changed, 203 insertions(+), 22 deletions(-) diff --git a/server/src/services/memories/helpersService.ts b/server/src/services/memories/helpersService.ts index 4b75ff4f..86383789 100644 --- a/server/src/services/memories/helpersService.ts +++ b/server/src/services/memories/helpersService.ts @@ -3,6 +3,7 @@ import { Readable } from 'node:stream'; import { Response } from 'express'; import { canAccessTrip, db } from "../../db/database"; import { safeFetch, SsrfBlockedError } from '../../utils/ssrfGuard'; +import { decrypt_api_key } from '../apiKeyCrypto'; // helpers for handling return types @@ -59,7 +60,7 @@ export type SyncAlbumResult = { export type AlbumsList = { - albums: Array<{ id: string; albumName: string; assetCount: number }> + albums: Array<{ id: string; albumName: string; assetCount: number; passphrase?: string }> }; export type Asset = { @@ -230,6 +231,23 @@ export function getAlbumIdFromLink(tripId: string, linkId: string, userId: numbe } } +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/synologyService.ts b/server/src/services/memories/synologyService.ts index 48d52c47..c908342e 100644 --- a/server/src/services/memories/synologyService.ts +++ b/server/src/services/memories/synologyService.ts @@ -433,21 +433,45 @@ export async function testSynologyConnection(userId: number, synologyUrl: string } export async function listSynologyAlbums(userId: number): Promise> { - const result = await _requestSynologyApi<{ list: SynologyPhotoItem[] }>(userId, { - api: 'SYNO.Foto.Browse.Album', - method: 'list', - version: 4, - offset: 0, - limit: 100, - }); - if (!result.success) return result as ServiceResult; + const [personal, shared, sharedWithMe] = await Promise.allSettled([ + _requestSynologyApi<{ list: any[] }>(userId, { + api: 'SYNO.Foto.Browse.Album', method: 'list', version: 4, + offset: 0, limit: 100, + }), + _requestSynologyApi<{ list: any[] }>(userId, { + api: 'SYNO.Foto.Browse.Album', method: 'list', version: 4, + offset: 0, limit: 100, category: 'shared', + }), + _requestSynologyApi<{ list: any[] }>(userId, { + api: 'SYNO.Foto.Sharing.Misc', method: 'list_shared_with_me_album', version: 1, + offset: 0, limit: 100, additional: ['thumbnail', 'sharing_info'], + }), + ]); - const albums = (result.data.list || []).map((album: any) => ({ - id: String(album.id), - albumName: album.name || '', - assetCount: album.item_count || 0, - })); + const map = new Map(); + const addAlbums = (result: PromiseSettledResult>, extractPassphrase: (a: any) => string | undefined) => { + if (result.status === 'rejected') return; + if (!result.value.success) { + console.warn('[Synology] album list partial failure:', (result.value as any).error?.message); + return; + } + for (const album of (result.value as any).data?.list ?? []) { + 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 }); } diff --git a/server/tests/integration/memories-synology.test.ts b/server/tests/integration/memories-synology.test.ts index 001a1c27..020813a8 100644 --- a/server/tests/integration/memories-synology.test.ts +++ b/server/tests/integration/memories-synology.test.ts @@ -396,6 +396,139 @@ describe('Synology search and albums', () => { }); }); +// ── Album listing — multi-source merge ─────────────────────────────────────── + +describe('Synology listSynologyAlbums multi-source merge', () => { + // Capture and restore the default safeFetch implementation around each test + // in this block so the persistent mockImplementation we set doesn't leak. + let _savedImpl: ((...args: any[]) => any) | undefined; + beforeEach(() => { _savedImpl = vi.mocked(safeFetch).getMockImplementation(); }); + afterEach(() => { if (_savedImpl) vi.mocked(safeFetch).mockImplementation(_savedImpl); }); + + it('SYNO-027 — personal-only: shared and shared-with-me return failure → merged result contains personal albums, no error', async () => { + const { user } = createUser(testDb); + setSynologyCredentials(testDb, user.id, 'https://synology.example.com', 'admin', 'pass'); + + vi.mocked(safeFetch).mockImplementation((_url: string, init?: any) => { + // Always read both URL params and body params; body takes precedence for request-specific fields. + const urlParams = (() => { try { return new URL(String(_url)).searchParams; } catch { return new URLSearchParams(); } })(); + const bodyParams: URLSearchParams = init?.body instanceof URLSearchParams ? init.body : new URLSearchParams(String(init?.body ?? '')); + const api = urlParams.get('api') || bodyParams.get('api') || ''; + const category = bodyParams.get('category') || urlParams.get('category'); + + if (api === 'SYNO.API.Auth') { + return Promise.resolve({ ok: true, status: 200, headers: { get: () => 'application/json' }, json: async () => ({ success: true, data: { sid: 'sid-027' } }), body: null } as any); + } + if (api === 'SYNO.Foto.Browse.Album') { + if (!category) { + // personal albums + return Promise.resolve({ ok: true, status: 200, headers: { get: () => 'application/json' }, json: async () => ({ success: true, data: { list: [{ id: 1, name: 'Personal Album', item_count: 5 }] } }), body: null } as any); + } + // shared category → failure + return Promise.resolve({ ok: true, status: 200, headers: { get: () => 'application/json' }, json: async () => ({ success: false, error: { code: 400 } }), body: null } as any); + } + if (api === 'SYNO.Foto.Sharing.Misc') { + return Promise.resolve({ ok: true, status: 200, headers: { get: () => 'application/json' }, json: async () => ({ success: false, error: { code: 400 } }), body: null } as any); + } + return Promise.reject(new Error(`Unexpected API: ${api}`)); + }); + + const res = await request(app) + .get(`${SYNO}/albums`) + .set('Cookie', authCookie(user.id)); + + expect(res.status).toBe(200); + expect(Array.isArray(res.body.albums)).toBe(true); + expect(res.body.albums).toHaveLength(1); + expect(res.body.albums[0]).toMatchObject({ albumName: 'Personal Album', assetCount: 5 }); + }); + + it('SYNO-028 — full merge: personal + shared (with passphrase) + shared-with-me (with sharing_info.passphrase) → 4 albums with correct passphrases', async () => { + const { user } = createUser(testDb); + setSynologyCredentials(testDb, user.id, 'https://synology.example.com', 'admin', 'pass'); + + vi.mocked(safeFetch).mockImplementation((_url: string, init?: any) => { + const urlParams = (() => { try { return new URL(String(_url)).searchParams; } catch { return new URLSearchParams(); } })(); + const bodyParams: URLSearchParams = init?.body instanceof URLSearchParams ? init.body : new URLSearchParams(String(init?.body ?? '')); + const api = urlParams.get('api') || bodyParams.get('api') || ''; + const category = bodyParams.get('category') || urlParams.get('category'); + + if (api === 'SYNO.API.Auth') { + return Promise.resolve({ ok: true, status: 200, headers: { get: () => 'application/json' }, json: async () => ({ success: true, data: { sid: 'sid-028' } }), body: null } as any); + } + if (api === 'SYNO.Foto.Browse.Album') { + if (!category) { + return Promise.resolve({ ok: true, status: 200, headers: { get: () => 'application/json' }, json: async () => ({ success: true, data: { list: [{ id: 10, name: 'Alpha Album', item_count: 3 }, { id: 11, name: 'Beta Album', item_count: 7 }] } }), body: null } as any); + } + // shared category — one album with passphrase + return Promise.resolve({ ok: true, status: 200, headers: { get: () => 'application/json' }, json: async () => ({ success: true, data: { list: [{ id: 20, name: 'Shared Out', item_count: 2, passphrase: 'pp-abc' }] } }), body: null } as any); + } + if (api === 'SYNO.Foto.Sharing.Misc') { + return Promise.resolve({ ok: true, status: 200, headers: { get: () => 'application/json' }, json: async () => ({ success: true, data: { list: [{ id: 30, name: 'Shared With Me', item_count: 4, sharing_info: { passphrase: 'pp-xyz' } }] } }), body: null } as any); + } + return Promise.reject(new Error(`Unexpected API: ${api}`)); + }); + + const res = await request(app) + .get(`${SYNO}/albums`) + .set('Cookie', authCookie(user.id)); + + expect(res.status).toBe(200); + expect(Array.isArray(res.body.albums)).toBe(true); + expect(res.body.albums).toHaveLength(4); + + const byName = (name: string) => res.body.albums.find((a: any) => a.albumName === name); + expect(byName('Alpha Album')).toMatchObject({ id: '10', assetCount: 3 }); + expect(byName('Beta Album')).toMatchObject({ id: '11', assetCount: 7 }); + expect(byName('Shared Out')).toMatchObject({ id: '20', passphrase: 'pp-abc' }); + expect(byName('Shared With Me')).toMatchObject({ id: '30', passphrase: 'pp-xyz' }); + + // personal albums carry no passphrase + expect(byName('Alpha Album').passphrase).toBeUndefined(); + }); + + it('SYNO-029 — dedup: same album id=99 in personal and shared-with-me → last-write-wins gives passphrase from shared-with-me', async () => { + const { user } = createUser(testDb); + setSynologyCredentials(testDb, user.id, 'https://synology.example.com', 'admin', 'pass'); + + vi.mocked(safeFetch).mockImplementation((_url: string, init?: any) => { + const urlParams = (() => { try { return new URL(String(_url)).searchParams; } catch { return new URLSearchParams(); } })(); + const bodyParams: URLSearchParams = init?.body instanceof URLSearchParams ? init.body : new URLSearchParams(String(init?.body ?? '')); + const api = urlParams.get('api') || bodyParams.get('api') || ''; + const category = bodyParams.get('category') || urlParams.get('category'); + + if (api === 'SYNO.API.Auth') { + return Promise.resolve({ ok: true, status: 200, headers: { get: () => 'application/json' }, json: async () => ({ success: true, data: { sid: 'sid-029' } }), body: null } as any); + } + if (api === 'SYNO.Foto.Browse.Album') { + if (!category) { + // personal: album id=99 without passphrase + return Promise.resolve({ ok: true, status: 200, headers: { get: () => 'application/json' }, json: async () => ({ success: true, data: { list: [{ id: 99, name: 'Dup Album', item_count: 10 }] } }), body: null } as any); + } + // shared: no entries + return Promise.resolve({ ok: true, status: 200, headers: { get: () => 'application/json' }, json: async () => ({ success: true, data: { list: [] } }), body: null } as any); + } + if (api === 'SYNO.Foto.Sharing.Misc') { + // shared-with-me: same album id=99 with passphrase + return Promise.resolve({ ok: true, status: 200, headers: { get: () => 'application/json' }, json: async () => ({ success: true, data: { list: [{ id: 99, name: 'Dup Album', item_count: 10, passphrase: 'pp-dup' }] } }), body: null } as any); + } + return Promise.reject(new Error(`Unexpected API: ${api}`)); + }); + + const res = await request(app) + .get(`${SYNO}/albums`) + .set('Cookie', authCookie(user.id)); + + expect(res.status).toBe(200); + expect(Array.isArray(res.body.albums)).toBe(true); + // Deduplicated to a single album + expect(res.body.albums).toHaveLength(1); + expect(res.body.albums[0]).toMatchObject({ id: '99', albumName: 'Dup Album' }); + // shared-with-me wins (last write) → passphrase present + expect(res.body.albums[0].passphrase).toBe('pp-dup'); + }); +}); + // ── Asset access ────────────────────────────────────────────────────────────── describe('Synology asset access', () => { @@ -691,8 +824,9 @@ describe('Synology session retry on error codes 106/107/119', () => { expect(res.status).toBe(200); expect(Array.isArray(res.body.albums)).toBe(true); expect(res.body.albums[0]).toMatchObject({ albumName: 'Retry Album' }); - // Four safeFetch calls: login, failed album list, re-login, successful album list - expect(vi.mocked(safeFetch)).toHaveBeenCalledTimes(4); + // Five safeFetch calls: login, failed album list (119), re-login, successful album list retry, + // plus one additional call for the shared or shared-with-me source (handled by default mock) + expect(vi.mocked(safeFetch)).toHaveBeenCalledTimes(5); }); it('SYNO-061 — request retries with fresh session when API returns error code 106', async () => { @@ -735,7 +869,9 @@ describe('Synology session retry on error codes 106/107/119', () => { expect(res.status).toBe(200); expect(res.body.albums[0]).toMatchObject({ albumName: 'Timeout Album' }); - expect(vi.mocked(safeFetch)).toHaveBeenCalledTimes(4); + // Five safeFetch calls: login, failed album list (106), re-login, successful album list retry, + // plus one additional call for the shared or shared-with-me source (handled by default mock) + expect(vi.mocked(safeFetch)).toHaveBeenCalledTimes(5); }); }); @@ -942,13 +1078,15 @@ describe('Synology SSRF blocked error handling', () => { expect(res.body.connected).toBe(false); }); - it('SYNO-081 — safeFetch throwing SsrfBlockedError during album list returns 400', async () => { + it('SYNO-081 — safeFetch throwing SsrfBlockedError during one album source is swallowed; other sources still return albums', async () => { const { user } = createUser(testDb); setSynologyCredentials(testDb, user.id, 'https://synology.example.com', 'admin', 'pass'); const { SsrfBlockedError: SsrfErr } = await import('../../src/utils/ssrfGuard'); - // Auth succeeds, but the album-list call throws SsrfBlockedError + // Auth succeeds, but the first album-list call throws SsrfBlockedError. + // The other two parallel album sources fall through to the default mock and succeed. + // listSynologyAlbums uses Promise.allSettled so a partial failure is logged and skipped. vi.mocked(safeFetch) .mockResolvedValueOnce({ ok: true, status: 200, @@ -962,8 +1100,9 @@ describe('Synology SSRF blocked error handling', () => { .get(`${SYNO}/albums`) .set('Cookie', authCookie(user.id)); - // _fetchSynologyJson catches SsrfBlockedError and returns fail(message, 400) - expect(res.status).toBe(400); - expect(res.body.error).toBeDefined(); + // The personal album source failed, but the other sources succeeded via the default mock. + // listSynologyAlbums is resilient: partial failure is logged, remaining albums returned. + expect(res.status).toBe(200); + expect(Array.isArray(res.body.albums)).toBe(true); }); }); From 129dfabaa3d5b710387cee6205323c8fa9d47255 Mon Sep 17 00:00:00 2001 From: jubnl Date: Thu, 16 Apr 2026 20:05:18 +0200 Subject: [PATCH 3/4] feat(synology): persist and use passphrase for shared album photo streaming (#689-4) - syncSynologyAlbumLink now uses getAlbumLinkForSync to read the stored passphrase and passes it in the SYNO.Foto.Browse.Item call when present, falling back to album_id for links without a passphrase. - Selection type gains optional passphrase field; addTripPhotos and _addTripPhoto thread it through to getOrCreateTrekPhoto. - getOrCreateTrekPhoto accepts an optional passphrase (4th param) and encrypts it when inserting a new trek_photos row; backfills existing rows that lack a passphrase. - streamPhoto and getPhotoInfo decrypt the stored passphrase from trek_photos and forward it to streamSynologyAsset / getSynologyAssetInfo so shared-album photos resolve correctly at access time. - Add SYNO-054 integration test covering the passphrase sync-and-persist path end-to-end. --- .../src/services/memories/helpersService.ts | 1 + .../services/memories/photoResolverService.ts | 20 ++++-- .../src/services/memories/synologyService.ts | 51 +++++++-------- .../src/services/memories/unifiedService.ts | 14 ++-- .../integration/memories-synology.test.ts | 65 +++++++++++++++++++ 5 files changed, 114 insertions(+), 37 deletions(-) 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 ─────────────────────────────────────────────────────── From bdb6b01765557a36a66a04cb1525259b08c08846 Mon Sep 17 00:00:00 2001 From: jubnl Date: Thu, 16 Apr 2026 20:54:35 +0200 Subject: [PATCH 4/4] fix(synology): paginate all three album sources past 100 albums and tighten targetUserId type - Extract _fetchAllSynologyAlbums helper that loops until the source is exhausted; listSynologyAlbums now uses it for personal, shared-out, and shared-with-me instead of a hard-capped single request of 100 - Make getSynologyAssetInfo targetUserId required (number, not number|undefined) to match every call site and eliminate an implicit any at the _requestSynologyApi boundary --- .../src/components/Memories/MemoriesPanel.tsx | 7 ++-- client/src/pages/JourneyDetailPage.tsx | 14 ++++--- server/src/db/migrations.ts | 5 +++ server/src/routes/memories/synology.ts | 9 +++-- server/src/routes/memories/unified.ts | 3 +- .../src/services/memories/synologyService.ts | 40 +++++++++++-------- server/src/types.ts | 1 + .../integration/memories-synology.test.ts | 20 +++++++--- 8 files changed, 63 insertions(+), 36 deletions(-) diff --git a/client/src/components/Memories/MemoriesPanel.tsx b/client/src/components/Memories/MemoriesPanel.tsx index 72c79f18..f2427ee6 100644 --- a/client/src/components/Memories/MemoriesPanel.tsx +++ b/client/src/components/Memories/MemoriesPanel.tsx @@ -85,7 +85,7 @@ export default function MemoriesPanel({ tripId, startDate, endDate }: MemoriesPa // Album linking const [showAlbumPicker, setShowAlbumPicker] = useState(false) - const [albums, setAlbums] = useState<{ id: string; albumName: string; assetCount: number }[]>([]) + const [albums, setAlbums] = useState<{ id: string; albumName: string; assetCount: number; passphrase?: string }[]>([]) const [albumsLoading, setAlbumsLoading] = useState(false) const [albumLinks, setAlbumLinks] = useState<{ id: number; provider: string; album_id: string; album_name: string; user_id: number; username: string; sync_enabled: number; last_synced_at: string | null }[]>([]) const [syncing, setSyncing] = useState(null) @@ -141,7 +141,7 @@ export default function MemoriesPanel({ tripId, startDate, endDate }: MemoriesPa await loadAlbums(selectedProvider) } - const linkAlbum = async (albumId: string, albumName: string) => { + const linkAlbum = async (albumId: string, albumName: string, passphrase?: string) => { if (!selectedProvider) { toast.error(t('memories.error.linkAlbum')) return @@ -152,6 +152,7 @@ export default function MemoriesPanel({ tripId, startDate, endDate }: MemoriesPa album_id: albumId, album_name: albumName, provider: selectedProvider, + ...(passphrase ? { passphrase } : {}), }) setShowAlbumPicker(false) await loadAlbumLinks() @@ -489,7 +490,7 @@ export default function MemoriesPanel({ tripId, startDate, endDate }: MemoriesPa {albums.map(album => { const isLinked = linkedIds.has(album.id) return ( -