refactor(collab): spool note attachments through storage; normalize files/ prefix

This commit is contained in:
jubnl
2026-08-16 09:29:46 +02:00
parent 428d32b18a
commit fd81ee00f1
7 changed files with 214 additions and 63 deletions
+10
View File
@@ -3891,6 +3891,16 @@ function runMigrations(db: Database.Database): void {
db.exec('ALTER TABLE budget_items ADD COLUMN place_id INTEGER REFERENCES places(id) ON DELETE SET NULL DEFAULT NULL');
}
},
// Storage slice 2 — collab note attachments historically stored 'files/<name>'
// in trip_files.filename while the file manager stored bare names in the same
// column; the storage layer addresses objects as category + bare name, so
// normalize the legacy rows. substr is 1-indexed: 7 drops the six chars of
// 'files/'. The LIKE guard makes it a no-op on already-bare rows.
// Appended LAST: the array is index-addressed against schema_version, so a
// slot inserted above this line never runs on an existing database.
() => {
db.exec("UPDATE trip_files SET filename = substr(filename, 7) WHERE filename LIKE 'files/%'");
},
];
if (currentVersion < migrations.length) {
+23 -24
View File
@@ -15,12 +15,11 @@ import {
UseInterceptors,
} from '@nestjs/common';
import { FileInterceptor } from '@nestjs/platform-express';
import { diskStorage } from 'multer';
import type { Options } from 'multer';
import path from 'path';
import fs from 'fs';
import { v4 as uuidv4 } from 'uuid';
import type { User } from '../../types';
import { CollabService } from './collab.service';
import { StorageService } from '../storage/storage.service';
import {
CollabNoteCreateDto,
CollabNoteUpdateDto,
@@ -34,24 +33,18 @@ import { CurrentUser } from '../auth/current-user.decorator';
import { RequirePermission, TripAccessGuard } from '../permissions/trip-access.guard';
import { BLOCKED_EXTENSIONS } from '../files/files.constants';
const MAX_NOTE_FILE_SIZE = 50 * 1024 * 1024;
const filesDir = path.join(__dirname, '../../../uploads/files');
const NOTE_UPLOAD = {
storage: diskStorage({
destination: (_req, _file, cb) => { if (!fs.existsSync(filesDir)) fs.mkdirSync(filesDir, { recursive: true }); cb(null, filesDir); },
filename: (_req, file, cb) => cb(null, `${uuidv4()}${path.extname(file.originalname)}`),
}),
limits: { fileSize: MAX_NOTE_FILE_SIZE },
defParamCharset: 'utf8', // parity with legacy routes/collab.ts — preserve non-ASCII original filenames
fileFilter: (_req: unknown, file: Express.Multer.File, cb: (err: Error | null, accept: boolean) => void) => {
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, false);
}
cb(null, true);
},
export const MAX_NOTE_FILE_SIZE = 50 * 1024 * 1024;
// 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);
};
/**
@@ -78,7 +71,10 @@ const NOTE_UPLOAD = {
// caller then sees ECONNRESET instead of the 404). Same as files.controller.ts.
@UseGuards(JwtAuthGuard)
export class CollabController {
constructor(private readonly collab: CollabService) {}
constructor(
private readonly collab: CollabService,
private readonly storage: StorageService,
) {}
private requireTrip(tripId: string, user: User) {
const trip = this.collab.verifyTripAccess(tripId, user.id);
@@ -148,8 +144,8 @@ export class CollabController {
}
@Post('notes/:id/files')
@UseInterceptors(FileInterceptor('file', NOTE_UPLOAD))
addNoteFile(@CurrentUser() user: User, @Param('tripId') tripId: string, @Param('id') id: string, @UploadedFile() file: Express.Multer.File | undefined, @Headers('x-socket-id') socketId?: string) {
@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);
@@ -157,6 +153,9 @@ export class CollabController {
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) {
throw new HttpException({ error: 'Note not found' }, 404);
+19 -2
View File
@@ -1,5 +1,6 @@
import { Module } from '@nestjs/common';
import { CollabController } from './collab.controller';
import { MulterModule } from '@nestjs/platform-express';
import { CollabController, collabNoteFileFilter, MAX_NOTE_FILE_SIZE } from './collab.controller';
import { CollabService } from './collab.service';
import { CollabRpc } from './collab.rpc';
import { PluginGuardsModule } from '../plugins/host/plugin-guards.module';
@@ -10,9 +11,25 @@ import { AuthModule } from '../auth/auth.module';
import { AddonsModule } from '../addons/addons.module';
import { NotificationsModule } from '../notifications/notifications.module';
import { McpSharedModule } from '../mcp-shared/mcp-shared.module';
import { StorageModule } from '../storage/storage.module';
import { StorageService } from '../storage/storage.service';
import { buildStorageUploadOptions } from '../storage/storage-upload.factory';
@Module({
imports: [McpSharedModule, NotificationsModule, PermissionsModule, AuthModule, RealtimeModule, PluginGuardsModule, AddonsModule],
imports: [
MulterModule.registerAsync({
imports: [StorageModule],
inject: [StorageService],
useFactory: (storage: StorageService) =>
buildStorageUploadOptions(storage, {
category: 'files',
maxSize: MAX_NOTE_FILE_SIZE,
defParamCharset: 'utf8', // parity with legacy routes/collab.ts — preserve non-ASCII original filenames
fileFilter: collabNoteFileFilter,
}),
}),
StorageModule,
McpSharedModule, NotificationsModule, PermissionsModule, AuthModule, RealtimeModule, PluginGuardsModule, AddonsModule],
controllers: [CollabController],
providers: [CollabService, CollabMcp, CollabRpc],
// For in-container consumers (CollabRpc).
+6 -3
View File
@@ -6,6 +6,7 @@ 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';
@@ -199,9 +200,11 @@ export class CollabService {
// 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.
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(__dirname, '../../../uploads', f.filename);
const filePath = path.join(filesDir, path.basename(f.filename));
try { fs.unlinkSync(filePath); } catch { /* ignore */ }
}
this.db.transaction(() => {
@@ -221,7 +224,7 @@ export class CollabService {
const result = this.db.run(
'INSERT INTO trip_files (trip_id, note_id, filename, original_name, file_size, mime_type) VALUES (?, ?, ?, ?, ?, ?)',
tripId, noteId, `files/${file.filename}`, file.originalname, file.size, file.mimetype
tripId, noteId, file.filename, file.originalname, file.size, file.mimetype
);
const saved = this.db.get<TripFile>('SELECT * FROM trip_files WHERE id = ?', result.lastInsertRowid)!;
@@ -242,7 +245,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(__dirname, '../../../uploads', file.filename);
const filePath = path.join(filesDir, path.basename(file.filename));
try { fs.unlinkSync(filePath); } catch { /* ignore */ }
this.db.run('DELETE FROM trip_files WHERE id = ?', fileId);