dorfteich/apps/api/src/files/file-storage.service.ts
Claude Sonnet 5 0fae699018
All checks were successful
CD / Build and push images (push) Successful in 2m2s
CI / Lint, typecheck, test (push) Successful in 1m43s
CI / Auth e2e pack (push) Successful in 1m48s
CI / Build container images (push) Has been skipped
CD / Deploy to Test (push) Successful in 8s
CD / Smoke tests against Test (push) Successful in 1m10s
CD / Promote to Int (push) Successful in 10s
Add file storage service and image upload API (#27)
Implements the FileStorage abstraction (uploads/<pondId>/<fileId> on the
mounted volume), the attachments model, and POST /ponds/:id/files, GET
/media/:fileId, DELETE /files/:id. Uploads are validated by sniffing
magic bytes rather than trusting the client's Content-Type/filename
(catches a renamed .html-as-.png), checked against the max_file_bytes
and storage_bytes quotas, and served with nosniff + immutable caching.

Closes #27
2026-07-08 10:35:03 +02:00

38 lines
1.3 KiB
TypeScript

import { createReadStream } from 'node:fs';
import { mkdir, rm, writeFile } from 'node:fs/promises';
import { join } from 'node:path';
import type { Readable } from 'node:stream';
import { Injectable } from '@nestjs/common';
import { AppConfig } from '../config/app-config.service';
/**
* Filesystem binding for uploaded files (ADR 0011, issue #27): opaque
* layout `<uploadsDir>/<pondId>/<fileId>`, original filenames and metadata
* live in the database, not on disk. Kept behind this interface so an S3
* binding stays possible later without touching callers.
*/
@Injectable()
export class FileStorageService {
constructor(private readonly config: AppConfig) {}
private pathFor(pondId: string, fileId: string): string {
return join(this.config.env.UPLOADS_DIR, pondId, fileId);
}
async save(pondId: string, fileId: string, data: Buffer): Promise<void> {
await mkdir(join(this.config.env.UPLOADS_DIR, pondId), { recursive: true });
await writeFile(this.pathFor(pondId, fileId), data);
}
createReadStream(pondId: string, fileId: string): Readable {
return createReadStream(this.pathFor(pondId, fileId));
}
/** Idempotent — removing an already-absent file is not an error. */
async delete(pondId: string, fileId: string): Promise<void> {
await rm(this.pathFor(pondId, fileId), { force: true });
}
}