353 lines
13 KiB
JavaScript
353 lines
13 KiB
JavaScript
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 };
|