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', // sessions table columns 'created_at', ]); // 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')) ) `); // Check if this is a legacy database (tables exist but no migration tracking) handleLegacyDatabase(); // After legacy reconciliation and user seeding, reset the default admin password // when INIT_ADMIN_PASS is set. This ensures legacy DBs can be accessed after migration. // The must_change_password flag forces the admin to pick a new password on first login. if (process.env.INIT_ADMIN_PASS) { const initUser = process.env.INIT_ADMIN_USER || 'admin'; const initPass = process.env.INIT_ADMIN_PASS; const bcrypt = require('bcryptjs'); const newPasswordHash = bcrypt.hashSync(initPass, 12); // Reset password for the default admin user if INIT_ADMIN_PASS is set const result = db.prepare(` UPDATE users SET password_hash = ?, must_change_password = 1 WHERE username = ? AND is_default_admin = 1 `).run(newPasswordHash, initUser); if (result.changes > 0) { console.log('[init] Reset password for default admin user'); } } runMigrations(); } function hasMigrationBeenApplied(version) { const stmt = db.prepare('SELECT 1 FROM schema_migrations WHERE version = ?'); return !!stmt.get(version); } function handleLegacyDatabase() { // Check if schema_migrations table exists but is empty // This indicates a legacy database that predates migration tracking const migrationCount = db.prepare('SELECT COUNT(*) as count FROM schema_migrations').get().count; if (migrationCount === 0) { // This might be a legacy database. Check if core tables exist. const tableCheck = db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name IN ('users', 'bills', 'payments', 'categories', 'settings')").all(); // If we have core tables but no migrations tracked, this is likely a legacy DB if (tableCheck.length >= 3) { // At least some core tables exist console.log('[migration] Detected legacy database, reconciling schema migrations...'); // For each migration, check if its changes are already present and mark as applied if so reconcileLegacyMigrations(); } } } function reconcileLegacyMigrations() { // Define all migrations with explicit version tracking const migrations = [ { version: 'v0.2', description: 'payments: soft-delete column', check: function() { const paymentCols = db.prepare('PRAGMA table_info(payments)').all().map(c => c.name); return paymentCols.includes('deleted_at'); }, 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', check: function() { // Check if the index exists const indexes = db.prepare("SELECT name FROM sqlite_master WHERE type='index' AND name='idx_payments_bill_date_del'").all(); return indexes.length > 0; }, 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', check: function() { const tables = db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='monthly_bill_state'").all(); return tables.length > 0; }, 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', check: function() { const userColsNow = db.prepare('PRAGMA table_info(users)').all().map(c => c.name); const profileCols = ['display_name', 'last_password_change_at']; return profileCols.every(col => userColsNow.includes(col)); }, 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', check: function() { const billColsHist = db.prepare('PRAGMA table_info(bills)').all().map(c => c.name); return billColsHist.includes('history_visibility'); }, 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', check: function() { const billColsInterest = db.prepare('PRAGMA table_info(bills)').all().map(c => c.name); return billColsInterest.includes('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', check: function() { const tables = db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name IN ('import_sessions', 'import_history')").all(); return tables.length >= 2; }, 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', check: function() { const userColsOidc = db.prepare('PRAGMA table_info(users)').all().map(c => c.name); const oidcUserCols = ['auth_provider', 'external_subject', 'email', 'last_login_at']; return oidcUserCols.every(col => userColsOidc.includes(col)); }, 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', check: function() { const tables = db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='monthly_income'").all(); return tables.length > 0; }, 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', check: function() { const tables = db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='monthly_starting_amounts'").all(); return tables.length > 0; }, 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', check: function() { const startingCols = db.prepare('PRAGMA table_info(monthly_starting_amounts)').all().map(c => c.name); return startingCols.includes('other_amount'); }, 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', check: function() { // Already handled in v0.15 return true; }, run: function() { // This was already handled in v0.15, but keeping for completeness } }, { version: 'v0.40', description: 'ownership: user-scoped bills/categories', check: function() { const billCols = db.prepare('PRAGMA table_info(bills)').all().map(c => c.name); const categoryCols = db.prepare('PRAGMA table_info(categories)').all().map(c => c.name); return billCols.includes('user_id') && categoryCols.includes('user_id'); }, 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 firstAdmin = db.prepare("SELECT id FROM users WHERE role = 'admin' ORDER BY id LIMIT 1").get(); if (firstAdmin) { db.prepare('UPDATE bills SET user_id = ? WHERE user_id IS NULL').run(firstAdmin.id); // Drop any NULL-owner categories whose name already exists for this admin (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(firstAdmin.id); db.prepare('UPDATE categories SET user_id = ? WHERE user_id IS NULL').run(firstAdmin.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', check: function() { const billColsSeeded = db.prepare('PRAGMA table_info(bills)').all().map(c => c.name); const categoryColsSeeded = db.prepare('PRAGMA table_info(categories)').all().map(c => c.name); return billColsSeeded.includes('is_seeded') && categoryColsSeeded.includes('is_seeded'); }, 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', check: function() { const tables = db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='bill_history_ranges'").all(); return tables.length > 0; }, 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)'); } }, { version: 'v0.43', description: 'sessions: add created_at column', check: function() { const sessionCols = db.prepare('PRAGMA table_info(sessions)').all().map(c => c.name); return sessionCols.includes('created_at'); }, run: function() { const sessionCols = db.prepare('PRAGMA table_info(sessions)').all().map(c => c.name); if (!sessionCols.includes('created_at')) { // Security FIX (2026-05-09): Validate column name to prevent SQL injection if (!isValidColumnName('created_at')) { throw new Error('Invalid migration: column created_at not in whitelist'); } db.exec("ALTER TABLE sessions ADD COLUMN created_at TEXT DEFAULT (datetime('now'))"); console.log('[migration] sessions.created_at column added'); } } } ]; // Check for legacy notification columns const userCols = db.prepare('PRAGMA table_info(users)').all().map(c => c.name); const newUserCols = [ 'active', 'is_default_admin', 'notification_email', 'notifications_enabled', 'notify_3d', 'notify_1d', 'notify_due', 'notify_overdue' ]; const hasNotificationColumns = newUserCols.every(col => userCols.includes(col)); // If notification columns exist, mark that migration as applied if (hasNotificationColumns) { try { recordMigration('legacy-notification-columns', 'users: notification columns'); console.log('[migration] Recorded legacy notification columns migration'); } catch (e) { // Ignore if already recorded } } // Process all versioned migrations for (const migration of migrations) { if (migration.check()) { try { recordMigration(migration.version, migration.description); console.log(`[migration] Recorded legacy migration ${migration.version}: ${migration.description}`); } catch (e) { // Ignore if already recorded } } else { // Migration changes are NOT present - run the migration to apply them try { console.log(`[migration] Running legacy migration ${migration.version}: ${migration.description}`); // Wrap legacy migration in transaction db.exec('BEGIN'); console.log(`[migration] Transaction BEGIN for legacy ${migration.version}`); migration.run(); recordMigration(migration.version, migration.description); db.exec('COMMIT'); console.log(`[migration] Transaction COMMIT for legacy ${migration.version}`); } catch (err) { db.exec('ROLLBACK'); console.error(`[migration-error] Failed to apply legacy migration ${migration.version}: ${err.message}. Rolled back.`); throw err; } } } console.log('[migration] Legacy database reconciliation complete'); } 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 firstAdmin = db.prepare("SELECT id FROM users WHERE role = 'admin' ORDER BY id LIMIT 1").get(); if (firstAdmin) { db.prepare('UPDATE bills SET user_id = ? WHERE user_id IS NULL').run(firstAdmin.id); // Drop any NULL-owner categories whose name already exists for this admin (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(firstAdmin.id); db.prepare('UPDATE categories SET user_id = ? WHERE user_id IS NULL').run(firstAdmin.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)'); } }, { version: 'v0.43', description: 'sessions: add created_at column', run: function() { const sessionCols = db.prepare('PRAGMA table_info(sessions)').all().map(c => c.name); if (!sessionCols.includes('created_at')) { // Security FIX (2026-05-09): Validate column name to prevent SQL injection if (!isValidColumnName('created_at')) { throw new Error('Invalid migration: column created_at not in whitelist'); } db.exec("ALTER TABLE sessions ADD COLUMN created_at TEXT DEFAULT (datetime('now'))"); console.log('[migration] sessions.created_at column added'); } } } ]; // ── users: notification columns ─────────────────────────────────────────── // This migration needs to run first since it's not versioned in the schema try { db.exec('BEGIN'); console.log('[migration] Transaction BEGIN for unversioned user notification columns'); 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) `); db.exec('COMMIT'); console.log('[migration] Transaction COMMIT for unversioned user notification columns'); } catch (err) { db.exec('ROLLBACK'); console.error(`[migration-error] Failed to apply unversioned user notification columns: ${err.message}. Rolled back.`); throw err; } // Process all versioned migrations for (const migration of migrations) { if (!hasMigrationBeenApplied(migration.version)) { console.log(`[migration] Applying ${migration.version}: ${migration.description}`); try { // Special handling for v0.40 migration which uses PRAGMA statements if (migration.version === 'v0.40') { // PRAGMA foreign_keys cannot run inside a transaction, so we // disable FK checks before BEGIN and re-enable in a finally block // to guarantee FK is always restored even on failure. const billCols = db.prepare('PRAGMA table_info(bills)').all().map(c => c.name); const categoryCols = db.prepare('PRAGMA table_info(categories)').all().map(c => c.name); const needsForeignKeyOff = !billCols.includes('user_id') || !categoryCols.includes('user_id'); if (needsForeignKeyOff) { db.exec('PRAGMA foreign_keys = OFF'); } try { db.exec('BEGIN'); console.log(`[migration] Transaction BEGIN for ${migration.version}`); migration.run(); recordMigration(migration.version, migration.description); db.exec('COMMIT'); console.log(`[migration] Transaction COMMIT for ${migration.version}`); } catch (innerErr) { db.exec('ROLLBACK'); console.error(`[migration-error] Failed to apply ${migration.version}: ${innerErr.message}. Rolled back.`); throw innerErr; } finally { // Always restore FK checks — even on failure path if (needsForeignKeyOff) { db.exec('PRAGMA foreign_keys = ON'); } } } else { // Standard transaction wrapping for other migrations db.exec('BEGIN'); console.log(`[migration] Transaction BEGIN for ${migration.version}`); migration.run(); recordMigration(migration.version, migration.description); db.exec('COMMIT'); console.log(`[migration] Transaction COMMIT for ${migration.version}`); } } catch (err) { db.exec('ROLLBACK'); console.error(`[migration-error] Failed to apply ${migration.version}: ${err.message}. Rolled back.`); 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; } /** * Cleanup expired sessions from the database * @returns {Object} Result object with changes count */ function cleanupExpiredSessions() { const result = db.prepare("DELETE FROM sessions WHERE expires_at < datetime('now')").run(); console.log(`[cleanup] Purged ${result.changes} expired sessions`); return result; } module.exports = { getDb, getSetting, setSetting, closeDb, getDbPath, ensureUserDefaultCategories, cleanupExpiredSessions };