From 76764e2995a5b853c28b8a4ad4c6477c5cc123e9 Mon Sep 17 00:00:00 2001 From: Fabian Schieder Date: Sat, 28 Feb 2026 19:43:58 +0100 Subject: [PATCH] Add initial implementation of mini database management tool with environment configuration and server status monitoring --- .env.example | 10 + .gitignore | 10 + adminer/README.md | 18 + adminer/adminer.php | 19 + adminer/auth.php | 96 +++++ adminer/env.php | 49 +++ adminer/index.php | 217 ++++++++++ adminer/views.php | 41 ++ assets/js/main.js | 96 +++++ includes/config/projects.php | 77 ++++ includes/config/server_status_targets.php | 35 ++ includes/lib/server_status.php | 175 ++++++++ includes/lib/view_helpers.php | 14 + includes/views/footer.php | 4 + includes/views/header.php | 6 + includes/views/layout_foot.php | 5 + includes/views/layout_head.php | 18 + includes/views/projects_section.php | 91 +++++ index.php | 475 +--------------------- 19 files changed, 993 insertions(+), 463 deletions(-) create mode 100644 .env.example create mode 100644 adminer/README.md create mode 100644 adminer/adminer.php create mode 100644 adminer/auth.php create mode 100644 adminer/env.php create mode 100644 adminer/index.php create mode 100644 adminer/views.php create mode 100644 assets/js/main.js create mode 100644 includes/config/projects.php create mode 100644 includes/config/server_status_targets.php create mode 100644 includes/lib/server_status.php create mode 100644 includes/lib/view_helpers.php create mode 100644 includes/views/footer.php create mode 100644 includes/views/header.php create mode 100644 includes/views/layout_foot.php create mode 100644 includes/views/layout_head.php create mode 100644 includes/views/projects_section.php diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..91aeebb --- /dev/null +++ b/.env.example @@ -0,0 +1,10 @@ +# Beispiel für lokale Secrets. NICHT committen: nutze .env +DB_SERVERNAME=localhost +DB_PORT=3306 +DB_USERNAME=FSST +DB_PASSWORD=change-me +DB_DATABASE=FSS_T + +# Optional: Basis-URL (wenn du was dynamisch bauen willst) +APP_URL=https://fabianschieder.com + diff --git a/.gitignore b/.gitignore index 5706cc7..6aa8296 100644 --- a/.gitignore +++ b/.gitignore @@ -9,3 +9,13 @@ Thumbs.db !icons/ !icons/* +# Lokale Secrets +.env +.env.* + +# lokale Cache/Temp-Dateien +*.log +*.tmp + +# Adminer: echte Single-File-Download-Version optional lokal +# adminer/adminer.php diff --git a/adminer/README.md b/adminer/README.md new file mode 100644 index 0000000..7343427 --- /dev/null +++ b/adminer/README.md @@ -0,0 +1,18 @@ +# DB-Verwaltung (Mini-Admin) + +Dieses Projekt enthält eine kleine, selbst implementierte DB-Verwaltung unter `/adminer`. + +## Setup +1. Erstelle eine lokale `.env` im Projekt-Root (siehe `.env.example`). +2. Trage dort deine DB-Zugangsdaten ein. + +> Wichtig: `.env` wird durch `.gitignore` ignoriert. + +## Nutzung +- Öffne im Browser: `/adminer` +- Login erfolgt über das Formular. + +## Hinweise +- Das Tool ist bewusst minimal (Tabellenliste + Browse + einfache SQL-Query). +- Für produktive Nutzung bitte zusätzlich absichern (z.B. Basic Auth / IP-Allowlist). + diff --git a/adminer/adminer.php b/adminer/adminer.php new file mode 100644 index 0000000..e9cf603 --- /dev/null +++ b/adminer/adminer.php @@ -0,0 +1,19 @@ + env_get($vars, 'DB_SERVERNAME', 'localhost') ?? 'localhost', + 'port' => (int)(env_get($vars, 'DB_PORT', '3306') ?? '3306'), + 'user' => env_get($vars, 'DB_USERNAME', '') ?? '', + 'pass' => env_get($vars, 'DB_PASSWORD', '') ?? '', + 'db' => env_get($vars, 'DB_DATABASE', '') ?? '', + ]; +} + +function admin_try_login(string $host, int $port, string $user, string $pass, string $db): array +{ + // Basic validation + if ($host === '' || $port <= 0 || $user === '' || $db === '') { + return ['ok' => false, 'error' => 'Bitte Host, Port, Benutzer und Datenbank angeben.']; + } + + $dsn = sprintf('mysql:host=%s;port=%d;dbname=%s;charset=utf8mb4', $host, $port, $db); + + try { + $pdo = new PDO($dsn, $user, $pass, [ + PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, + PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC, + ]); + + // smoke query + $pdo->query('SELECT 1'); + + admin_session_start(); + $_SESSION['db_admin'] = [ + 'ok' => true, + 'host' => $host, + 'port' => $port, + 'user' => $user, + 'pass' => $pass, + 'db' => $db, + ]; + + return ['ok' => true, 'error' => null]; + } catch (Throwable $e) { + return ['ok' => false, 'error' => 'Login fehlgeschlagen: ' . $e->getMessage()]; + } +} + +function admin_pdo(): PDO +{ + admin_session_start(); + if (empty($_SESSION['db_admin']['ok'])) { + throw new RuntimeException('Nicht eingeloggt'); + } + + $host = (string)$_SESSION['db_admin']['host']; + $port = (int)$_SESSION['db_admin']['port']; + $db = (string)$_SESSION['db_admin']['db']; + $user = (string)$_SESSION['db_admin']['user']; + $pass = (string)$_SESSION['db_admin']['pass']; + + $dsn = sprintf('mysql:host=%s;port=%d;dbname=%s;charset=utf8mb4', $host, $port, $db); + + return new PDO($dsn, $user, $pass, [ + PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, + PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC, + ]); +} diff --git a/adminer/env.php b/adminer/env.php new file mode 100644 index 0000000..3c3b418 --- /dev/null +++ b/adminer/env.php @@ -0,0 +1,49 @@ += 2) { + $first = $v[0]; + $last = $v[$len - 1]; + if (($first === '"' && $last === '"') || ($first === "'" && $last === "'")) { + $v = substr($v, 1, -1); + } + } + + if ($k !== '') $vars[$k] = $v; + } + + return $vars; +} + +function env_get(array $vars, $key, $default = null) +{ + if (array_key_exists($key, $vars)) return (string)$vars[$key]; + $v = getenv((string)$key); + if ($v !== false) return (string)$v; + return $default; +} diff --git a/adminer/index.php b/adminer/index.php new file mode 100644 index 0000000..7fa97b4 --- /dev/null +++ b/adminer/index.php @@ -0,0 +1,217 @@ +

