import Database from 'better-sqlite3'; import path from 'path'; import fs from 'fs'; import crypto from 'crypto'; import { fileURLToPath } from 'url'; import { dirname } from 'path'; const __filename = fileURLToPath(import.meta.url); const __dirname = dirname(__filename); // ANSI color codes for terminal output const colors = { reset: '\x1b[0m', bright: '\x1b[1m', cyan: '\x1b[36m', dim: '\x1b[2m', }; const c = { info: (text) => `${colors.cyan}${text}${colors.reset}`, bright: (text) => `${colors.bright}${text}${colors.reset}`, dim: (text) => `${colors.dim}${text}${colors.reset}`, }; // Use DATABASE_PATH environment variable if set, otherwise use default location const DB_PATH = process.env.DATABASE_PATH || path.join(__dirname, 'auth.db'); const INIT_SQL_PATH = path.join(__dirname, 'init.sql'); // Ensure database directory exists if custom path is provided if (process.env.DATABASE_PATH) { const dbDir = path.dirname(DB_PATH); try { if (!fs.existsSync(dbDir)) { fs.mkdirSync(dbDir, { recursive: true }); console.log(`Created database directory: ${dbDir}`); } } catch (error) { console.error(`Failed to create database directory ${dbDir}:`, error.message); throw error; } } // As part of 1.19.2 we are introducing a new location for auth.db. The below handles exisitng moving legacy database from install directory to new location const LEGACY_DB_PATH = path.join(__dirname, 'auth.db'); if (DB_PATH !== LEGACY_DB_PATH && !fs.existsSync(DB_PATH) && fs.existsSync(LEGACY_DB_PATH)) { try { fs.copyFileSync(LEGACY_DB_PATH, DB_PATH); console.log(`[MIGRATION] Copied database from ${LEGACY_DB_PATH} to ${DB_PATH}`); for (const suffix of ['-wal', '-shm']) { if (fs.existsSync(LEGACY_DB_PATH + suffix)) { fs.copyFileSync(LEGACY_DB_PATH + suffix, DB_PATH + suffix); } } } catch (err) { console.warn(`[MIGRATION] Could not copy legacy database: ${err.message}`); } } // Create database connection const db = new Database(DB_PATH); // Show app installation path prominently const appInstallPath = path.join(__dirname, '../..'); console.log(''); console.log(c.dim('═'.repeat(60))); console.log(`${c.info('[INFO]')} App Installation: ${c.bright(appInstallPath)}`); console.log(`${c.info('[INFO]')} Database: ${c.dim(path.relative(appInstallPath, DB_PATH))}`); if (process.env.DATABASE_PATH) { console.log(` ${c.dim('(Using custom DATABASE_PATH from environment)')}`); } console.log(c.dim('═'.repeat(60))); console.log(''); const runMigrations = () => { try { const tableInfo = db.prepare("PRAGMA table_info(users)").all(); const columnNames = tableInfo.map(col => col.name); if (!columnNames.includes('git_name')) { console.log('Running migration: Adding git_name column'); db.exec('ALTER TABLE users ADD COLUMN git_name TEXT'); } if (!columnNames.includes('git_email')) { console.log('Running migration: Adding git_email column'); db.exec('ALTER TABLE users ADD COLUMN git_email TEXT'); } if (!columnNames.includes('has_completed_onboarding')) { console.log('Running migration: Adding has_completed_onboarding column'); db.exec('ALTER TABLE users ADD COLUMN has_completed_onboarding BOOLEAN DEFAULT 0'); } db.exec(` CREATE TABLE IF NOT EXISTS user_notification_preferences ( user_id INTEGER PRIMARY KEY, preferences_json TEXT NOT NULL, updated_at DATETIME DEFAULT CURRENT_TIMESTAMP, FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE ) `); db.exec(` CREATE TABLE IF NOT EXISTS vapid_keys ( id INTEGER PRIMARY KEY AUTOINCREMENT, public_key TEXT NOT NULL, private_key TEXT NOT NULL, created_at DATETIME DEFAULT CURRENT_TIMESTAMP ) `); db.exec(` CREATE TABLE IF NOT EXISTS push_subscriptions ( id INTEGER PRIMARY KEY AUTOINCREMENT, user_id INTEGER NOT NULL, endpoint TEXT NOT NULL UNIQUE, keys_p256dh TEXT NOT NULL, keys_auth TEXT NOT NULL, created_at DATETIME DEFAULT CURRENT_TIMESTAMP, FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE ) `); console.log('Database migrations completed successfully'); } catch (error) { console.error('Error running migrations:', error.message); throw error; } }; // Initialize database with schema const initializeDatabase = async () => { try { const initSQL = fs.readFileSync(INIT_SQL_PATH, 'utf8'); db.exec(initSQL); console.log('Database initialized successfully'); runMigrations(); } catch (error) { console.error('Error initializing database:', error.message); throw error; } }; // User database operations const userDb = { // Check if any users exist hasUsers: () => { try { const row = db.prepare('SELECT COUNT(*) as count FROM users').get(); return row.count > 0; } catch (err) { throw err; } }, // Create a new user createUser: (username, passwordHash) => { try { const stmt = db.prepare('INSERT INTO users (username, password_hash) VALUES (?, ?)'); const result = stmt.run(username, passwordHash); return { id: result.lastInsertRowid, username }; } catch (err) { throw err; } }, // Get user by username getUserByUsername: (username) => { try { const row = db.prepare('SELECT * FROM users WHERE username = ? AND is_active = 1').get(username); return row; } catch (err) { throw err; } }, // Update last login time (non-fatal — logged but not thrown) updateLastLogin: (userId) => { try { db.prepare('UPDATE users SET last_login = CURRENT_TIMESTAMP WHERE id = ?').run(userId); } catch (err) { console.warn('Failed to update last login:', err.message); } }, // Get user by ID getUserById: (userId) => { try { const row = db.prepare('SELECT id, username, created_at, last_login FROM users WHERE id = ? AND is_active = 1').get(userId); return row; } catch (err) { throw err; } }, getFirstUser: () => { try { const row = db.prepare('SELECT id, username, created_at, last_login FROM users WHERE is_active = 1 LIMIT 1').get(); return row; } catch (err) { throw err; } }, updateGitConfig: (userId, gitName, gitEmail) => { try { const stmt = db.prepare('UPDATE users SET git_name = ?, git_email = ? WHERE id = ?'); stmt.run(gitName, gitEmail, userId); } catch (err) { throw err; } }, getGitConfig: (userId) => { try { const row = db.prepare('SELECT git_name, git_email FROM users WHERE id = ?').get(userId); return row; } catch (err) { throw err; } }, completeOnboarding: (userId) => { try { const stmt = db.prepare('UPDATE users SET has_completed_onboarding = 1 WHERE id = ?'); stmt.run(userId); } catch (err) { throw err; } }, hasCompletedOnboarding: (userId) => { try { const row = db.prepare('SELECT has_completed_onboarding FROM users WHERE id = ?').get(userId); return row?.has_completed_onboarding === 1; } catch (err) { throw err; } } }; // API Keys database operations const apiKeysDb = { // Generate a new API key generateApiKey: () => { return 'ck_' + crypto.randomBytes(32).toString('hex'); }, // Create a new API key createApiKey: (userId, keyName) => { try { const apiKey = apiKeysDb.generateApiKey(); const stmt = db.prepare('INSERT INTO api_keys (user_id, key_name, api_key) VALUES (?, ?, ?)'); const result = stmt.run(userId, keyName, apiKey); return { id: result.lastInsertRowid, keyName, apiKey }; } catch (err) { throw err; } }, // Get all API keys for a user getApiKeys: (userId) => { try { const rows = db.prepare('SELECT id, key_name, api_key, created_at, last_used, is_active FROM api_keys WHERE user_id = ? ORDER BY created_at DESC').all(userId); return rows; } catch (err) { throw err; } }, // Validate API key and get user validateApiKey: (apiKey) => { try { const row = db.prepare(` SELECT u.id, u.username, ak.id as api_key_id FROM api_keys ak JOIN users u ON ak.user_id = u.id WHERE ak.api_key = ? AND ak.is_active = 1 AND u.is_active = 1 `).get(apiKey); if (row) { // Update last_used timestamp db.prepare('UPDATE api_keys SET last_used = CURRENT_TIMESTAMP WHERE id = ?').run(row.api_key_id); } return row; } catch (err) { throw err; } }, // Delete an API key deleteApiKey: (userId, apiKeyId) => { try { const stmt = db.prepare('DELETE FROM api_keys WHERE id = ? AND user_id = ?'); const result = stmt.run(apiKeyId, userId); return result.changes > 0; } catch (err) { throw err; } }, // Toggle API key active status toggleApiKey: (userId, apiKeyId, isActive) => { try { const stmt = db.prepare('UPDATE api_keys SET is_active = ? WHERE id = ? AND user_id = ?'); const result = stmt.run(isActive ? 1 : 0, apiKeyId, userId); return result.changes > 0; } catch (err) { throw err; } } }; // User credentials database operations (for GitHub tokens, GitLab tokens, etc.) const credentialsDb = { // Create a new credential createCredential: (userId, credentialName, credentialType, credentialValue, description = null) => { try { const stmt = db.prepare('INSERT INTO user_credentials (user_id, credential_name, credential_type, credential_value, description) VALUES (?, ?, ?, ?, ?)'); const result = stmt.run(userId, credentialName, credentialType, credentialValue, description); return { id: result.lastInsertRowid, credentialName, credentialType }; } catch (err) { throw err; } }, // Get all credentials for a user, optionally filtered by type getCredentials: (userId, credentialType = null) => { try { let query = 'SELECT id, credential_name, credential_type, description, created_at, is_active FROM user_credentials WHERE user_id = ?'; const params = [userId]; if (credentialType) { query += ' AND credential_type = ?'; params.push(credentialType); } query += ' ORDER BY created_at DESC'; const rows = db.prepare(query).all(...params); return rows; } catch (err) { throw err; } }, // Get active credential value for a user by type (returns most recent active) getActiveCredential: (userId, credentialType) => { try { const row = db.prepare('SELECT credential_value FROM user_credentials WHERE user_id = ? AND credential_type = ? AND is_active = 1 ORDER BY created_at DESC LIMIT 1').get(userId, credentialType); return row?.credential_value || null; } catch (err) { throw err; } }, // Delete a credential deleteCredential: (userId, credentialId) => { try { const stmt = db.prepare('DELETE FROM user_credentials WHERE id = ? AND user_id = ?'); const result = stmt.run(credentialId, userId); return result.changes > 0; } catch (err) { throw err; } }, // Toggle credential active status toggleCredential: (userId, credentialId, isActive) => { try { const stmt = db.prepare('UPDATE user_credentials SET is_active = ? WHERE id = ? AND user_id = ?'); const result = stmt.run(isActive ? 1 : 0, credentialId, userId); return result.changes > 0; } catch (err) { throw err; } } }; const DEFAULT_NOTIFICATION_PREFERENCES = { channels: { inApp: false, webPush: true }, events: { actionRequired: true, stop: true, error: true } }; const normalizeNotificationPreferences = (value) => { const source = value && typeof value === 'object' ? value : {}; return { channels: { inApp: source.channels?.inApp === true, webPush: source.channels?.webPush !== false }, events: { actionRequired: source.events?.actionRequired !== false, stop: source.events?.stop !== false, error: source.events?.error !== false } }; }; const notificationPreferencesDb = { getPreferences: (userId) => { try { const row = db.prepare('SELECT preferences_json FROM user_notification_preferences WHERE user_id = ?').get(userId); if (!row) { const defaults = normalizeNotificationPreferences(DEFAULT_NOTIFICATION_PREFERENCES); db.prepare( 'INSERT INTO user_notification_preferences (user_id, preferences_json, updated_at) VALUES (?, ?, CURRENT_TIMESTAMP)' ).run(userId, JSON.stringify(defaults)); return defaults; } let parsed; try { parsed = JSON.parse(row.preferences_json); } catch { parsed = DEFAULT_NOTIFICATION_PREFERENCES; } return normalizeNotificationPreferences(parsed); } catch (err) { throw err; } }, updatePreferences: (userId, preferences) => { try { const normalized = normalizeNotificationPreferences(preferences); db.prepare( `INSERT INTO user_notification_preferences (user_id, preferences_json, updated_at) VALUES (?, ?, CURRENT_TIMESTAMP) ON CONFLICT(user_id) DO UPDATE SET preferences_json = excluded.preferences_json, updated_at = CURRENT_TIMESTAMP` ).run(userId, JSON.stringify(normalized)); return normalized; } catch (err) { throw err; } } }; const pushSubscriptionsDb = { saveSubscription: (userId, endpoint, keysP256dh, keysAuth) => { try { db.prepare( `INSERT INTO push_subscriptions (user_id, endpoint, keys_p256dh, keys_auth) VALUES (?, ?, ?, ?) ON CONFLICT(endpoint) DO UPDATE SET user_id = excluded.user_id, keys_p256dh = excluded.keys_p256dh, keys_auth = excluded.keys_auth` ).run(userId, endpoint, keysP256dh, keysAuth); } catch (err) { throw err; } }, getSubscriptions: (userId) => { try { return db.prepare('SELECT endpoint, keys_p256dh, keys_auth FROM push_subscriptions WHERE user_id = ?').all(userId); } catch (err) { throw err; } }, removeSubscription: (endpoint) => { try { db.prepare('DELETE FROM push_subscriptions WHERE endpoint = ?').run(endpoint); } catch (err) { throw err; } }, removeAllForUser: (userId) => { try { db.prepare('DELETE FROM push_subscriptions WHERE user_id = ?').run(userId); } catch (err) { throw err; } } }; // Backward compatibility - keep old names pointing to new system const githubTokensDb = { createGithubToken: (userId, tokenName, githubToken, description = null) => { return credentialsDb.createCredential(userId, tokenName, 'github_token', githubToken, description); }, getGithubTokens: (userId) => { return credentialsDb.getCredentials(userId, 'github_token'); }, getActiveGithubToken: (userId) => { return credentialsDb.getActiveCredential(userId, 'github_token'); }, deleteGithubToken: (userId, tokenId) => { return credentialsDb.deleteCredential(userId, tokenId); }, toggleGithubToken: (userId, tokenId, isActive) => { return credentialsDb.toggleCredential(userId, tokenId, isActive); } }; export { db, initializeDatabase, userDb, apiKeysDb, credentialsDb, notificationPreferencesDb, pushSubscriptionsDb, githubTokensDb // Backward compatibility };