summaryrefslogtreecommitdiffstats
path: root/library/Icinga/Web
diff options
context:
space:
mode:
Diffstat (limited to '')
-rw-r--r--library/Icinga/Web/Announcement.php158
-rw-r--r--library/Icinga/Web/Announcement/AnnouncementCookie.php138
-rw-r--r--library/Icinga/Web/Announcement/AnnouncementIniRepository.php152
-rw-r--r--library/Icinga/Web/ApplicationStateCookie.php74
-rw-r--r--library/Icinga/Web/Controller.php264
-rw-r--r--library/Icinga/Web/Controller/ActionController.php617
-rw-r--r--library/Icinga/Web/Controller/AuthBackendController.php151
-rw-r--r--library/Icinga/Web/Controller/BasePreferenceController.php39
-rw-r--r--library/Icinga/Web/Controller/ControllerTabCollector.php97
-rw-r--r--library/Icinga/Web/Controller/Dispatcher.php93
-rw-r--r--library/Icinga/Web/Controller/ModuleActionController.php80
-rw-r--r--library/Icinga/Web/Controller/StaticController.php87
-rw-r--r--library/Icinga/Web/Cookie.php299
-rw-r--r--library/Icinga/Web/CookieSet.php58
-rw-r--r--library/Icinga/Web/Dom/DomNodeIterator.php84
-rw-r--r--library/Icinga/Web/FileCache.php293
-rw-r--r--library/Icinga/Web/Form.php1666
-rw-r--r--library/Icinga/Web/Form/Decorator/Autosubmit.php133
-rw-r--r--library/Icinga/Web/Form/Decorator/ConditionalHidden.php35
-rw-r--r--library/Icinga/Web/Form/Decorator/ElementDoubler.php63
-rw-r--r--library/Icinga/Web/Form/Decorator/FormDescriptions.php76
-rw-r--r--library/Icinga/Web/Form/Decorator/FormHints.php142
-rw-r--r--library/Icinga/Web/Form/Decorator/FormNotifications.php125
-rw-r--r--library/Icinga/Web/Form/Decorator/Help.php113
-rw-r--r--library/Icinga/Web/Form/Decorator/Spinner.php48
-rw-r--r--library/Icinga/Web/Form/Element/Button.php81
-rw-r--r--library/Icinga/Web/Form/Element/Checkbox.php9
-rw-r--r--library/Icinga/Web/Form/Element/CsrfCounterMeasure.php99
-rw-r--r--library/Icinga/Web/Form/Element/Date.php19
-rw-r--r--library/Icinga/Web/Form/Element/DateTimePicker.php80
-rw-r--r--library/Icinga/Web/Form/Element/Note.php55
-rw-r--r--library/Icinga/Web/Form/Element/Number.php144
-rw-r--r--library/Icinga/Web/Form/Element/Textarea.php20
-rw-r--r--library/Icinga/Web/Form/Element/Time.php19
-rw-r--r--library/Icinga/Web/Form/ErrorLabeller.php71
-rw-r--r--library/Icinga/Web/Form/FormElement.php61
-rw-r--r--library/Icinga/Web/Form/InvalidCSRFTokenException.php11
-rw-r--r--library/Icinga/Web/Form/Validator/DateFormatValidator.php61
-rw-r--r--library/Icinga/Web/Form/Validator/DateTimeValidator.php77
-rw-r--r--library/Icinga/Web/Form/Validator/InArray.php28
-rw-r--r--library/Icinga/Web/Form/Validator/InternalUrlValidator.php41
-rw-r--r--library/Icinga/Web/Form/Validator/ReadablePathValidator.php53
-rw-r--r--library/Icinga/Web/Form/Validator/TimeFormatValidator.php58
-rw-r--r--library/Icinga/Web/Form/Validator/UrlValidator.php40
-rw-r--r--library/Icinga/Web/Form/Validator/WritablePathValidator.php72
-rw-r--r--library/Icinga/Web/Helper/CookieHelper.php81
-rw-r--r--library/Icinga/Web/Helper/HtmlPurifier.php95
-rw-r--r--library/Icinga/Web/Helper/Markdown.php34
-rw-r--r--library/Icinga/Web/Helper/Markdown/LinkTransformer.php73
-rw-r--r--library/Icinga/Web/Hook.php16
-rw-r--r--library/Icinga/Web/JavaScript.php269
-rw-r--r--library/Icinga/Web/LessCompiler.php255
-rw-r--r--library/Icinga/Web/Menu.php152
-rw-r--r--library/Icinga/Web/Navigation/ConfigMenu.php327
-rw-r--r--library/Icinga/Web/Navigation/DashboardPane.php84
-rw-r--r--library/Icinga/Web/Navigation/DropdownItem.php20
-rw-r--r--library/Icinga/Web/Navigation/Navigation.php572
-rw-r--r--library/Icinga/Web/Navigation/NavigationItem.php948
-rw-r--r--library/Icinga/Web/Navigation/Renderer/BadgeNavigationItemRenderer.php139
-rw-r--r--library/Icinga/Web/Navigation/Renderer/HealthNavigationRenderer.php44
-rw-r--r--library/Icinga/Web/Navigation/Renderer/NavigationItemRenderer.php235
-rw-r--r--library/Icinga/Web/Navigation/Renderer/NavigationRenderer.php356
-rw-r--r--library/Icinga/Web/Navigation/Renderer/NavigationRendererInterface.php142
-rw-r--r--library/Icinga/Web/Navigation/Renderer/RecursiveNavigationRenderer.php186
-rw-r--r--library/Icinga/Web/Navigation/Renderer/SummaryNavigationItemRenderer.php72
-rw-r--r--library/Icinga/Web/Notification.php220
-rw-r--r--library/Icinga/Web/Paginator/Adapter/QueryAdapter.php84
-rw-r--r--library/Icinga/Web/Paginator/ScrollingStyle/SlidingWithBorder.php78
-rw-r--r--library/Icinga/Web/RememberMe.php363
-rw-r--r--library/Icinga/Web/RememberMeUserDevicesList.php144
-rw-r--r--library/Icinga/Web/RememberMeUserList.php106
-rw-r--r--library/Icinga/Web/Request.php142
-rw-r--r--library/Icinga/Web/Response.php460
-rw-r--r--library/Icinga/Web/Response/JsonResponse.php241
-rw-r--r--library/Icinga/Web/Session.php54
-rw-r--r--library/Icinga/Web/Session/Php72Session.php37
-rw-r--r--library/Icinga/Web/Session/PhpSession.php256
-rw-r--r--library/Icinga/Web/Session/Session.php126
-rw-r--r--library/Icinga/Web/Session/SessionNamespace.php201
-rw-r--r--library/Icinga/Web/StyleSheet.php342
-rw-r--r--library/Icinga/Web/Url.php806
-rw-r--r--library/Icinga/Web/UrlParams.php433
-rw-r--r--library/Icinga/Web/UserAgent.php86
-rw-r--r--library/Icinga/Web/View.php254
-rw-r--r--library/Icinga/Web/View/AppHealth.php89
-rw-r--r--library/Icinga/Web/View/Helper/IcingaCheckbox.php30
-rw-r--r--library/Icinga/Web/View/PrivilegeAudit.php622
-rw-r--r--library/Icinga/Web/View/helpers/format.php72
-rw-r--r--library/Icinga/Web/View/helpers/generic.php15
-rw-r--r--library/Icinga/Web/View/helpers/string.php36
-rw-r--r--library/Icinga/Web/View/helpers/url.php158
-rw-r--r--library/Icinga/Web/Widget.php49
-rw-r--r--library/Icinga/Web/Widget/AbstractWidget.php121
-rw-r--r--library/Icinga/Web/Widget/Announcements.php55
-rw-r--r--library/Icinga/Web/Widget/ApplicationStateMessages.php74
-rw-r--r--library/Icinga/Web/Widget/Chart/HistoryColorGrid.php400
-rw-r--r--library/Icinga/Web/Widget/Chart/InlinePie.php257
-rw-r--r--library/Icinga/Web/Widget/Dashboard.php475
-rw-r--r--library/Icinga/Web/Widget/Dashboard/Dashlet.php315
-rw-r--r--library/Icinga/Web/Widget/Dashboard/Pane.php335
-rw-r--r--library/Icinga/Web/Widget/Dashboard/UserWidget.php36
-rw-r--r--library/Icinga/Web/Widget/FilterEditor.php811
-rw-r--r--library/Icinga/Web/Widget/ItemList/MigrationFileListItem.php92
-rw-r--r--library/Icinga/Web/Widget/ItemList/MigrationList.php133
-rw-r--r--library/Icinga/Web/Widget/ItemList/MigrationListItem.php151
-rw-r--r--library/Icinga/Web/Widget/Limiter.php54
-rw-r--r--library/Icinga/Web/Widget/Paginator.php167
-rw-r--r--library/Icinga/Web/Widget/SearchDashboard.php111
-rw-r--r--library/Icinga/Web/Widget/SingleValueSearchControl.php200
-rw-r--r--library/Icinga/Web/Widget/SortBox.php260
-rw-r--r--library/Icinga/Web/Widget/Tab.php323
-rw-r--r--library/Icinga/Web/Widget/Tabextension/DashboardAction.php35
-rw-r--r--library/Icinga/Web/Widget/Tabextension/DashboardSettings.php39
-rw-r--r--library/Icinga/Web/Widget/Tabextension/MenuAction.php35
-rw-r--r--library/Icinga/Web/Widget/Tabextension/OutputFormat.php114
-rw-r--r--library/Icinga/Web/Widget/Tabextension/Tabextension.php25
-rw-r--r--library/Icinga/Web/Widget/Tabs.php453
-rw-r--r--library/Icinga/Web/Widget/Widget.php24
-rw-r--r--library/Icinga/Web/Window.php125
-rw-r--r--library/Icinga/Web/Wizard.php720
120 files changed, 21728 insertions, 0 deletions
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 &gt;noscript&lt; 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('/&#8203;'),
+ 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('/&#8203;'),
+ 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 &lt;li&gt; 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 &lt;ul&gt; 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 &lt;ul&gt; 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();
+ }
+}