mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-30 01:48:12 +00:00
feat: setup attachment to the freight chat
This commit is contained in:
@@ -0,0 +1,100 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user