Add group cloud: files index api, web UI, archive sync bot

The Telegram-like group file contract (packages/core shared-files.ts) becomes
a working subsystem, built in three parallel tracks:

API (NestJS): migration 029 group_files (chat_media_archive metadata-only
index with partial replay-unique + RLS, group_file_shares with sha256 token
persistence, archive_sync_state/outbox durable-claim seam incl. a unique
claim_key index), group-files module (list/search with cursor pagination,
direct upload ≤20 MiB streamed+hashed to oCIS WebDAV, download with ACL +
retention rechecks, share links with expiry, retention modes incl.
legalHold) — RBAC group-membership authz + audit chain events.

Web: Files section per group — group picker, drag-drop upload (20 MiB cap),
search + MIME/origin filters, cursor pagination, download via authenticated
blob fetch, share dialog with expiry + shown-once token, admin retention
dialog; bilingual EN/ES (wiki i18n pattern); nav sidebar/palette/bottom-nav
wiring; MIME classes aligned to the DB CHECK (document/other).

Archive bot (apps/archive-bot): plain sync-loop Matrix bot mirroring
attachments from explicitly-mapped unencrypted archive rooms — raw /sync
with durable since-cursor, outbox-claimed ingestion (hash-verified stream,
no temp files), magic-byte MIME sniffing, safe-name normalization, WebDAV
upload, indexed + replied with link, redaction→unavailable; health endpoint;
Dockerfile. Tests 74/74, typecheck clean across api/web/bot.
This commit is contained in:
glass beadian 2026-10-11 08:21:11 +00:00
parent eb1b60f67d
commit d58db63fe6
58 changed files with 5543 additions and 4 deletions

View file

