import { Body, Controller, Delete, Get, Param, Post, Res, StreamableFile, UploadedFile, UseInterceptors, } from '@nestjs/common'; import { FileInterceptor } from '@nestjs/platform-express'; import { ApiBody, ApiConsumes, ApiCreatedResponse, ApiOkResponse, ApiOperation, ApiParam, ApiProduces, ApiTags, } from '@nestjs/swagger'; import { User } from '@task-stream/api/identity/domain'; import { ApiOptionalPireonAuth, ApiPireonAuth, CurrentUser, } from '@task-stream/api/identity/presenters'; import { ApiCommandErrors, ApiReadErrors, } from '@task-stream/api/shared/presenters'; import { ValidationError } from '@task-stream/api/shared/domain'; import { IssuesService } from '@task-stream/api/tracker/application'; import type { Response } from 'express'; import { viewerOf } from '../common/viewer'; import { AttachFileDto } from './dto/requests.dto'; import { AttachmentResultDto, CommandResultDto } from './dto/responses.dto'; import { Refs } from './refs'; const ISSUE = ApiParam({ name: 'issue', description: 'Ticket id or key', example: 'WEB-12', }); /** What multer hands over for one uploaded field. */ interface UploadedPart { originalname: string; mimetype: string; buffer: Buffer; } /** * Types a browser may show in place. Anything else — HTML, SVG, scripts — is * always downloaded: a file someone uploaded must never run in this origin. */ const SHOWN_INLINE = /^(image\/(png|jpe?g|gif|webp|avif)|video\/(mp4|webm)|audio\/(mpeg|ogg|wav)|application\/pdf|text\/plain)$/; /** REST adapter for a ticket's files: uploaded here, listed on the ticket. */ @ApiTags('Issues') @ApiPireonAuth() @Controller('issues/:issue/attachments') export class AttachmentsController { constructor( private readonly issues: IssuesService, private readonly refs: Refs, ) {} @Post() @ISSUE @UseInterceptors(FileInterceptor('file')) @ApiConsumes('multipart/form-data') @ApiBody({ type: AttachFileDto }) @ApiOperation({ summary: 'Attach a file to a ticket (members)', description: 'Multipart with the file as `file` and, optionally, `commentId` to place it under a comment. ' + 'Up to ATTACHMENTS_MAX_MB megabytes (25 by default) and 50 files per ticket.', }) @ApiCreatedResponse({ type: AttachmentResultDto }) @ApiCommandErrors() async attach( @CurrentUser() me: User, @Param('issue') issue: string, @UploadedFile() file: UploadedPart | undefined, @Body() body: AttachFileDto, ) { if (!file) throw new ValidationError('Send the file as `file`'); return this.issues.attach( me.id, await this.refs.issueId(me.id, issue), { fileName: Buffer.from(file.originalname, 'latin1').toString('utf8'), mimeType: file.mimetype, content: file.buffer, }, body.commentId || null, ); } @Get(':attachmentId') @ApiOptionalPireonAuth() @ISSUE @ApiOperation({ summary: "Download one of a ticket's files" }) @ApiProduces('application/octet-stream') @ApiOkResponse({ description: 'The file' }) @ApiReadErrors() async download( @CurrentUser() me: User | undefined, @Param('issue') issue: string, @Param('attachmentId') attachmentId: string, @Res({ passthrough: true }) res: Response, ): Promise { const viewer = viewerOf(me); const { attachment, content } = await this.issues.download( viewer, await this.refs.issueId(viewer, issue), attachmentId, ); const inline = SHOWN_INLINE.test(attachment.mimeType); res.set({ 'Content-Type': inline ? attachment.mimeType : 'application/octet-stream', 'Content-Length': String(attachment.size), 'Content-Disposition': `${inline ? 'inline' : 'attachment'}; filename="${asciiName(attachment.fileName)}"; filename*=UTF-8''${encodeURIComponent(attachment.fileName)}`, 'X-Content-Type-Options': 'nosniff', 'Content-Security-Policy': "default-src 'none'; sandbox", 'Cache-Control': 'private, max-age=3600', }); return new StreamableFile(content); } @Delete(':attachmentId') @ISSUE @ApiOperation({ summary: 'Remove a file (whoever uploaded it, or an owner/admin)', }) @ApiOkResponse({ type: CommandResultDto }) @ApiCommandErrors() async remove( @CurrentUser() me: User, @Param('issue') issue: string, @Param('attachmentId') attachmentId: string, ) { return this.issues.detach( me.id, await this.refs.issueId(me.id, issue), attachmentId, ); } } /** The name with anything outside printable ASCII replaced, for old clients. */ function asciiName(name: string): string { return name.replace(/[^\x20-\x7e]|["\\]/g, '_'); }