import type { User } from '../../types'; import { RuntimeEnvService } from '../app-config/runtime-env.service'; import { CurrentUser } from '../auth-core/current-user.decorator'; import { JwtAuthGuard } from '../auth-core/jwt-auth.guard'; import { DEMO_WRITE_ERROR, isDemoWriteBlocked } from '../common/demo-write'; import { ResponseContract } from '../common/response-contract'; import { SpoolCleanupInterceptor } from '../common/spool-cleanup.interceptor'; import { BLOCKED_EXTENSIONS, MAX_FILE_SIZE } from '../files/files.constants'; import { RequirePermission, TripAccessGuard } from '../permissions/trip-access.guard'; import { StorageService } from '../storage/storage.service'; import { CollabNoteCreateDto, CollabNoteUpdateDto, CollabPollCreateDto, CollabPollVoteDto, CollabLinkCreateDto, CollabLinkUpdateDto, CollabMessageCreateDto, CollabReactionDto, } from './collab.dto'; import { CollabService } from './collab.service'; 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 { collabLinkPreviewResponseSchema, collabLinkResponseSchema, collabLinksResponseSchema, collabMessageResponseSchema, collabMessagesResponseSchema, collabNoteFileResponseSchema, collabNoteResponseSchema, collabNotesResponseSchema, collabPollResponseSchema, collabPollsResponseSchema, collabReactionsResponseSchema, successResponseSchema, } from '@trek/shared'; import fs from 'fs'; import type { Options } from 'multer'; import path from 'path'; // 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, // Whether DEMO_MODE is on, read on every upload: the demo account's are refused. private readonly env: RuntimeEnvService, ) {} private async requireTrip(tripId: string, user: User) { const trip = await this.collab.verifyTripAccess(tripId, user.id); if (!trip) { throw new HttpException({ error: 'Trip not found' }, 404); } return trip; } private async requireEdit( trip: NonNullable>>, user: User, ): Promise { if (!(await this.collab.canEdit(trip, user))) { throw new HttpException({ error: 'No permission' }, 403); } } // ── Notes ─────────────────────────────────────────────────────────────── @UseGuards(TripAccessGuard) @Get('notes') @ResponseContract(collabNotesResponseSchema) async listNotes(@CurrentUser() user: User, @Param('tripId') tripId: string) { return { notes: await this.collab.listNotes(tripId) }; } @UseGuards(TripAccessGuard) @RequirePermission('collab_edit') @Post('notes') @ResponseContract(collabNoteResponseSchema) async createNote( @CurrentUser() user: User, @Param('tripId') tripId: string, @Body() body: CollabNoteCreateDto, @Headers('x-socket-id') socketId?: string, ) { const note = await this.collab.postNote( tripId, { title: body.title, content: body.content, category: body.category, color: body.color, website: body.website }, this.collab.restWriter(tripId, user, socketId), ); return { note }; } @UseGuards(TripAccessGuard) @RequirePermission('collab_edit') @Put('notes/:id') @ResponseContract(collabNoteResponseSchema) async updateNote( @CurrentUser() user: User, @Param('tripId') tripId: string, @Param('id') id: string, @Body() body: CollabNoteUpdateDto, @Headers('x-socket-id') socketId?: string, ) { const note = await 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') @ResponseContract(successResponseSchema) 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')) @ResponseContract(collabNoteFileResponseSchema) 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 = await this.requireTrip(tripId, user); // After the 404, as on the trip-file upload, so the demo account cannot // learn which trips exist; refused like every other upload of it // (common/demo-write.ts). if (isDemoWriteBlocked(this.env, user.email)) { throw new HttpException(DEMO_WRITE_ERROR, 403); } if (!(await 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 = await 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: await this.collab.getFormattedNoteById(tripId, id) }, socketId, ); return result; } @UseGuards(TripAccessGuard) @RequirePermission('collab_edit') @Delete('notes/:id/files/:fileId') @ResponseContract(successResponseSchema) 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: await this.collab.getFormattedNoteById(tripId, id) }, socketId, ); return { success: true }; } // ── Shared links ──────────────────────────────────────────────────────── @UseGuards(TripAccessGuard) @Get('links') @ResponseContract(collabLinksResponseSchema) async listLinks(@CurrentUser() user: User, @Param('tripId') tripId: string) { return { links: await this.collab.listLinks(tripId) }; } @UseGuards(TripAccessGuard) @RequirePermission('collab_edit') @Post('links') @ResponseContract(collabLinkResponseSchema) async createLink( @CurrentUser() user: User, @Param('tripId') tripId: string, @Body() body: CollabLinkCreateDto, @Headers('x-socket-id') socketId?: string, ) { const link = await 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') @ResponseContract(collabLinkResponseSchema) async updateLink( @CurrentUser() user: User, @Param('tripId') tripId: string, @Param('id') id: string, @Body() body: CollabLinkUpdateDto, @Headers('x-socket-id') socketId?: string, ) { const link = await 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') @ResponseContract(successResponseSchema) async deleteLink( @CurrentUser() user: User, @Param('tripId') tripId: string, @Param('id') id: string, @Headers('x-socket-id') socketId?: string, ) { if (!(await 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') @ResponseContract(collabPollsResponseSchema) async listPolls(@CurrentUser() user: User, @Param('tripId') tripId: string) { return { polls: await this.collab.listPolls(tripId) }; } @UseGuards(TripAccessGuard) @RequirePermission('collab_edit') @Post('polls') @ResponseContract(collabPollResponseSchema) async createPoll( @CurrentUser() user: User, @Param('tripId') tripId: string, @Body() body: CollabPollCreateDto, @Headers('x-socket-id') socketId?: string, ) { const poll = await 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) @ResponseContract(collabPollResponseSchema) async votePoll( @CurrentUser() user: User, @Param('tripId') tripId: string, @Param('id') id: string, @Body() body: CollabPollVoteDto, @Headers('x-socket-id') socketId?: string, ) { const result = await 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') @ResponseContract(collabPollResponseSchema) async closePoll( @CurrentUser() user: User, @Param('tripId') tripId: string, @Param('id') id: string, @Headers('x-socket-id') socketId?: string, ) { const poll = await 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') @ResponseContract(successResponseSchema) async deletePoll( @CurrentUser() user: User, @Param('tripId') tripId: string, @Param('id') id: string, @Headers('x-socket-id') socketId?: string, ) { if (!(await 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') @ResponseContract(collabMessagesResponseSchema) async listMessages(@CurrentUser() user: User, @Param('tripId') tripId: string, @Query('before') before?: string) { return { messages: await 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, ) @ResponseContract(collabMessageResponseSchema) 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 = await this.requireTrip(tripId, user); await this.requireEdit(trip, user); } catch (err) { cleanupSpool(); throw err; } // Only the images are refused to the demo account; its text still posts. if (uploaded.length && isDemoWriteBlocked(this.env, user.email)) { cleanupSpool(); throw new HttpException(DEMO_WRITE_ERROR, 403); } if (uploaded.length && !(await 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 = await this.collab.postMessage( tripId, { text, replyTo: Number.isFinite(replyTo as number) ? replyTo : null, files: uploaded.map((file) => ({ filename: file.filename, originalname: file.originalname, size: file.size, mimetype: file.mimetype, })), }, this.collab.restWriter(tripId, user, socketId), ); 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); } return { message: result.message }; } @UseGuards(TripAccessGuard) @RequirePermission('collab_edit') @Post('messages/:id/react') @HttpCode(200) @ResponseContract(collabReactionsResponseSchema) async react( @CurrentUser() user: User, @Param('tripId') tripId: string, @Param('id') id: string, @Body() body: CollabReactionDto, @Headers('x-socket-id') socketId?: string, ) { const result = await 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') @ResponseContract(successResponseSchema) async deleteMessage( @CurrentUser() user: User, @Param('tripId') tripId: string, @Param('id') id: string, @Headers('x-socket-id') socketId?: string, ) { const result = await 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') @ResponseContract(collabLinkPreviewResponseSchema) 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 }; } } }