From c6d47dd689d6818739ea18f2bafc7dac728dfafc Mon Sep 17 00:00:00 2001 From: Fini Date: Tue, 5 May 2026 12:22:56 +0800 Subject: [PATCH] fix(canvas): asset resolver passes through same-origin /api routes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Image #42 logs showed every image fetch URL came out wrapped twice: http://localhost:3000/api/local-asset?path=%2Fapi%2Fai%2Fimage-proxy%3Furl%3D... The image-search pipeline correctly returned \`/api/ai/image-proxy?url=...\` thumbUrls (so browser fetches go through the dev server, which can reach openverse via the system proxy). But \`isLocalAssetPath\` only excluded \`data:\`/\`https?:\`/ \`blob:\` from local-asset bridging — anything else, including absolute paths starting with \`/api/\`, was treated as a file-system asset and re-wrapped through \`/api/local-asset?path=\`. That bridge handler then 404s because the encoded path \`/api/ai/image-proxy?...\` isn't a real file. Net effect: every search-found image stayed at the placeholder visual even though the search succeeded. Add a same-origin route carve-out: /^\/(?:api|_)\// Paths under those prefixes are runtime endpoints (Nitro \`/api/*\`, Vite \`/_/*\`), not file system assets, so they pass through the resolver as-is. \`/assets/hero.png\` and similar absolute file-style paths still go through the local-asset bridge. New regression test covers /api/ai/image-proxy, /api/local-asset, and /_/* paths returning false from isLocalAssetPath, and verifies ordinary /assets/... paths still return true. --- .../utils/__tests__/document-assets.test.ts | 18 ++++++++++++++++++ apps/web/src/utils/document-assets.ts | 14 +++++++++++++- 2 files changed, 31 insertions(+), 1 deletion(-) diff --git a/apps/web/src/utils/__tests__/document-assets.test.ts b/apps/web/src/utils/__tests__/document-assets.test.ts index 50f70b703..b5a601044 100644 --- a/apps/web/src/utils/__tests__/document-assets.test.ts +++ b/apps/web/src/utils/__tests__/document-assets.test.ts @@ -110,6 +110,24 @@ describe('document asset paths', () => { expect(isLocalAssetPath('data:image/png;base64,abc')).toBe(false); }); + it('treats same-origin server routes as external (no local-asset wrap)', () => { + // Regression: the image-search pipeline returns thumbUrls as + // `/api/ai/image-proxy?url=...`. Without this carve-out the + // resolver wraps that path through the local-asset bridge and + // produces a broken double-wrapped URL like + // `/api/local-asset?path=%2Fapi%2Fai%2Fimage-proxy%3Furl%3D...` + // which 404s because the local-asset handler can't dispatch on + // a route that isn't a real file path. `/api/...` and `/_/...` + // are runtime endpoints, not file system assets. + expect(isLocalAssetPath('/api/ai/image-proxy?url=https%3A%2F%2Fa.com%2Fb.jpg')).toBe(false); + expect(isLocalAssetPath('/api/local-asset?path=foo')).toBe(false); + expect(isLocalAssetPath('/_/static/icon.svg')).toBe(false); + // Non-API absolute paths are still treated as local file paths + // — `/assets/hero.png` could legitimately be a unix-style asset + // path embedded in a .pen file. + expect(isLocalAssetPath('/assets/hero.png')).toBe(true); + }); + it('bridges local assets through the app origin when running over http', () => { const originalWindow = globalThis.window; Object.defineProperty(globalThis, 'window', { diff --git a/apps/web/src/utils/document-assets.ts b/apps/web/src/utils/document-assets.ts index 0b1c49bba..39dd61f94 100644 --- a/apps/web/src/utils/document-assets.ts +++ b/apps/web/src/utils/document-assets.ts @@ -2,6 +2,15 @@ const EXTERNAL_ASSET_RE = /^(?:data:|https?:|blob:)/i; const FILE_URL_RE = /^file:\/\//i; const HTTP_PROTOCOL_RE = /^https?:$/i; const LOCAL_IMAGE_EXT_RE = /\.(?:png|jpe?g|gif|webp|bmp|svg|avif)$/i; +// Same-origin server routes the dev server / Nitro serves directly. +// These are NOT file-system asset paths — they're runtime endpoints +// (`/api/ai/image-proxy?url=...`, `/api/local-asset?path=...`, etc). +// Without this carve-out the asset resolver treats them as local +// file paths and double-wraps them through `/api/local-asset`, +// producing a broken `/api/local-asset?path=%2Fapi%2Fai%2Fimage-proxy%3F...` +// URL that only the local-asset handler can dispatch on, which then +// 404s because `/api/ai/image-proxy?...` isn't a real file path. +const SAME_ORIGIN_ROUTE_RE = /^\/(?:api|_)\//; export interface RuntimeAssetSource { sourcePath: string | null; @@ -12,7 +21,10 @@ export interface RuntimeAssetSource { export function isLocalAssetPath(assetPath: string | null | undefined): boolean { if (!assetPath) return false; - return !EXTERNAL_ASSET_RE.test(assetPath.trim()); + const trimmed = assetPath.trim(); + if (EXTERNAL_ASSET_RE.test(trimmed)) return false; + if (SAME_ORIGIN_ROUTE_RE.test(trimmed)) return false; + return true; } export function resolveRuntimeAssetSource(