import { type OutboundUrlGuardMode, getProviderOutboundGuard, parseAndValidatePublicUrl, parseOutboundUrl, } from "@/shared/network/outboundUrlGuard"; const DEFAULT_MAX_REMOTE_IMAGE_BYTES = 20 * 1024 * 1024; const DEFAULT_MAX_REDIRECTS = 3; const DEFAULT_TIMEOUT_MS = 15000; export interface RemoteImageFetchOptions { fetchImpl?: typeof fetch; guard?: OutboundUrlGuardMode; maxBytes?: number; maxRedirects?: number; signal?: AbortSignal; timeoutMs?: number; } export interface RemoteImageFetchResult { buffer: Buffer; contentType: string; url: string; } function validateRemoteImageUrl(input: string | URL, guard: OutboundUrlGuardMode) { return guard === "public-only" ? parseAndValidatePublicUrl(input) : parseOutboundUrl(input); } function combineSignals(signal: AbortSignal | undefined, timeoutMs: number) { const timeoutSignal = AbortSignal.timeout(timeoutMs); if (!signal) return timeoutSignal; return AbortSignal.any([signal, timeoutSignal]); } async function readResponseBuffer(response: Response, maxBytes: number) { const contentLengthHeader = response.headers.get("content-length"); const contentLength = contentLengthHeader ? Number.parseInt(contentLengthHeader, 10) : null; if (contentLength !== null && Number.isFinite(contentLength) && contentLength > maxBytes) { throw new Error(`Remote image exceeds ${maxBytes} byte limit`); } if (!response.body) { const buffer = Buffer.from(await response.arrayBuffer()); if (buffer.byteLength > maxBytes) { throw new Error(`Remote image exceeds ${maxBytes} byte limit`); } return buffer; } const reader = response.body.getReader(); const chunks: Buffer[] = []; let totalBytes = 0; try { while (true) { const { done, value } = await reader.read(); if (done) break; const chunk = Buffer.from(value); totalBytes += chunk.byteLength; if (totalBytes > maxBytes) { await reader.cancel(); throw new Error(`Remote image exceeds ${maxBytes} byte limit`); } chunks.push(chunk); } } finally { reader.releaseLock(); } return Buffer.concat(chunks, totalBytes); } export async function fetchRemoteImage( input: string | URL, options: RemoteImageFetchOptions = {} ): Promise { const fetchImpl = options.fetchImpl ?? fetch; const guard = options.guard ?? getProviderOutboundGuard(); const maxBytes = options.maxBytes ?? DEFAULT_MAX_REMOTE_IMAGE_BYTES; const maxRedirects = options.maxRedirects ?? DEFAULT_MAX_REDIRECTS; const signal = combineSignals(options.signal, options.timeoutMs ?? DEFAULT_TIMEOUT_MS); let currentUrl = validateRemoteImageUrl(input, guard); for (let redirectCount = 0; redirectCount <= maxRedirects; redirectCount++) { const response = await fetchImpl(currentUrl.toString(), { method: "GET", redirect: "manual", signal, }); if (response.status >= 300 && response.status < 400) { const location = response.headers.get("location"); if (!location) { throw new Error(`Remote image redirect missing Location header (${response.status})`); } if (redirectCount >= maxRedirects) { throw new Error(`Remote image exceeded ${maxRedirects} redirect limit`); } currentUrl = validateRemoteImageUrl(new URL(location, currentUrl), guard); continue; } if (!response.ok) { throw new Error(`Remote image fetch error ${response.status}`); } return { buffer: await readResponseBuffer(response, maxBytes), contentType: response.headers.get("content-type") || "application/octet-stream", url: currentUrl.toString(), }; } throw new Error(`Remote image exceeded ${maxRedirects} redirect limit`); }