- Exclude supabase/ from Docker build context (leaked service_role key file) - /api/files: exact per-user namespace match + reject path traversal; storage resolveKey rejects ".."/"." segments (fixes cross-user file read) - Add ownsProperty/Unit/Tenant checks to tenants, maintenance (landlord path), and documents (JSON branch, now field-whitelisted) create handlers - Escape user data in follow-up + payment-link emails (reuse escapeHtml) - Neutralize CSV formula injection in toCsv + export routes - Tighter sign-in rate limit (10/min); env-gated email verification + sender - Per-request nonce CSP; drop script-src 'unsafe-inline' (styles unchanged) - Add input length bounds; validate follow-ups POST body Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
80 lines
2.7 KiB
TypeScript
80 lines
2.7 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(/^\/+/, "")
|
|
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.
|
|
}
|
|
}
|