@ -0,0 +1,245 @@
-- 029: group files index — the Telegram-like group file contract
-- (docs/occupymedia-storage.md §"Telegram-like group file contract" and
-- §"Metadata index and hub-archive seam").
--
-- Metadata ONLY. chat_media_archive is the index over remote oCIS payloads:
-- it NEVER holds file bodies, encrypted-file keys, WOPI tokens or presigned
-- URLs. Verified byte size + SHA-256 only — the payload always lives behind
-- oCIS WebDAV, streamed through the API with the hash re-verified.
--
-- Attribution is by sender MXID (never a display name/alias) and origin is
-- one of the four SharedFileOrigin kinds (packages/core/src/shared-files.ts).
-- event_id is NULL for direct uploads/exports until a room announcement
-- supplies one, so replay identity uniqueness is PARTIAL on event_id IS NOT
-- NULL: "unique (workspace_id, room_id, event_id) for attachment events".
--
-- Retention is kept SEPARATE from link expiry: mode is 'keep' (phase-one
-- default, no automated purge) or 'until' (requires retention_until), with an
-- orthogonal legal_hold flag that blocks deletion. A share expiring never
-- deletes or hides a file.
--
-- group_file_shares persists link IDs + sha256(token) ONLY: the plaintext
-- token is returned once at creation and can never be recovered from the DB.
-- No presigned object URLs are ever stored. public links require an explicit
-- expiry (access='public' ⇒ expires_at is bounded below); the claim path uses
-- the narrow SECURITY DEFINER lookup (017 invite-link pattern) because the
-- claimer has no tenant context and the table is FORCE RLS.
--
-- archive_sync_state / archive_sync_outbox reserve the hub-archive seam
-- (HubArchivePort): the sync bot (not implemented in this slice) commits the
-- library reference and its outbox entry BEFORE advancing sync_cursor.
--
-- RLS / NULLIF / reject_workspace_change / sp_app grants follow the
-- 001/008/021/026 conventions: no app.workspace_id => NULL => no rows; FORCE
-- binds sp_app too. `origin`/`mime_class`/`retention_mode`/`legal_hold` CHECKs are coarse
-- DB backstops — the authoritative typed validation is app-side
-- (apps/api/src/group-files/group-files.validation.ts).
BEGIN;
-- ---------------------------------------------------------------------------
-- chat_media_archive — the metadata index
-- ---------------------------------------------------------------------------
CREATE TABLE chat_media_archive (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
workspace_id uuid NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE ON UPDATE RESTRICT,
group_id uuid NOT NULL REFERENCES groups(id) ON DELETE CASCADE ON UPDATE RESTRICT,
-- Immutable Matrix room ID, never an alias. Tenant-pinned below.
room_id text NOT NULL CHECK (btrim(room_id) <> '' AND room_id ~ '^!'),
-- NULL for direct uploads/pad exports until a room announcement supplies one.
event_id text CHECK (event_id IS NULL OR btrim(event_id) <> ''),
origin text NOT NULL CHECK (origin IN ('matrix','bridge','upload','pad-export')),
-- Sender attribution: MXID only, never a display name (mxc/handle spoofing).
sender_mxid text NOT NULL CHECK (sender_mxid ~ '^@[^@\s]+:[^@\s]+$'),
-- Redacted/superseded Matrix event context for the hub-archive seam; never
-- file bodies or encrypted-file keys (jsonb_typeof backstop only).
matrix_event_json jsonb CHECK (matrix_event_json IS NULL OR jsonb_typeof(matrix_event_json) = 'object'),
original_name text NOT NULL CHECK (btrim(original_name) <> '' AND char_length(original_name) <= 1024),
-- Normalized safe display filename (app-side sanitizeName).
safe_name text NOT NULL CHECK (btrim(safe_name) <> '' AND char_length(safe_name) <= 255),
mime text NOT NULL CHECK (mime <> ''),
-- Coarse class derived from mime (image/video/audio/document/other).
mime_class text NOT NULL CHECK (mime_class IN ('image','video','audio','document','other')),
byte_size bigint NOT NULL CHECK (byte_size >= 0),
-- SHA-256 of verified payload bytes, lowercase hex. Never an S3 ETag.
sha256 text NOT NULL CHECK (sha256 ~ '^[0-9a-f]{64}$'),
-- Stable oCIS library resource identity (payload stays remote).
ocis_resource_id text NOT NULL CHECK (btrim(ocis_resource_id) <> ''),
ocis_resource_version text,
created_at timestamptz NOT NULL,
indexed_at timestamptz NOT NULL DEFAULT now(),
available boolean NOT NULL DEFAULT true,
-- FileRetention (packages/core): mode is keep|until; legalHold is a FLAG
-- alongside the mode, not a third mode.
retention_mode text NOT NULL DEFAULT 'keep' CHECK (retention_mode IN ('keep','until')),
legal_hold boolean NOT NULL DEFAULT false,
retention_until timestamptz,
CONSTRAINT chat_media_archive_ws_group FOREIGN KEY (group_id, workspace_id)
REFERENCES groups(id, workspace_id) ON DELETE CASCADE ON UPDATE RESTRICT,
-- 'until' retention requires a deadline; 'keep' must not carry one.
-- legalHold is orthogonal: it blocks deletion regardless of mode.
CONSTRAINT chat_media_archive_retention_shape CHECK (
(retention_mode = 'until' AND retention_until IS NOT NULL)
OR (retention_mode = 'keep' AND retention_until IS NULL)
)
);
-- Tenant-pinned reference target for group_file_shares (015 style):
-- the composite UNIQUE lets child FKs pin (id, workspace_id) so a share can
-- never point across tenants even if ids collided.
CREATE UNIQUE INDEX chat_media_archive_id_ws_key ON chat_media_archive (id, workspace_id);
CREATE TRIGGER trg_chat_media_archive_ws_immutable BEFORE UPDATE ON chat_media_archive
FOR EACH ROW EXECUTE FUNCTION reject_workspace_change();
ALTER TABLE chat_media_archive ENABLE ROW LEVEL SECURITY;
ALTER TABLE chat_media_archive FORCE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation ON chat_media_archive
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 chat_media_archive TO sp_app;
-- Replay identity for ATTACHMENT events (029 note: uploads have NULL event_id,
-- so the unique constraint must be partial). AttachmentArchiveKey's
-- collision-free replay rule, enforced durably.
CREATE UNIQUE INDEX chat_media_archive_replay_uq
ON chat_media_archive (workspace_id, room_id, event_id)
WHERE event_id IS NOT NULL;
-- Cursor pagination index: (createdAt,id) keyset scoped to workspace/group.
-- Spec: "indexes on (workspace_id, group_id, created_at desc, id desc)".
CREATE INDEX chat_media_archive_ws_group_time_idx
ON chat_media_archive (workspace_id, group_id, created_at DESC, id DESC);
-- Sender/MIME-class/checksum facets (spec: "indexes on ... sender, MIME class,
-- name and checksum"). MXID is indexed for per-sender listing/attribution.
CREATE INDEX chat_media_archive_sender_idx ON chat_media_archive (workspace_id, sender_mxid, created_at DESC);
CREATE INDEX chat_media_archive_mime_class_idx ON chat_media_archive (mime_class);
CREATE INDEX chat_media_archive_sha256_idx ON chat_media_archive (sha256);
CREATE INDEX chat_media_archive_name_idx ON chat_media_archive (safe_name text_pattern_ops);
-- ---------------------------------------------------------------------------
-- group_file_shares — link IDs + sha256(token), never presigned URLs
-- ---------------------------------------------------------------------------
CREATE TABLE group_file_shares (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
workspace_id uuid NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE ON UPDATE RESTRICT,
item_id uuid NOT NULL,
-- 'members' (default library link, ACL-checked) or 'public' (requires an
-- explicit grant + bounded expiry; revoked on removal per the contract).
access text NOT NULL DEFAULT 'members' CHECK (access IN ('members','public')),
-- sha256(plaintext token). The plaintext is returned ONCE at creation; the
-- DB can never re-derive it, so a leaked DB dump mints no downloads.
token_hash text NOT NULL UNIQUE CHECK (token_hash ~ '^[0-9a-f]{64}$'),
created_by uuid REFERENCES users(id) ON DELETE SET NULL,
expires_at timestamptz NOT NULL,
revoked_at timestamptz,
created_at timestamptz NOT NULL DEFAULT now(),
CONSTRAINT group_file_shares_ws_item FOREIGN KEY (item_id, workspace_id)
REFERENCES chat_media_archive(id, workspace_id) ON DELETE CASCADE ON UPDATE RESTRICT
);
CREATE UNIQUE INDEX group_file_shares_ws_item_key ON group_file_shares (item_id, workspace_id);
CREATE TRIGGER trg_group_file_shares_ws_immutable BEFORE UPDATE ON group_file_shares
FOR EACH ROW EXECUTE FUNCTION reject_workspace_change();
ALTER TABLE group_file_shares ENABLE ROW LEVEL SECURITY;
ALTER TABLE group_file_shares FORCE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation ON group_file_shares
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 group_file_shares TO sp_app;
-- ---------------------------------------------------------------------------
-- share_for_public_claim — narrow SECURITY DEFINER lookup (017 pattern)
-- ---------------------------------------------------------------------------
-- Public downloads arrive with NO tenant context (the claimer holds only the
-- token) and group_file_shares is FORCE RLS, so an ordinary SELECT sees zero
-- rows. Exactly like invite_link_for_claim (017), this exposes ONE row keyed
-- by the secret — workspace/file discovery only. It returns the row only when
-- it is live: not revoked and not expired (the caller still rechecks the item
-- row's availability/retention inside the tenant context it enters next).
CREATE FUNCTION share_for_public_claim(p_token_hash text)
RETURNS TABLE(id uuid, workspace_id uuid, item_id uuid, access text,
expires_at timestamptz, revoked_at timestamptz)
LANGUAGE sql STABLE SECURITY DEFINER SET search_path = public AS $$
SELECT s.id, s.workspace_id, s.item_id, s.access, s.expires_at, s.revoked_at
FROM group_file_shares s
WHERE s.token_hash = p_token_hash
AND s.revoked_at IS NULL
AND s.expires_at > now()
$$;
REVOKE ALL ON FUNCTION share_for_public_claim(text) FROM PUBLIC;
GRANT EXECUTE ON FUNCTION share_for_public_claim(text) TO sp_app;
-- ---------------------------------------------------------------------------
-- archive_sync_state — per-room sync cursor (hub-archive seam)
-- ---------------------------------------------------------------------------
CREATE TABLE archive_sync_state (
room_id text PRIMARY KEY CHECK (btrim(room_id) <> ''),
workspace_id uuid NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE ON UPDATE RESTRICT,
-- Last committed Matrix sync token; the bot never advances past a failed
-- event (durable retry lives in archive_sync_outbox).
sync_cursor text,
updated_at timestamptz NOT NULL DEFAULT now()
);
CREATE TRIGGER trg_archive_sync_state_ws_immutable BEFORE UPDATE ON archive_sync_state
FOR EACH ROW EXECUTE FUNCTION reject_workspace_change();
ALTER TABLE archive_sync_state ENABLE ROW LEVEL SECURITY;
ALTER TABLE archive_sync_state FORCE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation ON archive_sync_state
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 archive_sync_state TO sp_app;
-- ---------------------------------------------------------------------------
-- archive_sync_outbox — durable reply/retry records before cursor advance
-- ---------------------------------------------------------------------------
CREATE TABLE archive_sync_outbox (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
workspace_id uuid NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE ON UPDATE RESTRICT,
room_id text NOT NULL CHECK (btrim(room_id) <> ''),
event_id text CHECK (event_id IS NULL OR btrim(event_id) <> ''),
-- Idempotent claim key: attachmentArchiveKey(workspace,room,event) shape.
claim_key text NOT NULL CHECK (btrim(claim_key) <> ''),
status text NOT NULL DEFAULT 'pending' CHECK (status IN ('pending','done','failed')),
attempts int NOT NULL DEFAULT 0 CHECK (attempts >= 0),
last_error text,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now()
);
-- DB-level idempotency for the durable claim: one outbox row per
-- attachmentArchiveKey(workspace,room,event). The bot inserts with a
-- NOT EXISTS guard + bare ON CONFLICT DO NOTHING; this index makes the
-- race window impossible rather than unlikely.
CREATE UNIQUE INDEX archive_sync_outbox_claim_key_uq ON archive_sync_outbox (claim_key);
CREATE TRIGGER trg_archive_sync_outbox_ws_immutable BEFORE UPDATE ON archive_sync_outbox
FOR EACH ROW EXECUTE FUNCTION reject_workspace_change();
ALTER TABLE archive_sync_outbox ENABLE ROW LEVEL SECURITY;
ALTER TABLE archive_sync_outbox FORCE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation ON archive_sync_outbox
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 archive_sync_outbox TO sp_app;
-- Worker scan: pending-first, oldest-first.
CREATE INDEX archive_sync_outbox_status_idx ON archive_sync_outbox (status, created_at);
-- Replay/idempotency lookup by claim key.
CREATE INDEX archive_sync_outbox_claim_idx ON archive_sync_outbox (claim_key);
COMMIT;

View file

@ -28,6 +28,7 @@ import { LiveChatModule } from "./live-chat/live-chat.module";
import { LiveModule } from "./live/live.module.js";
import { ChatgovModule } from "./chatgov/chatgov.module.js";
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 { SettingsService } from "./settings/settings.service.js";
@ -37,7 +38,7 @@ import { AnalyticsController } from "./analytics/analytics.controller";
import { SnapshotService } from "./analytics/snapshot.service";
@Module({
imports: [HealthModule, LiveMonitorModule, MediaControlModule, ProductionModule, RealtimeModule, LiveChatModule, LiveModule, VirtualChannelsModule, ChatgovModule, IntakeModule, SettingsModule],
imports: [HealthModule, LiveMonitorModule, MediaControlModule, ProductionModule, RealtimeModule, LiveChatModule, LiveModule, VirtualChannelsModule, ChatgovModule, IntakeModule, GroupFilesModule, SettingsModule],
controllers: [
DemoController,
PublishingController,

View file

@ -0,0 +1,44 @@
/**
* (createdAt,id) keyset cursor for the group-files index, scoped to
* workspace/group in the query itself (docs/occupymedia-storage.md
* §"Metadata index and hub-archive seam": "Use a (createdAt,id) cursor scoped
* to workspace/group").
*
* House pattern (video.service.ts / intake-search.ts): base64url JSON, the
* cursor carries ONLY the sort key — the workspace/group scope never travels
* in the cursor, it is re-bound server-side on every request so a cursor from
* another tenant/group is just an empty page.
*/
import { BadRequestException } from "@nestjs/common";
import { isUuid } from "../http/validation.js";
export interface FileListCursor {
createdAt: string;
id: string;
}
const ISO_TIMESTAMP = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d+)?Z$/;
export function encodeFileListCursor(cursor: FileListCursor): string {
return Buffer.from(JSON.stringify(cursor), "utf8").toString("base64url");
}
export function decodeFileListCursor(cursor: string): FileListCursor {
let decoded: unknown;
try {
decoded = JSON.parse(Buffer.from(cursor, "base64url").toString("utf8")) as unknown;
} catch {
throw new BadRequestException("cursor is invalid");
}
if (typeof decoded !== "object" || decoded === null || Array.isArray(decoded)) {
throw new BadRequestException("cursor is invalid");
}
const row = decoded as Record<string, unknown>;
const createdAt = row.createdAt;
const id = row.id;
if (typeof createdAt !== "string" || !ISO_TIMESTAMP.test(createdAt) || !Number.isFinite(Date.parse(createdAt))) {
throw new BadRequestException("cursor is invalid");
}
if (typeof id !== "string" || !isUuid(id)) throw new BadRequestException("cursor is invalid");
return { createdAt, id };
}

View file

@ -0,0 +1,148 @@
import { BadRequestException, Body, Controller, Delete, Get, HttpCode, Inject, Param, ParseUUIDPipe, Post, Query, Req, Res, UseGuards } from "@nestjs/common";
import type { Request, Response } from "express";
import { RbacGuard, RequirePermission, type AuthenticatedRequest } from "../rbac/rbac.guard.js";
import { requireObject, requirePositiveInt, requireSession, requireString } from "../http/validation.js";
import { GroupFilesService, type FilePage } from "./group-files.service.js";
import type { GroupFile } from "@tactical-media-broadcast-hub/core";
import { verifyWhileStreaming } from "./ocis-webdav.js";
/**
* Group files API — the Telegram-like group file surface.
*
* GET /files list/search, keyset (groupId, senderMxid,
* mimeClass, name, from, to, cursor, limit)
* GET /files/:id one GroupFile
* GET /files/:id/download stream payload (sha256 re-verified)
* POST /files/:id/retention set retention (keep/until/legalHold)
* POST /files/:id/revoke revoke links + stop serving
* POST /files/:id/shares mint a share link (token shown ONCE)
* DELETE /files/shares/:shareId revoke a share
*
* Authenticated routes gate on media.view (read) / media.manage (mutations) —
* the same media permission family the video library uses. ACLs are
* re-resolved per request in the service (current group membership), never
* carried by the token.
*
* The download route streams: the API proxies the payload with the digest
* verified chunk-by-chunk instead of redirecting or presigning, per the
* "never presigned object URLs" contract.
*/
@Controller("files")
@UseGuards(RbacGuard)
export class GroupFilesController {
constructor(@Inject(GroupFilesService) private readonly files: GroupFilesService) {}
@Get()
@RequirePermission("media.view")
async list(@Req() request: Request & AuthenticatedRequest, @Query() query: Record<string, unknown>): Promise<FilePage> {
const session = requireSession(request);
return this.files.listFiles(session.workspaceId, session.userId, session.role, query);
}
@Get(":id")
@RequirePermission("media.view")
async get(@Req() request: Request & AuthenticatedRequest, @Param("id", ParseUUIDPipe) id: string): Promise<GroupFile> {
const session = requireSession(request);
return this.files.getFile(session.workspaceId, session.userId, session.role, id);
}
@Get(":id/download")
@RequirePermission("media.view")
async download(
@Req() request: Request & AuthenticatedRequest,
@Param("id", ParseUUIDPipe) id: string,
@Res() response: Response,
): Promise<void> {
const session = requireSession(request);
const handle = await this.files.openDownload(session.workspaceId, session.userId, session.role, id);
response.status(200);
response.setHeader("Content-Type", handle.item.mimeType);
response.setHeader("Content-Disposition", `attachment; filename="${encodeURIComponent(handle.item.name)}"`);
response.setHeader("Cache-Control", "no-store");
if (handle.download.sizeBytes !== null) response.setHeader("Content-Length", String(handle.download.sizeBytes));
response.flushHeaders();
await verifyWhileStreaming(handle.download, handle.item.checksum.value, (chunk) => {
response.write(chunk);
});
response.end();
}
@Post(":id/retention")
@RequirePermission("media.manage")
async setRetention(
@Req() request: Request & AuthenticatedRequest,
@Param("id", ParseUUIDPipe) id: string,
@Body() body: unknown,
): Promise<GroupFile> {
const session = requireSession(request);
return this.files.setRetention(session.workspaceId, id, requireObject(body, "body"), {
userId: session.userId,
role: session.role,
});
}
@Post(":id/revoke")
@HttpCode(200)
@RequirePermission("media.manage")
async revoke(@Req() request: Request & AuthenticatedRequest, @Param("id", ParseUUIDPipe) id: string): Promise<GroupFile> {
const session = requireSession(request);
return this.files.markRevoked(session.workspaceId, id, { userId: session.userId, role: session.role });
}
@Post(":id/shares")
@RequirePermission("media.manage")
async createShare(
@Req() request: Request & AuthenticatedRequest,
@Param("id", ParseUUIDPipe) id: string,
@Body() body: unknown,
): Promise<ReturnType<GroupFilesService["createShare"]>> {
const session = requireSession(request);
const dto = requireObject(body, "body");
if (dto.access !== undefined && dto.access !== "members" && dto.access !== "public") {
throw new BadRequestException("access must be 'members' or 'public'");
}
const expiresInHours = requirePositiveInt(dto.expiresInHours, "expiresInHours", 720);
return this.files.createShare(
session.workspaceId,
id,
{ access: dto.access, expiresInHours },
{ userId: session.userId, role: session.role },
);
}
/** Declared before :id routes so the literal path wins route matching. */
@Delete("shares/:shareId")
@RequirePermission("media.manage")
async revokeShare(
@Req() request: Request & AuthenticatedRequest,
@Param("shareId", ParseUUIDPipe) shareId: string,
): Promise<{ ok: true }> {
const session = requireSession(request);
await this.files.revokeShare(session.workspaceId, shareId, { userId: session.userId, role: session.role });
return { ok: true };
}
}
/**
* Public share-claim surface: NO RbacGuard (the claimer holds only the
* token). Token lives in the path — same shape as the invite claim routes.
*/
@Controller("public/files")
export class PublicFileShareController {
constructor(@Inject(GroupFilesService) private readonly files: GroupFilesService) {}
@Get("s/:token")
async claim(@Param("token") token: string, @Res() response: Response): Promise<void> {
response.setHeader("Cache-Control", "no-store");
const handle = await this.files.claimPublicDownload(requireString(token, "token"));
response.status(200);
response.setHeader("Content-Type", handle.item.mimeType);
response.setHeader("Content-Disposition", `attachment; filename="${encodeURIComponent(handle.item.name)}"`);
if (handle.download.sizeBytes !== null) response.setHeader("Content-Length", String(handle.download.sizeBytes));
response.flushHeaders();
await verifyWhileStreaming(handle.download, handle.item.checksum.value, (chunk) => {
response.write(chunk);
});
response.end();
}
}

View file

@ -0,0 +1,27 @@
import { Module } from "@nestjs/common";
import { GroupFilesController, PublicFileShareController } from "./group-files.controller.js";
import { GroupFilesService } from "./group-files.service.js";
/**
* Group files module — the metadata index + share/link lifecycle over
* chat_media_archive (029) and the oCIS WebDAV port (occ-files deploy).
*
* NOT registered in AppModule by this slice: per the deployment contract
* (infra/occupymedia/files/README.md) the oCIS service is scaffolded
* separately and admission checks are mandatory before this surface accepts
* user payloads. Wiring is one line in apps/api/src/app.module.ts:
*
* imports: [..., GroupFilesModule]
*
* until that gate is passed — the ChatgovModule precedent.
*
* The oCIS client is built lazily from env on first use, so importing the
* module never requires FILES_OCID_* to be present; routes fail 503 when the
* deployment is absent instead of breaking startup.
*/
@Module({
controllers: [GroupFilesController, PublicFileShareController],
providers: [GroupFilesService],
exports: [GroupFilesService],
})
export class GroupFilesModule {}

View file

@ -0,0 +1,304 @@
import type { PoolClient } from "pg";
import { GroupFilesService } from "./group-files.service.js";
import { hashShareToken } from "./share-token.js";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { BadRequestException, ConflictException, ForbiddenException, GoneException, NotFoundException } from "@nestjs/common";
/** Deterministic 64-hex helper: repeat a pattern to exactly 64 chars. */
function hex64(seed: string): string {
let out = "";
while (out.length < 64) out += Buffer.from(seed).toString("hex");
return out.slice(0, 64);
}
// ---------------------------------------------------------------------------
// Fake db surface: route by SQL shape, capture every statement.
// ---------------------------------------------------------------------------
type QueryResult = { rows: Record<string, unknown>[]; rowCount: number };
type Behavior = (sql: string, values: unknown[]) => QueryResult | Promise<QueryResult>;
let behavior: Behavior = () => ({ rows: [], rowCount: 0 });
const statements: Array<{ sql: string; values: unknown[] }> = [];
vi.mock("../db/client.js", () => ({
withTenant: async (_ws: string, fn: (client: PoolClient) => Promise<unknown>) =>
fn({
query: async (sql: string, values: unknown[] = []) => {
statements.push({ sql, values });
return behavior(sql, values);
},
} as unknown as PoolClient),
withSystem: async (fn: (client: PoolClient) => Promise<unknown>) =>
fn({
query: async (sql: string, values: unknown[] = []) => {
statements.push({ sql, values });
return behavior(sql, values);
},
} as unknown as PoolClient),
}));
const row = {
id: "11111111-1111-4111-8111-111111111111",
workspace_id: "22222222-2222-4222-8222-222222222222",
group_id: "33333333-3333-4333-8333-333333333333",
room_id: "!r:example.org",
event_id: "$e",
origin: "matrix",
sender_mxid: "@s:example.org",
matrix_event_json: { mxcUri: "mxc://example.org/x" },
original_name: "clip.mp4",
safe_name: "clip.mp4",
mime: "video/mp4",
mime_class: "video",
byte_size: "10",
sha256: hex64("z"),
ocis_resource_id: "res-1",
ocis_resource_version: "v1",
created_at: new Date("2026-01-02T03:04:05Z"),
indexed_at: new Date("2026-01-02T03:04:05Z"),
available: true,
retention_mode: "keep",
legal_hold: false,
retention_until: null,
share_access: null,
share_expires_at: null,
share_revoked_at: null,
};
/** sha256 hex of a string, computed with the real crypto module. */
function digestOf(input: string): string {
// eslint-disable-next-line @typescript-eslint/no-require-imports
const { createHash } = require("node:crypto") as typeof import("node:crypto");
return createHash("sha256").update(input, "utf8").digest("hex");
}
function envReady(): void {
process.env.FILES_OCID_WEBDAV_URL = "https://files.example.org";
process.env.FILES_OCID_USER = "svc";
process.env.FILES_OCID_PASSWORD = "pw";
process.env.FILES_PUBLIC_BASE = "https://files.example.org";
}
beforeEach(() => {
statements.length = 0;
behavior = () => ({ rows: [], rowCount: 0 });
envReady();
});
describe("GroupFilesService invariants", () => {
it("scopes listings to group membership for non-Owner/Admin, binding the caller id positionally", async () => {
behavior = () => ({ rows: [row], rowCount: 1 });
const service = new GroupFilesService();
const page = await service.listFiles(row.workspace_id, "user-1", "Publisher", {});
const list = statements.find((q) => q.sql.includes("FROM chat_media_archive"))!;
expect(list.sql).toContain("group_members");
expect(list.values[1]).toBe("user-1"); // caller id is a bound parameter…
expect(list.sql).not.toContain("user-1"); // …never SQL text
expect(list.sql).not.toContain("user-1");
// Owner/Admin skip the membership subquery but keep $2 bound
await service.listFiles(row.workspace_id, "user-1", "Owner", {});
const ownerList = statements.filter((q) => q.sql.includes("FROM chat_media_archive")).at(-1)!;
expect(ownerList.sql).not.toContain("group_members");
expect(ownerList.values[1]).toBe("user-1");
expect(page.items[0]?.status).toBe("available");
expect(page.items[0]?.shareLink.url).toBe("https://files.example.org/f/res-1");
});
it("filters facets positionally and paginates with a (createdAt,id) keyset cursor", async () => {
behavior = () => ({ rows: [row], rowCount: 1 });
const service = new GroupFilesService();
const cursor = Buffer.from(JSON.stringify({ createdAt: "2026-01-01T00:00:00Z", id: row.id })).toString("base64url");
await service.listFiles(row.workspace_id, "u", "Owner", {
groupId: row.group_id,
senderMxid: "@s:example.org",
mimeClass: "video",
name: "clip",
from: "2026-01-01",
to: "2026-02-01",
cursor,
limit: "10",
});
const q = statements.find((s) => s.sql.includes("FROM chat_media_archive"))!;
expect(q.values).toEqual([row.workspace_id, "u", row.group_id, "@s:example.org", "video", "%clip%", "2026-01-01", "2026-02-01", "2026-01-01T00:00:00Z", row.id, 11]);
expect(q.sql).toContain("(a.created_at, a.id) <");
expect(q.sql).toContain("ORDER BY a.created_at DESC, a.id DESC");
// bad facets are rejected before SQL
await expect(service.listFiles(row.workspace_id, "u", "Owner", { mimeClass: "zip" })).rejects.toBeInstanceOf(BadRequestException);
await expect(service.listFiles(row.workspace_id, "u", "Owner", { from: "nope" })).rejects.toBeInstanceOf(BadRequestException);
await expect(service.listFiles(row.workspace_id, "u", "Owner", { from: "2026-03-01", to: "2026-01-01" })).rejects.toBeInstanceOf(BadRequestException);
});
it("emits no presigned URL anywhere in a served payload", async () => {
behavior = () => ({ rows: [row], rowCount: 1 });
const service = new GroupFilesService();
const page = await service.listFiles(row.workspace_id, "u", "Owner", {});
expect(JSON.stringify(page)).not.toContain("X-Amz-Signature");
expect(JSON.stringify(page)).not.toContain("presign");
expect(page.items[0]?.shareLink.url).toBe("https://files.example.org/f/res-1");
});
it("keeps retention independent from share expiry, and legalHold is an orthogonal flag", async () => {
behavior = () => ({
rows: [{ ...row, share_access: "public", share_expires_at: new Date("2020-01-01T00:00:00Z") }],
rowCount: 1,
});
const service = new GroupFilesService();
const item = await service.getFile(row.workspace_id, "u", "Owner", row.id);
// share link long gone; the FILE itself is still fully available
expect(item.status).toBe("available");
expect(item.shareLink.expiresAt).toBe("2020-01-01T00:00:00.000Z");
expect(item.retention.mode).toBe("keep");
// legal hold survives alongside the mode (FileRetention contract)
behavior = () => ({ rows: [{ ...row, legal_hold: true }], rowCount: 1 });
const held = await service.getFile(row.workspace_id, "u", "Owner", row.id);
expect(held.retention).toEqual({ mode: "keep", legalHold: true });
expect(held.status).toBe("available");
});
it("reconstructs all four origin kinds from matrix_event_json", async () => {
const cases = [
["matrix", { mxcUri: "mxc://e/x" }, { kind: "matrix", mxcUri: "mxc://e/x" }],
["bridge", { mxcUri: "mxc://e/x", providerId: "telegram", remoteEventId: "9" }, { kind: "bridge", mxcUri: "mxc://e/x", providerId: "telegram", remoteEventId: "9" }],
["upload", null, { kind: "upload" }],
["pad-export", { padId: "p", revision: "3" }, { kind: "pad-export", padId: "p", revision: "3" }],
] as const;
for (const [origin, json, expected] of cases) {
behavior = () => ({ rows: [{ ...row, origin, matrix_event_json: json }], rowCount: 1 });
const service = new GroupFilesService();
const item = await service.getFile(row.workspace_id, "u", "Owner", row.id);
expect(item.origin).toEqual(expected);
}
});
it("returns the share token exactly once and keeps it out of audit rows", async () => {
let auditedDiff: unknown = null;
behavior = (sql) => {
if (sql.startsWith("SELECT audit_append")) return { rows: [{ id: "audit-1" }], rowCount: 1 };
if (sql.startsWith("INSERT INTO group_file_shares")) {
return {
rows: [{ id: "55555555-5555-4555-8555-555555555555", access: "public", expires_at: new Date(Date.now() + 3_600_000), revoked_at: null }],
rowCount: 1,
};
}
return { rows: [row], rowCount: 1 };
};
const service = new GroupFilesService();
const share = await service.createShare(row.workspace_id, row.id, { access: "public", expiresInHours: 2 }, { userId: "u", role: "Owner" });
expect(share.token).toBeDefined();
expect(hashShareToken(share.token)).toMatch(/^[0-9a-f]{64}$/);
expect(share.url).toContain("/files/s/");
const auditCall = statements.find((q) => q.sql.startsWith("SELECT audit_append"));
expect(auditCall).toBeDefined();
auditedDiff = auditCall?.values[6];
expect(JSON.stringify(auditedDiff)).not.toContain(share.token);
expect(JSON.stringify(auditCall?.values)).not.toContain(share.token);
// shape errors before any write
await expect(service.createShare(row.workspace_id, row.id, { access: "secret" }, { userId: "u", role: "Owner" })).rejects.toBeInstanceOf(BadRequestException);
await expect(service.createShare(row.workspace_id, row.id, { expiresInHours: 0 }, { userId: "u", role: "Owner" })).rejects.toBeInstanceOf(BadRequestException);
});
it("revoking stops serving immediately: item + every live share flipped", async () => {
let flipped = false;
behavior = (sql) => {
if (sql.startsWith("UPDATE chat_media_archive SET available = false")) flipped = true;
return { rows: [flipped ? { ...row, available: false } : row], rowCount: 1 };
};
const service = new GroupFilesService();
const revoked = await service.markRevoked(row.workspace_id, row.id, { userId: "u", role: "Owner" });
expect(revoked.status).toBe("revoked");
const shareRevoke = statements.find((q) => q.sql.startsWith("UPDATE group_file_shares SET revoked_at"));
expect(shareRevoke).toBeDefined();
expect(shareRevoke?.values[0]).toBe(row.id);
// already-revoked is a conflict, not a silent no-op
behavior = () => ({ rows: [{ ...row, available: false }], rowCount: 1 });
await expect(service.markRevoked(row.workspace_id, row.id, { userId: "u", role: "Owner" })).rejects.toBeInstanceOf(ConflictException);
});
it("replay of an attachment event is a conflict (partial unique (ws,room,event))", async () => {
behavior = (sql) => {
if (sql.startsWith("INSERT INTO chat_media_archive")) {
throw Object.assign(new Error("dup"), { code: "23505" });
}
return { rows: [], rowCount: 0 };
};
const service = new GroupFilesService();
await expect(
service.registerArchiveEntry(row.workspace_id, {
groupId: row.group_id,
roomId: "!r:example.org",
eventId: "$e",
origin: {},
originKind: "matrix",
senderMxid: "@s:example.org",
originalName: "a.mp4",
safeName: "a.mp4",
mime: "video/mp4",
mimeClass: "video",
byteSize: 10,
sha256: hex64("z"),
ocisResourceVersion: null,
ocisResourceId: "res",
createdAt: new Date(),
}),
).rejects.toBeInstanceOf(ConflictException);
});
it("openDownload refuses revoked/expired items with Gone, missing rows with NotFound", async () => {
behavior = () => ({ rows: [{ ...row, available: false }], rowCount: 1 });
const service = new GroupFilesService();
await expect(service.openDownload(row.workspace_id, "u", "Owner", row.id)).rejects.toBeInstanceOf(GoneException);
behavior = () => ({ rows: [{ ...row, retention_mode: "until", retention_until: new Date("2020-01-01T00:00:00Z") }], rowCount: 1 });
await expect(service.openDownload(row.workspace_id, "u", "Owner", row.id)).rejects.toBeInstanceOf(GoneException);
behavior = () => ({ rows: [], rowCount: 0 });
await expect(service.getFile(row.workspace_id, "u", "Owner", row.id)).rejects.toBeInstanceOf(NotFoundException);
await expect(service.getFile(row.workspace_id, "u", "Owner", "not-a-uuid")).rejects.toBeInstanceOf(BadRequestException);
});
it("membership gate denies non-members (Forbidden)", async () => {
behavior = (sql) => {
if (sql.includes("FROM group_members")) return { rows: [], rowCount: 0 };
if (sql.includes("FROM chat_media_archive")) return { rows: [row], rowCount: 1 };
return { rows: [], rowCount: 0 };
};
const service = new GroupFilesService();
await expect(service.getFile(row.workspace_id, "u", "Publisher", row.id)).rejects.toBeInstanceOf(ForbiddenException);
});
it("public claim resolves via the SECURITY DEFINER lookup, then rechecks in-tenant", async () => {
behavior = (sql) => {
if (sql.includes("share_for_public_claim")) {
return { rows: [{ id: "s1", workspace_id: row.workspace_id, item_id: row.id, access: "public", expires_at: new Date(Date.now() + 3600_000), revoked_at: null }], rowCount: 1 };
}
return { rows: [{ ...row, sha256: digestOf("payload-bytes") }], rowCount: 1 };
};
const payload = Buffer.from("payload-bytes");
const { createHash } = await import("node:crypto");
const digest = createHash("sha256").update(payload).digest("hex");
const { Readable } = await import("node:stream");
const fetcher = (async () =>
new Response(Readable.toWeb(Readable.from([payload])) as unknown as ReadableStream, {
status: 200,
headers: { "content-type": "application/octet-stream", "content-length": String(payload.byteLength) },
})) as unknown as typeof fetch;
const service = new GroupFilesService({ fetcher });
const handle = await service.claimPublicDownload("some-token");
expect(handle.item.id).toBe(row.id);
expect(handle.item.checksum.value).toBe(digest);
expect(handle.download.sizeBytes).toBe(payload.byteLength);
const claim = statements.find((q) => q.sql.includes("share_for_public_claim"));
expect(claim).toBeDefined();
// members-only tokens are rejected on the public surface
behavior = (sql) => {
if (sql.includes("share_for_public_claim")) {
return { rows: [{ id: "s1", workspace_id: row.workspace_id, item_id: row.id, access: "members", expires_at: new Date(Date.now() + 3600_000), revoked_at: null }], rowCount: 1 };
}
return { rows: [row], rowCount: 1 };
};
await expect(service.claimPublicDownload("some-token")).rejects.toBeInstanceOf(ForbiddenException);
// unknown token: 404, no tenant entered
behavior = () => ({ rows: [], rowCount: 0 });
await expect(service.claimPublicDownload("nope")).rejects.toBeInstanceOf(NotFoundException);
});
});

View file

@ -0,0 +1,573 @@
/**
* Group files service — the metadata index + share/link lifecycle over
* chat_media_archive (029) and the oCIS WebDAV port.
*
* Invariants this service owns (docs/occupymedia-storage.md):
* * List/search return ONLY available, unexpired rows visible through
* current group/file ACLs — expired/retention-past/revoked rows never
* surface, and non-Owner/Admin callers only see their groups' files.
* * Attribution by sender MXID; origin is one of the four SharedFileOrigin
* kinds, reconstructed from origin + matrix_event_json.
* * Retention is SEPARATE from link expiry: an expired share never deletes
* or hides a file; 'legalHold' blocks deletion outright.
* * Share tokens: plaintext returned ONCE, sha256(token) persisted, public
* access requires an explicit grant + bounded expiry, revoke stops
* serving immediately.
* * Downloads stream through the API with the SHA-256 re-verified; no
* presigned object URL is ever minted or stored.
*
* Every mutation lands its audit row on the SAME tenant transaction
* (audit_append), the house pattern.
*/
import { BadRequestException, ConflictException, ForbiddenException, GoneException, Injectable, NotFoundException, Optional, Inject, ServiceUnavailableException } from "@nestjs/common";
import { createHash } from "node:crypto";
import type { PoolClient } from "pg";
import { attachmentArchiveKey, type FileRetention, type GroupFile } from "@tactical-media-broadcast-hub/core";
import { appendTenantAudit, type AuditEvent } from "../audit/audit.service.js";
import { withSystem, withTenant } from "../db/client.js";
import { isUuid, parseLimit } from "../http/validation.js";
import { decodeFileListCursor, encodeFileListCursor } from "./file-cursor.js";
import { ocisConfigFromEnv, OcisWebdavClient, type FetchLike, type OcisDownload } from "./ocis-webdav.js";
import { hashShareToken, mintShareToken } from "./share-token.js";
import { MIME_CLASSES, parseRetentionInput, type MimeClass } from "./group-files.validation.js";
export const GROUP_FILES_DEPENDENCIES: unique symbol = Symbol("GROUP_FILES_DEPENDENCIES");
/** Wraps the raw payload handle the controller streams out. */
export interface FileDownloadHandle {
item: GroupFile;
download: OcisDownload;
}
export interface ShareCreated {
id: string;
itemId: string;
access: "members" | "public";
url: string;
expiresAt: string;
revokedAt: string | null;
/** Plaintext capability — returned ONCE here, never persisted anywhere. */
token: string;
}
export interface FileListFilters {
groupId: string | null;
senderMxid: string | null;
mimeClass: MimeClass | null;
name: string | null;
from: string | null;
to: string | null;
}
export interface FilePage {
items: GroupFile[];
nextCursor: string | null;
}
const MAX_SHARE_TTL_HOURS = 720;
const WORKSPACE_WIDE_ROLES: readonly string[] = ["Owner", "Admin"];
interface ArchiveRow {
id: string;
workspace_id: string;
group_id: string;
room_id: string;
event_id: string | null;
origin: string;
sender_mxid: string;
matrix_event_json: Record<string, unknown> | null;
original_name: string;
safe_name: string;
mime: string;
mime_class: string;
byte_size: string | number;
sha256: string;
ocis_resource_id: string;
ocis_resource_version: string | null;
created_at: Date;
indexed_at: Date;
available: boolean;
retention_mode: string;
legal_hold: boolean;
retention_until: Date | null;
share_access: string | null;
share_expires_at: Date | null;
share_revoked_at: Date | null;
}
interface ShareRow {
id: string;
workspace_id: string;
item_id: string;
access: string;
expires_at: Date;
revoked_at: Date | null;
}
const ARCHIVE_SELECT = `SELECT a.*, s.access AS share_access, s.expires_at AS share_expires_at, s.revoked_at AS share_revoked_at
FROM chat_media_archive a
LEFT JOIN LATERAL (
SELECT access, expires_at, revoked_at FROM group_file_shares
WHERE item_id = a.id AND revoked_at IS NULL AND expires_at > now()
ORDER BY created_at DESC LIMIT 1
) s ON true`;
function iso(value: Date): string {
return new Date(value).toISOString();
}
function safeNumber(value: string | number): number {
const n = typeof value === "number" ? value : Number(value);
if (!Number.isSafeInteger(n)) throw new Error(`unsafe numeric column: ${String(value)}`);
return n;
}
/** Row → GroupFile, reconstructing origin + retention + share link. */
function rowToGroupFile(row: ArchiveRow, libraryUrl: (resourceId: string) => string): GroupFile {
const payload = row.matrix_event_json ?? {};
const origin: GroupFile["origin"] = (() => {
if (row.origin === "matrix") return { kind: "matrix", mxcUri: String(payload.mxcUri ?? "") };
if (row.origin === "bridge") {
return {
kind: "bridge",
mxcUri: String(payload.mxcUri ?? ""),
providerId: String(payload.providerId ?? ""),
...(payload.remoteEventId === undefined ? {} : { remoteEventId: String(payload.remoteEventId) }),
};
}
if (row.origin === "pad-export") {
return { kind: "pad-export", padId: String(payload.padId ?? ""), revision: String(payload.revision ?? "") };
}
return { kind: "upload" };
})();
const retention: FileRetention =
row.retention_mode === "until" && row.retention_until !== null
? { mode: "until", legalHold: row.legal_hold, expiresAt: iso(row.retention_until) }
: { mode: "keep", legalHold: row.legal_hold };
const status: GroupFile["status"] = !row.available
? "revoked"
: retention.mode === "until" && new Date(retention.expiresAt).getTime() <= Date.now()
? "expired"
: "available";
return {
id: row.id,
workspaceId: row.workspace_id,
groupId: row.group_id,
roomId: row.room_id,
senderId: row.sender_mxid,
eventId: row.event_id,
origin,
name: row.original_name,
mimeType: row.mime,
sizeBytes: safeNumber(row.byte_size),
checksum: { algorithm: "sha256", value: row.sha256 },
createdAt: iso(row.created_at),
indexedAt: iso(row.indexed_at),
file: {
spaceId: row.group_id,
resourceId: row.ocis_resource_id,
version: row.ocis_resource_version ?? row.ocis_resource_id,
},
shareLink: {
url: libraryUrl(row.ocis_resource_id),
access: row.share_access === "public" ? "public" : "members",
expiresAt: row.share_expires_at === null ? null : iso(row.share_expires_at),
},
retention,
status,
};
}
@Injectable()
export class GroupFilesService {
private client: OcisWebdavClient | null = null;
constructor(@Optional() @Inject(GROUP_FILES_DEPENDENCIES) private readonly deps?: { fetcher?: FetchLike }) {}
/** Lazily bound: the oCIS env may be absent in dev/test contexts. */
private webdav(): OcisWebdavClient {
if (this.client === null) {
const fetcher: FetchLike = this.deps?.fetcher ?? ((url, init) => fetch(url, init));
this.client = new OcisWebdavClient(ocisConfigFromEnv(), fetcher);
}
return this.client;
}
// -------------------------------------------------------------------------
// Read paths
// -------------------------------------------------------------------------
/**
* Keyset page over the visible slice: available, unexpired, and — for
* non-Owner/Admin callers — restricted to the caller's groups (the ACL
* rule "List/search return only available, unexpired rows visible through
* current group/file ACLs").
*/
async listFiles(
workspaceId: string,
userId: string,
role: string,
query: Record<string, unknown>,
): Promise<FilePage> {
const filters = parseFilters(query);
const limit = parseLimit(query.limit, 50, 100);
const cursor = typeof query.cursor === "string" && query.cursor !== "" ? decodeFileListCursor(query.cursor) : undefined;
return withTenant(workspaceId, async (client) => {
// values[1] is always the caller's id: the membership subquery needs it
// for non-Owner/Admin callers; Owner/Admin leave $2 bound but unused so
// every placeholder stays positional and no SQL text carries a value.
const values: unknown[] = [workspaceId, userId];
const conds: string[] = ["a.available = true", "(a.retention_mode <> 'until' OR a.retention_until > now())"];
if (!WORKSPACE_WIDE_ROLES.includes(role)) {
conds.push("a.group_id IN (SELECT group_id FROM group_members WHERE user_id = $2 AND status = 'active')");
}
if (filters.groupId !== null) {
values.push(filters.groupId);
conds.push(`a.group_id = $${values.length}::uuid`);
}
if (filters.senderMxid !== null) {
values.push(filters.senderMxid);
conds.push(`a.sender_mxid = $${values.length}`);
}
if (filters.mimeClass !== null) {
values.push(filters.mimeClass);
conds.push(`a.mime_class = $${values.length}`);
}
if (filters.name !== null) {
values.push(`%${filters.name}%`);
conds.push(`a.safe_name ILIKE $${values.length}`);
}
if (filters.from !== null) {
values.push(filters.from);
conds.push(`a.created_at >= $${values.length}::timestamptz`);
}
if (filters.to !== null) {
values.push(filters.to);
conds.push(`a.created_at < $${values.length}::timestamptz`);
}
if (cursor !== undefined) {
values.push(cursor.createdAt, cursor.id);
conds.push(`(a.created_at, a.id) < ($${values.length - 1}::timestamptz, $${values.length}::uuid)`);
}
values.push(limit + 1);
const sql = `${ARCHIVE_SELECT}
WHERE ${conds.join(" AND ")}
ORDER BY a.created_at DESC, a.id DESC
LIMIT $${values.length}`;
const result = await client.query<ArchiveRow>(sql, values);
const page = result.rows.slice(0, limit);
const last = page.at(-1);
const lib = (resourceId: string): string => this.webdav().libraryUrl(resourceId);
return {
items: page.map((row) => rowToGroupFile(row, lib)),
nextCursor: result.rows.length > limit && last !== undefined
? encodeFileListCursor({ createdAt: iso(last.created_at), id: last.id })
: null,
};
});
}
async getFile(workspaceId: string, userId: string, role: string, itemId: string): Promise<GroupFile> {
return withTenant(workspaceId, async (client) => {
const row = await this.requireRow(client, itemId);
await this.requireAccess(client, userId, role, row.group_id);
return rowToGroupFile(row, (resourceId) => this.webdav().libraryUrl(resourceId));
});
}
/** Open the payload for streaming; membership/ACL + retention rechecked here. */
async openDownload(workspaceId: string, userId: string, role: string, itemId: string): Promise<FileDownloadHandle> {
const item = await this.getFile(workspaceId, userId, role, itemId);
if (item.status !== "available") throw new GoneException(`file is ${item.status}`);
const download = await this.webdav().download(item.file.resourceId).catch(() => {
throw new ServiceUnavailableException("file storage is unavailable");
});
return { item, download };
}
// -------------------------------------------------------------------------
// Retention / availability
// -------------------------------------------------------------------------
async setRetention(workspaceId: string, itemId: string, retentionInput: unknown, actor: ActorRef): Promise<GroupFile> {
const retention = parseRetentionInput(retentionInput);
return withTenant(workspaceId, async (client) => {
const before = await this.requireRow(client, itemId);
await client.query(
`UPDATE chat_media_archive
SET retention_mode = $2, legal_hold = $3, retention_until = $4
WHERE id = $1`,
[itemId, retention.mode, retention.legalHold, retention.mode === "until" ? new Date(retention.expiresAt) : null],
);
await this.audit(client, workspaceId, actor, {
action: "file.retention_changed",
targetId: itemId,
diff: {
from: { mode: before.retention_mode, legalHold: before.legal_hold, until: before.retention_until === null ? null : iso(before.retention_until) },
to: { mode: retention.mode, legalHold: retention.legalHold, until: retention.mode === "until" ? retention.expiresAt : null },
name: before.safe_name,
},
});
const after = await this.requireRow(client, itemId);
return rowToGroupFile(after, (resourceId) => this.webdav().libraryUrl(resourceId));
});
}
/** Redaction/membership-removal path: revoke links, stop serving. No deletion. */
async markRevoked(workspaceId: string, itemId: string, actor: ActorRef): Promise<GroupFile> {
return withTenant(workspaceId, async (client) => {
const before = await this.requireRow(client, itemId);
if (!before.available) throw new ConflictException("file is already revoked");
await client.query("UPDATE chat_media_archive SET available = false WHERE id = $1", [itemId]);
await client.query("UPDATE group_file_shares SET revoked_at = now() WHERE item_id = $1 AND revoked_at IS NULL", [itemId]);
await this.audit(client, workspaceId, actor, {
action: "file.revoked",
targetId: itemId,
diff: { name: before.safe_name, roomId: before.room_id, eventId: before.event_id },
});
const after = await this.requireRow(client, itemId);
return rowToGroupFile(after, (resourceId) => this.webdav().libraryUrl(resourceId));
});
}
// -------------------------------------------------------------------------
// Shares / public links
// -------------------------------------------------------------------------
async createShare(
workspaceId: string,
itemId: string,
input: { access?: unknown; expiresInHours?: unknown },
actor: ActorRef,
): Promise<ShareCreated> {
const access = input.access === undefined ? "members" : input.access;
if (access !== "members" && access !== "public") throw new BadRequestException("access must be 'members' or 'public'");
const hours = typeof input.expiresInHours === "number" ? input.expiresInHours : NaN;
if (!Number.isInteger(hours) || hours < 1 || hours > MAX_SHARE_TTL_HOURS) {
throw new BadRequestException(`expiresInHours must be an integer between 1 and ${MAX_SHARE_TTL_HOURS}`);
}
const token = mintShareToken();
const tokenHash = hashShareToken(token);
const expiresAt = new Date(Date.now() + hours * 3_600_000);
return withTenant(workspaceId, async (client) => {
const row = await this.requireRow(client, itemId);
const inserted = await client.query<ShareRow>(
`INSERT INTO group_file_shares (workspace_id, item_id, access, token_hash, created_by, expires_at)
VALUES ($1, $2, $3, $4, $5, $6) RETURNING id, workspace_id, item_id, access, expires_at, revoked_at`,
[workspaceId, itemId, access, tokenHash, actor.userId, expiresAt],
);
const share = inserted.rows[0];
if (share === undefined) throw new Error("share insert returned no row");
await this.audit(client, workspaceId, actor, {
action: "file.share_created",
targetId: share.id,
diff: { itemId, access, expiresAt: iso(expiresAt), name: row.safe_name }, // never the token
});
return {
id: share.id,
itemId,
access,
url: `${this.publicWebBase()}/files/s/${token}`,
expiresAt: iso(expiresAt),
revokedAt: null,
token,
};
});
}
async revokeShare(workspaceId: string, shareId: string, actor: ActorRef): Promise<void> {
await withTenant(workspaceId, async (client) => {
if (!isUuid(shareId)) throw new BadRequestException("shareId must be a UUID");
const updated = await client.query(
"UPDATE group_file_shares SET revoked_at = now() WHERE id = $1 AND workspace_id = $2 AND revoked_at IS NULL RETURNING item_id",
[shareId, workspaceId],
);
if ((updated.rowCount ?? 0) === 0) {
const existing = await client.query("SELECT item_id FROM group_file_shares WHERE id = $1 AND workspace_id = $2", [shareId, workspaceId]);
if (existing.rows[0] === undefined) throw new NotFoundException(`share ${shareId} not found`);
throw new ConflictException("share is already revoked");
}
await this.audit(client, workspaceId, actor, {
action: "file.share_revoked",
targetId: shareId,
diff: { itemId: String(updated.rows[0]?.item_id) },
});
});
}
/**
* Public claim path (no session): resolve the token through the narrow
* SECURITY DEFINER lookup — no tenant context exists yet, exactly like the
* invite-link claim — then enter the row's tenant to recheck availability
* and retention before opening the payload. Serving stops immediately on
* revoke/expiry (Gone).
*/
async claimPublicDownload(token: string): Promise<FileDownloadHandle> {
if (token === "") throw new NotFoundException("share token is invalid");
const tokenHash = createHash("sha256").update(token, "utf8").digest("hex");
const share = await withSystem(async (client) => {
const result = await client.query<ShareRow>(
"SELECT id, workspace_id, item_id, access, expires_at, revoked_at FROM share_for_public_claim($1)",
[tokenHash],
);
return result.rows[0];
});
if (share === undefined) throw new NotFoundException("share token is invalid or has expired");
if (share.access !== "public") throw new ForbiddenException("share token is not a public link");
return withTenant(share.workspace_id, async (client) => {
const row = await this.requireRow(client, share.item_id);
if (!row.available) throw new GoneException("file is revoked");
if (row.retention_mode === "until" && row.retention_until !== null && row.retention_until.getTime() <= Date.now()) {
throw new GoneException("file retention has expired");
}
const item = rowToGroupFile(row, (resourceId) => this.webdav().libraryUrl(resourceId));
const download = await this.webdav().download(item.file.resourceId).catch(() => {
throw new ServiceUnavailableException("file storage is unavailable");
});
return { item, download };
});
}
// -------------------------------------------------------------------------
// Ingest seam (hub-archive adapter / direct upload), same idempotency rule
// -------------------------------------------------------------------------
/**
* Register a committed library reference. Replay-safe: the partial unique
* (workspace,room,event) makes a second attempt for the same attachment
* event a conflict, and the claim key is attachmentArchiveKey().
*/
async registerArchiveEntry(
workspaceId: string,
entry: ArchiveEntryInput,
): Promise<{ id: string; claimKey: string | null }> {
const claimKey = entry.eventId === null ? null : attachmentArchiveKey(workspaceId, entry.roomId, entry.eventId);
return withTenant(workspaceId, async (client) => {
let inserted: { rows: Array<{ id: string }> };
try {
inserted = await client.query<{ id: string }>(
`INSERT INTO chat_media_archive
(workspace_id, group_id, room_id, event_id, origin, sender_mxid, matrix_event_json,
original_name, safe_name, mime, mime_class, byte_size, sha256,
ocis_resource_id, ocis_resource_version, created_at)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16)
RETURNING id`,
[
workspaceId, entry.groupId, entry.roomId, entry.eventId, entry.originKind, entry.senderMxid,
JSON.stringify(entry.origin), entry.originalName, entry.safeName, entry.mime, entry.mimeClass,
entry.byteSize, entry.sha256, entry.ocisResourceId, entry.ocisResourceVersion, entry.createdAt,
],
);
} catch (err) {
if (typeof err === "object" && err !== null && (err as { code?: string }).code === "23505") {
throw new ConflictException("attachment event is already archived");
}
throw err;
}
const id = inserted.rows[0]?.id;
if (id === undefined) throw new Error("archive insert returned no row");
return { id, claimKey };
});
}
// -------------------------------------------------------------------------
// internals
// -------------------------------------------------------------------------
private publicWebBase(): string {
const base = process.env.FILES_PUBLIC_BASE ?? "";
return base === "" ? "" : base.replace(/\/+$/, "");
}
private async requireRow(client: PoolClient, itemId: string): Promise<ArchiveRow> {
if (!isUuid(itemId)) throw new BadRequestException("itemId must be a UUID");
const result = await client.query<ArchiveRow>(`${ARCHIVE_SELECT} WHERE a.id = $1`, [itemId]);
const row = result.rows[0];
if (row === undefined) throw new NotFoundException(`file ${itemId} not found`);
return row;
}
/** Default links require current group membership; Owner/Admin pass. */
private async requireAccess(client: PoolClient, userId: string, role: string, groupId: string): Promise<void> {
if (WORKSPACE_WIDE_ROLES.includes(role)) return;
const result = await client.query(
"SELECT 1 FROM group_members WHERE group_id = $1 AND user_id = $2 AND status = 'active'",
[groupId, userId],
);
if ((result.rowCount ?? 0) === 0) throw new ForbiddenException("file belongs to a group you are not a member of");
}
private async audit(
client: PoolClient,
workspaceId: string,
actor: ActorRef,
event: Omit<AuditEvent, "actorUserId" | "actorRole" | "targetType">,
): Promise<void> {
await client.query("SELECT audit_append($1, $2, $3, $4, $5, $6, $7)", [
workspaceId,
actor.userId,
actor.role,
event.action,
"group_file",
event.targetId,
event.diff ?? null,
]);
}
}
export interface ActorRef {
userId: string;
role: string;
}
export interface ArchiveEntryInput {
groupId: string;
roomId: string;
eventId: string | null;
origin: Record<string, unknown>;
originKind: string;
senderMxid: string;
originalName: string;
safeName: string;
mime: string;
mimeClass: MimeClass;
byteSize: number;
sha256: string;
ocisResourceId: string;
ocisResourceVersion: string | null;
createdAt: Date;
}
/** Kept for the audit import contract; appendTenantAudit is used by callers that prefer a fresh tx. */
export { appendTenantAudit };
function parseFilters(query: Record<string, unknown>): FileListFilters {
const groupId = optionalUuid(query.groupId);
const senderMxid = typeof query.senderMxid === "string" && query.senderMxid.trim() !== "" ? query.senderMxid.trim() : null;
if (senderMxid !== null && senderMxid.length > 320) throw new BadRequestException("senderMxid is too long");
const rawClass = typeof query.mimeClass === "string" && query.mimeClass !== "" ? query.mimeClass : null;
if (rawClass !== null && !(MIME_CLASSES as readonly string[]).includes(rawClass)) {
throw new BadRequestException(`mimeClass must be one of: ${MIME_CLASSES.join(", ")}`);
}
const name = typeof query.name === "string" && query.name.trim() !== "" ? query.name.trim().slice(0, 255) : null;
const from = parseBound(query.from, "from");
const to = parseBound(query.to, "to");
if (from !== null && to !== null && Date.parse(from) > Date.parse(to)) throw new BadRequestException("from must not be after to");
return { groupId, senderMxid, mimeClass: rawClass as MimeClass | null, name, from, to };
}
function optionalUuid(value: unknown): string | null {
if (value === undefined || value === null || value === "") return null;
if (typeof value !== "string" || !isUuid(value)) throw new BadRequestException("groupId must be a UUID");
return value;
}
const ISO_BOUND = /^\d{4}-\d{2}-\d{2}([T ]\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}:?\d{2})?)?$/;
function parseBound(value: unknown, field: string): string | null {
if (value === undefined || value === null || value === "") return null;
if (typeof value !== "string" || value.length > 32 || !ISO_BOUND.test(value) || !Number.isFinite(Date.parse(value))) {
throw new BadRequestException(`${field} must be an ISO-8601 date or timestamp`);
}
return value;
}

View file

@ -0,0 +1,242 @@
import { describe, expect, it } from "vitest";
import { BadRequestException, ServiceUnavailableException } from "@nestjs/common";
import { Readable } from "node:stream";
import {
decodeFileListCursor,
encodeFileListCursor,
} from "./file-cursor.js";
import {
parseGroupFile,
parseMatrixAttachmentCandidate,
parseRetention,
parseRetentionInput,
parseSharedFileOrigin,
requireMxid,
requireMxcUri,
requireRoomId,
requireSha256,
} from "./group-files.validation.js";
import { OcisVerificationError, ocisConfigFromEnv, OcisWebdavClient, verifyWhileStreaming } from "./ocis-webdav.js";
import { hashShareToken, isPlausibleShareToken, mintShareToken, sameToken } from "./share-token.js";
/** Deterministic 64-hex helper: repeat a pattern to exactly 64 chars. */
function hex64(seed: string): string {
let out = "";
while (out.length < 64) out += Buffer.from(seed).toString("hex");
return out.slice(0, 64);
}
describe("group-files runtime validation (shared contracts)", () => {
it("accepts the four SharedFileOrigin variants and rejects anything else", () => {
expect(parseSharedFileOrigin({ kind: "matrix", mxcUri: "mxc://example.org/abc" })).toEqual({
kind: "matrix",
mxcUri: "mxc://example.org/abc",
});
expect(
parseSharedFileOrigin({ kind: "bridge", mxcUri: "mxc://example.org/abc", providerId: "telegram" }),
).toEqual({ kind: "bridge", mxcUri: "mxc://example.org/abc", providerId: "telegram" });
expect(parseSharedFileOrigin({ kind: "bridge", mxcUri: "mxc://example.org/abc", providerId: "t", remoteEventId: "42" })).toEqual({
kind: "bridge",
mxcUri: "mxc://example.org/abc",
providerId: "t",
remoteEventId: "42",
});
expect(parseSharedFileOrigin({ kind: "upload" })).toEqual({ kind: "upload" });
expect(parseSharedFileOrigin({ kind: "pad-export", padId: "p", revision: "7" })).toEqual({
kind: "pad-export",
padId: "p",
revision: "7",
});
for (const bad of [null, {}, { kind: "nope" }, { kind: "matrix" }, { kind: "matrix", mxcUri: "https://evil/x" }]) {
expect(() => parseSharedFileOrigin(bad)).toThrow(BadRequestException);
}
});
it("validates MXIDs, room ids, mxc URIs and sha256 shape", () => {
expect(requireMxid("@alice:example.org")).toBe("@alice:example.org");
expect(() => requireMxid("Alice (display)")).toThrow(BadRequestException);
expect(() => requireMxid("#alias:example.org")).toThrow(BadRequestException);
expect(requireRoomId("!archive:example.org")).toBe("!archive:example.org");
expect(() => requireRoomId("#alias:example.org")).toThrow(BadRequestException); // alias, never an identity
expect(() => requireRoomId("")).toThrow(BadRequestException);
expect(requireMxcUri("mxc://example.org/AbC123")).toBe("mxc://example.org/AbC123");
expect(() => requireMxcUri("mxc://example.org/")).toThrow(BadRequestException);
expect(requireSha256(hex64("a"))).toBe(hex64("a"));
expect(() => requireSha256("nope")).toThrow(BadRequestException);
// hex digits include letters: an uppercase digest must be rejected
const upperHex = "A".repeat(64);
expect(/[A-F]/.test(upperHex)).toBe(true);
expect(() => requireSha256(upperHex)).toThrow(BadRequestException);
expect(() => requireSha256("0".repeat(63))).toThrow(BadRequestException); // wrong length
});
it("keeps retention separate from link expiry and enforces shape coupling", () => {
expect(parseRetention({ mode: "keep", legalHold: false })).toEqual({ mode: "keep", legalHold: false });
// legalHold is a FLAG alongside the mode, not a third mode (FileRetention contract)
expect(parseRetention({ mode: "keep", legalHold: true })).toEqual({ mode: "keep", legalHold: true });
expect(parseRetention({ mode: "until", legalHold: true, expiresAt: "2030-01-01T00:00:00Z" })).toEqual({
mode: "until",
legalHold: true,
expiresAt: "2030-01-01T00:00:00Z",
});
expect(() => parseRetention({ mode: "burn", legalHold: false })).toThrow(BadRequestException);
expect(() => parseRetention({ mode: "until", legalHold: false })).toThrow(BadRequestException);
expect(() => parseRetention({ mode: "keep", legalHold: false, expiresAt: "2030-01-01T00:00:00Z" })).toThrow(BadRequestException);
expect(() => parseRetention({ mode: "until", legalHold: false, expiresAt: "next tuesday" })).toThrow(BadRequestException);
// input variant
expect(parseRetentionInput({ mode: "until", until: "2030-01-01T00:00:00Z" })).toEqual({
mode: "until",
legalHold: false,
expiresAt: "2030-01-01T00:00:00Z",
});
expect(() => parseRetentionInput({ mode: "until" })).toThrow(BadRequestException);
expect(() => parseRetentionInput({ mode: "keep", until: "2030-01-01T00:00:00Z" })).toThrow(BadRequestException);
expect(parseRetentionInput({ mode: "keep", legalHold: true })).toEqual({ mode: "keep", legalHold: true });
expect(() => parseRetentionInput({ mode: "legalHold" })).toThrow(BadRequestException); // not a mode
});
it("fails closed on Matrix attachment candidates with unknown/bad state", () => {
const good = {
roomId: "!a:example.org",
eventId: "$1",
senderId: "@s:example.org",
eventType: "m.room.message",
msgtype: "m.image",
roomEncryption: "unencrypted",
encryptedFile: false,
redacted: false,
mxcUri: "mxc://example.org/x",
};
expect(parseMatrixAttachmentCandidate(good).roomEncryption).toBe("unencrypted");
// shape-level failures
for (const patch of [
{ roomId: "#alias:example.org" },
{ senderId: "Bob" },
{ mxcUri: "https://attacker/x" },
{ encryptedFile: "yes" },
]) {
expect(() => parseMatrixAttachmentCandidate({ ...good, ...patch })).toThrow(BadRequestException);
}
// state-level eligibility is the CORE helper's call, not the parser's:
// parse accepts encrypted/unknown/redacted states; canArchiveAttachment
// is what fails closed on them (core test shared-files.test.ts).
const parsed = parseMatrixAttachmentCandidate({ ...good, roomEncryption: "unknown" });
expect(parsed.roomEncryption).toBe("unknown");
});
it("round-trips a full GroupFile wire shape", () => {
const value = {
id: "b3a1c2d4-1111-4222-8333-444455556666",
workspaceId: "c3a1c2d4-1111-4222-8333-444455556666",
groupId: "d3a1c2d4-1111-4222-8333-444455556666",
roomId: "!r:example.org",
senderId: "@s:example.org",
eventId: "$evt",
origin: { kind: "matrix", mxcUri: "mxc://example.org/x" },
name: "clip.mp4",
mimeType: "video/mp4",
sizeBytes: 12,
checksum: { algorithm: "sha256", value: hex64("z") },
createdAt: "2026-01-02T03:04:05Z",
indexedAt: "2026-01-02T03:04:05Z",
file: { spaceId: "sp", resourceId: "res", version: "v1" },
shareLink: { url: "https://files.example.org/f/res", access: "members", expiresAt: null },
retention: { mode: "keep", legalHold: false },
status: "available",
};
expect(parseGroupFile(value)).toEqual(value);
expect(() => parseGroupFile({ ...value, sizeBytes: -1 })).toThrow(BadRequestException);
expect(() => parseGroupFile({ ...value, senderId: "Bob" })).toThrow(BadRequestException);
expect(() => parseGroupFile({ ...value, checksum: { algorithm: "sha256", value: "zz" } })).toThrow(BadRequestException);
});
});
describe("file-cursor", () => {
it("encodes/decodes (createdAt,id) and rejects malformed scopes", () => {
const cursor = { createdAt: "2026-01-02T03:04:05Z", id: "b3a1c2d4-1111-4222-8333-444455556666" };
expect(decodeFileListCursor(encodeFileListCursor(cursor))).toEqual(cursor);
for (const bad of ["", "!!!", Buffer.from(JSON.stringify({ createdAt: "nope", id: "x" })).toString("base64url")]) {
expect(() => decodeFileListCursor(bad)).toThrow(BadRequestException);
}
// a cursor from another tenant carries no scope: it is just an empty page,
// never a leak — the scope is re-bound server-side on every request.
expect(decodeFileListCursor(encodeFileListCursor({ createdAt: "2026-01-02T03:04:05Z", id: "00000000-0000-4000-8000-000000000000" })).id).toBe(
"00000000-0000-4000-8000-000000000000",
);
});
});
describe("share tokens", () => {
it("mints 256-bit capabilities and stores only sha256(token)", () => {
const token = mintShareToken();
expect(token.length).toBeGreaterThanOrEqual(40);
expect(isPlausibleShareToken(token)).toBe(true);
expect(isPlausibleShareToken("short")).toBe(false);
expect(isPlausibleShareToken("not base64url!!")).toBe(false);
const hash = hashShareToken(token);
expect(hash).toMatch(/^[0-9a-f]{64}$/);
expect(hashShareToken(token)).toBe(hash); // deterministic
expect(hashShareToken(mintShareToken())).not.toBe(hash); // unique per mint
expect(sameToken(token, token)).toBe(true);
expect(sameToken(token, mintShareToken())).toBe(false);
// the plaintext can never be derived from the hash
expect(hash).not.toContain(token);
});
});
describe("ocis-webdav config + streaming verification", () => {
it("reads env lazily and throws 503 when unconfigured", () => {
expect(() => ocisConfigFromEnv({})).toThrow(ServiceUnavailableException);
expect(() => ocisConfigFromEnv({ FILES_OCID_WEBDAV_URL: "https://files.example.org" })).toThrow(ServiceUnavailableException);
const config = ocisConfigFromEnv({
FILES_OCID_WEBDAV_URL: "https://files.example.org/",
FILES_OCID_USER: "svc",
FILES_OCID_PASSWORD: "pw",
FILES_PUBLIC_BASE: "https://files.example.org/",
});
expect(config.webdavUrl).toBe("https://files.example.org");
expect(config.publicBase).toBe("https://files.example.org");
});
it("uploads hash the actual streamed bytes and surface oc-fileid/oc-etag", async () => {
const bytes = Buffer.from("group file payload");
const fetcher = (async () =>
new Response(null, {
status: 201,
headers: { "oc-fileid": "res-1", "oc-etag": "v-9" },
})) as unknown as typeof fetch;
const client = new OcisWebdavClient(
{ webdavUrl: "https://files.example.org", user: "svc", password: "pw", publicBase: "https://files.example.org" },
fetcher,
);
const result = await client.upload("group-1", "clip.mp4", Readable.from([bytes]), "video/mp4");
const { createHash } = await import("node:crypto");
expect(result.sha256).toBe(createHash("sha256").update(bytes).digest("hex"));
expect(result.sizeBytes).toBe(bytes.byteLength);
expect(result.resourceId).toBe("res-1");
expect(result.version).toBe("v-9");
expect(client.uploadUrl("g", "n.mp4")).toContain("/remote.php/dav/files/svc/hub-groups/g/n.mp4");
expect(client.libraryUrl("res-1")).toBe("https://files.example.org/f/res-1");
});
it("verifyWhileStreaming passes on match and aborts on tamper", async () => {
const { createHash } = await import("node:crypto");
const payload = Buffer.from("0123456789");
const good = createHash("sha256").update(payload).digest("hex");
const sinkChunks: Buffer[] = [];
const out = await verifyWhileStreaming(
{ stream: Readable.from([payload.subarray(0, 4), payload.subarray(4)]), sizeBytes: payload.byteLength, contentType: "application/octet-stream" },
good,
(chunk) => sinkChunks.push(chunk),
);
expect(out.sizeBytes).toBe(10);
expect(Buffer.concat(sinkChunks).toString()).toBe("0123456789");
await expect(
verifyWhileStreaming(
{ stream: Readable.from([Buffer.from("tampered")]), sizeBytes: 8, contentType: null },
good,
() => undefined,
),
).rejects.toBeInstanceOf(OcisVerificationError);
});
});

View file

@ -0,0 +1,241 @@
/**
* Runtime validation for the shared group-files contracts
* (packages/core/src/shared-files.ts — imported as @tactical-media-broadcast-hub/core).
*
* The shared file is TYPES ONLY; the task contract says "future HTTP/DB
* adapters must validate these shapes at runtime". This module is that
* adapter boundary: every value entering or leaving the DB/HTTP seam passes
* through one of these guards, so a stray row can never render an
* invalid GroupFile.
*
* Style follows the api's existing house convention: `unknown` in, typed
* value out, BadRequestException on bad shape (http/validation.ts), never
* `any`, and DB CHECK constraints (029) are coarse backstops only.
*/
import { BadRequestException } from "@nestjs/common";
import type {
FileRetention,
GroupFile,
GroupFileMapping,
MatrixAttachmentCandidate,
SharedFileOrigin,
} from "@tactical-media-broadcast-hub/core";
// FileRetention (packages/core/src/shared-files.ts): mode is keep|until and
// legalHold is an orthogonal boolean flag — NOT a third mode.
export const RETENTION_MODES = ["keep", "until"] as const;
export type RetentionMode = (typeof RETENTION_MODES)[number];
export const ORIGIN_KINDS = ["matrix", "bridge", "upload", "pad-export"] as const;
export type OriginKind = (typeof ORIGIN_KINDS)[number];
export const MIME_CLASSES = ["image", "video", "audio", "document", "other"] as const;
export type MimeClass = (typeof MIME_CLASSES)[number];
export const FILE_STATUSES = ["available", "revoked", "expired"] as const;
export type FileStatus = (typeof FILE_STATUSES)[number];
const SHA256_HEX = /^[0-9a-f]{64}$/;
const MXID = /^@[^@\s]+:[^@\s]+$/;
const MXC_URI = /^mxc:\/\/[^/\s]+\/[^/\s?#]+$/;
const ISO_TIMESTAMP = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d+)?Z$/;
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function requireField(container: Record<string, unknown>, key: string): unknown {
if (!(key in container)) throw new BadRequestException(`${key} is required`);
return container[key];
}
function stringField(container: Record<string, unknown>, key: string, field = key): string {
const value = requireField(container, key);
if (typeof value !== "string" || value === "") throw new BadRequestException(`${field} must be a non-empty string`);
return value;
}
function isoField(container: Record<string, unknown>, key: string, field = key): string {
const value = stringField(container, key, field);
if (!ISO_TIMESTAMP.test(value)) throw new BadRequestException(`${field} must be a UTC ISO-8601 timestamp (…Z)`);
return value;
}
function enumField<T extends string>(container: Record<string, unknown>, key: string, allowed: readonly T[], field = key): T {
const value = requireField(container, key);
if (typeof value !== "string" || !(allowed as readonly string[]).includes(value)) {
throw new BadRequestException(`${field} must be one of: ${allowed.join(", ")}`);
}
return value as T;
}
function integerField(container: Record<string, unknown>, key: string, field = key): number {
const value = requireField(container, key);
const n = typeof value === "number" ? value : NaN;
if (!Number.isSafeInteger(n) || n < 0) throw new BadRequestException(`${field} must be a non-negative safe integer`);
return n;
}
/** sha256 lowercase hex, per the contract ("verified payload bytes ... hex"). */
export function isSha256Hex(value: unknown): value is string {
return typeof value === "string" && SHA256_HEX.test(value);
}
export function requireSha256(value: unknown, field = "sha256"): string {
if (!isSha256Hex(value)) throw new BadRequestException(`${field} must be lowercase sha256 hex (64 chars)`);
return value;
}
export function requireMxid(value: unknown, field = "senderMxid"): string {
if (typeof value !== "string" || !MXID.test(value)) throw new BadRequestException(`${field} must be a Matrix user id (@user:host)`);
return value;
}
export function requireMxcUri(value: unknown, field = "mxcUri"): string {
if (typeof value !== "string" || !MXC_URI.test(value)) throw new BadRequestException(`${field} must be an mxc:// URI`);
return value;
}
export function requireRoomId(value: unknown, field = "roomId"): string {
if (typeof value !== "string" || !/^![^:\s]+:[^:\s]+$/.test(value)) {
throw new BadRequestException(`${field} must be an immutable Matrix room id (!localpart:host) — never an alias`);
}
return value;
}
/** Retention is decoupled from link expiry; legalHold blocks deletion. */
export function parseRetention(value: unknown, field = "retention"): FileRetention {
if (!isRecord(value)) throw new BadRequestException(`${field} must be an object`);
const mode = enumField(value, "mode", RETENTION_MODES, `${field}.mode`);
const legalHold = requireField(value, "legalHold");
if (typeof legalHold !== "boolean") throw new BadRequestException(`${field}.legalHold must be a boolean`);
if (mode === "until") return { mode, legalHold, expiresAt: isoField(value, "expiresAt", `${field}.expiresAt`) };
if (value.expiresAt !== undefined && value.expiresAt !== null) {
throw new BadRequestException(`${field}.expiresAt must not be set when mode is "keep"`);
}
return { mode, legalHold };
}
/** POST /files/:id/retention body {mode, until?, legalHold?} → FileRetention. */
export function parseRetentionInput(value: unknown): FileRetention {
if (!isRecord(value)) throw new BadRequestException("body must be an object");
const mode = enumField(value, "mode", RETENTION_MODES, "mode");
const legalHold = value.legalHold === undefined ? false : value.legalHold;
if (typeof legalHold !== "boolean") throw new BadRequestException("legalHold must be a boolean");
const rawUntil = value.until;
if (mode === "until") {
if (rawUntil === undefined || rawUntil === null) throw new BadRequestException("until is required when mode is 'until'");
return { mode, legalHold, expiresAt: requireIsoTimestamp(rawUntil, "until") };
}
if (rawUntil !== undefined && rawUntil !== null) {
throw new BadRequestException("until must not be set when mode is 'keep'");
}
return { mode, legalHold };
}
export function requireIsoTimestamp(value: unknown, field: string): string {
if (typeof value !== "string" || !ISO_TIMESTAMP.test(value) || !Number.isFinite(Date.parse(value))) {
throw new BadRequestException(`${field} must be a UTC ISO-8601 timestamp (…Z)`);
}
return value;
}
/** The four SharedFileOrigin variants, discriminated by `kind`. */
export function parseSharedFileOrigin(value: unknown, field = "origin"): SharedFileOrigin {
if (!isRecord(value)) throw new BadRequestException(`${field} must be an object`);
const kind = enumField(value, "kind", ORIGIN_KINDS, `${field}.kind`);
if (kind === "matrix") return { kind, mxcUri: requireMxcUri(value.mxcUri, `${field}.mxcUri`) };
if (kind === "bridge") {
return {
kind,
mxcUri: requireMxcUri(value.mxcUri, `${field}.mxcUri`),
providerId: stringField(value, "providerId", `${field}.providerId`),
...(value.remoteEventId === undefined || value.remoteEventId === null ? {} : { remoteEventId: stringField(value, "remoteEventId", `${field}.remoteEventId`) }),
};
}
if (kind === "upload") return { kind };
return { kind: "pad-export", padId: stringField(value, "padId", `${field}.padId`), revision: stringField(value, "revision", `${field}.revision`) };
}
/** Matrix event eligibility input for the hub-archive seam. Unknown state fails closed (core helper). */
export function parseMatrixAttachmentCandidate(value: unknown, field = "event"): MatrixAttachmentCandidate {
if (!isRecord(value)) throw new BadRequestException(`${field} must be an object`);
const encryption = enumField(value, "roomEncryption", ["unencrypted", "encrypted", "unknown"] as const, `${field}.roomEncryption`);
const encryptedFile = requireField(value, "encryptedFile");
const redacted = requireField(value, "redacted");
if (typeof encryptedFile !== "boolean") throw new BadRequestException(`${field}.encryptedFile must be a boolean`);
if (typeof redacted !== "boolean") throw new BadRequestException(`${field}.redacted must be a boolean`);
return {
roomId: requireRoomId(value.roomId, `${field}.roomId`),
eventId: stringField(value, "eventId", `${field}.eventId`),
senderId: requireMxid(value.senderId, `${field}.senderId`),
eventType: stringField(value, "eventType", `${field}.eventType`),
msgtype: stringField(value, "msgtype", `${field}.msgtype`),
roomEncryption: encryption,
encryptedFile,
redacted,
mxcUri: requireMxcUri(value.mxcUri, `${field}.mxcUri`),
};
}
export function parseGroupFileMapping(value: unknown, field = "mapping"): GroupFileMapping {
if (!isRecord(value)) throw new BadRequestException(`${field} must be an object`);
const policy = enumField(value, "archivePolicy", ["disabled", "unencrypted-archive"] as const, `${field}.archivePolicy`);
return {
workspaceId: stringField(value, "workspaceId", `${field}.workspaceId`),
groupId: stringField(value, "groupId", `${field}.groupId`),
roomId: requireRoomId(value.roomId, `${field}.roomId`),
spaceId: stringField(value, "spaceId", `${field}.spaceId`),
folderId: stringField(value, "folderId", `${field}.folderId`),
archivePolicy: policy,
retention: parseRetention(value.retention, `${field}.retention`),
};
}
/** The served GroupFile wire shape, guarded on the way out of the DB. */
export function parseGroupFile(value: unknown, field = "item"): GroupFile {
if (!isRecord(value)) throw new BadRequestException(`${field} must be an object`);
const origin = parseSharedFileOrigin(value.origin, `${field}.origin`);
const eventId = value.eventId;
if (eventId !== undefined && eventId !== null && typeof eventId !== "string") {
throw new BadRequestException(`${field}.eventId must be a string or null`);
}
const file = value.file;
if (!isRecord(file)) throw new BadRequestException(`${field}.file must be an object`);
const shareLink = value.shareLink;
if (!isRecord(shareLink)) throw new BadRequestException(`${field}.shareLink must be an object`);
const access = enumField(shareLink, "access", ["members", "public"] as const, `${field}.shareLink.access`);
const shareExpires = shareLink.expiresAt;
if (shareExpires !== undefined && shareExpires !== null && (typeof shareExpires !== "string" || !ISO_TIMESTAMP.test(shareExpires))) {
throw new BadRequestException(`${field}.shareLink.expiresAt must be a UTC ISO-8601 timestamp or null`);
}
const checksum = value.checksum;
if (!isRecord(checksum)) throw new BadRequestException(`${field}.checksum must be an object`);
return {
id: stringField(value, "id", `${field}.id`),
workspaceId: stringField(value, "workspaceId", `${field}.workspaceId`),
groupId: stringField(value, "groupId", `${field}.groupId`),
roomId: requireRoomId(value.roomId, `${field}.roomId`),
senderId: requireMxid(value.senderId, `${field}.senderId`),
eventId: eventId === undefined || eventId === null ? null : eventId,
origin,
name: stringField(value, "name", `${field}.name`),
mimeType: stringField(value, "mimeType", `${field}.mimeType`),
sizeBytes: integerField(value, "sizeBytes", `${field}.sizeBytes`),
checksum: { algorithm: "sha256", value: requireSha256(checksum.value, `${field}.checksum.value`) },
createdAt: isoField(value, "createdAt", `${field}.createdAt`),
indexedAt: isoField(value, "indexedAt", `${field}.indexedAt`),
file: {
spaceId: stringField(file, "spaceId", `${field}.file.spaceId`),
resourceId: stringField(file, "resourceId", `${field}.file.resourceId`),
version: stringField(file, "version", `${field}.file.version`),
},
shareLink: {
url: stringField(shareLink, "url", `${field}.shareLink.url`),
access,
expiresAt: shareExpires === undefined || shareExpires === null ? null : shareExpires,
},
retention: parseRetention(value.retention, `${field}.retention`),
status: enumField(value, "status", FILE_STATUSES, `${field}.status`),
};
}

View file

@ -0,0 +1,254 @@
/**
* oCIS WebDAV port — the ONLY payload I/O path for group files.
*
* The API streams bytes through itself on both upload and download: it NEVER
* mints or persists a presigned object URL (docs/occupymedia-storage.md
* "Persist library links/share IDs and expiry, not presigned object URLs").
* Downloads re-verify the SHA-256 while streaming so storage-level tampering
* is detected instead of silently relayed.
*
* ENV CONTRACT (lazy getters — read per call so tests/parallel deployments
* can reconfigure without a rebuild; nothing is read at import time):
*
* FILES_OCID_WEBDAV_URL oCIS WebDAV root, e.g. https://files.example.org
* FILES_OCID_USER service account username (the scoped oCIS identity)
* FILES_OCID_PASSWORD service account password
* FILES_PUBLIC_BASE public base of the file library, used to build the
* persistent (authenticated) library link returned in
* GroupFile.shareLink.url — a stable resource URL,
* NEVER a presigned object URL.
*
* The oCIS deployment lands in parallel; when the env is unset the port
* reports unconfigured and routes fail 503 rather than silently no-op.
*
* WebDAV shape: PUT returns the stable resource id (oc-fileid) and ETag
* (oc-etag / etag) this index needs; plain PUT/GET carry the bytes. The
* transport is injectable (OCIS_FETCH token) so tests stub it without HTTP.
*/
import { ServiceUnavailableException } from "@nestjs/common";
import { createHash } from "node:crypto";
import { Readable } from "node:stream";
export interface OcisEnvConfig {
webdavUrl: string;
user: string;
password: string;
publicBase: string;
}
export function ocisConfigFromEnv(env: Readonly<Record<string, string | undefined>> = process.env): OcisEnvConfig {
const webdavUrl = env.FILES_OCID_WEBDAV_URL;
const user = env.FILES_OCID_USER;
const password = env.FILES_OCID_PASSWORD;
if (webdavUrl === undefined || webdavUrl === "") throw new ServiceUnavailableException("FILES_OCID_WEBDAV_URL is not configured");
if (user === undefined || user === "") throw new ServiceUnavailableException("FILES_OCID_USER is not configured");
if (password === undefined || password === "") throw new ServiceUnavailableException("FILES_OCID_PASSWORD is not configured");
return {
webdavUrl: webdavUrl.replace(/\/+$/, ""),
user,
password,
publicBase: (env.FILES_PUBLIC_BASE ?? "").replace(/\/+$/, ""),
};
}
/** Result of a committed upload: stable identity + digest over bytes sent. */
export interface OcisUploadResult {
resourceId: string;
version: string | null;
sha256: string;
sizeBytes: number;
}
/** An opened download: verifyWhileStreaming checks the digest before relay. */
export interface OcisDownload {
stream: Readable;
sizeBytes: number | null;
contentType: string | null;
}
export class OcisVerificationError extends Error {
constructor(expected: string, actual: string) {
super(`oCIS payload verification failed: expected sha256 ${expected}, got ${actual}`);
this.name = "OcisVerificationError";
}
}
/** Minimal fetch surface so tests can stub transport without HTTP. */
export type FetchLike = (url: string, init: RequestInit) => Promise<Response>;
export const OCIS_FETCH: unique symbol = Symbol("OCIS_FETCH");
/** Bytes allowed in flight to the transport before the pump pauses. */
export const UPLOAD_BUFFER_CAP = 8 * 1024 * 1024;
/** Node's undici fetch needs this hint when a stream body is supplied. */
type FetchInitWithDuplex = RequestInit & { duplex?: "half" };
/**
* WebDAV client. `fetcher` defaults to the global fetch.
*/
export class OcisWebdavClient {
constructor(
private readonly config: OcisEnvConfig,
private readonly fetcher: FetchLike = (url, init) => fetch(url, init),
) {}
/** Group folder path under the service identity's WebDAV root. */
uploadUrl(groupId: string, safeName: string): string {
return `${this.config.webdavUrl}/remote.php/dav/files/${encodeURIComponent(this.config.user)}/hub-groups/${encodeURIComponent(groupId)}/${encodeURIComponent(safeName)}`;
}
downloadUrl(resourceId: string): string {
return `${this.config.webdavUrl}/remote.php/dav/files/${encodeURIComponent(this.config.user)}/hub-resources/${encodeURIComponent(resourceId)}`;
}
private authHeaders(): Record<string, string> {
const basic = Buffer.from(`${this.config.user}:${this.config.password}`, "utf8").toString("base64");
return { authorization: `Basic ${basic}` };
}
/**
* Stream-to-storage PUT, hashing while streaming (contract: "Hash actual
* bytes while streaming"). The digest covers the exact bytes handed to the
* store — never a claimed Content-Length.
*
* Backpressure: the pump pauses when more than UPLOAD_BUFFER_CAP bytes are
* queued for the transport, so RAM stays bounded for large payloads. The
* pump is driven by our own loop, so the digest completes even if the
* transport returns without draining a small body (a 2xx store that never
* reads its request body is broken and will surface as an integrity error).
*/
async upload(groupId: string, safeName: string, body: Readable, contentType: string): Promise<OcisUploadResult> {
const hash = createHash("sha256");
let sizeBytes = 0;
let queuedBytes = 0;
const queue: Buffer[] = [];
let sourceEnded = false;
let sourceError: Error | undefined;
let wakeTransport: (() => void) | undefined;
let wakePump: (() => void) | undefined;
const requestStream = new ReadableStream<Buffer>({
async pull(controller): Promise<void> {
if (queue.length > 0) {
const next = queue.shift()!;
queuedBytes -= next.byteLength;
controller.enqueue(next);
wakePump?.();
return;
}
if (sourceEnded) {
if (sourceError !== undefined) controller.error(sourceError);
else controller.close();
return;
}
await new Promise<void>((resolve) => {
wakeTransport = resolve;
});
wakeTransport = undefined;
if (queue.length > 0) {
controller.enqueue(queue.shift()!);
wakePump?.();
} else if (sourceError !== undefined) {
controller.error(sourceError);
} else if (sourceEnded) {
controller.close();
}
},
cancel(): void {
// The transport hung up (e.g. it rejected early): keep draining the
// source so the digest/size accounting still completes.
wakeTransport?.();
},
});
const pumped = (async (): Promise<void> => {
for await (const chunk of body) {
const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk as Uint8Array | string);
hash.update(buffer);
sizeBytes += buffer.byteLength;
queuedBytes += buffer.byteLength;
queue.push(buffer);
wakeTransport?.();
while (queuedBytes > UPLOAD_BUFFER_CAP) {
await new Promise<void>((resolve) => {
wakePump = resolve;
});
wakePump = undefined;
}
}
sourceEnded = true;
wakeTransport?.();
})().catch((err: unknown) => {
sourceError = err instanceof Error ? err : new Error(String(err));
wakeTransport?.();
wakePump?.();
});
const response = await this.fetcher(this.uploadUrl(groupId, safeName), {
method: "PUT",
headers: { ...this.authHeaders(), "content-type": contentType },
body: requestStream as unknown as ReadableStream,
duplex: "half",
} satisfies FetchInitWithDuplex);
if (!response.ok) {
requestStream.cancel().catch(() => undefined);
await pumped;
throw new Error(`oCIS upload failed with ${response.status}`);
}
await pumped;
const etag = response.headers.get("oc-etag") ?? response.headers.get("etag");
const resourceId = response.headers.get("oc-fileid");
return {
resourceId: resourceId ?? safeName,
version: etag,
sha256: hash.digest("hex"),
sizeBytes,
};
}
/** Open a GET. The digest is re-checked by verifyWhileStreaming downstream. */
async download(resourceId: string): Promise<OcisDownload> {
const response = await this.fetcher(this.downloadUrl(resourceId), { method: "GET", headers: this.authHeaders() });
if (!response.ok || response.body === null) throw new Error(`oCIS download failed with ${response.status}`);
const length = response.headers.get("content-length");
return {
stream: response.body as unknown as Readable,
sizeBytes: length === null ? null : Number(length),
contentType: response.headers.get("content-type"),
};
}
/** Stable resource link for GroupFile.shareLink.url — never presigned. */
libraryUrl(resourceId: string): string {
return this.config.publicBase === "" ? resourceId : `${this.config.publicBase}/f/${encodeURIComponent(resourceId)}`;
}
}
/**
* Pipe a download forward, verifying sha256 chunk-by-chunk. Contract:
* "SHA-256 verified while streaming" — a mismatch aborts the relay rather
* than handing over tampered bytes (the sink sees them, but the caller
* treats the error as fatal and closes the response).
*/
export async function verifyWhileStreaming(
download: OcisDownload,
expectedSha256: string,
sink: (chunk: Buffer) => void,
): Promise<{ sizeBytes: number }> {
const hash = createHash("sha256");
let sizeBytes = 0;
for await (const chunk of download.stream) {
const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk as Uint8Array);
hash.update(buffer);
sizeBytes += buffer.byteLength;
sink(buffer);
}
const actual = hash.digest("hex");
if (actual !== expectedSha256) throw new OcisVerificationError(expectedSha256, actual);
return { sizeBytes };
}
export function isOcisVerificationError(err: unknown): err is OcisVerificationError {
return err instanceof OcisVerificationError;
}

