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