DB-Verwaltung

Mini-Admin"; + $body .= "
"; + $body .= "

Login-Daten werden nur in der Session gespeichert. Für Defaults wird .env aus dem Projekt-Root gelesen.

"; + + if ($error) $body .= '
' . h($error) . '

'; + + $body .= "
"; + $body .= ""; + $body .= "
"; + + $body .= "
"; + $body .= "

"; + $body .= "

"; + $body .= "

"; + $body .= "

"; + $body .= "

"; + $body .= ""; + $body .= "
"; + + $body .= "
"; + $body .= "

Hinweise

"; + $body .= "
    "; + $body .= "
  • Dieses Tool ist bewusst minimal (Tabellenliste, Browse, SQL Query).
  • "; + $body .= "
  • Für produktive Nutzung bitte zusätzlich absichern (Basic Auth / IP-Allowlist).
  • "; + $body .= "
"; + $body .= "
"; + + $body .= "
"; + $body .= "
"; + $body .= "
"; + + admin_layout('DB-Verwaltung', $body); + exit; +} + +// Logged-in area +try { + $pdo = admin_pdo(); + + $table = (string)($_GET['t'] ?? ''); + $page = max(1, (int)($_GET['p'] ?? 1)); + $limit = 50; + $offset = ($page - 1) * $limit; + + $msg = null; + $queryResultHtml = ''; + + if ($_SERVER['REQUEST_METHOD'] === 'POST' && (string)($_POST['action'] ?? '') === 'query') { + $sql = trim((string)($_POST['sql'] ?? '')); + if ($sql !== '') { + // Allow multiple statements? No. Keep minimal & safer. + if (preg_match('/;\s*\S/', $sql)) { + $msg = ['type' => 'err', 'text' => 'Bitte nur ein Statement ohne zusätzliche Semikolons ausführen.']; + } else { + try { + $stmt = $pdo->query($sql); + if ($stmt instanceof PDOStatement) { + $rows = $stmt->fetchAll(); + $queryResultHtml .= '

