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,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] };
}