Deploy on DigitalOcean App Platform (GitHub-source build) + consolidate audit-fixes

Deploy config:
- .do/app.yaml: build the Dockerfile directly from GitHub (deploy_on_push) instead
  of a pre-built DOCR image; NEXT_PUBLIC_* set RUN_AND_BUILD_TIME with the
  propertymanagement.network domain so they bake into the client bundle; add
  custom domains block (apex + www); wire Sentry DSN (server + browser).

Included pending work from the audit-fixes branch:
- AI provider abstraction (OpenAI/Anthropic, admin-selectable; Anthropic default)
- Per-landlord e-signature (DocuSign OAuth + Dropbox Sign) + migration 0010
- Outbound webhooks / Zapier integration
- PayPal removal (Stripe-only billing)
- Storage hardening (fail-loud when Spaces unconfigured), security fixes

Verified: full production Docker build (same build-args as DO) passes clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Leon Serfaty
2026-07-03 04:45:24 -04:00
co-authored by Claude Opus 4.8
parent 917a06ee85
commit 5495b94924
86 changed files with 7647 additions and 1182 deletions
+1 -1
View File
@@ -44,7 +44,7 @@ export async function DELETE(_: Request, { params }: { params: Promise<{ id: str
await db.delete(documents).where(and(eq(documents.id, id), eq(documents.user_id, ownerId)))
if (doc.storage_path) {
await deleteFile(doc.storage_path)
await deleteFile(doc.storage_path, ownerId)
}
return NextResponse.json({ success: true })
+28 -3
View File
@@ -3,7 +3,14 @@ import { and, desc, eq } from "drizzle-orm"
import { db } from "@/lib/db"
import { documents, properties } from "@/lib/db/schema"
import { getSessionUser } from "@/lib/session"
import { saveFile, isAllowedUploadExt, StorageNotConfiguredError } from "@/lib/storage"
import {
saveFile,
isAllowedUploadExt,
StorageNotConfiguredError,
keyBelongsToOwner,
contentMatchesExtension,
extOf,
} from "@/lib/storage"
import { checkStorageLimit } from "@/lib/plan-limits"
import { ownsProperty, ownsTenant } from "@/lib/db/ownership"
import { getEffectiveOwnerId, getAccountContext } from "@/lib/account"
@@ -57,6 +64,10 @@ export async function POST(request: Request) {
if (!file) return NextResponse.json({ error: "No file provided" }, { status: 400 })
if (file.size > 20 * 1024 * 1024) return NextResponse.json({ error: "File too large (max 20 MB)" }, { status: 400 })
if (!isAllowedUploadExt(file.name)) return NextResponse.json({ error: "File type not allowed" }, { status: 400 })
const head = Buffer.from(await file.slice(0, 16).arrayBuffer())
if (!contentMatchesExtension(head, extOf(file.name))) {
return NextResponse.json({ error: "File content does not match its type" }, { status: 400 })
}
const storageError = await checkStorageLimit(ownerId, file.size)
if (storageError) return NextResponse.json({ error: storageError }, { status: 403 })
@@ -113,6 +124,20 @@ export async function POST(request: Request) {
return NextResponse.json({ error: "Tenant not found" }, { status: 404 })
}
// The file reference is client-supplied. Require it to be an /api/files URL
// inside the caller's OWN namespace, and derive storage_path from it — never
// trust a separate client storage_path (which could point at another tenant's
// object and later be deleted). Also blocks javascript:/external file_url values.
const FILES_PREFIX = "/api/files/"
const fileUrl = typeof body.file_url === "string" ? body.file_url : ""
if (!fileUrl.startsWith(FILES_PREFIX)) {
return NextResponse.json({ error: "file_url must reference an uploaded file" }, { status: 400 })
}
const storagePath = fileUrl.slice(FILES_PREFIX.length)
if (!keyBelongsToOwner(storagePath, ownerId)) {
return NextResponse.json({ error: "Invalid file reference" }, { status: 403 })
}
// Whitelist insertable columns — never trust client-supplied user_id/id/created_at.
const [data] = await db
.insert(documents)
@@ -122,8 +147,8 @@ export async function POST(request: Request) {
tenant_id: tenantId,
name: body.name as string,
category: (body.category as typeof documents.$inferInsert.category) ?? "other",
file_url: body.file_url as string,
storage_path: body.storage_path as string | undefined,
file_url: fileUrl,
storage_path: storagePath,
file_type: body.file_type as string | undefined,
file_size: body.file_size as number | undefined,
})