From 599a68cc4185c0f2b2a34389aed82fc95c07bbaf Mon Sep 17 00:00:00 2001 From: glass beadian Date: Wed, 14 Oct 2026 13:32:00 +0000 Subject: [PATCH] =?UTF-8?q?feat:=20anonymous=20group=20submission=20boxes?= =?UTF-8?q?=20=E2=80=94=20public=20intake,=20CC=20licence,=20moderation?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- apps/api/migrations/035_submissions.sql | 66 ++++ apps/api/src/app.module.ts | 3 +- apps/api/src/submissions/mime.ts | 29 ++ .../src/submissions/submissions.controller.ts | 100 ++++++ .../submissions.integration.test.ts | 174 +++++++++ .../api/src/submissions/submissions.module.ts | 29 ++ .../submissions/submissions.service.test.ts | 64 ++++ .../src/submissions/submissions.service.ts | 329 ++++++++++++++++++ 8 files changed, 793 insertions(+), 1 deletion(-) create mode 100644 apps/api/migrations/035_submissions.sql create mode 100644 apps/api/src/submissions/mime.ts create mode 100644 apps/api/src/submissions/submissions.controller.ts create mode 100644 apps/api/src/submissions/submissions.integration.test.ts create mode 100644 apps/api/src/submissions/submissions.module.ts create mode 100644 apps/api/src/submissions/submissions.service.test.ts create mode 100644 apps/api/src/submissions/submissions.service.ts diff --git a/apps/api/migrations/035_submissions.sql b/apps/api/migrations/035_submissions.sql new file mode 100644 index 0000000..4f301da --- /dev/null +++ b/apps/api/migrations/035_submissions.sql @@ -0,0 +1,66 @@ +-- Anonymous group submission boxes (owner request): a public, accountless +-- intake for footage/photos. Submitters tick a Creative Commons qualifier; +-- credit name and a licensing contact are OPTIONAL free text. Submissions are +-- quarantined (pending) until a workspace group manager approves them. + +CREATE TABLE submission_boxes ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + workspace_id uuid NOT NULL REFERENCES workspaces (id) ON DELETE CASCADE, + group_id uuid NOT NULL, + public_token text NOT NULL UNIQUE, + license_default text NOT NULL DEFAULT 'CC BY-SA 4.0', + enabled boolean NOT NULL DEFAULT true, + created_at timestamptz NOT NULL DEFAULT now(), + UNIQUE (group_id, workspace_id), + UNIQUE (id, workspace_id), + FOREIGN KEY (group_id, workspace_id) REFERENCES groups (id, workspace_id) ON DELETE CASCADE +); + +CREATE POLICY tenant_isolation ON submission_boxes + USING (workspace_id = NULLIF(current_setting('app.workspace_id', true), '')::uuid); + +CREATE TABLE submissions ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + workspace_id uuid NOT NULL REFERENCES workspaces (id) ON DELETE CASCADE, + box_id uuid NOT NULL, + group_id uuid NOT NULL, + kind text NOT NULL CHECK (kind IN ('video', 'photo')), + storage_key text NOT NULL, + mime text NOT NULL, + size_bytes bigint NOT NULL CHECK (size_bytes > 0), + sha256 text NOT NULL, + license text NOT NULL, + credit_name text CHECK (credit_name IS NULL OR (btrim(credit_name) <> '' AND length(credit_name) <= 100)), + contact text CHECK (contact IS NULL OR (btrim(contact) <> '' AND length(contact) <= 200)), + state text NOT NULL DEFAULT 'pending' CHECK (state IN ('pending', 'approved', 'rejected')), + rejection_note text CHECK (rejection_note IS NULL OR length(rejection_note) <= 500), + submitter_ip_hash text, + created_at timestamptz NOT NULL DEFAULT now(), + decided_at timestamptz, + decided_by uuid REFERENCES users (id) ON DELETE SET NULL, + FOREIGN KEY (box_id, workspace_id) REFERENCES submission_boxes (id, workspace_id) ON DELETE CASCADE +); + +CREATE POLICY tenant_isolation ON submissions + USING (workspace_id = NULLIF(current_setting('app.workspace_id', true), '')::uuid); + +CREATE INDEX submissions_box_state ON submissions (box_id, state, created_at); +CREATE INDEX submissions_rate ON submissions (submitter_ip_hash, created_at) WHERE submitter_ip_hash IS NOT NULL; + +-- The public submit route is unauthenticated: the box lookup runs before any +-- tenant context exists, so it goes through a definer-rights function (the +-- audit_append / oidc_refresh_lookup pattern). +CREATE FUNCTION submission_box_lookup(p_token text) RETURNS TABLE ( + id uuid, workspace_id uuid, group_id uuid, license_default text, enabled boolean +) LANGUAGE sql SECURITY DEFINER SET search_path = public AS $$ + SELECT id, workspace_id, group_id, license_default, enabled + FROM submission_boxes + WHERE public_token = p_token +$$; + +-- Rate limiting needs the workspace of an ip-hash without a tenant context. +CREATE FUNCTION submission_rate_count(p_ip_hash text, p_since timestamptz) RETURNS integer +LANGUAGE sql SECURITY DEFINER SET search_path = public AS $$ + SELECT count(*)::integer FROM submissions + WHERE submitter_ip_hash = p_ip_hash AND created_at > p_since +$$; diff --git a/apps/api/src/app.module.ts b/apps/api/src/app.module.ts index d86f5b7..2436d91 100644 --- a/apps/api/src/app.module.ts +++ b/apps/api/src/app.module.ts @@ -39,9 +39,10 @@ import { SnapshotService } from "./analytics/snapshot.service"; import { CollectivesModule } from "./collectives/collectives.module.js"; import { MatrixSsoModule } from "./matrix-sso/matrix-sso.module.js"; import { OidcModule } from "./oidc/oidc.module.js"; +import { SubmissionsModule } from "./submissions/submissions.module.js"; @Module({ - imports: [HealthModule, LiveMonitorModule, MediaControlModule, ProductionModule, RealtimeModule, LiveChatModule, LiveModule, VirtualChannelsModule, ChatgovModule, IntakeModule, GroupFilesModule, SettingsModule, CollectivesModule, MatrixSsoModule, OidcModule], + imports: [HealthModule, LiveMonitorModule, MediaControlModule, ProductionModule, RealtimeModule, LiveChatModule, LiveModule, VirtualChannelsModule, ChatgovModule, IntakeModule, GroupFilesModule, SettingsModule, CollectivesModule, MatrixSsoModule, OidcModule, SubmissionsModule], controllers: [ DemoController, PublishingController, diff --git a/apps/api/src/submissions/mime.ts b/apps/api/src/submissions/mime.ts new file mode 100644 index 0000000..c766c7c --- /dev/null +++ b/apps/api/src/submissions/mime.ts @@ -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"); +} diff --git a/apps/api/src/submissions/submissions.controller.ts b/apps/api/src/submissions/submissions.controller.ts new file mode 100644 index 0000000..657bd56 --- /dev/null +++ b/apps/api/src/submissions/submissions.controller.ts @@ -0,0 +1,100 @@ +import { BadRequestException, Body, Controller, Get, Inject, Param, Post, Query, Req, UploadedFile, UseGuards, UseInterceptors } from "@nestjs/common"; +import { FileInterceptor } from "@nestjs/platform-express"; +import type { Request } from "express"; +import { RbacGuard, RequirePermission, type AuthenticatedRequest } from "../rbac/rbac.guard.js"; +import { requireSession } from "../http/validation.js"; +import { SubmissionsService, SUBMISSION_MAX_VIDEO_BYTES, type SubmissionBoxInfo, type SubmissionReceiveResult, type SubmissionRecord } from "./submissions.service.js"; + +/** Last hop of X-Forwarded-For was appended by our own edge; earlier entries are client-asserted. */ +function clientIp(request: Request): string | null { + const xff = request.headers["x-forwarded-for"]; + if (typeof xff === "string" && xff.trim() !== "") { + const hops = xff.split(",").map((hop) => hop.trim()).filter((hop) => hop !== ""); + const last = hops[hops.length - 1]; + if (last !== undefined) return last; + } + return request.ip ?? null; +} + +interface UploadFile { + buffer: Buffer; + mimetype?: string; +} + +/** + * Public, unauthenticated surface of the anonymous submission boxes. + * Only box metadata and intake live here; moderation is the admin controller. + */ +@Controller("public/submit") +export class PublicSubmissionsController { + constructor(@Inject(SubmissionsService) private readonly submissions: SubmissionsService) {} + + @Get(":token") + async box(@Param("token") token: string): Promise { + return this.submissions.boxForToken(token); + } + + @Post(":token") + @UseInterceptors(FileInterceptor("file", { limits: { fileSize: SUBMISSION_MAX_VIDEO_BYTES, files: 1, fields: 4, fieldSize: 1024, parts: 6 } })) + async receive( + @Param("token") token: string, + @UploadedFile() file?: UploadFile, + @Body() body?: Record, + @Req() request?: Request, + ): Promise { + if (file?.buffer === undefined || file.buffer.length === 0) throw new BadRequestException("a file is required (field: file)"); + return this.submissions.receive(token, { + bytes: file.buffer, + declaredMime: typeof file.mimetype === "string" ? file.mimetype : "", + licenseAccepted: body?.licenseAccepted === "true" || body?.licenseAccepted === true, + creditName: body?.creditName, + contact: body?.contact, + ip: request === undefined ? null : clientIp(request), + }); + } +} + +/** Workspace-manager surface: box config, moderation queue, approve/reject. */ +@Controller() +@UseGuards(RbacGuard) +export class SubmissionsAdminController { + constructor(@Inject(SubmissionsService) private readonly submissions: SubmissionsService) {} + + @Get("groups/:groupId/submission-box") + @RequirePermission("workspace.members.manage") + async box(@Req() request: Request & AuthenticatedRequest, @Param("groupId") groupId: string): Promise<{ token: string; enabled: boolean }> { + const session = requireSession(request); + return this.submissions.ensureBox(session.workspaceId, groupId, { userId: session.userId, role: session.role }); + } + + @Post("groups/:groupId/submission-box") + @RequirePermission("workspace.members.manage") + async configure(@Req() request: Request & AuthenticatedRequest, @Param("groupId") groupId: string, @Body() body: { enabled?: boolean; rotateToken?: boolean }): Promise<{ token: string; enabled: boolean }> { + const session = requireSession(request); + return this.submissions.configureBox(session.workspaceId, groupId, { enabled: body?.enabled, rotateToken: body?.rotateToken === true }, { userId: session.userId, role: session.role }); + } + + @Get("groups/:groupId/submissions") + @RequirePermission("workspace.members.manage") + async queue(@Req() request: Request & AuthenticatedRequest, @Param("groupId") groupId: string, @Query("state") state: string | undefined): Promise { + const session = requireSession(request); + const parsed = state === "approved" || state === "rejected" || state === "all" ? state : "pending"; + return this.submissions.listForGroup(session.workspaceId, groupId, parsed); + } + + @Post("submissions/:id/approve") + @RequirePermission("workspace.members.manage") + async approve(@Req() request: Request & AuthenticatedRequest, @Param("id") id: string, @Body() body: { groupId?: string }): Promise { + const session = requireSession(request); + if (typeof body?.groupId !== "string") throw new BadRequestException("groupId is required"); + return this.submissions.decide(session.workspaceId, body.groupId, id, "approved", { actorUserId: session.userId, actorRole: session.role }); + } + + @Post("submissions/:id/reject") + @RequirePermission("workspace.members.manage") + async reject(@Req() request: Request & AuthenticatedRequest, @Param("id") id: string, @Body() body: { groupId?: string; note?: string }): Promise { + const session = requireSession(request); + if (typeof body?.groupId !== "string") throw new BadRequestException("groupId is required"); + return this.submissions.decide(session.workspaceId, body.groupId, id, "rejected", { actorUserId: session.userId, actorRole: session.role, note: body.note }); + } +} diff --git a/apps/api/src/submissions/submissions.integration.test.ts b/apps/api/src/submissions/submissions.integration.test.ts new file mode 100644 index 0000000..6eb13c0 --- /dev/null +++ b/apps/api/src/submissions/submissions.integration.test.ts @@ -0,0 +1,174 @@ +import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { Pool, type PoolClient } from "pg"; +import type { SubmissionsService as SubmissionsServiceType } from "./submissions.service.js"; + +type Modules = { submissions: typeof import("./submissions.service.js") }; + +/** + * WS0 live-DB loop: box creation → public receive (stubbed storage) → + * quarantine → pending cap → dedup → rate limit → approve/reject. + * Owns its workspace in the chatgov IT database. Skips clean. + */ +const appUrl = process.env.CHATGOV_IT_DATABASE_URL; +const ownerUrl = process.env.CHATGOV_IT_SUPER_DATABASE_URL; + +const VIDEO_BYTES = Buffer.concat([Buffer.from([0, 0, 0, 24]), Buffer.from("ftypisom"), Buffer.alloc(128, 7)]); +const PHOTO_BYTES = Buffer.concat([Buffer.from([0xff, 0xd8, 0xff, 0xe0]), Buffer.alloc(128, 3)]); + +describe.skipIf(appUrl === undefined || ownerUrl === undefined)("submission boxes (live DB)", () => { + let ownerPool: Pool; + let appPool: Pool; + const suffix = `${Date.now()}${Math.floor(Math.random() * 1e6)}`; + let ws: string; + let groupId: string; + let managerId: string; + const videoUploads: Array<{ workspaceId: string; storageKey: string }> = []; + + async function inTenant(fn: (client: PoolClient) => Promise): Promise { + const client = await ownerPool.connect(); + try { + await client.query("BEGIN"); + await client.query("SELECT set_config('app.workspace_id', $1, true)", [ws]); + const result = await fn(client); + await client.query("COMMIT"); + return result; + } catch (error) { + await client.query("ROLLBACK"); + throw error; + } finally { + client.release(); + } + } + + let m: Modules; + function service(ipSalt = "it-salt"): SubmissionsServiceType { + const storedKeys: string[] = []; + return new m.submissions.SubmissionsService({ + storage: { + putContent: async (bytes: Buffer, mime: string) => { + const key = `submissions/${storedKeys.length}`; + storedKeys.push(key); + return { key, contentType: mime, sizeBytes: bytes.length, deduplicated: false }; + }, + } as never, + videoUpload: async (workspaceId: string, storageKey: string) => { + videoUploads.push({ workspaceId, storageKey }); + return { videoAsset: { id: `video-${videoUploads.length}` } }; + }, + ipSalt: () => ipSalt, + now: () => new Date("2026-10-14T12:00:00Z"), + } as never); + } + + const receive = (svc: SubmissionsService, token: string, bytes: Buffer, extra: Record = {}) => + svc.receive(token, { bytes, declaredMime: "application/octet-stream", licenseAccepted: true, ip: "203.0.113.7", ...extra }); + + beforeAll(async () => { + process.env.DATABASE_URL = appUrl; + process.env.SUPER_DATABASE_URL = ownerUrl; + m = { submissions: await import("./submissions.service.js") }; + const { pool } = await import("../db/client.js"); + ownerPool = new Pool({ connectionString: ownerUrl }); + appPool = pool; + const c = await ownerPool.connect(); + try { + ws = (await c.query("INSERT INTO workspaces (name, slug) VALUES ($1, $2) RETURNING id", ["subm", `subm-${suffix}`])).rows[0].id as string; + await c.query("SELECT set_config('app.workspace_id', $1, true)", [ws]); + groupId = (await c.query("INSERT INTO groups (workspace_id, name) VALUES ($1, $2) RETURNING id", [ws, `Frontline ${suffix}`])).rows[0].id as string; + managerId = (await c.query( + "INSERT INTO users (external_id, username, email, display_name) VALUES ($1, $2, $3, $4) RETURNING id", + [`subm:manager:${suffix}`, `subm-${suffix}`, `subm-${suffix}@example.com`, "Box Manager"], + )).rows[0].id as string; + } finally { + c.release(); + } + }, 60_000); + + afterAll(async () => { + if (ownerPool !== undefined) { + await ownerPool.query("DELETE FROM workspaces WHERE id = $1", [ws]); + await ownerPool.query("DELETE FROM users WHERE external_id LIKE $1", [`subm:%:${suffix}`]); + await ownerPool.end(); + } + await appPool?.end(); + }); + + it("box lifecycle: create → public metadata → receive quarantines → approve routes video to the library", async () => { + const svc = service(); + const box = await svc.ensureBox(ws, groupId, { userId: managerId, role: "Owner" }); + expect(box.token).toMatch(/^[A-Za-z0-9_-]{20,}$/); + expect(await svc.boxForToken(box.token)).toMatchObject({ groupId, licenseDefault: "CC BY-SA 4.0", enabled: true }); + + const first = await receive(svc, box.token, VIDEO_BYTES, { creditName: "Anon comrade", contact: "signal: @frontline" }); + expect(first.state).toBe("pending"); + const queue = await svc.listForGroup(ws, groupId, "pending"); + expect(queue).toHaveLength(1); + expect(queue[0]).toMatchObject({ kind: "video", creditName: "Anon comrade", contact: "signal: @frontline", state: "pending" }); + + const decided = await svc.decide(ws, groupId, first.submissionId, "approved", { actorUserId: managerId, actorRole: "Owner" }); + expect(decided.videoAssetId).toBe("video-1"); + expect(videoUploads[0]?.workspaceId).toBe(ws); + expect((await svc.listForGroup(ws, groupId, "pending"))).toHaveLength(0); + const audit = await inTenant(async (c) => + (await c.query("SELECT action FROM audit_log WHERE target_id = $1 ORDER BY action", [first.submissionId])).rows.map((r) => r.action)); + expect(audit).toContain("submission.received"); + expect(audit).toContain("submission.approved"); + }); + + it("photos link into group assets; double-decide and dedup are refused", async () => { + const svc = service(); + const { token } = await svc.ensureBox(ws, groupId, { userId: managerId, role: "Owner" }); + const photo = await receive(svc, token, PHOTO_BYTES); + await expect(receive(svc, token, PHOTO_BYTES)).rejects.toThrow(/already submitted/); + const decided = await svc.decide(ws, groupId, photo.submissionId, "approved", { actorUserId: managerId, actorRole: "Owner" }); + expect(decided.groupAssetId).toBeDefined(); + expect(decided.videoAssetId).toBeUndefined(); + await expect(svc.decide(ws, groupId, photo.submissionId, "approved", { actorUserId: managerId, actorRole: "Owner" })).rejects.toThrow(/already approved/); + const assets = await inTenant(async (c) => + (await c.query("SELECT asset_id FROM group_assets WHERE group_id = $1", [groupId])).rows); + expect(assets).toHaveLength(1); + }); + + it("rejection needs a note; the pending cap and the per-network rate limit hold", async () => { + const svc = service("cap-salt"); + const { token } = await svc.ensureBox(ws, groupId, { userId: managerId, role: "Owner" }); + const junk = await svc.receive(token, { bytes: Buffer.concat([VIDEO_BYTES, Buffer.from([66])]), declaredMime: "", licenseAccepted: true, ip: null }); + await expect(svc.decide(ws, groupId, junk.submissionId, "rejected", { actorUserId: managerId, actorRole: "Owner" })).rejects.toThrow(/needs a note/); + await svc.decide(ws, groupId, junk.submissionId, "rejected", { actorUserId: managerId, actorRole: "Owner", note: "out of scope" }); + + // ip=null skips the rate limit, so the fifth pending row exercises the cap alone. + for (let i = 0; i < 5; i++) await svc.receive(token, { bytes: Buffer.concat([VIDEO_BYTES, Buffer.from([i])]), declaredMime: "", licenseAccepted: true, ip: null }); + const capError = await svc.receive(token, { bytes: Buffer.concat([VIDEO_BYTES, Buffer.from([99])]), declaredMime: "", licenseAccepted: true, ip: null }).then(() => null, (error: Error) => error); + expect(capError?.message).toMatch(/box is full/); + // Drain the box so the rate-limit part starts from free capacity. + for (const pending of await svc.listForGroup(ws, groupId, "pending")) { + await svc.decide(ws, groupId, pending.id, "rejected", { actorUserId: managerId, actorRole: "Owner", note: "cap test cleanup" }); + } + + const rateSvc = service("rate-salt"); + const { token: token2 } = await rateSvc.ensureBox(ws, groupId, { userId: managerId, role: "Owner" }); + for (let i = 0; i < 5; i++) await receive(rateSvc, token2, Buffer.concat([VIDEO_BYTES, Buffer.from([200 + i])])); + const rateError = await receive(rateSvc, token2, Buffer.concat([VIDEO_BYTES, Buffer.from([250])])).then(() => null, (error: Error) => error); + expect(rateError?.message).toMatch(/too many submissions/); + // A different network is not affected by the first network's rate bucket: + // same submit through a second group's box (capacity 0 used) succeeds. + const group2 = await inTenant(async (c) => + (await c.query("INSERT INTO groups (workspace_id, name) VALUES ($1, $2) RETURNING id", [ws, `Second ${suffix}`])).rows[0].id as string); + const { token: token3 } = await rateSvc.ensureBox(ws, group2, { userId: managerId, role: "Owner" }); + const other = await rateSvc.receive(token3, { bytes: Buffer.concat([VIDEO_BYTES, Buffer.from([250])]), declaredMime: "", licenseAccepted: true, ip: "198.51.100.4" }); + expect(other.state).toBe("pending"); + }); + + it("token rotation kills the old token; disable hides the box", async () => { + const svc = service(); + const { token } = await svc.ensureBox(ws, groupId, { userId: managerId, role: "Owner" }); + const rotated = await svc.configureBox(ws, groupId, { rotateToken: true }, { userId: managerId, role: "Owner" }); + expect(rotated.token).not.toBe(token); + const oldBox = await svc.boxForToken(token).then(() => "live", (error: Error) => error.message); + expect(oldBox).toBe("submission box not found"); + expect((await svc.boxForToken(rotated.token)).enabled).toBe(true); + await svc.configureBox(ws, groupId, { enabled: false }, { userId: managerId, role: "Owner" }); + const disabled = await svc.boxForToken(rotated.token).then(() => "live", (error: Error) => error.message); + expect(disabled).toBe("submission box not found"); + }); +}); diff --git a/apps/api/src/submissions/submissions.module.ts b/apps/api/src/submissions/submissions.module.ts new file mode 100644 index 0000000..63f1dd5 --- /dev/null +++ b/apps/api/src/submissions/submissions.module.ts @@ -0,0 +1,29 @@ +import { Module } from "@nestjs/common"; +import { VideoService } from "../video/video.service.js"; +import { PublicSubmissionsController, SubmissionsAdminController } from "./submissions.controller.js"; +import { SUBMISSION_DEPS, SubmissionsService } from "./submissions.service.js"; + +/** + * WS0 (work plan 2026-10-14): anonymous group submission boxes. The public + * intake controller is deliberately guard-free; everything mutates through + * SubmissionsService, which enforces token/limits/state and RLS via + * withTenant. Video approval reuses VideoService's registration+probe path. + */ +@Module({ + controllers: [PublicSubmissionsController, SubmissionsAdminController], + providers: [ + SubmissionsService, + { + provide: SUBMISSION_DEPS, + useFactory: (video: VideoService | null) => ({ + storage: null, + videoUpload: video === null + ? null + : (workspaceId: string, storageKey: string, mimeType: string, sizeBytes: number) => video.uploadVideo(workspaceId, storageKey, mimeType, sizeBytes), + }), + inject: [VideoService], + }, + ], + exports: [SubmissionsService], +}) +export class SubmissionsModule {} diff --git a/apps/api/src/submissions/submissions.service.test.ts b/apps/api/src/submissions/submissions.service.test.ts new file mode 100644 index 0000000..d8ba4bc --- /dev/null +++ b/apps/api/src/submissions/submissions.service.test.ts @@ -0,0 +1,64 @@ +import { describe, expect, it } from "vitest"; +import { BadRequestException } from "@nestjs/common"; +import { ipHash, sniffMedia } from "./mime.js"; +import { SUBMISSION_MAX_PHOTO_BYTES, SUBMISSION_MAX_VIDEO_BYTES, SubmissionsService } from "./submissions.service.js"; + +const VIDEO_BYTES = Buffer.concat([Buffer.from([0, 0, 0, 24]), Buffer.from("ftypisom"), Buffer.alloc(64, 7)]); +const PHOTO_BYTES = Buffer.concat([Buffer.from([0xff, 0xd8, 0xff, 0xe0]), Buffer.alloc(64, 3)]); + +describe("submission mime sniffing", () => { + it("decides kind by magic bytes, not the client header", () => { + expect(sniffMedia(VIDEO_BYTES)).toEqual({ kind: "video", mime: "video/mp4" }); + expect(sniffMedia(PHOTO_BYTES)).toMatchObject({ kind: "photo", mime: "image/jpeg" }); + expect(sniffMedia(Buffer.from(""))).toBeNull(); + expect(sniffMedia(Buffer.alloc(4))).toBeNull(); + }); + + it("rate-limit hashes never contain the raw ip and rotate by day", () => { + const a = ipHash("203.0.113.9", "salt", "2026-10-14"); + expect(a).not.toContain("203.0.113.9"); + expect(a).not.toBe(ipHash("203.0.113.9", "salt", "2026-10-15")); + expect(a).not.toBe(ipHash("203.0.113.10", "salt", "2026-10-14")); + }); +}); + +function serviceWith(overrides: Partial[0]> = {}) { + const stored: Array<{ key: string; mime: string }> = []; + const service = new SubmissionsService({ + storage: { + putContent: async (bytes: Buffer, mime: string) => { + const key = `sub/${stored.length}`; + stored.push({ key, mime }); + return { key, contentType: mime, sizeBytes: bytes.length, deduplicated: false }; + }, + } as never, + videoUpload: async () => ({ videoAsset: { id: "video-1" } }), + ipSalt: () => "test-salt", + now: () => new Date("2026-10-14T12:00:00Z"), + ...overrides, + } as never); + return { service, stored }; +} + +describe("submissions service guardrails (validation before any storage)", () => { + it("refuses submissions without the licence tick, bad sniffs and size violations", async () => { + const { service } = serviceWith(); + await expect(service.receive("tokentokentokentokentokentokentoke", { bytes: VIDEO_BYTES, declaredMime: "video/mp4", licenseAccepted: false, ip: null })) + .rejects.toBeInstanceOf(BadRequestException); + const box = await service.boxForToken("tokentokentokentokentokentokentoke").catch((error) => error); + expect(box).toBeInstanceOf(Object); // unknown box → NotFound path comes from the DB layer + const huge = Buffer.concat([VIDEO_BYTES, Buffer.alloc(SUBMISSION_MAX_VIDEO_BYTES)]); + await expect(service.receive("tokentokentokentokentokentokentoke", { bytes: huge, declaredMime: "video/mp4", licenseAccepted: true, ip: null })) + .rejects.toThrow(/bytes for video/); + await expect(service.receive("tokentokentokentokentokentokentoke", { bytes: Buffer.alloc(SUBMISSION_MAX_PHOTO_BYTES + 1, 0xff).subarray(0, SUBMISSION_MAX_PHOTO_BYTES + 1), declaredMime: "image/jpeg", licenseAccepted: true, ip: null })) + .rejects.toThrow(); + }); + + it("video claims backed by non-video bytes are refused (mime confusion)", async () => { + const { service } = serviceWith(); + await expect(service.receive("tokentokentokentokentokentokentoke", { + bytes: PHOTO_BYTES, declaredMime: "video/mp4", licenseAccepted: true, ip: null, + })).rejects.toThrow(/does not match the claimed video type/); + expect(SUBMISSION_MAX_PHOTO_BYTES).toBeLessThan(SUBMISSION_MAX_VIDEO_BYTES); + }); +}); diff --git a/apps/api/src/submissions/submissions.service.ts b/apps/api/src/submissions/submissions.service.ts new file mode 100644 index 0000000..0cc0518 --- /dev/null +++ b/apps/api/src/submissions/submissions.service.ts @@ -0,0 +1,329 @@ +import { BadRequestException, ForbiddenException, Inject, Injectable, NotFoundException, Optional, ServiceUnavailableException } from "@nestjs/common"; +import { randomBytes } from "node:crypto"; +import type { PoolClient } from "pg"; +import { appendTenantAudit } from "../audit/audit.service.js"; +import { withSystem, withTenant } from "../db/client.js"; +import { S3Storage, s3ConfigFromEnv, sha256, type StorageService } from "../storage/storage.js"; +import { ipHash, sniffMedia, type SubmissionKind } from "./mime.js"; + +/** + * Anonymous group submission boxes: public, accountless intake of footage and + * photos. A submission is quarantined in `pending` until a workspace group + * manager approves it — approval of a video registers it in the video + * library (the same path as POST /video/upload metadata registration); photos + * link straight into the group's assets. credit_name/contact are optional + * free text; contact is licensing-inquiry data, never copied onto assets and + * never shown outside the moderation surface. + */ + +export const SUBMISSION_MAX_VIDEO_BYTES = 200 * 1024 * 1024; +export const SUBMISSION_MAX_PHOTO_BYTES = 50 * 1024 * 1024; +export const SUBMISSION_MAX_PENDING_PER_BOX = 5; +export const SUBMISSION_RATE_PER_HOUR = 5; + +export interface SubmissionBoxInfo { + id: string; + groupId: string; + licenseDefault: string; + enabled: boolean; +} + +export interface SubmissionRecord { + id: string; + boxId: string; + groupId: string; + kind: SubmissionKind; + mime: string; + sizeBytes: number; + sha256: string; + license: string; + creditName: string | null; + contact: string | null; + state: "pending" | "approved" | "rejected"; + rejectionNote: string | null; + createdAt: Date; +} + +export interface SubmissionReceiveResult { + submissionId: string; + state: "pending"; +} + +export interface SubmissionDeps { + storage: StorageService | null; + videoUpload: ((workspaceId: string, storageKey: string, mimeType: string, sizeBytes: number) => Promise<{ videoAsset: { id: string } }>) | null; + /** Daily-rotating rate-limit salt; defaults to SESSION_SECRET-derived. */ + ipSalt: () => string; + now: () => Date; +} + +export const SUBMISSION_DEPS = Symbol("SUBMISSION_DEPS"); + +const DEFAULT_DEPS: SubmissionDeps = { + storage: null, + videoUpload: null, + ipSalt: () => process.env.SESSION_SECRET ?? "dev-submission-salt", + now: () => new Date(), +}; + +interface BoxRow { + id: string; + workspace_id: string; + group_id: string; + license_default: string; + enabled: boolean; + public_token: string; +} + +interface SubmissionRow { + id: string; + box_id: string; + group_id: string; + kind: SubmissionKind; + storage_key: string; + mime: string; + size_bytes: string | number; + sha256: string; + license: string; + credit_name: string | null; + contact: string | null; + state: "pending" | "approved" | "rejected"; + rejection_note: string | null; + created_at: Date; +} + +function fromRow(row: SubmissionRow): SubmissionRecord { + return { + id: row.id, + boxId: row.box_id, + groupId: row.group_id, + kind: row.kind, + mime: row.mime, + sizeBytes: Number(row.size_bytes), + sha256: row.sha256, + license: row.license, + creditName: row.credit_name, + contact: row.contact, + state: row.state, + rejectionNote: row.rejection_note, + createdAt: new Date(row.created_at), + }; +} + +function optionalText(value: unknown, max: number, field: string): string | null { + if (value === undefined || value === null || value === "") return null; + if (typeof value !== "string") throw new BadRequestException(`${field} must be a string`); + const trimmed = value.trim(); + if (trimmed === "") return null; + if (trimmed.length > max) throw new BadRequestException(`${field} must be at most ${max} characters`); + return trimmed; +} + +@Injectable() +export class SubmissionsService { + private readonly deps: SubmissionDeps; + + constructor(@Optional() @Inject(SUBMISSION_DEPS) deps?: Partial) { + this.deps = { ...DEFAULT_DEPS, ...deps }; + } + + private requireStorage(): StorageService { + if (this.deps.storage === null) { + // Same lazy construction as VideoService.requireStorage: env-driven S3. + this.deps.storage = new S3Storage(s3ConfigFromEnv()); + } + return this.deps.storage; + } + + /** Public: box metadata for the submit page. */ + async boxForToken(token: string): Promise { + const box = await this.findBoxByToken(token); + if (box === undefined || !box.enabled) throw new NotFoundException("submission box not found"); + return { id: box.id, groupId: box.group_id, licenseDefault: box.license_default, enabled: box.enabled }; + } + + private async findBoxByToken(token: string): Promise { + if (!/^[A-Za-z0-9_-]{20,}$/.test(token)) return undefined; + return (await withSystem((client) => client.query("SELECT * FROM submission_box_lookup($1)", [token]))).rows[0]; + } + + /** Public: receive one submission. Cheap validation fails before any I/O. */ + async receive(token: string, input: { + bytes: Buffer; declaredMime: string; licenseAccepted: boolean; + creditName?: unknown; contact?: unknown; + ip: string | null; + }): Promise { + if (input.licenseAccepted !== true) throw new BadRequestException("the creative commons licence box must be ticked"); + const sniffed = sniffMedia(input.bytes); + if (sniffed === null) throw new BadRequestException("unrecognised file type — expected mp4/webm video or jpeg/png photo"); + const max = sniffed.kind === "video" ? SUBMISSION_MAX_VIDEO_BYTES : SUBMISSION_MAX_PHOTO_BYTES; + if (input.bytes.length === 0 || input.bytes.length > max) { + throw new BadRequestException(`file must be 1 byte to ${max} bytes for ${sniffed.kind}`); + } + if (typeof input.declaredMime === "string" && input.declaredMime.startsWith("video/") && sniffed.kind !== "video") { + throw new BadRequestException("file content does not match the claimed video type"); + } + const box = await this.findBoxByToken(token); + if (box === undefined || !box.enabled) throw new NotFoundException("submission box not found"); + if (this.deps.videoUpload === null && sniffed.kind === "video") { + throw new ServiceUnavailableException("video intake is not configured"); + } + + const digest = sha256(input.bytes); + const ipHashValue = input.ip === null ? null : ipHash(input.ip, this.deps.ipSalt(), this.deps.now().toISOString().slice(0, 10)); + + // Global rate limit (definer fn — cross-workspace by design, IP-hash only). + if (ipHashValue !== null) { + const rate = (await withSystem((client) => + client.query<{ count: number }>("SELECT submission_rate_count($1, $2) AS count", [ipHashValue, new Date(this.deps.now().getTime() - 3_600_000)]))).rows[0]; + if (Number(rate?.count ?? 0) >= SUBMISSION_RATE_PER_HOUR) { + throw new ForbiddenException("too many submissions from this network — try again later"); + } + } + + const storage = this.requireStorage(); + const stored = await storage.putContent(input.bytes, sniffed.mime).catch(() => { + throw new ServiceUnavailableException("storage unavailable — try again later"); + }); + + const submissionId = await withTenant(box.workspace_id, async (client) => { + const pending = (await client.query<{ count: number }>( + "SELECT count(*)::integer AS count FROM submissions WHERE box_id = $1 AND state = 'pending'", [box.id])).rows[0]; + if (Number(pending?.count ?? 0) >= SUBMISSION_MAX_PENDING_PER_BOX) { + throw new ForbiddenException("this submission box is full — the collective will review earlier submissions first"); + } + const duplicate = (await client.query<{ id: string }>( + "SELECT id FROM submissions WHERE box_id = $1 AND sha256 = $2 AND state <> 'rejected' LIMIT 1", [box.id, digest])).rows[0]; + if (duplicate !== undefined) throw new BadRequestException("this exact file was already submitted"); + const inserted = await client.query<{ id: string }>( + `INSERT INTO submissions (workspace_id, box_id, group_id, kind, storage_key, mime, size_bytes, sha256, license, credit_name, contact, submitter_ip_hash) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12) RETURNING id`, + [box.workspace_id, box.id, box.group_id, sniffed.kind, stored.key, sniffed.mime, stored.sizeBytes, digest, + box.license_default, optionalText(input.creditName, 100, "creditName"), optionalText(input.contact, 200, "contact"), ipHashValue]); + const insertedId = inserted.rows[0]?.id; + if (insertedId === undefined) throw new Error("submission insert returned no id"); + const id = String(insertedId); + await appendTenantAudit(box.workspace_id, { + actorUserId: null, + actorRole: "Anonymous", + action: "submission.received", + targetType: "submission", + targetId: id, + diff: { kind: sniffed.kind, sizeBytes: input.bytes.length, boxId: box.id }, + }); + return id; + }); + return { submissionId, state: "pending" }; + } + + /** Workspace manager: the moderation queue (contact included — admin-only surface). */ + async listForGroup(workspaceId: string, groupId: string, state: "pending" | "approved" | "rejected" | "all"): Promise { + return withTenant(workspaceId, async (client) => { + const box = await this.requireBox(client, workspaceId, groupId); + const rows = state === "all" + ? (await client.query("SELECT * FROM submissions WHERE box_id = $1 ORDER BY created_at DESC LIMIT 200", [box.id])).rows + : (await client.query("SELECT * FROM submissions WHERE box_id = $1 AND state = $2 ORDER BY created_at DESC LIMIT 200", [box.id, state])).rows; + return rows.map(fromRow); + }); + } + + /** Approve: video → the video library (probe pipeline); photo → group asset link. */ + async decide(workspaceId: string, groupId: string, submissionId: string, decision: "approved" | "rejected", opts: { + actorUserId: string; actorRole: string; note?: string; + }): Promise { + if (decision === "rejected" && (opts.note === undefined || opts.note.trim() === "")) { + throw new BadRequestException("a rejection needs a note for the moderation log"); + } + if (decision === "approved" && this.deps.videoUpload === null) { + throw new ServiceUnavailableException("video intake is not configured"); + } + return withTenant(workspaceId, async (client) => { + const box = await this.requireBox(client, workspaceId, groupId); + const row = (await client.query("SELECT * FROM submissions WHERE id = $1 AND box_id = $2 FOR UPDATE", [submissionId, box.id])).rows[0]; + if (row === undefined) throw new NotFoundException("submission not found"); + if (row.state !== "pending") throw new BadRequestException(`submission is already ${row.state}`); + const updated = await client.query( + "UPDATE submissions SET state = $2, rejection_note = $3, decided_at = now(), decided_by = $4 WHERE id = $1 RETURNING *", + [submissionId, decision, decision === "rejected" ? opts.note!.trim() : null, opts.actorUserId], + ); + const record = fromRow(updated.rows[0]!); + if (decision === "rejected") { + await appendTenantAudit(workspaceId, { actorUserId: opts.actorUserId, actorRole: opts.actorRole, action: "submission.rejected", targetType: "submission", targetId: submissionId, diff: { note: opts.note!.trim() } }); + return record; + } + let videoAssetId: string | undefined; + let groupAssetId: string | undefined; + if (row.kind === "video" && this.deps.videoUpload !== null) { + const uploaded = await this.deps.videoUpload(workspaceId, row.storage_key, row.mime, Number(row.size_bytes)); + videoAssetId = uploaded.videoAsset.id; + } else if (row.kind === "photo") { + const linked = await client.query<{ id: string }>( + "INSERT INTO group_assets (workspace_id, group_id, asset_id, assigned_by) VALUES ($1, $2, $3, $4) RETURNING id", + [workspaceId, groupId, row.storage_key, opts.actorUserId]); + const linkedId = linked.rows[0]?.id; + if (linkedId === undefined) throw new Error("group asset insert returned no id"); + groupAssetId = String(linkedId); + } + await appendTenantAudit(workspaceId, { + actorUserId: opts.actorUserId, actorRole: opts.actorRole, action: "submission.approved", targetType: "submission", targetId: submissionId, + diff: { kind: row.kind, ...(videoAssetId !== undefined ? { videoAssetId } : {}), ...(groupAssetId !== undefined ? { groupAssetId } : {}), creditName: row.credit_name }, + }); + return { ...record, ...(videoAssetId !== undefined ? { videoAssetId } : {}), ...(groupAssetId !== undefined ? { groupAssetId } : {}) }; + }); + } + + /** Workspace manager: enable/disable or rotate the public token (old token dies). */ + async configureBox(workspaceId: string, groupId: string, action: { enabled?: boolean; rotateToken?: boolean }, actor: { userId: string; role: string }): Promise<{ token: string; enabled: boolean }> { + return withTenant(workspaceId, async (client) => { + const box = await this.requireBox(client, workspaceId, groupId, true); + const patch: string[] = []; + const values: unknown[] = []; + if (action.rotateToken === true) { + patch.push(`public_token = $${values.length + 1}`); + values.push(randomBytes(24).toString("base64url")); + } + if (typeof action.enabled === "boolean") { + patch.push(`enabled = $${values.length + 1}`); + values.push(action.enabled); + } + if (patch.length === 0) return { token: box.public_token, enabled: box.enabled }; + values.push(box.id, workspaceId); + const updated = await client.query<{ public_token: string; enabled: boolean }>( + `UPDATE submission_boxes SET ${patch.join(", ")} WHERE id = $${values.length - 1} AND workspace_id = $${values.length} RETURNING public_token, enabled`, values); + await appendTenantAudit(workspaceId, { + actorUserId: actor.userId, actorRole: actor.role, action: "submission_box.configured", + targetType: "group", targetId: groupId, diff: { rotateToken: action.rotateToken === true, enabled: action.enabled ?? box.enabled }, + }); + const row = updated.rows[0]!; + return { token: row.public_token, enabled: row.enabled }; + }); + } + + async ensureBox(workspaceId: string, groupId: string, actor: { userId: string; role: string }): Promise<{ token: string; enabled: boolean }> { + return withTenant(workspaceId, async (client) => { + const existing = await this.findBox(client, workspaceId, groupId); + if (existing !== undefined) return { token: existing.public_token, enabled: existing.enabled }; + const created = await client.query<{ public_token: string; enabled: boolean }>( + "INSERT INTO submission_boxes (workspace_id, group_id, public_token) VALUES ($1, $2, $3) RETURNING public_token, enabled", + [workspaceId, groupId, randomBytes(24).toString("base64url")]); + await appendTenantAudit(workspaceId, { + actorUserId: actor.userId, actorRole: actor.role, action: "submission_box.created", + targetType: "group", targetId: groupId, diff: {}, + }); + const row = created.rows[0]!; + return { token: row.public_token, enabled: row.enabled }; + }); + } + + private async findBox(client: PoolClient, workspaceId: string, groupId: string) { + return (await client.query( + "SELECT * FROM submission_boxes WHERE workspace_id = $1 AND group_id = $2", [workspaceId, groupId])).rows[0]; + } + + private async requireBox(client: PoolClient, workspaceId: string, groupId: string, lock = false) { + const box = await this.findBox(client, workspaceId, groupId); + if (box === undefined) throw new NotFoundException("this group has no submission box"); + if (lock) await client.query("SELECT id FROM submission_boxes WHERE id = $1 AND workspace_id = $2 FOR UPDATE", [box.id, workspaceId]); + return box; + } +}