This commit is contained in:
root
2025-10-28 16:07:38 +00:00
parent e96cd63f33
commit 4eea4b23bd
16 changed files with 907 additions and 230 deletions
+4 -4
View File
@@ -3,7 +3,7 @@ import { db } from '../db/index.js';
export const SUPERUSER_GROUP_NAME = 'superuser';
const AVATAR_COLORS = [
export const AVATAR_COLORS = [
'bg-arcticBlue-600',
'bg-copperRust-500',
'bg-sunsetCoral-600',
@@ -16,10 +16,10 @@ const AVATAR_COLORS = [
'bg-mossGreen-400'
];
const DEFAULT_AVATAR_COLOR = 'bg-mossGreen-500';
export const DEFAULT_AVATAR_COLOR = 'bg-mossGreen-500';
const AVATAR_COLOR_SET = new Set([...AVATAR_COLORS, DEFAULT_AVATAR_COLOR]);
const pickRandomAvatarColor = () => {
export const pickRandomAvatarColor = () => {
if (!Array.isArray(AVATAR_COLORS) || AVATAR_COLORS.length === 0) {
return DEFAULT_AVATAR_COLOR;
}
@@ -27,7 +27,7 @@ const pickRandomAvatarColor = () => {
return AVATAR_COLORS[index] ?? DEFAULT_AVATAR_COLOR;
};
const normalizeAvatarColor = (value) => {
export const normalizeAvatarColor = (value) => {
if (!value) return null;
const candidate = String(value).trim();
return AVATAR_COLOR_SET.has(candidate) ? candidate : null;
+116 -1
View File
@@ -27,7 +27,7 @@ const createUsersTable = `
CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
username TEXT NOT NULL UNIQUE,
email TEXT NOT NULL UNIQUE,
email TEXT UNIQUE,
password_hash TEXT NOT NULL,
password_salt TEXT,
avatar_color TEXT,
@@ -59,6 +59,28 @@ CREATE TABLE IF NOT EXISTS user_group_memberships (
);
`;
const createPermissionsTable = `
CREATE TABLE IF NOT EXISTS permissions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
key TEXT NOT NULL UNIQUE,
description TEXT,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
`;
const createUserGroupPermissionsTable = `
CREATE TABLE IF NOT EXISTS user_group_permissions (
group_id INTEGER NOT NULL,
permission_id INTEGER NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (group_id, permission_id),
FOREIGN KEY (group_id) REFERENCES user_groups(id) ON DELETE CASCADE,
FOREIGN KEY (permission_id) REFERENCES permissions(id) ON DELETE CASCADE
);
`;
const createServersTable = `
CREATE TABLE IF NOT EXISTS servers (
@@ -84,6 +106,46 @@ CREATE TABLE IF NOT EXISTS endpoints (
);
`;
const createUserServerPermissionOverridesTable = `
CREATE TABLE IF NOT EXISTS user_server_permission_overrides (
user_id INTEGER NOT NULL,
server_id INTEGER NOT NULL,
permission_id INTEGER NOT NULL,
change_type TEXT NOT NULL CHECK (change_type IN ('ADD', 'REMOVE')),
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
UNIQUE (user_id, server_id, permission_id),
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
FOREIGN KEY (server_id) REFERENCES servers(id) ON DELETE CASCADE,
FOREIGN KEY (permission_id) REFERENCES permissions(id) ON DELETE CASCADE
);
`;
const createUserEndpointPermissionOverridesTable = `
CREATE TABLE IF NOT EXISTS user_endpoint_permission_overrides (
user_id INTEGER NOT NULL,
endpoint_id INTEGER NOT NULL,
permission_id INTEGER NOT NULL,
change_type TEXT NOT NULL CHECK (change_type IN ('ADD', 'REMOVE')),
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
UNIQUE (user_id, endpoint_id, permission_id),
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
FOREIGN KEY (endpoint_id) REFERENCES endpoints(id) ON DELETE CASCADE,
FOREIGN KEY (permission_id) REFERENCES permissions(id) ON DELETE CASCADE
);
`;
const createUserServerPermissionOverridesIndex = `
CREATE INDEX IF NOT EXISTS idx_user_server_permission_overrides_user_server
ON user_server_permission_overrides (user_id, server_id);
`;
const createUserEndpointPermissionOverridesIndex = `
CREATE INDEX IF NOT EXISTS idx_user_endpoint_permission_overrides_user_endpoint
ON user_endpoint_permission_overrides (user_id, endpoint_id);
`;
const createServerApiKeysTable = `
CREATE TABLE IF NOT EXISTS server_api_keys (
@@ -138,6 +200,44 @@ try {
db.exec('ALTER TABLE users ADD COLUMN avatar_color TEXT');
console.log('️ avatar_color column hinzugefügt');
}
const emailColumn = userColumns.find((column) => column.name === 'email');
if (emailColumn && emailColumn.notnull === 1) {
try {
db.exec('PRAGMA foreign_keys = OFF;');
db.exec('DROP TABLE IF EXISTS users_backup;');
db.exec('ALTER TABLE users RENAME TO users_backup;');
db.exec(`
CREATE TABLE users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
username TEXT NOT NULL UNIQUE,
email TEXT UNIQUE,
password_hash TEXT NOT NULL,
password_salt TEXT,
avatar_color TEXT,
is_active INTEGER NOT NULL DEFAULT 1,
last_login DATETIME,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
`);
db.exec(`
INSERT INTO users (id, username, email, password_hash, password_salt, avatar_color, is_active, last_login, created_at, updated_at)
SELECT id, username, email, password_hash, password_salt, avatar_color, is_active, last_login, created_at, updated_at
FROM users_backup;
`);
db.exec('DROP TABLE IF EXISTS users_backup;');
console.log('️ users.email Spalte erlaubt jetzt NULL-Werte');
} catch (migrationError) {
console.error('⚠️ Umbau der users Tabelle fehlgeschlagen:', migrationError.message);
try {
db.exec('ALTER TABLE users_backup RENAME TO users;');
} catch (restoreError) {
console.error('⚠️ Konnte ursprüngliche users Tabelle nicht wiederherstellen:', restoreError.message);
}
} finally {
db.exec('PRAGMA foreign_keys = ON;');
}
}
} catch (err) {
console.error('⚠️ Konnte avatar_color Spalte nicht prüfen/erstellen:', err.message);
}
@@ -148,12 +248,27 @@ console.log('✅ user_groups table ready');
db.exec(createUserGroupMembershipsTable);
console.log('✅ user_group_memberships table ready');
db.exec(createPermissionsTable);
console.log('✅ permissions table ready');
db.exec(createUserGroupPermissionsTable);
console.log('✅ user_group_permissions table ready');
db.exec(createServersTable);
console.log('✅ servers table ready');
db.exec(createEndpointsTable);
console.log('✅ endpoints table ready');
db.exec(createUserServerPermissionOverridesTable);
console.log('✅ user_server_permission_overrides table ready');
db.exec(createUserEndpointPermissionOverridesTable);
console.log('✅ user_endpoint_permission_overrides table ready');
db.exec(createUserServerPermissionOverridesIndex);
db.exec(createUserEndpointPermissionOverridesIndex);
db.exec(createServerApiKeysTable);
console.log('✅ server_api_keys table ready');
+50
View File
@@ -16,6 +16,34 @@ const selectGroupsWithMembers = db.prepare(`
ORDER BY g.name COLLATE NOCASE
`);
const selectGroupWithMembersById = db.prepare(`
SELECT
g.id,
g.name,
g.description,
g.created_at,
g.updated_at,
COUNT(DISTINCT u.id) AS member_count,
GROUP_CONCAT(u.id || '::' || u.username, '|||') AS member_pairs
FROM user_groups g
LEFT JOIN user_group_memberships m ON m.group_id = g.id
LEFT JOIN users u ON u.id = m.user_id
WHERE g.id = ?
GROUP BY g.id
`);
const selectGroupIdByName = db.prepare(`
SELECT id
FROM user_groups
WHERE lower(name) = lower(?)
LIMIT 1
`);
const insertGroupStatement = db.prepare(`
INSERT INTO user_groups (name, description, created_at, updated_at)
VALUES (?, ?, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
`);
const parseMembers = (rawPairs) => {
if (!rawPairs) {
return [];
@@ -48,3 +76,25 @@ export function listGroups() {
const rows = selectGroupsWithMembers.all();
return rows.map(sanitizeGroup);
}
export function createGroup({ name, description }) {
const normalizedName = typeof name === 'string' ? name.trim() : '';
if (!normalizedName) {
const error = new Error('GROUP_NAME_REQUIRED');
error.code = 'GROUP_NAME_REQUIRED';
throw error;
}
const existing = selectGroupIdByName.get(normalizedName);
if (existing) {
const error = new Error('GROUP_NAME_TAKEN');
error.code = 'GROUP_NAME_TAKEN';
throw error;
}
const normalizedDescription = typeof description === 'string' ? description.trim() : null;
const insertResult = insertGroupStatement.run(normalizedName, normalizedDescription || null);
const groupId = Number(insertResult.lastInsertRowid);
const row = selectGroupWithMembersById.get(groupId);
return row ? sanitizeGroup(row) : null;
}
+46 -2
View File
@@ -45,8 +45,8 @@ import {
removeServer,
setServerApiKey
} from './setup/index.js';
import { listUsers, getUserById, updateUserGroups } from './users/index.js';
import { listGroups } from './groups/index.js';
import { listUsers, getUserById, updateUserGroups, createUser } from './users/index.js';
import { listGroups, createGroup } from './groups/index.js';
dotenv.config();
@@ -1684,6 +1684,33 @@ app.get('/api/users', (req, res) => {
}
});
app.post('/api/users', (req, res) => {
const { username, email, password, groupId, avatarColor } = req.body ?? {};
try {
const user = createUser({ username, email, password, groupId, avatarColor });
res.status(201).json({ item: user });
} catch (error) {
if (error.code === 'USERNAME_REQUIRED' || error.code === 'INVALID_GROUP_ID') {
return res.status(400).json({ error: error.code });
}
if (error.code === 'GROUP_NOT_FOUND') {
return res.status(400).json({ error: 'GROUP_NOT_FOUND' });
}
if (error.code === 'INVALID_EMAIL') {
return res.status(400).json({ error: 'INVALID_EMAIL' });
}
if (error.code === 'INVALID_PASSWORD' || error.code === 'PASSWORD_TOO_SHORT') {
return res.status(400).json({ error: error.code });
}
if (error.code === 'USERNAME_TAKEN' || error.code === 'EMAIL_TAKEN') {
return res.status(409).json({ error: error.code });
}
console.error('⚠️ [Users] Anlegen eines Benutzers fehlgeschlagen:', error);
res.status(500).json({ error: 'USER_CREATE_FAILED' });
}
});
app.get('/api/users/:userId', (req, res) => {
const { userId } = req.params;
const numericId = Number(userId);
@@ -1741,6 +1768,23 @@ app.get('/api/groups', (req, res) => {
}
});
app.post('/api/groups', (req, res) => {
const { name, description } = req.body ?? {};
try {
const group = createGroup({ name, description });
res.status(201).json({ item: group });
} catch (error) {
if (error.code === 'GROUP_NAME_REQUIRED') {
return res.status(400).json({ error: 'GROUP_NAME_REQUIRED' });
}
if (error.code === 'GROUP_NAME_TAKEN') {
return res.status(409).json({ error: 'GROUP_NAME_TAKEN' });
}
console.error('⚠️ [Groups] Anlegen der Benutzergruppe fehlgeschlagen:', error);
res.status(500).json({ error: 'GROUP_CREATE_FAILED' });
}
});
// Superuser Setup
app.get('/api/auth/superuser/status', (req, res) => {
const exists = hasSuperuser();
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+2 -2
View File
@@ -34,8 +34,8 @@
<script defer data-site="YOUR_DOMAIN_HERE" src="https://api.nepcha.com/js/nepcha-analytics.js"></script>
<script type="module" crossorigin src="/assets/index-ec92bae1.js"></script>
<link rel="stylesheet" href="/assets/index-ecd5fb94.css">
<script type="module" crossorigin src="/assets/index-665b980d.js"></script>
<link rel="stylesheet" href="/assets/index-5f261614.css">
</head>
<body>
<div id="root"></div>
+106 -1
View File
@@ -1,4 +1,10 @@
import { db } from '../db/index.js';
import {
hashPassword,
normalizeAvatarColor,
pickRandomAvatarColor,
DEFAULT_AVATAR_COLOR
} from '../auth/superuser.js';
const selectUsersWithGroups = db.prepare(`
SELECT
@@ -36,6 +42,9 @@ const selectUserWithGroupsById = db.prepare(`
GROUP BY u.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 = ?
@@ -48,6 +57,11 @@ const insertMembershipForUser = db.prepare(`
const selectGroupById = db.prepare('SELECT id, name FROM user_groups WHERE id = ?');
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 parseGroupPairs = (rawPairs) => {
if (!rawPairs) {
return [];
@@ -72,7 +86,7 @@ const parseGroupPairs = (rawPairs) => {
const sanitizeUserRecord = (row) => ({
id: row.id,
username: row.username,
email: row.email,
email: row.email || null,
isActive: Boolean(row.is_active),
avatarColor: row.avatar_color || null,
lastLogin: row.last_login || null,
@@ -98,6 +112,97 @@ const applyUserGroupAssignments = db.transaction((userId, groupIds) => {
});
});
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 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 }) {
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;
}
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
});
return getUserById(userId);
}
export function updateUserGroups(userId, groupIds) {
const numericUserId = Number(userId);
if (!Number.isFinite(numericUserId) || numericUserId <= 0) {