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 | |
parent | Initial commit. (diff) | |
download | icingaweb2-3e02d5aff85babc3ffbfcf52313f2108e313aa23.tar.xz icingaweb2-3e02d5aff85babc3ffbfcf52313f2108e313aa23.zip |
Adding upstream version 2.12.1.upstream/2.12.1upstream
Signed-off-by: Daniel Baumann <daniel.baumann@progress-linux.org>
Diffstat (limited to '')
362 files changed, 62756 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(); +} diff --git a/library/Icinga/Authentication/AdmissionLoader.php b/library/Icinga/Authentication/AdmissionLoader.php new file mode 100644 index 0000000..0c3fd3f --- /dev/null +++ b/library/Icinga/Authentication/AdmissionLoader.php @@ -0,0 +1,249 @@ +<?php +/* Icinga Web 2 | (c) 2014 Icinga Development Team | GPLv2+ */ + +namespace Icinga\Authentication; + +use Generator; +use Icinga\Application\Config; +use Icinga\Application\Logger; +use Icinga\Exception\ConfigurationError; +use Icinga\Exception\NotReadableError; +use Icinga\Data\ConfigObject; +use Icinga\User; +use Icinga\Util\StringHelper; + +/** + * Retrieve restrictions and permissions for users + */ +class AdmissionLoader +{ + const LEGACY_PERMISSIONS = [ + 'admin' => 'application/announcements', + 'application/stacktraces' => 'user/application/stacktraces', + 'application/share/navigation' => 'user/share/navigation', + // Migrating config/application/* would include config/modules, so that's skipped + //'config/application/*' => 'config/*', + 'config/application/general' => 'config/general', + 'config/application/resources' => 'config/resources', + 'config/application/navigation' => 'config/navigation', + 'config/application/userbackend' => 'config/access-control/users', + 'config/application/usergroupbackend' => 'config/access-control/groups', + 'config/authentication/*' => 'config/access-control/*', + 'config/authentication/users/*' => 'config/access-control/users', + 'config/authentication/users/show' => 'config/access-control/users', + 'config/authentication/users/add' => 'config/access-control/users', + 'config/authentication/users/edit' => 'config/access-control/users', + 'config/authentication/users/remove' => 'config/access-control/users', + 'config/authentication/groups/*' => 'config/access-control/groups', + 'config/authentication/groups/show' => 'config/access-control/groups', + 'config/authentication/groups/edit' => 'config/access-control/groups', + 'config/authentication/groups/add' => 'config/access-control/groups', + 'config/authentication/groups/remove' => 'config/access-control/groups', + 'config/authentication/roles/*' => 'config/access-control/roles', + 'config/authentication/roles/show' => 'config/access-control/roles', + 'config/authentication/roles/add' => 'config/access-control/roles', + 'config/authentication/roles/edit' => 'config/access-control/roles', + 'config/authentication/roles/remove' => 'config/access-control/roles' + ]; + + /** @var Role[] */ + protected $roles; + + /** @var ConfigObject */ + protected $roleConfig; + + public function __construct() + { + try { + $this->roleConfig = Config::app('roles'); + } catch (NotReadableError $e) { + Logger::error('Can\'t access roles configuration. An exception was thrown:', $e); + } + } + + /** + * Whether the user or groups are a member of the role + * + * @param string $username + * @param array $userGroups + * @param ConfigObject $section + * + * @return bool + */ + protected function match($username, $userGroups, ConfigObject $section) + { + $username = strtolower($username); + if (! empty($section->users)) { + $users = array_map('strtolower', StringHelper::trimSplit($section->users)); + if (in_array('*', $users)) { + return true; + } + + if (in_array($username, $users)) { + return true; + } + } + + if (! empty($section->groups)) { + $groups = array_map('strtolower', StringHelper::trimSplit($section->groups)); + foreach ($userGroups as $userGroup) { + if (in_array(strtolower($userGroup), $groups)) { + return true; + } + } + } + + return false; + } + + /** + * Process role configuration and yield resulting roles + * + * This will also resolve any parent-child relationships. + * + * @param string $name + * @param ConfigObject $section + * + * @return Generator + * @throws ConfigurationError + */ + protected function loadRole($name, ConfigObject $section) + { + if (! isset($this->roles[$name])) { + $permissions = $section->permissions ? StringHelper::trimSplit($section->permissions) : []; + $refusals = $section->refusals ? StringHelper::trimSplit($section->refusals) : []; + + list($permissions, $newRefusals) = self::migrateLegacyPermissions($permissions); + if (! empty($newRefusals)) { + array_push($refusals, ...$newRefusals); + } + + $restrictions = $section->toArray(); + unset($restrictions['users'], $restrictions['groups']); + unset($restrictions['parent'], $restrictions['unrestricted']); + unset($restrictions['refusals'], $restrictions['permissions']); + + $role = new Role(); + $this->roles[$name] = $role + ->setName($name) + ->setRefusals($refusals) + ->setPermissions($permissions) + ->setRestrictions($restrictions) + ->setIsUnrestricted($section->get('unrestricted', false)); + + if (isset($section->parent)) { + $parentName = $section->parent; + if (! $this->roleConfig->hasSection($parentName)) { + Logger::error( + 'Failed to parse authentication configuration: Missing parent role "%s" (required by "%s")', + $parentName, + $name + ); + throw new ConfigurationError( + t('Unable to parse authentication configuration. Check the log for more details.') + ); + } + + foreach ($this->loadRole($parentName, $this->roleConfig->getSection($parentName)) as $parent) { + if ($parent->getName() === $parentName) { + $role->setParent($parent); + $parent->addChild($role); + + // Only yield main role once fully assembled + yield $role; + } + + yield $parent; + } + } else { + yield $role; + } + } else { + yield $this->roles[$name]; + } + } + + /** + * Apply permissions, restrictions and roles to the given user + * + * @param User $user + */ + public function applyRoles(User $user) + { + if ($this->roleConfig === null) { + return; + } + + $username = $user->getUsername(); + $userGroups = $user->getGroups(); + + $roles = []; + $permissions = []; + $restrictions = []; + $assignedRoles = []; + $isUnrestricted = false; + foreach ($this->roleConfig as $roleName => $roleConfig) { + $assigned = $this->match($username, $userGroups, $roleConfig); + if ($assigned) { + $assignedRoles[] = $roleName; + } + + if (! isset($roles[$roleName]) && $assigned) { + foreach ($this->loadRole($roleName, $roleConfig) as $role) { + /** @var Role $role */ + if (isset($roles[$role->getName()])) { + continue; + } + + $roles[$role->getName()] = $role; + + $permissions = array_merge( + $permissions, + array_diff($role->getPermissions(), $permissions) + ); + + $roleRestrictions = $role->getRestrictions(); + foreach ($roleRestrictions as $name => & $restriction) { + $restriction = str_replace( + '$user.local_name$', + $user->getLocalUsername(), + $restriction + ); + $restrictions[$name][] = $restriction; + } + + $role->setRestrictions($roleRestrictions); + + if (! $isUnrestricted) { + $isUnrestricted = $role->isUnrestricted(); + } + } + } + } + + $user->setAdditional('assigned_roles', $assignedRoles); + + $user->setIsUnrestricted($isUnrestricted); + $user->setRestrictions($isUnrestricted ? [] : $restrictions); + $user->setPermissions($permissions); + $user->setRoles(array_values($roles)); + } + + public static function migrateLegacyPermissions(array $permissions) + { + $migratedGrants = []; + $refusals = []; + + foreach ($permissions as $permission) { + if (array_key_exists($permission, self::LEGACY_PERMISSIONS)) { + $migratedGrants[] = self::LEGACY_PERMISSIONS[$permission]; + } elseif ($permission === 'no-user/password-change') { + $refusals[] = 'user/password-change'; + } else { + $migratedGrants[] = $permission; + } + } + + return [$migratedGrants, $refusals]; + } +} diff --git a/library/Icinga/Authentication/Auth.php b/library/Icinga/Authentication/Auth.php new file mode 100644 index 0000000..f358eac --- /dev/null +++ b/library/Icinga/Authentication/Auth.php @@ -0,0 +1,453 @@ +<?php +/* Icinga Web 2 | (c) 2013 Icinga Development Team | GPLv2+ */ + +namespace Icinga\Authentication; + +use Exception; +use Icinga\Application\Config; +use Icinga\Application\Hook\AuditHook; +use Icinga\Application\Icinga; +use Icinga\Application\Logger; +use Icinga\Authentication\User\ExternalBackend; +use Icinga\Authentication\UserGroup\UserGroupBackend; +use Icinga\Data\ConfigObject; +use Icinga\Exception\IcingaException; +use Icinga\Exception\NotReadableError; +use Icinga\User; +use Icinga\User\Preferences; +use Icinga\User\Preferences\PreferencesStore; +use Icinga\Web\Session; +use Icinga\Web\StyleSheet; + +class Auth +{ + /** + * Singleton instance + * + * @var self + */ + private static $instance; + + /** + * Request + * + * @var \Icinga\Web\Request + */ + protected $request; + + /** + * Response + * + * @var \Icinga\Web\Response + */ + protected $response; + + /** + * Authenticated user + * + * @var User|null + */ + private $user; + + + /** + * @see getInstance() + */ + private function __construct() + { + } + + /** + * Get the authentication manager + * + * @return self + */ + public static function getInstance() + { + if (self::$instance === null) { + self::$instance = new self(); + } + return self::$instance; + } + + /** + * Get the auth chain + * + * @return AuthChain + */ + public function getAuthChain() + { + return new AuthChain(); + } + + /** + * Get whether the user is authenticated + * + * @return bool + */ + public function isAuthenticated() + { + if ($this->user !== null) { + return true; + } + $this->authenticateFromSession(); + if ($this->user === null && ! $this->authExternal()) { + return false; + } + return true; + } + + public function setAuthenticated(User $user, $persist = true) + { + $this->setupUser($user); + + // Reload CSS if the theme changed + $themingConfig = Icinga::app()->getConfig()->getSection('themes'); + $userTheme = $user->getPreferences()->getValue('icingaweb', 'theme'); + if (! (bool) $themingConfig->get('disabled', false) && $userTheme !== null) { + $defaultTheme = $themingConfig->get('default', StyleSheet::DEFAULT_THEME); + if ($userTheme !== $defaultTheme) { + $this->getResponse()->setReloadCss(true); + } + } + + // Also reload CSS if the theme mode changed + $themeMode = $user->getPreferences()->getValue('icingaweb', 'theme_mode'); + if ($themeMode && $themeMode !== StyleSheet::DEFAULT_MODE) { + $this->getResponse()->setReloadCss(true); + } + + // Reload entire layout if the locale changed + if (($locale = $user->getPreferences()->getValue('icingaweb', 'language')) !== null) { + if (setlocale(LC_ALL, 0) !== $locale && $this->getRequest()->isXmlHttpRequest()) { + $this->getResponse()->setHeader('X-Icinga-Redirect-Http', 'yes'); + } + } + + $this->user = $user; + if ($persist) { + $this->persistCurrentUser(); + } + + AuditHook::logActivity('login', 'User logged in'); + } + + /** + * Getter for groups belonged to authenticated user + * + * @return array + * @see User::getGroups + */ + public function getGroups() + { + return $this->user->getGroups(); + } + + /** + * Get the request + * + * @return \Icinga\Web\Request + */ + public function getRequest() + { + if ($this->request === null) { + $this->request = Icinga::app()->getRequest(); + } + return $this->request; + } + + /** + * Get the response + * + * @return \Icinga\Web\Response + */ + public function getResponse() + { + if ($this->response === null) { + $this->response = Icinga::app()->getResponse(); + } + return $this->response; + } + + /** + * Get applied restrictions matching a given restriction name + * + * Returns a list of applied restrictions, empty if no user is + * authenticated + * + * @param string $restriction Restriction name + * @return array + */ + public function getRestrictions($restriction) + { + if (! $this->isAuthenticated()) { + return array(); + } + return $this->user->getRestrictions($restriction); + } + + /** + * Returns the current user or null if no user is authenticated + * + * @return User|null + */ + public function getUser() + { + return $this->user; + } + + /** + * Set the authenticated user + * + * Note that this method just sets the authenticated user and thus bypasses our default authentication process in + * {@link setAuthenticated()}. + * + * @param User $user + * + * @return $this + */ + public function setUser(User $user) + { + $this->user = $user; + + return $this; + } + + /** + * Try to authenticate the user with the current session + * + * Authentication for externally-authenticated users will be revoked if the username changed or external + * authentication is no longer in effect + */ + public function authenticateFromSession() + { + $this->user = Session::getSession()->get('user'); + if ($this->user !== null && $this->user->isExternalUser()) { + list($originUsername, $field) = $this->user->getExternalUserInformation(); + $username = ExternalBackend::getRemoteUser($field); + if ($username === null || $username !== $originUsername) { + $this->removeAuthorization(); + } + } + } + + /** + * Attempt to authenticate a user from external user backends + * + * @return bool + */ + protected function authExternal() + { + $user = new User(''); + foreach ($this->getAuthChain() as $userBackend) { + if ($userBackend instanceof ExternalBackend) { + if ($userBackend->authenticate($user)) { + if (! $user->hasDomain()) { + $user->setDomain(Config::app()->get('authentication', 'default_domain')); + } + $this->setAuthenticated($user); + return true; + } + } + } + return false; + } + + /** + * Attempt to authenticate a user using HTTP authentication on API requests only + * + * Supports only the Basic HTTP authentication scheme. XHR will be ignored. + * + * @return bool + */ + public function authHttp() + { + $request = $this->getRequest(); + $header = $request->getHeader('Authorization'); + if (empty($header)) { + return false; + } + list($scheme) = explode(' ', $header, 2); + if ($scheme !== 'Basic') { + return false; + } + $authorization = substr($header, strlen('Basic ')); + $credentials = base64_decode($authorization); + $credentials = array_filter(explode(':', $credentials, 2)); + if (count($credentials) !== 2) { + // Deny empty username and/or password + return false; + } + $user = new User($credentials[0]); + if (! $user->hasDomain()) { + $user->setDomain(Config::app()->get('authentication', 'default_domain')); + } + $password = $credentials[1]; + if ($this->getAuthChain()->setSkipExternalBackends(true)->authenticate($user, $password)) { + $this->setAuthenticated($user, false); + $user->setIsHttpUser(true); + return true; + } else { + return false; + } + } + + /** + * Challenge client immediately for HTTP authentication + * + * Sends the response w/ the 401 Unauthorized status code and WWW-Authenticate header. + */ + public function challengeHttp() + { + $response = $this->getResponse(); + $response->setHttpResponseCode(401); + $response->setHeader('WWW-Authenticate', 'Basic realm="Icinga Web 2"'); + $response->sendHeaders(); + exit(); + } + + /** + * Whether an authenticated user has a given permission + * + * @param string $permission Permission name + * + * @return bool True if the user owns the given permission, false if not or if not authenticated + */ + public function hasPermission($permission) + { + if (! $this->isAuthenticated()) { + return false; + } + return $this->user->can($permission); + } + + /** + * Writes the current user to the session + */ + public function persistCurrentUser() + { + // @TODO(el): https://dev.icinga.com/issues/10646 + $params = session_get_cookie_params(); + setcookie( + 'icingaweb2-session', + time(), + 0, + $params['path'], + $params['domain'], + $params['secure'], + $params['httponly'] + ); + Session::getSession()->set('user', $this->user)->refreshId(); + } + + /** + * Purges the current authorization information and session + */ + public function removeAuthorization() + { + AuditHook::logActivity('logout', 'User logged out'); + $this->user = null; + Session::getSession()->purge(); + } + + /** + * Setup the given user + * + * This loads preferences, groups and roles. + * + * @param User $user + * + * @return void + */ + public function setupUser(User $user) + { + // Load the user's preferences + + try { + $config = Config::app(); + } catch (NotReadableError $e) { + Logger::error( + new IcingaException( + 'Cannot load preferences for user "%s". An exception was thrown: %s', + $user->getUsername(), + $e + ) + ); + $config = new Config(); + } + + $preferencesConfig = new ConfigObject([ + 'resource' => $config->get('global', 'config_resource') + ]); + + try { + $preferencesStore = PreferencesStore::create($preferencesConfig, $user); + $preferences = new Preferences($preferencesStore->load()); + } catch (Exception $e) { + Logger::error( + new IcingaException( + 'Cannot load preferences for user "%s". An exception was thrown: %s', + $user->getUsername(), + $e + ) + ); + $preferences = new Preferences(); + } + + $user->setPreferences($preferences); + + // Load the user's groups + $groups = $user->getGroups(); + $userBackendName = $user->getAdditional('backend_name'); + foreach (Config::app('groups') as $name => $config) { + $groupsUserBackend = $config->user_backend; + if ($groupsUserBackend + && $groupsUserBackend !== 'none' + && $userBackendName !== null + && $groupsUserBackend !== $userBackendName + ) { + // Do not ask for Group membership if a specific User Backend + // has been assigned to that Group Backend, and the user has + // been authenticated by another User Backend + continue; + } + + try { + $groupBackend = UserGroupBackend::create($name, $config); + $groupsFromBackend = $groupBackend->getMemberships($user); + } catch (Exception $e) { + Logger::error( + 'Can\'t get group memberships for user \'%s\' from backend \'%s\'. An exception was thrown: %s', + $user->getUsername(), + $name, + $e + ); + continue; + } + + if (empty($groupsFromBackend)) { + Logger::debug( + 'No groups found in backend "%s" which the user "%s" is a member of.', + $name, + $user->getUsername() + ); + continue; + } + + $groupsFromBackend = array_values($groupsFromBackend); + Logger::debug( + 'Groups found in backend "%s" for user "%s": %s', + $name, + $user->getUsername(), + join(', ', $groupsFromBackend) + ); + $groups = array_merge($groups, array_combine($groupsFromBackend, $groupsFromBackend)); + } + + $user->setGroups($groups); + + // Load the user's roles + $admissionLoader = new AdmissionLoader(); + $admissionLoader->applyRoles($user); + } +} diff --git a/library/Icinga/Authentication/AuthChain.php b/library/Icinga/Authentication/AuthChain.php new file mode 100644 index 0000000..39468e3 --- /dev/null +++ b/library/Icinga/Authentication/AuthChain.php @@ -0,0 +1,269 @@ +<?php +/* Icinga Web 2 | (c) 2014 Icinga Development Team | GPLv2+ */ + +namespace Icinga\Authentication; + +use Icinga\Application\Hook\AuditHook; +use Iterator; +use Icinga\Application\Config; +use Icinga\Application\Logger; +use Icinga\Authentication\User\ExternalBackend; +use Icinga\Authentication\User\UserBackend; +use Icinga\Authentication\User\UserBackendInterface; +use Icinga\Data\ConfigObject; +use Icinga\Exception\AuthenticationException; +use Icinga\Exception\ConfigurationError; +use Icinga\Exception\NotReadableError; +use Icinga\User; + +/** + * Iterate user backends created from config + */ +class AuthChain implements Authenticatable, Iterator +{ + /** + * Authentication config file + * + * @var string + */ + const AUTHENTICATION_CONFIG = 'authentication'; + + /** + * Error code if the authentication configuration was not readable + * + * @var int + */ + const EPERM = 1; + + /** + * Error code if the authentication configuration is empty + */ + const EEMPTY = 2; + + /** + * Error code if all authentication methods failed + * + * @var int + */ + const EFAIL = 3; + + /** + * Error code if not all authentication methods were available + * + * @var int + */ + const ENOTALL = 4; + + /** + * User backends configuration + * + * @var Config + */ + protected $config; + + /** + * The consecutive user backend while looping + * + * @var UserBackendInterface + */ + protected $currentBackend; + + /** + * Last error code + * + * @var int|null + */ + protected $error; + + /** + * Whether external user backends should be skipped on iteration + * + * @var bool + */ + protected $skipExternalBackends = false; + + /** + * Create a new authentication chain from config + * + * @param Config $config User backends configuration + */ + public function __construct(Config $config = null) + { + if ($config === null) { + try { + $this->config = Config::app(static::AUTHENTICATION_CONFIG); + } catch (NotReadableError $e) { + $this->config = new Config(); + $this->error = static::EPERM; + } + } else { + $this->config = $config; + } + } + + /** + * {@inheritdoc} + */ + public function authenticate(User $user, $password) + { + $this->error = null; + $backendsTried = 0; + $backendsWithError = 0; + foreach ($this as $backend) { + ++$backendsTried; + try { + $authenticated = $backend->authenticate($user, $password); + } catch (AuthenticationException $e) { + Logger::error($e); + ++$backendsWithError; + continue; + } + if ($authenticated) { + $user->setAdditional('backend_name', $backend->getName()); + $user->setAdditional('backend_type', $this->config->current()->get('backend')); + return true; + } + } + + if ($backendsTried === 0) { + $this->error = static::EEMPTY; + } elseif ($backendsTried === $backendsWithError) { + $this->error = static::EFAIL; + } elseif ($backendsWithError) { + $this->error = static::ENOTALL; + } else { + AuditHook::logActivity('login-failed', 'User failed to authenticate', null, $user->getUsername()); + } + + return false; + } + + /** + * Get the last error code + * + * @return int|null + */ + public function getError() + { + return $this->error; + } + + /** + * Whether authentication had errors + * + * @return bool + */ + public function hasError() + { + return $this->error !== null; + } + + /** + * Get whether to skip external user backends on iteration + * + * @return bool + */ + public function getSkipExternalBackends() + { + return $this->skipExternalBackends; + } + + /** + * Set whether to skip external user backends on iteration + * + * @param bool $skipExternalBackends + * + * @return $this + */ + public function setSkipExternalBackends($skipExternalBackends = true) + { + $this->skipExternalBackends = (bool) $skipExternalBackends; + return $this; + } + + /** + * Rewind the chain + * + * @return void + */ + public function rewind(): void + { + $this->currentBackend = null; + $this->config->rewind(); + } + + /** + * Get the current user backend + * + * @return UserBackendInterface + */ + public function current(): UserBackendInterface + { + return $this->currentBackend; + } + + /** + * Get the key of the current user backend config + * + * @return string + */ + public function key(): string + { + return $this->config->key(); + } + + /** + * Move forward to the next user backend config + * + * @return void + */ + public function next(): void + { + $this->config->next(); + } + + /** + * Check whether the current user backend is valid, i.e. it's enabled, not an external user backend and whether its + * config is valid + * + * @return bool + */ + public function valid(): bool + { + if (! $this->config->valid()) { + // Stop when there are no more backends to check + return false; + } + + $backendConfig = $this->config->current(); + if ((bool) $backendConfig->get('disabled', false)) { + $this->next(); + return $this->valid(); + } + + $name = $this->key(); + try { + $backend = UserBackend::create($name, $backendConfig); + } catch (ConfigurationError $e) { + Logger::error( + new ConfigurationError( + 'Can\'t create authentication backend "%s". An exception was thrown:', + $name, + $e + ) + ); + $this->next(); + return $this->valid(); + } + + if ($this->getSkipExternalBackends() + && $backend instanceof ExternalBackend + ) { + $this->next(); + return $this->valid(); + } + + $this->currentBackend = $backend; + return true; + } +} diff --git a/library/Icinga/Authentication/Authenticatable.php b/library/Icinga/Authentication/Authenticatable.php new file mode 100644 index 0000000..c10d6d3 --- /dev/null +++ b/library/Icinga/Authentication/Authenticatable.php @@ -0,0 +1,21 @@ +<?php +/* Icinga Web 2 | (c) 2015 Icinga Development Team | GPLv2+ */ + +namespace Icinga\Authentication; + +use Icinga\User; + +interface Authenticatable +{ + /** + * Authenticate a user + * + * @param User $user + * @param string $password + * + * @return bool + * + * @throws \Icinga\Exception\AuthenticationException If authentication errors + */ + public function authenticate(User $user, $password); +} diff --git a/library/Icinga/Authentication/Role.php b/library/Icinga/Authentication/Role.php new file mode 100644 index 0000000..c409ba4 --- /dev/null +++ b/library/Icinga/Authentication/Role.php @@ -0,0 +1,334 @@ +<?php +/* Icinga Web 2 | (c) 2016 Icinga Development Team | GPLv2+ */ + +namespace Icinga\Authentication; + +class Role +{ + /** + * Name of the role + * + * @var string + */ + protected $name; + + /** + * The role from which to inherit privileges + * + * @var Role + */ + protected $parent; + + /** + * The roles to which privileges are inherited + * + * @var Role[] + */ + protected $children; + + /** + * Whether restrictions should not apply to owners of the role + * + * @var bool + */ + protected $unrestricted = false; + + /** + * Permissions of the role + * + * @var string[] + */ + protected $permissions = []; + + /** + * Refusals of the role + * + * @var string[] + */ + protected $refusals = []; + + /** + * Restrictions of the role + * + * @var string[] + */ + protected $restrictions = []; + + /** + * Get the name of the role + * + * @return string + */ + public function getName() + { + return $this->name; + } + + /** + * Set the name of the role + * + * @param string $name + * + * @return $this + */ + public function setName($name) + { + $this->name = $name; + + return $this; + } + + /** + * Get the role from which privileges are inherited + * + * @return Role + */ + public function getParent() + { + return $this->parent; + } + + /** + * Set the role from which to inherit privileges + * + * @param Role $parent + * + * @return $this + */ + public function setParent(Role $parent) + { + $this->parent = $parent; + + return $this; + } + + /** + * Get the roles to which privileges are inherited + * + * @return Role[] + */ + public function getChildren() + { + return $this->children; + } + + /** + * Set the roles to which inherit privileges + * + * @param Role[] $children + * + * @return $this + */ + public function setChildren(array $children) + { + $this->children = $children; + + return $this; + } + + /** + * Add a role to which inherit privileges + * + * @param Role $role + * + * @return $this + */ + public function addChild(Role $role) + { + $this->children[] = $role; + + return $this; + } + + /** + * Get whether restrictions should not apply to owners of the role + * + * @return bool + */ + public function isUnrestricted() + { + return $this->unrestricted; + } + + /** + * Set whether restrictions should not apply to owners of the role + * + * @param bool $state + * + * @return $this + */ + public function setIsUnrestricted($state) + { + $this->unrestricted = (bool) $state; + + return $this; + } + + /** + * Get the permissions of the role + * + * @return string[] + */ + public function getPermissions() + { + return $this->permissions; + } + + /** + * Set the permissions of the role + * + * @param string[] $permissions + * + * @return $this + */ + public function setPermissions(array $permissions) + { + $this->permissions = $permissions; + + return $this; + } + + /** + * Get the refusals of the role + * + * @return string[] + */ + public function getRefusals() + { + return $this->refusals; + } + + /** + * Set the refusals of the role + * + * @param array $refusals + * + * @return $this + */ + public function setRefusals(array $refusals) + { + $this->refusals = $refusals; + + return $this; + } + + /** + * Get the restrictions of the role + * + * @param string $name Optional name of the restriction + * + * @return string[]|null + */ + public function getRestrictions($name = null) + { + $restrictions = $this->restrictions; + + if ($name === null) { + return $restrictions; + } + + if (isset($restrictions[$name])) { + return $restrictions[$name]; + } + + return null; + } + + /** + * Set the restrictions of the role + * + * @param string[] $restrictions + * + * @return $this + */ + public function setRestrictions(array $restrictions) + { + $this->restrictions = $restrictions; + + return $this; + } + + /** + * Whether this role grants the given permission + * + * @param string $permission + * @param bool $ignoreParent Only evaluate the role's own permissions + * @param bool $cascadeUpwards `false` if `foo/bar/*` and `foo/bar/raboof` should not match `foo/*` + * + * @return bool + */ + public function grants($permission, $ignoreParent = false, $cascadeUpwards = true) + { + foreach ($this->permissions as $grantedPermission) { + if ($this->match($grantedPermission, $permission, $cascadeUpwards)) { + return true; + } + } + + if (! $ignoreParent && $this->getParent() !== null) { + return $this->getParent()->grants($permission, false, $cascadeUpwards); + } + + return false; + } + + /** + * Whether this role denies the given permission + * + * @param string $permission + * @param bool $ignoreParent Only evaluate the role's own refusals + * + * @return bool + */ + public function denies($permission, $ignoreParent = false) + { + foreach ($this->refusals as $refusedPermission) { + if ($this->match($refusedPermission, $permission, false)) { + return true; + } + } + + if (! $ignoreParent && $this->getParent() !== null) { + return $this->getParent()->denies($permission); + } + + return false; + } + + /** + * Get whether the role expression matches the required permission + * + * @param string $roleExpression + * @param string $requiredPermission + * @param bool $cascadeUpwards `false` if `foo/bar/*` and `foo/bar/raboof` should not match `foo/*` + * + * @return bool + */ + protected function match($roleExpression, $requiredPermission, $cascadeUpwards = true) + { + if ($roleExpression === '*' || $roleExpression === $requiredPermission) { + return true; + } + + $requiredWildcard = strpos($requiredPermission, '*'); + if ($requiredWildcard !== false) { + if (($grantedWildcard = strpos($roleExpression, '*')) !== false) { + $wildcard = $cascadeUpwards ? min($requiredWildcard, $grantedWildcard) : $grantedWildcard; + } else { + $wildcard = $cascadeUpwards ? $requiredWildcard : false; + } + } else { + $wildcard = strpos($roleExpression, '*'); + } + + if ($wildcard !== false && $wildcard > 0) { + if (substr($requiredPermission, 0, $wildcard) === substr($roleExpression, 0, $wildcard)) { + return true; + } + } elseif ($requiredPermission === $roleExpression) { + return true; + } + + return false; + } +} diff --git a/library/Icinga/Authentication/RolesConfig.php b/library/Icinga/Authentication/RolesConfig.php new file mode 100644 index 0000000..ac5695f --- /dev/null +++ b/library/Icinga/Authentication/RolesConfig.php @@ -0,0 +1,43 @@ +<?php +/* Icinga Web 2 | (c) 2019 Icinga GmbH | GPLv2+ */ + +namespace Icinga\Authentication; + +use Icinga\Application\Icinga; +use Icinga\Repository\IniRepository; + +class RolesConfig extends IniRepository +{ + protected $configs = [ + 'roles' => [ + 'name' => 'roles', + 'keyColumn' => 'name' + ] + ]; + + protected function initializeQueryColumns() + { + $columns = [ + 'roles' => [ + 'parent', + 'name', + 'users', + 'groups', + 'refusals', + 'permissions', + 'unrestricted', + 'application/share/users', + 'application/share/groups' + ] + ]; + + $moduleManager = Icinga::app()->getModuleManager(); + foreach ($moduleManager->listInstalledModules() as $moduleName) { + foreach ($moduleManager->getModule($moduleName, false)->getProvidedRestrictions() as $restriction) { + $columns['roles'][] = $restriction->name; + } + } + + return $columns; + } +} diff --git a/library/Icinga/Authentication/User/DbUserBackend.php b/library/Icinga/Authentication/User/DbUserBackend.php new file mode 100644 index 0000000..0e8cc6a --- /dev/null +++ b/library/Icinga/Authentication/User/DbUserBackend.php @@ -0,0 +1,256 @@ +<?php +/* Icinga Web 2 | (c) 2013 Icinga Development Team | GPLv2+ */ + +namespace Icinga\Authentication\User; + +use Exception; +use Icinga\Data\Inspectable; +use Icinga\Data\Inspection; +use Icinga\Data\Filter\Filter; +use Icinga\Exception\AuthenticationException; +use Icinga\Repository\DbRepository; +use Icinga\User; +use PDO; + +class DbUserBackend extends DbRepository implements UserBackendInterface, Inspectable +{ + /** + * The query columns being provided + * + * @var array + */ + protected $queryColumns = array( + 'user' => array( + 'user' => 'name COLLATE utf8mb4_general_ci', + 'user_name' => 'name', + 'is_active' => 'active', + 'created_at' => 'UNIX_TIMESTAMP(ctime)', + 'last_modified' => 'UNIX_TIMESTAMP(mtime)' + ) + ); + + /** + * The statement columns being provided + * + * @var array + */ + protected $statementColumns = array( + 'user' => array( + 'password' => 'password_hash', + 'created_at' => 'ctime', + 'last_modified' => 'mtime' + ) + ); + + /** + * The columns which are not permitted to be queried + * + * @var array + */ + protected $blacklistedQueryColumns = array('user'); + + /** + * The search columns being provided + * + * @var array + */ + protected $searchColumns = array('user'); + + /** + * The default sort rules to be applied on a query + * + * @var array + */ + protected $sortRules = array( + 'user_name' => array( + 'columns' => array( + 'is_active desc', + 'user_name' + ) + ) + ); + + /** + * The value conversion rules to apply on a query or statement + * + * @var array + */ + protected $conversionRules = array( + 'user' => array( + 'password' + ) + ); + + /** + * Initialize this database user backend + */ + protected function init() + { + if (! $this->ds->getTablePrefix()) { + $this->ds->setTablePrefix('icingaweb_'); + } + } + + /** + * Initialize this repository's filter columns + * + * @return array + */ + protected function initializeFilterColumns() + { + $userLabel = t('Username') . ' ' . t('(Case insensitive)'); + return array( + $userLabel => 'user', + t('Username') => 'user_name', + t('Active') => 'is_active', + t('Created at') => 'created_at', + t('Last modified') => 'last_modified' + ); + } + + /** + * Insert a table row with the given data + * + * @param string $table + * @param array $bind + * + * @return void + */ + public function insert($table, array $bind, array $types = array()) + { + $this->requireTable($table); + $bind['created_at'] = date('Y-m-d H:i:s'); + $this->ds->insert( + $this->prependTablePrefix($table), + $this->requireStatementColumns($table, $bind), + array( + 'active' => PDO::PARAM_INT, + 'password_hash' => PDO::PARAM_LOB + ) + ); + } + + /** + * Update table rows with the given data, optionally limited by using a filter + * + * @param string $table + * @param array $bind + * @param Filter $filter + */ + public function update($table, array $bind, Filter $filter = null, array $types = array()) + { + $this->requireTable($table); + $bind['last_modified'] = date('Y-m-d H:i:s'); + if ($filter) { + $filter = $this->requireFilter($table, $filter); + } + + $this->ds->update( + $this->prependTablePrefix($table), + $this->requireStatementColumns($table, $bind), + $filter, + array( + 'active' => PDO::PARAM_INT, + 'password_hash' => PDO::PARAM_LOB + ) + ); + } + + /** + * Hash and return the given password + * + * @param string $value + * + * @return string + */ + protected function persistPassword($value) + { + return password_hash($value, PASSWORD_DEFAULT); + } + + /** + * Fetch the hashed password for the given user + * + * @param string $username The name of the user + * + * @return string + */ + protected function getPasswordHash($username) + { + if ($this->ds->getDbType() === 'pgsql') { + // Since PostgreSQL version 9.0 the default value for bytea_output is 'hex' instead of 'escape' + $columns = array('password_hash' => 'ENCODE(password_hash, \'escape\')'); + } else { + $columns = array('password_hash'); + } + + $nameColumn = 'name'; + if ($this->ds->getDbType() === 'mysql') { + $username = strtolower($username); + $nameColumn = 'BINARY LOWER(name)'; + } + + $query = $this->ds->select() + ->from($this->prependTablePrefix('user'), $columns) + ->where($nameColumn, $username) + ->where('active', true); + + $statement = $this->ds->getDbAdapter()->prepare($query->getSelectQuery()); + $statement->execute(); + $statement->bindColumn(1, $lob, PDO::PARAM_LOB); + $statement->fetch(PDO::FETCH_BOUND); + if (is_resource($lob)) { + $lob = stream_get_contents($lob); + } + + if ($lob === null) { + return ''; + } + + return $this->ds->getDbType() === 'pgsql' ? pg_unescape_bytea($lob) : $lob; + } + + /** + * Authenticate the given user + * + * @param User $user + * @param string $password + * + * @return bool True on success, false on failure + * + * @throws AuthenticationException In case authentication is not possible due to an error + */ + public function authenticate(User $user, $password) + { + try { + return password_verify( + $password, + $this->getPasswordHash($user->getUsername()) + ); + } catch (Exception $e) { + throw new AuthenticationException( + 'Failed to authenticate user "%s" against backend "%s". An exception was thrown:', + $user->getUsername(), + $this->getName(), + $e + ); + } + } + + /** + * Inspect this object to gain extended information about its health + * + * @return Inspection The inspection result + */ + public function inspect() + { + $insp = new Inspection('Db User Backend'); + $insp->write($this->ds->inspect()); + try { + $insp->write(sprintf('%s active users', $this->select()->where('is_active', true)->count())); + } catch (Exception $e) { + $insp->error(sprintf('Query failed: %s', $e->getMessage())); + } + return $insp; + } +} diff --git a/library/Icinga/Authentication/User/DomainAwareInterface.php b/library/Icinga/Authentication/User/DomainAwareInterface.php new file mode 100644 index 0000000..3ff9c31 --- /dev/null +++ b/library/Icinga/Authentication/User/DomainAwareInterface.php @@ -0,0 +1,17 @@ +<?php +/* Icinga Web 2 | (c) 2017 Icinga Development Team | GPLv2+ */ + +namespace Icinga\Authentication\User; + +/** + * Interface for user backends that are responsible for a specific domain + */ +interface DomainAwareInterface +{ + /** + * Get the domain the backend is responsible for + * + * @return string + */ + public function getDomain(); +} diff --git a/library/Icinga/Authentication/User/ExternalBackend.php b/library/Icinga/Authentication/User/ExternalBackend.php new file mode 100644 index 0000000..6e79928 --- /dev/null +++ b/library/Icinga/Authentication/User/ExternalBackend.php @@ -0,0 +1,124 @@ +<?php +/* Icinga Web 2 | (c) 2014 Icinga Development Team | GPLv2+ */ + +namespace Icinga\Authentication\User; + +use Icinga\Application\Logger; +use Icinga\Data\ConfigObject; +use Icinga\User; + +/** + * Test login with external authentication mechanism, e.g. Apache + */ +class ExternalBackend implements UserBackendInterface +{ + /** + * Possible variables where to read the user from + * + * @var string[] + */ + public static $remoteUserEnvvars = array('REMOTE_USER', 'REDIRECT_REMOTE_USER'); + + /** + * The name of this backend + * + * @var string + */ + protected $name; + + /** + * Regexp expression to strip values from a username + * + * @var string + */ + protected $stripUsernameRegexp; + + /** + * Create new authentication backend of type "external" + * + * @param ConfigObject $config + */ + public function __construct(ConfigObject $config) + { + $this->stripUsernameRegexp = $config->get('strip_username_regexp'); + } + + /** + * {@inheritdoc} + */ + public function getName() + { + return $this->name; + } + + /** + * {@inheritdoc} + */ + public function setName($name) + { + $this->name = $name; + return $this; + } + + /** + * Get the remote user from environment or $_SERVER, if any + * + * @param string $variable The name of the variable where to read the user from + * + * @return string|null + */ + public static function getRemoteUser($variable = 'REMOTE_USER') + { + $username = getenv($variable); + if (! empty($username)) { + return $username; + } + + if (array_key_exists($variable, $_SERVER) && ! empty($_SERVER[$variable])) { + return $_SERVER[$variable]; + } + } + + /** + * Get the remote user information from environment or $_SERVER, if any + * + * @return array Contains always two entries, the username and origin which may both set to null. + */ + public static function getRemoteUserInformation() + { + foreach (static::$remoteUserEnvvars as $envVar) { + $username = static::getRemoteUser($envVar); + if ($username !== null) { + return array($username, $envVar); + } + } + + return array(null, null); + } + + /** + * {@inheritdoc} + */ + public function authenticate(User $user, $password = null) + { + list($username, $field) = static::getRemoteUserInformation(); + if ($username !== null) { + $user->setExternalUserInformation($username, $field); + + if ($this->stripUsernameRegexp) { + $stripped = @preg_replace($this->stripUsernameRegexp, '', $username); + if ($stripped === false) { + Logger::error('Failed to strip external username. The configured regular expression is invalid.'); + return false; + } + + $username = $stripped; + } + + $user->setUsername($username); + return true; + } + + return false; + } +} diff --git a/library/Icinga/Authentication/User/LdapUserBackend.php b/library/Icinga/Authentication/User/LdapUserBackend.php new file mode 100644 index 0000000..6a2cacf --- /dev/null +++ b/library/Icinga/Authentication/User/LdapUserBackend.php @@ -0,0 +1,479 @@ +<?php +/* Icinga Web 2 | (c) 2013 Icinga Development Team | GPLv2+ */ + +namespace Icinga\Authentication\User; + +use DateTime; +use Exception; +use Icinga\Data\ConfigObject; +use Icinga\Data\Inspectable; +use Icinga\Data\Inspection; +use Icinga\Exception\AuthenticationException; +use Icinga\Exception\ProgrammingError; +use Icinga\Exception\QueryException; +use Icinga\Repository\LdapRepository; +use Icinga\Repository\RepositoryQuery; +use Icinga\Protocol\Ldap\LdapException; +use Icinga\User; + +class LdapUserBackend extends LdapRepository implements UserBackendInterface, DomainAwareInterface, Inspectable +{ + /** + * The base DN to use for a query + * + * @var string + */ + protected $baseDn; + + /** + * The objectClass where look for users + * + * @var string + */ + protected $userClass; + + /** + * The attribute name where to find a user's name + * + * @var string + */ + protected $userNameAttribute; + + /** + * The custom LDAP filter to apply on search queries + * + * @var string + */ + protected $filter; + + /** + * The domain the backend is responsible for + * + * @var string + */ + protected $domain; + + /** + * The columns which are not permitted to be queried + * + * @var array + */ + protected $blacklistedQueryColumns = array('user'); + + /** + * The search columns being provided + * + * @var array + */ + protected $searchColumns = array('user'); + + /** + * The default sort rules to be applied on a query + * + * @var array + */ + protected $sortRules = array( + 'user_name' => array( + 'columns' => array( + 'is_active desc', + 'user_name' + ) + ) + ); + + /** + * Set the base DN to use for a query + * + * @param string $baseDn + * + * @return $this + */ + public function setBaseDn($baseDn) + { + if ($baseDn && ($baseDn = trim($baseDn))) { + $this->baseDn = $baseDn; + } + + return $this; + } + + /** + * Return the base DN to use for a query + * + * @return string + */ + public function getBaseDn() + { + return $this->baseDn; + } + + /** + * Set the objectClass where to look for users + * + * @param string $userClass + * + * @return $this + */ + public function setUserClass($userClass) + { + $this->userClass = $this->getNormedAttribute($userClass); + return $this; + } + + /** + * Return the objectClass where to look for users + * + * @return string + */ + public function getUserClass() + { + return $this->userClass; + } + + /** + * Set the attribute name where to find a user's name + * + * @param string $userNameAttribute + * + * @return $this + */ + public function setUserNameAttribute($userNameAttribute) + { + $this->userNameAttribute = $this->getNormedAttribute($userNameAttribute); + return $this; + } + + /** + * Return the attribute name where to find a user's name + * + * @return string + */ + public function getUserNameAttribute() + { + return $this->userNameAttribute; + } + + /** + * Set the custom LDAP filter to apply on search queries + * + * @param string $filter + * + * @return $this + */ + public function setFilter($filter) + { + if ($filter && ($filter = trim($filter))) { + if ($filter[0] === '(') { + $filter = substr($filter, 1, -1); + } + + $this->filter = $filter; + } + + return $this; + } + + /** + * Return the custom LDAP filter to apply on search queries + * + * @return string + */ + public function getFilter() + { + return $this->filter; + } + + public function getDomain() + { + return $this->domain; + } + + /** + * Set the domain the backend is responsible for + * + * @param string $domain + * + * @return $this + */ + public function setDomain($domain) + { + if ($domain && ($domain = trim($domain))) { + $this->domain = $domain; + } + + return $this; + } + + /** + * Initialize this repository's virtual tables + * + * @return array + * + * @throws ProgrammingError In case $this->userClass has not been set yet + */ + protected function initializeVirtualTables() + { + if ($this->userClass === null) { + throw new ProgrammingError('It is required to set the object class where to find users first'); + } + + return array( + 'user' => $this->userClass + ); + } + + /** + * Initialize this repository's query columns + * + * @return array + * + * @throws ProgrammingError In case $this->userNameAttribute has not been set yet + */ + protected function initializeQueryColumns() + { + if ($this->userNameAttribute === null) { + throw new ProgrammingError('It is required to set a attribute name where to find a user\'s name first'); + } + + if ($this->ds->getCapabilities()->isActiveDirectory()) { + $isActiveAttribute = 'userAccountControl'; + $createdAtAttribute = 'whenCreated'; + $lastModifiedAttribute = 'whenChanged'; + } else { + // TODO(jom): Elaborate whether it is possible to add dynamic support for the ppolicy + $isActiveAttribute = 'shadowExpire'; + + $createdAtAttribute = 'createTimestamp'; + $lastModifiedAttribute = 'modifyTimestamp'; + } + + return array( + 'user' => array( + 'user' => $this->userNameAttribute, + 'user_name' => $this->userNameAttribute, + 'is_active' => $isActiveAttribute, + 'created_at' => $createdAtAttribute, + 'last_modified' => $lastModifiedAttribute + ) + ); + } + + /** + * Initialize this repository's filter columns + * + * @return array + */ + protected function initializeFilterColumns() + { + return array( + t('Username') => 'user_name', + t('Active') => 'is_active', + t('Created At') => 'created_at', + t('Last modified') => 'last_modified' + ); + } + + /** + * Initialize this repository's conversion rules + * + * @return array + */ + protected function initializeConversionRules() + { + if ($this->ds->getCapabilities()->isActiveDirectory()) { + $stateConverter = 'user_account_control'; + } else { + $stateConverter = 'shadow_expire'; + } + + return array( + 'user' => array( + 'is_active' => $stateConverter, + 'created_at' => 'generalized_time', + 'last_modified' => 'generalized_time' + ) + ); + } + + /** + * Return whether the given userAccountControl value defines that a user is permitted to login + * + * @param string|null $value + * + * @return bool + */ + protected function retrieveUserAccountControl($value) + { + if ($value === null) { + return $value; + } + + $ADS_UF_ACCOUNTDISABLE = 2; + return ((int) $value & $ADS_UF_ACCOUNTDISABLE) === 0; + } + + /** + * Return whether the given shadowExpire value defines that a user is permitted to login + * + * @param string|null $value + * + * @return bool + */ + protected function retrieveShadowExpire($value) + { + if ($value === null) { + return $value; + } + + $now = new DateTime(); + $bigBang = clone $now; + $bigBang->setTimestamp(0); + return ((int) $value) >= $bigBang->diff($now)->days; + } + + /** + * Validate that the requested table exists + * + * @param string $table The table to validate + * @param RepositoryQuery $query An optional query to pass as context + * + * @return string + * + * @throws ProgrammingError In case the given table does not exist + */ + public function requireTable($table, RepositoryQuery $query = null) + { + if ($query !== null) { + $query->getQuery()->setBase($this->baseDn); + if ($this->filter) { + $query->getQuery()->setNativeFilter($this->filter); + } + } + + return parent::requireTable($table, $query); + } + + /** + * Validate that the given column is a valid query target and return it or the actual name if it's an alias + * + * @param string $table The table where to look for the column or alias + * @param string $name The name or alias of the column to validate + * @param RepositoryQuery $query An optional query to pass as context + * + * @return string The given column's name + * + * @throws QueryException In case the given column is not a valid query column + */ + public function requireQueryColumn($table, $name, RepositoryQuery $query = null) + { + $column = parent::requireQueryColumn($table, $name, $query); + if ($name === 'user_name' && $query !== null) { + $query->getQuery()->setUnfoldAttribute('user_name'); + } + + return $column; + } + + /** + * Authenticate the given user + * + * @param User $user + * @param string $password + * + * @return bool True on success, false on failure + * + * @throws AuthenticationException In case authentication is not possible due to an error + */ + public function authenticate(User $user, $password) + { + if ($this->domain !== null) { + if (! $user->hasDomain() || strtolower($user->getDomain()) !== strtolower($this->domain)) { + return false; + } + + $username = $user->getLocalUsername(); + } else { + $username = $user->getUsername(); + } + + try { + $userDn = $this + ->select() + ->where('user_name', str_replace('*', '', $username)) + ->getQuery() + ->setUsePagedResults(false) + ->fetchDn(); + if ($userDn === null) { + return false; + } + + $validCredentials = $this->ds->testCredentials($userDn, $password); + if ($validCredentials) { + $user->setAdditional('ldap_dn', $userDn); + } + + return $validCredentials; + } catch (LdapException $e) { + throw new AuthenticationException( + 'Failed to authenticate user "%s" against backend "%s". An exception was thrown:', + $username, + $this->getName(), + $e + ); + } + } + + /** + * Inspect if this LDAP User Backend is working as expected by probing the backend + * and testing if thea uthentication is possible + * + * Try to bind to the backend and fetch a single user to check if: + * <ul> + * <li>Connection credentials are correct and the bind is possible</li> + * <li>At least one user exists</li> + * <li>The specified userClass has the property specified by userNameAttribute</li> + * </ul> + * + * @return Inspection Inspection result + */ + public function inspect() + { + $result = new Inspection('Ldap User Backend'); + + // inspect the used connection to get more diagnostic info in case the connection is not working + $result->write($this->ds->inspect()); + try { + try { + $res = $this->select()->fetchRow(); + } catch (LdapException $e) { + throw new AuthenticationException('Connection not possible', $e); + } + $result->write('Searching for: ' . sprintf( + 'objectClass "%s" in DN "%s" (Filter: %s)', + $this->userClass, + $this->baseDn ?: $this->ds->getDn(), + $this->filter ?: 'None' + )); + if ($res === false) { + throw new AuthenticationException('Error, no users found in backend'); + } + $result->write(sprintf('%d users found in backend', $this->select()->count())); + if (! isset($res->user_name)) { + throw new AuthenticationException( + 'UserNameAttribute "%s" not existing in objectClass "%s"', + $this->userNameAttribute, + $this->userClass + ); + } + } catch (AuthenticationException $e) { + if (($previous = $e->getPrevious()) !== null) { + $result->error($previous->getMessage()); + } else { + $result->error($e->getMessage()); + } + } catch (Exception $e) { + $result->error(sprintf('Unable to validate authentication: %s', $e->getMessage())); + } + return $result; + } +} diff --git a/library/Icinga/Authentication/User/UserBackend.php b/library/Icinga/Authentication/User/UserBackend.php new file mode 100644 index 0000000..423b278 --- /dev/null +++ b/library/Icinga/Authentication/User/UserBackend.php @@ -0,0 +1,259 @@ +<?php +/* Icinga Web 2 | (c) 2013 Icinga Development Team | GPLv2+ */ + +namespace Icinga\Authentication\User; + +use Icinga\Application\Config; +use Icinga\Application\Logger; +use Icinga\Application\Icinga; +use Icinga\Data\ConfigObject; +use Icinga\Data\ResourceFactory; +use Icinga\Exception\ConfigurationError; +use Icinga\Util\ConfigAwareFactory; + +/** + * Factory for user backends + */ +class UserBackend implements ConfigAwareFactory +{ + /** + * The default user backend types provided by Icinga Web 2 + * + * @var array + */ + protected static $defaultBackends = array( + 'external', + 'db', + 'ldap', + 'msldap' + ); + + /** + * The registered custom user backends with their identifier as key and class name as value + * + * @var array + */ + protected static $customBackends; + + /** + * User backend configuration + * + * @var Config + */ + private static $backends; + + /** + * Set user backend configuration + * + * @param Config $config + */ + public static function setConfig($config) + { + self::$backends = $config; + } + + /** + * Return the configuration of all existing user backends + * + * @return Config + */ + public static function getBackendConfigs() + { + self::assertBackendsExist(); + return self::$backends; + } + + /** + * Check if any user backends exist. If not, throw an error. + * + * @throws ConfigurationError + */ + private static function assertBackendsExist() + { + if (self::$backends === null) { + throw new ConfigurationError( + 'User backends not set up. Please contact your Icinga Web administrator' + ); + } + } + + /** + * Register all custom user backends from all loaded modules + */ + protected static function registerCustomUserBackends() + { + if (static::$customBackends !== null) { + return; + } + + static::$customBackends = array(); + $providedBy = array(); + foreach (Icinga::app()->getModuleManager()->getLoadedModules() as $module) { + foreach ($module->getUserBackends() as $identifier => $className) { + if (array_key_exists($identifier, $providedBy)) { + Logger::warning( + 'Cannot register user backend of type "%s" provided by module "%s".' + . ' The type is already provided by module "%s"', + $identifier, + $module->getName(), + $providedBy[$identifier] + ); + } elseif (in_array($identifier, static::$defaultBackends)) { + Logger::warning( + 'Cannot register user backend of type "%s" provided by module "%s".' + . ' The type is a default type provided by Icinga Web 2', + $identifier, + $module->getName() + ); + } else { + $providedBy[$identifier] = $module->getName(); + static::$customBackends[$identifier] = $className; + } + } + } + } + + /** + * Get config forms of all custom user backends + */ + public static function getCustomBackendConfigForms() + { + $customBackendConfigForms = []; + static::registerCustomUserBackends(); + foreach (self::$customBackends as $customBackendType => $customBackendClass) { + if (method_exists($customBackendClass, 'getConfigurationFormClass')) { + $customBackendConfigForms[$customBackendType] = $customBackendClass::getConfigurationFormClass(); + } + } + + return $customBackendConfigForms; + } + + /** + * Return the class for the given custom user backend + * + * @param string $identifier The identifier of the custom user backend + * + * @return string|null The name of the class or null in case there was no + * backend found with the given identifier + * + * @throws ConfigurationError In case the class associated to the given identifier does not exist + */ + protected static function getCustomUserBackend($identifier) + { + static::registerCustomUserBackends(); + if (array_key_exists($identifier, static::$customBackends)) { + $className = static::$customBackends[$identifier]; + if (! class_exists($className)) { + throw new ConfigurationError( + 'Cannot utilize user backend of type "%s". Class "%s" does not exist', + $identifier, + $className + ); + } + + return $className; + } + } + + /** + * Create and return a user backend with the given name and given configuration applied to it + * + * @param string $name + * @param ConfigObject $backendConfig + * + * @return UserBackendInterface + * + * @throws ConfigurationError + */ + public static function create($name, ConfigObject $backendConfig = null) + { + if ($backendConfig === null) { + self::assertBackendsExist(); + if (self::$backends->hasSection($name)) { + $backendConfig = self::$backends->getSection($name); + } else { + throw new ConfigurationError('User backend "%s" does not exist', $name); + } + } + + if ($backendConfig->name !== null) { + $name = $backendConfig->name; + } + + if (! ($backendType = strtolower($backendConfig->backend))) { + throw new ConfigurationError( + 'Authentication configuration for user backend "%s" is missing the \'backend\' directive', + $name + ); + } + + if ($backendType === 'external') { + $backend = new ExternalBackend($backendConfig); + $backend->setName($name); + return $backend; + } + if (in_array($backendType, static::$defaultBackends)) { + // The default backend check is the first one because of performance reasons: + // Do not attempt to load a custom user backend unless it's actually required + } elseif (($customClass = static::getCustomUserBackend($backendType)) !== null) { + $backend = new $customClass($backendConfig); + if (! is_a($backend, 'Icinga\Authentication\User\UserBackendInterface')) { + throw new ConfigurationError( + 'Cannot utilize user backend of type "%s". Class "%s" does not implement UserBackendInterface', + $backendType, + $customClass + ); + } + + $backend->setName($name); + return $backend; + } else { + throw new ConfigurationError( + 'Authentication configuration for user backend "%s" defines an invalid backend type.' + . ' Backend type "%s" is not supported', + $name, + $backendType + ); + } + + if ($backendConfig->resource === null) { + throw new ConfigurationError( + 'Authentication configuration for user backend "%s" is missing the \'resource\' directive', + $name + ); + } + + $resourceConfig = ResourceFactory::getResourceConfig($backendConfig->resource); + if ($backendType === 'db' && $resourceConfig->db === 'mysql') { + $resourceConfig->charset = 'utf8mb4'; + } + + $resource = ResourceFactory::createResource($resourceConfig); + $backend = null; + switch ($backendType) { + case 'db': + $backend = new DbUserBackend($resource); + break; + case 'msldap': + $backend = new LdapUserBackend($resource); + $backend->setBaseDn($backendConfig->base_dn); + $backend->setUserClass($backendConfig->get('user_class', 'user')); + $backend->setUserNameAttribute($backendConfig->get('user_name_attribute', 'sAMAccountName')); + $backend->setFilter($backendConfig->filter); + $backend->setDomain($backendConfig->domain); + break; + case 'ldap': + $backend = new LdapUserBackend($resource); + $backend->setBaseDn($backendConfig->base_dn); + $backend->setUserClass($backendConfig->get('user_class', 'inetOrgPerson')); + $backend->setUserNameAttribute($backendConfig->get('user_name_attribute', 'uid')); + $backend->setFilter($backendConfig->filter); + $backend->setDomain($backendConfig->domain); + break; + } + + $backend->setName($name); + return $backend; + } +} diff --git a/library/Icinga/Authentication/User/UserBackendInterface.php b/library/Icinga/Authentication/User/UserBackendInterface.php new file mode 100644 index 0000000..4660eb0 --- /dev/null +++ b/library/Icinga/Authentication/User/UserBackendInterface.php @@ -0,0 +1,39 @@ +<?php +/* Icinga Web 2 | (c) 2015 Icinga Development Team | GPLv2+ */ + +namespace Icinga\Authentication\User; + +use Icinga\Authentication\Authenticatable; +use Icinga\User; + +/** + * Interface for user backends + */ +interface UserBackendInterface extends Authenticatable +{ + /** + * Set this backend's name + * + * @param string $name + * + * @return $this + */ + public function setName($name); + + /** + * Return this backend's name + * + * @return string + */ + public function getName(); + + /** + * Return this backend's configuration form class path + * + * This is not part of the interface to not break existing implementations. + * If you need a custom backend form, implement this method. + * + * @return string + */ + //public static function getConfigurationFormClass(); +} diff --git a/library/Icinga/Authentication/UserGroup/DbUserGroupBackend.php b/library/Icinga/Authentication/UserGroup/DbUserGroupBackend.php new file mode 100644 index 0000000..5299bbb --- /dev/null +++ b/library/Icinga/Authentication/UserGroup/DbUserGroupBackend.php @@ -0,0 +1,325 @@ +<?php +/* Icinga Web 2 | (c) 2014 Icinga Development Team | GPLv2+ */ + +namespace Icinga\Authentication\UserGroup; + +use Exception; +use Icinga\Data\Filter\Filter; +use Icinga\Data\Inspectable; +use Icinga\Data\Inspection; +use Icinga\Exception\NotFoundError; +use Icinga\Repository\DbRepository; +use Icinga\Repository\RepositoryQuery; +use Icinga\User; + +class DbUserGroupBackend extends DbRepository implements Inspectable, UserGroupBackendInterface +{ + /** + * The query columns being provided + * + * @var array + */ + protected $queryColumns = array( + 'group' => array( + 'group_id' => 'g.id', + 'group' => 'g.name COLLATE utf8mb4_general_ci', + 'group_name' => 'g.name', + 'parent' => 'g.parent', + 'created_at' => 'UNIX_TIMESTAMP(g.ctime)', + 'last_modified' => 'UNIX_TIMESTAMP(g.mtime)' + ), + 'group_membership' => array( + 'group_id' => 'gm.group_id', + 'user' => 'gm.username COLLATE utf8mb4_general_ci', + 'user_name' => 'gm.username', + 'created_at' => 'UNIX_TIMESTAMP(gm.ctime)', + 'last_modified' => 'UNIX_TIMESTAMP(gm.mtime)' + ) + ); + + /** + * The table aliases being applied + * + * @var array + */ + protected $tableAliases = array( + 'group' => 'g', + 'group_membership' => 'gm' + ); + + /** + * The statement columns being provided + * + * @var array + */ + protected $statementColumns = array( + 'group' => array( + 'group_id' => 'id', + 'group_name' => 'name', + 'parent' => 'parent', + 'created_at' => 'ctime', + 'last_modified' => 'mtime' + ), + 'group_membership' => array( + 'group_id' => 'group_id', + 'group_name' => 'group_id', + 'user_name' => 'username', + 'created_at' => 'ctime', + 'last_modified' => 'mtime' + ) + ); + + /** + * The columns which are not permitted to be queried + * + * @var array + */ + protected $blacklistedQueryColumns = array('group', 'user'); + + /** + * The search columns being provided + * + * @var array + */ + protected $searchColumns = array('group', 'user'); + + /** + * The value conversion rules to apply on a query or statement + * + * @var array + */ + protected $conversionRules = array( + 'group' => array( + 'parent' => 'group_id' + ), + 'group_membership' => array( + 'group_name' => 'group_id' + ) + ); + + /** + * Initialize this database user group backend + */ + protected function init() + { + if (! $this->ds->getTablePrefix()) { + $this->ds->setTablePrefix('icingaweb_'); + } + } + + /** + * Initialize this repository's filter columns + * + * @return array + */ + protected function initializeFilterColumns() + { + $userLabel = t('Username') . ' ' . t('(Case insensitive)'); + $groupLabel = t('User Group') . ' ' . t('(Case insensitive)'); + return array( + $userLabel => 'user', + t('Username') => 'user_name', + $groupLabel => 'group', + t('User Group') => 'group_name', + t('Parent') => 'parent', + t('Created At') => 'created_at', + t('Last modified') => 'last_modified' + ); + } + + /** + * Insert a table row with the given data + * + * @param string $table + * @param array $bind + */ + public function insert($table, array $bind, array $types = array()) + { + $bind['created_at'] = date('Y-m-d H:i:s'); + parent::insert($table, $bind); + } + + /** + * Update table rows with the given data, optionally limited by using a filter + * + * @param string $table + * @param array $bind + * @param Filter $filter + */ + public function update($table, array $bind, Filter $filter = null, array $types = array()) + { + $bind['last_modified'] = date('Y-m-d H:i:s'); + parent::update($table, $bind, $filter); + } + + /** + * Delete table rows, optionally limited by using a filter + * + * @param string $table + * @param Filter $filter + */ + public function delete($table, Filter $filter = null) + { + if ($table === 'group') { + parent::delete('group_membership', $filter); + $idQuery = $this->select(array('group_id')); + if ($filter !== null) { + $idQuery->applyFilter($filter); + } + + $this->update('group', array('parent' => null), Filter::where('parent', $idQuery->fetchColumn())); + } + + parent::delete($table, $filter); + } + + /** + * Return the groups the given user is a member of + * + * @param User $user + * + * @return array + */ + public function getMemberships(User $user) + { + $groupQuery = $this->ds + ->select() + ->from( + array('g' => $this->prependTablePrefix('group')), + array( + 'group_name' => 'g.name', + 'parent_name' => 'gg.name' + ) + )->joinLeft( + array('gg' => $this->prependTablePrefix('group')), + 'g.parent = gg.id', + array() + ); + + $groups = array(); + foreach ($groupQuery as $group) { + $groups[$group->group_name] = $group->parent_name; + } + + $membershipQuery = $this + ->select() + ->from('group_membership', array('group_name')) + ->where('user_name', $user->getUsername()); + + $memberships = array(); + foreach ($membershipQuery as $membership) { + $memberships[] = $membership->group_name; + $parent = $groups[$membership->group_name]; + while ($parent !== null) { + $memberships[] = $parent; + // Usually a parent is an existing group, but since we do not have a constraint on our table.. + $parent = isset($groups[$parent]) ? $groups[$parent] : null; + } + } + + return $memberships; + } + + /** + * Return the name of the backend that is providing the given user + * + * @param string $username Currently unused + * + * @return null|string The name of the backend or null in case this information is not available + */ + public function getUserBackendName($username) + { + return null; // TODO(10373): Store this to the database when inserting and fetch it here + } + + /** + * Join group into group_membership + * + * @param RepositoryQuery $query + */ + protected function joinGroup(RepositoryQuery $query) + { + $query->getQuery()->join( + $this->requireTable('group'), + 'gm.group_id = g.id', + array() + ); + } + + /** + * Join group_membership into group + * + * @param RepositoryQuery $query + */ + protected function joinGroupMembership(RepositoryQuery $query) + { + $query->getQuery()->joinLeft( + $this->requireTable('group_membership'), + 'g.id = gm.group_id', + array() + )->group('g.id'); + } + + /** + * Fetch and return the corresponding id for the given group's name + * + * @param string|array $groupName + * + * @return int + * + * @throws NotFoundError + */ + protected function persistGroupId($groupName) + { + if (empty($groupName) || is_numeric($groupName)) { + return $groupName; + } + + if (is_array($groupName)) { + if (is_numeric($groupName[0])) { + return $groupName; // In case the array contains mixed types... + } + + $groupIds = $this->ds + ->select() + ->from($this->prependTablePrefix('group'), array('id')) + ->where('name', $groupName) + ->fetchColumn(); + if (empty($groupIds)) { + throw new NotFoundError('No groups found matching one of: %s', implode(', ', $groupName)); + } + + return $groupIds; + } + + $groupId = $this->ds + ->select() + ->from($this->prependTablePrefix('group'), array('id')) + ->where('name', $groupName) + ->fetchOne(); + if ($groupId === false) { + throw new NotFoundError('Group "%s" does not exist', $groupName); + } + + return $groupId; + } + + /** + * Inspect this object to gain extended information about its health + * + * @return Inspection The inspection result + */ + public function inspect() + { + $insp = new Inspection('Db User Group Backend'); + $insp->write($this->ds->inspect()); + + try { + $insp->write(sprintf('%s group(s)', $this->select()->count())); + } catch (Exception $e) { + $insp->error(sprintf('Query failed: %s', $e->getMessage())); + } + + return $insp; + } +} diff --git a/library/Icinga/Authentication/UserGroup/LdapUserGroupBackend.php b/library/Icinga/Authentication/UserGroup/LdapUserGroupBackend.php new file mode 100644 index 0000000..e78242e --- /dev/null +++ b/library/Icinga/Authentication/UserGroup/LdapUserGroupBackend.php @@ -0,0 +1,945 @@ +<?php +/* Icinga Web 2 | (c) 2015 Icinga Development Team | GPLv2+ */ + +namespace Icinga\Authentication\UserGroup; + +use Exception; +use Icinga\Authentication\User\UserBackend; +use Icinga\Authentication\User\LdapUserBackend; +use Icinga\Application\Logger; +use Icinga\Data\ConfigObject; +use Icinga\Data\Inspectable; +use Icinga\Data\Inspection; +use Icinga\Exception\AuthenticationException; +use Icinga\Exception\ConfigurationError; +use Icinga\Exception\ProgrammingError; +use Icinga\Exception\QueryException; +use Icinga\Protocol\Ldap\LdapException; +use Icinga\Protocol\Ldap\LdapUtils; +use Icinga\Repository\LdapRepository; +use Icinga\Repository\RepositoryQuery; +use Icinga\User; + +class LdapUserGroupBackend extends LdapRepository implements Inspectable, UserGroupBackendInterface +{ + /** + * The user backend being associated with this user group backend + * + * @var LdapUserBackend + */ + protected $userBackend; + + /** + * The base DN to use for a user query + * + * @var string + */ + protected $userBaseDn; + + /** + * The base DN to use for a group query + * + * @var string + */ + protected $groupBaseDn; + + /** + * The objectClass where look for users + * + * @var string + */ + protected $userClass; + + /** + * The objectClass where look for groups + * + * @var string + */ + protected $groupClass; + + /** + * The attribute name where to find a user's name + * + * @var string + */ + protected $userNameAttribute; + + /** + * The attribute name where to find a group's name + * + * @var string + */ + protected $groupNameAttribute; + + /** + * The attribute name where to find a group's member + * + * @var string + */ + protected $groupMemberAttribute; + + /** + * Whether the attribute name where to find a group's member holds ambiguous values + * + * @var bool + */ + protected $ambiguousMemberAttribute; + + /** + * The custom LDAP filter to apply on a user query + * + * @var string + */ + protected $userFilter; + + /** + * The custom LDAP filter to apply on a group query + * + * @var string + */ + protected $groupFilter; + + /** + * ActiveDirectory nested group on the user? + * + * @var bool + */ + protected $nestedGroupSearch; + + /** + * The domain the backend is responsible for + * + * @var string + */ + protected $domain; + + /** + * The columns which are not permitted to be queried + * + * @var array + */ + protected $blacklistedQueryColumns = array('group', 'user'); + + /** + * The search columns being provided + * + * @var array + */ + protected $searchColumns = array('group', 'user'); + + /** + * The default sort rules to be applied on a query + * + * @var array + */ + protected $sortRules = array( + 'group_name' => array( + 'order' => 'asc' + ) + ); + + /** + * Set the user backend to be associated with this user group backend + * + * @param LdapUserBackend $backend + * + * @return $this + */ + public function setUserBackend(LdapUserBackend $backend) + { + $this->userBackend = $backend; + return $this; + } + + /** + * Return the user backend being associated with this user group backend + * + * @return LdapUserBackend + */ + public function getUserBackend() + { + return $this->userBackend; + } + + /** + * Set the base DN to use for a user query + * + * @param string $baseDn + * + * @return $this + */ + public function setUserBaseDn($baseDn) + { + if ($baseDn && ($baseDn = trim($baseDn))) { + $this->userBaseDn = $baseDn; + } + + return $this; + } + + /** + * Return the base DN to use for a user query + * + * @return string + */ + public function getUserBaseDn() + { + return $this->userBaseDn; + } + + /** + * Set the base DN to use for a group query + * + * @param string $baseDn + * + * @return $this + */ + public function setGroupBaseDn($baseDn) + { + if ($baseDn && ($baseDn = trim($baseDn))) { + $this->groupBaseDn = $baseDn; + } + + return $this; + } + + /** + * Return the base DN to use for a group query + * + * @return string + */ + public function getGroupBaseDn() + { + return $this->groupBaseDn; + } + + /** + * Set the objectClass where to look for users + * + * @param string $userClass + * + * @return $this + */ + public function setUserClass($userClass) + { + $this->userClass = $this->getNormedAttribute($userClass); + return $this; + } + + /** + * Return the objectClass where to look for users + * + * @return string + */ + public function getUserClass() + { + return $this->userClass; + } + + /** + * Set the objectClass where to look for groups + * + * @param string $groupClass + * + * @return $this + */ + public function setGroupClass($groupClass) + { + $this->groupClass = $this->getNormedAttribute($groupClass); + return $this; + } + + /** + * Return the objectClass where to look for groups + * + * @return string + */ + public function getGroupClass() + { + return $this->groupClass; + } + + /** + * Set the attribute name where to find a user's name + * + * @param string $userNameAttribute + * + * @return $this + */ + public function setUserNameAttribute($userNameAttribute) + { + $this->userNameAttribute = $this->getNormedAttribute($userNameAttribute); + return $this; + } + + /** + * Return the attribute name where to find a user's name + * + * @return string + */ + public function getUserNameAttribute() + { + return $this->userNameAttribute; + } + + /** + * Set the attribute name where to find a group's name + * + * @param string $groupNameAttribute + * + * @return $this + */ + public function setGroupNameAttribute($groupNameAttribute) + { + $this->groupNameAttribute = $this->getNormedAttribute($groupNameAttribute); + return $this; + } + + /** + * Return the attribute name where to find a group's name + * + * @return string + */ + public function getGroupNameAttribute() + { + return $this->groupNameAttribute; + } + + /** + * Set the attribute name where to find a group's member + * + * @param string $groupMemberAttribute + * + * @return $this + */ + public function setGroupMemberAttribute($groupMemberAttribute) + { + $this->groupMemberAttribute = $this->getNormedAttribute($groupMemberAttribute); + return $this; + } + + /** + * Return the attribute name where to find a group's member + * + * @return string + */ + public function getGroupMemberAttribute() + { + return $this->groupMemberAttribute; + } + + /** + * Set the custom LDAP filter to apply on a user query + * + * @param string $filter + * + * @return $this + */ + public function setUserFilter($filter) + { + if ($filter && ($filter = trim($filter))) { + if ($filter[0] === '(') { + $filter = substr($filter, 1, -1); + } + + $this->userFilter = $filter; + } + + return $this; + } + + /** + * Return the custom LDAP filter to apply on a user query + * + * @return string + */ + public function getUserFilter() + { + return $this->userFilter; + } + + /** + * Set the custom LDAP filter to apply on a group query + * + * @param string $filter + * + * @return $this + */ + public function setGroupFilter($filter) + { + if ($filter && ($filter = trim($filter))) { + $this->groupFilter = $filter; + } + + return $this; + } + + /** + * Return the custom LDAP filter to apply on a group query + * + * @return string + */ + public function getGroupFilter() + { + return $this->groupFilter; + } + + /** + * Set nestedGroupSearch for the group query + * + * @param bool $enable + * + * @return $this + */ + public function setNestedGroupSearch($enable = true) + { + $this->nestedGroupSearch = $enable; + return $this; + } + + /** + * Get nestedGroupSearch for the group query + * + * @return bool + */ + public function getNestedGroupSearch() + { + return $this->nestedGroupSearch; + } + + /** + * Get the domain the backend is responsible for + * + * If the LDAP group backend is linked with a LDAP user backend, + * the domain of the user backend will be returned. + * + * @return string + */ + public function getDomain() + { + return $this->userBackend !== null ? $this->userBackend->getDomain() : $this->domain; + } + + /** + * Set the domain the backend is responsible for + * + * If the LDAP group backend is linked with a LDAP user backend, + * the domain of the user backend will be used nonetheless. + * + * @param string $domain + * + * @return $this + */ + public function setDomain($domain) + { + if ($domain && ($domain = trim($domain))) { + $this->domain = $domain; + } + + return $this; + } + + /** + * Return whether the attribute name where to find a group's member holds ambiguous values + * + * This tries to detect if the member attribute of groups contain: + * + * full DN -> distinguished name of another object + * other -> ambiguous field referencing the member by userNameAttribute + * + * @return bool + * + * @throws ProgrammingError In case either $this->groupClass or $this->groupMemberAttribute + * has not been set yet + */ + protected function isMemberAttributeAmbiguous() + { + if ($this->ambiguousMemberAttribute === null) { + if ($this->groupClass === null) { + throw new ProgrammingError( + 'It is required to set the objectClass where to look for groups first' + ); + } elseif ($this->groupMemberAttribute === null) { + throw new ProgrammingError( + 'It is required to set a attribute name where to find a group\'s members first' + ); + } + + $sampleValues = $this->ds + ->select() + ->from($this->groupClass, array($this->groupMemberAttribute)) + ->where($this->groupMemberAttribute, '*') + ->limit(Logger::getInstance()->getLevel() === Logger::DEBUG ? 3 : 1) + ->setUnfoldAttribute($this->groupMemberAttribute) + ->setBase($this->groupBaseDn) + ->fetchAll(); + + Logger::debug('Ambiguity query returned %d results', count($sampleValues)); + + $i = 0; + $sampleValue = null; + foreach ($sampleValues as $key => $value) { + if ($sampleValue === null) { + $sampleValue = $value; + } + + Logger::debug('Result %d: %s (%s)', ++$i, $value, $key); + } + + if (is_object($sampleValue) && isset($sampleValue->{$this->groupMemberAttribute})) { + $this->ambiguousMemberAttribute = ! LdapUtils::isDn($sampleValue->{$this->groupMemberAttribute}); + + Logger::debug( + 'Ambiguity check came to the conclusion that the member attribute %s ambiguous. Tested sample: %s', + $this->ambiguousMemberAttribute ? 'is' : 'is not', + $sampleValue->{$this->groupMemberAttribute} + ); + } else { + Logger::warning( + 'Ambiguity query returned zero or invalid results. Sample value is `%s`', + print_r($sampleValue, true) + ); + } + } + + return $this->ambiguousMemberAttribute; + } + + /** + * Initialize this repository's virtual tables + * + * @return array + * + * @throws ProgrammingError In case $this->groupClass has not been set yet + */ + protected function initializeVirtualTables() + { + if ($this->groupClass === null) { + throw new ProgrammingError('It is required to set the object class where to find groups first'); + } + + return array( + 'group' => $this->groupClass, + 'group_membership' => $this->groupClass + ); + } + + /** + * Initialize this repository's query columns + * + * @return array + * + * @throws ProgrammingError In case either $this->groupNameAttribute or + * $this->groupMemberAttribute has not been set yet + */ + protected function initializeQueryColumns() + { + if ($this->groupNameAttribute === null) { + throw new ProgrammingError('It is required to set a attribute name where to find a group\'s name first'); + } + if ($this->groupMemberAttribute === null) { + throw new ProgrammingError('It is required to set a attribute name where to find a group\'s members first'); + } + + if ($this->ds->getCapabilities()->isActiveDirectory()) { + $createdAtAttribute = 'whenCreated'; + $lastModifiedAttribute = 'whenChanged'; + } else { + $createdAtAttribute = 'createTimestamp'; + $lastModifiedAttribute = 'modifyTimestamp'; + } + + $columns = array( + 'group' => $this->groupNameAttribute, + 'group_name' => $this->groupNameAttribute, + 'user' => $this->groupMemberAttribute, + 'user_name' => $this->groupMemberAttribute, + 'created_at' => $createdAtAttribute, + 'last_modified' => $lastModifiedAttribute + ); + return array('group' => $columns, 'group_membership' => $columns); + } + + /** + * Initialize this repository's filter columns + * + * @return array + */ + protected function initializeFilterColumns() + { + return array( + t('Username') => 'user_name', + t('User Group') => 'group_name', + t('Created At') => 'created_at', + t('Last modified') => 'last_modified' + ); + } + + /** + * Initialize this repository's conversion rules + * + * @return array + */ + protected function initializeConversionRules() + { + $rules = array( + 'group' => array( + 'created_at' => 'generalized_time', + 'last_modified' => 'generalized_time' + ), + 'group_membership' => array( + 'created_at' => 'generalized_time', + 'last_modified' => 'generalized_time' + ) + ); + if (! $this->isMemberAttributeAmbiguous()) { + $rules['group_membership']['user_name'] = 'user_name'; + $rules['group_membership']['user'] = 'user_name'; + $rules['group']['user_name'] = 'user_name'; + $rules['group']['user'] = 'user_name'; + } + + return $rules; + } + + /** + * Return the distinguished name for the given uid or gid + * + * @param string $name + * + * @return string + */ + protected function persistUserName($name) + { + try { + $userDn = $this->ds + ->select() + ->from($this->userClass, array()) + ->where($this->userNameAttribute, $name) + ->setBase($this->userBaseDn) + ->setUsePagedResults(false) + ->fetchDn(); + if ($userDn) { + return $userDn; + } + + $groupDn = $this->ds + ->select() + ->from($this->groupClass, array()) + ->where($this->groupNameAttribute, $name) + ->setBase($this->groupBaseDn) + ->setUsePagedResults(false) + ->fetchDn(); + if ($groupDn) { + return $groupDn; + } + } catch (LdapException $_) { + // pass + } + + Logger::debug('Unable to persist uid or gid "%s" in repository "%s". No DN found.', $name, $this->getName()); + return $name; + } + + /** + * Return the uid for the given distinguished name + * + * @param string $username + * + * @return string + */ + protected function retrieveUserName($dn) + { + return $this->ds + ->select() + ->from('*', array($this->userNameAttribute)) + ->setUnfoldAttribute($this->userNameAttribute) + ->setBase($dn) + ->fetchOne(); + } + + /** + * Validate that the requested table exists + * + * @param string $table The table to validate + * @param RepositoryQuery $query An optional query to pass as context + * + * @return string + * + * @throws ProgrammingError In case the given table does not exist + */ + public function requireTable($table, RepositoryQuery $query = null) + { + if ($query !== null) { + $query->getQuery()->setBase($this->groupBaseDn); + if ($table === 'group' && $this->groupFilter) { + $query->getQuery()->setNativeFilter($this->groupFilter); + } + } + + return parent::requireTable($table, $query); + } + + /** + * Validate that the given column is a valid query target and return it or the actual name if it's an alias + * + * @param string $table The table where to look for the column or alias + * @param string $name The name or alias of the column to validate + * @param RepositoryQuery $query An optional query to pass as context + * + * @return string The given column's name + * + * @throws QueryException In case the given column is not a valid query column + */ + public function requireQueryColumn($table, $name, RepositoryQuery $query = null) + { + $column = parent::requireQueryColumn($table, $name, $query); + if (($name === 'user_name' || $name === 'group_name') && $query !== null) { + $query->getQuery()->setUnfoldAttribute($name); + } + + return $column; + } + + /** + * Return the groups the given user is a member of + * + * @param User $user + * + * @return array + */ + public function getMemberships(User $user) + { + $domain = $this->getDomain(); + + if ($domain !== null) { + if (! $user->hasDomain() || strtolower($user->getDomain()) !== strtolower($domain)) { + return array(); + } + + $username = $user->getLocalUsername(); + } else { + $username = $user->getUsername(); + } + + if ($this->isMemberAttributeAmbiguous()) { + $queryValue = $username; + } elseif (($queryValue = $user->getAdditional('ldap_dn')) === null) { + $userQuery = $this->ds + ->select() + ->from($this->userClass) + ->where($this->userNameAttribute, $username) + ->setBase($this->userBaseDn) + ->setUsePagedResults(false); + if ($this->userFilter) { + $userQuery->setNativeFilter($this->userFilter); + } + + if (($queryValue = $userQuery->fetchDn()) === null) { + return array(); + } + } + + if ($this->nestedGroupSearch) { + $groupMemberAttribute = $this->groupMemberAttribute . ':1.2.840.113556.1.4.1941:'; + } else { + $groupMemberAttribute = $this->groupMemberAttribute; + } + + $groupQuery = $this->ds + ->select() + ->from($this->groupClass, array($this->groupNameAttribute)) + ->setUnfoldAttribute($this->groupNameAttribute) + ->where($groupMemberAttribute, $queryValue) + ->setBase($this->groupBaseDn); + if ($this->groupFilter) { + $groupQuery->setNativeFilter($this->groupFilter); + } + + $groups = array(); + foreach ($groupQuery as $row) { + $groups[] = $row->{$this->groupNameAttribute}; + if ($domain !== null) { + $groups[] = $row->{$this->groupNameAttribute} . "@$domain"; + } + } + + return $groups; + } + + /** + * Return the name of the backend that is providing the given user + * + * @param string $username Unused + * + * @return null|string The name of the backend or null in case this information is not available + */ + public function getUserBackendName($username) + { + $userBackend = $this->getUserBackend(); + if ($userBackend !== null) { + return $userBackend->getName(); + } + } + + /** + * Apply the given configuration on this backend + * + * @param ConfigObject $config + * + * @return $this + * + * @throws ConfigurationError In case a linked user backend does not exist or is invalid + */ + public function setConfig(ConfigObject $config) + { + if ($config->backend === 'ldap') { + $defaults = $this->getOpenLdapDefaults(); + } elseif ($config->backend === 'msldap') { + $defaults = $this->getActiveDirectoryDefaults(); + } else { + $defaults = new ConfigObject(); + } + + if ($config->user_backend && $config->user_backend !== 'none') { + $userBackend = UserBackend::create($config->user_backend); + if (! $userBackend instanceof LdapUserBackend) { + throw new ConfigurationError('User backend "%s" is not of type LDAP', $config->user_backend); + } + + if ($this->ds->getHostname() !== $userBackend->getDataSource()->getHostname() + || $this->ds->getPort() !== $userBackend->getDataSource()->getPort() + ) { + // TODO(jom): Elaborate whether it makes sense to link directories on different hosts + throw new ConfigurationError( + 'It is required that a linked user backend refers to the ' + . 'same directory as it\'s user group backend counterpart' + ); + } + + $this->setUserBackend($userBackend); + $defaults->merge(array( + 'user_base_dn' => $userBackend->getBaseDn(), + 'user_class' => $userBackend->getUserClass(), + 'user_name_attribute' => $userBackend->getUserNameAttribute(), + 'user_filter' => $userBackend->getFilter(), + 'domain' => $userBackend->getDomain() + )); + } + + return $this + ->setGroupBaseDn($config->base_dn) + ->setUserBaseDn($config->get('user_base_dn', $defaults->get('user_base_dn', $this->getGroupBaseDn()))) + ->setGroupClass($config->get('group_class', $defaults->group_class)) + ->setUserClass($config->get('user_class', $defaults->user_class)) + ->setGroupNameAttribute($config->get('group_name_attribute', $defaults->group_name_attribute)) + ->setUserNameAttribute($config->get('user_name_attribute', $defaults->user_name_attribute)) + ->setGroupMemberAttribute($config->get('group_member_attribute', $defaults->group_member_attribute)) + ->setGroupFilter($config->group_filter) + ->setUserFilter($config->user_filter) + ->setNestedGroupSearch((bool) $config->get('nested_group_search', $defaults->nested_group_search)) + ->setDomain($defaults->get('domain', $config->domain)); + } + + /** + * Return the configuration defaults for an OpenLDAP environment + * + * @return ConfigObject + */ + public function getOpenLdapDefaults() + { + return new ConfigObject(array( + 'group_class' => 'group', + 'user_class' => 'inetOrgPerson', + 'group_name_attribute' => 'gid', + 'user_name_attribute' => 'uid', + 'group_member_attribute' => 'member', + 'nested_group_search' => '0' + )); + } + + /** + * Return the configuration defaults for an ActiveDirectory environment + * + * @return ConfigObject + */ + public function getActiveDirectoryDefaults() + { + return new ConfigObject(array( + 'group_class' => 'group', + 'user_class' => 'user', + 'group_name_attribute' => 'sAMAccountName', + 'user_name_attribute' => 'sAMAccountName', + 'group_member_attribute' => 'member', + 'nested_group_search' => '0' + )); + } + + /** + * Inspect if this LDAP User Group Backend is working as expected by probing the backend + * + * Try to bind to the backend and fetch a single group to check if: + * <ul> + * <li>Connection credentials are correct and the bind is possible</li> + * <li>At least one group exists</li> + * <li>The specified groupClass has the property specified by groupNameAttribute</li> + * </ul> + * + * @return Inspection Inspection result + */ + public function inspect() + { + $result = new Inspection('Ldap User Group Backend'); + + // inspect the used connection to get more diagnostic info in case the connection is not working + $result->write($this->ds->inspect()); + + try { + try { + $groupQuery = $this->ds + ->select() + ->from($this->groupClass, array($this->groupNameAttribute)) + ->setBase($this->groupBaseDn); + + if ($this->groupFilter) { + $groupQuery->setNativeFilter($this->groupFilter); + } + + $res = $groupQuery->fetchRow(); + } catch (LdapException $e) { + throw new AuthenticationException('Connection not possible', $e); + } + + $result->write('Searching for: ' . sprintf( + 'objectClass "%s" in DN "%s" (Filter: %s)', + $this->groupClass, + $this->groupBaseDn ?: $this->ds->getDn(), + $this->groupFilter ?: 'None' + )); + + if ($res === false) { + throw new AuthenticationException('Error, no groups found in backend'); + } + + $result->write(sprintf('%d groups found in backend', $groupQuery->count())); + + if (! isset($res->{$this->groupNameAttribute})) { + throw new AuthenticationException( + 'GroupNameAttribute "%s" not existing in objectClass "%s"', + $this->groupNameAttribute, + $this->groupClass + ); + } + } catch (AuthenticationException $e) { + if (($previous = $e->getPrevious()) !== null) { + $result->error($previous->getMessage()); + } else { + $result->error($e->getMessage()); + } + } catch (Exception $e) { + $result->error(sprintf('Unable to validate backend: %s', $e->getMessage())); + } + + return $result; + } +} diff --git a/library/Icinga/Authentication/UserGroup/UserGroupBackend.php b/library/Icinga/Authentication/UserGroup/UserGroupBackend.php new file mode 100644 index 0000000..7f0bfcc --- /dev/null +++ b/library/Icinga/Authentication/UserGroup/UserGroupBackend.php @@ -0,0 +1,189 @@ +<?php +/* Icinga Web 2 | (c) 2014 Icinga Development Team | GPLv2+ */ + +namespace Icinga\Authentication\UserGroup; + +use Icinga\Application\Logger; +use Icinga\Application\Icinga; +use Icinga\Data\ConfigObject; +use Icinga\Data\ResourceFactory; +use Icinga\Exception\ConfigurationError; + +/** + * Factory for user group backends + */ +class UserGroupBackend +{ + /** + * The default user group backend types provided by Icinga Web 2 + * + * @var array + */ + protected static $defaultBackends = array( + 'db', + 'ldap', + 'msldap' + ); + + /** + * The registered custom user group backends with their identifier as key and class name as value + * + * @var array + */ + protected static $customBackends; + + /** + * Register all custom user group backends from all loaded modules + */ + public static function registerCustomUserGroupBackends() + { + if (static::$customBackends !== null) { + return; + } + + static::$customBackends = array(); + $providedBy = array(); + foreach (Icinga::app()->getModuleManager()->getLoadedModules() as $module) { + foreach ($module->getUserGroupBackends() as $identifier => $className) { + if (array_key_exists($identifier, $providedBy)) { + Logger::warning( + 'Cannot register user group backend of type "%s" provided by module "%s".' + . ' The type is already provided by module "%s"', + $identifier, + $module->getName(), + $providedBy[$identifier] + ); + } elseif (in_array($identifier, static::$defaultBackends)) { + Logger::warning( + 'Cannot register user group backend of type "%s" provided by module "%s".' + . ' The type is a default type provided by Icinga Web 2', + $identifier, + $module->getName() + ); + } else { + $providedBy[$identifier] = $module->getName(); + static::$customBackends[$identifier] = $className; + } + } + } + } + + /** + * Get config forms of all custom user group backends + */ + public static function getCustomBackendConfigForms() + { + $customBackendConfigForms = []; + static::registerCustomUserGroupBackends(); + foreach (self::$customBackends as $customBackendType => $customBackendClass) { + if (method_exists($customBackendClass, 'getConfigurationFormClass')) { + $customBackendConfigForms[$customBackendType] = $customBackendClass::getConfigurationFormClass(); + } + } + + return $customBackendConfigForms; + } + + /** + * Return the class for the given custom user group backend + * + * @param string $identifier The identifier of the custom user group backend + * + * @return string|null The name of the class or null in case there was no + * backend found with the given identifier + * + * @throws ConfigurationError In case the class associated to the given identifier does not exist + */ + protected static function getCustomUserGroupBackend($identifier) + { + static::registerCustomUserGroupBackends(); + if (array_key_exists($identifier, static::$customBackends)) { + $className = static::$customBackends[$identifier]; + if (! class_exists($className)) { + throw new ConfigurationError( + 'Cannot utilize user group backend of type "%s". Class "%s" does not exist', + $identifier, + $className + ); + } + + return $className; + } + } + + /** + * Create and return a user group backend with the given name and given configuration applied to it + * + * @param string $name + * @param ConfigObject $backendConfig + * + * @return UserGroupBackendInterface + * + * @throws ConfigurationError + */ + public static function create($name, ConfigObject $backendConfig) + { + if ($backendConfig->name !== null) { + $name = $backendConfig->name; + } + + if (! ($backendType = strtolower($backendConfig->backend))) { + throw new ConfigurationError( + 'Configuration for user group backend "%s" is missing the \'backend\' directive', + $name + ); + } + if (in_array($backendType, static::$defaultBackends)) { + // The default backend check is the first one because of performance reasons: + // Do not attempt to load a custom user group backend unless it's actually required + } elseif (($customClass = static::getCustomUserGroupBackend($backendType)) !== null) { + $backend = new $customClass($backendConfig); + if (! is_a($backend, 'Icinga\Authentication\UserGroup\UserGroupBackendInterface')) { + throw new ConfigurationError( + 'Cannot utilize user group backend of type "%s".' + . ' Class "%s" does not implement UserGroupBackendInterface', + $backendType, + $customClass + ); + } + + $backend->setName($name); + return $backend; + } else { + throw new ConfigurationError( + 'Configuration for user group backend "%s" defines an invalid backend type.' + . ' Backend type "%s" is not supported', + $name, + $backendType + ); + } + + if ($backendConfig->resource === null) { + throw new ConfigurationError( + 'Configuration for user group backend "%s" is missing the \'resource\' directive', + $name + ); + } + + $resourceConfig = ResourceFactory::getResourceConfig($backendConfig->resource); + if ($backendType === 'db' && $resourceConfig->db === 'mysql') { + $resourceConfig->charset = 'utf8mb4'; + } + + $backend = null; + $resource = ResourceFactory::createResource($resourceConfig); + switch ($backendType) { + case 'db': + $backend = new DbUserGroupBackend($resource); + break; + case 'ldap': + case 'msldap': + $backend = new LdapUserGroupBackend($resource); + $backend->setConfig($backendConfig); + break; + } + + $backend->setName($name); + return $backend; + } +} diff --git a/library/Icinga/Authentication/UserGroup/UserGroupBackendInterface.php b/library/Icinga/Authentication/UserGroup/UserGroupBackendInterface.php new file mode 100644 index 0000000..cc9438f --- /dev/null +++ b/library/Icinga/Authentication/UserGroup/UserGroupBackendInterface.php @@ -0,0 +1,56 @@ +<?php +/* Icinga Web 2 | (c) 2015 Icinga Development Team | GPLv2+ */ + +namespace Icinga\Authentication\UserGroup; + +use Icinga\User; + +/** + * Interface for user group backends + */ +interface UserGroupBackendInterface +{ + /** + * Set this backend's name + * + * @param string $name + * + * @return $this + */ + public function setName($name); + + /** + * Return this backend's name + * + * @return string + */ + public function getName(); + + /** + * Return the groups the given user is a member of + * + * @param User $user + * + * @return array + */ + public function getMemberships(User $user); + + /** + * Return the name of the backend that is providing the given user + * + * @param string $username + * + * @return null|string The name of the backend or null in case this information is not available + */ + public function getUserBackendName($username); + + /** + * Return this backend's configuration form class path + * + * This is not part of the interface to not break existing implementations. + * If you need a custom backend form, implement this method. + * + * @return string + */ + //public static function getConfigurationFormClass(); +} diff --git a/library/Icinga/Chart/Axis.php b/library/Icinga/Chart/Axis.php new file mode 100644 index 0000000..1639939 --- /dev/null +++ b/library/Icinga/Chart/Axis.php @@ -0,0 +1,485 @@ +<?php +/* Icinga Web 2 | (c) 2013 Icinga Development Team | GPLv2+ */ + +namespace Icinga\Chart; + +use DOMElement; +use Icinga\Chart\Primitive\Drawable; +use Icinga\Chart\Primitive\Line; +use Icinga\Chart\Primitive\Text; +use Icinga\Chart\Render\RenderContext; +use Icinga\Chart\Render\Rotator; +use Icinga\Chart\Unit\AxisUnit; +use Icinga\Chart\Unit\CalendarUnit; +use Icinga\Chart\Unit\LinearUnit; + +/** + * Axis class for the GridChart class. + * + * Implements drawing functions for the axis and its labels but delegates tick and label calculations + * to the AxisUnit implementations + * + * @see GridChart + * @see AxisUnit + */ +class Axis implements Drawable +{ + /** + * Draw the label text horizontally + */ + const LABEL_ROTATE_HORIZONTAL = 'normal'; + + /** + * Draw the label text diagonally + */ + const LABEL_ROTATE_DIAGONAL = 'diagonal'; + + /** + * Whether to draw the horizontal lines for the background grid + * + * @var bool + */ + private $drawXGrid = true; + + /** + * Whether to draw the vertical lines for the background grid + * + * @var bool + */ + private $drawYGrid = true; + + /** + * The label for the x axis + * + * @var string + */ + private $xLabel = ""; + + /** + * The label for the y axis + * + * @var string + */ + private $yLabel = ""; + + /** + * The AxisUnit implementation to use for calculating the ticks for the x axis + * + * @var AxisUnit + */ + private $xUnit = null; + + /** + * The AxisUnit implementation to use for calculating the ticks for the y axis + * + * @var AxisUnit + */ + private $yUnit = null; + + /** + * The minimum amount of units each step must take up + * + * @var int + */ + public $minUnitsPerStep = 80; + + /** + * The minimum amount of units each tick must take up + * + * @var int + */ + public $minUnitsPerTick = 15; + + /** + * If the displayed labels should be aligned horizontally or diagonally + */ + protected $labelRotationStyle = self::LABEL_ROTATE_HORIZONTAL; + + /** + * Inform the axis about an added dataset + * + * This is especially needed when one or more AxisUnit implementations dynamically define + * their min or max values, as this is the point where they detect the min and max value + * from the datasets + * + * @param array $dataset An dataset to respect on axis generation + */ + public function addDataset(array $dataset) + { + $this->xUnit->addValues($dataset, 0); + $this->yUnit->addValues($dataset, 1); + } + + /** + * Set the AxisUnit implementation to use for generating the x axis + * + * @param AxisUnit $unit The AxisUnit implementation to use for the x axis + * + * @return $this This Axis Object + * @see Axis::CalendarUnit + * @see Axis::LinearUnit + */ + public function setUnitForXAxis(AxisUnit $unit) + { + $this->xUnit = $unit; + return $this; + } + + /** + * Set the AxisUnit implementation to use for generating the y axis + * + * @param AxisUnit $unit The AxisUnit implementation to use for the y axis + * + * @return $this This Axis Object + * @see Axis::CalendarUnit + * @see Axis::LinearUnit + */ + public function setUnitForYAxis(AxisUnit $unit) + { + $this->yUnit = $unit; + return $this; + } + + /** + * Return the padding this axis requires + * + * @return array An array containing the padding for all sides + */ + public function getRequiredPadding() + { + return array(10, 5, 15, 10); + } + + /** + * Render the horizontal axis + * + * @param RenderContext $ctx The context to use for rendering + * @param DOMElement $group The DOMElement this axis will be added to + */ + private function renderHorizontalAxis(RenderContext $ctx, DOMElement $group) + { + $steps = $this->ticksPerX($this->xUnit->getTicks(), $ctx->getNrOfUnitsX(), $this->minUnitsPerStep); + $ticks = $this->ticksPerX($this->xUnit->getTicks(), $ctx->getNrOfUnitsX(), $this->minUnitsPerTick); + + // Steps should always be ticks + if ($ticks !== $steps) { + $steps = $ticks * 5; + } + + // Check whether there is enough room for regular labels + $labelRotationStyle = $this->labelRotationStyle; + if ($this->labelsOversized($this->xUnit, 6)) { + $labelRotationStyle = self::LABEL_ROTATE_DIAGONAL; + } + + /* + $line = new Line(0, 100, 100, 100); + $line->setStrokeWidth(2); + $group->appendChild($line->toSvg($ctx)); + */ + + // contains the approximate end position of the last label + $lastLabelEnd = -1; + $shift = 0; + + $i = 0; + foreach ($this->xUnit as $label => $pos) { + if ($i % $ticks === 0) { + /* + $tick = new Line($pos, 100, $pos, 101); + $group->appendChild($tick->toSvg($ctx)); + */ + } + + if ($i % $steps === 0) { + if ($labelRotationStyle === self::LABEL_ROTATE_HORIZONTAL) { + // If the last label would overlap this label we shift the y axis a bit + if ($lastLabelEnd > $pos) { + $shift = ($shift + 5) % 10; + } else { + $shift = 0; + } + } + + $labelField = new Text($pos + 0.5, ($this->xLabel ? 107 : 105) + $shift, $label); + if ($labelRotationStyle === self::LABEL_ROTATE_HORIZONTAL) { + $labelField->setAlignment(Text::ALIGN_MIDDLE) + ->setFontSize('2.5em'); + } else { + $labelField->setFontSize('2.5em'); + } + + if ($labelRotationStyle === self::LABEL_ROTATE_DIAGONAL) { + $labelField = new Rotator($labelField, 45); + } + $labelField = $labelField->toSvg($ctx); + + $group->appendChild($labelField); + + if ($this->drawYGrid) { + $bgLine = new Line($pos, 0, $pos, 100); + $bgLine->setStrokeWidth(0.5) + ->setStrokeColor('#BFBFBF'); + $group->appendChild($bgLine->toSvg($ctx)); + } + $lastLabelEnd = $pos + strlen($label) * 1.2; + } + $i++; + } + } + + /** + * Render the vertical axis + * + * @param RenderContext $ctx The context to use for rendering + * @param DOMElement $group The DOMElement this axis will be added to + */ + private function renderVerticalAxis(RenderContext $ctx, DOMElement $group) + { + $steps = $this->ticksPerX($this->yUnit->getTicks(), $ctx->getNrOfUnitsY(), $this->minUnitsPerStep); + $ticks = $this->ticksPerX($this->yUnit->getTicks(), $ctx->getNrOfUnitsY(), $this->minUnitsPerTick); + + // Steps should always be ticks + if ($ticks !== $steps) { + $steps = $ticks * 5; + } + /* + $line = new Line(0, 0, 0, 100); + $line->setStrokeWidth(2); + $group->appendChild($line->toSvg($ctx)); + */ + + $i = 0; + foreach ($this->yUnit as $label => $pos) { + $pos = 100 - $pos; + + if ($i % $ticks === 0) { + // draw a tick + //$tick = new Line(0, $pos, -1, $pos); + //$group->appendChild($tick->toSvg($ctx)); + } + + if ($i % $steps === 0) { + // draw a step + $labelField = new Text(-0.5, $pos + 0.5, $label); + $labelField->setFontSize('2.5em') + ->setAlignment(Text::ALIGN_END); + + $group->appendChild($labelField->toSvg($ctx)); + if ($this->drawXGrid) { + $bgLine = new Line(0, $pos, 100, $pos); + $bgLine->setStrokeWidth(0.5) + ->setStrokeColor('#BFBFBF'); + $group->appendChild($bgLine->toSvg($ctx)); + } + } + $i++; + } + + if ($this->yLabel || $this->xLabel) { + if ($this->yLabel && $this->xLabel) { + $txt = $this->yLabel . ' / ' . $this->xLabel; + } elseif ($this->xLabel) { + $txt = $this->xLabel; + } else { + $txt = $this->yLabel; + } + + $axisLabel = new Text(50, -3, $txt); + $axisLabel->setFontSize('2em') + ->setFontWeight('bold') + ->setAlignment(Text::ALIGN_MIDDLE); + + $group->appendChild($axisLabel->toSvg($ctx)); + } + } + + /** + * Factory method, create an Axis instance using Linear ticks as the unit + * + * @return Axis The axis that has been created + * @see LinearUnit + */ + public static function createLinearAxis() + { + $axis = new Axis(); + $axis->setUnitForXAxis(self::linearUnit()); + $axis->setUnitForYAxis(self::linearUnit()); + return $axis; + } + + /** + * Set the label for the x axis + * + * An empty string means 'no label'. + * + * @param string $label The label to use for the x axis + * + * @return $this Fluid interface + */ + public function setXLabel($label) + { + $this->xLabel = $label; + return $this; + } + + /** + * Set the label for the y axis + * + * An empty string means 'no label'. + * + * @param string $label The label to use for the y axis + * + * @return $this Fluid interface + */ + public function setYLabel($label) + { + $this->yLabel = $label; + return $this; + } + + /** + * Set the labels minimum value for the x axis + * + * Setting the value to null let's the axis unit decide which value to use for the minimum + * + * @param int $xMin The minimum value to use for the x axis + * + * @return $this Fluid interface + */ + public function setXMin($xMin) + { + $this->xUnit->setMin($xMin); + return $this; + } + + /** + * Set the labels minimum value for the y axis + * + * Setting the value to null let's the axis unit decide which value to use for the minimum + * + * @param int $yMin The minimum value to use for the x axis + * + * @return $this Fluid interface + */ + public function setYMin($yMin) + { + $this->yUnit->setMin($yMin); + return $this; + } + + /** + * Set the labels maximum value for the x axis + * + * Setting the value to null let's the axis unit decide which value to use for the maximum + * + * @param int $xMax The minimum value to use for the x axis + * + * @return $this Fluid interface + */ + public function setXMax($xMax) + { + $this->xUnit->setMax($xMax); + return $this; + } + + /** + * Set the labels maximum value for the y axis + * + * Setting the value to null let's the axis unit decide which value to use for the maximum + * + * @param int $yMax The minimum value to use for the y axis + * + * @return $this Fluid interface + */ + public function setYMax($yMax) + { + $this->yUnit->setMax($yMax); + return $this; + } + + /** + * Transform all coordinates of the given dataset to coordinates that fit the graph's coordinate system + * + * @param array $dataSet The absolute coordinates as provided in the draw call + * + * @return array A graph relative representation of the given coordinates + */ + public function transform(array &$dataSet) + { + $result = array(); + foreach ($dataSet as &$points) { + $result[] = array( + $this->xUnit->transform($points[0]), + 100 - $this->yUnit->transform($points[1]) + ); + } + return $result; + } + + /** + * Create an AxisUnit that can be used in the axis to represent timestamps + * + * @return CalendarUnit + */ + public static function calendarUnit() + { + return new CalendarUnit(); + } + + /** + * Create an AxisUnit that can be used in the axis to represent a dataset as equally distributed + * ticks + * + * @param int $ticks + * @return LinearUnit + */ + public static function linearUnit($ticks = 10) + { + return new LinearUnit($ticks); + } + + /** + * Return the SVG representation of this object + * + * @param RenderContext $ctx The context to use for calculations + * + * @return DOMElement + * @see Drawable::toSvg + */ + public function toSvg(RenderContext $ctx) + { + $group = $ctx->getDocument()->createElement('g'); + $this->renderHorizontalAxis($ctx, $group); + $this->renderVerticalAxis($ctx, $group); + return $group; + } + + protected function ticksPerX($ticks, $units, $min) + { + $per = 1; + while ($per * $units / $ticks < $min) { + $per++; + } + return $per; + } + + /** + * Returns whether at least one label of the given Axis + * is bigger than the given maxLength + * + * @param AxisUnit $axis The axis that contains the labels that will be checked + * + * @return boolean Whether at least one label is bigger than maxLength + */ + private function labelsOversized(AxisUnit $axis, $maxLength = 5) + { + $oversized = false; + foreach ($axis as $label => $pos) { + if (strlen($label) > $maxLength) { + $oversized = true; + } + } + return $oversized; + } +} diff --git a/library/Icinga/Chart/Chart.php b/library/Icinga/Chart/Chart.php new file mode 100644 index 0000000..eaf69d1 --- /dev/null +++ b/library/Icinga/Chart/Chart.php @@ -0,0 +1,162 @@ +<?php +/* Icinga Web 2 | (c) 2013 Icinga Development Team | GPLv2+ */ + +namespace Icinga\Chart; + +use Imagick; +use Icinga\Chart\Legend; +use Icinga\Chart\Palette; +use Icinga\Chart\Primitive\Drawable; +use Icinga\Chart\SVGRenderer; +use Icinga\Exception\IcingaException; + +/** + * Base class for charts, extended by all other Chart classes. + */ +abstract class Chart implements Drawable +{ + protected $align = false; + + /** + * SVG renderer that handles + * + * @var SVGRenderer + */ + protected $renderer; + + /** + * Legend to use for this chart + * + * @var Legend + */ + protected $legend; + + /** + * The style-palette for this chart + * + * @var Palette + */ + protected $palette; + + /** + * The title of this chart, used for providing accessibility features + * + * @var string + */ + public $title; + + /** + * The description for this chart, mandatory for providing accessibility features + * + * @var string + */ + public $description; + + /** + * Create a new chart object and create internal objects + * + * If you want to extend this class use the init() method as an extension point, + * as this will be called at the end of the construct call + */ + public function __construct() + { + $this->legend = new Legend(); + $this->palette = new Palette(); + $this->init(); + } + + /** + * Extension point for subclasses, called on __construct + */ + protected function init() + { + } + + /** + * Extension point for implementing rendering logic + * + * This method is called after data validation, but before toSvg is called + */ + protected function build() + { + } + + /** + * Check if the current dataset has the proper structure for this chart. + * + * Needs to be overwritten by extending classes. The default implementation returns false. + * + * @return bool True when the dataset is valid, otherwise false + */ + abstract public function isValidDataFormat(); + + + /** + * Disable the legend for this chart + */ + public function disableLegend() + { + $this->legend = null; + } + + /** + * Render this graph and return the created SVG + * + * @return string The SVG created by the SvgRenderer + * + * @throws IcingaException Thrown wen the dataset is not valid for this graph + * @see SVGRenderer::render + */ + public function render() + { + if (!$this->isValidDataFormat()) { + throw new IcingaException('Dataset for graph doesn\'t have the proper structure'); + } + $this->build(); + if ($this->align) { + $this->renderer->preserveAspectRatio(); + $this->renderer->setXAspectRatioAlignment(SVGRenderer::X_ASPECT_RATIO_MIN); + $this->renderer->setYAspectRatioAlignment(SVGRenderer::Y_ASPECT_RATIO_MIN); + } + + $this->renderer->setAriaDescription($this->description); + $this->renderer->setAriaTitle($this->title); + $this->renderer->getCanvas()->setAriaRole('presentation'); + + $this->renderer->getCanvas()->addElement($this); + return $this->renderer->render(); + } + + /** + * Return this graph rendered as PNG + * + * @param int $width The width of the PNG in pixel + * @param int $height The height of the PNG in pixel + * + * @return string A PNG binary string + * + * @throws IcingaException In case ImageMagick is not available + */ + public function toPng($width, $height) + { + if (! class_exists('Imagick')) { + throw new IcingaException('Cannot render PNGs without ImageMagick'); + } + + $image = new Imagick(); + $image->readImageBlob($this->render()); + $image->setImageFormat('png24'); + $image->resizeImage($width, $height, imagick::FILTER_LANCZOS, 1); + return $image; + } + + /** + * Align the chart to the top left corner instead of centering it + * + * @param bool $align + */ + public function alignTopLeft($align = true) + { + $this->align = $align; + } +} diff --git a/library/Icinga/Chart/Donut.php b/library/Icinga/Chart/Donut.php new file mode 100644 index 0000000..9d2a2a8 --- /dev/null +++ b/library/Icinga/Chart/Donut.php @@ -0,0 +1,465 @@ +<?php +/* Icinga Web 2 | (c) 2017 Icinga Development Team | GPLv2+ */ + +namespace Icinga\Chart; + +use Icinga\Web\Url; + +/** Donut chart implementation */ +class Donut +{ + /** + * Big label in the middle of the donut, color is critical (red) + * + * @var string + */ + protected $labelBig; + + /** + * Url behind the big label + * + * @var Url + */ + protected $labelBigUrl; + + /** + * The state the big label shall indicate + * + * @var string|null + */ + protected $labelBigState = 'critical'; + + /** + * Small label in the lower part of the donuts hole + * + * @var string + */ + protected $labelSmall; + + /** + * Thickness of the donut ring + * + * @var int + */ + protected $thickness = 6; + + /** + * Radius based of 100 to simplify the calculations + * + * 100 / (2 * M_PI) + * + * @var float + */ + protected $radius = 15.9154943092; + + /** + * Color of the hole in the donut + * + * Transparent by default so it can be placed anywhere with ease + * + * @var string + */ + protected $centerColor = 'transparent'; + + /** + * The different colored parts that represent the data + * + * @var array + */ + protected $slices = array(); + + /** + * The total amount of data units + * + * @var int + */ + protected $count = 0; + + /** + * Adds a colored part that represent the data + * + * @param integer $data Units of data + * @param array $attributes HTML attributes for this slice. (For example ['class' => 'slice-state-ok']) + * + * @return $this + */ + public function addSlice($data, $attributes = array()) + { + $this->slices[] = array($data, $attributes); + + $this->count += $data; + + return $this; + } + + /** + * Set the thickness for this Donut + * + * @param integer $thickness + * + * @return $this + */ + public function setThickness($thickness) + { + $this->thickness = $thickness; + + return $this; + } + /** + * Get the thickness for this Donut + * + * @return integer + */ + public function getThickness() + { + return $this->thickness; + } + + /** + * Set the center color for this Donut + * + * @param string $centerColor + * + * @return $this + */ + public function setCenterColor($centerColor) + { + $this->centerColor = $centerColor; + + return $this; + } + + /** + * Get the center color for this Donut + * + * @return string + */ + public function getCenterColor() + { + return $this->centerColor; + } + + /** + * Set the text of the big label + * + * @param string $labelBig + * + * @return $this + */ + public function setLabelBig($labelBig) + { + $this->labelBig = $labelBig; + + return $this; + } + + /** + * Get the text of the big label + * + * @return string + */ + public function getLabelBig() + { + return $this->labelBig; + } + + /** + * Set the url behind the big label + * + * @param Url $labelBigUrl + * + * @return $this + */ + public function setLabelBigUrl($labelBigUrl) + { + $this->labelBigUrl = $labelBigUrl; + + return $this; + } + + /** + * Get the url behind the big label + * + * @return Url + */ + public function getLabelBigUrl() + { + return $this->labelBigUrl; + } + + /** + * Get whether the big label shall be eye-catching + * + * @return bool + */ + public function getLabelBigEyeCatching() + { + return $this->labelBigState !== null; + } + + /** + * Set whether the big label shall be eye-catching + * + * @param bool $labelBigEyeCatching + * + * @return $this + */ + public function setLabelBigEyeCatching($labelBigEyeCatching = true) + { + $this->labelBigState = $labelBigEyeCatching ? 'critical' : null; + + return $this; + } + + /** + * Get the state the big label shall indicate + * + * @return string|null + */ + public function getLabelBigState() + { + return $this->labelBigState; + } + + /** + * Set the state the big label shall indicate + * + * @param string|null $labelBigState + * + * @return $this + */ + public function setLabelBigState($labelBigState) + { + $this->labelBigState = $labelBigState; + + return $this; + } + + /** + * Set the text of the small label + * + * @param string $labelSmall + * + * @return $this + */ + public function setLabelSmall($labelSmall) + { + $this->labelSmall = $labelSmall; + + return $this; + } + + /** + * Get the text of the small label + * + * @return string + */ + public function getLabelSmall() + { + return $this->labelSmall; + } + + /** + * Put together all slices of this Donut + * + * @return array $svg + */ + protected function assemble() + { + // svg tag containing the ring + $svg = array( + 'tag' => 'svg', + 'attributes' => array( + 'xmlns' => 'http://www.w3.org/2000/svg', + 'viewbox' => '0 0 40 40', + 'class' => 'donut-graph' + ), + 'content' => array() + ); + + // Donut hole + $svg['content'][] = array( + 'tag' => 'circle', + 'attributes' => array( + 'cx' => 20, + 'cy' => 20, + 'r' => sprintf('%F', $this->radius), + 'fill' => $this->getCenterColor() + ) + ); + + // When there is no data show gray circle + $svg['content'][] = array( + 'tag' => 'circle', + 'attributes' => array( + 'aria-hidden' => true, + 'cx' => 20, + 'cy' => 20, + 'r' => sprintf('%F', $this->radius), + 'fill' => $this->getCenterColor(), + 'stroke-width' => $this->getThickness(), + 'class' => 'slice-state-not-checked' + ) + ); + + $slices = $this->slices; + + if ($this->count !== 0) { + array_walk($slices, function (&$slice) { + $slice[0] = round(100 / $this->count * $slice[0], 2); + }); + } + + // on 0 the donut would start at "3 o'clock" and the offset shifts counterclockwise + $offset = 25; + + foreach ($slices as $slice) { + $svg['content'][] = array( + 'tag' => 'circle', + 'attributes' => $slice[1] + array( + 'cx' => 20, + 'cy' => 20, + 'r' => sprintf('%F', $this->radius), + 'fill' => 'transparent', + 'stroke-width' => $this->getThickness(), + 'stroke-dasharray' => sprintf('%F', $slice[0]) + . ' ' + . sprintf('%F', (99.9 - $slice[0])), // 99.9 prevents gaps (slight overlap) + 'stroke-dashoffset' => sprintf('%F', $offset) + ) + ); + // negative values shift in the clockwise direction + $offset -= $slice[0]; + } + + $result = array( + 'tag' => 'div', + 'content' => array($svg) + ); + + $labelBig = (string) $this->getLabelBig(); + $labelSmall = (string) $this->getLabelSmall(); + + if ($labelBig !== '' || $labelSmall !== '') { + $labels = array( + 'tag' => 'div', + 'attributes' => array( + 'class' => 'donut-label' + ), + 'content' => array() + ); + + if ($labelBig !== '') { + $labels['content'][] = + array( + 'tag' => 'a', + 'attributes' => array( + 'aria-label' => $labelBig . ' ' . $labelSmall, + 'href' => $this->getLabelBigUrl() ? $this->getLabelBigUrl()->getAbsoluteUrl() : null, + 'class' => $this->labelBigState === null + ? 'donut-label-big' + : 'donut-label-big state-' . $this->labelBigState + ), + 'content' => $this->shortenLabel($labelBig) + ); + } + + if ($labelSmall !== '') { + $labels['content'][] = array( + 'tag' => 'p', + 'attributes' => array( + 'class' => 'donut-label-small', + 'x' => '50%', + 'y' => '50%' + ), + 'content' => $labelSmall + ); + } + + $result['content'][] = $labels; + } + + return $result; + } + + /** + * Shorten the label to 3 digits if it is numeric + * + * 10 => 10 ... 1111 => ~1k ... 1888 => ~2k + * + * @param int|string $label + * + * @return string + */ + protected function shortenLabel($label) + { + if (is_numeric($label) && strlen($label) > 3) { + return round($label, -3)/1000 . 'k'; + } + + return $label; + } + + protected function encode($content) + { + return htmlspecialchars($content, ENT_COMPAT | ENT_SUBSTITUTE | ENT_HTML5, 'UTF-8', true); + } + + protected function renderAttributes(array $attributes) + { + $html = array(); + + foreach ($attributes as $name => $value) { + if ($value === null) { + continue; + } + + if (is_bool($value) && $value) { + $html[] = $name; + continue; + } + + if (is_array($value)) { + $value = implode(' ', $value); + } + + $html[] = "$name=\"" . $this->encode($value) . '"'; + } + + return implode(' ', $html); + } + + protected function renderContent(array $element) + { + $tag = $element['tag']; + $attributes = isset($element['attributes']) ? $element['attributes'] : array(); + $content = isset($element['content']) ? $element['content'] : null; + + $html = array( + // rtrim because attributes may be empty + rtrim("<$tag " . $this->renderAttributes($attributes)) + . ">" + ); + + if ($content !== null) { + if (is_array($content)) { + foreach ($content as $child) { + $html[] = is_array($child) ? $this->renderContent($child) : $this->encode($child); + } + } else { + $html[] = $this->encode($content); + } + } + + $html[] = "</$tag>"; + + return implode("\n", $html); + } + + public function render() + { + $svg = $this->assemble(); + + return $this->renderContent($svg); + } +} diff --git a/library/Icinga/Chart/Format.php b/library/Icinga/Chart/Format.php new file mode 100644 index 0000000..9e6c4db --- /dev/null +++ b/library/Icinga/Chart/Format.php @@ -0,0 +1,21 @@ +<?php +/* Icinga Web 2 | (c) 2013 Icinga Development Team | GPLv2+ */ + +namespace Icinga\Chart; + +class Format +{ + /** + * Format a number into a number-string as defined by the SVG-Standard + * + * @see http://www.w3.org/TR/SVG/types.html#DataTypeNumber + * + * @param $number + * + * @return string + */ + public static function formatSVGNumber($number) + { + return number_format($number, 1, '.', ''); + } +} diff --git a/library/Icinga/Chart/Graph/BarGraph.php b/library/Icinga/Chart/Graph/BarGraph.php new file mode 100644 index 0000000..be142bf --- /dev/null +++ b/library/Icinga/Chart/Graph/BarGraph.php @@ -0,0 +1,163 @@ +<?php +/* Icinga Web 2 | (c) 2013 Icinga Development Team | GPLv2+ */ + +namespace Icinga\Chart\Graph; + +use DOMElement; +use Icinga\Chart\Primitive\Animation; +use Icinga\Chart\Primitive\Drawable; +use Icinga\Chart\Primitive\Rect; +use Icinga\Chart\Primitive\Styleable; +use Icinga\Chart\Render\RenderContext; + +/** + * Bar graph implementation + */ +class BarGraph extends Styleable implements Drawable +{ + /** + * The dataset order + * + * @var int + */ + private $order = 0; + + /** + * The width of the bars. + * + * @var int + */ + private $barWidth = 3; + + /** + * The dataset to use for this bar graph + * + * @var array + */ + private $dataSet; + + /** + * The tooltips + * + * @var + */ + private $tooltips; + + /** + * All graphs + * + * @var + */ + private $graphs; + + /** + * Create a new BarGraph with the given dataset + * + * @param array $dataSet An array of data points + * @param int $order The graph number displayed by this BarGraph + * @param array $tooltips The tooltips to display for each value + */ + public function __construct( + array $dataSet, + array &$graphs, + $order, + array $tooltips = null + ) { + $this->order = $order; + $this->dataSet = $dataSet; + + $this->tooltips = $tooltips; + $ts = []; + foreach ($this->tooltips as $value) { + $ts[] = $value; + } + $this->tooltips = $ts; + + $this->graphs = $graphs; + } + + /** + * Apply configuration styles from the $cfg + * + * @param array $cfg The configuration as given in the drawBars call + */ + public function setStyleFromConfig(array $cfg) + { + foreach ($cfg as $elem => $value) { + if ($elem === 'color') { + $this->setFill($value); + } elseif ($elem === 'width') { + $this->setStrokeWidth($value); + } + } + } + + /** + * Draw a single rectangle + * + * @param array $point The + * @param string $fill The fill color to use + * @param $strokeWidth + * @param ?int $index + * + * @return Rect + */ + private function drawSingleBar($point, $fill, $strokeWidth, $index = null) + { + $rect = new Rect($point[0] - ($this->barWidth / 2), $point[1], $this->barWidth, 100 - $point[1]); + $rect->setFill($fill); + $rect->setStrokeWidth($strokeWidth); + $rect->setStrokeColor('black'); + if (isset($index)) { + $rect->setAttribute('data-icinga-graph-index', $index); + } + $rect->setAttribute('data-icinga-graph-type', 'bar'); + $rect->setAdditionalStyle(['clip-path' => 'url(#clip)']); + return $rect; + } + + /** + * Render this BarChart + * + * @param RenderContext $ctx The rendering context to use for drawing + * + * @return DOMElement $dom Element + */ + public function toSvg(RenderContext $ctx) + { + $doc = $ctx->getDocument(); + $group = $doc->createElement('g'); + $idx = 0; + + if (count($this->dataSet) > 15) { + $this->barWidth = 2; + } + if (count($this->dataSet) > 25) { + $this->barWidth = 1; + } + + foreach ($this->dataSet as $x => $point) { + // add white background bar, to prevent other bars from altering transparency effects + $bar = $this->drawSingleBar($point, 'white', $this->strokeWidth, $idx++)->toSvg($ctx); + $group->appendChild($bar); + + // draw actual bar + $bar = $this->drawSingleBar($point, $this->fill, $this->strokeWidth)->toSvg($ctx); + if (isset($this->tooltips[$x])) { + $data = array( + 'label' => isset($this->graphs[$this->order]['label']) ? + strtolower($this->graphs[$this->order]['label']) : '', + 'color' => isset($this->graphs[$this->order]['color']) ? + strtolower($this->graphs[$this->order]['color']) : '#fff' + ); + $format = isset($this->graphs[$this->order]['tooltip']) + ? $this->graphs[$this->order]['tooltip'] : null; + $title = $ctx->getDocument()->createElement('title'); + $title->textContent = $this->tooltips[$x]->renderNoHtml($this->order, $data, $format); + $bar->appendChild($title); + } + $group->appendChild($bar); + } + return $group; + } +} diff --git a/library/Icinga/Chart/Graph/LineGraph.php b/library/Icinga/Chart/Graph/LineGraph.php new file mode 100644 index 0000000..21f930a --- /dev/null +++ b/library/Icinga/Chart/Graph/LineGraph.php @@ -0,0 +1,202 @@ +<?php +/* Icinga Web 2 | (c) 2013 Icinga Development Team | GPLv2+ */ + + +namespace Icinga\Chart\Graph; + +use DOMElement; +use Icinga\Chart\Primitive\Drawable; +use Icinga\Chart\Primitive\Path; +use Icinga\Chart\Primitive\Circle; +use Icinga\Chart\Primitive\Styleable; +use Icinga\Chart\Render\RenderContext; + +/** + * LineGraph implementation for drawing a set of datapoints as + * a connected path + */ +class LineGraph extends Styleable implements Drawable +{ + /** + * The dataset to use + * + * @var array + */ + private $dataset; + + /** + * True to show dots for each datapoint + * + * @var bool + */ + private $showDataPoints = false; + + /** + * When true, the path will be discrete, i.e. showing hard steps instead of a direct line + * + * @var bool + */ + private $isDiscrete = false; + + /** + * The tooltips + * + * @var + */ + private $tooltips; + + /** @var array */ + private $graphs; + + /** @var int */ + private $order; + + /** + * The default stroke width + * @var int + */ + public $strokeWidth = 5; + + /** + * The size of the displayed dots + * + * @var int + */ + public $dotWith = 0; + + /** + * Create a new LineGraph displaying the given dataset + * + * @param array $dataset An array of [x, y] arrays to display + */ + public function __construct( + array $dataset, + array &$graphs, + $order, + array $tooltips = null + ) { + usort($dataset, array($this, 'sortByX')); + $this->dataset = $dataset; + $this->graphs = $graphs; + + $this->tooltips = $tooltips; + $ts = []; + foreach ($this->tooltips as $value) { + $ts[] = $value; + } + $this->tooltips = $ts; + $this->order = $order; + } + + /** + * Set datapoints to be emphased via dots + * + * @param bool $bool True to enable datapoints, otherwise false + */ + public function setShowDataPoints($bool) + { + $this->showDataPoints = $bool; + } + + /** + * Sort the daset by the xaxis + * + * @param array $v1 + * @param array $v2 + * @return int + */ + private function sortByX(array $v1, array $v2) + { + if ($v1[0] === $v2[0]) { + return 0; + } + return ($v1[0] < $v2[0]) ? -1 : 1; + } + + /** + * Configure this style + * + * @param array $cfg The configuration as given in the drawLine call + */ + public function setStyleFromConfig(array $cfg) + { + $fill = false; + foreach ($cfg as $elem => $value) { + if ($elem === 'color') { + $this->setStrokeColor($value); + } elseif ($elem === 'width') { + $this->setStrokeWidth($value); + } elseif ($elem === 'showPoints') { + $this->setShowDataPoints($value); + } elseif ($elem === 'fill') { + $fill = $value; + } elseif ($elem === 'discrete') { + $this->isDiscrete = true; + } + } + if ($fill) { + $this->setFill($this->strokeColor); + $this->setStrokeColor('black'); + } + } + + /** + * Render this BarChart + * + * @param RenderContext $ctx The rendering context to use for drawing + * + * @return DOMElement $dom Element + */ + public function toSvg(RenderContext $ctx) + { + $path = new Path($this->dataset); + if ($this->isDiscrete) { + $path->setDiscrete(true); + } + $path->setStrokeColor($this->strokeColor); + $path->setStrokeWidth($this->strokeWidth); + + $path->setAttribute('data-icinga-graph-type', 'line'); + if ($this->fill !== 'none') { + $firstX = $this->dataset[0][0]; + $lastX = $this->dataset[count($this->dataset)-1][0]; + $path->prepend(array($firstX, 100)) + ->append(array($lastX, 100)); + $path->setFill($this->fill); + } + + $path->setAdditionalStyle(['clip-path' => 'url(#clip)']); + $path->setId($this->id ?? uniqid('line-graph-')); + $group = $path->toSvg($ctx); + + foreach ($this->dataset as $x => $point) { + if ($this->showDataPoints === true) { + $dot = new Circle($point[0], $point[1], $this->dotWith); + $dot->setFill($this->strokeColor); + $group->appendChild($dot->toSvg($ctx)); + } + + // Draw invisible circle for tooltip hovering + if (isset($this->tooltips[$x])) { + $invisible = new Circle($point[0], $point[1], 20); + $invisible->setFill($this->strokeColor); + $invisible->setAdditionalStyle(['opacity' => '0.0']); + $data = array( + 'label' => isset($this->graphs[$this->order]['label']) ? + strtolower($this->graphs[$this->order]['label']) : '', + 'color' => isset($this->graphs[$this->order]['color']) ? + strtolower($this->graphs[$this->order]['color']) : '#fff' + ); + $format = isset($this->graphs[$this->order]['tooltip']) + ? $this->graphs[$this->order]['tooltip'] : null; + $title = $ctx->getDocument()->createElement('title'); + $title->textContent = $this->tooltips[$x]->renderNoHtml($this->order, $data, $format); + $invisibleRendered = $invisible->toSvg($ctx); + $invisibleRendered->appendChild($title); + $group->appendChild($invisibleRendered); + } + } + + return $group; + } +} diff --git a/library/Icinga/Chart/Graph/StackedGraph.php b/library/Icinga/Chart/Graph/StackedGraph.php new file mode 100644 index 0000000..49801a9 --- /dev/null +++ b/library/Icinga/Chart/Graph/StackedGraph.php @@ -0,0 +1,88 @@ +<?php +/* Icinga Web 2 | (c) 2013 Icinga Development Team | GPLv2+ */ + +namespace Icinga\Chart\Graph; + +use DOMElement; +use Icinga\Chart\Primitive\Drawable; +use Icinga\Chart\Render\RenderContext; + +/** + * Graph implementation that stacks several graphs and displays them in a cumulative way + */ +class StackedGraph implements Drawable +{ + /** + * All graphs displayed in this stackedgraph + * + * @var array + */ + private $stack = array(); + + /** + * An associative array containing x points as the key and an array of y values as the value + * + * @var array + */ + private $points = array(); + + /** + * Add a graph to this stack and aggregate the values on the fly + * + * This modifies the dataset as a side effect + * + * @param array $subGraph + */ + public function addGraph(array &$subGraph) + { + foreach ($subGraph['data'] as &$point) { + $x = $point[0]; + if (!isset($this->points[$x])) { + $this->points[$x] = 0; + } + // store old y-value for displaying the actual (non-aggregated) + // value in the tooltip + $point[2] = $point[1]; + + $this->points[$x] += $point[1]; + $point[1] = $this->points[$x]; + } + } + + /** + * Add a graph to the stack + * + * @param $graph + */ + public function addToStack($graph) + { + $this->stack[] = $graph; + } + + /** + * Empty the stack + * + * @return bool + */ + public function stackEmpty() + { + return empty($this->stack); + } + + /** + * Render this stack in the correct order + * + * @param RenderContext $ctx The context to use for rendering + * + * @return DOMElement The SVG representation of this graph + */ + public function toSvg(RenderContext $ctx) + { + $group = $ctx->getDocument()->createElement('g'); + $renderOrder = array_reverse($this->stack); + foreach ($renderOrder as $stackElem) { + $group->appendChild($stackElem->toSvg($ctx)); + } + return $group; + } +} diff --git a/library/Icinga/Chart/Graph/Tooltip.php b/library/Icinga/Chart/Graph/Tooltip.php new file mode 100644 index 0000000..7236685 --- /dev/null +++ b/library/Icinga/Chart/Graph/Tooltip.php @@ -0,0 +1,143 @@ +<?php +/* Icinga Web 2 | (c) 2014 Icinga Development Team | GPLv2+ */ + +namespace Icinga\Chart\Graph; + +/** + * A tooltip that stores and aggregates information about displayed data + * points of a graph and replaces them in a format string to render the description + * for specific data points of the graph. + * + * When render() is called, placeholders for the keys for each data entry will be replaced by + * the current value of this data set and the formatted string will be returned. + * The content of the replaced keys can change for each data set and depends on how the data + * is passed to this class. There are several types of properties: + * + * <ul> + * <li>Global properties</li>: Key-value pairs that stay the same every time render is called, and are + * passed to an instance in the constructor. + * <li>Aggregated properties</li>: Global properties that are created automatically from + * all attached data points. + * <li>Local properties</li>: Key-value pairs that only apply to a single data point and + * are passed to the render-function. + * </ul> + */ +class Tooltip +{ + /** + * The default format string used + * when no other format is specified + * + * @var string + */ + private $defaultFormat; + + /** + * All aggregated points + * + * @var array + */ + private $points = array(); + + /** + * Contains all static replacements + * + * @var array + */ + private $data = array( + 'sum' => 0 + ); + + /** + * Used to format the displayed tooltip. + * + * @var string + */ + protected $tooltipFormat; + + /** + * Create a new tooltip with the specified default format string + * + * Allows you to set the global data for this tooltip, that is displayed every + * time render is called. + * + * @param array $data Map of global properties + * @param string $format The default format string + */ + public function __construct( + $data = array(), + $format = '<b>{title}</b>: {value} {label}' + ) { + $this->data = array_merge($this->data, $data); + $this->defaultFormat = $format; + } + + /** + * Add a single data point to update the aggregated properties for this tooltip + * + * @param $point array Contains the (x,y) values of the data set + */ + public function addDataPoint($point) + { + // set x-value + if (!isset($this->data['title'])) { + $this->data['title'] = $point[0]; + } + + // aggregate y-values + $y = (int)$point[1]; + if (isset($point[2])) { + // load original value in case value already aggregated + $y = (int)$point[2]; + } + + if (!isset($this->data['min']) || $this->data['min'] > $y) { + $this->data['min'] = $y; + } + if (!isset($this->data['max']) || $this->data['max'] < $y) { + $this->data['max'] = $y; + } + $this->data['sum'] += $y; + $this->points[] = $y; + } + + /** + * Format the tooltip for a certain data point + * + * @param array $order Which data set to render + * @param array $data The local data for this tooltip + * @param string $format Use a custom format string for this data set + * + * @return mixed|string The tooltip value + */ + public function render($order, $data = array(), $format = null) + { + if (isset($format)) { + $str = $format; + } else { + $str = $this->defaultFormat; + } + $data['value'] = $this->points[$order]; + foreach (array_merge($this->data, $data) as $key => $value) { + $str = str_replace('{' . $key . '}', $value, $str); + } + return $str; + } + + /** + * Format the tooltip for a certain data point but remove all + * occurring html tags + * + * This is useful for rendering clean tooltips on client without JavaScript + * + * @param array $order Which data set to render + * @param array $data The local data for this tooltip + * @param string $format Use a custom format string for this data set + * + * @return mixed|string The tooltip value, without any HTML tags + */ + public function renderNoHtml($order, $data, $format) + { + return strip_tags($this->render($order, $data, $format)); + } +} diff --git a/library/Icinga/Chart/GridChart.php b/library/Icinga/Chart/GridChart.php new file mode 100644 index 0000000..a8cfca6 --- /dev/null +++ b/library/Icinga/Chart/GridChart.php @@ -0,0 +1,446 @@ +<?php +/* Icinga Web 2 | (c) 2013 Icinga Development Team | GPLv2+ */ + +namespace Icinga\Chart; + +use DOMElement; +use Icinga\Chart\Chart; +use Icinga\Chart\Axis; +use Icinga\Chart\Graph\BarGraph; +use Icinga\Chart\Graph\LineGraph; +use Icinga\Chart\Graph\StackedGraph; +use Icinga\Chart\Graph\Tooltip; +use Icinga\Chart\Primitive\Canvas; +use Icinga\Chart\Primitive\Rect; +use Icinga\Chart\Primitive\Path; +use Icinga\Chart\Render\LayoutBox; +use Icinga\Chart\Render\RenderContext; +use Icinga\Chart\Unit\AxisUnit; + +/** + * Base class for grid based charts. + * + * Allows drawing of Line and Barcharts. See the graphing documentation for further details. + * + * Example: + * <pre> + * <code> + * $this->chart = new GridChart(); + * $this->chart->setAxisLabel("X axis label", "Y axis label"); + * $this->chart->setXAxis(Axis::CalendarUnit()); + * $this->chart->drawLines( + * array( + * 'data' => array( + * array(time()-7200, 10),array(time()-3620, 30), array(time()-1800, 15), array(time(), 92)) + * ) + * ); + * </code> + * </pre> + */ +class GridChart extends Chart +{ + /** + * Internal identifier for Line Chart elements + */ + const TYPE_LINE = "LINE"; + + /** + * Internal identifier fo Bar Chart elements + */ + const TYPE_BAR = "BAR"; + + /** + * Internal array containing all elements to be drawn in the order they are drawn + * + * @var array + */ + private $graphs = array(); + + /** + * An associative array containing all axis of this Chart in the "name" => Axis() form. + * + * Currently only the 'default' axis is really supported + * + * @var array + */ + private $axis = array(); + + /** + * An associative array containing all StackedGraph objects used for cumulative graphs + * + * The array key is the 'stack' value given in the graph definitions + * + * @var array + */ + private $stacks = array(); + + /** + * An associative array containing all Tooltips used to render the titles + * + * Each tooltip represents the summary for all y-values of a certain x-value + * in the grid chart + * + * @var Tooltip + */ + private $tooltips = array(); + + public function __construct() + { + $this->title = t('Grid Chart'); + $this->description = t('Contains data in a bar or line chart.'); + parent::__construct(); + } + + /** + * Check if the current dataset has the proper structure for this chart. + * + * Needs to be overwritten by extending classes. The default implementation returns false. + * + * @return bool True when the dataset is valid, otherwise false + */ + public function isValidDataFormat() + { + foreach ($this->graphs as $values) { + foreach ($values as $value) { + if (!isset($value['data']) || !is_array($value['data'])) { + return false; + } + } + } + return true; + } + + /** + * Calls Axis::addDataset for every graph added to this GridChart + * + * @see Axis::addDataset + */ + private function configureAxisFromDatasets() + { + foreach ($this->graphs as $axis => &$graphs) { + $axisObj = $this->axis[$axis]; + foreach ($graphs as &$graph) { + $axisObj->addDataset($graph); + } + } + } + + /** + * Add an arbitrary number of lines to be drawn + * + * Refer to the graphs.md for a detailed list of allowed attributes + * + * @param array $axis,... The line definitions to draw + * + * @return $this Fluid interface + */ + public function drawLines(array $axis) + { + $this->draw(self::TYPE_LINE, func_get_args()); + return $this; + } + + /** + * Add arbitrary number of bars to be drawn + * + * Refer to the graphs.md for a detailed list of allowed attributes + * + * @param array $axis + * @return $this + */ + public function drawBars(array $axis) + { + $this->draw(self::TYPE_BAR, func_get_args()); + return $this; + } + + /** + * Generic method for adding elements to the drawing stack + * + * @param string $type The type of the element to draw (see TYPE_ constants in this class) + * @param array $data The data given to the draw call + */ + private function draw($type, $data) + { + $axisName = 'default'; + if (is_string($data[0])) { + $axisName = $data[0]; + array_shift($data); + } + foreach ($data as &$graph) { + $graph['graphType'] = $type; + if (isset($graph['stack'])) { + if (!isset($this->stacks[$graph['stack']])) { + $this->stacks[$graph['stack']] = new StackedGraph(); + } + $this->stacks[$graph['stack']]->addGraph($graph); + $graph['stack'] = $this->stacks[$graph['stack']]; + } + + if (!isset($graph['color'])) { + $colorType = isset($graph['palette']) ? $graph['palette'] : Palette::NEUTRAL; + $graph['color'] = $this->palette->getNext($colorType); + } + $this->graphs[$axisName][] = $graph; + if ($this->legend) { + $this->legend->addDataset($graph); + } + } + $this->initTooltips($data); + } + + + private function initTooltips($data) + { + foreach ($data as &$graph) { + foreach ($graph['data'] as $x => $point) { + if (!array_key_exists($x, $this->tooltips)) { + $this->tooltips[$x] = new Tooltip( + array( + 'color' => $graph['color'], + + ) + ); + } + $this->tooltips[$x]->addDataPoint($point); + } + } + } + + /** + * Set the label for the x and y axis + * + * @param string $xAxisLabel The label to use for the x axis + * @param string $yAxisLabel The label to use for the y axis + * @param string $axisName The name of the axis, for now 'default' + * + * @return $this Fluid interface + */ + public function setAxisLabel($xAxisLabel, $yAxisLabel, $axisName = 'default') + { + $this->axis[$axisName]->setXLabel($xAxisLabel)->setYLabel($yAxisLabel); + return $this; + } + + /** + * Set the AxisUnit to use for calculating the values of the x axis + * + * @param AxisUnit $unit The unit for the x axis + * @param string $axisName The name of the axis to set the label for, currently only 'default' + * + * @return $this Fluid interface + */ + public function setXAxis(AxisUnit $unit, $axisName = 'default') + { + $this->axis[$axisName]->setUnitForXAxis($unit); + return $this; + } + + /** + * Set the AxisUnit to use for calculating the values of the y axis + * + * @param AxisUnit $unit The unit for the y axis + * @param string $axisName The name of the axis to set the label for, currently only 'default' + * + * @return $this Fluid interface + */ + public function setYAxis(AxisUnit $unit, $axisName = 'default') + { + $this->axis[$axisName]->setUnitForYAxis($unit); + return $this; + } + + /** + * Pre-render setup of the axis + * + * @see Chart::build + */ + protected function build() + { + $this->configureAxisFromDatasets(); + } + + /** + * Initialize the renderer and overwrite it with an 2:1 ration renderer + */ + protected function init() + { + $this->renderer = new SVGRenderer(100, 100); + $this->setAxis(Axis::createLinearAxis()); + } + + /** + * Overwrite the axis to use + * + * @param Axis $axis The new axis to use + * @param string $name The name of the axis, currently only 'default' + * + * @return $this Fluid interface + */ + public function setAxis(Axis $axis, $name = 'default') + { + $this->axis = array($name => $axis); + return $this; + } + + /** + * Add an axis to this graph (not really supported right now) + * + * @param Axis $axis The axis object to add + * @param string $name The name of the axis + * + * @return $this Fluid interface + */ + public function addAxis(Axis $axis, $name) + { + $this->axis[$name] = $axis; + return $this; + } + + /** + * Set minimum values for the x and y axis. + * + * Setting null to an axis means this will use a value determined by the dataset + * + * @param int $xMin The minimum value for the x axis or null to use a dynamic value + * @param int $yMin The minimum value for the y axis or null to use a dynamic value + * @param string $axisName The name of the axis to set the minimum, currently only 'default' + * + * @return $this Fluid interface + */ + public function setAxisMin($xMin = null, $yMin = null, $axisName = 'default') + { + $this->axis[$axisName]->setXMin($xMin)->setYMin($yMin); + return $this; + } + + /** + * Set maximum values for the x and y axis. + * + * Setting null to an axis means this will use a value determined by the dataset + * + * @param int $xMax The maximum value for the x axis or null to use a dynamic value + * @param int $yMax The maximum value for the y axis or null to use a dynamic value + * @param string $axisName The name of the axis to set the maximum, currently only 'default' + * + * @return $this Fluid interface + */ + public function setAxisMax($xMax = null, $yMax = null, $axisName = 'default') + { + $this->axis[$axisName]->setXMax($xMax)->setYMax($yMax); + return $this; + } + + /** + * Render this GridChart to SVG + * + * @param RenderContext $ctx The context to use for rendering + * + * @return DOMElement + */ + public function toSvg(RenderContext $ctx) + { + $outerBox = new Canvas('outerGraph', new LayoutBox(0, 0, 100, 100)); + $innerBox = new Canvas('graph', new LayoutBox(0, 0, 95, 90)); + + $maxPadding = array(0,0,0,0); + foreach ($this->axis as $axis) { + $padding = $axis->getRequiredPadding(); + for ($i=0; $i < count($padding); $i++) { + $maxPadding[$i] = max($maxPadding[$i], $padding[$i]); + } + $innerBox->addElement($axis); + } + $this->renderGraphContent($innerBox); + + $innerBox->getLayout()->setPadding($maxPadding[0], $maxPadding[1], $maxPadding[2], $maxPadding[3]); + $this->createContentClipBox($innerBox); + + $outerBox->addElement($innerBox); + if ($this->legend) { + $outerBox->addElement($this->legend); + } + return $outerBox->toSvg($ctx); + } + + /** + * Create a clip box that defines which area of the graph is drawable and adds it to the graph. + * + * The clipbox has the id '#clip' and can be used in the clip-mask element + * + * @param Canvas $innerBox The inner canvas of the graph to add the clip box to + */ + private function createContentClipBox(Canvas $innerBox) + { + $clipBox = new Canvas('clip', new LayoutBox(0, 0, 100, 100)); + $clipBox->toClipPath(); + $innerBox->addElement($clipBox); + $rect = new Rect(0.1, 0, 100, 99.9); + $clipBox->addElement($rect); + } + + /** + * Render the content of the graph, i.e. the draw stack + * + * @param Canvas $innerBox The inner canvas of the graph to add the content to + */ + private function renderGraphContent(Canvas $innerBox) + { + foreach ($this->graphs as $axisName => $graphs) { + $axis = $this->axis[$axisName]; + $graphObj = null; + foreach ($graphs as $dataset => $graph) { + // determine the type and create a graph object for it + switch ($graph['graphType']) { + case self::TYPE_BAR: + $graphObj = new BarGraph( + $axis->transform($graph['data']), + $graphs, + $dataset, + $this->tooltips + ); + break; + case self::TYPE_LINE: + $graphObj = new LineGraph( + $axis->transform($graph['data']), + $graphs, + $dataset, + $this->tooltips + ); + break; + default: + continue 2; + } + $el = $this->setupGraph($graphObj, $graph); + if ($el) { + $innerBox->addElement($el); + } + } + } + } + + /** + * Setup the provided Graph type + * + * @param mixed $graphObject The graph class, needs the setStyleFromConfig method + * @param array $graphConfig The configration array of the graph + * + * @return mixed Either the graph to be added or null if the graph is not directly added + * to the document (e.g. stacked graphs are added by + * the StackedGraph Composite object) + */ + private function setupGraph($graphObject, array $graphConfig) + { + $graphObject->setStyleFromConfig($graphConfig); + // When in a stack return the StackedGraph object instead of the graphObject + if (isset($graphConfig['stack'])) { + $graphConfig['stack']->addToStack($graphObject); + if (!$graphConfig['stack']->stackEmpty()) { + return $graphConfig['stack']; + } + // return no object when the graph should not be rendered + return null; + } + return $graphObject; + } +} diff --git a/library/Icinga/Chart/Inline/Inline.php b/library/Icinga/Chart/Inline/Inline.php new file mode 100644 index 0000000..3acbd73 --- /dev/null +++ b/library/Icinga/Chart/Inline/Inline.php @@ -0,0 +1,96 @@ +<?php +/* Icinga Web 2 | (c) 2014 Icinga Development Team | GPLv2+ */ + +namespace Icinga\Chart\Inline; + +/** + * Class to render and inline chart directly from the request params. + * + * When rendering huge amounts of inline charts it is too expensive + * to bootstrap the complete application for ever single chart and + * we need to be able render Charts in a compact environment without + * the other Icinga classes. + * + * Class Inline + * @package Icinga\Chart\Inline + */ +class Inline +{ + + /** + * The data displayed in this chart + * + * @var array + */ + protected $data; + + /** + * The colors used to display this chart + * + * @var array + */ + protected $colors = array( + '#00FF00', // OK + '#FFFF00', // Warning + '#FF0000', // Critical + '#E066FF' // Unreachable + ); + + /** + * The labels displayed on this chart + * + * @var array + */ + protected $labels = array(); + + /** + * The height in percent + * + * @var int + */ + protected $height = 100; + + /** + * The width in percent + * + * @var int + */ + protected $width = 100; + + protected function sanitizeStringArray(array $arr) + { + $sanitized = array(); + foreach ($arr as $key => $value) { + $sanitized[$key] = htmlspecialchars($value); + } + return $sanitized; + } + + /** + * Populate the properties from the current request. + */ + public function initFromRequest() + { + $this->data = explode(',', $_GET['data']); + foreach ($this->data as $key => $value) { + $this->data[$key] = (int)$value; + } + for ($i = 0; $i < count($this->data); $i++) { + $this->labels[] = ''; + } + + if (array_key_exists('colors', $_GET)) { + $this->colors = $this->sanitizeStringArray(explode(',', $_GET['colors'])); + } + while (count($this->colors) < count($this->data)) { + $this->colors[] = '#FEFEFE'; + } + + if (array_key_exists('width', $_GET)) { + $this->width = (int)$_GET['width']; + } + if (array_key_exists('height', $_GET)) { + $this->height = (int)$_GET['height']; + } + } +} diff --git a/library/Icinga/Chart/Inline/PieChart.php b/library/Icinga/Chart/Inline/PieChart.php new file mode 100644 index 0000000..de68213 --- /dev/null +++ b/library/Icinga/Chart/Inline/PieChart.php @@ -0,0 +1,41 @@ +<?php +/* Icinga Web 2 | (c) 2013 Icinga Development Team | GPLv2+ */ + +namespace Icinga\Chart\Inline; + +use Icinga\Chart\PieChart as PieChartRenderer; + +/** + * Draw an inline pie-chart directly from the available request parameters. + */ +class PieChart extends Inline +{ + protected function getChart() + { + $pie = new PieChartRenderer(); + $pie->alignTopLeft(); + $pie->disableLegend(); + $pie->drawPie(array( + 'data' => $this->data, 'colors' => $this->colors, 'labels' => $this->labels + )); + return $pie; + } + + public function toSvg($output = true) + { + if ($output) { + echo $this->getChart()->render(); + } else { + return $this->getChart()->render(); + } + } + + public function toPng($output = true) + { + if ($output) { + echo $this->getChart()->toPng($this->width, $this->height); + } else { + return $this->getChart()->toPng($this->width, $this->height); + } + } +} diff --git a/library/Icinga/Chart/Legend.php b/library/Icinga/Chart/Legend.php new file mode 100644 index 0000000..ab1c9e0 --- /dev/null +++ b/library/Icinga/Chart/Legend.php @@ -0,0 +1,102 @@ +<?php +/* Icinga Web 2 | (c) 2013 Icinga Development Team | GPLv2+ */ + +namespace Icinga\Chart; + +use DOMElement; +use Icinga\Chart\Palette; +use Icinga\Chart\Primitive\Canvas; +use Icinga\Chart\Primitive\Drawable; +use Icinga\Chart\Primitive\Rect; +use Icinga\Chart\Primitive\Text; +use Icinga\Chart\Render\LayoutBox; +use Icinga\Chart\Render\RenderContext; + +/** + * Drawable for creating a Graph Legend on the bottom of a graph. + * + * Usually used by the GridChart class internally. + */ +class Legend implements Drawable +{ + + /** + * Internal counter for unnamed label identifiers + * + * @var int + */ + private $internalCtr = 0; + + /** + * + * Content of this legend + * + * @var array + */ + private $dataset = array(); + + + /** + * Set the content to be displayed by this legend + * + * @param array $dataset An array of datasets in the form they are provided to the graphing implementation + */ + public function addDataset(array $dataset) + { + if (!isset($dataset['label'])) { + $dataset['label'] = 'Dataset ' . (++$this->internalCtr); + } + if (!isset($dataset['color'])) { + return; + } + $this->dataset[$dataset['color']] = $dataset['label']; + } + + /** + * Render the legend to an SVG object + * + * @param RenderContext $ctx The context to use for rendering this legend + * + * @return DOMElement The SVG representation of this legend + */ + public function toSvg(RenderContext $ctx) + { + $outer = new Canvas('legend', new LayoutBox(0, 0, 100, 100)); + $outer->getLayout()->setPadding(2, 2, 2, 2); + $nrOfColumns = 4; + + $topstep = 10 / $nrOfColumns + 2; + + $top = 0; + $left = 0; + $lastLabelEndPos = -1; + foreach ($this->dataset as $color => $text) { + $leftstep = 100 / $nrOfColumns + strlen($text); + + // Make sure labels don't overlap each other + while ($lastLabelEndPos >= $left) { + $left += $leftstep; + } + // When a label is longer than the available space, use the next line + if ($left + strlen($text) > 100) { + $top += $topstep; + $left = 0; + } + + $colorBox = new Rect($left, $top, 2, 2); + $colorBox->setFill($color)->setStrokeWidth(2); + $colorBox->keepRatio(); + $outer->addElement($colorBox); + + $textBox = new Text($left+5, $top+2, $text); + $textBox->setFontSize('2em'); + $outer->addElement($textBox); + + // readjust layout + $lastLabelEndPos = $left + strlen($text); + $left += $leftstep; + } + $svg = $outer->toSvg($ctx); + return $svg; + } +} diff --git a/library/Icinga/Chart/Palette.php b/library/Icinga/Chart/Palette.php new file mode 100644 index 0000000..90ad74b --- /dev/null +++ b/library/Icinga/Chart/Palette.php @@ -0,0 +1,65 @@ +<?php +/* Icinga Web 2 | (c) 2013 Icinga Development Team | GPLv2+ */ + +namespace Icinga\Chart; + +/** + * Provide a set of colors that will be used by the chart as default values + */ +class Palette +{ + /** + * Neutral colors without special meaning + */ + const NEUTRAL = 'neutral'; + + /** + * A set of problem (i.e. red) colors + */ + const PROBLEM = 'problem'; + + /** + * A set of ok (i.e. green) colors + */ + const OK = 'ok'; + + /** + * A set of warning (i.e. yellow) colors + */ + const WARNING = 'warning'; + + /** + * The colorsets for specific categories + * + * @var array + */ + public $colorSets = array( + self::OK => array('#00FF00'), + self::PROBLEM => array('#FF0000'), + self::WARNING => array('#FFFF00'), + self::NEUTRAL => array('#f3f3f3') + ); + + /** + * Return the next available color as an hex string for the given type + * + * @param string $type The type to receive a color from + * + * @return string The color in hex format + */ + public function getNext($type = self::NEUTRAL) + { + if (!isset($this->colorSets[$type])) { + $type = self::NEUTRAL; + } + + $color = current($this->colorSets[$type]); + if ($color === false) { + reset($this->colorSets[$type]); + + $color = current($this->colorSets[$type]); + } + next($this->colorSets[$type]); + return $color; + } +} diff --git a/library/Icinga/Chart/PieChart.php b/library/Icinga/Chart/PieChart.php new file mode 100644 index 0000000..1bcf380 --- /dev/null +++ b/library/Icinga/Chart/PieChart.php @@ -0,0 +1,306 @@ +<?php +/* Icinga Web 2 | (c) 2013 Icinga Development Team | GPLv2+ */ + +namespace Icinga\Chart; + +use DOMElement; +use Icinga\Chart\Chart; +use Icinga\Chart\Primitive\Canvas; +use Icinga\Chart\Primitive\PieSlice; +use Icinga\Chart\Primitive\RawElement; +use Icinga\Chart\Primitive\Rect; +use Icinga\Chart\Render\RenderContext; +use Icinga\Chart\Render\LayoutBox; + +/** + * Graphing component for rendering Pie Charts. + * + * See the graphs.md documentation for further information about how to use this component + */ +class PieChart extends Chart +{ + /** + * Stack multiple pies + */ + const STACKED = "stacked"; + + /** + * Draw multiple pies beneath each other + */ + const ROW = "row"; + + /** + * The drawing stack containing all pie definitions in the order they will be drawn + * + * @var array + */ + private $pies = array(); + + /** + * The composition type currently used + * + * @var string + */ + private $type = PieChart::STACKED; + + /** + * Disable drawing of captions when set true + * + * @var bool + */ + private $noCaption = false; + + public function __construct() + { + $this->title = t('Pie Chart'); + $this->description = t('Contains data in a pie chart.'); + parent::__construct(); + } + + /** + * Test if the given pies have the correct format + * + * @return bool True when the given pies are correct, otherwise false + */ + public function isValidDataFormat() + { + foreach ($this->pies as $pie) { + if (!isset($pie['data']) || !is_array($pie['data'])) { + return false; + } + } + return true; + } + + /** + * Create renderer and normalize the dataset to represent percentage information + */ + protected function build() + { + $this->renderer = new SVGRenderer(($this->type === self::STACKED) ? 1 : count($this->pies), 1); + foreach ($this->pies as &$pie) { + $this->normalizeDataSet($pie); + } + } + + /** + * Normalize the given dataset to represent percentage information instead of absolute valuess + * + * @param array $pie The pie definition given in the drawPie call + */ + private function normalizeDataSet(&$pie) + { + $total = array_sum($pie['data']); + if ($total === 100) { + return; + } + if ($total == 0) { + return; + } + foreach ($pie['data'] as &$slice) { + $slice = $slice/$total * 100; + } + } + + /** + * Draw an arbitrary number of pies in this chart + * + * @param array $dataSet,... The pie definition, see graphs.md for further details concerning the format + * + * @return $this Fluent interface + */ + public function drawPie(array $dataSet) + { + $dataSets = func_get_args(); + $this->pies += $dataSets; + foreach ($dataSets as $dataSet) { + $this->legend->addDataset($dataSet); + } + return $this; + } + + /** + * Return the SVG representation of this graph + * + * @param RenderContext $ctx The context to use for drawings + * + * @return DOMElement The SVG representation of this graph + */ + public function toSvg(RenderContext $ctx) + { + $labelBox = $ctx->getDocument()->createElement('g'); + if (!$this->noCaption) { + // Scale SVG to make room for captions + $outerBox = new Canvas('outerGraph', new LayoutBox(33, -5, 40, 40)); + $innerBox = new Canvas('graph', new LayoutBox(0, 0, 100, 100)); + $innerBox->getLayout()->setPadding(10, 10, 10, 10); + } else { + $outerBox = new Canvas('outerGraph', new LayoutBox(1.5, -10, 124, 124)); + $innerBox = new Canvas('graph', new LayoutBox(0, 0, 100, 100)); + $innerBox->getLayout()->setPadding(0, 0, 0, 0); + } + $this->createContentClipBox($innerBox); + $this->renderPies($innerBox, $labelBox); + $innerBox->addElement(new RawElement($labelBox)); + $outerBox->addElement($innerBox); + + return $outerBox->toSvg($ctx); + } + + /** + * Render the pies in the draw stack using the selected algorithm for composition + * + * @param Canvas $innerBox The canvas to use for inserting the pies + * @param DOMElement $labelBox The DOM element to add the labels to (so they can't be overlapped by pie elements) + */ + private function renderPies(Canvas $innerBox, DOMElement $labelBox) + { + if ($this->type === self::STACKED) { + $this->renderStackedPie($innerBox, $labelBox); + } else { + $this->renderPieRow($innerBox, $labelBox); + } + } + + /** + * Return the color to be used for the given pie slice + * + * @param array $pie The pie configuration as provided in the drawPie call + * @param int $dataIdx The index of the pie slice in the pie configuration + * + * @return string The hex color string to use for the pie slice + */ + private function getColorForPieSlice(array $pie, $dataIdx) + { + if (isset($pie['colors']) && is_array($pie['colors']) && isset($pie['colors'][$dataIdx])) { + return $pie['colors'][$dataIdx]; + } + $type = Palette::NEUTRAL; + if (isset($pie['palette']) && is_array($pie['palette']) && isset($pie['palette'][$dataIdx])) { + $type = $pie['palette'][$dataIdx]; + } + return $this->palette->getNext($type); + } + + /** + * Render a row of pies + * + * @param Canvas $innerBox The canvas to insert the pies to + * @param DOMElement $labelBox The DOMElement to use for adding label elements + */ + private function renderPieRow(Canvas $innerBox, DOMElement $labelBox) + { + $radius = 50 / count($this->pies); + $x = $radius; + foreach ($this->pies as $pie) { + $labelPos = 0; + $lastRadius = 0; + + foreach ($pie['data'] as $idx => $dataset) { + $slice = new PieSlice($radius, $dataset, $lastRadius); + $slice->setX($x) + ->setStrokeColor('#000') + ->setStrokeWidth(1) + ->setY(50) + ->setFill($this->getColorForPieSlice($pie, $idx)); + $innerBox->addElement($slice); + // add caption if not disabled + if (!$this->noCaption && isset($pie['labels'])) { + $slice->setCaption($pie['labels'][$labelPos++]) + ->setLabelGroup($labelBox); + } + $lastRadius += $dataset; + } + // shift right for next pie + $x += $radius*2; + } + } + + /** + * Render pies in a stacked way so one pie is nested in the previous pie + * + * @param Canvas $innerBox The canvas to insert the pie to + * @param DOMElement $labelBox The DOMElement to use for adding label elements + */ + private function renderStackedPie(Canvas $innerBox, DOMElement $labelBox) + { + $radius = 40; + $minRadius = 20; + if (count($this->pies) == 0) { + return; + } + $shrinkStep = ($radius - $minRadius) / count($this->pies); + $x = $radius; + + for ($i = 0; $i < count($this->pies); $i++) { + $pie = $this->pies[$i]; + // the offset for the caption path, outer caption indicator shouldn't point + // to the middle of the slice as there will be another pie + $offset = isset($this->pies[$i+1]) ? $radius - $shrinkStep : 0; + $labelPos = 0; + $lastRadius = 0; + foreach ($pie['data'] as $idx => $dataset) { + $color = $this->getColorForPieSlice($pie, $idx); + if ($dataset == 0) { + $labelPos++; + continue; + } + $slice = new PieSlice($radius, $dataset, $lastRadius); + $slice->setY(50) + ->setX($x) + ->setStrokeColor('#000') + ->setStrokeWidth(1) + ->setFill($color) + ->setLabelGroup($labelBox); + + if (!$this->noCaption && isset($pie['labels'])) { + $slice->setCaption($pie['labels'][$labelPos++]) + ->setCaptionOffset($offset) + ->setOuterCaptionBound(50); + } + $innerBox->addElement($slice); + $lastRadius += $dataset; + } + // shrinken the next pie + $radius -= $shrinkStep; + } + } + + /** + * Set the composition type of this PieChart + * + * @param string $type Either self::STACKED or self::ROW + * + * @return $this Fluent interface + */ + public function setType($type) + { + $this->type = $type; + return $this; + } + + /** + * Hide the caption from this PieChart + * + * @return $this Fluent interface + */ + public function disableLegend() + { + $this->noCaption = true; + return $this; + } + + /** + * Create the content for this PieChart + * + * @param Canvas $innerBox The innerbox to add the clip mask to + */ + private function createContentClipBox(Canvas $innerBox) + { + $clipBox = new Canvas('clip', new LayoutBox(0, 0, 100, 100)); + $clipBox->toClipPath(); + $innerBox->addElement($clipBox); + $rect = new Rect(0.1, 0, 100, 99.9); + $clipBox->addElement($rect); + } +} diff --git a/library/Icinga/Chart/Primitive/Animatable.php b/library/Icinga/Chart/Primitive/Animatable.php new file mode 100644 index 0000000..69ba0e1 --- /dev/null +++ b/library/Icinga/Chart/Primitive/Animatable.php @@ -0,0 +1,43 @@ +<?php +/* Icinga Web 2 | (c) 2013 Icinga Development Team | GPLv2+ */ + +namespace Icinga\Chart\Primitive; + +use DOMElement; +use Icinga\Chart\Render\RenderContext; + +/** + * Base interface for animatable objects + */ +abstract class Animatable extends Styleable +{ + /** + * The animation object set + * + * @var Animation + */ + public $animation = null; + + /** + * Set the animation for this object + * + * @param Animation $anim The animation to use + */ + public function setAnimation(Animation $anim) + { + $this->animation = $anim; + } + + /** + * Append the animation to the given element + * + * @param DOMElement $dom The element to append the animation to + * @param RenderContext $ctx The context to use for rendering the animation object + */ + protected function appendAnimation(DOMElement $dom, RenderContext $ctx) + { + if ($this->animation) { + $dom->appendChild($this->animation->toSvg($ctx)); + } + } +} diff --git a/library/Icinga/Chart/Primitive/Animation.php b/library/Icinga/Chart/Primitive/Animation.php new file mode 100644 index 0000000..e620fa7 --- /dev/null +++ b/library/Icinga/Chart/Primitive/Animation.php @@ -0,0 +1,87 @@ +<?php +/* Icinga Web 2 | (c) 2013 Icinga Development Team | GPLv2+ */ + +namespace Icinga\Chart\Primitive; + +use DOMElement; +use Icinga\Chart\Render\RenderContext; + +/** + * Drawable for the SVG animate tag + */ +class Animation implements Drawable +{ + /** + * The attribute to animate + * + * @var string + */ + private $attribute; + + /** + * The 'from' value + * + * @var mixed + */ + private $from; + + /** + * The to value + * + * @var mixed + */ + private $to; + + /** + * The begin value (in seconds) + * + * @var float + */ + private $begin = 0; + + /** + * The duration value (in seconds) + * + * @var float + */ + private $duration = 0.5; + + /** + * Create an animation object + * + * @param string $attribute The attribute to animate + * @param string $from The from value for the animation + * @param string $to The to value for the animation + * @param float $duration The duration of the duration + * @param float $begin The begin of the duration + */ + public function __construct($attribute, $from, $to, $duration = 0.5, $begin = 0.0) + { + $this->attribute = $attribute; + $this->from = $from; + $this->to = $to; + $this->duration = $duration; + $this->begin = $begin; + } + + /** + * Create the SVG representation from this Drawable + * + * @param RenderContext $ctx The context to use for rendering + * @return DOMElement The SVG Element + */ + public function toSvg(RenderContext $ctx) + { + + $animate = $ctx->getDocument()->createElement('animate'); + $animate->setAttribute('attributeName', $this->attribute); + $animate->setAttribute('attributeType', 'XML'); + $animate->setAttribute('from', $this->from); + $animate->setAttribute('to', $this->to); + $animate->setAttribute('begin', $this->begin . 's'); + $animate->setAttribute('dur', $this->duration . 's'); + $animate->setAttribute('fill', "freeze"); + + return $animate; + } +} diff --git a/library/Icinga/Chart/Primitive/Canvas.php b/library/Icinga/Chart/Primitive/Canvas.php new file mode 100644 index 0000000..32f06bf --- /dev/null +++ b/library/Icinga/Chart/Primitive/Canvas.php @@ -0,0 +1,140 @@ +<?php +/* Icinga Web 2 | (c) 2013 Icinga Development Team | GPLv2+ */ + + +namespace Icinga\Chart\Primitive; + +use DOMElement; +use Icinga\Chart\Render\LayoutBox; +use Icinga\Chart\Render\RenderContext; + +/** + * Canvas SVG component that encapsulates grouping and padding and allows rendering + * multiple elements in a group + * + */ +class Canvas implements Drawable +{ + /** + * The name of the canvas, will be used as the id + * + * @var string + */ + private $name; + + /** + * An array of child elements of this Canvas + * + * @var array + */ + private $children = array(); + + /** + * When true, this canvas is encapsulated in a clipPath tag and not drawn + * + * @var bool + */ + private $isClipPath = false; + + /** + * The LayoutBox of this Canvas + * + * @var LayoutBox + */ + private $rect; + + /** + * The aria role used to describe this canvas' purpose in the accessibility tree + * + * @var string + */ + private $ariaRole; + + /** + * Create this canvas + * + * @param String $name The name of this canvas + * @param LayoutBox $rect The layout and size of this canvas + */ + public function __construct($name, LayoutBox $rect) + { + $this->rect = $rect; + $this->name = $name; + } + + /** + * Convert this canvas to a clipPath element + */ + public function toClipPath() + { + $this->isClipPath = true; + } + + /** + * Return the layout of this canvas + * + * @return LayoutBox + */ + public function getLayout() + { + return $this->rect; + } + + /** + * Add an element to this canvas + * + * @param Drawable $child + */ + public function addElement(Drawable $child) + { + $this->children[] = $child; + } + + /** + * Create the SVG representation from this Drawable + * + * @param RenderContext $ctx The context to use for rendering + * @return DOMElement The SVG Element + */ + public function toSvg(RenderContext $ctx) + { + $doc = $ctx->getDocument(); + if ($this->isClipPath) { + $outer = $doc->createElement('defs'); + $innerContainer = $element = $doc->createElement('clipPath'); + $outer->appendChild($element); + } else { + $outer = $element = $doc->createElement('g'); + $innerContainer = $doc->createElement('g'); + $innerContainer->setAttribute('x', 0); + $innerContainer->setAttribute('y', 0); + $innerContainer->setAttribute('id', $this->name . '_inner'); + $innerContainer->setAttribute('transform', $this->rect->getInnerTransform($ctx)); + $element->appendChild($innerContainer); + } + + $element->setAttribute('id', $this->name); + foreach ($this->children as $child) { + $innerContainer->appendChild($child->toSvg($ctx)); + } + + if (isset($this->ariaRole)) { + $outer->setAttribute('role', $this->ariaRole); + } + return $outer; + } + + /** + * Set the aria role used to determine the meaning of this canvas in the accessibility tree + * + * The role 'presentation' will indicate that the purpose of this canvas is entirely decorative, while the role + * 'img' will indicate that the canvas contains an image, with a possible title or a description. For other + * possible roles, see http://www.w3.org/TR/wai-aria/roles + * + * @param $role string The aria role to set + */ + public function setAriaRole($role) + { + $this->ariaRole = $role; + } +} diff --git a/library/Icinga/Chart/Primitive/Circle.php b/library/Icinga/Chart/Primitive/Circle.php new file mode 100644 index 0000000..f98ffac --- /dev/null +++ b/library/Icinga/Chart/Primitive/Circle.php @@ -0,0 +1,84 @@ +<?php +/* Icinga Web 2 | (c) 2013 Icinga Development Team | GPLv2+ */ + + +namespace Icinga\Chart\Primitive; + +use DOMDocument; +use DOMElement; +use Icinga\Chart\Render\RenderContext; +use Icinga\Chart\Format; + +/** + * Drawable for svg circles + */ +class Circle extends Styleable implements Drawable +{ + /** + * The circles x position + * + * @var int + */ + private $x; + + /** + * The circles y position + * + * @var int + */ + private $y; + + /** + * The circles radius + * + * @var int + */ + private $radius; + + /** + * Construct the circle + * + * @param int $x The x position of the circle + * @param int $y The y position of the circle + * @param int $radius The radius of the circle + */ + public function __construct($x, $y, $radius) + { + $this->x = $x; + $this->y = $y; + $this->radius = $radius; + } + + /** + * Create the SVG representation from this Drawable + * + * @param RenderContext $ctx The context to use for rendering + * @return DOMElement The SVG Element + */ + public function toSvg(RenderContext $ctx) + { + $coords = $ctx->toAbsolute($this->x, $this->y); + $circle = $ctx->getDocument()->createElement('circle'); + $circle->setAttribute('cx', Format::formatSVGNumber($coords[0])); + $circle->setAttribute('cy', Format::formatSVGNumber($coords[1])); + $circle->setAttribute('r', $this->radius); + + $id = $this->id ?? uniqid('circle-'); + $circle->setAttribute('id', $id); + $this->setId($id); + + $this->applyAttributes($circle); + + $style = new DOMDocument(); + $style->loadHTML($this->getStyle()); + + $circle->appendChild( + $circle->ownerDocument->importNode( + $style->getElementsByTagName('style')->item(0), + true + ) + ); + + return $circle; + } +} diff --git a/library/Icinga/Chart/Primitive/Drawable.php b/library/Icinga/Chart/Primitive/Drawable.php new file mode 100644 index 0000000..5b4355c --- /dev/null +++ b/library/Icinga/Chart/Primitive/Drawable.php @@ -0,0 +1,22 @@ +<?php +/* Icinga Web 2 | (c) 2013 Icinga Development Team | GPLv2+ */ + +namespace Icinga\Chart\Primitive; + +use DOMElement; +use Icinga\Chart\Render\RenderContext; + +/** + * Drawable element for creating svg out of components + */ +interface Drawable +{ + /** + * Create the SVG representation from this Drawable + * + * @param RenderContext $ctx The context to use for rendering + * + * @return DOMElement The SVG Element + */ + public function toSvg(RenderContext $ctx); +} diff --git a/library/Icinga/Chart/Primitive/Line.php b/library/Icinga/Chart/Primitive/Line.php new file mode 100644 index 0000000..d83cbea --- /dev/null +++ b/library/Icinga/Chart/Primitive/Line.php @@ -0,0 +1,103 @@ +<?php +/* Icinga Web 2 | (c) 2013 Icinga Development Team | GPLv2+ */ + +namespace Icinga\Chart\Primitive; + +use DOMDocument; +use DOMElement; +use Icinga\Chart\Render\RenderContext; +use Icinga\Chart\Format; + +/** + * Drawable for the svg line element + */ +class Line extends Styleable implements Drawable +{ + + /** + * The default stroke width + * + * @var int + */ + public $strokeWidth = 1; + + /** + * The line's start x coordinate + * + * @var int + */ + private $xStart = 0; + + /** + * The line's end x coordinate + * + * @var int + */ + private $xEnd = 0; + + /** + * The line's start y coordinate + * + * @var int + */ + private $yStart = 0; + + /** + * The line's end y coordinate + * + * @var int + */ + private $yEnd = 0; + + /** + * Create a line object starting at the first coordinate and ending at the second one + * + * @param int $x1 The line's start x coordinate + * @param int $y1 The line's start y coordinate + * @param int $x2 The line's end x coordinate + * @param int $y2 The line's end y coordinate + */ + public function __construct($x1, $y1, $x2, $y2) + { + $this->xStart = $x1; + $this->xEnd = $x2; + $this->yStart = $y1; + $this->yEnd = $y2; + } + + /** + * Create the SVG representation from this Drawable + * + * @param RenderContext $ctx The context to use for rendering + * @return DOMElement The SVG Element + */ + public function toSvg(RenderContext $ctx) + { + $doc = $ctx->getDocument(); + list($x1, $y1) = $ctx->toAbsolute($this->xStart, $this->yStart); + list($x2, $y2) = $ctx->toAbsolute($this->xEnd, $this->yEnd); + $line = $doc->createElement('line'); + $line->setAttribute('x1', Format::formatSVGNumber($x1)); + $line->setAttribute('x2', Format::formatSVGNumber($x2)); + $line->setAttribute('y1', Format::formatSVGNumber($y1)); + $line->setAttribute('y2', Format::formatSVGNumber($y2)); + + $id = $this->id ?? uniqid('line-'); + $line->setAttribute('id', $id); + $this->setId($id); + + $this->applyAttributes($line); + + $style = new DOMDocument(); + $style->loadHTML($this->getStyle()); + + $line->appendChild( + $line->ownerDocument->importNode( + $style->getElementsByTagName('style')->item(0), + true + ) + ); + + return $line; + } +} diff --git a/library/Icinga/Chart/Primitive/Path.php b/library/Icinga/Chart/Primitive/Path.php new file mode 100644 index 0000000..b9d5f7b --- /dev/null +++ b/library/Icinga/Chart/Primitive/Path.php @@ -0,0 +1,187 @@ +<?php +/* Icinga Web 2 | (c) 2013 Icinga Development Team | GPLv2+ */ + +namespace Icinga\Chart\Primitive; + +use DOMDocument; +use DOMElement; +use Icinga\Chart\Render\RenderContext; +use Icinga\Chart\Format; + +/** + * Drawable for creating a svg path element + */ +class Path extends Styleable implements Drawable +{ + /** + * Syntax template for moving + * + * @see http://www.w3.org/TR/SVG/paths.html#PathDataMovetoCommands + */ + const TPL_MOVE = 'M %s %s '; + + /** + * Syntax template for bezier curve + * + * @see http://www.w3.org/TR/SVG/paths.html#PathDataCubicBezierCommands + */ + const TPL_BEZIER = 'S %s %s '; + + /** + * Syntax template for straight lines + * + * @see http://www.w3.org/TR/SVG/paths.html#PathDataLinetoCommands + */ + const TPL_STRAIGHT = 'L %s %s '; + + /** + * The default stroke width + * + * @var int + */ + public $strokeWidth = 1; + + /** + * True to treat coordinates as absolute values + * + * @var bool + */ + protected $isAbsolute = false; + + /** + * The points to draw, in the order they are drawn + * + * @var array + */ + protected $points = array(); + + /** + * True to draw the path discrete, i.e. make hard steps between points + * + * @var bool + */ + protected $discrete = false; + + /** + * Create the path using the given points + * + * @param array $points Either a single [x, y] point or an array of x, y points + */ + public function __construct(array $points) + { + $this->append($points); + } + + /** + * Append a single point or an array of points to this path + * + * @param array $points Either a single [x, y] point or an array of x, y points + * + * @return $this Fluid interface + */ + public function append(array $points) + { + if (count($points) === 0) { + return $this; + } + if (!is_array($points[0])) { + $points = array($points); + } + $this->points = array_merge($this->points, $points); + return $this; + } + + /** + * Prepend a single point or an array of points to this path + * + * @param array $points Either a single [x, y] point or an array of x, y points + * + * @return $this Fluid interface + */ + public function prepend(array $points) + { + if (count($points) === 0) { + return $this; + } + if (!is_array($points[0])) { + $points = array($points); + } + $this->points = array_merge($points, $this->points); + return $this; + } + + /** + * Set this path to be discrete + * + * @param boolean $bool True to draw discrete or false to draw straight lines between points + * + * @return $this Fluid interface + */ + public function setDiscrete($bool) + { + $this->discrete = $bool; + return $this; + } + + /** + * Mark this path as containing absolute coordinates + * + * @return $this Fluid interface + */ + public function toAbsolute() + { + $this->isAbsolute = true; + return $this; + } + + /** + * Create the SVG representation from this Drawable + * + * @param RenderContext $ctx The context to use for rendering + * @return DOMElement The SVG Element + */ + public function toSvg(RenderContext $ctx) + { + $doc = $ctx->getDocument(); + $group = $doc->createElement('g'); + + $pathDescription = ''; + $tpl = self::TPL_MOVE; + $lastPoint = null; + foreach ($this->points as $point) { + if (!$this->isAbsolute) { + $point = $ctx->toAbsolute($point[0], $point[1]); + } + $point[0] = Format::formatSVGNumber($point[0]); + $point[1] = Format::formatSVGNumber($point[1]); + if ($lastPoint && $this->discrete) { + $pathDescription .= sprintf($tpl, $point[0], $lastPoint[1]); + } + $pathDescription .= vsprintf($tpl, $point); + $lastPoint = $point; + $tpl = self::TPL_STRAIGHT; + } + + $path = $doc->createElement('path'); + + $id = $this->id ?? uniqid('path-'); + $path->setAttribute('id', $id); + $this->setId($id); + + $path->setAttribute('d', $pathDescription); + + $this->applyAttributes($path); + $style = new DOMDocument(); + $style->loadHTML($this->getStyle()); + + $path->appendChild( + $path->ownerDocument->importNode( + $style->getElementsByTagName('style')->item(0), + true + ) + ); + + $group->appendChild($path); + return $group; + } +} diff --git a/library/Icinga/Chart/Primitive/PieSlice.php b/library/Icinga/Chart/Primitive/PieSlice.php new file mode 100644 index 0000000..f898435 --- /dev/null +++ b/library/Icinga/Chart/Primitive/PieSlice.php @@ -0,0 +1,307 @@ +<?php +/* Icinga Web 2 | (c) 2013 Icinga Development Team | GPLv2+ */ + +namespace Icinga\Chart\Primitive; + +use DOMDocument; +use DOMElement; +use Icinga\Chart\Render\RenderContext; +use Icinga\Chart\Format; + +/** + * Component for drawing a pie slice + */ +class PieSlice extends Animatable implements Drawable +{ + /** + * The radius of this pieslice relative to the canvas + * + * @var int + */ + private $radius = 50; + + /** + * The start radian of the pie slice + * + * @var float + */ + private $startRadian = 0; + + /** + * The end radian of the pie slice + * + * @var float + */ + private $endRadian = 0; + + /** + * The x position of the pie slice's center + * + * @var int + */ + private $x; + + /** + * The y position of the pie slice's center + * + * @var int + */ + private $y; + + /** + * The caption of the pie slice, empty string means no caption + * + * @var string + */ + private $caption = ""; + + /** + * The offset of the caption, shifting the indicator from the center of the pie slice + * + * This is required for nested pie slices. + * + * @var int + */ + private $captionOffset = 0; + + /** + * The minimum radius the label must respect + * + * @var int + */ + private $outerCaptionBound = 0; + + /** + * An optional group element to add labels to when rendering + * + * @var DOMElement + */ + private $labelGroup; + + /** + * Create a pie slice + * + * @param int $radius The radius of the slice + * @param int $percent The percentage the slice represents + * @param int $percentStart The percentage where this slice starts + */ + public function __construct($radius, $percent, $percentStart = 0) + { + $this->x = $this->y = $this->radius = $radius; + + $this->startRadian = M_PI * $percentStart/50; + $this->endRadian = M_PI * ($percent + $percentStart)/50; + } + + /** + * Create the path for the pie slice + * + * @param int $x The x position of the pie slice + * @param int $y The y position of the pie slice + * @param int $r The absolute radius of the pie slice + * + * @return string A SVG path string + */ + private function getPieSlicePath($x, $y, $r) + { + // The coordinate system is mirrored on the Y axis, so we have to flip cos and sin + $xStart = $x + ($r * sin($this->startRadian)); + $yStart = $y - ($r * cos($this->startRadian)); + + if ($this->endRadian - $this->startRadian == 2*M_PI) { + // To draw a full circle, adjust arc endpoint by a small (unvisible) value + $this->endRadian -= 0.001; + $pathString = 'M ' . Format::formatSVGNumber($xStart) . ' ' . Format::formatSVGNumber($yStart); + } else { + // Start at the center of the pieslice + $pathString = 'M ' . $x . ' ' . $y; + // Draw a straight line to the upper part of the arc + $pathString .= ' L ' . Format::formatSVGNumber($xStart) . ' ' . Format::formatSVGNumber($yStart); + } + + // Instead of directly connecting the upper part of the arc (leaving a triangle), draw a bow with the radius + $pathString .= ' A ' . Format::formatSVGNumber($r) . ' ' . Format::formatSVGNumber($r); + // These are the flags for the bow, see the SVG path documentation for details + // http://www.w3.org/TR/SVG/paths.html#PathDataEllipticalArcCommands + $pathString .= ' 0 ' . (($this->endRadian - $this->startRadian > M_PI) ? '1' : '0 ') . ' 1'; + + // xEnd and yEnd are the lower point of the arc + $xEnd = $x + ($r * sin($this->endRadian)); + $yEnd = $y - ($r * cos($this->endRadian)); + $pathString .= ' ' . Format::formatSVGNumber($xEnd) . ' ' . Format::formatSVGNumber($yEnd); + + return $pathString; + } + + /** + * Draw the label handler and the text for this pie slice + * + * @param RenderContext $ctx The rendering context to use for coordinate translation + * @param int $r The radius of the pie in absolute coordinates + * + * @return DOMElement The group DOMElement containing the handle and label + */ + private function drawDescriptionLabel(RenderContext $ctx, $r) + { + $group = $ctx->getDocument()->createElement('g'); + $rOuter = ($ctx->xToAbsolute($this->outerCaptionBound) + $ctx->yToAbsolute($this->outerCaptionBound)) / 2; + $addOffset = $rOuter - $r ; + if ($addOffset < 0) { + $addOffset = 0; + } + list($x, $y) = $ctx->toAbsolute($this->x, $this->y); + $midRadius = $this->startRadian + ($this->endRadian - $this->startRadian) / 2; + list($offsetX, $offsetY) = $ctx->toAbsolute($this->captionOffset, $this->captionOffset); + + $midX = $x + intval(($offsetX + $r)/2 * sin($midRadius)); + $midY = $y - intval(($offsetY + $r)/2 * cos($midRadius)); + + // Draw the handle + $path = new Path(array($midX, $midY)); + + $midX += ($addOffset + $r/3) * ($midRadius > M_PI ? -1 : 1); + $path->append(array($midX, $midY))->toAbsolute(); + + $midX += intval($r/2 * sin(M_PI/9)) * ($midRadius > M_PI ? -1 : 1); + $midY -= intval($r/2 * cos(M_PI/3)) * ($midRadius < M_PI*1.4 && $midRadius > M_PI/3 ? -1 : 1); + + if ($ctx->yToRelative($midY) > 100) { + $midY = $ctx->yToAbsolute(100); + } elseif ($ctx->yToRelative($midY) < 0) { + $midY = $ctx->yToAbsolute($ctx->yToRelative(100+$midY)); + } + + $path->append(array($midX , $midY)); + $rel = $ctx->toRelative($midX, $midY); + + // Draw the text box + $text = new Text($rel[0]+1.5, $rel[1], $this->caption); + $text->setFontSize('5em'); + $text->setAlignment(($midRadius > M_PI ? Text::ALIGN_END : Text::ALIGN_START)); + + $group->appendChild($path->toSvg($ctx)); + $group->appendChild($text->toSvg($ctx)); + + return $group; + } + + /** + * Set the x position of the pie slice + * + * @param int $x The new x position + * + * @return $this Fluid interface + */ + public function setX($x) + { + $this->x = $x; + return $this; + } + + /** + * Set the y position of the pie slice + * + * @param int $y The new y position + * + * @return $this Fluid interface + */ + public function setY($y) + { + $this->y = $y; + return $this; + } + + /** + * Set a root element to be used for drawing labels + * + * @param DOMElement $group The label group + * + * @return $this Fluid interface + */ + public function setLabelGroup(DOMElement $group) + { + $this->labelGroup = $group; + return $this; + } + + /** + * Set the caption for this label + * + * @param string $caption The caption for this element + * + * @return $this Fluid interface + */ + public function setCaption($caption) + { + $this->caption = $caption; + return $this; + } + + /** + * Set the internal offset of the caption handle + * + * @param int $offset The offset for the caption handle + * + * @return $this Fluid interface + */ + public function setCaptionOffset($offset) + { + $this->captionOffset = $offset; + return $this; + } + + /** + * Set the minimum radius to be used for drawing labels + * + * @param int $bound The offset for the caption text + * + * @return $this Fluid interface + */ + public function setOuterCaptionBound($bound) + { + $this->outerCaptionBound = $bound; + return $this; + } + + /** + * Create the SVG representation from this Drawable + * + * @param RenderContext $ctx The context to use for rendering + * + * @return DOMElement The SVG Element + */ + public function toSvg(RenderContext $ctx) + { + $doc = $ctx->getDocument(); + $group = $doc->createElement('g'); + $r = ($ctx->xToAbsolute($this->radius) + $ctx->yToAbsolute($this->radius)) / 2; + list($x, $y) = $ctx->toAbsolute($this->x, $this->y); + + $slicePath = $doc->createElement('path'); + + $slicePath->setAttribute('d', $this->getPieSlicePath($x, $y, $r)); + $slicePath->setAttribute('data-icinga-graph-type', 'pieslice'); + + $id = $this->id ?? uniqid('slice-'); + $slicePath->setAttribute('id', $id); + $this->setId($id); + + $style = new DOMDocument(); + $style->loadHTML($this->getStyle()); + + $slicePath->appendChild( + $slicePath->ownerDocument->importNode( + $style->getElementsByTagName('style')->item(0), + true + ) + ); + + $this->applyAttributes($slicePath); + $group->appendChild($slicePath); + if ($this->caption != "") { + $lblGroup = ($this->labelGroup ? $this->labelGroup : $group); + $lblGroup->appendChild($this->drawDescriptionLabel($ctx, $r)); + } + return $group; + } +} diff --git a/library/Icinga/Chart/Primitive/RawElement.php b/library/Icinga/Chart/Primitive/RawElement.php new file mode 100644 index 0000000..721b6e0 --- /dev/null +++ b/library/Icinga/Chart/Primitive/RawElement.php @@ -0,0 +1,43 @@ +<?php +/* Icinga Web 2 | (c) 2013 Icinga Development Team | GPLv2+ */ + +namespace Icinga\Chart\Primitive; + +use DOMElement; +use Icinga\Chart\Render\RenderContext; + +/** + * Wrapper for raw elements to be added as Drawable's + */ +class RawElement implements Drawable +{ + + /** + * The DOMElement wrapped by this Drawable + * + * @var DOMElement + */ + private $domEl; + + /** + * Create this RawElement + * + * @param DOMElement $el The element to wrap here + */ + public function __construct(DOMElement $el) + { + $this->domEl = $el; + } + + /** + * Create the SVG representation from this Drawable + * + * @param RenderContext $ctx The context to use for rendering + * + * @return DOMElement The SVG Element + */ + public function toSvg(RenderContext $ctx) + { + return $this->domEl; + } +} diff --git a/library/Icinga/Chart/Primitive/Rect.php b/library/Icinga/Chart/Primitive/Rect.php new file mode 100644 index 0000000..0c0835f --- /dev/null +++ b/library/Icinga/Chart/Primitive/Rect.php @@ -0,0 +1,119 @@ +<?php +/* Icinga Web 2 | (c) 2013 Icinga Development Team | GPLv2+ */ + +namespace Icinga\Chart\Primitive; + +use DOMElement; +use DOMDocument; +use Icinga\Chart\Render\RenderContext; +use Icinga\Chart\Format; + +/** + * Drawable representing the SVG rect element + */ +class Rect extends Animatable implements Drawable +{ + /** + * The x position + * + * @var int + */ + private $x; + + /** + * The y position + * + * @var int + */ + private $y; + + /** + * The width of this rect + * + * @var int + */ + private $width; + + /** + * The height of this rect + * + * @var int + */ + private $height; + + /** + * Whether to keep the ratio + * + * @var bool + */ + private $keepRatio = false; + + /** + * Create this rect + * + * @param int $x The x position of the rect + * @param int $y The y position of the rectangle + * @param int $width The width of the rectangle + * @param int $height The height of the rectangle + */ + public function __construct($x, $y, $width, $height) + { + $this->x = $x; + $this->y = $y; + $this->width = $width; + $this->height = $height; + } + + /** + * Call to let the rectangle keep the ratio + */ + public function keepRatio() + { + $this->keepRatio = true; + } + + /** + * Create the SVG representation from this Drawable + * + * @param RenderContext $ctx The context to use for rendering + * + * @return DOMElement The SVG Element + */ + public function toSvg(RenderContext $ctx) + { + $doc = $ctx->getDocument(); + $rect = $doc->createElement('rect'); + + list($x, $y) = $ctx->toAbsolute($this->x, $this->y); + if ($this->keepRatio) { + $ctx->keepRatio(); + } + list($width, $height) = $ctx->toAbsolute($this->width, $this->height); + if ($this->keepRatio) { + $ctx->ignoreRatio(); + } + $rect->setAttribute('x', Format::formatSVGNumber($x)); + $rect->setAttribute('y', Format::formatSVGNumber($y)); + $rect->setAttribute('width', Format::formatSVGNumber($width)); + $rect->setAttribute('height', Format::formatSVGNumber($height)); + + $id = $this->id ?? uniqid('rect-'); + $rect->setAttribute('id', $id); + $this->setId($id); + + $this->applyAttributes($rect); + $this->appendAnimation($rect, $ctx); + + $style = new DOMDocument(); + $style->loadHTML($this->getStyle()); + + $rect->appendChild( + $rect->ownerDocument->importNode( + $style->getElementsByTagName('style')->item(0), + true + ) + ); + + return $rect; + } +} diff --git a/library/Icinga/Chart/Primitive/Styleable.php b/library/Icinga/Chart/Primitive/Styleable.php new file mode 100644 index 0000000..15025bf --- /dev/null +++ b/library/Icinga/Chart/Primitive/Styleable.php @@ -0,0 +1,161 @@ +<?php +/* Icinga Web 2 | (c) 2013 Icinga Development Team | GPLv2+ */ + + +namespace Icinga\Chart\Primitive; + +use DOMElement; +use Icinga\Util\Csp; +use ipl\Web\Style; + +/** + * Base class for stylable drawables + */ +class Styleable +{ + + /** + * The stroke width to use + * + * @var int|float + */ + public $strokeWidth = 0; + + /** + * The stroke color to use + * + * @var string + */ + public $strokeColor = '#000'; + + /** + * The fill color to use + * + * @var string + */ + public $fill = 'none'; + + /** + * Additional styles to be appended to the style attribute + * + * @var array<string, string> + */ + public $additionalStyle = []; + + /** + * The id of this element + * + * @var ?string + */ + public $id = null; + + /** + * Additional attributes to be set + * + * @var array + */ + public $attributes = array(); + + /** + * Set the stroke width for this drawable + * + * @param int|float $width The stroke with unit + * + * @return $this Fluid interface + */ + public function setStrokeWidth($width) + { + $this->strokeWidth = $width; + return $this; + } + + /** + * Set the color for the stroke or none for no stroke + * + * @param string $color The color to set for the stroke + * + * @return $this Fluid interface + */ + public function setStrokeColor($color) + { + $this->strokeColor = $color ? $color : 'none'; + return $this; + } + + /** + * Set additional styles for this drawable + * + * @param array<string, string> $styles The styles to set additionally + * + * @return $this Fluid interface + */ + public function setAdditionalStyle($styles) + { + $this->additionalStyle = $styles; + return $this; + } + + /** + * Set the fill for this styleable + * + * @param string $color The color to use for filling or null to use no fill + * + * @return $this Fluid interface + */ + public function setFill($color = null) + { + $this->fill = $color ? $color : 'none'; + return $this; + } + + /** + * Set the id for this element + * + * @param string $id The id to set for this element + * + * @return $this Fluid interface + */ + public function setId($id) + { + $this->id = $id; + + return $this; + } + + /** + * Return the ruleset used for styling the DOMNode + * + * @return Style A ruleset containing styles + */ + public function getStyle() + { + $styles = $this->additionalStyle; + $styles['fill'] = $this->fill; + $styles['stroke'] = $this->strokeColor; + $styles['stroke-width'] = (string) $this->strokeWidth; + + return (new Style()) + ->setNonce(Csp::getStyleNonce()) + ->add("#$this->id", $styles); + } + + /** + * Add an additional attribute to this element + */ + public function setAttribute($key, $value) + { + $this->attributes[$key] = $value; + } + + /** + * Apply attribute to a DOMElement + * + * @param DOMElement $el Element to apply attributes + */ + protected function applyAttributes(DOMElement $el) + { + foreach ($this->attributes as $name => $value) { + $el->setAttribute($name, $value); + } + } +} diff --git a/library/Icinga/Chart/Primitive/Text.php b/library/Icinga/Chart/Primitive/Text.php new file mode 100644 index 0000000..f6bf365 --- /dev/null +++ b/library/Icinga/Chart/Primitive/Text.php @@ -0,0 +1,184 @@ +<?php +/* Icinga Web 2 | (c) 2013 Icinga Development Team | GPLv2+ */ + + +namespace Icinga\Chart\Primitive; + +use DOMDocument; +use DOMElement; +use DOMText; +use Icinga\Chart\Render\RenderContext; +use Icinga\Chart\Format; +use ipl\Html\HtmlDocument; + +/** + * Wrapper for the SVG text element + */ +class Text extends Styleable implements Drawable +{ + /** + * Align the text to end at the x and y position + */ + const ALIGN_END = 'end'; + + /** + * Align the text to start at the x and y position + */ + const ALIGN_START = 'start'; + + /** + * Align the text to be centered at the x and y position + */ + const ALIGN_MIDDLE = 'middle'; + + /** + * The x position of the Text + * + * @var int + */ + private $x; + + /** + * The y position of the Text + * + * @var int + */ + private $y; + + /** + * The text content + * + * @var string + */ + private $text; + + /** + * The size of the font + * + * @var string + */ + private $fontSize = '1.5em'; + + /** + * The weight of the font + * + * @var string + */ + private $fontWeight = 'normal'; + + /** + * The default fill color + * + * @var string + */ + public $fill = '#000'; + + /** + * The alignment of the text + * + * @var string + */ + private $alignment = self::ALIGN_START; + + /** + * Set the font-stretch property of the text + */ + private $fontStretch = 'semi-condensed'; + + /** + * Construct a new text drawable + * + * @param int $x The x position of the text + * @param int $y The y position of the text + * @param string $text The text this component should contain + * @param string $fontSize The font size of the text + */ + public function __construct($x, $y, $text, $fontSize = '1.5em') + { + $this->x = $x; + $this->y = $y; + $this->text = $text; + $this->fontSize = $fontSize; + + $this->setAdditionalStyle([ + 'font-size' => $this->fontSize, + 'font-family' => 'Ubuntu, Calibri, Trebuchet MS, Helvetica, Verdana, sans-serif', + 'font-weight' => $this->fontWeight, + 'font-stretch' => $this->fontStretch, + 'font-style' => 'normal', + 'text-anchor' => $this->alignment + ]); + } + + /** + * Set the font size of the svg text element + * + * @param string $size The font size including a unit + * + * @return $this Fluid interface + */ + public function setFontSize($size) + { + $this->fontSize = $size; + return $this; + } + + /** + * Set the text alignment with one of the ALIGN_* constants + * + * @param String $align Value how to align + * + * @return $this Fluid interface + */ + public function setAlignment($align) + { + $this->alignment = $align; + return $this; + } + + /** + * Set the weight of the current font + * + * @param string $weight The weight of the string + * + * @return $this Fluid interface + */ + public function setFontWeight($weight) + { + $this->fontWeight = $weight; + return $this; + } + + /** + * Create the SVG representation from this Drawable + * + * @param RenderContext $ctx The context to use for rendering + * + * @return DOMElement The SVG Element + */ + public function toSvg(RenderContext $ctx) + { + list($x, $y) = $ctx->toAbsolute($this->x, $this->y); + $text = $ctx->getDocument()->createElement('text'); + $text->setAttribute('x', Format::formatSVGNumber($x - 15)); + + $id = $this->id ?? uniqid('text-'); + $text->setAttribute('id', $id); + $this->setId($id); + + $text->setAttribute('y', Format::formatSVGNumber($y)); + $text->appendChild(new DOMText($this->text)); + + $style = new DOMDocument(); + $style->loadHTML($this->getStyle()); + + $text->appendChild( + $text->ownerDocument->importNode( + $style->getElementsByTagName('style')->item(0), + true + ) + ); + + return $text; + } +} diff --git a/library/Icinga/Chart/Render/LayoutBox.php b/library/Icinga/Chart/Render/LayoutBox.php new file mode 100644 index 0000000..fa49461 --- /dev/null +++ b/library/Icinga/Chart/Render/LayoutBox.php @@ -0,0 +1,200 @@ +<?php +/* Icinga Web 2 | (c) 2013 Icinga Development Team | GPLv2+ */ + +namespace Icinga\Chart\Render; + +use Icinga\Chart\Format; + +/** + * Layout class encapsulating size, padding and margin information + */ +class LayoutBox +{ + /** + * Padding index for top padding + */ + const PADDING_TOP = 0; + + /** + * Padding index for right padding + */ + const PADDING_RIGHT = 1; + + /** + * Padding index for bottom padding + */ + const PADDING_BOTTOM = 2; + + /** + * Padding index for left padding + */ + const PADDING_LEFT = 3; + + /** + * The height of this layout element + * + * @var int + */ + private $height; + + /** + * The width of this layout element + * + * @var int + */ + private $width; + + /** + * The x position of this layout + * + * @var int + */ + private $x; + + /** + * The y position of this layout + * + * @var int + */ + private $y; + + /** + * The padding of this layout + * + * @var array + */ + private $padding = array(0, 0, 0, 0); + + /** + * Create this layout box + * + * Note that x, y, width and height are relative: x with 0 means leftmost, x with 100 means rightmost + * + * @param int $x The relative x coordinate + * @param int $y The relative y coordinate + * @param int $width The optional, relative width + * @param int $height The optional, relative height + */ + public function __construct($x, $y, $width = null, $height = null) + { + $this->height = $height ? $height : 100; + $this->width = $width ? $width : 100; + $this->x = $x; + $this->y = $y; + } + + /** + * Set a padding to all four sides uniformly + * + * @param int $padding The padding to set for all four sides + */ + public function setUniformPadding($padding) + { + $this->padding = array($padding, $padding, $padding, $padding); + } + + /** + * Set the padding for this LayoutBox + * + * @param int $top The top side padding + * @param int $right The right side padding + * @param int $bottom The bottom side padding + * @param int $left The left side padding + */ + public function setPadding($top, $right, $bottom, $left) + { + $this->padding = array($top, $right, $bottom, $left); + } + + /** + * Return a string containing the SVG transform attribute values for the padding + * + * @param RenderContext $ctx The context to determine the translation coordinates + * + * @return string The transformation string + */ + public function getInnerTransform(RenderContext $ctx) + { + list($translateX, $translateY) = $ctx->toAbsolute( + $this->padding[self::PADDING_LEFT] + $this->getX(), + $this->padding[self::PADDING_TOP] + $this->getY() + ); + list($scaleX, $scaleY) = $ctx->paddingToScaleFactor($this->padding); + + $scaleX *= $this->getWidth()/100; + $scaleY *= $this->getHeight()/100; + return sprintf( + 'translate(%s, %s) scale(%s, %s)', + Format::formatSVGNumber($translateX), + Format::formatSVGNumber($translateY), + Format::formatSVGNumber($scaleX), + Format::formatSVGNumber($scaleY) + ); + } + + /** + * String representation for this Layout, for debug purposes + * + * @return string A string containing the bounds of this LayoutBox + */ + public function __toString() + { + return sprintf( + 'Rectangle: x: %s y: %s, height: %s, width: %s', + $this->x, + $this->y, + $this->height, + $this->width + ); + } + + /** + * Return a four element array with the padding + * + * @return array The padding of this LayoutBox + */ + public function getPadding() + { + return $this->padding; + } + + /** + * Return the height of this LayoutBox + * + * @return int The height of this box + */ + public function getHeight() + { + return $this->height; + } + + /** + * Return the width of this LayoutBox + * + * @return int The width of this box + */ + public function getWidth() + { + return $this->width; + } + + /** + * Return the x position of this LayoutBox + * + * @return int The x position of this box + */ + public function getX() + { + return $this->x; + } + + /** + * Return the y position of this LayoutBox + * + * @return int The y position of this box + */ + public function getY() + { + return $this->y; + } +} diff --git a/library/Icinga/Chart/Render/RenderContext.php b/library/Icinga/Chart/Render/RenderContext.php new file mode 100644 index 0000000..457fbf3 --- /dev/null +++ b/library/Icinga/Chart/Render/RenderContext.php @@ -0,0 +1,225 @@ +<?php +/* Icinga Web 2 | (c) 2013 Icinga Development Team | GPLv2+ */ + + +namespace Icinga\Chart\Render; + +use DOMDocument; + +/** + * Context for rendering, handles ratio based coordinate calculations. + * + * The most important functions when rendering are the toAbsolute and roRelative + * values, taking world coordinates and translating them into local coordinates. + */ +class RenderContext +{ + + /** + * The base size of the viewport, i.e. how many units are available on a 1:1 ratio + * + * @var array + */ + private $viewBoxSize = array(1000, 1000); + + + /** + * The DOMDocument for modifying the elements + * + * @var DOMDocument + */ + private $document; + + /** + * If true no ratio correction will be made + * + * @var bool + */ + private $respectRatio = false; + + /** + * The ratio on the x side. A x ration of 2 means that the width of the SVG is divided in 2000 + * units (see $viewBox) + * + * @var int + */ + private $xratio = 1; + + /** + * The ratio on the y side. A y ration of 2 means that the height of the SVG is divided in 2000 + * units (see $viewBox) + * + * @var int + */ + private $yratio = 1; + + /** + * Creates a new context for the given DOM Document + * + * @param DOMDocument $document The DOM document represented by this context + * @param int $width The width (may be approximate) of the document + * (only required for ratio calculation) + * @param int $height The height (may be approximate) of the document + * (only required for ratio calculation) + */ + public function __construct(DOMDocument $document, $width, $height) + { + $this->document = $document; + if ($width > $height) { + $this->xratio = $width / $height; + } elseif ($height > $width) { + $this->yratio = $height / $width; + } + } + + /** + * Return the document represented by this Rendering context + * + * @return DOMDocument The DOMDocument for creating files + */ + public function getDocument() + { + return $this->document; + } + + /** + * Let successive toAbsolute operations ignore ratio correction + * + * This can be called to avoid distortion on certain elements like rectangles. + */ + public function keepRatio() + { + $this->respectRatio = true; + } + + /** + * Let successive toAbsolute operations perform ratio correction + * + * This will cause distortion on certain elements like rectangles. + */ + public function ignoreRatio() + { + $this->respectRatio = false; + } + + /** + * Return how many unit s are available in the Y axis + * + * @return int The number of units available on the y axis + */ + public function getNrOfUnitsY() + { + return intval($this->viewBoxSize[1] * $this->yratio); + } + + /** + * Return how many unit s are available in the X axis + * + * @return int The number of units available on the x axis + */ + public function getNrOfUnitsX() + { + return intval($this->viewBoxSize[0] * $this->xratio); + } + + /** + * Transforms the x,y coordinate from relative coordinates to absolute world coordinates + * + * (50, 50) would be a point in the middle of the document and map to 500, 1000 on a + * 1000 x 1000 viewbox with a 1:2 ratio. + * + * @param int $x The relative x coordinate + * @param int $y The relative y coordinate + * + * @return array An x,y tuple containing absolute coordinates + * @see RenderContext::toRelative + */ + public function toAbsolute($x, $y) + { + return array($this->xToAbsolute($x), $this->yToAbsolute($y)); + } + + /** + * Transforms the x,y coordinate from absolute coordinates to relative world coordinates + * + * This is the inverse function of toAbsolute + * + * @param int $x The absolute x coordinate + * @param int $y The absolute y coordinate + * + * @return array An x,y tupel containing absolute coordinates + * @see RenderContext::toAbsolute + */ + public function toRelative($x, $y) + { + return array($this->xToRelative($x), $this->yToRelative($y)); + } + + /** + * Calculates the scale transformation required to apply the padding on an Canvas + * + * @param array $padding A 4 element array containing top, right, bottom and left padding + * + * @return array An array containing the x and y scale + */ + public function paddingToScaleFactor(array $padding) + { + list($horizontalPadding, $verticalPadding) = $this->toAbsolute( + $padding[LayoutBox::PADDING_RIGHT] + $padding[LayoutBox::PADDING_LEFT], + $padding[LayoutBox::PADDING_TOP] + $padding[LayoutBox::PADDING_BOTTOM] + ); + + return array( + ($this->getNrOfUnitsX() - $horizontalPadding) / $this->getNrOfUnitsX(), + ($this->getNrOfUnitsY() - $verticalPadding) / $this->getNrOfUnitsY() + ); + } + + /** + * Transform a relative x coordinate to an absolute one + * + * @param int $x A relative x coordinate + * + * @return int An absolute x coordinate + **/ + public function xToAbsolute($x) + { + return $this->getNrOfUnitsX() / 100 * $x / ($this->respectRatio ? $this->xratio : 1); + } + + /** + * Transform a relative y coordinate to an absolute one + * + * @param int $y A relative y coordinate + * + * @return int An absolute y coordinate + */ + public function yToAbsolute($y) + { + return $this->getNrOfUnitsY() / 100 * $y / ($this->respectRatio ? $this->yratio : 1); + } + + /** + * Transform a absolute x coordinate to an relative one + * + * @param int $x An absolute x coordinate + * + * @return int A relative x coordinate + */ + public function xToRelative($x) + { + return $x / $this->getNrOfUnitsX() * 100 * ($this->respectRatio ? $this->xratio : 1); + } + + /** + * Transform a absolute y coordinate to an relative one + * + * @param int $y An absolute x coordinate + * + * @return int A relative x coordinate + */ + public function yToRelative($y) + { + return $y / $this->getNrOfUnitsY() * 100 * ($this->respectRatio ? $this->yratio : 1); + } +} diff --git a/library/Icinga/Chart/Render/Rotator.php b/library/Icinga/Chart/Render/Rotator.php new file mode 100644 index 0000000..3e7071c --- /dev/null +++ b/library/Icinga/Chart/Render/Rotator.php @@ -0,0 +1,80 @@ +<?php +/* Icinga Web 2 | (c) 2014 Icinga Development Team | GPLv2+ */ + +namespace Icinga\Chart\Render; + +use Icinga\Chart\Render\RenderContext; +use Icinga\Chart\Primitive\Drawable; +use DOMElement; + +/** + * Class Rotator + * @package Icinga\Chart\Render + */ +class Rotator implements Drawable +{ + /** + * The drawable element to rotate + * + * @var Drawable + */ + private $element; + + /** + * @var int + */ + private $degrees; + + /** + * Wrap an element into a new instance of Rotator + * + * @param Drawable $element The element to rotate + * @param int $degrees The amount of degrees + */ + public function __construct(Drawable $element, $degrees) + { + $this->element = $element; + $this->degrees = $degrees; + } + + /** + * Rotate the given element. + * + * @param RenderContext $ctx The rendering context + * @param DOMElement $el The element to rotate + * @param $degrees The amount of degrees + * + * @return DOMElement The rotated DOMElement + */ + private function rotate(RenderContext $ctx, DOMElement $el, $degrees) + { + // Create a box containing the rotated element relative to the original element position + $container = $ctx->getDocument()->createElement('g'); + $x = $el->getAttribute('x'); + $y = $el->getAttribute('y'); + $container->setAttribute('transform', 'translate(' . $x . ',' . $y . ')'); + $el->removeAttribute('x'); + $el->removeAttribute('y'); + + // Put the element into a rotated group + //$rotate = $ctx->getDocument()->createElement('g'); + $el->setAttribute('transform', 'rotate(' . $degrees . ')'); + //$rotate->appendChild($el); + + $container->appendChild($el); + return $container; + } + + /** + * Create the SVG representation from this Drawable + * + * @param RenderContext $ctx The context to use for rendering + * + * @return DOMElement The SVG Element + */ + public function toSvg(RenderContext $ctx) + { + $el = $this->element->toSvg($ctx); + return $this->rotate($ctx, $el, $this->degrees); + } +} diff --git a/library/Icinga/Chart/SVGRenderer.php b/library/Icinga/Chart/SVGRenderer.php new file mode 100644 index 0000000..d3891f2 --- /dev/null +++ b/library/Icinga/Chart/SVGRenderer.php @@ -0,0 +1,331 @@ +<?php +/* Icinga Web 2 | (c) 2013 Icinga Development Team | GPLv2+ */ + +namespace Icinga\Chart; + +use DOMNode; +use DOMElement; +use DOMDocument; +use DOMImplementation; +use Icinga\Chart\Render\LayoutBox; +use Icinga\Chart\Render\RenderContext; +use Icinga\Chart\Primitive\Canvas; + +/** + * SVG Renderer component. + * + * Creates the basic DOM tree of the SVG to use + */ +class SVGRenderer +{ + const X_ASPECT_RATIO_MIN = 'xMin'; + + const X_ASPECT_RATIO_MID = 'xMid'; + + const X_ASPECT_RATIO_MAX = 'xMax'; + + const Y_ASPECT_RATIO_MIN = 'YMin'; + + const Y_ASPECT_RATIO_MID = 'YMid'; + + const Y_ASPECT_RATIO_MAX = 'YMax'; + + const ASPECT_RATIO_PAD = 'meet'; + + const ASPECT_RATIO_CUTOFF = 'slice'; + + /** + * The XML-document + * + * @var DOMDocument + */ + private $document; + + /** + * The SVG-element + * + * @var DOMNode + */ + private $svg; + + /** + * The description of this SVG, useful for screen readers + * + * @var string + */ + private $ariaDescription; + + /** + * The title of this SVG, useful for screen readers + * + * @var string + */ + private $ariaTitle; + + /** + * The aria role used by this svg element + * + * @var string + */ + private $ariaRole = 'img'; + + /** + * The root layer for all elements + * + * @var Canvas + */ + private $rootCanvas; + + /** + * The width of this renderer + * + * @var int + */ + private $width = 100; + + /** + * The height of this renderer + * + * @var int + */ + private $height = 100; + + /** + * Whether the aspect ratio is preversed + * + * @var bool + */ + private $preserveAspectRatio = false; + + /** + * Horizontal alignment of SVG element + * + * @var string + */ + private $xAspectRatio = self::X_ASPECT_RATIO_MID; + + /** + * Vertical alignment of SVG element + * + * @var string + */ + private $yAspectRatio = self::Y_ASPECT_RATIO_MID; + + /** + * Define whether aspect differences should be handled using padding (default) or cutoff + * + * @var string + */ + private $xFillMode = "meet"; + + + /** + * Create the root document and the SVG root node + */ + private function createRootDocument() + { + $implementation = new DOMImplementation(); + $docType = $implementation->createDocumentType( + 'svg', + '-//W3C//DTD SVG 1.1//EN', + 'http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd' + ); + + $this->document = $implementation->createDocument(null, '', $docType); + $this->svg = $this->createOuterBox(); + $this->document->appendChild($this->svg); + } + + /** + * Create the outer SVG box containing the root svg element and namespace and return it + * + * @return DOMElement The SVG root node + */ + private function createOuterBox() + { + $ctx = $this->createRenderContext(); + $svg = $this->document->createElement('svg'); + $svg->setAttribute('xmlns', 'http://www.w3.org/2000/svg'); + $svg->setAttribute('xmlns:xlink', 'http://www.w3.org/1999/xlink'); + $svg->setAttribute('role', $this->ariaRole); + $svg->setAttribute('width', '100%'); + $svg->setAttribute('height', '100%'); + $svg->setAttribute( + 'viewBox', + sprintf( + '0 0 %s %s', + $ctx->getNrOfUnitsX(), + $ctx->getNrOfUnitsY() + ) + ); + if ($this->preserveAspectRatio) { + $svg->setAttribute( + 'preserveAspectRatio', + sprintf( + '%s%s %s', + $this->xAspectRatio, + $this->yAspectRatio, + $this->xFillMode + ) + ); + } + return $svg; + } + + /** + * Add aria title and description + * + * Adds an aria title and desc element to the given SVG node, which are used to describe this SVG by accessibility + * tools such as screen readers. + * + * @param DOMNode $svg The SVG DOMNode to which the aria attributes should be attached + * @param $title The title text + * @param $description The description text + */ + private function addAriaDescription(DOMNode $svg, $titleText, $descriptionText) + { + $doc = $svg->ownerDocument; + + $titleId = $descId = ''; + if (isset($this->ariaTitle)) { + $titleId = 'aria-title-' . $this->stripNonAlphanumeric($titleText); + $title = $doc->createElement('title'); + $title->setAttribute('id', $titleId); + + $title->appendChild($doc->createTextNode($titleText)); + $svg->appendChild($title); + } + + if (isset($this->ariaDescription)) { + $descId = 'aria-desc-' . $this->stripNonAlphanumeric($descriptionText); + $desc = $doc->createElement('desc'); + $desc->setAttribute('id', $descId); + + $desc->appendChild($doc->createTextNode($descriptionText)); + $svg->appendChild($desc); + } + + $svg->setAttribute('aria-labelledby', join(' ', array($titleId, $descId))); + } + + /** + * Initialises the XML-document, SVG-element and this figure's root canvas + * + * @param int $width The width ratio + * @param int $height The height ratio + */ + public function __construct($width, $height) + { + $this->width = $width; + $this->height = $height; + $this->rootCanvas = new Canvas('root', new LayoutBox(0, 0)); + } + + /** + * Render the SVG-document + * + * @return string The resulting XML structure + */ + public function render() + { + $this->createRootDocument(); + $ctx = $this->createRenderContext(); + $this->addAriaDescription($this->svg, $this->ariaTitle, $this->ariaDescription); + $this->svg->appendChild($this->rootCanvas->toSvg($ctx)); + $this->document->formatOutput = true; + $this->document->encoding = 'UTF-8'; + return $this->document->saveXML(); + } + + /** + * Create a render context that will be used for rendering elements + * + * @return RenderContext The created RenderContext instance + */ + public function createRenderContext() + { + return new RenderContext($this->document, $this->width, $this->height); + } + + /** + * Return the root canvas of this rendered + * + * @return Canvas The canvas that will be the uppermost element in this figure + */ + public function getCanvas() + { + return $this->rootCanvas; + } + + /** + * Preserve the aspect ratio of the rendered object + * + * Do not deform the content of the SVG when the aspect ratio of the viewBox + * differs from the aspect ratio of the SVG element, but add padding or cutoff + * instead + * + * @param bool $preserve Whether the aspect ratio should be preserved + */ + public function preserveAspectRatio($preserve = true) + { + $this->preserveAspectRatio = $preserve; + } + + /** + * Change the horizontal alignment of the SVG element + * + * Change the horizontal alignment of the svg, when preserveAspectRatio is used and + * padding is present. Defaults to + */ + public function setXAspectRatioAlignment($alignment) + { + $this->xAspectRatio = $alignment; + } + + /** + * Change the vertical alignment of the SVG element + * + * Change the vertical alignment of the svg, when preserveAspectRatio is used and + * padding is present. + */ + public function setYAspectRatioAlignment($alignment) + { + $this->yAspectRatio = $alignment; + } + + /** + * Set the aria description, that is used as a title for this SVG in screen readers + * + * @param $text + */ + public function setAriaTitle($text) + { + $this->ariaTitle = $text; + } + + /** + * Set the aria description, that is used to describe this SVG in screen readers + * + * @param $text + */ + public function setAriaDescription($text) + { + $this->ariaDescription = $text; + } + + /** + * Set the aria role, that is used to describe the purpose of this SVG in screen readers + * + * @param $text + */ + public function setAriaRole($text) + { + $this->ariaRole = $text; + } + + + private function stripNonAlphanumeric($str) + { + return preg_replace('/[^A-Za-z]+/', '', $str); + } +} diff --git a/library/Icinga/Chart/Unit/AxisUnit.php b/library/Icinga/Chart/Unit/AxisUnit.php new file mode 100644 index 0000000..251787f --- /dev/null +++ b/library/Icinga/Chart/Unit/AxisUnit.php @@ -0,0 +1,56 @@ +<?php +/* Icinga Web 2 | (c) 2013 Icinga Development Team | GPLv2+ */ + +namespace Icinga\Chart\Unit; + +use Iterator; + +/** + * Base class for Axis Units + * + * An AxisUnit takes a set of values and places them on a given range + * + * Concrete subclasses must implement the iterator interface, with + * getCurrent returning the axis relative position and getValue the label + * that will be displayed + */ +interface AxisUnit extends Iterator +{ + /** + * Add a dataset to this AxisUnit, required for dynamic min and max vlaues + * + * @param array $dataset The dataset that will be shown in the Axis + * @param int $id The idx in the dataset (0 for x, 1 for y) + */ + public function addValues(array $dataset, $id = 0); + + /** + * Transform the given absolute value in an axis relative value + * + * @param int $value The absolute, dataset dependent value + * + * @return int An axis relative value + */ + public function transform($value); + + /** + * Set the axis minimum value to a fixed value + * + * @param int $min The new minimum value + */ + public function setMin($min); + + /** + * Set the axis maximum value to a fixed value + * + * @param int $max The new maximum value + */ + public function setMax($max); + + /** + * Get the amount of ticks of this axis + * + * @return int + */ + public function getTicks(); +} diff --git a/library/Icinga/Chart/Unit/CalendarUnit.php b/library/Icinga/Chart/Unit/CalendarUnit.php new file mode 100644 index 0000000..74680c7 --- /dev/null +++ b/library/Icinga/Chart/Unit/CalendarUnit.php @@ -0,0 +1,167 @@ +<?php +/* Icinga Web 2 | (c) 2013 Icinga Development Team | GPLv2+ */ + + +namespace Icinga\Chart\Unit; + +use DateTime; + +/** + * Calendar Axis Unit that transforms timestamps into user-readable values + * + */ +class CalendarUnit extends LinearUnit +{ + /** + * Constant for a minute + */ + const MINUTE = 60; + + /** + * Constant for an hour + */ + const HOUR = 3600; + + /** + * Constant for a day + */ + const DAY = 864000; + + /** + * Constant for ~a month + * 30 Days, this is sufficient for our needs + */ + const MONTH = 2592000; // x + + /** + * An array containing all labels that will be displayed + * + * @var array + */ + private $labels = array(); + + /** + * The date format to use + * + * @var string + */ + private $dateFormat = 'd-m'; + + /** + * The time format to use + * + * @var string + */ + private $timeFormat = 'g:i:s'; + + /** + * Create the labels for the given dataset + */ + private function createLabels() + { + $this->labels = array(); + $duration = $this->getMax() - $this->getMin(); + + if ($duration <= self::HOUR) { + $unit = self::MINUTE; + } elseif ($duration <= self::DAY) { + $unit = self::HOUR; + } elseif ($duration <= self::MONTH) { + $unit = self::DAY; + } else { + $unit = self::MONTH; + } + $this->calculateLabels($unit); + } + + /** + * Calculate the labels for this dataset + * + * @param integer $unit The unit to use as the basis for calculation + */ + private function calculateLabels($unit) + { + $fac = new DateTime(); + + $duration = $this->getMax() - $this->getMin(); + + // Calculate number of ticks, but not more than 30 + $tickCount = ($duration/$unit * 10); + if ($tickCount > 30) { + $tickCount = 30; + } + + $step = $duration / $tickCount; + $format = $this->timeFormat; + if ($unit === self::DAY) { + $format = $this->dateFormat; + } elseif ($unit === self::MONTH) { + $format = $this->dateFormat; + } + + for ($i = 0; $i <= $duration; $i += $step) { + $this->labels[] = $fac->setTimestamp($this->getMin() + $i)->format($format); + } + } + + /** + * Add a dataset to this CalendarUnit and update labels + * + * @param array $dataset The dataset to update + * @param int $idx The index to use for determining the data + * + * @return $this Fluid interface + */ + public function addValues(array $dataset, $idx = 0) + { + parent::addValues($dataset, $idx); + $this->createLabels(); + return $this; + } + + /** + * Return the current axis relative position + * + * @return int The position of the next tick (between 0 and 100) + */ + public function current(): int + { + return 100 * (key($this->labels) / count($this->labels)); + } + + /** + * Move to next tick + */ + public function next(): void + { + next($this->labels); + } + + /** + * Return the current tick caption + * + * @return string + */ + public function key(): string + { + return current($this->labels); + } + + /** + * Return true when the iterator is in a valid range + * + * @return bool + */ + public function valid(): bool + { + return current($this->labels) !== false; + } + + /** + * Rewind the internal array + */ + public function rewind(): void + { + reset($this->labels); + } +} diff --git a/library/Icinga/Chart/Unit/LinearUnit.php b/library/Icinga/Chart/Unit/LinearUnit.php new file mode 100644 index 0000000..ea4792b --- /dev/null +++ b/library/Icinga/Chart/Unit/LinearUnit.php @@ -0,0 +1,227 @@ +<?php +/* Icinga Web 2 | (c) 2013 Icinga Development Team | GPLv2+ */ + +namespace Icinga\Chart\Unit; + +/** + * Linear tick distribution over the axis + */ +class LinearUnit implements AxisUnit +{ + /** + * The minimum value to display + * + * @var int + */ + protected $min; + + /** + * The maximum value to display + * + * @var int + */ + protected $max; + + /** + * True when the minimum value is static and isn't affected by the dataset + * + * @var bool + */ + protected $staticMin = false; + + /** + * True when the maximum value is static and isn't affected by the dataset + * + * @var bool + */ + protected $staticMax = false; + + /** + * The number of ticks to use + * + * @var int + */ + protected $nrOfTicks = 10; + + /** + * The currently displayed tick + * + * @var int + */ + protected $currentTick = 0; + + /** + * The currently displayed value + * @var int + */ + protected $currentValue = 0; + + /** + * Create and initialize this AxisUnit + * + * @param int $nrOfTicks The number of ticks to use + */ + public function __construct($nrOfTicks = 10) + { + $this->min = PHP_INT_MAX; + $this->max = ~PHP_INT_MAX; + $this->nrOfTicks = $nrOfTicks; + } + + /** + * Add a dataset and calculate the minimum and maximum value for this AxisUnit + * + * @param array $dataset The dataset to add + * @param int $idx The idx (0 for x, 1 for y) + * + * @return $this Fluent interface + */ + public function addValues(array $dataset, $idx = 0) + { + $datapoints = array(); + + foreach ($dataset['data'] as $points) { + $datapoints[] = $points[$idx]; + } + if (empty($datapoints)) { + return $this; + } + sort($datapoints); + if (!$this->staticMax) { + $this->max = max($this->max, $datapoints[count($datapoints) - 1]); + } + if (!$this->staticMin) { + $this->min = min($this->min, $datapoints[0]); + } + $this->currentTick = 0; + $this->currentValue = $this->min; + if ($this->max === $this->min) { + $this->max = $this->min + 10; + } + $this->nrOfTicks = $this->max - $this->min; + return $this; + } + + /** + * Transform the absolute value to an axis relative value + * + * @param int $value The absolute coordinate from the dataset + * @return float|int The axis relative coordinate (between 0 and 100) + */ + public function transform($value) + { + if ($value < $this->min) { + return 0; + } elseif ($value > $this->max) { + return 100; + } else { + return 100 * ($value - $this->min) / $this->nrOfTicks; + } + } + + /** + * Return the position of the current tick + * + * @return int + */ + #[\ReturnTypeWillChange] + public function current() + { + return $this->currentTick; + } + + /** + * Calculate the next tick and tick value + */ + public function next(): void + { + $this->currentTick += (100 / $this->nrOfTicks); + $this->currentValue += (($this->max - $this->min) / $this->nrOfTicks); + } + + /** + * Return the label for the current tick + * + * @return string The label for the current tick + */ + #[\ReturnTypeWillChange] + public function key() + { + return (string) intval($this->currentValue); + } + + /** + * True when we're at a valid tick (iterator interface) + * + * @return bool + */ + public function valid(): bool + { + return $this->currentTick >= 0 && $this->currentTick <= 100; + } + + /** + * Reset the current tick and label value + */ + public function rewind(): void + { + $this->currentTick = 0; + $this->currentValue = $this->min; + } + + /** + * Set the axis maximum value to a fixed value + * + * @param int $max The new maximum value + */ + public function setMax($max) + { + if ($max !== null) { + $this->max = $max; + $this->staticMax = true; + } + } + + /** + * Set the axis minimum value to a fixed value + * + * @param int $min The new minimum value + */ + public function setMin($min) + { + if ($min !== null) { + $this->min = $min; + $this->staticMin = true; + } + } + + /** + * Return the current minimum value of the axis + * + * @return int The minimum set for this axis + */ + public function getMin() + { + return $this->min; + } + + /** + * Return the current maximum value of the axis + * + * @return int The maximum set for this axis + */ + public function getMax() + { + return $this->max; + } + + /** + * Get the amount of ticks necessary to display this AxisUnit + * + * @return int + */ + public function getTicks() + { + return $this->nrOfTicks; + } +} diff --git a/library/Icinga/Chart/Unit/LogarithmicUnit.php b/library/Icinga/Chart/Unit/LogarithmicUnit.php new file mode 100644 index 0000000..70961e2 --- /dev/null +++ b/library/Icinga/Chart/Unit/LogarithmicUnit.php @@ -0,0 +1,263 @@ +<?php +/* Icinga Web 2 | (c) 2013 Icinga Development Team | GPLv2+ */ + +namespace Icinga\Chart\Unit; + +/** + * Logarithmic tick distribution over the axis + * + * This class does not use the actual logarithm, but a slightly altered version called the + * Log-Modulo transformation. This is necessary, since a regular logarithmic scale is not able to display negative + * values and zero-points. See <a href="http://blogs.sas.com/content/iml/2014/07/14/log-transformation-of-pos-neg> + * this article </a> for a more detailed description. + */ +class LogarithmicUnit implements AxisUnit +{ + /** + * @var int + */ + protected $base; + + /** + * @var + */ + protected $currentTick; + + /** + * @var + */ + protected $minExp; + + /** + * @var + */ + protected $maxExp; + + /** + * True when the minimum value is static and isn't affected by the data set + * + * @var bool + */ + protected $staticMin = false; + + /** + * True when the maximum value is static and isn't affected by the data set + * + * @var bool + */ + protected $staticMax = false; + + /** + * Create and initialize this AxisUnit + * + * @param int $nrOfTicks The number of ticks to use + */ + public function __construct($base = 10) + { + $this->base = $base; + $this->minExp = PHP_INT_MAX; + $this->maxExp = ~PHP_INT_MAX; + } + + /** + * Add a dataset and calculate the minimum and maximum value for this AxisUnit + * + * @param array $dataset The dataset to add + * @param int $idx The idx (0 for x, 1 for y) + * + * @return $this Fluent interface + */ + public function addValues(array $dataset, $idx = 0) + { + $datapoints = array(); + + foreach ($dataset['data'] as $points) { + $datapoints[] = $points[$idx]; + } + if (empty($datapoints)) { + return $this; + } + sort($datapoints); + if (!$this->staticMax) { + $this->maxExp = max($this->maxExp, $this->logCeil($datapoints[count($datapoints) - 1])); + } + if (!$this->staticMin) { + $this->minExp = min($this->minExp, $this->logFloor($datapoints[0])); + } + $this->currentTick = 0; + + return $this; + } + + /** + * Transform the absolute value to an axis relative value + * + * @param int $value The absolute coordinate from the data set + * @return float|int The axis relative coordinate (between 0 and 100) + */ + public function transform($value) + { + if ($value < $this->pow($this->minExp)) { + return 0; + } elseif ($value > $this->pow($this->maxExp)) { + return 100; + } else { + return 100 * ($this->log($value) - $this->minExp) / $this->getTicks(); + } + } + + /** + * Return the position of the current tick + * + * @return int + */ + public function current(): int + { + return $this->currentTick * (100 / $this->getTicks()); + } + + /** + * Calculate the next tick and tick value + */ + public function next(): void + { + ++ $this->currentTick; + } + + /** + * Return the label for the current tick + * + * @return string The label for the current tick + */ + public function key(): string + { + $currentBase = $this->currentTick + $this->minExp; + if (abs($currentBase) > 4) { + return $this->base . 'E' . $currentBase; + } + return (string) intval($this->pow($currentBase)); + } + + /** + * True when we're at a valid tick (iterator interface) + * + * @return bool + */ + public function valid(): bool + { + return $this->currentTick >= 0 && $this->currentTick < $this->getTicks(); + } + + /** + * Reset the current tick and label value + */ + public function rewind(): void + { + $this->currentTick = 0; + } + + /** + * Perform a log-modulo transformation + * + * @param $value The value to transform + * + * @return double The transformed value + */ + protected function log($value) + { + $sign = $value > 0 ? 1 : -1; + return $sign * log1p($sign * $value) / log($this->base); + } + + /** + * Calculate the biggest exponent necessary to display the given data point + * + * @param $value + * + * @return float + */ + protected function logCeil($value) + { + return ceil($this->log($value)) + 1; + } + + /** + * Calculate the smallest exponent necessary to display the given data point + * + * @param $value + * + * @return float + */ + protected function logFloor($value) + { + return floor($this->log($value)); + } + + /** + * Inverse function to the log-modulo transformation + * + * @param $value + * + * @return double + */ + protected function pow($value) + { + if ($value == 0) { + return 0; + } + $sign = $value > 0 ? 1 : -1; + return $sign * (pow($this->base, $sign * $value)); + } + + /** + * Set the axis minimum value to a fixed value + * + * @param int $min The new minimum value + */ + public function setMin($min) + { + $this->minExp = $this->logFloor($min); + $this->staticMin = true; + } + + /** + * Set the axis maximum value to a fixed value + * + * @param int $max The new maximum value + */ + public function setMax($max) + { + $this->maxExp = $this->logCeil($max); + $this->staticMax = true; + } + + /** + * Return the current minimum value of the axis + * + * @return int The minimum set for this axis + */ + public function getMin() + { + return $this->pow($this->minExp); + } + + /** + * Return the current maximum value of the axis + * + * @return int The maximum set for this axis + */ + public function getMax() + { + return $this->pow($this->maxExp); + } + + /** + * Get the amount of ticks necessary to display this AxisUnit + * + * @return int + */ + public function getTicks() + { + return $this->maxExp - $this->minExp; + } +} diff --git a/library/Icinga/Chart/Unit/StaticAxis.php b/library/Icinga/Chart/Unit/StaticAxis.php new file mode 100644 index 0000000..6b32aca --- /dev/null +++ b/library/Icinga/Chart/Unit/StaticAxis.php @@ -0,0 +1,130 @@ +<?php +/* Icinga Web 2 | (c) 2013 Icinga Development Team | GPLv2+ */ + + +namespace Icinga\Chart\Unit; + +class StaticAxis implements AxisUnit +{ + private $items = array(); + + /** + * Add a dataset to this AxisUnit, required for dynamic min and max values + * + * @param array $dataset The dataset that will be shown in the Axis + * @param int $idx The idx in the dataset (0 for x, 1 for y) + * + * @return $this Fluent interface + */ + public function addValues(array $dataset, $idx = 0) + { + $datapoints = array(); + foreach ($dataset['data'] as $points) { + $this->items[] = $points[$idx]; + } + $this->items = array_unique($this->items); + + return $this; + } + + /** + * Transform the given absolute value in an axis relative value + * + * @param int $value The absolute, dataset dependent value + * + * @return int An axis relative value + */ + public function transform($value) + { + $flipped = array_flip($this->items); + if (!isset($flipped[$value])) { + return 0; + } + $pos = $flipped[$value]; + return 1 + (99 / count($this->items) * $pos); + } + /** + * Set the axis minimum value to a fixed value + * + * @param int $min The new minimum value + */ + public function setMin($min) + { + } + + /** + * Set the axis maximum value to a fixed value + * + * @param int $max The new maximum value + */ + public function setMax($max) + { + } + + /** + * (PHP 5 >= 5.0.0)<br/> + * Return the current element + * @link http://php.net/manual/en/iterator.current.php + * @return int. + */ + public function current(): int + { + return 1 + (99 / count($this->items) * key($this->items)); + } + + /** + * (PHP 5 >= 5.0.0)<br/> + * Move forward to next element + * @link http://php.net/manual/en/iterator.next.php + * @return void Any returned value is ignored. + */ + public function next(): void + { + next($this->items); + } + + /** + * (PHP 5 >= 5.0.0)<br/> + * Return the key of the current element + * @link http://php.net/manual/en/iterator.key.php + * @return mixed scalar on success, or null on failure. + */ + #[\ReturnTypeWillChange] + public function key() + { + return current($this->items); + } + + /** + * (PHP 5 >= 5.0.0)<br/> + * Checks if current position is valid + * @link http://php.net/manual/en/iterator.valid.php + * @return boolean The return value will be casted to boolean and then evaluated. + * Returns true on success or false on failure. + */ + public function valid(): bool + { + return current($this->items) !== false; + } + + /** + * (PHP 5 >= 5.0.0)<br/> + * Rewind the Iterator to the first element + * @link http://php.net/manual/en/iterator.rewind.php + * @return void Any returned value is ignored. + */ + public function rewind(): void + { + reset($this->items); + } + + /** + * Get the amount of ticks of this axis + * + * @return int + */ + public function getTicks() + { + return count($this->items); + } +} diff --git a/library/Icinga/Cli/AnsiScreen.php b/library/Icinga/Cli/AnsiScreen.php new file mode 100644 index 0000000..2780f08 --- /dev/null +++ b/library/Icinga/Cli/AnsiScreen.php @@ -0,0 +1,122 @@ +<?php +/* Icinga Web 2 | (c) 2013 Icinga Development Team | GPLv2+ */ + +namespace Icinga\Cli; + +use Icinga\Cli\Screen; +use Icinga\Exception\IcingaException; + +// @see http://en.wikipedia.org/wiki/ANSI_escape_code + +class AnsiScreen extends Screen +{ + protected $fgColors = array( + 'black' => '30', + 'darkgray' => '1;30', + 'red' => '31', + 'lightred' => '1;31', + 'green' => '32', + 'lightgreen' => '1;32', + 'brown' => '33', + 'yellow' => '1;33', + 'blue' => '34', + 'lightblue' => '1;34', + 'purple' => '35', + 'lightpurple' => '1;35', + 'cyan' => '36', + 'lightcyan' => '1;36', + 'lightgray' => '37', + 'white' => '1;37', + ); + + protected $bgColors = array( + 'black' => '40', + 'red' => '41', + 'green' => '42', + 'brown' => '43', + 'blue' => '44', + 'purple' => '45', + 'cyan' => '46', + 'lightgray' => '47', + ); + + public function strlen($string) + { + return strlen($this->stripAnsiCodes($string)); + } + + public function stripAnsiCodes($string) + { + return preg_replace('/\e\[?.*?[\@-~]/', '', $string); + } + + public function clear() + { + return "\033[2J" // Clear the whole screen + . "\033[1;1H" // Move the cursor to row 1, column 1 + . "\033[1S"; // Scroll whole page up by 1 line (why?) + } + + public function underline($text) + { + return "\033[4m" + . $text + . "\033[0m"; // Reset color codes + } + + public function colorize($text, $fgColor = null, $bgColor = null) + { + return $this->startColor($fgColor, $bgColor) + . $text + . "\033[0m"; // Reset color codes + } + + protected function fgColor($color) + { + if (! array_key_exists($color, $this->fgColors)) { + throw new IcingaException( + 'There is no such foreground color: %s', + $color + ); + } + return $this->fgColors[$color]; + } + + protected function bgColor($color) + { + if (! array_key_exists($color, $this->bgColors)) { + throw new IcingaException( + 'There is no such background color: %s', + $color + ); + } + return $this->bgColors[$color]; + } + + protected function startColor($fgColor = null, $bgColor = null) + { + $escape = "ESC["; + $parts = array(); + if ($fgColor !== null + && $bgColor !== null + && ! array_key_exists($bgColor, $this->bgColors) + && array_key_exists($bgColor, $this->fgColors) + && array_key_exists($fgColor, $this->bgColors) + ) { + $parts[] = '7'; // reverse video, negative image + $parts[] = $this->bgColor($fgColor); + $parts[] = $this->fgColor($bgColor); + } else { + if ($fgColor !== null) { + $parts[] = $this->fgColor($fgColor); + } + if ($bgColor !== null) { + $parts[] = $this->bgColor($bgColor); + } + } + if (empty($parts)) { + return ''; + } + return "\033[" . implode(';', $parts) . 'm'; + } +} diff --git a/library/Icinga/Cli/Command.php b/library/Icinga/Cli/Command.php new file mode 100644 index 0000000..7fd5f87 --- /dev/null +++ b/library/Icinga/Cli/Command.php @@ -0,0 +1,216 @@ +<?php +/* Icinga Web 2 | (c) 2013 Icinga Development Team | GPLv2+ */ + +namespace Icinga\Cli; + +use Icinga\Application\ApplicationBootstrap as App; +use Icinga\Application\Config; +use Icinga\Application\Logger; +use Icinga\Exception\IcingaException; +use Icinga\Exception\NotReadableError; +use ipl\I18n\Translation; + +abstract class Command +{ + use Translation; + + protected $app; + protected $docs; + + /** + * @var Params + */ + protected $params; + protected $screen; + + /** + * Whether the --verbose switch is given and thus the set log level INFO is + * + * @var bool + */ + protected $isVerbose; + + /** + * Whether the --debug switch is given and thus the set log level DEBUG is + * + * @var bool + */ + protected $isDebugging; + + protected $moduleName; + protected $commandName; + protected $actionName; + + protected $config; + + protected $configs; + + protected $defaultActionName = 'default'; + + /** @var bool Whether to automatically load enabled modules */ + protected $loadEnabledModules = true; + + /** @var bool Whether to enable trace for the CLI commands */ + protected $trace = false; + + public function __construct(App $app, $moduleName, $commandName, $actionName, $initialize = true) + { + $this->app = $app; + $this->moduleName = $moduleName; + $this->commandName = $commandName; + $this->actionName = $actionName; + $this->params = $app->getParams(); + $this->screen = Screen::instance(); + $this->trace = $this->params->shift('trace', false); + $this->isVerbose = $this->params->shift('verbose', false); + $this->isDebugging = $this->params->shift('debug', false); + $this->configs = []; + + $this->translationDomain = $moduleName ?: 'icinga'; + + if ($this->loadEnabledModules) { + try { + $app->getModuleManager()->loadEnabledModules(); + } catch (NotReadableError $e) { + Logger::error(new IcingaException('Cannot load enabled modules. An exception was thrown:', $e)); + } + } + + if ($initialize) { + $this->init(); + } + } + + public function Config($file = null) + { + if ($this->isModule()) { + return $this->getModuleConfig($file); + } else { + return $this->getMainConfig($file); + } + } + + private function getModuleConfig($file = null) + { + if ($file === null) { + if ($this->config === null) { + $this->config = Config::module($this->moduleName); + } + return $this->config; + } else { + if (! array_key_exists($file, $this->configs)) { + $this->configs[$file] = Config::module($this->moduleName, $file); + } + return $this->configs[$file]; + } + } + + private function getMainConfig($file = null) + { + if ($file === null) { + if ($this->config === null) { + $this->config = Config::app(); + } + return $this->config; + } else { + if (! array_key_exists($file, $this->configs)) { + $this->configs[$file] = Config::app($file); + } + return $this->configs[$file]; + } + return $this->config; + } + + public function isModule() + { + return substr(get_class($this), 0, 14) === 'Icinga\\Module\\'; + } + + public function setParams(Params $params) + { + $this->params = $params; + } + + public function hasRemainingParams() + { + return $this->params->count() > 0; + } + + public function showTrace() + { + return $this->trace; + } + + /** + * @param $msg + * + * @throws IcingaException + */ + public function fail($msg) + { + throw new IcingaException('%s', $msg); + } + + public function getDefaultActionName() + { + return $this->defaultActionName; + } + + /** + * Get {@link moduleName} + * + * @return string + */ + public function getModuleName() + { + return $this->moduleName; + } + + public function hasDefaultActionName() + { + return $this->hasActionName($this->defaultActionName); + } + + public function hasActionName($name) + { + $actions = $this->listActions(); + return in_array($name, $actions); + } + + public function listActions() + { + $actions = array(); + foreach (get_class_methods($this) as $method) { + if (preg_match('~^([A-Za-z0-9]+)Action$~', $method, $m)) { + $actions[] = $m[1]; + } + } + sort($actions); + return $actions; + } + + public function docs() + { + if ($this->docs === null) { + $this->docs = new Documentation($this->app); + } + return $this->docs; + } + + public function showUsage($action = null) + { + if ($action === null) { + $action = $this->actionName; + } + echo $this->docs()->usage( + $this->moduleName, + $this->commandName, + $action + ); + return false; + } + + public function init() + { + } +} diff --git a/library/Icinga/Cli/Documentation.php b/library/Icinga/Cli/Documentation.php new file mode 100644 index 0000000..6881467 --- /dev/null +++ b/library/Icinga/Cli/Documentation.php @@ -0,0 +1,167 @@ +<?php +/* Icinga Web 2 | (c) 2013 Icinga Development Team | GPLv2+ */ + +namespace Icinga\Cli; + +use Icinga\Application\ApplicationBootstrap as App; +use Icinga\Cli\Documentation\CommentParser; +use ReflectionClass; +use ReflectionMethod; + +class Documentation +{ + protected $icinga; + + protected $app; + + protected $loader; + + public function __construct(App $app) + { + $this->app = $app; + $this->loader = $app->cliLoader(); + } + + public function usage($module = null, $command = null, $action = null) + { + if ($module) { + $module = $this->loader->resolveModuleName($module); + return $this->moduleUsage($module, $command, $action); + } + if ($command) { + $command = $this->loader->resolveCommandName($command); + return $this->commandUsage($command, $action); + } + return $this->globalUsage(); + } + + public function globalUsage() + { + $d = "USAGE: icingacli [module] <command> [action] [options]\n\n" + . "Available commands:\n\n"; + foreach ($this->loader->listCommands() as $command) { + if ($command !== 'autocomplete') { + $obj = $this->loader->getCommandInstance($command); + $d .= sprintf( + " %-14s %s\n", + $command, + $this->getClassTitle($obj) + ); + } + } + $d .= "\nAvailable modules:\n\n"; + foreach ($this->loader->listModules() as $module) { + $d .= ' ' . $module . "\n"; + } + $d .= "\nGlobal options:\n\n" + . " --log [t] Log to <t>, either stderr, file or syslog (default: stderr)\n" + . " --log-path <f> Which file to log into in case of --log file\n" + . " --verbose Be verbose\n" + . " --debug Show debug output\n" + . " --help Show help\n" + . " --benchmark Show benchmark summary\n" + . " --watch [s] Refresh output every <s> seconds (default: 5)\n" + . " --version Shows version of Icinga Web 2, loaded modules and PHP\n" + ; + $d .= "\nShow help on a specific command : icingacli help <command>" + . "\nShow help on a specific module : icingacli help <module>" + . "\n"; + return $d; + } + + public function moduleUsage($module, $command = null, $action = null) + { + $commands = $this->loader->listModuleCommands($module); + + if (empty($commands)) { + return "The '$module' module does not provide any CLI commands\n"; + } + $d = ''; + $obj = null; + if ($command) { + $obj = $this->loader->getModuleCommandInstance($module, $command); + } + if ($command === null) { + $d = "USAGE: icingacli $module <command> [<action>] [options]\n\n" + . "Available commands:\n\n"; + foreach ($commands as $command) { + $d .= ' ' . $command . "\n"; + } + $d .= "\nShow help on a specific command: icingacli help $module <command>\n"; + } elseif ($action === null) { + $d .= $this->showCommandActions($obj, $command); + } else { + $action = $this->loader->resolveObjectActionName($obj, $action); + $d .= $this->getMethodDocumentation($obj, $action); + } + return $d; + } + + /** + * @param Command $command + * @param string $name + * + * @return string + */ + protected function showCommandActions($command, $name) + { + $actions = $command->listActions(); + $d = $this->getClassDocumentation($command) + . "Available actions:\n\n"; + foreach ($actions as $action) { + $d .= sprintf( + " %-14s %s\n", + $action, + $this->getMethodTitle($command, $action) + ); + } + $d .= "\nShow help on a specific action: icingacli help "; + if ($command->isModule()) { + $d .= $command->getModuleName() . ' '; + } + $d .= "$name <action>\n"; + return $d; + } + + public function commandUsage($command, $action = null) + { + $obj = $this->loader->getCommandInstance($command); + $action = $this->loader->resolveObjectActionName($obj, $action); + + $d = "\n"; + if ($action) { + $d .= $this->getMethodDocumentation($obj, $action); + } else { + $d .= $this->showCommandActions($obj, $command); + } + return $d; + } + + protected function getClassTitle($class) + { + $ref = new ReflectionClass($class); + $comment = new CommentParser($ref->getDocComment()); + return $comment->getTitle(); + } + + protected function getClassDocumentation($class) + { + $ref = new ReflectionClass($class); + $comment = new CommentParser($ref->getDocComment()); + return $comment->dump(); + } + + protected function getMethodTitle($class, $method) + { + $ref = new ReflectionMethod($class, $method . 'Action'); + $comment = new CommentParser($ref->getDocComment()); + return $comment->getTitle(); + } + + protected function getMethodDocumentation($class, $method) + { + $ref = new ReflectionMethod($class, $method . 'Action'); + $comment = new CommentParser($ref->getDocComment()); + return $comment->dump(); + } +} diff --git a/library/Icinga/Cli/Documentation/CommentParser.php b/library/Icinga/Cli/Documentation/CommentParser.php new file mode 100644 index 0000000..4104848 --- /dev/null +++ b/library/Icinga/Cli/Documentation/CommentParser.php @@ -0,0 +1,85 @@ +<?php +/* Icinga Web 2 | (c) 2013 Icinga Development Team | GPLv2+ */ + +namespace Icinga\Cli\Documentation; + +use Icinga\Cli\Screen; + +class CommentParser +{ + protected $raw; + protected $plain; + protected $title; + protected $paragraphs = array(); + + public function __construct($raw) + { + $this->raw = $raw; + if ($raw) { + $this->parse(); + } + } + + public function getTitle() + { + return $this->title; + } + + protected function parse() + { + $plain = $this->raw; + + // Strip comment start /** + $plain = preg_replace('~^/\s*\*\*\n~s', '', $plain); + + // Strip comment end */ + $plain = preg_replace('~\n\s*\*/\s*~s', "\n", $plain); + $p = null; + foreach (preg_split('~\n~', $plain) as $line) { + // Strip * at line start + $line = preg_replace('~^\s*\*\s?~', '', $line); + $line = rtrim($line); + if ($this->title === null) { + $this->title = $line; + continue; + } + if ($p === null && empty($this->paragraphs)) { + $p = & $this->paragraphs[]; + } + + if ($line === '') { + if ($p !== null) { + $p = & $this->paragraphs[]; + } + continue; + } + if ($p === null) { + $p = $line; + } else { + if (substr($line, 0, 2) === ' ') { + $p .= "\n" . $line; + } else { + $p .= ' ' . $line; + } + } + } + if ($p === null) { + array_pop($this->paragraphs); + } + } + + public function dump() + { + if ($this->title) { + $res = $this->title . "\n" . str_repeat('=', strlen($this->title)) . "\n\n"; + } else { + $res = ''; + } + + foreach ($this->paragraphs as $p) { + $res .= wordwrap($p, Screen::instance()->getColumns()) . "\n\n"; + } + + return $res; + } +} diff --git a/library/Icinga/Cli/Loader.php b/library/Icinga/Cli/Loader.php new file mode 100644 index 0000000..5e63f3f --- /dev/null +++ b/library/Icinga/Cli/Loader.php @@ -0,0 +1,501 @@ +<?php +/* Icinga Web 2 | (c) 2013 Icinga Development Team | GPLv2+ */ + +namespace Icinga\Cli; + +use Icinga\Application\ApplicationBootstrap as App; +use Icinga\Exception\IcingaException; +use Icinga\Exception\NotReadableError; +use Icinga\Exception\ProgrammingError; +use Icinga\Cli\Params; +use Icinga\Cli\Screen; +use Icinga\Cli\Command; +use Icinga\Cli\Documentation; +use Exception; + +/** + * + */ +class Loader +{ + protected $app; + + protected $docs; + + protected $commands; + + protected $modules; + + protected $moduleCommands = array(); + + protected $coreAppDir; + + protected $screen; + + protected $moduleName; + + protected $commandName; + + protected $actionName; // Should this better be moved to the Command? + + /** + * [$command] = $class; + */ + protected $commandClassMap = array(); + + /** + * [$command] = $file; + */ + protected $commandFileMap = array(); + + /** + * [$module][$command] = $class; + */ + protected $moduleClassMap = array(); + + /** + * [$module][$command] = $file; + */ + protected $moduleFileMap = array(); + + protected $commandInstances = array(); + + protected $moduleInstances = array(); + + protected $lastSuggestions = array(); + + public function __construct(App $app) + { + $this->app = $app; + $this->coreAppDir = $app->getApplicationDir('clicommands'); + } + + /** + * Screen shortcut + * + * @return Screen + */ + protected function screen() + { + if ($this->screen === null) { + $this->screen = Screen::instance(STDERR); + } + + return $this->screen; + } + + /** + * Documentation shortcut + * + * @return Documentation + */ + protected function docs() + { + if ($this->docs === null) { + $this->docs = new Documentation($this->app); + } + return $this->docs; + } + + /** + * Show given message and exit + * + * @param string $msg message to show + */ + public function fail($msg) + { + fprintf(STDERR, "%s: %s\n", $this->screen()->colorize('ERROR', 'red'), $msg); + exit(1); + } + + public function getModuleName() + { + return $this->moduleName; + } + + public function setModuleName($name) + { + $this->moduleName = $name; + return $this; + } + + public function getCommandName() + { + return $this->commandName; + } + + public function getActionName() + { + return $this->actionName; + } + + public function getCommandInstance($command) + { + if (! array_key_exists($command, $this->commandInstances)) { + $this->assertCommandExists($command); + require_once $this->commandFileMap[$command]; + $className = $this->commandClassMap[$command]; + $this->commandInstances[$command] = new $className( + $this->app, + null, + $command, + null, + false + ); + } + return $this->commandInstances[$command]; + } + + public function getModuleCommandInstance($module, $command) + { + if (! array_key_exists($command, $this->moduleInstances[$module])) { + $this->assertModuleCommandExists($module, $command); + require_once $this->moduleFileMap[$module][$command]; + $className = $this->moduleClassMap[$module][$command]; + $this->moduleInstances[$module][$command] = new $className( + $this->app, + $module, + $command, + null, + false + ); + } + return $this->moduleInstances[$module][$command]; + } + + public function getLastSuggestions() + { + return $this->lastSuggestions; + } + + public function showLastSuggestions() + { + if (! empty($this->lastSuggestions)) { + foreach ($this->lastSuggestions as & $s) { + $s = $this->screen()->colorize($s, 'lightblue'); + } + fprintf( + STDERR, + "Did you mean %s?\n", + implode(" or ", $this->lastSuggestions) + ); + } + } + + public function parseParams(Params $params = null) + { + if ($params === null) { + $params = $this->app->getParams(); + } + + $first = null; + if ($this->moduleName === null) { + $first = $params->shift(); + if (! $first) { + return; + } + $found = $this->resolveName($first); + } else { + $found = $this->moduleName; + } + if (! $found) { + $msg = "There is no such module or command: '$first'"; + fprintf(STDERR, "%s: %s\n", $this->screen()->colorize('ERROR', 'red'), $msg); + $this->showLastSuggestions(); + fwrite(STDERR, "\n"); + } + + $obj = null; + if ($this->hasCommand($found)) { + $this->commandName = $found; + $obj = $this->getCommandInstance($this->commandName); + } elseif ($this->hasModule($found)) { + $this->moduleName = $found; + $command = $this->resolveModuleCommandName($found, $params->shift()); + if ($command) { + $this->commandName = $command; + $obj = $this->getModuleCommandInstance( + $this->moduleName, + $this->commandName + ); + } + } + if ($obj !== null) { + $action = $this->resolveObjectActionName( + $obj, + $params->getStandalone() + ); + if ($obj->hasActionName($action)) { + $this->actionName = $action; + $params->shift(); + } elseif ($obj->hasDefaultActionName()) { + $this->actionName = $obj->getDefaultActionName(); + } + } + return $this; + } + + public function handleParams(Params $params = null) + { + $this->parseParams($params); + $this->dispatch(); + } + + public function dispatch(Params $overrideParams = null) + { + if ($this->commandName === null) { + fwrite(STDERR, $this->docs()->usage($this->moduleName)); + return false; + } elseif ($this->actionName === null) { + fwrite(STDERR, $this->docs()->usage($this->moduleName, $this->commandName)); + return false; + } + + $obj = null; + try { + if ($this->moduleName) { + $this->app->getModuleManager()->loadModule($this->moduleName); + $obj = $this->getModuleCommandInstance( + $this->moduleName, + $this->commandName + ); + } else { + $obj = $this->getCommandInstance($this->commandName); + } + if ($overrideParams !== null) { + $obj->setParams($overrideParams); + } + $obj->init(); + return $obj->{$this->actionName . 'Action'}(); + } catch (Exception $e) { + if ($obj instanceof Command && $obj->showTrace()) { + fwrite(STDERR, $this->formatTrace($e->getTrace())); + } + + $this->fail(IcingaException::describe($e)); + } + } + + protected function searchMatch($needle, $haystack) + { + if ($needle === null) { + $needle = ''; + } + + $this->lastSuggestions = preg_grep(sprintf('/^%s.*$/', preg_quote($needle, '/')), $haystack); + $match = array_search($needle, $haystack, true); + if (false !== $match) { + return $haystack[$match]; + } + if (count($this->lastSuggestions) === 1) { + $lastSuggestions = array_values($this->lastSuggestions); + return $lastSuggestions[0]; + } + return false; + } + + public function resolveName($name) + { + return $this->searchMatch( + $name, + array_merge($this->listCommands(), $this->listModules()) + ); + } + + public function resolveCommandName($name) + { + return $this->searchMatch($name, $this->listCommands()); + } + + public function resolveModuleName($name) + { + return $this->searchMatch($name, $this->listModules()); + } + + public function resolveModuleCommandName($module, $name) + { + return $this->searchMatch($name, $this->listModuleCommands($module)); + } + + public function resolveObjectActionName($obj, $name) + { + return $this->searchMatch($name, $obj->listActions()); + } + + protected function assertModuleExists($module) + { + if (! $this->hasModule($module)) { + throw new ProgrammingError( + 'There is no such module: %s', + $module + ); + } + } + + protected function assertCommandExists($command) + { + if (! $this->hasCommand($command)) { + throw new ProgrammingError( + 'There is no such command: %s', + $command + ); + } + } + + protected function assertModuleCommandExists($module, $command) + { + $this->assertModuleExists($module); + if (! $this->hasModuleCommand($module, $command)) { + throw new ProgrammingError( + 'The module \'%s\' has no such command: %s', + $module, + $command + ); + } + } + + protected function formatTrace($trace) + { + $output = array(); + foreach ($trace as $i => $step) { + $object = ''; + if (isset($step['object']) && is_object($step['object'])) { + $object = sprintf('[%s]', get_class($step['object'])) . $step['type']; + } elseif (! empty($step['object'])) { + $object = (string) $step['object'] . $step['type']; + } + if (isset($step['args']) && is_array($step['args'])) { + foreach ($step['args'] as & $arg) { + if (is_object($arg)) { + $arg = sprintf('[%s]', get_class($arg)); + } + if (is_string($arg)) { + $arg = preg_replace('~\n~', '\n', $arg); + if (strlen($arg) > 50) { + $arg = substr($arg, 0, 47) . '...'; + } + $arg = "'" . $arg . "'"; + } + if ($arg === null) { + $arg = 'NULL'; + } + if (is_bool($arg)) { + $arg = $arg ? 'TRUE' : 'FALSE'; + } + } + } else { + $step['args'] = array(); + } + $args = $step['args']; + foreach ($args as & $v) { + if (is_array($v)) { + $v = var_export($v, 1); + } else { + $v = (string) $v; + } + } + $output[$i] = sprintf( + '#%d %s:%d %s%s(%s)', + $i, + isset($step['file']) ? preg_replace( + '~.+/library/~', + 'library/', + $step['file'] + ) : '[unknown file]', + isset($step['line']) ? $step['line'] : '0', + $object, + $step['function'], + implode(', ', $args) + ); + } + return implode(PHP_EOL, $output) . PHP_EOL; + } + + public function hasCommand($name) + { + return in_array($name, $this->listCommands()); + } + + public function hasModule($name) + { + return in_array($name, $this->listModules()); + } + + public function hasModuleCommand($module, $name) + { + return in_array($name, $this->listModuleCommands($module)); + } + + public function listModules() + { + if ($this->modules === null) { + $this->modules = array(); + try { + $this->modules = array_unique(array_merge( + $this->app->getModuleManager()->listEnabledModules(), + $this->app->getModuleManager()->listLoadedModules() + )); + } catch (NotReadableError $e) { + $this->fail($e->getMessage()); + } + } + return $this->modules; + } + + protected function retrieveCommandsFromDir($dirname) + { + $commands = array(); + if (! @file_exists($dirname) || ! is_readable($dirname)) { + return $commands; + } + + $base = opendir($dirname); + if ($base === false) { + return $commands; + } + while (false !== ($dir = readdir($base))) { + if ($dir[0] === '.') { + continue; + } + if (preg_match('~^([A-Za-z0-9]+)Command\.php$~', $dir, $m)) { + $cmd = strtolower($m[1]); + $commands[] = $cmd; + } + } + closedir($base); + sort($commands); + return $commands; + } + + public function listCommands() + { + if ($this->commands === null) { + $this->commands = array(); + $ns = 'Icinga\\Clicommands\\'; + $this->commands = $this->retrieveCommandsFromDir($this->coreAppDir); + foreach ($this->commands as $cmd) { + $this->commandClassMap[$cmd] = $ns . ucfirst($cmd) . 'Command'; + $this->commandFileMap[$cmd] = $this->coreAppDir . '/' . ucfirst($cmd) . 'Command.php'; + } + } + return $this->commands; + } + + public function listModuleCommands($module) + { + if (! array_key_exists($module, $this->moduleCommands)) { + $ns = 'Icinga\\Module\\' . ucfirst($module) . '\\Clicommands\\'; + $this->assertModuleExists($module); + $manager = $this->app->getModuleManager(); + $manager->loadModule($module); + $dir = $manager->getModuleDir($module) . '/application/clicommands'; + $this->moduleCommands[$module] = $this->retrieveCommandsFromDir($dir); + $this->moduleInstances[$module] = array(); + foreach ($this->moduleCommands[$module] as $cmd) { + $this->moduleClassMap[$module][$cmd] = $ns . ucfirst($cmd) . 'Command'; + $this->moduleFileMap[$module][$cmd] = $dir . '/' . ucfirst($cmd) . 'Command.php'; + } + } + return $this->moduleCommands[$module]; + } +} diff --git a/library/Icinga/Cli/Params.php b/library/Icinga/Cli/Params.php new file mode 100644 index 0000000..463d4ae --- /dev/null +++ b/library/Icinga/Cli/Params.php @@ -0,0 +1,320 @@ +<?php +/* Icinga Web 2 | (c) 2013 Icinga Development Team | GPLv2+ */ + +namespace Icinga\Cli; + +use Icinga\Exception\MissingParameterException; + +/** + * Params + * + * A class to ease commandline-option and -argument handling. + */ +class Params +{ + /** + * The name and path of the executable + * + * @var string + */ + protected $program; + + /** + * The arguments + * + * @var array + */ + protected $standalone = array(); + + /** + * The options + * + * @var array + */ + protected $params = array(); + + /** + * Parse the given commandline and create a new Params object + * + * @param array $argv The commandline + */ + public function __construct($argv) + { + $noOptionFlag = false; + $this->program = array_shift($argv); + for ($i = 0; $i < count($argv); $i++) { + if ($argv[$i] === '--') { + $noOptionFlag = true; + } elseif (!$noOptionFlag && substr($argv[$i], 0, 2) === '--') { + $key = substr($argv[$i], 2); + $matches = array(); + if (1 === preg_match( + '/(?<!.)([^=]+)=(.*)(?!.)/ms', + $key, + $matches + )) { + $this->params[$matches[1]] = $matches[2]; + } elseif (! isset($argv[$i + 1]) || substr($argv[$i + 1], 0, 2) === '--') { + $this->params[$key] = true; + } elseif (array_key_exists($key, $this->params)) { + if (!is_array($this->params[$key])) { + $this->params[$key] = array($this->params[$key]); + } + $this->params[$key][] = $argv[++$i]; + } else { + $this->params[$key] = $argv[++$i]; + } + } else { + $this->standalone[] = $argv[$i]; + } + } + } + + /** + * Return the value for an argument by position + * + * @param int $pos The position of the argument + * @param mixed $default The default value to return + * + * @return mixed + */ + public function getStandalone($pos = 0, $default = null) + { + if (isset($this->standalone[$pos])) { + return $this->standalone[$pos]; + } + return $default; + } + + /** + * Count and return the number of arguments and options + * + * @return int + */ + public function count() + { + return count($this->standalone) + count($this->params); + } + + /** + * Return the options + * + * @return array + */ + public function getParams() + { + return $this->params; + } + + /** + * Return the arguments + * + * @return array + */ + public function getAllStandalone() + { + return $this->standalone; + } + + /** + * Support isset() and empty() checks on options + * + * @param $name + * + * @return bool + */ + public function __isset($name) + { + return isset($this->params[$name]); + } + + /** + * @see Params::get() + */ + public function __get($key) + { + return $this->get($key); + } + + /** + * Return whether the given option exists + * + * @param string $key The option name to check + * + * @return bool + */ + public function has($key) + { + return array_key_exists($key, $this->params); + } + + /** + * Return the value of the given option + * + * @param string $key The option name + * @param mixed $default The default value to return + * + * @return mixed + */ + public function get($key, $default = null) + { + if ($this->has($key)) { + return $this->params[$key]; + } + return $default; + } + + /** + * Require a parameter + * + * @param string $name Name of the parameter + * @param bool $strict Whether the parameter's value must not be the empty string + * + * @return mixed + * + * @throws MissingParameterException If the parameter was not given + */ + public function getRequired($name, $strict = true) + { + if ($this->has($name)) { + $value = $this->get($name); + if (! $strict || strlen($value) > 0) { + return $value; + } + } + $e = new MissingParameterException(t('Required parameter \'%s\' missing'), $name); + $e->setParameter($name); + throw $e; + } + + /** + * Set a value for the given option + * + * @param string $key The option name + * @param mixed $value The value to set + * + * @return $this + */ + public function set($key, $value) + { + $this->params[$key] = $value; + return $this; + } + + /** + * Remove a single option or multiple options + * + * @param string|array $keys The option or options to remove + * + * @return $this + */ + public function remove($keys = array()) + { + if (! is_array($keys)) { + $keys = array($keys); + } + foreach ($keys as $key) { + if (array_key_exists($key, $this->params)) { + unset($this->params[$key]); + } + } + return $this; + } + + /** + * Return a copy of this object with the given options being removed + * + * @param string|array $keys The option or options to remove + * + * @return Params + */ + public function without($keys = array()) + { + $params = clone($this); + return $params->remove($keys); + } + + /** + * Remove and return the value of the given option + * + * Called multiple times for an option with multiple values returns + * them one by one in case the default is not an array. + * + * @param string $key The option name + * @param mixed $default The default value to return + * + * @return mixed + */ + public function shift($key = null, $default = null) + { + if ($key === null) { + if (count($this->standalone) > 0) { + return array_shift($this->standalone); + } + return $default; + } + $result = $this->get($key, $default); + if (is_array($result) && !is_array($default)) { + $result = array_shift($result) || $default; + if ($result === $default) { + $this->remove($key); + } + } else { + $this->remove($key); + } + return $result; + } + + /** + * Require and remove a parameter + * + * @param string $name Name of the parameter + * @param bool $strict Whether the parameter's value must not be the empty string + * + * @return mixed + * + * @throws MissingParameterException If the parameter was not given + */ + public function shiftRequired($name, $strict = true) + { + if ($this->has($name)) { + $value = $this->get($name); + if (! $strict || strlen($value) > 0) { + $this->shift($name); + return $value; + } + } + $e = new MissingParameterException(t('Required parameter \'%s\' missing'), $name); + $e->setParameter($name); + throw $e; + } + + /** + * Put the given value onto the argument stack + * + * @param mixed $key The argument + * + * @return $this + */ + public function unshift($key) + { + array_unshift($this->standalone, $key); + return $this; + } + + /** + * Parse the given commandline + * + * @param array $argv The commandline to parse + * + * @return Params + */ + public static function parse($argv = null) + { + if ($argv === null) { + $argv = $GLOBALS['argv']; + } + $params = new self($argv); + return $params; + } +} diff --git a/library/Icinga/Cli/Screen.php b/library/Icinga/Cli/Screen.php new file mode 100644 index 0000000..4ffad72 --- /dev/null +++ b/library/Icinga/Cli/Screen.php @@ -0,0 +1,106 @@ +<?php +/* Icinga Web 2 | (c) 2013 Icinga Development Team | GPLv2+ */ + +namespace Icinga\Cli; + +use Icinga\Cli\AnsiScreen; + +class Screen +{ + protected static $instances = []; + + protected $isUtf8; + + public function getColumns() + { + $cols = (int) getenv('COLUMNS'); + if (! $cols) { + // stty -a ? + $cols = (int) exec('tput cols'); + } + if (! $cols) { + $cols = 80; + } + return $cols; + } + + public function getRows() + { + $rows = (int) getenv('ROWS'); + if (! $rows) { + // stty -a ? + $rows = (int) exec('tput lines'); + } + if (! $rows) { + $rows = 25; + } + return $rows; + } + + public function strlen($string) + { + return strlen($string); + } + + public function newlines($count = 1) + { + return str_repeat("\n", $count); + } + + public function center($txt) + { + $len = $this->strlen($txt); + $width = floor(($this->getColumns() + $len) / 2) - $len; + return str_repeat(' ', $width) . $txt; + } + + public function hasUtf8() + { + if ($this->isUtf8 === null) { + // null should equal 0 here, however seems to equal '' on some systems: + $current = setlocale(LC_ALL, 0); + + $parts = preg_split('/;/', $current); + $lc_parts = array(); + foreach ($parts as $part) { + if (strpos($part, '=') === false) { + continue; + } + list($key, $val) = preg_split('/=/', $part, 2); + $lc_parts[$key] = $val; + } + + $this->isUtf8 = array_key_exists('LC_CTYPE', $lc_parts) + && preg_match('~\.UTF-8$~i', $lc_parts['LC_CTYPE']); + } + return $this->isUtf8; + } + + public function clear() + { + return "\n"; + } + + public function underline($text) + { + return $text; + } + + public function colorize($text, $fgColor = null, $bgColor = null) + { + return $text; + } + + public static function instance($output = STDOUT) + { + if (! isset(self::$instances[(int) $output])) { + if (function_exists('posix_isatty') && posix_isatty($output)) { + self::$instances[(int) $output] = new AnsiScreen(); + } else { + self::$instances[(int) $output] = new Screen(); + } + } + + return self::$instances[(int) $output]; + } +} diff --git a/library/Icinga/Common/Database.php b/library/Icinga/Common/Database.php new file mode 100644 index 0000000..d54eb25 --- /dev/null +++ b/library/Icinga/Common/Database.php @@ -0,0 +1,56 @@ +<?php +/* Icinga Web 2 | (c) 2020 Icinga GmbH | GPLv2+ */ + +namespace Icinga\Common; + +use Icinga\Application\Config as IcingaConfig; +use Icinga\Data\ResourceFactory; +use ipl\Sql\Config as SqlConfig; +use ipl\Sql\Connection; +use LogicException; +use PDO; + +/** + * Trait for accessing the Icinga Web database + */ +trait Database +{ + /** + * Get a connection to the Icinga Web database + * + * @return Connection + * + * @throws \Icinga\Exception\ConfigurationError + */ + protected function getDb(): Connection + { + if (! $this->hasDb()) { + throw new LogicException('Please check if a db instance exists at all'); + } + + $config = new SqlConfig(ResourceFactory::getResourceConfig( + IcingaConfig::app()->get('global', 'config_resource') + )); + if ($config->db === 'mysql') { + $config->charset = 'utf8mb4'; + } + + $config->options = [PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_OBJ]; + if ($config->db === 'mysql') { + $config->options[PDO::MYSQL_ATTR_INIT_COMMAND] = "SET SESSION SQL_MODE='STRICT_TRANS_TABLES" + . ",NO_ZERO_IN_DATE,NO_ZERO_DATE,ERROR_FOR_DIVISION_BY_ZERO,NO_ENGINE_SUBSTITUTION'"; + } + + return new Connection($config); + } + + /** + * Check if db exists + * + * @return bool true if a database was found otherwise false + */ + protected function hasDb() + { + return (bool) IcingaConfig::app()->get('global', 'config_resource'); + } +} diff --git a/library/Icinga/Common/PdfExport.php b/library/Icinga/Common/PdfExport.php new file mode 100644 index 0000000..afea9bf --- /dev/null +++ b/library/Icinga/Common/PdfExport.php @@ -0,0 +1,105 @@ +<?php +/* Icinga Web 2 | (c) 2022 Icinga GmbH | GPLv2+ */ + +namespace Icinga\Common; + +use Icinga\Application\Icinga; +use Icinga\Date\DateFormatter; +use Icinga\Exception\ConfigurationError; +use Icinga\Module\Pdfexport\PrintableHtmlDocument; +use Icinga\Util\Environment; +use Icinga\Web\Controller; +use ipl\Html\Html; +use ipl\Html\HtmlString; +use ipl\Html\ValidHtml; +use ipl\Web\Compat\CompatController; +use ipl\Web\Url; + +trait PdfExport +{ + /** @var string The image to show in a pdf exports page header */ + private $pdfHeaderImage = 'img/icinga-logo-big-dark.png'; + + /** + * Export the requested action to PDF and send it + * + * @return never + * @throws ConfigurationError If the pdfexport module is not available + */ + protected function sendAsPdf() + { + if (! Icinga::app()->getModuleManager()->has('pdfexport')) { + throw new ConfigurationError('The pdfexport module is required for exports to PDF'); + } + + putenv('ICINGAWEB_EXPORT_FORMAT=pdf'); + Environment::raiseMemoryLimit('512M'); + Environment::raiseExecutionTime(300); + + $time = DateFormatter::formatDateTime(time()); + $iconPath = is_readable($this->pdfHeaderImage) + ? $this->pdfHeaderImage + : Icinga::app()->getBootstrapDirectory() . '/' . $this->pdfHeaderImage; + $encodedIcon = is_readable($iconPath) ? base64_encode(file_get_contents($iconPath)) : null; + $html = $this instanceof CompatController && ! $this->content->isEmpty() + ? $this->content + : $this->renderControllerAction(); + + $doc = (new PrintableHtmlDocument()) + ->setTitle($this->view->title) + ->setHeader(Html::wantHtml([ + Html::tag('span', ['class' => 'title']), + $encodedIcon + ? Html::tag('img', ['height' => 13, 'src' => 'data:image/png;base64,' . $encodedIcon]) + : null, + Html::tag('time', null, $time) + ])) + ->setFooter(Html::wantHtml([ + Html::tag('span', null, [ + t('Page') . ' ', + Html::tag('span', ['class' => 'pageNumber']), + ' / ', + Html::tag('span', ['class' => 'totalPages']) + ]), + Html::tag('p', null, rawurldecode(Url::fromRequest()->setParams($this->params))) + ])) + ->addHtml($html); + + if (($moduleName = $this->getRequest()->getModuleName()) !== 'default') { + $doc->getAttributes()->add('class', 'icinga-module module-' . $moduleName); + } + + \Icinga\Module\Pdfexport\ProvidedHook\Pdfexport::first()->streamPdfFromHtml($doc, sprintf( + '%s-%s', + $this->view->title ?: $this->getRequest()->getActionName(), + $time + )); + } + + /** + * Render the requested action + * + * @return ValidHtml + */ + protected function renderControllerAction() + { + /** @var Controller $this */ + $this->view->compact = true; + + $viewRenderer = $this->getHelper('viewRenderer'); + $viewRenderer->postDispatch(); + + $layoutHelper = $this->getHelper('layout'); + $oldLayout = $layoutHelper->getLayout(); + $layout = $layoutHelper->setLayout('inline'); + + $layout->content = $this->getResponse(); + $html = $layout->render(); + + // Restore previous layout and reset content, to properly show errors + $this->getResponse()->clearBody($viewRenderer->getResponseSegment()); + $layoutHelper->setLayout($oldLayout); + + return HtmlString::create($html); + } +} diff --git a/library/Icinga/Crypt/AesCrypt.php b/library/Icinga/Crypt/AesCrypt.php new file mode 100644 index 0000000..8e9d453 --- /dev/null +++ b/library/Icinga/Crypt/AesCrypt.php @@ -0,0 +1,337 @@ +<?php +/* Icinga Web 2 | (c) 2021 Icinga GmbH | GPLv2+ */ + +namespace Icinga\Crypt; + +use UnexpectedValueException; +use RuntimeException; + +/** + * Data encryption and decryption using symmetric algorithm + * + * # Example Usage + * + * ```php + * + * // Encryption + * $encryptedData = (new AesCrypt())->encrypt($data); // Accepts a string + * + * + * // Encrypt and encode to Base64 + * $encryptedData = (new AesCrypt())->encryptToBase64($data); // Accepts a string + * + * + * // Decryption + * $aesCrypt = (new AesCrypt()) + * ->setTag($tag) // if exists + * ->setIV($iv) + * ->setKey($key); + * + * $decryptedData = $aesCrypt->decrypt($data); + * + * // Decode from Base64 and decrypt + * $aesCrypt = (new AesCrypt()) + * ->setTag($tag) + * ->setIV($iv) + * ->setKey($key); + * + * $decryptedData = $aesCrypt->decryptFromBase64($data); + * ``` + * + */ +class AesCrypt +{ + /** @var array The list of cipher methods */ + const METHODS = [ + 'aes-256-gcm', + 'aes-256-cbc', + 'aes-256-ctr' + ]; + + /** @var string The encryption key */ + private $key; + + /** @var int The length of the key */ + private $keyLength; + + /** @var string The initialization vector which is not NULL */ + private $iv; + + /** @var string The authentication tag which is passed by reference when using AEAD cipher mode */ + private $tag; + + /** @var string The cipher method */ + private $method; + + public function __construct($keyLength = 128) + { + $this->keyLength = $keyLength; + } + + /** + * Set the method + * + * @return $this + */ + public function setMethod($method) + { + $this->method = $method; + + return $this; + } + + /** + * Get the method + * + * @return string + */ + public function getMethod() + { + if ($this->method === null) { + $this->method = $this->getSupportedMethod(); + } + + return $this->method; + } + + /** + * Get supported method + * + * @return string + * + * @throws RuntimeException If none of the methods listed in the METHODS array is available + */ + protected function getSupportedMethod() + { + $availableMethods = openssl_get_cipher_methods(); + $methods = self::METHODS; + + if (! $this->isAuthenticatedEncryptionSupported()) { + unset($methods[0]); + } + + foreach ($methods as $method) { + if (in_array($method, $availableMethods)) { + return $method; + } + } + + throw new RuntimeException('No supported method found'); + } + + /** + * Set the key + * + * @return $this + */ + public function setKey($key) + { + $this->key = $key; + + return $this; + } + + /** + * Get the key + * + * @return string + * + */ + public function getKey() + { + if (empty($this->key)) { + $this->key = random_bytes($this->keyLength); + } + + return $this->key; + } + + /** + * Set the IV + * + * @return $this + */ + public function setIV($iv) + { + $this->iv = $iv; + + return $this; + } + + /** + * Get the IV + * + * @return string + * + */ + public function getIV() + { + if (empty($this->iv)) { + $len = openssl_cipher_iv_length($this->getMethod()); + $this->iv = random_bytes($len); + } + + return $this->iv; + } + + /** + * Set the Tag + * + * @return $this + * + * @throws RuntimeException If a tag is available but authenticated encryption (AE) is not supported. + * + * @throws UnexpectedValueException If tag length is less then 16 + */ + public function setTag($tag) + { + if (! $this->isAuthenticatedEncryptionSupported()) { + throw new RuntimeException(sprintf( + "The given decryption method is not supported in php version '%s'", + PHP_VERSION + )); + } + + if (strlen($tag) !== 16) { + throw new UnexpectedValueException(sprintf( + 'expects tag length to be 16, got instead %s', + strlen($tag) + )); + } + + $this->tag = $tag; + + return $this; + } + + /** + * Get the Tag + * + * @return string + * + * @throws RuntimeException If the Tag is not set + */ + public function getTag() + { + if (empty($this->tag)) { + throw new RuntimeException('No tag set'); + } + + return $this->tag; + } + + /** + * Decrypt the given string + * + * @param string $data + * + * @return string + * + * @throws RuntimeException If decryption fails + */ + public function decrypt($data) + { + if (! $this->isAuthenticatedEncryptionRequired()) { + return $this->nonAEDecrypt($data); + } + + $decrypt = openssl_decrypt($data, $this->getMethod(), $this->getKey(), 0, $this->getIV(), $this->getTag()); + + if ($decrypt === false) { + throw new RuntimeException('Decryption failed'); + } + + return $decrypt; + } + + /** + * Encrypt the given string + * + * @param string $data + * + * @return string encrypted string + * + * @throws RuntimeException If decryption fails + */ + public function encrypt($data) + { + if (! $this->isAuthenticatedEncryptionRequired()) { + return $this->nonAEEncrypt($data); + } + + $encrypt = openssl_encrypt($data, $this->getMethod(), $this->getKey(), 0, $this->getIV(), $this->tag); + + if ($encrypt === false) { + throw new RuntimeException('Encryption failed'); + } + + return $encrypt; + } + + /** + * Decrypt the given string with non Authenticated encryption (AE) cipher method + * + * @param string $data + * + * @return string decrypted string + * + * @throws RuntimeException If decryption fails + */ + private function nonAEDecrypt($data) + { + $c = base64_decode($data); + $hmac = substr($c, 0, 32); + $data = substr($c, 32); + + $decrypt = openssl_decrypt($data, $this->getMethod(), $this->getKey(), 0, $this->getIV()); + $calcHmac = hash_hmac('sha256', $this->getIV() . $data, $this->getKey(), true); + + if ($decrypt === false || ! hash_equals($hmac, $calcHmac)) { + throw new RuntimeException('Decryption failed'); + } + + return $decrypt; + } + + /** + * Encrypt the given string with non Authenticated encryption (AE) cipher method + * + * @param string $data + * + * @return string encrypted string + * + * @throws RuntimeException If encryption fails + */ + private function nonAEEncrypt($data) + { + $encrypt = openssl_encrypt($data, $this->getMethod(), $this->getKey(), 0, $this->getIV()); + + if ($encrypt === false) { + throw new RuntimeException('Encryption failed'); + } + + $hmac = hash_hmac('sha256', $this->getIV() . $encrypt, $this->getKey(), true); + + return base64_encode($hmac . $encrypt); + } + + /** + * Whether the Authenticated encryption (a tag) is required + * + * @return bool True if required false otherwise + */ + public function isAuthenticatedEncryptionRequired() + { + return $this->getMethod() === 'aes-256-gcm'; + } + + /** + * Whether the php version supports Authenticated encryption (AE) or not + * + * @return bool True if supported false otherwise + */ + public function isAuthenticatedEncryptionSupported() + { + return PHP_VERSION_ID >= 70100; + } +} diff --git a/library/Icinga/Data/ConfigObject.php b/library/Icinga/Data/ConfigObject.php new file mode 100644 index 0000000..c9a3134 --- /dev/null +++ b/library/Icinga/Data/ConfigObject.php @@ -0,0 +1,289 @@ +<?php +/* Icinga Web 2 | (c) 2013 Icinga Development Team | GPLv2+ */ + +namespace Icinga\Data; + +use Iterator; +use ArrayAccess; +use Icinga\Data\DataArray\ArrayDatasource; +use Icinga\Exception\ProgrammingError; + +/** + * Container for configuration values + */ +class ConfigObject extends ArrayDatasource implements Iterator, ArrayAccess +{ + /** + * Create a new config + * + * @param array $data The data to initialize the new config with + */ + public function __construct(array $data = array()) + { + // Convert all embedded arrays to ConfigObjects as well + foreach ($data as & $value) { + if (is_array($value)) { + $value = new static($value); + } + } + + parent::__construct($data); + } + + /** + * Deep clone this config + */ + public function __clone() + { + $array = array(); + foreach ($this->data as $key => $value) { + if ($value instanceof self) { + $array[$key] = clone $value; + } else { + $array[$key] = $value; + } + } + + $this->data = $array; + } + + /** + * Reset the current position of $this->data + * + * @return void + */ + public function rewind(): void + { + reset($this->data); + } + + /** + * Return the section's or property's value of the current iteration + * + * @return mixed + */ + #[\ReturnTypeWillChange] + public function current() + { + return current($this->data); + } + + /** + * Return whether the position of the current iteration is valid + * + * @return bool + */ + public function valid(): bool + { + return key($this->data) !== null; + } + + /** + * Return the section's or property's name of the current iteration + * + * @return string + */ + public function key(): string + { + return key($this->data); + } + + /** + * Advance the position of the current iteration and return the new section's or property's value + * + * @return void + */ + public function next(): void + { + next($this->data); + } + + /** + * Return whether the given section or property is set + * + * @param string $key The name of the section or property + * + * @return bool + */ + public function __isset($key) + { + return isset($this->data[$key]); + } + + /** + * Return the value for the given property or the config for the given section + * + * @param string $key The name of the property or section + * + * @return mixed|NULL The value or NULL in case $key does not exist + */ + public function __get($key) + { + return $this->get($key); + } + + /** + * Add a new property or section + * + * @param string $key The name of the new property or section + * @param mixed $value The value to set for the new property or section + */ + public function __set($key, $value) + { + if (is_array($value)) { + $this->data[$key] = new static($value); + } else { + $this->data[$key] = $value; + } + } + + /** + * Remove the given property or section + * + * @param string $key The property or section to remove + */ + public function __unset($key) + { + unset($this->data[$key]); + } + + /** + * Return whether the given section or property is set + * + * @param string $key The name of the section or property + * + * @return bool + */ + public function offsetExists($key): bool + { + return isset($this->$key); + } + + /** + * Return the value for the given property or the config for the given section + * + * @param string $key The name of the property or section + * + * @return ?mixed The value or NULL in case $key does not exist + */ + #[\ReturnTypeWillChange] + public function offsetGet($key) + { + return $this->get($key); + } + + /** + * Add a new property or section + * + * @param string $key The name of the new property or section + * @param mixed $value The value to set for the new property or section + * + * @throws ProgrammingError If the key is null + */ + public function offsetSet($key, $value): void + { + if ($key === null) { + throw new ProgrammingError('Appending values without an explicit key is not supported'); + } + + $this->$key = $value; + } + + /** + * Remove the given property or section + * + * @param string $key The property or section to remove + */ + public function offsetUnset($key): void + { + unset($this->$key); + } + + /** + * Return whether this config has any data + * + * @return bool + */ + public function isEmpty() + { + return empty($this->data); + } + + /** + * Return the value for the given property or the config for the given section + * + * @param string $key The name of the property or section + * @param mixed $default The value to return in case the property or section is missing + * + * @return mixed + */ + public function get($key, $default = null) + { + if (array_key_exists($key, $this->data)) { + return $this->data[$key]; + } + + return $default; + } + + /** + * Return all section and property names + * + * @return array + */ + public function keys() + { + return array_keys($this->data); + } + + /** + * Return this config's data as associative array + * + * @return array + */ + public function toArray() + { + $array = array(); + foreach ($this->data as $key => $value) { + if ($value instanceof self) { + $array[$key] = $value->toArray(); + } else { + $array[$key] = $value; + } + } + + return $array; + } + + /** + * Merge the given data with this config + * + * @param array|ConfigObject $data An array or a config + * + * @return $this + */ + public function merge($data) + { + if ($data instanceof self) { + $data = $data->toArray(); + } + + foreach ($data as $key => $value) { + if (array_key_exists($key, $this->data)) { + if (is_array($value)) { + if ($this->data[$key] instanceof self) { + $this->data[$key]->merge($value); + } else { + $this->data[$key] = new static($value); + } + } else { + $this->data[$key] = $value; + } + } else { + $this->data[$key] = is_array($value) ? new static($value) : $value; + } + } + + return $this; + } +} diff --git a/library/Icinga/Data/ConnectionInterface.php b/library/Icinga/Data/ConnectionInterface.php new file mode 100644 index 0000000..bd7d026 --- /dev/null +++ b/library/Icinga/Data/ConnectionInterface.php @@ -0,0 +1,8 @@ +<?php +/* Icinga Web 2 | (c) 2014 Icinga Development Team | GPLv2+ */ + +namespace Icinga\Data; + +interface ConnectionInterface extends Selectable, Queryable +{ +} diff --git a/library/Icinga/Data/DataArray/ArrayDatasource.php b/library/Icinga/Data/DataArray/ArrayDatasource.php new file mode 100644 index 0000000..e300616 --- /dev/null +++ b/library/Icinga/Data/DataArray/ArrayDatasource.php @@ -0,0 +1,292 @@ +<?php +/* Icinga Web 2 | (c) 2013 Icinga Development Team | GPLv2+ */ + +namespace Icinga\Data\DataArray; + +use ArrayIterator; +use Icinga\Data\Selectable; +use Icinga\Data\SimpleQuery; + +class ArrayDatasource implements Selectable +{ + /** + * The array being used as data source + * + * @var array + */ + protected $data; + + /** + * The current result + * + * @var array + */ + protected $result; + + /** + * The result of a counted query + * + * @var int + */ + protected $count; + + /** + * The name of the column to map array keys on + * + * In case the array being used as data source provides keys of type string,this name + * will be used to set such as column on each row, if the column is not set already. + * + * @var string + */ + protected $keyColumn; + + /** + * Create a new data source for the given array + * + * @param array $data The array you're going to use as a data source + */ + public function __construct(array $data) + { + $this->data = $data; + } + + /** + * Set the name of the column to map array keys on + * + * @param string $name + * + * @return $this + */ + public function setKeyColumn($name) + { + $this->keyColumn = $name; + return $this; + } + + /** + * Return the name of the column to map array keys on + * + * @return string + */ + public function getKeyColumn() + { + return $this->keyColumn; + } + + /** + * Provide a query for this data source + * + * @return SimpleQuery + */ + public function select() + { + return new SimpleQuery(clone $this); + } + + /** + * Fetch and return all rows of the given query's result set using an iterator + * + * @param SimpleQuery $query + * + * @return ArrayIterator + */ + public function query(SimpleQuery $query) + { + return new ArrayIterator($this->fetchAll($query)); + } + + /** + * Fetch and return a column of all rows of the result set as an array + * + * @param SimpleQuery $query + * + * @return array + */ + public function fetchColumn(SimpleQuery $query) + { + $result = array(); + foreach ($this->getResult($query) as $row) { + $arr = (array) $row; + $result[] = array_shift($arr); + } + + return $result; + } + + /** + * Fetch and return all rows of the given query's result as a flattened key/value based array + * + * @param SimpleQuery $query + * + * @return array + */ + public function fetchPairs(SimpleQuery $query) + { + $result = array(); + $keys = null; + foreach ($this->getResult($query) as $row) { + if ($keys === null) { + $keys = array_keys((array) $row); + if (count($keys) < 2) { + $keys[1] = $keys[0]; + } + } + + $result[$row->{$keys[0]}] = $row->{$keys[1]}; + } + + return $result; + } + + /** + * Fetch and return the first row of the given query's result + * + * @param SimpleQuery $query + * + * @return object|false The row or false in case the result is empty + */ + public function fetchRow(SimpleQuery $query) + { + $result = $this->getResult($query); + if (empty($result)) { + return false; + } + + return array_shift($result); + } + + /** + * Fetch and return all rows of the given query's result as an array + * + * @param SimpleQuery $query + * + * @return array + */ + public function fetchAll(SimpleQuery $query) + { + return $this->getResult($query); + } + + /** + * Count all rows of the given query's result + * + * @param SimpleQuery $query + * + * @return int + */ + public function count(SimpleQuery $query) + { + if ($this->count === null) { + $this->count = count($this->createResult($query)); + } + + return $this->count; + } + + /** + * Create and return the result for the given query + * + * @param SimpleQuery $query + * + * @return array + */ + protected function createResult(SimpleQuery $query) + { + $columns = $query->getColumns(); + $filter = $query->getFilter(); + $offset = $query->hasOffset() ? $query->getOffset() : 0; + $limit = $query->hasLimit() ? $query->getLimit() : 0; + $data = $this->data; + + if ($query->hasOrder()) { + uasort($data, [$query, 'compare']); + } + + $foundStringKey = false; + $result = []; + $skipped = 0; + foreach ($data as $key => $row) { + if ($this->keyColumn !== null && !isset($row->{$this->keyColumn})) { + $row = clone $row; // Make sure that this won't affect the actual data + $row->{$this->keyColumn} = $key; + } + + if (! $filter->matches($row)) { + continue; + } elseif ($skipped < $offset) { + $skipped++; + continue; + } + + // Get only desired columns if asked so + if (! empty($columns)) { + $filteredRow = (object) array(); + foreach ($columns as $alias => $name) { + if (! is_string($alias)) { + $alias = $name; + } + + if (isset($row->$name)) { + $filteredRow->$alias = $row->$name; + } else { + $filteredRow->$alias = null; + } + } + } else { + $filteredRow = $row; + } + + $foundStringKey |= is_string($key); + $result[$key] = $filteredRow; + + if (count($result) === $limit) { + break; + } + } + + if (! $foundStringKey) { + $result = array_values($result); + } + + return $result; + } + + /** + * Return whether a query result exists + * + * @return bool + */ + protected function hasResult() + { + return $this->result !== null; + } + + /** + * Set the current result + * + * @param array $result + * + * @return $this + */ + protected function setResult(array $result) + { + $this->result = $result; + return $this; + } + + /** + * Return the result for the given query + * + * @param SimpleQuery $query + * + * @return array + */ + protected function getResult(SimpleQuery $query) + { + if (! $this->hasResult()) { + $this->setResult($this->createResult($query)); + } + + return $this->result; + } +} diff --git a/library/Icinga/Data/Db/DbConnection.php b/library/Icinga/Data/Db/DbConnection.php new file mode 100644 index 0000000..fc6814d --- /dev/null +++ b/library/Icinga/Data/Db/DbConnection.php @@ -0,0 +1,655 @@ +<?php +/* Icinga Web 2 | (c) 2013 Icinga Development Team | GPLv2+ */ + +namespace Icinga\Data\Db; + +use DateTime; +use DateTimeZone; +use Exception; +use Icinga\Data\Filter\FilterEqual; +use Icinga\Data\Filter\FilterNotEqual; +use Icinga\Data\Inspectable; +use Icinga\Data\Inspection; +use PDO; +use Iterator; +use Zend_Db; +use Zend_Db_Expr; +use Icinga\Data\ConfigObject; +use Icinga\Data\Extensible; +use Icinga\Data\Filter\Filter; +use Icinga\Data\Filter\FilterAnd; +use Icinga\Data\Filter\FilterNot; +use Icinga\Data\Filter\FilterOr; +use Icinga\Data\Reducible; +use Icinga\Data\ResourceFactory; +use Icinga\Data\Selectable; +use Icinga\Data\Updatable; +use Icinga\Exception\ConfigurationError; +use Icinga\Exception\ProgrammingError; + +/** + * Encapsulate database connections and query creation + */ +class DbConnection implements Selectable, Extensible, Updatable, Reducible, Inspectable +{ + /** + * Connection config + * + * @var ConfigObject + */ + private $config; + + /** + * Database type + * + * @var string + */ + private $dbType; + + /** + * @var \Zend_Db_Adapter_Abstract + */ + private $dbAdapter; + + /** + * Table prefix + * + * @var string + */ + private $tablePrefix = ''; + + private static $genericAdapterOptions = array( + Zend_Db::AUTO_QUOTE_IDENTIFIERS => false, + Zend_Db::CASE_FOLDING => Zend_Db::CASE_LOWER + ); + + private static $driverOptions = array( + PDO::ATTR_TIMEOUT => 10, + PDO::ATTR_CASE => PDO::CASE_LOWER, + PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION + ); + + /** + * Create a new connection object + * + * @param ConfigObject $config + */ + public function __construct(ConfigObject $config = null) + { + $this->config = $config; + $this->connect(); + } + + /** + * Provide a query on this connection + * + * @return DbQuery + */ + public function select() + { + return new DbQuery($this); + } + + /** + * Fetch and return all rows of the given query's result set using an iterator + * + * @param DbQuery $query + * + * @return Iterator + */ + public function query(DbQuery $query) + { + return $query->getSelectQuery()->query(); + } + + /** + * Get the connection configuration + * + * @return ConfigObject + */ + public function getConfig() + { + return $this->config; + } + + /** + * Getter for database type + * + * @return string + */ + public function getDbType() + { + return $this->dbType; + } + + /** + * Getter for the Zend_Db_Adapter + * + * @return \Zend_Db_Adapter_Abstract + */ + public function getDbAdapter() + { + return $this->dbAdapter; + } + + /** + * Create a new connection + */ + private function connect() + { + $genericAdapterOptions = self::$genericAdapterOptions; + $driverOptions = self::$driverOptions; + $adapterParamaters = array( + 'host' => $this->config->host, + 'username' => $this->config->username, + 'password' => $this->config->password, + 'dbname' => $this->config->dbname, + 'charset' => $this->config->charset ?: null, + 'options' => & $genericAdapterOptions, + 'driver_options' => & $driverOptions + ); + $this->dbType = strtolower($this->config->get('db', 'mysql')); + switch ($this->dbType) { + case 'mssql': + $adapter = 'Pdo_Mssql'; + $pdoType = $this->config->get('pdoType'); + if (empty($pdoType)) { + if (extension_loaded('sqlsrv')) { + $adapter = 'Sqlsrv'; + } else { + $pdoType = 'dblib'; + } + } + if ($pdoType === 'dblib') { + // Driver does not support setting attributes + unset($adapterParamaters['options']); + unset($adapterParamaters['driver_options']); + } + if (! empty($pdoType)) { + $adapterParamaters['pdoType'] = $pdoType; + } + $defaultPort = 1433; + break; + case 'mysql': + $adapter = 'Pdo_Mysql'; + if ($this->config->use_ssl) { + # The presence of these keys as empty strings or null cause non-ssl connections to fail + if ($this->config->ssl_key) { + $adapterParamaters['driver_options'][PDO::MYSQL_ATTR_SSL_KEY] = $this->config->ssl_key; + } + if ($this->config->ssl_cert) { + $adapterParamaters['driver_options'][PDO::MYSQL_ATTR_SSL_CERT] = $this->config->ssl_cert; + } + if ($this->config->ssl_ca) { + $adapterParamaters['driver_options'][PDO::MYSQL_ATTR_SSL_CA] = $this->config->ssl_ca; + } + if ($this->config->ssl_capath) { + $adapterParamaters['driver_options'][PDO::MYSQL_ATTR_SSL_CAPATH] = $this->config->ssl_capath; + } + if ($this->config->ssl_cipher) { + $adapterParamaters['driver_options'][PDO::MYSQL_ATTR_SSL_CIPHER] = $this->config->ssl_cipher; + } + if (defined('PDO::MYSQL_ATTR_SSL_VERIFY_SERVER_CERT') + && $this->config->ssl_do_not_verify_server_cert + ) { + $adapterParamaters['driver_options'][PDO::MYSQL_ATTR_SSL_VERIFY_SERVER_CERT] = false; + } + } + /* + * Set MySQL server SQL modes to behave as closely as possible to Oracle and PostgreSQL. Note that the + * ONLY_FULL_GROUP_BY mode is left on purpose because MySQL requires you to specify all non-aggregate + * columns in the group by list even if the query is grouped by the master table's primary key which is + * valid ANSI SQL though. Further in that case the query plan would suffer if you add more columns to + * the group by list. + */ + $driverOptions[PDO::MYSQL_ATTR_INIT_COMMAND] = + 'SET SESSION SQL_MODE=\'STRICT_ALL_TABLES,NO_ZERO_IN_DATE,NO_ZERO_DATE,ERROR_FOR_DIVISION_BY_ZERO,' + . 'ANSI_QUOTES,PIPES_AS_CONCAT,NO_ENGINE_SUBSTITUTION\''; + if (isset($adapterParamaters['charset'])) { + $driverOptions[PDO::MYSQL_ATTR_INIT_COMMAND] .= ', NAMES ' . $adapterParamaters['charset']; + if (trim($adapterParamaters['charset']) === 'latin1') { + // Required for MySQL 8+ because we need PIPES_AS_CONCAT and + // have several columns with explicit COLLATE instructions + $driverOptions[PDO::MYSQL_ATTR_INIT_COMMAND] .= ' COLLATE latin1_general_ci'; + } + + unset($adapterParamaters['charset']); + } + + $driverOptions[PDO::MYSQL_ATTR_INIT_COMMAND] .= ", time_zone='" . $this->defaultTimezoneOffset() . "'"; + $driverOptions[PDO::MYSQL_ATTR_INIT_COMMAND] .=';'; + $defaultPort = 3306; + break; + case 'oci': + $adapter = 'Oracle'; + unset($adapterParamaters['options']); + unset($adapterParamaters['driver_options']); + $adapterParamaters['driver_options'] = array( + 'lob_as_string' => true + ); + $defaultPort = 1521; + break; + case 'oracle': + $adapter = 'Pdo_Oci'; + $defaultPort = 1521; + + // remove host parameter when not configured + if (empty($this->config->host)) { + unset($adapterParamaters['host']); + } + break; + case 'pgsql': + $adapter = 'Pdo_Pgsql'; + $defaultPort = 5432; + break; + case 'ibm': + $adapter = 'Pdo_Ibm'; + $defaultPort = 50000; + break; + case 'sqlite': + $adapter = 'Pdo_Sqlite'; + $defaultPort = 0; // Dummy port because a value is required + break; + default: + throw new ConfigurationError( + 'Backend "%s" is not supported', + $this->dbType + ); + } + $adapterParamaters['port'] = $this->config->get('port', $defaultPort); + $this->dbAdapter = Zend_Db::factory($adapter, $adapterParamaters); + $this->dbAdapter->setFetchMode(Zend_Db::FETCH_OBJ); + // TODO(el/tg): The profiler is disabled per default, why do we disable the profiler explicitly? + $this->dbAdapter->getProfiler()->setEnabled(false); + } + + public static function fromResourceName($name) + { + return new static(ResourceFactory::getResourceConfig($name)); + } + + /** + * Getter for the table prefix + * + * @return string + */ + public function getTablePrefix() + { + return $this->tablePrefix; + } + + /** + * Setter for the table prefix + * + * @param string $prefix + * + * @return $this + */ + public function setTablePrefix($prefix) + { + $this->tablePrefix = $prefix; + return $this; + } + + /** + * Get offset from the current default timezone to GMT + * + * @return string + */ + protected function defaultTimezoneOffset() + { + $tz = new DateTimeZone(date_default_timezone_get()); + $offset = $tz->getOffset(new DateTime()); + $prefix = $offset >= 0 ? '+' : '-'; + $offset = abs($offset); + $hours = (int) floor($offset / 3600); + $minutes = (int) floor(($offset % 3600) / 60); + return sprintf('%s%d:%02d', $prefix, $hours, $minutes); + } + + /** + * Count all rows of the result set + * + * @param DbQuery $query + * + * @return int + */ + public function count(DbQuery $query) + { + return (int) $this->dbAdapter->fetchOne($query->getCountQuery()); + } + + /** + * Retrieve an array containing all rows of the result set + * + * @param DbQuery $query + * + * @return array + */ + public function fetchAll(DbQuery $query) + { + return $this->dbAdapter->fetchAll($query->getSelectQuery()); + } + + /** + * Fetch the first row of the result set + * + * @param DbQuery $query + * + * @return mixed + */ + public function fetchRow(DbQuery $query) + { + return $this->dbAdapter->fetchRow($query->getSelectQuery()); + } + + /** + * Fetch the first column of all rows of the result set as an array + * + * @param DbQuery $query + * + * @return array + */ + public function fetchColumn(DbQuery $query) + { + return $this->dbAdapter->fetchCol($query->getSelectQuery()); + } + + /** + * Fetch the first column of the first row of the result set + * + * @param DbQuery $query + * + * @return string + */ + public function fetchOne(DbQuery $query) + { + return $this->dbAdapter->fetchOne($query->getSelectQuery()); + } + + /** + * Fetch all rows of the result set as an array of key-value pairs + * + * The first column is the key, the second column is the value. + * + * @param DbQuery $query + * + * @return array + */ + public function fetchPairs(DbQuery $query) + { + return $this->dbAdapter->fetchPairs($query->getSelectQuery()); + } + + /** + * Insert a table row with the given data + * + * Note that the base implementation does not perform any quoting on the $table argument. + * Pass an array with a column name (the same as in $bind) and a PDO::PARAM_* constant as value + * as third parameter $types to define a different type than string for a particular column. + * + * @param string $table + * @param array $bind + * @param array $types + * + * @return int The number of affected rows + */ + public function insert($table, array $bind, array $types = array()) + { + $columns = $values = array(); + foreach ($bind as $column => $value) { + $columns[] = $column; + if ($value instanceof Zend_Db_Expr) { + $values[] = (string) $value; + unset($bind[$column]); + } else { + $values[] = ':' . $column; + } + } + + $sql = 'INSERT INTO ' . $table + . ' (' . join(', ', $columns) . ') ' + . 'VALUES (' . join(', ', $values) . ')'; + $statement = $this->dbAdapter->prepare($sql); + + foreach ($bind as $column => $value) { + $type = isset($types[$column]) ? $types[$column] : PDO::PARAM_STR; + $statement->bindValue(':' . $column, $value, $type); + } + + $statement->execute(); + return $statement->rowCount(); + } + + /** + * Update table rows with the given data, optionally limited by using a filter + * + * Note that the base implementation does not perform any quoting on the $table argument. + * Pass an array with a column name (the same as in $bind) and a PDO::PARAM_* constant as value + * as fourth parameter $types to define a different type than string for a particular column. + * + * @param string $table + * @param array $bind + * @param Filter $filter + * @param array $types + * + * @return int The number of affected rows + */ + public function update($table, array $bind, Filter $filter = null, array $types = array()) + { + $set = array(); + foreach ($bind as $column => $value) { + if ($value instanceof Zend_Db_Expr) { + $set[] = $column . ' = ' . $value; + unset($bind[$column]); + } else { + $set[] = $column . ' = :' . $column; + } + } + + $sql = 'UPDATE ' . $table + . ' SET ' . join(', ', $set) + . ($filter ? ' WHERE ' . $this->renderFilter($filter) : ''); + $statement = $this->dbAdapter->prepare($sql); + + foreach ($bind as $column => $value) { + $type = isset($types[$column]) ? $types[$column] : PDO::PARAM_STR; + $statement->bindValue(':' . $column, $value, $type); + } + + $statement->execute(); + return $statement->rowCount(); + } + + /** + * Delete table rows, optionally limited by using a filter + * + * @param string $table + * @param Filter $filter + * + * @return int The number of affected rows + */ + public function delete($table, Filter $filter = null) + { + return $this->dbAdapter->delete($table, $filter ? $this->renderFilter($filter) : ''); + } + + /** + * Render and return the given filter as SQL-WHERE clause + * + * @param Filter $filter + * + * @return string + */ + public function renderFilter(Filter $filter, $level = 0) + { + // TODO: This is supposed to supersede DbQuery::renderFilter() + $where = ''; + if ($filter->isChain()) { + if ($filter instanceof FilterAnd) { + $operator = ' AND '; + } elseif ($filter instanceof FilterOr) { + $operator = ' OR '; + } elseif ($filter instanceof FilterNot) { + $operator = ' AND '; + $where .= ' NOT '; + } else { + throw new ProgrammingError('Cannot render filter: %s', get_class($filter)); + } + + if (! $filter->isEmpty()) { + $parts = array(); + foreach ($filter->filters() as $filterPart) { + $part = $this->renderFilter($filterPart, $level + 1); + if ($part) { + $parts[] = $part; + } + } + + if (! empty($parts)) { + if ($level > 0) { + $where .= ' (' . implode($operator, $parts) . ') '; + } else { + $where .= implode($operator, $parts); + } + } + } else { + return ''; // Explicitly return the empty string due to the FilterNot case + } + } else { + $where .= $this->renderFilterExpression($filter); + } + + return $where; + } + + /** + * Render and return the given filter expression + * + * @param Filter $filter + * + * @return string + */ + protected function renderFilterExpression(Filter $filter) + { + $column = $filter->getColumn(); + $sign = $filter->getSign(); + $value = $filter->getExpression(); + + if (is_array($value)) { + $comp = []; + $pattern = []; + foreach ($value as $val) { + if (strpos($val, '*') === false) { + $comp[] = $val; + } else { + $pattern[] = $this->renderFilterExpression(Filter::expression($column, $sign, $val)); + } + } + + $sql = $pattern; + if ($sign === '=') { + if (! empty($comp)) { + $sql[] = $column . ' IN (' . $this->dbAdapter->quote($comp) . ')'; + } + + $operator = 'OR'; + } elseif ($sign === '!=') { + if (! empty($comp)) { + $sql[] = sprintf( + '(%1$s NOT IN (%2$s) OR %1$s IS NULL)', + $column, + $this->dbAdapter->quote($comp) + ); + } + + $operator = 'AND'; + } else { + throw new ProgrammingError( + 'Unable to render array expressions with operators other than equal or not equal' + ); + } + + return count($sql) === 1 ? $sql[0] : '(' . implode(" $operator ", $sql) . ')'; + } elseif ($sign === '=' + && ! $filter instanceof FilterEqual + && $value !== null + && strpos($value, '*') !== false + ) { + if ($value === '*') { + return $column . ' IS NOT NULL'; + } + + return $column . ' LIKE ' . $this->dbAdapter->quote(preg_replace('~\*~', '%', $value)); + } elseif ($sign === '!=' + && ! $filter instanceof FilterNotEqual + && $value !== null + && strpos($value, '*') !== false + ) { + if ($value === '*') { + return $column . ' IS NULL'; + } + + return sprintf( + '(%1$s NOT LIKE %2$s OR %1$s IS NULL)', + $column, + $this->dbAdapter->quote(preg_replace('~\*~', '%', $value)) + ); + } elseif ($sign === '!=') { + return sprintf('(%1$s != %2$s OR %1$s IS NULL)', $column, $this->dbAdapter->quote($value)); + } else { + return sprintf('%s %s %s', $column, $sign, $this->dbAdapter->quote($value)); + } + } + + public function inspect() + { + $insp = new Inspection('Db Connection'); + try { + $this->getDbAdapter()->getConnection(); + $config = $this->dbAdapter->getConfig(); + $insp->write(sprintf( + 'Connection to %s as %s on %s:%s successful', + $config['dbname'], + $config['username'], + array_key_exists('host', $config) ? $config['host'] : '(none)', + $config['port'] + )); + switch ($this->dbType) { + case 'mysql': + $rows = $this->dbAdapter->query( + 'SHOW VARIABLES WHERE variable_name ' . + 'IN (\'version\', \'protocol_version\', \'version_compile_os\', \'have_ssl\');' + )->fetchAll(); + $sqlinsp = new Inspection('MySQL'); + $hasSsl = false; + foreach ($rows as $row) { + $sqlinsp->write($row->variable_name . ': ' . $row->value); + if ($row->variable_name === 'have_ssl' && $row->value === 'YES') { + $hasSsl = true; + } + } + if ($hasSsl) { + $ssl_rows = $this->dbAdapter->query( + 'SHOW STATUS WHERE variable_name ' . + 'IN (\'Ssl_Cipher\');' + )->fetchAll(); + foreach ($ssl_rows as $ssl_row) { + $sqlinsp->write($ssl_row->variable_name . ': ' . $ssl_row->value); + } + } + $insp->write($sqlinsp); + break; + case 'pgsql': + $row = $this->dbAdapter->query('SELECT version();')->fetchAll(); + $sqlinsp = new Inspection('PostgreSQL'); + $sqlinsp->write($row[0]->version); + $insp->write($sqlinsp); + break; + } + } catch (Exception $e) { + return $insp->error(sprintf('Connection failed %s', $e->getMessage())); + } + return $insp; + } +} diff --git a/library/Icinga/Data/Db/DbQuery.php b/library/Icinga/Data/Db/DbQuery.php new file mode 100644 index 0000000..ff1d131 --- /dev/null +++ b/library/Icinga/Data/Db/DbQuery.php @@ -0,0 +1,565 @@ +<?php +/* Icinga Web 2 | (c) 2014 Icinga Development Team | GPLv2+ */ + +namespace Icinga\Data\Db; + +use DateInterval; +use DateTime; +use DateTimeZone; +use Exception; +use Icinga\Data\Filter\Filter; +use Zend_Db_Adapter_Abstract; +use Zend_Db_Expr; +use Zend_Db_Select; +use Icinga\Application\Logger; +use Icinga\Data\SimpleQuery; +use Icinga\Exception\ProgrammingError; +use Icinga\Exception\QueryException; + +/** + * Database query class + */ +class DbQuery extends SimpleQuery +{ + /** + * @var Zend_Db_Adapter_Abstract + */ + protected $db; + + /** + * Whether or not the query is a sub query + * + * Sub queries are automatically wrapped in parentheses + * + * @var bool + */ + protected $isSubQuery = false; + + /** + * Select query + * + * @var Zend_Db_Select + */ + protected $select; + + /** + * Whether to use a subquery for counting + * + * When the query is distinct or has a HAVING or GROUP BY clause this must be set to true + * + * @var bool + */ + protected $useSubqueryCount = false; + + /** + * Count query result + * + * Count queries are only executed once + * + * @var int + */ + protected $count; + + /** + * GROUP BY clauses + * + * @var string|array + */ + protected $group; + + protected function init() + { + $this->db = $this->ds->getDbAdapter(); + $this->select = $this->db->select(); + parent::init(); + } + + /** + * Get whether or not the query is a sub query + */ + public function getIsSubQuery() + { + return $this->isSubQuery; + } + + /** + * Set whether or not the query is a sub query + * + * @param bool $isSubQuery + * + * @return $this + */ + public function setIsSubQuery($isSubQuery = true) + { + $this->isSubQuery = (bool) $isSubQuery; + return $this; + } + + public function setUseSubqueryCount($useSubqueryCount = true) + { + $this->useSubqueryCount = $useSubqueryCount; + return $this; + } + + public function from($target, array $fields = null) + { + parent::from($target, $fields); + $this->select->from($this->target, array()); + return $this; + } + + public function where($condition, $value = null) + { + // $this->count = $this->select = null; + return parent::where($condition, $value); + } + + public function addFilter(Filter $filter) + { + $this->expressionsToTimestamp($filter); + return parent::addFilter($filter); + } + + private function expressionsToTimestamp(Filter $filter) + { + if ($filter->isChain()) { + foreach ($filter->filters() as $child) { + $this->expressionsToTimestamp($child); + } + } elseif ($this->isTimestamp($filter->getColumn())) { + $filter->setExpression($this->valueToTimestamp($filter->getExpression())); + } + } + + protected function dbSelect() + { + return clone $this->select; + } + + /** + * Return the underlying select + * + * @return Zend_Db_Select + */ + public function select() + { + return $this->select; + } + + /** + * Get the select query + * + * Applies order and limit if any + * + * @return Zend_Db_Select + */ + public function getSelectQuery() + { + $select = $this->dbSelect(); + // Add order fields to select for postgres distinct queries (#6351) + if ($this->hasOrder() + && $this->getDatasource()->getDbType() === 'pgsql' + && $select->getPart(Zend_Db_Select::DISTINCT) === true) { + foreach ($this->getOrder() as $fieldAndDirection) { + if (array_search($fieldAndDirection[0], $this->columns, true) === false) { + $this->columns[] = $fieldAndDirection[0]; + } + } + } + + $group = $this->getGroup(); + if ($group) { + $select->group($group); + } + + if (! empty($this->columns)) { + $select->columns($this->columns); + } + + $this->applyFilterSql($select); + + if ($this->hasLimit() || $this->hasOffset()) { + $select->limit($this->getLimit(), $this->getOffset()); + } + if ($this->hasOrder()) { + foreach ($this->getOrder() as $fieldAndDirection) { + $select->order( + $fieldAndDirection[0] . ' ' . $fieldAndDirection[1] + ); + } + } + + return $select; + } + + protected function applyFilterSql($select) + { + $where = $this->getDatasource()->renderFilter($this->filter); + if ($where !== '') { + $select->where($where); + } + } + + protected function escapeForSql($value) + { + // bindParam? bindValue? + if (is_array($value)) { + $ret = array(); + foreach ($value as $val) { + $ret[] = $this->escapeForSql($val); + } + return implode(', ', $ret); + } else { + //if (preg_match('/^\d+$/', $value)) { + // return $value; + //} else { + return $this->db->quote($value); + //} + } + } + + protected function escapeWildcards($value) + { + return preg_replace('/\*/', '%', $value); + } + + protected function valueToTimestamp($value) + { + if (is_string($value)) { + if (ctype_digit($value)) { + $value = (int) $value; + } else { + $value = strtotime($value); + } + } elseif (! is_int($value)) { + $value = (int) $value; + } + + return $value; + } + + /** + * Render the given timestamp based on the local timezone + * + * Since {@see DbConnection::defaultTimezoneOffset()} tells the database the timezone with just an offset, + * this will prepare the rendered value in a way that it plays fine with daylight savings. + * + * @param int $value + * @return string + */ + protected function timestampForSql($value) + { + if ($this->getDatasource()->getDbType() === 'pgsql') { + // We don't tell PostgreSQL the user's timezone + $dateTime = (new DateTime()) + ->setTimezone(new DateTimeZone('UTC')) + ->setTimestamp($value); + } else { + $dateTime = new DateTime(); + // Get "current" offset the database will use + $offsetToUTC = $dateTime->getOffset(); + // Set timezone to UTC and initialize it with the timestamp + $dateTime->setTimezone(new DateTimeZone('UTC'))->setTimestamp($value); + // Normalize every datetime based on the only offset the database knows about + if ($offsetToUTC >= 0) { + $dateTime->add(new DateInterval("PT{$offsetToUTC}S")); + } else { + $offsetToUTC = abs($offsetToUTC); + $dateTime->sub(new DateInterval("PT{$offsetToUTC}S")); + } + } + + return $dateTime->format('Y-m-d H:i:s'); + } + + /** + * Check for timestamp fields + * + * TODO: This is not here to do automagic timestamp stuff. One may + * override this function for custom voodoo, IdoQuery right now + * does. IMO we need to split whereToSql functionality, however + * I'd prefer to wait with this unless we understood how other + * backends will work. We probably should also rename this + * function to isTimestampColumn(). + * + * @param string $field Field Field name to checked + * @return bool Whether this field expects timestamps + */ + public function isTimestamp($field) + { + return false; + } + + /** + * Get the count query + * + * @return Zend_Db_Select + */ + public function getCountQuery() + { + // TODO: there may be situations where we should clone the "select" + $count = $this->dbSelect(); + $this->applyFilterSql($count); + $group = $this->getGroup(); + if ($this->useSubqueryCount || $group) { + if (! empty($this->columns)) { + $count->columns($this->columns); + } + if ($group) { + $count->group($group); + } + $columns = array('cnt' => 'COUNT(*)'); + return $this->db->select()->from($count, $columns); + } + + $count->columns(array('cnt' => 'COUNT(*)')); + return $count; + } + + /** + * Count all rows of the result set + * + * @return int + */ + public function count(): int + { + if ($this->count === null) { + $this->count = parent::count(); + } + + return $this->count; + } + + /** + * Return the select and count query as a textual representation + * + * @return string A string containing the select and count query, using unix style newlines as linebreaks + */ + public function dump() + { + return "QUERY\n=====\n" + . $this->getSelectQuery() + . "\n\nCOUNT\n=====\n" + . $this->getCountQuery() + . "\n\n"; + } + + public function __clone() + { + parent::__clone(); + $this->select = clone $this->select; + } + + /** + * @return string + */ + public function __toString() + { + try { + $select = (string) $this->getSelectQuery(); + return $this->getIsSubQuery() ? ('(' . $select . ')') : $select; + } catch (Exception $e) { + Logger::debug('Failed to render DbQuery. An error occured: %s', $e); + return ''; + } + } + + /** + * Add a GROUP BY clause + * + * @param string|array $group + * + * @return $this + */ + public function group($group) + { + $this->group = $group; + return $this; + } + + /** + * Return the GROUP BY clause + * + * @return string|array + */ + public function getGroup() + { + return $this->group; + } + + /** + * Return whether the given table has been joined + * + * @param string $table + * + * @return bool + */ + public function hasJoinedTable($table) + { + $fromPart = $this->select->getPart(Zend_Db_Select::FROM); + if (isset($fromPart[$table])) { + return true; + } + + foreach ($fromPart as $options) { + if ($options['tableName'] === $table && $options['joinType'] !== Zend_Db_Select::FROM) { + return true; + } + } + + return false; + } + + /** + * Return the alias used for joining the given table + * + * @param string $table + * + * @return string|null null in case no alias is being used + * + * @throws ProgrammingError In case the given table has not been joined + */ + public function getJoinedTableAlias($table) + { + $fromPart = $this->select->getPart(Zend_Db_Select::FROM); + if (isset($fromPart[$table])) { + if ($fromPart[$table]['joinType'] === Zend_Db_Select::FROM) { + throw new ProgrammingError('Table "%s" has not been joined', $table); + } + + return; // No alias in use + } + + foreach ($fromPart as $alias => $options) { + if ($options['tableName'] === $table && $options['joinType'] !== Zend_Db_Select::FROM) { + return $alias; + } + } + + throw new ProgrammingError('Table "%s" has not been joined', $table); + } + + /** + * Add an INNER JOIN table and colums to the query + * + * @param array|string|Zend_Db_Expr $name The table name + * @param string $cond Join on this condition + * @param array|string $cols The columns to select from the joined table + * @param string $schema The database name to specify, if any + * + * @return $this + */ + public function join($name, $cond, $cols = Zend_Db_Select::SQL_WILDCARD, $schema = null) + { + $this->select->joinInner($name, $cond, $cols, $schema); + return $this; + } + + /** + * Add an INNER JOIN table and colums to the query + * + * @param array|string|Zend_Db_Expr $name The table name + * @param string $cond Join on this condition + * @param array|string $cols The columns to select from the joined table + * @param string $schema The database name to specify, if any + * + * @return $this + */ + public function joinInner($name, $cond, $cols = Zend_Db_Select::SQL_WILDCARD, $schema = null) + { + $this->select->joinInner($name, $cond, $cols, $schema); + return $this; + } + + /** + * Add a LEFT OUTER JOIN table and colums to the query + * + * @param array|string|Zend_Db_Expr $name The table name + * @param string $cond Join on this condition + * @param array|string $cols The columns to select from the joined table + * @param string $schema The database name to specify, if any + * + * @return $this + */ + public function joinLeft($name, $cond, $cols = Zend_Db_Select::SQL_WILDCARD, $schema = null) + { + $this->select->joinLeft($name, $cond, $cols, $schema); + return $this; + } + + /** + * Add a RIGHT OUTER JOIN table and colums to the query + * + * @param array|string|Zend_Db_Expr $name The table name + * @param string $cond Join on this condition + * @param array|string $cols The columns to select from the joined table + * @param string $schema The database name to specify, if any + * + * @return $this + */ + public function joinRight($name, $cond, $cols = Zend_Db_Select::SQL_WILDCARD, $schema = null) + { + $this->select->joinRight($name, $cond, $cols, $schema); + return $this; + } + + /** + * Add a FULL OUTER JOIN table and colums to the query + * + * @param array|string|Zend_Db_Expr $name The table name + * @param string $cond Join on this condition + * @param array|string $cols The columns to select from the joined table + * @param string $schema The database name to specify, if any + * + * @return $this + */ + public function joinFull($name, $cond, $cols = Zend_Db_Select::SQL_WILDCARD, $schema = null) + { + $this->select->joinFull($name, $cond, $cols, $schema); + return $this; + } + + /** + * Add a CROSS JOIN table and colums to the query + * + * @param array|string|Zend_Db_Expr $name The table name + * @param array|string $cols The columns to select from the joined table + * @param string $schema The database name to specify, if any + * + * @return $this + */ + public function joinCross($name, $cols = Zend_Db_Select::SQL_WILDCARD, $schema = null) + { + $this->select->joinCross($name, $cols, $schema); + return $this; + } + + /** + * Add a NATURAL JOIN table and colums to the query + * + * @param array|string|Zend_Db_Expr $name The table name + * @param array|string $cols The columns to select from the joined table + * @param string $schema The database name to specify, if any + * + * @return $this + */ + public function joinNatural($name, $cols = Zend_Db_Select::SQL_WILDCARD, $schema = null) + { + $this->select->joinNatural($name, $cols, $schema); + return $this; + } + + /** + * Add a UNION clause to the query + * + * @param array $select Select clauses for the union + * @param string $type Type of UNION to use + * + * @return $this + */ + public function union($select = array(), $type = Zend_Db_Select::SQL_UNION) + { + $this->select->union($select, $type); + return $this; + } +} diff --git a/library/Icinga/Data/Extensible.php b/library/Icinga/Data/Extensible.php new file mode 100644 index 0000000..ad690d8 --- /dev/null +++ b/library/Icinga/Data/Extensible.php @@ -0,0 +1,22 @@ +<?php +/* Icinga Web 2 | (c) 2015 Icinga Development Team | GPLv2+ */ + +namespace Icinga\Data; + +use Icinga\Exception\StatementException; + +/** + * Interface for data insertion + */ +interface Extensible +{ + /** + * Insert the given data for the given target + * + * @param string $target + * @param array $data + * + * @throws StatementException + */ + public function insert($target, array $data); +} diff --git a/library/Icinga/Data/Fetchable.php b/library/Icinga/Data/Fetchable.php new file mode 100644 index 0000000..342740a --- /dev/null +++ b/library/Icinga/Data/Fetchable.php @@ -0,0 +1,47 @@ +<?php +/* Icinga Web 2 | (c) 2014 Icinga Development Team | GPLv2+ */ + +namespace Icinga\Data; + +/** + * Interface for retrieving data + */ +interface Fetchable +{ + /** + * Retrieve an array containing all rows of the result set + * + * @return array + */ + public function fetchAll(); + + /** + * Fetch the first row of the result set + * + * @return mixed + */ + public function fetchRow(); + + /** + * Fetch the first column of all rows of the result set as an array + * + * @return array + */ + public function fetchColumn(); + + /** + * Fetch the first column of the first row of the result set + * + * @return string + */ + public function fetchOne(); + + /** + * Fetch all rows of the result set as an array of key-value pairs + * + * The first column is the key, the second column is the value. + * + * @return array + */ + public function fetchPairs(); +} diff --git a/library/Icinga/Data/Filter/Filter.php b/library/Icinga/Data/Filter/Filter.php new file mode 100644 index 0000000..f5d8bdf --- /dev/null +++ b/library/Icinga/Data/Filter/Filter.php @@ -0,0 +1,255 @@ +<?php +/* Icinga Web 2 | (c) 2014 Icinga Development Team | GPLv2+ */ + +namespace Icinga\Data\Filter; + +use Icinga\Web\UrlParams; +use Icinga\Exception\ProgrammingError; + +/** + * Filter + * + * Base class for filters (why?) and factory for the different FilterOperators + */ +abstract class Filter +{ + protected $id = '1'; + + public function setId($id) + { + $this->id = (string) $id; + return $this; + } + + abstract public function isExpression(); + + abstract public function isChain(); + + abstract public function isEmpty(); + + abstract public function toQueryString(); + + abstract public function andFilter(Filter $filter); + + abstract public function orFilter(Filter $filter); + + /** + * Whether the give row matches this Filter + * + * @param mixed $row Preferrably an stdClass instance + * @return bool + */ + abstract public function matches($row); + + public function getUrlParams() + { + return UrlParams::fromQueryString($this->toQueryString()); + } + + public function getById($id) + { + if ((string) $id === $this->getId()) { + return $this; + } + throw new ProgrammingError( + 'Trying to get invalid filter index "%s" from "%s" ("%s")', + $id, + $this, + $this->id + ); + } + + public function getId() + { + return $this->id; + } + + public function isRootNode() + { + return false === strpos($this->id, '-'); + } + + abstract public function listFilteredColumns(); + + public function applyChanges($changes) + { + $filter = $this; + $pairs = array(); + foreach ($changes as $k => $v) { + if (preg_match('/^(column|value|sign|operator)_([\d-]+)$/', $k, $m)) { + $pairs[$m[2]][$m[1]] = $v; + } + } + $operators = array(); + foreach ($pairs as $id => $fs) { + if (array_key_exists('operator', $fs)) { + $operators[$id] = $fs['operator']; + } else { + $f = $filter->getById($id); + $f->setColumn($fs['column']); + if ($f->getSign() !== $fs['sign']) { + if ($f->isRootNode()) { + $filter = $f->setSign($fs['sign']); + } else { + $filter->replaceById($id, $f->setSign($fs['sign'])); + } + } + $f->setExpression($fs['value']); + } + } + + krsort($operators, SORT_NATURAL); + foreach ($operators as $id => $operator) { + $f = $filter->getById($id); + if ($f->getOperatorName() !== $operator) { + if ($f->isRootNode()) { + $filter = $f->setOperatorName($operator); + } else { + $filter->replaceById($id, $f->setOperatorName($operator)); + } + } + } + + return $filter; + } + + public function getParentId() + { + if ($this->isRootNode()) { + throw new ProgrammingError('Filter root nodes have no parent'); + } + return substr($this->id, 0, strrpos($this->id, '-')); + } + + public function getParent() + { + return $this->getById($this->getParentId()); + } + + public function hasId($id) + { + if ($id === $this->getId()) { + return true; + } + return false; + } + + /** + * Where Filter factory + * + * @param string $col Column to be filtered + * @param string $filter Filter expression + * + * @throws FilterException + * @return FilterExpression + */ + public static function where($col, $filter) + { + return new FilterExpression($col, '=', $filter); + } + + public static function expression($col, $op, $expression) + { + switch ($op) { + case '=': + return new FilterMatch($col, $op, $expression); + case '<': + return new FilterLessThan($col, $op, $expression); + case '>': + return new FilterGreaterThan($col, $op, $expression); + case '>=': + return new FilterEqualOrGreaterThan($col, $op, $expression); + case '<=': + return new FilterEqualOrLessThan($col, $op, $expression); + case '!=': + return new FilterMatchNot($col, $op, $expression); + default: + throw new ProgrammingError( + 'There is no such filter sign: %s', + $op + ); + } + } + + /** + * Or FilterOperator factory + * + * @param Filter $filter,... Unlimited optional list of Filters + * + * @return FilterOr + */ + public static function matchAny() + { + $args = func_get_args(); + if (count($args) === 1 && is_array($args[0])) { + $args = $args[0]; + } + return new FilterOr($args); + } + + /** + * Or FilterOperator factory + * + * @param Filter $filter,... Unlimited optional list of Filters + * + * @return FilterAnd + */ + public static function matchAll() + { + $args = func_get_args(); + if (count($args) === 1 && is_array($args[0])) { + $args = $args[0]; + } + return new FilterAnd($args); + } + + /** + * FilterNot factory, negates the given filter + * + * @param Filter $filter Filter to be negated + * + * @return FilterNot + */ + public static function not() + { + $args = func_get_args(); + if (count($args) === 1) { + if (is_array($args[0])) { + $args = $args[0]; + } + } + if (count($args) > 1) { + return new FilterNot(array(new FilterAnd($args))); + } else { + return new FilterNot($args); + } + } + + public static function chain($operator, $filters = array()) + { + switch ($operator) { + case 'AND': + return self::matchAll($filters); + case 'OR': + return self::matchAny($filters); + case 'NOT': + return self::not($filters); + } + throw new ProgrammingError( + '"%s" is not a valid filter chain operator', + $operator + ); + } + + /** + * Create filter from queryString + * + * This is still pretty basic, need improvement + * + * @return static + */ + public static function fromQueryString($query) + { + return FilterQueryString::parse($query); + } +} diff --git a/library/Icinga/Data/Filter/FilterAnd.php b/library/Icinga/Data/Filter/FilterAnd.php new file mode 100644 index 0000000..96b68cc --- /dev/null +++ b/library/Icinga/Data/Filter/FilterAnd.php @@ -0,0 +1,42 @@ +<?php +/* Icinga Web 2 | (c) 2014 Icinga Development Team | GPLv2+ */ + +namespace Icinga\Data\Filter; + +/** + * Filter list AND + * + * Binary AND, all contained filters must succeed + */ +class FilterAnd extends FilterChain +{ + protected $operatorName = 'AND'; + + protected $operatorSymbol = '&'; + + /** + * Whether the given row object matches this filter + * + * @object $row + * @return boolean + */ + public function matches($row) + { + foreach ($this->filters as $filter) { + if (! $filter->matches($row)) { + return false; + } + } + return true; + } + + public function andFilter(Filter $filter) + { + return $this->addFilter($filter); + } + + public function orFilter(Filter $filter) + { + return Filter::matchAny($this, $filter); + } +} diff --git a/library/Icinga/Data/Filter/FilterChain.php b/library/Icinga/Data/Filter/FilterChain.php new file mode 100644 index 0000000..0f1e071 --- /dev/null +++ b/library/Icinga/Data/Filter/FilterChain.php @@ -0,0 +1,286 @@ +<?php +/* Icinga Web 2 | (c) 2014 Icinga Development Team | GPLv2+ */ + +namespace Icinga\Data\Filter; + +use Icinga\Exception\ProgrammingError; +use Icinga\Exception\QueryException; + +/** + * FilterChain + * + * A FilterChain contains a list ... + */ +abstract class FilterChain extends Filter +{ + protected $filters = array(); + + protected $operatorName; + + protected $operatorSymbol; + + protected $allowedColumns; + + /** + * Set the filters + * + * @param array $filters + * + * @return $this + */ + public function setFilters(array $filters) + { + $this->filters = $filters; + + $this->refreshChildIds(); + + return $this; + } + + public function hasId($id) + { + foreach ($this->filters() as $filter) { + if ($filter->hasId($id)) { + return true; + } + } + return parent::hasId($id); + } + + public function getById($id) + { + foreach ($this->filters() as $filter) { + if ($filter->hasId($id)) { + return $filter->getById($id); + } + } + return parent::getById($id); + } + + public function removeId($id) + { + if ($id === $this->getId()) { + $this->filters = array(); + return $this; + } + $remove = null; + foreach ($this->filters as $key => $filter) { + if ($filter->getId() === $id) { + $remove = $key; + } elseif ($filter instanceof FilterChain) { + $filter->removeId($id); + } + } + if ($remove !== null) { + unset($this->filters[$remove]); + $this->filters = array_values($this->filters); + } + $this->refreshChildIds(); + return $this; + } + + public function replaceById($id, $filter) + { + $found = false; + foreach ($this->filters as $k => $child) { + if ($child->getId() == $id) { + $this->filters[$k] = $filter; + $found = true; + break; + } + if ($child->hasId($id)) { + $child->replaceById($id, $filter); + $found = true; + break; + } + } + if (! $found) { + throw new ProgrammingError('You tried to replace an unexistant child filter'); + } + $this->refreshChildIds(); + return $this; + } + + protected function refreshChildIds() + { + $i = 0; + $id = $this->getId(); + foreach ($this->filters as $filter) { + $i++; + $filter->setId($id . '-' . $i); + } + return $this; + } + + public function setId($id) + { + return parent::setId($id)->refreshChildIds(); + } + + public function getOperatorName() + { + return $this->operatorName; + } + + public function setOperatorName($name) + { + if ($name !== $this->operatorName) { + return Filter::chain($name, $this->filters); + } + return $this; + } + + public function getOperatorSymbol() + { + return $this->operatorSymbol; + } + + public function setAllowedFilterColumns(array $columns) + { + $this->allowedColumns = $columns; + return $this; + } + + /** + * List and return all column names referenced in this filter + * + * @param array $columns The columns listed so far + * + * @return array + */ + public function listFilteredColumns(array $columns = array()) + { + foreach ($this->filters as $filter) { + if ($filter instanceof FilterExpression) { + $column= $filter->getColumn(); + if (! in_array($column, $columns, true)) { + $columns[] = $column; + } + } else { + $columns = $filter->listFilteredColumns($columns); + } + } + + return $columns; + } + + public function toQueryString() + { + $parts = array(); + if (empty($this->filters)) { + return ''; + } + foreach ($this->filters() as $filter) { + if (! $filter->isEmpty()) { + $parts[] = $filter->toQueryString(); + } + } + + // TODO: getLevel?? + if (strpos($this->getId(), '-')) { + return '(' . implode($this->getOperatorSymbol(), $parts) . ')'; + } else { + return implode($this->getOperatorSymbol(), $parts); + } + } + + /** + * Get simple string representation + * + * Useful for debugging only + * + * @return string + */ + public function __toString() + { + if (empty($this->filters)) { + return ''; + } + $parts = array(); + foreach ($this->filters as $filter) { + if ($filter instanceof FilterChain) { + $parts[] = '(' . $filter . ')'; + } else { + $parts[] = (string) $filter; + } + } + $op = ' ' . $this->getOperatorSymbol() . ' '; + return implode($op, $parts); + } + + public function __construct($filters = array()) + { + foreach ($filters as $filter) { + $this->addFilter($filter); + } + } + + public function isExpression() + { + return false; + } + + public function isChain() + { + return true; + } + + public function isEmpty() + { + return empty($this->filters); + } + + public function addFilter(Filter $filter) + { + if (! empty($this->allowedColumns)) { + $this->validateFilterColumns($filter); + } + + $this->filters[] = $filter; + $filter->setId($this->getId() . '-' . $this->count()); + return $this; + } + + protected function validateFilterColumns(Filter $filter) + { + if ($filter->isExpression()) { + $valid = false; + foreach ($this->allowedColumns as $column) { + if (is_callable($column)) { + if (call_user_func($column, $filter->getColumn())) { + $valid = true; + break; + } + } elseif ($filter->getColumn() === $column) { + $valid = true; + break; + } + } + + if (! $valid) { + throw new QueryException('Invalid filter column provided: %s', $filter->getColumn()); + } + } else { + foreach ($filter->filters() as $subFilter) { + $this->validateFilterColumns($subFilter); + } + } + } + + public function &filters() + { + return $this->filters; + } + + public function count() + { + return count($this->filters); + } + + public function __clone() + { + foreach ($this->filters as & $filter) { + $filter = clone $filter; + } + } +} diff --git a/library/Icinga/Data/Filter/FilterEqual.php b/library/Icinga/Data/Filter/FilterEqual.php new file mode 100644 index 0000000..da53d3f --- /dev/null +++ b/library/Icinga/Data/Filter/FilterEqual.php @@ -0,0 +1,16 @@ +<?php +/* Icinga Web 2 | (c) 2014 Icinga Development Team | GPLv2+ */ + +namespace Icinga\Data\Filter; + +class FilterEqual extends FilterExpression +{ + public function matches($row) + { + if (! isset($row->{$this->column})) { + return false; + } + + return (string) $row->{$this->column} === (string) $this->expression; + } +} diff --git a/library/Icinga/Data/Filter/FilterEqualOrGreaterThan.php b/library/Icinga/Data/Filter/FilterEqualOrGreaterThan.php new file mode 100644 index 0000000..d7bd5b8 --- /dev/null +++ b/library/Icinga/Data/Filter/FilterEqualOrGreaterThan.php @@ -0,0 +1,16 @@ +<?php +/* Icinga Web 2 | (c) 2014 Icinga Development Team | GPLv2+ */ + +namespace Icinga\Data\Filter; + +class FilterEqualOrGreaterThan extends FilterExpression +{ + public function matches($row) + { + if (! isset($row->{$this->column})) { + return false; + } + + return (string) $row->{$this->column} >= (string) $this->expression; + } +} diff --git a/library/Icinga/Data/Filter/FilterEqualOrLessThan.php b/library/Icinga/Data/Filter/FilterEqualOrLessThan.php new file mode 100644 index 0000000..8016fc4 --- /dev/null +++ b/library/Icinga/Data/Filter/FilterEqualOrLessThan.php @@ -0,0 +1,26 @@ +<?php +/* Icinga Web 2 | (c) 2014 Icinga Development Team | GPLv2+ */ + +namespace Icinga\Data\Filter; + +class FilterEqualOrLessThan extends FilterExpression +{ + public function __toString() + { + return $this->column . ' <= ' . $this->expression; + } + + public function toQueryString() + { + return $this->column . '<=' . $this->expression; + } + + public function matches($row) + { + if (! isset($row->{$this->column})) { + return false; + } + + return (string) $row->{$this->column} <= (string) $this->expression; + } +} diff --git a/library/Icinga/Data/Filter/FilterException.php b/library/Icinga/Data/Filter/FilterException.php new file mode 100644 index 0000000..842d7ab --- /dev/null +++ b/library/Icinga/Data/Filter/FilterException.php @@ -0,0 +1,15 @@ +<?php +/* Icinga Web 2 | (c) 2014 Icinga Development Team | GPLv2+ */ + +namespace Icinga\Data\Filter; + +use Icinga\Exception\IcingaException; + +/** + * Filter Exception Class + * + * Filter Exceptions should be thrown on filter parse errors or similar + */ +class FilterException extends IcingaException +{ +} diff --git a/library/Icinga/Data/Filter/FilterExpression.php b/library/Icinga/Data/Filter/FilterExpression.php new file mode 100644 index 0000000..73fb625 --- /dev/null +++ b/library/Icinga/Data/Filter/FilterExpression.php @@ -0,0 +1,224 @@ +<?php +/* Icinga Web 2 | (c) 2014 Icinga Development Team | GPLv2+ */ + +namespace Icinga\Data\Filter; + +use Exception; + +class FilterExpression extends Filter +{ + protected $column; + protected $sign; + protected $expression; + + /** + * Does this filter compare case sensitive? + * + * @var bool + */ + protected $caseSensitive; + + public function __construct($column, $sign, $expression) + { + $column = trim($column); + $this->column = $column; + $this->sign = $sign; + $this->expression = $expression; + $this->caseSensitive = true; + } + + public function isExpression() + { + return true; + } + + public function isChain() + { + return false; + } + + public function isEmpty() + { + return false; + } + + public function getColumn() + { + return $this->column; + } + + public function getSign() + { + return $this->sign; + } + + public function setColumn($column) + { + $this->column = $column; + return $this; + } + + public function getExpression() + { + return $this->expression; + } + + /** + * Return whether this filter compares case sensitive + * + * @return bool + */ + public function getCaseSensitive() + { + return $this->caseSensitive; + } + + public function setExpression($expression) + { + $this->expression = $expression; + return $this; + } + + public function setSign($sign) + { + if ($sign !== $this->sign) { + return Filter::expression($this->column, $sign, $this->expression); + } + return $this; + } + + /** + * Set this filter's case sensitivity + * + * @param bool $caseSensitive + * + * @return $this + */ + public function setCaseSensitive($caseSensitive = true) + { + $this->caseSensitive = $caseSensitive; + return $this; + } + + public function listFilteredColumns() + { + return array($this->getColumn()); + } + + public function __toString() + { + if ($this->isBooleanTrue()) { + return $this->column; + } + + $expression = is_array($this->expression) ? + '( ' . implode(' | ', $this->expression) . ' )' : + $this->expression; + + return sprintf( + '%s %s %s', + $this->column, + $this->sign, + $expression + ); + } + + public function toQueryString() + { + if ($this->isBooleanTrue()) { + return $this->column; + } + + $expression = is_array($this->expression) ? + '(' . implode('|', array_map('rawurlencode', $this->expression)) . ')' : + rawurlencode($this->expression); + + return $this->column . $this->sign . $expression; + } + + protected function isBooleanTrue() + { + return $this->sign === '=' && $this->expression === true; + } + + /** + * If $var is a scalar, do the same as strtolower() would do. + * If $var is an array, map $this->strtolowerRecursive() to its elements. + * Otherwise, return $var unchanged. + * + * @param mixed $var + * + * @return mixed + */ + protected function strtolowerRecursive($var) + { + if ($var === null) { + return ''; + } + if (is_scalar($var)) { + return strtolower($var); + } + if (is_array($var)) { + return array_map(array($this, 'strtolowerRecursive'), $var); + } + return $var; + } + + public function matches($row) + { + try { + $rowValue = $row->{$this->column}; + } catch (Exception $e) { + // TODO: REALLY? Exception? + return false; + } + + if ($this->caseSensitive) { + $expression = $this->expression; + } else { + $rowValue = $this->strtolowerRecursive($rowValue); + $expression = $this->strtolowerRecursive($this->expression); + } + + if (is_array($expression)) { + return in_array($rowValue, $expression); + } + + $expression = (string) $expression; + if (strpos($expression, '*') === false) { + if (is_array($rowValue)) { + return in_array($expression, $rowValue); + } + + return (string) $rowValue === $expression; + } + + $parts = array(); + foreach (preg_split('~\*~', $expression) as $part) { + $parts[] = preg_quote($part, '/'); + } + $pattern = '/^' . implode('.*', $parts) . '$/'; + + if (is_array($rowValue)) { + foreach ($rowValue as $candidate) { + if (preg_match($pattern, $candidate)) { + return true; + } + } + + return false; + } + + return $rowValue !== null && preg_match($pattern, $rowValue); + } + + public function andFilter(Filter $filter) + { + return Filter::matchAll($this, $filter); + } + + public function orFilter(Filter $filter) + { + return Filter::matchAny($this, $filter); + } +} diff --git a/library/Icinga/Data/Filter/FilterGreaterThan.php b/library/Icinga/Data/Filter/FilterGreaterThan.php new file mode 100644 index 0000000..92a0e62 --- /dev/null +++ b/library/Icinga/Data/Filter/FilterGreaterThan.php @@ -0,0 +1,16 @@ +<?php +/* Icinga Web 2 | (c) 2014 Icinga Development Team | GPLv2+ */ + +namespace Icinga\Data\Filter; + +class FilterGreaterThan extends FilterExpression +{ + public function matches($row) + { + if (! isset($row->{$this->column})) { + // TODO: REALLY? Exception? + return false; + } + return (string) $row->{$this->column} > (string) $this->expression; + } +} diff --git a/library/Icinga/Data/Filter/FilterLessThan.php b/library/Icinga/Data/Filter/FilterLessThan.php new file mode 100644 index 0000000..c13a1ce --- /dev/null +++ b/library/Icinga/Data/Filter/FilterLessThan.php @@ -0,0 +1,26 @@ +<?php +/* Icinga Web 2 | (c) 2014 Icinga Development Team | GPLv2+ */ + +namespace Icinga\Data\Filter; + +class FilterLessThan extends FilterExpression +{ + public function __toString() + { + return $this->column . ' < ' . $this->expression; + } + + public function toQueryString() + { + return $this->column . '<' . $this->expression; + } + + public function matches($row) + { + if (! isset($row->{$this->column})) { + return false; + } + + return (string) $row->{$this->column} < (string) $this->expression; + } +} diff --git a/library/Icinga/Data/Filter/FilterMatch.php b/library/Icinga/Data/Filter/FilterMatch.php new file mode 100644 index 0000000..a3befad --- /dev/null +++ b/library/Icinga/Data/Filter/FilterMatch.php @@ -0,0 +1,8 @@ +<?php +/* Icinga Web 2 | (c) 2014 Icinga Development Team | GPLv2+ */ + +namespace Icinga\Data\Filter; + +class FilterMatch extends FilterExpression +{ +} diff --git a/library/Icinga/Data/Filter/FilterMatchCaseInsensitive.php b/library/Icinga/Data/Filter/FilterMatchCaseInsensitive.php new file mode 100644 index 0000000..9eca173 --- /dev/null +++ b/library/Icinga/Data/Filter/FilterMatchCaseInsensitive.php @@ -0,0 +1,13 @@ +<?php +/* Icinga Web 2 | (c) 2016 Icinga Development Team | GPLv2+ */ + +namespace Icinga\Data\Filter; + +class FilterMatchCaseInsensitive extends FilterMatch +{ + public function __construct($column, $sign, $expression) + { + parent::__construct($column, $sign, $expression); + $this->caseSensitive = false; + } +} diff --git a/library/Icinga/Data/Filter/FilterMatchNot.php b/library/Icinga/Data/Filter/FilterMatchNot.php new file mode 100644 index 0000000..1e5050e --- /dev/null +++ b/library/Icinga/Data/Filter/FilterMatchNot.php @@ -0,0 +1,12 @@ +<?php +/* Icinga Web 2 | (c) 2014 Icinga Development Team | GPLv2+ */ + +namespace Icinga\Data\Filter; + +class FilterMatchNot extends FilterExpression +{ + public function matches($row) + { + return !parent::matches($row); + } +} diff --git a/library/Icinga/Data/Filter/FilterMatchNotCaseInsensitive.php b/library/Icinga/Data/Filter/FilterMatchNotCaseInsensitive.php new file mode 100644 index 0000000..3838fa2 --- /dev/null +++ b/library/Icinga/Data/Filter/FilterMatchNotCaseInsensitive.php @@ -0,0 +1,13 @@ +<?php +/* Icinga Web 2 | (c) 2016 Icinga Development Team | GPLv2+ */ + +namespace Icinga\Data\Filter; + +class FilterMatchNotCaseInsensitive extends FilterMatchNot +{ + public function __construct($column, $sign, $expression) + { + parent::__construct($column, $sign, $expression); + $this->caseSensitive = false; + } +} diff --git a/library/Icinga/Data/Filter/FilterNot.php b/library/Icinga/Data/Filter/FilterNot.php new file mode 100644 index 0000000..b61f497 --- /dev/null +++ b/library/Icinga/Data/Filter/FilterNot.php @@ -0,0 +1,58 @@ +<?php +/* Icinga Web 2 | (c) 2014 Icinga Development Team | GPLv2+ */ + +namespace Icinga\Data\Filter; + +class FilterNot extends FilterChain +{ + protected $operatorName = 'NOT'; + + protected $operatorSymbol = '!'; // BULLSHIT + +// TODO: Max count 1 or autocreate sub-and? + + public function matches($row) + { + foreach ($this->filters() as $filter) { + if ($filter->matches($row)) { + return false; + } + } + return true; + } + + public function andFilter(Filter $filter) + { + return Filter::matchAll($this, $filter); + } + + public function orFilter(Filter $filter) + { + return Filter::matchAny($filter); + } + + public function toQueryString() + { + $parts = array(); + if (empty($this->filters)) { + return ''; + } + + foreach ($this->filters() as $filter) { + $parts[] = $filter->toQueryString(); + } + if (count($parts) === 1) { + return '!' . $parts[0]; + } else { + return '!(' . implode('&', $parts) . ')'; + } + } + + public function __toString() + { + if (count($this->filters) === 1) { + return '! ' . $this->filters[0]; + } + return '! (' . implode('&', $this->filters) . ')'; + } +} diff --git a/library/Icinga/Data/Filter/FilterNotEqual.php b/library/Icinga/Data/Filter/FilterNotEqual.php new file mode 100644 index 0000000..8915a3d --- /dev/null +++ b/library/Icinga/Data/Filter/FilterNotEqual.php @@ -0,0 +1,12 @@ +<?php +/* Icinga Web 2 | (c) 2014 Icinga Development Team | GPLv2+ */ + +namespace Icinga\Data\Filter; + +class FilterNotEqual extends FilterExpression +{ + public function matches($row) + { + return (string) $row->{$this->column} !== (string) $this->expression; + } +} diff --git a/library/Icinga/Data/Filter/FilterOr.php b/library/Icinga/Data/Filter/FilterOr.php new file mode 100644 index 0000000..aca91f3 --- /dev/null +++ b/library/Icinga/Data/Filter/FilterOr.php @@ -0,0 +1,39 @@ +<?php +/* Icinga Web 2 | (c) 2014 Icinga Development Team | GPLv2+ */ + +namespace Icinga\Data\Filter; + +class FilterOr extends FilterChain +{ + protected $operatorName = 'OR'; + + protected $operatorSymbol = '|'; + + public function matches($row) + { + foreach ($this->filters as $filter) { + if ($filter->matches($row)) { + return true; + } + } + return false; + } + + public function setOperatorName($name) + { + if ($this->count() > 1 && $name === 'NOT') { + return Filter::not(clone $this); + } + return parent::setOperatorName($name); + } + + public function andFilter(Filter $filter) + { + return Filter::matchAll($this, $filter); + } + + public function orFilter(Filter $filter) + { + return $this->addFilter($filter); + } +} diff --git a/library/Icinga/Data/Filter/FilterParseException.php b/library/Icinga/Data/Filter/FilterParseException.php new file mode 100644 index 0000000..f2b732b --- /dev/null +++ b/library/Icinga/Data/Filter/FilterParseException.php @@ -0,0 +1,10 @@ +<?php +/* Icinga Web 2 | (c) 2014 Icinga Development Team | GPLv2+ */ + +namespace Icinga\Data\Filter; + +use Icinga\Exception\IcingaException; + +class FilterParseException extends IcingaException +{ +} diff --git a/library/Icinga/Data/Filter/FilterQueryString.php b/library/Icinga/Data/Filter/FilterQueryString.php new file mode 100644 index 0000000..8535df5 --- /dev/null +++ b/library/Icinga/Data/Filter/FilterQueryString.php @@ -0,0 +1,320 @@ +<?php +/* Icinga Web 2 | (c) 2014 Icinga Development Team | GPLv2+ */ + +namespace Icinga\Data\Filter; + +class FilterQueryString +{ + protected $string; + + protected $pos; + + protected $debug = array(); + + protected $reportDebug = false; + + protected $length; + + protected function __construct() + { + } + + protected function debug($msg, $level = 0, $op = null) + { + if ($op === null) { + $op = 'NULL'; + } + $this->debug[] = sprintf( + '%s[%d=%s] (%s): %s', + str_repeat('* ', $level), + $this->pos, + $this->string[$this->pos - 1], + $op, + $msg + ); + } + + public static function parse($string) + { + $parser = new static(); + return $parser->parseQueryString($string); + } + + protected function readNextKey() + { + $str = $this->readUnlessSpecialChar(); + + if ($str === false) { + return $str; + } + return rawurldecode($str); + } + + protected function readNextValue() + { + if ($this->nextChar() === '(') { + $this->readChar(); + $var = preg_split('~\|~', $this->readUnless(')')); + if ($this->readChar() !== ')') { + $this->parseError(null, 'Expected ")"'); + } + } else { + $var = rawurldecode($this->readUnless(array(')', '&', '|', '>', '<'))); + } + return $var; + } + + protected function readNextExpression() + { + if ('' === ($key = $this->readNextKey())) { + return false; + } + + foreach (array('<', '>') as $sign) { + if (false !== ($pos = strpos($key, $sign))) { + if ($this->nextChar() === '=') { + break; + } + $var = substr($key, $pos + 1); + $key = substr($key, 0, $pos); + + if (ctype_digit($var)) { + $var = (float) $var; + } + + return Filter::expression($key, $sign, $var); + } + } + if (in_array($this->nextChar(), array('=', '>', '<', '!'))) { + $sign = $this->readChar(); + } else { + $sign = false; + } + if ($sign === false) { + return Filter::expression($key, '=', true); + } + + $toFloat = false; + if ($sign === '=') { + $last = substr($key, -1); + if ($last === '>' || $last === '<') { + $sign = $last . $sign; + $key = substr($key, 0, -1); + $toFloat = true; + } + // TODO: Same as above for unescaped <> - do we really need this? + } elseif ($sign === '>' || $sign === '<' || $sign === '!') { + $toFloat = $sign === '>' || $sign === '<'; + if ($this->nextChar() === '=') { + $sign .= $this->readChar(); + } + } + + $var = $this->readNextValue(); + if ($toFloat && ctype_digit($var)) { + $var = (float) $var; + } + + return Filter::expression($key, $sign, $var); + } + + protected function parseError($char = null, $extraMsg = null) + { + if ($extraMsg === null) { + $extra = ''; + } else { + $extra = ': ' . $extraMsg; + } + if ($char === null) { + $char = $this->string[$this->pos]; + } + if ($this->reportDebug) { + $extra .= "\n" . implode("\n", $this->debug); + } + + throw new FilterParseException( + 'Invalid filter "%s", unexpected %s at pos %d%s', + $this->string, + $char, + $this->pos, + $extra + ); + } + + protected function readFilters($nestingLevel = 0, $op = null) + { + $filters = array(); + while ($this->pos < $this->length) { + if ($op === '!' && count($filters) === 1) { + break; + } + $filter = $this->readNextExpression(); + $next = $this->readChar(); + + + if ($filter === false) { + $this->debug('Got no next expression, next is ' . $next, $nestingLevel, $op); + if ($next === '!') { + $not = $this->readFilters($nestingLevel + 1, '!'); + $filters[] = $not; + if (in_array($this->nextChar(), array('|', '&', ')'))) { + $next = $this->readChar(); + $this->debug('Got NOT, next is now: ' . $next, $nestingLevel, $op); + } else { + $this->debug('Breaking after NOT: ' . $not, $nestingLevel, $op); + break; + } + } + + if ($op === null && count($filters) > 0 && ($next === '&' || $next === '|')) { + $op = $next; + continue; + } + + if ($next === false) { + // Nothing more to read + break; + } + + if ($next === ')') { + if ($nestingLevel > 0) { + $this->debug('Closing without filter: ' . $next, $nestingLevel, $op); + break; + } + $this->parseError($next); + } + if ($next === '(') { + $filters[] = $this->readFilters($nestingLevel + 1, null); + continue; + } + if ($next === $op) { + continue; + } + $this->parseError($next, "$op level $nestingLevel"); + } else { + $this->debug('Got new expression: ' . $filter, $nestingLevel, $op); + + $filters[] = $filter; + + if ($next === false) { + $this->debug('Next is false, nothing to read but got filter', $nestingLevel, $op); + // Got filter, nothing more to read + break; + } + + if ($op === '!') { + $this->pos--; + break; + } + if ($next === $op) { + $this->debug('Next matches operator', $nestingLevel, $op); + continue; // Break?? + } + + if ($next === ')') { + if ($nestingLevel > 0) { + $this->debug('Closing with filter: ' . $next, $nestingLevel, $op); + break; + } + $this->parseError($next); + } + if ($op === null && in_array($next, array('&', '|'))) { + $this->debug('Setting op to ' . $next, $nestingLevel, $op); + $op = $next; + continue; + } + $this->parseError($next); + } + } + + if ($nestingLevel === 0 && $this->pos < $this->length) { + $this->parseError($op, 'Did not read full filter'); + } + + if ($nestingLevel === 0 && count($filters) === 1 && $op !== '!') { + // There is only one filter expression, no chain + $this->debug('Returning first filter only: ' . $filters[0], $nestingLevel, $op); + return $filters[0]; + } + + if ($op === null && count($filters) === 1) { + $this->debug('No op, single filter, setting AND', $nestingLevel, $op); + $op = '&'; + } + $this->debug(sprintf('Got %d filters, returning', count($filters)), $nestingLevel, $op); + + switch ($op) { + case '&': + return Filter::matchAll($filters); + case '|': + return Filter::matchAny($filters); + case '!': + return Filter::not($filters); + case null: + return Filter::matchAll(); + default: + $this->parseError($op); + } + } + + protected function parseQueryString($string) + { + $this->pos = 0; + + $this->string = $string; + + $this->length = $string ? strlen($string) : 0; + + if ($this->length === 0) { + return Filter::matchAll(); + } + return $this->readFilters(); + } + + protected function readUnless($char) + { + $buffer = ''; + while (false !== ($c = $this->readChar())) { + if (is_array($char)) { + if (in_array($c, $char)) { + $this->pos--; + break; + } + } else { + if ($c === $char) { + $this->pos--; + break; + } + } + $buffer .= $c; + } + + return $buffer; + } + + protected function readUnlessSpecialChar() + { + return $this->readUnless(array('=', '(', ')', '&', '|', '>', '<', '!')); + } + + protected function readExpressionOperator() + { + return $this->readUnless(array('=', '>', '<', '!')); + } + + protected function readChar() + { + if ($this->length > $this->pos) { + return $this->string[$this->pos++]; + } + return false; + } + + protected function nextChar() + { + if ($this->length > $this->pos) { + return $this->string[$this->pos]; + } + return false; + } +} diff --git a/library/Icinga/Data/FilterColumns.php b/library/Icinga/Data/FilterColumns.php new file mode 100644 index 0000000..7eaacea --- /dev/null +++ b/library/Icinga/Data/FilterColumns.php @@ -0,0 +1,21 @@ +<?php +/* Icinga Web 2 | (c) 2015 Icinga Development Team | GPLv2+ */ + +namespace Icinga\Data; + +interface FilterColumns +{ + /** + * Return a filterable's filter columns with their optional label as key + * + * @return array + */ + public function getFilterColumns(); + + /** + * Return a filterable's search columns + * + * @return array + */ + public function getSearchColumns(); +} diff --git a/library/Icinga/Data/Filterable.php b/library/Icinga/Data/Filterable.php new file mode 100644 index 0000000..ceca22f --- /dev/null +++ b/library/Icinga/Data/Filterable.php @@ -0,0 +1,27 @@ +<?php +/* Icinga Web 2 | (c) 2013 Icinga Development Team | GPLv2+ */ + +namespace Icinga\Data; + +use Icinga\Data\Filter\Filter; + +/** + * Interface for filtering a result set + * + * @deprecated(EL): addFilter and applyFilter do the same in all usages. + * addFilter could be replaced w/ getFilter()->add(). We must no require classes implementing this interface to + * implement redundant methods over and over again. This interface must be moved to the namespace Icinga\Data\Filter. + * It lacks documentation. + */ +interface Filterable +{ + public function applyFilter(Filter $filter); + + public function setFilter(Filter $filter); + + public function getFilter(); + + public function addFilter(Filter $filter); + + public function where($condition, $value = null); +} diff --git a/library/Icinga/Data/Identifiable.php b/library/Icinga/Data/Identifiable.php new file mode 100644 index 0000000..7435026 --- /dev/null +++ b/library/Icinga/Data/Identifiable.php @@ -0,0 +1,17 @@ +<?php +/* Icinga Web 2 | (c) 2014 Icinga Development Team | GPLv2+ */ + +namespace Icinga\Data; + +/** + * Interface for objects that are identifiable by an ID of any type + */ +interface Identifiable +{ + /** + * Get the ID associated with this Identifiable object + * + * @return mixed + */ + public function getId(); +} diff --git a/library/Icinga/Data/Inspectable.php b/library/Icinga/Data/Inspectable.php new file mode 100644 index 0000000..d40ce57 --- /dev/null +++ b/library/Icinga/Data/Inspectable.php @@ -0,0 +1,20 @@ +<?php +/* Icinga Web 2 | (c) 2015 Icinga Development Team | GPLv2+ */ + +namespace Icinga\Data; + +/** + * An object for which the user can retrieve status information + * + * This interface is useful for providing summaries or diagnostic information about objects + * to users. + */ +interface Inspectable +{ + /** + * Inspect this object to gain extended information about its health + * + * @return Inspection The inspection result + */ + public function inspect(); +} diff --git a/library/Icinga/Data/Inspection.php b/library/Icinga/Data/Inspection.php new file mode 100644 index 0000000..b0dd298 --- /dev/null +++ b/library/Icinga/Data/Inspection.php @@ -0,0 +1,129 @@ +<?php +/* Icinga Web 2 | (c) 2015 Icinga Development Team | GPLv2+ */ + +namespace Icinga\Data; + +use Icinga\Application\Logger; +use Icinga\Exception\ProgrammingError; + +/** + * Contains information about an object in the form of human-readable log entries and indicates if the object has errors + */ +class Inspection +{ + /** + * @var array + */ + protected $log = array(); + + /** + * @var string + */ + protected $description; + + /** + * @var string|Inspection + */ + protected $error; + + /** + * @param $description Describes the object that is being inspected + */ + public function __construct($description) + { + $this->description = $description; + } + + /** + * Get the name of this Inspection + * + * @return mixed + */ + public function getDescription() + { + return $this->description; + } + + /** + * Append the given log entry or nested inspection + * + * @throws ProgrammingError When called after erroring + * + * @param $entry string|Inspection A log entry or nested inspection + */ + public function write($entry) + { + if (isset($this->error)) { + throw new ProgrammingError('Inspection object used after error'); + } + if ($entry instanceof Inspection) { + $this->log[$entry->description] = $entry->toArray(); + } else { + Logger::debug($entry); + $this->log[] = $entry; + } + } + + /** + * Append the given log entry and fail this inspection with the given error + * + * @param $entry string|Inspection A log entry or nested inspection + * + * @throws ProgrammingError When called multiple times + * + * @return $this fluent interface + */ + public function error($entry) + { + if (isset($this->error)) { + throw new ProgrammingError('Inspection object used after error'); + } + Logger::error($entry); + $this->log[] = $entry; + $this->error = $entry; + return $this; + } + + /** + * If the inspection resulted in an error + * + * @return bool + */ + public function hasError() + { + return isset($this->error); + } + + /** + * The error that caused the inspection to fail + * + * @return Inspection|string + */ + public function getError() + { + return $this->error; + } + + /** + * Convert the inspection to an array + * + * @return array An array of strings that describe the state in a human-readable form, each array element + * represents one log entry about this object. + */ + public function toArray() + { + return $this->log; + } + + /** + * Return a text representation of the inspection log entries + */ + public function __toString() + { + return sprintf( + 'Inspection: description: "%s" error: "%s"', + $this->description, + $this->error + ); + } +} diff --git a/library/Icinga/Data/Limitable.php b/library/Icinga/Data/Limitable.php new file mode 100644 index 0000000..8591a79 --- /dev/null +++ b/library/Icinga/Data/Limitable.php @@ -0,0 +1,48 @@ +<?php +/* Icinga Web 2 | (c) 2014 Icinga Development Team | GPLv2+ */ + +namespace Icinga\Data; + +/** + * Interface for retrieving just a portion of a result set + */ +interface Limitable +{ + /** + * Set a limit count and offset + * + * @param int $count Number of rows to return + * @param int $offset Start returning after this many rows + * + * @return self + */ + public function limit($count = null, $offset = null); + + /** + * Whether a limit is set + * + * @return bool + */ + public function hasLimit(); + + /** + * Get the limit if any + * + * @return int|null + */ + public function getLimit(); + + /** + * Whether an offset is set + * + * @return bool + */ + public function hasOffset(); + + /** + * Get the offset if any + * + * @return int|null + */ + public function getOffset(); +} diff --git a/library/Icinga/Data/Paginatable.php b/library/Icinga/Data/Paginatable.php new file mode 100644 index 0000000..468cca2 --- /dev/null +++ b/library/Icinga/Data/Paginatable.php @@ -0,0 +1,10 @@ +<?php +/* Icinga Web 2 | (c) 2014 Icinga Development Team | GPLv2+ */ + +namespace Icinga\Data; + +use Countable; + +interface Paginatable extends Limitable, Countable +{ +} diff --git a/library/Icinga/Data/PivotTable.php b/library/Icinga/Data/PivotTable.php new file mode 100644 index 0000000..6c7f806 --- /dev/null +++ b/library/Icinga/Data/PivotTable.php @@ -0,0 +1,396 @@ +<?php +/* Icinga Web 2 | (c) 2013 Icinga Development Team | GPLv2+ */ + +namespace Icinga\Data; + +use Icinga\Data\Filter\Filter; +use Icinga\Application\Icinga; +use Icinga\Web\Paginator\Adapter\QueryAdapter; +use Zend_Paginator; + +class PivotTable implements Sortable +{ + /** + * The query to fetch as pivot table + * + * @var SimpleQuery + */ + protected $baseQuery; + + /** + * X-axis pivot column + * + * @var string + */ + protected $xAxisColumn; + + /** + * Y-axis pivot column + * + * @var string + */ + protected $yAxisColumn; + + /** + * Column for sorting the result set + * + * @var array + */ + protected $order = array(); + + /** + * The filter being applied on the query for the x-axis + * + * @var Filter + */ + protected $xAxisFilter; + + /** + * The filter being applied on the query for the y-axis + * + * @var Filter + */ + protected $yAxisFilter; + + /** + * The query to fetch the leading x-axis rows and their headers + * + * @var SimpleQuery + */ + protected $xAxisQuery; + + /** + * The query to fetch the leading y-axis rows and their headers + * + * @var SimpleQuery + */ + protected $yAxisQuery; + + /** + * X-axis header column + * + * @var string|null + */ + protected $xAxisHeader; + + /** + * Y-axis header column + * + * @var string|null + */ + protected $yAxisHeader; + + /** + * Create a new pivot table + * + * @param SimpleQuery $query The query to fetch as pivot table + * @param string $xAxisColumn X-axis pivot column + * @param string $yAxisColumn Y-axis pivot column + */ + public function __construct(SimpleQuery $query, $xAxisColumn, $yAxisColumn) + { + $this->baseQuery = $query; + $this->xAxisColumn = $xAxisColumn; + $this->yAxisColumn = $yAxisColumn; + } + + /** + * {@inheritdoc} + */ + public function getOrder() + { + return $this->order; + } + + /** + * {@inheritdoc} + */ + public function hasOrder() + { + return ! empty($this->order); + } + + /** + * {@inheritdoc} + */ + public function order($field, $direction = null) + { + $this->order[$field] = $direction; + return $this; + } + + /** + * Set the filter to apply on the query for the x-axis + * + * @param Filter $filter + * + * @return $this + */ + public function setXAxisFilter(Filter $filter = null) + { + $this->xAxisFilter = $filter; + return $this; + } + + /** + * Set the filter to apply on the query for the y-axis + * + * @param Filter $filter + * + * @return $this + */ + public function setYAxisFilter(Filter $filter = null) + { + $this->yAxisFilter = $filter; + return $this; + } + + /** + * Get the x-axis header + * + * Defaults to {@link $xAxisColumn} in case no x-axis header has been set using {@link setXAxisHeader()} + * + * @return string + */ + public function getXAxisHeader() + { + return $this->xAxisHeader !== null ? $this->xAxisHeader : $this->xAxisColumn; + } + + /** + * Set the x-axis header + * + * @param string $xAxisHeader + * + * @return $this + */ + public function setXAxisHeader($xAxisHeader) + { + $this->xAxisHeader = (string) $xAxisHeader; + return $this; + } + + /** + * Get the y-axis header + * + * Defaults to {@link $yAxisColumn} in case no x-axis header has been set using {@link setYAxisHeader()} + * + * @return string + */ + public function getYAxisHeader() + { + return $this->yAxisHeader !== null ? $this->yAxisHeader : $this->yAxisColumn; + } + + /** + * Set the y-axis header + * + * @param string $yAxisHeader + * + * @return $this + */ + public function setYAxisHeader($yAxisHeader) + { + $this->yAxisHeader = (string) $yAxisHeader; + return $this; + } + + /** + * Return the value for the given request parameter + * + * @param string $axis The axis for which to return the parameter ('x' or 'y') + * @param string $param The parameter name to return + * @param int $default The default value to return + * + * @return int + */ + protected function getPaginationParameter($axis, $param, $default = null) + { + $request = Icinga::app()->getRequest(); + + $value = $request->getParam($param, ''); + if (strpos($value, ',') > 0) { + $parts = explode(',', $value, 2); + return intval($parts[$axis === 'x' ? 0 : 1]); + } + + return $default !== null ? $default : 0; + } + + /** + * Query horizontal (x) axis + * + * @return SimpleQuery + */ + protected function queryXAxis() + { + if ($this->xAxisQuery === null) { + $this->xAxisQuery = clone $this->baseQuery; + $this->xAxisQuery->clearGroupingRules(); + $xAxisHeader = $this->getXAxisHeader(); + $columns = array($this->xAxisColumn, $xAxisHeader); + $this->xAxisQuery->group(array_unique($columns)); // xAxisColumn and header may be the same column + $this->xAxisQuery->columns($columns); + + if ($this->xAxisFilter !== null) { + $this->xAxisQuery->addFilter($this->xAxisFilter); + } + + $this->xAxisQuery->order( + $xAxisHeader, + isset($this->order[$xAxisHeader]) ? $this->order[$xAxisHeader] : self::SORT_ASC + ); + } + + return $this->xAxisQuery; + } + + /** + * Query vertical (y) axis + * + * @return SimpleQuery + */ + protected function queryYAxis() + { + if ($this->yAxisQuery === null) { + $this->yAxisQuery = clone $this->baseQuery; + $this->yAxisQuery->clearGroupingRules(); + $yAxisHeader = $this->getYAxisHeader(); + $columns = array($this->yAxisColumn, $yAxisHeader); + $this->yAxisQuery->group(array_unique($columns)); // yAxisColumn and header may be the same column + $this->yAxisQuery->columns($columns); + + if ($this->yAxisFilter !== null) { + $this->yAxisQuery->addFilter($this->yAxisFilter); + } + + $this->yAxisQuery->order( + $yAxisHeader, + isset($this->order[$yAxisHeader]) ? $this->order[$yAxisHeader] : self::SORT_ASC + ); + } + return $this->yAxisQuery; + } + + /** + * Return a pagination adapter for the x-axis query + * + * $limit and $page are taken from the current request if not given. + * + * @param int $limit The maximum amount of entries to fetch + * @param int $page The page to set as current one + * + * @return Zend_Paginator + */ + public function paginateXAxis($limit = null, $page = null) + { + if ($limit === null || $page === null) { + if ($limit === null) { + $limit = $this->getPaginationParameter('x', 'limit', 20); + } + + if ($page === null) { + $page = $this->getPaginationParameter('x', 'page', 1); + } + } + + $query = $this->queryXAxis(); + $query->limit($limit, $page > 0 ? ($page - 1) * $limit : 0); + + $paginator = new Zend_Paginator(new QueryAdapter($query)); + $paginator->setItemCountPerPage($limit); + $paginator->setCurrentPageNumber($page); + return $paginator; + } + + /** + * Return a pagination adapter for the y-axis query + * + * $limit and $page are taken from the current request if not given. + * + * @param int $limit The maximum amount of entries to fetch + * @param int $page The page to set as current one + * + * @return Zend_Paginator + */ + public function paginateYAxis($limit = null, $page = null) + { + if ($limit === null || $page === null) { + if ($limit === null) { + $limit = $this->getPaginationParameter('y', 'limit', 20); + } + + if ($page === null) { + $page = $this->getPaginationParameter('y', 'page', 1); + } + } + + $query = $this->queryYAxis(); + $query->limit($limit, $page > 0 ? ($page - 1) * $limit : 0); + + $paginator = new Zend_Paginator(new QueryAdapter($query)); + $paginator->setItemCountPerPage($limit); + $paginator->setCurrentPageNumber($page); + return $paginator; + } + + /** + * Return the pivot table as an array of pivot data and pivot header + * + * @return array + */ + public function toArray() + { + if (($this->xAxisFilter === null && $this->yAxisFilter === null) + || ($this->xAxisFilter !== null && $this->yAxisFilter !== null) + ) { + $xAxis = $this->queryXAxis()->fetchPairs(); + $yAxis = $this->queryYAxis()->fetchPairs(); + $xAxisKeys = array_keys($xAxis); + $yAxisKeys = array_keys($yAxis); + } else { + if ($this->xAxisFilter !== null) { + $xAxis = $this->queryXAxis()->fetchPairs(); + $xAxisKeys = array_keys($xAxis); + $yAxis = $this->queryYAxis()->where($this->xAxisColumn, $xAxisKeys)->fetchPairs(); + $yAxisKeys = array_keys($yAxis); + } else { // $this->yAxisFilter !== null + $yAxis = $this->queryYAxis()->fetchPairs(); + $yAxisKeys = array_keys($yAxis); + $xAxis = $this->queryXAxis()->where($this->yAxisColumn, $yAxisKeys)->fetchPairs(); + $xAxisKeys = array_keys($xAxis); + } + } + $pivotData = array(); + $pivotHeader = array( + 'cols' => $xAxis, + 'rows' => $yAxis + ); + if (! empty($xAxis) && ! empty($yAxis)) { + $this->baseQuery + ->where($this->xAxisColumn, array_map( + function ($key) { + return (string) $key; + }, + $xAxisKeys + )) + ->where($this->yAxisColumn, array_map( + function ($key) { + return (string) $key; + }, + $yAxisKeys + )); + + foreach ($yAxisKeys as $yAxisKey) { + foreach ($xAxisKeys as $xAxisKey) { + $pivotData[$yAxisKey][$xAxisKey] = null; + } + } + + foreach ($this->baseQuery as $row) { + $pivotData[$row->{$this->yAxisColumn}][$row->{$this->xAxisColumn}] = $row; + } + } + return array($pivotData, $pivotHeader); + } +} diff --git a/library/Icinga/Data/QueryInterface.php b/library/Icinga/Data/QueryInterface.php new file mode 100644 index 0000000..e723857 --- /dev/null +++ b/library/Icinga/Data/QueryInterface.php @@ -0,0 +1,8 @@ +<?php +/* Icinga Web 2 | (c) 2013 Icinga Development Team | GPLv2+ */ + +namespace Icinga\Data; + +interface QueryInterface extends Fetchable, Filterable, Paginatable, Sortable +{ +} diff --git a/library/Icinga/Data/Queryable.php b/library/Icinga/Data/Queryable.php new file mode 100644 index 0000000..75cdc98 --- /dev/null +++ b/library/Icinga/Data/Queryable.php @@ -0,0 +1,20 @@ +<?php +/* Icinga Web 2 | (c) 2014 Icinga Development Team | GPLv2+ */ + +namespace Icinga\Data; + +/** + * Interface for specifying data sources + */ +interface Queryable +{ + /** + * Set the target and fields to query + * + * @param string $target + * @param array $fields + * + * @return Fetchable + */ + public function from($target, array $fields = null); +} diff --git a/library/Icinga/Data/Reducible.php b/library/Icinga/Data/Reducible.php new file mode 100644 index 0000000..6ece17e --- /dev/null +++ b/library/Icinga/Data/Reducible.php @@ -0,0 +1,23 @@ +<?php +/* Icinga Web 2 | (c) 2015 Icinga Development Team | GPLv2+ */ + +namespace Icinga\Data; + +use Icinga\Data\Filter\Filter; +use Icinga\Exception\StatementException; + +/** + * Interface for data deletion + */ +interface Reducible +{ + /** + * Delete entries in the given target, optionally limiting the affected entries by using a filter + * + * @param string $target + * @param Filter $filter + * + * @throws StatementException + */ + public function delete($target, Filter $filter = null); +} diff --git a/library/Icinga/Data/ResourceFactory.php b/library/Icinga/Data/ResourceFactory.php new file mode 100644 index 0000000..5b477c7 --- /dev/null +++ b/library/Icinga/Data/ResourceFactory.php @@ -0,0 +1,138 @@ +<?php +/* Icinga Web 2 | (c) 2013 Icinga Development Team | GPLv2+ */ + +namespace Icinga\Data; + +use Icinga\Application\Config; +use Icinga\Util\ConfigAwareFactory; +use Icinga\Exception\ConfigurationError; +use Icinga\Data\Db\DbConnection; +use Icinga\Protocol\Ldap\LdapConnection; +use Icinga\Protocol\File\FileReader; + +/** + * Create resources from names or resource configuration + */ +class ResourceFactory implements ConfigAwareFactory +{ + /** + * Resource configuration + * + * @var Config + */ + private static $resources; + + /** + * Set resource configurations + * + * @param Config $config + */ + public static function setConfig($config) + { + self::$resources = $config; + } + + /** + * Get the configuration for a specific resource + * + * @param $resourceName String The resource's name + * + * @return ConfigObject The configuration of the resource + * + * @throws ConfigurationError + */ + public static function getResourceConfig($resourceName) + { + self::assertResourcesExist(); + $resourceConfig = self::$resources->getSection($resourceName); + if ($resourceConfig->isEmpty()) { + throw new ConfigurationError( + 'Cannot load resource config "%s". Resource does not exist', + $resourceName + ); + } + return $resourceConfig; + } + + /** + * Get the configuration of all existing resources, or all resources of the given type + * + * @param string $type Filter for resource type + * + * @return Config The resources configuration + */ + public static function getResourceConfigs($type = null) + { + self::assertResourcesExist(); + if ($type === null) { + return self::$resources; + } + $resources = array(); + foreach (self::$resources as $name => $resource) { + if ($resource->get('type') === $type) { + $resources[$name] = $resource; + } + } + return Config::fromArray($resources); + } + + /** + * Check if the existing resources are set. If not, load them from resources.ini + * + * @throws ConfigurationError + */ + private static function assertResourcesExist() + { + if (self::$resources === null) { + self::$resources = Config::app('resources'); + } + } + + /** + * Create and return a resource based on the given configuration + * + * @param ConfigObject $config The configuration of the resource to create + * + * @return Selectable The resource + * @throws ConfigurationError In case of an unsupported type or invalid configuration + */ + public static function createResource(ConfigObject $config) + { + switch (strtolower($config->type)) { + case 'db': + $resource = new DbConnection($config); + break; + case 'ldap': + if (empty($config->root_dn)) { + throw new ConfigurationError('LDAP root DN missing'); + } + + $resource = new LdapConnection($config); + break; + case 'file': + $resource = new FileReader($config); + break; + case 'ini': + $resource = Config::fromIni($config->ini); + break; + default: + throw new ConfigurationError( + 'Unsupported resource type "%s"', + $config->type + ); + } + + return $resource; + } + + /** + * Create a resource from name + * + * @param string $resourceName + * @return DbConnection|LdapConnection + */ + public static function create($resourceName) + { + return self::createResource(self::getResourceConfig($resourceName)); + } +} diff --git a/library/Icinga/Data/Selectable.php b/library/Icinga/Data/Selectable.php new file mode 100644 index 0000000..ace4e79 --- /dev/null +++ b/library/Icinga/Data/Selectable.php @@ -0,0 +1,17 @@ +<?php +/* Icinga Web 2 | (c) 2014 Icinga Development Team | GPLv2+ */ + +namespace Icinga\Data; + +/** + * Interface for classes providing a data source to fetch data from + */ +interface Selectable +{ + /** + * Provide a data source to fetch data from + * + * @return Queryable + */ + public function select(); +} diff --git a/library/Icinga/Data/SimpleQuery.php b/library/Icinga/Data/SimpleQuery.php new file mode 100644 index 0000000..1ef0c27 --- /dev/null +++ b/library/Icinga/Data/SimpleQuery.php @@ -0,0 +1,650 @@ +<?php +/* Icinga Web 2 | (c) 2013 Icinga Development Team | GPLv2+ */ + +namespace Icinga\Data; + +use Iterator; +use IteratorAggregate; +use Icinga\Application\Benchmark; +use Icinga\Data\Filter\Filter; +use Icinga\Exception\IcingaException; +use Icinga\Exception\ProgrammingError; + +class SimpleQuery implements QueryInterface, Queryable, Iterator +{ + /** + * Query data source + * + * @var mixed + */ + protected $ds; + + /** + * This query's iterator + * + * @var Iterator + */ + protected $iterator; + + /** + * The current position of this query's iterator + * + * @var int + */ + protected $iteratorPosition; + + /** + * The amount of rows previously calculated + * + * @var int + */ + protected $cachedCount; + + /** + * The target you are going to query + * + * @var mixed + */ + protected $target; + + /** + * The columns you asked for + * + * All columns if null, no column if empty??? Alias handling goes here! + * + * @var array + */ + protected $desiredColumns = array(); + + /** + * The columns you are interested in + * + * All columns if null, no column if empty??? Alias handling goes here! + * + * @var array + */ + protected $columns = array(); + + /** + * The columns and their aliases flipped in order to handle aliased sort columns + * + * Supposed to be used and populated by $this->compare *only*. + * + * @var array + */ + protected $flippedColumns; + + /** + * The columns you're using to sort the query result + * + * @var array + */ + protected $order = array(); + + /** + * Number of rows to return + * + * @var int + */ + protected $limitCount; + + /** + * Result starts with this row + * + * @var int + */ + protected $limitOffset; + + /** + * Whether to peek ahead for more results + * + * @var bool + */ + protected $peekAhead; + + /** + * Whether the query did not yield all available results + * + * @var bool + */ + protected $hasMore; + + protected $filter; + + /** + * Constructor + * + * @param mixed $ds + */ + public function __construct($ds, $columns = null) + { + $this->ds = $ds; + $this->filter = Filter::matchAll(); + if ($columns !== null) { + $this->desiredColumns = $columns; + } + $this->init(); + if ($this->desiredColumns !== null) { + $this->columns($this->desiredColumns); + } + } + + /** + * Initialize query + * + * Overwrite this instead of __construct (it's called at the end of the construct) to + * implement custom initialization logic on construction time + */ + protected function init() + { + } + + /** + * Get the data source + * + * @return mixed + */ + public function getDatasource() + { + return $this->ds; + } + + /** + * Return the current position of this query's iterator + * + * @return int + */ + public function getIteratorPosition() + { + return $this->iteratorPosition; + } + + /** + * Start or rewind the iteration + */ + public function rewind(): void + { + if ($this->iterator === null) { + $iterator = $this->ds->query($this); + if ($iterator instanceof IteratorAggregate) { + $this->iterator = $iterator->getIterator(); + } else { + $this->iterator = $iterator; + } + } + + $this->iterator->rewind(); + $this->iteratorPosition = null; + Benchmark::measure('Query result iteration started'); + } + + /** + * Fetch and return the current row of this query's result + * + * @return mixed + */ + #[\ReturnTypeWillChange] + public function current() + { + return $this->iterator->current(); + } + + /** + * Return whether the current row of this query's result is valid + * + * @return bool + */ + public function valid(): bool + { + $valid = $this->iterator->valid(); + if ($valid && $this->peekAhead && $this->hasLimit() && $this->iteratorPosition + 1 === $this->getLimit()) { + $this->hasMore = true; + $valid = false; // We arrived at the last result, which is the requested extra row, so stop the iteration + } elseif (! $valid) { + $this->hasMore = false; + } + + if (! $valid) { + Benchmark::measure('Query result iteration finished'); + return false; + } elseif ($this->iteratorPosition === null) { + $this->iteratorPosition = 0; + } + + return true; + } + + /** + * Return the key for the current row of this query's result + * + * @return mixed + */ + #[\ReturnTypeWillChange] + public function key() + { + return $this->iterator->key(); + } + + /** + * Advance to the next row of this query's result + */ + public function next(): void + { + $this->iterator->next(); + $this->iteratorPosition += 1; + } + + /** + * Choose a table and the columns you are interested in + * + * Query will return all available columns if none are given here. + * + * @param mixed $target + * @param array $fields + * + * @return $this + */ + public function from($target, array $fields = null) + { + $this->target = $target; + if ($fields !== null) { + $this->columns($fields); + } + return $this; + } + + /** + * Add a where condition to the query by and + * + * The syntax of the condition and valid values are defined by the concrete backend-specific query implementation. + * + * @param string $condition + * @param mixed $value + * + * @return $this + */ + public function where($condition, $value = null) + { + // TODO: more intelligence please + $this->filter->addFilter(Filter::expression($condition, '=', $value)); + return $this; + } + + public function getFilter() + { + return $this->filter; + } + + public function applyFilter(Filter $filter) + { + return $this->addFilter($filter); + } + + public function addFilter(Filter $filter) + { + $this->filter->addFilter($filter); + return $this; + } + + public function setFilter(Filter $filter) + { + $this->filter = $filter; + return $this; + } + + public function setOrderColumns(array $orderColumns) + { + throw new IcingaException('This function does nothing and will be removed'); + } + + /** + * Split order field into its field and sort direction + * + * @param string $field + * + * @return array + */ + public function splitOrder($field) + { + $fieldAndDirection = explode(' ', $field, 2); + if (count($fieldAndDirection) === 1) { + $direction = null; + } else { + $field = $fieldAndDirection[0]; + $direction = (strtoupper(trim($fieldAndDirection[1])) === 'DESC') ? + Sortable::SORT_DESC : Sortable::SORT_ASC; + } + return array($field, $direction); + } + + /** + * Sort result set by the given field (and direction) + * + * Preferred usage: + * <code> + * $query->order('field, 'ASC') + * </code> + * + * @param string $field + * @param string $direction + * + * @return $this + */ + public function order($field, $direction = null) + { + if ($direction === null) { + list($field, $direction) = $this->splitOrder($field); + if ($direction === null) { + $direction = Sortable::SORT_ASC; + } + } else { + switch (($direction = strtoupper($direction))) { + case Sortable::SORT_ASC: + case Sortable::SORT_DESC: + break; + default: + $direction = Sortable::SORT_ASC; + break; + } + } + $this->order[] = array($field, $direction); + return $this; + } + + /** + * Compare $a with $b based on this query's sort rules and column aliases + * + * @param object $a + * @param object $b + * @param int $orderIndex + * + * @return int + */ + public function compare($a, $b, $orderIndex = 0) + { + if (! array_key_exists($orderIndex, $this->order)) { + return 0; // Last column to sort reached, rows are considered being equal + } + + if ($this->flippedColumns === null) { + $this->flippedColumns = array_flip($this->columns); + } + + $column = $this->order[$orderIndex][0]; + if (array_key_exists($column, $this->flippedColumns) && is_string($this->flippedColumns[$column])) { + $column = $this->flippedColumns[$column]; + } + + $result = strcmp(strtolower($a->$column ?? ''), strtolower($b->$column ?? '')); + if ($result === 0) { + return $this->compare($a, $b, ++$orderIndex); + } + + $direction = $this->order[$orderIndex][1]; + if ($direction === self::SORT_ASC) { + return $result; + } else { + return $result * -1; + } + } + + /** + * Clear the order if any + * + * @return $this + */ + public function clearOrder() + { + $this->order = array(); + return $this; + } + + /** + * Whether an order is set + * + * @return bool + */ + public function hasOrder() + { + return ! empty($this->order); + } + + /** + * Get the order + * + * @return array + */ + public function getOrder() + { + return $this->order; + } + + /** + * Set whether this query should peek ahead for more results + * + * Enabling this causes the current query limit to be increased by one. The potential extra row being yielded will + * be removed from the result set. Note that this only applies when fetching multiple results of limited queries. + * + * @return $this + */ + public function peekAhead($state = true) + { + $this->peekAhead = (bool) $state; + return $this; + } + + /** + * Return whether this query did not yield all available results + * + * @return bool + * + * @throws ProgrammingError In case the query did not run yet + */ + public function hasMore() + { + if ($this->hasMore === null) { + throw new ProgrammingError('Query did not run. Cannot determine whether there are more results.'); + } + + return $this->hasMore; + } + + /** + * Return whether this query will or has yielded any result + * + * @return bool + */ + public function hasResult() + { + return $this->cachedCount > 0 || $this->iteratorPosition !== null || $this->fetchRow() !== false; + } + + /** + * Set a limit count and offset to the query + * + * @param int $count Number of rows to return + * @param int $offset Start returning after this many rows + * + * @return $this + */ + public function limit($count = null, $offset = null) + { + $this->limitCount = $count !== null ? (int) $count : null; + $this->limitOffset = (int) $offset; + return $this; + } + + /** + * Whether a limit is set + * + * @return bool + */ + public function hasLimit() + { + return $this->limitCount !== null && $this->limitCount > 0; + } + + /** + * Get the limit if any + * + * @return int|null + */ + public function getLimit() + { + return $this->peekAhead && $this->hasLimit() ? $this->limitCount + 1 : $this->limitCount; + } + + /** + * Whether an offset is set + * + * @return bool + */ + public function hasOffset() + { + return $this->limitOffset > 0; + } + + /** + * Get the offset if any + * + * @return int|null + */ + public function getOffset() + { + return $this->limitOffset; + } + + /** + * Retrieve an array containing all rows of the result set + * + * @return array + */ + public function fetchAll() + { + Benchmark::measure('Fetching all results started'); + $results = $this->ds->fetchAll($this); + Benchmark::measure('Fetching all results finished'); + + if ($this->peekAhead && $this->hasLimit() && count($results) === $this->getLimit()) { + $this->hasMore = true; + array_pop($results); + } else { + $this->hasMore = false; + } + + return $results; + } + + /** + * Fetch the first row of the result set + * + * @return mixed + */ + public function fetchRow() + { + Benchmark::measure('Fetching one row started'); + $row = $this->ds->fetchRow($this); + Benchmark::measure('Fetching one row finished'); + return $row; + } + + /** + * Fetch the first column of all rows of the result set as an array + * + * @return array + */ + public function fetchColumn() + { + Benchmark::measure('Fetching one column started'); + $values = $this->ds->fetchColumn($this); + Benchmark::measure('Fetching one column finished'); + + if ($this->peekAhead && $this->hasLimit() && count($values) === $this->getLimit()) { + $this->hasMore = true; + array_pop($values); + } else { + $this->hasMore = false; + } + + return $values; + } + + /** + * Fetch the first column of the first row of the result set + * + * @return string + */ + public function fetchOne() + { + Benchmark::measure('Fetching one value started'); + $value = $this->ds->fetchOne($this); + Benchmark::measure('Fetching one value finished'); + return $value; + } + + /** + * Fetch all rows of the result set as an array of key-value pairs + * + * The first column is the key, the second column is the value. + * + * @return array + */ + public function fetchPairs() + { + Benchmark::measure('Fetching pairs started'); + $pairs = $this->ds->fetchPairs($this); + Benchmark::measure('Fetching pairs finished'); + + if ($this->peekAhead && $this->hasLimit() && count($pairs) === $this->getLimit()) { + $this->hasMore = true; + array_pop($pairs); + } else { + $this->hasMore = false; + } + + return $pairs; + } + + /** + * Count all rows of the result set, ignoring limit and offset + * + * @return int + */ + public function count(): int + { + $query = clone $this; + $query->limit(0, 0); + Benchmark::measure('Counting all results started'); + $count = $this->ds->count($query); + $this->cachedCount = $count; + Benchmark::measure('Counting all results finished'); + return $count; + } + + /** + * Set columns + * + * @param array $columns + * + * @return $this + */ + public function columns(array $columns) + { + $this->columns = $columns; + $this->flippedColumns = null; // Reset, due to updated columns + return $this; + } + + public function getColumns() + { + return $this->columns; + } + + /** + * Deep clone self::$filter + */ + public function __clone() + { + $this->filter = clone $this->filter; + } +} diff --git a/library/Icinga/Data/SortRules.php b/library/Icinga/Data/SortRules.php new file mode 100644 index 0000000..c93bdda --- /dev/null +++ b/library/Icinga/Data/SortRules.php @@ -0,0 +1,14 @@ +<?php +/* Icinga Web 2 | (c) 2015 Icinga Development Team | GPLv2+ */ + +namespace Icinga\Data; + +interface SortRules +{ + /** + * Return some sort rules + * + * @return array + */ + public function getSortRules(); +} diff --git a/library/Icinga/Data/Sortable.php b/library/Icinga/Data/Sortable.php new file mode 100644 index 0000000..11d38c3 --- /dev/null +++ b/library/Icinga/Data/Sortable.php @@ -0,0 +1,49 @@ +<?php +/* Icinga Web 2 | (c) 2014 Icinga Development Team | GPLv2+ */ + +namespace Icinga\Data; + +/** + * Interface for sorting a result set + */ +interface Sortable +{ + /** + * Sort ascending + */ + const SORT_ASC = 'ASC'; + + /** + * Sort descending + */ + const SORT_DESC = 'DESC'; + + /** + * Sort result set by the given field (and direction) + * + * Preferred usage: + * <code> + * $query->order('field, 'ASC') + * </code> + * + * @param string $field + * @param string $direction + * + * @return self + */ + public function order($field, $direction = null); + + /** + * Whether an order is set + * + * @return bool + */ + public function hasOrder(); + + /** + * Get the order if any + * + * @return array|null + */ + public function getOrder(); +} diff --git a/library/Icinga/Data/Tree/SimpleTree.php b/library/Icinga/Data/Tree/SimpleTree.php new file mode 100644 index 0000000..e89f589 --- /dev/null +++ b/library/Icinga/Data/Tree/SimpleTree.php @@ -0,0 +1,90 @@ +<?php +/* Icinga Web 2 | (c) 2015 Icinga Development Team | GPLv2+ */ + +namespace Icinga\Data\Tree; + +use IteratorAggregate; +use LogicException; +use Traversable; + +/** + * A simple tree + */ +class SimpleTree implements IteratorAggregate +{ + /** + * Root node + * + * @var TreeNode + */ + protected $sentinel; + + /** + * Nodes + * + * @var array + */ + protected $nodes = array(); + + /** + * Create a new simple tree + */ + public function __construct() + { + $this->sentinel = new TreeNode(); + } + + /** + * Add a child node + * + * @param TreeNode $child + * @param TreeNode $parent + * + * @return $this + */ + public function addChild(TreeNode $child, TreeNode $parent = null) + { + if ($parent === null) { + $parent = $this->sentinel; + } elseif (! isset($this->nodes[$parent->getId()])) { + throw new LogicException(sprintf( + 'Can\'t append child node %s to parent node %s: Parent node does not exist', + $child->getId(), + $parent->getId() + )); + } + if (isset($this->nodes[$child->getId()])) { + throw new LogicException(sprintf( + 'Can\'t append child node %s to parent node %s: Child node does already exist', + $child->getId(), + $parent->getId() + )); + } + $this->nodes[$child->getId()] = $child; + $parent->appendChild($child); + return $this; + } + + /** + * Get a node by its ID + * + * @param mixed $id + * + * @return TreeNode|null + */ + public function getNode($id) + { + if (! isset($this->nodes[$id])) { + return null; + } + return $this->nodes[$id]; + } + + /** + * @return TreeNodeIterator + */ + public function getIterator(): Traversable + { + return new TreeNodeIterator($this->sentinel); + } +} diff --git a/library/Icinga/Data/Tree/TreeNode.php b/library/Icinga/Data/Tree/TreeNode.php new file mode 100644 index 0000000..66bce79 --- /dev/null +++ b/library/Icinga/Data/Tree/TreeNode.php @@ -0,0 +1,109 @@ +<?php +/* Icinga Web 2 | (c) 2014 Icinga Development Team | GPLv2+ */ + +namespace Icinga\Data\Tree; + +use Icinga\Data\Identifiable; + +class TreeNode implements Identifiable +{ + /** + * The node's ID + * + * @var mixed + */ + protected $id; + + /** + * The node's value + * + * @var mixed + */ + protected $value; + + /** + * The node's children + * + * @var array + */ + protected $children = array(); + + /** + * Set the node's ID + * + * @param mixed $id ID of the node + * + * @return $this + */ + public function setId($id) + { + $this->id = $id; + return $this; + } + + /** + * (non-PHPDoc) + * @see Identifiable::getId() For the method documentation. + */ + public function getId() + { + return $this->id; + } + + /** + * Set the node's value + * + * @param mixed $value + * + * @return $this + */ + public function setValue($value) + { + $this->value = $value; + return $this; + } + + /** + * Get the node's value + * + * @return mixed + */ + public function getValue() + { + return $this->value; + } + + /** + * Append a child node as the last child of this node + * + * @param TreeNode $child The child to append + * + * @return $this + */ + public function appendChild(TreeNode $child) + { + $this->children[] = $child; + return $this; + } + + + /** + * Get whether the node has children + * + * @return bool + */ + public function hasChildren() + { + return ! empty($this->children); + } + + /** + * Get the node's children + * + * @return array + */ + public function getChildren() + { + return $this->children; + } +} diff --git a/library/Icinga/Data/Tree/TreeNodeIterator.php b/library/Icinga/Data/Tree/TreeNodeIterator.php new file mode 100644 index 0000000..1c71787 --- /dev/null +++ b/library/Icinga/Data/Tree/TreeNodeIterator.php @@ -0,0 +1,75 @@ +<?php +/* Icinga Web 2 | (c) 2015 Icinga Development Team | GPLv2+ */ + +namespace Icinga\Data\Tree; + +use ArrayIterator; +use RecursiveIterator; + +/** + * Iterator over a tree node's children + */ +class TreeNodeIterator implements RecursiveIterator +{ + /** + * The node's children + * + * @var ArrayIterator + */ + protected $children; + + /** + * Create a new iterator over a tree node's children + * + * @param TreeNode $node + */ + public function __construct(TreeNode $node) + { + $this->children = new ArrayIterator($node->getChildren()); + } + + public function current(): TreeNode + { + return $this->children->current(); + } + + public function key(): int + { + return $this->children->key(); + } + + public function next(): void + { + $this->children->next(); + } + + public function rewind(): void + { + $this->children->rewind(); + } + + public function valid(): bool + { + return $this->children->valid(); + } + + public function hasChildren(): bool + { + return $this->current()->hasChildren(); + } + + public function getChildren(): TreeNodeIterator + { + return new static($this->current()); + } + + /** + * Get whether the iterator is empty + * + * @return bool + */ + public function isEmpty() + { + return ! $this->children->count(); + } +} diff --git a/library/Icinga/Data/Updatable.php b/library/Icinga/Data/Updatable.php new file mode 100644 index 0000000..ff70b99 --- /dev/null +++ b/library/Icinga/Data/Updatable.php @@ -0,0 +1,24 @@ +<?php +/* Icinga Web 2 | (c) 2015 Icinga Development Team | GPLv2+ */ + +namespace Icinga\Data; + +use Icinga\Data\Filter\Filter; +use Icinga\Exception\StatementException; + +/** + * Interface for data updating + */ +interface Updatable +{ + /** + * Update the target with the given data and optionally limit the affected entries by using a filter + * + * @param string $target + * @param array $data + * @param Filter $filter + * + * @throws StatementException + */ + public function update($target, array $data, Filter $filter = null); +} diff --git a/library/Icinga/Date/DateFormatter.php b/library/Icinga/Date/DateFormatter.php new file mode 100644 index 0000000..867462a --- /dev/null +++ b/library/Icinga/Date/DateFormatter.php @@ -0,0 +1,265 @@ +<?php +/* Icinga Web 2 | (c) 2015 Icinga Development Team | GPLv2+ */ + +namespace Icinga\Date; + +/** + * Date formatting + */ +class DateFormatter +{ + /** + * Format relative + * + * @var int + */ + const RELATIVE = 0; + + /** + * Format time + * + * @var int + */ + const TIME = 1; + + /** + * Format date + * + * @var int + */ + const DATE = 2; + + /** + * Format date and time + * + * @var int + */ + const DATETIME = 4; + + /** + * Get the diff between the given time and the current time + * + * @param int|float $time + * @param bool $requireTime + * + * @return array + */ + protected static function diff($time, $requireTime = false) + { + $invert = false; + $now = time(); + $time = (int) $time; + $diff = $time - $now; + if ($diff < 0) { + $diff = abs($diff); + $invert = true; + } + if ($diff > 3600 * 24 * 3) { + $type = static::DATE; + if (date('Y') === date('Y', $time)) { + $formatted = date($requireTime ? 'M j H:i' : 'M j', $time); + } else { + $formatted = date($requireTime ? 'Y-m-d H:i' : 'Y-m', $time); + } + } else { + $minutes = floor($diff / 60); + if ($minutes < 60) { + $type = static::RELATIVE; + $formatted = sprintf('%dm %ds', $minutes, $diff % 60); + } else { + $hours = floor($minutes / 60); + if ($hours < 24) { + if (date('d') === date('d', $time)) { + $type = static::TIME; + $formatted = date('H:i', $time); + } else { + $type = static::DATE; + $formatted = date('M j H:i', $time); + } + } else { + $type = static::RELATIVE; + $formatted = sprintf('%dd %dh', floor($hours / 24), $hours % 24); + } + } + } + return array($type, $formatted, $invert); + } + + /** + * Format date + * + * @param int|float $date + * + * @return string + */ + public static function formatDate($date) + { + return date('Y-m-d', (int) $date); + } + + /** + * Format date and time + * + * @param int|float $dateTime + * + * @return string + */ + public static function formatDateTime($dateTime) + { + return date('Y-m-d H:i:s', (int) $dateTime); + } + + /** + * Format a duration + * + * @param int|float $seconds Duration in seconds + * + * @return string + */ + public static function formatDuration($seconds) + { + $minutes = floor((float) $seconds / 60); + if ($minutes < 60) { + $formatted = sprintf('%dm %ds', $minutes, $seconds % 60); + } else { + $hours = floor($minutes / 60); + if ($hours < 24) { + $formatted = sprintf('%dh %dm', $hours, $minutes % 60); + } else { + $formatted = sprintf('%dd %dh', floor($hours / 24), $hours % 24); + } + } + return $formatted; + } + + /** + * Format time + * + * @param int|float $time + * + * @return string + */ + public static function formatTime($time) + { + return date('H:i:s', (int) $time); + } + + /** + * Format time as time ago + * + * @param int|float $time + * @param bool $timeOnly + * @param bool $requireTime + * + * @return ?string + */ + public static function timeAgo($time, $timeOnly = false, $requireTime = false) + { + list($type, $ago, $invert) = static::diff($time, $requireTime); + if ($timeOnly) { + return $ago; + } + + $formatted = null; + switch ($type) { + case static::DATE: + // Move to next case + case static::DATETIME: + $formatted = sprintf( + t('on %s', 'An event happened on the given date or date and time'), + $ago + ); + break; + case static::RELATIVE: + $formatted = sprintf( + t('%s ago', 'An event that happened the given time interval ago'), + $ago + ); + break; + case static::TIME: + $formatted = sprintf(t('at %s', 'An event happened at the given time'), $ago); + break; + } + return $formatted; + } + + /** + * Format time as time since + * + * @param int|float $time + * @param bool $timeOnly + * @param bool $requireTime + * + * @return ?string + */ + public static function timeSince($time, $timeOnly = false, $requireTime = false) + { + list($type, $since, $invert) = static::diff($time, $requireTime); + if ($timeOnly) { + return $since; + } + + $formatted = null; + switch ($type) { + case static::RELATIVE: + $formatted = sprintf( + t('for %s', 'A status is lasting for the given time interval'), + $since + ); + break; + case static::DATE: + // Move to next case + case static::DATETIME: + // Move to next case + case static::TIME: + $formatted = sprintf( + t('since %s', 'A status is lasting since the given time, date or date and time'), + $since + ); + break; + } + return $formatted; + } + + /** + * Format time as time until + * + * @param int|float $time + * @param bool $timeOnly + * @param bool $requireTime + * + * @return ?string + */ + public static function timeUntil($time, $timeOnly = false, $requireTime = false) + { + list($type, $until, $invert) = static::diff($time, $requireTime); + if ($invert && $type === static::RELATIVE) { + $until = '-' . $until; + } + if ($timeOnly) { + return $until; + } + + $formatted = null; + switch ($type) { + case static::DATE: + // Move to next case + case static::DATETIME: + $formatted = sprintf( + t('on %s', 'An event will happen on the given date or date and time'), + $until + ); + break; + case static::RELATIVE: + $formatted = sprintf( + t('in %s', 'An event will happen after the given time interval has elapsed'), + $until + ); + break; + case static::TIME: + $formatted = sprintf(t('at %s', 'An event will happen at the given time'), $until); + break; + } + return $formatted; + } +} diff --git a/library/Icinga/Exception/AlreadyExistsException.php b/library/Icinga/Exception/AlreadyExistsException.php new file mode 100644 index 0000000..d70c58f --- /dev/null +++ b/library/Icinga/Exception/AlreadyExistsException.php @@ -0,0 +1,11 @@ +<?php +/* Icinga Web 2 | (c) 2013 Icinga Development Team | GPLv2+ */ + +namespace Icinga\Exception; + +/** + * Exception thrown if something to add already exists + */ +class AlreadyExistsException extends IcingaException +{ +} diff --git a/library/Icinga/Exception/AuthenticationException.php b/library/Icinga/Exception/AuthenticationException.php new file mode 100644 index 0000000..50910b8 --- /dev/null +++ b/library/Icinga/Exception/AuthenticationException.php @@ -0,0 +1,11 @@ +<?php +/* Icinga Web 2 | (c) 2014 Icinga Development Team | GPLv2+ */ + +namespace Icinga\Exception; + +/** + * Exception thrown if an error occurs during authentication + */ +class AuthenticationException extends IcingaException +{ +} diff --git a/library/Icinga/Exception/ConfigurationError.php b/library/Icinga/Exception/ConfigurationError.php new file mode 100644 index 0000000..e66ec46 --- /dev/null +++ b/library/Icinga/Exception/ConfigurationError.php @@ -0,0 +1,12 @@ +<?php +/* Icinga Web 2 | (c) 2013 Icinga Development Team | GPLv2+ */ + +namespace Icinga\Exception; + +/** + * Class ConfigurationError + * @package Icinga\Exception + */ +class ConfigurationError extends IcingaException +{ +} diff --git a/library/Icinga/Exception/Http/BaseHttpException.php b/library/Icinga/Exception/Http/BaseHttpException.php new file mode 100644 index 0000000..cad41c6 --- /dev/null +++ b/library/Icinga/Exception/Http/BaseHttpException.php @@ -0,0 +1,73 @@ +<?php +/* Icinga Web 2 | (c) 2017 Icinga Development Team | GPLv2+ */ + +namespace Icinga\Exception\Http; + +use Icinga\Exception\IcingaException; + +/** + * Base class for HTTP exceptions + */ +class BaseHttpException extends IcingaException implements HttpExceptionInterface +{ + /** + * This exception's HTTP status code + * + * @var int + */ + protected $statusCode; + + /** + * This exception's HTTP response headers + * + * @var array + */ + protected $headers; + + /** + * Return this exception's HTTP status code + * + * @return int + */ + public function getStatusCode() + { + return $this->statusCode; + } + + /** + * Set this exception's HTTP response headers + * + * @param array $headers + * + * @return $this + */ + public function setHeaders(array $headers) + { + $this->headers = $headers; + return $this; + } + + /** + * Set/Add a HTTP response header + * + * @param string $name + * @param string $value + * + * @return $this + */ + public function setHeader($name, $value) + { + $this->headers[$name] = $value; + return $this; + } + + /** + * Return this exception's HTTP response headers + * + * @return array An array where each key is a header name and the value its value + */ + public function getHeaders() + { + return $this->headers ?: array(); + } +} diff --git a/library/Icinga/Exception/Http/HttpBadRequestException.php b/library/Icinga/Exception/Http/HttpBadRequestException.php new file mode 100644 index 0000000..004eabd --- /dev/null +++ b/library/Icinga/Exception/Http/HttpBadRequestException.php @@ -0,0 +1,12 @@ +<?php +/* Icinga Web 2 | (c) 2015 Icinga Development Team | GPLv2+ */ + +namespace Icinga\Exception\Http; + +/** + * Exception thrown for sending a HTTP 400 response w/ a custom message + */ +class HttpBadRequestException extends BaseHttpException +{ + protected $statusCode = 400; +} diff --git a/library/Icinga/Exception/Http/HttpException.php b/library/Icinga/Exception/Http/HttpException.php new file mode 100644 index 0000000..cd6b543 --- /dev/null +++ b/library/Icinga/Exception/Http/HttpException.php @@ -0,0 +1,25 @@ +<?php +/* Icinga Web 2 | (c) 2015 Icinga Development Team | GPLv2+ */ + +namespace Icinga\Exception\Http; + +class HttpException extends BaseHttpException +{ + /** + * Create a new HttpException + * + * @param int $statusCode HTTP status code + * @param string $message Exception message or exception format string + * @param mixed ...$arg Format string argument + * + * If there is at least one exception, the last one will be used for exception chaining. + */ + public function __construct($statusCode, $message) + { + $this->statusCode = (int) $statusCode; + + $args = func_get_args(); + array_shift($args); + call_user_func_array('parent::__construct', $args); + } +} diff --git a/library/Icinga/Exception/Http/HttpExceptionInterface.php b/library/Icinga/Exception/Http/HttpExceptionInterface.php new file mode 100644 index 0000000..c5e0cc7 --- /dev/null +++ b/library/Icinga/Exception/Http/HttpExceptionInterface.php @@ -0,0 +1,21 @@ +<?php +/* Icinga Web 2 | (c) 2017 Icinga Development Team | GPLv2+ */ + +namespace Icinga\Exception\Http; + +interface HttpExceptionInterface +{ + /** + * Return this exception's HTTP status code + * + * @return int + */ + public function getStatusCode(); + + /** + * Return this exception's HTTP response headers + * + * @return array An array where each key is a header name and the value its value + */ + public function getHeaders(); +} diff --git a/library/Icinga/Exception/Http/HttpMethodNotAllowedException.php b/library/Icinga/Exception/Http/HttpMethodNotAllowedException.php new file mode 100644 index 0000000..4e40b6a --- /dev/null +++ b/library/Icinga/Exception/Http/HttpMethodNotAllowedException.php @@ -0,0 +1,36 @@ +<?php +/* Icinga Web 2 | (c) 2015 Icinga Development Team | GPLv2+ */ + +namespace Icinga\Exception\Http; + +/** + * Exception thrown if the HTTP method is not allowed + */ +class HttpMethodNotAllowedException extends BaseHttpException +{ + protected $statusCode = 405; + + /** + * Get the allowed HTTP methods + * + * @return string + */ + public function getAllowedMethods() + { + $headers = $this->getHeaders(); + return isset($headers['Allow']) ? $headers['Allow'] : null; + } + + /** + * Set the allowed HTTP methods + * + * @param string $allowedMethods + * + * @return $this + */ + public function setAllowedMethods($allowedMethods) + { + $this->setHeader('Allow', (string) $allowedMethods); + return $this; + } +} diff --git a/library/Icinga/Exception/Http/HttpNotFoundException.php b/library/Icinga/Exception/Http/HttpNotFoundException.php new file mode 100644 index 0000000..eb91d63 --- /dev/null +++ b/library/Icinga/Exception/Http/HttpNotFoundException.php @@ -0,0 +1,12 @@ +<?php +/* Icinga Web 2 | (c) 2015 Icinga Development Team | GPLv2+ */ + +namespace Icinga\Exception\Http; + +/** + * Exception thrown for sending a HTTP 404 response w/ a custom message + */ +class HttpNotFoundException extends BaseHttpException +{ + protected $statusCode = 404; +} diff --git a/library/Icinga/Exception/IcingaException.php b/library/Icinga/Exception/IcingaException.php new file mode 100644 index 0000000..f3d06d1 --- /dev/null +++ b/library/Icinga/Exception/IcingaException.php @@ -0,0 +1,114 @@ +<?php +/* Icinga Web 2 | (c) 2014 Icinga Development Team | GPLv2+ */ + +namespace Icinga\Exception; + +use Exception; +use ReflectionClass; +use Throwable; + +class IcingaException extends Exception +{ + /** + * Create a new exception + * + * @param string $message Exception message or exception format string + * @param mixed ...$arg Format string argument + * + * If there is at least one exception, the last one will be used for exception chaining. + */ + public function __construct($message) + { + $args = array_slice(func_get_args(), 1); + $exc = null; + foreach ($args as &$arg) { + if ($arg instanceof Throwable) { + $exc = $arg; + } + } + + if (! empty($args)) { + $message = vsprintf($message, $args); + } + + parent::__construct($message, 0, $exc); + } + + /** + * Create the exception from an array of arguments + * + * @param array $args + * + * @return static + */ + public static function create(array $args) + { + $e = new ReflectionClass(get_called_class()); + return $e->newInstanceArgs($args); + } + + /** + * Return the given exception formatted as one-liner + * + * The format used is: %class% in %path%:%line% with message: %message% + * + * @param Throwable $exception + * + * @return string + */ + public static function describe(Throwable $exception) + { + return sprintf( + '%s in %s:%d with message: %s', + get_class($exception), + $exception->getFile(), + $exception->getLine(), + $exception->getMessage() + ); + } + + /** + * Return the same as {@link Exception::getTraceAsString()} for the given exception, + * but show only the types of scalar arguments + * + * @param Throwable $exception + * + * @return string + */ + public static function getConfidentialTraceAsString(Throwable $exception) + { + $trace = array(); + + $index = 0; + foreach ($exception->getTrace() as $index => $frame) { + $trace[] = isset($frame['file']) + ? "#{$index} {$frame['file']}({$frame['line']}): " + : "#{$index} [internal function]: "; + + if (isset($frame['class'])) { + $trace[] = $frame['class']; + } + + if (isset($frame['type'])) { + $trace[] = $frame['type']; + } + + $trace[] = "{$frame['function']}("; + + if (isset($frame['args'])) { + $args = array(); + foreach ($frame['args'] as $arg) { + $type = gettype($arg); + $args[] = $type === 'object' ? 'Object(' . get_class($arg) . ')' : ucfirst($type); + } + + $trace[] = implode(', ', $args); + } + $trace[] = ")\n"; + } + + $trace[] = '#' . ($index + 1) . ' {main}'; + + return implode($trace); + } +} diff --git a/library/Icinga/Exception/InvalidPropertyException.php b/library/Icinga/Exception/InvalidPropertyException.php new file mode 100644 index 0000000..e7bcf32 --- /dev/null +++ b/library/Icinga/Exception/InvalidPropertyException.php @@ -0,0 +1,11 @@ +<?php +/* Icinga Web 2 | (c) 2014 Icinga Development Team | GPLv2+ */ + +namespace Icinga\Exception; + +/** + * Exception thrown if a property does not exist + */ +class InvalidPropertyException extends IcingaException +{ +} diff --git a/library/Icinga/Exception/Json/JsonDecodeException.php b/library/Icinga/Exception/Json/JsonDecodeException.php new file mode 100644 index 0000000..978eb30 --- /dev/null +++ b/library/Icinga/Exception/Json/JsonDecodeException.php @@ -0,0 +1,11 @@ +<?php +/* Icinga Web 2 | (c) 2017 Icinga Development Team | GPLv2+ */ + +namespace Icinga\Exception\Json; + +/** + * Exception thrown by {@link \Icinga\Util\Json::decode()} on failure + */ +class JsonDecodeException extends JsonException +{ +} diff --git a/library/Icinga/Exception/Json/JsonEncodeException.php b/library/Icinga/Exception/Json/JsonEncodeException.php new file mode 100644 index 0000000..0bcc6c0 --- /dev/null +++ b/library/Icinga/Exception/Json/JsonEncodeException.php @@ -0,0 +1,11 @@ +<?php +/* Icinga Web 2 | (c) 2017 Icinga Development Team | GPLv2+ */ + +namespace Icinga\Exception\Json; + +/** + * Exception thrown by {@link \Icinga\Util\Json::encode()} on failure + */ +class JsonEncodeException extends JsonException +{ +} diff --git a/library/Icinga/Exception/Json/JsonException.php b/library/Icinga/Exception/Json/JsonException.php new file mode 100644 index 0000000..2ca3605 --- /dev/null +++ b/library/Icinga/Exception/Json/JsonException.php @@ -0,0 +1,13 @@ +<?php +/* Icinga Web 2 | (c) 2017 Icinga Development Team | GPLv2+ */ + +namespace Icinga\Exception\Json; + +use Icinga\Exception\IcingaException; + +/** + * Exception thrown by {@link \Icinga\Util\Json} on failure + */ +abstract class JsonException extends IcingaException +{ +} diff --git a/library/Icinga/Exception/MissingParameterException.php b/library/Icinga/Exception/MissingParameterException.php new file mode 100644 index 0000000..a8bd78d --- /dev/null +++ b/library/Icinga/Exception/MissingParameterException.php @@ -0,0 +1,40 @@ +<?php +/* Icinga Web 2 | (c) 2013 Icinga Development Team | GPLv2+ */ + +namespace Icinga\Exception; + +/** + * Exception thrown if a mandatory parameter was not given + */ +class MissingParameterException extends IcingaException +{ + /** + * Name of the missing parameter + * + * @var string + */ + protected $parameter; + + /** + * Get the name of the missing parameter + * + * @return string + */ + public function getParameter() + { + return $this->parameter; + } + + /** + * Set the name of the missing parameter + * + * @param string $name + * + * @return $this + */ + public function setParameter($name) + { + $this->parameter = (string) $name; + return $this; + } +} diff --git a/library/Icinga/Exception/NotFoundError.php b/library/Icinga/Exception/NotFoundError.php new file mode 100644 index 0000000..74e6941 --- /dev/null +++ b/library/Icinga/Exception/NotFoundError.php @@ -0,0 +1,8 @@ +<?php +/* Icinga Web 2 | (c) 2014 Icinga Development Team | GPLv2+ */ + +namespace Icinga\Exception; + +class NotFoundError extends IcingaException +{ +} diff --git a/library/Icinga/Exception/NotImplementedError.php b/library/Icinga/Exception/NotImplementedError.php new file mode 100644 index 0000000..395b4b2 --- /dev/null +++ b/library/Icinga/Exception/NotImplementedError.php @@ -0,0 +1,12 @@ +<?php +/* Icinga Web 2 | (c) 2013 Icinga Development Team | GPLv2+ */ + +namespace Icinga\Exception; + +/** + * Class NotImplementedError + * @package Icinga\Exception + */ +class NotImplementedError extends IcingaException +{ +} diff --git a/library/Icinga/Exception/NotReadableError.php b/library/Icinga/Exception/NotReadableError.php new file mode 100644 index 0000000..6bf2b3c --- /dev/null +++ b/library/Icinga/Exception/NotReadableError.php @@ -0,0 +1,8 @@ +<?php +/* Icinga Web 2 | (c) 2014 Icinga Development Team | GPLv2+ */ + +namespace Icinga\Exception; + +class NotReadableError extends IcingaException +{ +} diff --git a/library/Icinga/Exception/NotWritableError.php b/library/Icinga/Exception/NotWritableError.php new file mode 100644 index 0000000..efe1fbb --- /dev/null +++ b/library/Icinga/Exception/NotWritableError.php @@ -0,0 +1,8 @@ +<?php +/* Icinga Web 2 | (c) 2014 Icinga Development Team | GPLv2+ */ + +namespace Icinga\Exception; + +class NotWritableError extends IcingaException +{ +} diff --git a/library/Icinga/Exception/ProgrammingError.php b/library/Icinga/Exception/ProgrammingError.php new file mode 100644 index 0000000..02d4b47 --- /dev/null +++ b/library/Icinga/Exception/ProgrammingError.php @@ -0,0 +1,12 @@ +<?php +/* Icinga Web 2 | (c) 2013 Icinga Development Team | GPLv2+ */ + +namespace Icinga\Exception; + +/** + * Class ProgrammingError + * @package Icinga\Exception + */ +class ProgrammingError extends IcingaException +{ +} diff --git a/library/Icinga/Exception/QueryException.php b/library/Icinga/Exception/QueryException.php new file mode 100644 index 0000000..9344b86 --- /dev/null +++ b/library/Icinga/Exception/QueryException.php @@ -0,0 +1,11 @@ +<?php +/* Icinga Web 2 | (c) 2013 Icinga Development Team | GPLv2+ */ + +namespace Icinga\Exception; + +/** + * Exception thrown if a query encountered an error + */ +class QueryException extends IcingaException +{ +} diff --git a/library/Icinga/Exception/StatementException.php b/library/Icinga/Exception/StatementException.php new file mode 100644 index 0000000..7501c86 --- /dev/null +++ b/library/Icinga/Exception/StatementException.php @@ -0,0 +1,8 @@ +<?php +/* Icinga Web 2 | (c) 2014 Icinga Development Team | GPLv2+ */ + +namespace Icinga\Exception; + +class StatementException extends IcingaException +{ +} diff --git a/library/Icinga/Exception/SystemPermissionException.php b/library/Icinga/Exception/SystemPermissionException.php new file mode 100644 index 0000000..5651169 --- /dev/null +++ b/library/Icinga/Exception/SystemPermissionException.php @@ -0,0 +1,11 @@ +<?php +/* Icinga Web 2 | (c) 2013 Icinga Development Team | GPLv2+ */ + +namespace Icinga\Exception; + +/** + * Handle problems according to file system permissions + */ +class SystemPermissionException extends IcingaException +{ +} diff --git a/library/Icinga/File/Csv.php b/library/Icinga/File/Csv.php new file mode 100644 index 0000000..56ee233 --- /dev/null +++ b/library/Icinga/File/Csv.php @@ -0,0 +1,47 @@ +<?php +/* Icinga Web 2 | (c) 2013 Icinga Development Team | GPLv2+ */ + +namespace Icinga\File; + +use Traversable; + +class Csv +{ + protected $query; + + protected function __construct() + { + } + + public static function fromQuery(Traversable $query) + { + $csv = new static(); + $csv->query = $query; + return $csv; + } + + public function dump() + { + header('Content-type: text/csv'); + echo (string) $this; + } + + public function __toString() + { + $first = true; + $csv = ''; + foreach ($this->query as $row) { + if ($first) { + $csv .= implode(',', array_keys((array) $row)) . "\r\n"; + $first = false; + } + $out = array(); + foreach ($row as & $val) { + $out[] = '"' . str_replace('"', '""', $val) . '"'; + } + $csv .= implode(',', $out) . "\r\n"; + } + + return $csv; + } +} diff --git a/library/Icinga/File/Ini/Dom/Comment.php b/library/Icinga/File/Ini/Dom/Comment.php new file mode 100644 index 0000000..c202d0f --- /dev/null +++ b/library/Icinga/File/Ini/Dom/Comment.php @@ -0,0 +1,37 @@ +<?php +/* Icinga Web 2 | (c) 2015 Icinga Development Team | GPLv2+ */ + +namespace Icinga\File\Ini\Dom; + +/** + * A single comment-line in an INI file + */ +class Comment +{ + /** + * The comment text + * + * @var string + */ + protected $content; + + /** + * Set the text content of this comment + * + * @param $content + */ + public function setContent($content) + { + $this->content = $content; + } + + /** + * Render this comment into INI markup + * + * @return string + */ + public function render() + { + return ';' . $this->content; + } +} diff --git a/library/Icinga/File/Ini/Dom/Directive.php b/library/Icinga/File/Ini/Dom/Directive.php new file mode 100644 index 0000000..4279a5f --- /dev/null +++ b/library/Icinga/File/Ini/Dom/Directive.php @@ -0,0 +1,166 @@ +<?php +/* Icinga Web 2 | (c) 2015 Icinga Development Team | GPLv2+ */ + +namespace Icinga\File\Ini\Dom; + +use Icinga\Exception\ConfigurationError; + +/** + * A key value pair in a Section + */ +class Directive +{ + /** + * The value of this configuration directive + * + * @var string + */ + protected $key; + + /** + * The immutable name of this configuration directive + * + * @var string + */ + protected $value; + + /** + * Comments added one line before this directive + * + * @var Comment[] The comment lines + */ + protected $commentsPre = null; + + /** + * Comment added at the end of the same line + * + * @var Comment + */ + protected $commentPost = null; + + /** + * @param string $key The name of this configuration directive + * + * @throws ConfigurationError + */ + public function __construct($key) + { + $this->key = trim($key); + if (strlen($this->key) < 1) { + throw new ConfigurationError(sprintf('Ini error: empty directive key.')); + } + } + + /** + * Return the name of this directive + * + * @return string + */ + public function getKey() + { + return $this->key; + } + + /** + * Return the value of this configuration directive + * + * @return string + */ + public function getValue() + { + return $this->value; + } + + /** + * Set the value of this configuration directive + * + * @param string $value + */ + public function setValue($value) + { + $this->value = trim($value); + } + + /** + * Set the comments to be rendered on the line before this directive + * + * @param Comment[] $comments + */ + public function setCommentsPre(array $comments) + { + $this->commentsPre = $comments; + } + + /** + * Return the comments to be rendered on the line before this directive + * + * @return Comment[] + */ + public function getCommentsPre() + { + return $this->commentsPre; + } + + /** + * Set the comment rendered on the same line of this directive + * + * @param Comment $comment + */ + public function setCommentPost(Comment $comment) + { + $this->commentPost = $comment; + } + + /** + * Render this configuration directive into INI markup + * + * @return string + */ + public function render() + { + $str = ''; + if (! empty($this->commentsPre)) { + $comments = array(); + foreach ($this->commentsPre as $comment) { + $comments[] = $comment->render(); + } + $str = implode(PHP_EOL, $comments) . PHP_EOL; + } + $str .= sprintf('%s = "%s"', $this->sanitizeKey($this->key), $this->sanitizeValue($this->value)); + if (isset($this->commentPost)) { + $str .= ' ' . $this->commentPost->render(); + } + return $str; + } + + /** + * Assure that the given identifier contains no newlines and pending or trailing whitespaces + * + * @param $str The string to sanitize + * + * @return string + */ + protected function sanitizeKey($str) + { + return trim(str_replace(PHP_EOL, ' ', $str)); + } + + /** + * Escape the significant characters in directive values, normalize line breaks and assure that + * the character contains no linebreaks + * + * @param $str The string to sanitize + * + * @return mixed|string + */ + protected function sanitizeValue($str) + { + $str = trim($str); + $str = str_replace('\\', '\\\\', $str); + $str = str_replace('"', '\"', $str); + $str = str_replace("\r", '\r', $str); + $str = str_replace("\n", '\n', $str); + + return $str; + } +} diff --git a/library/Icinga/File/Ini/Dom/Document.php b/library/Icinga/File/Ini/Dom/Document.php new file mode 100644 index 0000000..f38f33e --- /dev/null +++ b/library/Icinga/File/Ini/Dom/Document.php @@ -0,0 +1,132 @@ +<?php +/* Icinga Web 2 | (c) 2015 Icinga Development Team | GPLv2+ */ + +namespace Icinga\File\Ini\Dom; + +class Document +{ + /** + * The sections of this INI file + * + * @var Section[] + */ + protected $sections = array(); + + /** + * The comemnts at file end that belong to no particular section + * + * @var Comment[] + */ + protected $commentsDangling; + + /** + * Append a section to the end of this INI file + * + * @param Section $section + */ + public function addSection(Section $section) + { + $this->sections[$section->getName()] = $section; + } + + /** + * Return whether this INI file has the section with the given key + * + * @param string $name + * + * @return bool + */ + public function hasSection($name) + { + return isset($this->sections[trim($name)]); + } + + /** + * Return the section with the given name + * + * @param string $name + * + * @return Section + */ + public function getSection($name) + { + return $this->sections[trim($name)]; + } + + /** + * Set the section with the given name + * + * @param string $name + * @param Section $section + * + * @return Section + */ + public function setSection($name, Section $section) + { + return $this->sections[trim($name)] = $section; + } + + /** + * Remove the section with the given name + * + * @param string $name + */ + public function removeSection($name) + { + unset($this->sections[trim($name)]); + } + + /** + * Set the dangling comments at file end that belong to no particular directive + * + * @param Comment[] $comments + */ + public function setCommentsDangling(array $comments) + { + $this->commentsDangling = $comments; + } + + /** + * Get the dangling comments at file end that belong to no particular directive + * + * @return array + */ + public function getCommentsDangling() + { + return $this->commentsDangling; + } + + /** + * Render this document into the corresponding INI markup + * + * @return string + */ + public function render() + { + $sections = array(); + foreach ($this->sections as $section) { + $sections []= $section->render(); + } + $str = implode(PHP_EOL, $sections); + if (! empty($this->commentsDangling)) { + foreach ($this->commentsDangling as $comment) { + $str .= PHP_EOL . $comment->render(); + } + } + return $str; + } + + /** + * Convert $this to an array + * + * @return array + */ + public function toArray() + { + $a = array(); + foreach ($this->sections as $section) { + $a[$section->getName()] = $section->toArray(); + } + return $a; + } +} diff --git a/library/Icinga/File/Ini/Dom/Section.php b/library/Icinga/File/Ini/Dom/Section.php new file mode 100644 index 0000000..5fac5ea --- /dev/null +++ b/library/Icinga/File/Ini/Dom/Section.php @@ -0,0 +1,190 @@ +<?php +/* Icinga Web 2 | (c) 2015 Icinga Development Team | GPLv2+ */ + +namespace Icinga\File\Ini\Dom; + +use Icinga\Exception\ConfigurationError; + +/** + * A section in an INI file + */ +class Section +{ + /** + * The immutable name of this section + * + * @var string + */ + protected $name; + + /** + * All configuration directives of this section + * + * @var Directive[] + */ + protected $directives = array(); + + /** + * Comments added one line before this section + * + * @var Comment[] + */ + protected $commentsPre; + + /** + * Comment added at the end of the same line + * + * @var Comment + */ + protected $commentPost; + + /** + * @param string $name The immutable name of this section + * + * @throws ConfigurationError When the section name is empty or contains brackets + */ + public function __construct($name) + { + $this->name = trim($name); + if (strlen($this->name) < 1) { + throw new ConfigurationError('Ini file error: empty section identifier'); + } elseif (strpos($name, '[') !== false || strpos($name, ']') !== false) { + throw new ConfigurationError( + 'Ini file error: Section name "%s" must not contain any brackets ([, ])', + $name + ); + } + } + + /** + * Append a directive to the end of this section + * + * @param Directive $directive The directive to append + */ + public function addDirective(Directive $directive) + { + $this->directives[$directive->getKey()] = $directive; + } + + /** + * Remove the directive with the given name + * + * @param string $key They name of the directive to remove + */ + public function removeDirective($key) + { + unset($this->directives[$key]); + } + + /** + * Return whether this section has a directive with the given key + * + * @param string $key The name of the directive + * + * @return bool + */ + public function hasDirective($key) + { + return isset($this->directives[$key]); + } + + /** + * Get the directive with the given key + * + * @param $key string + * + * @return Directive + */ + public function getDirective($key) + { + return $this->directives[$key]; + } + + /** + * Return the name of this section + * + * @return string The name + */ + public function getName() + { + return $this->name; + } + + /** + * Set the comments to be rendered on the line before this section + * + * @param Comment[] $comments + */ + public function setCommentsPre(array $comments) + { + $this->commentsPre = $comments; + } + + /** + * Set the comment rendered on the same line of this section + * + * @param Comment $comment + */ + public function setCommentPost(Comment $comment) + { + $this->commentPost = $comment; + } + + /** + * Render this section into INI markup + * + * @return string + */ + public function render() + { + $dirs = ''; + $i = 0; + foreach ($this->directives as $directive) { + $comments = $directive->getCommentsPre(); + $dirs .= (($i++ > 0 && ! empty($comments)) ? PHP_EOL : '') + . $directive->render() . PHP_EOL; + } + $cms = ''; + if (! empty($this->commentsPre)) { + foreach ($this->commentsPre as $comment) { + $comments[] = $comment->render(); + } + $cms = implode(PHP_EOL, $comments) . PHP_EOL; + } + $post = ''; + if (isset($this->commentPost)) { + $post = ' ' . $this->commentPost->render(); + } + return $cms . sprintf('[%s]', $this->sanitize($this->name)) . $post . PHP_EOL . $dirs; + } + + /** + * Escape the significant characters in sections and normalize line breaks + * + * @param $str The string to sanitize + * + * @return mixed + */ + protected function sanitize($str) + { + $str = trim($str); + $str = str_replace('\\', '\\\\', $str); + $str = str_replace('"', '\\"', $str); + $str = str_replace(';', '\\;', $str); + return str_replace(PHP_EOL, ' ', $str); + } + + /** + * Convert $this to an array + * + * @return array + */ + public function toArray() + { + $a = array(); + foreach ($this->directives as $directive) { + $a[$directive->getKey()] = $directive->getValue(); + } + return $a; + } +} diff --git a/library/Icinga/File/Ini/IniParser.php b/library/Icinga/File/Ini/IniParser.php new file mode 100644 index 0000000..279aa45 --- /dev/null +++ b/library/Icinga/File/Ini/IniParser.php @@ -0,0 +1,310 @@ +<?php +/* Icinga Web 2 | (c) 2015 Icinga Development Team | GPLv2+ */ + +namespace Icinga\File\Ini; + +use ErrorException; +use Icinga\File\Ini\Dom\Section; +use Icinga\File\Ini\Dom\Comment; +use Icinga\File\Ini\Dom\Document; +use Icinga\File\Ini\Dom\Directive; +use Icinga\Application\Logger; +use Icinga\Exception\ConfigurationError; +use Icinga\Exception\NotReadableError; +use Icinga\Application\Config; + +class IniParser +{ + const LINE_START = 0; + const SECTION = 1; + const ESCAPE = 2; + const DIRECTIVE_KEY = 4; + const DIRECTIVE_VALUE_START = 5; + const DIRECTIVE_VALUE = 6; + const DIRECTIVE_VALUE_QUOTED = 7; + const COMMENT = 8; + const COMMENT_END = 9; + const LINE_END = 10; + + /** + * Cancel the parsing with an error + * + * @param $message The error description + * @param $line The line in which the error occured + * + * @throws ConfigurationError + */ + private static function throwParseError($message, $line) + { + throw new ConfigurationError(sprintf('Ini parser error: %s. (l. %d)', $message, $line)); + } + + /** + * Read the ini file contained in a string and return a mutable DOM that can be used + * to change the content of an INI file. + * + * @param $str A string containing the whole ini file + * + * @return Document The mutable DOM object. + * @throws ConfigurationError In case the file is not parseable + */ + public static function parseIni($str) + { + $doc = new Document(); + $sec = null; + $dir = null; + $coms = array(); + $state = self::LINE_START; + $escaping = null; + $token = ''; + $line = 0; + + for ($i = 0; $i < strlen($str); $i++) { + $s = $str[$i]; + switch ($state) { + case self::LINE_START: + if (ctype_space($s)) { + continue 2; + } + switch ($s) { + case '[': + $state = self::SECTION; + break; + case ';': + $state = self::COMMENT; + break; + default: + $state = self::DIRECTIVE_KEY; + $token = $s; + break; + } + break; + + case self::ESCAPE: + $token .= $s; + $state = $escaping; + $escaping = null; + break; + + case self::SECTION: + if ($s === "\n") { + self::throwParseError('Unterminated SECTION', $line); + } elseif ($s === '\\') { + $state = self::ESCAPE; + $escaping = self::SECTION; + } elseif ($s !== ']') { + $token .= $s; + } else { + $sec = new Section($token); + $sec->setCommentsPre($coms); + $doc->addSection($sec); + $dir = null; + $coms = array(); + + $state = self::LINE_END; + $token = ''; + } + break; + + case self::DIRECTIVE_KEY: + if ($s !== '=') { + $token .= $s; + } else { + $dir = new Directive($token); + $dir->setCommentsPre($coms); + if (isset($sec)) { + $sec->addDirective($dir); + } else { + Logger::warning(sprintf( + 'Ini parser warning: section-less directive "%s" ignored. (l. %d)', + $token, + $line + )); + } + + $coms = array(); + $state = self::DIRECTIVE_VALUE_START; + $token = ''; + } + break; + + case self::DIRECTIVE_VALUE_START: + if (ctype_space($s)) { + continue 2; + } elseif ($s === '"') { + $state = self::DIRECTIVE_VALUE_QUOTED; + } else { + $state = self::DIRECTIVE_VALUE; + $token = $s; + } + break; + + case self::DIRECTIVE_VALUE: + /* + Escaping non-quoted values is not supported by php_parse_ini, it might + be reasonable to include in case we are switching completely our own + parser implementation + */ + if ($s === "\n" || $s === ";") { + $dir->setValue($token); + $token = ''; + + if ($s === "\n") { + $state = self::LINE_START; + $line ++; + } elseif ($s === ';') { + $state = self::COMMENT; + } + } else { + $token .= $s; + } + break; + + case self::DIRECTIVE_VALUE_QUOTED: + if ($s === '\\') { + $state = self::ESCAPE; + $escaping = self::DIRECTIVE_VALUE_QUOTED; + } elseif ($s !== '"') { + $token .= $s; + } else { + $dir->setValue($token); + $token = ''; + $state = self::LINE_END; + } + break; + + case self::COMMENT: + case self::COMMENT_END: + if ($s !== "\n") { + $token .= $s; + } else { + $com = new Comment(); + $com->setContent($token); + $token = ''; + + // Comments at the line end belong to the current line's directive or section. Comments + // on empty lines belong to the next directive that shows up. + if ($state === self::COMMENT_END) { + if (isset($dir)) { + $dir->setCommentPost($com); + } else { + $sec->setCommentPost($com); + } + } else { + $coms[] = $com; + } + $state = self::LINE_START; + $line ++; + } + break; + + case self::LINE_END: + if ($s === "\n") { + $state = self::LINE_START; + $line ++; + } elseif ($s === ';') { + $state = self::COMMENT_END; + } + break; + } + } + + // process the last token + switch ($state) { + case self::COMMENT: + case self::COMMENT_END: + $com = new Comment(); + $com->setContent($token); + if ($state === self::COMMENT_END) { + if (isset($dir)) { + $dir->setCommentPost($com); + } else { + $sec->setCommentPost($com); + } + } else { + $coms[] = $com; + } + break; + + case self::DIRECTIVE_VALUE: + $dir->setValue($token); + $sec->addDirective($dir); + break; + + case self::ESCAPE: + case self::DIRECTIVE_VALUE_QUOTED: + case self::DIRECTIVE_KEY: + case self::SECTION: + self::throwParseError('File ended in unterminated state ' . $state, $line); + } + if (! empty($coms)) { + $doc->setCommentsDangling($coms); + } + return $doc; + } + + /** + * Read the ini file and parse it with ::parseIni() + * + * @param string $file The ini file to read + * + * @return Config + * @throws NotReadableError When the file cannot be read + */ + public static function parseIniFile($file) + { + if (($path = realpath($file)) === false) { + throw new NotReadableError('Couldn\'t compute the absolute path of `%s\'', $file); + } + + if (($content = file_get_contents($path)) === false) { + throw new NotReadableError('Couldn\'t read the file `%s\'', $path); + } + + try { + $configArray = parse_ini_string($content, true, INI_SCANNER_RAW); + } catch (ErrorException $e) { + throw new ConfigurationError('Couldn\'t parse the INI file `%s\'', $path, $e); + } + + $unescaped = array(); + foreach ($configArray as $section => $options) { + $unescaped[self::unescapeSectionName($section)] = array_map([__CLASS__, 'unescapeOptionValue'], $options); + } + + return Config::fromArray($unescaped)->setConfigFile($file); + } + + /** + * Unescape significant characters in the given section name + * + * @param string $str + * + * @return string + */ + protected static function unescapeSectionName($str) + { + $str = str_replace('\"', '"', $str); + $str = str_replace('\;', ';', $str); + + return str_replace('\\\\', '\\', $str); + } + + /** + * Unescape significant characters in the given option value + * + * @param string $str + * + * @return string + */ + protected static function unescapeOptionValue($str) + { + $str = str_replace('\n', "\n", $str); + $str = str_replace('\r', "\r", $str); + $str = str_replace('\"', '"', $str); + $str = str_replace('\\\\', '\\', $str); + + // This replacement is a work-around for PHP bug #76965. Fixed with versions 7.1.24, 7.2.12 and 7.3.0. + return preg_replace('~^([\'"])(.*?)\1\s+$~', '$2', $str); + } +} diff --git a/library/Icinga/File/Ini/IniWriter.php b/library/Icinga/File/Ini/IniWriter.php new file mode 100644 index 0000000..1f470b0 --- /dev/null +++ b/library/Icinga/File/Ini/IniWriter.php @@ -0,0 +1,205 @@ +<?php +/* Icinga Web 2 | (c) 2013 Icinga Development Team | GPLv2+ */ + +namespace Icinga\File\Ini; + +use Icinga\Application\Logger; +use Icinga\Data\ConfigObject; +use Icinga\Exception\ProgrammingError; +use Icinga\File\Ini\Dom\Directive; +use Icinga\File\Ini\Dom\Document; +use Icinga\File\Ini\Dom\Section; +use Zend_Config_Exception; +use Icinga\Application\Config; + +/** + * A INI file adapter that respects the file structure and the comments of already existing ini files + */ +class IniWriter +{ + /** + * Stores the options + * + * @var array + */ + protected $options; + + /** + * The configuration object to write + * + * @var Config + */ + protected $config; + + /** + * The mode to set on new files + * + * @var int + */ + protected $fileMode; + + /** + * The path to write to + * + * @var string + */ + protected $filename; + + /** + * Create a new INI writer + * + * @param Config $config The configuration to write + * @param string $filename The file name to write to + * @param int $filemode Octal file persmissions + * + * @link http://framework.zend.com/apidoc/1.12/files/Config.Writer.html#\Zend_Config_Writer + */ + public function __construct(Config $config, $filename, $filemode = 0660, $options = array()) + { + $this->config = $config; + $this->filename = $filename; + $this->fileMode = $filemode; + $this->options = $options; + } + + /** + * Render the Zend_Config into a config filestring + * + * @return string + */ + public function render() + { + if (file_exists($this->filename)) { + $oldconfig = Config::fromIni($this->filename); + $content = trim(file_get_contents($this->filename)); + } else { + $oldconfig = Config::fromArray(array()); + $content = ''; + } + $doc = IniParser::parseIni($content); + $this->diffPropertyUpdates($this->config, $doc); + $this->diffPropertyDeletions($oldconfig, $this->config, $doc); + $doc = $this->updateSectionOrder($this->config, $doc); + return $doc->render(); + } + + /** + * Write configuration to file and set file mode in case it does not exist yet + * + * @param string $filename + * @param bool $exclusiveLock + * + * @throws Zend_Config_Exception + */ + public function write($filename = null, $exclusiveLock = false) + { + $filePath = isset($filename) ? $filename : $this->filename; + $setMode = false === file_exists($filePath); + + if (file_put_contents($filePath, $this->render(), $exclusiveLock ? LOCK_EX : 0) === false) { + throw new Zend_Config_Exception('Could not write to file "' . $filePath . '"'); + } + + if ($setMode) { + // file was newly created + $mode = $this->fileMode; + if (is_int($this->fileMode) && false === @chmod($filePath, $this->fileMode)) { + throw new Zend_Config_Exception(sprintf('Failed to set file mode "%o" on file "%s"', $mode, $filePath)); + } + } + } + + /** + * Update the order of the sections in the ini file to match the order of the new config + * + * @return Document A new document with the changed section order applied + */ + protected function updateSectionOrder(Config $newconfig, Document $oldDoc) + { + $doc = new Document(); + $dangling = $oldDoc->getCommentsDangling(); + if (! empty($dangling)) { + $doc->setCommentsDangling($dangling); + } + foreach ($newconfig->toArray() as $section => $directives) { + $doc->addSection($oldDoc->getSection($section)); + } + return $doc; + } + + /** + * Search for created and updated properties and use the editor to create or update these entries + * + * @param Config $newconfig The config representing the state after the change + * @param Document $doc + * + * @throws ProgrammingError + */ + protected function diffPropertyUpdates(Config $newconfig, Document $doc) + { + foreach ($newconfig->toArray() as $section => $directives) { + if (! is_array($directives)) { + Logger::warning('Section-less property ' . (string)$directives . ' was ignored.'); + continue; + } + if (!$doc->hasSection($section)) { + $domSection = new Section($section); + $doc->addSection($domSection); + } else { + $domSection = $doc->getSection($section); + } + foreach ($directives as $key => $value) { + if ($value === null) { + continue; + } + + if ($value instanceof ConfigObject) { + throw new ProgrammingError('Cannot diff recursive configs'); + } + if ($domSection->hasDirective($key)) { + $domSection->getDirective($key)->setValue($value); + } else { + $dir = new Directive($key); + $dir->setValue($value); + $domSection->addDirective($dir); + } + } + } + } + + /** + * Search for deleted properties and use the editor to delete these entries + * + * @param Config $oldconfig The config representing the state before the change + * @param Config $newconfig The config representing the state after the change + * @param Document $doc + * + * @throws ProgrammingError + */ + protected function diffPropertyDeletions(Config $oldconfig, Config $newconfig, Document $doc) + { + // Iterate over all properties in the old configuration file and remove those that don't + // exist in the new config + foreach ($oldconfig->toArray() as $section => $directives) { + if (! is_array($directives)) { + Logger::warning('Section-less property ' . (string)$directives . ' was ignored.'); + continue; + } + + if ($newconfig->hasSection($section)) { + $newSection = $newconfig->getSection($section); + $oldDomSection = $doc->getSection($section); + foreach ($directives as $key => $value) { + if ($value instanceof ConfigObject) { + throw new ProgrammingError('Cannot diff recursive configs'); + } + if (null === $newSection->get($key) && $oldDomSection->hasDirective($key)) { + $oldDomSection->removeDirective($key); + } + } + } else { + $doc->removeSection($section); + } + } + } +} diff --git a/library/Icinga/File/Pdf.php b/library/Icinga/File/Pdf.php new file mode 100644 index 0000000..1b78424 --- /dev/null +++ b/library/Icinga/File/Pdf.php @@ -0,0 +1,81 @@ +<?php +/* Icinga Web 2 | (c) 2013 Icinga Development Team | GPLv2+ */ + +namespace Icinga\File; + +use Dompdf\Dompdf; +use Dompdf\Options; +use Icinga\Application\Icinga; +use Icinga\Exception\ProgrammingError; +use Icinga\Util\Environment; +use Icinga\Web\Hook; +use Icinga\Web\Url; + +class Pdf +{ + protected function assertNoHeadersSent() + { + if (headers_sent()) { + throw new ProgrammingError( + 'Could not send pdf-response, content already written to output.' + ); + } + } + + public function renderControllerAction($controller) + { + $this->assertNoHeadersSent(); + + Environment::raiseMemoryLimit('512M'); + Environment::raiseExecutionTime(300); + + $viewRenderer = $controller->getHelper('viewRenderer'); + $viewRenderer->postDispatch(); + + $layoutHelper = $controller->getHelper('layout'); + $oldLayout = $layoutHelper->getLayout(); + $layout = $layoutHelper->setLayout('pdf'); + + $layout->content = $controller->getResponse(); + $html = $layout->render(); + + // Restore previous layout and reset content, to properly show errors + $controller->getResponse()->clearBody($viewRenderer->getResponseSegment()); + $layoutHelper->setLayout($oldLayout); + + $imgDir = Url::fromPath('img'); + $html = preg_replace( + '~src="' . $imgDir . '/~', + 'src="' . Icinga::app()->getBootstrapDirectory() . '/img/', + $html + ); + + $request = $controller->getRequest(); + + if (Hook::has('Pdfexport')) { + $pdfexport = Hook::first('Pdfexport'); + $pdfexport->streamPdfFromHtml($html, sprintf( + '%s-%s-%d', + $request->getControllerName(), + $request->getActionName(), + time() + )); + + return; + } + + $options = new Options(); + $options->set('defaultPaperSize', 'A4'); + $dompdf = new Dompdf($options); + $dompdf->loadHtml($html); + $dompdf->render(); + $dompdf->stream( + sprintf( + '%s-%s-%d', + $request->getControllerName(), + $request->getActionName(), + time() + ) + ); + } +} diff --git a/library/Icinga/File/Storage/LocalFileStorage.php b/library/Icinga/File/Storage/LocalFileStorage.php new file mode 100644 index 0000000..e1ed641 --- /dev/null +++ b/library/Icinga/File/Storage/LocalFileStorage.php @@ -0,0 +1,164 @@ +<?php +/* Icinga Web 2 | (c) 2017 Icinga Development Team | GPLv2+ */ + +namespace Icinga\File\Storage; + +use ErrorException; +use Icinga\Exception\AlreadyExistsException; +use Icinga\Exception\NotFoundError; +use Icinga\Exception\NotReadableError; +use Icinga\Exception\NotWritableError; +use InvalidArgumentException; +use RecursiveDirectoryIterator; +use RecursiveIteratorIterator; +use Traversable; +use UnexpectedValueException; + +/** + * Stores files in the local file system + */ +class LocalFileStorage implements StorageInterface +{ + /** + * The root directory of this storage + * + * @var string + */ + protected $baseDir; + + /** + * Constructor + * + * @param string $baseDir The root directory of this storage + */ + public function __construct($baseDir) + { + $this->baseDir = rtrim($baseDir, DIRECTORY_SEPARATOR); + } + + public function getIterator(): Traversable + { + try { + return new RecursiveIteratorIterator( + new RecursiveDirectoryIterator( + $this->baseDir, + RecursiveDirectoryIterator::CURRENT_AS_FILEINFO + | RecursiveDirectoryIterator::KEY_AS_PATHNAME + | RecursiveDirectoryIterator::SKIP_DOTS + ) + ); + } catch (UnexpectedValueException $e) { + throw new NotReadableError('Couldn\'t read the directory "%s": %s', $this->baseDir, $e); + } + } + + public function has($path) + { + return is_file($this->resolvePath($path)); + } + + public function create($path, $content) + { + $resolvedPath = $this->resolvePath($path); + + $this->ensureDir(dirname($resolvedPath)); + + try { + $stream = fopen($resolvedPath, 'x'); + } catch (ErrorException $e) { + throw new AlreadyExistsException('Couldn\'t create the file "%s": %s', $path, $e); + } + + try { + fclose($stream); + chmod($resolvedPath, 0664); + file_put_contents($resolvedPath, $content); + } catch (ErrorException $e) { + throw new NotWritableError('Couldn\'t create the file "%s": %s', $path, $e); + } + + return $this; + } + + public function read($path) + { + $resolvedPath = $this->resolvePath($path, true); + + try { + return file_get_contents($resolvedPath); + } catch (ErrorException $e) { + throw new NotReadableError('Couldn\'t read the file "%s": %s', $path, $e); + } + } + + public function update($path, $content) + { + $resolvedPath = $this->resolvePath($path, true); + + try { + file_put_contents($resolvedPath, $content); + } catch (ErrorException $e) { + throw new NotWritableError('Couldn\'t update the file "%s": %s', $path, $e); + } + + return $this; + } + + public function delete($path) + { + $resolvedPath = $this->resolvePath($path, true); + + try { + unlink($resolvedPath); + } catch (ErrorException $e) { + throw new NotWritableError('Couldn\'t delete the file "%s": %s', $path, $e); + } + + return $this; + } + + public function resolvePath($path, $assertExistence = false) + { + if ($assertExistence && ! $this->has($path)) { + throw new NotFoundError('No such file: "%s"', $path); + } + + $steps = preg_split('~/~', $path, -1, PREG_SPLIT_NO_EMPTY); + for ($i = 0; $i < count($steps);) { + if ($steps[$i] === '.') { + array_splice($steps, $i, 1); + } elseif ($steps[$i] === '..' && $i > 0 && $steps[$i - 1] !== '..') { + array_splice($steps, $i - 1, 2); + --$i; + } else { + ++$i; + } + } + + if ($steps[0] === '..') { + throw new InvalidArgumentException('Paths above the base directory are not allowed'); + } + + return $this->baseDir . DIRECTORY_SEPARATOR . implode(DIRECTORY_SEPARATOR, $steps); + } + + /** + * Ensure that the given directory exists + * + * @param string $dir + * + * @throws NotWritableError + */ + protected function ensureDir($dir) + { + if (! is_dir($dir)) { + $this->ensureDir(dirname($dir)); + + try { + mkdir($dir, 02770); + } catch (ErrorException $e) { + throw new NotWritableError('Couldn\'t create the directory "%s": %s', $dir, $e); + } + } + } +} diff --git a/library/Icinga/File/Storage/StorageInterface.php b/library/Icinga/File/Storage/StorageInterface.php new file mode 100644 index 0000000..f416b00 --- /dev/null +++ b/library/Icinga/File/Storage/StorageInterface.php @@ -0,0 +1,94 @@ +<?php +/* Icinga Web 2 | (c) 2017 Icinga Development Team | GPLv2+ */ + +namespace Icinga\File\Storage; + +use Icinga\Exception\AlreadyExistsException; +use Icinga\Exception\NotFoundError; +use Icinga\Exception\NotReadableError; +use Icinga\Exception\NotWritableError; +use IteratorAggregate; +use Traversable; + +interface StorageInterface extends IteratorAggregate +{ + /** + * Iterate over all existing files' paths + * + * @return Traversable + * + * @throws NotReadableError If the file list can't be read + */ + public function getIterator(): Traversable; + + /** + * Return whether the given file exists + * + * @param string $path + * + * @return bool + */ + public function has($path); + + /** + * Create the given file with the given content + * + * @param string $path + * @param mixed $content + * + * @return $this + * + * @throws AlreadyExistsException If the file already exists + * @throws NotWritableError If the file can't be written to + */ + public function create($path, $content); + + /** + * Load the content of the given file + * + * @param string $path + * + * @return mixed + * + * @throws NotFoundError If the file can't be found + * @throws NotReadableError If the file can't be read + */ + public function read($path); + + /** + * Overwrite the given file with the given content + * + * @param string $path + * @param mixed $content + * + * @return $this + * + * @throws NotFoundError If the file can't be found + * @throws NotWritableError If the file can't be written to + */ + public function update($path, $content); + + /** + * Delete the given file + * + * @param string $path + * + * @return $this + * + * @throws NotFoundError If the file can't be found + * @throws NotWritableError If the file can't be deleted + */ + public function delete($path); + + /** + * Get the absolute path to the given file + * + * @param string $path + * @param bool $assertExistence Whether to require that the given file exists + * + * @return string + * + * @throws NotFoundError If the file has to exist, but can't be found + */ + public function resolvePath($path, $assertExistence = false); +} diff --git a/library/Icinga/File/Storage/TemporaryLocalFileStorage.php b/library/Icinga/File/Storage/TemporaryLocalFileStorage.php new file mode 100644 index 0000000..faf91f5 --- /dev/null +++ b/library/Icinga/File/Storage/TemporaryLocalFileStorage.php @@ -0,0 +1,59 @@ +<?php +/* Icinga Web 2 | (c) 2017 Icinga Development Team | GPLv2+ */ + +namespace Icinga\File\Storage; + +use ErrorException; +use RecursiveDirectoryIterator; +use RecursiveIteratorIterator; + +/** + * Stores files in a temporary directory + */ +class TemporaryLocalFileStorage extends LocalFileStorage +{ + /** + * Constructor + */ + public function __construct() + { + $path = sys_get_temp_dir() . DIRECTORY_SEPARATOR . uniqid(); + mkdir($path, 0700); + + parent::__construct($path); + } + + /** + * Destructor + */ + public function __destruct() + { + // Some classes may have cleaned up the tmp file, so we need to check this + // beforehand to prevent an unexpected crash. + if (! @realpath($this->baseDir)) { + return; + } + + $directoryIterator = new RecursiveIteratorIterator( + new RecursiveDirectoryIterator( + $this->baseDir, + RecursiveDirectoryIterator::CURRENT_AS_FILEINFO + | RecursiveDirectoryIterator::KEY_AS_PATHNAME + | RecursiveDirectoryIterator::SKIP_DOTS + ), + RecursiveIteratorIterator::CHILD_FIRST + ); + + foreach ($directoryIterator as $path => $entry) { + /** @var \SplFileInfo $entry */ + + if ($entry->isDir() && ! $entry->isLink()) { + rmdir($path); + } else { + unlink($path); + } + } + + rmdir($this->baseDir); + } +} diff --git a/library/Icinga/Legacy/DashboardConfig.php b/library/Icinga/Legacy/DashboardConfig.php new file mode 100644 index 0000000..3fb5c2f --- /dev/null +++ b/library/Icinga/Legacy/DashboardConfig.php @@ -0,0 +1,137 @@ +<?php +/* Icinga Web 2 | (c) 2016 Icinga Development Team | GPLv2+ */ + +namespace Icinga\Legacy; + +use Icinga\Application\Config; +use Icinga\User; +use Icinga\Web\Navigation\DashboardPane; +use Icinga\Web\Navigation\Navigation; +use Icinga\Web\Navigation\NavigationItem; + +/** + * Legacy dashboard config class for case insensitive interpretation of dashboard config files + * + * Before 2.2, the username part in dashboard config files was not lowered. + * + * @deprecated(el): Remove. TBD. + */ +class DashboardConfig extends Config +{ + /** + * User + * + * @var User + */ + protected $user; + + /** + * Get the user + * + * @return User + */ + public function getUser() + { + return $this->user; + } + + /** + * Set the user + * + * @param User $user + * + * @return $this + */ + public function setUser($user) + { + $this->user = $user; + return $this; + } + + + /** + * List all dashboard configuration files that match the given user + * + * @param User $user + * + * @return string[] + */ + public static function listConfigFilesForUser(User $user) + { + $files = array(); + $dashboards = static::resolvePath('dashboards'); + if ($handle = @opendir($dashboards)) { + while (false !== ($entry = readdir($handle))) { + if ($entry[0] === '.' || ! is_dir($dashboards . '/' . $entry)) { + continue; + } + if (strtolower($entry) === strtolower($user->getUsername())) { + $files[] = $dashboards . '/' . $entry . '/dashboard.ini'; + } + } + closedir($handle); + } + return $files; + } + + /** + * {@inheritdoc} + */ + public function saveIni($filePath = null, $fileMode = 0660) + { + // Preprocessing start, ensures that the non-translated names are used to save module dashboard changes + // TODO: This MUST NOT survive the new dashboard implementation (yes, it's still a thing..) + $dashboardNavigation = new Navigation(); + $dashboardNavigation->load('dashboard-pane'); + $getDashboardPane = function ($label) use ($dashboardNavigation) { + foreach ($dashboardNavigation as $dashboardPane) { + /** @var DashboardPane $dashboardPane */ + if ($dashboardPane->getLabel() === $label) { + return $dashboardPane; + } + + foreach ($dashboardPane->getChildren() as $dashlet) { + /** @var NavigationItem $dashlet */ + if ($dashlet->getLabel() === $label) { + return $dashlet; + } + } + } + }; + + foreach (clone $this->config as $name => $options) { + if (strpos($name, '.') !== false) { + list($dashboardLabel, $dashletLabel) = explode('.', $name, 2); + } else { + $dashboardLabel = $name; + $dashletLabel = null; + } + + $dashboardPane = $getDashboardPane($dashboardLabel); + if ($dashboardPane !== null) { + $dashboardLabel = $dashboardPane->getName(); + } + + if ($dashletLabel !== null) { + $dashletItem = $getDashboardPane($dashletLabel); + if ($dashletItem !== null) { + $dashletLabel = $dashletItem->getName(); + } + } + + unset($this->config[$name]); + $this->config[$dashboardLabel . ($dashletLabel ? '.' . $dashletLabel : '')] = $options; + } + // Preprocessing end + + parent::saveIni($filePath, $fileMode); + if ($filePath === null) { + $filePath = $this->configFile; + } + foreach (static::listConfigFilesForUser($this->user) as $file) { + if ($file !== $filePath) { + @unlink($file); + } + } + } +} diff --git a/library/Icinga/Less/Call.php b/library/Icinga/Less/Call.php new file mode 100644 index 0000000..0a78cb5 --- /dev/null +++ b/library/Icinga/Less/Call.php @@ -0,0 +1,77 @@ +<?php + +/* Icinga Web 2 | (c) 2022 Icinga GmbH | GPLv2+ */ + +namespace Icinga\Less; + +use Less_Tree_Call; +use Less_Tree_Color; +use Less_Tree_Value; +use Less_Tree_Variable; + +class Call extends Less_Tree_Call +{ + public static function fromCall(Less_Tree_Call $call) + { + return new static($call->name, $call->args, $call->index, $call->currentFileInfo); + } + + public function compile($env = null) + { + if (! $env) { + // Not sure how to trigger this, but if there is no $env, there is nothing we can do + return parent::compile($env); + } + + foreach ($this->args as $arg) { + if (! is_array($arg->value)) { + continue; + } + + $name = null; + if ($arg->value[0] instanceof Less_Tree_Variable) { + // This is the case when defining a variable with a callable LESS rules such as fade, fadeout.. + // Example: `@foo: #fff; @foo-bar: fade(@foo, 10);` + $name = $arg->value[0]->name; + } elseif ($arg->value[0] instanceof ColorPropOrVariable) { + // This is the case when defining a CSS rule using the LESS functions and passing + // a variable as an argument to them. Example: `... { color: fade(@foo, 10%); }` + $name = $arg->value[0]->getVariable()->name; + } + + if ($name) { + foreach ($env->frames as $frame) { + if (($v = $frame->variable($name))) { + // Variables from the frame stack are always of type LESS Tree Rule + $vr = $v->value; + if ($vr instanceof Less_Tree_Value) { + // Get the actual color prop, otherwise this may cause an invalid argument error + $vr = $vr->compile($env); + } + + if ($vr instanceof DeferredColorProp) { + if (! $vr->hasReference()) { + // Should never happen, though just for safety's sake + $vr->compile($env); + } + + // Get the uppermost variable of the variable references + while (! $vr instanceof ColorProp) { + $vr = $vr->getRef(); + } + } elseif ($vr instanceof Less_Tree_Color) { + $vr = ColorProp::fromColor($vr); + $vr->setName($name); + } + + $arg->value[0] = $vr; + + break; + } + } + } + } + + return parent::compile($env); + } +} diff --git a/library/Icinga/Less/ColorProp.php b/library/Icinga/Less/ColorProp.php new file mode 100644 index 0000000..3f83c5e --- /dev/null +++ b/library/Icinga/Less/ColorProp.php @@ -0,0 +1,109 @@ +<?php +/* Icinga Web 2 | (c) 2022 Icinga GmbH | GPLv2+ */ + +namespace Icinga\Less; + +use Less_Tree_Call; +use Less_Tree_Color; +use Less_Tree_Keyword; + +/** + * ColorProp renders Less colors as CSS var() function calls + * + * It extends {@link Less_Tree_Color} so that Less functions that take a Less_Tree_Color as an argument do not fail. + */ +class ColorProp extends Less_Tree_Color +{ + /** @var Less_Tree_Color Color with which we created the ColorProp */ + protected $color; + + /** @var int */ + protected $index; + + /** @var string Color variable name */ + protected $name; + + public function __construct() + { + } + + /** + * @param Less_Tree_Color $color + * + * @return static + */ + public static function fromColor(Less_Tree_Color $color) + { + $self = new static(); + $self->color = $color; + + foreach ($color as $k => $v) { + if ($k === 'name') { + $self->setName($v); // Removes the @ char from the name + } else { + $self->$k = $v; + } + } + + return $self; + } + + /** + * @return int + */ + public function getIndex() + { + return $this->index; + } + + /** + * @param int $index + * + * @return $this + */ + public function setIndex($index) + { + $this->index = $index; + + return $this; + } + + /** + * @return string + */ + public function getName() + { + return $this->name; + } + + /** + * @param string $name + * + * @return $this + */ + public function setName($name) + { + if ($name[0] === '@') { + $name = substr($name, 1); + } + + $this->name = $name; + + return $this; + } + + public function genCSS($output) + { + $css = (new Less_Tree_Call( + 'var', + [ + new Less_Tree_Keyword('--' . $this->getName()), + // Use the Less_Tree_Color with which we created the ColorProp so that we don't get into genCSS() loops. + $this->color + ], + $this->getIndex() + ))->toCSS(); + + $output->add($css); + } +} diff --git a/library/Icinga/Less/ColorPropOrVariable.php b/library/Icinga/Less/ColorPropOrVariable.php new file mode 100644 index 0000000..7918674 --- /dev/null +++ b/library/Icinga/Less/ColorPropOrVariable.php @@ -0,0 +1,71 @@ +<?php +/* Icinga Web 2 | (c) 2022 Icinga GmbH | GPLv2+ */ + +namespace Icinga\Less; + +use Less_Tree; +use Less_Tree_Color; +use Less_Tree_Variable; + +/** + * Compile a Less variable to {@link ColorProp} if it is a color + */ +class ColorPropOrVariable extends Less_Tree +{ + public $type = 'Variable'; + + /** @var Less_Tree_Variable */ + protected $variable; + + /** + * @return Less_Tree_Variable + */ + public function getVariable() + { + return $this->variable; + } + + /** + * @param Less_Tree_Variable $variable + * + * @return $this + */ + public function setVariable(Less_Tree_Variable $variable) + { + $this->variable = $variable; + + return $this; + } + + public function compile($env) + { + $v = $this->getVariable(); + + if ($v->name[1] === '@') { + // Evaluate variable variable as in Less_Tree_Variable:28. + $vv = new Less_Tree_Variable(substr($v->name, 1), $v->index + 1, $v->currentFileInfo); + // Overwrite the name so that the variable variable is not evaluated again. + $result = $vv->compile($env); + if ($result instanceof DeferredColorProp) { + $v->name = $result->name; + } else { + $v->name = '@' . $result->value; + } + } + + $compiled = $v->compile($env); + + if ($compiled instanceof ColorProp) { + // We may already have a ColorProp, which is the case with mixin calls. + return $compiled; + } + + if ($compiled instanceof Less_Tree_Color) { + return ColorProp::fromColor($compiled) + ->setIndex($v->index) + ->setName($v->name); + } + + return $compiled; + } +} diff --git a/library/Icinga/Less/DeferredColorProp.php b/library/Icinga/Less/DeferredColorProp.php new file mode 100644 index 0000000..c9c39ad --- /dev/null +++ b/library/Icinga/Less/DeferredColorProp.php @@ -0,0 +1,136 @@ +<?php + +/* Icinga Web 2 | (c) 2022 Icinga GmbH | GPLv2+ */ + +namespace Icinga\Less; + +use Less_Exception_Compiler; +use Less_Tree_Call; +use Less_Tree_Color; +use Less_Tree_Keyword; +use Less_Tree_Value; +use Less_Tree_Variable; + +class DeferredColorProp extends Less_Tree_Variable +{ + /** @var DeferredColorProp|ColorProp */ + protected $reference; + + protected $resolved = false; + + public function __construct($name, $variable, $index = null, $currentFileInfo = null) + { + parent::__construct($name, $index, $currentFileInfo); + + if ($variable instanceof Less_Tree_Variable) { + $this->reference = self::fromVariable($variable); + } + } + + public function isResolved() + { + return $this->resolved; + } + + public function getName() + { + $name = $this->name; + if ($this->name[0] === '@') { + $name = substr($this->name, 1); + } + + return $name; + } + + public function hasReference() + { + return $this->reference !== null; + } + + public function getRef() + { + return $this->reference; + } + + public function setReference($ref) + { + $this->reference = $ref; + + return $this; + } + + public static function fromVariable(Less_Tree_Variable $variable) + { + $static = new static($variable->name, $variable->index, $variable->currentFileInfo); + $static->evaluating = $variable->evaluating; + $static->type = $variable->type; + + return $static; + } + + public function compile($env) + { + if (! $this->hasReference()) { + // This is never supposed to happen, however, we might have a deferred color prop + // without a reference. In this case we can simply use the parent method. + return parent::compile($env); + } + + if ($this->isResolved()) { + // The dependencies are already resolved, no need to traverse the frame stack over again! + return $this; + } + + if ($this->evaluating) { // Just like the parent method + throw new Less_Exception_Compiler( + "Recursive variable definition for " . $this->name, + null, + $this->index, + $this->currentFileInfo + ); + } + + $this->evaluating = true; + + foreach ($env->frames as $frame) { + if (($v = $frame->variable($this->getRef()->name))) { + $rv = $v->value; + if ($rv instanceof Less_Tree_Value) { + $rv = $rv->compile($env); + } + + // As we are at it anyway, let's cast the tree color to our color prop as well! + if ($rv instanceof Less_Tree_Color) { + $rv = ColorProp::fromColor($rv); + $rv->setName($this->getRef()->getName()); + } + + $this->evaluating = false; + $this->resolved = true; + $this->setReference($rv); + + break; + } + } + + return $this; + } + + public function genCSS($output) + { + if (! $this->hasReference()) { + return; // Nothing to generate + } + + $css = (new Less_Tree_Call( + 'var', + [ + new Less_Tree_Keyword('--' . $this->getName()), + $this->getRef() // Each of the references will be generated recursively + ], + $this->index + ))->toCSS(); + + $output->add($css); + } +} diff --git a/library/Icinga/Less/LightMode.php b/library/Icinga/Less/LightMode.php new file mode 100644 index 0000000..b4b72a0 --- /dev/null +++ b/library/Icinga/Less/LightMode.php @@ -0,0 +1,128 @@ +<?php +/* Icinga Web 2 | (c) 2022 Icinga GmbH | GPLv2+ */ + +namespace Icinga\Less; + +use ArrayIterator; +use InvalidArgumentException; +use IteratorAggregate; +use Less_Environment; +use Traversable; + +/** + * Registry for light modes and the environments in which they are defined + */ +class LightMode implements IteratorAggregate +{ + /** @var array Mode environments as mode-environment pairs */ + protected $envs = []; + + /** @var array Assoc list of modes */ + protected $modes = []; + + /** @var array Mode selectors as mode-selector pairs */ + protected $selectors = []; + + /** + * @param string $mode + * + * @return $this + * + * @throws InvalidArgumentException If the mode already exists + */ + public function add($mode) + { + if (array_key_exists($mode, $this->modes)) { + throw new InvalidArgumentException("$mode already exists"); + } + + $this->modes[$mode] = true; + + return $this; + } + + /** + * @param string $mode + * + * @return Less_Environment + * + * @throws InvalidArgumentException If there is no environment for the given mode + */ + public function getEnv($mode) + { + if (! isset($this->envs[$mode])) { + throw new InvalidArgumentException("$mode does not exist"); + } + + return $this->envs[$mode]; + } + + /** + * @param string $mode + * @param Less_Environment $env + * + * @return $this + * + * @throws InvalidArgumentException If an environment for given the mode already exists + */ + public function setEnv($mode, Less_Environment $env) + { + if (array_key_exists($mode, $this->envs)) { + throw new InvalidArgumentException("$mode already exists"); + } + + $this->envs[$mode] = $env; + + return $this; + } + + /** + * @param string $mode + * + * @return bool + */ + public function hasSelector($mode) + { + return isset($this->selectors[$mode]); + } + + /** + * @param string $mode + * + * @return string + * + * @throws InvalidArgumentException If there is no selector for the given mode + */ + public function getSelector($mode) + { + if (! isset($this->selectors[$mode])) { + throw new InvalidArgumentException("$mode does not exist"); + } + + return $this->selectors[$mode]; + } + + /** + * @param string $mode + * @param string $selector + * + * @return $this + * + * @throws InvalidArgumentException If a selector for given the mode already exists + */ + public function setSelector($mode, $selector) + { + if (array_key_exists($mode, $this->selectors)) { + throw new InvalidArgumentException("$mode already exists"); + } + + $this->selectors[$mode] = $selector; + + return $this; + } + + public function getIterator(): Traversable + { + return new ArrayIterator(array_keys($this->modes)); + } +} diff --git a/library/Icinga/Less/LightModeCall.php b/library/Icinga/Less/LightModeCall.php new file mode 100644 index 0000000..d899e3c --- /dev/null +++ b/library/Icinga/Less/LightModeCall.php @@ -0,0 +1,38 @@ +<?php +/* Icinga Web 2 | (c) 2022 Icinga GmbH | GPLv2+ */ + +namespace Icinga\Less; + +use Less_Environment; +use Less_Tree_Ruleset; +use Less_Tree_RulesetCall; + +/** + * Use the environment where the light mode was defined to evaluate the call + */ +class LightModeCall extends Less_Tree_RulesetCall +{ + use LightModeTrait; + + /** + * @param Less_Tree_RulesetCall $c + * + * @return static + */ + public static function fromRulesetCall(Less_Tree_RulesetCall $c) + { + return new static($c->variable); + } + + /** + * @param Less_Environment $env + * + * @return Less_Tree_Ruleset + */ + public function compile($env) + { + return parent::compile( + $env->copyEvalEnv(array_merge($env->frames, $this->getLightMode()->getEnv($this->variable)->frames)) + ); + } +} diff --git a/library/Icinga/Less/LightModeDefinition.php b/library/Icinga/Less/LightModeDefinition.php new file mode 100644 index 0000000..929e95c --- /dev/null +++ b/library/Icinga/Less/LightModeDefinition.php @@ -0,0 +1,75 @@ +<?php +/* Icinga Web 2 | (c) 2022 Icinga GmbH | GPLv2+ */ + +namespace Icinga\Less; + +use Less_Environment; +use Less_Exception_Compiler; +use Less_Tree_DetachedRuleset; +use Less_Tree_Ruleset; + +/** + * Register the environment in which the light mode is defined + */ +class LightModeDefinition extends Less_Tree_DetachedRuleset +{ + use LightModeTrait; + + /** @var string */ + protected $name; + + /** + * @param Less_Tree_DetachedRuleset $drs + * + * @return static + */ + public static function fromDetachedRuleset(Less_Tree_DetachedRuleset $drs) + { + return new static($drs->ruleset, $drs->frames); + } + + /** + * @return string + */ + public function getName() + { + return $this->name; + } + + /** + * @param string $name + * + * @return $this + */ + public function setName($name) + { + $this->name = $name; + + return $this; + } + + /** + * @param Less_Environment $env + * + * @return Less_Tree_DetachedRuleset + */ + public function compile($env) + { + $drs = parent::compile($env); + + /** @var $frame Less_Tree_Ruleset */ + foreach ($env->frames as $frame) { + if ($frame->variable($this->getName())) { + if (! empty($frame->first_oelements) && ! isset($frame->first_oelements['.icinga-module'])) { + throw new Less_Exception_Compiler('Light mode definition not allowed in selectors'); + } + + break; + } + } + + $this->getLightMode()->setEnv($this->getName(), $env->copyEvalEnv($env->frames)); + + return $drs; + } +} diff --git a/library/Icinga/Less/LightModeTrait.php b/library/Icinga/Less/LightModeTrait.php new file mode 100644 index 0000000..d328265 --- /dev/null +++ b/library/Icinga/Less/LightModeTrait.php @@ -0,0 +1,30 @@ +<?php +/* Icinga Web 2 | (c) 2022 Icinga GmbH | GPLv2+ */ + +namespace Icinga\Less; + +trait LightModeTrait +{ + /** @var LightMode */ + private $lightMode; + + /** + * @return LightMode + */ + public function getLightMode() + { + return $this->lightMode; + } + + /** + * @param LightMode $lightMode + * + * @return $this + */ + public function setLightMode(LightMode $lightMode) + { + $this->lightMode = $lightMode; + + return $this; + } +} diff --git a/library/Icinga/Less/LightModeVisitor.php b/library/Icinga/Less/LightModeVisitor.php new file mode 100644 index 0000000..35758b4 --- /dev/null +++ b/library/Icinga/Less/LightModeVisitor.php @@ -0,0 +1,26 @@ +<?php +/* Icinga Web 2 | (c) 2022 Icinga GmbH | GPLv2+ */ + +namespace Icinga\Less; + +use Less_VisitorReplacing; + +/** + * Ensure that light mode calls have access to the environment in which the mode was defined + */ +class LightModeVisitor extends Less_VisitorReplacing +{ + use LightModeTrait; + + public $isPreVisitor = true; + + public function visitRulesetCall($c) + { + return LightModeCall::fromRulesetCall($c)->setLightMode($this->getLightMode()); + } + + public function run($node) + { + return $this->visitObj($node); + } +} diff --git a/library/Icinga/Less/Visitor.php b/library/Icinga/Less/Visitor.php new file mode 100644 index 0000000..c04a0eb --- /dev/null +++ b/library/Icinga/Less/Visitor.php @@ -0,0 +1,233 @@ +<?php +/* Icinga Web 2 | (c) 2022 Icinga GmbH | GPLv2+ */ + +namespace Icinga\Less; + +use Less_Parser; +use Less_Tree_Expression; +use Less_Tree_Rule; +use Less_Tree_Value; +use Less_Tree_Variable; +use Less_VisitorReplacing; +use LogicException; +use ReflectionProperty; + +/** + * Replace compiled Less colors with CSS var() function calls and inject light mode calls + * + * Color replacing basically works by replacing every visited Less variable with {@link ColorPropOrVariable}, + * which is later compiled to {@link ColorProp} if it is a color. + * + * Light mode calls are generated from light mode definitions. + */ +class Visitor extends Less_VisitorReplacing +{ + const LIGHT_MODE_CSS = <<<'CSS' +@media (min-height: @prefer-light-color-scheme), print, +(prefers-color-scheme: light) and (min-height: @enable-color-preference) { + %s +} +CSS; + + const LIGHT_MODE_NAME = 'light-mode'; + + public $isPreEvalVisitor = true; + + /** + * Whether calling var() CSS function + * + * If that's the case, don't try to replace compiled Less colors with CSS var() function calls. + * + * @var bool|string + */ + protected $callingVar = false; + + /** + * Whether defining a variable + * + * If that's the case, don't try to replace compiled Less colors with CSS var() function calls. + * + * @var false|string + */ + protected $definingVariable = false; + + /** @var Less_Tree_Rule If defining a variable, determines the origin rule of the variable */ + protected $variableOrigin; + + /** @var LightMode Light mode registry */ + protected $lightMode; + + /** @var false|string Whether parsing module Less */ + protected $moduleScope = false; + + /** @var null|string CSS module selector if any */ + protected $moduleSelector; + + public function visitCall($c) + { + if ($c->name !== 'var') { + // We need to use our own tree call class , so that we can precompile the arguments before making + // the actual LESS function calls. Otherwise, it will produce lots of invalid argument exceptions! + $c = Call::fromCall($c); + } + + return $c; + } + + public function visitDetachedRuleset($drs) + { + if ($this->variableOrigin->name === '@' . static::LIGHT_MODE_NAME) { + $this->variableOrigin->name .= '-' . substr(sha1(uniqid(mt_rand(), true)), 0, 7); + + $this->lightMode->add($this->variableOrigin->name); + + if ($this->moduleSelector !== false) { + $this->lightMode->setSelector($this->variableOrigin->name, $this->moduleSelector); + } + + $drs = LightModeDefinition::fromDetachedRuleset($drs) + ->setLightMode($this->lightMode) + ->setName($this->variableOrigin->name); + } + + // Since a detached ruleset is a variable definition in the first place, + // just reset that we define a variable. + $this->definingVariable = false; + + return $drs; + } + + public function visitMixinCall($c) + { + // Less_Tree_Mixin_Call::accept() does not visit arguments, but we have to replace them if necessary. + foreach ($c->arguments as $a) { + $a['value'] = $this->visitObj($a['value']); + } + + return $c; + } + + public function visitMixinDefinition($m) + { + // Less_Tree_Mixin_Definition::accept() does not visit params, but we have to replace them if necessary. + foreach ($m->params as $p) { + if (! isset($p['value'])) { + continue; + } + + $p['value'] = $this->visitObj($p['value']); + } + + return $m; + } + + public function visitRule($r) + { + if ($r->name[0] === '@' && $r->variable) { + if ($this->definingVariable !== false) { + throw new LogicException('Already defining a variable'); + } + + $this->definingVariable = spl_object_hash($r); + $this->variableOrigin = $r; + + if ($r->value instanceof Less_Tree_Value) { + if ($r->value->value[0] instanceof Less_Tree_Expression) { + if ($r->value->value[0]->value[0] instanceof Less_Tree_Variable) { + // Transform the variable definition rule into our own class + $r->value->value[0]->value[0] = new DeferredColorProp($r->name, $r->value->value[0]->value[0]); + } + } + } + } + + return $r; + } + + public function visitRuleOut($r) + { + if ($this->definingVariable !== false && $this->definingVariable === spl_object_hash($r)) { + $this->definingVariable = false; + $this->variableOrigin = null; + } + } + + public function visitRuleset($rs) + { + // Method is required, otherwise visitRulesetOut will not be called. + return $rs; + } + + public function visitRulesetOut($rs) + { + if ($this->moduleScope !== false + && isset($rs->selectors) + && spl_object_hash($rs->selectors[0]) === $this->moduleScope + ) { + $this->moduleSelector = null; + $this->moduleScope = false; + } + } + + public function visitSelector($s) + { + if ($s->_oelements_len === 2 && $s->_oelements[0] === '.icinga-module') { + $this->moduleSelector = implode('', $s->_oelements); + $this->moduleScope = spl_object_hash($s); + } + + return $s; + } + + public function visitVariable($v) + { + if ($this->definingVariable !== false) { + return $v; + } + + return (new ColorPropOrVariable()) + ->setVariable($v); + } + + public function run($node) + { + $this->lightMode = new LightMode(); + + $evald = $this->visitObj($node); + + // The visitor has registered all light modes in visitDetachedRuleset, but has not called them yet. + // Now the light mode calls are prepared with the appropriate CSS selectors. + $calls = []; + foreach ($this->lightMode as $mode) { + if ($this->lightMode->hasSelector($mode)) { + $calls[] = "{$this->lightMode->getSelector($mode)} {\n$mode();\n}"; + } else { + $calls[] = "$mode();"; + } + } + + if (! empty($calls)) { + // Place and parse light mode calls into a new anonymous file, + // leaving the original Less in which the light modes were defined untouched. + $parser = (new Less_Parser()) + ->parse(sprintf(static::LIGHT_MODE_CSS, implode("\n", $calls))); + + // Because Less variables are block scoped, + // we can't just access the light mode definitions in the calls above. + // The LightModeVisitor ensures that all calls have access to the environment in which the mode was defined. + // Finally, the rules are merged so that the light mode calls are also rendered to CSS. + $rules = new ReflectionProperty(get_class($parser), 'rules'); + $rules->setAccessible(true); + $evald->rules = array_merge( + $evald->rules, + (new LightModeVisitor()) + ->setLightMode($this->lightMode) + ->visitArray($rules->getValue($parser)) + ); + // The LightModeVisitor is used explicitly here instead of using it as a plugin + // since we only need to process the newly created rules for the light mode calls. + } + + return $evald; + } +} diff --git a/library/Icinga/Model/Schema.php b/library/Icinga/Model/Schema.php new file mode 100644 index 0000000..465cce0 --- /dev/null +++ b/library/Icinga/Model/Schema.php @@ -0,0 +1,49 @@ +<?php + +/* Icinga Web 2 | (c) 2023 Icinga GmbH | GPLv2+ */ + +namespace Icinga\Model; + +use DateTime; +use ipl\Orm\Behavior\BoolCast; +use ipl\Orm\Behavior\MillisecondTimestamp; +use ipl\Orm\Behaviors; +use ipl\Orm\Model; + +/** + * A database model for Icinga Web schema version table + * + * @property int $id Unique identifier of the database schema entries + * @property string $version The current schema version of Icinga Web + * @property DateTime $timestamp The insert/modify time of the schema entry + * @property bool $success Whether the database migration of the current version was successful + * @property ?string $reason The reason why the database migration has failed + */ +class Schema extends Model +{ + public function getTableName(): string + { + return 'icingaweb_schema'; + } + + public function getKeyName() + { + return 'id'; + } + + public function getColumns(): array + { + return [ + 'version', + 'timestamp', + 'success', + 'reason' + ]; + } + + public function createBehaviors(Behaviors $behaviors): void + { + $behaviors->add(new BoolCast(['success'])); + $behaviors->add(new MillisecondTimestamp(['timestamp'])); + } +} diff --git a/library/Icinga/Protocol/Dns.php b/library/Icinga/Protocol/Dns.php new file mode 100644 index 0000000..3d422d7 --- /dev/null +++ b/library/Icinga/Protocol/Dns.php @@ -0,0 +1,89 @@ +<?php +/* Icinga Web 2 | (c) 2014 Icinga Development Team | GPLv2+ */ + +namespace Icinga\Protocol; + +/** + * Discover dns records using regular or reverse lookup + */ +class Dns +{ + /** + * Discover all service records on a given domain + * + * @param string $domain The domain to search + * @param string $service The type of the service, like for example 'ldaps' or 'ldap' + * @param string $protocol The transport protocol used by the service, defaults to 'tcp' + * + * @return array An array of all found service records + */ + public static function getSrvRecords($domain, $service, $protocol = 'tcp') + { + $records = dns_get_record('_' . $service . '._' . $protocol . '.' . $domain, DNS_SRV); + return $records === false ? array() : $records; + } + + /** + * Get all ldap records for the given domain + * + * @param string $query The domain to query + * @param int $type The type of DNS-entry to fetch, see + * http://www.php.net/manual/de/function.dns-get-record.php for available types + * + * @return array|null An array of record entries + */ + public static function records($query, $type = DNS_ANY) + { + return dns_get_record($query, $type); + } + + /** + * Reverse lookup all host names available on the given ip address + * + * @param string $ipAddress + * @param int $type + * + * @return array|null + */ + public static function ptr($ipAddress, $type = DNS_ANY) + { + $host = gethostbyaddr($ipAddress); + if ($host === false || $host === $ipAddress) { + // malformed input or no host found + return null; + } + return self::records($host, $type); + } + + /** + * Get the IPv4 address of the given hostname. + * + * @param $hostname The hostname to resolve + * + * @return string|null The IPv4 address of the given hostname or null, when no entry exists. + */ + public static function ipv4($hostname) + { + $records = dns_get_record($hostname, DNS_A); + if ($records !== false && count($records) > 0) { + return $records[0]['ip']; + } + return null; + } + + /** + * Get the IPv6 address of the given hostname. + * + * @param $hostname The hostname to resolve + * + * @return string|null The IPv6 address of the given hostname or null, when no entry exists. + */ + public static function ipv6($hostname) + { + $records = dns_get_record($hostname, DNS_AAAA); + if ($records !== false && count($records) > 0) { + return $records[0]['ip']; + } + return null; + } +} diff --git a/library/Icinga/Protocol/File/Exception/FileReaderException.php b/library/Icinga/Protocol/File/Exception/FileReaderException.php new file mode 100644 index 0000000..237352c --- /dev/null +++ b/library/Icinga/Protocol/File/Exception/FileReaderException.php @@ -0,0 +1,12 @@ +<?php +/* Icinga Web 2 | (c) 2014 Icinga Development Team | GPLv2+ */ +namespace Icinga\Protocol\File; + +use Icinga\Exception\IcingaException; + +/** + * Exception thrown if a file reader specific error occurs + */ +class FileReaderException extends IcingaException +{ +} diff --git a/library/Icinga/Protocol/File/FileIterator.php b/library/Icinga/Protocol/File/FileIterator.php new file mode 100644 index 0000000..64b6600 --- /dev/null +++ b/library/Icinga/Protocol/File/FileIterator.php @@ -0,0 +1,81 @@ +<?php +/* Icinga Web 2 | (c) 2014 Icinga Development Team | GPLv2+ */ + +namespace Icinga\Protocol\File; + +use Icinga\Util\EnumeratingFilterIterator; +use Icinga\Util\File; + +/** + * Class FileIterator + * + * Iterate over a file, yielding only fields of non-empty lines which match a PCRE expression + */ +class FileIterator extends EnumeratingFilterIterator +{ + /** + * A PCRE string with the fields to extract from the file's lines as named subpatterns + * + * @var string + */ + protected $fields; + + /** + * An associative array of the current line's fields ($field => $value) + * + * @var array + */ + protected $currentData; + + public function __construct($filename, $fields) + { + $this->fields = $fields; + $f = new File($filename); + $f->setFlags( + File::DROP_NEW_LINE | + File::READ_AHEAD | + File::SKIP_EMPTY + ); + parent::__construct($f); + } + + /** + * Return the current data + * + * @return array + */ + public function current(): array + { + return $this->currentData; + } + + /** + * Accept lines matching the given PCRE pattern + * + * @return bool + * + * @throws FileReaderException If PHP failed parsing the PCRE pattern + */ + public function accept(): bool + { + $data = array(); + $matched = preg_match( + $this->fields, + $this->getInnerIterator()->current(), + $data + ); + + if ($matched === false) { + throw new FileReaderException('Failed parsing regular expression!'); + } elseif ($matched === 1) { + foreach ($data as $key => $value) { + if (is_int($key)) { + unset($data[$key]); + } + } + $this->currentData = $data; + return true; + } + return false; + } +} diff --git a/library/Icinga/Protocol/File/FileQuery.php b/library/Icinga/Protocol/File/FileQuery.php new file mode 100644 index 0000000..504de2e --- /dev/null +++ b/library/Icinga/Protocol/File/FileQuery.php @@ -0,0 +1,86 @@ +<?php +/* Icinga Web 2 | (c) 2014 Icinga Development Team | GPLv2+ */ + +namespace Icinga\Protocol\File; + +use Icinga\Data\SimpleQuery; +use Icinga\Data\Filter\Filter; + +/** + * Class FileQuery + * + * Query for Datasource Icinga\Protocol\File\FileReader + * + * @package Icinga\Protocol\File + */ +class FileQuery extends SimpleQuery +{ + /** + * Sort direction + * + * @var int + */ + private $sortDir; + + /** + * Filters to apply on result + * + * @var array + */ + private $filters = array(); + + /** + * Nothing to do here + */ + public function applyFilter(Filter $filter) + { + } + + /** + * Sort query result chronological + * + * @param string $dir Sort direction, 'ASC' or 'DESC' (default) + * + * @return FileQuery + */ + public function order($field, $direction = null) + { + $this->sortDir = ( + $direction === null || strtoupper(trim($direction)) === 'DESC' + ) ? self::SORT_DESC : self::SORT_ASC; + return $this; + } + + /** + * Return true if sorting descending, false otherwise + * + * @return bool + */ + public function sortDesc() + { + return $this->sortDir === self::SORT_DESC; + } + + /** + * Add an mandatory filter expression to be applied on this query + * + * @param string $expression the filter expression to be applied + * + * @return FileQuery + */ + public function andWhere($expression) + { + $this->filters[] = $expression; + return $this; + } + + /** + * Get filters currently applied on this query + * + * @return array + */ + public function getFilters() + { + return $this->filters; + } +} diff --git a/library/Icinga/Protocol/File/FileReader.php b/library/Icinga/Protocol/File/FileReader.php new file mode 100644 index 0000000..a06494c --- /dev/null +++ b/library/Icinga/Protocol/File/FileReader.php @@ -0,0 +1,208 @@ +<?php +/* Icinga Web 2 | (c) 2014 Icinga Development Team | GPLv2+ */ + +namespace Icinga\Protocol\File; + +use Countable; +use ArrayIterator; +use Icinga\Data\Selectable; +use Icinga\Data\ConfigObject; + +/** + * Read file line by line + */ +class FileReader implements Selectable, Countable +{ + /** + * A PCRE string with the fields to extract from the file's lines as named subpatterns + * + * @var string + */ + protected $fields; + + /** + * Name of the target file + * + * @var string + */ + protected $filename; + + /** + * Cache for static::count() + * + * @var int + */ + protected $count = null; + + /** + * Create a new reader + * + * @param ConfigObject $config + * + * @throws FileReaderException If a required $config directive (filename or fields) is missing + */ + public function __construct(ConfigObject $config) + { + foreach (array('filename', 'fields') as $key) { + if (isset($config->{$key})) { + $this->{$key} = $config->{$key}; + } else { + throw new FileReaderException('The directive `%s\' is required', $key); + } + } + } + + /** + * Instantiate a FileIterator object with the target file + * + * @return FileIterator + */ + public function iterate() + { + return new LogFileIterator($this->filename, $this->fields); + } + + /** + * Instantiate a FileQuery object + * + * @return FileQuery + */ + public function select() + { + return new FileQuery($this); + } + + /** + * Fetch and return all rows of the given query's result set using an iterator + * + * @param FileQuery $query + * + * @return ArrayIterator + */ + public function query(FileQuery $query) + { + return new ArrayIterator($this->fetchAll($query)); + } + + /** + * Return the number of available valid lines. + * + * @return int + */ + public function count(): int + { + if ($this->count === null) { + $this->count = iterator_count($this->iterate()); + } + return $this->count; + } + + /** + * Fetch result as an array of objects + * + * @param FileQuery $query + * + * @return array + */ + public function fetchAll(FileQuery $query) + { + $all = array(); + foreach ($this->fetchPairs($query) as $index => $value) { + $all[$index] = (object) $value; + } + return $all; + } + + /** + * Fetch result as a key/value pair array + * + * @param FileQuery $query + * + * @return array + */ + public function fetchPairs(FileQuery $query) + { + $skip = $query->getOffset(); + $read = $query->getLimit(); + if ($skip === null) { + $skip = 0; + } + $lines = array(); + if ($query->sortDesc()) { + $count = $this->count(); + if ($count <= $skip) { + return $lines; + } elseif ($count < ($skip + $read)) { + $read = $count - $skip; + $skip = 0; + } else { + $skip = $count - ($skip + $read); + } + } + foreach ($this->iterate() as $index => $line) { + if ($index >= $skip) { + if ($index >= $skip + $read) { + break; + } + $lines[] = $line; + } + } + if ($query->sortDesc()) { + $lines = array_reverse($lines); + } + return $lines; + } + + /** + * Fetch first result row + * + * @param FileQuery $query + * + * @return object + */ + public function fetchRow(FileQuery $query) + { + $all = $this->fetchAll($query); + if (isset($all[0])) { + return $all[0]; + } + return null; + } + + /** + * Fetch first result column + * + * @param FileQuery $query + * + * @return array + */ + public function fetchColumn(FileQuery $query) + { + $column = array(); + foreach ($this->fetchPairs($query) as $pair) { + foreach ($pair as $value) { + $column[] = $value; + break; + } + } + return $column; + } + + /** + * Fetch first column value from first result row + * + * @param FileQuery $query + * + * @return mixed + */ + public function fetchOne(FileQuery $query) + { + $pairs = $this->fetchPairs($query); + if (isset($pairs[0])) { + foreach ($pairs[0] as $value) { + return $value; + } + } + return null; + } +} diff --git a/library/Icinga/Protocol/File/LogFileIterator.php b/library/Icinga/Protocol/File/LogFileIterator.php new file mode 100644 index 0000000..67a4d99 --- /dev/null +++ b/library/Icinga/Protocol/File/LogFileIterator.php @@ -0,0 +1,149 @@ +<?php +/* Icinga Web 2 | (c) 2015 Icinga Development Team | GPLv2+ */ + +namespace Icinga\Protocol\File; + +use Icinga\Exception\IcingaException; +use SplFileObject; +use Iterator; + +/** + * Iterate over a log file, yielding the regex fields of the log messages + */ +class LogFileIterator implements Iterator +{ + /** + * Log file + * + * @var SplFileObject + */ + protected $file; + + /** + * A PCRE string with the fields to extract + * from the log messages as named subpatterns + * + * @var string + */ + protected $fields; + + /** + * Value for static::current() + * + * @var array + */ + protected $current; + + /** + * Index for static::key() + * + * @var int + */ + protected $index; + + /** + * Value for static::valid() + * + * @var boolean + */ + protected $valid; + + /** + * @var string + */ + protected $next = null; + + /** + * @param string $filename The log file's name + * @param string $fields A PCRE string with the fields to extract + * from the log messages as named subpatterns + */ + public function __construct($filename, $fields) + { + $this->file = new SplFileObject($filename); + $this->file->setFlags( + SplFileObject::DROP_NEW_LINE | + SplFileObject::READ_AHEAD + ); + $this->fields = $fields; + } + + public function rewind(): void + { + $this->file->rewind(); + $this->index = 0; + $this->nextMessage(); + } + + public function next(): void + { + $this->file->next(); + ++$this->index; + $this->nextMessage(); + } + + public function current(): array + { + return $this->current; + } + + public function key(): int + { + return $this->index; + } + + public function valid(): bool + { + return $this->valid; + } + + protected function nextMessage() + { + $message = $this->next === null ? array() : array($this->next); + $this->valid = null; + while ($this->file->valid()) { + if (false === ($res = preg_match( + $this->fields, + $current = $this->file->current() + ))) { + throw new IcingaException('Failed at preg_match()'); + } + if (empty($message)) { + if ($res === 1) { + $message[] = $current; + } + } elseif ($res === 1) { + $this->next = $current; + $this->valid = true; + break; + } else { + $message[] = $current; + } + + $this->file->next(); + } + if ($this->valid === null) { + $this->next = null; + $this->valid = ! empty($message); + } + + if ($this->valid) { + while (! empty($message)) { + $matches = array(); + if (false === ($res = preg_match( + $this->fields, + implode(PHP_EOL, $message), + $matches + ))) { + throw new IcingaException('Failed at preg_match()'); + } + if ($res === 1) { + $this->current = $matches; + return; + } + array_pop($message); + } + $this->valid = false; + } + } +} diff --git a/library/Icinga/Protocol/Ldap/Discovery.php b/library/Icinga/Protocol/Ldap/Discovery.php new file mode 100644 index 0000000..9c7990a --- /dev/null +++ b/library/Icinga/Protocol/Ldap/Discovery.php @@ -0,0 +1,143 @@ +<?php +/* Icinga Web 2 | (c) 2014 Icinga Development Team | GPLv2+ */ + +namespace Icinga\Protocol\Ldap; + +use Icinga\Data\ConfigObject; +use Icinga\Protocol\Dns; + +class Discovery +{ + /** + * @var LdapConnection + */ + private $connection; + + /** + * @param LdapConnection $conn The ldap connection to use for the discovery + */ + public function __construct(LdapConnection $conn) + { + $this->connection = $conn; + } + + /** + * Suggests a resource configuration of hostname, port and root_dn + * based on the discovery + * + * @return array The suggested configuration as an array + */ + public function suggestResourceSettings() + { + return array( + 'hostname' => $this->connection->getHostname(), + 'port' => $this->connection->getPort(), + 'root_dn' => $this->connection->getCapabilities()->getDefaultNamingContext() + ); + } + + /** + * Suggests a backend configuration of base_dn, user_class and user_name_attribute + * based on the discovery + * + * @return array The suggested configuration as an array + */ + public function suggestBackendSettings() + { + if ($this->isAd()) { + return array( + 'backend' => 'msldap', + 'base_dn' => $this->connection->getCapabilities()->getDefaultNamingContext(), + 'user_class' => 'user', + 'user_name_attribute' => 'sAMAccountName' + ); + } else { + return array( + 'backend' => 'ldap', + 'base_dn' => $this->connection->getCapabilities()->getDefaultNamingContext(), + 'user_class' => 'inetOrgPerson', + 'user_name_attribute' => 'uid' + ); + } + } + + /** + * Whether the suggested ldap server is an ActiveDirectory + * + * @return boolean + */ + public function isAd() + { + return $this->connection->getCapabilities()->isActiveDirectory(); + } + + /** + * Whether the discovery was successful + * + * @return bool False when the suggestions are guessed + */ + public function isSuccess() + { + return $this->connection->discoverySuccessful(); + } + + /** + * Why the discovery failed + * + * @return \Exception|null + */ + public function getError() + { + return $this->connection->getDiscoveryError(); + } + + /** + * Discover LDAP servers on the given domain + * + * @param ?string $domain The object containing the form elements + * + * @return Discovery True when the discovery was successful, false when the configuration was guessed + */ + public static function discoverDomain($domain) + { + if (! isset($domain)) { + return false; + } + + // Attempt 1: Connect to the domain directly + $disc = Discovery::discover($domain, 389); + if ($disc->isSuccess()) { + return $disc; + } + + // Attempt 2: Discover all available ldap dns records and connect to the first one + $records = array_merge(Dns::getSrvRecords($domain, 'ldap'), Dns::getSrvRecords($domain, 'ldaps')); + if (isset($records[0])) { + $record = $records[0]; + return Discovery::discover( + isset($record['target']) ? $record['target'] : $domain, + isset($record['port']) ? $record['port'] : $domain + ); + } + + // Return the first failed discovery, which will suggest properties based on guesses + return $disc; + } + + /** + * Convenience method to instantiate a new Discovery + * + * @param $host The host on which to execute the discovery + * @param $port The port on which to execute the discovery + * + * @return Discovery The resulting Discovery + */ + public static function discover($host, $port) + { + $conn = new LdapConnection(new ConfigObject(array( + 'hostname' => $host, + 'port' => $port + ))); + return new Discovery($conn); + } +} diff --git a/library/Icinga/Protocol/Ldap/LdapCapabilities.php b/library/Icinga/Protocol/Ldap/LdapCapabilities.php new file mode 100644 index 0000000..721655a --- /dev/null +++ b/library/Icinga/Protocol/Ldap/LdapCapabilities.php @@ -0,0 +1,440 @@ +<?php +/* Icinga Web 2 | (c) 2015 Icinga Development Team | GPLv2+ */ + +namespace Icinga\Protocol\Ldap; + +use Icinga\Application\Logger; +use stdClass; + +/** + * The properties and capabilities of an LDAP server + * + * Provides information about the available encryption mechanisms (StartTLS), the supported + * LDAP protocol (v2/v3), vendor-specific extensions or protocols controls and extensions. + */ +class LdapCapabilities +{ + const LDAP_SERVER_START_TLS_OID = '1.3.6.1.4.1.1466.20037'; + + const LDAP_PAGED_RESULT_OID_STRING = '1.2.840.113556.1.4.319'; + + const LDAP_SERVER_SHOW_DELETED_OID = '1.2.840.113556.1.4.417'; + + const LDAP_SERVER_SORT_OID = '1.2.840.113556.1.4.473'; + + const LDAP_SERVER_CROSSDOM_MOVE_TARGET_OID = '1.2.840.113556.1.4.521'; + + const LDAP_SERVER_NOTIFICATION_OID = '1.2.840.113556.1.4.528'; + + const LDAP_SERVER_EXTENDED_DN_OID = '1.2.840.113556.1.4.529'; + + const LDAP_SERVER_LAZY_COMMIT_OID = '1.2.840.113556.1.4.619'; + + const LDAP_SERVER_SD_FLAGS_OID = '1.2.840.113556.1.4.801'; + + const LDAP_SERVER_TREE_DELETE_OID = '1.2.840.113556.1.4.805'; + + const LDAP_SERVER_DIRSYNC_OID = '1.2.840.113556.1.4.841'; + + const LDAP_SERVER_VERIFY_NAME_OID = '1.2.840.113556.1.4.1338'; + + const LDAP_SERVER_DOMAIN_SCOPE_OID = '1.2.840.113556.1.4.1339'; + + const LDAP_SERVER_SEARCH_OPTIONS_OID = '1.2.840.113556.1.4.1340'; + + const LDAP_SERVER_PERMISSIVE_MODIFY_OID = '1.2.840.113556.1.4.1413'; + + const LDAP_SERVER_ASQ_OID = '1.2.840.113556.1.4.1504'; + + const LDAP_SERVER_FAST_BIND_OID = '1.2.840.113556.1.4.1781'; + + const LDAP_CONTROL_VLVREQUEST = '2.16.840.1.113730.3.4.9'; + + + // MS Capabilities, Source: http://msdn.microsoft.com/en-us/library/cc223359.aspx + + // Running Active Directory as AD DS + const LDAP_CAP_ACTIVE_DIRECTORY_OID = '1.2.840.113556.1.4.800'; + + // Capable of signing and sealing on an NTLM authenticated connection + // and of performing subsequent binds on a signed or sealed connection + const LDAP_CAP_ACTIVE_DIRECTORY_LDAP_INTEG_OID = '1.2.840.113556.1.4.1791'; + + // If AD DS: running at least W2K3, if AD LDS running at least W2K8 + const LDAP_CAP_ACTIVE_DIRECTORY_V51_OID = '1.2.840.113556.1.4.1670'; + + // If AD LDS: accepts DIGEST-MD5 binds for AD LDSsecurity principals + const LDAP_CAP_ACTIVE_DIRECTORY_ADAM_DIGEST = '1.2.840.113556.1.4.1880'; + + // Running Active Directory as AD LDS + const LDAP_CAP_ACTIVE_DIRECTORY_ADAM_OID = '1.2.840.113556.1.4.1851'; + + // If AD DS: it's a Read Only DC (RODC) + const LDAP_CAP_ACTIVE_DIRECTORY_PARTIAL_SECRETS_OID = '1.2.840.113556.1.4.1920'; + + // Running at least W2K8 + const LDAP_CAP_ACTIVE_DIRECTORY_V60_OID = '1.2.840.113556.1.4.1935'; + + // Running at least W2K8r2 + const LDAP_CAP_ACTIVE_DIRECTORY_V61_R2_OID = '1.2.840.113556.1.4.2080'; + + // Running at least W2K12 + const LDAP_CAP_ACTIVE_DIRECTORY_W8_OID = '1.2.840.113556.1.4.2237'; + + /** + * Attributes of the LDAP Server returned by the discovery query + * + * @var stdClass + */ + private $attributes; + + /** + * Map of supported available OIDS + * + * @var array + */ + private $oids; + + /** + * Construct a new capability + * + * @param $attributes stdClass The attributes returned, may be null for guessing default capabilities + */ + public function __construct($attributes = null) + { + $this->setAttributes($attributes); + } + + /** + * Set the attributes and (re)build the OIDs + * + * @param $attributes stdClass The attributes returned, may be null for guessing default capabilities + */ + protected function setAttributes($attributes) + { + $this->attributes = $attributes; + $this->oids = array(); + + $keys = array('supportedControl', 'supportedExtension', 'supportedFeatures', 'supportedCapabilities'); + foreach ($keys as $key) { + if (isset($attributes->$key)) { + if (is_array($attributes->$key)) { + foreach ($attributes->$key as $oid) { + $this->oids[$oid] = true; + } + } else { + $this->oids[$attributes->$key] = true; + } + } + } + } + + /** + * Return if the capability object contains support for StartTLS + * + * @return bool Whether StartTLS is supported + */ + public function hasStartTls() + { + return isset($this->oids[self::LDAP_SERVER_START_TLS_OID]); + } + + /** + * Return if the capability object contains support for paged results + * + * @return bool Whether StartTLS is supported + */ + public function hasPagedResult() + { + return isset($this->oids[self::LDAP_PAGED_RESULT_OID_STRING]); + } + + /** + * Whether the ldap server is an ActiveDirectory server + * + * @return boolean + */ + public function isActiveDirectory() + { + return isset($this->oids[self::LDAP_CAP_ACTIVE_DIRECTORY_OID]); + } + + /** + * Whether the ldap server is an OpenLDAP server + * + * @return bool + */ + public function isOpenLdap() + { + return isset($this->attributes->structuralObjectClass) && + $this->attributes->structuralObjectClass === 'OpenLDAProotDSE'; + } + + /** + * Return if the capability objects contains support for LdapV3, defaults to true if discovery failed + * + * @return bool + */ + public function hasLdapV3() + { + if (! isset($this->attributes) || ! isset($this->attributes->supportedLDAPVersion)) { + // Default to true, if unknown + return true; + } + + return (is_string($this->attributes->supportedLDAPVersion) + && (int) $this->attributes->supportedLDAPVersion === 3) + || (is_array($this->attributes->supportedLDAPVersion) + && in_array(3, $this->attributes->supportedLDAPVersion)); + } + + /** + * Whether the capability with the given OID is supported + * + * @param $oid string The OID of the capability + * + * @return bool + */ + public function hasOid($oid) + { + return isset($this->oids[$oid]); + } + + /** + * Get the default naming context + * + * @return string|null the default naming context, or null when no contexts are available + */ + public function getDefaultNamingContext() + { + // defaultNamingContext entry has higher priority + if (isset($this->attributes->defaultNamingContext)) { + return $this->attributes->defaultNamingContext; + } + + // if its missing use namingContext + $namingContexts = $this->namingContexts(); + return empty($namingContexts) ? null : $namingContexts[0]; + } + + /** + * Get the configuration naming context + * + * @return string|null + */ + public function getConfigurationNamingContext() + { + if (isset($this->attributes->configurationNamingContext)) { + return $this->attributes->configurationNamingContext; + } + } + + /** + * Get the NetBIOS name + * + * @return string|null + */ + public function getNetBiosName() + { + if (isset($this->attributes->nETBIOSName)) { + return $this->attributes->nETBIOSName; + } + } + + /** + * Fetch the namingContexts + * + * @return array the available naming contexts + */ + public function namingContexts() + { + if (!isset($this->attributes->namingContexts)) { + return array(); + } + if (!is_array($this->attributes->namingContexts)) { + return array($this->attributes->namingContexts); + } + return$this->attributes->namingContexts; + } + + public function getVendor() + { + /* + rfc #3045 specifies that the name of the server MAY be included in the attribute 'verndorName', + AD and OpenLDAP don't do this, but for all all other vendors we follow the standard and + just hope for the best. + */ + + if ($this->isActiveDirectory()) { + return 'Microsoft Active Directory'; + } + + if ($this->isOpenLdap()) { + return 'OpenLDAP'; + } + + if (! isset($this->attributes->vendorName)) { + return null; + } + return $this->attributes->vendorName; + } + + public function getVersion() + { + /* + rfc #3045 specifies that the version of the server MAY be included in the attribute 'vendorVersion', + but AD and OpenLDAP don't do this. For OpenLDAP there is no way to query the server versions, but for all + all other vendors we follow the standard and just hope for the best. + */ + + if ($this->isActiveDirectory()) { + return $this->getAdObjectVersionName(); + } + + if (! isset($this->attributes->vendorVersion)) { + return null; + } + return $this->attributes->vendorVersion; + } + + /** + * Discover the capabilities of the given LDAP server + * + * @param LdapConnection $connection The ldap connection to use + * + * @return LdapCapabilities + * + * @throws LdapException In case the capability query has failed + */ + public static function discoverCapabilities(LdapConnection $connection) + { + $ds = $connection->getConnection(); + + $fields = array( + 'configurationNamingContext', + 'defaultNamingContext', + 'namingContexts', + 'vendorName', + 'vendorVersion', + 'supportedSaslMechanisms', + 'dnsHostName', + 'schemaNamingContext', + 'supportedLDAPVersion', // => array(3, 2) + 'supportedCapabilities', + 'supportedControl', + 'supportedExtension', + 'objectVersion', + '+' + ); + + $result = @ldap_read($ds, '', (string) $connection->select()->from('*', $fields), $fields); + if (! $result) { + throw new LdapException( + 'Capability query failed (%s; Default port: %d): %s. Check if hostname and port' + . ' of the ldap resource are correct and if anonymous access is permitted.', + $connection->getHostname(), + $connection->getPort(), + ldap_error($ds) + ); + } + + $entry = ldap_first_entry($ds, $result); + if ($entry === false) { + throw new LdapException( + 'Capabilities not available (%s; Default port: %d): %s. Discovery of root DSE probably not permitted.', + $connection->getHostname(), + $connection->getPort(), + ldap_error($ds) + ); + } + + $cap = new LdapCapabilities($connection->cleanupAttributes(ldap_get_attributes($ds, $entry), $fields)); + $cap->discoverAdConfigOptions($connection); + + if (isset($cap->attributes) && Logger::getInstance()->getLevel() === Logger::DEBUG) { + Logger::debug('Capability query discovered the following attributes:'); + foreach ($cap->attributes as $name => $value) { + if ($value !== null) { + Logger::debug(' %s = %s', $name, $value); + } + } + Logger::debug('Capability query attribute listing ended.'); + } + + return $cap; + } + + /** + * Discover the AD-specific configuration options of the given LDAP server + * + * @param LdapConnection $connection The ldap connection to use + * + * @throws LdapException In case the configuration options query has failed + */ + protected function discoverAdConfigOptions(LdapConnection $connection) + { + if ($this->isActiveDirectory()) { + $configurationNamingContext = $this->getConfigurationNamingContext(); + $defaultNamingContext = $this->getDefaultNamingContext(); + if (!($configurationNamingContext === null || $defaultNamingContext === null)) { + $ds = $connection->bind()->getConnection(); + $adFields = array('nETBIOSName'); + $partitions = 'CN=Partitions,' . $configurationNamingContext; + + $result = @ldap_list( + $ds, + $partitions, + (string) $connection->select()->from('*', $adFields)->where('nCName', $defaultNamingContext), + $adFields + ); + if ($result) { + $entry = ldap_first_entry($ds, $result); + if ($entry === false) { + throw new LdapException( + 'Configuration options not available (%s:%d). Discovery of "%s" probably not permitted.', + $connection->getHostname(), + $connection->getPort(), + $partitions + ); + } + + $this->setAttributes((object) array_merge( + (array) $this->attributes, + (array) $connection->cleanupAttributes(ldap_get_attributes($ds, $entry), $adFields) + )); + } else { + if (ldap_errno($ds) !== 1) { + // One stands for "operations error" which occurs if not bound non-anonymously. + + throw new LdapException( + 'Configuration options query failed (%s:%d): %s. Check if hostname and port of the' + . ' ldap resource are correct and if anonymous access is permitted.', + $connection->getHostname(), + $connection->getPort(), + ldap_error($ds) + ); + } + } + } + } + } + + /** + * Determine the active directory version using the available capabillities + * + * @return null|string The server version description or null when unknown + */ + protected function getAdObjectVersionName() + { + if (isset($this->oids[self::LDAP_CAP_ACTIVE_DIRECTORY_W8_OID])) { + return 'Windows Server 2012 (or newer)'; + } + if (isset($this->oids[self::LDAP_CAP_ACTIVE_DIRECTORY_V61_R2_OID])) { + return 'Windows Server 2008 R2 (or newer)'; + } + if (isset($this->oids[self::LDAP_CAP_ACTIVE_DIRECTORY_V60_OID])) { + return 'Windows Server 2008 (or newer)'; + } + return null; + } +} diff --git a/library/Icinga/Protocol/Ldap/LdapConnection.php b/library/Icinga/Protocol/Ldap/LdapConnection.php new file mode 100644 index 0000000..a620e6d --- /dev/null +++ b/library/Icinga/Protocol/Ldap/LdapConnection.php @@ -0,0 +1,1584 @@ +<?php +/* Icinga Web 2 | (c) 2013 Icinga Development Team | GPLv2+ */ + +namespace Icinga\Protocol\Ldap; + +use ArrayIterator; +use Exception; +use Icinga\Data\Filter\FilterNot; +use LogicException; +use stdClass; +use Icinga\Application\Config; +use Icinga\Application\Logger; +use Icinga\Data\ConfigObject; +use Icinga\Data\Filter\Filter; +use Icinga\Data\Filter\FilterChain; +use Icinga\Data\Filter\FilterExpression; +use Icinga\Data\Inspectable; +use Icinga\Data\Inspection; +use Icinga\Data\Selectable; +use Icinga\Data\Sortable; +use Icinga\Exception\ProgrammingError; +use Icinga\Web\Url; + +/** + * Encapsulate LDAP connections and query creation + */ +class LdapConnection implements Selectable, Inspectable +{ + /** + * Indicates that the target object cannot be found + * + * @var int + */ + const LDAP_NO_SUCH_OBJECT = 32; + + /** + * Indicates that in a search operation, the size limit specified by the client or the server has been exceeded + * + * @var int + */ + const LDAP_SIZELIMIT_EXCEEDED = 4; + + /** + * Indicates that an LDAP server limit set by an administrative authority has been exceeded + * + * @var int + */ + const LDAP_ADMINLIMIT_EXCEEDED = 11; + + /** + * Indicates that during a bind operation one of the following occurred: The client passed either an incorrect DN + * or password, or the password is incorrect because it has expired, intruder detection has locked the account, or + * another similar reason. + * + * @var int + */ + const LDAP_INVALID_CREDENTIALS = 49; + + /** + * The default page size to use for paged queries + * + * @var int + */ + const PAGE_SIZE = 1000; + + /** + * Encrypt connection using STARTTLS (upgrading a plain text connection) + * + * @var string + */ + const STARTTLS = 'starttls'; + + /** + * Encrypt connection using LDAP over SSL (using a separate port) + * + * @var string + */ + const LDAPS = 'ldaps'; + + /** @var ConfigObject Connection configuration */ + protected $config; + + /** + * Encryption for the connection if any + * + * @var string + */ + protected $encryption; + + /** + * The LDAP link identifier being used + * + * @var resource + */ + protected $ds; + + /** + * The ip address, hostname or ldap URI being used to connect with the LDAP server + * + * @var string + */ + protected $hostname; + + /** + * The port being used to connect with the LDAP server + * + * @var int + */ + protected $port; + + /** + * The distinguished name being used to bind to the LDAP server + * + * @var string + */ + protected $bindDn; + + /** + * The password being used to bind to the LDAP server + * + * @var string + */ + protected $bindPw; + + /** + * The distinguished name being used as the base path for queries which do not provide one theirselves + * + * @var string + */ + protected $rootDn; + + /** + * Whether the bind on this connection has already been performed + * + * @var bool + */ + protected $bound; + + /** + * The current connection's root node + * + * @var Root + */ + protected $root; + + /** + * LDAP_OPT_NETWORK_TIMEOUT for the LDAP connection + * + * @var int + */ + protected $timeout; + + /** + * The properties and capabilities of the LDAP server + * + * @var LdapCapabilities + */ + protected $capabilities; + + /** + * Whether discovery was successful + * + * @var bool + */ + protected $discoverySuccess; + + /** + * The cause of the discovery's failure + * + * @var Exception|null + */ + private $discoveryError; + + /** + * Whether the current connection is encrypted + * + * @var bool + */ + protected $encrypted = null; + + /** + * Create a new connection object + * + * @param ConfigObject $config + */ + public function __construct(ConfigObject $config) + { + $this->config = $config; + $this->hostname = $config->hostname; + $this->bindDn = $config->bind_dn; + $this->bindPw = $config->bind_pw; + $this->rootDn = $config->root_dn; + $this->port = (int) $config->get('port', 389); + $this->timeout = (int) $config->get('timeout', 5); + + $this->encryption = $config->encryption; + if ($this->encryption !== null) { + $this->encryption = strtolower($this->encryption); + } + } + + /** + * Return the ip address, hostname or ldap URI being used to connect with the LDAP server + * + * @return string + */ + public function getHostname() + { + return $this->hostname; + } + + /** + * Return the port being used to connect with the LDAP server + * + * @return int + */ + public function getPort() + { + return $this->port; + } + + /** + * Return the distinguished name being used as the base path for queries which do not provide one theirselves + * + * @return string + */ + public function getDn() + { + return $this->rootDn; + } + + /** + * Return the root node for this connection + * + * @return Root + */ + public function root() + { + if ($this->root === null) { + $this->root = Root::forConnection($this); + } + + return $this->root; + } + + /** + * Return the LDAP link identifier being used + * + * Establishes a connection if necessary. + * + * @return resource + */ + public function getConnection() + { + if ($this->ds === null) { + $this->ds = $this->prepareNewConnection(); + } + + return $this->ds; + } + + /** + * Return the capabilities of the current connection + * + * @return LdapCapabilities + */ + public function getCapabilities() + { + if ($this->capabilities === null) { + try { + $this->capabilities = LdapCapabilities::discoverCapabilities($this); + $this->discoverySuccess = true; + $this->discoveryError = null; + } catch (LdapException $e) { + Logger::debug($e); + Logger::warning('LADP discovery failed, assuming default LDAP capabilities.'); + $this->capabilities = new LdapCapabilities(); // create empty default capabilities + $this->discoverySuccess = false; + $this->discoveryError = $e; + } + } + + return $this->capabilities; + } + + /** + * Return whether discovery was successful + * + * @return bool true if the capabilities were successfully determined, false if the capabilities were guessed + */ + public function discoverySuccessful() + { + if ($this->discoverySuccess === null) { + $this->getCapabilities(); // Initializes self::$discoverySuccess + } + + return $this->discoverySuccess; + } + + /** + * Get discovery error if any + * + * @return Exception|null + */ + public function getDiscoveryError() + { + return $this->discoveryError; + } + + /** + * Return whether the current connection is encrypted + * + * @return bool + */ + public function isEncrypted() + { + if ($this->encrypted === null) { + return false; + } + + return $this->encrypted; + } + + /** + * Perform a LDAP bind on the current connection + * + * @throws LdapException In case the LDAP bind was unsuccessful or insecure + */ + public function bind() + { + if ($this->bound) { + return $this; + } + + $ds = $this->getConnection(); + + $success = @ldap_bind($ds, $this->bindDn, $this->bindPw); + if (! $success) { + throw new LdapException( + 'LDAP bind (%s / %s) to %s failed: %s', + $this->bindDn, + '***' /* $this->bindPw */, + $this->normalizeHostname($this->hostname), + ldap_error($ds) + ); + } + + $this->bound = true; + return $this; + } + + /** + * Provide a query on this connection + * + * @return LdapQuery + */ + public function select() + { + return new LdapQuery($this); + } + + /** + * Fetch and return all rows of the given query's result set using an iterator + * + * @param LdapQuery $query The query returning the result set + * + * @return ArrayIterator + */ + public function query(LdapQuery $query) + { + return new ArrayIterator($this->fetchAll($query)); + } + + /** + * Count all rows of the given query's result set + * + * @param LdapQuery $query The query returning the result set + * + * @return int + */ + public function count(LdapQuery $query) + { + $this->bind(); + + if (($unfoldAttribute = $query->getUnfoldAttribute()) !== null) { + $desiredColumns = $query->getColumns(); + if (isset($desiredColumns[$unfoldAttribute])) { + $fields = array($unfoldAttribute => $desiredColumns[$unfoldAttribute]); + } elseif (in_array($unfoldAttribute, $desiredColumns, true)) { + $fields = array($unfoldAttribute); + } else { + throw new ProgrammingError( + 'The attribute used to unfold a query\'s result must be selected' + ); + } + + $res = $this->runQuery($query, $fields); + return count($res); + } + + $ds = $this->getConnection(); + $results = $this->ldapSearch($query, array('dn')); + + if ($results === false) { + if (ldap_errno($ds) !== self::LDAP_NO_SUCH_OBJECT) { + throw new LdapException( + 'LDAP count query "%s" (base %s) failed: %s', + (string) $query, + $query->getBase() ?: $this->getDn(), + ldap_error($ds) + ); + } + } + + return ldap_count_entries($ds, $results); + } + + /** + * Retrieve an array containing all rows of the result set + * + * @param LdapQuery $query The query returning the result set + * @param array $fields Request these attributes instead of the ones registered in the given query + * + * @return array + */ + public function fetchAll(LdapQuery $query, array $fields = null) + { + $this->bind(); + + if ($query->getUsePagedResults() && $this->getCapabilities()->hasPagedResult()) { + return $this->runPagedQuery($query, $fields); + } else { + return $this->runQuery($query, $fields); + } + } + + /** + * Fetch the first row of the result set + * + * @param LdapQuery $query The query returning the result set + * @param array $fields Request these attributes instead of the ones registered in the given query + * + * @return mixed + */ + public function fetchRow(LdapQuery $query, array $fields = null) + { + $clonedQuery = clone $query; + $clonedQuery->limit(1); + $clonedQuery->setUsePagedResults(false); + $results = $this->fetchAll($clonedQuery, $fields); + return array_shift($results) ?: false; + } + + /** + * Fetch the first column of all rows of the result set as an array + * + * @param LdapQuery $query The query returning the result set + * @param array $fields Request these attributes instead of the ones registered in the given query + * + * @return array + * + * @throws ProgrammingError In case no attribute is being requested + */ + public function fetchColumn(LdapQuery $query, array $fields = null) + { + if ($fields === null) { + $fields = $query->getColumns(); + } + + if (empty($fields)) { + throw new ProgrammingError('You must request at least one attribute when fetching a single column'); + } + + $alias = key($fields); + $results = $this->fetchAll($query, array($alias => current($fields))); + $column = is_int($alias) ? current($fields) : $alias; + $values = array(); + foreach ($results as $row) { + if (isset($row->$column)) { + $values[] = $row->$column; + } + } + + return $values; + } + + /** + * Fetch the first column of the first row of the result set + * + * @param LdapQuery $query The query returning the result set + * @param array $fields Request these attributes instead of the ones registered in the given query + * + * @return string + */ + public function fetchOne(LdapQuery $query, array $fields = null) + { + $row = $this->fetchRow($query, $fields); + if ($row === false) { + return false; + } + + $values = get_object_vars($row); + if (empty($values)) { + return false; + } + + if ($fields === null) { + // Fetch the desired columns from the query if not explicitly overriden in the method's parameter + $fields = $query->getColumns(); + } + + if (empty($fields)) { + // The desired columns may be empty independently whether provided by the query or the method's parameter + return array_shift($values); + } + + $alias = key($fields); + return $values[is_string($alias) ? $alias : $fields[$alias]]; + } + + /** + * Fetch all rows of the result set as an array of key-value pairs + * + * The first column is the key, the second column is the value. + * + * @param LdapQuery $query The query returning the result set + * @param array $fields Request these attributes instead of the ones registered in the given query + * + * @return array + * + * @throws ProgrammingError In case there are less than two attributes being requested + */ + public function fetchPairs(LdapQuery $query, array $fields = null) + { + if ($fields === null) { + $fields = $query->getColumns(); + } + + if (count($fields) < 2) { + throw new ProgrammingError('You are required to request at least two attributes'); + } + + $columns = $desiredColumnNames = array(); + foreach ($fields as $alias => $column) { + if (is_int($alias)) { + $columns[] = $column; + $desiredColumnNames[] = $column; + } else { + $columns[$alias] = $column; + $desiredColumnNames[] = $alias; + } + + if (count($desiredColumnNames) === 2) { + break; + } + } + + $results = $this->fetchAll($query, $columns); + $pairs = array(); + foreach ($results as $row) { + $colOne = $desiredColumnNames[0]; + $colTwo = $desiredColumnNames[1]; + $pairs[$row->$colOne] = $row->$colTwo; + } + + return $pairs; + } + + /** + * Fetch an LDAP entry by its DN + * + * @param string $dn + * @param array|null $fields + * + * @return StdClass|bool + */ + public function fetchByDn($dn, array $fields = null) + { + return $this->select() + ->from('*', $fields) + ->setBase($dn) + ->setScope('base') + ->fetchRow(); + } + + /** + * Test the given LDAP credentials by establishing a connection and attempting a LDAP bind + * + * @param string $bindDn + * @param string $bindPw + * + * @return bool Whether the given credentials are valid + * + * @throws LdapException In case an error occured while establishing the connection or attempting the bind + */ + public function testCredentials($bindDn, $bindPw) + { + $ds = $this->getConnection(); + $success = @ldap_bind($ds, $bindDn, $bindPw); + if (! $success) { + if (ldap_errno($ds) === self::LDAP_INVALID_CREDENTIALS) { + Logger::debug( + 'Testing LDAP credentials (%s / %s) failed: %s', + $bindDn, + '***', + ldap_error($ds) + ); + return false; + } + + throw new LdapException(ldap_error($ds)); + } + + return true; + } + + /** + * Return whether an entry identified by the given distinguished name exists + * + * @param string $dn + * + * @return bool + */ + public function hasDn($dn) + { + $ds = $this->getConnection(); + $this->bind(); + + $result = ldap_read($ds, $dn, '(objectClass=*)', array('objectClass')); + return ldap_count_entries($ds, $result) > 0; + } + + /** + * Delete a root entry and all of its children identified by the given distinguished name + * + * @param string $dn + * + * @return bool + * + * @throws LdapException In case an error occured while deleting an entry + */ + public function deleteRecursively($dn) + { + $ds = $this->getConnection(); + $this->bind(); + + $result = @ldap_list($ds, $dn, '(objectClass=*)', array('objectClass')); + if ($result === false) { + if (ldap_errno($ds) === self::LDAP_NO_SUCH_OBJECT) { + return false; + } + + throw new LdapException('LDAP list for "%s" failed: %s', $dn, ldap_error($ds)); + } + + $children = ldap_get_entries($ds, $result); + for ($i = 0; $i < $children['count']; $i++) { + $result = $this->deleteRecursively($children[$i]['dn']); + if (! $result) { + // TODO: return result code, if delete fails + throw new LdapException('Recursively deleting "%s" failed', $dn); + } + } + + return $this->deleteDn($dn); + } + + /** + * Delete a single entry identified by the given distinguished name + * + * @param string $dn + * + * @return bool + * + * @throws LdapException In case an error occured while deleting the entry + */ + public function deleteDn($dn) + { + $ds = $this->getConnection(); + $this->bind(); + + $result = @ldap_delete($ds, $dn); + if ($result === false) { + if (ldap_errno($ds) === self::LDAP_NO_SUCH_OBJECT) { + return false; // TODO: Isn't it a success if something i'd like to remove is not existing at all??? + } + + throw new LdapException('LDAP delete for "%s" failed: %s', $dn, ldap_error($ds)); + } + + return true; + } + + /** + * Fetch the distinguished name of the result of the given query + * + * @param LdapQuery $query The query returning the result set + * + * @return string The distinguished name, or false when the given query yields no results + * + * @throws LdapException In case the query yields multiple results + */ + public function fetchDn(LdapQuery $query) + { + $rows = $this->fetchAll($query, array()); + if (count($rows) > 1) { + throw new LdapException('Cannot fetch single DN for %s', $query); + } + + return key($rows); + } + + /** + * Run the given LDAP query and return the resulting entries + * + * @param LdapQuery $query The query to fetch results with + * @param array $fields Request these attributes instead of the ones registered in the given query + * + * @return array + * + * @throws LdapException In case an error occured while fetching the results + */ + protected function runQuery(LdapQuery $query, array $fields = null) + { + $limit = $query->getLimit(); + $offset = $query->hasOffset() ? $query->getOffset() : 0; + + if ($fields === null) { + $fields = $query->getColumns(); + } + + $ds = $this->getConnection(); + + $serverSorting = ! $this->config->disable_server_side_sort + && $this->getCapabilities()->hasOid(LdapCapabilities::LDAP_SERVER_SORT_OID); + + if ($query->hasOrder()) { + if ($serverSorting) { + ldap_set_option($ds, LDAP_OPT_SERVER_CONTROLS, array( + array( + 'oid' => LdapCapabilities::LDAP_SERVER_SORT_OID, + 'value' => $this->encodeSortRules($query->getOrder()) + ) + )); + } elseif (! empty($fields)) { + foreach ($query->getOrder() as $rule) { + if (! in_array($rule[0], $fields, true)) { + $fields[] = $rule[0]; + } + } + } + } + + $unfoldAttribute = $query->getUnfoldAttribute(); + if ($unfoldAttribute) { + foreach ($query->getFilter()->listFilteredColumns() as $filterColumn) { + $fieldKey = array_search($filterColumn, $fields, true); + if ($fieldKey === false || is_string($fieldKey)) { + $fields[] = $filterColumn; + } + } + } + + $results = $this->ldapSearch( + $query, + array_values($fields), + 0, + ($serverSorting || ! $query->hasOrder()) && $limit ? $offset + $limit : 0 + ); + if ($results === false) { + if (ldap_errno($ds) === self::LDAP_NO_SUCH_OBJECT) { + return array(); + } + + throw new LdapException( + 'LDAP query "%s" (base %s) failed. Error: %s', + $query, + $query->getBase() ?: $this->rootDn, + ldap_error($ds) + ); + } elseif (ldap_count_entries($ds, $results) === 0) { + return array(); + } + + $count = 0; + $entries = array(); + $entry = ldap_first_entry($ds, $results); + do { + if ($unfoldAttribute) { + $rows = $this->cleanupAttributes(ldap_get_attributes($ds, $entry), $fields, $unfoldAttribute); + if (is_array($rows)) { + // TODO: Register the DN the same way as a section name in the ArrayDatasource! + foreach ($rows as $row) { + if ($query->getFilter()->matches($row)) { + $count += 1; + if (! $serverSorting || $offset === 0 || $offset < $count) { + $entries[] = $row; + } + + if ($serverSorting && $limit > 0 && $limit === count($entries)) { + break; + } + } + } + } else { + $count += 1; + if (! $serverSorting || $offset === 0 || $offset < $count) { + $entries[ldap_get_dn($ds, $entry)] = $rows; + } + } + } else { + $count += 1; + if (! $serverSorting || $offset === 0 || $offset < $count) { + $entries[ldap_get_dn($ds, $entry)] = $this->cleanupAttributes( + ldap_get_attributes($ds, $entry), + $fields + ); + } + } + } while ((! $serverSorting || $limit === 0 || $limit !== count($entries)) + && ($entry = ldap_next_entry($ds, $entry)) + ); + + if (! $serverSorting) { + if ($query->hasOrder()) { + uasort($entries, array($query, 'compare')); + } + + if ($limit && $count > $limit) { + $entries = array_splice($entries, $query->hasOffset() ? $query->getOffset() : 0, $limit); + } + } + + ldap_free_result($results); + return $entries; + } + + /** + * Run the given LDAP query and return the resulting entries + * + * This utilizes paged search requests as defined in RFC 2696. + * + * @param LdapQuery $query The query to fetch results with + * @param array $fields Request these attributes instead of the ones registered in the given query + * @param int $pageSize The maximum page size, defaults to self::PAGE_SIZE + * + * @return array + * + * @throws LdapException In case an error occured while fetching the results + */ + protected function runPagedQuery(LdapQuery $query, array $fields = null, $pageSize = null) + { + if ($pageSize === null) { + $pageSize = static::PAGE_SIZE; + } + + $limit = $query->getLimit(); + $offset = $query->hasOffset() ? $query->getOffset() : 0; + + if ($fields === null) { + $fields = $query->getColumns(); + } + + $ds = $this->getConnection(); + + $serverSorting = false;//$this->getCapabilities()->hasOid(LdapCapabilities::LDAP_SERVER_SORT_OID); + if (! $serverSorting && $query->hasOrder() && ! empty($fields)) { + foreach ($query->getOrder() as $rule) { + if (! in_array($rule[0], $fields, true)) { + $fields[] = $rule[0]; + } + } + } + + $unfoldAttribute = $query->getUnfoldAttribute(); + if ($unfoldAttribute) { + foreach ($query->getFilter()->listFilteredColumns() as $filterColumn) { + $fieldKey = array_search($filterColumn, $fields, true); + if ($fieldKey === false || is_string($fieldKey)) { + $fields[] = $filterColumn; + } + } + } + + $controls = []; + $legacyControlHandling = version_compare(PHP_VERSION, '7.3.0') < 0; + if ($serverSorting && $query->hasOrder()) { + $control = [ + 'oid' => LDAP_CONTROL_SORTREQUEST, + 'value' => $this->encodeSortRules($query->getOrder()) + ]; + if ($legacyControlHandling) { + ldap_set_option($ds, LDAP_OPT_SERVER_CONTROLS, [$control]); + } else { + $controls[LDAP_CONTROL_SORTREQUEST] = $control; + } + } + + $count = 0; + $cookie = ''; + $entries = array(); + do { + if ($legacyControlHandling) { + // Do not request the pagination control as a critical extension, as we want the + // server to return results even if the paged search request cannot be satisfied + ldap_control_paged_result($ds, $pageSize, false, $cookie); + } else { + $controls[LDAP_CONTROL_PAGEDRESULTS] = [ + 'oid' => LDAP_CONTROL_PAGEDRESULTS, + 'iscritical' => false, // See above + 'value' => [ + 'size' => $pageSize, + 'cookie' => $cookie + ] + ]; + } + + $results = $this->ldapSearch( + $query, + array_values($fields), + 0, + ($serverSorting || ! $query->hasOrder()) && $limit ? $offset + $limit : 0, + 0, + LDAP_DEREF_NEVER, + empty($controls) ? null : $controls + ); + if ($results === false) { + if (ldap_errno($ds) === self::LDAP_NO_SUCH_OBJECT) { + break; + } + + throw new LdapException( + 'LDAP query "%s" (base %s) failed. Error: %s', + (string) $query, + $query->getBase() ?: $this->getDn(), + ldap_error($ds) + ); + } elseif (ldap_count_entries($ds, $results) === 0) { + if (in_array( + ldap_errno($ds), + array(static::LDAP_SIZELIMIT_EXCEEDED, static::LDAP_ADMINLIMIT_EXCEEDED), + true + )) { + Logger::warning( + 'Unable to request more than %u results. Does the server allow paged search requests? (%s)', + $count, + ldap_error($ds) + ); + } + + break; + } + + $entry = ldap_first_entry($ds, $results); + do { + if ($unfoldAttribute) { + $rows = $this->cleanupAttributes(ldap_get_attributes($ds, $entry), $fields, $unfoldAttribute); + if (is_array($rows)) { + // TODO: Register the DN the same way as a section name in the ArrayDatasource! + foreach ($rows as $row) { + if ($query->getFilter()->matches($row)) { + $count += 1; + if (! $serverSorting || $offset === 0 || $offset < $count) { + $entries[] = $row; + } + + if ($serverSorting && $limit > 0 && $limit === count($entries)) { + break; + } + } + } + } else { + $count += 1; + if (! $serverSorting || $offset === 0 || $offset < $count) { + $entries[ldap_get_dn($ds, $entry)] = $rows; + } + } + } else { + $count += 1; + if (! $serverSorting || $offset === 0 || $offset < $count) { + $entries[ldap_get_dn($ds, $entry)] = $this->cleanupAttributes( + ldap_get_attributes($ds, $entry), + $fields + ); + } + } + } while ((! $serverSorting || $limit === 0 || $limit !== count($entries)) + && ($entry = ldap_next_entry($ds, $entry)) + ); + + if ($legacyControlHandling) { + if (false === @ldap_control_paged_result_response($ds, $results, $cookie)) { + // If the page size is greater than or equal to the sizeLimit value, the server should ignore the + // control as the request can be satisfied in a single page: https://www.ietf.org/rfc/rfc2696.txt + // This applies no matter whether paged search requests are permitted or not. You're done once you + // got everything you were out for. + if ($serverSorting && count($entries) !== $limit) { + // The server does not support pagination, but still returned a response by ignoring the + // pagedResultsControl. We output a warning to indicate that the pagination control was ignored. + Logger::warning( + 'Unable to request paged LDAP results. Does the server allow paged search requests?' + ); + } + } + } else { + ldap_parse_result($ds, $results, $errno, $dn, $errmsg, $refs, $controlsReturned); + $cookie = $controlsReturned[LDAP_CONTROL_PAGEDRESULTS]['value']['cookie']; + } + + ldap_free_result($results); + } while ($cookie && (! $serverSorting || $limit === 0 || count($entries) < $limit)); + + if ($legacyControlHandling && $cookie) { + // A sequence of paged search requests is abandoned by the client sending a search request containing a + // pagedResultsControl with the size set to zero (0) and the cookie set to the last cookie returned by + // the server: https://www.ietf.org/rfc/rfc2696.txt + ldap_control_paged_result($ds, 0, false, $cookie); + // Returns no entries, due to the page size + ldap_search($ds, $query->getBase() ?: $this->getDn(), (string) $query); + } + + if (! $serverSorting) { + if ($query->hasOrder()) { + uasort($entries, array($query, 'compare')); + } + + if ($limit && $count > $limit) { + $entries = array_splice($entries, $query->hasOffset() ? $query->getOffset() : 0, $limit); + } + } + + return $entries; + } + + /** + * Clean up the given attributes and return them as simple object + * + * Applies column aliases, aggregates/unfolds multi-value attributes + * as array and sets null for each missing attribute. + * + * @param array $attributes + * @param array $requestedFields + * @param string $unfoldAttribute + * + * @return object|array An array in case the object has been unfolded + */ + public function cleanupAttributes($attributes, array $requestedFields, $unfoldAttribute = null) + { + // In case the result contains attributes with a differing case than the requested fields, it is + // necessary to create another array to map attributes case insensitively to their requested counterparts. + // This does also apply the virtual alias handling. (Since an LDAP server does not handle such) + $loweredFieldMap = array(); + foreach ($requestedFields as $alias => $name) { + $loweredName = strtolower($name); + if (isset($loweredFieldMap[$loweredName])) { + if (! is_array($loweredFieldMap[$loweredName])) { + $loweredFieldMap[$loweredName] = array($loweredFieldMap[$loweredName]); + } + + $loweredFieldMap[$loweredName][] = is_string($alias) ? $alias : $name; + } else { + $loweredFieldMap[$loweredName] = is_string($alias) ? $alias : $name; + } + } + + $cleanedAttributes = array(); + for ($i = 0; $i < $attributes['count']; $i++) { + $attribute_name = $attributes[$i]; + if ($attributes[$attribute_name]['count'] === 1) { + $attribute_value = $attributes[$attribute_name][0]; + } else { + $attribute_value = array(); + for ($j = 0; $j < $attributes[$attribute_name]['count']; $j++) { + $attribute_value[] = $attributes[$attribute_name][$j]; + } + } + + $requestedAttributeName = isset($loweredFieldMap[strtolower($attribute_name)]) + ? $loweredFieldMap[strtolower($attribute_name)] + : $attribute_name; + if (is_array($requestedAttributeName)) { + foreach ($requestedAttributeName as $requestedName) { + $cleanedAttributes[$requestedName] = $attribute_value; + } + } else { + $cleanedAttributes[$requestedAttributeName] = $attribute_value; + } + } + + // The result may not contain all requested fields, so populate the cleaned + // result with the missing fields and their value being set to null + foreach ($requestedFields as $alias => $name) { + if (! is_string($alias)) { + $alias = $name; + } + + if (! array_key_exists($alias, $cleanedAttributes)) { + $cleanedAttributes[$alias] = null; + Logger::debug('LDAP query result does not provide the requested field "%s"', $name); + } + } + + if ($unfoldAttribute !== null + && isset($cleanedAttributes[$unfoldAttribute]) + && is_array($cleanedAttributes[$unfoldAttribute]) + ) { + $siblings = array(); + foreach ($loweredFieldMap as $loweredName => $requestedNames) { + if (is_array($requestedNames) && in_array($unfoldAttribute, $requestedNames, true)) { + $siblings = array_diff($requestedNames, array($unfoldAttribute)); + break; + } + } + + $values = $cleanedAttributes[$unfoldAttribute]; + unset($cleanedAttributes[$unfoldAttribute]); + $baseRow = (object) $cleanedAttributes; + $rows = array(); + foreach ($values as $value) { + $row = clone $baseRow; + $row->{$unfoldAttribute} = $value; + foreach ($siblings as $sibling) { + $row->{$sibling} = $value; + } + + $rows[] = $row; + } + + return $rows; + } + + return (object) $cleanedAttributes; + } + + /** + * Encode the given array of sort rules as ASN.1 octet stream according to RFC 2891 + * + * @param array $sortRules + * + * @return string Binary representation of the octet stream + */ + protected function encodeSortRules(array $sortRules) + { + $sequenceOf = ''; + + foreach ($sortRules as $rule) { + if ($rule[1] === Sortable::SORT_DESC) { + $reversed = '8101ff'; + } else { + $reversed = ''; + } + + $attributeType = unpack('H*', $rule[0]); + $attributeType = $attributeType[1]; + $attributeOctets = strlen($attributeType) / 2; + if ($attributeOctets >= 127) { + // Use the indefinite form of the length octets (the long form would be another option) + $attributeType = '0440' . $attributeType . '0000'; + } else { + $attributeType = '04' . str_pad(dechex($attributeOctets), 2, '0', STR_PAD_LEFT) . $attributeType; + } + + $sequence = $attributeType . $reversed; + $sequenceOctects = strlen($sequence) / 2; + if ($sequenceOctects >= 127) { + $sequence = '3040' . $sequence . '0000'; + } else { + $sequence = '30' . str_pad(dechex($sequenceOctects), 2, '0', STR_PAD_LEFT) . $sequence; + } + + $sequenceOf .= $sequence; + } + + $sequenceOfOctets = strlen($sequenceOf) / 2; + if ($sequenceOfOctets >= 127) { + $sequenceOf = '3040' . $sequenceOf . '0000'; + } else { + $sequenceOf = '30' . str_pad(dechex($sequenceOfOctets), 2, '0', STR_PAD_LEFT) . $sequenceOf; + } + + return hex2bin($sequenceOf); + } + + /** + * Prepare and establish a connection with the LDAP server + * + * @param Inspection $info Optional inspection to fill with diagnostic info + * + * @return resource A LDAP link identifier + * + * @throws LdapException In case the connection is not possible + */ + protected function prepareNewConnection(Inspection $info = null) + { + if (! isset($info)) { + $info = new Inspection(''); + } + + $hostname = $this->normalizeHostname($this->hostname); + + $ds = ldap_connect($hostname); + + // Set a proper timeout for each connection + ldap_set_option($ds, LDAP_OPT_NETWORK_TIMEOUT, $this->timeout); + + // Usage of ldap_rename, setting LDAP_OPT_REFERRALS to 0 or using STARTTLS requires LDAPv3. + // If this does not work we're probably not in a PHP 5.3+ environment as it is VERY + // unlikely that the server complains about it by itself prior to a bind request + ldap_set_option($ds, LDAP_OPT_PROTOCOL_VERSION, 3); + + // Not setting this results in "Operations error" on AD when using the whole domain as search base + ldap_set_option($ds, LDAP_OPT_REFERRALS, 0); + + if ($this->encryption === static::LDAPS) { + $info->write('Connect using LDAPS'); + } elseif ($this->encryption === static::STARTTLS) { + $this->encrypted = true; + $info->write('Connect using STARTTLS'); + if (! ldap_start_tls($ds)) { + throw new LdapException('LDAP STARTTLS failed: %s', ldap_error($ds)); + } + } elseif ($this->encryption !== static::LDAPS) { + $this->encrypted = false; + $info->write('Connect without encryption'); + } + + return $ds; + } + + /** + * Perform a LDAP search and return the result + * + * @param LdapQuery $query + * @param array $attributes An array of the required attributes + * @param int $attrsonly Should be set to 1 if only attribute types are wanted + * @param int $sizelimit Enables you to limit the count of entries fetched + * @param int $timelimit Sets the number of seconds how long is spend on the search + * @param int $deref + * @param array $controls LDAP Controls to send with the request (Only supported with PHP v7.3+) + * + * @return resource|bool A search result identifier or false on error + * + * @throws LogicException If the LDAP query search scope is unsupported + */ + public function ldapSearch( + LdapQuery $query, + array $attributes = null, + $attrsonly = 0, + $sizelimit = 0, + $timelimit = 0, + $deref = LDAP_DEREF_NEVER, + $controls = null + ) { + $queryString = (string) $query; + $baseDn = $query->getBase() ?: $this->getDn(); + $scope = $query->getScope(); + + if (Logger::getInstance()->getLevel() === Logger::DEBUG) { + // We're checking the level by ourselves to avoid rendering the ldapsearch commandline for nothing + $starttlsParam = $this->encryption === static::STARTTLS ? ' -ZZ' : ''; + + $bindParams = ''; + if ($this->bound) { + $bindParams = ' -D "' . $this->bindDn . '"' . ($this->bindPw ? ' -W' : ''); + } + + if ($deref === LDAP_DEREF_NEVER) { + $derefName = 'never'; + } elseif ($deref === LDAP_DEREF_ALWAYS) { + $derefName = 'always'; + } elseif ($deref === LDAP_DEREF_SEARCHING) { + $derefName = 'search'; + } else { // $deref === LDAP_DEREF_FINDING + $derefName = 'find'; + } + + Logger::debug("Issuing LDAP search. Use '%s' to reproduce.", sprintf( + 'ldapsearch -P 3%s -H "%s"%s -b "%s" -s "%s" -z %u -l %u -a "%s"%s%s%s', + $starttlsParam, + $this->normalizeHostname($this->hostname), + $bindParams, + $baseDn, + $scope, + $sizelimit, + $timelimit, + $derefName, + $attrsonly ? ' -A' : '', + $queryString ? ' "' . $queryString . '"' : '', + $attributes ? ' "' . join('" "', $attributes) . '"' : '' + )); + } + + switch ($scope) { + case LdapQuery::SCOPE_SUB: + $function = 'ldap_search'; + break; + case LdapQuery::SCOPE_ONE: + $function = 'ldap_list'; + break; + case LdapQuery::SCOPE_BASE: + $function = 'ldap_read'; + break; + default: + throw new LogicException('LDAP scope %s not supported by ldapSearch', $scope); + } + + // Explicit calls with and without controls, + // because the parameter is only supported since PHP 7.3. + // Since it is a public method, + // providing controls will naturally fail if the parameter is not supported by PHP. + if ($controls !== null) { + return @$function( + $this->getConnection(), + $baseDn, + $queryString, + $attributes, + $attrsonly, + $sizelimit, + $timelimit, + $deref, + $controls + ); + } else { + return @$function( + $this->getConnection(), + $baseDn, + $queryString, + $attributes, + $attrsonly, + $sizelimit, + $timelimit, + $deref + ); + } + } + + /** + * Create an LDAP entry + * + * @param string $dn The distinguished name to use + * @param array $attributes The entry's attributes + * + * @return bool Whether the operation was successful + */ + public function addEntry($dn, array $attributes) + { + return ldap_add($this->getConnection(), $dn, $attributes); + } + + /** + * Modify an LDAP entry + * + * @param string $dn The distinguished name to use + * @param array $attributes The attributes to update the entry with + * + * @return bool Whether the operation was successful + */ + public function modifyEntry($dn, array $attributes) + { + return ldap_modify($this->getConnection(), $dn, $attributes); + } + + /** + * Change the distinguished name of an LDAP entry + * + * @param string $dn The entry's current distinguished name + * @param string $newRdn The new relative distinguished name + * @param string $newParentDn The new parent or superior entry's distinguished name + * + * @return resource The resulting search result identifier + * + * @throws LdapException In case an error occured + */ + public function moveEntry($dn, $newRdn, $newParentDn) + { + $ds = $this->getConnection(); + $result = ldap_rename($ds, $dn, $newRdn, $newParentDn, false); + if ($result === false) { + throw new LdapException('Could not move entry "%s" to "%s": %s', $dn, $newRdn, ldap_error($ds)); + } + + return $result; + } + + /** + * Return the LDAP specific configuration directory with the given relative path being appended + * + * @param string $sub + * + * @return string + */ + protected function getConfigDir($sub = null) + { + $dir = Config::$configDir . '/ldap'; + if ($sub !== null) { + $dir .= '/' . $sub; + } + + return $dir; + } + + /** + * Render and return a valid LDAP filter representation of the given filter + * + * @param Filter $filter + * @param int $level + * + * @return string + */ + public function renderFilter(Filter $filter, $level = 0) + { + if ($filter->isExpression()) { + /** @var $filter FilterExpression */ + return $this->renderFilterExpression($filter); + } + + /** @var $filter FilterChain */ + $parts = array(); + foreach ($filter->filters() as $filterPart) { + $part = $this->renderFilter($filterPart, $level + 1); + if ($part) { + $parts[] = $part; + } + } + + if (empty($parts)) { + return ''; + } + + $format = '%1$s(%2$s)'; + if (count($parts) === 1 && ! $filter instanceof FilterNot) { + $format = '%2$s'; + } + if ($level === 0) { + $format = '(' . $format . ')'; + } + + return sprintf($format, $filter->getOperatorSymbol(), implode(')(', $parts)); + } + + /** + * Render and return a valid LDAP filter expression of the given filter + * + * @param FilterExpression $filter + * + * @return string + */ + protected function renderFilterExpression(FilterExpression $filter) + { + $column = $filter->getColumn(); + $sign = $filter->getSign(); + $expression = $filter->getExpression(); + $format = '%1$s%2$s%3$s'; + + if ($expression === null || $expression === true) { + $expression = '*'; + } elseif (is_array($expression)) { + $seqFormat = '|(%s)'; + if ($sign === '!=') { + $seqFormat = '!(' . $seqFormat . ')'; + $sign = '='; + } + + $seqParts = array(); + foreach ($expression as $expressionValue) { + $seqParts[] = sprintf( + $format, + LdapUtils::quoteForSearch($column), + $sign, + LdapUtils::quoteForSearch($expressionValue, true) + ); + } + + return sprintf($seqFormat, implode(')(', $seqParts)); + } + + if ($sign === '!=') { + $format = '!(%1$s=%3$s)'; + } + + return sprintf( + $format, + LdapUtils::quoteForSearch($column), + $sign, + LdapUtils::quoteForSearch($expression, true) + ); + } + + /** + * Inspect if this LDAP Connection is working as expected + * + * Check if connection, bind and encryption is working as expected and get additional + * information about the used + * + * @return Inspection Inspection result + */ + public function inspect() + { + $insp = new Inspection('Ldap Connection'); + + // Try to connect to the server with the given connection parameters + try { + $ds = $this->prepareNewConnection($insp); + } catch (Exception $e) { + if ($this->encryption === 'starttls') { + // The Exception does not return any proper error messages in case of certificate errors. Connecting + // by STARTTLS will usually fail at this point when the certificate is unknown, + // so at least try to give some hints. + $insp->write('NOTE: There might be an issue with the chosen encryption. Ensure that the LDAP-Server ' . + 'supports STARTTLS and that the LDAP-Client is configured to accept its certificate.'); + } + return $insp->error($e->getMessage()); + } + + // Try a bind-command with the given user credentials, this must not fail + $success = @ldap_bind($ds, $this->bindDn, $this->bindPw); + $msg = sprintf( + 'LDAP bind (%s / %s) to %s', + $this->bindDn, + '***' /* $this->bindPw */, + $this->normalizeHostname($this->hostname) + ); + if (! $success) { + // ldap_error does not return any proper error messages in case of certificate errors. Connecting + // by LDAPS will usually fail at this point when the certificate is unknown, so at least try to give + // some hints. + if ($this->encryption === 'ldaps') { + $insp->write('NOTE: There might be an issue with the chosen encryption. Ensure that the LDAP-Server ' . + ' supports LDAPS and that the LDAP-Client is configured to accept its certificate.'); + } + return $insp->error(sprintf('%s failed: %s', $msg, ldap_error($ds))); + } + $insp->write(sprintf($msg . ' successful')); + + // Try to execute a schema discovery this may fail if schema discovery is not supported + try { + $cap = LdapCapabilities::discoverCapabilities($this); + $discovery = new Inspection('Discovery Results'); + $vendor = $cap->getVendor(); + if (isset($vendor)) { + $discovery->write($vendor); + } + $version = $cap->getVersion(); + if (isset($version)) { + $discovery->write($version); + } + $discovery->write('Supports STARTTLS: ' . ($cap->hasStartTls() ? 'True' : 'False')); + $discovery->write('Default naming context: ' . $cap->getDefaultNamingContext()); + $insp->write($discovery); + } catch (Exception $e) { + $insp->write('Schema discovery not possible: ' . $e->getMessage()); + } + return $insp; + } + + protected function normalizeHostname($hostname) + { + $scheme = $this->encryption === static::LDAPS ? 'ldaps://' : 'ldap://'; + $normalizeHostname = function ($hostname) use ($scheme) { + if (strpos($hostname, $scheme) === false) { + $hostname = $scheme . $hostname; + } + + if (! preg_match('/:\d+$/', $hostname)) { + $hostname .= ':' . $this->port; + } + + return $hostname; + }; + + $ldapUrls = explode(' ', $hostname); + if (count($ldapUrls) > 1) { + foreach ($ldapUrls as & $uri) { + $uri = $normalizeHostname($uri); + } + + $hostname = implode(' ', $ldapUrls); + } else { + $hostname = $normalizeHostname($hostname); + } + + return $hostname; + } +} diff --git a/library/Icinga/Protocol/Ldap/LdapException.php b/library/Icinga/Protocol/Ldap/LdapException.php new file mode 100644 index 0000000..740ee29 --- /dev/null +++ b/library/Icinga/Protocol/Ldap/LdapException.php @@ -0,0 +1,14 @@ +<?php +/* Icinga Web 2 | (c) 2013 Icinga Development Team | GPLv2+ */ + +namespace Icinga\Protocol\Ldap; + +use Icinga\Exception\IcingaException; + +/** + * Class LdapException + * @package Icinga\Protocol\Ldap + */ +class LdapException extends IcingaException +{ +} diff --git a/library/Icinga/Protocol/Ldap/LdapQuery.php b/library/Icinga/Protocol/Ldap/LdapQuery.php new file mode 100644 index 0000000..f4e1986 --- /dev/null +++ b/library/Icinga/Protocol/Ldap/LdapQuery.php @@ -0,0 +1,361 @@ +<?php +/* Icinga Web 2 | (c) 2013 Icinga Development Team | GPLv2+ */ + +namespace Icinga\Protocol\Ldap; + +use Icinga\Data\Filter\Filter; +use LogicException; +use Icinga\Data\SimpleQuery; + +/** + * LDAP query class + */ +class LdapQuery extends SimpleQuery +{ + /** + * The base dn being used for this query + * + * @var string + */ + protected $base; + + /** + * Whether this query is permitted to utilize paged results + * + * @var bool + */ + protected $usePagedResults; + + /** + * The name of the attribute used to unfold the result + * + * @var string + */ + protected $unfoldAttribute; + + /** + * This query's native LDAP filter + * + * @var string + */ + protected $nativeFilter; + + /** + * Only fetch the entry at the base of the search + */ + const SCOPE_BASE = 'base'; + + /** + * Fetch entries one below the base DN + */ + const SCOPE_ONE = 'one'; + + /** + * Fetch all entries below the base DN + */ + const SCOPE_SUB = 'sub'; + + /** + * All available scopes + * + * @var array + */ + public static $scopes = array( + LdapQuery::SCOPE_BASE, + LdapQuery::SCOPE_ONE, + LdapQuery::SCOPE_SUB + ); + + /** + * LDAP search scope (default: SCOPE_SUB) + * + * @var string + */ + protected $scope = LdapQuery::SCOPE_SUB; + + /** + * Initialize this query + */ + protected function init() + { + $this->usePagedResults = false; + } + + /** + * Set the base dn to be used for this query + * + * @param string $base + * + * @return $this + */ + public function setBase($base) + { + $this->base = $base; + return $this; + } + + /** + * Return the base dn being used for this query + * + * @return string + */ + public function getBase() + { + return $this->base; + } + + /** + * Set whether this query is permitted to utilize paged results + * + * @param bool $state + * + * @return $this + */ + public function setUsePagedResults($state = true) + { + $this->usePagedResults = (bool) $state; + return $this; + } + + /** + * Return whether this query is permitted to utilize paged results + * + * @return bool + */ + public function getUsePagedResults() + { + return $this->usePagedResults; + } + + /** + * Set the attribute to be used to unfold the result + * + * @param string $attributeName + * + * @return $this + */ + public function setUnfoldAttribute($attributeName) + { + $this->unfoldAttribute = $attributeName; + return $this; + } + + /** + * Return the attribute to use to unfold the result + * + * @return string + */ + public function getUnfoldAttribute() + { + return $this->unfoldAttribute; + } + + /** + * Set this query's native LDAP filter + * + * @param string $filter + * + * @return $this + */ + public function setNativeFilter($filter) + { + $this->nativeFilter = $filter; + return $this; + } + + /** + * Return this query's native LDAP filter + * + * @return string + */ + public function getNativeFilter() + { + return $this->nativeFilter; + } + + /** + * Choose an objectClass and the columns you are interested in + * + * {@inheritdoc} This creates an objectClass filter. + */ + public function from($target, array $fields = null) + { + $this->where('objectClass', $target); + return parent::from($target, $fields); + } + + public function where($condition, $value = null) + { + $this->addFilter(Filter::expression($condition, '=', $value)); + return $this; + } + + public function addFilter(Filter $filter) + { + $this->makeCaseInsensitive($filter); + return parent::addFilter($filter); + } + + public function setFilter(Filter $filter) + { + $this->makeCaseInsensitive($filter); + return parent::setFilter($filter); + } + + protected function makeCaseInsensitive(Filter $filter) + { + if ($filter->isExpression()) { + /** @var \Icinga\Data\Filter\FilterExpression $filter */ + $filter->setCaseSensitive(false); + } else { + /** @var \Icinga\Data\Filter\FilterChain $filter */ + foreach ($filter->filters() as $subFilter) { + $this->makeCaseInsensitive($subFilter); + } + } + } + + public function compare($a, $b, $orderIndex = 0) + { + if (array_key_exists($orderIndex, $this->order)) { + $column = $this->order[$orderIndex][0]; + $direction = $this->order[$orderIndex][1]; + + $flippedColumns = $this->flippedColumns ?: array_flip($this->columns); + if (array_key_exists($column, $flippedColumns) && is_string($flippedColumns[$column])) { + $column = $flippedColumns[$column]; + } + + if (is_array($a->$column)) { + // rfc2891 states: If a sort key is a multi-valued attribute, and an entry happens to + // have multiple values for that attribute and no other controls are + // present that affect the sorting order, then the server SHOULD use the + // least value (according to the ORDERING rule for that attribute). + $a = clone $a; + $a->$column = array_reduce($a->$column, function ($carry, $item) use ($direction) { + $result = $carry === null ? 0 : strcmp($item, $carry); + return ($direction === self::SORT_ASC ? $result : $result * -1) < 1 ? $item : $carry; + }); + } + + if (is_array($b->$column)) { + $b = clone $b; + $b->$column = array_reduce($b->$column, function ($carry, $item) use ($direction) { + $result = $carry === null ? 0 : strcmp($item, $carry); + return ($direction === self::SORT_ASC ? $result : $result * -1) < 1 ? $item : $carry; + }); + } + } + + return parent::compare($a, $b, $orderIndex); + } + + /** + * Fetch result as tree + * + * @return Root + * + * @todo This is untested waste, not being used anywhere and ignores the query's order and base dn. + * Evaluate whether it's reasonable to properly implement and test it. + */ + public function fetchTree() + { + $result = $this->fetchAll(); + $sorted = array(); + $quotedDn = preg_quote($this->ds->getDn(), '/'); + foreach ($result as $key => & $item) { + $new_key = LdapUtils::implodeDN( + array_reverse( + LdapUtils::explodeDN( + preg_replace('/,' . $quotedDn . '$/', '', $key) + ) + ) + ); + $sorted[$new_key] = $key; + } + + ksort($sorted); + + $tree = Root::forConnection($this->ds); + $root_dn = $tree->getDN(); + foreach ($sorted as $sort_key => & $key) { + if ($key === $root_dn) { + continue; + } + $tree->createChildByDN($key, $result[$key]); + } + return $tree; + } + + /** + * Fetch the distinguished name of the first result + * + * @return string|false The distinguished name or false in case it's not possible to fetch a result + * + * @throws LdapException In case the query returns multiple results + * (i.e. it's not possible to fetch a unique DN) + */ + public function fetchDn() + { + return $this->ds->fetchDn($this); + } + + /** + * Render and return this query's filter + * + * @return string + */ + public function renderFilter() + { + $filter = $this->ds->renderFilter($this->filter); + if ($this->nativeFilter) { + $filter = '(&(' . $this->nativeFilter . ')' . $filter . ')'; + } + + return $filter; + } + + /** + * Return the LDAP filter to be applied on this query + * + * @return string + */ + public function __toString() + { + return $this->renderFilter(); + } + + /** + * Get LDAP search scope + * + * @return string + */ + public function getScope() + { + return $this->scope; + } + + /** + * Set LDAP search scope + * + * Valid: sub one base (Default: sub) + * + * @param string $scope + * + * @return LdapQuery + * + * @throws LogicException If scope value is invalid + */ + public function setScope($scope) + { + if (! in_array($scope, static::$scopes)) { + throw new LogicException( + 'Can\'t set scope %d, it is is invalid. Use one of %s or LdapQuery\'s constants.', + $scope, + implode(', ', static::$scopes) + ); + } + $this->scope = $scope; + return $this; + } +} diff --git a/library/Icinga/Protocol/Ldap/LdapUtils.php b/library/Icinga/Protocol/Ldap/LdapUtils.php new file mode 100644 index 0000000..9c9ae10 --- /dev/null +++ b/library/Icinga/Protocol/Ldap/LdapUtils.php @@ -0,0 +1,148 @@ +<?php +/* Icinga Web 2 | (c) 2013 Icinga Development Team | GPLv2+ */ + +namespace Icinga\Protocol\Ldap; + +/** + * This class provides useful LDAP-related functions + * + * @copyright Copyright (c) 2013 Icinga-Web Team <info@icinga.com> + * @author Icinga-Web Team <info@icinga.com> + * @package Icinga\Protocol\Ldap + * @license http://www.gnu.org/copyleft/gpl.html GNU General Public License + */ +class LdapUtils +{ + /** + * Extends PHPs ldap_explode_dn() function + * + * UTF-8 chars like German umlauts would otherwise be escaped and shown + * as backslash-prefixed hexcode-sequenzes. + * + * @param string $dn DN + * @param boolean $with_type Returns 'type=value' when true and 'value' when false + * + * @return array + */ + public static function explodeDN($dn, $with_type = true) + { + $res = ldap_explode_dn($dn, $with_type ? 0 : 1); + + foreach ($res as $k => $v) { + $res[$k] = preg_replace_callback( + '/\\\([0-9a-f]{2})/i', + function ($m) { + return chr(hexdec($m[1])); + }, + $v + ); + } + unset($res['count']); + return $res; + } + + /** + * Implode unquoted RDNs to a DN + * + * TODO: throw away, this is not how it shall be done + * + * @param array $parts DN-component + * + * @return string + */ + public static function implodeDN($parts) + { + $str = ''; + foreach ($parts as $part) { + if ($str !== '') { + $str .= ','; + } + list($key, $val) = preg_split('~=~', $part, 2); + $str .= $key . '=' . self::quoteForDN($val); + } + return $str; + } + + /** + * Test if supplied value looks like a DN + * + * @param mixed $value + * + * @return bool + */ + public static function isDn($value) + { + if (is_string($value)) { + return ldap_dn2ufn($value) !== false; + } + return false; + } + + /** + * Quote a string that should be used in a DN + * + * Special characters will be escaped + * + * @param string $str DN-component + * + * @return string + */ + public static function quoteForDN($str) + { + return self::quoteChars( + $str, + array( + ',', + '=', + '+', + '<', + '>', + ';', + '\\', + '"', + '#' + ) + ); + } + + /** + * Quote a string that should be used in an LDAP search + * + * Special characters will be escaped + * + * @param string String to be escaped + * @param bool $allow_wildcard + * @return string + */ + public static function quoteForSearch($str, $allow_wildcard = false) + { + if ($allow_wildcard) { + return self::quoteChars($str, array('(', ')', '\\', chr(0))); + } + return self::quoteChars($str, array('*', '(', ')', '\\', chr(0))); + } + + /** + * Escape given characters in the given string + * + * Special characters will be escaped + * + * @param $str + * @param $chars + * @internal param String $string to be escaped + * @return string + */ + protected static function quoteChars($str, $chars) + { + $quotedChars = array(); + foreach ($chars as $k => $v) { + // Temporarily prefixing with illegal '(' + $quotedChars[$k] = '(' . str_pad(dechex(ord($v)), 2, '0'); + } + $str = str_replace($chars, $quotedChars, $str); + // Replacing temporary '(' with '\\'. This is a workaround, as + // str_replace behaves pretty strange with leading a backslash: + $str = preg_replace('~\(~', '\\', $str); + return $str; + } +} diff --git a/library/Icinga/Protocol/Ldap/Node.php b/library/Icinga/Protocol/Ldap/Node.php new file mode 100644 index 0000000..176f962 --- /dev/null +++ b/library/Icinga/Protocol/Ldap/Node.php @@ -0,0 +1,69 @@ +<?php +/* Icinga Web 2 | (c) 2013 Icinga Development Team | GPLv2+ */ + +namespace Icinga\Protocol\Ldap; + +/** + * This class represents an LDAP node object + * + * @copyright Copyright (c) 2013 Icinga-Web Team <info@icinga.com> + * @author Icinga-Web Team <info@icinga.com> + * @package Icinga\Protocol\Ldap + * @license http://www.gnu.org/copyleft/gpl.html GNU General Public License + */ +class Node extends Root +{ + /** + * @var LdapConnection + */ + protected $connection; + + /** + * @var + */ + protected $rdn; + + /** + * @var Root + */ + protected $parent; + + /** + * @param Root $parent + */ + protected function __construct(Root $parent) + { + $this->connection = $parent->getConnection(); + $this->parent = $parent; + } + + /** + * @param $parent + * @param $rdn + * @param array $props + * @return Node + */ + public static function createWithRDN($parent, $rdn, $props = array()) + { + $node = new Node($parent); + $node->rdn = $rdn; + $node->props = $props; + return $node; + } + + /** + * @return mixed + */ + public function getRDN() + { + return $this->rdn; + } + + /** + * @return mixed|string + */ + public function getDN() + { + return $this->getRDN() . ',' . $this->parent->getDN(); + } +} diff --git a/library/Icinga/Protocol/Ldap/Root.php b/library/Icinga/Protocol/Ldap/Root.php new file mode 100644 index 0000000..48d8719 --- /dev/null +++ b/library/Icinga/Protocol/Ldap/Root.php @@ -0,0 +1,242 @@ +<?php +/* Icinga Web 2 | (c) 2013 Icinga Development Team | GPLv2+ */ + +namespace Icinga\Protocol\Ldap; + +use Icinga\Exception\IcingaException; + +/** + * This class is a special node object, representing your connections root node + * + * @copyright Copyright (c) 2013 Icinga-Web Team <info@icinga.com> + * @author Icinga-Web Team <info@icinga.com> + * @package Icinga\Protocol\Ldap + * @license http://www.gnu.org/copyleft/gpl.html GNU General Public License + * @package Icinga\Protocol\Ldap + */ +class Root +{ + /** + * @var string + */ + protected $rdn; + + /** + * @var LdapConnection + */ + protected $connection; + + /** + * @var array + */ + protected $children = array(); + + /** + * @var array + */ + protected $props = array(); + + /** + * @param LdapConnection $connection + */ + protected function __construct(LdapConnection $connection) + { + $this->connection = $connection; + } + + /** + * @return bool + */ + public function hasParent() + { + return false; + } + + /** + * @param LdapConnection $connection + * @return Root + */ + public static function forConnection(LdapConnection $connection) + { + $root = new Root($connection); + return $root; + } + + /** + * @param $dn + * @param array $props + * @return Node + */ + public function createChildByDN($dn, $props = array()) + { + $dn = $this->stripMyDN($dn); + $parts = array_reverse(LdapUtils::explodeDN($dn)); + $parent = $this; + $child = null; + while ($rdn = array_shift($parts)) { + if ($parent->hasChildRDN($rdn)) { + $child = $parent->getChildByRDN($rdn); + } else { + $child = Node::createWithRDN($parent, $rdn, (array)$props); + $parent->addChild($child); + } + $parent = $child; + } + return $child; + } + + /** + * @param $rdn + * @return bool + */ + public function hasChildRDN($rdn) + { + return array_key_exists(strtolower($rdn), $this->children); + } + + /** + * @param $rdn + * @return mixed + * @throws IcingaException + */ + public function getChildByRDN($rdn) + { + if (!$this->hasChildRDN($rdn)) { + throw new IcingaException( + 'The child RDN "%s" is not available', + $rdn + ); + } + return $this->children[strtolower($rdn)]; + } + + /** + * @return array + */ + public function children() + { + return $this->children; + } + + public function countChildren() + { + return count($this->children); + } + + /** + * @return bool + */ + public function hasChildren() + { + return !empty($this->children); + } + + /** + * @param Node $child + * @return $this + */ + public function addChild(Node $child) + { + $this->children[strtolower($child->getRDN())] = $child; + return $this; + } + + /** + * @param $dn + * @return string + */ + protected function stripMyDN($dn) + { + $this->assertSubDN($dn); + return substr($dn, 0, strlen($dn) - strlen($this->getDN()) - 1); + } + + /** + * @param $dn + * @return $this + * @throws IcingaException + */ + protected function assertSubDN($dn) + { + $mydn = $this->getDN(); + $end = substr($dn, -1 * strlen($mydn)); + if (strtolower($end) !== strtolower($mydn)) { + throw new IcingaException( + '"%s" is not a child of "%s"', + $dn, + $mydn + ); + } + if (strlen($dn) === strlen($mydn)) { + throw new IcingaException( + '"%s" is not a child of "%s", they are equal', + $dn, + $mydn + ); + } + return $this; + } + + /** + * @param LdapConnection $connection + * @return $this + */ + public function setConnection(LdapConnection $connection) + { + $this->connection = $connection; + return $this; + } + + /** + * @return LdapConnection + */ + public function getConnection() + { + return $this->connection; + } + + /** + * @return bool + */ + public function hasBeenChanged() + { + return false; + } + + /** + * @return mixed + */ + public function getRDN() + { + return $this->getDN(); + } + + /** + * @return mixed + */ + public function getDN() + { + return $this->connection->getDn(); + } + + /** + * @param $key + * @return null + */ + public function __get($key) + { + if (!array_key_exists($key, $this->props)) { + return null; + } + return $this->props[$key]; + } + + /** + * @param $key + * @return bool + */ + public function __isset($key) + { + return array_key_exists($key, $this->props); + } +} diff --git a/library/Icinga/Protocol/Nrpe/Connection.php b/library/Icinga/Protocol/Nrpe/Connection.php new file mode 100644 index 0000000..491a965 --- /dev/null +++ b/library/Icinga/Protocol/Nrpe/Connection.php @@ -0,0 +1,111 @@ +<?php +/* Icinga Web 2 | (c) 2013 Icinga Development Team | GPLv2+ */ + +namespace Icinga\Protocol\Nrpe; + +use Icinga\Exception\IcingaException; + +class Connection +{ + protected $host; + protected $port; + protected $connection; + protected $use_ssl = false; + protected $lastReturnCode = null; + + public function __construct($host, $port = 5666) + { + $this->host = $host; + $this->port = $port; + } + + public function useSsl($use_ssl = true) + { + $this->use_ssl = $use_ssl; + return $this; + } + + public function sendCommand($command, $args = null) + { + if (! empty($args)) { + $command .= '!' . implode('!', $args); + } + + $packet = Packet::createQuery($command); + return $this->send($packet); + } + + public function getLastReturnCode() + { + return $this->lastReturnCode; + } + + public function send(Packet $packet) + { + $conn = $this->connection(); + $bytes = $packet->getBinary(); + fputs($conn, $bytes, strlen($bytes)); + // TODO: Check result checksum! + $result = fread($conn, 8192); + if ($result === false) { + throw new IcingaException('CHECK_NRPE: Error receiving data from daemon.'); + } elseif (strlen($result) === 0) { + throw new IcingaException( + 'CHECK_NRPE: Received 0 bytes from daemon. Check the remote server logs for error messages' + ); + } + // TODO: CHECK_NRPE: Receive underflow - only %d bytes received (%d expected) + $code = unpack('n', substr($result, 8, 2)); + $this->lastReturnCode = $code[1]; + $this->disconnect(); + return rtrim(substr($result, 10, -2)); + } + + protected function connect() + { + $ctx = stream_context_create(); + if ($this->use_ssl) { + // TODO: fail if not ok: + $res = stream_context_set_option($ctx, 'ssl', 'ciphers', 'ADH'); + $uri = sprintf('ssl://%s:%d', $this->host, $this->port); + } else { + $uri = sprintf('tcp://%s:%d', $this->host, $this->port); + } + $this->connection = @stream_socket_client( + $uri, + $errno, + $errstr, + 10, + STREAM_CLIENT_CONNECT, + $ctx + ); + if (! $this->connection) { + throw new IcingaException( + 'NRPE Connection failed: %s', + $errstr + ); + } + } + + protected function connection() + { + if ($this->connection === null) { + $this->connect(); + } + return $this->connection; + } + + protected function disconnect() + { + if (is_resource($this->connection)) { + fclose($this->connection); + $this->connection = null; + } + return $this; + } + + public function __destruct() + { + $this->disconnect(); + } +} diff --git a/library/Icinga/Protocol/Nrpe/Packet.php b/library/Icinga/Protocol/Nrpe/Packet.php new file mode 100644 index 0000000..54c8526 --- /dev/null +++ b/library/Icinga/Protocol/Nrpe/Packet.php @@ -0,0 +1,69 @@ +<?php +/* Icinga Web 2 | (c) 2013 Icinga Development Team | GPLv2+ */ + +namespace Icinga\Protocol\Nrpe; + +class Packet +{ + const QUERY = 0x01; + const RESPONSE = 0x02; + + protected $version = 0x02; + protected $type; + protected $body; + protected static $randomBytes; + + public function __construct($type, $body) + { + $this->type = $type; + $this->body = $body; + $this->regenerateRandomBytes(); + } + + // TODO: renew "from time to time" to allow long-running daemons + protected function regenerateRandomBytes() + { + self::$randomBytes = ''; + for ($i = 0; $i < 4096; $i++) { + self::$randomBytes .= pack('N', mt_rand()); + } + } + + public static function createQuery($body) + { + $packet = new Packet(self::QUERY, $body); + return $packet; + } + + protected function getFillString($length) + { + $max = strlen(self::$randomBytes) - $length; + return substr(self::$randomBytes, rand(0, $max), $length); + } + + // TODO: WTF is SR? And 2324? + public function getBinary() + { + $version = pack('n', $this->version); + $type = pack('n', $this->type); + $dummycrc = "\x00\x00\x00\x00"; + $result = "\x00\x00"; + $result = pack('n', 2324); + $body = $this->body + . "\x00" + . $this->getFillString(1023 - strlen($this->body)) + . 'SR'; + + $crc = pack( + 'N', + crc32($version . $type . $dummycrc . $result . $body) + ); + $bytes = $version . $type . $crc . $result . $body; + return $bytes; + } + + public function __toString() + { + return $this->body; + } +} diff --git a/library/Icinga/Repository/DbRepository.php b/library/Icinga/Repository/DbRepository.php new file mode 100644 index 0000000..3f8b604 --- /dev/null +++ b/library/Icinga/Repository/DbRepository.php @@ -0,0 +1,1078 @@ +<?php +/* Icinga Web 2 | (c) 2015 Icinga Development Team | GPLv2+ */ + +namespace Icinga\Repository; + +use Icinga\Exception\QueryException; +use Zend_Db_Expr; +use Icinga\Data\Db\DbConnection; +use Icinga\Data\Extensible; +use Icinga\Data\Filter\Filter; +use Icinga\Data\Filter\FilterExpression; +use Icinga\Data\Reducible; +use Icinga\Data\Updatable; +use Icinga\Exception\IcingaException; +use Icinga\Exception\ProgrammingError; +use Icinga\Exception\StatementException; +use Icinga\Util\StringHelper; + +/** + * Abstract base class for concrete database repository implementations + * + * Additionally provided features: + * <ul> + * <li>Support for table aliases</li> + * <li>Automatic table prefix handling</li> + * <li>Insert, update and delete capabilities</li> + * <li>Differentiation between statement and query columns</li> + * <li>Capability to join additional tables depending on the columns being selected or used in a filter</li> + * </ul> + * + * @method DbConnection getDataSource($table = null) + */ +abstract class DbRepository extends Repository implements Extensible, Updatable, Reducible +{ + /** + * The datasource being used + * + * @var DbConnection + */ + protected $ds; + + /** + * The table aliases being applied + * + * This must be initialized by repositories which are going to make use of table aliases. Every table for which + * aliased columns are provided must be defined in this array using its name as key and the alias being used as + * value. Failure to do so will result in invalid queries. + * + * @var array + */ + protected $tableAliases; + + /** + * The join probability rules + * + * This may be initialized by repositories which make use of the table join capability. It allows to define + * probability rules to enhance control how ambiguous column aliases are associated with the correct table. + * To define a rule use the name of a base table as key and another array of table names as probable join + * targets ordered by priority. (Ascending: Lower means higher priority) + * <code> + * array( + * 'table_name' => array('target1', 'target2', 'target3') + * ) + * </code> + * + * @todo Support for tree-ish rules + * + * @var array + */ + protected $joinProbabilities; + + /** + * The statement columns being provided + * + * This may be initialized by repositories which are going to make use of table aliases. It allows to provide + * alias-less column names to be used for a statement. The array needs to be in the following format: + * <code> + * array( + * 'table_name' => array( + * 'column1', + * 'alias1' => 'column2', + * 'alias2' => 'column3' + * ) + * ) + * </code> + * + * @var array + */ + protected $statementColumns; + + /** + * An array to map table names to statement columns/aliases + * + * @var array + */ + protected $statementAliasTableMap; + + /** + * A flattened array to map statement columns to aliases + * + * @var array + */ + protected $statementAliasColumnMap; + + /** + * An array to map table names to statement columns + * + * @var array + */ + protected $statementColumnTableMap; + + /** + * A flattened array to map aliases to statement columns + * + * @var array + */ + protected $statementColumnAliasMap; + + /** + * List of column names or aliases mapped to their table where the COLLATE SQL-instruction has been removed + * + * This list is being populated in case of a PostgreSQL backend only, + * to ensure case-insensitive string comparison in WHERE clauses. + * + * @var array + */ + protected $caseInsensitiveColumns; + + /** + * Create a new DB repository object + * + * In case $this->queryColumns has already been initialized, this initializes + * $this->caseInsensitiveColumns in case of a PostgreSQL connection. + * + * @param DbConnection $ds The datasource to use + */ + public function __construct(DbConnection $ds) + { + parent::__construct($ds); + + if ($ds->getDbType() === 'pgsql' && $this->queryColumns !== null) { + $this->queryColumns = $this->removeCollateInstruction($this->queryColumns); + } + } + + /** + * Return the query columns being provided + * + * Initializes $this->caseInsensitiveColumns in case of a PostgreSQL connection. + * + * @return array + */ + public function getQueryColumns() + { + if ($this->queryColumns === null) { + $this->queryColumns = parent::getQueryColumns(); + if ($this->ds->getDbType() === 'pgsql') { + $this->queryColumns = $this->removeCollateInstruction($this->queryColumns); + } + } + + return $this->queryColumns; + } + + /** + * Return the table aliases to be applied + * + * Calls $this->initializeTableAliases() in case $this->tableAliases is null. + * + * @return array + */ + public function getTableAliases() + { + if ($this->tableAliases === null) { + $this->tableAliases = $this->initializeTableAliases(); + } + + return $this->tableAliases; + } + + /** + * Overwrite this in your repository implementation in case you need to initialize the table aliases lazily + * + * @return array + */ + protected function initializeTableAliases() + { + return array(); + } + + /** + * Return the join probability rules + * + * Calls $this->initializeJoinProbabilities() in case $this->joinProbabilities is null. + * + * @return array + */ + public function getJoinProbabilities() + { + if ($this->joinProbabilities === null) { + $this->joinProbabilities = $this->initializeJoinProbabilities(); + } + + return $this->joinProbabilities; + } + + /** + * Overwrite this in your repository implementation in case you need to initialize the join probabilities lazily + * + * @return array + */ + protected function initializeJoinProbabilities() + { + return array(); + } + + /** + * Remove each COLLATE SQL-instruction from all given query columns + * + * @param array $queryColumns + * + * @return array $queryColumns, the updated version + */ + protected function removeCollateInstruction($queryColumns) + { + foreach ($queryColumns as $table => & $columns) { + foreach ($columns as $alias => & $column) { + // Using a regex here because COLLATE may occur anywhere in the string + $column = preg_replace('/ COLLATE .+$/', '', $column, -1, $count); + if ($count > 0) { + $this->caseInsensitiveColumns[$table][is_string($alias) ? $alias : $column] = true; + } + } + } + + return $queryColumns; + } + + /** + * Initialize table, column and alias maps + * + * @throws ProgrammingError In case $this->queryColumns does not provide any column information + */ + protected function initializeAliasMaps() + { + parent::initializeAliasMaps(); + + foreach ($this->aliasTableMap as $alias => $table) { + if ($table !== null) { + if (strpos($alias, '.') !== false) { + $prefixedAlias = str_replace('.', '_', $alias); + } else { + $prefixedAlias = $table . '_' . $alias; + } + + if (array_key_exists($prefixedAlias, $this->aliasTableMap)) { + if ($this->aliasTableMap[$prefixedAlias] !== null) { + $existingTable = $this->aliasTableMap[$prefixedAlias]; + $existingColumn = $this->aliasColumnMap[$prefixedAlias]; + $this->aliasTableMap[$existingTable . '.' . $prefixedAlias] = $existingTable; + $this->aliasColumnMap[$existingTable . '.' . $prefixedAlias] = $existingColumn; + $this->aliasTableMap[$prefixedAlias] = null; + $this->aliasColumnMap[$prefixedAlias] = null; + } + + $this->aliasTableMap[$table . '.' . $prefixedAlias] = $table; + $this->aliasColumnMap[$table . '.' . $prefixedAlias] = $this->aliasColumnMap[$alias]; + } else { + $this->aliasTableMap[$prefixedAlias] = $table; + $this->aliasColumnMap[$prefixedAlias] = $this->aliasColumnMap[$alias]; + } + } + } + } + + /** + * Return the given table with the datasource's prefix being prepended + * + * @param array|string $table + * + * @return array|string + * + * @throws IcingaException In case $table is not of a supported type + */ + protected function prependTablePrefix($table) + { + $prefix = $this->ds->getTablePrefix(); + if (! $prefix) { + return $table; + } + + if (is_array($table)) { + foreach ($table as & $tableName) { + if (strpos($tableName, $prefix) === false) { + $tableName = $prefix . $tableName; + } + } + } elseif (is_string($table)) { + $table = (strpos($table, $prefix) === false ? $prefix : '') . $table; + } else { + throw new IcingaException('Table prefix handling for type "%s" is not supported', gettype($table)); + } + + return $table; + } + + /** + * Remove the datasource's prefix from the given table name and return the remaining part + * + * @param array|string $table + * + * @return array|string + * + * @throws IcingaException In case $table is not of a supported type + */ + protected function removeTablePrefix($table) + { + $prefix = $this->ds->getTablePrefix(); + if (! $prefix) { + return $table; + } + + if (is_array($table)) { + foreach ($table as & $tableName) { + if (strpos($tableName, $prefix) === 0) { + $tableName = str_replace($prefix, '', $tableName); + } + } + } elseif (is_string($table)) { + if (strpos($table, $prefix) === 0) { + $table = str_replace($prefix, '', $table); + } + } else { + throw new IcingaException('Table prefix handling for type "%s" is not supported', gettype($table)); + } + + return $table; + } + + /** + * Return the given table with its alias being applied + * + * @param array|string $table + * @param string $virtualTable + * + * @return array|string + */ + protected function applyTableAlias($table, $virtualTable = null) + { + if (! is_array($table)) { + $tableAliases = $this->getTableAliases(); + if ($virtualTable !== null && isset($tableAliases[$virtualTable])) { + return array($tableAliases[$virtualTable] => $table); + } + + if (isset($tableAliases[($nonPrefixedTable = $this->removeTablePrefix($table))])) { + return array($tableAliases[$nonPrefixedTable] => $table); + } + } + + return $table; + } + + /** + * Return the given table with its alias being cleared + * + * @param array|string $table + * + * @return string + * + * @throws IcingaException In case $table is not of a supported type + */ + protected function clearTableAlias($table) + { + if (is_string($table)) { + return $table; + } + + if (is_array($table)) { + return reset($table); + } + + throw new IcingaException('Table alias handling for type "%s" is not supported', type($table)); + } + + /** + * Insert a table row with the given data + * + * Note that the base implementation does not perform any quoting on the $table argument. + * Pass an array with a column name (the same as in $bind) and a PDO::PARAM_* constant as value + * as third parameter $types to define a different type than string for a particular column. + * + * @param string $table + * @param array $bind + * @param array $types + * + * @return int The number of affected rows + */ + public function insert($table, array $bind, array $types = array()) + { + $realTable = $this->clearTableAlias($this->requireTable($table)); + + foreach ($types as $alias => $type) { + unset($types[$alias]); + $types[$this->requireStatementColumn($table, $alias)] = $type; + } + + return $this->ds->insert($realTable, $this->requireStatementColumns($table, $bind), $types); + } + + /** + * Update table rows with the given data, optionally limited by using a filter + * + * Note that the base implementation does not perform any quoting on the $table argument. + * Pass an array with a column name (the same as in $bind) and a PDO::PARAM_* constant as value + * as fourth parameter $types to define a different type than string for a particular column. + * + * @param string $table + * @param array $bind + * @param Filter $filter + * @param array $types + * + * @return int The number of affected rows + */ + public function update($table, array $bind, Filter $filter = null, array $types = array()) + { + $realTable = $this->clearTableAlias($this->requireTable($table)); + + if ($filter) { + $filter = $this->requireFilter($table, $filter); + } + + foreach ($types as $alias => $type) { + unset($types[$alias]); + $types[$this->requireStatementColumn($table, $alias)] = $type; + } + + return $this->ds->update($realTable, $this->requireStatementColumns($table, $bind), $filter, $types); + } + + /** + * Delete table rows, optionally limited by using a filter + * + * @param string $table + * @param Filter $filter + * + * @return int The number of affected rows + */ + public function delete($table, Filter $filter = null) + { + $realTable = $this->clearTableAlias($this->requireTable($table)); + + if ($filter) { + $filter = $this->requireFilter($table, $filter); + } + + return $this->ds->delete($realTable, $filter); + } + + /** + * Return the statement columns being provided + * + * Calls $this->initializeStatementColumns() in case $this->statementColumns is null. + * + * @return array + */ + public function getStatementColumns() + { + if ($this->statementColumns === null) { + $this->statementColumns = $this->initializeStatementColumns(); + } + + return $this->statementColumns; + } + + /** + * Overwrite this in your repository implementation in case you need to initialize the statement columns lazily + * + * @return array + */ + protected function initializeStatementColumns() + { + return array(); + } + + /** + * Return an array to map table names to statement columns/aliases + * + * @return array + */ + protected function getStatementAliasTableMap() + { + if ($this->statementAliasTableMap === null) { + $this->initializeStatementMaps(); + } + + return $this->statementAliasTableMap; + } + + /** + * Return a flattened array to map statement columns to aliases + * + * @return array + */ + protected function getStatementAliasColumnMap() + { + if ($this->statementAliasColumnMap === null) { + $this->initializeStatementMaps(); + } + + return $this->statementAliasColumnMap; + } + + /** + * Return an array to map table names to statement columns + * + * @return array + */ + protected function getStatementColumnTableMap() + { + if ($this->statementColumnTableMap === null) { + $this->initializeStatementMaps(); + } + + return $this->statementColumnTableMap; + } + + /** + * Return a flattened array to map aliases to statement columns + * + * @return array + */ + protected function getStatementColumnAliasMap() + { + if ($this->statementColumnAliasMap === null) { + $this->initializeStatementMaps(); + } + + return $this->statementColumnAliasMap; + } + + /** + * Initialize $this->statementAliasTableMap and $this->statementAliasColumnMap + */ + protected function initializeStatementMaps() + { + $this->statementAliasTableMap = array(); + $this->statementAliasColumnMap = array(); + $this->statementColumnTableMap = array(); + $this->statementColumnAliasMap = array(); + foreach ($this->getStatementColumns() as $table => $columns) { + foreach ($columns as $alias => $column) { + $key = is_string($alias) ? $alias : $column; + if (array_key_exists($key, $this->statementAliasTableMap)) { + if ($this->statementAliasTableMap[$key] !== null) { + $existingTable = $this->statementAliasTableMap[$key]; + $existingColumn = $this->statementAliasColumnMap[$key]; + $this->statementAliasTableMap[$existingTable . '.' . $key] = $existingTable; + $this->statementAliasColumnMap[$existingTable . '.' . $key] = $existingColumn; + $this->statementAliasTableMap[$key] = null; + $this->statementAliasColumnMap[$key] = null; + } + + $this->statementAliasTableMap[$table . '.' . $key] = $table; + $this->statementAliasColumnMap[$table . '.' . $key] = $column; + } else { + $this->statementAliasTableMap[$key] = $table; + $this->statementAliasColumnMap[$key] = $column; + } + + if (array_key_exists($column, $this->statementColumnTableMap)) { + if ($this->statementColumnTableMap[$column] !== null) { + $existingTable = $this->statementColumnTableMap[$column]; + $existingAlias = $this->statementColumnAliasMap[$column]; + $this->statementColumnTableMap[$existingTable . '.' . $column] = $existingTable; + $this->statementColumnAliasMap[$existingTable . '.' . $column] = $existingAlias; + $this->statementColumnTableMap[$column] = null; + $this->statementColumnAliasMap[$column] = null; + } + + $this->statementColumnTableMap[$table . '.' . $column] = $table; + $this->statementColumnAliasMap[$table . '.' . $column] = $key; + } else { + $this->statementColumnTableMap[$column] = $table; + $this->statementColumnAliasMap[$column] = $key; + } + } + } + } + + /** + * Return whether this repository is capable of converting values for the given table and optional column + * + * This does not check whether any conversion for the given table is available if $column is not given, as it + * may be possible that columns from another table where joined in which would otherwise not being converted. + * + * @param string $table + * @param string $column + * + * @return bool + */ + public function providesValueConversion($table, $column = null) + { + if ($column !== null) { + if ($column instanceof Zend_Db_Expr) { + return false; + } + + if ($this->validateQueryColumnAssociation($table, $column)) { + return parent::providesValueConversion($table, $column); + } + + if (($tableName = $this->findTableName($column, $table))) { + return parent::providesValueConversion($tableName, $column); + } + + return false; + } + + $conversionRules = $this->getConversionRules(); + return !empty($conversionRules); + } + + /** + * Return the name of the conversion method for the given alias or column name and context + * + * If a query column or a filter column, which is part of a query filter, needs to be converted, + * you'll need to pass $query, otherwise the column is considered a statement column. + * + * @param string $table The datasource's table + * @param string $name The alias or column name for which to return a conversion method + * @param string $context The context of the conversion: persist or retrieve + * @param RepositoryQuery $query If given the column is considered a query column, + * statement column otherwise + * + * @return string + * + * @throws ProgrammingError In case a conversion rule is found but not any conversion method + */ + protected function getConverter($table, $name, $context, RepositoryQuery $query = null) + { + if ($name instanceof Zend_Db_Expr) { + return; + } + + if (! ($query !== null && $this->validateQueryColumnAssociation($table, $name)) + && !($query === null && $this->validateStatementColumnAssociation($table, $name)) + ) { + $table = $this->findTableName($name, $table); + if (! $table) { + if ($query !== null) { + // It may be an aliased Zend_Db_Expr + $desiredColumns = $query->getColumns(); + if (isset($desiredColumns[$name]) && $desiredColumns[$name] instanceof Zend_Db_Expr) { + return; + } + } + + throw new ProgrammingError('Column name validation seems to have failed. Did you require the column?'); + } + } + + return parent::getConverter($table, $name, $context, $query); + } + + /** + * Validate that the requested table exists + * + * This will prepend the datasource's table prefix and will apply the table's alias, if any. + * + * @param string $table The table to validate + * @param RepositoryQuery $query An optional query to pass as context + * (unused by the base implementation) + * + * @return array|string + * + * @throws ProgrammingError In case the given table does not exist + */ + public function requireTable($table, RepositoryQuery $query = null) + { + $virtualTable = null; + $statementColumns = $this->getStatementColumns(); + if (! isset($statementColumns[$table])) { + $newTable = parent::requireTable($table); + if ($newTable !== $table) { + $virtualTable = $table; + } + + $table = $newTable; + } else { + $virtualTables = $this->getVirtualTables(); + if (isset($virtualTables[$table])) { + $virtualTable = $table; + $table = $virtualTables[$table]; + } + } + + return $this->prependTablePrefix($this->applyTableAlias($table, $virtualTable)); + } + + /** + * Return the alias for the given table or null if none has been defined + * + * @param string $table + * + * @return string|null + */ + public function resolveTableAlias($table) + { + $tableAliases = $this->getTableAliases(); + if (isset($tableAliases[$table])) { + return $tableAliases[$table]; + } + } + + /** + * Return the alias for the given query column name or null in case the query column name does not exist + * + * @param string $table + * @param string $column + * + * @return string|null + */ + public function reassembleQueryColumnAlias($table, $column) + { + $alias = parent::reassembleQueryColumnAlias($table, $column); + if ($alias === null + && !$this->validateQueryColumnAssociation($table, $column) + && ($tableName = $this->findTableName($column, $table)) + ) { + return parent::reassembleQueryColumnAlias($tableName, $column); + } + + return $alias; + } + + /** + * Validate that the given column is a valid query target and return it or the actual name if it's an alias + * + * Attempts to join the given column from a different table if its association to the given table cannot be + * verified. + * + * @param string $table The table where to look for the column or alias + * @param string $name The name or alias of the column to validate + * @param RepositoryQuery $query An optional query to pass as context, + * if not given no join will be attempted + * + * @return string The given column's name + * + * @throws QueryException In case the given column is not a valid query column + * @throws ProgrammingError In case the given column is not found in $table and cannot be joined in + */ + public function requireQueryColumn($table, $name, RepositoryQuery $query = null) + { + if ($name instanceof Zend_Db_Expr) { + return $name; + } + + if ($query === null || $this->validateQueryColumnAssociation($table, $name)) { + return parent::requireQueryColumn($table, $name, $query); + } + + $column = $this->joinColumn($name, $table, $query); + if ($column === null) { + if ($query !== null) { + // It may be an aliased Zend_Db_Expr + $desiredColumns = $query->getColumns(); + if (isset($desiredColumns[$name]) && $desiredColumns[$name] instanceof Zend_Db_Expr) { + $column = $desiredColumns[$name]; + } + } + + if ($column === null) { + throw new ProgrammingError( + 'Unable to find a valid table for column "%s" to join into "%s"', + $name, + $table + ); + } + } + + return $column; + } + + /** + * Validate that the given column is a valid filter target and return it or the actual name if it's an alias + * + * Attempts to join the given column from a different table if its association to the given table cannot be + * verified. In case of a PostgreSQL connection and if a COLLATE SQL-instruction is part of the resolved column, + * this applies LOWER() on the column and, if given, strtolower() on the filter's expression. + * + * @param string $table The table where to look for the column or alias + * @param string $name The name or alias of the column to validate + * @param RepositoryQuery $query An optional query to pass as context, + * if not given the column is considered being used for a statement filter + * @param FilterExpression $filter An optional filter to pass as context + * + * @return string The given column's name + * + * @throws QueryException In case the given column is not a valid filter column + * @throws ProgrammingError In case the given column is not found in $table and cannot be joined in + */ + public function requireFilterColumn($table, $name, RepositoryQuery $query = null, FilterExpression $filter = null) + { + if ($name instanceof Zend_Db_Expr) { + return $name; + } + + $joined = false; + if ($query === null) { + $column = $this->requireStatementColumn($table, $name); + } elseif ($this->validateQueryColumnAssociation($table, $name)) { + $column = parent::requireFilterColumn($table, $name, $query, $filter); + } else { + $column = $this->joinColumn($name, $table, $query); + if ($column === null) { + if ($query !== null) { + // It may be an aliased Zend_Db_Expr + $desiredColumns = $query->getColumns(); + if (isset($desiredColumns[$name]) && $desiredColumns[$name] instanceof Zend_Db_Expr) { + $column = $desiredColumns[$name]; + } + } + + if ($column === null) { + throw new ProgrammingError( + 'Unable to find a valid table for column "%s" to join into "%s"', + $name, + $table + ); + } + } else { + $joined = true; + } + } + + if (! empty($this->caseInsensitiveColumns)) { + if ($joined) { + $table = $this->findTableName($name, $table); + } + + if ($column === $name) { + if ($query === null) { + $name = $this->reassembleStatementColumnAlias($table, $name); + } else { + $name = $this->reassembleQueryColumnAlias($table, $name); + } + } + + if (isset($this->caseInsensitiveColumns[$table][$name])) { + $column = 'LOWER(' . $column . ')'; + if ($filter !== null) { + $expression = $filter->getExpression(); + if (is_array($expression)) { + $filter->setExpression(array_map('strtolower', $expression)); + } else { + $filter->setExpression(strtolower($expression)); + } + } + } + } + + return $column; + } + + /** + * Return the statement column name for the given alias or null in case the alias does not exist + * + * @param string $table + * @param string $alias + * + * @return string|null + */ + public function resolveStatementColumnAlias($table, $alias) + { + $statementAliasColumnMap = $this->getStatementAliasColumnMap(); + if (isset($statementAliasColumnMap[$alias])) { + return $statementAliasColumnMap[$alias]; + } + + $prefixedAlias = $table . '.' . $alias; + if (isset($statementAliasColumnMap[$prefixedAlias])) { + return $statementAliasColumnMap[$prefixedAlias]; + } + } + + /** + * Return the alias for the given statement column name or null in case the statement column does not exist + * + * @param string $table + * @param string $column + * + * @return string|null + */ + public function reassembleStatementColumnAlias($table, $column) + { + $statementColumnAliasMap = $this->getStatementColumnAliasMap(); + if (isset($statementColumnAliasMap[$column])) { + return $statementColumnAliasMap[$column]; + } + + $prefixedColumn = $table . '.' . $column; + if (isset($statementColumnAliasMap[$prefixedColumn])) { + return $statementColumnAliasMap[$prefixedColumn]; + } + } + + /** + * Return whether the given alias or statement column name is available in the given table + * + * @param string $table + * @param string $alias + * + * @return bool + */ + public function validateStatementColumnAssociation($table, $alias) + { + $statementAliasTableMap = $this->getStatementAliasTableMap(); + if (isset($statementAliasTableMap[$alias])) { + return $statementAliasTableMap[$alias] === $table; + } + + $prefixedAlias = $table . '.' . $alias; + if (isset($statementAliasTableMap[$prefixedAlias])) { + return true; + } + + $statementColumnTableMap = $this->getStatementColumnTableMap(); + if (isset($statementColumnTableMap[$alias])) { + return $statementColumnTableMap[$alias] === $table; + } + + return isset($statementColumnTableMap[$prefixedAlias]); + } + + /** + * Return whether the given column name or alias of the given table is a valid statement column + * + * @param string $table The table where to look for the column or alias + * @param string $name The column name or alias to check + * + * @return bool + */ + public function hasStatementColumn($table, $name) + { + if (($this->resolveStatementColumnAlias($table, $name) === null + && $this->reassembleStatementColumnAlias($table, $name) === null) + || !$this->validateStatementColumnAssociation($table, $name) + ) { + return parent::hasStatementColumn($table, $name); + } + + return true; + } + + /** + * Validate that the given column is a valid statement column and return it or the actual name if it's an alias + * + * @param string $table The table for which to require the column + * @param string $name The name or alias of the column to validate + * + * @return string The given column's name + * + * @throws StatementException In case the given column is not a statement column + */ + public function requireStatementColumn($table, $name) + { + if (($column = $this->resolveStatementColumnAlias($table, $name)) !== null) { + $alias = $name; + } elseif (($alias = $this->reassembleStatementColumnAlias($table, $name)) !== null) { + $column = $name; + } else { + return parent::requireStatementColumn($table, $name); + } + + if (! $this->validateStatementColumnAssociation($table, $alias)) { + throw new StatementException('Statement column "%s" not found in table "%s"', $name, $table); + } + + return $column; + } + + /** + * Join alias or column $name into $table using $query + * + * Attempts to find a valid table for the given alias or column name and a method labelled join<TableName> + * to process the actual join logic. If neither of those is found, null is returned. + * The method is called with the same parameters but in reversed order. + * + * @param string $name The alias or column name to join into $target + * @param string $target The table to join $name into + * @param RepositoryQuery $query The query to apply the JOIN-clause on + * + * @return string|null The resolved alias or $name, null if no join logic is found + */ + public function joinColumn($name, $target, RepositoryQuery $query) + { + if (! ($tableName = $this->findTableName($name, $target))) { + return; + } + + if (($column = $this->resolveQueryColumnAlias($tableName, $name)) === null) { + $column = $name; + } + + if (($joinIdentifier = $this->resolveTableAlias($tableName)) === null) { + $joinIdentifier = $this->prependTablePrefix($tableName); + } + if ($query->getQuery()->hasJoinedTable($joinIdentifier)) { + return $column; + } + + $joinMethod = 'join' . StringHelper::cname($tableName); + if (! method_exists($this, $joinMethod)) { + throw new ProgrammingError( + 'Unable to join table "%s" into "%s". Method "%s" not found', + $tableName, + $target, + $joinMethod + ); + } + + $this->$joinMethod($query, $target, $name); + return $column; + } + + /** + * Return the table name for the given alias or column name + * + * @param string $column The alias or column name + * @param string $origin The base table of a SELECT query + * + * @return string|null null in case no table is found + */ + protected function findTableName($column, $origin) + { + // First, try to produce an exact match since it's faster and cheaper + $aliasTableMap = $this->getAliasTableMap(); + if (isset($aliasTableMap[$column])) { + $table = $aliasTableMap[$column]; + } else { + $columnTableMap = $this->getColumnTableMap(); + if (isset($columnTableMap[$column])) { + $table = $columnTableMap[$column]; + } + } + + // But only return it if it's a probable join... + $joinProbabilities = $this->getJoinProbabilities(); + if (isset($joinProbabilities[$origin])) { + $probableJoins = $joinProbabilities[$origin]; + } + + // ...if probability can be determined + if (isset($table) && (empty($probableJoins) || in_array($table, $probableJoins, true))) { + return $table; + } + + // Without a proper exact match, there is only one fast and cheap way to find a suitable table.. + if (! empty($probableJoins)) { + foreach ($probableJoins as $table) { + if (isset($aliasTableMap[$table . '.' . $column])) { + return $table; + } + } + } + + // Last chance to find a table. Though, this usually ends up with a QueryException.. + foreach ($aliasTableMap as $prefixedAlias => $table) { + if (strpos($prefixedAlias, '.') !== false) { + list($_, $alias) = explode('.', $prefixedAlias, 2); + if ($alias === $column) { + return $table; + } + } + } + } +} diff --git a/library/Icinga/Repository/IniRepository.php b/library/Icinga/Repository/IniRepository.php new file mode 100644 index 0000000..2519d03 --- /dev/null +++ b/library/Icinga/Repository/IniRepository.php @@ -0,0 +1,418 @@ +<?php +/* Icinga Web 2 | (c) 2015 Icinga Development Team | GPLv2+ */ + +namespace Icinga\Repository; + +use Exception; +use Icinga\Application\Config; +use Icinga\Data\ConfigObject; +use Icinga\Data\Extensible; +use Icinga\Data\Filter\Filter; +use Icinga\Data\Updatable; +use Icinga\Data\Reducible; +use Icinga\Exception\ProgrammingError; +use Icinga\Exception\StatementException; + +/** + * Abstract base class for concrete INI repository implementations + * + * Additionally provided features: + * <ul> + * <li>Insert, update and delete capabilities</li> + * <li>Triggers for inserts, updates and deletions</li> + * <li>Lazy initialization of table specific configs</li> + * </ul> + */ +abstract class IniRepository extends Repository implements Extensible, Updatable, Reducible +{ + /** + * The configuration files used as table specific datasources + * + * This must be initialized by concrete repository implementations, in the following format + * <code> + * array( + * 'table_name' => array( + * 'name' => 'name_of_the_ini_file_without_extension', + * 'keyColumn' => 'the_name_of_the_column_to_use_as_key_column', + * ['module' => 'the_name_of_the_module_if_any'] + * ) + * ) + * </code> + * + * @var array + */ + protected $configs; + + /** + * The tables for which triggers are available when inserting, updating or deleting rows + * + * This may be initialized by concrete repository implementations and describes for which table names triggers + * are available. The repository attempts to find a method depending on the type of event and table for which + * to run the trigger. The name of such a method is expected to be declared using lowerCamelCase. + * (e.g. group_membership will be translated to onUpdateGroupMembership and groupmembership will be translated + * to onUpdateGroupmembership) The available events are onInsert, onUpdate and onDelete. + * + * @var array + */ + protected $triggers; + + /** + * Create a new INI repository object + * + * @param Config|null $ds The data source to use + * + * @throws ProgrammingError In case the given data source does not provide a valid key column + */ + public function __construct(Config $ds = null) + { + parent::__construct($ds); // First! Due to init(). + + if ($ds !== null && !$ds->getConfigObject()->getKeyColumn()) { + throw new ProgrammingError('INI repositories require their data source to provide a valid key column'); + } + } + + /** + * {@inheritDoc} + * + * @return Config + */ + public function getDataSource($table = null) + { + if ($this->ds !== null) { + return parent::getDataSource($table); + } + + $table = $table ?: $this->getBaseTable(); + $configs = $this->getConfigs(); + if (! isset($configs[$table])) { + throw new ProgrammingError('Config for table "%s" missing', $table); + } elseif (! $configs[$table] instanceof Config) { + $configs[$table] = $this->createConfig($configs[$table], $table); + } + + if (! $configs[$table]->getConfigObject()->getKeyColumn()) { + throw new ProgrammingError( + 'INI repositories require their data source to provide a valid key column' + ); + } + + return $configs[$table]; + } + + /** + * Return the configuration files used as table specific datasources + * + * Calls $this->initializeConfigs() in case $this->configs is null. + * + * @return array + */ + public function getConfigs() + { + if ($this->configs === null) { + $this->configs = $this->initializeConfigs(); + } + + return $this->configs; + } + + /** + * Overwrite this in your repository implementation in case you need to initialize the configs lazily + * + * @return array + */ + protected function initializeConfigs() + { + return array(); + } + + /** + * Return the tables for which triggers are available when inserting, updating or deleting rows + * + * Calls $this->initializeTriggers() in case $this->triggers is null. + * + * @return array + */ + public function getTriggers() + { + if ($this->triggers === null) { + $this->triggers = $this->initializeTriggers(); + } + + return $this->triggers; + } + + /** + * Overwrite this in your repository implementation in case you need to initialize the triggers lazily + * + * @return array + */ + protected function initializeTriggers() + { + return array(); + } + + /** + * Run a trigger for the given table and row which is about to be inserted + * + * @param string $table + * @param ConfigObject $new + * + * @return ConfigObject + */ + public function onInsert($table, ConfigObject $new) + { + $trigger = $this->getTrigger($table, 'onInsert'); + if ($trigger !== null) { + $row = $this->$trigger($new); + if ($row !== null) { + $new = $row; + } + } + + return $new; + } + + /** + * Run a trigger for the given table and row which is about to be updated + * + * @param string $table + * @param ConfigObject $old + * @param ConfigObject $new + * + * @return ConfigObject + */ + public function onUpdate($table, ConfigObject $old, ConfigObject $new) + { + $trigger = $this->getTrigger($table, 'onUpdate'); + if ($trigger !== null) { + $row = $this->$trigger($old, $new); + if ($row !== null) { + $new = $row; + } + } + + return $new; + } + + /** + * Run a trigger for the given table and row which has been deleted + * + * @param string $table + * @param ConfigObject $old + */ + public function onDelete($table, ConfigObject $old) + { + $trigger = $this->getTrigger($table, 'onDelete'); + if ($trigger !== null) { + $this->$trigger($old); + } + } + + /** + * Return the name of the trigger method for the given table and event-type + * + * @param string $table The table name for which to return a trigger method + * @param string $event The name of the event type + * + * @return ?string + */ + protected function getTrigger($table, $event) + { + if (! in_array($table, $this->getTriggers())) { + return; + } + + $identifier = join('', array_map('ucfirst', explode('_', $table))); + if (method_exists($this, $event . $identifier)) { + return $event . $identifier; + } + } + + /** + * Insert the given data for the given target + * + * $data must provide a proper value for the data source's key column. + * + * @param string $target + * @param array $data + * + * @throws StatementException In case the operation has failed + */ + public function insert($target, array $data) + { + $ds = $this->getDataSource($target); + $newData = $this->requireStatementColumns($target, $data); + + $config = $this->onInsert($target, new ConfigObject($newData)); + $section = $this->extractSectionName($config, $ds->getConfigObject()->getKeyColumn()); + + if ($ds->hasSection($section)) { + throw new StatementException(t('Cannot insert. Section "%s" does already exist'), $section); + } + + $ds->setSection($section, $config); + + try { + $ds->saveIni(); + } catch (Exception $e) { + throw new StatementException(t('Failed to insert. An error occurred: %s'), $e->getMessage()); + } + } + + /** + * Update the target with the given data and optionally limit the affected entries by using a filter + * + * @param string $target + * @param array $data + * @param Filter $filter + * + * @throws StatementException In case the operation has failed + */ + public function update($target, array $data, Filter $filter = null) + { + $ds = $this->getDataSource($target); + $newData = $this->requireStatementColumns($target, $data); + + $keyColumn = $ds->getConfigObject()->getKeyColumn(); + if ($filter === null && isset($newData[$keyColumn])) { + throw new StatementException( + t('Cannot update. Column "%s" holds a section\'s name which must be unique'), + $keyColumn + ); + } + + $query = $ds->select(); + if ($filter !== null) { + $query->addFilter($this->requireFilter($target, $filter)); + } + + /** @var ConfigObject $config */ + $newSection = null; + foreach ($query as $section => $config) { + if ($newSection !== null) { + throw new StatementException( + t('Cannot update. Column "%s" holds a section\'s name which must be unique'), + $keyColumn + ); + } + + $newConfig = clone $config; + foreach ($newData as $column => $value) { + if ($column === $keyColumn) { + if ($value !== $config->get($keyColumn)) { + $newSection = $value; + } + } else { + $newConfig->$column = $value; + } + } + + // This is necessary as the query result set contains the key column. + unset($newConfig->$keyColumn); + + if ($newSection) { + if ($ds->hasSection($newSection)) { + throw new StatementException(t('Cannot update. Section "%s" does already exist'), $newSection); + } + + $ds->removeSection($section)->setSection( + $newSection, + $this->onUpdate($target, $config, $newConfig) + ); + } else { + $ds->setSection( + $section, + $this->onUpdate($target, $config, $newConfig) + ); + } + } + + try { + $ds->saveIni(); + } catch (Exception $e) { + throw new StatementException(t('Failed to update. An error occurred: %s'), $e->getMessage()); + } + } + + /** + * Delete entries in the given target, optionally limiting the affected entries by using a filter + * + * @param string $target + * @param Filter $filter + * + * @throws StatementException In case the operation has failed + */ + public function delete($target, Filter $filter = null) + { + $ds = $this->getDataSource($target); + + $query = $ds->select(); + if ($filter !== null) { + $query->addFilter($this->requireFilter($target, $filter)); + } + + /** @var ConfigObject $config */ + foreach ($query as $section => $config) { + $ds->removeSection($section); + $this->onDelete($target, $config); + } + + try { + $ds->saveIni(); + } catch (Exception $e) { + throw new StatementException(t('Failed to delete. An error occurred: %s'), $e->getMessage()); + } + } + + /** + * Create and return a Config for the given meta and table + * + * @param array $meta + * @param string $table + * + * @return Config + * + * @throws ProgrammingError In case the given meta is invalid + */ + protected function createConfig(array $meta, $table) + { + if (! isset($meta['name'])) { + throw new ProgrammingError('Config file name missing for table "%s"', $table); + } elseif (! isset($meta['keyColumn'])) { + throw new ProgrammingError('Config key column name missing for table "%s"', $table); + } + + if (isset($meta['module'])) { + $config = Config::module($meta['module'], $meta['name']); + } else { + $config = Config::app($meta['name']); + } + + $config->getConfigObject()->setKeyColumn($meta['keyColumn']); + return $config; + } + + /** + * Extract and return the section name off of the given $config + * + * @param array|ConfigObject $config + * @param string $keyColumn + * + * @return string + * + * @throws ProgrammingError In case no valid section name is available + */ + protected function extractSectionName(&$config, $keyColumn) + { + if (! is_array($config) && !$config instanceof ConfigObject) { + throw new ProgrammingError('$config is neither an array nor a ConfigObject'); + } elseif (! isset($config[$keyColumn])) { + throw new ProgrammingError('$config does not provide a value for key column "%s"', $keyColumn); + } + + $section = $config[$keyColumn]; + unset($config[$keyColumn]); + return $section; + } +} diff --git a/library/Icinga/Repository/LdapRepository.php b/library/Icinga/Repository/LdapRepository.php new file mode 100644 index 0000000..af3cf00 --- /dev/null +++ b/library/Icinga/Repository/LdapRepository.php @@ -0,0 +1,71 @@ +<?php +/* Icinga Web 2 | (c) 2015 Icinga Development Team | GPLv2+ */ + +namespace Icinga\Repository; + +use Icinga\Protocol\Ldap\LdapConnection; + +/** + * Abstract base class for concrete LDAP repository implementations + * + * Additionally provided features: + * <ul> + * <li>Attribute name normalization</li> + * </ul> + */ +abstract class LdapRepository extends Repository +{ + /** + * The datasource being used + * + * @var LdapConnection + */ + protected $ds; + + /** + * Normed attribute names based on known LDAP environments + * + * @var array + */ + protected $normedAttributes = array( + 'uid' => 'uid', + 'gid' => 'gid', + 'user' => 'user', + 'group' => 'group', + 'member' => 'member', + 'memberuid' => 'memberUid', + 'posixgroup' => 'posixGroup', + 'uniquemember' => 'uniqueMember', + 'groupofnames' => 'groupOfNames', + 'inetorgperson' => 'inetOrgPerson', + 'samaccountname' => 'sAMAccountName', + 'groupofuniquenames' => 'groupOfUniqueNames' + ); + + /** + * Create a new LDAP repository object + * + * @param LdapConnection $ds The data source to use + */ + public function __construct(LdapConnection $ds) + { + parent::__construct($ds); + } + + /** + * Return the given attribute name normed to known LDAP enviroments, if possible + * + * @param ?string $name + * + * @return string + */ + protected function getNormedAttribute($name) + { + $loweredName = strtolower($name ?? ''); + if (array_key_exists($loweredName, $this->normedAttributes)) { + return $this->normedAttributes[$loweredName]; + } + + return $name; + } +} diff --git a/library/Icinga/Repository/Repository.php b/library/Icinga/Repository/Repository.php new file mode 100644 index 0000000..404f1f6 --- /dev/null +++ b/library/Icinga/Repository/Repository.php @@ -0,0 +1,1261 @@ +<?php +/* Icinga Web 2 | (c) 2015 Icinga Development Team | GPLv2+ */ + +namespace Icinga\Repository; + +use DateTime; +use Icinga\Application\Logger; +use Icinga\Data\Filter\Filter; +use Icinga\Data\Filter\FilterExpression; +use Icinga\Data\Selectable; +use Icinga\Exception\ProgrammingError; +use Icinga\Exception\QueryException; +use Icinga\Exception\StatementException; +use Icinga\Util\ASN1; +use Icinga\Util\StringHelper; +use InvalidArgumentException; + +/** + * Abstract base class for concrete repository implementations + * + * To utilize this class and its features, the following is required: + * <ul> + * <li>Concrete implementations need to initialize Repository::$queryColumns</li> + * <li>The datasource passed to a repository must implement the Selectable interface</li> + * <li>The datasource must yield an instance of Queryable when its select() method is called</li> + * </ul> + */ +abstract class Repository implements Selectable +{ + /** + * The format to use when converting values of type date_time + */ + const DATETIME_FORMAT = 'd/m/Y g:i A'; + + /** + * The name of this repository + * + * @var string + */ + protected $name; + + /** + * The datasource being used + * + * @var Selectable + */ + protected $ds; + + /** + * The base table name this repository is responsible for + * + * This will be automatically set to the first key of $queryColumns if not explicitly set. + * + * @var string + */ + protected $baseTable; + + /** + * The virtual tables being provided + * + * This may be initialized by concrete repository implementations with an array + * where a key is the name of a virtual table and its value the real table name. + * + * @var array + */ + protected $virtualTables; + + /** + * The query columns being provided + * + * This must be initialized by concrete repository implementations, in the following format + * <code> + * array( + * 'baseTable' => array( + * 'column1', + * 'alias1' => 'column2', + * 'alias2' => 'column3' + * ) + * ) + * </code> + * + * @var array + */ + protected $queryColumns; + + /** + * The columns (or aliases) which are not permitted to be queried + * + * Blacklisted query columns can still occur in a filter expression or sort rule. + * + * @var array An array of strings + */ + protected $blacklistedQueryColumns; + + /** + * Whether the blacklisted query columns are in the legacy format + * + * @var bool + */ + protected $legacyBlacklistedQueryColumns; + + /** + * The filter columns being provided + * + * This may be intialized by concrete repository implementations, in the following format + * <code> + * array( + * 'alias_or_column_name', + * 'label_to_show_in_the_filter_editor' => 'alias_or_column_name' + * ) + * </code> + * + * @var array + */ + protected $filterColumns; + + /** + * Whether the provided filter columns are in the legacy format + * + * @var bool + */ + protected $legacyFilterColumns; + + /** + * The search columns (or aliases) being provided + * + * @var array An array of strings + */ + protected $searchColumns; + + /** + * Whether the provided search columns are in the legacy format + * + * @var bool + */ + protected $legacySearchColumns; + + /** + * The sort rules to be applied on a query + * + * This may be initialized by concrete repository implementations, in the following format + * <code> + * array( + * 'alias_or_column_name' => array( + * 'order' => 'asc' + * ), + * 'alias_or_column_name' => array( + * 'columns' => array( + * 'once_more_the_alias_or_column_name_as_in_the_parent_key', + * 'an_additional_alias_or_column_name_with_a_specific_direction asc' + * ), + * 'order' => 'desc' + * ), + * 'alias_or_column_name' => array( + * 'columns' => array('a_different_alias_or_column_name_designated_to_act_as_the_only_sort_column') + * // Ascendant sort by default + * ) + * ) + * </code> + * Note that it's mandatory to supply the alias name in case there is one. + * + * @var array + */ + protected $sortRules; + + /** + * Whether the provided sort rules are in the legacy format + * + * @var bool + */ + protected $legacySortRules; + + /** + * The value conversion rules to apply on a query or statement + * + * This may be initialized by concrete repository implementations and describes for which aliases or column + * names what type of conversion is available. For entries, where the key is the alias/column and the value + * is the type identifier, the repository attempts to find a conversion method for the alias/column first and, + * if none is found, then for the type. If an entry only provides a value, which is the alias/column, the + * repository only attempts to find a conversion method for the alias/column. The name of a conversion method + * is expected to be declared using lowerCamelCase. (e.g. user_name will be translated to persistUserName and + * groupname will be translated to retrieveGroupname) + * + * @var array + */ + protected $conversionRules; + + /** + * An array to map table names to aliases + * + * @var array + */ + protected $aliasTableMap; + + /** + * A flattened array to map query columns to aliases + * + * @var array + */ + protected $aliasColumnMap; + + /** + * An array to map table names to query columns + * + * @var array + */ + protected $columnTableMap; + + /** + * A flattened array to map aliases to query columns + * + * @var array + */ + protected $columnAliasMap; + + /** + * Create a new repository object + * + * @param Selectable|null $ds The datasource to use. + * Only pass null if you have overridden {@link getDataSource()}! + */ + public function __construct(Selectable $ds = null) + { + $this->ds = $ds; + $this->aliasTableMap = array(); + $this->aliasColumnMap = array(); + $this->columnTableMap = array(); + $this->columnAliasMap = array(); + + $this->init(); + } + + /** + * Initialize this repository + * + * Supposed to be overwritten by concrete repository implementations. + */ + protected function init() + { + } + + /** + * Set this repository's name + * + * @param string $name + * + * @return $this + */ + public function setName($name) + { + $this->name = $name; + return $this; + } + + /** + * Return this repository's name + * + * In case no name has been explicitly set yet, the class name is returned. + * + * @return string + */ + public function getName() + { + return $this->name ?: __CLASS__; + } + + /** + * Return the datasource being used for the given table + * + * @param string $table + * + * @return Selectable + * + * @throws ProgrammingError In case no datasource is available + */ + public function getDataSource($table = null) + { + if ($this->ds === null) { + throw new ProgrammingError( + 'No data source available. It is required to either pass it' + . ' at initialization time or by overriding this method.' + ); + } + + return $this->ds; + } + + /** + * Return the base table name this repository is responsible for + * + * @return string + * + * @throws ProgrammingError In case no base table name has been set and + * $this->queryColumns does not provide one either + */ + public function getBaseTable() + { + if ($this->baseTable === null) { + $queryColumns = $this->getQueryColumns(); + reset($queryColumns); + $this->baseTable = key($queryColumns); + if (is_int($this->baseTable) || !is_array($queryColumns[$this->baseTable])) { + throw new ProgrammingError('"%s" is not a valid base table', $this->baseTable); + } + } + + return $this->baseTable; + } + + /** + * Return the virtual tables being provided + * + * Calls $this->initializeVirtualTables() in case $this->virtualTables is null. + * + * @return array + */ + public function getVirtualTables() + { + if ($this->virtualTables === null) { + $this->virtualTables = $this->initializeVirtualTables(); + } + + return $this->virtualTables; + } + + /** + * Overwrite this in your repository implementation in case you need to initialize the virtual tables lazily + * + * @return array + */ + protected function initializeVirtualTables() + { + return array(); + } + + /** + * Return the query columns being provided + * + * Calls $this->initializeQueryColumns() in case $this->queryColumns is null. + * + * @return array + */ + public function getQueryColumns() + { + if ($this->queryColumns === null) { + $this->queryColumns = $this->initializeQueryColumns(); + } + + return $this->queryColumns; + } + + /** + * Overwrite this in your repository implementation in case you need to initialize the query columns lazily + * + * @return array + */ + protected function initializeQueryColumns() + { + return array(); + } + + /** + * Return the columns (or aliases) which are not permitted to be queried + * + * Calls $this->initializeBlacklistedQueryColumns() in case $this->blacklistedQueryColumns is null. + * + * @param string $table + * + * @return array + */ + public function getBlacklistedQueryColumns($table = null) + { + if ($this->blacklistedQueryColumns === null) { + $this->legacyBlacklistedQueryColumns = false; + + $blacklistedQueryColumns = $this->initializeBlacklistedQueryColumns($table); + if (is_int(key($blacklistedQueryColumns))) { + $this->blacklistedQueryColumns[$table] = $blacklistedQueryColumns; + } else { + $this->blacklistedQueryColumns = $blacklistedQueryColumns; + } + } elseif ($this->legacyBlacklistedQueryColumns === null) { + $this->legacyBlacklistedQueryColumns = is_int(key($this->blacklistedQueryColumns)); + } + + if ($this->legacyBlacklistedQueryColumns) { + return $this->blacklistedQueryColumns; + } elseif (! isset($this->blacklistedQueryColumns[$table])) { + $this->blacklistedQueryColumns[$table] = $this->initializeBlacklistedQueryColumns($table); + } + + return $this->blacklistedQueryColumns[$table]; + } + + /** + * Overwrite this in your repository implementation in case you need to initialize the + * blacklisted query columns lazily or dependent on a query's current base table + * + * @param string $table + * + * @return array + */ + protected function initializeBlacklistedQueryColumns() + { + // $table is not part of the signature due to PHP strict standards + return array(); + } + + /** + * Return the filter columns being provided + * + * Calls $this->initializeFilterColumns() in case $this->filterColumns is null. + * + * @param string $table + * + * @return array + */ + public function getFilterColumns($table = null) + { + if ($this->filterColumns === null) { + $this->legacyFilterColumns = false; + + $filterColumns = $this->initializeFilterColumns($table); + $foundTables = array_intersect_key($this->getQueryColumns(), $filterColumns); + if (empty($foundTables)) { + $this->filterColumns[$table] = $filterColumns; + } else { + $this->filterColumns = $filterColumns; + } + } elseif ($this->legacyFilterColumns === null) { + $foundTables = array_intersect_key($this->getQueryColumns(), $this->filterColumns); + $this->legacyFilterColumns = empty($foundTables); + } + + if ($this->legacyFilterColumns) { + return $this->filterColumns; + } elseif (! isset($this->filterColumns[$table])) { + $this->filterColumns[$table] = $this->initializeFilterColumns($table); + } + + return $this->filterColumns[$table]; + } + + /** + * Overwrite this in your repository implementation in case you need to initialize + * the filter columns lazily or dependent on a query's current base table + * + * @param string $table + * + * @return array + */ + protected function initializeFilterColumns() + { + // $table is not part of the signature due to PHP strict standards + return array(); + } + + /** + * Return the search columns being provided + * + * Calls $this->initializeSearchColumns() in case $this->searchColumns is null. + * + * @param string $table + * + * @return array + */ + public function getSearchColumns($table = null) + { + if ($this->searchColumns === null) { + $this->legacySearchColumns = false; + + $searchColumns = $this->initializeSearchColumns($table); + if (is_int(key($searchColumns))) { + $this->searchColumns[$table] = $searchColumns; + } else { + $this->searchColumns = $searchColumns; + } + } elseif ($this->legacySearchColumns === null) { + $this->legacySearchColumns = is_int(key($this->searchColumns)); + } + + if ($this->legacySearchColumns) { + return $this->searchColumns; + } elseif (! isset($this->searchColumns[$table])) { + $this->searchColumns[$table] = $this->initializeSearchColumns($table); + } + + return $this->searchColumns[$table]; + } + + /** + * Overwrite this in your repository implementation in case you need to initialize + * the search columns lazily or dependent on a query's current base table + * + * @param string $table + * + * @return array + */ + protected function initializeSearchColumns() + { + // $table is not part of the signature due to PHP strict standards + return array(); + } + + /** + * Return the sort rules to be applied on a query + * + * Calls $this->initializeSortRules() in case $this->sortRules is null. + * + * @param string $table + * + * @return array + */ + public function getSortRules($table = null) + { + if ($this->sortRules === null) { + $this->legacySortRules = false; + + $sortRules = $this->initializeSortRules($table); + $foundTables = array_intersect_key($this->getQueryColumns(), $sortRules); + if (empty($foundTables)) { + $this->sortRules[$table] = $sortRules; + } else { + $this->sortRules = $sortRules; + } + } elseif ($this->legacySortRules === null) { + $foundTables = array_intersect_key($this->getQueryColumns(), $this->sortRules); + $this->legacySortRules = empty($foundTables); + } + + if ($this->legacySortRules) { + return $this->sortRules; + } elseif (! isset($this->sortRules[$table])) { + $this->sortRules[$table] = $this->initializeSortRules($table); + } + + return $this->sortRules[$table]; + } + + /** + * Overwrite this in your repository implementation in case you need to initialize + * the sort rules lazily or dependent on a query's current base table + * + * @param string $table + * + * @return array + */ + protected function initializeSortRules() + { + // $table is not part of the signature due to PHP strict standards + return array(); + } + + /** + * Return the value conversion rules to apply on a query + * + * Calls $this->initializeConversionRules() in case $this->conversionRules is null. + * + * @return array + */ + public function getConversionRules() + { + if ($this->conversionRules === null) { + $this->conversionRules = $this->initializeConversionRules(); + } + + return $this->conversionRules; + } + + /** + * Overwrite this in your repository implementation in case you need to initialize the conversion rules lazily + * + * @return array + */ + protected function initializeConversionRules() + { + return array(); + } + + /** + * Return an array to map table names to aliases + * + * @return array + */ + protected function getAliasTableMap() + { + if (empty($this->aliasTableMap)) { + $this->initializeAliasMaps(); + } + + return $this->aliasTableMap; + } + + /** + * Return a flattened array to map query columns to aliases + * + * @return array + */ + protected function getAliasColumnMap() + { + if (empty($this->aliasColumnMap)) { + $this->initializeAliasMaps(); + } + + return $this->aliasColumnMap; + } + + /** + * Return an array to map table names to query columns + * + * @return array + */ + protected function getColumnTableMap() + { + if (empty($this->columnTableMap)) { + $this->initializeAliasMaps(); + } + + return $this->columnTableMap; + } + + /** + * Return a flattened array to map aliases to query columns + * + * @return array + */ + protected function getColumnAliasMap() + { + if (empty($this->columnAliasMap)) { + $this->initializeAliasMaps(); + } + + return $this->columnAliasMap; + } + + /** + * Initialize $this->aliasTableMap and $this->aliasColumnMap + * + * @throws ProgrammingError In case $this->queryColumns does not provide any column information + */ + protected function initializeAliasMaps() + { + $queryColumns = $this->getQueryColumns(); + if (empty($queryColumns)) { + throw new ProgrammingError('Repositories are required to initialize $this->queryColumns first'); + } + + foreach ($queryColumns as $table => $columns) { + foreach ($columns as $alias => $column) { + if (! is_string($alias)) { + $key = $column; + } else { + $key = $alias; + $column = preg_replace('~\n\s*~', ' ', $column); + } + + if (array_key_exists($key, $this->aliasTableMap)) { + if ($this->aliasTableMap[$key] !== null) { + $existingTable = $this->aliasTableMap[$key]; + $existingColumn = $this->aliasColumnMap[$key]; + $this->aliasTableMap[$existingTable . '.' . $key] = $existingTable; + $this->aliasColumnMap[$existingTable . '.' . $key] = $existingColumn; + $this->aliasTableMap[$key] = null; + $this->aliasColumnMap[$key] = null; + } + + $this->aliasTableMap[$table . '.' . $key] = $table; + $this->aliasColumnMap[$table . '.' . $key] = $column; + } else { + $this->aliasTableMap[$key] = $table; + $this->aliasColumnMap[$key] = $column; + } + + if (array_key_exists($column, $this->columnTableMap)) { + if ($this->columnTableMap[$column] !== null) { + $existingTable = $this->columnTableMap[$column]; + $existingAlias = $this->columnAliasMap[$column]; + $this->columnTableMap[$existingTable . '.' . $column] = $existingTable; + $this->columnAliasMap[$existingTable . '.' . $column] = $existingAlias; + $this->columnTableMap[$column] = null; + $this->columnAliasMap[$column] = null; + } + + $this->columnTableMap[$table . '.' . $column] = $table; + $this->columnAliasMap[$table . '.' . $column] = $key; + } else { + $this->columnTableMap[$column] = $table; + $this->columnAliasMap[$column] = $key; + } + } + } + } + + /** + * Return a new query for the given columns + * + * @param array $columns The desired columns, if null all columns will be queried + * + * @return RepositoryQuery + */ + public function select(array $columns = null) + { + $query = new RepositoryQuery($this); + $query->from($this->getBaseTable(), $columns); + return $query; + } + + /** + * Return whether this repository is capable of converting values for the given table and optional column + * + * @param string $table + * @param string $column + * + * @return bool + */ + public function providesValueConversion($table, $column = null) + { + $conversionRules = $this->getConversionRules(); + if (empty($conversionRules)) { + return false; + } + + if (! isset($conversionRules[$table])) { + return false; + } elseif ($column === null) { + return true; + } + + $alias = $this->reassembleQueryColumnAlias($table, $column) ?: $column; + return array_key_exists($alias, $conversionRules[$table]) || in_array($alias, $conversionRules[$table]); + } + + /** + * Convert a value supposed to be transmitted to the data source + * + * @param string $table The table where to persist the value + * @param string $name The alias or column name + * @param mixed $value The value to convert + * @param RepositoryQuery $query An optional query to pass as context + * (Directly passed through to $this->getConverter) + * + * @return mixed If conversion was possible, the converted value, + * otherwise the unchanged value + */ + public function persistColumn($table, $name, $value, RepositoryQuery $query = null) + { + $converter = $this->getConverter($table, $name, 'persist', $query); + if ($converter !== null) { + $value = $this->$converter($value, $name, $table, $query); + } + + return $value; + } + + /** + * Convert a value which was fetched from the data source + * + * @param string $table The table the value has been fetched from + * @param string $name The alias or column name + * @param mixed $value The value to convert + * @param RepositoryQuery $query An optional query to pass as context + * (Directly passed through to $this->getConverter) + * + * @return mixed If conversion was possible, the converted value, + * otherwise the unchanged value + */ + public function retrieveColumn($table, $name, $value, RepositoryQuery $query = null) + { + $converter = $this->getConverter($table, $name, 'retrieve', $query); + if ($converter !== null) { + $value = $this->$converter($value, $name, $table, $query); + } + + return $value; + } + + /** + * Return the name of the conversion method for the given alias or column name and context + * + * @param string $table The datasource's table + * @param string $name The alias or column name for which to return a conversion method + * @param string $context The context of the conversion: persist or retrieve + * @param RepositoryQuery $query An optional query to pass as context + * (unused by the base implementation) + * + * @return ?string + * + * @throws ProgrammingError In case a conversion rule is found but not any conversion method + */ + protected function getConverter($table, $name, $context, RepositoryQuery $query = null) + { + $conversionRules = $this->getConversionRules(); + if (! isset($conversionRules[$table])) { + return null; + } + + $tableRules = $conversionRules[$table]; + if (($alias = $this->reassembleQueryColumnAlias($table, $name)) === null) { + $alias = $name; + } + + // Check for a conversion method for the alias/column first + if (array_key_exists($alias, $tableRules) || in_array($alias, $tableRules)) { + $methodName = $context . join('', array_map('ucfirst', explode('_', $alias))); + if (method_exists($this, $methodName)) { + return $methodName; + } + } + + // The conversion method for the type is just a fallback, but it is required to exist if defined + if (isset($tableRules[$alias])) { + $identifier = join('', array_map('ucfirst', explode('_', $tableRules[$alias]))); + if (! method_exists($this, $context . $identifier)) { + // Do not throw an error in case at least one conversion method exists + if (! method_exists($this, ($context === 'persist' ? 'retrieve' : 'persist') . $identifier)) { + throw new ProgrammingError( + 'Cannot find any conversion method for type "%s"' + . '. Add a proper conversion method or remove the type definition', + $tableRules[$alias] + ); + } + + Logger::debug( + 'Conversion method "%s" for type definition "%s" does not exist in repository "%s".', + $context . $identifier, + $tableRules[$alias], + $this->getName() + ); + } else { + return $context . $identifier; + } + } + } + + /** + * Convert a timestamp or DateTime object to a string formatted using static::DATETIME_FORMAT + * + * @param mixed $value + * + * @return string + */ + protected function persistDateTime($value) + { + if (is_numeric($value)) { + $value = date(static::DATETIME_FORMAT, $value); + } elseif ($value instanceof DateTime) { + $value = date(static::DATETIME_FORMAT, $value->getTimestamp()); // Using date here, to ignore any timezone + } elseif ($value !== null) { + throw new ProgrammingError( + 'Cannot persist value "%s" as type date_time. It\'s not a timestamp or DateTime object', + $value + ); + } + + return $value; + } + + /** + * Convert a string formatted using static::DATETIME_FORMAT to a unix timestamp + * + * @param string $value + * + * @return int + */ + protected function retrieveDateTime($value) + { + if (is_numeric($value)) { + $value = (int) $value; + } elseif (is_string($value)) { + $dateTime = DateTime::createFromFormat(static::DATETIME_FORMAT, $value); + if ($dateTime === false) { + Logger::debug( + 'Unable to parse string "%s" as type date_time with format "%s" in repository "%s"', + $value, + static::DATETIME_FORMAT, + $this->getName() + ); + $value = null; + } else { + $value = $dateTime->getTimestamp(); + } + } elseif ($value !== null) { + throw new ProgrammingError( + 'Cannot retrieve value "%s" as type date_time. It\'s not a integer or (numeric) string', + $value + ); + } + + return $value; + } + + /** + * Convert the given array to an comma separated string + * + * @param array|string $value + * + * @return string + */ + protected function persistCommaSeparatedString($value) + { + if (is_array($value)) { + $value = join(',', array_map('trim', $value)); + } elseif ($value !== null && !is_string($value)) { + throw new ProgrammingError('Cannot persist value "%s" as comma separated string', $value); + } + + return $value; + } + + /** + * Convert the given comma separated string to an array + * + * @param string $value + * + * @return array + */ + protected function retrieveCommaSeparatedString($value) + { + if ($value && is_string($value)) { + $value = StringHelper::trimSplit($value); + } elseif ($value !== null) { + throw new ProgrammingError('Cannot retrieve value "%s" as array. It\'s not a string', $value); + } + + return $value; + } + + /** + * Parse the given value based on the ASN.1 standard (GeneralizedTime) and return its timestamp representation + * + * @param string|null $value + * + * @return ?int + * + * @see https://tools.ietf.org/html/rfc4517#section-3.3.13 + */ + protected function retrieveGeneralizedTime($value) + { + if ($value === null) { + return $value; + } + + try { + return ASN1::parseGeneralizedTime($value)->getTimestamp(); + } catch (InvalidArgumentException $e) { + Logger::debug(sprintf('Repository "%s": %s', $this->getName(), $e->getMessage())); + } + } + + /** + * Validate that the requested table exists and resolve it's real name if necessary + * + * @param string $table The table to validate + * @param RepositoryQuery $query An optional query to pass as context + * (unused by the base implementation) + * + * @return string The table's name, may differ from the given one + * + * @throws ProgrammingError In case the given table does not exist + */ + public function requireTable($table, RepositoryQuery $query = null) + { + $queryColumns = $this->getQueryColumns(); + if (! isset($queryColumns[$table])) { + throw new ProgrammingError('Table "%s" not found', $table); + } + + $virtualTables = $this->getVirtualTables(); + if (isset($virtualTables[$table])) { + $table = $virtualTables[$table]; + } + + return $table; + } + + /** + * Recurse the given filter, require each column for the given table and convert all values + * + * @param string $table The table being filtered + * @param Filter $filter The filter to recurse + * @param RepositoryQuery $query An optional query to pass as context + * (Directly passed through to $this->requireFilterColumn) + * @param bool $clone Whether to clone $filter first + * + * @return Filter The udpated filter + */ + public function requireFilter($table, Filter $filter, RepositoryQuery $query = null, $clone = true) + { + if ($clone) { + $filter = clone $filter; + } + + if ($filter->isExpression()) { + $column = $filter->getColumn(); + $filter->setColumn($this->requireFilterColumn($table, $column, $query, $filter)); + $filter->setExpression($this->persistColumn($table, $column, $filter->getExpression(), $query)); + } elseif ($filter->isChain()) { + foreach ($filter->filters() as $chainOrExpression) { + $this->requireFilter($table, $chainOrExpression, $query, false); + } + } + + return $filter; + } + + /** + * Return this repository's query columns of the given table mapped to their respective aliases + * + * @param string $table + * + * @return array + * + * @throws ProgrammingError In case $table does not exist + */ + public function requireAllQueryColumns($table) + { + $queryColumns = $this->getQueryColumns(); + if (! array_key_exists($table, $queryColumns)) { + throw new ProgrammingError('Table name "%s" not found', $table); + } + + $blacklist = $this->getBlacklistedQueryColumns($table); + $columns = array(); + foreach ($queryColumns[$table] as $alias => $column) { + $name = is_string($alias) ? $alias : $column; + if (! in_array($name, $blacklist)) { + $columns[$alias] = $this->resolveQueryColumnAlias($table, $name); + } + } + + return $columns; + } + + /** + * Return the query column name for the given alias or null in case the alias does not exist + * + * @param string $table + * @param string $alias + * + * @return string|null + */ + public function resolveQueryColumnAlias($table, $alias) + { + $aliasColumnMap = $this->getAliasColumnMap(); + if (isset($aliasColumnMap[$alias])) { + return $aliasColumnMap[$alias]; + } + + $prefixedAlias = $table . '.' . $alias; + if (isset($aliasColumnMap[$prefixedAlias])) { + return $aliasColumnMap[$prefixedAlias]; + } + } + + /** + * Return the alias for the given query column name or null in case the query column name does not exist + * + * @param string $table + * @param string $column + * + * @return string|null + */ + public function reassembleQueryColumnAlias($table, $column) + { + $columnAliasMap = $this->getColumnAliasMap(); + if (isset($columnAliasMap[$column])) { + return $columnAliasMap[$column]; + } + + $prefixedColumn = $table . '.' . $column; + if (isset($columnAliasMap[$prefixedColumn])) { + return $columnAliasMap[$prefixedColumn]; + } + } + + /** + * Return whether the given alias or query column name is available in the given table + * + * @param string $table + * @param string $alias + * + * @return bool + */ + public function validateQueryColumnAssociation($table, $alias) + { + $aliasTableMap = $this->getAliasTableMap(); + if (isset($aliasTableMap[$alias])) { + return $aliasTableMap[$alias] === $table; + } + + $prefixedAlias = $table . '.' . $alias; + if (isset($aliasTableMap[$prefixedAlias])) { + return true; + } + + $columnTableMap = $this->getColumnTableMap(); + if (isset($columnTableMap[$alias])) { + return $columnTableMap[$alias] === $table; + } + + return isset($columnTableMap[$prefixedAlias]); + } + + /** + * Return whether the given column name or alias is a valid query column + * + * @param string $table The table where to look for the column or alias + * @param string $name The column name or alias to check + * + * @return bool + */ + public function hasQueryColumn($table, $name) + { + if ($this->resolveQueryColumnAlias($table, $name) !== null) { + $alias = $name; + } elseif (($alias = $this->reassembleQueryColumnAlias($table, $name)) === null) { + return false; + } + + return !in_array($alias, $this->getBlacklistedQueryColumns($table)) + && $this->validateQueryColumnAssociation($table, $name); + } + + /** + * Validate that the given column is a valid query target and return it or the actual name if it's an alias + * + * @param string $table The table where to look for the column or alias + * @param string $name The name or alias of the column to validate + * @param RepositoryQuery $query An optional query to pass as context (unused by the base implementation) + * + * @return string The given column's name + * + * @throws QueryException In case the given column is not a valid query column + */ + public function requireQueryColumn($table, $name, RepositoryQuery $query = null) + { + if (($column = $this->resolveQueryColumnAlias($table, $name)) !== null) { + $alias = $name; + } elseif (($alias = $this->reassembleQueryColumnAlias($table, $name)) !== null) { + $column = $name; + } else { + throw new QueryException(t('Query column "%s" not found'), $name); + } + + if (in_array($alias, $this->getBlacklistedQueryColumns($table))) { + throw new QueryException(t('Column "%s" cannot be queried'), $name); + } + + if (! $this->validateQueryColumnAssociation($table, $alias)) { + throw new QueryException(t('Query column "%s" not found in table "%s"'), $name, $table); + } + + return $column; + } + + /** + * Return whether the given column name or alias is a valid filter column + * + * @param string $table The table where to look for the column or alias + * @param string $name The column name or alias to check + * + * @return bool + */ + public function hasFilterColumn($table, $name) + { + return ($this->resolveQueryColumnAlias($table, $name) !== null + || $this->reassembleQueryColumnAlias($table, $name) !== null) + && $this->validateQueryColumnAssociation($table, $name); + } + + /** + * Validate that the given column is a valid filter target and return it or the actual name if it's an alias + * + * @param string $table The table where to look for the column or alias + * @param string $name The name or alias of the column to validate + * @param RepositoryQuery $query An optional query to pass as context (unused by the base implementation) + * @param FilterExpression $filter An optional filter to pass as context (unused by the base implementation) + * + * @return string The given column's name + * + * @throws QueryException In case the given column is not a valid filter column + */ + public function requireFilterColumn($table, $name, RepositoryQuery $query = null, FilterExpression $filter = null) + { + if (($column = $this->resolveQueryColumnAlias($table, $name)) !== null) { + $alias = $name; + } elseif (($alias = $this->reassembleQueryColumnAlias($table, $name)) !== null) { + $column = $name; + } else { + throw new QueryException(t('Filter column "%s" not found'), $name); + } + + if (! $this->validateQueryColumnAssociation($table, $alias)) { + throw new QueryException(t('Filter column "%s" not found in table "%s"'), $name, $table); + } + + return $column; + } + + /** + * Return whether the given column name or alias of the given table is a valid statement column + * + * @param string $table The table where to look for the column or alias + * @param string $name The column name or alias to check + * + * @return bool + */ + public function hasStatementColumn($table, $name) + { + return $this->hasQueryColumn($table, $name); + } + + /** + * Validate that the given column is a valid statement column and return it or the actual name if it's an alias + * + * @param string $table The table for which to require the column + * @param string $name The name or alias of the column to validate + * + * @return string The given column's name + * + * @throws StatementException In case the given column is not a statement column + */ + public function requireStatementColumn($table, $name) + { + if (($column = $this->resolveQueryColumnAlias($table, $name)) !== null) { + $alias = $name; + } elseif (($alias = $this->reassembleQueryColumnAlias($table, $name)) !== null) { + $column = $name; + } else { + throw new StatementException('Statement column "%s" not found', $name); + } + + if (in_array($alias, $this->getBlacklistedQueryColumns($table))) { + throw new StatementException('Column "%s" cannot be referenced in a statement', $name); + } + + if (! $this->validateQueryColumnAssociation($table, $alias)) { + throw new StatementException('Statement column "%s" not found in table "%s"', $name, $table); + } + + return $column; + } + + /** + * Resolve the given aliases or column names of the given table supposed to be persisted and convert their values + * + * @param string $table + * @param array $data + * + * @return array + */ + public function requireStatementColumns($table, array $data) + { + $resolved = array(); + foreach ($data as $alias => $value) { + $resolved[$this->requireStatementColumn($table, $alias)] = $this->persistColumn($table, $alias, $value); + } + + return $resolved; + } +} diff --git a/library/Icinga/Repository/RepositoryQuery.php b/library/Icinga/Repository/RepositoryQuery.php new file mode 100644 index 0000000..84f7c6e --- /dev/null +++ b/library/Icinga/Repository/RepositoryQuery.php @@ -0,0 +1,797 @@ +<?php +/* Icinga Web 2 | (c) 2015 Icinga Development Team | GPLv2+ */ + +namespace Icinga\Repository; + +use Iterator; +use IteratorAggregate; +use Traversable; +use Icinga\Application\Benchmark; +use Icinga\Application\Logger; +use Icinga\Data\QueryInterface; +use Icinga\Data\Filter\Filter; +use Icinga\Data\FilterColumns; +use Icinga\Data\SortRules; +use Icinga\Exception\QueryException; + +/** + * Query class supposed to mediate between a repository and its datasource's query + */ +class RepositoryQuery implements QueryInterface, SortRules, FilterColumns, Iterator +{ + /** + * The repository being used + * + * @var Repository + */ + protected $repository; + + /** + * The real query being used + * + * @var QueryInterface + */ + protected $query; + + /** + * The current target to be queried + * + * @var mixed + */ + protected $target; + + /** + * The real query's iterator + * + * @var Iterator + */ + protected $iterator; + + /** + * This query's custom aliases + * + * @var array + */ + protected $customAliases; + + /** + * Create a new repository query + * + * @param Repository $repository The repository to use + */ + public function __construct(Repository $repository) + { + $this->repository = $repository; + } + + /** + * Clone all state relevant properties of this query + */ + public function __clone() + { + if ($this->query !== null) { + $this->query = clone $this->query; + } + if ($this->iterator !== null) { + $this->iterator = clone $this->iterator; + } + } + + /** + * Return a string representation of this query + * + * @return string + */ + public function __toString() + { + return (string) $this->query; + } + + /** + * Return the real query being used + * + * @return QueryInterface + */ + public function getQuery() + { + return $this->query; + } + + /** + * Set where to fetch which columns + * + * This notifies the repository about each desired query column. + * + * @param mixed $target The target from which to fetch the columns + * @param array $columns If null or an empty array, all columns will be fetched + * + * @return $this + */ + public function from($target, array $columns = null) + { + $this->query = $this->repository->getDataSource($target)->select(); + $this->query->from($this->repository->requireTable($target, $this)); + $this->query->columns($this->prepareQueryColumns($target, $columns)); + $this->target = $target; + return $this; + } + + /** + * Return the columns to fetch + * + * @return array + */ + public function getColumns() + { + return $this->query->getColumns(); + } + + /** + * Set which columns to fetch + * + * This notifies the repository about each desired query column. + * + * @param array $columns If null or an empty array, all columns will be fetched + * + * @return $this + */ + public function columns(array $columns) + { + $this->query->columns($this->prepareQueryColumns($this->target, $columns)); + return $this; + } + + /** + * Resolve the given columns supposed to be fetched + * + * This notifies the repository about each desired query column. + * + * @param mixed $target The target where to look for each column + * @param array $desiredColumns Pass null or an empty array to require all query columns + * + * @return array The desired columns indexed by their respective alias + */ + protected function prepareQueryColumns($target, array $desiredColumns = null) + { + $this->customAliases = array(); + if (empty($desiredColumns)) { + $columns = $this->repository->requireAllQueryColumns($target); + } else { + $columns = array(); + foreach ($desiredColumns as $customAlias => $columnAlias) { + $resolvedColumn = $this->repository->requireQueryColumn($target, $columnAlias, $this); + if ($resolvedColumn !== $columnAlias) { + if (is_string($customAlias)) { + $columns[$customAlias] = $resolvedColumn; + $this->customAliases[$customAlias] = $columnAlias; + } else { + $columns[$columnAlias] = $resolvedColumn; + } + } elseif (is_string($customAlias)) { + $columns[$customAlias] = $columnAlias; + $this->customAliases[$customAlias] = $columnAlias; + } else { + $columns[] = $columnAlias; + } + } + } + + return $columns; + } + + /** + * Return the native column alias for the given custom alias + * + * If no custom alias is found with the given name, it is returned unchanged. + * + * @param string $customAlias + * + * @return string + */ + protected function getNativeAlias($customAlias) + { + if (isset($this->customAliases[$customAlias])) { + return $this->customAliases[$customAlias]; + } + + return $customAlias; + } + + /** + * Return this query's available filter columns with their optional label as key + * + * @return array + */ + public function getFilterColumns() + { + return $this->repository->getFilterColumns($this->target); + } + + /** + * Return this query's available search columns + * + * @return array + */ + public function getSearchColumns() + { + return $this->repository->getSearchColumns($this->target); + } + + /** + * Filter this query using the given column and value + * + * This notifies the repository about the required filter column. + * + * @param string $column + * @param mixed $value + * + * @return $this + */ + public function where($column, $value = null) + { + $this->addFilter(Filter::where($column, $value)); + return $this; + } + + /** + * Add an additional filter expression to this query + * + * This notifies the repository about each required filter column. + * + * @param Filter $filter + * + * @return $this + */ + public function applyFilter(Filter $filter) + { + return $this->addFilter($filter); + } + + /** + * Set a filter for this query + * + * This notifies the repository about each required filter column. + * + * @param Filter $filter + * + * @return $this + */ + public function setFilter(Filter $filter) + { + $this->query->setFilter($this->repository->requireFilter($this->target, $filter, $this)); + return $this; + } + + /** + * Add an additional filter expression to this query + * + * This notifies the repository about each required filter column. + * + * @param Filter $filter + * + * @return $this + */ + public function addFilter(Filter $filter) + { + $this->query->addFilter($this->repository->requireFilter($this->target, $filter, $this)); + return $this; + } + + /** + * Return the current filter + * + * @return Filter + */ + public function getFilter() + { + return $this->query->getFilter(); + } + + /** + * Return the sort rules being applied on this query + * + * @return array + */ + public function getSortRules() + { + return $this->repository->getSortRules($this->target); + } + + /** + * Add a sort rule for this query + * + * If called without a specific column, the repository's defaul sort rules will be applied. + * This notifies the repository about each column being required as filter column. + * + * @param string $field The name of the column by which to sort the query's result + * @param string $direction The direction to use when sorting (asc or desc, default is asc) + * @param bool $ignoreDefault Whether to ignore any default sort rules if $field is given + * + * @return $this + */ + public function order($field = null, $direction = null, $ignoreDefault = false) + { + $sortRules = $this->getSortRules(); + if ($field === null) { + // Use first available sort rule as default + if (empty($sortRules)) { + // Return early in case of no sort defaults and no given $field + return $this; + } + + $sortColumns = reset($sortRules); + if (! array_key_exists('columns', $sortColumns)) { + $sortColumns['columns'] = array(key($sortRules)); + } + if ($direction !== null || !array_key_exists('order', $sortColumns)) { + $sortColumns['order'] = $direction ?: static::SORT_ASC; + } + } else { + $alias = $this->repository->reassembleQueryColumnAlias($this->target, $field) ?: $field; + if (! $ignoreDefault && array_key_exists($alias, $sortRules)) { + $sortColumns = $sortRules[$alias]; + if (! array_key_exists('columns', $sortColumns)) { + $sortColumns['columns'] = array($alias); + } + if ($direction !== null || !array_key_exists('order', $sortColumns)) { + $sortColumns['order'] = $direction ?: static::SORT_ASC; + } + } else { + $sortColumns = array( + 'columns' => array($alias), + 'order' => $direction + ); + } + } + + $baseDirection = isset($sortColumns['order']) && strtoupper($sortColumns['order']) === static::SORT_DESC + ? static::SORT_DESC + : static::SORT_ASC; + + foreach ($sortColumns['columns'] as $column) { + list($column, $specificDirection) = $this->splitOrder($column); + + if ($this->hasLimit() && $this->repository->providesValueConversion($this->target, $column)) { + Logger::debug( + 'Cannot order by column "%s" in repository "%s". The query is' + . ' limited and applies value conversion rules on the column', + $column, + $this->repository->getName() + ); + continue; + } + + try { + $this->query->order( + $this->repository->requireFilterColumn($this->target, $column, $this), + $specificDirection ?: $baseDirection + // I would have liked the following solution, but hey, a coder should be allowed to produce crap... + // $specificDirection && (! $direction || $column !== $field) ? $specificDirection : $baseDirection + ); + } catch (QueryException $_) { + Logger::info('Cannot order by column "%s" in repository "%s"', $column, $this->repository->getName()); + } + } + + return $this; + } + + /** + * Extract and return the name and direction of the given sort column definition + * + * @param string $field + * + * @return array An array of two items: $columnName, $direction + */ + protected function splitOrder($field) + { + $columnAndDirection = explode(' ', $field, 2); + if (count($columnAndDirection) === 1) { + $column = $field; + $direction = null; + } else { + $column = $columnAndDirection[0]; + $direction = strtoupper($columnAndDirection[1]) === static::SORT_DESC + ? static::SORT_DESC + : static::SORT_ASC; + } + + return array($column, $direction); + } + + /** + * Return whether any sort rules were applied to this query + * + * @return bool + */ + public function hasOrder() + { + return $this->query->hasOrder(); + } + + /** + * Return the sort rules applied to this query + * + * @return array + */ + public function getOrder() + { + return $this->query->getOrder(); + } + + /** + * Set whether this query should peek ahead for more results + * + * Enabling this causes the current query limit to be increased by one. The potential extra row being yielded will + * be removed from the result set. Note that this only applies when fetching multiple results of limited queries. + * + * @param bool $state + * + * @return $this + */ + public function peekAhead($state = true) + { + $this->query->peekAhead($state); + return $this; + } + + /** + * Return whether this query did not yield all available results + * + * @return bool + */ + public function hasMore() + { + return $this->query->hasMore(); + } + + /** + * Return whether this query will or has yielded any result + * + * @return bool + */ + public function hasResult() + { + return $this->query->hasResult(); + } + + /** + * Limit this query's results + * + * @param int $count When to stop returning results + * @param int $offset When to start returning results + * + * @return $this + */ + public function limit($count = null, $offset = null) + { + $this->query->limit($count, $offset); + return $this; + } + + /** + * Return whether this query does not return all available entries from its result + * + * @return bool + */ + public function hasLimit() + { + return $this->query->hasLimit(); + } + + /** + * Return the limit when to stop returning results + * + * @return int + */ + public function getLimit() + { + return $this->query->getLimit(); + } + + /** + * Return whether this query does not start returning results at the very first entry + * + * @return bool + */ + public function hasOffset() + { + return $this->query->hasOffset(); + } + + /** + * Return the offset when to start returning results + * + * @return int + */ + public function getOffset() + { + return $this->query->getOffset(); + } + + /** + * Fetch and return the first column of this query's first row + * + * @return mixed|false False in case of no result + */ + public function fetchOne() + { + if (! $this->hasOrder()) { + $this->order(); + } + + $result = $this->query->fetchOne(); + if ($result !== false && $this->repository->providesValueConversion($this->target)) { + $columns = $this->getColumns(); + $column = isset($columns[0]) ? $columns[0] : $this->getNativeAlias(key($columns)); + return $this->repository->retrieveColumn($this->target, $column, $result, $this); + } + + return $result; + } + + /** + * Fetch and return the first row of this query's result + * + * @return object|false False in case of no result + */ + public function fetchRow() + { + if (! $this->hasOrder()) { + $this->order(); + } + + $result = $this->query->fetchRow(); + if ($result !== false && $this->repository->providesValueConversion($this->target)) { + foreach ($this->getColumns() as $alias => $column) { + if (! is_string($alias)) { + $alias = $column; + } + + $result->$alias = $this->repository->retrieveColumn( + $this->target, + $this->getNativeAlias($alias), + $result->$alias, + $this + ); + } + } + + return $result; + } + + /** + * Fetch and return the first column of all rows of the result set as an array + * + * @return array + */ + public function fetchColumn() + { + if (! $this->hasOrder()) { + $this->order(); + } + + $results = $this->query->fetchColumn(); + if (! empty($results) && $this->repository->providesValueConversion($this->target)) { + $columns = $this->getColumns(); + $aliases = array_keys($columns); + $column = is_int($aliases[0]) ? $columns[0] : $this->getNativeAlias($aliases[0]); + if ($this->repository->providesValueConversion($this->target, $column)) { + foreach ($results as & $value) { + $value = $this->repository->retrieveColumn($this->target, $column, $value, $this); + } + } + } + + return $results; + } + + /** + * Fetch and return all rows of this query's result set as an array of key-value pairs + * + * The first column is the key, the second column is the value. + * + * @return array + */ + public function fetchPairs() + { + if (! $this->hasOrder()) { + $this->order(); + } + + $results = $this->query->fetchPairs(); + if (! empty($results) && $this->repository->providesValueConversion($this->target)) { + $columns = $this->getColumns(); + $aliases = array_keys($columns); + $colOne = $aliases[0] !== 0 ? $this->getNativeAlias($aliases[0]) : $columns[0]; + $colTwo = count($aliases) < 2 ? $colOne : ( + $aliases[1] !== 1 ? $this->getNativeAlias($aliases[1]) : $columns[1] + ); + + if ($this->repository->providesValueConversion($this->target, $colOne) + || $this->repository->providesValueConversion($this->target, $colTwo) + ) { + $newResults = array(); + foreach ($results as $colOneValue => $colTwoValue) { + $colOneValue = $this->repository->retrieveColumn($this->target, $colOne, $colOneValue, $this); + $newResults[$colOneValue] = $this->repository->retrieveColumn( + $this->target, + $colTwo, + $colTwoValue, + $this + ); + } + + $results = $newResults; + } + } + + return $results; + } + + /** + * Fetch and return all results of this query + * + * @return array + */ + public function fetchAll() + { + if (! $this->hasOrder()) { + $this->order(); + } + + $results = $this->query->fetchAll(); + if (! empty($results) && $this->repository->providesValueConversion($this->target)) { + $updateOrder = false; + $columns = $this->getColumns(); + $flippedColumns = array_flip($columns); + foreach ($results as $row) { + foreach ($columns as $alias => $column) { + if (! is_string($alias)) { + $alias = $column; + } + + $row->$alias = $this->repository->retrieveColumn( + $this->target, + $this->getNativeAlias($alias), + $row->$alias, + $this + ); + } + + foreach (($this->getOrder() ?: array()) as $rule) { + $nativeAlias = $this->getNativeAlias($rule[0]); + if (! array_key_exists($rule[0], $flippedColumns) && property_exists($row, $rule[0])) { + if ($this->repository->providesValueConversion($this->target, $nativeAlias)) { + $updateOrder = true; + $row->{$rule[0]} = $this->repository->retrieveColumn( + $this->target, + $nativeAlias, + $row->{$rule[0]}, + $this + ); + } + } elseif (array_key_exists($rule[0], $flippedColumns)) { + if ($this->repository->providesValueConversion($this->target, $nativeAlias)) { + $updateOrder = true; + } + } + } + } + + if ($updateOrder) { + uasort($results, array($this->query, 'compare')); + } + } + + return $results; + } + + /** + * Count all results of this query + * + * @return int + */ + public function count(): int + { + return $this->query->count(); + } + + /** + * Return the current position of this query's iterator + * + * @return int + */ + public function getIteratorPosition() + { + return $this->query->getIteratorPosition(); + } + + /** + * Start or rewind the iteration + */ + public function rewind(): void + { + if ($this->iterator === null) { + if (! $this->hasOrder()) { + $this->order(); + } + + if ($this->query instanceof Traversable) { + $iterator = $this->query; + } else { + $iterator = $this->repository->getDataSource($this->target)->query($this->query); + } + + if ($iterator instanceof IteratorAggregate) { + $this->iterator = $iterator->getIterator(); + } else { + $this->iterator = $iterator; + } + } + + $this->iterator->rewind(); + Benchmark::measure('Query result iteration started'); + } + + /** + * Fetch and return the current row of this query's result + * + * @return mixed + */ + #[\ReturnTypeWillChange] + public function current() + { + $row = $this->iterator->current(); + if ($this->repository->providesValueConversion($this->target)) { + foreach ($this->getColumns() as $alias => $column) { + if (! is_string($alias)) { + $alias = $column; + } + + $row->$alias = $this->repository->retrieveColumn( + $this->target, + $this->getNativeAlias($alias), + $row->$alias, + $this + ); + } + } + + return $row; + } + + /** + * Return whether the current row of this query's result is valid + * + * @return bool + */ + public function valid(): bool + { + if (! $this->iterator->valid()) { + Benchmark::measure('Query result iteration finished'); + return false; + } + + return true; + } + + /** + * Return the key for the current row of this query's result + * + * @return mixed + */ + #[\ReturnTypeWillChange] + public function key() + { + return $this->iterator->key(); + } + + /** + * Advance to the next row of this query's result + */ + public function next(): void + { + $this->iterator->next(); + } +} diff --git a/library/Icinga/Security/SecurityException.php b/library/Icinga/Security/SecurityException.php new file mode 100644 index 0000000..861dcf1 --- /dev/null +++ b/library/Icinga/Security/SecurityException.php @@ -0,0 +1,13 @@ +<?php +/* Icinga Web 2 | (c) 2015 Icinga Development Team | GPLv2+ */ + +namespace Icinga\Security; + +use Icinga\Exception\IcingaException; + +/** + * Exception thrown when a caller does not have the permissions required to access a resource + */ +class SecurityException extends IcingaException +{ +} diff --git a/library/Icinga/Test/BaseTestCase.php b/library/Icinga/Test/BaseTestCase.php new file mode 100644 index 0000000..1283013 --- /dev/null +++ b/library/Icinga/Test/BaseTestCase.php @@ -0,0 +1,313 @@ +<?php +/* Icinga Web 2 | (c) 2013 Icinga Development Team | GPLv2+ */ + +namespace Icinga\Test { + + use Exception; + use Icinga\Util\Csp; + use Icinga\Web\Request; + use Icinga\Web\Response; + use Icinga\Web\Session; + use ipl\I18n\NoopTranslator; + use ipl\I18n\StaticTranslator; + use RuntimeException; + use Mockery; + use Icinga\Application\Icinga; + use Icinga\Data\ConfigObject; + use Icinga\Data\ResourceFactory; + use Icinga\Data\Db\DbConnection; + use Tests\Icinga\Lib\FakeSession; + + /** + * Class BaseTestCase + */ + abstract class BaseTestCase extends Mockery\Adapter\Phpunit\MockeryTestCase implements DbTest + { + /** + * Path to application/ + * + * @var string + * @deprecated Use Icinga::app()->getApplicationDir() instead + */ + public static $appDir; + + /** + * Path to library/Icinga + * + * @var string + * @deprecated Use Icinga::app()->getLibraryDir('Icinga') instead + */ + public static $libDir; + + /** + * Path to etc/ + * + * @var string + * @deprecated Use Icinga::app()->getBaseDir('etc') instead + */ + public static $etcDir; + + /** + * Path to test/php/ + * + * @var string + * @deprecated Use Icinga::app()->getBaseDir('test/php') instead + */ + public static $testDir; + + /** + * Path to share/icinga2-web + * + * @var string + * @deprecated Unused + */ + public static $shareDir; + + /** + * Path to modules/ + * + * @var string + * @deprecated Use Icinga::app()->getModuleManager()->getModuleDirs() instead + */ + public static $moduleDir; + + /** + * Resource configuration for different database types + * + * @var array + */ + protected static $dbConfiguration = array( + 'mysql' => array( + 'type' => 'db', + 'db' => 'mysql', + 'host' => '127.0.0.1', + 'port' => 3306, + 'dbname' => 'icinga_unittest', + 'username' => 'icinga_unittest', + 'password' => 'icinga_unittest' + ), + 'pgsql' => array( + 'type' => 'db', + 'db' => 'pgsql', + 'host' => '127.0.0.1', + 'port' => 5432, + 'dbname' => 'icinga_unittest', + 'username' => 'icinga_unittest', + 'password' => 'icinga_unittest' + ), + ); + + /** @var Request */ + private $requestMock; + + /** @var Response */ + private $responseMock; + + /** + * Setup MVC bootstrapping and ensure that the Icinga-Mock gets reinitialized + */ + public function setUp(): void + { + parent::setUp(); + $this->setupRequestMock(); + $this->setupResponseMock(); + Session::create(new FakeSession()); + Csp::createNonce(); + + StaticTranslator::$instance = new NoopTranslator(); + } + + private function setupRequestMock() + { + $this->requestMock = Mockery::mock('Icinga\Web\Request')->shouldDeferMissing(); + $this->requestMock->shouldReceive('getPathInfo')->andReturn('')->byDefault() + ->shouldReceive('getBaseUrl')->andReturn('/')->byDefault() + ->shouldReceive('getQuery')->andReturn(array())->byDefault() + ->shouldReceive('getParam')->with(Mockery::type('string'), Mockery::type('string')) + ->andReturnUsing(function ($name, $default) { + return $default; + })->byDefault(); + + Icinga::app()->setRequest($this->requestMock); + } + + private function setupResponseMock() + { + $this->responseMock = Mockery::mock('Icinga\Web\Response')->shouldDeferMissing(); + Icinga::app()->setResponse($this->responseMock); + } + + /** + * Return the currently active request mock object + * + * @return Request + */ + public function getRequestMock() + { + return $this->requestMock; + } + + /** + * Return the currently active response mock object + * + * @return Response + */ + public function getResponseMock() + { + return $this->responseMock; + } + + /** + * Create Config for database configuration + * + * @param string $name + * + * @return ConfigObject + * @throws RuntimeException + */ + protected function createDbConfigFor($name) + { + if (array_key_exists($name, self::$dbConfiguration)) { + $config = new ConfigObject(self::$dbConfiguration[$name]); + + $host = getenv(sprintf('ICINGAWEB_TEST_%s_HOST', strtoupper($name))); + if ($host) { + $config['host'] = $host; + } + + $port = getenv(sprintf('ICINGAWEB_TEST_%s_PORT', strtoupper($name))); + if ($port) { + $config['port'] = $port; + } + + return $config; + } + + throw new RuntimeException('Configuration for database type not available: ' . $name); + } + + /** + * Creates an array of Icinga\Data\Db\DbConnection + * + * @param string $name + * + * @return array + */ + protected function createDbConnectionFor($name) + { + try { + $conn = ResourceFactory::createResource($this->createDbConfigFor($name)); + } catch (Exception $e) { + $conn = $e->getMessage(); + } + + return array( + array($conn) + ); + } + + /** + * PHPUnit provider for mysql + * + * @return DbConnection + */ + public function mysqlDb() + { + return $this->createDbConnectionFor('mysql'); + } + + /** + * PHPUnit provider for pgsql + * + * @return DbConnection + */ + public function pgsqlDb() + { + return $this->createDbConnectionFor('pgsql'); + } + + /** + * PHPUnit provider for oracle + * + * @return DbConnection + */ + public function oracleDb() + { + return $this->createDbConnectionFor('oracle'); + } + + /** + * Executes sql file by using the database connection + * + * @param DbConnection $resource + * @param string $filename + * + * @throws RuntimeException + */ + public function loadSql(DbConnection $resource, $filename) + { + if (!is_file($filename)) { + throw new RuntimeException( + 'Sql file not found: ' . $filename . ' (test=' . $this->getName() . ')' + ); + } + + $sqlData = file_get_contents($filename); + + if (!$sqlData) { + throw new RuntimeException( + 'Sql file is empty: ' . $filename . ' (test=' . $this->getName() . ')' + ); + } + + $resource->getDbAdapter()->exec($sqlData); + } + + /** + * Setup provider for testcase + * + * @param string|DbConnection|null $resource + */ + public function setupDbProvider($resource) + { + if (!$resource instanceof DbConnection) { + if (is_string($resource)) { + $this->markTestSkipped('Could not initialize provider: ' . $resource); + } else { + $this->markTestSkipped('Could not initialize provider'); + } + return; + } + + $adapter = $resource->getDbAdapter(); + + try { + $adapter->getConnection(); + } catch (Exception $e) { + $this->markTestSkipped('Could not connect to provider: '. $e->getMessage()); + } + + $tables = $adapter->listTables(); + foreach ($tables as $table) { + $adapter->exec('DROP TABLE ' . $table . ';'); + } + } + + /** + * Add assertMatchesRegularExpression() method for phpunit >= 8.0 < 9.0 for compatibility with PHP 7.2. + * + * @TODO Remove once PHP 7.2 support is not needed for testing anymore. + */ + public static function assertMatchesRegularExpression( + string $pattern, + string $string, + string $message = '' + ): void { + if (method_exists(parent::class, 'assertMatchesRegularExpression')) { + parent::assertMatchesRegularExpression($pattern, $string, $message); + } else { + static::assertRegExp($pattern, $string, $message); + } + } + } +} diff --git a/library/Icinga/Test/ClassLoader.php b/library/Icinga/Test/ClassLoader.php new file mode 100644 index 0000000..af90a7e --- /dev/null +++ b/library/Icinga/Test/ClassLoader.php @@ -0,0 +1,113 @@ +<?php +/* Icinga Web 2 | (c) 2015 Icinga Development Team | GPLv2+ */ + +namespace Icinga\Test; + +/** + * PSR-4 class loader + */ +class ClassLoader +{ + /** + * Namespace separator + */ + const NAMESPACE_SEPARATOR = '\\'; + + /** + * Namespaces + * + * @var array + */ + private $namespaces = array(); + + /** + * Register a base directory for a namespace prefix + * + * @param string $namespace + * @param string $directory + * + * @return $this + */ + public function registerNamespace($namespace, $directory) + { + $this->namespaces[$namespace] = $directory; + + 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) + { + foreach ($this->namespaces as $namespace => $dir) { + if ($class === strstr($class, $namespace)) { + $classPath = str_replace( + self::NAMESPACE_SEPARATOR, + DIRECTORY_SEPARATOR, + substr($class, strlen($namespace)) + ) . '.php'; + if (file_exists($file = $dir . $classPath)) { + return $file; + } + } + } + return null; + } + + /** + * 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)) { + 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/Test/DbTest.php b/library/Icinga/Test/DbTest.php new file mode 100644 index 0000000..d1b1ff0 --- /dev/null +++ b/library/Icinga/Test/DbTest.php @@ -0,0 +1,47 @@ +<?php +/* Icinga Web 2 | (c) 2013 Icinga Development Team | GPLv2+ */ + +namespace Icinga\Test; + +use Icinga\Data\Db\DbConnection; + +interface DbTest +{ + /** + * PHPUnit provider for mysql + * + * @return DbConnection + */ + public function mysqlDb(); + + /** + * PHPUnit provider for pgsql + * + * @return DbConnection + */ + public function pgsqlDb(); + + /** + * PHPUnit provider for oracle + * + * @return DbConnection + */ + public function oracleDb(); + + /** + * Executes sql file on PDO object + * + * @param DbConnection $resource + * @param string $filename + * + * @return boolean Operational success flag + */ + public function loadSql(DbConnection $resource, $filename); + + /** + * Setup provider for testcase + * + * @param string|DbConnection|null $resource + */ + public function setupDbProvider($resource); +} diff --git a/library/Icinga/User.php b/library/Icinga/User.php new file mode 100644 index 0000000..8610dd0 --- /dev/null +++ b/library/Icinga/User.php @@ -0,0 +1,649 @@ +<?php +/* Icinga Web 2 | (c) 2013 Icinga Development Team | GPLv2+ */ + +namespace Icinga; + +use DateTimeZone; +use Icinga\Authentication\AdmissionLoader; +use InvalidArgumentException; +use Icinga\Application\Config; +use Icinga\Authentication\Role; +use Icinga\Exception\ProgrammingError; +use Icinga\User\Preferences; +use Icinga\Web\Navigation\Navigation; + +/** + * This class represents an authorized user + * + * You can retrieve authorization information (@TODO: Not implemented yet) or user information + */ +class User +{ + /** + * Firstname + * + * @var string + */ + protected $firstname; + + /** + * Lastname + * + * @var string + */ + protected $lastname; + + /** + * Users email address + * + * @var string + */ + protected $email; + + /** + * {@link username} without {@link domain} + * + * @var string + */ + protected $localUsername; + + /** + * Domain + * + * @var string + */ + protected $domain; + + /** + * More information about this user + * + * @var array + */ + protected $additionalInformation = array(); + + /** + * Information if the user is externally authenticated + * + * Keys: + * + * 0: origin username + * 1: origin field name + * + * @var array + */ + protected $externalUserInformation = array(); + + /** + * Whether restrictions should not apply to this user + * + * @var bool + */ + protected $unrestricted = false; + + /** + * Set of permissions + * + * @var array + */ + protected $permissions = array(); + + /** + * Set of restrictions + * + * @var array + */ + protected $restrictions = array(); + + /** + * Groups for this user + * + * @var array + */ + protected $groups = array(); + + /** + * Roles of this user + * + * @var Role[] + */ + protected $roles = array(); + + /** + * Preferences object + * + * @var Preferences + */ + protected $preferences; + + /** + * Whether the user is authenticated using a HTTP authentication mechanism + * + * @var bool + */ + protected $isHttpUser = false; + + /** + * Creates a user object given the provided information + * + * @param string $username + * @param string $firstname + * @param string $lastname + * @param string $email + */ + public function __construct($username, $firstname = null, $lastname = null, $email = null) + { + $this->setUsername($username); + + if ($firstname !== null) { + $this->setFirstname($firstname); + } + + if ($lastname !== null) { + $this->setLastname($lastname); + } + + if ($email !== null) { + $this->setEmail($email); + } + } + + /** + * Setter for preferences + * + * @param Preferences $preferences + * + * @return $this + */ + public function setPreferences(Preferences $preferences) + { + $this->preferences = $preferences; + return $this; + } + + /** + * Getter for preferences + * + * @return Preferences + */ + public function getPreferences() + { + if ($this->preferences === null) { + $this->preferences = new Preferences(); + } + + return $this->preferences; + } + + /** + * Return all groups this user belongs to + * + * @return array + */ + public function getGroups() + { + return $this->groups; + } + + /** + * Set the groups this user belongs to + * + * @param array $groups + * + * @return $this + */ + public function setGroups(array $groups) + { + $this->groups = $groups; + return $this; + } + + /** + * Return true if the user is a member of this group + * + * @param string $group + * + * @return boolean + */ + public function isMemberOf($group) + { + return in_array($group, $this->groups); + } + + /** + * Get whether restrictions should not apply to this user + * + * @return bool + */ + public function isUnrestricted() + { + return $this->unrestricted; + } + + /** + * Set whether restrictions should not apply to this user + * + * @param bool $state + * + * @return $this + */ + public function setIsUnrestricted($state) + { + $this->unrestricted = (bool) $state; + + return $this; + } + + /** + * Get the user's permissions + * + * @return array + */ + public function getPermissions() + { + return $this->permissions; + } + + /** + * Set the user's permissions + * + * @param array $permissions + * + * @return $this + */ + public function setPermissions(array $permissions) + { + if (! empty($permissions)) { + natcasesort($permissions); + $this->permissions = array_combine($permissions, $permissions); + } + return $this; + } + + /** + * Return restriction information for this user + * + * @param string $name + * + * @return array + */ + public function getRestrictions($name) + { + if (array_key_exists($name, $this->restrictions)) { + return $this->restrictions[$name]; + } + + return array(); + } + + /** + * Set the user's restrictions + * + * @param string[] $restrictions + * + * @return $this + */ + public function setRestrictions(array $restrictions) + { + $this->restrictions = $restrictions; + return $this; + } + + /** + * Get the roles of the user + * + * @return Role[] + */ + public function getRoles() + { + return $this->roles; + } + + /** + * Set the roles of the user + * + * @param Role[] $roles + * + * @return $this + */ + public function setRoles(array $roles) + { + $this->roles = $roles; + return $this; + } + + /** + * Getter for username + * + * @return string + */ + public function getUsername() + { + return $this->domain === null ? $this->localUsername : $this->localUsername . '@' . $this->domain; + } + + /** + * Setter for username + * + * @param string $name + * + * @return $this + */ + public function setUsername($name) + { + $parts = explode('\\', $name, 2); + if (count($parts) === 2) { + list($this->domain, $this->localUsername) = $parts; + } else { + $parts = explode('@', $name, 2); + if (count($parts) === 2) { + list($this->localUsername, $this->domain) = $parts; + } else { + $this->localUsername = $name; + $this->domain = null; + } + } + + return $this; + } + + /** + * Getter for firstname + * + * @return string + */ + public function getFirstname() + { + return $this->firstname; + } + + /** + * Setter for firstname + * + * @param string $name + * + * @return $this + */ + public function setFirstname($name) + { + $this->firstname = $name; + return $this; + } + + /** + * Getter for lastname + * + * @return string + */ + public function getLastname() + { + return $this->lastname; + } + + /** + * Setter for lastname + * + * @param string $name + * + * @return $this + */ + public function setLastname($name) + { + $this->lastname = $name; + return $this; + } + + /** + * Getter for email + * + * @return string + */ + public function getEmail() + { + return $this->email; + } + + /** + * Setter for mail + * + * @param string $mail + * + * @return $this + * + * @throws InvalidArgumentException When an invalid mail is provided + */ + public function setEmail($mail) + { + if ($mail !== null && !filter_var($mail, FILTER_VALIDATE_EMAIL)) { + throw new InvalidArgumentException( + sprintf('Invalid mail given for user %s: %s', $this->getUsername(), $mail) + ); + } + + $this->email = $mail; + return $this; + } + + /** + * Set the domain + * + * @param string $domain + * + * @return $this + */ + public function setDomain($domain) + { + if ($domain && ($domain = trim($domain))) { + $this->domain = $domain; + } + + return $this; + } + + /** + * Get whether the user has a domain + * + * @return bool + */ + public function hasDomain() + { + return $this->domain !== null; + } + + /** + * Get the domain + * + * @return string + * + * @throws ProgrammingError If the user does not have a domain + */ + public function getDomain() + { + if ($this->domain === null) { + throw new ProgrammingError( + 'User does not have a domain.' + . ' Use User::hasDomain() to check whether the user has a domain beforehand.' + ); + } + return $this->domain; + } + + /** + * Get the local username, ie. the username without its domain + * + * @return string + */ + public function getLocalUsername() + { + return $this->localUsername; + } + + /** + * Set additional information about user + * + * @param string $key + * @param string $value + * + * @return $this + */ + public function setAdditional($key, $value) + { + $this->additionalInformation[$key] = $value; + return $this; + } + + /** + * Getter for additional information + * + * @param string $key + * @return mixed|null + */ + public function getAdditional($key) + { + if (isset($this->additionalInformation[$key])) { + return $this->additionalInformation[$key]; + } + + return null; + } + + /** + * Retrieve the user's timezone + * + * If the user did not set a timezone, the default timezone set via config.ini is returned + * + * @return DateTimeZone + */ + public function getTimeZone() + { + $tz = $this->preferences->get('timezone'); + if ($tz === null) { + $tz = date_default_timezone_get(); + } + + return new DateTimeZone($tz); + } + + /** + * Set additional external user information + * + * @param string $username + * @param string $field + * + * @return $this + */ + public function setExternalUserInformation($username, $field) + { + $this->externalUserInformation = array($username, $field); + return $this; + } + + /** + * Get additional external user information + * + * @return array + */ + public function getExternalUserInformation() + { + return $this->externalUserInformation; + } + + /** + * Return true if user has external user information set + * + * @return bool + */ + public function isExternalUser() + { + return ! empty($this->externalUserInformation); + } + + /** + * Get whether the user is authenticated using a HTTP authentication mechanism + * + * @return bool + */ + public function getIsHttpUser() + { + return $this->isHttpUser; + } + + /** + * Set whether the user is authenticated using a HTTP authentication mechanism + * + * @param bool $isHttpUser + * + * @return $this + */ + public function setIsHttpUser($isHttpUser = true) + { + $this->isHttpUser = (bool) $isHttpUser; + return $this; + } + + /** + * Whether the user has a given permission + * + * @param string $requiredPermission + * + * @return bool + */ + public function can($requiredPermission) + { + list($permissions, $refusals) = AdmissionLoader::migrateLegacyPermissions([$requiredPermission]); + if (! empty($permissions)) { + $requiredPermission = array_pop($permissions); + } elseif (! empty($refusals)) { + throw new InvalidArgumentException( + 'Refusals are not supported anymore. Check for a grant instead!' + ); + } + + $granted = false; + foreach ($this->getRoles() as $role) { + if ($role->denies($requiredPermission)) { + return false; + } + + if (! $granted && $role->grants($requiredPermission)) { + $granted = true; + } + } + + return $granted; + } + + /** + * Load and return this user's configured navigation of the given type + * + * @param string $type + * + * @return Navigation + */ + public function getNavigation($type) + { + $config = Config::navigation($type === 'dashboard-pane' ? 'dashlet' : $type, $this->getUsername()); + + if ($type === 'dashboard-pane') { + $panes = array(); + foreach ($config as $dashletName => $dashletConfig) { + // 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 { + $navigation = Navigation::fromConfig($config); + } + + return $navigation; + } +} diff --git a/library/Icinga/User/Preferences.php b/library/Icinga/User/Preferences.php new file mode 100644 index 0000000..b09462b --- /dev/null +++ b/library/Icinga/User/Preferences.php @@ -0,0 +1,169 @@ +<?php +/* Icinga Web 2 | (c) 2013 Icinga Development Team | GPLv2+ */ + +namespace Icinga\User; + +use Countable; + +/** + * User preferences container + * + * Usage example: + * <code> + * <?php + * + * use Icinga\User\Preferences; + * + * $preferences = new Preferences(); // Start with empty preferences + * + * $preferences = new Preferences(array('aPreference' => 'value')); // Start with initial preferences + * + * $preferences->aNewPreference = 'value'; // Set a preference + * + * unset($preferences->aPreference); // Unset a preference + * + * // Retrieve a preference and return a default value if the preference does not exist + * $anotherPreference = $preferences->get('anotherPreference', 'defaultValue'); + */ +class Preferences implements Countable +{ + /** + * Preferences key-value array + * + * @var array + */ + protected $preferences = array(); + + /** + * Constructor + * + * @param array $preferences Preferences key-value array + */ + public function __construct(array $preferences = array()) + { + $this->preferences = $preferences; + } + + /** + * Count all preferences + * + * @return int The number of preferences + */ + public function count(): int + { + return count($this->preferences); + } + + /** + * Determine whether a preference exists + * + * @param string $name + * + * @return bool + */ + public function has($name) + { + return array_key_exists($name, $this->preferences); + } + + /** + * Write data to a preference + * + * @param string $name + * @param mixed $value + */ + public function __set($name, $value) + { + $this->preferences[$name] = $value; + } + + /** + * Retrieve a preference section + * + * @param string $name + * + * @return array|null + */ + public function get($name) + { + if (array_key_exists($name, $this->preferences)) { + return $this->preferences[$name]; + } + + return null; + } + + /** + * Retrieve a value from a specific section + * + * @param string $section + * @param string $name + * @param null $default + * + * @return array|null + */ + public function getValue($section, $name, $default = null) + { + if (array_key_exists($section, $this->preferences) + && array_key_exists($name, $this->preferences[$section]) + ) { + return $this->preferences[$section][$name]; + } + + return $default; + } + + /** + * Magic method so that $obj->value will work. + * + * @param string $name + * + * @return mixed + */ + public function __get($name) + { + return $this->get($name); + } + + /** + * Remove a given preference + * + * @param string $name Preference name + */ + public function remove($name) + { + unset($this->preferences[$name]); + } + + /** + * Determine if a preference is set and is not NULL + * + * @param string $name Preference name + * + * @return bool + */ + public function __isset($name) + { + return isset($this->preferences[$name]); + } + + /** + * Unset a given preference + * + * @param string $name Preference name + */ + public function __unset($name) + { + $this->remove($name); + } + + /** + * Get preferences as array + * + * @return array + */ + public function toArray() + { + return $this->preferences; + } +} diff --git a/library/Icinga/User/Preferences/PreferencesStore.php b/library/Icinga/User/Preferences/PreferencesStore.php new file mode 100644 index 0000000..8ecc677 --- /dev/null +++ b/library/Icinga/User/Preferences/PreferencesStore.php @@ -0,0 +1,344 @@ +<?php +/* Icinga Web 2 | (c) 2014 Icinga Development Team | GPLv2+ */ + +namespace Icinga\User\Preferences; + +use Exception; +use Icinga\Exception\NotReadableError; +use Icinga\Exception\NotWritableError; +use Icinga\User; +use Icinga\User\Preferences; +use Icinga\Data\ConfigObject; +use Icinga\Data\ResourceFactory; +use Icinga\Exception\ConfigurationError; +use Zend_Db_Expr; + +/** + * Preferences store factory + * + * Load and save user preferences by using a database + * + * Usage example: + * <code> + * <?php + * + * use Icinga\Data\ConfigObject; + * use Icinga\User\Preferences; + * use Icinga\User\Preferences\PreferencesStore; + * + * // Create a db store + * $store = PreferencesStore::create( + * new ConfigObject( + * 'resource' => 'resource name' + * ), + * $user // Instance of \Icinga\User + * ); + * + * $preferences = new Preferences($store->load()); + * $preferences->aPreference = 'value'; + * $store->save($preferences); + * </code> + */ +class PreferencesStore +{ + /** + * Column name for username + */ + const COLUMN_USERNAME = 'username'; + + /** + * Column name for section + */ + const COLUMN_SECTION = 'section'; + + /** + * Column name for preference + */ + const COLUMN_PREFERENCE = 'name'; + + /** + * Column name for value + */ + const COLUMN_VALUE = 'value'; + + /** + * Column name for created time + */ + const COLUMN_CREATED_TIME = 'ctime'; + + /** + * Column name for modified time + */ + const COLUMN_MODIFIED_TIME = 'mtime'; + + /** + * Table name + * + * @var string + */ + protected $table = 'icingaweb_user_preference'; + + /** + * Stored preferences + * + * @var array + */ + protected $preferences = []; + + /** + * Store config + * + * @var ConfigObject + */ + protected $config; + + /** + * Given user + * + * @var User + */ + protected $user; + + /** + * Create a new store + * + * @param ConfigObject $config The config for this adapter + * @param User $user The user to which these preferences belong + */ + public function __construct(ConfigObject $config, User $user) + { + $this->config = $config; + $this->user = $user; + $this->init(); + } + + /** + * Getter for the store config + * + * @return ConfigObject + */ + public function getStoreConfig(): ConfigObject + { + return $this->config; + } + + /** + * Getter for the user + * + * @return User + */ + public function getUser(): User + { + return $this->user; + } + + /** + * Initialize the store + */ + protected function init(): void + { + } + + /** + * Load preferences from the database + * + * @return array + * + * @throws NotReadableError In case the database operation failed + */ + public function load(): array + { + try { + $select = $this->getStoreConfig()->connection->getDbAdapter()->select(); + $result = $select + ->from($this->table, [self::COLUMN_SECTION, self::COLUMN_PREFERENCE, self::COLUMN_VALUE]) + ->where(self::COLUMN_USERNAME . ' = ?', $this->getUser()->getUsername()) + ->query() + ->fetchAll(); + } catch (Exception $e) { + throw new NotReadableError( + 'Cannot fetch preferences for user %s from database', + $this->getUser()->getUsername(), + $e + ); + } + + if ($result !== false) { + $values = []; + foreach ($result as $row) { + $values[$row->{self::COLUMN_SECTION}][$row->{self::COLUMN_PREFERENCE}] = $row->{self::COLUMN_VALUE}; + } + + $this->preferences = $values; + } + + return $this->preferences; + } + + /** + * Save the given preferences in the database + * + * @param Preferences $preferences The preferences to save + */ + public function save(Preferences $preferences): void + { + $preferences = $preferences->toArray(); + + $sections = array_keys($preferences); + + foreach ($sections as $section) { + if (! array_key_exists($section, $this->preferences)) { + $this->preferences[$section] = []; + } + + if (! array_key_exists($section, $preferences)) { + $preferences[$section] = []; + } + + $toBeInserted = array_diff_key($preferences[$section], $this->preferences[$section]); + if (!empty($toBeInserted)) { + $this->insert($toBeInserted, $section); + } + + $toBeUpdated = array_intersect_key( + array_diff_assoc($preferences[$section], $this->preferences[$section]), + array_diff_assoc($this->preferences[$section], $preferences[$section]) + ); + + if (!empty($toBeUpdated)) { + $this->update($toBeUpdated, $section); + } + + $toBeDeleted = array_keys(array_diff_key($this->preferences[$section], $preferences[$section])); + if (!empty($toBeDeleted)) { + $this->delete($toBeDeleted, $section); + } + } + } + + /** + * Insert the given preferences into the database + * + * @param array $preferences The preferences to insert + * @param string $section The preferences in section to update + * + * @throws NotWritableError In case the database operation failed + */ + protected function insert(array $preferences, string $section): void + { + /** @var \Zend_Db_Adapter_Abstract $db */ + $db = $this->getStoreConfig()->connection->getDbAdapter(); + + try { + foreach ($preferences as $key => $value) { + $db->insert( + $this->table, + [ + self::COLUMN_USERNAME => $this->getUser()->getUsername(), + $db->quoteIdentifier(self::COLUMN_SECTION) => $section, + $db->quoteIdentifier(self::COLUMN_PREFERENCE) => $key, + self::COLUMN_VALUE => $value, + self::COLUMN_CREATED_TIME => new Zend_Db_Expr('NOW()'), + self::COLUMN_MODIFIED_TIME => new Zend_Db_Expr('NOW()') + ] + ); + } + } catch (Exception $e) { + throw new NotWritableError( + 'Cannot insert preferences for user %s into database', + $this->getUser()->getUsername(), + $e + ); + } + } + + /** + * Update the given preferences in the database + * + * @param array $preferences The preferences to update + * @param string $section The preferences in section to update + * + * @throws NotWritableError In case the database operation failed + */ + protected function update(array $preferences, string $section): void + { + /** @var \Zend_Db_Adapter_Abstract $db */ + $db = $this->getStoreConfig()->connection->getDbAdapter(); + + try { + foreach ($preferences as $key => $value) { + $db->update( + $this->table, + [ + self::COLUMN_VALUE => $value, + self::COLUMN_MODIFIED_TIME => new Zend_Db_Expr('NOW()') + ], + [ + self::COLUMN_USERNAME . '=?' => $this->getUser()->getUsername(), + $db->quoteIdentifier(self::COLUMN_SECTION) . '=?' => $section, + $db->quoteIdentifier(self::COLUMN_PREFERENCE) . '=?' => $key + ] + ); + } + } catch (Exception $e) { + throw new NotWritableError( + 'Cannot update preferences for user %s in database', + $this->getUser()->getUsername(), + $e + ); + } + } + + /** + * Delete the given preference names from the database + * + * @param array $preferenceKeys The preference names to delete + * @param string $section The preferences in section to update + * + * @throws NotWritableError In case the database operation failed + */ + protected function delete(array $preferenceKeys, string $section): void + { + /** @var \Zend_Db_Adapter_Abstract $db */ + $db = $this->getStoreConfig()->connection->getDbAdapter(); + + try { + $db->delete( + $this->table, + [ + self::COLUMN_USERNAME . '=?' => $this->getUser()->getUsername(), + $db->quoteIdentifier(self::COLUMN_SECTION) . '=?' => $section, + $db->quoteIdentifier(self::COLUMN_PREFERENCE) . ' IN (?)' => $preferenceKeys + ] + ); + } catch (Exception $e) { + throw new NotWritableError( + 'Cannot delete preferences for user %s from database', + $this->getUser()->getUsername(), + $e + ); + } + } + + /** + * Create preferences storage adapter from config + * + * @param ConfigObject $config The config for the adapter + * @param User $user The user to which these preferences belong + * + * @return self + * + * @throws ConfigurationError When the configuration defines an invalid storage type + */ + public static function create(ConfigObject $config, User $user): self + { + $resourceConfig = ResourceFactory::getResourceConfig($config->resource); + if ($resourceConfig->db === 'mysql') { + $resourceConfig->charset = 'utf8mb4'; + } + + $config->connection = ResourceFactory::createResource($resourceConfig); + + return new self($config, $user); + } +} diff --git a/library/Icinga/Util/ASN1.php b/library/Icinga/Util/ASN1.php new file mode 100644 index 0000000..9e00258 --- /dev/null +++ b/library/Icinga/Util/ASN1.php @@ -0,0 +1,102 @@ +<?php +/* Icinga Web 2 | (c) 2018 Icinga Development Team | GPLv2+ */ + +namespace Icinga\Util; + +use DateInterval; +use DateTime; +use InvalidArgumentException; + +/** + * Parsers for ASN.1 types + */ +class ASN1 +{ + /** + * Parse the given value based on the "3.3.13. Generalized Time" syntax as specified by IETF RFC 4517 + * + * @param string $value + * + * @return DateTime + * + * @throws InvalidArgumentException + * + * @see https://tools.ietf.org/html/rfc4517#section-3.3.13 + */ + public static function parseGeneralizedTime($value) + { + $generalizedTimePattern = <<<EOD +/\A + (?P<YmdH> + [0-9]{4} # century year + (?:0[1-9]|1[0-2]) # month + (?:0[1-9]|[12][0-9]|3[0-1]) # day + (?:[01][0-9]|2[0-3]) # hour + ) + (?: + (?P<i>[0-5][0-9]) # minute + (?P<s>[0-5][0-9]|60)? # second or leap-second + )? + (?:[.,](?P<frac>[0-9]+))? # fraction + (?P<tz> # g-time-zone + Z + | + [-+] + (?:[01][0-9]|2[0-3]) # hour + (?:[0-5][0-9])? # minute + ) +\z/x +EOD; + + $matches = array(); + + if (preg_match($generalizedTimePattern, $value, $matches)) { + $dateTimeRaw = $matches['YmdH']; + $dateTimeFormat = 'YmdH'; + + if ($matches['i'] !== '') { + $dateTimeRaw .= $matches['i']; + $dateTimeFormat .= 'i'; + + if ($matches['s'] !== '') { + $dateTimeRaw .= $matches['s']; + $dateTimeFormat .= 's'; + $fractionOfSeconds = 1; + } else { + $fractionOfSeconds = 60; + } + } else { + $fractionOfSeconds = 3600; + } + + $dateTimeFormat .= 'O'; + + if ($matches['tz'] === 'Z') { + $dateTimeRaw .= '+0000'; + } else { + $dateTimeRaw .= $matches['tz']; + + if (strlen($matches['tz']) === 3) { + $dateTimeRaw .= '00'; + } + } + + $dateTime = DateTime::createFromFormat($dateTimeFormat, $dateTimeRaw); + + if ($dateTime !== false) { + if (isset($matches['frac'])) { + $dateTime->add(new DateInterval( + 'PT' . round((float) ('0.' . $matches['frac']) * $fractionOfSeconds) . 'S' + )); + } + + return $dateTime; + } + } + + throw new InvalidArgumentException(sprintf( + 'Failed to parse %s based on the ASN.1 standard (GeneralizedTime)', + var_export($value, true) + )); + } +} diff --git a/library/Icinga/Util/Color.php b/library/Icinga/Util/Color.php new file mode 100644 index 0000000..cf88f41 --- /dev/null +++ b/library/Icinga/Util/Color.php @@ -0,0 +1,121 @@ +<?php +/* Icinga Web 2 | (c) 2014 Icinga Development Team | GPLv2+ */ + +namespace Icinga\Util; + +/** + * Provide functions to change and convert colors. + */ +class Color +{ + /** + * Convert a given color string to an rgb-array containing + * each color as a decimal value. + * + * @param $color The color-string #RRGGBB + * + * @return array The converted rgb-array. + */ + public static function rgbAsArray($color) + { + if (substr($color, 0, 1) !== '#') { + $color = '#' . $color; + } + if (strlen($color) !== 7) { + return; + } + $r = (float)intval(substr($color, 1, 2), 16); + $g = (float)intval(substr($color, 3, 2), 16); + $b = (float)intval(substr($color, 5, 2), 16); + return array($r, $g, $b); + } + + /** + * Convert a rgb array to a color-string + * + * @param array $rgb The rgb-array + * + * @return string The color string #RRGGBB + */ + public static function arrayToRgb(array $rgb) + { + $r = (string)dechex($rgb[0]); + $g = (string)dechex($rgb[1]); + $b = (string)dechex($rgb[2]); + return '#' + . (strlen($r) > 1 ? $r : '0' . $r) + . (strlen($g) > 1 ? $g : '0' . $g) + . (strlen($b) > 1 ? $b : '0' . $b); + } + + /** + * Change the saturation for a given color. + * + * @param $color string The color to change + * @param $change float The change. + * 0.0 creates a black-and-white image. + * 0.5 reduces the color saturation by half. + * 1.0 causes no change. + * 2.0 doubles the color saturation. + * @return string + */ + public static function changeSaturation($color, $change) + { + return self::arrayToRgb(self::changeRgbSaturation(self::rgbAsArray($color), $change)); + } + + /** + * Change the brightness for a given color + * + * @param $color string The color to change + * @param $change float The change in percent + * + * @return string + */ + public static function changeBrightness($color, $change) + { + return self::arrayToRgb(self::changeRgbBrightness(self::rgbAsArray($color), $change)); + } + + /** + * @param $rgb array The rgb-array to change + * @param $change float The factor + * + * @return array The updated rgb-array + */ + private static function changeRgbSaturation(array $rgb, $change) + { + $pr = 0.499; // 0.299 + $pg = 0.387; // 0.587 + $pb = 0.114; // 0.114 + $r = $rgb[0]; + $g = $rgb[1]; + $b = $rgb[2]; + $p = sqrt( + $r * $r * $pr + + $g * $g * $pg + + $b * $b * $pb + ); + $rgb[0] = (int)($p + ($r - $p) * $change); + $rgb[1] = (int)($p + ($g - $p) * $change); + $rgb[2] = (int)($p + ($b - $p) * $change); + return $rgb; + } + + /** + * @param $rgb array The rgb-array to change + * @param $change float The factor + * + * @return array The updated rgb-array + */ + private static function changeRgbBrightness(array $rgb, $change) + { + $red = $rgb[0] + ($rgb[0] * $change); + $green = $rgb[1] + ($rgb[1] * $change); + $blue = $rgb[2] + ($rgb[2] * $change); + $rgb[0] = $red < 255 ? (int) $red : 255; + $rgb[1] = $green < 255 ? (int) $green : 255; + $rgb[2] = $blue < 255 ? (int) $blue : 255; + return $rgb; + } +} diff --git a/library/Icinga/Util/ConfigAwareFactory.php b/library/Icinga/Util/ConfigAwareFactory.php new file mode 100644 index 0000000..133887a --- /dev/null +++ b/library/Icinga/Util/ConfigAwareFactory.php @@ -0,0 +1,18 @@ +<?php +/* Icinga Web 2 | (c) 2013 Icinga Development Team | GPLv2+ */ + +namespace Icinga\Util; + +/** + * Interface defining a factory which is configured at runtime + */ +interface ConfigAwareFactory +{ + /** + * Set the factory's config + * + * @param mixed $config + * @throws \Icinga\Exception\ConfigurationError if the given config is not valid + */ + public static function setConfig($config); +} diff --git a/library/Icinga/Util/Csp.php b/library/Icinga/Util/Csp.php new file mode 100644 index 0000000..bd275c6 --- /dev/null +++ b/library/Icinga/Util/Csp.php @@ -0,0 +1,107 @@ +<?php + +/* Icinga Web 2 | (c) 2023 Icinga GmbH | GPLv2+ */ + +namespace Icinga\Util; + +use Icinga\Web\Response; +use Icinga\Web\Window; +use RuntimeException; + +use function ipl\Stdlib\get_php_type; + +/** + * Helper to enable strict content security policy (CSP) + * + * {@see static::addHeader()} adds a strict Content-Security-Policy header with a nonce to still support dynamic CSS + * securely. + * Note that {@see static::createNonce()} must be called first. + * Use {@see static::getStyleNonce()} to access the nonce for dynamic CSS. + * + * A nonce is not created for dynamic JS, + * and it is questionable whether this will ever be supported. + */ +class Csp +{ + /** @var static */ + protected static $instance; + + /** @var ?string */ + protected $styleNonce; + + /** Singleton */ + private function __construct() + { + } + + /** + * Add Content-Security-Policy header with a nonce for dynamic CSS + * + * Note that {@see static::createNonce()} must be called beforehand. + * + * @param Response $response + * + * @throws RuntimeException If no nonce set for CSS + */ + public static function addHeader(Response $response): void + { + $csp = static::getInstance(); + + if (empty($csp->styleNonce)) { + throw new RuntimeException('No nonce set for CSS'); + } + + $response->setHeader('Content-Security-Policy', "style-src 'self' 'nonce-$csp->styleNonce';", true); + } + + /** + * Set/recreate nonce for dynamic CSS + * + * Should always be called upon initial page loads or page reloads, + * as it sets/recreates a nonce for CSS and writes it to a window-aware session. + */ + public static function createNonce(): void + { + $csp = static::getInstance(); + $csp->styleNonce = base64_encode(random_bytes(16)); + + Window::getInstance()->getSessionNamespace('csp')->set('style_nonce', $csp->styleNonce); + } + + /** + * Get nonce for dynamic CSS + * + * @return ?string + */ + public static function getStyleNonce(): ?string + { + return static::getInstance()->styleNonce; + } + + /** + * Get the CSP instance + * + * @return self + */ + protected static function getInstance(): self + { + if (static::$instance === null) { + $csp = new static(); + $nonce = Window::getInstance()->getSessionNamespace('csp')->get('style_nonce'); + if ($nonce !== null && ! is_string($nonce)) { + throw new RuntimeException( + sprintf( + 'Nonce value is expected to be string, got %s instead', + get_php_type($nonce) + ) + ); + } + + $csp->styleNonce = $nonce; + + static::$instance = $csp; + } + + return static::$instance; + } +} diff --git a/library/Icinga/Util/Dimension.php b/library/Icinga/Util/Dimension.php new file mode 100644 index 0000000..6860fd8 --- /dev/null +++ b/library/Icinga/Util/Dimension.php @@ -0,0 +1,123 @@ +<?php +/* Icinga Web 2 | (c) 2013 Icinga Development Team | GPLv2+ */ + +namespace Icinga\Util; + +class Dimension +{ + /** + * Defines this dimension as nr of pixels + */ + const UNIT_PX = "px"; + + /** + * Defines this dimension as width of 'M' in current font + */ + const UNIT_EM = "em"; + + /** + * Defines this dimension as a percentage value + */ + const UNIT_PERCENT = "%"; + + /** + * Defines this dimension in points + */ + const UNIT_PT = "pt"; + + /** + * The current set value for this dimension + * + * @var int + */ + private $value = 0; + + /** + * The unit to interpret the value with + * + * @var string + */ + private $unit = self::UNIT_PX; + + /** + * Create a new Dimension object with the given size and unit + * + * @param int $value The new value + * @param string $unit The unit to use (default: px) + */ + public function __construct($value, $unit = self::UNIT_PX) + { + $this->setValue($value, $unit); + } + + /** + * Change the value and unit of this dimension + * + * @param int $value The new value + * @param string $unit The unit to use (default: px) + */ + public function setValue($value, $unit = self::UNIT_PX) + { + $this->value = intval($value); + $this->unit = $unit; + } + + /** + * Return true when the value is > 0 + * + * @return bool + */ + public function isDefined() + { + return $this->value > 0; + } + + /** + * Return the underlying value without unit information + * + * @return int + */ + public function getValue() + { + return $this->value; + } + + /** + * Return the unit used for the value + * + * @return string + */ + public function getUnit() + { + return $this->unit; + } + + /** + * Return this value with it's according unit as a string + * + * @return string + */ + public function __toString() + { + if (!$this->isDefined()) { + return ""; + } + return $this->value.$this->unit; + } + + /** + * Create a new Dimension object from a string containing the numeric value and the dimension (e.g. 200px, 20%) + * + * @param $string The string to parse + * + * @return Dimension + */ + public static function fromString($string) + { + $matches = array(); + if (!preg_match_all('/^ *([0-9]+)(px|pt|em|\%) */i', $string, $matches)) { + return new Dimension(0); + } + return new Dimension(intval($matches[1][0]), $matches[2][0]); + } +} diff --git a/library/Icinga/Util/DirectoryIterator.php b/library/Icinga/Util/DirectoryIterator.php new file mode 100644 index 0000000..cee37b6 --- /dev/null +++ b/library/Icinga/Util/DirectoryIterator.php @@ -0,0 +1,214 @@ +<?php +/* Icinga Web 2 | (c) 2015 Icinga Development Team | GPLv2+ */ + +namespace Icinga\Util; + +use ArrayIterator; +use InvalidArgumentException; +use RecursiveIterator; + +/** + * Iterator for traversing a directory + */ +class DirectoryIterator implements RecursiveIterator +{ + /** + * Iterate files first + * + * @var int + */ + const FILES_FIRST = 1; + + /** + * Current directory item + * + * @var string|false + */ + private $current; + + /** + * The file extension to filter for + * + * @var string + */ + protected $extension; + + /** + * Scanned files + * + * @var ArrayIterator + */ + private $files; + + /** + * Iterator flags + * + * @var int + */ + protected $flags; + + /** + * Current key + * + * @var string|false + */ + private $key; + + /** + * The path of the directory to traverse + * + * @var string + */ + protected $path; + + /** + * Directory queue if FILES_FIRST flag is set + * + * @var array + */ + private $queue; + + /** + * Whether to skip empty files + * + * Defaults to true. + * + * @var bool + */ + protected $skipEmpty = true; + + /** + * Whether to skip hidden files + * + * Defaults to true. + * + * @var bool + */ + protected $skipHidden = true; + + /** + * Create a new directory iterator from path + * + * The given path will not be validated whether it is readable. Use {@link isReadable()} before creating a new + * directory iterator instance. + * + * @param string $path The path of the directory to traverse + * @param string $extension The file extension to filter for. A leading dot is optional + * @param int $flags Iterator flags + */ + public function __construct($path, $extension = null, $flags = null) + { + if (empty($path)) { + throw new InvalidArgumentException('The path can\'t be empty'); + } + $this->path = $path; + if (! empty($extension)) { + $this->extension = '.' . ltrim($extension, '.'); + } + if ($flags !== null) { + $this->flags = $flags; + } + } + + /** + * Check whether the given path is a directory and is readable + * + * @param string $path The path of the directory + * + * @return bool + */ + public static function isReadable($path) + { + return is_dir($path) && is_readable($path); + } + + public function hasChildren(): bool + { + return static::isReadable($this->current); + } + + public function getChildren(): DirectoryIterator + { + return new static($this->current, $this->extension, $this->flags); + } + + #[\ReturnTypeWillChange] + public function current() + { + return $this->current; + } + + public function next(): void + { + $path = null; + do { + $this->files->next(); + $skip = false; + if (! $this->files->valid()) { + $file = false; + $path = false; + break; + } else { + $file = $this->files->current(); + do { + if ($this->skipHidden && $file[0] === '.') { + $skip = true; + break; + } + + $path = $this->path . '/' . $file; + + if (is_dir($path)) { + if ($this->flags & static::FILES_FIRST === static::FILES_FIRST) { + $this->queue[] = array($path, $file); + $skip = true; + } + break; + } + + if ($this->skipEmpty && ! filesize($path)) { + $skip = true; + break; + } + + if ($this->extension && ! StringHelper::endsWith($file, $this->extension)) { + $skip = true; + break; + } + } while (0); + } + } while ($skip); + + /** @noinspection PhpUndefinedVariableInspection */ + + if ($path === false && ! empty($this->queue)) { + list($path, $file) = array_shift($this->queue); + } + + $this->current = $path; + $this->key = $file; + } + + #[\ReturnTypeWillChange] + public function key() + { + return $this->key; + } + + public function valid(): bool + { + return $this->current !== false; + } + + public function rewind(): void + { + if ($this->files === null) { + $files = scandir($this->path); + natcasesort($files); + $this->files = new ArrayIterator($files); + } + $this->files->rewind(); + $this->queue = array(); + $this->next(); + } +} diff --git a/library/Icinga/Util/EnumeratingFilterIterator.php b/library/Icinga/Util/EnumeratingFilterIterator.php new file mode 100644 index 0000000..0659961 --- /dev/null +++ b/library/Icinga/Util/EnumeratingFilterIterator.php @@ -0,0 +1,30 @@ +<?php +/* Icinga Web 2 | (c) 2014 Icinga Development Team | GPLv2+ */ + +namespace Icinga\Util; + +use FilterIterator; + +/** + * Class EnumeratingFilterIterator + * + * FilterIterator with continuous numeric key (index) + */ +abstract class EnumeratingFilterIterator extends FilterIterator +{ + /** + * @var int + */ + private $index; + + public function rewind(): void + { + parent::rewind(); + $this->index = 0; + } + + public function key(): int + { + return $this->index++; + } +} diff --git a/library/Icinga/Util/Environment.php b/library/Icinga/Util/Environment.php new file mode 100644 index 0000000..8d47b84 --- /dev/null +++ b/library/Icinga/Util/Environment.php @@ -0,0 +1,42 @@ +<?php +/* Icinga Web 2 | (c) 2018 Icinga Development Team | GPLv2+ */ + +namespace Icinga\Util; + +/** + * Helper for configuring the PHP environment + */ +class Environment +{ + /** + * Raise the PHP memory_limit + * + * Unless it is not already set to a higher limit + * + * @param string|int $minimum + */ + public static function raiseMemoryLimit($minimum = '512M') + { + if (is_string($minimum)) { + $minimum = Format::unpackShorthandBytes($minimum); + } + + if (Format::unpackShorthandBytes(ini_get('memory_limit')) < $minimum) { + ini_set('memory_limit', $minimum); + } + } + + /** + * Raise the PHP max_execution_time + * + * Unless it is not already configured to a higher value. + * + * @param int $minimum + */ + public static function raiseExecutionTime($minimum = 300) + { + if ((int) ini_get('max_execution_time') < $minimum) { + ini_set('max_execution_time', $minimum); + } + } +} diff --git a/library/Icinga/Util/File.php b/library/Icinga/Util/File.php new file mode 100644 index 0000000..dad332a --- /dev/null +++ b/library/Icinga/Util/File.php @@ -0,0 +1,195 @@ +<?php +/* Icinga Web 2 | (c) 2014 Icinga Development Team | GPLv2+ */ + +namespace Icinga\Util; + +use SplFileObject; +use ErrorException; +use RuntimeException; +use Icinga\Exception\NotWritableError; + +/** + * File + * + * A class to ease opening files and reading/writing to them. + */ +class File extends SplFileObject +{ + /** + * The mode used to open the file + * + * @var string + */ + protected $openMode; + + /** + * The access mode to use when creating directories + * + * @var int + */ + public static $dirMode = 1528; // 2770 + + /** + * @see SplFileObject::__construct() + */ + public function __construct($filename, $openMode = 'r', $useIncludePath = false, $context = null) + { + $this->openMode = $openMode; + if ($context === null) { + parent::__construct($filename, $openMode, $useIncludePath); + } else { + parent::__construct($filename, $openMode, $useIncludePath, $context); + } + } + + /** + * Create a file using the given access mode and return a instance of File open for writing + * + * @param string $path The path to the file + * @param int $accessMode The access mode to set + * @param bool $recursive Whether missing nested directories of the given path should be created + * + * @return File + * + * @throws RuntimeException In case the file cannot be created or the access mode cannot be set + * @throws NotWritableError In case the path's (existing) parent is not writable + */ + public static function create($path, $accessMode, $recursive = true) + { + $dirPath = dirname($path); + if ($recursive && !is_dir($dirPath)) { + static::createDirectories($dirPath); + } elseif (! is_writable($dirPath)) { + throw new NotWritableError(sprintf('Path "%s" is not writable', $dirPath)); + } + + $file = new static($path, 'x+'); + + if (! @chmod($path, $accessMode)) { + $error = error_get_last(); + throw new RuntimeException(sprintf( + 'Cannot set access mode "%s" on file "%s" (%s)', + decoct($accessMode), + $path, + $error['message'] + )); + } + + return $file; + } + + /** + * Create missing directories + * + * @param string $path + * + * @throws RuntimeException In case a directory cannot be created or the access mode cannot be set + */ + protected static function createDirectories($path) + { + $part = strpos($path, DIRECTORY_SEPARATOR) === 0 ? DIRECTORY_SEPARATOR : ''; + foreach (explode(DIRECTORY_SEPARATOR, ltrim($path, DIRECTORY_SEPARATOR)) as $dir) { + $part .= $dir . DIRECTORY_SEPARATOR; + + if (! is_dir($part)) { + if (! @mkdir($part, static::$dirMode)) { + $error = error_get_last(); + throw new RuntimeException(sprintf( + 'Failed to create missing directory "%s" (%s)', + $part, + $error['message'] + )); + } + + if (! @chmod($part, static::$dirMode)) { + $error = error_get_last(); + throw new RuntimeException(sprintf( + 'Failed to set access mode "%s" for directory "%s" (%s)', + decoct(static::$dirMode), + $part, + $error['message'] + )); + } + } + } + } + + #[\ReturnTypeWillChange] + public function fwrite($str, $length = null) + { + $this->assertOpenForWriting(); + $this->setupErrorHandler(); + $retVal = $length === null ? parent::fwrite($str) : parent::fwrite($str, $length); + restore_error_handler(); + return $retVal; + } + + public function ftruncate($size): bool + { + $this->assertOpenForWriting(); + $this->setupErrorHandler(); + $retVal = parent::ftruncate($size); + restore_error_handler(); + return $retVal; + } + + #[\ReturnTypeWillChange] + public function ftell() + { + $this->setupErrorHandler(); + $retVal = parent::ftell(); + restore_error_handler(); + return $retVal; + } + + public function flock($operation, &$wouldblock = null): bool + { + $this->setupErrorHandler(); + $retVal = parent::flock($operation, $wouldblock); + restore_error_handler(); + return $retVal; + } + + #[\ReturnTypeWillChange] + public function fgetc() + { + $this->setupErrorHandler(); + $retVal = parent::fgetc(); + restore_error_handler(); + return $retVal; + } + + public function fflush(): bool + { + $this->setupErrorHandler(); + $retVal = parent::fflush(); + restore_error_handler(); + return $retVal; + } + + /** + * Setup an error handler that throws a RuntimeException for every emitted E_WARNING + */ + protected function setupErrorHandler() + { + set_error_handler( + function ($errno, $errstr, $errfile, $errline) { + restore_error_handler(); + throw new ErrorException($errstr, 0, $errno, $errfile, $errline); + }, + E_WARNING + ); + } + + /** + * Assert that the file was opened for writing and throw an exception otherwise + * + * @throws NotWritableError In case the file was not opened for writing + */ + protected function assertOpenForWriting() + { + if (!preg_match('@w|a|\+@', $this->openMode)) { + throw new NotWritableError('File not open for writing'); + } + } +} diff --git a/library/Icinga/Util/Format.php b/library/Icinga/Util/Format.php new file mode 100644 index 0000000..1158208 --- /dev/null +++ b/library/Icinga/Util/Format.php @@ -0,0 +1,197 @@ +<?php +/* Icinga Web 2 | (c) 2013 Icinga Development Team | GPLv2+ */ + +namespace Icinga\Util; + +use DateTime; + +class Format +{ + const STANDARD_IEC = 0; + const STANDARD_SI = 1; + protected static $instance; + + protected static $bitPrefix = array( + array('bit', 'Kibit', 'Mibit', 'Gibit', 'Tibit', 'Pibit', 'Eibit', 'Zibit', 'Yibit'), + array('bit', 'kbit', 'Mbit', 'Gbit', 'Tbit', 'Pbit', 'Ebit', 'Zbit', 'Ybit'), + ); + protected static $bitBase = array(1024, 1000); + + protected static $bytePrefix = array( + array('B', 'KiB', 'MiB', 'GiB', 'TiB', 'PiB', 'EiB', 'ZiB', 'YiB'), + array('B', 'kB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB'), + ); + protected static $byteBase = array(1024, 1000); + + protected static $secondPrefix = array('s', 'ms', 'µs', 'ns', 'ps', 'fs', 'as'); + protected static $secondBase = 1000; + + public static function getInstance() + { + if (self::$instance === null) { + self::$instance = new Format; + } + return self::$instance; + } + + public static function bits($value, $standard = self::STANDARD_SI) + { + return self::formatForUnits( + $value, + self::$bitPrefix[$standard], + self::$bitBase[$standard] + ); + } + + public static function bytes($value, $standard = self::STANDARD_IEC) + { + return self::formatForUnits( + $value, + self::$bytePrefix[$standard], + self::$byteBase[$standard] + ); + } + + public static function seconds($value) + { + if ($value === null) { + return ''; + } + + $absValue = abs($value); + + if ($absValue < 60) { + return self::formatForUnits($value, self::$secondPrefix, self::$secondBase); + } elseif ($absValue < 3600) { + return sprintf('%0.2f m', $value / 60); + } elseif ($absValue < 86400) { + return sprintf('%0.2f h', $value / 3600); + } + + // TODO: Do we need weeks, months and years? + return sprintf('%0.2f d', $value / 86400); + } + + protected static function formatForUnits($value, &$units, $base) + { + if ($value === null) { + return ''; + } + + $sign = ''; + if ($value < 0) { + $value = abs($value); + $sign = '-'; + } + + if ($value == 0) { + $pow = $result = 0; + } else { + $pow = floor(log($value, $base)); + $result = $value / pow($base, $pow); + } + + // 1034.23 looks better than 1.03, but 2.03 is fine: + if ($pow > 0 && $result < 2) { + $result = $value / pow($base, --$pow); + } + + return sprintf( + '%s%0.2f %s', + $sign, + $result, + $units[abs($pow)] + ); + } + + /** + * Return the amount of seconds based on the given month + * + * @param DateTime|int $dateTimeOrTimestamp The date and time to use + * + * @return int + */ + public static function secondsByMonth($dateTimeOrTimestamp) + { + if ($dateTimeOrTimestamp === null) { + return 0; + } + + if (!($dt = $dateTimeOrTimestamp) instanceof DateTime) { + $dt = new DateTime(); + $dt->setTimestamp($dateTimeOrTimestamp); + } + + return (int) $dt->format('t') * 24 * 3600; + } + + /** + * Return the amount of seconds based on the given year + * + * @param DateTime|int $dateTimeOrTimestamp The date and time to use + * + * @return int + */ + public static function secondsByYear($dateTimeOrTimestamp) + { + if ($dateTimeOrTimestamp === null) { + return 0; + } + + return (self::isLeapYear($dateTimeOrTimestamp) ? 366 : 365) * 24 * 3600; + } + + /** + * Return whether the given year is a leap year + * + * @param DateTime|int $dateTimeOrTimestamp The date and time to use + * + * @return bool + */ + public static function isLeapYear($dateTimeOrTimestamp) + { + if ($dateTimeOrTimestamp === null) { + return false; + } + + if (!($dt = $dateTimeOrTimestamp) instanceof DateTime) { + $dt = new DateTime(); + $dt->setTimestamp($dateTimeOrTimestamp); + } + + return $dt->format('L') == 1; + } + + /** + * Unpack shorthand bytes PHP directives to bytes + * + * @param string $subject + * + * @return int + */ + public static function unpackShorthandBytes($subject) + { + $base = (int) $subject; + + if ($base <= -1) { + return INF; + } + + switch (strtoupper($subject[strlen($subject) - 1])) { + case 'K': + $multiplier = 1024; + break; + case 'M': + $multiplier = 1024 ** 2; + break; + case 'G': + $multiplier = 1024 ** 3; + break; + default: + $multiplier = 1; + break; + } + + return $base * $multiplier; + } +} diff --git a/library/Icinga/Util/GlobFilter.php b/library/Icinga/Util/GlobFilter.php new file mode 100644 index 0000000..ac0493a --- /dev/null +++ b/library/Icinga/Util/GlobFilter.php @@ -0,0 +1,182 @@ +<?php +/* Icinga Web 2 | (c) 2016 Icinga Development Team | GPLv2+ */ + +namespace Icinga\Util; + +use stdClass; + +/** + * GLOB-like filter for simple data structures + * + * e.g. this filters: + * + * foo.bar.baz + * foo.b*r.baz + * **.baz + * + * match this one: + * + * array( + * 'foo' => array( + * 'bar' => array( + * 'baz' => 'deadbeef' // <--- + * ) + * ) + * ) + */ +class GlobFilter +{ + /** + * The prepared filters + * + * @var array + */ + protected $filters; + + /** + * Create a new filter from a comma-separated list of GLOB-like filters or an array of such lists. + * + * @param string|\Traversable|iterable $filters + */ + public function __construct($filters) + { + $patterns = array(array('')); + $lastIndex1 = $lastIndex2 = 0; + + foreach ((is_string($filters) ? array($filters) : $filters) as $rawPatterns) { + $escape = false; + + foreach (str_split($rawPatterns) as $c) { + if ($escape) { + $escape = false; + $patterns[$lastIndex1][$lastIndex2] .= preg_quote($c, '/'); + } else { + switch ($c) { + case '\\': + $escape = true; + break; + case ',': + $patterns[] = array(''); + ++$lastIndex1; + $lastIndex2 = 0; + break; + case '.': + $patterns[$lastIndex1][] = ''; + ++$lastIndex2; + break; + case '*': + $patterns[$lastIndex1][$lastIndex2] .= '.*'; + break; + default: + $patterns[$lastIndex1][$lastIndex2] .= preg_quote($c, '/'); + } + } + } + + if ($escape) { + $patterns[$lastIndex1][$lastIndex2] .= '\\\\'; + } + } + + $this->filters = array(); + + foreach ($patterns as $pattern) { + foreach ($pattern as $i => $subPattern) { + if ($subPattern === '') { + unset($pattern[$i]); + } elseif ($subPattern === '.*.*') { + $pattern[$i] = '**'; + } elseif ($subPattern === '.*') { + $pattern[$i] = '/^' . $subPattern . '$/'; + } else { + $pattern[$i] = '/^' . trim($subPattern) . '$/i'; + } + } + + if (! empty($pattern)) { + $found = false; + foreach ($pattern as $i => $v) { + if ($found) { + if ($v === '**') { + unset($pattern[$i]); + } else { + $found = false; + } + } elseif ($v === '**') { + $found = true; + } + } + + if (end($pattern) === '**') { + $pattern[] = '/^.*$/'; + } + + $this->filters[] = array_values($pattern); + } + } + } + + /** + * Remove all keys/attributes matching any of $this->filters from $dataStructure + * + * @param stdClass|array $dataStructure + * + * @return stdClass|array The modified copy of $dataStructure + */ + public function removeMatching($dataStructure) + { + foreach ($this->filters as $filter) { + $dataStructure = static::removeMatchingRecursive($dataStructure, $filter); + } + return $dataStructure; + } + + /** + * Helper method for removeMatching() + * + * @param stdClass|array $dataStructure + * @param array $filter + * + * @return stdClass|array + */ + protected static function removeMatchingRecursive($dataStructure, $filter) + { + $multiLevelPattern = $filter[0] === '**'; + if ($multiLevelPattern) { + $dataStructure = static::removeMatchingRecursive($dataStructure, array_slice($filter, 1)); + } + + $isObject = $dataStructure instanceof stdClass; + if ($isObject || is_array($dataStructure)) { + if ($isObject) { + $dataStructure = (array) $dataStructure; + } + + if ($multiLevelPattern) { + foreach ($dataStructure as $k => & $v) { + $v = static::removeMatchingRecursive($v, $filter); + unset($v); + } + } else { + $currentLevel = $filter[0]; + $nextLevels = count($filter) === 1 ? null : array_slice($filter, 1); + foreach ($dataStructure as $k => & $v) { + if (preg_match($currentLevel, (string) $k)) { + if ($nextLevels === null) { + unset($dataStructure[$k]); + } else { + $v = static::removeMatchingRecursive($v, $nextLevels); + } + } + unset($v); + } + } + + if ($isObject) { + $dataStructure = (object) $dataStructure; + } + } + + return $dataStructure; + } +} diff --git a/library/Icinga/Util/Json.php b/library/Icinga/Util/Json.php new file mode 100644 index 0000000..0b89dcc --- /dev/null +++ b/library/Icinga/Util/Json.php @@ -0,0 +1,151 @@ +<?php +/* Icinga Web 2 | (c) 2017 Icinga Development Team | GPLv2+ */ + +namespace Icinga\Util; + +use Icinga\Exception\Json\JsonDecodeException; +use Icinga\Exception\Json\JsonEncodeException; + +/** + * Wrap {@link json_encode()} and {@link json_decode()} with error handling + */ +class Json +{ + /** + * {@link json_encode()} wrapper + * + * @param mixed $value + * @param int $options + * @param int $depth + * + * @return string + * @throws JsonEncodeException + */ + public static function encode($value, $options = 0, $depth = 512) + { + return static::encodeAndSanitize($value, $options, $depth, false); + } + + /** + * {@link json_encode()} wrapper, automatically sanitizes bad UTF-8 + * + * @param mixed $value + * @param int $options + * @param int $depth + * + * @return string + * @throws JsonEncodeException + */ + public static function sanitize($value, $options = 0, $depth = 512) + { + return static::encodeAndSanitize($value, $options, $depth, true); + } + + /** + * {@link json_encode()} wrapper, sanitizes bad UTF-8 + * + * @param mixed $value + * @param int $options + * @param int $depth + * @param bool $autoSanitize Automatically sanitize invalid UTF-8 (if any) + * + * @return string + * @throws JsonEncodeException + */ + protected static function encodeAndSanitize($value, $options, $depth, $autoSanitize) + { + $encoded = json_encode($value, $options, $depth); + + switch (json_last_error()) { + case JSON_ERROR_NONE: + return $encoded; + + /** @noinspection PhpMissingBreakStatementInspection */ + case JSON_ERROR_UTF8: + if ($autoSanitize) { + return static::encode(static::sanitizeUtf8Recursive($value), $options, $depth); + } + // Fallthrough + + default: + throw new JsonEncodeException('%s: %s', json_last_error_msg(), var_export($value, true)); + } + } + + /** + * {@link json_decode()} wrapper + * + * @param string $json + * @param bool $assoc + * @param int $depth + * @param int $options + * + * @return mixed + * @throws JsonDecodeException + */ + public static function decode($json, $assoc = false, $depth = 512, $options = 0) + { + $decoded = $json ? json_decode($json, $assoc, $depth, $options) : null; + + if (json_last_error() !== JSON_ERROR_NONE) { + throw new JsonDecodeException('%s: %s', json_last_error_msg(), var_export($json, true)); + } + return $decoded; + } + + /** + * Replace bad byte sequences in UTF-8 strings inside the given JSON-encodable structure with question marks + * + * @param mixed $value + * + * @return mixed + */ + protected static function sanitizeUtf8Recursive($value) + { + switch (gettype($value)) { + case 'string': + return static::sanitizeUtf8String($value); + + case 'array': + $sanitized = array(); + + foreach ($value as $key => $val) { + if (is_string($key)) { + $key = static::sanitizeUtf8String($key); + } + + $sanitized[$key] = static::sanitizeUtf8Recursive($val); + } + + return $sanitized; + + case 'object': + $sanitized = array(); + + foreach ($value as $key => $val) { + if (is_string($key)) { + $key = static::sanitizeUtf8String($key); + } + + $sanitized[$key] = static::sanitizeUtf8Recursive($val); + } + + return (object) $sanitized; + + default: + return $value; + } + } + + /** + * Replace bad byte sequences in the given UTF-8 string with question marks + * + * @param string $string + * + * @return string + */ + protected static function sanitizeUtf8String($string) + { + return mb_convert_encoding($string, 'UTF-8', 'UTF-8'); + } +} diff --git a/library/Icinga/Util/LessParser.php b/library/Icinga/Util/LessParser.php new file mode 100644 index 0000000..1e07aa9 --- /dev/null +++ b/library/Icinga/Util/LessParser.php @@ -0,0 +1,15 @@ +<?php +/* Icinga Web 2 | (c) 2021 Icinga GmbH | GPLv2+ */ + +namespace Icinga\Util; + +use Icinga\Less\Visitor; +use lessc; + +class LessParser extends lessc +{ + public function __construct() + { + $this->setOption('plugins', [new Visitor()]); + } +} diff --git a/library/Icinga/Util/StringHelper.php b/library/Icinga/Util/StringHelper.php new file mode 100644 index 0000000..67a836b --- /dev/null +++ b/library/Icinga/Util/StringHelper.php @@ -0,0 +1,184 @@ +<?php +/* Icinga Web 2 | (c) 2013 Icinga Development Team | GPLv2+ */ + +namespace Icinga\Util; + +/** + * Common string functions + */ +class StringHelper +{ + /** + * Split string into an array and trim spaces + * + * @param string $value + * @param string $delimiter + * @param int $limit + * + * @return array + */ + public static function trimSplit($value, $delimiter = ',', $limit = null) + { + if ($value === null) { + return []; + } + + if ($limit !== null) { + $exploded = explode($delimiter, $value, $limit); + } else { + $exploded = explode($delimiter, $value); + } + + return array_map('trim', $exploded); + } + + /** + * Uppercase the first character of each word in a string + * + * Converts 'first_name' to 'FirstName' for example. + * + * @param string $name + * @param string $separator Word separator + * + * @return string + */ + public static function cname($name, $separator = '_') + { + if ($name === null) { + return ''; + } + + return str_replace(' ', '', ucwords(str_replace($separator, ' ', strtolower($name)))); + } + + /** + * Add ellipsis when a string is longer than max length + * + * @param string $string + * @param int $maxLength + * @param string $ellipsis + * + * @return string + */ + public static function ellipsis($string, $maxLength, $ellipsis = '...') + { + if ($string === null) { + return ''; + } + + if (strlen($string) > $maxLength) { + return substr($string, 0, $maxLength - strlen($ellipsis)) . $ellipsis; + } + + return $string; + } + + /** + * Add ellipsis in the center of a string when a string is longer than max length + * + * @param string $string + * @param int $maxLength + * @param string $ellipsis + * + * @return string + */ + public static function ellipsisCenter($string, $maxLength, $ellipsis = '...') + { + if ($string === null) { + return ''; + } + + $start = ceil($maxLength / 2.0); + $end = floor($maxLength / 2.0); + if (strlen($string) > $maxLength) { + return substr($string, 0, $start - strlen($ellipsis)) . $ellipsis . substr($string, - $end); + } + + return $string; + } + + /** + * Find and return all similar strings in $possibilites matching $string with the given minimum $similarity + * + * @param string $string + * @param array $possibilities + * @param float $similarity + * + * @return array + */ + public static function findSimilar($string, array $possibilities, $similarity = 0.33) + { + if (empty($string)) { + return array(); + } + + $matches = array(); + foreach ($possibilities as $possibility) { + $distance = levenshtein($string, $possibility); + if ($distance / strlen($string) <= $similarity) { + $matches[] = $possibility; + } + } + + return $matches; + } + + /** + * Test whether the given string ends with the given suffix + * + * @param string $string The string to test + * @param string $suffix The suffix the string must end with + * + * @return bool + */ + public static function endsWith($string, $suffix) + { + if ($string === null) { + return false; + } + + $stringSuffix = substr($string, -strlen($suffix)); + return $stringSuffix !== false ? $stringSuffix === $suffix : false; + } + + /** + * Generates an array of strings that constitutes the cartesian product of all passed sets, with all + * string combinations concatenated using the passed join-operator. + * + * <pre> + * cartesianProduct( + * array(array('foo', 'bar'), array('mumble', 'grumble', null)), + * '_' + * ); + * => array('foo_mumble', 'foo_grumble', 'bar_mumble', 'bar_grumble', 'foo', 'bar') + * </pre> + * + * @param array $sets An array of arrays containing all sets for which the cartesian + * product should be calculated. + * @param string $glue The glue used to join the strings, defaults to ''. + * + * @returns array The cartesian product in one array of strings. + */ + public static function cartesianProduct(array $sets, $glue = '') + { + $product = null; + foreach ($sets as $set) { + if (! isset($product)) { + $product = $set; + } else { + $newProduct = array(); + foreach ($product as $strA) { + foreach ($set as $strB) { + if ($strB === null) { + $newProduct []= $strA; + } else { + $newProduct []= $strA . $glue . $strB; + } + } + } + $product = $newProduct; + } + } + return $product; + } +} diff --git a/library/Icinga/Util/TimezoneDetect.php b/library/Icinga/Util/TimezoneDetect.php new file mode 100644 index 0000000..4967c7f --- /dev/null +++ b/library/Icinga/Util/TimezoneDetect.php @@ -0,0 +1,107 @@ +<?php +/* Icinga Web 2 | (c) 2014 Icinga Development Team | GPLv2+ */ + +namespace Icinga\Util; + +/** + * Retrieve timezone information from cookie + */ +class TimezoneDetect +{ + /** + * If detection was successful + * + * @var bool + */ + private static $success; + + /** + * Timezone offset in minutes + * + * @var int + */ + private static $offset = 0; + + /** + * @var string + */ + private static $timezoneName; + + /** + * Cookie name + * + * @var string + */ + public static $cookieName = 'icingaweb2-tzo'; + + /** + * Timezone name + * + * @var string + */ + private static $timezone; + + /** + * Create new object and try to identify the timezone + */ + public function __construct() + { + if (self::$success !== null) { + return; + } + + if (array_key_exists(self::$cookieName, $_COOKIE)) { + $matches = array(); + if (preg_match('/\A(-?\d+)[\-,](\d+)\z/', $_COOKIE[self::$cookieName], $matches)) { + $offset = $matches[1]; + $timezoneName = timezone_name_from_abbr('', (int) $offset, (int) $matches[2]); + + self::$success = (bool) $timezoneName; + if (self::$success) { + self::$offset = $offset; + self::$timezoneName = $timezoneName; + } + } + } + } + + /** + * Get offset + * + * @return int + */ + public function getOffset() + { + return self::$offset; + } + + /** + * Get timezone name + * + * @return string + */ + public function getTimezoneName() + { + return self::$timezoneName; + } + + /** + * True on success + * + * @return bool + */ + public function success() + { + return self::$success; + } + + /** + * Reset object + */ + public function reset() + { + self::$success = null; + self::$timezoneName = null; + self::$offset = 0; + } +} diff --git a/library/Icinga/Web/Announcement.php b/library/Icinga/Web/Announcement.php new file mode 100644 index 0000000..9835ce0 --- /dev/null +++ b/library/Icinga/Web/Announcement.php @@ -0,0 +1,158 @@ +<?php +/* Icinga Web 2 | (c) 2016 Icinga Development Team | GPLv2+ */ + +namespace Icinga\Web; + +/** + * An announcement to be displayed prominently in the web UI + */ +class Announcement +{ + /** + * @var string + */ + protected $author; + + /** + * @var string + */ + protected $message; + + /** + * @var int + */ + protected $start; + + /** + * @var int + */ + protected $end; + + /** + * Hash of the message + * + * @var string|null + */ + protected $hash = null; + + /** + * Announcement constructor + * + * @param array $properties + */ + public function __construct(array $properties = array()) + { + foreach ($properties as $key => $value) { + $method = 'set' . ucfirst($key); + if (method_exists($this, $method)) { + $this->$method($value); + } + } + } + + /** + * Get the author of the acknowledged + * + * @return string + */ + public function getAuthor() + { + return $this->author; + } + + /** + * Set the author of the acknowledged + * + * @param string $author + * + * @return $this + */ + public function setAuthor($author) + { + $this->author = $author; + return $this; + } + + /** + * Get the message of the acknowledged + * + * @return string + */ + public function getMessage() + { + return $this->message; + } + + /** + * Set the message of the acknowledged + * + * @param string $message + * + * @return $this + */ + public function setMessage($message) + { + $this->message = $message; + $this->hash = null; + return $this; + } + + /** + * Get the start date and time of the acknowledged + * + * @return int + */ + public function getStart() + { + return $this->start; + } + + /** + * Set the start date and time of the acknowledged + * + * @param int $start + * + * @return $this + */ + public function setStart($start) + { + $this->start = $start; + return $this; + } + + /** + * Get the end date and time of the acknowledged + * + * @return int + */ + public function getEnd() + { + return $this->end; + } + + /** + * Set the end date and time of the acknowledged + * + * @param int $end + * + * @return $this + */ + public function setEnd($end) + { + $this->end = $end; + return $this; + } + + /** + * Get the hash of the acknowledgement + * + * @return string + */ + public function getHash() + { + if ($this->hash === null) { + $this->hash = md5($this->message); + } + return $this->hash; + } +} diff --git a/library/Icinga/Web/Announcement/AnnouncementCookie.php b/library/Icinga/Web/Announcement/AnnouncementCookie.php new file mode 100644 index 0000000..6d23872 --- /dev/null +++ b/library/Icinga/Web/Announcement/AnnouncementCookie.php @@ -0,0 +1,138 @@ +<?php +/* Icinga Web 2 | (c) 2016 Icinga Development Team | GPLv2+ */ + +namespace Icinga\Web\Announcement; + +use Icinga\Util\Json; +use Icinga\Web\Cookie; + +/** + * Handle acknowledged announcements via cookie + */ +class AnnouncementCookie extends Cookie +{ + /** + * Array of hashes representing acknowledged announcements + * + * @var string[] + */ + protected $acknowledged = array(); + + /** + * ETag of the last known announcements.ini + * + * @var string + */ + protected $etag; + + /** + * Timestamp of the next active acknowledgement, if any + * + * @var int|null + */ + protected $nextActive; + + /** + * AnnouncementCookie constructor + */ + public function __construct() + { + parent::__construct('icingaweb2-announcements'); + $this->setExpire(2147483648); + if (isset($_COOKIE['icingaweb2-announcements'])) { + $cookie = json_decode($_COOKIE['icingaweb2-announcements'], true); + if ($cookie !== null) { + if (isset($cookie['acknowledged'])) { + $this->setAcknowledged($cookie['acknowledged']); + } + if (isset($cookie['etag'])) { + $this->setEtag($cookie['etag']); + } + if (isset($cookie['next'])) { + $this->setNextActive($cookie['next']); + } + } + } + } + + /** + * Get the hashes of the acknowledged announcements + * + * @return string[] + */ + public function getAcknowledged() + { + return $this->acknowledged; + } + + /** + * Set the hashes of the acknowledged announcements + * + * @param string[] $acknowledged + * + * @return $this + */ + public function setAcknowledged(array $acknowledged) + { + $this->acknowledged = $acknowledged; + return $this; + } + + /** + * Get the ETag + * + * @return string + */ + public function getEtag() + { + return $this->etag; + } + + /** + * Set the ETag + * + * @param string $etag + * + * @return $this + */ + public function setEtag($etag) + { + $this->etag = $etag; + return $this; + } + + /** + * Get the timestamp of the next active announcement + * + * @return ?int + */ + public function getNextActive() + { + return $this->nextActive; + } + + /** + * Set the timestamp of the next active announcement + * + * @param ?int $nextActive + * + * @return $this + */ + public function setNextActive(?int $nextActive) + { + $this->nextActive = $nextActive; + return $this; + } + + /** + * {@inheritdoc} + */ + public function getValue() + { + return Json::encode(array( + 'acknowledged' => $this->getAcknowledged(), + 'etag' => $this->getEtag(), + 'next' => $this->getNextActive() + )); + } +} diff --git a/library/Icinga/Web/Announcement/AnnouncementIniRepository.php b/library/Icinga/Web/Announcement/AnnouncementIniRepository.php new file mode 100644 index 0000000..d972a1d --- /dev/null +++ b/library/Icinga/Web/Announcement/AnnouncementIniRepository.php @@ -0,0 +1,152 @@ +<?php +/* Icinga Web 2 | (c) 2016 Icinga Development Team | GPLv2+ */ + +namespace Icinga\Web\Announcement; + +use DateTime; +use Icinga\Data\ConfigObject; +use Icinga\Data\Filter\Filter; +use Icinga\Data\Filter\FilterAnd; +use Icinga\Data\SimpleQuery; +use Icinga\Repository\IniRepository; +use Icinga\Web\Announcement; + +/** + * A collection of announcements stored in an INI file + */ +class AnnouncementIniRepository extends IniRepository +{ + protected $queryColumns = array('announcement' => array('id', 'author', 'message', 'hash', 'start', 'end')); + + protected $triggers = array('announcement'); + + protected $configs = array('announcement' => array( + 'name' => 'announcements', + 'keyColumn' => 'id' + )); + + protected $conversionRules = array('announcement' => array( + 'start' => 'timestamp', + 'end' => 'timestamp' + )); + + /** + * Get a DateTime's timestamp + * + * @param DateTime $datetime + * + * @return int|null + */ + protected function persistTimestamp(DateTime $datetime) + { + return $datetime === null ? null : $datetime->getTimestamp(); + } + + /** + * Before-insert trigger (per row) + * + * @param ConfigObject $new The original data to insert + * + * @return ConfigObject The eventually modified data to insert + */ + protected function onInsertAnnouncement(ConfigObject $new) + { + if (! isset($new->id)) { + $new->id = uniqid(); + } + + if (! isset($new->hash)) { + $announcement = new Announcement($new->toArray()); + $new->hash = $announcement->getHash(); + } + + return $new; + } + + /** + * Before-update trigger (per row) + * + * @param ConfigObject $old The original data as currently stored + * @param ConfigObject $new The original data to update + * + * @return ConfigObject The eventually modified data to update + */ + protected function onUpdateAnnouncement(ConfigObject $old, ConfigObject $new) + { + if ($new->message !== $old->message) { + $announcement = new Announcement($new->toArray()); + $new->hash = $announcement->getHash(); + } + + return $new; + } + + /** + * Get the ETag of the announcements.ini file + * + * @return string + */ + public function getEtag() + { + $file = $this->getDataSource('announcement')->getConfigFile(); + + if (@is_readable($file)) { + $mtime = filemtime($file); + $size = filesize($file); + + return hash('crc32', $mtime . $size); + } + + return null; + } + + /** + * Get the query for all active announcements + * + * @return SimpleQuery + */ + public function findActive() + { + $now = new DateTime(); + + $query = $this + ->select(array('hash', 'message', 'start')) + ->setFilter(new FilterAnd(array( + Filter::expression('start', '<=', $now), + Filter::expression('end', '>=', $now) + ))) + ->order('start'); + + return $query; + } + + /** + * Get the timestamp of the next active announcement + * + * @return int|null + */ + public function findNextActive() + { + $now = new DateTime(); + + $query = $this + ->select(array('start', 'end')) + ->setFilter(Filter::matchAny(array( + Filter::expression('start', '>', $now), Filter::expression('end', '>', $now) + ))); + + $refresh = null; + + foreach ($query as $row) { + $min = min($row->start, $row->end); + + if ($refresh === null) { + $refresh = $min; + } else { + $refresh = min($refresh, $min); + } + } + + return $refresh; + } +} diff --git a/library/Icinga/Web/ApplicationStateCookie.php b/library/Icinga/Web/ApplicationStateCookie.php new file mode 100644 index 0000000..e40c17b --- /dev/null +++ b/library/Icinga/Web/ApplicationStateCookie.php @@ -0,0 +1,74 @@ +<?php +/* Icinga Web 2 | (c) 2018 Icinga Development Team | GPLv2+ */ + +namespace Icinga\Web; + +use Icinga\Application\Logger; +use Icinga\Authentication\Auth; +use Icinga\Exception\Json\JsonDecodeException; +use Icinga\Util\Json; + +/** + * Handle acknowledged application state messages via cookie + */ +class ApplicationStateCookie extends Cookie +{ + /** @var array */ + protected $acknowledgedMessages = []; + + public function __construct() + { + parent::__construct('icingaweb2-application-state'); + + $this->setExpire(2147483648); + + if (isset($_COOKIE['icingaweb2-application-state'])) { + try { + $cookie = Json::decode($_COOKIE['icingaweb2-application-state'], true); + } catch (JsonDecodeException $e) { + Logger::error( + "Can't decode the application state cookie of user '%s'. An error occurred: %s", + Auth::getInstance()->getUser()->getUsername(), + $e + ); + + return; + } + + if (isset($cookie['acknowledged-messages'])) { + $this->setAcknowledgedMessages($cookie['acknowledged-messages']); + } + } + } + + /** + * Get the acknowledged messages + * + * @return array + */ + public function getAcknowledgedMessages() + { + return $this->acknowledgedMessages; + } + + /** + * Set the acknowledged messages + * + * @param array $acknowledged + * + * @return $this + */ + public function setAcknowledgedMessages(array $acknowledged) + { + $this->acknowledgedMessages = $acknowledged; + + return $this; + } + + public function getValue() + { + return Json::encode([ + 'acknowledged-messages' => $this->getAcknowledgedMessages() + ]); + } +} diff --git a/library/Icinga/Web/Controller.php b/library/Icinga/Web/Controller.php new file mode 100644 index 0000000..008fbf6 --- /dev/null +++ b/library/Icinga/Web/Controller.php @@ -0,0 +1,264 @@ +<?php +/* Icinga Web 2 | (c) 2014 Icinga Development Team | GPLv2+ */ + +namespace Icinga\Web; + +use Icinga\Data\Filterable; +use Icinga\Data\Sortable; +use Icinga\Data\QueryInterface; +use Icinga\Exception\Http\HttpBadRequestException; +use Icinga\Exception\Http\HttpNotFoundException; +use Icinga\Web\Controller\ModuleActionController; +use Icinga\Web\Widget\Limiter; +use Icinga\Web\Widget\Paginator; +use Icinga\Web\Widget\SortBox; + +/** + * This is the controller all modules should inherit from + * We will flip code with the ModuleActionController as soon as a couple + * of pending feature branches are merged back to the master. + * + * @property View $view + */ +class Controller extends ModuleActionController +{ + /** + * Cache for page size configured via user preferences + * + * @var false|int + */ + protected $userPageSize; + + /** + * @see ActionController::init + */ + public function init() + { + parent::init(); + $this->handleSortControlSubmit(); + } + + /** + * Check whether the sort control has been submitted and redirect using GET parameters + */ + protected function handleSortControlSubmit() + { + $request = $this->getRequest(); + if (! $request->isPost()) { + return; + } + + if (($sort = $request->getPost('sort')) || ($direction = $request->getPost('dir'))) { + $url = Url::fromRequest(); + if ($sort) { + $url->setParam('sort', $sort); + $url->remove('dir'); + } else { + $url->setParam('dir', $direction); + } + + $this->redirectNow($url); + } + } + + /** + * Immediately respond w/ HTTP 400 + * + * @param string $message Exception message or exception format string + * @param mixed ...$arg Format string argument + * + * @throws HttpBadRequestException + */ + public function httpBadRequest($message) + { + throw HttpBadRequestException::create(func_get_args()); + } + + /** + * Immediately respond w/ HTTP 404 + * + * @param string $message Exception message or exception format string + * @param mixed ...$arg Format string argument + * + * @throws HttpNotFoundException + */ + public function httpNotFound($message) + { + throw HttpNotFoundException::create(func_get_args()); + } + + /** + * Render the given form using a simple view script + * + * @param Form $form + * @param string $tab + */ + public function renderForm(Form $form, $tab) + { + $this->getTabs()->add(uniqid(), array( + 'active' => true, + 'label' => $tab, + 'url' => Url::fromRequest() + )); + $this->view->form = $form; + $this->render('simple-form', null, true); + } + + /** + * Create a SortBox widget and apply its sort rules on the given query + * + * The widget is set on the `sortBox' view property only if the current view has not been requested as compact + * + * @param array $columns An array containing the sort columns, with the + * submit value as the key and the label as the value + * @param Sortable $query Query to apply the user chosen sort rules on + * @param array $defaults An array containing default sort directions for specific columns + * + * @return $this + */ + protected function setupSortControl(array $columns, Sortable $query = null, array $defaults = null) + { + $request = $this->getRequest(); + $sortBox = SortBox::create('sortbox-' . $request->getActionName(), $columns, $defaults); + $sortBox->setRequest($request); + + if ($query) { + $sortBox->setQuery($query); + $sortBox->handleRequest($request); + } + + if (! $this->view->compact) { + $this->view->sortBox = $sortBox; + } + + return $this; + } + + /** + * Create a Limiter widget at the `limiter' view property + * + * In case the current view has been requested as compact this method does nothing. + * + * @param int $itemsPerPage Default number of items per page + * + * @return $this + */ + protected function setupLimitControl($itemsPerPage = 25) + { + if (! $this->view->compact) { + $this->view->limiter = new Limiter(); + $this->view->limiter->setDefaultLimit($this->getPageSize($itemsPerPage)); + } + + return $this; + } + + /** + * Get the page size configured via user preferences or return the default value + * + * @param ?int $default + * + * @return int + */ + protected function getPageSize($default) + { + if ($this->userPageSize === null) { + $user = $this->Auth()->getUser(); + if ($user !== null) { + $pageSize = $user->getPreferences()->getValue('icingaweb', 'default_page_size'); + $this->userPageSize = $pageSize ? (int) $pageSize : false; + } else { + $this->userPageSize = false; + } + } + + return $this->userPageSize !== false ? $this->userPageSize : $default; + } + + /** + * Apply the given page limit and number on the given query and setup a paginator for it + * + * The $itemsPerPage and $pageNumber parameters are only applied if not available in the current request. + * The paginator is set on the `paginator' view property only if the current view has not been requested as compact. + * + * @param QueryInterface $query The query to create a paginator for + * @param int $itemsPerPage Default number of items per page + * @param int $pageNumber Default page number + * + * @return $this + */ + protected function setupPaginationControl(QueryInterface $query, $itemsPerPage = 25, $pageNumber = 0) + { + $request = $this->getRequest(); + $limit = $request->getParam('limit', $this->getPageSize($itemsPerPage)); + $page = $request->getParam('page', $pageNumber); + $query->limit($limit, $page > 0 ? ($page - 1) * $limit : 0); + + if (! $this->view->compact) { + $paginator = new Paginator(); + $paginator->setQuery($query); + $this->view->paginator = $paginator; + } + + return $this; + } + + /** + * Create a FilterEditor widget and apply the user's chosen filter options on the given filterable + * + * The widget is set on the `filterEditor' view property only if the current view has not been requested as compact. + * The optional $filterColumns parameter should be an array of key-value pairs where the key is the name of the + * column and the value the label to show to the user. The optional $searchColumns parameter should be an array + * of column names to be used to handle quick searches. + * + * If the given filterable is an instance of Icinga\Data\FilterColumns, $filterable->getFilterColumns() and + * $filterable->getSearchColumns() is called to provide the respective columns if $filterColumns or $searchColumns + * is not given. + * + * @param Filterable $filterable The filterable to create a filter editor for + * @param array $filterColumns The filter columns to offer to the user + * @param array $searchColumns The search columns to utilize for quick searches + * @param array $preserveParams The url parameters to preserve + * + * @return $this + * + * @todo Preserving and ignoring parameters should be configurable (another two method params? property magic?) + */ + protected function setupFilterControl( + Filterable $filterable, + array $filterColumns = null, + array $searchColumns = null, + array $preserveParams = null + ) { + $defaultPreservedParams = array( + 'limit', // setupPaginationControl() + 'sort', // setupSortControl() + 'dir', // setupSortControl() + 'backend', // Framework + 'showCompact', // Framework + '_dev' // Framework + ); + + $editor = Widget::create('filterEditor'); + /** @var \Icinga\Web\Widget\FilterEditor $editor */ + call_user_func_array( + array($editor, 'preserveParams'), + array_merge($defaultPreservedParams, $preserveParams ?: array()) + ); + + $editor + ->setQuery($filterable) + ->ignoreParams('page') // setupPaginationControl() + ->setColumns($filterColumns) + ->setSearchColumns($searchColumns) + ->handleRequest($this->getRequest()); + + if ($this->view->compact) { + $editor->setVisible(false); + } + + $this->view->filterEditor = $editor; + + return $this; + } +} diff --git a/library/Icinga/Web/Controller/ActionController.php b/library/Icinga/Web/Controller/ActionController.php new file mode 100644 index 0000000..2e36d7d --- /dev/null +++ b/library/Icinga/Web/Controller/ActionController.php @@ -0,0 +1,617 @@ +<?php +/* Icinga Web 2 | (c) 2013 Icinga Development Team | GPLv2+ */ + +namespace Icinga\Web\Controller; + +use Icinga\Application\Modules\Module; +use Icinga\Common\PdfExport; +use Icinga\File\Pdf; +use Icinga\Util\Csp; +use Icinga\Web\View; +use ipl\I18n\Translation; +use Zend_Controller_Action; +use Zend_Controller_Action_HelperBroker; +use Zend_Controller_Request_Abstract; +use Zend_Controller_Response_Abstract; +use Icinga\Application\Benchmark; +use Icinga\Application\Config; +use Icinga\Authentication\Auth; +use Icinga\Exception\Http\HttpMethodNotAllowedException; +use Icinga\Exception\IcingaException; +use Icinga\Exception\ProgrammingError; +use Icinga\Forms\AutoRefreshForm; +use Icinga\Security\SecurityException; +use Icinga\Web\Session; +use Icinga\Web\Url; +use Icinga\Web\UrlParams; +use Icinga\Web\Widget\Tabs; +use Icinga\Web\Window; + +/** + * Base class for all core action controllers + * + * All Icinga Web core controllers should extend this class + * + * @method \Icinga\Web\Request getRequest() { + * {@inheritdoc} + * @return \Icinga\Web\Request + * } + * + * @method \Icinga\Web\Response getResponse() { + * {@inheritdoc} + * @return \Icinga\Web\Response + * } + */ +class ActionController extends Zend_Controller_Action +{ + use Translation; + use PdfExport { + sendAsPdf as private newSendAsPdf; + } + + /** + * The login route to use when requiring authentication + */ + const LOGIN_ROUTE = 'authentication/login'; + + /** + * The default page title to use + */ + const DEFAULT_TITLE = 'Icinga Web'; + + /** + * Whether the controller requires the user to be authenticated + * + * @var bool + */ + protected $requiresAuthentication = true; + + /** + * The current module's name + * + * @var string + */ + protected $moduleName; + + /** + * A page's automatic refresh interval + * + * The initial value will not be subject to a user's preferences. + * + * @var int + */ + protected $autorefreshInterval; + + protected $reloadCss = false; + + protected $window; + + protected $rerenderLayout = false; + + /** + * The inline layout (inside columns) to use + * + * @var string + */ + protected $inlineLayout = 'inline'; + + /** + * The inner layout (inside the body) to use + * + * @var string + */ + protected $innerLayout = 'body'; + + /** + * Authentication manager + * + * @var Auth|null + */ + protected $auth; + + /** + * URL parameters + * + * @var UrlParams + */ + protected $params; + + /** + * @var View + */ + public $view; + + /** + * The constructor starts benchmarking, loads the configuration and sets + * other useful controller properties + * + * @param Zend_Controller_Request_Abstract $request + * @param Zend_Controller_Response_Abstract $response + * @param array $invokeArgs Any additional invocation arguments + */ + public function __construct( + Zend_Controller_Request_Abstract $request, + Zend_Controller_Response_Abstract $response, + array $invokeArgs = array() + ) { + /** @var \Icinga\Web\Request $request */ + /** @var \Icinga\Web\Response $response */ + $this->params = UrlParams::fromQueryString(); + + $this->setRequest($request) + ->setResponse($response) + ->_setInvokeArgs($invokeArgs); + $this->_helper = new Zend_Controller_Action_HelperBroker($this); + + $moduleName = $this->getModuleName(); + $this->view->defaultTitle = static::DEFAULT_TITLE; + $this->translationDomain = $moduleName !== 'default' ? $moduleName : 'icinga'; + $this->view->translationDomain = $this->translationDomain; + $this->_helper->layout()->isIframe = $request->getUrl()->shift('isIframe'); + $this->_helper->layout()->showFullscreen = $request->getUrl()->shift('showFullscreen'); + $this->_helper->layout()->moduleName = $moduleName; + + $this->view->compact = false; + if ($request->getUrl()->getParam('view') === 'compact') { + $request->getUrl()->remove('view'); + $this->view->compact = true; + } + if ($request->getUrl()->shift('showCompact')) { + $this->view->compact = true; + } + $this->rerenderLayout = $request->getUrl()->shift('renderLayout'); + if ($request->getUrl()->shift('_disableLayout')) { + $this->_helper->layout()->disableLayout(); + } + + // $auth->authenticate($request, $response, $this->requiresLogin()); + if ($this->requiresLogin()) { + if (! $request->isXmlHttpRequest() && $request->isApiRequest()) { + Auth::getInstance()->challengeHttp(); + } + $this->redirectToLogin(Url::fromRequest()); + } + + if (! $this->isXhr() && Config::app()->get('security', 'use_strict_csp', false)) { + Csp::createNonce(); + } + + $this->view->tabs = new Tabs(); + $this->prepareInit(); + $this->init(); + } + + /** + * Prepare controller initialization + * + * As it should not be required for controllers to call the parent's init() method, base controllers should use + * prepareInit() in order to prepare the controller initialization. + * + * @see \Zend_Controller_Action::init() For the controller initialization method. + */ + protected function prepareInit() + { + } + + /** + * Get the authentication manager + * + * @return Auth + */ + public function Auth() + { + if ($this->auth === null) { + $this->auth = Auth::getInstance(); + } + return $this->auth; + } + + /** + * Whether the current user has the given permission + * + * @param string $permission Name of the permission + * + * @return bool + */ + public function hasPermission($permission) + { + return $this->Auth()->hasPermission($permission); + } + + /** + * Assert that the current user has the given permission + * + * @param string $permission Name of the permission + * + * @throws SecurityException If the current user lacks the given permission + */ + public function assertPermission($permission) + { + if (! $this->Auth()->hasPermission($permission)) { + throw new SecurityException('No permission for %s', $permission); + } + } + + /** + * Return the current module's name + * + * @return string + */ + public function getModuleName() + { + if ($this->moduleName === null) { + $this->moduleName = $this->getRequest()->getModuleName(); + } + + return $this->moduleName; + } + + public function Config($file = null) + { + if ($file === null) { + return Config::app(); + } else { + return Config::app($file); + } + } + + public function Window() + { + if ($this->window === null) { + $this->window = Window::getInstance(); + } + + return $this->window; + } + + protected function reloadCss() + { + $this->reloadCss = true; + return $this; + } + + /** + * Respond with HTTP 405 if the current request's method is not one of the given methods + * + * @param string $httpMethod Unlimited number of allowed HTTP methods + * + * @throws HttpMethodNotAllowedException If the request method is not one of the given methods + */ + public function assertHttpMethod($httpMethod) + { + $httpMethods = array_flip(array_map('strtoupper', func_get_args())); + if (! isset($httpMethods[$this->getRequest()->getMethod()])) { + $e = new HttpMethodNotAllowedException($this->translate('Method Not Allowed')); + $e->setAllowedMethods(implode(', ', array_keys($httpMethods))); + throw $e; + } + } + + /** + * Return restriction information for an eventually authenticated user + * + * @param string $name Restriction name + * + * @return array + */ + public function getRestrictions($name) + { + return $this->Auth()->getRestrictions($name); + } + + /** + * Check whether the controller requires a login. That is when the controller requires authentication and the + * user is currently not authenticated + * + * @return bool + */ + protected function requiresLogin() + { + if (! $this->requiresAuthentication) { + return false; + } + + return ! $this->Auth()->isAuthenticated(); + } + + /** + * Return the tabs + * + * @return Tabs + */ + public function getTabs() + { + return $this->view->tabs; + } + + protected function ignoreXhrBody() + { + if ($this->isXhr()) { + $this->getResponse()->setHeader('X-Icinga-Container', 'ignore'); + } + } + + /** + * Set the interval (in seconds) at which the page should automatically refresh + * + * This may be adjusted based on the user's preferences. The result could be a + * lower or higher rate of the page's automatic refresh. If this is not desired, + * the only way to bypass this is to initialize the {@see ActionController::$autorefreshInterval} + * property or to set the `autorefreshInterval` property of the layout directly. + * + * @param int $interval + * + * @return $this + */ + public function setAutorefreshInterval($interval) + { + if (! is_int($interval) || $interval < 1) { + throw new ProgrammingError( + 'Setting autorefresh interval smaller than 1 second is not allowed' + ); + } + + $user = $this->getRequest()->getUser(); + if ($user !== null) { + $speed = (float) $user->getPreferences()->getValue('icingaweb', 'auto_refresh_speed', 1.0); + $interval = max(round($interval * $speed), min($interval, 5)); + } + + $this->autorefreshInterval = $interval; + + return $this; + } + + public function disableAutoRefresh() + { + $this->autorefreshInterval = null; + + return $this; + } + + /** + * Redirect to login + * + * XHR will always redirect to __SELF__ if an URL to redirect to after successful login is set. __SELF__ instructs + * JavaScript to redirect to the current window's URL if it's an auto-refresh request or to redirect to the URL + * which required login if it's not an auto-refreshing one. + * + * XHR will respond with HTTP status code 403 Forbidden. + * + * @param Url|string $redirect URL to redirect to after successful login + */ + protected function redirectToLogin($redirect = null) + { + $login = Url::fromPath(static::LOGIN_ROUTE); + if ($this->isXhr()) { + if ($redirect !== null) { + $login->setParam('redirect', '__SELF__'); + } + + $this->_response->setHttpResponseCode(403); + } elseif ($redirect !== null) { + if (! $redirect instanceof Url) { + $redirect = Url::fromPath($redirect); + } + + if (($relativeUrl = $redirect->getRelativeUrl())) { + $login->setParam('redirect', $relativeUrl); + } + } + + $this->getResponse()->setReloadWindow(true); + $this->redirectNow($login); + } + + protected function rerenderLayout() + { + $this->rerenderLayout = true; + return $this; + } + + public function isXhr() + { + return $this->getRequest()->isXmlHttpRequest(); + } + + /** + * Issue a redirect that's performed with XHR by the client + * + * @param Url|string $url + * + * @return never + */ + protected function redirectXhr($url) + { + $response = $this->getResponse(); + + if ($this->reloadCss) { + $response->setReloadCss(true); + } + + if ($this->rerenderLayout) { + $response->setRerenderLayout(true); + } + + $response->redirectAndExit($url); + } + + /** + * Issue a redirect that's performed as a native HTTP request by the client + * + * This will effectively reload the window + * + * @param Url|string $url + * + * @return never + */ + protected function redirectHttp($url) + { + if ($this->isXhr()) { + $this->getResponse()->setHeader('X-Icinga-Redirect-Http', 'yes'); + } + + $this->getResponse()->redirectAndExit($url); + } + + /** + * Redirect to a specific url, updating the browsers URL field + * + * @param Url|string $url The target to redirect to + * + * @return never + **/ + public function redirectNow($url) + { + if ($this->isXhr()) { + $this->redirectXhr($url); + } else { + $this->redirectHttp($url); + } + } + + /** + * @see Zend_Controller_Action::preDispatch() + */ + public function preDispatch() + { + $form = new AutoRefreshForm(); + if (! $this->getRequest()->isApiRequest()) { + $form->handleRequest(); + } + $this->_helper->layout()->autoRefreshForm = $form; + } + + /** + * Detect whether the current request requires changes in the layout and apply them before rendering + * + * @see Zend_Controller_Action::postDispatch() + */ + public function postDispatch() + { + Benchmark::measure('Action::postDispatch()'); + + $req = $this->getRequest(); + $layout = $this->_helper->layout(); + $layout->innerLayout = $this->innerLayout; + $layout->inlineLayout = $this->inlineLayout; + + if ($user = $req->getUser()) { + if ((bool) $user->getPreferences()->getValue('icingaweb', 'show_benchmark', false)) { + if ($this->_helper->layout()->isEnabled()) { + $layout->benchmark = $this->renderBenchmark(); + } + } + + if (! (bool) $user->getPreferences()->getValue('icingaweb', 'auto_refresh', true)) { + $this->disableAutoRefresh(); + } + } + + if ($this->autorefreshInterval !== null) { + $layout->autorefreshInterval = $this->autorefreshInterval; + } + + if ($req->getParam('error_handler') === null && $req->getParam('format') === 'pdf') { + $this->sendAsPdf(); + $this->shutdownSession(); + exit; + } + + if ($this->isXhr()) { + $this->postDispatchXhr(); + } + + $this->shutdownSession(); + } + + protected function postDispatchXhr() + { + $resp = $this->getResponse(); + + if ($this->reloadCss) { + $resp->setReloadCss(true); + } + + if ($this->view->title) { + if (preg_match('~[\r\n]~', $this->view->title)) { + // TODO: Innocent exception and error log for hack attempts + throw new IcingaException('No way, guy'); + } + $resp->setHeader( + 'X-Icinga-Title', + rawurlencode($this->view->title . ' :: ' . $this->view->defaultTitle), + true + ); + } else { + $resp->setHeader('X-Icinga-Title', rawurlencode($this->view->defaultTitle), true); + } + + $layout = $this->_helper->layout(); + if ($this->rerenderLayout) { + $layout->setLayout($this->innerLayout); + $resp->setRerenderLayout(true); + } else { + // The layout may be disabled and there's no indication that the layout is explicitly desired, + // that's why we're passing false as second parameter to setLayout + $layout->setLayout($this->inlineLayout, false); + } + + if ($this->autorefreshInterval !== null) { + $resp->setAutoRefreshInterval($this->autorefreshInterval); + } + } + + protected function sendAsPdf() + { + if (Module::exists('pdfexport')) { + $this->newSendAsPdf(); + } else { + $pdf = new Pdf(); + $pdf->renderControllerAction($this); + } + } + + protected function shutdownSession() + { + $session = Session::getSession(); + if ($session->hasChanged()) { + $session->write(); + } + } + + /** + * Render the benchmark + * + * @return string Benchmark HTML + */ + protected function renderBenchmark() + { + $this->_helper->viewRenderer->postDispatch(); + Benchmark::measure('Response ready'); + return Benchmark::renderToHtml(); + } + + /** + * Try to call compatible methods from older zend versions + * + * Methods like getParam and redirect are _getParam/_redirect in older Zend versions (which reside for example + * in Debian Wheezy). Using those methods without the "_" causes the application to fail on those platforms, but + * using the version with "_" forces us to use deprecated code. So we try to catch this issue by looking for methods + * with the same name, but with a "_" prefix prepended. + * + * @param string $name The method name to check + * @param mixed $params The method parameters + * @return mixed Anything the method returns + */ + public function __call($name, $params) + { + $deprecatedMethod = '_' . $name; + + if (method_exists($this, $deprecatedMethod)) { + return call_user_func_array(array($this, $deprecatedMethod), $params); + } + + parent::__call($name, $params); + } +} diff --git a/library/Icinga/Web/Controller/AuthBackendController.php b/library/Icinga/Web/Controller/AuthBackendController.php new file mode 100644 index 0000000..12f8b72 --- /dev/null +++ b/library/Icinga/Web/Controller/AuthBackendController.php @@ -0,0 +1,151 @@ +<?php +/* Icinga Web 2 | (c) 2015 Icinga Development Team | GPLv2+ */ + +namespace Icinga\Web\Controller; + +use ipl\Web\Compat\CompatController; +use Zend_Controller_Action_Exception; +use Icinga\Application\Config; +use Icinga\Authentication\User\UserBackend; +use Icinga\Authentication\User\UserBackendInterface; +use Icinga\Authentication\UserGroup\UserGroupBackend; +use Icinga\Authentication\UserGroup\UserGroupBackendInterface; + +/** + * Base class for authentication backend controllers + */ +class AuthBackendController extends CompatController +{ + public function init() + { + parent::init(); + + $this->tabs->disableLegacyExtensions(); + } + + /** + * Redirect to this controller's list action + */ + public function indexAction() + { + $this->redirectNow($this->getRequest()->getControllerName() . '/list'); + } + + /** + * Return all user backends implementing the given interface + * + * @param string $interface The class path of the interface, or null if no interface check should be made + * + * @return array + */ + protected function loadUserBackends($interface = null) + { + $backends = array(); + foreach (Config::app('authentication') as $backendName => $backendConfig) { + $candidate = UserBackend::create($backendName, $backendConfig); + if (! $interface || $candidate instanceof $interface) { + $backends[] = $candidate; + } + } + + return $backends; + } + + /** + * Return the given user backend or the first match in order + * + * @param string $name The name of the backend, or null in case the first match should be returned + * @param string $interface The interface the backend should implement, no interface check if null + * + * @return UserBackendInterface + * + * @throws Zend_Controller_Action_Exception In case the given backend name is invalid + */ + protected function getUserBackend($name = null, $interface = 'Icinga\Data\Selectable') + { + $backend = null; + if ($name !== null) { + $config = Config::app('authentication'); + if (! $config->hasSection($name)) { + $this->httpNotFound(sprintf($this->translate('Authentication backend "%s" not found'), $name)); + } else { + $backend = UserBackend::create($name, $config->getSection($name)); + if ($interface && !$backend instanceof $interface) { + $interfaceParts = explode('\\', strtolower($interface)); + throw new Zend_Controller_Action_Exception( + sprintf( + $this->translate('Authentication backend "%s" is not %s'), + $name, + array_pop($interfaceParts) + ), + 400 + ); + } + } + } else { + $backends = $this->loadUserBackends($interface); + $backend = array_shift($backends); + } + + return $backend; + } + + /** + * Return all user group backends implementing the given interface + * + * @param string $interface The class path of the interface, or null if no interface check should be made + * + * @return array + */ + protected function loadUserGroupBackends($interface = null) + { + $backends = array(); + foreach (Config::app('groups') as $backendName => $backendConfig) { + $candidate = UserGroupBackend::create($backendName, $backendConfig); + if (! $interface || $candidate instanceof $interface) { + $backends[] = $candidate; + } + } + + return $backends; + } + + /** + * Return the given user group backend or the first match in order + * + * @param string $name The name of the backend, or null in case the first match should be returned + * @param string $interface The interface the backend should implement, no interface check if null + * + * @return UserGroupBackendInterface + * + * @throws Zend_Controller_Action_Exception In case the given backend name is invalid + */ + protected function getUserGroupBackend($name = null, $interface = 'Icinga\Data\Selectable') + { + $backend = null; + if ($name !== null) { + $config = Config::app('groups'); + if (! $config->hasSection($name)) { + $this->httpNotFound(sprintf($this->translate('User group backend "%s" not found'), $name)); + } else { + $backend = UserGroupBackend::create($name, $config->getSection($name)); + if ($interface && !$backend instanceof $interface) { + $interfaceParts = explode('\\', strtolower($interface)); + throw new Zend_Controller_Action_Exception( + sprintf( + $this->translate('User group backend "%s" is not %s'), + $name, + array_pop($interfaceParts) + ), + 400 + ); + } + } + } else { + $backends = $this->loadUserGroupBackends($interface); + $backend = array_shift($backends); + } + + return $backend; + } +} diff --git a/library/Icinga/Web/Controller/BasePreferenceController.php b/library/Icinga/Web/Controller/BasePreferenceController.php new file mode 100644 index 0000000..8f2da8f --- /dev/null +++ b/library/Icinga/Web/Controller/BasePreferenceController.php @@ -0,0 +1,39 @@ +<?php +/* Icinga Web 2 | (c) 2013 Icinga Development Team | GPLv2+ */ + +namespace Icinga\Web\Controller; + +/** + * Base class for Preference Controllers + * + * Module preferences use this class to make sure they are automatically + * added to the general preferences dialog. If you create a subclass of + * BasePreferenceController and overwrite @see init(), make sure you call + * parent::init(), otherwise you won't have the $tabs property in your view. + * + */ +class BasePreferenceController extends ActionController +{ + /** + * Return an array of tabs provided by this preference controller. + * + * Those tabs will automatically be added to the application's preference dialog + * + * @return array + */ + public static function createProvidedTabs() + { + return array(); + } + + /** + * Initialize the controller and collect all tabs for it from the application and its modules + * + * @see ActionController::init() + */ + public function init() + { + parent::init(); + $this->view->tabs = ControllerTabCollector::collectControllerTabs('PreferenceController'); + } +} diff --git a/library/Icinga/Web/Controller/ControllerTabCollector.php b/library/Icinga/Web/Controller/ControllerTabCollector.php new file mode 100644 index 0000000..b452a20 --- /dev/null +++ b/library/Icinga/Web/Controller/ControllerTabCollector.php @@ -0,0 +1,97 @@ +<?php +/* Icinga Web 2 | (c) 2013 Icinga Development Team | GPLv2+ */ + +namespace Icinga\Web\Controller; + +use Icinga\Application\Modules\Module; +use Icinga\Application\Icinga; +use Icinga\Web\Widget\Tabs; + +/** + * Static helper class that collects tabs provided by the 'createProvidedTabs' method of controllers + */ +class ControllerTabCollector +{ + /** + * Scan all controllers with given name in the application and (loaded) module folders and collects their provided + * tabs + * + * @param string $controllerName The name of the controllers to use for tab collection + * + * @return Tabs A {@link Tabs} instance containing the application tabs first followed by the + * tabs provided from the modules + */ + public static function collectControllerTabs($controllerName) + { + $controller = '\Icinga\\' . Dispatcher::CONTROLLER_NAMESPACE . '\\' . $controllerName; + $applicationTabs = $controller::createProvidedTabs(); + $moduleTabs = self::collectModuleTabs($controllerName); + + $tabs = new Tabs(); + foreach ($applicationTabs as $name => $tab) { + $tabs->add($name, $tab); + } + + foreach ($moduleTabs as $name => $tab) { + // Don't overwrite application tabs if the module wants to + if ($tabs->has($name)) { + continue; + } + $tabs->add($name, $tab); + } + return $tabs; + } + + /** + * Collect module tabs for all modules containing the given controller + * + * @param string $controller The controller name to use for tab collection + * + * @return array An array of Tabs objects or arrays containing Tab descriptions + */ + private static function collectModuleTabs($controller) + { + $moduleManager = Icinga::app()->getModuleManager(); + $modules = $moduleManager->listEnabledModules(); + $tabs = array(); + foreach ($modules as $module) { + $tabs += self::createModuleConfigurationTabs($controller, $moduleManager->getModule($module)); + } + + return $tabs; + } + + /** + * Collects the tabs from the createProvidedTabs() method in the configuration controller + * + * If the module doesn't have the given controller or createProvidedTabs method in the controller an empty array + * will be returned + * + * @param string $controllerName The name of the controller that provides tabs via createProvidedTabs + * @param Module $module The module instance that provides the controller + * + * @return array + */ + private static function createModuleConfigurationTabs($controllerName, Module $module) + { + // TODO(el): Only works for controllers w/o namepsace: https://dev.icinga.com/issues/4149 + $controllerDir = $module->getControllerDir(); + $name = $module->getName(); + + $controllerDir = $controllerDir . '/' . $controllerName . '.php'; + $controllerName = ucfirst($name) . '_' . $controllerName; + + if (is_readable($controllerDir)) { + require_once(realpath($controllerDir)); + if (! method_exists($controllerName, 'createProvidedTabs')) { + return array(); + } + $tab = $controllerName::createProvidedTabs(); + if (! is_array($tab)) { + $tab = array($name => $tab); + } + return $tab; + } + return array(); + } +} diff --git a/library/Icinga/Web/Controller/Dispatcher.php b/library/Icinga/Web/Controller/Dispatcher.php new file mode 100644 index 0000000..e2dfb80 --- /dev/null +++ b/library/Icinga/Web/Controller/Dispatcher.php @@ -0,0 +1,93 @@ +<?php +/* Icinga Web 2 | (c) 2014 Icinga Development Team | GPLv2+ */ + +namespace Icinga\Web\Controller; + +use Exception; +use Icinga\Util\StringHelper; +use Zend_Controller_Action; +use Zend_Controller_Action_Interface; +use Zend_Controller_Dispatcher_Exception; +use Zend_Controller_Dispatcher_Standard; +use Zend_Controller_Request_Abstract; +use Zend_Controller_Response_Abstract; + +/** + * Dispatcher supporting Zend-style and namespaced controllers + * + * Does not support a namespaced default controller in combination w/ the Zend parameter useDefaultControllerAlways. + */ +class Dispatcher extends Zend_Controller_Dispatcher_Standard +{ + /** + * Controller namespace + * + * @var string + */ + const CONTROLLER_NAMESPACE = 'Controllers'; + + /** + * Dispatch request to a controller and action + * + * @param Zend_Controller_Request_Abstract $request + * @param Zend_Controller_Response_Abstract $response + * + * @throws Zend_Controller_Dispatcher_Exception If the controller is not an instance of + * Zend_Controller_Action_Interface + * @throws Exception If dispatching the request fails + */ + public function dispatch(Zend_Controller_Request_Abstract $request, Zend_Controller_Response_Abstract $response) + { + $this->setResponse($response); + $controllerName = $request->getControllerName(); + if (! $controllerName) { + parent::dispatch($request, $response); + return; + } + $controllerName = StringHelper::cname($controllerName, '-') . 'Controller'; + $moduleName = $request->getModuleName(); + if ($moduleName === null || $moduleName === $this->_defaultModule) { + $controllerClass = 'Icinga\\' . self::CONTROLLER_NAMESPACE . '\\' . $controllerName; + } else { + $controllerClass = 'Icinga\\Module\\' . ucfirst($moduleName) . '\\' . self::CONTROLLER_NAMESPACE . '\\' + . $controllerName; + } + if (! class_exists($controllerClass)) { + parent::dispatch($request, $response); + return; + } + $controller = new $controllerClass($request, $response, $this->getParams()); + if (! $controller instanceof Zend_Controller_Action + && ! $controller instanceof Zend_Controller_Action_Interface + ) { + throw new Zend_Controller_Dispatcher_Exception( + 'Controller "' . $controllerClass . '" is not an instance of Zend_Controller_Action_Interface' + ); + } + $action = $this->getActionMethod($request); + $request->setDispatched(true); + // Buffer output by default + $disableOb = $this->getParam('disableOutputBuffering'); + $obLevel = ob_get_level(); + if (empty($disableOb)) { + ob_start(); + } + try { + $controller->dispatch($action); + } catch (Exception $e) { + // Clean output buffer on error + $curObLevel = ob_get_level(); + if ($curObLevel > $obLevel) { + do { + ob_get_clean(); + $curObLevel = ob_get_level(); + } while ($curObLevel > $obLevel); + } + throw $e; + } + if (empty($disableOb)) { + $content = ob_get_clean(); + $response->appendBody($content); + } + } +} diff --git a/library/Icinga/Web/Controller/ModuleActionController.php b/library/Icinga/Web/Controller/ModuleActionController.php new file mode 100644 index 0000000..ad66264 --- /dev/null +++ b/library/Icinga/Web/Controller/ModuleActionController.php @@ -0,0 +1,80 @@ +<?php +/* Icinga Web 2 | (c) 2013 Icinga Development Team | GPLv2+ */ + +namespace Icinga\Web\Controller; + +use Icinga\Application\Config; +use Icinga\Application\Icinga; +use Icinga\Application\Modules\Manager; +use Icinga\Application\Modules\Module; + +/** + * Base class for module action controllers + */ +class ModuleActionController extends ActionController +{ + protected $config; + + protected $configs = array(); + + protected $module; + + /** + * (non-PHPDoc) + * @see \Icinga\Web\Controller\ActionController For the method documentation. + */ + protected function prepareInit() + { + $this->moduleInit(); + if (($this->Auth()->isAuthenticated() || $this->requiresLogin()) + && $this->getFrontController()->getDefaultModule() !== $this->getModuleName()) { + $this->assertPermission(Manager::MODULE_PERMISSION_NS . $this->getModuleName()); + } + } + + /** + * Prepare module action controller initialization + */ + protected function moduleInit() + { + } + + public function Config($file = null) + { + if ($file === null) { + if ($this->config === null) { + $this->config = Config::module($this->getModuleName()); + } + return $this->config; + } else { + if (! array_key_exists($file, $this->configs)) { + $this->configs[$file] = Config::module($this->getModuleName(), $file); + } + return $this->configs[$file]; + } + } + + /** + * Return this controller's module + * + * @return Module + */ + public function Module() + { + if ($this->module === null) { + $this->module = Icinga::app()->getModuleManager()->getModule($this->getModuleName()); + } + + return $this->module; + } + + /** + * (non-PHPDoc) + * @see \Icinga\Web\Controller\ActionController::postDispatchXhr() For the method documentation. + */ + public function postDispatchXhr() + { + parent::postDispatchXhr(); + $this->getResponse()->setHeader('X-Icinga-Module', $this->getModuleName(), true); + } +} diff --git a/library/Icinga/Web/Controller/StaticController.php b/library/Icinga/Web/Controller/StaticController.php new file mode 100644 index 0000000..f5ce163 --- /dev/null +++ b/library/Icinga/Web/Controller/StaticController.php @@ -0,0 +1,87 @@ +<?php +/* Icinga Web 2 | (c) 2020 Icinga GmbH | GPLv2+ */ + +namespace Icinga\Web\Controller; + +use Icinga\Application\Icinga; +use Icinga\Web\Request; + +class StaticController +{ + /** + * Handle incoming request + * + * @param Request $request + * + * @returns void + */ + public function handle(Request $request) + { + $app = Icinga::app(); + + // +4 because strlen('/lib') === 4 + $assetPath = ltrim(substr($request->getRequestUri(), strlen($request->getBaseUrl()) + 4), '/'); + + $library = null; + foreach ($app->getLibraries() as $candidate) { + if (substr($assetPath, 0, strlen($candidate->getName())) === $candidate->getName()) { + $library = $candidate; + $assetPath = ltrim(substr($assetPath, strlen($candidate->getName())), '/'); + break; + } + } + + if ($library === null) { + $app->getResponse() + ->setHttpResponseCode(404); + + return; + } + + $assetRoot = $library->getStaticAssetPath(); + if (empty($assetRoot)) { + $app->getResponse() + ->setHttpResponseCode(404); + + return; + } + + $filePath = $assetRoot . DIRECTORY_SEPARATOR . $assetPath; + $dirPath = realpath(dirname($filePath)); // dirname, because the file may be a link + + if ($dirPath === false + || substr($dirPath, 0, strlen($assetRoot)) !== $assetRoot + || ! is_file($filePath) + ) { + $app->getResponse() + ->setHttpResponseCode(404); + + return; + } + + $fileStat = stat($filePath); + $eTag = sprintf( + '%x-%x-%x', + $fileStat['ino'], + $fileStat['size'], + (float) str_pad($fileStat['mtime'], 16, '0') + ); + + $app->getResponse()->setHeader( + 'Cache-Control', + 'public, max-age=1814400, stale-while-revalidate=604800', + true + ); + + if ($request->getServer('HTTP_IF_NONE_MATCH') === $eTag) { + $app->getResponse() + ->setHttpResponseCode(304); + } else { + $app->getResponse() + ->setHeader('ETag', $eTag) + ->setHeader('Content-Type', mime_content_type($filePath), true) + ->setHeader('Last-Modified', gmdate('D, d M Y H:i:s', $fileStat['mtime']) . ' GMT') + ->setBody(file_get_contents($filePath)); + } + } +} diff --git a/library/Icinga/Web/Cookie.php b/library/Icinga/Web/Cookie.php new file mode 100644 index 0000000..283f07a --- /dev/null +++ b/library/Icinga/Web/Cookie.php @@ -0,0 +1,299 @@ +<?php +/* Icinga Web 2 | (c) 2013 Icinga Development Team | GPLv2+ */ + +namespace Icinga\Web; + +use Icinga\Application\Config; +use Icinga\Application\Icinga; +use InvalidArgumentException; + +/** + * A HTTP cookie + */ +class Cookie +{ + /** + * Domain of the cookie + * + * @var string + */ + protected $domain; + + /** + * The timestamp at which the cookie expires + * + * @var int + */ + protected $expire; + + /** + * Whether to protect the cookie against client side script code attempts to read the cookie + * + * Defaults to true. + * + * @var bool + */ + protected $httpOnly = true; + + /** + * Name of the cookie + * + * @var string + */ + protected $name; + + /** + * The path on the web server where the cookie is available + * + * Defaults to the base URL. + * + * @var string + */ + protected $path; + + /** + * Whether to send the cookie only over a secure connection + * + * Defaults to auto-detection so that if the current request was sent over a secure connection the secure flag will + * be set to true. + * + * @var bool + */ + protected $secure; + + /** + * Value of the cookie + * + * @var string + */ + protected $value; + + /** + * Create a new cookie + * + * @param string $name + * @param string $value + */ + public function __construct($name, $value = null) + { + if (preg_match("/[=,; \t\r\n\013\014]/", $name)) { + throw new InvalidArgumentException(sprintf( + 'Cookie name can\'t contain these characters: =,; \t\r\n\013\014 (%s)', + $name + )); + } + if (empty($name)) { + throw new InvalidArgumentException('The cookie name can\'t be empty'); + } + $this->name = $name; + $this->value = $value; + } + + /** + * Get the domain of the cookie + * + * @return string + */ + public function getDomain() + { + if ($this->domain === null) { + $this->domain = Config::app()->get('cookie', 'domain'); + } + return $this->domain; + } + + /** + * Set the domain of the cookie + * + * @param string $domain + * + * @return $this + */ + public function setDomain($domain) + { + $this->domain = $domain; + return $this; + } + + /** + * Get the timestamp at which the cookie expires + * + * @return int + */ + public function getExpire() + { + return $this->expire; + } + + /** + * Set the timestamp at which the cookie expires + * + * @param int $expire + * + * @return $this + */ + public function setExpire($expire) + { + $this->expire = $expire; + return $this; + } + + /** + * Get whether to protect the cookie against client side script code attempts to read the cookie + * + * @return bool + */ + public function isHttpOnly() + { + return $this->httpOnly; + } + + /** + * Set whether to protect the cookie against client side script code attempts to read the cookie + * + * @param bool $httpOnly + * + * @return $this + */ + public function setHttpOnly($httpOnly) + { + $this->httpOnly = $httpOnly; + return $this; + } + + /** + * Get the name of the cookie + * + * @return string + */ + public function getName() + { + return $this->name; + } + + /** + * Get the path on the web server where the cookie is available + * + * If the path has not been set either via {@link setPath()} or via config, the base URL will be returned. + * + * @return string + */ + public function getPath() + { + if ($this->path === null) { + $path = Config::app()->get('cookie', 'path'); + if ($path === null) { + // The following call could be used as default for ConfigObject::get(), but we prevent unnecessary + // function calls here, if the path is set in the config + $path = Icinga::app()->getRequest()->getBaseUrl() . '/'; // Zend has rtrim($baseUrl, '/') + } + $this->path = $path; + } + return $this->path; + } + + /** + * Set the path on the web server where the cookie is available + * + * @param string $path + * + * @return $this + */ + public function setPath($path) + { + $this->path = $path; + return $this; + } + + /** + * Get whether to send the cookie only over a secure connection + * + * If the secure flag has not been set either via {@link setSecure()} or via config and if the current request was + * sent over a secure connection, true will be returned. + * + * @return bool + */ + public function isSecure() + { + if ($this->secure === null) { + $secure = Config::app()->get('cookie', 'secure'); + if ($secure === null) { + // The following call could be used as default for ConfigObject::get(), but we prevent unnecessary + // function calls here, if the secure flag is set in the config + $secure = Icinga::app()->getRequest()->isSecure(); + } + $this->secure = $secure; + } + return $this->secure; + } + + /** + * Set whether to send the cookie only over a secure connection + * + * @param bool $secure + * + * @return $this + */ + public function setSecure($secure) + { + $this->secure = $secure; + return $this; + } + + /** + * Get the value of the cookie + * + * @return string + */ + public function getValue() + { + return $this->value; + } + + /** + * Set the value of the cookie + * + * @param string $value + * + * @return $this + */ + public function setValue($value) + { + $this->value = $value; + return $this; + } + + /** + * Create invalidation cookie + * + * This method clones the current cookie and sets its value to null and expire time to 1. + * That way, the cookie removes itself when it has been sent to and processed by the client. + * + * We're cloning the current cookie in order to meet the [RFC6265 spec](https://tools.ietf.org/search/rfc6265) + * regarding the `Path` and `Domain` attribute: + * + * > Finally, to remove a cookie, the server returns a Set-Cookie header with an expiration date in the past. + * > The server will be successful in removing the cookie only if the Path and the Domain attribute in the + * > Set-Cookie header match the values used when the cookie was created. + * + * Note that the cookie has to be sent to the client. + * + * # Example Usage + * + * ```php + * $response->setCookie( + * $cookie->forgetMe() + * ); + * ``` + * + * @return static + */ + public function forgetMe() + { + $forgetMe = clone $this; + + return $forgetMe + ->setValue(null) + ->setExpire(1); + } +} diff --git a/library/Icinga/Web/CookieSet.php b/library/Icinga/Web/CookieSet.php new file mode 100644 index 0000000..019be29 --- /dev/null +++ b/library/Icinga/Web/CookieSet.php @@ -0,0 +1,58 @@ +<?php +/* Icinga Web 2 | (c) 2015 Icinga Development Team | GPLv2+ */ + +namespace Icinga\Web; + +use ArrayIterator; +use IteratorAggregate; +use Traversable; + +/** + * Maintain a set of cookies + */ +class CookieSet implements IteratorAggregate +{ + /** + * Cookies in this set indexed by the cookie names + * + * @var Cookie[] + */ + protected $cookies = array(); + + /** + * Get an iterator for traversing the cookies in this set + * + * @return ArrayIterator An iterator for traversing the cookies in this set + */ + public function getIterator(): Traversable + { + return new ArrayIterator($this->cookies); + } + + /** + * Add a cookie to the set + * + * If a cookie with the same name already exists, the cookie will be overridden. + * + * @param Cookie $cookie The cookie to add + * + * @return $this + */ + public function add(Cookie $cookie) + { + $this->cookies[$cookie->getName()] = $cookie; + return $this; + } + + /** + * Get the cookie with the given name from the set + * + * @param string $name The name of the cookie + * + * @return Cookie|null The cookie with the given name or null if the cookie does not exist + */ + public function get($name) + { + return isset($this->cookies[$name]) ? $this->cookies[$name] : null; + } +} diff --git a/library/Icinga/Web/Dom/DomNodeIterator.php b/library/Icinga/Web/Dom/DomNodeIterator.php new file mode 100644 index 0000000..1ea20b8 --- /dev/null +++ b/library/Icinga/Web/Dom/DomNodeIterator.php @@ -0,0 +1,84 @@ +<?php +/* Icinga Web 2 | (c) 2015 Icinga Development Team | GPLv2+ */ + +namespace Icinga\Web\Dom; + +use DOMNode; +use IteratorIterator; +use RecursiveIterator; + +/** + * Recursive iterator over a DOMNode + * + * Usage example: + * <code> + * <?php + * + * namespace Icinga\Example; + * + * use DOMDocument; + * use RecursiveIteratorIterator; + * use Icinga\Web\Dom\DomIterator; + * + * $doc = new DOMDocument(); + * $doc->loadHTML(...); + * $dom = new RecursiveIteratorIterator(new DomNodeIterator($doc), RecursiveIteratorIterator::SELF_FIRST); + * foreach ($dom as $node) { + * .... + * } + * </code> + */ +class DomNodeIterator implements RecursiveIterator +{ + /** + * The node's children + * + * @var IteratorIterator + */ + protected $children; + + /** + * Create a new iterator over a DOMNode's children + * + * @param DOMNode $node + */ + public function __construct(DOMNode $node) + { + $this->children = new IteratorIterator($node->childNodes); + } + + public function current(): ?DOMNode + { + return $this->children->current(); + } + + public function key(): int + { + return $this->children->key(); + } + + public function next(): void + { + $this->children->next(); + } + + public function rewind(): void + { + $this->children->rewind(); + } + + public function valid(): bool + { + return $this->children->valid(); + } + + public function hasChildren(): bool + { + return $this->current()->hasChildNodes(); + } + + public function getChildren(): DomNodeIterator + { + return new static($this->current()); + } +} diff --git a/library/Icinga/Web/FileCache.php b/library/Icinga/Web/FileCache.php new file mode 100644 index 0000000..03f0c19 --- /dev/null +++ b/library/Icinga/Web/FileCache.php @@ -0,0 +1,293 @@ +<?php +/* Icinga Web 2 | (c) 2014 Icinga Development Team | GPLv2+ */ + +namespace Icinga\Web; + +class FileCache +{ + /** + * FileCache singleton instances + * + * @var array + */ + protected static $instances = array(); + + /** + * Cache instance base directory + * + * @var string + */ + protected $basedir; + + /** + * Instance name + * + * @var string + */ + protected $name; + + /** + * Whether the cache is enabled + * + * @var bool + */ + protected $enabled = false; + + /** + * The protected constructor creates a new instance with the given name + * + * @param string $name Cache instance name + */ + protected function __construct($name) + { + $this->name = $name; + $tmpDir = sys_get_temp_dir(); + $runtimePath = $tmpDir . '/FileCache_' . $name; + if (is_dir($runtimePath)) { + // Don't combine the following if with the above because else the elseif path will be evaluated if the + // runtime path exists and is not writeable + if (is_writeable($runtimePath)) { + $this->basedir = $runtimePath; + $this->enabled = true; + } + } elseif (is_dir($tmpDir) && is_writeable($tmpDir) && @mkdir($runtimePath, octdec('1750'), true)) { + // Suppress mkdir errors because it may error w/ no such file directory if the systemd private tmp directory + // for the web server has been removed + $this->basedir = $runtimePath; + $this->enabled = true; + } + } + + /** + * Store the given content to the desired file name + * + * @param string $file new (relative) filename + * @param string $content the content to be stored + * + * @return bool whether the file has been stored + */ + public function store($file, $content) + { + if (! $this->enabled) { + return false; + } + + return file_put_contents($this->filename($file), $content); + } + + /** + * Find out whether a given file exists + * + * @param string $file the (relative) filename + * @param int $newerThan optional timestamp to compare against + * + * @return bool whether such file exists + */ + public function has($file, $newerThan = null) + { + if (! $this->enabled) { + return false; + } + + $filename = $this->filename($file); + + if (! file_exists($filename) || ! is_readable($filename)) { + return false; + } + + if ($newerThan === null) { + return true; + } + + $info = stat($filename); + + if ($info === false) { + return false; + } + + return (int) $newerThan < $info['mtime']; + } + + /** + * Get a specific file or false if no such file available + * + * @param string $file the disired file name + * + * @return string|bool Filename content or false + */ + public function get($file) + { + if ($this->has($file)) { + return file_get_contents($this->filename($file)); + } + + return false; + } + + /** + * Send a specific file to the browser (output) + * + * @param string $file the disired file name + * + * @return bool Whether the file has been sent + */ + public function send($file) + { + if ($this->has($file)) { + readfile($this->filename($file)); + + return true; + } + + return false; + } + + /** + * Get absolute filename for a given file + * + * @param string $file the disired file name + * + * @return string absolute filename + */ + protected function filename($file) + { + return $this->basedir . '/' . $file; + } + + /** + * Prepare a sub directory with the given name and return its path + * + * @param string $name + * + * @return string|false Returns FALSE in case the cache is not enabled or an error occurred + */ + public function directory($name) + { + if (! $this->enabled) { + return false; + } + + $path = $this->filename($name); + if (! is_dir($path) && ! @mkdir($path, octdec('1750'), true)) { + return false; + } + + return $path; + } + + /** + * Whether the given ETag matches a cached file + * + * If no ETag is given we'll try to fetch the one from the current + * HTTP request. + * + * @param string $file The cached file you want to check + * @param string $match The ETag to match against + * + * @return string|bool ETag on match, otherwise false + */ + public function etagMatchesCachedFile($file, $match = null) + { + return self::etagMatchesFiles($this->filename($file), $match); + } + + /** + * Create an ETag for the given file + * + * @param string $file The desired cache file + * + * @return string your ETag + */ + public function etagForCachedFile($file) + { + return self::etagForFiles($this->filename($file)); + } + + /** + * Whether the given ETag matchesspecific file(s) on disk + * + * @param string|array $files file(s) to check + * @param string $match ETag to match against + * + * @return string|bool ETag on match, otherwise false + */ + public static function etagMatchesFiles($files, $match = null) + { + if ($match === null) { + $match = isset($_SERVER['HTTP_IF_NONE_MATCH']) + ? trim($_SERVER['HTTP_IF_NONE_MATCH'], '"') + : false; + } + if (! $match) { + return false; + } + + if (preg_match('/([0-9a-f]{8}-[0-9a-f]{8}-[0-9a-f]{8})-\w+/i', $match, $matches)) { + // Removes compression suffixes as our custom algorithm can't handle compressed cache files anyway + $match = $matches[1]; + } + + $etag = self::etagForFiles($files); + return $match === $etag ? $etag : false; + } + + /** + * Create ETag for the given files + * + * Custom algorithm creating an ETag based on filenames, mtimes + * and file sizes. Supports single files or a list of files. This + * way we are able to create ETags for virtual files depending on + * multiple source files (e.g. compressed JS, CSS). + * + * @param string|array $files Single file or a list of such + * + * @return string The generated ETag + */ + public static function etagForFiles($files) + { + if (is_string($files)) { + $files = array($files); + } + + $sizes = array(); + $mtimes = array(); + + foreach ($files as $file) { + $file = realpath($file); + if ($file !== false && $info = stat($file)) { + $mtimes[] = $info['mtime']; + $sizes[] = $info['size']; + } else { + $mtimes[] = time(); + $sizes[] = 0; + } + } + + return sprintf( + '%s-%s-%s', + hash('crc32', implode('|', $files)), + hash('crc32', implode('|', $sizes)), + hash('crc32', implode('|', $mtimes)) + ); + } + + /** + * Factory creating your cache instance + * + * @param string $name Instance name + * + * @return FileCache + */ + public static function instance($name = 'icingaweb') + { + if ($name !== 'icingaweb') { + $name = 'icingaweb/modules/' . $name; + } + + if (!array_key_exists($name, self::$instances)) { + self::$instances[$name] = new static($name); + } + + return self::$instances[$name]; + } +} diff --git a/library/Icinga/Web/Form.php b/library/Icinga/Web/Form.php new file mode 100644 index 0000000..b421849 --- /dev/null +++ b/library/Icinga/Web/Form.php @@ -0,0 +1,1666 @@ +<?php +/* Icinga Web 2 | (c) 2013 Icinga Development Team | GPLv2+ */ + +namespace Icinga\Web; + +use Icinga\Web\Form\Element\DateTimePicker; +use ipl\I18n\Translation; +use Zend_Config; +use Zend_Form; +use Zend_Form_Element; +use Zend_View_Interface; +use Icinga\Application\Icinga; +use Icinga\Authentication\Auth; +use Icinga\Exception\ProgrammingError; +use Icinga\Security\SecurityException; +use Icinga\Web\Form\ErrorLabeller; +use Icinga\Web\Form\Decorator\Autosubmit; +use Icinga\Web\Form\Element\CsrfCounterMeasure; + +/** + * Base class for forms providing CSRF protection, confirmation logic and auto submission + * + * @method \Zend_Form_Element[] getElements() { + * {@inheritdoc} + * @return \Zend_Form_Element[] + * } + */ +class Form extends Zend_Form +{ + use Translation { + translate as i18nTranslate; + translatePlural as i18nTranslatePlural; + } + + /** + * The suffix to append to a field's hidden default field name + */ + const DEFAULT_SUFFIX = '_default'; + + /** + * A form's default CSS classes + */ + const DEFAULT_CLASSES = 'icinga-form icinga-controls'; + + /** + * Identifier for notifications of type error + */ + const NOTIFICATION_ERROR = 0; + + /** + * Identifier for notifications of type warning + */ + const NOTIFICATION_WARNING = 1; + + /** + * Identifier for notifications of type info + */ + const NOTIFICATION_INFO = 2; + + /** + * Whether this form has been created + * + * @var bool + */ + protected $created = false; + + /** + * This form's parent + * + * Gets automatically set upon calling addSubForm(). + * + * @var Form + */ + protected $_parent; + + /** + * Whether the form is an API target + * + * When the form is an API target, the form evaluates as submitted if the request method equals the form method. + * That means, that the submit button and form identification are not taken into account. In addition, the CSRF + * counter measure will not be added to the form's elements. + * + * @var bool + */ + protected $isApiTarget = false; + + /** + * The request associated with this form + * + * @var Request + */ + protected $request; + + /** + * The callback to call instead of Form::onSuccess() + * + * @var callable + */ + protected $onSuccess; + + /** + * Label to use for the standard submit button + * + * @var string + */ + protected $submitLabel; + + /** + * Label to use for showing the user an activity indicator when submitting the form + * + * @var string + */ + protected $progressLabel; + + /** + * The url to redirect to upon success + * + * @var Url + */ + protected $redirectUrl; + + /** + * The view script to use when rendering this form + * + * @var string + */ + protected $viewScript; + + /** + * Whether this form should NOT add random generated "challenge" tokens that are associated with the user's current + * session in order to prevent Cross-Site Request Forgery (CSRF). It is the form's responsibility to verify the + * existence and correctness of this token + * + * @var bool + */ + protected $tokenDisabled = false; + + /** + * Name of the CSRF token element + * + * @var string + */ + protected $tokenElementName = 'CSRFToken'; + + /** + * Whether this form should add a UID element being used to distinct different forms posting to the same action + * + * @var bool + */ + protected $uidDisabled = false; + + /** + * Name of the form identification element + * + * @var string + */ + protected $uidElementName = 'formUID'; + + /** + * Whether the form should validate the sent data when being automatically submitted + * + * @var bool + */ + protected $validatePartial = false; + + /** + * Whether element ids will be protected against collisions by appending a request-specific unique identifier + * + * @var bool + */ + protected $protectIds = true; + + /** + * The cue that is appended to each element's label if it's required + * + * @var string + */ + protected $requiredCue = '*'; + + /** + * The descriptions of this form + * + * @var array + */ + protected $descriptions; + + /** + * The notifications of this form + * + * @var array + */ + protected $notifications; + + /** + * The hints of this form + * + * @var array + */ + protected $hints; + + /** + * Whether the Autosubmit decorator should be applied to this form + * + * If this is true, the Autosubmit decorator is being applied to this form instead of to each of its elements. + * + * @var bool + */ + protected $useFormAutosubmit = false; + + /** + * Authentication manager + * + * @var Auth|null + */ + private $auth; + + /** + * Default element decorators + * + * @var array + */ + public static $defaultElementDecorators = array( + array('Label', array('tag'=>'span', 'separator' => '', 'class' => 'control-label')), + array(array('labelWrap' => 'HtmlTag'), array('tag' => 'div', 'class' => 'control-label-group')), + array('ViewHelper', array('separator' => '')), + array('Help', array()), + array('Errors', array('separator' => '')), + array('HtmlTag', array('tag' => 'div', 'class' => 'control-group')) + ); + + /** + * (non-PHPDoc) + * @see \Zend_Form::construct() For the method documentation. + */ + public function __construct($options = null) + { + // Zend's plugin loader reverses the order of added prefix paths thus trying our paths first before trying + // Zend paths + $this->addPrefixPaths(array( + array( + 'prefix' => 'Icinga\\Web\\Form\\Element\\', + 'path' => Icinga::app()->getLibraryDir('Icinga/Web/Form/Element'), + 'type' => static::ELEMENT + ), + array( + 'prefix' => 'Icinga\\Web\\Form\\Decorator\\', + 'path' => Icinga::app()->getLibraryDir('Icinga/Web/Form/Decorator'), + 'type' => static::DECORATOR + ) + )); + + if (! isset($options['attribs']['class'])) { + $options['attribs']['class'] = static::DEFAULT_CLASSES; + } + + parent::__construct($options); + } + + /** + * Set this form's parent + * + * @param Form $form + * + * @return $this + */ + public function setParent(Form $form) + { + $this->_parent = $form; + return $this; + } + + /** + * Return this form's parent + * + * @return Form + */ + public function getParent() + { + return $this->_parent; + } + + /** + * Set a callback that is called instead of this form's onSuccess method + * + * It is called using the following signature: (Form $this). + * + * @param callable $onSuccess Callback + * + * @return $this + * + * @throws ProgrammingError If the callback is not callable + */ + public function setOnSuccess($onSuccess) + { + if (! is_callable($onSuccess)) { + throw new ProgrammingError('The option `onSuccess\' is not callable'); + } + $this->onSuccess = $onSuccess; + return $this; + } + + /** + * Set the label to use for the standard submit button + * + * @param string $label The label to use for the submit button + * + * @return $this + */ + public function setSubmitLabel($label) + { + $this->submitLabel = $label; + return $this; + } + + /** + * Return the label being used for the standard submit button + * + * @return string + */ + public function getSubmitLabel() + { + return $this->submitLabel; + } + + /** + * Set the label to use for showing the user an activity indicator when submitting the form + * + * @param string $label + * + * @return $this + */ + public function setProgressLabel($label) + { + $this->progressLabel = $label; + return $this; + } + + /** + * Return the label to use for showing the user an activity indicator when submitting the form + * + * @return string + */ + public function getProgressLabel() + { + return $this->progressLabel; + } + + /** + * Set the url to redirect to upon success + * + * @param string|Url $url The url to redirect to + * + * @return $this + * + * @throws ProgrammingError In case $url is neither a string nor a instance of Icinga\Web\Url + */ + public function setRedirectUrl($url) + { + if (is_string($url)) { + $url = Url::fromPath($url, array(), $this->getRequest()); + } elseif (! $url instanceof Url) { + throw new ProgrammingError('$url must be a string or instance of Icinga\Web\Url'); + } + + $this->redirectUrl = $url; + return $this; + } + + /** + * Return the url to redirect to upon success + * + * @return Url + */ + public function getRedirectUrl() + { + if ($this->redirectUrl === null) { + $this->redirectUrl = $this->getRequest()->getUrl(); + if ($this->getMethod() === 'get') { + // Be sure to remove all form dependent params because we do not want to submit it again + $this->redirectUrl = $this->redirectUrl->without(array_keys($this->getElements())); + } + } + + return $this->redirectUrl; + } + + /** + * Set the view script to use when rendering this form + * + * @param string $viewScript The view script to use + * + * @return $this + */ + public function setViewScript($viewScript) + { + $this->viewScript = $viewScript; + return $this; + } + + /** + * Return the view script being used when rendering this form + * + * @return string + */ + public function getViewScript() + { + return $this->viewScript; + } + + /** + * Disable CSRF counter measure and remove its field if already added + * + * @param bool $disabled Set true in order to disable CSRF protection for this form, otherwise false + * + * @return $this + */ + public function setTokenDisabled($disabled = true) + { + $this->tokenDisabled = (bool) $disabled; + + if ($disabled && $this->getElement($this->tokenElementName) !== null) { + $this->removeElement($this->tokenElementName); + } + + return $this; + } + + /** + * Return whether CSRF counter measures are disabled for this form + * + * @return bool + */ + public function getTokenDisabled() + { + return $this->tokenDisabled; + } + + /** + * Set the name to use for the CSRF element + * + * @param string $name The name to set + * + * @return $this + */ + public function setTokenElementName($name) + { + $this->tokenElementName = $name; + return $this; + } + + /** + * Return the name of the CSRF element + * + * @return string + */ + public function getTokenElementName() + { + return $this->tokenElementName; + } + + /** + * Disable form identification and remove its field if already added + * + * @param bool $disabled Set true in order to disable identification for this form, otherwise false + * + * @return $this + */ + public function setUidDisabled($disabled = true) + { + $this->uidDisabled = (bool) $disabled; + + if ($disabled && $this->getElement($this->uidElementName) !== null) { + $this->removeElement($this->uidElementName); + } + + return $this; + } + + /** + * Return whether identification is disabled for this form + * + * @return bool + */ + public function getUidDisabled() + { + return $this->uidDisabled; + } + + /** + * Set the name to use for the form identification element + * + * @param string $name The name to set + * + * @return $this + */ + public function setUidElementName($name) + { + $this->uidElementName = $name; + return $this; + } + + /** + * Return the name of the form identification element + * + * @return string + */ + public function getUidElementName() + { + return $this->uidElementName; + } + + /** + * Set whether this form should validate the sent data when being automatically submitted + * + * @param bool $state + * + * @return $this + */ + public function setValidatePartial($state) + { + $this->validatePartial = $state; + return $this; + } + + /** + * Return whether this form should validate the sent data when being automatically submitted + * + * @return bool + */ + public function getValidatePartial() + { + return $this->validatePartial; + } + + /** + * Set whether each element's id should be altered to avoid duplicates + * + * @param bool $value + * + * @return Form + */ + public function setProtectIds($value = true) + { + $this->protectIds = (bool) $value; + return $this; + } + + /** + * Return whether each element's id is being altered to avoid duplicates + * + * @return bool + */ + public function getProtectIds() + { + return $this->protectIds; + } + + /** + * Set the cue to append to each element's label if it's required + * + * @param string $cue + * + * @return Form + */ + public function setRequiredCue($cue) + { + $this->requiredCue = $cue; + return $this; + } + + /** + * Return the cue being appended to each element's label if it's required + * + * @return string + */ + public function getRequiredCue() + { + return $this->requiredCue; + } + + /** + * Set the descriptions for this form + * + * @param array $descriptions + * + * @return Form + */ + public function setDescriptions(array $descriptions) + { + $this->descriptions = $descriptions; + return $this; + } + + /** + * Add a description for this form + * + * If $description is an array the second value should be + * an array as well containing additional HTML properties. + * + * @param string|array $description + * + * @return Form + */ + public function addDescription($description) + { + $this->descriptions[] = $description; + return $this; + } + + /** + * Return the descriptions of this form + * + * @return array + */ + public function getDescriptions() + { + if ($this->descriptions === null) { + return array(); + } + + return $this->descriptions; + } + + /** + * Set the notifications for this form + * + * @param array $notifications + * + * @return $this + */ + public function setNotifications(array $notifications) + { + $this->notifications = $notifications; + return $this; + } + + /** + * Add a notification for this form + * + * If $notification is an array the second value should be + * an array as well containing additional HTML properties. + * + * @param string|array $notification + * @param int $type + * + * @return $this + */ + public function addNotification($notification, $type) + { + $this->notifications[$type][] = $notification; + return $this; + } + + /** + * Return the notifications of this form + * + * @return array + */ + public function getNotifications() + { + if ($this->notifications === null) { + return array(); + } + + return $this->notifications; + } + + /** + * Set the hints for this form + * + * @param array $hints + * + * @return $this + */ + public function setHints(array $hints) + { + $this->hints = $hints; + return $this; + } + + /** + * Add a hint for this form + * + * If $hint is an array the second value should be an + * array as well containing additional HTML properties. + * + * @param string|array $hint + * + * @return $this + */ + public function addHint($hint) + { + $this->hints[] = $hint; + return $this; + } + + /** + * Return the hints of this form + * + * @return array + */ + public function getHints() + { + if ($this->hints === null) { + return array(); + } + + return $this->hints; + } + + /** + * Set whether the Autosubmit decorator should be applied to this form + * + * If true, the Autosubmit decorator is being applied to this form instead of to each of its elements. + * + * @param bool $state + * + * @return Form + */ + public function setUseFormAutosubmit($state = true) + { + $this->useFormAutosubmit = (bool) $state; + if ($this->useFormAutosubmit) { + $this->setAttrib('data-progress-element', 'header-' . $this->getId()); + } else { + $this->removeAttrib('data-progress-element'); + } + + return $this; + } + + /** + * Return whether the Autosubmit decorator is being applied to this form + * + * @return bool + */ + public function getUseFormAutosubmit() + { + return $this->useFormAutosubmit; + } + + /** + * Get whether the form is an API target + * + * @todo This should probably only return true if the request is also an api request + * @return bool + */ + public function getIsApiTarget() + { + return $this->isApiTarget; + } + + /** + * Set whether the form is an API target + * + * @param bool $isApiTarget + * + * @return $this + */ + public function setIsApiTarget($isApiTarget = true) + { + $this->isApiTarget = (bool) $isApiTarget; + return $this; + } + + /** + * Create this form + * + * @param array $formData The data sent by the user + * + * @return $this + */ + public function create(array $formData = array()) + { + if (! $this->created) { + $this->createElements($formData); + $this->addFormIdentification() + ->addCsrfCounterMeasure() + ->addSubmitButton(); + + // Use Form::getAttrib() instead of Form::getAction() here because we want to explicitly check against + // null. Form::getAction() would return the empty string '' if the action is not set. + // For not setting the action attribute use Form::setAction(''). This is required for for the + // accessibility's enable/disable auto-refresh mechanic + if ($this->getAttrib('action') === null) { + $action = $this->getRequest()->getUrl(); + if ($this->getMethod() === 'get') { + $action = $action->without(array_keys($this->getElements())); + } + + // TODO(el): Re-evalute this necessity. + // JavaScript could use the container'sURL if there's no action set. + // We MUST set an action as JS gets confused otherwise, if + // this form is being displayed in an additional column + $this->setAction($action); + } + + $this->created = true; + } + + return $this; + } + + /** + * Create and add elements to this form + * + * Intended to be implemented by concrete form classes. + * + * @param array $formData The data sent by the user + */ + public function createElements(array $formData) + { + } + + /** + * Perform actions after this form was submitted using a valid request + * + * Intended to be implemented by concrete form classes. The base implementation returns always FALSE. + * + * @return null|bool Return FALSE in case no redirect should take place + */ + public function onSuccess() + { + return false; + } + + /** + * Perform actions when no form dependent data was sent + * + * Intended to be implemented by concrete form classes. + */ + public function onRequest() + { + } + + /** + * Add a submit button to this form + * + * Uses the label previously set with Form::setSubmitLabel(). Overwrite this + * method in order to add multiple submit buttons or one with a custom name. + * + * @return $this + */ + public function addSubmitButton() + { + $submitLabel = $this->getSubmitLabel(); + if ($submitLabel) { + $this->addElement( + 'submit', + 'btn_submit', + array( + 'class' => 'btn-primary', + 'ignore' => true, + 'label' => $submitLabel, + 'data-progress-label' => $this->getProgressLabel(), + 'decorators' => array( + 'ViewHelper', + array('Spinner', array('separator' => '')), + array('HtmlTag', array('tag' => 'div', 'class' => 'control-group form-controls')) + ) + ) + ); + } + + return $this; + } + + /** + * Add a subform + * + * @param Zend_Form $form The subform to add + * @param string $name The name of the subform or null to use the name of $form + * @param int $order The location where to insert the form + * + * @return Zend_Form + */ + public function addSubForm(Zend_Form $form, $name = null, $order = null) + { + if ($form instanceof self) { + $form->setDecorators(array('FormElements')); // TODO: Makes it difficult to customise subform decorators.. + $form->setSubmitLabel(''); + $form->setTokenDisabled(); + $form->setUidDisabled(); + $form->setParent($this); + } + + if ($name === null) { + $name = $form->getName(); + } + + return parent::addSubForm($form, $name, $order); + } + + /** + * Create a new element + * + * Icinga Web 2 loads its own default element decorators. For loading Zend's default element decorators set the + * `disableLoadDefaultDecorators' option to any other value than `true'. For loading custom element decorators use + * the 'decorators' option. + * + * @param string $type The type of the element + * @param string $name The name of the element + * @param mixed $options The options for the element + * + * @return Zend_Form_Element + * + * @see Form::$defaultElementDecorators For Icinga Web 2's default element decorators. + */ + public function createElement($type, $name, $options = null) + { + if ($options !== null) { + if ($options instanceof Zend_Config) { + $options = $options->toArray(); + } + if (! isset($options['decorators']) + && ! array_key_exists('disabledLoadDefaultDecorators', $options) + ) { + $options['decorators'] = static::$defaultElementDecorators; + if (! isset($options['data-progress-label']) && ($type === 'submit' + || ($type === 'button' && isset($options['type']) && $options['type'] === 'submit')) + ) { + array_splice($options['decorators'], 1, 0, array(array('Spinner', array('separator' => '')))); + } elseif ($type === 'hidden') { + $options['decorators'] = array('ViewHelper'); + } + } + } else { + $options = array('decorators' => static::$defaultElementDecorators); + if ($type === 'submit') { + array_splice($options['decorators'], 1, 0, array(array('Spinner', array('separator' => '')))); + } elseif ($type === 'hidden') { + $options['decorators'] = array('ViewHelper'); + } + } + + $el = parent::createElement($type, $name, $options); + $el->setTranslator(new ErrorLabeller(array('element' => $el))); + + $el->addPrefixPaths(array( + array( + 'prefix' => 'Icinga\\Web\\Form\\Validator\\', + 'path' => Icinga::app()->getLibraryDir('Icinga/Web/Form/Validator'), + 'type' => $el::VALIDATE + ) + )); + + if ($this->protectIds) { + $el->setAttrib('id', $this->getRequest()->protectId($this->getId(false) . '_' . $el->getId())); + } + + if ($el->getAttrib('autosubmit')) { + if ($this->getUseFormAutosubmit()) { + $warningId = 'autosubmit_warning_' . $el->getId(); + $warningText = $this->getView()->escape($this->translate( + 'This page will be automatically updated upon change of the value' + )); + $autosubmitDecorator = $this->_getDecorator('Callback', array( + 'placement' => 'PREPEND', + 'callback' => function ($content) use ($warningId, $warningText) { + return '<span class="sr-only" id="' . $warningId . '">' . $warningText . '</span>'; + } + )); + } else { + $autosubmitDecorator = new Autosubmit(); + $autosubmitDecorator->setAccessible(); + $warningId = $autosubmitDecorator->getWarningId($el); + } + + $decorators = $el->getDecorators(); + $pos = array_search('Zend_Form_Decorator_ViewHelper', array_keys($decorators), true) + 1; + $el->setDecorators( + array_slice($decorators, 0, $pos, true) + + array('autosubmit' => $autosubmitDecorator) + + array_slice($decorators, $pos, count($decorators) - $pos, true) + ); + + if (($describedBy = $el->getAttrib('aria-describedby')) !== null) { + $el->setAttrib('aria-describedby', $describedBy . ' ' . $warningId); + } else { + $el->setAttrib('aria-describedby', $warningId); + } + + $class = $el->getAttrib('class'); + if (is_array($class)) { + $class[] = 'autosubmit'; + } elseif ($class === null) { + $class = 'autosubmit'; + } else { + $class .= ' autosubmit'; + } + $el->setAttrib('class', $class); + + unset($el->autosubmit); + } + + if ($el->getAttrib('preserveDefault')) { + $el->addDecorator( + array('preserveDefault' => 'HtmlTag'), + array( + 'tag' => 'input', + 'type' => 'hidden', + 'name' => $name . static::DEFAULT_SUFFIX, + 'value' => $el instanceof DateTimePicker + ? $el->getValue()->format($el->getFormat()) + : $el->getValue() + ) + ); + + unset($el->preserveDefault); + } + + return $this->ensureElementAccessibility($el); + } + + /** + * Add accessibility related attributes + * + * @param Zend_Form_Element $element + * + * @return Zend_Form_Element + */ + public function ensureElementAccessibility(Zend_Form_Element $element) + { + if ($element->isRequired()) { + $element->setAttrib('aria-required', 'true'); // ARIA + $element->setAttrib('required', ''); // HTML5 + if (($cue = $this->getRequiredCue()) !== null && ($label = $element->getDecorator('label')) !== false) { + $element->setLabel($this->getView()->escape($element->getLabel())); + $label->setOption('escape', false); + $label->setRequiredSuffix(sprintf(' <span aria-hidden="true">%s</span>', $cue)); + } + } + + if ($element->getDescription() !== null && ($help = $element->getDecorator('help')) !== false) { + if (($describedBy = $element->getAttrib('aria-describedby')) !== null) { + // Assume that it's because of the element being of type autosubmit or + // that one who did set the property manually removes the help decorator + // in case it has already an aria-describedby property set + $element->setAttrib( + 'aria-describedby', + $help->setAccessible()->getDescriptionId($element) . ' ' . $describedBy + ); + } else { + $element->setAttrib('aria-describedby', $help->setAccessible()->getDescriptionId($element)); + } + } + + return $element; + } + + /** + * Add a field with a unique and form specific ID + * + * @return $this + */ + public function addFormIdentification() + { + if (! $this->uidDisabled && $this->getElement($this->uidElementName) === null) { + $this->addElement( + 'hidden', + $this->uidElementName, + array( + 'ignore' => true, + 'value' => $this->getName(), + 'decorators' => array('ViewHelper') + ) + ); + } + + return $this; + } + + /** + * Add CSRF counter measure field to this form + * + * @return $this + */ + public function addCsrfCounterMeasure() + { + if (! $this->tokenDisabled) { + $request = $this->getRequest(); + if (! $request->isXmlHttpRequest() + && ($this->getIsApiTarget() || $request->isApiRequest()) + ) { + return $this; + } + if ($this->getElement($this->tokenElementName) === null) { + $this->addElement('CsrfCounterMeasure', $this->tokenElementName); + } + } + return $this; + } + + /** + * {@inheritdoc} + * + * Creates the form if not created yet. + * + * @param array $values + * + * @return $this + */ + public function setDefaults(array $values) + { + $this->create($values); + return parent::setDefaults($values); + } + + /** + * Populate the elements with the given values + * + * @param array $defaults The values to populate the elements with + * + * @return $this + */ + public function populate(array $defaults) + { + $this->create($defaults); + $this->preserveDefaults($this, $defaults); + return parent::populate($defaults); + } + + /** + * Recurse the given form and unset all unchanged default values + * + * @param Zend_Form $form + * @param array $defaults + */ + protected function preserveDefaults(Zend_Form $form, array &$defaults) + { + foreach ($form->getElements() as $name => $element) { + if ((array_key_exists($name, $defaults) + && array_key_exists($name . static::DEFAULT_SUFFIX, $defaults) + && $defaults[$name] === $defaults[$name . static::DEFAULT_SUFFIX]) + || $element->getAttrib('disabled') + ) { + unset($defaults[$name]); + } + } + + foreach ($form->getSubForms() as $_ => $subForm) { + $this->preserveDefaults($subForm, $defaults); + } + } + + /** + * Process the given request using this form + * + * Redirects to the url set with setRedirectUrl() upon success. See onSuccess() + * and onRequest() wherewith you can customize the processing logic. + * + * @param Request $request The request to be processed + * + * @return Request The request supposed to be processed + */ + public function handleRequest(Request $request = null) + { + if ($request === null) { + $request = $this->getRequest(); + } else { + $this->request = $request; + } + + $formData = $this->getRequestData(); + if ($this->getIsApiTarget() + // TODO: Very very bad, wasSent() must not be bypassed if it's only an api request but not an qpi target + || $this->getRequest()->isApiRequest() + || $this->getUidDisabled() + || $this->wasSent($formData) + ) { + $this->populate($formData); // Necessary to get isSubmitted() to work + if (! $this->getSubmitLabel() || $this->isSubmitted()) { + if ($this->isValid($formData) + && (($this->onSuccess !== null && false !== call_user_func($this->onSuccess, $this)) + || ($this->onSuccess === null && false !== $this->onSuccess())) + ) { + // TODO: Still bad. An api target must not behave as one if it's not an api request + if ($this->getIsApiTarget() || $this->getRequest()->isApiRequest()) { + // API targets and API requests will never redirect but immediately respond w/ JSON-encoded + // notifications + $notifications = Notification::getInstance()->popMessages(); + $message = null; + foreach ($notifications as $notification) { + if ($notification->type === Notification::SUCCESS) { + $message = $notification->message; + break; + } + } + $this->getResponse()->json() + ->setSuccessData($message !== null ? array('message' => $message) : null) + ->sendResponse(); + } else { + $this->getResponse()->redirectAndExit($this->getRedirectUrl()); + } + // TODO: Still bad. An api target must not behave as one if it's not an api request + } elseif ($this->getIsApiTarget() || $this->getRequest()->isApiRequest()) { + $this->getResponse()->json()->setFailData($this->getMessages())->sendResponse(); + } + } elseif ($this->getValidatePartial()) { + // The form can't be processed but we may want to show validation errors though + $this->isValidPartial($formData); + } + } else { + $this->onRequest(); + } + + return $request; + } + + /** + * Return whether the submit button of this form was pressed + * + * When overwriting Form::addSubmitButton() be sure to overwrite this method as well. + * + * @return bool True in case it was pressed, False otherwise or no submit label was set + */ + public function isSubmitted() + { + $requestMethod = $this->getRequest()->getMethod(); + if (strtolower($requestMethod ?: '') !== $this->getMethod()) { + return false; + } + if ($this->getIsApiTarget() || $this->getRequest()->isApiRequest()) { + return true; + } + if ($this->getSubmitLabel()) { + return $this->getElement('btn_submit')->isChecked(); + } + + return false; + } + + /** + * Return whether the data sent by the user refers to this form + * + * Ensures that the correct form gets processed in case there are multiple forms + * with equal submit button names being posted against the same route. + * + * @param array $formData The data sent by the user + * + * @return bool Whether the given data refers to this form + */ + public function wasSent(array $formData) + { + return isset($formData[$this->uidElementName]) && $formData[$this->uidElementName] === $this->getName(); + } + + /** + * Return whether the given values (possibly incomplete) are valid + * + * Unlike Zend_Form::isValid() this will not set NULL as value for + * an element that is not present in the given data. + * + * @param array $formData The data to validate + * + * @return bool + */ + public function isValidPartial(array $formData) + { + $this->create($formData); + + foreach ($this->getElements() as $name => $element) { + if (array_key_exists($name, $formData)) { + if ($element->getAttrib('disabled')) { + // Ensure that disabled elements are not overwritten + // (http://www.zendframework.com/issues/browse/ZF-6909) + $formData[$name] = $element->getValue(); + } elseif (array_key_exists($name . static::DEFAULT_SUFFIX, $formData) + && $formData[$name] === $formData[$name . static::DEFAULT_SUFFIX] + ) { + unset($formData[$name]); + } + } + } + + return parent::isValidPartial($formData); + } + + /** + * Return whether the given values are valid + * + * @param array $formData The data to validate + * + * @return bool + */ + public function isValid($formData) + { + $this->create($formData); + + // Ensure that disabled elements are not overwritten (http://www.zendframework.com/issues/browse/ZF-6909) + foreach ($this->getElements() as $name => $element) { + if ($element->getAttrib('disabled')) { + $formData[$name] = $element->getValue(); + } + } + + return parent::isValid($formData); + } + + /** + * Remove all elements of this form + * + * @return self + */ + public function clearElements() + { + $this->created = false; + return parent::clearElements(); + } + + /** + * Load the default decorators + * + * Overwrites Zend_Form::loadDefaultDecorators to avoid having + * the HtmlTag-Decorator added and to provide view script usage + * + * @return $this + */ + public function loadDefaultDecorators() + { + if ($this->loadDefaultDecoratorsIsDisabled()) { + return $this; + } + + $decorators = $this->getDecorators(); + if (empty($decorators)) { + if ($this->viewScript) { + $this->addDecorator('ViewScript', array( + 'viewScript' => $this->viewScript, + 'form' => $this + )); + } else { + $this->addDecorator('Description', array('tag' => 'h1')); + if ($this->getUseFormAutosubmit()) { + $this->getDecorator('Description')->setEscape(false); + $this->addDecorator( + 'HtmlTag', + array( + 'tag' => 'div', + 'class' => 'header', + 'id' => 'header-' . $this->getId() + ) + ); + } + + $this->addDecorator('FormDescriptions') + ->addDecorator('FormNotifications') + ->addDecorator('FormErrors', array('onlyCustomFormErrors' => true)) + ->addDecorator('FormElements') + ->addDecorator('FormHints') + //->addDecorator('HtmlTag', array('tag' => 'dl', 'class' => 'zend_form')) + ->addDecorator('Form'); + } + } + + return $this; + } + + /** + * Get element id + * + * Returns the protected id, in case id protection is enabled. + * + * @param bool $protect + * + * @return string + */ + public function getId($protect = true) + { + $id = parent::getId(); + return $protect && $this->protectIds ? $this->getRequest()->protectId($id) : $id; + } + + /** + * Return the name of this form + * + * @return string + */ + public function getName() + { + $name = parent::getName(); + if (! $name) { + $name = get_class($this); + $this->setName($name); + $name = parent::getName(); + } + return $name; + } + + /** + * Retrieve form description + * + * This will return the escaped description with the autosubmit warning icon if form autosubmit is enabled. + * + * @return string + */ + public function getDescription() + { + $description = parent::getDescription(); + if ($description && $this->getUseFormAutosubmit()) { + $autosubmit = $this->_getDecorator('Autosubmit', array('accessible' => true)); + $autosubmit->setElement($this); + $description = $autosubmit->render($this->getView()->escape($description)); + } + + return $description; + } + + /** + * Set the action to submit this form against + * + * Note that if you'll pass a instance of URL, Url::getAbsoluteUrl('&') is called to set the action. + * + * @param Url|string $action + * + * @return $this + */ + public function setAction($action) + { + if ($action instanceof Url) { + $action = $action->getAbsoluteUrl('&'); + } + + return parent::setAction($action); + } + + /** + * Set form description + * + * Alias for Zend_Form::setDescription(). + * + * @param string $value + * + * @return Form + */ + public function setTitle($value) + { + return $this->setDescription($value); + } + + /** + * Return the request associated with this form + * + * Returns the global request if none has been set for this form yet. + * + * @return Request + */ + public function getRequest() + { + if ($this->request === null) { + $this->request = Icinga::app()->getRequest(); + } + + return $this->request; + } + + /** + * Set the request + * + * @param Request $request + * + * @return $this + */ + public function setRequest(Request $request) + { + $this->request = $request; + return $this; + } + + /** + * Return the current Response + * + * @return Response + */ + public function getResponse() + { + return Icinga::app()->getFrontController()->getResponse(); + } + + /** + * Return the request data based on this form's request method + * + * @return array + */ + protected function getRequestData() + { + $requestMethod = $this->getRequest()->getMethod(); + if (strtolower($requestMethod ?: '') === $this->getMethod()) { + return $this->request->{'get' . ($this->request->isPost() ? 'Post' : 'Query')}(); + } + + return array(); + } + + /** + * Get the translation domain for this form + * + * The returned translation domain is either determined based on this form's qualified name or it is the default + * 'icinga' domain + * + * @return string + */ + protected function getTranslationDomain() + { + $parts = explode('\\', get_called_class()); + if (count($parts) > 1 && $parts[1] === 'Module') { + // Assume format Icinga\Module\ModuleName\Forms\... + return strtolower($parts[2]); + } + + return 'icinga'; + } + + /** + * Translate a string + * + * @param string $text The string to translate + * @param string|null $context Optional parameter for context based translation + * + * @return string The translated string + */ + protected function translate($text, $context = null) + { + $this->translationDomain = $this->getTranslationDomain(); + + return $this->i18nTranslate($text, $context); + } + + /** + * Translate a plural string + * + * @param string $textSingular The string in singular form to translate + * @param string $textPlural The string in plural form to translate + * @param integer $number The amount to determine from whether to return singular or plural + * @param string|null $context Optional parameter for context based translation + * + * @return string The translated string + */ + protected function translatePlural($textSingular, $textPlural, $number, $context = null) + { + $this->translationDomain = $this->getTranslationDomain(); + + return $this->i18nTranslatePlural($textSingular, $textPlural, $number, $context); + } + + /** + * Render this form + * + * @param Zend_View_Interface $view The view context to use + * + * @return string + */ + public function render(Zend_View_Interface $view = null) + { + $this->create(); + return parent::render($view); + } + + /** + * Get the authentication manager + * + * @return Auth + */ + public function Auth() + { + if ($this->auth === null) { + $this->auth = Auth::getInstance(); + } + return $this->auth; + } + + /** + * Whether the current user has the given permission + * + * @param string $permission Name of the permission + * + * @return bool + */ + public function hasPermission($permission) + { + return $this->Auth()->hasPermission($permission); + } + + /** + * Assert that the current user has the given permission + * + * @param string $permission Name of the permission + * + * @throws SecurityException If the current user lacks the given permission + */ + public function assertPermission($permission) + { + if (! $this->Auth()->hasPermission($permission)) { + throw new SecurityException('No permission for %s', $permission); + } + } + + /** + * Add a error notification + * + * @param string|array $message The notification message + * @param bool $markAsError Whether to prevent the form from being successfully validated or not + * + * @return $this + */ + public function error($message, $markAsError = true) + { + if ($this->getIsApiTarget()) { + $this->addErrorMessage($message); + } else { + $this->addNotification($message, self::NOTIFICATION_ERROR); + } + + if ($markAsError) { + $this->markAsError(); + } + + return $this; + } + + /** + * Add a warning notification + * + * @param string|array $message The notification message + * @param bool $markAsError Whether to prevent the form from being successfully validated or not + * + * @return $this + */ + public function warning($message, $markAsError = true) + { + if ($this->getIsApiTarget()) { + $this->addErrorMessage($message); + } else { + $this->addNotification($message, self::NOTIFICATION_WARNING); + } + + if ($markAsError) { + $this->markAsError(); + } + + return $this; + } + + /** + * Add a info notification + * + * @param string|array $message The notification message + * @param bool $markAsError Whether to prevent the form from being successfully validated or not + * + * @return $this + */ + public function info($message, $markAsError = true) + { + if ($this->getIsApiTarget()) { + $this->addErrorMessage($message); + } else { + $this->addNotification($message, self::NOTIFICATION_INFO); + } + + if ($markAsError) { + $this->markAsError(); + } + + return $this; + } +} diff --git a/library/Icinga/Web/Form/Decorator/Autosubmit.php b/library/Icinga/Web/Form/Decorator/Autosubmit.php new file mode 100644 index 0000000..4405d0b --- /dev/null +++ b/library/Icinga/Web/Form/Decorator/Autosubmit.php @@ -0,0 +1,133 @@ +<?php +/* Icinga Web 2 | (c) 2015 Icinga Development Team | GPLv2+ */ + +namespace Icinga\Web\Form\Decorator; + +use Zend_Form_Decorator_Abstract; +use Icinga\Application\Icinga; +use Icinga\Web\View; +use Icinga\Web\Form; + +/** + * Decorator to add an icon and a submit button encapsulated in noscript-tags + * + * The icon is shown in JS environments to indicate that a specific form field does automatically request an update + * of its form upon it has changed. The button allows users in non-JS environments to trigger the update manually. + */ +class Autosubmit extends Zend_Form_Decorator_Abstract +{ + /** + * Whether a hidden <span> should be created with the same warning as in the icon label + * + * @var bool + */ + protected $accessible; + + /** + * The id used to identify the auto-submit warning associated with the decorated form element + * + * @var string + */ + protected $warningId; + + /** + * Set whether a hidden <span> should be created with the same warning as in the icon label + * + * @param bool $state + * + * @return Autosubmit + */ + public function setAccessible($state = true) + { + $this->accessible = (bool) $state; + return $this; + } + + /** + * Return whether a hidden <span> is being created with the same warning as in the icon label + * + * @return bool + */ + public function getAccessible() + { + if ($this->accessible === null) { + $this->accessible = $this->getOption('accessible') ?: false; + } + + return $this->accessible; + } + + /** + * Return the id used to identify the auto-submit warning associated with the decorated element + * + * @param mixed $element The element for which to generate a id + * + * @return string + */ + public function getWarningId($element = null) + { + if ($this->warningId === null) { + $element = $element ?: $this->getElement(); + $this->warningId = 'autosubmit_warning_' . $element->getId(); + } + + return $this->warningId; + } + + /** + * Return the current view + * + * @return View + */ + protected function getView() + { + return Icinga::app()->getViewRenderer()->view; + } + + /** + * Add a auto-submit icon and submit button encapsulated in noscript-tags to the element + * + * @param string $content The html rendered so far + * + * @return string The updated html + */ + public function render($content = '') + { + if ($content) { + $isForm = $this->getElement() instanceof Form; + $warning = $isForm + ? t('This page will be automatically updated upon change of any of this form\'s fields') + : t('This page will be automatically updated upon change of the value'); + $content .= $this->getView()->icon('cw', $warning, array( + 'aria-hidden' => $isForm ? 'false' : 'true', + 'class' => 'spinner autosubmit-info' + )); + if (! $isForm && $this->getAccessible()) { + $content = '<span id="' + . $this->getWarningId() + . '" class="sr-only">' + . $warning + . '</span>' + . $content; + } + + $content .= sprintf( + '<noscript><button' + . ' name="noscript_apply"' + . ' class="noscript-apply"' + . ' type="submit"' + . ' value="1"' + . ($this->getAccessible() ? ' aria-label="%1$s"' : '') + . ' title="%1$s"' + . '>%2$s</button></noscript>', + $isForm + ? t('Push this button to update the form to reflect the changes that were made below') + : t('Push this button to update the form to reflect the change' + . ' that was made in the field on the left'), + $this->getView()->icon('cw') . t('Apply') + ); + } + + return $content; + } +} diff --git a/library/Icinga/Web/Form/Decorator/ConditionalHidden.php b/library/Icinga/Web/Form/Decorator/ConditionalHidden.php new file mode 100644 index 0000000..0f84535 --- /dev/null +++ b/library/Icinga/Web/Form/Decorator/ConditionalHidden.php @@ -0,0 +1,35 @@ +<?php +/* Icinga Web 2 | (c) 2013 Icinga Development Team | GPLv2+ */ + +namespace Icinga\Web\Form\Decorator; + +use Zend_Form_Decorator_Abstract; + +/** + * Decorator to hide elements using a >noscript< tag instead of + * type='hidden' or css styles. + * + * This allows to hide depending elements for browsers with javascript + * (who can then automatically refresh their pages) but show them in + * case JavaScript is disabled + */ +class ConditionalHidden extends Zend_Form_Decorator_Abstract +{ + /** + * Generate a field that will be wrapped in <noscript> tag if the + * "condition" attribute is set and false or 0 + * + * @param string $content The tag's content + * + * @return string The generated tag + */ + public function render($content = '') + { + $attributes = $this->getElement()->getAttribs(); + $condition = isset($attributes['condition']) ? $attributes['condition'] : 1; + if ($condition != 1) { + $content = '<noscript>' . $content . '</noscript>'; + } + return $content; + } +} diff --git a/library/Icinga/Web/Form/Decorator/ElementDoubler.php b/library/Icinga/Web/Form/Decorator/ElementDoubler.php new file mode 100644 index 0000000..2da5646 --- /dev/null +++ b/library/Icinga/Web/Form/Decorator/ElementDoubler.php @@ -0,0 +1,63 @@ +<?php +/* Icinga Web 2 | (c) 2014 Icinga Development Team | GPLv2+ */ + +namespace Icinga\Web\Form\Decorator; + +use Zend_Form_Element; +use Zend_Form_Decorator_Abstract; + +/** + * A decorator that will double a single element of a display group + * + * The options `condition', `double' and `attributes' can be passed to the constructor and are used to affect whether + * the doubling should take effect, which element should be doubled and which HTML attributes should be applied to the + * doubled element, respectively. + * + * `condition' must be an element's name that when it's part of the display group causes the condition to be met. + * `double' must be an element's name and must be part of the display group. + * `attributes' is just an array of key-value pairs. + * + * You can also pass `placement' to control whether the doubled element is prepended or appended. + */ +class ElementDoubler extends Zend_Form_Decorator_Abstract +{ + /** + * Return the display group's elements with an additional copy of an element being added if the condition is met + * + * @param string $content The HTML rendered so far + * + * @return string + */ + public function render($content) + { + $group = $this->getElement(); + if ($group->getElement($this->getOption('condition')) !== null) { + if ($this->getPlacement() === static::APPEND) { + return $content . $this->applyAttributes($group->getElement($this->getOption('double')))->render(); + } else { // $this->getPlacement() === static::PREPEND + return $this->applyAttributes($group->getElement($this->getOption('double')))->render() . $content; + } + } + + return $content; + } + + /** + * Apply all element attributes + * + * @param Zend_Form_Element $element The element to apply the attributes to + * + * @return Zend_Form_Element + */ + protected function applyAttributes(Zend_Form_Element $element) + { + $attributes = $this->getOption('attributes'); + if ($attributes !== null) { + foreach ($attributes as $name => $value) { + $element->setAttrib($name, $value); + } + } + + return $element; + } +} diff --git a/library/Icinga/Web/Form/Decorator/FormDescriptions.php b/library/Icinga/Web/Form/Decorator/FormDescriptions.php new file mode 100644 index 0000000..5bd5f6a --- /dev/null +++ b/library/Icinga/Web/Form/Decorator/FormDescriptions.php @@ -0,0 +1,76 @@ +<?php +/* Icinga Web 2 | (c) 2015 Icinga Development Team | GPLv2+ */ + +namespace Icinga\Web\Form\Decorator; + +use Icinga\Application\Icinga; +use Icinga\Web\Form; +use Zend_Form_Decorator_Abstract; + +/** + * Decorator to add a list of descriptions at the top or bottom of a form + */ +class FormDescriptions extends Zend_Form_Decorator_Abstract +{ + /** + * Render form descriptions + * + * @param string $content The html rendered so far + * + * @return ?string The updated html + */ + public function render($content = '') + { + $form = $this->getElement(); + if (! $form instanceof Form) { + return $content; + } + + $view = $form->getView(); + if ($view === null) { + return $content; + } + + $descriptions = $this->recurseForm($form); + if (empty($descriptions)) { + return $content; + } + + $html = '<div class="form-description">' + . Icinga::app()->getViewRenderer()->view->icon('info-circled', '', ['class' => 'form-description-icon']) + . '<ul class="form-description-list">'; + + foreach ($descriptions as $description) { + if (is_array($description)) { + list($description, $properties) = $description; + $html .= '<li' . $view->propertiesToString($properties) . '>' . $view->escape($description) . '</li>'; + } else { + $html .= '<li>' . $view->escape($description) . '</li>'; + } + } + + switch ($this->getPlacement()) { + case self::APPEND: + return $content . $html . '</ul></div>'; + case self::PREPEND: + return $html . '</ul></div>' . $content; + } + } + + /** + * Recurse the given form and return the descriptions for it and all of its subforms + * + * @param Form $form The form to recurse + * + * @return array + */ + protected function recurseForm(Form $form) + { + $descriptions = array($form->getDescriptions()); + foreach ($form->getSubForms() as $subForm) { + $descriptions[] = $this->recurseForm($subForm); + } + + return call_user_func_array('array_merge', $descriptions); + } +} diff --git a/library/Icinga/Web/Form/Decorator/FormHints.php b/library/Icinga/Web/Form/Decorator/FormHints.php new file mode 100644 index 0000000..2a0f193 --- /dev/null +++ b/library/Icinga/Web/Form/Decorator/FormHints.php @@ -0,0 +1,142 @@ +<?php +/* Icinga Web 2 | (c) 2015 Icinga Development Team | GPLv2+ */ + +namespace Icinga\Web\Form\Decorator; + +use Zend_Form_Decorator_Abstract; +use Icinga\Web\Form; + +/** + * Decorator to add a list of hints at the top or bottom of a form + * + * The hint for required form elements is automatically being handled. + */ +class FormHints extends Zend_Form_Decorator_Abstract +{ + /** + * A list of element class names to be ignored when detecting which message to use to describe required elements + * + * @var array + */ + protected $blacklist; + + /** + * {@inheritdoc} + */ + public function __construct($options = null) + { + parent::__construct($options); + $this->blacklist = array( + 'Zend_Form_Element_Hidden', + 'Zend_Form_Element_Submit', + 'Zend_Form_Element_Button', + 'Icinga\Web\Form\Element\Note', + 'Icinga\Web\Form\Element\Button', + 'Icinga\Web\Form\Element\CsrfCounterMeasure' + ); + } + + /** + * Render form hints + * + * @param string $content The html rendered so far + * + * @return ?string The updated html + */ + public function render($content = '') + { + $form = $this->getElement(); + if (! $form instanceof Form) { + return $content; + } + + $view = $form->getView(); + if ($view === null) { + return $content; + } + + $hints = $this->recurseForm($form, $entirelyRequired); + if ($entirelyRequired !== null) { + $hints[] = sprintf( + $form->getView()->translate('%s Required field'), + $form->getRequiredCue() + ); + } + + if (empty($hints)) { + return $content; + } + + $html = '<ul class="form-info">'; + foreach ($hints as $hint) { + if (is_array($hint)) { + list($hint, $properties) = $hint; + $html .= '<li' . $view->propertiesToString($properties) . '>' . $view->escape($hint) . '</li>'; + } else { + $html .= '<li>' . $view->escape($hint) . '</li>'; + } + } + + switch ($this->getPlacement()) { + case self::APPEND: + return $content . $html . '</ul>'; + case self::PREPEND: + return $html . '</ul>' . $content; + } + } + + /** + * Recurse the given form and return the hints for it and all of its subforms + * + * @param Form $form The form to recurse + * @param mixed $entirelyRequired Set by reference, true means all elements in the hierarchy are + * required, false only a partial subset and null none at all + * @param bool $elementsPassed Whether there were any elements passed during the recursion until now + * + * @return array + */ + protected function recurseForm(Form $form, &$entirelyRequired = null, $elementsPassed = false) + { + $requiredLabels = array(); + if ($form->getRequiredCue() !== null) { + $partiallyRequired = $partiallyOptional = false; + foreach ($form->getElements() as $element) { + if (! in_array($element->getType(), $this->blacklist)) { + if (! $element->isRequired()) { + $partiallyOptional = true; + if ($entirelyRequired) { + $entirelyRequired = false; + } + } else { + $partiallyRequired = true; + if (($label = $element->getDecorator('label')) !== false) { + $requiredLabels[] = $label; + } + } + } + } + + if (! $elementsPassed) { + $elementsPassed = $partiallyRequired || $partiallyOptional; + if ($entirelyRequired === null && $partiallyRequired) { + $entirelyRequired = ! $partiallyOptional; + } + } elseif ($entirelyRequired === null && $partiallyRequired) { + $entirelyRequired = false; + } + } + + $hints = array($form->getHints()); + foreach ($form->getSubForms() as $subForm) { + $hints[] = $this->recurseForm($subForm, $entirelyRequired, $elementsPassed); + } + + if ($entirelyRequired) { + foreach ($requiredLabels as $label) { + $label->setRequiredSuffix(''); + } + } + + return call_user_func_array('array_merge', $hints); + } +} diff --git a/library/Icinga/Web/Form/Decorator/FormNotifications.php b/library/Icinga/Web/Form/Decorator/FormNotifications.php new file mode 100644 index 0000000..87d12aa --- /dev/null +++ b/library/Icinga/Web/Form/Decorator/FormNotifications.php @@ -0,0 +1,125 @@ +<?php +/* Icinga Web 2 | (c) 2015 Icinga Development Team | GPLv2+ */ + +namespace Icinga\Web\Form\Decorator; + +use Icinga\Application\Icinga; +use Icinga\Exception\ProgrammingError; +use Icinga\Web\Form; +use Zend_Form_Decorator_Abstract; + +/** + * Decorator to add a list of notifications at the top or bottom of a form + */ +class FormNotifications extends Zend_Form_Decorator_Abstract +{ + /** + * Render form notifications + * + * @param string $content The html rendered so far + * + * @return ?string The updated html + */ + public function render($content = '') + { + $form = $this->getElement(); + if (! $form instanceof Form) { + return $content; + } + + $view = $form->getView(); + if ($view === null) { + return $content; + } + + $notifications = $this->recurseForm($form); + if (empty($notifications)) { + return $content; + } + + $html = '<ul class="form-notification-list">'; + foreach (array(Form::NOTIFICATION_ERROR, Form::NOTIFICATION_WARNING, Form::NOTIFICATION_INFO) as $type) { + if (isset($notifications[$type])) { + $html .= '<li><ul class="notification-' . $this->getNotificationTypeName($type) . '">'; + foreach ($notifications[$type] as $message) { + if (is_array($message)) { + list($message, $properties) = $message; + $html .= '<li' . $view->propertiesToString($properties) . '>' + . $view->escape($message) + . '</li>'; + } else { + $html .= '<li>' . $view->escape($message) . '</li>'; + } + } + + $html .= '</ul></li>'; + } + } + + if (isset($notifications[Form::NOTIFICATION_ERROR])) { + $icon = 'cancel'; + $class = 'error'; + } elseif (isset($notifications[Form::NOTIFICATION_WARNING])) { + $icon = 'warning-empty'; + $class = 'warning'; + } else { + $icon = 'info'; + $class = 'info'; + } + + $html = "<div class=\"form-notifications $class\">" + . Icinga::app()->getViewRenderer()->view->icon($icon, '', ['class' => 'form-notification-icon']) + . $html; + + switch ($this->getPlacement()) { + case self::APPEND: + return $content . $html . '</ul></div>'; + case self::PREPEND: + return $html . '</ul></div>' . $content; + } + } + + /** + * Recurse the given form and return the notifications for it and all of its subforms + * + * @param Form $form The form to recurse + * + * @return array + */ + protected function recurseForm(Form $form) + { + $notifications = $form->getNotifications(); + foreach ($form->getSubForms() as $subForm) { + foreach ($this->recurseForm($subForm) as $type => $messages) { + foreach ($messages as $message) { + $notifications[$type][] = $message; + } + } + } + + return $notifications; + } + + /** + * Return the name for the given notification type + * + * @param int $type + * + * @return string + * + * @throws ProgrammingError In case the given type is invalid + */ + protected function getNotificationTypeName($type) + { + switch ($type) { + case Form::NOTIFICATION_ERROR: + return 'error'; + case Form::NOTIFICATION_WARNING: + return 'warning'; + case Form::NOTIFICATION_INFO: + return 'info'; + default: + throw new ProgrammingError('Invalid notification type "%s" provided', $type); + } + } +} diff --git a/library/Icinga/Web/Form/Decorator/Help.php b/library/Icinga/Web/Form/Decorator/Help.php new file mode 100644 index 0000000..9e30e86 --- /dev/null +++ b/library/Icinga/Web/Form/Decorator/Help.php @@ -0,0 +1,113 @@ +<?php +/* Icinga Web 2 | (c) 2014 Icinga Development Team | GPLv2+ */ + +namespace Icinga\Web\Form\Decorator; + +use Zend_Form_Element; +use Zend_Form_Decorator_Abstract; +use Icinga\Application\Icinga; +use Icinga\Web\View; + +/** + * Decorator to add helptext to a form element + */ +class Help extends Zend_Form_Decorator_Abstract +{ + /** + * Whether a hidden <span> should be created to describe the decorated form element + * + * @var bool + */ + protected $accessible = false; + + /** + * The id used to identify the description associated with the decorated form element + * + * @var string + */ + protected $descriptionId; + + /** + * Set whether a hidden <span> should be created to describe the decorated form element + * + * @param bool $state + * + * @return Help + */ + public function setAccessible($state = true) + { + $this->accessible = (bool) $state; + return $this; + } + + /** + * Return the id used to identify the description associated with the decorated element + * + * @param Zend_Form_Element $element The element for which to generate a id + * + * @return string + */ + public function getDescriptionId(Zend_Form_Element $element = null) + { + if ($this->descriptionId === null) { + $element = $element ?: $this->getElement(); + $this->descriptionId = 'desc_' . $element->getId(); + } + + return $this->descriptionId; + } + + /** + * Return the current view + * + * @return View + */ + protected function getView() + { + return Icinga::app()->getViewRenderer()->view; + } + + /** + * Add a help icon to the left of an element + * + * @param string $content The html rendered so far + * + * @return ?string The updated html + */ + public function render($content = '') + { + $element = $this->getElement(); + $description = $element->getDescription(); + $requirement = $element->getAttrib('requirement'); + unset($element->requirement); + + $helpContent = ''; + if ($description || $requirement) { + if ($this->accessible) { + $helpContent = '<span id="' + . $this->getDescriptionId() + . '" class="sr-only">' + . $description + . ($description && $requirement ? ' ' : '') + . $requirement + . '</span>'; + } + + $helpContent = $this->getView()->icon( + 'info-circled', + $description . ($description && $requirement ? ' ' : '') . $requirement, + array( + 'class' => 'control-info', + 'aria-hidden' => $this->accessible ? 'true' : 'false' + ) + ) . $helpContent; + } + + switch ($this->getPlacement()) { + case self::APPEND: + return $content . $helpContent; + case self::PREPEND: + return $helpContent . $content; + } + } +} diff --git a/library/Icinga/Web/Form/Decorator/Spinner.php b/library/Icinga/Web/Form/Decorator/Spinner.php new file mode 100644 index 0000000..09a3ae9 --- /dev/null +++ b/library/Icinga/Web/Form/Decorator/Spinner.php @@ -0,0 +1,48 @@ +<?php +/* Icinga Web 2 | (c) 2015 Icinga Development Team | GPLv2+ */ + +namespace Icinga\Web\Form\Decorator; + +use Zend_Form_Decorator_Abstract; +use Icinga\Application\Icinga; +use Icinga\Web\View; + +/** + * Decorator to add a spinner next to an element + */ +class Spinner extends Zend_Form_Decorator_Abstract +{ + /** + * Return the current view + * + * @return View + */ + protected function getView() + { + return Icinga::app()->getViewRenderer()->view; + } + + /** + * Add a spinner icon to a form element + * + * @param string $content The html rendered so far + * + * @return ?string The updated html + */ + public function render($content = '') + { + $spinner = '<div ' + . ($this->getOption('id') !== null ? ' id="' . $this->getOption('id') . '"' : '') + . 'class="spinner ' . ($this->getOption('class') ?: '') . '"' + . '>' + . $this->getView()->icon('spin6') + . '</div>'; + + switch ($this->getPlacement()) { + case self::APPEND: + return $content . $spinner; + case self::PREPEND: + return $spinner . $content; + } + } +} diff --git a/library/Icinga/Web/Form/Element/Button.php b/library/Icinga/Web/Form/Element/Button.php new file mode 100644 index 0000000..307247e --- /dev/null +++ b/library/Icinga/Web/Form/Element/Button.php @@ -0,0 +1,81 @@ +<?php +/* Icinga Web 2 | (c) 2014 Icinga Development Team | GPLv2+ */ + +namespace Icinga\Web\Form\Element; + +use Icinga\Web\Request; +use Icinga\Application\Icinga; +use Icinga\Web\Form\FormElement; +use Zend_Config; + +/** + * A button + */ +class Button extends FormElement +{ + /** + * Use formButton view helper by default + * + * @var string + */ + public $helper = 'formButton'; + + /** + * Constructor + * + * @param string|array|Zend_Config $spec Element name or configuration + * @param string|array|Zend_Config $options Element value or configuration + */ + public function __construct($spec, $options = null) + { + if (is_string($spec) && ((null !== $options) && is_string($options))) { + $options = array('label' => $options); + } + + if (!isset($options['ignore'])) { + $options['ignore'] = true; + } + + parent::__construct($spec, $options); + + if ($label = $this->getLabel()) { + // Necessary to get the label shown on the generated HTML + $this->content = $label; + } + } + + /** + * Validate element value (pseudo) + * + * There is no need to reset the value + * + * @param mixed $value Is always ignored + * @param mixed $context Is always ignored + * + * @return bool Returns always TRUE + */ + public function isValid($value, $context = null) + { + return true; + } + + /** + * Has this button been selected? + * + * @return bool + */ + public function isChecked() + { + return $this->getRequest()->getParam($this->getName()) === $this->getValue(); + } + + /** + * Return the current request + * + * @return Request + */ + protected function getRequest() + { + return Icinga::app()->getRequest(); + } +} diff --git a/library/Icinga/Web/Form/Element/Checkbox.php b/library/Icinga/Web/Form/Element/Checkbox.php new file mode 100644 index 0000000..d4499a0 --- /dev/null +++ b/library/Icinga/Web/Form/Element/Checkbox.php @@ -0,0 +1,9 @@ +<?php +/* Icinga Web 2 | (c) 2019 Icinga GmbH | GPLv2+ */ + +namespace Icinga\Web\Form\Element; + +class Checkbox extends \Zend_Form_Element_Checkbox +{ + public $helper = 'icingaCheckbox'; +} diff --git a/library/Icinga/Web/Form/Element/CsrfCounterMeasure.php b/library/Icinga/Web/Form/Element/CsrfCounterMeasure.php new file mode 100644 index 0000000..c59e1f9 --- /dev/null +++ b/library/Icinga/Web/Form/Element/CsrfCounterMeasure.php @@ -0,0 +1,99 @@ +<?php +/* Icinga Web 2 | (c) 2014 Icinga Development Team | GPLv2+ */ + +namespace Icinga\Web\Form\Element; + +use Icinga\Web\Session; +use Icinga\Web\Form\FormElement; +use Icinga\Web\Form\InvalidCSRFTokenException; + +/** + * CSRF counter measure element + * + * You must not set a value to successfully use this element, just give it a name and you're good to go. + */ +class CsrfCounterMeasure extends FormElement +{ + /** + * Default form view helper to use for rendering + * + * @var string + */ + public $helper = 'formHidden'; + + /** + * Counter measure element is required + * + * @var bool + */ + protected $_ignore = true; + + /** + * Ignore element when retrieving values at form level + * + * @var bool + */ + protected $_required = true; + + /** + * Initialize this form element + */ + public function init() + { + $this->setDecorators(['ViewHelper']); + $this->setValue($this->generateCsrfToken()); + } + + /** + * Check whether $value is a valid CSRF token + * + * @param string $value The value to check + * @param mixed $context Context to use + * + * @return bool True, in case the CSRF token is valid + * + * @throws InvalidCSRFTokenException In case the CSRF token is not valid + */ + public function isValid($value, $context = null) + { + if (parent::isValid($value, $context) && $this->isValidCsrfToken($value)) { + return true; + } + + throw new InvalidCSRFTokenException(); + } + + /** + * Check whether the given value is a valid CSRF token for the current session + * + * @param string $token The CSRF token + * + * @return bool + */ + protected function isValidCsrfToken($token) + { + if (strpos($token, '|') === false) { + return false; + } + + list($seed, $hash) = explode('|', $token); + + if (false === is_numeric($seed)) { + return false; + } + + return $hash === hash('sha256', Session::getSession()->getId() . $seed); + } + + /** + * Generate a new (seed, token) pair + * + * @return string + */ + protected function generateCsrfToken() + { + $seed = mt_rand(); + $hash = hash('sha256', Session::getSession()->getId() . $seed); + return sprintf('%s|%s', $seed, $hash); + } +} diff --git a/library/Icinga/Web/Form/Element/Date.php b/library/Icinga/Web/Form/Element/Date.php new file mode 100644 index 0000000..8e0985c --- /dev/null +++ b/library/Icinga/Web/Form/Element/Date.php @@ -0,0 +1,19 @@ +<?php +/* Icinga Web 2 | (c) 2017 Icinga Development Team | GPLv2+ */ + +namespace Icinga\Web\Form\Element; + +use Icinga\Web\Form\FormElement; + +/** + * A date input control + */ +class Date extends FormElement +{ + /** + * Form view helper to use for rendering + * + * @var string + */ + public $helper = 'formDate'; +} diff --git a/library/Icinga/Web/Form/Element/DateTimePicker.php b/library/Icinga/Web/Form/Element/DateTimePicker.php new file mode 100644 index 0000000..284a744 --- /dev/null +++ b/library/Icinga/Web/Form/Element/DateTimePicker.php @@ -0,0 +1,80 @@ +<?php +/* Icinga Web 2 | (c) 2013 Icinga Development Team | GPLv2+ */ + +namespace Icinga\Web\Form\Element; + +use DateTime; +use Icinga\Web\Form\FormElement; +use Icinga\Web\Form\Validator\DateTimeValidator; + +/** + * A date-and-time input control + */ +class DateTimePicker extends FormElement +{ + /** + * Form view helper to use for rendering + * + * @var string + */ + public $helper = 'formDateTime'; + + /** + * @var bool + */ + protected $local = true; + + /** + * (non-PHPDoc) + * @see Zend_Form_Element::init() For the method documentation. + */ + public function init() + { + $this->addValidator( + new DateTimeValidator($this->local), + true // true for breaking the validator chain on failure + ); + } + + /** + * Get the expected date and time format of any user input + * + * @return string + */ + public function getFormat() + { + return $this->local ? 'Y-m-d\TH:i:s' : DateTime::RFC3339; + } + + /** + * Is the date and time valid? + * + * @param string|DateTime $value + * @param mixed $context + * + * @return bool + */ + public function isValid($value, $context = null) + { + if (is_scalar($value) && $value !== '' && ! preg_match('/\D/', $value)) { + $dateTime = new DateTime(); + $value = $dateTime->setTimestamp($value)->format($this->getFormat()); + } + + if (! parent::isValid($value, $context)) { + return false; + } + + if (! $value instanceof DateTime) { + $format = $this->getFormat(); + $dateTime = DateTime::createFromFormat($format, $value); + if ($dateTime === false) { + $dateTime = DateTime::createFromFormat(substr($format, 0, strrpos($format, ':')), $value); + } + + $this->setValue($dateTime); + } + + return true; + } +} diff --git a/library/Icinga/Web/Form/Element/Note.php b/library/Icinga/Web/Form/Element/Note.php new file mode 100644 index 0000000..9569dee --- /dev/null +++ b/library/Icinga/Web/Form/Element/Note.php @@ -0,0 +1,55 @@ +<?php +/* Icinga Web 2 | (c) 2013 Icinga Development Team | GPLv2+ */ + +namespace Icinga\Web\Form\Element; + +use Icinga\Web\Form\FormElement; + +/** + * A note + */ +class Note extends FormElement +{ + /** + * Form view helper to use for rendering + * + * @var string + */ + public $helper = 'formNote'; + + /** + * Ignore element when retrieving values at form level + * + * @var bool + */ + protected $_ignore = true; + + /** + * (non-PHPDoc) + * @see Zend_Form_Element::init() For the method documentation. + */ + public function init() + { + if (count($this->getDecorators()) === 0) { + $this->setDecorators(array( + 'ViewHelper', + array( + 'HtmlTag', + array('tag' => 'p') + ) + )); + } + } + + /** + * Validate element value (pseudo) + * + * @param mixed $value Ignored + * + * @return bool Always true + */ + public function isValid($value, $context = null) + { + return true; + } +} diff --git a/library/Icinga/Web/Form/Element/Number.php b/library/Icinga/Web/Form/Element/Number.php new file mode 100644 index 0000000..afbd07d --- /dev/null +++ b/library/Icinga/Web/Form/Element/Number.php @@ -0,0 +1,144 @@ +<?php +/* Icinga Web 2 | (c) 2013 Icinga Development Team | GPLv2+ */ + +namespace Icinga\Web\Form\Element; + +use Icinga\Web\Form\FormElement; + +/** + * A number input control + */ +class Number extends FormElement +{ + /** + * Form view helper to use for rendering + * + * @var string + */ + public $helper = 'formNumber'; + + /** + * The expected lower bound for the element’s value + * + * @var float|null + */ + protected $min; + + /** + * The expected upper bound for the element’s + * + * @var float|null + */ + protected $max; + + /** + * The value granularity of the element’s value + * + * Normally, number input controls are limited to an accuracy of integer values. + * + * @var float|string|null + */ + protected $step; + + /** + * (non-PHPDoc) + * @see \Zend_Form_Element::init() For the method documentation. + */ + public function init() + { + if ($this->min !== null || $this->max !== null) { + $this->addValidator('Between', true, array( + 'min' => $this->min === null ? -INF : $this->min, + 'max' => $this->max === null ? INF : $this->max, + 'inclusive' => true + )); + } + } + + /** + * Set the expected lower bound for the element’s value + * + * @param float $min + * + * @return $this + */ + public function setMin($min) + { + $this->min = (float) $min; + return $this; + } + + /** + * Get the expected lower bound for the element’s value + * + * @return float|null + */ + public function getMin() + { + return $this->min; + } + + /** + * Set the expected upper bound for the element’s value + * + * @param float $max + * + * @return $this + */ + public function setMax($max) + { + $this->max = (float) $max; + return $this; + } + + /** + * Get the expected upper bound for the element’s value + * + * @return float|null + */ + public function getMax() + { + return $this->max; + } + + /** + * Set the value granularity of the element’s value + * + * @param float|string $step + * + * @return $this + */ + public function setStep($step) + { + if ($step !== 'any') { + $step = (float) $step; + } + $this->step = $step; + return $this; + } + + /** + * Get the value granularity of the element’s value + * + * @return float|string|null + */ + public function getStep() + { + return $this->step; + } + + /** + * (non-PHPDoc) + * @see \Zend_Form_Element::isValid() For the method documentation. + */ + public function isValid($value, $context = null) + { + $this->setValue($value); + $value = $this->getValue(); + if ($value !== null && $value !== '' && ! is_numeric($value)) { + $this->addError(sprintf(t('\'%s\' is not a valid number'), $value)); + return false; + } + return parent::isValid($value, $context); + } +} diff --git a/library/Icinga/Web/Form/Element/Textarea.php b/library/Icinga/Web/Form/Element/Textarea.php new file mode 100644 index 0000000..119cd56 --- /dev/null +++ b/library/Icinga/Web/Form/Element/Textarea.php @@ -0,0 +1,20 @@ +<?php +/* Icinga Web 2 | (c) 2019 Icinga Development Team | GPLv2+ */ + +namespace Icinga\Web\Form\Element; + +use Icinga\Web\Form\FormElement; + +class Textarea extends FormElement +{ + public $helper = 'formTextarea'; + + public function __construct($spec, $options = null) + { + parent::__construct($spec, $options); + + if ($this->getAttrib('rows') === null) { + $this->setAttrib('rows', 3); + } + } +} diff --git a/library/Icinga/Web/Form/Element/Time.php b/library/Icinga/Web/Form/Element/Time.php new file mode 100644 index 0000000..4b76a33 --- /dev/null +++ b/library/Icinga/Web/Form/Element/Time.php @@ -0,0 +1,19 @@ +<?php +/* Icinga Web 2 | (c) 2017 Icinga Development Team | GPLv2+ */ + +namespace Icinga\Web\Form\Element; + +use Icinga\Web\Form\FormElement; + +/** + * A time input control + */ +class Time extends FormElement +{ + /** + * Form view helper to use for rendering + * + * @var string + */ + public $helper = 'formTime'; +} diff --git a/library/Icinga/Web/Form/ErrorLabeller.php b/library/Icinga/Web/Form/ErrorLabeller.php new file mode 100644 index 0000000..3f822d5 --- /dev/null +++ b/library/Icinga/Web/Form/ErrorLabeller.php @@ -0,0 +1,71 @@ +<?php +/* Icinga Web 2 | (c) 2015 Icinga Development Team | GPLv2+ */ + +namespace Icinga\Web\Form; + +use BadMethodCallException; +use Zend_Translate_Adapter; +use Zend_Validate_NotEmpty; +use Zend_Validate_File_MimeType; +use Icinga\Web\Form\Validator\DateTimeValidator; +use Icinga\Web\Form\Validator\ReadablePathValidator; +use Icinga\Web\Form\Validator\WritablePathValidator; + +class ErrorLabeller extends Zend_Translate_Adapter +{ + protected $messages; + + public function __construct($options = array()) + { + if (! isset($options['element'])) { + throw new BadMethodCallException('Option "element" is missing'); + } + + $this->messages = $this->createMessages($options['element']); + } + + public function isTranslated($messageId, $original = false, $locale = null) + { + return array_key_exists($messageId, $this->messages); + } + + public function translate($messageId, $locale = null) + { + if (array_key_exists($messageId, $this->messages)) { + return $this->messages[$messageId]; + } + + return $messageId; + } + + protected function createMessages($element) + { + $label = $element->getLabel() ?: $element->getName(); + + return array( + Zend_Validate_NotEmpty::IS_EMPTY => sprintf(t('%s is required and must not be empty'), $label), + Zend_Validate_File_MimeType::FALSE_TYPE => sprintf( + t('%s (%%value%%) has a false MIME type of "%%type%%"'), + $label + ), + Zend_Validate_File_MimeType::NOT_DETECTED => sprintf(t('%s (%%value%%) has no MIME type'), $label), + WritablePathValidator::NOT_WRITABLE => sprintf(t('%s is not writable', 'config.path'), $label), + WritablePathValidator::DOES_NOT_EXIST => sprintf(t('%s does not exist', 'config.path'), $label), + ReadablePathValidator::NOT_READABLE => sprintf(t('%s is not readable', 'config.path'), $label), + DateTimeValidator::INVALID_DATETIME_FORMAT => sprintf( + t('%s not in the expected format: %%value%%'), + $label + ) + ); + } + + protected function _loadTranslationData($data, $locale, array $options = array()) + { + // nonsense, required as being abstract otherwise... + } + + public function toString() + { + return 'ErrorLabeller'; // nonsense, required as being abstract otherwise... + } +} diff --git a/library/Icinga/Web/Form/FormElement.php b/library/Icinga/Web/Form/FormElement.php new file mode 100644 index 0000000..766d916 --- /dev/null +++ b/library/Icinga/Web/Form/FormElement.php @@ -0,0 +1,61 @@ +<?php +/* Icinga Web 2 | (c) 2014 Icinga Development Team | GPLv2+ */ + +namespace Icinga\Web\Form; + +use Zend_Form_Element; +use Icinga\Web\Form; + +/** + * Base class for Icinga Web 2 form elements + */ +class FormElement extends Zend_Form_Element +{ + /** + * Whether loading default decorators is disabled + * + * Icinga Web 2 loads its own default element decorators. For loading Zend's default element decorators set this + * property to false. + * + * @var null|bool + */ + protected $_disableLoadDefaultDecorators; + + /** + * Whether loading default decorators is disabled + * + * @return bool + */ + public function loadDefaultDecoratorsIsDisabled() + { + return $this->_disableLoadDefaultDecorators === true; + } + + /** + * Load default decorators + * + * Icinga Web 2 loads its own default element decorators. For loading Zend's default element decorators set + * FormElement::$_disableLoadDefaultDecorators to false. + * + * @return $this + * @see Form::$defaultElementDecorators For Icinga Web 2's default element decorators. + */ + public function loadDefaultDecorators() + { + if ($this->loadDefaultDecoratorsIsDisabled()) { + return $this; + } + + if (! isset($this->_disableLoadDefaultDecorators)) { + $decorators = $this->getDecorators(); + if (empty($decorators)) { + // Load Icinga Web 2's default element decorators + $this->addDecorators(Form::$defaultElementDecorators); + } + } else { + // Load Zend's default decorators + parent::loadDefaultDecorators(); + } + return $this; + } +} diff --git a/library/Icinga/Web/Form/InvalidCSRFTokenException.php b/library/Icinga/Web/Form/InvalidCSRFTokenException.php new file mode 100644 index 0000000..d0eb68a --- /dev/null +++ b/library/Icinga/Web/Form/InvalidCSRFTokenException.php @@ -0,0 +1,11 @@ +<?php +/* Icinga Web 2 | (c) 2013 Icinga Development Team | GPLv2+ */ + +namespace Icinga\Web\Form; + +/** + * Exceptions for invalid form tokens + */ +class InvalidCSRFTokenException extends \Exception +{ +} diff --git a/library/Icinga/Web/Form/Validator/DateFormatValidator.php b/library/Icinga/Web/Form/Validator/DateFormatValidator.php new file mode 100644 index 0000000..eacb29c --- /dev/null +++ b/library/Icinga/Web/Form/Validator/DateFormatValidator.php @@ -0,0 +1,61 @@ +<?php +/* Icinga Web 2 | (c) 2013 Icinga Development Team | GPLv2+ */ + +namespace Icinga\Web\Form\Validator; + +use Zend_Validate_Abstract; + +/** + * Validator that checks if a textfield contains a correct date format + */ +class DateFormatValidator extends Zend_Validate_Abstract +{ + + /** + * Valid date characters according to @see http://www.php.net/manual/en/function.date.php + * + * @var array + * + * @see http://www.php.net/manual/en/function.date.php + */ + private $validChars = + array('d', 'D', 'j', 'l', 'N', 'S', 'w', 'z', 'W', 'F', 'm', 'M', 'n', 't', 'L', 'o', 'Y', 'y'); + + /** + * List of sensible time separators + * + * @var array + */ + private $validSeparators = array(' ', ':', '-', '/', ';', ',', '.'); + + /** + * Error templates + * + * @var array + * + * @see Zend_Validate_Abstract::$_messageTemplates + */ + protected $_messageTemplates = array( + 'INVALID_CHARACTERS' => 'Invalid date format' + ); + + /** + * Validate the input value + * + * @param string $value The format string to validate + * @param null $context The form context (ignored) + * + * @return bool True when the input is valid, otherwise false + * + * @see Zend_Validate_Abstract::isValid() + */ + public function isValid($value, $context = null) + { + $rest = trim($value, join(' ', array_merge($this->validChars, $this->validSeparators))); + if (strlen($rest) > 0) { + $this->_error('INVALID_CHARACTERS'); + return false; + } + return true; + } +} diff --git a/library/Icinga/Web/Form/Validator/DateTimeValidator.php b/library/Icinga/Web/Form/Validator/DateTimeValidator.php new file mode 100644 index 0000000..5ef327d --- /dev/null +++ b/library/Icinga/Web/Form/Validator/DateTimeValidator.php @@ -0,0 +1,77 @@ +<?php +/* Icinga Web 2 | (c) 2013 Icinga Development Team | GPLv2+ */ + +namespace Icinga\Web\Form\Validator; + +use DateTime; +use Zend_Validate_Abstract; + +/** + * Validator for date-and-time input controls + * + * @see \Icinga\Web\Form\Element\DateTimePicker For the date-and-time input control. + */ +class DateTimeValidator extends Zend_Validate_Abstract +{ + const INVALID_DATETIME_TYPE = 'invalidDateTimeType'; + const INVALID_DATETIME_FORMAT = 'invalidDateTimeFormat'; + + /** + * The messages to write on differen error states + * + * @var array + * + * @see Zend_Validate_Abstract::$_messageTemplates‚ + */ + protected $_messageTemplates = array( + self::INVALID_DATETIME_TYPE => 'Invalid type given. Instance of DateTime or date/time string expected', + self::INVALID_DATETIME_FORMAT => 'Date/time string not in the expected format: %value%' + ); + + protected $local; + + /** + * Create a new date-and-time input control validator + * + * @param bool $local + */ + public function __construct($local) + { + $this->local = (bool) $local; + } + + /** + * Is the date and time valid? + * + * @param string|DateTime $value + * @param mixed $context + * + * @return bool + * + * @see \Zend_Validate_Interface::isValid() + */ + public function isValid($value, $context = null) + { + if (! $value instanceof DateTime && ! is_string($value)) { + $this->_error(self::INVALID_DATETIME_TYPE); + return false; + } + + if (! $value instanceof DateTime) { + $format = $baseFormat = $this->local === true ? 'Y-m-d\TH:i:s' : DateTime::RFC3339; + $dateTime = DateTime::createFromFormat($format, $value); + + if ($dateTime === false) { + $format = substr($format, 0, strrpos($format, ':')); + $dateTime = DateTime::createFromFormat($format, $value); + } + + if ($dateTime === false || $dateTime->format($format) !== $value) { + $this->_error(self::INVALID_DATETIME_FORMAT, $baseFormat); + return false; + } + } + + return true; + } +} diff --git a/library/Icinga/Web/Form/Validator/InArray.php b/library/Icinga/Web/Form/Validator/InArray.php new file mode 100644 index 0000000..5d3925e --- /dev/null +++ b/library/Icinga/Web/Form/Validator/InArray.php @@ -0,0 +1,28 @@ +<?php +/* Icinga Web 2 | (c) 2015 Icinga Development Team | GPLv2+ */ + +namespace Icinga\Web\Form\Validator; + +use Zend_Validate_InArray; +use Icinga\Util\StringHelper; + +class InArray extends Zend_Validate_InArray +{ + protected function _error($messageKey, $value = null) + { + if ($messageKey === static::NOT_IN_ARRAY) { + $matches = StringHelper::findSimilar($this->_value, $this->_haystack); + if (empty($matches)) { + $this->_messages[$messageKey] = sprintf(t('"%s" is not in the list of allowed values.'), $this->_value); + } else { + $this->_messages[$messageKey] = sprintf( + t('"%s" is not in the list of allowed values. Did you mean one of the following?: %s'), + $this->_value, + implode(', ', $matches) + ); + } + } else { + parent::_error($messageKey, $value); + } + } +} diff --git a/library/Icinga/Web/Form/Validator/InternalUrlValidator.php b/library/Icinga/Web/Form/Validator/InternalUrlValidator.php new file mode 100644 index 0000000..f936bb5 --- /dev/null +++ b/library/Icinga/Web/Form/Validator/InternalUrlValidator.php @@ -0,0 +1,41 @@ +<?php +/* Icinga Web 2 | (c) 2016 Icinga Development Team | GPLv2+ */ + +namespace Icinga\Web\Form\Validator; + +use Icinga\Application\Icinga; +use Zend_Validate_Abstract; +use Icinga\Web\Url; + +/** + * Validator that checks whether a textfield doesn't contain an external URL + */ +class InternalUrlValidator extends Zend_Validate_Abstract +{ + /** + * {@inheritdoc} + */ + public function isValid($value) + { + $url = Url::fromPath($value); + if ($url->getRelativeUrl() === '' || $url->isExternal()) { + $this->_error('IS_EXTERNAL'); + + return false; + } + + return true; + } + + /** + * {@inheritdoc} + */ + protected function _error($messageKey, $value = null) + { + if ($messageKey === 'IS_EXTERNAL') { + $this->_messages[$messageKey] = t('The url must not be external.'); + } else { + parent::_error($messageKey, $value); + } + } +} diff --git a/library/Icinga/Web/Form/Validator/ReadablePathValidator.php b/library/Icinga/Web/Form/Validator/ReadablePathValidator.php new file mode 100644 index 0000000..826421c --- /dev/null +++ b/library/Icinga/Web/Form/Validator/ReadablePathValidator.php @@ -0,0 +1,53 @@ +<?php +/* Icinga Web 2 | (c) 2014 Icinga Development Team | GPLv2+ */ + +namespace Icinga\Web\Form\Validator; + +use Zend_Validate_Abstract; + +/** + * Validator that interprets the value as a filepath and checks if it's readable + * + * This validator should be preferred due to Zend_Validate_File_Exists is + * getting confused if there is another element in the form called `name'. + */ +class ReadablePathValidator extends Zend_Validate_Abstract +{ + const NOT_READABLE = 'notReadable'; + const DOES_NOT_EXIST = 'doesNotExist'; + + /** + * The messages to write on different error states + * + * @var array + * + * @see Zend_Validate_Abstract::$_messageTemplates‚ + */ + protected $_messageTemplates = array( + self::NOT_READABLE => 'Path is not readable', + self::DOES_NOT_EXIST => 'Path does not exist' + ); + + /** + * Check whether the given value is a readable filepath + * + * @param string $value The value submitted in the form + * @param mixed $context The context of the form + * + * @return bool Whether the value was successfully validated + */ + public function isValid($value, $context = null) + { + if (false === file_exists($value)) { + $this->_error(self::DOES_NOT_EXIST); + return false; + } + + if (false === is_readable($value)) { + $this->_error(self::NOT_READABLE); + return false; + } + + return true; + } +} diff --git a/library/Icinga/Web/Form/Validator/TimeFormatValidator.php b/library/Icinga/Web/Form/Validator/TimeFormatValidator.php new file mode 100644 index 0000000..9c1c99a --- /dev/null +++ b/library/Icinga/Web/Form/Validator/TimeFormatValidator.php @@ -0,0 +1,58 @@ +<?php +/* Icinga Web 2 | (c) 2013 Icinga Development Team | GPLv2+ */ + +namespace Icinga\Web\Form\Validator; + +use Zend_Validate_Abstract; + +/** + * Validator that checks if a textfield contains a correct time format + */ +class TimeFormatValidator extends Zend_Validate_Abstract +{ + + /** + * Valid time characters according to @see http://www.php.net/manual/en/function.date.php + * + * @var array + * @see http://www.php.net/manual/en/function.date.php + */ + private $validChars = array('a', 'A', 'B', 'g', 'G', 'h', 'H', 'i', 's', 'u'); + + /** + * List of sensible time separators + * + * @var array + */ + private $validSeparators = array(' ', ':', '-', '/', ';', ',', '.'); + + /** + * Error templates + * + * @var array + * @see Zend_Validate_Abstract::$_messageTemplates + */ + protected $_messageTemplates = array( + 'INVALID_CHARACTERS' => 'Invalid time format' + ); + + /** + * Validate the input value + * + * @param string $value The format string to validate + * @param null $context The form context (ignored) + * + * @return bool True when the input is valid, otherwise false + * + * @see Zend_Validate_Abstract::isValid() + */ + public function isValid($value, $context = null) + { + $rest = trim($value, join(' ', array_merge($this->validChars, $this->validSeparators))); + if (strlen($rest) > 0) { + $this->_error('INVALID_CHARACTERS'); + return false; + } + return true; + } +} diff --git a/library/Icinga/Web/Form/Validator/UrlValidator.php b/library/Icinga/Web/Form/Validator/UrlValidator.php new file mode 100644 index 0000000..b1b578f --- /dev/null +++ b/library/Icinga/Web/Form/Validator/UrlValidator.php @@ -0,0 +1,40 @@ +<?php +/* Icinga Web 2 | (c) 2016 Icinga Development Team | GPLv2+ */ + +namespace Icinga\Web\Form\Validator; + +use Zend_Validate_Abstract; + +/** + * Validator that checks whether a textfield doesn't contain raw double quotes + */ +class UrlValidator extends Zend_Validate_Abstract +{ + /** + * Constructor + */ + public function __construct() + { + $this->_messageTemplates = array('HAS_QUOTES' => t( + 'The url must not contain raw double quotes. If you really need double quotes, use %22 instead.' + )); + } + + /** + * Validate the input value + * + * @param string $value The string to validate + * + * @return bool true if and only if the input is valid, otherwise false + * + * @see Zend_Validate_Abstract::isValid() + */ + public function isValid($value) + { + $hasQuotes = false === strpos($value, '"'); + if (! $hasQuotes) { + $this->_error('HAS_QUOTES'); + } + return $hasQuotes; + } +} diff --git a/library/Icinga/Web/Form/Validator/WritablePathValidator.php b/library/Icinga/Web/Form/Validator/WritablePathValidator.php new file mode 100644 index 0000000..76efb58 --- /dev/null +++ b/library/Icinga/Web/Form/Validator/WritablePathValidator.php @@ -0,0 +1,72 @@ +<?php +/* Icinga Web 2 | (c) 2013 Icinga Development Team | GPLv2+ */ + +namespace Icinga\Web\Form\Validator; + +use Zend_Validate_Abstract; + +/** + * Validator that interprets the value as a path and checks if it's writable + */ +class WritablePathValidator extends Zend_Validate_Abstract +{ + const NOT_WRITABLE = 'notWritable'; + const DOES_NOT_EXIST = 'doesNotExist'; + + /** + * The messages to write on differen error states + * + * @var array + * + * @see Zend_Validate_Abstract::$_messageTemplates‚ + */ + protected $_messageTemplates = array( + self::NOT_WRITABLE => 'Path is not writable', + self::DOES_NOT_EXIST => 'Path does not exist' + ); + + /** + * When true, the file or directory must exist + * + * @var bool + */ + private $requireExistence = false; + + /** + * Set this validator to require the target file to exist + */ + public function setRequireExistence() + { + $this->requireExistence = true; + } + + /** + * Check whether the given value is writable path + * + * @param string $value The value submitted in the form + * @param mixed $context The context of the form + * + * @return bool True when validation worked, otherwise false + * + * @see Zend_Validate_Abstract::isValid() + */ + public function isValid($value, $context = null) + { + $value = (string) $value; + + $this->_setValue($value); + if ($this->requireExistence && !file_exists($value)) { + $this->_error(self::DOES_NOT_EXIST); + return false; + } + + if ((file_exists($value) && is_writable($value)) || + (is_dir(dirname($value)) && is_writable(dirname($value))) + ) { + return true; + } + + $this->_error(self::NOT_WRITABLE); + return false; + } +} diff --git a/library/Icinga/Web/Helper/CookieHelper.php b/library/Icinga/Web/Helper/CookieHelper.php new file mode 100644 index 0000000..cc7c448 --- /dev/null +++ b/library/Icinga/Web/Helper/CookieHelper.php @@ -0,0 +1,81 @@ +<?php +/* Icinga Web 2 | (c) 2013 Icinga Development Team | GPLv2+ */ + +namespace Icinga\Web\Helper; + +use Icinga\Web\Request; + +/** + * Helper Class Cookie + */ +class CookieHelper +{ + /** + * The name of the control cookie + */ + const CHECK_COOKIE = '_chc'; + + /** + * The request + * + * @var Request + */ + protected $request; + + /** + * Create a new cookie + * + * @param Request $request + */ + public function __construct(Request $request) + { + $this->request = $request; + } + + /** + * Check whether cookies are supported or not + * + * @return bool + */ + public function isSupported() + { + if (! empty($_COOKIE)) { + $this->cleanupCheck(); + return true; + } + + $url = $this->request->getUrl(); + + if ($url->hasParam('_checkCookie') && empty($_COOKIE)) { + return false; + } + + if (! $url->hasParam('_checkCookie')) { + $this->provideCheck(); + } + + return false; + } + + /** + * Prepare check to detect cookie support + */ + public function provideCheck() + { + setcookie(self::CHECK_COOKIE, '1'); + + $requestUri = $this->request->getUrl()->addParams(array('_checkCookie' => 1)); + $this->request->getResponse()->redirectAndExit($requestUri); + } + + /** + * Cleanup the cookie support check + */ + public function cleanupCheck() + { + if ($this->request->getUrl()->hasParam('_checkCookie') && isset($_COOKIE[self::CHECK_COOKIE])) { + $requestUri =$this->request->getUrl()->without('_checkCookie'); + $this->request->getResponse()->redirectAndExit($requestUri); + } + } +} diff --git a/library/Icinga/Web/Helper/HtmlPurifier.php b/library/Icinga/Web/Helper/HtmlPurifier.php new file mode 100644 index 0000000..19fd207 --- /dev/null +++ b/library/Icinga/Web/Helper/HtmlPurifier.php @@ -0,0 +1,95 @@ +<?php +/* Icinga Web 2 | (c) 2018 Icinga Development Team | GPLv2+ */ + +namespace Icinga\Web\Helper; + +use Closure; +use Icinga\Web\FileCache; +use InvalidArgumentException; + +class HtmlPurifier +{ + /** + * The actual purifier instance + * + * @var \HTMLPurifier + */ + protected $purifier; + + /** + * Create a new HtmlPurifier + * + * @param array|Closure $config Additional configuration + */ + public function __construct($config = null) + { + $purifierConfig = \HTMLPurifier_Config::createDefault(); + $purifierConfig->set('Core.EscapeNonASCIICharacters', true); + $purifierConfig->set('Attr.AllowedFrameTargets', array('_blank')); + + if (($cachePath = FileCache::instance()->directory('htmlpurifier.cache')) !== false) { + $purifierConfig->set('Cache.SerializerPath', $cachePath); + } else { + $purifierConfig->set('Cache.DefinitionImpl', null); + } + + // This avoids permission problems: + // $purifierConfig->set('Core.DefinitionCache', null); + + // $purifierConfig->set('URI.Base', 'http://www.example.com'); + // $purifierConfig->set('URI.MakeAbsolute', true); + + $this->configure($purifierConfig); + + if ($config instanceof Closure) { + call_user_func($config, $purifierConfig); + } elseif (is_array($config)) { + $purifierConfig->loadArray($config); + } elseif ($config !== null) { + throw new InvalidArgumentException('$config must be either a Closure or array'); + } + + $this->purifier = new \HTMLPurifier($purifierConfig); + } + + /** + * Apply additional default configuration + * + * May be overwritten by more concrete purifier implementations. + * + * @param \HTMLPurifier_Config $config + */ + protected function configure($config) + { + } + + /** + * Purify and return the given HTML string + * + * @param string $html + * @param array|Closure $config Configuration to use instead of the default + * + * @return string + */ + public function purify($html, $config = null) + { + return $this->purifier->purify($html, $config); + } + + /** + * Purify and return the given HTML string + * + * Convenience method to bypass object creation. + * + * @param string $html + * @param array|Closure $config Additional configuration + * + * @return string + */ + public static function process($html, $config = null) + { + $purifier = new static($config); + + return $purifier->purify($html); + } +} diff --git a/library/Icinga/Web/Helper/Markdown.php b/library/Icinga/Web/Helper/Markdown.php new file mode 100644 index 0000000..cb854b4 --- /dev/null +++ b/library/Icinga/Web/Helper/Markdown.php @@ -0,0 +1,34 @@ +<?php +/* Icinga Web 2 | (c) 2019 Icinga GmbH | GPLv2+ */ + +namespace Icinga\Web\Helper; + +use Icinga\Web\Helper\Markdown\LinkTransformer; +use Parsedown; + +class Markdown +{ + public static function line($content, $config = null) + { + if ($config === null) { + $config = function (\HTMLPurifier_Config $config) { + $config->set('HTML.Parent', 'span'); // Only allow inline elements + + LinkTransformer::attachTo($config); + }; + } + + return HtmlPurifier::process(Parsedown::instance()->line($content), $config); + } + + public static function text($content, $config = null) + { + if ($config === null) { + $config = function (\HTMLPurifier_Config $config) { + LinkTransformer::attachTo($config); + }; + } + + return HtmlPurifier::process(Parsedown::instance()->text($content), $config); + } +} diff --git a/library/Icinga/Web/Helper/Markdown/LinkTransformer.php b/library/Icinga/Web/Helper/Markdown/LinkTransformer.php new file mode 100644 index 0000000..f323085 --- /dev/null +++ b/library/Icinga/Web/Helper/Markdown/LinkTransformer.php @@ -0,0 +1,73 @@ +<?php +/* Icinga Web 2 | (c) 2021 Icinga GmbH | GPLv2+ */ + +namespace Icinga\Web\Helper\Markdown; + +use HTMLPurifier_AttrTransform; +use HTMLPurifier_Config; +use ipl\Web\Url; + +class LinkTransformer extends HTMLPurifier_AttrTransform +{ + /** + * Link targets that are considered to have a thumbnail + * + * @var string[] + */ + public static $IMAGE_FILES = [ + 'jpg', + 'jpeg', + 'png', + 'bmp', + 'gif', + 'heif', + 'heic', + 'webp' + ]; + + public function transform($attr, $config, $context) + { + if (! isset($attr['href'])) { + return $attr; + } + + $url = Url::fromPath($attr['href']); + $fileName = basename($url->getPath()); + + $ext = null; + if (($extAt = strrpos($fileName, '.')) !== false) { + $ext = substr($fileName, $extAt + 1); + } + + $hasThumbnail = $ext !== null && in_array($ext, static::$IMAGE_FILES, true); + if ($hasThumbnail) { + // I would have liked to not only base this off of the extension, but also by + // whether there is an actual img tag inside the anchor. Seems not possible :( + $attr['class'] = 'with-thumbnail'; + } + + if (! isset($attr['target'])) { + if ($url->isExternal()) { + $attr['target'] = '_blank'; + } else { + $attr['data-base-target'] = '_next'; + } + } + + return $attr; + } + + public static function attachTo(HTMLPurifier_Config $config) + { + $module = $config->getHTMLDefinition(true) + ->getAnonymousModule(); + + if (isset($module->info['a'])) { + $a = $module->info['a']; + } else { + $a = $module->addBlankElement('a'); + } + + $a->attr_transform_post[] = new self(); + } +} diff --git a/library/Icinga/Web/Hook.php b/library/Icinga/Web/Hook.php new file mode 100644 index 0000000..b098518 --- /dev/null +++ b/library/Icinga/Web/Hook.php @@ -0,0 +1,16 @@ +<?php +/* Icinga Web 2 | (c) 2013 Icinga Development Team | GPLv2+ */ + +namespace Icinga\Web; + +use Icinga\Application\Hook as NewHookImplementation; + +/** + * Icinga Web Hook registry + * + * @deprecated It is highly recommended to use {@see Icinga\Application\Hook} instead. Though since this message + * (or rather the previous message) hasn't been visible for ages... This won't be removed anyway.... + */ +class Hook extends NewHookImplementation +{ +} diff --git a/library/Icinga/Web/JavaScript.php b/library/Icinga/Web/JavaScript.php new file mode 100644 index 0000000..1865136 --- /dev/null +++ b/library/Icinga/Web/JavaScript.php @@ -0,0 +1,269 @@ +<?php +/* Icinga Web 2 | (c) 2014 Icinga Development Team | GPLv2+ */ + +namespace Icinga\Web; + +use Icinga\Application\Icinga; +use Icinga\Application\Logger; +use Icinga\Exception\Json\JsonDecodeException; +use Icinga\Util\Json; +use JShrink\Minifier; + +class JavaScript +{ + /** @var string */ + const DEFINE_RE = + '/(?<!\.)define\(\s*([\'"][^\'"]*[\'"])?[,\s]*(\[[^]]*\])?[,\s]*((?>function\s*\([^)]*\)|[^=]*=>|\w+).*)/'; + + protected static $jsFiles = [ + 'js/helpers.js', + 'js/icinga.js', + 'js/icinga/logger.js', + 'js/icinga/storage.js', + 'js/icinga/utils.js', + 'js/icinga/ui.js', + 'js/icinga/timer.js', + 'js/icinga/loader.js', + 'js/icinga/eventlistener.js', + 'js/icinga/events.js', + 'js/icinga/history.js', + 'js/icinga/module.js', + 'js/icinga/timezone.js', + 'js/icinga/behavior/application-state.js', + 'js/icinga/behavior/autofocus.js', + 'js/icinga/behavior/collapsible.js', + 'js/icinga/behavior/detach.js', + 'js/icinga/behavior/dropdown.js', + 'js/icinga/behavior/navigation.js', + 'js/icinga/behavior/form.js', + 'js/icinga/behavior/actiontable.js', + 'js/icinga/behavior/flyover.js', + 'js/icinga/behavior/filtereditor.js', + 'js/icinga/behavior/selectable.js', + 'js/icinga/behavior/modal.js', + 'js/icinga/behavior/input-enrichment.js', + 'js/icinga/behavior/datetime-picker.js', + 'js/icinga/behavior/copy-to-clipboard.js' + ]; + + protected static $vendorFiles = []; + + protected static $baseFiles = [ + 'js/define.js' + ]; + + public static function sendMinified() + { + self::send(true); + } + + /** + * Send the client side script code to the client + * + * Does not cache the client side script code if the HTTP header Cache-Control or Pragma is set to no-cache. + * + * @param bool $minified Whether to compress the client side script code + */ + public static function send($minified = false) + { + header('Content-Type: application/javascript'); + $basedir = Icinga::app()->getBootstrapDirectory(); + $moduleManager = Icinga::app()->getModuleManager(); + + $files = []; + $js = $out = ''; + $min = $minified ? '.min' : ''; + + // Prepare vendor file list + $vendorFiles = []; + foreach (self::$vendorFiles as $file) { + $filePath = $basedir . '/' . $file . $min . '.js'; + $vendorFiles[] = $filePath; + $files[] = $filePath; + } + + // Prepare base file list + $baseFiles = []; + foreach (self::$baseFiles as $file) { + $filePath = $basedir . '/' . $file; + $baseFiles[] = $filePath; + $files[] = $filePath; + } + + // Prepare library file list + foreach (Icinga::app()->getLibraries() as $library) { + $files = array_merge($files, $library->getJsAssets()); + } + + // Prepare core file list + $coreFiles = []; + foreach (self::$jsFiles as $file) { + $filePath = $basedir . '/' . $file; + $coreFiles[] = $filePath; + $files[] = $filePath; + } + + $moduleFiles = []; + foreach ($moduleManager->getLoadedModules() as $name => $module) { + if ($module->hasJs()) { + $jsDir = $module->getJsDir(); + foreach ($module->getJsFiles() as $path) { + if (file_exists($path)) { + $moduleFiles[$name][$jsDir][] = $path; + $files[] = $path; + } + } + } + } + + $request = Icinga::app()->getRequest(); + $noCache = $request->getHeader('Cache-Control') === 'no-cache' || $request->getHeader('Pragma') === 'no-cache'; + + header('Cache-Control: public,no-cache,must-revalidate'); + + if (! $noCache && FileCache::etagMatchesFiles($files)) { + header("HTTP/1.1 304 Not Modified"); + return; + } else { + $etag = FileCache::etagForFiles($files); + } + + header('ETag: "' . $etag . '"'); + header('Content-Type: application/javascript'); + + $cacheFile = 'icinga-' . $etag . $min . '.js'; + $cache = FileCache::instance(); + if (! $noCache && $cache->has($cacheFile)) { + $cache->send($cacheFile); + return; + } + + // We do not minify vendor files + foreach ($vendorFiles as $file) { + $out .= ';' . ltrim(trim(file_get_contents($file)), ';') . "\n"; + } + + $baseJs = ''; + foreach ($baseFiles as $file) { + $baseJs .= file_get_contents($file) . "\n\n\n"; + } + + // Library files need to be namespaced first before they can be included + foreach (Icinga::app()->getLibraries() as $library) { + foreach ($library->getJsAssets() as $file) { + $alreadyMinified = false; + if ($minified && file_exists(($minFile = substr($file, 0, -3) . '.min.js'))) { + $alreadyMinified = true; + $file = $minFile; + } + + $content = self::optimizeDefine( + file_get_contents($file), + $file, + $library->getJsAssetPath(), + $library->getName() + ); + + if ($alreadyMinified) { + $out .= ';' . ltrim(trim($content), ';') . "\n"; + } else { + $js .= $content . "\n\n\n"; + } + } + } + + foreach ($coreFiles as $file) { + $js .= file_get_contents($file) . "\n\n\n"; + } + + foreach ($moduleFiles as $name => $paths) { + foreach ($paths as $basePath => $filePaths) { + foreach ($filePaths as $file) { + $content = self::optimizeDefine(file_get_contents($file), $file, $basePath, $name); + if (substr($file, -7, 7) === '.min.js') { + $out .= ';' . ltrim(trim($content), ';') . "\n"; + } else { + $js .= $content . "\n\n\n"; + } + } + } + } + + if ($minified) { + $out .= Minifier::minify($js, ['flaggedComments' => false]); + $baseOut = Minifier::minify($baseJs, ['flaggedComments' => false]); + $out = ';' . ltrim($baseOut, ';') . "\n" . $out; + } else { + $out = $baseJs . $out . $js; + } + + $cache->store($cacheFile, $out); + echo $out; + } + + /** + * Optimize define() calls in the given JS + * + * @param string $js + * @param string $filePath + * @param string $basePath + * @param string $packageName + * + * @return string + */ + public static function optimizeDefine($js, $filePath, $basePath, $packageName) + { + if (! preg_match(self::DEFINE_RE, $js, $match) || strpos($js, 'define.amd') !== false) { + return $js; + } + + try { + $assetName = $match[1] ? Json::decode($match[1]) : ''; + if (! $assetName) { + $assetName = explode('.', basename($filePath))[0]; + } + + $assetName = join(DIRECTORY_SEPARATOR, array_filter([ + $packageName, + ltrim(substr(dirname($filePath), strlen($basePath)), DIRECTORY_SEPARATOR), + $assetName + ])); + + $assetName = Json::encode($assetName, JSON_UNESCAPED_SLASHES); + } catch (JsonDecodeException $_) { + $assetName = $match[1]; + Logger::debug('Can\'t optimize name of "%s". Are single quotes used instead of double quotes?', $filePath); + } + + try { + $dependencies = $match[2] ? Json::decode($match[2]) : []; + foreach ($dependencies as &$dependencyName) { + if ($dependencyName === 'exports') { + // exports is a special keyword and doesn't need optimization + continue; + } + + if (preg_match('~^((?:\.\.?/)+)*(.*)~', $dependencyName, $natch)) { + $dependencyName = join(DIRECTORY_SEPARATOR, array_filter([ + $packageName, + ltrim(substr( + realpath(join(DIRECTORY_SEPARATOR, [dirname($filePath), $natch[1]])), + strlen(realpath($basePath)) + ), DIRECTORY_SEPARATOR), + $natch[2] + ])); + } + } + + $dependencies = Json::encode($dependencies, JSON_UNESCAPED_SLASHES); + } catch (JsonDecodeException $_) { + $dependencies = $match[2]; + Logger::debug( + 'Can\'t optimize dependencies of "%s". Are single quotes used instead of double quotes?', + $filePath + ); + } + + return str_replace($match[0], sprintf("define(%s, %s, %s", $assetName, $dependencies, $match[3]), $js); + } +} diff --git a/library/Icinga/Web/LessCompiler.php b/library/Icinga/Web/LessCompiler.php new file mode 100644 index 0000000..d7eda09 --- /dev/null +++ b/library/Icinga/Web/LessCompiler.php @@ -0,0 +1,255 @@ +<?php +/* Icinga Web 2 | (c) 2013 Icinga Development Team | GPLv2+ */ + +namespace Icinga\Web; + +use Icinga\Application\Logger; +use Icinga\Util\LessParser; +use Less_Exception_Parser; + +/** + * Compile LESS into CSS + * + * Comments will be removed always. lessc is messing them up. + */ +class LessCompiler +{ + /** + * lessphp compiler + * + * @var LessParser + */ + protected $lessc; + + /** + * Array of LESS files + * + * @var string[] + */ + protected $lessFiles = array(); + + /** + * Array of module LESS files indexed by module names + * + * @var array[] + */ + protected $moduleLessFiles = array(); + + /** + * LESS source + * + * @var string + */ + protected $source; + + /** + * Path of the LESS theme + * + * @var string + */ + protected $theme; + + /** + * Path of the LESS theme mode + * + * @var string + */ + protected $themeMode; + + /** + * Create a new LESS compiler + */ + public function __construct() + { + $this->lessc = new LessParser(); + } + + /** + * Add a Web 2 LESS file + * + * @param string $lessFile Path to the LESS file + * + * @return $this + */ + public function addLessFile($lessFile) + { + $this->lessFiles[] = realpath($lessFile); + return $this; + } + + /** + * Add a module LESS file + * + * @param string $moduleName Name of the module + * @param string $lessFile Path to the LESS file + * + * @return $this + */ + public function addModuleLessFile($moduleName, $lessFile) + { + if (! isset($this->moduleLessFiles[$moduleName])) { + $this->moduleLessFiles[$moduleName] = array(); + } + $this->moduleLessFiles[$moduleName][] = realpath($lessFile); + return $this; + } + + /** + * Get the list of LESS files added to the compiler + * + * @return string[] + */ + public function getLessFiles() + { + $lessFiles = $this->lessFiles; + + foreach ($this->moduleLessFiles as $moduleLessFiles) { + $lessFiles = array_merge($lessFiles, $moduleLessFiles); + } + + if ($this->theme !== null) { + $lessFiles[] = $this->theme; + } + + if ($this->themeMode !== null) { + $lessFiles[] = $this->themeMode; + } + + return $lessFiles; + } + + /** + * Set the path to the LESS theme + * + * @param ?string $theme Path to the LESS theme + * + * @return $this + */ + public function setTheme($theme) + { + if ($theme === null || (is_file($theme) && is_readable($theme))) { + $this->theme = $theme; + } else { + Logger::error('Can\t load theme %s. Make sure that the theme exists and is readable', $theme); + } + return $this; + } + + /** + * Set the path to the LESS theme mode + * + * @param string $themeMode Path to the LESS theme mode + * + * @return $this + */ + public function setThemeMode($themeMode) + { + if (is_file($themeMode) && is_readable($themeMode)) { + $this->themeMode = $themeMode; + } else { + Logger::error('Can\t load theme mode %s. Make sure that the theme mode exists and is readable', $themeMode); + } + return $this; + } + + /** + * Instruct the compiler to minify CSS + * + * @return $this + */ + public function compress() + { + $this->lessc->setFormatter('compressed'); + return $this; + } + + /** + * Render to CSS + * + * @return string + */ + public function render() + { + foreach ($this->lessFiles as $lessFile) { + $this->source .= file_get_contents($lessFile); + } + + $moduleCss = ''; + $exportedVars = []; + foreach ($this->moduleLessFiles as $moduleName => $moduleLessFiles) { + $moduleCss .= '.icinga-module.module-' . $moduleName . ' {'; + + foreach ($moduleLessFiles as $moduleLessFile) { + $content = file_get_contents($moduleLessFile); + + $pattern = '/^@exports:\s*{((?:\s*@[^:}]+:[^;]*;\s+)+)};$/m'; + if (preg_match_all($pattern, $content, $matches, PREG_SET_ORDER)) { + foreach ($matches as $match) { + $content = str_replace($match[0], '', $content); + foreach (explode("\n", trim($match[1])) as $line) { + list($name, $value) = explode(':', $line, 2); + $exportedVars[trim($name)] = trim($value, ' ;'); + } + } + } + + $moduleCss .= $content; + } + + $moduleCss .= '}'; + } + + $this->source .= $moduleCss; + + $varExports = ''; + foreach ($exportedVars as $name => $value) { + $varExports .= sprintf("%s: %s;\n", $name, $value); + } + + // exported vars are injected at the beginning to avoid that they are + // able to override other variables, that's what themes are for + $this->source = $varExports . "\n\n" . $this->source; + + if ($this->theme !== null) { + $this->source .= file_get_contents($this->theme); + } + + if ($this->themeMode !== null) { + $this->source .= file_get_contents($this->themeMode); + } + + try { + return preg_replace( + '/(\.icinga-module\.module-[^\s]+) (#layout\.[^\s]+)/m', + '\2 \1', + $this->lessc->compile($this->source) + ); + } catch (Less_Exception_Parser $e) { + $excerpt = substr($this->source, $e->index - 500, 1000); + + $lines = []; + $found = false; + $pos = $e->index - 500; + foreach (explode("\n", $excerpt) as $i => $line) { + if ($i === 0) { + $pos += strlen($line); + $lines[] = '.. ' . $line; + } else { + $pos += strlen($line) + 1; + $sep = ' '; + if (! $found && $pos > $e->index) { + $found = true; + $sep = '!! '; + } + + $lines[] = $sep . $line; + } + } + + $lines[] = '..'; + $excerpt = join("\n", $lines); + + return sprintf("%s\n%s\n\n\n%s", $e->getMessage(), $e->getTraceAsString(), $excerpt); + } + } +} diff --git a/library/Icinga/Web/Menu.php b/library/Icinga/Web/Menu.php new file mode 100644 index 0000000..dc1cdc8 --- /dev/null +++ b/library/Icinga/Web/Menu.php @@ -0,0 +1,152 @@ +<?php +/* Icinga Web 2 | (c) 2018 Icinga Development Team | GPLv2+ */ + +namespace Icinga\Web; + +use Icinga\Application\Logger; +use Icinga\Authentication\Auth; +use Icinga\Web\Navigation\Navigation; + +/** + * Main menu for Icinga Web 2 + */ +class Menu extends Navigation +{ + /** + * Create the main menu + */ + public function __construct() + { + $this->init(); + $this->load('menu-item'); + } + + /** + * Setup the main menu + */ + public function init() + { + $this->addItem('dashboard', [ + 'label' => t('Dashboard'), + 'url' => 'dashboard', + 'icon' => 'dashboard', + 'priority' => 10 + ]); + $this->addItem('system', [ + 'cssClass' => 'system-nav-item', + 'label' => t('System'), + 'icon' => 'services', + 'priority' => 700, + 'renderer' => [ + 'SummaryNavigationItemRenderer', + 'state' => 'critical' + ], + 'children' => [ + 'about' => [ + 'icon' => 'info', + 'description' => t('Open about page'), + 'label' => t('About'), + 'url' => 'about', + 'priority' => 700 + ], + 'health' => [ + 'icon' => 'eye', + 'description' => t('Open health overview'), + 'label' => t('Health'), + 'url' => 'health', + 'priority' => 710, + 'renderer' => 'HealthNavigationRenderer' + ], + 'announcements' => [ + 'icon' => 'megaphone', + 'description' => t('List announcements'), + 'label' => t('Announcements'), + 'url' => 'announcements', + 'priority' => 720 + ], + 'sessions' => [ + 'icon' => 'host', + 'description' => t('List of users who stay logged in'), + 'label' => t('User Sessions'), + 'permission' => 'application/sessions', + 'url' => 'manage-user-devices', + 'priority' => 730 + ] + ] + ]); + $this->addItem('configuration', [ + 'cssClass' => 'configuration-nav-item', + 'label' => t('Configuration'), + 'icon' => 'wrench', + 'permission' => 'config/*', + 'priority' => 800, + 'children' => [ + 'application' => [ + 'icon' => 'wrench', + 'description' => t('Open application configuration'), + 'label' => t('Application'), + 'url' => 'config', + 'priority' => 810 + ], + 'authentication' => [ + 'icon' => 'users', + 'description' => t('Open access control configuration'), + 'label' => t('Access Control'), + 'permission' => 'config/access-control/*', + 'priority' => 830, + 'url' => 'role' + ], + 'navigation' => [ + 'icon' => 'sitemap', + 'description' => t('Open shared navigation configuration'), + 'label' => t('Shared Navigation'), + 'url' => 'navigation/shared', + 'permission' => 'config/navigation', + 'priority' => 840, + ], + 'modules' => [ + 'icon' => 'cubes', + 'description' => t('Open module configuration'), + 'label' => t('Modules'), + 'url' => 'config/modules', + 'permission' => 'config/modules', + 'priority' => 890 + ] + ] + ]); + $this->addItem('user', [ + 'cssClass' => 'user-nav-item', + 'label' => Auth::getInstance()->getUser()->getUsername(), + 'icon' => 'user', + 'priority' => 900, + 'children' => [ + 'account' => [ + 'icon' => 'sliders', + 'description' => t('Open your account preferences'), + 'label' => t('My Account'), + 'priority' => 100, + 'url' => 'account' + ], + 'logout' => [ + 'icon' => 'off', + 'description' => t('Log out'), + 'label' => t('Logout'), + 'priority' => 200, + 'attributes' => ['target' => '_self'], + 'url' => 'authentication/logout' + ] + ] + ]); + + if (Logger::writesToFile()) { + $this->getItem('system')->addChild($this->createItem('application_log', [ + 'icon' => 'doc-text', + 'description' => t('Open Application Log'), + 'label' => t('Application Log'), + 'url' => 'list/applicationlog', + 'permission' => 'application/log', + 'priority' => 900 + ])); + } + } +} diff --git a/library/Icinga/Web/Navigation/ConfigMenu.php b/library/Icinga/Web/Navigation/ConfigMenu.php new file mode 100644 index 0000000..583bf42 --- /dev/null +++ b/library/Icinga/Web/Navigation/ConfigMenu.php @@ -0,0 +1,327 @@ +<?php +/* Icinga Web 2 | (c) 2022 Icinga GmbH | GPLv2+ */ + +namespace Icinga\Web\Navigation; + +use Icinga\Application\Hook\HealthHook; +use Icinga\Application\Icinga; +use Icinga\Application\Logger; +use Icinga\Application\MigrationManager; +use Icinga\Authentication\Auth; +use Icinga\Web\Navigation\Renderer\BadgeNavigationItemRenderer; +use ipl\Html\Attributes; +use ipl\Html\BaseHtmlElement; +use ipl\Html\HtmlElement; +use ipl\Html\Text; +use ipl\Web\Url; +use ipl\Web\Widget\Icon; +use ipl\Web\Widget\StateBadge; +use Throwable; + +class ConfigMenu extends BaseHtmlElement +{ + const STATE_OK = 'ok'; + const STATE_CRITICAL = 'critical'; + const STATE_WARNING = 'warning'; + const STATE_PENDING = 'pending'; + const STATE_UNKNOWN = 'unknown'; + + protected $tag = 'ul'; + + protected $defaultAttributes = ['class' => 'nav']; + + protected $children; + + protected $selected; + + protected $state; + + public function __construct() + { + $this->children = [ + 'system' => [ + 'title' => t('System'), + 'items' => [ + 'about' => [ + 'label' => t('About'), + 'url' => 'about' + ], + 'health' => [ + 'label' => t('Health'), + 'url' => 'health', + ], + 'migrations' => [ + 'label' => t('Migrations'), + 'url' => 'migrations', + ], + 'announcements' => [ + 'label' => t('Announcements'), + 'url' => 'announcements' + ], + 'sessions' => [ + 'label' => t('User Sessions'), + 'permission' => 'application/sessions', + 'url' => 'manage-user-devices' + ] + ] + ], + 'configuration' => [ + 'title' => t('Configuration'), + 'permission' => 'config/*', + 'items' => [ + 'application' => [ + 'label' => t('Application'), + 'url' => 'config/general' + ], + 'authentication' => [ + 'label' => t('Access Control'), + 'permission' => 'config/access-control/*', + 'url' => 'role/list' + ], + 'navigation' => [ + 'label' => t('Shared Navigation'), + 'permission' => 'config/navigation', + 'url' => 'navigation/shared' + ], + 'modules' => [ + 'label' => t('Modules'), + 'permission' => 'config/modules', + 'url' => 'config/modules' + ] + ] + ], + 'logout' => [ + 'items' => [ + 'logout' => [ + 'label' => t('Logout'), + 'atts' => [ + 'target' => '_self', + 'class' => 'nav-item-logout' + ], + 'url' => 'authentication/logout' + ] + ] + ] + ]; + + if (Logger::writesToFile()) { + $this->children['system']['items']['application_log'] = [ + 'label' => t('Application Log'), + 'url' => 'list/applicationlog', + 'permission' => 'application/log' + ]; + } + } + + protected function assembleUserMenuItem(BaseHtmlElement $userMenuItem) + { + $username = Auth::getInstance()->getUser()->getUsername(); + + $userMenuItem->add( + new HtmlElement( + 'a', + Attributes::create(['href' => Url::fromPath('account')]), + new HtmlElement( + 'i', + Attributes::create(['class' => 'user-ball']), + Text::create($username[0]) + ), + Text::create($username) + ) + ); + + if (Icinga::app()->getRequest()->getUrl()->matches('account')) { + $userMenuItem->addAttributes(['class' => 'selected active']); + } + } + + protected function assembleCogMenuItem($cogMenuItem) + { + $cogMenuItem->add([ + HtmlElement::create( + 'button', + null, + [ + new Icon('cog'), + $this->createHealthBadge() ?? $this->createMigrationBadge(), + ] + ), + $this->createLevel2Menu() + ]); + } + + protected function assembleLevel2Nav(BaseHtmlElement $level2Nav) + { + $navContent = HtmlElement::create('div', ['class' => 'flyout-content']); + foreach ($this->children as $c) { + if (isset($c['permission']) && ! Auth::getInstance()->hasPermission($c['permission'])) { + continue; + } + + if (isset($c['title'])) { + $navContent->add(HtmlElement::create( + 'h3', + null, + $c['title'] + )); + } + + $ul = HtmlElement::create('ul', ['class' => 'nav']); + foreach ($c['items'] as $key => $item) { + $ul->add($this->createLevel2MenuItem($item, $key)); + } + + $navContent->add($ul); + } + + $level2Nav->add($navContent); + } + + protected function getHealthCount() + { + $count = 0; + $worstState = null; + foreach (HealthHook::collectHealthData()->select() as $result) { + if ($worstState === null || $result->state > $worstState) { + $worstState = $result->state; + $count = 1; + } elseif ($worstState === $result->state) { + $count++; + } + } + + switch ($worstState) { + case HealthHook::STATE_OK: + $count = 0; + break; + case HealthHook::STATE_WARNING: + $this->state = self::STATE_WARNING; + break; + case HealthHook::STATE_CRITICAL: + $this->state = self::STATE_CRITICAL; + break; + case HealthHook::STATE_UNKNOWN: + $this->state = self::STATE_UNKNOWN; + break; + } + + return $count; + } + + protected function isSelectedItem($item) + { + if ($item !== null && Icinga::app()->getRequest()->getUrl()->matches($item['url'])) { + $this->selected = $item; + return true; + } + + return false; + } + + protected function createHealthBadge(): ?StateBadge + { + $stateBadge = null; + if ($this->getHealthCount() > 0) { + $stateBadge = new StateBadge($this->getHealthCount(), $this->state); + $stateBadge->addAttributes(['class' => 'disabled']); + } + + return $stateBadge; + } + + protected function createMigrationBadge(): ?StateBadge + { + try { + $mm = MigrationManager::instance(); + $count = $mm->count(); + } catch (Throwable $e) { + Logger::error('Failed to load pending migrations: %s', $e); + $count = 0; + } + + $stateBadge = null; + if ($count > 0) { + $stateBadge = new StateBadge($count, BadgeNavigationItemRenderer::STATE_PENDING); + $stateBadge->addAttributes(['class' => 'disabled']); + } + + return $stateBadge; + } + + protected function createLevel2Menu() + { + $level2Nav = HtmlElement::create( + 'div', + Attributes::create(['class' => 'nav-level-1 flyout']) + ); + + $this->assembleLevel2Nav($level2Nav); + + return $level2Nav; + } + + protected function createLevel2MenuItem($item, $key) + { + if (isset($item['permission']) && ! Auth::getInstance()->hasPermission($item['permission'])) { + return null; + } + + $stateBadge = null; + $class = null; + if ($key === 'health') { + $class = 'badge-nav-item'; + $stateBadge = $this->createHealthBadge(); + } elseif ($key === 'migrations') { + $class = 'badge-nav-item'; + $stateBadge = $this->createMigrationBadge(); + } + + $li = HtmlElement::create( + 'li', + $item['atts'] ?? [], + [ + HtmlElement::create( + 'a', + Attributes::create(['href' => Url::fromPath($item['url'])]), + [ + $item['label'], + $stateBadge ?? '' + ] + ), + ] + ); + $li->addAttributes(['class' => $class]); + + if ($this->isSelectedItem($item)) { + $li->addAttributes(['class' => 'selected']); + } + + return $li; + } + + protected function createUserMenuItem() + { + $userMenuItem = HtmlElement::create('li', ['class' => 'user-nav-item']); + + $this->assembleUserMenuItem($userMenuItem); + + return $userMenuItem; + } + + protected function createCogMenuItem() + { + $cogMenuItem = HtmlElement::create('li', ['class' => 'config-nav-item']); + + $this->assembleCogMenuItem($cogMenuItem); + + return $cogMenuItem; + } + + protected function assemble() + { + $this->add([ + $this->createUserMenuItem(), + $this->createCogMenuItem() + ]); + } +} diff --git a/library/Icinga/Web/Navigation/DashboardPane.php b/library/Icinga/Web/Navigation/DashboardPane.php new file mode 100644 index 0000000..71b3215 --- /dev/null +++ b/library/Icinga/Web/Navigation/DashboardPane.php @@ -0,0 +1,84 @@ +<?php +/* Icinga Web 2 | (c) 2015 Icinga Development Team | GPLv2+ */ + +namespace Icinga\Web\Navigation; + +use Icinga\Web\Url; + +/** + * A dashboard pane + */ +class DashboardPane extends NavigationItem +{ + /** + * This pane's dashlets + * + * @var array + */ + protected $dashlets; + + protected $disabled; + + /** + * Set this pane's dashlets + * + * @param array $dashlets + * + * @return $this + */ + public function setDashlets(array $dashlets) + { + $this->dashlets = $dashlets; + return $this; + } + + /** + * Return this pane's dashlets + * + * @param bool $ordered Whether to order the dashlets first + * + * @return array + */ + public function getDashlets($ordered = true) + { + if ($this->dashlets === null) { + return array(); + } + + if ($ordered) { + $dashlets = $this->dashlets; + ksort($dashlets); + return $dashlets; + } + + return $this->dashlets; + } + + /** + * {@inheritdoc} + */ + public function init() + { + $this->setUrl(Url::fromPath('dashboard', array('pane' => $this->getName()))); + } + + /** + * Set disabled state for pane + * + * @param bool $disabled + */ + public function setDisabled($disabled = true) + { + $this->disabled = (bool) $disabled; + } + + /** + * Get disabled state for pane + * + * @return bool + */ + public function getDisabled() + { + return $this->disabled; + } +} diff --git a/library/Icinga/Web/Navigation/DropdownItem.php b/library/Icinga/Web/Navigation/DropdownItem.php new file mode 100644 index 0000000..2342b96 --- /dev/null +++ b/library/Icinga/Web/Navigation/DropdownItem.php @@ -0,0 +1,20 @@ +<?php +/* Icinga Web 2 | (c) 2015 Icinga Development Team | GPLv2+ */ + +namespace Icinga\Web\Navigation; + +/** + * Dropdown navigation item + * + * @see \Icinga\Web\Navigation\Navigation For a usage example. + */ +class DropdownItem extends NavigationItem +{ + /** + * {@inheritdoc} + */ + public function init() + { + $this->children->setLayout(Navigation::LAYOUT_DROPDOWN); + } +} diff --git a/library/Icinga/Web/Navigation/Navigation.php b/library/Icinga/Web/Navigation/Navigation.php new file mode 100644 index 0000000..4343c3c --- /dev/null +++ b/library/Icinga/Web/Navigation/Navigation.php @@ -0,0 +1,572 @@ +<?php +/* Icinga Web 2 | (c) 2015 Icinga Development Team | GPLv2+ */ + +namespace Icinga\Web\Navigation; + +use ArrayAccess; +use ArrayIterator; +use Exception; +use Countable; +use InvalidArgumentException; +use IteratorAggregate; +use Traversable; +use Icinga\Application\Icinga; +use Icinga\Application\Logger; +use Icinga\Authentication\Auth; +use Icinga\Data\ConfigObject; +use Icinga\Exception\ConfigurationError; +use Icinga\Exception\IcingaException; +use Icinga\Exception\ProgrammingError; +use Icinga\Util\StringHelper; +use Icinga\Web\Navigation\Renderer\RecursiveNavigationRenderer; + +/** + * Container for navigation items + */ +class Navigation implements ArrayAccess, Countable, IteratorAggregate +{ + /** + * The class namespace where to locate navigation type classes + * + * @var string + */ + const NAVIGATION_NS = 'Web\\Navigation'; + + /** + * Flag for dropdown layout + * + * @var int + */ + const LAYOUT_DROPDOWN = 1; + + /** + * Flag for tabs layout + * + * @var int + */ + const LAYOUT_TABS = 2; + + /** + * Known navigation types + * + * @var array + */ + protected static $types; + + /** + * This navigation's items + * + * @var NavigationItem[] + */ + protected $items = array(); + + /** + * This navigation's layout + * + * @var int + */ + protected $layout; + + public function offsetExists($offset): bool + { + return isset($this->items[$offset]); + } + + public function offsetGet($offset): ?NavigationItem + { + return $this->items[$offset] ?? null; + } + + public function offsetSet($offset, $value): void + { + $this->items[$offset] = $value; + } + + public function offsetUnset($offset): void + { + unset($this->items[$offset]); + } + + public function count(): int + { + return count($this->items); + } + + public function getIterator(): Traversable + { + $this->order(); + return new ArrayIterator($this->items); + } + + /** + * Create and return a new navigation item for the given configuration + * + * @param string $name + * @param array|ConfigObject $properties + * + * @return NavigationItem + * + * @throws InvalidArgumentException If the $properties argument is neither an array nor a ConfigObject + */ + public function createItem($name, $properties) + { + if ($properties instanceof ConfigObject) { + $properties = $properties->toArray(); + } elseif (! is_array($properties)) { + throw new InvalidArgumentException('Argument $properties must be of type array or ConfigObject'); + } + + $itemType = isset($properties['type']) ? StringHelper::cname($properties['type'], '-') : 'NavigationItem'; + if (! empty(static::$types) && isset(static::$types[$itemType])) { + return new static::$types[$itemType]($name, $properties); + } + + $item = null; + $classPath = null; + foreach (Icinga::app()->getModuleManager()->getLoadedModules() as $module) { + $classPath = 'Icinga\\Module\\' + . ucfirst($module->getName()) + . '\\' + . static::NAVIGATION_NS + . '\\' + . $itemType; + if (class_exists($classPath)) { + $item = new $classPath($name, $properties); + break; + } + } + + if ($item === null) { + $classPath = 'Icinga\\' . static::NAVIGATION_NS . '\\' . $itemType; + if (class_exists($classPath)) { + $item = new $classPath($name, $properties); + } + } + + if ($item === null) { + if ($itemType !== 'MenuItem') { + Logger::debug( + 'Failed to find custom navigation item class %s for item %s. Using base class NavigationItem now', + $itemType, + $name + ); + } + + $item = new NavigationItem($name, $properties); + static::$types[$itemType] = 'Icinga\\Web\\Navigation\\NavigationItem'; + } elseif (! $item instanceof NavigationItem) { + throw new ProgrammingError('Class %s must inherit from NavigationItem', $classPath); + } else { + static::$types[$itemType] = $classPath; + } + + return $item; + } + + /** + * Add a navigation item + * + * If you do not pass an instance of NavigationItem, this will only add the item + * if it does not require a permission or the current user has the permission. + * + * @param string|NavigationItem $name The name of the item or an instance of NavigationItem + * @param array $properties The properties of the item to add (Ignored if $name is not a string) + * + * @return bool Whether the item was added or not + * + * @throws InvalidArgumentException In case $name is neither a string nor an instance of NavigationItem + */ + public function addItem($name, array $properties = array()) + { + if (is_string($name)) { + if (isset($properties['permission'])) { + if (! Auth::getInstance()->hasPermission($properties['permission'])) { + return false; + } + + unset($properties['permission']); + } + + $item = $this->createItem($name, $properties); + } elseif (! $name instanceof NavigationItem) { + throw new InvalidArgumentException('Argument $name must be of type string or NavigationItem'); + } else { + $item = $name; + } + + $this->items[$item->getName()] = $item; + return true; + } + + /** + * Return the item with the given name + * + * @param string $name + * @param mixed $default + * + * @return NavigationItem|mixed + */ + public function getItem($name, $default = null) + { + return isset($this->items[$name]) ? $this->items[$name] : $default; + } + + /** + * Return the currently active item or the first one if none is active + * + * @return NavigationItem + */ + public function getActiveItem() + { + foreach ($this->items as $item) { + if ($item->getActive()) { + return $item; + } + } + + $firstItem = reset($this->items); + return $firstItem ? $firstItem->setActive() : null; + } + + /** + * Return this navigation's items + * + * @return array + */ + public function getItems() + { + return $this->items; + } + + /** + * Return whether this navigation is empty + * + * @return bool + */ + public function isEmpty() + { + return empty($this->items); + } + + /** + * Return whether this navigation has any renderable items + * + * @return bool + */ + public function hasRenderableItems() + { + foreach ($this->getItems() as $item) { + if ($item->shouldRender()) { + return true; + } + } + + return false; + } + + /** + * Return this navigation's layout + * + * @return int + */ + public function getLayout() + { + return $this->layout; + } + + /** + * Set this navigation's layout + * + * @param int $layout + * + * @return $this + */ + public function setLayout($layout) + { + $this->layout = (int) $layout; + return $this; + } + + /** + * Create and return the renderer for this navigation + * + * @return RecursiveNavigationRenderer + */ + public function getRenderer() + { + return new RecursiveNavigationRenderer($this); + } + + /** + * Return this navigation rendered to HTML + * + * @return string + */ + public function render() + { + return $this->getRenderer()->render(); + } + + /** + * Order this navigation's items + * + * @return $this + */ + public function order() + { + uasort($this->items, array($this, 'compareItems')); + foreach ($this->items as $item) { + if ($item->hasChildren()) { + $item->getChildren()->order(); + } + } + + return $this; + } + + /** + * Return whether the first item is less than, more than or equal to the second one + * + * @param NavigationItem $a + * @param NavigationItem $b + * + * @return int + */ + protected function compareItems(NavigationItem $a, NavigationItem $b) + { + if ($a->getPriority() === $b->getPriority()) { + return strcasecmp($a->getLabel(), $b->getLabel()); + } + + return $a->getPriority() > $b->getPriority() ? 1 : -1; + } + + /** + * Try to find and return a item with the given or a similar name + * + * @param string $name + * + * @return ?NavigationItem + */ + public function findItem($name) + { + $item = $this->getItem($name); + if ($item !== null) { + return $item; + } + + $loweredName = strtolower($name); + foreach ($this->getItems() as $item) { + if (strtolower($item->getName()) === $loweredName) { + return $item; + } + } + } + + /** + * Merge this navigation with the given one + * + * Any duplicate items of this navigation will be overwritten by the given navigation's items. + * + * @param Navigation $navigation + * + * @return $this + */ + public function merge(Navigation $navigation) + { + foreach ($navigation as $item) { + /** @var $item NavigationItem */ + if (($existingItem = $this->findItem($item->getName())) !== null) { + if ($existingItem->conflictsWith($item)) { + $name = $item->getName(); + do { + if (preg_match('~_(\d+)$~', $name, $matches)) { + $name = preg_replace('~_\d+$~', (int) $matches[1] + 1, $name); + } else { + $name .= '_2'; + } + } while ($this->getItem($name) !== null); + + $this->addItem($item->setName($name)); + } else { + $existingItem->merge($item); + } + } else { + $this->addItem($item); + } + } + + return $this; + } + + /** + * Extend this navigation set with all additional items of the given type + * + * This will fetch navigation items from the following sources: + * * User Shareables + * * User Preferences + * * Modules + * Any existing entry will be overwritten by one that is coming later in order. + * + * @param string $type + * + * @return $this + */ + public function load($type) + { + $user = Auth::getInstance()->getUser(); + if ($type !== 'dashboard-pane') { + // Shareables + $this->merge(Icinga::app()->getSharedNavigation($type)); + + // User Preferences + $this->merge($user->getNavigation($type)); + } + + // Modules + $moduleManager = Icinga::app()->getModuleManager(); + foreach ($moduleManager->getLoadedModules() as $module) { + if ($user->can($moduleManager::MODULE_PERMISSION_NS . $module->getName())) { + if ($type === 'menu-item') { + $this->merge($module->getMenu()); + } elseif ($type === 'dashboard-pane') { + $this->merge($module->getDashboard()); + } + } + } + + return $this; + } + + /** + * Return the global navigation item type configuration + * + * @return array + */ + public static function getItemTypeConfiguration() + { + $defaultItemTypes = array( + 'menu-item' => array( + 'label' => t('Menu Entry'), + 'config' => 'menu' + )/*, // Disabled, until it is able to fully replace the old implementation + 'dashlet' => array( + 'label' => 'Dashlet', + 'config' => 'dashboard' + )*/ + ); + + $moduleItemTypes = array(); + $moduleManager = Icinga::app()->getModuleManager(); + foreach ($moduleManager->getLoadedModules() as $module) { + if (Auth::getInstance()->hasPermission($moduleManager::MODULE_PERMISSION_NS . $module->getName())) { + foreach ($module->getNavigationItems() as $type => $options) { + if (! isset($moduleItemTypes[$type])) { + $moduleItemTypes[$type] = $options; + } + } + } + } + + return array_merge($defaultItemTypes, $moduleItemTypes); + } + + /** + * Create and return a new set of navigation items for the given configuration + * + * Note that this is supposed to be utilized for one dimensional structures + * only. Multi dimensional structures can be processed by fromArray(). + * + * @param Traversable|array $config + * + * @return Navigation + * + * @throws InvalidArgumentException In case the given configuration is invalid + * @throws ConfigurationError In case a referenced parent does not exist + */ + public static function fromConfig($config) + { + if (! is_array($config) && !$config instanceof Traversable) { + throw new InvalidArgumentException('Argument $config must be an array or a instance of Traversable'); + } + + $flattened = $orphans = $topLevel = array(); + foreach ($config as $sectionName => $sectionConfig) { + $parentName = $sectionConfig->parent; + unset($sectionConfig->parent); + + if (! $parentName) { + $topLevel[$sectionName] = $sectionConfig->toArray(); + $flattened[$sectionName] = & $topLevel[$sectionName]; + } elseif (isset($flattened[$parentName])) { + $flattened[$parentName]['children'][$sectionName] = $sectionConfig->toArray(); + $flattened[$sectionName] = & $flattened[$parentName]['children'][$sectionName]; + } else { + $orphans[$parentName][$sectionName] = $sectionConfig->toArray(); + $flattened[$sectionName] = & $orphans[$parentName][$sectionName]; + } + } + + do { + $match = false; + foreach ($orphans as $parentName => $children) { + if (isset($flattened[$parentName])) { + if (isset($flattened[$parentName]['children'])) { + $flattened[$parentName]['children'] = array_merge( + $flattened[$parentName]['children'], + $children + ); + } else { + $flattened[$parentName]['children'] = $children; + } + + unset($orphans[$parentName]); + $match = true; + } + } + } while ($match && !empty($orphans)); + + if (! empty($orphans)) { + throw new ConfigurationError( + t( + 'Failed to fully parse navigation configuration. Ensure that' + . ' all referenced parents are existing navigation items: %s' + ), + join(', ', array_keys($orphans)) + ); + } + + return static::fromArray($topLevel); + } + + /** + * Create and return a new set of navigation items for the given array + * + * @param array $array + * + * @return Navigation + */ + public static function fromArray(array $array) + { + $navigation = new static(); + foreach ($array as $name => $properties) { + $navigation->addItem((string) $name, $properties); + } + + return $navigation; + } + + /** + * Return this navigation rendered to HTML + * + * @return string + */ + public function __toString() + { + try { + return $this->render(); + } catch (Exception $e) { + return IcingaException::describe($e); + } + } +} diff --git a/library/Icinga/Web/Navigation/NavigationItem.php b/library/Icinga/Web/Navigation/NavigationItem.php new file mode 100644 index 0000000..8aaf7b8 --- /dev/null +++ b/library/Icinga/Web/Navigation/NavigationItem.php @@ -0,0 +1,948 @@ +<?php +/* Icinga Web 2 | (c) 2015 Icinga Development Team | GPLv2+ */ + +namespace Icinga\Web\Navigation; + +use Exception; +use Icinga\Authentication\Auth; +use InvalidArgumentException; +use IteratorAggregate; +use Icinga\Application\Icinga; +use Icinga\Application\Logger; +use Icinga\Exception\IcingaException; +use Icinga\Exception\ProgrammingError; +use Icinga\Web\Navigation\Renderer\NavigationItemRenderer; +use Icinga\Web\Url; +use Traversable; + +/** + * A navigation item + */ +class NavigationItem implements IteratorAggregate +{ + /** + * Alternative markup element for items without a url + * + * @var string + */ + const LINK_ALTERNATIVE = 'span'; + + /** + * The class namespace where to locate navigation type renderer classes + */ + const RENDERER_NS = 'Web\\Navigation\\Renderer'; + + /** + * Whether this item is active + * + * @var bool + */ + protected $active; + + /** + * Whether this item is selected + * + * @var bool + */ + protected $selected; + + /** + * The CSS class used for the outer li element + * + * @var string + */ + protected $cssClass; + + /** + * This item's priority + * + * The priority defines when the item is rendered in relation to its parent's childs. + * + * @var int + */ + protected $priority; + + /** + * The attributes of this item's element + * + * @var array + */ + protected $attributes; + + /** + * This item's children + * + * @var Navigation + */ + protected $children; + + /** + * This item's icon + * + * @var string + */ + protected $icon; + + /** + * This item's name + * + * @var string + */ + protected $name; + + /** + * This item's label + * + * @var string + */ + protected $label; + + /** + * The item's description + * + * @var string + */ + protected $description; + + /** + * This item's parent + * + * @var NavigationItem + */ + protected $parent; + + /** + * This item's url + * + * @var Url + */ + protected $url; + + /** + * This item's url target + * + * @var string + */ + protected $target; + + /** + * Additional parameters for this item's url + * + * @var array + */ + protected $urlParameters; + + /** + * This item's renderer + * + * @var NavigationItemRenderer + */ + protected $renderer; + + /** + * Whether to render this item + * + * @var bool + */ + protected $render; + + /** + * Create a new NavigationItem + * + * @param string $name + * @param array $properties + */ + public function __construct($name, array $properties = null) + { + $this->setName($name); + $this->children = new Navigation(); + + if (! empty($properties)) { + $this->setProperties($properties); + } + + $this->init(); + } + + /** + * Initialize this NavigationItem + */ + public function init() + { + } + + /** + * @return Navigation + */ + public function getIterator(): Traversable + { + return $this->getChildren(); + } + + /** + * Return whether this item is active + * + * @return bool + */ + public function getActive() + { + if ($this->active === null) { + $this->active = false; + if ($this->getUrl() !== null && Icinga::app()->getRequest()->getUrl()->matches($this->getUrl())) { + $this->setActive(); + } elseif ($this->hasChildren()) { + foreach ($this->getChildren() as $item) { + /** @var NavigationItem $item */ + if ($item->getActive()) { + // Do nothing, a true active state is automatically passed to all parents + } + } + } + } + + return $this->active; + } + + /** + * Set whether this item is active + * + * If it's active and has a parent, the parent gets activated as well. + * + * @param bool $active + * + * @return $this + */ + public function setActive($active = true) + { + $this->active = (bool) $active; + if ($this->active && $this->getParent() !== null) { + $this->getParent()->setActive(); + } + + return $this; + } + + /** + * Return whether this item is selected + * + * @return bool + */ + public function getSelected() + { + if ($this->selected === null) { + $this->active = false; + if ($this->getUrl() !== null && Icinga::app()->getRequest()->getUrl()->matches($this->getUrl())) { + $this->setSelected(); + } + } + + return $this->selected; + } + + /** + * Set whether this item is active + * + * If it's active and has a parent, the parent gets activated as well. + * + * @param bool $selected + * + * @return $this + */ + public function setSelected($selected = true) + { + $this->selected = (bool) $selected; + + return $this; + } + + /** + * Get the CSS class used for the outer li element + * + * @return string + */ + public function getCssClass() + { + return $this->cssClass; + } + + /** + * Set the CSS class to use for the outer li element + * + * @param string $class + * + * @return $this + */ + public function setCssClass($class) + { + $this->cssClass = (string) $class; + return $this; + } + + /** + * Return this item's priority + * + * @return int + */ + public function getPriority() + { + return $this->priority !== null ? $this->priority : 100; + } + + /** + * Set this item's priority + * + * @param int $priority + * + * @return $this + */ + public function setPriority($priority) + { + $this->priority = (int) $priority; + return $this; + } + + /** + * Return the value of the given element attribute + * + * @param string $name + * @param mixed $default + * + * @return mixed + */ + public function getAttribute($name, $default = null) + { + $attributes = $this->getAttributes(); + return array_key_exists($name, $attributes) ? $attributes[$name] : $default; + } + + /** + * Set the value of the given element attribute + * + * @param string $name + * @param mixed $value + * + * @return $this + */ + public function setAttribute($name, $value) + { + $this->attributes[$name] = $value; + return $this; + } + + /** + * Return the attributes of this item's element + * + * @return array + */ + public function getAttributes() + { + return $this->attributes ?: array(); + } + + /** + * Set the attributes of this item's element + * + * @param array $attributes + * + * @return $this + */ + public function setAttributes(array $attributes) + { + $this->attributes = $attributes; + return $this; + } + + /** + * Add a child to this item + * + * If the child is active this item gets activated as well. + * + * @param NavigationItem $child + * + * @return $this + */ + public function addChild(NavigationItem $child) + { + $this->getChildren()->addItem($child->setParent($this)); + if ($child->getActive()) { + $this->setActive(); + } + + return $this; + } + + /** + * Return this item's children + * + * @return Navigation + */ + public function getChildren() + { + return $this->children; + } + + /** + * Return whether this item has any children + * + * @return bool + */ + public function hasChildren() + { + return ! $this->getChildren()->isEmpty(); + } + + /** + * Set this item's children + * + * @param array|Navigation $children + * + * @return $this + */ + public function setChildren($children) + { + if (is_array($children)) { + $children = Navigation::fromArray($children); + } elseif (! $children instanceof Navigation) { + throw new InvalidArgumentException('Argument $children must be of type array or Navigation'); + } + + foreach ($children as $item) { + $item->setParent($this); + } + + $this->children = $children; + return $this; + } + + /** + * Return this item's icon + * + * @return string + */ + public function getIcon() + { + return $this->icon; + } + + /** + * Set this item's icon + * + * @param string $icon + * + * @return $this + */ + public function setIcon($icon) + { + $this->icon = $icon; + return $this; + } + + /** + * Return this item's name escaped with only ASCII chars and/or digits + * + * @return string + */ + protected function getEscapedName() + { + return preg_replace('~[^a-zA-Z0-9]~', '_', $this->getName()); + } + + /** + * Return a unique version of this item's name + * + * @return string + */ + public function getUniqueName() + { + if ($this->getParent() === null) { + return 'navigation-' . $this->getEscapedName(); + } + + return $this->getParent()->getUniqueName() . '-' . $this->getEscapedName(); + } + + /** + * Return this item's name + * + * @return string + */ + public function getName() + { + return $this->name; + } + + /** + * Set this item's name + * + * @param string $name + * + * @return $this + */ + public function setName($name) + { + $this->name = $name; + return $this; + } + + /** + * Set this item's parent + * + * @param NavigationItem $parent + * + * @return $this + */ + public function setParent(NavigationItem $parent) + { + $this->parent = $parent; + return $this; + } + + /** + * Return this item's parent + * + * @return NavigationItem + */ + public function getParent() + { + return $this->parent; + } + + /** + * Return this item's label + * + * @return string + */ + public function getLabel() + { + return $this->label !== null ? $this->label : $this->getName(); + } + + /** + * Set this item's label + * + * @param string $label + * + * @return $this + */ + public function setLabel($label) + { + $this->label = $label; + return $this; + } + + /** + * Get the item's description + * + * @return string + */ + public function getDescription() + { + return $this->description; + } + + /** + * Set the item's description + * + * @param string $description + * + * @return $this + */ + public function setDescription($description) + { + $this->description = $description; + + return $this; + } + + /** + * Set this item's url target + * + * @param string $target + * + * @return $this + */ + public function setTarget($target) + { + $this->target = $target; + return $this; + } + + /** + * Return this item's url target + * + * @return string + */ + public function getTarget() + { + return $this->target; + } + + /** + * Return this item's url + * + * @return Url + */ + public function getUrl() + { + if ($this->url === null && $this->hasChildren()) { + $this->setUrl(Url::fromPath('navigation/dashboard', array('name' => strtolower($this->getName())))); + } + + return $this->url; + } + + /** + * Set this item's url + * + * @param Url|string $url + * + * @return $this + * + * @throws InvalidArgumentException If the given url is neither of type + */ + public function setUrl($url) + { + if (is_string($url)) { + $url = Url::fromPath($this->resolveMacros($url)); + } elseif ($url instanceof Url) { + $url = Url::fromPath($this->resolveMacros($url->getAbsoluteUrl())); + } else { + throw new InvalidArgumentException('Argument $url must be of type string or Url'); + } + + $this->url = $url; + + return $this; + } + + /** + * Return the value of the given url parameter + * + * @param string $name + * @param mixed $default + * + * @return mixed + */ + public function getUrlParameter($name, $default = null) + { + $parameters = $this->getUrlParameters(); + return isset($parameters[$name]) ? $parameters[$name] : $default; + } + + /** + * Set the value of the given url parameter + * + * @param string $name + * @param mixed $value + * + * @return $this + */ + public function setUrlParameter($name, $value) + { + $this->urlParameters[$name] = $value; + return $this; + } + + /** + * Return all additional parameters for this item's url + * + * @return array + */ + public function getUrlParameters() + { + return $this->urlParameters ?: array(); + } + + /** + * Set additional parameters for this item's url + * + * @param array $urlParameters + * + * @return $this + */ + public function setUrlParameters(array $urlParameters) + { + $this->urlParameters = $urlParameters; + return $this; + } + + /** + * Set this item's properties + * + * Unknown properties (no matching setter) are considered as element attributes. + * + * @param array $properties + * + * @return $this + */ + public function setProperties(array $properties) + { + foreach ($properties as $name => $value) { + $setter = 'set' . ucfirst($name); + if (method_exists($this, $setter)) { + $this->$setter($value); + } else { + $this->setAttribute($name, $value); + } + } + + return $this; + } + + /** + * Merge this item with the given one + * + * @param NavigationItem $item + * + * @return $this + */ + public function merge(NavigationItem $item) + { + if ($this->conflictsWith($item)) { + throw new ProgrammingError('Cannot merge, conflict detected.'); + } + + if ($this->priority === null) { + $priority = $item->getPriority(); + if ($priority !== 100) { + $this->setPriority($priority); + } + } + + if (! $this->getIcon()) { + $this->setIcon($item->getIcon()); + } + + if ($this->getLabel() === $this->getName() && $item->getLabel() !== $item->getName()) { + $this->setLabel($item->getLabel()); + } + + if ($this->target === null && ($target = $item->getTarget()) !== null) { + $this->setTarget($target); + } + + if ($this->renderer === null) { + $renderer = $item->getRenderer(); + if (get_class($renderer) !== 'NavigationItemRenderer') { + $this->setRenderer($renderer); + } + } + + foreach ($item->getAttributes() as $name => $value) { + $this->setAttribute($name, $value); + } + + foreach ($item->getUrlParameters() as $name => $value) { + $this->setUrlParameter($name, $value); + } + + if ($item->hasChildren()) { + $this->getChildren()->merge($item->getChildren()); + } + + return $this; + } + + /** + * Return whether it's possible to merge this item with the given one + * + * @param NavigationItem $item + * + * @return bool + */ + public function conflictsWith(NavigationItem $item) + { + if (! $item instanceof $this) { + return true; + } + + if ($this->getUrl() === null || $item->getUrl() === null) { + return false; + } + + return !$this->getUrl()->matches($item->getUrl()); + } + + /** + * Create and return the given renderer + * + * @param string|array $name + * + * @return NavigationItemRenderer + */ + protected function createRenderer($name) + { + if (is_array($name)) { + $options = array_splice($name, 1); + $name = $name[0]; + } else { + $options = array(); + } + + $renderer = null; + $classPath = null; + foreach (Icinga::app()->getModuleManager()->getLoadedModules() as $module) { + $classPath = 'Icinga\\Module\\' . ucfirst($module->getName()) . '\\' . static::RENDERER_NS . '\\' . $name; + if (class_exists($classPath)) { + $renderer = new $classPath($options); + break; + } + } + + if ($renderer === null) { + $classPath = 'Icinga\\' . static::RENDERER_NS . '\\' . $name; + if (class_exists($classPath)) { + $renderer = new $classPath($options); + } + } + + if ($renderer === null) { + throw new ProgrammingError( + 'Cannot find renderer "%s" for navigation item "%s"', + $name, + $this->getName() + ); + } elseif (! $renderer instanceof NavigationItemRenderer) { + throw new ProgrammingError('Class %s must inherit from NavigationItemRenderer', $classPath); + } + + return $renderer; + } + + /** + * Set this item's renderer + * + * @param string|array|NavigationItemRenderer $renderer + * + * @return $this + * + * @throws InvalidArgumentException If the $renderer argument is neither a string nor a NavigationItemRenderer + */ + public function setRenderer($renderer) + { + if (is_string($renderer) || is_array($renderer)) { + $renderer = $this->createRenderer($renderer); + } elseif (! $renderer instanceof NavigationItemRenderer) { + throw new InvalidArgumentException( + 'Argument $renderer must be of type string, array or NavigationItemRenderer' + ); + } + + $this->renderer = $renderer; + return $this; + } + + /** + * Return this item's renderer + * + * @return NavigationItemRenderer + */ + public function getRenderer() + { + if ($this->renderer === null) { + $this->setRenderer('NavigationItemRenderer'); + } + + return $this->renderer; + } + + /** + * Set whether this item should be rendered + * + * @param bool $state + * + * @return $this + */ + public function setRender($state = true) + { + $this->render = (bool) $state; + return $this; + } + + /** + * Return whether this item should be rendered + * + * @return bool + */ + public function getRender() + { + if ($this->render === null) { + return $this->getUrl() !== null; + } + + return $this->render; + } + + /** + * Return whether this item should be rendered + * + * Alias for NavigationItem::getRender(). + * + * @return bool + */ + public function shouldRender() + { + return $this->getRender(); + } + + /** + * Return this item rendered to HTML + * + * @return string + */ + public function render() + { + try { + return $this->getRenderer()->setItem($this)->render(); + } catch (Exception $e) { + Logger::error( + 'Could not invoke custom navigation item renderer. %s in %s:%d with message: %s', + get_class($e), + $e->getFile(), + $e->getLine(), + $e->getMessage() + ); + + $renderer = new NavigationItemRenderer(); + return $renderer->render($this); + } + } + + /** + * Return this item rendered to HTML + * + * @return string + */ + public function __toString() + { + try { + return $this->render(); + } catch (Exception $e) { + return IcingaException::describe($e); + } + } + + /** + * Resolve all macros in the given URL + * + * @param string $url + * + * @return string + */ + protected function resolveMacros($url) + { + if (strpos($url, '$') === false) { + return $url; + } + + $macros = []; + if (Auth::getInstance()->isAuthenticated()) { + $macros['$user.local_name$'] = Auth::getInstance()->getUser()->getLocalUsername(); + } + if (! empty($macros)) { + $url = str_replace(array_keys($macros), array_values($macros), $url); + } + + return $url; + } +} diff --git a/library/Icinga/Web/Navigation/Renderer/BadgeNavigationItemRenderer.php b/library/Icinga/Web/Navigation/Renderer/BadgeNavigationItemRenderer.php new file mode 100644 index 0000000..8510f70 --- /dev/null +++ b/library/Icinga/Web/Navigation/Renderer/BadgeNavigationItemRenderer.php @@ -0,0 +1,139 @@ +<?php +/* Icinga Web 2 | (c) 2015 Icinga Development Team | GPLv2+ */ + +namespace Icinga\Web\Navigation\Renderer; + +use Icinga\Web\Navigation\NavigationItem; + +/** + * Abstract base class for a NavigationItem with a status badge + */ +abstract class BadgeNavigationItemRenderer extends NavigationItemRenderer +{ + const STATE_OK = 'ok'; + const STATE_CRITICAL = 'critical'; + const STATE_WARNING = 'warning'; + const STATE_PENDING = 'pending'; + const STATE_UNKNOWN = 'unknown'; + + /** + * The tooltip text for the badge + * + * @var string + */ + protected $title; + + /** + * The state identifier being used + * + * The state identifier defines the background color of the badge. + * + * @var string + */ + protected $state; + + /** + * Set the tooltip text for the badge + * + * @param string $title + * + * @return $this + */ + public function setTitle($title) + { + $this->title = $title; + return $this; + } + + /** + * Return the tooltip text for the badge + * + * @return string + */ + public function getTitle() + { + return $this->title; + } + + /** + * Set the state identifier to use + * + * @param string $state + * + * @return $this + */ + public function setState($state) + { + $this->state = $state; + return $this; + } + + /** + * Return the state identifier to use + * + * @return string + */ + public function getState() + { + return $this->state; + } + + /** + * Return the amount of items represented by the badge + * + * @return int + */ + abstract public function getCount(); + + /** + * Render the given navigation item as HTML anchor with a badge + * + * @param NavigationItem $item + * + * @return string + */ + public function render(NavigationItem $item = null) + { + if ($item === null) { + $item = $this->getItem(); + } + + $cssClass = ''; + if ($item->getCssClass() !== null) { + $cssClass = ' ' . $item->getCssClass(); + } + + $item->setCssClass('badge-nav-item' . $cssClass); + $this->setEscapeLabel(false); + $label = $this->view()->escape($item->getLabel()); + $item->setLabel($this->renderBadge() . $label); + $html = parent::render($item); + return $html; + } + + /** + * Render the badge + * + * @return string + */ + protected function renderBadge() + { + if ($count = $this->getCount()) { + if ($count > 1000000) { + $count = round($count, -6) / 1000000 . 'M'; + } elseif ($count > 1000) { + $count = round($count, -3) / 1000 . 'k'; + } + + $view = $this->view(); + return sprintf( + '<span title="%s" class="badge state-%s">%s</span>', + $view->escape($this->getTitle()), + $view->escape($this->getState()), + $count + ); + } + + return ''; + } +} diff --git a/library/Icinga/Web/Navigation/Renderer/HealthNavigationRenderer.php b/library/Icinga/Web/Navigation/Renderer/HealthNavigationRenderer.php new file mode 100644 index 0000000..577895b --- /dev/null +++ b/library/Icinga/Web/Navigation/Renderer/HealthNavigationRenderer.php @@ -0,0 +1,44 @@ +<?php +/* Icinga Web 2 | (c) 2021 Icinga GmbH | GPLv2+ */ + +namespace Icinga\Web\Navigation\Renderer; + +use Icinga\Application\Hook\HealthHook; + +class HealthNavigationRenderer extends BadgeNavigationItemRenderer +{ + public function getCount() + { + $count = 0; + $title = null; + $worstState = null; + foreach (HealthHook::collectHealthData()->select() as $result) { + if ($worstState === null || $result->state > $worstState) { + $worstState = $result->state; + $title = $result->message; + $count = 1; + } elseif ($worstState === $result->state) { + $count++; + } + } + + switch ($worstState) { + case HealthHook::STATE_OK: + $count = 0; + break; + case HealthHook::STATE_WARNING: + $this->state = self::STATE_WARNING; + break; + case HealthHook::STATE_CRITICAL: + $this->state = self::STATE_CRITICAL; + break; + case HealthHook::STATE_UNKNOWN: + $this->state = self::STATE_UNKNOWN; + break; + } + + $this->title = $title; + + return $count; + } +} diff --git a/library/Icinga/Web/Navigation/Renderer/NavigationItemRenderer.php b/library/Icinga/Web/Navigation/Renderer/NavigationItemRenderer.php new file mode 100644 index 0000000..51136ff --- /dev/null +++ b/library/Icinga/Web/Navigation/Renderer/NavigationItemRenderer.php @@ -0,0 +1,235 @@ +<?php +/* Icinga Web 2 | (c) 2015 Icinga Development Team | GPLv2+ */ + +namespace Icinga\Web\Navigation\Renderer; + +use Icinga\Application\Icinga; +use Icinga\Exception\ProgrammingError; +use Icinga\Util\StringHelper; +use Icinga\Web\Navigation\NavigationItem; +use Icinga\Web\Url; +use Icinga\Web\View; + +/** + * NavigationItemRenderer + */ +class NavigationItemRenderer +{ + /** + * View + * + * @var View + */ + protected $view; + + /** + * The item being rendered + * + * @var NavigationItem + */ + protected $item; + + /** + * Internal link targets provided by Icinga Web 2 + * + * @var array + */ + protected $internalLinkTargets; + + /** + * Whether to escape the label + * + * @var bool + */ + protected $escapeLabel; + + /** + * Create a new NavigationItemRenderer + * + * @param array $options + */ + public function __construct(array $options = null) + { + if (! empty($options)) { + $this->setOptions($options); + } + + $this->internalLinkTargets = array('_main', '_self', '_next'); + $this->init(); + } + + /** + * Initialize this renderer + */ + public function init() + { + } + + /** + * Set the given options + * + * @param array $options + * + * @return $this + */ + public function setOptions(array $options) + { + foreach ($options as $name => $value) { + $setter = 'set' . StringHelper::cname($name); + if (method_exists($this, $setter)) { + $this->$setter($value); + } + } + + return $this; + } + + /** + * Set the view + * + * @param View $view + * + * @return $this + */ + public function setView(View $view) + { + $this->view = $view; + return $this; + } + + /** + * Return the view + * + * @return View + */ + public function view() + { + if ($this->view === null) { + $this->setView(Icinga::app()->getViewRenderer()->view); + } + + return $this->view; + } + + /** + * Set the navigation item to render + * + * @param NavigationItem $item + * + * @return $this + */ + public function setItem(NavigationItem $item) + { + $this->item = $item; + return $this; + } + + /** + * Return the navigation item being rendered + * + * @return NavigationItem + */ + public function getItem() + { + return $this->item; + } + + /** + * Set whether to escape the label + * + * @param bool $state + * + * @return $this + */ + public function setEscapeLabel($state = true) + { + $this->escapeLabel = (bool) $state; + return $this; + } + + /** + * Return whether to escape the label + * + * @return bool + */ + public function getEscapeLabel() + { + return $this->escapeLabel !== null ? $this->escapeLabel : true; + } + + /** + * Render the given navigation item as HTML anchor + * + * @param NavigationItem $item + * + * @return string + */ + public function render(NavigationItem $item = null) + { + if ($item !== null) { + $this->setItem($item); + } elseif (($item = $this->getItem()) === null) { + throw new ProgrammingError( + 'Cannot render nothing. Pass the item to render as part' + . ' of the call to render() or set it with setItem()' + ); + } + + $label = $this->getEscapeLabel() + ? $this->view()->escape($item->getLabel()) + : $item->getLabel(); + if (($icon = $item->getIcon()) !== null) { + $label = $this->view()->icon($icon) . $label; + } elseif ($item->getName()) { + $firstLetter = $item->getName()[0]; + $label = $this->view()->icon('letter', null, ['data-letter' => strtolower($firstLetter)]) . $label; + } + + if (($url = $item->getUrl()) !== null) { + $url->overwriteParams($item->getUrlParameters()); + + $target = $item->getTarget(); + if ($url->isExternal() && (!$target || in_array($target, $this->internalLinkTargets, true))) { + $url = Url::fromPath('iframe', array('url' => $url)); + } + + $content = sprintf( + '<a%s href="%s"%s>%s</a>', + $this->view()->propertiesToString($item->getAttributes()), + $this->view()->escape($url->getAbsoluteUrl('&')), + $this->renderTargetAttribute(), + $label + ); + } elseif ($label) { + $content = sprintf( + '<%1$s%2$s>%3$s</%1$s>', + $item::LINK_ALTERNATIVE, + $this->view()->propertiesToString($item->getAttributes()), + $label + ); + } else { + $content = ''; + } + + return $content; + } + + /** + * Render and return the attribute to provide a non-default target for the url + * + * @return string + */ + protected function renderTargetAttribute() + { + $target = $this->getItem()->getTarget(); + if ($target === null || $this->getItem()->getUrl()->getAbsoluteUrl() == '#') { + return ''; + } + + if (! in_array($target, $this->internalLinkTargets, true)) { + return ' target="' . $this->view()->escape($target) . '"'; + } + + return ' data-base-target="' . $target . '"'; + } +} diff --git a/library/Icinga/Web/Navigation/Renderer/NavigationRenderer.php b/library/Icinga/Web/Navigation/Renderer/NavigationRenderer.php new file mode 100644 index 0000000..00c0f9a --- /dev/null +++ b/library/Icinga/Web/Navigation/Renderer/NavigationRenderer.php @@ -0,0 +1,356 @@ +<?php +/* Icinga Web 2 | (c) 2015 Icinga Development Team | GPLv2+ */ + +namespace Icinga\Web\Navigation\Renderer; + +use ArrayIterator; +use Exception; +use RecursiveIterator; +use Icinga\Application\Icinga; +use Icinga\Exception\IcingaException; +use Icinga\Web\Navigation\Navigation; +use Icinga\Web\Navigation\NavigationItem; +use Icinga\Web\View; + +/** + * Renderer for single level navigation + */ +class NavigationRenderer implements RecursiveIterator, NavigationRendererInterface +{ + /** + * The tag used for the outer element + * + * @var string + */ + protected $elementTag; + + /** + * The CSS class used for the outer element + * + * @var string + */ + protected $cssClass; + + /** + * The navigation's heading text + * + * @var string + */ + protected $heading; + + /** + * The content rendered so far + * + * @var array + */ + protected $content; + + /** + * Whether to skip rendering the outer element + * + * @var bool + */ + protected $skipOuterElement; + + /** + * The navigation's iterator + * + * @var ArrayIterator + */ + protected $iterator; + + /** + * The navigation + * + * @var Navigation + */ + protected $navigation; + + /** + * View + * + * @var View + */ + protected $view; + + /** + * Create a new NavigationRenderer + * + * @param Navigation $navigation + * @param bool $skipOuterElement + */ + public function __construct(Navigation $navigation, $skipOuterElement = false) + { + $this->skipOuterElement = $skipOuterElement; + $this->iterator = $navigation->getIterator(); + $this->navigation = $navigation; + $this->content = array(); + } + + /** + * {@inheritdoc} + */ + public function setElementTag($tag) + { + $this->elementTag = $tag; + return $this; + } + + /** + * {@inheritdoc} + */ + public function getElementTag() + { + return $this->elementTag ?: static::OUTER_ELEMENT_TAG; + } + + /** + * {@inheritdoc} + */ + public function setCssClass($class) + { + $this->cssClass = $class; + return $this; + } + + /** + * {@inheritdoc} + */ + public function getCssClass() + { + return $this->cssClass; + } + + /** + * {@inheritdoc} + */ + public function setHeading($heading) + { + $this->heading = $heading; + return $this; + } + + /** + * {@inheritdoc} + */ + public function getHeading() + { + return $this->heading; + } + + /** + * Return the view + * + * @return View + */ + public function view() + { + if ($this->view === null) { + $this->setView(Icinga::app()->getViewRenderer()->view); + } + + return $this->view; + } + + /** + * Set the view + * + * @param View $view + * + * @return $this + */ + public function setView(View $view) + { + $this->view = $view; + return $this; + } + + public function getChildren(): NavigationRenderer + { + return new static($this->current()->getChildren(), $this->skipOuterElement); + } + + public function hasChildren(): bool + { + return $this->current()->hasChildren(); + } + + public function current(): NavigationItem + { + return $this->iterator->current(); + } + + public function key(): int + { + return $this->iterator->key(); + } + + public function next(): void + { + $this->iterator->next(); + } + + public function rewind(): void + { + $this->iterator->rewind(); + if (! $this->skipOuterElement) { + $this->content[] = $this->beginMarkup(); + } + } + + public function valid(): bool + { + $valid = $this->iterator->valid(); + if (! $this->skipOuterElement && !$valid) { + $this->content[] = $this->endMarkup(); + } + + return $valid; + } + + /** + * Return the opening markup for the navigation + * + * @return string + */ + public function beginMarkup() + { + $content = array(); + $content[] = sprintf( + '<%s%s role="navigation">', + $this->getElementTag(), + $this->getCssClass() !== null ? ' class="' . $this->getCssClass() . '"' : '' + ); + if (($heading = $this->getHeading()) !== null) { + $content[] = sprintf( + '<h%1$d id="navigation" class="sr-only" tabindex="-1">%2$s</h%1$d>', + static::HEADING_RANK, + $this->view()->escape($heading) + ); + } + $content[] = $this->beginChildrenMarkup(); + return join("\n", $content); + } + + /** + * Return the closing markup for the navigation + * + * @return string + */ + public function endMarkup() + { + $content = array(); + $content[] = $this->endChildrenMarkup(); + $content[] = '</' . $this->getElementTag() . '>'; + return join("\n", $content); + } + + /** + * Return the opening markup for multiple navigation items + * + * @param int $level + * + * @return string + */ + public function beginChildrenMarkup($level = 1) + { + $cssClass = array(static::CSS_CLASS_NAV); + if ($this->navigation->getLayout() === Navigation::LAYOUT_TABS) { + $cssClass[] = static::CSS_CLASS_NAV_TABS; + } elseif ($this->navigation->getLayout() === Navigation::LAYOUT_DROPDOWN) { + $cssClass[] = static::CSS_CLASS_NAV_DROPDOWN; + } + + $cssClass[] = 'nav-level-' . $level; + + return '<ul class="' . join(' ', $cssClass) . '">'; + } + + /** + * Return the closing markup for multiple navigation items + * + * @return string + */ + public function endChildrenMarkup() + { + return '</ul>'; + } + + /** + * Return the opening markup for the given navigation item + * + * @param NavigationItem $item + * + * @return string + */ + public function beginItemMarkup(NavigationItem $item) + { + $cssClasses = array(static::CSS_CLASS_ITEM); + + if ($item->hasChildren() && $item->getChildren()->getLayout() === Navigation::LAYOUT_DROPDOWN) { + $cssClasses[] = static::CSS_CLASS_DROPDOWN; + $item + ->setAttribute('class', static::CSS_CLASS_DROPDOWN_TOGGLE) + ->setIcon(static::DROPDOWN_TOGGLE_ICON) + ->setUrl('#'); + } + + if ($item->getActive()) { + $cssClasses[] = static::CSS_CLASS_ACTIVE; + } + + if ($item->getSelected()) { + $cssClasses[] = static::CSS_CLASS_SELECTED; + } + + if ($cssClass = $item->getCssClass()) { + $cssClasses[] = $cssClass; + } + + $content = sprintf( + '<li class="%s">', + join(' ', $cssClasses) + ); + return $content; + } + + /** + * Return the closing markup for a navigation item + * + * @return string + */ + public function endItemMarkup() + { + return '</li>'; + } + + /** + * {@inheritdoc} + */ + public function render() + { + foreach ($this as $item) { + /** @var NavigationItem $item */ + if ($item->shouldRender()) { + $content = $item->render(); + $this->content[] = $this->beginItemMarkup($item); + $this->content[] = $content; + $this->content[] = $this->endItemMarkup(); + } + } + + return join("\n", $this->content); + } + + /** + * {@inheritdoc} + */ + public function __toString() + { + try { + return $this->render(); + } catch (Exception $e) { + return IcingaException::describe($e); + } + } +} diff --git a/library/Icinga/Web/Navigation/Renderer/NavigationRendererInterface.php b/library/Icinga/Web/Navigation/Renderer/NavigationRendererInterface.php new file mode 100644 index 0000000..4495b73 --- /dev/null +++ b/library/Icinga/Web/Navigation/Renderer/NavigationRendererInterface.php @@ -0,0 +1,142 @@ +<?php +/* Icinga Web 2 | (c) 2015 Icinga Development Team | GPLv2+ */ + +namespace Icinga\Web\Navigation\Renderer; + +/** + * Interface for navigation renderers + */ +interface NavigationRendererInterface +{ + /** + * CSS class for items + * + * @var string + */ + const CSS_CLASS_ITEM = 'nav-item'; + + /** + * CSS class for active items + * + * @var string + */ + const CSS_CLASS_ACTIVE = 'active'; + + /** + * CSS class for selected items + * + * @var string + */ + const CSS_CLASS_SELECTED = 'selected'; + + /** + * CSS class for dropdown items + * + * @var string + */ + const CSS_CLASS_DROPDOWN = 'dropdown-nav-item'; + + /** + * CSS class for a dropdown item's trigger + * + * @var string + */ + const CSS_CLASS_DROPDOWN_TOGGLE = 'dropdown-toggle'; + + /** + * CSS class for the ul element + * + * @var string + */ + const CSS_CLASS_NAV = 'nav'; + + /** + * CSS class for the ul element with dropdown layout + * + * @var string + */ + const CSS_CLASS_NAV_DROPDOWN = 'dropdown-nav'; + + /** + * CSS class for the ul element with tabs layout + * + * @var string + */ + const CSS_CLASS_NAV_TABS = 'tab-nav'; + + /** + * Icon for a dropdown item's trigger + * + * @var string + */ + const DROPDOWN_TOGGLE_ICON = 'menu'; + + /** + * Default tag for the outer element the navigation will be wrapped with + * + * @var string + */ + const OUTER_ELEMENT_TAG = 'div'; + + /** + * The heading's rank + * + * @var int + */ + const HEADING_RANK = 1; + + /** + * Set the tag for the outer element the navigation is wrapped with + * + * @param string $tag + * + * @return $this + */ + public function setElementTag($tag); + + /** + * Return the tag for the outer element the navigation is wrapped with + * + * @return string + */ + public function getElementTag(); + + /** + * Set the CSS class to use for the outer element + * + * @param string $class + * + * @return $this + */ + public function setCssClass($class); + + /** + * Get the CSS class used for the outer element + * + * @return string + */ + public function getCssClass(); + + /** + * Set the navigation's heading text + * + * @param string $heading + * + * @return $this + */ + public function setHeading($heading); + + /** + * Return the navigation's heading text + * + * @return string + */ + public function getHeading(); + + /** + * Return the navigation rendered to HTML + * + * @return string + */ + public function render(); +} diff --git a/library/Icinga/Web/Navigation/Renderer/RecursiveNavigationRenderer.php b/library/Icinga/Web/Navigation/Renderer/RecursiveNavigationRenderer.php new file mode 100644 index 0000000..315c2aa --- /dev/null +++ b/library/Icinga/Web/Navigation/Renderer/RecursiveNavigationRenderer.php @@ -0,0 +1,186 @@ +<?php +/* Icinga Web 2 | (c) 2015 Icinga Development Team | GPLv2+ */ + +namespace Icinga\Web\Navigation\Renderer; + +use Exception; +use RecursiveIteratorIterator; +use Icinga\Exception\IcingaException; +use Icinga\Web\Navigation\Navigation; +use Icinga\Web\Navigation\NavigationItem; +use Icinga\Web\Navigation\Renderer\NavigationItemRenderer; + +/** + * Renderer for multi level navigation + * + * @method NavigationRenderer getInnerIterator() { + * {@inheritdoc} + * } + */ +class RecursiveNavigationRenderer extends RecursiveIteratorIterator implements NavigationRendererInterface +{ + /** + * The content rendered so far + * + * @var array + */ + protected $content; + + /** + * Whether to use the standard item renderer + * + * @var bool + */ + protected $useStandardRenderer; + + /** + * Create a new RecursiveNavigationRenderer + * + * @param Navigation $navigation + */ + public function __construct(Navigation $navigation) + { + $this->content = array(); + parent::__construct( + new NavigationRenderer($navigation, true), + RecursiveIteratorIterator::SELF_FIRST + ); + } + + /** + * Set whether to use the standard navigation item renderer + * + * @param bool $state + * + * @return $this + */ + public function setUseStandardItemRenderer($state = true) + { + $this->useStandardRenderer = (bool) $state; + return $this; + } + + /** + * Return whether to use the standard navigation item renderer + * + * @return bool + */ + public function getUseStandardItemRenderer() + { + return $this->useStandardRenderer; + } + + /** + * {@inheritdoc} + */ + public function setElementTag($tag) + { + $this->getInnerIterator()->setElementTag($tag); + return $this; + } + + /** + * {@inheritdoc} + */ + public function getElementTag() + { + return $this->getInnerIterator()->getElementTag(); + } + + /** + * {@inheritdoc} + */ + public function setCssClass($class) + { + $this->getInnerIterator()->setCssClass($class); + return $this; + } + + /** + * {@inheritdoc} + */ + public function getCssClass() + { + return $this->getInnerIterator()->getCssClass(); + } + + /** + * {@inheritdoc} + */ + public function setHeading($heading) + { + $this->getInnerIterator()->setHeading($heading); + return $this; + } + + /** + * {@inheritdoc} + */ + public function getHeading() + { + return $this->getInnerIterator()->getHeading(); + } + + public function beginIteration(): void + { + $this->content[] = $this->getInnerIterator()->beginMarkup(); + } + + public function endIteration(): void + { + $this->content[] = $this->getInnerIterator()->endMarkup(); + } + + public function beginChildren(): void + { + $this->content[] = $this->getInnerIterator()->beginChildrenMarkup($this->getDepth() + 1); + } + + public function endChildren(): void + { + $this->content[] = $this->getInnerIterator()->endChildrenMarkup(); + $this->content[] = $this->getInnerIterator()->endItemMarkup(); + } + + /** + * {@inheritdoc} + */ + public function render() + { + foreach ($this as $item) { + /** @var NavigationItem $item */ + if ($item->shouldRender()) { + if ($this->getDepth() > 0) { + $item->setIcon(null); + } + if ($this->getUseStandardItemRenderer()) { + $renderer = new NavigationItemRenderer(); + $content = $renderer->render($item); + } else { + $content = $item->render(); + } + $this->content[] = $this->getInnerIterator()->beginItemMarkup($item); + + $this->content[] = $content; + + if (! $item->hasChildren()) { + $this->content[] = $this->getInnerIterator()->endItemMarkup(); + } + } + } + + return join("\n", $this->content); + } + + /** + * {@inheritdoc} + */ + public function __toString() + { + try { + return $this->render(); + } catch (Exception $e) { + return IcingaException::describe($e); + } + } +} diff --git a/library/Icinga/Web/Navigation/Renderer/SummaryNavigationItemRenderer.php b/library/Icinga/Web/Navigation/Renderer/SummaryNavigationItemRenderer.php new file mode 100644 index 0000000..2916f4e --- /dev/null +++ b/library/Icinga/Web/Navigation/Renderer/SummaryNavigationItemRenderer.php @@ -0,0 +1,72 @@ +<?php +/* Icinga Web 2 | (c) 2015 Icinga Development Team | GPLv2+ */ + +namespace Icinga\Web\Navigation\Renderer; + +/** + * Badge renderer summing up the worst state of its children + */ +class SummaryNavigationItemRenderer extends BadgeNavigationItemRenderer +{ + /** + * Cached count + * + * @var int + */ + protected $count; + + /** + * State to severity map + * + * @var array + */ + protected static $stateSeverityMap = array( + self::STATE_OK => 0, + self::STATE_PENDING => 1, + self::STATE_UNKNOWN => 2, + self::STATE_WARNING => 3, + self::STATE_CRITICAL => 4, + ); + + /** + * Severity to state map + * + * @var array + */ + protected static $severityStateMap = array( + self::STATE_OK, + self::STATE_PENDING, + self::STATE_UNKNOWN, + self::STATE_WARNING, + self::STATE_CRITICAL + ); + + /** + * {@inheritdoc} + */ + public function getCount() + { + if ($this->count === null) { + $countMap = array_fill(0, 5, 0); + $maxSeverity = 0; + $titles = array(); + foreach ($this->getItem()->getChildren() as $child) { + $renderer = $child->getRenderer(); + if ($renderer instanceof BadgeNavigationItemRenderer) { + $count = $renderer->getCount(); + if ($count) { + $severity = static::$stateSeverityMap[$renderer->getState()]; + $countMap[$severity] += $count; + $titles[] = $renderer->getTitle(); + $maxSeverity = max($maxSeverity, $severity); + } + } + } + $this->count = $countMap[$maxSeverity]; + $this->state = static::$severityStateMap[$maxSeverity]; + $this->title = implode('. ', $titles); + } + + return $this->count; + } +} diff --git a/library/Icinga/Web/Notification.php b/library/Icinga/Web/Notification.php new file mode 100644 index 0000000..6f33a32 --- /dev/null +++ b/library/Icinga/Web/Notification.php @@ -0,0 +1,220 @@ +<?php +/* Icinga Web 2 | (c) 2013 Icinga Development Team | GPLv2+ */ + +namespace Icinga\Web; + +use Icinga\Exception\ProgrammingError; +use Icinga\Application\Platform; +use Icinga\Application\Logger; +use Icinga\Web\Session; + +/** + * // @TODO(eL): Use Notification not as Singleton but within request: + * <code> + * <?php + * $request->[getUser()]->notify('some message', Notification::INFO); + * </code> + */ +class Notification +{ + /** + * Notification type info + * + * @var string + */ + const INFO = 'info'; + + /** + * Notification type error + * + * @var string + */ + const ERROR = 'error'; + + /** + * Notification type success + * + * @var string + */ + const SUCCESS = 'success'; + + /** + * Notification type warning + * + * @var string + */ + const WARNING = 'warning'; + + /** + * Name of the session key for notification messages + * + * @var string + */ + const SESSION_KEY = 'session'; + + /** + * Singleton instance + * + * @var self + */ + protected static $instance; + + /** + * Whether the platform is CLI + * + * @var bool + */ + protected $isCli = false; + + /** + * Notification messages + * + * @var array + */ + protected $messages = array(); + + /** + * Session + * + * @var Session + */ + protected $session; + + /** + * Create the notification instance + */ + final private function __construct() + { + if (Platform::isCli()) { + $this->isCli = true; + return; + } + + $this->session = Session::getSession(); + $messages = $this->session->get(self::SESSION_KEY); + if (is_array($messages)) { + $this->messages = $messages; + $this->session->delete(self::SESSION_KEY); + $this->session->write(); + } + } + + /** + * Get the Notification instance + * + * @return Notification + */ + public static function getInstance() + { + if (self::$instance === null) { + self::$instance = new self(); + } + return self::$instance; + } + + /** + * Add info notification + * + * @param string $msg + */ + public static function info($msg) + { + self::getInstance()->addMessage($msg, self::INFO); + } + + /** + * Add error notification + * + * @param string $msg + */ + public static function error($msg) + { + self::getInstance()->addMessage($msg, self::ERROR); + } + + /** + * Add success notification + * + * @param string $msg + */ + public static function success($msg) + { + self::getInstance()->addMessage($msg, self::SUCCESS); + } + + /** + * Add warning notification + * + * @param string $msg + */ + public static function warning($msg) + { + self::getInstance()->addMessage($msg, self::WARNING); + } + + /** + * Add a notification message + * + * @param string $message + * @param string $type + */ + protected function addMessage($message, $type = self::INFO) + { + if ($this->isCli) { + $msg = sprintf('[%s] %s', $type, $message); + switch ($type) { + case self::INFO: + case self::SUCCESS: + Logger::info($msg); + break; + case self::ERROR: + Logger::error($msg); + break; + case self::WARNING: + Logger::warning($msg); + break; + } + } else { + $this->messages[] = (object) array( + 'type' => $type, + 'message' => $message, + ); + } + } + + /** + * Pop the notification messages + * + * @return array + */ + public function popMessages() + { + $messages = $this->messages; + $this->messages = array(); + return $messages; + } + + /** + * Get whether notification messages have been added + * + * @return bool + */ + public function hasMessages() + { + return ! empty($this->messages); + } + + /** + * Destroy the notification instance + */ + final public function __destruct() + { + if ($this->isCli) { + return; + } + if ($this->hasMessages() && $this->session->get('messages') !== $this->messages) { + $this->session->set(self::SESSION_KEY, $this->messages); + $this->session->write(); + } + } +} diff --git a/library/Icinga/Web/Paginator/Adapter/QueryAdapter.php b/library/Icinga/Web/Paginator/Adapter/QueryAdapter.php new file mode 100644 index 0000000..6f103e5 --- /dev/null +++ b/library/Icinga/Web/Paginator/Adapter/QueryAdapter.php @@ -0,0 +1,84 @@ +<?php +/* Icinga Web 2 | (c) 2013 Icinga Development Team | GPLv2+ */ + +namespace Icinga\Web\Paginator\Adapter; + +use Zend_Paginator_Adapter_Interface; +use Icinga\Data\QueryInterface; + +class QueryAdapter implements Zend_Paginator_Adapter_Interface +{ + /** + * The query being paginated + * + * @var QueryInterface + */ + protected $query; + + /** + * Item count + * + * @var int + */ + protected $count; + + /** + * Create a new QueryAdapter + * + * @param QueryInterface $query The query to paginate + */ + public function __construct(QueryInterface $query) + { + $this->setQuery($query); + } + + /** + * Set the query to paginate + * + * @param QueryInterface $query + * + * @return $this + */ + public function setQuery(QueryInterface $query) + { + $this->query = $query; + return $this; + } + + /** + * Return the query being paginated + * + * @return QueryInterface + */ + public function getQuery() + { + return $this->query; + } + + /** + * Fetch and return the rows in the given range of the query result + * + * @param int $offset Page offset + * @param int $itemCountPerPage Number of items per page + * + * @return array + */ + public function getItems($offset, $itemCountPerPage) + { + return $this->query->limit($itemCountPerPage, $offset)->fetchAll(); + } + + /** + * Return the total number of items in the query result + * + * @return int + */ + public function count(): int + { + if ($this->count === null) { + $this->count = $this->query->count(); + } + + return $this->count; + } +} diff --git a/library/Icinga/Web/Paginator/ScrollingStyle/SlidingWithBorder.php b/library/Icinga/Web/Paginator/ScrollingStyle/SlidingWithBorder.php new file mode 100644 index 0000000..d9b2ed9 --- /dev/null +++ b/library/Icinga/Web/Paginator/ScrollingStyle/SlidingWithBorder.php @@ -0,0 +1,78 @@ +<?php +/* Icinga Web 2 | (c) 2013 Icinga Development Team | GPLv2+ */ + +/** + * @see Zend_Paginator_ScrollingStyle_Interface + */ +class Icinga_Web_Paginator_ScrollingStyle_SlidingWithBorder implements Zend_Paginator_ScrollingStyle_Interface +{ + /** + * Returns an array of "local" pages given a page number and range. + * + * @param Zend_Paginator $paginator + * @param integer $pageRange (Optional) Page range + * @return array + */ + public function getPages(Zend_Paginator $paginator, $pageRange = null) + { + // This is unused + if ($pageRange === null) { + $pageRange = $paginator->getPageRange(); + } + + $pageNumber = $paginator->getCurrentPageNumber(); + $pageCount = count($paginator); + $range = array(); + + if ($pageCount < 10) { + // Show all pages if we have less than 10. + + for ($i = 1; $i < 10; $i++) { + if ($i > $pageCount) { + break; + } + $range[$i] = $i; + } + } else { + // More than 10 pages: + + foreach (array(1, 2) as $i) { + $range[$i] = $i; + } + if ($pageNumber < 6) { + // We are on page 1-5 from + for ($i = 1; $i <= 7; $i++) { + $range[$i] = $i; + } + } else { + // Current page > 5 + $range[] = '...'; + + // Less than 5 pages left + if (($pageCount - $pageNumber) < 5) { + $start = 5 - ($pageCount - $pageNumber); + } else { + $start = 1; + } + + for ($i = $pageNumber - $start; $i < ($pageNumber + (4 - $start)); $i++) { + if ($i > $pageCount) { + break; + } + $range[$i] = $i; + } + } + if ($pageNumber < ($pageCount - 2)) { + $range[] = '...'; + } + + foreach (array($pageCount - 1, $pageCount) as $i) { + $range[$i] = $i; + } + } + if (empty($range)) { + $range[] = 1; + } + return $range; + } +} diff --git a/library/Icinga/Web/RememberMe.php b/library/Icinga/Web/RememberMe.php new file mode 100644 index 0000000..1002396 --- /dev/null +++ b/library/Icinga/Web/RememberMe.php @@ -0,0 +1,363 @@ +<?php +/* Icinga Web 2 | (c) 2021 Icinga GmbH | GPLv2+ */ + +namespace Icinga\Web; + +use Icinga\Application\Config; +use Icinga\Authentication\Auth; +use Icinga\Crypt\AesCrypt; +use Icinga\Common\Database; +use Icinga\User; +use ipl\Sql\Expression; +use ipl\Sql\Select; +use RuntimeException; + +/** + * Remember me component + * + * Retains credentials for 30 days by default in order to stay signed in even after the session is closed. + */ +class RememberMe +{ + use Database; + + /** @var string Cookie name */ + const COOKIE = 'icingaweb2-remember-me'; + + /** @var string Database table name */ + const TABLE = 'icingaweb_rememberme'; + + /** @var string Encrypted password of the user */ + protected $encryptedPassword; + + /** @var string */ + protected $username; + + /** @var AesCrypt Instance for encrypting/decrypting the credentials */ + protected $aesCrypt; + + /** @var int Timestamp when the remember me cookie expires */ + protected $expiresAt; + + /** + * Get whether staying logged in is possible + * + * @return bool + */ + public static function isSupported() + { + $self = new self(); + + if (! $self->hasDb()) { + return false; + } + + try { + (new AesCrypt())->getMethod(); + } catch (RuntimeException $_) { + return false; + } + + return true; + } + + /** + * Get whether the remember cookie is set + * + * @return bool + */ + public static function hasCookie() + { + return isset($_COOKIE[static::COOKIE]); + } + + /** + * Remove the database entry if exists and unset the remember me cookie from PHP's `$_COOKIE` superglobal + * + * @return Cookie The invalidation cookie which has to be sent to client in oder to remove the remember me cookie + */ + public static function forget() + { + if (self::hasCookie()) { + $data = explode('|', $_COOKIE[static::COOKIE]); + $iv = base64_decode(array_pop($data)); + (new self())->remove(bin2hex($iv)); + } + + unset($_COOKIE[static::COOKIE]); + + return (new Cookie(static::COOKIE)) + ->setHttpOnly(true) + ->forgetMe(); + } + + /** + * Create the remember me component from the remember me cookie + * + * @return static + */ + public static function fromCookie() + { + $data = explode('|', $_COOKIE[static::COOKIE]); + $iv = base64_decode(array_pop($data)); + + $select = (new Select()) + ->from(static::TABLE) + ->columns('*') + ->where(['random_iv = ?' => bin2hex($iv)]); + + $rememberMe = new static(); + $rs = $rememberMe->getDb()->select($select)->fetch(); + + if (! $rs) { + throw new RuntimeException(sprintf( + "No database entry found for IV '%s'", + bin2hex($iv) + )); + } + + $rememberMe->aesCrypt = (new AesCrypt()) + ->setKey(hex2bin($rs->passphrase)) + ->setIV($iv); + + if (count($data) > 1) { + $rememberMe->aesCrypt->setTag( + base64_decode(array_pop($data)) + ); + } elseif ($rememberMe->aesCrypt->isAuthenticatedEncryptionRequired()) { + throw new RuntimeException( + "The given decryption method needs a tag, but is not specified. " + . "You have probably updated the PHP version." + ); + } + + $rememberMe->username = $rs->username; + $rememberMe->encryptedPassword = $data[0]; + + return $rememberMe; + } + + /** + * Create the remember me component from the given username and password + * + * @param string $username + * @param string $password + * + * @return static + */ + public static function fromCredentials($username, $password) + { + $aesCrypt = new AesCrypt(); + $rememberMe = new static(); + $rememberMe->encryptedPassword = $aesCrypt->encrypt($password); + $rememberMe->username = $username; + $rememberMe->aesCrypt = $aesCrypt; + + return $rememberMe; + } + + /** + * Remove expired remember me information from the database + */ + public static function removeExpired() + { + $rememberMe = new static(); + if (! $rememberMe->hasDb()) { + return; + } + + $rememberMe->getDb()->delete(static::TABLE, [ + 'expires_at < NOW()' + ]); + } + + /** + * Get the remember me cookie + * + * @return Cookie + */ + public function getCookie() + { + $values = [ + $this->encryptedPassword, + base64_encode($this->aesCrypt->getIV()), + ]; + + if ($this->aesCrypt->isAuthenticatedEncryptionRequired()) { + array_splice($values, 1, 0, base64_encode($this->aesCrypt->getTag())); + } + + return (new Cookie(static::COOKIE)) + ->setExpire($this->getExpiresAt()) + ->setHttpOnly(true) + ->setValue(implode('|', $values)); + } + + /** + * Get the timestamp when the cookie expires + * + * Defaults to now plus 30 days, if not set via {@link setExpiresAt()}. + * + * @return int + */ + public function getExpiresAt() + { + if ($this->expiresAt === null) { + $this->expiresAt = time() + 60 * 60 * 24 * 30; + } + + return $this->expiresAt; + } + + /** + * Set the timestamp when the cookie expires + * + * @param int $expiresAt + * + * @return $this + */ + public function setExpiresAt($expiresAt) + { + $this->expiresAt = $expiresAt; + + return $this; + } + + /** + * Authenticate via the remember me cookie + * + * @return bool + * + * @throws \Icinga\Exception\AuthenticationException + */ + public function authenticate() + { + $auth = Auth::getInstance(); + $authChain = $auth->getAuthChain(); + $authChain->setSkipExternalBackends(true); + $user = new User($this->username); + if (! $user->hasDomain()) { + $user->setDomain(Config::app()->get('authentication', 'default_domain')); + } + + $authenticated = $authChain->authenticate( + $user, + $this->aesCrypt->decrypt($this->encryptedPassword) + ); + + if ($authenticated) { + $auth->setAuthenticated($user); + } + + return $authenticated; + } + + /** + * Persist the remember me information into the database + * + * To remove any previous stored information, set the iv + * + * @param string|null $iv To remove a specific iv record from the database + * + * @return $this + */ + public function persist($iv = null) + { + if ($iv) { + $this->remove(bin2hex($iv)); + } + + $this->getDb()->insert(static::TABLE, [ + 'username' => $this->username, + 'passphrase' => bin2hex($this->aesCrypt->getKey()), + 'random_iv' => bin2hex($this->aesCrypt->getIV()), + 'http_user_agent' => (new UserAgent)->getAgent(), + 'expires_at' => date('Y-m-d H:i:s', $this->getExpiresAt()), + 'ctime' => new Expression('NOW()'), + 'mtime' => new Expression('NOW()') + ]); + + return $this; + } + + /** + * Remove remember me information from the database on the basis of iv + * + * @param string $iv + * + * @return $this + */ + public function remove($iv) + { + $this->getDb()->delete(static::TABLE, [ + 'random_iv = ?' => $iv + ]); + + return $this; + } + + /** + * Create renewed remember me cookie + * + * @return static New remember me cookie which has to be sent to the client + */ + public function renew() + { + return static::fromCredentials( + $this->username, + $this->aesCrypt->decrypt($this->encryptedPassword) + ); + } + + /** + * Get all users using remember me cookie + * + * @return array Array of users + */ + public static function getAllUser() + { + $rememberMe = new static(); + if (! $rememberMe->hasDb()) { + return []; + } + + $select = (new Select()) + ->from(static::TABLE) + ->columns('username') + ->groupBy('username'); + + return $rememberMe->getDb()->select($select)->fetchAll(); + } + + /** + * Get all remember me entries from the database of the given user. + * + * @param $username + * + * @return array Array of database entries + */ + public static function getAllByUsername($username) + { + $rememberMe = new static(); + if (! $rememberMe->hasDb()) { + return []; + } + + $select = (new Select()) + ->from(static::TABLE) + ->columns(['http_user_agent', 'random_iv']) + ->where(['username = ?' => $username]); + + return $rememberMe->getDb()->select($select)->fetchAll(); + } + + /** + * Get the AesCrypt instance + * + * @return AesCrypt + */ + public function getAesCrypt() + { + return $this->aesCrypt; + } +} diff --git a/library/Icinga/Web/RememberMeUserDevicesList.php b/library/Icinga/Web/RememberMeUserDevicesList.php new file mode 100644 index 0000000..66609de --- /dev/null +++ b/library/Icinga/Web/RememberMeUserDevicesList.php @@ -0,0 +1,144 @@ +<?php +/* Icinga Web 2 | (c) 2021 Icinga GmbH | GPLv2+ */ + +namespace Icinga\Web; + +use ipl\Html\BaseHtmlElement; +use ipl\Html\Html; +use ipl\Web\Url as iplWebUrl; //alias is needed for php5.6 +use ipl\Web\Widget\Icon; +use ipl\Web\Widget\Link; + +class RememberMeUserDevicesList extends BaseHtmlElement +{ + protected $tag = 'table'; + + protected $defaultAttributes = [ + 'class' => 'common-table', + 'data-base-target' => '_self' + ]; + + /** + * @var array + */ + protected $devicesList; + + /** + * @var string + */ + protected $username; + + /** + * @var string + */ + protected $url; + + /** + * @return string + */ + public function getUrl() + { + return $this->url; + } + + /** + * @param string $url + * + * @return $this + */ + public function setUrl($url) + { + $this->url = $url; + + return $this; + } + + /** + * @return string + */ + public function getUsername() + { + return $this->username; + } + + /** + * @param string $username + * + * @return $this + */ + public function setUsername($username) + { + $this->username = $username; + + return $this; + } + + /** + * @return array List of devices. Each device contains user agent and fingerprint string + */ + public function getDevicesList() + { + return $this->devicesList; + } + + /** + * @param $devicesList + * + * @return $this + */ + public function setDevicesList($devicesList) + { + $this->devicesList = $devicesList; + + return $this; + } + + protected function assemble() + { + $thead = Html::tag('thead'); + $theadRow = Html::tag('tr') + ->add(Html::tag( + 'th', + sprintf(t('List of devices and browsers %s is currently logged in:'), $this->getUsername()) + )); + + $thead->add($theadRow); + + $head = Html::tag('tr') + ->add(Html::tag('th', t('OS'))) + ->add(Html::tag('th', t('Browser'))) + ->add(Html::tag('th', t('Fingerprint'))); + + $thead->add($head); + $tbody = Html::tag('tbody'); + + if (empty($this->getDevicesList())) { + $tbody->add(Html::tag('td', t('No device found'))); + } else { + foreach ($this->getDevicesList() as $device) { + $agent = new UserAgent($device); + $element = Html::tag('tr') + ->add(Html::tag('td', $agent->getOs())) + ->add(Html::tag('td', $agent->getBrowser())) + ->add(Html::tag('td', $device->random_iv)); + + $link = (new Link( + new Icon('trash'), + iplWebUrl::fromPath($this->getUrl()) + ->addParams( + [ + 'name' => $this->getUsername(), + 'fingerprint' => $device->random_iv, + ] + ) + )); + + $element->add(Html::tag('td', $link)); + $tbody->add($element); + } + } + + $this->add($thead); + $this->add($tbody); + } +} diff --git a/library/Icinga/Web/RememberMeUserList.php b/library/Icinga/Web/RememberMeUserList.php new file mode 100644 index 0000000..bb95dc9 --- /dev/null +++ b/library/Icinga/Web/RememberMeUserList.php @@ -0,0 +1,106 @@ +<?php +/* Icinga Web 2 | (c) 2021 Icinga GmbH | GPLv2+ */ + +namespace Icinga\Web; + +use ipl\Html\BaseHtmlElement; +use ipl\Html\Html; +use ipl\Web\Url as iplWebUrl; //alias is needed for php5.6 +use ipl\Web\Widget\Link; + +/** + * Class RememberMeUserList + * + * @package Icinga\Web + */ +class RememberMeUserList extends BaseHtmlElement +{ + protected $tag = 'table'; + + protected $defaultAttributes = [ + 'class' => 'common-table table-row-selectable', + 'data-base-target' => '_next', + ]; + + /** + * @var array + */ + protected $users; + + /** + * @var string + */ + protected $url; + + /** + * @return string + */ + public function getUrl() + { + return $this->url; + } + + /** + * @param string $url + * + * @return $this + */ + public function setUrl($url) + { + $this->url = $url; + + return $this; + } + + /** + * @return array + */ + public function getUsers() + { + return $this->users; + } + + /** + * @param array $users + * + * @return $this + */ + public function setUsers($users) + { + $this->users = $users; + + return $this; + } + + protected function assemble() + { + $thead = Html::tag('thead'); + $theadRow = Html::tag('tr') + ->add(Html::tag( + 'th', + t('List of users who stay logged in') + )); + + $thead->add($theadRow); + $tbody = Html::tag('tbody'); + + if (empty($this->getUsers())) { + $tbody->add(Html::tag('td', t('No user found'))); + } else { + foreach ($this->getUsers() as $user) { + $element = Html::tag('tr'); + $link = new Link( + $user->username, + iplWebUrl::fromPath($this->getUrl())->addParams(['name' => $user->username]), + ['title' => sprintf(t('Device list of %s'), $user->username)] + ); + + $element->add(Html::tag('td', $link)); + $tbody->add($element); + } + } + + $this->add($thead); + $this->add($tbody); + } +} diff --git a/library/Icinga/Web/Request.php b/library/Icinga/Web/Request.php new file mode 100644 index 0000000..064ce63 --- /dev/null +++ b/library/Icinga/Web/Request.php @@ -0,0 +1,142 @@ +<?php +/* Icinga Web 2 | (c) 2013 Icinga Development Team | GPLv2+ */ + +namespace Icinga\Web; + +use Icinga\Util\Json; +use Zend_Controller_Request_Http; +use Icinga\Application\Icinga; +use Icinga\User; + +/** + * A request + */ +class Request extends Zend_Controller_Request_Http +{ + /** + * Response + * + * @var Response + */ + protected $response; + + /** + * Unique identifier + * + * @var string + */ + protected $uniqueId; + + /** + * Request URL + * + * @var Url + */ + protected $url; + + /** + * User if authenticated + * + * @var User|null + */ + protected $user; + + /** + * Get the response + * + * @return Response + */ + public function getResponse() + { + if ($this->response === null) { + $this->response = Icinga::app()->getResponse(); + } + + return $this->response; + } + + /** + * Get the request URL + * + * @return Url + */ + public function getUrl() + { + if ($this->url === null) { + $this->url = Url::fromRequest($this); + } + return $this->url; + } + + /** + * Get the user if authenticated + * + * @return User|null + */ + public function getUser() + { + return $this->user; + } + + /** + * Set the authenticated user + * + * @param User $user + * + * @return $this + */ + public function setUser(User $user) + { + $this->user = $user; + return $this; + } + + /** + * Get whether the request seems to be an API request + * + * @return bool + */ + public function isApiRequest() + { + return $this->getHeader('Accept') === 'application/json'; + } + + /** + * Makes an ID unique to this request, to prevent id collisions in different containers + * + * Call this whenever an ID might show up multiple times in different containers. This function is useful + * for ensuring unique ids on sites, even if we combine the HTML of different requests into one site, + * while still being able to reference elements uniquely in the same request. + * + * @param string $id + * + * @return string The id suffixed w/ an identifier unique to this request + */ + public function protectId($id) + { + return $id . '-' . Window::getInstance()->getContainerId(); + } + + public function getPost($key = null, $default = null) + { + if ($key === null && $this->extractMediaType($this->getHeader('Content-Type')) === 'application/json') { + return Json::decode(file_get_contents('php://input'), true); + } + + return parent::getPost($key, $default); + } + + /** + * Extract and return the media type from the given header value + * + * @param string $headerValue + * + * @return string + */ + protected function extractMediaType($headerValue) + { + // Pretty basic and does not care about parameters + $parts = explode(';', $headerValue, 2); + return strtolower(trim($parts[0])); + } +} diff --git a/library/Icinga/Web/Response.php b/library/Icinga/Web/Response.php new file mode 100644 index 0000000..555d3fa --- /dev/null +++ b/library/Icinga/Web/Response.php @@ -0,0 +1,460 @@ +<?php +/* Icinga Web 2 | (c) 2014 Icinga Development Team | GPLv2+ */ + +namespace Icinga\Web; + +use Icinga\Application\Config; +use Icinga\Util\Csp; +use Zend_Controller_Response_Http; +use Icinga\Application\Icinga; +use Icinga\Web\Response\JsonResponse; + +/** + * A HTTP response + */ +class Response extends Zend_Controller_Response_Http +{ + /** + * The default content type being used for responses + * + * @var string + */ + const DEFAULT_CONTENT_TYPE = 'text/html; charset=UTF-8'; + + /** + * Auto-refresh interval + * + * @var int + */ + protected $autoRefreshInterval; + + /** + * Set of cookies which are to be sent to the client + * + * @var CookieSet + */ + protected $cookies; + + /** + * Redirect URL + * + * @var Url|null + */ + protected $redirectUrl; + + /** + * Request + * + * @var Request + */ + protected $request; + + /** + * Whether to instruct the client to reload the window + * + * @var bool + */ + protected $reloadWindow; + + /** + * Whether to instruct client side script code to reload CSS + * + * @var bool + */ + protected $reloadCss; + + /** + * Whether to send the rerender layout header on XHR + * + * @var bool + */ + protected $rerenderLayout = false; + + /** + * Whether to send the current window ID to the client + * + * @var bool + */ + protected $overrideWindowId = false; + + /** + * Get the auto-refresh interval + * + * @return int + */ + public function getAutoRefreshInterval() + { + return $this->autoRefreshInterval; + } + + /** + * Set the auto-refresh interval + * + * @param int $autoRefreshInterval + * + * @return $this + */ + public function setAutoRefreshInterval($autoRefreshInterval) + { + $this->autoRefreshInterval = $autoRefreshInterval; + return $this; + } + + /** + * Get the set of cookies which are to be sent to the client + * + * @return CookieSet + */ + public function getCookies() + { + if ($this->cookies === null) { + $this->cookies = new CookieSet(); + } + return $this->cookies; + } + + /** + * Get the cookie with the given name from the set of cookies which are to be sent to the client + * + * @param string $name The name of the cookie + * + * @return Cookie|null The cookie with the given name or null if the cookie does not exist + */ + public function getCookie($name) + { + return $this->getCookies()->get($name); + } + + /** + * Set the given cookie for sending it to the client + * + * @param Cookie $cookie The cookie to send to the client + * + * @return $this + */ + public function setCookie(Cookie $cookie) + { + $this->getCookies()->add($cookie); + return $this; + } + + /** + * Get the redirect URL + * + * @return Url|null + */ + protected function getRedirectUrl() + { + return $this->redirectUrl; + } + + /** + * Set the redirect URL + * + * Unlike {@link setRedirect()} this method only sets a redirect URL on the response for later usage. + * {@link prepare()} will take care of the correct redirect handling and HTTP headers on XHR and "normal" browser + * requests. + * + * @param string|Url $redirectUrl + * + * @return $this + */ + protected function setRedirectUrl($redirectUrl) + { + if (! $redirectUrl instanceof Url) { + $redirectUrl = Url::fromPath((string) $redirectUrl); + } + $redirectUrl->getParams()->setSeparator('&'); + $this->redirectUrl = $redirectUrl; + return $this; + } + + /** + * Get an array of all header values for the given name + * + * @param string $name The name of the header + * @param bool $lastOnly If this is true, the last value will be returned as a string + * + * @return null|array|string + */ + public function getHeader($name, $lastOnly = false) + { + $result = ($lastOnly ? null : array()); + $headers = $this->getHeaders(); + foreach ($headers as $header) { + if ($header['name'] === $name) { + if ($lastOnly) { + $result = $header['value']; + } else { + $result[] = $header['value']; + } + } + } + + return $result; + } + + /** + * Get the request + * + * @return Request + */ + public function getRequest() + { + if ($this->request === null) { + $this->request = Icinga::app()->getRequest(); + } + return $this->request; + } + + /** + * Get whether to instruct the client to reload the window + * + * @return bool + */ + public function isWindowReloaded() + { + return $this->reloadWindow; + } + + /** + * Set whether to instruct the client to reload the window + * + * @param bool $reloadWindow + * + * @return $this + */ + public function setReloadWindow($reloadWindow) + { + $this->reloadWindow = $reloadWindow; + + return $this; + } + + /** + * Get whether to instruct client side script code to reload CSS + * + * @return bool + */ + public function isReloadCss() + { + return $this->reloadCss; + } + + /** + * Set whether to instruct client side script code to reload CSS + * + * @param bool $reloadCss + * + * @return $this + */ + public function setReloadCss($reloadCss) + { + $this->reloadCss = $reloadCss; + return $this; + } + + /** + * Get whether to send the rerender layout header on XHR + * + * @return bool + */ + public function getRerenderLayout() + { + return $this->rerenderLayout; + } + + /** + * Get whether to send the rerender layout header on XHR + * + * @param bool $rerenderLayout + * + * @return $this + */ + public function setRerenderLayout($rerenderLayout = true) + { + $this->rerenderLayout = (bool) $rerenderLayout; + return $this; + } + + /** + * Get whether to send the current window ID to the client + * + * @return bool + */ + public function getOverrideWindowId() + { + return $this->overrideWindowId; + } + + /** + * Set whether to send the current window ID to the client + * + * @param bool $overrideWindowId + * + * @return $this + */ + public function setOverrideWindowId($overrideWindowId = true) + { + $this->overrideWindowId = $overrideWindowId; + return $this; + } + + /** + * Entry point for HTTP responses in JSON format + * + * @return JsonResponse + */ + public function json() + { + $response = new JsonResponse(); + $response->copyMetaDataFrom($this); + return $response; + } + + /** + * Prepare the request before sending + */ + protected function prepare() + { + $request = $this->getRequest(); + $redirectUrl = $this->getRedirectUrl(); + if ($request->isXmlHttpRequest()) { + if ($redirectUrl !== null) { + if ($request->isGet() && Icinga::app()->getViewRenderer()->view->compact) { + if ($redirectUrl->getParam('redirect') !== '__SELF__') { + $redirectUrl->getParams()->set('showCompact', true); + } + } + + $encodedRedirectUrl = rawurlencode($redirectUrl->getAbsoluteUrl()); + + // TODO: Compatibility only. Remove once v2.14 is out. + $targetId = $request->getHeader('X-Icinga-Container'); + $redirectTargetId = $this->getHeader('X-Icinga-Container', true) ?? $targetId; + if ($request->isPost() + && ! $this->getRerenderLayout() + && $targetId === 'col2' + && $redirectTargetId === $targetId + && $request->getHeader('X-Icinga-Col2-State') + ) { + $col1State = Url::fromPath($request->getHeader('X-Icinga-Col1-State')); + $col2State = Url::fromPath($request->getHeader('X-Icinga-Col2-State')); + if ($col2State->getPath() !== $redirectUrl->getPath() + && $col1State->getPath() === $redirectUrl->getPath() + ) { + $encodedRedirectUrl = '__CLOSE__'; + } + } + + $this->setHeader('X-Icinga-Redirect', $encodedRedirectUrl, true); + if ($this->getRerenderLayout()) { + $this->setHeader('X-Icinga-Rerender-Layout', 'yes', true); + } + } + if ($this->getOverrideWindowId()) { + $this->setHeader('X-Icinga-WindowId', Window::getInstance()->getId(), true); + } + if ($this->getRerenderLayout()) { + $this->setHeader('X-Icinga-Container', 'layout', true); + } + if ($this->isWindowReloaded()) { + $this->setHeader('X-Icinga-Reload-Window', 'yes', true); + } + if ($this->isReloadCss()) { + $this->setHeader('X-Icinga-Reload-Css', 'now', true); + } + if (($autoRefreshInterval = $this->getAutoRefreshInterval()) !== null) { + $this->setHeader('X-Icinga-Refresh', $autoRefreshInterval, true); + } + + $notifications = Notification::getInstance(); + if ($notifications->hasMessages()) { + $notificationList = array(); + foreach ($notifications->popMessages() as $m) { + $notificationList[] = rawurlencode($m->type . ' ' . $m->message); + } + $this->setHeader('X-Icinga-Notification', implode('&', $notificationList), true); + } + } else { + if ($redirectUrl !== null) { + $this->setRedirect($redirectUrl->getAbsoluteUrl()); + } + + if (Csp::getStyleNonce() && Config::app()->get('security', 'use_strict_csp', false)) { + Csp::addHeader($this); + } + } + + if (! $this->getHeader('Content-Type', true)) { + $this->setHeader('Content-Type', static::DEFAULT_CONTENT_TYPE); + } + } + + /** + * Redirect to the given URL and exit immediately + * + * @param string|Url $url + * + * @return never + */ + public function redirectAndExit($url) + { + $this->setRedirectUrl($url); + + $session = Session::getSession(); + if ($session->hasChanged()) { + $session->write(); + } + + $this->sendHeaders(); + exit; + } + + /** + * Send the cookies to the client + */ + public function sendCookies() + { + foreach ($this->getCookies() as $cookie) { + /** @var Cookie $cookie */ + setcookie( + $cookie->getName(), + $cookie->getValue() ?? '', + $cookie->getExpire() ?? 0, + $cookie->getPath(), + $cookie->getDomain() ?? '', + $cookie->isSecure(), + $cookie->isHttpOnly() ?? true + ); + } + } + + /** + * {@inheritdoc} + */ + public function sendHeaders() + { + $this->prepare(); + if (! $this->getRequest()->isApiRequest()) { + $this->sendCookies(); + } + return parent::sendHeaders(); + } + + /** + * Copies non-body-related response data from $response + * + * @param Response $response + * + * @return $this + */ + protected function copyMetaDataFrom(self $response) + { + $this->_headers = $response->_headers; + $this->_headersRaw = $response->_headersRaw; + $this->_httpResponseCode = $response->_httpResponseCode; + $this->headersSentThrowsException = $response->headersSentThrowsException; + return $this; + } +} diff --git a/library/Icinga/Web/Response/JsonResponse.php b/library/Icinga/Web/Response/JsonResponse.php new file mode 100644 index 0000000..025e88d --- /dev/null +++ b/library/Icinga/Web/Response/JsonResponse.php @@ -0,0 +1,241 @@ +<?php +/* Icinga Web 2 | (c) 2015 Icinga Development Team | GPLv2+ */ + +namespace Icinga\Web\Response; + +use Icinga\Util\Json; +use Zend_Controller_Action_HelperBroker; +use Icinga\Web\Response; + +/** + * HTTP response in JSON format + */ +class JsonResponse extends Response +{ + /** + * {@inheritdoc} + */ + const DEFAULT_CONTENT_TYPE = 'application/json'; + + /** + * Status identifier for failed API calls due to an error on the server + * + * @var string + */ + const STATUS_ERROR = 'error'; + + /** + * Status identifier for rejected API calls most due to invalid data or call conditions + * + * @var string + */ + const STATUS_FAIL = 'fail'; + + /** + * Status identifier for successful API requests + * + * @var string + */ + const STATUS_SUCCESS = 'success'; + + /** + * JSON encoding options + * + * @var int + */ + protected $encodingOptions = 0; + + /** + * Whether to automatically sanitize invalid UTF-8 (if any) + * + * @var bool + */ + protected $autoSanitize = false; + + /** + * Error message if the API call failed due to a server error + * + * @var string|null + */ + protected $errorMessage; + + /** + * Fail data for rejected API calls + * + * @var array|null + */ + protected $failData; + + /** + * API request status + * + * @var string + */ + protected $status; + + /** + * Success data for successful API requests + * + * @var array|null + */ + protected $successData; + + /** + * Get the JSON encoding options + * + * @return int + */ + public function getEncodingOptions() + { + return $this->encodingOptions; + } + + /** + * Set the JSON encoding options + * + * @param int $encodingOptions + * + * @return $this + */ + public function setEncodingOptions($encodingOptions) + { + $this->encodingOptions = (int) $encodingOptions; + return $this; + } + + /** + * Get whether to automatically sanitize invalid UTF-8 (if any) + * + * @return bool + */ + public function getAutoSanitize() + { + return $this->autoSanitize; + } + + /** + * Set whether to automatically sanitize invalid UTF-8 (if any) + * + * @param bool $autoSanitize + * + * @return $this + */ + public function setAutoSanitize($autoSanitize = true) + { + $this->autoSanitize = $autoSanitize; + + return $this; + } + + /** + * Get the error message if the API call failed due to a server error + * + * @return string|null + */ + public function getErrorMessage() + { + return $this->errorMessage; + } + + /** + * Set the error message if the API call failed due to a server error + * + * @param string $errorMessage + * + * @return $this + */ + public function setErrorMessage($errorMessage) + { + $this->errorMessage = (string) $errorMessage; + $this->status = static::STATUS_ERROR; + return $this; + } + + /** + * Get the fail data for rejected API calls + * + * @return array|null + */ + public function getFailData() + { + return (! is_array($this->failData) || empty($this->failData)) ? null : $this->failData; + } + + /** + * Set the fail data for rejected API calls + * + * @param array $failData + * + * @return $this + */ + public function setFailData(array $failData) + { + $this->failData = $failData; + $this->status = static::STATUS_FAIL; + return $this; + } + + /** + * Get the data for successful API requests + * + * @return array|null + */ + public function getSuccessData() + { + return (! is_array($this->successData) || empty($this->successData)) ? null : $this->successData; + } + + /** + * Set the data for successful API requests + * + * @param array $successData + * + * @return $this + */ + public function setSuccessData(array $successData = null) + { + $this->successData = $successData; + $this->status = static::STATUS_SUCCESS; + return $this; + } + + /** + * {@inheritdoc} + */ + public function outputBody() + { + $body = array( + 'status' => $this->status + ); + switch ($this->status) { + /** @noinspection PhpMissingBreakStatementInspection */ + case static::STATUS_ERROR: + $body['message'] = $this->getErrorMessage(); + // Fallthrough + case static::STATUS_FAIL: + $failData = $this->getFailData(); + if ($failData !== null || $this->status === static::STATUS_FAIL) { + $body['data'] = $failData; + } + break; + case static::STATUS_SUCCESS: + $body['data'] = $this->getSuccessData(); + break; + } + echo $this->getAutoSanitize() + ? Json::sanitize($body, $this->getEncodingOptions()) + : Json::encode($body, $this->getEncodingOptions()); + } + + /** + * Send the response, including all headers, excluding a rendered view. + * + * @return never + */ + public function sendResponse() + { + Zend_Controller_Action_HelperBroker::getStaticHelper('viewRenderer')->setNoRender(true); + parent::sendResponse(); + exit; + } +} diff --git a/library/Icinga/Web/Session.php b/library/Icinga/Web/Session.php new file mode 100644 index 0000000..40df89f --- /dev/null +++ b/library/Icinga/Web/Session.php @@ -0,0 +1,54 @@ +<?php +/* Icinga Web 2 | (c) 2013 Icinga Development Team | GPLv2+ */ + +namespace Icinga\Web; + +use Icinga\Web\Session\PhpSession; +use Icinga\Web\Session\Session as BaseSession; +use Icinga\Exception\ProgrammingError; + +/** + * Session container + */ +class Session +{ + /** + * The current session + * + * @var BaseSession $session + */ + private static $session; + + /** + * Create the session + * + * @param BaseSession $session + * + * @return BaseSession + */ + public static function create(BaseSession $session = null) + { + if ($session === null) { + self::$session = PhpSession::create(); + } else { + self::$session = $session; + } + + return self::$session; + } + + /** + * Return the current session + * + * @return BaseSession + * @throws ProgrammingError + */ + public static function getSession() + { + if (self::$session === null) { + self::create(); + } + + return self::$session; + } +} diff --git a/library/Icinga/Web/Session/Php72Session.php b/library/Icinga/Web/Session/Php72Session.php new file mode 100644 index 0000000..e6a6b19 --- /dev/null +++ b/library/Icinga/Web/Session/Php72Session.php @@ -0,0 +1,37 @@ +<?php +/* Icinga Web 2 | (c) 2017 Icinga Development Team | GPLv2+ */ + +namespace Icinga\Web\Session; + +use Icinga\Application\Logger; +use Icinga\Exception\ConfigurationError; +use Icinga\Web\Cookie; + +/** + * Session implementation in PHP + */ +class Php72Session extends PhpSession +{ + /** + * Open a PHP session + */ + protected function open() + { + session_name($this->sessionName); + + $cookie = new Cookie('bogus'); + session_set_cookie_params( + 0, + $cookie->getPath(), + $cookie->getDomain(), + $cookie->isSecure(), + true + ); + + session_start(array( + 'use_cookies' => true, + 'use_only_cookies' => true, + 'use_trans_sid' => false + )); + } +} diff --git a/library/Icinga/Web/Session/PhpSession.php b/library/Icinga/Web/Session/PhpSession.php new file mode 100644 index 0000000..36dd84e --- /dev/null +++ b/library/Icinga/Web/Session/PhpSession.php @@ -0,0 +1,256 @@ +<?php +/* Icinga Web 2 | (c) 2013 Icinga Development Team | GPLv2+ */ + +namespace Icinga\Web\Session; + +use Icinga\Application\Logger; +use Icinga\Exception\ConfigurationError; +use Icinga\Web\Cookie; + +/** + * Session implementation in PHP + */ +class PhpSession extends Session +{ + /** + * The namespace prefix + * + * Used to differentiate between standard session keys and namespace identifiers + */ + const NAMESPACE_PREFIX = 'ns.'; + + /** + * Whether the session has already been closed + * + * @var bool + */ + protected $hasBeenTouched = false; + + /** + * Name of the session + * + * @var string + */ + protected $sessionName = 'Icingaweb2'; + + /** + * Create a new PHPSession object using the provided options (if any) + * + * @param array $options An optional array of ini options to set + * + * @return static + * + * @throws ConfigurationError + * @see http://php.net/manual/en/session.configuration.php + */ + public static function create(array $options = null) + { + return version_compare(PHP_VERSION, '7.2.0') < 0 ? new self($options) : new Php72Session($options); + } + + /** + * Create a new PHPSession object using the provided options (if any) + * + * @param array $options An optional array of ini options to set + * + * @throws ConfigurationError + * @see http://php.net/manual/en/session.configuration.php + */ + public function __construct(array $options = null) + { + $defaultCookieOptions = array( + 'use_trans_sid' => false, + 'use_cookies' => true, + 'cookie_httponly' => true, + 'use_only_cookies' => true + ); + + if (version_compare(PHP_VERSION, '7.1.0') < 0) { + $defaultCookieOptions['hash_function'] = true; + $defaultCookieOptions['hash_bits_per_character'] = 5; + } else { + $defaultCookieOptions['sid_bits_per_character'] = 5; + } + + if ($options !== null) { + $options = array_merge($defaultCookieOptions, $options); + } else { + $options = $defaultCookieOptions; + } + + if (array_key_exists('test_session_name', $options)) { + $this->sessionName = $options['test_session_name']; + unset($options['test_session_name']); + } + + foreach ($options as $sessionVar => $value) { + if (ini_set("session." . $sessionVar, $value) === false) { + Logger::warning( + 'Could not set php.ini setting %s = %s. This might affect your sessions behaviour.', + $sessionVar, + $value + ); + } + } + + $sessionSavePath = session_save_path() ?: sys_get_temp_dir(); + if (session_module_name() === 'files' && !is_writable($sessionSavePath)) { + throw new ConfigurationError("Can't save session, path '$sessionSavePath' is not writable."); + } + + if ($this->exists()) { + // We do not want to start a new session here if there is not any + $this->read(); + } + } + + /** + * Open a PHP session + */ + protected function open() + { + session_name($this->sessionName); + + if ($this->hasBeenTouched) { + $cacheLimiter = ini_get('session.cache_limiter'); + ini_set('session.use_cookies', false); + ini_set('session.use_only_cookies', false); + ini_set('session.cache_limiter', null); + } + + $cookie = new Cookie('bogus'); + session_set_cookie_params( + 0, + $cookie->getPath(), + $cookie->getDomain(), + $cookie->isSecure(), + true + ); + + session_start(); + + if ($this->hasBeenTouched) { + ini_set('session.use_cookies', true); + ini_set('session.use_only_cookies', true); + /** @noinspection PhpUndefinedVariableInspection */ + ini_set('session.cache_limiter', $cacheLimiter); + } + } + + /** + * Read all values written to the underling session and make them accessible. + */ + public function read() + { + $this->clear(); + $this->open(); + + foreach ($_SESSION as $key => $value) { + if (strpos($key, self::NAMESPACE_PREFIX) === 0) { + $namespace = new SessionNamespace(); + $namespace->setAll($value); + $this->namespaces[substr($key, strlen(self::NAMESPACE_PREFIX))] = $namespace; + } else { + $this->set($key, $value); + } + } + + session_write_close(); + $this->hasBeenTouched = true; + } + + /** + * Write all values of this session object to the underlying session implementation + */ + public function write() + { + $this->open(); + + foreach ($this->removed as $key) { + unset($_SESSION[$key]); + } + foreach ($this->values as $key => $value) { + $_SESSION[$key] = $value; + } + foreach ($this->removedNamespaces as $identifier) { + unset($_SESSION[self::NAMESPACE_PREFIX . $identifier]); + } + foreach ($this->namespaces as $identifier => $namespace) { + $_SESSION[self::NAMESPACE_PREFIX . $identifier] = $namespace->getAll(); + } + + session_write_close(); + $this->hasBeenTouched = true; + } + + /** + * Delete the current session, causing all session information to be lost + */ + public function purge() + { + $this->open(); + $_SESSION = array(); + $this->clear(); + session_destroy(); + $this->clearCookies(); + session_write_close(); + $this->hasBeenTouched = true; + } + + /** + * Remove session cookies + */ + protected function clearCookies() + { + if (ini_get('session.use_cookies')) { + Logger::debug('Clear session cookie'); + $params = session_get_cookie_params(); + setcookie( + session_name(), + '', + time() - 42000, + $params['path'], + $params['domain'], + $params['secure'], + $params['httponly'] + ); + } + } + + /** + * @see Session::getId() + */ + public function getId() + { + if (($id = session_id()) === '') { + // Make sure we actually get a id + $this->open(); + session_write_close(); + $this->hasBeenTouched = true; + $id = session_id(); + } + + return $id; + } + + /** + * Assign a new sessionId to the currently active session + */ + public function refreshId() + { + $this->open(); + if ($this->exists()) { + session_regenerate_id(); + } + session_write_close(); + $this->hasBeenTouched = true; + } + + /** + * @see Session::exists() + */ + public function exists() + { + return isset($_COOKIE[$this->sessionName]); + } +} diff --git a/library/Icinga/Web/Session/Session.php b/library/Icinga/Web/Session/Session.php new file mode 100644 index 0000000..e73e9b4 --- /dev/null +++ b/library/Icinga/Web/Session/Session.php @@ -0,0 +1,126 @@ +<?php +/* Icinga Web 2 | (c) 2014 Icinga Development Team | GPLv2+ */ + +namespace Icinga\Web\Session; + +use Icinga\Exception\NotImplementedError; + +/** + * Base class for handling sessions + */ +abstract class Session extends SessionNamespace +{ + /** + * Container for session namespaces + * + * @var array + */ + protected $namespaces = array(); + + /** + * The identifiers of all namespaces removed from this session + * + * @var array + */ + protected $removedNamespaces = array(); + + /** + * Read all values from the underlying session implementation + */ + abstract public function read(); + + /** + * Persists changes to the underlying session implementation + */ + public function write() + { + throw new NotImplementedError('You are required to implement write() in your session implementation'); + } + + /** + * Return whether a session exists + * + * @return bool + */ + abstract public function exists(); + + /** + * Purge session + */ + abstract public function purge(); + + /** + * Assign a new session id to this session. + */ + abstract public function refreshId(); + + /** + * Return the id of this session + * + * @return string + */ + abstract public function getId(); + + /** + * Get or create a new session namespace + * + * @param string $identifier The namespace's identifier + * + * @return SessionNamespace + */ + public function getNamespace($identifier) + { + if (!isset($this->namespaces[$identifier])) { + if (in_array($identifier, $this->removedNamespaces, true)) { + unset($this->removedNamespaces[array_search($identifier, $this->removedNamespaces, true)]); + } + + $this->namespaces[$identifier] = new SessionNamespace(); + } + + return $this->namespaces[$identifier]; + } + + /** + * Return whether the given session namespace exists + * + * @param string $identifier The namespace's identifier to check + * + * @return bool + */ + public function hasNamespace($identifier) + { + return isset($this->namespaces[$identifier]); + } + + /** + * Remove the given session namespace + * + * @param string $identifier The identifier of the namespace to remove + */ + public function removeNamespace($identifier) + { + unset($this->namespaces[$identifier]); + $this->removedNamespaces[] = $identifier; + } + + /** + * Return whether the session has changed + * + * @return bool + */ + public function hasChanged() + { + return parent::hasChanged() || false === empty($this->namespaces) || false === empty($this->removedNamespaces); + } + + /** + * Clear all values and namespaces from the session cache + */ + public function clear() + { + parent::clear(); + $this->namespaces = array(); + $this->removedNamespaces = array(); + } +} diff --git a/library/Icinga/Web/Session/SessionNamespace.php b/library/Icinga/Web/Session/SessionNamespace.php new file mode 100644 index 0000000..1c9c13f --- /dev/null +++ b/library/Icinga/Web/Session/SessionNamespace.php @@ -0,0 +1,201 @@ +<?php +/* Icinga Web 2 | (c) 2014 Icinga Development Team | GPLv2+ */ + +namespace Icinga\Web\Session; + +use Exception; +use ArrayIterator; +use Icinga\Exception\IcingaException; +use IteratorAggregate; +use Traversable; + +/** + * Container for session values + */ +class SessionNamespace implements IteratorAggregate +{ + /** + * The actual values stored in this container + * + * @var array + */ + protected $values = array(); + + /** + * The names of all values removed from this container + * + * @var array + */ + protected $removed = array(); + + /** + * Return an iterator for all values in this namespace + * + * @return ArrayIterator + */ + public function getIterator(): Traversable + { + return new ArrayIterator($this->getAll()); + } + + /** + * Set a session value by property access + * + * @param string $key The value's name + * @param mixed $value The value + */ + public function __set($key, $value) + { + $this->set($key, $value); + } + + /** + * Return a session value by property access + * + * @param string $key The value's name + * + * @return mixed The value + * @throws Exception When the given value-name is not found + */ + public function __get($key) + { + if (!array_key_exists($key, $this->values)) { + throw new IcingaException( + 'Cannot access non-existent session value "%s"', + $key + ); + } + + return $this->get($key); + } + + /** + * Return whether the given session value is set + * + * @param string $key The value's name + * @return bool + */ + public function __isset($key) + { + return isset($this->values[$key]); + } + + /** + * Unset the given session value + * + * @param string $key The value's name + */ + public function __unset($key) + { + $this->delete($key); + } + + /** + * Setter for session values + * + * @param string $key Name of value + * @param mixed $value Value to set + * + * @return $this + */ + public function set($key, $value) + { + $this->values[$key] = $value; + + if (in_array($key, $this->removed, true)) { + unset($this->removed[array_search($key, $this->removed, true)]); + } + + return $this; + } + + public function setByRef($key, &$value) + { + $this->values[$key] = & $value; + + if (in_array($key, $this->removed, true)) { + unset($this->removed[array_search($key, $this->removed, true)]); + } + + return $this; + } + + /** + * Getter for session values + * + * @param string $key Name of the value to return + * @param mixed $default Default value to return + * + * @return mixed + */ + public function get($key, $default = null) + { + return isset($this->values[$key]) ? $this->values[$key] : $default; + } + + public function & getByRef($key, $default = null) + { + $value = $default; + if (isset($this->values[$key])) { + $value = & $this->values[$key]; + } + + return $value; + } + + /** + * Delete the given value from the session + * + * @param string $key The value's name + */ + public function delete($key) + { + $this->removed[] = $key; + unset($this->values[$key]); + } + + /** + * Getter for all session values + * + * @return array + */ + public function getAll() + { + return $this->values; + } + + /** + * Put an array into the session + * + * @param array $values Values to set + * @param bool $overwrite Overwrite existing values + */ + public function setAll(array $values, $overwrite = false) + { + foreach ($values as $key => $value) { + if ($this->get($key, $value) !== $value && !$overwrite) { + continue; + } + $this->set($key, $value); + } + } + + /** + * Return whether the session namespace has been changed + * + * @return bool + */ + public function hasChanged() + { + return false === empty($this->values) || false === empty($this->removed); + } + + /** + * Clear all values from the session namespace + */ + public function clear() + { + $this->values = array(); + $this->removed = array(); + } +} diff --git a/library/Icinga/Web/StyleSheet.php b/library/Icinga/Web/StyleSheet.php new file mode 100644 index 0000000..65cbb97 --- /dev/null +++ b/library/Icinga/Web/StyleSheet.php @@ -0,0 +1,342 @@ +<?php +/* Icinga Web 2 | (c) 2014 Icinga Development Team | GPLv2+ */ + +namespace Icinga\Web; + +use Exception; +use Icinga\Application\Icinga; +use Icinga\Application\Logger; +use Icinga\Authentication\Auth; +use Icinga\Exception\IcingaException; + +/** + * Send CSS for Web 2 and all loaded modules to the client + */ +class StyleSheet +{ + /** + * The name of the default theme + * + * @var string + */ + const DEFAULT_THEME = 'Icinga'; + + /** + * The name of the default theme mode + * + * @var string + */ + const DEFAULT_MODE = 'none'; + + /** + * The themes that are compatible with the default theme + * + * @var array + */ + const THEME_WHITELIST = [ + 'colorblind', + 'high-contrast', + 'Winter' + ]; + + /** + * Sequence that signals that a theme supports light mode + * + * @var string + */ + const LIGHT_MODE_IDENTIFIER = '@light-mode:'; + + /** + * Array of core LESS files Web 2 sends to the client + * + * @var string[] + */ + protected static $lessFiles = [ + '../application/fonts/fontello-ifont/css/ifont-embedded.css', + 'css/vendor/normalize.css', + 'css/icinga/base.less', + 'css/icinga/badges.less', + 'css/icinga/configmenu.less', + 'css/icinga/mixins.less', + 'css/icinga/grid.less', + 'css/icinga/nav.less', + 'css/icinga/main.less', + 'css/icinga/animation.less', + 'css/icinga/layout.less', + 'css/icinga/layout-structure.less', + 'css/icinga/menu.less', + 'css/icinga/tabs.less', + 'css/icinga/forms.less', + 'css/icinga/setup.less', + 'css/icinga/widgets.less', + 'css/icinga/login.less', + 'css/icinga/about.less', + 'css/icinga/controls.less', + 'css/icinga/dev.less', + 'css/icinga/spinner.less', + 'css/icinga/compat.less', + 'css/icinga/print.less', + 'css/icinga/responsive.less', + 'css/icinga/modal.less', + 'css/icinga/audit.less', + 'css/icinga/health.less', + 'css/icinga/php-diff.less', + 'css/icinga/pending-migration.less', + ]; + + /** + * Application instance + * + * @var \Icinga\Application\EmbeddedWeb + */ + protected $app; + + /** @var string[] Pre-compiled CSS files */ + protected $cssFiles = []; + + /** + * Less compiler + * + * @var LessCompiler + */ + protected $lessCompiler; + + /** + * Path to the public directory + * + * @var string + */ + protected $pubPath; + + /** + * Create the StyleSheet + */ + public function __construct() + { + $app = Icinga::app(); + $this->app = $app; + $this->lessCompiler = new LessCompiler(); + $this->pubPath = $app->getBaseDir('public'); + $this->collect(); + } + + /** + * Collect Web 2 and module LESS files and add them to the LESS compiler + */ + protected function collect() + { + foreach ($this->app->getLibraries() as $library) { + foreach ($library->getCssAssets() as $lessFile) { + if (substr($lessFile, -4) === '.css') { + $this->cssFiles[] = $lessFile; + } else { + $this->lessCompiler->addLessFile($lessFile); + } + } + } + + foreach (self::$lessFiles as $lessFile) { + $this->lessCompiler->addLessFile($this->pubPath . '/' . $lessFile); + } + + $mm = $this->app->getModuleManager(); + + foreach ($mm->getLoadedModules() as $moduleName => $module) { + if ($module->hasCss()) { + foreach ($module->getCssFiles() as $lessFilePath) { + $this->lessCompiler->addModuleLessFile($moduleName, $lessFilePath); + } + } + } + + $themingConfig = $this->app->getConfig()->getSection('themes'); + $defaultTheme = $themingConfig->get('default'); + $theme = null; + if ($defaultTheme !== null && $defaultTheme !== self::DEFAULT_THEME) { + $theme = $defaultTheme; + } + + if (! (bool) $themingConfig->get('disabled', false)) { + $auth = Auth::getInstance(); + if ($auth->isAuthenticated()) { + $userTheme = $auth->getUser()->getPreferences()->getValue('icingaweb', 'theme'); + if ($userTheme !== null) { + $theme = $userTheme; + } + } + } + + if ($themePath = self::getThemeFile($theme)) { + if ($this->app->isCli() || is_file($themePath) && is_readable($themePath)) { + $this->lessCompiler->setTheme($themePath); + } else { + $themePath = null; + Logger::warning(sprintf( + 'Theme "%s" set by user "%s" has not been found.', + $theme, + ($user = Auth::getInstance()->getUser()) !== null ? $user->getUsername() : 'anonymous' + )); + } + } + + if (! $themePath || in_array($theme, self::THEME_WHITELIST, true)) { + $this->lessCompiler->addLessFile($this->pubPath . '/css/icinga/login-orbs.less'); + } + + $mode = 'none'; + if ($user = Auth::getInstance()->getUser()) { + $file = $themePath !== null ? @file_get_contents($themePath) : false; + if (! $file || strpos($file, self::LIGHT_MODE_IDENTIFIER) !== false) { + $mode = $user->getPreferences()->getValue('icingaweb', 'theme_mode', self::DEFAULT_MODE); + } + } + + $this->lessCompiler->setThemeMode($this->pubPath . '/css/modes/'. $mode . '.less'); + } + + /** + * Get all collected files + * + * @return string[] + */ + protected function getFiles(): array + { + return array_merge($this->cssFiles, $this->lessCompiler->getLessFiles()); + } + + /** + * Get the stylesheet for PDF export + * + * @return $this + */ + public static function forPdf() + { + $styleSheet = new self(); + $styleSheet->lessCompiler->setTheme(null); + $styleSheet->lessCompiler->setThemeMode($styleSheet->pubPath . '/css/modes/none.less'); + $styleSheet->lessCompiler->addLessFile($styleSheet->pubPath . '/css/pdf/pdfprint.less'); + // TODO(el): Caching + return $styleSheet; + } + + /** + * Render the stylesheet + * + * @param bool $minified Whether to compress the stylesheet + * + * @return string CSS + */ + public function render($minified = false) + { + if ($minified) { + $this->lessCompiler->compress(); + } + + $css = ''; + foreach ($this->cssFiles as $cssFile) { + $css .= file_get_contents($cssFile); + } + + return $css . $this->lessCompiler->render(); + } + + /** + * Send the stylesheet to the client + * + * Does not cache the stylesheet if the HTTP header Cache-Control or Pragma is set to no-cache. + * + * @param bool $minified Whether to compress the stylesheet + */ + public static function send($minified = false) + { + $styleSheet = new self(); + + $request = $styleSheet->app->getRequest(); + $response = $styleSheet->app->getResponse(); + $response->setHeader('Cache-Control', 'private,no-cache,must-revalidate', true); + + $noCache = $request->getHeader('Cache-Control') === 'no-cache' || $request->getHeader('Pragma') === 'no-cache'; + + $collectedFiles = $styleSheet->getFiles(); + if (! $noCache && FileCache::etagMatchesFiles($collectedFiles)) { + $response + ->setHttpResponseCode(304) + ->sendHeaders(); + return; + } + + $etag = FileCache::etagForFiles($collectedFiles); + + $response->setHeader('ETag', $etag, true) + ->setHeader('Content-Type', 'text/css', true); + + $cacheFile = 'icinga-' . $etag . ($minified ? '.min' : '') . '.css'; + $cache = FileCache::instance(); + + if (! $noCache && $cache->has($cacheFile)) { + $response->setBody($cache->get($cacheFile)); + } else { + $css = $styleSheet->render($minified); + $response->setBody($css); + $cache->store($cacheFile, $css); + } + + $response->sendResponse(); + } + + /** + * Render the stylesheet + * + * @return string + */ + public function __toString() + { + try { + return $this->render(); + } catch (Exception $e) { + Logger::error($e); + return IcingaException::describe($e); + } + } + + /** + * Get the path to the current LESS theme file + * + * @param $theme + * + * @return string|null Return null if self::DEFAULT_THEME is set as theme, path otherwise + */ + public static function getThemeFile($theme) + { + $app = Icinga::app(); + + if ($theme && $theme !== self::DEFAULT_THEME) { + if (Hook::has('ThemeLoader')) { + try { + $path = Hook::first('ThemeLoader')->getThemeFile($theme); + } catch (Exception $e) { + Logger::error('Failed to call ThemeLoader hook: %s', $e); + $path = null; + } + + if ($path !== null) { + return $path; + } + } + + if (($pos = strpos($theme, '/')) !== false) { + $moduleName = substr($theme, 0, $pos); + $theme = substr($theme, $pos + 1); + if ($app->getModuleManager()->hasLoaded($moduleName)) { + $module = $app->getModuleManager()->getModule($moduleName); + + return $module->getCssDir() . '/themes/' . $theme . '.less'; + } + } else { + return $app->getBaseDir('public') . '/css/themes/' . $theme . '.less'; + } + } + + return null; + } +} diff --git a/library/Icinga/Web/Url.php b/library/Icinga/Web/Url.php new file mode 100644 index 0000000..c90ca48 --- /dev/null +++ b/library/Icinga/Web/Url.php @@ -0,0 +1,806 @@ +<?php +/* Icinga Web 2 | (c) 2013 Icinga Development Team | GPLv2+ */ + +namespace Icinga\Web; + +use Icinga\Application\Icinga; +use Icinga\Exception\ProgrammingError; +use Icinga\Data\Filter\Filter; + +/** + * Url class that provides convenient access to parameters, allows to modify query parameters and + * returns Urls reflecting all changes made to the url and to the parameters. + * + * Direct instantiation is prohibited and should be done either with @see Url::fromRequest() or + * @see Url::fromPath() + */ +class Url +{ + /** + * Whether this url points to an external resource + * + * @var bool + */ + protected $external; + + /** + * An array of all parameters stored in this Url + * + * @var UrlParams + */ + protected $params; + + /** + * The site anchor after the '#' + * + * @var string + */ + protected $anchor = ''; + + /** + * The relative path of this Url, without query parameters + * + * @var string + */ + protected $path = ''; + + /** + * The basePath of this Url + * + * @var string + */ + protected $basePath; + + /** + * The host of this Url + * + * @var string + */ + protected $host; + + /** + * The port of this Url + * + * @var string + */ + protected $port; + + /** + * The scheme of this Url + * + * @var string + */ + protected $scheme; + + /** + * The username passed with this Url + * + * @var string + */ + protected $username; + + /** + * The password passed with this Url + * + * @var string + */ + protected $password; + + protected function __construct() + { + $this->params = UrlParams::fromQueryString(''); // TODO: ::create() + } + + /** + * Create a new Url class representing the current request + * + * If $params are given, those will be added to the request's parameters + * and overwrite any existing parameters + * + * @param UrlParams|array $params Parameters that should additionally be considered for the url + * @param Request $request A request to use instead of the default one + * + * @return static + */ + public static function fromRequest($params = array(), $request = null) + { + if ($request === null) { + $request = static::getRequest(); + } + + $url = new static(); + $url->setPath(ltrim($request->getPathInfo(), '/')); + + // $urlParams = UrlParams::fromQueryString($request->getQuery()); + if (isset($_SERVER['QUERY_STRING'])) { + $urlParams = UrlParams::fromQueryString($_SERVER['QUERY_STRING']); + } else { + $urlParams = UrlParams::fromQueryString(''); + foreach ($request->getQuery() as $k => $v) { + $urlParams->set($k, $v); + } + } + + foreach ($params as $k => $v) { + $urlParams->set($k, $v); + } + $url->setParams($urlParams); + $url->setBasePath($request->getBaseUrl()); + return $url; + } + + /** + * Return a request object that should be used for determining the URL + * + * @return Request + */ + protected static function getRequest() + { + $app = Icinga::app(); + if ($app->isCli()) { + throw new ProgrammingError( + 'Url::fromRequest and Url::fromPath are currently not supported for CLI operations' + ); + } else { + return $app->getRequest(); + } + } + + /** + * Create a new Url class representing the given url + * + * If $params are given, those will be added to the urls parameters + * and overwrite any existing parameters + * + * @param string $url The string representation of the url to parse + * @param array $params An array of parameters that should additionally be considered for the url + * @param Request $request A request to use instead of the default one + * + * @return static + */ + public static function fromPath($url, array $params = array(), $request = null) + { + if ($request === null) { + $request = static::getRequest(); + } + + if (! is_string($url)) { + throw new ProgrammingError( + 'url %s is not a string', + var_export($url, true) + ); + } + + $urlObject = new static(); + + if ($url === '#') { + $urlObject->setPath($url); + return $urlObject; + } + + $urlParts = parse_url($url); + if (isset($urlParts['scheme']) && ( + $urlParts['scheme'] !== $request->getScheme() + || (isset($urlParts['host']) && $urlParts['host'] !== $request->getServer('SERVER_NAME')) + || (isset($urlParts['port']) && $urlParts['port'] != $request->getServer('SERVER_PORT'))) + ) { + $urlObject->setIsExternal(); + } + + if (isset($urlParts['path'])) { + $urlPath = $urlParts['path']; + if ($urlPath && $urlPath[0] === '/') { + if ($urlObject->isExternal() || isset($urlParts['user'])) { + $urlPath = ltrim($urlPath, '/'); + } else { + $requestBaseUrl = $request->getBaseUrl(); + if ($requestBaseUrl && $requestBaseUrl !== '/' && strpos($urlPath, $requestBaseUrl) === 0) { + $urlPath = ltrim(substr($urlPath, strlen($requestBaseUrl)), '/'); + $urlObject->setBasePath($requestBaseUrl); + } + } + } elseif (! $urlObject->isExternal()) { + $urlObject->setBasePath($request->getBaseUrl()); + } + + $urlObject->setPath($urlPath); + } elseif (! $urlObject->isExternal()) { + $urlObject->setBasePath($request->getBaseUrl()); + } + + // TODO: This has been used by former filter implementation, remove it: + if (isset($urlParts['query'])) { + $params = UrlParams::fromQueryString($urlParts['query'])->mergeValues($params); + } + if (isset($urlParts['fragment'])) { + $urlObject->setAnchor($urlParts['fragment']); + } + + if (isset($urlParts['user']) || $urlObject->isExternal()) { + if (isset($urlParts['user'])) { + $urlObject->setUsername($urlParts['user']); + } + if (isset($urlParts['host'])) { + $urlObject->setHost($urlParts['host']); + } + if (isset($urlParts['port'])) { + $urlObject->setPort($urlParts['port']); + } + if (isset($urlParts['scheme'])) { + $urlObject->setScheme($urlParts['scheme']); + } + if (isset($urlParts['pass'])) { + $urlObject->setPassword($urlParts['pass']); + } + } + + $urlObject->setParams($params); + return $urlObject; + } + + /** + * Create a new filter that needs to fullfill the base filter and the optional filter (if it exists) + * + * @param string $url The url to apply the new filter to + * @param Filter $filter The base filter + * @param ?Filter $optional The optional filter + * + * @return static The altered URL containing the new filter + * @throws ProgrammingError + */ + public static function urlAddFilterOptional($url, $filter, $optional) + { + $url = static::fromPath($url); + $f = $filter; + if (isset($optional)) { + $f = Filter::matchAll($filter, $optional); + } + return $url->setQueryString($f->toQueryString()); + } + + /** + * Add the given filter to the current filter of the URL + * + * @param Filter $and + * + * @return $this + */ + public function addFilter($and) + { + $this->setQueryString( + Filter::fromQueryString($this->getQueryString()) + ->andFilter($and) + ->toQueryString() + ); + return $this; + } + + /** + * Set the basePath for this url + * + * @param string $basePath New basePath of this url + * + * @return $this + */ + public function setBasePath($basePath) + { + $this->basePath = rtrim($basePath, '/ '); + return $this; + } + + /** + * Return the basePath set for this url + * + * @return string + */ + public function getBasePath() + { + return $this->basePath; + } + + /** + * Set the host for this url + * + * @param string $host New host of this Url + * + * @return $this + */ + public function setHost($host) + { + $this->host = $host; + return $this; + } + + /** + * Return the host set for this url + * + * @return string + */ + public function getHost() + { + return $this->host; + } + + /** + * Set the port for this url + * + * @param string $port New port of this url + * + * @return $this + */ + public function setPort($port) + { + $this->port = $port; + return $this; + } + + /** + * Return the port set for this url + * + * @return string + */ + public function getPort() + { + return $this->port; + } + + /** + * Set the scheme for this url + * + * @param string $scheme The scheme used for this url + * + * @return $this + */ + public function setScheme($scheme) + { + $this->scheme = $scheme; + return $this; + } + + /** + * Return the scheme set for this url + * + * @return string + */ + public function getScheme() + { + return $this->scheme; + } + + /** + * Set the relative path of this url, without query parameters + * + * @param string $path The path to set + * + * @return $this + */ + public function setPath($path) + { + $this->path = $path; + return $this; + } + + /** + * Return the relative path of this url, without query parameters + * + * If you want the relative path with query parameters use getRelativeUrl + * + * @return string + */ + public function getPath() + { + return $this->path; + } + + /** + * Set whether this url points to an external resource + * + * @param bool $state + * + * @return $this + */ + public function setIsExternal($state = true) + { + $this->external = (bool) $state; + return $this; + } + + /** + * Return whether this url points to an external resource + * + * @return bool + */ + public function isExternal() + { + return $this->external; + } + + /** + * Set the username passed with this url + * + * @param string $username The username to set + * + * @return $this + */ + public function setUsername($username) + { + $this->username = $username; + return $this; + } + + /** + * Return the username passed with this url + * + * @return string + */ + public function getUsername() + { + return $this->username; + } + + /** + * Set the username passed with this url + * + * @param string $password The password to set + * + * @return $this + */ + public function setPassword($password) + { + $this->password = $password; + return $this; + } + + /** + * Return the password passed with this url + * + * @return string + */ + public function getPassword() + { + return $this->password; + } + + /** + * Return the relative url + * + * @return string + */ + public function getRelativeUrl($separator = '&') + { + $path = $this->buildPathQueryAndFragment($separator); + if ($path && $path[0] === '/') { + return ''; + } + + return $path; + } + + /** + * Return this url's path with its query parameters and fragment as string + * + * @return string + */ + protected function buildPathQueryAndFragment($querySeparator) + { + $anchor = $this->getAnchor(); + if ($anchor) { + $anchor = '#' . $anchor; + } + + $query = $this->getQueryString($querySeparator); + if ($query) { + $query = '?' . $query; + } + + return $this->getPath() . $query . $anchor; + } + + public function setQueryString($queryString) + { + $this->params = UrlParams::fromQueryString($queryString); + return $this; + } + + public function getQueryString($separator = null) + { + return $this->params->toString($separator); + } + + /** + * Return the absolute url with query parameters as a string + * + * @return string + */ + public function getAbsoluteUrl($separator = '&') + { + $path = $this->buildPathQueryAndFragment($separator); + if ($path && ($path === '#' || $path[0] === '/')) { + return $path; + } + + $basePath = $this->getBasePath(); + if (! $basePath) { + $basePath = '/'; + } + + if ($this->getUsername() || $this->isExternal()) { + $urlString = ''; + if ($this->getScheme()) { + $urlString .= $this->getScheme() . '://'; + } + if ($this->getPassword()) { + $urlString .= $this->getUsername() . ':' . $this->getPassword() . '@'; + } elseif ($this->getUsername()) { + $urlString .= $this->getUsername() . '@'; + } + if ($this->getHost()) { + $urlString .= $this->getHost(); + } + if ($this->getPort()) { + $urlString .= ':' . $this->getPort(); + } + + return $urlString . $basePath . ($basePath !== '/' && $path ? '/' : '') . $path; + } else { + return $basePath . ($basePath !== '/' && $path ? '/' : '') . $path; + } + } + + /** + * Add a set of parameters to the query part if the keys don't exist yet + * + * @param array $params The parameters to add + * + * @return $this + */ + public function addParams(array $params) + { + foreach ($params as $k => $v) { + $this->params->add($k, $v); + } + + return $this; + } + + /** + * Set and overwrite the given params if one if the same key already exists + * + * @param array $params The parameters to set + * + * @return $this + */ + public function overwriteParams(array $params) + { + foreach ($params as $k => $v) { + $this->params->set($k, $v); + } + + return $this; + } + + /** + * Overwrite the parameters used in the query part + * + * @param UrlParams|array $params The new parameters to use for the query part + * + * @return $this + */ + public function setParams($params) + { + if ($params instanceof UrlParams) { + $this->params = $params; + } elseif (is_array($params)) { + $urlParams = UrlParams::fromQueryString(''); + foreach ($params as $k => $v) { + $urlParams->set($k, $v); + } + $this->params = $urlParams; + } else { + throw new ProgrammingError( + 'Url params needs to be either an array or an UrlParams instance' + ); + } + return $this; + } + + /** + * Return all parameters that will be used in the query part + * + * @return UrlParams An instance of UrlParam containing all parameters + */ + public function getParams() + { + return $this->params; + } + + /** + * Return true if a urls' query parameter exists, otherwise false + * + * @param string $param The url parameter name to check + * + * @return bool + */ + public function hasParam($param) + { + return $this->params->has($param); + } + + /** + * Return a url's query parameter if it exists, otherwise $default + * + * @param string $param A query parameter name to return if existing + * @param mixed $default A value to return when the parameter doesn't exist + * + * @return mixed + */ + public function getParam($param, $default = null) + { + return $this->params->get($param, $default); + } + + /** + * Set a single parameter, overwriting any existing one with the same name + * + * @param string $param The query parameter name + * @param array|string|bool $value An array or string to set as the parameter value + * + * @return $this + */ + public function setParam($param, $value = true) + { + $this->params->set($param, $value); + return $this; + } + + /** + * Set the url anchor-part + * + * @param string $anchor The site's anchor string without the '#' + * + * @return $this + */ + public function setAnchor($anchor) + { + $this->anchor = $anchor; + return $this; + } + + /** + * Return the url anchor-part + * + * @return string The site's anchor string without the '#' + */ + public function getAnchor() + { + return $this->anchor; + } + + /** + * Remove provided key (if string) or keys (if array of string) from the query parameter array + * + * @param string|array $keyOrArrayOfKeys An array of strings or a string representing the key(s) + * of the parameters to be removed + * @return $this + */ + public function remove($keyOrArrayOfKeys) + { + $this->params->remove($keyOrArrayOfKeys); + return $this; + } + + /** + * Shift a query parameter from this URL if it exists, otherwise $default + * + * @param string $param Parameter name + * @param mixed $default Default value in case $param does not exist + * + * @return mixed + */ + public function shift($param, $default = null) + { + return $this->params->shift($param, $default); + } + + /** + * Whether the given URL matches this URL object + * + * This does an exact match, parameters MUST be in the same order + * + * @param Url|string $url the URL to compare against + * + * @return bool whether the URL matches + */ + public function matches($url) + { + if (! $url instanceof static) { + $url = static::fromPath($url); + } + return (string) $url === (string) $this; + } + + /** + * Return a copy of this url without the parameter given + * + * The argument can be either a single query parameter name or an array of parameter names to + * remove from the query list + * + * @param string|array $keyOrArrayOfKeys A single string or an array containing parameter names + * + * @return static + */ + public function getUrlWithout($keyOrArrayOfKeys) + { + return $this->without($keyOrArrayOfKeys); + } + + public function without($keyOrArrayOfKeys) + { + $url = clone($this); + $url->remove($keyOrArrayOfKeys); + return $url; + } + + /** + * Return a copy of this url with the given parameter(s) + * + * The argument can be either a single query parameter name or an array of parameter names to + * remove from the query list + * + * @param string|array $param A single string or an array containing parameter names + * @param mixed $values an optional values array + * + * @return static + */ + public function with($param, $values = null) + { + $url = clone($this); + $url->params->mergeValues($param, $values); + return $url; + } + + /** + * Return a copy of this url with only the given parameter(s) + * + * The argument can be either a single query parameter name or + * an array of parameter names to keep on on the query + * + * @param string|array $keyOrArrayOfKeys + * + * @return static + */ + public function onlyWith($keyOrArrayOfKeys) + { + if (! is_array($keyOrArrayOfKeys)) { + $keyOrArrayOfKeys = [$keyOrArrayOfKeys]; + } + + $url = clone $this; + foreach ($url->getParams()->toArray(false) as $param => $value) { + if (is_int($param)) { + $param = $value; + } + + if (! in_array($param, $keyOrArrayOfKeys, true)) { + $url->remove($param); + } + } + + return $url; + } + + public function __clone() + { + $this->params = clone $this->params; + } + + /** + * Alias for @see Url::getAbsoluteUrl() + * + * @return string + */ + public function __toString() + { + return htmlspecialchars($this->getAbsoluteUrl(), ENT_COMPAT | ENT_SUBSTITUTE | ENT_HTML5, 'UTF-8', true); + } +} diff --git a/library/Icinga/Web/UrlParams.php b/library/Icinga/Web/UrlParams.php new file mode 100644 index 0000000..2265235 --- /dev/null +++ b/library/Icinga/Web/UrlParams.php @@ -0,0 +1,433 @@ +<?php +/* Icinga Web 2 | (c) 2014 Icinga Development Team | GPLv2+ */ + +namespace Icinga\Web; + +use Icinga\Exception\MissingParameterException; + +class UrlParams +{ + protected $separator = '&'; + + protected $params = array(); + + protected $index = array(); + + public function isEmpty() + { + return empty($this->index); + } + + public function setSeparator($separator) + { + $this->separator = $separator; + return $this; + } + + /** + * Get the given parameter + * + * Returns the last URL param if defined multiple times, $default if not + * given at all + * + * @param string $param The parameter you're interested in + * @param string|int|bool|null $default An optional default value + * + * @return mixed + */ + public function get($param, $default = null) + { + if (! $this->has($param)) { + return $default; + } + + return rawurldecode($this->params[ end($this->index[$param]) ][ 1 ]); + } + + /** + * Require a parameter + * + * @param string $name Name of the parameter + * @param bool $strict Whether the parameter's value must not be the empty string + * + * @return mixed + * + * @throws MissingParameterException If the parameter was not given + */ + public function getRequired($name, $strict = true) + { + if ($this->has($name)) { + $value = $this->get($name); + if (! $strict || strlen($value) > 0) { + return $value; + } + } + $e = new MissingParameterException(t('Required parameter \'%s\' missing'), $name); + $e->setParameter($name); + throw $e; + } + + /** + * Get all instances of the given parameter + * + * Returns an array containing all values defined for a given parameter, + * $default if none. + * + * @param string $param The parameter you're interested in + * @param array $default An optional default value + * + * @return mixed + */ + public function getValues($param, $default = array()) + { + if (! $this->has($param)) { + return $default; + } + + $ret = array(); + foreach ($this->index[$param] as $key) { + $ret[] = rawurldecode($this->params[$key][1]); + } + return $ret; + } + + /** + * Whether the given parameter exists + * + * Returns true if such a parameter has been defined, false otherwise. + * + * @param string $param The parameter you're interested in + * + * @return boolean + */ + public function has($param) + { + return array_key_exists($param, $this->index); + } + + /** + * Get and remove the given parameter + * + * Returns the last URL param if defined multiple times, $default if not + * given at all. The parameter will be removed from this object. + * + * @param string $param The parameter you're interested in + * @param string $default An optional default value + * + * @return mixed + */ + public function shift($param = null, $default = null) + { + if ($param === null) { + if (empty($this->params)) { + return $default; + } + $ret = array_shift($this->params); + $ret[0] = rawurldecode($ret[0]); + $ret[1] = rawurldecode($ret[1]); + } else { + if (! $this->has($param)) { + return $default; + } + $key = reset($this->index[$param]); + $ret = rawurldecode($this->params[$key][1]); + unset($this->params[$key]); + } + + $this->reIndexAll(); + return $ret; + } + + /** + * Require and remove a parameter + * + * @param string $name Name of the parameter + * @param bool $strict Whether the parameter's value must not be the empty string + * + * @return mixed + * + * @throws MissingParameterException If the parameter was not given + */ + public function shiftRequired($name, $strict = true) + { + if ($this->has($name)) { + $value = $this->get($name); + if (! $strict || strlen($value) > 0) { + $this->shift($name); + return $value; + } + } + $e = new MissingParameterException(t('Required parameter \'%s\' missing'), $name); + $e->setParameter($name); + throw $e; + } + + public function addEncoded($param, $value = true) + { + $this->params[] = array($param, $this->cleanupValue($value)); + $this->indexLastOne(); + return $this; + } + + protected function urlEncode($value) + { + return rawurlencode($value instanceof Url ? $value->getAbsoluteUrl() : (string) $value); + } + + /** + * Add the given parameter with the given value + * + * This will add the given parameter, regardless of whether it already + * exists. + * + * @param string $param The parameter you're interested in + * @param string|bool $value The value to be stored + * + * @return $this + */ + public function add($param, $value = true) + { + return $this->addEncoded($this->urlEncode($param), $this->urlEncode($value)); + } + + /** + * Adds a list of parameters + * + * This may be used with either a list of values for a single parameter or + * with a list of parameter / value pairs. + * + * @param string|array $param Parameter name or param/value list + * @param ?array $value The value to be stored + * + * @return $this + */ + public function addValues($param, $values = null) + { + if ($values === null && is_array($param)) { + foreach ($param as $k => $v) { + $this->add($k, $v); + } + } else { + foreach ($values as $value) { + $this->add($param, $value); + } + } + + return $this; + } + + protected function clearValues() + { + $this->params = array(); + $this->index = array(); + } + + public function mergeValues($param, $values = null) + { + if ($values === null && is_array($param)) { + foreach ($param as $k => $v) { + $this->set($k, $v); + } + } else { + if (! is_array($values)) { + $values = array($values); + } + foreach ($values as $value) { + $this->set($param, $value); + } + } + + return $this; + } + + public function setValues($param, $values = null) + { + $this->clearValues(); + return $this->addValues($param, $values); + } + + /** + * Add the given parameter with the given value in front of all other values + * + * This will add the given parameter in front of all others, regardless of + * whether it already exists. + * + * @param string $param The parameter you're interested in + * @param string $value The value to be stored + * + * @return $this + */ + public function unshift($param, $value) + { + array_unshift($this->params, array($this->urlEncode($param), $this->urlEncode($value))); + $this->reIndexAll(); + return $this; + } + + /** + * Set the given parameter with the given value + * + * This will set the given parameter, and override eventually existing ones. + * + * @param string $param The parameter you want to set + * @param string $value The value to be stored + * + * @return $this + */ + public function set($param, $value) + { + if (! $this->has($param)) { + return $this->add($param, $value); + } + + while (count($this->index[$param]) > 1) { + $remove = array_pop($this->index[$param]); + unset($this->params[$remove]); + } + + $this->params[$this->index[$param][0]] = array( + $this->urlEncode($param), + $this->urlEncode($this->cleanupValue($value)) + ); + $this->reIndexAll(); + + return $this; + } + + public function remove($param) + { + $changed = false; + + if (! is_array($param)) { + $param = array($param); + } + + foreach ($param as $p) { + if ($this->has($p)) { + foreach ($this->index[$p] as $key) { + unset($this->params[$key]); + } + $changed = true; + } + } + + if ($changed) { + $this->reIndexAll(); + } + + return $this; + } + + public function without($param) + { + $params = clone $this; + return $params->remove($param); + } + + // TODO: push, pop? + + protected function indexLastOne() + { + end($this->params); + $key = key($this->params); + $param = $this->params[$key][0]; + $this->addParamToIndex($param, $key); + } + + protected function addParamToIndex($param, $key) + { + if (! $this->has($param)) { + $this->index[$param] = array(); + } + $this->index[$param][] = $key; + } + + protected function reIndexAll() + { + $this->index = array(); + $this->params = array_values($this->params); + foreach ($this->params as $key => & $param) { + $this->addParamToIndex($param[0], $key); + } + } + + protected function cleanupValue($value) + { + return is_bool($value) ? $value : (string) $value; + } + + protected function parseQueryString($queryString) + { + $parts = preg_split('~&~', $queryString, -1, PREG_SPLIT_NO_EMPTY); + foreach ($parts as $part) { + $this->parseQueryStringPart($part); + } + } + + protected function parseQueryStringPart($part) + { + if (strpos($part, '=') === false) { + $this->addEncoded($part, true); + } else { + list($key, $val) = preg_split('/=/', $part, 2); + $this->addEncoded($key, $val); + } + } + + /** + * Return the parameters of this url as sequenced or associative array + * + * @param bool $sequenced + * + * @return array + */ + public function toArray($sequenced = true) + { + if ($sequenced) { + return $this->params; + } + + $params = array(); + foreach ($this->params as $param) { + if ($param[1] === true) { + $params[] = $param[0]; + } else { + $params[$param[0]] = $param[1]; + } + } + + return $params; + } + + public function toString($separator = null) + { + if ($separator === null) { + $separator = $this->separator; + } + $parts = array(); + foreach ($this->params as $p) { + if ($p[1] === true) { + $parts[] = $p[0]; + } else { + $parts[] = $p[0] . '=' . $p[1]; + } + } + return implode($separator, $parts); + } + + public function __toString() + { + return $this->toString(); + } + + public static function fromQueryString($queryString = null) + { + if ($queryString === null) { + $queryString = isset($_SERVER['QUERY_STRING']) ? $_SERVER['QUERY_STRING'] : ''; + } + $params = new static(); + $params->parseQueryString($queryString); + + return $params; + } +} diff --git a/library/Icinga/Web/UserAgent.php b/library/Icinga/Web/UserAgent.php new file mode 100644 index 0000000..71c1a8b --- /dev/null +++ b/library/Icinga/Web/UserAgent.php @@ -0,0 +1,86 @@ +<?php +/* Icinga Web 2 | (c) 2021 Icinga GmbH | GPLv2+ */ + +namespace Icinga\Web; + +/** + * Class UserAgent + * + * This class helps to get user agent information like OS type and browser name + * + * @package Icinga\Web + */ +class UserAgent +{ + /** + * $_SERVER['HTTP_USER_AGENT'] output string + * + * @var string|null + */ + private $agent; + + public function __construct($agent = null) + { + $this->agent = isset($_SERVER['HTTP_USER_AGENT']) ? $_SERVER['HTTP_USER_AGENT'] : null; + + if ($agent) { + $this->agent = $agent->http_user_agent; + } + } + + /** + * Return $_SERVER['HTTP_USER_AGENT'] output string of given or current device + * + * @return string + */ + public function getAgent() + { + return $this->agent; + } + + /** + * Get Browser name + * + * @return string Browser name or unknown if not found + */ + public function getBrowser() + { + // key => regex value + $browsers = [ + "Internet Explorer" => "/MSIE(.*)/i", + "Seamonkey" => "/Seamonkey(.*)/i", + "MS Edge" => "/Edg(.*)/i", + "Opera" => "/Opera(.*)/i", + "Opera Browser" => "/OPR(.*)/i", + "Chromium" => "/Chromium(.*)/i", + "Firefox" => "/Firefox(.*)/i", + "Google Chrome" => "/Chrome(.*)/i", + "Safari" => "/Safari(.*)/i" + ]; + //TODO find a way to return also the version of the browser + foreach ($browsers as $browser => $regex) { + if (preg_match($regex, $this->agent)) { + return $browser; + } + } + + return 'unknown'; + } + + /** + * Get Operating system information + * + * @return string os information + */ + public function getOs() + { + // get string before the first appearance of ')' + $device = strstr($this->agent, ')', true); + if (! $device) { + return 'unknown'; + } + + // return string after the first appearance of '(' + return substr($device, strpos($device, '(') + 1); + } +} diff --git a/library/Icinga/Web/View.php b/library/Icinga/Web/View.php new file mode 100644 index 0000000..2c80d1d --- /dev/null +++ b/library/Icinga/Web/View.php @@ -0,0 +1,254 @@ +<?php +/* Icinga Web 2 | (c) 2013 Icinga Development Team | GPLv2+ */ + +namespace Icinga\Web; + +use Closure; +use Icinga\Application\Icinga; +use ipl\I18n\Translation; +use Zend_View_Abstract; +use Icinga\Authentication\Auth; +use Icinga\Exception\ProgrammingError; + +/** + * Icinga view + * + * @method Url href($path = null, $params = null) { + * @param Url|string|null $path + * @param string[]|null $params + * } + * + * @method Url url($path = null, $params = null) { + * @param Url|string|null $path + * @param string[]|null $params + * } + * + * @method Url qlink($title, $url, $params = null, $properties = null, $escape = true) { + * @param string $title + * @param Url|string|null $url + * @param string[]|null $params + * @param string[]|null $properties + * @param bool $escape + * } + * + * @method string img($url, $params = null, array $properties = array()) { + * @param Url|string|null $url + * @param string[]|null $params + * @param string[] $properties + * } + * + * @method string icon($img, $title = null, array $properties = array()) { + * @param string $img + * @param string|null $title + * @param string[] $properties + * } + * + * @method string propertiesToString($properties) { + * @param string[] $properties + * } + * + * @method string attributeToString($key, $value) { + * @param string $key + * @param string $value + * } + */ +class View extends Zend_View_Abstract +{ + use Translation; + + /** + * Charset to be used - we only support UTF-8 + */ + const CHARSET = 'UTF-8'; + + /** + * Registered helper functions + */ + private $helperFunctions = array(); + + /** + * Authentication manager + * + * @var Auth|null + */ + private $auth; + + /** + * Create a new view object + * + * @param array $config + * @see Zend_View_Abstract::__construct + */ + public function __construct($config = array()) + { + $config['helperPath']['Icinga\\Web\\View\\Helper\\'] = Icinga::app()->getLibraryDir('Icinga/Web/View/Helper'); + + parent::__construct($config); + } + + /** + * Initialize the view + * + * @see Zend_View_Abstract::init + */ + public function init() + { + $this->loadGlobalHelpers(); + } + + /** + * Escape the given value top be safely used in view scripts + * + * @param ?string $var The output to be escaped + * @return string + */ + public function escape($var) + { + return htmlspecialchars($var ?? '', ENT_COMPAT | ENT_SUBSTITUTE | ENT_HTML5, self::CHARSET, true); + } + + /** + * Whether a specific helper (closure) has been registered + * + * @param string $name The desired function name + * @return boolean + */ + public function hasHelperFunction($name) + { + return array_key_exists($name, $this->helperFunctions); + } + + /** + * Add a new helper function + * + * @param string $name The desired function name + * @param Closure $function An anonymous function + * @return $this + */ + public function addHelperFunction($name, Closure $function) + { + if ($this->hasHelperFunction($name)) { + throw new ProgrammingError( + 'Cannot assign the same helper function twice: "%s"', + $name + ); + } + + $this->helperFunctions[$name] = $function; + return $this; + } + + /** + * Set or overwrite a helper function + * + * @param string $name + * @param Closure $function + * + * @return $this + */ + public function setHelperFunction($name, Closure $function) + { + $this->helperFunctions[$name] = $function; + return $this; + } + + /** + * Drop a helper function + * + * @param string $name + * + * @return $this + */ + public function dropHelperFunction($name) + { + unset($this->helperFunctions[$name]); + return $this; + } + + /** + * Call a helper function + * + * @param string $name The desired function name + * @param Array $args Function arguments + * @return mixed + */ + public function callHelperFunction($name, $args) + { + return call_user_func_array( + $this->helperFunctions[$name], + $args + ); + } + + /** + * Load helpers + */ + private function loadGlobalHelpers() + { + $pattern = dirname(__FILE__) . '/View/helpers/*.php'; + $files = glob($pattern); + foreach ($files as $file) { + require_once $file; + } + } + + /** + * Get the authentication manager + * + * @return Auth + */ + public function Auth() + { + if ($this->auth === null) { + $this->auth = Auth::getInstance(); + } + return $this->auth; + } + + /** + * Whether the current user has the given permission + * + * @param string $permission Name of the permission + * + * @return bool + */ + public function hasPermission($permission) + { + return $this->Auth()->hasPermission($permission); + } + + /** + * Use to include the view script in a scope that only allows public + * members. + * + * @return mixed + * + * @see Zend_View_Abstract::run + */ + protected function _run() + { + foreach ($this->getVars() as $k => $v) { + // Exporting global variables to view scripts: + $$k = $v; + } + + include func_get_arg(0); + } + + /** + * Accesses a helper object from within a script + * + * @param string $name + * @param array $args + * + * @return string + */ + public function __call($name, $args) + { + if ($this->hasHelperFunction($name)) { + return $this->callHelperFunction($name, $args); + } else { + return parent::__call($name, $args); + } + } +} diff --git a/library/Icinga/Web/View/AppHealth.php b/library/Icinga/Web/View/AppHealth.php new file mode 100644 index 0000000..c66ca05 --- /dev/null +++ b/library/Icinga/Web/View/AppHealth.php @@ -0,0 +1,89 @@ +<?php +/* Icinga Web 2 | (c) 2021 Icinga GmbH | GPLv2+ */ + +namespace Icinga\Web\View; + +use Icinga\Application\Hook\HealthHook; +use ipl\Html\FormattedString; +use ipl\Html\HtmlElement; +use ipl\Html\Table; +use ipl\Web\Common\BaseTarget; +use ipl\Web\Widget\Link; +use Traversable; + +class AppHealth extends Table +{ + use BaseTarget; + + protected $defaultAttributes = ['class' => ['app-health', 'common-table', 'table-row-selectable']]; + + /** @var Traversable */ + protected $data; + + public function __construct(Traversable $data) + { + $this->data = $data; + + $this->setBaseTarget('_next'); + } + + protected function assemble() + { + foreach ($this->data as $row) { + $this->add(Table::tr([ + Table::th(HtmlElement::create('span', ['class' => [ + 'ball', + 'ball-size-xl', + $this->getStateClass($row->state) + ]])), + Table::td([ + new HtmlElement('header', null, FormattedString::create( + t('%s by %s is %s', '<check> by <module> is <state-text>'), + $row->url + ? new Link(HtmlElement::create('span', null, $row->name), $row->url) + : HtmlElement::create('span', null, $row->name), + HtmlElement::create('span', null, $row->module), + HtmlElement::create('span', null, $this->getStateText($row->state)) + )), + HtmlElement::create('section', null, $row->message) + ]) + ])); + } + } + + protected function getStateClass($state) + { + if ($state === null) { + $state = HealthHook::STATE_UNKNOWN; + } + + switch ($state) { + case HealthHook::STATE_OK: + return 'state-ok'; + case HealthHook::STATE_WARNING: + return 'state-warning'; + case HealthHook::STATE_CRITICAL: + return 'state-critical'; + case HealthHook::STATE_UNKNOWN: + return 'state-unknown'; + } + } + + protected function getStateText($state) + { + if ($state === null) { + $state = t('UNKNOWN'); + } + + switch ($state) { + case HealthHook::STATE_OK: + return t('OK'); + case HealthHook::STATE_WARNING: + return t('WARNING'); + case HealthHook::STATE_CRITICAL: + return t('CRITICAL'); + case HealthHook::STATE_UNKNOWN: + return t('UNKNOWN'); + } + } +} diff --git a/library/Icinga/Web/View/Helper/IcingaCheckbox.php b/library/Icinga/Web/View/Helper/IcingaCheckbox.php new file mode 100644 index 0000000..07cf01f --- /dev/null +++ b/library/Icinga/Web/View/Helper/IcingaCheckbox.php @@ -0,0 +1,30 @@ +<?php +/* Icinga Web 2 | (c) 2019 Icinga GmbH | GPLv2+ */ + +namespace Icinga\Web\View\Helper; + +class IcingaCheckbox extends \Zend_View_Helper_FormCheckbox +{ + public function icingaCheckbox($name, $value = null, $attribs = null, array $checkedOptions = null) + { + if (! isset($attribs['id'])) { + $attribs['id'] = $this->view->protectId('icingaCheckbox_' . $name); + } + + $attribs['class'] = (isset($attribs['class']) ? $attribs['class'] . ' ' : '') . 'sr-only'; + $html = parent::formCheckbox($name, $value, $attribs, $checkedOptions); + + $class = 'toggle-switch'; + if (isset($attribs['disabled'])) { + $class .= ' disabled'; + } + + return $html + . '<label for="' + . $attribs['id'] + . '" aria-hidden="true"' + . ' class="' + . $class + . '"><span class="toggle-slider"></span></label>'; + } +} diff --git a/library/Icinga/Web/View/PrivilegeAudit.php b/library/Icinga/Web/View/PrivilegeAudit.php new file mode 100644 index 0000000..fcb4083 --- /dev/null +++ b/library/Icinga/Web/View/PrivilegeAudit.php @@ -0,0 +1,622 @@ +<?php +/* Icinga Web 2 | (c) 2021 Icinga GmbH | GPLv2+ */ + +namespace Icinga\Web\View; + +use Icinga\Authentication\Role; +use Icinga\Forms\Security\RoleForm; +use Icinga\Util\StringHelper; +use ipl\Html\Attributes; +use ipl\Html\BaseHtmlElement; +use ipl\Html\HtmlElement; +use ipl\Html\HtmlString; +use ipl\Html\Text; +use ipl\Stdlib\Filter; +use ipl\Web\Common\BaseTarget; +use ipl\Web\Filter\QueryString; +use ipl\Web\Url; +use ipl\Web\Widget\Icon; +use ipl\Web\Widget\Link; + +class PrivilegeAudit extends BaseHtmlElement +{ + use BaseTarget; + + /** @var string */ + const UNRESTRICTED_PERMISSION = 'unrestricted'; + + protected $tag = 'ul'; + + protected $defaultAttributes = ['class' => 'privilege-audit']; + + /** @var Role[] */ + protected $roles; + + public function __construct(array $roles) + { + $this->roles = $roles; + $this->setBaseTarget('_next'); + } + + protected function auditPermission($permission) + { + $grantedBy = []; + $refusedBy = []; + foreach ($this->roles as $role) { + if ($permission === self::UNRESTRICTED_PERMISSION) { + if ($role->isUnrestricted()) { + $grantedBy[] = $role->getName(); + } + } elseif ($role->denies($permission)) { + $refusedBy[] = $role->getName(); + } elseif ($role->grants($permission, false, false)) { + $grantedBy[] = $role->getName(); + } + } + + $header = new HtmlElement('summary'); + if (! empty($refusedBy)) { + $header->add([ + new Icon('times-circle', ['class' => 'refused']), + count($refusedBy) > 2 + ? sprintf( + tp( + 'Refused by %s and %s as well as one other', + 'Refused by %s and %s as well as %d others', + count($refusedBy) - 2 + ), + $refusedBy[0], + $refusedBy[1], + count($refusedBy) - 2 + ) + : sprintf( + tp('Refused by %s', 'Refused by %s and %s', count($refusedBy)), + ...$refusedBy + ) + ]); + } elseif (! empty($grantedBy)) { + $header->add([ + new Icon('check-circle', ['class' => 'granted']), + count($grantedBy) > 2 + ? sprintf( + tp( + 'Granted by %s and %s as well as one other', + 'Granted by %s and %s as well as %d others', + count($grantedBy) - 2 + ), + $grantedBy[0], + $grantedBy[1], + count($grantedBy) - 2 + ) + : sprintf( + tp('Granted by %s', 'Granted by %s and %s', count($grantedBy)), + ...$grantedBy + ) + ]); + } else { + $header->add([new Icon('minus-circle'), t('Not granted or refused by any role')]); + } + + $vClass = null; + $rolePaths = []; + foreach (array_reverse($this->roles) as $role) { + if (! in_array($role->getName(), $refusedBy, true) && ! in_array($role->getName(), $grantedBy, true)) { + continue; + } + + /** @var Role[] $rolesReversed */ + $rolesReversed = []; + + do { + array_unshift($rolesReversed, $role); + } while (($role = $role->getParent()) !== null); + + $path = new HtmlElement('ol'); + + $class = null; + $setInitiator = false; + foreach ($rolesReversed as $role) { + $granted = false; + $refused = false; + $icon = new Icon('minus-circle'); + if ($permission === self::UNRESTRICTED_PERMISSION) { + if ($role->isUnrestricted()) { + $granted = true; + $icon = new Icon('check-circle', ['class' => 'granted']); + } + } elseif ($role->denies($permission, true)) { + $refused = true; + $icon = new Icon('times-circle', ['class' => 'refused']); + } elseif ($role->grants($permission, true, false)) { + $granted = true; + $icon = new Icon('check-circle', ['class' => 'granted']); + } + + $connector = null; + if ($role->getParent() !== null) { + $connector = HtmlElement::create('li', ['class' => ['connector', $class]]); + if ($setInitiator) { + $setInitiator = false; + $connector->getAttributes()->add('class', 'initiator'); + } + + $path->prependHtml($connector); + } + + $path->prependHtml(new HtmlElement('li', Attributes::create([ + 'class' => ['role', $class], + 'title' => $role->getName() + ]), new Link([$icon, $role->getName()], Url::fromPath('role/edit', ['role' => $role->getName()])))); + + if ($refused) { + $setInitiator = $class !== 'refused'; + $class = 'refused'; + } elseif ($granted) { + $setInitiator = $class === null; + $class = $class ?: 'granted'; + } + } + + if ($vClass === null || $vClass === 'granted') { + $vClass = $class; + } + + array_unshift($rolePaths, $path->prepend([ + empty($rolePaths) ? null : HtmlElement::create('li', ['class' => ['vertical-line', $vClass]]), + new HtmlElement('li', Attributes::create(['class' => [ + 'connector', + $class, + $setInitiator ? 'initiator' : null + ]])) + ])); + } + + if (empty($rolePaths)) { + return [ + empty($refusedBy) ? (empty($grantedBy) ? null : true) : false, + new HtmlElement( + 'div', + Attributes::create(['class' => 'inheritance-paths']), + $header->setTag('div') + ) + ]; + } + + return [ + empty($refusedBy) ? (empty($grantedBy) ? null : true) : false, + HtmlElement::create('details', [ + 'class' => ['collapsible', 'inheritance-paths'], + 'data-no-persistence' => true, + 'open' => getenv('ICINGAWEB_EXPORT_FORMAT') === 'pdf' + ], [ + $header->addAttributes(['class' => 'collapsible-control']), + $rolePaths + ]) + ]; + } + + protected function auditRestriction($restriction) + { + $restrictedBy = []; + $restrictions = []; + foreach ($this->roles as $role) { + if ($role->isUnrestricted()) { + $restrictedBy = []; + $restrictions = []; + break; + } + + foreach ($this->collectRestrictions($role, $restriction) as $role => $roleRestriction) { + $restrictedBy[] = $role; + $restrictions[] = $roleRestriction; + } + } + + $header = new HtmlElement('summary'); + if (! empty($restrictedBy)) { + $header->add([ + new Icon('filter', ['class' => 'restricted']), + count($restrictedBy) > 2 + ? sprintf( + tp( + 'Restricted by %s and %s as well as one other', + 'Restricted by %s and %s as well as %d others', + count($restrictedBy) - 2 + ), + $restrictedBy[0]->getName(), + $restrictedBy[1]->getName(), + count($restrictedBy) - 2 + ) + : sprintf( + tp('Restricted by %s', 'Restricted by %s and %s', count($restrictedBy)), + ...array_map(function ($role) { + return $role->getName(); + }, $restrictedBy) + ) + ]); + } else { + $header->add([new Icon('filter'), t('Not restricted by any role')]); + } + + $roles = []; + if (! empty($restrictions) && count($restrictions) > 1) { + list($combinedRestrictions, $combinedLinks) = $this->createRestrictionLinks($restriction, $restrictions); + $roles[] = HtmlElement::create('li', null, [ + new HtmlElement( + 'div', + Attributes::create(['class' => 'flex-overflow']), + HtmlElement::create('span', [ + 'class' => 'role', + 'title' => t('All roles combined') + ], join(' | ', array_map(function ($role) { + return $role->getName(); + }, $restrictedBy))), + HtmlElement::create('code', ['class' => 'restriction'], $combinedRestrictions) + ), + $combinedLinks ? new HtmlElement( + 'div', + Attributes::create(['class' => 'previews']), + HtmlElement::create('em', null, t('Previews:')), + $combinedLinks + ) : null + ]); + } + + foreach ($restrictedBy as $role) { + list($roleRestriction, $restrictionLinks) = $this->createRestrictionLinks( + $restriction, + [$role->getRestrictions($restriction)] + ); + + $roles[] = HtmlElement::create('li', null, [ + new HtmlElement( + 'div', + Attributes::create(['class' => 'flex-overflow']), + new Link($role->getName(), Url::fromPath('role/edit', ['role' => $role->getName()]), [ + 'class' => 'role', + 'title' => $role->getName() + ]), + HtmlElement::create('code', ['class' => 'restriction'], $roleRestriction) + ), + $restrictionLinks ? new HtmlElement( + 'div', + Attributes::create(['class' => 'previews']), + HtmlElement::create('em', null, t('Previews:')), + $restrictionLinks + ) : null + ]); + } + + if (empty($roles)) { + return [ + ! empty($restrictedBy), + new HtmlElement( + 'div', + Attributes::create(['class' => 'restrictions']), + $header->setTag('div') + ) + ]; + } + + return [ + ! empty($restrictedBy), + new HtmlElement( + 'details', + Attributes::create([ + 'class' => ['collapsible', 'restrictions'], + 'data-no-persistence' => true, + 'open' => getenv('ICINGAWEB_EXPORT_FORMAT') === 'pdf' + ]), + $header->addAttributes(['class' => 'collapsible-control']), + new HtmlElement('ul', null, ...$roles) + ) + ]; + } + + protected function assemble() + { + list($permissions, $restrictions) = RoleForm::collectProvidedPrivileges(); + list($wildcardState, $wildcardAudit) = $this->auditPermission('*'); + list($unrestrictedState, $unrestrictedAudit) = $this->auditPermission(self::UNRESTRICTED_PERMISSION); + + $this->addHtml(new HtmlElement( + 'li', + null, + new HtmlElement( + 'details', + Attributes::create([ + 'class' => ['collapsible', 'privilege-section'], + 'open' => ($wildcardState || $unrestrictedState) && getenv('ICINGAWEB_EXPORT_FORMAT') === 'pdf' + ]), + new HtmlElement( + 'summary', + Attributes::create(['class' => [ + 'collapsible-control', // Helps JS, improves performance a bit + ]]), + new HtmlElement('span', null, Text::create(t('Administrative Privileges'))), + HtmlElement::create( + 'span', + ['class' => 'audit-preview'], + $wildcardState || $unrestrictedState + ? new Icon('check-circle', ['class' => 'granted']) + : null + ), + new Icon('angles-down', ['class' => 'collapse-icon']), + new Icon('angles-left', ['class' => 'expand-icon']) + ), + new HtmlElement( + 'ol', + Attributes::create(['class' => 'privilege-list']), + new HtmlElement( + 'li', + null, + HtmlElement::create('p', ['class' => 'privilege-label'], t('Administrative Access')), + HtmlElement::create('div', ['class' => 'spacer']), + $wildcardAudit + ), + new HtmlElement( + 'li', + null, + HtmlElement::create('p', ['class' => 'privilege-label'], t('Unrestricted Access')), + HtmlElement::create('div', ['class' => 'spacer']), + $unrestrictedAudit + ) + ) + ) + )); + + $privilegeSources = array_unique(array_merge(array_keys($permissions), array_keys($restrictions))); + foreach ($privilegeSources as $source) { + $anythingGranted = false; + $anythingRefused = false; + $anythingRestricted = false; + + $permissionList = new HtmlElement('ol', Attributes::create(['class' => 'privilege-list'])); + foreach (isset($permissions[$source]) ? $permissions[$source] : [] as $permission => $metaData) { + list($permissionState, $permissionAudit) = $this->auditPermission($permission); + if ($permissionState !== null) { + if ($permissionState) { + $anythingGranted = true; + } else { + $anythingRefused = true; + } + } + + $permissionList->addHtml(new HtmlElement( + 'li', + null, + HtmlElement::create( + 'p', + ['class' => 'privilege-label'], + isset($metaData['label']) + ? $metaData['label'] + : array_map(function ($segment) { + return $segment[0] === '/' ? [ + // Adds a zero-width char after each slash to help browsers break onto newlines + new HtmlString('/​'), + HtmlElement::create('span', ['class' => 'no-wrap'], substr($segment, 1)) + ] : HtmlElement::create('em', null, $segment); + }, preg_split( + '~(/[^/]+)~', + $permission, + -1, + PREG_SPLIT_DELIM_CAPTURE|PREG_SPLIT_NO_EMPTY + )) + ), + new HtmlElement('div', Attributes::create(['class' => 'spacer'])), + $permissionAudit + )); + } + + $restrictionList = new HtmlElement('ol', Attributes::create(['class' => 'privilege-list'])); + foreach (isset($restrictions[$source]) ? $restrictions[$source] : [] as $restriction => $metaData) { + list($restrictionState, $restrictionAudit) = $this->auditRestriction($restriction); + if ($restrictionState) { + $anythingRestricted = true; + } + + $restrictionList->addHtml(new HtmlElement( + 'li', + null, + HtmlElement::create( + 'p', + ['class' => 'privilege-label'], + isset($metaData['label']) + ? $metaData['label'] + : array_map(function ($segment) { + return $segment[0] === '/' ? [ + // Adds a zero-width char after each slash to help browsers break onto newlines + new HtmlString('/​'), + HtmlElement::create('span', ['class' => 'no-wrap'], substr($segment, 1)) + ] : HtmlElement::create('em', null, $segment); + }, preg_split( + '~(/[^/]+)~', + $restriction, + -1, + PREG_SPLIT_DELIM_CAPTURE|PREG_SPLIT_NO_EMPTY + )) + ), + new HtmlElement('div', Attributes::create(['class' => 'spacer'])), + $restrictionAudit + )); + } + + if ($source === 'application') { + $label = 'Icinga Web 2'; + } else { + $label = [$source, ' ', HtmlElement::create('em', null, t('Module'))]; + } + + $this->addHtml(new HtmlElement( + 'li', + null, + HtmlElement::create('details', [ + 'class' => ['collapsible', 'privilege-section'], + 'open' => ($anythingGranted || $anythingRefused || $anythingRestricted) + && getenv('ICINGAWEB_EXPORT_FORMAT') === 'pdf' + ], [ + new HtmlElement( + 'summary', + Attributes::create(['class' => [ + 'collapsible-control', // Helps JS, improves performance a bit + ]]), + HtmlElement::create('span', null, $label), + HtmlElement::create('span', ['class' => 'audit-preview'], [ + $anythingGranted ? new Icon('check-circle', ['class' => 'granted']) : null, + $anythingRefused ? new Icon('times-circle', ['class' => 'refused']) : null, + $anythingRestricted ? new Icon('filter', ['class' => 'restricted']) : null + ]), + new Icon('angles-down', ['class' => 'collapse-icon']), + new Icon('angles-left', ['class' => 'expand-icon']) + ), + $permissionList->isEmpty() ? null : [ + HtmlElement::create('h4', null, t('Permissions')), + $permissionList + ], + $restrictionList->isEmpty() ? null : [ + HtmlElement::create('h4', null, t('Restrictions')), + $restrictionList + ] + ]) + )); + } + } + + private function collectRestrictions(Role $role, $restrictionName) + { + do { + $restriction = $role->getRestrictions($restrictionName); + if ($restriction) { + yield $role => $restriction; + } + } while (($role = $role->getParent()) !== null); + } + + private function createRestrictionLinks($restrictionName, array $restrictions) + { + // TODO: Remove this hardcoded mess. Do this based on the restriction's meta data + switch ($restrictionName) { + case 'icingadb/filter/objects': + $filterString = join('|', $restrictions); + $list = new HtmlElement( + 'ul', + Attributes::create(['class' => 'links']), + new HtmlElement('li', null, new Link( + 'icingadb/hosts', + Url::fromPath('icingadb/hosts')->setQueryString($filterString) + )), + new HtmlElement('li', null, new Link( + 'icingadb/services', + Url::fromPath('icingadb/services')->setQueryString($filterString) + )), + new HtmlElement('li', null, new Link( + 'icingadb/hostgroups', + Url::fromPath('icingadb/hostgroups')->setQueryString($filterString) + )), + new HtmlElement('li', null, new Link( + 'icingadb/servicegroups', + Url::fromPath('icingadb/servicegroups')->setQueryString($filterString) + )) + ); + + break; + case 'icingadb/filter/hosts': + $filterString = join('|', $restrictions); + $list = new HtmlElement( + 'ul', + Attributes::create(['class' => 'links']), + new HtmlElement('li', null, new Link( + 'icingadb/hosts', + Url::fromPath('icingadb/hosts')->setQueryString($filterString) + )), + new HtmlElement('li', null, new Link( + 'icingadb/services', + Url::fromPath('icingadb/services')->setQueryString($filterString) + )) + ); + + break; + case 'icingadb/filter/services': + $filterString = join('|', $restrictions); + $list = new HtmlElement( + 'ul', + Attributes::create(['class' => 'links']), + new HtmlElement('li', null, new Link( + 'icingadb/services', + Url::fromPath('icingadb/services')->setQueryString($filterString) + )) + ); + + break; + case 'monitoring/filter/objects': + $filterString = join('|', $restrictions); + $list = new HtmlElement( + 'ul', + Attributes::create(['class' => 'links']), + new HtmlElement('li', null, new Link( + 'monitoring/list/hosts', + Url::fromPath('monitoring/list/hosts')->setQueryString($filterString) + )), + new HtmlElement('li', null, new Link( + 'monitoring/list/services', + Url::fromPath('monitoring/list/services')->setQueryString($filterString) + )), + new HtmlElement('li', null, new Link( + 'monitoring/list/hostgroups', + Url::fromPath('monitoring/list/hostgroups')->setQueryString($filterString) + )), + new HtmlElement('li', null, new Link( + 'monitoring/list/servicegroups', + Url::fromPath('monitoring/list/servicegroups')->setQueryString($filterString) + )) + ); + + break; + case 'application/share/users': + $filter = Filter::any(); + foreach ($restrictions as $roleRestriction) { + $userNames = StringHelper::trimSplit($roleRestriction); + foreach ($userNames as $userName) { + $filter->add(Filter::equal('user_name', $userName)); + } + } + + $filterString = QueryString::render($filter); + $list = new HtmlElement( + 'ul', + Attributes::create(['class' => 'links']), + new HtmlElement('li', null, new Link( + 'user/list', + Url::fromPath('user/list')->setQueryString($filterString) + )) + ); + + break; + case 'application/share/groups': + $filter = Filter::any(); + foreach ($restrictions as $roleRestriction) { + $groupNames = StringHelper::trimSplit($roleRestriction); + foreach ($groupNames as $groupName) { + $filter->add(Filter::equal('group_name', $groupName)); + } + } + + $filterString = QueryString::render($filter); + $list = new HtmlElement( + 'ul', + Attributes::create(['class' => 'links']), + new HtmlElement('li', null, new Link( + 'group/list', + Url::fromPath('group/list')->setQueryString($filterString) + )) + ); + + break; + default: + $filterString = join(', ', $restrictions); + $list = null; + } + + return [$filterString, $list]; + } +} diff --git a/library/Icinga/Web/View/helpers/format.php b/library/Icinga/Web/View/helpers/format.php new file mode 100644 index 0000000..4008583 --- /dev/null +++ b/library/Icinga/Web/View/helpers/format.php @@ -0,0 +1,72 @@ +<?php +/* Icinga Web 2 | (c) 2014 Icinga Development Team | GPLv2+ */ + +namespace Icinga\Web\View; + +use Icinga\Date\DateFormatter; +use Icinga\Util\Format; + +$this->addHelperFunction('format', function () { + return Format::getInstance(); +}); + +$this->addHelperFunction('formatDate', function ($date) { + if (! $date) { + return ''; + } + return DateFormatter::formatDate($date); +}); + +$this->addHelperFunction('formatDateTime', function ($dateTime) { + if (! $dateTime) { + return ''; + } + return DateFormatter::formatDateTime($dateTime); +}); + +$this->addHelperFunction('formatDuration', function ($seconds) { + if (! $seconds) { + return ''; + } + return DateFormatter::formatDuration($seconds); +}); + +$this->addHelperFunction('formatTime', function ($time) { + if (! $time) { + return ''; + } + return DateFormatter::formatTime($time); +}); + +$this->addHelperFunction('timeAgo', function ($time, $timeOnly = false, $requireTime = false) { + if (! $time) { + return ''; + } + return sprintf( + '<span class="relative-time time-ago" title="%s">%s</span>', + DateFormatter::formatDateTime($time), + DateFormatter::timeAgo($time, $timeOnly, $requireTime) + ); +}); + +$this->addHelperFunction('timeSince', function ($time, $timeOnly = false, $requireTime = false) { + if (! $time) { + return ''; + } + return sprintf( + '<span class="relative-time time-since" title="%s">%s</span>', + DateFormatter::formatDateTime($time), + DateFormatter::timeSince($time, $timeOnly, $requireTime) + ); +}); + +$this->addHelperFunction('timeUntil', function ($time, $timeOnly = false, $requireTime = false) { + if (! $time) { + return ''; + } + return sprintf( + '<span class="relative-time time-until" title="%s">%s</span>', + DateFormatter::formatDateTime($time), + DateFormatter::timeUntil($time, $timeOnly, $requireTime) + ); +}); diff --git a/library/Icinga/Web/View/helpers/generic.php b/library/Icinga/Web/View/helpers/generic.php new file mode 100644 index 0000000..bfd3f86 --- /dev/null +++ b/library/Icinga/Web/View/helpers/generic.php @@ -0,0 +1,15 @@ +<?php +/* Icinga Web 2 | (c) 2014 Icinga Development Team | GPLv2+ */ + +namespace Icinga\Web\View; + +use Icinga\Authentication\Auth; +use Icinga\Web\Widget; + +$this->addHelperFunction('auth', function () { + return Auth::getInstance(); +}); + +$this->addHelperFunction('widget', function ($name, $options = null) { + return Widget::create($name, $options); +}); diff --git a/library/Icinga/Web/View/helpers/string.php b/library/Icinga/Web/View/helpers/string.php new file mode 100644 index 0000000..b3f667b --- /dev/null +++ b/library/Icinga/Web/View/helpers/string.php @@ -0,0 +1,36 @@ +<?php +/* Icinga Web 2 | (c) 2015 Icinga Development Team | GPLv2+ */ + +namespace Icinga\Web\View; + +use Icinga\Util\StringHelper; +use Icinga\Web\Helper\Markdown; + +$this->addHelperFunction('ellipsis', function ($string, $maxLength, $ellipsis = '...') { + return StringHelper::ellipsis($string, $maxLength, $ellipsis); +}); + +$this->addHelperFunction('nl2br', function ($string) { + return nl2br(str_replace(array('\r\n', '\r', '\n'), '<br>', $string), false); +}); + +$this->addHelperFunction('markdown', function ($content, $containerAttribs = null) { + if (! isset($containerAttribs['class'])) { + $containerAttribs['class'] = 'markdown'; + } else { + $containerAttribs['class'] .= ' markdown'; + } + + return '<section' . $this->propertiesToString($containerAttribs) . '>' . Markdown::text($content) . '</section>'; +}); + +$this->addHelperFunction('markdownLine', function ($content, $containerAttribs = null) { + if (! isset($containerAttribs['class'])) { + $containerAttribs['class'] = 'markdown inline'; + } else { + $containerAttribs['class'] .= ' markdown inline'; + } + + return '<section' . $this->propertiesToString($containerAttribs) . '>' . + Markdown::line($content) . '</section>'; +}); diff --git a/library/Icinga/Web/View/helpers/url.php b/library/Icinga/Web/View/helpers/url.php new file mode 100644 index 0000000..277c237 --- /dev/null +++ b/library/Icinga/Web/View/helpers/url.php @@ -0,0 +1,158 @@ +<?php +/* Icinga Web 2 | (c) 2013 Icinga Development Team | GPLv2+ */ + +namespace Icinga\Web\View; + +use Icinga\Web\Url; +use Icinga\Exception\ProgrammingError; + +$view = $this; + +$this->addHelperFunction('href', function ($path = null, $params = null) use ($view) { + return $view->url($path, $params); +}); + +$this->addHelperFunction('url', function ($path = null, $params = null) { + if ($path === null) { + $url = Url::fromRequest(); + } elseif ($path instanceof Url) { + $url = $path; + } else { + $url = Url::fromPath($path); + } + + if ($params !== null) { + if ($url === $path) { + $url = clone $url; + } + + $url->overwriteParams($params); + } + + return $url; +}); + +$this->addHelperFunction( + 'qlink', + function ($title, $url, $params = null, $properties = null, $escape = true) use ($view) { + $icon = ''; + if ($properties) { + if (array_key_exists('title', $properties) && !array_key_exists('aria-label', $properties)) { + $properties['aria-label'] = $properties['title']; + } + + if (array_key_exists('icon', $properties)) { + $icon = $view->icon($properties['icon']); + unset($properties['icon']); + } + + if (array_key_exists('img', $properties)) { + $icon = $view->img($properties['img']); + unset($properties['img']); + } + } + + return sprintf( + '<a href="%s"%s>%s</a>', + $view->url($url, $params), + $view->propertiesToString($properties), + $icon . ($escape ? $view->escape($title) : $title) + ); + } +); + +$this->addHelperFunction('img', function ($url, $params = null, array $properties = array()) use ($view) { + if (! array_key_exists('alt', $properties)) { + $properties['alt'] = ''; + } + + $ariaHidden = array_key_exists('aria-hidden', $properties) ? $properties['aria-hidden'] : null; + if (array_key_exists('title', $properties)) { + if (! array_key_exists('aria-label', $properties) && $ariaHidden !== 'true') { + $properties['aria-label'] = $properties['title']; + } + } elseif ($ariaHidden === null) { + $properties['aria-hidden'] = 'true'; + } + + return sprintf( + '<img src="%s"%s />', + $view->escape($view->url($url, $params)->getAbsoluteUrl()), + $view->propertiesToString($properties) + ); +}); + +$this->addHelperFunction('icon', function ($img, $title = null, array $properties = array()) use ($view) { + if (strpos($img, '.') !== false) { + if (array_key_exists('class', $properties)) { + $properties['class'] .= ' icon'; + } else { + $properties['class'] = 'icon'; + } + if (strpos($img, '/') === false) { + return $view->img('img/icons/' . $img, null, $properties); + } else { + return $view->img($img, null, $properties); + } + } + + $ariaHidden = array_key_exists('aria-hidden', $properties) ? $properties['aria-hidden'] : null; + if ($title !== null) { + $properties['role'] = 'img'; + $properties['title'] = $title; + + if (! array_key_exists('aria-label', $properties) && $ariaHidden !== 'true') { + $properties['aria-label'] = $title; + } + } elseif ($ariaHidden === null) { + $properties['aria-hidden'] = 'true'; + } + + if (isset($properties['class'])) { + $properties['class'] .= ' icon-' . $img; + } else { + $properties['class'] = 'icon-' . $img; + } + + return sprintf('<i %s></i>', $view->propertiesToString($properties)); +}); + +$this->addHelperFunction('propertiesToString', function ($properties) use ($view) { + if (empty($properties)) { + return ''; + } + $attributes = array(); + + foreach ($properties as $key => $val) { + if ($key === 'style' && is_array($val)) { + if (empty($val)) { + continue; + } + $parts = array(); + foreach ($val as $k => $v) { + $parts[] = "$k: $v"; + } + $val = implode('; ', $parts); + continue; + } + + $attributes[] = $view->attributeToString($key, $val); + } + return ' ' . implode(' ', $attributes); +}); + +$this->addHelperFunction('attributeToString', function ($key, $value) use ($view) { + // TODO: Doublecheck this! + if (! preg_match('~^[a-zA-Z0-9-]+$~', $key)) { + throw new ProgrammingError( + 'Trying to set an invalid HTML attribute name: %s', + $key + ); + } + + return sprintf( + '%s="%s"', + $key, + $view->escape($value) + ); +}); diff --git a/library/Icinga/Web/Widget.php b/library/Icinga/Web/Widget.php new file mode 100644 index 0000000..48ae7bd --- /dev/null +++ b/library/Icinga/Web/Widget.php @@ -0,0 +1,49 @@ +<?php +/* Icinga Web 2 | (c) 2013 Icinga Development Team | GPLv2+ */ + +namespace Icinga\Web; + +use Icinga\Exception\ProgrammingError; +use Icinga\Web\Widget\AbstractWidget; + +/** + * Web widgets make things easier for you! + * + * This class provides nothing but a static factory method for widget creation. + * Usually it will not be used directly as there are widget()-helpers available + * in your action controllers and view scripts. + * + * Usage example: + * <code> + * $tabs = Widget::create('tabs'); + * </code> + * + * @copyright Copyright (c) 2013 Icinga-Web Team <info@icinga.com> + * @author Icinga-Web Team <info@icinga.com> + * @license http://www.gnu.org/copyleft/gpl.html GNU General Public License + */ +class Widget +{ + /** + * Create a new widget + * + * @param string $name Widget name + * @param array $options Widget constructor options + * + * @return AbstractWidget + */ + public static function create($name, $options = array(), $module_name = null) + { + $class = 'Icinga\\Web\\Widget\\' . ucfirst($name); + + if (! class_exists($class)) { + throw new ProgrammingError( + 'There is no such widget: %s', + $name + ); + } + + $widget = new $class($options, $module_name); + return $widget; + } +} diff --git a/library/Icinga/Web/Widget/AbstractWidget.php b/library/Icinga/Web/Widget/AbstractWidget.php new file mode 100644 index 0000000..1090548 --- /dev/null +++ b/library/Icinga/Web/Widget/AbstractWidget.php @@ -0,0 +1,121 @@ +<?php +/* Icinga Web 2 | (c) 2013 Icinga Development Team | GPLv2+ */ + +namespace Icinga\Web\Widget; + +use Icinga\Exception\ProgrammingError; +use Icinga\Application\Icinga; +use Exception; +use Zend_View_Abstract; + +/** + * Web widgets MUST extend this class + * + * AbstractWidget implements getters and setters for widget options stored in + * the protected options array. If you want to allow options for your own + * widget, you have to set a default value (may be null) for each single option + * in this array. + * + * Please have a look at the available widgets in this folder to get a better + * idea on what they should look like. + * + * @copyright Copyright (c) 2013 Icinga-Web Team <info@icinga.com> + * @author Icinga-Web Team <info@icinga.com> + * @license http://www.gnu.org/copyleft/gpl.html GNU General Public License + */ +abstract class AbstractWidget +{ + /** + * If you are going to access the current view with the view() function, + * its instance is stored here for performance reasons. + * + * @var Zend_View_Abstract + */ + protected static $view; + + // TODO: Should we kick this? + protected $properties = array(); + + /** + * Getter for widget properties + * + * @param string $key The option you're interested in + * + * @throws ProgrammingError for unknown property name + * + * @return mixed + */ + public function __get($key) + { + if (array_key_exists($key, $this->properties)) { + return $this->properties[$key]; + } + + throw new ProgrammingError( + 'Trying to get invalid "%s" property for %s', + $key, + get_class($this) + ); + } + + /** + * Setter for widget properties + * + * @param string $key The option you want to set + * @param string $val The new value going to be assigned to this option + * + * @throws ProgrammingError for unknown property name + * + * @return mixed + */ + public function __set($key, $val) + { + if (array_key_exists($key, $this->properties)) { + $this->properties[$key] = $val; + return; + } + + throw new ProgrammingError( + 'Trying to set invalid "%s" property in %s. Allowed are: %s', + $key, + get_class($this), + empty($this->properties) + ? 'none' + : implode(', ', array_keys($this->properties)) + ); + } + + abstract public function render(); + + /** + * Access the current view + * + * Will instantiate a new one if none exists + * // TODO: App->getView + * + * @return Zend_View_Abstract + */ + protected function view() + { + if (self::$view === null) { + self::$view = Icinga::app()->getViewRenderer()->view; + } + + return self::$view; + } + + /** + * Cast this widget to a string. Will call your render() function + * + * @return string + */ + public function __toString() + { + try { + $html = $this->render(); + } catch (Exception $e) { + return htmlspecialchars($e->getMessage()); + } + return (string) $html; + } +} diff --git a/library/Icinga/Web/Widget/Announcements.php b/library/Icinga/Web/Widget/Announcements.php new file mode 100644 index 0000000..e0fac77 --- /dev/null +++ b/library/Icinga/Web/Widget/Announcements.php @@ -0,0 +1,55 @@ +<?php +/* Icinga Web 2 | (c) 2016 Icinga Development Team | GPLv2+ */ + +namespace Icinga\Web\Widget; + +use Icinga\Application\Icinga; +use Icinga\Data\Filter\Filter; +use Icinga\Forms\Announcement\AcknowledgeAnnouncementForm; +use Icinga\Web\Announcement\AnnouncementCookie; +use Icinga\Web\Announcement\AnnouncementIniRepository; +use Icinga\Web\Helper\Markdown; + +/** + * Render announcements + */ +class Announcements extends AbstractWidget +{ + /** + * {@inheritdoc} + */ + public function render() + { + $repo = new AnnouncementIniRepository(); + $etag = $repo->getEtag(); + $cookie = new AnnouncementCookie(); + if ($cookie->getEtag() !== $etag) { + $cookie->setEtag($etag); + $cookie->setNextActive($repo->findNextActive()); + Icinga::app()->getResponse()->setCookie($cookie); + } + $acked = array(); + foreach ($cookie->getAcknowledged() as $hash) { + $acked[] = Filter::expression('hash', '!=', $hash); + } + $acked = Filter::matchAll($acked); + $announcements = $repo->findActive(); + $announcements->applyFilter($acked); + if ($announcements->hasResult()) { + $html = '<ul role="alert">'; + foreach ($announcements as $announcement) { + $ackForm = new AcknowledgeAnnouncementForm(); + $ackForm->populate(array('hash' => $announcement->hash)); + $html .= '<li><div class="message">' + . Markdown::text($announcement->message) + . '</div>' + . $ackForm + . '</li>'; + } + $html .= '</ul>'; + return $html; + } + // Force container update on XHR + return '<div hidden></div>'; + } +} diff --git a/library/Icinga/Web/Widget/ApplicationStateMessages.php b/library/Icinga/Web/Widget/ApplicationStateMessages.php new file mode 100644 index 0000000..99d3bb2 --- /dev/null +++ b/library/Icinga/Web/Widget/ApplicationStateMessages.php @@ -0,0 +1,74 @@ +<?php +/* Icinga Web 2 | (c) 2018 Icinga Development Team | GPLv2+ */ + +namespace Icinga\Web\Widget; + +use Icinga\Application\Config; +use Icinga\Application\Hook\ApplicationStateHook; +use Icinga\Authentication\Auth; +use Icinga\Forms\AcknowledgeApplicationStateMessageForm; +use Icinga\Web\ApplicationStateCookie; +use Icinga\Web\Helper\Markdown; + +/** + * Render application state messages + */ +class ApplicationStateMessages extends AbstractWidget +{ + protected function getMessages() + { + $cookie = new ApplicationStateCookie(); + + $acked = array_flip($cookie->getAcknowledgedMessages()); + $messages = ApplicationStateHook::getAllMessages(); + + $active = array_diff_key($messages, $acked); + + return $active; + } + + public function render() + { + $enabled = Auth::getInstance() + ->getUser() + ->getPreferences() + ->getValue('icingaweb', 'show_application_state_messages', 'system'); + + if ($enabled === 'system') { + $enabled = Config::app()->get('global', 'show_application_state_messages', true); + } + + if (! (bool) $enabled) { + return '<div hidden></div>'; + } + + $active = $this->getMessages(); + + if (empty($active)) { + // Force container update on XHR + return '<div hidden></div>'; + } + + $html = '<div>'; + + reset($active); + + $id = key($active); + $spec = current($active); + $message = array_pop($spec); // We don't use state and timestamp here + + + $ackForm = new AcknowledgeApplicationStateMessageForm(); + $ackForm->populate(['id' => $id]); + + $html .= '<section class="markdown">'; + $html .= Markdown::text($message); + $html .= '</section>'; + + $html .= $ackForm; + + $html .= '</div>'; + + return $html; + } +} diff --git a/library/Icinga/Web/Widget/Chart/HistoryColorGrid.php b/library/Icinga/Web/Widget/Chart/HistoryColorGrid.php new file mode 100644 index 0000000..b7b50d0 --- /dev/null +++ b/library/Icinga/Web/Widget/Chart/HistoryColorGrid.php @@ -0,0 +1,400 @@ +<?php +/* Icinga Web 2 | (c) 2014 Icinga Development Team | GPLv2+ */ + +namespace Icinga\Web\Widget\Chart; + +use DateInterval; +use DateTime; +use Icinga\Util\Color; +use Icinga\Util\Csp; +use Icinga\Web\Widget\AbstractWidget; +use ipl\Web\Style; + +/** + * Display a colored grid that visualizes a set of values for each day + * on a given time-frame. + */ +class HistoryColorGrid extends AbstractWidget +{ + const CAL_GROW_INTO_PAST = 'past'; + const CAL_GROW_INTO_PRESENT = 'present'; + + const ORIENTATION_VERTICAL = 'vertical'; + const ORIENTATION_HORIZONTAL = 'horizontal'; + + public $weekFlow = self::CAL_GROW_INTO_PAST; + public $orientation = self::ORIENTATION_VERTICAL; + public $weekStartMonday = true; + + private $maxValue = 1; + + private $start = null; + private $end = null; + private $data = array(); + private $color; + public $opacity = 1.0; + + /** @var array<string, array<string, string>> History grid css rulesets */ + protected $rulesets = []; + + public function __construct($color = '#51e551', $start = null, $end = null) + { + $this->setColor($color); + if (isset($start)) { + $this->start = $this->tsToDateStr($start); + } + if (isset($end)) { + $this->end = $this->tsToDateStr($end); + } + } + + /** + * Set the displayed data-set + * + * @param $events array The history events to display as an array of arrays: + * value: The value to display + * caption: The caption on mouse-over + * url: The url to open on click. + */ + public function setData(array $events) + { + $this->data = $events; + $start = time(); + $end = time(); + foreach ($this->data as $entry) { + $entry['value'] = intval($entry['value']); + } + foreach ($this->data as $date => $entry) { + $time = strtotime($date); + if ($entry['value'] > $this->maxValue) { + $this->maxValue = $entry['value']; + } + if ($time > $end) { + $end = $time; + } + if ($time < $start) { + $start = $time; + } + } + if (!isset($this->start)) { + $this->start = $this->tsToDateStr($start); + } + if (!isset($this->end)) { + $this->end = $this->tsToDateStr($end); + } + } + + /** + * Set the used color. + * + * @param $color + */ + public function setColor($color) + { + $this->color = $color; + } + + /** + * Set the used opacity + * + * @param $opacity + */ + public function setOpacity($opacity) + { + $this->opacity = $opacity; + } + + /** + * Calculate the color to display for the given value. + * + * @param $value integer + * + * @return string The color-string to use for this entry. + */ + private function calculateColor($value) + { + $saturation = $value / $this->maxValue; + return Color::changeSaturation($this->color, $saturation); + } + + /** + * Render the html to display the given $day + * + * @param $day string The day to display YYYY-MM-DD + * + * @return string The rendered html + */ + private function renderDay($day) + { + if (array_key_exists($day, $this->data) && $this->data[$day]['value'] > 0) { + $entry = $this->data[$day]; + $this->rulesets['.grid-day-with-entry-' . $entry['value']] = [ + 'background-color' => $this->calculateColor($entry['value']), + 'opacity' => $this->opacity + ]; + + return '<a class="grid-day-with-entry-' + . $entry['value'] + . '" ' + . 'aria-label="' . $entry['caption'] + . '" ' + . 'title="' . $entry['caption'] + . '" ' + . 'href="' . $entry['url'] + . '" ' + . '"></a>'; + } else { + if (! isset($this->rulesets['.grid-day-no-entry'])) { + $this->rulesets['.grid-day-no-entry'] = [ + 'background-color' => $this->calculateColor(0), + 'opacity' => $this->opacity + ]; + } + + return '<span class="grid-day-no-entry"' . ' title="No entries for ' . $day . '"></span>'; + } + } + + /** + * Render the grid with an horizontal alignment. + * + * @param array $grid The values returned from the createGrid function + * + * @return string The rendered html + */ + private function renderHorizontal($grid) + { + $weeks = $grid['weeks']; + $months = $grid['months']; + $years = $grid['years']; + $html = '<table class="historycolorgrid">'; + $html .= '<tr><th></th>'; + $old = -1; + foreach ($months as $week => $month) { + if ($old !== $month) { + $old = $month; + $txt = $this->monthName($month, $years[$week]); + } else { + $txt = ''; + } + $html .= '<th>' . $txt . '</th>'; + } + $html .= '</tr>'; + for ($i = 0; $i < 7; $i++) { + $html .= $this->renderWeekdayHorizontal($i, $weeks); + } + $html .= '</table>'; + return $html; + } + + /** + * @param $grid + * + * @return string + */ + private function renderVertical($grid) + { + $years = $grid['years']; + $weeks = $grid['weeks']; + $months = $grid['months']; + $html = '<table class="historycolorgrid">'; + $html .= '<tr>'; + for ($i = 0; $i < 7; $i++) { + $html .= '<th>' . $this->weekdayName($this->weekStartMonday ? $i + 1 : $i) . "</th>"; + } + $html .= '</tr>'; + $old = -1; + foreach ($weeks as $index => $week) { + for ($i = 0; $i < 7; $i++) { + if (array_key_exists($i, $week)) { + $html .= '<td>' . $this->renderDay($week[$i]) . '</td>'; + } else { + $html .= '<td></td>'; + } + } + if ($old !== $months[$index]) { + $old = $months[$index]; + $txt = $this->monthName($old, $years[$index]); + } else { + $txt = ''; + } + $html .= '<td class="weekday">' . $txt . '</td></tr>'; + } + $html .= '</table>'; + return $html; + } + + /** + * Render the row for the given weekday. + * + * @param integer $weekday The day to render (0-6) + * @param array $weeks The weeks + * + * @return string The formatted table-row + */ + private function renderWeekdayHorizontal($weekday, &$weeks) + { + $html = '<tr><td class="weekday">' + . $this->weekdayName($this->weekStartMonday ? $weekday + 1 : $weekday) + . '</td>'; + foreach ($weeks as $week) { + if (array_key_exists($weekday, $week)) { + $html .= '<td>' . $this->renderDay($week[$weekday]) . '</td>'; + } else { + $html .= '<td></td>'; + } + } + $html .= '</tr>'; + return $html; + } + + + + /** + * @return array + */ + private function createGrid() + { + $weeks = array(array()); + $week = 0; + $months = array(); + $years = array(); + $start = strtotime($this->start); + $year = intval(date('Y', $start)); + $month = intval(date('n', $start)); + $day = intval(date('j', $start)); + $weekday = intval(date('w', $start)); + if ($this->weekStartMonday) { + // 0 => monday, 6 => sunday + $weekday = $weekday === 0 ? 6 : $weekday - 1; + } + + $date = $this->toDateStr($day, $month, $year); + $weeks[0][$weekday] = $date; + $years[0] = $year; + $months[0] = $month; + while ($date !== $this->end) { + $day++; + $weekday++; + if ($weekday > 6) { + $weekday = 0; + $weeks[] = array(); + // PRESENT => The last day of week determines the month + if ($this->weekFlow === self::CAL_GROW_INTO_PRESENT) { + $months[$week] = $month; + $years[$week] = $year; + } + $week++; + } + if ($day > date('t', mktime(0, 0, 0, $month, 1, $year))) { + $month++; + if ($month > 12) { + $year++; + $month = 1; + } + $day = 1; + } + if ($weekday === 0) { + // PAST => The first day of each week determines the month + if ($this->weekFlow === self::CAL_GROW_INTO_PAST) { + $months[$week] = $month; + $years[$week] = $year; + } + } + $date = $this->toDateStr($day, $month, $year); + $weeks[$week][$weekday] = $date; + }; + $years[$week] = $year; + $months[$week] = $month; + if ($this->weekFlow == self::CAL_GROW_INTO_PAST) { + return array( + 'weeks' => array_reverse($weeks), + 'months' => array_reverse($months), + 'years' => array_reverse($years) + ); + } + return array( + 'weeks' => $weeks, + 'months' => $months, + 'years' => $years + ); + } + + /** + * Get the localized month-name for the given month + * + * @param integer $month The month-number + * + * @return string The + */ + private function monthName($month, $year) + { + // TODO: find a way to render years without messing up the layout + $dt = new DateTime($year . '-' . $month . '-01'); + return $dt->format('M'); + } + + /** + * @param $weekday + * + * @return string + */ + private function weekdayName($weekday) + { + $sun = new DateTime('last Sunday'); + $interval = new DateInterval('P' . $weekday . 'D'); + $sun->add($interval); + return substr($sun->format('D'), 0, 2); + } + + /** + * + * + * @param $timestamp + * + * @return bool|string + */ + private function tsToDateStr($timestamp) + { + return date('Y-m-d', $timestamp); + } + + /** + * @param $day + * @param $mon + * @param $year + * + * @return string + */ + private function toDateStr($day, $mon, $year) + { + $day = $day > 9 ? (string)$day : '0' . (string)$day; + $mon = $mon > 9 ? (string)$mon : '0' . (string)$mon; + return $year . '-' . $mon . '-' . $day; + } + + /** + * @return string + */ + public function render() + { + if (empty($this->data)) { + return '<div>No entries</div>'; + } + $grid = $this->createGrid(); + if ($this->orientation === self::ORIENTATION_HORIZONTAL) { + $html = $this->renderHorizontal($grid); + } else { + $html = $this->renderVertical($grid); + } + + $historyGridStyle = new Style(); + $historyGridStyle->setNonce(Csp::getStyleNonce()); + + foreach ($this->rulesets as $selector => $properties) { + $historyGridStyle->add($selector, $properties); + } + + return $html . $historyGridStyle; + } +} diff --git a/library/Icinga/Web/Widget/Chart/InlinePie.php b/library/Icinga/Web/Widget/Chart/InlinePie.php new file mode 100644 index 0000000..21b4ca4 --- /dev/null +++ b/library/Icinga/Web/Widget/Chart/InlinePie.php @@ -0,0 +1,257 @@ +<?php +/* Icinga Web 2 | (c) 2014 Icinga Development Team | GPLv2+ */ + +namespace Icinga\Web\Widget\Chart; + +use Icinga\Chart\PieChart; +use Icinga\Module\Monitoring\Plugin\PerfdataSet; +use Icinga\Util\StringHelper; +use Icinga\Web\Widget\AbstractWidget; +use Icinga\Web\Url; +use Icinga\Util\Format; +use Icinga\Application\Logger; +use Icinga\Exception\IcingaException; +use stdClass; + +/** + * A SVG-PieChart intended to be displayed as a small icon next to labels, to offer a better visualization of the + * shown data + * + * NOTE: When InlinePies are shown in a dynamically loaded view, like the side-bar or in the dashboard, the SVGs will + * be replaced with a jQuery-Sparkline to save resources @see loader.js + * + * @package Icinga\Web\Widget\Chart + */ +class InlinePie extends AbstractWidget +{ + const NUMBER_FORMAT_NONE = 'none'; + const NUMBER_FORMAT_TIME = 'time'; + const NUMBER_FORMAT_BYTES = 'bytes'; + const NUMBER_FORMAT_RATIO = 'ratio'; + + public static $colorsHostStates = array( + '#44bb77', // up + '#ff99aa', // down + '#cc77ff', // unreachable + '#77aaff' // pending + ); + + public static $colorsHostStatesHandledUnhandled = array( + '#44bb77', // up + '#44bb77', + '#ff99aa', // down + '#ff5566', + '#cc77ff', // unreachable + '#aa44ff', + '#77aaff', // pending + '#77aaff' + ); + + public static $colorsServiceStates = array( + '#44bb77', // Ok + '#ffaa44', // Warning + '#ff99aa', // Critical + '#aa44ff', // Unknown + '#77aaff' // Pending + ); + + public static $colorsServiceStatesHandleUnhandled = array( + '#44bb77', // Ok + '#44bb77', + '#ffaa44', // Warning + '#ffcc66', + '#ff99aa', // Critical + '#ff5566', + '#cc77ff', // Unknown + '#aa44ff', + '#77aaff', // Pending + '#77aaff' + ); + + /** + * The template string used for rendering this widget + * + * @var string + */ + private $template = '<div class="inline-pie {class}">{svg}</div>'; + + /** + * The colors used to display the slices of this pie-chart. + * + * @var array + */ + private $colors = array('#049BAF', '#ffaa44', '#ff5566', '#ddccdd'); + + /** + * The title of the chart + * + * @var string + */ + private $title; + + /** + * @var int + */ + private $size = 16; + + /** + * The data displayed by the pie-chart + * + * @var array + */ + private $data; + + /** + * @var + */ + private $class = ''; + + /** + * Set the data to be displayed. + * + * @param $data array + * + * @return $this + */ + public function setData(array $data) + { + $this->data = $data; + + return $this; + } + + /** + * Set the size of the inline pie + * + * @param int $size Sets both, the height and width + * + * @return $this + */ + public function setSize($size = null) + { + $this->size = $size; + + return $this; + } + + /** + * Set the class to define the + * + * @param $class + * + * @return $this + */ + public function setSparklineClass($class) + { + $this->class = $class; + + return $this; + } + + /** + * Set the colors used by the slices of the pie chart. + * + * @param array $colors + * + * @return $this + */ + public function setColors(array $colors = null) + { + $this->colors = $colors; + + return $this; + } + + /** + * Set the title of the displayed Data + * + * @param string $title + * + * @return $this + */ + public function setTitle($title) + { + $this->title = $this->view()->escape($title); + + return $this; + } + + /** + * Create a new InlinePie + * + * @param array $data The data displayed by the slices + * @param string $title The title of this Pie + * @param array $colors An array of RGB-Color values to use + */ + public function __construct(array $data, $title, $colors = null) + { + $this->setTitle($title); + + if (array_key_exists('data', $data)) { + $this->data = $data['data']; + if (array_key_exists('colors', $data)) { + $this->colors = $data['colors']; + } + } else { + $this->setData($data); + } + + if (isset($colors)) { + $this->setColors($colors); + } else { + $this->setColors($this->colors); + } + } + + /** + * Renders this widget via the given view and returns the + * HTML as a string + * + * @return string + */ + public function render() + { + $pie = new PieChart(); + $pie->alignTopLeft(); + $pie->disableLegend(); + $pie->drawPie([ + 'data' => $this->data, + 'colors' => $this->colors + ]); + + if ($this->view()->layout()->getLayout() === 'pdf') { + try { + $png = $pie->toPng($this->size, $this->size); + return '<img class="inlinepie" src="data:image/png;base64,' . base64_encode($png) . '" />'; + } catch (IcingaException $_) { + return ''; + } + } + + $pie->title = $this->title; + $pie->description = $this->title; + + $template = $this->template; + $template = str_replace('{class}', $this->class, $template); + $template = str_replace('{svg}', $pie->render(), $template); + + return $template; + } + + public static function createFromStateSummary(stdClass $states, $title, array $colors) + { + $handledUnhandledStates = []; + foreach ($states as $key => $value) { + if (StringHelper::endsWith($key, '_handled') || StringHelper::endsWith($key, '_unhandled')) { + $handledUnhandledStates[$key] = $value; + } + } + + $chart = new self(array_values($handledUnhandledStates), $title, $colors); + + return $chart + ->setSize(50) + ->setTitle('') + ->setSparklineClass('sparkline-multi'); + } +} diff --git a/library/Icinga/Web/Widget/Dashboard.php b/library/Icinga/Web/Widget/Dashboard.php new file mode 100644 index 0000000..5a8796d --- /dev/null +++ b/library/Icinga/Web/Widget/Dashboard.php @@ -0,0 +1,475 @@ +<?php +/* Icinga Web 2 | (c) 2013 Icinga Development Team | GPLv2+ */ + +namespace Icinga\Web\Widget; + +use Icinga\Application\Config; +use Icinga\Exception\ConfigurationError; +use Icinga\Exception\NotReadableError; +use Icinga\Exception\ProgrammingError; +use Icinga\Legacy\DashboardConfig; +use Icinga\User; +use Icinga\Web\Navigation\DashboardPane; +use Icinga\Web\Navigation\Navigation; +use Icinga\Web\Url; +use Icinga\Web\Widget\Dashboard\Dashlet as DashboardDashlet; +use Icinga\Web\Widget\Dashboard\Pane; + +/** + * Dashboards display multiple views on a single page + * + * The terminology is as follows: + * - Dashlet: A single view showing a specific url + * - Pane: Aggregates one or more dashlets on one page, displays its title as a tab + * - Dashboard: Shows all panes + * + */ +class Dashboard extends AbstractWidget +{ + /** + * An array containing all panes of this dashboard + * + * @var array + */ + private $panes = array(); + + /** + * The @see Icinga\Web\Widget\Tabs object for displaying displayable panes + * + * @var Tabs + */ + protected $tabs; + + /** + * The parameter that will be added to identify panes + * + * @var string + */ + private $tabParam = 'pane'; + + /** + * @var User + */ + private $user; + + /** + * Set the given tab name as active. + * + * @param string $name The tab name to activate + * + */ + public function activate($name) + { + $this->getTabs()->activate($name); + } + + /** + * Load Pane items provided by all enabled modules + * + * @return $this + */ + public function load() + { + $navigation = new Navigation(); + $navigation->load('dashboard-pane'); + + $panes = array(); + foreach ($navigation as $dashboardPane) { + /** @var DashboardPane $dashboardPane */ + $pane = new Pane($dashboardPane->getLabel()); + foreach ($dashboardPane->getChildren() as $dashlet) { + $pane->addDashlet($dashlet->getLabel(), $dashlet->getUrl()); + } + + $panes[] = $pane; + } + + $this->mergePanes($panes); + $this->loadUserDashboards($navigation); + return $this; + } + + /** + * Create and return a Config object for this dashboard + * + * @return Config + */ + public function getConfig() + { + $output = array(); + foreach ($this->panes as $pane) { + if ($pane->isUserWidget()) { + $output[$pane->getName()] = $pane->toArray(); + } + foreach ($pane->getDashlets() as $dashlet) { + if ($dashlet->isUserWidget()) { + $output[$pane->getName() . '.' . $dashlet->getName()] = $dashlet->toArray(); + } + } + } + + return DashboardConfig::fromArray($output)->setConfigFile($this->getConfigFile())->setUser($this->user); + } + + /** + * Load user dashboards from all config files that match the username + */ + protected function loadUserDashboards(Navigation $navigation) + { + foreach (DashboardConfig::listConfigFilesForUser($this->user) as $file) { + $this->loadUserDashboardsFromFile($file, $navigation); + } + } + + /** + * Load user dashboards from the given config file + * + * @param string $file + * + * @return bool + */ + protected function loadUserDashboardsFromFile($file, Navigation $dashboardNavigation) + { + try { + $config = Config::fromIni($file); + } catch (NotReadableError $e) { + return false; + } + + if (! count($config)) { + return false; + } + $panes = array(); + $dashlets = array(); + foreach ($config as $key => $part) { + if (strpos($key, '.') === false) { + $dashboardPane = $dashboardNavigation->getItem($key); + if ($dashboardPane !== null) { + $key = $dashboardPane->getLabel(); + } + if ($this->hasPane($key)) { + $panes[$key] = $this->getPane($key); + } else { + $panes[$key] = new Pane($key); + $panes[$key]->setTitle($part->title); + } + $panes[$key]->setUserWidget(); + if ((bool) $part->get('disabled', false) === true) { + $panes[$key]->setDisabled(); + } + } else { + list($paneName, $dashletName) = explode('.', $key, 2); + $dashboardPane = $dashboardNavigation->getItem($paneName); + if ($dashboardPane !== null) { + $paneName = $dashboardPane->getLabel(); + $dashletItem = $dashboardPane->getChildren()->getItem($dashletName); + if ($dashletItem !== null) { + $dashletName = $dashletItem->getLabel(); + } + } + $part->pane = $paneName; + $part->dashlet = $dashletName; + $dashlets[] = $part; + } + } + foreach ($dashlets as $dashletData) { + $pane = null; + + if (array_key_exists($dashletData->pane, $panes) === true) { + $pane = $panes[$dashletData->pane]; + } elseif (array_key_exists($dashletData->pane, $this->panes) === true) { + $pane = $this->panes[$dashletData->pane]; + } else { + continue; + } + $dashlet = new DashboardDashlet( + $dashletData->title, + $dashletData->url, + $pane + ); + $dashlet->setName($dashletData->dashlet); + + if ((bool) $dashletData->get('disabled', false) === true) { + $dashlet->setDisabled(true); + } + + $dashlet->setUserWidget(); + $pane->addDashlet($dashlet); + } + + $this->mergePanes($panes); + + return true; + } + + /** + * Merge panes with existing panes + * + * @param array $panes + * + * @return $this + */ + public function mergePanes(array $panes) + { + /** @var $pane Pane */ + foreach ($panes as $pane) { + if ($this->hasPane($pane->getName()) === true) { + /** @var $current Pane */ + $current = $this->panes[$pane->getName()]; + $current->addDashlets($pane->getDashlets()); + } else { + $this->panes[$pane->getName()] = $pane; + } + } + + return $this; + } + + /** + * Return the tab object used to navigate through this dashboard + * + * @return Tabs + */ + public function getTabs() + { + $url = Url::fromPath('dashboard')->getUrlWithout($this->tabParam); + if ($this->tabs === null) { + $this->tabs = new Tabs(); + + foreach ($this->panes as $key => $pane) { + if ($pane->getDisabled()) { + continue; + } + $this->tabs->add( + $key, + array( + 'title' => sprintf( + t('Show %s', 'dashboard.pane.tooltip'), + $pane->getTitle() + ), + 'label' => $pane->getTitle(), + 'url' => clone($url), + 'urlParams' => array($this->tabParam => $key) + ) + ); + } + } + return $this->tabs; + } + + /** + * Return all panes of this dashboard + * + * @return array + */ + public function getPanes() + { + return $this->panes; + } + + + /** + * Creates a new empty pane with the given title + * + * @param string $title + * + * @return $this + */ + public function createPane($title) + { + $pane = new Pane($title); + $pane->setTitle($title); + $this->addPane($pane); + + return $this; + } + + /** + * Checks if the current dashboard has any panes + * + * @return bool + */ + public function hasPanes() + { + return ! empty($this->panes); + } + + /** + * Check if a panel exist + * + * @param string $pane + * @return bool + */ + public function hasPane($pane) + { + return $pane && array_key_exists($pane, $this->panes); + } + + /** + * Add a pane object to this dashboard + * + * @param Pane $pane The pane to add + * + * @return $this + */ + public function addPane(Pane $pane) + { + $this->panes[$pane->getName()] = $pane; + return $this; + } + + public function removePane($title) + { + if ($this->hasPane($title) === true) { + $pane = $this->getPane($title); + if ($pane->isUserWidget() === true) { + unset($this->panes[$pane->getName()]); + } else { + $pane->setDisabled(); + $pane->setUserWidget(); + } + } else { + throw new ProgrammingError('Pane not found: ' . $title); + } + } + + /** + * Return the pane with the provided name + * + * @param string $name The name of the pane to return + * + * @return Pane The pane or null if no pane with the given name exists + * @throws ProgrammingError + */ + public function getPane($name) + { + if (! array_key_exists($name, $this->panes)) { + throw new ProgrammingError( + 'Trying to retrieve invalid dashboard pane "%s"', + $name + ); + } + return $this->panes[$name]; + } + + /** + * Return an array with pane name=>title format used for comboboxes + * + * @return array + */ + public function getPaneKeyTitleArray() + { + $list = array(); + foreach ($this->panes as $name => $pane) { + $list[$name] = $pane->getTitle(); + } + return $list; + } + + /** + * @see Icinga\Web\Widget::render + */ + public function render() + { + if (empty($this->panes)) { + return ''; + } + + return $this->determineActivePane()->render(); + } + + /** + * Activates the default pane of this dashboard and returns its name + * + * @return mixed + */ + private function setDefaultPane() + { + $active = null; + + foreach ($this->panes as $key => $pane) { + if ($pane->getDisabled() === false) { + $active = $key; + break; + } + } + + if ($active !== null) { + $this->activate($active); + } + return $active; + } + + /** + * @see determineActivePane() + */ + public function getActivePane() + { + return $this->determineActivePane(); + } + + /** + * Determine the active pane either by the selected tab or the current request + * + * @throws \Icinga\Exception\ConfigurationError + * @throws \Icinga\Exception\ProgrammingError + * + * @return Pane The currently active pane + */ + public function determineActivePane() + { + $active = $this->getTabs()->getActiveName(); + if (! $active) { + if ($active = Url::fromRequest()->getParam($this->tabParam)) { + if ($this->hasPane($active)) { + $this->activate($active); + } else { + throw new ProgrammingError( + 'Try to get an inexistent pane.' + ); + } + } else { + $active = $this->setDefaultPane(); + } + } + + if (isset($this->panes[$active])) { + return $this->panes[$active]; + } + + throw new ConfigurationError('Could not determine active pane'); + } + + /** + * Setter for user object + * + * @param User $user + */ + public function setUser(User $user) + { + $this->user = $user; + } + + /** + * Getter for user object + * + * @return User + */ + public function getUser() + { + return $this->user; + } + + /** + * Get config file + * + * @return string + */ + public function getConfigFile() + { + if ($this->user === null) { + throw new ProgrammingError('Can\'t load dashboards. User is not set'); + } + return Config::resolvePath('dashboards/' . strtolower($this->user->getUsername()) . '/dashboard.ini'); + } +} diff --git a/library/Icinga/Web/Widget/Dashboard/Dashlet.php b/library/Icinga/Web/Widget/Dashboard/Dashlet.php new file mode 100644 index 0000000..2ba26df --- /dev/null +++ b/library/Icinga/Web/Widget/Dashboard/Dashlet.php @@ -0,0 +1,315 @@ +<?php +/* Icinga Web 2 | (c) 2013 Icinga Development Team | GPLv2+ */ + +namespace Icinga\Web\Widget\Dashboard; + +use Icinga\Web\Url; +use Icinga\Data\ConfigObject; +use Icinga\Exception\IcingaException; + +/** + * A dashboard pane dashlet + * + * This is the element displaying a specific view in icinga2web + * + */ +class Dashlet extends UserWidget +{ + /** + * The url of this Dashlet + * + * @var Url|null + */ + private $url; + + private $name; + + /** + * The title being displayed on top of the dashlet + * @var + */ + private $title; + + /** + * The pane containing this dashlet, needed for the 'remove button' + * @var Pane + */ + private $pane; + + /** + * The disabled option is used to "delete" default dashlets provided by modules + * + * @var bool + */ + private $disabled = false; + + /** + * The progress label being used + * + * @var string + */ + private $progressLabel; + + /** + * The template string used for rendering this widget + * + * @var string + */ + private $template =<<<'EOD' + + <div class="container" data-icinga-url="{URL}"> + <h1><a href="{FULL_URL}" aria-label="{TOOLTIP}" title="{TOOLTIP}" data-base-target="col1">{TITLE}</a></h1> + <p class="progress-label">{PROGRESS_LABEL}<span>.</span><span>.</span><span>.</span></p> + <noscript> + <div class="iframe-container"> + <iframe + src="{IFRAME_URL}" + frameborder="no" + title="{TITLE_PREFIX}{TITLE}"> + </iframe> + </div> + </noscript> + </div> +EOD; + + /** + * The template string used for rendering this widget in case of an error + * + * @var string + */ + private $errorTemplate = <<<'EOD' + + <div class="container"> + <h1 title="{TOOLTIP}">{TITLE}</h1> + <p class="error-message">{ERROR_MESSAGE}</p> + </div> +EOD; + + /** + * Create a new dashlet displaying the given url in the provided pane + * + * @param string $title The title to use for this dashlet + * @param Url|string $url The url this dashlet uses for displaying information + * @param Pane $pane The pane this Dashlet will be added to + */ + public function __construct($title, $url, Pane $pane) + { + $this->name = $title; + $this->title = $title; + $this->pane = $pane; + $this->url = $url; + } + + public function setName($name) + { + $this->name = $name; + return $this; + } + + public function getName() + { + return $this->name; + } + + /** + * Retrieve the dashlets title + * + * @return string + */ + public function getTitle() + { + return $this->title; + } + + /** + * @param string $title + */ + public function setTitle($title) + { + $this->title = $title; + } + + /** + * Retrieve the dashlets url + * + * @return Url|null + */ + public function getUrl() + { + if ($this->url !== null && ! $this->url instanceof Url) { + $this->url = Url::fromPath($this->url); + } + return $this->url; + } + + /** + * Set the dashlets URL + * + * @param string|Url $url The url to use, either as an Url object or as a path + * + * @return $this + */ + public function setUrl($url) + { + $this->url = $url; + return $this; + } + + /** + * Set the disabled property + * + * @param boolean $disabled + */ + public function setDisabled($disabled) + { + $this->disabled = $disabled; + } + + /** + * Get the disabled property + * + * @return boolean + */ + public function getDisabled() + { + return $this->disabled; + } + + /** + * Set the progress label to use + * + * @param string $label + * + * @return $this + */ + public function setProgressLabel($label) + { + $this->progressLabel = $label; + return $this; + } + + /** + * Return the progress label to use + * + * @return string + */ + public function getProgressLabe() + { + if ($this->progressLabel === null) { + return $this->view()->translate('Loading'); + } + + return $this->progressLabel; + } + + /** + * Return this dashlet's structure as array + * + * @return array + */ + public function toArray() + { + $array = array( + 'url' => $this->getUrl()->getRelativeUrl(), + 'title' => $this->getTitle() + ); + if ($this->getDisabled() === true) { + $array['disabled'] = 1; + } + return $array; + } + + /** + * @see Widget::render() + */ + public function render() + { + if ($this->disabled === true) { + return ''; + } + + $view = $this->view(); + + if (! $this->url) { + $searchTokens = array( + '{TOOLTIP}', + '{TITLE}', + '{ERROR_MESSAGE}' + ); + + $replaceTokens = array( + sprintf($view->translate('Show %s', 'dashboard.dashlet.tooltip'), $view->escape($this->getTitle())), + $view->escape($this->getTitle()), + $view->escape( + sprintf($view->translate('Cannot create dashboard dashlet "%s" without valid URL'), $this->title) + ) + ); + + return str_replace($searchTokens, $replaceTokens, $this->errorTemplate); + } + + $url = $this->getUrl(); + $url->setParam('showCompact', true); + $iframeUrl = clone $url; + $iframeUrl->setParam('isIframe'); + + $searchTokens = array( + '{URL}', + '{IFRAME_URL}', + '{FULL_URL}', + '{TOOLTIP}', + '{TITLE}', + '{TITLE_PREFIX}', + '{PROGRESS_LABEL}' + ); + + $replaceTokens = array( + $url, + $iframeUrl, + $url->getUrlWithout(['showCompact', 'limit', 'view']), + sprintf($view->translate('Show %s', 'dashboard.dashlet.tooltip'), $view->escape($this->getTitle())), + $view->escape($this->getTitle()), + $view->translate('Dashlet') . ': ', + $this->getProgressLabe() + ); + + return str_replace($searchTokens, $replaceTokens, $this->template); + } + + /** + * Create a @see Dashlet instance from the given Zend config, using the provided title + * + * @param $title The title for this dashlet + * @param ConfigObject $config The configuration defining url, parameters, height, width, etc. + * @param Pane $pane The pane this dashlet belongs to + * + * @return Dashlet A newly created Dashlet for use in the Dashboard + */ + public static function fromIni($title, ConfigObject $config, Pane $pane) + { + $height = null; + $width = null; + $url = $config->get('url'); + $parameters = $config->toArray(); + unset($parameters['url']); // otherwise there's an url = parameter in the Url + + $cmp = new Dashlet($title, Url::fromPath($url, $parameters), $pane); + return $cmp; + } + + /** + * @param \Icinga\Web\Widget\Dashboard\Pane $pane + */ + public function setPane(Pane $pane) + { + $this->pane = $pane; + } + + /** + * @return \Icinga\Web\Widget\Dashboard\Pane + */ + public function getPane() + { + return $this->pane; + } +} diff --git a/library/Icinga/Web/Widget/Dashboard/Pane.php b/library/Icinga/Web/Widget/Dashboard/Pane.php new file mode 100644 index 0000000..c8b14c5 --- /dev/null +++ b/library/Icinga/Web/Widget/Dashboard/Pane.php @@ -0,0 +1,335 @@ +<?php +/* Icinga Web 2 | (c) 2013 Icinga Development Team | GPLv2+ */ + +namespace Icinga\Web\Widget\Dashboard; + +use Icinga\Data\ConfigObject; +use Icinga\Web\Widget\AbstractWidget; +use Icinga\Exception\ProgrammingError; +use Icinga\Exception\ConfigurationError; + +/** + * A pane, displaying different Dashboard dashlets + */ +class Pane extends UserWidget +{ + /** + * The name of this pane, as defined in the ini file + * + * @var string + */ + private $name; + + /** + * The title of this pane, as displayed in the dashboard tabs + * + * @var string + */ + private $title; + + /** + * An array of @see Dashlets that are displayed in this pane + * + * @var array + */ + private $dashlets = array(); + + /** + * Disabled flag of a pane + * + * @var bool + */ + private $disabled = false; + + /** + * Create a new pane + * + * @param string $name The pane to create + */ + public function __construct($name) + { + $this->name = $name; + $this->title = $name; + } + + /** + * Set the name of this pane + * + * @param string $name + */ + public function setName($name) + { + $this->name = $name; + } + + /** + * Returns the name of this pane + * + * @return string + */ + public function getName() + { + return $this->name; + } + + /** + * Returns the title of this pane + * + * @return string + */ + public function getTitle() + { + return $this->title; + } + + /** + * Overwrite the title of this pane + * + * @param string $title The new title to use for this pane + * + * @return $this + */ + public function setTitle($title) + { + $this->title = $title; + return $this; + } + + /** + * Return true if a dashlet with the given title exists in this pane + * + * @param string $title The title of the dashlet to check for existence + * + * @return bool + */ + public function hasDashlet($title) + { + return array_key_exists($title, $this->dashlets); + } + + /** + * Checks if the current pane has any dashlets + * + * @return bool + */ + public function hasDashlets() + { + return ! empty($this->dashlets); + } + + /** + * Return a dashlet with the given name if existing + * + * @param string $title The title of the dashlet to return + * + * @return Dashlet The dashlet with the given title + * @throws ProgrammingError If the dashlet doesn't exist + */ + public function getDashlet($title) + { + if ($this->hasDashlet($title)) { + return $this->dashlets[$title]; + } + throw new ProgrammingError( + 'Trying to access invalid dashlet: %s', + $title + ); + } + + /** + * Removes the dashlet with the given title if it exists in this pane + * + * @param string $title The pane + * @return Pane $this + */ + public function removeDashlet($title) + { + if ($this->hasDashlet($title)) { + $dashlet = $this->getDashlet($title); + if ($dashlet->isUserWidget() === true) { + unset($this->dashlets[$title]); + } else { + $dashlet->setDisabled(true); + $dashlet->setUserWidget(); + } + } else { + throw new ProgrammingError('Dashlet does not exist: ' . $title); + } + return $this; + } + + /** + * Removes all or a given list of dashlets from this pane + * + * @param array $dashlets Optional list of dashlet titles + * @return Pane $this + */ + public function removeDashlets(array $dashlets = null) + { + if ($dashlets === null) { + $this->dashlets = array(); + } else { + foreach ($dashlets as $dashlet) { + $this->removeDashlet($dashlet); + } + } + return $this; + } + + /** + * Return all dashlets added at this pane + * + * @return array + */ + public function getDashlets() + { + return $this->dashlets; + } + + /** + * @see Widget::render + */ + public function render() + { + $dashlets = array_filter( + $this->dashlets, + function ($e) { + return ! $e->getDisabled(); + } + ); + return implode("\n", $dashlets) . "\n"; + } + + /** + * Create, add and return a new dashlet + * + * @param string $title + * @param string $url + * + * @return Dashlet + */ + public function createDashlet($title, $url = null) + { + $dashlet = new Dashlet($title, $url, $this); + $this->addDashlet($dashlet); + return $dashlet; + } + + /** + * Add a dashlet to this pane, optionally creating it if $dashlet is a string + * + * @param string|Dashlet $dashlet The dashlet object or title + * (if a new dashlet will be created) + * @param string|null $url An Url to be used when dashlet is a string + * + * @return $this + * @throws \Icinga\Exception\ConfigurationError + */ + public function addDashlet($dashlet, $url = null) + { + if ($dashlet instanceof Dashlet) { + $this->dashlets[$dashlet->getName()] = $dashlet; + } elseif (is_string($dashlet) && $url !== null) { + $this->createDashlet($dashlet, $url); + } else { + throw new ConfigurationError('Invalid dashlet added: %s', $dashlet); + } + return $this; + } + + /** + * Add new dashlets to existing dashlets + * + * @param array $dashlets + * @return $this + */ + public function addDashlets(array $dashlets) + { + /* @var $dashlet Dashlet */ + foreach ($dashlets as $dashlet) { + if (array_key_exists($dashlet->getName(), $this->dashlets)) { + if (preg_match('/_(\d+)$/', $dashlet->getName(), $m)) { + $name = preg_replace('/_\d+$/', $m[1]++, $dashlet->getName()); + } else { + $name = $dashlet->getName() . '_2'; + } + $this->dashlets[$name] = $dashlet; + } else { + $this->dashlets[$dashlet->getName()] = $dashlet; + } + } + + return $this; + } + + /** + * Add a dashlet to the current pane + * + * @param $title + * @param $url + * @return Dashlet + * + * @see addDashlet() + */ + public function add($title, $url = null) + { + $this->addDashlet($title, $url); + + return $this->dashlets[$title]; + } + + /** + * Return the this pane's structure as array + * + * @return array + */ + public function toArray() + { + $pane = array( + 'title' => $this->getTitle(), + ); + + if ($this->getDisabled() === true) { + $pane['disabled'] = 1; + } + + return $pane; + } + + /** + * Create a new pane with the title $title from the given configuration + * + * @param $title The title for this pane + * @param ConfigObject $config The configuration to use for setup + * + * @return Pane + */ + public static function fromIni($title, ConfigObject $config) + { + $pane = new Pane($title); + if ($config->get('title', false)) { + $pane->setTitle($config->get('title')); + } + return $pane; + } + + /** + * Setter for disabled + * + * @param boolean $disabled + */ + public function setDisabled($disabled = true) + { + $this->disabled = (bool) $disabled; + } + + /** + * Getter for disabled + * + * @return boolean + */ + public function getDisabled() + { + return $this->disabled; + } +} diff --git a/library/Icinga/Web/Widget/Dashboard/UserWidget.php b/library/Icinga/Web/Widget/Dashboard/UserWidget.php new file mode 100644 index 0000000..164d58b --- /dev/null +++ b/library/Icinga/Web/Widget/Dashboard/UserWidget.php @@ -0,0 +1,36 @@ +<?php +/* Icinga Web 2 | (c) 2014 Icinga Development Team | GPLv2+ */ + +namespace Icinga\Web\Widget\Dashboard; + +use Icinga\Web\Widget\AbstractWidget; + +abstract class UserWidget extends AbstractWidget +{ + /** + * Flag if widget is created by an user + * + * @var bool + */ + protected $userWidget = false; + + /** + * Set the user widget flag + * + * @param boolean $userWidget + */ + public function setUserWidget($userWidget = true) + { + $this->userWidget = (bool) $userWidget; + } + + /** + * Getter for user widget flag + * + * @return boolean + */ + public function isUserWidget() + { + return $this->userWidget; + } +} diff --git a/library/Icinga/Web/Widget/FilterEditor.php b/library/Icinga/Web/Widget/FilterEditor.php new file mode 100644 index 0000000..24f4b15 --- /dev/null +++ b/library/Icinga/Web/Widget/FilterEditor.php @@ -0,0 +1,811 @@ +<?php +/* Icinga Web 2 | (c) 2014 Icinga Development Team | GPLv2+ */ + +namespace Icinga\Web\Widget; + +use Icinga\Data\Filterable; +use Icinga\Data\FilterColumns; +use Icinga\Data\Filter\Filter; +use Icinga\Data\Filter\FilterExpression; +use Icinga\Data\Filter\FilterChain; +use Icinga\Data\Filter\FilterOr; +use Icinga\Web\Url; +use Icinga\Application\Icinga; +use Icinga\Exception\ProgrammingError; +use Icinga\Web\Notification; +use Exception; + +/** + * Filter + */ +class FilterEditor extends AbstractWidget +{ + /** + * The filter + * + * @var Filter + */ + private $filter; + + /** + * The query to filter + * + * @var Filterable + */ + protected $query; + + protected $url; + + protected $addTo; + + protected $cachedColumnSelect; + + protected $preserveParams = array(); + + protected $preservedParams = array(); + + protected $preservedUrl; + + protected $ignoreParams = array(); + + protected $searchColumns; + + /** + * @var string + */ + private $selectedIdx; + + /** + * Whether the filter control is visible + * + * @var bool + */ + protected $visible = true; + + /** + * Create a new FilterEditor + * + * @param Filter $filter Your filter + */ + public function __construct($props) + { + if (array_key_exists('filter', $props)) { + $this->setFilter($props['filter']); + } + if (array_key_exists('query', $props)) { + $this->setQuery($props['query']); + } + } + + public function setFilter(Filter $filter) + { + $this->filter = $filter; + return $this; + } + + public function getFilter() + { + if ($this->filter === null) { + $this->filter = Filter::fromQueryString((string) $this->url()->getParams()); + } + return $this->filter; + } + + /** + * Set columns to search in + * + * @param array $searchColumns + * + * @return $this + */ + public function setSearchColumns(array $searchColumns = null) + { + $this->searchColumns = $searchColumns; + return $this; + } + + public function setUrl($url) + { + $this->url = $url; + return $this; + } + + protected function url() + { + if ($this->url === null) { + $this->url = Url::fromRequest(); + } + return $this->url; + } + + protected function preservedUrl() + { + if ($this->preservedUrl === null) { + $this->preservedUrl = $this->url()->with($this->preservedParams); + } + return $this->preservedUrl; + } + + /** + * Set the query to filter + * + * @param Filterable $query + * + * @return $this + */ + public function setQuery(Filterable $query) + { + $this->query = $query; + return $this; + } + + public function ignoreParams() + { + $this->ignoreParams = func_get_args(); + return $this; + } + + public function preserveParams() + { + $this->preserveParams = func_get_args(); + return $this; + } + + /** + * Get whether the filter control is visible + * + * @return bool + */ + public function isVisible() + { + return $this->visible; + } + + /** + * Set whether the filter control is visible + * + * @param bool $visible + * + * @return $this + */ + public function setVisible($visible) + { + $this->visible = (bool) $visible; + + return $this; + } + + protected function redirectNow($url) + { + $response = Icinga::app()->getFrontController()->getResponse(); + $response->redirectAndExit($url); + } + + protected function mergeRootExpression($filter, $column, $sign, $expression) + { + $found = false; + if ($filter->isChain() && $filter->getOperatorName() === 'AND') { + foreach ($filter->filters() as $f) { + if ($f->isExpression() + && $f->getColumn() === $column + && $f->getSign() === $sign + ) { + $f->setExpression($expression); + $found = true; + break; + } + } + } elseif ($filter->isExpression()) { + if ($filter->getColumn() === $column && $filter->getSign() === $sign) { + $filter->setExpression($expression); + $found = true; + } + } + if (! $found) { + $filter = $filter->andFilter( + Filter::expression($column, $sign, $expression) + ); + } + return $filter; + } + + protected function resetSearchColumns(Filter &$filter) + { + if ($filter->isChain()) { + $filters = &$filter->filters(); + if (!($empty = empty($filters))) { + foreach ($filters as $k => &$f) { + if (false === $this->resetSearchColumns($f)) { + unset($filters[$k]); + } + } + } + return $empty || !empty($filters); + } + return $filter->isExpression() ? !( + in_array($filter->getColumn(), $this->searchColumns) + && + $filter->getSign() === '=' + ) : true; + } + + public function handleRequest($request) + { + $this->setUrl($request->getUrl()->without($this->ignoreParams)); + $params = $this->url()->getParams(); + + $preserve = array(); + foreach ($this->preserveParams as $key) { + if (null !== ($value = $params->shift($key))) { + $preserve[$key] = $value; + } + } + $this->preservedParams = $preserve; + + $add = $params->shift('addFilter'); + $remove = $params->shift('removeFilter'); + $strip = $params->shift('stripFilter'); + $modify = $params->shift('modifyFilter'); + + + + $search = null; + if ($request->isPost()) { + $search = $request->getPost('q'); + } + + if ($search === null) { + $search = $params->shift('q'); + } + + $filter = $this->getFilter(); + + if ($search !== null) { + if (strpos($search, '=') !== false) { + list($k, $v) = preg_split('/=/', $search); + $filter = $this->mergeRootExpression($filter, trim($k), '=', ltrim($v)); + } else { + if ($this->searchColumns === null && $this->query instanceof FilterColumns) { + $this->searchColumns = $this->query->getSearchColumns($search); + } + + if (! empty($this->searchColumns)) { + if (! $this->resetSearchColumns($filter)) { + $filter = Filter::matchAll(); + } + $filters = array(); + $search = trim($search); + foreach ($this->searchColumns as $searchColumn) { + $filters[] = Filter::expression($searchColumn, '=', "*$search*"); + } + $filter = $filter->andFilter(new FilterOr($filters)); + } else { + Notification::error(mt('monitoring', 'Cannot search here')); + return $this; + } + } + + $url = Url::fromRequest()->onlyWith($this->preserveParams); + $urlParams = $url->getParams(); + $url->setQueryString($filter->toQueryString()); + foreach ($urlParams->toArray(false) as $key => $value) { + $url->getParams()->addEncoded($key, $value); + } + + $this->redirectNow($url); + } + + if ($remove) { + $redirect = $this->url(); + if ($filter->getById($remove)->isRootNode()) { + $redirect->setQueryString(''); + } else { + $filter->removeId($remove); + $redirect->setQueryString($filter->toQueryString())->getParams()->add('modifyFilter'); + } + $this->redirectNow($redirect->addParams($preserve)); + } + + if ($strip) { + $redirect = $this->url(); + $subId = $strip . '-1'; + if ($filter->getId() === $strip) { + $filter = $filter->getById($strip . '-1'); + } else { + $filter->replaceById($strip, $filter->getById($strip . '-1')); + } + $redirect->setQueryString($filter->toQueryString())->getParams()->add('modifyFilter'); + $this->redirectNow($redirect->addParams($preserve)); + } + + + if ($modify) { + if ($request->isPost()) { + if ($request->get('cancel') === 'Cancel') { + $this->redirectNow($this->preservedUrl()->without('modifyFilter')); + } + if ($request->get('formUID') === 'FilterEditor') { + $filter = $this->applyChanges($request->getPost()); + $url = $this->url()->setQueryString($filter->toQueryString())->addParams($preserve); + $url->getParams()->add('modifyFilter'); + + $addFilter = $request->get('add_filter'); + if ($addFilter !== null) { + $url->setParam('addFilter', $addFilter); + } + + $removeFilter = $request->get('remove_filter'); + if ($removeFilter !== null) { + $url->setParam('removeFilter', $removeFilter); + } + + $this->redirectNow($url); + } + } + $this->url()->getParams()->add('modifyFilter'); + } + + if ($add) { + $this->addFilterToId($add); + } + + if ($this->query !== null && $request->isGet()) { + $this->query->applyFilter($this->getFilter()); + } + + return $this; + } + + protected function select($name, $list, $selected, $attributes = null) + { + $view = $this->view(); + if ($attributes === null) { + $attributes = ''; + } else { + $attributes = $view->propertiesToString($attributes); + } + $html = sprintf( + '<select name="%s"%s class="autosubmit">' . "\n", + $view->escape($name), + $attributes + ); + + foreach ($list as $k => $v) { + $active = ''; + if ($k === $selected) { + $active = ' selected="selected"'; + } + $html .= sprintf( + ' <option value="%s"%s>%s</option>' . "\n", + $view->escape($k), + $active, + $view->escape($v) + ); + } + $html .= '</select>' . "\n\n"; + return $html; + } + + protected function addFilterToId($id) + { + $this->addTo = $id; + return $this; + } + + protected function removeIndex($idx) + { + $this->selectedIdx = $idx; + return $this; + } + + protected function removeLink(Filter $filter) + { + return "<button type='submit' name='remove_filter' value='{$filter->getId()}'>" + . $this->view()->icon('trash', t('Remove this part of your filter')) + . '</button>'; + } + + protected function addLink(Filter $filter) + { + return "<button type='submit' name='add_filter' value='{$filter->getId()}'>" + . $this->view()->icon('plus', t('Add another filter')) + . '</button>'; + } + + protected function stripLink(Filter $filter) + { + return $this->view()->qlink( + '', + $this->preservedUrl()->with('stripFilter', $filter->getId()), + null, + array( + 'icon' => 'minus', + 'title' => t('Strip this filter') + ) + ); + } + + protected function cancelLink() + { + return $this->view()->qlink( + '', + $this->preservedUrl()->without('addFilter'), + null, + array( + 'icon' => 'cancel', + 'title' => t('Cancel this operation') + ) + ); + } + + protected function renderFilter($filter, $level = 0) + { + if ($level === 0 && $filter->isChain() && $filter->isEmpty()) { + return '<ul class="datafilter"><li class="active">' . $this->renderNewFilter() . '</li></ul>'; + } + + if ($filter instanceof FilterChain) { + return $this->renderFilterChain($filter, $level); + } elseif ($filter instanceof FilterExpression) { + return $this->renderFilterExpression($filter); + } else { + throw new ProgrammingError('Got a Filter being neither expression nor chain'); + } + } + + protected function renderFilterChain(FilterChain $filter, $level) + { + $html = '<span class="handle"> </span>' + . $this->selectOperator($filter) + . $this->removeLink($filter) + . ($filter->count() === 1 ? $this->stripLink($filter) : '') + . $this->addLink($filter); + + if ($filter->isEmpty() && ! $this->addTo) { + return $html; + } + + $parts = array(); + foreach ($filter->filters() as $f) { + $parts[] = '<li>' . $this->renderFilter($f, $level + 1) . '</li>'; + } + + if ($this->addTo && $this->addTo == $filter->getId()) { + $parts[] = '<li class="new-filter">' . $this->renderNewFilter() .$this->cancelLink(). '</li>'; + } + + $class = $level === 0 ? ' class="datafilter"' : ''; + $html .= sprintf( + "<ul%s>\n%s</ul>\n", + $class, + implode("", $parts) + ); + return $html; + } + + protected function renderFilterExpression(FilterExpression $filter) + { + if ($this->addTo && $this->addTo === $filter->getId()) { + return + preg_replace( + '/ class="autosubmit"/', + ' class="autofocus"', + $this->selectOperator() + ) + . '<ul><li>' + . $this->selectColumn($filter) + . $this->selectSign($filter) + . $this->text($filter) + . $this->removeLink($filter) + . $this->addLink($filter) + . '</li><li class="active">' + . $this->renderNewFilter() .$this->cancelLink() + . '</li></ul>' + ; + } else { + return $this->selectColumn($filter) + . $this->selectSign($filter) + . $this->text($filter) + . $this->removeLink($filter) + . $this->addLink($filter) + ; + } + } + + protected function text(Filter $filter = null) + { + $value = $filter === null ? '' : $filter->getExpression(); + if (is_array($value)) { + $value = '(' . implode('|', $value) . ')'; + } + return sprintf( + '<input type="text" name="%s" value="%s" />', + $this->elementId('value', $filter), + $this->view()->escape($value) + ); + } + + protected function renderNewFilter() + { + $html = $this->selectColumn() + . $this->selectSign() + . $this->text(); + + return preg_replace( + '/ class="autosubmit"/', + '', + $html + ); + } + + protected function arrayForSelect($array, $flip = false) + { + $res = array(); + foreach ($array as $k => $v) { + if (is_int($k)) { + $res[$v] = ucwords(str_replace('_', ' ', $v)); + } elseif ($flip) { + $res[$v] = $k; + } else { + $res[$k] = $v; + } + } + // sort($res); + return $res; + } + + protected function elementId($prefix, Filter $filter = null) + { + if ($filter === null) { + return $prefix . '_new_' . ($this->addTo ?: '0'); + } else { + return $prefix . '_' . $filter->getId(); + } + } + + protected function selectOperator(Filter $filter = null) + { + $ops = array( + 'AND' => 'AND', + 'OR' => 'OR', + 'NOT' => 'NOT' + ); + + return $this->select( + $this->elementId('operator', $filter), + $ops, + $filter === null ? null : $filter->getOperatorName(), + ['class' => 'filter-operator'] + ); + } + + protected function selectSign(Filter $filter = null) + { + $signs = array( + '=' => '=', + '!=' => '!=', + '>' => '>', + '<' => '<', + '>=' => '>=', + '<=' => '<=', + ); + + return $this->select( + $this->elementId('sign', $filter), + $signs, + $filter === null ? null : $filter->getSign(), + ['class' => 'filter-rule'] + ); + } + + public function setColumns(array $columns = null) + { + $this->cachedColumnSelect = $columns ? $this->arrayForSelect($columns) : null; + return $this; + } + + protected function selectColumn(Filter $filter = null) + { + $active = $filter === null ? null : $filter->getColumn(); + + if ($this->cachedColumnSelect === null && $this->query === null) { + return sprintf( + '<input type="text" name="%s" value="%s" />', + $this->elementId('column', $filter), + $this->view()->escape($active) // Escape attribute? + ); + } + + if ($this->cachedColumnSelect === null && $this->query instanceof FilterColumns) { + $this->cachedColumnSelect = $this->arrayForSelect($this->query->getFilterColumns(), true); + asort($this->cachedColumnSelect); + } elseif ($this->cachedColumnSelect === null) { + throw new ProgrammingError('No columns set nor does the query provide any'); + } + + $cols = $this->cachedColumnSelect; + if ($active && !isset($cols[$active])) { + $cols[$active] = str_replace('_', ' ', ucfirst(ltrim($active, '_'))); + } + + return $this->select($this->elementId('column', $filter), $cols, $active); + } + + protected function applyChanges($changes) + { + $filter = $this->filter; + $pairs = array(); + $addTo = null; + $add = array(); + foreach ($changes as $k => $v) { + if (preg_match('/^(column|value|sign|operator)((?:_new)?)_([\d-]+)$/', $k, $m)) { + if ($m[2] === '_new') { + if ($addTo !== null && $addTo !== $m[3]) { + throw new \Exception('F...U'); + } + $addTo = $m[3]; + $add[$m[1]] = $v; + } else { + $pairs[$m[3]][$m[1]] = $v; + } + } + } + + $operators = array(); + foreach ($pairs as $id => $fs) { + if (array_key_exists('operator', $fs)) { + $operators[$id] = $fs['operator']; + } else { + $f = $filter->getById($id); + $f->setColumn($fs['column']); + if ($f->getSign() !== $fs['sign']) { + if ($f->isRootNode()) { + $filter = $f->setSign($fs['sign']); + } else { + $filter->replaceById($id, $f->setSign($fs['sign'])); + } + } + $f->setExpression($fs['value']); + } + } + + krsort($operators, SORT_NATURAL); + foreach ($operators as $id => $operator) { + $f = $filter->getById($id); + if ($f->getOperatorName() !== $operator) { + if ($f->isRootNode()) { + $filter = $f->setOperatorName($operator); + } else { + $filter->replaceById($id, $f->setOperatorName($operator)); + } + } + } + + if ($addTo !== null) { + if ($addTo === '0') { + $filter = Filter::expression($add['column'], $add['sign'], $add['value']); + } else { + $parent = $filter->getById($addTo); + $f = Filter::expression($add['column'], $add['sign'], $add['value']); + if (isset($add['operator'])) { + switch ($add['operator']) { + case 'AND': + if ($parent->isExpression()) { + if ($parent->isRootNode()) { + $filter = Filter::matchAll(clone $parent, $f); + } else { + $filter = $filter->replaceById($addTo, Filter::matchAll(clone $parent, $f)); + } + } else { + $parent->addFilter(Filter::matchAll($f)); + } + break; + case 'OR': + if ($parent->isExpression()) { + if ($parent->isRootNode()) { + $filter = Filter::matchAny(clone $parent, $f); + } else { + $filter = $filter->replaceById($addTo, Filter::matchAny(clone $parent, $f)); + } + } else { + $parent->addFilter(Filter::matchAny($f)); + } + break; + case 'NOT': + if ($parent->isExpression()) { + if ($parent->isRootNode()) { + $filter = Filter::not(Filter::matchAll($parent, $f)); + } else { + $filter = $filter->replaceById($addTo, Filter::not(Filter::matchAll($parent, $f))); + } + } else { + $parent->addFilter(Filter::not($f)); + } + break; + } + } else { + $parent->addFilter($f); + } + } + } + + return $filter; + } + + public function renderSearch() + { + $preservedUrl = $this->preservedUrl(); + + $html = ' <form method="post" class="search inline" action="' + . $preservedUrl + . '"><input type="text" name="q" class="search search-input" value="" placeholder="' + . t('Search...') + . '" /></form>'; + + if ($this->filter->isEmpty()) { + $title = t('Filter this list'); + } else { + $title = t('Modify this filter'); + if (! $this->filter->isEmpty()) { + $title .= ': ' . $this->view()->escape($this->filter); + } + } + + return $html + . '<a href="' + . $preservedUrl->with('modifyFilter', ! $preservedUrl->getParam('modifyFilter')) + . '" aria-label="' + . $title + . '" title="' + . $title + . '">' + . '<i aria-hidden="true" class="icon-filter"></i>' + . '</a>'; + } + + public function render() + { + if (! $this->visible) { + return ''; + } + if (! $this->preservedUrl()->getParam('modifyFilter')) { + return '<div class="filter icinga-controls">' + . $this->renderSearch() + . $this->view()->escape($this->shorten($this->filter, 50)) + . '</div>'; + } + return '<div class="filter icinga-controls">' + . $this->renderSearch() + . '<form action="' + . Url::fromRequest() + . '" class="editor" method="POST">' + . '<input type="submit" name="submit" value="Apply" hidden/>' + . '<ul class="tree"><li>' + . $this->renderFilter($this->filter) + . '</li></ul>' + . '<div class="buttons">' + . '<input type="submit" name="cancel" value="Cancel" class="button btn-cancel" />' + . '<input type="submit" name="submit" value="Apply" class="button btn-primary"/>' + . '</div>' + . '<input type="hidden" name="formUID" value="FilterEditor">' + . '</form>' + . '</div>'; + } + + protected function shorten($string, $length) + { + if (strlen($string) > $length) { + return substr($string, 0, $length) . '...'; + } + return $string; + } + + public function __toString() + { + try { + return $this->render(); + } catch (Exception $e) { + return 'ERROR in FilterEditor: ' . $e->getMessage(); + } + } +} diff --git a/library/Icinga/Web/Widget/ItemList/MigrationFileListItem.php b/library/Icinga/Web/Widget/ItemList/MigrationFileListItem.php new file mode 100644 index 0000000..007a730 --- /dev/null +++ b/library/Icinga/Web/Widget/ItemList/MigrationFileListItem.php @@ -0,0 +1,92 @@ +<?php + +/* Icinga Web 2 | (c) 2023 Icinga GmbH | GPLv2+ */ + +namespace Icinga\Web\Widget\ItemList; + +use Icinga\Application\Hook\Common\DbMigrationStep; +use ipl\Html\Attributes; +use ipl\Html\BaseHtmlElement; +use ipl\Html\Html; +use ipl\Html\HtmlElement; +use ipl\Html\HtmlString; +use ipl\Html\Text; +use ipl\I18n\Translation; +use ipl\Web\Common\BaseListItem; +use ipl\Web\Widget\EmptyState; +use ipl\Web\Widget\Icon; + +class MigrationFileListItem extends BaseListItem +{ + use Translation; + + /** @var DbMigrationStep Just for type hint */ + protected $item; + + protected function assembleVisual(BaseHtmlElement $visual): void + { + if ($this->item->getLastState()) { + $visual->getAttributes()->add('class', 'upgrade-failed'); + $visual->addHtml(new Icon('circle-xmark')); + } + } + + protected function assembleTitle(BaseHtmlElement $title): void + { + $scriptPath = $this->item->getScriptPath(); + /** @var string $parentDirs */ + $parentDirs = substr($scriptPath, (int) strpos($scriptPath, 'schema')); + $parentDirs = substr($parentDirs, 0, strrpos($parentDirs, '/') + 1); + + $title->addHtml( + new HtmlElement('span', null, Text::create($parentDirs)), + new HtmlElement( + 'span', + Attributes::create(['class' => 'version']), + Text::create($this->item->getVersion() . '.sql') + ) + ); + + if ($this->item->getLastState()) { + $title->addHtml( + new HtmlElement( + 'span', + Attributes::create(['class' => 'upgrade-failed']), + Text::create($this->translate('Upgrade failed')) + ) + ); + } + } + + protected function assembleHeader(BaseHtmlElement $header): void + { + $header->addHtml($this->createTitle()); + } + + protected function assembleCaption(BaseHtmlElement $caption): void + { + if ($this->item->getDescription()) { + $caption->addHtml(Text::create($this->item->getDescription())); + } else { + $caption->addHtml(new EmptyState(Text::create($this->translate('No description provided.')))); + } + } + + protected function assembleFooter(BaseHtmlElement $footer): void + { + if ($this->item->getLastState()) { + $footer->addHtml( + new HtmlElement( + 'section', + Attributes::create(['class' => 'caption']), + new HtmlElement('pre', null, new HtmlString(Html::escape($this->item->getLastState()))) + ) + ); + } + } + + protected function assembleMain(BaseHtmlElement $main): void + { + $main->addHtml($this->createHeader(), $this->createCaption()); + } +} diff --git a/library/Icinga/Web/Widget/ItemList/MigrationList.php b/library/Icinga/Web/Widget/ItemList/MigrationList.php new file mode 100644 index 0000000..43699d3 --- /dev/null +++ b/library/Icinga/Web/Widget/ItemList/MigrationList.php @@ -0,0 +1,133 @@ +<?php + +/* Icinga Web 2 | (c) 2023 Icinga GmbH | GPLv2+ */ + +namespace Icinga\Web\Widget\ItemList; + +use Generator; +use Icinga\Application\Hook\Common\DbMigrationStep; +use Icinga\Application\Hook\DbMigrationHook; +use Icinga\Application\MigrationManager; +use Icinga\Forms\MigrationForm; +use ipl\I18n\Translation; +use ipl\Web\Common\BaseItemList; +use ipl\Web\Widget\EmptyStateBar; + +class MigrationList extends BaseItemList +{ + use Translation; + + protected $baseAttributes = ['class' => 'item-list']; + + /** @var Generator<DbMigrationHook> */ + protected $data; + + /** @var ?MigrationForm */ + protected $migrationForm; + + /** @var bool Whether to render minimal migration list items */ + protected $minimal = true; + + /** + * Create a new migration list + * + * @param Generator<DbMigrationHook>|array<DbMigrationStep|DbMigrationHook> $data + * + * @param ?MigrationForm $form + */ + public function __construct($data, MigrationForm $form = null) + { + parent::__construct($data); + + $this->migrationForm = $form; + } + + /** + * Set whether to render minimal migration list items + * + * @param bool $minimal + * + * @return $this + */ + public function setMinimal(bool $minimal): self + { + $this->minimal = $minimal; + + return $this; + } + + /** + * Get whether to render minimal migration list items + * + * @return bool + */ + public function isMinimal(): bool + { + return $this->minimal; + } + + protected function getItemClass(): string + { + if ($this->isMinimal()) { + return MigrationListItem::class; + } + + return MigrationFileListItem::class; + } + + protected function assemble(): void + { + $itemClass = $this->getItemClass(); + if (! $this->isMinimal()) { + $this->getAttributes()->add('class', 'file-list'); + } + + /** @var DbMigrationHook $data */ + foreach ($this->data as $data) { + /** @var MigrationFileListItem|MigrationListItem $item */ + $item = new $itemClass($data, $this); + if ($item instanceof MigrationListItem && $this->migrationForm) { + $migrateButton = $this->migrationForm->createElement( + 'submit', + sprintf('migrate-%s', $data->getModuleName()), + [ + 'required' => false, + 'label' => $this->translate('Migrate'), + 'title' => sprintf( + $this->translatePlural( + 'Migrate %d pending migration', + 'Migrate all %d pending migrations', + $data->count() + ), + $data->count() + ) + ] + ); + + $mm = MigrationManager::instance(); + if ($data->isModule() && $mm->hasMigrations(DbMigrationHook::DEFAULT_MODULE)) { + $migrateButton->getAttributes() + ->set('disabled', true) + ->set( + 'title', + $this->translate( + 'Please apply all the pending migrations of Icinga Web first or use the apply all' + . ' button instead.' + ) + ); + } + + $this->migrationForm->registerElement($migrateButton); + + $item->setMigrateButton($migrateButton); + } + + $this->addHtml($item); + } + + if ($this->isEmpty()) { + $this->setTag('div'); + $this->addHtml(new EmptyStateBar(t('No items found.'))); + } + } +} diff --git a/library/Icinga/Web/Widget/ItemList/MigrationListItem.php b/library/Icinga/Web/Widget/ItemList/MigrationListItem.php new file mode 100644 index 0000000..284ce4c --- /dev/null +++ b/library/Icinga/Web/Widget/ItemList/MigrationListItem.php @@ -0,0 +1,151 @@ +<?php + +/* Icinga Web 2 | (c) 2023 Icinga GmbH | GPLv2+ */ + +namespace Icinga\Web\Widget\ItemList; + +use Icinga\Application\Hook\Common\DbMigrationStep; +use Icinga\Application\Hook\DbMigrationHook; +use ipl\Html\Attributes; +use ipl\Html\BaseHtmlElement; +use ipl\Html\Contract\FormElement; +use ipl\Html\FormattedString; +use ipl\Html\Html; +use ipl\Html\HtmlElement; +use ipl\Html\HtmlString; +use ipl\Html\Text; +use ipl\I18n\Translation; +use ipl\Web\Common\BaseListItem; +use ipl\Web\Url; +use ipl\Web\Widget\EmptyState; +use ipl\Web\Widget\Icon; +use ipl\Web\Widget\Link; +use LogicException; + +class MigrationListItem extends BaseListItem +{ + use Translation; + + /** @var ?FormElement */ + protected $migrateButton; + + /** @var DbMigrationHook Just for type hint */ + protected $item; + + /** + * Set a migration form of this list item + * + * @param FormElement $migrateButton + * + * @return $this + */ + public function setMigrateButton(FormElement $migrateButton): self + { + $this->migrateButton = $migrateButton; + + return $this; + } + + protected function assembleTitle(BaseHtmlElement $title): void + { + $title->addHtml( + FormattedString::create( + t('%s ', '<name>'), + HtmlElement::create('span', ['class' => 'subject'], $this->item->getName()) + ) + ); + } + + protected function assembleHeader(BaseHtmlElement $header): void + { + if ($this->migrateButton === null) { + throw new LogicException('Please set the migrate submit button beforehand'); + } + + $header->addHtml($this->createTitle()); + $header->addHtml($this->migrateButton); + } + + protected function assembleCaption(BaseHtmlElement $caption): void + { + $migrations = $this->item->getMigrations(); + /** @var DbMigrationStep $migration */ + $migration = array_shift($migrations); + if ($migration->getLastState()) { + if ($migration->getDescription()) { + $caption->addHtml(Text::create($migration->getDescription())); + } else { + $caption->addHtml(new EmptyState(Text::create($this->translate('No description provided.')))); + } + + $scriptPath = $migration->getScriptPath(); + /** @var string $parentDirs */ + $parentDirs = substr($scriptPath, (int) strpos($scriptPath, 'schema')); + $parentDirs = substr($parentDirs, 0, strrpos($parentDirs, '/') + 1); + + $title = new HtmlElement('div', Attributes::create(['class' => 'title'])); + $title->addHtml( + new HtmlElement('span', null, Text::create($parentDirs)), + new HtmlElement( + 'span', + Attributes::create(['class' => 'version']), + Text::create($migration->getVersion() . '.sql') + ), + new HtmlElement( + 'span', + Attributes::create(['class' => 'upgrade-failed']), + Text::create($this->translate('Upgrade failed')) + ) + ); + + $error = new HtmlElement('div', Attributes::create([ + 'class' => 'collapsible', + 'data-visible-height' => '58', + ])); + $error->addHtml(new HtmlElement('pre', null, new HtmlString(Html::escape($migration->getLastState())))); + + $errorSection = new HtmlElement('div', Attributes::create(['class' => 'errors-section',])); + $errorSection->addHtml( + new HtmlElement('header', null, new Icon('circle-xmark', ['class' => 'status-icon']), $title), + $caption, + $error + ); + + $caption->prependWrapper($errorSection); + } + } + + protected function assembleFooter(BaseHtmlElement $footer): void + { + $footer->addHtml((new MigrationList($this->item->getLatestMigrations(3)))->setMinimal(false)); + if ($this->item->count() > 3) { + $footer->addHtml( + new Link( + sprintf($this->translate('Show all %d migrations'), $this->item->count()), + Url::fromPath( + 'migrations/migration', + [DbMigrationHook::MIGRATION_PARAM => $this->item->getModuleName()] + ), + [ + 'data-base-target' => '_next', + 'class' => 'show-more' + ] + ) + ); + } + } + + protected function assembleMain(BaseHtmlElement $main): void + { + $main->addHtml($this->createHeader()); + $caption = $this->createCaption(); + if (! $caption->isEmpty()) { + $main->addHtml($caption); + } + + $footer = $this->createFooter(); + if ($footer) { + $main->addHtml($footer); + } + } +} diff --git a/library/Icinga/Web/Widget/Limiter.php b/library/Icinga/Web/Widget/Limiter.php new file mode 100644 index 0000000..d127aca --- /dev/null +++ b/library/Icinga/Web/Widget/Limiter.php @@ -0,0 +1,54 @@ +<?php +/* Icinga Web 2 | (c) 2014 Icinga Development Team | GPLv2+ */ + +namespace Icinga\Web\Widget; + +use Icinga\Forms\Control\LimiterControlForm; + +/** + * Limiter control widget + */ +class Limiter extends AbstractWidget +{ + /** + * Default limit for this instance + * + * @var int|null + */ + protected $defaultLimit; + + /** + * Get the default limit + * + * @return int|null + */ + public function getDefaultLimit() + { + return $this->defaultLimit; + } + + /** + * Set the default limit + * + * @param int $defaultLimit + * + * @return $this + */ + public function setDefaultLimit($defaultLimit) + { + $this->defaultLimit = (int) $defaultLimit; + return $this; + } + + /** + * {@inheritdoc} + */ + public function render() + { + $control = new LimiterControlForm(); + $control + ->setDefaultLimit($this->defaultLimit) + ->handleRequest(); + return (string)$control; + } +} diff --git a/library/Icinga/Web/Widget/Paginator.php b/library/Icinga/Web/Widget/Paginator.php new file mode 100644 index 0000000..5f3ef04 --- /dev/null +++ b/library/Icinga/Web/Widget/Paginator.php @@ -0,0 +1,167 @@ +<?php +/* Icinga Web 2 | (c) 2015 Icinga Development Team | GPLv2+ */ + +namespace Icinga\Web\Widget; + +use Icinga\Data\Paginatable; +use Icinga\Exception\ProgrammingError; + +/** + * Paginator + */ +class Paginator extends AbstractWidget +{ + /** + * The query the paginator widget is created for + * + * @var Paginatable + */ + protected $query; + + /** + * The view script in use + * + * @var string|array + */ + protected $viewScript = array('mixedPagination.phtml', 'default'); + + /** + * Set the query to create the paginator widget for + * + * @param Paginatable $query + * + * @return $this + */ + public function setQuery(Paginatable $query) + { + $this->query = $query; + return $this; + } + + /** + * Set the view script to use + * + * @param string|array $script + * + * @return $this + */ + public function setViewScript($script) + { + $this->viewScript = $script; + return $this; + } + + /** + * Render this paginator + */ + public function render() + { + if ($this->query === null) { + throw new ProgrammingError('Need a query to create the paginator widget for'); + } + + $itemCountPerPage = $this->query->getLimit(); + if (! $itemCountPerPage) { + return ''; // No pagination required + } + + $totalItemCount = count($this->query); + $pageCount = (int) ceil($totalItemCount / $itemCountPerPage); + $currentPage = $this->query->hasOffset() ? ($this->query->getOffset() / $itemCountPerPage) + 1 : 1; + $pagesInRange = $this->getPages($pageCount, $currentPage); + $variables = array( + 'totalItemCount' => $totalItemCount, + 'pageCount' => $pageCount, + 'itemCountPerPage' => $itemCountPerPage, + 'first' => 1, + 'current' => $currentPage, + 'last' => $pageCount, + 'pagesInRange' => $pagesInRange, + 'firstPageInRange' => min($pagesInRange), + 'lastPageInRange' => max($pagesInRange) + ); + + if ($currentPage > 1) { + $variables['previous'] = $currentPage - 1; + } + + if ($currentPage < $pageCount) { + $variables['next'] = $currentPage + 1; + } + + if (is_array($this->viewScript)) { + if ($this->viewScript[1] !== null) { + return $this->view()->partial($this->viewScript[0], $this->viewScript[1], $variables); + } + + return $this->view()->partial($this->viewScript[0], $variables); + } + + return $this->view()->partial($this->viewScript, $variables); + } + + /** + * Returns an array of "local" pages given the page count and current page number + * + * @return array + */ + protected function getPages($pageCount, $currentPage) + { + $range = array(); + + if ($pageCount < 10) { + // Show all pages if we have less than 10 + for ($i = 1; $i < 10; $i++) { + if ($i > $pageCount) { + break; + } + + $range[$i] = $i; + } + } else { + // More than 10 pages: + foreach (array(1, 2) as $i) { + $range[$i] = $i; + } + + if ($currentPage < 6) { + // We are on page 1-5 from + for ($i = 1; $i <= 7; $i++) { + $range[$i] = $i; + } + } else { + // Current page > 5 + $range[] = '...'; + + if (($pageCount - $currentPage) < 5) { + // Less than 5 pages left + $start = 5 - ($pageCount - $currentPage); + } else { + $start = 1; + } + + for ($i = $currentPage - $start; $i < ($currentPage + (4 - $start)); $i++) { + if ($i > $pageCount) { + break; + } + + $range[$i] = $i; + } + } + + if ($currentPage < ($pageCount - 2)) { + $range[] = '...'; + } + + foreach (array($pageCount - 1, $pageCount) as $i) { + $range[$i] = $i; + } + } + + if (empty($range)) { + $range[] = 1; + } + + return $range; + } +} diff --git a/library/Icinga/Web/Widget/SearchDashboard.php b/library/Icinga/Web/Widget/SearchDashboard.php new file mode 100644 index 0000000..1ce4c46 --- /dev/null +++ b/library/Icinga/Web/Widget/SearchDashboard.php @@ -0,0 +1,111 @@ +<?php +/* Icinga Web 2 | (c) 2014 Icinga Development Team | GPLv2+ */ + +namespace Icinga\Web\Widget; + +use Zend_Controller_Action_Exception; +use Icinga\Application\Icinga; +use Icinga\Web\Url; + +/** + * Class SearchDashboard display multiple search views on a single search page + */ +class SearchDashboard extends Dashboard +{ + /** + * Name for the search pane + * + * @var string + */ + const SEARCH_PANE = 'search'; + + /** + * {@inheritdoc} + */ + public function getTabs() + { + if ($this->tabs === null) { + $this->tabs = new Tabs(); + $this->tabs->add( + 'search', + array( + 'title' => t('Show Search', 'dashboard.pane.tooltip'), + 'label' => t('Search'), + 'url' => Url::fromRequest() + ) + ); + } + return $this->tabs; + } + + /** + * Load all available search dashlets from modules + * + * @param string $searchString + * + * @return $this + */ + public function search($searchString = '') + { + $pane = $this->createPane(self::SEARCH_PANE)->getPane(self::SEARCH_PANE)->setTitle(t('Search')); + $this->activate(self::SEARCH_PANE); + + $manager = Icinga::app()->getModuleManager(); + $searchUrls = array(); + + foreach ($manager->getLoadedModules() as $module) { + if ($this->getUser()->can($manager::MODULE_PERMISSION_NS . $module->getName())) { + $moduleSearchUrls = $module->getSearchUrls(); + if (! empty($moduleSearchUrls)) { + if ($searchString === '') { + $pane->add(t('Ready to search'), 'search/hint'); + return $this; + } + $searchUrls = array_merge($searchUrls, $moduleSearchUrls); + } + } + } + + usort($searchUrls, array($this, 'compareSearchUrls')); + + foreach (array_reverse($searchUrls) as $searchUrl) { + $pane->createDashlet( + $searchUrl->title . ': ' . $searchString, + Url::fromPath($searchUrl->url, array('q' => $searchString)) + )->setProgressLabel(t('Searching')); + } + + return $this; + } + + /** + * Renders the output + * + * @return string + * + * @throws Zend_Controller_Action_Exception + */ + public function render() + { + if (! $this->getPane(self::SEARCH_PANE)->hasDashlets()) { + throw new Zend_Controller_Action_Exception(t('Page not found'), 404); + } + return parent::render(); + } + + /** + * Compare search URLs based on their priority + * + * @param object $a + * @param object $b + * + * @return int + */ + private function compareSearchUrls($a, $b) + { + if ($a->priority === $b->priority) { + return 0; + } + return ($a->priority < $b->priority) ? -1 : 1; + } +} diff --git a/library/Icinga/Web/Widget/SingleValueSearchControl.php b/library/Icinga/Web/Widget/SingleValueSearchControl.php new file mode 100644 index 0000000..470518c --- /dev/null +++ b/library/Icinga/Web/Widget/SingleValueSearchControl.php @@ -0,0 +1,200 @@ +<?php +/* Icinga Web 2 | (c) 2021 Icinga GmbH | GPLv2+ */ + +namespace Icinga\Web\Widget; + +use Icinga\Application\Icinga; +use ipl\Html\Attributes; +use ipl\Html\Form; +use ipl\Html\FormElement\InputElement; +use ipl\Html\HtmlElement; +use ipl\Web\Control\SearchBar\Suggestions; +use ipl\Web\Url; + +class SingleValueSearchControl extends Form +{ + /** @var string */ + const DEFAULT_SEARCH_PARAMETER = 'q'; + + protected $defaultAttributes = ['class' => 'icinga-controls inline']; + + /** @var string */ + protected $searchParameter = self::DEFAULT_SEARCH_PARAMETER; + + /** @var string */ + protected $inputLabel; + + /** @var string */ + protected $submitLabel; + + /** @var Url */ + protected $suggestionUrl; + + /** @var array */ + protected $metaDataNames; + + /** + * Set the search parameter to use + * + * @param string $name + * @return $this + */ + public function setSearchParameter($name) + { + $this->searchParameter = $name; + + return $this; + } + + /** + * Set the input's label + * + * @param string $label + * + * @return $this + */ + public function setInputLabel($label) + { + $this->inputLabel = $label; + + return $this; + } + + /** + * Set the submit button's label + * + * @param string $label + * + * @return $this + */ + public function setSubmitLabel($label) + { + $this->submitLabel = $label; + + return $this; + } + + /** + * Set the suggestion url + * + * @param Url $url + * + * @return $this + */ + public function setSuggestionUrl(Url $url) + { + $this->suggestionUrl = $url; + + return $this; + } + + /** + * Set names for which hidden meta data elements should be created + * + * @param string ...$names + * + * @return $this + */ + public function setMetaDataNames(...$names) + { + $this->metaDataNames = $names; + + return $this; + } + + protected function assemble() + { + $suggestionsId = Icinga::app()->getRequest()->protectId('single-value-suggestions'); + + $this->addElement( + 'text', + $this->searchParameter, + [ + 'required' => true, + 'minlength' => 1, + 'autocomplete' => 'off', + 'class' => 'search', + 'data-enrichment-type' => 'completion', + 'data-term-suggestions' => '#' . $suggestionsId, + 'data-suggest-url' => $this->suggestionUrl, + 'placeholder' => $this->inputLabel + ] + ); + + if (! empty($this->metaDataNames)) { + $fieldset = new HtmlElement('fieldset'); + foreach ($this->metaDataNames as $name) { + $hiddenElement = $this->createElement('hidden', $this->searchParameter . '-' . $name); + $this->registerElement($hiddenElement); + $fieldset->addHtml($hiddenElement); + } + + $this->getElement($this->searchParameter)->prependWrapper($fieldset); + } + + $this->addElement( + 'submit', + 'btn_sumit', + [ + 'label' => $this->submitLabel, + 'class' => 'btn-primary' + ] + ); + + $this->add(HtmlElement::create('div', [ + 'id' => $suggestionsId, + 'class' => 'search-suggestions' + ])); + } + + /** + * Create a list of search suggestions based on the given groups + * + * @param array $groups + * + * @return HtmlElement + */ + public static function createSuggestions(array $groups) + { + $ul = new HtmlElement('ul'); + foreach ($groups as list($name, $entries)) { + if ($name) { + if ($entries === false) { + $ul->addHtml(HtmlElement::create('li', ['class' => 'failure-message'], [ + HtmlElement::create('em', null, t('Can\'t search:')), + $name + ])); + continue; + } elseif (empty($entries)) { + $ul->addHtml(HtmlElement::create('li', ['class' => 'failure-message'], [ + HtmlElement::create('em', null, t('No results:')), + $name + ])); + continue; + } else { + $ul->addHtml( + HtmlElement::create('li', ['class' => Suggestions::SUGGESTION_TITLE_CLASS], $name) + ); + } + } + + $index = 0; + foreach ($entries as list($label, $metaData)) { + $attributes = [ + 'value' => $label, + 'type' => 'button', + 'tabindex' => -1 + ]; + foreach ($metaData as $key => $value) { + $attributes['data-' . $key] = $value; + } + + $liAtrs = ['class' => $index === 0 ? 'default' : null]; + $ul->addHtml(new HtmlElement('li', Attributes::create($liAtrs), new InputElement(null, $attributes))); + $index++; + } + } + + return $ul; + } +} diff --git a/library/Icinga/Web/Widget/SortBox.php b/library/Icinga/Web/Widget/SortBox.php new file mode 100644 index 0000000..72b6f58 --- /dev/null +++ b/library/Icinga/Web/Widget/SortBox.php @@ -0,0 +1,260 @@ +<?php +/* Icinga Web 2 | (c) 2013 Icinga Development Team | GPLv2+ */ + +namespace Icinga\Web\Widget; + +use Icinga\Application\Icinga; +use Icinga\Data\Sortable; +use Icinga\Data\SortRules; +use Icinga\Web\Form; +use Icinga\Web\Request; + +/** + * SortBox widget + * + * The "SortBox" Widget allows you to create a generic sort input for sortable views. It automatically creates a select + * box with all sort options and a dropbox with the sort direction. It also handles automatic submission of sorting + * changes and draws an additional submit button when JavaScript is disabled. + * + * The constructor takes a string for the component name and an array containing the select options, where the key is + * the value to be submitted and the value is the label that will be shown. You then should call setRequest in order + * to make sure the form is correctly populated when a request with a sort parameter is being made. + * + * Call setQuery in case you'll do not want to handle URL parameters manually, but to automatically apply the user's + * chosen sort rules on the given sortable query. This will also allow the SortBox to display the user the correct + * default sort rules if the given query provides already some sort rules. + */ +class SortBox extends AbstractWidget +{ + /** + * An array containing all sort columns with their associated labels + * + * @var array + */ + protected $sortFields; + + /** + * An array containing default sort directions for specific columns + * + * The first entry will be used as default sort column. + * + * @var array + */ + protected $sortDefaults; + + /** + * The name used to uniquely identfy the forms being created + * + * @var string + */ + protected $name; + + /** + * The request to fetch sort rules from + * + * @var Request + */ + protected $request; + + /** + * The query to apply sort rules on + * + * @var Sortable + */ + protected $query; + + /** + * Create a SortBox with the entries from $sortFields + * + * @param string $name The name for the SortBox + * @param array $sortFields An array containing the columns and their labels to be displayed in the SortBox + * @param array $sortDefaults An array containing default sort directions for specific columns + */ + public function __construct($name, array $sortFields, array $sortDefaults = null) + { + $this->name = $name; + $this->sortFields = $sortFields; + $this->sortDefaults = $sortDefaults; + } + + /** + * Create a SortBox + * + * @param string $name The name for the SortBox + * @param array $sortFields An array containing the columns and their labels to be displayed in the SortBox + * @param array $sortDefaults An array containing default sort directions for specific columns + * + * @return SortBox + */ + public static function create($name, array $sortFields, array $sortDefaults = null) + { + return new static($name, $sortFields, $sortDefaults); + } + + /** + * Set the request to fetch sort rules from + * + * @param Request $request + * + * @return $this + */ + public function setRequest($request) + { + $this->request = $request; + return $this; + } + + /** + * Set the query to apply sort rules on + * + * @param Sortable $query + * + * @return $this + */ + public function setQuery(Sortable $query) + { + $this->query = $query; + return $this; + } + + /** + * Return the default sort rule for the query + * + * @param string $column An optional column + * + * @return array An array of two values: $column, $direction + */ + protected function getSortDefaults($column = null) + { + $direction = null; + if (! empty($this->sortDefaults) && ($column === null || isset($this->sortDefaults[$column]))) { + if ($column === null) { + reset($this->sortDefaults); + $column = key($this->sortDefaults); + } + + $direction = $this->sortDefaults[$column]; + } elseif ($this->query !== null && $this->query instanceof SortRules) { + $sortRules = $this->query->getSortRules(); + if ($column === null) { + $column = key($sortRules); + } + + if ($column !== null && isset($sortRules[$column]['order'])) { + $direction = strtoupper($sortRules[$column]['order']) === Sortable::SORT_DESC ? 'desc' : 'asc'; + } + } elseif ($column === null) { + reset($this->sortFields); + $column = key($this->sortFields); + } + + return array($column, $direction); + } + + /** + * Apply the sort rules from the given or current request on the query + * + * @param Request $request + * + * @return $this + */ + public function handleRequest(Request $request = null) + { + if ($this->query !== null) { + if ($request === null) { + $request = Icinga::app()->getRequest(); + } + + if (! ($sort = $request->getParam('sort'))) { + list($sort, $dir) = $this->getSortDefaults(); + } else { + list($_, $dir) = $this->getSortDefaults($sort); + } + + $this->query->order($sort, $request->getParam('dir', $dir)); + } + + return $this; + } + + /** + * Render this SortBox as HTML + * + * @return string + */ + public function render() + { + $columnForm = new Form(); + $columnForm->setTokenDisabled(); + $columnForm->setName($this->name . '-column'); + $columnForm->setAttrib('class', 'icinga-controls inline'); + $columnForm->addElement( + 'select', + 'sort', + array( + 'autosubmit' => true, + 'label' => $this->view()->translate('Sort by'), + 'multiOptions' => $this->sortFields, + 'decorators' => array( + array('ViewHelper'), + array('Label') + ) + ) + ); + + $column = null; + if ($this->request) { + $url = $this->request->getUrl(); + if ($url->hasParam('sort')) { + $column = $url->getParam('sort'); + + if ($url->hasParam('dir')) { + $direction = $url->getParam('dir'); + } else { + list($_, $direction) = $this->getSortDefaults($column); + } + } elseif ($url->hasParam('dir')) { + $direction = $url->getParam('dir'); + list($column, $_) = $this->getSortDefaults(); + } + } + + if ($column === null) { + list($column, $direction) = $this->getSortDefaults(); + } + + // TODO(el): ToggleButton :) + $toggle = array('asc' => 'sort-name-down', 'desc' => 'sort-name-up'); + unset($toggle[isset($direction) ? strtolower($direction) : 'asc']); + $newDirection = key($toggle); + $icon = current($toggle); + + $orderForm = new Form(); + $orderForm->setTokenDisabled(); + $orderForm->setName($this->name . '-order'); + $orderForm->setAttrib('class', 'inline sort-direction-control'); + $orderForm->addElement( + 'hidden', + 'dir' + ); + $orderForm->addElement( + 'button', + 'btn_submit', + array( + 'ignore' => true, + 'type' => 'submit', + 'label' => $this->view()->icon($icon), + 'decorators' => array('ViewHelper'), + 'escape' => false, + 'class' => 'link-button spinner', + 'value' => 'submit', + 'title' => t('Change sort direction'), + ) + ); + + + $columnForm->populate(array('sort' => $column)); + $orderForm->populate(array('dir' => $newDirection)); + return '<div class="sort-control">' . $columnForm . $orderForm . '</div>'; + } +} diff --git a/library/Icinga/Web/Widget/Tab.php b/library/Icinga/Web/Widget/Tab.php new file mode 100644 index 0000000..a367f00 --- /dev/null +++ b/library/Icinga/Web/Widget/Tab.php @@ -0,0 +1,323 @@ +<?php +/* Icinga Web 2 | (c) 2013 Icinga Development Team | GPLv2+ */ + +namespace Icinga\Web\Widget; + +use Icinga\Web\Url; + +/** + * A single tab, usually used through the tabs widget + * + * Will generate an <li> list item, with an optional link and icon + * + * @property string $name Tab identifier + * @property string $title Tab title + * @property string $icon Icon URL, preferrably relative to the Icinga + * base URL + * @property string|URL $url Action URL, preferrably relative to the Icinga + * base URL + * @property string $urlParams Action URL Parameters + * + */ +class Tab extends AbstractWidget +{ + /** + * Whether this tab is currently active + * + * @var bool + */ + private $active = false; + + /** + * Default values for widget properties + * + * @var array + */ + private $name = null; + + /** + * The title displayed for this tab + * + * @var string + */ + private $title = ''; + + /** + * The label displayed for this tab + * + * @var string + */ + private $label = ''; + + /** + * The Url this tab points to + * + * @var Url|null + */ + private $url = null; + + /** + * The parameters for this tab's Url + * + * @var array + */ + private $urlParams = array(); + + /** + * The icon image to use for this tab or null if none + * + * @var string|null + */ + private $icon = null; + + /** + * The icon class to use if $icon is null + * + * @var string|null + */ + private $iconCls = null; + + /** + * Additional a tag attributes + * + * @var array + */ + private $tagParams; + + /** + * Whether to open the link target on a new page + * + * @var boolean + */ + private $targetBlank = false; + + /** + * Data base target that determines if the link will be opened in a side-bar or in the main container + * + * @var null + */ + private $baseTarget = null; + + /** + * Sets an icon image for this tab + * + * @param string $icon The url of the image to use + */ + public function setIcon($icon) + { + if (is_string($icon) && strpos($icon, '.') !== false) { + $icon = Url::fromPath($icon); + } + $this->icon = $icon; + } + + /** + * Set's an icon class that will be used in an <i> tag if no icon image is set + * + * @param string $iconCls The CSS class of the icon to use + */ + public function setIconCls($iconCls) + { + $this->iconCls = $iconCls; + } + + /** + * @param mixed $name + */ + public function setName($name) + { + $this->name = $name; + } + + /** + * @return mixed + */ + public function getName() + { + return $this->name; + } + + /** + * Set the tab label + * + * @param string $label + */ + public function setLabel($label) + { + $this->label = $label; + } + + /** + * Get the tab label + * + * @return string + */ + public function getLabel() + { + if (! $this->label) { + return $this->title; + } + + return $this->label; + } + + /** + * @param mixed $title + */ + public function setTitle($title) + { + $this->title = $title; + } + + /** + * Set the Url this tab points to + * + * @param string|Url $url The Url to use for this tab + */ + public function setUrl($url) + { + if (is_string($url)) { + $url = Url::fromPath($url); + } + $this->url = $url; + } + + /** + * Get the tab's target URL + * + * @return Url + */ + public function getUrl() + { + return $this->url; + } + + /** + * Set the parameters to be set for this tabs Url + * + * @param array $url The Url parameters to set + */ + public function setUrlParams(array $urlParams) + { + $this->urlParams = $urlParams; + } + + /** + * Set additional a tag attributes + * + * @param array $tagParams + */ + public function setTagParams(array $tagParams) + { + $this->tagParams = $tagParams; + } + + public function setTargetBlank($value = true) + { + $this->targetBlank = $value; + } + + public function setBaseTarget($value) + { + $this->baseTarget = $value; + } + + /** + * Create a new Tab with the given properties + * + * Allowed properties are all properties for which a setter exists + * + * @param array $properties An array of properties + */ + public function __construct(array $properties = array()) + { + foreach ($properties as $name => $value) { + $setter = 'set' . ucfirst($name); + if (method_exists($this, $setter)) { + $this->$setter($value); + } + } + } + + /** + * Set this tab active (default) or inactive + * + * This is usually done through the tabs container widget, therefore it + * is not a good idea to directly call this function + * + * @param bool $active Whether the tab should be active + * + * @return $this + */ + public function setActive($active = true) + { + $this->active = (bool) $active; + return $this; + } + + /** + * @see Widget::render() + */ + public function render() + { + $view = $this->view(); + $classes = array(); + if ($this->active) { + $classes[] = 'active'; + } + + $caption = $view->escape($this->getLabel()); + $tagParams = $this->tagParams; + if ($this->targetBlank) { + // add warning to links that open in new tabs to improve accessibility, as recommended by WCAG20 G201 + $caption .= '<span class="info-box display-on-hover"> opens in new window </span>'; + $tagParams['target'] ='_blank'; + } + + if ($this->title) { + if ($tagParams !== null) { + $tagParams['title'] = $this->title; + $tagParams['aria-label'] = $this->title; + } else { + $tagParams = array( + 'title' => $this->title, + 'aria-label' => $this->title + ); + } + } + + if ($this->baseTarget !== null) { + $tagParams['data-base-target'] = $this->baseTarget; + } + + if ($this->icon !== null) { + if (strpos($this->icon, '.') === false) { + $caption = $view->icon($this->icon) . $caption; + } else { + $caption = $view->img($this->icon, null, array('class' => 'icon')) . $caption; + } + } + + if ($this->url !== null) { + $this->url->overwriteParams($this->urlParams); + + if ($tagParams !== null) { + $params = $view->propertiesToString($tagParams); + } else { + $params = ''; + } + + $tab = sprintf( + '<a href="%s"%s>%s</a>', + $this->view()->escape($this->url->getAbsoluteUrl()), + $params, + $caption + ); + } else { + $tab = $caption; + } + + $class = empty($classes) ? '' : sprintf(' class="%s"', implode(' ', $classes)); + return '<li ' . $class . '>' . $tab . "</li>\n"; + } +} diff --git a/library/Icinga/Web/Widget/Tabextension/DashboardAction.php b/library/Icinga/Web/Widget/Tabextension/DashboardAction.php new file mode 100644 index 0000000..a3e6c43 --- /dev/null +++ b/library/Icinga/Web/Widget/Tabextension/DashboardAction.php @@ -0,0 +1,35 @@ +<?php +/* Icinga Web 2 | (c) 2013 Icinga Development Team | GPLv2+ */ + +namespace Icinga\Web\Widget\Tabextension; + +use Icinga\Web\Url; +use Icinga\Web\Widget\Tabs; + +/** + * Tabextension that allows to add the current URL to a dashboard + * + * Displayed as a dropdown field in the tabs + */ +class DashboardAction implements Tabextension +{ + /** + * Applies the dashboard actions to the provided tabset + * + * @param Tabs $tabs The tabs object to extend with + */ + public function apply(Tabs $tabs) + { + $tabs->addAsDropdown( + 'dashboard', + array( + 'icon' => 'dashboard', + 'label' => t('Add To Dashboard'), + 'url' => Url::fromPath('dashboard/new-dashlet'), + 'urlParams' => array( + 'url' => rawurlencode(Url::fromRequest()->getRelativeUrl()) + ) + ) + ); + } +} diff --git a/library/Icinga/Web/Widget/Tabextension/DashboardSettings.php b/library/Icinga/Web/Widget/Tabextension/DashboardSettings.php new file mode 100644 index 0000000..fc7412a --- /dev/null +++ b/library/Icinga/Web/Widget/Tabextension/DashboardSettings.php @@ -0,0 +1,39 @@ +<?php +/* Icinga Web 2 | (c) 2014 Icinga Development Team | GPLv2+ */ + +namespace Icinga\Web\Widget\Tabextension; + +use Icinga\Web\Url; +use Icinga\Web\Widget\Tabs; + +/** + * Dashboard settings + */ +class DashboardSettings implements Tabextension +{ + /** + * Apply this tabextension to the provided tabs + * + * @param Tabs $tabs The tabbar to modify + */ + public function apply(Tabs $tabs) + { + $tabs->addAsDropdown( + 'dashboard_add', + array( + 'icon' => 'dashboard', + 'label' => t('Add Dashlet'), + 'url' => Url::fromPath('dashboard/new-dashlet') + ) + ); + + $tabs->addAsDropdown( + 'dashboard_settings', + array( + 'icon' => 'dashboard', + 'label' => t('Settings'), + 'url' => Url::fromPath('dashboard/settings') + ) + ); + } +} diff --git a/library/Icinga/Web/Widget/Tabextension/MenuAction.php b/library/Icinga/Web/Widget/Tabextension/MenuAction.php new file mode 100644 index 0000000..d713892 --- /dev/null +++ b/library/Icinga/Web/Widget/Tabextension/MenuAction.php @@ -0,0 +1,35 @@ +<?php +/* Icinga Web 2 | (c) 2013 Icinga Development Team | GPLv2+ */ + +namespace Icinga\Web\Widget\Tabextension; + +use Icinga\Web\Url; +use Icinga\Web\Widget\Tabs; + +/** + * Tabextension that allows to add the current URL as menu entry + * + * Displayed as a dropdown field in the tabs + */ +class MenuAction implements Tabextension +{ + /** + * Applies the menu actions to the provided tabset + * + * @param Tabs $tabs The tabs object to extend with + */ + public function apply(Tabs $tabs) + { + $tabs->addAsDropdown( + 'menu-entry', + array( + 'icon' => 'menu', + 'label' => t('Add To Menu'), + 'url' => Url::fromPath('navigation/add'), + 'urlParams' => array( + 'url' => rawurlencode(Url::fromRequest()->getRelativeUrl()) + ) + ) + ); + } +} diff --git a/library/Icinga/Web/Widget/Tabextension/OutputFormat.php b/library/Icinga/Web/Widget/Tabextension/OutputFormat.php new file mode 100644 index 0000000..d5d83af --- /dev/null +++ b/library/Icinga/Web/Widget/Tabextension/OutputFormat.php @@ -0,0 +1,114 @@ +<?php +/* Icinga Web 2 | (c) 2013 Icinga Development Team | GPLv2+ */ + +namespace Icinga\Web\Widget\Tabextension; + +use Icinga\Application\Platform; +use Icinga\Application\Hook; +use Icinga\Web\Url; +use Icinga\Web\Widget\Tab; +use Icinga\Web\Widget\Tabs; + +/** + * Tabextension that offers different output formats for the user in the dropdown area + */ +class OutputFormat implements Tabextension +{ + /** + * PDF output type + */ + const TYPE_PDF = 'pdf'; + + /** + * JSON output type + */ + const TYPE_JSON = 'json'; + + /** + * CSV output type + */ + const TYPE_CSV = 'csv'; + + /** + * An array of tabs to be added to the dropdown area + * + * @var array + */ + private $tabs = array(); + + /** + * Create a new OutputFormat extender + * + * In general, it's assumed that all types are supported when an outputFormat extension + * is added, so this class offers to remove specific types instead of adding ones + * + * @param array $disabled An array of output types to <b>not</b> show. + */ + public function __construct(array $disabled = array()) + { + foreach ($this->getSupportedTypes() as $type => $tabConfig) { + if (!in_array($type, $disabled)) { + $tabConfig['url'] = Url::fromRequest(); + $tab = new Tab($tabConfig); + $tab->setTargetBlank(); + $this->tabs[] = $tab; + } + } + } + + /** + * Applies the format selectio to the provided tabset + * + * @param Tabs $tabs The tabs object to extend with + * + * @see Tabextension::apply() + */ + public function apply(Tabs $tabs) + { + foreach ($this->tabs as $tab) { + $tabs->addAsDropdown($tab->getName(), $tab); + } + } + + /** + * Return an array containing the tab definitions for all supported types + * + * Using array_keys on this array or isset allows to check whether a + * requested type is supported + * + * @return array + */ + public function getSupportedTypes() + { + $supportedTypes = array(); + + $pdfexport = Hook::has('Pdfexport'); + + if ($pdfexport || Platform::extensionLoaded('gd')) { + $supportedTypes[self::TYPE_PDF] = array( + 'name' => 'pdf', + 'label' => 'PDF', + 'icon' => 'file-pdf', + 'urlParams' => array('format' => 'pdf'), + ); + } + + $supportedTypes[self::TYPE_CSV] = array( + 'name' => 'csv', + 'label' => 'CSV', + 'icon' => 'file-excel', + 'urlParams' => array('format' => 'csv') + ); + + if (Platform::extensionLoaded('json')) { + $supportedTypes[self::TYPE_JSON] = array( + 'name' => 'json', + 'label' => 'JSON', + 'icon' => 'doc-text', + 'urlParams' => array('format' => 'json') + ); + } + + return $supportedTypes; + } +} diff --git a/library/Icinga/Web/Widget/Tabextension/Tabextension.php b/library/Icinga/Web/Widget/Tabextension/Tabextension.php new file mode 100644 index 0000000..ea49c4b --- /dev/null +++ b/library/Icinga/Web/Widget/Tabextension/Tabextension.php @@ -0,0 +1,25 @@ +<?php +/* Icinga Web 2 | (c) 2013 Icinga Development Team | GPLv2+ */ + +namespace Icinga\Web\Widget\Tabextension; + +use Icinga\Web\Widget\Tabs; + +/** + * Tabextension interface that allows to extend a tabbar with reusable components + * + * Tabs can be either extended by creating a `Tabextension` and calling the `apply()` method + * or by calling the `\Icinga\Web\Widget\Tabs` `extend()` method and providing + * a tab extension + * + * @see \Icinga\Web\Widget\Tabs::extend() + */ +interface Tabextension +{ + /** + * Apply this tabextension to the provided tabs + * + * @param Tabs $tabs The tabbar to modify + */ + public function apply(Tabs $tabs); +} diff --git a/library/Icinga/Web/Widget/Tabs.php b/library/Icinga/Web/Widget/Tabs.php new file mode 100644 index 0000000..9efa423 --- /dev/null +++ b/library/Icinga/Web/Widget/Tabs.php @@ -0,0 +1,453 @@ +<?php +/* Icinga Web 2 | (c) 2013 Icinga Development Team | GPLv2+ */ + +namespace Icinga\Web\Widget; + +use Exception; +use Icinga\Exception\Http\HttpNotFoundException; +use Icinga\Exception\ProgrammingError; +use Icinga\Web\Url; +use Icinga\Web\Widget\Tabextension\Tabextension; +use Icinga\Application\Icinga; +use Countable; + +/** + * Navigation tab widget + */ +class Tabs extends AbstractWidget implements Countable +{ + /** + * Template used for the base tabs + * + * @var string + */ + private $baseTpl = <<< 'EOT' +<ul class="tabs primary-nav nav"> + {TABS} + {DROPDOWN} + {REFRESH} + {CLOSE} +</ul> +EOT; + + /** + * Template used for the tabs dropdown + * + * @var string + */ + private $dropdownTpl = <<< 'EOT' +<li class="dropdown-nav-item"> + <a href="#" class="dropdown-toggle" title="{TITLE}" aria-label="{TITLE}"> + <i aria-hidden="true" class="icon-down-open"></i> + </a> + <ul class="nav"> + {TABS} + </ul> +</li> +EOT; + + /** + * Template used for the close-button + * + * @var string + */ + private $closeTpl = <<< 'EOT' +<li class="close-container-btn"> + <a href="#" title="{TITLE}" aria-label="{TITLE}" class="close-container-control"> + <i aria-hidden="true" class="icon-cancel"></i> + </a> +</li> +EOT; + + /** + * Template used for the refresh icon + * + * @var string + */ + private $refreshTpl = <<< 'EOT' +<li> + <a class="refresh-container-control spinner" href="{URL}" title="{TITLE}" aria-label="{LABEL}"> + <i aria-hidden="true" class="icon-cw"></i> + </a> +</li> +EOT; + + /** + * This is where single tabs added to this container will be stored + * + * @var array + */ + private $tabs = array(); + + /** + * The name of the currently activated tab + * + * @var string + */ + private $active; + + /** + * Array of tab names which should be displayed in a dropdown + * + * @var array + */ + private $dropdownTabs = array(); + + /** + * Whether only the close-button should by rendered for this tab + * + * @var bool + */ + private $closeButtonOnly = false; + + /** + * Whether the tabs should contain a close-button + * + * @var bool + */ + private $closeTab = true; + + /** + * CSS class name(s) for the <ul> element + * + * @var string + */ + private $tab_class; + + /** + * Set whether the current tab is closable + */ + public function hideCloseButton() + { + $this->closeTab = false; + } + + /** + * Activate the tab with the given name + * + * If another tab is currently active it will be deactivated + * + * @param string $name Name of the tab going to be activated + * + * @return $this + * + * @throws HttpNotFoundException When the tab w/ the given name does not exist + * + */ + public function activate($name) + { + if (! $this->has($name)) { + throw new HttpNotFoundException('Can\'t activate tab %s. Tab does not exist', $name); + } + + if ($this->active !== null) { + $this->tabs[$this->active]->setActive(false); + } + $this->get($name)->setActive(); + $this->active = $name; + + return $this; + } + + /** + * Return the name of the active tab + * + * @return string + */ + public function getActiveName() + { + return $this->active; + } + + /** + * Set the CSS class name(s) for the <ul> element + * + * @param string $name CSS class name(s) + * + * @return $this + */ + public function setClass($name) + { + $this->tab_class = $name; + return $this; + } + + /** + * Whether the given tab name exists + * + * @param string $name Tab name + * + * @return bool + */ + public function has($name) + { + return array_key_exists($name, $this->tabs); + } + + /** + * Whether the given tab name exists + * + * @param string $name The tab you're interested in + * + * @return Tab + * + * @throws ProgrammingError When the given tab name doesn't exist + */ + public function get($name) + { + if (!$this->has($name)) { + return null; + } + return $this->tabs[$name]; + } + + /** + * Add a new tab + * + * A unique tab name is required, the Tab itself can either be an array + * with tab properties or an instance of an existing Tab + * + * @param string $name The new tab name + * @param array|Tab $tab The tab itself of its properties + * + * @return $this + * + * @throws ProgrammingError When the tab name already exists + */ + public function add($name, $tab) + { + if ($this->has($name)) { + throw new ProgrammingError( + 'Cannot add a tab named "%s" twice"', + $name + ); + } + return $this->set($name, $tab); + } + + /** + * Set a tab + * + * A unique tab name is required, will be replaced in case it already + * exists. The tab can either be an array with tab properties or an instance + * of an existing Tab + * + * @param string $name The new tab name + * @param array|Tab $tab The tab itself of its properties + * + * @return $this + */ + public function set($name, $tab) + { + if ($tab instanceof Tab) { + $this->tabs[$name] = $tab; + } else { + $this->tabs[$name] = new Tab($tab + array('name' => $name)); + } + return $this; + } + + /** + * Remove a tab + * + * @param string $name + * + * @return $this + */ + public function remove($name) + { + if ($this->has($name)) { + unset($this->tabs[$name]); + if (($dropdownIndex = array_search($name, $this->dropdownTabs, true)) !== false) { + array_splice($this->dropdownTabs, $dropdownIndex, 1); + } + } + + return $this; + } + + /** + * Add a tab to the dropdown on the right side of the tab-bar. + * + * @param $name + * @param $tab + */ + public function addAsDropdown($name, $tab) + { + $this->set($name, $tab); + $this->dropdownTabs[] = $name; + $this->dropdownTabs = array_unique($this->dropdownTabs); + } + + /** + * Render the dropdown area with its tabs and return the resulting HTML + * + * @return mixed|string + */ + private function renderDropdownTabs() + { + if (empty($this->dropdownTabs)) { + return ''; + } + $tabs = ''; + foreach ($this->dropdownTabs as $tabname) { + $tab = $this->get($tabname); + if ($tab === null) { + continue; + } + $tabs .= $tab; + } + return str_replace(array('{TABS}', '{TITLE}'), array($tabs, t('Dropdown menu')), $this->dropdownTpl); + } + + /** + * Render all tabs, except the ones in dropdown area and return the resulting HTML + * + * @return string + */ + private function renderTabs() + { + $tabs = ''; + foreach ($this->tabs as $name => $tab) { + // ignore tabs added to dropdown + if (in_array($name, $this->dropdownTabs)) { + continue; + } + $tabs .= $tab; + } + return $tabs; + } + + private function renderCloseTab() + { + return str_replace('{TITLE}', t('Close container'), $this->closeTpl); + } + + private function renderRefreshTab() + { + $url = Url::fromRequest(); + $tab = $this->get($this->getActiveName()); + + if ($tab !== null) { + $label = $this->view()->escape( + $tab->getLabel() + ); + } + + if (! empty($label)) { + $caption = $label; + } else { + $caption = t('Content'); + } + + $label = sprintf(t('Refresh the %s'), $caption); + $title = $label; + + $tpl = str_replace( + array( + '{URL}', + '{TITLE}', + '{LABEL}' + ), + array( + $this->view()->escape($url->getAbsoluteUrl()), + $title, + $label + ), + $this->refreshTpl + ); + + return $tpl; + } + + /** + * Render to HTML + * + * @see Widget::render + */ + public function render() + { + if (empty($this->tabs) || true === $this->closeButtonOnly) { + $tabs = ''; + $drop = ''; + } else { + $tabs = $this->renderTabs(); + $drop = $this->renderDropdownTabs(); + } + $close = $this->closeTab ? $this->renderCloseTab() : ''; + $refresh = $this->renderRefreshTab(); + + return str_replace( + array( + '{TABS}', + '{DROPDOWN}', + '{REFRESH}', + '{CLOSE}' + ), + array( + $tabs, + $drop, + $refresh, + $close + ), + $this->baseTpl + ); + } + + public function __toString() + { + try { + $html = $this->render(); + } catch (Exception $e) { + return htmlspecialchars($e->getMessage()); + } + return $html; + } + + /** + * Return the number of tabs + * + * @return int + * + * @see Countable + */ + public function count(): int + { + return count($this->tabs); + } + + /** + * Return all tabs contained in this tab panel + * + * @return array + */ + public function getTabs() + { + return $this->tabs; + } + + /** + * Whether to hide all elements except of the close button + * + * @param bool $value + * @return Tabs fluent interface + */ + public function showOnlyCloseButton($value = true) + { + $this->closeButtonOnly = $value; + return $this; + } + + /** + * Apply a Tabextension on this tabs object + * + * @param Tabextension $tabextension + * + * @return $this + */ + public function extend(Tabextension $tabextension) + { + $tabextension->apply($this); + return $this; + } +} diff --git a/library/Icinga/Web/Widget/Widget.php b/library/Icinga/Web/Widget/Widget.php new file mode 100644 index 0000000..879858a --- /dev/null +++ b/library/Icinga/Web/Widget/Widget.php @@ -0,0 +1,24 @@ +<?php +/* Icinga Web 2 | (c) 2013 Icinga Development Team | GPLv2+ */ + +namespace Icinga\Web\Widget; + +use Icinga\Web\View; +use Zend_View_Abstract; + +/** + * Abstract class for reusable view elements that can be + * rendered to a view + * + */ +interface Widget +{ + /** + * Renders this widget via the given view and returns the + * HTML as a string + * + * @param \Zend_View_Abstract $view + * @return string + */ + // public function render(Zend_View_Abstract $view); +} diff --git a/library/Icinga/Web/Window.php b/library/Icinga/Web/Window.php new file mode 100644 index 0000000..158483a --- /dev/null +++ b/library/Icinga/Web/Window.php @@ -0,0 +1,125 @@ +<?php +/* Icinga Web 2 | (c) 2014 Icinga Development Team | GPLv2+ */ + +namespace Icinga\Web; + +use Icinga\Application\Icinga; +use Icinga\Web\Session\SessionNamespace; + +class Window +{ + const UNDEFINED = 'undefined'; + + /** @var Window */ + protected static $window; + + /** @var string */ + protected $id; + + /** @var string */ + protected $containerId; + + public function __construct($id) + { + $parts = explode('_', $id, 2); + if (isset($parts[1])) { + $this->id = $parts[0]; + $this->containerId = $id; + } else { + $this->id = $id; + } + } + + /** + * Get whether the window's ID is undefined + * + * @return bool + */ + public function isUndefined() + { + return $this->id === self::UNDEFINED; + } + + /** + * Get the window's ID + * + * @return string + */ + public function getId() + { + return $this->id; + } + + /** + * Get the container's ID + * + * @return string + */ + public function getContainerId() + { + return $this->containerId ?: $this->id; + } + + /** + * Return a window-aware session by using the given prefix + * + * @param string $prefix The prefix to use + * @param bool $reset Whether to reset any existing session-data + * + * @return SessionNamespace + */ + public function getSessionNamespace($prefix, $reset = false) + { + $session = Session::getSession(); + + $identifier = $prefix . '_' . $this->getId(); + if ($reset && $session->hasNamespace($identifier)) { + $session->removeNamespace($identifier); + } + + $namespace = $session->getNamespace($identifier); + $nsUndef = $prefix . '_' . self::UNDEFINED; + + if (! $reset && ! $this->isUndefined() && $session->hasNamespace($nsUndef)) { + // We may not have any window-id on the very first request. Now we add + // all values from the namespace, that has been created in this case, + // to the new one and remove it afterwards. + foreach ($session->getNamespace($nsUndef) as $name => $value) { + $namespace->set($name, $value); + } + + $session->removeNamespace($nsUndef); + } + + return $namespace; + } + + /** + * Generate a random string + * + * @return string + */ + public static function generateId() + { + $letters = 'abcefghijklmnopqrstuvwxyz'; + return substr(str_shuffle($letters), 0, 12); + } + + /** + * @return Window + */ + public static function getInstance() + { + if (! isset(static::$window)) { + $id = Icinga::app()->getRequest()->getHeader('X-Icinga-WindowId'); + if (empty($id) || $id === static::UNDEFINED) { + Icinga::app()->getResponse()->setOverrideWindowId(); + $id = static::generateId(); + } + + static::$window = new Window($id); + } + + return static::$window; + } +} diff --git a/library/Icinga/Web/Wizard.php b/library/Icinga/Web/Wizard.php new file mode 100644 index 0000000..9a1b8b6 --- /dev/null +++ b/library/Icinga/Web/Wizard.php @@ -0,0 +1,720 @@ +<?php +/* Icinga Web 2 | (c) 2014 Icinga Development Team | GPLv2+ */ + +namespace Icinga\Web; + +use Icinga\Forms\ConfigForm; +use Icinga\Module\Setup\Forms\ModulePage; +use LogicException; +use InvalidArgumentException; +use Icinga\Web\Session\SessionNamespace; +use Icinga\Web\Form\Decorator\ElementDoubler; + +/** + * Container and controller for form based wizards + */ +class Wizard +{ + /** + * An integer describing the wizard's forward direction + */ + const FORWARD = 0; + + /** + * An integer describing the wizard's backward direction + */ + const BACKWARD = 1; + + /** + * An integer describing that the wizard does not change its position + */ + const NO_CHANGE = 2; + + /** + * The name of the button to advance the wizard's position + */ + const BTN_NEXT = 'btn_next'; + + /** + * The name of the button to rewind the wizard's position + */ + const BTN_PREV = 'btn_prev'; + + /** + * The name and id of the element for showing the user an activity indicator when advancing the wizard + */ + const PROGRESS_ELEMENT = 'wizard_progress'; + + /** + * This wizard's parent + * + * @var Wizard + */ + protected $parent; + + /** + * The name of the wizard's current page + * + * @var string + */ + protected $currentPage; + + /** + * The pages being part of this wizard + * + * @var array + */ + protected $pages = array(); + + /** + * Initialize a new wizard + */ + public function __construct() + { + $this->init(); + } + + /** + * Run additional initialization routines + * + * Should be implemented by subclasses to add pages to the wizard. + */ + protected function init() + { + } + + /** + * Return this wizard's parent or null in case it has none + * + * @return Wizard|null + */ + public function getParent() + { + return $this->parent; + } + + /** + * Set this wizard's parent + * + * @param Wizard $wizard The parent wizard + * + * @return $this + */ + public function setParent(Wizard $wizard) + { + $this->parent = $wizard; + return $this; + } + + /** + * Return the pages being part of this wizard + * + * In case this is a nested wizard a flattened array of all contained pages is returned. + * + * @return array + */ + public function getPages() + { + $pages = array(); + foreach ($this->pages as $page) { + if ($page instanceof self) { + $pages = array_merge($pages, $page->getPages()); + } else { + $pages[] = $page; + } + } + + return $pages; + } + + /** + * Return the page with the given name + * + * Note that it's also possible to retrieve a nested wizard's page by using this method. + * + * @param string $name The name of the page to return + * + * @return ModulePage|Form|null The page or null in case there is no page with the given name + */ + public function getPage($name) + { + foreach ($this->getPages() as $page) { + if ($name === $page->getName()) { + return $page; + } + } + } + + /** + * Add a new page or wizard to this wizard + * + * @param Form|Wizard $page The page or wizard to add to the wizard + * + * @return $this + */ + public function addPage($page) + { + if (! $page instanceof Form && ! $page instanceof self) { + throw new InvalidArgumentException( + 'The $page argument must be an instance of Icinga\Web\Form ' + . 'or Icinga\Web\Wizard but is of type: ' . get_class($page) + ); + } elseif ($page instanceof self) { + $page->setParent($this); + } + + $this->pages[] = $page; + return $this; + } + + /** + * Add multiple pages or wizards to this wizard + * + * @param array $pages The pages or wizards to add to the wizard + * + * @return $this + */ + public function addPages(array $pages) + { + foreach ($pages as $page) { + $this->addPage($page); + } + + return $this; + } + + /** + * Assert that this wizard has any pages + * + * @throws LogicException In case this wizard has no pages + */ + protected function assertHasPages() + { + $pages = $this->getPages(); + if (count($pages) < 2) { + throw new LogicException("Although Chuck Norris can advance a wizard with less than two pages, you can't."); + } + } + + /** + * Return the current page of this wizard + * + * @return Form + * + * @throws LogicException In case the name of the current page currently being set is invalid + */ + public function getCurrentPage() + { + if ($this->parent) { + return $this->parent->getCurrentPage(); + } + + if ($this->currentPage === null) { + $this->assertHasPages(); + $pages = $this->getPages(); + $this->currentPage = $this->getSession()->get('current_page', $pages[0]->getName()); + } + + if (($page = $this->getPage($this->currentPage)) === null) { + throw new LogicException(sprintf('No page found with name "%s"', $this->currentPage)); + } + + return $page; + } + + /** + * Set the current page of this wizard + * + * @param Form $page The page to set as current page + * + * @return $this + */ + public function setCurrentPage(Form $page) + { + $this->currentPage = $page->getName(); + $this->getSession()->set('current_page', $this->currentPage); + return $this; + } + + /** + * Setup the given page that is either going to be displayed or validated + * + * Implement this method in a subclass to populate default values and/or other data required to process the form. + * + * @param Form $page The page to setup + * @param Request $request The current request + */ + public function setupPage(Form $page, Request $request) + { + } + + /** + * Process the given request using this wizard + * + * Validate the request data using the current page, update the wizard's + * position and redirect to the page's redirect url upon success. + * + * @param Request $request The request to be processed + * + * @return Request The request supposed to be processed + */ + public function handleRequest(Request $request = null) + { + $page = $this->getCurrentPage(); + + if (($wizard = $this->findWizard($page)) !== null) { + return $wizard->handleRequest($request); + } + + if ($request === null) { + $request = $page->getRequest(); + } + + $this->setupPage($page, $request); + $requestData = $this->getRequestData($page, $request); + if ($page->wasSent($requestData)) { + if (($requestedPage = $this->getRequestedPage($requestData)) !== null) { + $isValid = false; + $direction = $this->getDirection($request); + if ($direction === static::FORWARD && $page->isValid($requestData)) { + $isValid = true; + if ($this->isLastPage($page)) { + $this->setIsFinished(); + } + } elseif ($direction === static::BACKWARD) { + $page->populate($requestData); + $isValid = true; + } + + if ($isValid) { + $pageData = & $this->getPageData(); + $pageData[$page->getName()] = ConfigForm::transformEmptyValuesToNull($page->getValues()); + $this->setCurrentPage($this->getNewPage($requestedPage, $page)); + $page->getResponse()->redirectAndExit($page->getRedirectUrl()); + } + } elseif ($page->getValidatePartial()) { + $page->isValidPartial($requestData); + } else { + $page->populate($requestData); + } + } elseif (($pageData = $this->getPageData($page->getName())) !== null) { + $page->populate($pageData); + } + + return $request; + } + + /** + * Return the wizard for the given page or null if its not part of a wizard + * + * @param Form $page The page to return its wizard for + * + * @return Wizard|null + */ + protected function findWizard(Form $page) + { + foreach ($this->getWizards() as $wizard) { + if ($wizard->getPage($page->getName()) === $page) { + return $wizard; + } + } + } + + /** + * Return this wizard's child wizards + * + * @return array + */ + protected function getWizards() + { + $wizards = array(); + foreach ($this->pages as $pageOrWizard) { + if ($pageOrWizard instanceof self) { + $wizards[] = $pageOrWizard; + } + } + + return $wizards; + } + + /** + * Return the request data based on given form's request method + * + * @param Form $page The page to fetch the data for + * @param Request $request The request to fetch the data from + * + * @return array + */ + protected function getRequestData(Form $page, Request $request) + { + if (strtolower($request->getMethod()) === $page->getMethod()) { + return $request->{'get' . ($request->isPost() ? 'Post' : 'Query')}(); + } + + return array(); + } + + /** + * Return the name of the requested page + * + * @param array $requestData The request's data + * + * @return null|string The name of the requested page or null in case no page has been requested + */ + protected function getRequestedPage(array $requestData) + { + if ($this->parent) { + return $this->parent->getRequestedPage($requestData); + } + + if (isset($requestData[static::BTN_NEXT])) { + return $requestData[static::BTN_NEXT]; + } elseif (isset($requestData[static::BTN_PREV])) { + return $requestData[static::BTN_PREV]; + } + } + + /** + * Return the direction of this wizard using the given request + * + * @param Request $request The request to use + * + * @return int The direction @see Wizard::FORWARD @see Wizard::BACKWARD @see Wizard::NO_CHANGE + */ + protected function getDirection(Request $request = null) + { + if ($this->parent) { + return $this->parent->getDirection($request); + } + + $currentPage = $this->getCurrentPage(); + + if ($request === null) { + $request = $currentPage->getRequest(); + } + + $requestData = $this->getRequestData($currentPage, $request); + if (isset($requestData[static::BTN_NEXT])) { + return static::FORWARD; + } elseif (isset($requestData[static::BTN_PREV])) { + return static::BACKWARD; + } + + return static::NO_CHANGE; + } + + /** + * Return the new page to set as current page + * + * Permission is checked by verifying that the requested page or its previous page has page data available. + * The requested page is automatically permitted without any checks if the origin page is its previous + * page or one that occurs later in order. + * + * @param string $requestedPage The name of the requested page + * @param Form $originPage The origin page + * + * @return Form The new page + * + * @throws InvalidArgumentException In case the requested page does not exist or is not permitted yet + */ + protected function getNewPage($requestedPage, Form $originPage) + { + if ($this->parent) { + return $this->parent->getNewPage($requestedPage, $originPage); + } + + if (($page = $this->getPage($requestedPage)) !== null) { + $permitted = true; + + $pages = $this->getPages(); + if (! $this->hasPageData($requestedPage) && ($index = array_search($page, $pages, true)) > 0) { + $previousPage = $pages[$index - 1]; + if ($originPage === null || ($previousPage->getName() !== $originPage->getName() + && array_search($originPage, $pages, true) < $index)) { + $permitted = $this->hasPageData($previousPage->getName()); + } + } + + if ($permitted) { + return $page; + } + } + + throw new InvalidArgumentException( + sprintf('"%s" is either an unknown page or one you are not permitted to view', $requestedPage) + ); + } + + /** + * Return the next or previous page based on the given one + * + * @param Form $page The page to skip + * + * @return Form + */ + protected function skipPage(Form $page) + { + if ($this->parent) { + return $this->parent->skipPage($page); + } + + if ($this->hasPageData($page->getName())) { + $pageData = & $this->getPageData(); + unset($pageData[$page->getName()]); + } + + $pages = $this->getPages(); + if ($this->getDirection() === static::FORWARD) { + $nextPage = $pages[array_search($page, $pages, true) + 1]; + $newPage = $this->getNewPage($nextPage->getName(), $page); + } else { // $this->getDirection() === static::BACKWARD + $previousPage = $pages[array_search($page, $pages, true) - 1]; + $newPage = $this->getNewPage($previousPage->getName(), $page); + } + + return $newPage; + } + + /** + * Return whether the given page is this wizard's last page + * + * @param Form $page The page to check + * + * @return bool + */ + protected function isLastPage(Form $page) + { + if ($this->parent) { + return $this->parent->isLastPage($page); + } + + $pages = $this->getPages(); + return $page->getName() === end($pages)->getName(); + } + + /** + * Return whether all of this wizard's pages were visited by the user + * + * The base implementation just verifies that the very last page has page data available. + * + * @return bool + */ + public function isComplete() + { + $pages = $this->getPages(); + return $this->hasPageData($pages[count($pages) - 1]->getName()); + } + + /** + * Set whether this wizard has been completed + * + * @param bool $state Whether this wizard has been completed + * + * @return $this + */ + public function setIsFinished($state = true) + { + $this->getSession()->set('isFinished', $state); + return $this; + } + + /** + * Return whether this wizard has been completed + * + * @return bool + */ + public function isFinished() + { + return $this->getSession()->get('isFinished', false); + } + + /** + * Return the overall page data or one for a particular page + * + * Note that this method returns by reference so in order to update the + * returned array set this method's return value also by reference. + * + * @param string $pageName The page for which to return the data + * + * @return array + */ + public function & getPageData($pageName = null) + { + $session = $this->getSession(); + + if (false === isset($session->page_data)) { + $session->page_data = array(); + } + + $pageData = & $session->getByRef('page_data'); + if ($pageName !== null) { + $data = null; + if (isset($pageData[$pageName])) { + $data = & $pageData[$pageName]; + } + + return $data; + } + + return $pageData; + } + + /** + * Return whether there is any data for the given page + * + * @param string $pageName The name of the page to check + * + * @return bool + */ + public function hasPageData($pageName) + { + return $this->getPageData($pageName) !== null; + } + + /** + * Return a session to be used by this wizard + * + * @return SessionNamespace + */ + public function getSession() + { + if ($this->parent) { + return $this->parent->getSession(); + } + + return Session::getSession()->getNamespace(get_class($this)); + } + + /** + * Clear the session being used by this wizard + */ + public function clearSession() + { + $this->getSession()->clear(); + } + + /** + * Add buttons to the given page based on its position in the page-chain + * + * @param Form $page The page to add the buttons to + */ + protected function addButtons(Form $page) + { + $pages = $this->getPages(); + $index = array_search($page, $pages, true); + if ($index === 0) { + $page->addElement( + 'button', + static::BTN_NEXT, + array( + 'class' => 'control-button btn-primary', + 'type' => 'submit', + 'value' => $pages[1]->getName(), + 'label' => t('Next'), + 'decorators' => array('ViewHelper', 'Spinner') + ) + ); + } elseif ($index < count($pages) - 1) { + $page->addElement( + 'button', + static::BTN_PREV, + array( + 'class' => 'control-button', + 'type' => 'submit', + 'value' => $pages[$index - 1]->getName(), + 'label' => t('Back'), + 'decorators' => array('ViewHelper'), + 'formnovalidate' => 'formnovalidate' + ) + ); + $page->addElement( + 'button', + static::BTN_NEXT, + array( + 'class' => 'control-button btn-primary', + 'type' => 'submit', + 'value' => $pages[$index + 1]->getName(), + 'label' => t('Next'), + 'decorators' => array('ViewHelper') + ) + ); + } else { + $page->addElement( + 'button', + static::BTN_PREV, + array( + 'class' => 'control-button', + 'type' => 'submit', + 'value' => $pages[$index - 1]->getName(), + 'label' => t('Back'), + 'decorators' => array('ViewHelper'), + 'formnovalidate' => 'formnovalidate' + ) + ); + $page->addElement( + 'button', + static::BTN_NEXT, + array( + 'class' => 'control-button btn-primary', + 'type' => 'submit', + 'value' => $page->getName(), + 'label' => t('Finish'), + 'decorators' => array('ViewHelper') + ) + ); + } + + $page->setAttrib('data-progress-element', static::PROGRESS_ELEMENT); + $page->addElement( + 'note', + static::PROGRESS_ELEMENT, + array( + 'order' => 99, // Ensures that it's shown on the right even if a sub-class adds another button + 'decorators' => array( + 'ViewHelper', + array('Spinner', array('id' => static::PROGRESS_ELEMENT)) + ) + ) + ); + + $page->addDisplayGroup( + array(static::BTN_PREV, static::BTN_NEXT, static::PROGRESS_ELEMENT), + 'buttons', + array( + 'decorators' => array( + 'FormElements', + new ElementDoubler(array( + 'double' => static::BTN_NEXT, + 'condition' => static::BTN_PREV, + 'placement' => ElementDoubler::PREPEND, + 'attributes' => array('tabindex' => -1, 'class' => 'double') + )), + array('HtmlTag', array('tag' => 'div', 'class' => 'buttons')) + ) + ) + ); + } + + /** + * Return the current page of this wizard with appropriate buttons being added + * + * @return Form + */ + public function getForm() + { + $form = $this->getCurrentPage(); + $form->create(); // Make sure that buttons are displayed at the very bottom + $this->addButtons($form); + return $form; + } + + /** + * Return the current page of this wizard rendered as HTML + * + * @return string + */ + public function __toString() + { + return (string) $this->getForm(); + } +} |