Files
redlight/migrate-from-greenlight.mjs
Michelle d886725c4f
All checks were successful
Build & Push Docker Image / build (push) Successful in 6m29s
feat(migration): add Greenlight to Redlight migration script
2026-03-03 12:34:35 +01:00

441 lines
17 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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);
});