', , ). */ 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 ''; } return ''; } /** 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]; }