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 = 2 * 1024 * 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 (≤2 GiB, 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): Promise { 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 { 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 { 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 { 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 { 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 { 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> { 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 { 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(); } }