From a62971926b307992c076ab7c9c9c3353db0b449b Mon Sep 17 00:00:00 2001 From: M_Viper Date: Sun, 25 Oct 2026 17:58:33 +0000 Subject: [PATCH] Upload via GUI (32 Dateien) --- plugin-webseite/Caddyfile | 2 +- plugin-webseite/Dockerfile | 4 +- plugin-webseite/README.md | 220 ++++----- plugin-webseite/admin.php | 467 +++++++++++++------- plugin-webseite/app-release.php | 40 ++ plugin-webseite/assets/apple-touch-icon.png | Bin 0 -> 4907 bytes plugin-webseite/assets/favicon-32.png | Bin 0 -> 992 bytes plugin-webseite/assets/favicon.svg | 6 + plugin-webseite/assets/style.css | 183 +++++--- plugin-webseite/favicon.ico | Bin 0 -> 1581 bytes plugin-webseite/inc/layout.php | 94 ++-- plugin-webseite/inc/lib.php | 49 ++ 12 files changed, 701 insertions(+), 364 deletions(-) create mode 100644 plugin-webseite/app-release.php create mode 100644 plugin-webseite/assets/apple-touch-icon.png create mode 100644 plugin-webseite/assets/favicon-32.png create mode 100644 plugin-webseite/assets/favicon.svg create mode 100644 plugin-webseite/favicon.ico diff --git a/plugin-webseite/Caddyfile b/plugin-webseite/Caddyfile index 2a203d8f..cb0d71b3 100644 --- a/plugin-webseite/Caddyfile +++ b/plugin-webseite/Caddyfile @@ -1,7 +1,7 @@ {$PLUGINS_DOMAIN} { encode gzip request_body { - max_size 14MB + max_size 320MB } header { Strict-Transport-Security "max-age=31536000" diff --git a/plugin-webseite/Dockerfile b/plugin-webseite/Dockerfile index d9472526..309d7b62 100644 --- a/plugin-webseite/Dockerfile +++ b/plugin-webseite/Dockerfile @@ -12,8 +12,8 @@ RUN apt-get update \ && 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 \ +# PHP: Uploads bis 310 MB (Pakete sind auf 10 MB begrenzt, App-Dateien auf 300 MB), keine Fehlerausgabe im Browser, Apache verrät keine Versionen +RUN { echo 'upload_max_filesize=310M'; echo 'post_max_size=315M'; echo 'max_execution_time=600'; echo 'max_input_time=600'; echo 'memory_limit=256M'; 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 diff --git a/plugin-webseite/README.md b/plugin-webseite/README.md index ea886682..723274f0 100644 --- a/plugin-webseite/README.md +++ b/plugin-webseite/README.md @@ -1,109 +1,111 @@ -# 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`. - -**Sprachpaket (Mehrsprachigkeit):** ein normales Plugin ohne Berechtigungen, dessen `plugin.json` zusätzlich `"language": {"code": "en", "name": "English", "flag": "flag.svg", "strings": "strings.json"}` enthält. Die Webseite prüft den Sprachcode (`en`, `pt-br` …), merkt sich Code und Name und nennt sie im Katalog (`language`); DeskHub zeigt dann „🌐 Sprache: English“ und blendet nach der Aktivierung die Flagge zum Umschalten ein. Flagge und Texte liegen im Paket. Fertige Pakete: `Plugins/plugins/lang-en-1.0.0.zip` und `lang-de-1.0.0.zip`, passende Vorschaubilder in `theme/vorschaubilder/plugin-lang-en.png` und `plugin-lang-de.png`. Nach einem Update dieser Webseite legt sie die neue Spalte beim ersten Aufruf selbst an. - -**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). - -## Push-Dienst für die Android-App (push.php) - -Die Webseite ist auch der **zentrale Push-Dienst** der Android-App aus dem Store. Der Firebase-Schlüssel (Dienstkonto) liegt nur hier; die App und die DeskHub-Server der Kunden enthalten ihn nie. - -1. Firebase-Konsole → Projekteinstellungen → Dienstkonten → „Neuen privaten Schlüssel generieren“ → die Datei als `schluessel/firebase-dienstkonto.json` ablegen (neben dem Signierschlüssel; der Ordner `schluessel/` ist von Git und vom Docker-Build ausgeschlossen). In der Firebase-Konsole muss die **Firebase Cloud Messaging API (V1)** aktiv sein. -2. Image neu bauen (`docker-build.ps1` bzw. `docker-build.sh`): Das Skript erkennt die Datei und baut sie als BuildKit-Secret fest ein (schreibgeschützt, nicht im Image-Verlauf). Ohne die Datei läuft die Webseite wie bisher, `push.php` antwortet dann mit 503. -3. DeskHub leitet die Adresse selbst aus der Katalog-Adresse ab (`https:///push.php`). Jedes DeskHub meldet sich beim ersten Senden mit dem Zugangsschlüssel des Katalogs an (`access_key` in `includes/catalog_defaults.php`) und bekommt eine eigene Installations-ID samt Geheimnis (nur als Hash gespeichert). - -Was der Dienst tut und nicht tut: Er nimmt nur Geräte-Token und Ticketnummer an (Betreff/Kundenname nur, wenn der Kunde „Details anzeigen“ einschaltet), baut den Text selbst, begrenzt auf 2000 Nachrichten je Installation und Stunde und 20 Anmeldungen je Adresse und Stunde und meldet abgemeldete Geräte zurück. Eine Installation sperren: in `storage/katalog.sqlite` (Volume `plugin_daten`) in Tabelle `push_installs` `blocked = 1` setzen. Datenschutz: Der Dienst und Google verarbeiten Geräte-Token und Ticketnummern; das gehört in eure Datenschutzerklärung für die App. -### Sicherung - -`./backup.sh /home/docker/backups 14` sichert das Volume `plugin_daten` (Pakete, Katalog- und Push-Datenbank) als `.tar.gz` und löscht Sicherungen, die älter als 14 Tage sind. Als Cronjob z. B. täglich um 03:15. Die Schlüssel im Ordner `schluessel/` sind nicht enthalten und müssen getrennt gesichert werden. Die Wiederherstellung steht im Kopf des Skripts. - -## 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 +# 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`. + +**Sprachpaket (Mehrsprachigkeit):** ein normales Plugin ohne Berechtigungen, dessen `plugin.json` zusätzlich `"language": {"code": "en", "name": "English", "flag": "flag.svg", "strings": "strings.json"}` enthält. Die Webseite prüft den Sprachcode (`en`, `pt-br` …), merkt sich Code und Name und nennt sie im Katalog (`language`); DeskHub zeigt dann „🌐 Sprache: English“ und blendet nach der Aktivierung die Flagge zum Umschalten ein. Flagge und Texte liegen im Paket. Fertige Pakete: `Plugins/plugins/lang-en-1.0.0.zip` und `lang-de-1.0.0.zip`, passende Vorschaubilder in `theme/vorschaubilder/plugin-lang-en.png` und `plugin-lang-de.png`. Nach einem Update dieser Webseite legt sie die neue Spalte beim ersten Aufruf selbst an. + +**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: + +Im Bereich `admin.php` gibt es außerdem „DeskHub-Apps veröffentlichen“ für Android-APK- und Windows-EXE-Releases. Tragt pro Upload eine neue Versionsnummer ein; DeskHub bezieht anschließend die aktuelle Version und die Datei über den geschützten App-Endpunkt. Für Windows-Setups muss PHP Uploads bis mindestens 300 MB erlauben (im Docker-Image und im Caddy schon eingestellt; bei einem eigenen Proxy dort ebenfalls, z. B. nginx `client_max_body_size 320m`) (`upload_max_filesize` und `post_max_size`); die Dateien landen wie andere Pakete im geschützten `storage`-Verzeichnis. + +- **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). + +## Push-Dienst für die Android-App (push.php) + +Die Webseite ist auch der **zentrale Push-Dienst** der Android-App aus dem Store. Der Firebase-Schlüssel (Dienstkonto) liegt nur hier; die App und die DeskHub-Server der Kunden enthalten ihn nie. + +1. Firebase-Konsole → Projekteinstellungen → Dienstkonten → „Neuen privaten Schlüssel generieren“ → die Datei als `schluessel/firebase-dienstkonto.json` ablegen (neben dem Signierschlüssel; der Ordner `schluessel/` ist von Git und vom Docker-Build ausgeschlossen). In der Firebase-Konsole muss die **Firebase Cloud Messaging API (V1)** aktiv sein. +2. Image neu bauen (`docker-build.ps1` bzw. `docker-build.sh`): Das Skript erkennt die Datei und baut sie als BuildKit-Secret fest ein (schreibgeschützt, nicht im Image-Verlauf). Ohne die Datei läuft die Webseite wie bisher, `push.php` antwortet dann mit 503. +3. DeskHub leitet die Adresse selbst aus der Katalog-Adresse ab (`https:///push.php`). Jedes DeskHub meldet sich beim ersten Senden mit dem Zugangsschlüssel des Katalogs an (`access_key` in `includes/catalog_defaults.php`) und bekommt eine eigene Installations-ID samt Geheimnis (nur als Hash gespeichert). + +Was der Dienst tut und nicht tut: Er nimmt nur Geräte-Token und Ticketnummer an (Betreff/Kundenname nur, wenn der Kunde „Details anzeigen“ einschaltet), baut den Text selbst, begrenzt auf 2000 Nachrichten je Installation und Stunde und 20 Anmeldungen je Adresse und Stunde und meldet abgemeldete Geräte zurück. Eine Installation sperren: in `storage/katalog.sqlite` (Volume `plugin_daten`) in Tabelle `push_installs` `blocked = 1` setzen. Datenschutz: Der Dienst und Google verarbeiten Geräte-Token und Ticketnummern; das gehört in eure Datenschutzerklärung für die App. +### Sicherung + +`./backup.sh /home/docker/backups 14` sichert das Volume `plugin_daten` (Pakete, Katalog- und Push-Datenbank) als `.tar.gz` und löscht Sicherungen, die älter als 14 Tage sind. Als Cronjob z. B. täglich um 03:15. Die Schlüssel im Ordner `schluessel/` sind nicht enthalten und müssen getrennt gesichert werden. Die Wiederherstellung steht im Kopf des Skripts. + +## 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 index 2f6bff3f..28e614d0 100644 --- a/plugin-webseite/admin.php +++ b/plugin-webseite/admin.php @@ -1,166 +1,301 @@ - 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 (in_array($action, ['push_block', 'push_unblock', 'push_delete'], true)) { - $pid = (string) ($_POST['id'] ?? ''); - if (preg_match('/^[a-f0-9]{24}$/', $pid)) { - $pp = push_db(); - if ($action === 'push_delete') $pp->prepare('DELETE FROM push_installs WHERE id = ?')->execute([$pid]); - else $pp->prepare('UPDATE push_installs SET blocked = ? WHERE id = ?')->execute([$action === 'push_block' ? 1 : 0, $pid]); - $message = ['push_block' => 'Installation gesperrt.', 'push_unblock' => 'Installation entsperrt.', 'push_delete' => 'Installation gelöscht (sie meldet sich beim nächsten Senden von selbst neu an).'][$action]; - } else { - $error = 'Ungültige Installation.'; - } - } elseif ($action === 'feature' || $action === 'unfeature') { - $message = site_set_featured((int) ($_POST['id'] ?? 0), $action === 'feature') ? ($action === 'feature' ? 'Als „Empfohlen“ markiert. DeskHub zeigt es nach der nächsten Katalog-Aktualisierung (spätestens in wenigen Minuten).' : 'Empfehlung entfernt.') : null; - $error = $message ? null : 'Eintrag nicht gefunden.'; - } 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) - - - - - -
- - -
- -

Push-Dienst (Android-App)

- query('SELECT * FROM push_installs ORDER BY COALESCE(last_used_at, created_at) DESC LIMIT 200')->fetchAll(); ?> -

-

Noch keine DeskHub-Installation angemeldet.

- - - - - - - - -
Angemeldete DeskHub-Installationen ()
gesendetgesperrt' : 'aktiv' ?>
- - -

Veröffentlichte Pakete

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

-

Noch keine.

- - - - - - - - -
★ Empfohlen' : '' ?> 🌐 Sprachpaket: () -
Downloadssigniert' : 'nicht signiert' ?>
- - - - - 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']); + +/** PHP verwirft bei zu großem Upload das ganze Formular (kein $_POST, kein Token) – das ist keine Abmeldung. */ +$tooBig = $_SERVER['REQUEST_METHOD'] === 'POST' && !$_POST && !$_FILES && (int) ($_SERVER['CONTENT_LENGTH'] ?? 0) > 0; +if ($tooBig) { + $error = 'Die Datei ist größer als die Servergrenze (post_max_size ' . ini_get('post_max_size') . ', upload_max_filesize ' . ini_get('upload_max_filesize') . '). Bitte die Grenzen erhöhen bzw. das Docker-Image neu bauen. Du bleibst angemeldet.'; +} + +if ($_SERVER['REQUEST_METHOD'] === 'POST' && !$tooBig) { + $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 === 'publish_app') { + $upload = $_FILES['app_file'] ?? []; + if (($upload['error'] ?? UPLOAD_ERR_NO_FILE) !== UPLOAD_ERR_OK) { + $error = 'App-Upload fehlgeschlagen (Code ' . ($upload['error'] ?? '?') . '). Prüft upload_max_filesize, post_max_size und die Servergrenze.'; + } else { + $res = site_publish_app((string) ($_POST['platform'] ?? ''), (string) $upload['tmp_name'], trim((string) ($_POST['version'] ?? '')), (string) ($_POST['notes'] ?? '')); + if ($res['ok']) $message = 'App-Version veröffentlicht.'; + else $error = $res['error']; + } + } elseif ($action === 'delete_app') { + $id = (int) ($_POST['id'] ?? 0); + $stmt = site_db()->prepare('SELECT platform,file FROM app_releases WHERE id = ?'); + $stmt->execute([$id]); + $release = $stmt->fetch(); + if ($release) { + site_db()->prepare('DELETE FROM app_releases WHERE id = ?')->execute([$id]); + @unlink(site_storage() . '/apps/' . $release['platform'] . '/' . basename($release['file'])); + $message = 'App-Version entfernt.'; + } else $error = 'App-Version wurde nicht gefunden.'; + } elseif (in_array($action, ['push_block', 'push_unblock', 'push_delete'], true)) { + $pid = (string) ($_POST['id'] ?? ''); + if (preg_match('/^[a-f0-9]{24}$/', $pid)) { + $pp = push_db(); + if ($action === 'push_delete') $pp->prepare('DELETE FROM push_installs WHERE id = ?')->execute([$pid]); + else $pp->prepare('UPDATE push_installs SET blocked = ? WHERE id = ?')->execute([$action === 'push_block' ? 1 : 0, $pid]); + $message = ['push_block' => 'Installation gesperrt.', 'push_unblock' => 'Installation entsperrt.', 'push_delete' => 'Installation gelöscht (sie meldet sich beim nächsten Senden von selbst neu an).'][$action]; + } else { + $error = 'Ungültige Installation.'; + } + } elseif ($action === 'feature' || $action === 'unfeature') { + $message = site_set_featured((int) ($_POST['id'] ?? 0), $action === 'feature') ? ($action === 'feature' ? 'Als „Empfohlen“ markiert. DeskHub zeigt es nach der nächsten Katalog-Aktualisierung (spätestens in wenigen Minuten).' : 'Empfehlung entfernt.') : null; + $error = $message ? null : 'Eintrag nicht gefunden.'; + } elseif ($action === 'delete') { + $message = site_delete_version((int) ($_POST['id'] ?? 0)) ? 'Version entfernt.' : null; + $error = $message ? null : 'Version nicht gefunden.'; + } +} + +$tabs = site_tabs(); +$tab = (string) ($_GET['tab'] ?? 'overview'); +if (!isset($tabs[$tab])) $tab = 'overview'; +$csrf = $loggedIn ? admin_csrf() : ''; +/** Kleines POST-Formular mit nur einem Knopf. */ +function admin_btn(string $action, array $fields, string $label, string $class = 'link', string $confirm = ''): string +{ + $h = '
'; + foreach ($fields as $k => $v) $h .= ''; + return $h . '
'; +} + +site_header($loggedIn ? $tabs[$tab][0] . ' · Verwaltung' : 'Anmelden', $tab, $loggedIn); +?> +
+
+ + +
+

Anmelden

Verwaltung der DeskHub-Plugins, Themes und Apps.

+ + + + +
+ null, 'windows' => null, 'minecraft' => null]; + foreach ($appReleases as $r) if (array_key_exists($r['platform'], $latestApp) && $latestApp[$r['platform']] === null) $latestApp[$r['platform']] = $r; + $pushSa = push_service_account(); + $pushRows = push_db()->query('SELECT * FROM push_installs ORDER BY COALESCE(last_used_at, created_at) DESC LIMIT 200')->fetchAll(); + $plugins = site_items('plugin'); + $themes = site_items('theme'); +?> + + +

Übersicht

Alles Wichtige auf einen Blick: veröffentlichte Pakete, aktuelle App-Versionen und der Zustand der Dienste.

+ +
+
+

Systemstatus

Einrichtung dieser Webseite

+
Signierschlüssel
+
Push-Dienst
+ +
Zugangsschlüssel für Katalog und Downloads
+
+
+

Schnellzugriff

Häufige Aufgaben

+

+ Plugin oder Theme hochladen + App-Version veröffentlichen + DeskHub-Einstellungen +

+
+
+ + +

Pakete

Plugins und Themes für den DeskHub-Katalog hochladen und verwalten.

+
+

Neues Paket veröffentlichen

ZIP-Datei mit plugin.json bzw. Theme-CSS

+
+ +
+ + +
+
Nur für Themes (bei Plugins steht alles in der plugin.json) +
+ + +
+
+ + +
+ +
+ +
+
+
+ +
+ ['Plugins', $plugins], 'theme' => ['Themes', $themes]] as $t => [$label, $items]): ?> +
+

