56 lines
3.1 KiB
JavaScript
56 lines
3.1 KiB
JavaScript
#!/usr/bin/env node
|
||
// Local recovery only: requires filesystem/container access to the controller.
|
||
import Database from 'better-sqlite3';
|
||
import { emitKeypressEvents } from 'node:readline';
|
||
import { hashPassword } from '../src/lib/server/auth/passwords.js';
|
||
|
||
function secret(prompt) {
|
||
return new Promise((resolve, reject) => {
|
||
let value = '';
|
||
process.stdout.write(prompt);
|
||
emitKeypressEvents(process.stdin);
|
||
process.stdin.setRawMode(true);
|
||
process.stdin.resume();
|
||
const cleanup = () => {
|
||
process.stdin.off('keypress', listener);
|
||
process.stdin.setRawMode(false);
|
||
process.stdin.pause();
|
||
process.stdout.write('\n');
|
||
};
|
||
const listener = (text, key) => {
|
||
if (key?.ctrl && key.name === 'c') { cleanup(); reject(new Error('Cancelled.')); }
|
||
else if (key?.name === 'return' || key?.name === 'enter') { cleanup(); resolve(value); }
|
||
else if (key?.name === 'backspace') value = [...value].slice(0,-1).join('');
|
||
else if (text && !key?.ctrl && !key?.meta && !/[\x00-\x1f\x7f]/.test(text) && [...value].length < 128) value += text;
|
||
};
|
||
process.stdin.on('keypress',listener);
|
||
});
|
||
}
|
||
|
||
try {
|
||
const username = process.argv[2]?.toLowerCase();
|
||
if (process.argv.length !== 3 || !username || !/^[a-z0-9][a-z0-9_.-]{2,63}$/.test(username)) throw new Error('Usage: node scripts/reset-admin.mjs <administrator-username>');
|
||
if (!process.stdin.isTTY) throw new Error('Run this command in an interactive terminal. Passwords are never accepted as command arguments.');
|
||
const database = new Database(process.env.DB_PATH || './data/cherry.db',{fileMustExist:true});
|
||
database.pragma('busy_timeout=5000');
|
||
const user = database.prepare("SELECT id FROM auth_users WHERE username=? AND role='admin' AND disabled=0").get(username);
|
||
if (!user) throw new Error('An enabled administrator with that username was not found.');
|
||
const password = await secret('New administrator password (8–128 characters): ');
|
||
const confirmation = await secret('Repeat password: ');
|
||
if (password !== confirmation) throw new Error('Passwords did not match.');
|
||
const passwordHash = await hashPassword(password);
|
||
database.transaction(() => {
|
||
const changed = database.prepare("UPDATE auth_users SET password_hash=?,must_change_password=0,updated_at=? WHERE id=? AND role='admin' AND disabled=0")
|
||
.run(passwordHash,Date.now(),user.id);
|
||
if (changed.changes !== 1) throw new Error('Administrator changed during recovery. Try again.');
|
||
database.prepare('DELETE FROM auth_sessions WHERE user_id=?').run(user.id);
|
||
database.prepare('INSERT INTO auth_audit(actor_id,action,target_id,details,created_at) VALUES(NULL,?,?,?,?)')
|
||
.run('password.recovered',user.id,'{"source":"local-cli"}',Date.now());
|
||
}).immediate();
|
||
database.close();
|
||
console.log('Administrator password reset. Existing sessions have been revoked.');
|
||
} catch (cause) {
|
||
console.error(cause instanceof Error ? cause.message : 'Administrator recovery failed.');
|
||
process.exitCode=1;
|
||
}
|