T-HUB/apps/archive-bot/src/archiver.ts
glass beadian d58db63fe6 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.
2026-10-11 08:21:11 +00:00

90 lines
3.3 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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