import { defineEventHandler, getQuery, setResponseHeader, setResponseStatus } from 'h3'; import { configureProxyDispatcher } from '../../utils/proxy-dispatcher'; // Route external fetches through the system proxy when set. Same // rationale as image-search.ts — the user's machine routes outbound // HTTPS through a local proxy and Node's native fetch ignores it // without an explicit dispatcher. configureProxyDispatcher(); /** * GET /api/ai/image-proxy?url= * * Proxies an external image fetch through the dev server so the * browser-side image loader doesn't have to reach the upstream host * directly. Without this proxy: * - Browser image loader does `img.src = ''`. * - Browser fetch ignores HTTP_PROXY env vars (only the Node * server-side fetch routes through `EnvHttpProxyAgent`). * - On a machine that requires a proxy to reach openverse.org * (clash / mihomo / corporate gateway), the browser fetch * ECONNREFUSEDs and the canvas shows the placeholder visual * even though the search-pipeline successfully fetched a URL * via the server-side proxy. * * The image-search endpoint already routes through the proxy. By * also routing the IMAGE BYTES through this server endpoint, we * guarantee the canvas can paint the photo regardless of the * browser's network configuration. The redirect happens at the * search-pipeline level — `mapOpenverseResult` rewrites the * `thumbUrl` to point at this endpoint. * * Allow-list: only `https://...` URLs from a small set of known * image providers (openverse, wikimedia commons, flickr's static * CDN that openverse references). Refusing arbitrary URLs prevents * the dev server from being used as an open proxy. */ const ALLOWED_HOSTS = new Set([ 'api.openverse.org', 'commons.wikimedia.org', 'upload.wikimedia.org', 'live.staticflickr.com', 'farm1.staticflickr.com', 'farm2.staticflickr.com', 'farm3.staticflickr.com', 'farm4.staticflickr.com', 'farm5.staticflickr.com', 'farm6.staticflickr.com', 'farm7.staticflickr.com', 'farm8.staticflickr.com', 'farm9.staticflickr.com', ]); export default defineEventHandler(async (event) => { const query = getQuery(event); const rawUrl = typeof query.url === 'string' ? query.url : ''; if (!rawUrl) { setResponseStatus(event, 400); return { error: 'Missing required query param: url' }; } let parsed: URL; try { parsed = new URL(rawUrl); } catch { setResponseStatus(event, 400); return { error: 'Invalid url' }; } if (parsed.protocol !== 'https:') { setResponseStatus(event, 400); return { error: 'Only https:// URLs are proxied' }; } if (!ALLOWED_HOSTS.has(parsed.host)) { setResponseStatus(event, 403); return { error: `Host not in allow-list: ${parsed.host}` }; } // Single AbortController + timeout for the entire request lifecycle // (DNS + TLS + headers + body). The earlier version cleared the // timeout in a finally{} right after `await fetch()`, but fetch() // resolves as soon as headers arrive — the body read happens below // in `reader.read()` and was unprotected. An upstream that drip- // feeds bytes (or stops sending mid-stream) would leave the dev // server hanging on `reader.read()` forever. Keep the timeout // armed until the body is fully drained or we bail; clear it in // the outer finally{} so it never leaks regardless of return path. const controller = new AbortController(); const timeoutId = setTimeout(() => controller.abort(), 15000); try { const upstream = await fetch(parsed.toString(), { signal: controller.signal, // Some image hosts (openverse thumbs) gate on Accept; an empty // Accept makes them happiest. headers: { Accept: 'image/*,*/*;q=0.5' }, }); if (!upstream.ok) { setResponseStatus(event, upstream.status); return { error: `Upstream returned ${upstream.status}` }; } // Cap upstream body size. The pre-cap version did // `await upstream.arrayBuffer()` which buffers the entire body // in memory with no limit — a malicious or accidentally large // upstream (Wikimedia Commons originals can be 100MB+) would // happily exhaust the dev server's heap. Cap at 16 MiB (well // above any reasonable thumbnail; high-res 4K JPEGs land around // 5–8 MiB) and abort the read if we exceed it. const declared = upstream.headers.get('content-length'); if (declared) { const declaredBytes = Number.parseInt(declared, 10); if (Number.isFinite(declaredBytes) && declaredBytes > MAX_BYTES) { controller.abort(); setResponseStatus(event, 413); return { error: `Upstream Content-Length ${declaredBytes} exceeds ${MAX_BYTES} cap`, }; } } if (!upstream.body) { setResponseStatus(event, 502); return { error: 'Upstream returned no body' }; } // Stream the body and accumulate with a hard cap. Any chunk // that pushes total bytes past MAX_BYTES aborts the upstream // fetch and returns 413 — no further bytes are buffered. The // overall AbortController timeout (set above) covers a slow / // stalled body too: if 15 s pass without a complete body the // controller fires and `reader.read()` rejects. const reader = upstream.body.getReader(); const chunks: Uint8Array[] = []; let total = 0; try { while (true) { const { done, value } = await reader.read(); if (done) break; if (value) { total += value.byteLength; if (total > MAX_BYTES) { controller.abort(); try { await reader.cancel(); } catch { /* ignore cancel errors */ } setResponseStatus(event, 413); return { error: `Upstream body exceeds ${MAX_BYTES}-byte cap (got ${total}+ so far)`, }; } chunks.push(value); } } } finally { try { reader.releaseLock(); } catch { /* lock already released */ } } const contentType = upstream.headers.get('content-type') ?? 'image/jpeg'; const cacheControl = upstream.headers.get('cache-control') ?? 'public, max-age=86400'; setResponseHeader(event, 'Content-Type', contentType); setResponseHeader(event, 'Cache-Control', cacheControl); // Ensure browser canvas fetches don't get blocked by CORS // mismatches when the canvas later reads pixels (image-loader // sets crossOrigin='anonymous'). Same-origin avoids the issue. setResponseHeader(event, 'Access-Control-Allow-Origin', '*'); return Buffer.concat(chunks.map((c) => Buffer.from(c.buffer, c.byteOffset, c.byteLength))); } catch (err) { // AbortError raised by the timeout (or by an explicit // controller.abort() inside the body loop) lands here. Report a // 504 for the timeout case so callers can distinguish it from a // generic upstream failure. const isAbort = err instanceof Error && err.name === 'AbortError'; setResponseStatus(event, isAbort ? 504 : 502); return { error: isAbort ? 'Upstream fetch timed out' : 'Upstream fetch failed', detail: err instanceof Error ? err.message : String(err), }; } finally { clearTimeout(timeoutId); } }); /** * Maximum bytes accepted from any single upstream image. 16 MiB is * well above what a thumbnail or even a high-res 4K JPEG needs (~5– * 8 MiB). Tuning past this risks a single proxied fetch eating * meaningful chunks of the dev server's heap; tuning below it would * reject legitimate Wikimedia Commons photos. */ const MAX_BYTES = 16 * 1024 * 1024;