Files
stackpulse/backend/users/index.js
T
2025-12-01 07:59:40 +00:00

1200 lines
34 KiB
JavaScript

import { db } from '../db/index.js';
import { logEvent } from '../logging/eventLogs.js';
import {
generateSecurityPhraseWords,
encryptSecurityPhrase,
decryptSecurityPhrase,
canonicalizeWords,
canonicalizePhraseInput
} from './securityPhrase.js';
import {
hashPassword,
normalizeAvatarColor,
pickRandomAvatarColor,
DEFAULT_AVATAR_COLOR,
SUPERUSER_GROUP_NAME
} from '../auth/superuser.js';
const selectUsersWithGroups = db.prepare(`
SELECT
u.id,
u.username,
u.email,
u.is_active,
u.avatar_color,
u.last_login,
u.created_at,
u.updated_at,
u.security_phrase_downloaded_at,
GROUP_CONCAT(g.id || '::' || g.name, '|||') AS group_pairs
FROM users u
LEFT JOIN user_group_memberships m ON m.user_id = u.id
LEFT JOIN user_groups g ON g.id = m.group_id
GROUP BY u.id
ORDER BY u.username COLLATE NOCASE
`);
const selectUserWithGroupsById = db.prepare(`
SELECT
u.id,
u.username,
u.email,
u.is_active,
u.avatar_color,
u.last_login,
u.created_at,
u.updated_at,
u.security_phrase_content,
u.security_phrase_iv,
u.security_phrase_tag,
u.security_phrase_downloaded_at,
GROUP_CONCAT(g.id || '::' || g.name, '|||') AS group_pairs
FROM users u
LEFT JOIN user_group_memberships m ON m.user_id = u.id
LEFT JOIN user_groups g ON g.id = m.group_id
WHERE u.id = ?
GROUP BY u.id
`);
const selectUserCredentialsById = db.prepare(`
SELECT id, username, email, password_hash, password_salt, avatar_color
FROM users
WHERE id = ?
`);
const selectUserByUsername = db.prepare('SELECT id FROM users WHERE username = ?');
const selectUserByEmail = db.prepare('SELECT id FROM users WHERE email = ?');
const deleteMembershipsByUser = db.prepare(`
DELETE FROM user_group_memberships
WHERE user_id = ?
`);
const insertMembershipForUser = db.prepare(`
INSERT OR IGNORE INTO user_group_memberships (user_id, group_id)
VALUES (?, ?)
`);
const selectServerByIdForAssignment = db.prepare('SELECT id, name, url FROM servers WHERE id = ?');
const selectAssignmentsByUser = db.prepare(`
SELECT
usa.user_id,
usa.server_id,
usa.group_id,
usa.use_global_group,
usa.created_at,
usa.updated_at,
s.name AS server_name,
s.url AS server_url,
g.name AS group_name
FROM user_server_assignments usa
JOIN servers s ON s.id = usa.server_id
LEFT JOIN user_groups g ON g.id = usa.group_id
WHERE usa.user_id = ?
ORDER BY s.name ASC, usa.server_id ASC
`);
const upsertUserServerAssignment = db.prepare(`
INSERT INTO user_server_assignments (
user_id,
server_id,
group_id,
use_global_group,
created_at,
updated_at
) VALUES (
?, ?, ?, ?, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP
)
ON CONFLICT(user_id, server_id) DO UPDATE SET
group_id = excluded.group_id,
use_global_group = excluded.use_global_group,
updated_at = CURRENT_TIMESTAMP
`);
const deleteUserServerAssignments = db.prepare(`
DELETE FROM user_server_assignments
WHERE user_id = ?
`);
const selectSecurityPhraseByUsername = db.prepare(`
SELECT
id,
username,
is_active,
security_phrase_content AS content,
security_phrase_iv AS iv,
security_phrase_tag AS tag,
security_phrase_downloaded_at AS downloaded_at
FROM users
WHERE lower(username) = lower(?)
LIMIT 1
`);
const selectGroupById = db.prepare('SELECT id, name FROM user_groups WHERE id = ?');
const selectGroupIdByName = db.prepare('SELECT id FROM user_groups WHERE lower(name) = lower(?) LIMIT 1');
const isUserInGroupStatement = db.prepare(`
SELECT 1 AS has_membership
FROM user_group_memberships m
INNER JOIN user_groups g ON g.id = m.group_id
WHERE m.user_id = ? AND lower(g.name) = lower(?)
LIMIT 1
`);
const insertUserStatement = db.prepare(`
INSERT INTO users (
username,
email,
password_hash,
password_salt,
avatar_color,
is_active,
security_phrase_content,
security_phrase_iv,
security_phrase_tag,
security_phrase_downloaded_at,
created_at,
updated_at
)
VALUES (?, ?, ?, ?, ?, 1, ?, ?, ?, NULL, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
`);
const updateUserCoreStatement = db.prepare(`
UPDATE users
SET username = ?,
email = ?,
password_hash = ?,
password_salt = ?,
avatar_color = ?,
updated_at = CURRENT_TIMESTAMP
WHERE id = ?
`);
const updateUserActiveStatement = db.prepare(`
UPDATE users
SET is_active = ?,
updated_at = CURRENT_TIMESTAMP
WHERE id = ?
`);
const updateUserPasswordStatement = db.prepare(`
UPDATE users
SET password_hash = ?,
password_salt = ?,
updated_at = CURRENT_TIMESTAMP
WHERE id = ?
`);
const parseGroupPairs = (rawPairs) => {
if (!rawPairs) {
return [];
}
return rawPairs
.split('|||')
.map((entry) => {
const [idPart, namePart] = entry.split('::');
const groupName = String(namePart || '').trim();
if (!groupName) {
return null;
}
const numericId = Number(idPart);
return {
id: Number.isFinite(numericId) ? numericId : null,
name: groupName
};
})
.filter(Boolean);
};
const sanitizeUserRecord = (row) => ({
id: row.id,
username: row.username,
email: row.email || null,
isActive: Boolean(row.is_active),
avatarColor: row.avatar_color || null,
lastLogin: row.last_login || null,
createdAt: row.created_at || null,
updatedAt: row.updated_at || null,
groups: parseGroupPairs(row.group_pairs),
securityPhraseDownloadedAt: row.security_phrase_downloaded_at || null
});
export function listUsers() {
const rows = selectUsersWithGroups.all();
return rows.map(sanitizeUserRecord);
}
export function getUserById(userId) {
const row = selectUserWithGroupsById.get(userId);
return row ? sanitizeUserRecord(row) : null;
}
const applyUserGroupAssignments = db.transaction((userId, groupIds) => {
deleteMembershipsByUser.run(userId);
groupIds.forEach((groupId) => {
insertMembershipForUser.run(userId, groupId);
});
});
const insertUserWithGroups = db.transaction(({
username,
email,
passwordHash,
passwordSalt,
avatarColor,
groupId,
securityPhrase
}) => {
const phraseToPersist = securityPhrase && typeof securityPhrase === 'object'
? {
content: securityPhrase.content ?? null,
iv: securityPhrase.iv ?? null,
tag: securityPhrase.tag ?? null
}
: { content: null, iv: null, tag: null };
const result = insertUserStatement.run(
username,
email,
passwordHash,
passwordSalt,
avatarColor,
phraseToPersist.content,
phraseToPersist.iv,
phraseToPersist.tag
);
const userId = Number(result.lastInsertRowid);
applyUserGroupAssignments(userId, [groupId]);
return userId;
});
const selectSecurityPhraseByUserId = db.prepare(`
SELECT
id,
username,
security_phrase_content AS content,
security_phrase_iv AS iv,
security_phrase_tag AS tag,
security_phrase_downloaded_at AS downloaded_at
FROM users
WHERE id = ?
`);
const updateSecurityPhraseStatement = db.prepare(`
UPDATE users
SET
security_phrase_content = ?,
security_phrase_iv = ?,
security_phrase_tag = ?,
security_phrase_downloaded_at = NULL,
updated_at = CURRENT_TIMESTAMP
WHERE id = ?
`);
const markSecurityPhraseDownloadedStatement = db.prepare(`
UPDATE users
SET security_phrase_downloaded_at = COALESCE(security_phrase_downloaded_at, CURRENT_TIMESTAMP),
updated_at = CURRENT_TIMESTAMP
WHERE id = ?
`);
const selectUsersMissingSecurityPhraseStatement = db.prepare(`
SELECT id
FROM users
WHERE security_phrase_content IS NULL
OR security_phrase_iv IS NULL
OR security_phrase_tag IS NULL
`);
const resolveActorFields = (actor) => {
if (!actor || actor.id === undefined || actor.id === null) {
return {};
}
const name = actor.username || actor.email || `User ${actor.id}`;
return {
actorType: 'user',
actorId: String(actor.id),
actorName: name
};
};
const normalizeEmail = (value) => {
if (!value) return null;
const trimmed = String(value).trim();
if (!trimmed) {
return null;
}
return trimmed.toLowerCase();
};
export function getUserSecurityPhrase(userId, options = {}) {
const numericUserId = Number(userId);
if (!Number.isFinite(numericUserId) || numericUserId <= 0) {
const error = new Error('INVALID_USER_ID');
error.code = 'INVALID_USER_ID';
throw error;
}
const { actor = null, allowAutoGenerate = true } = options;
const record = selectSecurityPhraseByUserId.get(numericUserId);
if (!record) {
const error = new Error('USER_NOT_FOUND');
error.code = 'USER_NOT_FOUND';
throw error;
}
const words = decryptSecurityPhrase(record);
if (allowAutoGenerate && (!Array.isArray(words) || words.length === 0)) {
return renewUserSecurityPhrase(numericUserId, { actor, suppressLog: true });
}
return {
userId: numericUserId,
username: record.username,
words,
downloadedAt: record.downloaded_at || null
};
}
export function renewUserSecurityPhrase(userId, options = {}) {
const actor = options.actor || null;
const suppressLog = Boolean(options.suppressLog);
const numericUserId = Number(userId);
if (!Number.isFinite(numericUserId) || numericUserId <= 0) {
const error = new Error('INVALID_USER_ID');
error.code = 'INVALID_USER_ID';
throw error;
}
const existingUser = selectUserCredentialsById.get(numericUserId);
if (!existingUser) {
const error = new Error('USER_NOT_FOUND');
error.code = 'USER_NOT_FOUND';
throw error;
}
const previousPhraseRecord = selectSecurityPhraseByUserId.get(numericUserId);
const words = generateSecurityPhraseWords(8);
const { encrypted: encryptedPhrase } = encryptSecurityPhrase(words);
updateSecurityPhraseStatement.run(
encryptedPhrase.content,
encryptedPhrase.iv,
encryptedPhrase.tag,
numericUserId
);
if (!suppressLog) {
logEvent({
category: 'benutzer',
eventType: 'benutzer-sicherheitsschluessel-erneuert',
action: 'sicherheitsschluessel-erneuern',
status: 'erfolgreich',
entityType: 'benutzer',
entityId: String(numericUserId),
entityName: existingUser.username ?? `ID ${numericUserId}`,
message: `Sicherheitsschlüssel für Benutzer "${existingUser.username ?? numericUserId}" erneuert`,
metadata: {
previousDownloadedAt: previousPhraseRecord?.downloaded_at ?? null
},
...resolveActorFields(actor)
});
}
return {
userId: numericUserId,
username: existingUser.username,
words,
downloadedAt: null
};
}
export function markSecurityPhraseDownloaded(userId, options = {}) {
const actor = options.actor || null;
const numericUserId = Number(userId);
if (!Number.isFinite(numericUserId) || numericUserId <= 0) {
const error = new Error('INVALID_USER_ID');
error.code = 'INVALID_USER_ID';
throw error;
}
const record = selectSecurityPhraseByUserId.get(numericUserId);
if (!record) {
const error = new Error('USER_NOT_FOUND');
error.code = 'USER_NOT_FOUND';
throw error;
}
markSecurityPhraseDownloadedStatement.run(numericUserId);
const updatedRecord = selectSecurityPhraseByUserId.get(numericUserId) || record;
const downloadedAt = updatedRecord?.downloaded_at || null;
logEvent({
category: 'benutzer',
eventType: 'benutzer-sicherheitsschluessel-heruntergeladen',
action: 'sicherheitsschluessel-herunterladen',
status: 'erfolgreich',
entityType: 'benutzer',
entityId: String(numericUserId),
entityName: record.username ?? `ID ${numericUserId}`,
message: `Sicherheitsschlüssel für Benutzer "${record.username ?? numericUserId}" heruntergeladen`,
metadata: {
downloadedAt
},
...resolveActorFields(actor)
});
return {
userId: numericUserId,
username: record.username,
downloadedAt
};
}
export function verifySecurityPhraseForUsername(username, phraseInput) {
const normalizedUsername = typeof username === 'string' ? username.trim() : '';
if (!normalizedUsername) {
const error = new Error('USERNAME_REQUIRED');
error.code = 'USERNAME_REQUIRED';
throw error;
}
const record = selectSecurityPhraseByUsername.get(normalizedUsername);
if (!record) {
const error = new Error('USER_NOT_FOUND');
error.code = 'USER_NOT_FOUND';
throw error;
}
const candidateCanonical = canonicalizePhraseInput(phraseInput);
if (!candidateCanonical) {
const error = new Error('PHRASE_REQUIRED');
error.code = 'PHRASE_REQUIRED';
throw error;
}
const words = decryptSecurityPhrase(record);
if (!Array.isArray(words) || words.length === 0) {
const error = new Error('PHRASE_NOT_INITIALIZED');
error.code = 'PHRASE_NOT_INITIALIZED';
throw error;
}
const storedCanonical = canonicalizeWords(words);
if (!storedCanonical) {
const error = new Error('PHRASE_NOT_INITIALIZED');
error.code = 'PHRASE_NOT_INITIALIZED';
throw error;
}
if (storedCanonical !== candidateCanonical) {
const error = new Error('PHRASE_MISMATCH');
error.code = 'PHRASE_MISMATCH';
throw error;
}
return {
userId: record.id,
username: record.username,
isActive: Boolean(record.is_active),
words
};
}
export function setUserPassword(userId, newPassword, options = {}) {
const actor = options.actor || null;
const resetMethod = options.resetMethod || 'security-phrase';
const numericUserId = Number(userId);
if (!Number.isFinite(numericUserId) || numericUserId <= 0) {
const error = new Error('INVALID_USER_ID');
error.code = 'INVALID_USER_ID';
throw error;
}
const existingUser = selectUserCredentialsById.get(numericUserId);
if (!existingUser) {
const error = new Error('USER_NOT_FOUND');
error.code = 'USER_NOT_FOUND';
throw error;
}
let passwordHash;
let passwordSalt;
try {
const hashed = hashPassword(newPassword);
passwordHash = hashed.hash;
passwordSalt = hashed.salt;
} catch (err) {
if (err && err.code) {
throw err;
}
const error = new Error('INVALID_PASSWORD');
error.code = 'INVALID_PASSWORD';
throw error;
}
updateUserPasswordStatement.run(passwordHash, passwordSalt, numericUserId);
const updated = getUserById(numericUserId);
logEvent({
category: 'benutzer',
eventType: 'benutzer-passwort-zurueckgesetzt',
action: 'passwort-zuruecksetzen',
status: 'erfolgreich',
entityType: 'benutzer',
entityId: String(numericUserId),
entityName: updated?.username ?? existingUser.username ?? `ID ${numericUserId}`,
message: `Passwort für Benutzer "${updated?.username ?? existingUser.username ?? numericUserId}" zurückgesetzt`,
metadata: {
resetMethod
},
...resolveActorFields(actor)
});
return updated;
}
export function ensureSecurityPhrasesForExistingUsers() {
const rows = selectUsersMissingSecurityPhraseStatement.all();
if (!Array.isArray(rows) || rows.length === 0) {
return 0;
}
let processed = 0;
const initialize = db.transaction((users) => {
users.forEach((entry) => {
if (!entry || entry.id === undefined || entry.id === null) {
return;
}
const words = generateSecurityPhraseWords(8);
const { encrypted: encryptedPhrase } = encryptSecurityPhrase(words);
updateSecurityPhraseStatement.run(
encryptedPhrase.content,
encryptedPhrase.iv,
encryptedPhrase.tag,
entry.id
);
processed += 1;
});
});
initialize(rows);
return processed;
}
export function createUser({ username, email, password, groupId, avatarColor }, options = {}) {
const actor = options.actor || null;
const normalizedUsername = typeof username === 'string' ? username.trim() : '';
if (!normalizedUsername) {
const error = new Error('USERNAME_REQUIRED');
error.code = 'USERNAME_REQUIRED';
throw error;
}
const numericGroupId = Number(groupId);
if (!Number.isFinite(numericGroupId) || numericGroupId <= 0) {
const error = new Error('INVALID_GROUP_ID');
error.code = 'INVALID_GROUP_ID';
throw error;
}
const groupRow = selectGroupById.get(numericGroupId);
if (!groupRow) {
const error = new Error('GROUP_NOT_FOUND');
error.code = 'GROUP_NOT_FOUND';
throw error;
}
if ((groupRow.name || '').toLowerCase() === SUPERUSER_GROUP_NAME) {
const error = new Error('GROUP_SUPERUSER_PROTECTED');
error.code = 'GROUP_SUPERUSER_PROTECTED';
throw error;
}
const normalizedEmail = normalizeEmail(email);
if (normalizedEmail && !normalizedEmail.includes('@')) {
const error = new Error('INVALID_EMAIL');
error.code = 'INVALID_EMAIL';
throw error;
}
const existingUsername = selectUserByUsername.get(normalizedUsername);
if (existingUsername) {
const error = new Error('USERNAME_TAKEN');
error.code = 'USERNAME_TAKEN';
throw error;
}
if (normalizedEmail) {
const existingEmail = selectUserByEmail.get(normalizedEmail);
if (existingEmail) {
const error = new Error('EMAIL_TAKEN');
error.code = 'EMAIL_TAKEN';
throw error;
}
}
let passwordHash;
let passwordSalt;
try {
const hashed = hashPassword(password);
passwordHash = hashed.hash;
passwordSalt = hashed.salt;
} catch (err) {
if (err && err.code) {
throw err;
}
const error = new Error('INVALID_PASSWORD');
error.code = 'INVALID_PASSWORD';
throw error;
}
const normalizedAvatarColor = normalizeAvatarColor(avatarColor);
const avatarColorToPersist = normalizedAvatarColor || pickRandomAvatarColor() || DEFAULT_AVATAR_COLOR;
const securityPhraseWords = generateSecurityPhraseWords(8);
const { encrypted: encryptedPhrase } = encryptSecurityPhrase(securityPhraseWords);
const securityPhrasePayload = {
content: encryptedPhrase.content,
iv: encryptedPhrase.iv,
tag: encryptedPhrase.tag
};
const userId = insertUserWithGroups({
username: normalizedUsername,
email: normalizedEmail,
passwordHash,
passwordSalt,
avatarColor: avatarColorToPersist,
groupId: numericGroupId,
securityPhrase: securityPhrasePayload
});
const userRecord = getUserById(userId);
logEvent({
category: 'benutzer',
eventType: 'benutzer-angelegt',
action: 'anlegen',
status: 'erfolgreich',
entityType: 'benutzer',
entityId: String(userRecord?.id ?? userId),
entityName: userRecord?.username ?? normalizedUsername,
message: `Benutzer "${normalizedUsername}" angelegt`,
metadata: {
email: userRecord?.email ?? normalizedEmail ?? null,
primaryGroupId: numericGroupId,
primaryGroupName: groupRow.name
},
...resolveActorFields(actor)
});
return userRecord;
}
export function updateUserGroups(userId, groupIds, options = {}) {
const actor = options.actor || null;
const numericUserId = Number(userId);
if (!Number.isFinite(numericUserId) || numericUserId <= 0) {
const error = new Error('INVALID_USER_ID');
error.code = 'INVALID_USER_ID';
throw error;
}
const existingUser = getUserById(numericUserId);
if (!existingUser) {
const error = new Error('USER_NOT_FOUND');
error.code = 'USER_NOT_FOUND';
throw error;
}
const normalizedGroupIds = Array.isArray(groupIds)
? Array.from(
new Set(
groupIds
.map((value) => Number(value))
.filter((value) => Number.isFinite(value) && value > 0)
)
)
: [];
const missingGroupIds = [];
const groupDetails = [];
normalizedGroupIds.forEach((groupId) => {
const groupRow = selectGroupById.get(groupId);
if (!groupRow) {
missingGroupIds.push(groupId);
} else {
groupDetails.push({
id: groupId,
name: groupRow.name
});
}
});
if (missingGroupIds.length > 0) {
const error = new Error('GROUP_NOT_FOUND');
error.code = 'GROUP_NOT_FOUND';
error.missingGroupIds = missingGroupIds;
throw error;
}
applyUserGroupAssignments(numericUserId, normalizedGroupIds);
const updated = getUserById(numericUserId);
logEvent({
category: 'benutzer',
eventType: 'benutzer-gruppen-aktualisiert',
action: 'gruppe-aktualisieren',
status: 'erfolgreich',
entityType: 'benutzer',
entityId: String(numericUserId),
entityName: updated?.username ?? existingUser.username ?? `ID ${numericUserId}`,
message: `Gruppenzuordnung für Benutzer "${updated?.username ?? existingUser.username ?? numericUserId}" aktualisiert`,
metadata: {
previousGroups: (existingUser.groups || []).map((group) => ({ id: group.id, name: group.name })),
groups: updated?.groups?.map((group) => ({ id: group.id, name: group.name })) ?? groupDetails
},
...resolveActorFields(actor)
});
return updated;
}
export function updateUserDetails(userId, { username, email, password, avatarColor, groupId, groupIds } = {}, options = {}) {
const actor = options.actor || null;
const numericUserId = Number(userId);
if (!Number.isFinite(numericUserId) || numericUserId <= 0) {
const error = new Error('INVALID_USER_ID');
error.code = 'INVALID_USER_ID';
throw error;
}
const existingUser = selectUserCredentialsById.get(numericUserId);
if (!existingUser) {
const error = new Error('USER_NOT_FOUND');
error.code = 'USER_NOT_FOUND';
throw error;
}
const previousUserRecord = getUserById(numericUserId);
const normalizedUsername = typeof username === 'string' ? username.trim() : existingUser.username;
if (!normalizedUsername) {
const error = new Error('USERNAME_REQUIRED');
error.code = 'USERNAME_REQUIRED';
throw error;
}
const usernameRow = selectUserByUsername.get(normalizedUsername);
if (usernameRow && Number(usernameRow.id) !== numericUserId) {
const error = new Error('USERNAME_TAKEN');
error.code = 'USERNAME_TAKEN';
throw error;
}
const normalizedEmail = email === undefined ? existingUser.email : normalizeEmail(email);
if (normalizedEmail && !normalizedEmail.includes('@')) {
const error = new Error('INVALID_EMAIL');
error.code = 'INVALID_EMAIL';
throw error;
}
if (normalizedEmail) {
const emailRow = selectUserByEmail.get(normalizedEmail);
if (emailRow && Number(emailRow.id) !== numericUserId) {
const error = new Error('EMAIL_TAKEN');
error.code = 'EMAIL_TAKEN';
throw error;
}
}
let passwordHash = existingUser.password_hash;
let passwordSalt = existingUser.password_salt;
if (typeof password === 'string') {
const trimmedPassword = password.trim();
if (trimmedPassword.length > 0) {
const hashed = hashPassword(trimmedPassword);
passwordHash = hashed.hash;
passwordSalt = hashed.salt;
}
} else if (password !== undefined && password !== null) {
const error = new Error('INVALID_PASSWORD');
error.code = 'INVALID_PASSWORD';
throw error;
}
let colorToPersist = existingUser.avatar_color || DEFAULT_AVATAR_COLOR;
if (avatarColor !== undefined) {
const candidate = String(avatarColor || '').trim();
if (!candidate) {
colorToPersist = DEFAULT_AVATAR_COLOR;
} else {
const normalizedColor = normalizeAvatarColor(candidate);
if (!normalizedColor) {
const error = new Error('INVALID_AVATAR_COLOR');
error.code = 'INVALID_AVATAR_COLOR';
throw error;
}
colorToPersist = normalizedColor;
}
}
const shouldUpdateGroups = groupId !== undefined || groupIds !== undefined;
let normalizedGroupIds = null;
let nextGroups = null;
if (shouldUpdateGroups) {
const incomingGroupIds = Array.isArray(groupIds)
? groupIds
: groupId !== undefined && groupId !== null
? [groupId]
: [];
normalizedGroupIds = Array.from(
new Set(
incomingGroupIds
.map((value) => Number(value))
.filter((value) => Number.isFinite(value) && value > 0)
)
);
const missingGroupIds = [];
nextGroups = [];
normalizedGroupIds.forEach((value) => {
const groupRow = selectGroupById.get(value);
if (!groupRow) {
missingGroupIds.push(value);
} else {
nextGroups.push({ id: value, name: groupRow.name });
}
});
if (missingGroupIds.length > 0) {
const error = new Error('GROUP_NOT_FOUND');
error.code = 'GROUP_NOT_FOUND';
error.missingGroupIds = missingGroupIds;
throw error;
}
}
const performUpdate = db.transaction(() => {
updateUserCoreStatement.run(
normalizedUsername,
normalizedEmail,
passwordHash,
passwordSalt,
colorToPersist,
numericUserId
);
if (normalizedGroupIds !== null) {
applyUserGroupAssignments(numericUserId, normalizedGroupIds);
}
});
performUpdate();
const updatedUser = getUserById(numericUserId);
logEvent({
category: 'benutzer',
eventType: 'benutzer-aktualisiert',
action: 'aktualisieren',
status: 'erfolgreich',
entityType: 'benutzer',
entityId: String(numericUserId),
entityName: updatedUser?.username ?? normalizedUsername,
message: `Benutzer "${updatedUser?.username ?? normalizedUsername}" aktualisiert`,
metadata: {
email: updatedUser?.email ?? normalizedEmail ?? null,
groupsUpdated: normalizedGroupIds !== null
? (updatedUser?.groups?.map((group) => ({ id: group.id, name: group.name })) ?? nextGroups)
: undefined,
previousGroups: normalizedGroupIds !== null
? (previousUserRecord?.groups || []).map((group) => ({ id: group.id, name: group.name }))
: undefined,
avatarColor: updatedUser?.avatarColor ?? colorToPersist
},
...resolveActorFields(actor)
});
return updatedUser;
}
const sanitizeAssignment = (row) => ({
userId: row.user_id,
serverId: row.server_id,
groupId: row.group_id ?? null,
groupName: row.group_name || null,
serverName: row.server_name || null,
serverUrl: row.server_url || null,
useGlobalGroup: Boolean(row.use_global_group),
createdAt: row.created_at || null,
updatedAt: row.updated_at || null
});
export function getUserServerAssignments(userId) {
const numericUserId = Number(userId);
if (!Number.isFinite(numericUserId) || numericUserId <= 0) {
const error = new Error('INVALID_USER_ID');
error.code = 'INVALID_USER_ID';
throw error;
}
const existingUser = selectUserCredentialsById.get(numericUserId);
if (!existingUser) {
const error = new Error('USER_NOT_FOUND');
error.code = 'USER_NOT_FOUND';
throw error;
}
const superuserMembership = isUserInGroupStatement.get(numericUserId, SUPERUSER_GROUP_NAME);
if (superuserMembership?.has_membership) {
return [];
}
const rows = selectAssignmentsByUser.all(numericUserId);
return rows.map(sanitizeAssignment);
}
export function setUserServerAssignments(userId, assignments = [], options = {}) {
const actor = options.actor || null;
const numericUserId = Number(userId);
if (!Number.isFinite(numericUserId) || numericUserId <= 0) {
const error = new Error('INVALID_USER_ID');
error.code = 'INVALID_USER_ID';
throw error;
}
const userRecord = selectUserCredentialsById.get(numericUserId);
if (!userRecord) {
const error = new Error('USER_NOT_FOUND');
error.code = 'USER_NOT_FOUND';
throw error;
}
const superuserMembership = isUserInGroupStatement.get(numericUserId, SUPERUSER_GROUP_NAME);
if (superuserMembership?.has_membership) {
const error = new Error('USER_SUPERUSER_PROTECTED');
error.code = 'USER_SUPERUSER_PROTECTED';
throw error;
}
const normalizedList = Array.isArray(assignments) ? assignments : [];
const missingServers = [];
const missingGroups = [];
const normalizedAssignments = new Map();
normalizedList.forEach((entry) => {
const rawServerId = entry?.serverId ?? entry?.server_id ?? entry?.id;
const serverId = Number(rawServerId);
if (!Number.isFinite(serverId) || serverId <= 0) {
return;
}
const serverRow = selectServerByIdForAssignment.get(serverId);
if (!serverRow) {
missingServers.push(serverId);
return;
}
const useGlobalGroup =
entry?.useGlobalGroup === true ||
entry?.use_global_group === 1 ||
entry?.use_global_group === true;
const rawGroupId = entry?.groupId ?? entry?.group_id;
const groupId = Number(rawGroupId);
const normalizedGroupId = Number.isFinite(groupId) && groupId > 0 ? groupId : null;
if (!useGlobalGroup && normalizedGroupId === null) {
missingGroups.push(serverId);
return;
}
if (normalizedGroupId !== null) {
const groupRow = selectGroupById.get(normalizedGroupId);
if (!groupRow) {
missingGroups.push(serverId);
return;
}
}
normalizedAssignments.set(serverId, {
serverId,
groupId: normalizedGroupId,
useGlobalGroup
});
});
if (missingServers.length > 0) {
const error = new Error('SERVER_NOT_FOUND');
error.code = 'SERVER_NOT_FOUND';
error.missingServerIds = missingServers;
throw error;
}
if (missingGroups.length > 0) {
const error = new Error('GROUP_NOT_FOUND');
error.code = 'GROUP_NOT_FOUND';
error.missingServerIds = missingGroups;
throw error;
}
const previousAssignments = selectAssignmentsByUser.all(numericUserId);
const applyAssignments = db.transaction(() => {
deleteUserServerAssignments.run(numericUserId);
Array.from(normalizedAssignments.values()).forEach((assignment) => {
upsertUserServerAssignment.run(
numericUserId,
assignment.serverId,
assignment.groupId,
assignment.useGlobalGroup ? 1 : 0
);
});
});
applyAssignments();
const updatedAssignments = selectAssignmentsByUser.all(numericUserId);
const sanitizedUpdated = updatedAssignments.map(sanitizeAssignment);
logEvent({
category: 'benutzer',
eventType: 'benutzer-server-zuordnung-aktualisiert',
action: 'aktualisieren',
status: 'erfolgreich',
entityType: 'benutzer',
entityId: String(numericUserId),
entityName: userRecord?.username ?? `ID ${numericUserId}`,
message: `Serverzuordnungen für Benutzer "${userRecord?.username ?? numericUserId}" aktualisiert`,
metadata: {
assignments: sanitizedUpdated.map((entry) => ({
serverId: entry.serverId,
serverName: entry.serverName,
useGlobalGroup: entry.useGlobalGroup,
groupId: entry.groupId,
groupName: entry.groupName
})),
previousAssignments: previousAssignments.map((entry) => ({
serverId: entry.server_id,
serverName: entry.server_name,
useGlobalGroup: Boolean(entry.use_global_group),
groupId: entry.group_id,
groupName: entry.group_name || null
}))
},
...resolveActorFields(actor)
});
return sanitizedUpdated;
}
const deleteMembershipsStatement = db.prepare(`
DELETE FROM user_group_memberships
WHERE user_id = ?
`);
const deleteUserStatement = db.prepare(`
DELETE FROM users
WHERE id = ?
`);
export function deleteUser(userId, options = {}) {
const actor = options.actor || null;
const numericUserId = Number(userId);
if (!Number.isFinite(numericUserId) || numericUserId <= 0) {
const error = new Error('INVALID_USER_ID');
error.code = 'INVALID_USER_ID';
throw error;
}
const existingUser = selectUserCredentialsById.get(numericUserId);
if (!existingUser) {
const error = new Error('USER_NOT_FOUND');
error.code = 'USER_NOT_FOUND';
throw error;
}
const superuserMembership = isUserInGroupStatement.get(numericUserId, SUPERUSER_GROUP_NAME);
if (superuserMembership && superuserMembership.has_membership) {
const error = new Error('USER_SUPERUSER_PROTECTED');
error.code = 'USER_SUPERUSER_PROTECTED';
throw error;
}
const performDelete = db.transaction(() => {
deleteMembershipsStatement.run(numericUserId);
deleteUserStatement.run(numericUserId);
});
performDelete();
logEvent({
category: 'benutzer',
eventType: 'benutzer-gelöscht',
action: 'gelöscht',
status: 'erfolgreich',
entityType: 'benutzer',
entityId: String(numericUserId),
entityName: existingUser.username ?? `ID ${numericUserId}`,
message: `Benutzer "${existingUser.username ?? numericUserId}" gelöscht`,
metadata: {
email: existingUser.email ?? null
},
...resolveActorFields(actor)
});
}
export function updateUserActiveStatus(userId, isActive, options = {}) {
const actor = options.actor || null;
const numericUserId = Number(userId);
if (!Number.isFinite(numericUserId) || numericUserId <= 0) {
const error = new Error('INVALID_USER_ID');
error.code = 'INVALID_USER_ID';
throw error;
}
const existingUser = selectUserCredentialsById.get(numericUserId);
if (!existingUser) {
const error = new Error('USER_NOT_FOUND');
error.code = 'USER_NOT_FOUND';
throw error;
}
const superuserMembership = isUserInGroupStatement.get(numericUserId, SUPERUSER_GROUP_NAME);
if (superuserMembership && superuserMembership.has_membership && !isActive) {
const error = new Error('USER_SUPERUSER_PROTECTED');
error.code = 'USER_SUPERUSER_PROTECTED';
throw error;
}
const normalizedIsActive = isActive ? 1 : 0;
updateUserActiveStatement.run(normalizedIsActive, numericUserId);
const updated = getUserById(numericUserId);
logEvent({
category: 'benutzer',
eventType: normalizedIsActive ? 'benutzer-aktiviert' : 'benutzer-deaktiviert',
action: 'status-aktualisieren',
status: 'erfolgreich',
entityType: 'benutzer',
entityId: String(numericUserId),
entityName: updated?.username ?? existingUser.username ?? `ID ${numericUserId}`,
message: normalizedIsActive
? `Benutzer "${updated?.username ?? existingUser.username ?? numericUserId}" aktiviert`
: `Benutzer "${updated?.username ?? existingUser.username ?? numericUserId}" deaktiviert`,
metadata: {
isActive: Boolean(normalizedIsActive)
},
...resolveActorFields(actor)
});
return updated;
}