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 { MixedAudience } from "../../common/booking-guards"; import { AuthUserPayload, resolveAuthUserId, } from "../../common/resolve-auth-user-id"; import { FREIGHT_PERMS } from "../../seed/freight-permissions.registry"; 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 `` 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") @MixedAudience(FREIGHT_PERMS.support.agentView) @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); } }