import { rm } from 'node:fs/promises'; import { BadRequestException, Controller, Delete, ForbiddenException, Get, HttpCode, HttpStatus, NotFoundException, Param, Post, Res, StreamableFile, UploadedFile, UseInterceptors, } from '@nestjs/common'; import { FileInterceptor } from '@nestjs/platform-express'; import { Session, type UserSession } from '@thallesp/nestjs-better-auth'; import type { Response } from 'express'; import { UploadFileUseCase } from '../../application/use-cases/upload-file/upload-file.use-case.js'; import { DownloadFileUseCase } from '../../application/use-cases/download-file/download-file.use-case.js'; import { DeleteFileUseCase } from '../../application/use-cases/delete-file/delete-file.use-case.js'; import { UploadFileCommand } from '../../application/use-cases/upload-file/upload-file.command.js'; import { DownloadFileQuery } from '../../application/use-cases/download-file/download-file.query.js'; import { DeleteFileCommand } from '../../application/use-cases/delete-file/delete-file.command.js'; import { FileNotFoundException } from '../../domain/exceptions/file-not-found.exception.js'; import { FileForbiddenException } from '../../domain/exceptions/file-forbidden.exception.js'; import { contentDisposition } from '../helpers/content-disposition.js'; @Controller('files') export class FileController { constructor( private readonly uploadFileUseCase: UploadFileUseCase, private readonly downloadFileUseCase: DownloadFileUseCase, private readonly deleteFileUseCase: DeleteFileUseCase, ) {} /** * multipart/form-data с полем `file`. Лимиты и временный каталог задаёт * MulterModule в FileModule; превышение размера — 413 от Nest. */ @Post() @UseInterceptors(FileInterceptor('file')) async upload(@UploadedFile() file: Express.Multer.File | undefined, @Session() session: UserSession) { if (!file) { throw new BadRequestException('Поле file обязательно'); } try { return await this.uploadFileUseCase.execute( new UploadFileCommand(session.user.id, file.path, file.originalname, file.size), ); } finally { await rm(file.path, { force: true }); } } @Get(':id/download') async download( @Param('id') id: string, @Session() session: UserSession, @Res({ passthrough: true }) res: Response, ): Promise { const { file, inline, stream } = await this.handleErrors(() => this.downloadFileUseCase.execute(new DownloadFileQuery(id, session.user.id)), ); // Браузер не должен «угадывать» тип: иначе octet-stream с HTML внутри // может исполниться как страница на нашем домене res.setHeader('X-Content-Type-Options', 'nosniff'); res.setHeader('Cache-Control', 'private, max-age=0, must-revalidate'); return new StreamableFile(stream, { type: file.mimeType, length: file.size, disposition: contentDisposition(inline ? 'inline' : 'attachment', file.fileName), }); } @Delete(':id') @HttpCode(HttpStatus.NO_CONTENT) async delete(@Param('id') id: string, @Session() session: UserSession) { await this.handleErrors(() => this.deleteFileUseCase.execute(new DeleteFileCommand(id, session.user.id))); } /** Транслирует доменные исключения в HTTP-ответ — это забота presentation-слоя */ private async handleErrors(fn: () => Promise): Promise { try { return await fn(); } catch (error) { if (error instanceof FileNotFoundException) { throw new NotFoundException(error.message); } if (error instanceof FileForbiddenException) { throw new ForbiddenException(error.message); } throw error; } } }