Release v1.0.1: PVE Auth Fix, Logout Cache-Buster und Docker-Hub Vorbereitung

This commit is contained in:
root
2026-08-23 21:15:05 +02:00
parent 4526301fd1
commit cc7bc514e1
6 changed files with 149 additions and 72 deletions
+3 -12
View File
@@ -1,41 +1,32 @@
# /home/docker/pve_dashboard/Dockerfile
FROM php:8.2-apache FROM php:8.2-apache
# Benötigte Pakete installieren (SQLite + OpenSSL für Zertifikate + Cron für den Scheduler)
RUN apt-get update && apt-get install -y \ RUN apt-get update && apt-get install -y \
libsqlite3-dev \ libsqlite3-dev \
openssl \ openssl \
cron \ cron \
&& docker-php-ext-install pdo_sqlite && docker-php-ext-install pdo_sqlite
# Apache Rewrite-Modul (für schöne URLs) und SSL-Modul aktivieren
RUN a2enmod rewrite ssl RUN a2enmod rewrite ssl
# Generiere ein Self-Signed Zertifikat (Gültig für 10 Jahre)
RUN openssl req -x509 -nodes -days 3650 -newkey rsa:2048 \ RUN openssl req -x509 -nodes -days 3650 -newkey rsa:2048 \
-keyout /etc/ssl/private/apache-selfsigned.key \ -keyout /etc/ssl/private/apache-selfsigned.key \
-out /etc/ssl/certs/apache-selfsigned.crt \ -out /etc/ssl/certs/apache-selfsigned.crt \
-subj "/C=DE/ST=Brandenburg/L=Gruenheide/O=PUC/OU=IT/CN=localhost" -subj "/C=DE/ST=Brandenburg/L=Gruenheide/O=PUC/OU=IT/CN=localhost"
# Passe die Default SSL Konfiguration an
RUN sed -i 's/ssl-cert-snakeoil.pem/apache-selfsigned.crt/g' /etc/apache2/sites-available/default-ssl.conf \ RUN sed -i 's/ssl-cert-snakeoil.pem/apache-selfsigned.crt/g' /etc/apache2/sites-available/default-ssl.conf \
&& sed -i 's/ssl-cert-snakeoil.key/apache-selfsigned.key/g' /etc/apache2/sites-available/default-ssl.conf && sed -i 's/ssl-cert-snakeoil.key/apache-selfsigned.key/g' /etc/apache2/sites-available/default-ssl.conf
# SSL Site in Apache aktivieren
RUN a2ensite default-ssl.conf RUN a2ensite default-ssl.conf
# === DAS HIER HAT GEFEHLT! ===
# Kopiere den PHP/JS Quellcode fest in das Image und setze die Rechte
COPY ./src /var/www/html/ COPY ./src /var/www/html/
RUN chown -R www-data:www-data /var/www/html/ RUN chown -R www-data:www-data /var/www/html/
# =============================
# Cronjob einrichten (Ruft die cron.php minütlich auf und leitet Output ins Docker-Log um) # Der goldene Fix für die Datenbankrechte!
RUN mkdir -p /var/www/data && chown -R www-data:www-data /var/www/data
RUN echo "* * * * * root /usr/local/bin/php /var/www/html/cron.php > /proc/1/fd/1 2>/proc/1/fd/2\n" >> /etc/crontab RUN echo "* * * * * root /usr/local/bin/php /var/www/html/cron.php > /proc/1/fd/1 2>/proc/1/fd/2\n" >> /etc/crontab
# Beide Ports freigeben
EXPOSE 80 EXPOSE 80
EXPOSE 443 EXPOSE 443
# Startbefehl: Startet erst den Cron-Daemon im Hintergrund und dann Apache im Vordergrund
CMD cron && apache2-foreground CMD cron && apache2-foreground
+30 -17
View File
@@ -38,32 +38,45 @@ Built entirely with native APIs—no slow iframes, no CORS issues.
--- ---
## 📦 Quickstart (Docker) ## 📦 Quickstart (Docker & Portainer)
Deploying the Proxmox Unified Console is incredibly easy using Docker. Die Installation erfolgt am einfachsten über Docker Compose. Die Applikation bringt einen eigenen Apache-Webserver mit und generiert sich automatisch ein SSL-Zertifikat für HTTPS.
1. **Clone the repository:** **1. `docker-compose.yml` anlegen:**
```bash ```yaml
git clone [https://github.com/tessmania90/proxmox-unified-console.git](https://github.com/tessmania90/proxmox-unified-console.git) version: '3.8'
cd proxmox-unified-console
Start the container: services:
Bash pve-dashboard:
image: stessmann/proxmox-unified-console:latest
container_name: puc_dashboard
restart: unless-stopped
ports:
- "8080:80"
- "8443:443"
volumes:
- ./data:/var/www/data
environment:
- TZ=Europe/Berlin
docker compose up -d --build 2. Starten:
Bash
Access the Dashboard: docker compose up -d
Open your browser and navigate to:
https://<YOUR-SERVER-IP>:8443
(Note: You will need to accept the self-signed certificate warning).
Default Login: 3. ⚠️ WICHTIG: Rechte für SQLite anpassen:
Da der Container als Benutzer www-data (UID 33) läuft, der neu erstellte Volume-Ordner auf dem Host aber oft root gehört, muss dem Ordner die Schreibberechtigung erteilt werden. Führe im Verzeichnis der Compose-Datei aus:
Bash
Username: admin sudo chown -R 33:33 ./data
sudo chmod -R 775 ./data
Password: admin 4. Login:
Rufe https://<DEINE-IP>:8443 in deinem Browser auf (Zertifikatswarnung ignorieren).
⚠️ IMPORTANT: Please change the default password immediately after your first login! Benutzername: admin
Passwort: admin (Bitte direkt nach dem Einloggen oben rechts über das 🔑-Symbol ändern!)
🛠️ Architecture 🛠️ Architecture
+2 -8
View File
@@ -1,20 +1,14 @@
# /home/docker/pve_dashboard/docker-compose.yml
version: '3.8' version: '3.8'
services: services:
pve-dashboard: pve-dashboard:
build: . image: stessmann/proxmox-unified-console:latest
container_name: pve_unified_console container_name: puc_dashboard
restart: unless-stopped restart: unless-stopped
ports: ports:
# Port 80 bleibt fürs Testing / HAProxy
- "8080:80" - "8080:80"
# NEU: Port 443 für sicheres NoVNC! (Wir mappen es nach außen z.B. auf 8443)
- "8443:443" - "8443:443"
volumes: volumes:
# Dein Source-Code
- ./src:/var/www/html
# Deine Datenbank, sicher ausgelagert
- ./data:/var/www/data - ./data:/var/www/data
environment: environment:
- TZ=Europe/Berlin - TZ=Europe/Berlin
+105 -29
View File
@@ -1,13 +1,13 @@
<?php <?php
// /home/docker/pve_dashboard/src/api.php // /home/docker/pve_dashboard/src/api.php
ini_set('display_errors', 0); // Verhindert, dass PHP-Warnungen das JSON zerstören! ini_set('display_errors', 0); // Verhindert, dass PHP-Warnungen das JSON zerstören
error_reporting(E_ALL); error_reporting(E_ALL);
require_once 'db.php'; require_once 'db.php';
header('Content-Type: application/json'); header('Content-Type: application/json');
$action = $_GET['action'] ?? ''; $action = $_GET['action'] ?? '';
// === ZENTRALE AUDIT LOG FUNKTION (Jetzt kugelsicher!) === // === ZENTRALE AUDIT LOG FUNKTION ===
function logAudit($actionName, $target = '') { function logAudit($actionName, $target = '') {
global $pdo; global $pdo;
try { try {
@@ -19,28 +19,46 @@ function logAudit($actionName, $target = '') {
$stmt = $pdo->prepare("INSERT INTO audit_logs (user_id, username, action, target) VALUES (?, ?, ?, ?)"); $stmt = $pdo->prepare("INSERT INTO audit_logs (user_id, username, action, target) VALUES (?, ?, ?, ?)");
$stmt->execute([$userId, $username, $actionName, $target]); $stmt->execute([$userId, $username, $actionName, $target]);
} catch (Throwable $e) { } catch (Throwable $e) {
// Fehler im Logbuch ignorieren, damit das Dashboard nicht crasht!
error_log("Audit Log Fehler: " . $e->getMessage()); error_log("Audit Log Fehler: " . $e->getMessage());
} }
} }
if ($action === 'login') { if ($action === 'login') {
$username = trim($_POST['username'] ?? ''); $password = $_POST['password'] ?? ''; $username = trim($_POST['username'] ?? '');
$stmt = $pdo->prepare("SELECT * FROM users WHERE username = ?"); $stmt->execute([$username]); $user = $stmt->fetch(PDO::FETCH_ASSOC); $password = $_POST['password'] ?? '';
$stmt = $pdo->prepare("SELECT * FROM users WHERE username = ?");
$stmt->execute([$username]);
$user = $stmt->fetch(PDO::FETCH_ASSOC);
if ($user && password_verify($password, $user['password_hash'])) { if ($user && password_verify($password, $user['password_hash'])) {
$_SESSION['user_id'] = $user['id']; $_SESSION['username'] = $user['username']; $_SESSION['role'] = $user['role']; @session_start();
$_SESSION['user_id'] = $user['id'];
$_SESSION['username'] = $user['username'];
$_SESSION['role'] = $user['role'];
@session_write_close();
$ip = $_SERVER['REMOTE_ADDR'] ?? 'Unknown'; $ip = $_SERVER['REMOTE_ADDR'] ?? 'Unknown';
logAudit('User Login', 'IP: ' . $ip); logAudit('User Login', 'IP: ' . $ip);
echo json_encode(['success' => true]); echo json_encode(['success' => true]);
} else echo json_encode(['success' => false, 'error' => 'Login fehlgeschlagen.']); } else {
echo json_encode(['success' => false, 'error' => 'Login fehlgeschlagen.']);
}
exit; exit;
} }
if ($action === 'logout') { if ($action === 'logout') {
logAudit('User Logout', ''); logAudit('User Logout', '');
session_destroy(); echo json_encode(['success' => true]); exit; @session_start();
session_destroy();
echo json_encode(['success' => true]);
exit;
} }
if (!isset($_SESSION['user_id'])) { echo json_encode(['success' => false, 'error' => 'Zugriff verweigert.']); exit; } @session_start();
if (!isset($_SESSION['user_id'])) {
echo json_encode(['success' => false, 'error' => 'Zugriff verweigert.']);
exit;
}
// === PASSWORT ÄNDERN === // === PASSWORT ÄNDERN ===
if ($action === 'change_password') { if ($action === 'change_password') {
@@ -68,30 +86,74 @@ if ($action === 'change_password') {
$isAdmin = ($_SESSION['role'] ?? '') === 'admin'; $isAdmin = ($_SESSION['role'] ?? '') === 'admin';
@session_write_close(); @session_write_close();
function getProxmoxData($ip, $tokenId, $tokenSecret, $endpoint, $type = 'pve', $method = "GET", $postData = null, $timeout = 4) { // === PROXMOX API FETCHER (JETZT MIT TICKET-AUTH FÜR ALLE SYSTEME) ===
function getProxmoxData($ip, $tokenId, $tokenSecret, $endpoint, $type = 'pve', $method = "GET", $postData = null, $timeout = 6) {
$port = ($type === 'pbs') ? 8007 : 8006; $port = ($type === 'pbs') ? 8007 : 8006;
if ($type === 'pmg' || $type === 'pbs') {
$chAuth = curl_init("https://{$ip}:{$port}/api2/json/access/ticket"); // 1. Ticket holen (Login)
curl_setopt_array($chAuth, [CURLOPT_RETURNTRANSFER => true, CURLOPT_SSL_VERIFYPEER => false, CURLOPT_SSL_VERIFYHOST => false, CURLOPT_TIMEOUT => 2, CURLOPT_POST => true, CURLOPT_POSTFIELDS => http_build_query(['username' => $tokenId, 'password' => $tokenSecret])]); $chAuth = curl_init("https://{$ip}:{$port}/api2/json/access/ticket");
$authRes = json_decode(curl_exec($chAuth), true); curl_close($chAuth); curl_setopt_array($chAuth, [
if(!isset($authRes['data']['ticket'])) return ['data' => []]; CURLOPT_RETURNTRANSFER => true,
$cookieName = ($type === 'pbs') ? 'PBSAuthCookie' : 'PMGAuthCookie'; CURLOPT_SSL_VERIFYPEER => false,
$headers = ["Cookie: {$cookieName}=" . $authRes['data']['ticket'], "CSRFPreventionToken: " . $authRes['data']['CSRFPreventionToken']]; CURLOPT_SSL_VERIFYHOST => false,
} else { CURLOPT_TIMEOUT => 4,
$headers = ["Authorization: PVEAPIToken={$tokenId}={$tokenSecret}"]; CURLOPT_POST => true,
CURLOPT_POSTFIELDS => http_build_query(['username' => $tokenId, 'password' => $tokenSecret])
]);
$authRes = json_decode(curl_exec($chAuth), true);
curl_close($chAuth);
if(!isset($authRes['data']['ticket'])) {
return ['data' => null, 'error' => 'Authentifizierung fehlgeschlagen'];
} }
// 2. Passendes Cookie für das System wählen
$cookieName = 'PVEAuthCookie';
if ($type === 'pbs') $cookieName = 'PBSAuthCookie';
if ($type === 'pmg') $cookieName = 'PMGAuthCookie';
$headers = [
"Cookie: {$cookieName}=" . $authRes['data']['ticket'],
"CSRFPreventionToken: " . $authRes['data']['CSRFPreventionToken']
];
// 3. Eigentlichen API-Call ausführen
$ch = curl_init("https://{$ip}:{$port}{$endpoint}"); $ch = curl_init("https://{$ip}:{$port}{$endpoint}");
$options = [CURLOPT_RETURNTRANSFER => true, CURLOPT_SSL_VERIFYPEER => false, CURLOPT_SSL_VERIFYHOST => false, CURLOPT_TIMEOUT => $timeout, CURLOPT_HTTPHEADER => $headers]; $options = [
if ($method !== "GET") { $options[CURLOPT_CUSTOMREQUEST] = $method; if ($postData) $options[CURLOPT_POSTFIELDS] = is_array($postData) ? http_build_query($postData) : $postData; } CURLOPT_RETURNTRANSFER => true,
CURLOPT_SSL_VERIFYPEER => false,
CURLOPT_SSL_VERIFYHOST => false,
CURLOPT_TIMEOUT => $timeout,
CURLOPT_HTTPHEADER => $headers
];
if ($method !== "GET") {
$options[CURLOPT_CUSTOMREQUEST] = $method;
if ($postData) {
$options[CURLOPT_POSTFIELDS] = is_array($postData) ? http_build_query($postData) : $postData;
}
}
curl_setopt_array($ch, $options); curl_setopt_array($ch, $options);
$res = curl_exec($ch); curl_close($ch); return json_decode($res, true); $res = curl_exec($ch);
curl_close($ch);
return json_decode($res, true) ?: [];
} }
function checkVmPermission($pdo, $vmid) { function checkVmPermission($pdo, $vmid) {
global $isAdmin; if ($isAdmin) return true; global $isAdmin;
@session_start(); $userId = $_SESSION['user_id'] ?? 0; @session_write_close(); if ($isAdmin) return true;
$stmt = $pdo->prepare("SELECT permissions FROM users WHERE id = ?"); $stmt->execute([$userId]); return in_array($vmid, json_decode($stmt->fetchColumn(), true)['allowed_vms'] ?? []);
@session_start();
$userId = $_SESSION['user_id'] ?? 0;
@session_write_close();
$stmt = $pdo->prepare("SELECT permissions FROM users WHERE id = ?");
$stmt->execute([$userId]);
$perms = json_decode($stmt->fetchColumn() ?: '{}', true);
return in_array($vmid, $perms['allowed_vms'] ?? []);
} }
if ($action === 'get_audit_logs') { if ($action === 'get_audit_logs') {
@@ -112,13 +174,22 @@ if ($action === 'get_recent_jobs') {
if (isset($tasks['data'])) { foreach ($tasks['data'] as $t) { $t['node_name'] = $node['name']; $allTasks[] = $t; } } if (isset($tasks['data'])) { foreach ($tasks['data'] as $t) { $t['node_name'] = $node['name']; $allTasks[] = $t; } }
} elseif ($node['type'] === 'pmg') { } elseif ($node['type'] === 'pmg') {
$nData = getProxmoxData($node['ip_address'], $node['token_id'], $node['token_secret'], "/api2/json/nodes", 'pmg'); $nData = getProxmoxData($node['ip_address'], $node['token_id'], $node['token_secret'], "/api2/json/nodes", 'pmg');
if (isset($nData['data'][0]['node'])) { $tasks = getProxmoxData($node['ip_address'], $node['token_id'], $node['token_secret'], "/api2/json/nodes/{$nData['data'][0]['node']}/tasks?limit=10", 'pmg'); if (isset($tasks['data'])) { foreach ($tasks['data'] as $t) { $t['node_name'] = $node['name']; $allTasks[] = $t; } } } if (isset($nData['data'][0]['node'])) {
$tasks = getProxmoxData($node['ip_address'], $node['token_id'], $node['token_secret'], "/api2/json/nodes/{$nData['data'][0]['node']}/tasks?limit=10", 'pmg');
if (isset($tasks['data'])) { foreach ($tasks['data'] as $t) { $t['node_name'] = $node['name']; $allTasks[] = $t; } }
}
} else { } else {
$nData = getProxmoxData($node['ip_address'], $node['token_id'], $node['token_secret'], "/api2/json/nodes", 'pve'); $nData = getProxmoxData($node['ip_address'], $node['token_id'], $node['token_secret'], "/api2/json/nodes", 'pve');
if (isset($nData['data'])) { foreach ($nData['data'] as $nInfo) { $tasks = getProxmoxData($node['ip_address'], $node['token_id'], $node['token_secret'], "/api2/json/nodes/{$nInfo['node']}/tasks?limit=10", 'pve'); if (isset($tasks['data'])) { foreach ($tasks['data'] as $t) { $t['node_name'] = $node['name']; $allTasks[] = $t; } } } } if (isset($nData['data'])) {
foreach ($nData['data'] as $nInfo) {
$tasks = getProxmoxData($node['ip_address'], $node['token_id'], $node['token_secret'], "/api2/json/nodes/{$nInfo['node']}/tasks?limit=10", 'pve');
if (isset($tasks['data'])) { foreach ($tasks['data'] as $t) { $t['node_name'] = $node['name']; $allTasks[] = $t; } }
}
}
} }
} }
usort($allTasks, function($a, $b) { return ($b['starttime'] ?? 0) <=> ($a['starttime'] ?? 0); }); echo json_encode(['success' => true, 'data' => array_slice($allTasks, 0, 15)]); exit; usort($allTasks, function($a, $b) { return ($b['starttime'] ?? 0) <=> ($a['starttime'] ?? 0); });
echo json_encode(['success' => true, 'data' => array_slice($allTasks, 0, 15)]); exit;
} }
if ($action === 'get_updates') { if ($action === 'get_updates') {
@@ -129,7 +200,12 @@ if ($action === 'get_updates') {
if (isset($apt['data'])) { $c = count($apt['data']); $total += $c; if ($c > 0) $nodesNeed[] = $node['name'] . " (" . $c . ")"; } if (isset($apt['data'])) { $c = count($apt['data']); $total += $c; if ($c > 0) $nodesNeed[] = $node['name'] . " (" . $c . ")"; }
} else { } else {
$nData = getProxmoxData($node['ip_address'], $node['token_id'], $node['token_secret'], "/api2/json/nodes", 'pve'); $nData = getProxmoxData($node['ip_address'], $node['token_id'], $node['token_secret'], "/api2/json/nodes", 'pve');
if (isset($nData['data'])) { foreach ($nData['data'] as $nInfo) { $apt = getProxmoxData($node['ip_address'], $node['token_id'], $node['token_secret'], "/api2/json/nodes/{$nInfo['node']}/apt/update", 'pve'); if (isset($apt['data'])) { $c = count($apt['data']); $total += $c; if ($c > 0) $nodesNeed[] = $nInfo['node'] . " (" . $c . ")"; } } } if (isset($nData['data'])) {
foreach ($nData['data'] as $nInfo) {
$apt = getProxmoxData($node['ip_address'], $node['token_id'], $node['token_secret'], "/api2/json/nodes/{$nInfo['node']}/apt/update", 'pve');
if (isset($apt['data'])) { $c = count($apt['data']); $total += $c; if ($c > 0) $nodesNeed[] = $nInfo['node'] . " (" . $c . ")"; }
}
}
} }
} }
echo json_encode(['success' => true, 'total' => $total, 'details' => implode(', ', $nodesNeed)]); exit; echo json_encode(['success' => true, 'total' => $total, 'details' => implode(', ', $nodesNeed)]); exit;
+4 -1
View File
@@ -11,7 +11,10 @@ if (!window.APP.isLoggedIn) {
if(setupForm) { setupForm.addEventListener('submit', async function(e) { e.preventDefault(); const btn = this.querySelector('button[type="submit"]'); const oTxt = btn.innerText; btn.innerText = 'Verbinde...'; const fd = new FormData(); fd.append('name', document.getElementById('nodeName').value); fd.append('ip', document.getElementById('nodeIp').value); fd.append('user', document.getElementById('nodeUser').value); fd.append('pass', document.getElementById('nodePass').value); fd.append('type', 'pve'); try { const res = await (await fetch('api.php?action=add_node', { method: 'POST', body: fd })).json(); if(res.success) { btn.innerText = 'Erfolgreich!'; setTimeout(() => window.location.reload(), 1000); } else { alert(res.error); btn.innerText = oTxt; } } catch (e) { alert('Netzwerkfehler.'); btn.innerText = oTxt; } }); } if(setupForm) { setupForm.addEventListener('submit', async function(e) { e.preventDefault(); const btn = this.querySelector('button[type="submit"]'); const oTxt = btn.innerText; btn.innerText = 'Verbinde...'; const fd = new FormData(); fd.append('name', document.getElementById('nodeName').value); fd.append('ip', document.getElementById('nodeIp').value); fd.append('user', document.getElementById('nodeUser').value); fd.append('pass', document.getElementById('nodePass').value); fd.append('type', 'pve'); try { const res = await (await fetch('api.php?action=add_node', { method: 'POST', body: fd })).json(); if(res.success) { btn.innerText = 'Erfolgreich!'; setTimeout(() => window.location.reload(), 1000); } else { alert(res.error); btn.innerText = oTxt; } } catch (e) { alert('Netzwerkfehler.'); btn.innerText = oTxt; } }); }
} }
async function logout() { await fetch('api.php?action=logout'); window.location.reload(); } async function logout() {
await fetch('api.php?action=logout');
window.location.href = window.location.pathname + '?t=' + Date.now();
}
if (window.APP.isLoggedIn && window.APP.nodeCount > 0) { if (window.APP.isLoggedIn && window.APP.nodeCount > 0) {
+3 -3
View File
@@ -39,11 +39,9 @@ $nodeCount = $stmt->fetchColumn();
<div class="flex items-center gap-4 text-sm"> <div class="flex items-center gap-4 text-sm">
<span class="text-gray-400">Hallo, <span class="text-white font-bold"><?= htmlspecialchars($_SESSION['username']) ?></span></span> <span class="text-gray-400">Hallo, <span class="text-white font-bold"><?= htmlspecialchars($_SESSION['username']) ?></span></span>
<!-- HIER IST DER SCHLÜSSEL BUTTON -->
<button onclick="openPasswordModal()" class="text-gray-400 hover:text-white transition-colors" title="Passwort ändern"> <button onclick="openPasswordModal()" class="text-gray-400 hover:text-white transition-colors" title="Passwort ändern">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 7a2 2 0 012 2m4 0a6 6 0 01-7.743 5.743L11 17H9v2H7v2H4v-3.286l5.742-5.742C9.4 11.135 9 10.126 9 9a6 6 0 0112 0z"></path></svg> <svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 7a2 2 0 012 2m4 0a6 6 0 01-7.743 5.743L11 17H9v2H7v2H4v-3.286l5.742-5.742C9.4 11.135 9 10.126 9 9a6 6 0 0112 0z"></path></svg>
</button> </button>
<!-- ============================ -->
<button onclick="logout()" class="text-red-400 hover:text-red-300 font-medium transition-colors">Abmelden</button> <button onclick="logout()" class="text-red-400 hover:text-red-300 font-medium transition-colors">Abmelden</button>
</div> </div>
@@ -132,6 +130,8 @@ $nodeCount = $stmt->fetchColumn();
</footer> </footer>
<script> window.APP = { isLoggedIn: <?= $isLoggedIn ? 'true' : 'false' ?>, nodeCount: <?= $nodeCount ?>, username: '<?= htmlspecialchars($_SESSION['username'] ?? '') ?>' }; </script> <script> window.APP = { isLoggedIn: <?= $isLoggedIn ? 'true' : 'false' ?>, nodeCount: <?= $nodeCount ?>, username: '<?= htmlspecialchars($_SESSION['username'] ?? '') ?>' }; </script>
<script src="app.js"></script>
<!-- HIER IST DER CACHE-BUSTER, DAMIT DER LOGOUT IMMER GEHT! -->
<script src="app.js?v=<?= time() ?>"></script>
</body> </body>
</html> </html>