SPB Git

Repository Public

storage.ts 4.9 KB 91 lines typescript

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
Files main
Menu

Theme