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.
This commit is contained in:
jubnl
2026-04-16 20:05:18 +02:00
parent 8a6d1b2aaf
commit 129dfabaa3
5 changed files with 114 additions and 37 deletions
@@ -43,6 +43,7 @@ export function handleServiceResult<T>(res: Response, result: ServiceResult<T>):
export type Selection = { export type Selection = {
provider: string; provider: string;
asset_ids: string[]; asset_ids: string[];
passphrase?: string;
}; };
export type StatusResult = { export type StatusResult = {
@@ -7,6 +7,7 @@ import { streamImmichAsset, getAssetInfo as getImmichAssetInfo } from './immichS
import { streamSynologyAsset, getSynologyAssetInfo } from './synologyService'; import { streamSynologyAsset, getSynologyAssetInfo } from './synologyService';
import type { ServiceResult, AssetInfo } from './helpersService'; import type { ServiceResult, AssetInfo } from './helpersService';
import { fail, success } from './helpersService'; import { fail, success } from './helpersService';
import { encrypt_api_key, decrypt_api_key } from '../apiKeyCrypto';
// ── Lookup / Register ──────────────────────────────────────────────────── // ── Lookup / Register ────────────────────────────────────────────────────
@@ -14,15 +15,22 @@ export function getOrCreateTrekPhoto(
provider: string, provider: string,
assetId: string, assetId: string,
ownerId: number, ownerId: number,
passphrase?: string,
): number { ): number {
const existing = db.prepare( const existing = db.prepare(
'SELECT id FROM trek_photos WHERE provider = ? AND asset_id = ? AND owner_id = ?' 'SELECT id FROM trek_photos WHERE provider = ? AND asset_id = ? AND owner_id = ?'
).get(provider, assetId, ownerId) as { id: number } | undefined; ).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( const res = db.prepare(
'INSERT INTO trek_photos (provider, asset_id, owner_id) VALUES (?, ?, ?)' 'INSERT INTO trek_photos (provider, asset_id, owner_id, passphrase) VALUES (?, ?, ?, ?)'
).run(provider, assetId, ownerId); ).run(provider, assetId, ownerId, passphrase ? encrypt_api_key(passphrase) : null);
return Number(res.lastInsertRowid); return Number(res.lastInsertRowid);
} }
@@ -77,7 +85,8 @@ export async function streamPhoto(
return; return;
} }
case 'synologyphotos': { 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; return;
} }
default: default:
@@ -112,7 +121,8 @@ export async function getPhotoInfo(
return success(result.data as AssetInfo); return success(result.data as AssetInfo);
} }
case 'synologyphotos': { 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: default:
return fail(`Unknown provider: ${photo.provider}`, 400); return fail(`Unknown provider: ${photo.provider}`, 400);
+25 -26
View File
@@ -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 { safeFetch, SsrfBlockedError, checkSsrf } from '../../utils/ssrfGuard';
import { addTripPhotos } from './unifiedService'; import { addTripPhotos } from './unifiedService';
import { import {
getAlbumIdFromLink, getAlbumLinkForSync,
updateSyncTimeForAlbumLink, updateSyncTimeForAlbumLink,
Selection, Selection,
ServiceResult, ServiceResult,
@@ -476,21 +476,16 @@ export async function listSynologyAlbums(userId: number): Promise<ServiceResult<
} }
export async function getSynologyAlbumPhotos(userId: number, albumId: string): Promise<ServiceResult<AssetsList>> { export async function getSynologyAlbumPhotos(userId: number, albumId: string, passphrase?: string): Promise<ServiceResult<AssetsList>> {
const allItems: SynologyPhotoItem[] = []; const allItems: SynologyPhotoItem[] = [];
const pageSize = 1000; const pageSize = 1000;
let offset = 0; let offset = 0;
while (true) { while (true) {
const result = await _requestSynologyApi<{ list: SynologyPhotoItem[] }>(userId, { const params: ApiCallParams = passphrase
api: 'SYNO.Foto.Browse.Item', ? { api: 'SYNO.Foto.Browse.Item', method: 'list', version: 1, passphrase, offset, limit: pageSize, additional: ['thumbnail'] }
method: 'list', : { api: 'SYNO.Foto.Browse.Item', method: 'list', version: 1, album_id: Number(albumId), offset, limit: pageSize, additional: ['thumbnail'] };
version: 1, const result = await _requestSynologyApi<{ list: SynologyPhotoItem[] }>(userId, params);
album_id: Number(albumId),
offset,
limit: pageSize,
additional: ['thumbnail'],
});
if (!result.success) return result as ServiceResult<AssetsList>; if (!result.success) return result as ServiceResult<AssetsList>;
const items = result.data.list || []; const items = result.data.list || [];
allItems.push(...items); 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<ServiceResult<SyncAlbumResult>> { export async function syncSynologyAlbumLink(userId: number, tripId: string, linkId: string, sid: string): Promise<ServiceResult<SyncAlbumResult>> {
const response = getAlbumIdFromLink(tripId, linkId, userId); const response = getAlbumLinkForSync(tripId, linkId, userId);
if (!response.success) return response as ServiceResult<SyncAlbumResult>; if (!response.success) return response as ServiceResult<SyncAlbumResult>;
const { albumId, passphrase } = response.data;
const allItems: SynologyPhotoItem[] = []; const allItems: SynologyPhotoItem[] = [];
const pageSize = 1000; const pageSize = 1000;
let offset = 0; let offset = 0;
while (true) { while (true) {
const result = await _requestSynologyApi<{ list: SynologyPhotoItem[] }>(userId, { const itemParams: ApiCallParams = passphrase
api: 'SYNO.Foto.Browse.Item', ? { api: 'SYNO.Foto.Browse.Item', method: 'list', version: 1, passphrase, offset, limit: pageSize, additional: ['thumbnail'] }
method: 'list', : { api: 'SYNO.Foto.Browse.Item', method: 'list', version: 1, album_id: Number(albumId), offset, limit: pageSize, additional: ['thumbnail'] };
version: 1,
album_id: Number(response.data), const result = await _requestSynologyApi<{ list: SynologyPhotoItem[] }>(userId, itemParams);
offset,
limit: pageSize,
additional: ['thumbnail'],
});
if (!result.success) return result as ServiceResult<SyncAlbumResult>; if (!result.success) return result as ServiceResult<SyncAlbumResult>;
@@ -536,9 +529,9 @@ export async function syncSynologyAlbumLink(userId: number, tripId: string, link
const selection: Selection = { const selection: Selection = {
provider: SYNOLOGY_PROVIDER, provider: SYNOLOGY_PROVIDER,
asset_ids: allItems.map(item => String(item.additional?.thumbnail?.cache_key || '')).filter(id => id), 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); const result = await addTripPhotos(tripId, userId, true, [selection], sid, linkId);
if (!result.success) return result as ServiceResult<SyncAlbumResult>; if (!result.success) return result as ServiceResult<SyncAlbumResult>;
@@ -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<ServiceResult<AssetInfo>> { export async function getSynologyAssetInfo(userId: number, photoId: string, targetUserId?: number, passphrase?: string): Promise<ServiceResult<AssetInfo>> {
const parsedId = _splitPackedSynologyId(photoId); const parsedId = _splitPackedSynologyId(photoId);
if (!parsedId) return fail('Invalid photo ID format', 400); if (!parsedId) return fail('Invalid photo ID format', 400);
const result = await _requestSynologyApi<{ list: SynologyPhotoItem[] }>(targetUserId, { const infoParams: ApiCallParams = {
api: 'SYNO.Foto.Browse.Item', api: 'SYNO.Foto.Browse.Item',
method: 'get', method: 'get',
version: 5, version: 5,
id: `[${Number(parsedId.id) + 1}]`, //for some reason synology wants id moved by one to get image info 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'], 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<AssetInfo>; if (!result.success) return result as ServiceResult<AssetInfo>;
@@ -609,6 +604,8 @@ export async function streamSynologyAsset(
targetUserId: number, targetUserId: number,
photoId: string, photoId: string,
kind: 'thumbnail' | 'original', kind: 'thumbnail' | 'original',
size?: string,
passphrase?: string,
): Promise<void> { ): Promise<void> {
const parsedId = _splitPackedSynologyId(photoId); const parsedId = _splitPackedSynologyId(photoId);
if (!parsedId) { if (!parsedId) {
@@ -634,6 +631,7 @@ export async function streamSynologyAsset(
//size: 'sm' 240px| 'm' 320px| 'xl' 1280px| 'preview' ? //size: 'sm' 240px| 'm' 320px| 'xl' 1280px| 'preview' ?
const resolvedSize = size || 'sm';
const params = kind === 'thumbnail' const params = kind === 'thumbnail'
? new URLSearchParams({ ? new URLSearchParams({
api: 'SYNO.Foto.Thumbnail', api: 'SYNO.Foto.Thumbnail',
@@ -642,7 +640,7 @@ export async function streamSynologyAsset(
mode: 'download', mode: 'download',
id: parsedId.id, id: parsedId.id,
type: 'unit', type: 'unit',
size: 'sm', size: resolvedSize,
cache_key: parsedId.cacheKey, cache_key: parsedId.cacheKey,
_sid: sid.data, _sid: sid.data,
}) })
@@ -654,6 +652,7 @@ export async function streamSynologyAsset(
unit_id: `[${parsedId.id}]`, unit_id: `[${parsedId.id}]`,
_sid: sid.data, _sid: sid.data,
}); });
if (passphrase) params.append('passphrase', passphrase);
const url = _buildSynologyEndpoint(synology_credentials.data.synology_url, params.toString()); const url = _buildSynologyEndpoint(synology_credentials.data.synology_url, params.toString());
await pipeAsset(url, response) await pipeAsset(url, response)
@@ -9,6 +9,7 @@ import {
Selection, Selection,
} from './helpersService'; } from './helpersService';
import { getOrCreateTrekPhoto } from './photoResolverService'; import { getOrCreateTrekPhoto } from './photoResolverService';
import { encrypt_api_key } from '../apiKeyCrypto';
function _providers(): Array<{id: string; enabled: boolean}> { function _providers(): Array<{id: string; enabled: boolean}> {
@@ -104,13 +105,13 @@ export function listTripAlbumLinks(tripId: string, userId: number): ServiceResul
//----------------------------------------------- //-----------------------------------------------
// managing photos in trip // managing photos in trip
function _addTripPhoto(tripId: string, userId: number, provider: string, assetId: string, shared: boolean, albumLinkId?: string): ServiceResult<boolean> { function _addTripPhoto(tripId: string, userId: number, provider: string, assetId: string, shared: boolean, albumLinkId?: string, passphrase?: string): ServiceResult<boolean> {
const providerResult = _validProvider(provider); const providerResult = _validProvider(provider);
if (!providerResult.success) { if (!providerResult.success) {
return providerResult as ServiceResult<boolean>; return providerResult as ServiceResult<boolean>;
} }
try { try {
const photoId = getOrCreateTrekPhoto(provider, assetId, userId); const photoId = getOrCreateTrekPhoto(provider, assetId, userId, passphrase);
const result = db.prepare( const result = db.prepare(
'INSERT OR IGNORE INTO trip_photos (trip_id, user_id, photo_id, shared, album_link_id) VALUES (?, ?, ?, ?, ?)' '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); ).run(tripId, userId, photoId, shared ? 1 : 0, albumLinkId || null);
@@ -147,7 +148,7 @@ export async function addTripPhotos(
for (const raw of selection.asset_ids) { for (const raw of selection.asset_ids) {
const assetId = String(raw || '').trim(); const assetId = String(raw || '').trim();
if (!assetId) continue; 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) { if (!result.success) {
return result as ServiceResult<{ added: number; shared: boolean }>; return result as ServiceResult<{ added: number; shared: boolean }>;
} }
@@ -222,7 +223,7 @@ export function removeTripPhoto(
// ---------------------------------------------- // ----------------------------------------------
// managing album links in trip // managing album links in trip
export function createTripAlbumLink(tripId: string, userId: number, providerRaw: unknown, albumIdRaw: unknown, albumNameRaw: unknown): ServiceResult<true> { export function createTripAlbumLink(tripId: string, userId: number, providerRaw: unknown, albumIdRaw: unknown, albumNameRaw: unknown, passphrase?: string): ServiceResult<true> {
const access = canAccessTrip(tripId, userId); const access = canAccessTrip(tripId, userId);
if (!access) { if (!access) {
return fail('Trip not found or access denied', 404); return fail('Trip not found or access denied', 404);
@@ -246,9 +247,10 @@ export function createTripAlbumLink(tripId: string, userId: number, providerRaw:
} }
try { try {
const encryptedPassphrase = passphrase ? encrypt_api_key(passphrase) : null;
const result = db.prepare( const result = db.prepare(
'INSERT OR IGNORE INTO trip_album_links (trip_id, user_id, provider, album_id, album_name) VALUES (?, ?, ?, ?, ?)' 'INSERT OR IGNORE INTO trip_album_links (trip_id, user_id, provider, album_id, album_name, passphrase) VALUES (?, ?, ?, ?, ?, ?)'
).run(tripId, userId, provider, albumId, albumName); ).run(tripId, userId, provider, albumId, albumName, encryptedPassphrase);
if (result.changes === 0) { if (result.changes === 0) {
return fail('Album already linked', 409); return fail('Album already linked', 409);
@@ -704,6 +704,7 @@ describe('Synology auth checks', () => {
// ── Album sync ──────────────────────────────────────────────────────────────── // ── Album sync ────────────────────────────────────────────────────────────────
import { addAlbumLink } from '../helpers/factories'; import { addAlbumLink } from '../helpers/factories';
import { encrypt_api_key } from '../../src/services/apiKeyCrypto';
describe('Synology syncSynologyAlbumLink', () => { describe('Synology syncSynologyAlbumLink', () => {
it('SYNO-050 — POST sync happy path: trip owner with album link saves photos to DB', async () => { 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 () => { 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); 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 ─────────────────────────────────────────────────────── // ── Session retry logic ───────────────────────────────────────────────────────