Files
deskhub/DeskHub-Webseite/includes/themes.php
T
2026-10-16 19:12:18 +00:00

413 lines
15 KiB
PHP

<?php
declare(strict_types=1);
const CUSTOM_THEME_MAX_ARCHIVE_SIZE = 10 * 1024 * 1024;
const CUSTOM_THEME_MAX_EXTRACTED_SIZE = 20 * 1024 * 1024;
const CUSTOM_THEME_MAX_FILES = 100;
function custom_themes_directory(): string
{
return rtrim((string) (getenv('DESKHUB_THEMES_DIR') ?: dirname(__DIR__) . '/assets/themes'), '/' . chr(92));
}
/**
* @return array<int, array{id: string, name: string, author: string, installed_at: string, version: string, theme_version: string}>
*/
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;
}