- 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>
41 lines
1.4 KiB
TypeScript
41 lines
1.4 KiB
TypeScript
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;
|
|
}
|
|
}
|