diff --git a/server/package-lock.json b/server/package-lock.json index d8286e5..0b9ebcb 100644 --- a/server/package-lock.json +++ b/server/package-lock.json @@ -21,12 +21,12 @@ "jsonwebtoken": "^9.0.2", "multer": "^2.1.1", "node-cron": "^4.2.1", - "node-fetch": "^2.7.0", "nodemailer": "^8.0.4", "otplib": "^12.0.1", "qrcode": "^1.5.4", "tsx": "^4.21.0", "typescript": "^6.0.2", + "undici": "^7.0.0", "unzipper": "^0.12.3", "uuid": "^9.0.0", "ws": "^8.19.0", @@ -4409,26 +4409,6 @@ "node": ">=6.0.0" } }, - "node_modules/node-fetch": { - "version": "2.7.0", - "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", - "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==", - "license": "MIT", - "dependencies": { - "whatwg-url": "^5.0.0" - }, - "engines": { - "node": "4.x || >=6.0.0" - }, - "peerDependencies": { - "encoding": "^0.1.0" - }, - "peerDependenciesMeta": { - "encoding": { - "optional": true - } - } - }, "node_modules/node-int64": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/node-int64/-/node-int64-0.4.0.tgz", @@ -5857,12 +5837,6 @@ "nodetouch": "bin/nodetouch.js" } }, - "node_modules/tr46": { - "version": "0.0.3", - "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", - "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==", - "license": "MIT" - }, "node_modules/tsx": { "version": "4.21.0", "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.21.0.tgz", @@ -5933,6 +5907,15 @@ "dev": true, "license": "MIT" }, + "node_modules/undici": { + "version": "7.24.7", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.24.7.tgz", + "integrity": "sha512-H/nlJ/h0ggGC+uRL3ovD+G0i4bqhvsDOpbDv7At5eFLlj2b41L8QliGbnl2H7SnDiYhENphh1tQFJZf+MyfLsQ==", + "license": "MIT", + "engines": { + "node": ">=20.18.1" + } + }, "node_modules/undici-types": { "version": "7.18.2", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz", @@ -6273,22 +6256,6 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, - "node_modules/webidl-conversions": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", - "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==", - "license": "BSD-2-Clause" - }, - "node_modules/whatwg-url": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", - "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==", - "license": "MIT", - "dependencies": { - "tr46": "~0.0.3", - "webidl-conversions": "^3.0.0" - } - }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", diff --git a/server/package.json b/server/package.json index dd4c616..c584854 100644 --- a/server/package.json +++ b/server/package.json @@ -26,7 +26,7 @@ "jsonwebtoken": "^9.0.2", "multer": "^2.1.1", "node-cron": "^4.2.1", - "node-fetch": "^2.7.0", + "undici": "^7.0.0", "nodemailer": "^8.0.4", "otplib": "^12.0.1", "qrcode": "^1.5.4", diff --git a/server/src/app.ts b/server/src/app.ts index 8355da4..bc36d97 100644 --- a/server/src/app.ts +++ b/server/src/app.ts @@ -35,7 +35,6 @@ import oidcRoutes from './routes/oidc'; import vacayRoutes from './routes/vacay'; import atlasRoutes from './routes/atlas'; import memoriesRoutes from './routes/memories/unified'; -import immichRoutes from './routes/immich'; import notificationRoutes from './routes/notifications'; import shareRoutes from './routes/share'; import { mcpHandler } from './mcp'; @@ -258,8 +257,6 @@ export function createApp(): express.Application { app.use('/api/addons/vacay', vacayRoutes); app.use('/api/addons/atlas', atlasRoutes); app.use('/api/integrations/memories', memoriesRoutes); - //old routes for immich integration (will be removed eventually) - app.use('/api/integrations/immich', immichRoutes); app.use('/api/maps', mapsRoutes); app.use('/api/weather', weatherRoutes); app.use('/api/settings', settingsRoutes); diff --git a/server/src/routes/immich.ts b/server/src/routes/immich.ts deleted file mode 100644 index 1149f62..0000000 --- a/server/src/routes/immich.ts +++ /dev/null @@ -1,274 +0,0 @@ -//DEPRECATED - This route is no longer used use new routes -//DEPRECATED - This route is no longer used use new routes -//DEPRECATED - This route is no longer used use new routes -//DEPRECATED - This route is no longer used use new routes -//DEPRECATED - This route is no longer used use new routes -//DEPRECATED - This route is no longer used use new routes -//DEPRECATED - This route is no longer used use new routes -//DEPRECATED - This route is no longer used use new routes -//DEPRECATED - This route is no longer used use new routes -//DEPRECATED - This route is no longer used use new routes -//DEPRECATED - This route is no longer used use new routes -//DEPRECATED - This route is no longer used use new routes -//DEPRECATED - This route is no longer used use new routes -//DEPRECATED - This route is no longer used use new routes -//DEPRECATED - This route is no longer used use new routes -//DEPRECATED - This route is no longer used use new routes -//DEPRECATED - This route is no longer used use new routes -//DEPRECATED - This route is no longer used use new routes -//DEPRECATED - This route is no longer used use new routes -//DEPRECATED - This route is no longer used use new routes -//DEPRECATED - This route is no longer used use new routes -//DEPRECATED - This route is no longer used use new routes -//DEPRECATED - This route is no longer used use new routes -//DEPRECATED - This route is no longer used use new routes -//DEPRECATED - This route is no longer used use new routes -//DEPRECATED - This route is no longer used use new routes -//DEPRECATED - This route is no longer used use new routes -//DEPRECATED - This route is no longer used use new routes -//DEPRECATED - This route is no longer used use new routes -//DEPRECATED - This route is no longer used use new routes -//DEPRECATED - This route is no longer used use new routes -//DEPRECATED - This route is no longer used use new routes -//DEPRECATED - This route is no longer used use new routes -//DEPRECATED - This route is no longer used use new routes -//DEPRECATED - This route is no longer used use new routes -//DEPRECATED - This route is no longer used use new routes -//DEPRECATED - This route is no longer used use new routes -//DEPRECATED - This route is no longer used use new routes -//DEPRECATED - This route is no longer used use new routes -//DEPRECATED - This route is no longer used use new routes -//DEPRECATED - This route is no longer used use new routes -//DEPRECATED - This route is no longer used use new routes - - - - -import express, { Request, Response, NextFunction } from 'express'; -import { db, canAccessTrip } from '../db/database'; -import { authenticate } from '../middleware/auth'; -import { AuthRequest } from '../types'; -import { consumeEphemeralToken } from '../services/ephemeralTokens'; -import { getClientIp } from '../services/auditLog'; -import { - getConnectionSettings, - saveImmichSettings, - testConnection, - getConnectionStatus, - browseTimeline, - searchPhotos, - getAssetInfo, - proxyThumbnail, - proxyOriginal, - isValidAssetId, - listAlbums, - listAlbumLinks, - createAlbumLink, - deleteAlbumLink, - syncAlbumAssets, -} from '../services/memories/immichService'; -import { addTripPhotos, listTripPhotos, removeTripPhoto, setTripPhotoSharing } from '../services/memories/unifiedService'; -import { Selection, canAccessUserPhoto } from '../services/memories/helpersService'; - -const router = express.Router(); - -// ── Dual auth middleware (JWT or ephemeral token for src) ───────────── - -function authFromQuery(req: Request, res: Response, next: NextFunction) { - const queryToken = req.query.token as string | undefined; - if (queryToken) { - const userId = consumeEphemeralToken(queryToken, 'immich'); - 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); -} - -// ── Immich Connection Settings ───────────────────────────────────────────── - -router.get('/settings', authenticate, (req: Request, res: Response) => { - const authReq = req as AuthRequest; - res.json(getConnectionSettings(authReq.user.id)); -}); - -router.put('/settings', authenticate, async (req: Request, res: Response) => { - const authReq = req as AuthRequest; - const { immich_url, immich_api_key } = req.body; - const result = await saveImmichSettings(authReq.user.id, immich_url, immich_api_key, getClientIp(req)); - if (!result.success) return res.status(400).json({ error: result.error }); - if (result.warning) return res.json({ success: true, warning: result.warning }); - res.json({ success: true }); -}); - -router.get('/status', authenticate, async (req: Request, res: Response) => { - const authReq = req as AuthRequest; - res.json(await getConnectionStatus(authReq.user.id)); -}); - -router.post('/test', authenticate, async (req: Request, res: Response) => { - const { immich_url, immich_api_key } = req.body; - if (!immich_url || !immich_api_key) return res.json({ connected: false, error: 'URL and API key required' }); - res.json(await testConnection(immich_url, immich_api_key)); -}); - -// ── Browse Immich Library (for photo picker) ─────────────────────────────── - -router.get('/browse', authenticate, async (req: Request, res: Response) => { - const authReq = req as AuthRequest; - const result = await browseTimeline(authReq.user.id); - if (result.error) return res.status(result.status!).json({ error: result.error }); - res.json({ buckets: result.buckets }); -}); - -router.post('/search', authenticate, async (req: Request, res: Response) => { - const authReq = req as AuthRequest; - const { from, to } = req.body; - const result = await searchPhotos(authReq.user.id, from, to); - if (result.error) return res.status(result.status!).json({ error: result.error }); - res.json({ assets: result.assets }); -}); - -// ── Trip Photos (selected by user) ──────────────────────────────────────── - -router.get('/trips/:tripId/photos', 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' }); - res.json({ photos: listTripPhotos(tripId, authReq.user.id) }); -}); - -router.post('/trips/:tripId/photos', authenticate, async (req: Request, res: Response) => { - const authReq = req as AuthRequest; - const { tripId } = req.params; - const sid = req.headers['x-socket-id'] as string; - if (!canAccessTrip(tripId, authReq.user.id)) return res.status(404).json({ error: 'Trip not found' }); - 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 selection: Selection = { - provider: 'immich', - asset_ids: asset_ids, - }; - const result = await addTripPhotos(tripId, authReq.user.id, shared, [selection], sid); - if ('error' in result) return res.status(result.error.status!).json({ error: result.error }); - res.json(result); -}); - -router.delete('/trips/:tripId/photos/:assetId', authenticate, async (req: Request, res: Response) => { - const authReq = req as AuthRequest; - if (!canAccessTrip(req.params.tripId, authReq.user.id)) return res.status(404).json({ error: 'Trip not found' }); - const result = await removeTripPhoto(req.params.tripId, authReq.user.id,'immich', req.params.assetId); - if ('error' in result) return res.status(result.error.status!).json({ error: result.error }); - res.json({ success: true }); -}); - -router.put('/trips/:tripId/photos/:assetId/sharing', authenticate, async (req: Request, res: Response) => { - const authReq = req as AuthRequest; - if (!canAccessTrip(req.params.tripId, authReq.user.id)) return res.status(404).json({ error: 'Trip not found' }); - const { shared } = req.body; - const result = await setTripPhotoSharing(req.params.tripId, authReq.user.id, req.params.assetId, 'immich', shared); - if ('error' in result) return res.status(result.error.status!).json({ error: result.error }); - res.json({ success: true }); -}); - -// ── 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' }); - const queryUserId = req.query.userId ? Number(req.query.userId) : undefined; - const ownerUserId = queryUserId && queryUserId !== authReq.user.id ? queryUserId : undefined; - const tripId = req.query.tripId as string; - if (ownerUserId && tripId && !canAccessUserPhoto(authReq.user.id, ownerUserId, tripId, assetId, 'immich')) { - return res.status(403).json({ error: 'Forbidden' }); - } - const result = await getAssetInfo(authReq.user.id, assetId, ownerUserId); - if (result.error) return res.status(result.status!).json({ error: result.error }); - res.json(result.data); -}); - -// ── Proxy Immich Assets ──────────────────────────────────────────────────── - -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'); - const queryUserId = req.query.userId ? Number(req.query.userId) : undefined; - const ownerUserId = queryUserId && queryUserId !== authReq.user.id ? queryUserId : undefined; - const tripId = req.query.tripId as string; - if (ownerUserId && tripId && !canAccessUserPhoto(authReq.user.id, ownerUserId, tripId, assetId, 'immich')) { - return res.status(403).send('Forbidden'); - } - const result = await proxyThumbnail(authReq.user.id, assetId, ownerUserId); - if (result.error) return res.status(result.status!).send(result.error); - res.set('Content-Type', result.contentType!); - res.set('Cache-Control', 'public, max-age=86400'); - res.send(result.buffer); -}); - -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'); - const queryUserId = req.query.userId ? Number(req.query.userId) : undefined; - const ownerUserId = queryUserId && queryUserId !== authReq.user.id ? queryUserId : undefined; - const tripId = req.query.tripId as string; - if (ownerUserId && tripId && !canAccessUserPhoto(authReq.user.id, ownerUserId, tripId, assetId, 'immich')) { - return res.status(403).send('Forbidden'); - } - const result = await proxyOriginal(authReq.user.id, assetId, ownerUserId); - if (result.error) return res.status(result.status!).send(result.error); - res.set('Content-Type', result.contentType!); - res.set('Cache-Control', 'public, max-age=86400'); - res.send(result.buffer); -}); - -// ── Album Linking ────────────────────────────────────────────────────────── - -router.get('/albums', authenticate, async (req: Request, res: Response) => { - const authReq = req as AuthRequest; - const result = await listAlbums(authReq.user.id); - if (result.error) return res.status(result.status!).json({ error: result.error }); - res.json({ albums: result.albums }); -}); - -router.get('/trips/:tripId/album-links', authenticate, (req: Request, res: Response) => { - const authReq = req as AuthRequest; - if (!canAccessTrip(req.params.tripId, authReq.user.id)) return res.status(404).json({ error: 'Trip not found' }); - res.json({ links: listAlbumLinks(req.params.tripId) }); -}); - -router.post('/trips/:tripId/album-links', authenticate, async (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 result = createAlbumLink(tripId, authReq.user.id, album_id, album_name); - if (!result.success) return res.status(400).json({ error: result.error }); - res.json({ success: true }); -}); - -router.delete('/trips/:tripId/album-links/:linkId', authenticate, (req: Request, res: Response) => { - const authReq = req as AuthRequest; - deleteAlbumLink(req.params.linkId, req.params.tripId, authReq.user.id); - res.json({ success: true }); -}); - -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 sid = req.headers['x-socket-id'] as string; - const result = await syncAlbumAssets(tripId, linkId, authReq.user.id, sid); - if (result.error) return res.status(result.status!).json({ error: result.error }); - res.json({ success: true, added: result.added, total: result.total }); -}); - -export default router; \ No newline at end of file diff --git a/server/src/routes/memories/immich.ts b/server/src/routes/memories/immich.ts index fa89258..27e631e 100644 --- a/server/src/routes/memories/immich.ts +++ b/server/src/routes/memories/immich.ts @@ -1,9 +1,8 @@ -import express, { Request, Response, NextFunction } from 'express'; -import { db, canAccessTrip } from '../../db/database'; +import express, { Request, Response } from 'express'; +import { canAccessTrip } from '../../db/database'; import { authenticate } from '../../middleware/auth'; import { broadcast } from '../../websocket'; import { AuthRequest } from '../../types'; -import { consumeEphemeralToken } from '../../services/ephemeralTokens'; import { getClientIp } from '../../services/auditLog'; import { getConnectionSettings, @@ -12,30 +11,16 @@ import { getConnectionStatus, browseTimeline, searchPhotos, - proxyThumbnail, - proxyOriginal, + streamImmichAsset, listAlbums, syncAlbumAssets, getAssetInfo, + isValidAssetId, } from '../../services/memories/immichService'; import { canAccessUserPhoto } from '../../services/memories/helpersService'; const router = express.Router(); -// ── Dual auth middleware (JWT or ephemeral token for src) ───────────── -function authFromQuery(req: Request, res: Response, next: NextFunction) { - const queryToken = req.query.token as string | undefined; - if (queryToken) { - const userId = consumeEphemeralToken(queryToken, 'immich'); - 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); -} - // ── Immich Connection Settings ───────────────────────────────────────────── router.get('/settings', authenticate, (req: Request, res: Response) => { @@ -86,6 +71,7 @@ router.get('/assets/:tripId/:assetId/:ownerId/info', authenticate, async (req: R const authReq = req as AuthRequest; const { tripId, assetId, ownerId } = req.params; + if (!isValidAssetId(assetId)) return res.status(400).json({ error: 'Invalid asset ID' }); if (!canAccessUserPhoto(authReq.user.id, Number(ownerId), tripId, assetId, 'immich')) { return res.status(403).json({ error: 'Forbidden' }); } @@ -96,32 +82,26 @@ router.get('/assets/:tripId/:assetId/:ownerId/info', authenticate, async (req: R // ── Proxy Immich Assets ──────────────────────────────────────────────────── -router.get('/assets/:tripId/:assetId/:ownerId/thumbnail', authFromQuery, async (req: Request, res: Response) => { +router.get('/assets/:tripId/:assetId/:ownerId/thumbnail', authenticate, async (req: Request, res: Response) => { const authReq = req as AuthRequest; const { tripId, assetId, ownerId } = req.params; + if (!isValidAssetId(assetId)) return res.status(400).json({ error: 'Invalid asset ID' }); if (!canAccessUserPhoto(authReq.user.id, Number(ownerId), tripId, assetId, 'immich')) { return res.status(403).json({ error: 'Forbidden' }); } - const result = await proxyThumbnail(authReq.user.id, assetId, Number(ownerId)); - if (result.error) return res.status(result.status!).send(result.error); - res.set('Content-Type', result.contentType!); - res.set('Cache-Control', 'public, max-age=86400'); - res.send(result.buffer); + await streamImmichAsset(res, authReq.user.id, assetId, 'thumbnail', Number(ownerId)); }); -router.get('/assets/:tripId/:assetId/:ownerId/original', authFromQuery, async (req: Request, res: Response) => { +router.get('/assets/:tripId/:assetId/:ownerId/original', authenticate, async (req: Request, res: Response) => { const authReq = req as AuthRequest; const { tripId, assetId, ownerId } = req.params; + if (!isValidAssetId(assetId)) return res.status(400).json({ error: 'Invalid asset ID' }); if (!canAccessUserPhoto(authReq.user.id, Number(ownerId), tripId, assetId, 'immich')) { return res.status(403).json({ error: 'Forbidden' }); } - const result = await proxyOriginal(authReq.user.id, assetId, Number(ownerId)); - if (result.error) return res.status(result.status!).send(result.error); - res.set('Content-Type', result.contentType!); - res.set('Cache-Control', 'public, max-age=86400'); - res.send(result.buffer); + await streamImmichAsset(res, authReq.user.id, assetId, 'original', Number(ownerId)); }); // ── Album Linking ────────────────────────────────────────────────────────── diff --git a/server/src/routes/memories/synology.ts b/server/src/routes/memories/synology.ts index 1dfa4be..7781f53 100644 --- a/server/src/routes/memories/synology.ts +++ b/server/src/routes/memories/synology.ts @@ -113,7 +113,9 @@ router.get('/assets/:tripId/:photoId/:ownerId/info', authenticate, async (req: R router.get('/assets/:tripId/:photoId/:ownerId/:kind', authenticate, async (req: Request, res: Response) => { const authReq = req as AuthRequest; const { tripId, photoId, ownerId, kind } = req.params; - const { size = "sm" } = req.query; + const VALID_SIZES = ['sm', 'm', 'xl'] as const; + const rawSize = String(req.query.size ?? 'sm'); + const size = VALID_SIZES.includes(rawSize as any) ? rawSize : 'sm'; if (kind !== 'thumbnail' && kind !== 'original') { return handleServiceResult(res, fail('Invalid asset kind', 400)); diff --git a/server/src/services/atlasService.ts b/server/src/services/atlasService.ts index 93779dd..f79d082 100644 --- a/server/src/services/atlasService.ts +++ b/server/src/services/atlasService.ts @@ -1,4 +1,3 @@ -import fetch from 'node-fetch'; import { db } from '../db/database'; import { Trip, Place } from '../types'; diff --git a/server/src/services/authService.ts b/server/src/services/authService.ts index d1f2571..5be0e45 100644 --- a/server/src/services/authService.ts +++ b/server/src/services/authService.ts @@ -3,7 +3,6 @@ import jwt from 'jsonwebtoken'; import crypto from 'crypto'; import path from 'path'; import fs from 'fs'; -import fetch from 'node-fetch'; import { authenticator } from 'otplib'; import QRCode from 'qrcode'; import { randomBytes, createHash } from 'crypto'; @@ -983,7 +982,7 @@ export function createWsToken(userId: number): { error?: string; status?: number } export function createResourceToken(userId: number, purpose?: string): { error?: string; status?: number; token?: string } { - if (purpose !== 'download' && purpose !== 'immich' && purpose !== 'synologyphotos') { + if (purpose !== 'download') { return { error: 'Invalid purpose', status: 400 }; } const token = createEphemeralToken(userId, purpose); diff --git a/server/src/services/collabService.ts b/server/src/services/collabService.ts index 52e6b52..38c0be2 100644 --- a/server/src/services/collabService.ts +++ b/server/src/services/collabService.ts @@ -2,7 +2,7 @@ import path from 'path'; import fs from 'fs'; import { db, canAccessTrip } from '../db/database'; import { CollabNote, CollabPoll, CollabMessage, TripFile } from '../types'; -import { checkSsrf, createPinnedAgent } from '../utils/ssrfGuard'; +import { checkSsrf, createPinnedDispatcher } from '../utils/ssrfGuard'; /* ------------------------------------------------------------------ */ /* Internal row types */ @@ -400,17 +400,16 @@ export async function fetchLinkPreview(url: string): Promise } try { - const nodeFetch = require('node-fetch'); const controller = new AbortController(); const timeout = setTimeout(() => controller.abort(), 5000); try { - const r: { ok: boolean; text: () => Promise } = await nodeFetch(url, { + const r = await fetch(url, { redirect: 'error', signal: controller.signal, - agent: createPinnedAgent(ssrf.resolvedIp!, parsed.protocol), + dispatcher: createPinnedDispatcher(ssrf.resolvedIp!), headers: { 'User-Agent': 'Mozilla/5.0 (compatible; NOMAD/1.0; +https://github.com/mauriceboe/NOMAD)' }, - }); + } as any); clearTimeout(timeout); if (!r.ok) throw new Error('Fetch failed'); diff --git a/server/src/services/ephemeralTokens.ts b/server/src/services/ephemeralTokens.ts index f880951..dc63132 100644 --- a/server/src/services/ephemeralTokens.ts +++ b/server/src/services/ephemeralTokens.ts @@ -3,8 +3,6 @@ import crypto from 'crypto'; const TTL: Record = { ws: 30_000, download: 60_000, - immich: 60_000, - synologyphotos: 60_000, }; const MAX_STORE_SIZE = 10_000; diff --git a/server/src/services/mapsService.ts b/server/src/services/mapsService.ts index 5e38ffd..3229c1b 100644 --- a/server/src/services/mapsService.ts +++ b/server/src/services/mapsService.ts @@ -1,4 +1,3 @@ -import fetch from 'node-fetch'; import { db } from '../db/database'; import { decrypt_api_key } from './apiKeyCrypto'; import { checkSsrf } from '../utils/ssrfGuard'; diff --git a/server/src/services/memories/helpersService.ts b/server/src/services/memories/helpersService.ts index 2ef7cf4..5ed382e 100644 --- a/server/src/services/memories/helpersService.ts +++ b/server/src/services/memories/helpersService.ts @@ -1,8 +1,8 @@ -import { Readable } from 'node:stream'; import { pipeline } from 'node:stream/promises'; +import { Readable } from 'node:stream'; import { Response } from 'express'; import { canAccessTrip, db } from "../../db/database"; -import { checkSsrf } from '../../utils/ssrfGuard'; +import { safeFetch, SsrfBlockedError } from '../../utils/ssrfGuard'; // helpers for handling return types @@ -162,33 +162,27 @@ export function updateSyncTimeForAlbumLink(linkId: string): void { db.prepare('UPDATE trip_album_links SET last_synced_at = CURRENT_TIMESTAMP WHERE id = ?').run(linkId); } -export async function pipeAsset(url: string, response: Response): Promise { - try{ +export async function pipeAsset(url: string, response: Response, headers?: Record, signal?: AbortSignal): Promise { + try { + const resp = await safeFetch(url, { headers, signal: signal as any }); - const SsrfResult = await checkSsrf(url); - if (!SsrfResult.allowed) { - response.status(400).json({ error: SsrfResult.error }); - response.end(); - return; - } - const resp = await fetch(url); - response.status(resp.status); if (resp.headers.get('content-type')) response.set('Content-Type', resp.headers.get('content-type') as string); if (resp.headers.get('cache-control')) response.set('Cache-Control', resp.headers.get('cache-control') as string); if (resp.headers.get('content-length')) response.set('Content-Length', resp.headers.get('content-length') as string); if (resp.headers.get('content-disposition')) response.set('Content-Disposition', resp.headers.get('content-disposition') as string); - + if (!resp.body) { response.end(); + } else { + await pipeline(Readable.fromWeb(resp.body as any), response); } - else { - pipeline(Readable.fromWeb(resp.body), response); + } catch (error) { + if (response.headersSent) return; + if (error instanceof SsrfBlockedError) { + response.status(400).json({ error: error.message }); + } else { + response.status(500).json({ error: 'Failed to fetch asset' }); } } - catch (error) { - response.status(500).json({ error: 'Failed to fetch asset' }); - response.end(); - } - } \ No newline at end of file diff --git a/server/src/services/memories/immichService.ts b/server/src/services/memories/immichService.ts index 51829d8..9f27006 100644 --- a/server/src/services/memories/immichService.ts +++ b/server/src/services/memories/immichService.ts @@ -1,16 +1,19 @@ +import { Response } from 'express'; import { db } from '../../db/database'; import { maybe_encrypt_api_key, decrypt_api_key } from '../apiKeyCrypto'; -import { checkSsrf } from '../../utils/ssrfGuard'; +import { checkSsrf, safeFetch } from '../../utils/ssrfGuard'; import { writeAudit } from '../auditLog'; import { addTripPhotos} from './unifiedService'; -import { getAlbumIdFromLink, updateSyncTimeForAlbumLink, Selection } from './helpersService'; +import { getAlbumIdFromLink, updateSyncTimeForAlbumLink, Selection, pipeAsset } from './helpersService'; // ── Credentials ──────────────────────────────────────────────────────────── export function getImmichCredentials(userId: number) { const user = db.prepare('SELECT immich_url, immich_api_key FROM users WHERE id = ?').get(userId) as any; if (!user?.immich_url || !user?.immich_api_key) return null; - return { immich_url: user.immich_url as string, immich_api_key: decrypt_api_key(user.immich_api_key) as string }; + const apiKey = decrypt_api_key(user.immich_api_key); + if (!apiKey) return null; + return { immich_url: user.immich_url as string, immich_api_key: apiKey }; } /** Validate that an asset ID is a safe UUID-like string (no path traversal). */ @@ -75,9 +78,9 @@ export async function testConnection( const ssrf = await checkSsrf(immichUrl); if (!ssrf.allowed) return { connected: false, error: ssrf.error ?? 'Invalid Immich URL' }; try { - const resp = await fetch(`${immichUrl}/api/users/me`, { + const resp = await safeFetch(`${immichUrl}/api/users/me`, { headers: { 'x-api-key': immichApiKey, 'Accept': 'application/json' }, - signal: AbortSignal.timeout(10000), + signal: AbortSignal.timeout(10000) as any, }); if (!resp.ok) return { connected: false, error: `HTTP ${resp.status}` }; const data = await resp.json() as { name?: string; email?: string }; @@ -109,9 +112,9 @@ export async function getConnectionStatus( const creds = getImmichCredentials(userId); if (!creds) return { connected: false, error: 'Not configured' }; try { - const resp = await fetch(`${creds.immich_url}/api/users/me`, { + const resp = await safeFetch(`${creds.immich_url}/api/users/me`, { headers: { 'x-api-key': creds.immich_api_key, 'Accept': 'application/json' }, - signal: AbortSignal.timeout(10000), + signal: AbortSignal.timeout(10000) as any, }); if (!resp.ok) return { connected: false, error: `HTTP ${resp.status}` }; const data = await resp.json() as { name?: string; email?: string }; @@ -130,10 +133,10 @@ export async function browseTimeline( if (!creds) return { error: 'Immich not configured', status: 400 }; try { - const resp = await fetch(`${creds.immich_url}/api/timeline/buckets`, { + const resp = await safeFetch(`${creds.immich_url}/api/timeline/buckets`, { method: 'GET', headers: { 'x-api-key': creds.immich_api_key, 'Accept': 'application/json' }, - signal: AbortSignal.timeout(15000), + signal: AbortSignal.timeout(15000) as any, }); if (!resp.ok) return { error: 'Failed to fetch from Immich', status: resp.status }; const buckets = await resp.json(); @@ -157,7 +160,7 @@ export async function searchPhotos( let page = 1; const pageSize = 1000; while (true) { - const resp = await fetch(`${creds.immich_url}/api/search/metadata`, { + const resp = await safeFetch(`${creds.immich_url}/api/search/metadata`, { method: 'POST', headers: { 'x-api-key': creds.immich_api_key, 'Content-Type': 'application/json' }, body: JSON.stringify({ @@ -167,7 +170,7 @@ export async function searchPhotos( size: pageSize, page, }), - signal: AbortSignal.timeout(15000), + signal: AbortSignal.timeout(15000) as any, }); if (!resp.ok) return { error: 'Search failed', status: resp.status }; const data = await resp.json() as { assets?: { items?: any[] } }; @@ -203,9 +206,9 @@ export async function getAssetInfo( if (!creds) return { error: 'Not found', status: 404 }; try { - const resp = await fetch(`${creds.immich_url}/api/assets/${assetId}`, { + const resp = await safeFetch(`${creds.immich_url}/api/assets/${assetId}`, { headers: { 'x-api-key': creds.immich_api_key, 'Accept': 'application/json' }, - signal: AbortSignal.timeout(10000), + signal: AbortSignal.timeout(10000) as any, }); if (!resp.ok) return { error: 'Failed', status: resp.status }; const asset = await resp.json() as any; @@ -235,50 +238,23 @@ export async function getAssetInfo( } } -export async function proxyThumbnail( +export async function streamImmichAsset( + response: Response, userId: number, assetId: string, + kind: 'thumbnail' | 'original', ownerUserId?: number -): Promise<{ buffer?: Buffer; contentType?: string; error?: string; status?: number }> { +): Promise<{ error?: string; status?: number } | void> { const effectiveUserId = ownerUserId ?? userId; const creds = getImmichCredentials(effectiveUserId); if (!creds) return { error: 'Not found', status: 404 }; - try { - const resp = await fetch(`${creds.immich_url}/api/assets/${assetId}/thumbnail`, { - headers: { 'x-api-key': creds.immich_api_key }, - signal: AbortSignal.timeout(10000), - }); - if (!resp.ok) return { error: 'Failed', status: resp.status }; - const buffer = Buffer.from(await resp.arrayBuffer()); - const contentType = resp.headers.get('content-type') || 'image/webp'; - return { buffer, contentType }; - } catch { - return { error: 'Proxy error', status: 502 }; - } -} + const path = kind === 'thumbnail' ? 'thumbnail' : 'original'; + const timeout = kind === 'thumbnail' ? 10000 : 30000; + const url = `${creds.immich_url}/api/assets/${assetId}/${path}`; -export async function proxyOriginal( - userId: number, - assetId: string, - ownerUserId?: number -): Promise<{ buffer?: Buffer; contentType?: string; error?: string; status?: number }> { - const effectiveUserId = ownerUserId ?? userId; - const creds = getImmichCredentials(effectiveUserId); - if (!creds) return { error: 'Not found', status: 404 }; - - try { - const resp = await fetch(`${creds.immich_url}/api/assets/${assetId}/original`, { - headers: { 'x-api-key': creds.immich_api_key }, - signal: AbortSignal.timeout(30000), - }); - if (!resp.ok) return { error: 'Failed', status: resp.status }; - const buffer = Buffer.from(await resp.arrayBuffer()); - const contentType = resp.headers.get('content-type') || 'image/jpeg'; - return { buffer, contentType }; - } catch { - return { error: 'Proxy error', status: 502 }; - } + response.set('Cache-Control', 'public, max-age=86400'); + await pipeAsset(url, response, { 'x-api-key': creds.immich_api_key }, AbortSignal.timeout(timeout)); } // ── Albums ────────────────────────────────────────────────────────────────── @@ -290,9 +266,9 @@ export async function listAlbums( if (!creds) return { error: 'Immich not configured', status: 400 }; try { - const resp = await fetch(`${creds.immich_url}/api/albums`, { + const resp = await safeFetch(`${creds.immich_url}/api/albums`, { headers: { 'x-api-key': creds.immich_api_key, 'Accept': 'application/json' }, - signal: AbortSignal.timeout(10000), + signal: AbortSignal.timeout(10000) as any, }); if (!resp.ok) return { error: 'Failed to fetch albums', status: resp.status }; const albums = (await resp.json() as any[]).map((a: any) => ({ @@ -358,9 +334,9 @@ export async function syncAlbumAssets( if (!creds) return { error: 'Immich not configured', status: 400 }; try { - const resp = await fetch(`${creds.immich_url}/api/albums/${response.data}`, { + const resp = await safeFetch(`${creds.immich_url}/api/albums/${response.data}`, { headers: { 'x-api-key': creds.immich_api_key, 'Accept': 'application/json' }, - signal: AbortSignal.timeout(15000), + signal: AbortSignal.timeout(15000) as any, }); if (!resp.ok) return { error: 'Failed to fetch album', status: resp.status }; const albumData = await resp.json() as { assets?: any[] }; diff --git a/server/src/services/memories/synologyService.ts b/server/src/services/memories/synologyService.ts index ff44179..8a66143 100644 --- a/server/src/services/memories/synologyService.ts +++ b/server/src/services/memories/synologyService.ts @@ -2,7 +2,7 @@ import { Response } from 'express'; import { db } from '../../db/database'; import { decrypt_api_key, encrypt_api_key, maybe_encrypt_api_key } from '../apiKeyCrypto'; -import { checkSsrf } from '../../utils/ssrfGuard'; +import { safeFetch, SsrfBlockedError, checkSsrf } from '../../utils/ssrfGuard'; import { addTripPhotos } from './unifiedService'; import { getAlbumIdFromLink, @@ -84,9 +84,6 @@ interface SynologyPhotoItem { function _readSynologyUser(userId: number, columns: string[]): ServiceResult { try { - - if (!columns) return null; - const row = db.prepare(`SELECT synology_url, synology_username, synology_password, synology_sid FROM users WHERE id = ?`).get(userId) as SynologyUserRecord | undefined; if (!row) { @@ -98,10 +95,6 @@ function _readSynologyUser(userId: number, columns: string[]): 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: decrypt_api_key(user.data.synology_password) as string, + synology_password: password, }); } @@ -136,30 +131,26 @@ function _buildSynologyFormBody(params: ApiCallParams): URLSearchParams { async function _fetchSynologyJson(url: string, body: URLSearchParams): Promise> { const endpoint = _buildSynologyEndpoint(url, `api=${body.get('api')}`); - const SsrfResult = await checkSsrf(endpoint); - if (!SsrfResult.allowed) { - return fail(SsrfResult.error, 400); - } try { - const resp = await fetch(endpoint, { + const resp = await safeFetch(endpoint, { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8', }, body, - signal: AbortSignal.timeout(30000), + signal: AbortSignal.timeout(30000) as any, }); if (!resp.ok) { return fail('Synology API request failed with status ' + resp.status, resp.status); } const response = await resp.json() as SynologyApiResponse; return response.success ? success(response.data) : fail('Synology failed with code ' + response.error.code, response.error.code); - } - catch { + } catch (error) { + if (error instanceof SsrfBlockedError) { + return fail(error.message, 400); + } return fail('Failed to connect to Synology API', 500); } - - } async function _loginToSynology(url: string, username: string, password: string): Promise> { @@ -196,11 +187,12 @@ async function _requestSynologyApi(userId: number, params: ApiCallParams): Pr const body = _buildSynologyFormBody({ ...params, _sid: session.data }); const result = await _fetchSynologyJson(creds.data.synology_url, body); - if ('error' in result && result.error.status === 119) { + // 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 session as ServiceResult; + return retrySession as ServiceResult; } return _fetchSynologyJson(creds.data.synology_url, _buildSynologyFormBody({ ...params, _sid: retrySession.data })); } @@ -240,7 +232,10 @@ function _clearSynologySID(userId: number): void { db.prepare('UPDATE users SET synology_sid = NULL WHERE id = ?').run(userId); } -function _splitPackedSynologyId(rawId: string): { id: string; cacheKey: string; assetId: string } { +function _splitPackedSynologyId(rawId: string): { id: string; cacheKey: string; assetId: string } | 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 }; } @@ -249,7 +244,9 @@ async function _getSynologySession(userId: number): Promise(userId, params); - if (!result.success) return result as ServiceResult<{ assets: AssetInfo[]; total: number; hasMore: boolean }>; + // 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 total = allItems.length; const assets = allItems.map(item => _normalizeSynologyPhotoInfo(item)); return success({ assets, - total, - hasMore: total === limit, + total: allItems.length, + hasMore: allItems.length === limit, }); } export async function getSynologyAssetInfo(userId: number, photoId: string, targetUserId?: number): Promise> { const parsedId = _splitPackedSynologyId(photoId); + if (!parsedId) return fail('Invalid photo ID format', 400); const result = await _requestSynologyApi<{ list: SynologyPhotoItem[] }>(targetUserId, { api: 'SYNO.Foto.Browse.Item', method: 'get', @@ -459,6 +458,10 @@ export async function streamSynologyAsset( size?: 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) { diff --git a/server/src/services/notifications.ts b/server/src/services/notifications.ts index c4f0a16..d8db3fd 100644 --- a/server/src/services/notifications.ts +++ b/server/src/services/notifications.ts @@ -1,9 +1,8 @@ import nodemailer from 'nodemailer'; -import fetch from 'node-fetch'; import { db } from '../db/database'; import { decrypt_api_key } from './apiKeyCrypto'; import { logInfo, logDebug, logError } from './auditLog'; -import { checkSsrf, createPinnedAgent } from '../utils/ssrfGuard'; +import { checkSsrf, createPinnedDispatcher } from '../utils/ssrfGuard'; // ── Types ────────────────────────────────────────────────────────────────── @@ -351,14 +350,13 @@ export async function sendWebhook(url: string, payload: { event: string; title: } try { - const agent = createPinnedAgent(ssrf.resolvedIp!, new URL(url).protocol); const res = await fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: buildWebhookBody(url, payload), signal: AbortSignal.timeout(10000), - agent, - }); + dispatcher: createPinnedDispatcher(ssrf.resolvedIp!), + } as any); if (!res.ok) { const errBody = await res.text().catch(() => ''); diff --git a/server/src/services/oidcService.ts b/server/src/services/oidcService.ts index 6ca6cc1..7ca46c6 100644 --- a/server/src/services/oidcService.ts +++ b/server/src/services/oidcService.ts @@ -1,5 +1,4 @@ import crypto from 'crypto'; -import fetch from 'node-fetch'; import jwt from 'jsonwebtoken'; import { db } from '../db/database'; import { JWT_SECRET } from '../config'; diff --git a/server/src/services/placeService.ts b/server/src/services/placeService.ts index c89cbac..911f5ae 100644 --- a/server/src/services/placeService.ts +++ b/server/src/services/placeService.ts @@ -1,4 +1,3 @@ -import fetch from 'node-fetch'; import { XMLParser } from 'fast-xml-parser'; import { db, getPlaceWithTags } from '../db/database'; import { loadTagsByPlaceIds } from './queryHelpers'; diff --git a/server/src/services/weatherService.ts b/server/src/services/weatherService.ts index 7a3bcad..baa0a13 100644 --- a/server/src/services/weatherService.ts +++ b/server/src/services/weatherService.ts @@ -1,4 +1,3 @@ -import fetch from 'node-fetch'; // ── Interfaces ────────────────────────────────────────────────────────── diff --git a/server/src/utils/ssrfGuard.ts b/server/src/utils/ssrfGuard.ts index afaf201..8bbb3f8 100644 --- a/server/src/utils/ssrfGuard.ts +++ b/server/src/utils/ssrfGuard.ts @@ -1,6 +1,5 @@ import dns from 'node:dns/promises'; -import http from 'node:http'; -import https from 'node:https'; +import { Agent } from 'undici'; const ALLOW_INTERNAL_NETWORK = process.env.ALLOW_INTERNAL_NETWORK === 'true'; @@ -106,22 +105,46 @@ export async function checkSsrf(rawUrl: string, bypassInternalIpAllowed: boolean } /** - * Returns an http/https Agent whose `lookup` function is pinned to the - * already-validated IP. This prevents DNS rebinding (TOCTOU) by ensuring - * the outbound connection goes to the IP we checked, not a re-resolved one. + * Thrown by safeFetch() when the URL is blocked by the SSRF guard. */ -export function createPinnedAgent(resolvedIp: string, protocol: string): http.Agent | https.Agent { - const options = { - lookup: (_hostname: string, opts: Record, callback: Function) => { - // Determine address family from IP format - const family = resolvedIp.includes(':') ? 6 : 4; - // Node.js 18+ may call lookup with `all: true`, expecting an array of address objects - if (opts && opts.all) { - callback(null, [{ address: resolvedIp, family }]); - } else { - callback(null, resolvedIp, family); - } - }, - }; - return protocol === 'https:' ? new https.Agent(options) : new http.Agent(options); +export class SsrfBlockedError extends Error { + constructor(message: string) { + super(message); + this.name = 'SsrfBlockedError'; + } +} + +/** + * SSRF-safe fetch wrapper. Validates the URL with checkSsrf(), then makes + * the request using a DNS-pinned dispatcher so the resolved IP cannot change + * between the check and the actual connection (DNS rebinding prevention). + */ +export async function safeFetch(url: string, init?: RequestInit): Promise { + const ssrf = await checkSsrf(url); + if (!ssrf.allowed) { + throw new SsrfBlockedError(ssrf.error ?? 'Request blocked by SSRF guard'); + } + const dispatcher = createPinnedDispatcher(ssrf.resolvedIp!); + return fetch(url, { ...init, dispatcher } as any); +} + +/** + * Returns an undici Agent whose connect.lookup is pinned to the already-validated + * IP. This prevents DNS rebinding (TOCTOU) by ensuring the outbound connection + * goes to the IP we checked, not a re-resolved one. + */ +export function createPinnedDispatcher(resolvedIp: string): Agent { + return new Agent({ + connect: { + lookup: (_hostname: string, opts: Record, callback: Function) => { + const family = resolvedIp.includes(':') ? 6 : 4; + // Node.js 18+ may call lookup with `all: true`, expecting an array of address objects + if (opts?.all) { + callback(null, [{ address: resolvedIp, family }]); + } else { + callback(null, resolvedIp, family); + } + }, + }, + }); } diff --git a/server/tests/helpers/factories.ts b/server/tests/helpers/factories.ts index 142080c..fd2251d 100644 --- a/server/tests/helpers/factories.ts +++ b/server/tests/helpers/factories.ts @@ -7,6 +7,7 @@ import Database from 'better-sqlite3'; import bcrypt from 'bcryptjs'; import { encryptMfaSecret } from '../../src/services/mfaCrypto'; +import { encrypt_api_key } from '../../src/services/apiKeyCrypto'; let _userSeq = 0; let _tripSeq = 0; @@ -506,3 +507,75 @@ export function disableNotificationPref( 'INSERT OR REPLACE INTO notification_channel_preferences (user_id, event_type, channel, enabled) VALUES (?, ?, ?, 0)' ).run(userId, eventType, channel); } + +// --------------------------------------------------------------------------- +// Photo integration helpers +// --------------------------------------------------------------------------- + +export interface TestTripPhoto { + id: number; + trip_id: number; + user_id: number; + asset_id: string; + provider: string; + shared: number; + album_link_id: number | null; +} + +export function addTripPhoto( + db: Database.Database, + tripId: number, + userId: number, + assetId: string, + provider: string, + opts: { shared?: boolean; albumLinkId?: number } = {} +): TestTripPhoto { + const result = db.prepare( + 'INSERT OR IGNORE INTO trip_photos (trip_id, user_id, asset_id, provider, shared, album_link_id) VALUES (?, ?, ?, ?, ?, ?)' + ).run(tripId, userId, assetId, provider, opts.shared ? 1 : 0, opts.albumLinkId ?? null); + return db.prepare('SELECT * FROM trip_photos WHERE id = ?').get(result.lastInsertRowid) as TestTripPhoto; +} + +export interface TestAlbumLink { + id: number; + trip_id: number; + user_id: number; + provider: string; + album_id: string; + album_name: string; +} + +export function addAlbumLink( + db: Database.Database, + tripId: number, + userId: number, + provider: string, + albumId: string, + albumName = 'Test Album' +): TestAlbumLink { + const result = db.prepare( + 'INSERT INTO trip_album_links (trip_id, user_id, provider, album_id, album_name) VALUES (?, ?, ?, ?, ?)' + ).run(tripId, userId, provider, albumId, albumName); + return db.prepare('SELECT * FROM trip_album_links WHERE id = ?').get(result.lastInsertRowid) as TestAlbumLink; +} + +export function setImmichCredentials( + db: Database.Database, + userId: number, + url: string, + apiKey: string +): void { + db.prepare('UPDATE users SET immich_url = ?, immich_api_key = ? WHERE id = ?') + .run(url, encrypt_api_key(apiKey), userId); +} + +export function setSynologyCredentials( + db: Database.Database, + userId: number, + url: string, + username: string, + password: string +): void { + db.prepare('UPDATE users SET synology_url = ?, synology_username = ?, synology_password = ? WHERE id = ?') + .run(url, username, encrypt_api_key(password), userId); +} diff --git a/server/tests/helpers/test-db.ts b/server/tests/helpers/test-db.ts index 436e190..00fd396 100644 --- a/server/tests/helpers/test-db.ts +++ b/server/tests/helpers/test-db.ts @@ -91,12 +91,22 @@ const DEFAULT_ADDONS = [ { id: 'collab', name: 'Collab', description: 'Notes, polls, live chat', type: 'trip', icon: 'Users', enabled: 1, sort_order: 6 }, ]; +const DEFAULT_PHOTO_PROVIDERS = [ + { id: 'immich', name: 'Immich', enabled: 1 }, + { id: 'synologyphotos', name: 'Synology Photos', enabled: 1 }, +]; + function seedDefaults(db: Database.Database): void { const insertCat = db.prepare('INSERT OR IGNORE INTO categories (name, color, icon) VALUES (?, ?, ?)'); for (const cat of DEFAULT_CATEGORIES) insertCat.run(cat.name, cat.color, cat.icon); const insertAddon = db.prepare('INSERT OR IGNORE INTO addons (id, name, description, type, icon, enabled, sort_order) VALUES (?, ?, ?, ?, ?, ?, ?)'); for (const a of DEFAULT_ADDONS) insertAddon.run(a.id, a.name, a.description, a.type, a.icon, a.enabled, a.sort_order); + + try { + const insertProvider = db.prepare('INSERT OR IGNORE INTO photo_providers (id, name, description, icon, enabled, sort_order) VALUES (?, ?, ?, ?, ?, ?)'); + for (const p of DEFAULT_PHOTO_PROVIDERS) insertProvider.run(p.id, p.name, p.id, 'Image', p.enabled, 0); + } catch { /* table may not exist in very old schemas */ } } /** diff --git a/server/tests/integration/immich.test.ts b/server/tests/integration/immich.test.ts index de1460e..6a4c40f 100644 --- a/server/tests/integration/immich.test.ts +++ b/server/tests/integration/immich.test.ts @@ -1,6 +1,6 @@ /** * Immich integration tests. - * Covers IMMICH-001 to IMMICH-015 (settings, SSRF protection, connection test). + * Covers IMMICH-001 to IMMICH-024 (settings, SSRF protection, album links). * * External Immich API calls are not made — tests focus on settings persistence * and input validation. @@ -60,6 +60,7 @@ vi.mock('../../src/utils/ssrfGuard', async () => { return { allowed: false, isPrivate: false, error: 'Invalid URL' }; } }), + safeFetch: vi.fn().mockRejectedValue(new Error('safeFetch should not be called in unit tests')), }; }); @@ -89,43 +90,43 @@ afterAll(() => { }); describe('Immich settings', () => { - it('IMMICH-001 — GET /api/immich/settings returns current settings', async () => { + it('IMMICH-001 — GET /api/integrations/memories/immich/settings returns current settings', async () => { const { user } = createUser(testDb); const res = await request(app) - .get('/api/integrations/immich/settings') + .get('/api/integrations/memories/immich/settings') .set('Cookie', authCookie(user.id)); expect(res.status).toBe(200); // Settings may be empty initially expect(res.body).toBeDefined(); }); - it('IMMICH-001 — PUT /api/immich/settings saves Immich URL and API key', async () => { + it('IMMICH-001 — PUT /api/integrations/memories/immich/settings saves Immich URL and API key', async () => { const { user } = createUser(testDb); const res = await request(app) - .put('/api/integrations/immich/settings') + .put('/api/integrations/memories/immich/settings') .set('Cookie', authCookie(user.id)) .send({ immich_url: 'https://immich.example.com', immich_api_key: 'test-api-key' }); expect(res.status).toBe(200); expect(res.body.success).toBe(true); }); - it('IMMICH-002 — PUT /api/immich/settings with private IP is blocked by SSRF guard', async () => { + it('IMMICH-002 — PUT /api/integrations/memories/immich/settings with private IP is blocked by SSRF guard', async () => { const { user } = createUser(testDb); const res = await request(app) - .put('/api/integrations/immich/settings') + .put('/api/integrations/memories/immich/settings') .set('Cookie', authCookie(user.id)) .send({ immich_url: 'http://192.168.1.100', immich_api_key: 'test-key' }); expect(res.status).toBe(400); }); - it('IMMICH-002 — PUT /api/immich/settings with loopback is blocked', async () => { + it('IMMICH-002 — PUT /api/integrations/memories/immich/settings with loopback is blocked', async () => { const { user } = createUser(testDb); const res = await request(app) - .put('/api/integrations/immich/settings') + .put('/api/integrations/memories/immich/settings') .set('Cookie', authCookie(user.id)) .send({ immich_url: 'http://127.0.0.1:2283', immich_api_key: 'test-key' }); expect(res.status).toBe(400); @@ -133,14 +134,14 @@ describe('Immich settings', () => { }); describe('Immich authentication', () => { - it('GET /api/immich/settings without auth returns 401', async () => { - const res = await request(app).get('/api/integrations/immich/settings'); + it('GET /api/integrations/memories/immich/settings without auth returns 401', async () => { + const res = await request(app).get('/api/integrations/memories/immich/settings'); expect(res.status).toBe(401); }); - it('PUT /api/immich/settings without auth returns 401', async () => { + it('PUT /api/integrations/memories/immich/settings without auth returns 401', async () => { const res = await request(app) - .put('/api/integrations/immich/settings') + .put('/api/integrations/memories/immich/settings') .send({ url: 'https://example.com', api_key: 'key' }); expect(res.status).toBe(401); }); @@ -152,9 +153,9 @@ describe('Immich album links', () => { const trip = testDb.prepare('INSERT INTO trips (user_id, title) VALUES (?, ?) RETURNING *').get(user.id, 'Test Trip') as any; const res = await request(app) - .post(`/api/integrations/immich/trips/${trip.id}/album-links`) + .post(`/api/integrations/memories/unified/trips/${trip.id}/album-links`) .set('Cookie', authCookie(user.id)) - .send({ album_id: 'album-uuid-123', album_name: 'Vacation 2024' }); + .send({ album_id: 'album-uuid-123', album_name: 'Vacation 2024', provider: 'immich' }); expect(res.status).toBe(200); expect(res.body.success).toBe(true); @@ -171,7 +172,7 @@ describe('Immich album links', () => { testDb.prepare('INSERT INTO trip_album_links (trip_id, user_id, album_id, album_name, provider) VALUES (?, ?, ?, ?, ?)').run(trip.id, user.id, 'album-abc', 'My Album', 'immich'); const res = await request(app) - .get(`/api/integrations/immich/trips/${trip.id}/album-links`) + .get(`/api/integrations/memories/unified/trips/${trip.id}/album-links`) .set('Cookie', authCookie(user.id)); expect(res.status).toBe(200); @@ -196,7 +197,7 @@ describe('Immich album links', () => { testDb.prepare('INSERT INTO trip_photos (trip_id, user_id, asset_id, provider, shared) VALUES (?, ?, ?, ?, 1)').run(trip.id, user.id, 'asset-manual', 'immich'); const res = await request(app) - .delete(`/api/integrations/immich/trips/${trip.id}/album-links/${linkResult.id}`) + .delete(`/api/integrations/memories/unified/trips/${trip.id}/album-links/${linkResult.id}`) .set('Cookie', authCookie(user.id)); expect(res.status).toBe(200); @@ -212,7 +213,7 @@ describe('Immich album links', () => { expect(link).toBeUndefined(); }); - it('IMMICH-023 — DELETE album-link by non-owner is a no-op', async () => { + it('IMMICH-023 — DELETE album-link by non-member returns 404', async () => { const { user: owner } = createUser(testDb); const { user: other } = createUser(testDb); const trip = testDb.prepare('INSERT INTO trips (user_id, title) VALUES (?, ?) RETURNING *').get(owner.id, 'Test Trip') as any; @@ -221,12 +222,12 @@ describe('Immich album links', () => { .get(trip.id, owner.id, 'album-secret', 'Secret Album', 'immich') as any; testDb.prepare('INSERT INTO trip_photos (trip_id, user_id, asset_id, provider, shared, album_link_id) VALUES (?, ?, ?, ?, 1, ?)').run(trip.id, owner.id, 'asset-owned', 'immich', linkResult.id); - // Other user tries to delete owner's album link + // Non-member tries to delete owner's album link — should be denied const res = await request(app) - .delete(`/api/integrations/immich/trips/${trip.id}/album-links/${linkResult.id}`) + .delete(`/api/integrations/memories/unified/trips/${trip.id}/album-links/${linkResult.id}`) .set('Cookie', authCookie(other.id)); - expect(res.status).toBe(200); // endpoint returns 200 even when no row matched + expect(res.status).toBe(404); // Link and photos should still exist const link = testDb.prepare('SELECT * FROM trip_album_links WHERE id = ?').get(linkResult.id); @@ -236,7 +237,7 @@ describe('Immich album links', () => { }); it('IMMICH-024 — DELETE album-link without auth returns 401', async () => { - const res = await request(app).delete('/api/integrations/immich/trips/1/album-links/1'); + const res = await request(app).delete('/api/integrations/memories/unified/trips/1/album-links/1'); expect(res.status).toBe(401); }); }); diff --git a/server/tests/integration/memories-immich.test.ts b/server/tests/integration/memories-immich.test.ts new file mode 100644 index 0000000..38104ac --- /dev/null +++ b/server/tests/integration/memories-immich.test.ts @@ -0,0 +1,513 @@ +/** + * Immich-specific integration tests (IMMICH-030 – IMMICH-070). + * Covers status, test-connection, browse, search, asset proxy, access control, + * and albums — everything NOT covered by the existing immich.test.ts. + * + * safeFetch is mocked to return fake Immich API responses based on URL patterns. + * No real HTTP calls are made. + */ +import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest'; +import request from 'supertest'; +import type { Application } from 'express'; + +// ── Hoisted DB mock ────────────────────────────────────────────────────────── + +const { testDb, dbMock } = vi.hoisted(() => { + const Database = require('better-sqlite3'); + const db = new Database(':memory:'); + db.exec('PRAGMA journal_mode = WAL'); + db.exec('PRAGMA foreign_keys = ON'); + db.exec('PRAGMA busy_timeout = 5000'); + const mock = { + db, + closeDb: () => {}, + reinitialize: () => {}, + getPlaceWithTags: () => null, + canAccessTrip: (tripId: any, userId: number) => + db.prepare(`SELECT t.id, t.user_id FROM trips t LEFT JOIN trip_members m ON m.trip_id = t.id AND m.user_id = ? WHERE t.id = ? AND (t.user_id = ? OR m.user_id IS NOT NULL)`).get(userId, tripId, userId), + isOwner: (tripId: any, userId: number) => + !!db.prepare('SELECT id FROM trips WHERE id = ? AND user_id = ?').get(tripId, userId), + }; + return { testDb: db, dbMock: mock }; +}); + +vi.mock('../../src/db/database', () => dbMock); +vi.mock('../../src/config', () => ({ + JWT_SECRET: 'test-jwt-secret-for-trek-testing-only', + ENCRYPTION_KEY: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2', + updateJwtSecret: () => {}, +})); +vi.mock('../../src/websocket', () => ({ broadcast: vi.fn() })); + +// ── SSRF guard mock — routes all Immich API calls to fake responses ─────────── +vi.mock('../../src/utils/ssrfGuard', async () => { + const actual = await vi.importActual('../../src/utils/ssrfGuard'); + + function makeFakeImmichFetch(url: string, init?: any) { + const u = typeof url === 'string' ? url : String(url); + + // /api/users/me — used by status + test-connection + if (u.includes('/api/users/me')) { + return Promise.resolve({ + ok: true, status: 200, + headers: { get: (h: string) => h === 'content-type' ? 'application/json' : null }, + json: () => Promise.resolve({ name: 'Test User', email: 'test@immich.local' }), + body: null, + }); + } + // /api/timeline/buckets — browse + if (u.includes('/api/timeline/buckets')) { + return Promise.resolve({ + ok: true, status: 200, + headers: { get: () => null }, + json: () => Promise.resolve([{ timeBucket: '2024-01-01T00:00:00.000Z', count: 3 }]), + body: null, + }); + } + // /api/search/metadata — search + if (u.includes('/api/search/metadata')) { + return Promise.resolve({ + ok: true, status: 200, + headers: { get: () => null }, + json: () => Promise.resolve({ + assets: { + items: [ + { id: 'asset-search-1', fileCreatedAt: '2024-06-01T10:00:00.000Z', exifInfo: { city: 'Paris', country: 'France' } }, + ], + }, + }), + body: null, + }); + } + // /api/assets/:id/thumbnail — thumbnail proxy + if (u.includes('/thumbnail')) { + const imageBytes = Buffer.from('fake-thumbnail-data'); + return Promise.resolve({ + ok: true, status: 200, + headers: { get: (h: string) => h === 'content-type' ? 'image/webp' : null }, + body: new ReadableStream({ start(c) { c.enqueue(imageBytes); c.close(); } }), + }); + } + // /api/assets/:id/original — original proxy + if (u.includes('/original')) { + const imageBytes = Buffer.from('fake-original-data'); + return Promise.resolve({ + ok: true, status: 200, + headers: { get: (h: string) => h === 'content-type' ? 'image/jpeg' : null }, + body: new ReadableStream({ start(c) { c.enqueue(imageBytes); c.close(); } }), + }); + } + // /api/assets/:id — asset info + if (/\/api\/assets\/[^/]+$/.test(u)) { + return Promise.resolve({ + ok: true, status: 200, + headers: { get: () => null }, + json: () => Promise.resolve({ + id: 'asset-info-1', + fileCreatedAt: '2024-06-01T10:00:00.000Z', + originalFileName: 'photo.jpg', + exifInfo: { + exifImageWidth: 4032, exifImageHeight: 3024, + make: 'Apple', model: 'iPhone 15', + lensModel: null, focalLength: 5.1, fNumber: 1.8, + exposureTime: '1/500', iso: 100, + city: 'Paris', state: 'Île-de-France', country: 'France', + latitude: 48.8566, longitude: 2.3522, + fileSizeInByte: 2048000, + }, + }), + body: null, + }); + } + // /api/albums — list albums + if (/\/api\/albums$/.test(u)) { + return Promise.resolve({ + ok: true, status: 200, + headers: { get: () => null }, + json: () => Promise.resolve([ + { id: 'album-uuid-1', albumName: 'Vacation 2024', assetCount: 42, startDate: '2024-06-01', endDate: '2024-06-14', albumThumbnailAssetId: null }, + ]), + body: null, + }); + } + // /api/albums/:id — album detail (for sync) + if (/\/api\/albums\//.test(u)) { + return Promise.resolve({ + ok: true, status: 200, + headers: { get: () => null }, + json: () => Promise.resolve({ assets: [{ id: 'asset-sync-1', type: 'IMAGE' }] }), + body: null, + }); + } + // fallback — unexpected call + return Promise.reject(new Error(`Unexpected safeFetch call: ${u}`)); + } + + return { + ...actual, + checkSsrf: vi.fn().mockImplementation(async (rawUrl: string) => { + try { + const url = new URL(rawUrl); + const h = url.hostname; + if (h === '127.0.0.1' || h === '::1' || h === 'localhost') { + return { allowed: false, isPrivate: true, error: 'Loopback not allowed' }; + } + if (/^(10\.|192\.168\.|172\.(1[6-9]|2\d|3[01])\.)/.test(h)) { + return { allowed: false, isPrivate: true, error: 'Private IP not allowed' }; + } + return { allowed: true, isPrivate: false, resolvedIp: '93.184.216.34' }; + } catch { + return { allowed: false, isPrivate: false, error: 'Invalid URL' }; + } + }), + safeFetch: vi.fn().mockImplementation(makeFakeImmichFetch), + }; +}); + +import { createApp } from '../../src/app'; +import { createTables } from '../../src/db/schema'; +import { runMigrations } from '../../src/db/migrations'; +import { resetTestDb } from '../helpers/test-db'; +import { createUser, createTrip, addTripMember, addTripPhoto, addAlbumLink, setImmichCredentials } from '../helpers/factories'; +import { authCookie } from '../helpers/auth'; +import { loginAttempts, mfaAttempts } from '../../src/routes/auth'; +import { safeFetch } from '../../src/utils/ssrfGuard'; + +const app: Application = createApp(); + +const IMMICH = '/api/integrations/memories/immich'; + +beforeAll(() => { + createTables(testDb); + runMigrations(testDb); +}); + +beforeEach(() => { + resetTestDb(testDb); + loginAttempts.clear(); + mfaAttempts.clear(); +}); + +afterAll(() => testDb.close()); + +// ── Connection status ───────────────────────────────────────────────────────── + +describe('Immich connection status', () => { + it('IMMICH-030 — GET /status when not configured returns { connected: false }', async () => { + const { user } = createUser(testDb); + + const res = await request(app) + .get(`${IMMICH}/status`) + .set('Cookie', authCookie(user.id)); + + expect(res.status).toBe(200); + expect(res.body.connected).toBe(false); + }); + + it('IMMICH-031 — GET /status when configured returns connected + user info', async () => { + const { user } = createUser(testDb); + setImmichCredentials(testDb, user.id, 'https://immich.example.com', 'test-api-key'); + + const res = await request(app) + .get(`${IMMICH}/status`) + .set('Cookie', authCookie(user.id)); + + expect(res.status).toBe(200); + expect(res.body.connected).toBe(true); + expect(res.body.user).toMatchObject({ name: 'Test User', email: 'test@immich.local' }); + }); +}); + +// ── Test connection ─────────────────────────────────────────────────────────── + +describe('Immich test connection', () => { + it('IMMICH-032 — POST /test with missing fields returns { connected: false }', async () => { + const { user } = createUser(testDb); + + const res = await request(app) + .post(`${IMMICH}/test`) + .set('Cookie', authCookie(user.id)) + .send({ immich_url: 'https://immich.example.com' }); // missing api_key + + expect(res.status).toBe(200); + expect(res.body.connected).toBe(false); + }); + + it('IMMICH-033 — POST /test with valid credentials returns { connected: true }', async () => { + const { user } = createUser(testDb); + + const res = await request(app) + .post(`${IMMICH}/test`) + .set('Cookie', authCookie(user.id)) + .send({ immich_url: 'https://immich.example.com', immich_api_key: 'valid-key' }); + + expect(res.status).toBe(200); + expect(res.body.connected).toBe(true); + expect(res.body.user).toBeDefined(); + }); +}); + +// ── Browse & Search ─────────────────────────────────────────────────────────── + +describe('Immich browse and search', () => { + it('IMMICH-040 — GET /browse when not configured returns 400', async () => { + const { user } = createUser(testDb); + + const res = await request(app) + .get(`${IMMICH}/browse`) + .set('Cookie', authCookie(user.id)); + + expect(res.status).toBe(400); + }); + + it('IMMICH-041 — GET /browse returns timeline buckets', async () => { + const { user } = createUser(testDb); + setImmichCredentials(testDb, user.id, 'https://immich.example.com', 'test-api-key'); + + const res = await request(app) + .get(`${IMMICH}/browse`) + .set('Cookie', authCookie(user.id)); + + expect(res.status).toBe(200); + expect(Array.isArray(res.body.buckets)).toBe(true); + expect(res.body.buckets.length).toBeGreaterThan(0); + }); + + it('IMMICH-042 — POST /search returns mapped assets', async () => { + const { user } = createUser(testDb); + setImmichCredentials(testDb, user.id, 'https://immich.example.com', 'test-api-key'); + + const res = await request(app) + .post(`${IMMICH}/search`) + .set('Cookie', authCookie(user.id)) + .send({}); + + expect(res.status).toBe(200); + expect(Array.isArray(res.body.assets)).toBe(true); + expect(res.body.assets[0]).toMatchObject({ id: 'asset-search-1', city: 'Paris', country: 'France' }); + }); + + it('IMMICH-043 — POST /search when upstream throws returns 502', async () => { + const { user } = createUser(testDb); + setImmichCredentials(testDb, user.id, 'https://immich.example.com', 'test-api-key'); + + vi.mocked(safeFetch).mockRejectedValueOnce(new Error('upstream unreachable')); + + const res = await request(app) + .post(`${IMMICH}/search`) + .set('Cookie', authCookie(user.id)) + .send({}); + + expect(res.status).toBe(502); + expect(res.body.error).toBeDefined(); + }); +}); + +// ── Asset proxy ─────────────────────────────────────────────────────────────── + +describe('Immich asset proxy', () => { + it('IMMICH-050 — GET /assets/info returns asset metadata for own photo', async () => { + const { user } = createUser(testDb); + const trip = createTrip(testDb, user.id); + setImmichCredentials(testDb, user.id, 'https://immich.example.com', 'test-api-key'); + addTripPhoto(testDb, trip.id, user.id, 'asset-info-1', 'immich', { shared: false }); + + const res = await request(app) + .get(`${IMMICH}/assets/${trip.id}/asset-info-1/${user.id}/info`) + .set('Cookie', authCookie(user.id)); + + expect(res.status).toBe(200); + expect(res.body).toMatchObject({ id: 'asset-info-1', city: 'Paris', country: 'France' }); + }); + + it('IMMICH-051 — GET /assets/info with invalid assetId (special chars) returns 400', async () => { + const { user } = createUser(testDb); + const trip = createTrip(testDb, user.id); + // ID contains characters outside [a-zA-Z0-9_-] → fails isValidAssetId() + const invalidId = 'asset!@#$%'; + + const res = await request(app) + .get(`${IMMICH}/assets/${trip.id}/${encodeURIComponent(invalidId)}/${user.id}/info`) + .set('Cookie', authCookie(user.id)); + + expect(res.status).toBe(400); + }); + + it('IMMICH-052 — GET /assets/info by non-owner of unshared photo returns 403', async () => { + const { user: owner } = createUser(testDb); + const { user: member } = createUser(testDb); + const trip = createTrip(testDb, owner.id); + addTripMember(testDb, trip.id, member.id); + setImmichCredentials(testDb, owner.id, 'https://immich.example.com', 'test-api-key'); + // private photo — shared = false + addTripPhoto(testDb, trip.id, owner.id, 'asset-private', 'immich', { shared: false }); + + const res = await request(app) + .get(`${IMMICH}/assets/${trip.id}/asset-private/${owner.id}/info`) + .set('Cookie', authCookie(member.id)); + + expect(res.status).toBe(403); + }); + + it('IMMICH-053 — GET /assets/info by trip member for shared photo returns 200', async () => { + const { user: owner } = createUser(testDb); + const { user: member } = createUser(testDb); + const trip = createTrip(testDb, owner.id); + addTripMember(testDb, trip.id, member.id); + setImmichCredentials(testDb, owner.id, 'https://immich.example.com', 'test-api-key'); + // shared photo + addTripPhoto(testDb, trip.id, owner.id, 'asset-shared', 'immich', { shared: true }); + + const res = await request(app) + .get(`${IMMICH}/assets/${trip.id}/asset-shared/${owner.id}/info`) + .set('Cookie', authCookie(member.id)); + + expect(res.status).toBe(200); + }); + + it('IMMICH-054 — GET /assets/thumbnail for own photo streams image data', async () => { + const { user } = createUser(testDb); + const trip = createTrip(testDb, user.id); + setImmichCredentials(testDb, user.id, 'https://immich.example.com', 'test-api-key'); + addTripPhoto(testDb, trip.id, user.id, 'asset-thumb', 'immich', { shared: false }); + + const res = await request(app) + .get(`${IMMICH}/assets/${trip.id}/asset-thumb/${user.id}/thumbnail`) + .set('Cookie', authCookie(user.id)); + + expect(res.status).toBe(200); + expect(res.headers['content-type']).toContain('image/webp'); + expect(res.body).toBeDefined(); + }); + + it('IMMICH-055 — GET /assets/thumbnail for other\'s unshared photo returns 403', async () => { + const { user: owner } = createUser(testDb); + const { user: member } = createUser(testDb); + const trip = createTrip(testDb, owner.id); + addTripMember(testDb, trip.id, member.id); + addTripPhoto(testDb, trip.id, owner.id, 'asset-noshare', 'immich', { shared: false }); + + const res = await request(app) + .get(`${IMMICH}/assets/${trip.id}/asset-noshare/${owner.id}/thumbnail`) + .set('Cookie', authCookie(member.id)); + + expect(res.status).toBe(403); + }); + + it('IMMICH-056 — GET /assets/original for shared photo streams image data', async () => { + const { user: owner } = createUser(testDb); + const { user: member } = createUser(testDb); + const trip = createTrip(testDb, owner.id); + addTripMember(testDb, trip.id, member.id); + setImmichCredentials(testDb, owner.id, 'https://immich.example.com', 'test-api-key'); + addTripPhoto(testDb, trip.id, owner.id, 'asset-orig', 'immich', { shared: true }); + + const res = await request(app) + .get(`${IMMICH}/assets/${trip.id}/asset-orig/${owner.id}/original`) + .set('Cookie', authCookie(member.id)); + + expect(res.status).toBe(200); + expect(res.headers['content-type']).toContain('image/jpeg'); + }); + + it('IMMICH-057 — GET /assets/info where trip does not exist returns 403', async () => { + const { user: owner } = createUser(testDb); + const { user: member } = createUser(testDb); + // Insert a shared photo referencing a trip that doesn't exist (FK disabled temporarily) + testDb.exec('PRAGMA foreign_keys = OFF'); + testDb.prepare( + 'INSERT INTO trip_photos (trip_id, user_id, asset_id, provider, shared) VALUES (?, ?, ?, ?, ?)' + ).run(9999, owner.id, 'asset-notrip', 'immich', 1); + testDb.exec('PRAGMA foreign_keys = ON'); + + const res = await request(app) + .get(`${IMMICH}/assets/9999/asset-notrip/${owner.id}/info`) + .set('Cookie', authCookie(member.id)); + + // canAccessUserPhoto: shared photo found, but canAccessTrip(9999) → null → false → 403 + expect(res.status).toBe(403); + }); + + it('IMMICH-058 — GET /assets/info when upstream returns error propagates status', async () => { + const { user } = createUser(testDb); + const trip = createTrip(testDb, user.id); + setImmichCredentials(testDb, user.id, 'https://immich.example.com', 'test-api-key'); + addTripPhoto(testDb, trip.id, user.id, 'asset-upstream-err', 'immich', { shared: false }); + + vi.mocked(safeFetch).mockResolvedValueOnce({ + ok: false, status: 503, + headers: { get: () => null } as any, + json: async () => ({}), + } as any); + + const res = await request(app) + .get(`${IMMICH}/assets/${trip.id}/asset-upstream-err/${user.id}/info`) + .set('Cookie', authCookie(user.id)); + + expect(res.status).toBe(503); + expect(res.body.error).toBeDefined(); + }); +}); + +// ── Albums ──────────────────────────────────────────────────────────────────── + +describe('Immich albums', () => { + it('IMMICH-060 — GET /albums when not configured returns 400', async () => { + const { user } = createUser(testDb); + + const res = await request(app) + .get(`${IMMICH}/albums`) + .set('Cookie', authCookie(user.id)); + + expect(res.status).toBe(400); + }); + + it('IMMICH-061 — GET /albums returns album list', async () => { + const { user } = createUser(testDb); + setImmichCredentials(testDb, user.id, 'https://immich.example.com', 'test-api-key'); + + const res = await request(app) + .get(`${IMMICH}/albums`) + .set('Cookie', authCookie(user.id)); + + expect(res.status).toBe(200); + expect(Array.isArray(res.body.albums)).toBe(true); + expect(res.body.albums[0]).toMatchObject({ id: 'album-uuid-1', albumName: 'Vacation 2024' }); + }); +}); + +// ── Auth checks ─────────────────────────────────────────────────────────────── + +describe('Immich auth checks', () => { + it('IMMICH-070 — GET /status without auth returns 401', async () => { + expect((await request(app).get(`${IMMICH}/status`)).status).toBe(401); + }); + + it('IMMICH-070 — POST /test without auth returns 401', async () => { + expect((await request(app).post(`${IMMICH}/test`)).status).toBe(401); + }); + + it('IMMICH-070 — GET /browse without auth returns 401', async () => { + expect((await request(app).get(`${IMMICH}/browse`)).status).toBe(401); + }); + + it('IMMICH-070 — POST /search without auth returns 401', async () => { + expect((await request(app).post(`${IMMICH}/search`)).status).toBe(401); + }); + + it('IMMICH-070 — GET /albums without auth returns 401', async () => { + expect((await request(app).get(`${IMMICH}/albums`)).status).toBe(401); + }); + + it('IMMICH-070 — GET /assets/info without auth returns 401', async () => { + expect((await request(app).get(`${IMMICH}/assets/1/asset-x/1/info`)).status).toBe(401); + }); + + it('IMMICH-070 — GET /assets/thumbnail without auth returns 401', async () => { + expect((await request(app).get(`${IMMICH}/assets/1/asset-x/1/thumbnail`)).status).toBe(401); + }); + + it('IMMICH-070 — GET /assets/original without auth returns 401', async () => { + expect((await request(app).get(`${IMMICH}/assets/1/asset-x/1/original`)).status).toBe(401); + }); +}); diff --git a/server/tests/integration/memories-synology.test.ts b/server/tests/integration/memories-synology.test.ts new file mode 100644 index 0000000..fba966b --- /dev/null +++ b/server/tests/integration/memories-synology.test.ts @@ -0,0 +1,545 @@ +/** + * Synology Photos integration tests (SYNO-001 – SYNO-040). + * Covers settings, connection test, search, albums, asset streaming, and access control. + * + * safeFetch is mocked to return fake Synology API JSON responses based on the `api` + * query/body parameter. The Synology service uses POST form-body requests so the mock + * inspects URLSearchParams to dispatch the right fake response. + * + * No real HTTP calls are made. + */ +import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest'; +import request from 'supertest'; +import type { Application } from 'express'; + +// ── Hoisted DB mock ────────────────────────────────────────────────────────── + +const { testDb, dbMock } = vi.hoisted(() => { + const Database = require('better-sqlite3'); + const db = new Database(':memory:'); + db.exec('PRAGMA journal_mode = WAL'); + db.exec('PRAGMA foreign_keys = ON'); + db.exec('PRAGMA busy_timeout = 5000'); + const mock = { + db, + closeDb: () => {}, + reinitialize: () => {}, + getPlaceWithTags: () => null, + canAccessTrip: (tripId: any, userId: number) => + db.prepare(`SELECT t.id, t.user_id FROM trips t LEFT JOIN trip_members m ON m.trip_id = t.id AND m.user_id = ? WHERE t.id = ? AND (t.user_id = ? OR m.user_id IS NOT NULL)`).get(userId, tripId, userId), + isOwner: (tripId: any, userId: number) => + !!db.prepare('SELECT id FROM trips WHERE id = ? AND user_id = ?').get(tripId, userId), + }; + return { testDb: db, dbMock: mock }; +}); + +vi.mock('../../src/db/database', () => dbMock); +vi.mock('../../src/config', () => ({ + JWT_SECRET: 'test-jwt-secret-for-trek-testing-only', + ENCRYPTION_KEY: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2', + updateJwtSecret: () => {}, +})); +vi.mock('../../src/websocket', () => ({ broadcast: vi.fn() })); + +// ── SSRF guard mock — routes all Synology API calls to fake responses ───────── +vi.mock('../../src/utils/ssrfGuard', async () => { + const actual = await vi.importActual('../../src/utils/ssrfGuard'); + + function makeFakeSynologyFetch(url: string, init?: any) { + const u = String(url); + + // Determine which API was called from the URL query param (e.g. ?api=SYNO.API.Auth) + // or from the body for POST requests. + let apiName = ''; + try { + apiName = new URL(u).searchParams.get('api') || ''; + } catch {} + if (!apiName && init?.body) { + const body = init.body instanceof URLSearchParams + ? init.body + : new URLSearchParams(String(init.body)); + apiName = body.get('api') || ''; + } + + // Auth login — used by settings save, status, test-connection + if (apiName === 'SYNO.API.Auth') { + return Promise.resolve({ + ok: true, status: 200, + headers: { get: () => 'application/json' }, + json: () => Promise.resolve({ success: true, data: { sid: 'fake-session-id-abc' } }), + body: null, + }); + } + + // Album list + if (apiName === 'SYNO.Foto.Browse.Album') { + return Promise.resolve({ + ok: true, status: 200, + headers: { get: () => 'application/json' }, + json: () => Promise.resolve({ + success: true, + data: { + list: [ + { id: 1, name: 'Summer Trip', item_count: 15 }, + { id: 2, name: 'Winter Holiday', item_count: 8 }, + ], + }, + }), + body: null, + }); + } + + // Search photos + if (apiName === 'SYNO.Foto.Search.Search') { + return Promise.resolve({ + ok: true, status: 200, + headers: { get: () => 'application/json' }, + json: () => Promise.resolve({ + success: true, + data: { + list: [ + { + id: 101, + filename: 'photo1.jpg', + filesize: 1024000, + time: 1717228800, // 2024-06-01 in Unix timestamp + additional: { + thumbnail: { cache_key: '101_cachekey' }, + address: { city: 'Tokyo', country: 'Japan', state: 'Tokyo' }, + exif: { camera: 'Sony A7IV', focal_length: '50', aperture: '1.8', exposure_time: '1/250', iso: 400 }, + gps: { latitude: 35.6762, longitude: 139.6503 }, + resolution: { width: 6000, height: 4000 }, + orientation: 1, + description: 'Tokyo street', + }, + }, + ], + total: 1, + }, + }), + body: null, + }); + } + + // Browse items (for album sync or asset info) + if (apiName === 'SYNO.Foto.Browse.Item') { + return Promise.resolve({ + ok: true, status: 200, + headers: { get: () => 'application/json' }, + json: () => Promise.resolve({ + success: true, + data: { + list: [ + { + id: 101, + filename: 'photo1.jpg', + filesize: 1024000, + time: 1717228800, + additional: { + thumbnail: { cache_key: '101_cachekey' }, + address: { city: 'Tokyo', country: 'Japan', state: 'Tokyo' }, + exif: { camera: 'Sony A7IV' }, + gps: { latitude: 35.6762, longitude: 139.6503 }, + resolution: { width: 6000, height: 4000 }, + orientation: 1, + description: null, + }, + }, + ], + }, + }), + body: null, + }); + } + + // Thumbnail stream + if (apiName === 'SYNO.Foto.Thumbnail') { + const imageBytes = Buffer.from('fake-synology-thumbnail'); + return Promise.resolve({ + ok: true, status: 200, + headers: { get: (h: string) => h === 'content-type' ? 'image/jpeg' : null }, + body: new ReadableStream({ start(c) { c.enqueue(imageBytes); c.close(); } }), + }); + } + + // Original download + if (apiName === 'SYNO.Foto.Download') { + const imageBytes = Buffer.from('fake-synology-original'); + return Promise.resolve({ + ok: true, status: 200, + headers: { get: (h: string) => h === 'content-type' ? 'image/jpeg' : null }, + body: new ReadableStream({ start(c) { c.enqueue(imageBytes); c.close(); } }), + }); + } + + return Promise.reject(new Error(`Unexpected safeFetch call to Synology: ${u}, api=${apiName}`)); + } + + return { + ...actual, + checkSsrf: vi.fn().mockImplementation(async (rawUrl: string) => { + try { + const url = new URL(rawUrl); + const h = url.hostname; + if (h === '127.0.0.1' || h === '::1' || h === 'localhost') { + return { allowed: false, isPrivate: true, error: 'Loopback not allowed' }; + } + if (/^(10\.|192\.168\.|172\.(1[6-9]|2\d|3[01])\.)/.test(h)) { + return { allowed: false, isPrivate: true, error: 'Private IP not allowed' }; + } + return { allowed: true, isPrivate: false, resolvedIp: '93.184.216.34' }; + } catch { + return { allowed: false, isPrivate: false, error: 'Invalid URL' }; + } + }), + safeFetch: vi.fn().mockImplementation(makeFakeSynologyFetch), + }; +}); + +import { createApp } from '../../src/app'; +import { createTables } from '../../src/db/schema'; +import { runMigrations } from '../../src/db/migrations'; +import { resetTestDb } from '../helpers/test-db'; +import { createUser, createTrip, addTripMember, addTripPhoto, setSynologyCredentials } from '../helpers/factories'; +import { authCookie } from '../helpers/auth'; +import { loginAttempts, mfaAttempts } from '../../src/routes/auth'; +import { safeFetch } from '../../src/utils/ssrfGuard'; + +const app: Application = createApp(); + +const SYNO = '/api/integrations/memories/synologyphotos'; + +beforeAll(() => { + createTables(testDb); + runMigrations(testDb); +}); + +beforeEach(() => { + resetTestDb(testDb); + loginAttempts.clear(); + mfaAttempts.clear(); +}); + +afterAll(() => testDb.close()); + +// ── Settings ────────────────────────────────────────────────────────────────── + +describe('Synology settings', () => { + it('SYNO-001 — GET /settings when not configured returns 400', async () => { + const { user } = createUser(testDb); + + const res = await request(app) + .get(`${SYNO}/settings`) + .set('Cookie', authCookie(user.id)); + + expect(res.status).toBe(400); + }); + + it('SYNO-002 — PUT /settings saves credentials and returns success', async () => { + const { user } = createUser(testDb); + + const res = await request(app) + .put(`${SYNO}/settings`) + .set('Cookie', authCookie(user.id)) + .send({ + synology_url: 'https://synology.example.com', + synology_username: 'admin', + synology_password: 'secure-password', + }); + + expect(res.status).toBe(200); + + const row = testDb.prepare('SELECT synology_url, synology_username FROM users WHERE id = ?').get(user.id) as any; + expect(row.synology_url).toBe('https://synology.example.com'); + expect(row.synology_username).toBe('admin'); + }); + + it('SYNO-003 — PUT /settings with SSRF-blocked URL returns 400', async () => { + const { user } = createUser(testDb); + + const res = await request(app) + .put(`${SYNO}/settings`) + .set('Cookie', authCookie(user.id)) + .send({ + synology_url: 'http://192.168.1.100', + synology_username: 'admin', + synology_password: 'pass', + }); + + expect(res.status).toBe(400); + }); + + it('SYNO-004 — PUT /settings without URL returns 400', async () => { + const { user } = createUser(testDb); + + const res = await request(app) + .put(`${SYNO}/settings`) + .set('Cookie', authCookie(user.id)) + .send({ synology_username: 'admin', synology_password: 'pass' }); // no url + + expect(res.status).toBe(400); + }); +}); + +// ── Connection ──────────────────────────────────────────────────────────────── + +describe('Synology connection', () => { + it('SYNO-010 — GET /status when not configured returns { connected: false }', async () => { + const { user } = createUser(testDb); + + const res = await request(app) + .get(`${SYNO}/status`) + .set('Cookie', authCookie(user.id)); + + expect(res.status).toBe(200); + expect(res.body.connected).toBe(false); + }); + + it('SYNO-011 — GET /status when configured returns { connected: true }', async () => { + const { user } = createUser(testDb); + setSynologyCredentials(testDb, user.id, 'https://synology.example.com', 'admin', 'pass'); + + const res = await request(app) + .get(`${SYNO}/status`) + .set('Cookie', authCookie(user.id)); + + expect(res.status).toBe(200); + expect(res.body.connected).toBe(true); + }); + + it('SYNO-012 — POST /test with valid credentials returns { connected: true }', async () => { + const { user } = createUser(testDb); + + const res = await request(app) + .post(`${SYNO}/test`) + .set('Cookie', authCookie(user.id)) + .send({ + synology_url: 'https://synology.example.com', + synology_username: 'admin', + synology_password: 'secure-password', + }); + + expect(res.status).toBe(200); + expect(res.body.connected).toBe(true); + }); + + it('SYNO-013 — POST /test with missing fields returns error', async () => { + const { user } = createUser(testDb); + + const res = await request(app) + .post(`${SYNO}/test`) + .set('Cookie', authCookie(user.id)) + .send({ synology_url: 'https://synology.example.com' }); // missing username+password + + expect(res.status).toBe(200); + expect(res.body.connected).toBe(false); + expect(res.body.error).toBeDefined(); + }); +}); + +// ── Search & Albums ─────────────────────────────────────────────────────────── + +describe('Synology search and albums', () => { + it('SYNO-020 — POST /search returns mapped assets', async () => { + const { user } = createUser(testDb); + setSynologyCredentials(testDb, user.id, 'https://synology.example.com', 'admin', 'pass'); + + const res = await request(app) + .post(`${SYNO}/search`) + .set('Cookie', authCookie(user.id)) + .send({}); + + expect(res.status).toBe(200); + expect(Array.isArray(res.body.assets)).toBe(true); + expect(res.body.assets[0]).toMatchObject({ city: 'Tokyo', country: 'Japan' }); + }); + + it('SYNO-021 — POST /search when upstream throws propagates 500', async () => { + const { user } = createUser(testDb); + setSynologyCredentials(testDb, user.id, 'https://synology.example.com', 'admin', 'pass'); + + // Auth call succeeds, search call throws a network error + vi.mocked(safeFetch) + .mockResolvedValueOnce({ + ok: true, status: 200, + headers: { get: () => 'application/json' }, + json: async () => ({ success: true, data: { sid: 'fake-sid' } }), + body: null, + } as any) + .mockRejectedValueOnce(new Error('Synology unreachable')); + + const res = await request(app) + .post(`${SYNO}/search`) + .set('Cookie', authCookie(user.id)) + .send({}); + + expect(res.status).toBe(500); + expect(res.body.error).toBeDefined(); + }); + + it('SYNO-022 — GET /albums returns album list', async () => { + const { user } = createUser(testDb); + setSynologyCredentials(testDb, user.id, 'https://synology.example.com', 'admin', 'pass'); + + const res = await request(app) + .get(`${SYNO}/albums`) + .set('Cookie', authCookie(user.id)); + + expect(res.status).toBe(200); + expect(Array.isArray(res.body.albums)).toBe(true); + expect(res.body.albums).toHaveLength(2); + expect(res.body.albums[0]).toMatchObject({ albumName: 'Summer Trip', assetCount: 15 }); + }); +}); + +// ── Asset access ────────────────────────────────────────────────────────────── + +describe('Synology asset access', () => { + it('SYNO-030 — GET /assets/info returns metadata for own photo', async () => { + const { user } = createUser(testDb); + const trip = createTrip(testDb, user.id); + setSynologyCredentials(testDb, user.id, 'https://synology.example.com', 'admin', 'pass'); + addTripPhoto(testDb, trip.id, user.id, '101_cachekey', 'synologyphotos', { shared: false }); + + const res = await request(app) + .get(`${SYNO}/assets/${trip.id}/101_cachekey/${user.id}/info`) + .set('Cookie', authCookie(user.id)); + + expect(res.status).toBe(200); + expect(res.body).toMatchObject({ city: 'Tokyo', country: 'Japan' }); + }); + + it('SYNO-031 — GET /assets/info by non-owner of unshared photo returns 403', async () => { + const { user: owner } = createUser(testDb); + const { user: member } = createUser(testDb); + const trip = createTrip(testDb, owner.id); + addTripMember(testDb, trip.id, member.id); + addTripPhoto(testDb, trip.id, owner.id, '101_cachekey', 'synologyphotos', { shared: false }); + + const res = await request(app) + .get(`${SYNO}/assets/${trip.id}/101_cachekey/${owner.id}/info`) + .set('Cookie', authCookie(member.id)); + + expect(res.status).toBe(403); + }); + + it('SYNO-032 — GET /assets/thumbnail streams image data for own photo', async () => { + const { user } = createUser(testDb); + const trip = createTrip(testDb, user.id); + setSynologyCredentials(testDb, user.id, 'https://synology.example.com', 'admin', 'pass'); + addTripPhoto(testDb, trip.id, user.id, '101_cachekey', 'synologyphotos', { shared: false }); + + const res = await request(app) + .get(`${SYNO}/assets/${trip.id}/101_cachekey/${user.id}/thumbnail`) + .set('Cookie', authCookie(user.id)); + + expect(res.status).toBe(200); + expect(res.headers['content-type']).toContain('image/jpeg'); + }); + + it('SYNO-033 — GET /assets/original streams image data for shared photo', async () => { + const { user: owner } = createUser(testDb); + const { user: member } = createUser(testDb); + const trip = createTrip(testDb, owner.id); + addTripMember(testDb, trip.id, member.id); + setSynologyCredentials(testDb, owner.id, 'https://synology.example.com', 'admin', 'pass'); + addTripPhoto(testDb, trip.id, owner.id, '101_cachekey', 'synologyphotos', { shared: true }); + + const res = await request(app) + .get(`${SYNO}/assets/${trip.id}/101_cachekey/${owner.id}/original`) + .set('Cookie', authCookie(member.id)); + + expect(res.status).toBe(200); + expect(res.headers['content-type']).toContain('image/jpeg'); + }); + + it('SYNO-034 — GET /assets with invalid kind returns 400', async () => { + const { user } = createUser(testDb); + const trip = createTrip(testDb, user.id); + addTripPhoto(testDb, trip.id, user.id, '101_cachekey', 'synologyphotos', { shared: false }); + + const res = await request(app) + .get(`${SYNO}/assets/${trip.id}/101_cachekey/${user.id}/badkind`) + .set('Cookie', authCookie(user.id)); + + expect(res.status).toBe(400); + }); + + it('SYNO-035 — GET /assets/info where trip does not exist returns 403', async () => { + const { user: owner } = createUser(testDb); + const { user: member } = createUser(testDb); + // Insert a shared photo referencing a trip that doesn't exist (FK disabled temporarily) + testDb.exec('PRAGMA foreign_keys = OFF'); + testDb.prepare( + 'INSERT INTO trip_photos (trip_id, user_id, asset_id, provider, shared) VALUES (?, ?, ?, ?, ?)' + ).run(9999, owner.id, '101_cachekey', 'synologyphotos', 1); + testDb.exec('PRAGMA foreign_keys = ON'); + + const res = await request(app) + .get(`${SYNO}/assets/9999/101_cachekey/${owner.id}/info`) + .set('Cookie', authCookie(member.id)); + + // canAccessUserPhoto: shared photo found, but canAccessTrip(9999) → null → false → 403 + expect(res.status).toBe(403); + }); + + it('SYNO-036 — GET /assets/info when upstream throws propagates 500', async () => { + const { user } = createUser(testDb); + const trip = createTrip(testDb, user.id); + setSynologyCredentials(testDb, user.id, 'https://synology.example.com', 'admin', 'pass'); + addTripPhoto(testDb, trip.id, user.id, '101_cachekey', 'synologyphotos', { shared: false }); + + // Auth call succeeds, Browse.Item call throws a network error + vi.mocked(safeFetch) + .mockResolvedValueOnce({ + ok: true, status: 200, + headers: { get: () => 'application/json' }, + json: async () => ({ success: true, data: { sid: 'fake-sid' } }), + body: null, + } as any) + .mockRejectedValueOnce(new Error('network failure')); + + const res = await request(app) + .get(`${SYNO}/assets/${trip.id}/101_cachekey/${user.id}/info`) + .set('Cookie', authCookie(user.id)); + + expect(res.status).toBe(500); + expect(res.body.error).toBeDefined(); + }); +}); + +// ── Auth checks ─────────────────────────────────────────────────────────────── + +describe('Synology auth checks', () => { + it('SYNO-040 — GET /settings without auth returns 401', async () => { + expect((await request(app).get(`${SYNO}/settings`)).status).toBe(401); + }); + + it('SYNO-040 — PUT /settings without auth returns 401', async () => { + expect((await request(app).put(`${SYNO}/settings`)).status).toBe(401); + }); + + it('SYNO-040 — GET /status without auth returns 401', async () => { + expect((await request(app).get(`${SYNO}/status`)).status).toBe(401); + }); + + it('SYNO-040 — POST /test without auth returns 401', async () => { + expect((await request(app).post(`${SYNO}/test`)).status).toBe(401); + }); + + it('SYNO-040 — GET /albums without auth returns 401', async () => { + expect((await request(app).get(`${SYNO}/albums`)).status).toBe(401); + }); + + it('SYNO-040 — POST /search without auth returns 401', async () => { + expect((await request(app).post(`${SYNO}/search`)).status).toBe(401); + }); + + it('SYNO-040 — GET /assets/info without auth returns 401', async () => { + expect((await request(app).get(`${SYNO}/assets/1/photo-x/1/info`)).status).toBe(401); + }); + + it('SYNO-040 — GET /assets/thumbnail without auth returns 401', async () => { + expect((await request(app).get(`${SYNO}/assets/1/photo-x/1/thumbnail`)).status).toBe(401); + }); +}); diff --git a/server/tests/integration/memories-unified.test.ts b/server/tests/integration/memories-unified.test.ts new file mode 100644 index 0000000..2d10e8f --- /dev/null +++ b/server/tests/integration/memories-unified.test.ts @@ -0,0 +1,334 @@ +/** + * Unified Memories integration tests (UNIFIED-001 – UNIFIED-020). + * Covers the provider-agnostic /unified/trips/:tripId/photos and + * /unified/trips/:tripId/album-links routes. + * + * No real HTTP is made — safeFetch is mocked to never be called. + * The broadcast WebSocket call is no-op mocked. + */ +import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest'; +import request from 'supertest'; +import type { Application } from 'express'; + +// ── Hoisted DB mock ────────────────────────────────────────────────────────── + +const { testDb, dbMock } = vi.hoisted(() => { + const Database = require('better-sqlite3'); + const db = new Database(':memory:'); + db.exec('PRAGMA journal_mode = WAL'); + db.exec('PRAGMA foreign_keys = ON'); + db.exec('PRAGMA busy_timeout = 5000'); + const mock = { + db, + closeDb: () => {}, + reinitialize: () => {}, + getPlaceWithTags: () => null, + canAccessTrip: (tripId: any, userId: number) => + db.prepare(`SELECT t.id, t.user_id FROM trips t LEFT JOIN trip_members m ON m.trip_id = t.id AND m.user_id = ? WHERE t.id = ? AND (t.user_id = ? OR m.user_id IS NOT NULL)`).get(userId, tripId, userId), + isOwner: (tripId: any, userId: number) => + !!db.prepare('SELECT id FROM trips WHERE id = ? AND user_id = ?').get(tripId, userId), + }; + return { testDb: db, dbMock: mock }; +}); + +vi.mock('../../src/db/database', () => dbMock); +vi.mock('../../src/config', () => ({ + JWT_SECRET: 'test-jwt-secret-for-trek-testing-only', + ENCRYPTION_KEY: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2', + updateJwtSecret: () => {}, +})); +vi.mock('../../src/websocket', () => ({ broadcast: vi.fn() })); +vi.mock('../../src/utils/ssrfGuard', async () => { + const actual = await vi.importActual('../../src/utils/ssrfGuard'); + return { + ...actual, + checkSsrf: vi.fn().mockResolvedValue({ allowed: true, isPrivate: false, resolvedIp: '93.184.216.34' }), + safeFetch: vi.fn().mockRejectedValue(new Error('safeFetch should not be called in unified tests')), + }; +}); + +import { createApp } from '../../src/app'; +import { createTables } from '../../src/db/schema'; +import { runMigrations } from '../../src/db/migrations'; +import { resetTestDb } from '../helpers/test-db'; +import { createUser, createTrip, addTripMember, addTripPhoto, addAlbumLink } from '../helpers/factories'; +import { authCookie } from '../helpers/auth'; +import { loginAttempts, mfaAttempts } from '../../src/routes/auth'; + +const app: Application = createApp(); + +const BASE = '/api/integrations/memories/unified'; + +beforeAll(() => { + createTables(testDb); + runMigrations(testDb); +}); + +beforeEach(() => { + resetTestDb(testDb); + loginAttempts.clear(); + mfaAttempts.clear(); +}); + +afterAll(() => testDb.close()); + +// ── Helpers ────────────────────────────────────────────────────────────────── + +function photosUrl(tripId: number) { return `${BASE}/trips/${tripId}/photos`; } +function albumLinksUrl(tripId: number, linkId?: number) { + return linkId ? `${BASE}/trips/${tripId}/album-links/${linkId}` : `${BASE}/trips/${tripId}/album-links`; +} + +// ── Unified Photo Management ───────────────────────────────────────────────── + +describe('Unified photo management', () => { + it('UNIFIED-001 — GET photos lists own + shared photos from other members', async () => { + const { user: owner } = createUser(testDb); + const { user: member } = createUser(testDb); + const trip = createTrip(testDb, owner.id); + addTripMember(testDb, trip.id, member.id); + + // owner has a private photo; member has a shared photo + addTripPhoto(testDb, trip.id, owner.id, 'asset-own', 'immich', { shared: false }); + addTripPhoto(testDb, trip.id, member.id, 'asset-shared', 'immich', { shared: true }); + + const res = await request(app) + .get(photosUrl(trip.id)) + .set('Cookie', authCookie(owner.id)); + + expect(res.status).toBe(200); + const ids = (res.body.photos as any[]).map((p: any) => p.asset_id); + expect(ids).toContain('asset-own'); + expect(ids).toContain('asset-shared'); + }); + + it('UNIFIED-002 — GET photos excludes other members\' private photos', async () => { + const { user: owner } = createUser(testDb); + const { user: member } = createUser(testDb); + const trip = createTrip(testDb, owner.id); + addTripMember(testDb, trip.id, member.id); + + addTripPhoto(testDb, trip.id, member.id, 'asset-private', 'immich', { shared: false }); + + const res = await request(app) + .get(photosUrl(trip.id)) + .set('Cookie', authCookie(owner.id)); + + expect(res.status).toBe(200); + const ids = (res.body.photos as any[]).map((p: any) => p.asset_id); + expect(ids).not.toContain('asset-private'); + }); + + it('UNIFIED-003 — GET photos returns 404 for non-member', async () => { + const { user: owner } = createUser(testDb); + const { user: stranger } = createUser(testDb); + const trip = createTrip(testDb, owner.id); + + const res = await request(app) + .get(photosUrl(trip.id)) + .set('Cookie', authCookie(stranger.id)); + + expect(res.status).toBe(404); + }); + + it('UNIFIED-004 — POST photos adds photos from selections', async () => { + const { user } = createUser(testDb); + const trip = createTrip(testDb, user.id); + + const res = await request(app) + .post(photosUrl(trip.id)) + .set('Cookie', authCookie(user.id)) + .send({ + shared: true, + selections: [{ provider: 'immich', asset_ids: ['asset-a', 'asset-b'] }], + }); + + expect(res.status).toBe(200); + expect(res.body.added).toBe(2); + + const rows = testDb.prepare('SELECT asset_id FROM trip_photos WHERE trip_id = ?').all(trip.id) as any[]; + expect(rows.map((r: any) => r.asset_id)).toEqual(expect.arrayContaining(['asset-a', 'asset-b'])); + }); + + it('UNIFIED-005 — POST photos with empty selections returns 400', async () => { + const { user } = createUser(testDb); + const trip = createTrip(testDb, user.id); + + const res = await request(app) + .post(photosUrl(trip.id)) + .set('Cookie', authCookie(user.id)) + .send({ selections: [] }); + + expect(res.status).toBe(400); + }); + + it('UNIFIED-006 — POST photos with invalid provider returns 400', async () => { + const { user } = createUser(testDb); + const trip = createTrip(testDb, user.id); + + const res = await request(app) + .post(photosUrl(trip.id)) + .set('Cookie', authCookie(user.id)) + .send({ selections: [{ provider: 'nonexistent', asset_ids: ['asset-x'] }] }); + + expect(res.status).toBe(400); + }); + + it('UNIFIED-007 — PUT photos/sharing toggles shared flag', async () => { + const { user } = createUser(testDb); + const trip = createTrip(testDb, user.id); + addTripPhoto(testDb, trip.id, user.id, 'asset-tog', 'immich', { shared: false }); + + const res = await request(app) + .put(`${photosUrl(trip.id)}/sharing`) + .set('Cookie', authCookie(user.id)) + .send({ provider: 'immich', asset_id: 'asset-tog', shared: true }); + + expect(res.status).toBe(200); + const row = testDb.prepare('SELECT shared FROM trip_photos WHERE asset_id = ?').get('asset-tog') as any; + expect(row.shared).toBe(1); + }); + + it('UNIFIED-008 — PUT photos/sharing on non-member trip returns 404', async () => { + const { user: owner } = createUser(testDb); + const { user: stranger } = createUser(testDb); + const trip = createTrip(testDb, owner.id); + + const res = await request(app) + .put(`${photosUrl(trip.id)}/sharing`) + .set('Cookie', authCookie(stranger.id)) + .send({ provider: 'immich', asset_id: 'any', shared: true }); + + expect(res.status).toBe(404); + }); + + it('UNIFIED-009 — DELETE photos removes own photo', async () => { + const { user } = createUser(testDb); + const trip = createTrip(testDb, user.id); + addTripPhoto(testDb, trip.id, user.id, 'asset-del', 'immich'); + + const res = await request(app) + .delete(photosUrl(trip.id)) + .set('Cookie', authCookie(user.id)) + .send({ provider: 'immich', asset_id: 'asset-del' }); + + expect(res.status).toBe(200); + const row = testDb.prepare('SELECT * FROM trip_photos WHERE asset_id = ?').get('asset-del'); + expect(row).toBeUndefined(); + }); + + it('UNIFIED-010 — DELETE photos on non-member trip returns 404', async () => { + const { user: owner } = createUser(testDb); + const { user: stranger } = createUser(testDb); + const trip = createTrip(testDb, owner.id); + + const res = await request(app) + .delete(photosUrl(trip.id)) + .set('Cookie', authCookie(stranger.id)) + .send({ provider: 'immich', asset_id: 'any' }); + + expect(res.status).toBe(404); + }); +}); + +// ── Unified Album-Link Management ──────────────────────────────────────────── + +describe('Unified album-link management', () => { + it('UNIFIED-011 — POST album-links with missing provider returns 400', async () => { + const { user } = createUser(testDb); + const trip = createTrip(testDb, user.id); + + const res = await request(app) + .post(albumLinksUrl(trip.id)) + .set('Cookie', authCookie(user.id)) + .send({ album_id: 'album-abc', album_name: 'Test' }); // no provider + + expect(res.status).toBe(400); + }); + + it('UNIFIED-012 — POST album-links with missing album_id returns 400', async () => { + const { user } = createUser(testDb); + const trip = createTrip(testDb, user.id); + + const res = await request(app) + .post(albumLinksUrl(trip.id)) + .set('Cookie', authCookie(user.id)) + .send({ provider: 'immich', album_name: 'Test' }); // no album_id + + expect(res.status).toBe(400); + }); + + it('UNIFIED-013 — POST album-links duplicate link returns 409', async () => { + const { user } = createUser(testDb); + const trip = createTrip(testDb, user.id); + + await request(app) + .post(albumLinksUrl(trip.id)) + .set('Cookie', authCookie(user.id)) + .send({ provider: 'immich', album_id: 'album-dup', album_name: 'Dup' }); + + const res = await request(app) + .post(albumLinksUrl(trip.id)) + .set('Cookie', authCookie(user.id)) + .send({ provider: 'immich', album_id: 'album-dup', album_name: 'Dup' }); + + expect(res.status).toBe(409); + }); + + it('UNIFIED-014 — GET album-links only returns links for enabled providers', async () => { + const { user } = createUser(testDb); + const trip = createTrip(testDb, user.id); + addAlbumLink(testDb, trip.id, user.id, 'immich', 'album-enabled'); + + // Disable the immich provider + testDb.prepare('UPDATE photo_providers SET enabled = 0 WHERE id = ?').run('immich'); + + const res = await request(app) + .get(albumLinksUrl(trip.id)) + .set('Cookie', authCookie(user.id)); + + // Re-enable for future tests + testDb.prepare('UPDATE photo_providers SET enabled = 1 WHERE id = ?').run('immich'); + + expect(res.status).toBe(400); // no providers enabled → error + }); +}); + +// ── Auth checks ─────────────────────────────────────────────────────────────── + +describe('Unified auth checks', () => { + it('UNIFIED-020 — GET photos without auth returns 401', async () => { + const res = await request(app).get(`${BASE}/trips/1/photos`); + expect(res.status).toBe(401); + }); + + it('UNIFIED-020 — POST photos without auth returns 401', async () => { + const res = await request(app).post(`${BASE}/trips/1/photos`); + expect(res.status).toBe(401); + }); + + it('UNIFIED-020 — PUT photos/sharing without auth returns 401', async () => { + const res = await request(app).put(`${BASE}/trips/1/photos/sharing`); + expect(res.status).toBe(401); + }); + + it('UNIFIED-020 — DELETE photos without auth returns 401', async () => { + const res = await request(app).delete(`${BASE}/trips/1/photos`); + expect(res.status).toBe(401); + }); + + it('UNIFIED-020 — GET album-links without auth returns 401', async () => { + const res = await request(app).get(`${BASE}/trips/1/album-links`); + expect(res.status).toBe(401); + }); + + it('UNIFIED-020 — POST album-links without auth returns 401', async () => { + const res = await request(app).post(`${BASE}/trips/1/album-links`); + expect(res.status).toBe(401); + }); + + it('UNIFIED-020 — DELETE album-links without auth returns 401', async () => { + const res = await request(app).delete(`${BASE}/trips/1/album-links/1`); + expect(res.status).toBe(401); + }); +}); diff --git a/server/tests/integration/vacay.test.ts b/server/tests/integration/vacay.test.ts index 74c7bb5..879d497 100644 --- a/server/tests/integration/vacay.test.ts +++ b/server/tests/integration/vacay.test.ts @@ -37,14 +37,12 @@ vi.mock('../../src/config', () => ({ updateJwtSecret: () => {}, })); -// Mock external holiday API (node-fetch used by some service paths) -vi.mock('node-fetch', () => ({ - default: vi.fn().mockResolvedValue({ - ok: true, - json: () => Promise.resolve([ - { date: '2025-01-01', name: 'New Year\'s Day', countryCode: 'DE' }, - ]), - }), +// Prevent real HTTP calls (holiday API etc.) +vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ + ok: true, + json: () => Promise.resolve([ + { date: '2025-01-01', name: 'New Year\'s Day', countryCode: 'DE' }, + ]), })); // Mock vacayService.getCountries to avoid real HTTP call to nager.at @@ -81,6 +79,7 @@ beforeEach(() => { afterAll(() => { testDb.close(); + vi.unstubAllGlobals(); }); describe('Vacay plan', () => { diff --git a/server/tests/integration/weather.test.ts b/server/tests/integration/weather.test.ts index 9d8e798..edf2b3f 100644 --- a/server/tests/integration/weather.test.ts +++ b/server/tests/integration/weather.test.ts @@ -39,23 +39,21 @@ vi.mock('../../src/config', () => ({ updateJwtSecret: () => {}, })); -// Mock node-fetch / global fetch so no real HTTP calls are made -vi.mock('node-fetch', () => ({ - default: vi.fn().mockResolvedValue({ - ok: true, - json: () => Promise.resolve({ - current: { temperature_2m: 22, weathercode: 1, windspeed_10m: 10, relativehumidity_2m: 60, precipitation: 0 }, - daily: { - time: ['2025-06-01'], - temperature_2m_max: [25], - temperature_2m_min: [18], - weathercode: [1], - precipitation_sum: [0], - windspeed_10m_max: [15], - sunrise: ['2025-06-01T06:00'], - sunset: ['2025-06-01T21:00'], - }, - }), +// Prevent real HTTP calls to Open-Meteo +vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ + ok: true, + json: () => Promise.resolve({ + current: { temperature_2m: 22, weathercode: 1, windspeed_10m: 10, relativehumidity_2m: 60, precipitation: 0 }, + daily: { + time: ['2025-06-01'], + temperature_2m_max: [25], + temperature_2m_min: [18], + weathercode: [1], + precipitation_sum: [0], + windspeed_10m_max: [15], + sunrise: ['2025-06-01T06:00'], + sunset: ['2025-06-01T21:00'], + }, }), })); @@ -82,6 +80,7 @@ beforeEach(() => { afterAll(() => { testDb.close(); + vi.unstubAllGlobals(); }); describe('Weather validation', () => { diff --git a/server/tests/unit/services/notificationService.test.ts b/server/tests/unit/services/notificationService.test.ts index a6b24f8..3f9eeba 100644 --- a/server/tests/unit/services/notificationService.test.ts +++ b/server/tests/unit/services/notificationService.test.ts @@ -47,11 +47,11 @@ vi.mock('nodemailer', () => ({ }, })); -vi.mock('node-fetch', () => ({ default: fetchMock })); +vi.stubGlobal('fetch', fetchMock); vi.mock('../../../src/websocket', () => ({ broadcastToUser: broadcastMock })); vi.mock('../../../src/utils/ssrfGuard', () => ({ checkSsrf: vi.fn(async () => ({ allowed: true, isPrivate: false, resolvedIp: '1.2.3.4' })), - createPinnedAgent: vi.fn(() => ({})), + createPinnedDispatcher: vi.fn(() => ({})), })); import { createTables } from '../../../src/db/schema'; @@ -109,6 +109,7 @@ beforeEach(() => { afterAll(() => { testDb.close(); + vi.unstubAllGlobals(); }); // ───────────────────────────────────────────────────────────────────────────── diff --git a/server/tests/unit/services/notifications.test.ts b/server/tests/unit/services/notifications.test.ts index 70a9dfd..acf7c3d 100644 --- a/server/tests/unit/services/notifications.test.ts +++ b/server/tests/unit/services/notifications.test.ts @@ -1,4 +1,4 @@ -import { describe, it, expect, vi, afterEach, beforeEach } from 'vitest'; +import { describe, it, expect, vi, afterEach, afterAll, beforeEach } from 'vitest'; vi.mock('../../../src/db/database', () => ({ db: { prepare: () => ({ get: vi.fn(() => undefined), all: vi.fn(() => []) }) }, @@ -16,12 +16,12 @@ vi.mock('../../../src/services/auditLog', () => ({ getClientIp: vi.fn(), })); vi.mock('nodemailer', () => ({ default: { createTransport: vi.fn(() => ({ sendMail: vi.fn() })) } })); -vi.mock('node-fetch', () => ({ default: vi.fn() })); +vi.stubGlobal('fetch', vi.fn()); // ssrfGuard is mocked per-test in the SSRF describe block; default passes all vi.mock('../../../src/utils/ssrfGuard', () => ({ checkSsrf: vi.fn(async () => ({ allowed: true, isPrivate: false, resolvedIp: '1.2.3.4' })), - createPinnedAgent: vi.fn(() => ({})), + createPinnedDispatcher: vi.fn(() => ({})), })); import { getEventText, buildEmailHtml, buildWebhookBody, sendWebhook } from '../../../src/services/notifications'; @@ -253,7 +253,7 @@ describe('sendWebhook SSRF protection (SEC-017)', () => { }); it('allows a public URL and calls fetch', async () => { - const mockFetch = (await import('node-fetch')).default as unknown as ReturnType; + const mockFetch = globalThis.fetch as unknown as ReturnType; mockFetch.mockResolvedValueOnce({ ok: true, text: async () => '' } as never); vi.mocked(checkSsrf).mockResolvedValueOnce({ allowed: true, isPrivate: false, resolvedIp: '1.2.3.4' }); @@ -306,7 +306,7 @@ describe('sendWebhook SSRF protection (SEC-017)', () => { }); it('does not call fetch when SSRF check blocks the URL', async () => { - const mockFetch = (await import('node-fetch')).default as unknown as ReturnType; + const mockFetch = globalThis.fetch as unknown as ReturnType; mockFetch.mockClear(); vi.mocked(checkSsrf).mockResolvedValueOnce({ allowed: false, isPrivate: true, resolvedIp: '127.0.0.1', @@ -317,3 +317,5 @@ describe('sendWebhook SSRF protection (SEC-017)', () => { expect(mockFetch).not.toHaveBeenCalled(); }); }); + +afterAll(() => vi.unstubAllGlobals()); diff --git a/server/tests/unit/services/weatherService.test.ts b/server/tests/unit/services/weatherService.test.ts index db14193..bee0c2a 100644 --- a/server/tests/unit/services/weatherService.test.ts +++ b/server/tests/unit/services/weatherService.test.ts @@ -1,10 +1,12 @@ -import { describe, it, expect, vi, beforeAll } from 'vitest'; +import { describe, it, expect, vi, beforeAll, afterAll } from 'vitest'; // Prevent the module-level setInterval from running during tests vi.useFakeTimers(); -// Mock node-fetch to prevent real HTTP requests -vi.mock('node-fetch', () => ({ default: vi.fn() })); +// Prevent real HTTP requests +vi.stubGlobal('fetch', vi.fn()); + +afterAll(() => vi.unstubAllGlobals()); import { estimateCondition, cacheKey } from '../../../src/services/weatherService';