fix(canvas): asset resolver passes through same-origin /api routes

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.
This commit is contained in:
Fini 2026-05-05 12:22:56 +08:00
parent a42b145223
commit c6d47dd689
2 changed files with 31 additions and 1 deletions

View file

@ -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', {

View file

@ -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(