commit
This commit is contained in:
+141
-24
@@ -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}`);
|
||||
});
|
||||
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user