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.
This commit is contained in:
parent
1069f20c54
commit
599a68cc41
8 changed files with 793 additions and 1 deletions
29
apps/api/src/submissions/mime.ts
Normal file
29
apps/api/src/submissions/mime.ts
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
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");
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue