/** * Greenlight → Redlight Migration Script * * Migrates users and their rooms (including settings and shared accesses) * from a Greenlight v3 PostgreSQL database into a Redlight instance * (SQLite or PostgreSQL). * * Usage: * node migrate-from-greenlight.mjs [options] * * Options: * --dry-run Show what would be migrated without writing anything * --skip-rooms Only migrate users, not rooms * --skip-shares Do not migrate room share (shared_accesses) * --verbose Print every imported row * * Environment variables (can also be in .env): * * Source (Greenlight DB — PostgreSQL): * GL_DATABASE_URL Full Postgres connection string * e.g. postgres://gl_user:pass@localhost/greenlight_db * * Target (Redlight DB — auto-detected): * DATABASE_URL Set for PostgreSQL target (same format as GL_DATABASE_URL) * SQLITE_PATH Set (or leave empty) for SQLite target * Default: ./redlight.db (relative to this script) * * Password hashes: * Both Greenlight and Redlight use bcrypt, so password_digest is * copied as-is — users can log in with their existing passwords. * * Meeting-option → Redlight field mapping: * record → record_meeting * muteOnStart → mute_on_join * guestPolicy → require_approval (ASK_MODERATOR = true) * glAnyoneCanStart → anyone_can_start * glAnyoneJoinAsModerator → all_join_moderator * glViewerAccessCode → access_code * glModeratorAccessCode → moderator_code */ import 'dotenv/config'; import pg from 'pg'; import crypto from 'crypto'; import { createRequire } from 'module'; // ── helpers ──────────────────────────────────────────────────────────────── const args = process.argv.slice(2); const DRY_RUN = args.includes('--dry-run'); const SKIP_ROOMS = args.includes('--skip-rooms'); const SKIP_SHARES = args.includes('--skip-shares'); const VERBOSE = args.includes('--verbose'); const c = { reset: '\x1b[0m', bold: '\x1b[1m', green: '\x1b[32m', yellow:'\x1b[33m', red: '\x1b[31m', cyan: '\x1b[36m', dim: '\x1b[2m', }; const log = (...a) => console.log(...a); const ok = (msg) => log(` ${c.green}✓${c.reset} ${msg}`); const warn = (msg) => log(` ${c.yellow}⚠${c.reset} ${msg}`); const skip = (msg) => log(` ${c.dim}– ${msg}${c.reset}`); const err = (msg) => log(` ${c.red}✗${c.reset} ${msg}`); const info = (msg) => log(` ${c.cyan}i${c.reset} ${msg}`); const loud = (msg) => { if (VERBOSE) log(` ${c.dim}${msg}${c.reset}`); }; // ── Source DB (Greenlight PostgreSQL) ───────────────────────────────────── const GL_URL = process.env.GL_DATABASE_URL; if (!GL_URL) { err('GL_DATABASE_URL is not set. Please set it in your .env file or environment.'); err(' Example: GL_DATABASE_URL=postgres://gl_user:pass@localhost/greenlight_production'); process.exit(1); } // ── Target DB (Redlight) ─────────────────────────────────────────────────── const RL_URL = process.env.DATABASE_URL; const isPostgresTarget = !!(RL_URL && RL_URL.startsWith('postgres')); // ── SQLite adapter (only loaded when needed) ─────────────────────────────── async function openSqlite() { const require = createRequire(import.meta.url); let Database; try { Database = require('better-sqlite3'); } catch { err('better-sqlite3 is not installed. Run: npm install better-sqlite3'); process.exit(1); } const dbPath = process.env.SQLITE_PATH || './redlight.db'; const db = Database(dbPath); db.pragma('journal_mode = WAL'); db.pragma('foreign_keys = ON'); return { async get(sql, params = []) { return db.prepare(sql).get(...params); }, async all(sql, params = []) { return db.prepare(sql).all(...params); }, async run(sql, params = []) { const r = db.prepare(sql).run(...params); return { lastInsertRowid: Number(r.lastInsertRowid), changes: r.changes }; }, close() { db.close(); }, type: 'sqlite', path: dbPath, }; } // ── PostgreSQL adapter ───────────────────────────────────────────────────── async function openPostgres(url, label) { const pool = new pg.Pool({ connectionString: url }); let index = 0; const convert = (sql) => sql.replace(/\?/g, () => `$${++index}`); return { async get(sql, params = []) { index = 0; const r = await pool.query(convert(sql), params); return r.rows[0]; }, async all(sql, params = []) { index = 0; const r = await pool.query(convert(sql), params); return r.rows; }, async run(sql, params = []) { index = 0; let q = convert(sql); if (/^\s*INSERT/i.test(q) && !/RETURNING/i.test(q)) q += ' RETURNING id'; const r = await pool.query(q, params); return { lastInsertRowid: r.rows[0]?.id, changes: r.rowCount }; }, async end() { await pool.end(); }, type: 'postgres', }; } // ── meeting option → redlight field map ─────────────────────────────────── const OPTION_MAP = { record: 'record_meeting', muteOnStart: 'mute_on_join', guestPolicy: 'require_approval', // special: "ASK_MODERATOR" glAnyoneCanStart: 'anyone_can_start', glAnyoneJoinAsModerator: 'all_join_moderator', glViewerAccessCode: 'access_code', glModeratorAccessCode: 'moderator_code', }; function boolOption(val) { return val === 'true' || val === '1' ? 1 : 0; } // ── main ─────────────────────────────────────────────────────────────────── async function main() { log(); log(`${c.bold}Greenlight → Redlight Migration${c.reset}`); if (DRY_RUN) log(`${c.yellow}${c.bold} DRY RUN — nothing will be written${c.reset}`); log(); // Connect to source let glDb; try { glDb = await openPostgres(GL_URL, 'Greenlight'); await glDb.get('SELECT 1'); info(`Connected to Greenlight DB (PostgreSQL)`); } catch (e) { err(`Cannot connect to Greenlight DB: ${e.message}`); process.exit(1); } // Connect to target let rlDb; try { if (isPostgresTarget) { rlDb = await openPostgres(RL_URL, 'Redlight'); await rlDb.get('SELECT 1'); info(`Connected to Redlight DB (PostgreSQL)`); } else { rlDb = await openSqlite(); info(`Connected to Redlight DB (SQLite: ${rlDb.path})`); } } catch (e) { err(`Cannot connect to Redlight DB: ${e.message}`); process.exit(1); } log(); // ── 1. Load Greenlight roles ─────────────────────────────────────────── const roles = await glDb.all('SELECT id, name FROM roles'); const adminRoleIds = new Set( roles.filter(r => /admin|administrator/i.test(r.name)).map(r => r.id) ); info(`Found ${roles.length} roles (${adminRoleIds.size} admin role(s))`); // ── 2. Load Greenlight users ─────────────────────────────────────────── const glUsers = await glDb.all(` SELECT id, name, email, password_digest, language, role_id, verified, status FROM users WHERE provider = 'greenlight' ORDER BY created_at ASC `); info(`Found ${glUsers.length} Greenlight user(s)`); log(); // ── 3. Migrate users ─────────────────────────────────────────────────── log(`${c.bold}Users${c.reset}`); const userIdMap = new Map(); // gl user id → rl user id let usersCreated = 0, usersSkipped = 0, usersUpdated = 0; for (const u of glUsers) { if (!u.password_digest) { warn(`${u.email} — no password (SSO user?), skipping`); usersSkipped++; continue; } const role = adminRoleIds.has(u.role_id) ? 'admin' : 'user'; const emailVerified = u.verified ? 1 : 0; const lang = u.language || 'de'; const displayName = u.name || ''; const existing = await rlDb.get('SELECT id FROM users WHERE email = ?', [u.email]); if (existing) { userIdMap.set(u.id, existing.id); skip(`${u.email} — already exists (id=${existing.id}), skipping`); usersSkipped++; continue; } loud(`INSERT user ${u.email} (role=${role})`); if (!DRY_RUN) { const result = await rlDb.run( `INSERT INTO users (name, display_name, email, password_hash, role, language, email_verified) VALUES (?, ?, ?, ?, ?, ?, ?)`, [displayName, displayName, u.email, u.password_digest, role, lang, emailVerified] ); userIdMap.set(u.id, result.lastInsertRowid); ok(`${u.email} (${role})`); } else { ok(`[dry] ${u.email} (${role})`); } usersCreated++; } log(); log(` Created: ${c.green}${usersCreated}${c.reset} Skipped: ${usersSkipped}`); log(); if (SKIP_ROOMS) { log(`${c.yellow}--skip-rooms set, stopping here.${c.reset}`); await cleanup(glDb, rlDb); return; } // ── 4. Load Greenlight rooms + meeting options ───────────────────────── log(`${c.bold}Rooms${c.reset}`); const glRooms = await glDb.all(` SELECT r.id, r.name, r.meeting_id, r.user_id FROM rooms r ORDER BY r.created_at ASC `); // Load all meeting options (key name list) const meetingOptions = await glDb.all('SELECT id, name FROM meeting_options'); const optionIdToName = new Map(meetingOptions.map(m => [m.id, m.name])); // Load all room_meeting_option values in one shot const roomOptionRows = await glDb.all('SELECT room_id, meeting_option_id, value FROM room_meeting_options'); const roomOptions = new Map(); // room_id → { optionName: value } for (const row of roomOptionRows) { const name = optionIdToName.get(row.meeting_option_id); if (!name) continue; if (!roomOptions.has(row.room_id)) roomOptions.set(row.room_id, {}); roomOptions.get(row.room_id)[name] = row.value; } info(`Found ${glRooms.length} room(s) across all users`); log(); let roomsCreated = 0, roomsSkipped = 0; const roomIdMap = new Map(); // gl room id → rl room id for (const room of glRooms) { // Determine the redlight owner const rlUserId = userIdMap.get(room.user_id); if (!rlUserId && !DRY_RUN) { // Try to look up the email in redlight directly in case user already existed const glUser = glUsers.find(u => u.id === room.user_id); if (glUser) { const ex = await rlDb.get('SELECT id FROM users WHERE email = ?', [glUser.email]); if (ex) { userIdMap.set(room.user_id, ex.id); } else { warn(`Room "${room.name}" — owner not found in Redlight, skipping`); roomsSkipped++; continue; } } else { warn(`Room "${room.name}" — owner not found in Greenlight users, skipping`); roomsSkipped++; continue; } } const ownerId = userIdMap.get(room.user_id) || null; // Use Greenlight meeting_id as the uid (preserves BBB meeting identity) // Greenlight meeting_ids can be longer, but Redlight stores uid as TEXT — no problem. const uid = room.meeting_id; // Check if already in Redlight const existingRoom = await rlDb.get('SELECT id FROM rooms WHERE uid = ?', [uid]); if (existingRoom) { roomIdMap.set(room.id, existingRoom.id); skip(`"${room.name}" (${uid.substring(0, 12)}…) — already exists`); roomsSkipped++; continue; } // Map meeting options to Redlight fields const opts = roomOptions.get(room.id) || {}; const mute_on_join = opts.muteOnStart ? boolOption(opts.muteOnStart) : 1; const record_meeting = opts.record ? boolOption(opts.record) : 1; const anyone_can_start = opts.glAnyoneCanStart ? boolOption(opts.glAnyoneCanStart) : 0; const all_join_moderator = opts.glAnyoneJoinAsModerator ? boolOption(opts.glAnyoneJoinAsModerator): 0; const require_approval = opts.guestPolicy === 'ASK_MODERATOR' ? 1 : 0; const access_code = (opts.glViewerAccessCode && opts.glViewerAccessCode !== 'false') ? opts.glViewerAccessCode : null; const moderator_code = (opts.glModeratorAccessCode && opts.glModeratorAccessCode !== 'false') ? opts.glModeratorAccessCode : null; const guest_access = 1; // Default open like Greenlight // Ensure room name meets Redlight 2-char minimum const roomName = (room.name || 'Room').length >= 2 ? room.name : (room.name || 'Room').padEnd(2, ' '); loud(`INSERT room "${roomName}" uid=${uid.substring(0, 16)}… owner=${ownerId}`); if (!DRY_RUN && ownerId) { const result = await rlDb.run( `INSERT INTO rooms (uid, name, user_id, mute_on_join, record_meeting, anyone_can_start, all_join_moderator, require_approval, access_code, moderator_code, guest_access) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, [uid, roomName, ownerId, mute_on_join, record_meeting, anyone_can_start, all_join_moderator, require_approval, access_code, moderator_code, guest_access] ); roomIdMap.set(room.id, result.lastInsertRowid); const optSummary = [ mute_on_join ? 'muted' : null, record_meeting ? 'record' : null, anyone_can_start ? 'anyStart' : null, all_join_moderator ? 'allMod' : null, require_approval ? 'approval' : null, access_code ? 'code:'+access_code.substring(0,4)+'…' : null, ].filter(Boolean).join(', '); ok(`"${roomName}"${optSummary ? ` [${optSummary}]` : ''}`); } else if (DRY_RUN) { ok(`[dry] "${roomName}" (uid=${uid.substring(0, 16)}…)`); } else { warn(`"${roomName}" — skipped (no owner resolved)`); roomsSkipped++; continue; } roomsCreated++; } log(); log(` Created: ${c.green}${roomsCreated}${c.reset} Skipped: ${roomsSkipped}`); log(); // ── 5. Migrate shared_accesses ───────────────────────────────────────── if (!SKIP_SHARES) { log(`${c.bold}Room Shares${c.reset}`); const shares = await glDb.all('SELECT user_id, room_id FROM shared_accesses'); info(`Found ${shares.length} shared accesse(s)`); log(); let sharesCreated = 0, sharesSkipped = 0; for (const s of shares) { const rlUser = userIdMap.get(s.user_id); const rlRoom = roomIdMap.get(s.room_id); if (!rlUser || !rlRoom) { loud(`Skip share user=${s.user_id} room=${s.room_id} — not found in target`); sharesSkipped++; continue; } const exists = await rlDb.get( 'SELECT id FROM room_shares WHERE room_id = ? AND user_id = ?', [rlRoom, rlUser] ); if (exists) { sharesSkipped++; continue; } if (!DRY_RUN) { await rlDb.run( 'INSERT INTO room_shares (room_id, user_id) VALUES (?, ?)', [rlRoom, rlUser] ); ok(`Share room_id=${rlRoom} → user_id=${rlUser}`); } else { ok(`[dry] Share room_id=${rlRoom} → user_id=${rlUser}`); } sharesCreated++; } log(); log(` Created: ${c.green}${sharesCreated}${c.reset} Skipped: ${sharesSkipped}`); log(); } // ── Summary ──────────────────────────────────────────────────────────── log(`${c.bold}${c.green}Migration complete${c.reset}`); log(); log(` Users migrated: ${c.green}${usersCreated}${c.reset}`); log(` Rooms migrated: ${c.green}${roomsCreated}${c.reset}`); if (!SKIP_SHARES) log(` Shares migrated: (see above)`); if (DRY_RUN) { log(); log(` ${c.yellow}${c.bold}This was a DRY RUN — rerun without --dry-run to apply.${c.reset}`); } log(); await cleanup(glDb, rlDb); } async function cleanup(glDb, rlDb) { try { await glDb.end?.(); } catch {} try { await rlDb.end?.(); rlDb.close?.(); } catch {} } main().catch(e => { console.error(`\n${c.red}Fatal error:${c.reset}`, e.message); process.exit(1); });