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
148
apps/api/src/group-files/group-files.controller.ts
Normal file
148
apps/api/src/group-files/group-files.controller.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue