fix(server): give the last six outbound fetches a timeout, and lint for it

Overpass, the Wikipedia photo lookup (which holds one of five shared
photo slots), the plugin download and registry, and the two API key
checks had no signal, so a provider that took the connection and went
quiet held the request for undici's 300 s default. ESLint now refuses a
fetch() whose init carries no signal.
This commit is contained in:
Maurice
2026-10-07 16:19:55 +02:00
parent a8fc93dfde
commit 7e8c9ac3a5
5 changed files with 46 additions and 4 deletions
@@ -13,6 +13,8 @@ import { isBlockedIp } from '../runtime/egress-policy';
const MAX_REDIRECTS = 5;
const MAX_BYTES = 50 * 1024 * 1024;
/** The whole download, every hop and the body included. */
const DOWNLOAD_TIMEOUT_MS = 120_000;
export class DownloadError extends Error {}
@@ -51,9 +53,10 @@ async function assertSafeHost(urlStr: string): Promise<void> {
export async function safeDownload(urlStr: string, maxBytes = MAX_BYTES): Promise<{ bytes: Buffer; sha256: string }> {
let current = urlStr;
const signal = AbortSignal.timeout(DOWNLOAD_TIMEOUT_MS);
for (let hop = 0; hop <= MAX_REDIRECTS; hop++) {
await assertSafeHost(current);
const resp = await fetch(current, { redirect: 'manual', headers: { 'User-Agent': 'TREK-Server' } });
const resp = await fetch(current, { redirect: 'manual', headers: { 'User-Agent': 'TREK-Server' }, signal });
if (resp.status >= 300 && resp.status < 400) {
const loc = resp.headers.get('location');
if (!loc) throw new DownloadError('redirect without a location');
@@ -220,7 +220,7 @@ export class PluginRegistryService {
headers['Cache-Control'] = 'no-cache';
headers.Pragma = 'no-cache';
}
const resp = await fetch(url, { headers });
const resp = await fetch(url, { headers, signal: AbortSignal.timeout(15_000) });
if (!resp.ok) throw new Error(`registry ${resp.status}`);
const data = (await resp.json()) as Registry;
if (!data || !Array.isArray(data.plugins)) throw new Error('malformed registry');