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,148 @@
import { BadRequestException, Body, Controller, Delete, Get, HttpCode, Inject, Param, ParseUUIDPipe, Post, Query, Req, Res, UseGuards } from "@nestjs/common";
import type { Request, Response } from "express";
import { RbacGuard, RequirePermission, type AuthenticatedRequest } from "../rbac/rbac.guard.js";
import { requireObject, requirePositiveInt, requireSession, requireString } from "../http/validation.js";
import { GroupFilesService, type FilePage } from "./group-files.service.js";
import type { GroupFile } from "@tactical-media-broadcast-hub/core";
import { verifyWhileStreaming } from "./ocis-webdav.js";
/**
* Group files API — the Telegram-like group file surface.
*
* GET /files list/search, keyset (groupId, senderMxid,
* mimeClass, name, from, to, cursor, limit)
* GET /files/:id one GroupFile
* GET /files/:id/download stream payload (sha256 re-verified)
* POST /files/:id/retention set retention (keep/until/legalHold)
* POST /files/:id/revoke revoke links + stop serving
* POST /files/:id/shares mint a share link (token shown ONCE)
* DELETE /files/shares/:shareId revoke a share
*
* Authenticated routes gate on media.view (read) / media.manage (mutations) —
* the same media permission family the video library uses. ACLs are
* re-resolved per request in the service (current group membership), never
* carried by the token.
*
* The download route streams: the API proxies the payload with the digest
* verified chunk-by-chunk instead of redirecting or presigning, per the
* "never presigned object URLs" contract.
*/
@Controller("files")
@UseGuards(RbacGuard)
export class GroupFilesController {
constructor(@Inject(GroupFilesService) private readonly files: GroupFilesService) {}
@Get()
@RequirePermission("media.view")
async list(@Req() request: Request & AuthenticatedRequest, @Query() query: Record<string, unknown>): Promise<FilePage> {
const session = requireSession(request);
return this.files.listFiles(session.workspaceId, session.userId, session.role, query);
}
@Get(":id")
@RequirePermission("media.view")
async get(@Req() request: Request & AuthenticatedRequest, @Param("id", ParseUUIDPipe) id: string): Promise<GroupFile> {
const session = requireSession(request);
return this.files.getFile(session.workspaceId, session.userId, session.role, id);
}
@Get(":id/download")
@RequirePermission("media.view")
async download(
@Req() request: Request & AuthenticatedRequest,
@Param("id", ParseUUIDPipe) id: string,
@Res() response: Response,
): Promise<void> {
const session = requireSession(request);
const handle = await this.files.openDownload(session.workspaceId, session.userId, session.role, id);
response.status(200);
response.setHeader("Content-Type", handle.item.mimeType);
response.setHeader("Content-Disposition", `attachment; filename="${encodeURIComponent(handle.item.name)}"`);
response.setHeader("Cache-Control", "no-store");
if (handle.download.sizeBytes !== null) response.setHeader("Content-Length", String(handle.download.sizeBytes));
response.flushHeaders();
await verifyWhileStreaming(handle.download, handle.item.checksum.value, (chunk) => {
response.write(chunk);
});
response.end();
}
@Post(":id/retention")
@RequirePermission("media.manage")
async setRetention(
@Req() request: Request & AuthenticatedRequest,
@Param("id", ParseUUIDPipe) id: string,
@Body() body: unknown,
): Promise<GroupFile> {
const session = requireSession(request);
return this.files.setRetention(session.workspaceId, id, requireObject(body, "body"), {
userId: session.userId,
role: session.role,
});
}
@Post(":id/revoke")
@HttpCode(200)
@RequirePermission("media.manage")
async revoke(@Req() request: Request & AuthenticatedRequest, @Param("id", ParseUUIDPipe) id: string): Promise<GroupFile> {
const session = requireSession(request);
return this.files.markRevoked(session.workspaceId, id, { userId: session.userId, role: session.role });
}
@Post(":id/shares")
@RequirePermission("media.manage")
async createShare(
@Req() request: Request & AuthenticatedRequest,
@Param("id", ParseUUIDPipe) id: string,
@Body() body: unknown,
): Promise<ReturnType<GroupFilesService["createShare"]>> {
const session = requireSession(request);
const dto = requireObject(body, "body");
if (dto.access !== undefined && dto.access !== "members" && dto.access !== "public") {
throw new BadRequestException("access must be 'members' or 'public'");
}
const expiresInHours = requirePositiveInt(dto.expiresInHours, "expiresInHours", 720);
return this.files.createShare(
session.workspaceId,
id,
{ access: dto.access, expiresInHours },
{ userId: session.userId, role: session.role },
);
}
/** Declared before :id routes so the literal path wins route matching. */
@Delete("shares/:shareId")
@RequirePermission("media.manage")
async revokeShare(
@Req() request: Request & AuthenticatedRequest,
@Param("shareId", ParseUUIDPipe) shareId: string,
): Promise<{ ok: true }> {
const session = requireSession(request);
await this.files.revokeShare(session.workspaceId, shareId, { userId: session.userId, role: session.role });
return { ok: true };
}
}
/**
* Public share-claim surface: NO RbacGuard (the claimer holds only the
* token). Token lives in the path — same shape as the invite claim routes.
*/
@Controller("public/files")
export class PublicFileShareController {
constructor(@Inject(GroupFilesService) private readonly files: GroupFilesService) {}
@Get("s/:token")
async claim(@Param("token") token: string, @Res() response: Response): Promise<void> {
response.setHeader("Cache-Control", "no-store");
const handle = await this.files.claimPublicDownload(requireString(token, "token"));
response.status(200);
response.setHeader("Content-Type", handle.item.mimeType);
response.setHeader("Content-Disposition", `attachment; filename="${encodeURIComponent(handle.item.name)}"`);
if (handle.download.sizeBytes !== null) response.setHeader("Content-Length", String(handle.download.sizeBytes));
response.flushHeaders();
await verifyWhileStreaming(handle.download, handle.item.checksum.value, (chunk) => {
response.write(chunk);
});
response.end();
}
}