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