feat(migration): add Greenlight to Redlight migration script
All checks were successful
Build & Push Docker Image / build (push) Successful in 6m29s
All checks were successful
Build & Push Docker Image / build (push) Successful in 6m29s
This commit is contained in:
440
migrate-from-greenlight.mjs
Normal file
440
migrate-from-greenlight.mjs
Normal 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);
|
||||
});
|
||||
Reference in New Issue
Block a user