Upload via GUI (258 Dateien)

This commit is contained in:
2026-10-16 19:12:18 +00:00
parent 56deade877
commit 6cced3beda
50 changed files with 11148 additions and 0 deletions
+158
View File
@@ -0,0 +1,158 @@
<?php
declare(strict_types=1);
/**
* Themes aus dem Katalog (Plugin-/Theme-Webseite) installieren. Nutzt dieselben Sicherheitsbausteine wie die Plugins:
* https-only, Prüfsumme, optionale Signatur mit dem hinterlegten Herausgeber-Schlüssel.
*
* Katalogformat: {"themes":[{"slug","name","version","description","author","downloadUrl","sha256","signature"}]}
* Signiert wird plugin_signature_message('theme-<slug>', <version>, <sha256>).
*/
function theme_catalog_url(): string
{
if (plugin_strict_mode()) return catalog_defaults()['themes_url'];
$own = trim((string) get_setting('theme_catalog_url', ''));
return $own !== '' ? $own : catalog_defaults()['themes_url'];
}
/** @return array{ok:bool, error?:string, themes?:array} */
function theme_catalog_fetch(): array
{
$url = theme_catalog_url();
if ($url === '') return ['ok' => false, 'error' => 'Es ist keine Katalog-Adresse für Themes hinterlegt.'];
if ($err = plugin_url_check($url)) return ['ok' => false, 'error' => $err];
[, $body, $error] = plugin_http_get($url, 1048576);
if ($error !== null || $body === null) return ['ok' => false, 'error' => 'Der Katalog konnte nicht geladen werden: ' . ($error ?? 'unbekannter Fehler')];
$data = json_decode($body, true);
if (!is_array($data) || !isset($data['themes']) || !is_array($data['themes'])) return ['ok' => false, 'error' => 'Der Katalog hat ein ungültiges Format.'];
$themes = [];
foreach ($data['themes'] as $e) {
if (!is_array($e)) continue;
$slug = (string) ($e['slug'] ?? '');
$version = (string) ($e['version'] ?? '');
$sha = strtolower((string) ($e['sha256'] ?? ''));
$dl = (string) ($e['downloadUrl'] ?? '');
if (!plugin_valid_slug($slug) || !preg_match('/^\d+(\.\d+){0,3}([-+][0-9A-Za-z.]+)?$/', $version) || !preg_match('/^[a-f0-9]{64}$/', $sha) || !str_starts_with($dl, 'https://')) continue;
$themes[] = [
'slug' => $slug, 'name' => mb_substr(trim((string) ($e['name'] ?? $slug)), 0, 120), 'version' => $version,
'description' => mb_substr((string) ($e['description'] ?? ''), 0, 500), 'author' => mb_substr((string) ($e['author'] ?? ''), 0, 120),
'downloadUrl' => $dl, 'sha256' => $sha, 'signature' => trim((string) ($e['signature'] ?? '')),
'preview' => catalog_preview_url($url, (string) ($e['previewUrl'] ?? '')),
];
}
return ['ok' => true, 'themes' => $themes];
}
/**
* Theme-Katalog mit Zwischenspeicher (1 Stunde), damit er ohne Klick sofort angezeigt wird. $refresh erzwingt einen neuen Abruf.
*
* @return array{ok:bool, error?:string, themes?:array, cached_at?:int}
*/
function theme_catalog_cached(bool $refresh = false): array
{
$key = hash('sha256', theme_catalog_url());
if (!$refresh) {
$cached = json_decode((string) get_setting('theme_catalog_cache', ''), true);
if (is_array($cached) && ($cached['key'] ?? '') === $key && time() - (int) ($cached['at'] ?? 0) < 3600) {
return ['ok' => true, 'themes' => $cached['themes'], 'cached_at' => (int) $cached['at']];
}
}
$res = theme_catalog_fetch();
if ($res['ok']) {
set_setting('theme_catalog_cache', json_encode(['key' => $key, 'at' => time(), 'themes' => $res['themes']], JSON_UNESCAPED_UNICODE));
$res['cached_at'] = time();
}
return $res;
}
/**
* Installierte Version eines Katalog-Themes (Vergleich über den Anzeigenamen) oder null, wenn es nicht installiert ist.
* Hat das installierte Theme keine Versionsangabe, gilt es als aktuell ("0"), damit es nicht endlos zum Update angeboten wird.
*/
function theme_installed_version(string $catalogName): ?string
{
$wanted = normalize_custom_theme_name($catalogName);
foreach (list_custom_themes() as $t) {
if (normalize_custom_theme_name((string) $t['name']) === $wanted) return (string) ($t['theme_version'] ?? '') !== '' ? (string) $t['theme_version'] : '0';
}
return null;
}
/**
* Vorschau für ein installiertes Theme: bevorzugt das Bild aus dem Katalog (gleicher Name), sonst eine preview.png/.jpg/.webp
* im Theme-Ordner (wer ein eigenes Theme hochlädt, kann so selbst eine Vorschau mitgeben). Ohne beides null.
*
* @return array{kind:string, slug?:string, url?:string}|null
*/
function theme_installed_preview(array $theme, ?array $catalogThemes = null): ?array
{
$wanted = normalize_custom_theme_name((string) $theme['name']);
foreach ($catalogThemes ?? [] as $c) {
if (!empty($c['preview']) && normalize_custom_theme_name((string) $c['name']) === $wanted) return ['kind' => 'catalog', 'slug' => (string) $c['slug']];
}
foreach (['png', 'jpg', 'webp'] as $ext) {
if (is_file(custom_themes_directory() . '/' . $theme['id'] . '/preview.' . $ext)) return ['kind' => 'file', 'url' => '/assets/themes/' . rawurlencode((string) $theme['id']) . '/preview.' . $ext];
}
return null;
}
/** Mini-Vorschau (96x54) eines installierten Themes oder Platzhalter mit Anfangsbuchstaben. */
function installed_theme_thumb(array $theme, ?array $catalogThemes = null): string
{
$box = 'width:96px;height:54px;border-radius:6px;flex:none;background:var(--bg-soft,#eef2f5);border:1px solid var(--border,#e2e8f0);';
$p = theme_installed_preview($theme, $catalogThemes);
if ($p) {
$src = $p['kind'] === 'catalog' ? '/staff/catalog_preview.php?kind=theme&amp;slug=' . e($p['slug']) : e($p['url']);
return '<img src="' . $src . '" alt="" loading="lazy" width="96" height="54" style="' . $box . 'object-fit:cover">';
}
return '<div aria-hidden="true" style="' . $box . 'display:flex;align-items:center;justify-content:center;font-weight:700;color:var(--muted,#64748b);font-size:1.3rem">' . e(mb_strtoupper(mb_substr((string) $theme['name'], 0, 1))) . '</div>';
}
/** Ist das Katalog-Theme schon installiert, und braucht es ein Update? ['installed' => bool, 'update' => bool, 'version' => ?string] */
function theme_catalog_state(array $entry): array
{
$have = theme_installed_version($entry['name']);
if ($have === null) return ['installed' => false, 'update' => false, 'version' => null];
return ['installed' => true, 'update' => $have !== '0' && version_compare($entry['version'], $have, '>'), 'version' => $have];
}
/** Installiert ein Theme aus dem Katalog (aktiviert es nicht). */
function theme_catalog_install(string $slug): array
{
$catalog = theme_catalog_fetch();
if (!$catalog['ok']) return $catalog;
$entry = null;
foreach ($catalog['themes'] as $t) if ($t['slug'] === $slug) { $entry = $t; break; }
if (!$entry) return ['ok' => false, 'error' => 'Dieses Theme steht nicht im Katalog.'];
$state = theme_catalog_state($entry);
if ($state['installed'] && !$state['update']) return ['ok' => false, 'error' => 'Dieses Theme ist bereits in der aktuellen Version installiert.'];
if ($err = plugin_url_check($entry['downloadUrl'])) return ['ok' => false, 'error' => 'Download-Adresse nicht erlaubt: ' . $err];
[, $body, $error] = plugin_http_get($entry['downloadUrl'], CUSTOM_THEME_MAX_ARCHIVE_SIZE, 60);
if ($error !== null || $body === null) return ['ok' => false, 'error' => 'Download fehlgeschlagen: ' . ($error ?? 'unbekannter Fehler')];
$sha = hash('sha256', $body);
if (!hash_equals($entry['sha256'], $sha)) return ['ok' => false, 'error' => 'Die Prüfsumme (SHA-256) des Downloads stimmt nicht mit dem Katalog überein.'];
$signed = false;
if ($entry['signature'] !== '') {
if (plugin_publisher_key() === '') return ['ok' => false, 'error' => 'Es ist kein Herausgeber-Schlüssel hinterlegt, die Signatur kann nicht geprüft werden.'];
if (!plugin_verify_signature(plugin_signature_message('theme-' . $slug, $entry['version'], $sha), $entry['signature'], plugin_publisher_key())) {
return ['ok' => false, 'error' => 'Die Signatur ist ungültig. Das Paket wurde verändert oder stammt nicht vom Herausgeber.'];
}
$signed = true;
} elseif (plugin_signatures_required('catalog')) {
return ['ok' => false, 'error' => 'Themes müssen signiert sein (Einstellung „Signatur erforderlich“). Diesem Paket fehlt die Signatur.'];
}
$tmp = tempnam(sys_get_temp_dir(), 'dht_');
file_put_contents($tmp, $body);
try {
$theme = install_custom_theme_archive($tmp, normalize_custom_theme_name($entry['name']));
} catch (Throwable $e) {
return ['ok' => false, 'error' => $e->getMessage()];
} finally {
@unlink($tmp);
}
return ['ok' => true, 'theme' => $theme, 'signed' => $signed, 'hash' => $sha];
}