mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-22 15:06:32 +00:00
fix(collab): stop serving the text of deleted chat messages
A deleted message keeps its row so the client can draw the placeholder off the flag, but listMessages still returned the original text - in the list itself and in the reply preview joined onto whatever quoted it. Both REST and the MCP resource read through that one method, so blanking it there covers every surface. Also cap the link-preview read, and delete the spooled upload when addNoteFile refuses the request instead of leaving it on disk.
This commit is contained in:
@@ -17,6 +17,7 @@ import {
|
||||
import { FileInterceptor } 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';
|
||||
@@ -146,9 +147,20 @@ export class CollabController {
|
||||
@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) {
|
||||
const trip = this.requireTrip(tripId, user);
|
||||
if (!this.collab.canUploadFiles(trip, user)) {
|
||||
throw new HttpException({ error: 'No permission to upload files' }, 403);
|
||||
// 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);
|
||||
@@ -158,6 +170,8 @@ export class CollabController {
|
||||
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);
|
||||
|
||||
@@ -6,12 +6,17 @@ import { RealtimeService } from '../realtime/realtime.service';
|
||||
import { PermissionsService } from '../permissions/permissions.service';
|
||||
import { avatarUrl } from '../common/avatarUrl';
|
||||
import { checkSsrf, createPinnedDispatcher } from '../../utils/ssrfGuard';
|
||||
import { exceedsDeclaredLength, readCappedText } from '../../utils/cappedFetch';
|
||||
import type { CollabNote, CollabPoll, CollabMessage, TripFile, User } from '../../types';
|
||||
import { NotificationsService } from '../notifications/notifications.service';
|
||||
import { StorageService } from '../storage/storage.service';
|
||||
|
||||
type Trip = TripAccess;
|
||||
|
||||
// Half a megabyte of markup is far more than any og:/<title> block needs, and
|
||||
// the URL is caller-supplied, so read no further than that.
|
||||
const MAX_PREVIEW_BYTES = 512 * 1024;
|
||||
|
||||
export interface ReactionRow {
|
||||
emoji: string;
|
||||
user_id: number;
|
||||
@@ -374,7 +379,8 @@ export class CollabService {
|
||||
listMessages(tripId: string | number, before?: string | number) {
|
||||
const query = `
|
||||
SELECT m.*, u.username, u.avatar,
|
||||
rm.text AS reply_text, ru.username AS reply_username
|
||||
CASE WHEN rm.deleted = 1 THEN '' ELSE rm.text END AS reply_text,
|
||||
ru.username AS reply_username
|
||||
FROM collab_messages m
|
||||
JOIN users u ON m.user_id = u.id
|
||||
LEFT JOIN collab_messages rm ON m.reply_to = rm.id
|
||||
@@ -390,6 +396,12 @@ export class CollabService {
|
||||
|
||||
messages.reverse();
|
||||
|
||||
// A deleted message keeps its row so the client can render the placeholder
|
||||
// off the flag, but the original text must not leave the server. REST and
|
||||
// both MCP surfaces read through this method, so blanking here covers all
|
||||
// three.
|
||||
for (const m of messages) if (m.deleted) m.text = '';
|
||||
|
||||
const msgIds = messages.map(m => m.id);
|
||||
const reactionsByMsg: Record<number, ReactionRow[]> = {};
|
||||
if (msgIds.length > 0) {
|
||||
@@ -468,8 +480,11 @@ export class CollabService {
|
||||
} as any);
|
||||
clearTimeout(timeout);
|
||||
if (!r.ok) throw new Error('Fetch failed');
|
||||
if (exceedsDeclaredLength(r, MAX_PREVIEW_BYTES)) return fallback;
|
||||
|
||||
const html = await r.text();
|
||||
// A truncated head still carries the tags we scrape, so a page over the
|
||||
// budget degrades to fewer fields rather than to an error.
|
||||
const { text: html } = await readCappedText(r, MAX_PREVIEW_BYTES);
|
||||
const get = (prop: string) => {
|
||||
const m = html.match(new RegExp(`<meta[^>]*property=["']og:${prop}["'][^>]*content=["']([^"']*)["']`, 'i'))
|
||||
|| html.match(new RegExp(`<meta[^>]*content=["']([^"']*)["'][^>]*property=["']og:${prop}["']`, 'i'));
|
||||
|
||||
Reference in New Issue
Block a user