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.
29 lines
1.2 KiB
TypeScript
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");
|
|
}
|