src/image.js
Rama master · réplica de lectura del repositorio original.
import https from 'node:https';
import http from 'node:http';
import { lookup as dnsLookup } from 'node:dns';
import { constants as bufferConstants } from 'node:buffer';
import { log } from './config.js';
import { tryExtractPdf } from './pdf.js';
import { isPrivateIp, resolvePublicAddresses } from './net-safety.js';
import { decodePng } from './vendor/png.js';
import jpegDecode from './vendor/jpeg-js/decoder.js';
import jpegEncode from './vendor/jpeg-js/encoder.js';
// Exported because the connect route (devin-connect.js:inlineRemoteImages) reuses
// fetchImageUrl and re-checks its result: one SSRF/size/content-type policy for
// both routes instead of a second copy that can drift.
export const MAX_SIZE = 5 * 1024 * 1024; // 5 MB
export const MAX_BASE64_LEN = Math.ceil(MAX_SIZE * 4 / 3) + 100;
const MAX_REDIRECTS = 3;
export const MIME_OK = new Set(['image/png', 'image/jpeg', 'image/webp', 'image/gif']);
// ---------------------------------------------------------------------------
// Inbound image inspection (pure-Node header parsing — NO pixel decode)
//
// Algorithm/defaults adapted from ZyphrZero kiro.rs `src/image_resize.rs` (MIT) —
// thanks. Anthropic's vision encoder caps the long side at 1568px (patch-grid
// boundary); AWS Q / Windsurf upstreams enforce a hard per-field byte limit, so
// oversized inbound images either get billed at full size or fail the request.
//
// Pure-Node header parsing (format detection, dimension reads, oversize
// classification) is the cheap fast path. Actual pixel downscale / re-encode
// is done by `shrinkPixels` using vendored zero-dependency codecs (a pure-Node
// PNG decoder over node:zlib + the BSD-3 jpeg-js decode/encode, both under
// `src/vendor/`). Any decode/encode failure — including an unsupported format
// such as WebP or an exotic PNG variant — degrades to a safe passthrough rather
// than failing the request or the server.
//
// What this module does:
// - magic-byte format detection (corrects a mislabeled media_type so the
// upstream's strict MIME check doesn't reject the image)
// - header-only dimension reads for PNG / JPEG / GIF / WebP (< 1ms, no decode)
// - structured oversize classification (bytes vs. dimensions)
// - real pixel downscale + JPEG re-encode for oversized images so they are
// sent (like the Devin CLI / Desktop) instead of dropped or billed at full
// size — see `shrinkPixels`
// ---------------------------------------------------------------------------
const IMAGE_MAX_LONG_SIDE = parseInt(process.env.WINDSURFAPI_IMAGE_MAX_LONG_SIDE || '1568', 10);
// Re-encode TARGET budget, measured in base64-string length (the on-the-wire
// unit the upstream per-field limit applies to). Oversized images are
// downscaled / re-encoded toward this. Default 400 KB mirrors ZyphrZero's
// `DEFAULT_MAX_BYTES` (a safe margin under the upstream hard field limit).
const IMAGE_MAX_BYTES = parseInt(process.env.WINDSURFAPI_IMAGE_MAX_BYTES || '400000', 10);
// JPEG re-encode starting quality (two-stage convergence steps down from here).
const IMAGE_JPEG_QUALITY = parseInt(process.env.WINDSURFAPI_IMAGE_JPEG_QUALITY || '85', 10);
// Hard drop ceiling: kept at the existing 5 MB so behavior does not regress.
// Only used as a last resort when even a re-encode cannot get under it (or the
// image could not be decoded). A re-encode normally lands far below this.
const IMAGE_MAX_BASE64_LEN = MAX_BASE64_LEN;
// Pixel-count ceiling for attempting a pixel decode. A crafted PNG can declare
// enormous IHDR dimensions with a few KB of high-ratio IDAT (a decode bomb);
// decoding it would allocate gigabytes. The vendored png.js rejects such
// dimensions itself, but we also predict the pixel budget here from the cheap
// header read so we never even hand oversized dimensions to the decoder. Kept
// aligned with png.js MAX_PIXELS (~40M pixels; 4K is ~8MP, so ample headroom).
const IMAGE_MAX_DECODE_PIXELS = 40 * 1024 * 1024;
// Decode a PNG or JPEG buffer to { width, height, data:RGBA } using the vendored
// zero-dependency codecs. WebP / GIF / exotic variants throw here, which the
// caller turns into a safe passthrough. Format is decided by magic bytes, not the
// declared label, so a mislabeled image still decodes.
function decodePixels(buf) {
const b = buf.subarray(0, 4);
// PNG magic: 89 50 4E 47
if (b[0] === 0x89 && b[1] === 0x50 && b[2] === 0x4e && b[3] === 0x47) {
return decodePng(buf);
}
// JPEG magic: FF D8
if (b[0] === 0xff && b[1] === 0xd8) {
// Ceiling aligned with IMAGE_MAX_DECODE_PIXELS: the decoder is the backstop for a
// frame whose header the module could not classify (truncated/unusual SOF), and a
// looser number here would let exactly those frames through. 512 MB memory guard kept.
const img = jpegDecode(buf, {
useTArray: true,
maxResolutionInMP: IMAGE_MAX_DECODE_PIXELS / 1_000_000,
maxMemoryUsageInMB: 512,
});
return { width: img.width, height: img.height, data: img.data };
}
throw new Error('unsupported image format for re-encode (only PNG/JPEG)');
}
// Bilinear downscale of an RGBA buffer to (dstW x dstH). Pure Node, no deps.
// Only ever called to shrink, so no special upscale handling is needed.
function scaleRGBA(src, srcW, srcH, dstW, dstH) {
// Every RGBA byte is overwritten below before the buffer can escape.
const out = Buffer.allocUnsafe(dstW * dstH * 4);
// Map dst pixel centers back into src space.
const xRatio = srcW / dstW;
const yRatio = srcH / dstH;
for (let dy = 0; dy < dstH; dy++) {
const sy = (dy + 0.5) * yRatio - 0.5;
let y0 = Math.floor(sy);
const wy = sy - y0;
if (y0 < 0) y0 = 0;
const y1 = Math.min(y0 + 1, srcH - 1);
for (let dx = 0; dx < dstW; dx++) {
const sx = (dx + 0.5) * xRatio - 0.5;
let x0 = Math.floor(sx);
const wx = sx - x0;
if (x0 < 0) x0 = 0;
const x1 = Math.min(x0 + 1, srcW - 1);
const o = (dy * dstW + dx) * 4;
const i00 = (y0 * srcW + x0) * 4;
const i01 = (y0 * srcW + x1) * 4;
const i10 = (y1 * srcW + x0) * 4;
const i11 = (y1 * srcW + x1) * 4;
for (let c = 0; c < 4; c++) {
const top = src[i00 + c] * (1 - wx) + src[i01 + c] * wx;
const bot = src[i10 + c] * (1 - wx) + src[i11 + c] * wx;
out[o + c] = (top * (1 - wy) + bot * wy + 0.5) | 0;
}
}
}
return out;
}
// Real pixel downscale + JPEG re-encode. Decodes the base64 image, scales it so
// the long side <= maxLongSide, then runs a two-stage convergence to fit
// maxBytes (base64-length budget): first step JPEG quality down to a floor,
// then shrink the long side and retry. GIF is skipped by the caller (may be
// animated). On ANY decode/encode failure this returns { ok:false } and the
// caller keeps the original image — a bad image must never fail the request.
// Algorithm/defaults adapted from ZyphrZero kiro.rs `src/image_resize.rs` (MIT).
export async function shrinkPixels(base64, opts = {}) {
const maxLongSide = opts.maxLongSide ?? IMAGE_MAX_LONG_SIDE;
const maxBytes = opts.maxBytes ?? IMAGE_MAX_BYTES;
const startQuality = opts.quality ?? IMAGE_JPEG_QUALITY;
const MIN_JPEG_QUALITY = 60; // quality floor before we shrink dimensions further
// The rounded 1568 -> 128 schedule has 13 passes, including both endpoints.
const MAX_DEFAULT_DIMENSION_PASSES = 13;
// Dimension floor for the byte-convergence loop. The vendored jpeg-js encoder
// produces larger output than jimp's for incompressible content, so allow the
// long side to shrink further than jimp needed (128 vs the old 256) to still
// meet a tight byte budget for near-random images. Real screenshots compress
// well and rarely reach this floor.
const MIN_LONG_SIDE = 128;
try {
const buf = Buffer.from(base64, 'base64');
let finalJpeg;
let outBase64Len;
// Pre-decode budget check: read the header dimensions (cheap, no pixel
// decode) and bail before allocating anything if the declared pixel count is
// absurd. This shields the decoders from a crafted decode bomb (tiny bytes,
// giant IHDR/SOF dimensions) — the vendored png.js also self-guards, but
// refusing here means we never even attempt the multi-hundred-MB allocation.
// Returning ok:false makes the caller keep the original image (passthrough).
const headerDims = readImageDimensions(base64);
if (headerDims && headerDims.width * headerDims.height > IMAGE_MAX_DECODE_PIXELS) {
return { ok: false, error: `image ${headerDims.width}x${headerDims.height} exceeds decode pixel budget` };
}
// The header read above is deliberately cheap (256 KiB for JPEG), so a SOF that
// sits behind a large EXIF/ICC/filler run is invisible to it and the guard above
// is skipped entirely. Re-check the dimensions on the FULL decoded buffer, which
// is the same ceiling applied at the same boundary — just over every byte we
// already hold, not a window of it. readJpegDimensions allocates nothing.
if (buf.length >= 2 && buf[0] === 0xff && buf[1] === 0xd8) {
const jpegDims = readJpegDimensions(buf);
if (jpegDims && jpegDims.width * jpegDims.height > IMAGE_MAX_DECODE_PIXELS) {
return { ok: false, error: `image ${jpegDims.width}x${jpegDims.height} exceeds decode pixel budget` };
}
}
const original = decodePixels(buf); // { width, height, data:RGBA }
const srcW = original.width;
const srcH = original.height;
if (!srcW || !srcH) return { ok: false, error: 'decoded image has no dimensions' };
let curLong = Math.min(maxLongSide, Math.max(srcW, srcH));
let maxDimensionPasses = MAX_DEFAULT_DIMENSION_PASSES;
if (!Number.isFinite(curLong)) {
// Preserve legacy non-finite option behavior; validation is a separate policy.
maxDimensionPasses = Infinity;
} else if (curLong > 1568) {
// Larger overrides retain every pass of the same rounded recurrence.
maxDimensionPasses = 1;
for (let probe = curLong; probe > MIN_LONG_SIDE;
probe = Math.max(MIN_LONG_SIDE, Math.round(probe * 0.8))) {
maxDimensionPasses++;
}
}
let outBase64 = '';
for (let dimensionPass = 0; dimensionPass < maxDimensionPasses; dimensionPass++) {
// Scale from the full-res original each pass so repeated downscales never
// compound quality loss.
let w = srcW, h = srcH, data = original.data;
if (Math.max(srcW, srcH) > curLong) {
const scale = curLong / Math.max(srcW, srcH);
w = Math.max(1, Math.round(srcW * scale));
h = Math.max(1, Math.round(srcH * scale));
data = scaleRGBA(original.data, srcW, srcH, w, h);
}
let quality = startQuality;
for (;;) {
const jpg = jpegEncode({ data, width: w, height: h }, quality);
finalJpeg = jpg.data;
// Padded base64 emits four characters per complete or partial triplet.
outBase64Len = 4 * Math.ceil(finalJpeg.length / 3);
if (outBase64Len > bufferConstants.MAX_STRING_LENGTH) {
// Preserve the native string-limit failure instead of trying a later candidate.
finalJpeg.toString('base64');
}
if (outBase64Len <= maxBytes || quality <= MIN_JPEG_QUALITY) break;
quality = Math.max(MIN_JPEG_QUALITY, quality - 10);
}
if (outBase64Len <= maxBytes || curLong <= MIN_LONG_SIDE) break;
curLong = Math.max(MIN_LONG_SIDE, Math.round(curLong * 0.8));
}
// No candidate is encoded again: serialize only the buffer selected above.
outBase64 = finalJpeg.toString('base64');
return { ok: true, base64_data: outBase64, mime_type: 'image/jpeg' };
} catch (e) {
return { ok: false, error: e?.message || String(e) };
}
}
const FORMAT_TO_MIME = {
png: 'image/png',
jpeg: 'image/jpeg',
gif: 'image/gif',
webp: 'image/webp',
};
// Decode the leading `n` base64 chars into bytes (cheap header probe).
function decodeHead(base64, n) {
// base64 is 4 chars -> 3 bytes; round the char count down to a 4-char boundary
// so Buffer.from never sees a partial group.
const chars = Math.min(base64.length, Math.ceil(n / 3) * 4) & ~3;
if (chars <= 0) return Buffer.alloc(0);
try { return Buffer.from(base64.slice(0, chars), 'base64'); }
catch { return Buffer.alloc(0); }
}
// Detect the real format from magic bytes. Returns 'png'|'jpeg'|'gif'|'webp'|null.
// `null` means "undetectable" — callers keep the declared label, never drop.
export function detectImageFormat(base64) {
if (typeof base64 !== 'string' || base64.length === 0) return null;
const b = decodeHead(base64, 16);
if (b.length < 4) return null;
// PNG: 89 50 4E 47 0D 0A 1A 0A
if (b.length >= 8 && b[0] === 0x89 && b[1] === 0x50 && b[2] === 0x4e && b[3] === 0x47 &&
b[4] === 0x0d && b[5] === 0x0a && b[6] === 0x1a && b[7] === 0x0a) return 'png';
// JPEG: FF D8 FF
if (b[0] === 0xff && b[1] === 0xd8 && b[2] === 0xff) return 'jpeg';
// GIF: "GIF87a" / "GIF89a"
if (b.length >= 6 && b[0] === 0x47 && b[1] === 0x49 && b[2] === 0x46 &&
b[3] === 0x38 && (b[4] === 0x37 || b[4] === 0x39) && b[5] === 0x61) return 'gif';
// WebP: "RIFF" .... "WEBP"
if (b.length >= 12 && b[0] === 0x52 && b[1] === 0x49 && b[2] === 0x46 && b[3] === 0x46 &&
b[8] === 0x57 && b[9] === 0x45 && b[10] === 0x42 && b[11] === 0x50) return 'webp';
return null;
}
const JPEG_SOF_MARKERS = new Set([
0xc0, 0xc1, 0xc2, 0xc3, 0xc5, 0xc6, 0xc7, 0xc9, 0xca, 0xcb, 0xcd, 0xce, 0xcf,
]);
function readPngDimensions(b) {
// signature(8) + len(4) + 'IHDR'(4) + width(4 BE) + height(4 BE)
if (b.length < 24) return null;
if (b[12] !== 0x49 || b[13] !== 0x48 || b[14] !== 0x44 || b[15] !== 0x52) return null; // 'IHDR'
const width = b.readUInt32BE(16);
const height = b.readUInt32BE(20);
return width > 0 && height > 0 ? { width, height } : null;
}
function readJpegDimensions(b) {
if (b.length < 4 || b[0] !== 0xff || b[1] !== 0xd8) return null;
let i = 2;
while (i + 9 <= b.length) {
if (b[i] !== 0xff) { i++; continue; } // resync over fill bytes
const marker = b[i + 1];
// Standalone markers carry no length payload.
if (marker === 0xd8 || marker === 0xd9 || marker === 0x01 ||
(marker >= 0xd0 && marker <= 0xd7)) { i += 2; continue; }
if (i + 4 > b.length) break;
const segLen = b.readUInt16BE(i + 2);
if (segLen < 2) break;
if (JPEG_SOF_MARKERS.has(marker)) {
// SOF: marker(2) + len(2) + precision(1) + height(2 BE) + width(2 BE)
if (i + 9 > b.length) break;
const height = b.readUInt16BE(i + 5);
const width = b.readUInt16BE(i + 7);
return width > 0 && height > 0 ? { width, height } : null;
}
i += 2 + segLen;
}
return null;
}
function readGifDimensions(b) {
if (b.length < 10) return null;
const width = b.readUInt16LE(6);
const height = b.readUInt16LE(8);
return width > 0 && height > 0 ? { width, height } : null;
}
function readWebpDimensions(b) {
if (b.length < 16) return null;
const chunk = b.toString('ascii', 12, 16);
if (chunk === 'VP8 ') {
// Lossy: key-frame start code 9D 01 2A at offset 23, dims at 26/28 (14-bit LE).
if (b.length < 30) return null;
if (b[23] !== 0x9d || b[24] !== 0x01 || b[25] !== 0x2a) return null;
const width = b.readUInt16LE(26) & 0x3fff;
const height = b.readUInt16LE(28) & 0x3fff;
return width > 0 && height > 0 ? { width, height } : null;
}
if (chunk === 'VP8L') {
// Lossless: signature 0x2F at offset 20, then 14-bit width-1 / height-1.
if (b.length < 25 || b[20] !== 0x2f) return null;
const b0 = b[21], b1 = b[22], b2 = b[23], b3 = b[24];
const width = 1 + (((b1 & 0x3f) << 8) | b0);
const height = 1 + (((b3 & 0x0f) << 10) | (b2 << 2) | (b1 >> 6));
return { width, height };
}
if (chunk === 'VP8X') {
// Extended: 24-bit LE width-1 / height-1 at offsets 24 / 27.
if (b.length < 30) return null;
const width = 1 + (b[24] | (b[25] << 8) | (b[26] << 16));
const height = 1 + (b[27] | (b[28] << 8) | (b[29] << 16));
return { width, height };
}
return null;
}
// Read pixel dimensions from the header only. Returns {width,height} or null.
// `format` is a hint ('png'|'jpeg'|'gif'|'webp'); detection falls back to magic
// bytes when the hint is missing/unknown.
export function readImageDimensions(base64, format) {
if (typeof base64 !== 'string' || base64.length === 0) return null;
// Magic-byte detection always wins: a mislabeled media_type (e.g. jpeg bytes
// tagged image/png) must not pick the wrong header parser. The hint is only a
// fallback for the rare case detection can't classify the bytes.
const fmt = detectImageFormat(base64) || ((format && FORMAT_TO_MIME[format]) ? format : null);
if (!fmt) return null;
// JPEG SOF can sit behind large EXIF/ICC segments; decode a generous head.
const headBytes = fmt === 'jpeg' ? 256 * 1024 : 64;
const b = decodeHead(base64, headBytes);
switch (fmt) {
case 'png': return readPngDimensions(b);
case 'jpeg': return readJpegDimensions(b);
case 'gif': return readGifDimensions(b);
case 'webp': return readWebpDimensions(b);
default: return null;
}
}
function mimeToFormat(mime) {
const sub = String(mime || '').toLowerCase().split('/')[1] || '';
if (sub === 'jpg' || sub === 'jpeg') return 'jpeg';
if (sub === 'png') return 'png';
if (sub === 'gif') return 'gif';
if (sub === 'webp') return 'webp';
return null;
}
// Inbound image gate. Inspects one image, corrects its declared mime from the
// real magic bytes, classifies oversize (bytes vs. dimensions), and — for a
// byte-oversized image — attempts a real pixel downscale + JPEG re-encode via
// `shrinkPixels` so the image is sent (like the Devin CLI/Desktop) instead of
// dropped. Async because the re-encode decodes pixels.
// - mime corrected from magic bytes (prevents upstream MIME mismatch)
// - oversized dimensions within byte budget -> forwarded as-is (token cost only)
// - byte-oversized -> re-encode; only if the re-encode still can't fit (or the
// image is a GIF / undecodable) is it dropped, with a structured reason
//
// Returns { base64_data, mime_type, format, width, height, base64Len,
// oversizeBytes, oversizeDimensions, resized, dropped, reason }.
// `dropped:true` means the caller should skip sending this image. `resized:true`
// means base64_data/mime_type were replaced with the re-encoded JPEG.
export async function maybeShrinkImage(img, opts = {}) {
const base64 = img?.base64_data || '';
const declaredMime = (img?.mime_type || 'image/png').toLowerCase();
const maxLongSide = opts.maxLongSide ?? IMAGE_MAX_LONG_SIDE;
const maxBase64Len = opts.maxBase64Len ?? IMAGE_MAX_BASE64_LEN;
const maxBytes = opts.maxBytes ?? IMAGE_MAX_BYTES;
const quality = opts.quality ?? IMAGE_JPEG_QUALITY;
const base64Len = base64.length;
const detected = detectImageFormat(base64);
const declaredFormat = mimeToFormat(declaredMime);
// Correct the format only when detection succeeds and disagrees with the label.
const format = detected || declaredFormat || null;
const mime_type = (detected && FORMAT_TO_MIME[detected]) || declaredMime;
const dims = readImageDimensions(base64, format);
const width = dims?.width ?? null;
const height = dims?.height ?? null;
const oversizeDimensions = dims ? Math.max(width, height) > maxLongSide : false;
const oversizeBytes = base64Len > maxBase64Len;
// Byte-oversized: try a real downscale + re-encode to rescue the image rather
// than forwarding it (would fail the whole upstream request) or dropping it.
// GIF is left alone (may be animated); a re-encode to JPEG would kill the
// animation, so a byte-oversized GIF is still dropped.
if (oversizeBytes) {
if (format !== 'gif') {
const shrunk = await shrinkPixels(base64, { maxLongSide, maxBytes, quality });
if (shrunk.ok && shrunk.base64_data.length <= maxBase64Len) {
return {
base64_data: shrunk.base64_data, mime_type: shrunk.mime_type, format: 'jpeg',
width, height, base64Len: shrunk.base64_data.length,
oversizeBytes: false, oversizeDimensions: false, resized: true, dropped: false,
reason: `re-encoded ${base64Len}B -> ${shrunk.base64_data.length}B JPEG (was byte-oversized)`,
};
}
return {
base64_data: base64, mime_type, format, width, height, base64Len,
oversizeBytes: true, oversizeDimensions, resized: false, dropped: true,
reason: shrunk.ok
? `base64 length ${base64Len} exceeds limit ${maxBase64Len}; re-encode reached ${shrunk.base64_data.length} but still over limit`
: `base64 length ${base64Len} exceeds limit ${maxBase64Len} and re-encode failed: ${shrunk.error}`,
};
}
return {
base64_data: base64, mime_type, format, width, height, base64Len,
oversizeBytes: true, oversizeDimensions, resized: false, dropped: true,
reason: `base64 length ${base64Len} exceeds limit ${maxBase64Len} and GIF is not re-encoded (may be animated)`,
};
}
// Within the hard byte budget but the long side is over the cap: downscale +
// re-encode so we send a correctly-sized image (saves tokens, avoids upstream
// re-downscaling). GIF stays untouched. A failed re-encode forwards as-is.
if (oversizeDimensions && format !== 'gif') {
const shrunk = await shrinkPixels(base64, { maxLongSide, maxBytes, quality });
if (shrunk.ok && shrunk.base64_data.length <= maxBase64Len) {
return {
base64_data: shrunk.base64_data, mime_type: shrunk.mime_type, format: 'jpeg',
width, height, base64Len: shrunk.base64_data.length,
oversizeBytes: false, oversizeDimensions: false, resized: true, dropped: false,
reason: `re-encoded long side ${Math.max(width, height)}px -> <=${maxLongSide}px JPEG`,
};
}
// Re-encode failed or didn't help: forward the original (token cost only).
return {
base64_data: base64, mime_type, format, width, height, base64Len,
oversizeBytes: false, oversizeDimensions: true, resized: false, dropped: false,
reason: `long side ${Math.max(width, height)}px exceeds ${maxLongSide}px (forwarded as-is; re-encode unavailable)`,
};
}
return {
base64_data: base64, mime_type, format, width, height, base64Len,
oversizeBytes: false, oversizeDimensions, resized: false, dropped: false,
reason: oversizeDimensions
? `long side ${Math.max(width, height)}px exceeds ${maxLongSide}px (GIF forwarded as-is)`
: null,
};
}
// http/https `lookup` hook: runs in place of the default DNS resolution.
// Rejecting here means the request never opens a socket to the internal
// address, closing the DNS-rebinding gap in the string-based host check.
function safeLookup(hostname, options, callback) {
dnsLookup(hostname, options, (err, address, family) => {
if (err) return callback(err);
const addrs = Array.isArray(address) ? address : [{ address, family }];
for (const a of addrs) {
if (isPrivateIp(a.address)) {
return callback(new Error(`Image URL resolves to private address: ${a.address}`));
}
}
callback(null, address, family);
});
}
// Plaintext http image bytes are a MITM surface: any middlebox between us and the
// image host can replace what is later inlined into the model's context (#10 /
// Cascade image blocks). https is the default. This escape hatch re-allows
// plaintext http for PUBLIC hosts only — private/loopback targets are rejected
// by the SSRF check below regardless of the switch — and is read live (exactly
// `1`, like net-safety's trustedProxyHops) so an operator or a test can flip it.
function allowPlaintextImageUrls(env = process.env) {
return env.WINDSURFAPI_ALLOW_HTTP_IMAGES === '1';
}
/** Host only — never log a full URL, which may carry a signed query string. */
function imageUrlHost(url) {
try { return new URL(url).host || '(unparseable)'; } catch { return '(unparseable)'; }
}
function validateImageUrl(url, env = process.env) {
let parsed;
try { parsed = new URL(url); } catch { throw new Error('Invalid image URL'); }
if (parsed.protocol !== 'https:' && parsed.protocol !== 'http:')
throw new Error('Image URL must be http or https');
// Private/internal is checked BEFORE the plaintext gate on purpose: an http URL
// aimed at an internal address is refused as SSRF first, and no escape hatch can
// make that address reachable.
if (String(parsed.hostname).toLowerCase() === 'localhost' || isPrivateIp(parsed.hostname))
throw new Error('Image URL targets a private/internal address');
// fetchImageHop calls this on EVERY hop, so an https -> http redirect is caught
// by the same check as a direct http URL.
if (parsed.protocol === 'http:' && !allowPlaintextImageUrls(env)) {
log.warn(`Image URL rejected: plaintext http (${imageUrlHost(url)}) — set WINDSURFAPI_ALLOW_HTTP_IMAGES=1 to allow it`);
throw new Error('Image URL must use https (plaintext http is rejected; set WINDSURFAPI_ALLOW_HTTP_IMAGES=1 to allow it)');
}
return parsed;
}
export function parseDataUrl(url) {
const clean = url.replace(/\s/g, '');
const m = clean.match(/^data:(image\/[a-z+]+);base64,(.+)$/i);
if (!m) return null;
if (m[2].length > MAX_BASE64_LEN) throw new Error(`Image data URL exceeds ${MAX_SIZE} byte limit`);
return { base64_data: m[2], mime_type: m[1].toLowerCase() };
}
// Extract base64 body from a data URL of any mime type. Used for PDF
// payloads which don't match parseDataUrl's image-only regex.
export function parseGenericDataUrl(url) {
const clean = url.replace(/\s/g, '');
const m = clean.match(/^data:([a-z0-9][a-z0-9.+/-]+);base64,(.+)$/i);
if (!m) return null;
if (m[2].length > MAX_BASE64_LEN) throw new Error(`Data URL exceeds ${MAX_SIZE} byte limit`);
return { base64_data: m[2], mime_type: m[1].toLowerCase() };
}
export async function assertPublicUrlHost(urlOrHost, lookupFn = dnsLookup) {
let host = urlOrHost;
try { host = new URL(urlOrHost).hostname; } catch {}
return resolvePublicAddresses(host, lookupFn);
}
// Gate one image through `maybeShrinkImage`, then either push it (corrected
// mime, real downscale/re-encode when oversized) or log a structured skip.
// Async because `maybeShrinkImage` may decode pixels to re-encode. Returns the
// pushed image object or null.
export async function pushImage(images, image) {
if (!image || !image.base64_data) return null;
const decision = await maybeShrinkImage(image);
if (decision.dropped) {
log.warn(`Image skipped: ${decision.reason} (format=${decision.format || 'unknown'}, ${decision.width || '?'}x${decision.height || '?'})`);
return null;
}
if (decision.resized) {
log.info(`Image re-encoded: ${decision.reason}`);
} else if (decision.oversizeDimensions) {
log.warn(`Image oversized: ${decision.reason}`);
}
const out = { base64_data: decision.base64_data, mime_type: decision.mime_type };
images.push(out);
return out;
}
// One absolute wall-clock budget for a whole image fetch: armed once by the root
// call, shared by every redirect hop, and cleared on settlement. The per-hop
// `timeout` option stays what node documents it as — a socket-INACTIVITY guard —
// so a chain of 7 s redirects or a body trickling one byte at a time can no longer
// hold the fetch open for minutes (each hop used to receive the full budget again).
//
// Every handle this helper owns (the active request/response) is tracked in `ctx`,
// so settlement destroys them and drops the helper's own listeners exactly once,
// whether the fetch ends by deadline, byte cap, bad status/MIME, transport error or
// success. Late events after settlement neither resolve again nor start a request.
const IMAGE_FETCH_TIMEOUT_MS = 8000;
// Remove the listeners this hop registered, so a settled (or abandoned) hop leaves
// nothing behind that can fire later.
function detach(emitter, listeners) {
if (!emitter) return;
for (const [event, fn] of listeners) emitter.removeListener(event, fn);
listeners.length = 0;
}
// Abort-shaped error for a cancelled image extraction. `signal.reason` is
// preserved when it is an Error/DOMException (AbortSignal.timeout gives a
// TimeoutError, abort(err) gives that exact error); otherwise a fresh
// AbortError is created. Either way callers see `name === 'AbortError'`.
function imageAbortError(signal) {
const reason = signal?.reason;
if (reason && typeof reason === 'object' && typeof reason.message === 'string') return reason;
const err = new Error(typeof reason === 'string' && reason ? reason : 'Image extraction aborted');
err.name = 'AbortError';
err.code = 'ABORT_ERR';
return err;
}
// A terminal error ends the extraction; an ordinary per-image failure (bad
// MIME, 404, SSRF, size, decode) keeps the log-and-continue behaviour.
function isTerminalImageError(err, signal) {
if (signal?.aborted) return true;
if (err?.name === 'AbortError' || err?.code === 'ABORT_ERR') return true;
// `beforeFetch` is the caller's turn deadline (client.js assertBudget). The
// marker is the one request-budget.js sets; checking it here instead of
// importing the predicate keeps image.js's import shape unchanged (the codec
// seam helper asserts it).
return err?.budgetExceeded === true || err?.code === 'DEADLINE_EXCEEDED';
}
export function fetchImageUrl(url, timeoutMs = IMAGE_FETCH_TIMEOUT_MS, _depth = 0, _ctx = null, signal = null) {
if (_depth > MAX_REDIRECTS) return Promise.reject(new Error('Too many image redirects'));
if (_ctx) return fetchImageHop(url, _depth, _ctx);
// Root call: one timer, one settlement, one set of owned handles. The
// caller's AbortSignal is an ADDITIONAL stop next to the absolute timer: an
// abort settles once through the same path, so every owned request/response
// is destroyed exactly as it is on a deadline. The timer is never replaced.
const ctx = { done: false, timer: null, handles: new Set(), timeoutMs, signal, onAbort: null };
const promise = new Promise((resolve, reject) => {
const settle = (error, value) => {
if (ctx.done) return;
ctx.done = true;
if (ctx.timer) { clearTimeout(ctx.timer); ctx.timer = null; }
if (ctx.onAbort) {
ctx.signal?.removeEventListener?.('abort', ctx.onAbort);
ctx.onAbort = null;
}
for (const handle of ctx.handles) {
detach(handle.res, handle.resListeners);
detach(handle.req, handle.reqListeners);
if (!handle.res?.destroyed) handle.res?.destroy();
if (!handle.req?.destroyed) handle.req?.destroy();
}
ctx.handles.clear();
if (error) reject(error);
else resolve(value);
};
ctx.timer = setTimeout(
() => settle(new Error(`Image fetch deadline exceeded (${timeoutMs} ms)`)),
timeoutMs,
);
if (typeof ctx.timer.unref === 'function') ctx.timer.unref();
if (signal) {
// Already-aborted signal: settle before any hop starts, so a turn that is
// already over opens zero requests.
if (signal.aborted) { settle(imageAbortError(signal)); return; }
ctx.onAbort = () => settle(imageAbortError(signal));
signal.addEventListener('abort', ctx.onAbort, { once: true });
}
fetchImageHop(url, _depth, ctx).then(
(value) => settle(null, value),
(error) => settle(error),
);
});
return promise;
}
function fetchImageHop(url, depth, ctx) {
return new Promise((resolve, reject) => {
if (ctx.done) { reject(new Error('Image fetch deadline exceeded')); return; }
if (ctx.signal?.aborted) { reject(imageAbortError(ctx.signal)); return; }
let parsed;
try { parsed = validateImageUrl(url); } catch (e) { reject(e); return; }
const handle = { req: null, res: null, reqListeners: [], resListeners: [] };
ctx.handles.add(handle);
let hopSettled = false;
const finish = (error, value) => {
if (hopSettled) return;
hopSettled = true;
detach(handle.res, handle.resListeners);
detach(handle.req, handle.reqListeners);
ctx.handles.delete(handle);
if (error) {
// A failed hop aborts whatever it still owns: no request/response may stay
// live (or keep streaming) after the fetch it belonged to has failed.
if (!handle.res?.destroyed) handle.res?.destroy();
if (!handle.req?.destroyed) handle.req?.destroy();
reject(error);
} else {
resolve(value);
}
};
const on = (emitter, list, event, fn) => {
emitter.on(event, fn);
list.push([event, fn]);
};
const mod = parsed.protocol === 'https:' ? https : http;
const req = mod.get(parsed.href, { timeout: ctx.timeoutMs ?? IMAGE_FETCH_TIMEOUT_MS, headers: { 'Accept': 'image/*' }, lookup: safeLookup }, (res) => {
handle.res = res;
if (ctx.done) { res.destroy(); return; }
if (ctx.signal?.aborted) { finish(imageAbortError(ctx.signal)); return; }
if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) {
// Destroy the redirect body instead of draining it: an endless 3xx response
// must not keep a socket (or its bytes) alive after we have moved on.
detach(res, handle.resListeners);
ctx.handles.delete(handle);
handle.res = null;
res.destroy();
// The hop must not start a request the caller has already cancelled.
if (ctx.signal?.aborted) { finish(imageAbortError(ctx.signal)); return; }
fetchImageUrl(res.headers.location, ctx.timeoutMs, depth + 1, ctx).then(
(v) => finish(null, v), (e) => finish(e),
);
return;
}
if (res.statusCode !== 200) {
res.resume();
finish(new Error(`Image fetch HTTP ${res.statusCode}`));
return;
}
const mime = (res.headers['content-type'] || '').split(';')[0].trim().toLowerCase();
if (!MIME_OK.has(mime)) {
res.resume();
finish(new Error(`Unsupported image type: ${mime}`));
return;
}
const chunks = [];
let size = 0;
on(res, handle.resListeners, 'data', (d) => {
if (hopSettled || ctx.done) return;
size += d.length;
if (size > MAX_SIZE) {
res.destroy();
finish(new Error(`Image exceeds ${MAX_SIZE} bytes`));
} else chunks.push(d);
});
on(res, handle.resListeners, 'end', () => {
if (hopSettled || ctx.done) return;
finish(null, { base64_data: Buffer.concat(chunks).toString('base64'), mime_type: mime });
});
on(res, handle.resListeners, 'error', (e) => finish(e));
});
handle.req = req;
on(req, handle.reqListeners, 'error', (e) => finish(e));
on(req, handle.reqListeners, 'timeout', () => finish(new Error('Image fetch timeout')));
});
}
// Test seam: extractImages resolves remote images through this indirection so
// cancellation can be exercised without the network. Production always points
// at fetchImageUrl; tests restore it in `finally`.
let fetchImageUrlImpl = fetchImageUrl;
export function __setFetchImageUrlForTest(fn) {
fetchImageUrlImpl = typeof fn === 'function' ? fn : fetchImageUrl;
}
/**
* Flatten content blocks to {text, images}.
*
* `signal` and `beforeFetch` are the Cascade turn's cancellation surface:
* `beforeFetch` (the caller's budget assertion) runs before every remote fetch
* and every pushImage, and an aborted `signal` stops extraction the same way.
* A terminal abort/deadline error is rethrown; an ordinary per-image failure
* (bad MIME, 404, SSRF, size, decode) still logs and continues, unchanged.
*/
export async function extractImages(contentBlocks, { signal = null, beforeFetch = null } = {}) {
if (!Array.isArray(contentBlocks)) return { text: String(contentBlocks ?? ''), images: [] };
const checkpoint = () => {
beforeFetch?.();
if (signal?.aborted) throw imageAbortError(signal);
};
let text = '';
const images = [];
for (const block of contentBlocks) {
if (!block || typeof block === 'string') { text += block || ''; continue; }
if (block.type === 'text') {
text += block.text || '';
} else if (block.type === 'document') {
const src = block.source || {};
const mime = (src.media_type || '').toLowerCase();
if (mime === 'application/pdf' && src.data) {
const pdf = tryExtractPdf(src.data);
if (pdf?.text) {
text += `\n[PDF Document — ${pdf.pageCount} page(s)]\n${pdf.text}\n`;
log.info(`PDF extracted: ${pdf.pageCount} pages, ${pdf.text.length} chars`);
} else {
text += '\n[PDF Document — no extractable text (scanned/image-only PDF)]\n';
}
}
} else if (block.type === 'image') {
const src = block.source || {};
const mime = (src.media_type || '').toLowerCase();
if (mime === 'application/pdf' && src.data) {
const pdf = tryExtractPdf(src.data);
if (pdf?.text) {
text += `\n[PDF Document — ${pdf.pageCount} page(s)]\n${pdf.text}\n`;
}
continue;
}
try {
if ((src.type === 'base64' || !src.type) && src.data) {
checkpoint();
await pushImage(images, { base64_data: src.data, mime_type: src.media_type || 'image/png' });
} else if (src.type === 'url' && src.url) {
checkpoint();
const fetched = await fetchImageUrlImpl(src.url, IMAGE_FETCH_TIMEOUT_MS, 0, null, signal);
// A turn can be spent while the fetch runs: do not attach an image
// the caller can no longer use.
checkpoint();
await pushImage(images, fetched);
}
} catch (e) {
if (isTerminalImageError(e, signal)) throw e;
log.warn(`Image extraction failed: ${e.message}`);
}
} else if (block.type === 'image_url') {
const url = block.image_url?.url || '';
try {
if (url.startsWith('data:')) {
// PDF-as-data-URL: let the model "see" it via text extraction
// rather than treating it as an unsupported image type.
const lower = url.slice(0, 40).toLowerCase();
if (lower.startsWith('data:application/pdf')) {
const g = parseGenericDataUrl(url);
if (g?.base64_data) {
const pdf = tryExtractPdf(g.base64_data);
if (pdf?.text) {
text += `\n[PDF Document — ${pdf.pageCount} page(s)]\n${pdf.text}\n`;
log.info(`PDF extracted (image_url data URL): ${pdf.pageCount} pages, ${pdf.text.length} chars`);
} else {
text += '\n[PDF Document — no extractable text (scanned/image-only PDF)]\n';
}
}
continue;
}
const parsed = parseDataUrl(url);
if (parsed) {
checkpoint();
await pushImage(images, parsed);
}
} else if (/^https?:\/\//i.test(url)) {
// Schemes are case-insensitive (RFC 3986 §3.1) and `new URL()` normalizes
// them, so a case-sensitive prefix test only SKIPPED the block: uppercase
// HTTPS never fetched, uppercase HTTP never reached the https-only gate —
// both vanished silently. The plaintext rejection still comes from
// validateImageUrl, unchanged.
checkpoint();
const fetched = await fetchImageUrlImpl(url, IMAGE_FETCH_TIMEOUT_MS, 0, null, signal);
checkpoint();
await pushImage(images, fetched);
}
} catch (e) {
if (isTerminalImageError(e, signal)) throw e;
log.warn(`Image fetch failed: ${e.message}`);
}
} else if (block.type === 'file' || block.type === 'input_file') {
// OpenAI PDF input: { type:'file', file:{ filename, file_data:'data:application/pdf;base64,...' } }
// or file_id (uploaded via Files API — we can't fetch, so ignore).
const file = block.file || {};
const dataUrl = file.file_data || file.url || '';
if (dataUrl.startsWith('data:application/pdf')) {
const g = parseGenericDataUrl(dataUrl);
if (g?.base64_data) {
const pdf = tryExtractPdf(g.base64_data);
if (pdf?.text) {
const label = file.filename ? ` "${file.filename}"` : '';
text += `\n[PDF Document${label} — ${pdf.pageCount} page(s)]\n${pdf.text}\n`;
log.info(`PDF extracted (OpenAI file block): ${pdf.pageCount} pages, ${pdf.text.length} chars`);
} else {
text += '\n[PDF Document — no extractable text (scanned/image-only PDF)]\n';
}
}
} else if (dataUrl && !file.file_id) {
log.warn(`Unsupported file block data URL: ${dataUrl.slice(0, 40)}...`);
} else if (file.file_id) {
log.warn(`File block references file_id=${file.file_id} — upload API not supported, skipping`);
}
}
}
// M7: the last block's fetch/push can outlive the abort — every checkpoint
// above runs before an await, so an abort landing inside the final
// pushImage (or during trailing non-image blocks) used to return the
// cancelled turn's images as if extraction had finished.
if (signal?.aborted) throw imageAbortError(signal);
return { text, images };
}