Files
ulitka/src/modules/file/presentation/controllers/file.controller.ts
T
vadimandClaude Opus 5.5 f32c6e348d 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>
2026-10-02 09:17:55 +03:00

102 lines
3.8 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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;
}
}
}