import bcrypt from 'bcryptjs'; import path from 'path'; import { fileURLToPath } from 'url'; import { log } from './logger.js'; const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); const DATABASE_URL = process.env.DATABASE_URL; const isPostgres = !!(DATABASE_URL && DATABASE_URL.startsWith('postgres')); let db; // Convert ? placeholders to $1, $2, ... for PostgreSQL function convertPlaceholders(sql) { let index = 0; return sql.replace(/\?/g, () => `$${++index}`); } // ── SQLite Adapter ────────────────────────────────────────────────────────── class SqliteAdapter { async init() { const { default: Database } = await import('better-sqlite3'); const dbPath = process.env.SQLITE_PATH || path.join(__dirname, '..', '..', 'redlight.db'); this.db = Database(dbPath); this.db.pragma('journal_mode = WAL'); this.db.pragma('foreign_keys = ON'); } async get(sql, params = []) { return this.db.prepare(sql).get(...params); } async all(sql, params = []) { return this.db.prepare(sql).all(...params); } async run(sql, params = []) { const result = this.db.prepare(sql).run(...params); return { lastInsertRowid: Number(result.lastInsertRowid), changes: result.changes }; } async exec(sql) { this.db.exec(sql); } async columnExists(table, column) { const columns = this.db.pragma(`table_info(${table})`); return !!columns.find(c => c.name === column); } close() { this.db.close(); } } // ── PostgreSQL Adapter ────────────────────────────────────────────────────── class PostgresAdapter { async init() { const pg = await import('pg'); // Parse int8 (bigint / COUNT) as JS number instead of string pg.default.types.setTypeParser(20, val => parseInt(val, 10)); this.pool = new pg.default.Pool({ connectionString: DATABASE_URL }); } async get(sql, params = []) { const result = await this.pool.query(convertPlaceholders(sql), params); return result.rows[0] || undefined; } async all(sql, params = []) { const result = await this.pool.query(convertPlaceholders(sql), params); return result.rows; } async run(sql, params = []) { let pgSql = convertPlaceholders(sql); const isInsert = /^\s*INSERT/i.test(pgSql); if (isInsert && !/RETURNING/i.test(pgSql)) { pgSql += ' RETURNING id'; } const result = await this.pool.query(pgSql, params); return { lastInsertRowid: isInsert ? result.rows[0]?.id : undefined, changes: result.rowCount, }; } async exec(sql) { await this.pool.query(sql); } async columnExists(table, column) { const result = await this.pool.query( 'SELECT column_name FROM information_schema.columns WHERE table_name = $1 AND column_name = $2', [table, column] ); return result.rows.length > 0; } close() { this.pool?.end(); } } // ── Public API ────────────────────────────────────────────────────────────── export function getDb() { if (!db) { throw new Error('Database not initialised – call initDatabase() first'); } return db; } export async function initDatabase() { // Create the right adapter if (isPostgres) { log.db.info('Using PostgreSQL database'); db = new PostgresAdapter(); } else { log.db.info('Using SQLite database'); db = new SqliteAdapter(); } await db.init(); // ── Schema creation ───────────────────────────────────────────────────── if (isPostgres) { await db.exec(` CREATE TABLE IF NOT EXISTS users ( id SERIAL PRIMARY KEY, name TEXT NOT NULL, display_name TEXT DEFAULT '', email TEXT UNIQUE NOT NULL, password_hash TEXT NOT NULL, role TEXT DEFAULT 'user' CHECK(role IN ('user', 'admin')), language TEXT DEFAULT 'de', theme TEXT DEFAULT 'dark', avatar_color TEXT DEFAULT '#6366f1', avatar_image TEXT DEFAULT NULL, email_verified INTEGER DEFAULT 0, verification_token TEXT, verification_token_expires TIMESTAMP, verification_resend_at TIMESTAMP DEFAULT NULL, created_at TIMESTAMP DEFAULT NOW(), updated_at TIMESTAMP DEFAULT NOW() ); CREATE TABLE IF NOT EXISTS rooms ( id SERIAL PRIMARY KEY, uid TEXT UNIQUE NOT NULL, name TEXT NOT NULL, user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, welcome_message TEXT DEFAULT 'Willkommen im Meeting!', max_participants INTEGER DEFAULT 0, access_code TEXT, mute_on_join INTEGER DEFAULT 1, require_approval INTEGER DEFAULT 0, anyone_can_start INTEGER DEFAULT 0, all_join_moderator INTEGER DEFAULT 0, record_meeting INTEGER DEFAULT 1, guest_access INTEGER DEFAULT 0, moderator_code TEXT, presentation_file TEXT DEFAULT NULL, created_at TIMESTAMP DEFAULT NOW(), updated_at TIMESTAMP DEFAULT NOW() ); CREATE TABLE IF NOT EXISTS room_shares ( id SERIAL PRIMARY KEY, room_id INTEGER NOT NULL REFERENCES rooms(id) ON DELETE CASCADE, user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, created_at TIMESTAMP DEFAULT NOW(), UNIQUE(room_id, user_id) ); CREATE INDEX IF NOT EXISTS idx_rooms_user_id ON rooms(user_id); CREATE INDEX IF NOT EXISTS idx_rooms_uid ON rooms(uid); CREATE INDEX IF NOT EXISTS idx_users_email ON users(email); CREATE INDEX IF NOT EXISTS idx_room_shares_room_id ON room_shares(room_id); CREATE INDEX IF NOT EXISTS idx_room_shares_user_id ON room_shares(user_id); CREATE TABLE IF NOT EXISTS settings ( key TEXT PRIMARY KEY, value TEXT, updated_at TIMESTAMP DEFAULT NOW() ); CREATE TABLE IF NOT EXISTS federation_invitations ( id SERIAL PRIMARY KEY, invite_id TEXT UNIQUE NOT NULL, from_user TEXT NOT NULL, to_user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, room_name TEXT NOT NULL, message TEXT, join_url TEXT NOT NULL, status TEXT DEFAULT 'pending' CHECK(status IN ('pending','accepted','declined')), created_at TIMESTAMP DEFAULT NOW() ); CREATE INDEX IF NOT EXISTS idx_fed_inv_to_user ON federation_invitations(to_user_id); CREATE INDEX IF NOT EXISTS idx_fed_inv_invite_id ON federation_invitations(invite_id); CREATE TABLE IF NOT EXISTS federated_rooms ( id SERIAL PRIMARY KEY, user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, invite_id TEXT UNIQUE NOT NULL, room_name TEXT NOT NULL, from_user TEXT NOT NULL, join_url TEXT NOT NULL, meet_id TEXT, max_participants INTEGER DEFAULT 0, allow_recording INTEGER DEFAULT 1, created_at TIMESTAMP DEFAULT NOW() ); CREATE INDEX IF NOT EXISTS idx_fed_rooms_user_id ON federated_rooms(user_id); `); } else { await db.exec(` CREATE TABLE IF NOT EXISTS users ( id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL, display_name TEXT DEFAULT '', email TEXT UNIQUE NOT NULL, password_hash TEXT NOT NULL, role TEXT DEFAULT 'user' CHECK(role IN ('user', 'admin')), language TEXT DEFAULT 'de', theme TEXT DEFAULT 'dark', avatar_color TEXT DEFAULT '#6366f1', avatar_image TEXT DEFAULT NULL, email_verified INTEGER DEFAULT 0, verification_token TEXT, verification_token_expires DATETIME, verification_resend_at DATETIME DEFAULT NULL, created_at DATETIME DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ); CREATE TABLE IF NOT EXISTS rooms ( id INTEGER PRIMARY KEY AUTOINCREMENT, uid TEXT UNIQUE NOT NULL, name TEXT NOT NULL, user_id INTEGER NOT NULL, welcome_message TEXT DEFAULT 'Willkommen im Meeting!', max_participants INTEGER DEFAULT 0, access_code TEXT, mute_on_join INTEGER DEFAULT 1, require_approval INTEGER DEFAULT 0, anyone_can_start INTEGER DEFAULT 0, all_join_moderator INTEGER DEFAULT 0, record_meeting INTEGER DEFAULT 1, guest_access INTEGER DEFAULT 0, moderator_code TEXT, presentation_file TEXT DEFAULT NULL, created_at DATETIME DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME DEFAULT CURRENT_TIMESTAMP, FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE ); CREATE TABLE IF NOT EXISTS room_shares ( id INTEGER PRIMARY KEY AUTOINCREMENT, room_id INTEGER NOT NULL, user_id INTEGER NOT NULL, created_at DATETIME DEFAULT CURRENT_TIMESTAMP, UNIQUE(room_id, user_id), FOREIGN KEY (room_id) REFERENCES rooms(id) ON DELETE CASCADE, FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE ); CREATE INDEX IF NOT EXISTS idx_rooms_user_id ON rooms(user_id); CREATE INDEX IF NOT EXISTS idx_rooms_uid ON rooms(uid); CREATE INDEX IF NOT EXISTS idx_users_email ON users(email); CREATE INDEX IF NOT EXISTS idx_room_shares_room_id ON room_shares(room_id); CREATE INDEX IF NOT EXISTS idx_room_shares_user_id ON room_shares(user_id); CREATE TABLE IF NOT EXISTS settings ( key TEXT PRIMARY KEY, value TEXT, updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ); CREATE TABLE IF NOT EXISTS federation_invitations ( id INTEGER PRIMARY KEY AUTOINCREMENT, invite_id TEXT UNIQUE NOT NULL, from_user TEXT NOT NULL, to_user_id INTEGER NOT NULL, room_name TEXT NOT NULL, message TEXT, join_url TEXT NOT NULL, status TEXT DEFAULT 'pending' CHECK(status IN ('pending','accepted','declined')), created_at DATETIME DEFAULT CURRENT_TIMESTAMP, FOREIGN KEY (to_user_id) REFERENCES users(id) ON DELETE CASCADE ); CREATE INDEX IF NOT EXISTS idx_fed_inv_to_user ON federation_invitations(to_user_id); CREATE INDEX IF NOT EXISTS idx_fed_inv_invite_id ON federation_invitations(invite_id); CREATE TABLE IF NOT EXISTS federated_rooms ( id INTEGER PRIMARY KEY AUTOINCREMENT, user_id INTEGER NOT NULL, invite_id TEXT UNIQUE NOT NULL, room_name TEXT NOT NULL, from_user TEXT NOT NULL, join_url TEXT NOT NULL, meet_id TEXT, max_participants INTEGER DEFAULT 0, allow_recording INTEGER DEFAULT 1, created_at DATETIME DEFAULT CURRENT_TIMESTAMP, FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE ); CREATE INDEX IF NOT EXISTS idx_fed_rooms_user_id ON federated_rooms(user_id); `); } // ── Migrations ────────────────────────────────────────────────────────── if (!(await db.columnExists('users', 'avatar_image'))) { await db.exec('ALTER TABLE users ADD COLUMN avatar_image TEXT DEFAULT NULL'); } if (!(await db.columnExists('rooms', 'guest_access'))) { await db.exec('ALTER TABLE rooms ADD COLUMN guest_access INTEGER DEFAULT 0'); } if (!(await db.columnExists('rooms', 'moderator_code'))) { await db.exec('ALTER TABLE rooms ADD COLUMN moderator_code TEXT'); } if (!(await db.columnExists('users', 'email_verified'))) { await db.exec('ALTER TABLE users ADD COLUMN email_verified INTEGER DEFAULT 0'); } if (!(await db.columnExists('users', 'verification_token'))) { await db.exec('ALTER TABLE users ADD COLUMN verification_token TEXT'); } if (!(await db.columnExists('users', 'verification_token_expires'))) { if (isPostgres) { await db.exec('ALTER TABLE users ADD COLUMN verification_token_expires TIMESTAMP'); } else { await db.exec('ALTER TABLE users ADD COLUMN verification_token_expires DATETIME'); } } if (!(await db.columnExists('federated_rooms', 'meet_id'))) { await db.exec('ALTER TABLE federated_rooms ADD COLUMN meet_id TEXT'); } if (!(await db.columnExists('federated_rooms', 'max_participants'))) { await db.exec('ALTER TABLE federated_rooms ADD COLUMN max_participants INTEGER DEFAULT 0'); } if (!(await db.columnExists('federated_rooms', 'allow_recording'))) { await db.exec('ALTER TABLE federated_rooms ADD COLUMN allow_recording INTEGER DEFAULT 1'); } if (!(await db.columnExists('federation_invitations', 'room_uid'))) { await db.exec('ALTER TABLE federation_invitations ADD COLUMN room_uid TEXT'); } if (!(await db.columnExists('federation_invitations', 'max_participants'))) { await db.exec('ALTER TABLE federation_invitations ADD COLUMN max_participants INTEGER DEFAULT 0'); } if (!(await db.columnExists('federation_invitations', 'allow_recording'))) { await db.exec('ALTER TABLE federation_invitations ADD COLUMN allow_recording INTEGER DEFAULT 1'); } if (!(await db.columnExists('users', 'display_name'))) { await db.exec("ALTER TABLE users ADD COLUMN display_name TEXT DEFAULT ''"); await db.exec("UPDATE users SET display_name = name WHERE display_name = ''"); } if (!(await db.columnExists('rooms', 'presentation_file'))) { await db.exec('ALTER TABLE rooms ADD COLUMN presentation_file TEXT DEFAULT NULL'); } if (!(await db.columnExists('rooms', 'presentation_name'))) { await db.exec('ALTER TABLE rooms ADD COLUMN presentation_name TEXT DEFAULT NULL'); } if (!(await db.columnExists('users', 'verification_resend_at'))) { if (isPostgres) { await db.exec('ALTER TABLE users ADD COLUMN verification_resend_at TIMESTAMP DEFAULT NULL'); } else { await db.exec('ALTER TABLE users ADD COLUMN verification_resend_at DATETIME DEFAULT NULL'); } } // Federation sync: add deleted + updated_at to federated_rooms if (!(await db.columnExists('federated_rooms', 'deleted'))) { await db.exec('ALTER TABLE federated_rooms ADD COLUMN deleted INTEGER DEFAULT 0'); } if (!(await db.columnExists('federated_rooms', 'updated_at'))) { if (isPostgres) { await db.exec('ALTER TABLE federated_rooms ADD COLUMN updated_at TIMESTAMP DEFAULT NOW()'); } else { await db.exec('ALTER TABLE federated_rooms ADD COLUMN updated_at DATETIME DEFAULT CURRENT_TIMESTAMP'); } } // Track outbound federation invites for deletion propagation if (isPostgres) { await db.exec(` CREATE TABLE IF NOT EXISTS federation_outbound_invites ( id SERIAL PRIMARY KEY, room_uid TEXT NOT NULL, remote_domain TEXT NOT NULL, created_at TIMESTAMP DEFAULT NOW(), UNIQUE(room_uid, remote_domain) ); CREATE INDEX IF NOT EXISTS idx_fed_out_room_uid ON federation_outbound_invites(room_uid); `); } else { await db.exec(` CREATE TABLE IF NOT EXISTS federation_outbound_invites ( id INTEGER PRIMARY KEY AUTOINCREMENT, room_uid TEXT NOT NULL, remote_domain TEXT NOT NULL, created_at DATETIME DEFAULT CURRENT_TIMESTAMP, UNIQUE(room_uid, remote_domain) ); CREATE INDEX IF NOT EXISTS idx_fed_out_room_uid ON federation_outbound_invites(room_uid); `); } // User invite tokens (invite-only registration) if (isPostgres) { await db.exec(` CREATE TABLE IF NOT EXISTS user_invites ( id SERIAL PRIMARY KEY, token TEXT UNIQUE NOT NULL, email TEXT NOT NULL, created_by INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, used_by INTEGER REFERENCES users(id) ON DELETE SET NULL, used_at TIMESTAMP, expires_at TIMESTAMP NOT NULL, created_at TIMESTAMP DEFAULT NOW() ); CREATE INDEX IF NOT EXISTS idx_user_invites_token ON user_invites(token); `); } else { await db.exec(` CREATE TABLE IF NOT EXISTS user_invites ( id INTEGER PRIMARY KEY AUTOINCREMENT, token TEXT UNIQUE NOT NULL, email TEXT NOT NULL, created_by INTEGER NOT NULL, used_by INTEGER, used_at DATETIME, expires_at DATETIME NOT NULL, created_at DATETIME DEFAULT CURRENT_TIMESTAMP, FOREIGN KEY (created_by) REFERENCES users(id) ON DELETE CASCADE, FOREIGN KEY (used_by) REFERENCES users(id) ON DELETE SET NULL ); CREATE INDEX IF NOT EXISTS idx_user_invites_token ON user_invites(token); `); } // ── Calendar tables ────────────────────────────────────────────────────── if (isPostgres) { await db.exec(` CREATE TABLE IF NOT EXISTS calendar_events ( id SERIAL PRIMARY KEY, uid TEXT UNIQUE NOT NULL, title TEXT NOT NULL, description TEXT, start_time TIMESTAMP NOT NULL, end_time TIMESTAMP NOT NULL, room_uid TEXT, user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, color TEXT DEFAULT '#6366f1', federated_from TEXT DEFAULT NULL, federated_join_url TEXT DEFAULT NULL, created_at TIMESTAMP DEFAULT NOW(), updated_at TIMESTAMP DEFAULT NOW() ); CREATE INDEX IF NOT EXISTS idx_cal_events_user_id ON calendar_events(user_id); CREATE INDEX IF NOT EXISTS idx_cal_events_uid ON calendar_events(uid); CREATE INDEX IF NOT EXISTS idx_cal_events_start ON calendar_events(start_time); CREATE TABLE IF NOT EXISTS calendar_event_shares ( id SERIAL PRIMARY KEY, event_id INTEGER NOT NULL REFERENCES calendar_events(id) ON DELETE CASCADE, user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, created_at TIMESTAMP DEFAULT NOW(), UNIQUE(event_id, user_id) ); CREATE INDEX IF NOT EXISTS idx_cal_shares_event ON calendar_event_shares(event_id); CREATE INDEX IF NOT EXISTS idx_cal_shares_user ON calendar_event_shares(user_id); `); } else { await db.exec(` CREATE TABLE IF NOT EXISTS calendar_events ( id INTEGER PRIMARY KEY AUTOINCREMENT, uid TEXT UNIQUE NOT NULL, title TEXT NOT NULL, description TEXT, start_time DATETIME NOT NULL, end_time DATETIME NOT NULL, room_uid TEXT, user_id INTEGER NOT NULL, color TEXT DEFAULT '#6366f1', federated_from TEXT DEFAULT NULL, federated_join_url TEXT DEFAULT NULL, created_at DATETIME DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME DEFAULT CURRENT_TIMESTAMP, FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE ); CREATE INDEX IF NOT EXISTS idx_cal_events_user_id ON calendar_events(user_id); CREATE INDEX IF NOT EXISTS idx_cal_events_uid ON calendar_events(uid); CREATE INDEX IF NOT EXISTS idx_cal_events_start ON calendar_events(start_time); CREATE TABLE IF NOT EXISTS calendar_event_shares ( id INTEGER PRIMARY KEY AUTOINCREMENT, event_id INTEGER NOT NULL, user_id INTEGER NOT NULL, created_at DATETIME DEFAULT CURRENT_TIMESTAMP, UNIQUE(event_id, user_id), FOREIGN KEY (event_id) REFERENCES calendar_events(id) ON DELETE CASCADE, FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE ); CREATE INDEX IF NOT EXISTS idx_cal_shares_event ON calendar_event_shares(event_id); CREATE INDEX IF NOT EXISTS idx_cal_shares_user ON calendar_event_shares(user_id); `); } // Calendar migrations: add federated columns if missing if (!(await db.columnExists('calendar_events', 'federated_from'))) { await db.exec('ALTER TABLE calendar_events ADD COLUMN federated_from TEXT DEFAULT NULL'); } if (!(await db.columnExists('calendar_events', 'federated_join_url'))) { await db.exec('ALTER TABLE calendar_events ADD COLUMN federated_join_url TEXT DEFAULT NULL'); } // ── Default admin (only on very first start) ──────────────────────────── const adminAlreadySeeded = await db.get("SELECT value FROM settings WHERE key = 'admin_seeded'"); if (!adminAlreadySeeded) { const adminEmail = process.env.ADMIN_EMAIL || 'admin@example.com'; const adminPassword = process.env.ADMIN_PASSWORD || 'admin123'; // Check if admin already exists (upgrade from older version without the flag) const existing = await db.get('SELECT id FROM users WHERE email = ?', [adminEmail]); if (!existing) { const hash = bcrypt.hashSync(adminPassword, 12); await db.run( 'INSERT INTO users (name, display_name, email, password_hash, role, email_verified) VALUES (?, ?, ?, ?, ?, 1)', ['Administrator', 'Administrator', adminEmail, hash, 'admin'] ); log.db.info(`Default admin created: ${adminEmail}`); } // Mark as seeded so it never runs again, even if the admin email is changed await db.run("INSERT INTO settings (key, value) VALUES ('admin_seeded', '1') RETURNING key"); } }