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
-3
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);
-274
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;
+11 -31
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 ──────────────────────────────────────────────────────────
+3 -1
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));
-1
View File
@@ -1,4 +1,3 @@
import fetch from 'node-fetch';
import { db } from '../db/database';
import { Trip, Place } from '../types';
+1 -2
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);
+4 -5
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');
-2
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;
-1
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';
+14 -20
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();
}
}
+29 -53
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[] };
+31 -28
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) {
+3 -5
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(() => '');
-1
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';
-1
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';
-1
View File
@@ -1,4 +1,3 @@
import fetch from 'node-fetch';
// ── Interfaces ──────────────────────────────────────────────────────────
+42 -19
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);
}
},
},
});
}