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:
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user