import { mkdir, readFile, rm, writeFile } from 'node:fs/promises'; import { join } from 'node:path'; import { Injectable } from '@nestjs/common'; import { AppConfig } from '../config/app-config.service'; /** * Filesystem binding for branding assets (issue #306; pond overrides #307). * * One flat directory of PNGs named by a caller-supplied key * (`instance-logo-light`, later `pond--favicon-32`). Flat because there * are a handful of files per instance and the backup archives the directory * as a whole — a tree would buy nothing and cost a traversal question. * * The key is constrained here rather than trusted from the route: it is the * only thing between a request parameter and a path. */ @Injectable() export class BrandingStorageService { constructor(private readonly config: AppConfig) {} /** Lowercase, digits and dashes only — no dot, so no `..`, and no slash, * so the file cannot leave the directory whatever a caller sends. */ private pathFor(key: string): string { if (!/^[a-z0-9-]{1,120}$/.test(key)) throw new Error(`invalid branding key: ${key}`); return join(this.config.env.BRANDING_DIR, `${key}.png`); } async save(key: string, bytes: Buffer): Promise { await mkdir(this.config.env.BRANDING_DIR, { recursive: true }); await writeFile(this.pathFor(key), bytes); } /** The bytes, or null when the file is absent — a missing asset is a normal * state here (nothing uploaded, or metadata and disk drifted after a * partial restore), and every caller has a fallback. */ async read(key: string): Promise { try { return await readFile(this.pathFor(key)); } catch { return null; } } /** Idempotent: removing what is not there is success. */ async remove(key: string): Promise { await rm(this.pathFor(key), { force: true }); } }