feat(proxy): serve local game assets from games root

This commit is contained in:
2026-08-09 21:31:23 +08:00
parent 0503ac84a7
commit 91d4b6c999
7 changed files with 162 additions and 20 deletions
+2 -1
View File
@@ -3,9 +3,10 @@ import { loadEnv } from './env.js';
describe('loadEnv', () => {
it('parses a valid environment', () => {
expect(loadEnv({ STEAM_API_KEY: 'key', PORT: '4000' })).toEqual({
expect(loadEnv({ STEAM_API_KEY: 'key', PORT: '4000', GAMES_ROOT: '/games' })).toEqual({
STEAM_API_KEY: 'key',
PORT: 4000,
GAMES_ROOT: '/games',
});
});
+3
View File
@@ -3,6 +3,8 @@ import { z } from 'zod';
const envSchema = z.object({
STEAM_API_KEY: z.string().min(1).optional(),
PORT: z.coerce.number().int().min(1).max(65535).default(3000),
/** Absolute path to the games root, for serving local game assets. */
GAMES_ROOT: z.string().min(1).optional(),
});
export type Env = z.infer<typeof envSchema>;
@@ -11,6 +13,7 @@ export type Env = z.infer<typeof envSchema>;
export interface Bindings {
STEAM_API_KEY?: string;
PORT: number;
GAMES_ROOT?: string;
}
export function loadEnv(source: NodeJS.ProcessEnv = process.env): Env {
+8
View File
@@ -1,6 +1,8 @@
import { serve } from '@hono/node-server';
import { cors } from 'hono/cors';
import { Hono } from 'hono';
import { fileURLToPath } from 'node:url';
import * as path from 'node:path';
import { loadEnv, type Bindings } from './env.js';
import asset from './routes/asset.js';
import health from './routes/health.js';
@@ -10,6 +12,12 @@ import trace from './routes/trace.js';
const env = loadEnv();
// Default GAMES_ROOT to the repo's `games` folder so local game assets work
// without configuration; override via env.
const gamesRoot =
env.GAMES_ROOT ??
path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', '..', '..', 'games');
const app = new Hono<{ Bindings: Bindings }>();
app.use('*', cors());
+28
View File
@@ -1,4 +1,7 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import * as path from 'node:path';
import { mkdtempSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import asset from './asset.js';
afterEach(() => {
@@ -40,4 +43,29 @@ describe('asset route', () => {
const res = await asset.request('/?url=https%3A%2F%2Fexample.com%2Fa.png');
expect(res.status).toBe(502);
});
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]));
const res = await asset.request('/?url=cards.png', {}, { GAMES_ROOT: dir });
expect(res.status).toBe(200);
expect(res.headers.get('content-type')).toBe('image/png');
expect(await res.arrayBuffer()).toEqual(new Uint8Array([1, 2, 3]).buffer);
});
it('returns 404 for a missing local asset', async () => {
const dir = mkdtempSync(path.join(tmpdir(), 'games-'));
const res = await asset.request('/?url=nope.png', {}, { GAMES_ROOT: dir });
expect(res.status).toBe(404);
});
it('rejects path traversal outside GAMES_ROOT', async () => {
const dir = mkdtempSync(path.join(tmpdir(), 'games-'));
const res = await asset.request(
'/?url=..%2F..%2Fetc%2Fpasswd',
{},
{ GAMES_ROOT: dir },
);
expect(res.status).toBe(400);
});
});
+24 -10
View File
@@ -1,12 +1,18 @@
import { Hono } from 'hono';
import type { Bindings } from '../env.js';
import { resolveAsset } from './resolveAsset.js';
const app = new Hono();
const app = new Hono<{ Bindings: Bindings }>();
/**
* Fetch an external asset (texture, model, etc.) and stream it back to the
* client. Workshop asset hosts (steamusercontent.com, etc.) often omit CORS
* headers, which would block `TextureLoader` / `GLTFLoader` in the browser.
* Routing through the proxy makes those assets loadable.
*
* A relative URL (no scheme) is treated as a game asset path relative to the
* `GAMES_ROOT` directory and served from disk, so bgm parts can reference
* local files (e.g. `poker/parts/assets/cards.png`).
*/
app.get('/', async (c) => {
const raw = c.req.query('url');
@@ -14,20 +20,28 @@ app.get('/', async (c) => {
return c.json({ error: 'Missing url query param' }, 400);
}
let url: URL;
try {
url = new URL(raw);
} catch {
return c.json({ error: 'Invalid url query param' }, 400);
const result = await resolveAsset(raw, c.env?.GAMES_ROOT);
if (!result.ok) {
// A missing local file vs an invalid reference.
return c.json(
{ error: result.reason === 'not-found' ? 'Asset not found' : 'Invalid url query param' },
result.reason === 'not-found' ? 404 : 400,
);
}
// Only allow http(s) to avoid SSRF via file://, etc.
if (url.protocol !== 'http:' && url.protocol !== 'https:') {
return c.json({ error: 'Only http(s) urls are allowed' }, 400);
const asset = result.asset;
if (asset.stream) {
return new Response(asset.stream as unknown as BodyInit, {
headers: {
'Content-Type': asset.contentType,
'Cache-Control': 'public, max-age=86400',
},
});
}
let res: Response;
try {
res = await fetch(url);
res = await fetch(asset.url!);
} catch (err) {
return c.json({ error: `Failed to fetch asset: ${String(err)}` }, 502);
}
+76
View File
@@ -0,0 +1,76 @@
import { createReadStream } from 'node:fs';
import { stat } from 'node:fs/promises';
import * as path from 'node:path';
/** Content-type by extension for local game assets. */
const CONTENT_TYPES: Record<string, string> = {
'.png': 'image/png',
'.jpg': 'image/jpeg',
'.jpeg': 'image/jpeg',
'.gif': 'image/gif',
'.webp': 'image/webp',
'.svg': 'image/svg+xml',
'.gltf': 'model/gltf+json',
'.glb': 'model/gltf-binary',
'.obj': 'text/plain',
'.fbx': 'application/octet-stream',
};
export interface ResolvedAsset {
/** The http(s) URL to fetch, when the asset is remote. */
url?: string;
/** A readable stream of a local file, when the asset is on disk. */
stream?: NodeJS.ReadableStream;
contentType: string;
}
export type ResolveResult =
| { ok: true; asset: ResolvedAsset }
| { ok: false; reason: 'invalid' | 'not-found' };
/**
* Resolve an asset reference to either a remote http(s) URL or a local file
* under `gamesRoot`. A reference with a scheme is remote; otherwise it's a
* game asset path relative to `gamesRoot`. Returns `{ ok: false }` with a
* reason when the reference is invalid (non-http scheme, path traversal) or
* the file is missing.
*/
export async function resolveAsset(
raw: string,
gamesRoot: string | undefined,
): Promise<ResolveResult> {
// A relative path (no scheme) is a local game asset.
if (!/^[a-z][a-z0-9+.-]*:/i.test(raw)) {
if (!gamesRoot) return { ok: false, reason: 'invalid' };
const rel = raw.replace(/^\/+/, '');
const abs = path.resolve(gamesRoot, rel);
if (!abs.startsWith(path.resolve(gamesRoot) + path.sep)) {
return { ok: false, reason: 'invalid' };
}
try {
await stat(abs);
} catch {
return { ok: false, reason: 'not-found' };
}
const ext = path.extname(abs).toLowerCase();
return {
ok: true,
asset: {
stream: createReadStream(abs),
contentType: CONTENT_TYPES[ext] ?? 'application/octet-stream',
},
};
}
let url: URL;
try {
url = new URL(raw);
} catch {
return { ok: false, reason: 'invalid' };
}
// Only allow http(s) to avoid SSRF via file://, etc.
if (url.protocol !== 'http:' && url.protocol !== 'https:') {
return { ok: false, reason: 'invalid' };
}
return { ok: true, asset: { url: raw, contentType: 'application/octet-stream' } };
}
+21 -9
View File
@@ -4,6 +4,8 @@ import sharp from 'sharp';
import { createRequire } from 'module';
import { traceRequestSchema, type TraceResult } from '@tts/shared';
import { offsetShape, parseSvgShape } from './svgShape.js';
import { resolveAsset } from './resolveAsset.js';
import type { Bindings } from '../env.js';
// vtracer is a CommonJS package; load it via require so the wasm initializes
// with the correct `__dirname`.
@@ -17,7 +19,7 @@ const vtracer = require('@visioncortex/vtracer') as {
): string;
};
const app = new Hono();
const app = new Hono<{ Bindings: Bindings }>();
/**
* Trace an image into a vector shape and return it BSON-encoded.
@@ -39,19 +41,29 @@ app.get('/', async (c) => {
const { url, mode, threshold, format, simplify, maxColors, offset } =
parsed.data;
// Only allow http(s) to avoid SSRF via file://, etc.
const parsedUrl = new URL(url);
if (parsedUrl.protocol !== 'http:' && parsedUrl.protocol !== 'https:') {
return c.json({ error: 'Only http(s) urls are allowed' }, 400);
// Resolve the image: a relative path is a local game asset under GAMES_ROOT;
// otherwise it must be an http(s) URL.
const resolved = await resolveAsset(url, c.env?.GAMES_ROOT);
if (!resolved.ok) {
return c.json({ error: 'Invalid or missing image url' }, 400);
}
const asset = resolved.asset;
let image: Buffer;
try {
const res = await fetch(url);
if (!res.ok) {
return c.json({ error: `Image responded ${res.status}` }, 502);
if (asset.stream) {
const chunks: Buffer[] = [];
for await (const chunk of asset.stream) {
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
}
image = Buffer.concat(chunks);
} else {
const res = await fetch(asset.url!);
if (!res.ok) {
return c.json({ error: `Image responded ${res.status}` }, 502);
}
image = Buffer.from(await res.arrayBuffer());
}
image = Buffer.from(await res.arrayBuffer());
} catch (err) {
return c.json({ error: `Failed to fetch image: ${String(err)}` }, 502);
}