View file

@ -0,0 +1,40 @@
/**
* Public share tokens for group files.
*
* Contract (docs/occupymedia-storage.md): "Persist library links/share IDs
* and expiry, not presigned object URLs, WOPI tokens or passwords." The DB
* stores sha256(token) ONLY (029 group_file_shares.token_hash); the
* plaintext token is returned exactly once at creation and can never be
* recovered from the database.
*
* 256 bits of entropy (32 random bytes, base64url — same shape as invite
* links), hashed with plain sha256. Deliberately NOT a salted password hash:
* the token is a high-entropy capability, not a low-entropy secret, so an
* offline dictionary attack against token_hash is infeasible and a plain
* digest keeps the public lookup O(1) on a unique index.
*/
import { createHash, randomBytes, timingSafeEqual } from "node:crypto";
export const SHARE_TOKEN_BYTES = 32;
/** Plaintext capability, base64url. Returned ONCE; never persisted. */
export function mintShareToken(): string {
return randomBytes(SHARE_TOKEN_BYTES).toString("base64url");
}
/** The ONLY value ever stored: sha256 of the plaintext, lowercase hex. */
export function hashShareToken(token: string): string {
return createHash("sha256").update(token, "utf8").digest("hex");
}
/** Constant-time compare for lookups done outside the unique-index path. */
export function sameToken(a: string, b: string): boolean {
const left = Buffer.from(a, "utf8");
const right = Buffer.from(b, "utf8");
return left.length === right.length && timingSafeEqual(left, right);
}
/** Shape guard before hashing: reject absurd values before touching the DB. */
export function isPlausibleShareToken(value: unknown): value is string {
return typeof value === "string" && value.length >= 20 && value.length <= 128 && /^[A-Za-z0-9_-]+$/.test(value);
}

