Files
Software-Uebersicht/index.php
T

415 lines
15 KiB
PHP

<?php
declare(strict_types=1);
/**
* ============================================================
* index.php - Startseite für software.obraasch.de
* ============================================================
*
* Listet automatisch alle Unterordner auf, die eine Datei
* "tool.json" enthalten. Ein Ordner ohne diese Datei taucht
* nicht auf - so bestimmst du selbst, was öffentlich wird.
*
* ------------------------------------------------------------
* BENUTZUNG
* ------------------------------------------------------------
* software.obraasch.de/
* ├── index.php (diese Datei)
* ├── impressum.html
* ├── datenschutz.html
* └── json-workbench/
* ├── tool.json <- macht den Ordner sichtbar
* ├── json-workbench.html
* └── lib/
*
* ------------------------------------------------------------
* SICHERHEITSKONZEPT
* ------------------------------------------------------------
* - Die Seite verarbeitet KEINERLEI Nutzereingaben. Es werden
* weder $_GET noch $_POST noch Cookies ausgewertet. Damit
* entfällt die gesamte übliche Angriffsfläche für Injection.
* - Es wird nichts aus einem Pfad zusammengesetzt, der von
* außen beeinflussbar wäre. Gelesen wird ausschließlich das
* eigene Verzeichnis.
* - Inhalte aus tool.json stammen zwar von dir, werden aber
* trotzdem streng geprüft und beim Ausgeben vollständig
* maskiert. Selbst wenn dort HTML oder Skriptcode stünde,
* erschiene er als Text.
* - Die Seite kommt ganz ohne JavaScript aus. Die Content-
* Security-Policy verbietet Skripte deshalb komplett.
* - Alle externen Links werden auf http/https geprüft und mit
* noopener/noreferrer versehen.
*
* Voraussetzung: PHP 8.0 oder neuer.
* ============================================================
*/
/* ===================== KONFIGURATION ===================== */
const SEITEN_TITEL = 'Software von Braasch Design';
const SEITEN_UNTERTITEL = 'Kleine Werkzeuge, kostenlos nutzbar direkt im Browser';
const BETREIBER = 'Oliver Braasch';
const KONTAKT_SEITE = 'impressum.html';
const DATENSCHUTZ_SEITE = 'datenschutz.html';
// Ordner, die nie aufgelistet werden - zusätzlich zu allen,
// die keine tool.json besitzen.
const GESPERRTE_ORDNER = ['lib', 'assets', 'vendor', 'node_modules', 'cgi-bin', 'tmp'];
// Obergrenzen als Schutz vor versehentlich riesigen Dateien
const MAX_JSON_BYTES = 65536;
const MAX_TEXT_LAENGE = 400;
const MAX_TAGS = 8;
/* =================== AB HIER NICHTS MEHR ================= */
$nonce = base64_encode(random_bytes(16));
header('Content-Type: text/html; charset=utf-8');
header(
"Content-Security-Policy: " .
"default-src 'none'; " .
"script-src 'none'; " .
"style-src 'nonce-$nonce'; " .
"img-src 'self' data:; " .
"font-src 'self'; " .
"connect-src 'none'; " .
"form-action 'none'; " .
"base-uri 'none'; " .
"frame-ancestors 'none'"
);
header('X-Content-Type-Options: nosniff');
header('X-Frame-Options: DENY');
header('Referrer-Policy: no-referrer');
header('Permissions-Policy: geolocation=(), microphone=(), camera=(), payment=(), usb=()');
header('Cross-Origin-Opener-Policy: same-origin');
/**
* Länge und Kürzen funktionieren auch ohne die mbstring-Erweiterung,
* da diese nicht auf jedem Server installiert ist.
*/
function textLaenge(string $wert): int {
if (function_exists('mb_strlen')) return mb_strlen($wert, 'UTF-8');
// Zeichen zählen statt Bytes: Fortsetzungsbytes (10xxxxxx) überspringen
return strlen(preg_replace('/[\x80-\xBF]/', '', $wert) ?? $wert);
}
function textKuerzen(string $wert, int $maxZeichen): string {
if (function_exists('mb_substr')) return mb_substr($wert, 0, $maxZeichen, 'UTF-8');
$ergebnis = '';
$zeichen = 0;
$laenge = strlen($wert);
for ($i = 0; $i < $laenge && $zeichen < $maxZeichen; $i++) {
$byte = ord($wert[$i]);
if (($byte & 0xC0) !== 0x80) $zeichen++; // neues Zeichen beginnt
if ($zeichen > $maxZeichen) break;
$ergebnis .= $wert[$i];
}
return $ergebnis;
}
/** Text säubern: ungültiges UTF-8 und Steuerzeichen raus, Länge begrenzen. */
function sauberText(mixed $wert, int $maxLaenge = MAX_TEXT_LAENGE): string {
if (!is_string($wert)) return '';
// Ungültiges UTF-8 komplett verwerfen - sonst könnten kaputte Bytes
// die spätere Maskierung unterlaufen.
if (preg_match('//u', $wert) !== 1) return '';
// Steuerzeichen byteweise entfernen; bei UTF-8 gefahrlos, weil
// Folgebytes immer >= 0x80 sind.
$wert = preg_replace('/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/', '', $wert) ?? '';
$wert = trim($wert);
if (textLaenge($wert) > $maxLaenge) {
$wert = textKuerzen($wert, $maxLaenge - 1) . '…';
}
return $wert;
}
/** Ausgabe maskieren - wird bei JEDER Ausgabe verwendet. */
function e(string $wert): string {
return htmlspecialchars($wert, ENT_QUOTES | ENT_SUBSTITUTE | ENT_HTML5, 'UTF-8');
}
/** Externe Adresse: nur http/https, sonst verworfen. */
function sichereUrl(mixed $wert): string {
if (!is_string($wert)) return '';
$wert = trim($wert);
if ($wert === '' || textLaenge($wert) > 500) return '';
if (!preg_match('#^https?://#i', $wert)) return '';
if (filter_var($wert, FILTER_VALIDATE_URL) === false) return '';
return $wert;
}
/** Relativer Pfad innerhalb des Tool-Ordners - kein Ausbruch möglich. */
function sichererEinstieg(mixed $wert, string $ordnerPfad): string {
if (!is_string($wert)) return '';
$wert = trim($wert);
if ($wert === '' || strlen($wert) > 200) return '';
if (!preg_match('#^[A-Za-z0-9._\-/]+$#', $wert)) return '';
if (str_contains($wert, '..') || str_starts_with($wert, '/')) return '';
$ziel = realpath($ordnerPfad . DIRECTORY_SEPARATOR . $wert);
$basis = realpath($ordnerPfad);
if ($ziel === false || $basis === false) return '';
if (!str_starts_with($ziel, $basis . DIRECTORY_SEPARATOR)) return '';
if (!is_file($ziel)) return '';
return $wert;
}
/** Datum nur akzeptieren, wenn es wirklich eines ist. */
function sicheresDatum(mixed $wert): string {
if (!is_string($wert)) return '';
$wert = trim($wert);
if (!preg_match('/^\d{4}-\d{2}-\d{2}$/', $wert)) return '';
[$j, $m, $t] = array_map('intval', explode('-', $wert));
return checkdate($m, $t, $j) ? $wert : '';
}
/** Alle Tools einsammeln. */
function toolsLesen(): array {
$basis = __DIR__;
$eintraege = @scandir($basis);
if ($eintraege === false) return [];
$tools = [];
foreach ($eintraege as $name) {
if ($name === '.' || $name === '..') continue;
if (str_starts_with($name, '.') || str_starts_with($name, '_')) continue;
if (in_array(strtolower($name), GESPERRTE_ORDNER, true)) continue;
$ordner = $basis . DIRECTORY_SEPARATOR . $name;
if (!is_dir($ordner)) continue;
// Symlinks, die aus dem Verzeichnis herausführen, werden ignoriert
$echt = realpath($ordner);
if ($echt === false || !str_starts_with($echt, $basis . DIRECTORY_SEPARATOR)) continue;
$jsonPfad = $echt . DIRECTORY_SEPARATOR . 'tool.json';
if (!is_file($jsonPfad)) continue;
if (filesize($jsonPfad) > MAX_JSON_BYTES) continue;
$roh = @file_get_contents($jsonPfad, false, null, 0, MAX_JSON_BYTES);
if ($roh === false) continue;
try {
$daten = json_decode($roh, true, 8, JSON_THROW_ON_ERROR);
} catch (JsonException) {
continue; // defekte Datei wird still übersprungen
}
if (!is_array($daten)) continue;
if (isset($daten['sichtbar']) && $daten['sichtbar'] === false) continue;
$anzeigename = sauberText($daten['name'] ?? '', 80) ?: $name;
$einstieg = sichererEinstieg($daten['einstieg'] ?? '', $echt);
$tags = [];
if (isset($daten['tags']) && is_array($daten['tags'])) {
foreach (array_slice($daten['tags'], 0, MAX_TAGS) as $tag) {
$tag = sauberText($tag, 24);
if ($tag !== '') $tags[] = $tag;
}
}
$tools[] = [
'ordner' => $name,
'name' => $anzeigename,
'beschreibung' => sauberText($daten['beschreibung'] ?? ''),
'einstieg' => $einstieg,
'repository' => sichereUrl($daten['repository'] ?? ''),
'repositoryName'=> sauberText($daten['repositoryName'] ?? '', 30) ?: 'Quellcode',
'version' => sauberText($daten['version'] ?? '', 20),
'lizenz' => sauberText($daten['lizenz'] ?? '', 40),
'stand' => sicheresDatum($daten['stand'] ?? ''),
'tags' => $tags,
];
}
usort($tools, fn(array $a, array $b) => strcasecmp($a['name'], $b['name']));
return $tools;
}
$tools = toolsLesen();
?>
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title><?= e(SEITEN_TITEL) ?></title>
<meta name="description" content="<?= e(SEITEN_UNTERTITEL) ?>">
<meta name="robots" content="index, follow">
<style nonce="<?= e($nonce) ?>">
:root {
--bg: #0a0a0c;
--card: #16171a;
--card-hover: #1c1d21;
--text: #e7e8ea;
--muted: #8b8d93;
--border: #2a2b30;
--accent: #ff7a1a;
--accent-soft: rgba(255, 122, 26, 0.12);
}
@media (prefers-color-scheme: light) {
:root {
--bg: #eef0f3;
--card: #ffffff;
--card-hover: #f6f7f9;
--text: #1b1c1f;
--muted: #6b6d74;
--border: #dde0e5;
--accent-soft: rgba(255, 122, 26, 0.10);
}
}
* { box-sizing: border-box; }
body {
margin: 0; padding: 0;
font-family: "Segoe UI", -apple-system, BlinkMacSystemFont, Roboto, Helvetica, Arial, sans-serif;
background: var(--bg); color: var(--text);
line-height: 1.6; -webkit-text-size-adjust: 100%;
}
.wrap { max-width: 1000px; margin: 0 auto; padding: 0 20px; }
header.top {
background: #000; border-bottom: 2px solid var(--accent);
padding: 26px 0 24px; margin-bottom: 34px;
}
@media (prefers-color-scheme: light) { header.top { background: #0b0c0e; } }
.brand { font-size: 22px; font-weight: 800; letter-spacing: 0.02em; color: #f2f2f3; }
.brand span { color: var(--accent); }
.tagline { color: #9a9ca3; font-size: 14px; margin-top: 4px; }
h2 {
font-size: 12px; font-weight: 700; letter-spacing: 0.1em;
text-transform: uppercase; color: var(--muted);
margin: 0 0 16px; padding-bottom: 8px; border-bottom: 1px solid var(--border);
}
.grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); gap: 16px; }
.card {
background: var(--card); border: 1px solid var(--border);
border-radius: 10px; padding: 18px; display: flex; flex-direction: column;
}
.card:hover { background: var(--card-hover); border-color: var(--accent); }
.card h3 { margin: 0 0 6px; font-size: 16px; font-weight: 700; }
.card .desc { color: var(--muted); font-size: 13.5px; margin: 0 0 14px; flex: 1; }
.meta { display: flex; flex-wrap: wrap; gap: 6px; margin-bottom: 14px; }
.tag {
font-size: 11px; font-weight: 600; padding: 3px 9px; border-radius: 20px;
background: var(--accent-soft); color: var(--accent); border: 1px solid transparent;
}
.tag.plain { background: transparent; color: var(--muted); border-color: var(--border); }
.actions { display: flex; gap: 8px; flex-wrap: wrap; }
a.btn {
display: inline-block; text-decoration: none; font-size: 13px; font-weight: 700;
padding: 8px 15px; border-radius: 7px; border: 1px solid var(--border); color: var(--text);
}
a.btn:hover { border-color: var(--accent); color: var(--accent); }
a.btn.primary { background: var(--accent); border-color: var(--accent); color: #fff; }
a.btn.primary:hover { background: #e8690e; border-color: #e8690e; color: #fff; }
.empty {
border: 1px dashed var(--border); border-radius: 10px;
padding: 34px 24px; text-align: center; color: var(--muted); font-size: 14px;
}
.empty code {
background: var(--card); padding: 2px 6px; border-radius: 4px;
font-family: Consolas, monospace; font-size: 12.5px; color: var(--text);
}
.info { color: var(--muted); font-size: 13.5px; margin: 34px 0 0; }
footer.bottom {
margin-top: 48px; border-top: 1px solid var(--border);
padding: 22px 0 34px; color: var(--muted); font-size: 13px;
}
footer.bottom a { color: var(--muted); text-decoration: none; }
footer.bottom a:hover { color: var(--accent); text-decoration: underline; }
.footer-links { display: flex; gap: 18px; flex-wrap: wrap; margin-bottom: 8px; }
</style>
</head>
<body>
<header class="top">
<div class="wrap">
<div class="brand">SOFT<span>WARE</span></div>
<div class="tagline"><?= e(SEITEN_UNTERTITEL) ?></div>
</div>
</header>
<main class="wrap">
<h2>Verfügbare Werkzeuge<?= $tools ? ' (' . count($tools) . ')' : '' ?></h2>
<?php if (!$tools): ?>
<div class="empty">
Hier ist noch nichts eingetragen.<br>
Ein Ordner erscheint automatisch, sobald er eine Datei <code>tool.json</code> enthält.
</div>
<?php else: ?>
<div class="grid">
<?php foreach ($tools as $tool): ?>
<article class="card">
<h3><?= e($tool['name']) ?></h3>
<?php if ($tool['beschreibung'] !== ''): ?>
<p class="desc"><?= e($tool['beschreibung']) ?></p>
<?php else: ?>
<p class="desc">Keine Beschreibung hinterlegt.</p>
<?php endif; ?>
<?php if ($tool['tags'] || $tool['version'] !== '' || $tool['lizenz'] !== '' || $tool['stand'] !== ''): ?>
<div class="meta">
<?php foreach ($tool['tags'] as $tag): ?>
<span class="tag"><?= e($tag) ?></span>
<?php endforeach; ?>
<?php if ($tool['version'] !== ''): ?>
<span class="tag plain">v<?= e($tool['version']) ?></span>
<?php endif; ?>
<?php if ($tool['lizenz'] !== ''): ?>
<span class="tag plain"><?= e($tool['lizenz']) ?></span>
<?php endif; ?>
<?php if ($tool['stand'] !== ''): ?>
<span class="tag plain">Stand <?= e(date('d.m.Y', (int)strtotime($tool['stand']))) ?></span>
<?php endif; ?>
</div>
<?php endif; ?>
<div class="actions">
<?php if ($tool['einstieg'] !== ''): ?>
<a class="btn primary" href="<?= e(rawurlencode($tool['ordner'])) ?>/<?= e($tool['einstieg']) ?>">Jetzt nutzen</a>
<?php endif; ?>
<?php if ($tool['repository'] !== ''): ?>
<a class="btn" href="<?= e($tool['repository']) ?>" rel="noopener noreferrer external"><?= e($tool['repositoryName']) ?></a>
<?php endif; ?>
</div>
</article>
<?php endforeach; ?>
</div>
<?php endif; ?>
<p class="info">
Alle Werkzeuge laufen vollständig in deinem Browser. Es werden keine Inhalte,
die du darin bearbeitest, an diesen Server übertragen oder dort gespeichert.
Näheres in der <a href="<?= e(DATENSCHUTZ_SEITE) ?>">Datenschutzerklärung</a>.
</p>
</main>
<footer class="bottom">
<div class="wrap">
<div class="footer-links">
<a href="<?= e(KONTAKT_SEITE) ?>">Impressum</a>
<a href="<?= e(DATENSCHUTZ_SEITE) ?>">Datenschutz</a>
</div>
<div>&copy; <?= e(date('Y')) ?> <?= e(BETREIBER) ?></div>
</div>
</footer>
</body>
</html>