mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-20 22:16:26 +00:00
265 lines
12 KiB
TypeScript
265 lines
12 KiB
TypeScript
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 { diskStorage } from 'multer';
|
|
import path from 'path';
|
|
import fs from 'fs';
|
|
import { v4 as uuidv4 } from 'uuid';
|
|
import { readEnv } from '../../app-config';
|
|
import type { User } from '../../types';
|
|
import { FilesService } from './files.service';
|
|
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
|
|
import { CurrentUser } from '../auth/current-user.decorator';
|
|
import { MAX_FILE_SIZE, MAX_VIDEO_SIZE, BLOCKED_EXTENSIONS, filesDir, getAllowedExtensions, isVideoExtension } from '../../services/fileService';
|
|
import { isDemoEmail } from '../../services/demo';
|
|
|
|
const 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)}`),
|
|
}),
|
|
// Allow up to the video cap; non-video files are still held to MAX_FILE_SIZE by
|
|
// the per-type guard in the upload handler (#823).
|
|
limits: { fileSize: MAX_VIDEO_SIZE },
|
|
defParamCharset: 'utf8', // parity with legacy routes/files.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();
|
|
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, false);
|
|
};
|
|
if (BLOCKED_EXTENSIONS.includes(ext) || file.mimetype.includes('svg')) return reject();
|
|
const allowed = getAllowedExtensions().split(',').map((e) => e.trim().toLowerCase());
|
|
const fileExt = ext.replace('.', '');
|
|
// Video is accepted as media regardless of the admin doc-types allowlist (#823).
|
|
if (allowed.includes(fileExt) || isVideoExtension(fileExt) || (allowed.includes('*') && !BLOCKED_EXTENSIONS.includes(ext))) return cb(null, true);
|
|
reject();
|
|
},
|
|
};
|
|
|
|
/**
|
|
* /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')
|
|
@UseGuards(JwtAuthGuard)
|
|
export class FilesController {
|
|
constructor(private readonly files: FilesService) {}
|
|
|
|
private requireTrip(tripId: string, user: User) {
|
|
const trip = this.files.verifyTripAccess(tripId, user.id);
|
|
if (!trip) {
|
|
throw new HttpException({ error: 'Trip not found' }, 404);
|
|
}
|
|
return trip;
|
|
}
|
|
|
|
// A file may only point at reservations/assignments/places 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 assertLinkTargets(tripId: string, body: { reservation_id?: string | null; assignment_id?: string | null; place_id?: string | null }) {
|
|
if (this.files.findForeignLinkTarget(tripId, body)) {
|
|
throw new HttpException({ error: 'Linked item does not belong to this trip' }, 400);
|
|
}
|
|
}
|
|
|
|
@Get()
|
|
list(@CurrentUser() user: User, @Param('tripId') tripId: string, @Query('trash') trash?: string) {
|
|
this.requireTrip(tripId, user);
|
|
return { files: this.files.listFiles(tripId, trash === 'true') };
|
|
}
|
|
|
|
@Post()
|
|
@UseInterceptors(FileInterceptor('file', UPLOAD))
|
|
upload(
|
|
@CurrentUser() user: User,
|
|
@Param('tripId') tripId: string,
|
|
@UploadedFile() file: Express.Multer.File | undefined,
|
|
@Body() body: { place_id?: string; description?: string; reservation_id?: string },
|
|
@Headers('x-socket-id') socketId?: string,
|
|
) {
|
|
// multer (diskStorage) has already written 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 = this.requireTrip(tripId, user);
|
|
if (readEnv().demo.enabled && isDemoEmail(user.email)) {
|
|
throw new HttpException({ error: 'Uploads are disabled in demo mode. Self-host TREK for full functionality.' }, 403);
|
|
}
|
|
if (!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 {
|
|
this.assertLinkTargets(tripId, { reservation_id: body.reservation_id, place_id: body.place_id });
|
|
} catch (err) {
|
|
cleanup();
|
|
throw err;
|
|
}
|
|
const created = this.files.createFile(tripId, file, user.id, {
|
|
place_id: body.place_id,
|
|
description: body.description,
|
|
reservation_id: body.reservation_id,
|
|
});
|
|
this.files.broadcast(tripId, 'file:created', { file: created }, socketId);
|
|
return { file: created };
|
|
}
|
|
|
|
@Put(':id')
|
|
update(@CurrentUser() user: User, @Param('tripId') tripId: string, @Param('id') id: string, @Body() body: { description?: string; place_id?: string | null; reservation_id?: string | null }, @Headers('x-socket-id') socketId?: string) {
|
|
const trip = this.requireTrip(tripId, user);
|
|
if (!this.files.can('file_edit', trip, user)) {
|
|
throw new HttpException({ error: 'No permission to edit files' }, 403);
|
|
}
|
|
const file = this.files.getFileById(id, tripId);
|
|
if (!file) {
|
|
throw new HttpException({ error: 'File not found' }, 404);
|
|
}
|
|
this.assertLinkTargets(tripId, { reservation_id: body.reservation_id, place_id: body.place_id });
|
|
const updated = this.files.updateFile(id, file, { description: body.description, place_id: body.place_id, reservation_id: body.reservation_id });
|
|
this.files.broadcast(tripId, 'file:updated', { file: updated }, socketId);
|
|
return { file: updated };
|
|
}
|
|
|
|
@Patch(':id/star')
|
|
star(@CurrentUser() user: User, @Param('tripId') tripId: string, @Param('id') id: string, @Headers('x-socket-id') socketId?: string) {
|
|
const trip = this.requireTrip(tripId, user);
|
|
if (!this.files.can('file_edit', trip, user)) {
|
|
throw new HttpException({ error: 'No permission' }, 403);
|
|
}
|
|
const file = this.files.getFileById(id, tripId);
|
|
if (!file) {
|
|
throw new HttpException({ error: 'File not found' }, 404);
|
|
}
|
|
const updated = this.files.toggleStarred(id, file.starred);
|
|
this.files.broadcast(tripId, 'file:updated', { file: updated }, socketId);
|
|
return { file: updated };
|
|
}
|
|
|
|
@Delete('trash/empty')
|
|
async emptyTrash(@CurrentUser() user: User, @Param('tripId') tripId: string) {
|
|
const trip = this.requireTrip(tripId, user);
|
|
if (!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 };
|
|
}
|
|
|
|
@Delete(':id/permanent')
|
|
async permanent(@CurrentUser() user: User, @Param('tripId') tripId: string, @Param('id') id: string, @Headers('x-socket-id') socketId?: string) {
|
|
const trip = this.requireTrip(tripId, user);
|
|
if (!this.files.can('file_delete', trip, user)) {
|
|
throw new HttpException({ error: 'No permission' }, 403);
|
|
}
|
|
const file = 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 };
|
|
}
|
|
|
|
@Delete(':id')
|
|
remove(@CurrentUser() user: User, @Param('tripId') tripId: string, @Param('id') id: string, @Headers('x-socket-id') socketId?: string) {
|
|
const trip = this.requireTrip(tripId, user);
|
|
if (!this.files.can('file_delete', trip, user)) {
|
|
throw new HttpException({ error: 'No permission to delete files' }, 403);
|
|
}
|
|
const file = this.files.getFileById(id, tripId);
|
|
if (!file) {
|
|
throw new HttpException({ error: 'File not found' }, 404);
|
|
}
|
|
this.files.softDeleteFile(id);
|
|
this.files.broadcast(tripId, 'file:deleted', { fileId: Number(id) }, socketId);
|
|
return { success: true };
|
|
}
|
|
|
|
@Post(':id/restore')
|
|
@HttpCode(200) // Express answers restore with res.json (200), not the POST-default 201.
|
|
restore(@CurrentUser() user: User, @Param('tripId') tripId: string, @Param('id') id: string, @Headers('x-socket-id') socketId?: string) {
|
|
const trip = this.requireTrip(tripId, user);
|
|
if (!this.files.can('file_delete', trip, user)) {
|
|
throw new HttpException({ error: 'No permission' }, 403);
|
|
}
|
|
const file = this.files.getDeletedFile(id, tripId);
|
|
if (!file) {
|
|
throw new HttpException({ error: 'File not found in trash' }, 404);
|
|
}
|
|
const restored = this.files.restoreFile(id);
|
|
this.files.broadcast(tripId, 'file:created', { file: restored }, socketId);
|
|
return { file: restored };
|
|
}
|
|
|
|
@Post(':id/link')
|
|
@HttpCode(200) // Express answers link with res.json (200).
|
|
link(@CurrentUser() user: User, @Param('tripId') tripId: string, @Param('id') id: string, @Body() body: { reservation_id?: string | null; assignment_id?: string | null; place_id?: string | null }) {
|
|
const trip = this.requireTrip(tripId, user);
|
|
if (!this.files.can('file_edit', trip, user)) {
|
|
throw new HttpException({ error: 'No permission' }, 403);
|
|
}
|
|
const file = this.files.getFileById(id, tripId);
|
|
if (!file) {
|
|
throw new HttpException({ error: 'File not found' }, 404);
|
|
}
|
|
this.assertLinkTargets(tripId, { reservation_id: body.reservation_id, assignment_id: body.assignment_id, place_id: body.place_id });
|
|
const links = this.files.createFileLink(id, { reservation_id: body.reservation_id, assignment_id: body.assignment_id, place_id: body.place_id });
|
|
return { success: true, links };
|
|
}
|
|
|
|
@Delete(':id/link/:linkId')
|
|
unlink(@CurrentUser() user: User, @Param('tripId') tripId: string, @Param('id') id: string, @Param('linkId') linkId: string) {
|
|
const trip = this.requireTrip(tripId, user);
|
|
if (!this.files.can('file_edit', trip, user)) {
|
|
throw new HttpException({ error: 'No permission' }, 403);
|
|
}
|
|
this.files.deleteFileLink(linkId, id);
|
|
return { success: true };
|
|
}
|
|
|
|
@Get(':id/links')
|
|
links(@CurrentUser() user: User, @Param('tripId') tripId: string, @Param('id') id: string) {
|
|
this.requireTrip(tripId, user);
|
|
return { links: this.files.getFileLinks(id) };
|
|
}
|
|
}
|