import { promises as fs } from "fs" import path from "path" import { randomBytes } from "crypto" // Root directory for uploaded files. Kept OUTSIDE the public web root so files // are only ever served through the auth-gated /api/files route. const STORAGE_DIR = path.resolve(process.cwd(), process.env.STORAGE_DIR ?? "./storage") const MIME_BY_EXT: Record = { pdf: "application/pdf", png: "image/png", jpg: "image/jpeg", jpeg: "image/jpeg", gif: "image/gif", webp: "image/webp", doc: "application/msword", docx: "application/vnd.openxmlformats-officedocument.wordprocessingml.document", xls: "application/vnd.ms-excel", xlsx: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", csv: "text/csv", txt: "text/plain", } export function contentTypeForKey(key: string): string { const ext = key.split(".").pop()?.toLowerCase() ?? "" return MIME_BY_EXT[ext] ?? "application/octet-stream" } /** Resolve a storage key to an absolute path, refusing path traversal. */ function resolveKey(key: string): string { const clean = key.replace(/^\/+/, "") if (clean.split(/[\\/]+/).some((seg) => seg === ".." || seg === ".")) { throw new Error("Invalid storage path") } const abs = path.resolve(STORAGE_DIR, clean) if (abs !== STORAGE_DIR && !abs.startsWith(STORAGE_DIR + path.sep)) { throw new Error("Invalid storage path") } return abs } function sanitizeSegment(s: string): string { return s.replace(/[^a-zA-Z0-9_-]/g, "_") } /** * Persist an uploaded File under `${userId}/${scope}/.` and return * the storage key (relative path). Optionally pass `fixedName` to make the file * name deterministic (e.g. one photo per property). */ export async function saveFile( file: File, opts: { userId: string; scope: string; fixedName?: string } ): Promise<{ key: string; size: number; type: string }> { const ext = (file.name.split(".").pop() ?? "bin").toLowerCase().replace(/[^a-z0-9]/g, "") const base = opts.fixedName ? sanitizeSegment(opts.fixedName) : `${Date.now()}-${randomBytes(6).toString("hex")}` const key = `${sanitizeSegment(opts.userId)}/${sanitizeSegment(opts.scope)}/${base}.${ext}` const abs = resolveKey(key) await fs.mkdir(path.dirname(abs), { recursive: true }) const buffer = Buffer.from(await file.arrayBuffer()) await fs.writeFile(abs, buffer) return { key, size: file.size, type: file.type || contentTypeForKey(key) } } export async function readFile(key: string): Promise { return fs.readFile(resolveKey(key)) } export async function deleteFile(key: string): Promise { try { await fs.unlink(resolveKey(key)) } catch { // Already gone — ignore. } }