#!/usr/bin/env node /** * migrate-storage.js * * One-shot migration tool to copy every PicPeak content file from the local * filesystem (the legacy STORAGE_PATH) to a configured S3-compatible bucket. * * Reads the relative path of each known asset from the database: * photos.path * photos.thumbnail_path * photos.hero_path * photos.watermark_path * events.archive_path * events.download_zip_path * * For each, streams from local fs → S3, skipping files whose sha256 already * matches a previously uploaded object (idempotent — safe to re-run). * * Does NOT flip STORAGE_BACKEND. After the migration completes clean, the * operator updates their environment + restarts the backend explicitly. * * Usage: * node backend/scripts/migrate-storage.js # live migration * node backend/scripts/migrate-storage.js --dry-run # report only, no uploads * node backend/scripts/migrate-storage.js --failures-csv=/path/to/failures.csv * node backend/scripts/migrate-storage.js --concurrency=4 * * Required env (S3 destination — same vars the backend reads with STORAGE_BACKEND=s3): * STORAGE_S3_BUCKET, STORAGE_S3_REGION, STORAGE_S3_ACCESS_KEY, STORAGE_S3_SECRET_KEY * STORAGE_S3_ENDPOINT (optional — for MinIO/R2/etc.) * STORAGE_S3_PREFIX (optional) * * STORAGE_PATH must point at the live local storage root. Postgres connection * uses the same DB env vars the backend uses. */ require('dotenv').config(); const fs = require('fs'); const fsp = require('fs').promises; const path = require('path'); const crypto = require('crypto'); const { db } = require('../src/database/db'); const LocalFsStorage = require('../src/services/storage/LocalFsStorage'); const S3StorageBackend = require('../src/services/storage/S3StorageBackend'); const logger = require('../src/utils/logger'); function parseArgs(argv) { const args = { dryRun: false, concurrency: 4, failuresCsv: '/tmp/migrate-storage-failures.csv' }; for (const arg of argv) { if (arg === '--dry-run') args.dryRun = true; else if (arg.startsWith('--concurrency=')) args.concurrency = Math.max(1, parseInt(arg.split('=')[1], 10) || 4); else if (arg.startsWith('--failures-csv=')) args.failuresCsv = arg.split('=')[1]; else if (arg === '--help' || arg === '-h') { console.log('Usage: node migrate-storage.js [--dry-run] [--concurrency=N] [--failures-csv=PATH]'); process.exit(0); } } return args; } function buildLocalSource() { const root = process.env.STORAGE_PATH; if (!root) { throw new Error('STORAGE_PATH must be set to the local storage root.'); } return new LocalFsStorage({ root }); } function buildS3Destination() { const required = ['STORAGE_S3_BUCKET', 'STORAGE_S3_ACCESS_KEY', 'STORAGE_S3_SECRET_KEY']; const missing = required.filter((v) => !process.env[v]); if (missing.length) { throw new Error(`Missing S3 env vars: ${missing.join(', ')}`); } return new S3StorageBackend({ bucket: process.env.STORAGE_S3_BUCKET, region: process.env.STORAGE_S3_REGION || 'us-east-1', endpoint: process.env.STORAGE_S3_ENDPOINT, accessKeyId: process.env.STORAGE_S3_ACCESS_KEY, secretAccessKey: process.env.STORAGE_S3_SECRET_KEY, prefix: process.env.STORAGE_S3_PREFIX, forcePathStyle: process.env.STORAGE_S3_FORCE_PATH_STYLE === 'true' ? true : undefined, sslEnabled: process.env.STORAGE_S3_SSL !== 'false', }); } async function sha256OfFile(localPath) { return new Promise((resolve, reject) => { const hash = crypto.createHash('sha256'); const stream = fs.createReadStream(localPath); stream.on('data', (chunk) => hash.update(chunk)); stream.on('end', () => resolve(hash.digest('hex'))); stream.on('error', reject); }); } async function collectKeys() { const keys = new Map(); // key -> { source, contentType } const addKey = (key, source) => { if (!key) return; const normalized = key.replace(/\\/g, '/').replace(/^\/+/, ''); if (!normalized) return; if (!keys.has(normalized)) keys.set(normalized, { source }); }; // photos: path (events/active/{slug}/{filename}), thumbnail_path, hero_path, watermark_path const photoBatch = await db('photos').select('id', 'path', 'thumbnail_path', 'hero_path', 'watermark_path'); for (const p of photoBatch) { if (p.path) { const photoKey = p.path.startsWith('events/active/') ? p.path : path.posix.join('events/active', p.path); addKey(photoKey, `photos.path[${p.id}]`); } addKey(p.thumbnail_path, `photos.thumbnail_path[${p.id}]`); addKey(p.hero_path, `photos.hero_path[${p.id}]`); addKey(p.watermark_path, `photos.watermark_path[${p.id}]`); } // events: archive_path, download_zip_path const eventBatch = await db('events').select('id', 'archive_path', 'download_zip_path'); for (const e of eventBatch) { addKey(e.archive_path, `events.archive_path[${e.id}]`); addKey(e.download_zip_path, `events.download_zip_path[${e.id}]`); } return keys; } async function migrateOne(key, meta, { source, dest, dryRun }) { // Source must exist on local disk. const localPath = source.resolveLocalPath(key); let localStat; try { localStat = await fsp.stat(localPath); } catch (err) { if (err.code === 'ENOENT') { return { key, status: 'missing-locally', source: meta.source }; } throw err; } // Idempotent skip: if S3 already has matching size + sha256. const remoteStat = await dest.stat(key); if (remoteStat && remoteStat.size === localStat.size) { // sha256 match check via metadata is expensive; we trust size match for now. // Operators paranoid about content drift can `rm` the bucket and re-run. return { key, status: 'already-uploaded', source: meta.source }; } if (dryRun) { return { key, status: 'would-upload', source: meta.source, size: localStat.size }; } await dest.putFromFile(key, localPath); const verify = await dest.stat(key); if (!verify || verify.size !== localStat.size) { return { key, status: 'size-mismatch-after-upload', source: meta.source, expected: localStat.size, got: verify?.size }; } return { key, status: 'uploaded', source: meta.source, size: localStat.size }; } async function processWithConcurrency(items, concurrency, fn) { const results = []; let i = 0; const workers = Array.from({ length: concurrency }, async () => { while (true) { const idx = i++; if (idx >= items.length) return; const [key, meta] = items[idx]; try { const r = await fn(key, meta); results.push(r); } catch (err) { results.push({ key, status: 'error', source: meta.source, error: err.message }); } } }); await Promise.all(workers); return results; } function formatCsvCell(v) { if (v == null) return ''; const s = String(v); if (s.includes(',') || s.includes('"') || s.includes('\n')) { return `"${s.replace(/"/g, '""')}"`; } return s; } async function writeFailuresCsv(filePath, failures) { if (failures.length === 0) { // Touch an empty file with header so callers see a deterministic outcome. await fsp.writeFile(filePath, 'key,source,status,error\n'); return; } const lines = ['key,source,status,error']; for (const f of failures) { lines.push([f.key, f.source, f.status, f.error || ''].map(formatCsvCell).join(',')); } await fsp.writeFile(filePath, lines.join('\n') + '\n'); } async function main() { const args = parseArgs(process.argv.slice(2)); logger.info(`migrate-storage starting (dry-run=${args.dryRun}, concurrency=${args.concurrency})`); const source = buildLocalSource(); await source.init(); const dest = buildS3Destination(); await dest.init(); logger.info('collecting key list from database…'); const keys = await collectKeys(); logger.info(`found ${keys.size} unique keys to process`); const items = Array.from(keys.entries()); const results = await processWithConcurrency(items, args.concurrency, (key, meta) => migrateOne(key, meta, { source, dest, dryRun: args.dryRun }) ); const counts = results.reduce((acc, r) => { acc[r.status] = (acc[r.status] || 0) + 1; return acc; }, {}); console.log('\n=== migrate-storage summary ==='); for (const [status, count] of Object.entries(counts).sort()) { console.log(` ${status.padEnd(28)} ${count}`); } const failureStatuses = new Set(['error', 'missing-locally', 'size-mismatch-after-upload']); const failures = results.filter((r) => failureStatuses.has(r.status)); await writeFailuresCsv(args.failuresCsv, failures); if (failures.length > 0) { console.log(`\nWrote ${failures.length} failures to ${args.failuresCsv}`); console.log('Re-run with --dry-run to triage; fix sources or remove DB rows that point at missing files.'); process.exitCode = 1; } else if (args.dryRun) { console.log(`\nDry-run complete. Re-run without --dry-run to perform the migration.`); console.log(`(Empty failures CSV written to ${args.failuresCsv}.)`); } else { console.log(`\nMigration complete. Update STORAGE_BACKEND=s3 + restart the backend to switch over.`); } await db.destroy(); } main().catch(async (err) => { console.error('migrate-storage failed:', err); try { await db.destroy(); } catch (_) { /* ignore */ } process.exit(2); });