mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-23 07:26:30 +00:00
446 lines
16 KiB
TypeScript
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) };
|
|
}
|
|
}
|