From 68f0d399ca7560fa4a6478012c59cd7d7280c9f5 Mon Sep 17 00:00:00 2001 From: Marek Maslowski Date: Sat, 4 Apr 2026 12:22:22 +0200 Subject: [PATCH 1/9] adding helper functions for syncing albums --- server/src/routes/memories.ts | 10 +++-- server/src/services/immichService.ts | 25 +++++++------ server/src/services/memoriesService.ts | 52 +++++++++++++++++--------- server/src/services/synologyService.ts | 33 +++++++--------- 4 files changed, 68 insertions(+), 52 deletions(-) diff --git a/server/src/routes/memories.ts b/server/src/routes/memories.ts index 5a6f1cf..1b1fac3 100644 --- a/server/src/routes/memories.ts +++ b/server/src/routes/memories.ts @@ -11,6 +11,7 @@ import { removeTripPhoto, setTripPhotoSharing, notifySharedTripPhotos, + normalizeSelections, } from '../services/memoriesService'; const router = express.Router(); @@ -44,13 +45,14 @@ router.delete('/trips/:tripId/album-links/:linkId', authenticate, (req: Request, router.post('/trips/:tripId/photos', authenticate, (req: Request, res: Response) => { const authReq = req as AuthRequest; const { tripId } = req.params; + + const selections = normalizeSelections(req.body?.selections, req.body?.provider, req.body?.asset_ids); + const shared = req.body?.shared === undefined ? true : !!req.body?.shared; const result = addTripPhotos( tripId, authReq.user.id, - req.body?.shared, - req.body?.selections, - req.body?.provider, - req.body?.asset_ids, + shared, + selections, ); if ('error' in result) return res.status(result.status).json({ error: result.error }); diff --git a/server/src/services/immichService.ts b/server/src/services/immichService.ts index baef3bb..4550b2a 100644 --- a/server/src/services/immichService.ts +++ b/server/src/services/immichService.ts @@ -2,6 +2,7 @@ import { db } from '../db/database'; import { maybe_encrypt_api_key, decrypt_api_key } from './apiKeyCrypto'; import { checkSsrf } from '../utils/ssrfGuard'; import { writeAudit } from './auditLog'; +import { addTripPhotos, getAlbumIdFromLink, Selection, updateSyncTimeForAlbumLink } from './memoriesService'; // ── Credentials ──────────────────────────────────────────────────────────── @@ -313,15 +314,14 @@ export async function syncAlbumAssets( linkId: string, userId: number ): Promise<{ success?: boolean; added?: number; total?: number; error?: string; status?: number }> { - const link = db.prepare('SELECT * FROM trip_album_links WHERE id = ? AND trip_id = ? AND user_id = ? AND provider = ?') - .get(linkId, tripId, userId, 'immich') as any; - if (!link) return { error: 'Album link not found', status: 404 }; + const albumId = getAlbumIdFromLink(tripId, linkId, userId); + if (!albumId) return { error: 'Album link not found', status: 404 }; const creds = getImmichCredentials(userId); if (!creds) return { error: 'Immich not configured', status: 400 }; try { - const resp = await fetch(`${creds.immich_url}/api/albums/${link.album_id}`, { + const resp = await fetch(`${creds.immich_url}/api/albums/${albumId}`, { headers: { 'x-api-key': creds.immich_api_key, 'Accept': 'application/json' }, signal: AbortSignal.timeout(15000), }); @@ -329,16 +329,17 @@ export async function syncAlbumAssets( const albumData = await resp.json() as { assets?: any[] }; const assets = (albumData.assets || []).filter((a: any) => a.type === 'IMAGE'); - const insert = db.prepare("INSERT OR IGNORE INTO trip_photos (trip_id, user_id, asset_id, provider, shared) VALUES (?, ?, ?, 'immich', 1)"); - let added = 0; - for (const asset of assets) { - const r = insert.run(tripId, userId, asset.id); - if (r.changes > 0) added++; - } + const selection: Selection = { + provider: 'immich', + asset_ids: assets.map((a: any) => a.id), + }; - db.prepare('UPDATE trip_album_links SET last_synced_at = CURRENT_TIMESTAMP WHERE id = ?').run(linkId); + const addResult = addTripPhotos(tripId, userId, true, [selection]); + if ('error' in addResult) return { error: addResult.error, status: addResult.status }; - return { success: true, added, total: assets.length }; + updateSyncTimeForAlbumLink(linkId); + + return { success: true, added: addResult.added, total: assets.length }; } catch { return { error: 'Could not reach Immich', status: 502 }; } diff --git a/server/src/services/memoriesService.ts b/server/src/services/memoriesService.ts index 3886c8d..0470559 100644 --- a/server/src/services/memoriesService.ts +++ b/server/src/services/memoriesService.ts @@ -38,12 +38,14 @@ function accessDeniedIfMissing(tripId: string, userId: number): ServiceError | n return null; } -type Selection = { +export type Selection = { provider: string; - asset_ids: unknown[]; + asset_ids: string[]; }; -function normalizeSelections(selectionsRaw: unknown, providerRaw: unknown, assetIdsRaw: unknown): Selection[] { + +//fallback for old clients that don't send selections as an array of provider/asset_id groups +export function normalizeSelections(selectionsRaw: unknown, providerRaw: unknown, assetIdsRaw: unknown): Selection[] { const selectionsFromBody = Array.isArray(selectionsRaw) ? selectionsRaw : null; const provider = String(providerRaw || '').toLowerCase(); @@ -145,40 +147,54 @@ export function removeAlbumLink(tripId: string, linkId: string, userId: number): return { success: true }; } +function addTripPhoto(tripId: string, userId: number, provider: string, assetId: string, shared: boolean): boolean { + const result = db.prepare( + 'INSERT OR IGNORE INTO trip_photos (trip_id, user_id, asset_id, provider, shared) VALUES (?, ?, ?, ?, ?)' + ).run(tripId, userId, assetId, provider, shared ? 1 : 0); + return result.changes > 0; +} + export function addTripPhotos( tripId: string, userId: number, - sharedRaw: unknown, - selectionsRaw: unknown, - providerRaw: unknown, - assetIdsRaw: unknown, + shared: boolean, + selections: Selection[], ): { success: true; added: number; shared: boolean } | ServiceError { const denied = accessDeniedIfMissing(tripId, userId); if (denied) return denied; - const shared = sharedRaw === undefined ? true : !!sharedRaw; - const selections = normalizeSelections(selectionsRaw, providerRaw, assetIdsRaw); if (selections.length === 0) { - return { error: 'selections required', status: 400 }; + return { error: 'No photos selected', status: 400 }; } - const insert = db.prepare( - 'INSERT OR IGNORE INTO trip_photos (trip_id, user_id, asset_id, provider, shared) VALUES (?, ?, ?, ?, ?)' - ); - let added = 0; for (const selection of selections) { for (const raw of selection.asset_ids) { const assetId = String(raw || '').trim(); if (!assetId) continue; - const result = insert.run(tripId, userId, assetId, selection.provider, shared ? 1 : 0); - if (result.changes > 0) added++; + if (addTripPhoto(tripId, userId, selection.provider, assetId, shared)) { + added++; + } } } - return { success: true, added, shared }; } +export function getAlbumIdFromLink(tripId: string, linkId: string, userId: number): string { + const denied = accessDeniedIfMissing(tripId, userId); + if (denied) return null; + + const { album_id } = 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 album_id; +} + +export function updateSyncTimeForAlbumLink(linkId: string): void { + db.prepare('UPDATE trip_album_links SET last_synced_at = CURRENT_TIMESTAMP WHERE id = ?').run(linkId); +} + + + export function removeTripPhoto( tripId: string, userId: number, @@ -256,3 +272,5 @@ export async function notifySharedTripPhotos( count: String(added), }); } + + diff --git a/server/src/services/synologyService.ts b/server/src/services/synologyService.ts index 0cb3308..7a01c1c 100644 --- a/server/src/services/synologyService.ts +++ b/server/src/services/synologyService.ts @@ -1,9 +1,11 @@ import { Readable } from 'node:stream'; import { pipeline } from 'node:stream/promises'; -import { Request, Response as ExpressResponse } from 'express'; +import { Response as ExpressResponse } from 'express'; import { db } from '../db/database'; import { decrypt_api_key, maybe_encrypt_api_key } from './apiKeyCrypto'; import { checkSsrf } from '../utils/ssrfGuard'; +import { addTripPhotos, getAlbumIdFromLink, Selection, updateSyncTimeForAlbumLink } from './memoriesService'; +import { error } from 'node:console'; const SYNOLOGY_API_TIMEOUT_MS = 30000; const SYNOLOGY_PROVIDER = 'synologyphotos'; @@ -401,10 +403,8 @@ export async function listSynologyAlbums(userId: number): Promise<{ albums: Arra export async function syncSynologyAlbumLink(userId: number, tripId: string, linkId: string): Promise<{ added: number; total: number }> { - const link = db.prepare(`SELECT * FROM trip_album_links WHERE id = ? AND trip_id = ? AND user_id = ? AND provider = ?`) - .get(linkId, tripId, userId, SYNOLOGY_PROVIDER) as { album_id?: string | number } | undefined; - - if (!link) { + const albumId = getAlbumIdFromLink(tripId, linkId, userId); + if (!albumId) { throw new SynologyServiceError(404, 'Album link not found'); } @@ -417,7 +417,7 @@ export async function syncSynologyAlbumLink(userId: number, tripId: string, link api: 'SYNO.Foto.Browse.Item', method: 'list', version: 1, - album_id: Number(link.album_id), + album_id: Number(albumId), offset, limit: pageSize, additional: ['thumbnail'], @@ -433,22 +433,17 @@ export async function syncSynologyAlbumLink(userId: number, tripId: string, link offset += pageSize; } - const insert = db.prepare( - "INSERT OR IGNORE INTO trip_photos (trip_id, user_id, asset_id, provider, shared) VALUES (?, ?, ?, 'synologyphotos', 1)" - ); + const selection: Selection = { + provider: SYNOLOGY_PROVIDER, + asset_ids: allItems.map(item => String(item.additional?.thumbnail?.cache_key || '')).filter(id => id), + }; - let added = 0; - for (const item of allItems) { - const transformed = normalizeSynologyPhotoInfo(item); - const assetId = String(transformed?.id || '').trim(); - if (!assetId) continue; - const result = insert.run(tripId, userId, assetId); - if (result.changes > 0) added++; - } + const addResult = addTripPhotos(tripId, userId, true, [selection]); + if ('error' in addResult) throw new SynologyServiceError(addResult.status, addResult.error); - db.prepare('UPDATE trip_album_links SET last_synced_at = CURRENT_TIMESTAMP WHERE id = ?').run(linkId); + updateSyncTimeForAlbumLink(linkId); - return { added, total: allItems.length }; + return { added: addResult.added, total: allItems.length }; } export async function searchSynologyPhotos(userId: number, from?: string, to?: string, offset = 0, limit = 300): Promise<{ assets: SynologyPhotoInfo[]; total: number; hasMore: boolean }> { From 504713d920eb5f8b78fab3a4230409b23786e23f Mon Sep 17 00:00:00 2001 From: Marek Maslowski Date: Sat, 4 Apr 2026 13:36:12 +0200 Subject: [PATCH 2/9] change in hadnling return values from unified service --- server/src/routes/memories.ts | 96 +++--- server/src/services/immichService.ts | 13 +- server/src/services/memoriesService.ts | 450 +++++++++++++------------ server/src/services/synologyService.ts | 15 +- 4 files changed, 300 insertions(+), 274 deletions(-) diff --git a/server/src/routes/memories.ts b/server/src/routes/memories.ts index 1b1fac3..e60b9e1 100644 --- a/server/src/routes/memories.ts +++ b/server/src/routes/memories.ts @@ -1,6 +1,5 @@ import express, { Request, Response } from 'express'; import { authenticate } from '../middleware/auth'; -import { broadcast } from '../websocket'; import { AuthRequest } from '../types'; import { listTripPhotos, @@ -10,94 +9,85 @@ import { addTripPhotos, removeTripPhoto, setTripPhotoSharing, - notifySharedTripPhotos, - normalizeSelections, } from '../services/memoriesService'; const router = express.Router(); +//------------------------------------------------ +// routes for managing photos linked to trip router.get('/trips/:tripId/photos', authenticate, (req: Request, res: Response) => { const authReq = req as AuthRequest; const { tripId } = req.params; const result = listTripPhotos(tripId, authReq.user.id); - if ('error' in result) return res.status(result.status).json({ error: result.error }); - res.json({ photos: result.photos }); + if ('error' in result) return res.status(result.error.status).json({ error: result.error.message }); + res.json({ photos: result.data }); }); -router.get('/trips/:tripId/album-links', authenticate, (req: Request, res: Response) => { - const authReq = req as AuthRequest; - const { tripId } = req.params; - const result = listTripAlbumLinks(tripId, authReq.user.id); - if ('error' in result) return res.status(result.status).json({ error: result.error }); - res.json({ links: result.links }); -}); - -router.delete('/trips/:tripId/album-links/:linkId', authenticate, (req: Request, res: Response) => { - const authReq = req as AuthRequest; - const { tripId, linkId } = req.params; - const result = removeAlbumLink(tripId, linkId, authReq.user.id); - if ('error' in result) return res.status(result.status).json({ error: result.error }); - res.json({ success: true }); - broadcast(tripId, 'memories:updated', { userId: authReq.user.id }, req.headers['x-socket-id'] as string); -}); - -router.post('/trips/:tripId/photos', authenticate, (req: Request, res: Response) => { +router.post('/trips/:tripId/photos', authenticate, async (req: Request, res: Response) => { const authReq = req as AuthRequest; const { tripId } = req.params; + const sid = req.headers['x-socket-id'] as string; - const selections = normalizeSelections(req.body?.selections, req.body?.provider, req.body?.asset_ids); const shared = req.body?.shared === undefined ? true : !!req.body?.shared; - const result = addTripPhotos( + const result = await addTripPhotos( tripId, authReq.user.id, shared, - selections, + req.body?.selections || [], + sid, ); - if ('error' in result) return res.status(result.status).json({ error: result.error }); + if ('error' in result) return res.status(result.error.status).json({ error: result.error.message }); - res.json({ success: true, added: result.added }); - broadcast(tripId, 'memories:updated', { userId: authReq.user.id }, req.headers['x-socket-id'] as string); - - if (result.shared && result.added > 0) { - void notifySharedTripPhotos( - tripId, - authReq.user.id, - authReq.user.username || authReq.user.email, - result.added, - ).catch(() => {}); - } + res.json({ success: true, added: result.data.added }); }); -router.delete('/trips/:tripId/photos', authenticate, (req: Request, res: Response) => { +router.put('/trips/:tripId/photos/sharing', authenticate, async (req: Request, res: Response) => { const authReq = req as AuthRequest; const { tripId } = req.params; - const result = removeTripPhoto(tripId, authReq.user.id, req.body?.provider, req.body?.asset_id); - if ('error' in result) return res.status(result.status).json({ error: result.error }); - res.json({ success: true }); - broadcast(tripId, 'memories:updated', { userId: authReq.user.id }, req.headers['x-socket-id'] as string); -}); - -router.put('/trips/:tripId/photos/sharing', authenticate, (req: Request, res: Response) => { - const authReq = req as AuthRequest; - const { tripId } = req.params; - const result = setTripPhotoSharing( + const result = await setTripPhotoSharing( tripId, authReq.user.id, req.body?.provider, req.body?.asset_id, req.body?.shared, ); - if ('error' in result) return res.status(result.status).json({ error: result.error }); + if ('error' in result) return res.status(result.error.status).json({ error: result.error.message }); res.json({ success: true }); - broadcast(tripId, 'memories:updated', { userId: authReq.user.id }, req.headers['x-socket-id'] as string); }); -router.post('/trips/:tripId/album-links', authenticate, (req: Request, res: Response) => { +router.delete('/trips/:tripId/photos', authenticate, async (req: Request, res: Response) => { + const authReq = req as AuthRequest; + const { tripId } = req.params; + const result = await removeTripPhoto(tripId, authReq.user.id, req.body?.provider, req.body?.asset_id); + if ('error' in result) return res.status(result.error.status).json({ error: result.error.message }); + res.json({ success: true }); +}); + +//------------------------------ +// routes for managing album links + +router.get('/trips/:tripId/album-links', authenticate, (req: Request, res: Response) => { + const authReq = req as AuthRequest; + const { tripId } = req.params; + const result = listTripAlbumLinks(tripId, authReq.user.id); + if ('error' in result) return res.status(result.error.status).json({ error: result.error.message }); + res.json({ links: result.data }); +}); + +router.post('/trips/:tripId/album-links', authenticate, async (req: Request, res: Response) => { const authReq = req as AuthRequest; const { tripId } = req.params; const result = createTripAlbumLink(tripId, authReq.user.id, req.body?.provider, req.body?.album_id, req.body?.album_name); - if ('error' in result) return res.status(result.status).json({ error: result.error }); + if ('error' in result) return res.status(result.error.status).json({ error: result.error.message }); + res.json({ success: true }); +}); + +router.delete('/trips/:tripId/album-links/:linkId', authenticate, async (req: Request, res: Response) => { + const authReq = req as AuthRequest; + const { tripId, linkId } = req.params; + const result = removeAlbumLink(tripId, linkId, authReq.user.id); + if ('error' in result) return res.status(result.error.status).json({ error: result.error.message }); res.json({ success: true }); }); diff --git a/server/src/services/immichService.ts b/server/src/services/immichService.ts index 4550b2a..d4a353b 100644 --- a/server/src/services/immichService.ts +++ b/server/src/services/immichService.ts @@ -3,6 +3,7 @@ import { maybe_encrypt_api_key, decrypt_api_key } from './apiKeyCrypto'; import { checkSsrf } from '../utils/ssrfGuard'; import { writeAudit } from './auditLog'; import { addTripPhotos, getAlbumIdFromLink, Selection, updateSyncTimeForAlbumLink } from './memoriesService'; +import { error } from 'node:console'; // ── Credentials ──────────────────────────────────────────────────────────── @@ -314,14 +315,14 @@ export async function syncAlbumAssets( linkId: string, userId: number ): Promise<{ success?: boolean; added?: number; total?: number; error?: string; status?: number }> { - const albumId = getAlbumIdFromLink(tripId, linkId, userId); - if (!albumId) return { error: 'Album link not found', status: 404 }; + 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 resp = await fetch(`${creds.immich_url}/api/albums/${albumId}`, { + const resp = await fetch(`${creds.immich_url}/api/albums/${response.data}`, { headers: { 'x-api-key': creds.immich_api_key, 'Accept': 'application/json' }, signal: AbortSignal.timeout(15000), }); @@ -334,12 +335,12 @@ export async function syncAlbumAssets( asset_ids: assets.map((a: any) => a.id), }; - const addResult = addTripPhotos(tripId, userId, true, [selection]); - if ('error' in addResult) return { error: addResult.error, status: addResult.status }; + const result = await addTripPhotos(tripId, userId, true, [selection]); + if ('error' in result) return { error: result.error.message, status: result.error.status }; updateSyncTimeForAlbumLink(linkId); - return { success: true, added: addResult.added, total: assets.length }; + return { success: true, added: result.data.added, total: assets.length }; } catch { return { error: 'Could not reach Immich', status: 502 }; } diff --git a/server/src/services/memoriesService.ts b/server/src/services/memoriesService.ts index 0470559..03ac465 100644 --- a/server/src/services/memoriesService.ts +++ b/server/src/services/memoriesService.ts @@ -1,14 +1,29 @@ import { db, canAccessTrip } from '../db/database'; import { notifyTripMembers } from './notifications'; - -type ServiceError = { error: string; status: number }; +import { broadcast } from '../websocket'; -/** - * Verify that requestingUserId can access a shared photo belonging to ownerUserId. - * The asset must be shared (shared=1) and the requesting user must be a member of - * the same trip that contains the photo. - */ +type ServiceError = { success: false; error: { message: string; status: number } }; +type ServiceResult = { success: true; data: T } | ServiceError; + +function fail(error: string, status: number): ServiceError { + return { success: false, error: { message: error, status }}; +} + +function success(data: T): ServiceResult { + return { success: true, data: data }; +} + +function mapDbError(error: unknown, fallbackMessage: string): ServiceError { + if (error instanceof Error && /unique|constraint/i.test(error.message)) { + return fail('Resource already exists', 409); + } + return fail(fallbackMessage, 500); +} + +//----------------------------------------------- +//access check helper + export function canAccessUserPhoto(requestingUserId: number, ownerUserId: number, tripId: string, assetId: string, provider: string): boolean { if (requestingUserId === ownerUserId) { return true; @@ -27,15 +42,70 @@ export function canAccessUserPhoto(requestingUserId: number, ownerUserId: number if (!sharedAsset) { return false; } - return !!canAccessTrip(String(tripId), requestingUserId); + return !!canAccessTrip(tripId, requestingUserId); } - -function accessDeniedIfMissing(tripId: string, userId: number): ServiceError | null { - if (!canAccessTrip(tripId, userId)) { - return { error: 'Trip not found', status: 404 }; +export function listTripPhotos(tripId: string, userId: number): ServiceResult { + const access = canAccessTrip(tripId, userId); + if (!access) { + return fail('Trip not found or access denied', 404); } - return null; + + try { + const photos = db.prepare(` + SELECT tp.asset_id, tp.provider, tp.user_id, tp.shared, tp.added_at, + u.username, u.avatar + FROM trip_photos tp + JOIN users u ON tp.user_id = u.id + WHERE tp.trip_id = ? + AND (tp.user_id = ? OR tp.shared = 1) + ORDER BY tp.added_at ASC + `).all(tripId, userId) as any[]; + + 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); + } + + 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 = ? + ORDER BY tal.created_at ASC + `).all(tripId); + + 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): boolean { + const result = db.prepare( + 'INSERT OR IGNORE INTO trip_photos (trip_id, user_id, asset_id, provider, shared) VALUES (?, ?, ?, ?, ?)' + ).run(tripId, userId, assetId, provider, shared ? 1 : 0); + return result.changes > 0; } export type Selection = { @@ -43,234 +113,198 @@ export type Selection = { asset_ids: string[]; }; - -//fallback for old clients that don't send selections as an array of provider/asset_id groups -export function normalizeSelections(selectionsRaw: unknown, providerRaw: unknown, assetIdsRaw: unknown): Selection[] { - const selectionsFromBody = Array.isArray(selectionsRaw) ? selectionsRaw : null; - const provider = String(providerRaw || '').toLowerCase(); - - if (selectionsFromBody && selectionsFromBody.length > 0) { - return selectionsFromBody - .map((selection: any) => ({ - provider: String(selection?.provider || '').toLowerCase(), - asset_ids: Array.isArray(selection?.asset_ids) ? selection.asset_ids : [], - })) - .filter((selection: Selection) => selection.provider && selection.asset_ids.length > 0); - } - - if (provider && Array.isArray(assetIdsRaw) && assetIdsRaw.length > 0) { - return [{ provider, asset_ids: assetIdsRaw }]; - } - - return []; -} - -export function listTripPhotos(tripId: string, userId: number): { photos: any[] } | ServiceError { - const denied = accessDeniedIfMissing(tripId, userId); - if (denied) return denied; - - const photos = db.prepare(` - SELECT tp.asset_id, tp.provider, tp.user_id, tp.shared, tp.added_at, - u.username, u.avatar - FROM trip_photos tp - JOIN users u ON tp.user_id = u.id - WHERE tp.trip_id = ? - AND (tp.user_id = ? OR tp.shared = 1) - ORDER BY tp.added_at ASC - `).all(tripId, userId) as any[]; - - return { photos }; -} - -export function listTripAlbumLinks(tripId: string, userId: number): { links: any[] } | ServiceError { - const denied = accessDeniedIfMissing(tripId, userId); - if (denied) return denied; - - 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 = ? - ORDER BY tal.created_at ASC - `).all(tripId); - - return { links }; -} - -export function createTripAlbumLink( +export async function addTripPhotos( tripId: string, userId: number, - providerRaw: unknown, - albumIdRaw: unknown, - albumNameRaw: unknown, -): { success: true } | ServiceError { - const denied = accessDeniedIfMissing(tripId, userId); - if (denied) return denied; + shared: boolean, + selections: Selection[], + sid?: 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); + } + + try { + let added = 0; + for (const selection of selections) { + for (const raw of selection.asset_ids) { + const assetId = String(raw || '').trim(); + if (!assetId) continue; + if (_addTripPhoto(tripId, userId, selection.provider, assetId, shared)) { + added++; + } + } + } + + await _notifySharedTripPhotos(tripId, userId, added); + broadcast(tripId, 'memories:updated', { userId }, sid); + return success({ added, shared }); + } catch (error) { + return mapDbError(error, 'Failed to add trip photos'); + } +} + + +export async function setTripPhotoSharing( + tripId: string, + userId: number, + provider: string, + assetId: string, + 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 asset_id = ? + AND provider = ? + `).run(shared ? 1 : 0, tripId, userId, assetId, provider); + + 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, + provider: string, + assetId: string, + 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 asset_id = ? + AND provider = ? + `).run(tripId, userId, assetId, provider); + + 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): 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 { error: 'provider is required', status: 400 }; + return fail('provider is required', 400); } if (!albumId) { - return { error: 'album_id required', status: 400 }; + return fail('album_id required', 400); } try { - db.prepare( + 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); - return { success: true }; - } catch { - return { error: 'Album already linked', status: 400 }; - } -} -export function removeAlbumLink(tripId: string, linkId: string, userId: number): { success: true } | ServiceError { - const denied = accessDeniedIfMissing(tripId, userId); - if (denied) return denied; - - db.prepare('DELETE FROM trip_album_links WHERE id = ? AND trip_id = ? AND user_id = ?') - .run(linkId, tripId, userId); - - return { success: true }; -} - -function addTripPhoto(tripId: string, userId: number, provider: string, assetId: string, shared: boolean): boolean { - const result = db.prepare( - 'INSERT OR IGNORE INTO trip_photos (trip_id, user_id, asset_id, provider, shared) VALUES (?, ?, ?, ?, ?)' - ).run(tripId, userId, assetId, provider, shared ? 1 : 0); - return result.changes > 0; -} - -export function addTripPhotos( - tripId: string, - userId: number, - shared: boolean, - selections: Selection[], -): { success: true; added: number; shared: boolean } | ServiceError { - const denied = accessDeniedIfMissing(tripId, userId); - if (denied) return denied; - - if (selections.length === 0) { - return { error: 'No photos selected', status: 400 }; - } - - let added = 0; - for (const selection of selections) { - for (const raw of selection.asset_ids) { - const assetId = String(raw || '').trim(); - if (!assetId) continue; - if (addTripPhoto(tripId, userId, selection.provider, assetId, shared)) { - added++; - } + if (result.changes === 0) { + return fail('Album already linked', 409); } + + return success(true); + } catch (error) { + return mapDbError(error, 'Failed to link album'); } - return { success: true, added, shared }; } -export function getAlbumIdFromLink(tripId: string, linkId: string, userId: number): string { - const denied = accessDeniedIfMissing(tripId, userId); - if (denied) return null; +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); + } - const { album_id } = 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; + try { + db.prepare('DELETE FROM trip_album_links WHERE id = ? AND trip_id = ? AND user_id = ?') + .run(linkId, tripId, userId); - return album_id; + return success(true); + } catch (error) { + return mapDbError(error, 'Failed to remove album link'); + } +} + +//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 updateSyncTimeForAlbumLink(linkId: string): void { db.prepare('UPDATE trip_album_links SET last_synced_at = CURRENT_TIMESTAMP WHERE id = ?').run(linkId); } +//----------------------------------------------- +// notifications helper - -export function removeTripPhoto( - tripId: string, - userId: number, - providerRaw: unknown, - assetIdRaw: unknown, -): { success: true } | ServiceError { - const assetId = String(assetIdRaw || ''); - const provider = String(providerRaw || '').toLowerCase(); - - if (!assetId) { - return { error: 'asset_id is required', status: 400 }; - } - if (!provider) { - return { error: 'provider is required', status: 400 }; - } - - const denied = accessDeniedIfMissing(tripId, userId); - if (denied) return denied; - - db.prepare(` - DELETE FROM trip_photos - WHERE trip_id = ? - AND user_id = ? - AND asset_id = ? - AND provider = ? - `).run(tripId, userId, assetId, provider); - - return { success: true }; -} - -export function setTripPhotoSharing( - tripId: string, - userId: number, - providerRaw: unknown, - assetIdRaw: unknown, - sharedRaw: unknown, -): { success: true } | ServiceError { - const assetId = String(assetIdRaw || ''); - const provider = String(providerRaw || '').toLowerCase(); - - if (!assetId) { - return { error: 'asset_id is required', status: 400 }; - } - if (!provider) { - return { error: 'provider is required', status: 400 }; - } - - const denied = accessDeniedIfMissing(tripId, userId); - if (denied) return denied; - - db.prepare(` - UPDATE trip_photos - SET shared = ? - WHERE trip_id = ? - AND user_id = ? - AND asset_id = ? - AND provider = ? - `).run(sharedRaw ? 1 : 0, tripId, userId, assetId, provider); - - return { success: true }; -} - -export async function notifySharedTripPhotos( +async function _notifySharedTripPhotos( tripId: string, actorUserId: number, - actorName: string, added: number, -): Promise { - if (added <= 0) return; +): Promise> { + if (added <= 0) return fail('No photos shared, skipping notifications', 200); - const tripInfo = db.prepare('SELECT title FROM trips WHERE id = ?').get(tripId) as { title: string } | undefined; - await notifyTripMembers(Number(tripId), actorUserId, 'photos_shared', { - trip: tripInfo?.title || 'Untitled', - actor: actorName, - count: String(added), - }); + try { + const actorRow = db.prepare('SELECT username FROM users WHERE id = ?').get(actorUserId) as { username: string | null }; + + const tripInfo = db.prepare('SELECT title FROM trips WHERE id = ?').get(tripId) as { title: string } | undefined; + await notifyTripMembers(Number(tripId), actorUserId, 'photos_shared', { + trip: tripInfo?.title || 'Untitled', + actor: actorRow?.username || 'Unknown', + count: String(added), + }); + return success(undefined); + } catch { + return fail('Failed to send notifications', 500); + } } diff --git a/server/src/services/synologyService.ts b/server/src/services/synologyService.ts index 7a01c1c..4f66fbd 100644 --- a/server/src/services/synologyService.ts +++ b/server/src/services/synologyService.ts @@ -6,6 +6,7 @@ import { decrypt_api_key, maybe_encrypt_api_key } from './apiKeyCrypto'; import { checkSsrf } from '../utils/ssrfGuard'; import { addTripPhotos, getAlbumIdFromLink, Selection, updateSyncTimeForAlbumLink } from './memoriesService'; import { error } from 'node:console'; +import { th } from 'zod/locales'; const SYNOLOGY_API_TIMEOUT_MS = 30000; const SYNOLOGY_PROVIDER = 'synologyphotos'; @@ -403,8 +404,8 @@ export async function listSynologyAlbums(userId: number): Promise<{ albums: Arra export async function syncSynologyAlbumLink(userId: number, tripId: string, linkId: string): Promise<{ added: number; total: number }> { - const albumId = getAlbumIdFromLink(tripId, linkId, userId); - if (!albumId) { + const response = getAlbumIdFromLink(tripId, linkId, userId); + if (!response.success) { throw new SynologyServiceError(404, 'Album link not found'); } @@ -417,7 +418,7 @@ export async function syncSynologyAlbumLink(userId: number, tripId: string, link api: 'SYNO.Foto.Browse.Item', method: 'list', version: 1, - album_id: Number(albumId), + album_id: Number(response.data), offset, limit: pageSize, additional: ['thumbnail'], @@ -438,12 +439,12 @@ export async function syncSynologyAlbumLink(userId: number, tripId: string, link asset_ids: allItems.map(item => String(item.additional?.thumbnail?.cache_key || '')).filter(id => id), }; - const addResult = addTripPhotos(tripId, userId, true, [selection]); - if ('error' in addResult) throw new SynologyServiceError(addResult.status, addResult.error); - updateSyncTimeForAlbumLink(linkId); - return { added: addResult.added, total: allItems.length }; + const result = await addTripPhotos(tripId, userId, true, [selection]); + if ('error' in result) throw new SynologyServiceError(result.error.status, result.error.message); + + return { added: result.data.added, total: allItems.length }; } export async function searchSynologyPhotos(userId: number, from?: string, to?: string, offset = 0, limit = 300): Promise<{ assets: SynologyPhotoInfo[]; total: number; hasMore: boolean }> { From bca82b3f8c3de1b00b7789adbe11d7f7c6764820 Mon Sep 17 00:00:00 2001 From: Marek Maslowski Date: Sat, 4 Apr 2026 14:01:51 +0200 Subject: [PATCH 3/9] changing routes and hierarchy of files for memories --- .../src/components/Memories/MemoriesPanel.tsx | 30 +++---- server/src/app.ts | 6 +- server/src/routes/{ => memories}/immich.ts | 17 ++-- server/src/routes/{ => memories}/synology.ts | 10 +-- .../{memories.ts => memories/unified.ts} | 28 ++++--- .../src/services/memories/helpersService.ts | 79 ++++++++++++++++++ .../services/{ => memories}/immichService.ts | 12 +-- .../{ => memories}/synologyService.ts | 11 ++- .../unifiedService.ts} | 81 +++---------------- 9 files changed, 147 insertions(+), 127 deletions(-) rename server/src/routes/{ => memories}/immich.ts (93%) rename server/src/routes/{ => memories}/synology.ts (96%) rename server/src/routes/{memories.ts => memories/unified.ts} (74%) create mode 100644 server/src/services/memories/helpersService.ts rename server/src/services/{ => memories}/immichService.ts (97%) rename server/src/services/{ => memories}/synologyService.ts (98%) rename server/src/services/{memoriesService.ts => memories/unifiedService.ts} (75%) diff --git a/client/src/components/Memories/MemoriesPanel.tsx b/client/src/components/Memories/MemoriesPanel.tsx index d8d167d..da9873e 100644 --- a/client/src/components/Memories/MemoriesPanel.tsx +++ b/client/src/components/Memories/MemoriesPanel.tsx @@ -89,7 +89,7 @@ export default function MemoriesPanel({ tripId, startDate, endDate }: MemoriesPa const loadAlbumLinks = async () => { try { - const res = await apiClient.get(`/integrations/memories/trips/${tripId}/album-links`) + const res = await apiClient.get(`/integrations/memories/unified/trips/${tripId}/album-links`) setAlbumLinks(res.data.links || []) } catch { setAlbumLinks([]) } } @@ -98,7 +98,7 @@ export default function MemoriesPanel({ tripId, startDate, endDate }: MemoriesPa if (!provider) return setAlbumsLoading(true) try { - const res = await apiClient.get(`/integrations/${provider}/albums`) + const res = await apiClient.get(`/integrations/memories/${provider}/albums`) setAlbums(res.data.albums || []) } catch { setAlbums([]) @@ -120,7 +120,7 @@ export default function MemoriesPanel({ tripId, startDate, endDate }: MemoriesPa } try { - await apiClient.post(`/integrations/memories/trips/${tripId}/album-links`, { + await apiClient.post(`/integrations/memories/unified/trips/${tripId}/album-links`, { album_id: albumId, album_name: albumName, provider: selectedProvider, @@ -128,7 +128,7 @@ export default function MemoriesPanel({ tripId, startDate, endDate }: MemoriesPa setShowAlbumPicker(false) await loadAlbumLinks() // Auto-sync after linking - const linksRes = await apiClient.get(`/integrations/memories/trips/${tripId}/album-links`) + const linksRes = await apiClient.get(`/integrations/memories/unified/trips/${tripId}/album-links`) const newLink = (linksRes.data.links || []).find((l: any) => l.album_id === albumId && l.provider === selectedProvider) if (newLink) await syncAlbum(newLink.id) } catch { toast.error(t('memories.error.linkAlbum')) } @@ -136,7 +136,7 @@ export default function MemoriesPanel({ tripId, startDate, endDate }: MemoriesPa const unlinkAlbum = async (linkId: number) => { try { - await apiClient.delete(`/integrations/memories/trips/${tripId}/album-links/${linkId}`) + await apiClient.delete(`/integrations/memories/unified/trips/${tripId}/album-links/${linkId}`) loadAlbumLinks() } catch { toast.error(t('memories.error.unlinkAlbum')) } } @@ -146,7 +146,7 @@ export default function MemoriesPanel({ tripId, startDate, endDate }: MemoriesPa if (!targetProvider) return setSyncing(linkId) try { - await apiClient.post(`/integrations/${targetProvider}/trips/${tripId}/album-links/${linkId}/sync`) + await apiClient.post(`/integrations/memories/${targetProvider}/trips/${tripId}/album-links/${linkId}/sync`) await loadAlbumLinks() await loadPhotos() } catch { toast.error(t('memories.error.syncAlbum')) } @@ -175,7 +175,7 @@ export default function MemoriesPanel({ tripId, startDate, endDate }: MemoriesPa const loadPhotos = async () => { try { - const photosRes = await apiClient.get(`/integrations/memories/trips/${tripId}/photos`) + const photosRes = await apiClient.get(`/integrations/memories/unified/trips/${tripId}/photos`) setTripPhotos(photosRes.data.photos || []) } catch { setTripPhotos([]) @@ -257,7 +257,7 @@ export default function MemoriesPanel({ tripId, startDate, endDate }: MemoriesPa setPickerPhotos([]) return } - const res = await apiClient.post(`/integrations/${provider.id}/search`, { + const res = await apiClient.post(`/integrations/memories/${provider.id}/search`, { from: useDate && startDate ? startDate : undefined, to: useDate && endDate ? endDate : undefined, }) @@ -296,7 +296,7 @@ export default function MemoriesPanel({ tripId, startDate, endDate }: MemoriesPa groupedByProvider.set(provider, list) } - await apiClient.post(`/integrations/memories/trips/${tripId}/photos`, { + await apiClient.post(`/integrations/memories/unified/trips/${tripId}/photos`, { selections: [...groupedByProvider.entries()].map(([provider, asset_ids]) => ({ provider, asset_ids })), shared: true, }) @@ -310,7 +310,7 @@ export default function MemoriesPanel({ tripId, startDate, endDate }: MemoriesPa const removePhoto = async (photo: TripPhoto) => { try { - await apiClient.delete(`/integrations/memories/trips/${tripId}/photos`, { + await apiClient.delete(`/integrations/memories/unified/trips/${tripId}/photos`, { data: { asset_id: photo.asset_id, provider: photo.provider, @@ -324,7 +324,7 @@ export default function MemoriesPanel({ tripId, startDate, endDate }: MemoriesPa const toggleSharing = async (photo: TripPhoto, shared: boolean) => { try { - await apiClient.put(`/integrations/memories/trips/${tripId}/photos/sharing`, { + await apiClient.put(`/integrations/memories/unified/trips/${tripId}/photos/sharing`, { shared, asset_id: photo.asset_id, provider: photo.provider, @@ -338,7 +338,7 @@ export default function MemoriesPanel({ tripId, startDate, endDate }: MemoriesPa // ── Helpers ─────────────────────────────────────────────────────────────── const thumbnailBaseUrl = (photo: TripPhoto) => - `/api/integrations/${photo.provider}/assets/${tripId}/${photo.asset_id}/${photo.user_id}/thumbnail` + `/api/integrations/memories/${photo.provider}/assets/${tripId}/${photo.asset_id}/${photo.user_id}/thumbnail` const makePickerKey = (provider: string, assetId: string): string => `${provider}::${assetId}` @@ -598,7 +598,7 @@ export default function MemoriesPanel({ tripId, startDate, endDate }: MemoriesPa outline: isSelected ? '3px solid var(--text-primary)' : 'none', outlineOffset: -3, }}> - {isSelected && (
setLightboxInfo(r.data)).catch(() => {}).finally(() => setLightboxInfoLoading(false)) }}> diff --git a/server/src/app.ts b/server/src/app.ts index 13b7668..60ddcb8 100644 --- a/server/src/app.ts +++ b/server/src/app.ts @@ -33,9 +33,7 @@ import backupRoutes from './routes/backup'; import oidcRoutes from './routes/oidc'; import vacayRoutes from './routes/vacay'; import atlasRoutes from './routes/atlas'; -import immichRoutes from './routes/immich'; -import synologyRoutes from './routes/synology'; -import memoriesRoutes from './routes/memories'; +import memoriesRoutes from './routes/memories/unified'; import notificationRoutes from './routes/notifications'; import shareRoutes from './routes/share'; import { mcpHandler } from './mcp'; @@ -255,8 +253,6 @@ export function createApp(): express.Application { app.use('/api/addons/vacay', vacayRoutes); app.use('/api/addons/atlas', atlasRoutes); app.use('/api/integrations/memories', memoriesRoutes); - app.use('/api/integrations/immich', immichRoutes); - app.use('/api/integrations/synologyphotos', synologyRoutes); app.use('/api/maps', mapsRoutes); app.use('/api/weather', weatherRoutes); app.use('/api/settings', settingsRoutes); diff --git a/server/src/routes/immich.ts b/server/src/routes/memories/immich.ts similarity index 93% rename from server/src/routes/immich.ts rename to server/src/routes/memories/immich.ts index 012d7ef..af9b4d0 100644 --- a/server/src/routes/immich.ts +++ b/server/src/routes/memories/immich.ts @@ -1,10 +1,10 @@ import express, { Request, Response, NextFunction } from 'express'; -import { db, canAccessTrip } from '../db/database'; -import { authenticate } from '../middleware/auth'; -import { broadcast } from '../websocket'; -import { AuthRequest } from '../types'; -import { consumeEphemeralToken } from '../services/ephemeralTokens'; -import { getClientIp } from '../services/auditLog'; +import { db, canAccessTrip } from '../../db/database'; +import { authenticate } from '../../middleware/auth'; +import { broadcast } from '../../websocket'; +import { AuthRequest } from '../../types'; +import { consumeEphemeralToken } from '../../services/ephemeralTokens'; +import { getClientIp } from '../../services/auditLog'; import { getConnectionSettings, saveImmichSettings, @@ -14,12 +14,11 @@ import { searchPhotos, proxyThumbnail, proxyOriginal, - isValidAssetId, listAlbums, syncAlbumAssets, getAssetInfo, -} from '../services/immichService'; -import { canAccessUserPhoto } from '../services/memoriesService'; +} from '../../services/memories/immichService'; +import { canAccessUserPhoto } from '../../services/memories/helpersService'; const router = express.Router(); diff --git a/server/src/routes/synology.ts b/server/src/routes/memories/synology.ts similarity index 96% rename from server/src/routes/synology.ts rename to server/src/routes/memories/synology.ts index 838a3de..cd3512d 100644 --- a/server/src/routes/synology.ts +++ b/server/src/routes/memories/synology.ts @@ -1,7 +1,7 @@ import express, { Request, Response } from 'express'; -import { authenticate } from '../middleware/auth'; -import { broadcast } from '../websocket'; -import { AuthRequest } from '../types'; +import { authenticate } from '../../middleware/auth'; +import { broadcast } from '../../websocket'; +import { AuthRequest } from '../../types'; import { getSynologySettings, updateSynologySettings, @@ -15,8 +15,8 @@ import { streamSynologyAsset, handleSynologyError, SynologyServiceError, -} from '../services/synologyService'; -import { canAccessUserPhoto } from '../services/memoriesService'; +} from '../../services/memories/synologyService'; +import { canAccessUserPhoto } from '../../services/memories/helpersService'; const router = express.Router(); diff --git a/server/src/routes/memories.ts b/server/src/routes/memories/unified.ts similarity index 74% rename from server/src/routes/memories.ts rename to server/src/routes/memories/unified.ts index e60b9e1..3e4561d 100644 --- a/server/src/routes/memories.ts +++ b/server/src/routes/memories/unified.ts @@ -1,6 +1,6 @@ import express, { Request, Response } from 'express'; -import { authenticate } from '../middleware/auth'; -import { AuthRequest } from '../types'; +import { authenticate } from '../../middleware/auth'; +import { AuthRequest } from '../../types'; import { listTripPhotos, listTripAlbumLinks, @@ -9,14 +9,19 @@ import { addTripPhotos, removeTripPhoto, setTripPhotoSharing, -} from '../services/memoriesService'; +} from '../../services/memories/unifiedService'; +import immichRouter from './immich'; +import synologyRouter from './synology'; const router = express.Router(); +router.use('/immich', immichRouter); +router.use('/synologyphotos', synologyRouter); + //------------------------------------------------ // routes for managing photos linked to trip -router.get('/trips/:tripId/photos', authenticate, (req: Request, res: Response) => { +router.get('/unified/trips/:tripId/photos', authenticate, (req: Request, res: Response) => { const authReq = req as AuthRequest; const { tripId } = req.params; const result = listTripPhotos(tripId, authReq.user.id); @@ -24,7 +29,7 @@ router.get('/trips/:tripId/photos', authenticate, (req: Request, res: Response) res.json({ photos: result.data }); }); -router.post('/trips/:tripId/photos', authenticate, async (req: Request, res: Response) => { +router.post('/unified/trips/:tripId/photos', authenticate, async (req: Request, res: Response) => { const authReq = req as AuthRequest; const { tripId } = req.params; const sid = req.headers['x-socket-id'] as string; @@ -42,7 +47,7 @@ router.post('/trips/:tripId/photos', authenticate, async (req: Request, res: Res res.json({ success: true, added: result.data.added }); }); -router.put('/trips/:tripId/photos/sharing', authenticate, async (req: Request, res: Response) => { +router.put('/unified/trips/:tripId/photos/sharing', authenticate, async (req: Request, res: Response) => { const authReq = req as AuthRequest; const { tripId } = req.params; const result = await setTripPhotoSharing( @@ -56,7 +61,7 @@ router.put('/trips/:tripId/photos/sharing', authenticate, async (req: Request, r res.json({ success: true }); }); -router.delete('/trips/:tripId/photos', authenticate, async (req: Request, res: Response) => { +router.delete('/unified/trips/:tripId/photos', authenticate, async (req: Request, res: Response) => { const authReq = req as AuthRequest; const { tripId } = req.params; const result = await removeTripPhoto(tripId, authReq.user.id, req.body?.provider, req.body?.asset_id); @@ -67,7 +72,7 @@ router.delete('/trips/:tripId/photos', authenticate, async (req: Request, res: R //------------------------------ // routes for managing album links -router.get('/trips/:tripId/album-links', authenticate, (req: Request, res: Response) => { +router.get('/unified/trips/:tripId/album-links', authenticate, (req: Request, res: Response) => { const authReq = req as AuthRequest; const { tripId } = req.params; const result = listTripAlbumLinks(tripId, authReq.user.id); @@ -75,7 +80,7 @@ router.get('/trips/:tripId/album-links', authenticate, (req: Request, res: Respo res.json({ links: result.data }); }); -router.post('/trips/:tripId/album-links', authenticate, async (req: Request, res: Response) => { +router.post('/unified/trips/:tripId/album-links', authenticate, async (req: Request, res: Response) => { const authReq = req as AuthRequest; const { tripId } = req.params; const result = createTripAlbumLink(tripId, authReq.user.id, req.body?.provider, req.body?.album_id, req.body?.album_name); @@ -83,7 +88,7 @@ router.post('/trips/:tripId/album-links', authenticate, async (req: Request, res res.json({ success: true }); }); -router.delete('/trips/:tripId/album-links/:linkId', authenticate, async (req: Request, res: Response) => { +router.delete('/unified/trips/:tripId/album-links/:linkId', authenticate, async (req: Request, res: Response) => { const authReq = req as AuthRequest; const { tripId, linkId } = req.params; const result = removeAlbumLink(tripId, linkId, authReq.user.id); @@ -91,4 +96,7 @@ router.delete('/trips/:tripId/album-links/:linkId', authenticate, async (req: Re res.json({ success: true }); }); + + + export default router; diff --git a/server/src/services/memories/helpersService.ts b/server/src/services/memories/helpersService.ts new file mode 100644 index 0000000..51c899a --- /dev/null +++ b/server/src/services/memories/helpersService.ts @@ -0,0 +1,79 @@ +import { canAccessTrip, db } from "../../db/database"; + +// helpers for handling return types + +type ServiceError = { success: false; error: { message: string; status: number } }; +export type ServiceResult = { success: true; data: T } | ServiceError; + + +export function fail(error: string, status: number): ServiceError { + return { success: false, error: { message: error, status } }; +} + + +export function success(data: T): ServiceResult { + return { success: true, data: data }; +} + + +export function mapDbError(error: unknown, fallbackMessage: string): ServiceError { + if (error instanceof Error && /unique|constraint/i.test(error.message)) { + return fail('Resource already exists', 409); + } + return fail(fallbackMessage, 500); +} + + +// ---------------------------------------------- +// types used across memories services +export type Selection = { + provider: string; + asset_ids: string[]; +}; + + +//----------------------------------------------- +//access check helper + +export function canAccessUserPhoto(requestingUserId: number, ownerUserId: number, tripId: string, assetId: string, provider: string): boolean { + if (requestingUserId === ownerUserId) { + return true; + } + const sharedAsset = db.prepare(` + SELECT 1 + FROM trip_photos + WHERE user_id = ? + AND asset_id = ? + AND provider = ? + AND trip_id = ? + AND shared = 1 + LIMIT 1 + `).get(ownerUserId, assetId, provider, tripId); + + if (!sharedAsset) { + return false; + } + return !!canAccessTrip(tripId, requestingUserId); +} + + +// ---------------------------------------------- +//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 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/immichService.ts b/server/src/services/memories/immichService.ts similarity index 97% rename from server/src/services/immichService.ts rename to server/src/services/memories/immichService.ts index d4a353b..47a9895 100644 --- a/server/src/services/immichService.ts +++ b/server/src/services/memories/immichService.ts @@ -1,9 +1,9 @@ -import { db } from '../db/database'; -import { maybe_encrypt_api_key, decrypt_api_key } from './apiKeyCrypto'; -import { checkSsrf } from '../utils/ssrfGuard'; -import { writeAudit } from './auditLog'; -import { addTripPhotos, getAlbumIdFromLink, Selection, updateSyncTimeForAlbumLink } from './memoriesService'; -import { error } from 'node:console'; +import { db } from '../../db/database'; +import { maybe_encrypt_api_key, decrypt_api_key } from '../apiKeyCrypto'; +import { checkSsrf } from '../../utils/ssrfGuard'; +import { writeAudit } from '../auditLog'; +import { addTripPhotos} from './unifiedService'; +import { getAlbumIdFromLink, updateSyncTimeForAlbumLink, Selection } from './helpersService'; // ── Credentials ──────────────────────────────────────────────────────────── diff --git a/server/src/services/synologyService.ts b/server/src/services/memories/synologyService.ts similarity index 98% rename from server/src/services/synologyService.ts rename to server/src/services/memories/synologyService.ts index 4f66fbd..c9c8b57 100644 --- a/server/src/services/synologyService.ts +++ b/server/src/services/memories/synologyService.ts @@ -1,12 +1,11 @@ import { Readable } from 'node:stream'; import { pipeline } from 'node:stream/promises'; import { Response as ExpressResponse } from 'express'; -import { db } from '../db/database'; -import { decrypt_api_key, maybe_encrypt_api_key } from './apiKeyCrypto'; -import { checkSsrf } from '../utils/ssrfGuard'; -import { addTripPhotos, getAlbumIdFromLink, Selection, updateSyncTimeForAlbumLink } from './memoriesService'; -import { error } from 'node:console'; -import { th } from 'zod/locales'; +import { db } from '../../db/database'; +import { decrypt_api_key, maybe_encrypt_api_key } from '../apiKeyCrypto'; +import { checkSsrf } from '../../utils/ssrfGuard'; +import { addTripPhotos} from './unifiedService'; +import { getAlbumIdFromLink, updateSyncTimeForAlbumLink, Selection } from './helpersService'; const SYNOLOGY_API_TIMEOUT_MS = 30000; const SYNOLOGY_PROVIDER = 'synologyphotos'; diff --git a/server/src/services/memoriesService.ts b/server/src/services/memories/unifiedService.ts similarity index 75% rename from server/src/services/memoriesService.ts rename to server/src/services/memories/unifiedService.ts index 03ac465..35d47eb 100644 --- a/server/src/services/memoriesService.ts +++ b/server/src/services/memories/unifiedService.ts @@ -1,49 +1,15 @@ -import { db, canAccessTrip } from '../db/database'; -import { notifyTripMembers } from './notifications'; -import { broadcast } from '../websocket'; +import { db, canAccessTrip } from '../../db/database'; +import { notifyTripMembers } from '../notifications'; +import { broadcast } from '../../websocket'; +import { + ServiceResult, + fail, + success, + mapDbError, + Selection, +} from './helpersService'; -type ServiceError = { success: false; error: { message: string; status: number } }; -type ServiceResult = { success: true; data: T } | ServiceError; - -function fail(error: string, status: number): ServiceError { - return { success: false, error: { message: error, status }}; -} - -function success(data: T): ServiceResult { - return { success: true, data: data }; -} - -function mapDbError(error: unknown, fallbackMessage: string): ServiceError { - if (error instanceof Error && /unique|constraint/i.test(error.message)) { - return fail('Resource already exists', 409); - } - return fail(fallbackMessage, 500); -} - -//----------------------------------------------- -//access check helper - -export function canAccessUserPhoto(requestingUserId: number, ownerUserId: number, tripId: string, assetId: string, provider: string): boolean { - if (requestingUserId === ownerUserId) { - return true; - } - const sharedAsset = db.prepare(` - SELECT 1 - FROM trip_photos - WHERE user_id = ? - AND asset_id = ? - AND provider = ? - AND trip_id = ? - AND shared = 1 - LIMIT 1 - `).get(ownerUserId, assetId, provider, tripId); - - if (!sharedAsset) { - return false; - } - return !!canAccessTrip(tripId, requestingUserId); -} export function listTripPhotos(tripId: string, userId: number): ServiceResult { const access = canAccessTrip(tripId, userId); @@ -108,11 +74,6 @@ function _addTripPhoto(tripId: string, userId: number, provider: string, assetId return result.changes > 0; } -export type Selection = { - provider: string; - asset_ids: string[]; -}; - export async function addTripPhotos( tripId: string, userId: number, @@ -181,7 +142,6 @@ export async function setTripPhotoSharing( } } - export function removeTripPhoto( tripId: string, userId: number, @@ -262,25 +222,6 @@ export function removeAlbumLink(tripId: string, linkId: string, userId: number): } } -//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 updateSyncTimeForAlbumLink(linkId: string): void { - db.prepare('UPDATE trip_album_links SET last_synced_at = CURRENT_TIMESTAMP WHERE id = ?').run(linkId); -} //----------------------------------------------- // notifications helper @@ -306,5 +247,3 @@ async function _notifySharedTripPhotos( return fail('Failed to send notifications', 500); } } - - From 877e1a09ccc6ceb5369809415fe7f71ceb7ef453 Mon Sep 17 00:00:00 2001 From: Marek Maslowski Date: Sat, 4 Apr 2026 14:20:52 +0200 Subject: [PATCH 4/9] removing the need of suplementing provider links in config --- server/src/app.ts | 7 ++++--- server/src/db/migrations.ts | 19 ++++++++++--------- server/src/db/schema.ts | 1 - server/src/db/seeds.ts | 16 ++-------------- server/src/services/adminService.ts | 14 +++++++------- .../src/services/memories/helpersService.ts | 19 +++++++++++++++++++ 6 files changed, 42 insertions(+), 34 deletions(-) diff --git a/server/src/app.ts b/server/src/app.ts index 60ddcb8..c46cfe7 100644 --- a/server/src/app.ts +++ b/server/src/app.ts @@ -38,6 +38,7 @@ import notificationRoutes from './routes/notifications'; import shareRoutes from './routes/share'; import { mcpHandler } from './mcp'; import { Addon } from './types'; +import { getPhotoProviderConfig } from './services/memories/helpersService'; export function createApp(): express.Application { const app = express(); @@ -194,11 +195,11 @@ export function createApp(): express.Application { app.get('/api/addons', authenticate, (_req: Request, res: Response) => { const addons = db.prepare('SELECT id, name, type, icon, enabled FROM addons WHERE enabled = 1 ORDER BY sort_order').all() as Pick[]; const providers = db.prepare(` - SELECT id, name, icon, enabled, config, sort_order + SELECT id, name, icon, enabled, sort_order FROM photo_providers WHERE enabled = 1 ORDER BY sort_order, id - `).all() as Array<{ id: string; name: string; icon: string; enabled: number; config: string; sort_order: number }>; + `).all() as Array<{ id: string; name: string; icon: string; enabled: number; sort_order: number }>; const fields = db.prepare(` SELECT provider_id, field_key, label, input_type, placeholder, required, secret, settings_key, payload_key, sort_order FROM photo_provider_fields @@ -232,7 +233,7 @@ export function createApp(): express.Application { type: 'photo_provider', icon: p.icon, enabled: !!p.enabled, - config: JSON.parse(p.config || '{}'), + config: getPhotoProviderConfig(p.id), fields: (fieldsByProvider.get(p.id) || []).map(f => ({ key: f.field_key, label: f.label, diff --git a/server/src/db/migrations.ts b/server/src/db/migrations.ts index 20f160d..aee415e 100644 --- a/server/src/db/migrations.ts +++ b/server/src/db/migrations.ts @@ -643,14 +643,13 @@ function runMigrations(db: Database.Database): void { // Seed Synology Photos provider and fields in existing databases try { db.prepare(` - INSERT INTO photo_providers (id, name, description, icon, enabled, config, sort_order) - VALUES (?, ?, ?, ?, ?, ?, ?) + INSERT INTO photo_providers (id, name, description, icon, enabled, sort_order) + VALUES (?, ?, ?, ?, ?, ?) ON CONFLICT(id) DO UPDATE SET name = excluded.name, description = excluded.description, icon = excluded.icon, enabled = excluded.enabled, - config = excluded.config, sort_order = excluded.sort_order `).run( 'synologyphotos', @@ -658,12 +657,6 @@ function runMigrations(db: Database.Database): void { 'Synology Photos integration with separate account settings', 'Image', 0, - JSON.stringify({ - settings_get: '/integrations/synologyphotos/settings', - settings_put: '/integrations/synologyphotos/settings', - status_get: '/integrations/synologyphotos/status', - test_post: '/integrations/synologyphotos/test', - }), 1, ); } catch (err: any) { @@ -691,6 +684,14 @@ function runMigrations(db: Database.Database): void { if (!err.message?.includes('no such table')) throw err; } }, + () => { + // Remove the stored config column from photo_providers now that it is generated from provider id. + const columns = db.prepare("PRAGMA table_info('photo_providers')").all() as Array<{ name: string }>; + const names = new Set(columns.map(c => c.name)); + if (!names.has('config')) return; + + db.exec('ALTER TABLE photo_providers DROP COLUMN config'); + }, ]; if (currentVersion < migrations.length) { diff --git a/server/src/db/schema.ts b/server/src/db/schema.ts index 9e243b6..e053df6 100644 --- a/server/src/db/schema.ts +++ b/server/src/db/schema.ts @@ -232,7 +232,6 @@ function createTables(db: Database.Database): void { description TEXT, icon TEXT DEFAULT 'Image', enabled INTEGER DEFAULT 0, - config TEXT DEFAULT '{}', sort_order INTEGER DEFAULT 0 ); diff --git a/server/src/db/seeds.ts b/server/src/db/seeds.ts index ef849d9..2e233f6 100644 --- a/server/src/db/seeds.ts +++ b/server/src/db/seeds.ts @@ -101,12 +101,6 @@ function seedAddons(db: Database.Database): void { icon: 'Image', enabled: 0, sort_order: 0, - config: JSON.stringify({ - settings_get: '/integrations/immich/settings', - settings_put: '/integrations/immich/settings', - status_get: '/integrations/immich/status', - test_post: '/integrations/immich/test', - }), }, { id: 'synologyphotos', @@ -115,16 +109,10 @@ function seedAddons(db: Database.Database): void { icon: 'Image', enabled: 0, sort_order: 1, - config: JSON.stringify({ - settings_get: '/integrations/synologyphotos/settings', - settings_put: '/integrations/synologyphotos/settings', - status_get: '/integrations/synologyphotos/status', - test_post: '/integrations/synologyphotos/test', - }), }, ]; - const insertProvider = db.prepare('INSERT OR IGNORE INTO photo_providers (id, name, description, icon, enabled, config, sort_order) VALUES (?, ?, ?, ?, ?, ?, ?)'); - for (const p of providerRows) insertProvider.run(p.id, p.name, p.description, p.icon, p.enabled, p.config, p.sort_order); + const insertProvider = db.prepare('INSERT OR IGNORE INTO photo_providers (id, name, description, icon, enabled, sort_order) VALUES (?, ?, ?, ?, ?, ?)'); + for (const p of providerRows) insertProvider.run(p.id, p.name, p.description, p.icon, p.enabled, p.sort_order); const providerFields = [ { provider_id: 'immich', field_key: 'immich_url', label: 'Immich URL', input_type: 'url', placeholder: 'https://immich.example.com', required: 1, secret: 0, settings_key: 'immich_url', payload_key: 'immich_url', sort_order: 0 }, diff --git a/server/src/services/adminService.ts b/server/src/services/adminService.ts index f7df1f4..167eaeb 100644 --- a/server/src/services/adminService.ts +++ b/server/src/services/adminService.ts @@ -9,6 +9,7 @@ import { maybe_encrypt_api_key, decrypt_api_key } from './apiKeyCrypto'; import { getAllPermissions, savePermissions as savePerms, PERMISSION_ACTIONS } from './permissions'; import { revokeUserSessions } from '../mcp'; import { validatePassword } from './passwordPolicy'; +import { getPhotoProviderConfig } from './memories/helpersService'; // ── Helpers ──────────────────────────────────────────────────────────────── @@ -466,10 +467,10 @@ export function deleteTemplateItem(itemId: string) { export function listAddons() { const addons = db.prepare('SELECT * FROM addons ORDER BY sort_order, id').all() as Addon[]; const providers = db.prepare(` - SELECT id, name, description, icon, enabled, config, sort_order + SELECT id, name, description, icon, enabled, sort_order FROM photo_providers ORDER BY sort_order, id - `).all() as Array<{ id: string; name: string; description?: string | null; icon: string; enabled: number; config: string; sort_order: number }>; + `).all() as Array<{ id: string; name: string; description?: string | null; icon: string; enabled: number; sort_order: number }>; const fields = db.prepare(` SELECT provider_id, field_key, label, input_type, placeholder, required, secret, settings_key, payload_key, sort_order FROM photo_provider_fields @@ -502,7 +503,7 @@ export function listAddons() { type: 'photo_provider', icon: p.icon, enabled: !!p.enabled, - config: JSON.parse(p.config || '{}'), + config: getPhotoProviderConfig(p.id), fields: (fieldsByProvider.get(p.id) || []).map(f => ({ key: f.field_key, label: f.label, @@ -521,7 +522,7 @@ export function listAddons() { export function updateAddon(id: string, data: { enabled?: boolean; config?: Record }) { const addon = db.prepare('SELECT * FROM addons WHERE id = ?').get(id) as Addon | undefined; - const provider = db.prepare('SELECT * FROM photo_providers WHERE id = ?').get(id) as { id: string; name: string; description?: string | null; icon: string; enabled: number; config: string; sort_order: number } | undefined; + const provider = db.prepare('SELECT * FROM photo_providers WHERE id = ?').get(id) as { id: string; name: string; description?: string | null; icon: string; enabled: number; sort_order: number } | undefined; if (!addon && !provider) return { error: 'Addon not found', status: 404 }; if (addon) { @@ -529,11 +530,10 @@ export function updateAddon(id: string, data: { enabled?: boolean; config?: Reco if (data.config !== undefined) db.prepare('UPDATE addons SET config = ? WHERE id = ?').run(JSON.stringify(data.config), id); } else { if (data.enabled !== undefined) db.prepare('UPDATE photo_providers SET enabled = ? WHERE id = ?').run(data.enabled ? 1 : 0, id); - if (data.config !== undefined) db.prepare('UPDATE photo_providers SET config = ? WHERE id = ?').run(JSON.stringify(data.config), id); } const updatedAddon = db.prepare('SELECT * FROM addons WHERE id = ?').get(id) as Addon | undefined; - const updatedProvider = db.prepare('SELECT * FROM photo_providers WHERE id = ?').get(id) as { id: string; name: string; description?: string | null; icon: string; enabled: number; config: string; sort_order: number } | undefined; + const updatedProvider = db.prepare('SELECT * FROM photo_providers WHERE id = ?').get(id) as { id: string; name: string; description?: string | null; icon: string; enabled: number; sort_order: number } | undefined; const updated = updatedAddon ? { ...updatedAddon, enabled: !!updatedAddon.enabled, config: JSON.parse(updatedAddon.config || '{}') } : updatedProvider @@ -544,7 +544,7 @@ export function updateAddon(id: string, data: { enabled?: boolean; config?: Reco type: 'photo_provider', icon: updatedProvider.icon, enabled: !!updatedProvider.enabled, - config: JSON.parse(updatedProvider.config || '{}'), + config: getPhotoProviderConfig(updatedProvider.id), sort_order: updatedProvider.sort_order, } : null; diff --git a/server/src/services/memories/helpersService.ts b/server/src/services/memories/helpersService.ts index 51c899a..465842f 100644 --- a/server/src/services/memories/helpersService.ts +++ b/server/src/services/memories/helpersService.ts @@ -32,6 +32,25 @@ export type Selection = { }; +//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 From 8c125738e8c76e029b81cc68432a2f09bfb997b5 Mon Sep 17 00:00:00 2001 From: Marek Maslowski Date: Sat, 4 Apr 2026 17:13:17 +0200 Subject: [PATCH 5/9] refactor of synology part 1 --- server/src/routes/memories/synology.ts | 140 +++---- .../src/services/memories/helpersService.ts | 54 +++ .../src/services/memories/synologyService.ts | 369 ++++++++---------- 3 files changed, 260 insertions(+), 303 deletions(-) diff --git a/server/src/routes/memories/synology.ts b/server/src/routes/memories/synology.ts index cd3512d..3bce3e4 100644 --- a/server/src/routes/memories/synology.ts +++ b/server/src/routes/memories/synology.ts @@ -1,6 +1,5 @@ import express, { Request, Response } from 'express'; import { authenticate } from '../../middleware/auth'; -import { broadcast } from '../../websocket'; import { AuthRequest } from '../../types'; import { getSynologySettings, @@ -11,114 +10,87 @@ import { syncSynologyAlbumLink, searchSynologyPhotos, getSynologyAssetInfo, - pipeSynologyProxy, streamSynologyAsset, - handleSynologyError, - SynologyServiceError, } from '../../services/memories/synologyService'; -import { canAccessUserPhoto } from '../../services/memories/helpersService'; +import { canAccessUserPhoto, handleServiceResult, fail } from '../../services/memories/helpersService'; const router = express.Router(); -function parseStringBodyField(value: unknown): string { +function _parseStringBodyField(value: unknown): string { return String(value ?? '').trim(); } -function parseNumberBodyField(value: unknown, fallback: number): number { +function _parseNumberBodyField(value: unknown, fallback: number): number { const parsed = Number(value); return Number.isFinite(parsed) ? parsed : fallback; } router.get('/settings', authenticate, async (req: Request, res: Response) => { const authReq = req as AuthRequest; - try { - res.json(await getSynologySettings(authReq.user.id)); - } catch (err: unknown) { - handleSynologyError(res, err, 'Failed to load settings'); - } + handleServiceResult(res, await getSynologySettings(authReq.user.id)); }); router.put('/settings', authenticate, async (req: Request, res: Response) => { const authReq = req as AuthRequest; const body = req.body as Record; - const synology_url = parseStringBodyField(body.synology_url); - const synology_username = parseStringBodyField(body.synology_username); - const synology_password = parseStringBodyField(body.synology_password); + const synology_url = _parseStringBodyField(body.synology_url); + const synology_username = _parseStringBodyField(body.synology_username); + const synology_password = _parseStringBodyField(body.synology_password); if (!synology_url || !synology_username) { - return handleSynologyError(res, new SynologyServiceError(400, 'URL and username are required'), 'Missing required fields'); + handleServiceResult(res, fail('URL and username are required', 400)); } - - try { - await updateSynologySettings(authReq.user.id, synology_url, synology_username, synology_password); - res.json({ success: true }); - } catch (err: unknown) { - handleSynologyError(res, err, 'Failed to save settings'); + else { + handleServiceResult(res, await updateSynologySettings(authReq.user.id, synology_url, synology_username, synology_password)); } }); router.get('/status', authenticate, async (req: Request, res: Response) => { const authReq = req as AuthRequest; - res.json(await getSynologyStatus(authReq.user.id)); + handleServiceResult(res, await getSynologyStatus(authReq.user.id)); }); router.post('/test', authenticate, async (req: Request, res: Response) => { const body = req.body as Record; - const synology_url = parseStringBodyField(body.synology_url); - const synology_username = parseStringBodyField(body.synology_username); - const synology_password = parseStringBodyField(body.synology_password); + const synology_url = _parseStringBodyField(body.synology_url); + const synology_username = _parseStringBodyField(body.synology_username); + const synology_password = _parseStringBodyField(body.synology_password); if (!synology_url || !synology_username || !synology_password) { - return handleSynologyError(res, new SynologyServiceError(400, 'URL, username and password are required'), 'Missing required fields'); + handleServiceResult(res, fail('URL, username, and password are required', 400)); + } + else{ + handleServiceResult(res, await testSynologyConnection(synology_url, synology_username, synology_password)); } - - res.json(await testSynologyConnection(synology_url, synology_username, synology_password)); }); router.get('/albums', authenticate, async (req: Request, res: Response) => { const authReq = req as AuthRequest; - try { - res.json(await listSynologyAlbums(authReq.user.id)); - } catch (err: unknown) { - handleSynologyError(res, err, 'Could not reach Synology'); - } + handleServiceResult(res, await listSynologyAlbums(authReq.user.id)); }); router.post('/trips/:tripId/album-links/:linkId/sync', authenticate, async (req: Request, res: Response) => { const authReq = req as AuthRequest; const { tripId, linkId } = req.params; - try { - const result = await syncSynologyAlbumLink(authReq.user.id, tripId, linkId); - res.json({ success: true, ...result }); - if (result.added > 0) { - broadcast(tripId, 'memories:updated', { userId: authReq.user.id }, req.headers['x-socket-id'] as string); - } - } catch (err: unknown) { - handleSynologyError(res, err, 'Could not reach Synology'); - } + handleServiceResult(res, await syncSynologyAlbumLink(authReq.user.id, tripId, linkId)); }); router.post('/search', authenticate, async (req: Request, res: Response) => { const authReq = req as AuthRequest; const body = req.body as Record; - const from = parseStringBodyField(body.from); - const to = parseStringBodyField(body.to); - const offset = parseNumberBodyField(body.offset, 0); - const limit = parseNumberBodyField(body.limit, 300); + const from = _parseStringBodyField(body.from); + const to = _parseStringBodyField(body.to); + const offset = _parseNumberBodyField(body.offset, 0); + const limit = _parseNumberBodyField(body.limit, 100); - try { - const result = await searchSynologyPhotos( - authReq.user.id, - from || undefined, - to || undefined, - offset, - limit, - ); - res.json(result); - } catch (err: unknown) { - handleSynologyError(res, err, 'Could not reach Synology'); - } + handleServiceResult(res, await searchSynologyPhotos( + authReq.user.id, + from || undefined, + to || undefined, + offset, + limit, + )); }); router.get('/assets/:tripId/:photoId/:ownerId/info', authenticate, async (req: Request, res: Response) => { @@ -126,53 +98,29 @@ router.get('/assets/:tripId/:photoId/:ownerId/info', authenticate, async (req: R const { tripId, photoId, ownerId } = req.params; if (!canAccessUserPhoto(authReq.user.id, Number(ownerId), tripId, photoId, 'synologyphotos')) { - return handleSynologyError(res, new SynologyServiceError(403, 'You don\'t have access to this photo'), 'Access denied'); + handleServiceResult(res, fail('You don\'t have access to this photo', 403)); } - - try { - res.json(await getSynologyAssetInfo(authReq.user.id, photoId, Number(ownerId))); - } catch (err: unknown) { - handleSynologyError(res, err, 'Could not reach Synology'); + else { + handleServiceResult(res, await getSynologyAssetInfo(authReq.user.id, photoId, Number(ownerId))); } }); -router.get('/assets/:tripId/:photoId/:ownerId/thumbnail', authenticate, async (req: Request, res: Response) => { +router.get('/assets/:tripId/:photoId/:ownerId/:kind', authenticate, async (req: Request, res: Response) => { const authReq = req as AuthRequest; - const { tripId, photoId, ownerId } = req.params; + const { tripId, photoId, ownerId, kind } = req.params; const { size = 'sm' } = req.query; + if (kind !== 'thumbnail' && kind !== 'original') { + handleServiceResult(res, fail('Invalid asset kind', 400)); + } + if (!canAccessUserPhoto(authReq.user.id, Number(ownerId), tripId, photoId, 'synologyphotos')) { - return handleSynologyError(res, new SynologyServiceError(403, 'You don\'t have access to this photo'), 'Access denied'); + handleServiceResult(res, fail('You don\'t have access to this photo', 403)); + } + else{ + await streamSynologyAsset(res, authReq.user.id, Number(ownerId), photoId, kind as 'thumbnail' | 'original', String(size)); } - try { - const proxy = await streamSynologyAsset(authReq.user.id, Number(ownerId), photoId, 'thumbnail', String(size)); - await pipeSynologyProxy(res, proxy); - } catch (err: unknown) { - if (res.headersSent) { - return; - } - handleSynologyError(res, err, 'Proxy error'); - } -}); - -router.get('/assets/:tripId/:photoId/:ownerId/original', authenticate, async (req: Request, res: Response) => { - const authReq = req as AuthRequest; - const { tripId, photoId, ownerId } = req.params; - - if (!canAccessUserPhoto(authReq.user.id, Number(ownerId), tripId, photoId, 'synologyphotos')) { - return handleSynologyError(res, new SynologyServiceError(403, 'You don\'t have access to this photo'), 'Access denied'); - } - - try { - const proxy = await streamSynologyAsset(authReq.user.id, Number(ownerId), photoId, 'original'); - await pipeSynologyProxy(res, proxy); - } catch (err: unknown) { - if (res.headersSent) { - return; - } - handleSynologyError(res, err, 'Proxy error'); - } }); export default router; diff --git a/server/src/services/memories/helpersService.ts b/server/src/services/memories/helpersService.ts index 465842f..59a673d 100644 --- a/server/src/services/memories/helpersService.ts +++ b/server/src/services/memories/helpersService.ts @@ -1,3 +1,6 @@ +import { Readable } from 'node:stream'; +import { pipeline } from 'node:stream/promises'; +import { Response } from 'express'; import { canAccessTrip, db } from "../../db/database"; // helpers for handling return types @@ -24,6 +27,16 @@ export function mapDbError(error: unknown, fallbackMessage: string): ServiceErro } +export function handleServiceResult(res: Response, result: ServiceResult): void { + if ('error' in result) { + res.status(result.error.status).json({ error: result.error.message }); + } + else { + console.log('Service result data:', result.data); + res.json(result.data); + } +} + // ---------------------------------------------- // types used across memories services export type Selection = { @@ -31,6 +44,29 @@ export type Selection = { asset_ids: string[]; }; +export type StatusResult = { + connected: true; + user: { name: string } +} | { + connected: false; + error: string +}; + +export type AlbumsList = { + albums: Array<{ id: string; albumName: string; assetCount: number }> +}; + +export type AssetInfo = { + id: string; + takenAt: string; +}; + +export type AssetsList = { + assets: AssetInfo[], + total: number, + hasMore: boolean +}; + //for loading routes to settings page, and validating which services user has connected type PhotoProviderConfig = { @@ -96,3 +132,21 @@ export function getAlbumIdFromLink(tripId: string, linkId: string, userId: numbe export function updateSyncTimeForAlbumLink(linkId: string): void { db.prepare('UPDATE trip_album_links SET last_synced_at = CURRENT_TIMESTAMP WHERE id = ?').run(linkId); } + +export async function pipeAsset(url: string, response: Response): Promise { + const resp = await fetch(url); + + response.status(resp.status); + if (resp.headers.get('content-type')) response.set('Content-Type', resp.headers.get('content-type') as string); + if (resp.headers.get('cache-control')) response.set('Cache-Control', resp.headers.get('cache-control') as string); + if (resp.headers.get('content-length')) response.set('Content-Length', resp.headers.get('content-length') as string); + if (resp.headers.get('content-disposition')) response.set('Content-Disposition', resp.headers.get('content-disposition') as string); + + if (!resp.body) { + response.end(); + } + else { + pipeline(Readable.fromWeb(resp.body), response); + } + +} \ No newline at end of file diff --git a/server/src/services/memories/synologyService.ts b/server/src/services/memories/synologyService.ts index c9c8b57..a444c20 100644 --- a/server/src/services/memories/synologyService.ts +++ b/server/src/services/memories/synologyService.ts @@ -1,11 +1,22 @@ -import { Readable } from 'node:stream'; -import { pipeline } from 'node:stream/promises'; -import { Response as ExpressResponse } from 'express'; + +import { Response } from 'express'; import { db } from '../../db/database'; import { decrypt_api_key, maybe_encrypt_api_key } from '../apiKeyCrypto'; import { checkSsrf } from '../../utils/ssrfGuard'; -import { addTripPhotos} from './unifiedService'; -import { getAlbumIdFromLink, updateSyncTimeForAlbumLink, Selection } from './helpersService'; +import { addTripPhotos } from './unifiedService'; +import { + getAlbumIdFromLink, + updateSyncTimeForAlbumLink, + Selection, + ServiceResult, + fail, + success, + handleServiceResult, + pipeAsset, + AlbumsList, + AssetsList, + StatusResult +} from './helpersService'; const SYNOLOGY_API_TIMEOUT_MS = 30000; const SYNOLOGY_PROVIDER = 'synologyphotos'; @@ -18,12 +29,6 @@ interface SynologyCredentials { synology_password: string; } -interface SynologySession { - success: boolean; - sid?: string; - error?: { code: number; message?: string }; -} - interface ApiCallParams { api: string; method: string; @@ -34,16 +39,7 @@ interface ApiCallParams { interface SynologyApiResponse { success: boolean; data?: T; - error?: { code: number; message?: string }; -} - -export class SynologyServiceError extends Error { - status: number; - - constructor(status: number, message: string) { - super(message); - this.status = status; - } + error?: { code: number }; } export interface SynologySettings { @@ -52,12 +48,6 @@ export interface SynologySettings { connected: boolean; } -export interface SynologyConnectionResult { - connected: boolean; - user?: { username: string }; - error?: string; -} - export interface SynologyAlbumLinkInput { album_id?: string | number; album_name?: string; @@ -132,43 +122,50 @@ type SynologyUserRecord = { synology_sid?: string | null; }; -function readSynologyUser(userId: number, columns: string[]): SynologyUserRecord | null { +function _readSynologyUser(userId: number, columns: string[]): ServiceResult { try { if (!columns) return null; const row = db.prepare(`SELECT synology_url, synology_username, synology_password, synology_sid FROM users WHERE id = ?`).get(userId) as SynologyUserRecord | undefined; - if (!row) return null; + if (!row) { + return fail('User not found', 404); + } const filtered: SynologyUserRecord = {}; for (const column of columns) { filtered[column] = row[column]; } - return filtered || null; + if (!filtered) { + return fail('Failed to read Synology user data', 500); + } + + return success(filtered); } catch { - return null; + return fail('Failed to read Synology user data', 500); } } -function getSynologyCredentials(userId: number): SynologyCredentials | null { - const user = readSynologyUser(userId, ['synology_url', 'synology_username', 'synology_password']); - if (!user?.synology_url || !user.synology_username || !user.synology_password) return null; - return { - synology_url: user.synology_url, - synology_username: user.synology_username, - synology_password: decrypt_api_key(user.synology_password) as string, - }; +function _getSynologyCredentials(userId: number): ServiceResult { + const user = _readSynologyUser(userId, ['synology_url', 'synology_username', 'synology_password']); + 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); + return success({ + synology_url: user.data.synology_url, + synology_username: user.data.synology_username, + synology_password: decrypt_api_key(user.data.synology_password) as string, + }); } -function buildSynologyEndpoint(url: string): string { +function _buildSynologyEndpoint(url: string): string { const normalized = url.replace(/\/$/, '').match(/^https?:\/\//) ? url.replace(/\/$/, '') : `https://${url.replace(/\/$/, '')}`; return `${normalized}${SYNOLOGY_ENDPOINT_PATH}`; } -function buildSynologyFormBody(params: ApiCallParams): URLSearchParams { +function _buildSynologyFormBody(params: ApiCallParams): URLSearchParams { const body = new URLSearchParams(); for (const [key, value] of Object.entries(params)) { if (value === undefined || value === null) continue; @@ -177,8 +174,8 @@ function buildSynologyFormBody(params: ApiCallParams): URLSearchParams { return body; } -async function fetchSynologyJson(url: string, body: URLSearchParams): Promise> { - const endpoint = buildSynologyEndpoint(url); +async function _fetchSynologyJson(url: string, body: URLSearchParams): Promise> { + const endpoint = _buildSynologyEndpoint(url); const resp = await fetch(endpoint, { method: 'POST', headers: { @@ -189,14 +186,14 @@ async function fetchSynologyJson(url: string, body: URLSearchParams): Promise }); if (!resp.ok) { - const text = await resp.text(); - return { success: false, error: { code: resp.status, message: text } }; + return fail('Synology API request failed with status ' + resp.status, resp.status); } - return resp.json() as Promise>; + const response = await resp.json() as SynologyApiResponse; + return response.success ? success(response.data) : fail('Synology failed with code ' + response.error.code, response.error.code); } -async function loginToSynology(url: string, username: string, password: string): Promise> { +async function _loginToSynology(url: string, username: string, password: string): Promise> { const body = new URLSearchParams({ api: 'SYNO.API.Auth', method: 'login', @@ -205,40 +202,43 @@ async function loginToSynology(url: string, username: string, password: string): passwd: password, }); - return fetchSynologyJson<{ sid?: string }>(url, body); + const result = await _fetchSynologyJson<{ sid?: string }>(url, body); + if (!result.success) { + return result as ServiceResult; + } + if (!result.data.sid) { + return fail('Failed to get session ID from Synology', 500); + } + return success(result.data.sid); + + } -async function requestSynologyApi(userId: number, params: ApiCallParams): Promise> { - const creds = getSynologyCredentials(userId); - if (!creds) { - return { success: false, error: { code: 400, message: 'Synology not configured' } }; +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.sid) { - return { success: false, error: session.error || { code: 400, message: 'Failed to get Synology session' } }; + const session = await _getSynologySession(userId); + if (!session.success || !session.data) { + return session as ServiceResult; } - const body = buildSynologyFormBody({ ...params, _sid: session.sid }); - const result = await fetchSynologyJson(creds.synology_url, body); - if (!result.success && result.error?.code === 119) { - clearSynologySID(userId); - const retrySession = await getSynologySession(userId); - if (!retrySession.success || !retrySession.sid) { - return { success: false, error: retrySession.error || { code: 400, message: 'Failed to get Synology session' } }; + const body = _buildSynologyFormBody({ ...params, _sid: session.data }); + const result = await _fetchSynologyJson(creds.data.synology_url, body); + if ('error' in result && result.error.status === 119) { + _clearSynologySID(userId); + const retrySession = await _getSynologySession(userId); + if (!retrySession.success || !retrySession.data) { + return session as ServiceResult; } - return fetchSynologyJson(creds.synology_url, buildSynologyFormBody({ ...params, _sid: retrySession.sid })); + return _fetchSynologyJson(creds.data.synology_url, _buildSynologyFormBody({ ...params, _sid: retrySession.data })); } return result; } -async function requestSynologyStream(url: string): Promise { - return fetch(url, { - signal: AbortSignal.timeout(SYNOLOGY_API_TIMEOUT_MS), - }); -} - -function normalizeSynologyPhotoInfo(item: SynologyPhotoItem): SynologyPhotoInfo { +function _normalizeSynologyPhotoInfo(item: SynologyPhotoItem): SynologyPhotoInfo { const address = item.additional?.address || {}; const exif = item.additional?.exif || {}; const gps = item.additional?.gps || {}; @@ -268,69 +268,65 @@ function normalizeSynologyPhotoInfo(item: SynologyPhotoItem): SynologyPhotoInfo }; } -export function handleSynologyError(res: ExpressResponse, err: unknown, fallbackMessage: string): ExpressResponse { - if (err instanceof SynologyServiceError) { - return res.status(err.status).json({ error: err.message }); - } - return res.status(502).json({ error: err instanceof Error ? err.message : fallbackMessage }); -} - -function cacheSynologySID(userId: number, sid: string): void { +function _cacheSynologySID(userId: number, sid: string): void { db.prepare('UPDATE users SET synology_sid = ? WHERE id = ?').run(sid, userId); } -function clearSynologySID(userId: number): void { +function _clearSynologySID(userId: number): void { db.prepare('UPDATE users SET synology_sid = NULL WHERE id = ?').run(userId); } -function splitPackedSynologyId(rawId: string): { id: string; cacheKey: string; assetId: string } { +function _splitPackedSynologyId(rawId: string): { id: string; cacheKey: string; assetId: string } { const id = rawId.split('_')[0]; return { id, cacheKey: rawId, assetId: rawId }; } -async function getSynologySession(userId: number): Promise { - const cachedSid = readSynologyUser(userId, ['synology_sid'])?.synology_sid || null; - if (cachedSid) { - return { success: true, sid: cachedSid }; - } - - const creds = getSynologyCredentials(userId); - if (!creds) { - return { success: false, error: { code: 400, message: 'Invalid Synology credentials' } }; +async function _getSynologySession(userId: number): Promise> { + const cachedSid = _readSynologyUser(userId, ['synology_sid']); + if (cachedSid.success && cachedSid.data?.synology_sid) { + return success(cachedSid.data.synology_sid); } - const resp = await loginToSynology(creds.synology_url, creds.synology_username, creds.synology_password); - - if (!resp.success || !resp.data?.sid) { - return { success: false, error: resp.error || { code: 400, message: 'Failed to authenticate with Synology' } }; + const creds = _getSynologyCredentials(userId); + if (!creds.success) { + return creds as ServiceResult; } - cacheSynologySID(userId, resp.data.sid); - return { success: true, sid: resp.data.sid }; + const resp = await _loginToSynology(creds.data.synology_url, creds.data.synology_username, creds.data.synology_password); + + if (!resp.success) { + return resp as ServiceResult; + } + + _cacheSynologySID(userId, resp.data); + return success(resp.data); } -export async function getSynologySettings(userId: number): Promise { - const creds = getSynologyCredentials(userId); - const session = await getSynologySession(userId); - return { - synology_url: creds?.synology_url || '', - synology_username: creds?.synology_username || '', +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 || '', connected: session.success, - }; + }); } -export async function updateSynologySettings(userId: number, synologyUrl: string, synologyUsername: string, synologyPassword?: string): Promise { +export async function updateSynologySettings(userId: number, synologyUrl: string, synologyUsername: string, synologyPassword?: string): Promise> { const ssrf = await checkSsrf(synologyUrl); if (!ssrf.allowed) { - throw new SynologyServiceError(400, ssrf.error ?? 'Invalid Synology URL'); + return fail(ssrf.error, 400); } - const existingEncryptedPassword = readSynologyUser(userId, ['synology_password'])?.synology_password || null; + const result = _readSynologyUser(userId, ['synology_password']) + if (!result.success) return result as ServiceResult; + const existingEncryptedPassword = result.data?.synology_password || null; if (!synologyPassword && !existingEncryptedPassword) { - throw new SynologyServiceError(400, 'No stored password found. Please provide a password to save settings.'); + return fail('No stored password found. Please provide a password to save settings.', 400); } try { @@ -341,79 +337,69 @@ export async function updateSynologySettings(userId: number, synologyUrl: string userId, ); } catch { - throw new SynologyServiceError(400, 'Failed to save settings'); + return fail('Failed to update Synology settings', 500); } - clearSynologySID(userId); - await getSynologySession(userId); + _clearSynologySID(userId); + return success("settings updated"); } -export async function getSynologyStatus(userId: number): Promise { +export async function getSynologyStatus(userId: number): Promise> { + const sid = await _getSynologySession(userId); + if ('error' in sid) return success({ connected: false, error: sid.error.status === 400 ? 'Invalid credentials' : sid.error.message }); + if (!sid.data) return success({ connected: false, error: 'Not connected to Synology' }); try { - const sid = await getSynologySession(userId); - if (!sid.success || !sid.sid) { - return { connected: false, error: 'Authentication failed' }; - } - const user = db.prepare('SELECT synology_username FROM users WHERE id = ?').get(userId) as { synology_username?: string } | undefined; - return { connected: true, user: { username: user?.synology_username || '' } }; + return success({ connected: true, user: { name: user?.synology_username || 'unknown user' } }); } catch (err: unknown) { - return { connected: false, error: err instanceof Error ? err.message : 'Connection failed' }; + return success({ connected: true, user: { name: 'unknown user' } }); } } -export async function testSynologyConnection(synologyUrl: string, synologyUsername: string, synologyPassword: string): Promise { +export async function testSynologyConnection(synologyUrl: string, synologyUsername: string, synologyPassword: string): Promise> { const ssrf = await checkSsrf(synologyUrl); if (!ssrf.allowed) { - return { connected: false, error: ssrf.error ?? 'Invalid Synology URL' }; + return fail(ssrf.error, 400); } - try { - const login = await loginToSynology(synologyUrl, synologyUsername, synologyPassword); - if (!login.success || !login.data?.sid) { - return { connected: false, error: login.error?.message || 'Authentication failed' }; - } - return { connected: true, user: { username: synologyUsername } }; - } catch (err: unknown) { - return { connected: false, error: err instanceof Error ? err.message : 'Connection failed' }; + + const resp = await _loginToSynology(synologyUrl, synologyUsername, synologyPassword); + if ('error' in resp) { + return success({ connected: false, error: resp.error.status === 400 ? 'Invalid credentials' : resp.error.message }); } + return success({ connected: true, user: { name: synologyUsername } }); } -export async function listSynologyAlbums(userId: number): Promise<{ albums: Array<{ id: string; albumName: string; assetCount: number }> }> { - const result = await requestSynologyApi<{ list: SynologyPhotoItem[] }>(userId, { +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; - if (!result.success || !result.data) { - throw new SynologyServiceError(result.error?.code || 500, result.error?.message || 'Failed to fetch albums'); - } - - const albums = (result.data.list || []).map((album: SynologyPhotoItem) => ({ + const albums = (result.data.list || []).map((album: any) => ({ id: String(album.id), albumName: album.name || '', assetCount: album.item_count || 0, })); - return { albums }; + return success({ albums }); } -export async function syncSynologyAlbumLink(userId: number, tripId: string, linkId: string): Promise<{ added: number; total: number }> { +export async function syncSynologyAlbumLink(userId: number, tripId: string, linkId: string): Promise> { const response = getAlbumIdFromLink(tripId, linkId, userId); - if (!response.success) { - throw new SynologyServiceError(404, 'Album link not found'); - } + if (!response.success) return response as ServiceResult<{ added: number; total: number }>; const allItems: SynologyPhotoItem[] = []; const pageSize = 1000; let offset = 0; while (true) { - const result = await requestSynologyApi<{ list: SynologyPhotoItem[] }>(userId, { + const result = await _requestSynologyApi<{ list: SynologyPhotoItem[] }>(userId, { api: 'SYNO.Foto.Browse.Item', method: 'list', version: 1, @@ -423,9 +409,7 @@ export async function syncSynologyAlbumLink(userId: number, tripId: string, link additional: ['thumbnail'], }); - if (!result.success || !result.data) { - throw new SynologyServiceError(502, result.error?.message || 'Failed to fetch album'); - } + if (!result.success) return result as ServiceResult<{ added: number; total: number }>; const items = result.data.list || []; allItems.push(...items); @@ -441,12 +425,12 @@ export async function syncSynologyAlbumLink(userId: number, tripId: string, link updateSyncTimeForAlbumLink(linkId); const result = await addTripPhotos(tripId, userId, true, [selection]); - if ('error' in result) throw new SynologyServiceError(result.error.status, result.error.message); + if (!result.success) return result as ServiceResult<{ added: number; total: number }>; - return { added: result.data.added, total: allItems.length }; + return success({ added: result.data.added, total: allItems.length }); } -export async function searchSynologyPhotos(userId: number, from?: string, to?: string, offset = 0, limit = 300): Promise<{ assets: SynologyPhotoInfo[]; total: number; hasMore: boolean }> { +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', @@ -466,25 +450,23 @@ export async function searchSynologyPhotos(userId: number, from?: string, to?: s } } - const result = await requestSynologyApi<{ list: SynologyPhotoItem[]; total: number }>(userId, params); - if (!result.success || !result.data) { - throw new SynologyServiceError(502, result.error?.message || 'Failed to fetch album photos'); - } + const result = await _requestSynologyApi<{ list: SynologyPhotoItem[]; total: number }>(userId, params); + if (!result.success) return result as ServiceResult<{ assets: SynologyPhotoInfo[]; total: number; hasMore: boolean }>; const allItems = result.data.list || []; const total = allItems.length; - const assets = allItems.map(item => normalizeSynologyPhotoInfo(item)); + const assets = allItems.map(item => _normalizeSynologyPhotoInfo(item)); - return { + return success({ assets, total, hasMore: total === limit, - }; + }); } -export async function getSynologyAssetInfo(userId: number, photoId: string, targetUserId?: number): Promise { - const parsedId = splitPackedSynologyId(photoId); - const result = await requestSynologyApi<{ list: SynologyPhotoItem[] }>(targetUserId ?? userId, { +export async function getSynologyAssetInfo(userId: number, photoId: string, targetUserId?: number): Promise> { + const parsedId = _splitPackedSynologyId(photoId); + const result = await _requestSynologyApi<{ list: SynologyPhotoItem[] }>(targetUserId, { api: 'SYNO.Foto.Browse.Item', method: 'get', version: 5, @@ -492,39 +474,41 @@ export async function getSynologyAssetInfo(userId: number, photoId: string, targ additional: ['resolution', 'exif', 'gps', 'address', 'orientation', 'description'], }); - if (!result.success || !result.data) { - throw new SynologyServiceError(404, 'Photo not found'); - } + if (!result.success) return result as ServiceResult; const metadata = result.data.list?.[0]; - if (!metadata) { - throw new SynologyServiceError(404, 'Photo not found'); - } + if (!metadata) return fail('Photo not found', 404); - const normalized = normalizeSynologyPhotoInfo(metadata); + const normalized = _normalizeSynologyPhotoInfo(metadata); normalized.id = photoId; - return normalized; + return success(normalized); } export async function streamSynologyAsset( + response: Response, userId: number, targetUserId: number, photoId: string, kind: 'thumbnail' | 'original', size?: string, -): Promise { - const parsedId = splitPackedSynologyId(photoId); - const synology_url = getSynologyCredentials(targetUserId).synology_url; - if (!synology_url) { - throw new SynologyServiceError(402, 'User not configured with Synology'); +): Promise { + const parsedId = _splitPackedSynologyId(photoId); + + const synology_credentials = _getSynologyCredentials(targetUserId); + if (!synology_credentials.success) { + handleServiceResult(response, synology_credentials); + return; } - const sid = await getSynologySession(targetUserId); - if (!sid.success || !sid.sid) { - throw new SynologyServiceError(401, 'Authentication failed'); + 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; } - - const params = kind === 'thumbnail' ? new URLSearchParams({ @@ -536,7 +520,7 @@ export async function streamSynologyAsset( type: 'unit', size: String(size || SYNOLOGY_DEFAULT_THUMBNAIL_SIZE), cache_key: parsedId.cacheKey, - _sid: sid.sid, + _sid: sid.data, }) : new URLSearchParams({ api: 'SYNO.Foto.Download', @@ -544,40 +528,11 @@ export async function streamSynologyAsset( version: '2', cache_key: parsedId.cacheKey, unit_id: `[${parsedId.id}]`, - _sid: sid.sid, + _sid: sid.data, }); - const url = `${buildSynologyEndpoint(synology_url)}?${params.toString()}`; - const resp = await requestSynologyStream(url); + const url = `${_buildSynologyEndpoint(synology_credentials.data.synology_url)}?${params.toString()}`; - if (!resp.ok) { - const body = kind === 'original' ? await resp.text() : 'Failed'; - throw new SynologyServiceError(resp.status, kind === 'original' ? `Failed: ${body}` : body); - } - - return { - status: resp.status, - headers: { - 'content-type': resp.headers.get('content-type') || (kind === 'thumbnail' ? 'image/jpeg' : 'application/octet-stream'), - 'cache-control': resp.headers.get('cache-control') || 'public, max-age=86400', - 'content-length': resp.headers.get('content-length'), - 'content-disposition': resp.headers.get('content-disposition'), - }, - body: resp.body, - }; + await pipeAsset(url, response) } -export async function pipeSynologyProxy(response: ExpressResponse, proxy: SynologyProxyResult): Promise { - response.status(proxy.status); - if (proxy.headers['content-type']) response.set('Content-Type', proxy.headers['content-type'] as string); - if (proxy.headers['cache-control']) response.set('Cache-Control', proxy.headers['cache-control'] as string); - if (proxy.headers['content-length']) response.set('Content-Length', proxy.headers['content-length'] as string); - if (proxy.headers['content-disposition']) response.set('Content-Disposition', proxy.headers['content-disposition'] as string); - - if (!proxy.body) { - response.end(); - return; - } - - await pipeline(Readable.fromWeb(proxy.body), response); -} From 3d0249e076ae49cbca08e63559a78707116c84c8 Mon Sep 17 00:00:00 2001 From: Marek Maslowski Date: Sat, 4 Apr 2026 18:16:46 +0200 Subject: [PATCH 6/9] finishing refactor --- server/src/routes/memories/synology.ts | 2 +- .../src/services/memories/helpersService.ts | 64 ++++++++++--- .../src/services/memories/synologyService.ts | 96 ++++++------------- 3 files changed, 77 insertions(+), 85 deletions(-) diff --git a/server/src/routes/memories/synology.ts b/server/src/routes/memories/synology.ts index 3bce3e4..94ddd88 100644 --- a/server/src/routes/memories/synology.ts +++ b/server/src/routes/memories/synology.ts @@ -108,7 +108,7 @@ router.get('/assets/:tripId/:photoId/:ownerId/info', authenticate, async (req: R router.get('/assets/:tripId/:photoId/:ownerId/:kind', authenticate, async (req: Request, res: Response) => { const authReq = req as AuthRequest; const { tripId, photoId, ownerId, kind } = req.params; - const { size = 'sm' } = req.query; + const { size = "sm" } = req.query; if (kind !== 'thumbnail' && kind !== 'original') { handleServiceResult(res, fail('Invalid asset kind', 400)); diff --git a/server/src/services/memories/helpersService.ts b/server/src/services/memories/helpersService.ts index 59a673d..a349269 100644 --- a/server/src/services/memories/helpersService.ts +++ b/server/src/services/memories/helpersService.ts @@ -32,7 +32,6 @@ export function handleServiceResult(res: Response, result: ServiceResult): res.status(result.error.status).json({ error: result.error.message }); } else { - console.log('Service result data:', result.data); res.json(result.data); } } @@ -52,22 +51,51 @@ export type StatusResult = { error: string }; +export type SyncAlbumResult = { + added: number; + total: number +}; + + export type AlbumsList = { albums: Array<{ id: string; albumName: string; assetCount: number }> }; -export type AssetInfo = { +export type Asset = { id: string; takenAt: string; }; export type AssetsList = { - assets: AssetInfo[], + assets: Asset[], total: number, hasMore: boolean }; +export type AssetInfo = { + id: string; + takenAt: string | null; + city: string | null; + country: string | null; + state?: string | null; + camera?: string | null; + lens?: string | null; + focalLength?: string | number | null; + aperture?: string | number | null; + shutter?: string | number | null; + iso?: string | number | null; + lat?: number | null; + lng?: number | null; + orientation?: number | null; + description?: string | null; + width?: number | null; + height?: number | null; + fileSize?: number | null; + fileName?: string | null; +} + + //for loading routes to settings page, and validating which services user has connected type PhotoProviderConfig = { settings_get: string; @@ -134,19 +162,25 @@ export function updateSyncTimeForAlbumLink(linkId: string): void { } export async function pipeAsset(url: string, response: Response): Promise { - const resp = await fetch(url); - - response.status(resp.status); - if (resp.headers.get('content-type')) response.set('Content-Type', resp.headers.get('content-type') as string); - if (resp.headers.get('cache-control')) response.set('Cache-Control', resp.headers.get('cache-control') as string); - if (resp.headers.get('content-length')) response.set('Content-Length', resp.headers.get('content-length') as string); - if (resp.headers.get('content-disposition')) response.set('Content-Disposition', resp.headers.get('content-disposition') as string); - - if (!resp.body) { - response.end(); + try{ + const resp = await fetch(url); + + response.status(resp.status); + if (resp.headers.get('content-type')) response.set('Content-Type', resp.headers.get('content-type') as string); + if (resp.headers.get('cache-control')) response.set('Cache-Control', resp.headers.get('cache-control') as string); + if (resp.headers.get('content-length')) response.set('Content-Length', resp.headers.get('content-length') as string); + if (resp.headers.get('content-disposition')) response.set('Content-Disposition', resp.headers.get('content-disposition') as string); + + if (!resp.body) { + response.end(); + } + else { + pipeline(Readable.fromWeb(resp.body), response); + } } - else { - pipeline(Readable.fromWeb(resp.body), response); + catch (error) { + response.status(500).json({ error: 'Failed to fetch asset' }); + response.end(); } } \ No newline at end of file diff --git a/server/src/services/memories/synologyService.ts b/server/src/services/memories/synologyService.ts index a444c20..76b8916 100644 --- a/server/src/services/memories/synologyService.ts +++ b/server/src/services/memories/synologyService.ts @@ -15,13 +15,20 @@ import { pipeAsset, AlbumsList, AssetsList, - StatusResult + StatusResult, + SyncAlbumResult, + AssetInfo } from './helpersService'; -const SYNOLOGY_API_TIMEOUT_MS = 30000; const SYNOLOGY_PROVIDER = 'synologyphotos'; const SYNOLOGY_ENDPOINT_PATH = '/photo/webapi/entry.cgi'; -const SYNOLOGY_DEFAULT_THUMBNAIL_SIZE = 'sm'; + +interface SynologyUserRecord { + synology_url?: string | null; + synology_username?: string | null; + synology_password?: string | null; + synology_sid?: string | null; +}; interface SynologyCredentials { synology_url: string; @@ -29,6 +36,12 @@ interface SynologyCredentials { synology_password: string; } +interface SynologySettings { + synology_url: string; + synology_username: string; + connected: boolean; +} + interface ApiCallParams { api: string; method: string; @@ -42,53 +55,6 @@ interface SynologyApiResponse { error?: { code: number }; } -export interface SynologySettings { - synology_url: string; - synology_username: string; - connected: boolean; -} - -export interface SynologyAlbumLinkInput { - album_id?: string | number; - album_name?: string; -} - -export interface SynologySearchInput { - from?: string; - to?: string; - offset?: number; - limit?: number; -} - -export interface SynologyProxyResult { - status: number; - headers: Record; - body: ReadableStream | null; -} - -interface SynologyPhotoInfo { - id: string; - takenAt: string | null; - city: string | null; - country: string | null; - state?: string | null; - camera?: string | null; - lens?: string | null; - focalLength?: string | number | null; - aperture?: string | number | null; - shutter?: string | number | null; - iso?: string | number | null; - lat?: number | null; - lng?: number | null; - orientation?: number | null; - description?: string | null; - filename?: string | null; - filesize?: number | null; - width?: number | null; - height?: number | null; - fileSize?: number | null; - fileName?: string | null; -} interface SynologyPhotoItem { id?: string | number; @@ -115,12 +81,6 @@ interface SynologyPhotoItem { }; } -type SynologyUserRecord = { - synology_url?: string | null; - synology_username?: string | null; - synology_password?: string | null; - synology_sid?: string | null; -}; function _readSynologyUser(userId: number, columns: string[]): ServiceResult { try { @@ -182,7 +142,7 @@ async function _fetchSynologyJson(url: string, body: URLSearchParams): Promis 'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8', }, body, - signal: AbortSignal.timeout(SYNOLOGY_API_TIMEOUT_MS), + signal: AbortSignal.timeout(30000), }); if (!resp.ok) { @@ -238,7 +198,7 @@ async function _requestSynologyApi(userId: number, params: ApiCallParams): Pr return result; } -function _normalizeSynologyPhotoInfo(item: SynologyPhotoItem): SynologyPhotoInfo { +function _normalizeSynologyPhotoInfo(item: SynologyPhotoItem): AssetInfo { const address = item.additional?.address || {}; const exif = item.additional?.exif || {}; const gps = item.additional?.gps || {}; @@ -259,8 +219,6 @@ function _normalizeSynologyPhotoInfo(item: SynologyPhotoItem): SynologyPhotoInfo lng: gps.longitude || null, orientation: item.additional?.orientation || null, description: item.additional?.description || null, - filename: item.filename || null, - filesize: item.filesize || null, width: item.additional?.resolution?.width || null, height: item.additional?.resolution?.height || null, fileSize: item.filesize || null, @@ -390,9 +348,9 @@ export async function listSynologyAlbums(userId: number): Promise> { +export async function syncSynologyAlbumLink(userId: number, tripId: string, linkId: string): Promise> { const response = getAlbumIdFromLink(tripId, linkId, userId); - if (!response.success) return response as ServiceResult<{ added: number; total: number }>; + if (!response.success) return response as ServiceResult; const allItems: SynologyPhotoItem[] = []; const pageSize = 1000; @@ -409,7 +367,7 @@ export async function syncSynologyAlbumLink(userId: number, tripId: string, link additional: ['thumbnail'], }); - if (!result.success) return result as ServiceResult<{ added: number; total: number }>; + if (!result.success) return result as ServiceResult; const items = result.data.list || []; allItems.push(...items); @@ -425,7 +383,7 @@ export async function syncSynologyAlbumLink(userId: number, tripId: string, link updateSyncTimeForAlbumLink(linkId); const result = await addTripPhotos(tripId, userId, true, [selection]); - if (!result.success) return result as ServiceResult<{ added: number; total: number }>; + if (!result.success) return result as ServiceResult; return success({ added: result.data.added, total: allItems.length }); } @@ -451,7 +409,7 @@ export async function searchSynologyPhotos(userId: number, from?: string, to?: s } const result = await _requestSynologyApi<{ list: SynologyPhotoItem[]; total: number }>(userId, params); - if (!result.success) return result as ServiceResult<{ assets: SynologyPhotoInfo[]; total: number; hasMore: boolean }>; + if (!result.success) return result as ServiceResult<{ assets: AssetInfo[]; total: number; hasMore: boolean }>; const allItems = result.data.list || []; const total = allItems.length; @@ -464,17 +422,17 @@ 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): Promise> { const parsedId = _splitPackedSynologyId(photoId); const result = await _requestSynologyApi<{ list: SynologyPhotoItem[] }>(targetUserId, { api: 'SYNO.Foto.Browse.Item', method: 'get', version: 5, - id: `[${parsedId.id}]`, + 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 (!result.success) return result as ServiceResult; + if (!result.success) return result as ServiceResult; const metadata = result.data.list?.[0]; if (!metadata) return fail('Photo not found', 404); @@ -518,7 +476,7 @@ export async function streamSynologyAsset( mode: 'download', id: parsedId.id, type: 'unit', - size: String(size || SYNOLOGY_DEFAULT_THUMBNAIL_SIZE), + size: size, cache_key: parsedId.cacheKey, _sid: sid.data, }) From 9f0ec8199fa7595991388d04aa0dd6d6c553677f Mon Sep 17 00:00:00 2001 From: Marek Maslowski Date: Sat, 4 Apr 2026 18:28:44 +0200 Subject: [PATCH 7/9] fixing db errors message --- server/src/services/memories/helpersService.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/server/src/services/memories/helpersService.ts b/server/src/services/memories/helpersService.ts index a349269..161d7bd 100644 --- a/server/src/services/memories/helpersService.ts +++ b/server/src/services/memories/helpersService.ts @@ -19,11 +19,11 @@ export function success(data: T): ServiceResult { } -export function mapDbError(error: unknown, fallbackMessage: string): ServiceError { - if (error instanceof Error && /unique|constraint/i.test(error.message)) { +export function mapDbError(error: Error, fallbackMessage: string): ServiceError { + if (error && /unique|constraint/i.test(error.message)) { return fail('Resource already exists', 409); } - return fail(fallbackMessage, 500); + return fail(error.message, 500); } From 5b25c60b6288676690289696d8fc0abd80f0ae94 Mon Sep 17 00:00:00 2001 From: Marek Maslowski Date: Sat, 4 Apr 2026 18:56:27 +0200 Subject: [PATCH 8/9] fixing migrations --- server/src/db/migrations.ts | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/server/src/db/migrations.ts b/server/src/db/migrations.ts index aee415e..fd0be24 100644 --- a/server/src/db/migrations.ts +++ b/server/src/db/migrations.ts @@ -692,6 +692,18 @@ function runMigrations(db: Database.Database): void { db.exec('ALTER TABLE photo_providers DROP COLUMN config'); }, + () => { + db.exec('ALTER TABLE `trip_photos` RENAME COLUMN immich_asset_id TO asset_id'); + }, + () => { + db.exec('ALTER TABLE `trip_photos` ADD COLUMN provider TEXT NOT NULL DEFAULT "immich"'); + }, + () => { + db.exec('ALTER TABLE `trip_album_links` ADD COLUMN provider TEXT NOT NULL DEFAULT "immich"'); + }, + () => { + db.exec('ALTER TABLE `trip_album_links` RENAME COLUMN immich_album_id TO album_id'); + }, ]; if (currentVersion < migrations.length) { From ba4bfc693a66d7da26afa5d096039cf66f370f0e Mon Sep 17 00:00:00 2001 From: Marek Maslowski Date: Sat, 4 Apr 2026 19:14:45 +0200 Subject: [PATCH 9/9] fixing schemas and making migrations not crash --- server/src/db/migrations.ts | 9 +++------ server/src/db/schema.ts | 3 +++ 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/server/src/db/migrations.ts b/server/src/db/migrations.ts index fd0be24..c9fd4ea 100644 --- a/server/src/db/migrations.ts +++ b/server/src/db/migrations.ts @@ -693,15 +693,12 @@ function runMigrations(db: Database.Database): void { db.exec('ALTER TABLE photo_providers DROP COLUMN config'); }, () => { + const columns = db.prepare("PRAGMA table_info('trip_photos')").all() as Array<{ name: string }>; + const names = new Set(columns.map(c => c.name)); + if (names.has('asset_id') && !names.has('immich_asset_id')) return; db.exec('ALTER TABLE `trip_photos` RENAME COLUMN immich_asset_id TO asset_id'); - }, - () => { db.exec('ALTER TABLE `trip_photos` ADD COLUMN provider TEXT NOT NULL DEFAULT "immich"'); - }, - () => { db.exec('ALTER TABLE `trip_album_links` ADD COLUMN provider TEXT NOT NULL DEFAULT "immich"'); - }, - () => { db.exec('ALTER TABLE `trip_album_links` RENAME COLUMN immich_album_id TO album_id'); }, ]; diff --git a/server/src/db/schema.ts b/server/src/db/schema.ts index e053df6..f18c8d0 100644 --- a/server/src/db/schema.ts +++ b/server/src/db/schema.ts @@ -18,6 +18,8 @@ function createTables(db: Database.Database): void { mfa_enabled INTEGER DEFAULT 0, mfa_secret TEXT, mfa_backup_codes TEXT, + immich_url TEXT, + immich_access_token TEXT, synology_url TEXT, synology_username TEXT, synology_password TEXT, @@ -166,6 +168,7 @@ function createTables(db: Database.Database): void { place_id INTEGER REFERENCES places(id) ON DELETE SET NULL, assignment_id INTEGER REFERENCES day_assignments(id) ON DELETE SET NULL, title TEXT NOT NULL, + accommodation_id TEXT, reservation_time TEXT, reservation_end_time TEXT, location TEXT,