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,6 @@
export class DeleteFileCommand {
constructor(
public readonly id: string,
public readonly userId: string,
) {}
}
@@ -0,0 +1,52 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import type { IFileRepository } from '../../../domain/repositories/file.repository.interface.js';
import type { IFileStorage } from '../../../domain/ports/file-storage.port.js';
import { FileForbiddenException } from '../../../domain/exceptions/file-forbidden.exception.js';
import { FileNotFoundException } from '../../../domain/exceptions/file-not-found.exception.js';
import { FileAccessService } from '../../services/file-access.service.js';
import { DeleteFileUseCase } from './delete-file.use-case.js';
import { DeleteFileCommand } from './delete-file.command.js';
import { buildFile, mockFileRepository, mockFileStorage, registryWithOwner } from '../test-helpers.js';
describe('DeleteFileUseCase', () => {
let fileRepository: IFileRepository;
let fileStorage: IFileStorage;
let useCase: DeleteFileUseCase;
beforeEach(() => {
fileRepository = mockFileRepository();
fileStorage = mockFileStorage();
const registry = registryWithOwner({ visibleTo: { 'user-2': ['file-1'] } });
useCase = new DeleteFileUseCase(fileRepository, fileStorage, new FileAccessService(fileRepository, registry));
vi.mocked(fileRepository.findById).mockResolvedValue(buildFile());
});
it('deletes the object first, then the row', async () => {
const order: string[] = [];
vi.mocked(fileStorage.delete).mockImplementation(async () => void order.push('storage'));
vi.mocked(fileRepository.delete).mockImplementation(async () => void order.push('row'));
await useCase.execute(new DeleteFileCommand('file-1', 'user-1'));
expect(fileStorage.delete).toHaveBeenCalledWith('files/file-1');
expect(fileRepository.delete).toHaveBeenCalledWith('file-1');
expect(order).toEqual(['storage', 'row']);
});
it('keeps the row when deleting the object fails, so a retry can finish', async () => {
vi.mocked(fileStorage.delete).mockRejectedValue(new Error('s3 down'));
await expect(useCase.execute(new DeleteFileCommand('file-1', 'user-1'))).rejects.toThrow('s3 down');
expect(fileRepository.delete).not.toHaveBeenCalled();
});
it('forbids deleting a visible file uploaded by someone else', async () => {
await expect(useCase.execute(new DeleteFileCommand('file-1', 'user-2'))).rejects.toThrow(FileForbiddenException);
expect(fileStorage.delete).not.toHaveBeenCalled();
});
it('returns not found for an invisible file', async () => {
await expect(useCase.execute(new DeleteFileCommand('file-1', 'user-3'))).rejects.toThrow(FileNotFoundException);
});
});
@@ -0,0 +1,29 @@
import { Inject, Injectable } from '@nestjs/common';
import { FILE_REPOSITORY, type IFileRepository } from '../../../domain/repositories/file.repository.interface.js';
import { FILE_STORAGE, type IFileStorage } from '../../../domain/ports/file-storage.port.js';
import { DeleteFileCommand } from './delete-file.command.js';
import { FileAccessService } from '../../services/file-access.service.js';
/**
* Удаляет файл целиком; связи с владельцами уходят каскадом в БД.
* Порядок как в FileGcService: сначала объект, потом строка — при сбое
* повторный DELETE доведёт дело до конца, объект не потеряется.
*/
@Injectable()
export class DeleteFileUseCase {
constructor(
@Inject(FILE_REPOSITORY)
private readonly fileRepository: IFileRepository,
@Inject(FILE_STORAGE)
private readonly fileStorage: IFileStorage,
private readonly fileAccess: FileAccessService,
) {}
async execute(command: DeleteFileCommand): Promise<void> {
const file = await this.fileAccess.getVisibleFile(command.id, command.userId);
file.assertOwnedBy(command.userId);
await this.fileStorage.delete(file.storageKey);
await this.fileRepository.delete(command.id);
}
}
@@ -0,0 +1,6 @@
export class DownloadFileQuery {
constructor(
public readonly id: string,
public readonly userId: string,
) {}
}
@@ -0,0 +1,63 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import type { Readable } from 'node:stream';
import type { IFileRepository } from '../../../domain/repositories/file.repository.interface.js';
import type { IFileStorage } from '../../../domain/ports/file-storage.port.js';
import { FileNotFoundException } from '../../../domain/exceptions/file-not-found.exception.js';
import { FileAccessService } from '../../services/file-access.service.js';
import { DownloadFileUseCase } from './download-file.use-case.js';
import { DownloadFileQuery } from './download-file.query.js';
import { buildFile, mockFileRepository, mockFileStorage, registryWithOwner } from '../test-helpers.js';
describe('DownloadFileUseCase', () => {
let fileRepository: IFileRepository;
let fileStorage: IFileStorage;
let useCase: DownloadFileUseCase;
const stream = {} as Readable;
beforeEach(() => {
fileRepository = mockFileRepository();
fileStorage = mockFileStorage();
const registry = registryWithOwner({ visibleTo: { 'user-2': ['file-1'] } });
useCase = new DownloadFileUseCase(fileStorage, new FileAccessService(fileRepository, registry));
vi.mocked(fileStorage.get).mockResolvedValue(stream);
});
it('lets the uploader download the file', async () => {
vi.mocked(fileRepository.findById).mockResolvedValue(buildFile());
const result = await useCase.execute(new DownloadFileQuery('file-1', 'user-1'));
expect(fileStorage.get).toHaveBeenCalledWith('files/file-1');
expect(result).toMatchObject({ file: { id: 'file-1' }, inline: true, stream });
});
it('lets a user who sees an owner of the file download it', async () => {
vi.mocked(fileRepository.findById).mockResolvedValue(buildFile());
await expect(useCase.execute(new DownloadFileQuery('file-1', 'user-2'))).resolves.toMatchObject({
file: { id: 'file-1' },
});
});
it('marks non-image files as attachment-only', async () => {
vi.mocked(fileRepository.findById).mockResolvedValue(buildFile({ mimeType: 'application/octet-stream' }));
const result = await useCase.execute(new DownloadFileQuery('file-1', 'user-1'));
expect(result.inline).toBe(false);
});
it('hides the file from everyone else', async () => {
vi.mocked(fileRepository.findById).mockResolvedValue(buildFile());
await expect(useCase.execute(new DownloadFileQuery('file-1', 'user-3'))).rejects.toThrow(FileNotFoundException);
expect(fileStorage.get).not.toHaveBeenCalled();
});
it('throws FileNotFoundException for a missing file', async () => {
vi.mocked(fileRepository.findById).mockResolvedValue(null);
await expect(useCase.execute(new DownloadFileQuery('file-1', 'user-1'))).rejects.toThrow(FileNotFoundException);
});
});
@@ -0,0 +1,34 @@
import type { Readable } from 'node:stream';
import { Inject, Injectable } from '@nestjs/common';
import { FILE_STORAGE, type IFileStorage } from '../../../domain/ports/file-storage.port.js';
import { DownloadFileQuery } from './download-file.query.js';
import { FileDto } from '../../dto/file.dto.js';
import { FileApplicationMapper } from '../../mappers/file-application.mapper.js';
import { FileAccessService } from '../../services/file-access.service.js';
export interface DownloadFileResult {
file: FileDto;
/** Можно ли показывать в браузере inline, иначе — только скачивание */
inline: boolean;
stream: Readable;
}
@Injectable()
export class DownloadFileUseCase {
constructor(
@Inject(FILE_STORAGE)
private readonly fileStorage: IFileStorage,
private readonly fileAccess: FileAccessService,
) {}
async execute(query: DownloadFileQuery): Promise<DownloadFileResult> {
const file = await this.fileAccess.getVisibleFile(query.id, query.userId);
const stream = await this.fileStorage.get(file.storageKey);
return {
file: FileApplicationMapper.toDto(file),
inline: file.isInlineViewable,
stream,
};
}
}
@@ -0,0 +1,3 @@
export class GetFilesQuery {
constructor(public readonly ids: string[]) {}
}
@@ -0,0 +1,24 @@
import { Inject, Injectable } from '@nestjs/common';
import { FILE_REPOSITORY, type IFileRepository } from '../../../domain/repositories/file.repository.interface.js';
import { GetFilesQuery } from './get-files.query.js';
import { FileDto } from '../../dto/file.dto.js';
import { FileApplicationMapper } from '../../mappers/file-application.mapper.js';
/**
* Метаданные файлов по id — для модулей-владельцев, раскрывающих свои связи
* в FileDto (по аналогии с GetUsersUseCase). Доступ проверяет вызывающий:
* он отдаёт только файлы своих сущностей, видимых пользователю.
*/
@Injectable()
export class GetFilesUseCase {
constructor(
@Inject(FILE_REPOSITORY)
private readonly fileRepository: IFileRepository,
) {}
async execute(query: GetFilesQuery): Promise<FileDto[]> {
const ids = [...new Set(query.ids)];
const files = await this.fileRepository.findByIds(ids);
return files.map(FileApplicationMapper.toDto);
}
}
@@ -0,0 +1,46 @@
import { vi } from 'vitest';
import type { IFileRepository } from '../../domain/repositories/file.repository.interface.js';
import type { IFileStorage } from '../../domain/ports/file-storage.port.js';
import { StoredFile } from '../../domain/entities/stored-file.entity.js';
import { FileOwnerRegistry, type FileOwner } from '../services/file-owner.registry.js';
export const buildFile = (
overrides: Partial<{ id: string; uploaderId: string | null; mimeType: string; createdAt: Date }> = {},
) =>
StoredFile.reconstitute({
id: overrides.id ?? 'file-1',
storageKey: `files/${overrides.id ?? 'file-1'}`,
fileName: 'photo.png',
mimeType: overrides.mimeType ?? 'image/png',
size: 10,
uploaderId: overrides.uploaderId === undefined ? 'user-1' : overrides.uploaderId,
createdAt: overrides.createdAt ?? new Date('2026-01-01T00:00:00.000Z'),
});
export const mockFileRepository = (): IFileRepository => ({
findById: vi.fn(),
findByIds: vi.fn(),
findCreatedBefore: vi.fn(),
create: vi.fn(),
delete: vi.fn(),
});
export const mockFileStorage = (): IFileStorage => ({
put: vi.fn(),
get: vi.fn(),
delete: vi.fn(),
});
/** Реестр с одним владельцем, которому видны/принадлежат перечисленные файлы */
export const registryWithOwner = (params: { referenced?: string[]; visibleTo?: Record<string, string[]> } = {}) => {
const owner: FileOwner = {
name: 'test',
findReferenced: vi.fn(async (ids: string[]) => ids.filter((id) => params.referenced?.includes(id))),
isVisibleThrough: vi.fn(async (fileId: string, userId: string) =>
Boolean(params.visibleTo?.[userId]?.includes(fileId)),
),
};
const registry = new FileOwnerRegistry();
registry.register(owner);
return registry;
};
@@ -0,0 +1,9 @@
export class UploadFileCommand {
constructor(
public readonly uploaderId: string,
/** Временный файл, куда multer сохранил загрузку. Удаляет его вызывающий */
public readonly tempPath: string,
public readonly originalName: string,
public readonly size: number,
) {}
}
@@ -0,0 +1,75 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import type { IFileRepository } from '../../../domain/repositories/file.repository.interface.js';
import type { IFileStorage } from '../../../domain/ports/file-storage.port.js';
import type { IFileTypeDetector } from '../../../domain/ports/file-type-detector.port.js';
import { StoredFile } from '../../../domain/entities/stored-file.entity.js';
import { UploadFileUseCase } from './upload-file.use-case.js';
import { UploadFileCommand } from './upload-file.command.js';
import { mockFileRepository, mockFileStorage } from '../test-helpers.js';
vi.mock('node:fs', () => ({ createReadStream: vi.fn(() => 'stream') }));
describe('UploadFileUseCase', () => {
let fileRepository: IFileRepository;
let fileStorage: IFileStorage;
let detector: IFileTypeDetector;
let useCase: UploadFileUseCase;
beforeEach(() => {
fileRepository = mockFileRepository();
fileStorage = mockFileStorage();
detector = { detect: vi.fn() };
useCase = new UploadFileUseCase(fileRepository, fileStorage, detector);
vi.mocked(fileRepository.create).mockImplementation(async (file: StoredFile) =>
StoredFile.reconstitute({
id: 'file-1',
storageKey: file.storageKey,
fileName: file.fileName,
mimeType: file.mimeType,
size: file.size,
uploaderId: file.uploaderId,
createdAt: file.createdAt,
}),
);
});
it('stores the file under a generated key with the sniffed MIME type', async () => {
vi.mocked(detector.detect).mockResolvedValue('image/png');
const dto = await useCase.execute(new UploadFileCommand('user-1', '/tmp/upload', 'photo.png', 10));
const [key, , meta] = vi.mocked(fileStorage.put).mock.calls[0]!;
expect(key).toMatch(/^files\/[0-9a-f-]{36}$/);
expect(meta).toEqual({ contentType: 'image/png', size: 10 });
expect(dto).toEqual({
id: 'file-1',
fileName: 'photo.png',
mimeType: 'image/png',
size: 10,
uploaderId: 'user-1',
createdAt: expect.any(Date),
});
expect(dto).not.toHaveProperty('storageKey');
});
it('falls back to octet-stream for unrecognized content, ignoring the name', async () => {
vi.mocked(detector.detect).mockResolvedValue(null);
const dto = await useCase.execute(new UploadFileCommand('user-1', '/tmp/upload', 'evil.html', 10));
expect(dto.mimeType).toBe('application/octet-stream');
});
it('removes the stored object when saving the row fails', async () => {
vi.mocked(detector.detect).mockResolvedValue('image/png');
vi.mocked(fileRepository.create).mockRejectedValue(new Error('db down'));
await expect(useCase.execute(new UploadFileCommand('user-1', '/tmp/upload', 'photo.png', 10))).rejects.toThrow(
'db down',
);
const [key] = vi.mocked(fileStorage.put).mock.calls[0]!;
expect(fileStorage.delete).toHaveBeenCalledWith(key);
});
});
@@ -0,0 +1,64 @@
import { randomUUID } from 'node:crypto';
import { createReadStream } from 'node:fs';
import { Inject, Injectable, Logger } from '@nestjs/common';
import { FILE_REPOSITORY, type IFileRepository } from '../../../domain/repositories/file.repository.interface.js';
import { FILE_STORAGE, type IFileStorage } from '../../../domain/ports/file-storage.port.js';
import { FILE_TYPE_DETECTOR, type IFileTypeDetector } from '../../../domain/ports/file-type-detector.port.js';
import { StoredFile } from '../../../domain/entities/stored-file.entity.js';
import { UploadFileCommand } from './upload-file.command.js';
import { FileDto } from '../../dto/file.dto.js';
import { FileApplicationMapper } from '../../mappers/file-application.mapper.js';
const UNKNOWN_MIME_TYPE = 'application/octet-stream';
/**
* Загружает файл без привязки к владельцу. Прикрепление к задаче/комментарию —
* отдельный шаг в модуле-владельце; не прикреплённый файл удалит GC.
*/
@Injectable()
export class UploadFileUseCase {
private readonly logger = new Logger(UploadFileUseCase.name);
constructor(
@Inject(FILE_REPOSITORY)
private readonly fileRepository: IFileRepository,
@Inject(FILE_STORAGE)
private readonly fileStorage: IFileStorage,
@Inject(FILE_TYPE_DETECTOR)
private readonly fileTypeDetector: IFileTypeDetector,
) {}
async execute(command: UploadFileCommand): Promise<FileDto> {
// Заголовку Content-Type от клиента не доверяем. Нераспознанное
// (в т.ч. HTML/SVG) хранится как octet-stream и отдаётся только на скачивание.
const mimeType = (await this.fileTypeDetector.detect(command.tempPath)) ?? UNKNOWN_MIME_TYPE;
const storageKey = `files/${randomUUID()}`;
await this.fileStorage.put(storageKey, createReadStream(command.tempPath), {
contentType: mimeType,
size: command.size,
});
try {
const file = await this.fileRepository.create(
StoredFile.create({
storageKey,
fileName: command.originalName,
mimeType,
size: command.size,
uploaderId: command.uploaderId,
}),
);
return FileApplicationMapper.toDto(file);
} catch (error) {
// Строки нет — GC этот объект никогда не найдёт, убираем сразу
try {
await this.fileStorage.delete(storageKey);
} catch (cleanupError) {
this.logger.warn(`Не удалось удалить объект ${storageKey}: ${String(cleanupError)}`);
}
throw error;
}
}
}