Merge pull request #4272 from Icinga/feature/library-support-4271

Add library support
This commit is contained in:
Johannes Meyer 2020-11-18 13:25:26 +01:00 committed by GitHub
commit e8fc6f93ae
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
25 changed files with 1113 additions and 68 deletions

View File

@ -90,17 +90,21 @@ class ModuleCommand extends Command
/**
* Enable a given module
*
* Usage: icingacli module enable <module-name>
* Usage: icingacli module enable <module-name> [--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);
}
/**

View File

@ -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(

View File

@ -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');
}

View File

@ -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;

View File

@ -94,7 +94,27 @@
)
) ?>
</div>
<h2><?= $this->translate('Loaded modules') ?></h2>
<h2><?= $this->translate('Loaded Libraries') ?></h2>
<table class="table-row-selectable common-table" data-base-target="_next">
<thead>
<tr>
<th><?= $this->translate('Name') ?></th>
<th><?= $this->translate('Version') ?></th>
</tr>
</thead>
<tbody>
<?php foreach ($libraries as $library): ?>
<tr>
<td>
<?= $this->escape($library->getName()) ?>
<td>
<?= $this->escape($library->getVersion()) ?: '-' ?>
</td>
</tr>
<?php endforeach ?>
</tbody>
</table>
<h2><?= $this->translate('Loaded Modules') ?></h2>
<table class="table-row-selectable common-table" data-base-target="_next">
<thead>
<tr>

View File

@ -6,9 +6,11 @@
<?= $this->translate('There is no such module installed.') ?>
<?php return; endif ?>
<?php
$dependencies = $module->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'
?>
<table class="name-value-table">
@ -20,9 +22,13 @@
<th><?= $this->translate('State') ?></th>
<td>
<?= $state ?>
<?php if (isset($this->toggleForm)): ?>
<?php if (isset($this->toggleForm)): ?>
<?php if ($moduleData->enabled || ! $unmetDependencies): ?>
<?= $this->toggleForm ?>
<?php else: ?>
<?= $this->icon('attention-alt', $this->translate('Module can\'t be enabled due to unmet dependencies')) ?>
<?php endif ?>
<?php endif ?>
</td>
<tr>
<th><?= $this->escape($this->translate('Version')) ?></th>
@ -43,13 +49,58 @@
</tr>
<tr>
<th><?= $this->escape($this->translate('Dependencies')) ?></th>
<td>
<?php
if (empty($dependencies)):
echo $this->translate('This module has no dependencies');
else: foreach ($dependencies as $name => $versionString): ?>
<strong><?= $this->escape($name) ?></strong><?php if ($versionString !== true): ?>: <?= $this->escape($versionString) ?><?php endif ?><br />
<?php endforeach; endif ?>
<td class="module-dependencies">
<?php if (empty($requiredLibs) && empty($requiredMods)): ?>
<?= $this->translate('This module has no dependencies') ?>
<?php else: ?>
<?php if ($unmetDependencies): ?>
<strong class="unmet-dependencies">
<?= $this->translate('Unmet dependencies found! Module can\'t be enabled unless all dependencies are met.') ?>
</strong>
<?php endif ?>
<?php if (! empty($requiredLibs)): ?>
<table class="name-value-table">
<caption><?= $this->translate('Libraries') ?></caption>
<?php foreach ($requiredLibs as $libraryName => $versionString): ?>
<tr>
<th><?= $this->escape($libraryName) ?></th>
<td>
<?php if ($libraries->has($libraryName, $versionString === true ? null : $versionString)): ?>
<?= $versionString === true ? '*' : $this->escape($versionString) ?>
<?php else: ?>
<span class="missing"><?= $versionString === true ? '*' : $this->escape($versionString) ?></span>
<?php if (($library = $libraries->get($libraryName)) !== null): ?>
(<?= $library->getVersion() ?>)
<?php endif ?>
<?php endif ?>
</td>
</tr>
<?php endforeach ?>
</table>
<?php endif ?>
<?php if (! empty($requiredMods)): ?>
<table class="name-value-table">
<caption><?= $this->translate('Modules') ?></caption>
<?php foreach ($requiredMods as $moduleName => $versionString): ?>
<tr>
<th><?= $this->escape($moduleName) ?></th>
<td>
<?php if ($moduleManager->has($moduleName, $versionString === true ? null : $versionString)): ?>
<?= $versionString === true ? '*' : $this->escape($versionString) ?>
<?php else: ?>
<span class="missing"><?= $versionString === true ? '*' : $this->escape($versionString) ?></span>
<?php if (! $moduleManager->hasInstalled($moduleName)): ?>
(<?= $this->translate('not installed') ?>)
<?php else: ?>
(<?= $moduleManager->getModule($moduleName, false)->getVersion() ?><?= $moduleManager->hasEnabled($moduleName) ? '' : ', ' . $this->translate('disabled') ?>)
<?php endif ?>
<?php endif ?>
</td>
</tr>
<?php endforeach ?>
</table>
<?php endif ?>
<?php endif ?>
</td>
</tr>
<?php if (! empty($permissions)): ?>

View File

@ -17,4 +17,41 @@ if (isset($stackTraces)) {
}
}
?>
<?php if (isset($module)): ?>
<?php $manager = \Icinga\Application\Icinga::app()->getModuleManager(); ?>
<?php if ($manager->hasUnmetDependencies($module->getName())): ?>
<div class="error-reason">
<p><?= sprintf($this->translate(
'This error might have occurred because module "%s" has unmet dependencies.'
. ' Please check it\'s installation instructions and install missing dependencies.'
), $module->getName()) ?></p>
<?php if (isset($requiredModule) && $requiredModule && isset($module->getRequiredModules()[$requiredModule])): ?>
<?php if (! $manager->hasInstalled($requiredModule)): ?>
<p><?= sprintf($this->translate(
'Module "%s" is required and missing. Please install a version of it matching the required one: %s'
), $requiredModule, $module->getRequiredModules()[$requiredModule]) ?></p>
<?php elseif (! $manager->hasEnabled($requiredModule)): ?>
<p><?= sprintf($this->translate(
'Module "%s" is required and installed, but not enabled. Please enable module "%1$s".'
), $requiredModule) ?></p>
<?php elseif (! $manager->has($requiredModule, $module->getRequiredModules()[$requiredModule])): ?>
<p><?= sprintf($this->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]) ?></p>
<?php endif ?>
<?php elseif (isset($requiredLibrary) && $requiredLibrary && isset($module->getRequiredLibraries()[$requiredLibrary])): ?>
<?php $libraries = \Icinga\Application\Icinga::app()->getLibraries(); ?>
<?php if (! $libraries->has($requiredLibrary)): ?>
<p><?= sprintf($this->translate(
'Library "%s" is required and missing. Please install a version of it matching the required one: %s'
), $requiredLibrary, $module->getRequiredLibraries()[$requiredLibrary]) ?></p>
<?php elseif (! $libraries->has($requiredLibrary, $module->getRequiredLibraries()[$requiredLibrary])): ?>
<p><?= sprintf($this->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]) ?></p>
<?php endif ?>
<?php endif ?>
</div>
<?php endif ?>
<?php endif ?>
</div>

View File

@ -16,6 +16,7 @@
</rule>
<rule ref="PSR1.Files.SideEffects.FoundWithSymbols">
<exclude-pattern>library/Icinga/Application/Cli.php</exclude-pattern>
<exclude-pattern>library/Icinga/Application/StaticWeb.php</exclude-pattern>
<exclude-pattern>library/Icinga/Application/EmbeddedWeb.php</exclude-pattern>
<exclude-pattern>library/Icinga/Application/functions.php</exclude-pattern>
<exclude-pattern>library/Icinga/Application/LegacyWeb.php</exclude-pattern>

View File

@ -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
*

View File

@ -38,6 +38,7 @@ class Cli extends ApplicationBootstrap
$this->assertRunningOnCli();
$this->setupLogging()
->setupErrorHandling()
->loadLibraries()
->loadConfig()
->setupTimezone()
->setupInternationalization()

View File

@ -65,6 +65,7 @@ class EmbeddedWeb extends ApplicationBootstrap
return $this
->setupZendAutoloader()
->setupErrorHandling()
->loadLibraries()
->loadConfig()
->setupLogging()
->setupLogger()

View File

@ -0,0 +1,83 @@
<?php
/* Icinga Web 2 | (c) 2020 Icinga GmbH | GPLv2+ */
namespace Icinga\Application;
use ArrayIterator;
use IteratorAggregate;
use Icinga\Application\Libraries\Library;
class Libraries implements IteratorAggregate
{
/** @var Library[] */
protected $libraries = [];
/**
* Iterate over registered libraries
*
* @return ArrayIterator
*/
public function getIterator()
{
return new ArrayIterator($this->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;
}
}
}
}

