feat: attachments on tickets and comments

Files are uploaded over REST, stored on disk (a volume in Docker) and
recorded on the ticket as events; the bytes are written first, so no
attachment ever points at a missing file. Only images, video, audio, PDF
and plain text are shown inline, everything with nosniff and a sandbox CSP.
The web shows thumbnails, uploads by drag and drop or by pasting a
screenshot, and attaches files to new comments.

Also adds `nx run api:contracts`, which writes schema.gql and
openapi.json without starting the API or any database.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-08 08:35:33 +02:00
parent 80d9bee6e0
commit 0b65c1c912
72 changed files with 6766 additions and 4297 deletions

View File

@@ -0,0 +1,158 @@
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 { 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: CommandResultDto })
@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<StreamableFile> {
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, '_');
}

View File

@@ -22,6 +22,7 @@ import {
IsInt,
IsISO8601,
IsOptional,
IsUUID,
IsString,
IsUrl,
Matches,
@@ -576,6 +577,20 @@ export class LinkTypeParamDto {
type!: IssueLinkType;
}
/** Multipart body of an upload; the file itself travels as `file`. */
export class AttachFileDto {
@ApiProperty({ type: 'string', format: 'binary', description: 'The file' })
file?: unknown;
@ApiPropertyOptional({
format: 'uuid',
description: "One of the ticket's comments, to show the file under it",
})
@IsOptional()
@IsUUID()
commentId?: string;
}
export class CommentBodyDto {
@ApiProperty({ example: 'Reproducido en Safari 17.', maxLength: 20000 })
@IsString()

View File

@@ -106,6 +106,22 @@ export class ReleaseRefDto {
@ApiProperty({ example: 'v1.4.0' }) name!: string;
}
export class AttachmentDto {
@ApiProperty({ format: 'uuid' }) id!: string;
@ApiProperty({ example: 'captura-login.png' }) fileName!: string;
@ApiProperty({ example: 'image/png' }) mimeType!: string;
@ApiProperty({ description: 'Bytes', example: 48213 }) size!: number;
@ApiProperty({ type: UserRefDto }) uploadedBy!: UserRefDto;
@ApiProperty({ format: 'date-time' }) uploadedAt!: string;
@ApiProperty({
type: String,
nullable: true,
format: 'uuid',
description: 'The comment it was attached to, if any',
})
commentId!: string | null;
}
export class IssueDto {
@ApiProperty({ format: 'uuid' }) id!: string;
@ApiProperty({ format: 'uuid' }) projectId!: string;
@@ -141,6 +157,7 @@ export class IssueDto {
@ApiProperty({ type: String, nullable: true, format: 'date' })
dueDate!: string | null;
@ApiProperty({ type: [IssueLinkDto] }) links!: IssueLinkDto[];
@ApiProperty({ type: [AttachmentDto] }) attachments!: AttachmentDto[];
@ApiProperty() commentCount!: number;
@ApiProperty({ format: 'date-time' }) createdAt!: string;
@ApiProperty({ format: 'date-time' }) updatedAt!: string;