Files
2025-10-28 16:07:38 +00:00

301 lines
8.7 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import crypto from 'crypto';
import { db } from '../db/index.js';
export const SUPERUSER_GROUP_NAME = 'superuser';
export const AVATAR_COLORS = [
'bg-arcticBlue-600',
'bg-copperRust-500',
'bg-sunsetCoral-600',
'bg-mintTea-400',
'bg-lavenderSmoke-500',
'bg-emeraldMist-500',
'bg-roseQuartz-500',
'bg-auroraTeal-500',
'bg-citrusPunch-500',
'bg-mossGreen-400'
];
export const DEFAULT_AVATAR_COLOR = 'bg-mossGreen-500';
const AVATAR_COLOR_SET = new Set([...AVATAR_COLORS, DEFAULT_AVATAR_COLOR]);
export const pickRandomAvatarColor = () => {
if (!Array.isArray(AVATAR_COLORS) || AVATAR_COLORS.length === 0) {
return DEFAULT_AVATAR_COLOR;
}
const index = Math.floor(Math.random() * AVATAR_COLORS.length);
return AVATAR_COLORS[index] ?? DEFAULT_AVATAR_COLOR;
};
export const normalizeAvatarColor = (value) => {
if (!value) return null;
const candidate = String(value).trim();
return AVATAR_COLOR_SET.has(candidate) ? candidate : null;
};
const selectGroupByName = db.prepare('SELECT * FROM user_groups WHERE name = ?');
const insertGroup = db.prepare('INSERT INTO user_groups (name, description) VALUES (?, ?)');
const selectSuperuser = db.prepare(`
SELECT u.*
FROM users u
INNER JOIN user_group_memberships m ON m.user_id = u.id
INNER JOIN user_groups g ON g.id = m.group_id
WHERE g.name = ?
LIMIT 1
`);
const selectUserByUsername = db.prepare('SELECT * FROM users WHERE username = ?');
const selectUserByEmail = db.prepare('SELECT * FROM users WHERE email = ?');
const selectUserById = db.prepare('SELECT * FROM users WHERE id = ?');
const updateLastLogin = db.prepare(`
UPDATE users
SET last_login = CURRENT_TIMESTAMP,
updated_at = CURRENT_TIMESTAMP
WHERE id = ?
`);
const insertUser = db.prepare(`
INSERT INTO users (username, email, password_hash, password_salt, avatar_color, is_active, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, 1, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
`);
const updateUser = db.prepare(`
UPDATE users
SET username = ?, email = ?, password_hash = ?, password_salt = ?, is_active = 1, avatar_color = COALESCE(?, avatar_color), updated_at = CURRENT_TIMESTAMP
WHERE id = ?
`);
const insertMembership = db.prepare(`
INSERT OR IGNORE INTO user_group_memberships (user_id, group_id)
VALUES (?, ?)
`);
const removeMembershipsForGroup = db.prepare(`
DELETE FROM user_group_memberships
WHERE group_id = ?
`);
const selectUserIdsForGroup = db.prepare(`
SELECT u.id
FROM users u
INNER JOIN user_group_memberships m ON m.user_id = u.id
WHERE m.group_id = ?
`);
const deleteUserById = db.prepare('DELETE FROM users WHERE id = ?');
const deleteGroupById = db.prepare('DELETE FROM user_groups WHERE id = ?');
const countSuperusers = db.prepare(`
SELECT COUNT(*) as count
FROM user_group_memberships m
INNER JOIN user_groups g ON g.id = m.group_id
WHERE g.name = ?
`);
const creationTransaction = db.transaction(({ username, email, passwordHash, passwordSalt, groupId, avatarColor }) => {
const existingUser = selectUserByUsername.get(username) || selectUserByEmail.get(email);
let userId;
if (existingUser) {
const existingColor = normalizeAvatarColor(existingUser.avatar_color);
const normalizedNewColor = normalizeAvatarColor(avatarColor);
const colorToPersist = existingColor || normalizedNewColor || DEFAULT_AVATAR_COLOR;
updateUser.run(username, email, passwordHash, passwordSalt, colorToPersist, existingUser.id);
userId = existingUser.id;
} else {
const colorToPersist = normalizeAvatarColor(avatarColor) || DEFAULT_AVATAR_COLOR;
const result = insertUser.run(username, email, passwordHash, passwordSalt, colorToPersist);
userId = result.lastInsertRowid;
}
removeMembershipsForGroup.run(groupId);
insertMembership.run(userId, groupId);
return selectUserById.get(userId);
});
export function hashPassword(password) {
if (!password || typeof password !== 'string') {
const err = new Error('INVALID_PASSWORD');
err.code = 'INVALID_PASSWORD';
throw err;
}
const trimmed = password.trim();
if (!trimmed) {
const err = new Error('INVALID_PASSWORD');
err.code = 'INVALID_PASSWORD';
throw err;
}
if (trimmed.length < 8) {
const err = new Error('PASSWORD_TOO_SHORT');
err.code = 'PASSWORD_TOO_SHORT';
throw err;
}
const salt = crypto.randomBytes(16).toString('hex');
const hash = crypto.pbkdf2Sync(trimmed, salt, 120000, 64, 'sha512').toString('hex');
return { salt, hash };
}
export function verifyPassword(password, hash, salt) {
if (!hash || !salt) return false;
if (!password || typeof password !== 'string') return false;
const trimmed = password.trim();
if (!trimmed) return false;
const derived = crypto.pbkdf2Sync(trimmed, salt, 120000, 64, 'sha512').toString('hex');
const storedBuffer = Buffer.from(hash, 'hex');
const derivedBuffer = Buffer.from(derived, 'hex');
if (storedBuffer.length !== derivedBuffer.length) {
return false;
}
return crypto.timingSafeEqual(storedBuffer, derivedBuffer);
}
function ensureGroup(name, description = null) {
let group = selectGroupByName.get(name);
if (group) return group;
insertGroup.run(name, description);
group = selectGroupByName.get(name);
return group;
}
export function hasSuperuser() {
const { count } = countSuperusers.get(SUPERUSER_GROUP_NAME);
return count > 0;
}
export function findUserByIdentifier(identifier) {
const value = String(identifier || '').trim();
if (!value) return null;
if (value.includes('@')) {
return selectUserByEmail.get(value.toLowerCase());
}
return selectUserByUsername.get(value);
}
export function findUserById(id) {
if (!id) return null;
return selectUserById.get(id);
}
export function markUserLogin(userId) {
if (!userId) return;
updateLastLogin.run(userId);
}
function createOrUpdateSuperuser({ username, email, password }) {
const normalizedUsername = String(username || '').trim();
const normalizedEmail = String(email || '').trim().toLowerCase();
if (!normalizedUsername) {
throw new Error('USERNAME_REQUIRED');
}
if (!normalizedEmail || !normalizedEmail.includes('@')) {
throw new Error('EMAIL_INVALID');
}
const { hash, salt } = hashPassword(password);
const group = ensureGroup(SUPERUSER_GROUP_NAME, 'System Superuser');
const avatarColor = pickRandomAvatarColor();
const user = creationTransaction({
username: normalizedUsername,
email: normalizedEmail,
passwordHash: hash,
passwordSalt: salt,
groupId: group.id,
avatarColor
});
const persistedColor = normalizeAvatarColor(user.avatar_color) || avatarColor || DEFAULT_AVATAR_COLOR;
return {
id: user.id,
username: user.username,
email: user.email,
avatarColor: persistedColor
};
}
const deletionTransaction = db.transaction((groupId) => {
const members = selectUserIdsForGroup.all(groupId);
members.forEach(({ id }) => {
deleteUserById.run(id);
});
const { changes: groupChanges } = deleteGroupById.run(groupId);
return {
usersRemoved: members.length,
groupRemoved: groupChanges > 0
};
});
export function ensureSuperuserFromEnv() {
const username = process.env.SUPERUSER_USERNAME;
const email = process.env.SUPERUSER_EMAIL;
const password = process.env.SUPERUSER_PASSWORD;
if (hasSuperuser()) {
if (username || email || password) {
console.log('️ Superuser existiert bereits - Umgebungsvariablen werden ignoriert.');
}
return false;
}
if (!username || !email || !password) {
return false;
}
try {
createOrUpdateSuperuser({ username, email, password });
console.log('✅ Superuser aus Umgebungsvariablen angelegt.');
return true;
} catch (error) {
console.error('⚠️ Superuser konnte nicht aus Umgebungsvariablen erzeugt werden:', error.message);
return false;
}
}
export function registerSuperuser({ username, email, password }) {
if (hasSuperuser()) {
const err = new Error('SUPERUSER_ALREADY_EXISTS');
err.code = 'SUPERUSER_ALREADY_EXISTS';
throw err;
}
try {
return createOrUpdateSuperuser({ username, email, password });
} catch (error) {
if (error.message === 'USERNAME_REQUIRED' || error.message === 'EMAIL_INVALID' || error.message === 'PASSWORD_TOO_SHORT') {
error.code = error.message;
}
throw error;
}
}
export function removeSuperuser() {
const group = selectGroupByName.get(SUPERUSER_GROUP_NAME);
if (!group) {
const error = new Error('SUPERUSER_NOT_FOUND');
error.code = 'SUPERUSER_NOT_FOUND';
throw error;
}
return deletionTransaction(group.id);
}
export function getSuperuserSummary() {
const user = selectSuperuser.get(SUPERUSER_GROUP_NAME);
if (!user) return null;
return {
id: user.id,
username: user.username,
email: user.email
};
}