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