165 lines
9.3 KiB
PHP
165 lines
9.3 KiB
PHP
<?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 kurzem Zwischenspeicher (Standard 5 Minuten), damit er ohne Klick sofort angezeigt wird. $refresh erzwingt einen neuen
|
|
* Abruf. Ist der Katalog-Server nicht erreichbar, bleibt der letzte Stand sichtbar und es wird zwei Minuten lang nicht erneut gefragt.
|
|
*
|
|
* @return array{ok:bool, error?:string, themes?:array, cached_at?:int, stale?:bool}
|
|
*/
|
|
function theme_catalog_cached(bool $refresh = false, int $maxAge = 300): array
|
|
{
|
|
$key = hash('sha256', theme_catalog_url());
|
|
$cached = json_decode((string) get_setting('theme_catalog_cache', ''), true);
|
|
$usable = is_array($cached) && ($cached['key'] ?? '') === $key;
|
|
if (!$refresh && $usable) {
|
|
$age = time() - (int) ($cached['at'] ?? 0);
|
|
if ($age < $maxAge) return ['ok' => true, 'themes' => $cached['themes'], 'cached_at' => (int) $cached['at']];
|
|
if (time() - (int) get_setting('theme_catalog_fail_at', '0') < 120) return ['ok' => true, 'themes' => $cached['themes'], 'cached_at' => (int) $cached['at'], 'stale' => true];
|
|
}
|
|
$res = theme_catalog_fetch();
|
|
if ($res['ok']) {
|
|
set_setting('theme_catalog_cache', json_encode(['key' => $key, 'at' => time(), 'themes' => $res['themes']], JSON_UNESCAPED_UNICODE));
|
|
set_setting('theme_catalog_fail_at', '0');
|
|
$res['cached_at'] = time();
|
|
return $res;
|
|
}
|
|
set_setting('theme_catalog_fail_at', (string) time());
|
|
if (!$refresh && $usable) return ['ok' => true, 'themes' => $cached['themes'], 'cached_at' => (int) $cached['at'], 'stale' => true];
|
|
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&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];
|
|
}
|