515 lines
17 KiB
PHP
515 lines
17 KiB
PHP
<?php
|
|
declare(strict_types=1);
|
|
|
|
/*
|
|
* Absolute Webpfade verwenden!
|
|
* Ordnerstruktur im Webroot:
|
|
* /icons/...
|
|
* /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
|
|
],
|
|
],
|
|
];
|
|
|
|
// ── 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;
|
|
}
|
|
|
|
$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) {
|
|
$decoded = json_decode($raw, true);
|
|
if (is_array($decoded) && isset($decoded['ts'], $decoded['data']) && is_array($decoded['data'])) {
|
|
$age = time() - (int)$decoded['ts'];
|
|
if ($age >= 0 && $age <= $cacheTtlSeconds) {
|
|
$serverStatus = $decoded['data'];
|
|
$cacheOk = true;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
if (!is_array($serverStatus)) {
|
|
$serverStatus = build_server_status($serverStatusTargets);
|
|
@file_put_contents($cacheFile, json_encode(['ts' => time(), 'data' => $serverStatus], JSON_UNESCAPED_SLASHES));
|
|
}
|
|
|
|
// Index: url => status
|
|
$serverStatusByUrl = [];
|
|
if (is_array($serverStatus)) {
|
|
foreach ($serverStatus as $svc) {
|
|
if (!empty($svc['url']) && is_string($svc['url'])) {
|
|
$serverStatusByUrl[$svc['url']] = $svc;
|
|
}
|
|
}
|
|
}
|
|
?>
|
|
<!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 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>
|
|
|
|
|
|
|