View file

@ -0,0 +1,17 @@
# Hub archive sync bot. Build from the monorepo root so workspace:* deps resolve:
# docker build -f apps/archive-bot/Dockerfile -t hub-archive-bot .
FROM node:22-slim AS build
WORKDIR /app
COPY . .
RUN NODE_ENV=development npx --yes pnpm@9.12.0 install --frozen-lockfile
RUN npx --yes pnpm@9.12.0 --filter @tactical-media-broadcast-hub/archive-bot... build
FROM node:22-slim
ENV NODE_ENV=production
WORKDIR /app
# Workspace node_modules are relative symlinks; copy the built tree whole.
COPY --from=build --chown=node:node /app /app
# Non-root, and the bot never writes payloads to disk (streams straight to WebDAV).
USER node
EXPOSE 8085
CMD ["node", "/app/apps/archive-bot/dist/main.js"]

View file

@ -0,0 +1,29 @@
{
"name": "@tactical-media-broadcast-hub/archive-bot",
"private": true,
"version": "0.1.0",
"type": "module",
"scripts": {
"dev": "tsx watch src/main.ts",
"build": "tsc -p tsconfig.build.json",
"start": "node dist/main.js",
"lint": "eslint src",
"typecheck": "tsc -p tsconfig.json --noEmit",
"test": "vitest run"
},
"dependencies": {
"@tactical-media-broadcast-hub/core": "workspace:*",
"pg": "^8.23.1",
"pino": "^9.3.2"
},
"devDependencies": {
"@types/node": "^22.5.0",
"@types/pg": "^8.23.1",
"tsx": "^4.17.0",
"typescript": "^5.5.4",
"vitest": "^2.0.5"
},
"engines": {
"node": ">=22.13.0"
}
}

View file

@ -0,0 +1,90 @@
import type { GroupFile } from "@tactical-media-broadcast-hub/core";
import { toGroupFileMapping, type RoomMapping } from "./config.js";
import type { AttachmentWork } from "./events.js";
import { inspectStream } from "./inspector.js";
import { replyTxnId } from "./keys.js";
import type { MatrixPort } from "./matrix.js";
import { safeFileName } from "./names.js";
import type { ArchiveStore, OutboxRow } from "./store.js";
import type { LibraryPort } from "./webdav.js";
export interface ArchivedAttachment {
file: GroupFile;
safeName: string;
}
/**
* Steps 2–3 of the processing contract for one claimed attachment:
* authenticated download → in-flight size/hash/MIME → staged WebDAV PUT →
* content-addressed MOVE → index row + outbox done in one transaction.
* The payload is never written to local disk.
*/
export class Archiver {
constructor(
private readonly matrix: MatrixPort,
private readonly library: LibraryPort,
private readonly store: ArchiveStore,
) {}
async archive(
mapping: RoomMapping,
work: AttachmentWork,
outbox: OutboxRow,
claimKey: string,
signal: AbortSignal,
): Promise<ArchivedAttachment> {
const { candidate, event } = work;
const download = await this.matrix.downloadMedia(candidate.mxcUri, signal);
const { stream, result } = inspectStream(download.body, download.contentLength);
const dir = ["Library", mapping.workspaceId, mapping.groupId];
// Deterministic per claim: a retried upload overwrites its own partial staging copy.
const stagingName = `${replyTxnId(claimKey)}.part`;
await this.library.putStaging(dir, stagingName, stream, signal);
const payload = await result;
const safeName = safeFileName(work.displayName, payload.sniffed.extension);
const resource = await this.library.commit(dir, stagingName, `${payload.sha256}-${safeName}`, signal);
const createdAt = new Date(event.origin_server_ts ?? Date.now());
const indexed = await this.store.commitArchived({
workspaceId: mapping.workspaceId,
groupId: mapping.groupId,
roomId: mapping.roomId,
eventId: event.event_id,
senderMxid: event.sender,
matrixEventJson: event,
originalName: work.displayName,
safeName,
mime: payload.sniffed.mime,
mimeClass: payload.sniffed.mimeClass,
byteSize: payload.byteSize,
sha256: payload.sha256,
ocisResourceId: resource.resourceId,
ocisResourceVersion: resource.version,
createdAt,
}, outbox.id);
const group = toGroupFileMapping(mapping);
return {
safeName,
file: {
id: indexed.id,
workspaceId: mapping.workspaceId,
groupId: mapping.groupId,
roomId: mapping.roomId,
senderId: event.sender,
eventId: event.event_id,
origin: { kind: "matrix", mxcUri: candidate.mxcUri },
name: safeName,
mimeType: payload.sniffed.mime,
sizeBytes: payload.byteSize,
checksum: { algorithm: "sha256", value: payload.sha256 },
createdAt: createdAt.toISOString(),
indexedAt: indexed.indexedAt.toISOString(),
file: { spaceId: group.spaceId, resourceId: resource.resourceId, version: resource.version },
shareLink: { url: resource.url, access: "members", expiresAt: null },
retention: group.retention,
status: "available",
},
};
}
}

View file

@ -0,0 +1,14 @@
export const BACKOFF_BASE_MS = 30_000;
export const BACKOFF_MAX_MS = 60 * 60 * 1000;
/** After this many attempts a row is marked failed (a durable, terminal retry record). */
export const MAX_ATTEMPTS = 12;
/** Exponential per-row delay after `attempts` failures, capped at one hour. */
export function backoffMs(attempts: number): number {
if (attempts <= 0) return 0;
return Math.min(BACKOFF_BASE_MS * 2 ** (attempts - 1), BACKOFF_MAX_MS);
}
export function nextAttemptAt(attempts: number, lastAttemptAt: Date): Date {
return new Date(lastAttemptAt.getTime() + backoffMs(attempts));
}

View file

@ -0,0 +1,56 @@
import { describe, expect, it } from "vitest";
import { ConfigError, loadConfig, parseRoomMappings, toGroupFileMapping } from "./config.js";
const WS = "11111111-1111-4111-8111-111111111111";
const GROUP = "22222222-2222-4222-8222-222222222222";
describe("parseRoomMappings", () => {
it("accepts immutable room IDs with UUID workspace/group", () => {
expect(parseRoomMappings(JSON.stringify([{ roomId: "!abc:example.org", workspaceId: WS, groupId: GROUP }])))
.toEqual([{ roomId: "!abc:example.org", workspaceId: WS, groupId: GROUP }]);
});
it("rejects aliases, non-UUIDs, duplicates, empty and malformed input", () => {
expect(() => parseRoomMappings(JSON.stringify([{ roomId: "#archive:example.org", workspaceId: WS, groupId: GROUP }])))
.toThrow(ConfigError);
expect(() => parseRoomMappings(JSON.stringify([{ roomId: "!a:x", workspaceId: "ws", groupId: GROUP }]))).toThrow(ConfigError);
const dup = { roomId: "!a:x", workspaceId: WS, groupId: GROUP };
expect(() => parseRoomMappings(JSON.stringify([dup, dup]))).toThrow(/duplicate/);
expect(() => parseRoomMappings("[]")).toThrow(ConfigError);
expect(() => parseRoomMappings("{nope")).toThrow(ConfigError);
});
it("maps to an unencrypted-archive GroupFileMapping kept forever", () => {
const mapping = toGroupFileMapping({ roomId: "!a:x", workspaceId: WS, groupId: GROUP });
expect(mapping.archivePolicy).toBe("unencrypted-archive");
expect(mapping.retention).toEqual({ legalHold: false, mode: "keep" });
});
});
describe("loadConfig", () => {
const env = {
DATABASE_URL: "postgres://bot@db/hub",
MATRIX_HOMESERVER_URL: "https://matrix.example.org",
MATRIX_USER: "archive-bot",
MATRIX_PASSWORD: "pw",
ROOM_MAPPINGS: JSON.stringify([{ roomId: "!a:x", workspaceId: WS, groupId: GROUP }]),
FILES_OCID_WEBDAV_URL: "https://files.example.org",
FILES_OCID_USER: "svc",
FILES_OCID_PASSWORD: "pw2",
ARCHIVE_BOT_HEALTH_PORT: "9100",
MATRIX_MEDIA_REDIRECT_HOSTS: " R2.example.com ,",
};
it("parses the full env contract", () => {
const config = loadConfig(env);
expect(config.healthPort).toBe(9100);
expect(config.mediaRedirectHosts).toEqual(["r2.example.com"]);
expect(config.matrixDeviceId).toBe("HUB_ARCHIVE_BOT");
});
it("fails on missing secrets or a bad port", () => {
expect(() => loadConfig({ ...env, MATRIX_PASSWORD: "" })).toThrow(/MATRIX_PASSWORD/);
expect(() => loadConfig({ ...env, ARCHIVE_BOT_HEALTH_PORT: "70000" })).toThrow(/PORT/);
expect(() => loadConfig({ ...env, FILES_OCID_WEBDAV_URL: "ftp://x" })).toThrow(/http/);
});
});

View file

@ -0,0 +1,110 @@
import type { GroupFileMapping } from "@tactical-media-broadcast-hub/core";
/** Explicit, immutable room → library mapping. Only rooms listed here are ever read. */
export interface RoomMapping {
roomId: string;
workspaceId: string;
groupId: string;
}
export interface BotConfig {
databaseUrl: string;
homeserverUrl: URL;
matrixUser: string;
matrixPassword: string;
/** Stable device so Matrix transaction-ID idempotency survives restarts. */
matrixDeviceId: string;
/** Extra hosts (e.g. the R2 media endpoint) media redirects may target; auth is always dropped. */
mediaRedirectHosts: string[];
mappings: RoomMapping[];
webdav: { baseUrl: URL; user: string; password: string };
healthPort: number;
}
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
/** Room IDs only: `!opaque:server`. Aliases (`#…`) are rejected — mappings must be immutable. */
const ROOM_ID = /^![^:\s]+:[^\s]+$/;
export class ConfigError extends Error {}
function required(env: NodeJS.ProcessEnv, name: string): string {
const value = env[name];
if (value === undefined || value.trim() === "") throw new ConfigError(`${name} is required`);
return value.trim();
}
function httpUrl(name: string, raw: string): URL {
let url: URL;
try {
url = new URL(raw);
} catch {
throw new ConfigError(`${name} must be an absolute URL`);
}
if (url.protocol !== "https:" && url.protocol !== "http:") throw new ConfigError(`${name} must be http(s)`);
return url;
}
export function parseRoomMappings(raw: string): RoomMapping[] {
let parsed: unknown;
try {
parsed = JSON.parse(raw);
} catch {
throw new ConfigError("ROOM_MAPPINGS must be JSON");
}
if (!Array.isArray(parsed) || parsed.length === 0) throw new ConfigError("ROOM_MAPPINGS must be a non-empty array");
const seen = new Set<string>();
return parsed.map((entry: unknown, index) => {
if (typeof entry !== "object" || entry === null) throw new ConfigError(`ROOM_MAPPINGS[${index}] must be an object`);
const { roomId, workspaceId, groupId } = entry as Record<string, unknown>;
if (typeof roomId !== "string" || !ROOM_ID.test(roomId)) {
throw new ConfigError(`ROOM_MAPPINGS[${index}].roomId must be a room ID (!id:server), not an alias`);
}
if (typeof workspaceId !== "string" || !UUID.test(workspaceId)) {
throw new ConfigError(`ROOM_MAPPINGS[${index}].workspaceId must be a UUID`);
}
if (typeof groupId !== "string" || !UUID.test(groupId)) {
throw new ConfigError(`ROOM_MAPPINGS[${index}].groupId must be a UUID`);
}
if (seen.has(roomId)) throw new ConfigError(`ROOM_MAPPINGS has duplicate roomId ${roomId}`);
seen.add(roomId);
return { roomId, workspaceId: workspaceId.toLowerCase(), groupId: groupId.toLowerCase() };
});
}
/** The env mapping only exists for rooms explicitly enabled as unencrypted archive rooms. */
export function toGroupFileMapping(mapping: RoomMapping): GroupFileMapping {
return {
workspaceId: mapping.workspaceId,
groupId: mapping.groupId,
roomId: mapping.roomId,
spaceId: mapping.workspaceId,
folderId: `${mapping.workspaceId}/${mapping.groupId}`,
archivePolicy: "unencrypted-archive",
retention: { legalHold: false, mode: "keep" },
};
}
export function loadConfig(env: NodeJS.ProcessEnv = process.env): BotConfig {
const healthPort = Number(env.ARCHIVE_BOT_HEALTH_PORT ?? 8085);
if (!Number.isInteger(healthPort) || healthPort <= 0 || healthPort > 65535) {
throw new ConfigError("ARCHIVE_BOT_HEALTH_PORT must be a TCP port");
}
return {
databaseUrl: required(env, "DATABASE_URL"),
homeserverUrl: httpUrl("MATRIX_HOMESERVER_URL", required(env, "MATRIX_HOMESERVER_URL")),
matrixUser: required(env, "MATRIX_USER"),
matrixPassword: required(env, "MATRIX_PASSWORD"),
matrixDeviceId: env.MATRIX_DEVICE_ID?.trim() || "HUB_ARCHIVE_BOT",
mediaRedirectHosts: (env.MATRIX_MEDIA_REDIRECT_HOSTS ?? "")
.split(",")
.map((h) => h.trim().toLowerCase())
.filter((h) => h !== ""),
mappings: parseRoomMappings(required(env, "ROOM_MAPPINGS")),
webdav: {
baseUrl: httpUrl("FILES_OCID_WEBDAV_URL", required(env, "FILES_OCID_WEBDAV_URL")),
user: required(env, "FILES_OCID_USER"),
password: required(env, "FILES_OCID_PASSWORD"),
},
healthPort,
};
}

View file

@ -0,0 +1,71 @@
import { describe, expect, it } from "vitest";
import type { RoomMapping } from "./config.js";
import { classifyEvent, parseMxc, redactionTarget, type MatrixEvent } from "./events.js";
const mapping: RoomMapping = {
roomId: "!room:example.org",
workspaceId: "11111111-1111-4111-8111-111111111111",
groupId: "22222222-2222-4222-8222-222222222222",
};
const BOT = "@archive:example.org";
function message(content: Record<string, unknown>, extra: Partial<MatrixEvent> = {}): MatrixEvent {
return { type: "m.room.message", event_id: "$ev1", sender: "@alice:example.org", content, ...extra };
}
const image = { msgtype: "m.image", body: "cat.png", url: "mxc://example.org/abc123" };
describe("classifyEvent", () => {
it("accepts an unencrypted m.image with an mxc URI", () => {
const result = classifyEvent(message(image), mapping, "unencrypted", BOT);
expect(result.kind).toBe("attachment");
if (result.kind !== "attachment") return;
expect(result.candidate).toMatchObject({ roomId: mapping.roomId, eventId: "$ev1", msgtype: "m.image", mxcUri: image.url });
expect(result.displayName).toBe("cat.png");
});
it("prefers content.filename over body (caption) for the display name", () => {
const result = classifyEvent(message({ ...image, filename: "real.png", body: "a caption" }), mapping, "unencrypted", BOT);
expect(result.kind === "attachment" && result.displayName).toBe("real.png");
});
it.each(["m.file", "m.audio", "m.video"])("accepts %s", (msgtype) => {
expect(classifyEvent(message({ ...image, msgtype }), mapping, "unencrypted", BOT).kind).toBe("attachment");
});
it.each([
["own-event", message(image, { sender: BOT }), "unencrypted"],
["room-encrypted", message(image), "encrypted"],
["room-unknown", message(image), "unknown"],
["encrypted-event", { ...message(image), type: "m.room.encrypted" }, "unencrypted"],
["encrypted-file", message({ msgtype: "m.file", body: "x", file: { url: "mxc://example.org/enc" } }), "unencrypted"],
["edit", message({ ...image, "m.relates_to": { rel_type: "m.replace", event_id: "$old" } }), "unencrypted"],
["edit", message({ ...image, "m.new_content": image }), "unencrypted"],
["redacted", message(image, { unsigned: { redacted_because: {} } }), "unencrypted"],
["not-an-attachment", message({ msgtype: "m.text", body: "hi" }), "unencrypted"],
["not-a-message", { ...message(image), type: "m.sticker" }, "unencrypted"],
["ineligible", message({ ...image, url: "https://evil.example/x.png" }), "unencrypted"],
["ineligible", message({ ...image, url: "mxc://example.org/a/b" }), "unencrypted"],
["ineligible", message({ msgtype: "m.image", body: "x" }), "unencrypted"],
] as const)("skips %s", (reason, event, encryption) => {
const result = classifyEvent(event, mapping, encryption, BOT);
expect(result).toMatchObject({ kind: "skip", reason });
});
it("classifies redactions regardless of room encryption (v11 content.redacts and legacy top-level)", () => {
const v11: MatrixEvent = { type: "m.room.redaction", event_id: "$r", sender: "@mod:x", content: { redacts: "$ev1" } };
const legacy: MatrixEvent = { type: "m.room.redaction", event_id: "$r", sender: "@mod:x", content: {}, redacts: "$ev2" };
expect(classifyEvent(v11, mapping, "encrypted", BOT)).toMatchObject({ kind: "redaction", redacts: "$ev1" });
expect(redactionTarget(legacy)).toBe("$ev2");
expect(classifyEvent({ ...v11, content: {} }, mapping, "unencrypted", BOT).kind).toBe("skip");
});
});
describe("parseMxc", () => {
it("splits server and media id", () => {
expect(parseMxc("mxc://example.org/abc")).toEqual({ serverName: "example.org", mediaId: "abc" });
});
it("rejects anything else", () => {
expect(() => parseMxc("https://example.org/abc")).toThrow();
expect(() => parseMxc("mxc://example.org/../x")).toThrow();
});
});

View file

