Files
property-management-network/lib/storage.ts
T

80 lines
2.7 KiB
TypeScript
Raw Normal View History

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(/^\/+/, "")
2026-07-01 13:56:34 -04:00
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}/<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.
}
}