Files
TREK/server/src/nest/files/files.controller.ts
T

446 lines
16 KiB
TypeScript

import type { TripAccess } from '../../db/repositories/Trips.repository';
import type { User } from '../../types';
import { RuntimeEnvService } from '../app-config/runtime-env.service';
import { logError } from '../audit/audit-log.logger';
import { CurrentUser } from '../auth-core/current-user.decorator';
import { JwtAuthGuard } from '../auth-core/jwt-auth.guard';
import { isDemoWriteBlocked, DEMO_WRITE_ERROR } from '../common/demo-write';
import { ResponseContract } from '../common/response-contract';
import { TripAccessGuard } from '../permissions/trip-access.guard';
import { Trip } from '../permissions/trip.decorator';
import { StorageService } from '../storage/storage.service';
import { AllowedFileTypesService } from './allowed-file-types.service';
import { MAX_FILE_SIZE, BLOCKED_EXTENSIONS, isUploadTypeAllowed, isVideoExtension } from './files.constants';
import { FileUploadDto, FileUpdateDto, FileLinkDto } from './files.dto';
import { FilesService } from './files.service';
import {
Body,
Controller,
Delete,
Get,
Headers,
HttpCode,
HttpException,
Param,
Patch,
Post,
Put,
Query,
UploadedFile,
UseGuards,
UseInterceptors,
} from '@nestjs/common';
import { FileInterceptor } from '@nestjs/platform-express';
import {
fileLinkCreateResponseSchema,
fileLinksResponseSchema,
fileTrashEmptyResponseSchema,
successResponseSchema,
tripFileResponseSchema,
tripFilesResponseSchema,
} from '@trek/shared';
import fs from 'fs';
import type { Options } from 'multer';
import path from 'path';
/**
* Trip-file upload filter, built from the container.
*
* A factory rather than a module-scope literal because it reads the operator's
* allowed-extension list at request time, and that list lives in the database.
* The rest of the multer options come from the storage upload factory
* (buildStorageUploadOptions in files.module.ts); this closure is passed
* through untouched.
*/
export function filesUploadFileFilter(allowedTypes: AllowedFileTypesService): Options['fileFilter'] {
return (_req, file, cb) => {
const ext = path.extname(file.originalname).toLowerCase();
const reject = () => {
// i18n key — the client resolves it via t() (see translateApiError).
const err: Error & { statusCode?: number } = new Error('files.uploadErrorType');
err.statusCode = 400;
cb(err);
};
if (BLOCKED_EXTENSIONS.includes(ext) || file.mimetype.includes('svg')) return reject();
// R1.5: multer's fileFilter is a callback API that cannot await, and the
// allowed-extension list is now an async read. The decision runs in a
// detached async function that always answers through `cb` — multer holds
// the file stream until it does (make-middleware.js says as much). A
// rejection refuses the file, so the filter still fails closed. Only the
// await is wrapped in try/catch; a throw from cb() itself lands in the
// trailing .catch, which logs it and answers multer one final time.
(async () => {
let allowedList: string;
try {
allowedList = await allowedTypes.get();
} catch {
return reject();
}
// Shared with the MCP upload tool (#1566): blocked/SVG never, video always (#823).
if (isUploadTypeAllowed(file.originalname, file.mimetype, allowedList)) return cb(null, true);
reject();
})().catch((err: unknown) => {
logError(`files upload fileFilter: ${err instanceof Error ? err.message : String(err)}`);
// cb() itself is the only thing that can reach here (every await is
// inside the try above); answer once more so multer never hangs, but a
// second throw from cb() means multer already answered — swallow it.
try {
cb(err instanceof Error ? err : new Error(String(err)));
} catch {
/* multer already answered */
}
});
};
}
/**
* /api/trips/:tripId/files — trip file manager (upload, metadata, starring,
* trash + restore, reservation links). The authenticated download lives in the
* separate unguarded FilesDownloadController (it carries its own token auth).
*
* Byte-identical to the legacy Express route (server/src/routes/files.ts): trip
* access (404), the demo-mode upload block (403), the file_upload/file_edit/
* file_delete permissions (403), create 201 / rest 200, the bespoke bodies and
* the WebSocket broadcasts with the forwarded X-Socket-Id.
*/
@Controller('api/trips/:tripId/files')
// TripAccessGuard carries the trip access check, applied PER HANDLER rather than on
// the class. The per-handler RIGHT stays inline because files use three different ones
// (file_upload, file_edit, file_delete), which one class-level @RequirePermission could
// not express.
//
// `upload` is deliberately left off the guard and keeps its own check. Guards run
// before interceptors, so a class-level guard would answer 404 before multer had read
// the multipart body — and a response sent while the client is still streaming gets the
// socket destroyed, so the caller sees ECONNRESET instead of the 404. Refusing after
// the body has been read costs the bandwidth but keeps the answer readable, which is
// the behaviour every client already handles.
@UseGuards(JwtAuthGuard)
export class FilesController {
constructor(
private readonly files: FilesService,
private readonly env: RuntimeEnvService,
private readonly storage: StorageService,
) {}
// A file may only point at reservations/assignments/places/budget_items from its own trip.
// Reject cross-trip ids before they are stored — the reservation JOIN would
// otherwise leak the foreign reservation's title back to the caller.
private async assertLinkTargets(
tripId: string,
body: {
reservation_id?: string | number | null;
assignment_id?: string | number | null;
place_id?: string | number | null;
budget_item_id?: string | number | null;
},
) {
if (await this.files.findForeignLinkTarget(tripId, body)) {
throw new HttpException({ error: 'Linked item does not belong to this trip' }, 400);
}
}
@UseGuards(TripAccessGuard)
@Get()
@ResponseContract(tripFilesResponseSchema)
async list(
@CurrentUser() user: User,
@Trip() trip: TripAccess,
@Param('tripId') tripId: string,
@Query('trash') trash?: string,
) {
return { files: await this.files.listFiles(tripId, trash === 'true') };
}
@Post()
@UseInterceptors(FileInterceptor('file'))
@ResponseContract(tripFileResponseSchema)
async upload(
@CurrentUser() user: User,
@Param('tripId') tripId: string,
@UploadedFile() file: Express.Multer.File | undefined,
@Body() body: FileUploadDto,
@Headers('x-socket-id') socketId?: string,
) {
// multer (diskStorage) has already spooled the upload by the time we get here,
// so every rejection below must remove the orphaned bytes — otherwise a 404/403
// leaves up to the 500 MB video cap on disk (#823).
const cleanup = () => {
if (file?.path) {
try {
fs.unlinkSync(file.path);
} catch {
/* best-effort */
}
}
};
try {
const trip = await this.files.verifyTripAccess(tripId, user.id);
if (!trip) {
throw new HttpException({ error: 'Trip not found' }, 404);
}
// Inline rather than DemoWriteGuard: the 404 above has to come first, so a demo
// user still cannot learn which trips exist.
if (isDemoWriteBlocked(this.env, user.email)) {
throw new HttpException(DEMO_WRITE_ERROR, 403);
}
if (!(await this.files.can('file_upload', trip, user))) {
throw new HttpException({ error: 'No permission to upload files' }, 403);
}
} catch (err) {
cleanup();
throw err;
}
if (!file) {
throw new HttpException({ error: 'No file uploaded' }, 400);
}
// The per-type cap is keyed on the EXTENSION, matching how the fileFilter
// decides acceptance — so a real video labelled application/octet-stream isn't
// wrongly rejected, and the 500 MB cap only applies to actual video extensions.
const isVideoUpload = isVideoExtension(path.extname(file.originalname || ''));
if (!isVideoUpload && file.size > MAX_FILE_SIZE) {
cleanup();
throw new HttpException({ error: 'File is too large' }, 400);
}
try {
await this.assertLinkTargets(tripId, {
reservation_id: body.reservation_id,
place_id: body.place_id,
budget_item_id: body.budget_item_id,
});
} catch (err) {
cleanup();
throw err;
}
// Commit the spooled upload to its final storage location (atomic
// same-volume rename) before anything references the final path.
try {
await this.storage.put('files', file.filename, { tmpPath: file.path });
} catch (err) {
cleanup();
throw err;
}
const created = await this.files.createFile(tripId, file, user.id, {
place_id: body.place_id,
description: body.description,
reservation_id: body.reservation_id,
budget_item_id: body.budget_item_id,
});
this.files.broadcast(tripId, 'file:created', { file: created }, socketId);
return { file: created };
}
@UseGuards(TripAccessGuard)
@Put(':id')
@ResponseContract(tripFileResponseSchema)
async update(
@CurrentUser() user: User,
@Trip() trip: TripAccess,
@Param('tripId') tripId: string,
@Param('id') id: string,
@Body() body: FileUpdateDto,
@Headers('x-socket-id') socketId?: string,
) {
if (!(await this.files.can('file_edit', trip, user))) {
throw new HttpException({ error: 'No permission to edit files' }, 403);
}
const file = await this.files.getFileById(id, tripId);
if (!file) {
throw new HttpException({ error: 'File not found' }, 404);
}
await this.assertLinkTargets(tripId, {
reservation_id: body.reservation_id,
place_id: body.place_id,
budget_item_id: body.budget_item_id,
});
const updated = await this.files.updateFile(id, file, {
description: body.description,
place_id: body.place_id,
reservation_id: body.reservation_id,
budget_item_id: body.budget_item_id,
});
this.files.broadcast(tripId, 'file:updated', { file: updated }, socketId);
return { file: updated };
}
@UseGuards(TripAccessGuard)
@Patch(':id/star')
@ResponseContract(tripFileResponseSchema)
async star(
@CurrentUser() user: User,
@Trip() trip: TripAccess,
@Param('tripId') tripId: string,
@Param('id') id: string,
@Headers('x-socket-id') socketId?: string,
) {
if (!(await this.files.can('file_edit', trip, user))) {
throw new HttpException({ error: 'No permission' }, 403);
}
const file = await this.files.getFileById(id, tripId);
if (!file) {
throw new HttpException({ error: 'File not found' }, 404);
}
const updated = await this.files.toggleStarred(id, file.starred);
this.files.broadcast(tripId, 'file:updated', { file: updated }, socketId);
return { file: updated };
}
@UseGuards(TripAccessGuard)
@Delete('trash/empty')
@ResponseContract(fileTrashEmptyResponseSchema)
async emptyTrash(@CurrentUser() user: User, @Trip() trip: TripAccess, @Param('tripId') tripId: string) {
if (!(await this.files.can('file_delete', trip, user))) {
throw new HttpException({ error: 'No permission' }, 403);
}
const deleted = await this.files.emptyTrash(tripId);
return { success: true, deleted };
}
@UseGuards(TripAccessGuard)
@Delete(':id/permanent')
@ResponseContract(successResponseSchema)
async permanent(
@CurrentUser() user: User,
@Trip() trip: TripAccess,
@Param('tripId') tripId: string,
@Param('id') id: string,
@Headers('x-socket-id') socketId?: string,
) {
if (!(await this.files.can('file_delete', trip, user))) {
throw new HttpException({ error: 'No permission' }, 403);
}
const file = await this.files.getDeletedFile(id, tripId);
if (!file) {
throw new HttpException({ error: 'File not found in trash' }, 404);
}
await this.files.permanentDeleteFile(file);
this.files.broadcast(tripId, 'file:deleted', { fileId: Number(id) }, socketId);
return { success: true };
}
@UseGuards(TripAccessGuard)
@Delete(':id')
@ResponseContract(successResponseSchema)
async remove(
@CurrentUser() user: User,
@Trip() trip: TripAccess,
@Param('tripId') tripId: string,
@Param('id') id: string,
@Headers('x-socket-id') socketId?: string,
) {
if (!(await this.files.can('file_delete', trip, user))) {
throw new HttpException({ error: 'No permission to delete files' }, 403);
}
const file = await this.files.getFileById(id, tripId);
if (!file) {
throw new HttpException({ error: 'File not found' }, 404);
}
await this.files.softDeleteFile(id);
this.files.broadcast(tripId, 'file:deleted', { fileId: Number(id) }, socketId);
return { success: true };
}
@UseGuards(TripAccessGuard)
@Post(':id/restore')
@HttpCode(200) // Express answers restore with res.json (200), not the POST-default 201.
@ResponseContract(tripFileResponseSchema)
async restore(
@CurrentUser() user: User,
@Trip() trip: TripAccess,
@Param('tripId') tripId: string,
@Param('id') id: string,
@Headers('x-socket-id') socketId?: string,
) {
if (!(await this.files.can('file_delete', trip, user))) {
throw new HttpException({ error: 'No permission' }, 403);
}
const file = await this.files.getDeletedFile(id, tripId);
if (!file) {
throw new HttpException({ error: 'File not found in trash' }, 404);
}
const restored = await this.files.restoreFile(id);
this.files.broadcast(tripId, 'file:created', { file: restored }, socketId);
return { file: restored };
}
@UseGuards(TripAccessGuard)
@Post(':id/link')
@HttpCode(200) // Express answers link with res.json (200).
@ResponseContract(fileLinkCreateResponseSchema)
async link(
@CurrentUser() user: User,
@Trip() trip: TripAccess,
@Param('tripId') tripId: string,
@Param('id') id: string,
@Body() body: FileLinkDto,
@Headers('x-socket-id') socketId?: string,
) {
if (!(await this.files.can('file_edit', trip, user))) {
throw new HttpException({ error: 'No permission' }, 403);
}
const file = await this.files.getFileById(id, tripId);
if (!file) {
throw new HttpException({ error: 'File not found' }, 404);
}
await this.assertLinkTargets(tripId, {
reservation_id: body.reservation_id,
assignment_id: body.assignment_id,
place_id: body.place_id,
budget_item_id: body.budget_item_id,
});
const links = await this.files.linkFile(
id,
{
reservation_id: body.reservation_id,
assignment_id: body.assignment_id,
place_id: body.place_id,
budget_item_id: body.budget_item_id,
},
this.files.restWriter(tripId, user, socketId),
);
return { success: true, links };
}
@UseGuards(TripAccessGuard)
@Delete(':id/link/:linkId')
@ResponseContract(successResponseSchema)
async unlink(
@CurrentUser() user: User,
@Trip() trip: TripAccess,
@Param('tripId') tripId: string,
@Param('id') id: string,
@Param('linkId') linkId: string,
@Headers('x-socket-id') socketId?: string,
) {
if (!(await this.files.can('file_edit', trip, user))) {
throw new HttpException({ error: 'No permission' }, 403);
}
// deleteFileLink scopes by (linkId, fileId) only, so the file itself has to
// be resolved against :tripId first. Otherwise a member of any trip could
// drop a link row belonging to a foreign trip's file.
const file = await this.files.getFileById(id, tripId);
if (!file) {
throw new HttpException({ error: 'File not found' }, 404);
}
await this.files.unlinkFile(id, linkId, this.files.restWriter(tripId, user, socketId));
return { success: true };
}
@UseGuards(TripAccessGuard)
@Get(':id/links')
@ResponseContract(fileLinksResponseSchema)
async links(
@CurrentUser() user: User,
@Trip() trip: TripAccess,
@Param('tripId') tripId: string,
@Param('id') id: string,
) {
const file = await this.files.getFileById(id, tripId);
if (!file) {
throw new HttpException({ error: 'File not found' }, 404);
}
return { links: await this.files.getFileLinks(id) };
}
}