feat(proxy): serve local game assets from games root
This commit is contained in:
@@ -3,9 +3,10 @@ import { loadEnv } from './env.js';
|
|||||||
|
|
||||||
describe('loadEnv', () => {
|
describe('loadEnv', () => {
|
||||||
it('parses a valid environment', () => {
|
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',
|
STEAM_API_KEY: 'key',
|
||||||
PORT: 4000,
|
PORT: 4000,
|
||||||
|
GAMES_ROOT: '/games',
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ import { z } from 'zod';
|
|||||||
const envSchema = z.object({
|
const envSchema = z.object({
|
||||||
STEAM_API_KEY: z.string().min(1).optional(),
|
STEAM_API_KEY: z.string().min(1).optional(),
|
||||||
PORT: z.coerce.number().int().min(1).max(65535).default(3000),
|
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>;
|
export type Env = z.infer<typeof envSchema>;
|
||||||
@@ -11,6 +13,7 @@ export type Env = z.infer<typeof envSchema>;
|
|||||||
export interface Bindings {
|
export interface Bindings {
|
||||||
STEAM_API_KEY?: string;
|
STEAM_API_KEY?: string;
|
||||||
PORT: number;
|
PORT: number;
|
||||||
|
GAMES_ROOT?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function loadEnv(source: NodeJS.ProcessEnv = process.env): Env {
|
export function loadEnv(source: NodeJS.ProcessEnv = process.env): Env {
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import { serve } from '@hono/node-server';
|
import { serve } from '@hono/node-server';
|
||||||
import { cors } from 'hono/cors';
|
import { cors } from 'hono/cors';
|
||||||
import { Hono } from 'hono';
|
import { Hono } from 'hono';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
import * as path from 'node:path';
|
||||||
import { loadEnv, type Bindings } from './env.js';
|
import { loadEnv, type Bindings } from './env.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';
|
||||||
@@ -10,6 +12,12 @@ import trace from './routes/trace.js';
|
|||||||
|
|
||||||
const env = loadEnv();
|
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 }>();
|
const app = new Hono<{ Bindings: Bindings }>();
|
||||||
|
|
||||||
app.use('*', cors());
|
app.use('*', cors());
|
||||||
|
|||||||
@@ -1,4 +1,7 @@
|
|||||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
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';
|
import asset from './asset.js';
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
@@ -40,4 +43,29 @@ describe('asset route', () => {
|
|||||||
const res = await asset.request('/?url=https%3A%2F%2Fexample.com%2Fa.png');
|
const res = await asset.request('/?url=https%3A%2F%2Fexample.com%2Fa.png');
|
||||||
expect(res.status).toBe(502);
|
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);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
@@ -1,12 +1,18 @@
|
|||||||
import { Hono } from 'hono';
|
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
|
* Fetch an external asset (texture, model, etc.) and stream it back to the
|
||||||
* client. Workshop asset hosts (steamusercontent.com, etc.) often omit CORS
|
* client. Workshop asset hosts (steamusercontent.com, etc.) often omit CORS
|
||||||
* headers, which would block `TextureLoader` / `GLTFLoader` in the browser.
|
* headers, which would block `TextureLoader` / `GLTFLoader` in the browser.
|
||||||
* Routing through the proxy makes those assets loadable.
|
* 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) => {
|
app.get('/', async (c) => {
|
||||||
const raw = c.req.query('url');
|
const raw = c.req.query('url');
|
||||||
@@ -14,20 +20,28 @@ app.get('/', async (c) => {
|
|||||||
return c.json({ error: 'Missing url query param' }, 400);
|
return c.json({ error: 'Missing url query param' }, 400);
|
||||||
}
|
}
|
||||||
|
|
||||||
let url: URL;
|
const result = await resolveAsset(raw, c.env?.GAMES_ROOT);
|
||||||
try {
|
if (!result.ok) {
|
||||||
url = new URL(raw);
|
// A missing local file vs an invalid reference.
|
||||||
} catch {
|
return c.json(
|
||||||
return c.json({ error: 'Invalid url query param' }, 400);
|
{ 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.
|
const asset = result.asset;
|
||||||
if (url.protocol !== 'http:' && url.protocol !== 'https:') {
|
|
||||||
return c.json({ error: 'Only http(s) urls are allowed' }, 400);
|
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;
|
let res: Response;
|
||||||
try {
|
try {
|
||||||
res = await fetch(url);
|
res = await fetch(asset.url!);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
return c.json({ error: `Failed to fetch asset: ${String(err)}` }, 502);
|
return c.json({ error: `Failed to fetch asset: ${String(err)}` }, 502);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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' } };
|
||||||
|
}
|
||||||
@@ -4,6 +4,8 @@ import sharp from 'sharp';
|
|||||||
import { createRequire } from 'module';
|
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 type { Bindings } from '../env.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`.
|
||||||
@@ -17,7 +19,7 @@ const vtracer = require('@visioncortex/vtracer') as {
|
|||||||
): string;
|
): string;
|
||||||
};
|
};
|
||||||
|
|
||||||
const app = new Hono();
|
const app = new Hono<{ Bindings: Bindings }>();
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Trace an image into a vector shape and return it BSON-encoded.
|
* 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 } =
|
const { url, mode, threshold, format, simplify, maxColors, offset } =
|
||||||
parsed.data;
|
parsed.data;
|
||||||
|
|
||||||
// Only allow http(s) to avoid SSRF via file://, etc.
|
// Resolve the image: a relative path is a local game asset under GAMES_ROOT;
|
||||||
const parsedUrl = new URL(url);
|
// otherwise it must be an http(s) URL.
|
||||||
if (parsedUrl.protocol !== 'http:' && parsedUrl.protocol !== 'https:') {
|
const resolved = await resolveAsset(url, c.env?.GAMES_ROOT);
|
||||||
return c.json({ error: 'Only http(s) urls are allowed' }, 400);
|
if (!resolved.ok) {
|
||||||
|
return c.json({ error: 'Invalid or missing image url' }, 400);
|
||||||
}
|
}
|
||||||
|
const asset = resolved.asset;
|
||||||
|
|
||||||
let image: Buffer;
|
let image: Buffer;
|
||||||
try {
|
try {
|
||||||
const res = await fetch(url);
|
if (asset.stream) {
|
||||||
if (!res.ok) {
|
const chunks: Buffer[] = [];
|
||||||
return c.json({ error: `Image responded ${res.status}` }, 502);
|
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) {
|
} catch (err) {
|
||||||
return c.json({ error: `Failed to fetch image: ${String(err)}` }, 502);
|
return c.json({ error: `Failed to fetch image: ${String(err)}` }, 502);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user