Ergebnis

'; + $queryResultHtml .= admin_render_table($rows); + $msg = ['type' => 'ok', 'text' => 'Query ausgeführt.']; + } else { + $msg = ['type' => 'ok', 'text' => 'Statement ausgeführt.']; + } + } catch (Throwable $e) { + $msg = ['type' => 'err', 'text' => 'Fehler: ' . $e->getMessage()]; + } + } + } + } + + // Build left nav tables + $tables = $pdo->query('SHOW TABLES')->fetchAll(PDO::FETCH_NUM); + + $body = '
' + . '

DB-Verwaltung

eingeloggt
' + . '
Logout
' + . '
'; + + $body .= '
'; + + $body .= '
' + . '

Tabellen

' + . '
Klick zum Anzeigen
'; + + if (empty($tables)) { + $body .= '
Keine Tabellen gefunden.
'; + } else { + $body .= '
    '; + foreach ($tables as $row) { + $tname = (string)$row[0]; + $active = ($tname === $table) ? ' style="font-weight:700"' : ''; + $body .= '
  • ' . h($tname) . '
  • '; + } + $body .= '
'; + } + $body .= '
'; + + $body .= '
'; + + if ($msg) { + $cls = $msg['type'] === 'ok' ? 'ok' : 'err'; + $body .= '
' . h($msg['text']) . '

'; + } + + // Browse table + if ($table !== '') { + // naive identifier quoting for MySQL + if (!preg_match('/^[A-Za-z0-9_]+$/', $table)) { + $body .= '
Ungültiger Tabellenname.
'; + } else { + $body .= '

Tabelle: ' . h($table) . '

'; + $stmt = $pdo->query('SELECT * FROM `' . $table . '` LIMIT ' . (int)$limit . ' OFFSET ' . (int)$offset); + $rows = $stmt->fetchAll(); + $body .= admin_render_table($rows); + + $body .= '
'; + if ($page > 1) { + $body .= '← Zurück'; + } + $body .= 'Weiter →'; + $body .= '
'; + } + + $body .= '
'; + } + + // Query box + $body .= '

SQL Query

'; + $body .= '
' + . '' + . '' + . '
' + . '
'; + + $body .= $queryResultHtml; + + $body .= '
'; // card + $body .= '
'; // grid + + admin_layout('DB-Verwaltung', $body); +} catch (Throwable $e) { + // Session invalid, force re-login + admin_logout(); + admin_layout('DB-Verwaltung', '

DB-Verwaltung

' . h($e->getMessage()) . '

