Add file module for uploads and storage

- 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>
This commit is contained in:
vadimandClaude Opus 5.5 committed 2026-10-02 09:17:55 +03:00
1 parent 5cfc8986d7
commit f32c6e348d
49 files changed
+1992 -2

No files matched your search

@@ -0,0 +1,101 @@
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;
}
}
}