diff --git a/index.php b/index.php
new file mode 100644
index 0000000..8a5524e
--- /dev/null
+++ b/index.php
@@ -0,0 +1,414 @@
+ $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();
+?>
+
+
+
+
+
+= e(SEITEN_TITEL) ?>
+
+
+
+
+
+
+
+
+
+
+ Verfügbare Werkzeuge= $tools ? ' (' . count($tools) . ')' : '' ?>
+
+
+
+ Hier ist noch nichts eingetragen.
+ Ein Ordner erscheint automatisch, sobald er eine Datei tool.json enthält.
+
+
+
+
+
+ = e($tool['name']) ?>
+
+
+ = e($tool['beschreibung']) ?>
+
+ Keine Beschreibung hinterlegt.
+
+
+
+
+
+ = e($tag) ?>
+
+
+ v= e($tool['version']) ?>
+
+
+ = e($tool['lizenz']) ?>
+
+
+ Stand = e(date('d.m.Y', (int)strtotime($tool['stand']))) ?>
+
+
+
+
+
+
+
+
+
+
+
+ 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 Datenschutzerklärung.
+
+
+
+
+
+
+
+
diff --git a/tool.json.beispiel b/tool.json.beispiel
new file mode 100644
index 0000000..7480246
--- /dev/null
+++ b/tool.json.beispiel
@@ -0,0 +1,12 @@
+{
+ "name": "JSON Studio & MariaDB Workbench",
+ "beschreibung": "Zwei JSON-Editoren nebeneinander: importieren aus CSV, Excel, YAML, XML und SQL, vergleichen, verschachtelte Daten auflösen und als MariaDB-Skript oder JSON-Schema wieder ausgeben.",
+ "einstieg": "json-workbench.html",
+ "repository": "https://gitea.obraasch.de/oliver/json-studio",
+ "repositoryName": "Quellcode (Gitea)",
+ "version": "3.0",
+ "lizenz": "MIT",
+ "stand": "2026-09-16",
+ "tags": ["JSON", "CSV", "Excel", "YAML", "MariaDB"],
+ "sichtbar": true
+}
diff --git a/webserver-sicherheit.md b/webserver-sicherheit.md
new file mode 100644
index 0000000..2cd307e
--- /dev/null
+++ b/webserver-sicherheit.md
@@ -0,0 +1,129 @@
+# Webserver absichern
+
+Konfiguration für `software.obraasch.de`. CloudPanel arbeitet in der Regel mit
+nginx — nimm den entsprechenden Abschnitt und lade danach neu.
+
+Die Startseite `index.php` setzt ihre eigenen Header bereits selbst. Die
+folgende Konfiguration deckt zusätzlich die statischen Dateien ab, also
+auch `json-workbench.html` und die Rechtstexte.
+
+---
+
+## nginx
+
+In den `server`-Block der Site einfügen:
+
+```nginx
+# --- Sicherheitsheader für alle Antworten ---
+add_header X-Content-Type-Options "nosniff" always;
+add_header X-Frame-Options "SAMEORIGIN" always;
+add_header Referrer-Policy "strict-origin-when-cross-origin" always;
+add_header Permissions-Policy "geolocation=(), microphone=(), camera=(), payment=(), usb=()" always;
+add_header Cross-Origin-Opener-Policy "same-origin" always;
+
+# HSTS erst aktivieren, wenn HTTPS sicher und dauerhaft läuft.
+# Ein zu früh gesetzter Wert sperrt Besucher für die angegebene Dauer aus,
+# falls das Zertifikat später Probleme macht.
+add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
+
+# --- Verstecktes und Sensibles nicht ausliefern ---
+location ~ /\. {
+ deny all;
+}
+location ~* /(tool\.json|LICENSE|README\.md|\.md)$ {
+ deny all;
+}
+# Die Datenbank-Brücke enthält Zugangsdaten und darf nie als Quelltext
+# ausgeliefert werden. Das passiert nur, wenn PHP ausfällt - deshalb
+# zur Sicherheit ausdrücklich absichern:
+location = /db-bridge.php {
+ # Beispiel: nur aus dem eigenen Netz erreichbar. Anpassen oder entfernen,
+ # wenn die Brücke von außen benutzt werden soll.
+ # allow 192.168.0.0/16;
+ # deny all;
+ include snippets/fastcgi-php.conf;
+ fastcgi_pass unix:/var/run/php/php8.2-fpm.sock;
+}
+
+# --- Verzeichnislisten abschalten ---
+autoindex off;
+
+# --- Uploads klein halten ---
+client_max_body_size 4m;
+```
+
+### Content-Security-Policy für das Werkzeug
+
+Wenn die Bibliotheken lokal in `lib/` liegen, kannst du die Regeln eng ziehen.
+`unsafe-inline` ist nötig, weil Stile und Skript direkt in der HTML-Datei
+stehen; `unsafe-eval` benötigt der JSON-Editor für seine Abfragesprache.
+
+```nginx
+location /json-workbench/ {
+ add_header Content-Security-Policy
+ "default-src 'self'; \
+ script-src 'self' 'unsafe-inline' 'unsafe-eval'; \
+ style-src 'self' 'unsafe-inline'; \
+ img-src 'self' data:; \
+ font-src 'self'; \
+ connect-src *; \
+ frame-ancestors 'none'; \
+ base-uri 'none'" always;
+}
+```
+
+`connect-src *` ist Absicht: URL-Import, Webhook und Datenbankanbindung
+sprechen Adressen an, die die Nutzer selbst eingeben. Willst du das
+einschränken, trage statt `*` die erlaubten Ziele ein — dann funktionieren
+diese drei Funktionen nur noch mit diesen Zielen.
+
+Lädst du die Bibliotheken doch vom CDN, ergänze bei `script-src` und
+`style-src` zusätzlich `https://cdn.jsdelivr.net`.
+
+---
+
+## Apache (.htaccess)
+
+```apache
+
+ Header always set X-Content-Type-Options "nosniff"
+ Header always set X-Frame-Options "SAMEORIGIN"
+ Header always set Referrer-Policy "strict-origin-when-cross-origin"
+ Header always set Permissions-Policy "geolocation=(), microphone=(), camera=(), payment=(), usb=()"
+ Header always set Cross-Origin-Opener-Policy "same-origin"
+ # Erst aktivieren, wenn HTTPS dauerhaft steht:
+ Header always set Strict-Transport-Security "max-age=31536000; includeSubDomains"
+
+
+# Verzeichnislisten abschalten
+Options -Indexes
+
+# Versteckte Dateien und Metadaten schützen
+
+ Require all denied
+
+
+# Nur nötig, falls die Domain nicht ohnehin auf HTTPS zwingt
+
+ RewriteEngine On
+ RewriteCond %{HTTPS} !=on
+ RewriteRule ^ https://%{HTTP_HOST}%{REQUEST_URI} [L,R=301]
+
+```
+
+---
+
+## Noch ein paar Punkte
+
+- **PHP-Version aktuell halten.** `index.php` und `db-bridge.php` brauchen
+ mindestens PHP 8.0.
+- **Fehlermeldungen nicht anzeigen.** In der `php.ini` produktiv
+ `display_errors = Off` und `log_errors = On`. Eine PHP-Fehlermeldung verrät
+ sonst Pfade und unter Umständen Zugangsdaten.
+- **Keine Backup-Dateien im Web-Verzeichnis.** Dateien wie
+ `db-bridge.php.bak` oder `index.php~` werden als Klartext ausgeliefert. Der
+ Editor-Ordner `.git` gehört ebenfalls nicht auf den Webserver — die
+ `\.`-Regel oben fängt das ab.
+- **Automatische Zertifikatserneuerung prüfen**, bevor du HSTS einschaltest.
+- **Logfile-Aufbewahrung** an das anpassen, was in der Datenschutzerklärung
+ steht. Beides muss zusammenpassen.