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.
90 lines
3.3 KiB
TypeScript
90 lines
3.3 KiB
TypeScript
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",
|
||
},
|
||
};
|
||
}
|
||
}
|