@ -0,0 +1,109 @@
import { canArchiveAttachment, type MatrixAttachmentCandidate } from "@tactical-media-broadcast-hub/core";
import { toGroupFileMapping, type RoomMapping } from "./config.js";
/** Client-server API event shape (only the fields the bot reads). */
export interface MatrixEvent {
type: string;
event_id: string;
sender: string;
origin_server_ts?: number;
state_key?: string;
/** Room v1–v10 redactions carry the target here; v11+ moved it into content. */
redacts?: string;
content: Record<string, unknown>;
unsigned?: { redacted_because?: unknown; [key: string]: unknown };
}
export type RoomEncryption = MatrixAttachmentCandidate["roomEncryption"];
export const ATTACHMENT_MSGTYPES = ["m.file", "m.image", "m.audio", "m.video"] as const;
export interface AttachmentWork {
kind: "attachment";
event: MatrixEvent;
candidate: MatrixAttachmentCandidate;
/** Sender-supplied display name; only ever used after normalisation. */
displayName: string;
}
export type Classified =
| AttachmentWork
| { kind: "redaction"; event: MatrixEvent; redacts: string }
| { kind: "skip"; event: MatrixEvent; reason: string };
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
/** Runtime guard for raw JSON from /sync or /messages. */
export function isMatrixEvent(value: unknown): value is MatrixEvent {
return isRecord(value)
&& typeof value.type === "string"
&& typeof value.event_id === "string"
&& typeof value.sender === "string"
&& isRecord(value.content);
}
export function redactionTarget(event: MatrixEvent): string | null {
const target = typeof event.content.redacts === "string" ? event.content.redacts : event.redacts;
return typeof target === "string" && target.startsWith("$") ? target : null;
}
function isEdit(content: Record<string, unknown>): boolean {
const relates = content["m.relates_to"];
return (isRecord(relates) && relates.rel_type === "m.replace") || "m.new_content" in content;
}
/**
* Decide what (if anything) to do with a timeline event in a mapped room.
* Every exclusion from the processing contract lives here so it is unit-testable;
* the final eligibility gate is the shared `canArchiveAttachment`.
*/
export function classifyEvent(
event: MatrixEvent,
mapping: RoomMapping,
roomEncryption: RoomEncryption,
botUserId: string,
): Classified {
if (event.sender === botUserId) return { kind: "skip", event, reason: "own-event" };
if (event.type === "m.room.redaction") {
const redacts = redactionTarget(event);
return redacts === null ? { kind: "skip", event, reason: "redaction-without-target" } : { kind: "redaction", event, redacts };
}
if (event.type === "m.room.encrypted") return { kind: "skip", event, reason: "encrypted-event" };
if (event.type !== "m.room.message") return { kind: "skip", event, reason: "not-a-message" };
if (event.unsigned?.redacted_because !== undefined) return { kind: "skip", event, reason: "redacted" };
const { content } = event;
const msgtype = typeof content.msgtype === "string" ? content.msgtype : "";
if (!(ATTACHMENT_MSGTYPES as readonly string[]).includes(msgtype)) {
return { kind: "skip", event, reason: "not-an-attachment" };
}
if (isEdit(content)) return { kind: "skip", event, reason: "edit" };
if (content.file !== undefined) return { kind: "skip", event, reason: "encrypted-file" };
if (roomEncryption !== "unencrypted") return { kind: "skip", event, reason: `room-${roomEncryption}` };
const candidate: MatrixAttachmentCandidate = {
roomId: mapping.roomId,
eventId: event.event_id,
senderId: event.sender,
eventType: event.type,
msgtype,
roomEncryption,
encryptedFile: content.file !== undefined,
redacted: false,
mxcUri: typeof content.url === "string" ? content.url : "",
};
if (!canArchiveAttachment(toGroupFileMapping(mapping), candidate)) {
return { kind: "skip", event, reason: "ineligible" };
}
const filename = typeof content.filename === "string" && content.filename.trim() !== "" ? content.filename : null;
const body = typeof content.body === "string" ? content.body : "";
return { kind: "attachment", event, candidate, displayName: filename ?? body };
}
/** Split a validated `mxc://server/mediaId` into its parts. */
export function parseMxc(mxcUri: string): { serverName: string; mediaId: string } {
const match = /^mxc:\/\/([^/\s]+)\/([^/\s?#]+)$/.exec(mxcUri);
if (match === null || match[1] === undefined || match[2] === undefined) throw new Error("invalid mxc URI");
return { serverName: match[1], mediaId: match[2] };
}

View file

@ -0,0 +1,24 @@
import { createServer, type Server } from "node:http";
import type { Logger } from "pino";
import type { HealthSnapshot } from "./processor.js";
/** GET /health (or /) → 200 {rooms, cursor, pending, lastProcessedAt}; 503 when the DB count fails. */
export function startHealthServer(port: number, snapshot: () => Promise<HealthSnapshot>, logger: Logger): Server {
const server = createServer((req, res) => {
if (req.method !== "GET" || (req.url !== "/" && req.url !== "/health")) {
res.writeHead(404).end();
return;
}
snapshot().then(
(body) => {
res.writeHead(200, { "content-type": "application/json" }).end(JSON.stringify(body));
},
(err: unknown) => {
logger.warn({ err: err instanceof Error ? err.message : String(err) }, "health: snapshot failed");
res.writeHead(503, { "content-type": "application/json" }).end(JSON.stringify({ status: "down" }));
},
);
});
server.listen(port, () => logger.info({ port }, "health: listening"));
return server;
}

View file

@ -0,0 +1,81 @@
import { createHash } from "node:crypto";
import { describe, expect, it } from "vitest";
import { inspectStream, parseContentLength, PayloadInspector, PermanentArchiveError } from "./inspector.js";
const PDF = new TextEncoder().encode("%PDF-1.7\nhello world");
function streamOf(...chunks: Uint8Array[]): ReadableStream<Uint8Array> {
return new ReadableStream({
start(controller) {
for (const chunk of chunks) controller.enqueue(chunk);
controller.close();
},
});
}
async function drain(stream: ReadableStream<Uint8Array>): Promise<number> {
let total = 0;
for await (const chunk of stream) total += chunk.byteLength;
return total;
}
describe("PayloadInspector", () => {
it("hashes, counts and sniffs across chunk boundaries", () => {
const inspector = new PayloadInspector(PDF.byteLength);
inspector.update(PDF.subarray(0, 2));
inspector.update(PDF.subarray(2));
expect(inspector.finish()).toEqual({
sha256: createHash("sha256").update(PDF).digest("hex"),
byteSize: PDF.byteLength,
sniffed: { mime: "application/pdf", mimeClass: "document", extension: "pdf" },
});
});
it("rejects an oversized declared length before reading", () => {
expect(() => new PayloadInspector(21 * 1024 * 1024)).toThrow(PermanentArchiveError);
});
it("rejects streams exceeding the cap without Content-Length", () => {
const inspector = new PayloadInspector(null, 10);
expect(() => inspector.update(new Uint8Array(11))).toThrow(/exceeds/);
});
it("rejects Content-Length lies in both directions", () => {
const longer = new PayloadInspector(4);
expect(() => longer.update(new Uint8Array(5))).toThrow(/longer/);
const shorter = new PayloadInspector(10);
shorter.update(new Uint8Array(4));
expect(() => shorter.finish()).toThrow(/shorter/);
});
it("rejects empty payloads", () => {
expect(() => new PayloadInspector(null).finish()).toThrow(/empty/);
});
});
describe("inspectStream", () => {
it("passes bytes through untouched and resolves the result", async () => {
const { stream, result } = inspectStream(streamOf(PDF.subarray(0, 5), PDF.subarray(5)), PDF.byteLength);
expect(await drain(stream)).toBe(PDF.byteLength);
expect((await result).byteSize).toBe(PDF.byteLength);
});
it("errors the stream (aborting the upload) when the source lies about length", async () => {
const { stream, result } = inspectStream(streamOf(PDF), PDF.byteLength + 1);
await expect(drain(stream)).rejects.toThrow(PermanentArchiveError);
await expect(result).rejects.toThrow(/shorter/);
});
it("errors mid-stream once the cap is crossed", async () => {
const { stream } = inspectStream(streamOf(new Uint8Array(6), new Uint8Array(6)), null, 10);
await expect(drain(stream)).rejects.toThrow(/exceeds/);
});
});
describe("parseContentLength", () => {
it("parses digits and treats absence as unknown", () => {
expect(parseContentLength("42")).toBe(42);
expect(parseContentLength(null)).toBeNull();
expect(() => parseContentLength("-1")).toThrow(PermanentArchiveError);
});
});

View file

@ -0,0 +1,105 @@
import { createHash, type Hash } from "node:crypto";
import { sniffMime, SNIFF_BYTES, type SniffedMime } from "./mime.js";
export const MAX_ATTACHMENT_BYTES = 20 * 1024 * 1024;
/** Non-retryable payload problems: the outbox row is marked failed immediately. */
export class PermanentArchiveError extends Error {}
export interface InspectedPayload {
sha256: string;
byteSize: number;
sniffed: SniffedMime;
}
/**
* Incremental size/hash/magic-byte inspection. Bytes pass straight through to
* the upload; nothing is buffered beyond the first SNIFF_BYTES.
*/
export class PayloadInspector {
private readonly hash: Hash = createHash("sha256");
private readonly head = new Uint8Array(SNIFF_BYTES);
private headLength = 0;
private total = 0;
constructor(
private readonly declaredLength: number | null,
private readonly maxBytes = MAX_ATTACHMENT_BYTES,
) {
if (declaredLength !== null && declaredLength > maxBytes) {
throw new PermanentArchiveError(`declared size ${declaredLength} exceeds ${maxBytes} bytes`);
}
}
update(chunk: Uint8Array): void {
this.total += chunk.byteLength;
if (this.total > this.maxBytes) throw new PermanentArchiveError(`payload exceeds ${this.maxBytes} bytes`);
if (this.declaredLength !== null && this.total > this.declaredLength) {
throw new PermanentArchiveError("payload longer than Content-Length");
}
if (this.headLength < SNIFF_BYTES) {
const take = chunk.subarray(0, SNIFF_BYTES - this.headLength);
this.head.set(take, this.headLength);
this.headLength += take.byteLength;
}
this.hash.update(chunk);
}
finish(): InspectedPayload {
if (this.declaredLength !== null && this.total !== this.declaredLength) {
throw new PermanentArchiveError("payload shorter than Content-Length");
}
if (this.total === 0) throw new PermanentArchiveError("empty payload");
return {
sha256: this.hash.digest("hex"),
byteSize: this.total,
sniffed: sniffMime(this.head.subarray(0, this.headLength)),
};
}
}
/**
* Wrap a byte stream so it is inspected in flight. Any violation errors the
* stream, which aborts the consuming upload before it can complete.
*/
export function inspectStream(
source: ReadableStream<Uint8Array>,
declaredLength: number | null,
maxBytes = MAX_ATTACHMENT_BYTES,
): { stream: ReadableStream<Uint8Array>; result: Promise<InspectedPayload> } {
const inspector = new PayloadInspector(declaredLength, maxBytes);
let resolve!: (value: InspectedPayload) => void;
let reject!: (reason: unknown) => void;
const result = new Promise<InspectedPayload>((res, rej) => {
resolve = res;
reject = rej;
});
// The caller awaits the upload first; avoid an unhandled rejection in the meantime.
result.catch(() => undefined);
const transform = new TransformStream<Uint8Array, Uint8Array>({
transform(chunk, controller) {
try {
inspector.update(chunk);
} catch (err) {
reject(err);
throw err;
}
controller.enqueue(chunk);
},
flush() {
try {
resolve(inspector.finish());
} catch (err) {
reject(err);
throw err;
}
},
});
return { stream: source.pipeThrough(transform), result };
}
export function parseContentLength(header: string | null): number | null {
if (header === null) return null;
if (!/^\d+$/.test(header.trim())) throw new PermanentArchiveError("invalid Content-Length");
return Number(header.trim());
}

View file

@ -0,0 +1,33 @@
import { createHash } from "node:crypto";
import { describe, expect, it } from "vitest";
import { backoffMs, BACKOFF_BASE_MS, BACKOFF_MAX_MS, nextAttemptAt } from "./backoff.js";
import { claimKey, replyTxnId } from "./keys.js";
describe("claimKey", () => {
it("is the shared attachmentArchiveKey encoding", () => {
expect(claimKey("w", "!r:x", "$e")).toBe(JSON.stringify(["w", "!r:x", "$e"]));
});
it("is collision-free across separators", () => {
expect(claimKey("a", "b|c", "d")).not.toBe(claimKey("a|b", "c", "d"));
});
});
describe("replyTxnId", () => {
it("is the first 24 hex chars of sha256(claim key) and stable", () => {
const key = claimKey("w", "!r:x", "$e");
const expected = createHash("sha256").update(key).digest("hex").slice(0, 24);
expect(replyTxnId(key)).toBe(expected);
expect(replyTxnId(key)).toMatch(/^[0-9a-f]{24}$/);
expect(replyTxnId(claimKey("w", "!r:x", "$f"))).not.toBe(expected);
});
});
describe("backoff", () => {
it("grows exponentially and caps at one hour", () => {
expect(backoffMs(0)).toBe(0);
expect(backoffMs(1)).toBe(BACKOFF_BASE_MS);
expect(backoffMs(3)).toBe(BACKOFF_BASE_MS * 4);
expect(backoffMs(40)).toBe(BACKOFF_MAX_MS);
expect(nextAttemptAt(2, new Date(0)).getTime()).toBe(BACKOFF_BASE_MS * 2);
});
});

View file

@ -0,0 +1,12 @@
import { createHash } from "node:crypto";
import { attachmentArchiveKey } from "@tactical-media-broadcast-hub/core";
/** Durable claim identity; the shared contract owns the encoding. */
export function claimKey(workspaceId: string, roomId: string, eventId: string): string {
return attachmentArchiveKey(workspaceId, roomId, eventId);
}
/** Stable Matrix transaction ID for the single confirmation reply of a claim. */
export function replyTxnId(key: string): string {
return createHash("sha256").update(key, "utf8").digest("hex").slice(0, 24);
}

View file

@ -0,0 +1,14 @@
import pino from "pino";
export const logger = pino({
level: process.env.LOG_LEVEL ?? "info",
base: { service: "archive-bot" },
redact: {
paths: [
"password", "accessToken", "access_token", "authorization",
"*.password", "*.accessToken", "*.access_token", "*.authorization",
"headers.authorization", "*.headers.authorization",
],
censor: "[REDACTED]",
},
});

View file

@ -0,0 +1,50 @@
import { Archiver } from "./archiver.js";
import { loadConfig } from "./config.js";
import { startHealthServer } from "./health.js";
import { logger } from "./logger.js";
import { MatrixClient } from "./matrix.js";
import { ArchiveProcessor } from "./processor.js";
import { PgArchiveStore } from "./store.js";
import { OcisWebDav } from "./webdav.js";
async function main(): Promise<void> {
const config = loadConfig();
const shutdown = new AbortController();
for (const signal of ["SIGTERM", "SIGINT"] as const) {
process.once(signal, () => {
logger.info({ signal }, "archive-bot: shutting down");
shutdown.abort();
});
}
const matrix = new MatrixClient({
homeserver: config.homeserverUrl,
user: config.matrixUser,
password: config.matrixPassword,
deviceId: config.matrixDeviceId,
mediaRedirectHosts: config.mediaRedirectHosts,
roomIds: config.mappings.map((m) => m.roomId),
logger,
});
await matrix.login(shutdown.signal);
logger.info({ userId: matrix.userId, rooms: config.mappings.length }, "archive-bot: logged in");
const store = PgArchiveStore.create(config.databaseUrl, matrix.userId);
const library = new OcisWebDav(config.webdav.baseUrl, config.webdav.user, config.webdav.password);
const processor = new ArchiveProcessor(matrix, store, new Archiver(matrix, library, store), config.mappings, logger);
await processor.init();
const health = startHealthServer(config.healthPort, () => processor.health(), logger);
try {
await processor.run(shutdown.signal, (signal) => matrix.login(signal));
} finally {
await new Promise<void>((resolve) => health.close(() => resolve()));
await store.close();
}
logger.info("archive-bot: stopped");
}
void main().catch((error: unknown) => {
logger.fatal({ err: error instanceof Error ? error.message : String(error) }, "archive-bot: fatal");
process.exitCode = 1;
});

View file

@ -0,0 +1,69 @@
import { describe, expect, it } from "vitest";
import { PermanentArchiveError } from "./inspector.js";
import { parseSync, resolveRedirect, supportsAuthenticatedMedia, syncFilter } from "./matrix.js";
const HS = new URL("https://matrix.example.org");
describe("supportsAuthenticatedMedia", () => {
it("requires v1.11+ or the MSC3916 stable flag", () => {
expect(supportsAuthenticatedMedia({ versions: ["v1.10", "v1.11"] })).toBe(true);
expect(supportsAuthenticatedMedia({ versions: ["v1.12"] })).toBe(true);
expect(supportsAuthenticatedMedia({ versions: ["v1.9"], unstable_features: { "org.matrix.msc3916.stable": true } })).toBe(true);
expect(supportsAuthenticatedMedia({ versions: ["r0.6.1", "v1.10"] })).toBe(false);
expect(supportsAuthenticatedMedia(null)).toBe(false);
});
});
describe("resolveRedirect", () => {
it("keeps auth only for the homeserver origin", () => {
expect(resolveRedirect(HS, "/_matrix/media/x", HS, [])).toMatchObject({ sendAuth: true });
});
it("drops auth for allowlisted object-store hosts", () => {
const hop = resolveRedirect(HS, "https://r2.example.com/bucket/obj?sig=1", HS, ["r2.example.com"]);
expect(hop).toMatchObject({ sendAuth: false });
expect(hop.url.hostname).toBe("r2.example.com");
});
it("refuses unapproved hosts and http downgrades", () => {
expect(() => resolveRedirect(HS, "https://evil.example/x", HS, ["r2.example.com"])).toThrow(PermanentArchiveError);
expect(() => resolveRedirect(HS, "http://r2.example.com/x", HS, ["r2.example.com"])).toThrow(/non-https/);
expect(() => resolveRedirect(HS, "https://matrix.example.org:8448/x", HS, [])).toThrow(PermanentArchiveError);
});
});
describe("parseSync", () => {
it("extracts joined timelines, state and invites, dropping malformed events", () => {
const result = parseSync({
next_batch: "s2",
rooms: {
join: {
"!a:x": {
timeline: { events: [{ type: "m.room.message", event_id: "$1", sender: "@u:x", content: {} }, { junk: true }], limited: true, prev_batch: "p1" },
state: { events: [{ type: "m.room.encryption", event_id: "$s", sender: "@u:x", state_key: "", content: { algorithm: "m.megolm.v1.aes-sha2" } }] },
},
},
invite: { "!b:x": {} },
},
});
expect(result.nextBatch).toBe("s2");
expect(result.invited).toEqual(["!b:x"]);
const room = result.joined.get("!a:x");
expect(room?.timeline).toMatchObject({ limited: true, prevBatch: "p1" });
expect(room?.timeline.events).toHaveLength(1);
expect(room?.stateEvents[0]?.type).toBe("m.room.encryption");
});
it("rejects a response without next_batch", () => {
expect(() => parseSync({})).toThrow();
});
});
describe("syncFilter", () => {
it("restricts to mapped rooms and the needed event types", () => {
const filter = syncFilter(["!a:x"]) as { room: { rooms: string[]; timeline: { types: string[] } } };
expect(filter.room.rooms).toEqual(["!a:x"]);
expect(filter.room.timeline.types).toContain("m.room.redaction");
expect(filter.room.timeline.types).toContain("m.room.encryption");
});
});

View file

@ -0,0 +1,298 @@
import type { Logger } from "pino";
import { isMatrixEvent, parseMxc, type MatrixEvent, type RoomEncryption } from "./events.js";
import { PermanentArchiveError, parseContentLength } from "./inspector.js";
export class MatrixHttpError extends Error {
constructor(
readonly status: number,
readonly errcode: string | null,
message: string,
) {
super(message);
}
}
export interface JoinedRoomSync {
timeline: { events: MatrixEvent[]; limited: boolean; prevBatch: string | null };
stateEvents: MatrixEvent[];
}
export interface SyncResult {
nextBatch: string;
joined: Map<string, JoinedRoomSync>;
invited: string[];
}
export interface MediaDownload {
body: ReadableStream<Uint8Array>;
contentLength: number | null;
}
/** The narrow Matrix surface the processor needs; faked in tests. */
export interface MatrixPort {
readonly userId: string;
sync(since: string | null, signal: AbortSignal): Promise<SyncResult>;
/** Older events between `from` (a prev_batch) back to `to` (the previous sync token), oldest first. */
backfill(roomId: string, from: string, to: string, signal: AbortSignal): Promise<MatrixEvent[]>;
roomEncryption(roomId: string, signal: AbortSignal): Promise<RoomEncryption>;
joinRoom(roomId: string, signal: AbortSignal): Promise<void>;
downloadMedia(mxcUri: string, signal: AbortSignal): Promise<MediaDownload>;
sendNotice(roomId: string, txnId: string, content: Record<string, unknown>, signal: AbortSignal): Promise<void>;
}
const SYNC_TIMEOUT_MS = 30_000;
const MAX_REDIRECTS = 3;
const BACKFILL_PAGES = 20;
const TIMELINE_TYPES = ["m.room.message", "m.room.redaction", "m.room.encrypted", "m.room.encryption"];
/** Client-side filter: mapped rooms only, minimal timeline types, no presence/ephemeral noise. */
export function syncFilter(roomIds: readonly string[]): Record<string, unknown> {
return {
presence: { not_types: ["*"] },
account_data: { not_types: ["*"] },
room: {
rooms: [...roomIds],
account_data: { not_types: ["*"] },
ephemeral: { not_types: ["*"] },
state: { types: ["m.room.encryption"], lazy_load_members: true },
timeline: { limit: 50, types: TIMELINE_TYPES },
},
};
}
/** Authenticated media requires spec v1.11+ (or the MSC3916 stable flag on older advertisers). */
export function supportsAuthenticatedMedia(versions: unknown): boolean {
if (typeof versions !== "object" || versions === null) return false;
const record = versions as { versions?: unknown; unstable_features?: unknown };
const list = Array.isArray(record.versions) ? record.versions.filter((v): v is string => typeof v === "string") : [];
const modern = list.some((v) => {
const match = /^v1\.(\d+)$/.exec(v);
return match !== null && Number(match[1]) >= 11;
});
const features = record.unstable_features;
const flagged = typeof features === "object" && features !== null
&& (features as Record<string, unknown>)["org.matrix.msc3916.stable"] === true;
return modern || flagged;
}
/**
* Redirect policy for media downloads. Bearer auth is only ever sent to the
* homeserver origin; other hosts must be explicitly allowlisted (e.g. the R2
* endpoint) and are fetched without credentials. Downgrades to http are refused.
*/
export function resolveRedirect(
current: URL,
location: string,
homeserver: URL,
allowedHosts: readonly string[],
): { url: URL; sendAuth: boolean } {
const url = new URL(location, current);
if (url.origin === homeserver.origin) return { url, sendAuth: true };
if (url.protocol !== "https:") throw new PermanentArchiveError(`refusing non-https media redirect to ${url.host}`);
if (!allowedHosts.includes(url.hostname.toLowerCase())) {
throw new PermanentArchiveError(`media redirect to unapproved host ${url.hostname}`);
}
return { url, sendAuth: false };
}
function events(value: unknown): MatrixEvent[] {
return Array.isArray(value) ? value.filter(isMatrixEvent) : [];
}
function record(value: unknown): Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value) ? (value as Record<string, unknown>) : {};
}
export function parseSync(body: unknown): SyncResult {
const root = record(body);
if (typeof root.next_batch !== "string") throw new Error("sync response missing next_batch");
const rooms = record(root.rooms);
const joined = new Map<string, JoinedRoomSync>();
for (const [roomId, raw] of Object.entries(record(rooms.join))) {
const room = record(raw);
const timeline = record(room.timeline);
joined.set(roomId, {
timeline: {
events: events(timeline.events),
limited: timeline.limited === true,
prevBatch: typeof timeline.prev_batch === "string" ? timeline.prev_batch : null,
},
stateEvents: events(record(room.state).events),
});
}
return { nextBatch: root.next_batch, joined, invited: Object.keys(record(rooms.invite)) };
}
export interface MatrixClientOptions {
homeserver: URL;
user: string;
password: string;
deviceId: string;
mediaRedirectHosts: string[];
roomIds: string[];
logger: Logger;
}
/** Plain client-server sync-loop client (not an appservice). No crypto: encrypted content is never touched. */
export class MatrixClient implements MatrixPort {
private accessToken: string | null = null;
private resolvedUserId = "";
private readonly filter: string;
constructor(private readonly opts: MatrixClientOptions) {
this.filter = JSON.stringify(syncFilter(opts.roomIds));
}
get userId(): string {
return this.resolvedUserId;
}
async login(signal: AbortSignal): Promise<void> {
const versions = await this.request("GET", "/_matrix/client/versions", { signal, auth: false });
if (!supportsAuthenticatedMedia(versions)) {
throw new Error("homeserver does not support authenticated media (v1.11); refusing to start");
}
const body = record(await this.request("POST", "/_matrix/client/v3/login", {
signal,
auth: false,
json: {
type: "m.login.password",
identifier: { type: "m.id.user", user: this.opts.user },
password: this.opts.password,
device_id: this.opts.deviceId,
initial_device_display_name: "Hub archive bot",
},
}));
if (typeof body.access_token !== "string" || typeof body.user_id !== "string") {
throw new Error("login response missing access_token/user_id");
}
this.accessToken = body.access_token;
this.resolvedUserId = body.user_id;
}
async sync(since: string | null, signal: AbortSignal): Promise<SyncResult> {
const query = new URLSearchParams({ filter: this.filter, timeout: String(since === null ? 0 : SYNC_TIMEOUT_MS) });
if (since !== null) query.set("since", since);
const timeout = AbortSignal.any([signal, AbortSignal.timeout(SYNC_TIMEOUT_MS + 30_000)]);
return parseSync(await this.request("GET", `/_matrix/client/v3/sync?${query.toString()}`, { signal: timeout }));
}
async backfill(roomId: string, from: string, to: string, signal: AbortSignal): Promise<MatrixEvent[]> {
const collected: MatrixEvent[] = [];
let cursor: string | null = from;
for (let page = 0; page < BACKFILL_PAGES && cursor !== null; page += 1) {
const query = new URLSearchParams({
from: cursor,
to,
dir: "b",
limit: "100",
filter: JSON.stringify({ types: TIMELINE_TYPES }),
});
const body = record(await this.request(
"GET",
`/_matrix/client/v3/rooms/${encodeURIComponent(roomId)}/messages?${query.toString()}`,
{ signal },
));
const chunk = events(body.chunk);
collected.push(...chunk);
cursor = chunk.length > 0 && typeof body.end === "string" ? body.end : null;
}
if (cursor !== null) this.opts.logger.warn({ roomId }, "matrix: backfill page budget exhausted; older gap events skipped");
return collected.reverse();
}
async roomEncryption(roomId: string, signal: AbortSignal): Promise<RoomEncryption> {
try {
const body = record(await this.request(
"GET",
`/_matrix/client/v3/rooms/${encodeURIComponent(roomId)}/state/m.room.encryption/`,
{ signal },
));
// Any present encryption state (even an unknown algorithm) means encrypted.
return Object.keys(body).length > 0 ? "encrypted" : "unknown";
} catch (err) {
if (err instanceof MatrixHttpError && err.status === 404 && err.errcode === "M_NOT_FOUND") return "unencrypted";
if (err instanceof MatrixHttpError && (err.status === 403 || err.status === 404)) return "unknown";
throw err;
}
}
async joinRoom(roomId: string, signal: AbortSignal): Promise<void> {
await this.request("POST", `/_matrix/client/v3/join/${encodeURIComponent(roomId)}`, { signal, json: {} });
}
async sendNotice(roomId: string, txnId: string, content: Record<string, unknown>, signal: AbortSignal): Promise<void> {
await this.request(
"PUT",
`/_matrix/client/v3/rooms/${encodeURIComponent(roomId)}/send/m.room.message/${encodeURIComponent(txnId)}`,
{ signal, json: content },
);
}
/** Authenticated media endpoint only; the URL is derived from a validated mxc URI, never user-supplied HTTP. */
async downloadMedia(mxcUri: string, signal: AbortSignal): Promise<MediaDownload> {
const { serverName, mediaId } = parseMxc(mxcUri);
let url = new URL(
`/_matrix/client/v1/media/download/${encodeURIComponent(serverName)}/${encodeURIComponent(mediaId)}?allow_redirect=true`,
this.opts.homeserver,
);
let sendAuth = true;
for (let hop = 0; hop <= MAX_REDIRECTS; hop += 1) {
const headers: Record<string, string> = {};
if (sendAuth) headers.authorization = `Bearer ${this.token()}`;
const res = await fetch(url, { headers, redirect: "manual", signal });
if (res.status >= 300 && res.status < 400) {
const location = res.headers.get("location");
await res.body?.cancel();
if (location === null) throw new Error("media redirect without Location");
({ url, sendAuth } = resolveRedirect(url, location, this.opts.homeserver, this.opts.mediaRedirectHosts));
continue;
}
if (!res.ok || res.body === null) {
const error = await this.toError(res);
// Missing/forbidden/too-large media will not appear on retry.
if (res.status === 404 || res.status === 403 || res.status === 413) throw new PermanentArchiveError(error.message);
throw error;
}
return { body: res.body, contentLength: parseContentLength(res.headers.get("content-length")) };
}
throw new PermanentArchiveError("too many media redirects");
}
private token(): string {
if (this.accessToken === null) throw new Error("matrix client not logged in");
return this.accessToken;
}
private async request(
method: string,
path: string,
opts: { signal: AbortSignal; auth?: boolean; json?: unknown },
): Promise<unknown> {
const headers: Record<string, string> = { accept: "application/json" };
if (opts.auth !== false) headers.authorization = `Bearer ${this.token()}`;
if (opts.json !== undefined) headers["content-type"] = "application/json";
const res = await fetch(new URL(path, this.opts.homeserver), {
method,
headers,
body: opts.json === undefined ? undefined : JSON.stringify(opts.json),
redirect: "error",
signal: opts.signal,
});
if (!res.ok) throw await this.toError(res);
return res.json() as Promise<unknown>;
}
private async toError(res: Response): Promise<MatrixHttpError> {
let errcode: string | null = null;
let message = `matrix HTTP ${res.status}`;
try {
const body = record(await res.json());
if (typeof body.errcode === "string") errcode = body.errcode;
if (typeof body.error === "string") message = `${message}: ${body.error}`;
} catch {
// Non-JSON error body; status is enough.
}
return new MatrixHttpError(res.status, errcode, message);
}
}

View file

@ -0,0 +1,27 @@
import { describe, expect, it } from "vitest";
import { sniffMime } from "./mime.js";
const bytes = (...parts: (number[] | string)[]): Uint8Array =>
Uint8Array.from(parts.flatMap((p) => (typeof p === "string" ? [...p].map((c) => c.charCodeAt(0)) : p)));
describe("sniffMime", () => {
it.each([
["png", bytes([0x89], "PNG", [0x0d, 0x0a, 0x1a, 0x0a, 0, 0]), "image/png", "image"],
["jpeg", bytes([0xff, 0xd8, 0xff, 0xe0]), "image/jpeg", "image"],
["gif87", bytes("GIF87a"), "image/gif", "image"],
["gif89", bytes("GIF89a"), "image/gif", "image"],
["pdf", bytes("%PDF-1.7"), "application/pdf", "document"],
["zip", bytes("PK", [3, 4, 20, 0]), "application/zip", "archive"],
["mp4", bytes([0, 0, 0, 0x18], "ftypisom"), "video/mp4", "video"],
["webm", bytes([0x1a, 0x45, 0xdf, 0xa3, 0x9f]), "video/webm", "video"],
["ogg", bytes("OggS", [0, 2]), "audio/ogg", "audio"],
])("%s", (_name, head, mime, mimeClass) => {
expect(sniffMime(head)).toMatchObject({ mime, mimeClass });
});
it("ignores declared types and falls back to octet-stream", () => {
expect(sniffMime(bytes("<html>"))).toEqual({ mime: "application/octet-stream", mimeClass: "other", extension: null });
expect(sniffMime(new Uint8Array())).toMatchObject({ mimeClass: "other" });
expect(sniffMime(bytes([0x89], "PN"))).toMatchObject({ mimeClass: "other" });
});
});

View file

