mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-28 03:10:54 +00:00
101 lines
3.2 KiB
TypeScript
101 lines
3.2 KiB
TypeScript
import { CurrentUser } from "@edr/api-common";
|
|
import { SUPPORT_ATTACHMENT_RESOURCE } from "@edr/types";
|
|
import {
|
|
Controller,
|
|
ForbiddenException,
|
|
Get,
|
|
NotFoundException,
|
|
Param,
|
|
ParseUUIDPipe,
|
|
Query,
|
|
Res,
|
|
} from "@nestjs/common";
|
|
import {
|
|
ApiBearerAuth,
|
|
ApiOperation,
|
|
ApiQuery,
|
|
ApiTags,
|
|
} from "@nestjs/swagger";
|
|
import { Response } from "express";
|
|
|
|
import {
|
|
AuthUserPayload,
|
|
resolveAuthUserId,
|
|
} from "../../common/resolve-auth-user-id";
|
|
import { FilesService } from "../files/files.service";
|
|
import { SupportChatService } from "./support-chat.service";
|
|
|
|
/**
|
|
* Authenticated download for chat attachments.
|
|
*
|
|
* This exists instead of reusing `GET /files/:fileId` because that route streams
|
|
* any file to any authenticated caller who knows its UUID — fine-ish for a
|
|
* booking document the caller already had a link to, not fine for chat, where
|
|
* one customer guessing another's file id would be a cross-tenant leak. That
|
|
* route now refuses `support_message` files outright and points here.
|
|
*
|
|
* Inline previews still use the short-lived signed URL on the message DTO — a
|
|
* browser `<img>` can't send a Bearer token. This route is for explicit
|
|
* downloads and for clients that would rather stream through the API.
|
|
*/
|
|
@ApiTags("support-chat")
|
|
@ApiBearerAuth()
|
|
@Controller("support/attachments")
|
|
export class SupportAttachmentController {
|
|
constructor(
|
|
private readonly files: FilesService,
|
|
private readonly chat: SupportChatService,
|
|
) {}
|
|
|
|
@Get(":fileId")
|
|
@ApiOperation({
|
|
summary: "Download a support chat attachment",
|
|
description:
|
|
"Streams the file only if the caller is backoffice staff or belongs to the " +
|
|
"company that owns the thread the attachment was posted in.",
|
|
})
|
|
@ApiQuery({
|
|
name: "download",
|
|
required: false,
|
|
description: "Set to 1/true to force a download instead of inline preview.",
|
|
})
|
|
async download(
|
|
@CurrentUser() user: AuthUserPayload,
|
|
@Param("fileId", ParseUUIDPipe) fileId: string,
|
|
@Query("download") download: string | undefined,
|
|
@Res() res: Response,
|
|
) {
|
|
const record = await this.files.findById(fileId);
|
|
|
|
// Don't let this route become a second general-purpose file endpoint: it can
|
|
// only vouch for chat attachments, so anything else is a 404 (not a 403 —
|
|
// no reason to confirm the id exists).
|
|
if (record.resource !== SUPPORT_ATTACHMENT_RESOURCE) {
|
|
throw new NotFoundException(`File ${fileId} not found`);
|
|
}
|
|
|
|
const allowed = await this.chat.canUserAccessMessage(
|
|
record.resourceId,
|
|
resolveAuthUserId(user),
|
|
);
|
|
if (!allowed) {
|
|
throw new ForbiddenException(
|
|
"This attachment belongs to another company's conversation.",
|
|
);
|
|
}
|
|
|
|
const { stream } = await this.files.streamById(fileId);
|
|
const forceDownload = download === "1" || download === "true";
|
|
|
|
res.setHeader("Content-Type", record.mimeType);
|
|
res.setHeader(
|
|
"Content-Disposition",
|
|
`${forceDownload ? "attachment" : "inline"}; filename="${record.name}"`,
|
|
);
|
|
// Private only — this response is scoped to one caller's authorization, so a
|
|
// shared cache must never reuse it for the next person asking.
|
|
res.setHeader("Cache-Control", "private, max-age=300");
|
|
stream.pipe(res);
|
|
}
|
|
}
|