This commit is contained in:
jhartworks
2026-06-23 14:48:36 +02:00
parent a445fef521
commit f0cb94ce4e
8 changed files with 796 additions and 81 deletions
+141 -24
View File
@@ -20,10 +20,13 @@ import {
deleteSelection,
getDashboard,
getPreferences,
getRoleMap,
getSelection,
listRoles,
listSelections,
saveDashboard,
savePreferences,
saveRole,
saveSelection,
} from "./lib/store.js";
import { redis } from "./redis.js";
@@ -111,6 +114,70 @@ function normalizeSqlDump(sql) {
.trim();
}
const PERMISSION_CATALOG = [
{ id: "view_dashboard", label: "Dashboard anzeigen" },
{ id: "view_trends", label: "Trendansicht anzeigen" },
{ id: "edit_aliases", label: "Aliase bearbeiten" },
{ id: "manage_sources", label: "Quellen verwalten" },
{ id: "manage_users", label: "Benutzer verwalten" },
{ id: "manage_roles", label: "Rollen verwalten" },
{ id: "view_debug", label: "Treiber-Debug anzeigen" },
];
async function getUserPermissions(roleName) {
const roleMap = await getRoleMap(redis);
return roleMap.get(String(roleName || "").trim())?.permissions || [];
}
async function serializeUserWithPermissions(row) {
const user = serializeUser(row);
return {
...user,
permissions: await getUserPermissions(user.role),
};
}
function requirePermission(...permissions) {
return async (req, res, next) => {
try {
if (!req.user) {
return res.status(401).json({ error: "Authentication required." });
}
const granted = new Set(await getUserPermissions(req.user.role));
if (!permissions.some((permission) => granted.has(permission))) {
return res.status(403).json({ error: "Insufficient permissions." });
}
next();
} catch (error) {
next(error);
}
};
}
function normalizeRoleName(value) {
return String(value || "")
.trim()
.toLowerCase()
.replace(/[^a-z0-9_-]+/g, "-")
.replace(/^-+|-+$/g, "")
.slice(0, 40) || "viewer";
}
async function resolveExistingRole(roleName, fallback = "viewer") {
const roleMap = await getRoleMap(redis);
const normalized = normalizeRoleName(roleName);
return roleMap.has(normalized) ? normalized : fallback;
}
async function ensureRolesSchema() {
await listRoles(redis);
try {
await pool.query("ALTER TABLE app_users MODIFY role VARCHAR(80) NOT NULL DEFAULT 'viewer'");
} catch {}
}
async function resolveSelectionPoints(body, user) {
if (body.selectionId) {
const selection = await getSelection(redis, body.selectionId);
@@ -150,7 +217,7 @@ async function ensureUserSchema() {
name VARCHAR(120) NOT NULL,
email VARCHAR(160) NULL,
password_hash VARCHAR(255) NOT NULL,
role ENUM('viewer', 'technician', 'admin') NOT NULL DEFAULT 'viewer',
role VARCHAR(80) NOT NULL DEFAULT 'viewer',
active TINYINT(1) NOT NULL DEFAULT 1,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
@@ -193,6 +260,10 @@ async function ensureUserSchema() {
try {
await pool.query("ALTER TABLE app_users ADD UNIQUE KEY uniq_app_users_email (email)");
} catch {}
try {
await pool.query("ALTER TABLE app_users MODIFY role VARCHAR(80) NOT NULL DEFAULT 'viewer'");
} catch {}
}
async function ensureAdminUser() {
@@ -255,7 +326,7 @@ app.post("/api/auth/login", wrap(async (req, res) => {
return res.status(401).json({ error: "Ungültige Zugangsdaten." });
}
const serializedUser = serializeUser(user);
const serializedUser = await serializeUserWithPermissions(user);
return res.json({
token: createToken(serializedUser),
@@ -296,7 +367,7 @@ app.get("/api/auth/me", authenticateToken, wrap(async (req, res) => {
return res.status(404).json({ error: "Benutzer nicht gefunden." });
}
return res.json({ user: serializeUser(rows[0]) });
return res.json({ user: await serializeUserWithPermissions(rows[0]) });
}));
app.get("/api/isps", authenticateToken, wrap(async (_req, res) => {
@@ -323,7 +394,7 @@ app.get("/api/isps/:isp/aliases", authenticateToken, wrap(async (req, res) => {
app.put(
"/api/isps/:isp/aliases",
authenticateToken,
requireRoles("technician", "admin"),
requirePermission("edit_aliases"),
wrap(async (req, res) => {
const { displayName, description, points } = req.body || {};
const updated = await updateIspMetadata(redis, req.params.isp, (metadata) => {
@@ -359,7 +430,7 @@ app.put(
})
);
app.post("/api/trend-tables", authenticateToken, requireRoles("admin"), wrap(async (req, res) => {
app.post("/api/trend-tables", authenticateToken, requirePermission("manage_sources"), wrap(async (req, res) => {
const rawName = String(req.body.tableName || "").trim().toLowerCase();
const displayName = String(req.body.displayName || rawName).trim();
const safeName = await createWideTrendTable(pool, rawName.startsWith("trend_") ? rawName : `trend_${rawName}`);
@@ -373,7 +444,7 @@ app.post("/api/trend-tables", authenticateToken, requireRoles("admin"), wrap(asy
app.post(
"/api/admin/normalize-alias-metadata",
authenticateToken,
requireRoles("technician", "admin"),
requirePermission("edit_aliases"),
wrap(async (req, res) => {
const targetIsp = req.body?.isp ? sanitizeIspName(req.body.isp) : null;
@@ -398,7 +469,7 @@ app.post(
app.post(
"/api/admin/import-sql",
authenticateToken,
requireRoles("admin"),
requirePermission("manage_sources"),
wrap(async (req, res) => {
const sql = normalizeSqlDump(req.body.sql);
@@ -412,22 +483,26 @@ app.post(
})
);
app.get("/api/users", authenticateToken, requireRoles("admin"), wrap(async (_req, res) => {
app.get("/api/users", authenticateToken, requirePermission("manage_users"), wrap(async (_req, res) => {
const [rows] = await pool.query(
"SELECT id, username, name, email, role, active, created_at, updated_at FROM app_users ORDER BY username"
);
res.json({ users: rows.map(serializeUser) });
}));
app.post("/api/users", authenticateToken, requireRoles("admin"), wrap(async (req, res) => {
app.post("/api/users", authenticateToken, requirePermission("manage_users"), wrap(async (req, res) => {
const username = String(req.body.username || "").trim().toLowerCase();
const name = String(req.body.name || "").trim();
const email = String(req.body.email || "").trim().toLowerCase() || null;
const role = ["viewer", "technician", "admin"].includes(req.body.role) ? req.body.role : "viewer";
const password = String(req.body.password || "");
const role = await resolveExistingRole(req.body.role, "viewer");
if (!username || !name || password.length < 8) {
return res.status(400).json({ error: "Username, Name und Passwort sind erforderlich." });
if (!username || !name) {
return res.status(400).json({ error: "Username und Name sind erforderlich." });
}
if (password.length < 8) {
return res.status(400).json({ error: "Das Passwort muss mindestens 8 Zeichen lang sein." });
}
const passwordHash = await bcrypt.hash(password, 10);
@@ -444,7 +519,7 @@ app.post("/api/users", authenticateToken, requireRoles("admin"), wrap(async (req
res.status(201).json({ user: serializeUser(rows[0]) });
}));
app.patch("/api/users/:id", authenticateToken, requireRoles("admin"), wrap(async (req, res) => {
app.patch("/api/users/:id", authenticateToken, requirePermission("manage_users"), wrap(async (req, res) => {
const userId = Number(req.params.id);
const payload = req.body || {};
const [rows] = await pool.query("SELECT * FROM app_users WHERE id = ? LIMIT 1", [userId]);
@@ -457,7 +532,11 @@ app.patch("/api/users/:id", authenticateToken, requireRoles("admin"), wrap(async
const username = typeof payload.username === "string" ? payload.username.trim().toLowerCase() || current.username : current.username;
const name = typeof payload.name === "string" ? payload.name.trim() || current.name : current.name;
const email = typeof payload.email === "string" ? payload.email.trim().toLowerCase() || null : current.email;
const role = ["viewer", "technician", "admin"].includes(payload.role) ? payload.role : current.role;
const nextRole = payload.role ? await resolveExistingRole(payload.role, current.role) : current.role;
if (userId === req.user.sub && payload.role && nextRole !== current.role) {
return res.status(403).json({ error: "Die eigene Rolle kann nicht geändert werden." });
}
const active = typeof payload.active === "boolean" ? Number(payload.active) : current.active;
const passwordHash = payload.password ? await bcrypt.hash(String(payload.password), 10) : current.password_hash;
@@ -467,19 +546,54 @@ app.patch("/api/users/:id", authenticateToken, requireRoles("admin"), wrap(async
SET username = ?, name = ?, email = ?, role = ?, active = ?, password_hash = ?
WHERE id = ?
`,
[username, name, email, role, active, passwordHash, userId]
[username, name, email, nextRole, active, passwordHash, userId]
);
const [updatedRows] = await pool.query("SELECT * FROM app_users WHERE id = ? LIMIT 1", [userId]);
res.json({ user: serializeUser(updatedRows[0]) });
}));
app.get("/api/selections", authenticateToken, wrap(async (req, res) => {
app.get("/api/roles", authenticateToken, requirePermission("manage_roles"), wrap(async (_req, res) => {
const roles = await listRoles(redis);
res.json({ roles, permissions: PERMISSION_CATALOG });
}));
app.post("/api/roles", authenticateToken, requirePermission("manage_roles"), wrap(async (req, res) => {
const payload = req.body || {};
const name = normalizeRoleName(payload.name);
if (!name) {
return res.status(400).json({ error: "Rollenname fehlt." });
}
const role = await saveRole(redis, {
name,
label: String(payload.label || name).trim() || name,
permissions: Array.isArray(payload.permissions) ? payload.permissions : [],
});
res.status(201).json({ role });
}));
app.patch("/api/roles/:name", authenticateToken, requirePermission("manage_roles"), wrap(async (req, res) => {
const roleMap = await getRoleMap(redis);
const existing = roleMap.get(normalizeRoleName(req.params.name));
if (!existing) {
return res.status(404).json({ error: "Rolle nicht gefunden." });
}
const role = await saveRole(redis, {
...existing,
label: String(req.body.label || existing.label).trim() || existing.label,
permissions: Array.isArray(req.body.permissions) ? req.body.permissions : existing.permissions,
}, existing.name);
res.json({ role });
}));
app.get("/api/selections", authenticateToken, requirePermission("view_trends"), wrap(async (req, res) => {
const selections = await listSelections(redis, req.user);
res.json({ selections });
}));
app.post("/api/selections", authenticateToken, wrap(async (req, res) => {
app.post("/api/selections", authenticateToken, requirePermission("view_trends"), wrap(async (req, res) => {
const points = assertPoints(req.body.points || []);
const selection = await saveSelection(redis, {
ownerId: req.user.sub,
@@ -493,7 +607,7 @@ app.post("/api/selections", authenticateToken, wrap(async (req, res) => {
res.status(201).json({ selection });
}));
app.put("/api/selections/:selectionId", authenticateToken, wrap(async (req, res) => {
app.put("/api/selections/:selectionId", authenticateToken, requirePermission("view_trends"), wrap(async (req, res) => {
const existing = await getSelection(redis, req.params.selectionId);
if (!existing) {
@@ -520,7 +634,7 @@ app.put("/api/selections/:selectionId", authenticateToken, wrap(async (req, res)
res.json({ selection });
}));
app.delete("/api/selections/:selectionId", authenticateToken, wrap(async (req, res) => {
app.delete("/api/selections/:selectionId", authenticateToken, requirePermission("view_trends"), wrap(async (req, res) => {
const existing = await getSelection(redis, req.params.selectionId);
if (!existing) {
@@ -535,29 +649,29 @@ app.delete("/api/selections/:selectionId", authenticateToken, wrap(async (req, r
res.status(204).send();
}));
app.post("/api/trends/query", authenticateToken, wrap(async (req, res) => {
app.post("/api/trends/query", authenticateToken, requirePermission("view_trends"), wrap(async (req, res) => {
const points = await resolveSelectionPoints(req.body, req.user);
const trendData = await fetchTrendSeries(pool, redis, points, normalizeRange(req.body.range));
res.json(trendData);
}));
app.post("/api/points/latest", authenticateToken, wrap(async (req, res) => {
app.post("/api/points/latest", authenticateToken, requirePermission("view_trends"), wrap(async (req, res) => {
const points = await resolveSelectionPoints(req.body, req.user);
const values = await fetchLatestValues(pool, redis, points);
res.json({ values });
}));
app.get("/api/dashboard", authenticateToken, wrap(async (req, res) => {
app.get("/api/dashboard", authenticateToken, requirePermission("view_dashboard"), wrap(async (req, res) => {
const dashboard = await getDashboard(redis, req.user.sub);
res.json(dashboard);
}));
app.put("/api/dashboard", authenticateToken, wrap(async (req, res) => {
app.put("/api/dashboard", authenticateToken, requirePermission("view_dashboard"), wrap(async (req, res) => {
const dashboard = await saveDashboard(redis, req.user.sub, req.body || {});
res.json(dashboard);
}));
app.post("/api/dashboard/data", authenticateToken, wrap(async (req, res) => {
app.post("/api/dashboard/data", authenticateToken, requirePermission("view_dashboard"), wrap(async (req, res) => {
const widgets = Array.isArray(req.body.widgets) ? req.body.widgets : [];
const results = [];
@@ -593,9 +707,12 @@ app.use((error, _req, res, _next) => {
await ensureUtf8Database();
await ensureUserSchema();
await ensureRolesSchema();
await ensureAdminUser();
app.listen(config.port, () => {
console.log(`SE Local Trenddata API listening on port ${config.port}`);
});
+113
View File
@@ -1,10 +1,123 @@
const SELECTION_SET_KEY = "selection:ids";
const SELECTION_SEQ_KEY = "selection:seq";
const ROLE_SET_KEY = "role:names";
export const DEFAULT_ROLE_DEFINITIONS = [
{
name: "viewer",
label: "Viewer",
permissions: ["view_dashboard", "view_trends"],
isSystem: true,
},
{
name: "technician",
label: "Techniker",
permissions: ["view_dashboard", "view_trends", "edit_aliases", "manage_sources", "view_debug"],
isSystem: true,
},
{
name: "admin",
label: "Admin",
permissions: ["view_dashboard", "view_trends", "edit_aliases", "manage_sources", "manage_users", "manage_roles", "view_debug"],
isSystem: true,
},
];
function safeParse(jsonValue) {
return jsonValue ? JSON.parse(jsonValue) : null;
}
function normalizeRoleName(value) {
const normalized = String(value || "")
.trim()
.toLowerCase()
.replace(/[^a-z0-9_-]+/g, "-")
.replace(/^-+|-+$/g, "")
.slice(0, 40);
return normalized || "viewer";
}
function normalizePermissions(input) {
return [...new Set((Array.isArray(input) ? input : [])
.map((entry) => String(entry || "").trim())
.filter(Boolean))].sort();
}
function normalizeRoleRecord(role) {
const systemRole = DEFAULT_ROLE_DEFINITIONS.find((entry) => entry.name === normalizeRoleName(role?.name));
return {
name: normalizeRoleName(role?.name),
label: String(role?.label || systemRole?.label || role?.name || "Rolle").trim() || systemRole?.label || "Rolle",
permissions: normalizePermissions(role?.permissions || systemRole?.permissions || []),
isSystem: Boolean(role?.isSystem ?? systemRole?.isSystem),
updatedAt: role?.updatedAt || new Date().toISOString(),
};
}
async function ensureDefaultRoles(redis) {
for (const definition of DEFAULT_ROLE_DEFINITIONS) {
const key = `role:${definition.name}`;
const existing = safeParse(await redis.get(key));
const payload = normalizeRoleRecord({
...definition,
...(existing || {}),
name: definition.name,
label: existing?.label || definition.label,
permissions: Array.isArray(existing?.permissions) && existing.permissions.length ? existing.permissions : definition.permissions,
isSystem: true,
});
await redis.set(key, JSON.stringify(payload));
await redis.sadd(ROLE_SET_KEY, definition.name);
}
}
export async function listRoles(redis) {
await ensureDefaultRoles(redis);
const names = await redis.smembers(ROLE_SET_KEY);
const items = await Promise.all(names.map((name) => redis.get(`role:${name}`)));
return items
.map((item) => safeParse(item))
.filter(Boolean)
.map((role) => normalizeRoleRecord(role))
.sort((left, right) => left.label.localeCompare(right.label, "de-DE"));
}
export async function getRoleMap(redis) {
const roles = await listRoles(redis);
return new Map(roles.map((role) => [role.name, role]));
}
export async function saveRole(redis, role, previousName = "") {
await ensureDefaultRoles(redis);
const existingName = normalizeRoleName(previousName || role?.name);
const payload = normalizeRoleRecord(role);
const current = safeParse(await redis.get(`role:${existingName}`));
if (current?.isSystem && existingName !== payload.name) {
throw new Error("Systemrollen können nicht umbenannt werden.");
}
const merged = normalizeRoleRecord({
...(current || {}),
...payload,
name: current?.isSystem ? existingName : payload.name,
isSystem: Boolean(current?.isSystem || payload.isSystem),
updatedAt: new Date().toISOString(),
});
if (existingName && existingName !== merged.name) {
await redis.del(`role:${existingName}`);
await redis.srem(ROLE_SET_KEY, existingName);
}
await redis.set(`role:${merged.name}`, JSON.stringify(merged));
await redis.sadd(ROLE_SET_KEY, merged.name);
return merged;
}
export async function listSelections(redis, user) {
const ids = await redis.smembers(SELECTION_SET_KEY);
const items = await Promise.all(ids.map((id) => redis.get(`selection:${id}`)));