@ -0,0 +1,43 @@
export type MimeClass = "image" | "video" | "audio" | "document" | "archive" | "other";
export interface SniffedMime {
mime: string;
mimeClass: MimeClass;
extension: string | null;
}
/** Bytes needed to recognise every supported signature. */
export const SNIFF_BYTES = 16;
const UNKNOWN: SniffedMime = { mime: "application/octet-stream", mimeClass: "other", extension: null };
function startsWith(head: Uint8Array, signature: readonly number[], offset = 0): boolean {
if (head.length < offset + signature.length) return false;
return signature.every((byte, i) => head[offset + i] === byte);
}
function ascii(text: string): number[] {
return [...text].map((c) => c.charCodeAt(0));
}
/**
* MIME is decided by magic bytes only; the sender's declared `info.mimetype`
* is never trusted. Unrecognised payloads are stored as octet-stream/other.
*/
export function sniffMime(head: Uint8Array): SniffedMime {
if (startsWith(head, [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])) {
return { mime: "image/png", mimeClass: "image", extension: "png" };
}
if (startsWith(head, [0xff, 0xd8, 0xff])) return { mime: "image/jpeg", mimeClass: "image", extension: "jpg" };
if (startsWith(head, ascii("GIF87a")) || startsWith(head, ascii("GIF89a"))) {
return { mime: "image/gif", mimeClass: "image", extension: "gif" };
}
if (startsWith(head, ascii("%PDF-"))) return { mime: "application/pdf", mimeClass: "document", extension: "pdf" };
if (startsWith(head, [0x50, 0x4b, 0x03, 0x04])) {
return { mime: "application/zip", mimeClass: "archive", extension: "zip" };
}
if (startsWith(head, ascii("ftyp"), 4)) return { mime: "video/mp4", mimeClass: "video", extension: "mp4" };
if (startsWith(head, [0x1a, 0x45, 0xdf, 0xa3])) return { mime: "video/webm", mimeClass: "video", extension: "webm" };
if (startsWith(head, ascii("OggS"))) return { mime: "audio/ogg", mimeClass: "audio", extension: "ogg" };
return UNKNOWN;
}

View file

@ -0,0 +1,40 @@
import { describe, expect, it } from "vitest";
import { MAX_SAFE_NAME_BYTES, safeFileName } from "./names.js";
describe("safeFileName", () => {
it("keeps ordinary names", () => {
expect(safeFileName("Meeting notes.pdf")).toBe("Meeting notes.pdf");
});
it("strips directories and traversal", () => {
expect(safeFileName("../../etc/passwd")).toBe("passwd");
expect(safeFileName("C:\\Users\\x\\evil.exe")).toBe("evil.exe");
expect(safeFileName("..")).toBe("attachment");
});
it("removes control/bidi characters and reserved punctuation", () => {
expect(safeFileName("a\u0000b\u202ecod.exe")).toBe("abcod.exe");
expect(safeFileName('we<ir>d:"na|me?*.txt')).toBe("we_ir_d_na_me_.txt");
expect(safeFileName(" .hidden ")).toBe("hidden");
});
it("NFKC-normalises and collapses whitespace", () => {
expect(safeFileName("full \t width.PNG")).toBe("full width.png");
});
it("guards Windows device names", () => {
expect(safeFileName("CON.txt")).toBe("_CON.txt");
});
it("appends the sniffed extension only when none exists", () => {
expect(safeFileName("photo", "jpg")).toBe("photo.jpg");
expect(safeFileName("photo.jpeg", "jpg")).toBe("photo.jpeg");
expect(safeFileName("", "pdf")).toBe("attachment.pdf");
});
it("bounds length in UTF-8 bytes while preserving the extension", () => {
const name = safeFileName(`${"ü".repeat(200)}.pdf`);
expect(Buffer.byteLength(name, "utf8")).toBeLessThanOrEqual(MAX_SAFE_NAME_BYTES);
expect(name.endsWith(".pdf")).toBe(true);
});
});

View file

@ -0,0 +1,44 @@
export const MAX_SAFE_NAME_BYTES = 120;
const FALLBACK = "attachment";
const WINDOWS_RESERVED = /^(con|prn|aux|nul|com[0-9]|lpt[0-9])(\..*)?$/i;
function truncateUtf8(text: string, maxBytes: number): string {
let out = "";
for (const ch of text) {
if (Buffer.byteLength(out + ch, "utf8") > maxBytes) break;
out += ch;
}
return out;
}
/**
* Library-safe single path segment: no directories, control or reserved
* characters, leading dots, or unbounded length. Extension is preserved when
* truncating; a sniffed extension is appended when the name has none.
*/
export function safeFileName(raw: string, sniffedExtension: string | null = null): string {
const basename = raw.normalize("NFKC").split(/[/\\]/).pop() ?? "";
let name = basename
.replace(/[\p{Cc}\p{Cf}]/gu, "")
.replace(/[<>:"|?*#%{}^~[\]`]/g, "_")
.replace(/\s+/g, " ")
.replace(/_+/g, "_")
.trim()
.replace(/^[.\s_-]+/, "")
.replace(/[.\s]+$/, "");
if (name === "") name = FALLBACK;
if (WINDOWS_RESERVED.test(name)) name = `_${name}`;
const dot = name.lastIndexOf(".");
let stem = dot > 0 ? name.slice(0, dot) : name;
let ext = dot > 0 ? name.slice(dot + 1).toLowerCase() : "";
if (ext === "" && sniffedExtension !== null) ext = sniffedExtension;
if (Buffer.byteLength(ext, "utf8") > 16) {
stem = name;
ext = sniffedExtension ?? "";
}
const suffix = ext === "" ? "" : `.${ext}`;
stem = truncateUtf8(stem, MAX_SAFE_NAME_BYTES - Buffer.byteLength(suffix, "utf8")).trim();
if (stem === "") stem = FALLBACK;
return `${stem}${suffix}`;
}

View file

@ -0,0 +1,267 @@
import pino from "pino";
import { beforeEach, describe, expect, it } from "vitest";
import { Archiver } from "./archiver.js";
import { BACKOFF_BASE_MS } from "./backoff.js";
import type { RoomMapping } from "./config.js";
import type { MatrixEvent, RoomEncryption } from "./events.js";
import { claimKey, replyTxnId } from "./keys.js";
import type { MatrixPort, MediaDownload, SyncResult } from "./matrix.js";
import { ArchiveProcessor } from "./processor.js";
import type { ArchiveRecord, ArchiveStore, OutboxRow } from "./store.js";
import type { LibraryPort, LibraryResource } from "./webdav.js";
const mapping: RoomMapping = {
roomId: "!room:example.org",
workspaceId: "11111111-1111-4111-8111-111111111111",
groupId: "22222222-2222-4222-8222-222222222222",
};
const BOT = "@archive:example.org";
const PNG = Uint8Array.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 1, 2, 3]);
function imageEvent(eventId: string, extra: Partial<MatrixEvent> = {}): MatrixEvent {
return {
type: "m.room.message",
event_id: eventId,
sender: "@alice:example.org",
origin_server_ts: 1_700_000_000_000,
content: { msgtype: "m.image", body: "../cat", url: `mxc://example.org/${eventId.slice(1)}` },
...extra,
};
}
class FakeMatrix implements MatrixPort {
readonly userId = BOT;
batches: SyncResult[] = [];
syncedWith: (string | null)[] = [];
encryption: RoomEncryption = "unencrypted";
notices: { roomId: string; txnId: string; content: Record<string, unknown> }[] = [];
failNotices = 0;
joined: string[] = [];
async sync(since: string | null): Promise<SyncResult> {
this.syncedWith.push(since);
const next = this.batches.shift();
if (next === undefined) throw new Error("no batch queued");
return next;
}
async backfill(): Promise<MatrixEvent[]> {
return [];
}
async roomEncryption(): Promise<RoomEncryption> {
return this.encryption;
}
async joinRoom(roomId: string): Promise<void> {
this.joined.push(roomId);
}
async downloadMedia(): Promise<MediaDownload> {
return {
body: new ReadableStream({ start: (c) => { c.enqueue(PNG); c.close(); } }),
contentLength: PNG.byteLength,
};
}
async sendNotice(roomId: string, txnId: string, content: Record<string, unknown>): Promise<void> {
if (this.failNotices > 0) {
this.failNotices -= 1;
throw new Error("notice failed");
}
this.notices.push({ roomId, txnId, content });
}
}
class FakeLibrary implements LibraryPort {
failPuts = 0;
committed: string[] = [];
async putStaging(_dir: string[], _name: string, body: ReadableStream<Uint8Array>): Promise<void> {
for await (const chunk of body) void chunk;
if (this.failPuts > 0) {
this.failPuts -= 1;
throw new Error("WebDAV PUT failed: 507");
}
}
async commit(dir: string[], _staging: string, finalName: string): Promise<LibraryResource> {
const path = [...dir, finalName].join("/");
this.committed.push(path);
return { path, url: `https://files/${path}`, resourceId: "rid", version: "v1" };
}
}
class FakeStore implements ArchiveStore {
cursor: string | null = null;
outbox = new Map<string, OutboxRow & { claimKey: string }>();
archive: (ArchiveRecord & { available: boolean })[] = [];
async loadCursor(): Promise<string | null> {
return this.cursor;
}
async saveCursor(_ws: readonly string[], cursor: string): Promise<void> {
this.cursor = cursor;
}
async claim(_ws: string, _room: string, _event: string, key: string): Promise<OutboxRow> {
const existing = this.outbox.get(key);
if (existing !== undefined) return { ...existing };
const row = { id: `ob${this.outbox.size + 1}`, status: "pending" as const, attempts: 0, lastError: null, updatedAt: new Date(0), claimKey: key };
this.outbox.set(key, row);
return { ...row };
}
async commitArchived(record: ArchiveRecord, outboxId: string): Promise<{ id: string; indexedAt: Date }> {
this.archive.push({ ...record, available: true });
this.byId(outboxId).status = "done";
return { id: `file-${this.archive.length}`, indexedAt: new Date(0) };
}
async recordFailure(_ws: string, outboxId: string, error: string, terminal: boolean): Promise<OutboxRow> {
const row = this.byId(outboxId);
row.attempts += 1;
row.lastError = error;
row.status = terminal ? "failed" : "pending";
row.updatedAt = clock.now;
return { ...row };
}
async markRedacted(_ws: string, _room: string, eventId: string, key: string): Promise<number> {
const rows = this.archive.filter((r) => r.eventId === eventId && r.available);
for (const r of rows) r.available = false;
const claim = this.outbox.get(key);
if (claim?.status === "pending") claim.status = "failed";
return rows.length;
}
async countPending(): Promise<number> {
return [...this.outbox.values()].filter((r) => r.status === "pending").length;
}
private byId(id: string): OutboxRow {
const row = [...this.outbox.values()].find((r) => r.id === id);
if (row === undefined) throw new Error("no row");
return row;
}
}
const clock = { now: new Date(1_000_000) };
function batch(nextBatch: string, events: MatrixEvent[], extra: { stateEvents?: MatrixEvent[]; invited?: string[] } = {}): SyncResult {
return {
nextBatch,
invited: extra.invited ?? [],
joined: new Map([[mapping.roomId, { timeline: { events, limited: false, prevBatch: null }, stateEvents: extra.stateEvents ?? [] }]]),
};
}
describe("ArchiveProcessor", () => {
let matrix: FakeMatrix;
let library: FakeLibrary;
let store: FakeStore;
let processor: ArchiveProcessor;
const signal = new AbortController().signal;
beforeEach(async () => {
clock.now = new Date(1_000_000);
matrix = new FakeMatrix();
library = new FakeLibrary();
store = new FakeStore();
processor = new ArchiveProcessor(
matrix, store, new Archiver(matrix, library, store), [mapping], pino({ level: "silent" }), () => clock.now,
);
await processor.init();
});
it("archives, indexes, replies once with the stable txn id, then advances the cursor", async () => {
matrix.batches.push(batch("s1", [imageEvent("$a")]));
expect(await processor.runBatch(signal)).toEqual({ advanced: true, retryAt: null });
expect(store.cursor).toBe("s1");
expect(store.archive).toHaveLength(1);
const row = store.archive[0];
expect(row).toMatchObject({ mime: "image/png", mimeClass: "image", safeName: "cat.png", originalName: "../cat", byteSize: PNG.byteLength });
expect(library.committed[0]).toBe(`Library/${mapping.workspaceId}/${mapping.groupId}/${row?.sha256}-cat.png`);
const key = claimKey(mapping.workspaceId, mapping.roomId, "$a");
expect(matrix.notices).toEqual([{
roomId: mapping.roomId,
txnId: replyTxnId(key),
content: expect.objectContaining({ body: "Archived to the group library: cat.png" }) as unknown,
}]);
// Replaying the same event (e.g. after a crash before cursor save) neither re-archives nor re-replies.
matrix.batches.push(batch("s2", [imageEvent("$a")]));
await processor.runBatch(signal);
expect(store.archive).toHaveLength(1);
expect(matrix.notices).toHaveLength(1);
});
it("holds the cursor on a WebDAV failure, records attempts, and retries after backoff", async () => {
library.failPuts = 1;
matrix.batches.push(batch("s1", [imageEvent("$a")]));
const first = await processor.runBatch(signal);
expect(first.advanced).toBe(false);
expect(first.retryAt?.getTime()).toBe(clock.now.getTime() + BACKOFF_BASE_MS);
expect(store.cursor).toBeNull();
const row = [...store.outbox.values()][0];
expect(row).toMatchObject({ status: "pending", attempts: 1, lastError: "WebDAV PUT failed: 507" });
// Re-sync before the row is due: still blocked, nothing uploaded.
matrix.batches.push(batch("s1b", [imageEvent("$a")]));
expect((await processor.runBatch(signal)).advanced).toBe(false);
expect(store.archive).toHaveLength(0);
expect(matrix.syncedWith).toEqual([null, null]);
clock.now = new Date(clock.now.getTime() + BACKOFF_BASE_MS);
matrix.batches.push(batch("s1c", [imageEvent("$a")]));
expect((await processor.runBatch(signal)).advanced).toBe(true);
expect(store.cursor).toBe("s1c");
expect(store.archive).toHaveLength(1);
});
it("retries a failed reply without re-archiving", async () => {
matrix.failNotices = 1;
matrix.batches.push(batch("s1", [imageEvent("$a")]), batch("s2", [imageEvent("$a")]));
expect((await processor.runBatch(signal)).advanced).toBe(false);
expect(store.archive).toHaveLength(1);
expect((await processor.runBatch(signal)).advanced).toBe(true);
expect(matrix.notices).toHaveLength(1);
expect(store.archive).toHaveLength(1);
});
it("marks permanent payload errors failed and lets the cursor advance", async () => {
matrix.downloadMedia = async () => ({
body: new ReadableStream({ start: (c) => { c.enqueue(PNG); c.close(); } }),
contentLength: PNG.byteLength + 10,
});
matrix.batches.push(batch("s1", [imageEvent("$a")]));
expect((await processor.runBatch(signal)).advanced).toBe(true);
expect([...store.outbox.values()][0]).toMatchObject({ status: "failed", attempts: 1 });
expect(matrix.notices).toHaveLength(0);
});
it("never mirrors when the room is encrypted or its state is unknown", async () => {
matrix.encryption = "unknown";
matrix.batches.push(batch("s1", [imageEvent("$a")]));
await processor.runBatch(signal);
matrix.encryption = "unencrypted";
const encryptionEvent: MatrixEvent = {
type: "m.room.encryption", event_id: "$enc", sender: "@admin:x", state_key: "", content: { algorithm: "m.megolm.v1.aes-sha2" },
};
matrix.batches.push(batch("s2", [imageEvent("$b"), encryptionEvent]));
await processor.runBatch(signal);
expect(store.outbox.size).toBe(0);
expect(store.archive).toHaveLength(0);
expect(store.cursor).toBe("s2");
});
it("ignores its own events", async () => {
matrix.batches.push(batch("s1", [imageEvent("$a", { sender: BOT })]));
await processor.runBatch(signal);
expect(store.outbox.size).toBe(0);
});
it("marks redacted attachments unavailable without touching the library", async () => {
matrix.batches.push(batch("s1", [imageEvent("$a")]));
await processor.runBatch(signal);
const redaction: MatrixEvent = { type: "m.room.redaction", event_id: "$r", sender: "@mod:x", content: { redacts: "$a" } };
matrix.batches.push(batch("s2", [redaction]));
await processor.runBatch(signal);
expect(store.archive[0]?.available).toBe(false);
expect(library.committed).toHaveLength(1);
});
it("joins invites only for mapped rooms and reports health", async () => {
matrix.batches.push({ nextBatch: "s1", joined: new Map(), invited: [mapping.roomId, "!other:x"] });
await processor.runBatch(signal);
expect(matrix.joined).toEqual([mapping.roomId]);
expect(await processor.health()).toEqual({ rooms: 1, cursor: "s1", pending: 0, lastProcessedAt: null });
});
});

View file

@ -0,0 +1,235 @@
import type { Logger } from "pino";
import type { Archiver } from "./archiver.js";
import { backoffMs, BACKOFF_MAX_MS, MAX_ATTEMPTS, nextAttemptAt } from "./backoff.js";
import type { RoomMapping } from "./config.js";
import { classifyEvent, type AttachmentWork, type MatrixEvent, type RoomEncryption } from "./events.js";
import { PermanentArchiveError } from "./inspector.js";
import { claimKey, replyTxnId } from "./keys.js";
import { MatrixHttpError, type MatrixPort } from "./matrix.js";
import type { ArchiveStore } from "./store.js";
export interface BatchResult {
/** True when the sync cursor was durably advanced past this batch. */
advanced: boolean;
/** Earliest time a blocked row may be retried; null when nothing is blocked. */
retryAt: Date | null;
}
export interface HealthSnapshot {
rooms: number;
cursor: string | null;
pending: number;
lastProcessedAt: string | null;
}
type Outcome = { blockedUntil: Date } | null;
export function replyContent(safeName: string, eventId: string): Record<string, unknown> {
return {
msgtype: "m.notice",
body: `Archived to the group library: ${safeName}`,
"m.relates_to": { "m.in_reply_to": { event_id: eventId } },
};
}
function errorText(err: unknown): string {
return err instanceof Error ? err.message : String(err);
}
function abortableSleep(ms: number, signal: AbortSignal): Promise<void> {
return new Promise((resolve) => {
if (signal.aborted) return resolve();
const timer = setTimeout(done, ms);
function done(): void {
clearTimeout(timer);
signal.removeEventListener("abort", done);
resolve();
}
signal.addEventListener("abort", done, { once: true });
});
}
/**
* Sync-loop orchestration. The cursor is persisted only after every event in
* the batch has reached a durable terminal state (done/failed/skipped) and its
* reply was sent; otherwise the same `since` is re-synced after the earliest
* per-row backoff, so no attachment is lost to a transient failure.
*/
export class ArchiveProcessor {
private cursor: string | null = null;
private lastProcessedAt: Date | null = null;
/** Committed claims whose single reply has not been delivered yet (key → safe name). */
private readonly unsentReplies = new Map<string, string>();
private readonly byRoom: Map<string, RoomMapping>;
private readonly workspaceIds: string[];
constructor(
private readonly matrix: MatrixPort,
private readonly store: ArchiveStore,
private readonly archiver: Archiver,
mappings: readonly RoomMapping[],
private readonly logger: Logger,
private readonly now: () => Date = () => new Date(),
) {
this.byRoom = new Map(mappings.map((m) => [m.roomId, m]));
this.workspaceIds = [...new Set(mappings.map((m) => m.workspaceId))];
}
async init(): Promise<void> {
this.cursor = await this.store.loadCursor(this.workspaceIds);
}
async health(): Promise<HealthSnapshot> {
return {
rooms: this.byRoom.size,
cursor: this.cursor,
pending: await this.store.countPending(this.workspaceIds),
lastProcessedAt: this.lastProcessedAt?.toISOString() ?? null,
};
}
async runBatch(signal: AbortSignal): Promise<BatchResult> {
const since = this.cursor;
const sync = await this.matrix.sync(since, signal);
for (const roomId of sync.invited) {
if (!this.byRoom.has(roomId)) continue;
await this.matrix.joinRoom(roomId, signal);
this.logger.info({ roomId }, "archive-bot: joined mapped room");
}
let retryAt: Date | null = null;
for (const [roomId, room] of sync.joined) {
const mapping = this.byRoom.get(roomId);
if (mapping === undefined) continue;
let events = room.timeline.events;
if (room.timeline.limited && room.timeline.prevBatch !== null && since !== null) {
events = [...(await this.matrix.backfill(roomId, room.timeline.prevBatch, since, signal)), ...events];
}
if (events.length === 0) continue;
const encryption = await this.encryptionState(mapping, [...room.stateEvents, ...events], signal);
for (const event of events) {
const outcome = await this.handleEvent(mapping, event, encryption, signal);
if (outcome !== null && (retryAt === null || outcome.blockedUntil < retryAt)) retryAt = outcome.blockedUntil;
}
}
if (retryAt !== null) return { advanced: false, retryAt };
await this.store.saveCursor(this.workspaceIds, sync.nextBatch);
this.cursor = sync.nextBatch;
return { advanced: true, retryAt: null };
}
/** Long-running loop; returns when `signal` aborts. */
async run(signal: AbortSignal, relogin: (signal: AbortSignal) => Promise<void>): Promise<void> {
let consecutiveErrors = 0;
while (!signal.aborted) {
try {
const result = await this.runBatch(signal);
consecutiveErrors = 0;
if (result.retryAt !== null) {
const wait = Math.min(Math.max(result.retryAt.getTime() - this.now().getTime(), 1000), BACKOFF_MAX_MS);
this.logger.info({ waitMs: wait }, "archive-bot: batch has pending rows; holding cursor");
await abortableSleep(wait, signal);
}
} catch (err) {
if (signal.aborted) break;
consecutiveErrors += 1;
if (err instanceof MatrixHttpError && err.status === 401) {
this.logger.warn("archive-bot: access token rejected; logging in again");
await relogin(signal).catch((loginErr: unknown) => {
this.logger.error({ err: errorText(loginErr) }, "archive-bot: re-login failed");
});
} else {
this.logger.error({ err: errorText(err) }, "archive-bot: batch failed; cursor held");
}
await abortableSleep(backoffMs(consecutiveErrors), signal);
}
}
}
/** Fail closed: an encryption event anywhere in this batch wins; otherwise ask the room state. */
private async encryptionState(mapping: RoomMapping, events: MatrixEvent[], signal: AbortSignal): Promise<RoomEncryption> {
const sawEncryption = events.some((e) => e.type === "m.room.encryption" && e.state_key === "");
const state = sawEncryption ? "encrypted" : await this.matrix.roomEncryption(mapping.roomId, signal);
if (state !== "unencrypted") {
this.logger.warn({ roomId: mapping.roomId, state }, "archive-bot: room not verifiably unencrypted; not mirroring");
}
return state;
}
private async handleEvent(
mapping: RoomMapping,
event: MatrixEvent,
encryption: RoomEncryption,
signal: AbortSignal,
): Promise<Outcome> {
const classified = classifyEvent(event, mapping, encryption, this.matrix.userId);
switch (classified.kind) {
case "skip":
if (classified.reason !== "own-event" && classified.reason !== "not-an-attachment" && classified.reason !== "not-a-message") {
this.logger.info({ roomId: mapping.roomId, eventId: event.event_id, reason: classified.reason }, "archive-bot: skipped");
}
return null;
case "redaction": {
const key = claimKey(mapping.workspaceId, mapping.roomId, classified.redacts);
const affected = await this.store.markRedacted(mapping.workspaceId, mapping.roomId, classified.redacts, key);
this.unsentReplies.delete(key);
if (affected > 0) {
this.logger.info({ roomId: mapping.roomId, eventId: classified.redacts }, "archive-bot: redacted; index marked unavailable");
}
this.lastProcessedAt = this.now();
return null;
}
case "attachment":
return this.handleAttachment(mapping, classified, signal);
}
}
private async handleAttachment(mapping: RoomMapping, work: AttachmentWork, signal: AbortSignal): Promise<Outcome> {
const eventId = work.event.event_id;
const key = claimKey(mapping.workspaceId, mapping.roomId, eventId);
const row = await this.store.claim(mapping.workspaceId, mapping.roomId, eventId, key);
const log = { roomId: mapping.roomId, eventId, outboxId: row.id };
if (row.status === "failed") return null;
if (row.status === "done") return this.sendReply(mapping, eventId, key, signal);
const due = nextAttemptAt(row.attempts, row.updatedAt);
if (row.attempts > 0 && due > this.now()) return { blockedUntil: due };
try {
const archived = await this.archiver.archive(mapping, work, row, key, signal);
this.logger.info({ ...log, fileId: archived.file.id, sha256: archived.file.checksum.value }, "archive-bot: archived");
this.lastProcessedAt = this.now();
this.unsentReplies.set(key, archived.safeName);
return this.sendReply(mapping, eventId, key, signal);
} catch (err) {
if (signal.aborted) throw err;
const terminal = err instanceof PermanentArchiveError || row.attempts + 1 >= MAX_ATTEMPTS;
const updated = await this.store.recordFailure(mapping.workspaceId, row.id, errorText(err), terminal);
if (terminal) {
this.logger.error({ ...log, err: errorText(err), attempts: updated.attempts }, "archive-bot: archive failed permanently");
return null;
}
const retry = nextAttemptAt(updated.attempts, updated.updatedAt);
this.logger.warn({ ...log, err: errorText(err), attempts: updated.attempts, retryAt: retry.toISOString() }, "archive-bot: archive failed; will retry");
return { blockedUntil: retry };
}
}
/** Exactly one reply per committed claim; the stable txn ID makes resends idempotent server-side. */
private async sendReply(mapping: RoomMapping, eventId: string, key: string, signal: AbortSignal): Promise<Outcome> {
const safeName = this.unsentReplies.get(key);
if (safeName === undefined) return null;
try {
await this.matrix.sendNotice(mapping.roomId, replyTxnId(key), replyContent(safeName, eventId), signal);
this.unsentReplies.delete(key);
return null;
} catch (err) {
if (signal.aborted) throw err;
this.logger.warn({ roomId: mapping.roomId, eventId, err: errorText(err) }, "archive-bot: reply failed; will retry");
return { blockedUntil: new Date(this.now().getTime() + backoffMs(1)) };
}
}
}

View file

