609 lines
18 KiB
JavaScript
609 lines
18 KiB
JavaScript
import { db } from '../db/index.js';
|
|
import { logEvent } from '../logging/eventLogs.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,
|
|
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,
|
|
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 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, created_at, updated_at)
|
|
VALUES (?, ?, ?, ?, ?, 1, 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 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)
|
|
});
|
|
|
|
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 }) => {
|
|
const result = insertUserStatement.run(username, email, passwordHash, passwordSalt, avatarColor);
|
|
const userId = Number(result.lastInsertRowid);
|
|
applyUserGroupAssignments(userId, [groupId]);
|
|
return userId;
|
|
});
|
|
|
|
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 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 userId = insertUserWithGroups({
|
|
username: normalizedUsername,
|
|
email: normalizedEmail,
|
|
passwordHash,
|
|
passwordSalt,
|
|
avatarColor: avatarColorToPersist,
|
|
groupId: numericGroupId
|
|
});
|
|
|
|
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 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;
|
|
}
|