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
66
apps/api/migrations/035_submissions.sql
Normal file
66
apps/api/migrations/035_submissions.sql
Normal file
|
|
@ -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
|
||||
$$;
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
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");
|
||||
}
|
||||
100
apps/api/src/submissions/submissions.controller.ts
Normal file
100
apps/api/src/submissions/submissions.controller.ts
Normal file
|
|
@ -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<SubmissionBoxInfo> {
|
||||
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<string, unknown>,
|
||||
@Req() request?: Request,
|
||||
): Promise<SubmissionReceiveResult> {
|
||||
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<SubmissionRecord[]> {
|
||||
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<SubmissionRecord> {
|
||||
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<SubmissionRecord> {
|
||||
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 });
|
||||
}
|
||||
}
|
||||
174
apps/api/src/submissions/submissions.integration.test.ts
Normal file
174
apps/api/src/submissions/submissions.integration.test.ts
Normal file
|
|
@ -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<T>(fn: (client: PoolClient) => Promise<T>): Promise<T> {
|
||||
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<string, unknown> = {}) =>
|
||||
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");
|
||||
});
|
||||
});
|
||||
29
apps/api/src/submissions/submissions.module.ts
Normal file
29
apps/api/src/submissions/submissions.module.ts
Normal file
|
|
@ -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 {}
|
||||
64
apps/api/src/submissions/submissions.service.test.ts
Normal file
64
apps/api/src/submissions/submissions.service.test.ts
Normal file
|
|
@ -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("<script>alert(1)</script>"))).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<ConstructorParameters<typeof SubmissionsService>[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);
|
||||
});
|
||||
});
|
||||
329
apps/api/src/submissions/submissions.service.ts
Normal file
329
apps/api/src/submissions/submissions.service.ts
Normal file
|
|
@ -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<SubmissionDeps>) {
|
||||
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<SubmissionBoxInfo> {
|
||||
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<BoxRow | undefined> {
|
||||
if (!/^[A-Za-z0-9_-]{20,}$/.test(token)) return undefined;
|
||||
return (await withSystem((client) => client.query<BoxRow>("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<SubmissionReceiveResult> {
|
||||
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<SubmissionRecord[]> {
|
||||
return withTenant(workspaceId, async (client) => {
|
||||
const box = await this.requireBox(client, workspaceId, groupId);
|
||||
const rows = state === "all"
|
||||
? (await client.query<SubmissionRow>("SELECT * FROM submissions WHERE box_id = $1 ORDER BY created_at DESC LIMIT 200", [box.id])).rows
|
||||
: (await client.query<SubmissionRow>("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<SubmissionRecord & { videoAssetId?: string; groupAssetId?: string }> {
|
||||
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<SubmissionRow>("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<SubmissionRow>(
|
||||
"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<BoxRow>(
|
||||
"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;
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue