diff options
author | Daniel Baumann <daniel.baumann@progress-linux.org> | 2024-04-13 11:46:43 +0000 |
---|---|---|
committer | Daniel Baumann <daniel.baumann@progress-linux.org> | 2024-04-13 11:46:43 +0000 |
commit | 3e02d5aff85babc3ffbfcf52313f2108e313aa23 (patch) | |
tree | b01f3923360c20a6a504aff42d45670c58af3ec5 /library/Icinga/Application | |
parent | Initial commit. (diff) | |
download | icingaweb2-upstream.tar.xz icingaweb2-upstream.zip |
Adding upstream version 2.12.1.upstream/2.12.1upstream
Signed-off-by: Daniel Baumann <daniel.baumann@progress-linux.org>
Diffstat (limited to 'library/Icinga/Application')
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(); +} |