diff --git a/plugin-webseite/.dockerignore b/plugin-webseite/.dockerignore
new file mode 100644
index 00000000..f2f4fa9e
--- /dev/null
+++ b/plugin-webseite/.dockerignore
@@ -0,0 +1,6 @@
+config.php
+storage
+*.pem
+.git
+docker-compose.yml
+Caddyfile
diff --git a/plugin-webseite/.gitattributes b/plugin-webseite/.gitattributes
new file mode 100644
index 00000000..694b7411
--- /dev/null
+++ b/plugin-webseite/.gitattributes
@@ -0,0 +1,4 @@
+*.sh text eol=lf
+Dockerfile text eol=lf
+docker-compose.yml text eol=lf
+docker-bake.php text eol=lf
diff --git a/plugin-webseite/.gitignore b/plugin-webseite/.gitignore
new file mode 100644
index 00000000..afa52c80
--- /dev/null
+++ b/plugin-webseite/.gitignore
@@ -0,0 +1,4 @@
+schluessel/
+config.php
+storage/
+*.pem
diff --git a/plugin-webseite/.htaccess b/plugin-webseite/.htaccess
new file mode 100644
index 00000000..94ea9b50
--- /dev/null
+++ b/plugin-webseite/.htaccess
@@ -0,0 +1,23 @@
+# Nur die Seiten sind erreichbar; Konfiguration, Hilfscode und Daten nie.
+Options -Indexes
+
+
+
+ Require all denied
+
+
+ Order deny,allow
+ Deny from all
+
+
+
+
+ RewriteEngine On
+ RewriteRule ^(inc|storage)/ - [F,L]
+
+
+# Sicherheits-Header
+
+ Header always set X-Content-Type-Options "nosniff"
+ Header always set X-Frame-Options "DENY"
+
diff --git a/plugin-webseite/Caddyfile b/plugin-webseite/Caddyfile
new file mode 100644
index 00000000..2a203d8f
--- /dev/null
+++ b/plugin-webseite/Caddyfile
@@ -0,0 +1,11 @@
+{$PLUGINS_DOMAIN} {
+ encode gzip
+ request_body {
+ max_size 14MB
+ }
+ header {
+ Strict-Transport-Security "max-age=31536000"
+ -Server
+ }
+ reverse_proxy webseite:80
+}
diff --git a/plugin-webseite/Dockerfile b/plugin-webseite/Dockerfile
new file mode 100644
index 00000000..788666b3
--- /dev/null
+++ b/plugin-webseite/Dockerfile
@@ -0,0 +1,46 @@
+# Plugin- und Theme-Webseite als Docker-Image.
+#
+# Admin-Zugang und Signierschlüssel werden beim BAUEN fest in das Image geschrieben: config.php ist schreibgeschützt und
+# 'locked'. Im laufenden Container lässt sich nichts davon per Umgebungsvariable, Volume oder Konfigurationsdatei ändern.
+# Zum Ändern wird das Image neu gebaut. Gebaut wird mit docker-build.ps1 (Windows) bzw. docker-build.sh (Linux/macOS).
+
+FROM php:8.3-apache
+
+RUN apt-get update \
+ && apt-get install -y --no-install-recommends libzip-dev \
+ && docker-php-ext-install zip \
+ && a2enmod rewrite headers \
+ && rm -rf /var/lib/apt/lists/*
+
+# PHP: Uploads bis 12 MB (Pakete sind auf 10 MB begrenzt), keine Fehlerausgabe im Browser, Apache verrät keine Versionen
+RUN { echo 'upload_max_filesize=12M'; echo 'post_max_size=14M'; echo 'display_errors=Off'; echo 'log_errors=On'; echo 'expose_php=Off'; } > /usr/local/etc/php/conf.d/plugin-site.ini \
+ && { echo 'ServerTokens Prod'; echo 'ServerSignature Off'; echo ''; echo ' AllowOverride All'; echo ' Require all granted'; echo ''; } > /etc/apache2/conf-available/plugin-site.conf \
+ && a2enconf plugin-site
+
+WORKDIR /var/www/html
+COPY . /var/www/html/
+COPY docker-bake.php /tmp/docker-bake.php
+RUN rm -rf config.php storage Dockerfile docker-compose.yml docker-build.ps1 docker-build.sh docker-bake.php Caddyfile .dockerignore
+
+ARG BASE_URL
+ARG SITE_NAME="DeskHub Plugins und Themes"
+ARG ADMIN_USER=admin
+
+# Zugangsdaten fest einbauen. Passwort-Hash und Signierschlüssel kommen als BuildKit-Secrets und stehen nicht in "docker history".
+RUN --mount=type=secret,id=admin_hash,required=true \
+ --mount=type=secret,id=signing_key,required=true \
+ --mount=type=secret,id=catalog_key,required=true \
+ mkdir -p /opt/plugin-site-secure /data \
+ && php /tmp/docker-bake.php \
+ && rm /tmp/docker-bake.php \
+ && chown -R root:root /var/www/html \
+ && chown root:www-data /var/www/html/config.php /opt/plugin-site-secure /opt/plugin-site-secure/herausgeber-privat.pem \
+ && chmod 0440 /var/www/html/config.php /opt/plugin-site-secure/herausgeber-privat.pem \
+ && chmod 0750 /opt/plugin-site-secure \
+ && chmod -R go-w /var/www/html \
+ && chown www-data:www-data /data
+
+# Beschreibbar ist nur /data (Pakete und Datenbank), alles andere bleibt unverändert
+VOLUME ["/data"]
+EXPOSE 80
+HEALTHCHECK --interval=30s --timeout=5s --start-period=10s CMD php -r 'exit(@file_get_contents("http://127.0.0.1/health.php") === "ok" ? 0 : 1);'
diff --git a/plugin-webseite/README.md b/plugin-webseite/README.md
new file mode 100644
index 00000000..c4e0a28e
--- /dev/null
+++ b/plugin-webseite/README.md
@@ -0,0 +1,94 @@
+# DeskHub Plugin- und Theme-Webseite
+
+Eigenständige Webseite, von der DeskHub-Installationen Plugins und Kundenportal-Themes laden können. Sie ist **unabhängig von DeskHub**: eigener Webspace, eigene Datenbank (SQLite), keine DeskHub-Dateien nötig.
+
+## Was sie kann
+
+- Verwaltung mit allen Paketen, Versionen, Signaturstatus und Download-Zähler (keine öffentliche Übersichtsseite: Die Startseite führt zur Verwaltung)
+- Katalog für DeskHub: `catalog.php?type=plugins` und `catalog.php?type=themes`
+- Verwaltung (`admin.php`): Pakete hochladen, Versionen entfernen, Vorschaubilder
+- Prüft jedes Paket beim Hochladen (Dateitypen, Pfade, `plugin.json`, Berechtigungen) und **signiert es automatisch** mit eurem privaten Schlüssel
+- Veröffentlichte Versionen sind unveränderlich: Für Änderungen gibt es eine neue Version
+
+## Voraussetzungen
+
+PHP 8.1 oder neuer mit den Erweiterungen `zip`, `openssl`, `pdo_sqlite`; Apache (`.htaccess` mitgeliefert) oder nginx. **HTTPS ist Pflicht**, DeskHub lädt nur über https.
+
+## Einrichten
+
+1. Den Ordner `plugin-webseite` auf euren Webspace hochladen (eigene Domain oder Unterdomain, z. B. `plugins.example.de`).
+2. Auf dem Server (oder lokal und dann hochladen) ausführen:
+
+ ```bash
+ php einrichten.php
+ ```
+
+ Das Skript fragt Name, öffentliche Adresse und Admin-Zugang ab, erzeugt den **Signierschlüssel** und schreibt `config.php`. Den Ordner mit dem privaten Schlüssel legt es bewusst außerhalb des Webverzeichnisses an.
+3. **Den privaten Schlüssel `herausgeber-privat.pem` sichern** (Passwortmanager oder verschlüsselte Sicherung) und nie weitergeben. Geht er verloren, müssen alle Admins einen neuen öffentlichen Schlüssel eintragen.
+4. Unter `https://eure-domain/admin.php` anmelden und das erste Paket veröffentlichen.
+
+nginx: Zugriff auf `inc/`, `storage/`, `config.php`, `config.example.php` und `einrichten.php` sperren (z. B. `location ~ ^/(inc|storage)/ { deny all; }` und `location ~ ^/(config|config\.example|einrichten)\.php$ { deny all; }`).
+
+## Nur geprüfte Plugins für eure Kunden
+
+Setzt den Katalog dieser Webseite als **offiziellen Katalog** eurer DeskHub-Version: `php einrichten.php` und die Verwaltung („Einstellungen für DeskHub“) geben den Inhalt für `includes/catalog_defaults.php` aus. Nach dem Eintragen und Bauen eines Releases installieren eure Kunden Katalog-Plugins nur noch mit eurer Signatur, also nur, was ihr hier veröffentlicht habt. Eigene, unsignierte Plugins können Kunden weiterhin selbst hochladen; sie sind als „Eigenes Plugin“ markiert. Der Katalog ist per Zugangsschlüssel geschützt: Beim Docker-Bau erzeugt `docker-build` die Datei `schluessel/katalog-zugangsschluessel.txt` und gibt den Schlüssel aus. Er gehört als `access_key` in `includes/catalog_defaults.php`. Ohne ihn antworten `catalog.php`, `download.php`, `preview.php` und `publisher-key.php` mit 401 (`health.php` bleibt offen).
+
+## Pakete veröffentlichen
+
+**Plugin:** ZIP mit `plugin.json` (`slug`, `name`, `version`, optional `description`, `author`, `requires`, `permissions`), siehe die DeskHub-Anleitung. Alle Angaben liest die Webseite aus der `plugin.json`.
+
+**Theme:** ZIP mit `theme.css` im Stammverzeichnis (nur CSS, Bilder und Schriften, kein PHP). Kennung, Name und Beschreibung tragt ihr beim Hochladen ein. Autor und Version stehen am besten im CSS-Kopf:
+
+```css
+/*
+ * Author: Name
+ * Version: 1.0.0
+ */
+```
+
+## In DeskHub nutzen
+
+Der Katalog dieser Webseite ist in DeskHub fest eingebaut (`includes/catalog_defaults.php`). Admins müssen nichts eintragen: Unter Plugins und Einstellungen → Design erscheint das Angebot automatisch. Den Inhalt der Datei liefern `php einrichten.php` und die Verwaltung unter „Einstellungen für DeskHub“.
+
+## Docker (externes Hosting mit fest eingebauten Zugangsdaten)
+
+Für den Betrieb auf einem externen Server gibt es ein Docker-Image. **Admin-Zugang und Signierschlüssel werden beim Bauen fest in das Image geschrieben.** Im laufenden Container lassen sie sich weder per Umgebungsvariable noch per Volume oder Konfigurationsdatei ändern (`config.php` ist schreibgeschützt und „gesperrt“). Zum Ändern wird das Image neu gebaut.
+
+**Bauen** (nur Docker nötig, PHP muss nicht installiert sein; auf dem Rechner, nicht auf dem Server):
+
+```powershell
+.\docker-build.ps1 -BaseUrl https://plugins.example.de # Windows
+```
+
+```bash
+./docker-build.sh https://plugins.example.de # Linux / macOS
+```
+
+Das Skript fragt das Admin-Passwort ab (mindestens 12 Zeichen), erzeugt bei Bedarf den Signierschlüssel (`schluessel/herausgeber-privat.pem`, **sichern, nie weitergeben**) und baut das Image `deskhub-plugins:latest`. Passwort-Hash und Schlüssel werden als BuildKit-Secrets übergeben und stehen nicht im Image-Verlauf (`docker history`).
+
+**Betreiben** (auf dem Server, z. B. im Ordner `/home/docker/plugin-deskhub`; das Image dort bauen oder per `docker save`/`docker load` aus einer **privaten** Quelle laden):
+
+```bash
+cd /home/docker/plugin-deskhub
+docker compose up -d # Webseite auf Port 3040 (http)
+```
+
+Die Webseite ist danach unter `http://SERVER:3040` erreichbar. **DeskHub lädt Plugins nur über https**, deshalb braucht die öffentliche Adresse (`BASE_URL`, die ihr beim Bauen angegeben habt) einen HTTPS-Proxy, der auf Port 3040 weiterleitet. Zwei Wege:
+
+- **Euer eigener Proxy** (nginx, Traefik, Nginx Proxy Manager …): `https://plugins.example.de` → `http://127.0.0.1:3040`.
+- **Mitgelieferter Caddy** (besorgt das Zertifikat selbst; Ports 80 und 443 frei, Domain zeigt auf den Server): `PLUGINS_DOMAIN=plugins.example.de docker compose --profile https up -d`.
+
+Der Container läuft mit schreibgeschütztem Dateisystem und ohne unnötige Rechte; beschreibbar ist nur das Volume `plugin_daten` (veröffentlichte Pakete und Datenbank). Port 3040 sollte von außen nur über den Proxy erreichbar sein (Firewall), sonst läuft die Verwaltung auch unverschlüsselt.
+
+**Wichtig:** Das Image enthält den Passwort-Hash und den **privaten Signierschlüssel**. Wer das Image besitzt, besitzt beides. Nur in eine private Registry oder direkt auf den Server übertragen, nie öffentlich hochladen. Ein neues Passwort oder ein neuer Schlüssel bedeutet: neu bauen und das Image austauschen (bei einem neuen Schlüssel müssen alle DeskHub-Installationen den neuen öffentlichen Schlüssel erhalten).
+
+## Pakete selbst signieren (ohne automatische Signatur)
+
+Wer den Schlüssel nicht auf dem Server haben möchte, lässt `signing_key_file` leer und signiert lokal mit dem Werkzeug `tools/plugin-sign.php` aus dem DeskHub-Paket. Die Webseite veröffentlicht dann unsignierte Pakete.
+
+## Sicherheit
+
+- Anmeldung mit Passwort-Hash, CSRF-Schutz, Sperre nach 5 Fehlversuchen pro 15 Minuten und IP
+- Pakete liegen in `storage/` (per `.htaccess` gesperrt) und werden nur über `download.php` ausgeliefert
+- Der Katalog liefert für jedes Paket Prüfsumme und Signatur; DeskHub prüft beides vor der Installation
+- Plugins sind Programmcode: Veröffentlicht nur Pakete, deren Inhalt ihr geprüft habt
diff --git a/plugin-webseite/admin.php b/plugin-webseite/admin.php
new file mode 100644
index 00000000..1e738479
--- /dev/null
+++ b/plugin-webseite/admin.php
@@ -0,0 +1,136 @@
+ 0, 'path' => '/', 'secure' => !empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off', 'httponly' => true, 'samesite' => 'Strict']);
+session_name('plugin_site_admin');
+session_start();
+
+$cfg = site_config();
+$error = null;
+$message = null;
+
+function admin_csrf(): string
+{
+ return $_SESSION['csrf'] ??= bin2hex(random_bytes(24));
+}
+function admin_check_csrf(): bool
+{
+ return hash_equals($_SESSION['csrf'] ?? '', (string) ($_POST['csrf'] ?? ''));
+}
+/** Höchstens 5 Fehlversuche pro IP in 15 Minuten. */
+function admin_throttled(bool $record = false): bool
+{
+ $file = site_storage() . '/login_attempts.json';
+ $data = is_file($file) ? (json_decode((string) file_get_contents($file), true) ?: []) : [];
+ $ip = $_SERVER['REMOTE_ADDR'] ?? '?';
+ $now = time();
+ $data[$ip] = array_values(array_filter($data[$ip] ?? [], fn($t) => $t > $now - 900));
+ if ($record) { $data[$ip][] = $now; file_put_contents($file, json_encode($data), LOCK_EX); }
+ return count($data[$ip]) >= 5;
+}
+
+$loggedIn = !empty($_SESSION['admin']);
+
+if ($_SERVER['REQUEST_METHOD'] === 'POST') {
+ $action = $_POST['action'] ?? '';
+ if ($action === 'login') {
+ if ($cfg['admin_password_hash'] === '') {
+ $error = 'Die Verwaltung ist nicht eingerichtet: In config.php fehlt admin_password_hash.';
+ } elseif (admin_throttled()) {
+ $error = 'Zu viele Fehlversuche. Bitte in 15 Minuten erneut versuchen.';
+ } elseif (hash_equals((string) $cfg['admin_user'], (string) ($_POST['user'] ?? '')) && password_verify((string) ($_POST['password'] ?? ''), (string) $cfg['admin_password_hash'])) {
+ session_regenerate_id(true);
+ $_SESSION['admin'] = true;
+ header('Location: admin.php');
+ exit;
+ } else {
+ admin_throttled(true);
+ usleep(400000);
+ $error = 'Benutzername oder Passwort ist falsch.';
+ }
+ } elseif (!$loggedIn || !admin_check_csrf()) {
+ http_response_code(403);
+ $error = 'Ungültige Anfrage. Bitte neu anmelden.';
+ $loggedIn = false;
+ } elseif ($action === 'logout') {
+ session_destroy();
+ header('Location: admin.php');
+ exit;
+ } elseif ($action === 'publish') {
+ $zip = $_FILES['package'] ?? [];
+ if (($zip['error'] ?? UPLOAD_ERR_NO_FILE) !== UPLOAD_ERR_OK) {
+ $error = 'Upload-Fehler (Code ' . ($zip['error'] ?? '?') . '). Prüft upload_max_filesize und post_max_size.';
+ } elseif (strtolower(pathinfo((string) $zip['name'], PATHINFO_EXTENSION)) !== 'zip') {
+ $error = 'Bitte eine ZIP-Datei hochladen.';
+ } else {
+ $prev = $_FILES['preview'] ?? [];
+ $hasPreview = ($prev['error'] ?? UPLOAD_ERR_NO_FILE) === UPLOAD_ERR_OK;
+ $res = site_publish((string) ($_POST['type'] ?? ''), $zip['tmp_name'], $_POST, $hasPreview ? $prev['tmp_name'] : null, $hasPreview ? (string) $prev['name'] : null);
+ if ($res['ok']) $message = "Veröffentlicht: {$res['slug']} {$res['version']} (" . ($res['signed'] ? 'signiert' : 'NICHT signiert, kein Signierschlüssel eingerichtet') . ').';
+ else $error = $res['error'];
+ }
+ } elseif ($action === 'delete') {
+ $message = site_delete_version((int) ($_POST['id'] ?? 0)) ? 'Version entfernt.' : null;
+ $error = $message ? null : 'Version nicht gefunden.';
+ }
+}
+
+site_header('Verwaltung');
+?>
+
Verwaltung
+
= site_h($error) ?>
+
= site_h($message) ?>
+
+
+
+
+
+
= site_public_key() !== '' ? '✓ Signierschlüssel eingerichtet: Neue Pakete werden automatisch signiert.' : 'Kein Signierschlüssel eingerichtet (signing_key_file in config.php): Pakete werden unsigniert veröffentlicht.' ?>
+
+ Einstellungen für DeskHub (offizieller Katalog)
+
Diesen Inhalt in includes/catalog_defaults.php von DeskHub einsetzen, bevor ihr ein Release baut. Dann installieren eure Kunden Plugins nur aus diesem Katalog, und nur signierte.
+
Es ist noch kein Signierschlüssel eingerichtet (php einrichten.php). Ohne Schlüssel ist der geprüfte Modus nicht nutzbar.