fix(proxy): wire games root into asset routes

This commit is contained in:
2026-08-09 21:44:07 +08:00
parent 91d4b6c999
commit 4d14120d54
5 changed files with 35 additions and 19 deletions
+12
View File
@@ -0,0 +1,12 @@
/**
* Runtime config for the proxy. `GAMES_ROOT` is set at startup (from env or a
* default) and read by the asset/trace routes to serve local game assets. It's
* a module-level value because the node-server adapter injects its own
* `HttpBindings` as the Hono env, not custom bindings.
*/
export let GAMES_ROOT: string | undefined;
/** Set the games root at startup. */
export function setGamesRoot(root: string | undefined): void {
GAMES_ROOT = root;
}
+4 -2
View File
@@ -4,6 +4,7 @@ import { Hono } from 'hono';
import { fileURLToPath } from 'node:url'; import { fileURLToPath } from 'node:url';
import * as path from 'node:path'; import * as path from 'node:path';
import { loadEnv, type Bindings } from './env.js'; import { loadEnv, type Bindings } from './env.js';
import { setGamesRoot } from './config.js';
import asset from './routes/asset.js'; import asset from './routes/asset.js';
import health from './routes/health.js'; import health from './routes/health.js';
import items from './routes/items.js'; import items from './routes/items.js';
@@ -14,9 +15,10 @@ const env = loadEnv();
// Default GAMES_ROOT to the repo's `games` folder so local game assets work // Default GAMES_ROOT to the repo's `games` folder so local game assets work
// without configuration; override via env. // without configuration; override via env.
const gamesRoot = setGamesRoot(
env.GAMES_ROOT ?? env.GAMES_ROOT ??
path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', '..', '..', 'games'); path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', '..', '..', 'games'),
);
const app = new Hono<{ Bindings: Bindings }>(); const app = new Hono<{ Bindings: Bindings }>();
+13 -11
View File
@@ -1,11 +1,20 @@
import { afterEach, describe, expect, it, vi } from 'vitest'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import * as path from 'node:path'; import * as path from 'node:path';
import { mkdtempSync, writeFileSync } from 'node:fs'; import { mkdtempSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os'; import { tmpdir } from 'node:os';
import asset from './asset.js'; import asset from './asset.js';
import { setGamesRoot } from '../config.js';
let dir: string;
beforeEach(() => {
dir = mkdtempSync(path.join(tmpdir(), 'games-'));
setGamesRoot(dir);
});
afterEach(() => { afterEach(() => {
vi.unstubAllGlobals(); vi.unstubAllGlobals();
setGamesRoot(undefined);
}); });
describe('asset route', () => { describe('asset route', () => {
@@ -45,27 +54,20 @@ describe('asset route', () => {
}); });
it('serves a local game asset from GAMES_ROOT', async () => { it('serves a local game asset from GAMES_ROOT', async () => {
const dir = mkdtempSync(path.join(tmpdir(), 'games-'));
writeFileSync(path.join(dir, 'cards.png'), new Uint8Array([1, 2, 3])); writeFileSync(path.join(dir, 'cards.png'), new Uint8Array([1, 2, 3]));
const res = await asset.request('/?url=cards.png', {}, { GAMES_ROOT: dir }); const res = await asset.request('/?url=cards.png');
expect(res.status).toBe(200); expect(res.status).toBe(200);
expect(res.headers.get('content-type')).toBe('image/png'); expect(res.headers.get('content-type')).toBe('image/png');
expect(await res.arrayBuffer()).toEqual(new Uint8Array([1, 2, 3]).buffer); expect(await res.arrayBuffer()).toEqual(new Uint8Array([1, 2, 3]).buffer);
}); });
it('returns 404 for a missing local asset', async () => { it('returns 404 for a missing local asset', async () => {
const dir = mkdtempSync(path.join(tmpdir(), 'games-')); const res = await asset.request('/?url=nope.png');
const res = await asset.request('/?url=nope.png', {}, { GAMES_ROOT: dir });
expect(res.status).toBe(404); expect(res.status).toBe(404);
}); });
it('rejects path traversal outside GAMES_ROOT', async () => { it('rejects path traversal outside GAMES_ROOT', async () => {
const dir = mkdtempSync(path.join(tmpdir(), 'games-')); const res = await asset.request('/?url=..%2F..%2Fetc%2Fpasswd');
const res = await asset.request(
'/?url=..%2F..%2Fetc%2Fpasswd',
{},
{ GAMES_ROOT: dir },
);
expect(res.status).toBe(400); expect(res.status).toBe(400);
}); });
}); });
+3 -3
View File
@@ -1,8 +1,8 @@
import { Hono } from 'hono'; import { Hono } from 'hono';
import type { Bindings } from '../env.js'; import { GAMES_ROOT } from '../config.js';
import { resolveAsset } from './resolveAsset.js'; import { resolveAsset } from './resolveAsset.js';
const app = new Hono<{ Bindings: Bindings }>(); const app = new Hono();
/** /**
* Fetch an external asset (texture, model, etc.) and stream it back to the * Fetch an external asset (texture, model, etc.) and stream it back to the
@@ -20,7 +20,7 @@ app.get('/', async (c) => {
return c.json({ error: 'Missing url query param' }, 400); return c.json({ error: 'Missing url query param' }, 400);
} }
const result = await resolveAsset(raw, c.env?.GAMES_ROOT); const result = await resolveAsset(raw, GAMES_ROOT);
if (!result.ok) { if (!result.ok) {
// A missing local file vs an invalid reference. // A missing local file vs an invalid reference.
return c.json( return c.json(
+3 -3
View File
@@ -5,7 +5,7 @@ import { createRequire } from 'module';
import { traceRequestSchema, type TraceResult } from '@tts/shared'; import { traceRequestSchema, type TraceResult } from '@tts/shared';
import { offsetShape, parseSvgShape } from './svgShape.js'; import { offsetShape, parseSvgShape } from './svgShape.js';
import { resolveAsset } from './resolveAsset.js'; import { resolveAsset } from './resolveAsset.js';
import type { Bindings } from '../env.js'; import { GAMES_ROOT } from '../config.js';
// vtracer is a CommonJS package; load it via require so the wasm initializes // vtracer is a CommonJS package; load it via require so the wasm initializes
// with the correct `__dirname`. // with the correct `__dirname`.
@@ -19,7 +19,7 @@ const vtracer = require('@visioncortex/vtracer') as {
): string; ): string;
}; };
const app = new Hono<{ Bindings: Bindings }>(); const app = new Hono();
/** /**
* Trace an image into a vector shape and return it BSON-encoded. * Trace an image into a vector shape and return it BSON-encoded.
@@ -43,7 +43,7 @@ app.get('/', async (c) => {
// Resolve the image: a relative path is a local game asset under GAMES_ROOT; // Resolve the image: a relative path is a local game asset under GAMES_ROOT;
// otherwise it must be an http(s) URL. // otherwise it must be an http(s) URL.
const resolved = await resolveAsset(url, c.env?.GAMES_ROOT); const resolved = await resolveAsset(url, GAMES_ROOT);
if (!resolved.ok) { if (!resolved.ok) {
return c.json({ error: 'Invalid or missing image url' }, 400); return c.json({ error: 'Invalid or missing image url' }, 400);
} }