*/ function list_custom_themes(?string $themesDirectory = null): array { $themesDirectory ??= custom_themes_directory(); if (!is_dir($themesDirectory)) { return []; } $themes = []; foreach (scandir($themesDirectory) ?: [] as $id) { if (!preg_match('/^[a-f0-9]{24}$/', $id)) { continue; } $directory = $themesDirectory . DIRECTORY_SEPARATOR . $id; $metadataPath = $directory . DIRECTORY_SEPARATOR . 'theme.json'; $stylesheetPath = $directory . DIRECTORY_SEPARATOR . 'theme.css'; if (!is_dir($directory) || !is_file($metadataPath) || !is_file($stylesheetPath)) { continue; } $metadata = json_decode((string) file_get_contents($metadataPath), true); if (!is_array($metadata) || !isset($metadata['name'], $metadata['installed_at'], $metadata['version'])) { continue; } $themes[] = [ 'id' => $id, 'name' => (string) $metadata['name'], 'author' => (string) ($metadata['author'] ?? 'DeskHub'), 'installed_at' => (string) $metadata['installed_at'], 'version' => (string) $metadata['version'], 'theme_version' => (string) ($metadata['theme_version'] ?? '1.0.0'), ]; } usort($themes, static fn(array $a, array $b): int => strcasecmp($a['name'], $b['name'])); return $themes; } function active_custom_theme_stylesheet(): ?string { $activeId = get_setting('active_custom_theme', ''); if (!$activeId || !preg_match('/^[a-f0-9]{24}$/', $activeId)) { return null; } foreach (list_custom_themes() as $theme) { if ($theme['id'] === $activeId) { return '/assets/themes/' . rawurlencode($theme['id']) . '/theme.css?v=' . rawurlencode($theme['version']); } } return null; } function normalize_custom_theme_name(string $name): string { return trim((string) preg_replace( '/\s+/', ' ', str_replace(['_', '-'], ' ', $name) )); } /** * @return array{id: string, name: string, author: string, installed_at: string, version: string, theme_version: string, replaced: bool} */ function install_custom_theme_upload(array $file, ?string $themesDirectory = null): array { if (!class_exists('ZipArchive')) { throw new RuntimeException('Die PHP-Erweiterung ZIP ist auf diesem Server nicht installiert.'); } if (empty($file['name'])) { throw new RuntimeException('Bitte eine ZIP-Datei auswählen.'); } if (($file['error'] ?? UPLOAD_ERR_NO_FILE) !== UPLOAD_ERR_OK) { throw new RuntimeException('Der Theme-Upload ist fehlgeschlagen.'); } if (($file['size'] ?? 0) > CUSTOM_THEME_MAX_ARCHIVE_SIZE) { throw new RuntimeException('Das Theme darf maximal 10 MB groß sein.'); } if (strtolower(pathinfo((string) $file['name'], PATHINFO_EXTENSION)) !== 'zip') { throw new RuntimeException('Bitte ein Theme als ZIP-Datei hochladen.'); } $mimeType = null; if (function_exists('finfo_open')) { $finfo = finfo_open(FILEINFO_MIME_TYPE); if ($finfo !== false) { $mimeType = finfo_file($finfo, (string) $file['tmp_name']) ?: null; finfo_close($finfo); } } if ($mimeType !== null && !in_array($mimeType, [ 'application/zip', 'application/x-zip', 'application/x-zip-compressed', 'application/octet-stream', ], true)) { throw new RuntimeException('Die hochgeladene Datei ist kein gültiges ZIP-Archiv.'); } $name = normalize_custom_theme_name(pathinfo((string) $file['name'], PATHINFO_FILENAME)); if ($name === '') { throw new RuntimeException('Der Theme-Dateiname ist ungültig.'); } return install_custom_theme_archive((string) $file['tmp_name'], $name, $themesDirectory); } /** * @return array{id: string, name: string, author: string, installed_at: string, version: string, theme_version: string, replaced: bool} */ function install_custom_theme_archive( string $archivePath, string $displayName, ?string $themesDirectory = null ): array { if (!class_exists('ZipArchive')) { throw new RuntimeException('Die PHP-Erweiterung ZIP ist auf diesem Server nicht installiert.'); } $displayName = normalize_custom_theme_name($displayName); if ($displayName === '') { throw new RuntimeException('Bitte einen Namen für das Theme angeben.'); } if (mb_strlen($displayName) > 80) { throw new RuntimeException('Der Theme-Name darf maximal 80 Zeichen lang sein.'); } $themesDirectory ??= custom_themes_directory(); if (!is_dir($themesDirectory) && !mkdir($themesDirectory, 0755, true) && !is_dir($themesDirectory)) { throw new RuntimeException('Das Theme-Verzeichnis konnte nicht erstellt werden.'); } $zip = new ZipArchive(); if ($zip->open($archivePath) !== true) { throw new RuntimeException('Das ZIP-Archiv konnte nicht geöffnet werden.'); } $matchingThemes = array_values(array_filter( list_custom_themes($themesDirectory), static fn(array $theme): bool => strcasecmp( normalize_custom_theme_name($theme['name']), $displayName ) === 0 )); $activeThemeId = function_exists('get_setting') ? (string) get_setting('active_custom_theme', '') : ''; $existingTheme = null; foreach ($matchingThemes as $matchingTheme) { if ($matchingTheme['id'] === $activeThemeId) { $existingTheme = $matchingTheme; break; } } $existingTheme ??= $matchingThemes[0] ?? null; $replaced = $existingTheme !== null; $themeId = $existingTheme['id'] ?? bin2hex(random_bytes(12)); $operationId = bin2hex(random_bytes(12)); $stagingDirectory = $themesDirectory . DIRECTORY_SEPARATOR . '.tmp-' . $operationId; $finalDirectory = $themesDirectory . DIRECTORY_SEPARATOR . $themeId; $backupDirectory = $themesDirectory . DIRECTORY_SEPARATOR . '.backup-' . $operationId; $allowedExtensions = ['css', 'png', 'jpg', 'jpeg', 'gif', 'webp', 'woff', 'woff2']; $fileCount = 0; $totalSize = 0; $hasRootStylesheet = false; try { if (!mkdir($stagingDirectory, 0755, true)) { throw new RuntimeException('Das Theme konnte nicht vorbereitet werden.'); } for ($index = 0; $index < $zip->numFiles; $index++) { $entry = $zip->statIndex($index); if ($entry === false) { throw new RuntimeException('Das ZIP-Archiv enthält einen ungültigen Eintrag.'); } $entryName = str_replace('\\', '/', (string) $entry['name']); if (!is_safe_custom_theme_path($entryName)) { throw new RuntimeException('Das ZIP-Archiv enthält einen unsicheren Dateipfad.'); } if (str_ends_with($entryName, '/')) { continue; } $fileCount++; $declaredSize = (int) ($entry['size'] ?? 0); $totalSize += $declaredSize; if ($fileCount > CUSTOM_THEME_MAX_FILES || $totalSize > CUSTOM_THEME_MAX_EXTRACTED_SIZE) { throw new RuntimeException('Das Theme enthält zu viele oder zu große Dateien.'); } $extension = strtolower(pathinfo($entryName, PATHINFO_EXTENSION)); if (!in_array($extension, $allowedExtensions, true)) { throw new RuntimeException("Dateityp nicht erlaubt: {$entryName}"); } if ($entryName === 'theme.css') { $hasRootStylesheet = true; } $destination = $stagingDirectory . DIRECTORY_SEPARATOR . str_replace('/', DIRECTORY_SEPARATOR, $entryName); $destinationDirectory = dirname($destination); if (!is_dir($destinationDirectory) && !mkdir($destinationDirectory, 0755, true) && !is_dir($destinationDirectory)) { throw new RuntimeException('Ein Theme-Unterverzeichnis konnte nicht erstellt werden.'); } $input = $zip->getStream((string) $entry['name']); $output = fopen($destination, 'xb'); if ($input === false || $output === false) { if (is_resource($input)) { fclose($input); } if (is_resource($output)) { fclose($output); } throw new RuntimeException("Theme-Datei konnte nicht entpackt werden: {$entryName}"); } $copied = stream_copy_to_stream($input, $output, CUSTOM_THEME_MAX_EXTRACTED_SIZE + 1); fclose($input); fclose($output); if ($copied === false || $copied !== $declaredSize) { throw new RuntimeException("Theme-Datei ist beschädigt: {$entryName}"); } } if (!$hasRootStylesheet) { throw new RuntimeException('Im Stammverzeichnis des ZIP-Archivs fehlt die Datei theme.css.'); } $stylesheetMetadata = read_custom_theme_stylesheet_metadata( $stagingDirectory . DIRECTORY_SEPARATOR . 'theme.css' ); $metadata = [ 'name' => $displayName, 'author' => $stylesheetMetadata['author'], 'theme_version' => $stylesheetMetadata['version'], 'installed_at' => date('Y-m-d H:i:s'), 'version' => time() . '-' . bin2hex(random_bytes(4)), ]; $metadataJson = json_encode($metadata, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR); if (file_put_contents($stagingDirectory . DIRECTORY_SEPARATOR . 'theme.json', $metadataJson) === false) { throw new RuntimeException('Die Theme-Metadaten konnten nicht gespeichert werden.'); } if ($replaced) { if (!rename($finalDirectory, $backupDirectory)) { throw new RuntimeException('Das vorhandene Theme konnte nicht für die Aktualisierung vorbereitet werden.'); } if (!rename($stagingDirectory, $finalDirectory)) { if (!rename($backupDirectory, $finalDirectory)) { throw new RuntimeException( 'Das Theme konnte nicht aktualisiert und die vorherige Version nicht wiederhergestellt werden.' ); } throw new RuntimeException('Das Theme konnte nicht aktualisiert werden.'); } remove_custom_theme_directory($backupDirectory); } elseif (!rename($stagingDirectory, $finalDirectory)) { throw new RuntimeException('Das Theme konnte nicht installiert werden.'); } foreach ($matchingThemes as $matchingTheme) { if ($matchingTheme['id'] !== $themeId && !delete_custom_theme($matchingTheme['id'], $themesDirectory)) { throw new RuntimeException( 'Das Theme wurde aktualisiert, aber ein vorhandenes Duplikat konnte nicht entfernt werden.' ); } } return ['id' => $themeId] + $metadata + ['replaced' => $replaced]; } finally { $zip->close(); if (is_dir($stagingDirectory)) { remove_custom_theme_directory($stagingDirectory); } } } /** * @return array{author: string, version: string} */ function read_custom_theme_stylesheet_metadata(string $stylesheetPath): array { $header = (string) file_get_contents($stylesheetPath, false, null, 0, 4096); $author = preg_match('/^\s*\*\s*Author:\s*(.+?)\s*$/mi', $header, $match) ? trim($match[1]) : 'Nicht angegeben'; $version = preg_match('/^\s*\*\s*Version:\s*([0-9]+(?:\.[0-9]+){0,2})\s*$/mi', $header, $match) ? $match[1] : '1.0.0'; if (mb_strlen($author) > 80) { throw new RuntimeException('Der Theme-Autor darf maximal 80 Zeichen lang sein.'); } return [ 'author' => $author !== '' ? $author : 'Nicht angegeben', 'version' => $version, ]; } function delete_custom_theme(string $themeId, ?string $themesDirectory = null): bool { if (!preg_match('/^[a-f0-9]{24}$/', $themeId)) { return false; } $themesDirectory ??= custom_themes_directory(); $directory = $themesDirectory . DIRECTORY_SEPARATOR . $themeId; if (!is_dir($directory)) { return false; } $trashDirectory = $themesDirectory . DIRECTORY_SEPARATOR . '.delete-' . $themeId . '-' . bin2hex(random_bytes(4)); for ($attempt = 0; $attempt < 4; $attempt++) { if (@rename($directory, $trashDirectory)) { return remove_custom_theme_directory($trashDirectory); } if ($attempt < 3) { usleep(250000); } } return remove_custom_theme_directory($directory); } function is_safe_custom_theme_path(string $path): bool { if ($path === '' || str_contains($path, "\0") || str_starts_with($path, '/') || str_contains($path, ':')) { return false; } foreach (explode('/', rtrim($path, '/')) as $part) { if ($part === '' || $part === '.' || $part === '..' || str_starts_with($part, '.')) { return false; } } return true; } function remove_custom_theme_directory(string $directory): bool { if (!is_dir($directory)) { return true; } foreach (scandir($directory) ?: [] as $entry) { if ($entry === '.' || $entry === '..') { continue; } $path = $directory . DIRECTORY_SEPARATOR . $entry; if (is_dir($path) && !is_link($path)) { if (!remove_custom_theme_directory($path)) { return false; } } else { $deleted = false; for ($attempt = 0; $attempt < 4; $attempt++) { @chmod($path, 0666); if (@unlink($path) || !file_exists($path)) { $deleted = true; break; } clearstatcache(true, $path); if ($attempt < 3) { usleep(250000); } } if (!$deleted) { return false; } } } for ($attempt = 0; $attempt < 4; $attempt++) { if (@rmdir($directory) || !is_dir($directory)) { return true; } clearstatcache(true, $directory); if ($attempt < 3) { usleep(250000); } } return false; }