Merge pull request #437 from mauriceboe/feat/migrate-node-fetch-to-native

refactor(server): replace node-fetch with native fetch + undici, fix photo integrations
This commit is contained in:
Julien G.
2026-04-05 21:15:03 +02:00
committed by GitHub
30 changed files with 1685 additions and 549 deletions

View File

@@ -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",

View File

@@ -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",

View File

@@ -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);

View File

@@ -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 <img> 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;

View File

@@ -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 <img> 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 ──────────────────────────────────────────────────────────

View File

@@ -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));

View File

@@ -1,4 +1,3 @@
import fetch from 'node-fetch';
import { db } from '../db/database';
import { Trip, Place } from '../types';

View File

@@ -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);

View File

@@ -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<LinkPreviewResult>
}
try {
const nodeFetch = require('node-fetch');
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 5000);
try {
const r: { ok: boolean; text: () => Promise<string> } = 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');

View File

@@ -3,8 +3,6 @@ import crypto from 'crypto';
const TTL: Record<string, number> = {
ws: 30_000,
download: 60_000,
immich: 60_000,
synologyphotos: 60_000,
};
const MAX_STORE_SIZE = 10_000;

View File

@@ -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';

View File

@@ -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<void> {
try{
export async function pipeAsset(url: string, response: Response, headers?: Record<string, string>, signal?: AbortSignal): Promise<void> {
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();
}
}

View File

@@ -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[] };

View File

@@ -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<SynologyUserRecord> {
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<Syn
filtered[column] = row[column];
}
if (!filtered) {
return fail('Failed to read Synology user data', 500);
}
return success(filtered);
} catch {
return fail('Failed to read Synology user data', 500);
@@ -112,10 +105,12 @@ function _getSynologyCredentials(userId: number): ServiceResult<SynologyCredenti
const user = _readSynologyUser(userId, ['synology_url', 'synology_username', 'synology_password']);
if (!user.success) return user as ServiceResult<SynologyCredentials>;
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<T>(url: string, body: URLSearchParams): Promise<ServiceResult<T>> {
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<T>;
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<ServiceResult<string>> {
@@ -196,11 +187,12 @@ async function _requestSynologyApi<T>(userId: number, params: ApiCallParams): Pr
const body = _buildSynologyFormBody({ ...params, _sid: session.data });
const result = await _fetchSynologyJson<T>(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<T>;
return retrySession as ServiceResult<T>;
}
return _fetchSynologyJson<T>(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<ServiceResult<string
const cachedSid = _readSynologyUser(userId, ['synology_sid']);
if (cachedSid.success && cachedSid.data?.synology_sid) {
const decryptedSid = decrypt_api_key(cachedSid.data.synology_sid);
return success(decryptedSid);
if (decryptedSid) return success(decryptedSid);
// Decryption failed (e.g. key rotation) — clear the stale SID and re-login
_clearSynologySID(userId);
}
const creds = _getSynologyCredentials(userId);
@@ -416,22 +413,24 @@ export async function searchSynologyPhotos(userId: number, from?: string, to?: s
}
}
const result = await _requestSynologyApi<{ list: SynologyPhotoItem[]; total: number }>(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<AssetsList>;
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<ServiceResult<AssetInfo>> {
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<void> {
const parsedId = _splitPackedSynologyId(photoId);
if (!parsedId) {
handleServiceResult(response, fail('Invalid photo ID format', 400));
return;
}
const synology_credentials = _getSynologyCredentials(targetUserId);
if (!synology_credentials.success) {

View File

@@ -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(() => '');

View File

@@ -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';

View File

@@ -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';

View File

@@ -1,4 +1,3 @@
import fetch from 'node-fetch';
// ── Interfaces ──────────────────────────────────────────────────────────

View File

@@ -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<string, unknown>, 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<Response> {
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<string, unknown>, 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);
}
},
},
});
}

View File

@@ -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);
}

View File

@@ -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 */ }
}
/**

View File

@@ -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);
});
});

View File

@@ -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<typeof import('../../src/utils/ssrfGuard')>('../../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);
});
});

View File

@@ -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<typeof import('../../src/utils/ssrfGuard')>('../../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);
});
});

View File

@@ -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<typeof import('../../src/utils/ssrfGuard')>('../../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);
});
});

View File

@@ -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', () => {

View File

@@ -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', () => {

View File

@@ -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();
});
// ─────────────────────────────────────────────────────────────────────────────

View File

@@ -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<typeof vi.fn>;
const mockFetch = globalThis.fetch as unknown as ReturnType<typeof vi.fn>;
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<typeof vi.fn>;
const mockFetch = globalThis.fetch as unknown as ReturnType<typeof vi.fn>;
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());

View File

@@ -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';