Upload via GUI (24 Dateien)
This commit is contained in:
@@ -0,0 +1,6 @@
|
|||||||
|
config.php
|
||||||
|
storage
|
||||||
|
*.pem
|
||||||
|
.git
|
||||||
|
docker-compose.yml
|
||||||
|
Caddyfile
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
*.sh text eol=lf
|
||||||
|
Dockerfile text eol=lf
|
||||||
|
docker-compose.yml text eol=lf
|
||||||
|
docker-bake.php text eol=lf
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
schluessel/
|
||||||
|
config.php
|
||||||
|
storage/
|
||||||
|
*.pem
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
# Nur die Seiten sind erreichbar; Konfiguration, Hilfscode und Daten nie.
|
||||||
|
Options -Indexes
|
||||||
|
|
||||||
|
<FilesMatch "^(config(\.example)?\.php|einrichten\.php|README\.md)$">
|
||||||
|
<IfModule mod_authz_core.c>
|
||||||
|
Require all denied
|
||||||
|
</IfModule>
|
||||||
|
<IfModule !mod_authz_core.c>
|
||||||
|
Order deny,allow
|
||||||
|
Deny from all
|
||||||
|
</IfModule>
|
||||||
|
</FilesMatch>
|
||||||
|
|
||||||
|
<IfModule mod_rewrite.c>
|
||||||
|
RewriteEngine On
|
||||||
|
RewriteRule ^(inc|storage)/ - [F,L]
|
||||||
|
</IfModule>
|
||||||
|
|
||||||
|
# Sicherheits-Header
|
||||||
|
<IfModule mod_headers.c>
|
||||||
|
Header always set X-Content-Type-Options "nosniff"
|
||||||
|
Header always set X-Frame-Options "DENY"
|
||||||
|
</IfModule>
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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 '<Directory /var/www/html>'; echo ' AllowOverride All'; echo ' Require all granted'; echo '</Directory>'; } > /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);'
|
||||||
@@ -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
|
||||||
@@ -0,0 +1,136 @@
|
|||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
/** Verwaltung der Webseite: Anmelden, Pakete veröffentlichen, Versionen entfernen. */
|
||||||
|
require_once __DIR__ . '/inc/layout.php';
|
||||||
|
|
||||||
|
session_set_cookie_params(['lifetime' => 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');
|
||||||
|
?>
|
||||||
|
<h1>Verwaltung</h1>
|
||||||
|
<?php if ($error): ?><div class="alert err"><?= site_h($error) ?></div><?php endif; ?>
|
||||||
|
<?php if ($message): ?><div class="alert okbox"><?= site_h($message) ?></div><?php endif; ?>
|
||||||
|
|
||||||
|
<?php if (!$loggedIn): ?>
|
||||||
|
<form method="post" class="form narrow">
|
||||||
|
<input type="hidden" name="action" value="login">
|
||||||
|
<label>Benutzername<input type="text" name="user" autocomplete="username" required></label>
|
||||||
|
<label>Passwort<input type="password" name="password" autocomplete="current-password" required></label>
|
||||||
|
<button type="submit">Anmelden</button>
|
||||||
|
</form>
|
||||||
|
<?php else: ?>
|
||||||
|
<form method="post" class="logout"><input type="hidden" name="action" value="logout"><input type="hidden" name="csrf" value="<?= site_h(admin_csrf()) ?>"><button type="submit" class="link">Abmelden</button></form>
|
||||||
|
<p class="<?= site_public_key() !== '' ? 'ok' : 'warn' ?>"><?= site_public_key() !== '' ? '✓ Signierschlüssel eingerichtet: Neue Pakete werden automatisch signiert.' : 'Kein Signierschlüssel eingerichtet (signing_key_file in config.php): Pakete werden unsigniert veröffentlicht.' ?></p>
|
||||||
|
|
||||||
|
<details class="form" style="max-width:none"><summary><strong>Einstellungen für DeskHub (offizieller Katalog)</strong></summary>
|
||||||
|
<p class="muted">Diesen Inhalt in <code>includes/catalog_defaults.php</code> von DeskHub einsetzen, bevor ihr ein Release baut. Dann installieren eure Kunden Plugins nur aus diesem Katalog, und nur signierte.</p>
|
||||||
|
<?php if (site_public_key() === ''): ?><p class="warn">Es ist noch kein Signierschlüssel eingerichtet (php einrichten.php). Ohne Schlüssel ist der geprüfte Modus nicht nutzbar.</p><?php endif; ?>
|
||||||
|
<pre class="key"><?= site_h(site_deskhub_defaults_snippet()) ?></pre>
|
||||||
|
</details>
|
||||||
|
|
||||||
|
<h2>Neues Paket veröffentlichen</h2>
|
||||||
|
<form method="post" enctype="multipart/form-data" class="form">
|
||||||
|
<input type="hidden" name="action" value="publish"><input type="hidden" name="csrf" value="<?= site_h(admin_csrf()) ?>">
|
||||||
|
<label>Art<select name="type" id="type"><option value="plugin">Plugin</option><option value="theme">Theme</option></select></label>
|
||||||
|
<label>ZIP-Paket<input type="file" name="package" accept=".zip" required></label>
|
||||||
|
<fieldset class="theme-only"><legend>Nur für Themes (bei Plugins steht alles in der plugin.json)</legend>
|
||||||
|
<label>Kennung (Slug)<input type="text" name="slug" placeholder="nordlicht" pattern="[a-z0-9][a-z0-9_\-]{1,40}"></label>
|
||||||
|
<label>Name<input type="text" name="name" placeholder="Nordlicht"></label>
|
||||||
|
<label>Version<input type="text" name="version" placeholder="1.0.0 (sonst aus dem CSS-Kopf)"></label>
|
||||||
|
<label>Autor<input type="text" name="author"></label>
|
||||||
|
<label>Beschreibung<textarea name="description" rows="3"></textarea></label>
|
||||||
|
</fieldset>
|
||||||
|
<label>Vorschaubild (optional, PNG/JPG/WebP, höchstens 1 MB)<input type="file" name="preview" accept="image/png,image/jpeg,image/webp"></label>
|
||||||
|
<button type="submit">Veröffentlichen</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<h2>Veröffentlichte Pakete</h2>
|
||||||
|
<?php foreach (['plugin' => 'Plugins', 'theme' => 'Themes'] as $t => $label): $items = site_items($t); ?>
|
||||||
|
<h3><?= site_h($label) ?></h3>
|
||||||
|
<?php if (!$items): ?><p class="muted">Noch keine.</p><?php endif; ?>
|
||||||
|
<?php foreach ($items as $it): ?>
|
||||||
|
<table class="admin">
|
||||||
|
<caption><?= site_h($it['name']) ?> <span class="muted small"><?= site_h($it['slug']) ?></span></caption>
|
||||||
|
<?php foreach (site_versions((int) $it['id']) as $v): ?>
|
||||||
|
<tr><td><?= site_h($v['version']) ?></td><td><?= site_h(substr($v['created_at'], 0, 16)) ?></td><td><?= (int) $v['downloads'] ?> Downloads</td>
|
||||||
|
<td><?= $v['signature'] !== '' ? '<span class="ok">signiert</span>' : '<span class="warn">nicht signiert</span>' ?></td>
|
||||||
|
<td><form method="post" data-confirm="Version wirklich entfernen?"><input type="hidden" name="action" value="delete"><input type="hidden" name="csrf" value="<?= site_h(admin_csrf()) ?>"><input type="hidden" name="id" value="<?= (int) $v['id'] ?>"><button type="submit" class="link danger">Entfernen</button></form></td></tr>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</table>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
<script src="assets/admin.js"></script>
|
||||||
|
<?php endif; ?>
|
||||||
|
<?php site_footer();
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
// Bestätigung vor dem Löschen und Ein-/Ausblenden der Theme-Felder (CSP erlaubt keine Inline-Skripte)
|
||||||
|
document.querySelectorAll('form[data-confirm]').forEach(function (f) {
|
||||||
|
f.addEventListener('submit', function (e) { if (!confirm(f.dataset.confirm)) e.preventDefault(); });
|
||||||
|
});
|
||||||
|
var type = document.getElementById('type');
|
||||||
|
function toggleTheme() {
|
||||||
|
document.querySelectorAll('.theme-only').forEach(function (el) { el.style.display = type.value === 'theme' ? '' : 'none'; });
|
||||||
|
}
|
||||||
|
if (type) { type.addEventListener('change', toggleTheme); toggleTheme(); }
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
: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; } }
|
||||||
|
* { 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; } }
|
||||||
|
|
||||||
|
/* 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; }
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
/** Katalog für DeskHub: catalog.php?type=plugins oder catalog.php?type=themes (JSON). */
|
||||||
|
require_once __DIR__ . '/inc/lib.php';
|
||||||
|
site_require_access();
|
||||||
|
|
||||||
|
$type = ($_GET['type'] ?? 'plugins') === 'themes' ? 'theme' : 'plugin';
|
||||||
|
header('Content-Type: application/json; charset=utf-8');
|
||||||
|
header('Cache-Control: public, max-age=300');
|
||||||
|
header('X-Content-Type-Options: nosniff');
|
||||||
|
echo json_encode(site_catalog($type), JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_PRETTY_PRINT);
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* Einrichtung: Diese Datei als config.php kopieren und anpassen (am einfachsten mit: php einrichten.php).
|
||||||
|
* config.php ist per .htaccess nicht abrufbar.
|
||||||
|
*/
|
||||||
|
return [
|
||||||
|
'site_name' => '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',
|
||||||
|
];
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* Läuft nur beim Docker-Build: schreibt die Zugangsdaten fest in config.php (schreibgeschützt, 'locked').
|
||||||
|
* Erwartet die BuildKit-Secrets /run/secrets/admin_hash und /run/secrets/signing_key sowie BASE_URL, SITE_NAME, ADMIN_USER.
|
||||||
|
*/
|
||||||
|
if (PHP_SAPI !== 'cli') exit(1);
|
||||||
|
|
||||||
|
$fail = function (string $m): never { fwrite(STDERR, "FEHLER: {$m}\n"); exit(1); };
|
||||||
|
// Pfade nur für Tests überschreibbar (beim Docker-Build gelten die Vorgaben)
|
||||||
|
$secrets = getenv('BAKE_SECRETS_DIR') ?: '/run/secrets';
|
||||||
|
$keyDir = getenv('BAKE_KEY_DIR') ?: '/opt/plugin-site-secure';
|
||||||
|
$configPath = getenv('BAKE_CONFIG_PATH') ?: '/var/www/html/config.php';
|
||||||
|
$storage = getenv('BAKE_STORAGE_DIR') ?: '/data';
|
||||||
|
|
||||||
|
$hash = trim((string) @file_get_contents($secrets . '/admin_hash'));
|
||||||
|
$key = (string) @file_get_contents($secrets . '/signing_key');
|
||||||
|
$access = trim((string) @file_get_contents($secrets . '/catalog_key'));
|
||||||
|
$base = rtrim((string) getenv('BASE_URL'), '/');
|
||||||
|
$user = (string) getenv('ADMIN_USER');
|
||||||
|
|
||||||
|
if (!str_starts_with($base, 'https://')) $fail('BASE_URL muss mit https:// beginnen.');
|
||||||
|
if ($user === '' || !preg_match('/^[A-Za-z0-9._@-]{3,64}$/', $user)) $fail('ADMIN_USER ist ungültig (3–64 Zeichen: Buchstaben, Ziffern, . _ @ -).');
|
||||||
|
$info = password_get_info($hash);
|
||||||
|
if (($info['algo'] ?? null) === null || $info['algo'] === 0) $fail('admin_hash ist kein gültiger Passwort-Hash (mit password_hash erzeugen).');
|
||||||
|
if (!preg_match('/^[A-Za-z0-9]{32,128}$/', $access)) $fail('catalog_key (Zugangsschlüssel für den Katalog) fehlt oder ist ungültig: 32–128 Buchstaben/Ziffern.');
|
||||||
|
$private = openssl_pkey_get_private($key);
|
||||||
|
if (!$private) $fail('signing_key ist kein gültiger privater Schlüssel (PEM).');
|
||||||
|
$details = openssl_pkey_get_details($private);
|
||||||
|
if (($details['type'] ?? null) !== OPENSSL_KEYTYPE_RSA || ($details['bits'] ?? 0) < 2048) $fail('Der Signierschlüssel muss RSA mit mindestens 2048 Bit sein.');
|
||||||
|
|
||||||
|
@mkdir($keyDir, 0750, true);
|
||||||
|
file_put_contents($keyDir . '/herausgeber-privat.pem', $key);
|
||||||
|
$cfg = [
|
||||||
|
'site_name' => (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, "<?php\nreturn " . var_export($cfg, true) . ";\n");
|
||||||
|
echo "Zugang für \"{$user}\" und Signierschlüssel fest eingebaut. Öffentlicher Schlüssel:\n" . $details['key'];
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
<#
|
||||||
|
.SYNOPSIS
|
||||||
|
Baut das Docker-Image der Plugin-Webseite mit fest eingebautem Admin-Zugang und Signierschlüssel.
|
||||||
|
|
||||||
|
.DESCRIPTION
|
||||||
|
Fragt Adresse, Benutzername und Passwort ab, erzeugt (falls nötig) den Signierschlüssel und baut das Image.
|
||||||
|
Passwort-Hash und privater Schlüssel werden als BuildKit-Secrets übergeben und stehen nicht im Image-Verlauf.
|
||||||
|
Benötigt nur Docker. PHP muss lokal NICHT installiert sein.
|
||||||
|
|
||||||
|
Der private Schlüssel liegt danach in .\schluessel\herausgeber-privat.pem. DIESE DATEI SICHERN und niemals weitergeben.
|
||||||
|
Das fertige Image enthält Passwort-Hash und privaten Schlüssel: nur in eine PRIVATE Registry hochladen.
|
||||||
|
#>
|
||||||
|
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)"
|
||||||
@@ -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)"
|
||||||
@@ -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:
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
require_once __DIR__ . '/inc/lib.php';
|
||||||
|
site_require_access();
|
||||||
|
|
||||||
|
$type = ($_GET['type'] ?? '') === 'theme' ? 'theme' : (($_GET['type'] ?? '') === 'plugin' ? 'plugin' : '');
|
||||||
|
$slug = (string) ($_GET['slug'] ?? '');
|
||||||
|
$item = $type !== '' && site_valid_slug($slug) ? site_item($type, $slug) : null;
|
||||||
|
$version = null;
|
||||||
|
if ($item) {
|
||||||
|
$wanted = (string) ($_GET['version'] ?? '');
|
||||||
|
foreach ($item['versions'] as $v) if ($wanted === '' ? $v === $item['versions'][0] : $v['version'] === $wanted) { $version = $v; break; }
|
||||||
|
}
|
||||||
|
$path = $version ? site_storage() . '/packages/' . $version['file'] : '';
|
||||||
|
if (!$version || !is_file($path)) {
|
||||||
|
http_response_code(404);
|
||||||
|
header('Content-Type: text/plain; charset=utf-8');
|
||||||
|
exit('Paket nicht gefunden.');
|
||||||
|
}
|
||||||
|
site_db()->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);
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* Einrichtungshilfe (nur Kommandozeile): php einrichten.php
|
||||||
|
* Legt config.php an, setzt das Admin-Passwort und erzeugt auf Wunsch den Signierschlüssel.
|
||||||
|
*/
|
||||||
|
if (PHP_SAPI !== 'cli') { http_response_code(404); exit; }
|
||||||
|
|
||||||
|
function ask(string $q, string $default = ''): string
|
||||||
|
{
|
||||||
|
echo $q . ($default !== '' ? " [{$default}]" : '') . ': ';
|
||||||
|
$a = trim((string) fgets(STDIN));
|
||||||
|
return $a === '' ? $default : $a;
|
||||||
|
}
|
||||||
|
|
||||||
|
$example = require __DIR__ . '/config.example.php';
|
||||||
|
$file = __DIR__ . '/config.php';
|
||||||
|
$cfg = is_file($file) ? (array) require $file : $example;
|
||||||
|
|
||||||
|
echo "Einrichtung der Plugin- und Theme-Webseite\n\n";
|
||||||
|
$cfg['site_name'] = ask('Name der Webseite', (string) $cfg['site_name']);
|
||||||
|
$cfg['base_url'] = rtrim(ask('Öffentliche Adresse (https://…)', (string) $cfg['base_url']), '/');
|
||||||
|
if (!str_starts_with($cfg['base_url'], 'https://')) echo "HINWEIS: DeskHub lädt nur über https. Mit http:// funktioniert der Katalog nicht.\n";
|
||||||
|
$cfg['admin_user'] = ask('Admin-Benutzername', (string) $cfg['admin_user']);
|
||||||
|
$pw = ask('Neues Admin-Passwort (mindestens 12 Zeichen, leer = unverändert)');
|
||||||
|
if ($pw !== '') {
|
||||||
|
if (strlen($pw) < 12) { fwrite(STDERR, "Das Passwort ist zu kurz.\n"); exit(1); }
|
||||||
|
$cfg['admin_password_hash'] = password_hash($pw, PASSWORD_DEFAULT);
|
||||||
|
}
|
||||||
|
if (empty($cfg['access_key'])) {
|
||||||
|
$cfg['access_key'] = bin2hex(random_bytes(24));
|
||||||
|
echo "Zugangsschlüssel für den Katalog erzeugt (DeskHub schickt ihn bei jedem Abruf mit).\n";
|
||||||
|
}
|
||||||
|
if ($cfg['signing_key_file'] === '' || !is_file($cfg['signing_key_file'])) {
|
||||||
|
if (strtolower(ask('Signierschlüssel jetzt erzeugen? (j/n)', 'j')) === 'j') {
|
||||||
|
$dir = ask('Ordner für den Schlüssel (außerhalb des Webverzeichnisses!)', dirname(__DIR__) . '/plugin-webseite-schluessel');
|
||||||
|
if (!is_dir($dir) && !mkdir($dir, 0700, true)) { fwrite(STDERR, "Ordner konnte nicht angelegt werden.\n"); exit(1); }
|
||||||
|
$cnf = tempnam(sys_get_temp_dir(), 'cnf');
|
||||||
|
file_put_contents($cnf, "[req]\ndistinguished_name=req_dn\n[req_dn]\n");
|
||||||
|
$key = openssl_pkey_new(['config' => $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, "<?php\nreturn " . var_export($cfg, true) . ";\n");
|
||||||
|
@chmod($file, 0640);
|
||||||
|
echo "\nconfig.php geschrieben. Verwaltung: {$cfg['base_url']}/admin.php\n";
|
||||||
|
if ($cfg['signing_key_file'] !== '' && is_file($cfg['signing_key_file'])) {
|
||||||
|
putenv('PLUGIN_SITE_CONFIG=' . $file);
|
||||||
|
require_once __DIR__ . '/inc/lib.php';
|
||||||
|
echo "\n--- Inhalt für DeskHub: includes/catalog_defaults.php (vor dem Release eintragen) ---\n" . site_deskhub_defaults_snippet();
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
// Für den Docker-Healthcheck: antwortet ohne Zugangsschlüssel und gibt nichts preis.
|
||||||
|
header('Content-Type: text/plain; charset=utf-8');
|
||||||
|
echo 'ok';
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
require_once __DIR__ . '/lib.php';
|
||||||
|
|
||||||
|
function site_headers(): void
|
||||||
|
{
|
||||||
|
header('X-Content-Type-Options: nosniff');
|
||||||
|
header('X-Frame-Options: DENY');
|
||||||
|
header('Referrer-Policy: same-origin');
|
||||||
|
header("Content-Security-Policy: default-src 'self'; img-src 'self' data:; style-src 'self'; script-src 'self'; frame-ancestors 'none'; base-uri 'self'; form-action 'self'");
|
||||||
|
}
|
||||||
|
|
||||||
|
function site_header(string $title, string $active = ''): void
|
||||||
|
{
|
||||||
|
site_headers();
|
||||||
|
$name = site_config()['site_name'];
|
||||||
|
?><!doctype html>
|
||||||
|
<html lang="de"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title><?= site_h($title) ?> · <?= site_h($name) ?></title><link rel="stylesheet" href="assets/style.css"></head>
|
||||||
|
<body>
|
||||||
|
<header class="top"><div class="wrap">
|
||||||
|
<a class="brand" href="index.php"><?= site_h($name) ?></a>
|
||||||
|
<nav><a href="admin.php" class="on">Verwaltung</a></nav>
|
||||||
|
</div></header>
|
||||||
|
<main class="wrap">
|
||||||
|
<?php
|
||||||
|
}
|
||||||
|
|
||||||
|
function site_footer(): void
|
||||||
|
{
|
||||||
|
?></main>
|
||||||
|
<footer class="wrap foot">Fremder Code läuft mit allen Rechten auf eurem Server. Installiert nur Pakete, deren Herkunft ihr prüfen könnt, am besten signierte.</footer>
|
||||||
|
</body></html>
|
||||||
|
<?php
|
||||||
|
}
|
||||||
@@ -0,0 +1,395 @@
|
|||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Kern der Plugin- und Theme-Webseite: Konfiguration, Datenbank, Paketprüfung, Veröffentlichen, Signieren, Katalog.
|
||||||
|
* Die Webseite ist unabhängig von DeskHub und läuft mit PHP 8.1+ (SQLite, zip, openssl).
|
||||||
|
*/
|
||||||
|
|
||||||
|
const SITE_MAX_PACKAGE_BYTES = 10 * 1024 * 1024;
|
||||||
|
const SITE_MAX_PREVIEW_BYTES = 1024 * 1024;
|
||||||
|
const SITE_PLUGIN_EXTENSIONS = ['php', 'json', 'sql', 'js', 'css', 'png', 'jpg', 'jpeg', 'gif', 'svg', 'webp', 'md', 'txt', 'html'];
|
||||||
|
const SITE_THEME_EXTENSIONS = ['css', 'png', 'jpg', 'jpeg', 'gif', 'webp', 'woff', 'woff2'];
|
||||||
|
|
||||||
|
/** Muss mit plugin_known_permissions() in DeskHub (includes/plugins.php) übereinstimmen. */
|
||||||
|
function site_known_permissions(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'hooks.tickets' => '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<IfModule !mod_authz_core.c>\nDeny from all\n</IfModule>\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 "<?php
|
||||||
|
return [
|
||||||
|
'plugins_url' => " . 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) . ",
|
||||||
|
];
|
||||||
|
";
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
// Es gibt keine öffentliche Übersicht: DeskHub liest nur catalog.php. Die Startseite führt zur Verwaltung.
|
||||||
|
header('Location: admin.php', true, 302);
|
||||||
|
exit;
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
/** Liefert Vorschaubilder aus dem geschützten Speicher aus. */
|
||||||
|
require_once __DIR__ . '/inc/lib.php';
|
||||||
|
site_require_access();
|
||||||
|
|
||||||
|
$file = basename((string) ($_GET['file'] ?? ''));
|
||||||
|
$path = site_storage() . '/previews/' . $file;
|
||||||
|
$ext = strtolower(pathinfo($file, PATHINFO_EXTENSION));
|
||||||
|
$types = ['png' => '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);
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
/** Öffentlicher Herausgeber-Schlüssel (PEM). Admins tragen ihn in DeskHub unter Plugins → Herausgeber-Schlüssel ein. */
|
||||||
|
require_once __DIR__ . '/inc/lib.php';
|
||||||
|
site_require_access();
|
||||||
|
|
||||||
|
$key = site_public_key();
|
||||||
|
if ($key === '') { http_response_code(404); header('Content-Type: text/plain; charset=utf-8'); exit('Auf dieser Webseite ist kein Signierschlüssel eingerichtet.'); }
|
||||||
|
header('Content-Type: text/plain; charset=utf-8');
|
||||||
|
header('Content-Disposition: inline; filename="herausgeber-oeffentlich.pem"');
|
||||||
|
echo $key;
|
||||||
Reference in New Issue
Block a user