# Conflicts: # .github/workflows/rust-multiplatform.yml # README.md # crates/openpencil-shell-core/src/lib.rs # crates/openpencil-shell-native/examples/basic_window.rs # crates/openpencil-shell-native/src/lib.rs
196 lines
7.6 KiB
TypeScript
196 lines
7.6 KiB
TypeScript
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=<encoded-openverse-thumb-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 = '<openverse-url>'`.
|
||
* - 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;
|