Files
redlight/server/config/database.js
2026-03-02 10:35:01 +01:00

537 lines
21 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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");
}
}