Files
edr-platform/apps/edr-freight-api/src/modules/files/files.controller.ts
2026-07-20 11:41:57 +00:00

81 lines
3.0 KiB
TypeScript

import { SUPPORT_ATTACHMENT_RESOURCE } from "@edr/types";
import {
Controller,
ForbiddenException,
Get,
Param,
ParseUUIDPipe,
Query,
Res,
} from "@nestjs/common";
import {
ApiBearerAuth,
ApiOperation,
ApiQuery,
ApiTags,
} from "@nestjs/swagger";
import { Response } from "express";
import { FilesService } from "./files.service";
@ApiTags("files")
@ApiBearerAuth()
@Controller("files")
export class FilesController {
constructor(private readonly filesService: FilesService) {}
@Get(":fileId")
// Authenticated: no @Public, so the global JwtGuard applies. Unguessable file
// UUIDs are obscurity, not authorization — raw byte streams must require auth.
// Browser inline previews (<img>/<iframe>/<a>) that can't carry the Bearer
// token should use a short-lived signed URL instead (FilesService.signUrl).
// TODO: enforce ownership-by-resource here next (scope the file to the
// caller's booking/company before streaming). Until that lands, any resource
// whose files are cross-tenant sensitive must opt OUT of this route and expose
// its own checked endpoint — see the support_message case below.
@ApiOperation({
summary: "Stream a file by ID",
description:
"Global endpoint — streams any uploaded file directly from MinIO by its UUID. " +
"No resource context (e.g. booking ID) required. Serves inline by default so " +
"the browser can preview it; pass ?download=1 to force a download. " +
"Support-chat attachments are NOT served here — use GET /support/attachments/:fileId.",
})
@ApiQuery({
name: "download",
required: false,
description: "Set to 1/true to force a download instead of inline preview.",
})
async download(
@Param("fileId", ParseUUIDPipe) fileId: string,
@Query("download") download: string | undefined,
@Res() res: Response,
) {
const record = await this.filesService.findById(fileId);
// Chat attachments are cross-tenant sensitive and this route has no
// ownership check, so a leaked/guessed UUID would hand one company's file to
// another. SupportAttachmentController scopes the caller to the owning
// thread; refuse here rather than quietly serving the bytes.
if (record.resource === SUPPORT_ATTACHMENT_RESOURCE) {
throw new ForbiddenException(
"Support chat attachments must be fetched via GET /support/attachments/:fileId.",
);
}
const { stream } = await this.filesService.streamById(fileId);
const forceDownload = download === "1" || download === "true";
const disposition = forceDownload ? "attachment" : "inline";
res.setHeader("Content-Type", record.mimeType);
res.setHeader(
"Content-Disposition",
`${disposition}; filename="${record.name}"`,
);
// Allow the browser to cache the streamed bytes briefly for smoother
// in-page previews (re-opening the viewer shouldn't re-hit MinIO).
res.setHeader("Cache-Control", "private, max-age=300");
stream.pipe(res);
}
}