const Database = require('better-sqlite3'); const path = require('path'); const fs = require('fs'); const DB_PATH = process.env.DB_PATH || path.join(__dirname, 'bills.db'); const SCHEMA_PATH = path.join(__dirname, 'schema.sql'); const DEFAULT_CATEGORIES = [ 'Housing', 'Utilities', 'Credit Cards', 'Loans', 'Insurance', 'Subscriptions', 'Phone & Internet', 'Transportation', 'Medical', 'Other', ]; // ── SQL Whitelist Mappings ──────────────────────────────────────────────────── // Security FIX (2026-05-08): Whitelist all allowed column names to prevent SQL injection // in migrations that use dynamic ALTER TABLE statements. const COLUMN_WHITELIST = new Set([ // users table columns 'active', 'is_default_admin', 'notification_email', 'notifications_enabled', 'notify_3d', 'notify_1d', 'notify_due', 'notify_overdue', 'display_name', 'last_password_change_at', 'auth_provider', 'external_subject', 'email', 'last_login_at', // payments table columns 'deleted_at', // monthly_starting_amounts table columns 'other_amount', // bills table columns 'history_visibility', 'interest_rate', 'user_id', ]); // Security validation function for column names function isValidColumnName(col) { if (!col || typeof col !== 'string') return false; // Must be in whitelist AND match valid SQL identifier pattern return COLUMN_WHITELIST.has(col) && /^[a-z0-9_]+$/i.test(col); } // Security validation function for SQL definition fragments function isValidSqlDefinition(def) { if (!def || typeof def !== 'string') return false; // Allow standard column definitions but reject any user input // This is safe because all definitions are hardcoded here return /^[\w\s\(\)\',!@#$%^&*+=\[\]<>\-.]+$/i.test(def); } fs.mkdirSync(path.dirname(DB_PATH), { recursive: true }); let db = null; let initializing = false; function assertWritableDbPath() { const dir = path.dirname(DB_PATH); const probe = path.join(dir, `.write-test-${process.pid}-${Date.now()}`); try { fs.mkdirSync(dir, { recursive: true }); fs.writeFileSync(probe, 'ok'); fs.unlinkSync(probe); if (fs.existsSync(DB_PATH)) { fs.accessSync(DB_PATH, fs.constants.R_OK | fs.constants.W_OK); } } catch (err) { const message = [ `Database path is not writable: ${DB_PATH}`, `Ensure the DB directory is writable by the app user. In Docker, rebuild the image and recreate the container so the entrypoint can chown /data.`, `Original error: ${err.message}`, ].join('\n'); const wrapped = new Error(message); wrapped.code = err.code; throw wrapped; } finally { try { if (fs.existsSync(probe)) fs.unlinkSync(probe); } catch {} } } function sleep(ms) { Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms); } function getDb() { // already ready if (db) return db; // wait if another init is happening while (initializing) { sleep(50); } // check again after wait if (db) return db; initializing = true; try { console.log('Opening DB at:', DB_PATH); assertWritableDbPath(); db = new Database(DB_PATH, { timeout: 5000 }); db.pragma('busy_timeout = 5000'); try { db.pragma('journal_mode = WAL'); } catch (e) { console.warn('WAL failed:', e.message); } db.pragma('foreign_keys = ON'); initSchema(); seedDefaults(); console.log('DB initialized successfully'); return db; } catch (err) { console.error('DB init failed:', err); throw err; } finally { initializing = false; } } function initSchema() { const schema = fs.readFileSync(SCHEMA_PATH, 'utf8'); db.exec(schema); // Create schema_migrations table for tracking applied migrations db.exec(` CREATE TABLE IF NOT EXISTS schema_migrations ( id INTEGER PRIMARY KEY AUTOINCREMENT, version TEXT NOT NULL UNIQUE, description TEXT NOT NULL, applied_at TEXT NOT NULL DEFAULT (datetime('now')) ) `); runMigrations(); } function hasMigrationBeenApplied(version) { const stmt = db.prepare('SELECT 1 FROM schema_migrations WHERE version = ?'); return !!stmt.get(version); } function recordMigration(version, description) { const stmt = db.prepare('INSERT INTO schema_migrations (version, description) VALUES (?, ?)'); stmt.run(version, description); console.log(`[migration] Applied ${version}: ${description}`); } function runMigrations() { // Define all migrations with explicit version tracking const migrations = [ { version: 'v0.2', description: 'payments: soft-delete column', run: function() { const paymentCols = db.prepare('PRAGMA table_info(payments)').all().map(c => c.name); if (!paymentCols.includes('deleted_at')) { db.exec('ALTER TABLE payments ADD COLUMN deleted_at TEXT'); // Index for fast filtering of live payments db.exec('CREATE INDEX IF NOT EXISTS idx_payments_deleted ON payments(deleted_at)'); console.log('[migration] payments.deleted_at column added'); } } }, { version: 'v0.3', description: 'payments: compound index for tracker query', run: function() { // Supports: WHERE bill_id = ? AND paid_date BETWEEN ? AND ? AND deleted_at IS NULL db.exec('CREATE INDEX IF NOT EXISTS idx_payments_bill_date_del ON payments(bill_id, paid_date, deleted_at)'); } }, { version: 'v0.4', description: 'monthly_bill_state: per-bill per-month overrides', run: function() { db.exec(` CREATE TABLE IF NOT EXISTS monthly_bill_state ( id INTEGER PRIMARY KEY AUTOINCREMENT, bill_id INTEGER NOT NULL REFERENCES bills(id) ON DELETE CASCADE, year INTEGER NOT NULL CHECK(year BETWEEN 2000 AND 2100), month INTEGER NOT NULL CHECK(month BETWEEN 1 AND 12), actual_amount REAL, notes TEXT, is_skipped INTEGER NOT NULL DEFAULT 0, created_at TEXT DEFAULT (datetime('now')), updated_at TEXT DEFAULT (datetime('now')), UNIQUE(bill_id, year, month) ) `); db.exec('CREATE INDEX IF NOT EXISTS idx_monthly_bill_state_lookup ON monthly_bill_state(bill_id, year, month)'); console.log('[migration] monthly_bill_state table ensured'); } }, { version: 'v0.13', description: 'users: profile columns', run: function() { const userColsNow = db.prepare('PRAGMA table_info(users)').all().map(c => c.name); const profileCols = [ ['display_name', 'TEXT'], ['last_password_change_at','TEXT'], ]; for (const [col, def] of profileCols) { if (!userColsNow.includes(col)) { // Security FIX (2026-05-08): Validate column name and definition to prevent SQL injection if (!isValidColumnName(col) || !isValidSqlDefinition(def)) { throw new Error(`Invalid migration: column '${col}' not in whitelist`); } db.exec(`ALTER TABLE users ADD COLUMN ${col} ${def}`); } } } }, { version: 'v0.14', description: 'bills: history visibility mode', run: function() { const billColsHist = db.prepare('PRAGMA table_info(bills)').all().map(c => c.name); if (!billColsHist.includes('history_visibility')) { db.exec("ALTER TABLE bills ADD COLUMN history_visibility TEXT NOT NULL DEFAULT 'default'"); console.log('[migration] bills.history_visibility column added'); } } }, { version: 'v0.14.4', description: 'bills: optional credit-card APR / interest rate', run: function() { const billColsInterest = db.prepare('PRAGMA table_info(bills)').all().map(c => c.name); if (!billColsInterest.includes('interest_rate')) { db.exec('ALTER TABLE bills ADD COLUMN interest_rate REAL'); console.log('[migration] bills.interest_rate column added'); } } }, { version: 'v0.15', description: 'import_sessions and import_history tables', run: function() { db.exec(` CREATE TABLE IF NOT EXISTS import_sessions ( id TEXT PRIMARY KEY, user_id INTEGER NOT NULL, created_at TEXT NOT NULL, expires_at TEXT NOT NULL, preview_json TEXT NOT NULL ) `); db.exec('CREATE INDEX IF NOT EXISTS idx_import_sessions_user ON import_sessions(user_id)'); db.exec('CREATE INDEX IF NOT EXISTS idx_import_sessions_expires ON import_sessions(expires_at)'); // ── import_history: per-user audit log (v0.38) ──────────────────────────── db.exec(` CREATE TABLE IF NOT EXISTS import_history ( id INTEGER PRIMARY KEY AUTOINCREMENT, user_id INTEGER NOT NULL, imported_at TEXT NOT NULL, source_filename TEXT, file_type TEXT DEFAULT 'xlsx', sheet_name TEXT, rows_parsed INTEGER DEFAULT 0, rows_created INTEGER DEFAULT 0, rows_updated INTEGER DEFAULT 0, rows_skipped INTEGER DEFAULT 0, rows_ambiguous INTEGER DEFAULT 0, rows_errored INTEGER DEFAULT 0, options_json TEXT, summary_json TEXT ) `); db.exec('CREATE INDEX IF NOT EXISTS idx_import_history_user ON import_history(user_id)'); } }, { version: 'v0.17', description: 'users: external identity / OIDC columns', run: function() { const userColsOidc = db.prepare('PRAGMA table_info(users)').all().map(c => c.name); const oidcUserCols = [ ['auth_provider', "TEXT NOT NULL DEFAULT 'local'"], ['external_subject', 'TEXT'], ['email', 'TEXT'], ['last_login_at', 'TEXT'], ]; for (const [col, def] of oidcUserCols) { if (!userColsOidc.includes(col)) { // Security FIX (2026-05-08): Validate column name and definition to prevent SQL injection if (!isValidColumnName(col) || !isValidSqlDefinition(def)) { throw new Error(`Invalid migration: column '${col}' not in whitelist`); } db.exec(`ALTER TABLE users ADD COLUMN ${col} ${def}`); } } // ── oidc_states: short-lived PKCE + nonce state for OIDC login (v0.17) ─── db.exec(` CREATE TABLE IF NOT EXISTS oidc_states ( id TEXT PRIMARY KEY, nonce TEXT NOT NULL, code_verifier TEXT NOT NULL, redirect_to TEXT, created_at TEXT NOT NULL, expires_at TEXT NOT NULL ) `); db.exec('CREATE INDEX IF NOT EXISTS idx_oidc_states_expires ON oidc_states(expires_at)'); } }, { version: 'v0.18.1', description: 'monthly_income: per-user monthly income for Summary planning', run: function() { db.exec(` CREATE TABLE IF NOT EXISTS monthly_income ( id INTEGER PRIMARY KEY AUTOINCREMENT, user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, year INTEGER NOT NULL CHECK(year BETWEEN 2000 AND 2100), month INTEGER NOT NULL CHECK(month BETWEEN 1 AND 12), label TEXT NOT NULL DEFAULT 'Salary', amount REAL NOT NULL DEFAULT 0, created_at TEXT DEFAULT (datetime('now')), updated_at TEXT DEFAULT (datetime('now')), UNIQUE(user_id, year, month) ) `); db.exec('CREATE INDEX IF NOT EXISTS idx_monthly_income_user_month ON monthly_income(user_id, year, month)'); } }, { version: 'v0.18.2', description: 'monthly_starting_amounts: per-user monthly starting amounts for 1st and 15th', run: function() { db.exec(` CREATE TABLE IF NOT EXISTS monthly_starting_amounts ( id INTEGER PRIMARY KEY AUTOINCREMENT, user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, year INTEGER NOT NULL CHECK(year BETWEEN 2000 AND 2100), month INTEGER NOT NULL CHECK(month BETWEEN 1 AND 12), first_amount REAL NOT NULL DEFAULT 0 CHECK(first_amount >= 0), fifteenth_amount REAL NOT NULL DEFAULT 0 CHECK(fifteenth_amount >= 0), other_amount REAL NOT NULL DEFAULT 0 CHECK(other_amount >= 0), notes TEXT, created_at TEXT DEFAULT (datetime('now')), updated_at TEXT DEFAULT (datetime('now')), UNIQUE(user_id, year, month) ) `); db.exec('CREATE INDEX IF NOT EXISTS idx_monthly_starting_amounts_user_month ON monthly_starting_amounts(user_id, year, month)'); } }, { version: 'v0.18.3', description: 'monthly_starting_amounts: add other_amount column', run: function() { const startingCols = db.prepare('PRAGMA table_info(monthly_starting_amounts)').all().map(c => c.name); if (!startingCols.includes('other_amount')) { // Security FIX (2026-05-08): Validate column name to prevent SQL injection if (!isValidColumnName('other_amount')) { throw new Error('Invalid migration: column other_amount not in whitelist'); } db.exec('ALTER TABLE monthly_starting_amounts ADD COLUMN other_amount REAL NOT NULL DEFAULT 0 CHECK(other_amount >= 0)'); console.log('[migration] monthly_starting_amounts.other_amount column added'); } } }, { version: 'v0.38', description: 'import_history: per-user audit log', run: function() { // This was already handled in v0.15, but keeping for completeness } }, { version: 'v0.40', description: 'ownership: user-scoped bills/categories', run: function() { const billCols = db.prepare('PRAGMA table_info(bills)').all().map(c => c.name); if (!billCols.includes('user_id')) { db.exec('ALTER TABLE bills ADD COLUMN user_id INTEGER REFERENCES users(id) ON DELETE CASCADE'); } const categoryCols = db.prepare('PRAGMA table_info(categories)').all().map(c => c.name); if (!categoryCols.includes('user_id')) { db.exec('ALTER TABLE categories ADD COLUMN user_id INTEGER REFERENCES users(id) ON DELETE CASCADE'); } const categorySql = db.prepare("SELECT sql FROM sqlite_master WHERE type='table' AND name='categories'").get()?.sql || ''; if (/name\s+TEXT\s+NOT\s+NULL\s+UNIQUE/i.test(categorySql)) { db.exec('PRAGMA foreign_keys = OFF'); db.exec(` CREATE TABLE IF NOT EXISTS categories_v040 ( id INTEGER PRIMARY KEY AUTOINCREMENT, user_id INTEGER REFERENCES users(id) ON DELETE CASCADE, name TEXT NOT NULL, created_at TEXT DEFAULT (datetime('now')), updated_at TEXT DEFAULT (datetime('now')) ) `); db.exec('INSERT INTO categories_v040 (id, user_id, name, created_at, updated_at) SELECT id, user_id, name, created_at, updated_at FROM categories'); db.exec('DROP TABLE categories'); db.exec('ALTER TABLE categories_v040 RENAME TO categories'); db.exec('PRAGMA foreign_keys = ON'); } const firstUser = db.prepare("SELECT id FROM users WHERE role = 'user' ORDER BY id LIMIT 1").get(); if (firstUser) { db.prepare('UPDATE bills SET user_id = ? WHERE user_id IS NULL').run(firstUser.id); // Drop any NULL-owner categories whose name already exists for this user (case-insensitive) // to prevent a UNIQUE(user_id, name) violation when we assign them below. db.prepare(` DELETE FROM categories WHERE user_id IS NULL AND LOWER(name) IN ( SELECT LOWER(name) FROM categories WHERE user_id = ? ) `).run(firstUser.id); db.prepare('UPDATE categories SET user_id = ? WHERE user_id IS NULL').run(firstUser.id); } db.exec('CREATE INDEX IF NOT EXISTS idx_bills_user_active ON bills(user_id, active)'); db.exec('CREATE INDEX IF NOT EXISTS idx_categories_user_name ON categories(user_id, name)'); db.exec('CREATE UNIQUE INDEX IF NOT EXISTS idx_categories_user_name_unique ON categories(user_id, name COLLATE NOCASE)'); } }, { version: 'v0.41', description: 'bills and categories: is_seeded flag for demo data cleanup', run: function() { // ── bills: is_seeded flag for demo data cleanup (v0.41) ─────────────────── const billColsSeeded = db.prepare('PRAGMA table_info(bills)').all().map(c => c.name); if (!billColsSeeded.includes('is_seeded')) { db.exec('ALTER TABLE bills ADD COLUMN is_seeded INTEGER NOT NULL DEFAULT 0'); console.log('[migration] bills.is_seeded column added'); } // ── categories: is_seeded flag for demo data cleanup (v0.41) ────────────── const categoryColsSeeded = db.prepare('PRAGMA table_info(categories)').all().map(c => c.name); if (!categoryColsSeeded.includes('is_seeded')) { db.exec('ALTER TABLE categories ADD COLUMN is_seeded INTEGER NOT NULL DEFAULT 0'); console.log('[migration] categories.is_seeded column added'); } } }, { version: 'v0.42', description: 'bill_history_ranges: per-bill date ranges for history visibility', run: function() { db.exec(` CREATE TABLE IF NOT EXISTS bill_history_ranges ( id INTEGER PRIMARY KEY AUTOINCREMENT, bill_id INTEGER NOT NULL REFERENCES bills(id) ON DELETE CASCADE, start_year INTEGER NOT NULL, start_month INTEGER NOT NULL, end_year INTEGER, end_month INTEGER, label TEXT, created_at TEXT DEFAULT (datetime('now')), updated_at TEXT DEFAULT (datetime('now')) ) `); db.exec('CREATE INDEX IF NOT EXISTS idx_bill_history_ranges_bill ON bill_history_ranges(bill_id)'); } } ]; // ── users: notification columns ─────────────────────────────────────────── // This migration needs to run first since it's not versioned in the schema const userCols = db.prepare('PRAGMA table_info(users)').all().map(c => c.name); const newUserCols = [ ['active', 'INTEGER NOT NULL DEFAULT 1'], ['is_default_admin', 'INTEGER NOT NULL DEFAULT 0'], ['notification_email', 'TEXT'], ['notifications_enabled', 'INTEGER NOT NULL DEFAULT 0'], ['notify_3d', 'INTEGER NOT NULL DEFAULT 1'], ['notify_1d', 'INTEGER NOT NULL DEFAULT 1'], ['notify_due', 'INTEGER NOT NULL DEFAULT 1'], ['notify_overdue', 'INTEGER NOT NULL DEFAULT 1'], ]; for (const [col, def] of newUserCols) { if (!userCols.includes(col)) { // Security FIX (2026-05-08): Validate column name and definition to prevent SQL injection if (!isValidColumnName(col) || !isValidSqlDefinition(def)) { throw new Error(`Invalid migration: column '${col}' not in whitelist`); } db.exec(`ALTER TABLE users ADD COLUMN ${col} ${def}`); } } const defaultAdminName = process.env.INIT_ADMIN_USER || 'admin'; db.prepare(` UPDATE users SET is_default_admin = 1 WHERE role = 'admin' AND username = ? AND NOT EXISTS (SELECT 1 FROM users WHERE is_default_admin = 1) `).run(defaultAdminName); db.exec(` UPDATE users SET is_default_admin = 1 WHERE id = ( SELECT id FROM users WHERE role = 'admin' ORDER BY id LIMIT 1 ) AND NOT EXISTS (SELECT 1 FROM users WHERE is_default_admin = 1) `); // Process all versioned migrations for (const migration of migrations) { if (!hasMigrationBeenApplied(migration.version)) { console.log(`[migration] Applying ${migration.version}: ${migration.description}`); try { migration.run(); recordMigration(migration.version, migration.description); } catch (err) { console.error(`[migration-error] Failed to apply ${migration.version}: ${err.message}`); throw err; } } else { console.log(`[migration] Skipping already applied ${migration.version}: ${migration.description}`); } } // All migrations are now versioned } function seedDefaults() { const defaults = [ ['currency', 'USD'], ['date_format', 'MM/DD/YYYY'], ['grace_period_days', '5'], ['notify_days_before', '3'], ['backup_enabled', 'false'], ['backup_frequency_days', '1'], ['backup_keep_count', '14'], ['backup_path', process.env.BACKUP_PATH || path.join(__dirname, '..', 'backups')], ['backup_schedule_enabled', 'false'], ['backup_schedule_frequency', 'daily'], ['backup_schedule_time', '02:00'], ['backup_schedule_retention_count', '14'], ['backup_schedule_last_run_at', ''], ['backup_schedule_last_error', ''], ['auth_mode', 'multi'], ['default_user_id', ''], ['notify_smtp_enabled', 'false'], ['notify_sender_name', 'Bill Tracker'], ['notify_sender_address', ''], ['notify_smtp_host', ''], ['notify_smtp_port', '587'], ['notify_smtp_encryption', 'starttls'], ['notify_smtp_self_signed', 'false'], ['notify_smtp_username', ''], ['notify_smtp_password', ''], ['notify_allow_user_config', 'false'], ['notify_global_recipient', ''], // Cleanup worker settings (v0.15) ['cleanup_import_sessions_enabled', 'true'], ['cleanup_temp_exports_enabled', 'true'], ['cleanup_temp_export_max_age_hours', '2'], ['cleanup_backup_partials_enabled', 'true'], ['cleanup_import_history_enabled', 'false'], ['cleanup_import_history_max_age_days', '365'], ['cleanup_last_run_at', ''], ['cleanup_last_result', ''], // Auth method settings (v0.18) ['local_login_enabled', 'true'], ['oidc_login_enabled', 'false'], ['oidc_provider_name', 'authentik'], ['oidc_issuer_url', ''], ['oidc_client_id', ''], ['oidc_client_secret', ''], ['oidc_token_auth_method', 'client_secret_basic'], ['oidc_redirect_uri', ''], ['oidc_scopes', 'openid email profile groups'], ['oidc_auto_provision', 'true'], ['oidc_admin_group', ''], ['oidc_default_role', 'user'], ]; const insert = db.prepare( 'INSERT OR IGNORE INTO settings (key, value) VALUES (?, ?)' ); for (const [key, value] of defaults) { insert.run(key, value); } // Category defaults are user-scoped. They are applied by // ensureUserDefaultCategories(userId) when user-owned category/bill data is read. // ── Create initial admin user if none exists ───────────────────────────── const userCount = db.prepare('SELECT COUNT(*) as cnt FROM users').get().cnt; if (userCount === 0) { const initUser = process.env.INIT_ADMIN_USER || 'admin'; const initPass = process.env.INIT_ADMIN_PASS || 'admin123'; // Use bcryptjs sync for database init (safe, runs once at startup) const bcrypt = require('bcryptjs'); const password_hash = bcrypt.hashSync(initPass, 12); db.prepare(` INSERT INTO users (username, password_hash, role, is_default_admin, active, email, created_at, updated_at) VALUES (?, ?, 'admin', 1, 1, ?, datetime('now'), datetime('now')) `).run(initUser, password_hash, initUser + '@local'); console.log(`[seed] Created initial admin user: ${initUser}`); } } function ensureUserDefaultCategories(userId) { const db = getDb(); const insert = db.prepare('INSERT INTO categories (user_id, name) VALUES (?, ?)'); for (const name of DEFAULT_CATEGORIES) { const existing = db.prepare('SELECT id FROM categories WHERE user_id = ? AND name = ? COLLATE NOCASE') .get(userId, name); if (!existing) insert.run(userId, name); } } function getSetting(key) { const db = getDb(); const row = db.prepare('SELECT value FROM settings WHERE key = ?').get(key); return row ? row.value : null; } function setSetting(key, value) { const db = getDb(); db.prepare( "INSERT OR REPLACE INTO settings (key, value, updated_at) VALUES (?, ?, datetime('now'))" ).run(key, String(value)); } function closeDb() { if (!db) return; db.close(); db = null; } function getDbPath() { return DB_PATH; } module.exports = { getDb, getSetting, setSetting, closeDb, getDbPath, ensureUserDefaultCategories };