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:
parent
eb1b60f67d
commit
d58db63fe6
58 changed files with 5543 additions and 4 deletions
44
apps/archive-bot/src/names.ts
Normal file
44
apps/archive-bot/src/names.ts
Normal 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}`;
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue