T-HUB/apps/api/src/group-files/group-files.controller.ts
glass beadian 7ea0b1e137 Phase two: archive origin facet, redaction-aware reads, share revocation on redaction
- group-files: origin filter facet; GET /files/:id returns 410 for unavailable
  rows after the ACL recheck; fix latent group_members.status filter and unbound
  workspace parameter that would 500 listFiles
- archive-bot: markRedacted revokes live group_file_shares rows in-transaction
- new live-DB integration suite for the archive index surface
2026-10-11 21:44:23 +00:00

178 lines
7.5 KiB
TypeScript

import { BadRequestException, Body, Controller, Delete, Get, HttpCode, Inject, Param, ParseUUIDPipe, Post, Query, Req, Res, UploadedFile, UseGuards, UseInterceptors } from "@nestjs/common";
import { FileInterceptor } from "@nestjs/platform-express";
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";
const MAX_FILE_UPLOAD_BYTES = 20 * 1024 * 1024;
interface FileUpload {
buffer: Buffer;
mimetype: string;
originalname: string;
}
/**
* Group files API — the Telegram-like group file surface.
*
* GET /files list/search, keyset (groupId, senderMxid,
* mimeClass, origin, name, from, to, cursor, limit)
* POST /files/groups/:groupId/upload direct web upload (≤20 MiB, multipart)
* 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);
}
@Post("groups/:groupId/upload")
@RequirePermission("media.view")
@UseInterceptors(FileInterceptor("file", {
limits: { fileSize: MAX_FILE_UPLOAD_BYTES, files: 1, fields: 3, fieldSize: 1024, parts: 4 },
}))
async upload(
@Req() request: Request & AuthenticatedRequest,
@Param("groupId", ParseUUIDPipe) groupId: string,
@UploadedFile() file?: FileUpload,
): Promise<GroupFile> {
const session = requireSession(request);
if (file === undefined) throw new BadRequestException("a file is required (field: file)");
return this.files.uploadForGroup(
session.workspaceId,
groupId,
{ userId: session.userId, role: session.role },
{ buffer: file.buffer, mimetype: file.mimetype, originalName: file.originalname },
);
}
@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();
}
}