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
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;
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue