import bcrypt from "bcryptjs"; import cors from "cors"; import express from "express"; import { authenticateToken, createToken, requireRoles } from "./auth.js"; import { config } from "./config.js"; import { pool } from "./db.js"; import { createWideTrendTable, ensureIspMetadata, fetchLatestValues, fetchTrendSeries, getAvailableIsps, normalizeEngineeringUnit, normalizeRange, sanitizeIspName, sanitizePointIndex, updateIspMetadata, } from "./lib/isp.js"; import { deleteSelection, getDashboard, getPreferences, getRoleMap, getSelection, listRoles, listSelections, saveDashboard, savePreferences, saveRole, saveSelection, } from "./lib/store.js"; import { redis } from "./redis.js"; const app = express(); const wrap = (handler) => (req, res, next) => Promise.resolve(handler(req, res, next)).catch(next); const allowedOrigins = config.corsOrigin .split(",") .map((entry) => entry.trim()) .filter(Boolean); app.use( cors({ origin(origin, callback) { if (!origin || allowedOrigins.includes("*") || allowedOrigins.includes(origin)) { callback(null, true); return; } callback(new Error("Origin not allowed.")); }, }) ); app.use(express.json({ limit: "50mb" })); function serializeUser(row) { return { id: row.id, username: row.username, name: row.name, email: row.email, role: row.role, active: Boolean(row.active), createdAt: row.created_at, updatedAt: row.updated_at, }; } function assertPoints(points) { if (!Array.isArray(points) || points.length === 0) { throw new Error("At least one data point is required."); } return points.map((point) => ({ isp: sanitizeIspName(point.isp), pointIndex: sanitizePointIndex(point.pointIndex), })); } function canUseSelection(selection, user) { return Boolean(selection) && (selection.shared || selection.ownerId === user.sub || user.role === "admin"); } function slugifyUsername(value) { const base = String(value || "") .toLowerCase() .replace(/[^a-z0-9]+/g, "-") .replace(/^-+|-+$/g, "") .slice(0, 40); return base || "user"; } function nextAvailableUsername(seed, taken) { const base = slugifyUsername(seed); let candidate = base; let counter = 1; while (taken.has(candidate)) { candidate = `${base}-${counter}`; counter += 1; } taken.add(candidate); return candidate; } function normalizeSqlDump(sql) { return String(sql || "") .replace(/^\uFEFF/, "") .split(/\r?\n/) .filter((line) => !line.trim().toUpperCase().startsWith("DELIMITER ")) .join("\n") .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); if (!canUseSelection(selection, user)) { throw new Error("Selection not found or not accessible."); } return assertPoints(selection.points || []); } return assertPoints(body.points || []); } async function ensureUtf8Database() { await pool.query(`ALTER DATABASE \`${config.dbName}\` CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci`); const [tables] = await pool.query( `SELECT TABLE_NAME AS tableName FROM information_schema.TABLES WHERE TABLE_SCHEMA = ? AND (TABLE_NAME = 'app_users' OR TABLE_NAME LIKE 'isp%' OR TABLE_NAME LIKE 'trend_%')`, [config.dbName] ); for (const table of tables) { await pool.query(`ALTER TABLE \`${table.tableName}\` CONVERT TO CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci`); } } async function ensureUserSchema() { await pool.query( ` CREATE TABLE IF NOT EXISTS app_users ( id INT NOT NULL AUTO_INCREMENT, username VARCHAR(80) NOT NULL, name VARCHAR(120) NOT NULL, email VARCHAR(160) NULL, password_hash VARCHAR(255) NOT NULL, 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, PRIMARY KEY (id), UNIQUE KEY uniq_app_users_username (username), UNIQUE KEY uniq_app_users_email (email) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; ` ); try { await pool.query("ALTER TABLE app_users ADD COLUMN username VARCHAR(80) NULL AFTER id"); } catch {} try { await pool.query("ALTER TABLE app_users MODIFY email VARCHAR(160) NULL"); } catch {} const [users] = await pool.query("SELECT id, username, name, email FROM app_users ORDER BY id"); const taken = new Set(users.map((user) => user.username).filter(Boolean)); for (const user of users) { if (user.username) { continue; } const seed = user.email?.split("@")[0] || user.name || `user-${user.id}`; const username = nextAvailableUsername(seed, taken); await pool.query("UPDATE app_users SET username = ? WHERE id = ?", [username, user.id]); } try { await pool.query("ALTER TABLE app_users MODIFY username VARCHAR(80) NOT NULL"); } catch {} try { await pool.query("ALTER TABLE app_users ADD UNIQUE KEY uniq_app_users_username (username)"); } catch {} 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() { const [rows] = await pool.query( "SELECT * FROM app_users WHERE username = ? OR email = ? LIMIT 1", [config.adminUsername.toLowerCase(), config.adminEmail.toLowerCase()] ); if (rows[0]) { const user = rows[0]; await pool.query( ` UPDATE app_users SET username = ?, name = ?, email = COALESCE(email, ?), role = 'admin', active = 1 WHERE id = ? `, [config.adminUsername.toLowerCase(), config.adminName, config.adminEmail.toLowerCase(), user.id] ); return; } const passwordHash = await bcrypt.hash(config.adminPassword, 10); await pool.query( ` INSERT INTO app_users (username, name, email, password_hash, role, active) VALUES (?, ?, ?, ?, 'admin', 1) `, [config.adminUsername.toLowerCase(), config.adminName, config.adminEmail.toLowerCase(), passwordHash] ); } app.get("/api/health", wrap(async (_req, res) => { const [dbRows] = await pool.query("SELECT 1 AS ok"); const redisOk = await redis.ping(); res.json({ ok: dbRows[0]?.ok === 1 && redisOk === "PONG", timestamp: new Date().toISOString(), }); })); app.post("/api/auth/login", wrap(async (req, res) => { const username = String(req.body.username || "").trim().toLowerCase(); const password = String(req.body.password || ""); const [rows] = await pool.query( "SELECT * FROM app_users WHERE (username = ? OR email = ?) AND active = 1 LIMIT 1", [username, username] ); const user = rows[0]; if (!user) { return res.status(401).json({ error: "Ungültige Zugangsdaten." }); } const isValid = await bcrypt.compare(password, user.password_hash); if (!isValid) { return res.status(401).json({ error: "Ungültige Zugangsdaten." }); } const serializedUser = await serializeUserWithPermissions(user); return res.json({ token: createToken(serializedUser), user: serializedUser, }); })); app.post("/api/auth/change-password", authenticateToken, wrap(async (req, res) => { const currentPassword = String(req.body.currentPassword || ""); const nextPassword = String(req.body.nextPassword || ""); if (nextPassword.length < 8) { return res.status(400).json({ error: "Das neue Passwort muss mindestens 8 Zeichen lang sein." }); } const [rows] = await pool.query("SELECT * FROM app_users WHERE id = ? LIMIT 1", [req.user.sub]); const user = rows[0]; if (!user) { return res.status(404).json({ error: "Benutzer nicht gefunden." }); } const isValid = await bcrypt.compare(currentPassword, user.password_hash); if (!isValid) { return res.status(400).json({ error: "Das aktuelle Passwort stimmt nicht." }); } const passwordHash = await bcrypt.hash(nextPassword, 10); await pool.query("UPDATE app_users SET password_hash = ? WHERE id = ?", [passwordHash, user.id]); res.json({ ok: true }); })); app.get("/api/auth/me", authenticateToken, wrap(async (req, res) => { const [rows] = await pool.query("SELECT * FROM app_users WHERE id = ? LIMIT 1", [req.user.sub]); if (!rows[0]) { return res.status(404).json({ error: "Benutzer nicht gefunden." }); } return res.json({ user: await serializeUserWithPermissions(rows[0]) }); })); app.get("/api/isps", authenticateToken, wrap(async (_req, res) => { const isps = await getAvailableIsps(pool, config.dbName); const response = await Promise.all( isps.map(async (isp) => { const metadata = await ensureIspMetadata(redis, isp.tableName, isp.pointCount); return { ...isp, displayName: metadata.displayName, description: metadata.description, }; }) ); res.json({ isps: response }); })); app.get("/api/isps/:isp/aliases", authenticateToken, wrap(async (req, res) => { const metadata = await ensureIspMetadata(redis, req.params.isp); res.json(metadata); })); app.put( "/api/isps/:isp/aliases", authenticateToken, requirePermission("edit_aliases"), wrap(async (req, res) => { const { displayName, description, points } = req.body || {}; const updated = await updateIspMetadata(redis, req.params.isp, (metadata) => { if (typeof displayName === "string") { metadata.displayName = displayName.trim() || metadata.displayName; } if (typeof description === "string") { metadata.description = description.trim(); } if (Array.isArray(points)) { points.forEach((point) => { const pointIndex = sanitizePointIndex(point.pointIndex); const key = String(pointIndex); metadata.points[key] = { ...metadata.points[key], alias: String(point.alias || metadata.points[key].alias).trim() || metadata.points[key].alias, unit: normalizeEngineeringUnit(point.unit || metadata.points[key].unit), factor: Number.isFinite(Number(point.factor)) ? Number(point.factor) : metadata.points[key].factor, kind: point.kind === "digital" ? "digital" : "analog", min: Number.isFinite(Number(point.min)) ? Number(point.min) : metadata.points[key].min, max: Number.isFinite(Number(point.max)) ? Number(point.max) : metadata.points[key].max, color: typeof point.color === "string" ? point.color : metadata.points[key].color, }; }); } return metadata; }); res.json(updated); }) ); 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}`); const metadata = await updateIspMetadata(redis, safeName, (current) => ({ ...current, displayName: displayName || current.displayName, })); res.status(201).json({ tableName: safeName, metadata }); })); app.post( "/api/admin/normalize-alias-metadata", authenticateToken, requirePermission("edit_aliases"), wrap(async (req, res) => { const targetIsp = req.body?.isp ? sanitizeIspName(req.body.isp) : null; if (!targetIsp && req.user.role !== "admin") { return res.status(403).json({ error: "Nur Admins dürfen alle ISPs auf einmal bereinigen." }); } const targets = targetIsp ? [{ tableName: targetIsp, pointCount: 200 }] : await getAvailableIsps(pool, config.dbName); const normalized = []; for (const isp of targets) { const metadata = await ensureIspMetadata(redis, isp.tableName, isp.pointCount || 200); normalized.push({ isp: isp.tableName, metadata }); } res.json({ ok: true, normalizedCount: normalized.length, items: normalized }); }) ); app.post( "/api/admin/import-sql", authenticateToken, requirePermission("manage_sources"), wrap(async (req, res) => { const sql = normalizeSqlDump(req.body.sql); if (!sql) { throw new Error("SQL-Inhalt ist leer."); } await pool.query(sql); const isps = await getAvailableIsps(pool, config.dbName); res.json({ ok: true, importedTables: isps.length, filename: req.body.filename || null }); }) ); 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, 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 password = String(req.body.password || ""); const role = await resolveExistingRole(req.body.role, "viewer"); 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); await pool.query( ` INSERT INTO app_users (username, name, email, password_hash, role, active) VALUES (?, ?, ?, ?, ?, 1) `, [username, name, email, passwordHash, role] ); const [rows] = await pool.query("SELECT * FROM app_users WHERE username = ? LIMIT 1", [username]); res.status(201).json({ user: serializeUser(rows[0]) }); })); 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]); if (!rows[0]) { return res.status(404).json({ error: "Benutzer nicht gefunden." }); } const current = rows[0]; 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 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; await pool.query( ` UPDATE app_users SET username = ?, name = ?, email = ?, role = ?, active = ?, password_hash = ? WHERE id = ? `, [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/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, requirePermission("view_trends"), wrap(async (req, res) => { const points = assertPoints(req.body.points || []); const selection = await saveSelection(redis, { ownerId: req.user.sub, ownerName: req.user.name, name: String(req.body.name || "").trim() || "Neue Auswahl", description: String(req.body.description || "").trim(), shared: Boolean(req.body.shared), points, }); res.status(201).json({ selection }); })); app.put("/api/selections/:selectionId", authenticateToken, requirePermission("view_trends"), wrap(async (req, res) => { const existing = await getSelection(redis, req.params.selectionId); if (!existing) { return res.status(404).json({ error: "Auswahl nicht gefunden." }); } if (existing.ownerId !== req.user.sub && req.user.role !== "admin") { return res.status(403).json({ error: "Du kannst nur eigene Auswahlen bearbeiten." }); } const points = assertPoints(req.body.points || existing.points || []); const selection = await saveSelection( redis, { ...existing, name: String(req.body.name || existing.name).trim(), description: String(req.body.description || existing.description || "").trim(), shared: typeof req.body.shared === "boolean" ? req.body.shared : existing.shared, points, }, existing.id ); res.json({ selection }); })); app.delete("/api/selections/:selectionId", authenticateToken, requirePermission("view_trends"), wrap(async (req, res) => { const existing = await getSelection(redis, req.params.selectionId); if (!existing) { return res.status(404).json({ error: "Auswahl nicht gefunden." }); } if (existing.ownerId !== req.user.sub && req.user.role !== "admin") { return res.status(403).json({ error: "Du kannst nur eigene Auswahlen löschen." }); } await deleteSelection(redis, existing.id); res.status(204).send(); })); 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, 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, requirePermission("view_dashboard"), wrap(async (req, res) => { const dashboard = await getDashboard(redis, req.user.sub); res.json(dashboard); })); 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, requirePermission("view_dashboard"), wrap(async (req, res) => { const widgets = Array.isArray(req.body.widgets) ? req.body.widgets : []; const results = []; for (const widget of widgets) { const points = widget.selectionId ? await resolveSelectionPoints({ selectionId: widget.selectionId }, req.user) : assertPoints(widget.points || []); if (widget.type === "chart") { const trendData = await fetchTrendSeries(pool, redis, points, normalizeRange(widget.range)); results.push({ id: widget.id, type: widget.type, ...trendData }); } else { const values = await fetchLatestValues(pool, redis, points); results.push({ id: widget.id, type: widget.type, values }); } } res.json({ widgets: results }); })); app.get("/api/preferences", authenticateToken, wrap(async (req, res) => { const preferences = await getPreferences(redis, req.user.sub); res.json(preferences); })); app.put("/api/preferences", authenticateToken, wrap(async (req, res) => { const preferences = await savePreferences(redis, req.user.sub, req.body || {}); res.json(preferences); })); app.use((error, _req, res, _next) => { console.error(error); res.status(400).json({ error: error.message || "Unexpected error." }); }); await ensureUtf8Database(); await ensureUserSchema(); await ensureRolesSchema(); await ensureAdminUser(); app.listen(config.port, () => { console.log(`SE Local Trenddata API listening on port ${config.port}`); });