Zum Login
'); +} + +function admin_render_table(array $rows): string +{ + if (empty($rows)) { + return '
(keine Zeilen)
'; + } + + $cols = array_keys((array)$rows[0]); + $html = ''; + foreach ($cols as $c) { + $html .= ''; + } + $html .= ''; + + foreach ($rows as $r) { + $html .= ''; + foreach ($cols as $c) { + $v = $r[$c] ?? null; + if ($v === null) { + $cell = 'NULL'; + } else { + $s = (string)$v; + $cell = strlen($s) > 500 ? h(substr($s, 0, 500)) . '…' : h($s); + } + $html .= ''; + } + $html .= ''; + } + + $html .= '
' . h((string)$c) . '
' . $cell . '
'; + return $html; +} + diff --git a/adminer/views.php b/adminer/views.php new file mode 100644 index 0000000..bb2687d --- /dev/null +++ b/adminer/views.php @@ -0,0 +1,41 @@ +\n"; + echo "\n\n"; + echo "\n"; + echo "\n"; + echo '' . h($title) . "\n"; + echo "\n"; + echo "\n\n"; + echo "
\n"; + echo $bodyHtml; + echo "
\n\n"; +} diff --git a/assets/js/main.js b/assets/js/main.js new file mode 100644 index 0000000..8d8e886 --- /dev/null +++ b/assets/js/main.js @@ -0,0 +1,96 @@ +// ── Typewriter ─────────────────────────────────────────── +(function () { + const el = document.getElementById('typewriter'); + const words = ['Entwickler', 'Schüler', 'Macher', 'Selfhoster', 'Tüftler']; + let wi = 0, ci = 0, deleting = false; + + function type() { + if (!el) return; + const word = words[wi]; + el.textContent = deleting ? word.slice(0, ci--) : word.slice(0, ci++); + + let delay = deleting ? 60 : 110; + if (!deleting && ci > word.length) { delay = 1400; deleting = true; } + if (deleting && ci < 0) { deleting = false; wi = (wi + 1) % words.length; ci = 0; delay = 300; } + setTimeout(type, delay); + } + setTimeout(type, 900); +})(); + +// ── Particles ──────────────────────────────────────────── +(function () { + const canvas = document.getElementById('particle-canvas'); + if (!canvas) return; + const ctx = canvas.getContext('2d'); + const COLORS = ['#6366f1', '#0ea5e9', '#a855f7', '#ec4899', '#22d3ee']; + let W, H, particles = []; + + function resize() { W = canvas.width = window.innerWidth; H = canvas.height = window.innerHeight; } + function rand(a, b) { return Math.random() * (b - a) + a; } + + function mkP() { + return { x: rand(0,W), y: rand(0,H), r: rand(0.8,2.2), dx: rand(-0.25,0.25), dy: rand(-0.35,-0.08), + alpha: rand(0.2,0.8), fade: rand(0.002,0.006), color: COLORS[Math.floor(Math.random()*COLORS.length)] }; + } + + function init() { resize(); particles = Array.from({length:90}, mkP); } + + function draw() { + ctx.clearRect(0, 0, W, H); + for (let p of particles) { + ctx.beginPath(); ctx.arc(p.x, p.y, p.r, 0, Math.PI*2); + ctx.fillStyle = p.color; ctx.globalAlpha = p.alpha; ctx.fill(); + p.x += p.dx; p.y += p.dy; p.alpha -= p.fade; + if (p.alpha <= 0 || p.y < -5) Object.assign(p, mkP(), { y: H+5, alpha: rand(0.2,0.7) }); + } + ctx.globalAlpha = 1; + requestAnimationFrame(draw); + } + + window.addEventListener('resize', resize); + init(); draw(); +})(); + +// ── 3D Tilt Cards ──────────────────────────────────────── +document.querySelectorAll('.card').forEach(card => { + card.addEventListener('mousemove', function (e) { + const rect = this.getBoundingClientRect(); + const cx = rect.left + rect.width / 2; + const cy = rect.top + rect.height / 2; + const dx = (e.clientX - cx) / (rect.width / 2); + const dy = (e.clientY - cy) / (rect.height / 2); + const rotX = (-dy * 8).toFixed(2); + const rotY = ( dx * 8).toFixed(2); + this.style.transform = `perspective(600px) rotateX(${rotX}deg) rotateY(${rotY}deg) translateY(-3px) scale(1.02)`; + + // glare + let glare = this.querySelector('.card-glare'); + if (!glare) { + glare = document.createElement('div'); + glare.className = 'card-glare'; + this.appendChild(glare); + } + const gx = ((e.clientX - rect.left) / rect.width * 100).toFixed(1); + const gy = ((e.clientY - rect.top) / rect.height * 100).toFixed(1); + glare.style.background = `radial-gradient(circle at ${gx}% ${gy}%, rgba(255,255,255,0.12) 0%, transparent 65%)`; + }); + + card.addEventListener('mouseleave', function () { + this.style.transform = ''; + this.querySelector('.card-glare')?.remove(); + }); + + // Ripple + card.addEventListener('click', function (e) { + const circle = document.createElement('span'); + const diameter = Math.max(this.clientWidth, this.clientHeight); + const rect = this.getBoundingClientRect(); + circle.classList.add('ripple'); + circle.style.width = circle.style.height = diameter + 'px'; + circle.style.left = (e.clientX - rect.left - diameter / 2) + 'px'; + circle.style.top = (e.clientY - rect.top - diameter / 2) + 'px'; + this.querySelector('.ripple')?.remove(); + this.appendChild(circle); + }); +}); + diff --git a/includes/config/projects.php b/includes/config/projects.php new file mode 100644 index 0000000..4cf88eb --- /dev/null +++ b/includes/config/projects.php @@ -0,0 +1,77 @@ + [ + [ + 'title' => 'Gitea', + 'description' => 'Mein privater Gitea - Server', + 'url' => '/git', + 'logo' => '/icons/gitea.svg', + 'color' => '#609926', + 'external' => true, + ], + [ + 'title' => 'Nextcloud', + 'description' => 'Meine persönliche Nextcloud', + 'url' => '/nextcloud', + 'logo' => '/icons/nextcloud.svg', + 'color' => '#0082c9', + 'external' => true, + ], + [ + 'title' => 'Server Dashboard', + 'description' => 'Server-Verwaltung mit Cockpit', + 'url' => 'https://cockpit.fabianschieder.com', + 'logo' => '/icons/ubuntu.svg', + 'color' => '#E95420', + 'external' => true, + ], + ], + 'schule' => [ + [ + 'title' => 'Geizkragen', + 'description' => 'Ein Online - Preisvergleichsportal', + 'url' => 'https://geizkragen.store', + 'logo' => '/icons/geizkragen.png', + 'color' => '#0082c9', + 'external' => true, + ], + ], + 'dienste' => [ + [ + 'title' => 'Home Assistant', + 'description' => 'Mein privater HomeAssistant Server', + 'url' => 'http://homeassistant.fabianschieder.com', + 'logo' => '/icons/homeassistant.svg', + 'color' => '#18BCF2', + 'external' => true, + ], + [ + 'title' => 'NAS', + 'description' => 'Mein privater Netzwerkspeicher', + 'url' => 'http://nas.fabianschieder.com', + 'logo' => '/icons/nas.svg', + 'color' => '#a855f7', + 'external' => true, + ], + ], + + // Neue Kategorie: Admin/Tools + 'admin' => [ + [ + 'title' => 'Datenbankverwaltung', + 'description' => 'Login erfolgt im Tool (keine Credentials im Code).', + 'url' => '/adminer', + 'logo' => null, + 'color' => '#14b8a6', + 'external' => false, + ], + ], +]; + diff --git a/includes/config/server_status_targets.php b/includes/config/server_status_targets.php new file mode 100644 index 0000000..1f24991 --- /dev/null +++ b/includes/config/server_status_targets.php @@ -0,0 +1,35 @@ + 'Gitea', + 'url' => 'https://fabianschieder.com/git', + ], + [ + 'title' => 'Nextcloud', + 'url' => 'https://fabianschieder.com/nextcloud', + ], + [ + 'title' => 'Home Assistant', + 'url' => 'http://homeassistant.fabianschieder.com', + ], + [ + 'title' => 'NAS', + 'url' => 'http://nas.fabianschieder.com', + ], + [ + 'title' => 'Cockpit', + 'url' => 'https://cockpit.fabianschieder.com', + ], + [ + 'title' => 'Geizkragen', + 'url' => 'https://geizkragen.store', + ], +]; + diff --git a/includes/lib/server_status.php b/includes/lib/server_status.php new file mode 100644 index 0000000..d9cfe65 --- /dev/null +++ b/includes/lib/server_status.php @@ -0,0 +1,175 @@ + [ + 'method' => 'HEAD', + 'timeout' => $timeoutSeconds, + 'ignore_errors' => true, + 'follow_location' => 0, + 'max_redirects' => 0, + 'header' => "User-Agent: ServerStatus/1.0\r\n", + ], + 'ssl' => [ + 'verify_peer' => true, + 'verify_peer_name' => true, + ], + ]); + + $code = null; + $error = null; + + set_error_handler(static function (int $severity, string $message) use (&$error): bool { + $error = $message; + return true; + }); + $fp = @fopen($url, 'rb', false, $ctx); + restore_error_handler(); + + if ($fp !== false) { + fclose($fp); + } + + $ms = (int)round((microtime(true) - $start) * 1000); + + // Parse status code from $http_response_header + if (isset($http_response_header) && is_array($http_response_header)) { + foreach ($http_response_header as $h) { + if (preg_match('#^HTTP/\S+\s+(\d{3})#i', (string)$h, $m)) { + $code = (int)$m[1]; + break; + } + } + } + + $ok = ($code !== null) && ($code >= 200 && $code < 400); + if ($code === null && $error === null) { + $error = 'Unbekannter Fehler'; + } + + return ['ok' => $ok, 'code' => $code, 'ms' => $ms, 'error' => $error]; +} + +function build_server_status(array $targets): array +{ + $allowedHosts = []; + foreach ($targets as $t) { + if (!empty($t['url'])) { + $p = parse_url((string)$t['url']); + if (is_array($p) && !empty($p['host'])) { + $allowedHosts[] = strtolower((string)$p['host']); + } + } + } + $allowedHosts = array_values(array_unique($allowedHosts)); + + $results = []; + foreach ($targets as $t) { + $url = (string)($t['url'] ?? ''); + if ($url === '' || !is_allowed_http_url($url, $allowedHosts)) { + $results[] = ['url' => $url, 'state' => 'unknown', 'ms' => null, 'detail' => 'Nicht erlaubt']; + continue; + } + + $r = check_http_head($url); + $results[] = [ + 'url' => $url, + 'state' => $r['ok'] ? 'up' : 'down', + 'ms' => $r['ms'], + 'detail' => $r['ok'] ? null : ($r['error'] ?? null), + ]; + } + + return $results; +} + diff --git a/includes/lib/view_helpers.php b/includes/lib/view_helpers.php new file mode 100644 index 0000000..d9494dc --- /dev/null +++ b/includes/lib/view_helpers.php @@ -0,0 +1,14 @@ + '🔒 Privat', + 'schule' => '🎓 Schule', + 'dienste' => '🔗 Verlinkungen', + 'admin' => '🛠️ Admin/Tools', + default => $category, + }; +} + diff --git a/includes/views/footer.php b/includes/views/footer.php new file mode 100644 index 0000000..5743417 --- /dev/null +++ b/includes/views/footer.php @@ -0,0 +1,4 @@ + + diff --git a/includes/views/header.php b/includes/views/header.php new file mode 100644 index 0000000..4037e00 --- /dev/null +++ b/includes/views/header.php @@ -0,0 +1,6 @@ +
+
FS
+

