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,27 @@
import type { File as PrismaFile, Prisma } from '../../../../../../generated/prisma/client.js';
import { StoredFile } from '../../../../domain/entities/stored-file.entity.js';
export class FilePrismaMapper {
static toDomain(record: PrismaFile): StoredFile {
return StoredFile.reconstitute({
id: record.id,
storageKey: record.storageKey,
fileName: record.fileName,
mimeType: record.mimeType,
size: record.size,
uploaderId: record.uploaderId,
createdAt: record.createdAt,
});
}
static toCreateInput(file: StoredFile): Prisma.FileUncheckedCreateInput {
return {
storageKey: file.storageKey,
fileName: file.fileName,
mimeType: file.mimeType,
size: file.size,
uploaderId: file.uploaderId,
createdAt: file.createdAt,
};
}
}
@@ -0,0 +1,46 @@
import { Injectable } from '@nestjs/common';
import { PrismaAdapter } from '../../../../../../common/prisma-adapter/prisma.adapter.js';
import { StoredFile } from '../../../../domain/entities/stored-file.entity.js';
import { IFileRepository } from '../../../../domain/repositories/file.repository.interface.js';
import { FilePrismaMapper } from '../mappers/file.mapper.js';
@Injectable()
export class FilePrismaRepository implements IFileRepository {
constructor(private readonly prisma: PrismaAdapter) {}
async findById(id: string): Promise<StoredFile | null> {
const record = await this.prisma.file.findUnique({ where: { id } });
return record ? FilePrismaMapper.toDomain(record) : null;
}
async findByIds(ids: string[]): Promise<StoredFile[]> {
if (ids.length === 0) return [];
const records = await this.prisma.file.findMany({ where: { id: { in: ids } } });
return records.map(FilePrismaMapper.toDomain);
}
async findCreatedBefore(params: { before: Date; limit: number; cursor?: string }): Promise<StoredFile[]> {
const records = await this.prisma.file.findMany({
where: { createdAt: { lt: params.before } },
orderBy: { id: 'asc' },
take: Math.max(1, params.limit),
...(params.cursor ? { cursor: { id: params.cursor }, skip: 1 } : {}),
});
return records.map(FilePrismaMapper.toDomain);
}
async create(file: StoredFile): Promise<StoredFile> {
const record = await this.prisma.file.create({
data: FilePrismaMapper.toCreateInput(file),
});
return FilePrismaMapper.toDomain(record);
}
async delete(id: string): Promise<void> {
// deleteMany не падает, если строку уже удалил параллельный запрос/GC
await this.prisma.file.deleteMany({ where: { id } });
}
}
@@ -0,0 +1,26 @@
import { ConfigService } from '@nestjs/config';
import type { IFileStorage } from '../../domain/ports/file-storage.port.js';
import { LocalFileStorage } from './local-file-storage.js';
import { S3FileStorage } from './s3-file-storage.js';
/** FILE_STORAGE=s3 — S3-совместимое хранилище, иначе локальный диск */
export function createFileStorage(config: ConfigService): IFileStorage {
const driver = config.get<string>('FILE_STORAGE', 'local');
if (driver === 's3') {
return new S3FileStorage({
endpoint: config.get<string>('S3_ENDPOINT') || undefined,
region: config.get<string>('S3_REGION', 'us-east-1'),
bucket: config.getOrThrow<string>('S3_BUCKET'),
accessKeyId: config.getOrThrow<string>('S3_ACCESS_KEY_ID'),
secretAccessKey: config.getOrThrow<string>('S3_SECRET_ACCESS_KEY'),
forcePathStyle: config.get<string>('S3_FORCE_PATH_STYLE', 'true') !== 'false',
});
}
if (driver !== 'local') {
throw new Error(`Неизвестный FILE_STORAGE=${driver}, ожидается local | s3`);
}
return new LocalFileStorage(config.get<string>('FILE_STORAGE_LOCAL_DIR', './storage'));
}
@@ -0,0 +1,11 @@
import { Injectable } from '@nestjs/common';
import { fileTypeFromFile } from 'file-type';
import type { IFileTypeDetector } from '../../domain/ports/file-type-detector.port.js';
/** Определение типа по сигнатуре (magic bytes) через file-type */
@Injectable()
export class FileTypeDetector implements IFileTypeDetector {
async detect(path: string): Promise<string | null> {
return (await fileTypeFromFile(path))?.mime ?? null;
}
}
@@ -0,0 +1,38 @@
import { mkdtemp, rm } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { Readable } from 'node:stream';
import { text } from 'node:stream/consumers';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { LocalFileStorage } from './local-file-storage.js';
describe('LocalFileStorage', () => {
let root: string;
let storage: LocalFileStorage;
beforeEach(async () => {
root = await mkdtemp(join(tmpdir(), 'ulitka-files-'));
storage = new LocalFileStorage(root);
});
afterEach(async () => {
await rm(root, { recursive: true, force: true });
});
it('puts, gets and deletes an object', async () => {
await storage.put('files/a', Readable.from(['hello']));
expect(await text(await storage.get('files/a'))).toBe('hello');
await storage.delete('files/a');
await expect(storage.get('files/a')).rejects.toThrow();
});
it('treats deleting a missing object as success', async () => {
await expect(storage.delete('files/missing')).resolves.toBeUndefined();
});
it('rejects keys escaping the root', async () => {
await expect(storage.put('../escape', Readable.from(['x']))).rejects.toThrow('Недопустимый ключ');
});
});
@@ -0,0 +1,40 @@
import { createReadStream, createWriteStream } from 'node:fs';
import { mkdir, rm, stat } from 'node:fs/promises';
import { dirname, resolve, sep } from 'node:path';
import type { Readable } from 'node:stream';
import { pipeline } from 'node:stream/promises';
import type { IFileStorage } from '../../domain/ports/file-storage.port.js';
/** Хранилище на локальном диске — для разработки и тестов */
export class LocalFileStorage implements IFileStorage {
private readonly root: string;
constructor(root: string) {
this.root = resolve(root);
}
async put(key: string, body: Readable): Promise<void> {
const path = this.resolveKey(key);
await mkdir(dirname(path), { recursive: true });
await pipeline(body, createWriteStream(path));
}
async get(key: string): Promise<Readable> {
const path = this.resolveKey(key);
await stat(path);
return createReadStream(path);
}
async delete(key: string): Promise<void> {
await rm(this.resolveKey(key), { force: true });
}
/** Ключи генерирует приложение, но выход за пределы root всё равно запрещаем */
private resolveKey(key: string): string {
const path = resolve(this.root, key);
if (!path.startsWith(this.root + sep)) {
throw new Error(`Недопустимый ключ хранилища: ${key}`);
}
return path;
}
}
@@ -0,0 +1,60 @@
import type { Readable } from 'node:stream';
import { DeleteObjectCommand, GetObjectCommand, PutObjectCommand, S3Client } from '@aws-sdk/client-s3';
import type { IFileStorage } from '../../domain/ports/file-storage.port.js';
export interface S3FileStorageOptions {
/** Для MinIO/RustFS — адрес сервера, для AWS можно не задавать */
endpoint?: string;
region: string;
bucket: string;
accessKeyId: string;
secretAccessKey: string;
/** MinIO/RustFS обычно требуют path-style (http://host/bucket/key) */
forcePathStyle: boolean;
}
/**
* S3-совместимое хранилище (AWS S3, MinIO, RustFS...).
* Бакет должен существовать заранее — приложение его не создаёт.
*/
export class S3FileStorage implements IFileStorage {
private readonly client: S3Client;
private readonly bucket: string;
constructor(options: S3FileStorageOptions) {
this.bucket = options.bucket;
this.client = new S3Client({
endpoint: options.endpoint,
region: options.region,
forcePathStyle: options.forcePathStyle,
credentials: {
accessKeyId: options.accessKeyId,
secretAccessKey: options.secretAccessKey,
},
});
}
async put(key: string, body: Readable, meta: { contentType: string; size: number }): Promise<void> {
await this.client.send(
new PutObjectCommand({
Bucket: this.bucket,
Key: key,
Body: body,
ContentType: meta.contentType,
// Без длины SDK не может отправить поток одним PUT
ContentLength: meta.size,
}),
);
}
async get(key: string): Promise<Readable> {
const response = await this.client.send(new GetObjectCommand({ Bucket: this.bucket, Key: key }));
// В Node.js Body — это Readable (IncomingMessage с примесями SDK)
return response.Body as Readable;
}
async delete(key: string): Promise<void> {
// DeleteObject в S3 идемпотентен — отсутствие объекта не ошибка
await this.client.send(new DeleteObjectCommand({ Bucket: this.bucket, Key: key }));
}
}