- 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
178 lines
7.5 KiB
TypeScript
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();
|
|
}
|
|
}
|