// 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/ 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)