From b4741c31a910891996d151cd47a4b97afe6e541b Mon Sep 17 00:00:00 2001 From: Marek Maslowski Date: Fri, 3 Apr 2026 16:25:45 +0200 Subject: [PATCH] moving business logic for synology to separet file --- server/src/routes/admin.ts | 5 +- server/src/routes/synology.ts | 720 +++++-------------------- server/src/services/synologyService.ts | 651 ++++++++++++++++++++++ 3 files changed, 783 insertions(+), 593 deletions(-) create mode 100644 server/src/services/synologyService.ts diff --git a/server/src/routes/admin.ts b/server/src/routes/admin.ts index 8558fe6..86896ab 100644 --- a/server/src/routes/admin.ts +++ b/server/src/routes/admin.ts @@ -1,6 +1,7 @@ import express, { Request, Response } from 'express'; import { authenticate, adminOnly } from '../middleware/auth'; -import { AuthRequest } from '../types'; +import { db } from '../db/database'; +import { AuthRequest, Addon } from '../types'; import { writeAudit, getClientIp, logInfo } from '../services/auditLog'; import * as svc from '../services/adminService'; @@ -355,7 +356,7 @@ router.put('/addons/:id', (req: Request, res: Response) => { action: 'admin.addon_update', resource: String(req.params.id), ip: getClientIp(req), - details: result.auditDetails, + details: { enabled: req.body.enabled, config: req.body.config }, }); res.json({ addon: updated }); }); diff --git a/server/src/routes/synology.ts b/server/src/routes/synology.ts index 71fed86..65f4dfb 100644 --- a/server/src/routes/synology.ts +++ b/server/src/routes/synology.ts @@ -1,645 +1,183 @@ -import express, { NextFunction, Request, Response } from 'express'; -import { Readable } from 'node:stream'; -import { pipeline } from 'node:stream/promises'; -import { db, canAccessTrip } from '../db/database'; +import express, { Request, Response } from 'express'; import { authenticate } from '../middleware/auth'; import { broadcast } from '../websocket'; import { AuthRequest } from '../types'; -import { maybe_encrypt_api_key, decrypt_api_key } from '../services/apiKeyCrypto'; -import { consumeEphemeralToken } from '../services/ephemeralTokens'; -import { checkSsrf } from '../utils/ssrfGuard'; +import { + getSynologySettings, + updateSynologySettings, + getSynologyStatus, + testSynologyConnection, + listSynologyAlbums, + linkSynologyAlbum, + syncSynologyAlbumLink, + searchSynologyPhotos, + getSynologyAssetInfo, + pipeSynologyProxy, + synologyAuthFromQuery, + getSynologyTargetUserId, + streamSynologyAsset, + handleSynologyError, + SynologyServiceError, +} from '../services/synologyService'; const router = express.Router(); -function copyProxyHeaders(resp: Response, upstream: globalThis.Response, headerNames: string[]): void { - for (const headerName of headerNames) { - const value = upstream.headers.get(headerName); - if (value) { - resp.set(headerName, value); - } - } +function parseStringBodyField(value: unknown): string { + return String(value ?? '').trim(); } -// Helper: Get Synology credentials from users table -function getSynologyCredentials(userId: number) { - try { - const user = db.prepare('SELECT synology_url, synology_username, synology_password FROM users WHERE id = ?').get(userId) as any; - if (!user?.synology_url || !user?.synology_username || !user?.synology_password) return null; - return { - synology_url: user.synology_url as string, - synology_username: user.synology_username as string, - synology_password: decrypt_api_key(user.synology_password) as string, - }; - } catch { - return null; - } +function parseNumberBodyField(value: unknown, fallback: number): number { + const parsed = Number(value); + return Number.isFinite(parsed) ? parsed : fallback; } -// Helper: Get cached SID from settings or users table -function getCachedSynologySID(userId: number) { - try { - const row = db.prepare('SELECT synology_sid FROM users WHERE id = ?').get(userId) as any; - return row?.synology_sid || null; - } catch { - return null; - } -} - -// Helper: Cache SID in users table -function cacheSynologySID(userId: number, sid: string) { - try { - db.prepare('UPDATE users SET synology_sid = ? WHERE id = ?').run(sid, userId); - } catch (err) { - // Ignore if columns don't exist yet - } -} - -// Helper: Get authenticated session - -interface SynologySession { - success: boolean; - sid?: string; - error?: { code: number; message?: string }; -} - -async function getSynologySession(userId: number): Promise { - // Check for cached SID - const cachedSid = getCachedSynologySID(userId); - if (cachedSid) { - return { success: true, sid: cachedSid }; - } - - const creds = getSynologyCredentials(userId); - // Login with credentials - if (!creds) { - return { success: false, error: { code: 400, message: 'Invalid Synology credentials' } }; - } - const endpoint = prepareSynologyEndpoint(creds.synology_url); - - const body = new URLSearchParams({ - api: 'SYNO.API.Auth', - method: 'login', - version: '3', - account: creds.synology_username, - passwd: creds.synology_password, - }); - - const resp = await fetch(endpoint, { - method: 'POST', - headers: { - 'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8', - }, - body, - signal: AbortSignal.timeout(30000), - }); - - if (!resp.ok) { - return { success: false, error: { code: resp.status, message: 'Failed to authenticate with Synology' } }; - } - - const data = await resp.json() as { success: boolean; data?: { sid?: string } }; - - if (data.success && data.data?.sid) { - const sid = data.data.sid; - cacheSynologySID(userId, sid); - return { success: true, sid }; - } - - return { success: false, error: { code: 500, message: 'Failed to get Synology session' } }; -} - -// Helper: Clear cached SID - -function clearSynologySID(userId: number): void { - try { - db.prepare('UPDATE users SET synology_sid = NULL WHERE id = ?').run(userId); - } catch { - // Ignore if columns don't exist yet - } -} - -interface ApiCallParams { - api: string; - method: string; - version?: number; - [key: string]: any; -} - -interface SynologyApiResponse { - success: boolean; - data?: T; - error?: { code: number, message?: string }; -} - -function prepareSynologyEndpoint(url: string): string { - url = url.replace(/\/$/, ''); - if (!/^https?:\/\//.test(url)) { - url = `https://${url}`; - } - return `${url}/photo/webapi/entry.cgi`; -} - -function splitPackedSynologyId(rawId: string): { id: string; cacheKey: string; assetId: string } { - const id = rawId.split('_')[0]; - return { id: id, cacheKey: rawId, assetId: rawId }; -} - -function transformSynologyPhoto(item: any): any { - const address = item.additional?.address || {}; - return { - id: item.additional?.thumbnail?.cache_key, - takenAt: item.time ? new Date(item.time * 1000).toISOString() : null, - city: address.city || null, - country: address.country || null, - }; -} - -async function callSynologyApi(userId: number, params: ApiCallParams): Promise> { - try { - const creds = getSynologyCredentials(userId); - if (!creds) { - return { success: false, error: { code: 400, message: 'Synology not configured' } }; - } - const endpoint = prepareSynologyEndpoint(creds.synology_url); - - - const body = new URLSearchParams(); - for (const [key, value] of Object.entries(params)) { - if (value === undefined || value === null) continue; - body.append(key, typeof value === 'object' ? JSON.stringify(value) : String(value)); - } - - const sid = await getSynologySession(userId); - if (!sid.success || !sid.sid) { - return { success: false, error: sid.error || { code: 500, message: 'Failed to get Synology session' } }; - } - body.append('_sid', sid.sid); - - const resp = await fetch(endpoint, { - method: 'POST', - headers: { - 'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8', - }, - body, - signal: AbortSignal.timeout(30000), - }); - - - if (!resp.ok) { - const text = await resp.text(); - return { success: false, error: { code: resp.status, message: text } }; - } - - const result = await resp.json() as SynologyApiResponse; - if (!result.success && result.error?.code === 119) { - clearSynologySID(userId); - return callSynologyApi(userId, params); - } - return result; - } catch (err) { - return { success: false, error: { code: -1, message: err instanceof Error ? err.message : 'Unknown error' } }; - } -} - -// Settings router.get('/settings', authenticate, (req: Request, res: Response) => { - const authReq = req as AuthRequest; - const creds = getSynologyCredentials(authReq.user.id); - res.json({ - synology_url: creds?.synology_url || '', - synology_username: creds?.synology_username || '', - connected: !!(creds?.synology_url && creds?.synology_username), - }); + const authReq = req as AuthRequest; + res.json(getSynologySettings(authReq.user.id)); }); -router.put('/settings', authenticate, (req: Request, res: Response) => { - const authReq = req as AuthRequest; - const { synology_url, synology_username, synology_password } = req.body; +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 url = String(synology_url || '').trim(); - const username = String(synology_username || '').trim(); - const password = String(synology_password || '').trim(); + if (!synology_url || !synology_username) { + return handleSynologyError(res, new SynologyServiceError(400, 'URL and username are required'), 'Missing required fields'); + } - if (!url || !username) { - return res.status(400).json({ error: 'URL and username are required' }); - } - - const existing = db.prepare('SELECT synology_password FROM users WHERE id = ?').get(authReq.user.id) as { synology_password?: string | null } | undefined; - const existingEncryptedPassword = existing?.synology_password || null; - - // First-time setup requires password; later updates may keep existing password. - if (!password && !existingEncryptedPassword) { - return res.status(400).json({ error: 'Password is required' }); - } - - try { - db.prepare('UPDATE users SET synology_url = ?, synology_username = ?, synology_password = ? WHERE id = ?').run( - url, - username, - password ? maybe_encrypt_api_key(password) : existingEncryptedPassword, - authReq.user.id - ); - } catch (err) { - return res.status(400).json({ error: 'Failed to save settings' }); - } - - clearSynologySID(authReq.user.id); - res.json({ success: true }); + 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'); + } }); -// Status router.get('/status', authenticate, async (req: Request, res: Response) => { - const authReq = req as AuthRequest; - - try { - const sid = await getSynologySession(authReq.user.id); - if (!sid.success || !sid.sid) { - return res.json({ connected: false, error: 'Authentication failed' }); - } - - const user = db.prepare('SELECT synology_username FROM users WHERE id = ?').get(authReq.user.id) as any; - res.json({ connected: true, user: { username: user.synology_username } }); - } catch (err: unknown) { - res.json({ connected: false, error: err instanceof Error ? err.message : 'Connection failed' }); - } + const authReq = req as AuthRequest; + res.json(await getSynologyStatus(authReq.user.id)); }); -// Test connection with provided credentials only router.post('/test', authenticate, async (req: Request, res: Response) => { - const { synology_url, synology_username, synology_password } = req.body as { synology_url?: string; synology_username?: string; synology_password?: string }; + 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 url = String(synology_url || '').trim(); - const username = String(synology_username || '').trim(); - const password = String(synology_password || '').trim(); - - if (!url || !username || !password) { - return res.json({ connected: false, error: 'URL, username, and password are required' }); - } - - const ssrf = await checkSsrf(url); - if (!ssrf.allowed) return res.json({ connected: false, error: ssrf.error ?? 'Invalid Synology URL' }); - - try { - const endpoint = prepareSynologyEndpoint(url); - const body = new URLSearchParams({ - api: 'SYNO.API.Auth', - method: 'login', - version: '3', - account: username, - passwd: password, - }); - - const resp = await fetch(endpoint, { - method: 'POST', - headers: { - 'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8', - }, - body, - signal: AbortSignal.timeout(30000), - }); - - if (!resp.ok) return res.json({ connected: false, error: `HTTP ${resp.status}` }); - const data = await resp.json() as { success: boolean; data?: { sid?: string } }; - if (!data.success || !data.data?.sid) return res.json({ connected: false, error: 'Authentication failed' }); - return res.json({ connected: true, user: { username } }); - } catch (err: unknown) { - return res.json({ connected: false, error: err instanceof Error ? err.message : 'Connection failed' }); - } -}); - -// Album linking parity with Immich -router.get('/albums', authenticate, async (req: Request, res: Response) => { - const authReq = req as AuthRequest; - try { - const result = await callSynologyApi<{ list: any[] }>(authReq.user.id, { - api: 'SYNO.Foto.Browse.Album', - method: 'list', - version: 4, - offset: 0, - limit: 100, - }); - - if (!result.success || !result.data) { - return res.status(502).json({ error: result.error?.message || 'Failed to fetch albums' }); + if (!synology_url || !synology_username || !synology_password) { + return handleSynologyError(res, new SynologyServiceError(400, 'URL, username and password are required'), 'Missing required fields'); } - const albums = (result.data.list || []).map((a: any) => ({ - id: String(a.id), - albumName: a.name || '', - assetCount: a.item_count || 0, - })); + res.json(await testSynologyConnection(synology_url, synology_username, synology_password)); +}); - res.json({ albums }); - } catch (err: unknown) { - res.status(502).json({ error: err instanceof Error ? err.message : 'Could not reach Synology' }); - } +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'); + } }); router.post('/trips/:tripId/album-links', authenticate, (req: Request, res: Response) => { - const authReq = req as AuthRequest; - const { tripId } = req.params; - if (!canAccessTrip(tripId, authReq.user.id)) return res.status(404).json({ error: 'Trip not found' }); - const { album_id, album_name } = req.body; - if (!album_id) return res.status(400).json({ error: 'album_id required' }); + const authReq = req as AuthRequest; + const { tripId } = req.params; + const body = req.body as Record; + const albumId = parseStringBodyField(body.album_id); + const albumName = parseStringBodyField(body.album_name); - try { - db.prepare( - 'INSERT OR IGNORE INTO trip_album_links (trip_id, user_id, provider, album_id, album_name) VALUES (?, ?, ?, ?, ?)' - ).run(tripId, authReq.user.id, 'synologyphotos', String(album_id), album_name || ''); - res.json({ success: true }); - } catch { - res.status(400).json({ error: 'Album already linked' }); - } + if (!albumId) { + return handleSynologyError(res, new SynologyServiceError(400, 'Album ID is required'), 'Missing required fields'); + } + + try { + linkSynologyAlbum(authReq.user.id, tripId, albumId, albumName || undefined); + res.json({ success: true }); + } catch (err: unknown) { + handleSynologyError(res, err, 'Failed to link album'); + } }); router.post('/trips/:tripId/album-links/:linkId/sync', authenticate, async (req: Request, res: Response) => { - const authReq = req as AuthRequest; - const { tripId, linkId } = req.params; + const authReq = req as AuthRequest; + const { tripId, linkId } = req.params; - const link = db.prepare("SELECT * FROM trip_album_links WHERE id = ? AND trip_id = ? AND user_id = ? AND provider = 'synologyphotos'") - .get(linkId, tripId, authReq.user.id) as any; - if (!link) return res.status(404).json({ error: 'Album link not found' }); - - try { - const allItems: any[] = []; - const pageSize = 1000; - let offset = 0; - - while (true) { - const result = await callSynologyApi<{ list: any[] }>(authReq.user.id, { - api: 'SYNO.Foto.Browse.Item', - method: 'list', - version: 1, - album_id: Number(link.album_id), - offset, - limit: pageSize, - additional: ['thumbnail'], - }); - - if (!result.success || !result.data) { - return res.status(502).json({ error: result.error?.message || 'Failed to fetch album' }); - } - - const items = result.data.list || []; - allItems.push(...items); - if (items.length < pageSize) break; - offset += pageSize; + 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'); } - - const insert = db.prepare( - "INSERT OR IGNORE INTO trip_photos (trip_id, user_id, asset_id, provider, shared) VALUES (?, ?, ?, 'synologyphotos', 1)" - ); - - let added = 0; - for (const item of allItems) { - const transformed = transformSynologyPhoto(item); - const assetId = String(transformed?.id || '').trim(); - if (!assetId) continue; - const r = insert.run(tripId, authReq.user.id, assetId); - if (r.changes > 0) added++; - } - - db.prepare('UPDATE trip_album_links SET last_synced_at = CURRENT_TIMESTAMP WHERE id = ?').run(linkId); - - res.json({ success: true, added, total: allItems.length }); - if (added > 0) { - broadcast(tripId, 'memories:updated', { userId: authReq.user.id }, req.headers['x-socket-id'] as string); - } - } catch (err: unknown) { - res.status(502).json({ error: err instanceof Error ? err.message : 'Could not reach Synology' }); - } }); -// Search router.post('/search', authenticate, async (req: Request, res: Response) => { - const authReq = req as AuthRequest; - let { from, to, offset = 0, limit = 300 } = req.body; + 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); - try { - const params: any = { - api: 'SYNO.Foto.Search.Search', - method: 'list_item', - version: 1, - offset, - limit, - keyword: '.', - additional: ['thumbnail', 'address'], - }; - - if (from || to) { - if (from) { - params.start_time = Math.floor(new Date(from).getTime() / 1000); - } - if (to) { - params.end_time = Math.floor(new Date(to).getTime() / 1000) + 86400; // Include entire end day - } + 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'); } - - - const result = await callSynologyApi<{ list: any[]; total: number }>(authReq.user.id, params); - - if (!result.success || !result.data) { - return res.status(502).json({ error: result.error?.message || 'Failed to fetch album photos' }); - } - - const allItems = (result.data.list || []); - const total = allItems.length; - - const assets = allItems.map((item: any) => transformSynologyPhoto(item)); - - res.json({ - assets, - total, - hasMore: total == limit, - }); - } catch (err: unknown) { - res.status(502).json({ error: err instanceof Error ? err.message : 'Could not reach Synology' }); - } }); -// Proxy Synology Assets - -// Asset info endpoint (returns metadata, not image) router.get('/assets/:photoId/info', authenticate, async (req: Request, res: Response) => { - const authReq = req as AuthRequest; - const { photoId } = req.params; - const parsedId = splitPackedSynologyId(photoId); - const { userId } = req.query; + const authReq = req as AuthRequest; + const { photoId } = req.params; - const targetUserId = userId ? Number(userId) : authReq.user.id; - - try { - const result = await callSynologyApi(targetUserId, { - api: 'SYNO.Foto.Browse.Item', - method: 'get', - version: 5, - id: `[${parsedId.id}]`, - additional: ['resolution', 'exif', 'gps', 'address', 'orientation', 'description'], - }); - if (!result.success || !result.data) { - return res.status(404).json({ error: 'Photo not found' }); + try { + res.json(await getSynologyAssetInfo(authReq.user.id, photoId, getSynologyTargetUserId(req))); + } catch (err: unknown) { + handleSynologyError(res, err, 'Could not reach Synology'); } - - const metadata = result.data.list[0]; - console.log(metadata); - const exif = metadata.additional?.exif || {}; - const address = metadata.additional?.address || {}; - const gps = metadata.additional?.gps || {}; - res.json({ - id: photoId, - takenAt: metadata.time ? new Date(metadata.time * 1000).toISOString() : null, - city: address.city || null, - country: address.country || null, - state: address.state || null, - camera: exif.camera || null, - lens: exif.lens || null, - focalLength: exif.focal_length || null, - aperture: exif.aperture || null, - shutter: exif.exposure_time || null, - iso: exif.iso || null, - lat: gps.latitude || null, - lng: gps.longitude || null, - orientation: metadata.additional?.orientation || null, - description: metadata.additional?.description || null, - filename: metadata.filename || null, - filesize: metadata.filesize || null, - width: metadata.additional?.resolution?.width || null, - height: metadata.additional?.resolution?.height || null, - fileSize: metadata.filesize || null, - fileName: metadata.filename || null, - }); - } catch (err: unknown) { - res.status(502).json({ error: err instanceof Error ? err.message : 'Could not reach Synology'}); - } }); -// Middleware: Accept ephemeral token from query param for tags -function authFromQuery(req: Request, res: Response, next: NextFunction) { - const queryToken = req.query.token as string | undefined; - if (queryToken) { - const userId = consumeEphemeralToken(queryToken, 'synologyphotos'); - if (!userId) return res.status(401).send('Invalid or expired token'); - const user = db.prepare('SELECT id, username, email, role, mfa_enabled FROM users WHERE id = ?').get(userId) as any; - if (!user) return res.status(401).send('User not found'); - (req as AuthRequest).user = user; - return next(); - } - return (authenticate as any)(req, res, next); -} +router.get('/assets/:photoId/thumbnail', synologyAuthFromQuery, async (req: Request, res: Response) => { + const authReq = req as AuthRequest; + const { photoId } = req.params; + const { size = 'sm' } = req.query; -router.get('/assets/:photoId/thumbnail', authFromQuery, async (req: Request, res: Response) => { - const authReq = req as AuthRequest; - const { photoId } = req.params; - const parsedId = splitPackedSynologyId(photoId); - const { userId, size = 'sm' } = req.query; - - const targetUserId = userId ? Number(userId) : authReq.user.id; - - const creds = getSynologyCredentials(targetUserId); - if (!creds) { - return res.status(404).send('Not found'); - } - - try { - const sid = await getSynologySession(authReq.user.id); - if (!sid.success && !sid.sid) { - return res.status(401).send('Authentication failed'); + try { + const proxy = await streamSynologyAsset(authReq.user.id, getSynologyTargetUserId(req), photoId, 'thumbnail', String(size)); + await pipeSynologyProxy(res, proxy); + } catch (err: unknown) { + if (res.headersSent) { + return; + } + handleSynologyError(res, err, 'Proxy error'); } - - const params = new URLSearchParams({ - api: 'SYNO.Foto.Thumbnail', - method: 'get', - version: '2', - mode: 'download', - id: parsedId.id, - type: 'unit', - size: String(size), - cache_key: parsedId.cacheKey, - _sid: sid.sid, - }); - const url = prepareSynologyEndpoint(creds.synology_url) + '?' + params.toString(); - const resp = await fetch(url, { - signal: AbortSignal.timeout(30000), - }); - - if (!resp.ok) { - return res.status(resp.status).send('Failed'); - } - - res.status(resp.status); - copyProxyHeaders(res, resp, ['content-type', 'cache-control', 'content-length', 'content-disposition']); - res.set('Content-Type', resp.headers.get('content-type') || 'image/jpeg'); - res.set('Cache-Control', resp.headers.get('cache-control') || 'public, max-age=86400'); - - if (!resp.body) { - return res.end(); - } - - await pipeline(Readable.fromWeb(resp.body), res); - } catch (err: unknown) { - if (res.headersSent) { - return; - } - res.status(502).send('Proxy error: ' + (err instanceof Error ? err.message : String(err))); - } }); +router.get('/assets/:photoId/original', synologyAuthFromQuery, async (req: Request, res: Response) => { + const authReq = req as AuthRequest; + const { photoId } = req.params; -router.get('/assets/:photoId/original', authFromQuery, async (req: Request, res: Response) => { - const authReq = req as AuthRequest; - const { photoId } = req.params; - const parsedId = splitPackedSynologyId(photoId || ''); - const { userId} = req.query; - - const targetUserId = userId ? Number(userId) : authReq.user.id; - - const creds = getSynologyCredentials(targetUserId); - if (!creds) { - return res.status(404).send('Not found'); - } - - try { - const sid = await getSynologySession(authReq.user.id); - if (!sid.success && !sid.sid) { - return res.status(401).send('Authentication failed'); + try { + const proxy = await streamSynologyAsset(authReq.user.id, getSynologyTargetUserId(req), photoId, 'original'); + await pipeSynologyProxy(res, proxy); + } catch (err: unknown) { + if (res.headersSent) { + return; + } + handleSynologyError(res, err, 'Proxy error'); } - - const params = new URLSearchParams({ - api: 'SYNO.Foto.Download', - method: 'download', - version: '2', - cache_key: parsedId.cacheKey, - unit_id: `[${parsedId.id}]`, - _sid: sid.sid, - }); - - const url = prepareSynologyEndpoint(creds.synology_url) + '?' + params.toString(); - const resp = await fetch(url, { - signal: AbortSignal.timeout(30000), - }); - - if (!resp.ok) { - const body = await resp.text(); - return res.status(resp.status).send('Failed: ' + body); - } - - res.status(resp.status); - copyProxyHeaders(res, resp, ['content-type', 'cache-control', 'content-length', 'content-disposition']); - res.set('Content-Type', resp.headers.get('content-type') || 'application/octet-stream'); - res.set('Cache-Control', resp.headers.get('cache-control') || 'public, max-age=86400'); - - if (!resp.body) { - return res.end(); - } - - await pipeline(Readable.fromWeb(resp.body), res); - } catch (err: unknown) { - if (res.headersSent) { - return; - } - res.status(502).send('Proxy error: ' + (err instanceof Error ? err.message : String(err))); - } }); - -export default router; +export default router; \ No newline at end of file diff --git a/server/src/services/synologyService.ts b/server/src/services/synologyService.ts new file mode 100644 index 0000000..1c25ef5 --- /dev/null +++ b/server/src/services/synologyService.ts @@ -0,0 +1,651 @@ +import { Readable } from 'node:stream'; +import { pipeline } from 'node:stream/promises'; +import { NextFunction, Request, Response as ExpressResponse } from 'express'; +import { db, canAccessTrip } from '../db/database'; +import { decrypt_api_key, maybe_encrypt_api_key } from './apiKeyCrypto'; +import { authenticate } from '../middleware/auth'; +import { AuthRequest } from '../types'; +import { consumeEphemeralToken } from './ephemeralTokens'; +import { checkSsrf } from '../utils/ssrfGuard'; +import { no } from 'zod/locales'; + +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 SynologyCredentials { + synology_url: string; + synology_username: string; + synology_password: string; +} + +interface SynologySession { + success: boolean; + sid?: string; + error?: { code: number; message?: string }; +} + +interface ApiCallParams { + api: string; + method: string; + version?: number; + [key: string]: unknown; +} + +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; + } +} + +export interface SynologySettings { + synology_url: string; + synology_username: string; + connected: boolean; +} + +export interface SynologyConnectionResult { + connected: boolean; + user?: { username: string }; + error?: string; +} + +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; + filename?: string; + filesize?: number; + time?: number; + item_count?: number; + name?: string; + additional?: { + thumbnail?: { cache_key?: string }; + address?: { city?: string; country?: string; state?: string }; + resolution?: { width?: number; height?: number }; + exif?: { + camera?: string; + lens?: string; + focal_length?: string | number; + aperture?: string | number; + exposure_time?: string | number; + iso?: string | number; + }; + gps?: { latitude?: number; longitude?: number }; + orientation?: number; + description?: string; + }; +} + +type SynologyUserRecord = { + synology_url?: string | null; + synology_username?: string | null; + synology_password?: string | null; + synology_sid?: string | null; +}; + +function readSynologyUser(userId: number, columns: string[]): SynologyUserRecord | null { + 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; + + const filtered: SynologyUserRecord = {}; + for (const column of columns) { + filtered[column] = row[column]; + } + + return filtered || null; + } catch { + return null; + } +} + +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 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 { + const body = new URLSearchParams(); + for (const [key, value] of Object.entries(params)) { + if (value === undefined || value === null) continue; + body.append(key, typeof value === 'object' ? JSON.stringify(value) : String(value)); + } + return body; +} + +async function fetchSynologyJson(url: string, body: URLSearchParams): Promise> { + const endpoint = buildSynologyEndpoint(url); + const resp = await fetch(endpoint, { + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8', + }, + body, + signal: AbortSignal.timeout(SYNOLOGY_API_TIMEOUT_MS), + }); + + if (!resp.ok) { + const text = await resp.text(); + return { success: false, error: { code: resp.status, message: text } }; + } + + return resp.json() as Promise>; +} + +async function loginToSynology(url: string, username: string, password: string): Promise> { + const body = new URLSearchParams({ + api: 'SYNO.API.Auth', + method: 'login', + version: '3', + account: username, + passwd: password, + }); + + return fetchSynologyJson<{ sid?: string }>(url, body); +} + +async function requestSynologyApi(userId: number, params: ApiCallParams): Promise> { + const creds = getSynologyCredentials(userId); + if (!creds) { + return { success: false, error: { code: 400, message: 'Synology not configured' } }; + } + + 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 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' } }; + } + return fetchSynologyJson(creds.synology_url, buildSynologyFormBody({ ...params, _sid: retrySession.sid })); + } + return result; +} + +async function requestSynologyStream(url: string): Promise { + return fetch(url, { + signal: AbortSignal.timeout(SYNOLOGY_API_TIMEOUT_MS), + }); +} + +function normalizeSynologyPhotoInfo(item: SynologyPhotoItem): SynologyPhotoInfo { + const address = item.additional?.address || {}; + const exif = item.additional?.exif || {}; + const gps = item.additional?.gps || {}; + + return { + id: String(item.additional?.thumbnail?.cache_key || ''), + takenAt: item.time ? new Date(item.time * 1000).toISOString() : null, + city: address.city || null, + country: address.country || null, + state: address.state || null, + camera: exif.camera || null, + lens: exif.lens || null, + focalLength: exif.focal_length || null, + aperture: exif.aperture || null, + shutter: exif.exposure_time || null, + iso: exif.iso || null, + lat: gps.latitude || null, + 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, + fileName: item.filename || null, + }; +} + +export function synologyAuthFromQuery(req: Request, res: ExpressResponse, next: NextFunction) { + const queryToken = req.query.token as string | undefined; + if (queryToken) { + const userId = consumeEphemeralToken(queryToken, SYNOLOGY_PROVIDER); + if (!userId) return res.status(401).send('Invalid or expired token'); + const user = db.prepare('SELECT id, username, email, role, mfa_enabled FROM users WHERE id = ?').get(userId) as any; + if (!user) return res.status(401).send('User not found'); + (req as AuthRequest).user = user; + return next(); + } + return (authenticate as any)(req, res, next); +} + +export function getSynologyTargetUserId(req: Request): number { + const { userId } = req.query; + return Number(userId); +} + +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 { + db.prepare('UPDATE users SET synology_sid = ? WHERE id = ?').run(sid, userId); +} + +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 } { + const id = rawId.split('_')[0]; + return { id, cacheKey: rawId, assetId: rawId }; +} + +function canStreamSynologyAsset(requestingUserId: number, targetUserId: number, assetId: string): boolean { + if (requestingUserId === targetUserId) { + return true; + } + + const sharedAsset = db.prepare(` + SELECT 1 + FROM trip_photos + WHERE user_id = ? + AND asset_id = ? + AND provider = 'synologyphotos' + AND shared = 1 + LIMIT 1 + `).get(targetUserId, assetId); + + return !!sharedAsset; +} + +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' } }; + } + + 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' } }; + } + + cacheSynologySID(userId, resp.data.sid); + return { success: true, sid: resp.data.sid }; +} + +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 || '', + connected: session.success, + }; +} + +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'); + } + + const existingEncryptedPassword = readSynologyUser(userId, ['synology_password'])?.synology_password || null; + + if (!synologyPassword && !existingEncryptedPassword) { + throw new SynologyServiceError(400, 'No stored password found. Please provide a password to save settings.'); + } + + try { + db.prepare('UPDATE users SET synology_url = ?, synology_username = ?, synology_password = ? WHERE id = ?').run( + synologyUrl, + synologyUsername, + synologyPassword ? maybe_encrypt_api_key(synologyPassword) : existingEncryptedPassword, + userId, + ); + } catch { + throw new SynologyServiceError(400, 'Failed to save settings'); + } + + clearSynologySID(userId); + await getSynologySession(userId); +} + +export async function getSynologyStatus(userId: number): Promise { + 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 || '' } }; + } catch (err: unknown) { + return { connected: false, error: err instanceof Error ? err.message : 'Connection failed' }; + } +} + +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' }; + } + 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' }; + } +} + +export async function listSynologyAlbums(userId: number): Promise<{ albums: Array<{ id: string; albumName: string; assetCount: number }> }> { + const result = await requestSynologyApi<{ list: SynologyPhotoItem[] }>(userId, { + api: 'SYNO.Foto.Browse.Album', + method: 'list', + version: 4, + offset: 0, + limit: 100, + }); + + 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) => ({ + id: String(album.id), + albumName: album.name || '', + assetCount: album.item_count || 0, + })); + + return { albums }; +} + +export function linkSynologyAlbum(userId: number, tripId: string, albumId: string | number | undefined, albumName?: string): void { + if (!canAccessTrip(tripId, userId)) { + throw new SynologyServiceError(404, 'Trip not found'); + } + + if (!albumId) { + throw new SynologyServiceError(400, 'album_id required'); + } + + const changes = db.prepare( + 'INSERT OR IGNORE INTO trip_album_links (trip_id, user_id, provider, album_id, album_name) VALUES (?, ?, ?, ?, ?)' + ).run(tripId, userId, SYNOLOGY_PROVIDER, String(albumId), albumName || '').changes; + + if (changes === 0) { + throw new SynologyServiceError(400, 'Album already linked'); + } +} + +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) { + throw new SynologyServiceError(404, 'Album link not found'); + } + + const allItems: SynologyPhotoItem[] = []; + const pageSize = 1000; + let offset = 0; + + while (true) { + const result = await requestSynologyApi<{ list: SynologyPhotoItem[] }>(userId, { + api: 'SYNO.Foto.Browse.Item', + method: 'list', + version: 1, + album_id: Number(link.album_id), + offset, + limit: pageSize, + additional: ['thumbnail'], + }); + + if (!result.success || !result.data) { + throw new SynologyServiceError(502, result.error?.message || 'Failed to fetch album'); + } + + const items = result.data.list || []; + allItems.push(...items); + if (items.length < pageSize) break; + offset += pageSize; + } + + const insert = db.prepare( + "INSERT OR IGNORE INTO trip_photos (trip_id, user_id, asset_id, provider, shared) VALUES (?, ?, ?, 'synologyphotos', 1)" + ); + + 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++; + } + + db.prepare('UPDATE trip_album_links SET last_synced_at = CURRENT_TIMESTAMP WHERE id = ?').run(linkId); + + return { 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 }> { + const params: ApiCallParams = { + api: 'SYNO.Foto.Search.Search', + method: 'list_item', + version: 1, + offset, + limit, + keyword: '.', + additional: ['thumbnail', 'address'], + }; + + if (from || to) { + if (from) { + params.start_time = Math.floor(new Date(from).getTime() / 1000); + } + if (to) { + params.end_time = Math.floor(new Date(to).getTime() / 1000) + 86400; //adding it as the next day 86400 seconds in day + } + } + + 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 allItems = result.data.list || []; + const total = allItems.length; + const assets = allItems.map(item => normalizeSynologyPhotoInfo(item)); + + return { + assets, + total, + hasMore: total === limit, + }; +} + +export async function getSynologyAssetInfo(userId: number, photoId: string, targetUserId?: number): Promise { + if (!canStreamSynologyAsset(userId, targetUserId ?? userId, photoId)) { + throw new SynologyServiceError(403, 'Youd don\'t have access to this photo'); + } + const parsedId = splitPackedSynologyId(photoId); + const result = await requestSynologyApi<{ list: SynologyPhotoItem[] }>(targetUserId ?? userId, { + api: 'SYNO.Foto.Browse.Item', + method: 'get', + version: 5, + id: `[${parsedId.id}]`, + additional: ['resolution', 'exif', 'gps', 'address', 'orientation', 'description'], + }); + + if (!result.success || !result.data) { + throw new SynologyServiceError(404, 'Photo not found'); + } + + const metadata = result.data.list?.[0]; + if (!metadata) { + throw new SynologyServiceError(404, 'Photo not found'); + } + + const normalized = normalizeSynologyPhotoInfo(metadata); + normalized.id = photoId; + return normalized; +} + +export async function streamSynologyAsset( + userId: number, + targetUserId: number, + photoId: string, + kind: 'thumbnail' | 'original', + size?: string, +): Promise { + if (!canStreamSynologyAsset(userId, targetUserId, photoId)) { + throw new SynologyServiceError(403, 'Youd don\'t have access to this photo'); + } + + const parsedId = splitPackedSynologyId(photoId); + const synology_url = getSynologyCredentials(targetUserId).synology_url; + if (!synology_url) { + throw new SynologyServiceError(402, 'User not configured with Synology'); + } + + const sid = await getSynologySession(targetUserId); + if (!sid.success || !sid.sid) { + throw new SynologyServiceError(401, 'Authentication failed'); + } + + + + const params = kind === 'thumbnail' + ? new URLSearchParams({ + api: 'SYNO.Foto.Thumbnail', + method: 'get', + version: '2', + mode: 'download', + id: parsedId.id, + type: 'unit', + size: String(size || SYNOLOGY_DEFAULT_THUMBNAIL_SIZE), + cache_key: parsedId.cacheKey, + _sid: sid.sid, + }) + : new URLSearchParams({ + api: 'SYNO.Foto.Download', + method: 'download', + version: '2', + cache_key: parsedId.cacheKey, + unit_id: `[${parsedId.id}]`, + _sid: sid.sid, + }); + + const url = `${buildSynologyEndpoint(synology_url)}?${params.toString()}`; + const resp = await requestSynologyStream(url); + + 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, + }; +} + +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); +}