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:
@@ -183,6 +183,18 @@ export class IssueResolver {
|
||||
);
|
||||
}
|
||||
|
||||
@Mutation(() => CommandResultGql, {
|
||||
description:
|
||||
'Remove a file from a ticket (whoever uploaded it, or an owner/admin). Upload with the REST API.',
|
||||
})
|
||||
removeAttachment(
|
||||
@CurrentUser() me: User,
|
||||
@Args('issueId', { type: () => ID }) issueId: string,
|
||||
@Args('attachmentId', { type: () => ID }) attachmentId: string,
|
||||
) {
|
||||
return this.issuesService.detach(me.id, issueId, attachmentId);
|
||||
}
|
||||
|
||||
@Mutation(() => CommandResultGql, {
|
||||
description: 'Assign (or unassign with null) a project member',
|
||||
})
|
||||
|
||||
@@ -91,6 +91,24 @@ export class IssueLinkGql {
|
||||
@Field() issueKey!: string;
|
||||
}
|
||||
|
||||
@ObjectType('Attachment', {
|
||||
description:
|
||||
'A file on a ticket. Download it from the REST API: GET /api/issues/{issueId}/attachments/{id}',
|
||||
})
|
||||
export class AttachmentGql {
|
||||
@Field(() => ID) id!: string;
|
||||
@Field() fileName!: string;
|
||||
@Field() mimeType!: string;
|
||||
@Field(() => Int, { description: 'Bytes' }) size!: number;
|
||||
@Field(() => UserRefGql) uploadedBy!: UserRefGql;
|
||||
@Field() uploadedAt!: string;
|
||||
@Field(() => ID, {
|
||||
nullable: true,
|
||||
description: 'The comment it was attached to, if any',
|
||||
})
|
||||
commentId!: string | null;
|
||||
}
|
||||
|
||||
@ObjectType('ReleaseRef')
|
||||
export class ReleaseRefGql {
|
||||
@Field(() => ID) id!: string;
|
||||
@@ -123,6 +141,7 @@ export class IssueGql {
|
||||
@Field(() => Int, { nullable: true }) storyPoints!: number | null;
|
||||
@Field(() => String, { nullable: true }) dueDate!: string | null;
|
||||
@Field(() => [IssueLinkGql]) links!: IssueLinkGql[];
|
||||
@Field(() => [AttachmentGql]) attachments!: AttachmentGql[];
|
||||
@Field(() => Int) commentCount!: number;
|
||||
@Field() createdAt!: string;
|
||||
@Field() updatedAt!: string;
|
||||
|
||||
@@ -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, '_');
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
import { DynamicModule, Module, Type } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { MulterModule } from '@nestjs/platform-express';
|
||||
import './graphql/enums';
|
||||
import { CommentResolver } from './graphql/comment.resolver';
|
||||
import { IssueResolver } from './graphql/issue.resolver';
|
||||
import { ProjectResolver } from './graphql/project.resolver';
|
||||
import { ReleaseResolver } from './graphql/release.resolver';
|
||||
import { SubscriptionResolver } from './graphql/subscription.resolver';
|
||||
import { AttachmentsController } from './http/attachments.controller';
|
||||
import { CommentsController } from './http/comments.controller';
|
||||
import { IssuesController } from './http/issues.controller';
|
||||
import { ExploreController } from './http/explore.controller';
|
||||
@@ -18,9 +21,24 @@ import { ReleasesController } from './http/releases.controller';
|
||||
* application services.
|
||||
*/
|
||||
@Module({
|
||||
imports: [
|
||||
// Uploads stay in memory until the command stores them; the size limit
|
||||
// is enforced here, before a byte reaches the application.
|
||||
MulterModule.registerAsync({
|
||||
inject: [ConfigService],
|
||||
useFactory: (config: ConfigService) => ({
|
||||
limits: {
|
||||
fileSize:
|
||||
(config.get<number>('ATTACHMENTS_MAX_MB') ?? 25) * 1024 * 1024,
|
||||
files: 1,
|
||||
},
|
||||
}),
|
||||
}),
|
||||
],
|
||||
controllers: [
|
||||
ProjectsController,
|
||||
IssuesController,
|
||||
AttachmentsController,
|
||||
CommentsController,
|
||||
ReleasesController,
|
||||
ExploreController,
|
||||
@@ -38,6 +56,9 @@ export class TrackerPresentersModule {
|
||||
static withApplication(
|
||||
applicationModule: Type | DynamicModule,
|
||||
): DynamicModule {
|
||||
return { module: TrackerPresentersModule, imports: [applicationModule] };
|
||||
return {
|
||||
module: TrackerPresentersModule,
|
||||
imports: [applicationModule],
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user