const Database = require('better-sqlite3-multiple-ciphers'); const path = require('path'); const fs = require('fs'); const bcrypt = require('bcryptjs'); const DB_PATH = process.env.DB_PATH || '/app/data/jama.db'; const DB_KEY = process.env.DB_KEY || ''; let db; function getDb() { if (!db) { // Ensure the data directory exists before opening the DB const dir = path.dirname(DB_PATH); if (!fs.existsSync(dir)) { fs.mkdirSync(dir, { recursive: true }); console.log(`[DB] Created data directory: ${dir}`); } db = new Database(DB_PATH); if (DB_KEY) { // Use SQLCipher4 AES-256-CBC — compatible with standard sqlcipher CLI and DB Browser // Must be applied before any other DB access const safeKey = DB_KEY.replace(/'/g, "''"); db.pragma(`cipher='sqlcipher'`); db.pragma(`legacy=4`); db.pragma(`key='${safeKey}'`); console.log('[DB] Encryption key applied (SQLCipher4)'); } else { console.warn('[DB] WARNING: DB_KEY not set — database is unencrypted'); } db.pragma('journal_mode = WAL'); db.pragma('foreign_keys = ON'); console.log(`[DB] Opened database at ${DB_PATH}`); } return db; } function initDb() { const db = getDb(); db.exec(` CREATE TABLE IF NOT EXISTS users ( id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL, email TEXT UNIQUE NOT NULL, password TEXT NOT NULL, role TEXT NOT NULL DEFAULT 'member', status TEXT NOT NULL DEFAULT 'active', is_default_admin INTEGER NOT NULL DEFAULT 0, must_change_password INTEGER NOT NULL DEFAULT 1, avatar TEXT, about_me TEXT, display_name TEXT, hide_admin_tag INTEGER NOT NULL DEFAULT 0, created_at TEXT NOT NULL DEFAULT (datetime('now')), updated_at TEXT NOT NULL DEFAULT (datetime('now')) ); CREATE TABLE IF NOT EXISTS groups ( id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL, type TEXT NOT NULL DEFAULT 'public', owner_id INTEGER, is_default INTEGER NOT NULL DEFAULT 0, is_readonly INTEGER NOT NULL DEFAULT 0, created_at TEXT NOT NULL DEFAULT (datetime('now')), updated_at TEXT NOT NULL DEFAULT (datetime('now')), FOREIGN KEY (owner_id) REFERENCES users(id) ); CREATE TABLE IF NOT EXISTS group_members ( id INTEGER PRIMARY KEY AUTOINCREMENT, group_id INTEGER NOT NULL, user_id INTEGER NOT NULL, joined_at TEXT NOT NULL DEFAULT (datetime('now')), UNIQUE(group_id, user_id), FOREIGN KEY (group_id) REFERENCES groups(id) ON DELETE CASCADE, FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE ); CREATE TABLE IF NOT EXISTS messages ( id INTEGER PRIMARY KEY AUTOINCREMENT, group_id INTEGER NOT NULL, user_id INTEGER NOT NULL, content TEXT, type TEXT NOT NULL DEFAULT 'text', image_url TEXT, reply_to_id INTEGER, is_deleted INTEGER NOT NULL DEFAULT 0, link_preview TEXT, created_at TEXT NOT NULL DEFAULT (datetime('now')), FOREIGN KEY (group_id) REFERENCES groups(id) ON DELETE CASCADE, FOREIGN KEY (user_id) REFERENCES users(id), FOREIGN KEY (reply_to_id) REFERENCES messages(id) ); CREATE TABLE IF NOT EXISTS reactions ( id INTEGER PRIMARY KEY AUTOINCREMENT, message_id INTEGER NOT NULL, user_id INTEGER NOT NULL, emoji TEXT NOT NULL, created_at TEXT NOT NULL DEFAULT (datetime('now')), UNIQUE(message_id, user_id, emoji), FOREIGN KEY (message_id) REFERENCES messages(id) ON DELETE CASCADE, FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE ); CREATE TABLE IF NOT EXISTS notifications ( id INTEGER PRIMARY KEY AUTOINCREMENT, user_id INTEGER NOT NULL, type TEXT NOT NULL, message_id INTEGER, group_id INTEGER, from_user_id INTEGER, is_read INTEGER NOT NULL DEFAULT 0, created_at TEXT NOT NULL DEFAULT (datetime('now')), FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE ); CREATE TABLE IF NOT EXISTS sessions ( id TEXT PRIMARY KEY, user_id INTEGER NOT NULL, created_at TEXT NOT NULL DEFAULT (datetime('now')), expires_at TEXT NOT NULL, FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE ); CREATE TABLE IF NOT EXISTS settings ( key TEXT PRIMARY KEY, value TEXT NOT NULL, updated_at TEXT NOT NULL DEFAULT (datetime('now')) ); CREATE TABLE IF NOT EXISTS active_sessions ( user_id INTEGER NOT NULL, device TEXT NOT NULL DEFAULT 'desktop', token TEXT NOT NULL, ua TEXT, created_at TEXT NOT NULL DEFAULT (datetime('now')), PRIMARY KEY (user_id, device), FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE ); CREATE TABLE IF NOT EXISTS push_subscriptions ( id INTEGER PRIMARY KEY AUTOINCREMENT, user_id INTEGER NOT NULL, endpoint TEXT NOT NULL, p256dh TEXT NOT NULL, auth TEXT NOT NULL, device TEXT NOT NULL DEFAULT 'desktop', created_at TEXT NOT NULL DEFAULT (datetime('now')), UNIQUE(user_id, device), FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE ); `); // Initialize default settings const insertSetting = db.prepare('INSERT OR IGNORE INTO settings (key, value) VALUES (?, ?)'); insertSetting.run('app_name', process.env.APP_NAME || 'jama'); insertSetting.run('logo_url', ''); insertSetting.run('pw_reset_active', process.env.ADMPW_RESET === 'true' ? 'true' : 'false'); insertSetting.run('icon_newchat', ''); insertSetting.run('icon_groupinfo', ''); insertSetting.run('pwa_icon_192', ''); insertSetting.run('pwa_icon_512', ''); // Migration: add hide_admin_tag if upgrading from older version try { db.exec("ALTER TABLE users ADD COLUMN hide_admin_tag INTEGER NOT NULL DEFAULT 0"); console.log('[DB] Migration: added hide_admin_tag column'); } catch (e) { /* column already exists */ } // Migration: replace single-session active_sessions with per-device version try { const cols = db.prepare("PRAGMA table_info(active_sessions)").all().map(c => c.name); if (!cols.includes('device')) { db.exec("DROP TABLE IF EXISTS active_sessions"); db.exec(` CREATE TABLE IF NOT EXISTS active_sessions ( user_id INTEGER NOT NULL, device TEXT NOT NULL DEFAULT 'desktop', token TEXT NOT NULL, ua TEXT, created_at TEXT NOT NULL DEFAULT (datetime('now')), PRIMARY KEY (user_id, device), FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE ) `); console.log('[DB] Migration: rebuilt active_sessions for per-device sessions'); } } catch (e) { console.error('[DB] active_sessions migration error:', e.message); } // Migration: add is_direct for user-to-user direct messages try { db.exec("ALTER TABLE groups ADD COLUMN is_direct INTEGER NOT NULL DEFAULT 0"); console.log('[DB] Migration: added is_direct column'); } catch (e) { /* column already exists */ } // Migration: store both peer IDs so direct-message names survive member leave try { db.exec("ALTER TABLE groups ADD COLUMN direct_peer1_id INTEGER"); console.log('[DB] Migration: added direct_peer1_id column'); } catch (e) { /* column already exists */ } try { db.exec("ALTER TABLE groups ADD COLUMN direct_peer2_id INTEGER"); console.log('[DB] Migration: added direct_peer2_id column'); } catch (e) { /* column already exists */ } // Migration: last_online timestamp per user try { db.exec("ALTER TABLE users ADD COLUMN last_online TEXT"); console.log('[DB] Migration: added last_online column'); } catch (e) { /* column already exists */ } // Migration: help_dismissed preference per user try { db.exec("ALTER TABLE users ADD COLUMN help_dismissed INTEGER NOT NULL DEFAULT 0"); console.log('[DB] Migration: added help_dismissed column'); } catch (e) { /* column already exists */ } // Migration: user-customised group display names (per-user, per-group) try { db.exec(` CREATE TABLE IF NOT EXISTS user_group_names ( user_id INTEGER NOT NULL, group_id INTEGER NOT NULL, name TEXT NOT NULL, PRIMARY KEY (user_id, group_id) ) `); console.log('[DB] Migration: user_group_names table ready'); } catch (e) { console.error('[DB] user_group_names migration error:', e.message); } // Migration: pinned conversations (per-user, pins a group to top of sidebar) try { db.exec(` CREATE TABLE IF NOT EXISTS pinned_conversations ( user_id INTEGER NOT NULL, group_id INTEGER NOT NULL, pinned_at TEXT NOT NULL DEFAULT (datetime('now')), PRIMARY KEY (user_id, group_id), FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE, FOREIGN KEY (group_id) REFERENCES groups(id) ON DELETE CASCADE ) `); console.log('[DB] Migration: pinned_conversations table ready'); } catch (e) { console.error('[DB] pinned_conversations migration error:', e.message); } console.log('[DB] Schema initialized'); return db; } function seedAdmin() { const db = getDb(); // Strip any surrounding quotes from env vars (common docker-compose mistake) const adminEmail = (process.env.ADMIN_EMAIL || 'admin@jama.local').replace(/^["']|["']$/g, '').trim(); const adminName = (process.env.ADMIN_NAME || 'Admin User').replace(/^["']|["']$/g, '').trim(); const adminPass = (process.env.ADMIN_PASS || 'Admin@1234').replace(/^["']|["']$/g, '').trim(); const pwReset = process.env.ADMPW_RESET === 'true'; console.log(`[DB] Checking for default admin (${adminEmail})...`); const existing = db.prepare('SELECT * FROM users WHERE is_default_admin = 1').get(); if (!existing) { try { const hash = bcrypt.hashSync(adminPass, 10); const result = db.prepare(` INSERT INTO users (name, email, password, role, status, is_default_admin, must_change_password) VALUES (?, ?, ?, 'admin', 'active', 1, 1) `).run(adminName, adminEmail, hash); console.log(`[DB] Default admin created: ${adminEmail} (id=${result.lastInsertRowid})`); // Create default public group const groupResult = db.prepare(` INSERT INTO groups (name, type, is_default, owner_id) VALUES (?, 'public', 1, ?) `).run(process.env.DEFCHAT_NAME || 'General Chat', result.lastInsertRowid); // Add admin to default group db.prepare('INSERT OR IGNORE INTO group_members (group_id, user_id) VALUES (?, ?)') .run(groupResult.lastInsertRowid, result.lastInsertRowid); console.log(`[DB] Default group created: ${process.env.DEFCHAT_NAME || 'General Chat'}`); seedSupportGroup(); } catch (err) { console.error('[DB] ERROR creating default admin:', err.message); } return; } console.log(`[DB] Default admin already exists (id=${existing.id})`); // Handle ADMPW_RESET if (pwReset) { const hash = bcrypt.hashSync(adminPass, 10); db.prepare(` UPDATE users SET password = ?, must_change_password = 1, updated_at = datetime('now') WHERE is_default_admin = 1 `).run(hash); db.prepare("UPDATE settings SET value = 'true', updated_at = datetime('now') WHERE key = 'pw_reset_active'").run(); console.log('[DB] Admin password reset via ADMPW_RESET=true'); } else { db.prepare("UPDATE settings SET value = 'false', updated_at = datetime('now') WHERE key = 'pw_reset_active'").run(); } } function addUserToPublicGroups(userId) { const db = getDb(); const publicGroups = db.prepare("SELECT id FROM groups WHERE type = 'public'").all(); const insert = db.prepare('INSERT OR IGNORE INTO group_members (group_id, user_id) VALUES (?, ?)'); for (const g of publicGroups) { insert.run(g.id, userId); } } function seedSupportGroup() { const db = getDb(); // Create the Support group if it doesn't exist const existing = db.prepare("SELECT id FROM groups WHERE name = 'Support' AND type = 'private'").get(); if (existing) return existing.id; const admin = db.prepare('SELECT id FROM users WHERE is_default_admin = 1').get(); if (!admin) return null; const result = db.prepare(` INSERT INTO groups (name, type, owner_id, is_default) VALUES ('Support', 'private', ?, 0) `).run(admin.id); const groupId = result.lastInsertRowid; // Add all current admins to the Support group const admins = db.prepare("SELECT id FROM users WHERE role = 'admin' AND status = 'active'").all(); const insert = db.prepare('INSERT OR IGNORE INTO group_members (group_id, user_id) VALUES (?, ?)'); for (const a of admins) insert.run(groupId, a.id); console.log('[DB] Support group created'); return groupId; } function getOrCreateSupportGroup() { const db = getDb(); const group = db.prepare("SELECT id FROM groups WHERE name = 'Support' AND type = 'private'").get(); if (group) return group.id; return seedSupportGroup(); } module.exports = { getDb, initDb, seedAdmin, seedSupportGroup, getOrCreateSupportGroup, addUserToPublicGroups };