Fabian Schieder

+

+
+ diff --git a/includes/views/layout_foot.php b/includes/views/layout_foot.php new file mode 100644 index 0000000..81d7cfb --- /dev/null +++ b/includes/views/layout_foot.php @@ -0,0 +1,5 @@ + + + + + diff --git a/includes/views/layout_head.php b/includes/views/layout_head.php new file mode 100644 index 0000000..bbe73c4 --- /dev/null +++ b/includes/views/layout_head.php @@ -0,0 +1,18 @@ + + + + + + Fabian Schieder + + + + + +
+
+
+
+ +
+ diff --git a/includes/views/projects_section.php b/includes/views/projects_section.php new file mode 100644 index 0000000..4f06ec5 --- /dev/null +++ b/includes/views/projects_section.php @@ -0,0 +1,91 @@ + +
+

+ +

+ + +
+ diff --git a/index.php b/index.php index 338ad19..61a0f43 100644 --- a/index.php +++ b/index.php @@ -8,253 +8,18 @@ declare(strict_types=1); * /style.css */ -$projects = [ - "privat" => [ - [ - "title" => "Gitea", - "description" => "Mein privater Gitea - Server", - "url" => "/git", - "logo" => "/icons/gitea.svg", - "color" => "#609926", - "external" => true - ], - [ - "title" => "Nextcloud", - "description" => "Meine persönliche Nextcloud", - "url" => "/nextcloud", - "logo" => "/icons/nextcloud.svg", - "color" => "#0082c9", - "external" => true - ], - [ - "title" => "Server Dashboard", - "description" => "Server-Verwaltung mit Cockpit", - "url" => "https://cockpit.fabianschieder.com", - "logo" => "/icons/ubuntu.svg", - "color" => "#E95420", - "external" => true - ], - ], - "schule" => [ - [ - "title" => "Geizkragen", - "description" => "Ein Online - Preisvergleichsportal", - "url" => "https://geizkragen.store", - "logo" => "/icons/geizkragen.png", - "color" => "#0082c9", - "external" => true - ], - ], - "dienste" => [ - [ - "title" => "Home Assistant", - "description" => "Mein privater HomeAssistant Server", - "url" => "http://homeassistant.fabianschieder.com", - "logo" => "/icons/homeassistant.svg", - "color" => "#18BCF2", - "external" => true - ], - [ - "title" => "NAS", - "description" => "Mein privater Netzwerkspeicher", - "url" => "http://nas.fabianschieder.com", - "logo" => "/icons/nas.svg", - "color" => "#a855f7", - "external" => true - ], - ], -]; +$projects = require __DIR__ . '/includes/config/projects.php'; +$serverStatusTargets = require __DIR__ . '/includes/config/server_status_targets.php'; -// ── Serverstatus (dependency-free, mit Cache & SSRF-Guards) ─────────────── -/** - * Contract: - * - Input: allowlist targets (host/url/port) - * - Output: array of results with state + latency - * - Safety: no user input, no redirects, blocks private/loopback IPs - */ - -$serverStatusTargets = [ - [ - 'title' => 'Gitea', - 'url' => 'https://fabianschieder.com/git', - ], - [ - 'title' => 'Nextcloud', - 'url' => 'https://fabianschieder.com/nextcloud', - ], - [ - 'title' => 'Home Assistant', - 'url' => 'http://homeassistant.fabianschieder.com', - ], - [ - 'title' => 'NAS', - 'url' => 'http://nas.fabianschieder.com', - ], - [ - 'title' => 'Cockpit', - 'url' => 'https://cockpit.fabianschieder.com', - ], - [ - 'title' => 'Geizkragen', - 'url' => 'https://geizkragen.store', - ], -]; - -/** @return bool true if IP is public-ish */ -function is_public_ip(string $ip): bool -{ - // FILTER_FLAG_NO_PRIV_RANGE + NO_RES_RANGE covers most private/reserved ranges. - if (filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE)) { - return true; - } - - // Fallback: treat unknown/invalid as not allowed. - return false; -} - -/** Resolve host to IPs (A/AAAA) */ -function resolve_host_ips(string $host): array -{ - $ips = []; - - // IPv4 - $a = @dns_get_record($host, DNS_A); - if (is_array($a)) { - foreach ($a as $rec) { - if (!empty($rec['ip'])) $ips[] = $rec['ip']; - } - } - - // IPv6 - $aaaa = @dns_get_record($host, DNS_AAAA); - if (is_array($aaaa)) { - foreach ($aaaa as $rec) { - if (!empty($rec['ipv6'])) $ips[] = $rec['ipv6']; - } - } - - // Deduplicate - $ips = array_values(array_unique(array_filter($ips))); - return $ips; -} - -function is_allowed_http_url(string $url, array $allowedHosts): bool -{ - $parts = parse_url($url); - if (!is_array($parts) || empty($parts['scheme']) || empty($parts['host'])) return false; - - $scheme = strtolower((string)$parts['scheme']); - if (!in_array($scheme, ['http', 'https'], true)) return false; - - $host = strtolower((string)$parts['host']); - if (!in_array($host, $allowedHosts, true)) return false; - - // Resolve and ensure all resolved IPs are public. - $ips = resolve_host_ips($host); - if (empty($ips)) return false; - foreach ($ips as $ip) { - if (!is_public_ip($ip)) return false; - } - - return true; -} - -/** - * HTTP HEAD check (no redirects). - * @return array{ok:bool, code:int|null, ms:int|null, error:string|null} - */ -function check_http_head(string $url, float $timeoutSeconds = 1.6): array -{ - $start = microtime(true); - - $ctx = stream_context_create([ - 'http' => [ - 'method' => 'HEAD', - 'timeout' => $timeoutSeconds, - 'ignore_errors' => true, - 'follow_location' => 0, - 'max_redirects' => 0, - 'header' => "User-Agent: ServerStatus/1.0\r\n", - ], - 'ssl' => [ - 'verify_peer' => true, - 'verify_peer_name' => true, - ], - ]); - - $code = null; - $error = null; - - set_error_handler(static function (int $severity, string $message) use (&$error): bool { - $error = $message; - return true; - }); - $fp = @fopen($url, 'rb', false, $ctx); - restore_error_handler(); - - if ($fp !== false) { - fclose($fp); - } - - $ms = (int)round((microtime(true) - $start) * 1000); - - // Parse status code from $http_response_header - if (isset($http_response_header) && is_array($http_response_header)) { - foreach ($http_response_header as $h) { - if (preg_match('#^HTTP/\S+\s+(\d{3})#i', (string)$h, $m)) { - $code = (int)$m[1]; - break; - } - } - } - - $ok = ($code !== null) && ($code >= 200 && $code < 400); - if ($code === null && $error === null) { - $error = 'Unbekannter Fehler'; - } - - return ['ok' => $ok, 'code' => $code, 'ms' => $ms, 'error' => $error]; -} - -function build_server_status(array $targets): array -{ - $allowedHosts = []; - foreach ($targets as $t) { - if (!empty($t['url'])) { - $p = parse_url((string)$t['url']); - if (is_array($p) && !empty($p['host'])) { - $allowedHosts[] = strtolower((string)$p['host']); - } - } - } - $allowedHosts = array_values(array_unique($allowedHosts)); - - $results = []; - foreach ($targets as $t) { - $url = (string)($t['url'] ?? ''); - if ($url === '' || !is_allowed_http_url($url, $allowedHosts)) { - $results[] = ['url' => $url, 'state' => 'unknown', 'ms' => null, 'detail' => 'Nicht erlaubt']; - continue; - } - - $r = check_http_head($url); - $results[] = [ - 'url' => $url, - 'state' => $r['ok'] ? 'up' : 'down', - 'ms' => $r['ms'], - 'detail' => $r['ok'] ? null : ($r['error'] ?? null), - ]; - } - - return $results; -} +require_once __DIR__ . '/includes/lib/server_status.php'; +require_once __DIR__ . '/includes/lib/view_helpers.php'; +// ── Serverstatus (mit Cache) ────────────────────────────────────────────── $cacheTtlSeconds = 30; $cacheKey = 'server_status_v1'; $cacheFile = rtrim(sys_get_temp_dir(), DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR . $cacheKey . '.json'; $serverStatus = null; -$cacheOk = false; if (is_file($cacheFile)) { $raw = @file_get_contents($cacheFile); if ($raw !== false) { @@ -263,7 +28,6 @@ if (is_file($cacheFile)) { $age = time() - (int)$decoded['ts']; if ($age >= 0 && $age <= $cacheTtlSeconds) { $serverStatus = $decoded['data']; - $cacheOk = true; } } } @@ -283,232 +47,17 @@ if (is_array($serverStatus)) { } } } + +require __DIR__ . '/includes/views/layout_head.php'; +require __DIR__ . '/includes/views/header.php'; ?> - - - - - - Fabian Schieder - - - - - -
-
-
-
- -
- -
-
FS
-

Fabian Schieder

-

-
$items): ?> -
-

- -

- - -
+
- - - - - - - - - +