diff --git a/application/clicommands/ModuleCommand.php b/application/clicommands/ModuleCommand.php index cd3390404..41bc7c377 100644 --- a/application/clicommands/ModuleCommand.php +++ b/application/clicommands/ModuleCommand.php @@ -90,17 +90,21 @@ class ModuleCommand extends Command /** * Enable a given module * - * Usage: icingacli module enable + * Usage: icingacli module enable [--force] */ public function enableAction() { if (! $module = $this->params->shift()) { $module = $this->params->shift('module'); } + + $force = $this->params->shift('force', false); + if (! $module || $this->hasRemainingParams()) { return $this->showUsage(); } - $this->modules->enableModule($module); + + $this->modules->enableModule($module, $force); } /** diff --git a/application/controllers/AboutController.php b/application/controllers/AboutController.php index f856d8435..59e3c203d 100644 --- a/application/controllers/AboutController.php +++ b/application/controllers/AboutController.php @@ -12,6 +12,7 @@ class AboutController extends Controller public function indexAction() { $this->view->version = Version::get(); + $this->view->libraries = Icinga::app()->getLibraries(); $this->view->modules = Icinga::app()->getModuleManager()->getLoadedModules(); $this->view->title = $this->translate('About'); $this->view->tabs = $this->getTabs()->add( diff --git a/application/controllers/ConfigController.php b/application/controllers/ConfigController.php index d6e6328c2..9e070a6da 100644 --- a/application/controllers/ConfigController.php +++ b/application/controllers/ConfigController.php @@ -138,6 +138,8 @@ class ConfigController extends Controller } $this->view->module = $module; + $this->view->libraries = $app->getLibraries(); + $this->view->moduleManager = $manager; $this->view->toggleForm = $toggleForm; $this->view->title = $module->getName(); $this->view->tabs = $module->getConfigTabs()->activate('info'); @@ -168,7 +170,7 @@ class ConfigController extends Controller $form->handleRequest(); } catch (Exception $e) { $this->view->exceptionMessage = $e->getMessage(); - $this->view->moduleName = $form->getValue('name'); + $this->view->moduleName = $form->getValue('identifier'); $this->view->action = 'enable'; $this->render('module-configuration-error'); } @@ -194,7 +196,7 @@ class ConfigController extends Controller $form->handleRequest(); } catch (Exception $e) { $this->view->exceptionMessage = $e->getMessage(); - $this->view->moduleName = $form->getValue('name'); + $this->view->moduleName = $form->getValue('identifier'); $this->view->action = 'disable'; $this->render('module-configuration-error'); } diff --git a/application/controllers/ErrorController.php b/application/controllers/ErrorController.php index 27198fb46..df422d716 100644 --- a/application/controllers/ErrorController.php +++ b/application/controllers/ErrorController.php @@ -18,6 +18,11 @@ use Icinga\Web\Url; */ class ErrorController extends ActionController { + /** + * Regular expression to match exceptions resulting from missing functions/classes + */ + const MISSING_DEP_ERROR = "/Uncaught Error:.*(?:undefined function (\S+)|Class '([^']+)' not found).* in ([^:]+)/"; + /** * {@inheritdoc} */ @@ -44,21 +49,23 @@ class ErrorController extends ActionController $this->innerLayout = 'guest-error'; } + $modules = Icinga::app()->getModuleManager(); + $sourcePath = ltrim($this->_request->get('PATH_INFO'), '/'); + $pathParts = preg_split('~/~', $sourcePath); + $moduleName = array_shift($pathParts); + + $module = null; switch ($error->type) { case Zend_Controller_Plugin_ErrorHandler::EXCEPTION_NO_ROUTE: case Zend_Controller_Plugin_ErrorHandler::EXCEPTION_NO_CONTROLLER: case Zend_Controller_Plugin_ErrorHandler::EXCEPTION_NO_ACTION: - $modules = Icinga::app()->getModuleManager(); - $path = ltrim($this->_request->get('PATH_INFO'), '/'); - $path = preg_split('~/~', $path); - $path = array_shift($path); $this->getResponse()->setHttpResponseCode(404); $this->view->messages = array($this->translate('Page not found.')); if ($isAuthenticated) { - if ($modules->hasInstalled($path) && ! $modules->hasEnabled($path)) { + if ($modules->hasInstalled($moduleName) && ! $modules->hasEnabled($moduleName)) { $this->view->messages[0] .= ' ' . sprintf( $this->translate('Enabling the "%s" module might help!'), - $path + $moduleName ); } } @@ -84,10 +91,29 @@ class ErrorController extends ActionController break; default: $this->getResponse()->setHttpResponseCode(500); + $module = $modules->hasLoaded($moduleName) ? $modules->getModule($moduleName) : null; Logger::error("%s\n%s", $exception, IcingaException::getConfidentialTraceAsString($exception)); break; } + // Try to narrow down why the request has failed + if (preg_match(self::MISSING_DEP_ERROR, $exception->getMessage(), $match)) { + $sourcePath = $match[3]; + foreach ($modules->listLoadedModules() as $name) { + $candidate = $modules->getModule($name); + $modulePath = $candidate->getBaseDir(); + if (substr($sourcePath, 0, strlen($modulePath)) === $modulePath) { + $module = $candidate; + break; + } + } + + if (preg_match('/^(?:Icinga\\\Module\\\(\w+)|(\w+)\\\)/', $match[1] ?: $match[2], $natch)) { + $this->view->requiredModule = isset($natch[1]) ? strtolower($natch[1]) : null; + $this->view->requiredLibrary = isset($natch[2]) ? $natch[2] : null; + } + } + $this->view->messages = array(); if ($this->getInvokeArg('displayExceptions')) { @@ -114,6 +140,7 @@ class ErrorController extends ActionController ->sendResponse(); } + $this->view->module = $module; $this->view->request = $error->request; if (! $isAuthenticated) { $this->view->hideControls = true; diff --git a/application/views/scripts/about/index.phtml b/application/views/scripts/about/index.phtml index d2237d996..080f2bee0 100644 --- a/application/views/scripts/about/index.phtml +++ b/application/views/scripts/about/index.phtml @@ -94,7 +94,27 @@ ) ) ?> -

translate('Loaded modules') ?>

+

translate('Loaded Libraries') ?>

+ + + + + + + + + + + + + + +
translate('Name') ?>translate('Version') ?>
+ escape($library->getName()) ?> + + escape($library->getVersion()) ?: '-' ?> +
+

translate('Loaded Modules') ?>

diff --git a/application/views/scripts/config/module.phtml b/application/views/scripts/config/module.phtml index 684c62f6d..6cadd8a90 100644 --- a/application/views/scripts/config/module.phtml +++ b/application/views/scripts/config/module.phtml @@ -6,9 +6,11 @@ translate('There is no such module installed.') ?> getDependencies(); + $requiredMods = $module->getRequiredModules(); + $requiredLibs = $module->getRequiredLibraries(); $restrictions = $module->getProvidedRestrictions(); $permissions = $module->getProvidedPermissions(); + $unmetDependencies = $moduleManager->hasUnmetDependencies($module->getName()); $state = $moduleData->enabled ? ($moduleData->loaded ? 'enabled' : 'failed') : 'disabled' ?>
@@ -20,9 +22,13 @@ @@ -43,13 +49,58 @@ - diff --git a/application/views/scripts/error/error.phtml b/application/views/scripts/error/error.phtml index 2dd87ee34..51ad9143f 100644 --- a/application/views/scripts/error/error.phtml +++ b/application/views/scripts/error/error.phtml @@ -17,4 +17,41 @@ if (isset($stackTraces)) { } } ?> + + getModuleManager(); ?> + hasUnmetDependencies($module->getName())): ?> +
+

