All checks were successful
Build & Push Docker Image / build (push) Successful in 6m59s
747 lines
30 KiB
JavaScript
747 lines
30 KiB
JavaScript
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');
|
|
}
|
|
if (!(await db.columnExists('calendar_events', 'reminder_minutes'))) {
|
|
await db.exec('ALTER TABLE calendar_events ADD COLUMN reminder_minutes INTEGER DEFAULT NULL');
|
|
}
|
|
if (!(await db.columnExists('calendar_events', 'reminder_sent_at'))) {
|
|
await db.exec(`ALTER TABLE calendar_events ADD COLUMN reminder_sent_at ${isPostgres ? 'TIMESTAMP' : 'DATETIME'} DEFAULT NULL`);
|
|
}
|
|
|
|
// Calendar invitations (federated calendar events that must be accepted first)
|
|
if (isPostgres) {
|
|
await db.exec(`
|
|
CREATE TABLE IF NOT EXISTS calendar_invitations (
|
|
id SERIAL PRIMARY KEY,
|
|
event_uid TEXT NOT NULL,
|
|
title TEXT NOT NULL,
|
|
description TEXT,
|
|
start_time TIMESTAMP NOT NULL,
|
|
end_time TIMESTAMP NOT NULL,
|
|
room_uid TEXT,
|
|
join_url TEXT,
|
|
from_user TEXT NOT NULL,
|
|
to_user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
|
color TEXT DEFAULT '#6366f1',
|
|
status TEXT DEFAULT 'pending' CHECK(status IN ('pending','accepted','declined')),
|
|
created_at TIMESTAMP DEFAULT NOW()
|
|
);
|
|
CREATE INDEX IF NOT EXISTS idx_cal_inv_to_user ON calendar_invitations(to_user_id);
|
|
CREATE UNIQUE INDEX IF NOT EXISTS idx_cal_inv_uid_user ON calendar_invitations(event_uid, to_user_id);
|
|
`);
|
|
} else {
|
|
await db.exec(`
|
|
CREATE TABLE IF NOT EXISTS calendar_invitations (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
event_uid TEXT NOT NULL,
|
|
title TEXT NOT NULL,
|
|
description TEXT,
|
|
start_time DATETIME NOT NULL,
|
|
end_time DATETIME NOT NULL,
|
|
room_uid TEXT,
|
|
join_url TEXT,
|
|
from_user TEXT NOT NULL,
|
|
to_user_id INTEGER NOT NULL,
|
|
color TEXT DEFAULT '#6366f1',
|
|
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,
|
|
UNIQUE(event_uid, to_user_id)
|
|
);
|
|
CREATE INDEX IF NOT EXISTS idx_cal_inv_to_user ON calendar_invitations(to_user_id);
|
|
`);
|
|
}
|
|
|
|
// Track outbound calendar event federation sends for deletion propagation
|
|
if (isPostgres) {
|
|
await db.exec(`
|
|
CREATE TABLE IF NOT EXISTS calendar_event_outbound (
|
|
id SERIAL PRIMARY KEY,
|
|
event_uid TEXT NOT NULL,
|
|
remote_domain TEXT NOT NULL,
|
|
created_at TIMESTAMP DEFAULT NOW(),
|
|
UNIQUE(event_uid, remote_domain)
|
|
);
|
|
CREATE INDEX IF NOT EXISTS idx_cal_out_event_uid ON calendar_event_outbound(event_uid);
|
|
`);
|
|
} else {
|
|
await db.exec(`
|
|
CREATE TABLE IF NOT EXISTS calendar_event_outbound (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
event_uid TEXT NOT NULL,
|
|
remote_domain TEXT NOT NULL,
|
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
|
UNIQUE(event_uid, remote_domain)
|
|
);
|
|
CREATE INDEX IF NOT EXISTS idx_cal_out_event_uid ON calendar_event_outbound(event_uid);
|
|
`);
|
|
}
|
|
|
|
// Local calendar event invitations (share-with-acceptance flow)
|
|
if (isPostgres) {
|
|
await db.exec(`
|
|
CREATE TABLE IF NOT EXISTS calendar_local_invitations (
|
|
id SERIAL PRIMARY KEY,
|
|
event_id INTEGER NOT NULL REFERENCES calendar_events(id) ON DELETE CASCADE,
|
|
from_user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
|
to_user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
|
status TEXT DEFAULT 'pending' CHECK(status IN ('pending','accepted','declined')),
|
|
created_at TIMESTAMP DEFAULT NOW(),
|
|
UNIQUE(event_id, to_user_id)
|
|
);
|
|
CREATE INDEX IF NOT EXISTS idx_cal_local_inv_to_user ON calendar_local_invitations(to_user_id);
|
|
CREATE INDEX IF NOT EXISTS idx_cal_local_inv_event ON calendar_local_invitations(event_id);
|
|
`);
|
|
} else {
|
|
await db.exec(`
|
|
CREATE TABLE IF NOT EXISTS calendar_local_invitations (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
event_id INTEGER NOT NULL,
|
|
from_user_id INTEGER NOT NULL,
|
|
to_user_id INTEGER NOT NULL,
|
|
status TEXT DEFAULT 'pending' CHECK(status IN ('pending','accepted','declined')),
|
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
|
UNIQUE(event_id, to_user_id),
|
|
FOREIGN KEY (event_id) REFERENCES calendar_events(id) ON DELETE CASCADE,
|
|
FOREIGN KEY (from_user_id) REFERENCES users(id) ON DELETE CASCADE,
|
|
FOREIGN KEY (to_user_id) REFERENCES users(id) ON DELETE CASCADE
|
|
);
|
|
CREATE INDEX IF NOT EXISTS idx_cal_local_inv_to_user ON calendar_local_invitations(to_user_id);
|
|
CREATE INDEX IF NOT EXISTS idx_cal_local_inv_event ON calendar_local_invitations(event_id);
|
|
`);
|
|
}
|
|
|
|
// ── Notifications table ──────────────────────────────────────────────────
|
|
if (isPostgres) {
|
|
await db.exec(`
|
|
CREATE TABLE IF NOT EXISTS notifications (
|
|
id SERIAL PRIMARY KEY,
|
|
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
|
type TEXT NOT NULL,
|
|
title TEXT NOT NULL,
|
|
body TEXT,
|
|
link TEXT,
|
|
read INTEGER DEFAULT 0,
|
|
created_at TIMESTAMP DEFAULT NOW()
|
|
);
|
|
CREATE INDEX IF NOT EXISTS idx_notifications_user_id ON notifications(user_id);
|
|
`);
|
|
} else {
|
|
await db.exec(`
|
|
CREATE TABLE IF NOT EXISTS notifications (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
user_id INTEGER NOT NULL,
|
|
type TEXT NOT NULL,
|
|
title TEXT NOT NULL,
|
|
body TEXT,
|
|
link TEXT,
|
|
read INTEGER DEFAULT 0,
|
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
|
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
|
);
|
|
CREATE INDEX IF NOT EXISTS idx_notifications_user_id ON notifications(user_id);
|
|
`);
|
|
}
|
|
|
|
// ── CalDAV tokens ────────────────────────────────────────────────────────
|
|
if (isPostgres) {
|
|
await db.exec(`
|
|
CREATE TABLE IF NOT EXISTS caldav_tokens (
|
|
id SERIAL PRIMARY KEY,
|
|
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
|
token TEXT UNIQUE NOT NULL,
|
|
name TEXT NOT NULL,
|
|
created_at TIMESTAMP DEFAULT NOW(),
|
|
last_used_at TIMESTAMP DEFAULT NULL
|
|
);
|
|
CREATE INDEX IF NOT EXISTS idx_caldav_tokens_user_id ON caldav_tokens(user_id);
|
|
CREATE INDEX IF NOT EXISTS idx_caldav_tokens_token ON caldav_tokens(token);
|
|
`);
|
|
} else {
|
|
await db.exec(`
|
|
CREATE TABLE IF NOT EXISTS caldav_tokens (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
user_id INTEGER NOT NULL,
|
|
token TEXT UNIQUE NOT NULL,
|
|
name TEXT NOT NULL,
|
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
|
last_used_at DATETIME DEFAULT NULL,
|
|
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
|
);
|
|
CREATE INDEX IF NOT EXISTS idx_caldav_tokens_user_id ON caldav_tokens(user_id);
|
|
CREATE INDEX IF NOT EXISTS idx_caldav_tokens_token ON caldav_tokens(token);
|
|
`);
|
|
}
|
|
|
|
// CalDAV: add token_hash column for SHA-256 hashed token lookup
|
|
if (!(await db.columnExists('caldav_tokens', 'token_hash'))) {
|
|
await db.exec('ALTER TABLE caldav_tokens ADD COLUMN token_hash TEXT DEFAULT NULL');
|
|
await db.exec('CREATE INDEX IF NOT EXISTS idx_caldav_tokens_hash ON caldav_tokens(token_hash)');
|
|
}
|
|
|
|
// ── OAuth tables ────────────────────────────────────────────────────────
|
|
if (isPostgres) {
|
|
await db.exec(`
|
|
CREATE TABLE IF NOT EXISTS oauth_states (
|
|
state TEXT PRIMARY KEY,
|
|
provider TEXT NOT NULL,
|
|
code_verifier TEXT NOT NULL,
|
|
return_to TEXT,
|
|
expires_at TIMESTAMP NOT NULL
|
|
);
|
|
CREATE INDEX IF NOT EXISTS idx_oauth_states_expires ON oauth_states(expires_at);
|
|
`);
|
|
} else {
|
|
await db.exec(`
|
|
CREATE TABLE IF NOT EXISTS oauth_states (
|
|
state TEXT PRIMARY KEY,
|
|
provider TEXT NOT NULL,
|
|
code_verifier TEXT NOT NULL,
|
|
return_to TEXT,
|
|
expires_at DATETIME NOT NULL
|
|
);
|
|
CREATE INDEX IF NOT EXISTS idx_oauth_states_expires ON oauth_states(expires_at);
|
|
`);
|
|
}
|
|
|
|
// Add OAuth columns to users table
|
|
if (!(await db.columnExists('users', 'oauth_provider'))) {
|
|
await db.exec('ALTER TABLE users ADD COLUMN oauth_provider TEXT DEFAULT NULL');
|
|
}
|
|
if (!(await db.columnExists('users', 'oauth_provider_id'))) {
|
|
await db.exec('ALTER TABLE users ADD COLUMN oauth_provider_id 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");
|
|
}
|
|
}
|