76 lines
2.3 KiB
TypeScript
76 lines
2.3 KiB
TypeScript
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' } };
|
|
} |