Merge dev into master
This commit is contained in:
@@ -1,3 +1,6 @@
|
||||
PORTAINER_URL=Your_Portainer_Server_Adress
|
||||
PORTAINER_API_KEY=Your_Portainer_API_Key
|
||||
PORTAINER_ENDPOINT_ID=Your_Portainer_Endpoint_ID
|
||||
PORTAINER_URL=Your_Portainer_Server_Adress (optional)
|
||||
PORTAINER_API_KEY=Your_Portainer_API_Key (optional)
|
||||
SUPERUSER_USERNAME=Your_Superuser_Username (optional)
|
||||
SUPERUSER_EMAIL=Your_Superuser_Email (optional)
|
||||
SUPERUSER_PASSWORD=Your_Superuser_Password (optional)
|
||||
SELF_STACK_ID=Your_StackPulse_Stack_ID (optional)
|
||||
@@ -0,0 +1,300 @@
|
||||
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
|
||||
};
|
||||
}
|
||||
+200
@@ -0,0 +1,200 @@
|
||||
-- Stackpulse database schema blueprint
|
||||
-- This document captures the expected structure of all tables, indexes and core seed requirements.
|
||||
|
||||
CREATE TABLE event_logs (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
timestamp DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
category TEXT NOT NULL,
|
||||
event_type TEXT,
|
||||
action TEXT,
|
||||
status TEXT,
|
||||
severity TEXT,
|
||||
entity_type TEXT,
|
||||
entity_id TEXT,
|
||||
entity_name TEXT,
|
||||
actor_type TEXT,
|
||||
actor_id TEXT,
|
||||
actor_name TEXT,
|
||||
source TEXT,
|
||||
context_type TEXT,
|
||||
context_id TEXT,
|
||||
context_label TEXT,
|
||||
message TEXT,
|
||||
metadata TEXT
|
||||
);
|
||||
CREATE INDEX idx_event_logs_timestamp ON event_logs (timestamp DESC);
|
||||
CREATE INDEX idx_event_logs_category ON event_logs (category);
|
||||
CREATE INDEX idx_event_logs_event_type ON event_logs (event_type);
|
||||
CREATE INDEX idx_event_logs_entity ON event_logs (entity_type, entity_id);
|
||||
CREATE INDEX idx_event_logs_context ON event_logs (context_type, context_id);
|
||||
|
||||
CREATE TABLE settings (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT,
|
||||
updated_at TEXT DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
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,
|
||||
security_phrase_content TEXT,
|
||||
security_phrase_iv TEXT,
|
||||
security_phrase_tag TEXT,
|
||||
security_phrase_downloaded_at DATETIME,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE TABLE user_groups (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL UNIQUE,
|
||||
description TEXT,
|
||||
avatar_color TEXT,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE TABLE user_group_memberships (
|
||||
user_id INTEGER NOT NULL,
|
||||
group_id INTEGER NOT NULL,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (user_id, group_id),
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (group_id) REFERENCES user_groups(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE TABLE 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
|
||||
);
|
||||
|
||||
CREATE TABLE 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
|
||||
);
|
||||
|
||||
CREATE TABLE permission_sections (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
key TEXT NOT NULL UNIQUE,
|
||||
title TEXT NOT NULL,
|
||||
sort_order INTEGER NOT NULL DEFAULT 0,
|
||||
has_navigation_flag INTEGER NOT NULL DEFAULT 0,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE TABLE permission_groups (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
section_id INTEGER NOT NULL,
|
||||
key TEXT NOT NULL,
|
||||
title TEXT NOT NULL,
|
||||
sort_order INTEGER NOT NULL DEFAULT 0,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE(section_id, key),
|
||||
FOREIGN KEY (section_id) REFERENCES permission_sections(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE TABLE permission_items (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
section_id INTEGER NOT NULL,
|
||||
group_id INTEGER,
|
||||
key TEXT NOT NULL UNIQUE,
|
||||
label TEXT NOT NULL,
|
||||
sort_order INTEGER NOT NULL DEFAULT 0,
|
||||
default_level TEXT NOT NULL,
|
||||
available_levels TEXT NOT NULL,
|
||||
is_required INTEGER NOT NULL DEFAULT 0,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (section_id) REFERENCES permission_sections(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (group_id) REFERENCES permission_groups(id) ON DELETE SET NULL
|
||||
);
|
||||
|
||||
CREATE TABLE permission_dependencies (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
permission_id INTEGER NOT NULL,
|
||||
depends_on_permission_id INTEGER NOT NULL,
|
||||
required_level TEXT DEFAULT NULL,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (permission_id) REFERENCES permission_items(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (depends_on_permission_id) REFERENCES permission_items(id) ON DELETE CASCADE,
|
||||
UNIQUE(permission_id, depends_on_permission_id)
|
||||
);
|
||||
CREATE INDEX idx_permission_dependencies_required
|
||||
ON permission_dependencies (permission_id, depends_on_permission_id);
|
||||
|
||||
CREATE TABLE group_permission_values (
|
||||
group_id INTEGER NOT NULL,
|
||||
permission_id INTEGER NOT NULL,
|
||||
level TEXT NOT NULL,
|
||||
effective_level TEXT,
|
||||
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 permission_items(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE TABLE servers (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL,
|
||||
url TEXT NOT NULL UNIQUE,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE TABLE 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
|
||||
);
|
||||
CREATE INDEX idx_user_server_permission_overrides_user_server
|
||||
ON user_server_permission_overrides (user_id, server_id);
|
||||
|
||||
CREATE TABLE server_api_keys (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
server_id INTEGER NOT NULL UNIQUE,
|
||||
key_cipher TEXT NOT NULL,
|
||||
key_iv TEXT NOT NULL,
|
||||
key_tag TEXT NOT NULL,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (server_id) REFERENCES servers(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE TABLE user_settings (
|
||||
user_id INTEGER NOT NULL,
|
||||
setting_key TEXT NOT NULL,
|
||||
setting_value TEXT,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (user_id, setting_key),
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
-- Core seed expectations (keys only – maintained via schemaEnsure)
|
||||
-- permission_sections: stacks, logs, users, user-groups, maintenance
|
||||
-- permission_items: includes users-security-phrase (available_levels ["full","none"])
|
||||
@@ -0,0 +1,14 @@
|
||||
import { ensureDatabaseSchema } from './schemaEnsure.js';
|
||||
|
||||
const run = async () => {
|
||||
try {
|
||||
ensureDatabaseSchema();
|
||||
console.log('✅ Datenbankschema abgeglichen.');
|
||||
process.exit(0);
|
||||
} catch (error) {
|
||||
console.error('⚠️ Schema-Abgleich fehlgeschlagen:', error);
|
||||
process.exit(1);
|
||||
}
|
||||
};
|
||||
|
||||
run();
|
||||
@@ -1,46 +0,0 @@
|
||||
import { db } from './index.js';
|
||||
|
||||
const createRedeployLogsTable = `
|
||||
CREATE TABLE IF NOT EXISTS redeploy_logs (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
timestamp DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
stack_id TEXT NOT NULL,
|
||||
stack_name TEXT NOT NULL,
|
||||
status TEXT NOT NULL,
|
||||
message TEXT,
|
||||
endpoint INTEGER,
|
||||
redeploy_type TEXT
|
||||
);
|
||||
`;
|
||||
|
||||
|
||||
|
||||
db.exec(createRedeployLogsTable);
|
||||
|
||||
try {
|
||||
const columns = db.prepare('PRAGMA table_info(redeploy_logs)').all();
|
||||
const hasRedeployType = columns.some((column) => column.name === 'redeploy_type');
|
||||
if (!hasRedeployType) {
|
||||
db.exec('ALTER TABLE redeploy_logs ADD COLUMN redeploy_type TEXT');
|
||||
console.log('ℹ️ redeploy_type column hinzugefügt');
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('⚠️ Konnte redeploy_type Spalte nicht prüfen/erstellen:', err.message);
|
||||
}
|
||||
|
||||
console.log('✅ redeploy_logs table ready');
|
||||
|
||||
const createSettingsTable = `
|
||||
CREATE TABLE IF NOT EXISTS settings (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT,
|
||||
updated_at TEXT DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
`;
|
||||
|
||||
db.exec(createSettingsTable);
|
||||
|
||||
console.log('✅ settings table ready');
|
||||
|
||||
|
||||
db.close();
|
||||
@@ -1,181 +0,0 @@
|
||||
import { db } from './index.js';
|
||||
|
||||
const valueToArray = (value) => {
|
||||
if (!value && value !== 0) return [];
|
||||
const base = Array.isArray(value) ? value : [value];
|
||||
return base
|
||||
.flatMap((entry) => String(entry).split(','))
|
||||
.map((entry) => entry.trim())
|
||||
.filter(Boolean);
|
||||
};
|
||||
|
||||
const singleValue = (value) => {
|
||||
if (value === undefined || value === null) return undefined;
|
||||
return Array.isArray(value) ? value[0] : value;
|
||||
};
|
||||
|
||||
export function buildLogFilter(queryParams = {}) {
|
||||
const filters = [];
|
||||
const params = {};
|
||||
|
||||
const ids = valueToArray(queryParams.ids ?? queryParams.id)
|
||||
.map((entry) => Number(entry))
|
||||
.filter((value) => !Number.isNaN(value));
|
||||
if (ids.length) {
|
||||
const placeholders = ids.map((_, idx) => `@id${idx}`);
|
||||
filters.push(`id IN (${placeholders.join(', ')})`);
|
||||
ids.forEach((value, idx) => {
|
||||
params[`id${idx}`] = value;
|
||||
});
|
||||
}
|
||||
|
||||
const stackIds = valueToArray(queryParams.stackIds ?? queryParams.stackId);
|
||||
if (stackIds.length) {
|
||||
const placeholders = stackIds.map((_, idx) => `@stackId${idx}`);
|
||||
filters.push(`stack_id IN (${placeholders.join(', ')})`);
|
||||
stackIds.forEach((stack, idx) => {
|
||||
params[`stackId${idx}`] = stack;
|
||||
});
|
||||
}
|
||||
|
||||
const statuses = valueToArray(queryParams.statuses ?? queryParams.status);
|
||||
if (statuses.length) {
|
||||
const placeholders = statuses.map((_, idx) => `@status${idx}`);
|
||||
filters.push(`status IN (${placeholders.join(', ')})`);
|
||||
statuses.forEach((entry, idx) => {
|
||||
params[`status${idx}`] = entry;
|
||||
});
|
||||
}
|
||||
|
||||
const endpoints = valueToArray(queryParams.endpoints ?? queryParams.endpoint);
|
||||
if (endpoints.length) {
|
||||
const placeholders = endpoints.map((_, idx) => `@endpoint${idx}`);
|
||||
filters.push(`endpoint IN (${placeholders.join(', ')})`);
|
||||
endpoints.forEach((entry, idx) => {
|
||||
const numeric = Number(entry);
|
||||
params[`endpoint${idx}`] = Number.isNaN(numeric) ? entry : numeric;
|
||||
});
|
||||
}
|
||||
|
||||
const redeployTypes = valueToArray(queryParams.redeployTypes ?? queryParams.redeployType);
|
||||
if (redeployTypes.length) {
|
||||
const placeholders = redeployTypes.map((_, idx) => `@redeployType${idx}`);
|
||||
filters.push(`redeploy_type IN (${placeholders.join(', ')})`);
|
||||
redeployTypes.forEach((entry, idx) => {
|
||||
params[`redeployType${idx}`] = entry;
|
||||
});
|
||||
}
|
||||
|
||||
const messageQuery = singleValue(queryParams.message);
|
||||
if (messageQuery && String(messageQuery).trim()) {
|
||||
filters.push('message LIKE @message');
|
||||
params.message = `%${String(messageQuery).trim()}%`;
|
||||
}
|
||||
|
||||
const from = singleValue(queryParams.from);
|
||||
if (from) {
|
||||
filters.push('timestamp >= @from');
|
||||
params.from = from;
|
||||
}
|
||||
|
||||
const to = singleValue(queryParams.to);
|
||||
if (to) {
|
||||
filters.push('timestamp <= @to');
|
||||
params.to = to;
|
||||
}
|
||||
|
||||
return {
|
||||
whereClause: filters.length ? `WHERE ${filters.join(' AND ')}` : '',
|
||||
params,
|
||||
};
|
||||
}
|
||||
|
||||
const insertRedeployLogStmt = db.prepare(`
|
||||
INSERT INTO redeploy_logs (stack_id, stack_name, status, message, endpoint, redeploy_type)
|
||||
VALUES (@stackId, @stackName, @status, @message, @endpoint, @redeployType)
|
||||
`);
|
||||
|
||||
export function logRedeployEvent({ stackId, stackName, status, message = null, endpoint = null, redeployType = null }) {
|
||||
try {
|
||||
insertRedeployLogStmt.run({
|
||||
stackId: String(stackId),
|
||||
stackName: stackName ?? 'Unknown',
|
||||
status,
|
||||
message,
|
||||
endpoint,
|
||||
redeployType: redeployType ?? null
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('❌ Fehler beim Speichern des Redeploy-Logs:', err.message);
|
||||
}
|
||||
}
|
||||
|
||||
export function deleteLogById(id) {
|
||||
const stmt = db.prepare('DELETE FROM redeploy_logs WHERE id = ?');
|
||||
const info = stmt.run(id);
|
||||
return info.changes || 0;
|
||||
}
|
||||
|
||||
export function deleteLogsByFilters(queryParams = {}) {
|
||||
const { whereClause, params } = buildLogFilter(queryParams);
|
||||
const stmt = db.prepare(`DELETE FROM redeploy_logs ${whereClause}`);
|
||||
const info = stmt.run(params);
|
||||
return info.changes || 0;
|
||||
}
|
||||
|
||||
export function exportLogsByFilters(queryParams = {}, format = 'txt') {
|
||||
const { whereClause, params } = buildLogFilter(queryParams);
|
||||
const rows = db.prepare(`
|
||||
SELECT id, timestamp, stack_id AS stackId, stack_name AS stackName, status, message, endpoint, redeploy_type AS redeployType
|
||||
FROM redeploy_logs
|
||||
${whereClause}
|
||||
ORDER BY datetime(timestamp) DESC
|
||||
`).all(params);
|
||||
|
||||
const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
|
||||
|
||||
if (format === 'sql') {
|
||||
const statements = rows.map((row) => {
|
||||
const columns = ['id', 'timestamp', 'stack_id', 'stack_name', 'status', 'message', 'endpoint', 'redeploy_type'];
|
||||
const values = [
|
||||
row.id,
|
||||
row.timestamp,
|
||||
row.stackId,
|
||||
row.stackName,
|
||||
row.status,
|
||||
row.message,
|
||||
row.endpoint,
|
||||
row.redeployType
|
||||
].map((value) => {
|
||||
if (value === null || value === undefined) return 'NULL';
|
||||
return `'${String(value).replace(/'/g, "''")}'`;
|
||||
});
|
||||
return `INSERT INTO redeploy_logs (${columns.join(', ')}) VALUES (${values.join(', ')});`;
|
||||
});
|
||||
|
||||
return {
|
||||
filename: `redeploy-logs-${timestamp}.sql`,
|
||||
contentType: 'application/sql; charset=utf-8',
|
||||
content: statements.join('\n')
|
||||
};
|
||||
}
|
||||
|
||||
const lines = rows.map((row) => {
|
||||
const parts = [
|
||||
`[${row.id}]`,
|
||||
row.timestamp,
|
||||
`Stack: ${row.stackName ?? 'Unbekannt'} (ID: ${row.stackId})`,
|
||||
`Status: ${row.status}`,
|
||||
`Endpoint: ${row.endpoint ?? '-'}`,
|
||||
`Nachricht: ${row.message ?? '-'}`,
|
||||
`Redeploy: ${row.redeployType ?? '-'}`
|
||||
];
|
||||
return parts.join(' | ');
|
||||
});
|
||||
|
||||
return {
|
||||
filename: `redeploy-logs-${timestamp}.txt`,
|
||||
contentType: 'text/plain; charset=utf-8',
|
||||
content: lines.join('\n')
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,738 @@
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
import { db } from './index.js';
|
||||
|
||||
const BLUEPRINT_FILENAME = 'dbs';
|
||||
const PERMISSION_BLUEPRINT = [
|
||||
{
|
||||
key: 'stacks',
|
||||
title: 'Stacks',
|
||||
sortOrder: 0,
|
||||
hasNavigation: 0,
|
||||
items: [
|
||||
{
|
||||
key: 'stacks-redeploy-single',
|
||||
label: 'Redeploy einzeln',
|
||||
sortOrder: 0,
|
||||
defaultLevel: 'none',
|
||||
levels: ['full', 'none'],
|
||||
dependencies: []
|
||||
},
|
||||
{
|
||||
key: 'stacks-redeploy-selection',
|
||||
label: 'Redeploy Auswahl',
|
||||
sortOrder: 1,
|
||||
defaultLevel: 'none',
|
||||
levels: ['full', 'none'],
|
||||
dependencies: []
|
||||
},
|
||||
{
|
||||
key: 'stacks-redeploy-all',
|
||||
label: 'Redeploy Alle',
|
||||
sortOrder: 2,
|
||||
defaultLevel: 'none',
|
||||
levels: ['full', 'none'],
|
||||
dependencies: []
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
key: 'logs',
|
||||
title: 'Logs',
|
||||
sortOrder: 1,
|
||||
hasNavigation: 1,
|
||||
items: [
|
||||
{
|
||||
key: 'logs-access',
|
||||
label: 'Bereich & Navigation',
|
||||
sortOrder: 0,
|
||||
defaultLevel: 'none',
|
||||
levels: ['full', 'none'],
|
||||
isRequired: 1,
|
||||
dependencies: []
|
||||
},
|
||||
{
|
||||
key: 'logs-export',
|
||||
label: 'Logs Exportieren',
|
||||
sortOrder: 1,
|
||||
defaultLevel: 'none',
|
||||
levels: ['full', 'none'],
|
||||
dependencies: [
|
||||
{ dependsOnKey: 'logs-access', requiredLevel: '!=none' }
|
||||
]
|
||||
},
|
||||
{
|
||||
key: 'logs-delete',
|
||||
label: 'Logs löschen',
|
||||
sortOrder: 2,
|
||||
defaultLevel: 'none',
|
||||
levels: ['full', 'none'],
|
||||
dependencies: [
|
||||
{ dependsOnKey: 'logs-access', requiredLevel: '!=none' }
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
key: 'users',
|
||||
title: 'Benutzer',
|
||||
sortOrder: 2,
|
||||
hasNavigation: 1,
|
||||
items: [
|
||||
{
|
||||
key: 'users-access',
|
||||
label: 'Bereich & Navigation',
|
||||
sortOrder: 0,
|
||||
defaultLevel: 'none',
|
||||
levels: ['full', 'none'],
|
||||
isRequired: 1,
|
||||
dependencies: []
|
||||
},
|
||||
{
|
||||
key: 'users-edit',
|
||||
label: 'Benutzer bearbeiten',
|
||||
sortOrder: 1,
|
||||
defaultLevel: 'read',
|
||||
levels: ['full', 'read'],
|
||||
dependencies: [
|
||||
{ dependsOnKey: 'users-access', requiredLevel: '!=none' }
|
||||
]
|
||||
},
|
||||
{
|
||||
key: 'users-delete',
|
||||
label: 'Benutzer löschen',
|
||||
sortOrder: 2,
|
||||
defaultLevel: 'none',
|
||||
levels: ['full', 'none'],
|
||||
dependencies: [
|
||||
{ dependsOnKey: 'users-access', requiredLevel: '!=none' }
|
||||
]
|
||||
},
|
||||
{
|
||||
key: 'users-security-phrase',
|
||||
label: 'Sicherheitsschlüssel',
|
||||
sortOrder: 3,
|
||||
defaultLevel: 'none',
|
||||
levels: ['full', 'none'],
|
||||
dependencies: [
|
||||
{ dependsOnKey: 'users-access', requiredLevel: '!=none' }
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
key: 'user-groups',
|
||||
title: 'Benutzergruppen',
|
||||
sortOrder: 3,
|
||||
hasNavigation: 1,
|
||||
items: [
|
||||
{
|
||||
key: 'user-groups-access',
|
||||
label: 'Bereich & Navigation',
|
||||
sortOrder: 0,
|
||||
defaultLevel: 'none',
|
||||
levels: ['full', 'none'],
|
||||
isRequired: 1,
|
||||
dependencies: []
|
||||
},
|
||||
{
|
||||
key: 'user-groups-edit',
|
||||
label: 'Benutzergruppen bearbeiten',
|
||||
sortOrder: 1,
|
||||
defaultLevel: 'read',
|
||||
levels: ['full', 'read'],
|
||||
dependencies: [
|
||||
{ dependsOnKey: 'user-groups-access', requiredLevel: '!=none' }
|
||||
]
|
||||
},
|
||||
{
|
||||
key: 'user-groups-delete',
|
||||
label: 'Benutzergruppen löschen',
|
||||
sortOrder: 2,
|
||||
defaultLevel: 'none',
|
||||
levels: ['full', 'none'],
|
||||
dependencies: [
|
||||
{ dependsOnKey: 'user-groups-access', requiredLevel: '!=none' }
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
key: 'maintenance',
|
||||
title: 'Wartung',
|
||||
sortOrder: 4,
|
||||
hasNavigation: 1,
|
||||
items: [
|
||||
{
|
||||
key: 'maintenance-access',
|
||||
label: 'Bereich & Navigation',
|
||||
sortOrder: 0,
|
||||
defaultLevel: 'none',
|
||||
levels: ['full', 'none'],
|
||||
isRequired: 1,
|
||||
dependencies: []
|
||||
}
|
||||
],
|
||||
groups: [
|
||||
{
|
||||
key: 'maintenance-server-group',
|
||||
title: 'Server',
|
||||
sortOrder: 0,
|
||||
items: [
|
||||
{
|
||||
key: 'maintenance-server-manage',
|
||||
label: 'Server-Sektion',
|
||||
sortOrder: 0,
|
||||
defaultLevel: 'none',
|
||||
levels: ['full', 'read', 'none'],
|
||||
dependencies: [
|
||||
{ dependsOnKey: 'maintenance-access', requiredLevel: '!=none' }
|
||||
]
|
||||
},
|
||||
{
|
||||
key: 'maintenance-server-delete',
|
||||
label: 'Server löschen',
|
||||
sortOrder: 1,
|
||||
defaultLevel: 'none',
|
||||
levels: ['full', 'none'],
|
||||
dependencies: [
|
||||
{ dependsOnKey: 'maintenance-access', requiredLevel: '!=none' },
|
||||
{ dependsOnKey: 'maintenance-server-manage', requiredLevel: '!=none' }
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
key: 'maintenance-superuser-group',
|
||||
title: 'Superuser',
|
||||
sortOrder: 1,
|
||||
items: [
|
||||
{
|
||||
key: 'maintenance-superuser-delete',
|
||||
label: 'Superuser löschen',
|
||||
sortOrder: 0,
|
||||
defaultLevel: 'none',
|
||||
levels: ['full', 'none'],
|
||||
dependencies: [
|
||||
{ dependsOnKey: 'maintenance-access', requiredLevel: '!=none' }
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
key: 'maintenance-portainer-group',
|
||||
title: 'Portainer',
|
||||
sortOrder: 2,
|
||||
items: [
|
||||
{
|
||||
key: 'maintenance-portainer',
|
||||
label: 'Portainer-Sektion',
|
||||
sortOrder: 0,
|
||||
defaultLevel: 'none',
|
||||
levels: ['full', 'none'],
|
||||
dependencies: [
|
||||
{ dependsOnKey: 'maintenance-access', requiredLevel: '!=none' }
|
||||
]
|
||||
},
|
||||
{
|
||||
key: 'maintenance-ssh-update',
|
||||
label: 'SSH/Update-Skript',
|
||||
sortOrder: 1,
|
||||
defaultLevel: 'none',
|
||||
levels: ['full', 'read', 'none'],
|
||||
dependencies: [
|
||||
{ dependsOnKey: 'maintenance-access', requiredLevel: '!=none' },
|
||||
{ dependsOnKey: 'maintenance-portainer', requiredLevel: '!=none' }
|
||||
]
|
||||
},
|
||||
{
|
||||
key: 'maintenance-update',
|
||||
label: 'Update durchführen',
|
||||
sortOrder: 2,
|
||||
defaultLevel: 'none',
|
||||
levels: ['full', 'none'],
|
||||
dependencies: [
|
||||
{ dependsOnKey: 'maintenance-access', requiredLevel: '!=none' },
|
||||
{ dependsOnKey: 'maintenance-portainer', requiredLevel: '!=none' }
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
key: 'maintenance-duplicates-group',
|
||||
title: 'Doppelte Stacks',
|
||||
sortOrder: 3,
|
||||
items: [
|
||||
{
|
||||
key: 'maintenance-duplicates',
|
||||
label: 'Doppelte Stacks',
|
||||
sortOrder: 0,
|
||||
defaultLevel: 'none',
|
||||
levels: ['full', 'read', 'none'],
|
||||
dependencies: [
|
||||
{ dependsOnKey: 'maintenance-access', requiredLevel: '!=none' }
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
];
|
||||
|
||||
const TABLE_REGEX = /^CREATE TABLE\s+([^\s(]+)\s*\(/i;
|
||||
const INDEX_REGEX = /^CREATE INDEX\s+([^\s(]+)\s+ON\s+([^\s(]+)\s*\(/i;
|
||||
const COLUMN_SKIP_PREFIXES = ['PRIMARY KEY', 'FOREIGN KEY', 'UNIQUE', 'CHECK', 'CONSTRAINT'];
|
||||
|
||||
const normalizeIdentifier = (value) => value.replace(/`|"/g, '').trim();
|
||||
|
||||
const loadBlueprintStatements = () => {
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
const filePath = path.join(__dirname, BLUEPRINT_FILENAME);
|
||||
const content = fs.readFileSync(filePath, 'utf8');
|
||||
const cleanContent = content
|
||||
.split('\n')
|
||||
.map((line) => line.trim())
|
||||
.filter((line) => line && !line.startsWith('--'))
|
||||
.join('\n');
|
||||
|
||||
return cleanContent
|
||||
.split(';')
|
||||
.map((statement) => statement.trim())
|
||||
.filter(Boolean);
|
||||
};
|
||||
|
||||
const parseTableDefinitions = (statements) => {
|
||||
const tables = new Map();
|
||||
|
||||
statements.forEach((statement) => {
|
||||
const match = statement.match(TABLE_REGEX);
|
||||
if (!match) {
|
||||
return;
|
||||
}
|
||||
|
||||
const tableName = normalizeIdentifier(match[1]);
|
||||
const body = statement.slice(statement.indexOf('(') + 1, statement.lastIndexOf(')'));
|
||||
const lines = body
|
||||
.split('\n')
|
||||
.map((line) => line.trim())
|
||||
.filter(Boolean);
|
||||
|
||||
const columns = [];
|
||||
lines.forEach((line) => {
|
||||
const cleaned = line.replace(/,$/, '');
|
||||
const upper = cleaned.toUpperCase();
|
||||
if (COLUMN_SKIP_PREFIXES.some((prefix) => upper.startsWith(prefix))) {
|
||||
return;
|
||||
}
|
||||
const [rawName, ...restParts] = cleaned.split(/\s+/);
|
||||
if (!rawName || restParts.length === 0) {
|
||||
return;
|
||||
}
|
||||
const columnName = normalizeIdentifier(rawName);
|
||||
const definition = restParts.join(' ');
|
||||
columns.push({ name: columnName, definition });
|
||||
});
|
||||
|
||||
tables.set(tableName, {
|
||||
createStatement: statement,
|
||||
columns
|
||||
});
|
||||
});
|
||||
|
||||
return tables;
|
||||
};
|
||||
|
||||
const parseIndexStatements = (statements) => {
|
||||
const indexes = [];
|
||||
statements.forEach((statement) => {
|
||||
const match = statement.match(INDEX_REGEX);
|
||||
if (!match) {
|
||||
return;
|
||||
}
|
||||
const indexName = normalizeIdentifier(match[1]);
|
||||
indexes.push({
|
||||
name: indexName,
|
||||
statement
|
||||
});
|
||||
});
|
||||
return indexes;
|
||||
};
|
||||
|
||||
const tableExists = (tableName) => {
|
||||
const result = db
|
||||
.prepare(`SELECT name FROM sqlite_master WHERE type = 'table' AND name = ? LIMIT 1`)
|
||||
.get(tableName);
|
||||
return Boolean(result);
|
||||
};
|
||||
|
||||
const dropDeprecatedArtifacts = () => {
|
||||
const deprecatedTables = ['user_endpoint_permission_overrides', 'endpoints'];
|
||||
deprecatedTables.forEach((table) => {
|
||||
if (tableExists(table)) {
|
||||
db.exec(`DROP TABLE IF EXISTS ${table}`);
|
||||
console.log(`ℹ️ Veraltete Tabelle ${table} entfernt`);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const getExistingColumns = (tableName) => {
|
||||
const pragma = db.prepare(`PRAGMA table_info(${tableName})`).all();
|
||||
return new Set(pragma.map((column) => column.name));
|
||||
};
|
||||
|
||||
const indexExists = (indexName) => {
|
||||
const result = db
|
||||
.prepare(`SELECT name FROM sqlite_master WHERE type = 'index' AND name = ? LIMIT 1`)
|
||||
.get(indexName);
|
||||
return Boolean(result);
|
||||
};
|
||||
|
||||
const ensureTablesAndColumns = (tables) => {
|
||||
tables.forEach(({ createStatement, columns }, tableName) => {
|
||||
if (!tableExists(tableName)) {
|
||||
const statementWithGuard = createStatement.replace(/^CREATE TABLE/i, 'CREATE TABLE IF NOT EXISTS');
|
||||
db.exec(`${statementWithGuard};`);
|
||||
console.log(`ℹ️ Tabelle ${tableName} angelegt`);
|
||||
return;
|
||||
}
|
||||
|
||||
const existingColumns = getExistingColumns(tableName);
|
||||
const missingColumns = columns.filter((column) => !existingColumns.has(column.name));
|
||||
missingColumns.forEach((column) => {
|
||||
try {
|
||||
db.exec(`ALTER TABLE ${tableName} ADD COLUMN ${column.name} ${column.definition};`);
|
||||
console.log(`ℹ️ Column ${column.name} added to ${tableName}`);
|
||||
} catch (error) {
|
||||
console.warn(`⚠️ Column ${column.name} could not be added to ${tableName}: ${error.message}`);
|
||||
}
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
const ensureIndexes = (indexes) => {
|
||||
indexes.forEach(({ name, statement }) => {
|
||||
if (indexExists(name)) {
|
||||
return;
|
||||
}
|
||||
const statementWithGuard = statement.replace(/^CREATE INDEX/i, 'CREATE INDEX IF NOT EXISTS');
|
||||
db.exec(`${statementWithGuard};`);
|
||||
console.log(`ℹ️ Index ${name} angelegt`);
|
||||
});
|
||||
};
|
||||
|
||||
const ensurePermissionSeeds = () => {
|
||||
const selectSection = db.prepare(`
|
||||
SELECT id, title, sort_order, has_navigation_flag
|
||||
FROM permission_sections
|
||||
WHERE key = ?
|
||||
LIMIT 1
|
||||
`);
|
||||
const insertSection = db.prepare(`
|
||||
INSERT INTO permission_sections (key, title, sort_order, has_navigation_flag, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
|
||||
`);
|
||||
const updateSection = db.prepare(`
|
||||
UPDATE permission_sections
|
||||
SET title = ?, sort_order = ?, has_navigation_flag = ?, updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = ?
|
||||
`);
|
||||
|
||||
const selectGroup = db.prepare(`
|
||||
SELECT id, title, sort_order
|
||||
FROM permission_groups
|
||||
WHERE section_id = ? AND key = ?
|
||||
LIMIT 1
|
||||
`);
|
||||
const insertGroup = db.prepare(`
|
||||
INSERT INTO permission_groups (section_id, key, title, sort_order, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
|
||||
`);
|
||||
const updateGroup = db.prepare(`
|
||||
UPDATE permission_groups
|
||||
SET title = ?, sort_order = ?, updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = ?
|
||||
`);
|
||||
|
||||
const selectItem = db.prepare(`
|
||||
SELECT
|
||||
id,
|
||||
section_id,
|
||||
group_id,
|
||||
label,
|
||||
sort_order,
|
||||
default_level,
|
||||
available_levels,
|
||||
is_required
|
||||
FROM permission_items
|
||||
WHERE key = ?
|
||||
LIMIT 1
|
||||
`);
|
||||
const insertItem = db.prepare(`
|
||||
INSERT INTO permission_items (
|
||||
section_id,
|
||||
group_id,
|
||||
key,
|
||||
label,
|
||||
sort_order,
|
||||
default_level,
|
||||
available_levels,
|
||||
is_required,
|
||||
created_at,
|
||||
updated_at
|
||||
) VALUES (
|
||||
?, ?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP
|
||||
)
|
||||
`);
|
||||
const updateItem = db.prepare(`
|
||||
UPDATE permission_items
|
||||
SET
|
||||
section_id = ?,
|
||||
group_id = ?,
|
||||
label = ?,
|
||||
sort_order = ?,
|
||||
default_level = ?,
|
||||
available_levels = ?,
|
||||
is_required = ?,
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = ?
|
||||
`);
|
||||
|
||||
const selectDependency = db.prepare(`
|
||||
SELECT id, required_level
|
||||
FROM permission_dependencies
|
||||
WHERE permission_id = ? AND depends_on_permission_id = ?
|
||||
LIMIT 1
|
||||
`);
|
||||
const insertDependency = db.prepare(`
|
||||
INSERT INTO permission_dependencies (
|
||||
permission_id,
|
||||
depends_on_permission_id,
|
||||
required_level,
|
||||
created_at,
|
||||
updated_at
|
||||
) VALUES (
|
||||
?, ?, ?, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP
|
||||
)
|
||||
`);
|
||||
const updateDependency = db.prepare(`
|
||||
UPDATE permission_dependencies
|
||||
SET required_level = ?, updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = ?
|
||||
`);
|
||||
|
||||
const itemKeyToId = new Map();
|
||||
|
||||
PERMISSION_BLUEPRINT.forEach((section, sectionIndex) => {
|
||||
const existingSection = selectSection.get(section.key);
|
||||
let sectionId;
|
||||
if (!existingSection) {
|
||||
const result = insertSection.run(
|
||||
section.key,
|
||||
section.title,
|
||||
section.sortOrder ?? sectionIndex,
|
||||
section.hasNavigation ? 1 : 0
|
||||
);
|
||||
sectionId = Number(result.lastInsertRowid);
|
||||
console.log(`ℹ️ Berechtigungs-Sektion ${section.key} angelegt`);
|
||||
} else {
|
||||
updateSection.run(
|
||||
section.title,
|
||||
section.sortOrder ?? sectionIndex,
|
||||
section.hasNavigation ? 1 : 0,
|
||||
existingSection.id
|
||||
);
|
||||
sectionId = existingSection.id;
|
||||
if (
|
||||
existingSection.title !== section.title ||
|
||||
(existingSection.sort_order ?? sectionIndex) !== (section.sortOrder ?? sectionIndex) ||
|
||||
Number(existingSection.has_navigation_flag) !== (section.hasNavigation ? 1 : 0)
|
||||
) {
|
||||
console.log(`ℹ️ Berechtigungs-Sektion ${section.key} aktualisiert`);
|
||||
}
|
||||
}
|
||||
|
||||
const sectionItems = Array.isArray(section.items) ? section.items : [];
|
||||
sectionItems.forEach((item, itemIdx) => {
|
||||
const existingItem = selectItem.get(item.key);
|
||||
const sortOrder = typeof item.sortOrder === 'number' ? item.sortOrder : itemIdx;
|
||||
const levelOptions = Array.isArray(item.levels) && item.levels.length
|
||||
? item.levels
|
||||
: ['full', 'read', 'none'];
|
||||
const availableLevels = JSON.stringify(levelOptions);
|
||||
const isRequired = item.isRequired ? 1 : 0;
|
||||
if (!existingItem) {
|
||||
const result = insertItem.run(
|
||||
sectionId,
|
||||
null,
|
||||
item.key,
|
||||
item.label,
|
||||
sortOrder,
|
||||
item.defaultLevel || 'none',
|
||||
availableLevels,
|
||||
isRequired
|
||||
);
|
||||
itemKeyToId.set(item.key, Number(result.lastInsertRowid));
|
||||
console.log(`ℹ️ Berechtigung ${item.key} angelegt`);
|
||||
} else {
|
||||
const hasChanges =
|
||||
existingItem.section_id !== sectionId ||
|
||||
existingItem.group_id !== null ||
|
||||
existingItem.label !== item.label ||
|
||||
(existingItem.sort_order ?? sortOrder) !== sortOrder ||
|
||||
existingItem.default_level !== (item.defaultLevel || 'none') ||
|
||||
existingItem.available_levels !== availableLevels ||
|
||||
Number(existingItem.is_required) !== isRequired;
|
||||
updateItem.run(
|
||||
sectionId,
|
||||
null,
|
||||
item.label,
|
||||
sortOrder,
|
||||
item.defaultLevel || 'none',
|
||||
availableLevels,
|
||||
isRequired,
|
||||
existingItem.id
|
||||
);
|
||||
itemKeyToId.set(item.key, existingItem.id);
|
||||
if (hasChanges) {
|
||||
console.log(`ℹ️ Berechtigung ${item.key} aktualisiert`);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const sectionGroups = Array.isArray(section.groups) ? section.groups : [];
|
||||
sectionGroups.forEach((group, groupIdx) => {
|
||||
const existingGroup = selectGroup.get(sectionId, group.key);
|
||||
const sortOrder = typeof group.sortOrder === 'number' ? group.sortOrder : groupIdx;
|
||||
let groupId;
|
||||
if (!existingGroup) {
|
||||
const result = insertGroup.run(sectionId, group.key, group.title, sortOrder);
|
||||
groupId = Number(result.lastInsertRowid);
|
||||
console.log(`ℹ️ Berechtigungs-Gruppe ${group.key} in Sektion ${section.key} angelegt`);
|
||||
} else {
|
||||
const hasGroupChanges =
|
||||
existingGroup.title !== group.title ||
|
||||
(existingGroup.sort_order ?? sortOrder) !== sortOrder;
|
||||
updateGroup.run(group.title, sortOrder, existingGroup.id);
|
||||
groupId = existingGroup.id;
|
||||
if (hasGroupChanges) {
|
||||
console.log(`ℹ️ Berechtigungs-Gruppe ${group.key} in Sektion ${section.key} aktualisiert`);
|
||||
}
|
||||
}
|
||||
const groupItems = Array.isArray(group.items) ? group.items : [];
|
||||
groupItems.forEach((item, itemIdx) => {
|
||||
const existingItem = selectItem.get(item.key);
|
||||
const itemSortOrder = typeof item.sortOrder === 'number' ? item.sortOrder : itemIdx;
|
||||
const levelOptions = Array.isArray(item.levels) && item.levels.length
|
||||
? item.levels
|
||||
: ['full', 'read', 'none'];
|
||||
const availableLevels = JSON.stringify(levelOptions);
|
||||
const isRequired = item.isRequired ? 1 : 0;
|
||||
if (!existingItem) {
|
||||
const result = insertItem.run(
|
||||
sectionId,
|
||||
groupId,
|
||||
item.key,
|
||||
item.label,
|
||||
itemSortOrder,
|
||||
item.defaultLevel || 'none',
|
||||
availableLevels,
|
||||
isRequired
|
||||
);
|
||||
itemKeyToId.set(item.key, Number(result.lastInsertRowid));
|
||||
console.log(`ℹ️ Berechtigung ${item.key} in Gruppe ${group.key} angelegt`);
|
||||
} else {
|
||||
const hasChanges =
|
||||
existingItem.section_id !== sectionId ||
|
||||
existingItem.group_id !== groupId ||
|
||||
existingItem.label !== item.label ||
|
||||
(existingItem.sort_order ?? itemSortOrder) !== itemSortOrder ||
|
||||
existingItem.default_level !== (item.defaultLevel || 'none') ||
|
||||
existingItem.available_levels !== availableLevels ||
|
||||
Number(existingItem.is_required) !== isRequired;
|
||||
updateItem.run(
|
||||
sectionId,
|
||||
groupId,
|
||||
item.label,
|
||||
itemSortOrder,
|
||||
item.defaultLevel || 'none',
|
||||
availableLevels,
|
||||
isRequired,
|
||||
existingItem.id
|
||||
);
|
||||
itemKeyToId.set(item.key, existingItem.id);
|
||||
if (hasChanges) {
|
||||
console.log(`ℹ️ Berechtigung ${item.key} in Gruppe ${group.key} aktualisiert`);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
PERMISSION_BLUEPRINT.forEach((section) => {
|
||||
const sectionItems = Array.isArray(section.items) ? section.items : [];
|
||||
sectionItems.forEach((item) => {
|
||||
const permissionId = itemKeyToId.get(item.key);
|
||||
if (!permissionId) return;
|
||||
(item.dependencies || []).forEach((dependency) => {
|
||||
const dependsId = itemKeyToId.get(dependency.dependsOnKey);
|
||||
if (!dependsId) return;
|
||||
const existing = selectDependency.get(permissionId, dependsId);
|
||||
const requiredLevel = dependency.requiredLevel ?? null;
|
||||
if (!existing) {
|
||||
insertDependency.run(permissionId, dependsId, requiredLevel);
|
||||
console.log(
|
||||
`ℹ️ Abhängigkeit ${item.key} -> ${dependency.dependsOnKey} angelegt`
|
||||
);
|
||||
} else if (existing.required_level !== requiredLevel) {
|
||||
updateDependency.run(requiredLevel, existing.id);
|
||||
console.log(
|
||||
`ℹ️ Abhängigkeit ${item.key} -> ${dependency.dependsOnKey} aktualisiert`
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
const sectionGroups = Array.isArray(section.groups) ? section.groups : [];
|
||||
sectionGroups.forEach((group) => {
|
||||
const groupItems = Array.isArray(group.items) ? group.items : [];
|
||||
groupItems.forEach((item) => {
|
||||
const permissionId = itemKeyToId.get(item.key);
|
||||
if (!permissionId) return;
|
||||
(item.dependencies || []).forEach((dependency) => {
|
||||
const dependsId = itemKeyToId.get(dependency.dependsOnKey);
|
||||
if (!dependsId) return;
|
||||
const requiredLevel = dependency.requiredLevel ?? null;
|
||||
const existing = selectDependency.get(permissionId, dependsId);
|
||||
if (!existing) {
|
||||
insertDependency.run(permissionId, dependsId, requiredLevel);
|
||||
console.log(
|
||||
`ℹ️ Abhängigkeit ${item.key} -> ${dependency.dependsOnKey} angelegt`
|
||||
);
|
||||
} else if (existing.required_level !== requiredLevel) {
|
||||
updateDependency.run(requiredLevel, existing.id);
|
||||
console.log(
|
||||
`ℹ️ Abhängigkeit ${item.key} -> ${dependency.dependsOnKey} aktualisiert`
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
export const ensureDatabaseSchema = () => {
|
||||
try {
|
||||
const statements = loadBlueprintStatements();
|
||||
const tableDefinitions = parseTableDefinitions(statements);
|
||||
const indexDefinitions = parseIndexStatements(statements);
|
||||
|
||||
dropDeprecatedArtifacts();
|
||||
ensureTablesAndColumns(tableDefinitions);
|
||||
ensureIndexes(indexDefinitions);
|
||||
ensurePermissionSeeds();
|
||||
} catch (error) {
|
||||
console.error('⚠️ Schema-Abgleich fehlgeschlagen:', error);
|
||||
}
|
||||
};
|
||||
@@ -1,8 +1,8 @@
|
||||
#!/bin/sh
|
||||
set -e
|
||||
|
||||
echo "🔧 Starte Migration (idempotent)..."
|
||||
node db/migrate.js
|
||||
echo "🔧 Überprüfe Datenbankschema..."
|
||||
node db/ensure.js
|
||||
|
||||
echo "✅ Migration abgeschlossen. Starte Anwendung..."
|
||||
echo "✅ Schema-Abgleich abgeschlossen. Starte Anwendung..."
|
||||
exec "$@"
|
||||
|
||||
@@ -0,0 +1,342 @@
|
||||
import { db } from '../db/index.js';
|
||||
import {
|
||||
normalizeAvatarColor,
|
||||
DEFAULT_AVATAR_COLOR,
|
||||
SUPERUSER_GROUP_NAME,
|
||||
pickRandomAvatarColor
|
||||
} from '../auth/superuser.js';
|
||||
import { logEvent } from '../logging/eventLogs.js';
|
||||
|
||||
const selectGroupsWithMembers = db.prepare(`
|
||||
SELECT
|
||||
g.id,
|
||||
g.name,
|
||||
g.description,
|
||||
g.avatar_color,
|
||||
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
|
||||
GROUP BY g.id
|
||||
ORDER BY g.name COLLATE NOCASE
|
||||
`);
|
||||
|
||||
const selectGroupWithMembersById = db.prepare(`
|
||||
SELECT
|
||||
g.id,
|
||||
g.name,
|
||||
g.description,
|
||||
g.avatar_color,
|
||||
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 selectGroupByIdBasic = db.prepare(`
|
||||
SELECT id, name, description, avatar_color
|
||||
FROM user_groups
|
||||
WHERE 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, avatar_color, created_at, updated_at)
|
||||
VALUES (?, ?, ?, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
|
||||
`);
|
||||
|
||||
const updateGroupStatement = db.prepare(`
|
||||
UPDATE user_groups
|
||||
SET name = ?,
|
||||
description = ?,
|
||||
avatar_color = ?,
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = ?
|
||||
`);
|
||||
|
||||
const countGroupMembersStatement = db.prepare(`
|
||||
SELECT COUNT(*) AS member_count
|
||||
FROM user_group_memberships
|
||||
WHERE group_id = ?
|
||||
`);
|
||||
|
||||
const deleteGroupStatement = db.prepare(`
|
||||
DELETE FROM user_groups
|
||||
WHERE id = ?
|
||||
`);
|
||||
|
||||
const parseMembers = (rawPairs) => {
|
||||
if (!rawPairs) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return rawPairs
|
||||
.split('|||')
|
||||
.map((entry) => {
|
||||
const [idPart, usernamePart] = entry.split('::');
|
||||
const numericId = Number(idPart);
|
||||
return {
|
||||
id: Number.isFinite(numericId) ? numericId : idPart,
|
||||
username: (usernamePart || '').trim()
|
||||
};
|
||||
})
|
||||
.filter((member) => member.username);
|
||||
};
|
||||
|
||||
const sanitizeGroup = (row) => ({
|
||||
id: row.id,
|
||||
name: row.name,
|
||||
description: row.description || '',
|
||||
avatarColor: row.avatar_color || null,
|
||||
createdAt: row.created_at || null,
|
||||
updatedAt: row.updated_at || null,
|
||||
memberCount: Number(row.member_count) || 0,
|
||||
members: parseMembers(row.member_pairs)
|
||||
});
|
||||
|
||||
export function listGroups() {
|
||||
const rows = selectGroupsWithMembers.all();
|
||||
return rows.map(sanitizeGroup);
|
||||
}
|
||||
|
||||
export function getGroupById(groupId) {
|
||||
const numericId = Number(groupId);
|
||||
if (!Number.isFinite(numericId) || numericId <= 0) {
|
||||
return null;
|
||||
}
|
||||
const row = selectGroupWithMembersById.get(numericId);
|
||||
return row ? sanitizeGroup(row) : null;
|
||||
}
|
||||
|
||||
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 avatarColor = pickRandomAvatarColor() || DEFAULT_AVATAR_COLOR;
|
||||
const insertResult = insertGroupStatement.run(normalizedName, normalizedDescription || null, avatarColor);
|
||||
const groupId = Number(insertResult.lastInsertRowid);
|
||||
const row = selectGroupWithMembersById.get(groupId);
|
||||
const group = row ? sanitizeGroup(row) : null;
|
||||
|
||||
logEvent({
|
||||
category: 'benutzergruppe',
|
||||
eventType: 'gruppe-angelegt',
|
||||
action: 'anlegen',
|
||||
status: 'erfolgreich',
|
||||
entityType: 'gruppe',
|
||||
entityId: String(groupId),
|
||||
entityName: group?.name ?? normalizedName,
|
||||
message: `Benutzergruppe "${normalizedName}" angelegt`,
|
||||
metadata: {
|
||||
description: group?.description ?? normalizedDescription ?? null,
|
||||
avatarColor
|
||||
}
|
||||
});
|
||||
|
||||
return group;
|
||||
}
|
||||
|
||||
export function updateGroupDetails(groupId, { name, description, avatarColor } = {}) {
|
||||
const numericId = Number(groupId);
|
||||
if (!Number.isFinite(numericId) || numericId <= 0) {
|
||||
const error = new Error('INVALID_GROUP_ID');
|
||||
error.code = 'INVALID_GROUP_ID';
|
||||
throw error;
|
||||
}
|
||||
|
||||
const existingRow = selectGroupByIdBasic.get(numericId);
|
||||
if (!existingRow) {
|
||||
const error = new Error('GROUP_NOT_FOUND');
|
||||
error.code = 'GROUP_NOT_FOUND';
|
||||
throw error;
|
||||
}
|
||||
|
||||
const normalizedName = typeof name === 'string' ? name.trim() : existingRow.name || '';
|
||||
if (!normalizedName) {
|
||||
const error = new Error('GROUP_NAME_REQUIRED');
|
||||
error.code = 'GROUP_NAME_REQUIRED';
|
||||
throw error;
|
||||
}
|
||||
|
||||
const nameRow = selectGroupIdByName.get(normalizedName);
|
||||
if (nameRow && Number(nameRow.id) !== numericId) {
|
||||
const error = new Error('GROUP_NAME_TAKEN');
|
||||
error.code = 'GROUP_NAME_TAKEN';
|
||||
throw error;
|
||||
}
|
||||
|
||||
let normalizedDescription;
|
||||
if (description === undefined) {
|
||||
normalizedDescription = existingRow.description ?? null;
|
||||
} else if (description === null) {
|
||||
normalizedDescription = null;
|
||||
} else {
|
||||
const trimmedDescription = typeof description === 'string' ? description.trim() : '';
|
||||
normalizedDescription = trimmedDescription ? trimmedDescription : null;
|
||||
}
|
||||
|
||||
const isSuperuserGroup = (existingRow.name || '').toLowerCase() === SUPERUSER_GROUP_NAME;
|
||||
|
||||
if (isSuperuserGroup) {
|
||||
if (name !== undefined) {
|
||||
const incomingName = typeof name === 'string' ? name.trim() : '';
|
||||
if (incomingName && incomingName !== existingRow.name) {
|
||||
const error = new Error('GROUP_SUPERUSER_PROTECTED');
|
||||
error.code = 'GROUP_SUPERUSER_PROTECTED';
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
if (description !== undefined) {
|
||||
const existingDescriptionComparable = existingRow.description === null || existingRow.description === undefined
|
||||
? ''
|
||||
: String(existingRow.description).trim();
|
||||
const incomingDescriptionComparable = description === null
|
||||
? ''
|
||||
: typeof description === 'string'
|
||||
? description.trim()
|
||||
: '';
|
||||
|
||||
if (incomingDescriptionComparable !== existingDescriptionComparable) {
|
||||
const error = new Error('GROUP_SUPERUSER_PROTECTED');
|
||||
error.code = 'GROUP_SUPERUSER_PROTECTED';
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// Enforce original values for protected fields
|
||||
normalizedDescription = existingRow.description ?? null;
|
||||
}
|
||||
|
||||
let colorToPersist = existingRow.avatar_color || null;
|
||||
if (avatarColor !== undefined) {
|
||||
const candidate = String(avatarColor || '').trim();
|
||||
if (!candidate) {
|
||||
colorToPersist = DEFAULT_AVATAR_COLOR;
|
||||
} else {
|
||||
const normalized = normalizeAvatarColor(candidate);
|
||||
if (!normalized) {
|
||||
const error = new Error('INVALID_AVATAR_COLOR');
|
||||
error.code = 'INVALID_AVATAR_COLOR';
|
||||
throw error;
|
||||
}
|
||||
colorToPersist = normalized;
|
||||
}
|
||||
}
|
||||
|
||||
updateGroupStatement.run(
|
||||
isSuperuserGroup ? existingRow.name : normalizedName,
|
||||
normalizedDescription,
|
||||
colorToPersist,
|
||||
numericId
|
||||
);
|
||||
|
||||
const updatedGroup = getGroupById(numericId);
|
||||
|
||||
logEvent({
|
||||
category: 'benutzergruppe',
|
||||
eventType: 'gruppe-aktualisiert',
|
||||
action: 'aktualisieren',
|
||||
status: 'erfolgreich',
|
||||
entityType: 'gruppe',
|
||||
entityId: String(numericId),
|
||||
entityName: updatedGroup?.name ?? normalizedName,
|
||||
message: `Benutzergruppe "${updatedGroup?.name ?? normalizedName}" aktualisiert`,
|
||||
metadata: {
|
||||
previous: {
|
||||
name: existingRow.name,
|
||||
description: existingRow.description ?? null,
|
||||
avatarColor: existingRow.avatar_color ?? null
|
||||
},
|
||||
current: updatedGroup
|
||||
? {
|
||||
name: updatedGroup.name,
|
||||
description: updatedGroup.description ?? null,
|
||||
avatarColor: updatedGroup.avatarColor ?? null,
|
||||
memberCount: updatedGroup.memberCount ?? 0
|
||||
}
|
||||
: {
|
||||
name: normalizedName,
|
||||
description: normalizedDescription ?? null,
|
||||
avatarColor: colorToPersist
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return updatedGroup;
|
||||
}
|
||||
|
||||
export function deleteGroup(groupId) {
|
||||
const numericId = Number(groupId);
|
||||
if (!Number.isFinite(numericId) || numericId <= 0) {
|
||||
const error = new Error('INVALID_GROUP_ID');
|
||||
error.code = 'INVALID_GROUP_ID';
|
||||
throw error;
|
||||
}
|
||||
|
||||
const existingRow = selectGroupByIdBasic.get(numericId);
|
||||
if (!existingRow) {
|
||||
const error = new Error('GROUP_NOT_FOUND');
|
||||
error.code = 'GROUP_NOT_FOUND';
|
||||
throw error;
|
||||
}
|
||||
|
||||
const isSuperuserGroup = (existingRow.name || '').toLowerCase() === SUPERUSER_GROUP_NAME;
|
||||
if (isSuperuserGroup) {
|
||||
const error = new Error('GROUP_SUPERUSER_PROTECTED');
|
||||
error.code = 'GROUP_SUPERUSER_PROTECTED';
|
||||
throw error;
|
||||
}
|
||||
|
||||
const { member_count: memberCount } = countGroupMembersStatement.get(numericId);
|
||||
if (Number(memberCount) > 0) {
|
||||
const error = new Error('GROUP_HAS_MEMBERS');
|
||||
error.code = 'GROUP_HAS_MEMBERS';
|
||||
error.memberCount = Number(memberCount);
|
||||
throw error;
|
||||
}
|
||||
|
||||
deleteGroupStatement.run(numericId);
|
||||
|
||||
logEvent({
|
||||
category: 'benutzergruppe',
|
||||
eventType: 'gruppe-gelöscht',
|
||||
action: 'löschen',
|
||||
status: 'erfolgreich',
|
||||
entityType: 'gruppe',
|
||||
entityId: String(numericId),
|
||||
entityName: existingRow.name ?? `ID ${numericId}`,
|
||||
message: `Benutzergruppe "${existingRow.name ?? numericId}" gelöscht`,
|
||||
metadata: {
|
||||
description: existingRow.description ?? null,
|
||||
avatarColor: existingRow.avatar_color ?? null,
|
||||
memberCountBeforeDelete: Number(memberCount)
|
||||
}
|
||||
});
|
||||
}
|
||||
+1925
-169
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,331 @@
|
||||
import { db } from '../db/index.js';
|
||||
|
||||
const valueToArray = (value) => {
|
||||
if (value === undefined || value === null) return [];
|
||||
const base = Array.isArray(value) ? value : [value];
|
||||
return base
|
||||
.flatMap((entry) => String(entry).split(','))
|
||||
.map((entry) => entry.trim())
|
||||
.filter((entry) => entry.length > 0);
|
||||
};
|
||||
|
||||
const singleValue = (value) => {
|
||||
if (value === undefined || value === null) return undefined;
|
||||
return Array.isArray(value) ? value[0] : value;
|
||||
};
|
||||
|
||||
const serializeJson = (value) => {
|
||||
if (value === undefined || value === null || value === '') return null;
|
||||
if (typeof value === 'string') return value;
|
||||
try {
|
||||
return JSON.stringify(value);
|
||||
} catch (err) {
|
||||
console.error('⚠️ Konnte JSON nicht serialisieren:', err.message);
|
||||
return JSON.stringify({ serializationError: true });
|
||||
}
|
||||
};
|
||||
|
||||
const appendInFilter = (filters, params, values, column, prefix) => {
|
||||
if (!values.length) return;
|
||||
const placeholders = values.map((_, idx) => {
|
||||
const key = `${prefix}${idx}`;
|
||||
params[key] = values[idx];
|
||||
return `@${key}`;
|
||||
});
|
||||
filters.push(`${column} IN (${placeholders.join(', ')})`);
|
||||
};
|
||||
|
||||
export function buildEventLogFilter(queryParams = {}) {
|
||||
const filters = [];
|
||||
const params = {};
|
||||
|
||||
const ids = valueToArray(queryParams.ids ?? queryParams.id)
|
||||
.map((entry) => Number(entry))
|
||||
.filter((value) => Number.isFinite(value));
|
||||
if (ids.length) {
|
||||
appendInFilter(filters, params, ids, 'id', 'id');
|
||||
}
|
||||
|
||||
const categories = valueToArray(queryParams.categories ?? queryParams.category);
|
||||
appendInFilter(filters, params, categories, 'category', 'category');
|
||||
|
||||
const statuses = valueToArray(queryParams.statuses ?? queryParams.status);
|
||||
appendInFilter(filters, params, statuses, 'status', 'status');
|
||||
|
||||
const actions = valueToArray(queryParams.actions ?? queryParams.action);
|
||||
appendInFilter(filters, params, actions, 'action', 'action');
|
||||
|
||||
const eventTypes = [
|
||||
...valueToArray(queryParams.eventTypes ?? queryParams.eventType),
|
||||
...valueToArray(queryParams.redeployTypes ?? queryParams.redeployType)
|
||||
];
|
||||
if (eventTypes.length) {
|
||||
const unique = Array.from(new Set(eventTypes));
|
||||
appendInFilter(filters, params, unique, 'event_type', 'eventType');
|
||||
}
|
||||
|
||||
const severities = valueToArray(queryParams.severities ?? queryParams.severity);
|
||||
appendInFilter(filters, params, severities, 'severity', 'severity');
|
||||
|
||||
const entityTypes = valueToArray(queryParams.entityTypes ?? queryParams.entityType);
|
||||
appendInFilter(filters, params, entityTypes, 'entity_type', 'entityType');
|
||||
|
||||
const entityIds = valueToArray(queryParams.entityIds ?? queryParams.entityId);
|
||||
appendInFilter(filters, params, entityIds, 'entity_id', 'entityId');
|
||||
|
||||
const actorTypes = valueToArray(queryParams.actorTypes ?? queryParams.actorType);
|
||||
appendInFilter(filters, params, actorTypes, 'actor_type', 'actorType');
|
||||
|
||||
const actorIds = valueToArray(queryParams.actorIds ?? queryParams.actorId);
|
||||
appendInFilter(filters, params, actorIds, 'actor_id', 'actorId');
|
||||
|
||||
const sources = valueToArray(queryParams.sources ?? queryParams.source);
|
||||
appendInFilter(filters, params, sources, 'source', 'source');
|
||||
|
||||
const contextTypes = valueToArray(queryParams.contextTypes ?? queryParams.contextType);
|
||||
appendInFilter(filters, params, contextTypes, 'context_type', 'contextType');
|
||||
|
||||
const contextIds = valueToArray(queryParams.contextIds ?? queryParams.contextId);
|
||||
appendInFilter(filters, params, contextIds, 'context_id', 'contextId');
|
||||
|
||||
const legacyStackIds = valueToArray(queryParams.stackIds ?? queryParams.stackId);
|
||||
if (legacyStackIds.length) {
|
||||
const placeholders = legacyStackIds.map((_, idx) => {
|
||||
const key = `legacyStackId${idx}`;
|
||||
params[key] = legacyStackIds[idx];
|
||||
return `@${key}`;
|
||||
});
|
||||
filters.push(`(entity_type = 'stack' AND entity_id IN (${placeholders.join(', ')}))`);
|
||||
}
|
||||
|
||||
const serverContexts = valueToArray(queryParams.servers ?? queryParams.server);
|
||||
if (serverContexts.length) {
|
||||
const placeholders = serverContexts.map((_, idx) => {
|
||||
const key = `serverContext${idx}`;
|
||||
params[key] = serverContexts[idx];
|
||||
return `@${key}`;
|
||||
});
|
||||
filters.push(`(context_type = 'server' AND context_id IN (${placeholders.join(', ')}))`);
|
||||
}
|
||||
|
||||
const messageQuery = singleValue(queryParams.message ?? queryParams.text);
|
||||
if (messageQuery && String(messageQuery).trim()) {
|
||||
filters.push('message LIKE @message');
|
||||
params.message = `%${String(messageQuery).trim()}%`;
|
||||
}
|
||||
|
||||
const search = singleValue(queryParams.search ?? queryParams.q);
|
||||
if (search && String(search).trim()) {
|
||||
filters.push('(message LIKE @search OR entity_name LIKE @search OR metadata LIKE @search)');
|
||||
params.search = `%${String(search).trim()}%`;
|
||||
}
|
||||
|
||||
const from = singleValue(queryParams.from ?? queryParams.start);
|
||||
if (from) {
|
||||
filters.push('timestamp >= @from');
|
||||
params.from = from;
|
||||
}
|
||||
|
||||
const to = singleValue(queryParams.to ?? queryParams.end);
|
||||
if (to) {
|
||||
filters.push('timestamp <= @to');
|
||||
params.to = to;
|
||||
}
|
||||
|
||||
return {
|
||||
whereClause: filters.length ? `WHERE ${filters.join(' AND ')}` : '',
|
||||
params
|
||||
};
|
||||
}
|
||||
|
||||
const insertEventLogStmt = db.prepare(`
|
||||
INSERT INTO event_logs (
|
||||
timestamp,
|
||||
category,
|
||||
event_type,
|
||||
action,
|
||||
status,
|
||||
severity,
|
||||
entity_type,
|
||||
entity_id,
|
||||
entity_name,
|
||||
actor_type,
|
||||
actor_id,
|
||||
actor_name,
|
||||
source,
|
||||
context_type,
|
||||
context_id,
|
||||
context_label,
|
||||
message,
|
||||
metadata
|
||||
)
|
||||
VALUES (
|
||||
COALESCE(@timestamp, CURRENT_TIMESTAMP),
|
||||
@category,
|
||||
@eventType,
|
||||
@action,
|
||||
@status,
|
||||
@severity,
|
||||
@entityType,
|
||||
@entityId,
|
||||
@entityName,
|
||||
@actorType,
|
||||
@actorId,
|
||||
@actorName,
|
||||
@source,
|
||||
@contextType,
|
||||
@contextId,
|
||||
@contextLabel,
|
||||
@message,
|
||||
@metadata
|
||||
)
|
||||
`);
|
||||
|
||||
export function logEvent(event = {}) {
|
||||
if (!event.category) {
|
||||
console.error('⚠️ logEvent wurde ohne Kategorie aufgerufen');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
insertEventLogStmt.run({
|
||||
timestamp: event.timestamp ?? null,
|
||||
category: event.category,
|
||||
eventType: event.eventType ?? null,
|
||||
action: event.action ?? null,
|
||||
status: event.status ?? null,
|
||||
severity: event.severity ?? null,
|
||||
entityType: event.entityType ?? null,
|
||||
entityId: event.entityId !== undefined && event.entityId !== null ? String(event.entityId) : null,
|
||||
entityName: event.entityName ?? null,
|
||||
actorType: event.actorType ?? null,
|
||||
actorId: event.actorId !== undefined && event.actorId !== null ? String(event.actorId) : null,
|
||||
actorName: event.actorName ?? null,
|
||||
source: event.source ?? null,
|
||||
contextType: event.contextType ?? null,
|
||||
contextId: event.contextId !== undefined && event.contextId !== null ? String(event.contextId) : null,
|
||||
contextLabel: event.contextLabel ?? null,
|
||||
message: event.message ?? null,
|
||||
metadata: serializeJson(event.metadata)
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('❌ Fehler beim Speichern des Event-Logs:', err.message);
|
||||
}
|
||||
}
|
||||
|
||||
export function deleteEventLogById(id) {
|
||||
const stmt = db.prepare('DELETE FROM event_logs WHERE id = ?');
|
||||
const info = stmt.run(id);
|
||||
return info.changes || 0;
|
||||
}
|
||||
|
||||
export function deleteEventLogsByFilters(queryParams = {}) {
|
||||
const { whereClause, params } = buildEventLogFilter(queryParams);
|
||||
const stmt = db.prepare(`DELETE FROM event_logs ${whereClause}`);
|
||||
const info = stmt.run(params);
|
||||
return info.changes || 0;
|
||||
}
|
||||
|
||||
export function exportEventLogsByFilters(queryParams = {}, format = 'txt') {
|
||||
const { whereClause, params } = buildEventLogFilter(queryParams);
|
||||
const rows = db.prepare(`
|
||||
SELECT
|
||||
id,
|
||||
timestamp,
|
||||
category,
|
||||
event_type AS eventType,
|
||||
action,
|
||||
status,
|
||||
severity,
|
||||
entity_type AS entityType,
|
||||
entity_id AS entityId,
|
||||
entity_name AS entityName,
|
||||
actor_type AS actorType,
|
||||
actor_id AS actorId,
|
||||
actor_name AS actorName,
|
||||
source,
|
||||
context_type AS contextType,
|
||||
context_id AS contextId,
|
||||
context_label AS contextLabel,
|
||||
message,
|
||||
metadata
|
||||
FROM event_logs
|
||||
${whereClause}
|
||||
ORDER BY datetime(timestamp) DESC
|
||||
`).all(params);
|
||||
|
||||
const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
|
||||
|
||||
if (format === 'sql') {
|
||||
const columns = [
|
||||
'id',
|
||||
'timestamp',
|
||||
'category',
|
||||
'event_type',
|
||||
'action',
|
||||
'status',
|
||||
'severity',
|
||||
'entity_type',
|
||||
'entity_id',
|
||||
'entity_name',
|
||||
'actor_type',
|
||||
'actor_id',
|
||||
'actor_name',
|
||||
'source',
|
||||
'context_type',
|
||||
'context_id',
|
||||
'context_label',
|
||||
'message',
|
||||
'metadata'
|
||||
];
|
||||
|
||||
const statements = rows.map((row) => {
|
||||
const values = columns.map((column) => {
|
||||
const key = {
|
||||
event_type: 'eventType',
|
||||
entity_type: 'entityType',
|
||||
entity_id: 'entityId',
|
||||
entity_name: 'entityName',
|
||||
actor_type: 'actorType',
|
||||
actor_id: 'actorId',
|
||||
actor_name: 'actorName',
|
||||
context_type: 'contextType',
|
||||
context_id: 'contextId',
|
||||
context_label: 'contextLabel'
|
||||
}[column] ?? column;
|
||||
const value = row[key];
|
||||
if (value === null || value === undefined) return 'NULL';
|
||||
return `'${String(value).replace(/'/g, "''")}'`;
|
||||
});
|
||||
return `INSERT INTO event_logs (${columns.join(', ')}) VALUES (${values.join(', ')});`;
|
||||
});
|
||||
|
||||
return {
|
||||
filename: `event-logs-${timestamp}.sql`,
|
||||
contentType: 'application/sql; charset=utf-8',
|
||||
content: statements.join('\n')
|
||||
};
|
||||
}
|
||||
|
||||
const lines = rows.map((row) => {
|
||||
const parts = [
|
||||
`[${row.id}]`,
|
||||
row.timestamp,
|
||||
`Kategorie: ${row.category}`,
|
||||
row.eventType ? `Typ: ${row.eventType}` : null,
|
||||
row.action ? `Aktion: ${row.action}` : null,
|
||||
row.status ? `Status: ${row.status}` : null,
|
||||
row.entityName ? `Entität: ${row.entityName}${row.entityId ? ` (#${row.entityId})` : ''}` : row.entityId ? `Entität-ID: ${row.entityId}` : null,
|
||||
row.contextType ? `Kontext: ${row.contextType}${row.contextId ? ` (#${row.contextId}${row.contextLabel ? ` – ${row.contextLabel}` : ''})` : ''}` : null,
|
||||
row.message ? `Nachricht: ${row.message}` : null,
|
||||
row.metadata ? `Metadaten: ${row.metadata}` : null
|
||||
].filter(Boolean);
|
||||
return parts.join(' | ');
|
||||
});
|
||||
|
||||
return {
|
||||
filename: `event-logs-${timestamp}.txt`,
|
||||
contentType: 'text/plain; charset=utf-8',
|
||||
content: lines.join('\n')
|
||||
};
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import { getJsonSetting, setJsonSetting } from '../db/settings.js';
|
||||
import { logEvent } from '../logging/eventLogs.js';
|
||||
|
||||
const MAINTENANCE_KEY = 'maintenance_mode';
|
||||
|
||||
@@ -44,6 +45,19 @@ export function isMaintenanceModeActive() {
|
||||
}
|
||||
|
||||
export function activateMaintenanceMode({ message = null, extra = null } = {}) {
|
||||
logEvent({
|
||||
category: 'wartung',
|
||||
eventType: 'Wartungsmodus',
|
||||
action: 'aktivieren',
|
||||
status: 'gestartet',
|
||||
entityType: 'system',
|
||||
entityId: 'wartung',
|
||||
entityName: 'StackPulse Wartung',
|
||||
contextType: 'System',
|
||||
contextId: 'System',
|
||||
message: 'Wartungsmodus aktiviert',
|
||||
source: 'system'
|
||||
});
|
||||
const now = new Date().toISOString();
|
||||
return persistState({
|
||||
active: true,
|
||||
@@ -54,6 +68,19 @@ export function activateMaintenanceMode({ message = null, extra = null } = {}) {
|
||||
}
|
||||
|
||||
export function deactivateMaintenanceMode({ message = null } = {}) {
|
||||
logEvent({
|
||||
category: 'wartung',
|
||||
eventType: 'wartungsmodus',
|
||||
action: 'deaktivieren',
|
||||
status: 'erfolgreich',
|
||||
entityType: 'system',
|
||||
entityId: 'wartung',
|
||||
entityName: 'StackPulse Wartung',
|
||||
contextType: 'System',
|
||||
contextId: 'System',
|
||||
message: 'Wartungsmodus deaktiviert',
|
||||
source: 'system'
|
||||
});
|
||||
return persistState({
|
||||
active: false,
|
||||
message,
|
||||
|
||||
Generated
+32
@@ -8,6 +8,7 @@
|
||||
"name": "backend",
|
||||
"version": "1.0.0",
|
||||
"dependencies": {
|
||||
"@scure/bip39": "^2.0.0",
|
||||
"axios": "^1.7.0",
|
||||
"better-sqlite3": "^9.6.0",
|
||||
"dockerode": "^4.0.7",
|
||||
@@ -59,6 +60,17 @@
|
||||
"url": "https://opencollective.com/js-sdsl"
|
||||
}
|
||||
},
|
||||
"node_modules/@noble/hashes": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.0.1.tgz",
|
||||
"integrity": "sha512-XlOlEbQcE9fmuXxrVTXCTlG2nlRXa9Rj3rr5Ue/+tX+nmkgbX720YHh0VR3hBF9xDvwnb8D2shVGOwNx+ulArw==",
|
||||
"engines": {
|
||||
"node": ">= 20.19.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://paulmillr.com/funding/"
|
||||
}
|
||||
},
|
||||
"node_modules/@protobufjs/aspromise": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/@protobufjs/aspromise/-/aspromise-1.1.2.tgz",
|
||||
@@ -113,6 +125,26 @@
|
||||
"resolved": "https://registry.npmjs.org/@protobufjs/utf8/-/utf8-1.1.0.tgz",
|
||||
"integrity": "sha512-Vvn3zZrhQZkkBE8LSuW3em98c0FwgO4nxzv6OdSxPKJIEKY2bGbHn+mhGIPerzI4twdxaP8/0+06HBpwf345Lw=="
|
||||
},
|
||||
"node_modules/@scure/base": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@scure/base/-/base-2.0.0.tgz",
|
||||
"integrity": "sha512-3E1kpuZginKkek01ovG8krQ0Z44E3DHPjc5S2rjJw9lZn3KSQOs8S7wqikF/AH7iRanHypj85uGyxk0XAyC37w==",
|
||||
"funding": {
|
||||
"url": "https://paulmillr.com/funding/"
|
||||
}
|
||||
},
|
||||
"node_modules/@scure/bip39": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@scure/bip39/-/bip39-2.0.1.tgz",
|
||||
"integrity": "sha512-PsxdFj/d2AcJcZDX1FXN3dDgitDDTmwf78rKZq1a6c1P1Nan1X/Sxc7667zU3U+AN60g7SxxP0YCVw2H/hBycg==",
|
||||
"dependencies": {
|
||||
"@noble/hashes": "2.0.1",
|
||||
"@scure/base": "2.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://paulmillr.com/funding/"
|
||||
}
|
||||
},
|
||||
"node_modules/@socket.io/component-emitter": {
|
||||
"version": "3.1.2",
|
||||
"resolved": "https://registry.npmjs.org/@socket.io/component-emitter/-/component-emitter-3.1.2.tgz",
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"start": "node index.js",
|
||||
"migrate": "node db/migrate.js"
|
||||
"migrate": "node db/ensure.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"axios": "^1.7.0",
|
||||
@@ -12,6 +12,7 @@
|
||||
"dockerode": "^4.0.7",
|
||||
"dotenv": "^16.0.0",
|
||||
"express": "^4.18.0",
|
||||
"socket.io": "^4.8.1"
|
||||
"socket.io": "^4.8.1",
|
||||
"@scure/bip39": "^2.0.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,375 @@
|
||||
import { db } from '../db/index.js';
|
||||
|
||||
const LEVEL_PRIORITY = {
|
||||
none: 0,
|
||||
read: 1,
|
||||
full: 2
|
||||
};
|
||||
|
||||
const selectSections = db.prepare(`
|
||||
SELECT id, key, title, sort_order, has_navigation_flag
|
||||
FROM permission_sections
|
||||
ORDER BY sort_order ASC, id ASC
|
||||
`);
|
||||
|
||||
const selectGroups = db.prepare(`
|
||||
SELECT id, section_id, key, title, sort_order
|
||||
FROM permission_groups
|
||||
ORDER BY section_id ASC, sort_order ASC, id ASC
|
||||
`);
|
||||
|
||||
const selectItems = db.prepare(`
|
||||
SELECT
|
||||
id,
|
||||
section_id,
|
||||
group_id,
|
||||
key,
|
||||
label,
|
||||
sort_order,
|
||||
default_level,
|
||||
available_levels,
|
||||
is_required
|
||||
FROM permission_items
|
||||
ORDER BY section_id ASC, COALESCE(group_id, 0) ASC, sort_order ASC, id ASC
|
||||
`);
|
||||
|
||||
const selectDependencies = db.prepare(`
|
||||
SELECT permission_id, depends_on_permission_id, required_level
|
||||
FROM permission_dependencies
|
||||
`);
|
||||
|
||||
const selectPermissionItemsForMap = db.prepare(`
|
||||
SELECT id, key, available_levels, default_level
|
||||
FROM permission_items
|
||||
`);
|
||||
|
||||
const selectGroupPermissionValues = db.prepare(`
|
||||
SELECT gp.permission_id, gp.level, gp.effective_level, pi.key
|
||||
FROM group_permission_values gp
|
||||
JOIN permission_items pi ON pi.id = gp.permission_id
|
||||
WHERE gp.group_id = ?
|
||||
`);
|
||||
|
||||
const deleteGroupPermissionValuesStmt = db.prepare(`
|
||||
DELETE FROM group_permission_values
|
||||
WHERE group_id = ?
|
||||
`);
|
||||
|
||||
const insertGroupPermissionValueStmt = db.prepare(`
|
||||
INSERT INTO group_permission_values (group_id, permission_id, level, effective_level, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
|
||||
`);
|
||||
|
||||
const parseAvailableLevels = (raw) => {
|
||||
if (typeof raw !== 'string' || !raw.trim()) {
|
||||
return ['full', 'read', 'none'];
|
||||
}
|
||||
try {
|
||||
const parsed = JSON.parse(raw);
|
||||
if (Array.isArray(parsed) && parsed.every((entry) => typeof entry === 'string')) {
|
||||
return parsed;
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('⚠️ Konnte available_levels nicht parsen:', error.message);
|
||||
}
|
||||
return ['full', 'read', 'none'];
|
||||
};
|
||||
|
||||
const getLevelPriority = (level) => {
|
||||
if (!level) return 0;
|
||||
return LEVEL_PRIORITY[level] ?? 0;
|
||||
};
|
||||
|
||||
let cachedPermissionItems = null;
|
||||
|
||||
const getPermissionItems = () => {
|
||||
if (!cachedPermissionItems) {
|
||||
cachedPermissionItems = selectPermissionItemsForMap.all();
|
||||
}
|
||||
return cachedPermissionItems;
|
||||
};
|
||||
|
||||
const resetPermissionItemsCache = () => {
|
||||
cachedPermissionItems = null;
|
||||
};
|
||||
|
||||
export function getPermissionStructure() {
|
||||
const sections = selectSections.all();
|
||||
const groups = selectGroups.all();
|
||||
const items = selectItems.all();
|
||||
const dependencies = selectDependencies.all();
|
||||
|
||||
const itemIdToKey = new Map(items.map((item) => [item.id, item.key]));
|
||||
|
||||
const dependenciesByItemId = new Map();
|
||||
dependencies.forEach((dependency) => {
|
||||
const list = dependenciesByItemId.get(dependency.permission_id) || [];
|
||||
list.push(dependency);
|
||||
dependenciesByItemId.set(dependency.permission_id, list);
|
||||
});
|
||||
|
||||
const groupsBySectionId = new Map();
|
||||
groups.forEach((group) => {
|
||||
const list = groupsBySectionId.get(group.section_id) || [];
|
||||
list.push(group);
|
||||
groupsBySectionId.set(group.section_id, list);
|
||||
});
|
||||
|
||||
const itemsBySectionId = new Map();
|
||||
const itemsByGroupId = new Map();
|
||||
|
||||
items.forEach((item) => {
|
||||
if (item.group_id) {
|
||||
const list = itemsByGroupId.get(item.group_id) || [];
|
||||
list.push(item);
|
||||
itemsByGroupId.set(item.group_id, list);
|
||||
} else {
|
||||
const list = itemsBySectionId.get(item.section_id) || [];
|
||||
list.push(item);
|
||||
itemsBySectionId.set(item.section_id, list);
|
||||
}
|
||||
});
|
||||
|
||||
const formatItem = (item) => {
|
||||
const availableLevels = parseAvailableLevels(item.available_levels);
|
||||
|
||||
const dependencyEntries = dependenciesByItemId.get(item.id) || [];
|
||||
const formattedDependencies = dependencyEntries
|
||||
.map((dependency) => {
|
||||
const dependsOnKey = itemIdToKey.get(dependency.depends_on_permission_id);
|
||||
if (!dependsOnKey) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
key: dependsOnKey,
|
||||
requiredLevel: dependency.required_level || null
|
||||
};
|
||||
})
|
||||
.filter(Boolean);
|
||||
|
||||
return {
|
||||
key: item.key,
|
||||
label: item.label,
|
||||
sortOrder: item.sort_order ?? 0,
|
||||
defaultLevel: item.default_level || 'none',
|
||||
availableLevels,
|
||||
isRequired: Boolean(item.is_required),
|
||||
dependencies: formattedDependencies
|
||||
};
|
||||
};
|
||||
|
||||
const formattedSections = sections.map((section) => {
|
||||
const sectionItems = (itemsBySectionId.get(section.id) || []).map(formatItem);
|
||||
const sectionGroups = (groupsBySectionId.get(section.id) || []).map((group) => ({
|
||||
key: group.key,
|
||||
title: group.title,
|
||||
sortOrder: group.sort_order ?? 0,
|
||||
items: (itemsByGroupId.get(group.id) || []).map(formatItem)
|
||||
}));
|
||||
|
||||
return {
|
||||
key: section.key,
|
||||
title: section.title,
|
||||
sortOrder: section.sort_order ?? 0,
|
||||
hasNavigation: Boolean(section.has_navigation_flag),
|
||||
items: sectionItems,
|
||||
groups: sectionGroups
|
||||
};
|
||||
});
|
||||
|
||||
return formattedSections;
|
||||
}
|
||||
|
||||
export function getPermissionValuesByGroup(groupId) {
|
||||
const numericId = Number(groupId);
|
||||
if (!Number.isFinite(numericId) || numericId <= 0) {
|
||||
return {};
|
||||
}
|
||||
|
||||
const rows = selectGroupPermissionValues.all(numericId);
|
||||
const values = {};
|
||||
rows.forEach((row) => {
|
||||
if (row?.key && typeof row.level === 'string') {
|
||||
values[row.key] = row.level;
|
||||
}
|
||||
});
|
||||
return values;
|
||||
}
|
||||
|
||||
export function clearGroupPermissionValues(groupId) {
|
||||
const numericId = Number(groupId);
|
||||
if (!Number.isFinite(numericId) || numericId <= 0) {
|
||||
return;
|
||||
}
|
||||
deleteGroupPermissionValuesStmt.run(numericId);
|
||||
}
|
||||
|
||||
export function saveGroupPermissionValues(groupId, values = {}) {
|
||||
const numericId = Number(groupId);
|
||||
if (!Number.isFinite(numericId) || numericId <= 0) {
|
||||
const error = new Error('INVALID_GROUP_ID');
|
||||
error.code = 'INVALID_GROUP_ID';
|
||||
throw error;
|
||||
}
|
||||
|
||||
if (values === null || typeof values !== 'object' || Array.isArray(values)) {
|
||||
const error = new Error('PERMISSION_INVALID_PAYLOAD');
|
||||
error.code = 'PERMISSION_INVALID_PAYLOAD';
|
||||
throw error;
|
||||
}
|
||||
|
||||
const items = getPermissionItems();
|
||||
const itemMap = new Map();
|
||||
items.forEach((item) => {
|
||||
itemMap.set(item.key, {
|
||||
id: item.id,
|
||||
key: item.key,
|
||||
defaultLevel: item.default_level || 'none',
|
||||
availableLevels: parseAvailableLevels(item.available_levels)
|
||||
});
|
||||
});
|
||||
|
||||
const entries = Object.entries(values);
|
||||
const normalizedEntries = [];
|
||||
|
||||
entries.forEach(([key, rawValue]) => {
|
||||
const item = itemMap.get(key);
|
||||
if (!item) {
|
||||
const error = new Error('PERMISSION_UNKNOWN_KEY');
|
||||
error.code = 'PERMISSION_UNKNOWN_KEY';
|
||||
error.permissionKey = key;
|
||||
throw error;
|
||||
}
|
||||
|
||||
let level = typeof rawValue === 'string' ? rawValue.trim() : String(rawValue ?? '').trim();
|
||||
if (!level) {
|
||||
level = 'none';
|
||||
}
|
||||
|
||||
if (!item.availableLevels.includes(level)) {
|
||||
const error = new Error('PERMISSION_INVALID_LEVEL');
|
||||
error.code = 'PERMISSION_INVALID_LEVEL';
|
||||
error.permissionKey = key;
|
||||
error.level = level;
|
||||
throw error;
|
||||
}
|
||||
|
||||
normalizedEntries.push({ item, level });
|
||||
});
|
||||
|
||||
const getSubmittedLevel = (permissionKey) => {
|
||||
const entry = normalizedEntries.find((entry) => entry.item.key === permissionKey);
|
||||
if (entry) {
|
||||
return entry.level;
|
||||
}
|
||||
const fallback = itemMap.get(permissionKey);
|
||||
return fallback ? fallback.defaultLevel || 'none' : 'none';
|
||||
};
|
||||
|
||||
const serverManageLevel = getSubmittedLevel('maintenance-server-manage');
|
||||
const serverDeleteLevel = getSubmittedLevel('maintenance-server-delete');
|
||||
|
||||
if (getLevelPriority(serverDeleteLevel) >= getLevelPriority('full') &&
|
||||
getLevelPriority(serverManageLevel) < getLevelPriority('full')) {
|
||||
const error = new Error('PERMISSION_DEPENDENCY_LEVEL');
|
||||
error.code = 'PERMISSION_DEPENDENCY_LEVEL';
|
||||
error.permissionKey = 'maintenance-server-delete';
|
||||
throw error;
|
||||
}
|
||||
|
||||
const runSave = db.transaction(() => {
|
||||
deleteGroupPermissionValuesStmt.run(numericId);
|
||||
normalizedEntries.forEach(({ item, level }) => {
|
||||
if (level === (item.defaultLevel || 'none')) {
|
||||
return;
|
||||
}
|
||||
insertGroupPermissionValueStmt.run(numericId, item.id, level, level);
|
||||
});
|
||||
});
|
||||
|
||||
runSave();
|
||||
resetPermissionItemsCache();
|
||||
|
||||
return getPermissionValuesByGroup(numericId);
|
||||
}
|
||||
|
||||
export function getDefaultPermissionMap() {
|
||||
const items = getPermissionItems();
|
||||
const defaults = {};
|
||||
items.forEach((item) => {
|
||||
defaults[item.key] = item.default_level || 'none';
|
||||
});
|
||||
return defaults;
|
||||
}
|
||||
|
||||
export function getSuperuserPermissionMap() {
|
||||
const items = getPermissionItems();
|
||||
const result = {};
|
||||
|
||||
items.forEach((item) => {
|
||||
const available = parseAvailableLevels(item.available_levels);
|
||||
let chosen = 'full';
|
||||
if (!available.includes('full')) {
|
||||
chosen = available.reduce(
|
||||
(best, candidate) =>
|
||||
getLevelPriority(candidate) > getLevelPriority(best) ? candidate : best,
|
||||
'none'
|
||||
);
|
||||
}
|
||||
result[item.key] = chosen;
|
||||
});
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
export function getEffectivePermissionsForGroup(groupId) {
|
||||
const defaults = getDefaultPermissionMap();
|
||||
const numericId = Number(groupId);
|
||||
if (!Number.isFinite(numericId) || numericId <= 0) {
|
||||
return defaults;
|
||||
}
|
||||
|
||||
const overrides = getPermissionValuesByGroup(numericId);
|
||||
const map = { ...defaults };
|
||||
|
||||
Object.keys(overrides).forEach((key) => {
|
||||
const value = overrides[key];
|
||||
if (typeof value === 'string') {
|
||||
map[key] = value;
|
||||
}
|
||||
});
|
||||
|
||||
return map;
|
||||
}
|
||||
|
||||
export function getEffectivePermissionsForGroups(groupIds = []) {
|
||||
const defaults = getDefaultPermissionMap();
|
||||
const finalMap = { ...defaults };
|
||||
|
||||
const iterableIds = Array.isArray(groupIds) ? groupIds : [];
|
||||
iterableIds
|
||||
.map((value) => Number(value))
|
||||
.filter((value) => Number.isFinite(value) && value > 0)
|
||||
.forEach((groupId) => {
|
||||
const map = getEffectivePermissionsForGroup(groupId);
|
||||
Object.entries(map).forEach(([key, level]) => {
|
||||
if (getLevelPriority(level) > getLevelPriority(finalMap[key] ?? 'none')) {
|
||||
finalMap[key] = level;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
return finalMap;
|
||||
}
|
||||
|
||||
export function hasRequiredPermission(permissions = {}, permissionKey, requiredLevel = 'full') {
|
||||
if (!permissionKey) {
|
||||
return true;
|
||||
}
|
||||
if (requiredLevel === 'none' || requiredLevel === null || requiredLevel === undefined) {
|
||||
return true;
|
||||
}
|
||||
const current = typeof permissions[permissionKey] === 'string' ? permissions[permissionKey] : 'none';
|
||||
const required = typeof requiredLevel === 'string' ? requiredLevel : 'full';
|
||||
return getLevelPriority(current) >= getLevelPriority(required);
|
||||
}
|
||||
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
Binary file not shown.
|
After Width: | Height: | Size: 99 KiB |
@@ -32,8 +32,10 @@
|
||||
<!-- Nepcha Analytics (nepcha.com) -->
|
||||
<!-- Nepcha is a easy-to-use web analytics. No cookies and fully compliant with GDPR, CCPA and PECR. -->
|
||||
<script defer data-site="YOUR_DOMAIN_HERE" src="https://api.nepcha.com/js/nepcha-analytics.js"></script>
|
||||
<script type="module" crossorigin src="/assets/index-12a5cbeb.js"></script>
|
||||
<link rel="stylesheet" href="/assets/index-c94d5694.css">
|
||||
|
||||
|
||||
<script type="module" crossorigin src="/assets/index-71db1823.js"></script>
|
||||
<link rel="stylesheet" href="/assets/index-dad5f6fa.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
@@ -0,0 +1,373 @@
|
||||
import crypto from 'crypto';
|
||||
import { db } from '../db/index.js';
|
||||
import { hasSuperuser } from '../auth/superuser.js';
|
||||
|
||||
const selectAllServers = db.prepare('SELECT * FROM servers ORDER BY id ASC');
|
||||
const selectServerById = db.prepare('SELECT * FROM servers WHERE id = ?');
|
||||
const selectServerByUrl = db.prepare('SELECT * FROM servers WHERE url = ?');
|
||||
const insertServer = db.prepare(`
|
||||
INSERT INTO servers (name, url)
|
||||
VALUES (?, ?)
|
||||
`);
|
||||
const updateServer = db.prepare(`
|
||||
UPDATE servers
|
||||
SET name = ?, url = ?, updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = ?
|
||||
`);
|
||||
|
||||
const deleteServerStmt = db.prepare('DELETE FROM servers WHERE id = ?');
|
||||
|
||||
const selectApiKeyByServerId = db.prepare('SELECT * FROM server_api_keys WHERE server_id = ?');
|
||||
const countApiKeysStmt = db.prepare('SELECT COUNT(*) as count FROM server_api_keys');
|
||||
const selectAllApiKeys = db.prepare('SELECT server_id, created_at, updated_at FROM server_api_keys');
|
||||
const upsertApiKey = db.prepare(`
|
||||
INSERT INTO server_api_keys (server_id, key_cipher, key_iv, key_tag, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
|
||||
ON CONFLICT(server_id) DO UPDATE SET
|
||||
key_cipher = excluded.key_cipher,
|
||||
key_iv = excluded.key_iv,
|
||||
key_tag = excluded.key_tag,
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
`);
|
||||
const deleteApiKeyByServerId = db.prepare('DELETE FROM server_api_keys WHERE server_id = ?');
|
||||
|
||||
const countServersStmt = db.prepare('SELECT COUNT(*) as count FROM servers');
|
||||
const selectFirstServerStmt = db.prepare('SELECT * FROM servers ORDER BY id ASC LIMIT 1');
|
||||
|
||||
const API_KEY_SECRET = crypto.createHash('sha256')
|
||||
.update(process.env.PORTAINER_API_SECRET || process.env.PORTAINER_API_KEY || 'stackpulse-portainer-api-key')
|
||||
.digest();
|
||||
|
||||
function encryptApiKey(apiKey) {
|
||||
if (!apiKey || typeof apiKey !== 'string') return null;
|
||||
const trimmed = apiKey.trim();
|
||||
if (!trimmed) return null;
|
||||
const iv = crypto.randomBytes(12);
|
||||
const cipher = crypto.createCipheriv('aes-256-gcm', API_KEY_SECRET, iv);
|
||||
const encrypted = Buffer.concat([cipher.update(trimmed, 'utf8'), cipher.final()]);
|
||||
const tag = cipher.getAuthTag();
|
||||
return {
|
||||
iv: iv.toString('base64'),
|
||||
content: encrypted.toString('base64'),
|
||||
tag: tag.toString('base64')
|
||||
};
|
||||
}
|
||||
|
||||
function decryptApiKey(row) {
|
||||
if (!row) return '';
|
||||
try {
|
||||
const iv = Buffer.from(row.key_iv, 'base64');
|
||||
const content = Buffer.from(row.key_cipher, 'base64');
|
||||
const tag = Buffer.from(row.key_tag, 'base64');
|
||||
const decipher = crypto.createDecipheriv('aes-256-gcm', API_KEY_SECRET, iv);
|
||||
decipher.setAuthTag(tag);
|
||||
const decrypted = Buffer.concat([decipher.update(content), decipher.final()]);
|
||||
return decrypted.toString('utf8');
|
||||
} catch (error) {
|
||||
console.warn('⚠️ [Setup] API-Key konnte nicht entschlüsselt werden:', error.message);
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeUrl(url) {
|
||||
if (!url || typeof url !== 'string') return null;
|
||||
const trimmed = url.trim();
|
||||
if (!trimmed) return null;
|
||||
try {
|
||||
const hasProtocol = /^[a-zA-Z][a-zA-Z0-9+.-]*:\/\//.test(trimmed);
|
||||
const candidate = hasProtocol ? trimmed : `https://${trimmed}`;
|
||||
const normalized = new URL(candidate);
|
||||
const pathname = normalized.pathname.replace(/\/+$/, '');
|
||||
normalized.pathname = pathname || '/';
|
||||
normalized.hash = '';
|
||||
return normalized.toString().replace(/\/$/, '');
|
||||
} catch (err) {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
function deriveServerName(url) {
|
||||
try {
|
||||
const parsed = new URL(url);
|
||||
return parsed.hostname || url;
|
||||
} catch (err) {
|
||||
return url;
|
||||
}
|
||||
}
|
||||
|
||||
function ensureServer({ name, url }) {
|
||||
const normalizedUrl = normalizeUrl(url);
|
||||
if (!normalizedUrl) {
|
||||
const error = new Error('SERVER_URL_REQUIRED');
|
||||
error.code = 'SERVER_URL_REQUIRED';
|
||||
throw error;
|
||||
}
|
||||
const normalizedName = (name || deriveServerName(normalizedUrl)).trim();
|
||||
if (!normalizedName) {
|
||||
const error = new Error('SERVER_NAME_REQUIRED');
|
||||
error.code = 'SERVER_NAME_REQUIRED';
|
||||
throw error;
|
||||
}
|
||||
|
||||
const existing = selectServerByUrl.get(normalizedUrl);
|
||||
if (existing) {
|
||||
if (existing.name !== normalizedName) {
|
||||
updateServer.run(normalizedName, normalizedUrl, existing.id);
|
||||
const updated = selectServerById.get(existing.id);
|
||||
console.log(`ℹ️ [Setup] Server aktualisiert: ${updated.name} (${updated.id})`);
|
||||
return updated;
|
||||
}
|
||||
return existing;
|
||||
}
|
||||
|
||||
const result = insertServer.run(normalizedName, normalizedUrl);
|
||||
const created = selectServerById.get(result.lastInsertRowid);
|
||||
console.log(`✅ [Setup] Server angelegt: ${created.name} (${created.id})`);
|
||||
return created;
|
||||
}
|
||||
|
||||
function setServerApiKey({ serverId, apiKey }) {
|
||||
const id = Number(serverId);
|
||||
if (!Number.isFinite(id)) {
|
||||
const error = new Error('SERVER_ID_INVALID');
|
||||
error.code = 'SERVER_ID_INVALID';
|
||||
throw error;
|
||||
}
|
||||
|
||||
const server = selectServerById.get(id);
|
||||
if (!server) {
|
||||
const error = new Error('SERVER_NOT_FOUND');
|
||||
error.code = 'SERVER_NOT_FOUND';
|
||||
throw error;
|
||||
}
|
||||
|
||||
const normalizedKey = typeof apiKey === 'string' ? apiKey.trim() : '';
|
||||
if (!normalizedKey) {
|
||||
const error = new Error('API_KEY_REQUIRED');
|
||||
error.code = 'API_KEY_REQUIRED';
|
||||
throw error;
|
||||
}
|
||||
|
||||
const encrypted = encryptApiKey(normalizedKey);
|
||||
if (!encrypted) {
|
||||
const error = new Error('API_KEY_ENCRYPT_FAILED');
|
||||
error.code = 'API_KEY_ENCRYPT_FAILED';
|
||||
throw error;
|
||||
}
|
||||
|
||||
const existing = selectApiKeyByServerId.get(id);
|
||||
upsertApiKey.run(id, encrypted.content, encrypted.iv, encrypted.tag);
|
||||
console.log(`${existing ? 'ℹ️' : '🔐'} [Setup] API-Key ${existing ? 'aktualisiert' : 'angelegt'}: Server ${server.name} (${server.id})`);
|
||||
|
||||
return {
|
||||
serverId: server.id,
|
||||
updatedAt: new Date().toISOString()
|
||||
};
|
||||
}
|
||||
|
||||
function getActiveApiKey() {
|
||||
const firstServer = selectFirstServerStmt.get();
|
||||
if (firstServer) {
|
||||
const row = selectApiKeyByServerId.get(firstServer.id);
|
||||
const key = decryptApiKey(row);
|
||||
if (key) return key;
|
||||
}
|
||||
|
||||
const envKey = process.env.PORTAINER_API_KEY ? process.env.PORTAINER_API_KEY.trim() : '';
|
||||
return envKey || '';
|
||||
}
|
||||
|
||||
function getActiveServerUrl() {
|
||||
const firstServer = selectFirstServerStmt.get();
|
||||
if (firstServer?.url) {
|
||||
const normalizedUrl = normalizeUrl(firstServer.url);
|
||||
if (normalizedUrl) {
|
||||
return normalizedUrl;
|
||||
}
|
||||
}
|
||||
|
||||
const envUrl = process.env.PORTAINER_URL ? process.env.PORTAINER_URL.trim() : '';
|
||||
const normalizedEnvUrl = envUrl ? normalizeUrl(envUrl) : '';
|
||||
return normalizedEnvUrl || '';
|
||||
}
|
||||
|
||||
function hasServer() {
|
||||
const { count } = countServersStmt.get();
|
||||
return count > 0;
|
||||
}
|
||||
|
||||
function removeServer(serverId) {
|
||||
const id = Number(serverId);
|
||||
if (!Number.isFinite(id)) {
|
||||
const error = new Error('SERVER_ID_INVALID');
|
||||
error.code = 'SERVER_ID_INVALID';
|
||||
throw error;
|
||||
}
|
||||
|
||||
const server = selectServerById.get(id);
|
||||
if (!server) {
|
||||
const error = new Error('SERVER_NOT_FOUND');
|
||||
error.code = 'SERVER_NOT_FOUND';
|
||||
throw error;
|
||||
}
|
||||
|
||||
const existingApiKey = selectApiKeyByServerId.get(id);
|
||||
|
||||
deleteServerStmt.run(id);
|
||||
|
||||
if (existingApiKey) {
|
||||
console.log(`🗑️ [Setup] API-Key entfernt: Server ${server.name} (${server.id})`);
|
||||
}
|
||||
|
||||
console.log(`🗑️ [Setup] Server entfernt: ${server.name} (${server.id})`);
|
||||
|
||||
return {
|
||||
server,
|
||||
removed: true
|
||||
};
|
||||
}
|
||||
|
||||
function hasApiKey() {
|
||||
const { count } = countApiKeysStmt.get();
|
||||
return count > 0;
|
||||
}
|
||||
|
||||
function hasCompleteSetup() {
|
||||
return hasSuperuser() && hasServer() && hasApiKey();
|
||||
}
|
||||
|
||||
function ensureDefaultsFromEnv() {
|
||||
const envServerUrlRaw = process.env.PORTAINER_URL;
|
||||
const envServerName = process.env.PORTAINER_SERVER_NAME;
|
||||
let server = null;
|
||||
if (envServerUrlRaw) {
|
||||
try {
|
||||
server = ensureServer({ name: envServerName, url: envServerUrlRaw });
|
||||
} catch (error) {
|
||||
console.error('⚠️ [Setup] Konnte Server aus Umgebungsvariablen nicht anlegen:', error.message);
|
||||
}
|
||||
}
|
||||
|
||||
const envApiKeyRaw = typeof process.env.PORTAINER_API_KEY === 'string' ? process.env.PORTAINER_API_KEY : '';
|
||||
if (envApiKeyRaw.trim()) {
|
||||
const targetServer = server || selectFirstServerStmt.get();
|
||||
if (targetServer) {
|
||||
try {
|
||||
setServerApiKey({ serverId: targetServer.id, apiKey: envApiKeyRaw.trim() });
|
||||
} catch (error) {
|
||||
console.error('⚠️ [Setup] Konnte API-Key aus Umgebungsvariablen nicht speichern:', error.message);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function getSetupStatus() {
|
||||
const servers = selectAllServers.all();
|
||||
const apiKeyRecords = selectAllApiKeys.all();
|
||||
const apiKeyMap = new Map(apiKeyRecords.map((entry) => [entry.server_id, entry]));
|
||||
|
||||
const rawEnvServerName = typeof process.env.PORTAINER_SERVER_NAME === 'string' ? process.env.PORTAINER_SERVER_NAME : '';
|
||||
const envServerName = rawEnvServerName.trim();
|
||||
const rawEnvServerUrl = typeof process.env.PORTAINER_URL === 'string' ? process.env.PORTAINER_URL : '';
|
||||
const envServerUrl = rawEnvServerUrl.trim();
|
||||
const rawEnvApiKey = typeof process.env.PORTAINER_API_KEY === 'string' ? process.env.PORTAINER_API_KEY : '';
|
||||
const envApiKeyTrimmed = rawEnvApiKey.trim();
|
||||
const envApiKeyProvided = Boolean(envApiKeyTrimmed);
|
||||
const envSuperuserUsernameRaw = typeof process.env.SUPERUSER_USERNAME === 'string' ? process.env.SUPERUSER_USERNAME : '';
|
||||
const envSuperuserEmailRaw = typeof process.env.SUPERUSER_EMAIL === 'string' ? process.env.SUPERUSER_EMAIL : '';
|
||||
const envSuperuserPasswordRaw = typeof process.env.SUPERUSER_PASSWORD === 'string' ? process.env.SUPERUSER_PASSWORD : '';
|
||||
const envSuperuserUsername = envSuperuserUsernameRaw.trim();
|
||||
const envSuperuserEmail = envSuperuserEmailRaw.trim();
|
||||
|
||||
const serverRequired = servers.length === 0;
|
||||
|
||||
const apiKeyItems = servers.map((server) => {
|
||||
const keyMeta = apiKeyMap.get(server.id) || null;
|
||||
return {
|
||||
serverId: server.id,
|
||||
serverName: server.name,
|
||||
hasKey: Boolean(keyMeta),
|
||||
updatedAt: keyMeta?.updated_at ?? null
|
||||
};
|
||||
});
|
||||
const apiKeyCount = apiKeyItems.filter((item) => item.hasKey).length;
|
||||
const apiKeyRequired = servers.length > 0 && apiKeyCount === 0;
|
||||
|
||||
const superuserExists = hasSuperuser();
|
||||
const setupComplete = superuserExists && !serverRequired && !apiKeyRequired;
|
||||
|
||||
return {
|
||||
superuser: {
|
||||
exists: superuserExists,
|
||||
envProvided: Boolean(envSuperuserUsername || envSuperuserEmail || envSuperuserPasswordRaw),
|
||||
env: {
|
||||
username: envSuperuserUsernameRaw,
|
||||
email: envSuperuserEmailRaw,
|
||||
password: envSuperuserPasswordRaw
|
||||
}
|
||||
},
|
||||
servers: {
|
||||
count: servers.length,
|
||||
items: servers,
|
||||
requireInput: serverRequired,
|
||||
envProvided: Boolean(envServerUrl)
|
||||
},
|
||||
apiKeys: {
|
||||
count: apiKeyCount,
|
||||
items: apiKeyItems,
|
||||
requireInput: apiKeyRequired,
|
||||
envProvided: envApiKeyProvided,
|
||||
envValue: rawEnvApiKey
|
||||
},
|
||||
requirements: {
|
||||
superuser: !superuserExists,
|
||||
server: serverRequired,
|
||||
apiKey: apiKeyRequired
|
||||
},
|
||||
setupComplete,
|
||||
envDefaults: {
|
||||
serverName: envServerName || (envServerUrl ? deriveServerName(envServerUrl) : ''),
|
||||
serverNameFromEnv: rawEnvServerName,
|
||||
serverUrl: envServerUrl,
|
||||
apiKeyProvided: envApiKeyProvided,
|
||||
apiKeyValue: rawEnvApiKey,
|
||||
superuserUsername: envSuperuserUsernameRaw,
|
||||
superuserEmail: envSuperuserEmailRaw,
|
||||
superuserPassword: envSuperuserPasswordRaw
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function completeSetup({ server: serverInput }) {
|
||||
let serverRecord = null;
|
||||
|
||||
if (serverInput && typeof serverInput.url === 'string' && serverInput.url.trim()) {
|
||||
serverRecord = ensureServer(serverInput);
|
||||
} else {
|
||||
serverRecord = selectFirstServerStmt.get();
|
||||
}
|
||||
|
||||
if (!serverRecord) {
|
||||
const error = new Error('SERVER_DETAILS_REQUIRED');
|
||||
error.code = 'SERVER_DETAILS_REQUIRED';
|
||||
throw error;
|
||||
}
|
||||
|
||||
return {
|
||||
server: serverRecord
|
||||
};
|
||||
}
|
||||
|
||||
export {
|
||||
ensureDefaultsFromEnv,
|
||||
ensureServer,
|
||||
setServerApiKey,
|
||||
getActiveApiKey,
|
||||
getActiveServerUrl,
|
||||
hasServer,
|
||||
hasApiKey,
|
||||
hasCompleteSetup,
|
||||
getSetupStatus,
|
||||
completeSetup,
|
||||
removeServer
|
||||
};
|
||||
@@ -0,0 +1,987 @@
|
||||
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 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 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;
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
import crypto from 'crypto';
|
||||
import { wordlist as englishWordlist } from '@scure/bip39/wordlists/english.js';
|
||||
|
||||
const FALLBACK_SECRET = 'stackpulse-security-phrase-secret';
|
||||
|
||||
const WORD_LIST = englishWordlist;
|
||||
|
||||
|
||||
const SECURITY_PHRASE_KEY = crypto
|
||||
.createHash('sha256')
|
||||
.update(String(process.env.SECURITY_PHRASE_SECRET || FALLBACK_SECRET))
|
||||
.digest();
|
||||
|
||||
const encryptValue = (value) => {
|
||||
const iv = crypto.randomBytes(12);
|
||||
const cipher = crypto.createCipheriv('aes-256-gcm', SECURITY_PHRASE_KEY, iv);
|
||||
const encrypted = Buffer.concat([cipher.update(value, 'utf8'), cipher.final()]);
|
||||
const tag = cipher.getAuthTag();
|
||||
return {
|
||||
iv: iv.toString('base64'),
|
||||
content: encrypted.toString('base64'),
|
||||
tag: tag.toString('base64')
|
||||
};
|
||||
};
|
||||
|
||||
const decryptValue = ({ content, iv, tag }) => {
|
||||
if (!content || !iv || !tag) {
|
||||
return '';
|
||||
}
|
||||
const decipher = crypto.createDecipheriv('aes-256-gcm', SECURITY_PHRASE_KEY, Buffer.from(iv, 'base64'));
|
||||
decipher.setAuthTag(Buffer.from(tag, 'base64'));
|
||||
const decrypted = Buffer.concat([
|
||||
decipher.update(Buffer.from(content, 'base64')),
|
||||
decipher.final()
|
||||
]);
|
||||
return decrypted.toString('utf8');
|
||||
};
|
||||
|
||||
export const generateSecurityPhraseWords = (count = 8) => {
|
||||
const words = [];
|
||||
for (let i = 0; i < count; i += 1) {
|
||||
const index = crypto.randomInt(0, WORD_LIST.length);
|
||||
words.push(WORD_LIST[index]);
|
||||
}
|
||||
return words;
|
||||
};
|
||||
|
||||
export const encodeWords = (words = []) => {
|
||||
return words
|
||||
.map((word) => {
|
||||
const index = WORD_LIST.indexOf(word);
|
||||
if (index === -1) {
|
||||
throw new Error(`SECURITY_PHRASE_WORD_UNKNOWN:${word}`);
|
||||
}
|
||||
return String(index);
|
||||
})
|
||||
.join('-');
|
||||
};
|
||||
|
||||
export const decodeWords = (encoded) => {
|
||||
if (!encoded) {
|
||||
return [];
|
||||
}
|
||||
return encoded.split('-').map((entry) => {
|
||||
const index = Number(entry);
|
||||
if (!Number.isFinite(index) || index < 0 || index >= WORD_LIST.length) {
|
||||
throw new Error('SECURITY_PHRASE_DECODE_FAILED');
|
||||
}
|
||||
return WORD_LIST[index];
|
||||
});
|
||||
};
|
||||
|
||||
export const encryptSecurityPhrase = (words) => {
|
||||
const encoded = encodeWords(words);
|
||||
const encrypted = encryptValue(encoded);
|
||||
return {
|
||||
encrypted,
|
||||
encoded
|
||||
};
|
||||
};
|
||||
|
||||
export const decryptSecurityPhrase = (record) => {
|
||||
if (!record) {
|
||||
return [];
|
||||
}
|
||||
const encoded = decryptValue(record);
|
||||
if (!encoded) {
|
||||
return [];
|
||||
}
|
||||
return decodeWords(encoded);
|
||||
};
|
||||
|
||||
export const formatPhraseForDisplay = (words) => {
|
||||
const rows = [];
|
||||
for (let i = 0; i < words.length; i += 4) {
|
||||
rows.push(words.slice(i, i + 4));
|
||||
}
|
||||
return rows;
|
||||
};
|
||||
|
||||
const normalizeWord = (value) => {
|
||||
if (!value && value !== 0) {
|
||||
return '';
|
||||
}
|
||||
return String(value).trim().toLowerCase();
|
||||
};
|
||||
|
||||
export const canonicalizeWords = (words = []) => {
|
||||
return words
|
||||
.map(normalizeWord)
|
||||
.filter((word) => word.length > 0)
|
||||
.join('');
|
||||
};
|
||||
|
||||
export const extractWordsFromString = (input) => {
|
||||
if (!input && input !== 0) {
|
||||
return [];
|
||||
}
|
||||
const normalized = String(input)
|
||||
.replace(/[\r\n\t]+/g, ' ')
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
if (!normalized) {
|
||||
return [];
|
||||
}
|
||||
return normalized.split(/\s+/).filter(Boolean);
|
||||
};
|
||||
|
||||
export const canonicalizePhraseInput = (input) => {
|
||||
if (Array.isArray(input)) {
|
||||
return canonicalizeWords(input);
|
||||
}
|
||||
return canonicalizeWords(extractWordsFromString(input));
|
||||
};
|
||||
|
||||
export default WORD_LIST;
|
||||
Reference in New Issue
Block a user