diff --git a/server/src/db/migrations.ts b/server/src/db/migrations.ts index 8e01979..d14d4a8 100644 --- a/server/src/db/migrations.ts +++ b/server/src/db/migrations.ts @@ -632,6 +632,65 @@ function runMigrations(db: Database.Database): void { } } }, + () => { + // Add Synology credential columns for existing databases + try { db.exec('ALTER TABLE users ADD COLUMN synology_url TEXT'); } catch (err: any) { if (!err.message?.includes('duplicate column name')) throw err; } + try { db.exec('ALTER TABLE users ADD COLUMN synology_username TEXT'); } catch (err: any) { if (!err.message?.includes('duplicate column name')) throw err; } + try { db.exec('ALTER TABLE users ADD COLUMN synology_password TEXT'); } catch (err: any) { if (!err.message?.includes('duplicate column name')) throw err; } + try { db.exec('ALTER TABLE users ADD COLUMN synology_sid TEXT'); } catch (err: any) { if (!err.message?.includes('duplicate column name')) throw err; } + }, + () => { + // 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 (?, ?, ?, ?, ?, ?, ?) + 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', + 'Synology Photos', + '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_get: '/integrations/synologyphotos/status', + }), + 1, + ); + } catch (err: any) { + if (!err.message?.includes('no such table')) throw err; + } + try { + const insertField = db.prepare(` + INSERT INTO photo_provider_fields + (provider_id, field_key, label, input_type, placeholder, required, secret, settings_key, payload_key, sort_order) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(provider_id, field_key) DO UPDATE SET + label = excluded.label, + input_type = excluded.input_type, + placeholder = excluded.placeholder, + required = excluded.required, + secret = excluded.secret, + settings_key = excluded.settings_key, + payload_key = excluded.payload_key, + sort_order = excluded.sort_order + `); + insertField.run('synologyphotos', 'synology_url', 'Server URL', 'url', 'https://synology.example.com', 1, 0, 'synology_url', 'synology_url', 0); + insertField.run('synologyphotos', 'synology_username', 'Username', 'text', 'Username', 1, 0, 'synology_username', 'synology_username', 1); + insertField.run('synologyphotos', 'synology_password', 'Password', 'password', 'Password', 1, 1, null, 'synology_password', 2); + } catch (err: any) { + if (!err.message?.includes('no such table')) throw err; + } + }, ]; if (currentVersion < migrations.length) { diff --git a/server/src/db/schema.ts b/server/src/db/schema.ts index 8fe5739..9e243b6 100644 --- a/server/src/db/schema.ts +++ b/server/src/db/schema.ts @@ -18,6 +18,10 @@ function createTables(db: Database.Database): void { mfa_enabled INTEGER DEFAULT 0, mfa_secret TEXT, mfa_backup_codes TEXT, + synology_url TEXT, + synology_username TEXT, + synology_password TEXT, + synology_sid TEXT, must_change_password INTEGER DEFAULT 0, created_at DATETIME DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME DEFAULT CURRENT_TIMESTAMP diff --git a/server/src/db/seeds.ts b/server/src/db/seeds.ts index f875794..8035d21 100644 --- a/server/src/db/seeds.ts +++ b/server/src/db/seeds.ts @@ -108,6 +108,20 @@ function seedAddons(db: Database.Database): void { test_post: '/integrations/immich/test', }), }, + { + id: 'synologyphotos', + name: 'Synology Photos', + description: 'Synology Photos integration with separate account settings', + 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_get: '/integrations/synologyphotos/status', + }), + }, ]; 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); @@ -115,6 +129,9 @@ function seedAddons(db: Database.Database): void { 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 }, { provider_id: 'immich', field_key: 'immich_api_key', label: 'API Key', input_type: 'password', placeholder: 'API Key', required: 1, secret: 1, settings_key: null, payload_key: 'immich_api_key', sort_order: 1 }, + { provider_id: 'synologyphotos', field_key: 'synology_url', label: 'Server URL', input_type: 'url', placeholder: 'https://synology.example.com', required: 1, secret: 0, settings_key: 'synology_url', payload_key: 'synology_url', sort_order: 0 }, + { provider_id: 'synologyphotos', field_key: 'synology_username', label: 'Username', input_type: 'text', placeholder: 'Username', required: 1, secret: 0, settings_key: 'synology_username', payload_key: 'synology_username', sort_order: 1 }, + { provider_id: 'synologyphotos', field_key: 'synology_password', label: 'Password', input_type: 'password', placeholder: 'Password', required: 1, secret: 1, settings_key: null, payload_key: 'synology_password', sort_order: 2 }, ]; const insertProviderField = db.prepare('INSERT OR IGNORE INTO photo_provider_fields (provider_id, field_key, label, input_type, placeholder, required, secret, settings_key, payload_key, sort_order) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)'); for (const f of providerFields) { diff --git a/server/src/index.ts b/server/src/index.ts index 533d92c..d1d682d 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -281,6 +281,8 @@ import atlasRoutes from './routes/atlas'; app.use('/api/addons/atlas', atlasRoutes); import immichRoutes from './routes/immich'; app.use('/api/integrations/immich', immichRoutes); +const synologyRoutes = require('./routes/synology').default; +app.use('/api/integrations/synologyphotos', synologyRoutes); import memoriesRoutes from './routes/memories'; app.use('/api/integrations/memories', memoriesRoutes); diff --git a/server/src/routes/auth.ts b/server/src/routes/auth.ts index eb7463d..29aadd4 100644 --- a/server/src/routes/auth.ts +++ b/server/src/routes/auth.ts @@ -315,9 +315,13 @@ router.post('/ws-token', authenticate, (req: Request, res: Response) => { // Short-lived single-use token for direct resource URLs router.post('/resource-token', authenticate, (req: Request, res: Response) => { const authReq = req as AuthRequest; - const result = createResourceToken(authReq.user.id, req.body.purpose); - if (result.error) return res.status(result.status!).json({ error: result.error }); - res.json({ token: result.token }); + const { purpose } = req.body as { purpose?: string }; + if (purpose !== 'download' && purpose !== 'immich' && purpose !== 'synologyphotos') { + return res.status(400).json({ error: 'Invalid purpose' }); + } + const token = createEphemeralToken(authReq.user.id, purpose); + if (!token) return res.status(503).json({ error: 'Service unavailable' }); + res.json({ token }); }); export default router; diff --git a/server/src/routes/synology.ts b/server/src/routes/synology.ts new file mode 100644 index 0000000..9e98fcf --- /dev/null +++ b/server/src/routes/synology.ts @@ -0,0 +1,610 @@ +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 { 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'; + +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); + } + } +} + +// 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; + } +} + +// 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), + }); +}); + +router.put('/settings', authenticate, (req: Request, res: Response) => { + const authReq = req as AuthRequest; + const { synology_url, synology_username, synology_password } = req.body; + + const url = String(synology_url || '').trim(); + const username = String(synology_username || '').trim(); + const password = String(synology_password || '').trim(); + + 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 }); +}); + +// 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' }); + } +}); + +// 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' }); + } + + const albums = (result.data.list || []).map((a: any) => ({ + id: String(a.id), + albumName: a.name || '', + assetCount: a.item_count || 0, + })); + + res.json({ albums }); + } catch (err: unknown) { + res.status(502).json({ error: err instanceof Error ? err.message : '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' }); + + 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' }); + } +}); + +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 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; + } + + 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; + + 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 + } + } + + + 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 targetUserId = userId ? Number(userId) : authReq.user.id; + + try { + const result = await callSynologyApi(targetUserId, { + api: 'SYNO.Foto.Browse.Item', + method: 'get', + version: 2, + id: Number(parsedId.id), + additional: ['thumbnail', 'resolution', 'exif', 'gps', 'address', 'orientation', 'description'], + }); + if (!result.success || !result.data) { + return res.status(404).json({ error: 'Photo not found' }); + } + + + const exif = result.data.additional?.exif || {}; + const address = result.data.additional?.address || {}; + const gps = result.data.additional?.gps || {}; + res.json({ + id: result.data.id, + takenAt: result.data.time ? new Date(result.data.time * 1000).toISOString() : null, + width: result.data.additional?.resolution?.width || null, + height: result.data.additional?.resolution?.height || null, + camera: exif.model || null, + lens: exif.lens_model || null, + focalLength: exif.focal_length ? `${exif.focal_length}mm` : null, + aperture: exif.f_number ? `f/${exif.f_number}` : null, + shutter: exif.exposure_time || null, + iso: exif.iso_speed_ratings || null, + city: address.city || null, + state: address.state || null, + country: address.country || null, + lat: gps.latitude || null, + lng: gps.longitude || null, + orientation: result.data.additional?.orientation || null, + description: result.data.additional?.description || null, + fileSize: result.data.filesize || null, + fileName: result.data.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', authFromQuery, async (req: Request, res: Response) => { + const authReq = req as AuthRequest; + const { photoId } = req.params; + const parsedId = splitPackedSynologyId(photoId); + const { userId, cacheKey, 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'); + } + + let resolvedCacheKey = cacheKey ? String(cacheKey) : parsedId.cacheKey; + if (!resolvedCacheKey) { + const row = db.prepare(` + SELECT asset_id FROM trip_photos + WHERE user_id = ? AND (asset_id = ? OR asset_id = ? OR asset_id LIKE ? OR asset_id LIKE ?) + ORDER BY id DESC LIMIT 1 + `).get(targetUserId, parsedId.assetId, parsedId.id, `${parsedId.id}_%`, `${parsedId.id}::%`) as { asset_id?: string } | undefined; + const packed = row?.asset_id || ''; + if (packed) { + resolvedCacheKey = splitPackedSynologyId(packed).cacheKey; + } + } + if (!resolvedCacheKey) return res.status(404).send('Missing cache key for thumbnail'); + + const params = new URLSearchParams({ + api: 'SYNO.Foto.Thumbnail', + method: 'get', + version: '2', + mode: 'download', + id: String(parsedId.id), + type: 'unit', + size: String(size), + cache_key: resolvedCacheKey, + _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/download', authFromQuery, async (req: Request, res: Response) => { + const authReq = req as AuthRequest; + const { userId, cacheKey, unitIds } = 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'); + } + + const params = new URLSearchParams({ + api: 'SYNO.Foto.Download', + method: 'download', + version: '2', + cache_key: String(cacheKey), + unit_id: "[" + String(unitIds) + "]", + _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; diff --git a/server/src/services/ephemeralTokens.ts b/server/src/services/ephemeralTokens.ts index 0d1c12b..f880951 100644 --- a/server/src/services/ephemeralTokens.ts +++ b/server/src/services/ephemeralTokens.ts @@ -4,6 +4,7 @@ const TTL: Record = { ws: 30_000, download: 60_000, immich: 60_000, + synologyphotos: 60_000, }; const MAX_STORE_SIZE = 10_000;