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