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' } }; }