T-HUB/apps/api/src/submissions/mime.ts
glass beadian 599a68cc41 feat: anonymous group submission boxes — public intake, CC licence, moderation
WS0 of the video-producer plan: every group can open a public,
accountless upload box (random token, rotatable, disable switch).
Submitters tick the Creative Commons qualifier; credit name and a
licensing contact are optional free text — contact is visible only in
the moderation surface and never copied onto assets. Intake sniffs
magic bytes (mp4/webm/jpeg/png), caps video 200 MiB / photo 50 MiB,
rate-limits 5/hour per salted IP hash (hash never rendered), dedups by
sha256, caps 5 pending per box, and quarantines everything as pending.
Group managers approve (video → video library via the register+probe
path, photo → group asset) or reject with a note; every step audited.
Migration 035 (RLS + submission_box_lookup/submission_rate_count
definer fns). 4 unit + 4 live-DB tests.
2026-10-14 13:32:00 +00:00

29 lines
1.2 KiB
TypeScript

import { createHash } from "node:crypto";
/** Content-type decided by magic bytes, never by the client's header. */
export type SubmissionKind = "video" | "photo";
export interface SniffResult {
kind: SubmissionKind;
mime: string;
}
const SNIFF_WINDOWS = 32;
/** mp4 (ftyp at offset 4), webm/mkv (EBML), jpeg (FFD8FF), png. */
export function sniffMedia(bytes: Buffer): SniffResult | null {
if (bytes.length < SNIFF_WINDOWS) return null;
if (bytes.subarray(4, 8).toString("latin1") === "ftyp") {
const brand = bytes.subarray(8, 12).toString("latin1");
return { kind: "video", mime: brand.startsWith("avi") ? "video/x-msvideo" : "video/mp4" };
}
if (bytes.subarray(0, 4).toString("hex") === "1a45dfa3") return { kind: "video", mime: "video/webm" };
if (bytes[0] === 0xff && bytes[1] === 0xd8 && bytes[2] === 0xff) return { kind: "photo", mime: "image/jpeg" };
if (bytes.subarray(0, 8).toString("hex") === "89504e470d0a1a0a") return { kind: "photo", mime: "image/png" };
return null;
}
/** sha256(ip + daily salt): rate-limit key only — never stored as IP, never rendered. */
export function ipHash(ip: string, salt: string, day: string): string {
return createHash("sha256").update(`${salt}:${day}:${ip}`).digest("base64url");
}