veröffentlicht

+
Noch keine veröffentlicht.
+ +
+
+
+ ★ Empfohlen + 🌐 () + (int) $it['id']], !empty($it['featured']) ? 'Empfehlung entfernen' : 'Als Empfehlung markieren', 'ghost sm') ?> +
+
+ + + + + +
Downloadssigniert' : 'nicht signiert' ?> (int) $v['id']], 'Entfernen', 'link danger', 'Version wirklich entfernen?') ?>
+
+ +
+ +
+ + +

Apps

Windows-, Android-App und Minecraft-Plugin veröffentlichen. Die Apps zeigen ihren Nutzern automatisch „Update verfügbar“, sobald hier eine höhere Version liegt.

+
+

Neue App-Version hochladen

Windows-Setups dürfen bis 300 MB groß sein (PHP-Grenzen upload_max_filesize / post_max_size beachten).

+
+ +
+ + +
+ + +
+
+
+
+ 'Windows', 'android' => 'Android', 'minecraft' => 'Minecraft'] as $pf => $pl): $mine = array_values(array_filter($appReleases, fn($r) => $r['platform'] === $pf)); $cur = $latestApp[$pf]; ?> +
+

'Setup (EXE)', 'android' => 'Installationsdatei (APK)', 'minecraft' => 'Plugin (JAR)'][$pf] ?>

+ aktuell ' . site_h($cur['version']) . '' : 'noch nichts veröffentlicht' ?>
+
Noch keine Version veröffentlicht.
+
+ + + + + + +
VersionDatumGröße
aktuell' : '' ?> MB (int) $release['id']], 'Entfernen', 'link danger', 'Diese App-Version wirklich entfernen?') ?>
+ +
+ +
+ + + +

Push-Dienst

Zentraler Push-Dienst der Android-App. Hier siehst du die angemeldeten DeskHub-Installationen.

+
+
+

Angemeldete Installationen

Einträge

+
Noch keine DeskHub-Installation angemeldet.
+
+ + + + + + + +
NameAngemeldetZuletzt genutztGesendetStatus
gesperrt' : 'aktiv' ?> $p['id']], $blocked ? 'Entsperren' : 'Sperren') ?>   $p['id']], 'Löschen', 'link danger', 'Installation wirklich löschen?') ?>
+ +
+ + +

Einstellungen

Einrichtung für DeskHub und Konto.

+
Signierschlüsselphp einrichten.php) – ohne Schlüssel ist der geprüfte Modus nicht nutzbar.' ?>
+
+

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.

+
+
+
+

Konto

Du bist als Administrator angemeldet.

+ +
+ + + + 'Kein App-Release vorhanden.'])); +} +$path = site_storage() . '/apps/' . $platform . '/' . basename((string) $release['file']); +if (!is_file($path)) { + http_response_code(404); + exit; +} +if (($_GET['download'] ?? '') !== '1') { + header('Content-Type: application/json; charset=utf-8'); + header('Cache-Control: no-store'); + echo json_encode([ + 'platform' => $platform, + 'version' => $release['version'], + 'sha256' => $release['sha256'], + 'size' => (int) $release['size'], + 'notes' => $release['notes'], + 'created_at' => $release['created_at'], + ], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); + exit; +} + +$filename = ['android' => 'DeskHub.apk', 'windows' => 'DeskHub-Setup.exe', 'minecraft' => 'DeskHub-Minecraft.jar'][$platform]; +while (ob_get_level() > 0) @ob_end_clean(); +header('Content-Type: ' . ['android' => 'application/vnd.android.package-archive', 'windows' => 'application/vnd.microsoft.portable-executable', 'minecraft' => 'application/java-archive'][$platform]); +header('Content-Disposition: attachment; filename="' . $filename . '"'); +header('Content-Length: ' . filesize($path)); +header('X-Content-Type-Options: nosniff'); +header('X-DeskHub-SHA256: ' . $release['sha256']); +header('X-DeskHub-Version: ' . $release['version']); +readfile($path); diff --git a/plugin-webseite/assets/apple-touch-icon.png b/plugin-webseite/assets/apple-touch-icon.png new file mode 100644 index 0000000000000000000000000000000000000000..9cd04babdb45dc3483425430646e87a194b8bfd9 GIT binary patch literal 4907 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 O0(RB}tH(#(Z~q@>b~gV2 literal 0 HcmV?d00001 diff --git a/plugin-webseite/assets/favicon-32.png b/plugin-webseite/assets/favicon-32.png new file mode 100644 index 0000000000000000000000000000000000000000..0f2c617135b861e18b4d99494f427063048a1cfe GIT binary patch literal 992 zcmV<610Vc}P)Px#1ZP1_K>z@;j|==^1poj532;bRa{vGi!vFvd!vV){sAK>D19eG6K~z{r#g|P? z6G0Tmd-P;?b|4`!Q4>rH8l$*m7G+Z1Wr{UzVj|R`-QJzCydnAP3NDR`ja;(Q%q8+g6q`y|yrJ!&w zXKuuZXS`uNGYkX{czOs39PpF}@T42?!~o!@F2LjcfXAGGeSLtR@W75Zfc4se9ku~0 zkQnL(cCZHs99VZZu&yp(`#XVkTE=r7mOKd=Q|`Q=%X#MAAOV?!cL3XG0mPfOvE5iC zp{1G6dbhFxXL&QQ4im6;<6d+n$g(@L3 z-+bi<{vZUdKi}cbo4Euy>V6Cwven8m0lz?=9l>eP-xtaIcsS}J1_+!>2d<>h!7q6J z?F*RSufpxQ$>_3Q8R?6{kK(G}I0i=@t;9g2$*9geUA^1+0oTVoV$Scazm3B6n`;Sh zWNDEMq_ONL(c!*nVN|KvA3%i zoLol*r}GU!@yh`CKW|8HVp{*X19^6nA{-f;r~z`QsK9Bp%av@O7ccmww=tD3FDSxM zdm}L*o@%M#bgRjw)MT6=)}#>;`96JAgd;-(H9(G26*%4Jaw&DV8$ORTBBJ%(ZACb0 zt0xA;^E@@2HW|56wO6^6;7n^BH9#&CDsWnGaw)+JJ;!%UXn#);&a~7L1LP*72B!rl zS28%Q_PXGrG$Nw;%?(93!_|O>UZK?BwBY1Q1}}8mVgE2JjfkivxTFYYnyV!PDsWnG za>c_h^BqvWw2%Pb{e1xWy=Fx?(^N$akjtYQoEF?^bbo$2C7fxjlnkiAX~CUT%U?HD z;Y>pXF+i@)YH(U`>1sb5PHW~A&eV$r#Cv;6I4!tTqPbrQZGmYesG9S5I8&=92Hf2> zYH)eAr|Rt|fipGbqz|~QMP~(Psvm(yD_EJcf-_ZRAbuARaPTbP=PMp=i3YH(vj92F z;cUXSm8GE7m&QKNkll0_*^J?=!7Cn=N$8lc%}_u-msT?j-;T{(qxlahFq8a7*)-Vz O0000 + + + + + diff --git a/plugin-webseite/assets/style.css b/plugin-webseite/assets/style.css index ebdea8a5..6bc833f8 100644 --- a/plugin-webseite/assets/style.css +++ b/plugin-webseite/assets/style.css @@ -1,53 +1,134 @@ -:root { --bg: #f4f6f9; --card: #fff; --ink: #1e293b; --muted: #64748b; --line: #e2e8f0; --brand: #0f6f6a; --navy: #14202e; --ok: #15803d; --warn: #b45309; --err: #dc2626; } -@media (prefers-color-scheme: dark) { :root { --bg: #0f1722; --card: #17222f; --ink: #e5eaf0; --muted: #94a3b8; --line: #263445; --brand: #2dd4bf; --navy: #0b121b; } } +:root { + --bg: #f3f6f8; --card: #ffffff; --card-2: #f8fafc; --ink: #162033; --muted: #5d6b82; --line: #e2e8f0; + --brand: #0f766e; --brand-2: #14b8a6; --brand-ink: #ffffff; --brand-soft: #e0f5f2; + --navy: #0f1b2b; --ok: #15803d; --ok-bg: #dcfce7; --warn: #b45309; --warn-bg: #fef3c7; --err: #b91c1c; --err-bg: #fee2e2; + --shadow: 0 1px 2px rgba(15, 27, 43, .06), 0 4px 14px rgba(15, 27, 43, .05); --radius: 14px; +} +@media (prefers-color-scheme: dark) { + :root { + --bg: #0b121b; --card: #131e2c; --card-2: #101a27; --ink: #e6ecf3; --muted: #93a2b8; --line: #243245; + --brand: #2dd4bf; --brand-2: #14b8a6; --brand-ink: #04201d; --brand-soft: #0f2a2b; + --navy: #08101a; --ok: #4ade80; --ok-bg: #12301f; --warn: #fbbf24; --warn-bg: #33270d; --err: #f87171; --err-bg: #3a1618; + --shadow: 0 1px 2px rgba(0, 0, 0, .35), 0 4px 14px rgba(0, 0, 0, .25); + } +} * { box-sizing: border-box; } -body { margin: 0; background: var(--bg); color: var(--ink); font: 16px/1.5 -apple-system, "Segoe UI", Roboto, Helvetica, Arial, sans-serif; } -.wrap { max-width: 1040px; margin: 0 auto; padding: 0 16px; } -.top { background: var(--navy); color: #fff; } -.top .wrap { display: flex; align-items: center; justify-content: space-between; gap: 16px; flex-wrap: wrap; min-height: 56px; } -.brand { color: #fff; font-weight: 700; text-decoration: none; } -nav a { color: #cbd5e1; text-decoration: none; margin-left: 18px; padding: 4px 0; border-bottom: 2px solid transparent; } -nav a.on, nav a:hover { color: #fff; border-color: var(--brand); } -main { padding-top: 24px; padding-bottom: 32px; } -h1 { margin: 0 0 8px; font-size: 1.7rem; } h2 { margin-top: 28px; } h3 { margin-top: 22px; } -.lead { color: var(--muted); max-width: 70ch; } -.muted { color: var(--muted); } .small { font-size: .8rem; } .meta { font-size: .85rem; color: var(--muted); margin: 4px 0; } -.ok { color: var(--ok); font-weight: 600; } .warn { color: var(--warn); font-weight: 600; } .danger { color: var(--err); } -.grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); gap: 16px; margin-top: 18px; } -.card { background: var(--card); border: 1px solid var(--line); border-radius: 12px; padding: 16px; color: inherit; text-decoration: none; display: block; transition: border-color .15s, transform .15s; } -.card:hover { border-color: var(--brand); transform: translateY(-2px); } -.card h2 { margin: 0 0 6px; font-size: 1.1rem; } -.thumb { width: 100%; height: 140px; object-fit: cover; border-radius: 8px; margin-bottom: 10px; background: var(--line); } -.preview { max-width: 100%; border-radius: 10px; border: 1px solid var(--line); margin: 8px 0 12px; } -.search { display: flex; gap: 8px; margin: 14px 0; } -input[type=search], input[type=text], input[type=password], select, textarea { width: 100%; padding: 9px 11px; border: 1px solid var(--line); border-radius: 8px; background: var(--card); color: var(--ink); font: inherit; } -button, .btn { background: var(--brand); color: #fff; border: 0; border-radius: 8px; padding: 9px 16px; font: inherit; font-weight: 600; cursor: pointer; text-decoration: none; display: inline-block; } -@media (prefers-color-scheme: dark) { button, .btn { color: #06201e; } } -button.link { background: none; color: var(--brand); padding: 0; font-weight: 400; } -button.link.danger { color: var(--err); } -table { width: 100%; border-collapse: collapse; background: var(--card); border: 1px solid var(--line); border-radius: 10px; overflow: hidden; } -th, td { text-align: left; padding: 9px 12px; border-bottom: 1px solid var(--line); vertical-align: middle; } -th { font-size: .75rem; text-transform: uppercase; letter-spacing: .05em; color: var(--muted); } -.hash { font: .72rem/1.3 ui-monospace, Consolas, monospace; word-break: break-all; max-width: 260px; } -caption { text-align: left; font-weight: 700; padding: 8px 2px; } -table.admin { margin-bottom: 14px; } -.form { background: var(--card); border: 1px solid var(--line); border-radius: 12px; padding: 18px; display: grid; gap: 12px; max-width: 640px; } -.form.narrow { max-width: 380px; } -.form label { display: grid; gap: 4px; font-size: .88rem; font-weight: 600; } -fieldset { border: 1px dashed var(--line); border-radius: 8px; display: grid; gap: 10px; } -.alert { padding: 10px 14px; border-radius: 8px; margin: 12px 0; } .alert.err { background: #fee2e2; color: #991b1b; } .alert.okbox { background: #dcfce7; color: #166534; } -pre.key, code { background: var(--card); border: 1px solid var(--line); border-radius: 6px; padding: 2px 6px; font-size: .85rem; } -pre.key { padding: 12px; overflow: auto; } -.logout { text-align: right; } -.foot { color: var(--muted); font-size: .8rem; padding-bottom: 30px; } -@media (max-width: 600px) { .hash { max-width: 120px; } th:nth-child(5), td:nth-child(5) { display: none; } } +html { -webkit-text-size-adjust: 100%; } +body { margin: 0; background: var(--bg); color: var(--ink); font: 15.5px/1.55 system-ui, -apple-system, "Segoe UI", Roboto, Helvetica, Arial, sans-serif; } +.wrap { max-width: 1560px; margin: 0 auto; padding: 0 20px; } +a { color: var(--brand); } -/* Layout-Korrekturen: lange Inhalte (Schlüssel, Hashes) dürfen Karten nicht sprengen */ -.form, .form > * { 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; } +/* Kopfzeile mit Tabs */ +.top { background: var(--navy); color: #fff; position: sticky; top: 0; z-index: 10; border-bottom: 1px solid rgba(255, 255, 255, .06); } +.top .wrap { display: flex; align-items: center; gap: 22px; flex-wrap: wrap; min-height: 62px; } +.brand { display: flex; align-items: center; gap: 10px; color: #fff; font-weight: 700; text-decoration: none; letter-spacing: .01em; } +.brand img { border-radius: 8px; display: block; } +.tabs { display: flex; gap: 4px; margin-left: auto; overflow-x: auto; scrollbar-width: none; max-width: 100%; } +.tabs::-webkit-scrollbar { display: none; } +.tabs a { color: #b8c4d4; text-decoration: none; padding: 8px 14px; border-radius: 10px; white-space: nowrap; font-weight: 600; font-size: .92rem; display: inline-flex; align-items: center; gap: 7px; transition: background .15s, color .15s; } +.tabs a .ico { opacity: .75; font-size: .95em; } +.tabs a:hover { color: #fff; background: rgba(255, 255, 255, .08); } +.tabs a.on { color: #fff; background: rgba(45, 212, 191, .18); box-shadow: inset 0 -2px 0 var(--brand-2); } +@media (max-width: 720px) { .top .wrap { gap: 6px; padding-top: 8px; padding-bottom: 8px; } .tabs { margin-left: 0; width: 100%; } } + +main { padding-top: 28px; padding-bottom: 36px; overflow-wrap: break-word; } +.pagehead { display: flex; align-items: flex-end; justify-content: space-between; gap: 16px; flex-wrap: wrap; margin-bottom: 20px; } +h1 { margin: 0; font-size: 1.75rem; line-height: 1.2; letter-spacing: -.01em; } +h2 { margin: 0 0 4px; font-size: 1.12rem; } h3 { margin: 0; font-size: 1rem; } +.sub { color: var(--muted); margin: 4px 0 0; max-width: 70ch; } +.muted { color: var(--muted); } .small { font-size: .82rem; } + +/* Karten und Raster */ +.card { background: var(--card); border: 1px solid var(--line); border-radius: var(--radius); box-shadow: var(--shadow); padding: 20px; margin-bottom: 20px; min-width: 0; } +.card > header { display: flex; align-items: flex-start; justify-content: space-between; gap: 12px; flex-wrap: wrap; margin-bottom: 14px; } +.card > header p { margin: 2px 0 0; color: var(--muted); font-size: .9rem; } +.cols { display: grid; grid-template-columns: repeat(auto-fit, minmax(340px, 1fr)); gap: 20px; align-items: start; } +.cols > .card { margin-bottom: 0; } +@media (max-width: 420px) { .cols { grid-template-columns: 1fr; } } +.stats { display: grid; grid-template-columns: repeat(auto-fit, minmax(165px, 1fr)); gap: 14px; margin-bottom: 20px; } +.stat { background: var(--card); border: 1px solid var(--line); border-radius: var(--radius); padding: 16px 18px; box-shadow: var(--shadow); text-decoration: none; color: inherit; display: block; transition: border-color .15s, transform .15s; } +a.stat:hover { border-color: var(--brand); transform: translateY(-2px); } +.stat .k { color: var(--muted); font-size: .78rem; font-weight: 700; text-transform: uppercase; letter-spacing: .06em; } +.stat .v { font-size: 1.9rem; font-weight: 800; line-height: 1.15; margin-top: 4px; } +.stat .s { color: var(--muted); font-size: .84rem; margin-top: 2px; } + +/* Status-Zeilen und Badges */ +.status { display: flex; align-items: center; gap: 12px; padding: 12px 14px; border-radius: 12px; background: var(--card-2); border: 1px solid var(--line); margin-bottom: 10px; } +.status .dot { width: 10px; height: 10px; border-radius: 50%; flex: none; background: var(--muted); } +.status.ok .dot { background: var(--ok); box-shadow: 0 0 0 4px var(--ok-bg); } .status.warn .dot { background: var(--warn); box-shadow: 0 0 0 4px var(--warn-bg); } +.status.ok, .status.warn { color: var(--ink); } +.status strong { display: block; } .status span { color: var(--muted); font-size: .88rem; } +.badge { display: inline-flex; align-items: center; gap: 5px; padding: 2px 10px; border-radius: 999px; font-size: .75rem; font-weight: 700; background: var(--card-2); border: 1px solid var(--line); color: var(--muted); white-space: nowrap; } +.badge.ok { background: var(--ok-bg); border-color: transparent; color: var(--ok); } .badge.warn { background: var(--warn-bg); border-color: transparent; color: var(--warn); } +.badge.brand { background: var(--brand-soft); border-color: transparent; color: var(--brand); } .badge.err { background: var(--err-bg); border-color: transparent; color: var(--err); } +.ok { color: var(--ok); } .warn { color: var(--warn); } .danger { color: var(--err); } + +/* Formulare */ +.form { display: grid; gap: 14px; } +.form.narrow { max-width: 400px; margin: 40px auto; } +.form label { display: grid; gap: 5px; font-size: .86rem; font-weight: 600; min-width: 0; } +.row2 { display: grid; grid-template-columns: 1fr 1fr; gap: 14px; align-items: end; } @media (max-width: 520px) { .row2 { grid-template-columns: 1fr; } } +input[type=search], input[type=text], input[type=password], select, textarea { width: 100%; padding: 10px 12px; border: 1px solid var(--line); border-radius: 10px; background: var(--card-2); color: var(--ink); font: inherit; font-weight: 400; transition: border-color .15s, box-shadow .15s; } +input:focus, select:focus, textarea:focus { outline: none; border-color: var(--brand); box-shadow: 0 0 0 3px var(--brand-soft); } +input[type=file] { width: 100%; padding: 12px; border: 1.5px dashed var(--line); border-radius: 10px; background: var(--card-2); color: var(--muted); font: inherit; font-weight: 400; cursor: pointer; } +input[type=file]::file-selector-button { margin-right: 12px; border: 0; border-radius: 8px; padding: 7px 14px; background: var(--brand); color: var(--brand-ink); font: inherit; font-weight: 600; cursor: pointer; } +input[type=file]:hover { border-color: var(--brand); } +fieldset { border: 1px dashed var(--line); border-radius: 12px; display: grid; gap: 12px; padding: 14px; margin: 0; min-width: 0; } +legend { padding: 0 6px; font-size: .8rem; color: var(--muted); font-weight: 600; } +button, .btn { background: var(--brand); color: var(--brand-ink); border: 0; border-radius: 10px; padding: 10px 18px; font: inherit; font-weight: 700; cursor: pointer; text-decoration: none; display: inline-flex; align-items: center; justify-content: center; gap: 6px; transition: filter .15s, transform .05s; } +button:hover, .btn:hover { filter: brightness(1.08); } button:active { transform: translateY(1px); } +button.ghost, .btn.ghost { background: transparent; color: var(--ink); border: 1px solid var(--line); } +button.ghost:hover { border-color: var(--brand); filter: none; } +button.sm { padding: 6px 12px; font-size: .85rem; border-radius: 8px; } +button.danger { background: var(--err-bg); color: var(--err); } +button.link { background: none; color: var(--brand); padding: 0; font-weight: 600; } +button.link.danger { background: none; color: var(--err); } +form.inline { display: inline; margin: 0; } +.alert { padding: 12px 16px; border-radius: 12px; margin: 0 0 18px; font-weight: 500; } +.alert.err { background: var(--err-bg); color: var(--err); } .alert.okbox { background: var(--ok-bg); color: var(--ok); } + +/* Tabellen */ +.tablewrap { overflow-x: auto; border: 1px solid var(--line); border-radius: 12px; } +table { width: 100%; border-collapse: collapse; background: var(--card); } +th, td { text-align: left; padding: 11px 14px; border-bottom: 1px solid var(--line); vertical-align: middle; } +tr:last-child td { border-bottom: 0; } +th { font-size: .72rem; text-transform: uppercase; letter-spacing: .06em; color: var(--muted); background: var(--card-2); white-space: nowrap; } +td.actions { text-align: right; white-space: nowrap; } +.hash { font: .72rem/1.3 ui-monospace, Consolas, monospace; word-break: break-all; } +.empty { text-align: center; color: var(--muted); padding: 28px 12px; border: 1.5px dashed var(--line); border-radius: 12px; } + +/* Paketliste */ +.pkg { border: 1px solid var(--line); border-radius: 12px; margin-bottom: 12px; overflow: hidden; background: var(--card); } +.pkg > .head { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; padding: 12px 14px; background: var(--card-2); border-bottom: 1px solid var(--line); } +.pkg .head .name { font-weight: 700; } .pkg .head .grow { flex: 1; min-width: 0; } +.pkg table { border: 0; } + +/* Code, Schlüssel, Details */ +pre.key, code { background: var(--card-2); border: 1px solid var(--line); border-radius: 8px; padding: 2px 6px; font-size: .85rem; } +pre.key { padding: 14px; overflow-x: auto; white-space: pre; max-width: 100%; margin: 10px 0 0; font-size: .8rem; line-height: 1.5; } +details.card summary { cursor: pointer; font-weight: 700; } +details.card p { overflow-wrap: anywhere; } +.thumb { width: 100%; height: 140px; object-fit: cover; border-radius: 10px; margin-bottom: 10px; background: var(--line); } +.foot { color: var(--muted); font-size: .8rem; padding-bottom: 32px; text-align: center; } + +@media (max-width: 560px) { .stats { grid-template-columns: 1fr 1fr; gap: 10px; } .stat { padding: 12px 14px; } .stat .v { font-size: 1.5rem; } main { padding-top: 20px; } } + +/* Ersatz für Inline-Styles (die Sicherheitsrichtlinie erlaubt keine) */ +.h1-sm { font-size: 1.4rem; } +.mt { margin-top: 20px; } +.actions-row { display: flex; gap: 10px; flex-wrap: wrap; margin: 0; } +.actions-row .btn { flex: 1 1 auto; white-space: nowrap; } +.tablewrap.flat { border: 0; border-radius: 0; } + +/* Zwei Spalten: Plugins | Themes, Windows | Android */ +.cols.two { grid-template-columns: repeat(2, minmax(0, 1fr)); } +@media (max-width: 900px) { .cols.two { grid-template-columns: 1fr; } } +.cols.two .pkg:last-child { margin-bottom: 0; } +.cols.two td, .cols.two th { padding: 10px 12px; } +.card > header .badge { align-self: center; } +.cols.two td, .cols.two th { white-space: nowrap; } +.cols.two > .card { padding: 16px; } +.cols.two td, .cols.two th { padding: 9px 8px; } +.cols.two td.actions { padding-right: 12px; } +.cols.three { grid-template-columns: repeat(3, minmax(0, 1fr)); } +@media (max-width: 1100px) { .cols.three { grid-template-columns: 1fr; } } diff --git a/plugin-webseite/favicon.ico b/plugin-webseite/favicon.ico new file mode 100644 index 0000000000000000000000000000000000000000..dccf9356f9a8d1442c55fe004420603f1260046d GIT binary patch literal 1581 zcmV+|2GaQe0096205C8B0096X02c-T02TlM0EtjeM-2)Z3IG5A4M|8uQUCw|FaQ7m zFbDEQ&C)#l(<&HZD)?P&p8^<-HZ0=?nbxj z?m~O)N?11uH`_^&TSGVLZco{1CjqBj2{=p8i(BfJauhMigW8(n!C;&klz~p&aSfCc z9iYU}?V$7@10_n`7XYQ#ue-+wN_U&?NGm8^UQmvsJs^i$Kn_s{n?deGyFu2dJDPO2 zyFfnX1UZ0i1lfNSWFK`~Lz1-At>b9(t9Ur%C>{zzp1_0LTF*?pwOWzWDRIn9y%N`! z6%U0jW*!3LR*?I@JsxSu=aE> zd^~ar-t6|9!#k@m0N54}YU}a={*7=eRNcP;zJJGOz3Iw%Q+RT;5^Qo@+n5hyv-9@l zH29R)o{VILX8|ZUZV!avUyruK2j2`Zt7qe&oM824W3akUn-?BO$Mbm1 zFaZ7v+2BdTVICldZyv!#b@AR^C>u$_>PR4C!MdjI7zX(C`?Fc$Nj(N&n=iGF5SQ+%rw#}xiNy7kdMf_ReNnJ%MfcVVg*eq02Gc%wLbh7|U=r8fbwQpy^T~oh8 z$#9n`JgK$w068pJocR}}E$2?ow#}<_jl50Qm)Y2);Pn7(KQ9K4ZG#K;MQZ(EuW8#D z!IPSD9zYNts}kGBNK^mKu*ItTcNqKo5&y(oQ!~@>PN+=?o;VKC0PIOh3?6$PtI|ja z{(My~?{LHVL_frS8RbVAD{=d#5IhfnkAY(Fc$31OHxdi*T+gSf5*O?G;XZiq&l7%h zrrr$)EW?xPgFJvBJl651CKTNc_Pc5oZgX_Vgp%# zX94hCr6@f1ygJm&FPSbDU>^7tZO*IdM^Qi0hCxqbj02&4mgJ%KggNTuE zhOr)>_2qlx{8Mv%)Dsedmk#d208MsLc - -<?= site_h($title) ?> · <?= site_h($name) ?> - -
- - -
-
-
-
Fremder Code läuft mit allen Rechten auf eurem Server. Installiert nur Pakete, deren Herkunft ihr prüfen könnt, am besten signierte.
- - [Beschriftung, Symbol]. */ +function site_tabs(): array +{ + return [ + 'overview' => ['Übersicht', '◧'], + 'packages' => ['Pakete', '▣'], + 'apps' => ['Apps', '⬇'], + 'push' => ['Push-Dienst', '🔔'], + 'settings' => ['Einstellungen', '⚙'], + ]; +} + +function site_header(string $title, string $active = '', bool $nav = false): void +{ + site_headers(); + $name = site_config()['site_name']; + ?> + + +<?= site_h($title) ?> · <?= site_h($name) ?> + + + + + + +
+ + + + +
+
+
+
Fremder Code läuft mit allen Rechten auf eurem Server. Installiert nur Pakete, deren Herkunft ihr prüfen könnt, am besten signierte.
+ +exec("CREATE TABLE IF NOT EXISTS app_releases ( + id INTEGER PRIMARY KEY AUTOINCREMENT, platform TEXT NOT NULL, version TEXT NOT NULL, file TEXT NOT NULL, + sha256 TEXT NOT NULL, size INTEGER NOT NULL, notes TEXT NOT NULL DEFAULT '', created_at TEXT NOT NULL, + UNIQUE(platform, version))"); // Empfehlung (nachträglich ergänzt, damit bestehende Datenbanken ohne Handarbeit weiterlaufen) $cols = array_column($pdo->query('PRAGMA table_info(items)')->fetchAll(), 'name'); if (!in_array('featured', $cols, true)) $pdo->exec('ALTER TABLE items ADD COLUMN featured INTEGER NOT NULL DEFAULT 0'); @@ -102,6 +107,50 @@ function site_db(): PDO return $pdo; } +/** Latest published app release per platform: the HIGHEST version, not the one uploaded last. */ +function site_app_release(string $platform): ?array +{ + if (!in_array($platform, ['android', 'windows', 'minecraft'], true)) return null; + $stmt = site_db()->prepare('SELECT * FROM app_releases WHERE platform = ?'); + $stmt->execute([$platform]); + $rows = $stmt->fetchAll(); + if (!$rows) return null; + usort($rows, fn($a, $b) => version_compare($b['version'], $a['version']) ?: ((int) $b['id'] <=> (int) $a['id'])); + return $rows[0]; +} + +function site_app_releases(): array +{ + $rows = site_db()->query('SELECT * FROM app_releases')->fetchAll(); + usort($rows, fn($a, $b) => strcmp($a['platform'], $b['platform']) ?: (version_compare($b['version'], $a['version']) ?: ((int) $b['id'] <=> (int) $a['id']))); + return $rows; +} + +function site_publish_app(string $platform, string $tmp, string $version, string $notes): array +{ + $types = ['android' => ['apk', "PK"], 'windows' => ['exe', "MZ"], 'minecraft' => ['jar', "PK"]]; + if (!isset($types[$platform])) return ['ok' => false, 'error' => 'Unbekannte Plattform.']; + if (!preg_match('/^\\d+\\.\\d+\\.\\d+(?:[-+][0-9A-Za-z.-]+)?$/', $version)) return ['ok' => false, 'error' => 'Bitte eine Versionsnummer wie 3.4.0 eingeben.']; + if (!is_uploaded_file($tmp)) return ['ok' => false, 'error' => 'Die hochgeladene Datei wurde nicht gefunden.']; + $size = (int) filesize($tmp); + if ($size < 1024 || $size > SITE_MAX_APP_BYTES) return ['ok' => false, 'error' => 'Die Datei muss zwischen 1 KB und 300 MB groß sein.']; + $head = (string) file_get_contents($tmp, false, null, 0, 2); + if ($head !== $types[$platform][1]) return ['ok' => false, 'error' => 'Dateityp passt nicht zur gewählten Plattform.']; + $dir = site_storage() . '/apps/' . $platform; + if (!is_dir($dir) && !@mkdir($dir, 0755, true)) return ['ok' => false, 'error' => 'App-Speicher konnte nicht angelegt werden.']; + $file = bin2hex(random_bytes(24)) . '.' . $types[$platform][0]; + if (!move_uploaded_file($tmp, $dir . '/' . $file)) return ['ok' => false, 'error' => 'App-Datei konnte nicht gespeichert werden.']; + $sha = hash_file('sha256', $dir . '/' . $file); + try { + $stmt = site_db()->prepare('INSERT INTO app_releases(platform,version,file,sha256,size,notes,created_at) VALUES(?,?,?,?,?,?,?)'); + $stmt->execute([$platform, $version, $file, $sha, $size, substr(trim($notes), 0, 2000), date('Y-m-d H:i:s')]); + } catch (PDOException $e) { + @unlink($dir . '/' . $file); + return ['ok' => false, 'error' => str_contains(strtolower($e->getMessage()), 'unique') ? 'Diese Version ist für diese Plattform bereits vorhanden.' : 'Release konnte nicht gespeichert werden.']; + } + return ['ok' => true]; +} + /** Plugin bzw. Theme als „Empfohlen“ markieren oder die Markierung entfernen. */ function site_set_featured(int $itemId, bool $featured): bool {