summaryrefslogtreecommitdiffstats
path: root/library/Icinga/Application/Hook
diff options
context:
space:
mode:
Diffstat (limited to 'library/Icinga/Application/Hook')
-rw-r--r--library/Icinga/Application/Hook/ApplicationStateHook.php90
-rw-r--r--library/Icinga/Application/Hook/AuditHook.php123
-rw-r--r--library/Icinga/Application/Hook/AuthenticationHook.php75
-rw-r--r--library/Icinga/Application/Hook/Common/DbMigrationStep.php129
-rw-r--r--library/Icinga/Application/Hook/ConfigFormEventsHook.php137
-rw-r--r--library/Icinga/Application/Hook/DbMigrationHook.php421
-rw-r--r--library/Icinga/Application/Hook/GrapherHook.php111
-rw-r--r--library/Icinga/Application/Hook/HealthHook.php222
-rw-r--r--library/Icinga/Application/Hook/PdfexportHook.php25
-rw-r--r--library/Icinga/Application/Hook/ThemeLoaderHook.php22
-rw-r--r--library/Icinga/Application/Hook/Ticket/TicketPattern.php140
-rw-r--r--library/Icinga/Application/Hook/TicketHook.php210
-rw-r--r--library/Icinga/Application/Hook/WebBaseHook.php54
13 files changed, 1759 insertions, 0 deletions
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;
+ }
+}