695 lines
28 KiB
JavaScript
695 lines
28 KiB
JavaScript
/**
|
||
* Greenlight → Redlight Migration Script
|
||
*
|
||
* Migrates users, rooms (including settings and shared accesses),
|
||
* site settings (branding, registration), logo, and OAuth/OIDC configuration
|
||
* 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)
|
||
* --skip-settings Do not migrate site_settings / branding
|
||
* --skip-oauth Do not migrate OAuth / OIDC configuration
|
||
* --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)
|
||
*
|
||
* OAuth (from Greenlight .env — optional, only if --skip-oauth is NOT set):
|
||
* GL_OIDC_ISSUER OIDC issuer URL (e.g. https://keycloak.example.com/realms/myrealm)
|
||
* GL_OIDC_CLIENT_ID OIDC client ID
|
||
* GL_OIDC_CLIENT_SECRET OIDC client secret
|
||
* GL_OIDC_DISPLAY_NAME Button label on the login page (default: "SSO")
|
||
*
|
||
* Password hashes:
|
||
* Both Greenlight and Redlight use bcrypt, so password_digest is
|
||
* copied as-is — users can log in with their existing passwords.
|
||
*
|
||
* Site-settings mapping (GL site_settings → Redlight settings):
|
||
* BrandingImage → downloads file → uploads/branding/logo.*
|
||
* PrimaryColor → (logged, not mapped — Redlight uses themes)
|
||
* RegistrationMethod → registration_mode (open / invite)
|
||
* Terms → imprint_url
|
||
* PrivacyPolicy → privacy_url
|
||
*
|
||
* 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 fs from 'fs';
|
||
import path from 'path';
|
||
import { fileURLToPath } from 'url';
|
||
import { createRequire } from 'module';
|
||
|
||
const __filename = fileURLToPath(import.meta.url);
|
||
const __dirname = path.dirname(__filename);
|
||
|
||
// ── 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 SKIP_SETTINGS = args.includes('--skip-settings');
|
||
const SKIP_OAUTH = args.includes('--skip-oauth');
|
||
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;
|
||
}
|
||
|
||
// ── AES-256-GCM encryption (matching Redlight's oauth.js) ─────────────────
|
||
const ENCRYPTION_KEY = crypto
|
||
.createHash('sha256')
|
||
.update(process.env.JWT_SECRET || '')
|
||
.digest();
|
||
|
||
function encryptSecret(plaintext) {
|
||
const iv = crypto.randomBytes(12);
|
||
const cipher = crypto.createCipheriv('aes-256-gcm', ENCRYPTION_KEY, iv);
|
||
let encrypted = cipher.update(plaintext, 'utf8', 'hex');
|
||
encrypted += cipher.final('hex');
|
||
const authTag = cipher.getAuthTag().toString('hex');
|
||
return `${iv.toString('hex')}:${authTag}:${encrypted}`;
|
||
}
|
||
|
||
// ── Helper: upsert a setting into the Redlight settings table ─────────────
|
||
async function upsertSetting(db, key, value, isPostgres) {
|
||
const existing = await db.get('SELECT key FROM settings WHERE key = ?', [key]);
|
||
if (existing) {
|
||
await db.run('UPDATE settings SET value = ?, updated_at = CURRENT_TIMESTAMP WHERE key = ?', [value, key]);
|
||
} else {
|
||
await db.run('INSERT INTO settings (key, value) VALUES (?, ?) RETURNING key', [key, value]);
|
||
}
|
||
}
|
||
|
||
// ── 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 (local + OIDC) ────────────────────────────
|
||
const glUsers = await glDb.all(`
|
||
SELECT id, name, email, password_digest, language, role_id, verified, status, provider, external_id
|
||
FROM users
|
||
ORDER BY created_at ASC
|
||
`);
|
||
const localUsers = glUsers.filter(u => u.provider === 'greenlight');
|
||
const oidcUsers = glUsers.filter(u => u.provider !== 'greenlight' && u.provider !== null);
|
||
info(`Found ${localUsers.length} local user(s), ${oidcUsers.length} OIDC/SSO user(s)`);
|
||
log();
|
||
|
||
// ── 3. Migrate local users ─────────────────────────────────────────────
|
||
log(`${c.bold}Local Users${c.reset}`);
|
||
const userIdMap = new Map(); // gl user id → rl user id
|
||
let usersCreated = 0, usersSkipped = 0;
|
||
|
||
for (const u of localUsers) {
|
||
if (!u.password_digest) {
|
||
warn(`${u.email} — no password, 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();
|
||
|
||
// ── 3b. Migrate OIDC / SSO users ───────────────────────────────────────
|
||
log(`${c.bold}OIDC / SSO Users${c.reset}`);
|
||
let oidcCreated = 0, oidcSkipped = 0;
|
||
|
||
for (const u of oidcUsers) {
|
||
const role = adminRoleIds.has(u.role_id) ? 'admin' : 'user';
|
||
const lang = u.language || 'de';
|
||
const displayName = u.name || '';
|
||
const email = (u.email || '').toLowerCase().trim();
|
||
if (!email) {
|
||
warn(`OIDC user id=${u.id} — no email, skipping`);
|
||
oidcSkipped++;
|
||
continue;
|
||
}
|
||
|
||
const existing = await rlDb.get('SELECT id FROM users WHERE email = ?', [email]);
|
||
|
||
if (existing) {
|
||
userIdMap.set(u.id, existing.id);
|
||
// Link OAuth provider if not set yet
|
||
if (!DRY_RUN && u.external_id) {
|
||
await rlDb.run(
|
||
'UPDATE users SET oauth_provider = ?, oauth_provider_id = ?, email_verified = 1, updated_at = CURRENT_TIMESTAMP WHERE id = ? AND oauth_provider IS NULL',
|
||
['oidc', u.external_id, existing.id]
|
||
);
|
||
}
|
||
skip(`${email} — already exists (id=${existing.id}), linked OIDC`);
|
||
oidcSkipped++;
|
||
continue;
|
||
}
|
||
|
||
// Generate a random unusable password hash for OIDC users
|
||
const randomHash = `oauth:${crypto.randomUUID()}`;
|
||
|
||
loud(`INSERT OIDC user ${email} (role=${role}, sub=${u.external_id || '?'})`);
|
||
if (!DRY_RUN) {
|
||
const result = await rlDb.run(
|
||
`INSERT INTO users (name, display_name, email, password_hash, role, language, email_verified, oauth_provider, oauth_provider_id)
|
||
VALUES (?, ?, ?, ?, ?, ?, 1, ?, ?)`,
|
||
[displayName, displayName, email, randomHash, role, lang, 'oidc', u.external_id || null]
|
||
);
|
||
userIdMap.set(u.id, result.lastInsertRowid);
|
||
ok(`${email} (${role}, OIDC)`);
|
||
} else {
|
||
ok(`[dry] ${email} (${role}, OIDC)`);
|
||
}
|
||
oidcCreated++;
|
||
}
|
||
|
||
log();
|
||
log(` Created: ${c.green}${oidcCreated}${c.reset} Skipped: ${oidcSkipped}`);
|
||
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();
|
||
}
|
||
|
||
// ── 6. Migrate site_settings / branding ────────────────────────────────
|
||
if (!SKIP_SETTINGS) {
|
||
log(`${c.bold}Site Settings & Branding${c.reset}`);
|
||
let settingsCount = 0;
|
||
|
||
// Check if site_settings table exists in Greenlight
|
||
let hasSiteSettings = false;
|
||
try {
|
||
await glDb.get('SELECT 1 FROM site_settings LIMIT 1');
|
||
hasSiteSettings = true;
|
||
} catch {
|
||
warn('No site_settings table found in Greenlight DB — skipping settings migration');
|
||
}
|
||
|
||
if (hasSiteSettings) {
|
||
const glSettings = await glDb.all('SELECT setting, value FROM site_settings');
|
||
const settingsMap = new Map(glSettings.map(s => [s.setting, s.value]));
|
||
info(`Found ${glSettings.length} site_setting(s) in Greenlight`);
|
||
|
||
// ── Registration mode ──────────────────────────────────────────────
|
||
const regMethod = settingsMap.get('RegistrationMethod');
|
||
if (regMethod) {
|
||
// Greenlight: "open", "invite", "approval" → Redlight: "open" or "invite"
|
||
const mode = regMethod === 'open' ? 'open' : 'invite';
|
||
if (!DRY_RUN) await upsertSetting(rlDb, 'registration_mode', mode, isPostgresTarget);
|
||
ok(`registration_mode → ${mode} (was: ${regMethod})`);
|
||
settingsCount++;
|
||
}
|
||
|
||
// ── Privacy policy URL ─────────────────────────────────────────────
|
||
const privacy = settingsMap.get('PrivacyPolicy');
|
||
if (privacy && privacy.trim()) {
|
||
if (!DRY_RUN) await upsertSetting(rlDb, 'privacy_url', privacy.trim(), isPostgresTarget);
|
||
ok(`privacy_url → ${privacy.trim()}`);
|
||
settingsCount++;
|
||
}
|
||
|
||
// ── Terms / Imprint URL ────────────────────────────────────────────
|
||
const terms = settingsMap.get('Terms');
|
||
if (terms && terms.trim()) {
|
||
if (!DRY_RUN) await upsertSetting(rlDb, 'imprint_url', terms.trim(), isPostgresTarget);
|
||
ok(`imprint_url → ${terms.trim()}`);
|
||
settingsCount++;
|
||
}
|
||
|
||
// ── Primary color (informational only — Redlight uses themes) ─────
|
||
const primaryColor = settingsMap.get('PrimaryColor');
|
||
if (primaryColor) {
|
||
info(`PrimaryColor in Greenlight was: ${primaryColor} (not mapped — Redlight uses themes)`);
|
||
}
|
||
|
||
// ── Branding image / logo ──────────────────────────────────────────
|
||
const brandingImage = settingsMap.get('BrandingImage');
|
||
if (brandingImage && brandingImage.trim()) {
|
||
const logoUrl = brandingImage.trim();
|
||
info(`BrandingImage URL: ${logoUrl}`);
|
||
|
||
if (!DRY_RUN) {
|
||
try {
|
||
const response = await fetch(logoUrl, { signal: AbortSignal.timeout(15_000) });
|
||
if (response.ok) {
|
||
const contentType = response.headers.get('content-type') || '';
|
||
let ext = '.png';
|
||
if (contentType.includes('svg')) ext = '.svg';
|
||
else if (contentType.includes('jpeg') || contentType.includes('jpg')) ext = '.jpg';
|
||
else if (contentType.includes('gif')) ext = '.gif';
|
||
else if (contentType.includes('webp')) ext = '.webp';
|
||
else if (contentType.includes('ico')) ext = '.ico';
|
||
|
||
const brandingDir = path.join(__dirname, 'uploads', 'branding');
|
||
fs.mkdirSync(brandingDir, { recursive: true });
|
||
|
||
// Remove old logos
|
||
if (fs.existsSync(brandingDir)) {
|
||
for (const f of fs.readdirSync(brandingDir)) {
|
||
if (f.startsWith('logo.')) fs.unlinkSync(path.join(brandingDir, f));
|
||
}
|
||
}
|
||
|
||
const logoPath = path.join(brandingDir, `logo${ext}`);
|
||
const buffer = Buffer.from(await response.arrayBuffer());
|
||
fs.writeFileSync(logoPath, buffer);
|
||
ok(`Logo saved → uploads/branding/logo${ext} (${(buffer.length / 1024).toFixed(1)} KB)`);
|
||
settingsCount++;
|
||
} else {
|
||
warn(`Could not download logo (HTTP ${response.status}) — skipping`);
|
||
}
|
||
} catch (dlErr) {
|
||
warn(`Logo download failed: ${dlErr.message}`);
|
||
}
|
||
} else {
|
||
ok(`[dry] Would download logo from ${logoUrl}`);
|
||
settingsCount++;
|
||
}
|
||
}
|
||
|
||
log();
|
||
log(` Settings migrated: ${c.green}${settingsCount}${c.reset}`);
|
||
log();
|
||
}
|
||
}
|
||
|
||
// ── 7. Migrate OAuth / OIDC configuration ──────────────────────────────
|
||
if (!SKIP_OAUTH) {
|
||
log(`${c.bold}OAuth / OIDC Configuration${c.reset}`);
|
||
|
||
const issuer = process.env.GL_OIDC_ISSUER;
|
||
const clientId = process.env.GL_OIDC_CLIENT_ID;
|
||
const clientSecret = process.env.GL_OIDC_CLIENT_SECRET;
|
||
const displayName = process.env.GL_OIDC_DISPLAY_NAME || 'SSO';
|
||
|
||
if (issuer && clientId && clientSecret) {
|
||
if (!process.env.JWT_SECRET) {
|
||
warn('JWT_SECRET is not set — cannot encrypt client secret. Skipping OAuth migration.');
|
||
} else {
|
||
// Check if already configured
|
||
const existing = await rlDb.get("SELECT key FROM settings WHERE key = 'oauth_config'");
|
||
if (existing) {
|
||
warn('oauth_config already exists in Redlight — skipping (delete it first to re-migrate)');
|
||
} else {
|
||
info(`Issuer: ${issuer}`);
|
||
info(`Client ID: ${clientId}`);
|
||
info(`Display name: ${displayName}`);
|
||
|
||
if (!DRY_RUN) {
|
||
const config = JSON.stringify({
|
||
issuer,
|
||
clientId,
|
||
encryptedSecret: encryptSecret(clientSecret),
|
||
displayName,
|
||
autoRegister: true,
|
||
});
|
||
await upsertSetting(rlDb, 'oauth_config', config, isPostgresTarget);
|
||
ok('OAuth/OIDC configuration saved');
|
||
} else {
|
||
ok('[dry] Would save OAuth/OIDC configuration');
|
||
}
|
||
}
|
||
}
|
||
} else if (issuer || clientId || clientSecret) {
|
||
warn('Incomplete OIDC config — set GL_OIDC_ISSUER, GL_OIDC_CLIENT_ID, and GL_OIDC_CLIENT_SECRET');
|
||
} else {
|
||
info('No GL_OIDC_* env vars set — skipping OAuth migration');
|
||
info('Set GL_OIDC_ISSUER, GL_OIDC_CLIENT_ID, GL_OIDC_CLIENT_SECRET to migrate OIDC config');
|
||
}
|
||
log();
|
||
}
|
||
|
||
// ── Summary ────────────────────────────────────────────────────────────
|
||
log(`${c.bold}${c.green}Migration complete${c.reset}`);
|
||
log();
|
||
log(` Local users migrated: ${c.green}${usersCreated}${c.reset}`);
|
||
log(` OIDC users migrated: ${c.green}${oidcCreated}${c.reset}`);
|
||
log(` Rooms migrated: ${c.green}${roomsCreated}${c.reset}`);
|
||
if (!SKIP_SHARES) log(` Shares migrated: (see above)`);
|
||
if (!SKIP_SETTINGS) log(` Settings migrated: (see above)`);
|
||
if (!SKIP_OAUTH) log(` OAuth config: (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);
|
||
});
|