Files
edr-platform/apps/edr-freight-api/src/modules/files/files.controller.ts
Nathnael 0114673120 feat(auth): gate and regate freight API controllers
Gates the previously open support-agent, procurement, compliance,
facilities, list-users and trade-access controllers, separates customer
from staff routes across bookings, contracts, companies, billing,
warehouses, files and train scheduling, and moves billing, overview,
reports and the settings controllers onto their own keys instead of the
blanket admin key. Drops the demo-permissions module and the untested
notification test route.
2026-08-07 07:32:25 +00:00

89 lines
3.4 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 { MixedAudience } from "../../common/booking-guards";
import { FilesService } from "./files.service";
@ApiTags("files")
@ApiBearerAuth()
@Controller("files")
export class FilesController {
constructor(private readonly filesService: FilesService) {}
@Get(":fileId")
@MixedAudience([])
// 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,
) {
// Includes soft-deleted records: a superseded document (replaced via a
// single-file document slot, or resolved as part of a license/PoA swap)
// is only reachable by UUID through the change-request/version-history
// diff, where reviewers need to open the "previous" file to compare it.
const record = await this.filesService.findByIdIncludingDeleted(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, {
includeDeleted: true,
});
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);
}
}