Files
property-management-network/lib/storage.ts
T
Leon SerfatyandClaude Opus 4.8 857b9a7811 Initial import: property management SaaS + security hardening + admin dashboard
Property Management Network — Next.js 16 (App Router), Better Auth,
Drizzle ORM over PostgreSQL, Stripe, OpenAI, Resend.

Includes:
- Security hardening: access-control/IDOR fixes, TLS-by-default DB layer,
  constant-time cron auth, strict security headers, atomic AI quota gating,
  HTML/email output encoding, demo-backdoor disabled in production.
- Superadmin dashboard at /admin (overview/MRR, server-paginated users with
  ban/impersonate/plan/delete, billing, platform activity + admin audit log,
  AI usage, system health) via the Better Auth admin plugin.
- Seed/migration utility scripts under scripts/.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 20:36:07 -04:00

77 lines
2.5 KiB
TypeScript

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<string, string> = {
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(/^\/+/, "")
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}/<random>.<ext>` 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<Buffer> {
return fs.readFile(resolveKey(key))
}
export async function deleteFile(key: string): Promise<void> {
try {
await fs.unlink(resolveKey(key))
} catch {
// Already gone — ignore.
}
}