fiemwl / src /shared /network /remoteImageFetch.ts
automindy's picture
Upload 1980 files
6111b2b verified
Raw
History Blame Contribute Delete
3.9 kB
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`);
}