diff --git a/modules/pihole/assets/pihole.css b/modules/pihole/assets/pihole.css index 03b098e..82bac11 100644 --- a/modules/pihole/assets/pihole.css +++ b/modules/pihole/assets/pihole.css @@ -163,6 +163,41 @@ gap: 6px; } +.pihole-page.is-busy { + position: relative; +} + +.pihole-busy-overlay { + position: fixed; + inset: 0; + display: flex; + align-items: center; + justify-content: center; + padding: 24px; + background: rgba(10, 14, 24, 0.28); + backdrop-filter: blur(2px); + z-index: 80; +} + +.pihole-busy-overlay[hidden] { + display: none; +} + +.pihole-busy-card { + min-width: min(320px, 92vw); + display: grid; + gap: 8px; + padding: 18px 20px; + border-radius: 16px; + border: 1px solid var(--line); + background: var(--panel); + box-shadow: var(--shadow); +} + +.pihole-busy-card span { + color: var(--muted); +} + .pihole-instance-card { padding: 16px; background: var(--panel-2); diff --git a/modules/pihole/assets/pihole.js b/modules/pihole/assets/pihole.js index ce2bd81..0ba9dc7 100644 --- a/modules/pihole/assets/pihole.js +++ b/modules/pihole/assets/pihole.js @@ -18,6 +18,7 @@ let refreshTimer = null; let loadInFlight = false; + let actionInFlight = false; const apiCall = async (action, payload = {}) => { const res = await fetch(`/module/pihole/api?action=${encodeURIComponent(action)}`, @@ -46,6 +47,39 @@ el.textContent = value; }; + const setActionLock = (locked, message = 'Bitte warten ...') => { + actionInFlight = locked; + page.classList.toggle('is-busy', locked); + + let overlay = page.querySelector('[data-pihole-busy-overlay]'); + if (!overlay) { + overlay = document.createElement('div'); + overlay.className = 'pihole-busy-overlay'; + overlay.dataset.piholeBusyOverlay = '1'; + overlay.hidden = true; + overlay.innerHTML = '
Aktion wird ausgefuehrt
'; + page.appendChild(overlay); + } + + const text = overlay.querySelector('[data-pihole-busy-text]'); + if (text) { + text.textContent = message; + } + + overlay.hidden = !locked; + + page.querySelectorAll('button, input, select, textarea').forEach((el) => { + const formControl = el; + if (locked) { + formControl.dataset.piholeWasDisabled = formControl.disabled ? 'true' : 'false'; + formControl.disabled = true; + return; + } + formControl.disabled = formControl.dataset.piholeWasDisabled === 'true'; + delete formControl.dataset.piholeWasDisabled; + }); + }; + const statusLabel = (status) => { if (status === 'enabled') return 'Aktiv'; if (status === 'disabled') return 'Deaktiviert'; @@ -188,6 +222,15 @@ } try { + const actionLabel = action === 'enable' + ? 'Pi-hole wird aktiviert ...' + : action === 'disable' || action === 'disable-custom' + ? 'Pi-hole wird deaktiviert ...' + : action === 'gravity' + ? 'Listen werden aktualisiert ...' + : 'Aktion wird ausgefuehrt ...'; + setActionLock(true, actionLabel); + if (action === 'enable') { await apiCall('enable', payload); } else if (action === 'disable' || action === 'disable-custom') { @@ -206,6 +249,8 @@ await loadDashboard(); } catch (err) { alert(`Aktion fehlgeschlagen: ${err.message}`); + } finally { + setActionLock(false); } }); }; @@ -250,7 +295,7 @@ }; const loadDashboard = async () => { - if (loadInFlight) return; + if (loadInFlight || actionInFlight) return; loadInFlight = true; try { const data = await apiCall('dashboard'); diff --git a/modules/pihole/pages/api.php b/modules/pihole/pages/api.php index 4aa6d03..69d3c98 100644 --- a/modules/pihole/pages/api.php +++ b/modules/pihole/pages/api.php @@ -25,6 +25,79 @@ $debugPush = static function (string $label, array $payload = []): void { module_debug_push('pihole', array_merge(['label' => $label], $payload)); }; +app()->session()->start(); + +$sessionFingerprint = static function (array $instance): string { + return sha1(json_encode([ + 'id' => (string)($instance['id'] ?? ''), + 'url' => (string)($instance['url'] ?? ''), + 'password' => (string)($instance['password'] ?? ''), + ], JSON_UNESCAPED_UNICODE)); +}; + +$getSessionBucket = static function (): array { + $bucket = $_SESSION['pihole_api_sessions'] ?? []; + return is_array($bucket) ? $bucket : []; +}; + +$readSessionCache = static function (array $instance) use ($getSessionBucket, $sessionFingerprint): ?array { + $bucket = $getSessionBucket(); + $key = (string)($instance['id'] ?? ''); + if ($key === '' || !isset($bucket[$key]) || !is_array($bucket[$key])) { + return null; + } + + $row = $bucket[$key]; + if (($row['fingerprint'] ?? '') !== $sessionFingerprint($instance)) { + return null; + } + + return $row; +}; + +$writeSessionCache = static function (array $instance, string $sid, int $validity = 300, ?string $csrf = null) use ($sessionFingerprint): void { + $key = (string)($instance['id'] ?? ''); + if ($key === '' || $sid === '') { + return; + } + + if (!isset($_SESSION['pihole_api_sessions']) || !is_array($_SESSION['pihole_api_sessions'])) { + $_SESSION['pihole_api_sessions'] = []; + } + + $_SESSION['pihole_api_sessions'][$key] = [ + 'fingerprint' => $sessionFingerprint($instance), + 'sid' => $sid, + 'csrf' => $csrf ?? '', + 'validity' => $validity > 0 ? $validity : 300, + 'expires_at' => time() + max(30, $validity - 15), + 'updated_at' => time(), + ]; +}; + +$touchSessionCache = static function (array $instance, ?int $validity = null) use ($readSessionCache, $writeSessionCache): void { + $cached = $readSessionCache($instance); + if (!$cached || empty($cached['sid'])) { + return; + } + + $writeSessionCache( + $instance, + (string)$cached['sid'], + $validity ?? (int)($cached['validity'] ?? 300), + (string)($cached['csrf'] ?? '') + ); +}; + +$clearSessionCache = static function (array $instance): void { + $key = (string)($instance['id'] ?? ''); + if ($key === '' || !isset($_SESSION['pihole_api_sessions']) || !is_array($_SESSION['pihole_api_sessions'])) { + return; + } + + unset($_SESSION['pihole_api_sessions'][$key]); +}; + $normalizeApiPath = function (string $baseUrl, string $apiPath): string { $base = rtrim($baseUrl, '/'); $path = $apiPath; @@ -138,7 +211,34 @@ $v5Request = function (array $instance, array $params) use ($normalizeApiPath, $ return $httpRequest('GET', $full, ['Accept: application/json'], null, $verify, $timeout); }; -$v6Auth = function (array $instance) use ($httpRequest): array { +$v6Logout = function (array $instance, string $sid) use ($httpRequest): void { + if ($sid === '') { + return; + } + + $base = rtrim((string)$instance['url'], '/'); + $url = $base . '/api/auth?sid=' . rawurlencode($sid); + $timeout = (int)($instance['timeout'] ?? 8); + if ($timeout <= 0) { + $timeout = 8; + } + $verify = !empty($instance['verify_tls']); + $httpRequest('DELETE', $url, ['Accept: application/json', 'X-FTL-SID: ' . $sid], null, $verify, $timeout); +}; + +$v6Auth = function (array $instance) use ($httpRequest, $readSessionCache, $writeSessionCache, $clearSessionCache, $v6Logout): array { + $cached = $readSessionCache($instance); + if ($cached && !empty($cached['sid']) && (int)($cached['expires_at'] ?? 0) > time()) { + return [ + 'ok' => true, + 'http_code' => 200, + 'url' => rtrim((string)$instance['url'], '/') . '/api/auth', + 'data' => ['session' => ['sid' => (string)$cached['sid'], 'validity' => (int)($cached['validity'] ?? 300)]], + 'sid' => (string)$cached['sid'], + 'cached' => true, + ]; + } + $base = rtrim((string)$instance['url'], '/'); $url = $base . '/api/auth'; $timeout = (int)($instance['timeout'] ?? 8); @@ -150,16 +250,26 @@ $v6Auth = function (array $instance) use ($httpRequest): array { $body = json_encode($payload, JSON_UNESCAPED_UNICODE); $res = $httpRequest('POST', $url, ['Accept: application/json', 'Content-Type: application/json'], $body, $verify, $timeout); if (!$res['ok']) { + $clearSessionCache($instance); return $res; } $data = (array)($res['data'] ?? []); $session = (array)($data['session'] ?? []); $sid = (string)($session['sid'] ?? ''); + $validity = (int)($session['validity'] ?? 300); + $csrf = (string)($session['csrf'] ?? ''); + if ($cached && !empty($cached['sid']) && $cached['sid'] !== $sid) { + $v6Logout($instance, (string)$cached['sid']); + } + if ($sid !== '') { + $writeSessionCache($instance, $sid, $validity, $csrf); + } $res['sid'] = $sid; + $res['cached'] = false; return $res; }; -$v6Request = function (array $instance, string $path, string $method, array $payload, string $sid) use ($httpRequest): array { +$v6Request = function (array $instance, string $path, string $method, array $payload, string $sid) use ($httpRequest, $clearSessionCache, $touchSessionCache): array { $base = rtrim((string)$instance['url'], '/'); $path = ltrim($path, '/'); $url = $base . '/api/' . $path; @@ -183,7 +293,17 @@ $v6Request = function (array $instance, string $path, string $method, array $pay $headers[] = 'Content-Type: application/json'; } - return $httpRequest($method, $url, $headers, $body, $verify, $timeout); + $result = $httpRequest($method, $url, $headers, $body, $verify, $timeout); + $httpCode = (int)($result['http_code'] ?? 0); + if ($sid !== '') { + if (($result['ok'] ?? false) === true) { + $touchSessionCache($instance); + } elseif (in_array($httpCode, [401, 403], true)) { + $clearSessionCache($instance); + } + } + + return $result; }; $v6RequestAny = function (array $instance, array $paths, string $method, array $payload, string $sid) use ($v6Request): array { @@ -202,7 +322,7 @@ $v6RequestAny = function (array $instance, array $paths, string $method, array $ return $last; }; -$detectApi = function (array $instance) use ($v6Auth, $v6RequestAny, $v5Request): array { +$detectApi = function (array $instance) use ($v6Auth, $v6RequestAny, $v5Request, $clearSessionCache): array { $sid = ''; $authRes = null; if (!empty($instance['password'])) { @@ -211,6 +331,14 @@ $detectApi = function (array $instance) use ($v6Auth, $v6RequestAny, $v5Request) $sid = (string)$authRes['sid']; $probe = $v6RequestAny($instance, ['dns/blocking', 'stats/summary', 'summary'], 'GET', [], $sid); + if (!(empty($authRes['cached'])) && in_array((int)($probe['http_code'] ?? 0), [401, 403], true)) { + $clearSessionCache($instance); + $authRes = $v6Auth($instance); + if (($authRes['ok'] ?? false) && !empty($authRes['sid'])) { + $sid = (string)$authRes['sid']; + $probe = $v6RequestAny($instance, ['dns/blocking', 'stats/summary', 'summary'], 'GET', [], $sid); + } + } return ['version' => 6, 'sid' => $sid, 'probe' => $probe, 'auth' => $authRes]; }