refactor(collab): note-file deletion through storage

This commit is contained in:
jubnl
2026-08-17 05:51:44 +02:00
parent dfb8f8a8a4
commit f382a7635e
9 changed files with 36 additions and 36 deletions
+4 -4
View File
@@ -135,8 +135,8 @@ export class CollabController {
@UseGuards(TripAccessGuard)
@RequirePermission('collab_edit')
@Delete('notes/:id')
deleteNote(@CurrentUser() user: User, @Param('tripId') tripId: string, @Param('id') id: string, @Headers('x-socket-id') socketId?: string) {
if (!this.collab.deleteNote(tripId, 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);
@@ -167,8 +167,8 @@ export class CollabController {
@UseGuards(TripAccessGuard)
@RequirePermission('collab_edit')
@Delete('notes/:id/files/:fileId')
deleteNoteFile(@CurrentUser() user: User, @Param('tripId') tripId: string, @Param('id') id: string, @Param('fileId') fileId: string, @Headers('x-socket-id') socketId?: string) {
if (!this.collab.deleteNoteFile(tripId, id, 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);
+1 -1
View File
@@ -145,7 +145,7 @@ export class CollabMcp {
if (this.auth.isDemoUser(ctx.userId)) return demoDenied();
if (!this.collab.verifyTripAccess(tripId, ctx.userId)) return noAccess();
if (!this.guards.hasTripPermission('collab_edit', tripId, ctx.userId)) return permissionDenied();
const deleted = this.collab.deleteNote(tripId, noteId);
const deleted = await this.collab.deleteNote(tripId, noteId);
if (!deleted) return errorResult('Note not found.');
this.guards.safeBroadcast(tripId, 'collab:note:deleted', { noteId });
return ok({ success: true });
+10 -12
View File
@@ -1,15 +1,14 @@
import path from 'path';
import fs from 'fs';
import { Injectable } from '@nestjs/common';
import { DatabaseService, type TripAccess } from '../database/database.service';
import type { TrekWsPayload, TrekWsTripEventName } from '@trek/shared';
import { RealtimeService } from '../realtime/realtime.service';
import { PermissionsService } from '../permissions/permissions.service';
import { avatarUrl } from '../common/avatarUrl';
import { filesDir } from '../files/files.constants';
import { checkSsrf, createPinnedDispatcher } from '../../utils/ssrfGuard';
import type { CollabNote, CollabPoll, CollabMessage, TripFile, User } from '../../types';
import { NotificationsService } from '../notifications/notifications.service';
import { StorageService } from '../storage/storage.service';
type Trip = TripAccess;
@@ -70,6 +69,7 @@ export class CollabService {
private readonly permissions: PermissionsService,
private readonly realtime: RealtimeService,
private readonly notifications: NotificationsService,
private readonly storage: StorageService,
) {}
verifyTripAccess(tripId: string | number, userId: number) {
@@ -194,18 +194,17 @@ export class CollabService {
return this.formatNote(note);
}
deleteNote(tripId: string | number, noteId: string | number): boolean {
async deleteNote(tripId: string | number, noteId: string | number): Promise<boolean> {
const existing = this.db.get('SELECT id FROM collab_notes WHERE id = ? AND trip_id = ?', noteId, tripId);
if (!existing) return false;
// Clean up attached files from disk (unlink-first is intentional — a
// failed row delete leaves dangling rows, never orphaned files).
// basename() tolerates any legacy 'files/'-prefixed row the boot migration
// has not seen; the raw unlink moves to storage.delete in the serving slice.
// Clean up attached objects first (delete-first is intentional — a failed
// row delete leaves dangling rows, never orphaned files). basename()
// tolerates any legacy 'files/'-prefixed row the boot migration has not
// seen.
const noteFiles = this.db.all<NoteFileRow>('SELECT id, filename FROM trip_files WHERE note_id = ?', noteId);
for (const f of noteFiles) {
const filePath = path.join(filesDir, path.basename(f.filename));
try { fs.unlinkSync(filePath); } catch { /* ignore */ }
await this.storage.delete('files', path.basename(f.filename)).catch(() => { /* ignore */ });
}
this.db.transaction(() => {
this.db.run('DELETE FROM trip_files WHERE note_id = ?', noteId);
@@ -237,7 +236,7 @@ export class CollabService {
return this.formatNote(note);
}
deleteNoteFile(tripId: string | number, noteId: string | number, fileId: string | number): boolean {
async deleteNoteFile(tripId: string | number, noteId: string | number, fileId: string | number): Promise<boolean> {
// Scope to the trip — like every sibling collab op — so a caller authorized for THEIR
// trip can't delete a note-file that belongs to someone else's trip (IDOR). trip_files
// carries trip_id, so this ties the deleted object to the URL's :tripId the controller
@@ -245,8 +244,7 @@ export class CollabService {
const file = this.db.get<TripFile>('SELECT * FROM trip_files WHERE id = ? AND note_id = ? AND trip_id = ?', fileId, noteId, tripId);
if (!file) return false;
const filePath = path.join(filesDir, path.basename(file.filename));
try { fs.unlinkSync(filePath); } catch { /* ignore */ }
await this.storage.delete('files', path.basename(file.filename)).catch(() => { /* ignore */ });
this.db.run('DELETE FROM trip_files WHERE id = ?', fileId);
return true;