@ -0,0 +1,223 @@
import { Pool, type PoolClient } from "pg";
export type OutboxStatus = "pending" | "done" | "failed";
export interface OutboxRow {
id: string;
status: OutboxStatus;
attempts: number;
lastError: string | null;
updatedAt: Date;
}
export interface ArchiveRecord {
workspaceId: string;
groupId: string;
roomId: string;
eventId: string;
senderMxid: string;
matrixEventJson: unknown;
originalName: string;
safeName: string;
mime: string;
mimeClass: string;
byteSize: number;
sha256: string;
ocisResourceId: string;
ocisResourceVersion: string;
createdAt: Date;
}
/** Durable state the processor depends on; faked in tests. */
export interface ArchiveStore {
loadCursor(workspaceIds: readonly string[]): Promise<string | null>;
saveCursor(workspaceIds: readonly string[], cursor: string): Promise<void>;
claim(workspaceId: string, roomId: string, eventId: string, claimKey: string): Promise<OutboxRow>;
/** Index row + outbox done in one transaction. Returns the archive row id and indexed time. */
commitArchived(record: ArchiveRecord, outboxId: string): Promise<{ id: string; indexedAt: Date }>;
recordFailure(workspaceId: string, outboxId: string, error: string, terminal: boolean): Promise<OutboxRow>;
/** Mark index rows unavailable and stop any not-yet-archived claim. Returns rows affected. */
markRedacted(workspaceId: string, roomId: string, eventId: string, claimKey: string): Promise<number>;
countPending(workspaceIds: readonly string[]): Promise<number>;
}
interface OutboxDbRow {
id: string;
status: OutboxStatus;
attempts: number;
last_error: string | null;
updated_at: Date;
}
function toOutbox(row: OutboxDbRow): OutboxRow {
return { id: row.id, status: row.status, attempts: row.attempts, lastError: row.last_error, updatedAt: row.updated_at };
}
const OUTBOX_COLUMNS = "id, status, attempts, last_error, updated_at";
const LAST_ERROR_MAX = 1000;
export class PgArchiveStore implements ArchiveStore {
constructor(
private readonly pool: Pool,
private readonly cursorId: string,
) {}
/** Single service connection; tenant context per transaction, like apps/api withTenant. */
static create(databaseUrl: string, cursorId: string): PgArchiveStore {
return new PgArchiveStore(new Pool({ connectionString: databaseUrl, max: 1 }), cursorId);
}
async close(): Promise<void> {
await this.pool.end();
}
/**
* The sync stream is global but rows are tenant-scoped, so each mapped
* workspace holds a copy. A partial write leaves copies disagreeing: resume
* from the least recently written (oldest) one — replay is idempotent.
*/
async loadCursor(workspaceIds: readonly string[]): Promise<string | null> {
let oldest: { cursor: string; at: Date } | null = null;
for (const workspaceId of workspaceIds) {
const row = await this.withTenant(workspaceId, async (c) => {
const res = await c.query<{ sync_cursor: string | null; updated_at: Date }>(
"SELECT sync_cursor, updated_at FROM archive_sync_state WHERE id = $1 AND workspace_id = $2",
[this.stateId(workspaceId), workspaceId],
);
return res.rows[0];
});
if (row?.sync_cursor == null) continue;
if (oldest === null || row.updated_at < oldest.at) oldest = { cursor: row.sync_cursor, at: row.updated_at };
}
return oldest?.cursor ?? null;
}
async saveCursor(workspaceIds: readonly string[], cursor: string): Promise<void> {
for (const workspaceId of workspaceIds) {
await this.withTenant(workspaceId, (c) => c.query(
`INSERT INTO archive_sync_state (id, workspace_id, sync_cursor, updated_at)
VALUES ($1, $2, $3, now())
ON CONFLICT (id) DO UPDATE SET sync_cursor = EXCLUDED.sync_cursor, updated_at = now()`,
[this.stateId(workspaceId), workspaceId, cursor],
));
}
}
async claim(workspaceId: string, roomId: string, eventId: string, claimKey: string): Promise<OutboxRow> {
return this.withTenant(workspaceId, async (c) => {
await c.query(
`INSERT INTO archive_sync_outbox (workspace_id, room_id, event_id, claim_key)
SELECT $1, $2, $3, $4
WHERE NOT EXISTS (SELECT 1 FROM archive_sync_outbox WHERE workspace_id = $1 AND claim_key = $4)
ON CONFLICT DO NOTHING`,
[workspaceId, roomId, eventId, claimKey],
);
const res = await c.query<OutboxDbRow>(
`SELECT ${OUTBOX_COLUMNS} FROM archive_sync_outbox
WHERE workspace_id = $1 AND claim_key = $2 ORDER BY created_at LIMIT 1`,
[workspaceId, claimKey],
);
const row = res.rows[0];
if (row === undefined) throw new Error("outbox claim not visible after insert");
return toOutbox(row);
});
}
async commitArchived(record: ArchiveRecord, outboxId: string): Promise<{ id: string; indexedAt: Date }> {
return this.withTenant(record.workspaceId, async (c) => {
await c.query(
`INSERT INTO chat_media_archive (
workspace_id, group_id, room_id, event_id, origin, sender_mxid, matrix_event_json,
original_name, safe_name, mime, mime_class, byte_size, sha256,
ocis_resource_id, ocis_resource_version, created_at
) VALUES ($1, $2, $3, $4, 'matrix', $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15)
ON CONFLICT DO NOTHING`,
[
record.workspaceId, record.groupId, record.roomId, record.eventId, record.senderMxid,
JSON.stringify(record.matrixEventJson), record.originalName, record.safeName, record.mime,
record.mimeClass, record.byteSize, record.sha256, record.ocisResourceId,
record.ocisResourceVersion, record.createdAt,
],
);
const indexed = await c.query<{ id: string; indexed_at: Date }>(
`SELECT id, indexed_at FROM chat_media_archive
WHERE workspace_id = $1 AND room_id = $2 AND event_id = $3 LIMIT 1`,
[record.workspaceId, record.roomId, record.eventId],
);
const row = indexed.rows[0];
if (row === undefined) throw new Error("archive row not visible after insert");
await c.query(
`UPDATE archive_sync_outbox SET status = 'done', last_error = NULL, updated_at = now()
WHERE id = $1 AND workspace_id = $2`,
[outboxId, record.workspaceId],
);
return { id: row.id, indexedAt: row.indexed_at };
});
}
async recordFailure(workspaceId: string, outboxId: string, error: string, terminal: boolean): Promise<OutboxRow> {
return this.withTenant(workspaceId, async (c) => {
const res = await c.query<OutboxDbRow>(
`UPDATE archive_sync_outbox
SET attempts = attempts + 1, last_error = $3, updated_at = now(),
status = CASE WHEN $4 THEN 'failed' ELSE 'pending' END
WHERE id = $1 AND workspace_id = $2 AND status = 'pending'
RETURNING ${OUTBOX_COLUMNS}`,
[outboxId, workspaceId, error.slice(0, LAST_ERROR_MAX), terminal],
);
const row = res.rows[0];
if (row === undefined) throw new Error("outbox row not pending");
return toOutbox(row);
});
}
async markRedacted(workspaceId: string, roomId: string, eventId: string, claimKey: string): Promise<number> {
return this.withTenant(workspaceId, async (c) => {
const res = await c.query(
`UPDATE chat_media_archive SET available = false
WHERE workspace_id = $1 AND room_id = $2 AND event_id = $3 AND available`,
[workspaceId, roomId, eventId],
);
await c.query(
`UPDATE archive_sync_outbox SET status = 'failed', last_error = 'redacted', updated_at = now()
WHERE workspace_id = $1 AND claim_key = $2 AND status = 'pending'`,
[workspaceId, claimKey],
);
return res.rowCount ?? 0;
});
}
async countPending(workspaceIds: readonly string[]): Promise<number> {
let total = 0;
for (const workspaceId of workspaceIds) {
total += await this.withTenant(workspaceId, async (c) => {
const res = await c.query<{ n: string }>(
"SELECT count(*) AS n FROM archive_sync_outbox WHERE workspace_id = $1 AND status = 'pending'",
[workspaceId],
);
return Number(res.rows[0]?.n ?? 0);
});
}
return total;
}
private stateId(workspaceId: string): string {
return `${this.cursorId}:${workspaceId}`;
}
private async withTenant<T>(workspaceId: string, fn: (client: PoolClient) => Promise<T>): Promise<T> {
const client = await this.pool.connect();
try {
await client.query("BEGIN");
await client.query("SELECT set_config('app.workspace_id', $1, true)", [workspaceId]);
const result = await fn(client);
await client.query("COMMIT");
return result;
} catch (err) {
await client.query("ROLLBACK").catch(() => undefined);
throw err;
} finally {
client.release();
}
}
}

View file

@ -0,0 +1,21 @@
import { describe, expect, it } from "vitest";
import { davPath, parsePropfind } from "./webdav.js";
describe("davPath", () => {
it("encodes every segment under the service user's files root", () => {
expect(davPath("svc", ["Library", "ws", "grp", "abc-My file #1.pdf"]))
.toBe("/remote.php/dav/files/svc/Library/ws/grp/abc-My%20file%20%231.pdf");
});
});
describe("parsePropfind", () => {
it("reads oc:fileid and an unquoted etag", () => {
const xml = `<d:multistatus xmlns:d="DAV:" xmlns:oc="http://owncloud.org/ns"><d:response><d:propstat><d:prop>
<oc:fileid>space$abc!def</oc:fileid><d:getetag>&quot;9f1c&quot;</d:getetag></d:prop></d:propstat></d:response></d:multistatus>`;
expect(parsePropfind(xml)).toEqual({ resourceId: "space$abc!def", version: "9f1c" });
});
it("returns null when properties are missing", () => {
expect(parsePropfind("<d:multistatus/>")).toBeNull();
});
});

View file

@ -0,0 +1,116 @@
export interface LibraryResource {
path: string;
url: string;
resourceId: string;
version: string;
}
/** oCIS library surface; faked in tests. */
export interface LibraryPort {
/** Stream bytes to a deterministic staging resource (overwritten on retry). */
putStaging(dir: string[], stagingName: string, body: ReadableStream<Uint8Array>, signal: AbortSignal): Promise<void>;
/** Move staging to its content-addressed final name; an existing final resource is reused. */
commit(dir: string[], stagingName: string, finalName: string, signal: AbortSignal): Promise<LibraryResource>;
}
export class WebDavError extends Error {
constructor(
readonly status: number,
message: string,
) {
super(message);
}
}
const STAGING_DIR = ".archive-staging";
/** Percent-encode each segment; segments themselves are already normalised safe names/UUIDs. */
export function davPath(user: string, segments: readonly string[]): string {
return `/remote.php/dav/files/${[user, ...segments].map(encodeURIComponent).join("/")}`;
}
/** Extract `oc:fileid` and `d:getetag` from a Depth:0 PROPFIND multistatus body. */
export function parsePropfind(xml: string): { resourceId: string; version: string } | null {
const fileId = /<(?:[\w-]+:)?fileid>([^<]+)<\/(?:[\w-]+:)?fileid>/.exec(xml)?.[1];
const etag = /<(?:[\w-]+:)?getetag>([^<]+)<\/(?:[\w-]+:)?getetag>/.exec(xml)?.[1];
if (fileId === undefined || etag === undefined) return null;
return { resourceId: fileId.trim(), version: etag.trim().replace(/^"|"$/g, "").replace(/&quot;/g, "") };
}
const PROPFIND_BODY = `<?xml version="1.0" encoding="utf-8"?>
<d:propfind xmlns:d="DAV:" xmlns:oc="http://owncloud.org/ns"><d:prop><oc:fileid/><d:getetag/></d:prop></d:propfind>`;
/** WebDAV client for the scoped oCIS service identity. Credentials never leave the configured origin. */
export class OcisWebDav implements LibraryPort {
private readonly authorization: string;
private readonly knownDirs = new Set<string>();
constructor(
private readonly baseUrl: URL,
private readonly user: string,
password: string,
) {
this.authorization = `Basic ${Buffer.from(`${user}:${password}`, "utf8").toString("base64")}`;
}
async putStaging(dir: string[], stagingName: string, body: ReadableStream<Uint8Array>, signal: AbortSignal): Promise<void> {
await this.ensureDir([...dir, STAGING_DIR], signal);
const res = await this.send("PUT", [...dir, STAGING_DIR, stagingName], signal, {
body,
headers: { "content-type": "application/octet-stream" },
});
if (res.status !== 201 && res.status !== 204) throw new WebDavError(res.status, `WebDAV PUT failed: ${res.status}`);
}
async commit(dir: string[], stagingName: string, finalName: string, signal: AbortSignal): Promise<LibraryResource> {
const staging = [...dir, STAGING_DIR, stagingName];
const final = [...dir, finalName];
const destination = new URL(davPath(this.user, final), this.baseUrl).toString();
const moved = await this.send("MOVE", staging, signal, { headers: { destination, overwrite: "F" } });
if (moved.status === 412) {
// Same sha256 + name already archived (crash after MOVE, or a duplicate post): reuse it.
const removed = await this.send("DELETE", staging, signal);
if (!removed.ok && removed.status !== 404) throw new WebDavError(removed.status, `WebDAV DELETE failed: ${removed.status}`);
} else if (moved.status !== 201 && moved.status !== 204) {
throw new WebDavError(moved.status, `WebDAV MOVE failed: ${moved.status}`);
}
const found = await this.send("PROPFIND", final, signal, {
body: PROPFIND_BODY,
headers: { depth: "0", "content-type": "application/xml" },
});
if (found.status !== 207) throw new WebDavError(found.status, `WebDAV PROPFIND failed: ${found.status}`);
const props = parsePropfind(await found.text());
if (props === null) throw new WebDavError(207, "WebDAV PROPFIND missing fileid/etag");
return { path: final.join("/"), url: destination, ...props };
}
private async ensureDir(segments: string[], signal: AbortSignal): Promise<void> {
for (let depth = 1; depth <= segments.length; depth += 1) {
const dir = segments.slice(0, depth);
const key = dir.join("/");
if (this.knownDirs.has(key)) continue;
const res = await this.send("MKCOL", dir, signal);
// 405: already exists.
if (res.status !== 201 && res.status !== 405) throw new WebDavError(res.status, `WebDAV MKCOL failed: ${res.status}`);
this.knownDirs.add(key);
}
}
private async send(
method: string,
segments: string[],
signal: AbortSignal,
init: { body?: ReadableStream<Uint8Array> | string; headers?: Record<string, string> } = {},
): Promise<Response> {
const res = await fetch(new URL(davPath(this.user, segments), this.baseUrl), {
method,
headers: { authorization: this.authorization, ...init.headers },
body: init.body,
duplex: init.body instanceof ReadableStream ? "half" : undefined,
redirect: "error",
signal,
});
if (method !== "PROPFIND") await res.body?.cancel();
return res;
}
}

View file

@ -0,0 +1,4 @@
{
"extends": "./tsconfig.json",
"exclude": ["src/**/*.test.ts"]
}

View file

@ -0,0 +1,11 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"outDir": "dist",
"rootDir": "src",
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext"
},
"include": ["src"]
}

View file

@ -0,0 +1,7 @@
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
include: ["src/**/*.test.ts"],
},
});

View file

@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 1200 1200">
<title>Nav Files</title>
<path d="M 220 380 L 470 380 L 560 470 L 980 470 L 980 860 A 60 60 0 0 1 920 920 L 280 920 A 60 60 0 0 1 220 860 Z" fill="none" stroke="#FF1610" stroke-width="60" stroke-linejoin="round" stroke-linecap="round"/>
</svg>

After

Width:  |  Height:  |  Size: 336 B

View file

@ -0,0 +1,13 @@
"use client";
import type { ReactNode } from "react";
import { FilesLangProvider } from "../../../components/files/i18n";
/**
* Files shell. Like the wiki, the language lives in localStorage per browser
* (lang-context explains why it is not an account setting) and is provided here
* so every Files screen and sub-component reads it through one context.
*/
export default function FilesLayout({ children }: { children: ReactNode }): React.ReactElement {
return <FilesLangProvider>{children}</FilesLangProvider>;
}

View file

@ -0,0 +1,383 @@
"use client";
import { useCallback, useEffect, useState } from "react";
import { Button, Input } from "../../../components/ui/primitives";
import { OfflineBadge } from "../../../components/ui/offline-badge";
import { EmptyState } from "../../../components/brand/empty-state";
import { Loading } from "../../../components/brand/loading";
import { api, ApiUnreachableError, errorMessage } from "../../../lib/api-client";
import { useAuth } from "../../../lib/auth-store";
import { useApiResource } from "../../../lib/use-api";
import { FileRow } from "../../../components/files/file-row";
import { UploadDropzone } from "../../../components/files/upload-dropzone";
import { ShareDialog } from "../../../components/files/share-dialog";
import { RetentionDialog } from "../../../components/files/retention-dialog";
import { downloadGroupFile } from "../../../components/files/files-client";
import { useFilesLang } from "../../../components/files/i18n";
import { MIME_CLASS_LABEL, ORIGIN_LABEL } from "../../../components/files/labels";
import type {
FileOrigin,
GroupFileItem,
GroupFilePage,
GroupSummary,
MimeClass,
RetentionInput,
} from "../../../components/files/types";
/**
* Group file library — the per-group "group cloud". Live mode pages through
* GET /files/groups/:groupId/items by cursor (video library's load-more
* pattern) with a debounced server-side `q` and mimeClass/origin filters.
* Offline the screen renders sample data read-only per useApiResource rules.
*/
const PAGE_LIMIT = 50;
const SAMPLE_GROUPS: GroupSummary[] = [{ id: "sample-group", name: "Collective media" }];
const SAMPLE_FILES: GroupFileItem[] = [
{
id: "sample-1", groupId: "sample-group", origin: "matrix", senderMxid: "@ada:collective.example",
originalName: "press-kit.zip", safeName: "press-kit.zip", mime: "application/zip", mimeClass: "other",
byteSize: 4_812_441, sha256: "a".repeat(64), createdAt: "2026-10-08T09:12:00.000Z", available: true,
retentionMode: "keep", retentionUntil: null,
},
{
id: "sample-2", groupId: "sample-group", origin: "upload", senderMxid: "@you:collective.example",
originalName: "launch-banner.png", safeName: "launch-banner.png", mime: "image/png", mimeClass: "image",
byteSize: 348_112, sha256: "b".repeat(64), createdAt: "2026-10-08T11:40:00.000Z", available: true,
retentionMode: "until", retentionUntil: "2026-11-30T00:00:00.000Z",
},
{
id: "sample-3", groupId: "sample-group", origin: "bridge", senderMxid: "@bridge:collective.example",
originalName: "statement-final.pdf", safeName: "statement-final.pdf", mime: "application/pdf", mimeClass: "document",
byteSize: 1_204_998, sha256: "c".repeat(64), createdAt: "2026-10-07T18:05:00.000Z", available: true,
retentionMode: "legalHold", retentionUntil: null,
},
];
const identityGroups = (groups: GroupSummary[]): GroupSummary[] => groups;
export default function FilesPage(): React.ReactElement {
const { t, lang, setLang } = useFilesLang();
const { status: authStatus, can, session } = useAuth();
const groups = useApiResource<GroupSummary[], GroupSummary[]>("/groups/mine", identityGroups, SAMPLE_GROUPS);
const [pickedGroup, setPickedGroup] = useState<string | null>(null);
const [items, setItems] = useState<GroupFileItem[]>([]);
const [source, setSource] = useState<"loading" | "live" | "sample">("loading");
const [error, setError] = useState<string | null>(null);
const [nextCursor, setNextCursor] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
const [loadingMore, setLoadingMore] = useState(false);
const [nonce, setNonce] = useState(0);
const [qInput, setQInput] = useState("");
const [q, setQ] = useState("");
const [mimeClass, setMimeClass] = useState<"" | MimeClass>("");
const [origin, setOrigin] = useState<"" | FileOrigin>("");
const [actionError, setActionError] = useState<string | null>(null);
const [notice, setNotice] = useState<string | null>(null);
const [busyId, setBusyId] = useState<string | null>(null);
const [shareFor, setShareFor] = useState<GroupFileItem | null>(null);
const [retentionFor, setRetentionFor] = useState<GroupFileItem | null>(null);
const [shares, setShares] = useState<Record<string, { shareId: string | null; tokenUrl: string }>>({});
const groupId = pickedGroup ?? groups.data[0]?.id ?? "";
const live = source === "live";
const isAdmin = can("workspace.members.manage") || session?.role === "Admin" || session?.role === "Owner";
// Debounce the search box; only the settled value drives the server query.
useEffect(() => {
const timer = window.setTimeout(() => setQ(qInput.trim()), 300);
return () => window.clearTimeout(timer);
}, [qInput]);
useEffect(() => {
if (authStatus === "loading") return;
if (authStatus !== "authenticated") {
setItems(SAMPLE_FILES);
setNextCursor(null);
setSource("sample");
setError(null);
return;
}
if (groupId === "") {
setItems([]);
setNextCursor(null);
setSource("live");
return;
}
const controller = new AbortController();
setLoading(true);
api
.get<GroupFilePage>(`/files/groups/${encodeURIComponent(groupId)}/items`, { q, mimeClass, origin, limit: PAGE_LIMIT }, controller.signal)
.then((page) => {
setItems(page.items);
setNextCursor(page.nextCursor);
setSource("live");
setError(null);
})
.catch((err: unknown) => {
if (controller.signal.aborted) return;
if (err instanceof ApiUnreachableError) {
setItems(SAMPLE_FILES);
setNextCursor(null);
setSource("sample");
return;
}
setSource("live");
setError(errorMessage(err));
})
.finally(() => {
if (!controller.signal.aborted) setLoading(false);
});
return () => controller.abort();
}, [authStatus, groupId, q, mimeClass, origin, nonce]);
async function loadMore(): Promise<void> {
if (nextCursor === null || groupId === "") return;
setLoadingMore(true);
try {
const page = await api.get<GroupFilePage>(`/files/groups/${encodeURIComponent(groupId)}/items`, {
q, mimeClass, origin, limit: PAGE_LIMIT, cursor: nextCursor,
});
setItems((current) => {
const seen = new Set(current.map((item) => item.id));
return [...current, ...page.items.filter((item) => !seen.has(item.id))];
});
setNextCursor(page.nextCursor);
} catch (err) {
setActionError(errorMessage(err));
} finally {
setLoadingMore(false);
}
}
const handleUploaded = useCallback((item: GroupFileItem): void => {
setItems((current) => [item, ...current.filter((existing) => existing.id !== item.id)]);
setNotice(t({ en: "File uploaded.", es: "Archivo subido." }));
}, [t]);
async function handleDownload(item: GroupFileItem): Promise<void> {
setActionError(null);
try {
await downloadGroupFile(item.id, item.safeName);
} catch (err) {
setActionError(errorMessage(err));
}
}
async function revokeShare(item: GroupFileItem): Promise<void> {
const entry = shares[item.id];
if (entry === undefined || entry.shareId === null || entry.shareId === "") {
setActionError(t({ en: "No share id to revoke — reopen the share dialog.", es: "No hay id de enlace que revocar — vuelve a abrir el cuadro de compartir." }));
return;
}
const previous = shares;
setBusyId(item.id);
setActionError(null);
// Optimistic: drop the link immediately, restore if the API rejects.
setShares((current) => {
const next = { ...current };
delete next[item.id];
return next;
});
try {
await api.del(`/files/shares/${encodeURIComponent(entry.shareId)}`);
setNotice(t({ en: "Share link revoked.", es: "Enlace para compartir revocado." }));
} catch (err) {
setShares(previous);
setActionError(errorMessage(err));
} finally {
setBusyId(null);
}
}
function handleRetentionSaved(itemId: string, input: RetentionInput): void {
setItems((current) =>
current.map((item) =>
item.id === itemId ? { ...item, retentionMode: input.mode, retentionUntil: input.until ?? null } : item,
),
);
setNotice(t({ en: "Retention updated.", es: "Retención actualizada." }));
}
const groupChosen = groupId !== "";
const showList = source !== "loading";
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">{t({ en: "Files", es: "Archivos" })}</h1>
<OfflineBadge source={source} />
{groups.data.length > 0 && (
<label className="flex items-center gap-2 text-xs text-text-dim">
<span className="sr-only">{t({ en: "Group", es: "Grupo" })}</span>
<select
aria-label={t({ en: "Group", es: "Grupo" })}
value={groupId}
onChange={(event) => setPickedGroup(event.target.value)}
className="rounded-md border border-border bg-surface-2 px-2 py-1 text-xs"
>
{groups.data.map((group) => (
<option key={group.id} value={group.id}>{group.name}</option>
))}
</select>
</label>
)}
<div className="ml-auto flex shrink-0 items-center rounded-md border border-border bg-surface-2 p-0.5" role="group" aria-label="Language">
{(["en", "es"] as const).map((code) => (
<button
key={code}
type="button"
onClick={() => setLang(code)}
aria-pressed={lang === code}
className={`min-h-8 rounded px-2.5 text-2xs font-bold uppercase transition-colors duration-150 ${
lang === code ? "bg-accent text-white" : "text-text-dim hover:text-text"
}`}
>
{code}
</button>
))}
</div>
</div>
<p className="mb-4 text-sm text-text-dim">
{t({
en: "Files shared with this group, from Matrix rooms, bridges, direct uploads and pad exports. Download and share links stream the verified bytes back through the hub — nothing is stored on a third party.",
es: "Archivos compartidos con este grupo, desde salas Matrix, puentes, subidas directas y exportaciones de pad. La descarga y los enlaces de compartir sirven los bytes verificados a través del hub — nada queda en terceros.",
})}
</p>
{groups.error !== null && <p className="mb-3 text-sm text-danger">{groups.error}</p>}
{error !== null && <p className="mb-3 text-sm text-danger">{error}</p>}
{actionError !== null && <p role="alert" className="mb-3 text-sm text-danger">{actionError}</p>}
{notice !== null && <p role="status" className="mb-3 text-sm text-live">{notice}</p>}
<UploadDropzone groupId={groupId} live={live} onUploaded={handleUploaded} />
<div className="mb-4 flex flex-wrap items-center gap-2">
<Input
type="search"
value={qInput}
onChange={(event) => setQInput(event.target.value)}
placeholder={t({ en: "Search files by name…", es: "Buscar archivos por nombre…" })}
aria-label={t({ en: "Search files", es: "Buscar archivos" })}
className="max-w-xs"
/>
<select
aria-label={t({ en: "MIME class", es: "Clase MIME" })}
value={mimeClass}
onChange={(event) => setMimeClass(event.target.value as "" | MimeClass)}
className="rounded-md border border-border bg-surface-2 px-2 py-1.5 text-xs"
>
<option value="">{t({ en: "All types", es: "Todos los tipos" })}</option>
{(Object.keys(MIME_CLASS_LABEL) as MimeClass[]).map((value) => (
<option key={value} value={value}>{t(MIME_CLASS_LABEL[value])}</option>
))}
</select>
<select
aria-label={t({ en: "Origin", es: "Origen" })}
value={origin}
onChange={(event) => setOrigin(event.target.value as "" | FileOrigin)}
className="rounded-md border border-border bg-surface-2 px-2 py-1.5 text-xs"
>
<option value="">{t({ en: "All origins", es: "Todos los orígenes" })}</option>
{(Object.keys(ORIGIN_LABEL) as FileOrigin[]).map((value) => (
<option key={value} value={value}>{t(ORIGIN_LABEL[value])}</option>
))}
</select>
{live && (
<Button size="sm" variant="ghost" onClick={() => setNonce((n) => n + 1)} disabled={loading}>
{t({ en: "Refresh", es: "Actualizar" })}
</Button>
)}
</div>
{groups.data.length === 0 && source !== "sample" && (
<EmptyState
title={t({ en: "No groups yet", es: "Aún no hay grupos" })}
hint={t({ en: "Files live inside a group. Join or create one, then come back.", es: "Los archivos viven dentro de un grupo. Únete o crea uno y vuelve." })}
/>
)}
{groups.data.length > 0 && !groupChosen && source !== "sample" && (
<EmptyState
title={t({ en: "Choose a group", es: "Elige un grupo" })}
hint={t({ en: "Pick a group above to see its shared files.", es: "Elige un grupo arriba para ver sus archivos compartidos." })}
/>
)}
{source === "loading" && (
<div className="flex items-center gap-2 text-xs text-text-faint">
<Loading size={16} label={t({ en: "Loading files", es: "Cargando archivos" })} />
{t({ en: "Loading files…", es: "Cargando archivos…" })}
</div>
)}
{showList && loading && items.length > 0 && (
<div className="mb-3 flex items-center gap-2 text-2xs text-text-faint">
<Loading size={12} label={t({ en: "Refreshing", es: "Actualizando" })} />
{t({ en: "Refreshing…", es: "Actualizando…" })}
</div>
)}
{showList && !loading && (groupChosen || source === "sample") && items.length === 0 && (
<EmptyState
title={t({ en: "No files here yet", es: "Aún no hay archivos aquí" })}
hint={
q !== "" || mimeClass !== "" || origin !== ""
? t({ en: "No file matches these filters. Clear the search or filters.", es: "Ningún archivo coincide con estos filtros. Limpia la búsqueda o los filtros." })
: t({ en: "Drop a file above, or wait for a room attachment to arrive.", es: "Suelta un archivo arriba, o espera a que llegue un adjunto de la sala." })
}
/>
)}
{showList && items.length > 0 && (
<div className="space-y-2.5">
{items.map((item) => (
<FileRow
key={item.id}
item={item}
live={live}
canRetention={isAdmin}
busy={busyId === item.id}
hasActiveShare={shares[item.id] !== undefined}
onDownload={() => void handleDownload(item)}
onShare={() => { setActionError(null); setShareFor(item); }}
onRevoke={() => void revokeShare(item)}
onRetention={() => { setActionError(null); setRetentionFor(item); }}
/>
))}
</div>
)}
{live && nextCursor !== null && (
<div className="mt-4 flex justify-center">
<Button variant="outline" size="sm" disabled={loadingMore} onClick={() => void loadMore()}>
{loadingMore ? t({ en: "Loading…", es: "Cargando…" }) : t({ en: "Load more", es: "Cargar más" })}
</Button>
</div>
)}
<p className="mt-6 text-2xs leading-relaxed text-text-faint">
{t({
en: "Retention (keep / until / legal hold) is separate from link expiry: revoking or expiring a share never deletes the file, and legal hold blocks deletion until an admin releases it.",
es: "La retención (conservar / hasta / retención legal) es independiente de la caducidad del enlace: revocar o dejar caducar un enlace nunca borra el archivo, y la retención legal bloquea el borrado hasta que un admin la libere.",
})}
</p>
{shareFor !== null && (
<ShareDialog
item={shareFor}
onClose={() => setShareFor(null)}
onCreated={(itemId, share) => setShares((current) => ({ ...current, [itemId]: share }))}
/>
)}
{retentionFor !== null && (
<RetentionDialog item={retentionFor} onClose={() => setRetentionFor(null)} onSaved={handleRetentionSaved} />
)}
</div>
);
}

View file

@ -15,7 +15,7 @@ import { useIsMobile } from "../../hooks/use-is-mobile";
const titles: Record<string, string> = {
streams: "Home", search: "Search", inbox: "Inbox", approvals: "Approvals", more: "More", compose: "New post",
calendar: "Calendar", video: "Video", analytics: "Analytics", connections: "Connections", team: "Team",
audit: "Audit log", onboarding: "Welcome", settings: "Settings", archive: "Archive",
audit: "Audit log", onboarding: "Welcome", settings: "Settings", archive: "Archive", files: "Files",
};
const rootTabs = new Set(["/streams", "/search", "/inbox", "/more"]);
const scrollPositions = new Map<string, { main: number; nested: number[] }>();

View file

