85 lines
3.7 KiB
JavaScript
85 lines
3.7 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 &&
|
||
// eslint-disable-next-line no-control-regex -- Reject terminal control characters while collecting password input.
|
||
!/[\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;
|
||
}
|