Files
property-management-network/scripts/migrate-storage-to-spaces.mjs
T
Leon SerfatyandClaude Opus 4.8 c9968531e4 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>
2026-07-02 13:42:34 -04:00

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)