413 lines
15 KiB
PHP
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;
|
|
}
|