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