Formatted text
- Descriptions, comments, replies and release notes render Markdown,
sanitised with DOMPurify (tested against scripts, handlers and
javascript: links). A pasted image is uploaded and written as
; the web fetches it with the reader's session,
so it works on private projects. Every text box has a preview. The
attachment upload now answers its attachmentId.
List view
- /p/:key/list: every ticket as rows, sortable by any column, filtered by
text, open/closed, type, priority and assignee; status, priority and
assignee change in place; bulk edit (the backlog's bar, now shared).
Ticket details
- Reactions on comments (GitHub's eight), for members and viewers.
- Duplicate a ticket: what it says, not where it stands.
- blockedBy: open tickets it waits for (BLOCKS / DEPENDS_ON), worked out
when reading with one query per board or page; shown on cards, list
rows and the ticket.
Public proposals
- A public project can take bug reports and ideas from anyone
(acceptsProposals): POST /api/explore/projects/{project}/proposals and
the form at /p/:key/proponer. They become tickets labelled «propuesta»,
opened by TaskStream; a honeypot field and 5 per address every 15
minutes keep spam out. No e-mail is asked: it would be public.
Votes
- Anyone signed in votes on a public project's tickets, members on a
private one (voteIssue, PUT/DELETE /api/issues/{issue}/vote): votes and
voted on each ticket, buttons on the ticket and the roadmap, a column
in the list. The showcase carries votes and the open proposals.
MCP: vote_issue, duplicate_issue, react_to_comment; listings show
blockers and votes. Docs, Word and PDF updated; chat notifications
(Discord, Slack, Telegram) noted for later.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
159 lines
4.6 KiB
TypeScript
159 lines
4.6 KiB
TypeScript
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<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, '_');
|
|
}
|