From 91d4b6c9999f172ccd796c4277b9436be421e154 Mon Sep 17 00:00:00 2001 From: hypercross Date: Sun, 9 Aug 2026 21:31:23 +0800 Subject: [PATCH] feat(proxy): serve local game assets from games root --- apps/proxy/src/env.test.ts | 3 +- apps/proxy/src/env.ts | 3 ++ apps/proxy/src/index.ts | 8 +++ apps/proxy/src/routes/asset.test.ts | 28 ++++++++++ apps/proxy/src/routes/asset.ts | 34 ++++++++---- apps/proxy/src/routes/resolveAsset.ts | 76 +++++++++++++++++++++++++++ apps/proxy/src/routes/trace.ts | 30 +++++++---- 7 files changed, 162 insertions(+), 20 deletions(-) create mode 100644 apps/proxy/src/routes/resolveAsset.ts diff --git a/apps/proxy/src/env.test.ts b/apps/proxy/src/env.test.ts index b3925ea..4aa6472 100644 --- a/apps/proxy/src/env.test.ts +++ b/apps/proxy/src/env.test.ts @@ -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', }); }); diff --git a/apps/proxy/src/env.ts b/apps/proxy/src/env.ts index 3819f02..18ca6bd 100644 --- a/apps/proxy/src/env.ts +++ b/apps/proxy/src/env.ts @@ -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; @@ -11,6 +13,7 @@ export type Env = z.infer; export interface Bindings { STEAM_API_KEY?: string; PORT: number; + GAMES_ROOT?: string; } export function loadEnv(source: NodeJS.ProcessEnv = process.env): Env { diff --git a/apps/proxy/src/index.ts b/apps/proxy/src/index.ts index 686d134..8e13c64 100644 --- a/apps/proxy/src/index.ts +++ b/apps/proxy/src/index.ts @@ -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()); diff --git a/apps/proxy/src/routes/asset.test.ts b/apps/proxy/src/routes/asset.test.ts index 7295f02..be9f76c 100644 --- a/apps/proxy/src/routes/asset.test.ts +++ b/apps/proxy/src/routes/asset.test.ts @@ -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); + }); }); \ No newline at end of file diff --git a/apps/proxy/src/routes/asset.ts b/apps/proxy/src/routes/asset.ts index a48ac93..28ef895 100644 --- a/apps/proxy/src/routes/asset.ts +++ b/apps/proxy/src/routes/asset.ts @@ -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); } diff --git a/apps/proxy/src/routes/resolveAsset.ts b/apps/proxy/src/routes/resolveAsset.ts new file mode 100644 index 0000000..7a055ba --- /dev/null +++ b/apps/proxy/src/routes/resolveAsset.ts @@ -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 = { + '.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 { + // 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' } }; +} \ No newline at end of file diff --git a/apps/proxy/src/routes/trace.ts b/apps/proxy/src/routes/trace.ts index ada1c02..319f2d0 100644 --- a/apps/proxy/src/routes/trace.ts +++ b/apps/proxy/src/routes/trace.ts @@ -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); }