Repository Public
1import { createReadStream } from "node:fs";2import { copyFile, mkdir, readFile, rename, stat, unlink, writeFile } from "node:fs/promises";3import { createHash } from "node:crypto";4import path from "node:path";5import { env } from "./env";6import { hmac, safeEqual, sha256 } from "./hash";7import { sql, j } from "./db";89/** Content-addressed asset storage. One driver today (filesystem); S3 would implement the same interface. */10export interface AssetStorage {11 put(bytes: Buffer, ext: string): Promise<{ sha: string; bytes: number }>;12 putFile(file: string, ext: string): Promise<{ sha: string; bytes: number }>;13 pathOf(sha: string, ext: string): string;14 read(sha: string, ext: string): Promise<Buffer>;15 exists(sha: string, ext: string): Promise<boolean>;16 remove(sha: string, ext: string): Promise<void>;17 getSignedUrl(sha: string, ext: string, ttlSeconds?: number): string;18}1920const SAFE = /^[a-f0-9]{64}$/, EXT = /^[a-z0-9]{2,5}$/;21export const MIME: Record<string, string> = { mp4: "video/mp4", jpg: "image/jpeg", png: "image/png", webp: "image/webp", mp3: "audio/mpeg", wav: "audio/wav", vtt: "text/vtt; charset=utf-8", json: "application/json" };2223function fsStorage(root: string): AssetStorage {24 const pathOf = (sha: string, ext: string) => {25 if (!SAFE.test(sha) || !EXT.test(ext)) throw new Error("Invalid asset reference");26 return path.join(root, "objects", sha.slice(0, 2), sha.slice(2, 4), `${sha}.${ext}`);27 };28 async function place(tmp: string, sha: string, ext: string) {29 const dest = pathOf(sha, ext);30 await mkdir(path.dirname(dest), { recursive: true });31 await rename(tmp, dest);32 return dest;33 }34 return {35 pathOf,36 async put(bytes, ext) {37 const sha = sha256(bytes);38 await mkdir(path.join(root, "tmp"), { recursive: true });39 const tmp = path.join(root, "tmp", `${sha}.${process.pid}.${Date.now()}`);40 await writeFile(tmp, bytes);41 await place(tmp, sha, ext);42 return { sha, bytes: bytes.length };43 },44 async putFile(file, ext) {45 const h = createHash("sha256");46 await new Promise<void>((res, rej) => createReadStream(file).on("data", (d) => h.update(d)).on("end", () => res()).on("error", rej));47 const sha = h.digest("hex");48 await mkdir(path.join(root, "tmp"), { recursive: true });49 const tmp = path.join(root, "tmp", `${sha}.${process.pid}.${Date.now()}`);50 await copyFile(file, tmp);51 await place(tmp, sha, ext);52 return { sha, bytes: (await stat(pathOf(sha, ext))).size };53 },54 read: (sha, ext) => readFile(pathOf(sha, ext)),55 exists: (sha, ext) => stat(pathOf(sha, ext)).then(() => true, () => false),56 remove: (sha, ext) => unlink(pathOf(sha, ext)).catch(() => {}),57 getSignedUrl(sha, ext, ttl = 900) {58 const exp = Math.floor(Date.now() / 1000) + ttl;59 return `/media/${sha}.${ext}?exp=${exp}&sig=${hmac(env.appSecret, `${sha}.${ext}.${exp}`)}`;60 },61 };62}63let store: AssetStorage | null = null;64export const storage = () => store ?? (store = fsStorage(env.assetRoot));65export const tmpDir = () => path.join(env.assetRoot, "work");6667export function verifySignedUrl(sha: string, ext: string, exp: string | null, sig: string | null) {68 if (!exp || !sig || Number(exp) < Date.now() / 1000) return false;69 return safeEqual(sig, hmac(env.appSecret, `${sha}.${ext}.${exp}`));70}7172export type AssetKind = "keyframe" | "shot_video" | "shot_audio" | "narration" | "master" | "delivery_low" | "poster" | "captions" | "thumb" | "intermediate";73export interface AssetRow { id: string; sha256: string; ext: string; mime: string; bytes: number; kind: AssetKind; width: number | null; height: number | null; duration: number | null; project_id: string | null; public: boolean; meta: Record<string, unknown> }7475export async function saveAsset(a: { bytes?: Buffer; file?: string; ext: string; kind: AssetKind; projectId?: string | null; width?: number; height?: number; duration?: number; meta?: Record<string, unknown> }): Promise<AssetRow> {76 const put = a.bytes ? await storage().put(a.bytes, a.ext) : await storage().putFile(a.file!, a.ext);77 const [row] = await sql<AssetRow[]>`78 INSERT INTO assets (sha256, ext, mime, bytes, kind, width, height, duration, project_id, meta)79 VALUES (${put.sha}, ${a.ext}, ${MIME[a.ext] ?? "application/octet-stream"}, ${put.bytes}, ${a.kind}, ${a.width ?? null}, ${a.height ?? null}, ${a.duration ?? null}, ${a.projectId ?? null}, ${j(a.meta ?? {})})80 RETURNING *`;81 return row;82}83export async function getAsset(id: string | null | undefined): Promise<AssetRow | null> {84 if (!id) return null;85 const [row] = await sql<AssetRow[]>`SELECT * FROM assets WHERE id = ${id}`;86 return row ?? null;87}88export const assetPath = (a: Pick<AssetRow, "sha256" | "ext">) => storage().pathOf(a.sha256, a.ext);89/** Private (studio) URL: signed, expiring. */90export const studioUrl = (a: Pick<AssetRow, "sha256" | "ext"> | null | undefined) => (a ? storage().getSignedUrl(a.sha256, a.ext, 6 * 3600) : null);91