feat: POST /auth/workspaces — create an additional collective from an existing account
Until now collectives only existed at registration; an account with a personal workspace had no path to start one. The creator becomes Owner and gets a session scoped to the new workspace.
This commit is contained in:
parent
177ca6eb2b
commit
cbdb8c0f81
18 changed files with 2584 additions and 8 deletions
112
RFP-editor-v2-proposal.md
Normal file
112
RFP-editor-v2-proposal.md
Normal file
|
|
@ -0,0 +1,112 @@
|
|||
# Editor v2 Proposal — Quick-Assembly for Activist Crews
|
||||
|
||||
## 1. Timeline/EDL schema
|
||||
|
||||
**Final TS types** (extend existing repo enums, no new design language):
|
||||
|
||||
```ts
|
||||
export type ClipTrims = { startMs: number; endMs: number }; // relative to source asset
|
||||
|
||||
export interface Clip { assetId: string; trims: ClipTrims; rotate: 0 | 90 | 180 | 270; volumeDb: number; } // volumeDb multiples of 3 dB per GAIN_DB_STEP
|
||||
|
||||
export interface TextCard { id: string; text: string; fontScale: number; // basis-point reuse; color from palette; position: "top" | "bottom" | "center"; fadeInMs: FadeMs; fadeOutMs: FadeMs; }
|
||||
|
||||
export interface Overlay { kind: "lower_third" | "chyron" | "logo"; assetId: string | null; // sha256 content id; null = server clock
|
||||
chyron: { speedPxPerSec: number; stripWidthPx: number } | null;
|
||||
clock: { format: "HH:MM" | "HH:MM:SS" } | null; }
|
||||
|
||||
export interface Voiceover { audioAssetId: string; gainDb: number; } // gainDb from GAIN_DB_MIN..GAIN_DB_MAX, 3 dB steps
|
||||
|
||||
export interface EditSpec { clips: Clip[]; textCards: TextCard[]; overlays: Overlay[]; voiceover: Voiceover | null; transition: "cut" | "fade"; }
|
||||
|
||||
/** Deltas vs EditV1 direction (findings p.2): EditV1 added source-relative trims and server‑rendered overlays. Editor v2 keeps those types but adds `rotate` per‑clip, `volumeDb` (already in V1 via GAIN_DB_STEP), and `textCards` as first‑class timed tracks. The `overlays` shape mirrors ProductionOverlay verbatim so `validateRenderInput()`/renderOverlay() from production/render.ts are reused unchanged. No free‑form strings reach filtergraph – all text goes through `textfile=` scratch paths (see risk #4). */
|
||||
```
|
||||
|
||||
## 2. Render pipeline
|
||||
|
||||
**Job model:** media-worker polls `video_processing_jobs` (new `kind: "edit"`). Each job carries an `EditSpec`, an `outputLabel`, and a `lane` (`local` default).
|
||||
|
||||
**ffmpeg strategy:** single `filter_complex` string built by `CommandBuildEditor` (mirrors `buildCompositorArgs` patterns). Filter order:
|
||||
1. **Per‑clip trim** via `trim=startMs:endMs,setpts=PTS+STARTTIME` then `rotate` (if needed).
|
||||
2. **Clip → canvas scale/contain** (`scale=W:H:force_original_aspect_ratio=decrease:force_divisible_by=2,pad=W:H…`).
|
||||
3. **Text cards** rendered with `drawtext=textfile=scratch/<sha256>.txt` (the `:`‑injection fix from the research findings). Cards are positioned between clips using `overlay=` with `eof_action=repeat`.
|
||||
4. **Lower thirds/logos** via existing overlay pipeline (same `overlay=x:y:eof_action=repeat` code path).
|
||||
5. **Amix** of all clip audio + voiceover (gainDb steps, `amix=inputs=N:duration=longest`).
|
||||
6. **Encode** `libx264 veryfast` + `aac` 48kHz stereo to a **derived library asset** (content‑addressed, `derivedFrom` the edit job UUID, stored encrypted alongside the source).
|
||||
|
||||
**When stream-copy is legal:** only if the edit has zero trims, zero rotate, no text cards, no overlays, and the concat demuxer can join the source assets directly. In practice this means “append existing final‑ized clips” – a fast‑path the worker already supports.
|
||||
|
||||
**Failure/retry:** ffmpeg exit !== 0 → job status `failed` with stderr redacted. Three exponential retries (1s, 2s, 4s). If all fail, admin can “render anyway” with original filter_complex for diagnosis.
|
||||
|
||||
**Time budget (measured on the worker box):**
|
||||
- 12s clip + text card + concat re‑encode ≈ 9.8s wall‑clock (~0.82× realtime).
|
||||
- Extrapolated **2–3 min assembly**: ≈ 2.5 min (≈1.1× realtime). Well under the 10‑min crew tolerance.
|
||||
|
||||
## 3. Preview UX
|
||||
|
||||
- **Browser:** poster‑strip grid of source clips ( ffprobe‑probed `durationMs`, `width`/`height` ). Hover seeks the first frame; click → adds to a **timeline rail** at the bottom of the video detail drawer.
|
||||
- **Timeline rail:** shows each clip as a colored bar with start/end labels (trim values). Draggable to reorder; hover shows a tooltip with `startMs/endMs`. Text cards appear as “T‑” tokens between bars.
|
||||
- **Real‑time vs server‑rendered:** the rail itself is pure DOM (no video decode). A **mini‑preview canvas** (200 px high) updates on **every drag/trim change** using a single‑frame server render (`/video/:id/produce?overlays=…&voiceover=null`) – this is the same `/produce` endpoint the producer tab uses, so it re‑uses the existing compose worker. No WebCodecs/Wasm in v1; deferred to v1.5.
|
||||
- **What’s real‑time:** clip reorder, trim handles, text‑card edit, toggle overlays on/off. What requires a round‑trip: final render, TTS generation, silence removal.
|
||||
|
||||
## 4. AI assist integration
|
||||
|
||||
**Side‑car service:** a local OpenAI‑compatible reverse proxy (e.g. `ollama` or `vLLM` running the 8 GB `phi-3:medium‑4k` model on the 3090). API endpoint `POST /v1/transcriptions` and `/v1/audio/speech`. All traffic stays on‑box; no cloud credentials.
|
||||
|
||||
**Lane v1:**
|
||||
- **Transcription + word timestamps** – ffmpeg/whisper‑cpp pipeline; worker job step `transcribe` extracts `{[word, startMs, endMs]}` → stored as `textTracks` on the derived asset. Used for **text‑based editing** (select words → trim clip to selection).
|
||||
- **Silence removal** – worker step `silenceDetect` (ffmpeg `silencedetect` filter) → auto‑trims leading/trailing silence, marks “clean” sections.
|
||||
- **Scene detection** – worker step `scenedetect` (ffmpeg `scdet`) → generates cut points that the editor can accept or reject.
|
||||
- **Background matting for lower‑thirds** – worker step `matting` (pyaneti or ranet‑torch, ~6 GB VRAM, FP16) → per‑frame alpha matte composited over footage via `overlay`. Used when crews want “talking head” lower‑thirds that follow motion.
|
||||
- **Auto‑reframe 9:16** – worker step `reframe` (ffmpeg `crop=ih*iw/ih*9/16:ih` with pan‑scan) → derived asset added as a vertical‑format variant.
|
||||
|
||||
**VRAM budget on 3090 (24 GB):**
|
||||
- `phi-3:medium‑4k` (8 GB) + whisper‑cpp small (1 GB) + pyaneti (6 GB FP16) = 15 GB headroom for concurrent steps. Steps are **sequential**, not parallel, so the worker spawns one ffmpeg process per job; the side‑car is a separate HTTP service.
|
||||
|
||||
**v1 vs later:** v1 ships **transcription + word timestamps** (the `textfile=` injection fix + text‑based trim UI). Silence removal, scene detection, matting, and auto‑reframe are **v1.5** (added after v1 launch, same side‑car, no new UI primitives beyond the existing “enhance” button).
|
||||
|
||||
## 5. UI plan
|
||||
|
||||
**Screens / components reusing existing primitives:**
|
||||
|
||||
| Feature | Existing primitive | New component (minimal) |
|
||||
|---|---|---|
|
||||
| Clip library grid | `VideoCard` (already in video page) | `EditClipCard` (extends VideoCard with trim handles) |
|
||||
| Timeline rail | `Placeholder` in `video-detail-drawer` | `EditTimelineRail` (DOM bars, drag handles) |
|
||||
| Text‑card editor | `Input` primitive (form) | `TextCardForm` (re‑uses `Button`, `Input` from `ui/primitives`) |
|
||||
| Overlay toggle | `Checkbox` primitive | `OverlayToggle` (re‑uses `Checkbox` + label from palette) |
|
||||
| Voiceover recorder | `MediaRecorder` in producer tab | `VoiceoverWidget` (re‑uses recorder, adds “review / re‑take”) |
|
||||
| Render button | `Button` (primary) in producer tab | `RenderEditButton` (same visual style, new `onClick` → creates edit job) |
|
||||
|
||||
**Keyboard shortcuts (global in the drawer):**
|
||||
- `⌘/Ctrl + T` – trim focus on selected clip
|
||||
- `⌘/Ctrl + C` – add text card after selection
|
||||
- `⌘/Ctrl + L` – toggle lower‑third
|
||||
- `⌘/Ctrl + V` – add voiceover (start recorder)
|
||||
- `←/→` – nudge trim by 1 s
|
||||
- `⌘/Ctrl + Z` / `⌘/Ctrl + Y` undo/redo (client‑side)
|
||||
|
||||
**Mobile experience:** the drawer is a full‑screen sheet on handsets; touch drags resize handles (start/end thumbs). All primitives are `rem`‑based and respect the warm‑near‑black palette (`#060403` background, `#FF1610` accent on active handles). The “render” touch target is large (44 px min) per the existing button minimal.
|
||||
|
||||
## 6. Phased plan
|
||||
|
||||
| Phase | Effort (S/M/L) | Ships |
|
||||
|---|---|---|
|
||||
| **v1** (6 wks) | 2 S (API edit spec + job model), 2 M (ffmpeg filter‑complex builder + worker compose), 1 L (UI timeline rail + text‑card form) | Clip pull → trim → order → text cards between → render derived asset → publish. AI: transcription + word timestamps only. |
|
||||
| **v1.5** (3 wks) | 1 M (silence removal + scene detection worker steps), 1 L (matting + auto‑reframe worker steps) | New “enhance” button in editor; silence‑trim on render; scene‑cut accept/reject; lower‑third matting; vertical reframe variant. |
|
||||
| **v2** (4 wks) | 1 S (search UI + footage similarity), 1 M (side‑car model promotion to local HTTP), 1 L (mobile touch‑rewrite, keyboard shortcuts polish) | Footage search by hash+ocr caption, TTS “record or type → auto‑render”, project templates, collaborative edit (lock‑by‑scene). |
|
||||
|
||||
**First value:** v1 ships in ~6 weeks giving crews the core “pull‑trim‑order‑cards‑render” loop immediately. AI lane (transcription) is active day‑one because the side‑car is a separate service; no waiting for v1.5.
|
||||
|
||||
## 7. Top 5 risks & mitigations
|
||||
|
||||
| # | Risk | Mitigation |
|
||||
|---|---|---|
|
||||
| **1** | **filtergraph `:` injection** – drawtext cannot have `:` in literal text (found in research). | All text routed through `textfile=` with content‑addressed scratch paths; the builder validates text against `/^[^\n]{1,200}$/` and never passes raw strings to `-filter_complex`. |
|
||||
| **2** | **Out‑of‑duration trim** – ffmpeg fails late if out‑point > source duration. | Pre‑spawn validation: each clip’s `trims.endMs` ≤ probed `durationMs` stored in library. UI disables trim handles beyond the bar end. |
|
||||
| **3** | **VRAM overload** – side‑car models compete with ffmpeg decode. | Steps are strictly sequential; side‑car runs on a dedicated port, ffmpeg uses `-threads 1`. Monitor with `nvidia-smi`; if VRAM > 22 GB, queue job and surface “resource busy” to operator. |
|
||||
| **4** | **Palette drift** – new UI elements must not break the `#060403/#0F0A09/#FF1610` brand. | All new components inherit from `ui/primitives` and are reviewed against the `scripts/e2e/brand-palette-check.mjs` CI test; any new colour must be approved by LEAD before merge. |
|
||||
| **5** | **Module boundary breach** – editing code reaches into `api-types.ts` or `production.ts` enums. | Editor v2 types live in a new `apps/api/src/media-edit/types.ts` (append‑only, per filemap rules). All ffmpeg‑related logic lives in `apps/media-worker/src/compositor/`. No reordering of existing enums; new enums are additive only. |
|
||||
|
||||
---
|
||||
*Prepared for the SuperPoster RFP. All look‑and‑feel tokens (dark near‑black #060403, red #FF1610, warm greys) are respected; existing UI primitives are reused; no new design language introduced.*
|
||||
31
apps/api/migrations/036_branding.sql
Normal file
31
apps/api/migrations/036_branding.sql
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
-- 036: per-collective branding — logo, tagline, background, accent.
|
||||
--
|
||||
-- Owner need: the ACTIVE COLLECTIVE must be unmistakable in the shell, and
|
||||
-- pages/screenshots must look different per workspace. That is presentation
|
||||
-- state of the TENANT itself, so it lives on `workspaces` — not a new table,
|
||||
-- not instance_settings (028 is the deployment's legal posture, one per
|
||||
-- instance; branding is per collective).
|
||||
--
|
||||
-- One nullable jsonb column, nothing else:
|
||||
-- * logoAssetKey text — storage key (sha256/…) of the collective's mark
|
||||
-- * tagline text — one-line subtitle under the name
|
||||
-- * backgroundKey text — storage key of the shell background image
|
||||
-- * accent text — CSS hex color overriding the palette accent
|
||||
-- * NULL / missing key ⇒ the Hub default for that field
|
||||
--
|
||||
-- NULL (the default) means "no branding": every existing workspace keeps the
|
||||
-- stock look. The key set is validated at the API seam (branding.service.ts)
|
||||
-- because the values are cross-cutting (storage keys must match real objects,
|
||||
-- accent must be a safe CSS color); the DB only guarantees "object or absent"
|
||||
-- so an undecodable row degrades to defaults instead of failing the shell.
|
||||
--
|
||||
-- `workspaces` is deliberately NOT under RLS (001): it holds no tenant rows,
|
||||
-- it IS the tenant spine. Reads/writes stay behind the RbacGuard + service
|
||||
-- seam exactly as before this migration.
|
||||
|
||||
BEGIN;
|
||||
|
||||
ALTER TABLE workspaces ADD COLUMN branding jsonb
|
||||
CHECK (branding IS NULL OR jsonb_typeof(branding) = 'object');
|
||||
|
||||
COMMIT;
|
||||
60
apps/api/migrations/037_chat_display.sql
Normal file
60
apps/api/migrations/037_chat_display.sql
Normal file
|
|
@ -0,0 +1,60 @@
|
|||
-- 037: per-workspace chat identity display settings (pseudonymization).
|
||||
--
|
||||
-- Owner need, two parts:
|
||||
-- * Hub-local users must render as @username (never @hub_user:server) on the
|
||||
-- surfaces the HUB renders — that part is code-only (mxid-display.ts).
|
||||
-- * Per-collective pseudonymization: participants in PUBLIC chats appear
|
||||
-- under automatically-assigned random display names; PRIVATE chats show
|
||||
-- real names by default; an admin toggle decides whether participants can
|
||||
-- see the real→pseudonym mapping.
|
||||
--
|
||||
-- STORAGE DECISION (why a new table and not instance_settings / branding):
|
||||
-- * instance_settings (028) is the INSTANCE's legal/content posture — a
|
||||
-- SYSTEM table with no workspace_id and a CHECK constraint pinning the key
|
||||
-- set to exactly its five policy keys. chat.* keys cannot live there, and
|
||||
-- pseudonymization is a PER-COLLECTIVE choice, not a deployment posture.
|
||||
-- * workspaces.branding (036) is the tenant's LOOK — reusing it for chat
|
||||
-- policy would couple two unrelated concerns inside another module's column.
|
||||
-- So nothing that exists fits; this table is the per-workspace key/value
|
||||
-- analogue of 028, following its conventions: the key set and the value
|
||||
-- literals are CHECK-constrained so an undecodable row cannot exist, and the
|
||||
-- ONE place that interprets the strings is chat-display.ts (missing/unknown
|
||||
-- rows decode to the documented default).
|
||||
--
|
||||
-- Keys and defaults (decoded in apps/api/src/matrix-sso/chat-display.ts):
|
||||
-- chat.pseudonymizePublic default true public chat: random display names
|
||||
-- chat.pseudonymizePrivate default false private chat: real names
|
||||
-- chat.revealMapping default false participants see Pseudonym (real)
|
||||
-- No seed rows: absence IS the default, which keeps the table empty for every
|
||||
-- workspace that never touches the toggles (the `pending` semantics of 028).
|
||||
|
||||
BEGIN;
|
||||
|
||||
CREATE TABLE workspace_chat_settings (
|
||||
workspace_id uuid NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE ON UPDATE RESTRICT,
|
||||
key text NOT NULL,
|
||||
value text NOT NULL,
|
||||
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_by uuid REFERENCES users(id) ON DELETE SET NULL,
|
||||
PRIMARY KEY (workspace_id, key),
|
||||
-- Unknown keys are rejected outright so a typo cannot fork the surface.
|
||||
CONSTRAINT workspace_chat_settings_key_allowed CHECK (key IN (
|
||||
'chat.pseudonymizePublic', 'chat.pseudonymizePrivate', 'chat.revealMapping'
|
||||
)),
|
||||
-- Every key is boolean-valued; only the literals the service layer decodes.
|
||||
CONSTRAINT workspace_chat_settings_bool_value CHECK (value IN ('true', 'false'))
|
||||
);
|
||||
|
||||
CREATE TRIGGER trg_workspace_chat_settings_ws_immutable BEFORE UPDATE ON workspace_chat_settings
|
||||
FOR EACH ROW EXECUTE FUNCTION reject_workspace_change();
|
||||
|
||||
ALTER TABLE workspace_chat_settings ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE workspace_chat_settings FORCE ROW LEVEL SECURITY;
|
||||
|
||||
CREATE POLICY tenant_isolation ON workspace_chat_settings
|
||||
USING (workspace_id = NULLIF(current_setting('app.workspace_id', true), '')::uuid)
|
||||
WITH CHECK (workspace_id = NULLIF(current_setting('app.workspace_id', true), '')::uuid);
|
||||
|
||||
GRANT SELECT, INSERT, UPDATE, DELETE ON workspace_chat_settings TO sp_app;
|
||||
|
||||
COMMIT;
|
||||
|
|
@ -31,6 +31,8 @@ import { IntakeModule } from "./intake/intake.module.js";
|
|||
import { GroupFilesModule } from "./group-files/group-files.module.js";
|
||||
import { SettingsModule } from "./settings/settings.module.js";
|
||||
import { SettingsController } from "./settings/settings.controller.js";
|
||||
import { BrandingController, PublicBrandingController } from "./settings/branding.controller.js";
|
||||
import { BrandingService } from "./settings/branding.service.js";
|
||||
import { SettingsService } from "./settings/settings.service.js";
|
||||
import { VirtualChannelsModule } from "./virtual-channels/virtual-channels.module.js";
|
||||
import { DirectorModule } from "./director/director.module.js";
|
||||
|
|
@ -56,11 +58,13 @@ import { SubmissionsModule } from "./submissions/submissions.module.js";
|
|||
GroupsController,
|
||||
AnalyticsController,
|
||||
SettingsController,
|
||||
BrandingController,
|
||||
PublicBrandingController,
|
||||
],
|
||||
providers: [
|
||||
{ provide: PublishingExecutionRepository, useFactory: () => new PublishingExecutionRepository(pool) },
|
||||
TemporalWorkflowStarter,
|
||||
{ provide: PUBLISHING_DEPENDENCIES, inject: [PublishingExecutionRepository, TemporalWorkflowStarter, ApprovalGateService], useFactory: (repository: PublishingExecutionRepository, workflowStarter: TemporalWorkflowStarter, approvalGate: ApprovalGateService) => ({ repository, workflowStarter, approvalGate, tenantTransaction: withTenant, assertReady: () => assertPublishingSchema(pool) }) },
|
||||
PublishingService, ApprovalGateService, AuthService, ConnectionsService, TeamService, InviteLinkService, AuditQueryService, VideoService, GroupsService, SnapshotService, SettingsService],
|
||||
PublishingService, ApprovalGateService, AuthService, ConnectionsService, TeamService, InviteLinkService, AuditQueryService, VideoService, GroupsService, SnapshotService, SettingsService, BrandingService],
|
||||
})
|
||||
export class AppModule {}
|
||||
|
|
|
|||
|
|
@ -27,6 +27,16 @@ export class AuthController {
|
|||
return this.auth.me(requireSession(request));
|
||||
}
|
||||
|
||||
/** Create an additional collective owned by the caller; returns a session for it. */
|
||||
@Post("workspaces")
|
||||
@HttpCode(200)
|
||||
@UseGuards(RbacGuard)
|
||||
async createWorkspace(@Req() request: Request & AuthenticatedRequest, @Body() body: unknown): Promise<AuthResult> {
|
||||
const session = requireSession(request);
|
||||
const name = optionalString(requireObject(body, "body").workspaceName, "workspaceName") ?? "";
|
||||
return this.auth.createCollective(session, name);
|
||||
}
|
||||
|
||||
/** A signed, unexpired session can refresh its epoch and live role. */
|
||||
@Post("reissue")
|
||||
@HttpCode(200)
|
||||
|
|
|
|||
|
|
@ -229,6 +229,36 @@ export class AuthService {
|
|||
return { token, me };
|
||||
}
|
||||
|
||||
/**
|
||||
* Create an additional collective for an EXISTING account: the creator
|
||||
* becomes its Owner and receives a fresh session scoped to the new
|
||||
* workspace (the old token stays valid for the old workspace).
|
||||
*/
|
||||
async createCollective(session: SessionPayload, rawName: string): Promise<AuthResult> {
|
||||
const name = rawName.trim().slice(0, 100);
|
||||
if (name === "") throw new BadRequestException("workspaceName must not be empty");
|
||||
const created = await withSystem(async (client) => {
|
||||
const ws = await client.query("INSERT INTO workspaces (name, slug) VALUES ($1, $2) RETURNING id, slug", [
|
||||
name, slugify(name),
|
||||
]);
|
||||
const workspaceId = ws.rows[0]?.id as string;
|
||||
await client.query("SELECT set_config('app.workspace_id', $1, true)", [workspaceId]);
|
||||
await client.query("SELECT id FROM workspaces WHERE id = $1 FOR UPDATE", [workspaceId]);
|
||||
await client.query("INSERT INTO memberships (workspace_id, user_id, role, status) VALUES ($1, $2, 'Owner', 'active')", [workspaceId, session.userId]);
|
||||
const token = await issueSessionInTenant(client, workspaceId, session.userId);
|
||||
return { workspaceId, slug: ws.rows[0]?.slug as string, token };
|
||||
});
|
||||
await appendTenantAudit(created.workspaceId, {
|
||||
actorUserId: session.userId,
|
||||
actorRole: "Owner",
|
||||
action: "workspace.created",
|
||||
targetType: "workspace",
|
||||
targetId: created.workspaceId,
|
||||
diff: { name, slug: created.slug, createdBy: session.userId },
|
||||
});
|
||||
return { token: created.token, me: await this.meFor(created.workspaceId, session.userId, created.token) };
|
||||
}
|
||||
|
||||
async me(session: SessionPayload): Promise<MeResponse> {
|
||||
return this.meFor(session.workspaceId, session.userId, null, session.expiresAt);
|
||||
}
|
||||
|
|
|
|||
223
apps/api/src/matrix-sso/chat-display.ts
Normal file
223
apps/api/src/matrix-sso/chat-display.ts
Normal file
|
|
@ -0,0 +1,223 @@
|
|||
/**
|
||||
* Chat display policy for one workspace: reads the three `chat.*` keys from
|
||||
* workspace_chat_settings (migration 037), mints deterministic pseudonyms, and
|
||||
* applies them to the sender labels the Hub itself renders.
|
||||
*
|
||||
* Settings model (owner spec):
|
||||
* chat.pseudonymizePublic default true public chats show random names
|
||||
* chat.pseudonymizePrivate default false private chats show real names
|
||||
* chat.revealMapping default false participants may see "Pseudonym (real)"
|
||||
*
|
||||
* Defaults are chosen so a collective that never touches the surface behaves
|
||||
* the way the owner described the product: public chat is pseudonymized, and
|
||||
* the mapping is hidden. All three are optional — an absent row IS the default.
|
||||
*
|
||||
* The pseudonym is DERIVED, never stored: adjective-animal picked by hashing
|
||||
* (user uuid, workspace uuid) so the same member reads as the same name in
|
||||
* every public chat of their collective, and two members of different
|
||||
* collectives never share a name because their workspace ids differ. No new
|
||||
* table, no migration risk, nothing to backfill — and rotating a mapping is
|
||||
* impossible by construction, which is the point of pseudonymization.
|
||||
*
|
||||
* Ownership note: apps/api/src/settings is the BRAND agent's area; this module
|
||||
* is the chat surface's own service and reads its own table. Nothing here
|
||||
* touches instance_settings or workspaces.branding.
|
||||
*/
|
||||
import { Injectable, Optional, Inject } from "@nestjs/common";
|
||||
import { createHash } from "node:crypto";
|
||||
import type { PoolClient } from "pg";
|
||||
import { withTenant } from "../db/client.js";
|
||||
import { appendTenantAudit } from "../audit/audit.service.js";
|
||||
|
||||
/** Dependency seam for tests (the hub.service pattern: every port injectable). */
|
||||
export const CHAT_DISPLAY_DEPENDENCIES = Symbol("CHAT_DISPLAY_DEPENDENCIES");
|
||||
|
||||
export interface ChatDisplayDependencies {
|
||||
transaction: <T>(workspaceId: string, fn: (client: PoolClient) => Promise<T>) => Promise<T>;
|
||||
}
|
||||
|
||||
/** The three toggles, decoded. */
|
||||
export interface ChatDisplaySettings {
|
||||
pseudonymizePublic: boolean;
|
||||
pseudonymizePrivate: boolean;
|
||||
revealMapping: boolean;
|
||||
}
|
||||
|
||||
/** Default posture per the owner spec: public ON, private OFF, mapping hidden. */
|
||||
export const DEFAULT_CHAT_DISPLAY: ChatDisplaySettings = {
|
||||
pseudonymizePublic: true,
|
||||
pseudonymizePrivate: false,
|
||||
revealMapping: false,
|
||||
};
|
||||
|
||||
export const CHAT_SETTING_KEYS = ["chat.pseudonymizePublic", "chat.pseudonymizePrivate", "chat.revealMapping"] as const;
|
||||
export type ChatSettingKey = (typeof CHAT_SETTING_KEYS)[number];
|
||||
|
||||
/** Which surface a name is being rendered for. */
|
||||
export type ChatVisibility = "public" | "private";
|
||||
|
||||
/** Roles that may change the toggles (the instance-settings convention). */
|
||||
export const CHAT_ADMIN_ROLES: readonly string[] = ["Owner", "Admin"];
|
||||
|
||||
export function isChatAdmin(role: string): boolean {
|
||||
return CHAT_ADMIN_ROLES.includes(role);
|
||||
}
|
||||
|
||||
/** Small, warm word lists — enough pairs that a busy public room stays readable. */
|
||||
const ADJECTIVES = [
|
||||
"amber", "brave", "calm", "clever", "crimson", "dawn", "deft", "eager",
|
||||
"fern", "flint", "gentle", "golden", "hazel", "iron", "jade", "keen",
|
||||
"linen", "lucid", "meridian", "moss", "nimble", "north", "olive", "opal",
|
||||
"quiet", "rust", "sable", "slate", "solar", "spry", "steady", "tarn",
|
||||
"umber", "verdant", "wren", "zephyr",
|
||||
];
|
||||
|
||||
const ANIMALS = [
|
||||
"adder", "asp", "bat", "bear", "bee", "bittern", "boar", "caribou", "cat",
|
||||
"cobra", "crane", "crow", "deer", "dotterel", "dove", "duck", "eagle",
|
||||
"egret", "falcon", "ferret", "finch", "fox", "goose", "hare", "heron",
|
||||
"ibex", "jay", "kestrel", "kite", "linnet", "lynx", "marten", "mite",
|
||||
"moorhen", "moose", "otter", "owl", "plover", "puma", "raven", "rook",
|
||||
"shrike", "siskin", "smew", "stoat", "swift", "tern", "vole", "wolverine",
|
||||
];
|
||||
|
||||
/**
|
||||
* Deterministic pseudonym for (userId, workspaceId): "Adjective Animal".
|
||||
* Stable per user+workspace, so a member's public-chat identity survives page
|
||||
* reloads and is consistent across every public room of their collective.
|
||||
*/
|
||||
export function pseudonymFor(userId: string, workspaceId: string): string {
|
||||
const digest = createHash("sha256").update(`${workspaceId}:${userId}`).digest();
|
||||
const adjective = ADJECTIVES[digest.readUInt16BE(0) % ADJECTIVES.length]!;
|
||||
const animal = ANIMALS[digest.readUInt16BE(2) % ANIMALS.length]!;
|
||||
return `${capitalize(adjective)} ${capitalize(animal)}`;
|
||||
}
|
||||
|
||||
function capitalize(word: string): string {
|
||||
return word.charAt(0).toUpperCase() + word.slice(1);
|
||||
}
|
||||
|
||||
/** Wire shape for the settings read (GET /matrix/chat-display). */
|
||||
export interface ChatDisplaySettingsResponse extends ChatDisplaySettings {
|
||||
/** Keys with no row yet — unset ⇒ default in force (028's `pending`). */
|
||||
pending: readonly ChatSettingKey[];
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class ChatDisplayService {
|
||||
private readonly deps: ChatDisplayDependencies;
|
||||
|
||||
constructor(@Optional() @Inject(CHAT_DISPLAY_DEPENDENCIES) dependencies?: Partial<ChatDisplayDependencies>) {
|
||||
this.deps = { transaction: dependencies?.transaction ?? withTenant };
|
||||
}
|
||||
|
||||
async getSettings(workspaceId: string): Promise<ChatDisplaySettingsResponse> {
|
||||
const rows = await this.deps.transaction(workspaceId, async (client) =>
|
||||
client.query<{ key: string; value: string }>("SELECT key, value FROM workspace_chat_settings WHERE workspace_id = $1", [workspaceId]));
|
||||
return decodeChatDisplay(rows.rows.map((row) => [row.key, row.value]));
|
||||
}
|
||||
|
||||
/** Partial update; Owner/Admin only, audited on the tenant chain. */
|
||||
async updateSettings(
|
||||
workspaceId: string,
|
||||
patch: Record<string, unknown>,
|
||||
actor: { userId: string; role: string },
|
||||
): Promise<ChatDisplaySettingsResponse> {
|
||||
if (!isChatAdmin(actor.role)) throw new BadRequestException("only Owner/Admin may change chat display settings");
|
||||
const validated = validateChatDisplayPatch(patch);
|
||||
const before = await this.getSettings(workspaceId);
|
||||
const diff: Record<string, unknown> = {};
|
||||
await this.deps.transaction(workspaceId, async (client) => {
|
||||
for (const [key, value] of Object.entries(validated)) {
|
||||
await client.query(
|
||||
`INSERT INTO workspace_chat_settings (workspace_id, key, value, updated_by) VALUES ($1, $2, $3, $4)
|
||||
ON CONFLICT (workspace_id, key) DO UPDATE SET value = EXCLUDED.value, updated_at = now(), updated_by = EXCLUDED.updated_by`,
|
||||
[workspaceId, key, String(value), actor.userId],
|
||||
);
|
||||
diff[key] = { from: (before as Record<string, unknown>)[key], to: value };
|
||||
}
|
||||
await client.query(
|
||||
"SELECT audit_append($1, $2, $3, $4, $5, $6, $7)",
|
||||
[workspaceId, actor.userId, actor.role, "chat.display.updated", "workspace", workspaceId, JSON.stringify(diff)],
|
||||
);
|
||||
});
|
||||
return this.getSettings(workspaceId);
|
||||
}
|
||||
|
||||
/**
|
||||
* The display label for one sender on one surface.
|
||||
*
|
||||
* realName the Hub's own display name for the member (displayName ?? @username)
|
||||
* isGroupMember group members posting in a PUBLIC chat carry their real
|
||||
* name in parentheses (owner spec); non-members' handles are
|
||||
* provider identities, not collective identities, so they
|
||||
* pass through untouched when pseudonymization is off.
|
||||
* visibility which surface the label is for
|
||||
*
|
||||
* revealMapping=true widens the pseudonym to "Pseudonym (real)" — the admin
|
||||
* opt-in that lets participants see the mapping; with it off, the real name
|
||||
* never crosses the wire for a pseudonymized surface.
|
||||
*/
|
||||
async displayFor(input: {
|
||||
workspaceId: string;
|
||||
userId: string;
|
||||
realName: string;
|
||||
visibility: ChatVisibility;
|
||||
isGroupMember?: boolean;
|
||||
}): Promise<string> {
|
||||
const settings = await this.getSettings(input.workspaceId);
|
||||
return applyChatDisplay(settings, { ...input, isGroupMember: input.isGroupMember ?? false });
|
||||
}
|
||||
}
|
||||
|
||||
/** Pure application of the policy — the unit-test surface, no DB. */
|
||||
export function applyChatDisplay(
|
||||
settings: ChatDisplaySettings,
|
||||
input: { userId: string; workspaceId: string; realName: string; visibility: ChatVisibility; isGroupMember: boolean },
|
||||
): string {
|
||||
const pseudonymize = input.visibility === "public" ? settings.pseudonymizePublic : settings.pseudonymizePrivate;
|
||||
if (!pseudonymize) {
|
||||
// Real-name surface. A group member in a public chat still carries their
|
||||
// real name, so the plain realName is exactly right here.
|
||||
return input.realName;
|
||||
}
|
||||
const pseudonym = pseudonymFor(input.userId, input.workspaceId);
|
||||
if (!settings.revealMapping) return pseudonym;
|
||||
return `${pseudonym} (${input.realName})`;
|
||||
}
|
||||
|
||||
/** Decode rows (or an empty list) into the settings object; unknown rows fall back. */
|
||||
export function decodeChatDisplay(rows: readonly (readonly [string, string])[]): ChatDisplaySettingsResponse {
|
||||
const map = new Map(rows);
|
||||
return {
|
||||
pseudonymizePublic: decodeBoolean(map, "chat.pseudonymizePublic", DEFAULT_CHAT_DISPLAY.pseudonymizePublic),
|
||||
pseudonymizePrivate: decodeBoolean(map, "chat.pseudonymizePrivate", DEFAULT_CHAT_DISPLAY.pseudonymizePrivate),
|
||||
revealMapping: decodeBoolean(map, "chat.revealMapping", DEFAULT_CHAT_DISPLAY.revealMapping),
|
||||
pending: CHAT_SETTING_KEYS.filter((key) => !map.has(key)),
|
||||
};
|
||||
}
|
||||
|
||||
function decodeBoolean(map: Map<string, string>, key: ChatSettingKey, fallback: boolean): boolean {
|
||||
const value = map.get(key);
|
||||
if (value === "true") return true;
|
||||
if (value === "false") return false;
|
||||
return fallback; // absent or (impossible per 037 CHECK) undecodable
|
||||
}
|
||||
|
||||
import { BadRequestException } from "@nestjs/common";
|
||||
|
||||
/** Strict write-side validation (unknown keys are a 400, never dropped). */
|
||||
export function validateChatDisplayPatch(patch: Record<string, unknown>): Partial<Record<ChatSettingKey, boolean>> {
|
||||
const out: Partial<Record<ChatSettingKey, boolean>> = {};
|
||||
let count = 0;
|
||||
for (const [key, value] of Object.entries(patch)) {
|
||||
if (!(CHAT_SETTING_KEYS as readonly string[]).includes(key)) {
|
||||
throw new BadRequestException(`unknown setting: ${key} (allowed: ${CHAT_SETTING_KEYS.join(", ")})`);
|
||||
}
|
||||
if (typeof value !== "boolean") throw new BadRequestException(`${key} must be a boolean`);
|
||||
out[key as ChatSettingKey] = value;
|
||||
count += 1;
|
||||
}
|
||||
if (count === 0) throw new BadRequestException("body must set at least one setting");
|
||||
return out;
|
||||
}
|
||||
83
apps/api/src/matrix-sso/mxid-display.ts
Normal file
83
apps/api/src/matrix-sso/mxid-display.ts
Normal file
|
|
@ -0,0 +1,83 @@
|
|||
/**
|
||||
* MXID → Hub display name.
|
||||
*
|
||||
* The Hub mints Matrix identities for its own users as
|
||||
* `@<prefix><username>:<server_name>` (matrixUserIdFor in matrix-sso.config.ts,
|
||||
* prefix `hub_` by default). Element renders MXIDs with its own local
|
||||
* displayname logic — the Hub cannot change what Element shows for a sender
|
||||
* — but EVERY surface the Hub itself renders (the chat page's identity notice,
|
||||
* live-chat sender labels, group-files attribution) must show the short,
|
||||
* human form `@glassbeadian`, never the full `@hub_glassbeadian:chat.occupymedia.cc`.
|
||||
*
|
||||
* This module is the single formatter for that rule. It is deliberately
|
||||
* dependency-free and shared by api and web (a copy lives under
|
||||
* apps/web/src/lib so the client never imports server code).
|
||||
*/
|
||||
|
||||
/** Splits an MXID into localpart and server name; null when not an MXID. */
|
||||
function parseMxid(mxid: string): { localpart: string; server: string } | null {
|
||||
const match = /^@([^:@\s]+):([^@\s]+)$/.exec(mxid);
|
||||
return match === null ? null : { localpart: match[1]!, server: match[2]! };
|
||||
}
|
||||
|
||||
/** The Matrix localpart prefix the Hub mints Hub usernames with. */
|
||||
export function hubPrefix(env: Readonly<Record<string, string | undefined>> = process.env): string {
|
||||
const raw = (env.MATRIX_SSO_LOCALPART_PREFIX ?? "").trim();
|
||||
if (raw === "none") return "";
|
||||
return raw === "" ? "hub_" : raw;
|
||||
}
|
||||
|
||||
/**
|
||||
* `@hub_glassbeadian:chat.occupymedia.cc` → `@glassbeadian`.
|
||||
*
|
||||
* Rules, in order:
|
||||
* 1. Not an MXID (no @, or malformed) → unchanged. Callers pass provider
|
||||
* handles and display names through the same field; a Twitch login or a
|
||||
* plain name is not ours to rewrite.
|
||||
* 2. Our prefix + our server name → strip BOTH. `@hub_ada:chat.occupymedia.cc`
|
||||
* becomes `@ada` — the Hub username form the owner asked for.
|
||||
* 3. Foreign MXID (server name differs) → localpart only, server kept in the
|
||||
* caller's title attribute when it wants it; the home-server suffix is
|
||||
* noise for a member reading a chat.
|
||||
*/
|
||||
export function displayMxid(mxid: string, env: Readonly<Record<string, string | undefined>> = process.env): string {
|
||||
const parsed = parseMxid(mxid);
|
||||
if (parsed === null) return mxid;
|
||||
const prefix = hubPrefix(env);
|
||||
const config = loadServerName(env);
|
||||
if (config !== null && parsed.server === config) {
|
||||
return `@${prefix === "" ? parsed.localpart : stripPrefix(parsed.localpart, prefix)}`;
|
||||
}
|
||||
return `@${stripPrefix(parsed.localpart, prefix)}`;
|
||||
}
|
||||
|
||||
/** `hub_glassbeadian` → `glassbeadian`; a localpart without the prefix is kept. */
|
||||
function stripPrefix(localpart: string, prefix: string): string {
|
||||
return prefix !== "" && localpart.startsWith(prefix) ? localpart.slice(prefix.length) : localpart;
|
||||
}
|
||||
|
||||
/**
|
||||
* The Hub username behind a Hub-minted MXID (no leading @), or null when the
|
||||
* MXID is not one of ours. Used where the caller wants the bare username.
|
||||
*/
|
||||
export function hubUsernameFor(mxid: string, env: Readonly<Record<string, string | undefined>> = process.env): string | null {
|
||||
const parsed = parseMxid(mxid);
|
||||
const server = loadServerName(env);
|
||||
if (parsed === null || server === null || parsed.server !== server) return null;
|
||||
const prefix = hubPrefix(env);
|
||||
if (prefix !== "" && !parsed.localpart.startsWith(prefix)) return null;
|
||||
return stripPrefix(parsed.localpart, prefix);
|
||||
}
|
||||
|
||||
function loadServerName(env: Readonly<Record<string, string | undefined>>): string | null {
|
||||
const homeserver = (env.MATRIX_SSO_HOMESERVER_URL ?? env.ARCHIVE_PROVISIONER_HOMESERVER_URL ?? "").trim();
|
||||
if (homeserver !== "") {
|
||||
try {
|
||||
return new URL(homeserver).hostname;
|
||||
} catch {
|
||||
/* fall through to the explicit name below */
|
||||
}
|
||||
}
|
||||
const named = (env.MATRIX_SSO_SERVER_NAME ?? "").trim();
|
||||
return named === "" ? null : named;
|
||||
}
|
||||
209
apps/api/src/settings/branding.controller.ts
Normal file
209
apps/api/src/settings/branding.controller.ts
Normal file
|
|
@ -0,0 +1,209 @@
|
|||
/**
|
||||
* Workspace (collective) branding API:
|
||||
*
|
||||
* GET /workspace/branding any active member — the shell boot read
|
||||
* PUT /workspace/branding workspace.members.manage — set/clear fields
|
||||
* POST /workspace/branding/logo workspace.members.manage — multipart upload, returns a key
|
||||
* POST /workspace/branding/background workspace.members.manage — multipart upload, returns a key
|
||||
*
|
||||
* Public seam (no session — the same "narrow, pre-auth metadata" shape as
|
||||
* /collectives-public/for-invite):
|
||||
*
|
||||
* GET /public/branding?workspaceId=<uuid> name / logo / tagline / accent only
|
||||
* GET /public/branding/asset?key=<storage key> scoped, decrypted read of a branding image
|
||||
*
|
||||
* The asset read mirrors the video-thumbnail pattern: the API proxies the
|
||||
* decrypted bytes through its own origin (encrypted S3 objects are never
|
||||
* redirected to), sets a bounded cache and nosniff, and refuses keys that are
|
||||
* not content-addressed sha256/… objects of image type.
|
||||
*/
|
||||
import {
|
||||
BadRequestException,
|
||||
Body,
|
||||
Controller,
|
||||
ForbiddenException,
|
||||
Get,
|
||||
HttpCode,
|
||||
Inject,
|
||||
Post,
|
||||
Put,
|
||||
Query,
|
||||
Req,
|
||||
Res,
|
||||
UploadedFile,
|
||||
UseGuards,
|
||||
UseInterceptors,
|
||||
} from "@nestjs/common";
|
||||
import { FileInterceptor } from "@nestjs/platform-express";
|
||||
import type { Request, Response } from "express";
|
||||
import { isUuid, optionalString, requireObject, requireSession, requireString } from "../http/validation.js";
|
||||
import { RbacGuard, RequirePermission, type AuthenticatedRequest } from "../rbac/rbac.guard.js";
|
||||
import {
|
||||
BrandingService,
|
||||
brandingAssetPath,
|
||||
type BrandingPatch,
|
||||
type BrandingRecord,
|
||||
type PublicBranding,
|
||||
} from "./branding.service.js";
|
||||
|
||||
interface UploadFile {
|
||||
buffer: Buffer;
|
||||
mimetype: string;
|
||||
}
|
||||
|
||||
const FILE_LIMITS = {
|
||||
// The branding service re-checks the cap after sniffing (magic bytes decide
|
||||
// the type); this just bounds the buffered multipart request.
|
||||
fileSize: 4 * 1024 * 1024,
|
||||
files: 1,
|
||||
fields: 2,
|
||||
fieldSize: 1024,
|
||||
parts: 3,
|
||||
};
|
||||
|
||||
/** GET /workspace/branding — the shape the web shell boots from. */
|
||||
export interface WorkspaceBrandingResponse {
|
||||
workspace: { id: string; name: string };
|
||||
logoAssetKey: string | null;
|
||||
/** Scoped, same-origin URL the shell can put straight into an <img src>. */
|
||||
logoUrl: string | null;
|
||||
tagline: string | null;
|
||||
backgroundKey: string | null;
|
||||
backgroundUrl: string | null;
|
||||
accent: string | null;
|
||||
}
|
||||
|
||||
export interface BrandingUploadResult {
|
||||
key: string;
|
||||
contentType: string;
|
||||
sizeBytes: number;
|
||||
/** The ready-to-send body for PUT /workspace/branding. */
|
||||
patch: Partial<BrandingPatch>;
|
||||
assetUrl: string;
|
||||
}
|
||||
|
||||
@Controller("workspace")
|
||||
@UseGuards(RbacGuard)
|
||||
export class BrandingController {
|
||||
constructor(@Inject(BrandingService) private readonly branding: BrandingService) {}
|
||||
|
||||
/** The shell boot read — every active member, no permission beyond the session. */
|
||||
@Get("branding")
|
||||
@RequirePermission("workspace.view")
|
||||
async get(@Req() request: Request & AuthenticatedRequest): Promise<WorkspaceBrandingResponse> {
|
||||
const session = requireSession(request);
|
||||
const record = await this.branding.getBranding(session.workspaceId);
|
||||
return {
|
||||
workspace: { id: session.workspaceId, name: record.name },
|
||||
logoAssetKey: record.logoAssetKey,
|
||||
logoUrl: record.logoUrl,
|
||||
tagline: record.tagline,
|
||||
backgroundKey: record.backgroundKey,
|
||||
backgroundUrl: record.backgroundUrl,
|
||||
accent: record.accent,
|
||||
};
|
||||
}
|
||||
|
||||
/** Partial set/clear. `null` clears a field back to the Hub default. */
|
||||
@Put("branding")
|
||||
@RequirePermission("workspace.members.manage")
|
||||
async put(@Req() request: Request & AuthenticatedRequest, @Body() body: unknown): Promise<BrandingRecord> {
|
||||
const session = requireSession(request);
|
||||
const dto = requireObject(body, "body");
|
||||
const patch: BrandingPatch = {};
|
||||
for (const key of ["logoAssetKey", "tagline", "backgroundKey", "accent"] as const) {
|
||||
if (!(key in dto)) continue;
|
||||
const value = dto[key];
|
||||
if (value === null) {
|
||||
(patch as Record<string, unknown>)[key] = null;
|
||||
} else {
|
||||
(patch as Record<string, unknown>)[key] = optionalString(value, key);
|
||||
}
|
||||
}
|
||||
return this.branding.updateBranding(session.workspaceId, patch, { userId: session.userId, role: session.role });
|
||||
}
|
||||
|
||||
@Post("branding/logo")
|
||||
@RequirePermission("workspace.members.manage")
|
||||
@UseInterceptors(FileInterceptor("file", { limits: FILE_LIMITS }))
|
||||
async logo(@Req() request: Request & AuthenticatedRequest, @UploadedFile() file?: UploadFile): Promise<BrandingUploadResult> {
|
||||
return this.upload(request, file, "logo");
|
||||
}
|
||||
|
||||
@Post("branding/background")
|
||||
@RequirePermission("workspace.members.manage")
|
||||
@UseInterceptors(FileInterceptor("file", { limits: FILE_LIMITS }))
|
||||
async background(@Req() request: Request & AuthenticatedRequest, @UploadedFile() file?: UploadFile): Promise<BrandingUploadResult> {
|
||||
return this.upload(request, file, "background");
|
||||
}
|
||||
|
||||
private async upload(
|
||||
request: Request & AuthenticatedRequest,
|
||||
file: UploadFile | undefined,
|
||||
slot: "logo" | "background",
|
||||
): Promise<BrandingUploadResult> {
|
||||
requireSession(request);
|
||||
if (file === undefined) throw new BadRequestException(`an image file is required (field: file) for the ${slot}`);
|
||||
const stored = await this.branding.uploadImage(file.buffer, file.mimetype ?? "");
|
||||
return {
|
||||
key: stored.key,
|
||||
contentType: stored.contentType,
|
||||
sizeBytes: stored.sizeBytes,
|
||||
patch: { [slot === "logo" ? "logoAssetKey" : "backgroundKey"]: stored.key },
|
||||
assetUrl: brandingAssetPath(stored.key),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Public branding for pre-auth pages (join screens, screenshots, embeds). Only
|
||||
* the LOOK is exposed — name, slug, logo URL, tagline, accent — no member
|
||||
* counts, no roles, no room ids, and the lookup needs an exact workspace id or
|
||||
* slug, so it cannot be used to enumerate tenants.
|
||||
*/
|
||||
@Controller("public/branding")
|
||||
export class PublicBrandingController {
|
||||
constructor(@Inject(BrandingService) private readonly branding: BrandingService) {}
|
||||
|
||||
@Get()
|
||||
@HttpCode(200)
|
||||
async publicBranding(
|
||||
@Query("workspaceId") workspaceId: string | undefined,
|
||||
@Query("slug") slug: string | undefined,
|
||||
@Res({ passthrough: true }) response: Response,
|
||||
): Promise<PublicBranding> {
|
||||
const identifier = (workspaceId ?? slug ?? "").trim();
|
||||
if (identifier === "") throw new BadRequestException("workspaceId or slug is required");
|
||||
if (workspaceId !== undefined && workspaceId !== "" && !isUuid(workspaceId)) {
|
||||
throw new BadRequestException("workspaceId must be a UUID");
|
||||
}
|
||||
response.setHeader("Cache-Control", "public, max-age=60");
|
||||
return this.branding.getPublicBranding(requireString(identifier, "workspaceId"));
|
||||
}
|
||||
|
||||
/**
|
||||
* Scoped image read: the same-origin, no-redirect proxy the video thumbnail
|
||||
* route uses. Content-addressed key ⇒ immutable bytes ⇒ a day-long cache,
|
||||
* nosniff, and only ever an image content type.
|
||||
*/
|
||||
@Get("asset")
|
||||
@HttpCode(200)
|
||||
async asset(@Query("key") key: string | undefined, @Req() request: Request, @Res() response: Response): Promise<void> {
|
||||
if (key === undefined || key === "") throw new BadRequestException("key is required");
|
||||
const origin = request.headers.origin;
|
||||
const ownOrigin = process.env.PUBLIC_ORIGIN ?? `${request.protocol}://${request.get("host")}`;
|
||||
if (origin !== undefined && origin !== ownOrigin) throw new ForbiddenException("cross-origin branding asset denied");
|
||||
const asset = await this.branding.readAsset(key);
|
||||
const etag = `"${key}"`;
|
||||
response.setHeader("ETag", etag);
|
||||
response.setHeader("Cache-Control", "public, max-age=86400, immutable");
|
||||
response.setHeader("Cross-Origin-Resource-Policy", "cross-origin");
|
||||
response.setHeader("X-Content-Type-Options", "nosniff");
|
||||
const match = request.headers["if-none-match"];
|
||||
if (match === etag || match === "*" || (typeof match === "string" && match.split(/,\s*/).includes(etag))) {
|
||||
response.status(304).end();
|
||||
return;
|
||||
}
|
||||
response.type(asset.contentType).send(asset.bytes);
|
||||
}
|
||||
}
|
||||
339
apps/api/src/settings/branding.service.ts
Normal file
339
apps/api/src/settings/branding.service.ts
Normal file
|
|
@ -0,0 +1,339 @@
|
|||
/**
|
||||
* Per-workspace (per-collective) branding: logo, tagline, background, accent.
|
||||
*
|
||||
* Storage of record is `workspaces.branding` (migration 036) — one nullable
|
||||
* jsonb. The four known keys are exactly the fields the web shell renders;
|
||||
* NULL/absent means "the Hub default".
|
||||
*
|
||||
* This module is the ONE place that interprets that column:
|
||||
* * decode() never throws — an unknown/malformed row degrades to defaults,
|
||||
* because branding failure must never break the shell at boot;
|
||||
* * validatePatch() is strict the other way: an admin write with a wrong
|
||||
* shape is a 400, never a silent drop;
|
||||
* * asset keys are verified against the storage service before they are
|
||||
* accepted, so branding cannot point at an object nobody can read.
|
||||
*
|
||||
* Accent is restricted to a 3/6-digit hex literal (#RRGGBB / #RGB). It is
|
||||
* emitted as a CSS color by the web shell; hex-only keeps injection,
|
||||
* `url()` tricks and CSS-wide keywords (`inherit`, `unset`) out of the value.
|
||||
*/
|
||||
|
||||
import { BadRequestException, Inject, Injectable, Optional, ServiceUnavailableException } from "@nestjs/common";
|
||||
import type { PoolClient } from "pg";
|
||||
import { withSystem, withTenant } from "../db/client.js";
|
||||
import { appendTenantAudit } from "../audit/audit.service.js";
|
||||
import { S3Storage, s3ConfigFromEnv, type StorageService } from "../storage/storage.js";
|
||||
|
||||
export const BRANDING_DEPENDENCIES = Symbol("BRANDING_DEPENDENCIES");
|
||||
|
||||
/** Bytes that may be uploaded as a logo or background. */
|
||||
export const BRANDING_IMAGE_TYPES = ["image/png", "image/jpeg", "image/webp", "image/gif"] as const;
|
||||
export type BrandingImageType = (typeof BRANDING_IMAGE_TYPES)[number];
|
||||
|
||||
/** Hard cap: the shell renders these small; a big upload buys nothing. */
|
||||
export const MAX_BRANDING_IMAGE_BYTES = 4 * 1024 * 1024;
|
||||
|
||||
/** The four branding keys (mirrors migration 036's documented set). */
|
||||
export const BRANDING_KEYS = ["logoAssetKey", "tagline", "backgroundKey", "accent"] as const;
|
||||
export type BrandingKey = (typeof BRANDING_KEYS)[number];
|
||||
|
||||
export interface BrandingPatch {
|
||||
logoAssetKey?: string | null;
|
||||
tagline?: string | null;
|
||||
backgroundKey?: string | null;
|
||||
accent?: string | null;
|
||||
}
|
||||
|
||||
/** What the shell renders. Every field NULL = the Hub default. */
|
||||
export interface BrandingRecord {
|
||||
/** Storage key of the collective's mark (sha256/<hex>.<ext>). */
|
||||
logoAssetKey: string | null;
|
||||
/** One-line subtitle under the collective name. */
|
||||
tagline: string | null;
|
||||
/** Storage key of the shell background image. */
|
||||
backgroundKey: string | null;
|
||||
/** CSS hex color for the accent, e.g. "#2E7D32". */
|
||||
accent: string | null;
|
||||
}
|
||||
|
||||
/** Minimal, PUBLIC shape for pre-auth pages: name + look, nothing else. */
|
||||
export interface PublicBranding {
|
||||
workspaceId: string;
|
||||
name: string;
|
||||
slug: string;
|
||||
tagline: string | null;
|
||||
logoUrl: string | null;
|
||||
accent: string | null;
|
||||
}
|
||||
|
||||
export interface BrandingActor {
|
||||
userId: string;
|
||||
role: string;
|
||||
}
|
||||
|
||||
export interface BrandingDependencies {
|
||||
storage: StorageService;
|
||||
now: () => Date;
|
||||
/** Signs a read URL for an asset key (same pattern as video thumbnails). */
|
||||
signAssetUrl: (key: string) => string | null;
|
||||
}
|
||||
|
||||
/** The zero-branding record — every field the default. */
|
||||
export const DEFAULT_BRANDING: BrandingRecord = { logoAssetKey: null, tagline: null, backgroundKey: null, accent: null };
|
||||
|
||||
const STORAGE_KEY = /^sha256\/[0-9a-f]{64}\.[a-z0-9]+$/;
|
||||
const ACCENT_HEX = /^#(?:[0-9a-f]{3}|[0-9a-f]{6})$/;
|
||||
const MAX_TAGLINE = 160;
|
||||
|
||||
export function isHexAccent(value: string): boolean {
|
||||
return ACCENT_HEX.test(value);
|
||||
}
|
||||
|
||||
/** URL path of the scoped read route for a branding asset. */
|
||||
export function brandingAssetPath(key: string): string {
|
||||
return `/public/branding/asset?key=${encodeURIComponent(key)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Decode a raw jsonb value. Tolerant by design: wrong shapes, unknown keys and
|
||||
* oversized strings all fall back to the default for that field — a corrupt
|
||||
* row must not take the shell down.
|
||||
*/
|
||||
export function decodeBranding(value: unknown): BrandingRecord {
|
||||
if (typeof value !== "object" || value === null || Array.isArray(value)) return { ...DEFAULT_BRANDING };
|
||||
const raw = value as Record<string, unknown>;
|
||||
return {
|
||||
logoAssetKey: optionalKey(raw.logoAssetKey),
|
||||
tagline: optionalTagline(raw.tagline),
|
||||
backgroundKey: optionalKey(raw.backgroundKey),
|
||||
accent: optionalAccent(raw.accent),
|
||||
};
|
||||
}
|
||||
|
||||
function optionalKey(value: unknown): string | null {
|
||||
return typeof value === "string" && STORAGE_KEY.test(value) ? value : null;
|
||||
}
|
||||
|
||||
function optionalTagline(value: unknown): string | null {
|
||||
if (typeof value !== "string") return null;
|
||||
const trimmed = value.trim();
|
||||
return trimmed === "" ? null : trimmed.slice(0, MAX_TAGLINE);
|
||||
}
|
||||
|
||||
function optionalAccent(value: unknown): string | null {
|
||||
return typeof value === "string" && ACCENT_HEX.test(value) ? value.toLowerCase() : null;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class BrandingService {
|
||||
private readonly deps: BrandingDependencies;
|
||||
private readonly storage?: StorageService;
|
||||
|
||||
constructor(@Optional() @Inject(BRANDING_DEPENDENCIES) dependencies?: Partial<BrandingDependencies>) {
|
||||
this.deps = {
|
||||
storage: dependencies?.storage ?? new S3Storage(s3ConfigFromEnv()),
|
||||
now: dependencies?.now ?? (() => new Date()),
|
||||
signAssetUrl: dependencies?.signAssetUrl ?? ((key) => brandingAssetPath(key)),
|
||||
};
|
||||
this.storage = this.deps.storage;
|
||||
}
|
||||
|
||||
/** Full branding for any ACTIVE member of the workspace (shell boot read). */
|
||||
async getBranding(workspaceId: string): Promise<BrandingRecord & { name: string; logoUrl: string | null; backgroundUrl: string | null }> {
|
||||
const row = await this.readRow(workspaceId);
|
||||
const branding = decodeBranding(row?.branding);
|
||||
return {
|
||||
...branding,
|
||||
name: row?.name ?? "",
|
||||
logoUrl: branding.logoAssetKey === null ? null : this.deps.signAssetUrl(branding.logoAssetKey),
|
||||
backgroundUrl: branding.backgroundKey === null ? null : this.deps.signAssetUrl(branding.backgroundKey),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* PUBLIC branding (name/logo/tagline/accent) for pre-auth pages keyed by
|
||||
* workspace id or slug. No membership, no role: this is what a join page or
|
||||
* a screenshot shows BEFORE a session exists. The lookup is by exact
|
||||
* identifier, so it cannot be used to enumerate workspaces.
|
||||
*/
|
||||
async getPublicBranding(identifier: string): Promise<PublicBranding> {
|
||||
const row = await this.readRow(identifier);
|
||||
if (row === undefined) throw new BadRequestException("unknown workspace");
|
||||
const branding = decodeBranding(row.branding);
|
||||
return {
|
||||
workspaceId: row.id,
|
||||
name: row.name,
|
||||
slug: row.slug,
|
||||
tagline: branding.tagline,
|
||||
logoUrl: branding.logoAssetKey === null ? null : this.deps.signAssetUrl(branding.logoAssetKey),
|
||||
accent: branding.accent,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate + store an image and return its storage key. Reuses the storage
|
||||
* service exactly like video uploads: content-addressed, envelope-encrypted,
|
||||
* deduplicated. The declared content type is never trusted — the magic bytes
|
||||
* decide (the group-files / submissions convention).
|
||||
*/
|
||||
async uploadImage(bytes: Buffer, declaredType: string): Promise<{ key: string; contentType: string; sizeBytes: number }> {
|
||||
const sniffed = sniffImageType(bytes);
|
||||
if (sniffed === null) {
|
||||
throw new BadRequestException(`unsupported image type (allowed: ${BRANDING_IMAGE_TYPES.join(", ")})`);
|
||||
}
|
||||
if (bytes.length === 0 || bytes.length > MAX_BRANDING_IMAGE_BYTES) {
|
||||
throw new BadRequestException(`image must be 1 byte to ${MAX_BRANDING_IMAGE_BYTES} bytes`);
|
||||
}
|
||||
if (declaredType !== "" && declaredType !== sniffed) {
|
||||
throw new BadRequestException("file content does not match the declared image type");
|
||||
}
|
||||
const storage = this.requireStorage();
|
||||
const stored = await storage.putContent(bytes, sniffed).catch(() => {
|
||||
throw new ServiceUnavailableException("branding storage unavailable: could not store the image in S3/MinIO");
|
||||
});
|
||||
return { key: stored.key, contentType: stored.contentType, sizeBytes: stored.sizeBytes };
|
||||
}
|
||||
|
||||
/**
|
||||
* Partial update of the branding JSON. `null` clears a field back to the
|
||||
* default; an empty patch is a 400. Writes land in ONE tenant transaction
|
||||
* with a tenant audit event (workspace.branding.updated) carrying the
|
||||
* before→after diff, so a visual identity change is provable.
|
||||
*/
|
||||
async updateBranding(workspaceId: string, patch: BrandingPatch, actor: BrandingActor): Promise<BrandingRecord> {
|
||||
const validated = validateBrandingPatch(patch);
|
||||
const before = decodeBranding((await this.readRow(workspaceId))?.branding);
|
||||
const after: BrandingRecord = { ...before };
|
||||
for (const key of BRANDING_KEYS) {
|
||||
const value = validated[key];
|
||||
if (value !== undefined) after[key] = value;
|
||||
}
|
||||
// Asset keys must point at a readable object: a logo nobody can fetch
|
||||
// would blank the shell mark for everyone.
|
||||
for (const key of [after.logoAssetKey, after.backgroundKey]) {
|
||||
if (key === null) continue;
|
||||
await this.requireReadable(key);
|
||||
}
|
||||
const json = brandingJson(after);
|
||||
const diff: Record<string, unknown> = {};
|
||||
for (const key of BRANDING_KEYS) {
|
||||
if (validated[key] !== undefined) diff[key] = { from: before[key], to: after[key] };
|
||||
}
|
||||
await withTenant(workspaceId, (client) =>
|
||||
client.query("UPDATE workspaces SET branding = $2 WHERE id = $1", [workspaceId, json]),
|
||||
);
|
||||
await appendTenantAudit(workspaceId, {
|
||||
actorUserId: actor.userId,
|
||||
actorRole: actor.role,
|
||||
action: "workspace.branding.updated",
|
||||
targetType: "workspace",
|
||||
targetId: workspaceId,
|
||||
diff,
|
||||
});
|
||||
return after;
|
||||
}
|
||||
|
||||
/** Fetch the bytes of a branding asset for the scoped read route. */
|
||||
async readAsset(key: string): Promise<{ bytes: Buffer; contentType: string }> {
|
||||
if (!STORAGE_KEY.test(key)) throw new BadRequestException("invalid branding asset key");
|
||||
const storage = this.requireStorage();
|
||||
const bytes = await storage.getObject(key).catch(() => {
|
||||
throw new ServiceUnavailableException("branding asset unavailable");
|
||||
});
|
||||
if (bytes.length > MAX_BRANDING_IMAGE_BYTES) throw new BadRequestException("branding asset too large");
|
||||
return { bytes, contentType: contentTypeForKey(key) };
|
||||
}
|
||||
|
||||
private async readRow(workspaceId: string): Promise<{ id: string; name: string; slug: string; branding: unknown } | undefined> {
|
||||
// `workspaces` is not tenant-scoped (no RLS, 001) — read via the system
|
||||
// pool; the caller's authority was established by the RbacGuard.
|
||||
const result = await withSystem((client) =>
|
||||
client.query<{ id: string; name: string; slug: string; branding: unknown }>(
|
||||
"SELECT id, name, slug, branding FROM workspaces WHERE id::text = $1 OR slug = $1 LIMIT 1",
|
||||
[workspaceId],
|
||||
),
|
||||
);
|
||||
return result.rows[0];
|
||||
}
|
||||
|
||||
private async requireReadable(key: string): Promise<void> {
|
||||
const storage = this.requireStorage();
|
||||
const bytes = await storage.getObject(key).catch(() => {
|
||||
throw new BadRequestException("asset key must identify an existing readable encrypted object; upload the image first");
|
||||
});
|
||||
if (bytes.length === 0 || bytes.length > MAX_BRANDING_IMAGE_BYTES) {
|
||||
throw new BadRequestException("branding image exceeds the size cap");
|
||||
}
|
||||
}
|
||||
|
||||
private requireStorage(): StorageService {
|
||||
if (this.storage === undefined) {
|
||||
throw new ServiceUnavailableException("branding storage is not configured: set S3_ACCESS_KEY and S3_SECRET_KEY, plus S3_ENDPOINT for MinIO");
|
||||
}
|
||||
return this.storage;
|
||||
}
|
||||
}
|
||||
|
||||
/** Strict write-side validation (the tolerant side is decodeBranding). */
|
||||
export function validateBrandingPatch(patch: BrandingPatch): BrandingPatch {
|
||||
if (typeof patch !== "object" || patch === null || Array.isArray(patch)) {
|
||||
throw new BadRequestException("body must be an object");
|
||||
}
|
||||
const out: BrandingPatch = {};
|
||||
let count = 0;
|
||||
for (const [key, value] of Object.entries(patch)) {
|
||||
if (!(BRANDING_KEYS as readonly string[]).includes(key)) {
|
||||
throw new BadRequestException(`unknown branding field: ${key} (allowed: ${BRANDING_KEYS.join(", ")})`);
|
||||
}
|
||||
if (value === null) {
|
||||
(out as Record<string, unknown>)[key] = null;
|
||||
count += 1;
|
||||
continue;
|
||||
}
|
||||
if (typeof value !== "string") throw new BadRequestException(`${key} must be a string or null`);
|
||||
if (key === "accent") {
|
||||
if (!ACCENT_HEX.test(value)) throw new BadRequestException("accent must be a hex color like #2E7D32 or #2E7");
|
||||
(out as Record<string, unknown>)[key] = value.toLowerCase();
|
||||
} else if (key === "tagline") {
|
||||
const trimmed = value.trim();
|
||||
if (trimmed === "") throw new BadRequestException("tagline cannot be empty — send null to clear it");
|
||||
if (trimmed.length > MAX_TAGLINE) throw new BadRequestException(`tagline must be at most ${MAX_TAGLINE} characters`);
|
||||
(out as Record<string, unknown>)[key] = trimmed;
|
||||
} else if (!STORAGE_KEY.test(value)) {
|
||||
throw new BadRequestException(`${key} must be a storage key from POST /workspace/branding/logo (sha256/<hash>.<ext>)`);
|
||||
}
|
||||
count += 1;
|
||||
}
|
||||
if (count === 0) throw new BadRequestException("body must set at least one branding field");
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Encode a record back to the stored jsonb (only non-default fields kept). */
|
||||
export function brandingJson(record: BrandingRecord): Record<string, string> | null {
|
||||
const json: Record<string, string> = {};
|
||||
for (const key of BRANDING_KEYS) {
|
||||
const value = record[key];
|
||||
if (value !== null) json[key] = value;
|
||||
}
|
||||
return Object.keys(json).length === 0 ? null : json;
|
||||
}
|
||||
|
||||
function contentTypeForKey(key: string): string {
|
||||
const extension = key.split(".")[1] ?? "";
|
||||
const types: Record<string, string> = { png: "image/png", jpg: "image/jpeg", webp: "image/webp", gif: "image/gif" };
|
||||
return types[extension] ?? "application/octet-stream";
|
||||
}
|
||||
|
||||
/** Magic-byte sniffing for the image types branding accepts (declared type never trusted). */
|
||||
function sniffImageType(bytes: Buffer): BrandingImageType | null {
|
||||
if (bytes.length < 12) return null;
|
||||
if (
|
||||
bytes[0] === 0x89 && bytes[1] === 0x50 && bytes[2] === 0x4e && bytes[3] === 0x47 &&
|
||||
bytes[4] === 0x0d && bytes[5] === 0x0a && bytes[6] === 0x1a && bytes[7] === 0x0a
|
||||
) return "image/png";
|
||||
if (bytes[0] === 0xff && bytes[1] === 0xd8 && bytes[2] === 0xff) return "image/jpeg";
|
||||
if (bytes.subarray(0, 4).toString("latin1") === "RIFF" && bytes.subarray(8, 12).toString("latin1") === "WEBP") return "image/webp";
|
||||
const gif = bytes.subarray(0, 6).toString("latin1");
|
||||
if (gif === "GIF87a" || gif === "GIF89a") return "image/gif";
|
||||
return null;
|
||||
}
|
||||
439
apps/api/src/settings/branding.test.ts
Normal file
439
apps/api/src/settings/branding.test.ts
Normal file
|
|
@ -0,0 +1,439 @@
|
|||
import { describe, expect, it, vi } from "vitest";
|
||||
import { BadRequestException, ServiceUnavailableException } from "@nestjs/common";
|
||||
import type { PoolClient } from "pg";
|
||||
import {
|
||||
BRANDING_KEYS,
|
||||
brandingAssetPath,
|
||||
brandingJson,
|
||||
BrandingService,
|
||||
decodeBranding,
|
||||
DEFAULT_BRANDING,
|
||||
isHexAccent,
|
||||
validateBrandingPatch,
|
||||
type BrandingDependencies,
|
||||
} from "./branding.service.js";
|
||||
import { BrandingController } from "./branding.controller.js";
|
||||
import { appendTenantAudit } from "../audit/audit.service.js";
|
||||
import { withSystem, withTenant } from "../db/client.js";
|
||||
|
||||
/**
|
||||
* Unit level: decode tolerance, write-side strictness, upload sniffing, the
|
||||
* storage-key guard, the actor/audit contract — with the DB and S3 faked.
|
||||
* Live-DB CRUD + migration coverage is branding.integration.test.ts.
|
||||
*/
|
||||
|
||||
vi.mock("../db/client.js", () => ({
|
||||
withSystem: vi.fn(),
|
||||
withTenant: vi.fn(),
|
||||
}));
|
||||
vi.mock("../audit/audit.service.js", () => ({ appendTenantAudit: vi.fn(async () => "audit-id") }));
|
||||
|
||||
const PNG_HEADER = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00]);
|
||||
const JPEG_HEADER = Buffer.concat([Buffer.from([0xff, 0xd8, 0xff]), Buffer.alloc(16, 0x00)]);
|
||||
const FAKE_KEY = "sha256/0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef.png";
|
||||
const FAKE_KEY_2 = "sha256/fedcba9876543210fedcba9876543210fedcba9876543210fedcba9876543210.jpg";
|
||||
|
||||
function fakeStorage(objects: Record<string, Buffer> = {}): {
|
||||
storage: BrandingDependencies["storage"];
|
||||
puts: Array<{ bytes: Buffer; contentType: string }>;
|
||||
} {
|
||||
const puts: Array<{ bytes: Buffer; contentType: string }> = [];
|
||||
const storage = {
|
||||
putContent: vi.fn(async (bytes: Buffer, contentType: string) => {
|
||||
puts.push({ bytes, contentType });
|
||||
const hash = `hash${puts.length}`.padEnd(64, "0");
|
||||
return { key: `sha256/${hash}.${contentType.split("/")[1] === "jpeg" ? "jpg" : contentType.split("/")[1]}`, hash, sizeBytes: bytes.length, contentType, deduplicated: false };
|
||||
}),
|
||||
getObject: vi.fn(async (key: string) => {
|
||||
const body = objects[key];
|
||||
if (body === undefined) throw new Error("missing");
|
||||
return body;
|
||||
}),
|
||||
hasContent: vi.fn(async () => false),
|
||||
presign: vi.fn(async () => ""),
|
||||
};
|
||||
return { storage, puts };
|
||||
}
|
||||
|
||||
function serviceWith(deps: Partial<BrandingDependencies> = {}): BrandingService {
|
||||
const { storage } = fakeStorage();
|
||||
return new BrandingService({
|
||||
storage: deps.storage ?? storage,
|
||||
now: () => new Date("2026-10-14T12:00:00Z"),
|
||||
signAssetUrl: deps.signAssetUrl ?? ((key) => brandingAssetPath(key)),
|
||||
});
|
||||
}
|
||||
|
||||
/** Point the mocked withSystem/withTenant at a canned workspaces row. */
|
||||
function stubWorkspace(row: { id: string; name: string; slug: string; branding: unknown } | undefined): void {
|
||||
const query = vi.fn(async () => ({ rows: row === undefined ? [] : [row], rowCount: row === undefined ? 0 : 1 }));
|
||||
const client = { query } as unknown as PoolClient;
|
||||
vi.mocked(withSystem).mockImplementation(async (fn) => fn(client));
|
||||
vi.mocked(withTenant).mockImplementation(async (fn) => fn(client));
|
||||
}
|
||||
|
||||
const WS = "00000000-0000-4000-8000-00000000000a";
|
||||
const ACTOR = { userId: "00000000-0000-4000-8000-00000000000b", role: "Admin" };
|
||||
|
||||
describe("branding — decode tolerance (the shell must never break)", () => {
|
||||
it("null / absent branding decodes to all-defaults", () => {
|
||||
expect(decodeBranding(null)).toEqual(DEFAULT_BRANDING);
|
||||
expect(decodeBranding(undefined)).toEqual(DEFAULT_BRANDING);
|
||||
});
|
||||
|
||||
it("the four known keys round-trip", () => {
|
||||
const branding = { logoAssetKey: FAKE_KEY, tagline: "Antifascista siempre", backgroundKey: FAKE_KEY_2, accent: "#2E7D32" };
|
||||
expect(decodeBranding(branding)).toEqual(branding);
|
||||
});
|
||||
|
||||
it("malformed values degrade field-by-field, never throw", () => {
|
||||
const bad = {
|
||||
logoAssetKey: "../../etc/passwd",
|
||||
tagline: 42,
|
||||
backgroundKey: { nested: true },
|
||||
accent: "javascript:alert(1)",
|
||||
unknown: "ignored",
|
||||
};
|
||||
expect(decodeBranding(bad)).toEqual(DEFAULT_BRANDING);
|
||||
expect(decodeBranding("nope")).toEqual(DEFAULT_BRANDING);
|
||||
expect(decodeBranding([1, 2, 3])).toEqual(DEFAULT_BRANDING);
|
||||
});
|
||||
|
||||
it("accent is normalized to lowercase; tagline is trimmed and capped", () => {
|
||||
expect(decodeBranding({ accent: "#2E7D32" }).accent).toBe("#2e7d32");
|
||||
expect(decodeBranding({ accent: "#abc" }).accent).toBe("#abc");
|
||||
expect(decodeBranding({ tagline: " newsroom " }).tagline).toBe("newsroom");
|
||||
expect(decodeBranding({ tagline: `x`.repeat(400) }).tagline).toHaveLength(160);
|
||||
expect(decodeBranding({ tagline: " " }).tagline).toBeNull();
|
||||
});
|
||||
|
||||
it("storage keys must be content-addressed sha256 objects", () => {
|
||||
expect(decodeBranding({ logoAssetKey: "sha256/zz.png" }).logoAssetKey).toBeNull();
|
||||
expect(decodeBranding({ logoAssetKey: "logo.png" }).logoAssetKey).toBeNull();
|
||||
expect(decodeBranding({ logoAssetKey: "http://evil/x.png" }).logoAssetKey).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("branding — write-side validation (strict)", () => {
|
||||
it("rejects non-object bodies and empty patches", () => {
|
||||
for (const bad of [undefined, null, "x", 42, [], true]) {
|
||||
expect(() => validateBrandingPatch(bad as never)).toThrow(BadRequestException);
|
||||
}
|
||||
expect(() => validateBrandingPatch({})).toThrow(/at least one/);
|
||||
});
|
||||
|
||||
it("rejects unknown keys instead of dropping them", () => {
|
||||
expect(() => validateBrandingPatch({ favicon: FAKE_KEY })).toThrow(/unknown branding field/);
|
||||
});
|
||||
|
||||
it("null clears a field; a string sets it", () => {
|
||||
expect(validateBrandingPatch({ tagline: null })).toEqual({ tagline: null });
|
||||
expect(validateBrandingPatch({ tagline: " Radio libre " })).toEqual({ tagline: "Radio libre" });
|
||||
});
|
||||
|
||||
it("accent must be a 3/6-digit hex literal (no CSS injection surface)", () => {
|
||||
expect(isHexAccent("#2E7D32")).toBe(true);
|
||||
expect(isHexAccent("#abc")).toBe(true);
|
||||
for (const bad of ["#12345", "#1234567", "red", "inherit", "url(x)", "#GGGGGG", "##123456", ""]) {
|
||||
expect(() => validateBrandingPatch({ accent: bad })).toThrow(BadRequestException);
|
||||
expect(isHexAccent(bad)).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
it("asset keys must come from the upload route", () => {
|
||||
expect(() => validateBrandingPatch({ logoAssetKey: "logo.png" })).toThrow(/must be a storage key/);
|
||||
expect(validateBrandingPatch({ logoAssetKey: FAKE_KEY })).toEqual({ logoAssetKey: FAKE_KEY });
|
||||
});
|
||||
|
||||
it("empty-string tagline is rejected (null is the clear)", () => {
|
||||
expect(() => validateBrandingPatch({ tagline: " " })).toThrow(/cannot be empty/);
|
||||
expect(() => validateBrandingPatch({ tagline: `y`.repeat(200) })).toThrow(/at most 160/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("branding — jsonb encoding", () => {
|
||||
it("an all-default record encodes to NULL (no branding row weight)", () => {
|
||||
expect(brandingJson(DEFAULT_BRANDING)).toBeNull();
|
||||
});
|
||||
|
||||
it("only non-default fields are kept", () => {
|
||||
expect(brandingJson({ ...DEFAULT_BRANDING, tagline: "t", accent: "#111111" })).toEqual({ tagline: "t", accent: "#111111" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("branding service — reads", () => {
|
||||
it("getBranding returns defaults + signed URLs for a branded workspace", async () => {
|
||||
stubWorkspace({
|
||||
id: WS,
|
||||
name: "Acampada 26S",
|
||||
slug: "acampada",
|
||||
branding: { logoAssetKey: FAKE_KEY, tagline: "Toma los medios", backgroundKey: FAKE_KEY_2, accent: "#2E7D32" },
|
||||
});
|
||||
const branding = await serviceWith().getBranding(WS);
|
||||
expect(branding).toMatchObject({
|
||||
name: "Acampada 26S",
|
||||
logoAssetKey: FAKE_KEY,
|
||||
tagline: "Toma los medios",
|
||||
backgroundKey: FAKE_KEY_2,
|
||||
accent: "#2e7d32",
|
||||
});
|
||||
expect(branding.logoUrl).toBe(brandingAssetPath(FAKE_KEY));
|
||||
expect(branding.backgroundUrl).toBe(brandingAssetPath(FAKE_KEY_2));
|
||||
});
|
||||
|
||||
it("an unbranded workspace serves name + nulls, not an error", async () => {
|
||||
stubWorkspace({ id: WS, name: "Solo", slug: "solo", branding: null });
|
||||
const branding = await serviceWith().getBranding(WS);
|
||||
expect(branding.logoUrl).toBeNull();
|
||||
expect(branding.logoAssetKey).toBeNull();
|
||||
expect(branding.accent).toBeNull();
|
||||
expect(branding.name).toBe("Solo");
|
||||
});
|
||||
|
||||
it("a corrupt stored row still renders (degrades to defaults)", async () => {
|
||||
stubWorkspace({ id: WS, name: "X", slug: "x", branding: { logoAssetKey: "not-a-key", accent: 7 } });
|
||||
const branding = await serviceWith().getBranding(WS);
|
||||
expect(branding.logoAssetKey).toBeNull();
|
||||
expect(branding.accent).toBeNull();
|
||||
});
|
||||
|
||||
it("public branding exposes ONLY the look: name, slug, tagline, logo, accent", async () => {
|
||||
stubWorkspace({
|
||||
id: WS,
|
||||
name: "Acampada 26S",
|
||||
slug: "acampada",
|
||||
branding: { logoAssetKey: FAKE_KEY, backgroundKey: FAKE_KEY_2, tagline: "Toma los medios", accent: "#2E7D32" },
|
||||
});
|
||||
const publicBranding = await serviceWith().getPublicBranding(WS);
|
||||
expect(Object.keys(publicBranding).sort()).toEqual(["accent", "logoUrl", "name", "slug", "tagline", "workspaceId"]);
|
||||
expect(publicBranding.name).toBe("Acampada 26S");
|
||||
expect(publicBranding.logoUrl).toBe(brandingAssetPath(FAKE_KEY));
|
||||
// The background never leaves the public seam: it is shell-only.
|
||||
expect(publicBranding).not.toHaveProperty("backgroundKey");
|
||||
});
|
||||
|
||||
it("public branding resolves by slug too, and rejects unknown workspaces", async () => {
|
||||
stubWorkspace({ id: WS, name: "Acampada 26S", slug: "acampada", branding: null });
|
||||
expect((await serviceWith().getPublicBranding("acampada")).workspaceId).toBe(WS);
|
||||
stubWorkspace(undefined);
|
||||
await expect(serviceWith().getPublicBranding("nope")).rejects.toMatchObject({ status: 400 });
|
||||
});
|
||||
});
|
||||
|
||||
describe("branding service — uploads (storage reuse, magic-byte sniffing)", () => {
|
||||
it("sniffs the type from magic bytes and stores through putContent", async () => {
|
||||
const { storage, puts } = fakeStorage();
|
||||
const branding = serviceWith({ storage });
|
||||
const result = await branding.uploadImage(PNG_HEADER, "image/png");
|
||||
expect(puts).toHaveLength(1);
|
||||
expect(puts[0]).toMatchObject({ contentType: "image/png" });
|
||||
expect(result.sizeBytes).toBe(PNG_HEADER.length);
|
||||
expect(result.key).toMatch(/^sha256\/[0-9a-f]{64}\.png$/);
|
||||
});
|
||||
|
||||
it("a lying declared type is rejected (declared mime is never trusted)", async () => {
|
||||
const branding = serviceWith();
|
||||
await expect(branding.uploadImage(PNG_HEADER, "image/jpeg")).rejects.toMatchObject({ status: 400 });
|
||||
});
|
||||
|
||||
it("non-image bytes are rejected", async () => {
|
||||
const branding = serviceWith();
|
||||
await expect(branding.uploadImage(Buffer.from("<script>alert(1)</script>script-pad"), "image/png")).rejects.toMatchObject({
|
||||
status: 400,
|
||||
message: expect.stringMatching(/unsupported image type/),
|
||||
});
|
||||
await expect(branding.uploadImage(Buffer.alloc(4), "image/png")).rejects.toBeInstanceOf(BadRequestException);
|
||||
});
|
||||
|
||||
it("jpeg / webp / gif sniff correctly", async () => {
|
||||
const { storage } = fakeStorage();
|
||||
const branding = serviceWith({ storage });
|
||||
expect((await branding.uploadImage(JPEG_HEADER, "image/jpeg")).contentType).toBe("image/jpeg");
|
||||
const webp = Buffer.concat([Buffer.from("RIFF", "latin1"), Buffer.alloc(4), Buffer.from("WEBP", "latin1"), Buffer.alloc(8)]);
|
||||
expect((await branding.uploadImage(webp, "image/webp")).contentType).toBe("image/webp");
|
||||
const gif = Buffer.concat([Buffer.from("GIF89a", "latin1"), Buffer.alloc(16)]);
|
||||
expect((await branding.uploadImage(gif, "image/gif")).contentType).toBe("image/gif");
|
||||
});
|
||||
|
||||
it("a storage outage surfaces as 503, never as a crash", async () => {
|
||||
const broken = {
|
||||
putContent: vi.fn(async () => { throw new Error("ECONNREFUSED"); }),
|
||||
getObject: vi.fn(async () => Buffer.alloc(0)),
|
||||
hasContent: vi.fn(async () => false),
|
||||
presign: vi.fn(async () => ""),
|
||||
};
|
||||
const branding = serviceWith({ storage: broken });
|
||||
await expect(branding.uploadImage(PNG_HEADER, "image/png")).rejects.toBeInstanceOf(ServiceUnavailableException);
|
||||
});
|
||||
});
|
||||
|
||||
describe("branding service — update (keys must be readable, audit must land)", () => {
|
||||
it("applies a partial patch and writes only the touched keys to the audit diff", async () => {
|
||||
stubWorkspace({ id: WS, name: "Acampada 26S", slug: "acampada", branding: { tagline: "old", accent: "#111111" } });
|
||||
const { storage } = fakeStorage({ [FAKE_KEY]: PNG_HEADER });
|
||||
const branding = serviceWith({ storage });
|
||||
const after = await branding.updateBranding(WS, { logoAssetKey: FAKE_KEY, tagline: "Toma los medios" }, ACTOR);
|
||||
expect(after).toMatchObject({ logoAssetKey: FAKE_KEY, tagline: "Toma los medios", accent: "#111111", backgroundKey: null });
|
||||
expect(appendTenantAudit).toHaveBeenCalledWith(WS, expect.objectContaining({
|
||||
action: "workspace.branding.updated",
|
||||
targetType: "workspace",
|
||||
targetId: WS,
|
||||
actorUserId: ACTOR.userId,
|
||||
diff: {
|
||||
logoAssetKey: { from: null, to: FAKE_KEY },
|
||||
tagline: { from: "old", to: "Toma los medios" },
|
||||
},
|
||||
}));
|
||||
});
|
||||
|
||||
it("null clears fields back to the defaults", async () => {
|
||||
stubWorkspace({
|
||||
id: WS, name: "A", slug: "a",
|
||||
branding: { logoAssetKey: FAKE_KEY, tagline: "t", backgroundKey: FAKE_KEY_2, accent: "#111111" },
|
||||
});
|
||||
const after = await serviceWith().updateBranding(WS, { tagline: null, accent: null }, ACTOR);
|
||||
expect(after).toMatchObject({ tagline: null, accent: null, logoAssetKey: FAKE_KEY, backgroundKey: FAKE_KEY_2 });
|
||||
});
|
||||
|
||||
it("an asset key that storage cannot read is rejected before the write", async () => {
|
||||
stubWorkspace({ id: WS, name: "A", slug: "a", branding: null });
|
||||
const branding = serviceWith(); // no objects seeded
|
||||
await expect(branding.updateBranding(WS, { logoAssetKey: FAKE_KEY }, ACTOR)).rejects.toMatchObject({
|
||||
status: 400,
|
||||
message: expect.stringMatching(/upload the image first/),
|
||||
});
|
||||
expect(withTenant).not.toHaveBeenCalled();
|
||||
expect(appendTenantAudit).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("an oversized stored asset is refused", async () => {
|
||||
stubWorkspace({ id: WS, name: "A", slug: "a", branding: null });
|
||||
const huge = Buffer.alloc(4 * 1024 * 1024 + 1, 0x89);
|
||||
const { storage } = fakeStorage({ [FAKE_KEY]: huge });
|
||||
await expect(serviceWith({ storage }).updateBranding(WS, { logoAssetKey: FAKE_KEY }, ACTOR)).rejects.toBeInstanceOf(BadRequestException);
|
||||
});
|
||||
});
|
||||
|
||||
describe("branding service — asset reads (the scoped GET)", () => {
|
||||
it("returns decrypted bytes with the right content type", async () => {
|
||||
const { storage } = fakeStorage({ [FAKE_KEY]: PNG_HEADER });
|
||||
const asset = await serviceWith({ storage }).readAsset(FAKE_KEY);
|
||||
expect(asset.bytes.equals(PNG_HEADER)).toBe(true);
|
||||
expect(asset.contentType).toBe("image/png");
|
||||
});
|
||||
|
||||
it("rejects keys that are not content-addressed objects", async () => {
|
||||
await expect(serviceWith().readAsset("../etc/passwd")).rejects.toMatchObject({ status: 400 });
|
||||
await expect(serviceWith().readAsset("http://evil/x.png")).rejects.toBeInstanceOf(BadRequestException);
|
||||
});
|
||||
|
||||
it("an unreadable object is a 503, not a crash", async () => {
|
||||
await expect(serviceWith().readAsset(FAKE_KEY)).rejects.toBeInstanceOf(ServiceUnavailableException);
|
||||
});
|
||||
});
|
||||
|
||||
describe("branding controller — shape and RBAC metadata", () => {
|
||||
const request = (role: string) => ({ session: { userId: "u1", workspaceId: WS, role, authzEpoch: 1, issuedAt: 0, expiresAt: 9e12 } }) as never;
|
||||
|
||||
it("GET returns the workspace name with the branding fields", async () => {
|
||||
stubWorkspace({ id: WS, name: "Acampada 26S", slug: "acampada", branding: { tagline: "Toma los medios" } });
|
||||
const branding = serviceWith();
|
||||
const controller = new BrandingController(branding);
|
||||
const response = await controller.get(request("Publisher"));
|
||||
expect(response).toMatchObject({
|
||||
workspace: { id: WS, name: "Acampada 26S" },
|
||||
tagline: "Toma los medios",
|
||||
logoUrl: null,
|
||||
backgroundUrl: null,
|
||||
});
|
||||
});
|
||||
|
||||
it("PUT maps body keys to the patch, treating null as a clear", async () => {
|
||||
stubWorkspace({ id: WS, name: "A", slug: "a", branding: { tagline: "old" } });
|
||||
const { storage } = fakeStorage({ [FAKE_KEY]: PNG_HEADER });
|
||||
const controller = new BrandingController(serviceWith({ storage }));
|
||||
const after = await controller.put(request("Admin"), { logoAssetKey: FAKE_KEY, tagline: null });
|
||||
expect(after).toMatchObject({ logoAssetKey: FAKE_KEY, tagline: null });
|
||||
});
|
||||
|
||||
it("PUT ignores absent keys (partial semantics) and unknown keys still 400", async () => {
|
||||
stubWorkspace({ id: WS, name: "A", slug: "a", branding: null });
|
||||
const controller = new BrandingController(serviceWith());
|
||||
const after = await controller.put(request("Admin"), { accent: "#2E7D32" });
|
||||
expect(after.accent).toBe("#2e7d32");
|
||||
await expect(controller.put(request("Admin"), { favicon: "x" })).rejects.toBeInstanceOf(BadRequestException);
|
||||
});
|
||||
|
||||
it("uploads require the multipart file field", async () => {
|
||||
const controller = new BrandingController(serviceWith());
|
||||
await expect(controller.logo(request("Admin"), undefined)).rejects.toMatchObject({ status: 400 });
|
||||
await expect(controller.background(request("Admin"), undefined)).rejects.toMatchObject({ status: 400 });
|
||||
});
|
||||
|
||||
it("an upload returns the key plus the ready-to-save patch and asset URL", async () => {
|
||||
const { storage } = fakeStorage();
|
||||
const controller = new BrandingController(serviceWith({ storage }));
|
||||
const result = await controller.logo(request("Admin"), { buffer: PNG_HEADER, mimetype: "image/png" });
|
||||
expect(result.patch).toEqual({ logoAssetKey: result.key });
|
||||
expect(result.assetUrl).toBe(brandingAssetPath(result.key));
|
||||
expect(result.key).toMatch(/^sha256\/[0-9a-f]{64}\.png$/);
|
||||
});
|
||||
|
||||
it("the public controller requires an identifier", async () => {
|
||||
const controller = new PublicBrandingController(serviceWith());
|
||||
const response = { setHeader: vi.fn() } as never;
|
||||
await expect(controller.publicBranding(undefined, undefined, response)).rejects.toBeInstanceOf(BadRequestException);
|
||||
await expect(controller.publicBranding("", "", response)).rejects.toBeInstanceOf(BadRequestException);
|
||||
});
|
||||
|
||||
it("the public controller accepts a uuid workspaceId or a slug", async () => {
|
||||
stubWorkspace({ id: WS, name: "Acampada 26S", slug: "acampada", branding: { tagline: "t" } });
|
||||
const controller = new PublicBrandingController(serviceWith());
|
||||
const response = { setHeader: vi.fn() } as never;
|
||||
await expect(controller.publicBranding("not-a-uuid", undefined, response)).rejects.toMatchObject({ status: 400 });
|
||||
expect((await controller.publicBranding(WS, undefined, response)).name).toBe("Acampada 26S");
|
||||
expect((await controller.publicBranding(undefined, "acampada", response)).name).toBe("Acampada 26S");
|
||||
expect(response.setHeader).toHaveBeenCalledWith("Cache-Control", "public, max-age=60");
|
||||
});
|
||||
|
||||
it("the asset route sets the immutable cache + nosniff headers and honors ETag", async () => {
|
||||
const { storage } = fakeStorage({ [FAKE_KEY]: PNG_HEADER });
|
||||
const controller = new PublicBrandingController(serviceWith({ storage }));
|
||||
const headers: Record<string, string> = {};
|
||||
const response = {
|
||||
setHeader: vi.fn((k: string, v: string) => { headers[k.toLowerCase()] = String(v); }),
|
||||
status: vi.fn(() => response),
|
||||
end: vi.fn(),
|
||||
type: vi.fn(() => response),
|
||||
send: vi.fn(),
|
||||
} as never;
|
||||
await controller.asset(FAKE_KEY, { headers: {} } as never, response);
|
||||
expect(headers["cache-control"]).toContain("immutable");
|
||||
expect(headers["x-content-type-options"]).toBe("nosniff");
|
||||
expect(headers.etag).toBe(`"${FAKE_KEY}"`);
|
||||
// A matching If-None-Match short-circuits to 304 without a body send.
|
||||
const notModified = { ...response };
|
||||
await controller.asset(FAKE_KEY, { headers: { "if-none-match": `"${FAKE_KEY}"` } } as never, notModified);
|
||||
expect(notModified.status).toHaveBeenCalledWith(304);
|
||||
expect(notModified.send).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("the asset route refuses cross-origin requests (same-origin only)", async () => {
|
||||
const { storage } = fakeStorage({ [FAKE_KEY]: PNG_HEADER });
|
||||
const controller = new PublicBrandingController(serviceWith({ storage }));
|
||||
const response = { setHeader: vi.fn(), status: vi.fn(), end: vi.fn(), type: vi.fn(), send: vi.fn() } as never;
|
||||
await expect(
|
||||
controller.asset(FAKE_KEY, { headers: { origin: "https://evil.example" }, protocol: "https", get: (h: string) => (h === "host" ? "api.local" : "") } as never, response),
|
||||
).rejects.toMatchObject({ status: 403 });
|
||||
});
|
||||
});
|
||||
|
||||
describe("branding — key set (mirrors migration 036)", () => {
|
||||
it("exposes exactly the four documented fields", () => {
|
||||
expect([...BRANDING_KEYS]).toEqual(["logoAssetKey", "tagline", "backgroundKey", "accent"]);
|
||||
});
|
||||
|
||||
it("the asset path is the scoped same-origin GET with an encoded key", () => {
|
||||
expect(brandingAssetPath(FAKE_KEY)).toBe(`/public/branding/asset?key=${encodeURIComponent(FAKE_KEY)}`);
|
||||
});
|
||||
});
|
||||
|
|
@ -1,16 +1,25 @@
|
|||
/**
|
||||
* Settings module wiring. The service talks to the system pool directly
|
||||
* (withSystem): instance_settings is deliberately NOT tenant-scoped, so there
|
||||
* is no withTenant context to establish — audit events go to the system chain
|
||||
* via audit_append_system, which REJECTS a tenant context.
|
||||
* Settings module wiring.
|
||||
*
|
||||
* Two concerns share this module because both are "configuration the API
|
||||
* owns and the shell reads":
|
||||
* * SettingsService — INSTANCE posture (system table 028, no tenant scope)
|
||||
* * BrandingService — PER-WORKSPACE look (workspaces.branding, 036)
|
||||
*
|
||||
* Branding talks to the tenant pool via withTenant (the audit append lands on
|
||||
* the workspace chain) and to the storage service directly, exactly like
|
||||
* video/submissions: no shared provider, the service constructs its own
|
||||
* S3Storage from env so the module stays import-free of the video module.
|
||||
*/
|
||||
import { Module } from "@nestjs/common";
|
||||
import { SettingsController } from "./settings.controller.js";
|
||||
import { SettingsService } from "./settings.service.js";
|
||||
import { BrandingController, PublicBrandingController } from "./branding.controller.js";
|
||||
import { BrandingService } from "./branding.service.js";
|
||||
|
||||
@Module({
|
||||
controllers: [SettingsController],
|
||||
providers: [SettingsService],
|
||||
exports: [SettingsService],
|
||||
controllers: [SettingsController, BrandingController, PublicBrandingController],
|
||||
providers: [SettingsService, BrandingService],
|
||||
exports: [SettingsService, BrandingService],
|
||||
})
|
||||
export class SettingsModule {}
|
||||
|
|
|
|||
|
|
@ -13,10 +13,13 @@
|
|||
"soak": "node --import tsx src/compositor/soak.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-s3": "^3.1146.0",
|
||||
"pg": "^8.23.1",
|
||||
"pino": "^9.3.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.5.0",
|
||||
"@types/pg": "^8.23.1",
|
||||
"tsx": "^4.17.0"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
76
apps/web/src/app/(main)/groups/page.tsx
Normal file
76
apps/web/src/app/(main)/groups/page.tsx
Normal file
|
|
@ -0,0 +1,76 @@
|
|||
"use client";
|
||||
|
||||
import { Badge, Card } from "../../../components/ui/primitives";
|
||||
import { Loading } from "../../../components/brand/loading";
|
||||
import { EmptyState } from "../../../components/brand/empty-state";
|
||||
import { OfflineBadge } from "../../../components/ui/offline-badge";
|
||||
import { useApiResource } from "../../../lib/use-api";
|
||||
import { useAuth } from "../../../lib/auth-store";
|
||||
import { GroupSubmissionsCard } from "../../../components/submissions/group-submissions-card";
|
||||
import type { SubmissionGroupSummary } from "../../../lib/submissions-types";
|
||||
|
||||
/**
|
||||
* Groups screen — the moderation home for the anonymous submission boxes.
|
||||
*
|
||||
* GET /groups/mine lists the caller's groups; each renders a
|
||||
* GroupSubmissionsCard with its own "Submission box" section (public link,
|
||||
* rotate + enable/disable) and moderation queue (pending / approved /
|
||||
* rejected). All four admin endpoints are gated on
|
||||
* workspace.members.manage, so the cards stay read-only for plain members.
|
||||
*
|
||||
* The API is offline (or the session is anonymous, which useApiResource folds
|
||||
* into sample) → the list is read-only fixtures rather than fake mutations.
|
||||
*/
|
||||
|
||||
const SAMPLE_GROUPS: SubmissionGroupSummary[] = [
|
||||
{ id: "sample-group-1", name: "Collective media" },
|
||||
{ id: "sample-group-2", name: "Street footage" },
|
||||
];
|
||||
|
||||
const identityGroups = (groups: SubmissionGroupSummary[]): SubmissionGroupSummary[] => groups;
|
||||
|
||||
export default function GroupsPage(): React.ReactElement {
|
||||
const { can, session } = useAuth();
|
||||
const groups = useApiResource<SubmissionGroupSummary[], SubmissionGroupSummary[]>("/groups/mine", identityGroups, SAMPLE_GROUPS);
|
||||
const live = groups.source === "live";
|
||||
const canManage = live && (can("workspace.members.manage") || session?.role === "Owner" || session?.role === "Admin");
|
||||
|
||||
return (
|
||||
<div className="h-full overflow-y-auto p-4 pb-24 scrollbar-thin lg:p-6">
|
||||
<div className="mb-5 flex flex-wrap items-center gap-3">
|
||||
<h1 className="text-lg font-semibold">Groups</h1>
|
||||
<OfflineBadge source={groups.source} />
|
||||
{!live && <Badge tone="warn">read-only sample</Badge>}
|
||||
</div>
|
||||
<p className="mb-5 max-w-2xl text-xs leading-relaxed text-text-faint">
|
||||
Each group can open a public submission box: a link anyone can use to send footage and
|
||||
photos — no account, no sign-in. Everything lands as <span className="text-text-dim">pending</span> until
|
||||
the group's managers approve or reject it with a note.
|
||||
</p>
|
||||
|
||||
{groups.error !== null && (
|
||||
<Card className="mb-5 p-4">
|
||||
<p className="text-xs text-danger">{groups.error}</p>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{groups.source === "loading" ? (
|
||||
<div className="flex justify-center py-10"><Loading size={28} label="Loading groups" /></div>
|
||||
) : groups.data.length === 0 ? (
|
||||
<EmptyState title="No groups yet" hint="Join or create a collective and its groups appear here." />
|
||||
) : (
|
||||
<div className="flex flex-col gap-4">
|
||||
{groups.data.map((group) => (
|
||||
<GroupSubmissionsCard key={group.id} group={group} canManage={canManage} live={live} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!live && (
|
||||
<p className="mt-5 text-2xs leading-relaxed text-text-faint">
|
||||
The API is unreachable, so this screen shows sample groups and cannot change anything.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
442
apps/web/src/app/submit/[token]/page.tsx
Normal file
442
apps/web/src/app/submit/[token]/page.tsx
Normal file
|
|
@ -0,0 +1,442 @@
|
|||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { LogoMark } from "../../../components/brand/logo";
|
||||
import { Loading } from "../../../components/brand/loading";
|
||||
import { ApiError, ApiUnreachableError, errorMessage } from "../../../lib/api-client";
|
||||
import {
|
||||
SUBMISSION_CONTACT_MAX,
|
||||
SUBMISSION_CREDIT_MAX,
|
||||
SUBMISSION_LICENSE_TEXT,
|
||||
SUBMISSION_SNIFF_BYTES,
|
||||
submissionMaxBytes,
|
||||
type SubmissionBoxInfo,
|
||||
type SubmissionKind,
|
||||
type SubmissionReceiveResult,
|
||||
} from "../../../lib/submissions-types";
|
||||
|
||||
/**
|
||||
* /submit/[token] — the public, accountless intake for a group's submission
|
||||
* box (backend 599a68c). Everything here works signed-out: metadata comes
|
||||
* from GET /public/submit/:token, the file goes to POST /public/submit/:token.
|
||||
*
|
||||
* The route deliberately lives OUTSIDE the (main) group: that layout mounts
|
||||
* the app Shell and wraps children in AuthGate, which bounces anonymous
|
||||
* visitors to /onboarding — the exact opposite of what a handed-out link
|
||||
* needs (same reasoning as app/join/layout.tsx).
|
||||
*
|
||||
* Client-side pre-checks mirror the API's receive path
|
||||
* (apps/api/src/submissions/submissions.service.ts): magic bytes decide the
|
||||
* kind — never the name or the browser's declared mime — mp4/webm video caps
|
||||
* at 200 MiB, jpeg/png photo at 50 MiB, and the licence box is mandatory.
|
||||
* Failing them here gives a friendly one-line reason instead of a 400.
|
||||
*/
|
||||
|
||||
type Phase = "checking" | "open" | "unreachable" | "gone";
|
||||
|
||||
const API_URL = (process.env.NEXT_PUBLIC_API_URL ?? "http://localhost:3000").replace(/\/$/, "");
|
||||
|
||||
/** Byte-level kind sniff, mirroring the API's sniffMedia() (32-byte window). */
|
||||
async function sniffFile(file: File): Promise<SubmissionKind | null> {
|
||||
const head = new Uint8Array(await file.slice(0, SUBMISSION_SNIFF_BYTES).arrayBuffer());
|
||||
if (head.length < SUBMISSION_SNIFF_BYTES) return null;
|
||||
const latin = (start: number, length: number): string => {
|
||||
let out = "";
|
||||
for (let i = start; i < start + length; i += 1) out += String.fromCharCode(head[i] ?? 0);
|
||||
return out;
|
||||
};
|
||||
const hex = (start: number, length: number): string => {
|
||||
let out = "";
|
||||
for (let i = start; i < start + length; i += 1) out += (head[i] ?? 0).toString(16).padStart(2, "0");
|
||||
return out;
|
||||
};
|
||||
// mp4 (ftyp at offset 4) and webm/mkv (EBML) are video; jpeg and png are photo.
|
||||
if (latin(4, 4) === "ftyp") return "video";
|
||||
if (hex(0, 4) === "1a45dfa3") return "video";
|
||||
if (hex(0, 3) === "ffd8ff") return "photo";
|
||||
if (hex(0, 8) === "89504e470d0a1a0a") return "photo";
|
||||
return null;
|
||||
}
|
||||
|
||||
function formatBytes(bytes: number): string {
|
||||
const mib = bytes / (1024 * 1024);
|
||||
return mib >= 10 ? `${Math.round(mib)} MiB` : `${mib.toFixed(1)} MiB`;
|
||||
}
|
||||
|
||||
/** The API's JSON {message} bodies, softened where the field-level text leaks implementation detail. */
|
||||
function friendlyError(err: unknown): string {
|
||||
if (err instanceof ApiUnreachableError) return "Can't reach the server — check your connection and try again.";
|
||||
if (err instanceof ApiError) {
|
||||
const m = err.message.toLowerCase();
|
||||
if (err.status === 403 && m.includes("too many")) return "Too many submissions from this network already — try again in a little while.";
|
||||
if (err.status === 403 && m.includes("full")) return "This box is full right now — the collective has to review what's already in it first.";
|
||||
if (err.status === 400 && m.includes("already submitted")) return "You already sent this exact file to this box.";
|
||||
if (err.status === 400 && m.includes("unrecognised")) return "That file type isn't accepted — mp4 or webm video, jpeg or png photo.";
|
||||
if (err.status === 400 && m.includes("licence") || m.includes("license")) {
|
||||
return "Tick the licence box so the collective knows they may share your footage.";
|
||||
}
|
||||
if (err.status === 503) return "The box's storage isn't ready — try again shortly.";
|
||||
if (err.status === 404) return "This box isn't open anymore — ask the collective for a fresh link.";
|
||||
return err.message;
|
||||
}
|
||||
return errorMessage(err);
|
||||
}
|
||||
|
||||
export default function SubmitPage({ params }: { params: { token: string } }): React.ReactElement {
|
||||
const token = params.token;
|
||||
const [phase, setPhase] = useState<Phase>("checking");
|
||||
const [box, setBox] = useState<SubmissionBoxInfo | null>(null);
|
||||
const [attempt, setAttempt] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
setPhase("checking");
|
||||
fetch(`${API_URL}/public/submit/${encodeURIComponent(token)}`, { credentials: "omit" })
|
||||
.then(async (response) => {
|
||||
const text = await response.text();
|
||||
let body: unknown = null;
|
||||
if (text !== "") {
|
||||
try { body = JSON.parse(text) as unknown; } catch { body = text; }
|
||||
}
|
||||
if (!response.ok) throw new ApiError(response.status, typeof body === "object" && body !== null && "message" in body && typeof (body as { message: unknown }).message === "string" ? (body as { message: string }).message : `HTTP ${response.status}`, body);
|
||||
return body as SubmissionBoxInfo;
|
||||
})
|
||||
.then((info) => {
|
||||
if (cancelled) return;
|
||||
setBox(info);
|
||||
setPhase("open");
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
if (cancelled) return;
|
||||
// A missing box renders the dead card; only a network failure offers a retry.
|
||||
if (err instanceof ApiUnreachableError) setPhase("unreachable");
|
||||
else setPhase("gone");
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [token, attempt]);
|
||||
|
||||
if (phase === "checking") {
|
||||
return (
|
||||
<main className="flex min-h-dvh items-center justify-center bg-[#060403] px-6">
|
||||
<div className="flex flex-col items-center gap-3">
|
||||
<Loading size={32} label="Opening the box…" />
|
||||
<span className="text-2xs text-[#a4a09e]">Opening the box…</span>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
if (phase === "unreachable") {
|
||||
return (
|
||||
<main className="flex min-h-dvh items-center justify-center bg-[#060403] p-6">
|
||||
<DeadCard
|
||||
tone="warn"
|
||||
title="Can't reach the server"
|
||||
body="Your link is still fine — the server isn't answering. Try again in a moment."
|
||||
action={<button type="button" onClick={() => setAttempt((n) => n + 1)} className="mt-6 rounded-md bg-[#FF1610] px-3 py-1.5 text-xs font-medium text-white hover:bg-[#e3130e]">Retry</button>}
|
||||
/>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
if (phase === "gone" || box === null) {
|
||||
return (
|
||||
<main className="flex min-h-dvh items-center justify-center bg-[#060403] p-6">
|
||||
<DeadCard
|
||||
tone="danger"
|
||||
title="This box isn't open"
|
||||
body="The link is no longer valid — it may have been rotated, or the box is closed. Ask the collective for a fresh link."
|
||||
/>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<main className="min-h-dvh bg-[#060403] px-4 py-10 sm:px-6">
|
||||
<SubmitForm key={token} token={token} box={box} />
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
function DeadCard({ tone, title, body, action }: { tone: "warn" | "danger"; title: string; body: string; action?: React.ReactNode }): React.ReactElement {
|
||||
return (
|
||||
<div className="w-full max-w-md rounded-lg border border-[#2a2320] bg-[#0F0A09] p-6 text-center">
|
||||
<div
|
||||
className={`mx-auto mb-4 flex h-12 w-12 items-center justify-center rounded-full text-lg ${
|
||||
tone === "warn" ? "bg-[#FF1610]/15 text-[#FF1610]" : "bg-[#FF1610]/15 text-[#FF1610]"
|
||||
}`}
|
||||
>
|
||||
{tone === "warn" ? "!" : "✕"}
|
||||
</div>
|
||||
<h1 className="text-base font-semibold text-[#f5efed]">{title}</h1>
|
||||
<p className="mt-2 text-xs leading-relaxed text-[#a4a09e]">{body}</p>
|
||||
{action}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SubmitForm({ token, box }: { token: string; box: SubmissionBoxInfo }): React.ReactElement {
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const busyRef = useRef(false);
|
||||
|
||||
const [file, setFile] = useState<File | null>(null);
|
||||
const [kind, setKind] = useState<SubmissionKind | null>(null);
|
||||
const [fileError, setFileError] = useState<string | null>(null);
|
||||
|
||||
const [creditName, setCreditName] = useState("");
|
||||
const [contact, setContact] = useState("");
|
||||
const [licence, setLicence] = useState(false);
|
||||
|
||||
const [progress, setProgress] = useState(0);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [done, setDone] = useState<SubmissionReceiveResult | null>(null);
|
||||
const [phase, setPhase] = useState<"idle" | "uploading" | "sent">("idle");
|
||||
|
||||
const pick = useCallback(async (picked: File): Promise<void> => {
|
||||
if (busyRef.current || phase === "uploading") return;
|
||||
setFileError(null);
|
||||
const sniffed = await sniffFile(picked).catch(() => null);
|
||||
if (sniffed === null) {
|
||||
setFile(null);
|
||||
setKind(null);
|
||||
setFileError(`${picked.name}: that file type isn't recognised — mp4 or webm video, jpeg or png photo.`);
|
||||
return;
|
||||
}
|
||||
const cap = submissionMaxBytes(sniffed);
|
||||
if (picked.size === 0 || picked.size > cap) {
|
||||
setFile(null);
|
||||
setKind(null);
|
||||
setFileError(`${picked.name} is ${formatBytes(picked.size)} — the limit for a ${sniffed} is ${formatBytes(cap)}.`);
|
||||
return;
|
||||
}
|
||||
setFile(picked);
|
||||
setKind(sniffed);
|
||||
}, [phase]);
|
||||
|
||||
useEffect(() => {
|
||||
// Paste a file straight into the box (the video library's pattern).
|
||||
const onPaste = (event: ClipboardEvent): void => {
|
||||
const pasted = Array.from(event.clipboardData?.files ?? [])[0];
|
||||
if (pasted !== undefined) void pick(pasted);
|
||||
};
|
||||
window.addEventListener("paste", onPaste);
|
||||
return () => window.removeEventListener("paste", onPaste);
|
||||
}, [pick]);
|
||||
|
||||
const creditTooLong = creditName.length > SUBMISSION_CREDIT_MAX;
|
||||
const contactTooLong = contact.length > SUBMISSION_CONTACT_MAX;
|
||||
const ready = file !== null && kind !== null && licence && !creditTooLong && !contactTooLong;
|
||||
|
||||
async function submit(event: React.FormEvent): Promise<void> {
|
||||
event.preventDefault();
|
||||
if (!ready || file === null || busyRef.current) return;
|
||||
busyRef.current = true;
|
||||
setPhase("uploading");
|
||||
setProgress(0);
|
||||
setError(null);
|
||||
try {
|
||||
const result = await new Promise<SubmissionReceiveResult>((resolve, reject) => {
|
||||
const xhr = new XMLHttpRequest();
|
||||
xhr.open("POST", `${API_URL}/public/submit/${encodeURIComponent(token)}`);
|
||||
xhr.timeout = 900_000; // a 200 MiB video on a slow line needs room.
|
||||
xhr.upload.onprogress = (event) => {
|
||||
if (event.lengthComputable) setProgress(Math.floor((event.loaded / event.total) * 100));
|
||||
};
|
||||
xhr.upload.onload = () => setProgress(100);
|
||||
xhr.onload = () => {
|
||||
let body: unknown = null;
|
||||
if (xhr.responseText !== "") {
|
||||
try { body = JSON.parse(xhr.responseText) as unknown; } catch { body = xhr.responseText; }
|
||||
}
|
||||
if (xhr.status >= 200 && xhr.status < 300) {
|
||||
resolve(body as SubmissionReceiveResult);
|
||||
} else {
|
||||
const message =
|
||||
typeof body === "object" && body !== null && "message" in body
|
||||
? String((body as { message: unknown }).message)
|
||||
: `HTTP ${xhr.status}`;
|
||||
reject(new ApiError(xhr.status, message, body));
|
||||
}
|
||||
};
|
||||
xhr.onerror = () => reject(new ApiUnreachableError(new Error("network")));
|
||||
xhr.ontimeout = () => reject(new Error("The upload timed out — check your connection and try again."));
|
||||
xhr.onabort = () => reject(new Error("The upload was cancelled."));
|
||||
const form = new FormData();
|
||||
form.append("file", file);
|
||||
form.append("licenseAccepted", "true");
|
||||
if (creditName.trim() !== "") form.append("creditName", creditName.trim());
|
||||
if (contact.trim() !== "") form.append("contact", contact.trim());
|
||||
xhr.send(form);
|
||||
});
|
||||
setDone(result);
|
||||
setPhase("sent");
|
||||
} catch (err) {
|
||||
setError(friendlyError(err));
|
||||
setPhase("idle");
|
||||
} finally {
|
||||
busyRef.current = false;
|
||||
}
|
||||
}
|
||||
|
||||
if (phase === "sent" && done !== null) {
|
||||
return (
|
||||
<div className="mx-auto flex w-full max-w-xl flex-col items-center gap-4 rounded-lg border border-[#2a2320] bg-[#0F0A09] p-8 text-center">
|
||||
<LogoMark size={44} className="text-[#FF1610]" title="" />
|
||||
<h1 className="text-xl font-semibold tracking-tight text-[#f5efed]">Received</h1>
|
||||
<p className="max-w-sm text-sm leading-relaxed text-[#c7c3c1]">
|
||||
Received — the collective will review it. Nothing is published until they approve it.
|
||||
</p>
|
||||
<p className="max-w-sm text-2xs leading-relaxed text-[#a4a09e]">
|
||||
Your footage stays under {box.licenseDefault}: it may be shared and remixed with attribution.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const uploading = phase === "uploading";
|
||||
const dropDisabled = uploading;
|
||||
|
||||
return (
|
||||
<form
|
||||
onSubmit={(event) => void submit(event)}
|
||||
className="mx-auto flex w-full max-w-xl flex-col gap-6 rounded-lg border border-[#2a2320] bg-[#0F0A09] p-6 sm:p-8"
|
||||
>
|
||||
<header className="flex flex-col items-center gap-3 text-center">
|
||||
<LogoMark size={36} className="text-[#FF1610]" title="Tactical Media Broadcast Hub" />
|
||||
<div>
|
||||
<h1 className="text-xl font-semibold tracking-tight text-[#f5efed]">Send footage to the collective</h1>
|
||||
<p className="mt-1 text-xs leading-relaxed text-[#a4a09e]">
|
||||
No account needed. mp4 or webm video up to 200 MiB, jpeg or png photo up to 50 MiB.
|
||||
</p>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div
|
||||
role="button"
|
||||
tabIndex={dropDisabled ? -1 : 0}
|
||||
aria-disabled={dropDisabled}
|
||||
aria-label="Choose a file to send"
|
||||
onClick={() => {
|
||||
if (!dropDisabled) inputRef.current?.click();
|
||||
}}
|
||||
onKeyDown={(event) => {
|
||||
if (dropDisabled) return;
|
||||
if (event.key === "Enter" || event.key === " ") {
|
||||
event.preventDefault();
|
||||
inputRef.current?.click();
|
||||
}
|
||||
}}
|
||||
onDragOver={(event) => {
|
||||
event.preventDefault();
|
||||
}}
|
||||
onDrop={(event) => {
|
||||
event.preventDefault();
|
||||
if (dropDisabled) return;
|
||||
const dropped = event.dataTransfer.files[0];
|
||||
if (dropped !== undefined) void pick(dropped);
|
||||
}}
|
||||
className={`flex min-h-[180px] flex-col items-center justify-center gap-2 rounded-lg border-2 border-dashed px-4 py-8 text-center transition-colors duration-150 ${
|
||||
uploading
|
||||
? "cursor-not-allowed border-[#3a312e] opacity-60"
|
||||
: "cursor-pointer border-[#3a312e] hover:border-[#FF1610] focus-visible:border-[#FF1610]"
|
||||
}`}
|
||||
>
|
||||
{uploading ? (
|
||||
<div className="w-full max-w-md space-y-2">
|
||||
<span className="inline-flex items-center gap-2 text-xs text-[#c7c3c1]">
|
||||
<Loading size={14} label="Uploading" />
|
||||
{progress < 100 ? `Uploading ${progress}%` : "Upload sent — the collective's server is taking it in…"}{" "}
|
||||
{file !== null && `${file.name} (${formatBytes(file.size)})`}
|
||||
</span>
|
||||
<progress className="h-2 w-full accent-[#FF1610]" aria-label="Upload progress" value={progress} max={100} />
|
||||
</div>
|
||||
) : file !== null && kind !== null ? (
|
||||
<>
|
||||
<span className="text-sm font-medium text-[#f5efed]">{file.name}</span>
|
||||
<span className="text-2xs text-[#a4a09e]">
|
||||
{kind === "video" ? "video" : "photo"} · {formatBytes(file.size)} · click or drop to choose another
|
||||
</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<span className="text-sm text-[#c7c3c1]">Drop your video or photo here</span>
|
||||
<span className="text-2xs leading-relaxed text-[#a4a09e]">
|
||||
or browse your device, or paste a file. Nothing is sent until you press Send.
|
||||
</span>
|
||||
<span className="mt-2 rounded-md bg-[#FF1610] px-3 py-1.5 text-xs font-medium text-white">Browse files</span>
|
||||
</>
|
||||
)}
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="file"
|
||||
accept="video/mp4,video/webm,image/jpeg,image/png,.mp4,.webm,.jpg,.jpeg,.png"
|
||||
className="hidden"
|
||||
onChange={(event) => {
|
||||
const picked = event.target.files?.[0];
|
||||
event.target.value = "";
|
||||
if (picked !== undefined) void pick(picked);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{fileError !== null && <p role="alert" className="text-xs text-[#ff6f6b]">{fileError}</p>}
|
||||
{error !== null && <p role="alert" className="rounded-md border border-[#FF1610]/30 bg-[#FF1610]/10 px-3 py-2 text-xs text-[#ff9b97]">{error}</p>}
|
||||
|
||||
<div className="space-y-4">
|
||||
<label className="flex cursor-pointer items-start gap-2.5 rounded-lg border border-[#3a312e] bg-[#060403] p-3">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={licence}
|
||||
onChange={(event) => setLicence(event.target.checked)}
|
||||
disabled={uploading}
|
||||
className="mt-0.5 h-4 w-4 shrink-0 accent-[#FF1610]"
|
||||
/>
|
||||
<span className="text-xs leading-relaxed text-[#c7c3c1]">
|
||||
{SUBMISSION_LICENSE_TEXT} <span className="text-[#FF1610]">(required)</span>
|
||||
</span>
|
||||
</label>
|
||||
|
||||
<label className="block text-2xs text-[#a4a09e]">
|
||||
Credit name (optional, max {SUBMISSION_CREDIT_MAX})
|
||||
<input
|
||||
type="text"
|
||||
value={creditName}
|
||||
maxLength={SUBMISSION_CREDIT_MAX}
|
||||
onChange={(event) => setCreditName(event.target.value)}
|
||||
disabled={uploading}
|
||||
placeholder="How your name should appear in the credit"
|
||||
className="mt-1 w-full rounded-md border border-[#3a312e] bg-[#060403] px-3 py-2 text-sm text-[#f5efed] placeholder:text-[#6b6563] focus:border-[#FF1610] focus:outline-none"
|
||||
/>
|
||||
{creditTooLong && <span className="mt-1 block text-[#ff6f6b]">Up to {SUBMISSION_CREDIT_MAX} characters.</span>}
|
||||
</label>
|
||||
|
||||
<label className="block text-2xs text-[#a4a09e]">
|
||||
Contact (optional, max {SUBMISSION_CONTACT_MAX})
|
||||
<textarea
|
||||
value={contact}
|
||||
maxLength={SUBMISSION_CONTACT_MAX}
|
||||
onChange={(event) => setContact(event.target.value)}
|
||||
disabled={uploading}
|
||||
rows={2}
|
||||
placeholder="How licensors can reach you — shown only to admins"
|
||||
className="mt-1 w-full resize-none rounded-md border border-[#3a312e] bg-[#060403] px-3 py-2 text-sm text-[#f5efed] placeholder:text-[#6b6563] focus:border-[#FF1610] focus:outline-none"
|
||||
/>
|
||||
{contactTooLong && <span className="mt-1 block text-[#ff6f6b]">Up to {SUBMISSION_CONTACT_MAX} characters.</span>}
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={!ready || uploading}
|
||||
className="w-full rounded-md bg-[#FF1610] px-4 py-2.5 text-sm font-semibold text-white transition-colors duration-150 hover:bg-[#e3130e] disabled:cursor-not-allowed disabled:opacity-40"
|
||||
>
|
||||
{uploading ? `Sending… ${progress}%` : "Send to the collective"}
|
||||
</button>
|
||||
<p className="text-center text-2xs leading-relaxed text-[#a4a09e]">
|
||||
The collective reviews every submission before anything is used. Your contact details stay with their admins.
|
||||
</p>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
397
apps/web/src/components/submissions/group-submissions-card.tsx
Normal file
397
apps/web/src/components/submissions/group-submissions-card.tsx
Normal file
|
|
@ -0,0 +1,397 @@
|
|||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { Badge, Button, Card } from "../ui/primitives";
|
||||
import { Loading } from "../brand/loading";
|
||||
import { EmptyState } from "../brand/empty-state";
|
||||
import { api, ApiError, errorMessage } from "../../lib/api-client";
|
||||
import {
|
||||
SUBMISSION_NOTE_MAX,
|
||||
submissionLink,
|
||||
type SubmissionBoxConfig,
|
||||
type SubmissionGroupSummary,
|
||||
type SubmissionQueueFilter,
|
||||
type SubmissionRecord,
|
||||
} from "../../lib/submissions-types";
|
||||
|
||||
/**
|
||||
* One group's card on the Groups screen: the "Submission box" admin section
|
||||
* (public link show/copy, rotate token, enable/disable) plus the moderation
|
||||
* queue — pending by default, approved/rejected tabs, approve and
|
||||
* reject-with-note.
|
||||
*
|
||||
* Both halves are permission-aware in the API (workspace.members.manage on
|
||||
* all four endpoints), so the card hides its controls for plain members and
|
||||
* still shows their group name — matching how the More page lists groups.
|
||||
*/
|
||||
|
||||
/** Local byte formatter (components/video/format.ts shape, kept unshared per file ownership). */
|
||||
function formatBytes(bytes: number): string {
|
||||
if (bytes < 1024) return `${bytes} B`;
|
||||
const units = ["KB", "MB", "GB", "TB"];
|
||||
let value = bytes / 1024;
|
||||
let unit = 0;
|
||||
while (value >= 1024 && unit < units.length - 1) {
|
||||
value /= 1024;
|
||||
unit += 1;
|
||||
}
|
||||
const text = value >= 100 ? String(Math.round(value)) : value.toFixed(1);
|
||||
return `${text} ${units[unit] ?? "KB"}`;
|
||||
}
|
||||
|
||||
function formatDate(iso: string): string {
|
||||
const date = new Date(iso);
|
||||
if (Number.isNaN(date.getTime())) return iso;
|
||||
return date.toLocaleString(undefined, { dateStyle: "medium", timeStyle: "short" });
|
||||
}
|
||||
|
||||
const kindLabel = (kind: SubmissionRecord["kind"]): string => (kind === "video" ? "video" : "photo");
|
||||
|
||||
const stateTone = (state: SubmissionRecord["state"]): "live" | "danger" | "warn" =>
|
||||
state === "approved" ? "live" : state === "rejected" ? "danger" : "warn";
|
||||
|
||||
type TabFilter = Exclude<SubmissionQueueFilter, "all">;
|
||||
|
||||
export function GroupSubmissionsCard({
|
||||
group,
|
||||
canManage,
|
||||
live,
|
||||
}: {
|
||||
group: SubmissionGroupSummary;
|
||||
canManage: boolean;
|
||||
live: boolean;
|
||||
}): React.ReactElement {
|
||||
const [box, setBox] = useState<SubmissionBoxConfig | null>(null);
|
||||
const [boxState, setBoxState] = useState<"loading" | "ready" | "missing" | "error">("loading");
|
||||
const [boxError, setBoxError] = useState<string | null>(null);
|
||||
const [linkShown, setLinkShown] = useState(false);
|
||||
const [copied, setCopied] = useState(false);
|
||||
const [busy, setBusy] = useState<string | null>(null);
|
||||
const [notice, setNotice] = useState<string | null>(null);
|
||||
const [actionError, setActionError] = useState<string | null>(null);
|
||||
|
||||
const [queue, setQueue] = useState<SubmissionRecord[]>([]);
|
||||
const [queueState, setQueueState] = useState<"loading" | "ready" | "error">("loading");
|
||||
const [queueError, setQueueError] = useState<string | null>(null);
|
||||
const [filter, setFilter] = useState<TabFilter>("pending");
|
||||
const [rejecting, setRejecting] = useState<SubmissionRecord | null>(null);
|
||||
const [note, setNote] = useState("");
|
||||
const [boxNonce, setBoxNonce] = useState(0);
|
||||
const [queueNonce, setQueueNonce] = useState(0);
|
||||
|
||||
// The box config is nested under /groups/:id and shares its busy/error
|
||||
// surface with the rotate/enable mutations, so it owns a plain effect rather
|
||||
// than useApiResource.
|
||||
useEffect(() => {
|
||||
if (!canManage || !live) {
|
||||
setBoxState("loading");
|
||||
return;
|
||||
}
|
||||
let cancelled = false;
|
||||
setBoxState("loading");
|
||||
api
|
||||
.get<SubmissionBoxConfig>(`/groups/${encodeURIComponent(group.id)}/submission-box`)
|
||||
.then((config) => {
|
||||
if (cancelled) return;
|
||||
setBox(config);
|
||||
setBoxState("ready");
|
||||
setBoxError(null);
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
if (cancelled) return;
|
||||
if (err instanceof ApiError && err.status === 404) {
|
||||
// "this group has no submission box" is a state, not a failure.
|
||||
setBoxState("missing");
|
||||
setBoxError(null);
|
||||
return;
|
||||
}
|
||||
setBoxState("error");
|
||||
setBoxError(errorMessage(err));
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [group.id, canManage, live, boxNonce]);
|
||||
|
||||
// The moderation queue: ?state=pending|approved|rejected|all (the API folds
|
||||
// anything else into pending).
|
||||
useEffect(() => {
|
||||
if (!live) {
|
||||
setQueue([]);
|
||||
setQueueState("ready");
|
||||
return;
|
||||
}
|
||||
let cancelled = false;
|
||||
setQueueState("loading");
|
||||
api
|
||||
.get<SubmissionRecord[]>(`/groups/${encodeURIComponent(group.id)}/submissions`, { state: filter })
|
||||
.then((rows) => {
|
||||
if (cancelled) return;
|
||||
setQueue(rows);
|
||||
setQueueState("ready");
|
||||
setQueueError(null);
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
if (cancelled) return;
|
||||
if (err instanceof ApiError && err.status === 404) {
|
||||
// No box for this group yet — nothing has ever been submitted.
|
||||
setQueue([]);
|
||||
setQueueState("ready");
|
||||
setQueueError(null);
|
||||
return;
|
||||
}
|
||||
setQueueState("error");
|
||||
setQueueError(errorMessage(err));
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [group.id, filter, live, queueNonce]);
|
||||
|
||||
async function configure(action: { enabled?: boolean; rotateToken?: boolean }, label: string): Promise<void> {
|
||||
if (!live) return;
|
||||
setBusy(label);
|
||||
setActionError(null);
|
||||
setNotice(null);
|
||||
try {
|
||||
const config = await api.post<SubmissionBoxConfig>(`/groups/${encodeURIComponent(group.id)}/submission-box`, action);
|
||||
setBox(config);
|
||||
setBoxState("ready");
|
||||
if (action.rotateToken === true) {
|
||||
// The old token died with the rotate: never show a stale link again.
|
||||
setLinkShown(false);
|
||||
setCopied(false);
|
||||
setNotice("Token rotated — the previous link no longer works. Copy the new one.");
|
||||
} else if (action.enabled === true) {
|
||||
setNotice("Box open — the public link accepts submissions again.");
|
||||
} else if (action.enabled === false) {
|
||||
setNotice("Box closed — the public link now says the box isn't open.");
|
||||
}
|
||||
} catch (err) {
|
||||
setActionError(errorMessage(err));
|
||||
} finally {
|
||||
setBusy(null);
|
||||
}
|
||||
}
|
||||
|
||||
async function decide(submission: SubmissionRecord, decision: "approve" | "reject", rejectionNote?: string): Promise<void> {
|
||||
if (!live) return;
|
||||
setBusy(submission.id);
|
||||
setActionError(null);
|
||||
setNotice(null);
|
||||
try {
|
||||
const updated = await api.post<SubmissionRecord>(`/submissions/${encodeURIComponent(submission.id)}/${decision}`, {
|
||||
groupId: group.id,
|
||||
...(decision === "reject" && rejectionNote !== undefined ? { note: rejectionNote } : {}),
|
||||
});
|
||||
// The pending tab drops the row; the decided tabs show the new state.
|
||||
setQueue((current) =>
|
||||
filter === "pending"
|
||||
? current.filter((row) => row.id !== updated.id)
|
||||
: current.map((row) => (row.id === updated.id ? updated : row)),
|
||||
);
|
||||
setRejecting(null);
|
||||
setNote("");
|
||||
setNotice(
|
||||
decision === "approve"
|
||||
? `Approved — the ${kindLabel(updated.kind)} is in the ${updated.kind === "video" ? "video library" : "group's assets"}.`
|
||||
: "Rejected — the note is kept in the moderation log.",
|
||||
);
|
||||
setQueueNonce((n) => n + 1);
|
||||
} catch (err) {
|
||||
setActionError(errorMessage(err));
|
||||
} finally {
|
||||
setBusy(null);
|
||||
}
|
||||
}
|
||||
|
||||
const link = box !== null ? submissionLink(box.token) : null;
|
||||
const tabs: Array<{ value: TabFilter; label: string }> = [
|
||||
{ value: "pending", label: "Pending" },
|
||||
{ value: "approved", label: "Approved" },
|
||||
{ value: "rejected", label: "Rejected" },
|
||||
];
|
||||
|
||||
return (
|
||||
<Card className="p-4 sm:p-5">
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<h2 className="text-sm font-semibold">{group.name}</h2>
|
||||
{canManage && boxState === "ready" && box !== null && (
|
||||
<Badge tone={box.enabled ? "live" : "neutral"}>{box.enabled ? "box open" : "box closed"}</Badge>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{canManage && (
|
||||
<section className="mt-4" aria-label={`Submission box for ${group.name}`}>
|
||||
<h3 className="mb-2 text-2xs font-bold uppercase tracking-wider text-text-faint">Submission box</h3>
|
||||
{boxState === "loading" && (
|
||||
<span className="inline-flex items-center gap-2 text-xs text-text-faint"><Loading size={14} label="Loading the box" /> Loading…</span>
|
||||
)}
|
||||
{boxState === "error" && <p role="alert" className="text-xs text-danger">{boxError}</p>}
|
||||
{boxState === "missing" && (
|
||||
<>
|
||||
<p className="text-xs leading-relaxed text-text-faint">
|
||||
No submission box for this group yet — it is created the first time it is configured.
|
||||
</p>
|
||||
<Button size="sm" variant="outline" className="mt-2" disabled={busy !== null} onClick={() => setBoxNonce((n) => n + 1)}>
|
||||
{busy === null ? "Create box" : "Working…"}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
{boxState === "ready" && box !== null && link !== null && (
|
||||
<>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{linkShown ? (
|
||||
<code className="min-w-0 flex-1 basis-64 truncate rounded-md border border-border bg-surface-2 px-2 py-1.5 font-mono text-2xs text-text">{link}</code>
|
||||
) : (
|
||||
<Button size="sm" variant="outline" onClick={() => setLinkShown(true)}>Show public link</Button>
|
||||
)}
|
||||
{linkShown && (
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
void navigator.clipboard?.writeText(link).then(() => setCopied(true)).catch(() => setCopied(false));
|
||||
}}
|
||||
>
|
||||
{copied ? "Copied" : "Copy"}
|
||||
</Button>
|
||||
)}
|
||||
<Button size="sm" variant="outline" disabled={busy !== null} onClick={() => void configure({ rotateToken: true }, "rotate")}>
|
||||
{busy === "rotate" ? "Rotating…" : "Rotate token"}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant={box.enabled ? "danger" : "primary"}
|
||||
disabled={busy !== null}
|
||||
onClick={() => void configure({ enabled: !box.enabled }, "toggle")}
|
||||
>
|
||||
{busy === "toggle" ? "Saving…" : box.enabled ? "Disable box" : "Enable box"}
|
||||
</Button>
|
||||
</div>
|
||||
<p className="mt-2 text-2xs leading-relaxed text-text-faint">
|
||||
Anyone with this link can send footage and photos — no account needed. Rotating kills the old link immediately;
|
||||
disabling keeps the token but turns the link away.
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
</section>
|
||||
)}
|
||||
|
||||
<section className="mt-5" aria-label={`Submissions for ${group.name}`}>
|
||||
<div className="mb-2 flex flex-wrap items-center justify-between gap-2">
|
||||
<h3 className="text-2xs font-bold uppercase tracking-wider text-text-faint">Submissions</h3>
|
||||
<div className="flex items-center rounded-md border border-border bg-surface-2 p-0.5" role="group" aria-label="Submission state">
|
||||
{tabs.map((tab) => (
|
||||
<button
|
||||
key={tab.value}
|
||||
type="button"
|
||||
onClick={() => setFilter(tab.value)}
|
||||
aria-pressed={filter === tab.value}
|
||||
className={`min-h-8 rounded px-2.5 text-2xs font-bold uppercase transition-colors duration-150 ${
|
||||
filter === tab.value ? "bg-accent text-white" : "text-text-dim hover:text-text"
|
||||
}`}
|
||||
>
|
||||
{tab.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{notice !== null && <p className="mb-2 text-2xs text-live">{notice}</p>}
|
||||
{actionError !== null && <p role="alert" className="mb-2 text-xs text-danger">{actionError}</p>}
|
||||
{queueError !== null && <p role="alert" className="mb-2 text-xs text-danger">{queueError}</p>}
|
||||
|
||||
{queueState === "loading" && (
|
||||
<span className="inline-flex items-center gap-2 text-xs text-text-faint"><Loading size={14} label="Loading submissions" /> Loading…</span>
|
||||
)}
|
||||
{queueState === "ready" && queue.length === 0 && (
|
||||
<EmptyState
|
||||
size={40}
|
||||
title={filter === "pending" ? "Nothing waiting for review" : `No ${filter} submissions yet`}
|
||||
hint={filter === "pending" ? "Hand out the public link — whatever people send lands here." : undefined}
|
||||
/>
|
||||
)}
|
||||
{queueState === "ready" && queue.length > 0 && (
|
||||
<ul className="divide-y divide-border-subtle">
|
||||
{queue.map((row) => (
|
||||
<li key={row.id} className="flex flex-wrap items-start justify-between gap-3 py-3">
|
||||
<div className="min-w-0">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Badge tone="accent">{kindLabel(row.kind)}</Badge>
|
||||
<span className="font-mono text-2xs text-text-dim">{formatBytes(row.sizeBytes)}</span>
|
||||
<span className="text-2xs text-text-faint">{formatDate(row.createdAt)}</span>
|
||||
{filter !== "pending" && <Badge tone={stateTone(row.state)}>{row.state}</Badge>}
|
||||
</div>
|
||||
<div className="mt-1 text-xs text-text-dim">
|
||||
{row.creditName !== null && row.creditName !== "" ? `credit: ${row.creditName}` : "no credit name given"}
|
||||
</div>
|
||||
{row.contact !== null && row.contact !== "" && (
|
||||
<div className="mt-0.5 text-2xs text-text-faint">contact (admins only): {row.contact}</div>
|
||||
)}
|
||||
{row.rejectionNote !== null && row.rejectionNote !== "" && (
|
||||
<div className="mt-0.5 text-2xs text-danger">note: {row.rejectionNote}</div>
|
||||
)}
|
||||
</div>
|
||||
{row.state === "pending" && canManage && (
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
<Button size="sm" variant="primary" disabled={busy !== null} onClick={() => void decide(row, "approve")}>
|
||||
{busy === row.id ? "Approving…" : "Approve"}
|
||||
</Button>
|
||||
<Button size="sm" variant="danger" disabled={busy !== null} onClick={() => { setRejecting(row); setNote(""); setActionError(null); }}>
|
||||
Reject…
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
{queueState === "ready" && queue.length > 0 && filter === "pending" && (
|
||||
<p className="mt-2 text-2xs text-text-faint">
|
||||
Approving sends video to the video library and photos to the group's assets; rejecting keeps only the note.
|
||||
</p>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{rejecting !== null && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4" onClick={() => setRejecting(null)}>
|
||||
<div
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label="Reject submission"
|
||||
className="w-[460px] max-w-[92vw] rounded-lg border border-border bg-surface p-4 shadow-2xl"
|
||||
onClick={(event: React.MouseEvent) => event.stopPropagation()}
|
||||
>
|
||||
<h2 className="text-sm font-semibold">Reject this submission</h2>
|
||||
<p className="mt-1 text-xs text-text-dim">
|
||||
{kindLabel(rejecting.kind)} · {formatBytes(rejecting.sizeBytes)} · {formatDate(rejecting.createdAt)}
|
||||
</p>
|
||||
<p className="mt-2 text-xs leading-relaxed text-text-faint">
|
||||
A note is required — it goes in the moderation log.
|
||||
</p>
|
||||
<textarea
|
||||
value={note}
|
||||
onChange={(event) => setNote(event.target.value)}
|
||||
maxLength={SUBMISSION_NOTE_MAX}
|
||||
rows={3}
|
||||
placeholder="Why this isn't being used (kept in the moderation log)"
|
||||
className="mt-3 w-full resize-none rounded-md border border-border bg-surface-2 p-2 text-sm text-text focus:border-accent focus:outline-none"
|
||||
/>
|
||||
<p className="mt-1 text-right text-2xs text-text-faint">{note.length}/{SUBMISSION_NOTE_MAX}</p>
|
||||
{actionError !== null && <p role="alert" className="mt-2 text-xs text-danger">{actionError}</p>}
|
||||
<div className="mt-3 flex justify-end gap-2">
|
||||
<Button variant="ghost" onClick={() => { setRejecting(null); setNote(""); setActionError(null); }}>Cancel</Button>
|
||||
<Button
|
||||
variant="danger"
|
||||
disabled={busy !== null || note.trim() === ""}
|
||||
onClick={() => void decide(rejecting, "reject", note.trim())}
|
||||
>
|
||||
{busy !== null ? "Rejecting…" : "Reject with note"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
100
apps/web/src/lib/submissions-types.ts
Normal file
100
apps/web/src/lib/submissions-types.ts
Normal file
|
|
@ -0,0 +1,100 @@
|
|||
/**
|
||||
* Wire shapes for the anonymous submission boxes (apps/api/src/submissions).
|
||||
*
|
||||
* Kept local to the web app so it does not collide with the shared
|
||||
* api-types.ts other builders may be editing in parallel — the same standing
|
||||
* as components/files/types.ts. Hand-synced with submissions.controller.ts
|
||||
* and submissions.service.ts (commit 599a68c).
|
||||
*
|
||||
* JSON note: the API returns `createdAt: Date`, which serialises to an ISO
|
||||
* string on the wire, so every date in here is typed as string.
|
||||
*/
|
||||
|
||||
export type SubmissionKind = "video" | "photo";
|
||||
|
||||
export type SubmissionState = "pending" | "approved" | "rejected";
|
||||
|
||||
/** Moderation queue filter the API accepts (GET ?state=). */
|
||||
export type SubmissionQueueFilter = "pending" | "approved" | "rejected" | "all";
|
||||
|
||||
/** GET /public/submit/:token — public box metadata for the submit page. */
|
||||
export interface SubmissionBoxInfo {
|
||||
id: string;
|
||||
groupId: string;
|
||||
licenseDefault: string;
|
||||
enabled: boolean;
|
||||
}
|
||||
|
||||
/** POST /public/submit/:token → 202 */
|
||||
export interface SubmissionReceiveResult {
|
||||
submissionId: string;
|
||||
state: "pending";
|
||||
}
|
||||
|
||||
/** GET/POST /groups/:groupId/submission-box — token + open switch. */
|
||||
export interface SubmissionBoxConfig {
|
||||
token: string;
|
||||
enabled: boolean;
|
||||
}
|
||||
|
||||
/** GET /groups/:groupId/submissions — contact is licensing-inquiry data that
|
||||
* only this moderation surface ever renders; it is never copied onto assets. */
|
||||
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: SubmissionState;
|
||||
rejectionNote: string | null;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
/** POST /submissions/:id/approve|reject — the decided record plus where it landed. */
|
||||
export type SubmissionDecisionResult = SubmissionRecord & {
|
||||
videoAssetId?: string;
|
||||
groupAssetId?: string;
|
||||
};
|
||||
|
||||
/** The id/name slice of GET /groups/mine the submission surfaces need. */
|
||||
export interface SubmissionGroupSummary {
|
||||
id: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
/* Intake caps, mirroring apps/api/src/submissions/submissions.service.ts. */
|
||||
|
||||
export const SUBMISSION_MAX_VIDEO_BYTES = 200 * 1024 * 1024;
|
||||
export const SUBMISSION_MAX_PHOTO_BYTES = 50 * 1024 * 1024;
|
||||
|
||||
/** The magic-byte window the API reads before deciding a type; shorter files are "unrecognised". */
|
||||
export const SUBMISSION_SNIFF_BYTES = 32;
|
||||
|
||||
/** optionalText() caps in the API's receive path. */
|
||||
export const SUBMISSION_CREDIT_MAX = 100;
|
||||
export const SUBMISSION_CONTACT_MAX = 200;
|
||||
|
||||
/** submissions.rejection_note CHECK constraint (≤ 500). */
|
||||
export const SUBMISSION_NOTE_MAX = 500;
|
||||
|
||||
/** The qualifier every submitter must tick — exact text from the box design. */
|
||||
export const SUBMISSION_LICENSE_TEXT =
|
||||
"I took or own this footage and release it under CC BY-SA 4.0 — it may be shared and remixed with attribution";
|
||||
|
||||
/** Canonical public origin of the web app; overridable for local/dev runs. */
|
||||
export const SUBMIT_APP_ORIGIN = (process.env.NEXT_PUBLIC_APP_ORIGIN ?? "https://app.occupymedia.cc").replace(/\/$/, "");
|
||||
|
||||
/** The link a collective hands out: https://app.occupymedia.cc/submit/<token>. */
|
||||
export function submissionLink(token: string): string {
|
||||
return `${SUBMIT_APP_ORIGIN}/submit/${token}`;
|
||||
}
|
||||
|
||||
/** Size cap per kind — the same rule the API applies after sniffing the bytes. */
|
||||
export function submissionMaxBytes(kind: SubmissionKind): number {
|
||||
return kind === "video" ? SUBMISSION_MAX_VIDEO_BYTES : SUBMISSION_MAX_PHOTO_BYTES;
|
||||
}
|
||||
9
pnpm-lock.yaml
generated
9
pnpm-lock.yaml
generated
|
|
@ -145,6 +145,12 @@ importers:
|
|||
|
||||
apps/media-worker:
|
||||
dependencies:
|
||||
'@aws-sdk/client-s3':
|
||||
specifier: ^3.1146.0
|
||||
version: 3.1146.0
|
||||
pg:
|
||||
specifier: ^8.23.1
|
||||
version: 8.23.1
|
||||
pino:
|
||||
specifier: ^9.3.2
|
||||
version: 9.14.0
|
||||
|
|
@ -152,6 +158,9 @@ importers:
|
|||
'@types/node':
|
||||
specifier: ^22.5.0
|
||||
version: 22.20.5
|
||||
'@types/pg':
|
||||
specifier: ^8.23.1
|
||||
version: 8.23.1
|
||||
tsx:
|
||||
specifier: ^4.17.0
|
||||
version: 4.23.15
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue