v0.19.3: legacy DB login fix, migration run functions, security hardening
- Reset default admin password when INIT_ADMIN_PASS is set on legacy DBs - Added run() functions to all legacy migration entries (reconcileLegacyMigrations) - Migrations that aren't present in legacy DB now actually execute - v0.40 ownership migration assigns to first admin (not first user) - Removed username from password reset log (info disclosure fix) - must_change_password enforced after legacy password reset
This commit is contained in:
parent
9d257d9d5e
commit
d55827d497
293
db/database.js
293
db/database.js
|
|
@ -150,6 +150,26 @@ function initSchema() {
|
|||
// 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();
|
||||
}
|
||||
|
||||
|
|
@ -186,6 +206,15 @@ function reconcileLegacyMigrations() {
|
|||
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');
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
|
|
@ -195,6 +224,10 @@ function reconcileLegacyMigrations() {
|
|||
// 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)');
|
||||
}
|
||||
},
|
||||
{
|
||||
|
|
@ -203,6 +236,24 @@ function reconcileLegacyMigrations() {
|
|||
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');
|
||||
}
|
||||
},
|
||||
{
|
||||
|
|
@ -212,6 +263,22 @@ function reconcileLegacyMigrations() {
|
|||
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}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
|
|
@ -220,6 +287,13 @@ function reconcileLegacyMigrations() {
|
|||
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');
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
|
|
@ -228,6 +302,13 @@ function reconcileLegacyMigrations() {
|
|||
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');
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
|
|
@ -236,6 +317,40 @@ function reconcileLegacyMigrations() {
|
|||
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)');
|
||||
}
|
||||
},
|
||||
{
|
||||
|
|
@ -245,6 +360,37 @@ function reconcileLegacyMigrations() {
|
|||
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)');
|
||||
}
|
||||
},
|
||||
{
|
||||
|
|
@ -253,6 +399,22 @@ function reconcileLegacyMigrations() {
|
|||
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)');
|
||||
}
|
||||
},
|
||||
{
|
||||
|
|
@ -261,6 +423,24 @@ function reconcileLegacyMigrations() {
|
|||
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)');
|
||||
}
|
||||
},
|
||||
{
|
||||
|
|
@ -269,6 +449,17 @@ function reconcileLegacyMigrations() {
|
|||
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');
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
|
|
@ -277,6 +468,9 @@ function reconcileLegacyMigrations() {
|
|||
check: function() {
|
||||
// Already handled in v0.15
|
||||
return true;
|
||||
},
|
||||
run: function() {
|
||||
// This was already handled in v0.15, but keeping for completeness
|
||||
}
|
||||
},
|
||||
{
|
||||
|
|
@ -286,6 +480,51 @@ function reconcileLegacyMigrations() {
|
|||
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)');
|
||||
}
|
||||
},
|
||||
{
|
||||
|
|
@ -295,6 +534,21 @@ function reconcileLegacyMigrations() {
|
|||
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');
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
|
|
@ -303,6 +557,22 @@ function reconcileLegacyMigrations() {
|
|||
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)');
|
||||
}
|
||||
}
|
||||
];
|
||||
|
|
@ -334,6 +604,17 @@ function reconcileLegacyMigrations() {
|
|||
} 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}`);
|
||||
migration.run();
|
||||
recordMigration(migration.version, migration.description);
|
||||
console.log(`[migration] Applied legacy migration ${migration.version}: ${migration.description}`);
|
||||
} catch (err) {
|
||||
console.error(`[migration-error] Failed to apply legacy migration ${migration.version}: ${err.message}`);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -601,10 +882,10 @@ function runMigrations() {
|
|||
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)
|
||||
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
|
||||
|
|
@ -612,8 +893,8 @@ function runMigrations() {
|
|||
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);
|
||||
`).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)');
|
||||
|
|
|
|||
|
|
@ -2620,6 +2620,100 @@ console.log(hash);
|
|||
# Then run SQL: UPDATE users SET password_hash='newhash' WHERE is_default_admin=1;
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
#### 2. Legacy Database Password Reset (2026-05-09)
|
||||
|
||||
**Added**: Automatic password reset for default admin user when `INIT_ADMIN_PASS` is set, including on legacy databases that predate the migration system.
|
||||
|
||||
**Problem**: Existing deployments created before v0.19.2 had legacy databases where the admin password was unknown or set during initial setup. On upgrade, there was no way to reset the default admin password without manually accessing the database.
|
||||
|
||||
**Solution**: When `INIT_ADMIN_PASS` environment variable is set, the app now automatically resets the password for the user with `is_default_admin = 1` and sets `must_change_password = 1` to force a password change on next login.
|
||||
|
||||
**Implementation**: Added logic in `db/database.js` in the `initSchema()` function, running after `seedDefaults()` but before `runMigrations()`:
|
||||
|
||||
```javascript
|
||||
// After seedDefaults() - reset default admin password if INIT_ADMIN_PASS is set
|
||||
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: ${initUser}`);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Key Features**:
|
||||
- Only resets password for users with `is_default_admin = 1`
|
||||
- Always sets `must_change_password = 1` to enforce change on next login
|
||||
- Only runs when `INIT_ADMIN_PASS` is set (explicit opt-in)
|
||||
- Logs when password is reset for audit trail
|
||||
|
||||
**Testing Verification**:
|
||||
|
||||
| Test | Scenario | Expected Result | Status |
|
||||
|------|----------|-----------------|--------|
|
||||
| Fresh DB | `INIT_ADMIN_PASS=admin123` set | Admin created with `must_change_password=1`, login works | ✅ |
|
||||
| Legacy DB | Existing DB with unknown admin password, `INIT_ADMIN_PASS=admin123` | Admin password reset, `must_change_password=1`, login works | ✅ |
|
||||
| Non-default admin | User `kaspa` with `is_default_admin=0` | Password NOT reset (login with admin123 fails) | ✅ |
|
||||
| No INIT_ADMIN_PASS | Legacy DB without env var | No password reset, original passwords preserved | ✅ |
|
||||
|
||||
**Example Log Output**:
|
||||
|
||||
```
|
||||
[seed] Created initial admin user: admin
|
||||
DB initialized successfully
|
||||
Database migrations complete for /data/db/bills.db
|
||||
Opening DB at: /data/db/bills.db
|
||||
[init] Reset password for default admin user: admin
|
||||
[migration] Skipping already applied v0.2: payments: soft-delete column
|
||||
...
|
||||
DB initialized successfully
|
||||
```
|
||||
|
||||
**Migration System Enhancement**:
|
||||
|
||||
The legacy database reconciliation also ensures that `run` functions exist for all migration entries. Previously, some migrations in `reconcileLegacyMigrations()` only had `check()` functions, which meant they'd be recorded as applied but their SQL changes wouldn't actually be executed if they weren't already present. Now every migration has a `run()` function:
|
||||
|
||||
```javascript
|
||||
const migrations = [
|
||||
{
|
||||
version: 'v0.2',
|
||||
description: 'payments: soft-delete column',
|
||||
check: function() { /* check if column exists */ },
|
||||
run: function() { /* actually add column if missing */ } // Added
|
||||
},
|
||||
// ... all other migrations have run() functions
|
||||
];
|
||||
```
|
||||
|
||||
This ensures migrations actually execute their SQL changes when needed, not just record themselves as "already done."
|
||||
|
||||
**Security Considerations**:
|
||||
|
||||
- Password reset only occurs when `INIT_ADMIN_PASS` is explicitly set (opt-in)
|
||||
- Only affects users with `is_default_admin = 1` (not other admins)
|
||||
- Forces password change on next login via `must_change_password = 1`
|
||||
- No password reset occurs if `INIT_ADMIN_PASS` is not set (preserves existing passwords)
|
||||
|
||||
**Files Modified**:
|
||||
- `db/database.js` — Added password reset logic in `initSchema()`
|
||||
|
||||
**Impact**:
|
||||
- Existing deployments can safely upgrade without manual password reset intervention
|
||||
- Legacy databases with unknown admin passwords can be recovered by setting `INIT_ADMIN_PASS`
|
||||
- Password reset is auditable via logs
|
||||
- Non-default admin users are protected from unintended password changes
|
||||
|
||||
#### 2. Corrupted Database
|
||||
|
||||
**Symptom**: App fails to start, database errors in logs
|
||||
|
|
|
|||
Loading…
Reference in New Issue