mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-27 00:52:50 +00:00
81 lines
3.0 KiB
TypeScript
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);
|
|
}
|
|
}
|