Files

136 lines
4.9 KiB
JavaScript
Raw Permalink Normal View History

2026-09-13 20:23:05 +01:00
import crypto from 'node:crypto';
import { nanoid } from 'nanoid';
import { USERS_FILE, SEED_ADMIN_USER, SEED_ADMIN_PASSWORD, SEED_ADMIN_EMAIL } from './config.js';
import { readJson, writeJson } from './store.js';
import { logEvent } from './logger.js';
let users = []; // { id, username, email, role, salt, hash, createdAt }
const authSessions = new Map(); // token -> { userId, expires }
const WEEK = 7 * 24 * 60 * 60 * 1000;
function hashPassword(password, salt) {
return crypto.scryptSync(String(password), salt, 64).toString('hex');
}
function buildUser(username, email, password, role) {
const salt = crypto.randomBytes(16).toString('hex');
return {
id: nanoid(10),
username: username.trim(),
email: (email || '').trim(),
role: role === 'admin' ? 'admin' : 'user',
salt,
hash: hashPassword(password, salt),
createdAt: Date.now()
};
}
export async function initUsers() {
const saved = await readJson(USERS_FILE, null);
users = Array.isArray(saved) ? saved : [];
// No auto-seed: on first run the operator creates the admin in the browser.
// For unattended deploys, ADMIN_USERNAME + ADMIN_PASSWORD may pre-seed one.
if (users.length === 0 && process.env.ADMIN_PASSWORD) {
users = [buildUser(SEED_ADMIN_USER, SEED_ADMIN_EMAIL, SEED_ADMIN_PASSWORD, 'admin')];
await persist();
await logEvent({ action: 'seed', message: `Pre-seeded admin "${SEED_ADMIN_USER}" from environment` });
}
}
export function hasUsers() {
return users.length > 0;
}
// Create the very first admin account (first-run setup). Refuses once any user exists.
export async function createFirstAdmin({ username, email, password }) {
if (users.length > 0) throw new Error('Setup has already been completed.');
if (!username || !password) throw new Error('Username and password are required.');
const u = buildUser(username, email, password, 'admin');
users.push(u);
await persist();
return publicUser(u);
}
async function persist() {
await writeJson(USERS_FILE, users);
}
export function publicUser(u) {
return u ? { id: u.id, username: u.username, email: u.email, role: u.role, createdAt: u.createdAt } : null;
}
export function listUsers() { return users.map(publicUser); }
export function getUserById(id) { return users.find((u) => u.id === id); }
export function getUserByName(name) {
const n = String(name || '').toLowerCase();
return users.find((u) => u.username.toLowerCase() === n);
}
export function adminCount() { return users.filter((u) => u.role === 'admin').length; }
export async function createUser({ username, email, password, role }) {
if (!username || !password) throw new Error('Username and password are required.');
if (getUserByName(username)) throw new Error('That username is already taken.');
const u = buildUser(username, email, password, role);
users.push(u);
await persist();
return publicUser(u);
}
export async function deleteUser(id, actingUserId) {
const u = getUserById(id);
if (!u) throw new Error('User not found.');
if (id === actingUserId) throw new Error('You cannot delete your own account.');
if (u.role === 'admin' && adminCount() <= 1) throw new Error('Cannot delete the last remaining admin.');
users = users.filter((x) => x.id !== id);
await persist();
for (const [token, s] of authSessions) if (s.userId === id) authSessions.delete(token);
return true;
}
export async function setPassword(id, password) {
const u = getUserById(id);
if (!u) throw new Error('User not found.');
if (!password) throw new Error('Password cannot be empty.');
u.salt = crypto.randomBytes(16).toString('hex');
u.hash = hashPassword(password, u.salt);
await persist();
}
export async function setRole(id, role, actingUserId) {
const u = getUserById(id);
if (!u) throw new Error('User not found.');
const next = role === 'admin' ? 'admin' : 'user';
if (u.role === 'admin' && next === 'user' && adminCount() <= 1) {
throw new Error('Cannot demote the last remaining admin.');
}
if (id === actingUserId && next === 'user') {
throw new Error('You cannot remove your own admin rights.');
}
u.role = next;
await persist();
}
export function verifyCredentials(username, password) {
const u = getUserByName(username);
if (!u) return null;
const attempt = Buffer.from(hashPassword(password, u.salt));
const known = Buffer.from(u.hash);
if (attempt.length !== known.length || !crypto.timingSafeEqual(attempt, known)) return null;
return u;
}
// --- auth sessions (cookie tokens, in-memory) ------------------------------
export function createAuthSession(userId) {
const token = nanoid(32);
authSessions.set(token, { userId, expires: Date.now() + WEEK });
return token;
}
export function getAuthUser(token) {
const s = token && authSessions.get(token);
if (!s) return null;
if (s.expires < Date.now()) { authSessions.delete(token); return null; }
return getUserById(s.userId) || null;
}
export function destroyAuthSession(token) {
if (token) authSessions.delete(token);
}