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
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
38 lines
1.3 KiB
TypeScript
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 });
|
|
}
|
|
}
|