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); }); });