translate( + 'This error might have occurred because module "%s" has unmet dependencies.' + . ' Please check it\'s installation instructions and install missing dependencies.' + ), $module->getName()) ?>

+ getRequiredModules()[$requiredModule])): ?> + hasInstalled($requiredModule)): ?> +

translate( + 'Module "%s" is required and missing. Please install a version of it matching the required one: %s' + ), $requiredModule, $module->getRequiredModules()[$requiredModule]) ?>

+ hasEnabled($requiredModule)): ?> +

translate( + 'Module "%s" is required and installed, but not enabled. Please enable module "%1$s".' + ), $requiredModule) ?>

+ has($requiredModule, $module->getRequiredModules()[$requiredModule])): ?> +

translate( + 'Module "%s" is required and installed, but its version (%s) does not satisfy the required one: %s' + ), $requiredModule, $manager->getModule($requiredModule, false)->getVersion(), $module->getRequiredModules()[$requiredModule]) ?>

+ + getRequiredLibraries()[$requiredLibrary])): ?> + getLibraries(); ?> + has($requiredLibrary)): ?> +

translate( + 'Library "%s" is required and missing. Please install a version of it matching the required one: %s' + ), $requiredLibrary, $module->getRequiredLibraries()[$requiredLibrary]) ?>

+ has($requiredLibrary, $module->getRequiredLibraries()[$requiredLibrary])): ?> +

translate( + 'Library "%s" is required and installed, but its version (%s) does not satisfy the required one: %s' + ), $requiredLibrary, $libraries->get($requiredLibrary)->getVersion(), $module->getRequiredLibraries()[$requiredLibrary]) ?>

