import { rm } from "node:fs/promises"; import { join, resolve, sep } from "node:path"; import sharp from "sharp"; import { ASSETS_DIR, MAX_INPUT_PIXELS } from "../config"; import { invalid, notFound, unprocessable } from "../errors"; import { ffprobe } from "../probe/ffprobe"; /** * The asset store — and the place docs/SECURITY.md's central invariant is enforced. * * A caller never supplies a path. They supply an ID we issued, and this module is the only thing * that turns an ID into a path. Everything downstream receives a path this module produced. */ export type AssetKind = "image" | "video" | "audio"; export type Asset = { id: string; bytes: number; sha256: string; kind: AssetKind; format: string; width?: number; height?: number; durationSeconds?: number; createdAt: string; }; /** `as_` plus 32 lowercase hex. Anchored, fixed length, no separators — nothing here can traverse. */ const ID_PATTERN = /^as_[0-9a-f]{32}$/; export function newAssetId(): string { return `as_${crypto.randomUUID().replaceAll("-", "")}`; } /** * Resolve an ID to its directory, or throw. * * Two checks, deliberately, because they answer different questions: * * 1. Does the ID match the pattern we issue? — about the caller's INTENT * 2. Is the resolved path still inside the store? — about what actually HAPPENED * * The second is not redundant. It is the one that still holds if the pattern is ever loosened, if * ASSETS_DIR contains a symlink, or if some future refactor builds the path differently. Cheap, and * the failure it prevents is "read any file on the machine". */ function assetDir(id: string): string { if (!ID_PATTERN.test(id)) { throw invalid(`Not a valid asset id: ${JSON.stringify(id.slice(0, 64))}`, "id"); } const dir = resolve(join(ASSETS_DIR, id)); if (dir !== join(ASSETS_DIR, id) || !dir.startsWith(ASSETS_DIR + sep)) { throw invalid("Not a valid asset id", "id"); } return dir; } export const contentPath = (id: string): string => join(assetDir(id), "content"); const metaPath = (id: string): string => join(assetDir(id), "meta.json"); export async function readAsset(id: string): Promise { const file = Bun.file(metaPath(id)); if (!(await file.exists())) throw notFound(); return (await file.json()) as Asset; } export async function assetExists(id: string): Promise { return Bun.file(metaPath(id)).exists(); } export async function deleteAsset(id: string): Promise { await rm(assetDir(id), { recursive: true, force: true }); } /** * Identify a file by INSPECTING IT, never from its name or the Content-Type a browser attached. * * Order matters: ffprobe will happily report a PNG as a video stream in an `image2` container, so * sharp is asked first and images are settled before ffprobe is consulted at all. */ async function identify(path: string): Promise> { try { const meta = await sharp(path, { limitInputPixels: MAX_INPUT_PIXELS, // `unlimited` is what gets past libheif's iref-reference cap, which iPhone HEICs carrying // burst or depth data routinely exceed — sharp reports it as "corrupt header", which it is not. // It loosens libvips' own buffer guards, so `limitInputPixels` is passed EXPLICITLY alongside: // verified that the pixel limit still rejects a decompression bomb with unlimited set. unlimited: true, }).metadata(); if (meta.format) { return { kind: "image", format: meta.format, width: meta.width, height: meta.height }; } } catch { // Not an image sharp can read. Fall through — it may well be a video. } const probe = await ffprobe(path).catch(() => null); const streams = probe?.streams ?? []; const video = streams.find((s) => s.codec_type === "video"); const audio = streams.find((s) => s.codec_type === "audio"); const duration = probe?.format?.duration ? Number(probe.format.duration) : undefined; const format = probe?.format?.format_name?.split(",")[0] ?? "unknown"; if (video) { return { kind: "video", format, width: typeof video.width === "number" ? video.width : undefined, height: typeof video.height === "number" ? video.height : undefined, durationSeconds: duration, }; } if (audio) return { kind: "audio", format, durationSeconds: duration }; throw unprocessable( "That file could not be read as an image, video or audio file. It may be corrupt, or it may not be media at all.", ); } /** * Store bytes and return the asset. * * The ID is issued here, so a caller can never influence where a file lands. Identification runs * AFTER the write because both sharp and ffprobe want a path — a file that turns out not to be * media is written and then removed rather than being buffered twice. */ export async function putAsset(bytes: ArrayBuffer | Uint8Array): Promise { const id = newAssetId(); const path = contentPath(id); await Bun.write(path, bytes); try { const identified = await identify(path); const buffer = bytes instanceof Uint8Array ? bytes : new Uint8Array(bytes); const asset: Asset = { id, bytes: buffer.byteLength, sha256: new Bun.CryptoHasher("sha256").update(buffer).digest("hex"), createdAt: new Date().toISOString(), ...identified, }; await Bun.write(metaPath(id), JSON.stringify(asset, null, 2)); return asset; } catch (error) { // Do not leave the bytes of something we refused sitting in the store. await deleteAsset(id).catch(() => {}); throw error; } }