Files
tts-workshop/apps/proxy/src/routes/resolveAsset.ts
T

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