Files
TREK/server/src/nest/collab/collab.controller.ts
T
Maurice 885896aba2 feat(files): FILE_UPLOAD_LIMIT_MB sets the upload limit for files and notes
The 50 MB limit for files, booking attachments and note attachments becomes FILE_UPLOAD_LIMIT_MB, 50 by default. The server hands it to the client through the app config, so the upload dialogs refuse a file over it up front and say what the limit is; on desktop a file over it no longer vanishes without a word. (#1364)
2026-10-01 11:56:14 +02:00

452 lines
21 KiB
TypeScript

import {
Body,
Controller,
Delete,
Get,
Headers,
HttpCode,
HttpException,
Param,
Post,
Put,
Query,
UploadedFile,
UploadedFiles,
UseGuards,
UseInterceptors,
} from '@nestjs/common';
import { FileInterceptor, FilesInterceptor } from '@nestjs/platform-express';
import type { Options } from 'multer';
import path from 'path';
import fs from 'fs';
import type { User } from '../../types';
import { CollabService } from './collab.service';
import { StorageService } from '../storage/storage.service';
import {
CollabNoteCreateDto,
CollabNoteUpdateDto,
CollabPollCreateDto,
CollabPollVoteDto,
CollabLinkCreateDto,
CollabLinkUpdateDto,
CollabMessageCreateDto,
CollabReactionDto,
} from './collab.dto';
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
import { CurrentUser } from '../auth/current-user.decorator';
import { RequirePermission, TripAccessGuard } from '../permissions/trip-access.guard';
import { BLOCKED_EXTENSIONS, MAX_FILE_SIZE } from '../files/files.constants';
import { SpoolCleanupInterceptor } from '../common/spool-cleanup.interceptor';
// Note attachments follow the same limit as the file manager (#1364).
export const MAX_NOTE_FILE_SIZE = MAX_FILE_SIZE;
const MAX_CHAT_IMAGES = 4;
const CHAT_IMAGE_TYPES = new Set(['image/jpeg', 'image/png', 'image/gif', 'image/webp']);
const CHAT_IMAGE_EXTENSIONS = new Set(['.jpg', '.jpeg', '.png', '.gif', '.webp']);
/**
* The extension is checked as well as the type, and both have to agree.
*
* `file.mimetype` is the Content-Type the client put on the part, so it is a
* claim, not a fact. The stored name keeps the extension of the name the client
* sent (`storage-upload.factory.ts`), and the download route derives the served
* Content-Type from that extension and sends it inline. Believing the header
* alone therefore lets `pwn.html` through as `image/png` and serves it back as
* HTML on our own origin. This filter also fully replaces the module-level one
* on this route, so the blocked-extension list has to be applied here too.
*/
export const collabChatImageFilter: Options['fileFilter'] = (_req, file, cb) => {
const reject = (message: string) => {
const err: Error & { statusCode?: number } = new Error(message);
err.statusCode = 400;
return cb(err);
};
if (!CHAT_IMAGE_TYPES.has(file.mimetype)) {
return reject('Only JPEG, PNG, GIF, and WebP images are allowed');
}
const ext = path.extname(file.originalname).toLowerCase();
if (BLOCKED_EXTENSIONS.includes(ext) || !CHAT_IMAGE_EXTENSIONS.has(ext)) {
return reject('Only JPEG, PNG, GIF, and WebP images are allowed');
}
cb(null, true);
};
// Consumed by collab.module.ts's MulterModule factory; the rest of the multer
// options (spool destination, filename, limits) come from the storage upload
// factory.
export const collabNoteFileFilter: Options['fileFilter'] = (_req, file, cb) => {
const ext = path.extname(file.originalname).toLowerCase();
if (BLOCKED_EXTENSIONS.includes(ext) || file.mimetype.includes('svg') || file.mimetype.includes('html') || file.mimetype.includes('javascript')) {
const err: Error & { statusCode?: number } = new Error('File type not allowed');
err.statusCode = 400;
return cb(err);
}
cb(null, true);
};
/**
* /api/trips/:tripId/collab — shared notes, polls, chat (+ reactions), link
* previews. WebSocket-backed group collaboration.
*
* Byte-identical to the legacy Express route (server/src/routes/collab.ts): trip
* access (404), 'collab_edit' (403) on mutations + 'file_upload' on note files,
* create 201 / rest 200 (vote + react POST stay 200), the bespoke 400/403/404
* bodies, the chat/note notifications, and all WebSocket broadcasts with the
* forwarded X-Socket-Id. Bodies validate against the @trek/shared collab
* schemas via the DTO classes in collab.dto.ts + the global ZodValidationPipe
* (400 with the standard `{ error }` envelope on mismatch — this replaced the
* legacy bespoke 'Title is required' / 'Question is required' / '... 2 options
* ...' / 5000-char / 'Emoji is required' checks; the whitespace-only 'Message
* text is required' check stays, since min(1) doesn't trim). One deliberate
* deviation from the legacy route: link-preview now verifies trip access (404)
* like every sibling handler.
*/
@Controller('api/trips/:tripId/collab')
// TripAccessGuard is applied PER HANDLER, not on the class: the note-file upload
// route keeps its own check, because guards run before interceptors and a 404 sent
// while the client is still streaming the multipart body destroys the socket (the
// caller then sees ECONNRESET instead of the 404). Same as files.controller.ts.
@UseGuards(JwtAuthGuard)
export class CollabController {
constructor(
private readonly collab: CollabService,
private readonly storage: StorageService,
) {}
private requireTrip(tripId: string, user: User) {
const trip = this.collab.verifyTripAccess(tripId, user.id);
if (!trip) {
throw new HttpException({ error: 'Trip not found' }, 404);
}
return trip;
}
private requireEdit(trip: NonNullable<ReturnType<CollabService['verifyTripAccess']>>, user: User): void {
if (!this.collab.canEdit(trip, user)) {
throw new HttpException({ error: 'No permission' }, 403);
}
}
// ── Notes ───────────────────────────────────────────────────────────────
@UseGuards(TripAccessGuard)
@Get('notes')
listNotes(@CurrentUser() user: User, @Param('tripId') tripId: string) {
return { notes: this.collab.listNotes(tripId) };
}
@UseGuards(TripAccessGuard)
@RequirePermission('collab_edit')
@Post('notes')
createNote(@CurrentUser() user: User, @Param('tripId') tripId: string, @Body() body: CollabNoteCreateDto, @Headers('x-socket-id') socketId?: string) {
const note = this.collab.createNote(tripId, user.id, {
title: body.title,
content: body.content,
category: body.category,
color: body.color,
website: body.website,
});
this.collab.broadcast(tripId, 'collab:note:created', { note }, socketId);
this.collab.notifyCollab(tripId, user);
return { note };
}
@UseGuards(TripAccessGuard)
@RequirePermission('collab_edit')
@Put('notes/:id')
updateNote(@CurrentUser() user: User, @Param('tripId') tripId: string, @Param('id') id: string, @Body() body: CollabNoteUpdateDto, @Headers('x-socket-id') socketId?: string) {
const note = this.collab.updateNote(tripId, id, {
title: body.title,
content: body.content,
category: body.category,
color: body.color,
pinned: body.pinned,
website: body.website,
});
if (!note) {
throw new HttpException({ error: 'Note not found' }, 404);
}
this.collab.broadcast(tripId, 'collab:note:updated', { note }, socketId);
return { note };
}
@UseGuards(TripAccessGuard)
@RequirePermission('collab_edit')
@Delete('notes/:id')
async deleteNote(@CurrentUser() user: User, @Param('tripId') tripId: string, @Param('id') id: string, @Headers('x-socket-id') socketId?: string) {
if (!(await this.collab.deleteNote(tripId, id))) {
throw new HttpException({ error: 'Note not found' }, 404);
}
this.collab.broadcast(tripId, 'collab:note:deleted', { noteId: Number(id) }, socketId);
return { success: true };
}
@Post('notes/:id/files')
@UseInterceptors(FileInterceptor('file'))
async addNoteFile(@CurrentUser() user: User, @Param('tripId') tripId: string, @Param('id') id: string, @UploadedFile() file: Express.Multer.File | undefined, @Headers('x-socket-id') socketId?: string) {
// multer has already written the upload to the spool dir by the time any of
// these checks run, and nothing sweeps orphans, so every refusal takes the
// bytes back out, with the same closure the trip-file upload uses.
const cleanupSpool = () => {
if (file?.path) { try { fs.unlinkSync(file.path); } catch { /* best-effort */ } }
};
try {
const trip = this.requireTrip(tripId, user);
if (!this.collab.canUploadFiles(trip, user)) {
throw new HttpException({ error: 'No permission to upload files' }, 403);
}
} catch (err) {
cleanupSpool();
throw err;
}
if (!file) {
throw new HttpException({ error: 'No file uploaded' }, 400);
}
// Commit the spooled upload to its final storage location (atomic
// same-volume rename) before the DB row references the final path.
await this.storage.put('files', file.filename, { tmpPath: file.path });
const result = this.collab.addNoteFile(tripId, id, file);
if (!result) {
// Already committed, so the spool file is gone; drop the final object.
await this.storage.delete('files', file.filename).catch(() => {});
throw new HttpException({ error: 'Note not found' }, 404);
}
this.collab.broadcast(tripId, 'collab:note:updated', { note: this.collab.getFormattedNoteById(tripId, id) }, socketId);
return result;
}
@UseGuards(TripAccessGuard)
@RequirePermission('collab_edit')
@Delete('notes/:id/files/:fileId')
async deleteNoteFile(@CurrentUser() user: User, @Param('tripId') tripId: string, @Param('id') id: string, @Param('fileId') fileId: string, @Headers('x-socket-id') socketId?: string) {
if (!(await this.collab.deleteNoteFile(tripId, id, fileId))) {
throw new HttpException({ error: 'File not found' }, 404);
}
this.collab.broadcast(tripId, 'collab:note:updated', { note: this.collab.getFormattedNoteById(tripId, id) }, socketId);
return { success: true };
}
// ── Shared links ────────────────────────────────────────────────────────
@UseGuards(TripAccessGuard)
@Get('links')
listLinks(@CurrentUser() user: User, @Param('tripId') tripId: string) {
return { links: this.collab.listLinks(tripId) };
}
@UseGuards(TripAccessGuard)
@RequirePermission('collab_edit')
@Post('links')
createLink(@CurrentUser() user: User, @Param('tripId') tripId: string, @Body() body: CollabLinkCreateDto, @Headers('x-socket-id') socketId?: string) {
const link = this.collab.createLink(tripId, user.id, { title: body.title, url: body.url, pinned: Boolean(body.pinned) });
this.collab.broadcast(tripId, 'collab:link:created', { link }, socketId);
return { link };
}
@UseGuards(TripAccessGuard)
@RequirePermission('collab_edit')
@Put('links/:id')
updateLink(@CurrentUser() user: User, @Param('tripId') tripId: string, @Param('id') id: string, @Body() body: CollabLinkUpdateDto, @Headers('x-socket-id') socketId?: string) {
const link = this.collab.updateLink(tripId, id, {
title: body.title,
url: body.url,
pinned: body.pinned === undefined ? undefined : Boolean(body.pinned),
});
if (!link) throw new HttpException({ error: 'Link not found' }, 404);
this.collab.broadcast(tripId, 'collab:link:updated', { link }, socketId);
return { link };
}
@UseGuards(TripAccessGuard)
@RequirePermission('collab_edit')
@Delete('links/:id')
deleteLink(@CurrentUser() user: User, @Param('tripId') tripId: string, @Param('id') id: string, @Headers('x-socket-id') socketId?: string) {
if (!this.collab.deleteLink(tripId, id)) throw new HttpException({ error: 'Link not found' }, 404);
this.collab.broadcast(tripId, 'collab:link:deleted', { linkId: Number(id) }, socketId);
return { success: true };
}
// ── Polls ───────────────────────────────────────────────────────────────
@UseGuards(TripAccessGuard)
@Get('polls')
listPolls(@CurrentUser() user: User, @Param('tripId') tripId: string) {
return { polls: this.collab.listPolls(tripId) };
}
@UseGuards(TripAccessGuard)
@RequirePermission('collab_edit')
@Post('polls')
createPoll(@CurrentUser() user: User, @Param('tripId') tripId: string, @Body() body: CollabPollCreateDto, @Headers('x-socket-id') socketId?: string) {
const poll = this.collab.createPoll(tripId, user.id, {
question: body.question,
options: body.options,
multiple: body.multiple,
multiple_choice: body.multiple_choice,
deadline: body.deadline,
});
this.collab.broadcast(tripId, 'collab:poll:created', { poll }, socketId);
return { poll };
}
@UseGuards(TripAccessGuard)
@RequirePermission('collab_edit')
@Post('polls/:id/vote')
@HttpCode(200)
votePoll(@CurrentUser() user: User, @Param('tripId') tripId: string, @Param('id') id: string, @Body() body: CollabPollVoteDto, @Headers('x-socket-id') socketId?: string) {
const result = this.collab.votePoll(tripId, id, user.id, body.option_index);
if (result.error === 'not_found') throw new HttpException({ error: 'Poll not found' }, 404);
if (result.error === 'closed') throw new HttpException({ error: 'Poll is closed' }, 400);
if (result.error === 'invalid_index') throw new HttpException({ error: 'Invalid option index' }, 400);
this.collab.broadcast(tripId, 'collab:poll:voted', { poll: result.poll }, socketId);
return { poll: result.poll };
}
@UseGuards(TripAccessGuard)
@RequirePermission('collab_edit')
@Put('polls/:id/close')
closePoll(@CurrentUser() user: User, @Param('tripId') tripId: string, @Param('id') id: string, @Headers('x-socket-id') socketId?: string) {
const poll = this.collab.closePoll(tripId, id);
if (!poll) {
throw new HttpException({ error: 'Poll not found' }, 404);
}
this.collab.broadcast(tripId, 'collab:poll:closed', { poll }, socketId);
return { poll };
}
@UseGuards(TripAccessGuard)
@RequirePermission('collab_edit')
@Delete('polls/:id')
deletePoll(@CurrentUser() user: User, @Param('tripId') tripId: string, @Param('id') id: string, @Headers('x-socket-id') socketId?: string) {
if (!this.collab.deletePoll(tripId, id)) {
throw new HttpException({ error: 'Poll not found' }, 404);
}
this.collab.broadcast(tripId, 'collab:poll:deleted', { pollId: Number(id) }, socketId);
return { success: true };
}
// ── Messages ────────────────────────────────────────────────────────────
@UseGuards(TripAccessGuard)
@Get('messages')
listMessages(@CurrentUser() user: User, @Param('tripId') tripId: string, @Query('before') before?: string) {
return { messages: this.collab.listMessages(tripId, before) };
}
// No guard decorators, deliberately, like every other upload route in this
// file: guards run before the interceptor, so a 403 or 404 goes out while the
// client is still streaming the body and the socket dies as ECONNRESET
// instead of carrying the error envelope. Both checks happen in the handler.
@Post('messages')
// SpoolCleanupInterceptor sits after the file one on purpose: the Zod pipe
// runs when the handler's parameters are resolved, which is after both have
// been entered, so a rejected body would otherwise leave up to four spooled
// images on disk with nothing to sweep them.
@UseInterceptors(
FilesInterceptor('images', MAX_CHAT_IMAGES, { fileFilter: collabChatImageFilter, limits: { files: MAX_CHAT_IMAGES, fileSize: 10 * 1024 * 1024 } }),
SpoolCleanupInterceptor,
)
async createMessage(@CurrentUser() user: User, @Param('tripId') tripId: string, @Body() body: CollabMessageCreateDto, @UploadedFiles() files: Express.Multer.File[] | undefined, @Headers('x-socket-id') socketId?: string) {
const uploaded = files || [];
const cleanupSpool = () => uploaded.forEach(file => { if (file.path) { try { fs.unlinkSync(file.path); } catch { /* best-effort */ } } });
let trip;
try {
trip = this.requireTrip(tripId, user);
this.requireEdit(trip, user);
} catch (err) {
cleanupSpool();
throw err;
}
if (uploaded.length && !this.collab.canUploadFiles(trip, user)) {
cleanupSpool();
throw new HttpException({ error: 'No permission to upload files' }, 403);
}
const text = (body.text || '').trim();
if (!text && !uploaded.length) {
cleanupSpool();
// The old wording is kept for a request that carried no file part at all,
// because that is exactly what a client from before this route took
// images sends, and parity is on the bespoke strings too. A multipart
// request gets the wording that actually describes its options.
const wasMultipart = files !== undefined;
throw new HttpException({ error: wasMultipart ? 'Message text or image is required' : 'Message text is required' }, 400);
}
const committed: string[] = [];
try {
for (const file of uploaded) {
await this.storage.put('files', file.filename, { tmpPath: file.path });
committed.push(file.filename);
}
} catch (err) {
cleanupSpool();
for (const name of committed) await this.storage.delete('files', name).catch(() => {});
throw err;
}
const replyTo = body.reply_to === undefined || body.reply_to === '' || body.reply_to === null ? null : Number(body.reply_to);
const result = this.collab.createMessage(
tripId,
user.id,
text,
Number.isFinite(replyTo as number) ? replyTo : null,
uploaded.map(file => ({ filename: file.filename, originalname: file.originalname, size: file.size, mimetype: file.mimetype })),
);
if (result.error === 'reply_not_found') {
for (const name of committed) await this.storage.delete('files', name).catch(() => {});
throw new HttpException({ error: 'Reply target message not found' }, 400);
}
this.collab.broadcast(tripId, 'collab:message:created', { message: result.message }, socketId);
const preview = text || 'sent an image';
this.collab.notifyCollab(tripId, user, preview.length > 80 ? preview.substring(0, 80) + '...' : preview);
return { message: result.message };
}
@UseGuards(TripAccessGuard)
@RequirePermission('collab_edit')
@Post('messages/:id/react')
@HttpCode(200)
react(@CurrentUser() user: User, @Param('tripId') tripId: string, @Param('id') id: string, @Body() body: CollabReactionDto, @Headers('x-socket-id') socketId?: string) {
const result = this.collab.reactMessage(id, tripId, user.id, body.emoji);
if (!result.found) {
throw new HttpException({ error: 'Message not found' }, 404);
}
this.collab.broadcast(tripId, 'collab:message:reacted', { messageId: Number(id), reactions: result.reactions }, socketId);
return { reactions: result.reactions };
}
@UseGuards(TripAccessGuard)
@RequirePermission('collab_edit')
@Delete('messages/:id')
deleteMessage(@CurrentUser() user: User, @Param('tripId') tripId: string, @Param('id') id: string, @Headers('x-socket-id') socketId?: string) {
const result = this.collab.deleteMessage(tripId, id, user.id);
if (result.error === 'not_found') throw new HttpException({ error: 'Message not found' }, 404);
if (result.error === 'not_owner') throw new HttpException({ error: 'You can only delete your own messages' }, 403);
this.collab.broadcast(tripId, 'collab:message:deleted', { messageId: Number(id), username: result.username || user.username }, socketId);
return { success: true };
}
// ── Link preview ──────────────────────────────────────────────────────────
// Deliberately no @RequirePermission: this is a read path. The client asks for
// a preview while *rendering* a message or a note (CollabChatMessages,
// CollabNotesCard), never while composing one, so it belongs with the GET
// siblings above — requiring 'collab_edit' would strip every preview from a
// trip whose owner has narrowed that right, for people who may still read the
// chat. What keeps the fetcher from being a free outbound proxy is the budget
// inside the service, not a write permission.
@UseGuards(TripAccessGuard)
@Get('link-preview')
async linkPreview(@CurrentUser() user: User, @Param('tripId') tripId: string, @Query('url') url?: string) {
// Unlike the legacy route, this verifies trip access — any authed user
// could otherwise drive the SSRF-guarded fetcher through arbitrary trip URLs.
if (!url) {
throw new HttpException({ error: 'URL is required' }, 400);
}
try {
const preview = await this.collab.linkPreview(url, user.id);
if (preview.rateLimited) {
throw new HttpException({ error: 'Too many requests' }, 429);
}
if (preview.error) {
throw new HttpException({ error: preview.error }, 400);
}
return preview;
} catch (err) {
if (err instanceof HttpException) throw err;
return { title: null, description: null, image: null, url };
}
}
}