+ + +
+ + diff --git a/icingaweb2.ruleset.xml b/icingaweb2.ruleset.xml index 6e1b31e4e..93cb742cc 100644 --- a/icingaweb2.ruleset.xml +++ b/icingaweb2.ruleset.xml @@ -16,6 +16,7 @@ library/Icinga/Application/Cli.php + library/Icinga/Application/StaticWeb.php library/Icinga/Application/EmbeddedWeb.php library/Icinga/Application/functions.php library/Icinga/Application/LegacyWeb.php diff --git a/library/Icinga/Application/ApplicationBootstrap.php b/library/Icinga/Application/ApplicationBootstrap.php index f3d8c086f..04cc930d2 100644 --- a/library/Icinga/Application/ApplicationBootstrap.php +++ b/library/Icinga/Application/ApplicationBootstrap.php @@ -3,6 +3,7 @@ namespace Icinga\Application; +use DirectoryIterator; use ErrorException; use Exception; use LogicException; @@ -62,7 +63,7 @@ abstract class ApplicationBootstrap protected $vendorDir; /** - * Library directory + * Icinga library directory * * @var string */ @@ -89,6 +90,20 @@ abstract class ApplicationBootstrap */ protected $storageDir; + /** + * External library paths + * + * @var string[] + */ + protected $libraryPaths; + + /** + * Loaded external libraries + * + * @var Libraries + */ + protected $libraries; + /** * Icinga class loader * @@ -176,6 +191,20 @@ abstract class ApplicationBootstrap $canonical = realpath($storageDir); $this->storageDir = $canonical ? $canonical : $storageDir; + if ($this->libraryPaths === null) { + $libraryPaths = getenv('ICINGAWEB_LIBDIR'); + if ($libraryPaths !== false) { + $this->libraryPaths = array_filter(array_map( + 'realpath', + explode(':', $libraryPaths) + ), 'is_dir'); + } else { + $this->libraryPaths = is_dir('/usr/share/php-icinga') + ? ['/usr/share/php-icinga'] + : []; + } + } + set_include_path( implode( PATH_SEPARATOR, @@ -197,6 +226,16 @@ abstract class ApplicationBootstrap */ abstract protected function bootstrap(); + /** + * Get loaded external libraries + * + * @return Libraries + */ + public function getLibraries() + { + return $this->libraries; + } + /** * Getter for module manager * @@ -499,6 +538,26 @@ abstract class ApplicationBootstrap return @file_exists($this->config->resolvePath('setup.token')); } + /** + * Load external libraries + * + * @return $this + */ + protected function loadLibraries() + { + $this->libraries = new Libraries(); + foreach ($this->libraryPaths as $libraryPath) { + foreach (new DirectoryIterator($libraryPath) as $path) { + if (! $path->isDot() && is_dir($path->getRealPath())) { + $this->libraries->registerPath($path->getPathname()) + ->registerAutoloader(); + } + } + } + + return $this; + } + /** * Setup default logging * diff --git a/library/Icinga/Application/Cli.php b/library/Icinga/Application/Cli.php index 194a86a52..70bd1c8e3 100644 --- a/library/Icinga/Application/Cli.php +++ b/library/Icinga/Application/Cli.php @@ -38,6 +38,7 @@ class Cli extends ApplicationBootstrap $this->assertRunningOnCli(); $this->setupLogging() ->setupErrorHandling() + ->loadLibraries() ->loadConfig() ->setupTimezone() ->setupInternationalization() diff --git a/library/Icinga/Application/EmbeddedWeb.php b/library/Icinga/Application/EmbeddedWeb.php index 8cf4fe31e..4ca3caca1 100644 --- a/library/Icinga/Application/EmbeddedWeb.php +++ b/library/Icinga/Application/EmbeddedWeb.php @@ -65,6 +65,7 @@ class EmbeddedWeb extends ApplicationBootstrap return $this ->setupZendAutoloader() ->setupErrorHandling() + ->loadLibraries() ->loadConfig() ->setupLogging() ->setupLogger() diff --git a/library/Icinga/Application/Libraries.php b/library/Icinga/Application/Libraries.php new file mode 100644 index 000000000..983381d2f --- /dev/null +++ b/library/Icinga/Application/Libraries.php @@ -0,0 +1,83 @@ +libraries); + } + + /** + * Register a library from the given path + * + * @param string $path + * + * @return Library The registered library + */ + public function registerPath($path) + { + $library = new Library($path); + $this->libraries[] = $library; + + return $library; + } + + /** + * Check if a library with the given name has been registered + * + * Passing a version constraint also verifies that the library's version matches. + * + * @param string $name + * @param string $version + * + * @return bool + */ + public function has($name, $version = null) + { + $library = $this->get($name); + if ($library === null) { + return false; + } elseif ($version === null) { + return true; + } + + $operator = '='; + if (preg_match('/^([<>=]{1,2})\s*v?((?:[\d.]+)(?:\D+)?)$/', $version, $match)) { + $operator = $match[1]; + $version = $match[2]; + } + + return version_compare($library->getVersion(), $version, $operator); + } + + /** + * Get a library by name + * + * @param string $name + * + * @return Library|null + */ + public function get($name) + { + foreach ($this->libraries as $library) { + if ($library->getName() === $name) { + return $library; + } + } + } +} diff --git a/library/Icinga/Application/Libraries/Library.php b/library/Icinga/Application/Libraries/Library.php new file mode 100644 index 000000000..b7024c8a5 --- /dev/null +++ b/library/Icinga/Application/Libraries/Library.php @@ -0,0 +1,224 @@ +path = $path; + } + + /** + * Get this library's path + * + * @return string + */ + public function getPath() + { + return $this->path; + } + + /** + * Get path of this library's JS assets + * + * @return string + */ + public function getJsAssetPath() + { + $this->assets(); + return $this->jsAssetPath; + } + + /** + * Get path of this library's CSS assets + * + * @return string + */ + public function getCssAssetPath() + { + $this->assets(); + return $this->cssAssetPath; + } + + /** + * Get path of this library's static assets + * + * @return string + */ + public function getStaticAssetPath() + { + $this->assets(); + return $this->staticAssetPath; + } + + /** + * Get this library's name + * + * @return string + */ + public function getName() + { + return $this->metaData()['name']; + } + + /** + * Get this library's version + * + * @return string + */ + public function getVersion() + { + if ($this->version === null) { + if (isset($this->metaData()['version'])) { + $this->version = trim(ltrim($this->metaData()['version'], 'v')); + } else { + $versionFile = $this->path . DIRECTORY_SEPARATOR . 'VERSION'; + if (file_exists($versionFile)) { + $this->version = trim(ltrim(file_get_contents($versionFile), 'v')); + } else { + $this->version = ''; + } + } + } + + return $this->version; + } + + /** + * Get this library's JS assets + * + * @return string[] Asset paths + */ + public function getJsAssets() + { + return $this->assets()['js']; + } + + /** + * Get this library's CSS assets + * + * @return string[] Asset paths + */ + public function getCssAssets() + { + return $this->assets()['css']; + } + + /** + * Get this library's static assets + * + * @return string[] Asset paths + */ + public function getStaticAssets() + { + return $this->assets()['static']; + } + + /** + * Register this library's autoloader + * + * @return void + */ + public function registerAutoloader() + { + $autoloaderPath = join(DIRECTORY_SEPARATOR, [$this->path, 'vendor', 'autoload.php']); + if (file_exists($autoloaderPath)) { + require_once $autoloaderPath; + } + } + + /** + * Parse and return this library's metadata + * + * @return array + * + * @throws ConfigurationError + * @throws JsonDecodeException + */ + protected function metaData() + { + if ($this->metaData === null) { + $metaData = file_get_contents($this->path . DIRECTORY_SEPARATOR . 'composer.json'); + if ($metaData === false) { + throw new ConfigurationError('Library at "%s" is not a composerized project', $this->path); + } + + $this->metaData = Json::decode($metaData, true); + } + + return $this->metaData; + } + + /** + * Register and return this library's assets + * + * @return array + */ + protected function assets() + { + if ($this->assets !== null) { + return $this->assets; + } + + $listAssets = function ($type) { + $dir = join(DIRECTORY_SEPARATOR, [$this->path, 'asset', $type]); + if (! is_dir($dir)) { + return []; + } + + $this->{$type . 'AssetPath'} = $dir; + + return new RecursiveIteratorIterator(new RecursiveDirectoryIterator( + $dir, + RecursiveDirectoryIterator::CURRENT_AS_PATHNAME | RecursiveDirectoryIterator::SKIP_DOTS + )); + }; + + $this->assets = []; + + $jsAssets = $listAssets('js'); + $this->assets['js'] = is_array($jsAssets) ? $jsAssets : iterator_to_array($jsAssets); + + $cssAssets = $listAssets('css'); + $this->assets['css'] = is_array($cssAssets) ? $cssAssets : iterator_to_array($cssAssets); + + $staticAssets = $listAssets('static'); + $this->assets['static'] = is_array($staticAssets) ? $staticAssets : iterator_to_array($staticAssets); + + return $this->assets; + } +} diff --git a/library/Icinga/Application/Modules/Manager.php b/library/Icinga/Application/Modules/Manager.php index 1437f39a0..d50b3244d 100644 --- a/library/Icinga/Application/Modules/Manager.php +++ b/library/Icinga/Application/Modules/Manager.php @@ -237,12 +237,13 @@ class Manager * Set the given module to the enabled state * * @param string $name The module to enable + * @param bool $force Whether to ignore unmet dependencies * * @return $this * @throws ConfigurationError When trying to enable a module that is not installed * @throws SystemPermissionException When insufficient permissions for the application exist */ - public function enableModule($name) + public function enableModule($name, $force = false) { if (! $this->hasInstalled($name)) { throw new ConfigurationError( @@ -260,6 +261,17 @@ class Manager ); } + if ($this->hasUnmetDependencies($name)) { + if ($force) { + Logger::warning(t('Enabling module "%s" although it has unmet dependencies'), $name); + } else { + throw new ConfigurationError( + t('Module "%s" can\'t be enabled. Module has unmet dependencies'), + $name + ); + } + } + clearstatcache(true); $target = $this->installedBaseDirs[$name]; $link = $this->enableDir . DIRECTORY_SEPARATOR . $name; @@ -430,6 +442,34 @@ class Manager return array_key_exists($name, $this->loadedModules); } + /** + * Check if a module with the given name is enabled + * + * Passing a version constraint also verifies that the module's version matches. + * + * @param string $name + * @param string $version + * + * @return bool + */ + public function has($name, $version = null) + { + if (! $this->hasEnabled($name)) { + return false; + } elseif ($version === null) { + return true; + } + + $operator = '='; + if (preg_match('/^([<>=]{1,2})\s*v?((?:[\d.]+)(?:\D+)?)$/', $version, $match)) { + $operator = $match[1]; + $version = $match[2]; + } + + $modVersion = ltrim($this->getModule($name)->getVersion(), 'v'); + return version_compare($modVersion, $version, $operator); + } + /** * Get the currently loaded modules * @@ -503,6 +543,36 @@ class Manager return $info; } + /** + * Check if the given module has unmet dependencies + * + * @param string $name + * + * @return bool + */ + public function hasUnmetDependencies($name) + { + $module = $this->getModule($name, false); + + $requiredMods = $module->getRequiredModules(); + foreach ($requiredMods as $moduleName => $moduleVersion) { + if (! $this->has($moduleName, $moduleVersion)) { + return true; + } + } + + $libraries = Icinga::app()->getLibraries(); + + $requiredLibs = $module->getRequiredLibraries(); + foreach ($requiredLibs as $libraryName => $libraryVersion) { + if (! $libraries->has($libraryName, $libraryVersion)) { + return true; + } + } + + return false; + } + /** * Return an array containing all enabled module names as strings * diff --git a/library/Icinga/Application/Modules/Module.php b/library/Icinga/Application/Modules/Module.php index 269666bb0..ade9677c6 100644 --- a/library/Icinga/Application/Modules/Module.php +++ b/library/Icinga/Application/Modules/Module.php @@ -57,6 +57,13 @@ class Module */ private $cssdir; + /** + * Directory for Javascript + * + * @var string + */ + private $jsdir; + /** * Base application directory * @@ -553,10 +560,16 @@ class Module * @param string $path The file's path, relative to the module's asset or base directory * @param string $from The module's name * + * @deprecated Deprecated with v2.9, don't use and depend on a library instead * @return $this */ protected function requireCssFile($path, $from) { + Logger::warning( + 'Module assets are deprecated since v2.9. Please check if the module "%s" provides a library instead.', + $from + ); + $module = self::get($from); $cssAssetDir = join(DIRECTORY_SEPARATOR, [$module->assetDir, 'css']); foreach ($module->getCssAssets() as $assetPath) { @@ -631,10 +644,16 @@ class Module * @param string $path The file's path, relative to the module's asset or base directory * @param string $from The module's name * + * @deprecated Deprecated with v2.9, don't use and depend on a library instead * @return $this */ protected function requireJsFile($path, $from) { + Logger::warning( + 'Module assets are deprecated since v2.9. Please check if the module "%s" provides a library instead.', + $from + ); + $module = self::get($from); $jsAssetDir = join(DIRECTORY_SEPARATOR, [$module->assetDir, 'js']); foreach ($module->getJsAssets() as $assetPath) { @@ -835,12 +854,33 @@ class Module * Get the module dependencies * * @return array + * @deprecated Use method getRequiredModules() instead */ public function getDependencies() { return $this->metadata()->depends; } + /** + * Get required libraries + * + * @return array + */ + public function getRequiredLibraries() + { + return $this->metadata()->libraries; + } + + /** + * Get required modules + * + * @return array + */ + public function getRequiredModules() + { + return $this->metadata()->modules ?: $this->metadata()->depends; + } + /** * Fetch module metadata * @@ -849,16 +889,19 @@ class Module protected function metadata() { if ($this->metadata === null) { - $metadata = (object) array( - 'name' => $this->getName(), - 'version' => '0.0.0', - 'title' => null, - 'description' => '', - 'depends' => array(), - ); + $metadata = (object) [ + 'name' => $this->getName(), + 'version' => '0.0.0', + 'title' => null, + 'description' => '', + 'depends' => [], + 'libraries' => [], + 'modules' => [] + ]; if (file_exists($this->metadataFile)) { $key = null; + $simpleRequires = false; $file = new File($this->metadataFile, 'r'); foreach ($file as $lineno => $line) { $line = rtrim($line); @@ -877,10 +920,8 @@ class Module if (strpos($line, ':') === false) { Logger::debug( - $this->translate( - "Can't process line %d in %s: Line does not specify a key:value pair" - . " nor is it part of the description (indented with a single space)" - ), + "Can't process line %d in %s: Line does not specify a key:value pair" + . " nor is it part of the description (indented with a single space)", $lineno, $this->metadataFile ); @@ -888,27 +929,54 @@ class Module break; } - list($key, $val) = preg_split('/:\s+/', $line, 2); - $key = lcfirst($key); + $parts = preg_split('/:\s+/', $line, 2); + if (count($parts) === 1) { + $parts[] = ''; + } + list($key, $val) = $parts; + + $key = strtolower($key); switch ($key) { + case 'requires': + if ($val) { + $simpleRequires = true; + $key = 'libraries'; + } else { + break; + } + + // Shares the syntax with `Depends` + case ' libraries': + case ' modules': + if ($simpleRequires && $key[0] === ' ') { + Logger::debug( + 'Can\'t process line %d in %s: Requirements already registered by a previous line', + $lineno, + $this->metadataFile + ); + break; + } + + $key = ltrim($key); + // Shares the syntax with `Depends` case 'depends': if (strpos($val, ' ') === false) { - $metadata->depends[$val] = true; + $metadata->{$key}[$val] = true; continue 2; } $parts = preg_split('/,\s+/', $val); foreach ($parts as $part) { if (preg_match('/^(\w+)\s+\((.+)\)$/', $part, $m)) { - $metadata->depends[$m[1]] = $m[2]; + $metadata->{$key}[$m[1]] = $m[2]; } else { // TODO: FAIL? continue; } } - break; + break; case 'description': if ($metadata->title === null) { $metadata->title = $val; @@ -928,8 +996,6 @@ class Module } if ($metadata->description === '') { - // TODO: Check whether the translation module is able to - // extract this $metadata->description = t( 'This module has no description' ); @@ -950,6 +1016,26 @@ class Module return $this->cssdir; } + /** + * Get the module's JS directory + * + * @return string + */ + public function getJsDir() + { + return $this->jsdir; + } + + /** + * Get the module's JS asset directory + * + * @return string + */ + public function getJsAssetDir() + { + return join(DIRECTORY_SEPARATOR, [$this->assetDir, 'js']); + } + /** * Get the module's controller directory * @@ -1312,6 +1398,11 @@ class Module return $this; } + Logger::warning( + 'Module assets are deprecated since v2.9. Please provide a library' + . ' for the parts you want to make available to other modules.' + ); + $listAssets = function ($type) { $dir = join(DIRECTORY_SEPARATOR, [$this->assetDir, $type]); if (! is_dir($dir)) { diff --git a/library/Icinga/Application/StaticWeb.php b/library/Icinga/Application/StaticWeb.php new file mode 100644 index 000000000..5c64dcba7 --- /dev/null +++ b/library/Icinga/Application/StaticWeb.php @@ -0,0 +1,21 @@ +setupErrorHandling() + ->loadLibraries() + ->loadConfig() + ->setupLogging() + ->setupLogger() + ->setupRequest() + ->setupResponse(); + } +} diff --git a/library/Icinga/Application/Web.php b/library/Icinga/Application/Web.php index 834e584fe..a865c7d3a 100644 --- a/library/Icinga/Application/Web.php +++ b/library/Icinga/Application/Web.php @@ -83,6 +83,7 @@ class Web extends EmbeddedWeb ->setupZendAutoloader() ->setupLogging() ->setupErrorHandling() + ->loadLibraries() ->loadConfig() ->setupLogger() ->setupRequest() diff --git a/library/Icinga/Application/webrouter.php b/library/Icinga/Application/webrouter.php index fc6884dca..d9ab30b4a 100644 --- a/library/Icinga/Application/webrouter.php +++ b/library/Icinga/Application/webrouter.php @@ -4,6 +4,7 @@ namespace Icinga\Application; use Icinga\Chart\Inline\PieChart; +use Icinga\Web\Controller\StaticController; use Icinga\Web\JavaScript; use Icinga\Web\StyleSheet; @@ -92,6 +93,11 @@ if (in_array($path, $special)) { $pie = new PieChart(); $pie->initFromRequest(); $pie->toPng(); +} elseif (substr($path, 0, 4) === 'lib/') { + include_once __DIR__ . '/StaticWeb.php'; + $app = StaticWeb::start(); + (new StaticController())->handle($app->getRequest()); + $app->getResponse()->sendResponse(); } elseif (file_exists($baseDir . '/' . $path) && is_file($baseDir . '/' . $path)) { return false; } else { diff --git a/library/Icinga/Web/Controller/StaticController.php b/library/Icinga/Web/Controller/StaticController.php new file mode 100644 index 000000000..7c06dfedb --- /dev/null +++ b/library/Icinga/Web/Controller/StaticController.php @@ -0,0 +1,77 @@ +getRequestUri(), strlen($request->getBaseUrl()) + 4), '/'); + + $library = null; + foreach ($app->getLibraries() as $candidate) { + if (substr($assetPath, 0, strlen($candidate->getName())) === $candidate->getName()) { + $library = $candidate; + $assetPath = ltrim(substr($assetPath, strlen($candidate->getName())), '/'); + break; + } + } + + if ($library === null) { + $app->getResponse() + ->setHttpResponseCode(404); + + return; + } + + $assetRoot = $library->getStaticAssetPath(); + $filePath = $assetRoot . DIRECTORY_SEPARATOR . $assetPath; + + // Doesn't use realpath as it isn't supposed to access files outside asset/static + if (! is_readable($filePath) || ! is_file($filePath)) { + $app->getResponse() + ->setHttpResponseCode(404); + + return; + } + + $fileStat = stat($filePath); + $eTag = sprintf( + '%x-%x-%x', + $fileStat['ino'], + $fileStat['size'], + (float) str_pad($fileStat['mtime'], 16, '0') + ); + + $app->getResponse()->setHeader( + 'Cache-Control', + 'public, max-age=1814400, stale-while-revalidate=604800', + true + ); + + if ($request->getServer('HTTP_IF_NONE_MATCH') === $eTag) { + $app->getResponse() + ->setHttpResponseCode(304); + } else { + $app->getResponse() + ->setHeader('ETag', $eTag) + ->setHeader('Content-Type', mime_content_type($filePath), true) + ->setHeader('Last-Modified', gmdate('D, d M Y H:i:s', $fileStat['mtime']) . ' GMT') + ->setBody(file_get_contents($filePath)); + } + } +} diff --git a/library/Icinga/Web/JavaScript.php b/library/Icinga/Web/JavaScript.php index 6d7059845..fc081c285 100644 --- a/library/Icinga/Web/JavaScript.php +++ b/library/Icinga/Web/JavaScript.php @@ -4,12 +4,17 @@ namespace Icinga\Web; use Icinga\Application\Icinga; -use Icinga\Web\FileCache; +use Icinga\Application\Logger; +use Icinga\Exception\Json\JsonDecodeException; +use Icinga\Util\Json; use JShrink\Minifier; class JavaScript { - protected static $jsFiles = array( + /** @var string */ + const DEFINE_RE = '/(?)/'; + + protected static $jsFiles = [ 'js/helpers.js', 'js/icinga.js', 'js/icinga/logger.js', @@ -36,12 +41,16 @@ class JavaScript 'js/icinga/behavior/filtereditor.js', 'js/icinga/behavior/selectable.js', 'js/icinga/behavior/modal.js' - ); + ]; - protected static $vendorFiles = array( + protected static $vendorFiles = [ 'js/vendor/jquery-3.4.1', 'js/vendor/jquery-migrate-3.1.0' - ); + ]; + + protected static $baseFiles = [ + 'js/define.js' + ]; public static function sendMinified() { @@ -59,52 +68,72 @@ class JavaScript { header('Content-Type: application/javascript'); $basedir = Icinga::app()->getBootstrapDirectory(); + $moduleManager = Icinga::app()->getModuleManager(); + $files = []; $js = $out = ''; $min = $minified ? '.min' : ''; // Prepare vendor file list - $vendorFiles = array(); + $vendorFiles = []; foreach (self::$vendorFiles as $file) { - $vendorFiles[] = $basedir . '/' . $file . $min . '.js'; + $filePath = $basedir . '/' . $file . $min . '.js'; + $vendorFiles[] = $filePath; + $files[] = $filePath; } - // Prepare Icinga JS file list - $jsFiles = array(); + // Prepare base file list + $baseFiles = []; + foreach (self::$baseFiles as $file) { + $filePath = $basedir . '/' . $file; + $baseFiles[] = $filePath; + $files[] = $filePath; + } + + // Prepare library file list + foreach (Icinga::app()->getLibraries() as $library) { + $files = array_merge($files, $library->getJsAssets()); + } + + // Prepare core file list + $coreFiles = []; foreach (self::$jsFiles as $file) { - $jsFiles[] = $basedir . '/' . $file; + $filePath = $basedir . '/' . $file; + $coreFiles[] = $filePath; + $files[] = $filePath; } - $sharedFiles = []; - foreach (Icinga::app()->getModuleManager()->getLoadedModules() as $name => $module) { + $moduleFiles = []; + foreach ($moduleManager->getLoadedModules() as $name => $module) { if ($module->hasJs()) { + $jsDir = $module->getJsDir(); foreach ($module->getJsFiles() as $path) { if (file_exists($path)) { - $jsFiles[] = $path; + $moduleFiles[$name][$jsDir][] = $path; + $files[] = $path; } } } - if ($module->requiresJs()) { - foreach ($module->getJsRequires() as $path) { - $sharedFiles[] = $path; - } + $assetDir = $module->getJsAssetDir(); + foreach ($module->getJsAssets() as $path) { + $moduleFiles[$name][$assetDir][] = $path; + $files[] = $path; } } - $sharedFiles = array_unique($sharedFiles); - $files = array_merge($vendorFiles, $jsFiles, $sharedFiles); - $request = Icinga::app()->getRequest(); $noCache = $request->getHeader('Cache-Control') === 'no-cache' || $request->getHeader('Pragma') === 'no-cache'; header('Cache-Control: public'); + if (! $noCache && FileCache::etagMatchesFiles($files)) { header("HTTP/1.1 304 Not Modified"); return; } else { $etag = FileCache::etagForFiles($files); } + header('ETag: "' . $etag . '"'); header('Content-Type: application/javascript'); @@ -120,15 +149,36 @@ class JavaScript $out .= ';' . ltrim(trim(file_get_contents($file)), ';') . "\n"; } - foreach ($jsFiles as $file) { + foreach ($baseFiles as $file) { $js .= file_get_contents($file) . "\n\n\n"; } - foreach ($sharedFiles as $file) { - if (substr($file, -7, 7) === '.min.js') { - $out .= ';' . ltrim(trim(file_get_contents($file)), ';') . "\n"; - } else { - $js .= file_get_contents($file) . "\n\n\n"; + // Library files need to be namespaced first before they can be included + foreach (Icinga::app()->getLibraries() as $library) { + foreach ($library->getJsAssets() as $file) { + $js .= self::optimizeDefine( + file_get_contents($file), + $file, + $library->getJsAssetPath(), + $library->getName() + ) . "\n\n\n"; + } + } + + foreach ($coreFiles as $file) { + $js .= file_get_contents($file) . "\n\n\n"; + } + + foreach ($moduleFiles as $name => $paths) { + foreach ($paths as $basePath => $filePaths) { + foreach ($filePaths as $file) { + $content = self::optimizeDefine(file_get_contents($file), $file, $basePath, $name); + if (substr($file, -7, 7) === '.min.js') { + $out .= ';' . ltrim(trim($content), ';') . "\n"; + } else { + $js .= $content . "\n\n\n"; + } + } } } @@ -138,7 +188,69 @@ class JavaScript } else { $out .= $js; } + $cache->store($cacheFile, $out); echo $out; } + + /** + * Optimize define() calls in the given JS + * + * @param string $js + * @param string $filePath + * @param string $basePath + * @param string $packageName + * + * @return string + */ + public static function optimizeDefine($js, $filePath, $basePath, $packageName) + { + if (! preg_match(self::DEFINE_RE, $js, $match)) { + return $js; + } + + try { + $assetName = $match[1] ? Json::decode($match[1]) : ''; + if (! $assetName) { + $assetName = explode('.', basename($filePath))[0]; + } + + $assetName = join(DIRECTORY_SEPARATOR, array_filter([ + $packageName, + ltrim(substr(dirname($filePath), strlen($basePath)), DIRECTORY_SEPARATOR), + $assetName + ])); + + $assetName = Json::encode($assetName, JSON_UNESCAPED_SLASHES); + } catch (JsonDecodeException $_) { + $assetName = $match[1]; + Logger::error('Can\'t optimize name of "%s". Are single quotes used instead of double quotes?', $filePath); + } + + try { + $dependencies = $match[2] ? Json::decode($match[2]) : []; + foreach ($dependencies as &$dependencyName) { + if (preg_match('~^((?:\.\.?/)+)*(.*)~', $dependencyName, $natch)) { + $dependencyName = join(DIRECTORY_SEPARATOR, array_filter([ + $packageName, + ltrim(substr( + realpath(join(DIRECTORY_SEPARATOR, [dirname($filePath), $natch[1]])), + strlen(realpath($basePath)) + ), DIRECTORY_SEPARATOR), + $natch[2] + ])); + } + } + + $dependencies = Json::encode($dependencies, JSON_UNESCAPED_SLASHES); + } catch (JsonDecodeException $_) { + $dependencies = $match[2]; + Logger::error( + 'Can\'t optimize dependencies of "%s". Are single quotes used instead of double quotes?', + $filePath + ); + } + + return str_replace($match[0], sprintf("define(%s, %s, %s", $assetName, $dependencies, $match[3]), $js); + } } diff --git a/library/Icinga/Web/StyleSheet.php b/library/Icinga/Web/StyleSheet.php index 86e43b192..1968f4537 100644 --- a/library/Icinga/Web/StyleSheet.php +++ b/library/Icinga/Web/StyleSheet.php @@ -26,7 +26,7 @@ class StyleSheet * * @var string[] */ - protected static $lessFiles = array( + protected static $lessFiles = [ '../application/fonts/fontello-ifont/css/ifont-embedded.css', 'css/vendor/normalize.css', 'css/vendor/tipsy.css', @@ -53,7 +53,7 @@ class StyleSheet 'css/icinga/print.less', 'css/icinga/responsive.less', 'css/icinga/modal.less' - ); + ]; /** * Application instance @@ -93,6 +93,12 @@ class StyleSheet */ protected function collect() { + foreach ($this->app->getLibraries() as $library) { + foreach ($library->getCssAssets() as $lessFile) { + $this->lessCompiler->addLessFile($lessFile); + } + } + foreach (self::$lessFiles as $lessFile) { $this->lessCompiler->addLessFile($this->pubPath . '/' . $lessFile); } diff --git a/modules/setup/library/Setup/Utils/EnableModuleStep.php b/modules/setup/library/Setup/Utils/EnableModuleStep.php index 343683ce6..92af5b7c0 100644 --- a/modules/setup/library/Setup/Utils/EnableModuleStep.php +++ b/modules/setup/library/Setup/Utils/EnableModuleStep.php @@ -5,6 +5,7 @@ namespace Icinga\Module\Setup\Utils; use Exception; use Icinga\Application\Icinga; +use Icinga\Exception\ConfigurationError; use Icinga\Exception\IcingaException; use Icinga\Module\Setup\Step; @@ -16,6 +17,8 @@ class EnableModuleStep extends Step protected $errors; + protected $warnings; + public function __construct(array $moduleNames) { $this->moduleNames = $moduleNames; @@ -35,6 +38,8 @@ class EnableModuleStep extends Step foreach ($this->moduleNames as $moduleName) { try { $moduleManager->enableModule($moduleName); + } catch (ConfigurationError $e) { + $this->warnings[$moduleName] = $e; } catch (Exception $e) { $this->errors[$moduleName] = $e; $success = false; @@ -59,6 +64,9 @@ class EnableModuleStep extends Step if (isset($this->errors[$moduleName])) { $report[] = sprintf($failMessage, $moduleName); $report[] = sprintf(mt('setup', 'ERROR: %s'), IcingaException::describe($this->errors[$moduleName])); + } elseif (isset($this->warnings[$moduleName])) { + $report[] = sprintf($failMessage, $moduleName); + $report[] = sprintf(mt('setup', 'WARNING: %s'), $this->warnings[$moduleName]->getMessage()); } else { $report[] = sprintf($okMessage, $moduleName); } diff --git a/public/css/icinga/about.less b/public/css/icinga/about.less index 69618110b..de151fe97 100644 --- a/public/css/icinga/about.less +++ b/public/css/icinga/about.less @@ -1,6 +1,10 @@ /*! Icinga Web 2 | (c) 2015 Icinga Development Team | GPLv2+ */ #about { + h2 { + margin-top: 2.5em; + } + .about-social i { font-size: 1.7em; color: @text-color; diff --git a/public/css/icinga/main.less b/public/css/icinga/main.less index 4588750c3..15bee5cda 100644 --- a/public/css/icinga/main.less +++ b/public/css/icinga/main.less @@ -11,6 +11,10 @@ font-weight: @font-weight-bold; } +.error-reason { + margin-top: 4em; +} + .large-icon { font-size: 200%; } @@ -178,6 +182,12 @@ a:hover > .icon-cancel { width: 100%; } +.name-value-table > caption { + margin-top: .5em; + text-align: left; + font-weight: bold; +} + .name-value-table > tbody > tr > th { color: @text-color-light; // Reset default font-weight @@ -350,4 +360,30 @@ a:hover > .icon-cancel { padding-right: 0; } } +} + +.module-dependencies { + .unmet-dependencies { + background-color: @color-warning; + color: @text-color-on-icinga-blue; + padding: .25em .5em; + margin-left: -.5em; + } + + .name-value-table { + > caption { + font-weight: normal; + color: @text-color-light; + } + + > tbody > tr > th { + font-weight: bold; + color: @text-color; + } + + .missing { + color: @color-critical; + font-weight: bold; + } + } } \ No newline at end of file diff --git a/public/js/define.js b/public/js/define.js new file mode 100644 index 000000000..059e3ef80 --- /dev/null +++ b/public/js/define.js @@ -0,0 +1,102 @@ +/*! Icinga Web 2 | (c) 2020 Icinga GmbH | GPLv2+ */ + +(function(window) { + + 'use strict'; + + /** + * Provide a reference to be later required by foreign code + * + * @param {string} name Optional, defaults to the name (and path) of the file + * @param {string[]} requirements Optional, list of required references, may be relative if from the same package + * @param {function} factory Required, function that accepts as many params as there are requirements and that + * produces a value to be referenced + */ + var define = function (name, requirements, factory) { + define.defines[name] = { + requirements: requirements, + factory: factory, + ref: null + } + + define.resolve(name); + } + + /** + * Return whether the given name references a value + * + * @param {string} name The absolute name of the reference + * @return {boolean} + */ + define.has = function (name) { + return name in define.defines && define.defines[name]['ref'] !== null; + } + + /** + * Get the value of a reference + * + * @param {string} name The absolute name of the reference + * @return {*} + */ + define.get = function (name) { + return define.defines[name]['ref']; + } + + /** + * Set the value of a reference + * + * @param {string} name The absolute name of the reference + * @param {*} ref The value to reference + */ + define.set = function (name, ref) { + define.defines[name]['ref'] = ref; + } + + /** + * Resolve a reference and, if successful, dependent references + * + * @param {string} name The absolute name of the reference + * @return {boolean} + */ + define.resolve = function (name) { + var requirements = define.defines[name]['requirements']; + if (requirements.filter(define.has).length < requirements.length) { + return false; + } + + var requiredRefs = []; + for (var i = 0; i < requirements.length; i++) { + requiredRefs.push(define.get(requirements[i])); + } + + var factory = define.defines[name]['factory']; + define.set(name, factory.apply(null, requiredRefs)); + + for (var definedName in define.defines) { + if (define.defines[definedName]['requirements'].indexOf(name) >= 0) { + define.resolve(definedName); + } + } + } + + /** + * Require a reference + * + * @param {string} name The absolute name of the reference + * @return {*} + */ + var require = function(name) { + if (define.has(name)) { + return define.get(name); + } + + throw new ReferenceError(name + ' is not defined'); + } + + define.icinga = true; + define.defines = {}; + + window.define = define; + window.require = require; + +})(window);
translate('State') ?> - toggleForm)): ?> + toggleForm)): ?> + enabled || ! $unmetDependencies): ?> toggleForm ?> + + icon('attention-alt', $this->translate('Module can\'t be enabled due to unmet dependencies')) ?> +
escape($this->translate('Version')) ?>
escape($this->translate('Dependencies')) ?> - translate('This module has no dependencies'); - else: foreach ($dependencies as $name => $versionString): ?> - escape($name) ?>: escape($versionString) ?>
- +
+ + translate('This module has no dependencies') ?> + + + + translate('Unmet dependencies found! Module can\'t be enabled unless all dependencies are met.') ?> + + + + + + $versionString): ?> + + + + + +
translate('Libraries') ?>
escape($libraryName) ?> + has($libraryName, $versionString === true ? null : $versionString)): ?> + escape($versionString) ?> + + escape($versionString) ?> + get($libraryName)) !== null): ?> + (getVersion() ?>) + + +
+ + + + + $versionString): ?> + + + + + +
translate('Modules') ?>
escape($moduleName) ?> + has($moduleName, $versionString === true ? null : $versionString)): ?> + escape($versionString) ?> + + escape($versionString) ?> + hasInstalled($moduleName)): ?> + (translate('not installed') ?>) + + (getModule($moduleName, false)->getVersion() ?>hasEnabled($moduleName) ? '' : ', ' . $this->translate('disabled') ?>) + + +
+ +