// SPDX-License-Identifier: GPL-3.0-or-later namespace Icinga\Web; use Exception; use Icinga\Application\Hook\ThemeLoaderHook; use Icinga\Application\Icinga; use Icinga\Application\Logger; use Icinga\Authentication\Auth; use Icinga\Exception\IcingaException; /** * Send CSS for Web 2 and all loaded modules to the client */ class StyleSheet { /** * The name of the default theme * * @var string */ const DEFAULT_THEME = 'Icinga'; /** * The name of the default theme mode * * @var string */ const DEFAULT_MODE = 'none'; /** * The themes that are compatible with the default theme * * @var array */ const THEME_WHITELIST = [ 'colorblind', 'high-contrast', 'Winter' ]; /** * Sequence that signals that a theme supports light mode * * @var string */ const LIGHT_MODE_IDENTIFIER = '@light-mode:'; /** * Array of core LESS files Web 2 sends to the client * * @var string[] */ protected static $lessFiles = [ '../application/fonts/fontello-ifont/css/ifont-embedded.css', 'css/vendor/normalize.css', 'css/icinga/base.less', 'css/icinga/badges.less', 'css/icinga/configmenu.less', 'css/icinga/mixins.less', 'css/icinga/grid.less', 'css/icinga/nav.less', 'css/icinga/main.less', 'css/icinga/animation.less', 'css/icinga/layout.less', 'css/icinga/layout-structure.less', 'css/icinga/menu.less', 'css/icinga/tabs.less', 'css/icinga/forms.less', 'css/icinga/setup.less', 'css/icinga/widgets.less', 'css/icinga/login.less', 'css/icinga/about.less', 'css/icinga/controls.less', 'css/icinga/dev.less', 'css/icinga/spinner.less', 'css/icinga/compat.less', 'css/icinga/print.less', 'css/icinga/responsive.less', 'css/icinga/modal.less', 'css/icinga/audit.less', 'css/icinga/health.less', 'css/icinga/php-diff.less', 'css/icinga/pending-migration.less', ]; /** * Application instance * * @var \Icinga\Application\EmbeddedWeb */ protected $app; /** @var string[] Pre-compiled CSS files */ protected $cssFiles = []; /** * Less compiler * * @var LessCompiler */ protected $lessCompiler; /** * Path to the public directory * * @var string */ protected $pubPath; /** * Create the StyleSheet */ public function __construct() { $app = Icinga::app(); $this->app = $app; $this->lessCompiler = new LessCompiler(); $this->pubPath = $app->getBaseDir('public'); $this->collect(); } /** * Collect Web 2 and module LESS files and add them to the LESS compiler */ protected function collect() { foreach ($this->app->getLibraries() as $library) { foreach ($library->getCssAssets() as $lessFile) { if (substr($lessFile, -4) === '.css') { $this->cssFiles[] = $lessFile; } else { $this->lessCompiler->addLessFile($lessFile); } } } foreach (self::$lessFiles as $lessFile) { $this->lessCompiler->addLessFile($this->pubPath . '/' . $lessFile); } $auth = Auth::getInstance(); $mm = $this->app->getModuleManager(); foreach ($mm->getLoadedModules() as $moduleName => $module) { if ($module->hasCss()) { foreach ($module->getCssFiles() as $lessFilePath) { $this->lessCompiler->addModuleLessFile($moduleName, $lessFilePath); } } } $themingConfig = $this->app->getConfig()->getSection('themes'); $defaultTheme = $themingConfig->get('default'); $theme = null; if ($defaultTheme !== null && $defaultTheme !== self::DEFAULT_THEME) { $theme = $defaultTheme; } if (! (bool) $themingConfig->get('disabled', false)) { if ($auth->isAuthenticated()) { $userTheme = $auth->getUser()->getPreferences()->getValue('icingaweb', 'theme'); if ($userTheme !== null) { $theme = $userTheme; } } } if ($themePath = self::getThemeFile($theme)) { if ($this->app->isCli() || is_file($themePath) && is_readable($themePath)) { $this->lessCompiler->setTheme($themePath); } else { $themePath = null; Logger::warning(sprintf( 'Theme "%s" set by user "%s" has not been found.', $theme, $auth->isAuthenticated() ? $auth->getUser()->getUsername() : 'anonymous' )); } } if (! $themePath || in_array($theme, self::THEME_WHITELIST, true)) { $this->lessCompiler->addLessFile($this->pubPath . '/css/icinga/login-orbs.less'); } $mode = 'none'; if ($auth->isAuthenticated()) { $file = $themePath !== null ? @file_get_contents($themePath) : false; if (! $file || strpos($file, self::LIGHT_MODE_IDENTIFIER) !== false) { $mode = $auth->getUser()->getPreferences()->getValue('icingaweb', 'theme_mode', self::DEFAULT_MODE); } } $this->lessCompiler->setThemeMode($this->pubPath . '/css/modes/'. $mode . '.less'); } /** * Get all collected files * * @return string[] */ protected function getFiles(): array { return array_merge($this->cssFiles, $this->lessCompiler->getLessFiles()); } /** * Get the stylesheet for PDF export * * @return $this */ public static function forPdf() { $styleSheet = new self(); $styleSheet->lessCompiler->setTheme(null); $styleSheet->lessCompiler->setThemeMode($styleSheet->pubPath . '/css/modes/none.less'); $styleSheet->lessCompiler->addLessFile($styleSheet->pubPath . '/css/pdf/pdfprint.less'); // TODO(el): Caching return $styleSheet; } /** * Render the stylesheet * * @param bool $minified Whether to compress the stylesheet * * @return string CSS */ public function render($minified = false) { if ($minified) { $this->lessCompiler->compress(); } $css = ''; foreach ($this->cssFiles as $cssFile) { $css .= file_get_contents($cssFile); } return $css . $this->lessCompiler->render(); } /** * Send the stylesheet to the client * * Does not cache the stylesheet if the HTTP header Cache-Control or Pragma is set to no-cache. * * @param bool $minified Whether to compress the stylesheet */ public static function send($minified = false) { $styleSheet = new self(); $request = $styleSheet->app->getRequest(); $response = $styleSheet->app->getResponse(); $response->setHeader('Cache-Control', 'private,no-cache,must-revalidate', true); $noCache = $request->getHeader('Cache-Control') === 'no-cache' || $request->getHeader('Pragma') === 'no-cache'; $collectedFiles = $styleSheet->getFiles(); if (! $noCache && FileCache::etagMatchesFiles($collectedFiles)) { $response ->setHttpResponseCode(304) ->sendHeaders(); return; } $etag = FileCache::etagForFiles($collectedFiles); $response->setHeader('ETag', $etag, true) ->setHeader('Content-Type', 'text/css', true); $cacheFile = 'icinga-' . $etag . ($minified ? '.min' : '') . '.css'; $cache = FileCache::instance(); if (! $noCache && $cache->has($cacheFile)) { $response->setBody($cache->get($cacheFile)); } else { $css = $styleSheet->render($minified); $response->setBody($css); $cache->store($cacheFile, $css); } $response->sendResponse(); } /** * Render the stylesheet * * @return string */ public function __toString() { try { return $this->render(); } catch (Exception $e) { Logger::error($e); return IcingaException::describe($e); } } /** * Get the path to the current LESS theme file * * @param $theme * * @return string|null Return null if self::DEFAULT_THEME is set as theme, path otherwise */ public static function getThemeFile($theme) { $app = Icinga::app(); if ($theme && $theme !== self::DEFAULT_THEME) { if (ThemeLoaderHook::isRegistered()) { try { $path = Hook::first('ThemeLoader')->getThemeFile($theme); } catch (Exception $e) { Logger::error('Failed to call ThemeLoader hook: %s', $e); $path = null; } if ($path !== null) { return $path; } } if (($pos = strpos($theme, '/')) !== false) { $moduleName = substr($theme, 0, $pos); $theme = substr($theme, $pos + 1); if ($app->getModuleManager()->hasLoaded($moduleName)) { $module = $app->getModuleManager()->getModule($moduleName); return $module->getCssDir() . '/themes/' . $theme . '.less'; } } else { return $app->getBaseDir('public') . '/css/themes/' . $theme . '.less'; } } return null; } }