From b289441e6febbe26cf98e36cfd125327276534e2 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 15 Feb 2026 05:01:05 +0000 Subject: [PATCH] refactor(media): share response size limiter --- src/media/fetch.ts | 57 ++++----------------------- src/media/input-files.ts | 43 +------------------- src/media/read-response-with-limit.ts | 52 ++++++++++++++++++++++++ 3 files changed, 61 insertions(+), 91 deletions(-) create mode 100644 src/media/read-response-with-limit.ts diff --git a/src/media/fetch.ts b/src/media/fetch.ts index 59a4d09199..493f71b872 100644 --- a/src/media/fetch.ts +++ b/src/media/fetch.ts @@ -2,6 +2,7 @@ import path from "node:path"; import type { LookupFn, SsrFPolicy } from "../infra/net/ssrf.js"; import { fetchWithSsrFGuard } from "../infra/net/fetch-guard.js"; import { detectMime, extensionForMime } from "./mime.js"; +import { readResponseWithLimit } from "./read-response-with-limit.js"; type FetchMediaResult = { buffer: Buffer; @@ -129,7 +130,13 @@ export async function fetchRemoteMedia(options: FetchMediaOptions): Promise + new MediaFetchError( + "max_bytes", + `Failed to fetch media from ${res.url || url}: payload exceeds maxBytes ${maxBytes}`, + ), + }) : Buffer.from(await res.arrayBuffer()); let fileNameFromUrl: string | undefined; try { @@ -169,51 +176,3 @@ export async function fetchRemoteMedia(options: FetchMediaOptions): Promise { - const body = res.body; - if (!body || typeof body.getReader !== "function") { - const fallback = Buffer.from(await res.arrayBuffer()); - if (fallback.length > maxBytes) { - throw new MediaFetchError( - "max_bytes", - `Failed to fetch media from ${res.url || "response"}: payload exceeds maxBytes ${maxBytes}`, - ); - } - return fallback; - } - - const reader = body.getReader(); - const chunks: Uint8Array[] = []; - let total = 0; - try { - while (true) { - const { done, value } = await reader.read(); - if (done) { - break; - } - if (value?.length) { - total += value.length; - if (total > maxBytes) { - try { - await reader.cancel(); - } catch {} - throw new MediaFetchError( - "max_bytes", - `Failed to fetch media from ${res.url || "response"}: payload exceeds maxBytes ${maxBytes}`, - ); - } - chunks.push(value); - } - } - } finally { - try { - reader.releaseLock(); - } catch {} - } - - return Buffer.concat( - chunks.map((chunk) => Buffer.from(chunk)), - total, - ); -} diff --git a/src/media/input-files.ts b/src/media/input-files.ts index e869ca3fbd..dcab74ac6a 100644 --- a/src/media/input-files.ts +++ b/src/media/input-files.ts @@ -2,6 +2,7 @@ import type { SsrFPolicy } from "../infra/net/ssrf.js"; import { fetchWithSsrFGuard } from "../infra/net/fetch-guard.js"; import { logWarn } from "../logger.js"; import { estimateBase64DecodedBytes } from "./base64.js"; +import { readResponseWithLimit } from "./read-response-with-limit.js"; type CanvasModule = typeof import("@napi-rs/canvas"); type PdfJsModule = typeof import("pdfjs-dist/legacy/build/pdf.mjs"); @@ -194,48 +195,6 @@ export async function fetchWithGuard(params: { } } -async function readResponseWithLimit(res: Response, maxBytes: number): Promise { - const body = res.body; - if (!body || typeof body.getReader !== "function") { - const fallback = Buffer.from(await res.arrayBuffer()); - if (fallback.byteLength > maxBytes) { - throw new Error(`Content too large: ${fallback.byteLength} bytes (limit: ${maxBytes} bytes)`); - } - return fallback; - } - - const reader = body.getReader(); - const chunks: Uint8Array[] = []; - let total = 0; - try { - while (true) { - const { done, value } = await reader.read(); - if (done) { - break; - } - if (value?.length) { - total += value.length; - if (total > maxBytes) { - try { - await reader.cancel(); - } catch {} - throw new Error(`Content too large: ${total} bytes (limit: ${maxBytes} bytes)`); - } - chunks.push(value); - } - } - } finally { - try { - reader.releaseLock(); - } catch {} - } - - return Buffer.concat( - chunks.map((chunk) => Buffer.from(chunk)), - total, - ); -} - function decodeTextContent(buffer: Buffer, charset: string | undefined): string { const encoding = charset?.trim().toLowerCase() || "utf-8"; try { diff --git a/src/media/read-response-with-limit.ts b/src/media/read-response-with-limit.ts new file mode 100644 index 0000000000..a9ad353f5e --- /dev/null +++ b/src/media/read-response-with-limit.ts @@ -0,0 +1,52 @@ +export async function readResponseWithLimit( + res: Response, + maxBytes: number, + opts?: { + onOverflow?: (params: { size: number; maxBytes: number; res: Response }) => Error; + }, +): Promise { + const onOverflow = + opts?.onOverflow ?? + ((params: { size: number; maxBytes: number }) => + new Error(`Content too large: ${params.size} bytes (limit: ${params.maxBytes} bytes)`)); + + const body = res.body; + if (!body || typeof body.getReader !== "function") { + const fallback = Buffer.from(await res.arrayBuffer()); + if (fallback.length > maxBytes) { + throw onOverflow({ size: fallback.length, maxBytes, res }); + } + return fallback; + } + + const reader = body.getReader(); + const chunks: Uint8Array[] = []; + let total = 0; + try { + while (true) { + const { done, value } = await reader.read(); + if (done) { + break; + } + if (value?.length) { + total += value.length; + if (total > maxBytes) { + try { + await reader.cancel(); + } catch {} + throw onOverflow({ size: total, maxBytes, res }); + } + chunks.push(value); + } + } + } finally { + try { + reader.releaseLock(); + } catch {} + } + + return Buffer.concat( + chunks.map((chunk) => Buffer.from(chunk)), + total, + ); +}