feat(migration): enhance migration script to include site settings, branding, and OAuth configuration
Some checks failed
Build & Push Docker Image / build (push) Has been cancelled

This commit is contained in:
2026-03-10 22:25:53 +01:00
parent 3ab7ab6a70
commit 5fc64330e0
2 changed files with 278 additions and 24 deletions

View File

@@ -1,7 +1,8 @@
/**
* Greenlight → Redlight Migration Script
*
* Migrates users and their rooms (including settings and shared accesses)
* 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).
*
@@ -9,10 +10,12 @@
* 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
* --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):
*
@@ -25,10 +28,23 @@
* 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
@@ -42,14 +58,22 @@
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 VERBOSE = args.includes('--verbose');
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',
@@ -151,6 +175,31 @@ 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();
@@ -193,24 +242,25 @@ async function main() {
);
info(`Found ${roles.length} roles (${adminRoleIds.size} admin role(s))`);
// ── 2. Load Greenlight users ───────────────────────────────────────────
// ── 2. Load Greenlight users (local + OIDC) ────────────────────────────
const glUsers = await glDb.all(`
SELECT id, name, email, password_digest, language, role_id, verified, status
SELECT id, name, email, password_digest, language, role_id, verified, status, provider, external_id
FROM users
WHERE provider = 'greenlight'
ORDER BY created_at ASC
`);
info(`Found ${glUsers.length} Greenlight user(s)`);
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 users ───────────────────────────────────────────────────
log(`${c.bold}Users${c.reset}`);
// ── 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, usersUpdated = 0;
let usersCreated = 0, usersSkipped = 0;
for (const u of glUsers) {
for (const u of localUsers) {
if (!u.password_digest) {
warn(`${u.email} — no password (SSO user?), skipping`);
warn(`${u.email} — no password, skipping`);
usersSkipped++;
continue;
}
@@ -248,6 +298,59 @@ async function main() {
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);
@@ -414,12 +517,163 @@ async function main() {
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(` Users migrated: ${c.green}${usersCreated}${c.reset}`);
log(` Rooms migrated: ${c.green}${roomsCreated}${c.reset}`);
if (!SKIP_SHARES) log(` Shares migrated: (see above)`);
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}`);

6
package-lock.json generated
View File

@@ -3214,9 +3214,9 @@
"license": "MIT"
},
"node_modules/multer": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/multer/-/multer-2.1.0.tgz",
"integrity": "sha512-TBm6j41rxNohqawsxlsWsNNh/VdV4QFXcBvRcPhXaA05EZ79z0qJ2bQFpync6JBoHTeNY5Q1JpG7AlTjdlfAEA==",
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/multer/-/multer-2.1.1.tgz",
"integrity": "sha512-mo+QTzKlx8R7E5ylSXxWzGoXoZbOsRMpyitcht8By2KHvMbf3tjwosZ/Mu/XYU6UuJ3VZnODIrak5ZrPiPyB6A==",
"license": "MIT",
"dependencies": {
"append-field": "^1.0.0",