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,90 @@
import type { GroupFile } from "@tactical-media-broadcast-hub/core";
import { toGroupFileMapping, type RoomMapping } from "./config.js";
import type { AttachmentWork } from "./events.js";
import { inspectStream } from "./inspector.js";
import { replyTxnId } from "./keys.js";
import type { MatrixPort } from "./matrix.js";
import { safeFileName } from "./names.js";
import type { ArchiveStore, OutboxRow } from "./store.js";
import type { LibraryPort } from "./webdav.js";
export interface ArchivedAttachment {
file: GroupFile;
safeName: string;
}
/**
* Steps 2–3 of the processing contract for one claimed attachment:
* authenticated download → in-flight size/hash/MIME → staged WebDAV PUT →
* content-addressed MOVE → index row + outbox done in one transaction.
* The payload is never written to local disk.
*/
export class Archiver {
constructor(
private readonly matrix: MatrixPort,
private readonly library: LibraryPort,
private readonly store: ArchiveStore,
) {}
async archive(
mapping: RoomMapping,
work: AttachmentWork,
outbox: OutboxRow,
claimKey: string,
signal: AbortSignal,
): Promise<ArchivedAttachment> {
const { candidate, event } = work;
const download = await this.matrix.downloadMedia(candidate.mxcUri, signal);
const { stream, result } = inspectStream(download.body, download.contentLength);
const dir = ["Library", mapping.workspaceId, mapping.groupId];
// Deterministic per claim: a retried upload overwrites its own partial staging copy.
const stagingName = `${replyTxnId(claimKey)}.part`;
await this.library.putStaging(dir, stagingName, stream, signal);
const payload = await result;
const safeName = safeFileName(work.displayName, payload.sniffed.extension);
const resource = await this.library.commit(dir, stagingName, `${payload.sha256}-${safeName}`, signal);
const createdAt = new Date(event.origin_server_ts ?? Date.now());
const indexed = await this.store.commitArchived({
workspaceId: mapping.workspaceId,
groupId: mapping.groupId,
roomId: mapping.roomId,
eventId: event.event_id,
senderMxid: event.sender,
matrixEventJson: event,
originalName: work.displayName,
safeName,
mime: payload.sniffed.mime,
mimeClass: payload.sniffed.mimeClass,
byteSize: payload.byteSize,
sha256: payload.sha256,
ocisResourceId: resource.resourceId,
ocisResourceVersion: resource.version,
createdAt,
}, outbox.id);
const group = toGroupFileMapping(mapping);
return {
safeName,
file: {
id: indexed.id,
workspaceId: mapping.workspaceId,
groupId: mapping.groupId,
roomId: mapping.roomId,
senderId: event.sender,
eventId: event.event_id,
origin: { kind: "matrix", mxcUri: candidate.mxcUri },
name: safeName,
mimeType: payload.sniffed.mime,
sizeBytes: payload.byteSize,
checksum: { algorithm: "sha256", value: payload.sha256 },
createdAt: createdAt.toISOString(),
indexedAt: indexed.indexedAt.toISOString(),
file: { spaceId: group.spaceId, resourceId: resource.resourceId, version: resource.version },
shareLink: { url: resource.url, access: "members", expiresAt: null },
retention: group.retention,
status: "available",
},
};
}
}