@ -63,6 +63,7 @@ export default function MorePage(): React.ReactElement {
<Row href="/calendar" title="Calendar" />
<Row href="/analytics" title="Analytics" />
<Row href="/video" title="Video library" />
<Row href="/files" title="Files" detail="Group file library" />
<Row href="/onboarding" title="Onboarding" />
<button onClick={() => void logout()} className="flex min-h-[56px] w-full items-center px-4 text-left text-sm font-medium text-danger">Sign out</button>
</div>

View file

@ -0,0 +1,65 @@
"use client";
import { Badge, Button, Card } from "../ui/primitives";
import { formatBytes } from "../video/format";
import { MimeIcon } from "./mime-icon";
import { mimeClassLabel, ORIGIN_LABEL, ORIGIN_TONE, RETENTION_LABEL, RETENTION_TONE } from "./labels";
import { useFilesLang } from "./i18n";
import type { GroupFileItem } from "./types";
/** One file row: identity + metadata on the left, actions on the right. */
export function FileRow({ item, live, canRetention, busy, hasActiveShare, onDownload, onShare, onRevoke, onRetention }: {
item: GroupFileItem;
live: boolean;
canRetention: boolean;
busy: boolean;
hasActiveShare: boolean;
onDownload: () => void;
onShare: () => void;
onRevoke: () => void;
onRetention: () => void;
}): React.ReactElement {
const { t } = useFilesLang();
const disabled = busy || !live || !item.available;
const retentionLabel =
item.retentionMode === "until" && item.retentionUntil !== null
? `${t(RETENTION_LABEL.until)} ${new Date(item.retentionUntil).toLocaleDateString()}`
: t(RETENTION_LABEL[item.retentionMode]);
return (
<Card className="flex flex-wrap items-center gap-3 p-3">
<MimeIcon mimeClass={item.mimeClass} size={30} className="shrink-0 text-accent" />
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-2">
<span className="truncate text-sm font-medium" title={item.originalName}>{item.safeName}</span>
<Badge tone={ORIGIN_TONE[item.origin]}>{t(ORIGIN_LABEL[item.origin])}</Badge>
<Badge tone={RETENTION_TONE[item.retentionMode]}>{retentionLabel}</Badge>
{!item.available && <Badge tone="danger">{t({ en: "Unavailable", es: "No disponible" })}</Badge>}
</div>
<div className="mt-0.5 flex flex-wrap items-center gap-x-2 gap-y-0.5 text-2xs text-text-faint">
<span>{formatBytes(item.byteSize)}</span>
<span aria-hidden="true">·</span>
<span className="truncate" title={`${t(mimeClassLabel(item.mimeClass))} · ${item.mime}`}>{t(mimeClassLabel(item.mimeClass))}</span>
<span aria-hidden="true">·</span>
<span className="max-w-[16rem] truncate" title={item.senderMxid}>{item.senderMxid}</span>
<span aria-hidden="true">·</span>
<time dateTime={item.createdAt}>{new Date(item.createdAt).toLocaleString()}</time>
</div>
</div>
<div className="flex flex-wrap items-center gap-1.5">
<Button size="sm" variant="outline" disabled={disabled} onClick={onDownload}>{t({ en: "Download", es: "Descargar" })}</Button>
<Button size="sm" variant="ghost" disabled={disabled} onClick={onShare}>{t({ en: "Share", es: "Compartir" })}</Button>
{hasActiveShare && (
<Button size="sm" variant="ghost" disabled={busy || !live} onClick={onRevoke}>
{t({ en: "Revoke link", es: "Revocar enlace" })}
</Button>
)}
{canRetention && (
<Button size="sm" variant="ghost" disabled={busy || !live} onClick={onRetention}>
{t({ en: "Retention", es: "Retención" })}
</Button>
)}
</div>
</Card>
);
}

View file

@ -0,0 +1,40 @@
import { API_URL, ApiError } from "../../lib/api-client";
import { getSessionToken } from "../../lib/session-token";
/**
* Binary download for GET /files/items/:itemId/download. This cannot use
* `apiFetch` (it parses JSON) or a bare anchor (the endpoint needs the
* Authorization header, which anchors cannot send), so it replays the client's
* auth rules: same base URL, Bearer token, cookie credentials — then streams
* the body into a Blob and clicks a temporary anchor.
*/
export async function downloadGroupFile(itemId: string, fileName: string): Promise<void> {
const token = getSessionToken();
const response = await fetch(`${API_URL}/files/items/${encodeURIComponent(itemId)}/download`, {
credentials: "include",
headers: token === null ? {} : { Authorization: `Bearer ${token}` },
});
if (!response.ok) {
if (response.status === 401) throw new ApiError(401, "Your session expired — sign in again.", null);
throw new ApiError(response.status, `Download failed (HTTP ${response.status})`, null);
}
const blob = await response.blob();
const url = URL.createObjectURL(blob);
const anchor = document.createElement("a");
anchor.href = url;
anchor.download = fileName;
document.body.append(anchor);
anchor.click();
anchor.remove();
URL.revokeObjectURL(url);
}
/**
* Derive the share id from a public tokenUrl of the form
* `/files/shares/:token/download`, used for DELETE /files/shares/:shareId when
* the POST response omits an explicit shareId.
*/
export function shareIdFromUrl(tokenUrl: string): string | null {
const match = /\/files\/shares\/([^/?#]+)/.exec(tokenUrl);
return match?.[1] !== undefined && match[1] !== "" ? match[1] : null;
}

View file

@ -0,0 +1,66 @@
"use client";
import { createContext, useCallback, useContext, useEffect, useMemo, useState, type ReactNode } from "react";
/**
* EN/ES language toggle for the Files section, mirroring
* app/(main)/wiki/lang-context.tsx exactly: a per-browser reading preference
* in localStorage (never sent to the server) and a `t({en, es})` helper every
* Files label goes through. Wired by app/(main)/files/layout.tsx.
*/
export type FilesLang = "en" | "es";
export interface LocalizedText {
readonly en: string;
readonly es: string;
}
interface FilesLangContextValue {
lang: FilesLang;
setLang: (lang: FilesLang) => void;
/** Pick the string for the current language — the one helper every Files screen uses. */
t: (text: LocalizedText) => string;
}
const STORAGE_KEY = "tmbh.files.lang";
const FilesLangContext = createContext<FilesLangContextValue | null>(null);
function readStoredLang(): FilesLang {
if (typeof window === "undefined") return "en";
try {
const stored = window.localStorage.getItem(STORAGE_KEY);
return stored === "es" ? "es" : "en";
} catch {
return "en";
}
}
export function FilesLangProvider({ children }: { children: ReactNode }): React.ReactElement {
const [lang, setLangState] = useState<FilesLang>("en");
useEffect(() => {
setLangState(readStoredLang());
}, []);
const setLang = useCallback((next: FilesLang) => {
setLangState(next);
try {
window.localStorage.setItem(STORAGE_KEY, next);
} catch {
// Private-browsing modes can refuse storage; the choice still holds for
// this screen, it just will not survive a reload.
}
}, []);
const t = useCallback((text: LocalizedText) => text[lang], [lang]);
const value = useMemo<FilesLangContextValue>(() => ({ lang, setLang, t }), [lang, setLang, t]);
return <FilesLangContext.Provider value={value}>{children}</FilesLangContext.Provider>;
}
export function useFilesLang(): FilesLangContextValue {
const context = useContext(FilesLangContext);
if (context === null) throw new Error("useFilesLang must be used inside FilesLangProvider");
return context;
}

View file

@ -0,0 +1,43 @@
import type { LocalizedText } from "./i18n";
import type { FileOrigin, MimeClass, RetentionMode } from "./types";
/** Bilingual display labels for the group-files enums (values are fixed by the API). */
export const ORIGIN_LABEL: Record<FileOrigin, LocalizedText> = {
matrix: { en: "Matrix", es: "Matrix" },
bridge: { en: "Bridge", es: "Puente" },
upload: { en: "Upload", es: "Subido" },
"pad-export": { en: "Pad export", es: "Export. de pad" },
};
export const ORIGIN_TONE: Record<FileOrigin, "neutral" | "accent" | "live" | "warn" | "danger"> = {
matrix: "accent",
bridge: "live",
upload: "neutral",
"pad-export": "warn",
};
export const MIME_CLASS_LABEL: Record<MimeClass, LocalizedText> = {
image: { en: "Image", es: "Imagen" },
video: { en: "Video", es: "Vídeo" },
audio: { en: "Audio", es: "Audio" },
document: { en: "Document", es: "Documento" },
other: { en: "File", es: "Archivo" },
};
export const RETENTION_LABEL: Record<RetentionMode, LocalizedText> = {
keep: { en: "Keep", es: "Conservar" },
until: { en: "Until", es: "Hasta" },
legalHold: { en: "Legal hold", es: "Retención legal" },
};
export const RETENTION_TONE: Record<RetentionMode, "neutral" | "accent" | "live" | "warn" | "danger"> = {
keep: "neutral",
until: "accent",
legalHold: "danger",
};
/** Human label for a MIME class, tolerating an unknown value from the server. */
export function mimeClassLabel(mimeClass: MimeClass): LocalizedText {
return MIME_CLASS_LABEL[mimeClass] ?? MIME_CLASS_LABEL.other;
}

View file

@ -0,0 +1,66 @@
import type { MimeClass } from "./types";
/**
* MIME-class glyph for a file row. Inline SVG drawn with `currentColor` so it
* inherits the palette (text-accent in the row) instead of shipping four more
* brand assets — the sidebar nav icon is the only new static asset.
*/
export function MimeIcon({ mimeClass, size = 28, className = "" }: {
mimeClass: MimeClass;
size?: number;
className?: string;
}): React.ReactElement {
const common = {
width: size,
height: size,
viewBox: "0 0 24 24",
fill: "none",
stroke: "currentColor",
strokeWidth: 1.6,
strokeLinecap: "round" as const,
strokeLinejoin: "round" as const,
"aria-hidden": true,
className,
};
if (mimeClass === "image") {
return (
<svg {...common}>
<rect x="3" y="4" width="18" height="16" rx="2" />
<circle cx="8.5" cy="9.5" r="1.5" />
<path d="M4 17l4.5-4.5a2 2 0 0 1 2.8 0L20 19" />
</svg>
);
}
if (mimeClass === "video") {
return (
<svg {...common}>
<rect x="3" y="5" width="13" height="14" rx="2" />
<path d="M16 9.5l5-2.8v10.6l-5-2.8" />
</svg>
);
}
if (mimeClass === "audio") {
return (
<svg {...common}>
<path d="M9 18V6l10-2v12" />
<circle cx="6" cy="18" r="3" />
<circle cx="16" cy="16" r="3" />
</svg>
);
}
if (mimeClass === "document") {
return (
<svg {...common}>
<path d="M6 3h7l5 5v13H6z" />
<path d="M13 3v5h5" />
<path d="M9 13h6M9 17h6" />
</svg>
);
}
return (
<svg {...common}>
<path d="M6 3h7l5 5v13H6z" />
<path d="M13 3v5h5" />
</svg>
);
}

View file

@ -0,0 +1,105 @@
"use client";
import { useState } from "react";
import { Button, Input } from "../ui/primitives";
import { api, errorMessage } from "../../lib/api-client";
import { RETENTION_LABEL } from "./labels";
import { useFilesLang } from "./i18n";
import type { GroupFileItem, RetentionInput, RetentionMode } from "./types";
/**
* Admin-only retention change: POST /files/items/:itemId/retention
* {mode, until?}. Retention is separate from share expiry — legalHold blocks
* deletion, `until` requires a deadline, `keep` is the no-purge default.
*/
export function RetentionDialog({ item, onClose, onSaved }: {
item: GroupFileItem;
onClose: () => void;
onSaved: (itemId: string, input: RetentionInput) => void;
}): React.ReactElement {
const { t } = useFilesLang();
const [mode, setMode] = useState<RetentionMode>(item.retentionMode);
const [until, setUntil] = useState(item.retentionUntil?.slice(0, 16) ?? "");
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const modes: RetentionMode[] = ["keep", "until", "legalHold"];
async function save(): Promise<void> {
let iso: string | undefined;
if (mode === "until") {
if (until === "") {
setError(t({ en: "Pick a retention deadline.", es: "Elige una fecha límite de retención." }));
return;
}
const parsed = new Date(until);
if (!Number.isFinite(parsed.getTime())) {
setError(t({ en: "That deadline is not a valid date.", es: "Esa fecha límite no es válida." }));
return;
}
iso = parsed.toISOString();
}
setBusy(true);
setError(null);
try {
const body: RetentionInput = mode === "until" ? { mode, until: iso } : { mode };
await api.post(`/files/items/${item.id}/retention`, body);
onSaved(item.id, body);
onClose();
} catch (err) {
setError(errorMessage(err));
} finally {
setBusy(false);
}
}
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4" onClick={onClose}>
<div
role="dialog"
aria-modal="true"
aria-label={t({ en: "Set retention", es: "Definir retención" })}
className="w-[460px] max-w-[92vw] rounded-lg border border-border bg-surface p-4 shadow-2xl"
onClick={(e: React.MouseEvent) => e.stopPropagation()}
>
<h2 className="text-sm font-semibold">{t({ en: "Set retention", es: "Definir retención" })}</h2>
<p className="mt-1 truncate text-xs text-text-dim" title={item.originalName}>{item.safeName}</p>
<p className="mt-3 text-xs leading-relaxed text-text-dim">
{t({
en: "Retention is independent of share expiry. Legal hold blocks deletion until an admin releases it.",
es: "La retención es independiente de la caducidad del enlace. La retención legal bloquea el borrado hasta que un admin la libere.",
})}
</p>
<div className="mt-3 flex flex-wrap gap-1.5" role="group" aria-label={t({ en: "Retention mode", es: "Modo de retención" })}>
{modes.map((value) => (
<button
key={value}
type="button"
onClick={() => setMode(value)}
aria-pressed={mode === value}
className={`min-h-9 rounded-md border px-3 text-xs font-medium transition-colors duration-150 ${
mode === value ? "border-accent bg-accent-dim text-accent" : "border-border text-text-dim hover:border-text-faint"
}`}
>
{t(RETENTION_LABEL[value])}
</button>
))}
</div>
{mode === "until" && (
<label className="mt-3 block text-2xs text-text-faint">
{t({ en: "Retain until", es: "Conservar hasta" })}
<Input type="datetime-local" value={until} onChange={(e) => setUntil(e.target.value)} className="mt-1" />
</label>
)}
{error !== null && <p role="alert" className="mt-3 text-xs text-danger">{error}</p>}
<div className="mt-4 flex justify-end gap-2">
<Button variant="ghost" onClick={onClose}>{t({ en: "Cancel", es: "Cancelar" })}</Button>
<Button variant="primary" disabled={busy} onClick={() => void save()}>
{busy ? t({ en: "Saving…", es: "Guardando…" }) : t({ en: "Save", es: "Guardar" })}
</Button>
</div>
</div>
</div>
);
}

View file

@ -0,0 +1,158 @@
"use client";
import { useMemo, useState } from "react";
import { Button, Input } from "../ui/primitives";
import { api, errorMessage } from "../../lib/api-client";
import { shareIdFromUrl } from "./files-client";
import { useFilesLang } from "./i18n";
import type { GroupFileItem, ShareResponse } from "./types";
/**
* Mint a public share link for one file: POST /files/items/:itemId/share
* {expiresAt}. The token is returned once — after this dialog closes it can
* never be recovered, so the result view says so in plain language and offers
* a copy button before the user leaves.
*/
type ExpiryKey = "1h" | "24h" | "7d" | "custom";
const EXPIRY_MS: Record<Exclude<ExpiryKey, "custom">, number> = {
"1h": 60 * 60 * 1000,
"24h": 24 * 60 * 60 * 1000,
"7d": 7 * 24 * 60 * 60 * 1000,
};
export function ShareDialog({ item, onClose, onCreated }: {
item: GroupFileItem;
onClose: () => void;
onCreated: (itemId: string, share: { shareId: string | null; tokenUrl: string }) => void;
}): React.ReactElement {
const { t } = useFilesLang();
const [expiry, setExpiry] = useState<ExpiryKey>("24h");
const [customAt, setCustomAt] = useState("");
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const [tokenUrl, setTokenUrl] = useState<string | null>(null);
const [copied, setCopied] = useState(false);
const options: Array<{ key: ExpiryKey; label: string }> = [
{ key: "1h", label: t({ en: "1 hour", es: "1 hora" }) },
{ key: "24h", label: t({ en: "24 hours", es: "24 horas" }) },
{ key: "7d", label: t({ en: "7 days", es: "7 días" }) },
{ key: "custom", label: t({ en: "Custom", es: "Personalizado" }) },
];
const minLocal = useMemo(() => {
const now = new Date(Date.now() - new Date().getTimezoneOffset() * 60_000);
return now.toISOString().slice(0, 16);
}, []);
function expiresAtIso(): string | null {
if (expiry === "custom") {
if (customAt === "") return null;
const parsed = new Date(customAt);
return Number.isFinite(parsed.getTime()) ? parsed.toISOString() : null;
}
return new Date(Date.now() + EXPIRY_MS[expiry]).toISOString();
}
async function create(): Promise<void> {
const expiresAt = expiresAtIso();
if (expiresAt === null) {
setError(t({ en: "Pick a future expiry date.", es: "Elige una fecha de caducidad futura." }));
return;
}
setBusy(true);
setError(null);
try {
const response = await api.post<ShareResponse>(`/files/items/${item.id}/share`, { expiresAt });
setTokenUrl(response.tokenUrl);
onCreated(item.id, { shareId: response.shareId ?? shareIdFromUrl(response.tokenUrl), tokenUrl: response.tokenUrl });
} catch (err) {
setError(errorMessage(err));
} finally {
setBusy(false);
}
}
async function copy(): Promise<void> {
if (tokenUrl === null) return;
try {
await navigator.clipboard.writeText(tokenUrl);
setCopied(true);
} catch {
setError(t({ en: "Could not copy — select the link and copy it manually.", es: "No se pudo copiar — selecciona el enlace y cópialo a mano." }));
}
}
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4" onClick={onClose}>
<div
role="dialog"
aria-modal="true"
aria-label={t({ en: "Share file", es: "Compartir archivo" })}
className="w-[460px] max-w-[92vw] rounded-lg border border-border bg-surface p-4 shadow-2xl"
onClick={(e: React.MouseEvent) => e.stopPropagation()}
>
<h2 className="text-sm font-semibold">{t({ en: "Share file", es: "Compartir archivo" })}</h2>
<p className="mt-1 truncate text-xs text-text-dim" title={item.originalName}>{item.safeName}</p>
{tokenUrl === null ? (
<>
<p className="mt-3 text-xs leading-relaxed text-text-dim">
{t({
en: "Anyone with the link can download this file until it expires. Expiry only ends the link — it never deletes or hides the file.",
es: "Cualquiera con el enlace puede descargar este archivo hasta que caduque. La caducidad solo termina el enlace — nunca borra ni oculta el archivo.",
})}
</p>
<div className="mt-3 flex flex-wrap gap-1.5" role="group" aria-label={t({ en: "Link expiry", es: "Caducidad del enlace" })}>
{options.map((option) => (
<button
key={option.key}
type="button"
onClick={() => setExpiry(option.key)}
aria-pressed={expiry === option.key}
className={`min-h-9 rounded-md border px-3 text-xs font-medium transition-colors duration-150 ${
expiry === option.key ? "border-accent bg-accent-dim text-accent" : "border-border text-text-dim hover:border-text-faint"
}`}
>
{option.label}
</button>
))}
</div>
{expiry === "custom" && (
<label className="mt-3 block text-2xs text-text-faint">
{t({ en: "Expires at", es: "Caduca el" })}
<Input type="datetime-local" min={minLocal} value={customAt} onChange={(e) => setCustomAt(e.target.value)} className="mt-1" />
</label>
)}
{error !== null && <p role="alert" className="mt-3 text-xs text-danger">{error}</p>}
<div className="mt-4 flex justify-end gap-2">
<Button variant="ghost" onClick={onClose}>{t({ en: "Cancel", es: "Cancelar" })}</Button>
<Button variant="primary" disabled={busy} onClick={() => void create()}>
{busy ? t({ en: "Creating…", es: "Creando…" }) : t({ en: "Create link", es: "Crear enlace" })}
</Button>
</div>
</>
) : (
<>
<p role="status" className="mt-3 text-xs leading-relaxed text-warn">
{t({
en: "Copy this link now — it is shown only once and cannot be recovered after you close this dialog.",
es: "Copia este enlace ahora — se muestra solo una vez y no se puede recuperar tras cerrar este cuadro.",
})}
</p>
<Input readOnly value={tokenUrl} aria-label={t({ en: "Share link", es: "Enlace para compartir" })} onFocus={(e) => e.target.select()} className="mt-2 font-mono text-2xs" />
{error !== null && <p role="alert" className="mt-2 text-xs text-danger">{error}</p>}
<div className="mt-4 flex justify-end gap-2">
<Button variant="ghost" onClick={onClose}>{t({ en: "Close", es: "Cerrar" })}</Button>
<Button variant={copied ? "outline" : "primary"} onClick={() => void copy()}>
{copied ? t({ en: "Copied", es: "Copiado" }) : t({ en: "Copy link", es: "Copiar enlace" })}
</Button>
</div>
</>
)}
</div>
</div>
);
}

View file

@ -0,0 +1,64 @@
/**
* Wire shapes for the group-files HTTP surface (apps/api/src/group-files).
* Kept local to the Files feature so it does not collide with the shared
* api-types.ts other builders may be editing in parallel. Hand-synced with the
* controller: GET /files/groups/:groupId/items returns the flat item below.
*/
export type FileOrigin = "matrix" | "bridge" | "upload" | "pad-export";
/**
* Coarse MIME class. NOTE: the API's DB CHECK (migration 029) and
* group-files.validation.ts MIME_CLASSES both use
* "image" | "video" | "audio" | "document" | "other" — the task brief said
* image/video/audio/file/doc; this follows the backend's real enum.
*/
export type MimeClass = "image" | "video" | "audio" | "document" | "other";
export type RetentionMode = "keep" | "until" | "legalHold";
/** One row of the group file library — the served item shape. */
export interface GroupFileItem {
id: string;
groupId: string;
origin: FileOrigin;
senderMxid: string;
originalName: string;
safeName: string;
mime: string;
mimeClass: MimeClass;
byteSize: number;
sha256: string;
createdAt: string;
available: boolean;
retentionMode: RetentionMode;
retentionUntil: string | null;
}
export interface GroupFilePage {
items: GroupFileItem[];
nextCursor: string | null;
}
/** GET /groups/mine (same shape the More page already consumes). */
export interface GroupSummary {
id: string;
name: string;
}
/**
* POST /files/items/:itemId/share response. The task surface documents only
* `tokenUrl`; the API may also return a `shareId` used by
* DELETE /files/shares/:shareId. When absent the client derives the id from
* the tokenUrl's path segment so revoke still works.
*/
export interface ShareResponse {
tokenUrl: string;
shareId?: string;
}
/** POST /files/items/:itemId/retention body. */
export interface RetentionInput {
mode: RetentionMode;
until?: string;
}

View file

@ -0,0 +1,160 @@
"use client";
import { useCallback, useRef, useState } from "react";
import { Button } from "../ui/primitives";
import { Loading } from "../brand/loading";
import { apiUploadFile, errorMessage } from "../../lib/api-client";
import { formatBytes } from "../video/format";
import { useFilesLang } from "./i18n";
import type { GroupFileItem } from "./types";
/**
* Drag-drop + picker for POST /files/groups/:groupId/upload. The API accepts a
* single `file` field capped at 20 MiB; the cap is enforced here too so an
* oversized pick fails fast with a friendly message instead of a 413.
*/
const MAX_UPLOAD_BYTES = 20 * 1024 * 1024;
export function UploadDropzone({ groupId, live, onUploaded }: {
groupId: string;
live: boolean;
onUploaded: (item: GroupFileItem) => void;
}): React.ReactElement {
const { t } = useFilesLang();
const inputRef = useRef<HTMLInputElement>(null);
const busyRef = useRef(false);
const [dragging, setDragging] = useState(false);
const [uploading, setUploading] = useState<string | null>(null);
const [progress, setProgress] = useState(0);
const [error, setError] = useState<string | null>(null);
const disabled = !live || groupId === "" || uploading !== null;
const register = useCallback(
async (file: File): Promise<void> => {
if (disabled || busyRef.current) return;
if (file.size === 0) {
setError(t({ en: `${file.name}: the file is empty.`, es: `${file.name}: el archivo está vacío.` }));
return;
}
if (file.size > MAX_UPLOAD_BYTES) {
setError(
t({
en: `${file.name} is ${formatBytes(file.size)} — the limit is 20 MiB.`,
es: `${file.name} pesa ${formatBytes(file.size)} — el límite es 20 MiB.`,
}),
);
return;
}
setError(null);
busyRef.current = true;
setProgress(0);
setUploading(`${file.name} (${formatBytes(file.size)})`);
try {
const created = await apiUploadFile<GroupFileItem>(
`/files/groups/${encodeURIComponent(groupId)}/upload`,
file,
setProgress,
);
onUploaded(created);
} catch (err) {
setError(errorMessage(err));
} finally {
setUploading(null);
busyRef.current = false;
}
},
[disabled, groupId, onUploaded, t],
);
const hint = !live
? t({ en: "Offline — connect to the API to upload.", es: "Sin conexión — conéctate a la API para subir." })
: groupId === ""
? t({ en: "Choose a group first.", es: "Elige un grupo primero." })
: t({
en: "Drop a file, paste one, or browse. Up to 20 MiB; stored with re-verified bytes.",
es: "Arrastra un archivo, pega uno o búscalo. Hasta 20 MiB; guardado con bytes reverificados.",
});
return (
<div className="mb-4">
<div
role="button"
tabIndex={disabled ? -1 : 0}
aria-disabled={disabled}
aria-label={t({ en: "Upload a file", es: "Subir un archivo" })}
onClick={() => {
if (!disabled) inputRef.current?.click();
}}
onKeyDown={(e) => {
if (disabled) return;
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
inputRef.current?.click();
}
}}
onDragOver={(e) => {
e.preventDefault();
if (!disabled) setDragging(true);
}}
onDragLeave={() => setDragging(false)}
onDrop={(e) => {
e.preventDefault();
setDragging(false);
if (disabled) return;
const file = e.dataTransfer.files[0];
if (file !== undefined) void register(file);
}}
className={`flex flex-col items-center justify-center gap-2 rounded-lg border border-dashed px-4 py-6 text-center transition-colors duration-150 ${
disabled
? "cursor-not-allowed border-border-subtle opacity-50"
: dragging
? "cursor-pointer border-accent bg-accent-dim"
: "cursor-pointer border-border hover:border-accent hover:bg-surface-2"
}`}
>
{uploading !== null ? (
<div className="w-full max-w-md space-y-2">
<span className="inline-flex items-center gap-2 text-xs text-text-dim">
<Loading size={14} label={t({ en: "Uploading", es: "Subiendo" })} />
{progress < 100
? t({ en: `Uploading ${progress}%`, es: `Subiendo ${progress}%` })
: t({ en: "Upload sent — indexing…", es: "Subida enviada — indexando…" })}{" "}
{uploading}
</span>
<progress className="h-2 w-full" aria-label={t({ en: "File upload progress", es: "Progreso de subida" })} value={progress} max={100} />
</div>
) : (
<>
<span className="text-sm text-text">{t({ en: "Drop a file here", es: "Suelta un archivo aquí" })}</span>
<span className="text-2xs leading-relaxed text-text-faint">{hint}</span>
{!disabled && (
<Button
size="sm"
variant="outline"
onClick={(e) => {
e.stopPropagation();
inputRef.current?.click();
}}
>
{t({ en: "Browse files", es: "Buscar archivos" })}
</Button>
)}
</>
)}
<input
ref={inputRef}
type="file"
className="hidden"
onChange={(e) => {
const file = e.target.files?.[0];
e.target.value = "";
if (file !== undefined) void register(file);
}}
/>
</div>
{error !== null && <p role="alert" className="mt-2 text-xs text-danger">{error}</p>}
</div>
);
}

View file

@ -38,6 +38,8 @@ function activeTab(pathname: string): string {
["/calendar", pathname.startsWith("/calendar") ? 1 : -1],
["/analytics", pathname.startsWith("/analytics") ? 1 : -1],
["/video", pathname.startsWith("/video") ? 1 : -1],
["/files", pathname.startsWith("/files") ? 1 : -1],
["/archive", pathname.startsWith("/archive") ? 1 : -1],
];
let best = "";
let bestScore = 0;

View file

@ -12,6 +12,7 @@ const PAGES = [
{ href: "/video", label: "Go to Video" },
{ href: "/inbox", label: "Go to Inbox" },
{ href: "/archive", label: "Go to Archive" },
{ href: "/files", label: "Go to Files" },
{ href: "/connections", label: "Go to Channels" },
{ href: "/team", label: "Go to Team" },
{ href: "/audit", label: "Go to Audit" },

View file

@ -17,6 +17,7 @@ const NAV = [
{ href: "/inbox", label: "Inbox" },
{ href: "/approvals", label: "Approvals" },
{ href: "/archive", label: "Archive" },
{ href: "/files", label: "Files" },
{ href: "/analytics", label: "Analytics" },
{ href: "/connections", label: "Channels" },
{ href: "/team", label: "Team" },
@ -58,8 +59,8 @@ export function Sidebar(): React.ReactElement {
}`}
>
<span className="flex items-center gap-2.5">
{item.href === "/video" || item.href === "/control-room" ? (
<NavIcon name="video" size={14} />
{item.href === "/video" || item.href === "/control-room" || item.href === "/files" ? (
<NavIcon name={item.href === "/files" ? "files" : "video"} size={14} />
) : (
<span className={`h-1.5 w-1.5 rounded-full ${active ? "bg-accent" : "bg-border group-hover:bg-text-faint"}`} />
)}

28
pnpm-lock.yaml generated
View file

@ -106,6 +106,34 @@ importers:
specifier: ^2.0.5
version: 2.1.9(@types/node@22.20.5)(lightningcss@1.32.0)(terser@5.51.2)
apps/archive-bot:
dependencies:
'@tactical-media-broadcast-hub/core':
specifier: workspace:*
version: link:../../packages/core
pg:
specifier: ^8.23.1
version: 8.23.1
pino:
specifier: ^9.3.2
version: 9.14.0
devDependencies:
'@types/node':
specifier: ^22.5.0
version: 22.20.5
'@types/pg':
specifier: ^8.23.1
version: 8.23.1
tsx:
specifier: ^4.17.0
version: 4.23.15
typescript:
specifier: ^5.5.4
version: 5.9.3
vitest:
specifier: ^2.0.5
version: 2.1.9(@types/node@22.20.5)(lightningcss@1.32.0)(terser@5.51.2)
apps/media-worker:
dependencies:
pino: