From 141aa4f820c2a1ea1606b4a1b1f2f4a937556646 Mon Sep 17 00:00:00 2001 From: Jan-Hendrik Willms Date: Thu, 24 Jul 2025 15:44:21 +0200 Subject: use plugin assets for theme css, fixes #5737 Closes #5737 Merge request studip/studip!4366 --- lib/classes/JsonApi/Schemas/Theme.php | 36 ++++++----- lib/classes/PageLayout.php | 18 +++--- lib/models/Theme.php | 115 +++++++++++++++++++++++++++++++--- public/assets.php | 93 +++++++++++++-------------- public/theme.php | 37 ----------- 5 files changed, 185 insertions(+), 114 deletions(-) delete mode 100644 public/theme.php diff --git a/lib/classes/JsonApi/Schemas/Theme.php b/lib/classes/JsonApi/Schemas/Theme.php index 8b8c09f..dfb7d1f 100644 --- a/lib/classes/JsonApi/Schemas/Theme.php +++ b/lib/classes/JsonApi/Schemas/Theme.php @@ -2,10 +2,7 @@ namespace JsonApi\Schemas; -use JsonApi\Schemas\SchemaProvider; use Neomerx\JsonApi\Contracts\Schema\ContextInterface; -use Neomerx\JsonApi\Schema\Link; -use Neomerx\JsonApi\Contracts\Schema\LinkInterface; class Theme extends SchemaProvider { @@ -25,22 +22,23 @@ class Theme extends SchemaProvider public function getAttributes($resource, ContextInterface $context): iterable { return [ - 'name' => $resource['name'], - 'active' => (bool)$resource['active'], - 'origin' => $resource['origin'], - 'studip_min_version' => $resource['studip_min_version'], - 'studip_max_version' => $resource['studip_max_version'], - 'author' => $resource['author'], - 'description' => $resource['description'], - 'type' => $resource['type'], - 'values' => empty($resource['values']) ? null : json_decode($resource['values']), - - 'mkdate' => date('c', $resource['mkdate']), - 'chdate' => date('c', $resource['chdate']), + 'name' => $resource->name, + 'active' => (bool) $resource->active, + 'origin' => $resource->origin, + 'studip_min_version' => $resource->studip_min_version, + 'studip_max_version' => $resource->studip_max_version, + 'author' => $resource->author, + 'description' => $resource->description, + 'type' => $resource->type, + 'values' => $resource->values->getArrayCopy() ?: null, + + 'mkdate' => date('c', $resource->mkdate), + 'chdate' => date('c', $resource->chdate), ]; } /** + * @param \Theme $resource * @SuppressWarnings(PHPMD.UnusedFormalParameter) */ public function getRelationships($resource, ContextInterface $context): iterable @@ -48,15 +46,21 @@ class Theme extends SchemaProvider return []; } + /** + * @param \Theme $resource + */ public function hasResourceMeta($resource): bool { return true; } + /** + * @param \Theme $resource + */ public function getResourceMeta($resource): iterable { return [ 'colorKeyCategories' => $resource->getColorKeyCategories(), ]; } -} \ No newline at end of file +} diff --git a/lib/classes/PageLayout.php b/lib/classes/PageLayout.php index f7d21c9..c529fa6 100644 --- a/lib/classes/PageLayout.php +++ b/lib/classes/PageLayout.php @@ -142,6 +142,17 @@ class PageLayout self::addHeadElement('script', [], 'window.Vue.use = () => {};'); self::addStylesheet('studip-base.css?v=' . $v, ['media' => 'screen']); + + try { + $old_base = URLHelper::setBaseURL($GLOBALS['ABSOLUTE_URI_STUDIP']); + self::addHeadElement('link', [ + 'rel' => 'stylesheet', + 'href' => Theme::getDownloadURL(), + ]); + URLHelper::setBaseURL($old_base); + } catch (Exception) { + } + self::addScript('studip-base.js?v=' . $v); self::addScript('studip-wysiwyg.js?v=' . $v); @@ -161,13 +172,6 @@ class PageLayout URLHelper::setBaseURL($old_base); } - - $old_base = URLHelper::setBaseURL($GLOBALS['ABSOLUTE_URI_STUDIP']); - self::addHeadElement('link', [ - 'rel' => 'stylesheet', - 'href' => URLHelper::getURL('theme.php', ignore_registered_params: true) - ]); - URLHelper::setBaseURL($old_base); } /** diff --git a/lib/models/Theme.php b/lib/models/Theme.php index 2b25d6e..824d8cd 100644 --- a/lib/models/Theme.php +++ b/lib/models/Theme.php @@ -2,10 +2,12 @@ /** * Theme.php - Stud.IP Theme Model Class - * + * * @author Ron Lucke * @license GPL2 or any later version - * + * + * @property int $id + * @property bool $active * @property string $name * @property string $origin * @property string $version @@ -13,8 +15,8 @@ * @property string $studip_max_version * @property string $author * @property string $description - * @property \JSONArrayObject $values * @property string $type + * @property JSONArrayObject $values * @property int $mkdate database column * @property int $chdate database column */ @@ -57,9 +59,37 @@ $config['db_table'] = 'themes'; $config['serialized_fields']['values'] = JSONArrayObject::class; + $config['registered_callbacks']['after_store'][] = function (Theme $theme): void { + if ( + $theme->isFieldDirty('active') + || ( + $theme->active + && $theme->isFieldDirty('values') + ) + ) { + self::loadActiveThemes(true); + self::getThemeAsset()->writeContent(self::getActiveCSS()); + } + }; + parent::configure($config); } + public static function getThemeAsset(): PluginAsset + { + $asset = new PluginAsset('studip-theme'); + if ($asset->isNew()) { + $asset->plugin_id = 0; + $asset->type = 'css'; + $asset->filename = 'theme.css'; + $asset->storagename = 'theme.css'; + $asset->store(); + + $asset->writeContent(self::getActiveCSS()); + } + return $asset; + } + /** * @return static[] */ @@ -72,24 +102,93 @@ ]; } + public static function getDownloadURL(): string + { + $asset = self::getThemeAsset(); + return URLHelper::getLink( + "assets.php/css/{$asset->id}#{$asset->filename}", + ['v' => $asset->chdate], + true + ); + + } + + public static function getActiveCSS(): string + { + $css = ''; + foreach (self::getActiveThemes() as $theme) { + if ($theme) { + $css .= $theme->render() . PHP_EOL; + } + } + return $css; + } + public static function getActiveLightTheme(): ?static { - return self::findOneBySQL('active = 1 AND type = "light"'); + return self::loadActiveThemes()['light']; } public static function getActiveDarkTheme(): ?static { - return self::findOneBySQL('active = 1 AND type = "dark"'); + return self::loadActiveThemes()['dark']; } public static function getActiveHighContrastTheme(): ?static { - return self::findOneBySQL('active = 1 AND type = "high-contrast"'); + return self::loadActiveThemes()['high-contrast']; + } + + protected static ?array $active_themes = null; + + public static function loadActiveThemes(bool $force = false): array + { + if ($force || self::$active_themes === null) { + self::$active_themes = [ + 'light' => null, + 'dark' => null, + 'high-contrast' => null, + ]; + self::findEachBySQL( + function (self $theme): void { + self::$active_themes[$theme->type] = $theme; + }, + 'active = 1' + ); + } + return self::$active_themes; } - public static function getcolorKeyCategories(): array + public static function getColorKeyCategories(): array { return self::COLOR_KEY_CATEGORIES; } - } \ No newline at end of file + public function render(): string + { + $lines = []; + + $indent = ' '; + if ($this->type === 'dark') { + $lines[] = '@media (prefers-color-scheme: dark) {'; + } elseif ($this->type === 'high-contrast') { + $lines[] = '@media (prefers-contrast: more) {'; + } else { + $indent = ''; + } + + $lines[] = $indent . ':root {'; + foreach ($this->values as $name => $value) { + + if ($value !== '') { + $lines[] = $indent . " {$name}: {$value};"; + } + } + $lines[] = $indent . '}'; + + if ($indent !== '') { + $lines[] = '}'; + } + return implode(PHP_EOL, $lines); + } + } diff --git a/public/assets.php b/public/assets.php index 3610a13..886886b 100644 --- a/public/assets.php +++ b/public/assets.php @@ -12,51 +12,52 @@ * @since Stud.IP 3.4 */ -require_once '../lib/bootstrap.php'; - -// Obtain request information -$uri = ltrim(Request::pathInfo(), '/'); -list($type, $id) = explode('/', $uri, 2); - -// Setup response -$response = new Trails\Response(); - -// Create response -if (!$type || !$id) { - // Invalid call - $response->set_status(400); -} elseif (!in_array($type, ['css', 'js'])) { - // Invalid type - $response->set_status(501); -} elseif (!PluginAsset::exists($id)) { - // Asset does not exist - $response->set_status(404); -} else { - // Load asset - $model = PluginAsset::find($id); - if (isset($_SERVER['HTTP_IF_MODIFIED_SINCE']) && $model->chdate <= strtotime($_SERVER['HTTP_IF_MODIFIED_SINCE'])) { - // Cached and still valid - $response->set_status(304); - } else { - // Output asset - $asset = new Assets\PluginAsset($model); - try { - $response->set_body($asset->getContent()); - - // Set appropriate header - $response->add_header('Content-Type', $type === 'css' ? 'text/css' : 'application/javascript'); - $response->add_header('Content-Length', $model->size); - $response->add_header('Content-Disposition', 'inline; ' . encode_header_parameter('filename', $model->filename)); - - // Store cache information - if (Studip\ENV !== 'development') { - $response->add_header('Last-Modified', gmdate('D, d M Y H:i:s', $model->chdate) . ' GMT'); - $response->add_header('Expires', gmdate('D, d M Y H:i:s', $model->chdate + PluginAsset::CACHE_DURATION) . ' GMT'); - } - } catch (Exception $e) { - $asset->delete(); - $response->set_status(500); +use Psr\Http\Message\ResponseInterface; +use Psr\Http\Message\ServerRequestInterface; +use Slim\Factory\AppFactory; + +require_once __DIR__ .'/../lib/bootstrap.php'; + +// Build PHP_DI Container +$container = app(); + +// Instantiate the app +AppFactory::setContainer($container); +$app = AppFactory::create(); +$app->setBasePath($GLOBALS['CANONICAL_RELATIVE_PATH_STUDIP'] . 'assets.php'); + +$app->get('/{type:js|css}/{id}', function (ServerRequestInterface $request, ResponseInterface $response, array $args) use ($app) { + $model = PluginAsset::find($args['id']); + if (!$model) { + return $response->withStatus(404); + } + + if ( + $request->hasHeader('If-Modified-Since') + && $model->chdate <= strtotime($request->getHeaderLine('If-Modified-Since')[0]) + ) { + return $response->withStatus(304); + } + + $asset = new Assets\PluginAsset($model); + + try { + $response->getBody()->write($asset->getContent()); + + $response = $response->withHeader('Content-Type', $args['type'] === 'css' ? 'text/css' : 'application/javascript'); + $response = $response->withHeader('Content-Length', $model->size); + + // Store cache information + if (Studip\ENV !== 'development') { + $response = $response->withHeader('Last-Modified', gmdate('D, d M Y H:i:s', $model->chdate) . ' GMT'); + $response = $response->withHeader('Expires', gmdate('D, d M Y H:i:s', $model->chdate + PluginAsset::CACHE_DURATION) . ' GMT'); } + + return $response; + } catch (Exception $e) { + $asset->delete(); + return $response->withStatus(500); } -} -$response->output(); +}); + +$app->run(); diff --git a/public/theme.php b/public/theme.php deleted file mode 100644 index ad24410..0000000 --- a/public/theme.php +++ /dev/null @@ -1,37 +0,0 @@ - $value) { - if ($value !== '') { - echo " $name: $value;" . PHP_EOL; - } - } - echo "}" . PHP_EOL; -} - -foreach ($themes as $themeName => $themeData) { - if ($themeName === 'high-contrast') { - echo "@media (prefers-contrast: more) {" . PHP_EOL; - } elseif (in_array($themeName, ['light', 'dark'])) { - echo "@media (prefers-color-scheme: $themeName) {" . PHP_EOL; - } else { - continue; - } - - echo " :root {" . PHP_EOL; - $values = $themeData['values'] ?? []; - foreach ($values as $name => $value) { - if ($value !== '') { - echo " $name: $value;" . PHP_EOL; - } - } - - echo " }" . PHP_EOL; - echo "}" . PHP_EOL; -} -- cgit v1.0