Consolidate audit-fixes branch: webhooks, integrations, and deploy hardening
Batch commit of the pending working tree on security/audit-fixes-2026-07. Major areas: - Outbound webhooks / Zapier: schema + signed delivery with retries, public v1 API (REST-hook subscribe/unsubscribe), settings UI, cron drain. - Deploy hardening: email via SMTP2GO (Resend fully removed), verified DB TLS (DATABASE_SSL=require + DATABASE_CA), storage fails loud in production when Spaces is unconfigured instead of silently using ephemeral disk. - Integrations & features (concurrent work): accounting (QuickBooks/Xero), e-signature (DocuSign/Dropbox Sign), PayPal, geocoding/maps, onboarding, expanded legal pages. - DB migrations 0006–0009. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
969d5d4c8a
commit
c9968531e4
@@ -0,0 +1,95 @@
|
||||
// One-off migration: copy every file under STORAGE_DIR (local disk) into the
|
||||
// DigitalOcean Spaces bucket, preserving the exact key path so existing
|
||||
// /api/files/<key> URLs in the database keep resolving after the switch.
|
||||
//
|
||||
// Usage (from the project root):
|
||||
// node scripts/migrate-storage-to-spaces.mjs # copy missing files
|
||||
// FORCE=1 node scripts/migrate-storage-to-spaces.mjs # overwrite existing
|
||||
//
|
||||
// Run it wherever the source files live (a machine/container with STORAGE_DIR
|
||||
// populated and the SPACES_* env vars configured).
|
||||
import { config } from "dotenv"
|
||||
config({ path: ".env.local" })
|
||||
import { promises as fs } from "fs"
|
||||
import path from "path"
|
||||
import {
|
||||
S3Client,
|
||||
PutObjectCommand,
|
||||
HeadObjectCommand,
|
||||
} from "@aws-sdk/client-s3"
|
||||
|
||||
const STORAGE_DIR = path.resolve(process.cwd(), process.env.STORAGE_DIR ?? "./storage")
|
||||
const bucket = process.env.SPACES_BUCKET
|
||||
const force = process.env.FORCE === "1"
|
||||
|
||||
if (!process.env.SPACES_KEY || !process.env.SPACES_SECRET || !bucket) {
|
||||
console.error("Missing SPACES_KEY / SPACES_SECRET / SPACES_BUCKET — aborting.")
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
const MIME = {
|
||||
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",
|
||||
}
|
||||
const ctype = (k) => MIME[k.split(".").pop()?.toLowerCase() ?? ""] ?? "application/octet-stream"
|
||||
|
||||
const s3 = new S3Client({
|
||||
region: process.env.SPACES_REGION || "us-east-1",
|
||||
endpoint: process.env.SPACES_ENDPOINT,
|
||||
forcePathStyle: false,
|
||||
credentials: { accessKeyId: process.env.SPACES_KEY, secretAccessKey: process.env.SPACES_SECRET },
|
||||
})
|
||||
|
||||
async function* walk(dir) {
|
||||
let entries
|
||||
try {
|
||||
entries = await fs.readdir(dir, { withFileTypes: true })
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
for (const e of entries) {
|
||||
const full = path.join(dir, e.name)
|
||||
if (e.isDirectory()) yield* walk(full)
|
||||
else if (e.isFile()) yield full
|
||||
}
|
||||
}
|
||||
|
||||
async function existsInBucket(key) {
|
||||
try {
|
||||
await s3.send(new HeadObjectCommand({ Bucket: bucket, Key: key }))
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
let migrated = 0, skipped = 0, failed = 0
|
||||
console.log(`Source: ${STORAGE_DIR}`)
|
||||
console.log(`Target: ${process.env.SPACES_ENDPOINT}/${bucket}${force ? " (FORCE overwrite)" : ""}\n`)
|
||||
|
||||
for await (const file of walk(STORAGE_DIR)) {
|
||||
const key = path.relative(STORAGE_DIR, file).split(path.sep).join("/")
|
||||
try {
|
||||
if (!force && (await existsInBucket(key))) {
|
||||
console.log(`skip ${key}`)
|
||||
skipped++
|
||||
continue
|
||||
}
|
||||
const body = await fs.readFile(file)
|
||||
await s3.send(new PutObjectCommand({
|
||||
Bucket: bucket, Key: key, Body: body, ContentType: ctype(key), ACL: "private",
|
||||
}))
|
||||
console.log(`upload ${key} (${body.length} bytes)`)
|
||||
migrated++
|
||||
} catch (e) {
|
||||
console.error(`FAIL ${key} -> ${e.name}: ${e.message}`)
|
||||
failed++
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`\nDone. migrated=${migrated} skipped=${skipped} failed=${failed}`)
|
||||
process.exit(failed > 0 ? 1 : 0)
|
||||
Reference in New Issue
Block a user