"use strict"; const express = require("express"); const bcrypt = require("bcrypt"); const jwt = require("jsonwebtoken"); const fs = require("fs"); const path = require("path"); const crypto = require("crypto"); const app = express(); const PORT = 3001; const DATA_FILE = "/var/lib/aquarium/data.json"; const JWT_SECRET = process.env.JWT_SECRET || crypto.randomBytes(48).toString("hex"); const SALT_ROUNDS = 12; app.use(express.json()); // ─── Data helpers ────────────────────────────────────────────────────────── function loadData() { try { if (fs.existsSync(DATA_FILE)) return JSON.parse(fs.readFileSync(DATA_FILE, "utf8")); } catch {} return { users: [], aquariums: [], entries: [], reminders: [] }; } function saveData(d) { const dir = path.dirname(DATA_FILE); if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true }); fs.writeFileSync(DATA_FILE, JSON.stringify(d, null, 2)); } // ─── Middleware ──────────────────────────────────────────────────────────── function auth(req, res, next) { const h = req.headers.authorization; if (!h || !h.startsWith("Bearer ")) return res.status(401).json({ error: "Nicht angemeldet" }); try { req.user = jwt.verify(h.slice(7), JWT_SECRET); next(); } catch { res.status(401).json({ error: "Sitzung abgelaufen" }); } } function admin(req, res, next) { if (req.user.role !== "admin") return res.status(403).json({ error: "Admin-Rechte erforderlich" }); next(); } function ownsAquarium(d, aqId, user) { const aq = d.aquariums.find(a => a.id === aqId); if (!aq) return null; if (aq.userId === user.id || user.role === "admin") return aq; return null; } // ─── Init admin ──────────────────────────────────────────────────────────── function initAdmin() { const d = loadData(); if (!d.users.find(u => u.username === "aqlab")) { d.users.push({ id: "admin-1", username: "aqlab", passwordHash: bcrypt.hashSync("D4sP4sswortBek0mmtKe1ner!", SALT_ROUNDS), role: "admin", displayName: "Administrator", createdAt: new Date().toISOString(), }); saveData(d); console.log("[aquarium] Admin-Benutzer 'aqlab' angelegt."); } } // ─── Auth routes ─────────────────────────────────────────────────────────── app.post("/api/auth/login", (req, res) => { const { username, password } = req.body || {}; if (!username || !password) return res.status(400).json({ error: "Benutzername und Passwort erforderlich" }); const d = loadData(); const u = d.users.find(x => x.username === username); if (!u || !bcrypt.compareSync(password, u.passwordHash)) return res.status(401).json({ error: "Ungültiger Benutzername oder Passwort" }); const token = jwt.sign({ id: u.id, username: u.username, role: u.role, displayName: u.displayName }, JWT_SECRET, { expiresIn: "14d" }); res.json({ token, user: { id: u.id, username: u.username, role: u.role, displayName: u.displayName } }); }); app.get("/api/auth/me", auth, (req, res) => res.json(req.user)); app.post("/api/auth/change-password", auth, (req, res) => { const { currentPassword, newPassword } = req.body || {}; if (!currentPassword || !newPassword) return res.status(400).json({ error: "Felder erforderlich" }); if (newPassword.length < 6) return res.status(400).json({ error: "Passwort mindestens 6 Zeichen" }); const d = loadData(); const u = d.users.find(x => x.id === req.user.id); if (!u || !bcrypt.compareSync(currentPassword, u.passwordHash)) return res.status(401).json({ error: "Aktuelles Passwort falsch" }); u.passwordHash = bcrypt.hashSync(newPassword, SALT_ROUNDS); saveData(d); res.json({ ok: true }); }); // ─── User management (admin) ─────────────────────────────────────────────── app.get("/api/users", auth, admin, (req, res) => { const d = loadData(); res.json(d.users.map(u => ({ id: u.id, username: u.username, role: u.role, displayName: u.displayName, createdAt: u.createdAt }))); }); app.post("/api/users", auth, admin, (req, res) => { const { username, password, role = "user", displayName = "" } = req.body || {}; if (!username || !password) return res.status(400).json({ error: "Benutzername und Passwort erforderlich" }); if (password.length < 6) return res.status(400).json({ error: "Passwort mindestens 6 Zeichen" }); const d = loadData(); if (d.users.find(u => u.username === username)) return res.status(409).json({ error: "Benutzername bereits vergeben" }); const u = { id: `user-${Date.now()}`, username, passwordHash: bcrypt.hashSync(password, SALT_ROUNDS), role, displayName: displayName || username, createdAt: new Date().toISOString() }; d.users.push(u); saveData(d); res.status(201).json({ id: u.id, username: u.username, role: u.role, displayName: u.displayName, createdAt: u.createdAt }); }); app.put("/api/users/:id", auth, admin, (req, res) => { const d = loadData(); const u = d.users.find(x => x.id === req.params.id); if (!u) return res.status(404).json({ error: "Benutzer nicht gefunden" }); if (req.body.displayName !== undefined) u.displayName = req.body.displayName; if (req.body.role !== undefined && u.id !== "admin-1") u.role = req.body.role; if (req.body.password && req.body.password.length >= 6) u.passwordHash = bcrypt.hashSync(req.body.password, SALT_ROUNDS); saveData(d); res.json({ id: u.id, username: u.username, role: u.role, displayName: u.displayName }); }); app.delete("/api/users/:id", auth, admin, (req, res) => { if (req.params.id === "admin-1") return res.status(403).json({ error: "Admin-Benutzer kann nicht gelöscht werden" }); if (req.params.id === req.user.id) return res.status(403).json({ error: "Eigener Account kann nicht gelöscht werden" }); const d = loadData(); const idx = d.users.findIndex(u => u.id === req.params.id); if (idx === -1) return res.status(404).json({ error: "Benutzer nicht gefunden" }); d.users.splice(idx, 1); saveData(d); res.json({ ok: true }); }); // ─── Aquarium routes ─────────────────────────────────────────────────────── app.get("/api/aquariums", auth, (req, res) => { const d = loadData(); const list = req.user.role === "admin" ? d.aquariums : d.aquariums.filter(a => a.userId === req.user.id); res.json(list); }); app.post("/api/aquariums", auth, (req, res) => { const { name, volume = 60, description = "" } = req.body || {}; if (!name?.trim()) return res.status(400).json({ error: "Name erforderlich" }); const d = loadData(); const aq = { id: `aq-${Date.now()}`, userId: req.user.id, name: name.trim(), volume: Number(volume) || 60, description, createdAt: new Date().toISOString() }; d.aquariums.push(aq); saveData(d); res.status(201).json(aq); }); app.put("/api/aquariums/:id", auth, (req, res) => { const d = loadData(); const aq = ownsAquarium(d, req.params.id, req.user); if (!aq) return res.status(404).json({ error: "Aquarium nicht gefunden" }); if (req.body.name?.trim()) aq.name = req.body.name.trim(); if (req.body.volume !== undefined) aq.volume = Number(req.body.volume) || aq.volume; if (req.body.description !== undefined) aq.description = req.body.description; saveData(d); res.json(aq); }); app.delete("/api/aquariums/:id", auth, (req, res) => { const d = loadData(); const aq = ownsAquarium(d, req.params.id, req.user); if (!aq) return res.status(404).json({ error: "Aquarium nicht gefunden" }); d.aquariums = d.aquariums.filter(a => a.id !== req.params.id); d.entries = d.entries.filter(e => e.aquariumId !== req.params.id); d.reminders = (d.reminders || []).filter(r => r.aquariumId !== req.params.id); saveData(d); res.json({ ok: true }); }); // ─── Entry routes ────────────────────────────────────────────────────────── app.get("/api/aquariums/:aqId/entries", auth, (req, res) => { const d = loadData(); if (!ownsAquarium(d, req.params.aqId, req.user)) return res.status(404).json({ error: "Aquarium nicht gefunden" }); const entries = d.entries .filter(e => e.aquariumId === req.params.aqId) .sort((a, b) => new Date(b.date) - new Date(a.date)); res.json(entries); }); app.post("/api/aquariums/:aqId/entries", auth, (req, res) => { const d = loadData(); if (!ownsAquarium(d, req.params.aqId, req.user)) return res.status(404).json({ error: "Aquarium nicht gefunden" }); const entry = { id: `e-${Date.now()}`, aquariumId: req.params.aqId, date: new Date().toISOString(), values: req.body.values || {}, note: req.body.note || "" }; d.entries.push(entry); saveData(d); res.status(201).json(entry); }); app.delete("/api/aquariums/:aqId/entries/:id", auth, (req, res) => { const d = loadData(); if (!ownsAquarium(d, req.params.aqId, req.user)) return res.status(404).json({ error: "Aquarium nicht gefunden" }); const idx = d.entries.findIndex(e => e.id === req.params.id && e.aquariumId === req.params.aqId); if (idx === -1) return res.status(404).json({ error: "Eintrag nicht gefunden" }); d.entries.splice(idx, 1); saveData(d); res.json({ ok: true }); }); // ─── Reminder routes ─────────────────────────────────────────────────────── app.get("/api/aquariums/:aqId/reminder", auth, (req, res) => { const d = loadData(); if (!ownsAquarium(d, req.params.aqId, req.user)) return res.status(404).json({ error: "Aquarium nicht gefunden" }); const r = (d.reminders || []).find(x => x.aquariumId === req.params.aqId) || null; res.json(r); }); app.put("/api/aquariums/:aqId/reminder", auth, (req, res) => { const d = loadData(); if (!ownsAquarium(d, req.params.aqId, req.user)) return res.status(404).json({ error: "Aquarium nicht gefunden" }); if (!d.reminders) d.reminders = []; const idx = d.reminders.findIndex(r => r.aquariumId === req.params.aqId); const r = { aquariumId: req.params.aqId, intervalDays: req.body.intervalDays ?? 7, lastChange: req.body.lastChange ?? new Date().toISOString(), enabled: req.body.enabled ?? true }; idx >= 0 ? (d.reminders[idx] = r) : d.reminders.push(r); saveData(d); res.json(r); }); initAdmin(); app.listen(PORT, "127.0.0.1", () => console.log(`[aquarium] API läuft auf :${PORT}`));