mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-19 21:46:49 +00:00
refactor(collab): spool note attachments through storage; normalize files/ prefix
This commit is contained in:
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,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);
|
||||
|
||||
Reference in New Issue
Block a user