import { Response } from 'express'; import { db } from '../../db/database'; import { decrypt_api_key, encrypt_api_key, maybe_encrypt_api_key } from '../apiKeyCrypto'; import { safeFetch, SsrfBlockedError, checkSsrf } from '../../utils/ssrfGuard'; import { addTripPhotos } from './unifiedService'; import { getAlbumLinkForSync, updateSyncTimeForAlbumLink, Selection, ServiceResult, fail, success, handleServiceResult, pipeAsset, AlbumsList, AssetsList, StatusResult, SyncAlbumResult, AssetInfo } from './helpersService'; import { send as sendNotification } from '../notificationService'; const SYNOLOGY_PROVIDER = 'synologyphotos'; // Users provide the full base URL including the Photos app path (e.g. https://nas:5001/photo). // The API endpoint is always at {base_url}/webapi/entry.cgi. const SYNOLOGY_ENDPOINT_PATH = '/webapi/entry.cgi'; const SYNOLOGY_ERROR_MESSAGES: Record = { 101: 'Missing API, method, or version parameter.', 102: 'Requested API does not exist.', 103: 'Requested method does not exist.', 104: 'Requested API version is not supported.', 105: 'Insufficient privilege.', 106: 'Connection timeout.', 107: 'Multiple logins blocked from this IP.', 117: 'Manager privilege required.', 119: 'Session is invalid or expired.', 400: 'Invalid credentials.', 401: 'Session expired or account disabled.', 402: 'No permission to use this account.', 403: 'Two-factor authentication code required.', 404: 'Two-factor authentication failed.', 406: 'Two-factor authentication is enforced for this account.', 407: 'Maximum login attempts reached.', 408: 'Password expired.', 409: 'Remote password expired.', 410: 'Password must be changed before login.', 412: 'Guest account cannot log in.', 413: 'OTP system files are corrupted.', 414: 'Unable to log in.', 416: 'Unable to log in.', 417: 'OTP system is full.', 498: 'System is upgrading.', 499: 'System is not ready.', }; interface SynologyUserRecord { synology_url?: string | null; synology_username?: string | null; synology_password?: string | null; synology_sid?: string | null; synology_did?: string | null; synology_skip_ssl?: number | null; }; interface SynologyCredentials { synology_url: string; synology_username: string; synology_password: string; synology_skip_ssl: boolean; } interface SynologySettings { synology_url: string; synology_username: string; synology_skip_ssl: boolean; connected: boolean; } interface ApiCallParams { api: string; method: string; version?: number; [key: string]: unknown; } interface SynologyApiResponse { success: boolean; data?: T; error?: { code: number }; } 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; }; } function _readSynologyUser(userId: number, columns: string[]): ServiceResult { try { const row = db.prepare(`SELECT synology_url, synology_username, synology_password, synology_sid, synology_did, synology_skip_ssl FROM users WHERE id = ?`).get(userId) as SynologyUserRecord | undefined; if (!row) { return fail('User not found', 404); } const filtered: SynologyUserRecord = {}; for (const column of columns) { filtered[column] = row[column]; } return success(filtered); } catch { return fail('Failed to read Synology user data', 500); } } function _getSynologyCredentials(userId: number): ServiceResult { const user = _readSynologyUser(userId, ['synology_url', 'synology_username', 'synology_password', 'synology_skip_ssl']); 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); const password = decrypt_api_key(user.data.synology_password); if (!password) return fail('Synology credentials corrupted', 500); return success({ synology_url: user.data.synology_url, synology_username: user.data.synology_username, synology_password: password, synology_skip_ssl: user.data.synology_skip_ssl !== 0, }); } function _buildSynologyEndpoint(url: string, params: string): string { const normalized = url.replace(/\/$/, '').match(/^https?:\/\//) ? url.replace(/\/$/, '') : `https://${url.replace(/\/$/, '')}`; return `${normalized}${SYNOLOGY_ENDPOINT_PATH}?${params}`; } 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, skipSsl = true): Promise> { const endpoint = _buildSynologyEndpoint(url, `api=${body.get('api')}`); try { const resp = await safeFetch(endpoint, { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8', }, body, signal: AbortSignal.timeout(30000) as any, }, { rejectUnauthorized: !skipSsl }); if (!resp.ok) { return fail('Synology API request failed with status ' + resp.status, resp.status); } const response = await resp.json() as SynologyApiResponse; if (!response.success) { const code = response.error.code; const message = SYNOLOGY_ERROR_MESSAGES[code] ?? 'Synology API request failed (code ' + code + ')'; // Preserve session error codes (106, 107, 119) for internal retry logic in _requestSynologyApi. // All other Synology app-level codes are mapped to HTTP 400 — they are not HTTP status codes. const httpStatus = [106, 107, 119].includes(code) ? code : 400; return fail(message, httpStatus); } return success(response.data); } catch (error) { if (error instanceof SsrfBlockedError) { return fail(error.message, 400); } return fail('Failed to connect to Synology API', 500); } } const SYNOLOGY_DEVICE_NAME = 'trek'; async function _loginToSynology( url: string, username: string, password: string, opts: { otp?: string; deviceId?: string; skipSsl?: boolean } = {}, ): Promise> { const { otp, deviceId, skipSsl = false } = opts; const body = new URLSearchParams({ api: 'SYNO.API.Auth', method: 'login', version: '6', account: username, passwd: password, format: 'sid', client: 'browser', device_name: SYNOLOGY_DEVICE_NAME, }); if (otp && otp.trim()) { body.append('otp_code', otp.trim()); body.append('enable_device_token', 'yes'); } if (deviceId) { body.append('device_id', deviceId); } const result = await _fetchSynologyJson<{ sid?: string; did?: string }>(url, body, skipSsl); if (!result.success) { return result as ServiceResult<{ sid: string; did?: string }>; } if (!result.data.sid) { return fail('Failed to get session ID from Synology', 500); } return success({ sid: result.data.sid, did: result.data.did }); } 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.data) { return session as ServiceResult; } const skipSsl = creds.data.synology_skip_ssl; const body = _buildSynologyFormBody({ ...params, _sid: session.data }); const result = await _fetchSynologyJson(creds.data.synology_url, body, skipSsl); // 106 = session timeout, 107 = duplicate login kicked us out, 119 = SID not found/invalid if ('error' in result && [106, 107, 119].includes(result.error.status)) { _clearSynologySID(userId); const retrySession = await _getSynologySession(userId); if (!retrySession.success || !retrySession.data) { return retrySession as ServiceResult; } return _fetchSynologyJson(creds.data.synology_url, _buildSynologyFormBody({ ...params, _sid: retrySession.data }), skipSsl); } return result; } function _normalizeSynologyPhotoInfo(item: SynologyPhotoItem): AssetInfo { 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, width: item.additional?.resolution?.width || null, height: item.additional?.resolution?.height || null, fileSize: item.filesize || null, fileName: item.filename || null, }; } function _clearSynologySID(userId: number): void { db.prepare('UPDATE users SET synology_sid = NULL WHERE id = ?').run(userId); } function _clearSynologySession(userId: number): void { db.prepare('UPDATE users SET synology_sid = NULL, synology_did = NULL WHERE id = ?').run(userId); } function _splitPackedSynologyId(rawId: string): { id: string; cacheKey: string; assetId: string } | null { // cache_key format from Synology is "{unit_id}_{timestamp}", e.g. "40808_1633659236". // The first segment must be a non-empty integer (the unit ID used for API calls). if (!/^\d+_.+$/.test(rawId)) return null; const id = rawId.split('_')[0]; return { id, cacheKey: rawId, assetId: rawId }; } async function _getSynologySession(userId: number): Promise> { const cached = _readSynologyUser(userId, ['synology_sid', 'synology_did']); if (cached.success && cached.data?.synology_sid) { const decryptedSid = decrypt_api_key(cached.data.synology_sid); if (decryptedSid) return success(decryptedSid); // Decryption failed (e.g. key rotation) — clear the stale SID and re-login _clearSynologySID(userId); } const creds = _getSynologyCredentials(userId); if (!creds.success) { return creds as ServiceResult; } // Use stored device ID to skip OTP on re-login (trusted device flow) const storedDid = cached.success && cached.data?.synology_did ? (decrypt_api_key(cached.data.synology_did) || undefined) : undefined; const resp = await _loginToSynology(creds.data.synology_url, creds.data.synology_username, creds.data.synology_password, { deviceId: storedDid, skipSsl: creds.data.synology_skip_ssl, }); if (!resp.success) { return resp as ServiceResult; } db.prepare('UPDATE users SET synology_sid = ? WHERE id = ?').run(encrypt_api_key(resp.data.sid), userId); return success(resp.data.sid); } 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 || '', synology_skip_ssl: creds.data.synology_skip_ssl, connected: session.success, }); } export async function updateSynologySettings(userId: number, synologyUrl: string, synologyUsername: string, synologyPassword?: string, synologySkipSsl = false): Promise> { const ssrf = await checkSsrf(synologyUrl); if (!ssrf.allowed) { return fail(ssrf.error, 400); } const result = _readSynologyUser(userId, ['synology_password']) if (!result.success) return result as ServiceResult; const existingEncryptedPassword = result.data?.synology_password || null; if (!synologyPassword && !existingEncryptedPassword) { return fail('No stored password found. Please provide a password to save settings.', 400); } // Only invalidate the session when the account itself changes (different URL or username). // If the user just tested the connection, testSynologyConnection already stored a fresh // sid + did — clearing them here would force an unnecessary re-login that may fail (MFA). const existing = _readSynologyUser(userId, ['synology_url', 'synology_username']); const urlChanged = existing.success && existing.data.synology_url !== synologyUrl; const userChanged = existing.success && existing.data.synology_username !== synologyUsername; const sessionCleared = urlChanged || userChanged; if (sessionCleared) { _clearSynologySession(userId); sendNotification({ event: 'synology_session_cleared', actorId: null, params: {}, scope: 'user', targetId: userId, }); } try { db.prepare('UPDATE users SET synology_url = ?, synology_username = ?, synology_password = ?, synology_skip_ssl = ? WHERE id = ?').run( synologyUrl, synologyUsername, synologyPassword ? maybe_encrypt_api_key(synologyPassword) : existingEncryptedPassword, synologySkipSsl ? 1 : 0, userId, ); } catch { return fail('Failed to update Synology settings', 500); } return success('settings updated'); } export async function getSynologyStatus(userId: number): Promise> { const sid = await _getSynologySession(userId); if ('error' in sid) return success({ connected: false, error: sid.error.message }); if (!sid.data) return success({ connected: false, error: 'Not connected to Synology' }); try { const user = db.prepare('SELECT synology_username FROM users WHERE id = ?').get(userId) as { synology_username?: string } | undefined; return success({ connected: true, user: { name: user?.synology_username || 'unknown user' } }); } catch (err: unknown) { return success({ connected: true, user: { name: 'unknown user' } }); } } export async function testSynologyConnection(userId: number, synologyUrl: string, synologyUsername: string, synologyPassword: string, synologyOtp?: string, synologySkipSsl = false): Promise> { const ssrf = await checkSsrf(synologyUrl); if (!ssrf.allowed) { return fail(ssrf.error, 400); } const resp = await _loginToSynology(synologyUrl, synologyUsername, synologyPassword, { otp: synologyOtp, skipSsl: synologySkipSsl }); if ('error' in resp) { return success({ connected: false, error: resp.error.message }); } // Persist the session so the OTP code is not required again on save. // The did (device token) allows future re-logins without OTP. db.prepare('UPDATE users SET synology_sid = ? WHERE id = ?').run(encrypt_api_key(resp.data.sid), userId); if (resp.data.did) { db.prepare('UPDATE users SET synology_did = ? WHERE id = ?').run(encrypt_api_key(resp.data.did), userId); } return success({ connected: true, user: { name: synologyUsername } }); } async function _fetchAllSynologyAlbums(userId: number, baseParams: ApiCallParams): Promise> { const pageSize = 100; const all: any[] = []; let offset = 0; while (true) { const result = await _requestSynologyApi<{ list: any[] }>(userId, { ...baseParams, offset, limit: pageSize }); if (!result.success) return result as ServiceResult; const items = result.data.list || []; all.push(...items); if (items.length < pageSize) break; offset += pageSize; } return success(all); } export async function listSynologyAlbums(userId: number): Promise> { const [personal, shared, sharedWithMe] = await Promise.allSettled([ _fetchAllSynologyAlbums(userId, { api: 'SYNO.Foto.Browse.Album', method: 'list', version: 4 }), _fetchAllSynologyAlbums(userId, { api: 'SYNO.Foto.Browse.Album', method: 'list', version: 4, category: 'shared' }), _fetchAllSynologyAlbums(userId, { api: 'SYNO.Foto.Sharing.Misc', method: 'list_shared_with_me_album', version: 1, additional: ['thumbnail', 'sharing_info'] }), ]); const map = new Map(); const addAlbums = (result: PromiseSettledResult>, extractPassphrase: (a: any) => string | undefined) => { if (result.status === 'rejected') return; if (!result.value.success) { console.warn('[Synology] album list partial failure:', (result.value as any).error?.message); return; } for (const album of result.value.data ?? []) { const id = String(album.id); const passphrase = extractPassphrase(album); map.set(id, { id, albumName: album.name || '', assetCount: album.item_count || 0, passphrase }); } }; addAlbums(personal, () => undefined); addAlbums(shared, (a) => a.passphrase || undefined); addAlbums(sharedWithMe, (a) => a.passphrase || a.sharing_info?.passphrase || undefined); if (map.size === 0 && personal.status === 'fulfilled' && !personal.value.success) { return personal.value as ServiceResult; } const albums = [...map.values()].sort((a, b) => a.albumName.localeCompare(b.albumName)); return success({ albums }); } export async function getSynologyAlbumPhotos(userId: number, albumId: string, passphrase?: string): Promise> { const allItems: SynologyPhotoItem[] = []; const pageSize = 50; let offset = 0; while (true) { const params: ApiCallParams = passphrase ? { api: 'SYNO.Foto.Browse.Item', method: 'list', version: 1, passphrase, offset, limit: pageSize, additional: ['thumbnail'] } : { api: 'SYNO.Foto.Browse.Item', method: 'list', version: 1, album_id: Number(albumId), offset, limit: pageSize, additional: ['thumbnail'] }; const result = await _requestSynologyApi<{ list: SynologyPhotoItem[] }>(userId, params); if (!result.success) return result as ServiceResult; const items = result.data.list || []; allItems.push(...items); if (items.length < pageSize) break; offset += pageSize; } const assets = allItems.map(item => ({ id: String(item.additional?.thumbnail?.cache_key || item.id || ''), takenAt: item.time ? new Date(item.time * 1000).toISOString() : '', })).filter(a => a.id); return success({ assets, total: assets.length, hasMore: false }); } export async function syncSynologyAlbumLink(userId: number, tripId: string, linkId: string, sid: string): Promise> { const response = getAlbumLinkForSync(tripId, linkId, userId); if (!response.success) return response as ServiceResult; const { albumId, passphrase } = response.data; const allItems: SynologyPhotoItem[] = []; const pageSize = 50; let offset = 0; while (true) { const itemParams: ApiCallParams = passphrase ? { api: 'SYNO.Foto.Browse.Item', method: 'list', version: 1, passphrase, offset, limit: pageSize, additional: ['thumbnail'] } : { api: 'SYNO.Foto.Browse.Item', method: 'list', version: 1, album_id: Number(albumId), offset, limit: pageSize, additional: ['thumbnail'] }; const result = await _requestSynologyApi<{ list: SynologyPhotoItem[] }>(userId, itemParams); if (!result.success) return result as ServiceResult; const items = result.data.list || []; allItems.push(...items); if (items.length < pageSize) break; offset += pageSize; } const selection: Selection = { provider: SYNOLOGY_PROVIDER, asset_ids: allItems.map(item => String(item.additional?.thumbnail?.cache_key || '')).filter(id => id), passphrase, }; const result = await addTripPhotos(tripId, userId, true, [selection], sid, linkId); if (!result.success) return result as ServiceResult; updateSyncTimeForAlbumLink(linkId); return success({ added: result.data.added, total: allItems.length }); } 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', 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 } } // SYNO.Foto.Search.Search list_item does not return a total count — only data.list. // hasMore is inferred: if we got a full page, there may be more. const result = await _requestSynologyApi<{ list: SynologyPhotoItem[] }>(userId, params); if (!result.success) return result as ServiceResult; const allItems = result.data.list || []; const assets = allItems.map(item => _normalizeSynologyPhotoInfo(item)); return success({ assets, total: allItems.length, hasMore: allItems.length === limit, }); } export async function getSynologyAssetInfo(userId: number, photoId: string, targetUserId: number, passphrase?: string): Promise> { const parsedId = _splitPackedSynologyId(photoId); if (!parsedId) return fail('Invalid photo ID format', 400); const infoParams: ApiCallParams = { api: 'SYNO.Foto.Browse.Item', method: 'get', version: 5, 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 (passphrase) infoParams.passphrase = passphrase; const result = await _requestSynologyApi<{ list: SynologyPhotoItem[] }>(targetUserId, infoParams); if (!result.success) return result as ServiceResult; const metadata = result.data.list?.[0]; if (!metadata) return fail('Photo not found', 404); const normalized = _normalizeSynologyPhotoInfo(metadata); normalized.id = photoId; return success(normalized); } export async function fetchSynologyThumbnailBytes( userId: number, targetUserId: number, photoId: string, passphrase?: string, ): Promise<{ bytes: Buffer; contentType: string } | { error: string; status: number }> { const parsedId = _splitPackedSynologyId(photoId); if (!parsedId) return { error: 'Invalid photo ID format', status: 400 }; const synology_credentials = _getSynologyCredentials(targetUserId); if (!synology_credentials.success) return { error: 'Credentials error', status: 500 }; const sid = await _getSynologySession(targetUserId); if (!sid.success) return { error: 'Session error', status: 500 }; if (!sid.data) return { error: 'Session ID missing', status: 500 }; const params = new URLSearchParams({ api: 'SYNO.Foto.Thumbnail', method: 'get', version: '2', mode: 'download', id: parsedId.id, type: 'unit', size: 'sm', cache_key: parsedId.cacheKey, _sid: sid.data, }); if (passphrase) params.append('passphrase', passphrase); const url = _buildSynologyEndpoint(synology_credentials.data.synology_url, params.toString()); try { const resp = await safeFetch(url); if (!resp.ok) return { error: 'Upstream error', status: resp.status }; const contentType = resp.headers.get('content-type') || 'image/jpeg'; const bytes = Buffer.from(await resp.arrayBuffer()); return { bytes, contentType }; } catch { return { error: 'Proxy error', status: 502 }; } } export async function streamSynologyAsset( response: Response, userId: number, targetUserId: number, photoId: string, kind: 'thumbnail' | 'original', size?: string, passphrase?: string, ): Promise { const parsedId = _splitPackedSynologyId(photoId); if (!parsedId) { handleServiceResult(response, fail('Invalid photo ID format', 400)); return; } const synology_credentials = _getSynologyCredentials(targetUserId); if (!synology_credentials.success) { handleServiceResult(response, synology_credentials); return; } 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; } //size: 'sm' 240px| 'm' 320px| 'xl' 1280px| 'preview' ? // Use Thumbnail API for both thumbnail and original — avoids serving raw HEIC files // (original uses xl size to get a full-resolution JPEG-compatible render) const resolvedSize = kind === 'original' ? 'xl' : (size || 'sm'); const params = new URLSearchParams({ api: 'SYNO.Foto.Thumbnail', method: 'get', version: '2', mode: 'download', id: parsedId.id, type: 'unit', size: resolvedSize, cache_key: parsedId.cacheKey, _sid: sid.data, }); if (passphrase) params.append('passphrase', passphrase); const url = _buildSynologyEndpoint(synology_credentials.data.synology_url, params.toString()); await pipeAsset(url, response, undefined, undefined, 'public, max-age=86400') }