Initial commit: Aquarium Logbuch React/Vite App

- React 18 + Vite Frontend
- Node.js/Express Backend
- Vollstaendige Logbuch-Funktionalitaet fuer Aquarien
- Deploy-Script fuer aqualog CT 211 (192.168.0.246)
This commit is contained in:
2026-04-15 09:24:31 +02:00
commit a1f6a21828
11 changed files with 4614 additions and 0 deletions

14
backend/package.json Normal file
View File

@@ -0,0 +1,14 @@
{
"name": "aquarium-backend",
"version": "1.0.0",
"description": "Aquarium Logbuch API Backend",
"main": "server.js",
"scripts": {
"start": "node server.js"
},
"dependencies": {
"bcrypt": "^5.1.1",
"express": "^4.19.2",
"jsonwebtoken": "^9.0.2"
}
}

226
backend/server.js Normal file
View File

@@ -0,0 +1,226 @@
"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}`));