mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-11-13 07:40:59 +00:00
2372dde716
Six upload routes refused the shared demo account in demo mode; journey photos, videos, gallery media and covers, collab note files and chat images did not, so any visitor could put images in front of every other one for the hour. They answer the same 403 now, checked in the handler after the upload is read, and a text-only chat message still goes through. The Demo Mode wiki page lists them, and says document sync is not covered.
639 lines
24 KiB
TypeScript
639 lines
24 KiB
TypeScript
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<Awaited<ReturnType<CollabService['verifyTripAccess']>>>,
|
|
user: User,
|
|
): Promise<void> {
|
|
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 };
|
|
}
|
|
}
|
|
}
|