This commit is contained in:
jhartworks
2026-06-23 10:59:58 +02:00
parent 9b651f1cd6
commit a445fef521
21 changed files with 3161 additions and 664 deletions
+113 -26
View File
@@ -5,10 +5,12 @@ 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,
@@ -29,9 +31,21 @@ 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: config.corsOrigin,
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" }));
@@ -111,6 +125,22 @@ async function resolveSelectionPoints(body, user) {
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(
`
@@ -209,20 +239,20 @@ app.post("/api/auth/login", wrap(async (req, res) => {
const password = String(req.body.password || "");
const [rows] = await pool.query(
"SELECT * FROM app_users WHERE username = ? AND active = 1 LIMIT 1",
[username]
"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: "Invalid credentials." });
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: "Invalid credentials." });
return res.status(401).json({ error: "Ungültige Zugangsdaten." });
}
const serializedUser = serializeUser(user);
@@ -233,11 +263,37 @@ app.post("/api/auth/login", wrap(async (req, res) => {
});
}));
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: "User not found." });
return res.status(404).json({ error: "Benutzer nicht gefunden." });
}
return res.json({ user: serializeUser(rows[0]) });
@@ -286,11 +342,12 @@ app.put(
metadata.points[key] = {
...metadata.points[key],
alias: String(point.alias || metadata.points[key].alias).trim() || metadata.points[key].alias,
unit: String(point.unit || metadata.points[key].unit).trim(),
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,
};
});
}
@@ -302,6 +359,42 @@ app.put(
})
);
app.post("/api/trend-tables", authenticateToken, requireRoles("admin"), 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,
requireRoles("technician", "admin"),
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,
@@ -310,7 +403,7 @@ app.post(
const sql = normalizeSqlDump(req.body.sql);
if (!sql) {
throw new Error("SQL content is empty.");
throw new Error("SQL-Inhalt ist leer.");
}
await pool.query(sql);
@@ -334,7 +427,7 @@ app.post("/api/users", authenticateToken, requireRoles("admin"), wrap(async (req
const password = String(req.body.password || "");
if (!username || !name || password.length < 8) {
return res.status(400).json({ error: "Username, name and password are required." });
return res.status(400).json({ error: "Username, Name und Passwort sind erforderlich." });
}
const passwordHash = await bcrypt.hash(password, 10);
@@ -357,22 +450,16 @@ app.patch("/api/users/:id", authenticateToken, requireRoles("admin"), wrap(async
const [rows] = await pool.query("SELECT * FROM app_users WHERE id = ? LIMIT 1", [userId]);
if (!rows[0]) {
return res.status(404).json({ error: "User not found." });
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 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 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 active = typeof payload.active === "boolean" ? Number(payload.active) : current.active;
const passwordHash = payload.password
? await bcrypt.hash(String(payload.password), 10)
: current.password_hash;
const passwordHash = payload.password ? await bcrypt.hash(String(payload.password), 10) : current.password_hash;
await pool.query(
`
@@ -410,11 +497,11 @@ app.put("/api/selections/:selectionId", authenticateToken, wrap(async (req, res)
const existing = await getSelection(redis, req.params.selectionId);
if (!existing) {
return res.status(404).json({ error: "Selection not found." });
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: "You can only edit your own selections." });
return res.status(403).json({ error: "Du kannst nur eigene Auswahlen bearbeiten." });
}
const points = assertPoints(req.body.points || existing.points || []);
@@ -437,11 +524,11 @@ app.delete("/api/selections/:selectionId", authenticateToken, wrap(async (req, r
const existing = await getSelection(redis, req.params.selectionId);
if (!existing) {
return res.status(404).json({ error: "Selection not found." });
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: "You can only delete your own selections." });
return res.status(403).json({ error: "Du kannst nur eigene Auswahlen löschen." });
}
await deleteSelection(redis, existing.id);
@@ -475,9 +562,7 @@ app.post("/api/dashboard/data", authenticateToken, wrap(async (req, res) => {
const results = [];
for (const widget of widgets) {
const points = widget.selectionId
? await resolveSelectionPoints({ selectionId: widget.selectionId }, req.user)
: assertPoints(widget.points || []);
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));
@@ -506,9 +591,11 @@ app.use((error, _req, res, _next) => {
res.status(400).json({ error: error.message || "Unexpected error." });
});
await ensureUtf8Database();
await ensureUserSchema();
await ensureAdminUser();
app.listen(config.port, () => {
console.log(`SE Local Trenddata API listening on port ${config.port}`);
});