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