Upload via GUI (24 Dateien)

This commit is contained in:
2026-10-16 18:42:18 +00:00
parent 1f03c7e4cd
commit d9d58112e0
24 changed files with 1167 additions and 0 deletions
+6
View File
@@ -0,0 +1,6 @@
config.php
storage
*.pem
.git
docker-compose.yml
Caddyfile
+4
View File
@@ -0,0 +1,4 @@
*.sh text eol=lf
Dockerfile text eol=lf
docker-compose.yml text eol=lf
docker-bake.php text eol=lf
+4
View File
@@ -0,0 +1,4 @@
schluessel/
config.php
storage/
*.pem
+23
View File
@@ -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>
+11
View File
@@ -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
}
+46
View File
@@ -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);'
+94
View File
@@ -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
+136
View File
@@ -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();
+9
View File
@@ -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(); }
+53
View File
@@ -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; }
+11
View File
@@ -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);
+19
View File
@@ -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',
];
+44
View File
@@ -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'];
+66
View File
@@ -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)"
+45
View File
@@ -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)"
+45
View File
@@ -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:
+28
View File
@@ -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);
+56
View File
@@ -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();
}
+5
View File
@@ -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';
+35
View File
@@ -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
}
+395
View File
@@ -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) . ",
];
";
}
+5
View File
@@ -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;
+16
View File
@@ -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);
+11
View File
@@ -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;