feat(proxy): serve local game assets from games root
This commit is contained in:
@@ -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' } };
|
||||
}
|
||||
Reference in New Issue
Block a user