| 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<RemoteImageFetchResult> { | |
| 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`); | |
| } | |