summaryrefslogtreecommitdiffstats
path: root/library/Icinga/Application
diff options
context:
space:
mode:
Diffstat (limited to '')
-rw-r--r--library/Icinga/Application/ApplicationBootstrap.php747
-rw-r--r--library/Icinga/Application/Benchmark.php300
-rw-r--r--library/Icinga/Application/ClassLoader.php306
-rw-r--r--library/Icinga/Application/Cli.php211
-rw-r--r--library/Icinga/Application/Config.php498
-rw-r--r--library/Icinga/Application/EmbeddedWeb.php115
-rw-r--r--library/Icinga/Application/Hook.php328
-rw-r--r--library/Icinga/Application/Hook/ApplicationStateHook.php90
-rw-r--r--library/Icinga/Application/Hook/AuditHook.php123
-rw-r--r--library/Icinga/Application/Hook/AuthenticationHook.php75
-rw-r--r--library/Icinga/Application/Hook/Common/DbMigrationStep.php129
-rw-r--r--library/Icinga/Application/Hook/ConfigFormEventsHook.php137
-rw-r--r--library/Icinga/Application/Hook/DbMigrationHook.php421
-rw-r--r--library/Icinga/Application/Hook/GrapherHook.php111
-rw-r--r--library/Icinga/Application/Hook/HealthHook.php222
-rw-r--r--library/Icinga/Application/Hook/PdfexportHook.php25
-rw-r--r--library/Icinga/Application/Hook/ThemeLoaderHook.php22
-rw-r--r--library/Icinga/Application/Hook/Ticket/TicketPattern.php140
-rw-r--r--library/Icinga/Application/Hook/TicketHook.php210
-rw-r--r--library/Icinga/Application/Hook/WebBaseHook.php54
-rw-r--r--library/Icinga/Application/Icinga.php49
-rw-r--r--library/Icinga/Application/LegacyWeb.php33
-rw-r--r--library/Icinga/Application/Libraries.php91
-rw-r--r--library/Icinga/Application/Libraries/Library.php259
-rw-r--r--library/Icinga/Application/Logger.php349
-rw-r--r--library/Icinga/Application/Logger/LogWriter.php30
-rw-r--r--library/Icinga/Application/Logger/Writer/FileWriter.php80
-rw-r--r--library/Icinga/Application/Logger/Writer/PhpWriter.php39
-rw-r--r--library/Icinga/Application/Logger/Writer/StderrWriter.php62
-rw-r--r--library/Icinga/Application/Logger/Writer/StdoutWriter.php13
-rw-r--r--library/Icinga/Application/Logger/Writer/SyslogWriter.php90
-rw-r--r--library/Icinga/Application/MigrationManager.php417
-rw-r--r--library/Icinga/Application/Modules/DashboardContainer.php58
-rw-r--r--library/Icinga/Application/Modules/Manager.php698
-rw-r--r--library/Icinga/Application/Modules/MenuItemContainer.php55
-rw-r--r--library/Icinga/Application/Modules/Module.php1451
-rw-r--r--library/Icinga/Application/Modules/NavigationItemContainer.php117
-rw-r--r--library/Icinga/Application/Platform.php435
-rw-r--r--library/Icinga/Application/ProvidedHook/DbMigration.php83
-rw-r--r--library/Icinga/Application/StaticWeb.php21
-rw-r--r--library/Icinga/Application/Test.php140
-rw-r--r--library/Icinga/Application/Version.php65
-rw-r--r--library/Icinga/Application/Web.php509
-rw-r--r--library/Icinga/Application/functions.php110
-rw-r--r--library/Icinga/Application/webrouter.php106
45 files changed, 9624 insertions, 0 deletions
diff --git a/library/Icinga/Application/ApplicationBootstrap.php b/library/Icinga/Application/ApplicationBootstrap.php
new file mode 100644
index 0000000..e484f6c
--- /dev/null
+++ b/library/Icinga/Application/ApplicationBootstrap.php
@@ -0,0 +1,747 @@
+<?php
+/* Icinga Web 2 | (c) 2013 Icinga Development Team | GPLv2+ */
+
+namespace Icinga\Application;
+
+use DirectoryIterator;
+use ErrorException;
+use Exception;
+use Icinga\Application\ProvidedHook\DbMigration;
+use ipl\I18n\GettextTranslator;
+use ipl\I18n\StaticTranslator;
+use LogicException;
+use Icinga\Application\Modules\Manager as ModuleManager;
+use Icinga\Authentication\User\UserBackend;
+use Icinga\Data\ConfigObject;
+use Icinga\Exception\ConfigurationError;
+use Icinga\Exception\NotReadableError;
+use Icinga\Exception\IcingaException;
+
+/**
+ * This class bootstraps a thin Icinga application layer
+ *
+ * Usage example for CLI:
+ * <code>
+ * use Icinga\Application\Cli;
+
+ * Cli::start();
+ * </code>
+ *
+ * Usage example for Icinga Web application:
+ * <code>
+ * use Icinga\Application\Web;
+ * Web::start()->dispatch();
+ * </code>
+ *
+ * Usage example for Icinga-Web 1.x compatibility mode:
+ * <code>
+ * use Icinga\Application\LegacyWeb;
+ * LegacyWeb::start()->setIcingaWebBasedir(ICINGAWEB_BASEDIR)->dispatch();
+ * </code>
+ */
+abstract class ApplicationBootstrap
+{
+ /**
+ * Base directory
+ *
+ * Parent folder for at least application, bin, modules and public
+ *
+ * @var string
+ */
+ protected $baseDir;
+
+ /**
+ * Application directory
+ *
+ * @var string
+ */
+ protected $appDir;
+
+ /**
+ * Icinga library directory
+ *
+ * @var string
+ */
+ protected $libDir;
+
+ /**
+ * Configuration directory
+ *
+ * @var string
+ */
+ protected $configDir;
+
+ /**
+ * Locale directory
+ *
+ * @var string
+ */
+ protected $localeDir;
+
+ /**
+ * Common storage directory
+ *
+ * @var string
+ */
+ protected $storageDir;
+
+ /**
+ * External library paths
+ *
+ * @var string[]
+ */
+ protected $libraryPaths;
+
+ /**
+ * Loaded external libraries
+ *
+ * @var Libraries
+ */
+ protected $libraries;
+
+ /**
+ * Icinga class loader
+ *
+ * @var ClassLoader
+ */
+ private $loader;
+
+ /**
+ * Config object
+ *
+ * @var Config
+ */
+ protected $config;
+
+ /**
+ * Module manager
+ *
+ * @var ModuleManager
+ */
+ private $moduleManager;
+
+ /**
+ * Flag indicates we're on cli environment
+ *
+ * @var bool
+ */
+ protected $isCli = false;
+
+ /**
+ * Flag indicates we're on web environment
+ *
+ * @var bool
+ */
+ protected $isWeb = false;
+
+ /**
+ * Whether Icinga Web 2 requires setup
+ *
+ * @var bool
+ */
+ protected $requiresSetup = false;
+
+ /**
+ * Constructor
+ *
+ * @param string $baseDir Icinga Web 2 base directory
+ * @param string $configDir Path to Icinga Web 2's configuration files
+ * @param string $storageDir Path to Icinga Web 2's stored files
+ */
+ protected function __construct($baseDir = null, $configDir = null, $storageDir = null)
+ {
+ if ($baseDir === null) {
+ $baseDir = dirname($this->getBootstrapDirectory());
+ }
+ $this->baseDir = $baseDir;
+ $this->appDir = $baseDir . '/application';
+ if (substr(__DIR__, 0, 8) === 'phar:///') {
+ $this->libDir = dirname(dirname(__DIR__));
+ } else {
+ $this->libDir = realpath(__DIR__ . '/../..');
+ }
+
+ $this->setupAutoloader();
+
+ if ($configDir === null) {
+ $configDir = getenv('ICINGAWEB_CONFIGDIR');
+ if ($configDir === false) {
+ $configDir = Platform::isWindows()
+ ? $baseDir . '/config'
+ : '/etc/icingaweb2';
+ }
+ }
+ $canonical = realpath($configDir);
+ $this->configDir = $canonical ? $canonical : $configDir;
+
+ if ($storageDir === null) {
+ $storageDir = getenv('ICINGAWEB_STORAGEDIR');
+ if ($storageDir === false) {
+ $storageDir = Platform::isWindows()
+ ? $baseDir . '/storage'
+ : '/var/lib/icingaweb2';
+ }
+ }
+ $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/icinga-php')
+ ? ['/usr/share/icinga-php']
+ : [];
+ }
+ }
+
+ Icinga::setApp($this);
+
+ require_once dirname(__FILE__) . '/functions.php';
+ }
+
+ /**
+ * Bootstrap interface method for concrete bootstrap objects
+ *
+ * @return mixed
+ */
+ abstract protected function bootstrap();
+
+ /**
+ * Get loaded external libraries
+ *
+ * @return Libraries
+ */
+ public function getLibraries()
+ {
+ return $this->libraries;
+ }
+
+ /**
+ * Getter for module manager
+ *
+ * @return ModuleManager
+ */
+ public function getModuleManager()
+ {
+ return $this->moduleManager;
+ }
+
+ /**
+ * Getter for class loader
+ *
+ * @return ClassLoader
+ */
+ public function getLoader()
+ {
+ return $this->loader;
+ }
+
+ /**
+ * Getter for configuration object
+ *
+ * @return Config
+ */
+ public function getConfig()
+ {
+ return $this->config;
+ }
+
+ /**
+ * Flag indicates we're on cli environment
+ *
+ * @return bool
+ */
+ public function isCli()
+ {
+ return $this->isCli;
+ }
+
+ /**
+ * Flag indicates we're on web environment
+ *
+ * @return bool
+ */
+ public function isWeb()
+ {
+ return $this->isWeb;
+ }
+
+ /**
+ * Helper to glue directories together
+ *
+ * @param string $dir
+ * @param string $subdir
+ *
+ * @return string
+ */
+ private function getDirWithSubDir($dir, $subdir = null)
+ {
+ if ($subdir !== null) {
+ $dir .= '/' . ltrim($subdir, '/');
+ }
+
+ return $dir;
+ }
+
+ /**
+ * Get the base directory
+ *
+ * @param string $subDir Optional sub directory to get
+ *
+ * @return string
+ */
+ public function getBaseDir($subDir = null)
+ {
+ return $this->getDirWithSubDir($this->baseDir, $subDir);
+ }
+
+ /**
+ * Get the application directory
+ *
+ * @param string $subDir Optional sub directory to get
+ *
+ * @return string
+ */
+ public function getApplicationDir($subDir = null)
+ {
+ return $this->getDirWithSubDir($this->appDir, $subDir);
+ }
+
+ /**
+ * Get the configuration directory
+ *
+ * @param string $subDir Optional sub directory to get
+ *
+ * @return string
+ */
+ public function getConfigDir($subDir = null)
+ {
+ return $this->getDirWithSubDir($this->configDir, $subDir);
+ }
+
+ /**
+ * Get the common storage directory
+ *
+ * @param string $subDir Optional sub directory to get
+ *
+ * @return string
+ */
+ public function getStorageDir($subDir = null)
+ {
+ return $this->getDirWithSubDir($this->storageDir, $subDir);
+ }
+
+ /**
+ * Get the Icinga library directory
+ *
+ * @param string $subDir Optional sub directory to get
+ *
+ * @return string
+ */
+ public function getLibraryDir($subDir = null)
+ {
+ return $this->getDirWithSubDir($this->libDir, $subDir);
+ }
+
+ /**
+ * Get the path to the bootstrapping directory
+ *
+ * This is usually /public for Web and EmbeddedWeb and /bin for the CLI
+ *
+ * @return string
+ *
+ * @throws LogicException If the base directory can not be detected
+ */
+ public function getBootstrapDirectory()
+ {
+ $script = $_SERVER['SCRIPT_FILENAME'];
+ $canonical = realpath($script);
+ if ($canonical !== false) {
+ $dir = dirname($canonical);
+ } elseif (substr($script, -14) === '/webrouter.php') {
+ // If Icinga Web 2 is served using PHP's built-in webserver with our webrouter.php script, the $_SERVER
+ // variable SCRIPT_FILENAME is set to DOCUMENT_ROOT/webrouter.php which is not a valid path to
+ // realpath but DOCUMENT_ROOT here still is the bootstrapping directory
+ $dir = dirname($script);
+ } else {
+ throw new LogicException('Can\'t detected base directory');
+ }
+ return $dir;
+ }
+
+ /**
+ * Start the bootstrap
+ *
+ * @param string $baseDir Icinga Web 2 base directory
+ * @param string $configDir Path to Icinga Web 2's configuration files
+ *
+ * @return static
+ */
+ public static function start($baseDir = null, $configDir = null)
+ {
+ $application = new static($baseDir, $configDir);
+ $application->bootstrap();
+ return $application;
+ }
+
+ /**
+ * Setup Icinga class loader
+ *
+ * @return $this
+ */
+ public function setupAutoloader()
+ {
+ require_once $this->libDir . '/Icinga/Application/ClassLoader.php';
+
+ $this->loader = new ClassLoader();
+ $this->loader->registerNamespace('Icinga', $this->libDir . '/Icinga');
+ $this->loader->registerNamespace('Icinga', $this->libDir . '/Icinga', $this->appDir);
+ $this->loader->register();
+
+ return $this;
+ }
+
+ /**
+ * Setup module manager
+ *
+ * @return $this
+ */
+ protected function setupModuleManager()
+ {
+ $paths = $this->getAvailableModulePaths();
+ $this->moduleManager = new ModuleManager(
+ $this,
+ $this->configDir . '/enabledModules',
+ $paths
+ );
+ return $this;
+ }
+
+ protected function getAvailableModulePaths()
+ {
+ $paths = [];
+
+ $configured = getenv('ICINGAWEB_MODULES_DIR');
+ if (! $configured) {
+ $configured = $this->config->get('global', 'module_path', $this->baseDir . '/modules');
+ }
+
+ $nextIsPhar = false;
+ foreach (explode(PATH_SEPARATOR, $configured) as $path) {
+ if ($path === 'phar') {
+ $nextIsPhar = true;
+ continue;
+ }
+
+ if ($nextIsPhar) {
+ $nextIsPhar = false;
+ $paths[] = 'phar:' . $path;
+ } else {
+ $paths[] = $path;
+ }
+ }
+
+ return $paths;
+ }
+
+ /**
+ * Load all enabled modules
+ *
+ * @return $this
+ */
+ protected function loadEnabledModules()
+ {
+ try {
+ $this->moduleManager->loadEnabledModules();
+ } catch (NotReadableError $e) {
+ Logger::error(new IcingaException('Cannot load enabled modules. An exception was thrown:', $e));
+ }
+ return $this;
+ }
+
+ /**
+ * Load the setup module if Icinga Web 2 requires setup or the setup token exists
+ *
+ * @return $this
+ */
+ protected function loadSetupModuleIfNecessary()
+ {
+ if (! @file_exists($this->config->resolvePath('authentication.ini'))) {
+ $this->requiresSetup = true;
+ if ($this->moduleManager->hasInstalled('setup')) {
+ $this->moduleManager->loadModule('setup');
+ }
+ } elseif ($this->setupTokenExists()) {
+ // Load setup module but do not require setup
+ if ($this->moduleManager->hasInstalled('setup')) {
+ $this->moduleManager->loadModule('setup');
+ }
+ }
+ return $this;
+ }
+
+ /**
+ * Get whether Icinga Web 2 requires setup
+ *
+ * @return bool
+ */
+ public function requiresSetup()
+ {
+ return $this->requiresSetup;
+ }
+
+ /**
+ * Get whether the setup token exists
+ *
+ * @return bool
+ */
+ public function setupTokenExists()
+ {
+ 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
+ *
+ * @return $this
+ */
+ protected function setupLogging()
+ {
+ Logger::create(
+ new ConfigObject(
+ array(
+ 'log' => 'syslog'
+ )
+ )
+ );
+ return $this;
+ }
+
+ /**
+ * Load Configuration
+ *
+ * @return $this
+ */
+ protected function loadConfig()
+ {
+ Config::$configDir = $this->configDir;
+
+ try {
+ $this->config = Config::app();
+ } catch (NotReadableError $e) {
+ Logger::error(new IcingaException('Cannot load application configuration. An exception was thrown:', $e));
+ $this->config = new Config();
+ }
+
+ return $this;
+ }
+
+ /**
+ * Error handling configuration
+ *
+ * @return $this
+ */
+ protected function setupErrorHandling()
+ {
+ error_reporting(E_ALL | E_STRICT);
+ ini_set('display_startup_errors', 1);
+ ini_set('display_errors', 1);
+ set_error_handler(function ($errno, $errstr, $errfile, $errline) {
+ if (! (error_reporting() & $errno)) {
+ // Error was suppressed with the @-operator
+ return false; // Continue with the normal error handler
+ }
+ switch ($errno) {
+ case E_NOTICE:
+ case E_WARNING:
+ case E_STRICT:
+ case E_RECOVERABLE_ERROR:
+ throw new ErrorException($errstr, 0, $errno, $errfile, $errline);
+ }
+ return false; // Continue with the normal error handler
+ });
+ return $this;
+ }
+
+ /**
+ * Set up logger
+ *
+ * @return $this
+ */
+ protected function setupLogger()
+ {
+ if ($this->config->hasSection('logging')) {
+ $loggingConfig = $this->config->getSection('logging');
+
+ try {
+ Logger::create($loggingConfig);
+ } catch (ConfigurationError $e) {
+ Logger::getInstance()->registerConfigError($e->getMessage());
+
+ try {
+ Logger::getInstance()->setLevel($loggingConfig->get('level', Logger::ERROR));
+ } catch (ConfigurationError $e) {
+ Logger::getInstance()->registerConfigError($e->getMessage());
+ }
+ }
+ }
+
+ return $this;
+ }
+
+ /**
+ * Set up the user backend factory
+ *
+ * @return $this
+ */
+ protected function setupUserBackendFactory()
+ {
+ try {
+ UserBackend::setConfig(Config::app('authentication'));
+ } catch (NotReadableError $e) {
+ Logger::error(
+ new IcingaException('Cannot load user backend configuration. An exception was thrown:', $e)
+ );
+ }
+
+ return $this;
+ }
+
+ /**
+ * Detect the timezone
+ *
+ * @return null|string
+ */
+ protected function detectTimezone()
+ {
+ return null;
+ }
+
+ /**
+ * Set up the timezone
+ *
+ * @return $this
+ */
+ final protected function setupTimezone()
+ {
+ $timezone = $this->detectTimezone();
+ if ($timezone === null || @date_default_timezone_set($timezone) === false) {
+ date_default_timezone_set(@date_default_timezone_get());
+ }
+ return $this;
+ }
+
+ /**
+ * Detect the locale
+ *
+ * @return null|string
+ */
+ protected function detectLocale()
+ {
+ return null;
+ }
+
+ /**
+ * Prepare internationalization using gettext
+ *
+ * @return $this
+ */
+ protected function prepareInternationalization()
+ {
+ StaticTranslator::$instance = (new GettextTranslator())
+ ->setDefaultDomain('icinga');
+
+ return $this;
+ }
+
+ /**
+ * Set up internationalization using gettext
+ *
+ * @return $this
+ */
+ final protected function setupInternationalization()
+ {
+ /** @var GettextTranslator $translator */
+ $translator = StaticTranslator::$instance;
+
+ if ($this->hasLocales()) {
+ $translator->addTranslationDirectory($this->getLocaleDir(), 'icinga');
+ }
+
+ $locale = $this->detectLocale();
+ if ($locale === null) {
+ $locale = $translator->getDefaultLocale();
+ }
+
+ try {
+ $translator->setLocale($locale);
+ } catch (Exception $error) {
+ Logger::error($error);
+ }
+
+ return $this;
+ }
+
+ /**
+ * @return string Our locale directory
+ */
+ public function getLocaleDir()
+ {
+ if ($this->localeDir === null) {
+ $L10nLocales = getenv('ICINGAWEB_LOCALEDIR') ?: '/usr/share/icinga-L10n/locale';
+ if (file_exists($L10nLocales) && is_dir($L10nLocales)) {
+ $this->localeDir = $L10nLocales;
+ } else {
+ $this->localeDir = false;
+ }
+ }
+
+ return $this->localeDir;
+ }
+
+ /**
+ * return bool Whether Icinga Web has translations
+ */
+ public function hasLocales()
+ {
+ $localedir = $this->getLocaleDir();
+ return $localedir !== false && file_exists($localedir) && is_dir($localedir);
+ }
+
+ /**
+ * Register all hooks provided by the main application
+ *
+ * @return $this
+ */
+ protected function registerApplicationHooks(): self
+ {
+ Hook::register('DbMigration', DbMigration::class, DbMigration::class);
+
+ return $this;
+ }
+}
diff --git a/library/Icinga/Application/Benchmark.php b/library/Icinga/Application/Benchmark.php
new file mode 100644
index 0000000..32a05ec
--- /dev/null
+++ b/library/Icinga/Application/Benchmark.php
@@ -0,0 +1,300 @@
+<?php
+/* Icinga Web 2 | (c) 2013 Icinga Development Team | GPLv2+ */
+
+/**
+ * Icinga\Application\Benchmark class
+ */
+namespace Icinga\Application;
+
+use Icinga\Util\Format;
+
+/**
+ * This class provides a simple and lightweight benchmark class
+ *
+ * <code>
+ * Benchmark::measure('Program started');
+ * // ...do something...
+ * Benchmark::measure('Task finieshed');
+ * Benchmark::dump();
+ * </code>
+ */
+class Benchmark
+{
+ const TIME = 0x01;
+ const MEMORY = 0x02;
+
+ protected static $instance;
+ protected $start;
+ protected $measures = array();
+
+ /**
+ * Add a measurement to your benchmark
+ *
+ * The same identifier can also be used multiple times
+ *
+ * @param string A comment identifying the current measurement
+ * @return void
+ */
+ public static function measure($message)
+ {
+ self::getInstance()->measures[] = (object) array(
+ 'timestamp' => microtime(true),
+ 'memory_real' => memory_get_usage(true),
+ 'memory' => memory_get_usage(),
+ 'message' => $message
+ );
+ }
+
+ /**
+ * Throws all measurements away
+ *
+ * This empties your measurement table and allows you to restart your
+ * benchmark from scratch
+ *
+ * @return void
+ */
+ public static function reset()
+ {
+ self::$instance = null;
+ }
+
+ /**
+ * Rerieve benchmark start time
+ *
+ * This will give you the timestamp of your first measurement
+ *
+ * @return float
+ */
+ public static function getStartTime()
+ {
+ return self::getInstance()->start;
+ }
+
+ /**
+ * Dump benchmark data
+ *
+ * Will dump a text table if running on CLI and a simple HTML table
+ * otherwise. Use Benchmark::TIME and Benchmark::MEMORY to choose whether
+ * you prefer to show either time or memory or both in your output
+ *
+ * @param ?int $what Whether to get time and/or memory summary
+ */
+ public static function dump($what = null)
+ {
+ if (Icinga::app()->isCli()) {
+ echo self::renderToText($what);
+ } else {
+ echo self::renderToHtml($what);
+ }
+ }
+
+ /**
+ * Render benchmark data to a simple text table
+ *
+ * Use Benchmark::TIME and Icinga::MEMORY to choose whether you prefer to
+ * show either time or memory or both in your output
+ *
+ * @param ?int $what Whether to get time and/or memory summary
+ * @return string
+ */
+ public static function renderToText($what = null)
+ {
+ $data = self::prepareDataForRendering($what);
+ $sep = '+';
+ $title = '|';
+ foreach ($data->columns as & $col) {
+ $col->format = ' %'
+ . ($col->align === 'right' ? '' : '-')
+ . $col->maxlen . 's |';
+
+ $sep .= str_repeat('-', $col->maxlen) . '--+';
+ $title .= sprintf($col->format, $col->title);
+ }
+
+ $out = $sep . "\n" . $title . "\n" . $sep . "\n";
+ foreach ($data->rows as & $row) {
+ $r = '|';
+ foreach ($data->columns as $key => & $col) {
+ $r .= sprintf($col->format, $row[$key]);
+ }
+ $out .= $r . "\n";
+ }
+
+ $out .= $sep . "\n";
+ return $out;
+ }
+
+ /**
+ * Render benchmark data to a simple HTML table
+ *
+ * Use Benchmark::TIME and Benchmark::MEMORY to choose whether you prefer
+ * to show either time or memory or both in your output
+ *
+ * @param ?int $what Whether to get time and/or memory summary
+ *
+ * @return string
+ */
+ public static function renderToHtml($what = null)
+ {
+ $data = self::prepareDataForRendering($what);
+
+ // TODO: Move formatting to CSS file
+ $html = '<table class="benchmark">' . "\n" . '<tr>';
+ foreach ($data->columns as & $col) {
+ if ($col->title === 'Time') {
+ continue;
+ }
+ $html .= sprintf(
+ '<td align="%s">%s</td>',
+ $col->align,
+ htmlspecialchars($col->title)
+ );
+ }
+ $html .= "</tr>\n";
+
+ foreach ($data->rows as & $row) {
+ $html .= '<tr>';
+ foreach ($data->columns as $key => & $col) {
+ if ($col->title === 'Time') {
+ continue;
+ }
+ $html .= sprintf(
+ '<td align="%s">%s</td>',
+ $col->align,
+ $row[$key]
+ );
+ }
+ $html .= "</tr>\n";
+ }
+ $html .= "</table>\n";
+ return $html;
+ }
+
+ /**
+ * Prepares benchmark data for output
+ *
+ * Use Benchmark::TIME and Benchmark::MEMORY to choose whether you prefer
+ * to have either time or memory or both in your output
+ *
+ * @param ?int $what Whether to get time and/or memory summary
+ *
+ * @return object
+ */
+ protected static function prepareDataForRendering($what = null)
+ {
+ if ($what === null) {
+ $what = self::TIME | self::MEMORY;
+ }
+
+ $columns = array(
+ (object) array(
+ 'title' => 'Time',
+ 'align' => 'left',
+ 'maxlen' => 4
+ ),
+ (object) array(
+ 'title' => 'Description',
+ 'align' => 'left',
+ 'maxlen' => 11
+ )
+ );
+ if ($what & self::TIME) {
+ $columns[] = (object) array(
+ 'title' => 'Off (ms)',
+ 'align' => 'right',
+ 'maxlen' => 11
+ );
+ $columns[] = (object) array(
+ 'title' => 'Dur (ms)',
+ 'align' => 'right',
+ 'maxlen' => 13
+ );
+ }
+ if ($what & self::MEMORY) {
+ $columns[] = (object) array(
+ 'title' => 'Mem (diff)',
+ 'align' => 'right',
+ 'maxlen' => 10
+ );
+ $columns[] = (object) array(
+ 'title' => 'Mem (total)',
+ 'align' => 'right',
+ 'maxlen' => 11
+ );
+ }
+
+ $bench = self::getInstance();
+ $last = $bench->start;
+ $rows = array();
+ $lastmem = 0;
+ foreach ($bench->measures as $m) {
+ $micro = sprintf(
+ '%03d',
+ round(($m->timestamp - floor($m->timestamp)) * 1000)
+ );
+ $vals = array(
+ date('H:i:s', (int) $m->timestamp) . '.' . $micro,
+ $m->message
+ );
+
+ if ($what & self::TIME) {
+ $m->relative = $m->timestamp - $bench->start;
+ $m->offset = $m->timestamp - $last;
+ $last = $m->timestamp;
+ $vals[] = sprintf('%0.3f', $m->relative * 1000);
+ $vals[] = sprintf('%0.3f', $m->offset * 1000);
+ }
+
+ if ($what & self::MEMORY) {
+ $mem = $m->memory - $lastmem;
+ $lastmem = $m->memory;
+ $vals[] = Format::bytes($mem);
+ $vals[] = Format::bytes($m->memory);
+ }
+
+ $row = & $rows[];
+ foreach ($vals as $col => $val) {
+ $row[$col] = $val;
+ $columns[$col]->maxlen = max(
+ strlen($val),
+ $columns[$col]->maxlen
+ );
+ }
+ }
+
+ return (object) array(
+ 'columns' => $columns,
+ 'rows' => $rows
+ );
+ }
+
+ /**
+ * Singleton
+ *
+ * Benchmark is run only once, but you are not allowed to directly access
+ * the getInstance() method
+ *
+ * @return self
+ */
+ protected static function getInstance()
+ {
+ if (self::$instance === null) {
+ self::$instance = new Benchmark();
+ self::$instance->start = microtime(true);
+ }
+
+ return self::$instance;
+ }
+
+ /**
+ * Constructor
+ *
+ * Singleton usage is enforced, the only way to instantiate Benchmark is by
+ * starting your measurements
+ *
+ * @return void
+ */
+ protected function __construct()
+ {
+ }
+}
diff --git a/library/Icinga/Application/ClassLoader.php b/library/Icinga/Application/ClassLoader.php
new file mode 100644
index 0000000..71b4d3e
--- /dev/null
+++ b/library/Icinga/Application/ClassLoader.php
@@ -0,0 +1,306 @@
+<?php
+/* Icinga Web 2 | (c) 2013 Icinga Development Team | GPLv2+ */
+
+namespace Icinga\Application;
+
+/**
+ * PSR-4 class loader
+ */
+class ClassLoader
+{
+ /**
+ * Namespace separator
+ */
+ const NAMESPACE_SEPARATOR = '\\';
+
+ /**
+ * Icinga Web 2 module namespace prefix
+ */
+ const MODULE_PREFIX = 'Icinga\\Module\\';
+
+ /**
+ * Icinga Web 2 module namespace prefix length
+ *
+ * Helps to make substr/strpos operations even faster
+ */
+ const MODULE_PREFIX_LENGTH = 14;
+
+ /**
+ * A hardcoded class/subdir map for application ns prefixes
+ *
+ * When a module registers with an application directory, those
+ * namespace prefixes (after the module prefix) will be looked up
+ * in the corresponding application subdirectories
+ *
+ * @var array
+ */
+ protected $applicationPrefixes = array(
+ 'Clicommands' => 'clicommands',
+ 'Controllers' => 'controllers',
+ 'Forms' => 'forms'
+ );
+
+ /**
+ * Whether we already instantiated the ZF autoloader
+ *
+ * @var boolean
+ */
+ protected $gotZend = false;
+
+ /**
+ * Namespaces
+ *
+ * @var array
+ */
+ private $namespaces = array();
+
+ /**
+ * Application directories
+ *
+ * @var array
+ */
+ private $applicationDirectories = array();
+
+ /**
+ * Register a base directory for a namespace prefix
+ *
+ * Application directory is optional and provides additional lookup
+ * logic for hardcoded namespaces like "Forms"
+ *
+ * @param string $namespace
+ * @param string $directory
+ * @param string $appDirectory
+ *
+ * @return $this
+ */
+ public function registerNamespace($namespace, $directory, $appDirectory = null)
+ {
+ $this->namespaces[$namespace] = $directory;
+
+ if ($appDirectory !== null) {
+ $this->applicationDirectories[$namespace] = $appDirectory;
+ }
+
+ return $this;
+ }
+
+ /**
+ * Test whether a namespace exists
+ *
+ * @param string $namespace
+ *
+ * @return bool
+ */
+ public function hasNamespace($namespace)
+ {
+ return array_key_exists($namespace, $this->namespaces);
+ }
+
+ /**
+ * Get the source file of the given class or interface
+ *
+ * @param string $class Name of the class or interface
+ *
+ * @return string|null
+ */
+ public function getSourceFile($class)
+ {
+ if ($file = $this->getModuleSourceFile($class)) {
+ return $file;
+ }
+
+ foreach ($this->namespaces as $namespace => $dir) {
+ if ($class === strstr($class, "$namespace\\")) {
+ return $this->buildClassFilename($class, $namespace);
+ }
+ }
+
+ return null;
+ }
+
+ /**
+ * Get the source file of the given module class or interface
+ *
+ * @param string $class Module class or interface name
+ *
+ * @return string|null
+ */
+ protected function getModuleSourceFile($class)
+ {
+ if (! $this->classBelongsToModule($class)) {
+ return null;
+ }
+
+ $modules = Icinga::app()->getModuleManager();
+ $namespace = $this->extractModuleNamespace($class);
+
+ if ($this->hasNamespace($namespace)) {
+ return $this->buildClassFilename($class, $namespace);
+ } elseif (! $modules->loadedAllEnabledModules()) {
+ $moduleName = $this->extractModuleName($class);
+
+ if ($modules->hasEnabled($moduleName)) {
+ $modules->loadModule($moduleName);
+
+ return $this->buildClassFilename($class, $namespace);
+ }
+ }
+
+ return null;
+ }
+
+ /**
+ * Extract the Icinga module namespace from a given namespaced class name
+ *
+ * Does no validation, prefix must have been checked before
+ *
+ * @return string
+ */
+ protected function extractModuleNamespace($class)
+ {
+ return substr(
+ $class,
+ 0,
+ strpos($class, self::NAMESPACE_SEPARATOR, self::MODULE_PREFIX_LENGTH + 1)
+ );
+ }
+
+ /**
+ * Extract the Icinga module name from a given namespaced class name
+ *
+ * Does no validation, prefix must have been checked before
+ *
+ * @return string
+ */
+ public static function extractModuleName($class)
+ {
+ return lcfirst(
+ substr(
+ $class,
+ self::MODULE_PREFIX_LENGTH,
+ strpos(
+ $class,
+ self::NAMESPACE_SEPARATOR,
+ self::MODULE_PREFIX_LENGTH + 1
+ ) - self::MODULE_PREFIX_LENGTH
+ )
+ );
+ }
+
+ /**
+ * Whether the given class name belongs to a module namespace
+ *
+ * @return boolean
+ */
+ public static function classBelongsToModule($class)
+ {
+ return substr($class, 0, self::MODULE_PREFIX_LENGTH) === self::MODULE_PREFIX;
+ }
+
+ /**
+ * Prepare a filename string for the given class
+ *
+ * Expects the given namespace to be registered with a path name
+ *
+ * @return string
+ */
+ protected function buildClassFilename($class, $namespace)
+ {
+ $relNs = substr($class, strlen($namespace) + 1);
+
+ if ($this->namespaceHasApplictionDirectory($namespace)) {
+ $prefixSeparator = strpos($relNs, self::NAMESPACE_SEPARATOR);
+ $prefix = substr($relNs, 0, $prefixSeparator);
+
+ if ($this->isApplicationPrefix($prefix)) {
+ return $this->applicationDirectories[$namespace]
+ . DIRECTORY_SEPARATOR
+ . $this->applicationPrefixes[$prefix]
+ . $this->classToRelativePhpFilename(substr($relNs, $prefixSeparator));
+ }
+ }
+
+ return $this->namespaces[$namespace] . DIRECTORY_SEPARATOR . $this->classToRelativePhpFilename($relNs);
+ }
+
+ /**
+ * Return the relative file name for the given (namespaces) class
+ *
+ * @param string $class
+ *
+ * @return string
+ */
+ protected function classToRelativePhpFilename($class)
+ {
+ return str_replace(
+ self::NAMESPACE_SEPARATOR,
+ DIRECTORY_SEPARATOR,
+ $class
+ ) . '.php';
+ }
+
+ /**
+ * Whether given prefix (Forms, Controllers...) makes part of "application"
+ *
+ * @param string $prefix
+ *
+ * @return boolean
+ */
+ protected function isApplicationPrefix($prefix)
+ {
+ return array_key_exists($prefix, $this->applicationPrefixes);
+ }
+
+ /**
+ * Whether the given namespace registered an application directory
+ *
+ * @return boolean
+ */
+ protected function namespaceHasApplictionDirectory($namespace)
+ {
+ return array_key_exists($namespace, $this->applicationDirectories);
+ }
+
+ /**
+ * Load the given class or interface
+ *
+ * @param string $class Name of the class or interface
+ *
+ * @return bool Whether the class or interface has been loaded
+ */
+ public function loadClass($class)
+ {
+ if ($file = $this->getSourceFile($class)) {
+ if (file_exists($file)) {
+ require $file;
+ return true;
+ }
+ }
+
+ return false;
+ }
+
+ /**
+ * Register {@link loadClass()} as an autoloader
+ */
+ public function register()
+ {
+ spl_autoload_register(array($this, 'loadClass'));
+ }
+
+ /**
+ * Unregister {@link loadClass()} as an autoloader
+ */
+ public function unregister()
+ {
+ spl_autoload_unregister(array($this, 'loadClass'));
+ }
+
+ /**
+ * Unregister this as an autoloader
+ */
+ public function __destruct()
+ {
+ $this->unregister();
+ }
+}
diff --git a/library/Icinga/Application/Cli.php b/library/Icinga/Application/Cli.php
new file mode 100644
index 0000000..3b93738
--- /dev/null
+++ b/library/Icinga/Application/Cli.php
@@ -0,0 +1,211 @@
+<?php
+/* Icinga Web 2 | (c) 2013 Icinga Development Team | GPLv2+ */
+
+namespace Icinga\Application;
+
+use Icinga\Application\Platform;
+use Icinga\Application\ApplicationBootstrap;
+use Icinga\Authentication\Auth;
+use Icinga\Cli\Params;
+use Icinga\Cli\Loader;
+use Icinga\Cli\Screen;
+use Icinga\Application\Logger;
+use Icinga\Application\Benchmark;
+use Icinga\Data\ConfigObject;
+use Icinga\Exception\ProgrammingError;
+use Icinga\User;
+
+require_once __DIR__ . '/ApplicationBootstrap.php';
+
+class Cli extends ApplicationBootstrap
+{
+ protected $isCli = true;
+
+ protected $params;
+
+ protected $showBenchmark = false;
+
+ protected $watchTimeout;
+
+ protected $cliLoader;
+
+ protected $verbose;
+
+ protected $debug;
+
+ protected function bootstrap()
+ {
+ $this->assertRunningOnCli();
+ $this->setupLogging()
+ ->setupErrorHandling()
+ ->loadLibraries()
+ ->loadConfig()
+ ->setupTimezone()
+ ->prepareInternationalization()
+ ->setupInternationalization()
+ ->parseBasicParams()
+ ->setupLogger()
+ ->setupModuleManager()
+ ->setupUserBackendFactory()
+ ->loadSetupModuleIfNecessary()
+ ->setupFakeAuthentication()
+ ->registerApplicationHooks();
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ protected function setupLogging()
+ {
+ Logger::create(
+ new ConfigObject(
+ array(
+ 'log' => 'stderr'
+ )
+ )
+ );
+
+ return $this;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ protected function setupLogger()
+ {
+ $config = new ConfigObject();
+ $config->log = $this->params->shift('log', 'stderr');
+ if ($config->log === 'file') {
+ $config->file = $this->params->shiftRequired('log-path');
+ } elseif ($config->log === 'syslog') {
+ $config->application = 'icingacli';
+ }
+
+ if ($this->params->get('verbose', false)) {
+ $config->level = Logger::INFO;
+ } elseif ($this->params->get('debug', false)) {
+ $config->level = Logger::DEBUG;
+ } else {
+ $config->level = Logger::WARNING;
+ }
+
+ Logger::create($config);
+ return $this;
+ }
+
+ protected function setupFakeAuthentication()
+ {
+ Auth::getInstance()->setUser(new User('cli'));
+
+ return $this;
+ }
+
+ public function cliLoader()
+ {
+ if ($this->cliLoader === null) {
+ $this->cliLoader = new Loader($this);
+ }
+ return $this->cliLoader;
+ }
+
+ protected function parseBasicParams()
+ {
+ $this->params = Params::parse();
+ if ($this->params->shift('help')) {
+ $this->params->unshift('help');
+ }
+ if ($this->params->shift('version')) {
+ $this->params->unshift('version');
+ }
+ if ($this->params->shift('autocomplete')) {
+ $this->params->unshift('autocomplete');
+ }
+
+ $watch = $this->params->shift('watch');
+ if ($watch === true) {
+ $this->watchTimeout = 5;
+ } elseif (is_numeric($watch)) {
+ $this->watchTimeout = (int) $watch;
+ }
+
+ $this->debug = (int) $this->params->get('debug');
+ $this->verbose = (int) $this->params->get('verbose');
+
+ $this->showBenchmark = (bool) $this->params->shift('benchmark');
+ return $this;
+ }
+
+ public function getParams()
+ {
+ return $this->params;
+ }
+
+ public function dispatchModule($name, $basedir = null)
+ {
+ $this->getModuleManager()->loadModule($name, $basedir);
+ $this->cliLoader()->setModuleName($name);
+ $this->dispatch();
+ }
+
+ public function dispatch()
+ {
+ Benchmark::measure('Dispatching CLI command');
+
+ if ($this->watchTimeout === null) {
+ $this->dispatchOnce();
+ } else {
+ $this->dispatchEndless();
+ }
+ }
+
+ protected function dispatchOnce()
+ {
+ $loader = $this->cliLoader();
+ $loader->parseParams();
+ $result = $loader->dispatch();
+ Benchmark::measure('All done');
+ if ($this->showBenchmark) {
+ Benchmark::dump();
+ }
+ if ($result === false) {
+ exit(3);
+ }
+ }
+
+ protected function dispatchEndless()
+ {
+ $loader = $this->cliLoader();
+ $loader->parseParams();
+ $screen = Screen::instance();
+
+ while (true) {
+ Benchmark::measure('Watch mode - loop begins');
+ ob_start();
+ $params = clone($this->params);
+ $loader->dispatch($params);
+ Benchmark::measure('Dispatch done');
+ if ($this->showBenchmark) {
+ Benchmark::dump();
+ }
+ Benchmark::reset();
+ $out = ob_get_contents();
+ ob_end_clean();
+ echo $screen->clear() . $out;
+ sleep($this->watchTimeout);
+ }
+ }
+
+ /**
+ * Fail if Icinga has not been called on CLI
+ *
+ * @throws ProgrammingError
+ * @return void
+ */
+ protected function assertRunningOnCli()
+ {
+ if (Platform::isCli()) {
+ return;
+ }
+ throw new ProgrammingError('Icinga is not running on CLI');
+ }
+}
diff --git a/library/Icinga/Application/Config.php b/library/Icinga/Application/Config.php
new file mode 100644
index 0000000..80fe3b8
--- /dev/null
+++ b/library/Icinga/Application/Config.php
@@ -0,0 +1,498 @@
+<?php
+/* Icinga Web 2 | (c) 2013 Icinga Development Team | GPLv2+ */
+
+namespace Icinga\Application;
+
+use Icinga\Exception\NotWritableError;
+use Iterator;
+use Countable;
+use LogicException;
+use UnexpectedValueException;
+use Icinga\Util\File;
+use Icinga\Data\ConfigObject;
+use Icinga\Data\Selectable;
+use Icinga\Data\SimpleQuery;
+use Icinga\File\Ini\IniWriter;
+use Icinga\File\Ini\IniParser;
+use Icinga\Exception\IcingaException;
+use Icinga\Exception\NotReadableError;
+use Icinga\Web\Navigation\Navigation;
+
+/**
+ * Container for INI like configuration and global registry of application and module related configuration.
+ */
+class Config implements Countable, Iterator, Selectable
+{
+ /**
+ * Configuration directory where ALL (application and module) configuration is located
+ *
+ * @var string
+ */
+ public static $configDir;
+
+ /**
+ * Application config instances per file
+ *
+ * @var array
+ */
+ protected static $app = array();
+
+ /**
+ * Module config instances per file
+ *
+ * @var array
+ */
+ protected static $modules = array();
+
+ /**
+ * Navigation config instances per type
+ *
+ * @var array
+ */
+ protected static $navigation = array();
+
+ /**
+ * The internal ConfigObject
+ *
+ * @var ConfigObject
+ */
+ protected $config;
+
+ /**
+ * The INI file this config has been loaded from or should be written to
+ *
+ * @var string
+ */
+ protected $configFile;
+
+ /**
+ * Create a new config
+ *
+ * @param ConfigObject $config The config object to handle
+ */
+ public function __construct(ConfigObject $config = null)
+ {
+ $this->config = $config !== null ? $config : new ConfigObject();
+ }
+
+ /**
+ * Return this config's file path
+ *
+ * @return string
+ */
+ public function getConfigFile()
+ {
+ return $this->configFile;
+ }
+
+ /**
+ * Set this config's file path
+ *
+ * @param string $filepath The path to the ini file
+ *
+ * @return $this
+ */
+ public function setConfigFile($filepath)
+ {
+ $this->configFile = $filepath;
+ return $this;
+ }
+
+ /**
+ * Return the internal ConfigObject
+ *
+ * @return ConfigObject
+ */
+ public function getConfigObject()
+ {
+ return $this->config;
+ }
+
+ /**
+ * Provide a query for the internal config object
+ *
+ * @return SimpleQuery
+ */
+ public function select()
+ {
+ return $this->config->select();
+ }
+
+ /**
+ * Return the count of available sections
+ *
+ * @return int
+ */
+ public function count(): int
+ {
+ return $this->select()->count();
+ }
+
+ /**
+ * Reset the current position of the internal config object
+ *
+ * @return void
+ */
+ public function rewind(): void
+ {
+ $this->config->rewind();
+ }
+
+ /**
+ * Return the section of the current iteration
+ *
+ * @return ConfigObject
+ */
+ public function current(): ConfigObject
+ {
+ return $this->config->current();
+ }
+
+ /**
+ * Return whether the position of the current iteration is valid
+ *
+ * @return bool
+ */
+ public function valid(): bool
+ {
+ return $this->config->valid();
+ }
+
+ /**
+ * Return the section's name of the current iteration
+ *
+ * @return string
+ */
+ public function key(): string
+ {
+ return $this->config->key();
+ }
+
+ /**
+ * Advance the position of the current iteration and return the new section
+ *
+ * @return void
+ */
+ public function next(): void
+ {
+ $this->config->next();
+ }
+
+ /**
+ * Return whether this config has any sections
+ *
+ * @return bool
+ */
+ public function isEmpty()
+ {
+ return $this->config->isEmpty();
+ }
+
+ /**
+ * Return this config's section names
+ *
+ * @return array
+ */
+ public function keys()
+ {
+ return $this->config->keys();
+ }
+
+ /**
+ * Return this config's data as associative array
+ *
+ * @return array
+ */
+ public function toArray()
+ {
+ return $this->config->toArray();
+ }
+
+ /**
+ * Return the value from a section's property
+ *
+ * @param string $section The section where the given property can be found
+ * @param string $key The section's property to fetch the value from
+ * @param mixed $default The value to return in case the section or the property is missing
+ *
+ * @return mixed
+ *
+ * @throws UnexpectedValueException In case the given section does not hold any configuration
+ */
+ public function get($section, $key, $default = null)
+ {
+ $value = $this->config->$section;
+ if ($value instanceof ConfigObject) {
+ $value = $value->$key;
+ } elseif ($value !== null) {
+ throw new UnexpectedValueException(
+ sprintf('Value "%s" is not of type "%s" or a sub-type of it', $value, get_class($this->config))
+ );
+ }
+
+ if ($value === null && $default !== null) {
+ $value = $default;
+ }
+
+ return $value;
+ }
+
+ /**
+ * Return the given section
+ *
+ * @param string $name The section's name
+ *
+ * @return ConfigObject
+ */
+ public function getSection($name)
+ {
+ $section = $this->config->get($name);
+ return $section !== null ? $section : new ConfigObject();
+ }
+
+ /**
+ * Set or replace a section
+ *
+ * @param string $name
+ * @param array|ConfigObject $config
+ *
+ * @return $this
+ */
+ public function setSection($name, $config = null)
+ {
+ if ($config === null) {
+ $config = new ConfigObject();
+ } elseif (! $config instanceof ConfigObject) {
+ $config = new ConfigObject($config);
+ }
+
+ $this->config->$name = $config;
+ return $this;
+ }
+
+ /**
+ * Remove a section
+ *
+ * @param string $name
+ *
+ * @return $this
+ */
+ public function removeSection($name)
+ {
+ unset($this->config->$name);
+ return $this;
+ }
+
+ /**
+ * Return whether the given section exists
+ *
+ * @param string $name
+ *
+ * @return bool
+ */
+ public function hasSection($name)
+ {
+ return isset($this->config->$name);
+ }
+
+ /**
+ * Initialize a new config using the given array
+ *
+ * The returned config has no file associated to it.
+ *
+ * @param array $array The array to initialize the config with
+ *
+ * @return Config
+ */
+ public static function fromArray(array $array)
+ {
+ return new static(new ConfigObject($array));
+ }
+
+ /**
+ * Load configuration from the given INI file
+ *
+ * @param string $file The file to parse
+ *
+ * @throws NotReadableError When the file cannot be read
+ */
+ public static function fromIni($file)
+ {
+ $emptyConfig = new static();
+
+ $filepath = realpath($file);
+ if ($filepath === false) {
+ $emptyConfig->setConfigFile($file);
+ } elseif (is_readable($filepath)) {
+ return IniParser::parseIniFile($filepath);
+ } elseif (@file_exists($filepath)) {
+ throw new NotReadableError(t('Cannot read config file "%s". Permission denied'), $filepath);
+ }
+
+ return $emptyConfig;
+ }
+
+ /**
+ * Save configuration to the given INI file
+ *
+ * @param string|null $filePath The path to the INI file or null in case this config's path should be used
+ * @param int $fileMode The file mode to store the file with
+ *
+ * @throws LogicException In case this config has no path and none is passed in either
+ * @throws NotWritableError In case the INI file cannot be written
+ *
+ * @todo create basepath and throw NotWritableError in case its not possible
+ */
+ public function saveIni($filePath = null, $fileMode = 0660)
+ {
+ if ($filePath === null && $this->configFile) {
+ $filePath = $this->configFile;
+ } elseif ($filePath === null) {
+ throw new LogicException('You need to pass $filePath or set a path using Config::setConfigFile()');
+ }
+
+ if (! file_exists($filePath)) {
+ File::create($filePath, $fileMode);
+ }
+
+ $this->getIniWriter($filePath, $fileMode)->write();
+ }
+
+ /**
+ * Return a IniWriter for this config
+ *
+ * @param string|null $filePath
+ * @param int $fileMode
+ *
+ * @return IniWriter
+ */
+ protected function getIniWriter($filePath = null, $fileMode = null)
+ {
+ return new IniWriter($this, $filePath, $fileMode);
+ }
+
+ /**
+ * Prepend configuration base dir to the given relative path
+ *
+ * @param string $path A relative path
+ *
+ * @return string
+ */
+ public static function resolvePath($path)
+ {
+ return self::$configDir . DIRECTORY_SEPARATOR . ltrim($path, DIRECTORY_SEPARATOR);
+ }
+
+ /**
+ * Retrieve a application config
+ *
+ * @param string $configname The configuration name (without ini suffix) to read and return
+ * @param bool $fromDisk When set true, the configuration will be read from disk, even
+ * if it already has been read
+ *
+ * @return Config The requested configuration
+ */
+ public static function app($configname = 'config', $fromDisk = false)
+ {
+ if (! isset(self::$app[$configname]) || $fromDisk) {
+ self::$app[$configname] = static::fromIni(static::resolvePath($configname . '.ini'));
+ }
+
+ return self::$app[$configname];
+ }
+
+ /**
+ * Retrieve a module config
+ *
+ * @param string $modulename The name of the module where to look for the requested configuration
+ * @param string $configname The configuration name (without ini suffix) to read and return
+ * @param bool $fromDisk When set true, the configuration will be read from disk, even
+ * if it already has been read
+ *
+ * @return Config The requested configuration
+ */
+ public static function module($modulename, $configname = 'config', $fromDisk = false)
+ {
+ if (! isset(self::$modules[$modulename])) {
+ self::$modules[$modulename] = array();
+ }
+
+ if (! isset(self::$modules[$modulename][$configname]) || $fromDisk) {
+ self::$modules[$modulename][$configname] = static::fromIni(
+ static::resolvePath('modules/' . $modulename . '/' . $configname . '.ini')
+ );
+ }
+ return self::$modules[$modulename][$configname];
+ }
+
+ /**
+ * Retrieve a navigation config
+ *
+ * @param string $type The type identifier of the navigation item for which to return its config
+ * @param string $username A user's name or null if the shared config is desired
+ * @param bool $fromDisk If true, the configuration will be read from disk
+ *
+ * @return Config The requested configuration
+ */
+ public static function navigation($type, $username = null, $fromDisk = false)
+ {
+ if (! isset(self::$navigation[$type])) {
+ self::$navigation[$type] = array();
+ }
+
+ $branch = $username ?: 'shared';
+ $typeConfigs = self::$navigation[$type];
+ if (! isset($typeConfigs[$branch]) || $fromDisk) {
+ $typeConfigs[$branch] = static::fromIni(static::getNavigationConfigPath($type, $username));
+ }
+
+ return $typeConfigs[$branch];
+ }
+
+ /**
+ * Return the path to the configuration file for the given navigation item type and user
+ *
+ * @param string $type
+ * @param string $username
+ *
+ * @return string
+ *
+ * @throws IcingaException In case the given type is unknown
+ */
+ protected static function getNavigationConfigPath($type, $username = null)
+ {
+ $itemTypeConfig = Navigation::getItemTypeConfiguration();
+ if (! isset($itemTypeConfig[$type])) {
+ throw new IcingaException('Invalid navigation item type %s provided', $type);
+ }
+
+ if (isset($itemTypeConfig[$type]['config'])) {
+ $filename = $itemTypeConfig[$type]['config'] . '.ini';
+ } else {
+ $filename = $type . 's.ini';
+ }
+
+ if ($username) {
+ $path = static::resolvePath(implode(DIRECTORY_SEPARATOR, array('preferences', $username, $filename)));
+ if (realpath($path) === false) {
+ $path = static::resolvePath(implode(
+ DIRECTORY_SEPARATOR,
+ array('preferences', strtolower($username), $filename)
+ ));
+ }
+ } else {
+ $path = static::resolvePath('navigation' . DIRECTORY_SEPARATOR . $filename);
+ }
+ return $path;
+ }
+
+ /**
+ * Return this config rendered as a INI structured string
+ *
+ * @return string
+ */
+ public function __toString()
+ {
+ return $this->getIniWriter()->render();
+ }
+}
diff --git a/library/Icinga/Application/EmbeddedWeb.php b/library/Icinga/Application/EmbeddedWeb.php
new file mode 100644
index 0000000..9adb3a4
--- /dev/null
+++ b/library/Icinga/Application/EmbeddedWeb.php
@@ -0,0 +1,115 @@
+<?php
+/* Icinga Web 2 | (c) 2013 Icinga Development Team | GPLv2+ */
+
+namespace Icinga\Application;
+
+require_once dirname(__FILE__) . '/ApplicationBootstrap.php';
+
+use Icinga\Web\Request;
+use Icinga\Web\Response;
+use ipl\I18n\NoopTranslator;
+use ipl\I18n\StaticTranslator;
+
+/**
+ * Use this if you want to make use of Icinga functionality in other web projects
+ *
+ * Usage example:
+ * <code>
+ * use Icinga\Application\EmbeddedWeb;
+ * EmbeddedWeb::start();
+ * </code>
+ */
+class EmbeddedWeb extends ApplicationBootstrap
+{
+ /**
+ * Request
+ *
+ * @var Request
+ */
+ protected $request;
+
+ /**
+ * Response
+ *
+ * @var Response
+ */
+ protected $response;
+
+ /**
+ * Get the request
+ *
+ * @return Request
+ */
+ public function getRequest()
+ {
+ return $this->request;
+ }
+
+ /**
+ * Get the response
+ *
+ * @return Response
+ */
+ public function getResponse()
+ {
+ return $this->response;
+ }
+
+ /**
+ * Embedded bootstrap parts
+ *
+ * @see ApplicationBootstrap::bootstrap
+ *
+ * @return $this
+ */
+ protected function bootstrap()
+ {
+ return $this
+ ->setupErrorHandling()
+ ->loadLibraries()
+ ->loadConfig()
+ ->setupLogging()
+ ->setupLogger()
+ ->setupRequest()
+ ->setupResponse()
+ ->setupTimezone()
+ ->prepareFakeInternationalization()
+ ->setupModuleManager()
+ ->loadEnabledModules()
+ ->registerApplicationHooks();
+ }
+
+ /**
+ * Set the request
+ *
+ * @return $this
+ */
+ protected function setupRequest()
+ {
+ $this->request = new Request();
+ return $this;
+ }
+
+ /**
+ * Set the response
+ *
+ * @return $this
+ */
+ protected function setupResponse()
+ {
+ $this->response = new Response();
+ return $this;
+ }
+
+ /**
+ * Prepare fake internationalization
+ *
+ * @return $this
+ */
+ protected function prepareFakeInternationalization()
+ {
+ StaticTranslator::$instance = new NoopTranslator();
+
+ return $this;
+ }
+}
diff --git a/library/Icinga/Application/Hook.php b/library/Icinga/Application/Hook.php
new file mode 100644
index 0000000..9720c6a
--- /dev/null
+++ b/library/Icinga/Application/Hook.php
@@ -0,0 +1,328 @@
+<?php
+/* Icinga Web 2 | (c) 2013 Icinga Development Team | GPLv2+ */
+
+namespace Icinga\Application;
+
+use Exception;
+use Icinga\Authentication\Auth;
+use Icinga\Application\Modules\Manager;
+use Icinga\Exception\ProgrammingError;
+
+/**
+ * Icinga Hook registry
+ *
+ * Modules making use of predefined hooks have to use this registry
+ *
+ * Usage:
+ * <code>
+ * Hook::register('grapher', 'My\\Grapher\\Class');
+ * </code>
+ */
+class Hook
+{
+ /**
+ * Our hook name registry
+ *
+ * @var array
+ */
+ protected static $hooks = array();
+
+ /**
+ * Hooks that have already been instantiated
+ *
+ * @var array
+ */
+ protected static $instances = array();
+
+ /**
+ * Namespace prefix
+ *
+ * @var string
+ */
+ public static $BASE_NS = 'Icinga\\Application\\Hook\\';
+
+ /**
+ * Append this string to base class
+ *
+ * All base classes renamed to *Hook
+ *
+ * @var string
+ */
+ public static $classSuffix = 'Hook';
+
+ /**
+ * Reset object state
+ */
+ public static function clean()
+ {
+ self::$hooks = array();
+ self::$instances = array();
+ self::$BASE_NS = 'Icinga\\Application\\Hook\\';
+ }
+
+ /**
+ * Whether someone registered itself for the given hook name
+ *
+ * @param string $name One of the predefined hook names
+ *
+ * @return bool
+ */
+ public static function has($name)
+ {
+ $name = self::normalizeHookName($name);
+
+ if (! array_key_exists($name, self::$hooks)) {
+ return false;
+ }
+
+ foreach (self::$hooks[$name] as $hook) {
+ list($class, $alwaysRun) = $hook;
+ if ($alwaysRun || self::hasPermission($class)) {
+ return true;
+ }
+ }
+
+ return false;
+ }
+
+ protected static function normalizeHookName($name)
+ {
+ if (strpos($name, '\\') === false) {
+ $parts = explode('/', $name);
+ foreach ($parts as & $part) {
+ $part = ucfirst($part);
+ }
+
+ return implode('\\', $parts);
+ }
+
+ return $name;
+ }
+
+ /**
+ * Create or return an instance of a given hook
+ *
+ * TODO: Should return some kind of a hook interface
+ *
+ * @param string $name One of the predefined hook names
+ * @param string $key The identifier of a specific subtype
+ *
+ * @return mixed
+ */
+ public static function createInstance($name, $key)
+ {
+ $name = self::normalizeHookName($name);
+
+ if (!self::has($name)) {
+ return null;
+ }
+
+ if (isset(self::$instances[$name][$key])) {
+ return self::$instances[$name][$key];
+ }
+
+ $class = self::$hooks[$name][$key][0];
+
+ if (! class_exists($class)) {
+ throw new ProgrammingError(
+ 'Erraneous hook implementation, class "%s" does not exist',
+ $class
+ );
+ }
+ try {
+ $instance = new $class();
+ } catch (Exception $e) {
+ Logger::debug(
+ 'Hook "%s" (%s) (%s) failed, will be unloaded: %s',
+ $name,
+ $key,
+ $class,
+ $e->getMessage()
+ );
+ // TODO: Persist unloading for "some time" or "current session"
+ unset(self::$hooks[$name][$key]);
+ return null;
+ }
+
+ self::assertValidHook($instance, $name);
+ self::$instances[$name][$key] = $instance;
+ return $instance;
+ }
+
+ protected static function splitHookName($name)
+ {
+ $sep = '\\';
+ if (false === $module = strpos($name, $sep)) {
+ return array(null, $name);
+ }
+ return array(
+ substr($name, 0, $module),
+ substr($name, $module + 1)
+ );
+ }
+
+ /**
+ * Extract the Icinga module name from a given namespaced class name
+ *
+ * Does no validation, prefix must have been checked before
+ *
+ * Shameless copy of ClassLoader::extractModuleName()
+ *
+ * @param string $class The hook's class path
+ *
+ * @return string
+ */
+ protected static function extractModuleName($class)
+ {
+ return lcfirst(
+ substr(
+ $class,
+ ClassLoader::MODULE_PREFIX_LENGTH,
+ strpos(
+ $class,
+ ClassLoader::NAMESPACE_SEPARATOR,
+ ClassLoader::MODULE_PREFIX_LENGTH + 1
+ ) - ClassLoader::MODULE_PREFIX_LENGTH
+ )
+ );
+ }
+
+ /**
+ * Return whether the user has the permission to access the module which provides the given hook
+ *
+ * @param string $class The hook's class path
+ *
+ * @return bool
+ */
+ protected static function hasPermission($class)
+ {
+ if (Icinga::app()->isCli()) {
+ return true;
+ }
+
+ return Auth::getInstance()->hasPermission(
+ Manager::MODULE_PERMISSION_NS . self::extractModuleName($class)
+ );
+ }
+
+ /**
+ * Test for a valid class name
+ *
+ * @param mixed $instance
+ * @param string $name
+ *
+ * @throws ProgrammingError
+ */
+ private static function assertValidHook($instance, $name)
+ {
+ $name = self::normalizeHookName($name);
+
+ $suffix = self::$classSuffix; // 'Hook'
+ $base = self::$BASE_NS; // 'Icinga\\Web\\Hook\\'
+
+ list($module, $name) = self::splitHookName($name);
+
+ if ($module === null) {
+ $base_class = $base . ucfirst($name) . 'Hook';
+
+ // I'm unsure whether this makes sense. Unused and Wrong.
+ if (strpos($base_class, $suffix) === false) {
+ $base_class .= $suffix;
+ }
+ } else {
+ $base_class = 'Icinga\\Module\\'
+ . ucfirst($module)
+ . '\\Hook\\'
+ . ucfirst($name)
+ . $suffix;
+ }
+
+ if (!$instance instanceof $base_class) {
+ // This is a compatibility check. Should be removed one far day:
+ if ($module !== null) {
+ $compat_class = 'Icinga\\Module\\'
+ . ucfirst($module)
+ . '\\Web\\Hook\\'
+ . ucfirst($name)
+ . $suffix;
+
+ if ($instance instanceof $compat_class) {
+ return;
+ }
+ }
+
+ throw new ProgrammingError(
+ '%s is not an instance of %s',
+ get_class($instance),
+ $base_class
+ );
+ }
+ }
+
+ /**
+ * Return all instances of a specific name
+ *
+ * @param string $name One of the predefined hook names
+ *
+ * @return array
+ */
+ public static function all($name): array
+ {
+ $name = self::normalizeHookName($name);
+ if (! self::has($name)) {
+ return [];
+ }
+
+ foreach (self::$hooks[$name] as $key => $hook) {
+ list($class, $alwaysRun) = $hook;
+ if ($alwaysRun || self::hasPermission($class)) {
+ self::createInstance($name, $key);
+ }
+ }
+
+ return self::$instances[$name] ?? [];
+ }
+
+ /**
+ * Get the first hook
+ *
+ * @param string $name One of the predefined hook names
+ *
+ * @return null|mixed
+ */
+ public static function first($name)
+ {
+ $name = self::normalizeHookName($name);
+
+ if (self::has($name)) {
+ foreach (self::$hooks[$name] as $key => $hook) {
+ list($class, $alwaysRun) = $hook;
+ if ($alwaysRun || self::hasPermission($class)) {
+ return self::createInstance($name, $key);
+ }
+ }
+ }
+ }
+
+ /**
+ * Register a class
+ *
+ * @param string $name One of the predefined hook names
+ * @param string $key The identifier of a specific subtype
+ * @param string $class Your class name, must inherit one of the
+ * classes in the Icinga/Application/Hook folder
+ * @param bool $alwaysRun To run the hook always (e.g. without permission check)
+ */
+ public static function register($name, $key, $class, $alwaysRun = false)
+ {
+ $name = self::normalizeHookName($name);
+
+ if (!isset(self::$hooks[$name])) {
+ self::$hooks[$name] = array();
+ }
+
+ $class = ltrim($class, ClassLoader::NAMESPACE_SEPARATOR);
+
+ self::$hooks[$name][$key] = [$class, $alwaysRun];
+ }
+}
diff --git a/library/Icinga/Application/Hook/ApplicationStateHook.php b/library/Icinga/Application/Hook/ApplicationStateHook.php
new file mode 100644
index 0000000..be973fe
--- /dev/null
+++ b/library/Icinga/Application/Hook/ApplicationStateHook.php
@@ -0,0 +1,90 @@
+<?php
+/* Icinga Web 2 | (c) 2018 Icinga Development Team | GPLv2+ */
+
+namespace Icinga\Application\Hook;
+
+use Icinga\Application\Hook;
+use Icinga\Application\Logger;
+
+/**
+ * Application state hook base class
+ */
+abstract class ApplicationStateHook
+{
+ const ERROR = 'error';
+
+ private $messages = [];
+
+ final public function hasMessages()
+ {
+ return ! empty($this->messages);
+ }
+
+ final public function getMessages()
+ {
+ return $this->messages;
+ }
+
+ /**
+ * Add an error message
+ *
+ * The timestamp of the message is used for deduplication and thus must refer to the time when the error first
+ * occurred. Don't use {@link time()} here!
+ *
+ * @param string $id ID of the message. The ID must be prefixed with the module name
+ * @param int $timestamp Timestamp when the error first occurred
+ * @param string $message Error message
+ *
+ * @return $this
+ */
+ final public function addError($id, $timestamp, $message)
+ {
+ $id = trim($id);
+ $timestamp = (int) $timestamp;
+
+ if (! strlen($id)) {
+ throw new \InvalidArgumentException('ID expected.');
+ }
+
+ if (! $timestamp) {
+ throw new \InvalidArgumentException('Timestamp expected.');
+ }
+
+ $this->messages[sha1($id . $timestamp)] = [self::ERROR, $timestamp, $message];
+
+ return $this;
+ }
+
+ /**
+ * Override this method in order to provide application state messages
+ */
+ abstract public function collectMessages();
+
+ final public static function getAllMessages()
+ {
+ $messages = [];
+
+ if (! Hook::has('ApplicationState')) {
+ return $messages;
+ }
+
+ foreach (Hook::all('ApplicationState') as $hook) {
+ /** @var self $hook */
+ try {
+ $hook->collectMessages();
+ } catch (\Exception $e) {
+ Logger::error(
+ "Failed to collect messages from hook '%s'. An error occurred: %s",
+ get_class($hook),
+ $e
+ );
+ }
+
+ if ($hook->hasMessages()) {
+ $messages += $hook->getMessages();
+ }
+ }
+
+ return $messages;
+ }
+}
diff --git a/library/Icinga/Application/Hook/AuditHook.php b/library/Icinga/Application/Hook/AuditHook.php
new file mode 100644
index 0000000..e6209da
--- /dev/null
+++ b/library/Icinga/Application/Hook/AuditHook.php
@@ -0,0 +1,123 @@
+<?php
+/* Icinga Web 2 | (c) 2018 Icinga Development Team | GPLv2+ */
+
+namespace Icinga\Application\Hook;
+
+use Exception;
+use InvalidArgumentException;
+use Icinga\Authentication\Auth;
+use Icinga\Application\Hook;
+use Icinga\Application\Logger;
+
+abstract class AuditHook
+{
+ /**
+ * Log an activity to the audit log
+ *
+ * Propagates the given message details to all known hook implementations.
+ *
+ * @param string $type An arbitrary name identifying the type of activity
+ * @param string $message A detailed description possibly referencing parameters in $data
+ * @param array $data Additional information (How this is stored or used is up to each implementation)
+ * @param string $identity An arbitrary name identifying the responsible subject, defaults to the current user
+ * @param int $time A timestamp defining when the activity occurred, defaults to now
+ */
+ public static function logActivity($type, $message, array $data = null, $identity = null, $time = null)
+ {
+ if (! Hook::has('audit')) {
+ return;
+ }
+
+ if ($identity === null) {
+ $identity = Auth::getInstance()->getUser()->getUsername();
+ }
+
+ if ($time === null) {
+ $time = time();
+ }
+
+ foreach (Hook::all('audit') as $hook) {
+ /** @var self $hook */
+ try {
+ $formattedMessage = $message;
+ if ($data !== null) {
+ // Calling formatMessage on each hook is intended and allows
+ // intercepting message formatting while keeping it implicit
+ $formattedMessage = $hook->formatMessage($message, $data);
+ }
+
+ $hook->logMessage($time, $identity, $type, $formattedMessage, $data);
+ } catch (Exception $e) {
+ Logger::error(
+ 'Failed to propagate audit message to hook "%s". An error occurred: %s',
+ get_class($hook),
+ $e
+ );
+ }
+ }
+ }
+
+ /**
+ * Log a message to the audit log
+ *
+ * @param int $time A timestamp defining when the activity occurred
+ * @param string $identity An arbitrary name identifying the responsible subject
+ * @param string $type An arbitrary name identifying the type of activity
+ * @param string $message A detailed description of the activity
+ * @param array $data Additional activity information
+ */
+ abstract public function logMessage($time, $identity, $type, $message, array $data = null);
+
+ /**
+ * Substitute the given message with its accompanying data
+ *
+ * @param string $message
+ * @param array $messageData
+ *
+ * @return string
+ */
+ public function formatMessage($message, array $messageData)
+ {
+ return preg_replace_callback('/{{(.+?)}}/', function ($match) use ($messageData) {
+ return $this->extractMessageValue(explode('.', $match[1]), $messageData);
+ }, $message);
+ }
+
+ /**
+ * Extract the given value path from the given message data
+ *
+ * @param array $path
+ * @param array $messageData
+ *
+ * @return mixed
+ *
+ * @throws InvalidArgumentException In case of an invalid or missing format parameter
+ */
+ protected function extractMessageValue(array $path, array $messageData)
+ {
+ $key = array_shift($path);
+ if (array_key_exists($key, $messageData)) {
+ $value = $messageData[$key];
+ } else {
+ throw new InvalidArgumentException("Missing format parameter '$key'");
+ }
+
+ if (empty($path)) {
+ if (! is_scalar($value)) {
+ throw new InvalidArgumentException(
+ 'Invalid format parameter. Expected scalar for path "' . join('.', $path) . '".'
+ . ' Got "' . gettype($value) . '" instead'
+ );
+ }
+
+ return $value;
+ } elseif (! is_array($value)) {
+ throw new InvalidArgumentException(
+ 'Invalid format parameter. Expected array for path "'. join('.', $path) . '".'
+ . ' Got "' . gettype($value) . '" instead'
+ );
+ }
+
+ return $this->extractMessageValue($path, $value);
+ }
+}
diff --git a/library/Icinga/Application/Hook/AuthenticationHook.php b/library/Icinga/Application/Hook/AuthenticationHook.php
new file mode 100644
index 0000000..41cc661
--- /dev/null
+++ b/library/Icinga/Application/Hook/AuthenticationHook.php
@@ -0,0 +1,75 @@
+<?php
+
+namespace Icinga\Application\Hook;
+
+use Icinga\User;
+use Icinga\Web\Hook;
+use Icinga\Application\Logger;
+
+/**
+ * Icinga Web Authentication Hook base class
+ *
+ * This hook can be used to authenticate the user in a third party application.
+ * Extend this class if you want to perform arbitrary actions during the login and logout.
+ */
+abstract class AuthenticationHook
+{
+ /**
+ * Name of the hook
+ */
+ const NAME = 'authentication';
+
+ /**
+ * Triggered after login in Icinga Web and when calling login action even if already authenticated in Icinga Web
+ *
+ * @param User $user
+ */
+ public function onLogin(User $user)
+ {
+ }
+
+ /**
+ * Triggered before logout from Icinga Web
+ *
+ * @param User $user
+ */
+ public function onLogout(User $user)
+ {
+ }
+
+ /**
+ * Call the onLogin() method of all registered AuthHook(s)
+ *
+ * @param User $user
+ */
+ public static function triggerLogin(User $user)
+ {
+ /** @var AuthenticationHook $hook */
+ foreach (Hook::all(self::NAME) as $hook) {
+ try {
+ $hook->onLogin($user);
+ } catch (\Exception $e) {
+ // Avoid error propagation if login failed in third party application
+ Logger::error($e);
+ }
+ }
+ }
+
+ /**
+ * Call the onLogout() method of all registered AuthHook(s)
+ *
+ * @param User $user
+ */
+ public static function triggerLogout(User $user)
+ {
+ /** @var AuthenticationHook $hook */
+ foreach (Hook::all(self::NAME) as $hook) {
+ try {
+ $hook->onLogout($user);
+ } catch (\Exception $e) {
+ // Avoid error propagation if login failed in third party application
+ Logger::error($e);
+ }
+ }
+ }
+}
diff --git a/library/Icinga/Application/Hook/Common/DbMigrationStep.php b/library/Icinga/Application/Hook/Common/DbMigrationStep.php
new file mode 100644
index 0000000..54a1139
--- /dev/null
+++ b/library/Icinga/Application/Hook/Common/DbMigrationStep.php
@@ -0,0 +1,129 @@
+<?php
+
+/* Icinga Web 2 | (c) 2023 Icinga GmbH | GPLv2+ */
+
+namespace Icinga\Application\Hook\Common;
+
+use ipl\Sql\Connection;
+use RuntimeException;
+
+class DbMigrationStep
+{
+ /** @var string The sql script version the queries are loaded from */
+ protected $version;
+
+ /** @var string */
+ protected $scriptPath;
+
+ /** @var ?string */
+ protected $description;
+
+ /** @var ?string */
+ protected $lastState;
+
+ public function __construct(string $version, string $scriptPath)
+ {
+ $this->scriptPath = $scriptPath;
+ $this->version = $version;
+ }
+
+ /**
+ * Get the sql script version the queries are loaded from
+ *
+ * @return string
+ */
+ public function getVersion(): string
+ {
+ return $this->version;
+ }
+
+ /**
+ * Get upgrade script relative path name
+ *
+ * @return string
+ */
+ public function getScriptPath(): string
+ {
+ return $this->scriptPath;
+ }
+
+ /**
+ * Get the description of this database migration if any
+ *
+ * @return ?string
+ */
+ public function getDescription(): ?string
+ {
+ return $this->description;
+ }
+
+ /**
+ * Set the description of this database migration
+ *
+ * @param ?string $description
+ *
+ * @return DbMigrationStep
+ */
+ public function setDescription(?string $description): self
+ {
+ $this->description = $description;
+
+ return $this;
+ }
+
+ /**
+ * Get the last error message of this hook if any
+ *
+ * @return ?string
+ */
+ public function getLastState(): ?string
+ {
+ return $this->lastState;
+ }
+
+ /**
+ * Set the last error message
+ *
+ * @param ?string $message
+ *
+ * @return $this
+ */
+ public function setLastState(?string $message): self
+ {
+ $this->lastState = $message;
+
+ return $this;
+ }
+
+ /**
+ * Perform the sql migration
+ *
+ * @param Connection $conn
+ *
+ * @return $this
+ *
+ * @throws RuntimeException Throws an error in case of any database errors or when there is nothing to migrate
+ */
+ public function apply(Connection $conn): self
+ {
+ $statements = @file_get_contents($this->getScriptPath());
+ if ($statements === false) {
+ throw new RuntimeException(sprintf('Cannot load upgrade script %s', $this->getScriptPath()));
+ }
+
+ if (empty($statements)) {
+ throw new RuntimeException('Nothing to migrate');
+ }
+
+ if (preg_match('/\s*delimiter\s*(\S+)\s*$/im', $statements, $matches)) {
+ /** @var string $statements */
+ $statements = preg_replace('/\s*delimiter\s*(\S+)\s*$/im', '', $statements);
+ /** @var string $statements */
+ $statements = preg_replace('/' . preg_quote($matches[1], '/') . '$/m', ';', $statements);
+ }
+
+ $conn->exec($statements);
+
+ return $this;
+ }
+}
diff --git a/library/Icinga/Application/Hook/ConfigFormEventsHook.php b/library/Icinga/Application/Hook/ConfigFormEventsHook.php
new file mode 100644
index 0000000..05fa05d
--- /dev/null
+++ b/library/Icinga/Application/Hook/ConfigFormEventsHook.php
@@ -0,0 +1,137 @@
+<?php
+/* Icinga Web 2 | (c) 2019 Icinga GmbH | GPLv2+ */
+
+namespace Icinga\Application\Hook;
+
+use Icinga\Application\Hook;
+use Icinga\Application\Logger;
+use Icinga\Exception\IcingaException;
+use Icinga\Web\Form;
+
+/**
+ * Base class for config form event hooks
+ */
+abstract class ConfigFormEventsHook
+{
+ /** @var array Array of errors found while processing the form event hooks */
+ private static $lastErrors = [];
+
+ /**
+ * Get whether the hook applies to the given config form
+ *
+ * @param Form $form
+ *
+ * @return bool
+ */
+ public function appliesTo(Form $form)
+ {
+ return false;
+ }
+
+ /**
+ * isValid event hook
+ *
+ * Implement this method in order to run code after the form has been validated successfully.
+ * Throw an exception here if either the form is not valid or you want interrupt the form handling.
+ * The exception's message will be automatically added as form error message so that it will be
+ * displayed in the frontend.
+ *
+ * @param Form $form
+ *
+ * @throws \Exception If either the form is not valid or to interrupt the form handling
+ */
+ public function isValid(Form $form)
+ {
+ }
+
+ /**
+ * onSuccess event hook
+ *
+ * Implement this method in order to run code after the configuration form has been stored successfully.
+ * You can't interrupt the form handling here. Any exception will be caught, logged and notified.
+ *
+ * @param Form $form
+ */
+ public function onSuccess(Form $form)
+ {
+ }
+
+ /**
+ * Get an array of errors found while processing the form event hooks
+ *
+ * @return array
+ */
+ final public static function getLastErrors()
+ {
+ return self::$lastErrors;
+ }
+
+ /**
+ * Run all isValid hooks
+ *
+ * @param Form $form
+ *
+ * @return bool Returns false if any hook threw an exception
+ */
+ final public static function runIsValid(Form $form)
+ {
+ return self::runEventMethod('isValid', $form);
+ }
+
+ /**
+ * Run all onSuccess hooks
+ *
+ * @param Form $form
+ *
+ * @return bool Returns false if any hook threw an exception
+ */
+ final public static function runOnSuccess(Form $form)
+ {
+ return self::runEventMethod('onSuccess', $form);
+ }
+
+ private static function runEventMethod($eventMethod, Form $form)
+ {
+ self::$lastErrors = [];
+
+ if (! Hook::has('ConfigFormEvents')) {
+ return true;
+ }
+
+ $success = true;
+
+ foreach (Hook::all('ConfigFormEvents') as $hook) {
+ /** @var self $hook */
+ if (! $hook->runAppliesTo($form)) {
+ continue;
+ }
+
+ try {
+ $hook->$eventMethod($form);
+ } catch (\Exception $e) {
+ self::$lastErrors[] = $e->getMessage();
+
+ Logger::error("%s\n%s", $e, IcingaException::getConfidentialTraceAsString($e));
+
+ $success = false;
+ }
+ }
+
+ return $success;
+ }
+
+ private function runAppliesTo(Form $form)
+ {
+ try {
+ $appliesTo = $this->appliesTo($form);
+ } catch (\Exception $e) {
+ // Don't save exception to last errors because we do not want to disturb the user for messed up
+ // appliesTo checks
+ Logger::error("%s\n%s", $e, IcingaException::getConfidentialTraceAsString($e));
+
+ $appliesTo = false;
+ }
+
+ return $appliesTo === true;
+ }
+}
diff --git a/library/Icinga/Application/Hook/DbMigrationHook.php b/library/Icinga/Application/Hook/DbMigrationHook.php
new file mode 100644
index 0000000..f34bc0d
--- /dev/null
+++ b/library/Icinga/Application/Hook/DbMigrationHook.php
@@ -0,0 +1,421 @@
+<?php
+
+/* Icinga Web 2 | (c) 2023 Icinga GmbH | GPLv2+ */
+
+namespace Icinga\Application\Hook;
+
+use Countable;
+use DateTime;
+use DirectoryIterator;
+use Exception;
+use Icinga\Application\ClassLoader;
+use Icinga\Application\Hook\Common\DbMigrationStep;
+use Icinga\Application\Icinga;
+use Icinga\Application\Logger;
+use Icinga\Application\Modules\Module;
+use Icinga\Model\Schema;
+use Icinga\Web\Session;
+use ipl\I18n\Translation;
+use ipl\Orm\Query;
+use ipl\Sql\Adapter\Pgsql;
+use ipl\Sql\Connection;
+use ipl\Stdlib\Filter;
+use PDO;
+use SplFileInfo;
+use stdClass;
+
+/**
+ * Allows you to automatically perform database migrations.
+ *
+ * The version numbers of the sql migrations are determined by extracting the respective migration script names.
+ * It's required to place the sql migrate scripts below the respective following directories:
+ *
+ * `{IcingaApp,Module}::baseDir()/schema/{mysql,pgsql}-upgrades`
+ */
+abstract class DbMigrationHook implements Countable
+{
+ use Translation;
+
+ public const MYSQL_UPGRADE_DIR = 'schema/mysql-upgrades';
+
+ public const PGSQL_UPGRADE_DIR = 'schema/pgsql-upgrades';
+
+ /** @var string Fakes a module when this hook is implemented by the framework itself */
+ public const DEFAULT_MODULE = 'icingaweb2';
+
+ /** @var string Migration hook param name */
+ public const MIGRATION_PARAM = 'migration';
+
+ public const ALL_MIGRATIONS = 'all-migrations';
+
+ /** @var ?array<string, DbMigrationStep> All pending database migrations of this hook */
+ protected $migrations;
+
+ /** @var ?string The current version of this hook */
+ protected $version;
+
+ /**
+ * Get whether the specified table exists in the given database
+ *
+ * @param Connection $conn
+ * @param string $table
+ *
+ * @return bool
+ */
+ public static function tableExists(Connection $conn, string $table): bool
+ {
+ /** @var false|int $exists */
+ $exists = $conn->prepexec(
+ 'SELECT EXISTS(SELECT 1 FROM information_schema.tables WHERE table_name = ?) AS result',
+ $table
+ )->fetchColumn();
+
+ return (bool) $exists;
+ }
+
+ /**
+ * Get whether the specified column exists in the provided table
+ *
+ * @param Connection $conn
+ * @param string $table
+ * @param string $column
+ *
+ * @return ?string
+ */
+ public static function getColumnType(Connection $conn, string $table, string $column): ?string
+ {
+ $pdoStmt = $conn->prepexec(
+ sprintf(
+ 'SELECT %s AS column_type, %s AS column_length FROM information_schema.columns'
+ . ' WHERE table_name = ? AND column_name = ?',
+ $conn->getAdapter() instanceof Pgsql ? 'udt_name' : 'column_type',
+ $conn->getAdapter() instanceof Pgsql ? 'character_maximum_length' : 'NULL'
+ ),
+ [$table, $column]
+ );
+
+ /** @var false|stdClass $result */
+ $result = $pdoStmt->fetch(PDO::FETCH_OBJ);
+ if ($result === false) {
+ return null;
+ }
+
+ if ($result->column_length !== null) {
+ $result->column_type .= '(' . $result->column_length . ')';
+ }
+
+ return $result->column_type;
+ }
+
+ /**
+ * Get the mysql collation name of the given column of the specified table
+ *
+ * @param Connection $conn
+ * @param string $table
+ * @param string $column
+ *
+ * @return ?string
+ */
+ public static function getColumnCollation(Connection $conn, string $table, string $column): ?string
+ {
+ if ($conn->getAdapter() instanceof Pgsql) {
+ return null;
+ }
+
+ /** @var false|string $collation */
+ $collation = $conn->prepexec(
+ 'SELECT collation_name FROM information_schema.columns WHERE table_name = ? AND column_name = ?',
+ [$table, $column]
+ )->fetchColumn();
+
+ return ! $collation ? null : $collation;
+ }
+
+ /**
+ * Get statically provided descriptions of the individual migrate scripts
+ *
+ * @return string[]
+ */
+ abstract public function providedDescriptions(): array;
+
+ /**
+ * Get the full name of the component this hook is implemented by
+ *
+ * @return string
+ */
+ abstract public function getName(): string;
+
+ /**
+ * Get the current schema version of this migration hook
+ *
+ * @return string
+ */
+ abstract public function getVersion(): string;
+
+ /**
+ * Get a database connection
+ *
+ * @return Connection
+ */
+ abstract public function getDb(): Connection;
+
+ /**
+ * Get all the pending migrations of this hook
+ *
+ * @return DbMigrationStep[]
+ */
+ public function getMigrations(): array
+ {
+ if ($this->migrations === null) {
+ $this->migrations = [];
+
+ $this->load();
+ }
+
+ return $this->migrations ?? [];
+ }
+
+ /**
+ * Get the latest migrations limited by the given number
+ *
+ * @param int $limit
+ *
+ * @return DbMigrationStep[]
+ */
+ public function getLatestMigrations(int $limit): array
+ {
+ $migrations = $this->getMigrations();
+ if ($limit > 0) {
+ $migrations = array_slice($migrations, -$limit, null, true);
+ }
+
+ return array_reverse($migrations);
+ }
+
+ /**
+ * Apply all pending migrations of this hook
+ *
+ * @param ?Connection $conn Use the provided database connection to apply the migrations.
+ * Is only used to elevate database users with insufficient privileges.
+ *
+ * @return bool Whether the migration(s) have been successfully applied
+ */
+ final public function run(Connection $conn = null): bool
+ {
+ if (! $conn) {
+ $conn = $this->getDb();
+ }
+
+ foreach ($this->getMigrations() as $migration) {
+ try {
+ $migration->apply($conn);
+
+ $this->version = $migration->getVersion();
+ unset($this->migrations[$migration->getVersion()]);
+
+ $data = [
+ 'name' => $this->getName(),
+ 'version' => $migration->getVersion()
+ ];
+ AuditHook::logActivity(
+ 'migrations',
+ 'Migrated database schema of {{name}} to version {{version}}',
+ $data
+ );
+
+ $this->storeState($migration->getVersion(), null);
+ } catch (Exception $e) {
+ Logger::error(
+ "Failed to apply %s pending migration version %s \n%s",
+ $this->getName(),
+ $migration->getVersion(),
+ $e
+ );
+ Logger::debug($e->getTraceAsString());
+
+ static::insertFailedEntry(
+ $conn,
+ $migration->getVersion(),
+ $e->getMessage() . PHP_EOL . $e->getTraceAsString()
+ );
+
+ return false;
+ }
+ }
+
+ return true;
+ }
+
+ /**
+ * Get whether this hook is implemented by a module
+ *
+ * @return bool
+ */
+ public function isModule(): bool
+ {
+ return ClassLoader::classBelongsToModule(static::class);
+ }
+
+ /**
+ * Get the name of the module this hook is implemented by
+ *
+ * @return string
+ */
+ public function getModuleName(): string
+ {
+ if (! $this->isModule()) {
+ return static::DEFAULT_MODULE;
+ }
+
+ return ClassLoader::extractModuleName(static::class);
+ }
+
+ /**
+ * Get the number of pending migrations of this hook
+ *
+ * @return int
+ */
+ public function count(): int
+ {
+ return count($this->getMigrations());
+ }
+
+ /**
+ * Get a schema version query
+ *
+ * @return Query
+ */
+ abstract protected function getSchemaQuery(): Query;
+
+ protected function load(): void
+ {
+ $upgradeDir = static::MYSQL_UPGRADE_DIR;
+ if ($this->getDb()->getAdapter() instanceof Pgsql) {
+ $upgradeDir = static::PGSQL_UPGRADE_DIR;
+ }
+
+ if (! $this->isModule()) {
+ $path = Icinga::app()->getBaseDir();
+ } else {
+ $path = Module::get($this->getModuleName())->getBaseDir();
+ }
+
+ $descriptions = $this->providedDescriptions();
+ $version = $this->getVersion();
+ /** @var SplFileInfo $file */
+ foreach (new DirectoryIterator($path . DIRECTORY_SEPARATOR . $upgradeDir) as $file) {
+ if (preg_match('/^(v)?([^_]+)(?:_(\w+))?\.sql$/', $file->getFilename(), $m, PREG_UNMATCHED_AS_NULL)) {
+ [$_, $_, $migrateVersion, $description] = array_pad($m, 4, null);
+ /** @var string $migrateVersion */
+ if ($migrateVersion && version_compare($migrateVersion, $version, '>')) {
+ $migration = new DbMigrationStep($migrateVersion, $file->getRealPath());
+ if (isset($descriptions[$migrateVersion])) {
+ $migration->setDescription($descriptions[$migrateVersion]);
+ } elseif ($description) {
+ $migration->setDescription(str_replace('_', ' ', $description));
+ }
+
+ $migration->setLastState($this->loadLastState($migrateVersion));
+
+ $this->migrations[$migrateVersion] = $migration;
+ }
+ }
+ }
+
+ if ($this->migrations) {
+ // Sort all the migrations by their version numbers in ascending order.
+ uksort($this->migrations, function ($a, $b) {
+ return version_compare($a, $b);
+ });
+ }
+ }
+
+ /**
+ * Insert failed migration entry into the database or to the session
+ *
+ * @param Connection $conn
+ * @param string $version
+ * @param string $reason
+ *
+ * @return $this
+ */
+ protected function insertFailedEntry(Connection $conn, string $version, string $reason): self
+ {
+ $schemaQuery = $this->getSchemaQuery()
+ ->filter(Filter::equal('version', $version));
+
+ if (! static::getColumnType($conn, $schemaQuery->getModel()->getTableName(), 'success')) {
+ $this->storeState($version, $reason);
+ } else {
+ /** @var Schema $schema */
+ $schema = $schemaQuery->first();
+ if ($schema) {
+ $conn->update($schema->getTableName(), [
+ 'timestamp' => (new DateTime())->getTimestamp() * 1000.0,
+ 'success' => 'n',
+ 'reason' => $reason
+ ], ['id = ?' => $schema->id]);
+ } else {
+ $conn->insert($schemaQuery->getModel()->getTableName(), [
+ 'version' => $version,
+ 'timestamp' => (new DateTime())->getTimestamp() * 1000.0,
+ 'success' => 'n',
+ 'reason' => $reason
+ ]);
+ }
+ }
+
+ return $this;
+ }
+
+ /**
+ * Store a failed state message in the session for the given version
+ *
+ * @param string $version
+ * @param ?string $reason
+ *
+ * @return $this
+ */
+ protected function storeState(string $version, ?string $reason): self
+ {
+ $session = Session::getSession()->getNamespace('migrations');
+ /** @var array<string, string> $states */
+ $states = $session->get($this->getModuleName(), []);
+ $states[$version] = $reason;
+
+ $session->set($this->getModuleName(), $states);
+
+ return $this;
+ }
+
+ /**
+ * Load last failed state from database/session for the given version
+ *
+ * @param string $version
+ *
+ * @return ?string
+ */
+ protected function loadLastState(string $version): ?string
+ {
+ $session = Session::getSession()->getNamespace('migrations');
+ /** @var array<string, string> $states */
+ $states = $session->get($this->getModuleName(), []);
+ if (! isset($states[$version])) {
+ $schemaQuery = $this->getSchemaQuery()
+ ->filter(Filter::equal('version', $version))
+ ->filter(Filter::all(Filter::equal('success', 'n')));
+
+ if (static::getColumnType($this->getDb(), $schemaQuery->getModel()->getTableName(), 'reason')) {
+ /** @var Schema $schema */
+ $schema = $schemaQuery->first();
+ if ($schema) {
+ return $schema->reason;
+ }
+ }
+
+ return null;
+ }
+
+ return $states[$version];
+ }
+}
diff --git a/library/Icinga/Application/Hook/GrapherHook.php b/library/Icinga/Application/Hook/GrapherHook.php
new file mode 100644
index 0000000..dfb2135
--- /dev/null
+++ b/library/Icinga/Application/Hook/GrapherHook.php
@@ -0,0 +1,111 @@
+<?php
+/* Icinga Web 2 | (c) 2013 Icinga Development Team | GPLv2+ */
+
+namespace Icinga\Application\Hook;
+
+use Icinga\Exception\ProgrammingError;
+use Icinga\Module\Monitoring\Object\MonitoredObject;
+
+/**
+ * Icinga Web Grapher Hook base class
+ *
+ * Extend this class if you want to integrate your graphing solution nicely into
+ * Icinga Web.
+ */
+abstract class GrapherHook extends WebBaseHook
+{
+ /**
+ * Whether this grapher provides previews
+ *
+ * @var bool
+ */
+ protected $hasPreviews = false;
+
+ /**
+ * Whether this grapher provides tiny previews
+ *
+ * @var bool
+ */
+ protected $hasTinyPreviews = false;
+
+ /**
+ * Constructor must live without arguments right now
+ *
+ * Therefore the constructor is final, we might change our opinion about
+ * this one far day
+ */
+ final public function __construct()
+ {
+ $this->init();
+ }
+
+ /**
+ * Overwrite this function if you want to do some initialization stuff
+ *
+ * @return void
+ */
+ protected function init()
+ {
+ }
+
+ /**
+ * Whether this grapher provides previews
+ *
+ * @return bool
+ */
+ public function hasPreviews()
+ {
+ return $this->hasPreviews;
+ }
+
+ /**
+ * Whether this grapher provides tiny previews
+ *
+ * @return bool
+ */
+ public function hasTinyPreviews()
+ {
+ return $this->hasTinyPreviews;
+ }
+
+ /**
+ * Whether a graph for the monitoring object exist
+ *
+ * @param MonitoredObject $object
+ *
+ * @return bool
+ */
+ abstract public function has(MonitoredObject $object);
+
+ /**
+ * Get a preview for the given object
+ *
+ * This function must return an empty string if no graph exists.
+ *
+ * @param MonitoredObject $object
+ *
+ * @return string
+ * @throws ProgrammingError
+ *
+ */
+ public function getPreviewHtml(MonitoredObject $object)
+ {
+ throw new ProgrammingError('This hook provide previews but it is not implemented');
+ }
+
+
+ /**
+ * Get a tiny preview for the given object
+ *
+ * This function must return an empty string if no graph exists.
+ *
+ * @param MonitoredObject $object
+ *
+ * @return string
+ * @throws ProgrammingError
+ */
+ public function getTinyPreviewHtml(MonitoredObject $object)
+ {
+ throw new ProgrammingError('This hook provide tiny previews but it is not implemented');
+ }
+}
diff --git a/library/Icinga/Application/Hook/HealthHook.php b/library/Icinga/Application/Hook/HealthHook.php
new file mode 100644
index 0000000..f6420b5
--- /dev/null
+++ b/library/Icinga/Application/Hook/HealthHook.php
@@ -0,0 +1,222 @@
+<?php
+/* Icinga Web 2 | (c) 2021 Icinga GmbH | GPLv2+ */
+
+namespace Icinga\Application\Hook;
+
+use Exception;
+use Icinga\Application\Hook;
+use Icinga\Application\Logger;
+use Icinga\Data\DataArray\ArrayDatasource;
+use Icinga\Exception\IcingaException;
+use ipl\Web\Url;
+use LogicException;
+
+abstract class HealthHook
+{
+ /** @var int */
+ const STATE_OK = 0;
+
+ /** @var int */
+ const STATE_WARNING = 1;
+
+ /** @var int */
+ const STATE_CRITICAL = 2;
+
+ /** @var int */
+ const STATE_UNKNOWN = 3;
+
+ /** @var int The overall state */
+ protected $state;
+
+ /** @var string Message describing the overall state */
+ protected $message;
+
+ /** @var array Available metrics */
+ protected $metrics;
+
+ /** @var Url Url to a graphical representation of the available metrics */
+ protected $url;
+
+ /**
+ * Get overall state
+ *
+ * @return int
+ */
+ public function getState()
+ {
+ return $this->state;
+ }
+
+ /**
+ * Set overall state
+ *
+ * @param int $state
+ *
+ * @return $this
+ */
+ public function setState($state)
+ {
+ $this->state = $state;
+
+ return $this;
+ }
+
+ /**
+ * Get the message describing the overall state
+ *
+ * @return string
+ */
+ public function getMessage()
+ {
+ return $this->message;
+ }
+
+ /**
+ * Set the message describing the overall state
+ *
+ * @param string $message
+ *
+ * @return $this
+ */
+ public function setMessage($message)
+ {
+ $this->message = $message;
+
+ return $this;
+ }
+
+ /**
+ * Get available metrics
+ *
+ * @return array
+ */
+ public function getMetrics()
+ {
+ return $this->metrics;
+ }
+
+ /**
+ * Set available metrics
+ *
+ * @param array $metrics
+ *
+ * @return $this
+ */
+ public function setMetrics(array $metrics)
+ {
+ $this->metrics = $metrics;
+
+ return $this;
+ }
+
+ /**
+ * Get the url to a graphical representation of the available metrics
+ *
+ * @return Url
+ */
+ public function getUrl()
+ {
+ return $this->url;
+ }
+
+ /**
+ * Set the url to a graphical representation of the available metrics
+ *
+ * @param Url $url
+ *
+ * @return $this
+ */
+ public function setUrl(Url $url)
+ {
+ $this->url = $url;
+
+ return $this;
+ }
+
+ /**
+ * Collect available health data from hooks
+ *
+ * @return ArrayDatasource
+ */
+ final public static function collectHealthData()
+ {
+ $checks = [];
+ foreach (Hook::all('health') as $hook) {
+ /** @var self $hook */
+
+ try {
+ $hook->checkHealth();
+ $url = $hook->getUrl();
+ $state = $hook->getState();
+ $message = $hook->getMessage();
+ $metrics = $hook->getMetrics();
+ } catch (Exception $e) {
+ Logger::error('Failed to check health: %s', $e);
+
+ $state = self::STATE_UNKNOWN;
+ $message = IcingaException::describe($e);
+ $metrics = null;
+ $url = null;
+ }
+
+ $checks[] = (object) [
+ 'module' => $hook->getModuleName(),
+ 'name' => $hook->getName(),
+ 'url' => $url ? $url->getAbsoluteUrl() : null,
+ 'state' => $state,
+ 'message' => $message,
+ 'metrics' => (object) $metrics
+ ];
+ }
+
+ return (new ArrayDatasource($checks))
+ ->setKeyColumn('name');
+ }
+
+ /**
+ * Get the name of the hook
+ *
+ * Only used in API responses to differentiate it from other hooks of the same module.
+ *
+ * @return string
+ */
+ public function getName()
+ {
+ $classPath = get_class($this);
+ $parts = explode('\\', $classPath);
+ $className = array_pop($parts);
+
+ if (substr($className, -4) === 'Hook') {
+ $className = substr($className, 1, -4);
+ }
+
+ return strtolower($className[0]) . substr($className, 1);
+ }
+
+ /**
+ * Get the name of the module providing this hook
+ *
+ * @return string
+ *
+ * @throws LogicException
+ */
+ public function getModuleName()
+ {
+ $classPath = get_class($this);
+ if (substr($classPath, 0, 14) !== 'Icinga\\Module\\') {
+ throw new LogicException('Not a module hook');
+ }
+
+ $withoutPrefix = substr($classPath, 14);
+ return strtolower(substr($withoutPrefix, 0, strpos($withoutPrefix, '\\')));
+ }
+
+ /**
+ * Check health
+ *
+ * Implement this method and set the overall state, message, url and metrics.
+ *
+ * @return void
+ */
+ abstract public function checkHealth();
+}
diff --git a/library/Icinga/Application/Hook/PdfexportHook.php b/library/Icinga/Application/Hook/PdfexportHook.php
new file mode 100644
index 0000000..36e9f51
--- /dev/null
+++ b/library/Icinga/Application/Hook/PdfexportHook.php
@@ -0,0 +1,25 @@
+<?php
+/* Icinga Web 2 | (c) 2018 Icinga Development Team | GPLv2+ */
+
+namespace Icinga\Application\Hook;
+
+/**
+ * Base class for the PDF Export Hook
+ */
+abstract class PdfexportHook
+{
+ /**
+ * Get whether PDF export is supported
+ *
+ * @return bool
+ */
+ abstract public function isSupported();
+
+ /**
+ * Render the specified HTML to PDF and stream it to the client
+ *
+ * @param string $html The HTML to render to PDF
+ * @param string $filename The filename for the generated PDF
+ */
+ abstract public function streamPdfFromHtml($html, $filename);
+}
diff --git a/library/Icinga/Application/Hook/ThemeLoaderHook.php b/library/Icinga/Application/Hook/ThemeLoaderHook.php
new file mode 100644
index 0000000..5320dd5
--- /dev/null
+++ b/library/Icinga/Application/Hook/ThemeLoaderHook.php
@@ -0,0 +1,22 @@
+<?php
+/* Icinga Web 2 | (c) 2022 Icinga GmbH | GPLv2+ */
+
+namespace Icinga\Application\Hook;
+
+/**
+ * Provide an implementation of this hook to dynamically provide themes.
+ * Note that only the first registered hook is utilized. Also note that
+ * for ordinary themes this hook is not required. Place such in your
+ * module's theme path: <module-path>/public/css/themes
+ */
+abstract class ThemeLoaderHook
+{
+ /**
+ * Get the path for the given theme
+ *
+ * @param ?string $theme
+ *
+ * @return ?string The path or NULL if the theme is unknown
+ */
+ abstract public function getThemeFile(?string $theme): ?string;
+}
diff --git a/library/Icinga/Application/Hook/Ticket/TicketPattern.php b/library/Icinga/Application/Hook/Ticket/TicketPattern.php
new file mode 100644
index 0000000..e37fcc1
--- /dev/null
+++ b/library/Icinga/Application/Hook/Ticket/TicketPattern.php
@@ -0,0 +1,140 @@
+<?php
+/* Icinga Web 2 | (c) 2016 Icinga Development Team | GPLv2+ */
+
+namespace Icinga\Application\Hook\Ticket;
+
+use ArrayAccess;
+
+/**
+ * A ticket pattern
+ *
+ * This class should be used by modules which provide implementations for the Web 2 ticket hook.
+ * Have a look at the GenericTTS module for a possible use case.
+ */
+class TicketPattern implements ArrayAccess
+{
+ /**
+ * The result of a performed ticket match
+ *
+ * @var array
+ */
+ protected $match = array();
+
+ /**
+ * The name of the TTS integration
+ *
+ * @var string
+ */
+ protected $name;
+
+ /**
+ * The ticket pattern
+ *
+ * @var string
+ */
+ protected $pattern;
+
+ public function offsetExists($offset): bool
+ {
+ return isset($this->match[$offset]);
+ }
+
+ public function offsetGet($offset): ?string
+ {
+ return array_key_exists($offset, $this->match) ? $this->match[$offset] : null;
+ }
+
+ public function offsetSet($offset, $value): void
+ {
+ if ($offset === null) {
+ $this->match[] = $value;
+ } else {
+ $this->match[$offset] = $value;
+ }
+ }
+
+ public function offsetUnset($offset): void
+ {
+ unset($this->match[$offset]);
+ }
+
+
+ /**
+ * Get the result of a performed ticket match
+ *
+ * @return array
+ */
+ public function getMatch()
+ {
+ return $this->match;
+ }
+
+ /**
+ * Set the result of a performed ticket match
+ *
+ * @param array $match
+ *
+ * @return $this
+ */
+ public function setMatch(array $match)
+ {
+ $this->match = $match;
+ return $this;
+ }
+
+ /**
+ * Get the name of the TTS integration
+ *
+ * @return string
+ */
+ public function getName()
+ {
+ return $this->name;
+ }
+
+ /**
+ * Set the name of the TTS integration
+ *
+ * @param string $name
+ *
+ * @return $this
+ */
+ public function setName($name)
+ {
+ $this->name = $name;
+ return $this;
+ }
+
+ /**
+ * Get the ticket pattern
+ *
+ * @return string
+ */
+ public function getPattern()
+ {
+ return $this->pattern;
+ }
+
+ /**
+ * Set the ticket pattern
+ *
+ * @param string $pattern
+ *
+ * @return $this
+ */
+ public function setPattern($pattern)
+ {
+ $this->pattern = $pattern;
+ return $this;
+ }
+
+ /**
+ * Whether the integration is properly configured, i.e. the pattern and the URL are not empty
+ *
+ * @return bool
+ */
+ public function isValid()
+ {
+ return ! empty($this->pattern);
+ }
+}
diff --git a/library/Icinga/Application/Hook/TicketHook.php b/library/Icinga/Application/Hook/TicketHook.php
new file mode 100644
index 0000000..ceb3738
--- /dev/null
+++ b/library/Icinga/Application/Hook/TicketHook.php
@@ -0,0 +1,210 @@
+<?php
+/* Icinga Web 2 | (c) 2014 Icinga Development Team | GPLv2+ */
+
+namespace Icinga\Application\Hook;
+
+use ArrayIterator;
+use ErrorException;
+use Exception;
+use Icinga\Application\Hook\Ticket\TicketPattern;
+use Icinga\Application\Logger;
+use Icinga\Exception\IcingaException;
+
+/**
+ * Base class for ticket hooks
+ *
+ * Extend this class if you want to integrate your ticketing solution into Icinga Web 2.
+ */
+abstract class TicketHook
+{
+ /**
+ * Last error, if any
+ *
+ * @var string|null
+ */
+ protected $lastError;
+
+ /**
+ * Create a new ticket hook
+ *
+ * @see init() For hook initialization.
+ */
+ final public function __construct()
+ {
+ $this->init();
+ }
+
+ /**
+ * Overwrite this function for hook initialization, e.g. loading the hook's config
+ */
+ protected function init()
+ {
+ }
+
+ /**
+ * Create a link for each matched element in the subject text
+ *
+ * @param array|TicketPattern $match Matched element according to {@link getPattern()}
+ *
+ * @return string Replacement string
+ */
+ abstract public function createLink($match);
+
+ /**
+ * Get the pattern(s) to search for
+ *
+ * Return an array of TicketPattern instances here to support multiple TTS integrations.
+ *
+ * @return string|TicketPattern[]
+ */
+ abstract public function getPattern();
+
+ /**
+ * Apply ticket patterns to the given text
+ *
+ * @param string $text
+ * @param TicketPattern[] $ticketPatterns
+ *
+ * @return string
+ */
+ private function applyTicketPatterns($text, array $ticketPatterns)
+ {
+ $out = '';
+ $start = 0;
+
+ $iterator = new ArrayIterator($ticketPatterns);
+ $iterator->rewind();
+
+ while ($iterator->valid()) {
+ $ticketPattern = $iterator->current();
+
+ try {
+ preg_match($ticketPattern->getPattern(), $text, $match, PREG_OFFSET_CAPTURE, $start);
+ } catch (ErrorException $e) {
+ $this->fail('Can\'t create ticket links: Pattern is invalid: %s', IcingaException::describe($e));
+ $iterator->next();
+ continue;
+ }
+
+ if (empty($match)) {
+ $iterator->next();
+ continue;
+ }
+
+ // Remove preg_offset from match for the ticket pattern
+ $carry = array();
+ array_walk($match, function ($value, $key) use (&$carry) {
+ $carry[$key] = $value[0];
+ }, $carry);
+ $ticketPattern->setMatch($carry);
+
+ $offsetLeft = $match[0][1];
+ $matchLength = strlen($match[0][0]);
+
+ $out .= substr($text, $start, $offsetLeft - $start);
+
+ try {
+ $out .= $this->createLink($ticketPattern);
+ } catch (Exception $e) {
+ $this->fail('Can\'t create ticket links: %s', IcingaException::describe($e));
+ return $text;
+ }
+
+ $start = $offsetLeft + $matchLength;
+ }
+
+ $out .= substr($text, $start);
+
+ return $out;
+ }
+
+ /**
+ * Helper function to create a TicketPattern instance
+ *
+ * @param string $name Name of the TTS integration
+ * @param string $pattern Ticket pattern
+ *
+ * @return TicketPattern
+ */
+ protected function createTicketPattern($name, $pattern)
+ {
+ $ticketPattern = new TicketPattern();
+ $ticketPattern
+ ->setName($name)
+ ->setPattern($pattern);
+ return $ticketPattern;
+ }
+
+ /**
+ * Set the hook as failed w/ the given message
+ *
+ * @param string $message Error message or error format string
+ * @param mixed ...$arg Format string argument
+ */
+ private function fail($message)
+ {
+ $args = array_slice(func_get_args(), 1);
+ $lastError = vsprintf($message, $args);
+ Logger::debug($lastError);
+ $this->lastError = $lastError;
+ }
+
+ /**
+ * Get the last error, if any
+ *
+ * @return string|null
+ */
+ public function getLastError()
+ {
+ return $this->lastError;
+ }
+
+ /**
+ * Create links w/ {@link createLink()} in the given text that matches to the subject from {@link getPattern()}
+ *
+ * In case of errors a debug message is recorded to the log and any subsequent call to {@link createLinks()} will
+ * be a no-op.
+ *
+ * @param string $text
+ *
+ * @return string
+ */
+ final public function createLinks($text)
+ {
+ if ($this->lastError !== null) {
+ return $text;
+ }
+
+ try {
+ $pattern = $this->getPattern();
+ } catch (Exception $e) {
+ $this->fail('Can\'t create ticket links: Retrieving the pattern failed: %s', IcingaException::describe($e));
+ return $text;
+ }
+
+ if (empty($pattern)) {
+ $this->fail('Can\'t create ticket links: Pattern is empty');
+ return $text;
+ }
+
+ if (is_array($pattern)) {
+ $text = $this->applyTicketPatterns($text, $pattern);
+ } else {
+ try {
+ $text = preg_replace_callback(
+ $pattern,
+ array($this, 'createLink'),
+ $text
+ );
+ } catch (ErrorException $e) {
+ $this->fail('Can\'t create ticket links: Pattern is invalid: %s', IcingaException::describe($e));
+ return $text;
+ } catch (Exception $e) {
+ $this->fail('Can\'t create ticket links: %s', IcingaException::describe($e));
+ return $text;
+ }
+ }
+
+ return $text;
+ }
+}
diff --git a/library/Icinga/Application/Hook/WebBaseHook.php b/library/Icinga/Application/Hook/WebBaseHook.php
new file mode 100644
index 0000000..09e8f4f
--- /dev/null
+++ b/library/Icinga/Application/Hook/WebBaseHook.php
@@ -0,0 +1,54 @@
+<?php
+/* Icinga Web 2 | (c) 2014 Icinga Development Team | GPLv2+ */
+
+namespace Icinga\Application\Hook;
+
+use Zend_Controller_Action_HelperBroker;
+use Zend_View;
+
+/**
+ * Base class for web hooks
+ *
+ * The class provides access to the view
+ */
+class WebBaseHook
+{
+ /**
+ * View instance
+ *
+ * @var Zend_View
+ */
+ private $view;
+
+ /**
+ * Set the view instance
+ *
+ * @param Zend_View $view
+ *
+ * @return $this
+ */
+ public function setView(Zend_View $view)
+ {
+ $this->view = $view;
+
+ return $this;
+ }
+
+ /**
+ * Get the view instance
+ *
+ * @return Zend_View
+ */
+ public function getView()
+ {
+ if ($this->view === null) {
+ $viewRenderer = Zend_Controller_Action_HelperBroker::getStaticHelper('viewRenderer');
+ if ($viewRenderer->view === null) {
+ $viewRenderer->initView();
+ }
+ $this->view = $viewRenderer->view;
+ }
+
+ return $this->view;
+ }
+}
diff --git a/library/Icinga/Application/Icinga.php b/library/Icinga/Application/Icinga.php
new file mode 100644
index 0000000..ba54015
--- /dev/null
+++ b/library/Icinga/Application/Icinga.php
@@ -0,0 +1,49 @@
+<?php
+/* Icinga Web 2 | (c) 2013 Icinga Development Team | GPLv2+ */
+
+namespace Icinga\Application;
+
+use Icinga\Exception\ProgrammingError;
+
+/**
+ * Icinga application container
+ */
+class Icinga
+{
+ /**
+ * @var ApplicationBootstrap
+ */
+ private static $app;
+
+ /**
+ * Getter for an application environment
+ *
+ * @return ApplicationBootstrap|Web
+ * @throws ProgrammingError
+ */
+ public static function app()
+ {
+ if (self::$app == null) {
+ throw new ProgrammingError('Icinga has never been started');
+ }
+
+ return self::$app;
+ }
+
+ /**
+ * Setter for an application environment
+ *
+ * @param ApplicationBootstrap $app
+ * @param bool $overwrite
+ *
+ * @throws ProgrammingError
+ */
+ public static function setApp(ApplicationBootstrap $app, $overwrite = false)
+ {
+ if (self::$app !== null && !$overwrite) {
+ throw new ProgrammingError('Cannot start Icinga twice');
+ }
+
+ self::$app = $app;
+ }
+}
diff --git a/library/Icinga/Application/LegacyWeb.php b/library/Icinga/Application/LegacyWeb.php
new file mode 100644
index 0000000..21181f7
--- /dev/null
+++ b/library/Icinga/Application/LegacyWeb.php
@@ -0,0 +1,33 @@
+<?php
+/* Icinga Web 2 | (c) 2013 Icinga Development Team | GPLv2+ */
+
+namespace Icinga\Application;
+
+require_once dirname(__FILE__) . '/Web.php';
+
+use Exception;
+use Icinga\Exception\ProgrammingError;
+
+class LegacyWeb extends Web
+{
+ // IcingaWeb 1.x base dir
+ protected $legacyBasedir;
+
+ protected function bootstrap()
+ {
+ parent::bootstrap();
+ throw new ProgrammingError('Not yet');
+ // $this->setupIcingaLegacyWrapper();
+ }
+
+ /**
+ * Get the Icinga-Web 1.x base path
+ *
+ * @throws Exception
+ * @return self
+ */
+ public function getLecacyBasedir()
+ {
+ return $this->legacyBasedir;
+ }
+}
diff --git a/library/Icinga/Application/Libraries.php b/library/Icinga/Application/Libraries.php
new file mode 100644
index 0000000..8e4a79d
--- /dev/null
+++ b/library/Icinga/Application/Libraries.php
@@ -0,0 +1,91 @@
+<?php
+/* Icinga Web 2 | (c) 2020 Icinga GmbH | GPLv2+ */
+
+namespace Icinga\Application;
+
+use ArrayIterator;
+use IteratorAggregate;
+use Icinga\Application\Libraries\Library;
+use Traversable;
+
+class Libraries implements IteratorAggregate
+{
+ /** @var Library[] */
+ protected $libraries = [];
+
+ /**
+ * Iterate over registered libraries
+ *
+ * @return ArrayIterator
+ */
+ public function getIterator(): Traversable
+ {
+ 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 || $version === true) {
+ 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)
+ {
+ $candidate = null;
+ foreach ($this->libraries as $library) {
+ $libraryName = $library->getName();
+ if ($libraryName === $name) {
+ return $library;
+ } elseif (strpos($libraryName, '/') !== false && explode('/', $libraryName)[1] === $name) {
+ // Also return libs which only partially match
+ $candidate = $library;
+ }
+ }
+
+ return $candidate;
+ }
+}
diff --git a/library/Icinga/Application/Libraries/Library.php b/library/Icinga/Application/Libraries/Library.php
new file mode 100644
index 0000000..63e50b2
--- /dev/null
+++ b/library/Icinga/Application/Libraries/Library.php
@@ -0,0 +1,259 @@
+<?php
+/* Icinga Web 2 | (c) 2020 Icinga GmbH | GPLv2+ */
+
+namespace Icinga\Application\Libraries;
+
+use CallbackFilterIterator;
+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;
+ }
+
+ /**
+ * Check whether the given package is required
+ *
+ * @param string $vendor The vendor of the project
+ * @param string $project The project's name
+ *
+ * @return bool
+ */
+ public function isRequired($vendor, $project)
+ {
+ // Ensure the parts are lowercase and separated by dashes, not capital letters
+ $project = strtolower(join('-', preg_split('/\w(?=[A-Z])/', $project)));
+
+ return isset($this->metaData()['require'][strtolower($vendor) . '/' . $project]);
+ }
+
+ /**
+ * 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;
+
+ $iterator = new RecursiveIteratorIterator(new RecursiveDirectoryIterator(
+ $dir,
+ RecursiveDirectoryIterator::CURRENT_AS_FILEINFO | RecursiveDirectoryIterator::SKIP_DOTS
+ ));
+ if ($type === 'static') {
+ return $iterator;
+ }
+
+ return new CallbackFilterIterator(
+ $iterator,
+ function ($path) use ($type) {
+ if ($type === 'js' && $path->getExtension() === 'js') {
+ return substr($path->getPathname(), -5 - strlen($type)) !== ".min.$type";
+ } elseif ($type === 'css'
+ && ($path->getExtension() === 'css' || $path->getExtension() === 'less')
+ ) {
+ return substr($path->getPathname(), -5 - strlen($type)) !== ".min.$type";
+ }
+
+ return false;
+ }
+ );
+ };
+
+ $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/Logger.php b/library/Icinga/Application/Logger.php
new file mode 100644
index 0000000..937029c
--- /dev/null
+++ b/library/Icinga/Application/Logger.php
@@ -0,0 +1,349 @@
+<?php
+/* Icinga Web 2 | (c) 2014 Icinga Development Team | GPLv2+ */
+
+namespace Icinga\Application;
+
+use Icinga\Data\ConfigObject;
+use Icinga\Application\Logger\Writer\FileWriter;
+use Icinga\Application\Logger\Writer\SyslogWriter;
+use Icinga\Exception\ConfigurationError;
+use Icinga\Exception\IcingaException;
+use Icinga\Util\Json;
+use Throwable;
+
+/**
+ * Logger
+ */
+class Logger
+{
+ /**
+ * Debug message
+ */
+ const DEBUG = 1;
+
+ /**
+ * Informational message
+ */
+ const INFO = 2;
+
+ /**
+ * Warning message
+ */
+ const WARNING = 4;
+
+ /**
+ * Error message
+ */
+ const ERROR = 8;
+
+ /**
+ * Log levels
+ *
+ * @var array
+ */
+ public static $levels = array(
+ Logger::DEBUG => 'DEBUG',
+ Logger::INFO => 'INFO',
+ Logger::WARNING => 'WARNING',
+ Logger::ERROR => 'ERROR'
+ );
+
+ /**
+ * This logger's instance
+ *
+ * @var static
+ */
+ protected static $instance;
+
+ /**
+ * Log writer
+ *
+ * @var \Icinga\Application\Logger\LogWriter
+ */
+ protected $writer;
+
+ /**
+ * Maximum level to emit
+ *
+ * @var int
+ */
+ protected $level;
+
+ /**
+ * Error messages to be displayed prior to any other log message
+ *
+ * @var array
+ */
+ protected $configErrors = array();
+
+ /**
+ * Create a new logger object
+ *
+ * @param ConfigObject $config
+ *
+ * @throws ConfigurationError If the logging configuration directive 'log' is missing or if the logging level is
+ * not defined
+ */
+ public function __construct(ConfigObject $config)
+ {
+ if ($config->log === null) {
+ throw new ConfigurationError('Required logging configuration directive \'log\' missing');
+ }
+
+ $this->setLevel($config->get('level', static::ERROR));
+
+ if (strtolower($config->get('log', 'syslog')) !== 'none') {
+ $this->writer = $this->createWriter($config);
+ }
+ }
+
+ /**
+ * Set the logging level to use
+ *
+ * @param mixed $level
+ *
+ * @return $this
+ *
+ * @throws ConfigurationError In case the given level is invalid
+ */
+ public function setLevel($level)
+ {
+ if (is_numeric($level)) {
+ $level = (int) $level;
+ if (! isset(static::$levels[$level])) {
+ throw new ConfigurationError(
+ 'Can\'t set logging level %d. Logging level is invalid. Use one of %s or one of the'
+ . ' Logger\'s constants.',
+ $level,
+ implode(', ', array_keys(static::$levels))
+ );
+ }
+
+ $this->level = $level;
+ } else {
+ $level = strtoupper($level);
+ $levels = array_flip(static::$levels);
+ if (! isset($levels[$level])) {
+ throw new ConfigurationError(
+ 'Can\'t set logging level "%s". Logging level is invalid. Use one of %s.',
+ $level,
+ implode(', ', array_keys($levels))
+ );
+ }
+
+ $this->level = $levels[$level];
+ }
+
+ return $this;
+ }
+
+ /**
+ * Return the logging level being used
+ *
+ * @return int
+ */
+ public function getLevel()
+ {
+ return $this->level;
+ }
+
+ /**
+ * Register the given message as config error
+ *
+ * Config errors are logged every time a log message is being logged.
+ *
+ * @param mixed $arg,... A string, exception or format-string + substitutions
+ *
+ * @return $this
+ */
+ public function registerConfigError()
+ {
+ if (func_num_args() > 0) {
+ $this->configErrors[] = static::formatMessage(func_get_args());
+ }
+
+ return $this;
+ }
+
+ /**
+ * Create a new logger object
+ *
+ * @param ConfigObject $config
+ *
+ * @return static
+ */
+ public static function create(ConfigObject $config)
+ {
+ static::$instance = new static($config);
+ return static::$instance;
+ }
+
+ /**
+ * Create a log writer
+ *
+ * @param ConfigObject $config The configuration to initialize the writer with
+ *
+ * @return \Icinga\Application\Logger\LogWriter The requested log writer
+ * @throws ConfigurationError If the requested writer cannot be found
+ */
+ protected function createWriter(ConfigObject $config)
+ {
+ $class = 'Icinga\\Application\\Logger\\Writer\\' . ucfirst(strtolower($config->log)) . 'Writer';
+ if (! class_exists($class)) {
+ throw new ConfigurationError(
+ 'Cannot find log writer of type "%s"',
+ $config->log
+ );
+ }
+ return new $class($config);
+ }
+
+ /**
+ * Log a message
+ *
+ * @param int $level The logging level
+ * @param string $message The log message
+ */
+ public function log($level, $message)
+ {
+ if ($this->writer !== null && $this->level <= $level) {
+ foreach ($this->configErrors as $error_message) {
+ $this->writer->log(static::ERROR, $error_message);
+ }
+
+ $this->writer->log($level, $message);
+ }
+ }
+
+ /**
+ * Return a string representation of the passed arguments
+ *
+ * This method provides three different processing techniques:
+ * - If the only passed argument is a string it is returned unchanged
+ * - If the only passed argument is an exception it is formatted as follows:
+ * <name> in <file>:<line> with message: <message>[ <- <name> ...]
+ * - If multiple arguments are passed the first is interpreted as format-string
+ * that gets substituted with the remaining ones which can be of any type
+ *
+ * @param array $arguments The arguments to format
+ *
+ * @return string The formatted result
+ */
+ protected static function formatMessage(array $arguments)
+ {
+ if (count($arguments) === 1) {
+ $message = $arguments[0];
+
+ if ($message instanceof Throwable) {
+ $messages = array();
+ $error = $message;
+ do {
+ $messages[] = IcingaException::describe($error);
+ } while ($error = $error->getPrevious());
+ $message = implode(' <- ', $messages);
+ }
+
+ return $message;
+ }
+
+ return vsprintf(
+ array_shift($arguments),
+ array_map(
+ function ($a) {
+ return is_string($a) ? $a : ($a instanceof Throwable
+ ? IcingaException::describe($a)
+ : Json::encode($a));
+ },
+ $arguments
+ )
+ );
+ }
+
+ /**
+ * Log a message with severity ERROR
+ *
+ * @param mixed $arg,... A string, exception or format-string + substitutions
+ */
+ public static function error()
+ {
+ if (static::$instance !== null && func_num_args() > 0) {
+ static::$instance->log(static::ERROR, static::formatMessage(func_get_args()));
+ }
+ }
+
+ /**
+ * Log a message with severity WARNING
+ *
+ * @param mixed $arg,... A string, exception or format-string + substitutions
+ */
+ public static function warning()
+ {
+ if (static::$instance !== null && func_num_args() > 0) {
+ static::$instance->log(static::WARNING, static::formatMessage(func_get_args()));
+ }
+ }
+
+ /**
+ * Log a message with severity INFO
+ *
+ * @param mixed $arg,... A string, exception or format-string + substitutions
+ */
+ public static function info()
+ {
+ if (static::$instance !== null && func_num_args() > 0) {
+ static::$instance->log(static::INFO, static::formatMessage(func_get_args()));
+ }
+ }
+
+ /**
+ * Log a message with severity DEBUG
+ *
+ * @param mixed $arg,... A string, exception or format-string + substitutions
+ */
+ public static function debug()
+ {
+ if (static::$instance !== null && func_num_args() > 0) {
+ static::$instance->log(static::DEBUG, static::formatMessage(func_get_args()));
+ }
+ }
+
+ /**
+ * Get the log writer to use
+ *
+ * @return \Icinga\Application\Logger\LogWriter
+ */
+ public function getWriter()
+ {
+ return $this->writer;
+ }
+
+ /**
+ * Is the logger writing to Syslog?
+ *
+ * @return bool
+ */
+ public static function writesToSyslog()
+ {
+ return static::$instance && static::$instance->getWriter() instanceof SyslogWriter;
+ }
+
+ /**
+ * Is the logger writing to a file?
+ *
+ * @return bool
+ */
+ public static function writesToFile()
+ {
+ return static::$instance && static::$instance->getWriter() instanceof FileWriter;
+ }
+
+ /**
+ * Get this' instance
+ *
+ * @return static
+ */
+ public static function getInstance()
+ {
+ return static::$instance;
+ }
+}
diff --git a/library/Icinga/Application/Logger/LogWriter.php b/library/Icinga/Application/Logger/LogWriter.php
new file mode 100644
index 0000000..019bdad
--- /dev/null
+++ b/library/Icinga/Application/Logger/LogWriter.php
@@ -0,0 +1,30 @@
+<?php
+/* Icinga Web 2 | (c) 2014 Icinga Development Team | GPLv2+ */
+
+namespace Icinga\Application\Logger;
+
+use Icinga\Data\ConfigObject;
+
+/**
+ * Abstract class for writers that write messages to a log
+ */
+abstract class LogWriter
+{
+ /**
+ * @var ConfigObject
+ */
+ protected $config;
+
+ /**
+ * Create a new log writer initialized with the given configuration
+ */
+ public function __construct(ConfigObject $config)
+ {
+ $this->config = $config;
+ }
+
+ /**
+ * Log a message with the given severity
+ */
+ abstract public function log($severity, $message);
+}
diff --git a/library/Icinga/Application/Logger/Writer/FileWriter.php b/library/Icinga/Application/Logger/Writer/FileWriter.php
new file mode 100644
index 0000000..6b4ed54
--- /dev/null
+++ b/library/Icinga/Application/Logger/Writer/FileWriter.php
@@ -0,0 +1,80 @@
+<?php
+/* Icinga Web 2 | (c) 2014 Icinga Development Team | GPLv2+ */
+
+namespace Icinga\Application\Logger\Writer;
+
+use Exception;
+use Icinga\Data\ConfigObject;
+use Icinga\Application\Logger;
+use Icinga\Application\Logger\LogWriter;
+use Icinga\Exception\ConfigurationError;
+use Icinga\Util\File;
+
+/**
+ * Log to a file
+ */
+class FileWriter extends LogWriter
+{
+ /**
+ * Path to the file
+ *
+ * @var string
+ */
+ protected $file;
+
+ /**
+ * Create a new file log writer
+ *
+ * @param ConfigObject $config
+ *
+ * @throws ConfigurationError If the configuration directive 'file' is missing or if the path to 'file' does
+ * not exist or if writing to 'file' is not possible
+ */
+ public function __construct(ConfigObject $config)
+ {
+ if ($config->file === null) {
+ throw new ConfigurationError('Required logging configuration directive \'file\' missing');
+ }
+ $this->file = $config->file;
+
+ if (substr($this->file, 0, 6) !== 'php://' && ! file_exists(dirname($this->file))) {
+ throw new ConfigurationError(
+ 'Log path "%s" does not exist',
+ dirname($this->file)
+ );
+ }
+
+ try {
+ $this->write(''); // Avoid to handle such errors on every write access
+ } catch (Exception $e) {
+ throw new ConfigurationError(
+ 'Cannot write to log file "%s" (%s)',
+ $this->file,
+ $e->getMessage()
+ );
+ }
+ }
+
+ /**
+ * Log a message
+ *
+ * @param int $level The logging level
+ * @param string $message The log message
+ */
+ public function log($level, $message)
+ {
+ $this->write(date('c') . ' - ' . Logger::$levels[$level] . ' - ' . $message . PHP_EOL);
+ }
+
+ /**
+ * Write a message to the log
+ *
+ * @param string $message
+ */
+ protected function write($message)
+ {
+ $file = new File($this->file, 'a');
+ $file->fwrite($message);
+ $file->fflush();
+ }
+}
diff --git a/library/Icinga/Application/Logger/Writer/PhpWriter.php b/library/Icinga/Application/Logger/Writer/PhpWriter.php
new file mode 100644
index 0000000..dedb2bd
--- /dev/null
+++ b/library/Icinga/Application/Logger/Writer/PhpWriter.php
@@ -0,0 +1,39 @@
+<?php
+/* Icinga Web 2 | (c) 2016 Icinga Development Team | GPLv2+ */
+
+namespace Icinga\Application\Logger\Writer;
+
+use Icinga\Application\Logger;
+use Icinga\Application\Logger\LogWriter;
+use Icinga\Data\ConfigObject;
+use Icinga\Exception\NotWritableError;
+
+/**
+ * Log to the webserver log, a file or syslog
+ *
+ * @see https://secure.php.net/manual/en/errorfunc.configuration.php#ini.error-log
+ */
+class PhpWriter extends LogWriter
+{
+ /**
+ * Prefix to prepend to each message
+ *
+ * @var string
+ */
+ protected $ident;
+
+ public function __construct(ConfigObject $config)
+ {
+ parent::__construct($config);
+ $this->ident = $config->get('application', 'icingaweb2');
+ }
+
+ public function log($severity, $message)
+ {
+ if (ini_get('error_log') === 'syslog') {
+ $message = str_replace("\n", ' ', $message);
+ }
+
+ error_log($this->ident . ': ' . Logger::$levels[$severity] . ' - ' . $message);
+ }
+}
diff --git a/library/Icinga/Application/Logger/Writer/StderrWriter.php b/library/Icinga/Application/Logger/Writer/StderrWriter.php
new file mode 100644
index 0000000..7df4278
--- /dev/null
+++ b/library/Icinga/Application/Logger/Writer/StderrWriter.php
@@ -0,0 +1,62 @@
+<?php
+/* Icinga Web 2 | (c) 2014 Icinga Development Team | GPLv2+ */
+
+namespace Icinga\Application\Logger\Writer;
+
+use Icinga\Cli\Screen;
+use Icinga\Application\Logger;
+use Icinga\Application\Logger\LogWriter;
+
+/**
+ * Class to write log messages to STDERR
+ */
+class StderrWriter extends LogWriter
+{
+ /**
+ * The current Screen in use
+ *
+ * @var Screen
+ */
+ protected $screen;
+
+ /**
+ * Return the current Screen
+ *
+ * @return Screen
+ */
+ protected function screen()
+ {
+ if ($this->screen === null) {
+ $this->screen = Screen::instance(STDERR);
+ }
+
+ return $this->screen;
+ }
+
+ /**
+ * Log a message with the given severity
+ *
+ * @param int $severity The severity to use
+ * @param string $message The message to log
+ */
+ public function log($severity, $message)
+ {
+ $color = null;
+ switch ($severity) {
+ case Logger::ERROR:
+ $color = 'red';
+ break;
+ case Logger::WARNING:
+ $color = 'yellow';
+ break;
+ case Logger::INFO:
+ $color = 'green';
+ break;
+ case Logger::DEBUG:
+ $color = 'blue';
+ break;
+ }
+
+ file_put_contents('php://stderr', $this->screen()->colorize($message, $color) . "\n");
+ }
+}
diff --git a/library/Icinga/Application/Logger/Writer/StdoutWriter.php b/library/Icinga/Application/Logger/Writer/StdoutWriter.php
new file mode 100644
index 0000000..a6f43e5
--- /dev/null
+++ b/library/Icinga/Application/Logger/Writer/StdoutWriter.php
@@ -0,0 +1,13 @@
+<?php
+/* Icinga Web 2 | (c) 2014 Icinga Development Team | GPLv2+ */
+
+namespace Icinga\Application\Logger\Writer;
+
+/**
+ * Deprecated, compat only.
+ *
+ * Use Icinga\Application\Logger\Writer\StderrWriter instead.
+ */
+class StdoutWriter extends StderrWriter
+{
+}
diff --git a/library/Icinga/Application/Logger/Writer/SyslogWriter.php b/library/Icinga/Application/Logger/Writer/SyslogWriter.php
new file mode 100644
index 0000000..93efc2a
--- /dev/null
+++ b/library/Icinga/Application/Logger/Writer/SyslogWriter.php
@@ -0,0 +1,90 @@
+<?php
+/* Icinga Web 2 | (c) 2014 Icinga Development Team | GPLv2+ */
+
+namespace Icinga\Application\Logger\Writer;
+
+use Icinga\Data\ConfigObject;
+use Icinga\Application\Logger;
+use Icinga\Application\Logger\LogWriter;
+use Icinga\Exception\ConfigurationError;
+
+/**
+ * Log to the syslog service
+ */
+class SyslogWriter extends LogWriter
+{
+ /**
+ * Syslog facility
+ *
+ * @var int
+ */
+ protected $facility;
+
+ /**
+ * Prefix to prepend to each message
+ *
+ * @var string
+ */
+ protected $ident;
+
+ /**
+ * Known syslog facilities
+ *
+ * @var array
+ */
+ public static $facilities = array(
+ 'user' => LOG_USER,
+ 'local0' => LOG_LOCAL0,
+ 'local1' => LOG_LOCAL1,
+ 'local2' => LOG_LOCAL2,
+ 'local3' => LOG_LOCAL3,
+ 'local4' => LOG_LOCAL4,
+ 'local5' => LOG_LOCAL5,
+ 'local6' => LOG_LOCAL6,
+ 'local7' => LOG_LOCAL7
+ );
+
+ /**
+ * Log level to syslog severity map
+ *
+ * @var array
+ */
+ public static $severityMap = array(
+ Logger::ERROR => LOG_ERR,
+ Logger::WARNING => LOG_WARNING,
+ Logger::INFO => LOG_INFO,
+ Logger::DEBUG => LOG_DEBUG
+ );
+
+ /**
+ * Create a new syslog log writer
+ *
+ * @param ConfigObject $config
+ */
+ public function __construct(ConfigObject $config)
+ {
+ $this->ident = $config->get('application', 'icingaweb2');
+
+ $configuredFacility = $config->get('facility', 'user');
+ if (! isset(static::$facilities[$configuredFacility])) {
+ throw new ConfigurationError(
+ 'Invalid logging facility: "%s" (expected one of: %s)',
+ $configuredFacility,
+ implode(', ', array_keys(static::$facilities))
+ );
+ }
+ $this->facility = static::$facilities[$configuredFacility];
+ }
+
+ /**
+ * Log a message
+ *
+ * @param int $level The logging level
+ * @param string $message The log message
+ */
+ public function log($level, $message)
+ {
+ openlog($this->ident, LOG_PID, $this->facility);
+ syslog(static::$severityMap[$level], str_replace("\n", ' ', $message));
+ }
+}
diff --git a/library/Icinga/Application/MigrationManager.php b/library/Icinga/Application/MigrationManager.php
new file mode 100644
index 0000000..9d32896
--- /dev/null
+++ b/library/Icinga/Application/MigrationManager.php
@@ -0,0 +1,417 @@
+<?php
+
+/* Icinga Web 2 | (c) 2023 Icinga GmbH | GPLv2+ */
+
+namespace Icinga\Application;
+
+use Countable;
+use Generator;
+use Icinga\Application\Hook\DbMigrationHook;
+use Icinga\Exception\NotFoundError;
+use Icinga\Module\Setup\Utils\DbTool;
+use Icinga\Module\Setup\WebWizard;
+use ipl\I18n\Translation;
+use ipl\Sql;
+use ReflectionClass;
+
+/**
+ * Migration manager allows you to manage all pending migrations in a structured way.
+ */
+final class MigrationManager implements Countable
+{
+ use Translation;
+
+ /** @var array<string, DbMigrationHook> All pending migration hooks */
+ protected $pendingMigrations;
+
+ /** @var MigrationManager */
+ private static $instance;
+
+ private function __construct()
+ {
+ }
+
+ /**
+ * Get the instance of this manager
+ *
+ * @return $this
+ */
+ public static function instance(): self
+ {
+ if (self::$instance === null) {
+ self::$instance = new self();
+ }
+
+ return self::$instance;
+ }
+
+ /**
+ * Get all pending migrations
+ *
+ * @return array<string, DbMigrationHook>
+ */
+ public function getPendingMigrations(): array
+ {
+ if ($this->pendingMigrations === null) {
+ $this->load();
+ }
+
+ return $this->pendingMigrations;
+ }
+
+ /**
+ * Get whether there are any pending migrations
+ *
+ * @return bool
+ */
+ public function hasPendingMigrations(): bool
+ {
+ return $this->count() > 0;
+ }
+
+ public function hasMigrations(string $module): bool
+ {
+ if (! $this->hasPendingMigrations()) {
+ return false;
+ }
+
+ return isset($this->getPendingMigrations()[$module]);
+ }
+
+ /**
+ * Get pending migration matching the given module name
+ *
+ * @param string $module
+ *
+ * @return DbMigrationHook
+ *
+ * @throws NotFoundError When there are no pending migrations matching the given module name
+ */
+ public function getMigration(string $module): DbMigrationHook
+ {
+ if (! $this->hasMigrations($module)) {
+ throw new NotFoundError('There are no pending migrations matching the given name: %s', $module);
+ }
+
+ return $this->getPendingMigrations()[$module];
+ }
+
+ /**
+ * Get the number of all pending migrations
+ *
+ * @return int
+ */
+ public function count(): int
+ {
+ return count($this->getPendingMigrations());
+ }
+
+ /**
+ * Apply all pending migrations matching the given migration module name
+ *
+ * @param string $module
+ *
+ * @return bool
+ */
+ public function applyByName(string $module): bool
+ {
+ $migration = $this->getMigration($module);
+ if ($migration->isModule() && $this->hasMigrations(DbMigrationHook::DEFAULT_MODULE)) {
+ return false;
+ }
+
+ return $this->apply($migration);
+ }
+
+ /**
+ * Apply the given migration hook
+ *
+ * @param DbMigrationHook $hook
+ * @param ?array<string, string> $elevateConfig
+ *
+ * @return bool
+ */
+ public function apply(DbMigrationHook $hook, array $elevateConfig = null): bool
+ {
+ if ($hook->isModule() && $this->hasMigrations(DbMigrationHook::DEFAULT_MODULE)) {
+ Logger::error(
+ 'Please apply the Icinga Web pending migration(s) first or apply all the migrations instead'
+ );
+
+ return false;
+ }
+
+ $conn = $hook->getDb();
+ if ($elevateConfig && ! $this->checkRequiredPrivileges($conn)) {
+ $conn = $this->elevateDatabaseConnection($conn, $elevateConfig);
+ }
+
+ if ($hook->run($conn)) {
+ unset($this->pendingMigrations[$hook->getModuleName()]);
+
+ Logger::info('Applied pending %s migrations successfully', $hook->getName());
+
+ return true;
+ }
+
+ return false;
+ }
+
+ /**
+ * Apply all pending modules/framework migrations
+ *
+ * @param ?array<string, string> $elevateConfig
+ *
+ * @return bool
+ */
+ public function applyAll(array $elevateConfig = null): bool
+ {
+ $default = DbMigrationHook::DEFAULT_MODULE;
+ if ($this->hasMigrations($default)) {
+ $migration = $this->getMigration($default);
+ if (! $this->apply($migration, $elevateConfig)) {
+ return false;
+ }
+ }
+
+ $succeeded = true;
+ foreach ($this->getPendingMigrations() as $migration) {
+ if (! $this->apply($migration, $elevateConfig) && $succeeded) {
+ $succeeded = false;
+ }
+ }
+
+ return $succeeded;
+ }
+
+ /**
+ * Yield module and framework pending migrations separately
+ *
+ * @param bool $modules
+ *
+ * @return Generator<DbMigrationHook>
+ */
+ public function yieldMigrations(bool $modules = false): Generator
+ {
+ foreach ($this->getPendingMigrations() as $migration) {
+ if ($modules === $migration->isModule()) {
+ yield $migration;
+ }
+ }
+ }
+
+ /**
+ * Get the required database privileges for database migrations
+ *
+ * @return string[]
+ */
+ public function getRequiredDatabasePrivileges(): array
+ {
+ return ['CREATE','SELECT','INSERT','UPDATE','DELETE','DROP','ALTER','CREATE VIEW','INDEX','EXECUTE','USAGE'];
+ }
+
+ /**
+ * Verify whether all database users of all pending migrations do have the required SQL privileges
+ *
+ * @param ?array<string, string> $elevateConfig
+ * @param bool $canIssueGrant
+ *
+ * @return bool
+ */
+ public function validateDatabasePrivileges(array $elevateConfig = null, bool $canIssueGrant = false): bool
+ {
+ if (! $this->hasPendingMigrations()) {
+ return true;
+ }
+
+ foreach ($this->getPendingMigrations() as $migration) {
+ if (! $this->checkRequiredPrivileges($migration->getDb(), $elevateConfig, $canIssueGrant)) {
+ return false;
+ }
+ }
+
+ return true;
+ }
+
+ /**
+ * Check if there are missing grants for the Icinga Web database and fix them
+ *
+ * This fixes the following problems on existing installations:
+ * - Setups made by the wizard have no access to `icingaweb_schema`
+ * - Setups made by the wizard have no DDL grants
+ * - Setups done manually using the advanced documentation chapter have no DDL grants
+ *
+ * @param Sql\Connection $db
+ * @param array<string, string> $elevateConfig
+ */
+ public function fixIcingaWebMysqlGrants(Sql\Connection $db, array $elevateConfig): void
+ {
+ $wizardProperties = (new ReflectionClass(WebWizard::class))
+ ->getDefaultProperties();
+ /** @var array<int, string> $privileges */
+ $privileges = $wizardProperties['databaseUsagePrivileges'];
+ /** @var array<int, string> $tables */
+ $tables = $wizardProperties['databaseTables'];
+
+ $actualUsername = $db->getConfig()->username;
+ $db = $this->elevateDatabaseConnection($db, $elevateConfig);
+ $tool = $this->createDbTool($db);
+ $tool->connectToDb();
+
+ $isPgsql = $db->getAdapter() instanceof Sql\Adapter\Pgsql;
+ // PgSQL doesn't have SELECT privilege on a database level and granting the CREATE,CONNECT, and TEMPORARY
+ // privileges on a database doesn't permit a user to read data from a table. Hence, we have to grant the
+ // required database,schema and table privileges simultaneously.
+ if (! $isPgsql && $tool->checkPrivileges(['SELECT'], [], $actualUsername)) {
+ // Checks only database level grants. If this succeeds, the grants were issued manually.
+ if (! $tool->checkPrivileges($privileges, [], $actualUsername) && $tool->isGrantable($privileges)) {
+ // Any missing grant is now granted on database level as well, not to mix things up
+ $tool->grantPrivileges($privileges, [], $actualUsername);
+ }
+ } elseif (! $tool->checkPrivileges($privileges, $tables, $actualUsername) && $tool->isGrantable($privileges)) {
+ // The above ensures that if this fails, we can safely apply table level grants, as it's
+ // very likely that the existing grants were issued by the setup wizard
+ $tool->grantPrivileges($privileges, $tables, $actualUsername);
+ }
+ }
+
+ /**
+ * Create and return a DbTool instance
+ *
+ * @param Sql\Connection $db
+ *
+ * @return DbTool
+ */
+ private function createDbTool(Sql\Connection $db): DbTool
+ {
+ $config = $db->getConfig();
+
+ return new DbTool(array_merge([
+ 'db' => $config->db,
+ 'host' => $config->host,
+ 'port' => $config->port,
+ 'dbname' => $config->dbname,
+ 'username' => $config->username,
+ 'password' => $config->password,
+ 'charset' => $config->charset
+ ], $db->getAdapter()->getOptions($config)));
+ }
+
+ protected function load(): void
+ {
+ $this->pendingMigrations = [];
+
+ /** @var DbMigrationHook $hook */
+ foreach (Hook::all('DbMigration') as $hook) {
+ if (empty($hook->getMigrations())) {
+ continue;
+ }
+
+ $this->pendingMigrations[$hook->getModuleName()] = $hook;
+ }
+
+ ksort($this->pendingMigrations);
+ }
+
+ /**
+ * Check the required SQL privileges of the given connection
+ *
+ * @param Sql\Connection $conn
+ * @param ?array<string, string> $elevateConfig
+ * @param bool $canIssueGrants
+ *
+ * @return bool
+ */
+ protected function checkRequiredPrivileges(
+ Sql\Connection $conn,
+ array $elevateConfig = null,
+ bool $canIssueGrants = false
+ ): bool {
+ if ($elevateConfig) {
+ $conn = $this->elevateDatabaseConnection($conn, $elevateConfig);
+ }
+
+ $wizardProperties = (new ReflectionClass(WebWizard::class))
+ ->getDefaultProperties();
+ /** @var array<int, string> $tables */
+ $tables = $wizardProperties['databaseTables'];
+
+ $dbTool = $this->createDbTool($conn);
+ $dbTool->connectToDb();
+
+ $isPgsql = $conn->getAdapter() instanceof Sql\Adapter\Pgsql;
+ $privileges = $this->getRequiredDatabasePrivileges();
+ $dbPrivilegesGranted = $dbTool->checkPrivileges($privileges);
+ $tablePrivilegesGranted = $dbTool->checkPrivileges($privileges, $tables);
+ if (! $dbPrivilegesGranted && ($isPgsql || ! $tablePrivilegesGranted)) {
+ return false;
+ }
+
+ if ($isPgsql && ! $tablePrivilegesGranted) {
+ return false;
+ }
+
+ if ($canIssueGrants && ! $dbTool->isGrantable($privileges)) {
+ return false;
+ }
+
+ return true;
+ }
+
+ /**
+ * Override the database config of the given connection by the specified new config
+ *
+ * Overrides only the username and password of existing database connection.
+ *
+ * @param Sql\Connection $conn
+ * @param array<string, string> $elevateConfig
+ * @return Sql\Connection
+ */
+ protected function elevateDatabaseConnection(Sql\Connection $conn, array $elevateConfig): Sql\Connection
+ {
+ $config = clone $conn->getConfig();
+ $config->username = $elevateConfig['username'];
+ $config->password = $elevateConfig['password'];
+
+ return new Sql\Connection($config);
+ }
+
+ /**
+ * Get all pending migrations as an array
+ *
+ * @return array<string, mixed>
+ */
+ public function toArray(): array
+ {
+ $framework = [];
+ $serialize = function (DbMigrationHook $hook): array {
+ $serialized = [
+ 'name' => $hook->getName(),
+ 'module' => $hook->getModuleName(),
+ 'isModule' => $hook->isModule(),
+ 'migrated_version' => $hook->getVersion(),
+ 'migrations' => []
+ ];
+
+ foreach ($hook->getMigrations() as $migration) {
+ $serialized['migrations'][$migration->getVersion()] = [
+ 'path' => $migration->getScriptPath(),
+ 'error' => $migration->getLastState()
+ ];
+ }
+
+ return $serialized;
+ };
+
+ foreach ($this->yieldMigrations() as $migration) {
+ $framework[] = $serialize($migration);
+ }
+
+ $modules = [];
+ foreach ($this->yieldMigrations(true) as $migration) {
+ $modules[] = $serialize($migration);
+ }
+
+ return ['System' => $framework, 'Modules' => $modules];
+ }
+}
diff --git a/library/Icinga/Application/Modules/DashboardContainer.php b/library/Icinga/Application/Modules/DashboardContainer.php
new file mode 100644
index 0000000..f3c8bc6
--- /dev/null
+++ b/library/Icinga/Application/Modules/DashboardContainer.php
@@ -0,0 +1,58 @@
+<?php
+/* Icinga Web 2 | (c) 2015 Icinga Development Team | GPLv2+ */
+
+namespace Icinga\Application\Modules;
+
+/**
+ * Container for module dashboards
+ */
+class DashboardContainer extends NavigationItemContainer
+{
+ /**
+ * This dashboard's dashlets
+ *
+ * @var array
+ */
+ protected $dashlets;
+
+ /**
+ * Set this dashboard's dashlets
+ *
+ * @param array $dashlets
+ *
+ * @return $this
+ */
+ public function setDashlets(array $dashlets)
+ {
+ $this->dashlets = $dashlets;
+ return $this;
+ }
+
+ /**
+ * Return this dashboard's dashlets
+ *
+ * @return array
+ */
+ public function getDashlets()
+ {
+ return $this->dashlets ?: array();
+ }
+
+ /**
+ * Add a new dashlet
+ *
+ * @param string $name
+ * @param string $url
+ * @param int $priority
+ *
+ * @return $this
+ */
+ public function add($name, $url, $priority = null)
+ {
+ $this->dashlets[$name] = [
+ 'url' => $url,
+ 'priority' => $priority
+ ];
+ return $this;
+ }
+}
diff --git a/library/Icinga/Application/Modules/Manager.php b/library/Icinga/Application/Modules/Manager.php
new file mode 100644
index 0000000..55d074d
--- /dev/null
+++ b/library/Icinga/Application/Modules/Manager.php
@@ -0,0 +1,698 @@
+<?php
+/* Icinga Web 2 | (c) 2013 Icinga Development Team | GPLv2+ */
+
+namespace Icinga\Application\Modules;
+
+use Icinga\Application\ApplicationBootstrap;
+use Icinga\Application\Icinga;
+use Icinga\Application\Logger;
+use Icinga\Data\DataArray\ArrayDatasource;
+use Icinga\Data\SimpleQuery;
+use Icinga\Exception\ConfigurationError;
+use Icinga\Exception\SystemPermissionException;
+use Icinga\Exception\ProgrammingError;
+use Icinga\Exception\NotReadableError;
+
+/**
+ * Module manager that handles detecting, enabling and disabling of modules
+ *
+ * Modules can have 3 states:
+ * * installed, module exists but is disabled
+ * * enabled, module enabled and should be loaded
+ * * loaded, module enabled and loaded via the autoloader
+ *
+ */
+class Manager
+{
+ /**
+ * Namespace for module permissions
+ *
+ * @var string
+ */
+ const MODULE_PERMISSION_NS = 'module/';
+
+ /**
+ * Array of all installed module's base directories
+ *
+ * @var array
+ */
+ private $installedBaseDirs = array();
+
+ /**
+ * Array of all enabled modules base dirs
+ *
+ * @var array
+ */
+ private $enabledDirs = array();
+
+ /**
+ * Array of all module names that have been loaded
+ *
+ * @var array
+ */
+ private $loadedModules = array();
+
+ /**
+ * Reference to Icinga::app
+ *
+ * @var Icinga
+ */
+ private $app;
+
+ /**
+ * The directory that is used to detect enabled modules
+ *
+ * @var string
+ */
+ private $enableDir;
+
+ /**
+ * All paths to look for installed modules that can be enabled
+ *
+ * @var array
+ */
+ private $modulePaths = array();
+
+ /**
+ * Whether we loaded all enabled modules
+ *
+ * @var bool
+ */
+ private $loadedAllEnabledModules = false;
+
+ /**
+ * Create a new instance of the module manager
+ *
+ * @param ApplicationBootstrap $app
+ * @param string $enabledDir Enabled modules location. The application maintains symlinks within
+ * the given path
+ * @param array $availableDirs Installed modules location
+ **/
+ public function __construct($app, $enabledDir, array $availableDirs)
+ {
+ $this->app = $app;
+ $this->modulePaths = $availableDirs;
+ $this->enableDir = $enabledDir;
+ }
+
+ /**
+ * Query interface for the module manager
+ *
+ * @return SimpleQuery
+ */
+ public function select()
+ {
+ $source = new ArrayDatasource($this->getModuleInfo());
+ return $source->select();
+ }
+
+ /**
+ * Check for enabled modules
+ *
+ * Update the internal $enabledDirs property with the enabled modules.
+ *
+ * @throws ConfigurationError If module dir does not exist, is not a directory or not readable
+ */
+ private function detectEnabledModules()
+ {
+ if (! file_exists($parent = dirname($this->enableDir))) {
+ return;
+ }
+ if (! is_readable($parent)) {
+ throw new NotReadableError(
+ 'Cannot read enabled modules. Config directory "%s" is not readable',
+ $parent
+ );
+ }
+
+ if (! file_exists($this->enableDir)) {
+ return;
+ }
+ if (! is_dir($this->enableDir)) {
+ throw new NotReadableError(
+ 'Cannot read enabled modules. Module directory "%s" is not a directory',
+ $this->enableDir
+ );
+ }
+ if (! is_readable($this->enableDir)) {
+ throw new NotReadableError(
+ 'Cannot read enabled modules. Module directory "%s" is not readable',
+ $this->enableDir
+ );
+ }
+ if (($dh = opendir($this->enableDir)) !== false) {
+ $isPhar = substr($this->enableDir, 0, 8) === 'phar:///';
+ $this->enabledDirs = array();
+ while (($file = readdir($dh)) !== false) {
+ if ($file[0] === '.' || $file === 'README') {
+ continue;
+ }
+
+ $link = $this->enableDir . DIRECTORY_SEPARATOR . $file;
+ if (! $isPhar && ! is_link($link)) {
+ Logger::warning(
+ 'Found invalid module in enabledModule directory "%s": "%s" is not a symlink',
+ $this->enableDir,
+ $link
+ );
+ continue;
+ }
+
+ $dir = $isPhar ? $link : realpath($link);
+ if ($dir !== false && is_dir($dir)) {
+ $this->enabledDirs[$file] = $dir;
+ } else {
+ $this->enabledDirs[$file] = null;
+
+ Logger::warning(
+ 'Found invalid module in enabledModule directory "%s": "%s" points to non existing path "%s"',
+ $this->enableDir,
+ $link,
+ $dir
+ );
+ }
+
+ ksort($this->enabledDirs);
+ }
+ closedir($dh);
+ }
+ }
+
+ /**
+ * Try to set all enabled modules in loaded sate
+ *
+ * @return $this
+ * @see Manager::loadModule()
+ */
+ public function loadEnabledModules()
+ {
+ if (! $this->loadedAllEnabledModules) {
+ foreach ($this->listEnabledModules() as $name) {
+ $this->loadModule($name);
+ }
+
+ $this->loadedAllEnabledModules = true;
+ }
+
+ return $this;
+ }
+
+ /**
+ * Whether we loaded all enabled modules
+ *
+ * @return bool
+ */
+ public function loadedAllEnabledModules()
+ {
+ return $this->loadedAllEnabledModules;
+ }
+
+
+ /**
+ * Try to load the module and register it in the application
+ *
+ * @param string $name The name of the module to load
+ * @param mixed $basedir Optional module base directory
+ *
+ * @return $this
+ */
+ public function loadModule($name, $basedir = null)
+ {
+ if ($this->hasLoaded($name)) {
+ return $this;
+ }
+
+ $module = null;
+ if ($basedir === null) {
+ $module = new Module($this->app, $name, $this->getModuleDir($name));
+ } else {
+ $module = new Module($this->app, $name, $basedir);
+ }
+
+ if ($name !== 'ipl' && $name !== 'reactbundle') {
+ $module->register();
+ }
+
+ $this->loadedModules[$name] = $module;
+ return $this;
+ }
+
+ /**
+ * 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, $force = false)
+ {
+ if (! $this->hasInstalled($name)) {
+ throw new ConfigurationError(
+ 'Cannot enable module "%s". Module is not installed.',
+ $name
+ );
+ }
+
+ if (strtolower(substr($name, 0, 18)) === 'icingaweb2-module-') {
+ throw new ConfigurationError(
+ 'Cannot enable module "%s": Directory name does not match the module\'s name.'
+ . ' Please rename the module to "%s" before enabling.',
+ $name,
+ substr($name, 18)
+ );
+ }
+
+ 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;
+
+ if (! is_dir($this->enableDir)) {
+ if (!@mkdir($this->enableDir, 0777, true)) {
+ $error = error_get_last();
+ throw new SystemPermissionException(
+ 'Failed to create enabledModules directory "%s" (%s)',
+ $this->enableDir,
+ $error['message']
+ );
+ }
+
+ chmod($this->enableDir, 02770);
+ } elseif (! is_writable($this->enableDir)) {
+ throw new SystemPermissionException(
+ 'Cannot enable module "%s". Check the permissions for the enabledModules directory: %s',
+ $name,
+ $this->enableDir
+ );
+ }
+
+ $this->loadedAllEnabledModules = false;
+
+ if (file_exists($link) && is_link($link)) {
+ return $this;
+ }
+
+ if (! @symlink($target, $link)) {
+ $error = error_get_last();
+ if (strstr($error["message"], "File exists") === false) {
+ throw new SystemPermissionException(
+ 'Cannot enable module "%s" at %s due to file system errors. '
+ . 'Please check path and mounting points because this is not a permission error. '
+ . 'Primary error was: %s',
+ $name,
+ $this->enableDir,
+ $error['message']
+ );
+ }
+ }
+
+ $this->enabledDirs[$name] = $link;
+ $this->loadModule($name);
+ return $this;
+ }
+
+ /**
+ * Disable the given module and remove its enabled state
+ *
+ * @param string $name The name of the module to disable
+ *
+ * @return $this
+ *
+ * @throws ConfigurationError When the module is not installed or it's not a symlink
+ * @throws SystemPermissionException When insufficient permissions for the application exist
+ */
+ public function disableModule($name)
+ {
+ if (! $this->hasEnabled($name)) {
+ throw new ConfigurationError(
+ 'Cannot disable module "%s". Module is not installed.',
+ $name
+ );
+ }
+
+ if (! is_writable($this->enableDir)) {
+ throw new SystemPermissionException(
+ 'Cannot disable module "%s". Check the permissions for the enabledModules directory: %s',
+ $name,
+ $this->enableDir
+ );
+ }
+
+ $link = $this->enableDir . DIRECTORY_SEPARATOR . $name;
+ if (! is_link($link)) {
+ throw new ConfigurationError(
+ 'Cannot disable module %s at %s. '
+ . 'It looks like you have installed this module manually and moved it to your module folder. '
+ . 'In order to dynamically enable and disable modules, you have to create a symlink to '
+ . 'the enabledModules folder.',
+ $name,
+ $this->enableDir
+ );
+ }
+
+ if (is_link($link)) {
+ if (! @unlink($link)) {
+ $error = error_get_last();
+ throw new SystemPermissionException(
+ 'Cannot enable module "%s" at %s due to file system errors. '
+ . 'Please check path and mounting points because this is not a permission error. '
+ . 'Primary error was: %s',
+ $name,
+ $this->enableDir,
+ $error['message']
+ );
+ }
+ }
+
+ unset($this->enabledDirs[$name]);
+ return $this;
+ }
+
+ /**
+ * Return the directory of the given module as a string, optionally with a given sub directoy
+ *
+ * @param string $name The module name to return the module directory of
+ * @param string $subdir The sub directory to append to the path
+ *
+ * @return string
+ *
+ * @throws ProgrammingError When the module is not installed or existing
+ */
+ public function getModuleDir($name, $subdir = '')
+ {
+ if ($this->hasLoaded($name)) {
+ return $this->getModule($name)->getBaseDir() . $subdir;
+ }
+
+ if ($this->hasEnabled($name)) {
+ return $this->enabledDirs[$name]. $subdir;
+ }
+
+ if ($this->hasInstalled($name)) {
+ return $this->installedBaseDirs[$name] . $subdir;
+ }
+
+ throw new ProgrammingError(
+ 'Trying to access uninstalled module dir: %s',
+ $name
+ );
+ }
+
+ /**
+ * Return true when the module with the given name is installed, otherwise false
+ *
+ * @param string $name The module to check for being installed
+ *
+ * @return bool
+ */
+ public function hasInstalled($name)
+ {
+ if (!count($this->installedBaseDirs)) {
+ $this->detectInstalledModules();
+ }
+ return array_key_exists($name, $this->installedBaseDirs);
+ }
+
+ /**
+ * Return true when the given module is in enabled state, otherwise false
+ *
+ * @param string $name The module to check for being enabled
+ *
+ * @return bool
+ */
+ public function hasEnabled($name)
+ {
+ return array_key_exists($name, $this->enabledDirs);
+ }
+
+ /**
+ * Return true when the module is in loaded state, otherwise false
+ *
+ * @param string $name The module to check for being loaded
+ *
+ * @return bool
+ */
+ public function hasLoaded($name)
+ {
+ 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 || $version === true) {
+ return true;
+ }
+
+ $operator = '=';
+ if (preg_match('/^([<>=]{1,2})\s*v?((?:[\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
+ *
+ * @return Module[]
+ */
+ public function getLoadedModules()
+ {
+ return $this->loadedModules;
+ }
+
+ /**
+ * Get a module
+ *
+ * @param string $name Name of the module
+ * @param bool $assertLoaded Whether or not to throw an exception if the module hasn't been loaded
+ *
+ * @return Module
+ * @throws ProgrammingError If the module hasn't been loaded
+ */
+ public function getModule($name, $assertLoaded = true)
+ {
+ if ($this->hasLoaded($name)) {
+ return $this->loadedModules[$name];
+ } elseif (! (bool) $assertLoaded) {
+ return new Module($this->app, $name, $this->getModuleDir($name));
+ }
+ throw new ProgrammingError(
+ 'Can\'t access module %s because it hasn\'t been loaded',
+ $name
+ );
+ }
+
+ /**
+ * Return an array containing information objects for each available module
+ *
+ * Each entry has the following fields
+ * * name, name of the module as a string
+ * * path, path where the module is located as a string
+ * * installed, whether the module is installed or not as a boolean
+ * * enabled, whether the module is enabled or not as a boolean
+ * * loaded, whether the module is loaded or not as a boolean
+ *
+ * @return array
+ */
+ public function getModuleInfo()
+ {
+ $info = array();
+
+ $installed = $this->listInstalledModules();
+ foreach ($installed as $name) {
+ $info[$name] = (object) array(
+ 'name' => $name,
+ 'path' => $this->installedBaseDirs[$name],
+ 'installed' => true,
+ 'enabled' => $this->hasEnabled($name),
+ 'loaded' => $this->hasLoaded($name)
+ );
+ }
+
+ $enabled = $this->listEnabledModules();
+ foreach ($enabled as $name) {
+ $info[$name] = (object) array(
+ 'name' => $name,
+ 'path' => $this->enabledDirs[$name],
+ 'installed' => $this->enabledDirs[$name] !== null,
+ 'enabled' => true,
+ 'loaded' => $this->hasLoaded($name)
+ );
+ }
+
+ 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();
+
+ if (isset($requiredMods['monitoring'], $requiredMods['icingadb'])) {
+ if (! $this->has('monitoring', $requiredMods['monitoring'])
+ && ! $this->has('icingadb', $requiredMods['icingadb'])
+ ) {
+ return true;
+ }
+
+ unset($requiredMods['monitoring'], $requiredMods['icingadb']);
+ }
+
+ 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
+ *
+ * @return array
+ */
+ public function listEnabledModules()
+ {
+ if (count($this->enabledDirs) === 0) {
+ $this->detectEnabledModules();
+ }
+
+ return array_keys($this->enabledDirs);
+ }
+
+ /**
+ * Return an array containing all loaded module names as strings
+ *
+ * @return array
+ */
+ public function listLoadedModules()
+ {
+ return array_keys($this->loadedModules);
+ }
+
+ /**
+ * Return an array of module names from installed modules
+ *
+ * Calls detectInstalledModules() if no module discovery has been performed yet
+ *
+ * @return array
+ *
+ * @see detectInstalledModules()
+ */
+ public function listInstalledModules()
+ {
+ if (!count($this->installedBaseDirs)) {
+ $this->detectInstalledModules();
+ }
+
+ if (count($this->installedBaseDirs)) {
+ return array_keys($this->installedBaseDirs);
+ }
+
+ return array();
+ }
+
+ /**
+ * Detect installed modules from every path provided in modulePaths
+ *
+ * @param array $availableDirs Installed modules location
+ *
+ * @return $this
+ */
+ public function detectInstalledModules(array $availableDirs = null)
+ {
+ $modulePaths = $availableDirs !== null ? $availableDirs : $this->modulePaths;
+ foreach ($modulePaths as $basedir) {
+ $canonical = realpath($basedir);
+ if ($canonical === false) {
+ Logger::warning('Module path "%s" does not exist', $basedir);
+ continue;
+ }
+ if (!is_dir($canonical)) {
+ Logger::error('Module path "%s" is not a directory', $canonical);
+ continue;
+ }
+ if (!is_readable($canonical)) {
+ Logger::error('Module path "%s" is not readable', $canonical);
+ continue;
+ }
+ if (($dh = opendir($canonical)) !== false) {
+ while (($file = readdir($dh)) !== false) {
+ if ($file[0] === '.') {
+ continue;
+ }
+ if (is_dir($canonical . '/' . $file)) {
+ if (! array_key_exists($file, $this->installedBaseDirs)) {
+ $this->installedBaseDirs[$file] = $canonical . '/' . $file;
+ } else {
+ Logger::debug(
+ 'Module "%s" already exists in installation path "%s" and is ignored.',
+ $canonical . '/' . $file,
+ $this->installedBaseDirs[$file]
+ );
+ }
+ }
+ }
+ closedir($dh);
+ }
+ }
+ ksort($this->installedBaseDirs);
+ return $this;
+ }
+
+ /**
+ * Get the directories where to look for installed modules
+ *
+ * @return array
+ */
+ public function getModuleDirs()
+ {
+ return $this->modulePaths;
+ }
+}
diff --git a/library/Icinga/Application/Modules/MenuItemContainer.php b/library/Icinga/Application/Modules/MenuItemContainer.php
new file mode 100644
index 0000000..88599e6
--- /dev/null
+++ b/library/Icinga/Application/Modules/MenuItemContainer.php
@@ -0,0 +1,55 @@
+<?php
+/* Icinga Web 2 | (c) 2015 Icinga Development Team | GPLv2+ */
+
+namespace Icinga\Application\Modules;
+
+/**
+ * Container for module menu items
+ */
+class MenuItemContainer extends NavigationItemContainer
+{
+ /**
+ * This menu item's children
+ *
+ * @var MenuItemContainer[]
+ */
+ protected $children;
+
+ /**
+ * Set this menu item's children
+ *
+ * @param MenuItemContainer[] $children
+ *
+ * @return $this
+ */
+ public function setChildren(array $children)
+ {
+ $this->children = $children;
+ return $this;
+ }
+
+ /**
+ * Return this menu item's children
+ *
+ * @return array
+ */
+ public function getChildren()
+ {
+ return $this->children ?: array();
+ }
+
+ /**
+ * Add a new sub menu
+ *
+ * @param string $name
+ * @param array $properties
+ *
+ * @return MenuItemContainer The newly added sub menu
+ */
+ public function add($name, array $properties = array())
+ {
+ $child = new MenuItemContainer($name, $properties);
+ $this->children[] = $child;
+ return $child;
+ }
+}
diff --git a/library/Icinga/Application/Modules/Module.php b/library/Icinga/Application/Modules/Module.php
new file mode 100644
index 0000000..6a5afb8
--- /dev/null
+++ b/library/Icinga/Application/Modules/Module.php
@@ -0,0 +1,1451 @@
+<?php
+/* Icinga Web 2 | (c) 2013 Icinga Development Team | GPLv2+ */
+
+namespace Icinga\Application\Modules;
+
+use Exception;
+use Icinga\Application\ApplicationBootstrap;
+use Icinga\Application\Config;
+use Icinga\Application\Hook;
+use Icinga\Application\Icinga;
+use Icinga\Application\Logger;
+use Icinga\Exception\IcingaException;
+use Icinga\Exception\ProgrammingError;
+use Icinga\Module\Setup\SetupWizard;
+use Icinga\Util\File;
+use Icinga\Web\Navigation\Navigation;
+use Icinga\Web\Widget;
+use ipl\I18n\GettextTranslator;
+use ipl\I18n\StaticTranslator;
+use ipl\I18n\Translation;
+use Zend_Controller_Router_Route;
+use Zend_Controller_Router_Route_Abstract;
+use Zend_Controller_Router_Route_Regex;
+
+/**
+ * Module handling
+ *
+ * Register modules and initialize it
+ */
+class Module
+{
+ use Translation {
+ translate as protected;
+ translatePlural as protected;
+ }
+
+ /**
+ * Module name
+ *
+ * @var string
+ */
+ private $name;
+
+ /**
+ * Base directory of module
+ *
+ * @var string
+ */
+ private $basedir;
+
+ /**
+ * Directory for styles
+ *
+ * @var string
+ */
+ private $cssdir;
+
+ /**
+ * Directory for Javascript
+ *
+ * @var string
+ */
+ private $jsdir;
+
+ /**
+ * Base application directory
+ *
+ * @var string
+ */
+ private $appdir;
+
+ /**
+ * Library directory
+ *
+ * @var string
+ */
+ private $libdir;
+
+ /**
+ * Config directory
+ *
+ * @var string
+ */
+ private $configdir;
+
+ /**
+ * Directory containing translations
+ *
+ * @var string
+ */
+ private $localedir;
+
+ /**
+ * Directory where controllers reside
+ *
+ * @var string
+ */
+ private $controllerdir;
+
+ /**
+ * Directory containing form implementations
+ *
+ * @var string
+ */
+ private $formdir;
+
+ /**
+ * Module bootstrapping script
+ *
+ * @var string
+ */
+ private $runScript;
+
+ /**
+ * Module configuration script
+ *
+ * @var string
+ */
+ private $configScript;
+
+ /**
+ * Module metadata filename
+ *
+ * @var string
+ */
+ private $metadataFile;
+
+ /**
+ * Module metadata (version...)
+ *
+ * @var object
+ */
+ private $metadata;
+
+ /**
+ * Whether we already tried to include the module configuration script
+ *
+ * @var bool
+ */
+ private $triedToLaunchConfigScript = false;
+
+ /**
+ * Whether the module's namespaces have been registered on our autoloader
+ *
+ * @var bool
+ */
+ protected $registeredAutoloader = false;
+
+ /**
+ * Whether this module has been registered
+ *
+ * @var bool
+ */
+ private $registered = false;
+
+ /**
+ * Provided permissions
+ *
+ * @var array
+ */
+ private $permissionList = array();
+
+ /**
+ * Provided restrictions
+ *
+ * @var array
+ */
+ private $restrictionList = array();
+
+ /**
+ * Provided config tabs
+ *
+ * @var array
+ */
+ private $configTabs = array();
+
+ /**
+ * Provided setup wizard
+ *
+ * @var string
+ */
+ private $setupWizard;
+
+ /**
+ * Icinga application
+ *
+ * @var \Icinga\Application\Web
+ */
+ private $app;
+
+ /**
+ * The CSS/LESS files this module provides
+ *
+ * @var array
+ */
+ protected $cssFiles = array();
+
+ /**
+ * The Javascript files this module provides
+ *
+ * @var array
+ */
+ protected $jsFiles = array();
+
+ /**
+ * Routes to add to the route chain
+ *
+ * @var array Array of name-route pairs
+ *
+ * @see addRoute()
+ */
+ protected $routes = array();
+
+ /**
+ * A set of menu elements
+ *
+ * @var MenuItemContainer[]
+ */
+ protected $menuItems = array();
+
+ /**
+ * A set of Pane elements
+ *
+ * @var array
+ */
+ protected $paneItems = array();
+
+ /**
+ * A set of objects representing a searchUrl configuration
+ *
+ * @var array
+ */
+ protected $searchUrls = array();
+
+ /**
+ * This module's user backends providing several authentication mechanisms
+ *
+ * @var array
+ */
+ protected $userBackends = array();
+
+ /**
+ * This module's user group backends
+ *
+ * @var array
+ */
+ protected $userGroupBackends = array();
+
+ /**
+ * This module's configurable navigation items
+ *
+ * @var array
+ */
+ protected $navigationItems = array();
+
+ /**
+ * Create a new module object
+ *
+ * @param ApplicationBootstrap $app
+ * @param string $name
+ * @param string $basedir
+ */
+ public function __construct(ApplicationBootstrap $app, $name, $basedir)
+ {
+ $this->app = $app;
+ $this->name = $name;
+ $this->basedir = $basedir;
+ $this->cssdir = $basedir . '/public/css';
+ $this->jsdir = $basedir . '/public/js';
+ $this->libdir = $basedir . '/library';
+ $this->configdir = $app->getConfigDir('modules/' . $name);
+ $this->appdir = $basedir . '/application';
+ $this->localedir = $basedir . '/application/locale';
+ $this->formdir = $basedir . '/application/forms';
+ $this->controllerdir = $basedir . '/application/controllers';
+ $this->runScript = $basedir . '/run.php';
+ $this->configScript = $basedir . '/configuration.php';
+ $this->metadataFile = $basedir . '/module.info';
+
+ $this->translationDomain = $name;
+ }
+
+ /**
+ * Provide a search URL
+ *
+ * @param string $title
+ * @param string $url
+ * @param int $priority
+ *
+ * @return $this
+ */
+ public function provideSearchUrl($title, $url, $priority = 0)
+ {
+ $this->searchUrls[] = (object) array(
+ 'title' => (string) $title,
+ 'url' => (string) $url,
+ 'priority' => (int) $priority
+ );
+
+ return $this;
+ }
+
+ /**
+ * Get this module's search urls
+ *
+ * @return array
+ */
+ public function getSearchUrls()
+ {
+ $this->launchConfigScript();
+ return $this->searchUrls;
+ }
+
+ /**
+ * Return this module's dashboard
+ *
+ * @return Navigation
+ */
+ public function getDashboard()
+ {
+ $this->launchConfigScript();
+ return $this->createDashboard($this->paneItems);
+ }
+
+ /**
+ * Create and return a new navigation for the given dashboard panes
+ *
+ * @param DashboardContainer[] $panes
+ *
+ * @return Navigation
+ */
+ public function createDashboard(array $panes)
+ {
+ $navigation = new Navigation();
+ foreach ($panes as $pane) {
+ /** @var DashboardContainer $pane */
+ $dashlets = [];
+ foreach ($pane->getDashlets() as $dashletName => $dashletConfig) {
+ $dashlets[$dashletName] = [
+ 'label' => $this->translate($dashletName),
+ 'url' => $dashletConfig['url'],
+ 'priority' => $dashletConfig['priority']
+ ];
+ }
+
+ $navigation->addItem(
+ $pane->getName(),
+ array_merge(
+ $pane->getProperties(),
+ array(
+ 'label' => $this->translate($pane->getName()),
+ 'type' => 'dashboard-pane',
+ 'children' => $dashlets
+ )
+ )
+ );
+ }
+
+ return $navigation;
+ }
+
+ /**
+ * Add or get a dashboard pane
+ *
+ * @param string $name
+ * @param array $properties
+ *
+ * @return DashboardContainer
+ */
+ protected function dashboard($name, array $properties = array())
+ {
+ if (array_key_exists($name, $this->paneItems)) {
+ $this->paneItems[$name]->setProperties($properties);
+ } else {
+ $this->paneItems[$name] = new DashboardContainer($name, $properties);
+ }
+
+ return $this->paneItems[$name];
+ }
+
+ /**
+ * Return this module's menu
+ *
+ * @return Navigation
+ */
+ public function getMenu()
+ {
+ $this->launchConfigScript();
+ return Navigation::fromArray($this->createMenu($this->menuItems));
+ }
+
+ /**
+ * Create and return an array structure for the given menu items
+ *
+ * @param MenuItemContainer[] $items
+ *
+ * @return array
+ */
+ private function createMenu(array $items)
+ {
+ $navigation = array();
+ foreach ($items as $item) {
+ /** @var MenuItemContainer $item */
+ $properties = $item->getProperties();
+ $properties['children'] = $this->createMenu($item->getChildren());
+ if (! isset($properties['label'])) {
+ $properties['label'] = $this->translate($item->getName());
+ }
+
+ $navigation[$item->getName()] = $properties;
+ }
+
+ return $navigation;
+ }
+
+ /**
+ * Add or get a menu section
+ *
+ * @param string $name
+ * @param array $properties
+ *
+ * @return MenuItemContainer
+ */
+ protected function menuSection($name, array $properties = array())
+ {
+ if (array_key_exists($name, $this->menuItems)) {
+ $this->menuItems[$name]->setProperties($properties);
+ } else {
+ $this->menuItems[$name] = new MenuItemContainer($name, $properties);
+ }
+
+ return $this->menuItems[$name];
+ }
+
+ /**
+ * Register module
+ *
+ * @return bool
+ */
+ public function register()
+ {
+ if ($this->registered) {
+ return true;
+ }
+
+ $this->registerAutoloader();
+ try {
+ $this->launchRunScript();
+ } catch (Exception $e) {
+ Logger::warning(
+ 'Launching the run script %s for module %s failed with the following exception: %s',
+ $this->runScript,
+ $this->name,
+ $e->getMessage()
+ );
+ return false;
+ }
+ $this->registerWebIntegration();
+ $this->registered = true;
+
+ return true;
+ }
+
+ /**
+ * Get whether this module has been registered
+ *
+ * @return bool
+ */
+ public function isRegistered()
+ {
+ return $this->registered;
+ }
+
+ /**
+ * Test for an enabled module by name
+ *
+ * @param string $name
+ *
+ * @return bool
+ */
+ public static function exists($name)
+ {
+ return Icinga::app()->getModuleManager()->hasEnabled($name);
+ }
+
+ /**
+ * Get a module by name
+ *
+ * @param string $name
+ * @param bool $autoload
+ *
+ * @return self
+ *
+ * @throws ProgrammingError When the module is not yet loaded
+ */
+ public static function get($name, $autoload = false)
+ {
+ $manager = Icinga::app()->getModuleManager();
+ if (!$manager->hasLoaded($name)) {
+ if ($autoload === true && $manager->hasEnabled($name)) {
+ $manager->loadModule($name);
+ }
+ }
+ // Throws ProgrammingError when the module is not yet loaded
+ return $manager->getModule($name);
+ }
+
+ /**
+ * Provide an additional CSS/LESS file
+ *
+ * @param string $path The path to the file, relative to self::$cssdir
+ *
+ * @return $this
+ */
+ protected function provideCssFile($path)
+ {
+ $this->cssFiles[] = $this->cssdir . DIRECTORY_SEPARATOR . $path;
+ return $this;
+ }
+
+ /**
+ * Test if module provides css
+ *
+ * @return bool
+ */
+ public function hasCss()
+ {
+ if (file_exists($this->getCssFilename())) {
+ return true;
+ }
+
+ $this->launchConfigScript();
+ return !empty($this->cssFiles);
+ }
+
+ /**
+ * Returns the complete less file name
+ *
+ * @return string
+ */
+ public function getCssFilename()
+ {
+ return $this->cssdir . '/module.less';
+ }
+
+ /**
+ * Return the CSS/LESS files this module provides
+ *
+ * @return array
+ */
+ public function getCssFiles()
+ {
+ $this->launchConfigScript();
+ $files = $this->cssFiles;
+ if (file_exists($this->getCssFilename())) {
+ $files[] = $this->getCssFilename();
+ }
+ return $files;
+ }
+
+ /**
+ * Provide an additional Javascript file
+ *
+ * @param string $path The path to the file, relative to self::$jsdir
+ *
+ * @return $this
+ */
+ protected function provideJsFile($path)
+ {
+ $this->jsFiles[] = $this->jsdir . DIRECTORY_SEPARATOR . $path;
+ return $this;
+ }
+
+ /**
+ * Test if module provides js
+ *
+ * @return bool
+ */
+ public function hasJs()
+ {
+ if (file_exists($this->getJsFilename())) {
+ return true;
+ }
+
+ $this->launchConfigScript();
+ return !empty($this->jsFiles);
+ }
+
+ /**
+ * Returns the complete js file name
+ *
+ * @return string
+ */
+ public function getJsFilename()
+ {
+ return $this->jsdir . '/module.js';
+ }
+
+ /**
+ * Return the Javascript files this module provides
+ *
+ * @return array
+ */
+ public function getJsFiles()
+ {
+ $this->launchConfigScript();
+ $files = $this->jsFiles;
+ $files[] = $this->getJsFilename();
+ return $files;
+ }
+
+ /**
+ * Get the module name
+ *
+ * @return string
+ */
+ public function getName()
+ {
+ return $this->name;
+ }
+
+ /**
+ * Get the module namespace
+ *
+ * @return string
+ */
+ public function getNamespace()
+ {
+ return 'Icinga\\Module\\' . ucfirst($this->getName());
+ }
+
+ /**
+ * Get the module version
+ *
+ * @return string
+ */
+ public function getVersion()
+ {
+ return $this->metadata()->version;
+ }
+
+ /**
+ * Get the module description
+ *
+ * @return string
+ */
+ public function getDescription()
+ {
+ return $this->metadata()->description;
+ }
+
+ /**
+ * Get the module title (short description)
+ *
+ * @return string
+ */
+ public function getTitle()
+ {
+ return $this->metadata()->title;
+ }
+
+ /**
+ * 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()
+ {
+ $requiredLibraries = $this->metadata()->libraries;
+
+ // Register module requirements for ipl and reactbundle as library requirements
+ $requiredModules = $this->metadata()->modules ?: $this->metadata()->depends;
+ if (isset($requiredModules['ipl']) && ! isset($requiredLibraries['icinga-php-library'])) {
+ $requiredLibraries['icinga-php-library'] = $requiredModules['ipl'];
+ }
+
+ if (isset($requiredModules['reactbundle']) && ! isset($requiredLibraries['icinga-php-thirdparty'])) {
+ $requiredLibraries['icinga-php-thirdparty'] = $requiredModules['reactbundle'];
+ }
+
+ return $requiredLibraries;
+ }
+
+ /**
+ * Get required modules
+ *
+ * @return array
+ */
+ public function getRequiredModules()
+ {
+ $requiredModules = $this->metadata()->modules ?: $this->metadata()->depends;
+
+ $hasIcingadb = isset($requiredModules['icingadb']);
+ if (isset($requiredModules['monitoring']) && ($this->isSupportingIcingadb() || $hasIcingadb)) {
+ $requiredMods = [];
+ $icingadbVersion = true;
+ if ($hasIcingadb) {
+ $icingadbVersion = isset($requiredModules['icingadb']) ? $requiredModules['icingadb'] : true;
+ unset($requiredModules['icingadb']);
+ }
+
+ foreach ($requiredModules as $name => $version) {
+ $requiredMods[$name] = $version;
+ if ($name === 'monitoring') {
+ $requiredMods['icingadb'] = $icingadbVersion;
+ }
+ }
+
+ $requiredModules = $requiredMods;
+ }
+
+ // Both modules are deprecated and their successors are now dependencies of web itself
+ unset($requiredModules['ipl'], $requiredModules['reactbundle']);
+
+ return $requiredModules;
+ }
+
+ /**
+ * Check whether module supports icingadb
+ *
+ * @return bool
+ */
+ protected function isSupportingIcingadb()
+ {
+ $icingadbSupportingModules = [
+ 'cube' => '1.2.0',
+ 'jira' => '1.2.0',
+ 'graphite' => '1.2.0',
+ 'director' => '1.9.0',
+ 'toplevelview' => '0.4.0',
+ 'businessprocess' => '2.4.0'
+ ];
+
+ return array_key_exists($this->getName(), $icingadbSupportingModules)
+ && version_compare($this->getVersion(), $icingadbSupportingModules[$this->getName()], '>=');
+ }
+
+ /**
+ * Fetch module metadata
+ *
+ * @return object
+ */
+ protected function metadata()
+ {
+ if ($this->metadata === null) {
+ $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);
+
+ if ($key === 'description') {
+ if (empty($line)) {
+ $metadata->description .= "\n";
+ continue;
+ } elseif ($line[0] === ' ') {
+ $metadata->description .= $line;
+ continue;
+ }
+ } elseif (empty($line)) {
+ continue;
+ }
+
+ if (strpos($line, ':') === false) {
+ Logger::debug(
+ "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
+ );
+
+ break;
+ }
+
+ $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->{$key}[$val] = true;
+ continue 2;
+ }
+
+ $parts = preg_split('/,\s+/', $val);
+ foreach ($parts as $part) {
+ if (preg_match('/^([\w\-\/]+)\s+\((.+)\)$/', $part, $m)) {
+ $metadata->{$key}[$m[1]] = $m[2];
+ } else {
+ $metadata->{$key}[$part] = true;
+ }
+ }
+
+ break;
+ case 'description':
+ if ($metadata->title === null) {
+ $metadata->title = $val;
+ } else {
+ $metadata->description = $val;
+ }
+ break;
+
+ default:
+ $metadata->{$key} = $val;
+ }
+ }
+ }
+
+ if ($metadata->title === null) {
+ $metadata->title = $this->getName();
+ }
+
+ if ($metadata->description === '') {
+ $metadata->description = t(
+ 'This module has no description'
+ );
+ }
+
+ $this->metadata = $metadata;
+ }
+ return $this->metadata;
+ }
+
+ /**
+ * Get the module's CSS directory
+ *
+ * @return string
+ */
+ public function getCssDir()
+ {
+ return $this->cssdir;
+ }
+
+ /**
+ * Get the module's JS directory
+ *
+ * @return string
+ */
+ public function getJsDir()
+ {
+ return $this->jsdir;
+ }
+
+ /**
+ * Get the module's controller directory
+ *
+ * @return string
+ */
+ public function getControllerDir()
+ {
+ return $this->controllerdir;
+ }
+
+ /**
+ * Get the module's base directory
+ *
+ * @return string
+ */
+ public function getBaseDir()
+ {
+ return $this->basedir;
+ }
+
+ /**
+ * Get the module's application directory
+ *
+ * @return string
+ */
+ public function getApplicationDir()
+ {
+ return $this->appdir;
+ }
+
+ /**
+ * Get the module's library directory
+ *
+ * @return string
+ */
+ public function getLibDir()
+ {
+ return $this->libdir;
+ }
+
+ /**
+ * Get the module's configuration directory
+ *
+ * @return string
+ */
+ public function getConfigDir()
+ {
+ return $this->configdir;
+ }
+
+ /**
+ * Get the module's form directory
+ *
+ * @return string
+ */
+ public function getFormDir()
+ {
+ return $this->formdir;
+ }
+
+ /**
+ * Get the module config
+ *
+ * @param string $file
+ *
+ * @return Config
+ */
+ public function getConfig($file = 'config')
+ {
+ return $this->app->getConfig()->module($this->name, $file);
+ }
+
+ /**
+ * Get provided permissions
+ *
+ * @return array
+ */
+ public function getProvidedPermissions()
+ {
+ $this->launchConfigScript();
+ return $this->permissionList;
+ }
+
+ /**
+ * Get provided restrictions
+ *
+ * @return array
+ */
+ public function getProvidedRestrictions()
+ {
+ $this->launchConfigScript();
+ return $this->restrictionList;
+ }
+
+ /**
+ * Whether the module provides the given restriction
+ *
+ * @param string $name Restriction name
+ *
+ * @return bool
+ */
+ public function providesRestriction($name)
+ {
+ $this->launchConfigScript();
+ return array_key_exists($name, $this->restrictionList);
+ }
+
+ /**
+ * Whether the module provides the given permission
+ *
+ * @param string $name Permission name
+ *
+ * @return bool
+ */
+ public function providesPermission($name)
+ {
+ $this->launchConfigScript();
+ return array_key_exists($name, $this->permissionList);
+ }
+
+ /**
+ * Get the module configuration tabs
+ *
+ * @return \Icinga\Web\Widget\Tabs
+ */
+ public function getConfigTabs()
+ {
+ $this->launchConfigScript();
+ $tabs = Widget::create('tabs');
+ /** @var \Icinga\Web\Widget\Tabs $tabs */
+ $tabs->add('info', array(
+ 'url' => 'config/module',
+ 'urlParams' => array('name' => $this->getName()),
+ 'label' => 'Module: ' . $this->getName()
+ ));
+
+ if ($this->app->getModuleManager()->hasEnabled($this->name)) {
+ foreach ($this->configTabs as $name => $config) {
+ $tabs->add($name, $config);
+ }
+ }
+
+ return $tabs;
+ }
+
+ /**
+ * Whether the module provides a setup wizard
+ *
+ * @return bool
+ */
+ public function providesSetupWizard()
+ {
+ $this->launchConfigScript();
+ if ($this->setupWizard && class_exists($this->setupWizard)) {
+ $wizard = new $this->setupWizard;
+ return $wizard instanceof SetupWizard;
+ }
+
+ return false;
+ }
+
+ /**
+ * Get the module's setup wizard
+ *
+ * @return SetupWizard
+ */
+ public function getSetupWizard()
+ {
+ return new $this->setupWizard;
+ }
+
+ /**
+ * Get the module's user backends
+ *
+ * @return array
+ */
+ public function getUserBackends()
+ {
+ $this->launchConfigScript();
+ return $this->userBackends;
+ }
+
+ /**
+ * Get the module's user group backends
+ *
+ * @return array
+ */
+ public function getUserGroupBackends()
+ {
+ $this->launchConfigScript();
+ return $this->userGroupBackends;
+ }
+
+ /**
+ * Return this module's configurable navigation items
+ *
+ * @return array
+ */
+ public function getNavigationItems()
+ {
+ $this->launchConfigScript();
+ return $this->navigationItems;
+ }
+
+ /**
+ * Provide a named permission
+ *
+ * @param string $name Unique permission name
+ * @param string $description Permission description
+ *
+ * @throws IcingaException If the permission is already provided
+ */
+ protected function providePermission($name, $description)
+ {
+ if ($this->providesPermission($name)) {
+ throw new IcingaException(
+ 'Cannot provide permission "%s" twice',
+ $name
+ );
+ }
+ $this->permissionList[$name] = (object) array(
+ 'name' => $name,
+ 'description' => $description
+ );
+ }
+
+ /**
+ * Provide a named restriction
+ *
+ * @param string $name Unique restriction name
+ * @param string $description Restriction description
+ *
+ * @throws IcingaException If the restriction is already provided
+ */
+ protected function provideRestriction($name, $description)
+ {
+ if ($this->providesRestriction($name)) {
+ throw new IcingaException(
+ 'Cannot provide restriction "%s" twice',
+ $name
+ );
+ }
+ $this->restrictionList[$name] = (object) array(
+ 'name' => $name,
+ 'description' => $description
+ );
+ }
+
+ /**
+ * Provide a module config tab
+ *
+ * @param string $name Unique tab name
+ * @param array $config Tab config
+ *
+ * @return $this
+ * @throws ProgrammingError If $config lacks the key 'url'
+ */
+ protected function provideConfigTab($name, $config = array())
+ {
+ if (! array_key_exists('url', $config)) {
+ throw new ProgrammingError('A module config tab MUST provide a "url"');
+ }
+ $config['url'] = $this->getName() . '/' . ltrim($config['url'], '/');
+ $this->configTabs[$name] = $config;
+ return $this;
+ }
+
+ /**
+ * Provide a setup wizard
+ *
+ * @param string $className The name of the class
+ *
+ * @return $this
+ */
+ protected function provideSetupWizard($className)
+ {
+ $this->setupWizard = $className;
+ return $this;
+ }
+
+ /**
+ * Provide a user backend capable of authenticating users
+ *
+ * @param string $identifier The identifier of the new backend type
+ * @param string $className The name of the class
+ *
+ * @return $this
+ */
+ protected function provideUserBackend($identifier, $className)
+ {
+ $this->userBackends[strtolower($identifier)] = $className;
+ return $this;
+ }
+
+ /**
+ * Provide a user group backend
+ *
+ * @param string $identifier The identifier of the new backend type
+ * @param string $className The name of the class
+ *
+ * @return $this
+ */
+ protected function provideUserGroupBackend($identifier, $className)
+ {
+ $this->userGroupBackends[strtolower($identifier)] = $className;
+ return $this;
+ }
+
+ /**
+ * Provide a new type of configurable navigation item with a optional label and config filename
+ *
+ * @param string $type
+ * @param string $label
+ * @param string $config
+ *
+ * @return $this
+ */
+ protected function provideNavigationItem($type, $label = null, $config = null)
+ {
+ $this->navigationItems[$type] = array(
+ 'label' => $label,
+ 'config' => $config
+ );
+
+ return $this;
+ }
+
+ /**
+ * Register module namespaces on our class loader
+ *
+ * @return $this
+ */
+ protected function registerAutoloader()
+ {
+ if ($this->registeredAutoloader) {
+ return $this;
+ }
+
+ $moduleName = ucfirst($this->getName());
+
+ $this->app->getLoader()->registerNamespace(
+ 'Icinga\\Module\\' . $moduleName,
+ $this->getLibDir() . '/'. $moduleName,
+ $this->getApplicationDir()
+ );
+
+ $this->registeredAutoloader = true;
+
+ return $this;
+ }
+
+ /**
+ * Bind text domain for i18n
+ *
+ * @return $this
+ */
+ protected function registerLocales()
+ {
+ if ($this->hasLocales() && StaticTranslator::$instance instanceof GettextTranslator) {
+ StaticTranslator::$instance->addTranslationDirectory($this->localedir, $this->name);
+ }
+
+ return $this;
+ }
+
+ /**
+ * Get whether the module has translations
+ */
+ public function hasLocales()
+ {
+ return file_exists($this->localedir) && is_dir($this->localedir);
+ }
+
+ /**
+ * List all available locales
+ *
+ * @return array Locale list
+ */
+ public function listLocales()
+ {
+ $locales = array();
+ if (! $this->hasLocales()) {
+ return $locales;
+ }
+
+ $dh = opendir($this->localedir);
+ while (false !== ($file = readdir($dh))) {
+ $filename = $this->localedir . DIRECTORY_SEPARATOR . $file;
+ if (preg_match('/^[a-z]{2}_[A-Z]{2}$/', $file) && is_dir($filename)) {
+ $locales[] = $file;
+ }
+ }
+ closedir($dh);
+ sort($locales);
+ return $locales;
+ }
+
+ /**
+ * Register web integration
+ *
+ * Add controller directory to mvc
+ *
+ * @return $this
+ */
+ protected function registerWebIntegration()
+ {
+ if (! $this->app->isWeb()) {
+ return $this;
+ }
+
+ return $this
+ ->registerLocales()
+ ->registerRoutes();
+ }
+
+ /**
+ * Add routes for static content and any route added via {@link addRoute()} to the route chain
+ *
+ * @return $this
+ */
+ protected function registerRoutes()
+ {
+ $router = $this->app->getFrontController()->getRouter();
+
+ // TODO: We should not be required to do this. Please check dispatch()
+ $this->app->getFrontController()->addControllerDirectory(
+ $this->getControllerDir(),
+ $this->getName()
+ );
+
+ /** @var \Zend_Controller_Router_Rewrite $router */
+ foreach ($this->routes as $name => $route) {
+ $router->addRoute($name, $route);
+ }
+ $router->addRoute(
+ $this->name . '_jsprovider',
+ new Zend_Controller_Router_Route(
+ 'js/' . $this->name . '/:file',
+ array(
+ 'action' => 'javascript',
+ 'controller' => 'static',
+ 'module' => 'default',
+ 'module_name' => $this->name
+ )
+ )
+ );
+ $router->addRoute(
+ $this->name . '_img',
+ new Zend_Controller_Router_Route_Regex(
+ 'img/' . $this->name . '/(.+)',
+ array(
+ 'action' => 'img',
+ 'controller' => 'static',
+ 'module' => 'default',
+ 'module_name' => $this->name
+ ),
+ array(
+ 1 => 'file'
+ )
+ )
+ );
+ return $this;
+ }
+
+ /**
+ * Run module bootstrap script
+ *
+ * @return $this
+ */
+ protected function launchRunScript()
+ {
+ return $this->includeScript($this->runScript);
+ }
+
+ /**
+ * Include a php script if it is readable
+ *
+ * @param string $file File to include
+ *
+ * @return $this
+ */
+ protected function includeScript($file)
+ {
+ if (file_exists($file) && is_readable($file)) {
+ include $file;
+ }
+
+ return $this;
+ }
+
+ /**
+ * Run module config script
+ *
+ * @return $this
+ */
+ protected function launchConfigScript()
+ {
+ if ($this->triedToLaunchConfigScript) {
+ return $this;
+ }
+ $this->triedToLaunchConfigScript = true;
+ $this->registerAutoloader();
+ return $this->includeScript($this->configScript);
+ }
+
+ protected function slashesToNamespace($class)
+ {
+ $list = explode('/', $class);
+ foreach ($list as &$part) {
+ $part = ucfirst($part);
+ }
+
+ return implode('\\', $list);
+ }
+
+ /**
+ * Provide a hook implementation
+ *
+ * @param string $name Name of the hook for which to provide an implementation
+ * @param string $implementation Fully qualified name of the class providing the hook implementation.
+ * Defaults to the module's ProvidedHook namespace plus the hook's name for the
+ * class name
+ * @param bool $alwaysRun To run the hook always (e.g. without permission check)
+ *
+ * @return $this
+ */
+ protected function provideHook($name, $implementation = null, $alwaysRun = false)
+ {
+ if ($implementation === null) {
+ $implementation = $name;
+ }
+
+ if (strpos($implementation, '\\') === false) {
+ $class = $this->getNamespace()
+ . '\\ProvidedHook\\'
+ . $this->slashesToNamespace($implementation);
+ } else {
+ $class = $implementation;
+ }
+
+ Hook::register($name, $class, $class, $alwaysRun);
+ return $this;
+ }
+
+ /**
+ * Add a route which will be added to the route chain
+ *
+ * @param string $name Name of the route
+ * @param Zend_Controller_Router_Route_Abstract $route Instance of the route
+ *
+ * @return $this
+ * @see registerRoutes()
+ */
+ protected function addRoute($name, Zend_Controller_Router_Route_Abstract $route)
+ {
+ $this->routes[$name] = $route;
+ return $this;
+ }
+}
diff --git a/library/Icinga/Application/Modules/NavigationItemContainer.php b/library/Icinga/Application/Modules/NavigationItemContainer.php
new file mode 100644
index 0000000..c906ccb
--- /dev/null
+++ b/library/Icinga/Application/Modules/NavigationItemContainer.php
@@ -0,0 +1,117 @@
+<?php
+/* Icinga Web 2 | (c) 2015 Icinga Development Team | GPLv2+ */
+
+namespace Icinga\Application\Modules;
+
+use Icinga\Exception\ProgrammingError;
+
+/**
+ * Container for module navigation items
+ */
+abstract class NavigationItemContainer
+{
+ /**
+ * This navigation item's name
+ *
+ * @var string
+ */
+ protected $name;
+
+ /**
+ * This navigation item's properties
+ *
+ * @var array
+ */
+ protected $properties;
+
+ /**
+ * Create a new NavigationItemContainer
+ *
+ * @param string $name
+ * @param array $properties
+ */
+ public function __construct($name, array $properties = array())
+ {
+ $this->name = $name;
+ $this->properties = $properties;
+ }
+
+ /**
+ * Set this menu item's name
+ *
+ * @param string $name
+ *
+ * @return $this
+ */
+ public function setName($name)
+ {
+ $this->name = $name;
+ return $this;
+ }
+
+ /**
+ * Return this menu item's name
+ *
+ * @return string
+ */
+ public function getName()
+ {
+ return $this->name;
+ }
+
+ /**
+ * Set this menu item's properties
+ *
+ * @param array $properties
+ *
+ * @return $this
+ */
+ public function setProperties(array $properties)
+ {
+ $this->properties = $properties;
+ return $this;
+ }
+
+ /**
+ * Return this menu item's properties
+ *
+ * @return array
+ */
+ public function getProperties()
+ {
+ return $this->properties ?: array();
+ }
+
+ /**
+ * Allow dynamic setters and getters for properties
+ *
+ * @param string $name
+ * @param array $arguments
+ *
+ * @return mixed
+ *
+ * @throws ProgrammingError In case the called method is not supported
+ */
+ public function __call($name, $arguments)
+ {
+ if (method_exists($this, $name)) {
+ return call_user_func(array($this, $name), $this, $arguments);
+ }
+
+ $type = substr($name, 0, 3);
+ if ($type !== 'set' && $type !== 'get') {
+ throw new ProgrammingError(
+ 'Dynamic method %s is not supported. Only getters (get*) and setters (set*) are.',
+ $name
+ );
+ }
+
+ $propertyName = strtolower(join('_', preg_split('~(?=[A-Z])~', lcfirst(substr($name, 3)))));
+ if ($type === 'set') {
+ $this->properties[$propertyName] = $arguments[0];
+ return $this;
+ } else { // $type === 'get'
+ return array_key_exists($propertyName, $this->properties) ? $this->properties[$propertyName] : null;
+ }
+ }
+}
diff --git a/library/Icinga/Application/Platform.php b/library/Icinga/Application/Platform.php
new file mode 100644
index 0000000..185a69e
--- /dev/null
+++ b/library/Icinga/Application/Platform.php
@@ -0,0 +1,435 @@
+<?php
+/* Icinga Web 2 | (c) 2013 Icinga Development Team | GPLv2+ */
+
+namespace Icinga\Application;
+
+/**
+ * Platform tests for icingaweb
+ */
+class Platform
+{
+ /**
+ * Domain name
+ *
+ * @var string
+ */
+ protected static $domain;
+
+ /**
+ * Host name
+ *
+ * @var string
+ */
+ protected static $hostname;
+
+ /**
+ * Fully qualified domain name
+ *
+ * @var string
+ */
+ protected static $fqdn;
+
+ /**
+ * Return the operating system's name
+ *
+ * @return string
+ */
+ public static function getOperatingSystemName()
+ {
+ return php_uname('s');
+ }
+
+ /**
+ * Test of windows
+ *
+ * @return bool
+ */
+ public static function isWindows()
+ {
+ return strtoupper(substr(self::getOperatingSystemName(), 0, 3)) === 'WIN';
+ }
+
+ /**
+ * Test of linux
+ *
+ * @return bool
+ */
+ public static function isLinux()
+ {
+ return strtoupper(substr(self::getOperatingSystemName(), 0, 5)) === 'LINUX';
+ }
+
+ /**
+ * Return the Linux distribution's name
+ * or 'linux' if the name could not be found out
+ * or false if the OS isn't Linux or an error occurred
+ *
+ * @param int $reliable
+ * 3: Only parse /etc/os-release (or /usr/lib/os-release).
+ * For the paranoid ones.
+ * 2: If that (3) doesn't help, check /etc/*-release, too.
+ * If something is unclear, return 'linux'.
+ * 1: Almost equal to mode 2. The possible return values also include:
+ * 'redhat' -- unclear whether RHEL/Fedora/...
+ * 'suse' -- unclear whether SLES/openSUSE/...
+ * 0: If even that (1) doesn't help, check /proc/version, too.
+ * This may not work (as expected) on LXC containers!
+ * (No reliability at all!)
+ *
+ * @return string|bool
+ */
+ public static function getLinuxDistro($reliable = 2)
+ {
+ if (! self::isLinux()) {
+ return false;
+ }
+
+ foreach (array('/etc/os-release', '/usr/lib/os-release') as $osReleaseFile) {
+ if (false === ($osRelease = @file(
+ $osReleaseFile,
+ FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES
+ ))) {
+ continue;
+ }
+
+ foreach ($osRelease as $osInfo) {
+ if (false === ($res = @preg_match('/(?<!.)[ \t]*#/ms', $osInfo))) {
+ return false;
+ }
+ if ($res === 1) {
+ continue;
+ }
+
+ $matches = array();
+ if (false === ($res = @preg_match(
+ '/(?<!.)[ \t]*ID[ \t]*=[ \t]*(\'|"|)(.*?)(?:\1)[ \t]*(?!.)/msi',
+ $osInfo,
+ $matches
+ ))) {
+ return false;
+ }
+ if (! ($res === 0 || $matches[2] === '' || $matches[2] === 'linux')) {
+ return $matches[2];
+ }
+ }
+ }
+
+ if ($reliable > 2) {
+ return 'linux';
+ }
+
+ foreach (array(
+ 'fedora' => '/etc/fedora-release',
+ 'centos' => '/etc/centos-release'
+ ) as $distro => $releaseFile) {
+ if (! (false === (
+ $release = @file_get_contents($releaseFile)
+ ) || false === strpos(strtolower($release), $distro))) {
+ return $distro;
+ }
+ }
+
+ if (false !== ($release = @file_get_contents('/etc/redhat-release'))) {
+ $release = strtolower($release);
+ if (false !== strpos($release, 'red hat enterprise linux')) {
+ return 'rhel';
+ }
+ foreach (array('fedora', 'centos') as $distro) {
+ if (false !== strpos($release, $distro)) {
+ return $distro;
+ }
+ }
+ return $reliable < 2 ? 'redhat' : 'linux';
+ }
+
+ if (false !== ($release = @file_get_contents('/etc/SuSE-release'))) {
+ $release = strtolower($release);
+ foreach (array(
+ 'opensuse' => 'opensuse',
+ 'sles' => 'suse linux enterprise server',
+ 'sled' => 'suse linux enterprise desktop'
+ ) as $distro => $name) {
+ if (false !== strpos($release, $name)) {
+ return $distro;
+ }
+ }
+ return $reliable < 2 ? 'suse' : 'linux';
+ }
+
+ if ($reliable < 1) {
+ if (false === ($procVersion = @file_get_contents('/proc/version'))) {
+ return false;
+ }
+ $procVersion = strtolower($procVersion);
+ foreach (array(
+ 'redhat' => 'red hat',
+ 'suse' => 'suse linux',
+ 'ubuntu' => 'ubuntu',
+ 'debian' => 'debian'
+ ) as $distro => $name) {
+ if (false !== strpos($procVersion, $name)) {
+ return $distro;
+ }
+ }
+ }
+
+ return 'linux';
+ }
+
+ /**
+ * Test of CLI environment
+ *
+ * @return bool
+ */
+ public static function isCli()
+ {
+ if (PHP_SAPI == 'cli') {
+ return true;
+ } elseif ((PHP_SAPI == 'cgi' || PHP_SAPI == 'cgi-fcgi')
+ && empty($_SERVER['SERVER_NAME'])) {
+ return true;
+ }
+ return false;
+ }
+
+ /**
+ * Get the hostname
+ *
+ * @return string
+ */
+ public static function getHostname()
+ {
+ if (self::$hostname === null) {
+ self::discoverHostname();
+ }
+ return self::$hostname;
+ }
+
+ /**
+ * Get the domain name
+ *
+ * @return string
+ */
+ public static function getDomain()
+ {
+ if (self::$domain === null) {
+ self::discoverHostname();
+ }
+ return self::$domain;
+ }
+
+ /**
+ * Get the fully qualified domain name
+ *
+ * @return string
+ */
+ public static function getFqdn()
+ {
+ if (self::$fqdn === null) {
+ self::discoverHostname();
+ }
+ return self::$fqdn;
+ }
+
+ /**
+ * Initialize domain and host strings
+ */
+ protected static function discoverHostname()
+ {
+ self::$hostname = gethostname();
+ self::$fqdn = gethostbyaddr(gethostbyname(self::$hostname));
+
+ if (substr(self::$fqdn, 0, strlen(self::$hostname)) === self::$hostname) {
+ self::$domain = substr(self::$fqdn, strlen(self::$hostname) + 1);
+ } else {
+ $parts = preg_split('~\.~', self::$hostname, 2);
+ self::$domain = array_shift($parts);
+ }
+ }
+
+ /**
+ * Return the version of PHP
+ *
+ * @return string
+ */
+ public static function getPhpVersion()
+ {
+ return phpversion();
+ }
+
+ /**
+ * Return the username PHP is running as
+ *
+ * @return ?string
+ */
+ public static function getPhpUser()
+ {
+ if (static::isWindows()) {
+ return get_current_user(); // http://php.net/manual/en/function.get-current-user.php#75059
+ }
+
+ if (function_exists('posix_geteuid')) {
+ $userInfo = posix_getpwuid(posix_geteuid());
+ return $userInfo['name'];
+ }
+ }
+
+ /**
+ * Test for php extension
+ *
+ * @param string $extensionName E.g. mysql, ldap
+ *
+ * @return bool
+ */
+ public static function extensionLoaded($extensionName)
+ {
+ return extension_loaded($extensionName);
+ }
+
+ /**
+ * Return the value for the given PHP configuration option
+ *
+ * @param string $option The option name for which to return the value
+ *
+ * @return string|false
+ */
+ public static function getPhpConfig($option)
+ {
+ return ini_get($option);
+ }
+
+ /**
+ * Return whether the given class exists
+ *
+ * @param string $name The name of the class to check
+ *
+ * @return bool
+ */
+ public static function classExists($name)
+ {
+ if (@class_exists($name)) {
+ return true;
+ }
+
+ if (strpos($name, '_') !== false) {
+ // Assume it's a Zend-Framework class
+ return (@include str_replace('_', '/', $name) . '.php') !== false;
+ }
+
+ return false;
+ }
+
+ /**
+ * Return whether it's possible to connect to a LDAP server
+ *
+ * Checks whether the ldap extension is loaded
+ *
+ * @return bool
+ */
+ public static function hasLdapSupport()
+ {
+ return static::extensionLoaded('ldap');
+ }
+
+ /**
+ * Return whether it's possible to connect to any of the supported database servers
+ *
+ * @return bool
+ */
+ public static function hasDatabaseSupport()
+ {
+ return static::hasMssqlSupport() || static::hasMysqlSupport() || static::hasOciSupport()
+ || static::hasOracleSupport() || static::hasPostgresqlSupport();
+ }
+
+ /**
+ * Return whether it's possible to connect to a MSSQL database
+ *
+ * Checks whether the mssql/dblib pdo or sqlsrv extension has
+ * been loaded and Zend framework adapter for MSSQL is available
+ *
+ * @return bool
+ */
+ public static function hasMssqlSupport()
+ {
+ if ((static::extensionLoaded('mssql') || static::extensionLoaded('pdo_dblib'))
+ && static::classExists('Zend_Db_Adapter_Pdo_Mssql')
+ ) {
+ return true;
+ }
+
+ return static::extensionLoaded('sqlsrv') && static::classExists('Zend_Db_Adapter_Sqlsrv');
+ }
+
+ /**
+ * Return whether it's possible to connect to a MySQL database
+ *
+ * Checks whether the mysql pdo extension has been loaded and the Zend framework adapter for MySQL is available
+ *
+ * @return bool
+ */
+ public static function hasMysqlSupport()
+ {
+ return static::extensionLoaded('pdo_mysql') && static::classExists('Zend_Db_Adapter_Pdo_Mysql');
+ }
+
+ /**
+ * Return whether it's possible to connect to a IBM DB2 database
+ *
+ * Checks whether the ibm pdo extension has been loaded and the Zend framework adapter for IBM is available
+ *
+ * @return bool
+ */
+ public static function hasIbmSupport()
+ {
+ return static::extensionLoaded('pdo_ibm') && static::classExists('Zend_Db_Adapter_Pdo_Ibm');
+ }
+
+ /**
+ * Return whether it's possible to connect to a Oracle database using OCI8
+ *
+ * Checks whether the OCI8 extension has been loaded and the Zend framework adapter for Oracle is available
+ *
+ * @return bool
+ */
+ public static function hasOciSupport()
+ {
+ return static::extensionLoaded('oci8') && static::classExists('Zend_Db_Adapter_Oracle');
+ }
+
+ /**
+ * Return whether it's possible to connect to a Oracle database using PDO_OCI
+ *
+ * Checks whether the OCI PDO extension has been loaded and the Zend framework adapter for Oci is available
+ *
+ * @return bool
+ */
+ public static function hasOracleSupport()
+ {
+ return static::extensionLoaded('pdo_oci') && static::classExists('Zend_Db_Adapter_Pdo_Oci');
+ }
+
+ /**
+ * Return whether it's possible to connect to a PostgreSQL database
+ *
+ * Checks whether the pgsql pdo extension has been loaded and the Zend framework adapter for PostgreSQL is available
+ *
+ * @return bool
+ */
+ public static function hasPostgresqlSupport()
+ {
+ return static::extensionLoaded('pdo_pgsql') && static::classExists('Zend_Db_Adapter_Pdo_Pgsql');
+ }
+
+ /**
+ * Return whether it's possible to connect to a SQLite database
+ *
+ * Checks whether the sqlite pdo extension has been loaded and the Zend framework adapter for SQLite is available
+ *
+ * @return bool
+ */
+ public static function hasSqliteSupport()
+ {
+ return static::extensionLoaded('pdo_sqlite') && static::classExists('Zend_Db_Adapter_Pdo_Sqlite');
+ }
+}
diff --git a/library/Icinga/Application/ProvidedHook/DbMigration.php b/library/Icinga/Application/ProvidedHook/DbMigration.php
new file mode 100644
index 0000000..899dbf6
--- /dev/null
+++ b/library/Icinga/Application/ProvidedHook/DbMigration.php
@@ -0,0 +1,83 @@
+<?php
+
+/* Icinga Web 2 | (c) 2023 Icinga GmbH | GPLv2+ */
+
+namespace Icinga\Application\ProvidedHook;
+
+use Icinga\Application\Hook\DbMigrationHook;
+use Icinga\Common\Database;
+use Icinga\Model\Schema;
+use ipl\Orm\Query;
+use ipl\Sql\Connection;
+
+class DbMigration extends DbMigrationHook
+{
+ use Database {
+ getDb as private getWebDb;
+ }
+
+ public function getDb(): Connection
+ {
+ return $this->getWebDb();
+ }
+
+ public function getName(): string
+ {
+ return $this->translate('Icinga Web');
+ }
+
+ public function providedDescriptions(): array
+ {
+ return [];
+ }
+
+ public function getVersion(): string
+ {
+ if ($this->version === null) {
+ $conn = $this->getDb();
+ $schemaQuery = $this->getSchemaQuery()
+ ->orderBy('id', SORT_DESC)
+ ->limit(2);
+
+ if (static::getColumnType($conn, $schemaQuery->getModel()->getTableName(), 'success')) {
+ /** @var Schema $schema */
+ foreach ($schemaQuery as $schema) {
+ if ($schema->success) {
+ $this->version = $schema->version;
+
+ break;
+ }
+ }
+
+ if (! $this->version) {
+ $this->version = '2.12.0';
+ }
+ } elseif (static::tableExists($conn, $schemaQuery->getModel()->getTableName())
+ || static::getColumnCollation($conn, 'icingaweb_user_preference', 'username') === 'utf8mb4_unicode_ci'
+ ) {
+ $this->version = '2.11.0';
+ } elseif (static::tableExists($conn, 'icingaweb_rememberme')) {
+ $randomIvType = static::getColumnType($conn, 'icingaweb_rememberme', 'random_iv');
+ if ($randomIvType === 'varchar(32)') {
+ $this->version = '2.9.1';
+ } else {
+ $this->version = '2.9.0';
+ }
+ } else {
+ $usernameType = static::getColumnType($conn, 'icingaweb_group_membership', 'username');
+ if ($usernameType === 'varchar(254)') {
+ $this->version = '2.5.0';
+ } else {
+ $this->version = '2.0.0';
+ }
+ }
+ }
+
+ return $this->version;
+ }
+
+ protected function getSchemaQuery(): Query
+ {
+ return Schema::on($this->getDb());
+ }
+}
diff --git a/library/Icinga/Application/StaticWeb.php b/library/Icinga/Application/StaticWeb.php
new file mode 100644
index 0000000..5c64dcb
--- /dev/null
+++ b/library/Icinga/Application/StaticWeb.php
@@ -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();
+ }
+}
diff --git a/library/Icinga/Application/Test.php b/library/Icinga/Application/Test.php
new file mode 100644
index 0000000..74321ea
--- /dev/null
+++ b/library/Icinga/Application/Test.php
@@ -0,0 +1,140 @@
+<?php
+
+namespace Icinga\Application;
+
+use Icinga\Web\Request;
+use Icinga\Web\Response;
+
+require_once __DIR__ . '/Cli.php';
+
+class Test extends Cli
+{
+ protected $isCli = false;
+
+ /** @var Request */
+ private $request;
+
+ /** @var Response */
+ private $response;
+
+ public function setRequest(Request $request): void
+ {
+ $this->request = $request;
+ }
+
+ public function getRequest(): Request
+ {
+ assert(isset($this->request), 'BaseTestCase should have set the request');
+
+ return $this->request;
+ }
+
+ public function setResponse(Response $response): void
+ {
+ $this->response = $response;
+ }
+
+ public function getResponse(): Response
+ {
+ assert(isset($this->request), 'BaseTestCase should have set the response');
+
+ return $this->response;
+ }
+
+ public function getFrontController()
+ {
+ return $this; // Callers are expected to only call getRequest or getResponse, hence the app should suffice
+ }
+
+ protected function bootstrap()
+ {
+ $this->assertRunningOnCli();
+ $this->setupLogging()
+ ->setupErrorHandling()
+ ->loadLibraries()
+ ->setupComposerAutoload()
+ ->loadConfig()
+ ->setupModuleAutoloaders()
+ ->setupTimezone()
+ ->prepareInternationalization()
+ ->setupInternationalization()
+ ->parseBasicParams()
+ ->setupLogger()
+ ->setupModuleManager()
+ ->setupUserBackendFactory()
+ ->setupFakeAuthentication();
+ }
+
+ public function setupAutoloader()
+ {
+ parent::setupAutoloader();
+
+ if (($icingaLibDir = getenv('ICINGAWEB_ICINGA_LIB')) !== false) {
+ $this->getLoader()->registerNamespace('Icinga', $icingaLibDir);
+ }
+
+ // Conflicts with `Tests\Icinga\Module\...\Lib`. But it seems it's not needed anyway...
+ //$this->getLoader()->registerNamespace('Tests', $this->getBaseDir('test/php/library'));
+ $this->getLoader()->registerNamespace('Tests\\Icinga\\Lib', $this->getBaseDir('test/php/Lib'));
+
+ return $this;
+ }
+
+ protected function detectTimezone()
+ {
+ return 'UTC';
+ }
+
+ private function setupModuleAutoloaders(): self
+ {
+ $modulePaths = getenv('ICINGAWEB_MODULE_DIRS');
+
+ if ($modulePaths) {
+ $modulePaths = preg_split('/:/', $modulePaths, -1, PREG_SPLIT_NO_EMPTY);
+ }
+
+ if (! $modulePaths) {
+ $modulePaths = [];
+ foreach ($this->getAvailableModulePaths() as $path) {
+ $candidates = array_flip(scandir($path));
+ unset($candidates['.'], $candidates['..']);
+ foreach ($candidates as $candidate => $_) {
+ $modulePaths[] = "$path/$candidate";
+ }
+ }
+ }
+
+ foreach ($modulePaths as $path) {
+ $module = basename($path);
+
+ $moduleNamespace = 'Icinga\\Module\\' . ucfirst($module);
+ $moduleLibraryPath = "$path/library/" . ucfirst($module);
+
+ if (is_dir($moduleLibraryPath)) {
+ $this->getLoader()->registerNamespace($moduleNamespace, $moduleLibraryPath, "$path/application");
+ }
+
+ $moduleTestPath = "$path/test/php/Lib";
+ if (is_dir($moduleTestPath)) {
+ $this->getLoader()->registerNamespace('Tests\\' . $moduleNamespace . '\\Lib', $moduleTestPath);
+ }
+
+ $composerAutoloader = "$path/vendor/autoload.php";
+ if (file_exists($composerAutoloader)) {
+ require_once $composerAutoloader;
+ }
+ }
+
+ return $this;
+ }
+
+ private function setupComposerAutoload(): self
+ {
+ $vendorAutoload = $this->getBaseDir('/vendor/autoload.php');
+ if (file_exists($vendorAutoload)) {
+ require_once $vendorAutoload;
+ }
+
+ return $this;
+ }
+}
diff --git a/library/Icinga/Application/Version.php b/library/Icinga/Application/Version.php
new file mode 100644
index 0000000..be804f1
--- /dev/null
+++ b/library/Icinga/Application/Version.php
@@ -0,0 +1,65 @@
+<?php
+/* Icinga Web 2 | (c) 2015 Icinga Development Team | GPLv2+ */
+
+namespace Icinga\Application;
+
+/**
+ * Retrieve the version of Icinga Web 2
+ */
+class Version
+{
+ const VERSION = '2.12.1';
+
+ /**
+ * Get the version of this instance of Icinga Web 2
+ *
+ * @return array
+ */
+ public static function get()
+ {
+ $version = array('appVersion' => self::VERSION);
+ preg_match('/2.(\d+)\./', self::VERSION, $matches);
+ $version['docVersion'] = isset($matches[1]) ? '2.' . $matches[1] : null;
+
+ if (false !== ($appVersion = @file_get_contents(Icinga::app()->getApplicationDir('VERSION')))) {
+ $matches = array();
+ if (@preg_match('/^(?P<gitCommitID>\w+) (?P<gitCommitDate>\S+)/', $appVersion, $matches)) {
+ return array_merge($version, $matches);
+ }
+ }
+
+ $gitCommitId = static::getGitHead(Icinga::app()->getBaseDir());
+ if ($gitCommitId !== false) {
+ $version['gitCommitID'] = $gitCommitId;
+ }
+
+ return $version;
+ }
+
+ /**
+ * Get the current commit of the Git repository in the given path
+ *
+ * @param string $repo Path to the Git repository
+ * @param bool $bare Whether the Git repository is bare
+ *
+ * @return string|bool False if not available
+ */
+ public static function getGitHead($repo, $bare = false)
+ {
+ if (! $bare) {
+ $repo .= '/.git';
+ }
+
+ $head = @file_get_contents($repo . '/HEAD');
+
+ if ($head !== false) {
+ if (preg_match('/^ref: (.+)/', $head, $matches)) {
+ return @file_get_contents($repo . '/' . $matches[1]);
+ }
+
+ return $head;
+ }
+
+ return false;
+ }
+}
diff --git a/library/Icinga/Application/Web.php b/library/Icinga/Application/Web.php
new file mode 100644
index 0000000..934af07
--- /dev/null
+++ b/library/Icinga/Application/Web.php
@@ -0,0 +1,509 @@
+<?php
+/* Icinga Web 2 | (c) 2013 Icinga Development Team | GPLv2+ */
+
+namespace Icinga\Application;
+
+require_once __DIR__ . '/EmbeddedWeb.php';
+
+use ErrorException;
+use ipl\I18n\GettextTranslator;
+use ipl\I18n\Locale;
+use ipl\I18n\StaticTranslator;
+use Zend_Controller_Action_HelperBroker;
+use Zend_Controller_Front;
+use Zend_Controller_Router_Route;
+use Zend_Layout;
+use Zend_Paginator;
+use Zend_View_Helper_PaginationControl;
+use Icinga\Authentication\Auth;
+use Icinga\User;
+use Icinga\Util\DirectoryIterator;
+use Icinga\Util\TimezoneDetect;
+use Icinga\Web\Controller\Dispatcher;
+use Icinga\Web\Navigation\Navigation;
+use Icinga\Web\Notification;
+use Icinga\Web\Session;
+use Icinga\Web\Session\Session as BaseSession;
+use Icinga\Web\StyleSheet;
+use Icinga\Web\View;
+
+/**
+ * Use this if you want to make use of Icinga functionality in other web projects
+ *
+ * Usage example:
+ * <code>
+ * use Icinga\Application\Web;
+ * Web::start();
+ * </code>
+ */
+class Web extends EmbeddedWeb
+{
+ /**
+ * View object
+ *
+ * @var View
+ */
+ private $viewRenderer;
+
+ /**
+ * Zend front controller instance
+ *
+ * @var Zend_Controller_Front
+ */
+ private $frontController;
+
+ /**
+ * Session object
+ *
+ * @var BaseSession
+ */
+ private $session;
+
+ /**
+ * User object
+ *
+ * @var User
+ */
+ private $user;
+
+ /** @var array */
+ protected $accessibleMenuItems;
+
+ /**
+ * Identify web bootstrap
+ *
+ * @var bool
+ */
+ protected $isWeb = true;
+
+ /**
+ * Initialize all together
+ *
+ * @return $this
+ */
+ protected function bootstrap()
+ {
+ return $this
+ ->setupLogging()
+ ->setupErrorHandling()
+ ->loadLibraries()
+ ->loadConfig()
+ ->setupLogger()
+ ->setupRequest()
+ ->setupSession()
+ ->setupNotifications()
+ ->setupResponse()
+ ->setupZendMvc()
+ ->prepareInternationalization()
+ ->setupModuleManager()
+ ->loadSetupModuleIfNecessary()
+ ->loadEnabledModules()
+ ->setupRoute()
+ ->setupPagination()
+ ->setupUserBackendFactory()
+ ->setupUser()
+ ->setupTimezone()
+ ->setupInternationalization()
+ ->setupFatalErrorHandling()
+ ->registerApplicationHooks();
+ }
+
+ /**
+ * Get themes provided by Web 2 and all enabled modules
+ *
+ * @return string[] Array of theme names as keys and values
+ */
+ public function getThemes()
+ {
+ $themes = array(StyleSheet::DEFAULT_THEME);
+ $applicationThemePath = $this->getBaseDir('public/css/themes');
+ if (DirectoryIterator::isReadable($applicationThemePath)) {
+ foreach (new DirectoryIterator($applicationThemePath, 'less') as $name => $theme) {
+ $themes[] = substr($name, 0, -5);
+ }
+ }
+ $mm = $this->getModuleManager();
+ foreach ($mm->listEnabledModules() as $moduleName) {
+ $moduleThemePath = $mm->getModule($moduleName)->getCssDir() . '/themes';
+ if (! DirectoryIterator::isReadable($moduleThemePath)) {
+ continue;
+ }
+ foreach (new DirectoryIterator($moduleThemePath, 'less') as $name => $theme) {
+ $themes[] = $moduleName . '/' . substr($name, 0, -5);
+ }
+ }
+ return array_combine($themes, $themes);
+ }
+
+ /**
+ * Prepare routing
+ *
+ * @return $this
+ */
+ private function setupRoute()
+ {
+ $this->frontController->getRouter()->addRoute(
+ 'module_javascript',
+ new Zend_Controller_Router_Route(
+ 'js/components/:module_name/:file',
+ array(
+ 'controller' => 'static',
+ 'action' => 'javascript'
+ )
+ )
+ );
+
+ return $this;
+ }
+
+ /**
+ * Getter for frontController
+ *
+ * @return Zend_Controller_Front
+ */
+ public function getFrontController()
+ {
+ return $this->frontController;
+ }
+
+ /**
+ * Getter for view
+ *
+ * @return View
+ */
+ public function getViewRenderer()
+ {
+ return $this->viewRenderer;
+ }
+
+ private function hasAccessToSharedNavigationItem(&$config, Config $navConfig)
+ {
+ // TODO: Provide a more sophisticated solution
+
+ if (isset($config['owner']) && strtolower($config['owner']) === strtolower($this->user->getUsername())) {
+ unset($config['owner']);
+ unset($config['users']);
+ unset($config['groups']);
+ return true;
+ }
+
+ if (isset($config['parent']) && $navConfig->hasSection($config['parent'])) {
+ unset($config['owner']);
+ if (isset($this->accessibleMenuItems[$config['parent']])) {
+ return $this->accessibleMenuItems[$config['parent']];
+ }
+
+ $parentConfig = $navConfig->getSection($config['parent']);
+ $this->accessibleMenuItems[$config['parent']] = $this->hasAccessToSharedNavigationItem(
+ $parentConfig,
+ $navConfig
+ );
+ return $this->accessibleMenuItems[$config['parent']];
+ }
+
+ if (isset($config['users'])) {
+ $users = array_map('trim', explode(',', strtolower($config['users'])));
+ if (in_array('*', $users, true) || in_array(strtolower($this->user->getUsername()), $users, true)) {
+ unset($config['owner']);
+ unset($config['users']);
+ unset($config['groups']);
+ return true;
+ }
+ }
+
+ if (isset($config['groups'])) {
+ $groups = array_map('trim', explode(',', strtolower($config['groups'])));
+ if (in_array('*', $groups, true)) {
+ unset($config['owner']);
+ unset($config['users']);
+ unset($config['groups']);
+ return true;
+ }
+
+ $userGroups = array_map('strtolower', $this->user->getGroups());
+ $matches = array_intersect($userGroups, $groups);
+ if (! empty($matches)) {
+ unset($config['owner']);
+ unset($config['users']);
+ unset($config['groups']);
+ return true;
+ }
+ }
+
+ return false;
+ }
+
+ /**
+ * Load and return the shared navigation of the given type
+ *
+ * @param string $type
+ *
+ * @return Navigation
+ */
+ public function getSharedNavigation($type)
+ {
+ $config = Config::navigation($type === 'dashboard-pane' ? 'dashlet' : $type);
+
+ if ($type === 'dashboard-pane') {
+ $panes = array();
+ foreach ($config as $dashletName => $dashletConfig) {
+ if ($this->hasAccessToSharedNavigationItem($dashletConfig, $config)) {
+ // TODO: Throw ConfigurationError if pane or url is missing
+ $panes[$dashletConfig->pane][$dashletName] = $dashletConfig->url;
+ }
+ }
+
+ $navigation = new Navigation();
+ foreach ($panes as $paneName => $dashlets) {
+ $navigation->addItem(
+ $paneName,
+ array(
+ 'type' => 'dashboard-pane',
+ 'dashlets' => $dashlets
+ )
+ );
+ }
+ } else {
+ $items = array();
+ foreach ($config as $name => $typeConfig) {
+ if (isset($this->accessibleMenuItems[$name])) {
+ if ($this->accessibleMenuItems[$name]) {
+ $items[$name] = $typeConfig;
+ }
+ } else {
+ if ($this->hasAccessToSharedNavigationItem($typeConfig, $config)) {
+ $this->accessibleMenuItems[$name] = true;
+ $items[$name] = $typeConfig;
+ } else {
+ $this->accessibleMenuItems[$name] = false;
+ }
+ }
+ }
+
+ $navigation = Navigation::fromConfig($items);
+ }
+
+ return $navigation;
+ }
+
+ /**
+ * Dispatch public interface
+ */
+ public function dispatch()
+ {
+ $this->frontController->dispatch($this->getRequest(), $this->getResponse());
+ }
+
+ /**
+ * Prepare Zend MVC Base
+ *
+ * @return $this
+ */
+ private function setupZendMvc()
+ {
+ Zend_Layout::startMvc(
+ array(
+ 'layout' => 'layout',
+ 'layoutPath' => $this->getApplicationDir('/layouts/scripts')
+ )
+ );
+
+ $this->setupFrontController();
+ $this->setupViewRenderer();
+ return $this;
+ }
+
+ /**
+ * Create user object
+ *
+ * @return $this
+ */
+ private function setupUser()
+ {
+ $auth = Auth::getInstance();
+ if (! $this->request->isXmlHttpRequest() && $this->request->isApiRequest() && ! $auth->isAuthenticated()) {
+ $auth->authHttp();
+ }
+ if ($auth->isAuthenticated()) {
+ $user = $auth->getUser();
+ $this->getRequest()->setUser($user);
+ $this->user = $user;
+
+ if ($user->can('user/application/stacktraces')) {
+ $displayExceptions = $this->user->getPreferences()->getValue(
+ 'icingaweb',
+ 'show_stacktraces'
+ );
+
+ if ($displayExceptions !== null) {
+ $this->frontController->setParams(
+ array(
+ 'displayExceptions' => $displayExceptions
+ )
+ );
+ }
+ }
+ }
+ return $this;
+ }
+
+ /**
+ * Initialize a session provider
+ *
+ * @return $this
+ */
+ private function setupSession()
+ {
+ $this->session = Session::create();
+ return $this;
+ }
+
+ /**
+ * Initialize notifications to remove them immediately from session
+ *
+ * @return $this
+ */
+ private function setupNotifications()
+ {
+ Notification::getInstance();
+ return $this;
+ }
+
+ /**
+ * Instantiate front controller
+ *
+ * @return $this
+ */
+ private function setupFrontController()
+ {
+ $this->frontController = Zend_Controller_Front::getInstance();
+ $this->frontController->setDispatcher(new Dispatcher());
+ $this->frontController->setRequest($this->getRequest());
+ $this->frontController->setControllerDirectory($this->getApplicationDir('/controllers'));
+
+ $displayExceptions = $this->config->get('global', 'show_stacktraces', true);
+
+ $this->frontController->setParams(
+ array(
+ 'displayExceptions' => $displayExceptions
+ )
+ );
+ return $this;
+ }
+
+ /**
+ * Register helper paths and views for renderer
+ *
+ * @return $this
+ */
+ private function setupViewRenderer()
+ {
+ $view = Zend_Controller_Action_HelperBroker::getStaticHelper('viewRenderer');
+ /** @var \Zend_Controller_Action_Helper_ViewRenderer $view */
+ $view->setView(new View());
+ $view->view->addHelperPath($this->getApplicationDir('/views/helpers'));
+ $view->view->setEncoding('UTF-8');
+ $view->view->headTitle()->prepend($this->config->get('global', 'project', 'Icinga'));
+ $view->view->headTitle()->setSeparator(' :: ');
+ $this->viewRenderer = $view;
+ return $this;
+ }
+
+ /**
+ * Configure pagination settings
+ *
+ * @return $this
+ */
+ private function setupPagination()
+ {
+ // TODO: document what we need for whatever reason?!
+ Zend_Paginator::addScrollingStylePrefixPath(
+ 'Icinga_Web_Paginator_ScrollingStyle_',
+ $this->getLibraryDir('Icinga/Web/Paginator/ScrollingStyle')
+ );
+
+ Zend_Paginator::addScrollingStylePrefixPath(
+ 'Icinga_Web_Paginator_ScrollingStyle',
+ 'Icinga/Web/Paginator/ScrollingStyle'
+ );
+
+ Zend_Paginator::setDefaultScrollingStyle('SlidingWithBorder');
+ Zend_View_Helper_PaginationControl::setDefaultViewPartial(
+ array('mixedPagination.phtml', 'default')
+ );
+ return $this;
+ }
+
+ /**
+ * Fatal error handling configuration
+ *
+ * @return $this
+ */
+ protected function setupFatalErrorHandling()
+ {
+ register_shutdown_function(function () {
+ $error = error_get_last();
+
+ if ($error !== null && $error['type'] === E_ERROR) {
+ $frontController = Icinga::app()->getFrontController();
+ $response = $frontController->getResponse();
+
+ $response->setException(new ErrorException(
+ $error['message'],
+ 0,
+ $error['type'],
+ $error['file'],
+ $error['line']
+ ));
+
+ // Clean PHP's fatal error stack trace and replace it with ours
+ ob_end_clean();
+ $frontController->dispatch($frontController->getRequest(), $response);
+ }
+ });
+
+ return $this;
+ }
+
+ /**
+ * (non-PHPDoc)
+ * @see ApplicationBootstrap::detectTimezone() For the method documentation.
+ */
+ protected function detectTimezone()
+ {
+ $auth = Auth::getInstance();
+ if (! $auth->isAuthenticated()
+ || ($timezone = $auth->getUser()->getPreferences()->getValue('icingaweb', 'timezone')) === null
+ ) {
+ $detect = new TimezoneDetect();
+ $timezone = $detect->getTimezoneName();
+ }
+ return $timezone;
+ }
+
+ /**
+ * Setup internationalization using gettext
+ *
+ * Uses the preferred user language or the browser suggested language or our default.
+ *
+ * @return string Detected locale code
+ */
+ protected function detectLocale()
+ {
+ $auth = Auth::getInstance();
+ if ($auth->isAuthenticated()
+ && ($locale = $auth->getUser()->getPreferences()->getValue('icingaweb', 'language')) !== null
+ ) {
+ return $locale;
+ }
+
+ /** @var GettextTranslator $translator */
+ $translator = StaticTranslator::$instance;
+
+ if (isset($_SERVER['HTTP_ACCEPT_LANGUAGE'])) {
+ return (new Locale())->getPreferred($_SERVER['HTTP_ACCEPT_LANGUAGE'], $translator->listLocales());
+ }
+
+ return $translator->getDefaultLocale();
+ }
+}
diff --git a/library/Icinga/Application/functions.php b/library/Icinga/Application/functions.php
new file mode 100644
index 0000000..12736fb
--- /dev/null
+++ b/library/Icinga/Application/functions.php
@@ -0,0 +1,110 @@
+<?php
+/* Icinga Web 2 | (c) 2013 Icinga Development Team | GPLv2+ */
+
+use ipl\Stdlib\Contract\Translator;
+use ipl\I18n\StaticTranslator;
+
+/**
+ * No-op translate
+ *
+ * Supposed to be used for marking a string as available for translation without actually translating it immediately.
+ * The returned string is the one given in the input. This does only work with the standard gettext macros t() and mt().
+ *
+ * @param string $messageId
+ *
+ * @return string
+ */
+function N_(string $messageId): string
+{
+ return $messageId;
+}
+
+// Workaround for test issues, this is required unless our tests are able to
+// accomplish "real" bootstrapping
+if (function_exists('t')) {
+ return;
+}
+
+if (extension_loaded('gettext')) {
+
+ /**
+ * @see Translator::translate() For the function documentation.
+ */
+ function t(string $messageId, ?string $context = null): string
+ {
+ return StaticTranslator::$instance->translate($messageId, $context);
+ }
+
+ /**
+ * @see Translator::translateInDomain() For the function documentation.
+ */
+ function mt(string $domain, string $messageId, ?string $context = null): string
+ {
+ return StaticTranslator::$instance->translateInDomain($domain, $messageId, $context);
+ }
+
+ /**
+ * @see Translator::translatePlural() For the function documentation.
+ */
+ function tp(string $messageId, string $messageId2, ?int $number, ?string $context = null): string
+ {
+ return StaticTranslator::$instance->translatePlural($messageId, $messageId2, $number ?? 0, $context);
+ }
+
+ /**
+ * @see Translator::translatePluralInDomain() For the function documentation.
+ */
+ function mtp(string $domain, string $messageId, string $messageId2, ?int $number, ?string $context = null): string
+ {
+ return StaticTranslator::$instance->translatePluralInDomain(
+ $domain,
+ $messageId,
+ $messageId2,
+ $number ?? 0,
+ $context
+ );
+ }
+
+} else {
+
+ /**
+ * @see Translator::translate() For the function documentation.
+ */
+ function t(string $messageId, ?string $context = null): string
+ {
+ return $messageId;
+ }
+
+ /**
+ * @see Translator::translate() For the function documentation.
+ */
+ function mt(string $domain, string $messageId, ?string $context = null): string
+ {
+ return $messageId;
+ }
+
+ /**
+ * @see Translator::translatePlural() For the function documentation.
+ */
+ function tp(string $messageId, string $messageId2, ?int $number, ?string $context = null): string
+ {
+ if ((int) $number !== 1) {
+ return $messageId2;
+ }
+
+ return $messageId;
+ }
+
+ /**
+ * @see Translator::translatePlural() For the function documentation.
+ */
+ function mtp(string $domain, string $messageId, string $messageId2, ?int $number, ?string $context = null): string
+ {
+ if ((int) $number !== 1) {
+ return $messageId2;
+ }
+
+ return $messageId;
+ }
+
+}
diff --git a/library/Icinga/Application/webrouter.php b/library/Icinga/Application/webrouter.php
new file mode 100644
index 0000000..d9ab30b
--- /dev/null
+++ b/library/Icinga/Application/webrouter.php
@@ -0,0 +1,106 @@
+<?php
+/* Icinga Web 2 | (c) 2014 Icinga Development Team | GPLv2+ */
+
+namespace Icinga\Application;
+
+use Icinga\Chart\Inline\PieChart;
+use Icinga\Web\Controller\StaticController;
+use Icinga\Web\JavaScript;
+use Icinga\Web\StyleSheet;
+
+error_reporting(E_ALL | E_STRICT);
+
+if (isset($_SERVER['REQUEST_URI'])) {
+ $ruri = $_SERVER['REQUEST_URI'];
+} else {
+ return false;
+}
+
+// Workaround, PHPs internal Webserver seems to mess up SCRIPT_FILENAME
+// as it prefixes it's absolute path with DOCUMENT_ROOT
+if (preg_match('/^PHP .* Development Server/', $_SERVER['SERVER_SOFTWARE'])) {
+ $script = basename($_SERVER['SCRIPT_FILENAME']);
+ $_SERVER['PHP_SELF'] = $_SERVER['SCRIPT_NAME'] = '/' . $script;
+ $_SERVER['SCRIPT_FILENAME'] = $_SERVER['DOCUMENT_ROOT']
+ . DIRECTORY_SEPARATOR
+ . $script;
+}
+
+$baseDir = $_SERVER['DOCUMENT_ROOT'];
+$baseDir = dirname($_SERVER['SCRIPT_FILENAME']);
+
+// Fix aliases
+$remove = str_replace('\\', '/', dirname($_SERVER['PHP_SELF']));
+if (substr($ruri, 0, strlen($remove)) !== $remove) {
+ return false;
+}
+$ruri = ltrim(substr($ruri, strlen($remove)), '/');
+
+if (strpos($ruri, '?') === false) {
+ $params = '';
+ $path = $ruri;
+} else {
+ list($path, $params) = preg_split('/\?/', $ruri, 2);
+}
+
+$special = array(
+ 'css/icinga.css',
+ 'css/icinga.min.css',
+ 'js/icinga.dev.js',
+ 'js/icinga.min.js'
+);
+
+if (in_array($path, $special)) {
+ include_once __DIR__ . '/EmbeddedWeb.php';
+ EmbeddedWeb::start();
+
+ switch ($path) {
+ case 'css/icinga.css':
+ Stylesheet::send();
+ exit;
+ case 'css/icinga.min.css':
+ Stylesheet::send(true);
+ exit;
+
+ case 'js/icinga.dev.js':
+ JavaScript::send();
+ exit;
+
+ case 'js/icinga.min.js':
+ JavaScript::sendMinified();
+ break;
+
+ default:
+ return false;
+ }
+} elseif ($path === 'svg/chart.php') {
+ if (!array_key_exists('data', $_GET)) {
+ return false;
+ }
+ include __DIR__ . '/EmbeddedWeb.php';
+ EmbeddedWeb::start();
+ header('Content-Type: image/svg+xml');
+ $pie = new PieChart();
+ $pie->initFromRequest();
+ $pie->toSvg();
+} elseif ($path === 'png/chart.php') {
+ if (!array_key_exists('data', $_GET)) {
+ return false;
+ }
+ include __DIR__ . '/EmbeddedWeb.php';
+ EmbeddedWeb::start();
+ header('Content-Type: image/png');
+ $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 {
+ include __DIR__ . '/Web.php';
+ Web::start()->dispatch();
+}