import { promises as fs } from "fs" import path from "path" import { randomBytes } from "crypto" import { S3Client, PutObjectCommand, GetObjectCommand, DeleteObjectCommand, ListObjectsV2Command, type GetObjectCommandOutput, } from "@aws-sdk/client-s3" import { getSignedUrl } from "@aws-sdk/s3-request-presigner" // Local-disk fallback root. Used only when object storage is not configured. // Kept OUTSIDE the public web root so files are only served through /api/files. 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" } // Single source of truth for what users may upload. Deliberately excludes svg // and any html/script types, which can execute JavaScript when served inline // from our origin. Enforce this at EVERY upload entry point (see /api/upload // and /api/documents) — divergence is how an allowlist gets bypassed. export const ALLOWED_UPLOAD_EXTENSIONS = [ "pdf", "png", "jpg", "jpeg", "gif", "webp", "doc", "docx", "xls", "xlsx", "csv", "txt", ] as const export function extOf(filename: string): string { return filename.split(".").pop()?.toLowerCase() ?? "" } export function isAllowedUploadExt(filename: string): boolean { return (ALLOWED_UPLOAD_EXTENSIONS as readonly string[]).includes(extOf(filename)) } // ── Object storage (DigitalOcean Spaces / S3-compatible) ────────────────────── // When SPACES_* are configured, uploads and serving use the bucket instead of // local disk. The key scheme (`//`) is identical either // way, so existing /api/files/ URLs stored in the DB keep working after the // backend switch — only the bytes move. const SPACES_BUCKET = process.env.SPACES_BUCKET ?? "" export function usingSpaces(): boolean { return Boolean(process.env.SPACES_KEY && process.env.SPACES_SECRET && SPACES_BUCKET) } /** * Thrown when a write is attempted in production without object storage * configured. The local-disk fallback is EPHEMERAL on App Platform, so silently * using it means uploads vanish on the next deploy. We fail loud instead. */ export class StorageNotConfiguredError extends Error { constructor() { super( "Object storage (SPACES_*) is not configured. Refusing to write uploads to " + "ephemeral local disk in production — files would be lost on the next deploy." ) this.name = "StorageNotConfiguredError" } } function newClient(endpoint: string | undefined): S3Client { return new S3Client({ region: process.env.SPACES_REGION || "us-east-1", endpoint, forcePathStyle: false, credentials: { accessKeyId: process.env.SPACES_KEY!, secretAccessKey: process.env.SPACES_SECRET!, }, }) } // Origin client — used for all mutating/reading operations (PUT/GET/DELETE/HEAD). let _s3: S3Client | null = null function s3(): S3Client { if (!_s3) _s3 = newClient(process.env.SPACES_ENDPOINT) // e.g. https://nyc3.digitaloceanspaces.com return _s3 } // Rewrite a presigned origin URL to the Spaces CDN edge host when the CDN is // enabled. The URL is signed against the ORIGIN host; the CDN forwards requests // to origin with the origin Host header, so the SigV4 signature still validates. // (Signing directly against the CDN host is rejected by origin with 403.) function toCdnUrl(signedUrl: string): string { const cdn = process.env.SPACES_CDN_ENDPOINT const origin = process.env.SPACES_ENDPOINT if (!cdn || !origin) return signedUrl try { const originHost = new URL(origin).host // e.g. nyc3.digitaloceanspaces.com const cdnHost = new URL(cdn).host // e.g. nyc3.cdn.digitaloceanspaces.com const u = new URL(signedUrl) if (u.host.endsWith(originHost)) { u.host = u.host.slice(0, u.host.length - originHost.length) + cdnHost return u.toString() } return signedUrl } catch { return signedUrl } } // ── key helpers ─────────────────────────────────────────────────────────────── /** Validate a storage key, refusing empty/traversal segments. Returns it cleaned. */ function assertSafeKey(key: string): string { const clean = key.replace(/^\/+/, "") if (!clean || clean.split(/[\\/]+/).some((seg) => seg === "" || seg === "." || seg === "..")) { throw new Error("Invalid storage path") } return clean } /** Resolve a key to an absolute local path (local-disk backend only). */ function resolveKey(key: string): string { const clean = assertSafeKey(key) 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, "_") } /** * True iff a storage key lives in the given owner's namespace (`/…`). * Keys are generated server-side as `//`, so any * client-supplied key/path whose first segment differs belongs to another tenant * (or is malformed) and must be rejected. */ export function keyBelongsToOwner(key: string | null | undefined, ownerId: string): boolean { if (!key || !ownerId) return false const first = key.replace(/^\/+/, "").split(/[\\/]+/)[0] return first === sanitizeSegment(ownerId) } /** * Lightweight magic-byte check: reject a file whose real content doesn't match * its claimed extension (e.g. an HTML/script payload renamed to `.pdf`). Types * without a reliable file signature (csv/txt) are allowed through. `head` should * be the first ~16 bytes of the file. */ export function contentMatchesExtension(head: Buffer, ext: string): boolean { const at = (offset: number, sig: number[]) => head.length >= offset + sig.length && sig.every((b, i) => head[offset + i] === b) switch (ext) { case "pdf": return at(0, [0x25, 0x50, 0x44, 0x46]) // %PDF case "png": return at(0, [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]) case "jpg": case "jpeg": return at(0, [0xff, 0xd8, 0xff]) case "gif": return at(0, [0x47, 0x49, 0x46, 0x38]) // GIF8 case "webp": return at(0, [0x52, 0x49, 0x46, 0x46]) && at(8, [0x57, 0x45, 0x42, 0x50]) // RIFF…WEBP case "docx": case "xlsx": return at(0, [0x50, 0x4b, 0x03, 0x04]) || at(0, [0x50, 0x4b, 0x05, 0x06]) // zip (PK) case "doc": case "xls": return at(0, [0xd0, 0xcf, 0x11, 0xe0, 0xa1, 0xb1, 0x1a, 0xe1]) || at(0, [0x50, 0x4b]) // OLE or zip case "csv": case "txt": return true // no reliable signature default: return true } } async function bodyToBuffer(body: GetObjectCommandOutput["Body"]): Promise { if (!body) return Buffer.alloc(0) // The AWS SDK v3 Node runtime adds transformToByteArray() to the stream body. const stream = body as { transformToByteArray?: () => Promise } if (typeof stream.transformToByteArray === "function") { return Buffer.from(await stream.transformToByteArray()) } const chunks: Buffer[] = [] for await (const chunk of body as AsyncIterable) chunks.push(Buffer.from(chunk)) return Buffer.concat(chunks) } /** * 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 buffer = Buffer.from(await file.arrayBuffer()) const type = file.type || contentTypeForKey(key) if (usingSpaces()) { await s3().send( new PutObjectCommand({ Bucket: SPACES_BUCKET, Key: key, Body: buffer, ContentType: type, ACL: "private", }) ) } else { // In production the local-disk backend is ephemeral (lost on redeploy), so a // misconfigured Spaces setup must fail loudly rather than silently drop data. if (process.env.NODE_ENV === "production") throw new StorageNotConfiguredError() const abs = resolveKey(key) await fs.mkdir(path.dirname(abs), { recursive: true }) await fs.writeFile(abs, buffer) } return { key, size: file.size, type } } /** * Persist raw bytes under `${userId}/${scope}/.` (server-generated, * so the key is always in the owner's namespace) and return the storage key. * Used for server-side artifacts like signed e-sign PDFs. */ export async function saveBuffer( buffer: Buffer, opts: { userId: string; scope: string; ext: string } ): Promise<{ key: string }> { const ext = opts.ext.replace(/[^a-z0-9]/gi, "").toLowerCase() || "bin" const key = `${sanitizeSegment(opts.userId)}/${sanitizeSegment(opts.scope)}/${Date.now()}-${randomBytes(6).toString("hex")}.${ext}` if (usingSpaces()) { await s3().send( new PutObjectCommand({ Bucket: SPACES_BUCKET, Key: key, Body: buffer, ContentType: contentTypeForKey(key), ACL: "private", }) ) } else { if (process.env.NODE_ENV === "production") throw new StorageNotConfiguredError() const abs = resolveKey(key) await fs.mkdir(path.dirname(abs), { recursive: true }) await fs.writeFile(abs, buffer) } return { key } } export async function readFile(key: string): Promise { if (usingSpaces()) { const res = await s3().send(new GetObjectCommand({ Bucket: SPACES_BUCKET, Key: assertSafeKey(key) })) return bodyToBuffer(res.Body) } return fs.readFile(resolveKey(key)) } /** * Presigned, time-limited GET URL for an object in Spaces — lets the browser * fetch the bytes directly from the bucket (offloading them from the app) while * access stays gated: the caller must pass auth + ownership checks before this * is issued, and the URL expires. `ResponseContent*` control how the browser * treats the file (inline image vs. attachment download) with the right type. */ export async function presignGetUrl( key: string, opts: { expiresIn?: number; disposition?: "inline" | "attachment"; filename?: string } = {} ): Promise { const safe = assertSafeKey(key) // The key's basename is already generated/sanitized at upload; keep only // filename-safe characters for the Content-Disposition header. const filename = (opts.filename ?? safe.split("/").pop() ?? "file").replace(/[^A-Za-z0-9._-]/g, "_") const cmd = new GetObjectCommand({ Bucket: SPACES_BUCKET, Key: safe, ResponseContentType: contentTypeForKey(safe), ResponseContentDisposition: `${opts.disposition ?? "inline"}; filename="${filename}"`, }) const signed = await getSignedUrl(s3(), cmd, { expiresIn: opts.expiresIn ?? 3600 }) return toCdnUrl(signed) } export async function deleteFile(key: string, ownerId: string): Promise { // Defense in depth: never delete an object outside the caller's own namespace, // even if a stored storage_path was tampered with to point at another tenant. if (!keyBelongsToOwner(key, ownerId)) return try { if (usingSpaces()) { await s3().send(new DeleteObjectCommand({ Bucket: SPACES_BUCKET, Key: assertSafeKey(key) })) } else { await fs.unlink(resolveKey(key)) } } catch { // Already gone — ignore. } } /** * Permanently delete EVERY stored object in a user's namespace (`/…`), * across whichever backend is active. Used by GDPR account deletion — there is * no undo. Returns the number of objects removed (best effort; local-disk * removals aren't counted individually). */ export async function deleteUserStorage(userId: string): Promise { const prefix = `${sanitizeSegment(userId)}/` if (usingSpaces()) { let deleted = 0 let token: string | undefined do { const res = await s3().send( new ListObjectsV2Command({ Bucket: SPACES_BUCKET, Prefix: prefix, ContinuationToken: token }) ) for (const obj of res.Contents ?? []) { if (!obj.Key) continue await s3().send(new DeleteObjectCommand({ Bucket: SPACES_BUCKET, Key: obj.Key })) deleted++ } token = res.IsTruncated ? res.NextContinuationToken : undefined } while (token) return deleted } await fs.rm(path.join(STORAGE_DIR, sanitizeSegment(userId)), { recursive: true, force: true }) return 0 } async function walkDirSize(dir: string): Promise { let entries try { entries = await fs.readdir(dir, { withFileTypes: true }) } catch { return 0 // directory doesn't exist yet → 0 bytes } let total = 0 for (const e of entries) { const full = path.join(dir, e.name) if (e.isDirectory()) total += await walkDirSize(full) else if (e.isFile()) { try { total += (await fs.stat(full)).size } catch { // File vanished between readdir and stat — ignore. } } } return total } /** * Total bytes currently stored for a user, across whichever backend is active. * Files are namespaced under `/…`, so we sum that prefix. Used to * enforce per-plan storage quotas at upload time. */ export async function getUserStorageBytes(userId: string): Promise { const prefix = `${sanitizeSegment(userId)}/` if (usingSpaces()) { let total = 0 let token: string | undefined do { const res = await s3().send( new ListObjectsV2Command({ Bucket: SPACES_BUCKET, Prefix: prefix, ContinuationToken: token }) ) for (const obj of res.Contents ?? []) total += obj.Size ?? 0 token = res.IsTruncated ? res.NextContinuationToken : undefined } while (token) return total } return walkDirSize(path.join(STORAGE_DIR, sanitizeSegment(userId))) }