diff --git a/migrate-from-greenlight.mjs b/migrate-from-greenlight.mjs new file mode 100644 index 0000000..34f7df8 --- /dev/null +++ b/migrate-from-greenlight.mjs @@ -0,0 +1,440 @@ +/** + * 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); +});