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>
This commit is contained in:
@@ -0,0 +1,76 @@
|
||||
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.
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user