db.js 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630
  1. import Database from 'better-sqlite3';
  2. import path from 'path';
  3. import fs from 'fs';
  4. import crypto from 'crypto';
  5. import { fileURLToPath } from 'url';
  6. import { dirname } from 'path';
  7. const __filename = fileURLToPath(import.meta.url);
  8. const __dirname = dirname(__filename);
  9. // ANSI color codes for terminal output
  10. const colors = {
  11. reset: '\x1b[0m',
  12. bright: '\x1b[1m',
  13. cyan: '\x1b[36m',
  14. dim: '\x1b[2m',
  15. };
  16. const c = {
  17. info: (text) => `${colors.cyan}${text}${colors.reset}`,
  18. bright: (text) => `${colors.bright}${text}${colors.reset}`,
  19. dim: (text) => `${colors.dim}${text}${colors.reset}`,
  20. };
  21. // Use DATABASE_PATH environment variable if set, otherwise use default location
  22. const DB_PATH = process.env.DATABASE_PATH || path.join(__dirname, 'auth.db');
  23. const INIT_SQL_PATH = path.join(__dirname, 'init.sql');
  24. // Ensure database directory exists if custom path is provided
  25. if (process.env.DATABASE_PATH) {
  26. const dbDir = path.dirname(DB_PATH);
  27. try {
  28. if (!fs.existsSync(dbDir)) {
  29. fs.mkdirSync(dbDir, { recursive: true });
  30. console.log(`Created database directory: ${dbDir}`);
  31. }
  32. } catch (error) {
  33. console.error(`Failed to create database directory ${dbDir}:`, error.message);
  34. throw error;
  35. }
  36. }
  37. // 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
  38. const LEGACY_DB_PATH = path.join(__dirname, 'auth.db');
  39. if (DB_PATH !== LEGACY_DB_PATH && !fs.existsSync(DB_PATH) && fs.existsSync(LEGACY_DB_PATH)) {
  40. try {
  41. fs.copyFileSync(LEGACY_DB_PATH, DB_PATH);
  42. console.log(`[MIGRATION] Copied database from ${LEGACY_DB_PATH} to ${DB_PATH}`);
  43. for (const suffix of ['-wal', '-shm']) {
  44. if (fs.existsSync(LEGACY_DB_PATH + suffix)) {
  45. fs.copyFileSync(LEGACY_DB_PATH + suffix, DB_PATH + suffix);
  46. }
  47. }
  48. } catch (err) {
  49. console.warn(`[MIGRATION] Could not copy legacy database: ${err.message}`);
  50. }
  51. }
  52. // Create database connection
  53. const db = new Database(DB_PATH);
  54. // app_config must exist before any other module imports (auth.js reads the JWT secret at load time).
  55. // runMigrations() also creates this table, but it runs too late for existing installations
  56. // where auth.js is imported before initializeDatabase() is called.
  57. db.exec(`CREATE TABLE IF NOT EXISTS app_config (
  58. key TEXT PRIMARY KEY,
  59. value TEXT NOT NULL,
  60. created_at DATETIME DEFAULT CURRENT_TIMESTAMP
  61. )`);
  62. // Show app installation path prominently
  63. const appInstallPath = path.join(__dirname, '../..');
  64. console.log('');
  65. console.log(c.dim('═'.repeat(60)));
  66. console.log(`${c.info('[INFO]')} App Installation: ${c.bright(appInstallPath)}`);
  67. console.log(`${c.info('[INFO]')} Database: ${c.dim(path.relative(appInstallPath, DB_PATH))}`);
  68. if (process.env.DATABASE_PATH) {
  69. console.log(` ${c.dim('(Using custom DATABASE_PATH from environment)')}`);
  70. }
  71. console.log(c.dim('═'.repeat(60)));
  72. console.log('');
  73. const runMigrations = () => {
  74. try {
  75. const tableInfo = db.prepare("PRAGMA table_info(users)").all();
  76. const columnNames = tableInfo.map(col => col.name);
  77. if (!columnNames.includes('git_name')) {
  78. console.log('Running migration: Adding git_name column');
  79. db.exec('ALTER TABLE users ADD COLUMN git_name TEXT');
  80. }
  81. if (!columnNames.includes('git_email')) {
  82. console.log('Running migration: Adding git_email column');
  83. db.exec('ALTER TABLE users ADD COLUMN git_email TEXT');
  84. }
  85. if (!columnNames.includes('has_completed_onboarding')) {
  86. console.log('Running migration: Adding has_completed_onboarding column');
  87. db.exec('ALTER TABLE users ADD COLUMN has_completed_onboarding BOOLEAN DEFAULT 0');
  88. }
  89. db.exec(`
  90. CREATE TABLE IF NOT EXISTS user_notification_preferences (
  91. user_id INTEGER PRIMARY KEY,
  92. preferences_json TEXT NOT NULL,
  93. updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
  94. FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
  95. )
  96. `);
  97. db.exec(`
  98. CREATE TABLE IF NOT EXISTS vapid_keys (
  99. id INTEGER PRIMARY KEY AUTOINCREMENT,
  100. public_key TEXT NOT NULL,
  101. private_key TEXT NOT NULL,
  102. created_at DATETIME DEFAULT CURRENT_TIMESTAMP
  103. )
  104. `);
  105. db.exec(`
  106. CREATE TABLE IF NOT EXISTS push_subscriptions (
  107. id INTEGER PRIMARY KEY AUTOINCREMENT,
  108. user_id INTEGER NOT NULL,
  109. endpoint TEXT NOT NULL UNIQUE,
  110. keys_p256dh TEXT NOT NULL,
  111. keys_auth TEXT NOT NULL,
  112. created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
  113. FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
  114. )
  115. `);
  116. // Create app_config table if it doesn't exist (for existing installations)
  117. db.exec(`CREATE TABLE IF NOT EXISTS app_config (
  118. key TEXT PRIMARY KEY,
  119. value TEXT NOT NULL,
  120. created_at DATETIME DEFAULT CURRENT_TIMESTAMP
  121. )`);
  122. // Create session_names table if it doesn't exist (for existing installations)
  123. db.exec(`CREATE TABLE IF NOT EXISTS session_names (
  124. id INTEGER PRIMARY KEY AUTOINCREMENT,
  125. session_id TEXT NOT NULL,
  126. provider TEXT NOT NULL DEFAULT 'claude',
  127. custom_name TEXT NOT NULL,
  128. created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
  129. updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
  130. UNIQUE(session_id, provider)
  131. )`);
  132. db.exec('CREATE INDEX IF NOT EXISTS idx_session_names_lookup ON session_names(session_id, provider)');
  133. console.log('Database migrations completed successfully');
  134. } catch (error) {
  135. console.error('Error running migrations:', error.message);
  136. throw error;
  137. }
  138. };
  139. // Initialize database with schema
  140. const initializeDatabase = async () => {
  141. try {
  142. const initSQL = fs.readFileSync(INIT_SQL_PATH, 'utf8');
  143. db.exec(initSQL);
  144. console.log('Database initialized successfully');
  145. runMigrations();
  146. } catch (error) {
  147. console.error('Error initializing database:', error.message);
  148. throw error;
  149. }
  150. };
  151. // User database operations
  152. const userDb = {
  153. // Check if any users exist
  154. hasUsers: () => {
  155. try {
  156. const row = db.prepare('SELECT COUNT(*) as count FROM users').get();
  157. return row.count > 0;
  158. } catch (err) {
  159. throw err;
  160. }
  161. },
  162. // Create a new user
  163. createUser: (username, passwordHash) => {
  164. try {
  165. const stmt = db.prepare('INSERT INTO users (username, password_hash) VALUES (?, ?)');
  166. const result = stmt.run(username, passwordHash);
  167. return { id: result.lastInsertRowid, username };
  168. } catch (err) {
  169. throw err;
  170. }
  171. },
  172. // Get user by username
  173. getUserByUsername: (username) => {
  174. try {
  175. const row = db.prepare('SELECT * FROM users WHERE username = ? AND is_active = 1').get(username);
  176. return row;
  177. } catch (err) {
  178. throw err;
  179. }
  180. },
  181. // Update last login time (non-fatal — logged but not thrown)
  182. updateLastLogin: (userId) => {
  183. try {
  184. db.prepare('UPDATE users SET last_login = CURRENT_TIMESTAMP WHERE id = ?').run(userId);
  185. } catch (err) {
  186. console.warn('Failed to update last login:', err.message);
  187. }
  188. },
  189. // Get user by ID
  190. getUserById: (userId) => {
  191. try {
  192. const row = db.prepare('SELECT id, username, created_at, last_login FROM users WHERE id = ? AND is_active = 1').get(userId);
  193. return row;
  194. } catch (err) {
  195. throw err;
  196. }
  197. },
  198. getFirstUser: () => {
  199. try {
  200. const row = db.prepare('SELECT id, username, created_at, last_login FROM users WHERE is_active = 1 LIMIT 1').get();
  201. return row;
  202. } catch (err) {
  203. throw err;
  204. }
  205. },
  206. updateGitConfig: (userId, gitName, gitEmail) => {
  207. try {
  208. const stmt = db.prepare('UPDATE users SET git_name = ?, git_email = ? WHERE id = ?');
  209. stmt.run(gitName, gitEmail, userId);
  210. } catch (err) {
  211. throw err;
  212. }
  213. },
  214. getGitConfig: (userId) => {
  215. try {
  216. const row = db.prepare('SELECT git_name, git_email FROM users WHERE id = ?').get(userId);
  217. return row;
  218. } catch (err) {
  219. throw err;
  220. }
  221. },
  222. completeOnboarding: (userId) => {
  223. try {
  224. const stmt = db.prepare('UPDATE users SET has_completed_onboarding = 1 WHERE id = ?');
  225. stmt.run(userId);
  226. } catch (err) {
  227. throw err;
  228. }
  229. },
  230. hasCompletedOnboarding: (userId) => {
  231. try {
  232. const row = db.prepare('SELECT has_completed_onboarding FROM users WHERE id = ?').get(userId);
  233. return row?.has_completed_onboarding === 1;
  234. } catch (err) {
  235. throw err;
  236. }
  237. }
  238. };
  239. // API Keys database operations
  240. const apiKeysDb = {
  241. // Generate a new API key
  242. generateApiKey: () => {
  243. return 'ck_' + crypto.randomBytes(32).toString('hex');
  244. },
  245. // Create a new API key
  246. createApiKey: (userId, keyName) => {
  247. try {
  248. const apiKey = apiKeysDb.generateApiKey();
  249. const stmt = db.prepare('INSERT INTO api_keys (user_id, key_name, api_key) VALUES (?, ?, ?)');
  250. const result = stmt.run(userId, keyName, apiKey);
  251. return { id: result.lastInsertRowid, keyName, apiKey };
  252. } catch (err) {
  253. throw err;
  254. }
  255. },
  256. // Get all API keys for a user
  257. getApiKeys: (userId) => {
  258. try {
  259. 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);
  260. return rows;
  261. } catch (err) {
  262. throw err;
  263. }
  264. },
  265. // Validate API key and get user
  266. validateApiKey: (apiKey) => {
  267. try {
  268. const row = db.prepare(`
  269. SELECT u.id, u.username, ak.id as api_key_id
  270. FROM api_keys ak
  271. JOIN users u ON ak.user_id = u.id
  272. WHERE ak.api_key = ? AND ak.is_active = 1 AND u.is_active = 1
  273. `).get(apiKey);
  274. if (row) {
  275. // Update last_used timestamp
  276. db.prepare('UPDATE api_keys SET last_used = CURRENT_TIMESTAMP WHERE id = ?').run(row.api_key_id);
  277. }
  278. return row;
  279. } catch (err) {
  280. throw err;
  281. }
  282. },
  283. // Delete an API key
  284. deleteApiKey: (userId, apiKeyId) => {
  285. try {
  286. const stmt = db.prepare('DELETE FROM api_keys WHERE id = ? AND user_id = ?');
  287. const result = stmt.run(apiKeyId, userId);
  288. return result.changes > 0;
  289. } catch (err) {
  290. throw err;
  291. }
  292. },
  293. // Toggle API key active status
  294. toggleApiKey: (userId, apiKeyId, isActive) => {
  295. try {
  296. const stmt = db.prepare('UPDATE api_keys SET is_active = ? WHERE id = ? AND user_id = ?');
  297. const result = stmt.run(isActive ? 1 : 0, apiKeyId, userId);
  298. return result.changes > 0;
  299. } catch (err) {
  300. throw err;
  301. }
  302. }
  303. };
  304. // User credentials database operations (for GitHub tokens, GitLab tokens, etc.)
  305. const credentialsDb = {
  306. // Create a new credential
  307. createCredential: (userId, credentialName, credentialType, credentialValue, description = null) => {
  308. try {
  309. const stmt = db.prepare('INSERT INTO user_credentials (user_id, credential_name, credential_type, credential_value, description) VALUES (?, ?, ?, ?, ?)');
  310. const result = stmt.run(userId, credentialName, credentialType, credentialValue, description);
  311. return { id: result.lastInsertRowid, credentialName, credentialType };
  312. } catch (err) {
  313. throw err;
  314. }
  315. },
  316. // Get all credentials for a user, optionally filtered by type
  317. getCredentials: (userId, credentialType = null) => {
  318. try {
  319. let query = 'SELECT id, credential_name, credential_type, description, created_at, is_active FROM user_credentials WHERE user_id = ?';
  320. const params = [userId];
  321. if (credentialType) {
  322. query += ' AND credential_type = ?';
  323. params.push(credentialType);
  324. }
  325. query += ' ORDER BY created_at DESC';
  326. const rows = db.prepare(query).all(...params);
  327. return rows;
  328. } catch (err) {
  329. throw err;
  330. }
  331. },
  332. // Get active credential value for a user by type (returns most recent active)
  333. getActiveCredential: (userId, credentialType) => {
  334. try {
  335. 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);
  336. return row?.credential_value || null;
  337. } catch (err) {
  338. throw err;
  339. }
  340. },
  341. // Delete a credential
  342. deleteCredential: (userId, credentialId) => {
  343. try {
  344. const stmt = db.prepare('DELETE FROM user_credentials WHERE id = ? AND user_id = ?');
  345. const result = stmt.run(credentialId, userId);
  346. return result.changes > 0;
  347. } catch (err) {
  348. throw err;
  349. }
  350. },
  351. // Toggle credential active status
  352. toggleCredential: (userId, credentialId, isActive) => {
  353. try {
  354. const stmt = db.prepare('UPDATE user_credentials SET is_active = ? WHERE id = ? AND user_id = ?');
  355. const result = stmt.run(isActive ? 1 : 0, credentialId, userId);
  356. return result.changes > 0;
  357. } catch (err) {
  358. throw err;
  359. }
  360. }
  361. };
  362. const DEFAULT_NOTIFICATION_PREFERENCES = {
  363. channels: {
  364. inApp: false,
  365. webPush: false
  366. },
  367. events: {
  368. actionRequired: true,
  369. stop: true,
  370. error: true
  371. }
  372. };
  373. const normalizeNotificationPreferences = (value) => {
  374. const source = value && typeof value === 'object' ? value : {};
  375. return {
  376. channels: {
  377. inApp: source.channels?.inApp === true,
  378. webPush: source.channels?.webPush === true
  379. },
  380. events: {
  381. actionRequired: source.events?.actionRequired !== false,
  382. stop: source.events?.stop !== false,
  383. error: source.events?.error !== false
  384. }
  385. };
  386. };
  387. const notificationPreferencesDb = {
  388. getPreferences: (userId) => {
  389. try {
  390. const row = db.prepare('SELECT preferences_json FROM user_notification_preferences WHERE user_id = ?').get(userId);
  391. if (!row) {
  392. const defaults = normalizeNotificationPreferences(DEFAULT_NOTIFICATION_PREFERENCES);
  393. db.prepare(
  394. 'INSERT INTO user_notification_preferences (user_id, preferences_json, updated_at) VALUES (?, ?, CURRENT_TIMESTAMP)'
  395. ).run(userId, JSON.stringify(defaults));
  396. return defaults;
  397. }
  398. let parsed;
  399. try {
  400. parsed = JSON.parse(row.preferences_json);
  401. } catch {
  402. parsed = DEFAULT_NOTIFICATION_PREFERENCES;
  403. }
  404. return normalizeNotificationPreferences(parsed);
  405. } catch (err) {
  406. throw err;
  407. }
  408. },
  409. updatePreferences: (userId, preferences) => {
  410. try {
  411. const normalized = normalizeNotificationPreferences(preferences);
  412. db.prepare(
  413. `INSERT INTO user_notification_preferences (user_id, preferences_json, updated_at)
  414. VALUES (?, ?, CURRENT_TIMESTAMP)
  415. ON CONFLICT(user_id) DO UPDATE SET
  416. preferences_json = excluded.preferences_json,
  417. updated_at = CURRENT_TIMESTAMP`
  418. ).run(userId, JSON.stringify(normalized));
  419. return normalized;
  420. } catch (err) {
  421. throw err;
  422. }
  423. }
  424. };
  425. const pushSubscriptionsDb = {
  426. saveSubscription: (userId, endpoint, keysP256dh, keysAuth) => {
  427. try {
  428. db.prepare(
  429. `INSERT INTO push_subscriptions (user_id, endpoint, keys_p256dh, keys_auth)
  430. VALUES (?, ?, ?, ?)
  431. ON CONFLICT(endpoint) DO UPDATE SET
  432. user_id = excluded.user_id,
  433. keys_p256dh = excluded.keys_p256dh,
  434. keys_auth = excluded.keys_auth`
  435. ).run(userId, endpoint, keysP256dh, keysAuth);
  436. } catch (err) {
  437. throw err;
  438. }
  439. },
  440. getSubscriptions: (userId) => {
  441. try {
  442. return db.prepare('SELECT endpoint, keys_p256dh, keys_auth FROM push_subscriptions WHERE user_id = ?').all(userId);
  443. } catch (err) {
  444. throw err;
  445. }
  446. },
  447. removeSubscription: (endpoint) => {
  448. try {
  449. db.prepare('DELETE FROM push_subscriptions WHERE endpoint = ?').run(endpoint);
  450. } catch (err) {
  451. throw err;
  452. }
  453. },
  454. removeAllForUser: (userId) => {
  455. try {
  456. db.prepare('DELETE FROM push_subscriptions WHERE user_id = ?').run(userId);
  457. } catch (err) {
  458. throw err;
  459. }
  460. }
  461. };
  462. // Session custom names database operations
  463. const sessionNamesDb = {
  464. // Set (insert or update) a custom session name
  465. setName: (sessionId, provider, customName) => {
  466. db.prepare(`
  467. INSERT INTO session_names (session_id, provider, custom_name)
  468. VALUES (?, ?, ?)
  469. ON CONFLICT(session_id, provider)
  470. DO UPDATE SET custom_name = excluded.custom_name, updated_at = CURRENT_TIMESTAMP
  471. `).run(sessionId, provider, customName);
  472. },
  473. // Get a single custom session name
  474. getName: (sessionId, provider) => {
  475. const row = db.prepare(
  476. 'SELECT custom_name FROM session_names WHERE session_id = ? AND provider = ?'
  477. ).get(sessionId, provider);
  478. return row?.custom_name || null;
  479. },
  480. // Batch lookup — returns Map<sessionId, customName>
  481. getNames: (sessionIds, provider) => {
  482. if (!sessionIds.length) return new Map();
  483. const placeholders = sessionIds.map(() => '?').join(',');
  484. const rows = db.prepare(
  485. `SELECT session_id, custom_name FROM session_names
  486. WHERE session_id IN (${placeholders}) AND provider = ?`
  487. ).all(...sessionIds, provider);
  488. return new Map(rows.map(r => [r.session_id, r.custom_name]));
  489. },
  490. // Delete a custom session name
  491. deleteName: (sessionId, provider) => {
  492. return db.prepare(
  493. 'DELETE FROM session_names WHERE session_id = ? AND provider = ?'
  494. ).run(sessionId, provider).changes > 0;
  495. },
  496. };
  497. // Apply custom session names from the database (overrides CLI-generated summaries)
  498. function applyCustomSessionNames(sessions, provider) {
  499. if (!sessions?.length) return;
  500. try {
  501. const ids = sessions.map(s => s.id);
  502. const customNames = sessionNamesDb.getNames(ids, provider);
  503. for (const session of sessions) {
  504. const custom = customNames.get(session.id);
  505. if (custom) session.summary = custom;
  506. }
  507. } catch (error) {
  508. console.warn(`[DB] Failed to apply custom session names for ${provider}:`, error.message);
  509. }
  510. }
  511. // App config database operations
  512. const appConfigDb = {
  513. get: (key) => {
  514. try {
  515. const row = db.prepare('SELECT value FROM app_config WHERE key = ?').get(key);
  516. return row?.value || null;
  517. } catch (err) {
  518. return null;
  519. }
  520. },
  521. set: (key, value) => {
  522. db.prepare(
  523. 'INSERT INTO app_config (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value'
  524. ).run(key, value);
  525. },
  526. getOrCreateJwtSecret: () => {
  527. let secret = appConfigDb.get('jwt_secret');
  528. if (!secret) {
  529. secret = crypto.randomBytes(64).toString('hex');
  530. appConfigDb.set('jwt_secret', secret);
  531. }
  532. return secret;
  533. }
  534. };
  535. // Backward compatibility - keep old names pointing to new system
  536. const githubTokensDb = {
  537. createGithubToken: (userId, tokenName, githubToken, description = null) => {
  538. return credentialsDb.createCredential(userId, tokenName, 'github_token', githubToken, description);
  539. },
  540. getGithubTokens: (userId) => {
  541. return credentialsDb.getCredentials(userId, 'github_token');
  542. },
  543. getActiveGithubToken: (userId) => {
  544. return credentialsDb.getActiveCredential(userId, 'github_token');
  545. },
  546. deleteGithubToken: (userId, tokenId) => {
  547. return credentialsDb.deleteCredential(userId, tokenId);
  548. },
  549. toggleGithubToken: (userId, tokenId, isActive) => {
  550. return credentialsDb.toggleCredential(userId, tokenId, isActive);
  551. }
  552. };
  553. export {
  554. db,
  555. initializeDatabase,
  556. userDb,
  557. apiKeysDb,
  558. credentialsDb,
  559. notificationPreferencesDb,
  560. pushSubscriptionsDb,
  561. sessionNamesDb,
  562. applyCustomSessionNames,
  563. appConfigDb,
  564. githubTokensDb // Backward compatibility
  565. };