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>
96 lines
3.1 KiB
JavaScript
96 lines
3.1 KiB
JavaScript
// 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)
|