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
43
apps/archive-bot/src/mime.ts
Normal file
43
apps/archive-bot/src/mime.ts
Normal 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;
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue