Add initial implementation of mini database management tool with environment configuration and server status monitoring
This commit is contained in:
parent
6e633e0308
commit
76764e2995
10
.env.example
Normal file
10
.env.example
Normal file
@ -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
|
||||
|
||||
10
.gitignore
vendored
10
.gitignore
vendored
@ -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
|
||||
|
||||
18
adminer/README.md
Normal file
18
adminer/README.md
Normal file
@ -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).
|
||||
|
||||
19
adminer/adminer.php
Normal file
19
adminer/adminer.php
Normal file
@ -0,0 +1,19 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* Adminer Loader (ohne Secrets im Repo)
|
||||
*
|
||||
* Damit /adminer funktioniert, brauchst du hier Adminer als Single-File.
|
||||
* Aus Lizenz/Copyright-Gründen wird Adminer nicht automatisch hier eingebettet.
|
||||
*
|
||||
* Vorgehen:
|
||||
* - Lade adminer.php von https://www.adminer.org/ herunter
|
||||
* - Ersetze diese Datei durch die echte Adminer-Datei
|
||||
*/
|
||||
|
||||
http_response_code(501);
|
||||
header('Content-Type: text/plain; charset=utf-8');
|
||||
echo "Adminer ist noch nicht installiert.\n";
|
||||
echo "Bitte lade die Datei 'adminer.php' von https://www.adminer.org/ herunter und ersetze diese Datei.\n";
|
||||
|
||||
96
adminer/auth.php
Normal file
96
adminer/auth.php
Normal file
@ -0,0 +1,96 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
require_once __DIR__ . '/env.php';
|
||||
|
||||
function admin_session_start()
|
||||
{
|
||||
if (session_status() === PHP_SESSION_NONE) {
|
||||
// Better defaults
|
||||
ini_set('session.cookie_httponly', '1');
|
||||
if (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off') {
|
||||
ini_set('session.cookie_secure', '1');
|
||||
}
|
||||
session_start();
|
||||
}
|
||||
}
|
||||
|
||||
function admin_is_logged_in()
|
||||
{
|
||||
admin_session_start();
|
||||
return !empty($_SESSION['db_admin']['ok']);
|
||||
}
|
||||
|
||||
function admin_logout()
|
||||
{
|
||||
admin_session_start();
|
||||
unset($_SESSION['db_admin']);
|
||||
}
|
||||
|
||||
function admin_default_creds(): array
|
||||
{
|
||||
$vars = env_load(dirname(__DIR__) . '/.env');
|
||||
|
||||
return [
|
||||
'host' => 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,
|
||||
]);
|
||||
}
|
||||
49
adminer/env.php
Normal file
49
adminer/env.php
Normal file
@ -0,0 +1,49 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* Mini .env loader (ohne externe Dependencies).
|
||||
* Liest KEY=VALUE aus Projekt-Root .env.
|
||||
*/
|
||||
|
||||
function env_load($path)
|
||||
{
|
||||
if (!is_file($path)) return [];
|
||||
|
||||
$vars = [];
|
||||
$lines = @file($path, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES);
|
||||
if (!is_array($lines)) return [];
|
||||
|
||||
foreach ($lines as $line) {
|
||||
$line = trim($line);
|
||||
if ($line === '' || (isset($line[0]) && $line[0] === '#')) continue;
|
||||
|
||||
$pos = strpos($line, '=');
|
||||
if ($pos === false) continue;
|
||||
|
||||
$k = trim(substr($line, 0, $pos));
|
||||
$v = trim(substr($line, $pos + 1));
|
||||
|
||||
// strip simple quotes
|
||||
$len = strlen($v);
|
||||
if ($len >= 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;
|
||||
}
|
||||
217
adminer/index.php
Normal file
217
adminer/index.php
Normal file
@ -0,0 +1,217 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
require_once __DIR__ . '/auth.php';
|
||||
require_once __DIR__ . '/views.php';
|
||||
|
||||
admin_session_start();
|
||||
|
||||
$action = (string)($_GET['a'] ?? '');
|
||||
if ($action === 'logout') {
|
||||
admin_logout();
|
||||
header('Location: /adminer', true, 302);
|
||||
exit;
|
||||
}
|
||||
|
||||
// Login
|
||||
$error = null;
|
||||
$success = null;
|
||||
if ($_SERVER['REQUEST_METHOD'] === 'POST' && (string)($_POST['action'] ?? '') === 'login') {
|
||||
$host = trim((string)($_POST['host'] ?? ''));
|
||||
$port = (int)($_POST['port'] ?? 3306);
|
||||
$user = trim((string)($_POST['user'] ?? ''));
|
||||
$pass = (string)($_POST['pass'] ?? '');
|
||||
$db = trim((string)($_POST['db'] ?? ''));
|
||||
|
||||
$res = admin_try_login($host, $port, $user, $pass, $db);
|
||||
if ($res['ok']) {
|
||||
$success = 'Login erfolgreich.';
|
||||
header('Location: /adminer', true, 302);
|
||||
exit;
|
||||
}
|
||||
$error = (string)($res['error'] ?? 'Login fehlgeschlagen.');
|
||||
}
|
||||
|
||||
$defaults = admin_default_creds();
|
||||
|
||||
// UI when not logged in
|
||||
if (!admin_is_logged_in()) {
|
||||
$body = "<div class=\"top\"><h1>DB-Verwaltung</h1><span class=\"pill\">Mini-Admin</span></div>";
|
||||
$body .= "<div class=\"card\">";
|
||||
$body .= "<p class=\"muted\">Login-Daten werden nur in der Session gespeichert. Für Defaults wird <code>.env</code> aus dem Projekt-Root gelesen.</p>";
|
||||
|
||||
if ($error) $body .= '<div class="err">' . h($error) . '</div><br>';
|
||||
|
||||
$body .= "<form method=\"post\">";
|
||||
$body .= "<input type=\"hidden\" name=\"action\" value=\"login\">";
|
||||
$body .= "<div class=\"grid\">";
|
||||
|
||||
$body .= "<div>";
|
||||
$body .= "<label>Host<br><input name=\"host\" value=\"" . h((string)$defaults['host']) . "\"></label><br><br>";
|
||||
$body .= "<label>Port<br><input name=\"port\" type=\"number\" value=\"" . h((string)$defaults['port']) . "\"></label><br><br>";
|
||||
$body .= "<label>Benutzer<br><input name=\"user\" value=\"" . h((string)$defaults['user']) . "\"></label><br><br>";
|
||||
$body .= "<label>Passwort<br><input name=\"pass\" type=\"password\" value=\"\"></label><br><br>";
|
||||
$body .= "<label>Datenbank<br><input name=\"db\" value=\"" . h((string)$defaults['db']) . "\"></label><br><br>";
|
||||
$body .= "<button class=\"btn\" type=\"submit\">Login</button>";
|
||||
$body .= "</div>";
|
||||
|
||||
$body .= "<div>";
|
||||
$body .= "<h3>Hinweise</h3>";
|
||||
$body .= "<ul class=\"muted\">";
|
||||
$body .= "<li>Dieses Tool ist bewusst minimal (Tabellenliste, Browse, SQL Query).</li>";
|
||||
$body .= "<li>Für produktive Nutzung bitte zusätzlich absichern (Basic Auth / IP-Allowlist).</li>";
|
||||
$body .= "</ul>";
|
||||
$body .= "</div>";
|
||||
|
||||
$body .= "</div>";
|
||||
$body .= "</form>";
|
||||
$body .= "</div>";
|
||||
|
||||
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 .= '<h3>Ergebnis</h3>';
|
||||
$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 = '<div class="top">'
|
||||
. '<div><h1>DB-Verwaltung</h1><div class="muted">eingeloggt</div></div>'
|
||||
. '<div><a class="btn secondary" href="/adminer?a=logout">Logout</a></div>'
|
||||
. '</div>';
|
||||
|
||||
$body .= '<div class="grid">';
|
||||
|
||||
$body .= '<div class="card">'
|
||||
. '<h3>Tabellen</h3>'
|
||||
. '<div class="muted" style="margin-bottom:8px">Klick zum Anzeigen</div>';
|
||||
|
||||
if (empty($tables)) {
|
||||
$body .= '<div class="muted">Keine Tabellen gefunden.</div>';
|
||||
} else {
|
||||
$body .= '<ul style="list-style:none;padding-left:0;margin:0">';
|
||||
foreach ($tables as $row) {
|
||||
$tname = (string)$row[0];
|
||||
$active = ($tname === $table) ? ' style="font-weight:700"' : '';
|
||||
$body .= '<li><a' . $active . ' href="/adminer?t=' . rawurlencode($tname) . '">' . h($tname) . '</a></li>';
|
||||
}
|
||||
$body .= '</ul>';
|
||||
}
|
||||
$body .= '</div>';
|
||||
|
||||
$body .= '<div class="card">';
|
||||
|
||||
if ($msg) {
|
||||
$cls = $msg['type'] === 'ok' ? 'ok' : 'err';
|
||||
$body .= '<div class="' . $cls . '">' . h($msg['text']) . '</div><br>';
|
||||
}
|
||||
|
||||
// Browse table
|
||||
if ($table !== '') {
|
||||
// naive identifier quoting for MySQL
|
||||
if (!preg_match('/^[A-Za-z0-9_]+$/', $table)) {
|
||||
$body .= '<div class="err">Ungültiger Tabellenname.</div>';
|
||||
} else {
|
||||
$body .= '<h3>Tabelle: <code>' . h($table) . '</code></h3>';
|
||||
$stmt = $pdo->query('SELECT * FROM `' . $table . '` LIMIT ' . (int)$limit . ' OFFSET ' . (int)$offset);
|
||||
$rows = $stmt->fetchAll();
|
||||
$body .= admin_render_table($rows);
|
||||
|
||||
$body .= '<div style="margin-top:10px;display:flex;gap:10px">';
|
||||
if ($page > 1) {
|
||||
$body .= '<a class="btn secondary" href="/adminer?t=' . rawurlencode($table) . '&p=' . ($page - 1) . '">← Zurück</a>';
|
||||
}
|
||||
$body .= '<a class="btn secondary" href="/adminer?t=' . rawurlencode($table) . '&p=' . ($page + 1) . '">Weiter →</a>';
|
||||
$body .= '</div>';
|
||||
}
|
||||
|
||||
$body .= '<hr style="border:0;border-top:1px solid rgba(255,255,255,.10);margin:16px 0">';
|
||||
}
|
||||
|
||||
// Query box
|
||||
$body .= '<h3>SQL Query</h3>';
|
||||
$body .= '<form method="post">'
|
||||
. '<input type="hidden" name="action" value="query">'
|
||||
. '<textarea name="sql" rows="6" spellcheck="false" placeholder="SELECT * FROM ..."></textarea>'
|
||||
. '<div style="margin-top:10px"><button class="btn" type="submit">Ausführen</button></div>'
|
||||
. '</form>';
|
||||
|
||||
$body .= $queryResultHtml;
|
||||
|
||||
$body .= '</div>'; // card
|
||||
$body .= '</div>'; // grid
|
||||
|
||||
admin_layout('DB-Verwaltung', $body);
|
||||
} catch (Throwable $e) {
|
||||
// Session invalid, force re-login
|
||||
admin_logout();
|
||||
admin_layout('DB-Verwaltung', '<div class="top"><h1>DB-Verwaltung</h1></div><div class="card"><div class="err">' . h($e->getMessage()) . '</div><br><a class="btn" href="/adminer">Zum Login</a></div>');
|
||||
}
|
||||
|
||||
function admin_render_table(array $rows): string
|
||||
{
|
||||
if (empty($rows)) {
|
||||
return '<div class="muted">(keine Zeilen)</div>';
|
||||
}
|
||||
|
||||
$cols = array_keys((array)$rows[0]);
|
||||
$html = '<table><thead><tr>';
|
||||
foreach ($cols as $c) {
|
||||
$html .= '<th>' . h((string)$c) . '</th>';
|
||||
}
|
||||
$html .= '</tr></thead><tbody>';
|
||||
|
||||
foreach ($rows as $r) {
|
||||
$html .= '<tr>';
|
||||
foreach ($cols as $c) {
|
||||
$v = $r[$c] ?? null;
|
||||
if ($v === null) {
|
||||
$cell = '<span class="muted">NULL</span>';
|
||||
} else {
|
||||
$s = (string)$v;
|
||||
$cell = strlen($s) > 500 ? h(substr($s, 0, 500)) . '…' : h($s);
|
||||
}
|
||||
$html .= '<td>' . $cell . '</td>';
|
||||
}
|
||||
$html .= '</tr>';
|
||||
}
|
||||
|
||||
$html .= '</tbody></table>';
|
||||
return $html;
|
||||
}
|
||||
|
||||
41
adminer/views.php
Normal file
41
adminer/views.php
Normal file
@ -0,0 +1,41 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
function h($s)
|
||||
{
|
||||
return htmlspecialchars((string)$s, ENT_QUOTES);
|
||||
}
|
||||
|
||||
function admin_layout($title, $bodyHtml)
|
||||
{
|
||||
echo "<!doctype html>\n";
|
||||
echo "<html lang=\"de\">\n<head>\n";
|
||||
echo "<meta charset=\"utf-8\">\n";
|
||||
echo "<meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">\n";
|
||||
echo '<title>' . h($title) . "</title>\n";
|
||||
echo "<style>
|
||||
body{font-family:system-ui,-apple-system,Segoe UI,Roboto,Ubuntu,Cantarell,Noto Sans,sans-serif;background:#0b1020;color:#e5e7eb;margin:0}
|
||||
.wrap{max-width:1100px;margin:32px auto;padding:0 16px}
|
||||
a{color:#60a5fa;text-decoration:none}
|
||||
a:hover{text-decoration:underline}
|
||||
.top{display:flex;gap:12px;align-items:center;justify-content:space-between;margin-bottom:18px}
|
||||
.card{background:rgba(255,255,255,.06);border:1px solid rgba(255,255,255,.10);border-radius:14px;padding:16px;backdrop-filter: blur(12px)}
|
||||
.grid{display:grid;grid-template-columns:320px 1fr;gap:16px}
|
||||
@media (max-width: 900px){.grid{grid-template-columns:1fr}}
|
||||
table{width:100%;border-collapse:collapse}
|
||||
th,td{padding:8px 10px;border-bottom:1px solid rgba(255,255,255,.10);vertical-align:top}
|
||||
th{color:#cbd5e1;text-align:left;font-weight:600}
|
||||
code,pre,textarea,input{font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,Liberation Mono,monospace}
|
||||
textarea,input{width:100%;box-sizing:border-box;background:rgba(0,0,0,.35);color:#e5e7eb;border:1px solid rgba(255,255,255,.14);border-radius:10px;padding:10px}
|
||||
.btn{display:inline-block;background:#2563eb;color:white;border:0;border-radius:10px;padding:10px 12px;cursor:pointer}
|
||||
.btn.secondary{background:rgba(255,255,255,.10)}
|
||||
.muted{color:#94a3b8}
|
||||
.err{color:#fecaca;background:rgba(220,38,38,.12);border:1px solid rgba(220,38,38,.25);padding:10px 12px;border-radius:10px}
|
||||
.ok{color:#bbf7d0;background:rgba(34,197,94,.12);border:1px solid rgba(34,197,94,.25);padding:10px 12px;border-radius:10px}
|
||||
.pill{font-size:12px;background:rgba(255,255,255,.10);padding:4px 8px;border-radius:999px}
|
||||
</style>\n";
|
||||
echo "</head>\n<body>\n";
|
||||
echo "<div class=\"wrap\">\n";
|
||||
echo $bodyHtml;
|
||||
echo "</div>\n</body>\n</html>";
|
||||
}
|
||||
96
assets/js/main.js
Normal file
96
assets/js/main.js
Normal file
@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
77
includes/config/projects.php
Normal file
77
includes/config/projects.php
Normal file
@ -0,0 +1,77 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* Zentrale Projekt-/Karten-Definitionen.
|
||||
* Absolute Webpfade verwenden!
|
||||
*/
|
||||
|
||||
return [
|
||||
'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,
|
||||
],
|
||||
],
|
||||
|
||||
// Neue Kategorie: Admin/Tools
|
||||
'admin' => [
|
||||
[
|
||||
'title' => 'Datenbankverwaltung',
|
||||
'description' => 'Login erfolgt im Tool (keine Credentials im Code).',
|
||||
'url' => '/adminer',
|
||||
'logo' => null,
|
||||
'color' => '#14b8a6',
|
||||
'external' => false,
|
||||
],
|
||||
],
|
||||
];
|
||||
|
||||
35
includes/config/server_status_targets.php
Normal file
35
includes/config/server_status_targets.php
Normal file
@ -0,0 +1,35 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* Allowlist-Targets für den Serverstatus.
|
||||
* Nur feste Ziele, keine User Inputs.
|
||||
*/
|
||||
|
||||
return [
|
||||
[
|
||||
'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',
|
||||
],
|
||||
];
|
||||
|
||||
175
includes/lib/server_status.php
Normal file
175
includes/lib/server_status.php
Normal file
@ -0,0 +1,175 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* 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
|
||||
*/
|
||||
|
||||
/** @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;
|
||||
}
|
||||
|
||||
14
includes/lib/view_helpers.php
Normal file
14
includes/lib/view_helpers.php
Normal file
@ -0,0 +1,14 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
function category_title(string $category): string
|
||||
{
|
||||
return match ($category) {
|
||||
'privat' => '🔒 Privat',
|
||||
'schule' => '🎓 Schule',
|
||||
'dienste' => '🔗 Verlinkungen',
|
||||
'admin' => '🛠️ Admin/Tools',
|
||||
default => $category,
|
||||
};
|
||||
}
|
||||
|
||||
4
includes/views/footer.php
Normal file
4
includes/views/footer.php
Normal file
@ -0,0 +1,4 @@
|
||||
<footer>
|
||||
<p>© <?= date('Y') ?> Fabian Schieder — Alle Rechte vorbehalten.</p>
|
||||
</footer>
|
||||
|
||||
6
includes/views/header.php
Normal file
6
includes/views/header.php
Normal file
@ -0,0 +1,6 @@
|
||||
<header>
|
||||
<div class="avatar">FS</div>
|
||||
<h1 class="glitch" data-text="Fabian Schieder">Fabian Schieder</h1>
|
||||
<p class="tagline" id="typewriter"></p>
|
||||
</header>
|
||||
|
||||
5
includes/views/layout_foot.php
Normal file
5
includes/views/layout_foot.php
Normal file
@ -0,0 +1,5 @@
|
||||
<script src="/assets/js/main.js" defer></script>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
|
||||
18
includes/views/layout_head.php
Normal file
18
includes/views/layout_head.php
Normal file
@ -0,0 +1,18 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Fabian Schieder</title>
|
||||
<link rel="stylesheet" href="/style.css">
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<canvas id="particle-canvas"></canvas>
|
||||
<div class="orb orb-1"></div>
|
||||
<div class="orb orb-2"></div>
|
||||
<div class="orb orb-3"></div>
|
||||
<div class="orb orb-4"></div>
|
||||
|
||||
<div class="background-blur"></div>
|
||||
|
||||
91
includes/views/projects_section.php
Normal file
91
includes/views/projects_section.php
Normal file
@ -0,0 +1,91 @@
|
||||
<?php
|
||||
/**
|
||||
* Erwartet Variablen:
|
||||
* - $category (string)
|
||||
* - $items (array)
|
||||
* - $serverStatusByUrl (array)
|
||||
*/
|
||||
?>
|
||||
<section>
|
||||
<h2 class="category-title">
|
||||
<?= htmlspecialchars(category_title((string)$category), ENT_QUOTES) ?>
|
||||
</h2>
|
||||
|
||||
<div class="cards">
|
||||
<?php foreach ($items as $project): ?>
|
||||
<?php
|
||||
// Optional: Status für dieses Projekt erkennen
|
||||
$status = null;
|
||||
$statusKey = null;
|
||||
|
||||
// 1) Externe absolute URL direkt matchen
|
||||
if (!empty($project['url']) && isset($serverStatusByUrl[$project['url']])) {
|
||||
$statusKey = $project['url'];
|
||||
}
|
||||
|
||||
// 2) Interne Pfade (/git, /nextcloud) auf Domain mappen
|
||||
if ($statusKey === null && !empty($project['url']) && is_string($project['url']) && substr($project['url'], 0, 1) === '/') {
|
||||
$statusKeyCandidate = 'https://fabianschieder.com' . $project['url'];
|
||||
if (isset($serverStatusByUrl[$statusKeyCandidate])) {
|
||||
$statusKey = $statusKeyCandidate;
|
||||
}
|
||||
}
|
||||
|
||||
if ($statusKey !== null) {
|
||||
$status = $serverStatusByUrl[$statusKey];
|
||||
}
|
||||
|
||||
$state = $status ? (string)($status['state'] ?? 'unknown') : null;
|
||||
$detail = $status && !empty($status['detail']) ? (string)$status['detail'] : '';
|
||||
|
||||
$badgeText = null;
|
||||
if ($state !== null) {
|
||||
$badgeText = 'Unbekannt';
|
||||
if ($state === 'up') $badgeText = 'Online';
|
||||
elseif ($state === 'down') $badgeText = 'Offline';
|
||||
}
|
||||
?>
|
||||
|
||||
<a
|
||||
href="<?= htmlspecialchars((string)$project['url'], ENT_QUOTES) ?>"
|
||||
class="card"
|
||||
<?= !empty($project['external']) ? 'target="_blank" rel="noopener noreferrer"' : '' ?>
|
||||
style="--accent: <?= htmlspecialchars((string)($project['color'] ?? '#6366f1'), ENT_QUOTES) ?>;"
|
||||
>
|
||||
|
||||
<div class="card-icon">
|
||||
<?php if (!empty($project['logo'])): ?>
|
||||
<img
|
||||
src="<?= htmlspecialchars((string)$project['logo'], ENT_QUOTES) ?>"
|
||||
alt="<?= htmlspecialchars((string)$project['title'], ENT_QUOTES) ?> Logo"
|
||||
class="card-logo"
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
>
|
||||
<?php else: ?>
|
||||
<span aria-hidden="true">📁</span>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
|
||||
<div class="card-body">
|
||||
<h3><?= htmlspecialchars((string)$project['title'], ENT_QUOTES) ?></h3>
|
||||
<p>
|
||||
<?= htmlspecialchars((string)$project['description'], ENT_QUOTES) ?>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<?php if ($badgeText !== null): ?>
|
||||
<div class="status-right">
|
||||
<span class="status-badge status-badge--<?= htmlspecialchars((string)$state, ENT_QUOTES) ?>"
|
||||
title="<?= htmlspecialchars($detail !== '' ? $detail : (string)$badgeText, ENT_QUOTES) ?>">
|
||||
<?= htmlspecialchars((string)$badgeText, ENT_QUOTES) ?>
|
||||
</span>
|
||||
</div>
|
||||
<?php else: ?>
|
||||
<div class="card-arrow" aria-hidden="true">→</div>
|
||||
<?php endif; ?>
|
||||
</a>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
475
index.php
475
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';
|
||||
?>
|
||||
<!DOCTYPE html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Fabian Schieder</title>
|
||||
<link rel="stylesheet" href="/style.css">
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<canvas id="particle-canvas"></canvas>
|
||||
<div class="orb orb-1"></div>
|
||||
<div class="orb orb-2"></div>
|
||||
<div class="orb orb-3"></div>
|
||||
<div class="orb orb-4"></div>
|
||||
|
||||
<div class="background-blur"></div>
|
||||
|
||||
<header>
|
||||
<div class="avatar">FS</div>
|
||||
<h1 class="glitch" data-text="Fabian Schieder">Fabian Schieder</h1>
|
||||
<p class="tagline" id="typewriter"></p>
|
||||
</header>
|
||||
|
||||
<main>
|
||||
<?php foreach ($projects as $category => $items): ?>
|
||||
<section>
|
||||
<h2 class="category-title">
|
||||
<?php
|
||||
switch ($category) {
|
||||
case "privat":
|
||||
echo '🔒 Privat';
|
||||
break;
|
||||
case "schule":
|
||||
echo '🎓 Schule';
|
||||
break;
|
||||
case "dienste":
|
||||
echo '🔗 Verlinkungen';
|
||||
break;
|
||||
}
|
||||
?>
|
||||
</h2>
|
||||
|
||||
<div class="cards">
|
||||
<?php foreach ($items as $project): ?>
|
||||
<?php
|
||||
// Optional: Status für dieses Projekt erkennen
|
||||
$status = null;
|
||||
$statusKey = null;
|
||||
|
||||
// 1) Externe absolute URL direkt matchen
|
||||
if (!empty($project['url']) && isset($serverStatusByUrl[$project['url']])) {
|
||||
$statusKey = $project['url'];
|
||||
}
|
||||
|
||||
// 2) Interne Pfade (/git, /nextcloud) auf Domain mappen
|
||||
if ($statusKey === null && !empty($project['url']) && is_string($project['url']) && substr($project['url'], 0, 1) === '/') {
|
||||
$statusKeyCandidate = 'https://fabianschieder.com' . $project['url'];
|
||||
if (isset($serverStatusByUrl[$statusKeyCandidate])) {
|
||||
$statusKey = $statusKeyCandidate;
|
||||
}
|
||||
}
|
||||
|
||||
if ($statusKey !== null) {
|
||||
$status = $serverStatusByUrl[$statusKey];
|
||||
}
|
||||
|
||||
$state = $status ? (string)($status['state'] ?? 'unknown') : null;
|
||||
$detail = $status && !empty($status['detail']) ? (string)$status['detail'] : '';
|
||||
$ms = ($status && isset($status['ms']) && is_int($status['ms'])) ? $status['ms'] : null;
|
||||
|
||||
$badgeText = null;
|
||||
if ($state !== null) {
|
||||
$badgeText = 'Unbekannt';
|
||||
if ($state === 'up') $badgeText = 'Online';
|
||||
elseif ($state === 'down') $badgeText = 'Offline';
|
||||
}
|
||||
?>
|
||||
|
||||
<a
|
||||
href="<?= htmlspecialchars($project['url'], ENT_QUOTES) ?>"
|
||||
class="card"
|
||||
<?= !empty($project['external']) ? 'target="_blank" rel="noopener noreferrer"' : '' ?>
|
||||
style="--accent: <?= htmlspecialchars($project['color'], ENT_QUOTES) ?>;"
|
||||
>
|
||||
|
||||
<div class="card-icon">
|
||||
<?php if (!empty($project['logo'])): ?>
|
||||
<img
|
||||
src="<?= htmlspecialchars($project['logo'], ENT_QUOTES) ?>"
|
||||
alt="<?= htmlspecialchars($project['title'], ENT_QUOTES) ?> Logo"
|
||||
class="card-logo"
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
>
|
||||
<?php else: ?>
|
||||
<span aria-hidden="true">📁</span>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
|
||||
<div class="card-body">
|
||||
<h3><?= htmlspecialchars($project['title'], ENT_QUOTES) ?></h3>
|
||||
<p>
|
||||
<?= htmlspecialchars($project['description'], ENT_QUOTES) ?>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<?php if ($badgeText !== null): ?>
|
||||
<div class="status-right">
|
||||
<span class="status-badge status-badge--<?= htmlspecialchars($state, ENT_QUOTES) ?>" title="<?= htmlspecialchars($detail !== '' ? $detail : $badgeText, ENT_QUOTES) ?>">
|
||||
<?= htmlspecialchars($badgeText, ENT_QUOTES) ?>
|
||||
</span>
|
||||
</div>
|
||||
<?php else: ?>
|
||||
<div class="card-arrow" aria-hidden="true">→</div>
|
||||
<?php endif; ?>
|
||||
</a>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
</section>
|
||||
<?php require __DIR__ . '/includes/views/projects_section.php'; ?>
|
||||
<?php endforeach; ?>
|
||||
</main>
|
||||
|
||||
<footer>
|
||||
<p>© <?= date('Y') ?> Fabian Schieder — Alle Rechte vorbehalten.</p>
|
||||
</footer>
|
||||
|
||||
<script>
|
||||
// ── 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() {
|
||||
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');
|
||||
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);
|
||||
});
|
||||
});
|
||||
</script>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
|
||||
|
||||
|
||||
<?php
|
||||
require __DIR__ . '/includes/views/footer.php';
|
||||
require __DIR__ . '/includes/views/layout_foot.php';
|
||||
|
||||
Loading…
Reference in New Issue
Block a user