feat(migration): add Greenlight to Redlight migration script
All checks were successful
Build & Push Docker Image / build (push) Successful in 6m29s

This commit is contained in:
2026-03-03 12:34:35 +01:00
parent f3ef490012
commit d886725c4f

440
migrate-from-greenlight.mjs Normal file
View File

@@ -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);
});