View File

@ -0,0 +1,224 @@
<?php
/* Icinga Web 2 | (c) 2020 Icinga GmbH | GPLv2+ */
namespace Icinga\Application\Libraries;
use Icinga\Exception\ConfigurationError;
use Icinga\Exception\Json\JsonDecodeException;
use Icinga\Util\Json;
use RecursiveDirectoryIterator;
use RecursiveIteratorIterator;
class Library
{
/** @var string */
protected $path;
/** @var string */
protected $jsAssetPath;
/** @var string */
protected $cssAssetPath;
/** @var string */
protected $staticAssetPath;
/** @var string */
protected $version;
/** @var array */
protected $metaData;
/** @var array */
protected $assets;
/**
* Create a new Library
*
* @param string $path
*/
public function __construct($path)
{
$this->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;
}
}

View File

@ -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
*

View File

@ -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)) {

View File

@ -0,0 +1,21 @@
<?php
/* Icinga Web 2 | (c) 2020 Icinga GmbH | GPLv2+ */
namespace Icinga\Application;
require_once dirname(__FILE__) . '/EmbeddedWeb.php';
class StaticWeb extends EmbeddedWeb
{
protected function bootstrap()
{
return $this
->setupErrorHandling()
->loadLibraries()
->loadConfig()
->setupLogging()
->setupLogger()
->setupRequest()
->setupResponse();
}
}

View File

@ -83,6 +83,7 @@ class Web extends EmbeddedWeb
->setupZendAutoloader()
->setupLogging()
->setupErrorHandling()
->loadLibraries()
->loadConfig()
->setupLogger()
->setupRequest()

View File

@ -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 {

View File

@ -0,0 +1,77 @@
<?php
/* Icinga Web 2 | (c) 2020 Icinga GmbH | GPLv2+ */
namespace Icinga\Web\Controller;
use Icinga\Application\Icinga;
use Icinga\Web\Request;
class StaticController
{
/**
* Handle incoming request
*
* @param Request $request
*
* @returns void
*/
public function handle(Request $request)
{
$app = Icinga::app();
// +4 because strlen('/lib') === 4
$assetPath = ltrim(substr($request->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));
}
}
}

View File

@ -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 = '/(?<!\.)define\(\s*([\'"][^\'"]*[\'"])?[,\s]*(\[[^]]*\])?[,\s]*(function\s*\([^)]*\)|[^=]*=>)/';
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);
}
}

View File

@ -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);
}

View File

@ -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);
}

View File

@ -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;

View File

@ -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;
}
}
}

102
public/js/define.js Normal file
View File

@ -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);