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

+
+
+ + +
+ + + + +
+ +
+

+ +
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.

+
+
+ +

Neues Paket veröffentlichen

+
+ + + +
Nur für Themes (bei Plugins steht alles in der plugin.json) + + + + + +
+ + +
+ +

Veröffentlichte Pakete

+ 'Plugins', 'theme' => 'Themes'] as $t => $label): $items = site_items($t); ?> +

+

Noch keine.

+ + + + + + + + +
Downloadssigniert' : 'nicht signiert' ?>
+ + + + + * { min-width: 0; } +details.form { display: block; max-width: 760px; } +details.form summary { cursor: pointer; } +details.form p { overflow-wrap: anywhere; } +pre.key { max-width: 100%; margin: 12px 0 0; overflow-x: auto; white-space: pre; box-sizing: border-box; font-size: .8rem; line-height: 1.45; } +main { overflow-wrap: break-word; } +table.admin td { white-space: nowrap; } +table.admin form { margin: 0; } diff --git a/plugin-webseite/catalog.php b/plugin-webseite/catalog.php new file mode 100644 index 00000000..992edd3e --- /dev/null +++ b/plugin-webseite/catalog.php @@ -0,0 +1,11 @@ + 'DeskHub Plugins und Themes', + // Öffentliche Adresse dieser Webseite OHNE abschließenden Schrägstrich, https ist Pflicht (DeskHub lädt nur über https). + 'base_url' => 'https://plugins.example.de', + // Zugang zur Verwaltung (admin.php). Das Passwort-Hash erzeugt: php einrichten.php + 'admin_user' => 'admin', + 'admin_password_hash' => '', + // Privater Signierschlüssel (PEM). Am besten AUSSERHALB des Webverzeichnisses ablegen. Erzeugen: php einrichten.php + 'signing_key_file' => '', + // Zugangsschlüssel für den Katalog (32–128 Buchstaben/Ziffern). DeskHub schickt ihn bei jedem Abruf mit. Leer = offen (nur Entwicklung). + 'access_key' => '', + // Speicherort für Pakete und Datenbank. Standard: Ordner storage neben dieser Datei (per .htaccess gesperrt). + // 'storage_dir' => '/var/www/plugins-daten', +]; diff --git a/plugin-webseite/docker-bake.php b/plugin-webseite/docker-bake.php new file mode 100644 index 00000000..52cd83dd --- /dev/null +++ b/plugin-webseite/docker-bake.php @@ -0,0 +1,44 @@ + (string) (getenv('SITE_NAME') ?: 'DeskHub Plugins und Themes'), + 'base_url' => $base, + 'admin_user' => $user, + 'admin_password_hash' => $hash, + 'signing_key_file' => $keyDir . '/herausgeber-privat.pem', + 'storage_dir' => $storage, + 'access_key' => $access, + 'locked' => true, +]; +file_put_contents($configPath, " +param( + [string]$BaseUrl, + [string]$AdminUser = 'admin', + [string]$SiteName = 'DeskHub Plugins und Themes', + [string]$Tag = 'deskhub-plugins:latest', + [string]$KeyDir = (Join-Path $PSScriptRoot 'schluessel') +) +$ErrorActionPreference = 'Stop' + +if (-not $BaseUrl) { $BaseUrl = Read-Host 'Öffentliche Adresse der Webseite (https://plugins.example.de)' } +if ($BaseUrl -notmatch '^https://') { throw 'Die Adresse muss mit https:// beginnen (DeskHub lädt nur über https).' } +$AdminUser = (Read-Host "Admin-Benutzername [$AdminUser]").Trim() | ForEach-Object { if ($_ -eq '') { $AdminUser } else { $_ } } + +$secure = Read-Host 'Admin-Passwort (mindestens 12 Zeichen)' -AsSecureString +$plain = [Runtime.InteropServices.Marshal]::PtrToStringAuto([Runtime.InteropServices.Marshal]::SecureStringToBSTR($secure)) +if ($plain.Length -lt 12) { throw 'Das Passwort ist zu kurz (mindestens 12 Zeichen).' } + +New-Item -ItemType Directory -Force $KeyDir | Out-Null +$hashFile = Join-Path $KeyDir 'admin-hash.txt' +$keyFile = Join-Path $KeyDir 'herausgeber-privat.pem' + +# Passwort-Hash in einem Wegwerf-Container erzeugen (das Passwort kommt über die Umgebung, nicht über die Befehlszeile) +$env:DH_PW = $plain +docker run --rm -e DH_PW php:8.3-cli php -r 'echo password_hash(getenv("DH_PW"), PASSWORD_DEFAULT);' | Out-File -NoNewline -Encoding ascii $hashFile +Remove-Item Env:\DH_PW +$plain = $null + +# Zugangsschlüssel für den Katalog: DeskHub schickt ihn bei jedem Abruf mit, ohne ihn liefert die Webseite nichts aus +$catalogKeyFile = Join-Path $KeyDir 'katalog-zugangsschluessel.txt' +if (-not (Test-Path $catalogKeyFile)) { + docker run --rm php:8.3-cli php -r 'echo bin2hex(random_bytes(24));' | Out-File -NoNewline -Encoding ascii $catalogKeyFile +} + +if (-not (Test-Path $keyFile)) { + Write-Host 'Erzeuge neuen Signierschlüssel (RSA 3072) ...' + docker run --rm php:8.3-cli php -r '$c=tempnam(sys_get_temp_dir(),"c");file_put_contents($c,"[req]\ndistinguished_name=d\n[d]\n");$k=openssl_pkey_new(["config"=>$c,"private_key_bits"=>3072,"private_key_type"=>OPENSSL_KEYTYPE_RSA]);openssl_pkey_export($k,$p,null,["config"=>$c]);echo $p;' | Out-File -NoNewline -Encoding ascii $keyFile +} else { + Write-Host "Vorhandenen Signierschlüssel weiterverwenden: $keyFile" +} + +$env:DOCKER_BUILDKIT = '1' +docker build ` + --secret "id=admin_hash,src=$hashFile" ` + --secret "id=signing_key,src=$keyFile" ` + --secret "id=catalog_key,src=$catalogKeyFile" ` + --build-arg "BASE_URL=$BaseUrl" --build-arg "ADMIN_USER=$AdminUser" --build-arg "SITE_NAME=$SiteName" ` + -t $Tag $PSScriptRoot +if ($LASTEXITCODE -ne 0) { throw 'docker build ist fehlgeschlagen.' } +Remove-Item $hashFile -Force + +Write-Host "`nFertig: Image $Tag gebaut. Admin-Zugang und Signierschlüssel sind fest eingebaut." +Write-Host "Privater Schlüssel (SICHERN, nicht weitergeben): $keyFile" +Write-Host "Zugangsschlüssel für DeskHub (in includes/catalog_defaults.php als access_key eintragen): $(Get-Content $catalogKeyFile -Raw)" +Write-Host "Starten: docker compose up -d (Webseite auf Port 3040, davor einen HTTPS-Proxy schalten)" diff --git a/plugin-webseite/docker-build.sh b/plugin-webseite/docker-build.sh new file mode 100644 index 00000000..a1a3d753 --- /dev/null +++ b/plugin-webseite/docker-build.sh @@ -0,0 +1,45 @@ +#!/usr/bin/env bash +# Baut das Docker-Image der Plugin-Webseite mit fest eingebautem Admin-Zugang und Signierschlüssel. +# Benötigt nur Docker (PHP muss lokal nicht installiert sein). +# +# ./docker-build.sh https://plugins.example.de [admin-benutzer] [image-tag] +# +# Der private Schlüssel liegt danach in ./schluessel/herausgeber-privat.pem: SICHERN, nie weitergeben. +# Das Image enthält Passwort-Hash und privaten Schlüssel: nur in eine PRIVATE Registry hochladen. +set -euo pipefail +cd "$(dirname "$0")" + +BASE_URL="${1:-}"; ADMIN_USER="${2:-admin}"; TAG="${3:-deskhub-plugins:latest}"; SITE_NAME="${SITE_NAME:-DeskHub Plugins und Themes}" +[[ "$BASE_URL" == https://* ]] || { echo "Aufruf: $0 https://plugins.example.de [admin-benutzer] [image-tag]"; exit 1; } + +read -r -s -p "Admin-Passwort (mindestens 12 Zeichen): " PW; echo +[[ ${#PW} -ge 12 ]] || { echo "Das Passwort ist zu kurz."; exit 1; } + +mkdir -p schluessel; chmod 700 schluessel +printf '%s' "$(DH_PW="$PW" docker run --rm -e DH_PW php:8.3-cli php -r 'echo password_hash(getenv("DH_PW"), PASSWORD_DEFAULT);')" > schluessel/admin-hash.txt +unset PW + +# Zugangsschlüssel für den Katalog: DeskHub schickt ihn bei jedem Abruf mit, ohne ihn liefert die Webseite nichts aus +if [[ ! -f schluessel/katalog-zugangsschluessel.txt ]]; then + docker run --rm php:8.3-cli php -r 'echo bin2hex(random_bytes(24));' > schluessel/katalog-zugangsschluessel.txt + chmod 600 schluessel/katalog-zugangsschluessel.txt +fi + +if [[ ! -f schluessel/herausgeber-privat.pem ]]; then + echo "Erzeuge neuen Signierschlüssel (RSA 3072) ..." + docker run --rm php:8.3-cli php -r '$c=tempnam(sys_get_temp_dir(),"c");file_put_contents($c,"[req]\ndistinguished_name=d\n[d]\n");$k=openssl_pkey_new(["config"=>$c,"private_key_bits"=>3072,"private_key_type"=>OPENSSL_KEYTYPE_RSA]);openssl_pkey_export($k,$p,null,["config"=>$c]);echo $p;' > schluessel/herausgeber-privat.pem + chmod 600 schluessel/herausgeber-privat.pem +fi + +DOCKER_BUILDKIT=1 docker build \ + --secret id=admin_hash,src=schluessel/admin-hash.txt \ + --secret id=signing_key,src=schluessel/herausgeber-privat.pem \ + --secret id=catalog_key,src=schluessel/katalog-zugangsschluessel.txt \ + --build-arg "BASE_URL=$BASE_URL" --build-arg "ADMIN_USER=$ADMIN_USER" --build-arg "SITE_NAME=$SITE_NAME" \ + -t "$TAG" . +rm -f schluessel/admin-hash.txt +echo +echo "Fertig: Image $TAG gebaut. Admin-Zugang und Signierschlüssel sind fest eingebaut." +echo "Privater Schlüssel (SICHERN, nicht weitergeben): $(pwd)/schluessel/herausgeber-privat.pem" +echo "Zugangsschluessel fuer DeskHub (in includes/catalog_defaults.php als access_key eintragen): $(cat schluessel/katalog-zugangsschluessel.txt)" +echo "Starten: docker compose up -d (Webseite auf Port 3040, davor einen HTTPS-Proxy schalten)" diff --git a/plugin-webseite/docker-compose.yml b/plugin-webseite/docker-compose.yml new file mode 100644 index 00000000..8535797e --- /dev/null +++ b/plugin-webseite/docker-compose.yml @@ -0,0 +1,45 @@ +# Plugin- und Theme-Webseite im Docker-Container, erreichbar auf Port 3040. +# +# docker compose up -d -> http://SERVER:3040 (Image "deskhub-plugins" muss vorher gebaut sein, siehe docker-build.sh) +# +# DeskHub lädt Plugins und Themes NUR über https. Die Webseite selbst spricht auf Port 3040 nur http; davor muss also ein +# HTTPS-Proxy stehen, der die öffentliche Adresse (BASE_URL beim Bauen) auf Port 3040 weiterleitet. Entweder euer vorhandener +# Proxy (nginx, Traefik, ...) oder der mitgelieferte Caddy, der das Zertifikat automatisch besorgt: +# +# PLUGINS_DOMAIN=plugins.example.de docker compose --profile https up -d +# +# Die Daten (Pakete, Datenbank) liegen im Volume "plugin_daten" und überleben Updates des Images. +services: + webseite: + image: deskhub-plugins:latest + restart: unless-stopped + read_only: true # Dateisystem schreibgeschützt, nur /data und /tmp sind beschreibbar + tmpfs: + - /tmp + - /var/run/apache2 + - /var/lock/apache2 + cap_drop: [ALL] + cap_add: [CHOWN, SETUID, SETGID, DAC_OVERRIDE, NET_BIND_SERVICE] + security_opt: ["no-new-privileges:true"] + volumes: + - plugin_daten:/data + ports: + - "3040:80" + + caddy: + image: caddy:2 + profiles: ["https"] # nur mit --profile https + restart: unless-stopped + depends_on: [webseite] + ports: ["80:80", "443:443"] + environment: + PLUGINS_DOMAIN: ${PLUGINS_DOMAIN:-localhost} + volumes: + - ./Caddyfile:/etc/caddy/Caddyfile:ro + - caddy_daten:/data + - caddy_config:/config + +volumes: + plugin_daten: + caddy_daten: + caddy_config: diff --git a/plugin-webseite/download.php b/plugin-webseite/download.php new file mode 100644 index 00000000..f55841ea --- /dev/null +++ b/plugin-webseite/download.php @@ -0,0 +1,28 @@ +prepare('UPDATE versions SET downloads = downloads + 1 WHERE id = ?')->execute([$version['id']]); + +while (ob_get_level() > 0) @ob_end_clean(); +header('Content-Type: application/zip'); +header('Content-Disposition: attachment; filename="' . $slug . '-' . $version['version'] . '.zip"'); +header('Content-Length: ' . filesize($path)); +header('X-Content-Type-Options: nosniff'); +header('X-DeskHub-SHA256: ' . $version['sha256']); +readfile($path); diff --git a/plugin-webseite/einrichten.php b/plugin-webseite/einrichten.php new file mode 100644 index 00000000..82ec3a64 --- /dev/null +++ b/plugin-webseite/einrichten.php @@ -0,0 +1,56 @@ + $cnf, 'private_key_bits' => 3072, 'private_key_type' => OPENSSL_KEYTYPE_RSA]); + if (!$key) { fwrite(STDERR, "Schlüssel konnte nicht erzeugt werden.\n"); exit(1); } + openssl_pkey_export($key, $private, null, ['config' => $cnf]); + file_put_contents($dir . '/herausgeber-privat.pem', $private); + @chmod($dir . '/herausgeber-privat.pem', 0600); + file_put_contents($dir . '/herausgeber-oeffentlich.pem', openssl_pkey_get_details($key)['key']); + $cfg['signing_key_file'] = realpath($dir) . DIRECTORY_SEPARATOR . 'herausgeber-privat.pem'; + echo "Schlüssel erzeugt in {$dir}. Den privaten Schlüssel SICHERN und niemals weitergeben.\n"; + } +} +file_put_contents($file, " + +<?= site_h($title) ?> · <?= site_h($name) ?> + +
+ + +
+
+
+ + + 'Auf Ticket-Ereignisse reagieren (erstellt, Antwort, Status, Zuweisung)', + 'hooks.cron' => 'Regelmäßig im Hintergrund laufen (etwa alle 5 Minuten)', + 'ui.menu' => 'Eigene Menüpunkte und Seiten im Mitarbeiterbereich', + 'ui.sidebar' => 'Bausteine in der Seitenleiste der Ticketansicht', + 'api.endpoints' => 'Eigene Endpunkte in der Mitarbeiter-API', + 'db.migrations' => 'Eigene Datenbank-Tabellen anlegen', + ]; +} + +/** + * Konfiguration aus config.php; Umgebungsvariablen (nur für Tests) überschreiben einzelne Werte. + * Ist in der config.php 'locked' => true gesetzt (Docker-Image mit fest eingebauten Zugangsdaten), gilt ausschließlich diese + * Datei: Weder Umgebungsvariablen noch eine andere Konfigurationsdatei können Zugang, Schlüssel oder Speicherort ändern. + */ +function site_config(): array +{ + static $cfg = null; + if ($cfg === null) { + $default = dirname(__DIR__) . '/config.php'; + $baked = is_file($default) ? (array) require $default : []; + $locked = !empty($baked['locked']); + $file = $locked ? $default : (getenv('PLUGIN_SITE_CONFIG') ?: $default); + $cfg = is_file($file) ? (array) require $file : []; + $cfg += ['site_name' => 'DeskHub Plugins und Themes', 'base_url' => '', 'admin_user' => 'admin', 'admin_password_hash' => '', 'signing_key_file' => '', 'storage_dir' => dirname(__DIR__) . '/storage', 'access_key' => '', 'locked' => false]; + if (!$locked) { + foreach (['PLUGIN_SITE_STORAGE' => 'storage_dir', 'PLUGIN_SITE_BASE_URL' => 'base_url', 'PLUGIN_SITE_ADMIN_HASH' => 'admin_password_hash', 'PLUGIN_SITE_SIGNING_KEY' => 'signing_key_file', 'PLUGIN_SITE_ACCESS_KEY' => 'access_key'] as $env => $key) { + if (getenv($env) !== false && getenv($env) !== '') $cfg[$key] = getenv($env); + } + } + } + return $cfg; +} + +/** + * Zugangsschutz für Katalog, Downloads, Vorschaubilder und den öffentlichen Schlüssel: DeskHub schickt den Schlüssel bei jedem + * Abruf im Header "X-DeskHub-Key" mit. Ohne konfigurierten Schlüssel (access_key leer) ist alles offen (nur für Entwicklung). + */ +function site_access_ok(): bool +{ + $key = (string) site_config()['access_key']; + if ($key === '') return true; + return hash_equals($key, (string) ($_SERVER['HTTP_X_DESKHUB_KEY'] ?? '')); +} + +/** Bricht mit 401 ab, wenn der Zugangsschlüssel fehlt oder falsch ist. */ +function site_require_access(): void +{ + if (site_access_ok()) return; + usleep(300000); // Raten bremsen + http_response_code(401); + header('Content-Type: text/plain; charset=utf-8'); + header('WWW-Authenticate: X-DeskHub-Key'); + exit('Zugriff nur mit gültigem Zugangsschlüssel.'); +} + +function site_storage(): string +{ + $dir = rtrim((string) site_config()['storage_dir'], '/' . chr(92)); + foreach ([$dir, $dir . '/packages', $dir . '/previews'] as $d) if (!is_dir($d)) @mkdir($d, 0755, true); + if (!is_file($dir . '/.htaccess')) @file_put_contents($dir . '/.htaccess', "Require all denied\n\nDeny from all\n\n"); + return $dir; +} + +function site_db(): PDO +{ + static $pdo = null; + if ($pdo !== null) return $pdo; + $pdo = new PDO('sqlite:' . site_storage() . '/katalog.sqlite'); + $pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION); + $pdo->setAttribute(PDO::ATTR_DEFAULT_FETCH_MODE, PDO::FETCH_ASSOC); + $pdo->exec('PRAGMA foreign_keys = ON'); + $pdo->exec("CREATE TABLE IF NOT EXISTS items ( + id INTEGER PRIMARY KEY AUTOINCREMENT, type TEXT NOT NULL, slug TEXT NOT NULL, name TEXT NOT NULL, description TEXT NOT NULL DEFAULT '', + author TEXT NOT NULL DEFAULT '', preview TEXT NULL, created_at TEXT NOT NULL, updated_at TEXT NOT NULL, UNIQUE (type, slug))"); + $pdo->exec("CREATE TABLE IF NOT EXISTS versions ( + id INTEGER PRIMARY KEY AUTOINCREMENT, item_id INTEGER NOT NULL, version TEXT NOT NULL, file TEXT NOT NULL, sha256 TEXT NOT NULL, + signature TEXT NOT NULL DEFAULT '', size INTEGER NOT NULL, requires TEXT NOT NULL DEFAULT '', permissions TEXT NOT NULL DEFAULT '', + downloads INTEGER NOT NULL DEFAULT 0, created_at TEXT NOT NULL, UNIQUE (item_id, version), + FOREIGN KEY (item_id) REFERENCES items(id) ON DELETE CASCADE)"); + return $pdo; +} + +function site_h(?string $s): string +{ + return htmlspecialchars((string) $s, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8'); +} + +function site_valid_slug(string $slug): bool +{ + return (bool) preg_match('/^[a-z0-9][a-z0-9_-]{1,40}$/', $slug); +} + +function site_valid_version(string $v): bool +{ + return (bool) preg_match('/^\d+(\.\d+){0,3}([-+][0-9A-Za-z.]+)?$/', $v); +} + +function site_format_size(int $bytes): string +{ + return $bytes >= 1048576 ? number_format($bytes / 1048576, 1, ',', '.') . ' MB' : number_format(max(1, $bytes / 1024), 0, ',', '.') . ' KB'; +} + +/** Absolute Adresse der Webseite (für Download-Links im Katalog). */ +function site_base_url(): string +{ + $base = rtrim((string) site_config()['base_url'], '/'); + if ($base !== '') return $base; + $https = !empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off'; + return ($https ? 'https' : 'http') . '://' . ($_SERVER['HTTP_HOST'] ?? 'localhost') . rtrim(dirname($_SERVER['SCRIPT_NAME'] ?? ''), '/' . chr(92)); +} + +// ─── Paketprüfung ──────────────────────────────────────────────────────────── + +function site_path_ok(string $rel, array $allowedExtensions): bool +{ + $rel = str_replace(chr(92), '/', $rel); + if ($rel === '' || $rel[0] === '/' || str_contains($rel, "\0") || preg_match('#(^|/)\.\.(/|$)#', $rel) || preg_match('#^[A-Za-z]:#', $rel)) return false; + foreach (explode('/', $rel) as $part) if ($part !== '' && $part[0] === '.') return false; + return in_array(strtolower(pathinfo($rel, PATHINFO_EXTENSION)), $allowedExtensions, true); +} + +/** + * Prüft ein Paket und liest seine Angaben. + * + * @return array{ok:bool, error?:string, meta?:array} + */ +function site_inspect_package(string $type, string $zipPath): array +{ + if (!class_exists('ZipArchive')) return ['ok' => false, 'error' => 'Die PHP-Erweiterung zip fehlt.']; + $size = (int) @filesize($zipPath); + if ($size < 100 || $size > SITE_MAX_PACKAGE_BYTES) return ['ok' => false, 'error' => 'Das Paket ist leer oder größer als ' . (SITE_MAX_PACKAGE_BYTES / 1048576) . ' MB.']; + $zip = new ZipArchive(); + if ($zip->open($zipPath) !== true) return ['ok' => false, 'error' => 'Die ZIP-Datei konnte nicht geöffnet werden.']; + $names = []; + for ($i = 0; $i < $zip->numFiles; $i++) $names[$i] = str_replace(chr(92), '/', (string) $zip->getNameIndex($i)); + $marker = $type === 'plugin' ? 'plugin.json' : 'theme.css'; + $prefix = ''; + if (!in_array($marker, $names, true)) { + foreach ($names as $n) if (preg_match('#^([^/]+/)' . preg_quote($marker, '#') . '$#', $n, $m)) { $prefix = $m[1]; break; } + if ($prefix === '') { $zip->close(); return ['ok' => false, 'error' => $marker . ' wurde im Paket nicht gefunden.']; } + } + $extensions = $type === 'plugin' ? SITE_PLUGIN_EXTENSIONS : SITE_THEME_EXTENSIONS; + $total = 0; + $count = 0; + foreach ($names as $i => $name) { + if (substr($name, -1) === '/') continue; + if ($prefix !== '' && strpos($name, $prefix) !== 0) { $zip->close(); return ['ok' => false, 'error' => 'Alle Dateien müssen im selben Ordner liegen.']; } + $rel = substr($name, strlen($prefix)); + if (!site_path_ok($rel, $extensions)) { $zip->close(); return ['ok' => false, 'error' => 'Unzulässige Datei im Paket: ' . $rel]; } + $total += (int) ($zip->statIndex($i)['size'] ?? 0); + if (++$count > 200 || $total > SITE_MAX_PACKAGE_BYTES * 4) { $zip->close(); return ['ok' => false, 'error' => 'Das Paket enthält zu viele oder zu große Dateien.']; } + } + $main = (string) $zip->getFromName($prefix . $marker); + $zip->close(); + + if ($type === 'plugin') { + $data = json_decode($main, true); + if (!is_array($data)) return ['ok' => false, 'error' => 'plugin.json ist kein gültiges JSON.']; + $slug = (string) ($data['slug'] ?? ''); + $name = trim((string) ($data['name'] ?? '')); + $version = (string) ($data['version'] ?? ''); + if (!site_valid_slug($slug)) return ['ok' => false, 'error' => 'plugin.json: "slug" fehlt oder ist ungültig (a–z, 0–9, _ und -, 2–41 Zeichen).']; + if (in_array($slug, ['core', 'system', 'admin', 'staff', 'api'], true)) return ['ok' => false, 'error' => 'Dieser Plugin-Name ist reserviert.']; + if ($name === '') return ['ok' => false, 'error' => 'plugin.json: "name" fehlt.']; + if (!site_valid_version($version)) return ['ok' => false, 'error' => 'plugin.json: ungültige Versionsnummer.']; + $perms = array_values(array_unique(array_map('strval', (array) ($data['permissions'] ?? [])))); + foreach ($perms as $p) if (!isset(site_known_permissions()[$p])) return ['ok' => false, 'error' => "plugin.json: unbekannte Berechtigung \"{$p}\"."]; + sort($perms); + return ['ok' => true, 'meta' => ['slug' => $slug, 'name' => mb_substr($name, 0, 120), 'version' => $version, 'description' => mb_substr((string) ($data['description'] ?? ''), 0, 1000), + 'author' => mb_substr((string) ($data['author'] ?? ''), 0, 120), 'requires' => (string) ($data['requires'] ?? ''), 'permissions' => $perms]]; + } + // Theme: Angaben stehen im Kopf der theme.css (Author:, Version:) + $head = substr($main, 0, 2000); + preg_match('/Version:\s*([0-9][0-9A-Za-z.\-+]*)/i', $head, $v); + preg_match('/Author:\s*([^\r\n*]+)/i', $head, $a); + return ['ok' => true, 'meta' => ['slug' => '', 'name' => '', 'version' => $v[1] ?? '', 'description' => '', 'author' => trim($a[1] ?? ''), 'requires' => '', 'permissions' => []]]; +} + +// ─── Signieren ─────────────────────────────────────────────────────────────── + +/** Exakt die Nachricht, die DeskHub prüft (siehe plugin_signature_message). Themes werden mit "theme-" getrennt. */ +function site_signature_message(string $type, string $slug, string $version, string $sha256): string +{ + return "deskhub-plugin\n" . ($type === 'theme' ? 'theme-' : '') . "{$slug}\n{$version}\n" . strtolower($sha256); +} + +function site_signing_key(): mixed +{ + $file = (string) site_config()['signing_key_file']; + if ($file === '' || !is_file($file)) return null; + return openssl_pkey_get_private((string) file_get_contents($file)) ?: null; +} + +function site_public_key(): string +{ + $key = site_signing_key(); + return $key ? (string) (openssl_pkey_get_details($key)['key'] ?? '') : ''; +} + +function site_sign(string $type, string $slug, string $version, string $sha256): string +{ + $key = site_signing_key(); + if (!$key) return ''; + openssl_sign(site_signature_message($type, $slug, $version, $sha256), $sig, $key, OPENSSL_ALGO_SHA256); + return base64_encode($sig); +} + +// ─── Veröffentlichen ───────────────────────────────────────────────────────── + +/** + * Veröffentlicht eine Version. Für Themes sind $form['slug'], ['name'], ['version'] Pflicht (aus dem Formular). + * + * @return array{ok:bool, error?:string, slug?:string, version?:string, signed?:bool} + */ +function site_publish(string $type, string $zipPath, array $form = [], ?string $previewPath = null, ?string $previewName = null): array +{ + if (!in_array($type, ['plugin', 'theme'], true)) return ['ok' => false, 'error' => 'Unbekannter Typ.']; + $res = site_inspect_package($type, $zipPath); + if (!$res['ok']) return $res; + $meta = $res['meta']; + if ($type === 'theme') { + $meta['slug'] = trim((string) ($form['slug'] ?? '')); + $meta['name'] = trim((string) ($form['name'] ?? '')); + $meta['version'] = trim((string) ($form['version'] ?? '')) ?: $meta['version']; + $meta['description'] = mb_substr(trim((string) ($form['description'] ?? '')), 0, 1000); + if (!empty($form['author'])) $meta['author'] = mb_substr(trim((string) $form['author']), 0, 120); + if (!site_valid_slug($meta['slug'])) return ['ok' => false, 'error' => 'Bitte eine Kennung (Slug) angeben: a–z, 0–9, _ und -, 2–41 Zeichen.']; + if ($meta['name'] === '') return ['ok' => false, 'error' => 'Bitte einen Namen angeben.']; + if (!site_valid_version($meta['version'])) return ['ok' => false, 'error' => 'Bitte eine Versionsnummer wie 1.0.0 angeben (oder im Kopf der theme.css mit "Version:" eintragen).']; + } + $pdo = site_db(); + $sha = hash_file('sha256', $zipPath); + $now = date('Y-m-d H:i:s'); + + $item = $pdo->prepare('SELECT * FROM items WHERE type = ? AND slug = ?'); + $item->execute([$type, $meta['slug']]); + $item = $item->fetch(); + if ($item) { + $dup = $pdo->prepare('SELECT COUNT(*) FROM versions WHERE item_id = ? AND version = ?'); + $dup->execute([$item['id'], $meta['version']]); + if ((int) $dup->fetchColumn() > 0) return ['ok' => false, 'error' => "Version {$meta['version']} existiert bereits. Bitte eine neue Versionsnummer verwenden, veröffentlichte Versionen bleiben unverändert."]; + } + + $preview = null; + if ($previewPath !== null && is_file($previewPath)) { + $ext = strtolower(pathinfo((string) $previewName, PATHINFO_EXTENSION)); + $info = @getimagesize($previewPath); + if (!in_array($ext, ['png', 'jpg', 'jpeg', 'webp'], true) || !$info || filesize($previewPath) > SITE_MAX_PREVIEW_BYTES) { + return ['ok' => false, 'error' => 'Das Vorschaubild muss PNG, JPG oder WebP sein und darf höchstens 1 MB groß sein.']; + } + $preview = $type . '-' . $meta['slug'] . '-' . substr($sha, 0, 8) . '.' . ($ext === 'jpeg' ? 'jpg' : $ext); + } + + $file = $type . '/' . $meta['slug'] . '-' . $meta['version'] . '.zip'; + $dest = site_storage() . '/packages/' . $file; + if (!is_dir(dirname($dest))) mkdir(dirname($dest), 0755, true); + if (!(is_uploaded_file($zipPath) ? move_uploaded_file($zipPath, $dest) : copy($zipPath, $dest))) return ['ok' => false, 'error' => 'Das Paket konnte nicht gespeichert werden.']; + if ($preview !== null) (is_uploaded_file($previewPath) ? move_uploaded_file($previewPath, site_storage() . '/previews/' . $preview) : copy($previewPath, site_storage() . '/previews/' . $preview)); + + $signature = site_sign($type, $meta['slug'], $meta['version'], $sha); + $pdo->beginTransaction(); + try { + if ($item) { + $pdo->prepare('UPDATE items SET name = ?, description = ?, author = ?, preview = COALESCE(?, preview), updated_at = ? WHERE id = ?') + ->execute([$meta['name'], $meta['description'], $meta['author'], $preview, $now, $item['id']]); + $itemId = (int) $item['id']; + } else { + $pdo->prepare('INSERT INTO items (type, slug, name, description, author, preview, created_at, updated_at) VALUES (?,?,?,?,?,?,?,?)') + ->execute([$type, $meta['slug'], $meta['name'], $meta['description'], $meta['author'], $preview, $now, $now]); + $itemId = (int) $pdo->lastInsertId(); + } + $pdo->prepare('INSERT INTO versions (item_id, version, file, sha256, signature, size, requires, permissions, created_at) VALUES (?,?,?,?,?,?,?,?,?)') + ->execute([$itemId, $meta['version'], $file, $sha, $signature, (int) filesize($dest), $meta['requires'], implode(',', $meta['permissions']), $now]); + $pdo->commit(); + } catch (Throwable $e) { + $pdo->rollBack(); + @unlink($dest); + return ['ok' => false, 'error' => 'Speichern fehlgeschlagen: ' . $e->getMessage()]; + } + return ['ok' => true, 'slug' => $meta['slug'], 'version' => $meta['version'], 'signed' => $signature !== '', 'sha256' => $sha]; +} + +function site_delete_version(int $versionId): bool +{ + $pdo = site_db(); + $v = $pdo->prepare('SELECT * FROM versions WHERE id = ?'); + $v->execute([$versionId]); + $v = $v->fetch(); + if (!$v) return false; + @unlink(site_storage() . '/packages/' . $v['file']); + $pdo->prepare('DELETE FROM versions WHERE id = ?')->execute([$versionId]); + $left = $pdo->prepare('SELECT COUNT(*) FROM versions WHERE item_id = ?'); + $left->execute([$v['item_id']]); + if ((int) $left->fetchColumn() === 0) { + $item = $pdo->prepare('SELECT preview FROM items WHERE id = ?'); + $item->execute([$v['item_id']]); + if ($p = $item->fetchColumn()) @unlink(site_storage() . '/previews/' . $p); + $pdo->prepare('DELETE FROM items WHERE id = ?')->execute([$v['item_id']]); + } + return true; +} + +// ─── Abfragen ──────────────────────────────────────────────────────────────── + +/** Alle Einträge eines Typs mit ihrer neuesten Version. */ +function site_items(string $type, string $search = ''): array +{ + $pdo = site_db(); + $stmt = $pdo->prepare('SELECT * FROM items WHERE type = ? ORDER BY name COLLATE NOCASE'); + $stmt->execute([$type]); + $out = []; + foreach ($stmt->fetchAll() as $item) { + if ($search !== '' && stripos($item['name'] . ' ' . $item['description'] . ' ' . $item['author'] . ' ' . $item['slug'], $search) === false) continue; + $versions = site_versions((int) $item['id']); + if (!$versions) continue; + $item['latest'] = $versions[0]; + $item['downloads'] = array_sum(array_column($versions, 'downloads')); + $out[] = $item; + } + return $out; +} + +/** Versionen eines Eintrags, neueste zuerst. */ +function site_versions(int $itemId): array +{ + $stmt = site_db()->prepare('SELECT * FROM versions WHERE item_id = ?'); + $stmt->execute([$itemId]); + $rows = $stmt->fetchAll(); + usort($rows, fn($a, $b) => version_compare($b['version'], $a['version'])); + return $rows; +} + +function site_item(string $type, string $slug): ?array +{ + $stmt = site_db()->prepare('SELECT * FROM items WHERE type = ? AND slug = ?'); + $stmt->execute([$type, $slug]); + $item = $stmt->fetch(); + if (!$item) return null; + $item['versions'] = site_versions((int) $item['id']); + return $item['versions'] ? $item : null; +} + +/** Katalog im Format, das DeskHub liest (neueste Version je Eintrag). */ +function site_catalog(string $type): array +{ + $base = site_base_url(); + $list = []; + foreach (site_items($type) as $item) { + $v = $item['latest']; + $entry = [ + 'slug' => $item['slug'], 'name' => $item['name'], 'version' => $v['version'], 'description' => $item['description'], 'author' => $item['author'], + 'downloadUrl' => $base . '/download.php?type=' . $type . '&slug=' . rawurlencode($item['slug']) . '&version=' . rawurlencode($v['version']), + 'sha256' => $v['sha256'], 'signature' => $v['signature'], + ]; + if ($item['preview']) $entry['previewUrl'] = $base . '/preview.php?file=' . rawurlencode($item['preview']); + if ($type === 'plugin') { + $entry['requires'] = $v['requires']; + $entry['permissions'] = array_values(array_filter(explode(',', (string) $v['permissions']))); + } + $list[] = $entry; + } + return [$type === 'plugin' ? 'plugins' : 'themes' => $list]; +} + +/** Inhalt der Datei includes/catalog_defaults.php für DeskHub-Releases (offizieller Katalog + öffentlicher Schlüssel). */ +function site_deskhub_defaults_snippet(): string +{ + $base = site_base_url(); + return " " . var_export($base . '/catalog.php?type=plugins', true) . ", + 'themes_url' => " . var_export($base . '/catalog.php?type=themes', true) + . ", + 'publisher_key' => " . var_export(site_public_key(), true) . ", + 'access_key' => " . var_export((string) site_config()['access_key'], true) . ", +]; +"; +} diff --git a/plugin-webseite/index.php b/plugin-webseite/index.php new file mode 100644 index 00000000..ad3d9614 --- /dev/null +++ b/plugin-webseite/index.php @@ -0,0 +1,5 @@ + 'image/png', 'jpg' => 'image/jpeg', 'webp' => 'image/webp']; +if ($file === '' || !isset($types[$ext]) || !is_file($path)) { http_response_code(404); exit; } +header('Content-Type: ' . $types[$ext]); +header('Content-Length: ' . filesize($path)); +header('Cache-Control: public, max-age=86400'); +header('X-Content-Type-Options: nosniff'); +readfile($path); diff --git a/plugin-webseite/publisher-key.php b/plugin-webseite/publisher-key.php new file mode 100644 index 00000000..7a47472a --- /dev/null +++ b/plugin-webseite/publisher-key.php @@ -0,0 +1,11 @@ +