fix(proxy): wire games root into asset routes
This commit is contained in:
@@ -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,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 }>();
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -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(
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user