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