import express, { Request, Response } from 'express'; import { db } from '../db/database'; import { authenticate } from '../middleware/auth'; import { broadcast } from '../websocket'; import { AuthRequest } from '../types'; const router = express.Router(); /** Validate that an asset ID is a safe UUID-like string (no path traversal). */ function isValidAssetId(id: string): boolean { return /^[a-zA-Z0-9_-]+$/.test(id) && id.length <= 100; } /** Validate that an Immich URL is a safe HTTP(S) URL (no internal/metadata IPs). */ function isValidImmichUrl(raw: string): boolean { try { const url = new URL(raw); if (url.protocol !== 'http:' && url.protocol !== 'https:') return false; const hostname = url.hostname.toLowerCase(); // Block metadata endpoints and localhost if (hostname === 'localhost' || hostname === '127.0.0.1' || hostname === '::1') return false; if (hostname === '169.254.169.254' || hostname === 'metadata.google.internal') return false; // Block link-local and loopback ranges if (hostname.startsWith('10.') || hostname.startsWith('172.') || hostname.startsWith('192.168.')) return false; if (hostname.endsWith('.internal') || hostname.endsWith('.local')) return false; return true; } catch { return false; } } // ── Immich Connection Settings ────────────────────────────────────────────── router.get('/settings', authenticate, (req: Request, res: Response) => { const authReq = req as AuthRequest; const user = db.prepare('SELECT immich_url, immich_api_key FROM users WHERE id = ?').get(authReq.user.id) as any; res.json({ immich_url: user?.immich_url || '', connected: !!(user?.immich_url && user?.immich_api_key), }); }); router.put('/settings', authenticate, (req: Request, res: Response) => { const authReq = req as AuthRequest; const { immich_url, immich_api_key } = req.body; if (immich_url && !isValidImmichUrl(immich_url.trim())) { return res.status(400).json({ error: 'Invalid Immich URL. Must be a valid HTTP(S) URL.' }); } db.prepare('UPDATE users SET immich_url = ?, immich_api_key = ? WHERE id = ?').run( immich_url?.trim() || null, immich_api_key?.trim() || null, authReq.user.id ); res.json({ success: true }); }); router.get('/status', authenticate, async (req: Request, res: Response) => { const authReq = req as AuthRequest; const user = db.prepare('SELECT immich_url, immich_api_key FROM users WHERE id = ?').get(authReq.user.id) as any; if (!user?.immich_url || !user?.immich_api_key) { return res.json({ connected: false, error: 'Not configured' }); } try { const resp = await fetch(`${user.immich_url}/api/users/me`, { headers: { 'x-api-key': user.immich_api_key, 'Accept': 'application/json' }, signal: AbortSignal.timeout(10000), }); if (!resp.ok) return res.json({ connected: false, error: `HTTP ${resp.status}` }); const data = await resp.json() as { name?: string; email?: string }; res.json({ connected: true, user: { name: data.name, email: data.email } }); } catch (err: unknown) { res.json({ connected: false, error: err instanceof Error ? err.message : 'Connection failed' }); } }); // ── Browse Immich Library (for photo picker) ──────────────────────────────── router.get('/browse', authenticate, async (req: Request, res: Response) => { const authReq = req as AuthRequest; const { page = '1', size = '50' } = req.query; const user = db.prepare('SELECT immich_url, immich_api_key FROM users WHERE id = ?').get(authReq.user.id) as any; if (!user?.immich_url || !user?.immich_api_key) return res.status(400).json({ error: 'Immich not configured' }); try { const resp = await fetch(`${user.immich_url}/api/timeline/buckets`, { method: 'GET', headers: { 'x-api-key': user.immich_api_key, 'Accept': 'application/json' }, signal: AbortSignal.timeout(15000), }); if (!resp.ok) return res.status(resp.status).json({ error: 'Failed to fetch from Immich' }); const buckets = await resp.json(); res.json({ buckets }); } catch (err: unknown) { res.status(502).json({ error: 'Could not reach Immich' }); } }); // Search photos by date range (for the date-filter in picker) router.post('/search', authenticate, async (req: Request, res: Response) => { const authReq = req as AuthRequest; const { from, to } = req.body; const user = db.prepare('SELECT immich_url, immich_api_key FROM users WHERE id = ?').get(authReq.user.id) as any; if (!user?.immich_url || !user?.immich_api_key) return res.status(400).json({ error: 'Immich not configured' }); try { // Paginate through all results (Immich limits per-page to 1000) const allAssets: any[] = []; let page = 1; const pageSize = 1000; while (true) { const resp = await fetch(`${user.immich_url}/api/search/metadata`, { method: 'POST', headers: { 'x-api-key': user.immich_api_key, 'Content-Type': 'application/json' }, body: JSON.stringify({ takenAfter: from ? `${from}T00:00:00.000Z` : undefined, takenBefore: to ? `${to}T23:59:59.999Z` : undefined, type: 'IMAGE', size: pageSize, page, }), signal: AbortSignal.timeout(15000), }); if (!resp.ok) return res.status(resp.status).json({ error: 'Search failed' }); const data = await resp.json() as { assets?: { items?: any[] } }; const items = data.assets?.items || []; allAssets.push(...items); if (items.length < pageSize) break; // Last page page++; if (page > 20) break; // Safety limit (20k photos max) } const assets = allAssets.map((a: any) => ({ id: a.id, takenAt: a.fileCreatedAt || a.createdAt, city: a.exifInfo?.city || null, country: a.exifInfo?.country || null, })); res.json({ assets }); } catch { res.status(502).json({ error: 'Could not reach Immich' }); } }); // ── Trip Photos (selected by user) ────────────────────────────────────────── // Get all photos for a trip (own + shared by others) router.get('/trips/:tripId/photos', authenticate, (req: Request, res: Response) => { const authReq = req as AuthRequest; const { tripId } = req.params; const photos = db.prepare(` SELECT tp.immich_asset_id, tp.user_id, tp.shared, tp.added_at, u.username, u.avatar, u.immich_url FROM trip_photos tp JOIN users u ON tp.user_id = u.id WHERE tp.trip_id = ? AND (tp.user_id = ? OR tp.shared = 1) ORDER BY tp.added_at ASC `).all(tripId, authReq.user.id); res.json({ photos }); }); // Add photos to a trip router.post('/trips/:tripId/photos', authenticate, (req: Request, res: Response) => { const authReq = req as AuthRequest; const { tripId } = req.params; const { asset_ids, shared = true } = req.body; if (!Array.isArray(asset_ids) || asset_ids.length === 0) { return res.status(400).json({ error: 'asset_ids required' }); } const insert = db.prepare( 'INSERT OR IGNORE INTO trip_photos (trip_id, user_id, immich_asset_id, shared) VALUES (?, ?, ?, ?)' ); let added = 0; for (const assetId of asset_ids) { const result = insert.run(tripId, authReq.user.id, assetId, shared ? 1 : 0); if (result.changes > 0) added++; } res.json({ success: true, added }); broadcast(tripId, 'memories:updated', { userId: authReq.user.id }, req.headers['x-socket-id'] as string); // Notify trip members about shared photos if (shared && added > 0) { import('../services/notifications').then(({ notifyTripMembers }) => { const tripInfo = db.prepare('SELECT title FROM trips WHERE id = ?').get(tripId) as { title: string } | undefined; notifyTripMembers(Number(tripId), authReq.user.id, 'photos_shared', { trip: tripInfo?.title || 'Untitled', actor: authReq.user.email, count: String(added) }).catch(() => {}); }); } }); // Remove a photo from a trip (own photos only) router.delete('/trips/:tripId/photos/:assetId', authenticate, (req: Request, res: Response) => { const authReq = req as AuthRequest; db.prepare('DELETE FROM trip_photos WHERE trip_id = ? AND user_id = ? AND immich_asset_id = ?') .run(req.params.tripId, authReq.user.id, req.params.assetId); res.json({ success: true }); broadcast(req.params.tripId, 'memories:updated', { userId: authReq.user.id }, req.headers['x-socket-id'] as string); }); // Toggle sharing for a specific photo router.put('/trips/:tripId/photos/:assetId/sharing', authenticate, (req: Request, res: Response) => { const authReq = req as AuthRequest; const { shared } = req.body; db.prepare('UPDATE trip_photos SET shared = ? WHERE trip_id = ? AND user_id = ? AND immich_asset_id = ?') .run(shared ? 1 : 0, req.params.tripId, authReq.user.id, req.params.assetId); res.json({ success: true }); broadcast(req.params.tripId, 'memories:updated', { userId: authReq.user.id }, req.headers['x-socket-id'] as string); }); // ── Asset Details ─────────────────────────────────────────────────────────── router.get('/assets/:assetId/info', authenticate, async (req: Request, res: Response) => { const authReq = req as AuthRequest; const { assetId } = req.params; if (!isValidAssetId(assetId)) return res.status(400).json({ error: 'Invalid asset ID' }); // Only allow accessing own Immich credentials — prevent leaking other users' API keys const user = db.prepare('SELECT immich_url, immich_api_key FROM users WHERE id = ?').get(authReq.user.id) as any; if (!user?.immich_url || !user?.immich_api_key) return res.status(404).json({ error: 'Not found' }); try { const resp = await fetch(`${user.immich_url}/api/assets/${assetId}`, { headers: { 'x-api-key': user.immich_api_key, 'Accept': 'application/json' }, signal: AbortSignal.timeout(10000), }); if (!resp.ok) return res.status(resp.status).json({ error: 'Failed' }); const asset = await resp.json() as any; res.json({ id: asset.id, takenAt: asset.fileCreatedAt || asset.createdAt, width: asset.exifInfo?.exifImageWidth || null, height: asset.exifInfo?.exifImageHeight || null, camera: asset.exifInfo?.make && asset.exifInfo?.model ? `${asset.exifInfo.make} ${asset.exifInfo.model}` : null, lens: asset.exifInfo?.lensModel || null, focalLength: asset.exifInfo?.focalLength ? `${asset.exifInfo.focalLength}mm` : null, aperture: asset.exifInfo?.fNumber ? `f/${asset.exifInfo.fNumber}` : null, shutter: asset.exifInfo?.exposureTime || null, iso: asset.exifInfo?.iso || null, city: asset.exifInfo?.city || null, state: asset.exifInfo?.state || null, country: asset.exifInfo?.country || null, lat: asset.exifInfo?.latitude || null, lng: asset.exifInfo?.longitude || null, fileSize: asset.exifInfo?.fileSizeInByte || null, fileName: asset.originalFileName || null, }); } catch { res.status(502).json({ error: 'Proxy error' }); } }); // ── Proxy Immich Assets ───────────────────────────────────────────────────── // Asset proxy routes accept token via query param (for src usage) function authFromQuery(req: Request, res: Response, next: Function) { const token = req.query.token as string; if (token && !req.headers.authorization) { req.headers.authorization = `Bearer ${token}`; } return (authenticate as any)(req, res, next); } router.get('/assets/:assetId/thumbnail', authFromQuery, async (req: Request, res: Response) => { const authReq = req as AuthRequest; const { assetId } = req.params; if (!isValidAssetId(assetId)) return res.status(400).send('Invalid asset ID'); // Use photo owner's Immich credentials if userId is provided (for shared photos) const targetUserId = req.query.userId ? Number(req.query.userId) : authReq.user.id; const user = db.prepare('SELECT immich_url, immich_api_key FROM users WHERE id = ?').get(targetUserId) as any; if (!user?.immich_url || !user?.immich_api_key) return res.status(404).send('Not found'); try { const resp = await fetch(`${user.immich_url}/api/assets/${assetId}/thumbnail`, { headers: { 'x-api-key': user.immich_api_key }, signal: AbortSignal.timeout(10000), }); if (!resp.ok) return res.status(resp.status).send('Failed'); res.set('Content-Type', resp.headers.get('content-type') || 'image/webp'); res.set('Cache-Control', 'public, max-age=86400'); const buffer = Buffer.from(await resp.arrayBuffer()); res.send(buffer); } catch { res.status(502).send('Proxy error'); } }); router.get('/assets/:assetId/original', authFromQuery, async (req: Request, res: Response) => { const authReq = req as AuthRequest; const { assetId } = req.params; if (!isValidAssetId(assetId)) return res.status(400).send('Invalid asset ID'); // Use photo owner's Immich credentials if userId is provided (for shared photos) const targetUserId = req.query.userId ? Number(req.query.userId) : authReq.user.id; const user = db.prepare('SELECT immich_url, immich_api_key FROM users WHERE id = ?').get(targetUserId) as any; if (!user?.immich_url || !user?.immich_api_key) return res.status(404).send('Not found'); try { const resp = await fetch(`${user.immich_url}/api/assets/${assetId}/original`, { headers: { 'x-api-key': user.immich_api_key }, signal: AbortSignal.timeout(30000), }); if (!resp.ok) return res.status(resp.status).send('Failed'); res.set('Content-Type', resp.headers.get('content-type') || 'image/jpeg'); res.set('Cache-Control', 'public, max-age=86400'); const buffer = Buffer.from(await resp.arrayBuffer()); res.send(buffer); } catch { res.status(502).send('Proxy error'); } }); // ── Album Linking ────────────────────────────────────────────────────────── // List user's Immich albums router.get('/albums', authenticate, async (req: Request, res: Response) => { const authReq = req as AuthRequest; const user = db.prepare('SELECT immich_url, immich_api_key FROM users WHERE id = ?').get(authReq.user.id) as any; if (!user?.immich_url || !user?.immich_api_key) return res.status(400).json({ error: 'Immich not configured' }); try { const resp = await fetch(`${user.immich_url}/api/albums`, { headers: { 'x-api-key': user.immich_api_key, 'Accept': 'application/json' }, signal: AbortSignal.timeout(10000), }); if (!resp.ok) return res.status(resp.status).json({ error: 'Failed to fetch albums' }); const albums = (await resp.json() as any[]).map((a: any) => ({ id: a.id, albumName: a.albumName, assetCount: a.assetCount || 0, startDate: a.startDate, endDate: a.endDate, albumThumbnailAssetId: a.albumThumbnailAssetId, })); res.json({ albums }); } catch { res.status(502).json({ error: 'Could not reach Immich' }); } }); // Get album links for a trip router.get('/trips/:tripId/album-links', authenticate, (req: Request, res: Response) => { const links = db.prepare(` SELECT tal.*, u.username FROM trip_album_links tal JOIN users u ON tal.user_id = u.id WHERE tal.trip_id = ? ORDER BY tal.created_at ASC `).all(req.params.tripId); res.json({ links }); }); // Link an album to a trip router.post('/trips/:tripId/album-links', authenticate, async (req: Request, res: Response) => { const authReq = req as AuthRequest; const { tripId } = req.params; 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, immich_album_id, album_name) VALUES (?, ?, ?, ?)' ).run(tripId, authReq.user.id, album_id, album_name || ''); res.json({ success: true }); } catch (err: any) { res.status(400).json({ error: 'Album already linked' }); } }); // Remove album link router.delete('/trips/:tripId/album-links/:linkId', authenticate, (req: Request, res: Response) => { const authReq = req as AuthRequest; db.prepare('DELETE FROM trip_album_links WHERE id = ? AND trip_id = ? AND user_id = ?') .run(req.params.linkId, req.params.tripId, authReq.user.id); res.json({ success: true }); }); // Sync album — fetch all assets from Immich album and add missing ones to trip 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 = ?') .get(linkId, tripId, authReq.user.id) as any; if (!link) return res.status(404).json({ error: 'Album link not found' }); const user = db.prepare('SELECT immich_url, immich_api_key FROM users WHERE id = ?').get(authReq.user.id) as any; if (!user?.immich_url || !user?.immich_api_key) return res.status(400).json({ error: 'Immich not configured' }); try { const resp = await fetch(`${user.immich_url}/api/albums/${link.immich_album_id}`, { headers: { 'x-api-key': user.immich_api_key, 'Accept': 'application/json' }, signal: AbortSignal.timeout(15000), }); if (!resp.ok) return res.status(resp.status).json({ error: 'Failed to fetch album' }); const albumData = await resp.json() as { assets?: any[] }; const assets = (albumData.assets || []).filter((a: any) => a.type === 'IMAGE'); const insert = db.prepare( 'INSERT OR IGNORE INTO trip_photos (trip_id, user_id, immich_asset_id, shared) VALUES (?, ?, ?, 1)' ); let added = 0; for (const asset of assets) { const r = insert.run(tripId, authReq.user.id, asset.id); 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: assets.length }); if (added > 0) { broadcast(tripId, 'memories:updated', { userId: authReq.user.id }, req.headers['x-socket-id'] as string); } } catch { res.status(502).json({ error: 'Could not reach Immich' }); } }); export default router;