- File model + migration: file metadata, content lives in object storage - IFileStorage port with local disk and S3-compatible (RustFS/MinIO) adapters - POST /files, GET /files/:id/download, DELETE /files/:id - MIME sniffed from content; only safe images served inline, nosniff header - FileOwnerRegistry: owner modules register to grant visibility and keep their files from GC; FileGcService removes orphaned files hourly - TransformResponseInterceptor passes StreamableFile through Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
102 lines
3.8 KiB
TypeScript
102 lines
3.8 KiB
TypeScript
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<StreamableFile> {
|
||
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<T>(fn: () => Promise<T>): Promise<T> {
|
||
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;
|
||
}
|
||
}
|
||
}
|