Auslagerung verschiedener teile aus der app.js

This commit is contained in:
Cay 2026-01-26 11:54:02 -01:00
parent 056c087e1a
commit 7e5896bc90
17 changed files with 1522 additions and 519 deletions

190
app.js
View File

@ -3,17 +3,17 @@ require("dotenv").config();
const express = require("express");
const session = require("express-session");
const helmet = require("helmet");
const mysql = require("mysql2/promise");
const fs = require("fs");
const path = require("path");
const expressLayouts = require("express-ejs-layouts");
// ✅ Verschlüsselte Config
const { configExists, saveConfig } = require("./config-manager");
// ✅ DB + Session Reset
// ✅ DB + Session Store
const db = require("./db");
const { getSessionStore, resetSessionStore } = require("./config/session");
const { getSessionStore } = require("./config/session");
// ✅ Setup Middleware + Setup Routes
const requireSetup = require("./middleware/requireSetup");
const setupRoutes = require("./routes/setup.routes");
// ✅ Routes (deine)
const adminRoutes = require("./routes/admin.routes");
@ -64,85 +64,48 @@ function passesModulo3(serial) {
return sum % 3 === 0;
}
/* ===============================
SETUP HTML
================================ */
function setupHtml(error = "") {
return `
<!doctype html>
<html lang="de">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Praxissoftware Setup</title>
<style>
body{font-family:Arial;background:#f4f4f4;padding:30px}
.card{max-width:520px;margin:auto;background:#fff;padding:22px;border-radius:14px}
input{width:100%;padding:10px;margin:6px 0;border:1px solid #ccc;border-radius:8px}
button{padding:10px 14px;border:0;border-radius:8px;background:#111;color:#fff;cursor:pointer}
.err{color:#b00020;margin:10px 0}
.hint{color:#666;font-size:13px;margin-top:12px}
</style>
</head>
<body>
<div class="card">
<h2>🔧 Datenbank Einrichtung</h2>
${error ? `<div class="err">❌ ${error}</div>` : ""}
<form method="POST" action="/setup">
<label>DB Host</label>
<input name="host" placeholder="85.215.63.122" required />
<label>DB Benutzer</label>
<input name="user" placeholder="praxisuser" required />
<label>DB Passwort</label>
<input name="password" type="password" required />
<label>DB Name</label>
<input name="name" placeholder="praxissoftware" required />
<button type="submit"> Speichern</button>
</form>
<div class="hint">
Die Daten werden verschlüsselt gespeichert (<b>config.enc</b>).<br/>
Danach wirst du automatisch auf die Loginseite weitergeleitet.
</div>
</div>
</body>
</html>
`;
}
/* ===============================
MIDDLEWARE
================================ */
app.use(express.urlencoded({ extended: true }));
app.use(express.json());
app.use(helmet());
app.use(
helmet({
contentSecurityPolicy: false,
})
);
app.use(
session({
name: "praxis.sid",
secret: process.env.SESSION_SECRET,
secret: process.env.SESSION_SECRET || "dev-secret",
store: getSessionStore(),
resave: false,
saveUninitialized: false,
}),
})
);
// ✅ i18n Middleware 1 (setzt res.locals.t + lang)
// ✅ i18n Middleware (SAFE)
app.use((req, res, next) => {
const lang = req.session.lang || "de";
try {
const lang = req.session.lang || "de";
const filePath = path.join(__dirname, "locales", `${lang}.json`);
const filePath = path.join(__dirname, "locales", `${lang}.json`);
const raw = fs.readFileSync(filePath, "utf-8");
let data = {};
if (fs.existsSync(filePath)) {
data = JSON.parse(fs.readFileSync(filePath, "utf-8"));
}
res.locals.t = JSON.parse(raw);
res.locals.lang = lang;
next();
res.locals.t = data;
res.locals.lang = lang;
next();
} catch (err) {
console.error("❌ i18n Fehler:", err.message);
res.locals.t = {};
res.locals.lang = "de";
next();
}
});
const flashMiddleware = require("./middleware/flash.middleware");
@ -152,20 +115,24 @@ app.use(express.static("public"));
app.use("/uploads", express.static("uploads"));
app.set("view engine", "ejs");
app.set("views", path.join(__dirname, "views"));
app.use(expressLayouts);
app.set("layout", "layout"); // verwendet views/layout.ejs
app.set("layout", "layout");
app.use((req, res, next) => {
res.locals.user = req.session.user || null;
next();
});
/* ===============================
SETUP ROUTES + SETUP GATE
WICHTIG: /setup zuerst mounten, danach requireSetup
================================ */
app.use("/setup", setupRoutes);
app.use(requireSetup);
/* ===============================
LICENSE/TRIAL GATE
- Trial startet automatisch, wenn noch NULL
- Wenn abgelaufen:
Admin -> /admin/serial-number
Arzt/Member -> /serial-number
================================ */
app.use(async (req, res, next) => {
try {
@ -189,7 +156,7 @@ app.use(async (req, res, next) => {
`SELECT id, serial_number, trial_started_at
FROM company_settings
ORDER BY id ASC
LIMIT 1`,
LIMIT 1`
);
const settings = rowsSettings?.[0];
@ -203,7 +170,7 @@ app.use(async (req, res, next) => {
.promise()
.query(
`UPDATE company_settings SET trial_started_at = NOW() WHERE id = ?`,
[settings.id],
[settings.id]
);
return next();
}
@ -230,57 +197,6 @@ app.use(async (req, res, next) => {
}
});
/* ===============================
SETUP ROUTES
================================ */
app.get("/setup", (req, res) => {
if (configExists()) return res.redirect("/");
return res.status(200).send(setupHtml());
});
app.post("/setup", async (req, res) => {
try {
const { host, user, password, name } = req.body;
if (!host || !user || !password || !name) {
return res.status(400).send(setupHtml("Bitte alle Felder ausfüllen."));
}
const conn = await mysql.createConnection({
host,
user,
password,
database: name,
});
await conn.query("SELECT 1");
await conn.end();
saveConfig({
db: { host, user, password, name },
});
if (typeof db.resetPool === "function") {
db.resetPool();
}
resetSessionStore();
return res.redirect("/");
} catch (err) {
return res
.status(500)
.send(setupHtml("DB Verbindung fehlgeschlagen: " + err.message));
}
});
// Wenn keine config.enc → alles außer /setup auf Setup umleiten
app.use((req, res, next) => {
if (!configExists() && req.path !== "/setup") {
return res.redirect("/setup");
}
next();
});
/* ===============================
Sprache ändern
================================ */
@ -302,14 +218,6 @@ app.get("/lang/:lang", (req, res) => {
/* ===============================
SERIAL PAGES
================================ */
/**
* /serial-number
* - Trial aktiv: zeigt Resttage + Button Dashboard
* - Trial abgelaufen:
* Admin -> redirect /admin/serial-number
* Arzt/Member -> trial_expired.ejs
*/
app.get("/serial-number", async (req, res) => {
try {
if (!req.session?.user) return res.redirect("/");
@ -318,7 +226,7 @@ app.get("/serial-number", async (req, res) => {
`SELECT id, serial_number, trial_started_at
FROM company_settings
ORDER BY id ASC
LIMIT 1`,
LIMIT 1`
);
const settings = rowsSettings?.[0];
@ -332,7 +240,7 @@ app.get("/serial-number", async (req, res) => {
.promise()
.query(
`UPDATE company_settings SET trial_started_at = NOW() WHERE id = ?`,
[settings.id],
[settings.id]
);
settings.trial_started_at = new Date();
}
@ -371,9 +279,6 @@ app.get("/serial-number", async (req, res) => {
}
});
/**
* Admin Seite: Seriennummer eingeben
*/
app.get("/admin/serial-number", async (req, res) => {
try {
if (!req.session?.user) return res.redirect("/");
@ -383,7 +288,7 @@ app.get("/admin/serial-number", async (req, res) => {
const [rowsSettings] = await db
.promise()
.query(
`SELECT serial_number FROM company_settings ORDER BY id ASC LIMIT 1`,
`SELECT serial_number FROM company_settings ORDER BY id ASC LIMIT 1`
);
const currentSerial = rowsSettings?.[0]?.serial_number || "";
@ -402,9 +307,6 @@ app.get("/admin/serial-number", async (req, res) => {
}
});
/**
* Admin Seite: Seriennummer speichern
*/
app.post("/admin/serial-number", async (req, res) => {
try {
if (!req.session?.user) return res.redirect("/");
@ -515,7 +417,7 @@ app.use((err, req, res, next) => {
SERVER
================================ */
const PORT = process.env.PORT || 51777;
const HOST = "127.0.0.1";
const HOST = process.env.HOST || "0.0.0.0";
app.listen(PORT, HOST, () => {
console.log(`Server läuft auf http://${HOST}:${PORT}`);

File diff suppressed because one or more lines are too long

View File

@ -266,8 +266,8 @@ async function showInvoiceOverview(req, res) {
res.render("admin/admin_invoice_overview", {
title: "Rechnungsübersicht",
sidebarPartial: "partials/sidebar-empty", // ✅ keine Sidebar
active: "",
sidebarPartial: "partials/admin-sidebar", // ✅ keine Sidebar
active: "invoices",
user: req.session.user,
lang: req.session.lang || "de",

View File

@ -53,7 +53,7 @@ function listMedications(req, res, next) {
lang: req.session.lang || "de",
});
});
}
}
// 💾 UPDATE
function updateMedication(req, res, next) {

View File

@ -0,0 +1,47 @@
const { configExists, loadConfig } = require("../config-manager");
/**
* Leitet beim ersten Programmstart automatisch zu /setup um,
* solange config.enc fehlt oder DB-Daten unvollständig sind.
*/
module.exports = function requireSetup(req, res, next) {
// ✅ Setup immer erlauben
if (req.path.startsWith("/setup")) return next();
// ✅ Static niemals blockieren
if (req.path.startsWith("/public")) return next();
if (req.path.startsWith("/css")) return next();
if (req.path.startsWith("/js")) return next();
if (req.path.startsWith("/images")) return next();
if (req.path.startsWith("/uploads")) return next();
if (req.path.startsWith("/favicon")) return next();
// ✅ Login/Logout erlauben
if (req.path.startsWith("/login")) return next();
if (req.path.startsWith("/logout")) return next();
// ✅ Wenn config.enc fehlt -> Setup erzwingen
if (!configExists()) {
return res.redirect("/setup");
}
// ✅ Wenn config existiert aber DB Daten fehlen -> Setup erzwingen
let cfg = null;
try {
cfg = loadConfig();
} catch (e) {
cfg = null;
}
const ok =
cfg?.db?.host &&
cfg?.db?.user &&
cfg?.db?.password &&
cfg?.db?.name;
if (!ok) {
return res.redirect("/setup");
}
next();
};

131
package-lock.json generated
View File

@ -23,6 +23,7 @@
"html-pdf-node": "^1.0.8",
"multer": "^2.0.2",
"mysql2": "^3.16.0",
"node-ssh": "^13.2.1",
"xlsx": "^0.18.5"
},
"devDependencies": {
@ -1648,6 +1649,14 @@
"dev": true,
"license": "MIT"
},
"node_modules/asn1": {
"version": "0.2.6",
"resolved": "https://registry.npmjs.org/asn1/-/asn1-0.2.6.tgz",
"integrity": "sha512-ix/FxPn0MDjeyJ7i/yoHGFt/EX6LyNbxSEhPPXODPL+KB0VPk86UYfL0lMdy+KCnv+fmvIzySwaK5COwqVbWTQ==",
"dependencies": {
"safer-buffer": "~2.1.0"
}
},
"node_modules/async": {
"version": "3.2.6",
"resolved": "https://registry.npmjs.org/async/-/async-3.2.6.tgz",
@ -1835,6 +1844,14 @@
"node": ">= 18"
}
},
"node_modules/bcrypt-pbkdf": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/bcrypt-pbkdf/-/bcrypt-pbkdf-1.0.2.tgz",
"integrity": "sha512-qeFIXtP4MSoi6NLqO12WfqARWWuCKi2Rn/9hJLEmtB5yTNr9DqFWkJRCf2qShWzPeAMRnOgCrq0sg/KLv5ES9w==",
"dependencies": {
"tweetnacl": "^0.14.3"
}
},
"node_modules/binary-extensions": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz",
@ -2062,6 +2079,15 @@
"integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==",
"license": "MIT"
},
"node_modules/buildcheck": {
"version": "0.0.7",
"resolved": "https://registry.npmjs.org/buildcheck/-/buildcheck-0.0.7.tgz",
"integrity": "sha512-lHblz4ahamxpTmnsk+MNTRWsjYKv965MwOrSJyeD588rR3Jcu7swE+0wN5F+PbL5cjgu/9ObkhfzEPuofEMwLA==",
"optional": true,
"engines": {
"node": ">=10.0.0"
}
},
"node_modules/busboy": {
"version": "1.6.0",
"resolved": "https://registry.npmjs.org/busboy/-/busboy-1.6.0.tgz",
@ -2514,6 +2540,20 @@
"dev": true,
"license": "MIT"
},
"node_modules/cpu-features": {
"version": "0.0.10",
"resolved": "https://registry.npmjs.org/cpu-features/-/cpu-features-0.0.10.tgz",
"integrity": "sha512-9IkYqtX3YHPCzoVg1Py+o9057a3i0fp7S530UWokCSaFVTc7CwXPRiOjRjBQQ18ZCNafx78YfnG+HALxtVmOGA==",
"hasInstallScript": true,
"optional": true,
"dependencies": {
"buildcheck": "~0.0.6",
"nan": "^2.19.0"
},
"engines": {
"node": ">=10.0.0"
}
},
"node_modules/crc-32": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/crc-32/-/crc-32-1.2.2.tgz",
@ -4111,7 +4151,6 @@
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz",
"integrity": "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8"
@ -5299,6 +5338,12 @@
"node": ">=8.0.0"
}
},
"node_modules/nan": {
"version": "2.25.0",
"resolved": "https://registry.npmjs.org/nan/-/nan-2.25.0.tgz",
"integrity": "sha512-0M90Ag7Xn5KMLLZ7zliPWP3rT90P6PN+IzVFS0VqmnPktBk3700xUVv8Ikm9EUaUE5SDWdp/BIxdENzVznpm1g==",
"optional": true
},
"node_modules/napi-postinstall": {
"version": "0.3.4",
"resolved": "https://registry.npmjs.org/napi-postinstall/-/napi-postinstall-0.3.4.tgz",
@ -5380,6 +5425,44 @@
"dev": true,
"license": "MIT"
},
"node_modules/node-ssh": {
"version": "13.2.1",
"resolved": "https://registry.npmjs.org/node-ssh/-/node-ssh-13.2.1.tgz",
"integrity": "sha512-rfl4GWMygQfzlExPkQ2LWyya5n2jOBm5vhEnup+4mdw7tQhNpJWbP5ldr09Jfj93k5SfY5lxcn8od5qrQ/6mBg==",
"dependencies": {
"is-stream": "^2.0.0",
"make-dir": "^3.1.0",
"sb-promise-queue": "^2.1.0",
"sb-scandir": "^3.1.0",
"shell-escape": "^0.2.0",
"ssh2": "^1.14.0"
},
"engines": {
"node": ">= 10"
}
},
"node_modules/node-ssh/node_modules/make-dir": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/make-dir/-/make-dir-3.1.0.tgz",
"integrity": "sha512-g3FeP20LNwhALb/6Cz6Dd4F2ngze0jz7tbzrD2wAV+o9FeNHe4rL+yK2md0J/fiSf1sa1ADhXqi5+oVwOM/eGw==",
"dependencies": {
"semver": "^6.0.0"
},
"engines": {
"node": ">=8"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/node-ssh/node_modules/semver": {
"version": "6.3.1",
"resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz",
"integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==",
"bin": {
"semver": "bin/semver.js"
}
},
"node_modules/nodemon": {
"version": "3.1.11",
"resolved": "https://registry.npmjs.org/nodemon/-/nodemon-3.1.11.tgz",
@ -6036,6 +6119,25 @@
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
"license": "MIT"
},
"node_modules/sb-promise-queue": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/sb-promise-queue/-/sb-promise-queue-2.1.1.tgz",
"integrity": "sha512-qXfdcJQMxMljxmPprn4Q4hl3pJmoljSCzUvvEBa9Kscewnv56n0KqrO6yWSrGLOL9E021wcGdPa39CHGKA6G0w==",
"engines": {
"node": ">= 8"
}
},
"node_modules/sb-scandir": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/sb-scandir/-/sb-scandir-3.1.1.tgz",
"integrity": "sha512-Q5xiQMtoragW9z8YsVYTAZcew+cRzdVBefPbb9theaIKw6cBo34WonP9qOCTKgyAmn/Ch5gmtAxT/krUgMILpA==",
"dependencies": {
"sb-promise-queue": "^2.1.0"
},
"engines": {
"node": ">= 8"
}
},
"node_modules/semver": {
"version": "7.7.3",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz",
@ -6137,6 +6239,11 @@
"node": ">=8"
}
},
"node_modules/shell-escape": {
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/shell-escape/-/shell-escape-0.2.0.tgz",
"integrity": "sha512-uRRBT2MfEOyxuECseCZd28jC1AJ8hmqqneWQ4VWUTgCAFvb3wKU1jLqj6egC4Exrr88ogg3dp+zroH4wJuaXzw=="
},
"node_modules/side-channel": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz",
@ -6311,6 +6418,23 @@
"node": ">=0.8"
}
},
"node_modules/ssh2": {
"version": "1.17.0",
"resolved": "https://registry.npmjs.org/ssh2/-/ssh2-1.17.0.tgz",
"integrity": "sha512-wPldCk3asibAjQ/kziWQQt1Wh3PgDFpC0XpwclzKcdT1vql6KeYxf5LIt4nlFkUeR8WuphYMKqUA56X4rjbfgQ==",
"hasInstallScript": true,
"dependencies": {
"asn1": "^0.2.6",
"bcrypt-pbkdf": "^1.0.2"
},
"engines": {
"node": ">=10.16.0"
},
"optionalDependencies": {
"cpu-features": "~0.0.10",
"nan": "^2.23.0"
}
},
"node_modules/stack-utils": {
"version": "2.0.6",
"resolved": "https://registry.npmjs.org/stack-utils/-/stack-utils-2.0.6.tgz",
@ -6726,6 +6850,11 @@
"license": "0BSD",
"optional": true
},
"node_modules/tweetnacl": {
"version": "0.14.5",
"resolved": "https://registry.npmjs.org/tweetnacl/-/tweetnacl-0.14.5.tgz",
"integrity": "sha512-KXXFFdAbFXY4geFIwoyNK+f5Z1b7swfXABfL7HXCmoIWMKU3dmS26672A4EeQtDzLKy7SXmfBu51JolvEKwtGA=="
},
"node_modules/type-detect": {
"version": "4.0.8",
"resolved": "https://registry.npmjs.org/type-detect/-/type-detect-4.0.8.tgz",

View File

@ -27,6 +27,7 @@
"html-pdf-node": "^1.0.8",
"multer": "^2.0.2",
"mysql2": "^3.16.0",
"node-ssh": "^13.2.1",
"xlsx": "^0.18.5"
},
"devDependencies": {

View File

@ -5,6 +5,8 @@ const fs = require("fs");
const path = require("path");
const { exec } = require("child_process");
const multer = require("multer");
const { NodeSSH } = require("node-ssh");
// ✅ Upload Ordner für Restore Dumps
const upload = multer({ dest: path.join(__dirname, "..", "uploads_tmp") });
@ -309,33 +311,37 @@ router.post("/database", requireAdmin, async (req, res) => {
/* ==========================
BACKUP (NUR ADMIN)
========================== */
router.post("/database/backup", requireAdmin, (req, res) => {
// ✅ Flash Safe (funktioniert auch ohne req.flash)
router.post("/database/backup", requireAdmin, async (req, res) => {
function flashSafe(type, msg) {
if (typeof req.flash === "function") {
req.flash(type, msg);
return;
}
if (typeof req.flash === "function") return req.flash(type, msg);
req.session.flash = req.session.flash || [];
req.session.flash.push({ type, message: msg });
console.log(`[FLASH-${type}]`, msg);
}
try {
const cfg = loadConfig();
if (!cfg?.db) {
flashSafe("danger", "❌ Keine DB Config gefunden (config.enc fehlt).");
return res.redirect("/admin/database");
}
const { host, user, password, name } = cfg.db;
const { host, port, user, password, name } = cfg.db;
// ✅ Programmserver Backup Dir
const backupDir = path.join(__dirname, "..", "backups");
if (!fs.existsSync(backupDir)) fs.mkdirSync(backupDir);
// ✅ SSH Ziel (DB-Server)
const sshHost = process.env.DBSERVER_HOST;
const sshUser = process.env.DBSERVER_USER;
const sshPort = Number(process.env.DBSERVER_PORT || 22);
if (!sshHost || !sshUser) {
flashSafe("danger", "❌ SSH Config fehlt (DBSERVER_HOST / DBSERVER_USER).");
return res.redirect("/admin/database");
}
const stamp = new Date()
.toISOString()
.replace(/T/, "_")
@ -343,120 +349,134 @@ router.post("/database/backup", requireAdmin, (req, res) => {
.split(".")[0];
const fileName = `${name}_${stamp}.sql`;
const filePath = path.join(backupDir, fileName);
// ✅ mysqldump.exe im Root
const mysqldumpPath = path.join(__dirname, "..", "mysqldump.exe");
// ✅ Datei wird zuerst auf DB-Server erstellt (tmp)
const remoteTmpPath = `/tmp/${fileName}`;
// ✅ plugin Ordner im Root (muss existieren)
const pluginDir = path.join(__dirname, "..", "plugin");
// ✅ Datei wird dann lokal (Programmserver) gespeichert
const localPath = path.join(backupDir, fileName);
if (!fs.existsSync(mysqldumpPath)) {
flashSafe("danger", "❌ mysqldump.exe nicht gefunden: " + mysqldumpPath);
return res.redirect("/admin/database");
}
if (!fs.existsSync(pluginDir)) {
flashSafe("danger", "❌ plugin Ordner nicht gefunden: " + pluginDir);
return res.redirect("/admin/database");
}
const cmd = `"${mysqldumpPath}" --plugin-dir="${pluginDir}" -h ${host} -u ${user} -p${password} ${name} > "${filePath}"`;
exec(cmd, (error, stdout, stderr) => {
if (error) {
console.error("❌ BACKUP ERROR:", error);
console.error("STDERR:", stderr);
flashSafe(
"danger",
"❌ Backup fehlgeschlagen: " + (stderr || error.message),
);
return res.redirect("/admin/database");
}
flashSafe("success", `✅ Backup erstellt: ${fileName}`);
return res.redirect("/admin/database");
const ssh = new NodeSSH();
await ssh.connect({
host: sshHost,
username: sshUser,
port: sshPort,
privateKeyPath: "/home/cay/.ssh/id_ed25519",
});
// ✅ 1) Dump auf DB-Server erstellen
const dumpCmd =
`mysqldump -h "${host}" -P "${port || 3306}" -u "${user}" -p'${password}' "${name}" > "${remoteTmpPath}"`;
const dumpRes = await ssh.execCommand(dumpCmd);
if (dumpRes.code !== 0) {
ssh.dispose();
flashSafe("danger", "❌ Backup fehlgeschlagen: " + (dumpRes.stderr || "mysqldump Fehler"));
return res.redirect("/admin/database");
}
// ✅ 2) Dump Datei vom DB-Server auf Programmserver kopieren
await ssh.getFile(localPath, remoteTmpPath);
// ✅ 3) Temp Datei auf DB-Server löschen
await ssh.execCommand(`rm -f "${remoteTmpPath}"`);
ssh.dispose();
flashSafe("success", `✅ Backup gespeichert (Programmserver): ${fileName}`);
return res.redirect("/admin/database");
} catch (err) {
console.error("❌ BACKUP ERROR:", err);
console.error("❌ BACKUP SSH ERROR:", err);
flashSafe("danger", "❌ Backup fehlgeschlagen: " + err.message);
return res.redirect("/admin/database");
}
});
/* ==========================
RESTORE (NUR ADMIN)
========================== */
router.post("/database/restore", requireAdmin, (req, res) => {
router.post("/database/restore", requireAdmin, async (req, res) => {
function flashSafe(type, msg) {
if (typeof req.flash === "function") {
req.flash(type, msg);
return;
}
if (typeof req.flash === "function") return req.flash(type, msg);
req.session.flash = req.session.flash || [];
req.session.flash.push({ type, message: msg });
console.log(`[FLASH-${type}]`, msg);
}
const ssh = new NodeSSH();
try {
const cfg = loadConfig();
if (!cfg?.db) {
flashSafe("danger", "❌ Keine DB Config gefunden (config.enc fehlt).");
return res.redirect("/admin/database");
}
const { host, user, password, name } = cfg.db;
const { host, port, user, password, name } = cfg.db;
const backupFile = req.body.backupFile;
if (!backupFile) {
flashSafe("danger", "❌ Kein Backup ausgewählt.");
return res.redirect("/admin/database");
}
if (backupFile.includes("..") || backupFile.includes("/") || backupFile.includes("\\")) {
flashSafe("danger", "❌ Ungültiger Dateiname.");
return res.redirect("/admin/database");
}
const backupDir = path.join(__dirname, "..", "backups");
const selectedFile = req.body.backupFile;
const localPath = path.join(backupDir, backupFile);
if (!selectedFile) {
flashSafe("danger", "❌ Bitte ein Backup auswählen.");
if (!fs.existsSync(localPath)) {
flashSafe("danger", "❌ Datei nicht gefunden (Programmserver): " + backupFile);
return res.redirect("/admin/database");
}
const fullPath = path.join(backupDir, selectedFile);
const sshHost = process.env.DBSERVER_HOST;
const sshUser = process.env.DBSERVER_USER;
const sshPort = Number(process.env.DBSERVER_PORT || 22);
if (!fs.existsSync(fullPath)) {
flashSafe("danger", "❌ Backup Datei nicht gefunden: " + selectedFile);
if (!sshHost || !sshUser) {
flashSafe("danger", "❌ SSH Config fehlt (DBSERVER_HOST / DBSERVER_USER).");
return res.redirect("/admin/database");
}
// ✅ mysql.exe im Root
const mysqlPath = path.join(__dirname, "..", "mysql.exe");
const pluginDir = path.join(__dirname, "..", "plugin");
const remoteTmpPath = `/tmp/${backupFile}`;
if (!fs.existsSync(mysqlPath)) {
flashSafe("danger", "❌ mysql.exe nicht gefunden im Root: " + mysqlPath);
return res.redirect("/admin/database");
}
const cmd = `"${mysqlPath}" --plugin-dir="${pluginDir}" -h ${host} -u ${user} -p${password} ${name} < "${fullPath}"`;
exec(cmd, (error, stdout, stderr) => {
if (error) {
console.error("❌ RESTORE ERROR:", error);
console.error("STDERR:", stderr);
flashSafe(
"danger",
"❌ Restore fehlgeschlagen: " + (stderr || error.message),
);
return res.redirect("/admin/database");
}
flashSafe(
"success",
"✅ Restore erfolgreich abgeschlossen: " + selectedFile,
);
return res.redirect("/admin/database");
await ssh.connect({
host: sshHost,
username: sshUser,
port: sshPort,
privateKeyPath: "/home/cay/.ssh/id_ed25519",
});
await ssh.putFile(localPath, remoteTmpPath);
const restoreCmd =
`mysql -h "${host}" -P "${port || 3306}" -u "${user}" -p'${password}' "${name}" < "${remoteTmpPath}"`;
const restoreRes = await ssh.execCommand(restoreCmd);
await ssh.execCommand(`rm -f "${remoteTmpPath}"`);
if (restoreRes.code !== 0) {
flashSafe("danger", "❌ Restore fehlgeschlagen: " + (restoreRes.stderr || "mysql Fehler"));
return res.redirect("/admin/database");
}
flashSafe("success", `✅ Restore erfolgreich: ${backupFile}`);
return res.redirect("/admin/database");
} catch (err) {
console.error("❌ RESTORE ERROR:", err);
console.error("❌ RESTORE SSH ERROR:", err);
flashSafe("danger", "❌ Restore fehlgeschlagen: " + err.message);
return res.redirect("/admin/database");
} finally {
try {
ssh.dispose();
} catch (e) {}
}
});

139
routes/setup.routes.js Normal file
View File

@ -0,0 +1,139 @@
const express = require("express");
const router = express.Router();
const mysql = require("mysql2/promise");
// ✅ nutzt deinen bestehenden config-manager (NICHT utils/config!)
const { configExists, saveConfig } = require("../config-manager");
// ✅ DB + Session Reset (wie in deiner app.js)
const db = require("../db");
const { resetSessionStore } = require("../config/session");
/**
* Setup darf nur laufen, wenn config.enc NICHT existiert
* (sonst könnte jeder die DB später überschreiben)
*/
function blockIfInstalled(req, res, next) {
if (configExists()) {
return res.redirect("/");
}
next();
}
/**
* Setup Form anzeigen
*/
router.get("/", blockIfInstalled, (req, res) => {
return res.render("setup/index", {
title: "Erstinstallation",
defaults: {
host: "127.0.0.1",
port: 3306,
user: "",
password: "",
name: "",
},
});
});
/**
* Verbindung testen (AJAX)
*/
router.post("/test", blockIfInstalled, async (req, res) => {
try {
const { host, port, user, password, name } = req.body;
if (!host || !user || !name) {
return res.status(400).json({
ok: false,
message: "Bitte Host, Benutzer und Datenbankname ausfüllen.",
});
}
const connection = await mysql.createConnection({
host,
port: Number(port || 3306),
user,
password,
database: name,
connectTimeout: 5000,
});
await connection.query("SELECT 1");
await connection.end();
return res.json({ ok: true, message: "✅ Verbindung erfolgreich!" });
} catch (err) {
return res.status(500).json({
ok: false,
message: "❌ Verbindung fehlgeschlagen: " + err.message,
});
}
});
/**
* Setup speichern (DB Daten in config.enc)
*/
router.post("/", blockIfInstalled, async (req, res) => {
try {
const { host, port, user, password, name } = req.body;
if (!host || !user || !name) {
req.session.flash = req.session.flash || [];
req.session.flash.push({
type: "danger",
message: "❌ Bitte Host, Benutzer und Datenbankname ausfüllen.",
});
return res.redirect("/setup");
}
// ✅ Verbindung testen bevor speichern
const connection = await mysql.createConnection({
host,
port: Number(port || 3306),
user,
password,
database: name,
connectTimeout: 5000,
});
await connection.query("SELECT 1");
await connection.end();
// ✅ speichern
saveConfig({
db: {
host,
port: Number(port || 3306),
user,
password,
name,
},
});
// ✅ DB Pool neu starten (damit neue config sofort aktiv ist)
if (typeof db.resetPool === "function") {
db.resetPool();
}
// ✅ Session Store neu starten
resetSessionStore();
req.session.flash = req.session.flash || [];
req.session.flash.push({
type: "success",
message: "✅ Setup abgeschlossen. Du kannst dich jetzt einloggen.",
});
return res.redirect("/login");
} catch (err) {
req.session.flash = req.session.flash || [];
req.session.flash.push({
type: "danger",
message: "❌ Setup fehlgeschlagen: " + err.message,
});
return res.redirect("/setup");
}
});
module.exports = router;

8
ssh_fuer_db_Server Normal file
View File

@ -0,0 +1,8 @@
-----BEGIN OPENSSH PRIVATE KEY-----
b3BlbnNzaC1rZXktdjEAAAAACmFlczI1Ni1jdHIAAAAGYmNyeXB0AAAAGAAAABDgIWJidh
WoA1VkDl2ScVZmAAAAGAAAAAEAAAAzAAAAC3NzaC1lZDI1NTE5AAAAIM0AYnTTnboA6hm+
zQcoG121zH1s0Jv2eOAuk+BS1UbfAAAAoJyvcKBc26mTti/T2iAbdEATM15fgtMs9Nlsi4
itZSVPfQ7OdYY2QMQpaiw0whrcQIdWlxUrbcYpmwPj7DATYUP00szFN2KbdRdsarfPqHFQ
zi8P2p9bj7/naRyLIENsfTj8JERxItd4xHvwL01keBmTty2hnprXcZHw4SkyIOIZyigTgS
7gkivG/j9jIOn4g0p0J1eaHdFNvJScpIs19SI=
-----END OPENSSH PRIVATE KEY-----

1
ssh_fuer_db_Server.pub Normal file
View File

@ -0,0 +1 @@
ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIM0AYnTTnboA6hm+zQcoG121zH1s0Jv2eOAuk+BS1Ubf cay@Cay-Workstation-VM

52
utils/config.js Normal file
View File

@ -0,0 +1,52 @@
const fs = require("fs");
const path = require("path");
const crypto = require("crypto");
const CONFIG_PATH = path.join(__dirname, "..", "config.enc");
function getKey() {
const raw = process.env.CONFIG_KEY;
if (!raw) {
throw new Error("CONFIG_KEY fehlt in .env");
}
return crypto.createHash("sha256").update(raw).digest(); // 32 bytes
}
function encrypt(obj) {
const iv = crypto.randomBytes(12);
const key = getKey();
const cipher = crypto.createCipheriv("aes-256-gcm", key, iv);
const data = Buffer.from(JSON.stringify(obj), "utf8");
const enc = Buffer.concat([cipher.update(data), cipher.final()]);
const tag = cipher.getAuthTag();
// [iv(12)] + [tag(16)] + [encData]
return Buffer.concat([iv, tag, enc]);
}
function decrypt(buf) {
const iv = buf.subarray(0, 12);
const tag = buf.subarray(12, 28);
const enc = buf.subarray(28);
const key = getKey();
const decipher = crypto.createDecipheriv("aes-256-gcm", key, iv);
decipher.setAuthTag(tag);
const data = Buffer.concat([decipher.update(enc), decipher.final()]);
return JSON.parse(data.toString("utf8"));
}
function loadConfig() {
if (!fs.existsSync(CONFIG_PATH)) return null;
const buf = fs.readFileSync(CONFIG_PATH);
return decrypt(buf);
}
function saveConfig(cfg) {
const buf = encrypt(cfg);
fs.writeFileSync(CONFIG_PATH, buf);
}
module.exports = { loadConfig, saveConfig, CONFIG_PATH };

View File

@ -1,3 +1,4 @@
<!-- ✅ Header -->
<%- include("../partials/page-header", {
user,
title: "Rechnungsübersicht",
@ -9,6 +10,7 @@
<!-- FILTER: JAHR VON / BIS -->
<div class="container-fluid mt-2">
<form method="get" class="row g-2 mb-4">
<div class="col-auto">
<input
@ -51,21 +53,21 @@
</tr>
</thead>
<tbody>
<% if (yearly.length === 0) { %>
<tr>
<td colspan="2" class="text-center text-muted">
Keine Daten
</td>
</tr>
<% if (!yearly || yearly.length === 0) { %>
<tr>
<td colspan="2" class="text-center text-muted">
Keine Daten
</td>
</tr>
<% } %>
<% yearly.forEach(y => { %>
<tr>
<td><%= y.year %></td>
<td class="text-end fw-semibold">
<%= Number(y.total).toFixed(2) %>
</td>
</tr>
<% (yearly || []).forEach(y => { %>
<tr>
<td><%= y.year %></td>
<td class="text-end fw-semibold">
<%= Number(y.total).toFixed(2) %>
</td>
</tr>
<% }) %>
</tbody>
</table>
@ -87,22 +89,22 @@
</tr>
</thead>
<tbody>
<% if (quarterly.length === 0) { %>
<tr>
<td colspan="3" class="text-center text-muted">
Keine Daten
</td>
</tr>
<% if (!quarterly || quarterly.length === 0) { %>
<tr>
<td colspan="3" class="text-center text-muted">
Keine Daten
</td>
</tr>
<% } %>
<% quarterly.forEach(q => { %>
<tr>
<td><%= q.year %></td>
<td>Q<%= q.quarter %></td>
<td class="text-end fw-semibold">
<%= Number(q.total).toFixed(2) %>
</td>
</tr>
<% (quarterly || []).forEach(q => { %>
<tr>
<td><%= q.year %></td>
<td>Q<%= q.quarter %></td>
<td class="text-end fw-semibold">
<%= Number(q.total).toFixed(2) %>
</td>
</tr>
<% }) %>
</tbody>
</table>
@ -123,21 +125,21 @@
</tr>
</thead>
<tbody>
<% if (monthly.length === 0) { %>
<tr>
<td colspan="2" class="text-center text-muted">
Keine Daten
</td>
</tr>
<% if (!monthly || monthly.length === 0) { %>
<tr>
<td colspan="2" class="text-center text-muted">
Keine Daten
</td>
</tr>
<% } %>
<% monthly.forEach(m => { %>
<tr>
<td><%= m.month %></td>
<td class="text-end fw-semibold">
<%= Number(m.total).toFixed(2) %>
</td>
</tr>
<% (monthly || []).forEach(m => { %>
<tr>
<td><%= m.month %></td>
<td class="text-end fw-semibold">
<%= Number(m.total).toFixed(2) %>
</td>
</tr>
<% }) %>
</tbody>
</table>
@ -182,21 +184,21 @@
</tr>
</thead>
<tbody>
<% if (patients.length === 0) { %>
<tr>
<td colspan="2" class="text-center text-muted">
Keine Daten
</td>
</tr>
<% if (!patients || patients.length === 0) { %>
<tr>
<td colspan="2" class="text-center text-muted">
Keine Daten
</td>
</tr>
<% } %>
<% patients.forEach(p => { %>
<tr>
<td><%= p.patient %></td>
<td class="text-end fw-semibold">
<%= Number(p.total).toFixed(2) %>
</td>
</tr>
<% (patients || []).forEach(p => { %>
<tr>
<td><%= p.patient %></td>
<td class="text-end fw-semibold">
<%= Number(p.total).toFixed(2) %>
</td>
</tr>
<% }) %>
</tbody>
</table>

View File

@ -1,252 +1,263 @@
<%- include("../partials/page-header", {
user,
title: "Datenbankverwaltung",
subtitle: "",
showUserName: true
}) %>
<div class="layout">
<div class="content p-4">
<!-- ✅ SIDEBAR (wie Dashboard, aber Admin Sidebar) -->
<%- include("../partials/admin-sidebar", { user, active: "database", lang }) %>
<%- include("../partials/flash") %>
<!-- ✅ MAIN -->
<div class="main">
<div class="container-fluid p-0">
<div class="row g-3">
<!-- ✅ HEADER (wie Dashboard) -->
<%- include("../partials/page-header", {
user,
title: "Datenbankverwaltung",
subtitle: "",
showUserName: true,
hideDashboardButton: true
}) %>
<!-- ✅ Sidebar -->
<div class="col-md-3 col-lg-2 p-0">
<%- include("../partials/admin-sidebar", { user, active: "database" }) %>
</div>
<div class="content p-4">
<!-- ✅ Content -->
<div class="col-md-9 col-lg-10">
<!-- Flash Messages -->
<%- include("../partials/flash") %>
<!-- ✅ DB Konfiguration -->
<div class="card shadow mb-3">
<div class="card-body">
<div class="container-fluid p-0">
<div class="row g-3">
<h4 class="mb-3">
<i class="bi bi-sliders"></i> Datenbank Konfiguration
</h4>
<!-- ✅ DB Konfiguration -->
<div class="col-12">
<div class="card shadow mb-3">
<div class="card-body">
<p class="text-muted mb-4">
Hier kannst du die DB-Verbindung testen und speichern.
</p>
<h4 class="mb-3">
<i class="bi bi-sliders"></i> Datenbank Konfiguration
</h4>
<!-- ✅ TEST (ohne speichern) + SPEICHERN -->
<form method="POST" action="/admin/database/test" class="row g-3 mb-3" autocomplete="off">
<div class="col-md-6">
<label class="form-label">Host / IP</label>
<input
type="text"
name="host"
class="form-control"
value="<%= dbConfig?.host || '' %>"
autocomplete="off"
required
>
</div>
<p class="text-muted mb-4">
Hier kannst du die DB-Verbindung testen und speichern.
</p>
<div class="col-md-3">
<label class="form-label">Port</label>
<input
type="number"
name="port"
class="form-control"
value="<%= dbConfig?.port || 3306 %>"
autocomplete="off"
required
>
</div>
<!-- ✅ TEST + SPEICHERN -->
<form method="POST" action="/admin/database/test" class="row g-3 mb-3" autocomplete="off">
<div class="col-md-3">
<label class="form-label">Datenbank</label>
<input
type="text"
name="name"
class="form-control"
value="<%= dbConfig?.name || '' %>"
autocomplete="off"
required
>
</div>
<div class="col-md-6">
<label class="form-label">Benutzer</label>
<input
type="text"
name="user"
class="form-control"
value="<%= dbConfig?.user || '' %>"
autocomplete="off"
required
>
</div>
<div class="col-md-6">
<label class="form-label">Passwort</label>
<input
type="password"
name="password"
class="form-control"
value="<%= dbConfig?.password || '' %>"
autocomplete="off"
required
>
</div>
<div class="col-12 d-flex flex-wrap gap-2">
<button type="submit" class="btn btn-outline-primary">
<i class="bi bi-plug"></i> Verbindung testen
</button>
<!-- ✅ Speichern + Testen -->
<button
type="submit"
class="btn btn-success"
formaction="/admin/database"
>
<i class="bi bi-save"></i> Speichern
</button>
</div>
</form>
<% if (typeof testResult !== "undefined" && testResult) { %>
<div class="alert <%= testResult.ok ? 'alert-success' : 'alert-danger' %> mb-0">
<%= testResult.message %>
</div>
<% } %>
</div>
</div>
<!-- ✅ System Info -->
<div class="card shadow mb-3">
<div class="card-body">
<h4 class="mb-3">
<i class="bi bi-info-circle"></i> Systeminformationen
</h4>
<% if (typeof systemInfo !== "undefined" && systemInfo?.error) { %>
<div class="alert alert-danger mb-0">
❌ Fehler beim Auslesen der Datenbankinfos:
<div class="mt-2"><code><%= systemInfo.error %></code></div>
</div>
<% } else if (typeof systemInfo !== "undefined" && systemInfo) { %>
<div class="row g-3">
<div class="col-md-4">
<div class="border rounded p-3 h-100">
<div class="text-muted small">MySQL Version</div>
<div class="fw-bold"><%= systemInfo.version %></div>
<div class="col-md-6">
<label class="form-label">Host / IP</label>
<input
type="text"
name="host"
class="form-control"
value="<%= (typeof dbConfig !== 'undefined' && dbConfig && dbConfig.host) ? dbConfig.host : '' %>"
autocomplete="off"
required
>
</div>
</div>
<div class="col-md-4">
<div class="border rounded p-3 h-100">
<div class="text-muted small">Anzahl Tabellen</div>
<div class="fw-bold"><%= systemInfo.tableCount %></div>
<div class="col-md-3">
<label class="form-label">Port</label>
<input
type="number"
name="port"
class="form-control"
value="<%= (typeof dbConfig !== 'undefined' && dbConfig && dbConfig.port) ? dbConfig.port : 3306 %>"
autocomplete="off"
required
>
</div>
</div>
<div class="col-md-4">
<div class="border rounded p-3 h-100">
<div class="text-muted small">Datenbankgröße</div>
<div class="fw-bold"><%= systemInfo.dbSizeMB %> MB</div>
<div class="col-md-3">
<label class="form-label">Datenbank</label>
<input
type="text"
name="name"
class="form-control"
value="<%= (typeof dbConfig !== 'undefined' && dbConfig && dbConfig.name) ? dbConfig.name : '' %>"
autocomplete="off"
required
>
</div>
</div>
<div class="col-md-6">
<label class="form-label">Benutzer</label>
<input
type="text"
name="user"
class="form-control"
value="<%= (typeof dbConfig !== 'undefined' && dbConfig && dbConfig.user) ? dbConfig.user : '' %>"
autocomplete="off"
required
>
</div>
<div class="col-md-6">
<label class="form-label">Passwort</label>
<input
type="password"
name="password"
class="form-control"
value="<%= (typeof dbConfig !== 'undefined' && dbConfig && dbConfig.password) ? dbConfig.password : '' %>"
autocomplete="off"
required
>
</div>
<div class="col-12 d-flex flex-wrap gap-2">
<button type="submit" class="btn btn-outline-primary">
<i class="bi bi-plug"></i> Verbindung testen
</button>
<button
type="submit"
class="btn btn-success"
formaction="/admin/database"
>
<i class="bi bi-save"></i> Speichern
</button>
</div>
</form>
<% if (typeof testResult !== "undefined" && testResult) { %>
<div class="alert <%= testResult.ok ? 'alert-success' : 'alert-danger' %> mb-0">
<%= testResult.message %>
</div>
<% } %>
</div>
<% if (systemInfo.tables && systemInfo.tables.length > 0) { %>
<hr>
<h6 class="mb-2">Tabellenübersicht</h6>
<div class="table-responsive">
<table class="table table-sm table-bordered table-hover align-middle">
<thead class="table-dark">
<tr>
<th>Tabelle</th>
<th class="text-end">Zeilen</th>
<th class="text-end">Größe (MB)</th>
</tr>
</thead>
<tbody>
<% systemInfo.tables.forEach(t => { %>
<tr>
<td><%= t.name %></td>
<td class="text-end"><%= t.row_count %></td>
<td class="text-end"><%= t.size_mb %></td>
</tr>
<% }) %>
</tbody>
</table>
</div>
<% } %>
<% } else { %>
<div class="alert alert-warning mb-0">
⚠️ Keine Systeminfos verfügbar (DB ist evtl. nicht konfiguriert oder Verbindung fehlgeschlagen).
</div>
<% } %>
</div>
</div>
<!-- ✅ Backup & Restore -->
<div class="card shadow">
<div class="card-body">
<h4 class="mb-3">
<i class="bi bi-hdd-stack"></i> Backup & Restore
</h4>
<div class="d-flex flex-wrap gap-3">
<!-- ✅ Backup erstellen -->
<form action="/admin/database/backup" method="POST">
<button type="submit" class="btn btn-primary">
<i class="bi bi-download"></i> Backup erstellen
</button>
</form>
<!-- ✅ Restore auswählen -->
<form action="/admin/database/restore" method="POST">
<div class="input-group">
<select name="backupFile" class="form-select" required>
<option value="">Backup auswählen...</option>
<% (typeof backupFiles !== "undefined" && backupFiles ? backupFiles : []).forEach(file => { %>
<option value="<%= file %>"><%= file %></option>
<% }) %>
</select>
<button type="submit" class="btn btn-warning">
<i class="bi bi-upload"></i> Restore starten
</button>
</div>
</form>
</div>
<% if (typeof backupFiles === "undefined" || !backupFiles || backupFiles.length === 0) { %>
<div class="alert alert-secondary mt-3 mb-0">
Noch keine Backups vorhanden.
</div>
<% } %>
</div>
</div>
<!-- ✅ System Info -->
<div class="col-12">
<div class="card shadow mb-3">
<div class="card-body">
<h4 class="mb-3">
<i class="bi bi-info-circle"></i> Systeminformationen
</h4>
<% if (typeof systemInfo !== "undefined" && systemInfo && systemInfo.error) { %>
<div class="alert alert-danger mb-0">
❌ Fehler beim Auslesen der Datenbankinfos:
<div class="mt-2"><code><%= systemInfo.error %></code></div>
</div>
<% } else if (typeof systemInfo !== "undefined" && systemInfo) { %>
<div class="row g-3">
<div class="col-md-4">
<div class="border rounded p-3 h-100">
<div class="text-muted small">MySQL Version</div>
<div class="fw-bold"><%= systemInfo.version %></div>
</div>
</div>
<div class="col-md-4">
<div class="border rounded p-3 h-100">
<div class="text-muted small">Anzahl Tabellen</div>
<div class="fw-bold"><%= systemInfo.tableCount %></div>
</div>
</div>
<div class="col-md-4">
<div class="border rounded p-3 h-100">
<div class="text-muted small">Datenbankgröße</div>
<div class="fw-bold"><%= systemInfo.dbSizeMB %> MB</div>
</div>
</div>
</div>
<% if (systemInfo.tables && systemInfo.tables.length > 0) { %>
<hr>
<h6 class="mb-2">Tabellenübersicht</h6>
<div class="table-responsive">
<table class="table table-sm table-bordered table-hover align-middle">
<thead class="table-dark">
<tr>
<th>Tabelle</th>
<th class="text-end">Zeilen</th>
<th class="text-end">Größe (MB)</th>
</tr>
</thead>
<tbody>
<% systemInfo.tables.forEach(t => { %>
<tr>
<td><%= t.name %></td>
<td class="text-end"><%= t.row_count %></td>
<td class="text-end"><%= t.size_mb %></td>
</tr>
<% }) %>
</tbody>
</table>
</div>
<% } %>
<% } else { %>
<div class="alert alert-warning mb-0">
⚠️ Keine Systeminfos verfügbar (DB ist evtl. nicht konfiguriert oder Verbindung fehlgeschlagen).
</div>
<% } %>
</div>
</div>
</div>
<!-- ✅ Backup & Restore -->
<div class="col-12">
<div class="card shadow">
<div class="card-body">
<h4 class="mb-3">
<i class="bi bi-hdd-stack"></i> Backup & Restore
</h4>
<div class="d-flex flex-wrap gap-3">
<!-- ✅ Backup erstellen -->
<form action="/admin/database/backup" method="POST">
<button type="submit" class="btn btn-primary">
<i class="bi bi-download"></i> Backup erstellen
</button>
</form>
<!-- ✅ Restore auswählen -->
<form action="/admin/database/restore" method="POST">
<div class="input-group">
<select name="backupFile" class="form-select" required>
<option value="">Backup auswählen...</option>
<% (typeof backupFiles !== "undefined" && backupFiles ? backupFiles : []).forEach(file => { %>
<option value="<%= file %>"><%= file %></option>
<% }) %>
</select>
<button type="submit" class="btn btn-warning">
<i class="bi bi-upload"></i> Restore starten
</button>
</div>
</form>
</div>
<% if (typeof backupFiles === "undefined" || !backupFiles || backupFiles.length === 0) { %>
<div class="alert alert-secondary mt-3 mb-0">
Noch keine Backups vorhanden.
</div>
<% } %>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>

View File

@ -127,10 +127,4 @@
</div>
</div>
</div>
<script>
// ⚠️ Inline Script wird von CSP blockiert!
// Wenn du diese Buttons brauchst, sag Bescheid,
// dann verlagern wir das sauber in /public/js/admin-users.js (CSP safe).
</script>
</div>

84
views/setup/index.ejs Normal file
View File

@ -0,0 +1,84 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<title><%= title %></title>
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<style>
body { font-family: Arial, sans-serif; background:#f5f5f5; padding:20px; }
.card { max-width: 560px; margin: 0 auto; background: white; padding: 20px; border-radius: 12px; box-shadow: 0 4px 16px rgba(0,0,0,.08); }
label { display:block; margin-top: 12px; font-weight: 600; }
input { width: 100%; padding: 10px; margin-top: 6px; border-radius: 8px; border: 1px solid #ddd; }
.row { display:flex; gap: 12px; }
.row > div { flex: 1; }
button { margin-top: 16px; padding: 10px 14px; border: 0; border-radius: 10px; cursor:pointer; }
.btn-primary { background:#2563eb; color:white; }
.btn-secondary { background:#111827; color:white; }
.msg { margin-top: 10px; padding:10px; border-radius: 10px; display:none; }
.msg.ok { background:#dcfce7; color:#166534; }
.msg.bad { background:#fee2e2; color:#991b1b; }
</style>
</head>
<body>
<div class="card">
<h2>🛠️ Erstinstallation</h2>
<p>Bitte DB Daten eingeben. Danach wird <code>config.enc</code> gespeichert.</p>
<form method="POST" action="/setup">
<label>DB Host</label>
<input name="host" placeholder="192.168.0.86" required />
<label>DB Port</label>
<input name="port" placeholder="3306" value="3306" required />
<label>DB Benutzer</label>
<input name="user" placeholder="praxisuser" required />
<label>DB Passwort</label>
<input name="password" type="password" required />
<label>DB Name</label>
<input name="name" placeholder="praxissoftware" required />
<label>Passwort</label>
<input name="password" type="password" value="<%= defaults.password %>" />
<button type="button" class="btn-secondary" onclick="testConnection()">🔍 Verbindung testen</button>
<button type="submit" class="btn-primary">✅ Speichern & Setup abschließen</button>
<div id="msg" class="msg"></div>
</form>
</div>
<script>
async function testConnection() {
const form = document.querySelector("form");
const data = new FormData(form);
const body = Object.fromEntries(data.entries());
const msg = document.getElementById("msg");
msg.style.display = "block";
msg.className = "msg";
msg.textContent = "Teste Verbindung...";
try {
const res = await fetch("/setup/test", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body)
});
const json = await res.json();
msg.textContent = json.message;
if (json.ok) msg.classList.add("ok");
else msg.classList.add("bad");
} catch (e) {
msg.textContent = "❌ Fehler: " + e.message;
msg.classList.add("bad");
}
}
</script>
</body>
</html>