diff options
Diffstat (limited to '')
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 >noscript< tag instead of + * type='hidden' or css styles. + * + * This allows to hide depending elements for browsers with javascript + * (who can then automatically refresh their pages) but show them in + * case JavaScript is disabled + */ +class ConditionalHidden extends Zend_Form_Decorator_Abstract +{ + /** + * Generate a field that will be wrapped in <noscript> tag if the + * "condition" attribute is set and false or 0 + * + * @param string $content The tag's content + * + * @return string The generated tag + */ + public function render($content = '') + { + $attributes = $this->getElement()->getAttribs(); + $condition = isset($attributes['condition']) ? $attributes['condition'] : 1; + if ($condition != 1) { + $content = '<noscript>' . $content . '</noscript>'; + } + return $content; + } +} diff --git a/library/Icinga/Web/Form/Decorator/ElementDoubler.php b/library/Icinga/Web/Form/Decorator/ElementDoubler.php new file mode 100644 index 0000000..2da5646 --- /dev/null +++ b/library/Icinga/Web/Form/Decorator/ElementDoubler.php @@ -0,0 +1,63 @@ +<?php +/* Icinga Web 2 | (c) 2014 Icinga Development Team | GPLv2+ */ + +namespace Icinga\Web\Form\Decorator; + +use Zend_Form_Element; +use Zend_Form_Decorator_Abstract; + +/** + * A decorator that will double a single element of a display group + * + * The options `condition', `double' and `attributes' can be passed to the constructor and are used to affect whether + * the doubling should take effect, which element should be doubled and which HTML attributes should be applied to the + * doubled element, respectively. + * + * `condition' must be an element's name that when it's part of the display group causes the condition to be met. + * `double' must be an element's name and must be part of the display group. + * `attributes' is just an array of key-value pairs. + * + * You can also pass `placement' to control whether the doubled element is prepended or appended. + */ +class ElementDoubler extends Zend_Form_Decorator_Abstract +{ + /** + * Return the display group's elements with an additional copy of an element being added if the condition is met + * + * @param string $content The HTML rendered so far + * + * @return string + */ + public function render($content) + { + $group = $this->getElement(); + if ($group->getElement($this->getOption('condition')) !== null) { + if ($this->getPlacement() === static::APPEND) { + return $content . $this->applyAttributes($group->getElement($this->getOption('double')))->render(); + } else { // $this->getPlacement() === static::PREPEND + return $this->applyAttributes($group->getElement($this->getOption('double')))->render() . $content; + } + } + + return $content; + } + + /** + * Apply all element attributes + * + * @param Zend_Form_Element $element The element to apply the attributes to + * + * @return Zend_Form_Element + */ + protected function applyAttributes(Zend_Form_Element $element) + { + $attributes = $this->getOption('attributes'); + if ($attributes !== null) { + foreach ($attributes as $name => $value) { + $element->setAttrib($name, $value); + } + } + + return $element; + } +} diff --git a/library/Icinga/Web/Form/Decorator/FormDescriptions.php b/library/Icinga/Web/Form/Decorator/FormDescriptions.php new file mode 100644 index 0000000..5bd5f6a --- /dev/null +++ b/library/Icinga/Web/Form/Decorator/FormDescriptions.php @@ -0,0 +1,76 @@ +<?php +/* Icinga Web 2 | (c) 2015 Icinga Development Team | GPLv2+ */ + +namespace Icinga\Web\Form\Decorator; + +use Icinga\Application\Icinga; +use Icinga\Web\Form; +use Zend_Form_Decorator_Abstract; + +/** + * Decorator to add a list of descriptions at the top or bottom of a form + */ +class FormDescriptions extends Zend_Form_Decorator_Abstract +{ + /** + * Render form descriptions + * + * @param string $content The html rendered so far + * + * @return ?string The updated html + */ + public function render($content = '') + { + $form = $this->getElement(); + if (! $form instanceof Form) { + return $content; + } + + $view = $form->getView(); + if ($view === null) { + return $content; + } + + $descriptions = $this->recurseForm($form); + if (empty($descriptions)) { + return $content; + } + + $html = '<div class="form-description">' + . Icinga::app()->getViewRenderer()->view->icon('info-circled', '', ['class' => 'form-description-icon']) + . '<ul class="form-description-list">'; + + foreach ($descriptions as $description) { + if (is_array($description)) { + list($description, $properties) = $description; + $html .= '<li' . $view->propertiesToString($properties) . '>' . $view->escape($description) . '</li>'; + } else { + $html .= '<li>' . $view->escape($description) . '</li>'; + } + } + + switch ($this->getPlacement()) { + case self::APPEND: + return $content . $html . '</ul></div>'; + case self::PREPEND: + return $html . '</ul></div>' . $content; + } + } + + /** + * Recurse the given form and return the descriptions for it and all of its subforms + * + * @param Form $form The form to recurse + * + * @return array + */ + protected function recurseForm(Form $form) + { + $descriptions = array($form->getDescriptions()); + foreach ($form->getSubForms() as $subForm) { + $descriptions[] = $this->recurseForm($subForm); + } + + return call_user_func_array('array_merge', $descriptions); + } +} diff --git a/library/Icinga/Web/Form/Decorator/FormHints.php b/library/Icinga/Web/Form/Decorator/FormHints.php new file mode 100644 index 0000000..2a0f193 --- /dev/null +++ b/library/Icinga/Web/Form/Decorator/FormHints.php @@ -0,0 +1,142 @@ +<?php +/* Icinga Web 2 | (c) 2015 Icinga Development Team | GPLv2+ */ + +namespace Icinga\Web\Form\Decorator; + +use Zend_Form_Decorator_Abstract; +use Icinga\Web\Form; + +/** + * Decorator to add a list of hints at the top or bottom of a form + * + * The hint for required form elements is automatically being handled. + */ +class FormHints extends Zend_Form_Decorator_Abstract +{ + /** + * A list of element class names to be ignored when detecting which message to use to describe required elements + * + * @var array + */ + protected $blacklist; + + /** + * {@inheritdoc} + */ + public function __construct($options = null) + { + parent::__construct($options); + $this->blacklist = array( + 'Zend_Form_Element_Hidden', + 'Zend_Form_Element_Submit', + 'Zend_Form_Element_Button', + 'Icinga\Web\Form\Element\Note', + 'Icinga\Web\Form\Element\Button', + 'Icinga\Web\Form\Element\CsrfCounterMeasure' + ); + } + + /** + * Render form hints + * + * @param string $content The html rendered so far + * + * @return ?string The updated html + */ + public function render($content = '') + { + $form = $this->getElement(); + if (! $form instanceof Form) { + return $content; + } + + $view = $form->getView(); + if ($view === null) { + return $content; + } + + $hints = $this->recurseForm($form, $entirelyRequired); + if ($entirelyRequired !== null) { + $hints[] = sprintf( + $form->getView()->translate('%s Required field'), + $form->getRequiredCue() + ); + } + + if (empty($hints)) { + return $content; + } + + $html = '<ul class="form-info">'; + foreach ($hints as $hint) { + if (is_array($hint)) { + list($hint, $properties) = $hint; + $html .= '<li' . $view->propertiesToString($properties) . '>' . $view->escape($hint) . '</li>'; + } else { + $html .= '<li>' . $view->escape($hint) . '</li>'; + } + } + + switch ($this->getPlacement()) { + case self::APPEND: + return $content . $html . '</ul>'; + case self::PREPEND: + return $html . '</ul>' . $content; + } + } + + /** + * Recurse the given form and return the hints for it and all of its subforms + * + * @param Form $form The form to recurse + * @param mixed $entirelyRequired Set by reference, true means all elements in the hierarchy are + * required, false only a partial subset and null none at all + * @param bool $elementsPassed Whether there were any elements passed during the recursion until now + * + * @return array + */ + protected function recurseForm(Form $form, &$entirelyRequired = null, $elementsPassed = false) + { + $requiredLabels = array(); + if ($form->getRequiredCue() !== null) { + $partiallyRequired = $partiallyOptional = false; + foreach ($form->getElements() as $element) { + if (! in_array($element->getType(), $this->blacklist)) { + if (! $element->isRequired()) { + $partiallyOptional = true; + if ($entirelyRequired) { + $entirelyRequired = false; + } + } else { + $partiallyRequired = true; + if (($label = $element->getDecorator('label')) !== false) { + $requiredLabels[] = $label; + } + } + } + } + + if (! $elementsPassed) { + $elementsPassed = $partiallyRequired || $partiallyOptional; + if ($entirelyRequired === null && $partiallyRequired) { + $entirelyRequired = ! $partiallyOptional; + } + } elseif ($entirelyRequired === null && $partiallyRequired) { + $entirelyRequired = false; + } + } + + $hints = array($form->getHints()); + foreach ($form->getSubForms() as $subForm) { + $hints[] = $this->recurseForm($subForm, $entirelyRequired, $elementsPassed); + } + + if ($entirelyRequired) { + foreach ($requiredLabels as $label) { + $label->setRequiredSuffix(''); + } + } + + return call_user_func_array('array_merge', $hints); + } +} diff --git a/library/Icinga/Web/Form/Decorator/FormNotifications.php b/library/Icinga/Web/Form/Decorator/FormNotifications.php new file mode 100644 index 0000000..87d12aa --- /dev/null +++ b/library/Icinga/Web/Form/Decorator/FormNotifications.php @@ -0,0 +1,125 @@ +<?php +/* Icinga Web 2 | (c) 2015 Icinga Development Team | GPLv2+ */ + +namespace Icinga\Web\Form\Decorator; + +use Icinga\Application\Icinga; +use Icinga\Exception\ProgrammingError; +use Icinga\Web\Form; +use Zend_Form_Decorator_Abstract; + +/** + * Decorator to add a list of notifications at the top or bottom of a form + */ +class FormNotifications extends Zend_Form_Decorator_Abstract +{ + /** + * Render form notifications + * + * @param string $content The html rendered so far + * + * @return ?string The updated html + */ + public function render($content = '') + { + $form = $this->getElement(); + if (! $form instanceof Form) { + return $content; + } + + $view = $form->getView(); + if ($view === null) { + return $content; + } + + $notifications = $this->recurseForm($form); + if (empty($notifications)) { + return $content; + } + + $html = '<ul class="form-notification-list">'; + foreach (array(Form::NOTIFICATION_ERROR, Form::NOTIFICATION_WARNING, Form::NOTIFICATION_INFO) as $type) { + if (isset($notifications[$type])) { + $html .= '<li><ul class="notification-' . $this->getNotificationTypeName($type) . '">'; + foreach ($notifications[$type] as $message) { + if (is_array($message)) { + list($message, $properties) = $message; + $html .= '<li' . $view->propertiesToString($properties) . '>' + . $view->escape($message) + . '</li>'; + } else { + $html .= '<li>' . $view->escape($message) . '</li>'; + } + } + + $html .= '</ul></li>'; + } + } + + if (isset($notifications[Form::NOTIFICATION_ERROR])) { + $icon = 'cancel'; + $class = 'error'; + } elseif (isset($notifications[Form::NOTIFICATION_WARNING])) { + $icon = 'warning-empty'; + $class = 'warning'; + } else { + $icon = 'info'; + $class = 'info'; + } + + $html = "<div class=\"form-notifications $class\">" + . Icinga::app()->getViewRenderer()->view->icon($icon, '', ['class' => 'form-notification-icon']) + . $html; + + switch ($this->getPlacement()) { + case self::APPEND: + return $content . $html . '</ul></div>'; + case self::PREPEND: + return $html . '</ul></div>' . $content; + } + } + + /** + * Recurse the given form and return the notifications for it and all of its subforms + * + * @param Form $form The form to recurse + * + * @return array + */ + protected function recurseForm(Form $form) + { + $notifications = $form->getNotifications(); + foreach ($form->getSubForms() as $subForm) { + foreach ($this->recurseForm($subForm) as $type => $messages) { + foreach ($messages as $message) { + $notifications[$type][] = $message; + } + } + } + + return $notifications; + } + + /** + * Return the name for the given notification type + * + * @param int $type + * + * @return string + * + * @throws ProgrammingError In case the given type is invalid + */ + protected function getNotificationTypeName($type) + { + switch ($type) { + case Form::NOTIFICATION_ERROR: + return 'error'; + case Form::NOTIFICATION_WARNING: + return 'warning'; + case Form::NOTIFICATION_INFO: + return 'info'; + default: + throw new ProgrammingError('Invalid notification type "%s" provided', $type); + } + } +} diff --git a/library/Icinga/Web/Form/Decorator/Help.php b/library/Icinga/Web/Form/Decorator/Help.php new file mode 100644 index 0000000..9e30e86 --- /dev/null +++ b/library/Icinga/Web/Form/Decorator/Help.php @@ -0,0 +1,113 @@ +<?php +/* Icinga Web 2 | (c) 2014 Icinga Development Team | GPLv2+ */ + +namespace Icinga\Web\Form\Decorator; + +use Zend_Form_Element; +use Zend_Form_Decorator_Abstract; +use Icinga\Application\Icinga; +use Icinga\Web\View; + +/** + * Decorator to add helptext to a form element + */ +class Help extends Zend_Form_Decorator_Abstract +{ + /** + * Whether a hidden <span> should be created to describe the decorated form element + * + * @var bool + */ + protected $accessible = false; + + /** + * The id used to identify the description associated with the decorated form element + * + * @var string + */ + protected $descriptionId; + + /** + * Set whether a hidden <span> should be created to describe the decorated form element + * + * @param bool $state + * + * @return Help + */ + public function setAccessible($state = true) + { + $this->accessible = (bool) $state; + return $this; + } + + /** + * Return the id used to identify the description associated with the decorated element + * + * @param Zend_Form_Element $element The element for which to generate a id + * + * @return string + */ + public function getDescriptionId(Zend_Form_Element $element = null) + { + if ($this->descriptionId === null) { + $element = $element ?: $this->getElement(); + $this->descriptionId = 'desc_' . $element->getId(); + } + + return $this->descriptionId; + } + + /** + * Return the current view + * + * @return View + */ + protected function getView() + { + return Icinga::app()->getViewRenderer()->view; + } + + /** + * Add a help icon to the left of an element + * + * @param string $content The html rendered so far + * + * @return ?string The updated html + */ + public function render($content = '') + { + $element = $this->getElement(); + $description = $element->getDescription(); + $requirement = $element->getAttrib('requirement'); + unset($element->requirement); + + $helpContent = ''; + if ($description || $requirement) { + if ($this->accessible) { + $helpContent = '<span id="' + . $this->getDescriptionId() + . '" class="sr-only">' + . $description + . ($description && $requirement ? ' ' : '') + . $requirement + . '</span>'; + } + + $helpContent = $this->getView()->icon( + 'info-circled', + $description . ($description && $requirement ? ' ' : '') . $requirement, + array( + 'class' => 'control-info', + 'aria-hidden' => $this->accessible ? 'true' : 'false' + ) + ) . $helpContent; + } + + switch ($this->getPlacement()) { + case self::APPEND: + return $content . $helpContent; + case self::PREPEND: + return $helpContent . $content; + } + } +} diff --git a/library/Icinga/Web/Form/Decorator/Spinner.php b/library/Icinga/Web/Form/Decorator/Spinner.php new file mode 100644 index 0000000..09a3ae9 --- /dev/null +++ b/library/Icinga/Web/Form/Decorator/Spinner.php @@ -0,0 +1,48 @@ +<?php +/* Icinga Web 2 | (c) 2015 Icinga Development Team | GPLv2+ */ + +namespace Icinga\Web\Form\Decorator; + +use Zend_Form_Decorator_Abstract; +use Icinga\Application\Icinga; +use Icinga\Web\View; + +/** + * Decorator to add a spinner next to an element + */ +class Spinner extends Zend_Form_Decorator_Abstract +{ + /** + * Return the current view + * + * @return View + */ + protected function getView() + { + return Icinga::app()->getViewRenderer()->view; + } + + /** + * Add a spinner icon to a form element + * + * @param string $content The html rendered so far + * + * @return ?string The updated html + */ + public function render($content = '') + { + $spinner = '<div ' + . ($this->getOption('id') !== null ? ' id="' . $this->getOption('id') . '"' : '') + . 'class="spinner ' . ($this->getOption('class') ?: '') . '"' + . '>' + . $this->getView()->icon('spin6') + . '</div>'; + + switch ($this->getPlacement()) { + case self::APPEND: + return $content . $spinner; + case self::PREPEND: + return $spinner . $content; + } + } +} diff --git a/library/Icinga/Web/Form/Element/Button.php b/library/Icinga/Web/Form/Element/Button.php new file mode 100644 index 0000000..307247e --- /dev/null +++ b/library/Icinga/Web/Form/Element/Button.php @@ -0,0 +1,81 @@ +<?php +/* Icinga Web 2 | (c) 2014 Icinga Development Team | GPLv2+ */ + +namespace Icinga\Web\Form\Element; + +use Icinga\Web\Request; +use Icinga\Application\Icinga; +use Icinga\Web\Form\FormElement; +use Zend_Config; + +/** + * A button + */ +class Button extends FormElement +{ + /** + * Use formButton view helper by default + * + * @var string + */ + public $helper = 'formButton'; + + /** + * Constructor + * + * @param string|array|Zend_Config $spec Element name or configuration + * @param string|array|Zend_Config $options Element value or configuration + */ + public function __construct($spec, $options = null) + { + if (is_string($spec) && ((null !== $options) && is_string($options))) { + $options = array('label' => $options); + } + + if (!isset($options['ignore'])) { + $options['ignore'] = true; + } + + parent::__construct($spec, $options); + + if ($label = $this->getLabel()) { + // Necessary to get the label shown on the generated HTML + $this->content = $label; + } + } + + /** + * Validate element value (pseudo) + * + * There is no need to reset the value + * + * @param mixed $value Is always ignored + * @param mixed $context Is always ignored + * + * @return bool Returns always TRUE + */ + public function isValid($value, $context = null) + { + return true; + } + + /** + * Has this button been selected? + * + * @return bool + */ + public function isChecked() + { + return $this->getRequest()->getParam($this->getName()) === $this->getValue(); + } + + /** + * Return the current request + * + * @return Request + */ + protected function getRequest() + { + return Icinga::app()->getRequest(); + } +} diff --git a/library/Icinga/Web/Form/Element/Checkbox.php b/library/Icinga/Web/Form/Element/Checkbox.php new file mode 100644 index 0000000..d4499a0 --- /dev/null +++ b/library/Icinga/Web/Form/Element/Checkbox.php @@ -0,0 +1,9 @@ +<?php +/* Icinga Web 2 | (c) 2019 Icinga GmbH | GPLv2+ */ + +namespace Icinga\Web\Form\Element; + +class Checkbox extends \Zend_Form_Element_Checkbox +{ + public $helper = 'icingaCheckbox'; +} diff --git a/library/Icinga/Web/Form/Element/CsrfCounterMeasure.php b/library/Icinga/Web/Form/Element/CsrfCounterMeasure.php new file mode 100644 index 0000000..c59e1f9 --- /dev/null +++ b/library/Icinga/Web/Form/Element/CsrfCounterMeasure.php @@ -0,0 +1,99 @@ +<?php +/* Icinga Web 2 | (c) 2014 Icinga Development Team | GPLv2+ */ + +namespace Icinga\Web\Form\Element; + +use Icinga\Web\Session; +use Icinga\Web\Form\FormElement; +use Icinga\Web\Form\InvalidCSRFTokenException; + +/** + * CSRF counter measure element + * + * You must not set a value to successfully use this element, just give it a name and you're good to go. + */ +class CsrfCounterMeasure extends FormElement +{ + /** + * Default form view helper to use for rendering + * + * @var string + */ + public $helper = 'formHidden'; + + /** + * Counter measure element is required + * + * @var bool + */ + protected $_ignore = true; + + /** + * Ignore element when retrieving values at form level + * + * @var bool + */ + protected $_required = true; + + /** + * Initialize this form element + */ + public function init() + { + $this->setDecorators(['ViewHelper']); + $this->setValue($this->generateCsrfToken()); + } + + /** + * Check whether $value is a valid CSRF token + * + * @param string $value The value to check + * @param mixed $context Context to use + * + * @return bool True, in case the CSRF token is valid + * + * @throws InvalidCSRFTokenException In case the CSRF token is not valid + */ + public function isValid($value, $context = null) + { + if (parent::isValid($value, $context) && $this->isValidCsrfToken($value)) { + return true; + } + + throw new InvalidCSRFTokenException(); + } + + /** + * Check whether the given value is a valid CSRF token for the current session + * + * @param string $token The CSRF token + * + * @return bool + */ + protected function isValidCsrfToken($token) + { + if (strpos($token, '|') === false) { + return false; + } + + list($seed, $hash) = explode('|', $token); + + if (false === is_numeric($seed)) { + return false; + } + + return $hash === hash('sha256', Session::getSession()->getId() . $seed); + } + + /** + * Generate a new (seed, token) pair + * + * @return string + */ + protected function generateCsrfToken() + { + $seed = mt_rand(); + $hash = hash('sha256', Session::getSession()->getId() . $seed); + return sprintf('%s|%s', $seed, $hash); + } +} diff --git a/library/Icinga/Web/Form/Element/Date.php b/library/Icinga/Web/Form/Element/Date.php new file mode 100644 index 0000000..8e0985c --- /dev/null +++ b/library/Icinga/Web/Form/Element/Date.php @@ -0,0 +1,19 @@ +<?php +/* Icinga Web 2 | (c) 2017 Icinga Development Team | GPLv2+ */ + +namespace Icinga\Web\Form\Element; + +use Icinga\Web\Form\FormElement; + +/** + * A date input control + */ +class Date extends FormElement +{ + /** + * Form view helper to use for rendering + * + * @var string + */ + public $helper = 'formDate'; +} diff --git a/library/Icinga/Web/Form/Element/DateTimePicker.php b/library/Icinga/Web/Form/Element/DateTimePicker.php new file mode 100644 index 0000000..284a744 --- /dev/null +++ b/library/Icinga/Web/Form/Element/DateTimePicker.php @@ -0,0 +1,80 @@ +<?php +/* Icinga Web 2 | (c) 2013 Icinga Development Team | GPLv2+ */ + +namespace Icinga\Web\Form\Element; + +use DateTime; +use Icinga\Web\Form\FormElement; +use Icinga\Web\Form\Validator\DateTimeValidator; + +/** + * A date-and-time input control + */ +class DateTimePicker extends FormElement +{ + /** + * Form view helper to use for rendering + * + * @var string + */ + public $helper = 'formDateTime'; + + /** + * @var bool + */ + protected $local = true; + + /** + * (non-PHPDoc) + * @see Zend_Form_Element::init() For the method documentation. + */ + public function init() + { + $this->addValidator( + new DateTimeValidator($this->local), + true // true for breaking the validator chain on failure + ); + } + + /** + * Get the expected date and time format of any user input + * + * @return string + */ + public function getFormat() + { + return $this->local ? 'Y-m-d\TH:i:s' : DateTime::RFC3339; + } + + /** + * Is the date and time valid? + * + * @param string|DateTime $value + * @param mixed $context + * + * @return bool + */ + public function isValid($value, $context = null) + { + if (is_scalar($value) && $value !== '' && ! preg_match('/\D/', $value)) { + $dateTime = new DateTime(); + $value = $dateTime->setTimestamp($value)->format($this->getFormat()); + } + + if (! parent::isValid($value, $context)) { + return false; + } + + if (! $value instanceof DateTime) { + $format = $this->getFormat(); + $dateTime = DateTime::createFromFormat($format, $value); + if ($dateTime === false) { + $dateTime = DateTime::createFromFormat(substr($format, 0, strrpos($format, ':')), $value); + } + + $this->setValue($dateTime); + } + + return true; + } +} diff --git a/library/Icinga/Web/Form/Element/Note.php b/library/Icinga/Web/Form/Element/Note.php new file mode 100644 index 0000000..9569dee --- /dev/null +++ b/library/Icinga/Web/Form/Element/Note.php @@ -0,0 +1,55 @@ +<?php +/* Icinga Web 2 | (c) 2013 Icinga Development Team | GPLv2+ */ + +namespace Icinga\Web\Form\Element; + +use Icinga\Web\Form\FormElement; + +/** + * A note + */ +class Note extends FormElement +{ + /** + * Form view helper to use for rendering + * + * @var string + */ + public $helper = 'formNote'; + + /** + * Ignore element when retrieving values at form level + * + * @var bool + */ + protected $_ignore = true; + + /** + * (non-PHPDoc) + * @see Zend_Form_Element::init() For the method documentation. + */ + public function init() + { + if (count($this->getDecorators()) === 0) { + $this->setDecorators(array( + 'ViewHelper', + array( + 'HtmlTag', + array('tag' => 'p') + ) + )); + } + } + + /** + * Validate element value (pseudo) + * + * @param mixed $value Ignored + * + * @return bool Always true + */ + public function isValid($value, $context = null) + { + return true; + } +} diff --git a/library/Icinga/Web/Form/Element/Number.php b/library/Icinga/Web/Form/Element/Number.php new file mode 100644 index 0000000..afbd07d --- /dev/null +++ b/library/Icinga/Web/Form/Element/Number.php @@ -0,0 +1,144 @@ +<?php +/* Icinga Web 2 | (c) 2013 Icinga Development Team | GPLv2+ */ + +namespace Icinga\Web\Form\Element; + +use Icinga\Web\Form\FormElement; + +/** + * A number input control + */ +class Number extends FormElement +{ + /** + * Form view helper to use for rendering + * + * @var string + */ + public $helper = 'formNumber'; + + /** + * The expected lower bound for the element’s value + * + * @var float|null + */ + protected $min; + + /** + * The expected upper bound for the element’s + * + * @var float|null + */ + protected $max; + + /** + * The value granularity of the element’s value + * + * Normally, number input controls are limited to an accuracy of integer values. + * + * @var float|string|null + */ + protected $step; + + /** + * (non-PHPDoc) + * @see \Zend_Form_Element::init() For the method documentation. + */ + public function init() + { + if ($this->min !== null || $this->max !== null) { + $this->addValidator('Between', true, array( + 'min' => $this->min === null ? -INF : $this->min, + 'max' => $this->max === null ? INF : $this->max, + 'inclusive' => true + )); + } + } + + /** + * Set the expected lower bound for the element’s value + * + * @param float $min + * + * @return $this + */ + public function setMin($min) + { + $this->min = (float) $min; + return $this; + } + + /** + * Get the expected lower bound for the element’s value + * + * @return float|null + */ + public function getMin() + { + return $this->min; + } + + /** + * Set the expected upper bound for the element’s value + * + * @param float $max + * + * @return $this + */ + public function setMax($max) + { + $this->max = (float) $max; + return $this; + } + + /** + * Get the expected upper bound for the element’s value + * + * @return float|null + */ + public function getMax() + { + return $this->max; + } + + /** + * Set the value granularity of the element’s value + * + * @param float|string $step + * + * @return $this + */ + public function setStep($step) + { + if ($step !== 'any') { + $step = (float) $step; + } + $this->step = $step; + return $this; + } + + /** + * Get the value granularity of the element’s value + * + * @return float|string|null + */ + public function getStep() + { + return $this->step; + } + + /** + * (non-PHPDoc) + * @see \Zend_Form_Element::isValid() For the method documentation. + */ + public function isValid($value, $context = null) + { + $this->setValue($value); + $value = $this->getValue(); + if ($value !== null && $value !== '' && ! is_numeric($value)) { + $this->addError(sprintf(t('\'%s\' is not a valid number'), $value)); + return false; + } + return parent::isValid($value, $context); + } +} diff --git a/library/Icinga/Web/Form/Element/Textarea.php b/library/Icinga/Web/Form/Element/Textarea.php new file mode 100644 index 0000000..119cd56 --- /dev/null +++ b/library/Icinga/Web/Form/Element/Textarea.php @@ -0,0 +1,20 @@ +<?php +/* Icinga Web 2 | (c) 2019 Icinga Development Team | GPLv2+ */ + +namespace Icinga\Web\Form\Element; + +use Icinga\Web\Form\FormElement; + +class Textarea extends FormElement +{ + public $helper = 'formTextarea'; + + public function __construct($spec, $options = null) + { + parent::__construct($spec, $options); + + if ($this->getAttrib('rows') === null) { + $this->setAttrib('rows', 3); + } + } +} diff --git a/library/Icinga/Web/Form/Element/Time.php b/library/Icinga/Web/Form/Element/Time.php new file mode 100644 index 0000000..4b76a33 --- /dev/null +++ b/library/Icinga/Web/Form/Element/Time.php @@ -0,0 +1,19 @@ +<?php +/* Icinga Web 2 | (c) 2017 Icinga Development Team | GPLv2+ */ + +namespace Icinga\Web\Form\Element; + +use Icinga\Web\Form\FormElement; + +/** + * A time input control + */ +class Time extends FormElement +{ + /** + * Form view helper to use for rendering + * + * @var string + */ + public $helper = 'formTime'; +} diff --git a/library/Icinga/Web/Form/ErrorLabeller.php b/library/Icinga/Web/Form/ErrorLabeller.php new file mode 100644 index 0000000..3f822d5 --- /dev/null +++ b/library/Icinga/Web/Form/ErrorLabeller.php @@ -0,0 +1,71 @@ +<?php +/* Icinga Web 2 | (c) 2015 Icinga Development Team | GPLv2+ */ + +namespace Icinga\Web\Form; + +use BadMethodCallException; +use Zend_Translate_Adapter; +use Zend_Validate_NotEmpty; +use Zend_Validate_File_MimeType; +use Icinga\Web\Form\Validator\DateTimeValidator; +use Icinga\Web\Form\Validator\ReadablePathValidator; +use Icinga\Web\Form\Validator\WritablePathValidator; + +class ErrorLabeller extends Zend_Translate_Adapter +{ + protected $messages; + + public function __construct($options = array()) + { + if (! isset($options['element'])) { + throw new BadMethodCallException('Option "element" is missing'); + } + + $this->messages = $this->createMessages($options['element']); + } + + public function isTranslated($messageId, $original = false, $locale = null) + { + return array_key_exists($messageId, $this->messages); + } + + public function translate($messageId, $locale = null) + { + if (array_key_exists($messageId, $this->messages)) { + return $this->messages[$messageId]; + } + + return $messageId; + } + + protected function createMessages($element) + { + $label = $element->getLabel() ?: $element->getName(); + + return array( + Zend_Validate_NotEmpty::IS_EMPTY => sprintf(t('%s is required and must not be empty'), $label), + Zend_Validate_File_MimeType::FALSE_TYPE => sprintf( + t('%s (%%value%%) has a false MIME type of "%%type%%"'), + $label + ), + Zend_Validate_File_MimeType::NOT_DETECTED => sprintf(t('%s (%%value%%) has no MIME type'), $label), + WritablePathValidator::NOT_WRITABLE => sprintf(t('%s is not writable', 'config.path'), $label), + WritablePathValidator::DOES_NOT_EXIST => sprintf(t('%s does not exist', 'config.path'), $label), + ReadablePathValidator::NOT_READABLE => sprintf(t('%s is not readable', 'config.path'), $label), + DateTimeValidator::INVALID_DATETIME_FORMAT => sprintf( + t('%s not in the expected format: %%value%%'), + $label + ) + ); + } + + protected function _loadTranslationData($data, $locale, array $options = array()) + { + // nonsense, required as being abstract otherwise... + } + + public function toString() + { + return 'ErrorLabeller'; // nonsense, required as being abstract otherwise... + } +} diff --git a/library/Icinga/Web/Form/FormElement.php b/library/Icinga/Web/Form/FormElement.php new file mode 100644 index 0000000..766d916 --- /dev/null +++ b/library/Icinga/Web/Form/FormElement.php @@ -0,0 +1,61 @@ +<?php +/* Icinga Web 2 | (c) 2014 Icinga Development Team | GPLv2+ */ + +namespace Icinga\Web\Form; + +use Zend_Form_Element; +use Icinga\Web\Form; + +/** + * Base class for Icinga Web 2 form elements + */ +class FormElement extends Zend_Form_Element +{ + /** + * Whether loading default decorators is disabled + * + * Icinga Web 2 loads its own default element decorators. For loading Zend's default element decorators set this + * property to false. + * + * @var null|bool + */ + protected $_disableLoadDefaultDecorators; + + /** + * Whether loading default decorators is disabled + * + * @return bool + */ + public function loadDefaultDecoratorsIsDisabled() + { + return $this->_disableLoadDefaultDecorators === true; + } + + /** + * Load default decorators + * + * Icinga Web 2 loads its own default element decorators. For loading Zend's default element decorators set + * FormElement::$_disableLoadDefaultDecorators to false. + * + * @return $this + * @see Form::$defaultElementDecorators For Icinga Web 2's default element decorators. + */ + public function loadDefaultDecorators() + { + if ($this->loadDefaultDecoratorsIsDisabled()) { + return $this; + } + + if (! isset($this->_disableLoadDefaultDecorators)) { + $decorators = $this->getDecorators(); + if (empty($decorators)) { + // Load Icinga Web 2's default element decorators + $this->addDecorators(Form::$defaultElementDecorators); + } + } else { + // Load Zend's default decorators + parent::loadDefaultDecorators(); + } + return $this; + } +} diff --git a/library/Icinga/Web/Form/InvalidCSRFTokenException.php b/library/Icinga/Web/Form/InvalidCSRFTokenException.php new file mode 100644 index 0000000..d0eb68a --- /dev/null +++ b/library/Icinga/Web/Form/InvalidCSRFTokenException.php @@ -0,0 +1,11 @@ +<?php +/* Icinga Web 2 | (c) 2013 Icinga Development Team | GPLv2+ */ + +namespace Icinga\Web\Form; + +/** + * Exceptions for invalid form tokens + */ +class InvalidCSRFTokenException extends \Exception +{ +} diff --git a/library/Icinga/Web/Form/Validator/DateFormatValidator.php b/library/Icinga/Web/Form/Validator/DateFormatValidator.php new file mode 100644 index 0000000..eacb29c --- /dev/null +++ b/library/Icinga/Web/Form/Validator/DateFormatValidator.php @@ -0,0 +1,61 @@ +<?php +/* Icinga Web 2 | (c) 2013 Icinga Development Team | GPLv2+ */ + +namespace Icinga\Web\Form\Validator; + +use Zend_Validate_Abstract; + +/** + * Validator that checks if a textfield contains a correct date format + */ +class DateFormatValidator extends Zend_Validate_Abstract +{ + + /** + * Valid date characters according to @see http://www.php.net/manual/en/function.date.php + * + * @var array + * + * @see http://www.php.net/manual/en/function.date.php + */ + private $validChars = + array('d', 'D', 'j', 'l', 'N', 'S', 'w', 'z', 'W', 'F', 'm', 'M', 'n', 't', 'L', 'o', 'Y', 'y'); + + /** + * List of sensible time separators + * + * @var array + */ + private $validSeparators = array(' ', ':', '-', '/', ';', ',', '.'); + + /** + * Error templates + * + * @var array + * + * @see Zend_Validate_Abstract::$_messageTemplates + */ + protected $_messageTemplates = array( + 'INVALID_CHARACTERS' => 'Invalid date format' + ); + + /** + * Validate the input value + * + * @param string $value The format string to validate + * @param null $context The form context (ignored) + * + * @return bool True when the input is valid, otherwise false + * + * @see Zend_Validate_Abstract::isValid() + */ + public function isValid($value, $context = null) + { + $rest = trim($value, join(' ', array_merge($this->validChars, $this->validSeparators))); + if (strlen($rest) > 0) { + $this->_error('INVALID_CHARACTERS'); + return false; + } + return true; + } +} diff --git a/library/Icinga/Web/Form/Validator/DateTimeValidator.php b/library/Icinga/Web/Form/Validator/DateTimeValidator.php new file mode 100644 index 0000000..5ef327d --- /dev/null +++ b/library/Icinga/Web/Form/Validator/DateTimeValidator.php @@ -0,0 +1,77 @@ +<?php +/* Icinga Web 2 | (c) 2013 Icinga Development Team | GPLv2+ */ + +namespace Icinga\Web\Form\Validator; + +use DateTime; +use Zend_Validate_Abstract; + +/** + * Validator for date-and-time input controls + * + * @see \Icinga\Web\Form\Element\DateTimePicker For the date-and-time input control. + */ +class DateTimeValidator extends Zend_Validate_Abstract +{ + const INVALID_DATETIME_TYPE = 'invalidDateTimeType'; + const INVALID_DATETIME_FORMAT = 'invalidDateTimeFormat'; + + /** + * The messages to write on differen error states + * + * @var array + * + * @see Zend_Validate_Abstract::$_messageTemplates‚ + */ + protected $_messageTemplates = array( + self::INVALID_DATETIME_TYPE => 'Invalid type given. Instance of DateTime or date/time string expected', + self::INVALID_DATETIME_FORMAT => 'Date/time string not in the expected format: %value%' + ); + + protected $local; + + /** + * Create a new date-and-time input control validator + * + * @param bool $local + */ + public function __construct($local) + { + $this->local = (bool) $local; + } + + /** + * Is the date and time valid? + * + * @param string|DateTime $value + * @param mixed $context + * + * @return bool + * + * @see \Zend_Validate_Interface::isValid() + */ + public function isValid($value, $context = null) + { + if (! $value instanceof DateTime && ! is_string($value)) { + $this->_error(self::INVALID_DATETIME_TYPE); + return false; + } + + if (! $value instanceof DateTime) { + $format = $baseFormat = $this->local === true ? 'Y-m-d\TH:i:s' : DateTime::RFC3339; + $dateTime = DateTime::createFromFormat($format, $value); + + if ($dateTime === false) { + $format = substr($format, 0, strrpos($format, ':')); + $dateTime = DateTime::createFromFormat($format, $value); + } + + if ($dateTime === false || $dateTime->format($format) !== $value) { + $this->_error(self::INVALID_DATETIME_FORMAT, $baseFormat); + return false; + } + } + + return true; + } +} diff --git a/library/Icinga/Web/Form/Validator/InArray.php b/library/Icinga/Web/Form/Validator/InArray.php new file mode 100644 index 0000000..5d3925e --- /dev/null +++ b/library/Icinga/Web/Form/Validator/InArray.php @@ -0,0 +1,28 @@ +<?php +/* Icinga Web 2 | (c) 2015 Icinga Development Team | GPLv2+ */ + +namespace Icinga\Web\Form\Validator; + +use Zend_Validate_InArray; +use Icinga\Util\StringHelper; + +class InArray extends Zend_Validate_InArray +{ + protected function _error($messageKey, $value = null) + { + if ($messageKey === static::NOT_IN_ARRAY) { + $matches = StringHelper::findSimilar($this->_value, $this->_haystack); + if (empty($matches)) { + $this->_messages[$messageKey] = sprintf(t('"%s" is not in the list of allowed values.'), $this->_value); + } else { + $this->_messages[$messageKey] = sprintf( + t('"%s" is not in the list of allowed values. Did you mean one of the following?: %s'), + $this->_value, + implode(', ', $matches) + ); + } + } else { + parent::_error($messageKey, $value); + } + } +} diff --git a/library/Icinga/Web/Form/Validator/InternalUrlValidator.php b/library/Icinga/Web/Form/Validator/InternalUrlValidator.php new file mode 100644 index 0000000..f936bb5 --- /dev/null +++ b/library/Icinga/Web/Form/Validator/InternalUrlValidator.php @@ -0,0 +1,41 @@ +<?php +/* Icinga Web 2 | (c) 2016 Icinga Development Team | GPLv2+ */ + +namespace Icinga\Web\Form\Validator; + +use Icinga\Application\Icinga; +use Zend_Validate_Abstract; +use Icinga\Web\Url; + +/** + * Validator that checks whether a textfield doesn't contain an external URL + */ +class InternalUrlValidator extends Zend_Validate_Abstract +{ + /** + * {@inheritdoc} + */ + public function isValid($value) + { + $url = Url::fromPath($value); + if ($url->getRelativeUrl() === '' || $url->isExternal()) { + $this->_error('IS_EXTERNAL'); + + return false; + } + + return true; + } + + /** + * {@inheritdoc} + */ + protected function _error($messageKey, $value = null) + { + if ($messageKey === 'IS_EXTERNAL') { + $this->_messages[$messageKey] = t('The url must not be external.'); + } else { + parent::_error($messageKey, $value); + } + } +} diff --git a/library/Icinga/Web/Form/Validator/ReadablePathValidator.php b/library/Icinga/Web/Form/Validator/ReadablePathValidator.php new file mode 100644 index 0000000..826421c --- /dev/null +++ b/library/Icinga/Web/Form/Validator/ReadablePathValidator.php @@ -0,0 +1,53 @@ +<?php +/* Icinga Web 2 | (c) 2014 Icinga Development Team | GPLv2+ */ + +namespace Icinga\Web\Form\Validator; + +use Zend_Validate_Abstract; + +/** + * Validator that interprets the value as a filepath and checks if it's readable + * + * This validator should be preferred due to Zend_Validate_File_Exists is + * getting confused if there is another element in the form called `name'. + */ +class ReadablePathValidator extends Zend_Validate_Abstract +{ + const NOT_READABLE = 'notReadable'; + const DOES_NOT_EXIST = 'doesNotExist'; + + /** + * The messages to write on different error states + * + * @var array + * + * @see Zend_Validate_Abstract::$_messageTemplates‚ + */ + protected $_messageTemplates = array( + self::NOT_READABLE => 'Path is not readable', + self::DOES_NOT_EXIST => 'Path does not exist' + ); + + /** + * Check whether the given value is a readable filepath + * + * @param string $value The value submitted in the form + * @param mixed $context The context of the form + * + * @return bool Whether the value was successfully validated + */ + public function isValid($value, $context = null) + { + if (false === file_exists($value)) { + $this->_error(self::DOES_NOT_EXIST); + return false; + } + + if (false === is_readable($value)) { + $this->_error(self::NOT_READABLE); + return false; + } + + return true; + } +} diff --git a/library/Icinga/Web/Form/Validator/TimeFormatValidator.php b/library/Icinga/Web/Form/Validator/TimeFormatValidator.php new file mode 100644 index 0000000..9c1c99a --- /dev/null +++ b/library/Icinga/Web/Form/Validator/TimeFormatValidator.php @@ -0,0 +1,58 @@ +<?php +/* Icinga Web 2 | (c) 2013 Icinga Development Team | GPLv2+ */ + +namespace Icinga\Web\Form\Validator; + +use Zend_Validate_Abstract; + +/** + * Validator that checks if a textfield contains a correct time format + */ +class TimeFormatValidator extends Zend_Validate_Abstract +{ + + /** + * Valid time characters according to @see http://www.php.net/manual/en/function.date.php + * + * @var array + * @see http://www.php.net/manual/en/function.date.php + */ + private $validChars = array('a', 'A', 'B', 'g', 'G', 'h', 'H', 'i', 's', 'u'); + + /** + * List of sensible time separators + * + * @var array + */ + private $validSeparators = array(' ', ':', '-', '/', ';', ',', '.'); + + /** + * Error templates + * + * @var array + * @see Zend_Validate_Abstract::$_messageTemplates + */ + protected $_messageTemplates = array( + 'INVALID_CHARACTERS' => 'Invalid time format' + ); + + /** + * Validate the input value + * + * @param string $value The format string to validate + * @param null $context The form context (ignored) + * + * @return bool True when the input is valid, otherwise false + * + * @see Zend_Validate_Abstract::isValid() + */ + public function isValid($value, $context = null) + { + $rest = trim($value, join(' ', array_merge($this->validChars, $this->validSeparators))); + if (strlen($rest) > 0) { + $this->_error('INVALID_CHARACTERS'); + return false; + } + return true; + } +} diff --git a/library/Icinga/Web/Form/Validator/UrlValidator.php b/library/Icinga/Web/Form/Validator/UrlValidator.php new file mode 100644 index 0000000..b1b578f --- /dev/null +++ b/library/Icinga/Web/Form/Validator/UrlValidator.php @@ -0,0 +1,40 @@ +<?php +/* Icinga Web 2 | (c) 2016 Icinga Development Team | GPLv2+ */ + +namespace Icinga\Web\Form\Validator; + +use Zend_Validate_Abstract; + +/** + * Validator that checks whether a textfield doesn't contain raw double quotes + */ +class UrlValidator extends Zend_Validate_Abstract +{ + /** + * Constructor + */ + public function __construct() + { + $this->_messageTemplates = array('HAS_QUOTES' => t( + 'The url must not contain raw double quotes. If you really need double quotes, use %22 instead.' + )); + } + + /** + * Validate the input value + * + * @param string $value The string to validate + * + * @return bool true if and only if the input is valid, otherwise false + * + * @see Zend_Validate_Abstract::isValid() + */ + public function isValid($value) + { + $hasQuotes = false === strpos($value, '"'); + if (! $hasQuotes) { + $this->_error('HAS_QUOTES'); + } + return $hasQuotes; + } +} diff --git a/library/Icinga/Web/Form/Validator/WritablePathValidator.php b/library/Icinga/Web/Form/Validator/WritablePathValidator.php new file mode 100644 index 0000000..76efb58 --- /dev/null +++ b/library/Icinga/Web/Form/Validator/WritablePathValidator.php @@ -0,0 +1,72 @@ +<?php +/* Icinga Web 2 | (c) 2013 Icinga Development Team | GPLv2+ */ + +namespace Icinga\Web\Form\Validator; + +use Zend_Validate_Abstract; + +/** + * Validator that interprets the value as a path and checks if it's writable + */ +class WritablePathValidator extends Zend_Validate_Abstract +{ + const NOT_WRITABLE = 'notWritable'; + const DOES_NOT_EXIST = 'doesNotExist'; + + /** + * The messages to write on differen error states + * + * @var array + * + * @see Zend_Validate_Abstract::$_messageTemplates‚ + */ + protected $_messageTemplates = array( + self::NOT_WRITABLE => 'Path is not writable', + self::DOES_NOT_EXIST => 'Path does not exist' + ); + + /** + * When true, the file or directory must exist + * + * @var bool + */ + private $requireExistence = false; + + /** + * Set this validator to require the target file to exist + */ + public function setRequireExistence() + { + $this->requireExistence = true; + } + + /** + * Check whether the given value is writable path + * + * @param string $value The value submitted in the form + * @param mixed $context The context of the form + * + * @return bool True when validation worked, otherwise false + * + * @see Zend_Validate_Abstract::isValid() + */ + public function isValid($value, $context = null) + { + $value = (string) $value; + + $this->_setValue($value); + if ($this->requireExistence && !file_exists($value)) { + $this->_error(self::DOES_NOT_EXIST); + return false; + } + + if ((file_exists($value) && is_writable($value)) || + (is_dir(dirname($value)) && is_writable(dirname($value))) + ) { + return true; + } + + $this->_error(self::NOT_WRITABLE); + return false; + } +} diff --git a/library/Icinga/Web/Helper/CookieHelper.php b/library/Icinga/Web/Helper/CookieHelper.php new file mode 100644 index 0000000..cc7c448 --- /dev/null +++ b/library/Icinga/Web/Helper/CookieHelper.php @@ -0,0 +1,81 @@ +<?php +/* Icinga Web 2 | (c) 2013 Icinga Development Team | GPLv2+ */ + +namespace Icinga\Web\Helper; + +use Icinga\Web\Request; + +/** + * Helper Class Cookie + */ +class CookieHelper +{ + /** + * The name of the control cookie + */ + const CHECK_COOKIE = '_chc'; + + /** + * The request + * + * @var Request + */ + protected $request; + + /** + * Create a new cookie + * + * @param Request $request + */ + public function __construct(Request $request) + { + $this->request = $request; + } + + /** + * Check whether cookies are supported or not + * + * @return bool + */ + public function isSupported() + { + if (! empty($_COOKIE)) { + $this->cleanupCheck(); + return true; + } + + $url = $this->request->getUrl(); + + if ($url->hasParam('_checkCookie') && empty($_COOKIE)) { + return false; + } + + if (! $url->hasParam('_checkCookie')) { + $this->provideCheck(); + } + + return false; + } + + /** + * Prepare check to detect cookie support + */ + public function provideCheck() + { + setcookie(self::CHECK_COOKIE, '1'); + + $requestUri = $this->request->getUrl()->addParams(array('_checkCookie' => 1)); + $this->request->getResponse()->redirectAndExit($requestUri); + } + + /** + * Cleanup the cookie support check + */ + public function cleanupCheck() + { + if ($this->request->getUrl()->hasParam('_checkCookie') && isset($_COOKIE[self::CHECK_COOKIE])) { + $requestUri =$this->request->getUrl()->without('_checkCookie'); + $this->request->getResponse()->redirectAndExit($requestUri); + } + } +} diff --git a/library/Icinga/Web/Helper/HtmlPurifier.php b/library/Icinga/Web/Helper/HtmlPurifier.php new file mode 100644 index 0000000..19fd207 --- /dev/null +++ b/library/Icinga/Web/Helper/HtmlPurifier.php @@ -0,0 +1,95 @@ +<?php +/* Icinga Web 2 | (c) 2018 Icinga Development Team | GPLv2+ */ + +namespace Icinga\Web\Helper; + +use Closure; +use Icinga\Web\FileCache; +use InvalidArgumentException; + +class HtmlPurifier +{ + /** + * The actual purifier instance + * + * @var \HTMLPurifier + */ + protected $purifier; + + /** + * Create a new HtmlPurifier + * + * @param array|Closure $config Additional configuration + */ + public function __construct($config = null) + { + $purifierConfig = \HTMLPurifier_Config::createDefault(); + $purifierConfig->set('Core.EscapeNonASCIICharacters', true); + $purifierConfig->set('Attr.AllowedFrameTargets', array('_blank')); + + if (($cachePath = FileCache::instance()->directory('htmlpurifier.cache')) !== false) { + $purifierConfig->set('Cache.SerializerPath', $cachePath); + } else { + $purifierConfig->set('Cache.DefinitionImpl', null); + } + + // This avoids permission problems: + // $purifierConfig->set('Core.DefinitionCache', null); + + // $purifierConfig->set('URI.Base', 'http://www.example.com'); + // $purifierConfig->set('URI.MakeAbsolute', true); + + $this->configure($purifierConfig); + + if ($config instanceof Closure) { + call_user_func($config, $purifierConfig); + } elseif (is_array($config)) { + $purifierConfig->loadArray($config); + } elseif ($config !== null) { + throw new InvalidArgumentException('$config must be either a Closure or array'); + } + + $this->purifier = new \HTMLPurifier($purifierConfig); + } + + /** + * Apply additional default configuration + * + * May be overwritten by more concrete purifier implementations. + * + * @param \HTMLPurifier_Config $config + */ + protected function configure($config) + { + } + + /** + * Purify and return the given HTML string + * + * @param string $html + * @param array|Closure $config Configuration to use instead of the default + * + * @return string + */ + public function purify($html, $config = null) + { + return $this->purifier->purify($html, $config); + } + + /** + * Purify and return the given HTML string + * + * Convenience method to bypass object creation. + * + * @param string $html + * @param array|Closure $config Additional configuration + * + * @return string + */ + public static function process($html, $config = null) + { + $purifier = new static($config); + + return $purifier->purify($html); + } +} diff --git a/library/Icinga/Web/Helper/Markdown.php b/library/Icinga/Web/Helper/Markdown.php new file mode 100644 index 0000000..cb854b4 --- /dev/null +++ b/library/Icinga/Web/Helper/Markdown.php @@ -0,0 +1,34 @@ +<?php +/* Icinga Web 2 | (c) 2019 Icinga GmbH | GPLv2+ */ + +namespace Icinga\Web\Helper; + +use Icinga\Web\Helper\Markdown\LinkTransformer; +use Parsedown; + +class Markdown +{ + public static function line($content, $config = null) + { + if ($config === null) { + $config = function (\HTMLPurifier_Config $config) { + $config->set('HTML.Parent', 'span'); // Only allow inline elements + + LinkTransformer::attachTo($config); + }; + } + + return HtmlPurifier::process(Parsedown::instance()->line($content), $config); + } + + public static function text($content, $config = null) + { + if ($config === null) { + $config = function (\HTMLPurifier_Config $config) { + LinkTransformer::attachTo($config); + }; + } + + return HtmlPurifier::process(Parsedown::instance()->text($content), $config); + } +} diff --git a/library/Icinga/Web/Helper/Markdown/LinkTransformer.php b/library/Icinga/Web/Helper/Markdown/LinkTransformer.php new file mode 100644 index 0000000..f323085 --- /dev/null +++ b/library/Icinga/Web/Helper/Markdown/LinkTransformer.php @@ -0,0 +1,73 @@ +<?php +/* Icinga Web 2 | (c) 2021 Icinga GmbH | GPLv2+ */ + +namespace Icinga\Web\Helper\Markdown; + +use HTMLPurifier_AttrTransform; +use HTMLPurifier_Config; +use ipl\Web\Url; + +class LinkTransformer extends HTMLPurifier_AttrTransform +{ + /** + * Link targets that are considered to have a thumbnail + * + * @var string[] + */ + public static $IMAGE_FILES = [ + 'jpg', + 'jpeg', + 'png', + 'bmp', + 'gif', + 'heif', + 'heic', + 'webp' + ]; + + public function transform($attr, $config, $context) + { + if (! isset($attr['href'])) { + return $attr; + } + + $url = Url::fromPath($attr['href']); + $fileName = basename($url->getPath()); + + $ext = null; + if (($extAt = strrpos($fileName, '.')) !== false) { + $ext = substr($fileName, $extAt + 1); + } + + $hasThumbnail = $ext !== null && in_array($ext, static::$IMAGE_FILES, true); + if ($hasThumbnail) { + // I would have liked to not only base this off of the extension, but also by + // whether there is an actual img tag inside the anchor. Seems not possible :( + $attr['class'] = 'with-thumbnail'; + } + + if (! isset($attr['target'])) { + if ($url->isExternal()) { + $attr['target'] = '_blank'; + } else { + $attr['data-base-target'] = '_next'; + } + } + + return $attr; + } + + public static function attachTo(HTMLPurifier_Config $config) + { + $module = $config->getHTMLDefinition(true) + ->getAnonymousModule(); + + if (isset($module->info['a'])) { + $a = $module->info['a']; + } else { + $a = $module->addBlankElement('a'); + } + + $a->attr_transform_post[] = new self(); + } +} diff --git a/library/Icinga/Web/Hook.php b/library/Icinga/Web/Hook.php new file mode 100644 index 0000000..b098518 --- /dev/null +++ b/library/Icinga/Web/Hook.php @@ -0,0 +1,16 @@ +<?php +/* Icinga Web 2 | (c) 2013 Icinga Development Team | GPLv2+ */ + +namespace Icinga\Web; + +use Icinga\Application\Hook as NewHookImplementation; + +/** + * Icinga Web Hook registry + * + * @deprecated It is highly recommended to use {@see Icinga\Application\Hook} instead. Though since this message + * (or rather the previous message) hasn't been visible for ages... This won't be removed anyway.... + */ +class Hook extends NewHookImplementation +{ +} diff --git a/library/Icinga/Web/JavaScript.php b/library/Icinga/Web/JavaScript.php new file mode 100644 index 0000000..1865136 --- /dev/null +++ b/library/Icinga/Web/JavaScript.php @@ -0,0 +1,269 @@ +<?php +/* Icinga Web 2 | (c) 2014 Icinga Development Team | GPLv2+ */ + +namespace Icinga\Web; + +use Icinga\Application\Icinga; +use Icinga\Application\Logger; +use Icinga\Exception\Json\JsonDecodeException; +use Icinga\Util\Json; +use JShrink\Minifier; + +class JavaScript +{ + /** @var string */ + const DEFINE_RE = + '/(?<!\.)define\(\s*([\'"][^\'"]*[\'"])?[,\s]*(\[[^]]*\])?[,\s]*((?>function\s*\([^)]*\)|[^=]*=>|\w+).*)/'; + + protected static $jsFiles = [ + 'js/helpers.js', + 'js/icinga.js', + 'js/icinga/logger.js', + 'js/icinga/storage.js', + 'js/icinga/utils.js', + 'js/icinga/ui.js', + 'js/icinga/timer.js', + 'js/icinga/loader.js', + 'js/icinga/eventlistener.js', + 'js/icinga/events.js', + 'js/icinga/history.js', + 'js/icinga/module.js', + 'js/icinga/timezone.js', + 'js/icinga/behavior/application-state.js', + 'js/icinga/behavior/autofocus.js', + 'js/icinga/behavior/collapsible.js', + 'js/icinga/behavior/detach.js', + 'js/icinga/behavior/dropdown.js', + 'js/icinga/behavior/navigation.js', + 'js/icinga/behavior/form.js', + 'js/icinga/behavior/actiontable.js', + 'js/icinga/behavior/flyover.js', + 'js/icinga/behavior/filtereditor.js', + 'js/icinga/behavior/selectable.js', + 'js/icinga/behavior/modal.js', + 'js/icinga/behavior/input-enrichment.js', + 'js/icinga/behavior/datetime-picker.js', + 'js/icinga/behavior/copy-to-clipboard.js' + ]; + + protected static $vendorFiles = []; + + protected static $baseFiles = [ + 'js/define.js' + ]; + + public static function sendMinified() + { + self::send(true); + } + + /** + * Send the client side script code to the client + * + * Does not cache the client side script code if the HTTP header Cache-Control or Pragma is set to no-cache. + * + * @param bool $minified Whether to compress the client side script code + */ + public static function send($minified = false) + { + header('Content-Type: application/javascript'); + $basedir = Icinga::app()->getBootstrapDirectory(); + $moduleManager = Icinga::app()->getModuleManager(); + + $files = []; + $js = $out = ''; + $min = $minified ? '.min' : ''; + + // Prepare vendor file list + $vendorFiles = []; + foreach (self::$vendorFiles as $file) { + $filePath = $basedir . '/' . $file . $min . '.js'; + $vendorFiles[] = $filePath; + $files[] = $filePath; + } + + // Prepare base file list + $baseFiles = []; + foreach (self::$baseFiles as $file) { + $filePath = $basedir . '/' . $file; + $baseFiles[] = $filePath; + $files[] = $filePath; + } + + // Prepare library file list + foreach (Icinga::app()->getLibraries() as $library) { + $files = array_merge($files, $library->getJsAssets()); + } + + // Prepare core file list + $coreFiles = []; + foreach (self::$jsFiles as $file) { + $filePath = $basedir . '/' . $file; + $coreFiles[] = $filePath; + $files[] = $filePath; + } + + $moduleFiles = []; + foreach ($moduleManager->getLoadedModules() as $name => $module) { + if ($module->hasJs()) { + $jsDir = $module->getJsDir(); + foreach ($module->getJsFiles() as $path) { + if (file_exists($path)) { + $moduleFiles[$name][$jsDir][] = $path; + $files[] = $path; + } + } + } + } + + $request = Icinga::app()->getRequest(); + $noCache = $request->getHeader('Cache-Control') === 'no-cache' || $request->getHeader('Pragma') === 'no-cache'; + + header('Cache-Control: public,no-cache,must-revalidate'); + + if (! $noCache && FileCache::etagMatchesFiles($files)) { + header("HTTP/1.1 304 Not Modified"); + return; + } else { + $etag = FileCache::etagForFiles($files); + } + + header('ETag: "' . $etag . '"'); + header('Content-Type: application/javascript'); + + $cacheFile = 'icinga-' . $etag . $min . '.js'; + $cache = FileCache::instance(); + if (! $noCache && $cache->has($cacheFile)) { + $cache->send($cacheFile); + return; + } + + // We do not minify vendor files + foreach ($vendorFiles as $file) { + $out .= ';' . ltrim(trim(file_get_contents($file)), ';') . "\n"; + } + + $baseJs = ''; + foreach ($baseFiles as $file) { + $baseJs .= file_get_contents($file) . "\n\n\n"; + } + + // Library files need to be namespaced first before they can be included + foreach (Icinga::app()->getLibraries() as $library) { + foreach ($library->getJsAssets() as $file) { + $alreadyMinified = false; + if ($minified && file_exists(($minFile = substr($file, 0, -3) . '.min.js'))) { + $alreadyMinified = true; + $file = $minFile; + } + + $content = self::optimizeDefine( + file_get_contents($file), + $file, + $library->getJsAssetPath(), + $library->getName() + ); + + if ($alreadyMinified) { + $out .= ';' . ltrim(trim($content), ';') . "\n"; + } else { + $js .= $content . "\n\n\n"; + } + } + } + + foreach ($coreFiles as $file) { + $js .= file_get_contents($file) . "\n\n\n"; + } + + foreach ($moduleFiles as $name => $paths) { + foreach ($paths as $basePath => $filePaths) { + foreach ($filePaths as $file) { + $content = self::optimizeDefine(file_get_contents($file), $file, $basePath, $name); + if (substr($file, -7, 7) === '.min.js') { + $out .= ';' . ltrim(trim($content), ';') . "\n"; + } else { + $js .= $content . "\n\n\n"; + } + } + } + } + + if ($minified) { + $out .= Minifier::minify($js, ['flaggedComments' => false]); + $baseOut = Minifier::minify($baseJs, ['flaggedComments' => false]); + $out = ';' . ltrim($baseOut, ';') . "\n" . $out; + } else { + $out = $baseJs . $out . $js; + } + + $cache->store($cacheFile, $out); + echo $out; + } + + /** + * Optimize define() calls in the given JS + * + * @param string $js + * @param string $filePath + * @param string $basePath + * @param string $packageName + * + * @return string + */ + public static function optimizeDefine($js, $filePath, $basePath, $packageName) + { + if (! preg_match(self::DEFINE_RE, $js, $match) || strpos($js, 'define.amd') !== false) { + return $js; + } + + try { + $assetName = $match[1] ? Json::decode($match[1]) : ''; + if (! $assetName) { + $assetName = explode('.', basename($filePath))[0]; + } + + $assetName = join(DIRECTORY_SEPARATOR, array_filter([ + $packageName, + ltrim(substr(dirname($filePath), strlen($basePath)), DIRECTORY_SEPARATOR), + $assetName + ])); + + $assetName = Json::encode($assetName, JSON_UNESCAPED_SLASHES); + } catch (JsonDecodeException $_) { + $assetName = $match[1]; + Logger::debug('Can\'t optimize name of "%s". Are single quotes used instead of double quotes?', $filePath); + } + + try { + $dependencies = $match[2] ? Json::decode($match[2]) : []; + foreach ($dependencies as &$dependencyName) { + if ($dependencyName === 'exports') { + // exports is a special keyword and doesn't need optimization + continue; + } + + if (preg_match('~^((?:\.\.?/)+)*(.*)~', $dependencyName, $natch)) { + $dependencyName = join(DIRECTORY_SEPARATOR, array_filter([ + $packageName, + ltrim(substr( + realpath(join(DIRECTORY_SEPARATOR, [dirname($filePath), $natch[1]])), + strlen(realpath($basePath)) + ), DIRECTORY_SEPARATOR), + $natch[2] + ])); + } + } + + $dependencies = Json::encode($dependencies, JSON_UNESCAPED_SLASHES); + } catch (JsonDecodeException $_) { + $dependencies = $match[2]; + Logger::debug( + 'Can\'t optimize dependencies of "%s". Are single quotes used instead of double quotes?', + $filePath + ); + } + + return str_replace($match[0], sprintf("define(%s, %s, %s", $assetName, $dependencies, $match[3]), $js); + } +} diff --git a/library/Icinga/Web/LessCompiler.php b/library/Icinga/Web/LessCompiler.php new file mode 100644 index 0000000..d7eda09 --- /dev/null +++ b/library/Icinga/Web/LessCompiler.php @@ -0,0 +1,255 @@ +<?php +/* Icinga Web 2 | (c) 2013 Icinga Development Team | GPLv2+ */ + +namespace Icinga\Web; + +use Icinga\Application\Logger; +use Icinga\Util\LessParser; +use Less_Exception_Parser; + +/** + * Compile LESS into CSS + * + * Comments will be removed always. lessc is messing them up. + */ +class LessCompiler +{ + /** + * lessphp compiler + * + * @var LessParser + */ + protected $lessc; + + /** + * Array of LESS files + * + * @var string[] + */ + protected $lessFiles = array(); + + /** + * Array of module LESS files indexed by module names + * + * @var array[] + */ + protected $moduleLessFiles = array(); + + /** + * LESS source + * + * @var string + */ + protected $source; + + /** + * Path of the LESS theme + * + * @var string + */ + protected $theme; + + /** + * Path of the LESS theme mode + * + * @var string + */ + protected $themeMode; + + /** + * Create a new LESS compiler + */ + public function __construct() + { + $this->lessc = new LessParser(); + } + + /** + * Add a Web 2 LESS file + * + * @param string $lessFile Path to the LESS file + * + * @return $this + */ + public function addLessFile($lessFile) + { + $this->lessFiles[] = realpath($lessFile); + return $this; + } + + /** + * Add a module LESS file + * + * @param string $moduleName Name of the module + * @param string $lessFile Path to the LESS file + * + * @return $this + */ + public function addModuleLessFile($moduleName, $lessFile) + { + if (! isset($this->moduleLessFiles[$moduleName])) { + $this->moduleLessFiles[$moduleName] = array(); + } + $this->moduleLessFiles[$moduleName][] = realpath($lessFile); + return $this; + } + + /** + * Get the list of LESS files added to the compiler + * + * @return string[] + */ + public function getLessFiles() + { + $lessFiles = $this->lessFiles; + + foreach ($this->moduleLessFiles as $moduleLessFiles) { + $lessFiles = array_merge($lessFiles, $moduleLessFiles); + } + + if ($this->theme !== null) { + $lessFiles[] = $this->theme; + } + + if ($this->themeMode !== null) { + $lessFiles[] = $this->themeMode; + } + + return $lessFiles; + } + + /** + * Set the path to the LESS theme + * + * @param ?string $theme Path to the LESS theme + * + * @return $this + */ + public function setTheme($theme) + { + if ($theme === null || (is_file($theme) && is_readable($theme))) { + $this->theme = $theme; + } else { + Logger::error('Can\t load theme %s. Make sure that the theme exists and is readable', $theme); + } + return $this; + } + + /** + * Set the path to the LESS theme mode + * + * @param string $themeMode Path to the LESS theme mode + * + * @return $this + */ + public function setThemeMode($themeMode) + { + if (is_file($themeMode) && is_readable($themeMode)) { + $this->themeMode = $themeMode; + } else { + Logger::error('Can\t load theme mode %s. Make sure that the theme mode exists and is readable', $themeMode); + } + return $this; + } + + /** + * Instruct the compiler to minify CSS + * + * @return $this + */ + public function compress() + { + $this->lessc->setFormatter('compressed'); + return $this; + } + + /** + * Render to CSS + * + * @return string + */ + public function render() + { + foreach ($this->lessFiles as $lessFile) { + $this->source .= file_get_contents($lessFile); + } + + $moduleCss = ''; + $exportedVars = []; + foreach ($this->moduleLessFiles as $moduleName => $moduleLessFiles) { + $moduleCss .= '.icinga-module.module-' . $moduleName . ' {'; + + foreach ($moduleLessFiles as $moduleLessFile) { + $content = file_get_contents($moduleLessFile); + + $pattern = '/^@exports:\s*{((?:\s*@[^:}]+:[^;]*;\s+)+)};$/m'; + if (preg_match_all($pattern, $content, $matches, PREG_SET_ORDER)) { + foreach ($matches as $match) { + $content = str_replace($match[0], '', $content); + foreach (explode("\n", trim($match[1])) as $line) { + list($name, $value) = explode(':', $line, 2); + $exportedVars[trim($name)] = trim($value, ' ;'); + } + } + } + + $moduleCss .= $content; + } + + $moduleCss .= '}'; + } + + $this->source .= $moduleCss; + + $varExports = ''; + foreach ($exportedVars as $name => $value) { + $varExports .= sprintf("%s: %s;\n", $name, $value); + } + + // exported vars are injected at the beginning to avoid that they are + // able to override other variables, that's what themes are for + $this->source = $varExports . "\n\n" . $this->source; + + if ($this->theme !== null) { + $this->source .= file_get_contents($this->theme); + } + + if ($this->themeMode !== null) { + $this->source .= file_get_contents($this->themeMode); + } + + try { + return preg_replace( + '/(\.icinga-module\.module-[^\s]+) (#layout\.[^\s]+)/m', + '\2 \1', + $this->lessc->compile($this->source) + ); + } catch (Less_Exception_Parser $e) { + $excerpt = substr($this->source, $e->index - 500, 1000); + + $lines = []; + $found = false; + $pos = $e->index - 500; + foreach (explode("\n", $excerpt) as $i => $line) { + if ($i === 0) { + $pos += strlen($line); + $lines[] = '.. ' . $line; + } else { + $pos += strlen($line) + 1; + $sep = ' '; + if (! $found && $pos > $e->index) { + $found = true; + $sep = '!! '; + } + + $lines[] = $sep . $line; + } + } + + $lines[] = '..'; + $excerpt = join("\n", $lines); + + return sprintf("%s\n%s\n\n\n%s", $e->getMessage(), $e->getTraceAsString(), $excerpt); + } + } +} diff --git a/library/Icinga/Web/Menu.php b/library/Icinga/Web/Menu.php new file mode 100644 index 0000000..dc1cdc8 --- /dev/null +++ b/library/Icinga/Web/Menu.php @@ -0,0 +1,152 @@ +<?php +/* Icinga Web 2 | (c) 2018 Icinga Development Team | GPLv2+ */ + +namespace Icinga\Web; + +use Icinga\Application\Logger; +use Icinga\Authentication\Auth; +use Icinga\Web\Navigation\Navigation; + +/** + * Main menu for Icinga Web 2 + */ +class Menu extends Navigation +{ + /** + * Create the main menu + */ + public function __construct() + { + $this->init(); + $this->load('menu-item'); + } + + /** + * Setup the main menu + */ + public function init() + { + $this->addItem('dashboard', [ + 'label' => t('Dashboard'), + 'url' => 'dashboard', + 'icon' => 'dashboard', + 'priority' => 10 + ]); + $this->addItem('system', [ + 'cssClass' => 'system-nav-item', + 'label' => t('System'), + 'icon' => 'services', + 'priority' => 700, + 'renderer' => [ + 'SummaryNavigationItemRenderer', + 'state' => 'critical' + ], + 'children' => [ + 'about' => [ + 'icon' => 'info', + 'description' => t('Open about page'), + 'label' => t('About'), + 'url' => 'about', + 'priority' => 700 + ], + 'health' => [ + 'icon' => 'eye', + 'description' => t('Open health overview'), + 'label' => t('Health'), + 'url' => 'health', + 'priority' => 710, + 'renderer' => 'HealthNavigationRenderer' + ], + 'announcements' => [ + 'icon' => 'megaphone', + 'description' => t('List announcements'), + 'label' => t('Announcements'), + 'url' => 'announcements', + 'priority' => 720 + ], + 'sessions' => [ + 'icon' => 'host', + 'description' => t('List of users who stay logged in'), + 'label' => t('User Sessions'), + 'permission' => 'application/sessions', + 'url' => 'manage-user-devices', + 'priority' => 730 + ] + ] + ]); + $this->addItem('configuration', [ + 'cssClass' => 'configuration-nav-item', + 'label' => t('Configuration'), + 'icon' => 'wrench', + 'permission' => 'config/*', + 'priority' => 800, + 'children' => [ + 'application' => [ + 'icon' => 'wrench', + 'description' => t('Open application configuration'), + 'label' => t('Application'), + 'url' => 'config', + 'priority' => 810 + ], + 'authentication' => [ + 'icon' => 'users', + 'description' => t('Open access control configuration'), + 'label' => t('Access Control'), + 'permission' => 'config/access-control/*', + 'priority' => 830, + 'url' => 'role' + ], + 'navigation' => [ + 'icon' => 'sitemap', + 'description' => t('Open shared navigation configuration'), + 'label' => t('Shared Navigation'), + 'url' => 'navigation/shared', + 'permission' => 'config/navigation', + 'priority' => 840, + ], + 'modules' => [ + 'icon' => 'cubes', + 'description' => t('Open module configuration'), + 'label' => t('Modules'), + 'url' => 'config/modules', + 'permission' => 'config/modules', + 'priority' => 890 + ] + ] + ]); + $this->addItem('user', [ + 'cssClass' => 'user-nav-item', + 'label' => Auth::getInstance()->getUser()->getUsername(), + 'icon' => 'user', + 'priority' => 900, + 'children' => [ + 'account' => [ + 'icon' => 'sliders', + 'description' => t('Open your account preferences'), + 'label' => t('My Account'), + 'priority' => 100, + 'url' => 'account' + ], + 'logout' => [ + 'icon' => 'off', + 'description' => t('Log out'), + 'label' => t('Logout'), + 'priority' => 200, + 'attributes' => ['target' => '_self'], + 'url' => 'authentication/logout' + ] + ] + ]); + + if (Logger::writesToFile()) { + $this->getItem('system')->addChild($this->createItem('application_log', [ + 'icon' => 'doc-text', + 'description' => t('Open Application Log'), + 'label' => t('Application Log'), + 'url' => 'list/applicationlog', + 'permission' => 'application/log', + 'priority' => 900 + ])); + } + } +} diff --git a/library/Icinga/Web/Navigation/ConfigMenu.php b/library/Icinga/Web/Navigation/ConfigMenu.php new file mode 100644 index 0000000..583bf42 --- /dev/null +++ b/library/Icinga/Web/Navigation/ConfigMenu.php @@ -0,0 +1,327 @@ +<?php +/* Icinga Web 2 | (c) 2022 Icinga GmbH | GPLv2+ */ + +namespace Icinga\Web\Navigation; + +use Icinga\Application\Hook\HealthHook; +use Icinga\Application\Icinga; +use Icinga\Application\Logger; +use Icinga\Application\MigrationManager; +use Icinga\Authentication\Auth; +use Icinga\Web\Navigation\Renderer\BadgeNavigationItemRenderer; +use ipl\Html\Attributes; +use ipl\Html\BaseHtmlElement; +use ipl\Html\HtmlElement; +use ipl\Html\Text; +use ipl\Web\Url; +use ipl\Web\Widget\Icon; +use ipl\Web\Widget\StateBadge; +use Throwable; + +class ConfigMenu extends BaseHtmlElement +{ + const STATE_OK = 'ok'; + const STATE_CRITICAL = 'critical'; + const STATE_WARNING = 'warning'; + const STATE_PENDING = 'pending'; + const STATE_UNKNOWN = 'unknown'; + + protected $tag = 'ul'; + + protected $defaultAttributes = ['class' => 'nav']; + + protected $children; + + protected $selected; + + protected $state; + + public function __construct() + { + $this->children = [ + 'system' => [ + 'title' => t('System'), + 'items' => [ + 'about' => [ + 'label' => t('About'), + 'url' => 'about' + ], + 'health' => [ + 'label' => t('Health'), + 'url' => 'health', + ], + 'migrations' => [ + 'label' => t('Migrations'), + 'url' => 'migrations', + ], + 'announcements' => [ + 'label' => t('Announcements'), + 'url' => 'announcements' + ], + 'sessions' => [ + 'label' => t('User Sessions'), + 'permission' => 'application/sessions', + 'url' => 'manage-user-devices' + ] + ] + ], + 'configuration' => [ + 'title' => t('Configuration'), + 'permission' => 'config/*', + 'items' => [ + 'application' => [ + 'label' => t('Application'), + 'url' => 'config/general' + ], + 'authentication' => [ + 'label' => t('Access Control'), + 'permission' => 'config/access-control/*', + 'url' => 'role/list' + ], + 'navigation' => [ + 'label' => t('Shared Navigation'), + 'permission' => 'config/navigation', + 'url' => 'navigation/shared' + ], + 'modules' => [ + 'label' => t('Modules'), + 'permission' => 'config/modules', + 'url' => 'config/modules' + ] + ] + ], + 'logout' => [ + 'items' => [ + 'logout' => [ + 'label' => t('Logout'), + 'atts' => [ + 'target' => '_self', + 'class' => 'nav-item-logout' + ], + 'url' => 'authentication/logout' + ] + ] + ] + ]; + + if (Logger::writesToFile()) { + $this->children['system']['items']['application_log'] = [ + 'label' => t('Application Log'), + 'url' => 'list/applicationlog', + 'permission' => 'application/log' + ]; + } + } + + protected function assembleUserMenuItem(BaseHtmlElement $userMenuItem) + { + $username = Auth::getInstance()->getUser()->getUsername(); + + $userMenuItem->add( + new HtmlElement( + 'a', + Attributes::create(['href' => Url::fromPath('account')]), + new HtmlElement( + 'i', + Attributes::create(['class' => 'user-ball']), + Text::create($username[0]) + ), + Text::create($username) + ) + ); + + if (Icinga::app()->getRequest()->getUrl()->matches('account')) { + $userMenuItem->addAttributes(['class' => 'selected active']); + } + } + + protected function assembleCogMenuItem($cogMenuItem) + { + $cogMenuItem->add([ + HtmlElement::create( + 'button', + null, + [ + new Icon('cog'), + $this->createHealthBadge() ?? $this->createMigrationBadge(), + ] + ), + $this->createLevel2Menu() + ]); + } + + protected function assembleLevel2Nav(BaseHtmlElement $level2Nav) + { + $navContent = HtmlElement::create('div', ['class' => 'flyout-content']); + foreach ($this->children as $c) { + if (isset($c['permission']) && ! Auth::getInstance()->hasPermission($c['permission'])) { + continue; + } + + if (isset($c['title'])) { + $navContent->add(HtmlElement::create( + 'h3', + null, + $c['title'] + )); + } + + $ul = HtmlElement::create('ul', ['class' => 'nav']); + foreach ($c['items'] as $key => $item) { + $ul->add($this->createLevel2MenuItem($item, $key)); + } + + $navContent->add($ul); + } + + $level2Nav->add($navContent); + } + + protected function getHealthCount() + { + $count = 0; + $worstState = null; + foreach (HealthHook::collectHealthData()->select() as $result) { + if ($worstState === null || $result->state > $worstState) { + $worstState = $result->state; + $count = 1; + } elseif ($worstState === $result->state) { + $count++; + } + } + + switch ($worstState) { + case HealthHook::STATE_OK: + $count = 0; + break; + case HealthHook::STATE_WARNING: + $this->state = self::STATE_WARNING; + break; + case HealthHook::STATE_CRITICAL: + $this->state = self::STATE_CRITICAL; + break; + case HealthHook::STATE_UNKNOWN: + $this->state = self::STATE_UNKNOWN; + break; + } + + return $count; + } + + protected function isSelectedItem($item) + { + if ($item !== null && Icinga::app()->getRequest()->getUrl()->matches($item['url'])) { + $this->selected = $item; + return true; + } + + return false; + } + + protected function createHealthBadge(): ?StateBadge + { + $stateBadge = null; + if ($this->getHealthCount() > 0) { + $stateBadge = new StateBadge($this->getHealthCount(), $this->state); + $stateBadge->addAttributes(['class' => 'disabled']); + } + + return $stateBadge; + } + + protected function createMigrationBadge(): ?StateBadge + { + try { + $mm = MigrationManager::instance(); + $count = $mm->count(); + } catch (Throwable $e) { + Logger::error('Failed to load pending migrations: %s', $e); + $count = 0; + } + + $stateBadge = null; + if ($count > 0) { + $stateBadge = new StateBadge($count, BadgeNavigationItemRenderer::STATE_PENDING); + $stateBadge->addAttributes(['class' => 'disabled']); + } + + return $stateBadge; + } + + protected function createLevel2Menu() + { + $level2Nav = HtmlElement::create( + 'div', + Attributes::create(['class' => 'nav-level-1 flyout']) + ); + + $this->assembleLevel2Nav($level2Nav); + + return $level2Nav; + } + + protected function createLevel2MenuItem($item, $key) + { + if (isset($item['permission']) && ! Auth::getInstance()->hasPermission($item['permission'])) { + return null; + } + + $stateBadge = null; + $class = null; + if ($key === 'health') { + $class = 'badge-nav-item'; + $stateBadge = $this->createHealthBadge(); + } elseif ($key === 'migrations') { + $class = 'badge-nav-item'; + $stateBadge = $this->createMigrationBadge(); + } + + $li = HtmlElement::create( + 'li', + $item['atts'] ?? [], + [ + HtmlElement::create( + 'a', + Attributes::create(['href' => Url::fromPath($item['url'])]), + [ + $item['label'], + $stateBadge ?? '' + ] + ), + ] + ); + $li->addAttributes(['class' => $class]); + + if ($this->isSelectedItem($item)) { + $li->addAttributes(['class' => 'selected']); + } + + return $li; + } + + protected function createUserMenuItem() + { + $userMenuItem = HtmlElement::create('li', ['class' => 'user-nav-item']); + + $this->assembleUserMenuItem($userMenuItem); + + return $userMenuItem; + } + + protected function createCogMenuItem() + { + $cogMenuItem = HtmlElement::create('li', ['class' => 'config-nav-item']); + + $this->assembleCogMenuItem($cogMenuItem); + + return $cogMenuItem; + } + + protected function assemble() + { + $this->add([ + $this->createUserMenuItem(), + $this->createCogMenuItem() + ]); + } +} diff --git a/library/Icinga/Web/Navigation/DashboardPane.php b/library/Icinga/Web/Navigation/DashboardPane.php new file mode 100644 index 0000000..71b3215 --- /dev/null +++ b/library/Icinga/Web/Navigation/DashboardPane.php @@ -0,0 +1,84 @@ +<?php +/* Icinga Web 2 | (c) 2015 Icinga Development Team | GPLv2+ */ + +namespace Icinga\Web\Navigation; + +use Icinga\Web\Url; + +/** + * A dashboard pane + */ +class DashboardPane extends NavigationItem +{ + /** + * This pane's dashlets + * + * @var array + */ + protected $dashlets; + + protected $disabled; + + /** + * Set this pane's dashlets + * + * @param array $dashlets + * + * @return $this + */ + public function setDashlets(array $dashlets) + { + $this->dashlets = $dashlets; + return $this; + } + + /** + * Return this pane's dashlets + * + * @param bool $ordered Whether to order the dashlets first + * + * @return array + */ + public function getDashlets($ordered = true) + { + if ($this->dashlets === null) { + return array(); + } + + if ($ordered) { + $dashlets = $this->dashlets; + ksort($dashlets); + return $dashlets; + } + + return $this->dashlets; + } + + /** + * {@inheritdoc} + */ + public function init() + { + $this->setUrl(Url::fromPath('dashboard', array('pane' => $this->getName()))); + } + + /** + * Set disabled state for pane + * + * @param bool $disabled + */ + public function setDisabled($disabled = true) + { + $this->disabled = (bool) $disabled; + } + + /** + * Get disabled state for pane + * + * @return bool + */ + public function getDisabled() + { + return $this->disabled; + } +} diff --git a/library/Icinga/Web/Navigation/DropdownItem.php b/library/Icinga/Web/Navigation/DropdownItem.php new file mode 100644 index 0000000..2342b96 --- /dev/null +++ b/library/Icinga/Web/Navigation/DropdownItem.php @@ -0,0 +1,20 @@ +<?php +/* Icinga Web 2 | (c) 2015 Icinga Development Team | GPLv2+ */ + +namespace Icinga\Web\Navigation; + +/** + * Dropdown navigation item + * + * @see \Icinga\Web\Navigation\Navigation For a usage example. + */ +class DropdownItem extends NavigationItem +{ + /** + * {@inheritdoc} + */ + public function init() + { + $this->children->setLayout(Navigation::LAYOUT_DROPDOWN); + } +} diff --git a/library/Icinga/Web/Navigation/Navigation.php b/library/Icinga/Web/Navigation/Navigation.php new file mode 100644 index 0000000..4343c3c --- /dev/null +++ b/library/Icinga/Web/Navigation/Navigation.php @@ -0,0 +1,572 @@ +<?php +/* Icinga Web 2 | (c) 2015 Icinga Development Team | GPLv2+ */ + +namespace Icinga\Web\Navigation; + +use ArrayAccess; +use ArrayIterator; +use Exception; +use Countable; +use InvalidArgumentException; +use IteratorAggregate; +use Traversable; +use Icinga\Application\Icinga; +use Icinga\Application\Logger; +use Icinga\Authentication\Auth; +use Icinga\Data\ConfigObject; +use Icinga\Exception\ConfigurationError; +use Icinga\Exception\IcingaException; +use Icinga\Exception\ProgrammingError; +use Icinga\Util\StringHelper; +use Icinga\Web\Navigation\Renderer\RecursiveNavigationRenderer; + +/** + * Container for navigation items + */ +class Navigation implements ArrayAccess, Countable, IteratorAggregate +{ + /** + * The class namespace where to locate navigation type classes + * + * @var string + */ + const NAVIGATION_NS = 'Web\\Navigation'; + + /** + * Flag for dropdown layout + * + * @var int + */ + const LAYOUT_DROPDOWN = 1; + + /** + * Flag for tabs layout + * + * @var int + */ + const LAYOUT_TABS = 2; + + /** + * Known navigation types + * + * @var array + */ + protected static $types; + + /** + * This navigation's items + * + * @var NavigationItem[] + */ + protected $items = array(); + + /** + * This navigation's layout + * + * @var int + */ + protected $layout; + + public function offsetExists($offset): bool + { + return isset($this->items[$offset]); + } + + public function offsetGet($offset): ?NavigationItem + { + return $this->items[$offset] ?? null; + } + + public function offsetSet($offset, $value): void + { + $this->items[$offset] = $value; + } + + public function offsetUnset($offset): void + { + unset($this->items[$offset]); + } + + public function count(): int + { + return count($this->items); + } + + public function getIterator(): Traversable + { + $this->order(); + return new ArrayIterator($this->items); + } + + /** + * Create and return a new navigation item for the given configuration + * + * @param string $name + * @param array|ConfigObject $properties + * + * @return NavigationItem + * + * @throws InvalidArgumentException If the $properties argument is neither an array nor a ConfigObject + */ + public function createItem($name, $properties) + { + if ($properties instanceof ConfigObject) { + $properties = $properties->toArray(); + } elseif (! is_array($properties)) { + throw new InvalidArgumentException('Argument $properties must be of type array or ConfigObject'); + } + + $itemType = isset($properties['type']) ? StringHelper::cname($properties['type'], '-') : 'NavigationItem'; + if (! empty(static::$types) && isset(static::$types[$itemType])) { + return new static::$types[$itemType]($name, $properties); + } + + $item = null; + $classPath = null; + foreach (Icinga::app()->getModuleManager()->getLoadedModules() as $module) { + $classPath = 'Icinga\\Module\\' + . ucfirst($module->getName()) + . '\\' + . static::NAVIGATION_NS + . '\\' + . $itemType; + if (class_exists($classPath)) { + $item = new $classPath($name, $properties); + break; + } + } + + if ($item === null) { + $classPath = 'Icinga\\' . static::NAVIGATION_NS . '\\' . $itemType; + if (class_exists($classPath)) { + $item = new $classPath($name, $properties); + } + } + + if ($item === null) { + if ($itemType !== 'MenuItem') { + Logger::debug( + 'Failed to find custom navigation item class %s for item %s. Using base class NavigationItem now', + $itemType, + $name + ); + } + + $item = new NavigationItem($name, $properties); + static::$types[$itemType] = 'Icinga\\Web\\Navigation\\NavigationItem'; + } elseif (! $item instanceof NavigationItem) { + throw new ProgrammingError('Class %s must inherit from NavigationItem', $classPath); + } else { + static::$types[$itemType] = $classPath; + } + + return $item; + } + + /** + * Add a navigation item + * + * If you do not pass an instance of NavigationItem, this will only add the item + * if it does not require a permission or the current user has the permission. + * + * @param string|NavigationItem $name The name of the item or an instance of NavigationItem + * @param array $properties The properties of the item to add (Ignored if $name is not a string) + * + * @return bool Whether the item was added or not + * + * @throws InvalidArgumentException In case $name is neither a string nor an instance of NavigationItem + */ + public function addItem($name, array $properties = array()) + { + if (is_string($name)) { + if (isset($properties['permission'])) { + if (! Auth::getInstance()->hasPermission($properties['permission'])) { + return false; + } + + unset($properties['permission']); + } + + $item = $this->createItem($name, $properties); + } elseif (! $name instanceof NavigationItem) { + throw new InvalidArgumentException('Argument $name must be of type string or NavigationItem'); + } else { + $item = $name; + } + + $this->items[$item->getName()] = $item; + return true; + } + + /** + * Return the item with the given name + * + * @param string $name + * @param mixed $default + * + * @return NavigationItem|mixed + */ + public function getItem($name, $default = null) + { + return isset($this->items[$name]) ? $this->items[$name] : $default; + } + + /** + * Return the currently active item or the first one if none is active + * + * @return NavigationItem + */ + public function getActiveItem() + { + foreach ($this->items as $item) { + if ($item->getActive()) { + return $item; + } + } + + $firstItem = reset($this->items); + return $firstItem ? $firstItem->setActive() : null; + } + + /** + * Return this navigation's items + * + * @return array + */ + public function getItems() + { + return $this->items; + } + + /** + * Return whether this navigation is empty + * + * @return bool + */ + public function isEmpty() + { + return empty($this->items); + } + + /** + * Return whether this navigation has any renderable items + * + * @return bool + */ + public function hasRenderableItems() + { + foreach ($this->getItems() as $item) { + if ($item->shouldRender()) { + return true; + } + } + + return false; + } + + /** + * Return this navigation's layout + * + * @return int + */ + public function getLayout() + { + return $this->layout; + } + + /** + * Set this navigation's layout + * + * @param int $layout + * + * @return $this + */ + public function setLayout($layout) + { + $this->layout = (int) $layout; + return $this; + } + + /** + * Create and return the renderer for this navigation + * + * @return RecursiveNavigationRenderer + */ + public function getRenderer() + { + return new RecursiveNavigationRenderer($this); + } + + /** + * Return this navigation rendered to HTML + * + * @return string + */ + public function render() + { + return $this->getRenderer()->render(); + } + + /** + * Order this navigation's items + * + * @return $this + */ + public function order() + { + uasort($this->items, array($this, 'compareItems')); + foreach ($this->items as $item) { + if ($item->hasChildren()) { + $item->getChildren()->order(); + } + } + + return $this; + } + + /** + * Return whether the first item is less than, more than or equal to the second one + * + * @param NavigationItem $a + * @param NavigationItem $b + * + * @return int + */ + protected function compareItems(NavigationItem $a, NavigationItem $b) + { + if ($a->getPriority() === $b->getPriority()) { + return strcasecmp($a->getLabel(), $b->getLabel()); + } + + return $a->getPriority() > $b->getPriority() ? 1 : -1; + } + + /** + * Try to find and return a item with the given or a similar name + * + * @param string $name + * + * @return ?NavigationItem + */ + public function findItem($name) + { + $item = $this->getItem($name); + if ($item !== null) { + return $item; + } + + $loweredName = strtolower($name); + foreach ($this->getItems() as $item) { + if (strtolower($item->getName()) === $loweredName) { + return $item; + } + } + } + + /** + * Merge this navigation with the given one + * + * Any duplicate items of this navigation will be overwritten by the given navigation's items. + * + * @param Navigation $navigation + * + * @return $this + */ + public function merge(Navigation $navigation) + { + foreach ($navigation as $item) { + /** @var $item NavigationItem */ + if (($existingItem = $this->findItem($item->getName())) !== null) { + if ($existingItem->conflictsWith($item)) { + $name = $item->getName(); + do { + if (preg_match('~_(\d+)$~', $name, $matches)) { + $name = preg_replace('~_\d+$~', (int) $matches[1] + 1, $name); + } else { + $name .= '_2'; + } + } while ($this->getItem($name) !== null); + + $this->addItem($item->setName($name)); + } else { + $existingItem->merge($item); + } + } else { + $this->addItem($item); + } + } + + return $this; + } + + /** + * Extend this navigation set with all additional items of the given type + * + * This will fetch navigation items from the following sources: + * * User Shareables + * * User Preferences + * * Modules + * Any existing entry will be overwritten by one that is coming later in order. + * + * @param string $type + * + * @return $this + */ + public function load($type) + { + $user = Auth::getInstance()->getUser(); + if ($type !== 'dashboard-pane') { + // Shareables + $this->merge(Icinga::app()->getSharedNavigation($type)); + + // User Preferences + $this->merge($user->getNavigation($type)); + } + + // Modules + $moduleManager = Icinga::app()->getModuleManager(); + foreach ($moduleManager->getLoadedModules() as $module) { + if ($user->can($moduleManager::MODULE_PERMISSION_NS . $module->getName())) { + if ($type === 'menu-item') { + $this->merge($module->getMenu()); + } elseif ($type === 'dashboard-pane') { + $this->merge($module->getDashboard()); + } + } + } + + return $this; + } + + /** + * Return the global navigation item type configuration + * + * @return array + */ + public static function getItemTypeConfiguration() + { + $defaultItemTypes = array( + 'menu-item' => array( + 'label' => t('Menu Entry'), + 'config' => 'menu' + )/*, // Disabled, until it is able to fully replace the old implementation + 'dashlet' => array( + 'label' => 'Dashlet', + 'config' => 'dashboard' + )*/ + ); + + $moduleItemTypes = array(); + $moduleManager = Icinga::app()->getModuleManager(); + foreach ($moduleManager->getLoadedModules() as $module) { + if (Auth::getInstance()->hasPermission($moduleManager::MODULE_PERMISSION_NS . $module->getName())) { + foreach ($module->getNavigationItems() as $type => $options) { + if (! isset($moduleItemTypes[$type])) { + $moduleItemTypes[$type] = $options; + } + } + } + } + + return array_merge($defaultItemTypes, $moduleItemTypes); + } + + /** + * Create and return a new set of navigation items for the given configuration + * + * Note that this is supposed to be utilized for one dimensional structures + * only. Multi dimensional structures can be processed by fromArray(). + * + * @param Traversable|array $config + * + * @return Navigation + * + * @throws InvalidArgumentException In case the given configuration is invalid + * @throws ConfigurationError In case a referenced parent does not exist + */ + public static function fromConfig($config) + { + if (! is_array($config) && !$config instanceof Traversable) { + throw new InvalidArgumentException('Argument $config must be an array or a instance of Traversable'); + } + + $flattened = $orphans = $topLevel = array(); + foreach ($config as $sectionName => $sectionConfig) { + $parentName = $sectionConfig->parent; + unset($sectionConfig->parent); + + if (! $parentName) { + $topLevel[$sectionName] = $sectionConfig->toArray(); + $flattened[$sectionName] = & $topLevel[$sectionName]; + } elseif (isset($flattened[$parentName])) { + $flattened[$parentName]['children'][$sectionName] = $sectionConfig->toArray(); + $flattened[$sectionName] = & $flattened[$parentName]['children'][$sectionName]; + } else { + $orphans[$parentName][$sectionName] = $sectionConfig->toArray(); + $flattened[$sectionName] = & $orphans[$parentName][$sectionName]; + } + } + + do { + $match = false; + foreach ($orphans as $parentName => $children) { + if (isset($flattened[$parentName])) { + if (isset($flattened[$parentName]['children'])) { + $flattened[$parentName]['children'] = array_merge( + $flattened[$parentName]['children'], + $children + ); + } else { + $flattened[$parentName]['children'] = $children; + } + + unset($orphans[$parentName]); + $match = true; + } + } + } while ($match && !empty($orphans)); + + if (! empty($orphans)) { + throw new ConfigurationError( + t( + 'Failed to fully parse navigation configuration. Ensure that' + . ' all referenced parents are existing navigation items: %s' + ), + join(', ', array_keys($orphans)) + ); + } + + return static::fromArray($topLevel); + } + + /** + * Create and return a new set of navigation items for the given array + * + * @param array $array + * + * @return Navigation + */ + public static function fromArray(array $array) + { + $navigation = new static(); + foreach ($array as $name => $properties) { + $navigation->addItem((string) $name, $properties); + } + + return $navigation; + } + + /** + * Return this navigation rendered to HTML + * + * @return string + */ + public function __toString() + { + try { + return $this->render(); + } catch (Exception $e) { + return IcingaException::describe($e); + } + } +} diff --git a/library/Icinga/Web/Navigation/NavigationItem.php b/library/Icinga/Web/Navigation/NavigationItem.php new file mode 100644 index 0000000..8aaf7b8 --- /dev/null +++ b/library/Icinga/Web/Navigation/NavigationItem.php @@ -0,0 +1,948 @@ +<?php +/* Icinga Web 2 | (c) 2015 Icinga Development Team | GPLv2+ */ + +namespace Icinga\Web\Navigation; + +use Exception; +use Icinga\Authentication\Auth; +use InvalidArgumentException; +use IteratorAggregate; +use Icinga\Application\Icinga; +use Icinga\Application\Logger; +use Icinga\Exception\IcingaException; +use Icinga\Exception\ProgrammingError; +use Icinga\Web\Navigation\Renderer\NavigationItemRenderer; +use Icinga\Web\Url; +use Traversable; + +/** + * A navigation item + */ +class NavigationItem implements IteratorAggregate +{ + /** + * Alternative markup element for items without a url + * + * @var string + */ + const LINK_ALTERNATIVE = 'span'; + + /** + * The class namespace where to locate navigation type renderer classes + */ + const RENDERER_NS = 'Web\\Navigation\\Renderer'; + + /** + * Whether this item is active + * + * @var bool + */ + protected $active; + + /** + * Whether this item is selected + * + * @var bool + */ + protected $selected; + + /** + * The CSS class used for the outer li element + * + * @var string + */ + protected $cssClass; + + /** + * This item's priority + * + * The priority defines when the item is rendered in relation to its parent's childs. + * + * @var int + */ + protected $priority; + + /** + * The attributes of this item's element + * + * @var array + */ + protected $attributes; + + /** + * This item's children + * + * @var Navigation + */ + protected $children; + + /** + * This item's icon + * + * @var string + */ + protected $icon; + + /** + * This item's name + * + * @var string + */ + protected $name; + + /** + * This item's label + * + * @var string + */ + protected $label; + + /** + * The item's description + * + * @var string + */ + protected $description; + + /** + * This item's parent + * + * @var NavigationItem + */ + protected $parent; + + /** + * This item's url + * + * @var Url + */ + protected $url; + + /** + * This item's url target + * + * @var string + */ + protected $target; + + /** + * Additional parameters for this item's url + * + * @var array + */ + protected $urlParameters; + + /** + * This item's renderer + * + * @var NavigationItemRenderer + */ + protected $renderer; + + /** + * Whether to render this item + * + * @var bool + */ + protected $render; + + /** + * Create a new NavigationItem + * + * @param string $name + * @param array $properties + */ + public function __construct($name, array $properties = null) + { + $this->setName($name); + $this->children = new Navigation(); + + if (! empty($properties)) { + $this->setProperties($properties); + } + + $this->init(); + } + + /** + * Initialize this NavigationItem + */ + public function init() + { + } + + /** + * @return Navigation + */ + public function getIterator(): Traversable + { + return $this->getChildren(); + } + + /** + * Return whether this item is active + * + * @return bool + */ + public function getActive() + { + if ($this->active === null) { + $this->active = false; + if ($this->getUrl() !== null && Icinga::app()->getRequest()->getUrl()->matches($this->getUrl())) { + $this->setActive(); + } elseif ($this->hasChildren()) { + foreach ($this->getChildren() as $item) { + /** @var NavigationItem $item */ + if ($item->getActive()) { + // Do nothing, a true active state is automatically passed to all parents + } + } + } + } + + return $this->active; + } + + /** + * Set whether this item is active + * + * If it's active and has a parent, the parent gets activated as well. + * + * @param bool $active + * + * @return $this + */ + public function setActive($active = true) + { + $this->active = (bool) $active; + if ($this->active && $this->getParent() !== null) { + $this->getParent()->setActive(); + } + + return $this; + } + + /** + * Return whether this item is selected + * + * @return bool + */ + public function getSelected() + { + if ($this->selected === null) { + $this->active = false; + if ($this->getUrl() !== null && Icinga::app()->getRequest()->getUrl()->matches($this->getUrl())) { + $this->setSelected(); + } + } + + return $this->selected; + } + + /** + * Set whether this item is active + * + * If it's active and has a parent, the parent gets activated as well. + * + * @param bool $selected + * + * @return $this + */ + public function setSelected($selected = true) + { + $this->selected = (bool) $selected; + + return $this; + } + + /** + * Get the CSS class used for the outer li element + * + * @return string + */ + public function getCssClass() + { + return $this->cssClass; + } + + /** + * Set the CSS class to use for the outer li element + * + * @param string $class + * + * @return $this + */ + public function setCssClass($class) + { + $this->cssClass = (string) $class; + return $this; + } + + /** + * Return this item's priority + * + * @return int + */ + public function getPriority() + { + return $this->priority !== null ? $this->priority : 100; + } + + /** + * Set this item's priority + * + * @param int $priority + * + * @return $this + */ + public function setPriority($priority) + { + $this->priority = (int) $priority; + return $this; + } + + /** + * Return the value of the given element attribute + * + * @param string $name + * @param mixed $default + * + * @return mixed + */ + public function getAttribute($name, $default = null) + { + $attributes = $this->getAttributes(); + return array_key_exists($name, $attributes) ? $attributes[$name] : $default; + } + + /** + * Set the value of the given element attribute + * + * @param string $name + * @param mixed $value + * + * @return $this + */ + public function setAttribute($name, $value) + { + $this->attributes[$name] = $value; + return $this; + } + + /** + * Return the attributes of this item's element + * + * @return array + */ + public function getAttributes() + { + return $this->attributes ?: array(); + } + + /** + * Set the attributes of this item's element + * + * @param array $attributes + * + * @return $this + */ + public function setAttributes(array $attributes) + { + $this->attributes = $attributes; + return $this; + } + + /** + * Add a child to this item + * + * If the child is active this item gets activated as well. + * + * @param NavigationItem $child + * + * @return $this + */ + public function addChild(NavigationItem $child) + { + $this->getChildren()->addItem($child->setParent($this)); + if ($child->getActive()) { + $this->setActive(); + } + + return $this; + } + + /** + * Return this item's children + * + * @return Navigation + */ + public function getChildren() + { + return $this->children; + } + + /** + * Return whether this item has any children + * + * @return bool + */ + public function hasChildren() + { + return ! $this->getChildren()->isEmpty(); + } + + /** + * Set this item's children + * + * @param array|Navigation $children + * + * @return $this + */ + public function setChildren($children) + { + if (is_array($children)) { + $children = Navigation::fromArray($children); + } elseif (! $children instanceof Navigation) { + throw new InvalidArgumentException('Argument $children must be of type array or Navigation'); + } + + foreach ($children as $item) { + $item->setParent($this); + } + + $this->children = $children; + return $this; + } + + /** + * Return this item's icon + * + * @return string + */ + public function getIcon() + { + return $this->icon; + } + + /** + * Set this item's icon + * + * @param string $icon + * + * @return $this + */ + public function setIcon($icon) + { + $this->icon = $icon; + return $this; + } + + /** + * Return this item's name escaped with only ASCII chars and/or digits + * + * @return string + */ + protected function getEscapedName() + { + return preg_replace('~[^a-zA-Z0-9]~', '_', $this->getName()); + } + + /** + * Return a unique version of this item's name + * + * @return string + */ + public function getUniqueName() + { + if ($this->getParent() === null) { + return 'navigation-' . $this->getEscapedName(); + } + + return $this->getParent()->getUniqueName() . '-' . $this->getEscapedName(); + } + + /** + * Return this item's name + * + * @return string + */ + public function getName() + { + return $this->name; + } + + /** + * Set this item's name + * + * @param string $name + * + * @return $this + */ + public function setName($name) + { + $this->name = $name; + return $this; + } + + /** + * Set this item's parent + * + * @param NavigationItem $parent + * + * @return $this + */ + public function setParent(NavigationItem $parent) + { + $this->parent = $parent; + return $this; + } + + /** + * Return this item's parent + * + * @return NavigationItem + */ + public function getParent() + { + return $this->parent; + } + + /** + * Return this item's label + * + * @return string + */ + public function getLabel() + { + return $this->label !== null ? $this->label : $this->getName(); + } + + /** + * Set this item's label + * + * @param string $label + * + * @return $this + */ + public function setLabel($label) + { + $this->label = $label; + return $this; + } + + /** + * Get the item's description + * + * @return string + */ + public function getDescription() + { + return $this->description; + } + + /** + * Set the item's description + * + * @param string $description + * + * @return $this + */ + public function setDescription($description) + { + $this->description = $description; + + return $this; + } + + /** + * Set this item's url target + * + * @param string $target + * + * @return $this + */ + public function setTarget($target) + { + $this->target = $target; + return $this; + } + + /** + * Return this item's url target + * + * @return string + */ + public function getTarget() + { + return $this->target; + } + + /** + * Return this item's url + * + * @return Url + */ + public function getUrl() + { + if ($this->url === null && $this->hasChildren()) { + $this->setUrl(Url::fromPath('navigation/dashboard', array('name' => strtolower($this->getName())))); + } + + return $this->url; + } + + /** + * Set this item's url + * + * @param Url|string $url + * + * @return $this + * + * @throws InvalidArgumentException If the given url is neither of type + */ + public function setUrl($url) + { + if (is_string($url)) { + $url = Url::fromPath($this->resolveMacros($url)); + } elseif ($url instanceof Url) { + $url = Url::fromPath($this->resolveMacros($url->getAbsoluteUrl())); + } else { + throw new InvalidArgumentException('Argument $url must be of type string or Url'); + } + + $this->url = $url; + + return $this; + } + + /** + * Return the value of the given url parameter + * + * @param string $name + * @param mixed $default + * + * @return mixed + */ + public function getUrlParameter($name, $default = null) + { + $parameters = $this->getUrlParameters(); + return isset($parameters[$name]) ? $parameters[$name] : $default; + } + + /** + * Set the value of the given url parameter + * + * @param string $name + * @param mixed $value + * + * @return $this + */ + public function setUrlParameter($name, $value) + { + $this->urlParameters[$name] = $value; + return $this; + } + + /** + * Return all additional parameters for this item's url + * + * @return array + */ + public function getUrlParameters() + { + return $this->urlParameters ?: array(); + } + + /** + * Set additional parameters for this item's url + * + * @param array $urlParameters + * + * @return $this + */ + public function setUrlParameters(array $urlParameters) + { + $this->urlParameters = $urlParameters; + return $this; + } + + /** + * Set this item's properties + * + * Unknown properties (no matching setter) are considered as element attributes. + * + * @param array $properties + * + * @return $this + */ + public function setProperties(array $properties) + { + foreach ($properties as $name => $value) { + $setter = 'set' . ucfirst($name); + if (method_exists($this, $setter)) { + $this->$setter($value); + } else { + $this->setAttribute($name, $value); + } + } + + return $this; + } + + /** + * Merge this item with the given one + * + * @param NavigationItem $item + * + * @return $this + */ + public function merge(NavigationItem $item) + { + if ($this->conflictsWith($item)) { + throw new ProgrammingError('Cannot merge, conflict detected.'); + } + + if ($this->priority === null) { + $priority = $item->getPriority(); + if ($priority !== 100) { + $this->setPriority($priority); + } + } + + if (! $this->getIcon()) { + $this->setIcon($item->getIcon()); + } + + if ($this->getLabel() === $this->getName() && $item->getLabel() !== $item->getName()) { + $this->setLabel($item->getLabel()); + } + + if ($this->target === null && ($target = $item->getTarget()) !== null) { + $this->setTarget($target); + } + + if ($this->renderer === null) { + $renderer = $item->getRenderer(); + if (get_class($renderer) !== 'NavigationItemRenderer') { + $this->setRenderer($renderer); + } + } + + foreach ($item->getAttributes() as $name => $value) { + $this->setAttribute($name, $value); + } + + foreach ($item->getUrlParameters() as $name => $value) { + $this->setUrlParameter($name, $value); + } + + if ($item->hasChildren()) { + $this->getChildren()->merge($item->getChildren()); + } + + return $this; + } + + /** + * Return whether it's possible to merge this item with the given one + * + * @param NavigationItem $item + * + * @return bool + */ + public function conflictsWith(NavigationItem $item) + { + if (! $item instanceof $this) { + return true; + } + + if ($this->getUrl() === null || $item->getUrl() === null) { + return false; + } + + return !$this->getUrl()->matches($item->getUrl()); + } + + /** + * Create and return the given renderer + * + * @param string|array $name + * + * @return NavigationItemRenderer + */ + protected function createRenderer($name) + { + if (is_array($name)) { + $options = array_splice($name, 1); + $name = $name[0]; + } else { + $options = array(); + } + + $renderer = null; + $classPath = null; + foreach (Icinga::app()->getModuleManager()->getLoadedModules() as $module) { + $classPath = 'Icinga\\Module\\' . ucfirst($module->getName()) . '\\' . static::RENDERER_NS . '\\' . $name; + if (class_exists($classPath)) { + $renderer = new $classPath($options); + break; + } + } + + if ($renderer === null) { + $classPath = 'Icinga\\' . static::RENDERER_NS . '\\' . $name; + if (class_exists($classPath)) { + $renderer = new $classPath($options); + } + } + + if ($renderer === null) { + throw new ProgrammingError( + 'Cannot find renderer "%s" for navigation item "%s"', + $name, + $this->getName() + ); + } elseif (! $renderer instanceof NavigationItemRenderer) { + throw new ProgrammingError('Class %s must inherit from NavigationItemRenderer', $classPath); + } + + return $renderer; + } + + /** + * Set this item's renderer + * + * @param string|array|NavigationItemRenderer $renderer + * + * @return $this + * + * @throws InvalidArgumentException If the $renderer argument is neither a string nor a NavigationItemRenderer + */ + public function setRenderer($renderer) + { + if (is_string($renderer) || is_array($renderer)) { + $renderer = $this->createRenderer($renderer); + } elseif (! $renderer instanceof NavigationItemRenderer) { + throw new InvalidArgumentException( + 'Argument $renderer must be of type string, array or NavigationItemRenderer' + ); + } + + $this->renderer = $renderer; + return $this; + } + + /** + * Return this item's renderer + * + * @return NavigationItemRenderer + */ + public function getRenderer() + { + if ($this->renderer === null) { + $this->setRenderer('NavigationItemRenderer'); + } + + return $this->renderer; + } + + /** + * Set whether this item should be rendered + * + * @param bool $state + * + * @return $this + */ + public function setRender($state = true) + { + $this->render = (bool) $state; + return $this; + } + + /** + * Return whether this item should be rendered + * + * @return bool + */ + public function getRender() + { + if ($this->render === null) { + return $this->getUrl() !== null; + } + + return $this->render; + } + + /** + * Return whether this item should be rendered + * + * Alias for NavigationItem::getRender(). + * + * @return bool + */ + public function shouldRender() + { + return $this->getRender(); + } + + /** + * Return this item rendered to HTML + * + * @return string + */ + public function render() + { + try { + return $this->getRenderer()->setItem($this)->render(); + } catch (Exception $e) { + Logger::error( + 'Could not invoke custom navigation item renderer. %s in %s:%d with message: %s', + get_class($e), + $e->getFile(), + $e->getLine(), + $e->getMessage() + ); + + $renderer = new NavigationItemRenderer(); + return $renderer->render($this); + } + } + + /** + * Return this item rendered to HTML + * + * @return string + */ + public function __toString() + { + try { + return $this->render(); + } catch (Exception $e) { + return IcingaException::describe($e); + } + } + + /** + * Resolve all macros in the given URL + * + * @param string $url + * + * @return string + */ + protected function resolveMacros($url) + { + if (strpos($url, '$') === false) { + return $url; + } + + $macros = []; + if (Auth::getInstance()->isAuthenticated()) { + $macros['$user.local_name$'] = Auth::getInstance()->getUser()->getLocalUsername(); + } + if (! empty($macros)) { + $url = str_replace(array_keys($macros), array_values($macros), $url); + } + + return $url; + } +} diff --git a/library/Icinga/Web/Navigation/Renderer/BadgeNavigationItemRenderer.php b/library/Icinga/Web/Navigation/Renderer/BadgeNavigationItemRenderer.php new file mode 100644 index 0000000..8510f70 --- /dev/null +++ b/library/Icinga/Web/Navigation/Renderer/BadgeNavigationItemRenderer.php @@ -0,0 +1,139 @@ +<?php +/* Icinga Web 2 | (c) 2015 Icinga Development Team | GPLv2+ */ + +namespace Icinga\Web\Navigation\Renderer; + +use Icinga\Web\Navigation\NavigationItem; + +/** + * Abstract base class for a NavigationItem with a status badge + */ +abstract class BadgeNavigationItemRenderer extends NavigationItemRenderer +{ + const STATE_OK = 'ok'; + const STATE_CRITICAL = 'critical'; + const STATE_WARNING = 'warning'; + const STATE_PENDING = 'pending'; + const STATE_UNKNOWN = 'unknown'; + + /** + * The tooltip text for the badge + * + * @var string + */ + protected $title; + + /** + * The state identifier being used + * + * The state identifier defines the background color of the badge. + * + * @var string + */ + protected $state; + + /** + * Set the tooltip text for the badge + * + * @param string $title + * + * @return $this + */ + public function setTitle($title) + { + $this->title = $title; + return $this; + } + + /** + * Return the tooltip text for the badge + * + * @return string + */ + public function getTitle() + { + return $this->title; + } + + /** + * Set the state identifier to use + * + * @param string $state + * + * @return $this + */ + public function setState($state) + { + $this->state = $state; + return $this; + } + + /** + * Return the state identifier to use + * + * @return string + */ + public function getState() + { + return $this->state; + } + + /** + * Return the amount of items represented by the badge + * + * @return int + */ + abstract public function getCount(); + + /** + * Render the given navigation item as HTML anchor with a badge + * + * @param NavigationItem $item + * + * @return string + */ + public function render(NavigationItem $item = null) + { + if ($item === null) { + $item = $this->getItem(); + } + + $cssClass = ''; + if ($item->getCssClass() !== null) { + $cssClass = ' ' . $item->getCssClass(); + } + + $item->setCssClass('badge-nav-item' . $cssClass); + $this->setEscapeLabel(false); + $label = $this->view()->escape($item->getLabel()); + $item->setLabel($this->renderBadge() . $label); + $html = parent::render($item); + return $html; + } + + /** + * Render the badge + * + * @return string + */ + protected function renderBadge() + { + if ($count = $this->getCount()) { + if ($count > 1000000) { + $count = round($count, -6) / 1000000 . 'M'; + } elseif ($count > 1000) { + $count = round($count, -3) / 1000 . 'k'; + } + + $view = $this->view(); + return sprintf( + '<span title="%s" class="badge state-%s">%s</span>', + $view->escape($this->getTitle()), + $view->escape($this->getState()), + $count + ); + } + + return ''; + } +} diff --git a/library/Icinga/Web/Navigation/Renderer/HealthNavigationRenderer.php b/library/Icinga/Web/Navigation/Renderer/HealthNavigationRenderer.php new file mode 100644 index 0000000..577895b --- /dev/null +++ b/library/Icinga/Web/Navigation/Renderer/HealthNavigationRenderer.php @@ -0,0 +1,44 @@ +<?php +/* Icinga Web 2 | (c) 2021 Icinga GmbH | GPLv2+ */ + +namespace Icinga\Web\Navigation\Renderer; + +use Icinga\Application\Hook\HealthHook; + +class HealthNavigationRenderer extends BadgeNavigationItemRenderer +{ + public function getCount() + { + $count = 0; + $title = null; + $worstState = null; + foreach (HealthHook::collectHealthData()->select() as $result) { + if ($worstState === null || $result->state > $worstState) { + $worstState = $result->state; + $title = $result->message; + $count = 1; + } elseif ($worstState === $result->state) { + $count++; + } + } + + switch ($worstState) { + case HealthHook::STATE_OK: + $count = 0; + break; + case HealthHook::STATE_WARNING: + $this->state = self::STATE_WARNING; + break; + case HealthHook::STATE_CRITICAL: + $this->state = self::STATE_CRITICAL; + break; + case HealthHook::STATE_UNKNOWN: + $this->state = self::STATE_UNKNOWN; + break; + } + + $this->title = $title; + + return $count; + } +} diff --git a/library/Icinga/Web/Navigation/Renderer/NavigationItemRenderer.php b/library/Icinga/Web/Navigation/Renderer/NavigationItemRenderer.php new file mode 100644 index 0000000..51136ff --- /dev/null +++ b/library/Icinga/Web/Navigation/Renderer/NavigationItemRenderer.php @@ -0,0 +1,235 @@ +<?php +/* Icinga Web 2 | (c) 2015 Icinga Development Team | GPLv2+ */ + +namespace Icinga\Web\Navigation\Renderer; + +use Icinga\Application\Icinga; +use Icinga\Exception\ProgrammingError; +use Icinga\Util\StringHelper; +use Icinga\Web\Navigation\NavigationItem; +use Icinga\Web\Url; +use Icinga\Web\View; + +/** + * NavigationItemRenderer + */ +class NavigationItemRenderer +{ + /** + * View + * + * @var View + */ + protected $view; + + /** + * The item being rendered + * + * @var NavigationItem + */ + protected $item; + + /** + * Internal link targets provided by Icinga Web 2 + * + * @var array + */ + protected $internalLinkTargets; + + /** + * Whether to escape the label + * + * @var bool + */ + protected $escapeLabel; + + /** + * Create a new NavigationItemRenderer + * + * @param array $options + */ + public function __construct(array $options = null) + { + if (! empty($options)) { + $this->setOptions($options); + } + + $this->internalLinkTargets = array('_main', '_self', '_next'); + $this->init(); + } + + /** + * Initialize this renderer + */ + public function init() + { + } + + /** + * Set the given options + * + * @param array $options + * + * @return $this + */ + public function setOptions(array $options) + { + foreach ($options as $name => $value) { + $setter = 'set' . StringHelper::cname($name); + if (method_exists($this, $setter)) { + $this->$setter($value); + } + } + + return $this; + } + + /** + * Set the view + * + * @param View $view + * + * @return $this + */ + public function setView(View $view) + { + $this->view = $view; + return $this; + } + + /** + * Return the view + * + * @return View + */ + public function view() + { + if ($this->view === null) { + $this->setView(Icinga::app()->getViewRenderer()->view); + } + + return $this->view; + } + + /** + * Set the navigation item to render + * + * @param NavigationItem $item + * + * @return $this + */ + public function setItem(NavigationItem $item) + { + $this->item = $item; + return $this; + } + + /** + * Return the navigation item being rendered + * + * @return NavigationItem + */ + public function getItem() + { + return $this->item; + } + + /** + * Set whether to escape the label + * + * @param bool $state + * + * @return $this + */ + public function setEscapeLabel($state = true) + { + $this->escapeLabel = (bool) $state; + return $this; + } + + /** + * Return whether to escape the label + * + * @return bool + */ + public function getEscapeLabel() + { + return $this->escapeLabel !== null ? $this->escapeLabel : true; + } + + /** + * Render the given navigation item as HTML anchor + * + * @param NavigationItem $item + * + * @return string + */ + public function render(NavigationItem $item = null) + { + if ($item !== null) { + $this->setItem($item); + } elseif (($item = $this->getItem()) === null) { + throw new ProgrammingError( + 'Cannot render nothing. Pass the item to render as part' + . ' of the call to render() or set it with setItem()' + ); + } + + $label = $this->getEscapeLabel() + ? $this->view()->escape($item->getLabel()) + : $item->getLabel(); + if (($icon = $item->getIcon()) !== null) { + $label = $this->view()->icon($icon) . $label; + } elseif ($item->getName()) { + $firstLetter = $item->getName()[0]; + $label = $this->view()->icon('letter', null, ['data-letter' => strtolower($firstLetter)]) . $label; + } + + if (($url = $item->getUrl()) !== null) { + $url->overwriteParams($item->getUrlParameters()); + + $target = $item->getTarget(); + if ($url->isExternal() && (!$target || in_array($target, $this->internalLinkTargets, true))) { + $url = Url::fromPath('iframe', array('url' => $url)); + } + + $content = sprintf( + '<a%s href="%s"%s>%s</a>', + $this->view()->propertiesToString($item->getAttributes()), + $this->view()->escape($url->getAbsoluteUrl('&')), + $this->renderTargetAttribute(), + $label + ); + } elseif ($label) { + $content = sprintf( + '<%1$s%2$s>%3$s</%1$s>', + $item::LINK_ALTERNATIVE, + $this->view()->propertiesToString($item->getAttributes()), + $label + ); + } else { + $content = ''; + } + + return $content; + } + + /** + * Render and return the attribute to provide a non-default target for the url + * + * @return string + */ + protected function renderTargetAttribute() + { + $target = $this->getItem()->getTarget(); + if ($target === null || $this->getItem()->getUrl()->getAbsoluteUrl() == '#') { + return ''; + } + + if (! in_array($target, $this->internalLinkTargets, true)) { + return ' target="' . $this->view()->escape($target) . '"'; + } + + return ' data-base-target="' . $target . '"'; + } +} diff --git a/library/Icinga/Web/Navigation/Renderer/NavigationRenderer.php b/library/Icinga/Web/Navigation/Renderer/NavigationRenderer.php new file mode 100644 index 0000000..00c0f9a --- /dev/null +++ b/library/Icinga/Web/Navigation/Renderer/NavigationRenderer.php @@ -0,0 +1,356 @@ +<?php +/* Icinga Web 2 | (c) 2015 Icinga Development Team | GPLv2+ */ + +namespace Icinga\Web\Navigation\Renderer; + +use ArrayIterator; +use Exception; +use RecursiveIterator; +use Icinga\Application\Icinga; +use Icinga\Exception\IcingaException; +use Icinga\Web\Navigation\Navigation; +use Icinga\Web\Navigation\NavigationItem; +use Icinga\Web\View; + +/** + * Renderer for single level navigation + */ +class NavigationRenderer implements RecursiveIterator, NavigationRendererInterface +{ + /** + * The tag used for the outer element + * + * @var string + */ + protected $elementTag; + + /** + * The CSS class used for the outer element + * + * @var string + */ + protected $cssClass; + + /** + * The navigation's heading text + * + * @var string + */ + protected $heading; + + /** + * The content rendered so far + * + * @var array + */ + protected $content; + + /** + * Whether to skip rendering the outer element + * + * @var bool + */ + protected $skipOuterElement; + + /** + * The navigation's iterator + * + * @var ArrayIterator + */ + protected $iterator; + + /** + * The navigation + * + * @var Navigation + */ + protected $navigation; + + /** + * View + * + * @var View + */ + protected $view; + + /** + * Create a new NavigationRenderer + * + * @param Navigation $navigation + * @param bool $skipOuterElement + */ + public function __construct(Navigation $navigation, $skipOuterElement = false) + { + $this->skipOuterElement = $skipOuterElement; + $this->iterator = $navigation->getIterator(); + $this->navigation = $navigation; + $this->content = array(); + } + + /** + * {@inheritdoc} + */ + public function setElementTag($tag) + { + $this->elementTag = $tag; + return $this; + } + + /** + * {@inheritdoc} + */ + public function getElementTag() + { + return $this->elementTag ?: static::OUTER_ELEMENT_TAG; + } + + /** + * {@inheritdoc} + */ + public function setCssClass($class) + { + $this->cssClass = $class; + return $this; + } + + /** + * {@inheritdoc} + */ + public function getCssClass() + { + return $this->cssClass; + } + + /** + * {@inheritdoc} + */ + public function setHeading($heading) + { + $this->heading = $heading; + return $this; + } + + /** + * {@inheritdoc} + */ + public function getHeading() + { + return $this->heading; + } + + /** + * Return the view + * + * @return View + */ + public function view() + { + if ($this->view === null) { + $this->setView(Icinga::app()->getViewRenderer()->view); + } + + return $this->view; + } + + /** + * Set the view + * + * @param View $view + * + * @return $this + */ + public function setView(View $view) + { + $this->view = $view; + return $this; + } + + public function getChildren(): NavigationRenderer + { + return new static($this->current()->getChildren(), $this->skipOuterElement); + } + + public function hasChildren(): bool + { + return $this->current()->hasChildren(); + } + + public function current(): NavigationItem + { + return $this->iterator->current(); + } + + public function key(): int + { + return $this->iterator->key(); + } + + public function next(): void + { + $this->iterator->next(); + } + + public function rewind(): void + { + $this->iterator->rewind(); + if (! $this->skipOuterElement) { + $this->content[] = $this->beginMarkup(); + } + } + + public function valid(): bool + { + $valid = $this->iterator->valid(); + if (! $this->skipOuterElement && !$valid) { + $this->content[] = $this->endMarkup(); + } + + return $valid; + } + + /** + * Return the opening markup for the navigation + * + * @return string + */ + public function beginMarkup() + { + $content = array(); + $content[] = sprintf( + '<%s%s role="navigation">', + $this->getElementTag(), + $this->getCssClass() !== null ? ' class="' . $this->getCssClass() . '"' : '' + ); + if (($heading = $this->getHeading()) !== null) { + $content[] = sprintf( + '<h%1$d id="navigation" class="sr-only" tabindex="-1">%2$s</h%1$d>', + static::HEADING_RANK, + $this->view()->escape($heading) + ); + } + $content[] = $this->beginChildrenMarkup(); + return join("\n", $content); + } + + /** + * Return the closing markup for the navigation + * + * @return string + */ + public function endMarkup() + { + $content = array(); + $content[] = $this->endChildrenMarkup(); + $content[] = '</' . $this->getElementTag() . '>'; + return join("\n", $content); + } + + /** + * Return the opening markup for multiple navigation items + * + * @param int $level + * + * @return string + */ + public function beginChildrenMarkup($level = 1) + { + $cssClass = array(static::CSS_CLASS_NAV); + if ($this->navigation->getLayout() === Navigation::LAYOUT_TABS) { + $cssClass[] = static::CSS_CLASS_NAV_TABS; + } elseif ($this->navigation->getLayout() === Navigation::LAYOUT_DROPDOWN) { + $cssClass[] = static::CSS_CLASS_NAV_DROPDOWN; + } + + $cssClass[] = 'nav-level-' . $level; + + return '<ul class="' . join(' ', $cssClass) . '">'; + } + + /** + * Return the closing markup for multiple navigation items + * + * @return string + */ + public function endChildrenMarkup() + { + return '</ul>'; + } + + /** + * Return the opening markup for the given navigation item + * + * @param NavigationItem $item + * + * @return string + */ + public function beginItemMarkup(NavigationItem $item) + { + $cssClasses = array(static::CSS_CLASS_ITEM); + + if ($item->hasChildren() && $item->getChildren()->getLayout() === Navigation::LAYOUT_DROPDOWN) { + $cssClasses[] = static::CSS_CLASS_DROPDOWN; + $item + ->setAttribute('class', static::CSS_CLASS_DROPDOWN_TOGGLE) + ->setIcon(static::DROPDOWN_TOGGLE_ICON) + ->setUrl('#'); + } + + if ($item->getActive()) { + $cssClasses[] = static::CSS_CLASS_ACTIVE; + } + + if ($item->getSelected()) { + $cssClasses[] = static::CSS_CLASS_SELECTED; + } + + if ($cssClass = $item->getCssClass()) { + $cssClasses[] = $cssClass; + } + + $content = sprintf( + '<li class="%s">', + join(' ', $cssClasses) + ); + return $content; + } + + /** + * Return the closing markup for a navigation item + * + * @return string + */ + public function endItemMarkup() + { + return '</li>'; + } + + /** + * {@inheritdoc} + */ + public function render() + { + foreach ($this as $item) { + /** @var NavigationItem $item */ + if ($item->shouldRender()) { + $content = $item->render(); + $this->content[] = $this->beginItemMarkup($item); + $this->content[] = $content; + $this->content[] = $this->endItemMarkup(); + } + } + + return join("\n", $this->content); + } + + /** + * {@inheritdoc} + */ + public function __toString() + { + try { + return $this->render(); + } catch (Exception $e) { + return IcingaException::describe($e); + } + } +} diff --git a/library/Icinga/Web/Navigation/Renderer/NavigationRendererInterface.php b/library/Icinga/Web/Navigation/Renderer/NavigationRendererInterface.php new file mode 100644 index 0000000..4495b73 --- /dev/null +++ b/library/Icinga/Web/Navigation/Renderer/NavigationRendererInterface.php @@ -0,0 +1,142 @@ +<?php +/* Icinga Web 2 | (c) 2015 Icinga Development Team | GPLv2+ */ + +namespace Icinga\Web\Navigation\Renderer; + +/** + * Interface for navigation renderers + */ +interface NavigationRendererInterface +{ + /** + * CSS class for items + * + * @var string + */ + const CSS_CLASS_ITEM = 'nav-item'; + + /** + * CSS class for active items + * + * @var string + */ + const CSS_CLASS_ACTIVE = 'active'; + + /** + * CSS class for selected items + * + * @var string + */ + const CSS_CLASS_SELECTED = 'selected'; + + /** + * CSS class for dropdown items + * + * @var string + */ + const CSS_CLASS_DROPDOWN = 'dropdown-nav-item'; + + /** + * CSS class for a dropdown item's trigger + * + * @var string + */ + const CSS_CLASS_DROPDOWN_TOGGLE = 'dropdown-toggle'; + + /** + * CSS class for the ul element + * + * @var string + */ + const CSS_CLASS_NAV = 'nav'; + + /** + * CSS class for the ul element with dropdown layout + * + * @var string + */ + const CSS_CLASS_NAV_DROPDOWN = 'dropdown-nav'; + + /** + * CSS class for the ul element with tabs layout + * + * @var string + */ + const CSS_CLASS_NAV_TABS = 'tab-nav'; + + /** + * Icon for a dropdown item's trigger + * + * @var string + */ + const DROPDOWN_TOGGLE_ICON = 'menu'; + + /** + * Default tag for the outer element the navigation will be wrapped with + * + * @var string + */ + const OUTER_ELEMENT_TAG = 'div'; + + /** + * The heading's rank + * + * @var int + */ + const HEADING_RANK = 1; + + /** + * Set the tag for the outer element the navigation is wrapped with + * + * @param string $tag + * + * @return $this + */ + public function setElementTag($tag); + + /** + * Return the tag for the outer element the navigation is wrapped with + * + * @return string + */ + public function getElementTag(); + + /** + * Set the CSS class to use for the outer element + * + * @param string $class + * + * @return $this + */ + public function setCssClass($class); + + /** + * Get the CSS class used for the outer element + * + * @return string + */ + public function getCssClass(); + + /** + * Set the navigation's heading text + * + * @param string $heading + * + * @return $this + */ + public function setHeading($heading); + + /** + * Return the navigation's heading text + * + * @return string + */ + public function getHeading(); + + /** + * Return the navigation rendered to HTML + * + * @return string + */ + public function render(); +} diff --git a/library/Icinga/Web/Navigation/Renderer/RecursiveNavigationRenderer.php b/library/Icinga/Web/Navigation/Renderer/RecursiveNavigationRenderer.php new file mode 100644 index 0000000..315c2aa --- /dev/null +++ b/library/Icinga/Web/Navigation/Renderer/RecursiveNavigationRenderer.php @@ -0,0 +1,186 @@ +<?php +/* Icinga Web 2 | (c) 2015 Icinga Development Team | GPLv2+ */ + +namespace Icinga\Web\Navigation\Renderer; + +use Exception; +use RecursiveIteratorIterator; +use Icinga\Exception\IcingaException; +use Icinga\Web\Navigation\Navigation; +use Icinga\Web\Navigation\NavigationItem; +use Icinga\Web\Navigation\Renderer\NavigationItemRenderer; + +/** + * Renderer for multi level navigation + * + * @method NavigationRenderer getInnerIterator() { + * {@inheritdoc} + * } + */ +class RecursiveNavigationRenderer extends RecursiveIteratorIterator implements NavigationRendererInterface +{ + /** + * The content rendered so far + * + * @var array + */ + protected $content; + + /** + * Whether to use the standard item renderer + * + * @var bool + */ + protected $useStandardRenderer; + + /** + * Create a new RecursiveNavigationRenderer + * + * @param Navigation $navigation + */ + public function __construct(Navigation $navigation) + { + $this->content = array(); + parent::__construct( + new NavigationRenderer($navigation, true), + RecursiveIteratorIterator::SELF_FIRST + ); + } + + /** + * Set whether to use the standard navigation item renderer + * + * @param bool $state + * + * @return $this + */ + public function setUseStandardItemRenderer($state = true) + { + $this->useStandardRenderer = (bool) $state; + return $this; + } + + /** + * Return whether to use the standard navigation item renderer + * + * @return bool + */ + public function getUseStandardItemRenderer() + { + return $this->useStandardRenderer; + } + + /** + * {@inheritdoc} + */ + public function setElementTag($tag) + { + $this->getInnerIterator()->setElementTag($tag); + return $this; + } + + /** + * {@inheritdoc} + */ + public function getElementTag() + { + return $this->getInnerIterator()->getElementTag(); + } + + /** + * {@inheritdoc} + */ + public function setCssClass($class) + { + $this->getInnerIterator()->setCssClass($class); + return $this; + } + + /** + * {@inheritdoc} + */ + public function getCssClass() + { + return $this->getInnerIterator()->getCssClass(); + } + + /** + * {@inheritdoc} + */ + public function setHeading($heading) + { + $this->getInnerIterator()->setHeading($heading); + return $this; + } + + /** + * {@inheritdoc} + */ + public function getHeading() + { + return $this->getInnerIterator()->getHeading(); + } + + public function beginIteration(): void + { + $this->content[] = $this->getInnerIterator()->beginMarkup(); + } + + public function endIteration(): void + { + $this->content[] = $this->getInnerIterator()->endMarkup(); + } + + public function beginChildren(): void + { + $this->content[] = $this->getInnerIterator()->beginChildrenMarkup($this->getDepth() + 1); + } + + public function endChildren(): void + { + $this->content[] = $this->getInnerIterator()->endChildrenMarkup(); + $this->content[] = $this->getInnerIterator()->endItemMarkup(); + } + + /** + * {@inheritdoc} + */ + public function render() + { + foreach ($this as $item) { + /** @var NavigationItem $item */ + if ($item->shouldRender()) { + if ($this->getDepth() > 0) { + $item->setIcon(null); + } + if ($this->getUseStandardItemRenderer()) { + $renderer = new NavigationItemRenderer(); + $content = $renderer->render($item); + } else { + $content = $item->render(); + } + $this->content[] = $this->getInnerIterator()->beginItemMarkup($item); + + $this->content[] = $content; + + if (! $item->hasChildren()) { + $this->content[] = $this->getInnerIterator()->endItemMarkup(); + } + } + } + + return join("\n", $this->content); + } + + /** + * {@inheritdoc} + */ + public function __toString() + { + try { + return $this->render(); + } catch (Exception $e) { + return IcingaException::describe($e); + } + } +} diff --git a/library/Icinga/Web/Navigation/Renderer/SummaryNavigationItemRenderer.php b/library/Icinga/Web/Navigation/Renderer/SummaryNavigationItemRenderer.php new file mode 100644 index 0000000..2916f4e --- /dev/null +++ b/library/Icinga/Web/Navigation/Renderer/SummaryNavigationItemRenderer.php @@ -0,0 +1,72 @@ +<?php +/* Icinga Web 2 | (c) 2015 Icinga Development Team | GPLv2+ */ + +namespace Icinga\Web\Navigation\Renderer; + +/** + * Badge renderer summing up the worst state of its children + */ +class SummaryNavigationItemRenderer extends BadgeNavigationItemRenderer +{ + /** + * Cached count + * + * @var int + */ + protected $count; + + /** + * State to severity map + * + * @var array + */ + protected static $stateSeverityMap = array( + self::STATE_OK => 0, + self::STATE_PENDING => 1, + self::STATE_UNKNOWN => 2, + self::STATE_WARNING => 3, + self::STATE_CRITICAL => 4, + ); + + /** + * Severity to state map + * + * @var array + */ + protected static $severityStateMap = array( + self::STATE_OK, + self::STATE_PENDING, + self::STATE_UNKNOWN, + self::STATE_WARNING, + self::STATE_CRITICAL + ); + + /** + * {@inheritdoc} + */ + public function getCount() + { + if ($this->count === null) { + $countMap = array_fill(0, 5, 0); + $maxSeverity = 0; + $titles = array(); + foreach ($this->getItem()->getChildren() as $child) { + $renderer = $child->getRenderer(); + if ($renderer instanceof BadgeNavigationItemRenderer) { + $count = $renderer->getCount(); + if ($count) { + $severity = static::$stateSeverityMap[$renderer->getState()]; + $countMap[$severity] += $count; + $titles[] = $renderer->getTitle(); + $maxSeverity = max($maxSeverity, $severity); + } + } + } + $this->count = $countMap[$maxSeverity]; + $this->state = static::$severityStateMap[$maxSeverity]; + $this->title = implode('. ', $titles); + } + + return $this->count; + } +} diff --git a/library/Icinga/Web/Notification.php b/library/Icinga/Web/Notification.php new file mode 100644 index 0000000..6f33a32 --- /dev/null +++ b/library/Icinga/Web/Notification.php @@ -0,0 +1,220 @@ +<?php +/* Icinga Web 2 | (c) 2013 Icinga Development Team | GPLv2+ */ + +namespace Icinga\Web; + +use Icinga\Exception\ProgrammingError; +use Icinga\Application\Platform; +use Icinga\Application\Logger; +use Icinga\Web\Session; + +/** + * // @TODO(eL): Use Notification not as Singleton but within request: + * <code> + * <?php + * $request->[getUser()]->notify('some message', Notification::INFO); + * </code> + */ +class Notification +{ + /** + * Notification type info + * + * @var string + */ + const INFO = 'info'; + + /** + * Notification type error + * + * @var string + */ + const ERROR = 'error'; + + /** + * Notification type success + * + * @var string + */ + const SUCCESS = 'success'; + + /** + * Notification type warning + * + * @var string + */ + const WARNING = 'warning'; + + /** + * Name of the session key for notification messages + * + * @var string + */ + const SESSION_KEY = 'session'; + + /** + * Singleton instance + * + * @var self + */ + protected static $instance; + + /** + * Whether the platform is CLI + * + * @var bool + */ + protected $isCli = false; + + /** + * Notification messages + * + * @var array + */ + protected $messages = array(); + + /** + * Session + * + * @var Session + */ + protected $session; + + /** + * Create the notification instance + */ + final private function __construct() + { + if (Platform::isCli()) { + $this->isCli = true; + return; + } + + $this->session = Session::getSession(); + $messages = $this->session->get(self::SESSION_KEY); + if (is_array($messages)) { + $this->messages = $messages; + $this->session->delete(self::SESSION_KEY); + $this->session->write(); + } + } + + /** + * Get the Notification instance + * + * @return Notification + */ + public static function getInstance() + { + if (self::$instance === null) { + self::$instance = new self(); + } + return self::$instance; + } + + /** + * Add info notification + * + * @param string $msg + */ + public static function info($msg) + { + self::getInstance()->addMessage($msg, self::INFO); + } + + /** + * Add error notification + * + * @param string $msg + */ + public static function error($msg) + { + self::getInstance()->addMessage($msg, self::ERROR); + } + + /** + * Add success notification + * + * @param string $msg + */ + public static function success($msg) + { + self::getInstance()->addMessage($msg, self::SUCCESS); + } + + /** + * Add warning notification + * + * @param string $msg + */ + public static function warning($msg) + { + self::getInstance()->addMessage($msg, self::WARNING); + } + + /** + * Add a notification message + * + * @param string $message + * @param string $type + */ + protected function addMessage($message, $type = self::INFO) + { + if ($this->isCli) { + $msg = sprintf('[%s] %s', $type, $message); + switch ($type) { + case self::INFO: + case self::SUCCESS: + Logger::info($msg); + break; + case self::ERROR: + Logger::error($msg); + break; + case self::WARNING: + Logger::warning($msg); + break; + } + } else { + $this->messages[] = (object) array( + 'type' => $type, + 'message' => $message, + ); + } + } + + /** + * Pop the notification messages + * + * @return array + */ + public function popMessages() + { + $messages = $this->messages; + $this->messages = array(); + return $messages; + } + + /** + * Get whether notification messages have been added + * + * @return bool + */ + public function hasMessages() + { + return ! empty($this->messages); + } + + /** + * Destroy the notification instance + */ + final public function __destruct() + { + if ($this->isCli) { + return; + } + if ($this->hasMessages() && $this->session->get('messages') !== $this->messages) { + $this->session->set(self::SESSION_KEY, $this->messages); + $this->session->write(); + } + } +} diff --git a/library/Icinga/Web/Paginator/Adapter/QueryAdapter.php b/library/Icinga/Web/Paginator/Adapter/QueryAdapter.php new file mode 100644 index 0000000..6f103e5 --- /dev/null +++ b/library/Icinga/Web/Paginator/Adapter/QueryAdapter.php @@ -0,0 +1,84 @@ +<?php +/* Icinga Web 2 | (c) 2013 Icinga Development Team | GPLv2+ */ + +namespace Icinga\Web\Paginator\Adapter; + +use Zend_Paginator_Adapter_Interface; +use Icinga\Data\QueryInterface; + +class QueryAdapter implements Zend_Paginator_Adapter_Interface +{ + /** + * The query being paginated + * + * @var QueryInterface + */ + protected $query; + + /** + * Item count + * + * @var int + */ + protected $count; + + /** + * Create a new QueryAdapter + * + * @param QueryInterface $query The query to paginate + */ + public function __construct(QueryInterface $query) + { + $this->setQuery($query); + } + + /** + * Set the query to paginate + * + * @param QueryInterface $query + * + * @return $this + */ + public function setQuery(QueryInterface $query) + { + $this->query = $query; + return $this; + } + + /** + * Return the query being paginated + * + * @return QueryInterface + */ + public function getQuery() + { + return $this->query; + } + + /** + * Fetch and return the rows in the given range of the query result + * + * @param int $offset Page offset + * @param int $itemCountPerPage Number of items per page + * + * @return array + */ + public function getItems($offset, $itemCountPerPage) + { + return $this->query->limit($itemCountPerPage, $offset)->fetchAll(); + } + + /** + * Return the total number of items in the query result + * + * @return int + */ + public function count(): int + { + if ($this->count === null) { + $this->count = $this->query->count(); + } + + return $this->count; + } +} diff --git a/library/Icinga/Web/Paginator/ScrollingStyle/SlidingWithBorder.php b/library/Icinga/Web/Paginator/ScrollingStyle/SlidingWithBorder.php new file mode 100644 index 0000000..d9b2ed9 --- /dev/null +++ b/library/Icinga/Web/Paginator/ScrollingStyle/SlidingWithBorder.php @@ -0,0 +1,78 @@ +<?php +/* Icinga Web 2 | (c) 2013 Icinga Development Team | GPLv2+ */ + +/** + * @see Zend_Paginator_ScrollingStyle_Interface + */ +class Icinga_Web_Paginator_ScrollingStyle_SlidingWithBorder implements Zend_Paginator_ScrollingStyle_Interface +{ + /** + * Returns an array of "local" pages given a page number and range. + * + * @param Zend_Paginator $paginator + * @param integer $pageRange (Optional) Page range + * @return array + */ + public function getPages(Zend_Paginator $paginator, $pageRange = null) + { + // This is unused + if ($pageRange === null) { + $pageRange = $paginator->getPageRange(); + } + + $pageNumber = $paginator->getCurrentPageNumber(); + $pageCount = count($paginator); + $range = array(); + + if ($pageCount < 10) { + // Show all pages if we have less than 10. + + for ($i = 1; $i < 10; $i++) { + if ($i > $pageCount) { + break; + } + $range[$i] = $i; + } + } else { + // More than 10 pages: + + foreach (array(1, 2) as $i) { + $range[$i] = $i; + } + if ($pageNumber < 6) { + // We are on page 1-5 from + for ($i = 1; $i <= 7; $i++) { + $range[$i] = $i; + } + } else { + // Current page > 5 + $range[] = '...'; + + // Less than 5 pages left + if (($pageCount - $pageNumber) < 5) { + $start = 5 - ($pageCount - $pageNumber); + } else { + $start = 1; + } + + for ($i = $pageNumber - $start; $i < ($pageNumber + (4 - $start)); $i++) { + if ($i > $pageCount) { + break; + } + $range[$i] = $i; + } + } + if ($pageNumber < ($pageCount - 2)) { + $range[] = '...'; + } + + foreach (array($pageCount - 1, $pageCount) as $i) { + $range[$i] = $i; + } + } + if (empty($range)) { + $range[] = 1; + } + return $range; + } +} diff --git a/library/Icinga/Web/RememberMe.php b/library/Icinga/Web/RememberMe.php new file mode 100644 index 0000000..1002396 --- /dev/null +++ b/library/Icinga/Web/RememberMe.php @@ -0,0 +1,363 @@ +<?php +/* Icinga Web 2 | (c) 2021 Icinga GmbH | GPLv2+ */ + +namespace Icinga\Web; + +use Icinga\Application\Config; +use Icinga\Authentication\Auth; +use Icinga\Crypt\AesCrypt; +use Icinga\Common\Database; +use Icinga\User; +use ipl\Sql\Expression; +use ipl\Sql\Select; +use RuntimeException; + +/** + * Remember me component + * + * Retains credentials for 30 days by default in order to stay signed in even after the session is closed. + */ +class RememberMe +{ + use Database; + + /** @var string Cookie name */ + const COOKIE = 'icingaweb2-remember-me'; + + /** @var string Database table name */ + const TABLE = 'icingaweb_rememberme'; + + /** @var string Encrypted password of the user */ + protected $encryptedPassword; + + /** @var string */ + protected $username; + + /** @var AesCrypt Instance for encrypting/decrypting the credentials */ + protected $aesCrypt; + + /** @var int Timestamp when the remember me cookie expires */ + protected $expiresAt; + + /** + * Get whether staying logged in is possible + * + * @return bool + */ + public static function isSupported() + { + $self = new self(); + + if (! $self->hasDb()) { + return false; + } + + try { + (new AesCrypt())->getMethod(); + } catch (RuntimeException $_) { + return false; + } + + return true; + } + + /** + * Get whether the remember cookie is set + * + * @return bool + */ + public static function hasCookie() + { + return isset($_COOKIE[static::COOKIE]); + } + + /** + * Remove the database entry if exists and unset the remember me cookie from PHP's `$_COOKIE` superglobal + * + * @return Cookie The invalidation cookie which has to be sent to client in oder to remove the remember me cookie + */ + public static function forget() + { + if (self::hasCookie()) { + $data = explode('|', $_COOKIE[static::COOKIE]); + $iv = base64_decode(array_pop($data)); + (new self())->remove(bin2hex($iv)); + } + + unset($_COOKIE[static::COOKIE]); + + return (new Cookie(static::COOKIE)) + ->setHttpOnly(true) + ->forgetMe(); + } + + /** + * Create the remember me component from the remember me cookie + * + * @return static + */ + public static function fromCookie() + { + $data = explode('|', $_COOKIE[static::COOKIE]); + $iv = base64_decode(array_pop($data)); + + $select = (new Select()) + ->from(static::TABLE) + ->columns('*') + ->where(['random_iv = ?' => bin2hex($iv)]); + + $rememberMe = new static(); + $rs = $rememberMe->getDb()->select($select)->fetch(); + + if (! $rs) { + throw new RuntimeException(sprintf( + "No database entry found for IV '%s'", + bin2hex($iv) + )); + } + + $rememberMe->aesCrypt = (new AesCrypt()) + ->setKey(hex2bin($rs->passphrase)) + ->setIV($iv); + + if (count($data) > 1) { + $rememberMe->aesCrypt->setTag( + base64_decode(array_pop($data)) + ); + } elseif ($rememberMe->aesCrypt->isAuthenticatedEncryptionRequired()) { + throw new RuntimeException( + "The given decryption method needs a tag, but is not specified. " + . "You have probably updated the PHP version." + ); + } + + $rememberMe->username = $rs->username; + $rememberMe->encryptedPassword = $data[0]; + + return $rememberMe; + } + + /** + * Create the remember me component from the given username and password + * + * @param string $username + * @param string $password + * + * @return static + */ + public static function fromCredentials($username, $password) + { + $aesCrypt = new AesCrypt(); + $rememberMe = new static(); + $rememberMe->encryptedPassword = $aesCrypt->encrypt($password); + $rememberMe->username = $username; + $rememberMe->aesCrypt = $aesCrypt; + + return $rememberMe; + } + + /** + * Remove expired remember me information from the database + */ + public static function removeExpired() + { + $rememberMe = new static(); + if (! $rememberMe->hasDb()) { + return; + } + + $rememberMe->getDb()->delete(static::TABLE, [ + 'expires_at < NOW()' + ]); + } + + /** + * Get the remember me cookie + * + * @return Cookie + */ + public function getCookie() + { + $values = [ + $this->encryptedPassword, + base64_encode($this->aesCrypt->getIV()), + ]; + + if ($this->aesCrypt->isAuthenticatedEncryptionRequired()) { + array_splice($values, 1, 0, base64_encode($this->aesCrypt->getTag())); + } + + return (new Cookie(static::COOKIE)) + ->setExpire($this->getExpiresAt()) + ->setHttpOnly(true) + ->setValue(implode('|', $values)); + } + + /** + * Get the timestamp when the cookie expires + * + * Defaults to now plus 30 days, if not set via {@link setExpiresAt()}. + * + * @return int + */ + public function getExpiresAt() + { + if ($this->expiresAt === null) { + $this->expiresAt = time() + 60 * 60 * 24 * 30; + } + + return $this->expiresAt; + } + + /** + * Set the timestamp when the cookie expires + * + * @param int $expiresAt + * + * @return $this + */ + public function setExpiresAt($expiresAt) + { + $this->expiresAt = $expiresAt; + + return $this; + } + + /** + * Authenticate via the remember me cookie + * + * @return bool + * + * @throws \Icinga\Exception\AuthenticationException + */ + public function authenticate() + { + $auth = Auth::getInstance(); + $authChain = $auth->getAuthChain(); + $authChain->setSkipExternalBackends(true); + $user = new User($this->username); + if (! $user->hasDomain()) { + $user->setDomain(Config::app()->get('authentication', 'default_domain')); + } + + $authenticated = $authChain->authenticate( + $user, + $this->aesCrypt->decrypt($this->encryptedPassword) + ); + + if ($authenticated) { + $auth->setAuthenticated($user); + } + + return $authenticated; + } + + /** + * Persist the remember me information into the database + * + * To remove any previous stored information, set the iv + * + * @param string|null $iv To remove a specific iv record from the database + * + * @return $this + */ + public function persist($iv = null) + { + if ($iv) { + $this->remove(bin2hex($iv)); + } + + $this->getDb()->insert(static::TABLE, [ + 'username' => $this->username, + 'passphrase' => bin2hex($this->aesCrypt->getKey()), + 'random_iv' => bin2hex($this->aesCrypt->getIV()), + 'http_user_agent' => (new UserAgent)->getAgent(), + 'expires_at' => date('Y-m-d H:i:s', $this->getExpiresAt()), + 'ctime' => new Expression('NOW()'), + 'mtime' => new Expression('NOW()') + ]); + + return $this; + } + + /** + * Remove remember me information from the database on the basis of iv + * + * @param string $iv + * + * @return $this + */ + public function remove($iv) + { + $this->getDb()->delete(static::TABLE, [ + 'random_iv = ?' => $iv + ]); + + return $this; + } + + /** + * Create renewed remember me cookie + * + * @return static New remember me cookie which has to be sent to the client + */ + public function renew() + { + return static::fromCredentials( + $this->username, + $this->aesCrypt->decrypt($this->encryptedPassword) + ); + } + + /** + * Get all users using remember me cookie + * + * @return array Array of users + */ + public static function getAllUser() + { + $rememberMe = new static(); + if (! $rememberMe->hasDb()) { + return []; + } + + $select = (new Select()) + ->from(static::TABLE) + ->columns('username') + ->groupBy('username'); + + return $rememberMe->getDb()->select($select)->fetchAll(); + } + + /** + * Get all remember me entries from the database of the given user. + * + * @param $username + * + * @return array Array of database entries + */ + public static function getAllByUsername($username) + { + $rememberMe = new static(); + if (! $rememberMe->hasDb()) { + return []; + } + + $select = (new Select()) + ->from(static::TABLE) + ->columns(['http_user_agent', 'random_iv']) + ->where(['username = ?' => $username]); + + return $rememberMe->getDb()->select($select)->fetchAll(); + } + + /** + * Get the AesCrypt instance + * + * @return AesCrypt + */ + public function getAesCrypt() + { + return $this->aesCrypt; + } +} diff --git a/library/Icinga/Web/RememberMeUserDevicesList.php b/library/Icinga/Web/RememberMeUserDevicesList.php new file mode 100644 index 0000000..66609de --- /dev/null +++ b/library/Icinga/Web/RememberMeUserDevicesList.php @@ -0,0 +1,144 @@ +<?php +/* Icinga Web 2 | (c) 2021 Icinga GmbH | GPLv2+ */ + +namespace Icinga\Web; + +use ipl\Html\BaseHtmlElement; +use ipl\Html\Html; +use ipl\Web\Url as iplWebUrl; //alias is needed for php5.6 +use ipl\Web\Widget\Icon; +use ipl\Web\Widget\Link; + +class RememberMeUserDevicesList extends BaseHtmlElement +{ + protected $tag = 'table'; + + protected $defaultAttributes = [ + 'class' => 'common-table', + 'data-base-target' => '_self' + ]; + + /** + * @var array + */ + protected $devicesList; + + /** + * @var string + */ + protected $username; + + /** + * @var string + */ + protected $url; + + /** + * @return string + */ + public function getUrl() + { + return $this->url; + } + + /** + * @param string $url + * + * @return $this + */ + public function setUrl($url) + { + $this->url = $url; + + return $this; + } + + /** + * @return string + */ + public function getUsername() + { + return $this->username; + } + + /** + * @param string $username + * + * @return $this + */ + public function setUsername($username) + { + $this->username = $username; + + return $this; + } + + /** + * @return array List of devices. Each device contains user agent and fingerprint string + */ + public function getDevicesList() + { + return $this->devicesList; + } + + /** + * @param $devicesList + * + * @return $this + */ + public function setDevicesList($devicesList) + { + $this->devicesList = $devicesList; + + return $this; + } + + protected function assemble() + { + $thead = Html::tag('thead'); + $theadRow = Html::tag('tr') + ->add(Html::tag( + 'th', + sprintf(t('List of devices and browsers %s is currently logged in:'), $this->getUsername()) + )); + + $thead->add($theadRow); + + $head = Html::tag('tr') + ->add(Html::tag('th', t('OS'))) + ->add(Html::tag('th', t('Browser'))) + ->add(Html::tag('th', t('Fingerprint'))); + + $thead->add($head); + $tbody = Html::tag('tbody'); + + if (empty($this->getDevicesList())) { + $tbody->add(Html::tag('td', t('No device found'))); + } else { + foreach ($this->getDevicesList() as $device) { + $agent = new UserAgent($device); + $element = Html::tag('tr') + ->add(Html::tag('td', $agent->getOs())) + ->add(Html::tag('td', $agent->getBrowser())) + ->add(Html::tag('td', $device->random_iv)); + + $link = (new Link( + new Icon('trash'), + iplWebUrl::fromPath($this->getUrl()) + ->addParams( + [ + 'name' => $this->getUsername(), + 'fingerprint' => $device->random_iv, + ] + ) + )); + + $element->add(Html::tag('td', $link)); + $tbody->add($element); + } + } + + $this->add($thead); + $this->add($tbody); + } +} diff --git a/library/Icinga/Web/RememberMeUserList.php b/library/Icinga/Web/RememberMeUserList.php new file mode 100644 index 0000000..bb95dc9 --- /dev/null +++ b/library/Icinga/Web/RememberMeUserList.php @@ -0,0 +1,106 @@ +<?php +/* Icinga Web 2 | (c) 2021 Icinga GmbH | GPLv2+ */ + +namespace Icinga\Web; + +use ipl\Html\BaseHtmlElement; +use ipl\Html\Html; +use ipl\Web\Url as iplWebUrl; //alias is needed for php5.6 +use ipl\Web\Widget\Link; + +/** + * Class RememberMeUserList + * + * @package Icinga\Web + */ +class RememberMeUserList extends BaseHtmlElement +{ + protected $tag = 'table'; + + protected $defaultAttributes = [ + 'class' => 'common-table table-row-selectable', + 'data-base-target' => '_next', + ]; + + /** + * @var array + */ + protected $users; + + /** + * @var string + */ + protected $url; + + /** + * @return string + */ + public function getUrl() + { + return $this->url; + } + + /** + * @param string $url + * + * @return $this + */ + public function setUrl($url) + { + $this->url = $url; + + return $this; + } + + /** + * @return array + */ + public function getUsers() + { + return $this->users; + } + + /** + * @param array $users + * + * @return $this + */ + public function setUsers($users) + { + $this->users = $users; + + return $this; + } + + protected function assemble() + { + $thead = Html::tag('thead'); + $theadRow = Html::tag('tr') + ->add(Html::tag( + 'th', + t('List of users who stay logged in') + )); + + $thead->add($theadRow); + $tbody = Html::tag('tbody'); + + if (empty($this->getUsers())) { + $tbody->add(Html::tag('td', t('No user found'))); + } else { + foreach ($this->getUsers() as $user) { + $element = Html::tag('tr'); + $link = new Link( + $user->username, + iplWebUrl::fromPath($this->getUrl())->addParams(['name' => $user->username]), + ['title' => sprintf(t('Device list of %s'), $user->username)] + ); + + $element->add(Html::tag('td', $link)); + $tbody->add($element); + } + } + + $this->add($thead); + $this->add($tbody); + } +} diff --git a/library/Icinga/Web/Request.php b/library/Icinga/Web/Request.php new file mode 100644 index 0000000..064ce63 --- /dev/null +++ b/library/Icinga/Web/Request.php @@ -0,0 +1,142 @@ +<?php +/* Icinga Web 2 | (c) 2013 Icinga Development Team | GPLv2+ */ + +namespace Icinga\Web; + +use Icinga\Util\Json; +use Zend_Controller_Request_Http; +use Icinga\Application\Icinga; +use Icinga\User; + +/** + * A request + */ +class Request extends Zend_Controller_Request_Http +{ + /** + * Response + * + * @var Response + */ + protected $response; + + /** + * Unique identifier + * + * @var string + */ + protected $uniqueId; + + /** + * Request URL + * + * @var Url + */ + protected $url; + + /** + * User if authenticated + * + * @var User|null + */ + protected $user; + + /** + * Get the response + * + * @return Response + */ + public function getResponse() + { + if ($this->response === null) { + $this->response = Icinga::app()->getResponse(); + } + + return $this->response; + } + + /** + * Get the request URL + * + * @return Url + */ + public function getUrl() + { + if ($this->url === null) { + $this->url = Url::fromRequest($this); + } + return $this->url; + } + + /** + * Get the user if authenticated + * + * @return User|null + */ + public function getUser() + { + return $this->user; + } + + /** + * Set the authenticated user + * + * @param User $user + * + * @return $this + */ + public function setUser(User $user) + { + $this->user = $user; + return $this; + } + + /** + * Get whether the request seems to be an API request + * + * @return bool + */ + public function isApiRequest() + { + return $this->getHeader('Accept') === 'application/json'; + } + + /** + * Makes an ID unique to this request, to prevent id collisions in different containers + * + * Call this whenever an ID might show up multiple times in different containers. This function is useful + * for ensuring unique ids on sites, even if we combine the HTML of different requests into one site, + * while still being able to reference elements uniquely in the same request. + * + * @param string $id + * + * @return string The id suffixed w/ an identifier unique to this request + */ + public function protectId($id) + { + return $id . '-' . Window::getInstance()->getContainerId(); + } + + public function getPost($key = null, $default = null) + { + if ($key === null && $this->extractMediaType($this->getHeader('Content-Type')) === 'application/json') { + return Json::decode(file_get_contents('php://input'), true); + } + + return parent::getPost($key, $default); + } + + /** + * Extract and return the media type from the given header value + * + * @param string $headerValue + * + * @return string + */ + protected function extractMediaType($headerValue) + { + // Pretty basic and does not care about parameters + $parts = explode(';', $headerValue, 2); + return strtolower(trim($parts[0])); + } +} diff --git a/library/Icinga/Web/Response.php b/library/Icinga/Web/Response.php new file mode 100644 index 0000000..555d3fa --- /dev/null +++ b/library/Icinga/Web/Response.php @@ -0,0 +1,460 @@ +<?php +/* Icinga Web 2 | (c) 2014 Icinga Development Team | GPLv2+ */ + +namespace Icinga\Web; + +use Icinga\Application\Config; +use Icinga\Util\Csp; +use Zend_Controller_Response_Http; +use Icinga\Application\Icinga; +use Icinga\Web\Response\JsonResponse; + +/** + * A HTTP response + */ +class Response extends Zend_Controller_Response_Http +{ + /** + * The default content type being used for responses + * + * @var string + */ + const DEFAULT_CONTENT_TYPE = 'text/html; charset=UTF-8'; + + /** + * Auto-refresh interval + * + * @var int + */ + protected $autoRefreshInterval; + + /** + * Set of cookies which are to be sent to the client + * + * @var CookieSet + */ + protected $cookies; + + /** + * Redirect URL + * + * @var Url|null + */ + protected $redirectUrl; + + /** + * Request + * + * @var Request + */ + protected $request; + + /** + * Whether to instruct the client to reload the window + * + * @var bool + */ + protected $reloadWindow; + + /** + * Whether to instruct client side script code to reload CSS + * + * @var bool + */ + protected $reloadCss; + + /** + * Whether to send the rerender layout header on XHR + * + * @var bool + */ + protected $rerenderLayout = false; + + /** + * Whether to send the current window ID to the client + * + * @var bool + */ + protected $overrideWindowId = false; + + /** + * Get the auto-refresh interval + * + * @return int + */ + public function getAutoRefreshInterval() + { + return $this->autoRefreshInterval; + } + + /** + * Set the auto-refresh interval + * + * @param int $autoRefreshInterval + * + * @return $this + */ + public function setAutoRefreshInterval($autoRefreshInterval) + { + $this->autoRefreshInterval = $autoRefreshInterval; + return $this; + } + + /** + * Get the set of cookies which are to be sent to the client + * + * @return CookieSet + */ + public function getCookies() + { + if ($this->cookies === null) { + $this->cookies = new CookieSet(); + } + return $this->cookies; + } + + /** + * Get the cookie with the given name from the set of cookies which are to be sent to the client + * + * @param string $name The name of the cookie + * + * @return Cookie|null The cookie with the given name or null if the cookie does not exist + */ + public function getCookie($name) + { + return $this->getCookies()->get($name); + } + + /** + * Set the given cookie for sending it to the client + * + * @param Cookie $cookie The cookie to send to the client + * + * @return $this + */ + public function setCookie(Cookie $cookie) + { + $this->getCookies()->add($cookie); + return $this; + } + + /** + * Get the redirect URL + * + * @return Url|null + */ + protected function getRedirectUrl() + { + return $this->redirectUrl; + } + + /** + * Set the redirect URL + * + * Unlike {@link setRedirect()} this method only sets a redirect URL on the response for later usage. + * {@link prepare()} will take care of the correct redirect handling and HTTP headers on XHR and "normal" browser + * requests. + * + * @param string|Url $redirectUrl + * + * @return $this + */ + protected function setRedirectUrl($redirectUrl) + { + if (! $redirectUrl instanceof Url) { + $redirectUrl = Url::fromPath((string) $redirectUrl); + } + $redirectUrl->getParams()->setSeparator('&'); + $this->redirectUrl = $redirectUrl; + return $this; + } + + /** + * Get an array of all header values for the given name + * + * @param string $name The name of the header + * @param bool $lastOnly If this is true, the last value will be returned as a string + * + * @return null|array|string + */ + public function getHeader($name, $lastOnly = false) + { + $result = ($lastOnly ? null : array()); + $headers = $this->getHeaders(); + foreach ($headers as $header) { + if ($header['name'] === $name) { + if ($lastOnly) { + $result = $header['value']; + } else { + $result[] = $header['value']; + } + } + } + + return $result; + } + + /** + * Get the request + * + * @return Request + */ + public function getRequest() + { + if ($this->request === null) { + $this->request = Icinga::app()->getRequest(); + } + return $this->request; + } + + /** + * Get whether to instruct the client to reload the window + * + * @return bool + */ + public function isWindowReloaded() + { + return $this->reloadWindow; + } + + /** + * Set whether to instruct the client to reload the window + * + * @param bool $reloadWindow + * + * @return $this + */ + public function setReloadWindow($reloadWindow) + { + $this->reloadWindow = $reloadWindow; + + return $this; + } + + /** + * Get whether to instruct client side script code to reload CSS + * + * @return bool + */ + public function isReloadCss() + { + return $this->reloadCss; + } + + /** + * Set whether to instruct client side script code to reload CSS + * + * @param bool $reloadCss + * + * @return $this + */ + public function setReloadCss($reloadCss) + { + $this->reloadCss = $reloadCss; + return $this; + } + + /** + * Get whether to send the rerender layout header on XHR + * + * @return bool + */ + public function getRerenderLayout() + { + return $this->rerenderLayout; + } + + /** + * Get whether to send the rerender layout header on XHR + * + * @param bool $rerenderLayout + * + * @return $this + */ + public function setRerenderLayout($rerenderLayout = true) + { + $this->rerenderLayout = (bool) $rerenderLayout; + return $this; + } + + /** + * Get whether to send the current window ID to the client + * + * @return bool + */ + public function getOverrideWindowId() + { + return $this->overrideWindowId; + } + + /** + * Set whether to send the current window ID to the client + * + * @param bool $overrideWindowId + * + * @return $this + */ + public function setOverrideWindowId($overrideWindowId = true) + { + $this->overrideWindowId = $overrideWindowId; + return $this; + } + + /** + * Entry point for HTTP responses in JSON format + * + * @return JsonResponse + */ + public function json() + { + $response = new JsonResponse(); + $response->copyMetaDataFrom($this); + return $response; + } + + /** + * Prepare the request before sending + */ + protected function prepare() + { + $request = $this->getRequest(); + $redirectUrl = $this->getRedirectUrl(); + if ($request->isXmlHttpRequest()) { + if ($redirectUrl !== null) { + if ($request->isGet() && Icinga::app()->getViewRenderer()->view->compact) { + if ($redirectUrl->getParam('redirect') !== '__SELF__') { + $redirectUrl->getParams()->set('showCompact', true); + } + } + + $encodedRedirectUrl = rawurlencode($redirectUrl->getAbsoluteUrl()); + + // TODO: Compatibility only. Remove once v2.14 is out. + $targetId = $request->getHeader('X-Icinga-Container'); + $redirectTargetId = $this->getHeader('X-Icinga-Container', true) ?? $targetId; + if ($request->isPost() + && ! $this->getRerenderLayout() + && $targetId === 'col2' + && $redirectTargetId === $targetId + && $request->getHeader('X-Icinga-Col2-State') + ) { + $col1State = Url::fromPath($request->getHeader('X-Icinga-Col1-State')); + $col2State = Url::fromPath($request->getHeader('X-Icinga-Col2-State')); + if ($col2State->getPath() !== $redirectUrl->getPath() + && $col1State->getPath() === $redirectUrl->getPath() + ) { + $encodedRedirectUrl = '__CLOSE__'; + } + } + + $this->setHeader('X-Icinga-Redirect', $encodedRedirectUrl, true); + if ($this->getRerenderLayout()) { + $this->setHeader('X-Icinga-Rerender-Layout', 'yes', true); + } + } + if ($this->getOverrideWindowId()) { + $this->setHeader('X-Icinga-WindowId', Window::getInstance()->getId(), true); + } + if ($this->getRerenderLayout()) { + $this->setHeader('X-Icinga-Container', 'layout', true); + } + if ($this->isWindowReloaded()) { + $this->setHeader('X-Icinga-Reload-Window', 'yes', true); + } + if ($this->isReloadCss()) { + $this->setHeader('X-Icinga-Reload-Css', 'now', true); + } + if (($autoRefreshInterval = $this->getAutoRefreshInterval()) !== null) { + $this->setHeader('X-Icinga-Refresh', $autoRefreshInterval, true); + } + + $notifications = Notification::getInstance(); + if ($notifications->hasMessages()) { + $notificationList = array(); + foreach ($notifications->popMessages() as $m) { + $notificationList[] = rawurlencode($m->type . ' ' . $m->message); + } + $this->setHeader('X-Icinga-Notification', implode('&', $notificationList), true); + } + } else { + if ($redirectUrl !== null) { + $this->setRedirect($redirectUrl->getAbsoluteUrl()); + } + + if (Csp::getStyleNonce() && Config::app()->get('security', 'use_strict_csp', false)) { + Csp::addHeader($this); + } + } + + if (! $this->getHeader('Content-Type', true)) { + $this->setHeader('Content-Type', static::DEFAULT_CONTENT_TYPE); + } + } + + /** + * Redirect to the given URL and exit immediately + * + * @param string|Url $url + * + * @return never + */ + public function redirectAndExit($url) + { + $this->setRedirectUrl($url); + + $session = Session::getSession(); + if ($session->hasChanged()) { + $session->write(); + } + + $this->sendHeaders(); + exit; + } + + /** + * Send the cookies to the client + */ + public function sendCookies() + { + foreach ($this->getCookies() as $cookie) { + /** @var Cookie $cookie */ + setcookie( + $cookie->getName(), + $cookie->getValue() ?? '', + $cookie->getExpire() ?? 0, + $cookie->getPath(), + $cookie->getDomain() ?? '', + $cookie->isSecure(), + $cookie->isHttpOnly() ?? true + ); + } + } + + /** + * {@inheritdoc} + */ + public function sendHeaders() + { + $this->prepare(); + if (! $this->getRequest()->isApiRequest()) { + $this->sendCookies(); + } + return parent::sendHeaders(); + } + + /** + * Copies non-body-related response data from $response + * + * @param Response $response + * + * @return $this + */ + protected function copyMetaDataFrom(self $response) + { + $this->_headers = $response->_headers; + $this->_headersRaw = $response->_headersRaw; + $this->_httpResponseCode = $response->_httpResponseCode; + $this->headersSentThrowsException = $response->headersSentThrowsException; + return $this; + } +} diff --git a/library/Icinga/Web/Response/JsonResponse.php b/library/Icinga/Web/Response/JsonResponse.php new file mode 100644 index 0000000..025e88d --- /dev/null +++ b/library/Icinga/Web/Response/JsonResponse.php @@ -0,0 +1,241 @@ +<?php +/* Icinga Web 2 | (c) 2015 Icinga Development Team | GPLv2+ */ + +namespace Icinga\Web\Response; + +use Icinga\Util\Json; +use Zend_Controller_Action_HelperBroker; +use Icinga\Web\Response; + +/** + * HTTP response in JSON format + */ +class JsonResponse extends Response +{ + /** + * {@inheritdoc} + */ + const DEFAULT_CONTENT_TYPE = 'application/json'; + + /** + * Status identifier for failed API calls due to an error on the server + * + * @var string + */ + const STATUS_ERROR = 'error'; + + /** + * Status identifier for rejected API calls most due to invalid data or call conditions + * + * @var string + */ + const STATUS_FAIL = 'fail'; + + /** + * Status identifier for successful API requests + * + * @var string + */ + const STATUS_SUCCESS = 'success'; + + /** + * JSON encoding options + * + * @var int + */ + protected $encodingOptions = 0; + + /** + * Whether to automatically sanitize invalid UTF-8 (if any) + * + * @var bool + */ + protected $autoSanitize = false; + + /** + * Error message if the API call failed due to a server error + * + * @var string|null + */ + protected $errorMessage; + + /** + * Fail data for rejected API calls + * + * @var array|null + */ + protected $failData; + + /** + * API request status + * + * @var string + */ + protected $status; + + /** + * Success data for successful API requests + * + * @var array|null + */ + protected $successData; + + /** + * Get the JSON encoding options + * + * @return int + */ + public function getEncodingOptions() + { + return $this->encodingOptions; + } + + /** + * Set the JSON encoding options + * + * @param int $encodingOptions + * + * @return $this + */ + public function setEncodingOptions($encodingOptions) + { + $this->encodingOptions = (int) $encodingOptions; + return $this; + } + + /** + * Get whether to automatically sanitize invalid UTF-8 (if any) + * + * @return bool + */ + public function getAutoSanitize() + { + return $this->autoSanitize; + } + + /** + * Set whether to automatically sanitize invalid UTF-8 (if any) + * + * @param bool $autoSanitize + * + * @return $this + */ + public function setAutoSanitize($autoSanitize = true) + { + $this->autoSanitize = $autoSanitize; + + return $this; + } + + /** + * Get the error message if the API call failed due to a server error + * + * @return string|null + */ + public function getErrorMessage() + { + return $this->errorMessage; + } + + /** + * Set the error message if the API call failed due to a server error + * + * @param string $errorMessage + * + * @return $this + */ + public function setErrorMessage($errorMessage) + { + $this->errorMessage = (string) $errorMessage; + $this->status = static::STATUS_ERROR; + return $this; + } + + /** + * Get the fail data for rejected API calls + * + * @return array|null + */ + public function getFailData() + { + return (! is_array($this->failData) || empty($this->failData)) ? null : $this->failData; + } + + /** + * Set the fail data for rejected API calls + * + * @param array $failData + * + * @return $this + */ + public function setFailData(array $failData) + { + $this->failData = $failData; + $this->status = static::STATUS_FAIL; + return $this; + } + + /** + * Get the data for successful API requests + * + * @return array|null + */ + public function getSuccessData() + { + return (! is_array($this->successData) || empty($this->successData)) ? null : $this->successData; + } + + /** + * Set the data for successful API requests + * + * @param array $successData + * + * @return $this + */ + public function setSuccessData(array $successData = null) + { + $this->successData = $successData; + $this->status = static::STATUS_SUCCESS; + return $this; + } + + /** + * {@inheritdoc} + */ + public function outputBody() + { + $body = array( + 'status' => $this->status + ); + switch ($this->status) { + /** @noinspection PhpMissingBreakStatementInspection */ + case static::STATUS_ERROR: + $body['message'] = $this->getErrorMessage(); + // Fallthrough + case static::STATUS_FAIL: + $failData = $this->getFailData(); + if ($failData !== null || $this->status === static::STATUS_FAIL) { + $body['data'] = $failData; + } + break; + case static::STATUS_SUCCESS: + $body['data'] = $this->getSuccessData(); + break; + } + echo $this->getAutoSanitize() + ? Json::sanitize($body, $this->getEncodingOptions()) + : Json::encode($body, $this->getEncodingOptions()); + } + + /** + * Send the response, including all headers, excluding a rendered view. + * + * @return never + */ + public function sendResponse() + { + Zend_Controller_Action_HelperBroker::getStaticHelper('viewRenderer')->setNoRender(true); + parent::sendResponse(); + exit; + } +} diff --git a/library/Icinga/Web/Session.php b/library/Icinga/Web/Session.php new file mode 100644 index 0000000..40df89f --- /dev/null +++ b/library/Icinga/Web/Session.php @@ -0,0 +1,54 @@ +<?php +/* Icinga Web 2 | (c) 2013 Icinga Development Team | GPLv2+ */ + +namespace Icinga\Web; + +use Icinga\Web\Session\PhpSession; +use Icinga\Web\Session\Session as BaseSession; +use Icinga\Exception\ProgrammingError; + +/** + * Session container + */ +class Session +{ + /** + * The current session + * + * @var BaseSession $session + */ + private static $session; + + /** + * Create the session + * + * @param BaseSession $session + * + * @return BaseSession + */ + public static function create(BaseSession $session = null) + { + if ($session === null) { + self::$session = PhpSession::create(); + } else { + self::$session = $session; + } + + return self::$session; + } + + /** + * Return the current session + * + * @return BaseSession + * @throws ProgrammingError + */ + public static function getSession() + { + if (self::$session === null) { + self::create(); + } + + return self::$session; + } +} diff --git a/library/Icinga/Web/Session/Php72Session.php b/library/Icinga/Web/Session/Php72Session.php new file mode 100644 index 0000000..e6a6b19 --- /dev/null +++ b/library/Icinga/Web/Session/Php72Session.php @@ -0,0 +1,37 @@ +<?php +/* Icinga Web 2 | (c) 2017 Icinga Development Team | GPLv2+ */ + +namespace Icinga\Web\Session; + +use Icinga\Application\Logger; +use Icinga\Exception\ConfigurationError; +use Icinga\Web\Cookie; + +/** + * Session implementation in PHP + */ +class Php72Session extends PhpSession +{ + /** + * Open a PHP session + */ + protected function open() + { + session_name($this->sessionName); + + $cookie = new Cookie('bogus'); + session_set_cookie_params( + 0, + $cookie->getPath(), + $cookie->getDomain(), + $cookie->isSecure(), + true + ); + + session_start(array( + 'use_cookies' => true, + 'use_only_cookies' => true, + 'use_trans_sid' => false + )); + } +} diff --git a/library/Icinga/Web/Session/PhpSession.php b/library/Icinga/Web/Session/PhpSession.php new file mode 100644 index 0000000..36dd84e --- /dev/null +++ b/library/Icinga/Web/Session/PhpSession.php @@ -0,0 +1,256 @@ +<?php +/* Icinga Web 2 | (c) 2013 Icinga Development Team | GPLv2+ */ + +namespace Icinga\Web\Session; + +use Icinga\Application\Logger; +use Icinga\Exception\ConfigurationError; +use Icinga\Web\Cookie; + +/** + * Session implementation in PHP + */ +class PhpSession extends Session +{ + /** + * The namespace prefix + * + * Used to differentiate between standard session keys and namespace identifiers + */ + const NAMESPACE_PREFIX = 'ns.'; + + /** + * Whether the session has already been closed + * + * @var bool + */ + protected $hasBeenTouched = false; + + /** + * Name of the session + * + * @var string + */ + protected $sessionName = 'Icingaweb2'; + + /** + * Create a new PHPSession object using the provided options (if any) + * + * @param array $options An optional array of ini options to set + * + * @return static + * + * @throws ConfigurationError + * @see http://php.net/manual/en/session.configuration.php + */ + public static function create(array $options = null) + { + return version_compare(PHP_VERSION, '7.2.0') < 0 ? new self($options) : new Php72Session($options); + } + + /** + * Create a new PHPSession object using the provided options (if any) + * + * @param array $options An optional array of ini options to set + * + * @throws ConfigurationError + * @see http://php.net/manual/en/session.configuration.php + */ + public function __construct(array $options = null) + { + $defaultCookieOptions = array( + 'use_trans_sid' => false, + 'use_cookies' => true, + 'cookie_httponly' => true, + 'use_only_cookies' => true + ); + + if (version_compare(PHP_VERSION, '7.1.0') < 0) { + $defaultCookieOptions['hash_function'] = true; + $defaultCookieOptions['hash_bits_per_character'] = 5; + } else { + $defaultCookieOptions['sid_bits_per_character'] = 5; + } + + if ($options !== null) { + $options = array_merge($defaultCookieOptions, $options); + } else { + $options = $defaultCookieOptions; + } + + if (array_key_exists('test_session_name', $options)) { + $this->sessionName = $options['test_session_name']; + unset($options['test_session_name']); + } + + foreach ($options as $sessionVar => $value) { + if (ini_set("session." . $sessionVar, $value) === false) { + Logger::warning( + 'Could not set php.ini setting %s = %s. This might affect your sessions behaviour.', + $sessionVar, + $value + ); + } + } + + $sessionSavePath = session_save_path() ?: sys_get_temp_dir(); + if (session_module_name() === 'files' && !is_writable($sessionSavePath)) { + throw new ConfigurationError("Can't save session, path '$sessionSavePath' is not writable."); + } + + if ($this->exists()) { + // We do not want to start a new session here if there is not any + $this->read(); + } + } + + /** + * Open a PHP session + */ + protected function open() + { + session_name($this->sessionName); + + if ($this->hasBeenTouched) { + $cacheLimiter = ini_get('session.cache_limiter'); + ini_set('session.use_cookies', false); + ini_set('session.use_only_cookies', false); + ini_set('session.cache_limiter', null); + } + + $cookie = new Cookie('bogus'); + session_set_cookie_params( + 0, + $cookie->getPath(), + $cookie->getDomain(), + $cookie->isSecure(), + true + ); + + session_start(); + + if ($this->hasBeenTouched) { + ini_set('session.use_cookies', true); + ini_set('session.use_only_cookies', true); + /** @noinspection PhpUndefinedVariableInspection */ + ini_set('session.cache_limiter', $cacheLimiter); + } + } + + /** + * Read all values written to the underling session and make them accessible. + */ + public function read() + { + $this->clear(); + $this->open(); + + foreach ($_SESSION as $key => $value) { + if (strpos($key, self::NAMESPACE_PREFIX) === 0) { + $namespace = new SessionNamespace(); + $namespace->setAll($value); + $this->namespaces[substr($key, strlen(self::NAMESPACE_PREFIX))] = $namespace; + } else { + $this->set($key, $value); + } + } + + session_write_close(); + $this->hasBeenTouched = true; + } + + /** + * Write all values of this session object to the underlying session implementation + */ + public function write() + { + $this->open(); + + foreach ($this->removed as $key) { + unset($_SESSION[$key]); + } + foreach ($this->values as $key => $value) { + $_SESSION[$key] = $value; + } + foreach ($this->removedNamespaces as $identifier) { + unset($_SESSION[self::NAMESPACE_PREFIX . $identifier]); + } + foreach ($this->namespaces as $identifier => $namespace) { + $_SESSION[self::NAMESPACE_PREFIX . $identifier] = $namespace->getAll(); + } + + session_write_close(); + $this->hasBeenTouched = true; + } + + /** + * Delete the current session, causing all session information to be lost + */ + public function purge() + { + $this->open(); + $_SESSION = array(); + $this->clear(); + session_destroy(); + $this->clearCookies(); + session_write_close(); + $this->hasBeenTouched = true; + } + + /** + * Remove session cookies + */ + protected function clearCookies() + { + if (ini_get('session.use_cookies')) { + Logger::debug('Clear session cookie'); + $params = session_get_cookie_params(); + setcookie( + session_name(), + '', + time() - 42000, + $params['path'], + $params['domain'], + $params['secure'], + $params['httponly'] + ); + } + } + + /** + * @see Session::getId() + */ + public function getId() + { + if (($id = session_id()) === '') { + // Make sure we actually get a id + $this->open(); + session_write_close(); + $this->hasBeenTouched = true; + $id = session_id(); + } + + return $id; + } + + /** + * Assign a new sessionId to the currently active session + */ + public function refreshId() + { + $this->open(); + if ($this->exists()) { + session_regenerate_id(); + } + session_write_close(); + $this->hasBeenTouched = true; + } + + /** + * @see Session::exists() + */ + public function exists() + { + return isset($_COOKIE[$this->sessionName]); + } +} diff --git a/library/Icinga/Web/Session/Session.php b/library/Icinga/Web/Session/Session.php new file mode 100644 index 0000000..e73e9b4 --- /dev/null +++ b/library/Icinga/Web/Session/Session.php @@ -0,0 +1,126 @@ +<?php +/* Icinga Web 2 | (c) 2014 Icinga Development Team | GPLv2+ */ + +namespace Icinga\Web\Session; + +use Icinga\Exception\NotImplementedError; + +/** + * Base class for handling sessions + */ +abstract class Session extends SessionNamespace +{ + /** + * Container for session namespaces + * + * @var array + */ + protected $namespaces = array(); + + /** + * The identifiers of all namespaces removed from this session + * + * @var array + */ + protected $removedNamespaces = array(); + + /** + * Read all values from the underlying session implementation + */ + abstract public function read(); + + /** + * Persists changes to the underlying session implementation + */ + public function write() + { + throw new NotImplementedError('You are required to implement write() in your session implementation'); + } + + /** + * Return whether a session exists + * + * @return bool + */ + abstract public function exists(); + + /** + * Purge session + */ + abstract public function purge(); + + /** + * Assign a new session id to this session. + */ + abstract public function refreshId(); + + /** + * Return the id of this session + * + * @return string + */ + abstract public function getId(); + + /** + * Get or create a new session namespace + * + * @param string $identifier The namespace's identifier + * + * @return SessionNamespace + */ + public function getNamespace($identifier) + { + if (!isset($this->namespaces[$identifier])) { + if (in_array($identifier, $this->removedNamespaces, true)) { + unset($this->removedNamespaces[array_search($identifier, $this->removedNamespaces, true)]); + } + + $this->namespaces[$identifier] = new SessionNamespace(); + } + + return $this->namespaces[$identifier]; + } + + /** + * Return whether the given session namespace exists + * + * @param string $identifier The namespace's identifier to check + * + * @return bool + */ + public function hasNamespace($identifier) + { + return isset($this->namespaces[$identifier]); + } + + /** + * Remove the given session namespace + * + * @param string $identifier The identifier of the namespace to remove + */ + public function removeNamespace($identifier) + { + unset($this->namespaces[$identifier]); + $this->removedNamespaces[] = $identifier; + } + + /** + * Return whether the session has changed + * + * @return bool + */ + public function hasChanged() + { + return parent::hasChanged() || false === empty($this->namespaces) || false === empty($this->removedNamespaces); + } + + /** + * Clear all values and namespaces from the session cache + */ + public function clear() + { + parent::clear(); + $this->namespaces = array(); + $this->removedNamespaces = array(); + } +} diff --git a/library/Icinga/Web/Session/SessionNamespace.php b/library/Icinga/Web/Session/SessionNamespace.php new file mode 100644 index 0000000..1c9c13f --- /dev/null +++ b/library/Icinga/Web/Session/SessionNamespace.php @@ -0,0 +1,201 @@ +<?php +/* Icinga Web 2 | (c) 2014 Icinga Development Team | GPLv2+ */ + +namespace Icinga\Web\Session; + +use Exception; +use ArrayIterator; +use Icinga\Exception\IcingaException; +use IteratorAggregate; +use Traversable; + +/** + * Container for session values + */ +class SessionNamespace implements IteratorAggregate +{ + /** + * The actual values stored in this container + * + * @var array + */ + protected $values = array(); + + /** + * The names of all values removed from this container + * + * @var array + */ + protected $removed = array(); + + /** + * Return an iterator for all values in this namespace + * + * @return ArrayIterator + */ + public function getIterator(): Traversable + { + return new ArrayIterator($this->getAll()); + } + + /** + * Set a session value by property access + * + * @param string $key The value's name + * @param mixed $value The value + */ + public function __set($key, $value) + { + $this->set($key, $value); + } + + /** + * Return a session value by property access + * + * @param string $key The value's name + * + * @return mixed The value + * @throws Exception When the given value-name is not found + */ + public function __get($key) + { + if (!array_key_exists($key, $this->values)) { + throw new IcingaException( + 'Cannot access non-existent session value "%s"', + $key + ); + } + + return $this->get($key); + } + + /** + * Return whether the given session value is set + * + * @param string $key The value's name + * @return bool + */ + public function __isset($key) + { + return isset($this->values[$key]); + } + + /** + * Unset the given session value + * + * @param string $key The value's name + */ + public function __unset($key) + { + $this->delete($key); + } + + /** + * Setter for session values + * + * @param string $key Name of value + * @param mixed $value Value to set + * + * @return $this + */ + public function set($key, $value) + { + $this->values[$key] = $value; + + if (in_array($key, $this->removed, true)) { + unset($this->removed[array_search($key, $this->removed, true)]); + } + + return $this; + } + + public function setByRef($key, &$value) + { + $this->values[$key] = & $value; + + if (in_array($key, $this->removed, true)) { + unset($this->removed[array_search($key, $this->removed, true)]); + } + + return $this; + } + + /** + * Getter for session values + * + * @param string $key Name of the value to return + * @param mixed $default Default value to return + * + * @return mixed + */ + public function get($key, $default = null) + { + return isset($this->values[$key]) ? $this->values[$key] : $default; + } + + public function & getByRef($key, $default = null) + { + $value = $default; + if (isset($this->values[$key])) { + $value = & $this->values[$key]; + } + + return $value; + } + + /** + * Delete the given value from the session + * + * @param string $key The value's name + */ + public function delete($key) + { + $this->removed[] = $key; + unset($this->values[$key]); + } + + /** + * Getter for all session values + * + * @return array + */ + public function getAll() + { + return $this->values; + } + + /** + * Put an array into the session + * + * @param array $values Values to set + * @param bool $overwrite Overwrite existing values + */ + public function setAll(array $values, $overwrite = false) + { + foreach ($values as $key => $value) { + if ($this->get($key, $value) !== $value && !$overwrite) { + continue; + } + $this->set($key, $value); + } + } + + /** + * Return whether the session namespace has been changed + * + * @return bool + */ + public function hasChanged() + { + return false === empty($this->values) || false === empty($this->removed); + } + + /** + * Clear all values from the session namespace + */ + public function clear() + { + $this->values = array(); + $this->removed = array(); + } +} diff --git a/library/Icinga/Web/StyleSheet.php b/library/Icinga/Web/StyleSheet.php new file mode 100644 index 0000000..65cbb97 --- /dev/null +++ b/library/Icinga/Web/StyleSheet.php @@ -0,0 +1,342 @@ +<?php +/* Icinga Web 2 | (c) 2014 Icinga Development Team | GPLv2+ */ + +namespace Icinga\Web; + +use Exception; +use Icinga\Application\Icinga; +use Icinga\Application\Logger; +use Icinga\Authentication\Auth; +use Icinga\Exception\IcingaException; + +/** + * Send CSS for Web 2 and all loaded modules to the client + */ +class StyleSheet +{ + /** + * The name of the default theme + * + * @var string + */ + const DEFAULT_THEME = 'Icinga'; + + /** + * The name of the default theme mode + * + * @var string + */ + const DEFAULT_MODE = 'none'; + + /** + * The themes that are compatible with the default theme + * + * @var array + */ + const THEME_WHITELIST = [ + 'colorblind', + 'high-contrast', + 'Winter' + ]; + + /** + * Sequence that signals that a theme supports light mode + * + * @var string + */ + const LIGHT_MODE_IDENTIFIER = '@light-mode:'; + + /** + * Array of core LESS files Web 2 sends to the client + * + * @var string[] + */ + protected static $lessFiles = [ + '../application/fonts/fontello-ifont/css/ifont-embedded.css', + 'css/vendor/normalize.css', + 'css/icinga/base.less', + 'css/icinga/badges.less', + 'css/icinga/configmenu.less', + 'css/icinga/mixins.less', + 'css/icinga/grid.less', + 'css/icinga/nav.less', + 'css/icinga/main.less', + 'css/icinga/animation.less', + 'css/icinga/layout.less', + 'css/icinga/layout-structure.less', + 'css/icinga/menu.less', + 'css/icinga/tabs.less', + 'css/icinga/forms.less', + 'css/icinga/setup.less', + 'css/icinga/widgets.less', + 'css/icinga/login.less', + 'css/icinga/about.less', + 'css/icinga/controls.less', + 'css/icinga/dev.less', + 'css/icinga/spinner.less', + 'css/icinga/compat.less', + 'css/icinga/print.less', + 'css/icinga/responsive.less', + 'css/icinga/modal.less', + 'css/icinga/audit.less', + 'css/icinga/health.less', + 'css/icinga/php-diff.less', + 'css/icinga/pending-migration.less', + ]; + + /** + * Application instance + * + * @var \Icinga\Application\EmbeddedWeb + */ + protected $app; + + /** @var string[] Pre-compiled CSS files */ + protected $cssFiles = []; + + /** + * Less compiler + * + * @var LessCompiler + */ + protected $lessCompiler; + + /** + * Path to the public directory + * + * @var string + */ + protected $pubPath; + + /** + * Create the StyleSheet + */ + public function __construct() + { + $app = Icinga::app(); + $this->app = $app; + $this->lessCompiler = new LessCompiler(); + $this->pubPath = $app->getBaseDir('public'); + $this->collect(); + } + + /** + * Collect Web 2 and module LESS files and add them to the LESS compiler + */ + protected function collect() + { + foreach ($this->app->getLibraries() as $library) { + foreach ($library->getCssAssets() as $lessFile) { + if (substr($lessFile, -4) === '.css') { + $this->cssFiles[] = $lessFile; + } else { + $this->lessCompiler->addLessFile($lessFile); + } + } + } + + foreach (self::$lessFiles as $lessFile) { + $this->lessCompiler->addLessFile($this->pubPath . '/' . $lessFile); + } + + $mm = $this->app->getModuleManager(); + + foreach ($mm->getLoadedModules() as $moduleName => $module) { + if ($module->hasCss()) { + foreach ($module->getCssFiles() as $lessFilePath) { + $this->lessCompiler->addModuleLessFile($moduleName, $lessFilePath); + } + } + } + + $themingConfig = $this->app->getConfig()->getSection('themes'); + $defaultTheme = $themingConfig->get('default'); + $theme = null; + if ($defaultTheme !== null && $defaultTheme !== self::DEFAULT_THEME) { + $theme = $defaultTheme; + } + + if (! (bool) $themingConfig->get('disabled', false)) { + $auth = Auth::getInstance(); + if ($auth->isAuthenticated()) { + $userTheme = $auth->getUser()->getPreferences()->getValue('icingaweb', 'theme'); + if ($userTheme !== null) { + $theme = $userTheme; + } + } + } + + if ($themePath = self::getThemeFile($theme)) { + if ($this->app->isCli() || is_file($themePath) && is_readable($themePath)) { + $this->lessCompiler->setTheme($themePath); + } else { + $themePath = null; + Logger::warning(sprintf( + 'Theme "%s" set by user "%s" has not been found.', + $theme, + ($user = Auth::getInstance()->getUser()) !== null ? $user->getUsername() : 'anonymous' + )); + } + } + + if (! $themePath || in_array($theme, self::THEME_WHITELIST, true)) { + $this->lessCompiler->addLessFile($this->pubPath . '/css/icinga/login-orbs.less'); + } + + $mode = 'none'; + if ($user = Auth::getInstance()->getUser()) { + $file = $themePath !== null ? @file_get_contents($themePath) : false; + if (! $file || strpos($file, self::LIGHT_MODE_IDENTIFIER) !== false) { + $mode = $user->getPreferences()->getValue('icingaweb', 'theme_mode', self::DEFAULT_MODE); + } + } + + $this->lessCompiler->setThemeMode($this->pubPath . '/css/modes/'. $mode . '.less'); + } + + /** + * Get all collected files + * + * @return string[] + */ + protected function getFiles(): array + { + return array_merge($this->cssFiles, $this->lessCompiler->getLessFiles()); + } + + /** + * Get the stylesheet for PDF export + * + * @return $this + */ + public static function forPdf() + { + $styleSheet = new self(); + $styleSheet->lessCompiler->setTheme(null); + $styleSheet->lessCompiler->setThemeMode($styleSheet->pubPath . '/css/modes/none.less'); + $styleSheet->lessCompiler->addLessFile($styleSheet->pubPath . '/css/pdf/pdfprint.less'); + // TODO(el): Caching + return $styleSheet; + } + + /** + * Render the stylesheet + * + * @param bool $minified Whether to compress the stylesheet + * + * @return string CSS + */ + public function render($minified = false) + { + if ($minified) { + $this->lessCompiler->compress(); + } + + $css = ''; + foreach ($this->cssFiles as $cssFile) { + $css .= file_get_contents($cssFile); + } + + return $css . $this->lessCompiler->render(); + } + + /** + * Send the stylesheet to the client + * + * Does not cache the stylesheet if the HTTP header Cache-Control or Pragma is set to no-cache. + * + * @param bool $minified Whether to compress the stylesheet + */ + public static function send($minified = false) + { + $styleSheet = new self(); + + $request = $styleSheet->app->getRequest(); + $response = $styleSheet->app->getResponse(); + $response->setHeader('Cache-Control', 'private,no-cache,must-revalidate', true); + + $noCache = $request->getHeader('Cache-Control') === 'no-cache' || $request->getHeader('Pragma') === 'no-cache'; + + $collectedFiles = $styleSheet->getFiles(); + if (! $noCache && FileCache::etagMatchesFiles($collectedFiles)) { + $response + ->setHttpResponseCode(304) + ->sendHeaders(); + return; + } + + $etag = FileCache::etagForFiles($collectedFiles); + + $response->setHeader('ETag', $etag, true) + ->setHeader('Content-Type', 'text/css', true); + + $cacheFile = 'icinga-' . $etag . ($minified ? '.min' : '') . '.css'; + $cache = FileCache::instance(); + + if (! $noCache && $cache->has($cacheFile)) { + $response->setBody($cache->get($cacheFile)); + } else { + $css = $styleSheet->render($minified); + $response->setBody($css); + $cache->store($cacheFile, $css); + } + + $response->sendResponse(); + } + + /** + * Render the stylesheet + * + * @return string + */ + public function __toString() + { + try { + return $this->render(); + } catch (Exception $e) { + Logger::error($e); + return IcingaException::describe($e); + } + } + + /** + * Get the path to the current LESS theme file + * + * @param $theme + * + * @return string|null Return null if self::DEFAULT_THEME is set as theme, path otherwise + */ + public static function getThemeFile($theme) + { + $app = Icinga::app(); + + if ($theme && $theme !== self::DEFAULT_THEME) { + if (Hook::has('ThemeLoader')) { + try { + $path = Hook::first('ThemeLoader')->getThemeFile($theme); + } catch (Exception $e) { + Logger::error('Failed to call ThemeLoader hook: %s', $e); + $path = null; + } + + if ($path !== null) { + return $path; + } + } + + if (($pos = strpos($theme, '/')) !== false) { + $moduleName = substr($theme, 0, $pos); + $theme = substr($theme, $pos + 1); + if ($app->getModuleManager()->hasLoaded($moduleName)) { + $module = $app->getModuleManager()->getModule($moduleName); + + return $module->getCssDir() . '/themes/' . $theme . '.less'; + } + } else { + return $app->getBaseDir('public') . '/css/themes/' . $theme . '.less'; + } + } + + return null; + } +} diff --git a/library/Icinga/Web/Url.php b/library/Icinga/Web/Url.php new file mode 100644 index 0000000..c90ca48 --- /dev/null +++ b/library/Icinga/Web/Url.php @@ -0,0 +1,806 @@ +<?php +/* Icinga Web 2 | (c) 2013 Icinga Development Team | GPLv2+ */ + +namespace Icinga\Web; + +use Icinga\Application\Icinga; +use Icinga\Exception\ProgrammingError; +use Icinga\Data\Filter\Filter; + +/** + * Url class that provides convenient access to parameters, allows to modify query parameters and + * returns Urls reflecting all changes made to the url and to the parameters. + * + * Direct instantiation is prohibited and should be done either with @see Url::fromRequest() or + * @see Url::fromPath() + */ +class Url +{ + /** + * Whether this url points to an external resource + * + * @var bool + */ + protected $external; + + /** + * An array of all parameters stored in this Url + * + * @var UrlParams + */ + protected $params; + + /** + * The site anchor after the '#' + * + * @var string + */ + protected $anchor = ''; + + /** + * The relative path of this Url, without query parameters + * + * @var string + */ + protected $path = ''; + + /** + * The basePath of this Url + * + * @var string + */ + protected $basePath; + + /** + * The host of this Url + * + * @var string + */ + protected $host; + + /** + * The port of this Url + * + * @var string + */ + protected $port; + + /** + * The scheme of this Url + * + * @var string + */ + protected $scheme; + + /** + * The username passed with this Url + * + * @var string + */ + protected $username; + + /** + * The password passed with this Url + * + * @var string + */ + protected $password; + + protected function __construct() + { + $this->params = UrlParams::fromQueryString(''); // TODO: ::create() + } + + /** + * Create a new Url class representing the current request + * + * If $params are given, those will be added to the request's parameters + * and overwrite any existing parameters + * + * @param UrlParams|array $params Parameters that should additionally be considered for the url + * @param Request $request A request to use instead of the default one + * + * @return static + */ + public static function fromRequest($params = array(), $request = null) + { + if ($request === null) { + $request = static::getRequest(); + } + + $url = new static(); + $url->setPath(ltrim($request->getPathInfo(), '/')); + + // $urlParams = UrlParams::fromQueryString($request->getQuery()); + if (isset($_SERVER['QUERY_STRING'])) { + $urlParams = UrlParams::fromQueryString($_SERVER['QUERY_STRING']); + } else { + $urlParams = UrlParams::fromQueryString(''); + foreach ($request->getQuery() as $k => $v) { + $urlParams->set($k, $v); + } + } + + foreach ($params as $k => $v) { + $urlParams->set($k, $v); + } + $url->setParams($urlParams); + $url->setBasePath($request->getBaseUrl()); + return $url; + } + + /** + * Return a request object that should be used for determining the URL + * + * @return Request + */ + protected static function getRequest() + { + $app = Icinga::app(); + if ($app->isCli()) { + throw new ProgrammingError( + 'Url::fromRequest and Url::fromPath are currently not supported for CLI operations' + ); + } else { + return $app->getRequest(); + } + } + + /** + * Create a new Url class representing the given url + * + * If $params are given, those will be added to the urls parameters + * and overwrite any existing parameters + * + * @param string $url The string representation of the url to parse + * @param array $params An array of parameters that should additionally be considered for the url + * @param Request $request A request to use instead of the default one + * + * @return static + */ + public static function fromPath($url, array $params = array(), $request = null) + { + if ($request === null) { + $request = static::getRequest(); + } + + if (! is_string($url)) { + throw new ProgrammingError( + 'url %s is not a string', + var_export($url, true) + ); + } + + $urlObject = new static(); + + if ($url === '#') { + $urlObject->setPath($url); + return $urlObject; + } + + $urlParts = parse_url($url); + if (isset($urlParts['scheme']) && ( + $urlParts['scheme'] !== $request->getScheme() + || (isset($urlParts['host']) && $urlParts['host'] !== $request->getServer('SERVER_NAME')) + || (isset($urlParts['port']) && $urlParts['port'] != $request->getServer('SERVER_PORT'))) + ) { + $urlObject->setIsExternal(); + } + + if (isset($urlParts['path'])) { + $urlPath = $urlParts['path']; + if ($urlPath && $urlPath[0] === '/') { + if ($urlObject->isExternal() || isset($urlParts['user'])) { + $urlPath = ltrim($urlPath, '/'); + } else { + $requestBaseUrl = $request->getBaseUrl(); + if ($requestBaseUrl && $requestBaseUrl !== '/' && strpos($urlPath, $requestBaseUrl) === 0) { + $urlPath = ltrim(substr($urlPath, strlen($requestBaseUrl)), '/'); + $urlObject->setBasePath($requestBaseUrl); + } + } + } elseif (! $urlObject->isExternal()) { + $urlObject->setBasePath($request->getBaseUrl()); + } + + $urlObject->setPath($urlPath); + } elseif (! $urlObject->isExternal()) { + $urlObject->setBasePath($request->getBaseUrl()); + } + + // TODO: This has been used by former filter implementation, remove it: + if (isset($urlParts['query'])) { + $params = UrlParams::fromQueryString($urlParts['query'])->mergeValues($params); + } + if (isset($urlParts['fragment'])) { + $urlObject->setAnchor($urlParts['fragment']); + } + + if (isset($urlParts['user']) || $urlObject->isExternal()) { + if (isset($urlParts['user'])) { + $urlObject->setUsername($urlParts['user']); + } + if (isset($urlParts['host'])) { + $urlObject->setHost($urlParts['host']); + } + if (isset($urlParts['port'])) { + $urlObject->setPort($urlParts['port']); + } + if (isset($urlParts['scheme'])) { + $urlObject->setScheme($urlParts['scheme']); + } + if (isset($urlParts['pass'])) { + $urlObject->setPassword($urlParts['pass']); + } + } + + $urlObject->setParams($params); + return $urlObject; + } + + /** + * Create a new filter that needs to fullfill the base filter and the optional filter (if it exists) + * + * @param string $url The url to apply the new filter to + * @param Filter $filter The base filter + * @param ?Filter $optional The optional filter + * + * @return static The altered URL containing the new filter + * @throws ProgrammingError + */ + public static function urlAddFilterOptional($url, $filter, $optional) + { + $url = static::fromPath($url); + $f = $filter; + if (isset($optional)) { + $f = Filter::matchAll($filter, $optional); + } + return $url->setQueryString($f->toQueryString()); + } + + /** + * Add the given filter to the current filter of the URL + * + * @param Filter $and + * + * @return $this + */ + public function addFilter($and) + { + $this->setQueryString( + Filter::fromQueryString($this->getQueryString()) + ->andFilter($and) + ->toQueryString() + ); + return $this; + } + + /** + * Set the basePath for this url + * + * @param string $basePath New basePath of this url + * + * @return $this + */ + public function setBasePath($basePath) + { + $this->basePath = rtrim($basePath, '/ '); + return $this; + } + + /** + * Return the basePath set for this url + * + * @return string + */ + public function getBasePath() + { + return $this->basePath; + } + + /** + * Set the host for this url + * + * @param string $host New host of this Url + * + * @return $this + */ + public function setHost($host) + { + $this->host = $host; + return $this; + } + + /** + * Return the host set for this url + * + * @return string + */ + public function getHost() + { + return $this->host; + } + + /** + * Set the port for this url + * + * @param string $port New port of this url + * + * @return $this + */ + public function setPort($port) + { + $this->port = $port; + return $this; + } + + /** + * Return the port set for this url + * + * @return string + */ + public function getPort() + { + return $this->port; + } + + /** + * Set the scheme for this url + * + * @param string $scheme The scheme used for this url + * + * @return $this + */ + public function setScheme($scheme) + { + $this->scheme = $scheme; + return $this; + } + + /** + * Return the scheme set for this url + * + * @return string + */ + public function getScheme() + { + return $this->scheme; + } + + /** + * Set the relative path of this url, without query parameters + * + * @param string $path The path to set + * + * @return $this + */ + public function setPath($path) + { + $this->path = $path; + return $this; + } + + /** + * Return the relative path of this url, without query parameters + * + * If you want the relative path with query parameters use getRelativeUrl + * + * @return string + */ + public function getPath() + { + return $this->path; + } + + /** + * Set whether this url points to an external resource + * + * @param bool $state + * + * @return $this + */ + public function setIsExternal($state = true) + { + $this->external = (bool) $state; + return $this; + } + + /** + * Return whether this url points to an external resource + * + * @return bool + */ + public function isExternal() + { + return $this->external; + } + + /** + * Set the username passed with this url + * + * @param string $username The username to set + * + * @return $this + */ + public function setUsername($username) + { + $this->username = $username; + return $this; + } + + /** + * Return the username passed with this url + * + * @return string + */ + public function getUsername() + { + return $this->username; + } + + /** + * Set the username passed with this url + * + * @param string $password The password to set + * + * @return $this + */ + public function setPassword($password) + { + $this->password = $password; + return $this; + } + + /** + * Return the password passed with this url + * + * @return string + */ + public function getPassword() + { + return $this->password; + } + + /** + * Return the relative url + * + * @return string + */ + public function getRelativeUrl($separator = '&') + { + $path = $this->buildPathQueryAndFragment($separator); + if ($path && $path[0] === '/') { + return ''; + } + + return $path; + } + + /** + * Return this url's path with its query parameters and fragment as string + * + * @return string + */ + protected function buildPathQueryAndFragment($querySeparator) + { + $anchor = $this->getAnchor(); + if ($anchor) { + $anchor = '#' . $anchor; + } + + $query = $this->getQueryString($querySeparator); + if ($query) { + $query = '?' . $query; + } + + return $this->getPath() . $query . $anchor; + } + + public function setQueryString($queryString) + { + $this->params = UrlParams::fromQueryString($queryString); + return $this; + } + + public function getQueryString($separator = null) + { + return $this->params->toString($separator); + } + + /** + * Return the absolute url with query parameters as a string + * + * @return string + */ + public function getAbsoluteUrl($separator = '&') + { + $path = $this->buildPathQueryAndFragment($separator); + if ($path && ($path === '#' || $path[0] === '/')) { + return $path; + } + + $basePath = $this->getBasePath(); + if (! $basePath) { + $basePath = '/'; + } + + if ($this->getUsername() || $this->isExternal()) { + $urlString = ''; + if ($this->getScheme()) { + $urlString .= $this->getScheme() . '://'; + } + if ($this->getPassword()) { + $urlString .= $this->getUsername() . ':' . $this->getPassword() . '@'; + } elseif ($this->getUsername()) { + $urlString .= $this->getUsername() . '@'; + } + if ($this->getHost()) { + $urlString .= $this->getHost(); + } + if ($this->getPort()) { + $urlString .= ':' . $this->getPort(); + } + + return $urlString . $basePath . ($basePath !== '/' && $path ? '/' : '') . $path; + } else { + return $basePath . ($basePath !== '/' && $path ? '/' : '') . $path; + } + } + + /** + * Add a set of parameters to the query part if the keys don't exist yet + * + * @param array $params The parameters to add + * + * @return $this + */ + public function addParams(array $params) + { + foreach ($params as $k => $v) { + $this->params->add($k, $v); + } + + return $this; + } + + /** + * Set and overwrite the given params if one if the same key already exists + * + * @param array $params The parameters to set + * + * @return $this + */ + public function overwriteParams(array $params) + { + foreach ($params as $k => $v) { + $this->params->set($k, $v); + } + + return $this; + } + + /** + * Overwrite the parameters used in the query part + * + * @param UrlParams|array $params The new parameters to use for the query part + * + * @return $this + */ + public function setParams($params) + { + if ($params instanceof UrlParams) { + $this->params = $params; + } elseif (is_array($params)) { + $urlParams = UrlParams::fromQueryString(''); + foreach ($params as $k => $v) { + $urlParams->set($k, $v); + } + $this->params = $urlParams; + } else { + throw new ProgrammingError( + 'Url params needs to be either an array or an UrlParams instance' + ); + } + return $this; + } + + /** + * Return all parameters that will be used in the query part + * + * @return UrlParams An instance of UrlParam containing all parameters + */ + public function getParams() + { + return $this->params; + } + + /** + * Return true if a urls' query parameter exists, otherwise false + * + * @param string $param The url parameter name to check + * + * @return bool + */ + public function hasParam($param) + { + return $this->params->has($param); + } + + /** + * Return a url's query parameter if it exists, otherwise $default + * + * @param string $param A query parameter name to return if existing + * @param mixed $default A value to return when the parameter doesn't exist + * + * @return mixed + */ + public function getParam($param, $default = null) + { + return $this->params->get($param, $default); + } + + /** + * Set a single parameter, overwriting any existing one with the same name + * + * @param string $param The query parameter name + * @param array|string|bool $value An array or string to set as the parameter value + * + * @return $this + */ + public function setParam($param, $value = true) + { + $this->params->set($param, $value); + return $this; + } + + /** + * Set the url anchor-part + * + * @param string $anchor The site's anchor string without the '#' + * + * @return $this + */ + public function setAnchor($anchor) + { + $this->anchor = $anchor; + return $this; + } + + /** + * Return the url anchor-part + * + * @return string The site's anchor string without the '#' + */ + public function getAnchor() + { + return $this->anchor; + } + + /** + * Remove provided key (if string) or keys (if array of string) from the query parameter array + * + * @param string|array $keyOrArrayOfKeys An array of strings or a string representing the key(s) + * of the parameters to be removed + * @return $this + */ + public function remove($keyOrArrayOfKeys) + { + $this->params->remove($keyOrArrayOfKeys); + return $this; + } + + /** + * Shift a query parameter from this URL if it exists, otherwise $default + * + * @param string $param Parameter name + * @param mixed $default Default value in case $param does not exist + * + * @return mixed + */ + public function shift($param, $default = null) + { + return $this->params->shift($param, $default); + } + + /** + * Whether the given URL matches this URL object + * + * This does an exact match, parameters MUST be in the same order + * + * @param Url|string $url the URL to compare against + * + * @return bool whether the URL matches + */ + public function matches($url) + { + if (! $url instanceof static) { + $url = static::fromPath($url); + } + return (string) $url === (string) $this; + } + + /** + * Return a copy of this url without the parameter given + * + * The argument can be either a single query parameter name or an array of parameter names to + * remove from the query list + * + * @param string|array $keyOrArrayOfKeys A single string or an array containing parameter names + * + * @return static + */ + public function getUrlWithout($keyOrArrayOfKeys) + { + return $this->without($keyOrArrayOfKeys); + } + + public function without($keyOrArrayOfKeys) + { + $url = clone($this); + $url->remove($keyOrArrayOfKeys); + return $url; + } + + /** + * Return a copy of this url with the given parameter(s) + * + * The argument can be either a single query parameter name or an array of parameter names to + * remove from the query list + * + * @param string|array $param A single string or an array containing parameter names + * @param mixed $values an optional values array + * + * @return static + */ + public function with($param, $values = null) + { + $url = clone($this); + $url->params->mergeValues($param, $values); + return $url; + } + + /** + * Return a copy of this url with only the given parameter(s) + * + * The argument can be either a single query parameter name or + * an array of parameter names to keep on on the query + * + * @param string|array $keyOrArrayOfKeys + * + * @return static + */ + public function onlyWith($keyOrArrayOfKeys) + { + if (! is_array($keyOrArrayOfKeys)) { + $keyOrArrayOfKeys = [$keyOrArrayOfKeys]; + } + + $url = clone $this; + foreach ($url->getParams()->toArray(false) as $param => $value) { + if (is_int($param)) { + $param = $value; + } + + if (! in_array($param, $keyOrArrayOfKeys, true)) { + $url->remove($param); + } + } + + return $url; + } + + public function __clone() + { + $this->params = clone $this->params; + } + + /** + * Alias for @see Url::getAbsoluteUrl() + * + * @return string + */ + public function __toString() + { + return htmlspecialchars($this->getAbsoluteUrl(), ENT_COMPAT | ENT_SUBSTITUTE | ENT_HTML5, 'UTF-8', true); + } +} diff --git a/library/Icinga/Web/UrlParams.php b/library/Icinga/Web/UrlParams.php new file mode 100644 index 0000000..2265235 --- /dev/null +++ b/library/Icinga/Web/UrlParams.php @@ -0,0 +1,433 @@ +<?php +/* Icinga Web 2 | (c) 2014 Icinga Development Team | GPLv2+ */ + +namespace Icinga\Web; + +use Icinga\Exception\MissingParameterException; + +class UrlParams +{ + protected $separator = '&'; + + protected $params = array(); + + protected $index = array(); + + public function isEmpty() + { + return empty($this->index); + } + + public function setSeparator($separator) + { + $this->separator = $separator; + return $this; + } + + /** + * Get the given parameter + * + * Returns the last URL param if defined multiple times, $default if not + * given at all + * + * @param string $param The parameter you're interested in + * @param string|int|bool|null $default An optional default value + * + * @return mixed + */ + public function get($param, $default = null) + { + if (! $this->has($param)) { + return $default; + } + + return rawurldecode($this->params[ end($this->index[$param]) ][ 1 ]); + } + + /** + * Require a parameter + * + * @param string $name Name of the parameter + * @param bool $strict Whether the parameter's value must not be the empty string + * + * @return mixed + * + * @throws MissingParameterException If the parameter was not given + */ + public function getRequired($name, $strict = true) + { + if ($this->has($name)) { + $value = $this->get($name); + if (! $strict || strlen($value) > 0) { + return $value; + } + } + $e = new MissingParameterException(t('Required parameter \'%s\' missing'), $name); + $e->setParameter($name); + throw $e; + } + + /** + * Get all instances of the given parameter + * + * Returns an array containing all values defined for a given parameter, + * $default if none. + * + * @param string $param The parameter you're interested in + * @param array $default An optional default value + * + * @return mixed + */ + public function getValues($param, $default = array()) + { + if (! $this->has($param)) { + return $default; + } + + $ret = array(); + foreach ($this->index[$param] as $key) { + $ret[] = rawurldecode($this->params[$key][1]); + } + return $ret; + } + + /** + * Whether the given parameter exists + * + * Returns true if such a parameter has been defined, false otherwise. + * + * @param string $param The parameter you're interested in + * + * @return boolean + */ + public function has($param) + { + return array_key_exists($param, $this->index); + } + + /** + * Get and remove the given parameter + * + * Returns the last URL param if defined multiple times, $default if not + * given at all. The parameter will be removed from this object. + * + * @param string $param The parameter you're interested in + * @param string $default An optional default value + * + * @return mixed + */ + public function shift($param = null, $default = null) + { + if ($param === null) { + if (empty($this->params)) { + return $default; + } + $ret = array_shift($this->params); + $ret[0] = rawurldecode($ret[0]); + $ret[1] = rawurldecode($ret[1]); + } else { + if (! $this->has($param)) { + return $default; + } + $key = reset($this->index[$param]); + $ret = rawurldecode($this->params[$key][1]); + unset($this->params[$key]); + } + + $this->reIndexAll(); + return $ret; + } + + /** + * Require and remove a parameter + * + * @param string $name Name of the parameter + * @param bool $strict Whether the parameter's value must not be the empty string + * + * @return mixed + * + * @throws MissingParameterException If the parameter was not given + */ + public function shiftRequired($name, $strict = true) + { + if ($this->has($name)) { + $value = $this->get($name); + if (! $strict || strlen($value) > 0) { + $this->shift($name); + return $value; + } + } + $e = new MissingParameterException(t('Required parameter \'%s\' missing'), $name); + $e->setParameter($name); + throw $e; + } + + public function addEncoded($param, $value = true) + { + $this->params[] = array($param, $this->cleanupValue($value)); + $this->indexLastOne(); + return $this; + } + + protected function urlEncode($value) + { + return rawurlencode($value instanceof Url ? $value->getAbsoluteUrl() : (string) $value); + } + + /** + * Add the given parameter with the given value + * + * This will add the given parameter, regardless of whether it already + * exists. + * + * @param string $param The parameter you're interested in + * @param string|bool $value The value to be stored + * + * @return $this + */ + public function add($param, $value = true) + { + return $this->addEncoded($this->urlEncode($param), $this->urlEncode($value)); + } + + /** + * Adds a list of parameters + * + * This may be used with either a list of values for a single parameter or + * with a list of parameter / value pairs. + * + * @param string|array $param Parameter name or param/value list + * @param ?array $value The value to be stored + * + * @return $this + */ + public function addValues($param, $values = null) + { + if ($values === null && is_array($param)) { + foreach ($param as $k => $v) { + $this->add($k, $v); + } + } else { + foreach ($values as $value) { + $this->add($param, $value); + } + } + + return $this; + } + + protected function clearValues() + { + $this->params = array(); + $this->index = array(); + } + + public function mergeValues($param, $values = null) + { + if ($values === null && is_array($param)) { + foreach ($param as $k => $v) { + $this->set($k, $v); + } + } else { + if (! is_array($values)) { + $values = array($values); + } + foreach ($values as $value) { + $this->set($param, $value); + } + } + + return $this; + } + + public function setValues($param, $values = null) + { + $this->clearValues(); + return $this->addValues($param, $values); + } + + /** + * Add the given parameter with the given value in front of all other values + * + * This will add the given parameter in front of all others, regardless of + * whether it already exists. + * + * @param string $param The parameter you're interested in + * @param string $value The value to be stored + * + * @return $this + */ + public function unshift($param, $value) + { + array_unshift($this->params, array($this->urlEncode($param), $this->urlEncode($value))); + $this->reIndexAll(); + return $this; + } + + /** + * Set the given parameter with the given value + * + * This will set the given parameter, and override eventually existing ones. + * + * @param string $param The parameter you want to set + * @param string $value The value to be stored + * + * @return $this + */ + public function set($param, $value) + { + if (! $this->has($param)) { + return $this->add($param, $value); + } + + while (count($this->index[$param]) > 1) { + $remove = array_pop($this->index[$param]); + unset($this->params[$remove]); + } + + $this->params[$this->index[$param][0]] = array( + $this->urlEncode($param), + $this->urlEncode($this->cleanupValue($value)) + ); + $this->reIndexAll(); + + return $this; + } + + public function remove($param) + { + $changed = false; + + if (! is_array($param)) { + $param = array($param); + } + + foreach ($param as $p) { + if ($this->has($p)) { + foreach ($this->index[$p] as $key) { + unset($this->params[$key]); + } + $changed = true; + } + } + + if ($changed) { + $this->reIndexAll(); + } + + return $this; + } + + public function without($param) + { + $params = clone $this; + return $params->remove($param); + } + + // TODO: push, pop? + + protected function indexLastOne() + { + end($this->params); + $key = key($this->params); + $param = $this->params[$key][0]; + $this->addParamToIndex($param, $key); + } + + protected function addParamToIndex($param, $key) + { + if (! $this->has($param)) { + $this->index[$param] = array(); + } + $this->index[$param][] = $key; + } + + protected function reIndexAll() + { + $this->index = array(); + $this->params = array_values($this->params); + foreach ($this->params as $key => & $param) { + $this->addParamToIndex($param[0], $key); + } + } + + protected function cleanupValue($value) + { + return is_bool($value) ? $value : (string) $value; + } + + protected function parseQueryString($queryString) + { + $parts = preg_split('~&~', $queryString, -1, PREG_SPLIT_NO_EMPTY); + foreach ($parts as $part) { + $this->parseQueryStringPart($part); + } + } + + protected function parseQueryStringPart($part) + { + if (strpos($part, '=') === false) { + $this->addEncoded($part, true); + } else { + list($key, $val) = preg_split('/=/', $part, 2); + $this->addEncoded($key, $val); + } + } + + /** + * Return the parameters of this url as sequenced or associative array + * + * @param bool $sequenced + * + * @return array + */ + public function toArray($sequenced = true) + { + if ($sequenced) { + return $this->params; + } + + $params = array(); + foreach ($this->params as $param) { + if ($param[1] === true) { + $params[] = $param[0]; + } else { + $params[$param[0]] = $param[1]; + } + } + + return $params; + } + + public function toString($separator = null) + { + if ($separator === null) { + $separator = $this->separator; + } + $parts = array(); + foreach ($this->params as $p) { + if ($p[1] === true) { + $parts[] = $p[0]; + } else { + $parts[] = $p[0] . '=' . $p[1]; + } + } + return implode($separator, $parts); + } + + public function __toString() + { + return $this->toString(); + } + + public static function fromQueryString($queryString = null) + { + if ($queryString === null) { + $queryString = isset($_SERVER['QUERY_STRING']) ? $_SERVER['QUERY_STRING'] : ''; + } + $params = new static(); + $params->parseQueryString($queryString); + + return $params; + } +} diff --git a/library/Icinga/Web/UserAgent.php b/library/Icinga/Web/UserAgent.php new file mode 100644 index 0000000..71c1a8b --- /dev/null +++ b/library/Icinga/Web/UserAgent.php @@ -0,0 +1,86 @@ +<?php +/* Icinga Web 2 | (c) 2021 Icinga GmbH | GPLv2+ */ + +namespace Icinga\Web; + +/** + * Class UserAgent + * + * This class helps to get user agent information like OS type and browser name + * + * @package Icinga\Web + */ +class UserAgent +{ + /** + * $_SERVER['HTTP_USER_AGENT'] output string + * + * @var string|null + */ + private $agent; + + public function __construct($agent = null) + { + $this->agent = isset($_SERVER['HTTP_USER_AGENT']) ? $_SERVER['HTTP_USER_AGENT'] : null; + + if ($agent) { + $this->agent = $agent->http_user_agent; + } + } + + /** + * Return $_SERVER['HTTP_USER_AGENT'] output string of given or current device + * + * @return string + */ + public function getAgent() + { + return $this->agent; + } + + /** + * Get Browser name + * + * @return string Browser name or unknown if not found + */ + public function getBrowser() + { + // key => regex value + $browsers = [ + "Internet Explorer" => "/MSIE(.*)/i", + "Seamonkey" => "/Seamonkey(.*)/i", + "MS Edge" => "/Edg(.*)/i", + "Opera" => "/Opera(.*)/i", + "Opera Browser" => "/OPR(.*)/i", + "Chromium" => "/Chromium(.*)/i", + "Firefox" => "/Firefox(.*)/i", + "Google Chrome" => "/Chrome(.*)/i", + "Safari" => "/Safari(.*)/i" + ]; + //TODO find a way to return also the version of the browser + foreach ($browsers as $browser => $regex) { + if (preg_match($regex, $this->agent)) { + return $browser; + } + } + + return 'unknown'; + } + + /** + * Get Operating system information + * + * @return string os information + */ + public function getOs() + { + // get string before the first appearance of ')' + $device = strstr($this->agent, ')', true); + if (! $device) { + return 'unknown'; + } + + // return string after the first appearance of '(' + return substr($device, strpos($device, '(') + 1); + } +} diff --git a/library/Icinga/Web/View.php b/library/Icinga/Web/View.php new file mode 100644 index 0000000..2c80d1d --- /dev/null +++ b/library/Icinga/Web/View.php @@ -0,0 +1,254 @@ +<?php +/* Icinga Web 2 | (c) 2013 Icinga Development Team | GPLv2+ */ + +namespace Icinga\Web; + +use Closure; +use Icinga\Application\Icinga; +use ipl\I18n\Translation; +use Zend_View_Abstract; +use Icinga\Authentication\Auth; +use Icinga\Exception\ProgrammingError; + +/** + * Icinga view + * + * @method Url href($path = null, $params = null) { + * @param Url|string|null $path + * @param string[]|null $params + * } + * + * @method Url url($path = null, $params = null) { + * @param Url|string|null $path + * @param string[]|null $params + * } + * + * @method Url qlink($title, $url, $params = null, $properties = null, $escape = true) { + * @param string $title + * @param Url|string|null $url + * @param string[]|null $params + * @param string[]|null $properties + * @param bool $escape + * } + * + * @method string img($url, $params = null, array $properties = array()) { + * @param Url|string|null $url + * @param string[]|null $params + * @param string[] $properties + * } + * + * @method string icon($img, $title = null, array $properties = array()) { + * @param string $img + * @param string|null $title + * @param string[] $properties + * } + * + * @method string propertiesToString($properties) { + * @param string[] $properties + * } + * + * @method string attributeToString($key, $value) { + * @param string $key + * @param string $value + * } + */ +class View extends Zend_View_Abstract +{ + use Translation; + + /** + * Charset to be used - we only support UTF-8 + */ + const CHARSET = 'UTF-8'; + + /** + * Registered helper functions + */ + private $helperFunctions = array(); + + /** + * Authentication manager + * + * @var Auth|null + */ + private $auth; + + /** + * Create a new view object + * + * @param array $config + * @see Zend_View_Abstract::__construct + */ + public function __construct($config = array()) + { + $config['helperPath']['Icinga\\Web\\View\\Helper\\'] = Icinga::app()->getLibraryDir('Icinga/Web/View/Helper'); + + parent::__construct($config); + } + + /** + * Initialize the view + * + * @see Zend_View_Abstract::init + */ + public function init() + { + $this->loadGlobalHelpers(); + } + + /** + * Escape the given value top be safely used in view scripts + * + * @param ?string $var The output to be escaped + * @return string + */ + public function escape($var) + { + return htmlspecialchars($var ?? '', ENT_COMPAT | ENT_SUBSTITUTE | ENT_HTML5, self::CHARSET, true); + } + + /** + * Whether a specific helper (closure) has been registered + * + * @param string $name The desired function name + * @return boolean + */ + public function hasHelperFunction($name) + { + return array_key_exists($name, $this->helperFunctions); + } + + /** + * Add a new helper function + * + * @param string $name The desired function name + * @param Closure $function An anonymous function + * @return $this + */ + public function addHelperFunction($name, Closure $function) + { + if ($this->hasHelperFunction($name)) { + throw new ProgrammingError( + 'Cannot assign the same helper function twice: "%s"', + $name + ); + } + + $this->helperFunctions[$name] = $function; + return $this; + } + + /** + * Set or overwrite a helper function + * + * @param string $name + * @param Closure $function + * + * @return $this + */ + public function setHelperFunction($name, Closure $function) + { + $this->helperFunctions[$name] = $function; + return $this; + } + + /** + * Drop a helper function + * + * @param string $name + * + * @return $this + */ + public function dropHelperFunction($name) + { + unset($this->helperFunctions[$name]); + return $this; + } + + /** + * Call a helper function + * + * @param string $name The desired function name + * @param Array $args Function arguments + * @return mixed + */ + public function callHelperFunction($name, $args) + { + return call_user_func_array( + $this->helperFunctions[$name], + $args + ); + } + + /** + * Load helpers + */ + private function loadGlobalHelpers() + { + $pattern = dirname(__FILE__) . '/View/helpers/*.php'; + $files = glob($pattern); + foreach ($files as $file) { + require_once $file; + } + } + + /** + * Get the authentication manager + * + * @return Auth + */ + public function Auth() + { + if ($this->auth === null) { + $this->auth = Auth::getInstance(); + } + return $this->auth; + } + + /** + * Whether the current user has the given permission + * + * @param string $permission Name of the permission + * + * @return bool + */ + public function hasPermission($permission) + { + return $this->Auth()->hasPermission($permission); + } + + /** + * Use to include the view script in a scope that only allows public + * members. + * + * @return mixed + * + * @see Zend_View_Abstract::run + */ + protected function _run() + { + foreach ($this->getVars() as $k => $v) { + // Exporting global variables to view scripts: + $$k = $v; + } + + include func_get_arg(0); + } + + /** + * Accesses a helper object from within a script + * + * @param string $name + * @param array $args + * + * @return string + */ + public function __call($name, $args) + { + if ($this->hasHelperFunction($name)) { + return $this->callHelperFunction($name, $args); + } else { + return parent::__call($name, $args); + } + } +} diff --git a/library/Icinga/Web/View/AppHealth.php b/library/Icinga/Web/View/AppHealth.php new file mode 100644 index 0000000..c66ca05 --- /dev/null +++ b/library/Icinga/Web/View/AppHealth.php @@ -0,0 +1,89 @@ +<?php +/* Icinga Web 2 | (c) 2021 Icinga GmbH | GPLv2+ */ + +namespace Icinga\Web\View; + +use Icinga\Application\Hook\HealthHook; +use ipl\Html\FormattedString; +use ipl\Html\HtmlElement; +use ipl\Html\Table; +use ipl\Web\Common\BaseTarget; +use ipl\Web\Widget\Link; +use Traversable; + +class AppHealth extends Table +{ + use BaseTarget; + + protected $defaultAttributes = ['class' => ['app-health', 'common-table', 'table-row-selectable']]; + + /** @var Traversable */ + protected $data; + + public function __construct(Traversable $data) + { + $this->data = $data; + + $this->setBaseTarget('_next'); + } + + protected function assemble() + { + foreach ($this->data as $row) { + $this->add(Table::tr([ + Table::th(HtmlElement::create('span', ['class' => [ + 'ball', + 'ball-size-xl', + $this->getStateClass($row->state) + ]])), + Table::td([ + new HtmlElement('header', null, FormattedString::create( + t('%s by %s is %s', '<check> by <module> is <state-text>'), + $row->url + ? new Link(HtmlElement::create('span', null, $row->name), $row->url) + : HtmlElement::create('span', null, $row->name), + HtmlElement::create('span', null, $row->module), + HtmlElement::create('span', null, $this->getStateText($row->state)) + )), + HtmlElement::create('section', null, $row->message) + ]) + ])); + } + } + + protected function getStateClass($state) + { + if ($state === null) { + $state = HealthHook::STATE_UNKNOWN; + } + + switch ($state) { + case HealthHook::STATE_OK: + return 'state-ok'; + case HealthHook::STATE_WARNING: + return 'state-warning'; + case HealthHook::STATE_CRITICAL: + return 'state-critical'; + case HealthHook::STATE_UNKNOWN: + return 'state-unknown'; + } + } + + protected function getStateText($state) + { + if ($state === null) { + $state = t('UNKNOWN'); + } + + switch ($state) { + case HealthHook::STATE_OK: + return t('OK'); + case HealthHook::STATE_WARNING: + return t('WARNING'); + case HealthHook::STATE_CRITICAL: + return t('CRITICAL'); + case HealthHook::STATE_UNKNOWN: + return t('UNKNOWN'); + } + } +} diff --git a/library/Icinga/Web/View/Helper/IcingaCheckbox.php b/library/Icinga/Web/View/Helper/IcingaCheckbox.php new file mode 100644 index 0000000..07cf01f --- /dev/null +++ b/library/Icinga/Web/View/Helper/IcingaCheckbox.php @@ -0,0 +1,30 @@ +<?php +/* Icinga Web 2 | (c) 2019 Icinga GmbH | GPLv2+ */ + +namespace Icinga\Web\View\Helper; + +class IcingaCheckbox extends \Zend_View_Helper_FormCheckbox +{ + public function icingaCheckbox($name, $value = null, $attribs = null, array $checkedOptions = null) + { + if (! isset($attribs['id'])) { + $attribs['id'] = $this->view->protectId('icingaCheckbox_' . $name); + } + + $attribs['class'] = (isset($attribs['class']) ? $attribs['class'] . ' ' : '') . 'sr-only'; + $html = parent::formCheckbox($name, $value, $attribs, $checkedOptions); + + $class = 'toggle-switch'; + if (isset($attribs['disabled'])) { + $class .= ' disabled'; + } + + return $html + . '<label for="' + . $attribs['id'] + . '" aria-hidden="true"' + . ' class="' + . $class + . '"><span class="toggle-slider"></span></label>'; + } +} diff --git a/library/Icinga/Web/View/PrivilegeAudit.php b/library/Icinga/Web/View/PrivilegeAudit.php new file mode 100644 index 0000000..fcb4083 --- /dev/null +++ b/library/Icinga/Web/View/PrivilegeAudit.php @@ -0,0 +1,622 @@ +<?php +/* Icinga Web 2 | (c) 2021 Icinga GmbH | GPLv2+ */ + +namespace Icinga\Web\View; + +use Icinga\Authentication\Role; +use Icinga\Forms\Security\RoleForm; +use Icinga\Util\StringHelper; +use ipl\Html\Attributes; +use ipl\Html\BaseHtmlElement; +use ipl\Html\HtmlElement; +use ipl\Html\HtmlString; +use ipl\Html\Text; +use ipl\Stdlib\Filter; +use ipl\Web\Common\BaseTarget; +use ipl\Web\Filter\QueryString; +use ipl\Web\Url; +use ipl\Web\Widget\Icon; +use ipl\Web\Widget\Link; + +class PrivilegeAudit extends BaseHtmlElement +{ + use BaseTarget; + + /** @var string */ + const UNRESTRICTED_PERMISSION = 'unrestricted'; + + protected $tag = 'ul'; + + protected $defaultAttributes = ['class' => 'privilege-audit']; + + /** @var Role[] */ + protected $roles; + + public function __construct(array $roles) + { + $this->roles = $roles; + $this->setBaseTarget('_next'); + } + + protected function auditPermission($permission) + { + $grantedBy = []; + $refusedBy = []; + foreach ($this->roles as $role) { + if ($permission === self::UNRESTRICTED_PERMISSION) { + if ($role->isUnrestricted()) { + $grantedBy[] = $role->getName(); + } + } elseif ($role->denies($permission)) { + $refusedBy[] = $role->getName(); + } elseif ($role->grants($permission, false, false)) { + $grantedBy[] = $role->getName(); + } + } + + $header = new HtmlElement('summary'); + if (! empty($refusedBy)) { + $header->add([ + new Icon('times-circle', ['class' => 'refused']), + count($refusedBy) > 2 + ? sprintf( + tp( + 'Refused by %s and %s as well as one other', + 'Refused by %s and %s as well as %d others', + count($refusedBy) - 2 + ), + $refusedBy[0], + $refusedBy[1], + count($refusedBy) - 2 + ) + : sprintf( + tp('Refused by %s', 'Refused by %s and %s', count($refusedBy)), + ...$refusedBy + ) + ]); + } elseif (! empty($grantedBy)) { + $header->add([ + new Icon('check-circle', ['class' => 'granted']), + count($grantedBy) > 2 + ? sprintf( + tp( + 'Granted by %s and %s as well as one other', + 'Granted by %s and %s as well as %d others', + count($grantedBy) - 2 + ), + $grantedBy[0], + $grantedBy[1], + count($grantedBy) - 2 + ) + : sprintf( + tp('Granted by %s', 'Granted by %s and %s', count($grantedBy)), + ...$grantedBy + ) + ]); + } else { + $header->add([new Icon('minus-circle'), t('Not granted or refused by any role')]); + } + + $vClass = null; + $rolePaths = []; + foreach (array_reverse($this->roles) as $role) { + if (! in_array($role->getName(), $refusedBy, true) && ! in_array($role->getName(), $grantedBy, true)) { + continue; + } + + /** @var Role[] $rolesReversed */ + $rolesReversed = []; + + do { + array_unshift($rolesReversed, $role); + } while (($role = $role->getParent()) !== null); + + $path = new HtmlElement('ol'); + + $class = null; + $setInitiator = false; + foreach ($rolesReversed as $role) { + $granted = false; + $refused = false; + $icon = new Icon('minus-circle'); + if ($permission === self::UNRESTRICTED_PERMISSION) { + if ($role->isUnrestricted()) { + $granted = true; + $icon = new Icon('check-circle', ['class' => 'granted']); + } + } elseif ($role->denies($permission, true)) { + $refused = true; + $icon = new Icon('times-circle', ['class' => 'refused']); + } elseif ($role->grants($permission, true, false)) { + $granted = true; + $icon = new Icon('check-circle', ['class' => 'granted']); + } + + $connector = null; + if ($role->getParent() !== null) { + $connector = HtmlElement::create('li', ['class' => ['connector', $class]]); + if ($setInitiator) { + $setInitiator = false; + $connector->getAttributes()->add('class', 'initiator'); + } + + $path->prependHtml($connector); + } + + $path->prependHtml(new HtmlElement('li', Attributes::create([ + 'class' => ['role', $class], + 'title' => $role->getName() + ]), new Link([$icon, $role->getName()], Url::fromPath('role/edit', ['role' => $role->getName()])))); + + if ($refused) { + $setInitiator = $class !== 'refused'; + $class = 'refused'; + } elseif ($granted) { + $setInitiator = $class === null; + $class = $class ?: 'granted'; + } + } + + if ($vClass === null || $vClass === 'granted') { + $vClass = $class; + } + + array_unshift($rolePaths, $path->prepend([ + empty($rolePaths) ? null : HtmlElement::create('li', ['class' => ['vertical-line', $vClass]]), + new HtmlElement('li', Attributes::create(['class' => [ + 'connector', + $class, + $setInitiator ? 'initiator' : null + ]])) + ])); + } + + if (empty($rolePaths)) { + return [ + empty($refusedBy) ? (empty($grantedBy) ? null : true) : false, + new HtmlElement( + 'div', + Attributes::create(['class' => 'inheritance-paths']), + $header->setTag('div') + ) + ]; + } + + return [ + empty($refusedBy) ? (empty($grantedBy) ? null : true) : false, + HtmlElement::create('details', [ + 'class' => ['collapsible', 'inheritance-paths'], + 'data-no-persistence' => true, + 'open' => getenv('ICINGAWEB_EXPORT_FORMAT') === 'pdf' + ], [ + $header->addAttributes(['class' => 'collapsible-control']), + $rolePaths + ]) + ]; + } + + protected function auditRestriction($restriction) + { + $restrictedBy = []; + $restrictions = []; + foreach ($this->roles as $role) { + if ($role->isUnrestricted()) { + $restrictedBy = []; + $restrictions = []; + break; + } + + foreach ($this->collectRestrictions($role, $restriction) as $role => $roleRestriction) { + $restrictedBy[] = $role; + $restrictions[] = $roleRestriction; + } + } + + $header = new HtmlElement('summary'); + if (! empty($restrictedBy)) { + $header->add([ + new Icon('filter', ['class' => 'restricted']), + count($restrictedBy) > 2 + ? sprintf( + tp( + 'Restricted by %s and %s as well as one other', + 'Restricted by %s and %s as well as %d others', + count($restrictedBy) - 2 + ), + $restrictedBy[0]->getName(), + $restrictedBy[1]->getName(), + count($restrictedBy) - 2 + ) + : sprintf( + tp('Restricted by %s', 'Restricted by %s and %s', count($restrictedBy)), + ...array_map(function ($role) { + return $role->getName(); + }, $restrictedBy) + ) + ]); + } else { + $header->add([new Icon('filter'), t('Not restricted by any role')]); + } + + $roles = []; + if (! empty($restrictions) && count($restrictions) > 1) { + list($combinedRestrictions, $combinedLinks) = $this->createRestrictionLinks($restriction, $restrictions); + $roles[] = HtmlElement::create('li', null, [ + new HtmlElement( + 'div', + Attributes::create(['class' => 'flex-overflow']), + HtmlElement::create('span', [ + 'class' => 'role', + 'title' => t('All roles combined') + ], join(' | ', array_map(function ($role) { + return $role->getName(); + }, $restrictedBy))), + HtmlElement::create('code', ['class' => 'restriction'], $combinedRestrictions) + ), + $combinedLinks ? new HtmlElement( + 'div', + Attributes::create(['class' => 'previews']), + HtmlElement::create('em', null, t('Previews:')), + $combinedLinks + ) : null + ]); + } + + foreach ($restrictedBy as $role) { + list($roleRestriction, $restrictionLinks) = $this->createRestrictionLinks( + $restriction, + [$role->getRestrictions($restriction)] + ); + + $roles[] = HtmlElement::create('li', null, [ + new HtmlElement( + 'div', + Attributes::create(['class' => 'flex-overflow']), + new Link($role->getName(), Url::fromPath('role/edit', ['role' => $role->getName()]), [ + 'class' => 'role', + 'title' => $role->getName() + ]), + HtmlElement::create('code', ['class' => 'restriction'], $roleRestriction) + ), + $restrictionLinks ? new HtmlElement( + 'div', + Attributes::create(['class' => 'previews']), + HtmlElement::create('em', null, t('Previews:')), + $restrictionLinks + ) : null + ]); + } + + if (empty($roles)) { + return [ + ! empty($restrictedBy), + new HtmlElement( + 'div', + Attributes::create(['class' => 'restrictions']), + $header->setTag('div') + ) + ]; + } + + return [ + ! empty($restrictedBy), + new HtmlElement( + 'details', + Attributes::create([ + 'class' => ['collapsible', 'restrictions'], + 'data-no-persistence' => true, + 'open' => getenv('ICINGAWEB_EXPORT_FORMAT') === 'pdf' + ]), + $header->addAttributes(['class' => 'collapsible-control']), + new HtmlElement('ul', null, ...$roles) + ) + ]; + } + + protected function assemble() + { + list($permissions, $restrictions) = RoleForm::collectProvidedPrivileges(); + list($wildcardState, $wildcardAudit) = $this->auditPermission('*'); + list($unrestrictedState, $unrestrictedAudit) = $this->auditPermission(self::UNRESTRICTED_PERMISSION); + + $this->addHtml(new HtmlElement( + 'li', + null, + new HtmlElement( + 'details', + Attributes::create([ + 'class' => ['collapsible', 'privilege-section'], + 'open' => ($wildcardState || $unrestrictedState) && getenv('ICINGAWEB_EXPORT_FORMAT') === 'pdf' + ]), + new HtmlElement( + 'summary', + Attributes::create(['class' => [ + 'collapsible-control', // Helps JS, improves performance a bit + ]]), + new HtmlElement('span', null, Text::create(t('Administrative Privileges'))), + HtmlElement::create( + 'span', + ['class' => 'audit-preview'], + $wildcardState || $unrestrictedState + ? new Icon('check-circle', ['class' => 'granted']) + : null + ), + new Icon('angles-down', ['class' => 'collapse-icon']), + new Icon('angles-left', ['class' => 'expand-icon']) + ), + new HtmlElement( + 'ol', + Attributes::create(['class' => 'privilege-list']), + new HtmlElement( + 'li', + null, + HtmlElement::create('p', ['class' => 'privilege-label'], t('Administrative Access')), + HtmlElement::create('div', ['class' => 'spacer']), + $wildcardAudit + ), + new HtmlElement( + 'li', + null, + HtmlElement::create('p', ['class' => 'privilege-label'], t('Unrestricted Access')), + HtmlElement::create('div', ['class' => 'spacer']), + $unrestrictedAudit + ) + ) + ) + )); + + $privilegeSources = array_unique(array_merge(array_keys($permissions), array_keys($restrictions))); + foreach ($privilegeSources as $source) { + $anythingGranted = false; + $anythingRefused = false; + $anythingRestricted = false; + + $permissionList = new HtmlElement('ol', Attributes::create(['class' => 'privilege-list'])); + foreach (isset($permissions[$source]) ? $permissions[$source] : [] as $permission => $metaData) { + list($permissionState, $permissionAudit) = $this->auditPermission($permission); + if ($permissionState !== null) { + if ($permissionState) { + $anythingGranted = true; + } else { + $anythingRefused = true; + } + } + + $permissionList->addHtml(new HtmlElement( + 'li', + null, + HtmlElement::create( + 'p', + ['class' => 'privilege-label'], + isset($metaData['label']) + ? $metaData['label'] + : array_map(function ($segment) { + return $segment[0] === '/' ? [ + // Adds a zero-width char after each slash to help browsers break onto newlines + new HtmlString('/​'), + HtmlElement::create('span', ['class' => 'no-wrap'], substr($segment, 1)) + ] : HtmlElement::create('em', null, $segment); + }, preg_split( + '~(/[^/]+)~', + $permission, + -1, + PREG_SPLIT_DELIM_CAPTURE|PREG_SPLIT_NO_EMPTY + )) + ), + new HtmlElement('div', Attributes::create(['class' => 'spacer'])), + $permissionAudit + )); + } + + $restrictionList = new HtmlElement('ol', Attributes::create(['class' => 'privilege-list'])); + foreach (isset($restrictions[$source]) ? $restrictions[$source] : [] as $restriction => $metaData) { + list($restrictionState, $restrictionAudit) = $this->auditRestriction($restriction); + if ($restrictionState) { + $anythingRestricted = true; + } + + $restrictionList->addHtml(new HtmlElement( + 'li', + null, + HtmlElement::create( + 'p', + ['class' => 'privilege-label'], + isset($metaData['label']) + ? $metaData['label'] + : array_map(function ($segment) { + return $segment[0] === '/' ? [ + // Adds a zero-width char after each slash to help browsers break onto newlines + new HtmlString('/​'), + HtmlElement::create('span', ['class' => 'no-wrap'], substr($segment, 1)) + ] : HtmlElement::create('em', null, $segment); + }, preg_split( + '~(/[^/]+)~', + $restriction, + -1, + PREG_SPLIT_DELIM_CAPTURE|PREG_SPLIT_NO_EMPTY + )) + ), + new HtmlElement('div', Attributes::create(['class' => 'spacer'])), + $restrictionAudit + )); + } + + if ($source === 'application') { + $label = 'Icinga Web 2'; + } else { + $label = [$source, ' ', HtmlElement::create('em', null, t('Module'))]; + } + + $this->addHtml(new HtmlElement( + 'li', + null, + HtmlElement::create('details', [ + 'class' => ['collapsible', 'privilege-section'], + 'open' => ($anythingGranted || $anythingRefused || $anythingRestricted) + && getenv('ICINGAWEB_EXPORT_FORMAT') === 'pdf' + ], [ + new HtmlElement( + 'summary', + Attributes::create(['class' => [ + 'collapsible-control', // Helps JS, improves performance a bit + ]]), + HtmlElement::create('span', null, $label), + HtmlElement::create('span', ['class' => 'audit-preview'], [ + $anythingGranted ? new Icon('check-circle', ['class' => 'granted']) : null, + $anythingRefused ? new Icon('times-circle', ['class' => 'refused']) : null, + $anythingRestricted ? new Icon('filter', ['class' => 'restricted']) : null + ]), + new Icon('angles-down', ['class' => 'collapse-icon']), + new Icon('angles-left', ['class' => 'expand-icon']) + ), + $permissionList->isEmpty() ? null : [ + HtmlElement::create('h4', null, t('Permissions')), + $permissionList + ], + $restrictionList->isEmpty() ? null : [ + HtmlElement::create('h4', null, t('Restrictions')), + $restrictionList + ] + ]) + )); + } + } + + private function collectRestrictions(Role $role, $restrictionName) + { + do { + $restriction = $role->getRestrictions($restrictionName); + if ($restriction) { + yield $role => $restriction; + } + } while (($role = $role->getParent()) !== null); + } + + private function createRestrictionLinks($restrictionName, array $restrictions) + { + // TODO: Remove this hardcoded mess. Do this based on the restriction's meta data + switch ($restrictionName) { + case 'icingadb/filter/objects': + $filterString = join('|', $restrictions); + $list = new HtmlElement( + 'ul', + Attributes::create(['class' => 'links']), + new HtmlElement('li', null, new Link( + 'icingadb/hosts', + Url::fromPath('icingadb/hosts')->setQueryString($filterString) + )), + new HtmlElement('li', null, new Link( + 'icingadb/services', + Url::fromPath('icingadb/services')->setQueryString($filterString) + )), + new HtmlElement('li', null, new Link( + 'icingadb/hostgroups', + Url::fromPath('icingadb/hostgroups')->setQueryString($filterString) + )), + new HtmlElement('li', null, new Link( + 'icingadb/servicegroups', + Url::fromPath('icingadb/servicegroups')->setQueryString($filterString) + )) + ); + + break; + case 'icingadb/filter/hosts': + $filterString = join('|', $restrictions); + $list = new HtmlElement( + 'ul', + Attributes::create(['class' => 'links']), + new HtmlElement('li', null, new Link( + 'icingadb/hosts', + Url::fromPath('icingadb/hosts')->setQueryString($filterString) + )), + new HtmlElement('li', null, new Link( + 'icingadb/services', + Url::fromPath('icingadb/services')->setQueryString($filterString) + )) + ); + + break; + case 'icingadb/filter/services': + $filterString = join('|', $restrictions); + $list = new HtmlElement( + 'ul', + Attributes::create(['class' => 'links']), + new HtmlElement('li', null, new Link( + 'icingadb/services', + Url::fromPath('icingadb/services')->setQueryString($filterString) + )) + ); + + break; + case 'monitoring/filter/objects': + $filterString = join('|', $restrictions); + $list = new HtmlElement( + 'ul', + Attributes::create(['class' => 'links']), + new HtmlElement('li', null, new Link( + 'monitoring/list/hosts', + Url::fromPath('monitoring/list/hosts')->setQueryString($filterString) + )), + new HtmlElement('li', null, new Link( + 'monitoring/list/services', + Url::fromPath('monitoring/list/services')->setQueryString($filterString) + )), + new HtmlElement('li', null, new Link( + 'monitoring/list/hostgroups', + Url::fromPath('monitoring/list/hostgroups')->setQueryString($filterString) + )), + new HtmlElement('li', null, new Link( + 'monitoring/list/servicegroups', + Url::fromPath('monitoring/list/servicegroups')->setQueryString($filterString) + )) + ); + + break; + case 'application/share/users': + $filter = Filter::any(); + foreach ($restrictions as $roleRestriction) { + $userNames = StringHelper::trimSplit($roleRestriction); + foreach ($userNames as $userName) { + $filter->add(Filter::equal('user_name', $userName)); + } + } + + $filterString = QueryString::render($filter); + $list = new HtmlElement( + 'ul', + Attributes::create(['class' => 'links']), + new HtmlElement('li', null, new Link( + 'user/list', + Url::fromPath('user/list')->setQueryString($filterString) + )) + ); + + break; + case 'application/share/groups': + $filter = Filter::any(); + foreach ($restrictions as $roleRestriction) { + $groupNames = StringHelper::trimSplit($roleRestriction); + foreach ($groupNames as $groupName) { + $filter->add(Filter::equal('group_name', $groupName)); + } + } + + $filterString = QueryString::render($filter); + $list = new HtmlElement( + 'ul', + Attributes::create(['class' => 'links']), + new HtmlElement('li', null, new Link( + 'group/list', + Url::fromPath('group/list')->setQueryString($filterString) + )) + ); + + break; + default: + $filterString = join(', ', $restrictions); + $list = null; + } + + return [$filterString, $list]; + } +} diff --git a/library/Icinga/Web/View/helpers/format.php b/library/Icinga/Web/View/helpers/format.php new file mode 100644 index 0000000..4008583 --- /dev/null +++ b/library/Icinga/Web/View/helpers/format.php @@ -0,0 +1,72 @@ +<?php +/* Icinga Web 2 | (c) 2014 Icinga Development Team | GPLv2+ */ + +namespace Icinga\Web\View; + +use Icinga\Date\DateFormatter; +use Icinga\Util\Format; + +$this->addHelperFunction('format', function () { + return Format::getInstance(); +}); + +$this->addHelperFunction('formatDate', function ($date) { + if (! $date) { + return ''; + } + return DateFormatter::formatDate($date); +}); + +$this->addHelperFunction('formatDateTime', function ($dateTime) { + if (! $dateTime) { + return ''; + } + return DateFormatter::formatDateTime($dateTime); +}); + +$this->addHelperFunction('formatDuration', function ($seconds) { + if (! $seconds) { + return ''; + } + return DateFormatter::formatDuration($seconds); +}); + +$this->addHelperFunction('formatTime', function ($time) { + if (! $time) { + return ''; + } + return DateFormatter::formatTime($time); +}); + +$this->addHelperFunction('timeAgo', function ($time, $timeOnly = false, $requireTime = false) { + if (! $time) { + return ''; + } + return sprintf( + '<span class="relative-time time-ago" title="%s">%s</span>', + DateFormatter::formatDateTime($time), + DateFormatter::timeAgo($time, $timeOnly, $requireTime) + ); +}); + +$this->addHelperFunction('timeSince', function ($time, $timeOnly = false, $requireTime = false) { + if (! $time) { + return ''; + } + return sprintf( + '<span class="relative-time time-since" title="%s">%s</span>', + DateFormatter::formatDateTime($time), + DateFormatter::timeSince($time, $timeOnly, $requireTime) + ); +}); + +$this->addHelperFunction('timeUntil', function ($time, $timeOnly = false, $requireTime = false) { + if (! $time) { + return ''; + } + return sprintf( + '<span class="relative-time time-until" title="%s">%s</span>', + DateFormatter::formatDateTime($time), + DateFormatter::timeUntil($time, $timeOnly, $requireTime) + ); +}); diff --git a/library/Icinga/Web/View/helpers/generic.php b/library/Icinga/Web/View/helpers/generic.php new file mode 100644 index 0000000..bfd3f86 --- /dev/null +++ b/library/Icinga/Web/View/helpers/generic.php @@ -0,0 +1,15 @@ +<?php +/* Icinga Web 2 | (c) 2014 Icinga Development Team | GPLv2+ */ + +namespace Icinga\Web\View; + +use Icinga\Authentication\Auth; +use Icinga\Web\Widget; + +$this->addHelperFunction('auth', function () { + return Auth::getInstance(); +}); + +$this->addHelperFunction('widget', function ($name, $options = null) { + return Widget::create($name, $options); +}); diff --git a/library/Icinga/Web/View/helpers/string.php b/library/Icinga/Web/View/helpers/string.php new file mode 100644 index 0000000..b3f667b --- /dev/null +++ b/library/Icinga/Web/View/helpers/string.php @@ -0,0 +1,36 @@ +<?php +/* Icinga Web 2 | (c) 2015 Icinga Development Team | GPLv2+ */ + +namespace Icinga\Web\View; + +use Icinga\Util\StringHelper; +use Icinga\Web\Helper\Markdown; + +$this->addHelperFunction('ellipsis', function ($string, $maxLength, $ellipsis = '...') { + return StringHelper::ellipsis($string, $maxLength, $ellipsis); +}); + +$this->addHelperFunction('nl2br', function ($string) { + return nl2br(str_replace(array('\r\n', '\r', '\n'), '<br>', $string), false); +}); + +$this->addHelperFunction('markdown', function ($content, $containerAttribs = null) { + if (! isset($containerAttribs['class'])) { + $containerAttribs['class'] = 'markdown'; + } else { + $containerAttribs['class'] .= ' markdown'; + } + + return '<section' . $this->propertiesToString($containerAttribs) . '>' . Markdown::text($content) . '</section>'; +}); + +$this->addHelperFunction('markdownLine', function ($content, $containerAttribs = null) { + if (! isset($containerAttribs['class'])) { + $containerAttribs['class'] = 'markdown inline'; + } else { + $containerAttribs['class'] .= ' markdown inline'; + } + + return '<section' . $this->propertiesToString($containerAttribs) . '>' . + Markdown::line($content) . '</section>'; +}); diff --git a/library/Icinga/Web/View/helpers/url.php b/library/Icinga/Web/View/helpers/url.php new file mode 100644 index 0000000..277c237 --- /dev/null +++ b/library/Icinga/Web/View/helpers/url.php @@ -0,0 +1,158 @@ +<?php +/* Icinga Web 2 | (c) 2013 Icinga Development Team | GPLv2+ */ + +namespace Icinga\Web\View; + +use Icinga\Web\Url; +use Icinga\Exception\ProgrammingError; + +$view = $this; + +$this->addHelperFunction('href', function ($path = null, $params = null) use ($view) { + return $view->url($path, $params); +}); + +$this->addHelperFunction('url', function ($path = null, $params = null) { + if ($path === null) { + $url = Url::fromRequest(); + } elseif ($path instanceof Url) { + $url = $path; + } else { + $url = Url::fromPath($path); + } + + if ($params !== null) { + if ($url === $path) { + $url = clone $url; + } + + $url->overwriteParams($params); + } + + return $url; +}); + +$this->addHelperFunction( + 'qlink', + function ($title, $url, $params = null, $properties = null, $escape = true) use ($view) { + $icon = ''; + if ($properties) { + if (array_key_exists('title', $properties) && !array_key_exists('aria-label', $properties)) { + $properties['aria-label'] = $properties['title']; + } + + if (array_key_exists('icon', $properties)) { + $icon = $view->icon($properties['icon']); + unset($properties['icon']); + } + + if (array_key_exists('img', $properties)) { + $icon = $view->img($properties['img']); + unset($properties['img']); + } + } + + return sprintf( + '<a href="%s"%s>%s</a>', + $view->url($url, $params), + $view->propertiesToString($properties), + $icon . ($escape ? $view->escape($title) : $title) + ); + } +); + +$this->addHelperFunction('img', function ($url, $params = null, array $properties = array()) use ($view) { + if (! array_key_exists('alt', $properties)) { + $properties['alt'] = ''; + } + + $ariaHidden = array_key_exists('aria-hidden', $properties) ? $properties['aria-hidden'] : null; + if (array_key_exists('title', $properties)) { + if (! array_key_exists('aria-label', $properties) && $ariaHidden !== 'true') { + $properties['aria-label'] = $properties['title']; + } + } elseif ($ariaHidden === null) { + $properties['aria-hidden'] = 'true'; + } + + return sprintf( + '<img src="%s"%s />', + $view->escape($view->url($url, $params)->getAbsoluteUrl()), + $view->propertiesToString($properties) + ); +}); + +$this->addHelperFunction('icon', function ($img, $title = null, array $properties = array()) use ($view) { + if (strpos($img, '.') !== false) { + if (array_key_exists('class', $properties)) { + $properties['class'] .= ' icon'; + } else { + $properties['class'] = 'icon'; + } + if (strpos($img, '/') === false) { + return $view->img('img/icons/' . $img, null, $properties); + } else { + return $view->img($img, null, $properties); + } + } + + $ariaHidden = array_key_exists('aria-hidden', $properties) ? $properties['aria-hidden'] : null; + if ($title !== null) { + $properties['role'] = 'img'; + $properties['title'] = $title; + + if (! array_key_exists('aria-label', $properties) && $ariaHidden !== 'true') { + $properties['aria-label'] = $title; + } + } elseif ($ariaHidden === null) { + $properties['aria-hidden'] = 'true'; + } + + if (isset($properties['class'])) { + $properties['class'] .= ' icon-' . $img; + } else { + $properties['class'] = 'icon-' . $img; + } + + return sprintf('<i %s></i>', $view->propertiesToString($properties)); +}); + +$this->addHelperFunction('propertiesToString', function ($properties) use ($view) { + if (empty($properties)) { + return ''; + } + $attributes = array(); + + foreach ($properties as $key => $val) { + if ($key === 'style' && is_array($val)) { + if (empty($val)) { + continue; + } + $parts = array(); + foreach ($val as $k => $v) { + $parts[] = "$k: $v"; + } + $val = implode('; ', $parts); + continue; + } + + $attributes[] = $view->attributeToString($key, $val); + } + return ' ' . implode(' ', $attributes); +}); + +$this->addHelperFunction('attributeToString', function ($key, $value) use ($view) { + // TODO: Doublecheck this! + if (! preg_match('~^[a-zA-Z0-9-]+$~', $key)) { + throw new ProgrammingError( + 'Trying to set an invalid HTML attribute name: %s', + $key + ); + } + + return sprintf( + '%s="%s"', + $key, + $view->escape($value) + ); +}); diff --git a/library/Icinga/Web/Widget.php b/library/Icinga/Web/Widget.php new file mode 100644 index 0000000..48ae7bd --- /dev/null +++ b/library/Icinga/Web/Widget.php @@ -0,0 +1,49 @@ +<?php +/* Icinga Web 2 | (c) 2013 Icinga Development Team | GPLv2+ */ + +namespace Icinga\Web; + +use Icinga\Exception\ProgrammingError; +use Icinga\Web\Widget\AbstractWidget; + +/** + * Web widgets make things easier for you! + * + * This class provides nothing but a static factory method for widget creation. + * Usually it will not be used directly as there are widget()-helpers available + * in your action controllers and view scripts. + * + * Usage example: + * <code> + * $tabs = Widget::create('tabs'); + * </code> + * + * @copyright Copyright (c) 2013 Icinga-Web Team <info@icinga.com> + * @author Icinga-Web Team <info@icinga.com> + * @license http://www.gnu.org/copyleft/gpl.html GNU General Public License + */ +class Widget +{ + /** + * Create a new widget + * + * @param string $name Widget name + * @param array $options Widget constructor options + * + * @return AbstractWidget + */ + public static function create($name, $options = array(), $module_name = null) + { + $class = 'Icinga\\Web\\Widget\\' . ucfirst($name); + + if (! class_exists($class)) { + throw new ProgrammingError( + 'There is no such widget: %s', + $name + ); + } + + $widget = new $class($options, $module_name); + return $widget; + } +} diff --git a/library/Icinga/Web/Widget/AbstractWidget.php b/library/Icinga/Web/Widget/AbstractWidget.php new file mode 100644 index 0000000..1090548 --- /dev/null +++ b/library/Icinga/Web/Widget/AbstractWidget.php @@ -0,0 +1,121 @@ +<?php +/* Icinga Web 2 | (c) 2013 Icinga Development Team | GPLv2+ */ + +namespace Icinga\Web\Widget; + +use Icinga\Exception\ProgrammingError; +use Icinga\Application\Icinga; +use Exception; +use Zend_View_Abstract; + +/** + * Web widgets MUST extend this class + * + * AbstractWidget implements getters and setters for widget options stored in + * the protected options array. If you want to allow options for your own + * widget, you have to set a default value (may be null) for each single option + * in this array. + * + * Please have a look at the available widgets in this folder to get a better + * idea on what they should look like. + * + * @copyright Copyright (c) 2013 Icinga-Web Team <info@icinga.com> + * @author Icinga-Web Team <info@icinga.com> + * @license http://www.gnu.org/copyleft/gpl.html GNU General Public License + */ +abstract class AbstractWidget +{ + /** + * If you are going to access the current view with the view() function, + * its instance is stored here for performance reasons. + * + * @var Zend_View_Abstract + */ + protected static $view; + + // TODO: Should we kick this? + protected $properties = array(); + + /** + * Getter for widget properties + * + * @param string $key The option you're interested in + * + * @throws ProgrammingError for unknown property name + * + * @return mixed + */ + public function __get($key) + { + if (array_key_exists($key, $this->properties)) { + return $this->properties[$key]; + } + + throw new ProgrammingError( + 'Trying to get invalid "%s" property for %s', + $key, + get_class($this) + ); + } + + /** + * Setter for widget properties + * + * @param string $key The option you want to set + * @param string $val The new value going to be assigned to this option + * + * @throws ProgrammingError for unknown property name + * + * @return mixed + */ + public function __set($key, $val) + { + if (array_key_exists($key, $this->properties)) { + $this->properties[$key] = $val; + return; + } + + throw new ProgrammingError( + 'Trying to set invalid "%s" property in %s. Allowed are: %s', + $key, + get_class($this), + empty($this->properties) + ? 'none' + : implode(', ', array_keys($this->properties)) + ); + } + + abstract public function render(); + + /** + * Access the current view + * + * Will instantiate a new one if none exists + * // TODO: App->getView + * + * @return Zend_View_Abstract + */ + protected function view() + { + if (self::$view === null) { + self::$view = Icinga::app()->getViewRenderer()->view; + } + + return self::$view; + } + + /** + * Cast this widget to a string. Will call your render() function + * + * @return string + */ + public function __toString() + { + try { + $html = $this->render(); + } catch (Exception $e) { + return htmlspecialchars($e->getMessage()); + } + return (string) $html; + } +} diff --git a/library/Icinga/Web/Widget/Announcements.php b/library/Icinga/Web/Widget/Announcements.php new file mode 100644 index 0000000..e0fac77 --- /dev/null +++ b/library/Icinga/Web/Widget/Announcements.php @@ -0,0 +1,55 @@ +<?php +/* Icinga Web 2 | (c) 2016 Icinga Development Team | GPLv2+ */ + +namespace Icinga\Web\Widget; + +use Icinga\Application\Icinga; +use Icinga\Data\Filter\Filter; +use Icinga\Forms\Announcement\AcknowledgeAnnouncementForm; +use Icinga\Web\Announcement\AnnouncementCookie; +use Icinga\Web\Announcement\AnnouncementIniRepository; +use Icinga\Web\Helper\Markdown; + +/** + * Render announcements + */ +class Announcements extends AbstractWidget +{ + /** + * {@inheritdoc} + */ + public function render() + { + $repo = new AnnouncementIniRepository(); + $etag = $repo->getEtag(); + $cookie = new AnnouncementCookie(); + if ($cookie->getEtag() !== $etag) { + $cookie->setEtag($etag); + $cookie->setNextActive($repo->findNextActive()); + Icinga::app()->getResponse()->setCookie($cookie); + } + $acked = array(); + foreach ($cookie->getAcknowledged() as $hash) { + $acked[] = Filter::expression('hash', '!=', $hash); + } + $acked = Filter::matchAll($acked); + $announcements = $repo->findActive(); + $announcements->applyFilter($acked); + if ($announcements->hasResult()) { + $html = '<ul role="alert">'; + foreach ($announcements as $announcement) { + $ackForm = new AcknowledgeAnnouncementForm(); + $ackForm->populate(array('hash' => $announcement->hash)); + $html .= '<li><div class="message">' + . Markdown::text($announcement->message) + . '</div>' + . $ackForm + . '</li>'; + } + $html .= '</ul>'; + return $html; + } + // Force container update on XHR + return '<div hidden></div>'; + } +} diff --git a/library/Icinga/Web/Widget/ApplicationStateMessages.php b/library/Icinga/Web/Widget/ApplicationStateMessages.php new file mode 100644 index 0000000..99d3bb2 --- /dev/null +++ b/library/Icinga/Web/Widget/ApplicationStateMessages.php @@ -0,0 +1,74 @@ +<?php +/* Icinga Web 2 | (c) 2018 Icinga Development Team | GPLv2+ */ + +namespace Icinga\Web\Widget; + +use Icinga\Application\Config; +use Icinga\Application\Hook\ApplicationStateHook; +use Icinga\Authentication\Auth; +use Icinga\Forms\AcknowledgeApplicationStateMessageForm; +use Icinga\Web\ApplicationStateCookie; +use Icinga\Web\Helper\Markdown; + +/** + * Render application state messages + */ +class ApplicationStateMessages extends AbstractWidget +{ + protected function getMessages() + { + $cookie = new ApplicationStateCookie(); + + $acked = array_flip($cookie->getAcknowledgedMessages()); + $messages = ApplicationStateHook::getAllMessages(); + + $active = array_diff_key($messages, $acked); + + return $active; + } + + public function render() + { + $enabled = Auth::getInstance() + ->getUser() + ->getPreferences() + ->getValue('icingaweb', 'show_application_state_messages', 'system'); + + if ($enabled === 'system') { + $enabled = Config::app()->get('global', 'show_application_state_messages', true); + } + + if (! (bool) $enabled) { + return '<div hidden></div>'; + } + + $active = $this->getMessages(); + + if (empty($active)) { + // Force container update on XHR + return '<div hidden></div>'; + } + + $html = '<div>'; + + reset($active); + + $id = key($active); + $spec = current($active); + $message = array_pop($spec); // We don't use state and timestamp here + + + $ackForm = new AcknowledgeApplicationStateMessageForm(); + $ackForm->populate(['id' => $id]); + + $html .= '<section class="markdown">'; + $html .= Markdown::text($message); + $html .= '</section>'; + + $html .= $ackForm; + + $html .= '</div>'; + + return $html; + } +} diff --git a/library/Icinga/Web/Widget/Chart/HistoryColorGrid.php b/library/Icinga/Web/Widget/Chart/HistoryColorGrid.php new file mode 100644 index 0000000..b7b50d0 --- /dev/null +++ b/library/Icinga/Web/Widget/Chart/HistoryColorGrid.php @@ -0,0 +1,400 @@ +<?php +/* Icinga Web 2 | (c) 2014 Icinga Development Team | GPLv2+ */ + +namespace Icinga\Web\Widget\Chart; + +use DateInterval; +use DateTime; +use Icinga\Util\Color; +use Icinga\Util\Csp; +use Icinga\Web\Widget\AbstractWidget; +use ipl\Web\Style; + +/** + * Display a colored grid that visualizes a set of values for each day + * on a given time-frame. + */ +class HistoryColorGrid extends AbstractWidget +{ + const CAL_GROW_INTO_PAST = 'past'; + const CAL_GROW_INTO_PRESENT = 'present'; + + const ORIENTATION_VERTICAL = 'vertical'; + const ORIENTATION_HORIZONTAL = 'horizontal'; + + public $weekFlow = self::CAL_GROW_INTO_PAST; + public $orientation = self::ORIENTATION_VERTICAL; + public $weekStartMonday = true; + + private $maxValue = 1; + + private $start = null; + private $end = null; + private $data = array(); + private $color; + public $opacity = 1.0; + + /** @var array<string, array<string, string>> History grid css rulesets */ + protected $rulesets = []; + + public function __construct($color = '#51e551', $start = null, $end = null) + { + $this->setColor($color); + if (isset($start)) { + $this->start = $this->tsToDateStr($start); + } + if (isset($end)) { + $this->end = $this->tsToDateStr($end); + } + } + + /** + * Set the displayed data-set + * + * @param $events array The history events to display as an array of arrays: + * value: The value to display + * caption: The caption on mouse-over + * url: The url to open on click. + */ + public function setData(array $events) + { + $this->data = $events; + $start = time(); + $end = time(); + foreach ($this->data as $entry) { + $entry['value'] = intval($entry['value']); + } + foreach ($this->data as $date => $entry) { + $time = strtotime($date); + if ($entry['value'] > $this->maxValue) { + $this->maxValue = $entry['value']; + } + if ($time > $end) { + $end = $time; + } + if ($time < $start) { + $start = $time; + } + } + if (!isset($this->start)) { + $this->start = $this->tsToDateStr($start); + } + if (!isset($this->end)) { + $this->end = $this->tsToDateStr($end); + } + } + + /** + * Set the used color. + * + * @param $color + */ + public function setColor($color) + { + $this->color = $color; + } + + /** + * Set the used opacity + * + * @param $opacity + */ + public function setOpacity($opacity) + { + $this->opacity = $opacity; + } + + /** + * Calculate the color to display for the given value. + * + * @param $value integer + * + * @return string The color-string to use for this entry. + */ + private function calculateColor($value) + { + $saturation = $value / $this->maxValue; + return Color::changeSaturation($this->color, $saturation); + } + + /** + * Render the html to display the given $day + * + * @param $day string The day to display YYYY-MM-DD + * + * @return string The rendered html + */ + private function renderDay($day) + { + if (array_key_exists($day, $this->data) && $this->data[$day]['value'] > 0) { + $entry = $this->data[$day]; + $this->rulesets['.grid-day-with-entry-' . $entry['value']] = [ + 'background-color' => $this->calculateColor($entry['value']), + 'opacity' => $this->opacity + ]; + + return '<a class="grid-day-with-entry-' + . $entry['value'] + . '" ' + . 'aria-label="' . $entry['caption'] + . '" ' + . 'title="' . $entry['caption'] + . '" ' + . 'href="' . $entry['url'] + . '" ' + . '"></a>'; + } else { + if (! isset($this->rulesets['.grid-day-no-entry'])) { + $this->rulesets['.grid-day-no-entry'] = [ + 'background-color' => $this->calculateColor(0), + 'opacity' => $this->opacity + ]; + } + + return '<span class="grid-day-no-entry"' . ' title="No entries for ' . $day . '"></span>'; + } + } + + /** + * Render the grid with an horizontal alignment. + * + * @param array $grid The values returned from the createGrid function + * + * @return string The rendered html + */ + private function renderHorizontal($grid) + { + $weeks = $grid['weeks']; + $months = $grid['months']; + $years = $grid['years']; + $html = '<table class="historycolorgrid">'; + $html .= '<tr><th></th>'; + $old = -1; + foreach ($months as $week => $month) { + if ($old !== $month) { + $old = $month; + $txt = $this->monthName($month, $years[$week]); + } else { + $txt = ''; + } + $html .= '<th>' . $txt . '</th>'; + } + $html .= '</tr>'; + for ($i = 0; $i < 7; $i++) { + $html .= $this->renderWeekdayHorizontal($i, $weeks); + } + $html .= '</table>'; + return $html; + } + + /** + * @param $grid + * + * @return string + */ + private function renderVertical($grid) + { + $years = $grid['years']; + $weeks = $grid['weeks']; + $months = $grid['months']; + $html = '<table class="historycolorgrid">'; + $html .= '<tr>'; + for ($i = 0; $i < 7; $i++) { + $html .= '<th>' . $this->weekdayName($this->weekStartMonday ? $i + 1 : $i) . "</th>"; + } + $html .= '</tr>'; + $old = -1; + foreach ($weeks as $index => $week) { + for ($i = 0; $i < 7; $i++) { + if (array_key_exists($i, $week)) { + $html .= '<td>' . $this->renderDay($week[$i]) . '</td>'; + } else { + $html .= '<td></td>'; + } + } + if ($old !== $months[$index]) { + $old = $months[$index]; + $txt = $this->monthName($old, $years[$index]); + } else { + $txt = ''; + } + $html .= '<td class="weekday">' . $txt . '</td></tr>'; + } + $html .= '</table>'; + return $html; + } + + /** + * Render the row for the given weekday. + * + * @param integer $weekday The day to render (0-6) + * @param array $weeks The weeks + * + * @return string The formatted table-row + */ + private function renderWeekdayHorizontal($weekday, &$weeks) + { + $html = '<tr><td class="weekday">' + . $this->weekdayName($this->weekStartMonday ? $weekday + 1 : $weekday) + . '</td>'; + foreach ($weeks as $week) { + if (array_key_exists($weekday, $week)) { + $html .= '<td>' . $this->renderDay($week[$weekday]) . '</td>'; + } else { + $html .= '<td></td>'; + } + } + $html .= '</tr>'; + return $html; + } + + + + /** + * @return array + */ + private function createGrid() + { + $weeks = array(array()); + $week = 0; + $months = array(); + $years = array(); + $start = strtotime($this->start); + $year = intval(date('Y', $start)); + $month = intval(date('n', $start)); + $day = intval(date('j', $start)); + $weekday = intval(date('w', $start)); + if ($this->weekStartMonday) { + // 0 => monday, 6 => sunday + $weekday = $weekday === 0 ? 6 : $weekday - 1; + } + + $date = $this->toDateStr($day, $month, $year); + $weeks[0][$weekday] = $date; + $years[0] = $year; + $months[0] = $month; + while ($date !== $this->end) { + $day++; + $weekday++; + if ($weekday > 6) { + $weekday = 0; + $weeks[] = array(); + // PRESENT => The last day of week determines the month + if ($this->weekFlow === self::CAL_GROW_INTO_PRESENT) { + $months[$week] = $month; + $years[$week] = $year; + } + $week++; + } + if ($day > date('t', mktime(0, 0, 0, $month, 1, $year))) { + $month++; + if ($month > 12) { + $year++; + $month = 1; + } + $day = 1; + } + if ($weekday === 0) { + // PAST => The first day of each week determines the month + if ($this->weekFlow === self::CAL_GROW_INTO_PAST) { + $months[$week] = $month; + $years[$week] = $year; + } + } + $date = $this->toDateStr($day, $month, $year); + $weeks[$week][$weekday] = $date; + }; + $years[$week] = $year; + $months[$week] = $month; + if ($this->weekFlow == self::CAL_GROW_INTO_PAST) { + return array( + 'weeks' => array_reverse($weeks), + 'months' => array_reverse($months), + 'years' => array_reverse($years) + ); + } + return array( + 'weeks' => $weeks, + 'months' => $months, + 'years' => $years + ); + } + + /** + * Get the localized month-name for the given month + * + * @param integer $month The month-number + * + * @return string The + */ + private function monthName($month, $year) + { + // TODO: find a way to render years without messing up the layout + $dt = new DateTime($year . '-' . $month . '-01'); + return $dt->format('M'); + } + + /** + * @param $weekday + * + * @return string + */ + private function weekdayName($weekday) + { + $sun = new DateTime('last Sunday'); + $interval = new DateInterval('P' . $weekday . 'D'); + $sun->add($interval); + return substr($sun->format('D'), 0, 2); + } + + /** + * + * + * @param $timestamp + * + * @return bool|string + */ + private function tsToDateStr($timestamp) + { + return date('Y-m-d', $timestamp); + } + + /** + * @param $day + * @param $mon + * @param $year + * + * @return string + */ + private function toDateStr($day, $mon, $year) + { + $day = $day > 9 ? (string)$day : '0' . (string)$day; + $mon = $mon > 9 ? (string)$mon : '0' . (string)$mon; + return $year . '-' . $mon . '-' . $day; + } + + /** + * @return string + */ + public function render() + { + if (empty($this->data)) { + return '<div>No entries</div>'; + } + $grid = $this->createGrid(); + if ($this->orientation === self::ORIENTATION_HORIZONTAL) { + $html = $this->renderHorizontal($grid); + } else { + $html = $this->renderVertical($grid); + } + + $historyGridStyle = new Style(); + $historyGridStyle->setNonce(Csp::getStyleNonce()); + + foreach ($this->rulesets as $selector => $properties) { + $historyGridStyle->add($selector, $properties); + } + + return $html . $historyGridStyle; + } +} diff --git a/library/Icinga/Web/Widget/Chart/InlinePie.php b/library/Icinga/Web/Widget/Chart/InlinePie.php new file mode 100644 index 0000000..21b4ca4 --- /dev/null +++ b/library/Icinga/Web/Widget/Chart/InlinePie.php @@ -0,0 +1,257 @@ +<?php +/* Icinga Web 2 | (c) 2014 Icinga Development Team | GPLv2+ */ + +namespace Icinga\Web\Widget\Chart; + +use Icinga\Chart\PieChart; +use Icinga\Module\Monitoring\Plugin\PerfdataSet; +use Icinga\Util\StringHelper; +use Icinga\Web\Widget\AbstractWidget; +use Icinga\Web\Url; +use Icinga\Util\Format; +use Icinga\Application\Logger; +use Icinga\Exception\IcingaException; +use stdClass; + +/** + * A SVG-PieChart intended to be displayed as a small icon next to labels, to offer a better visualization of the + * shown data + * + * NOTE: When InlinePies are shown in a dynamically loaded view, like the side-bar or in the dashboard, the SVGs will + * be replaced with a jQuery-Sparkline to save resources @see loader.js + * + * @package Icinga\Web\Widget\Chart + */ +class InlinePie extends AbstractWidget +{ + const NUMBER_FORMAT_NONE = 'none'; + const NUMBER_FORMAT_TIME = 'time'; + const NUMBER_FORMAT_BYTES = 'bytes'; + const NUMBER_FORMAT_RATIO = 'ratio'; + + public static $colorsHostStates = array( + '#44bb77', // up + '#ff99aa', // down + '#cc77ff', // unreachable + '#77aaff' // pending + ); + + public static $colorsHostStatesHandledUnhandled = array( + '#44bb77', // up + '#44bb77', + '#ff99aa', // down + '#ff5566', + '#cc77ff', // unreachable + '#aa44ff', + '#77aaff', // pending + '#77aaff' + ); + + public static $colorsServiceStates = array( + '#44bb77', // Ok + '#ffaa44', // Warning + '#ff99aa', // Critical + '#aa44ff', // Unknown + '#77aaff' // Pending + ); + + public static $colorsServiceStatesHandleUnhandled = array( + '#44bb77', // Ok + '#44bb77', + '#ffaa44', // Warning + '#ffcc66', + '#ff99aa', // Critical + '#ff5566', + '#cc77ff', // Unknown + '#aa44ff', + '#77aaff', // Pending + '#77aaff' + ); + + /** + * The template string used for rendering this widget + * + * @var string + */ + private $template = '<div class="inline-pie {class}">{svg}</div>'; + + /** + * The colors used to display the slices of this pie-chart. + * + * @var array + */ + private $colors = array('#049BAF', '#ffaa44', '#ff5566', '#ddccdd'); + + /** + * The title of the chart + * + * @var string + */ + private $title; + + /** + * @var int + */ + private $size = 16; + + /** + * The data displayed by the pie-chart + * + * @var array + */ + private $data; + + /** + * @var + */ + private $class = ''; + + /** + * Set the data to be displayed. + * + * @param $data array + * + * @return $this + */ + public function setData(array $data) + { + $this->data = $data; + + return $this; + } + + /** + * Set the size of the inline pie + * + * @param int $size Sets both, the height and width + * + * @return $this + */ + public function setSize($size = null) + { + $this->size = $size; + + return $this; + } + + /** + * Set the class to define the + * + * @param $class + * + * @return $this + */ + public function setSparklineClass($class) + { + $this->class = $class; + + return $this; + } + + /** + * Set the colors used by the slices of the pie chart. + * + * @param array $colors + * + * @return $this + */ + public function setColors(array $colors = null) + { + $this->colors = $colors; + + return $this; + } + + /** + * Set the title of the displayed Data + * + * @param string $title + * + * @return $this + */ + public function setTitle($title) + { + $this->title = $this->view()->escape($title); + + return $this; + } + + /** + * Create a new InlinePie + * + * @param array $data The data displayed by the slices + * @param string $title The title of this Pie + * @param array $colors An array of RGB-Color values to use + */ + public function __construct(array $data, $title, $colors = null) + { + $this->setTitle($title); + + if (array_key_exists('data', $data)) { + $this->data = $data['data']; + if (array_key_exists('colors', $data)) { + $this->colors = $data['colors']; + } + } else { + $this->setData($data); + } + + if (isset($colors)) { + $this->setColors($colors); + } else { + $this->setColors($this->colors); + } + } + + /** + * Renders this widget via the given view and returns the + * HTML as a string + * + * @return string + */ + public function render() + { + $pie = new PieChart(); + $pie->alignTopLeft(); + $pie->disableLegend(); + $pie->drawPie([ + 'data' => $this->data, + 'colors' => $this->colors + ]); + + if ($this->view()->layout()->getLayout() === 'pdf') { + try { + $png = $pie->toPng($this->size, $this->size); + return '<img class="inlinepie" src="data:image/png;base64,' . base64_encode($png) . '" />'; + } catch (IcingaException $_) { + return ''; + } + } + + $pie->title = $this->title; + $pie->description = $this->title; + + $template = $this->template; + $template = str_replace('{class}', $this->class, $template); + $template = str_replace('{svg}', $pie->render(), $template); + + return $template; + } + + public static function createFromStateSummary(stdClass $states, $title, array $colors) + { + $handledUnhandledStates = []; + foreach ($states as $key => $value) { + if (StringHelper::endsWith($key, '_handled') || StringHelper::endsWith($key, '_unhandled')) { + $handledUnhandledStates[$key] = $value; + } + } + + $chart = new self(array_values($handledUnhandledStates), $title, $colors); + + return $chart + ->setSize(50) + ->setTitle('') + ->setSparklineClass('sparkline-multi'); + } +} diff --git a/library/Icinga/Web/Widget/Dashboard.php b/library/Icinga/Web/Widget/Dashboard.php new file mode 100644 index 0000000..5a8796d --- /dev/null +++ b/library/Icinga/Web/Widget/Dashboard.php @@ -0,0 +1,475 @@ +<?php +/* Icinga Web 2 | (c) 2013 Icinga Development Team | GPLv2+ */ + +namespace Icinga\Web\Widget; + +use Icinga\Application\Config; +use Icinga\Exception\ConfigurationError; +use Icinga\Exception\NotReadableError; +use Icinga\Exception\ProgrammingError; +use Icinga\Legacy\DashboardConfig; +use Icinga\User; +use Icinga\Web\Navigation\DashboardPane; +use Icinga\Web\Navigation\Navigation; +use Icinga\Web\Url; +use Icinga\Web\Widget\Dashboard\Dashlet as DashboardDashlet; +use Icinga\Web\Widget\Dashboard\Pane; + +/** + * Dashboards display multiple views on a single page + * + * The terminology is as follows: + * - Dashlet: A single view showing a specific url + * - Pane: Aggregates one or more dashlets on one page, displays its title as a tab + * - Dashboard: Shows all panes + * + */ +class Dashboard extends AbstractWidget +{ + /** + * An array containing all panes of this dashboard + * + * @var array + */ + private $panes = array(); + + /** + * The @see Icinga\Web\Widget\Tabs object for displaying displayable panes + * + * @var Tabs + */ + protected $tabs; + + /** + * The parameter that will be added to identify panes + * + * @var string + */ + private $tabParam = 'pane'; + + /** + * @var User + */ + private $user; + + /** + * Set the given tab name as active. + * + * @param string $name The tab name to activate + * + */ + public function activate($name) + { + $this->getTabs()->activate($name); + } + + /** + * Load Pane items provided by all enabled modules + * + * @return $this + */ + public function load() + { + $navigation = new Navigation(); + $navigation->load('dashboard-pane'); + + $panes = array(); + foreach ($navigation as $dashboardPane) { + /** @var DashboardPane $dashboardPane */ + $pane = new Pane($dashboardPane->getLabel()); + foreach ($dashboardPane->getChildren() as $dashlet) { + $pane->addDashlet($dashlet->getLabel(), $dashlet->getUrl()); + } + + $panes[] = $pane; + } + + $this->mergePanes($panes); + $this->loadUserDashboards($navigation); + return $this; + } + + /** + * Create and return a Config object for this dashboard + * + * @return Config + */ + public function getConfig() + { + $output = array(); + foreach ($this->panes as $pane) { + if ($pane->isUserWidget()) { + $output[$pane->getName()] = $pane->toArray(); + } + foreach ($pane->getDashlets() as $dashlet) { + if ($dashlet->isUserWidget()) { + $output[$pane->getName() . '.' . $dashlet->getName()] = $dashlet->toArray(); + } + } + } + + return DashboardConfig::fromArray($output)->setConfigFile($this->getConfigFile())->setUser($this->user); + } + + /** + * Load user dashboards from all config files that match the username + */ + protected function loadUserDashboards(Navigation $navigation) + { + foreach (DashboardConfig::listConfigFilesForUser($this->user) as $file) { + $this->loadUserDashboardsFromFile($file, $navigation); + } + } + + /** + * Load user dashboards from the given config file + * + * @param string $file + * + * @return bool + */ + protected function loadUserDashboardsFromFile($file, Navigation $dashboardNavigation) + { + try { + $config = Config::fromIni($file); + } catch (NotReadableError $e) { + return false; + } + + if (! count($config)) { + return false; + } + $panes = array(); + $dashlets = array(); + foreach ($config as $key => $part) { + if (strpos($key, '.') === false) { + $dashboardPane = $dashboardNavigation->getItem($key); + if ($dashboardPane !== null) { + $key = $dashboardPane->getLabel(); + } + if ($this->hasPane($key)) { + $panes[$key] = $this->getPane($key); + } else { + $panes[$key] = new Pane($key); + $panes[$key]->setTitle($part->title); + } + $panes[$key]->setUserWidget(); + if ((bool) $part->get('disabled', false) === true) { + $panes[$key]->setDisabled(); + } + } else { + list($paneName, $dashletName) = explode('.', $key, 2); + $dashboardPane = $dashboardNavigation->getItem($paneName); + if ($dashboardPane !== null) { + $paneName = $dashboardPane->getLabel(); + $dashletItem = $dashboardPane->getChildren()->getItem($dashletName); + if ($dashletItem !== null) { + $dashletName = $dashletItem->getLabel(); + } + } + $part->pane = $paneName; + $part->dashlet = $dashletName; + $dashlets[] = $part; + } + } + foreach ($dashlets as $dashletData) { + $pane = null; + + if (array_key_exists($dashletData->pane, $panes) === true) { + $pane = $panes[$dashletData->pane]; + } elseif (array_key_exists($dashletData->pane, $this->panes) === true) { + $pane = $this->panes[$dashletData->pane]; + } else { + continue; + } + $dashlet = new DashboardDashlet( + $dashletData->title, + $dashletData->url, + $pane + ); + $dashlet->setName($dashletData->dashlet); + + if ((bool) $dashletData->get('disabled', false) === true) { + $dashlet->setDisabled(true); + } + + $dashlet->setUserWidget(); + $pane->addDashlet($dashlet); + } + + $this->mergePanes($panes); + + return true; + } + + /** + * Merge panes with existing panes + * + * @param array $panes + * + * @return $this + */ + public function mergePanes(array $panes) + { + /** @var $pane Pane */ + foreach ($panes as $pane) { + if ($this->hasPane($pane->getName()) === true) { + /** @var $current Pane */ + $current = $this->panes[$pane->getName()]; + $current->addDashlets($pane->getDashlets()); + } else { + $this->panes[$pane->getName()] = $pane; + } + } + + return $this; + } + + /** + * Return the tab object used to navigate through this dashboard + * + * @return Tabs + */ + public function getTabs() + { + $url = Url::fromPath('dashboard')->getUrlWithout($this->tabParam); + if ($this->tabs === null) { + $this->tabs = new Tabs(); + + foreach ($this->panes as $key => $pane) { + if ($pane->getDisabled()) { + continue; + } + $this->tabs->add( + $key, + array( + 'title' => sprintf( + t('Show %s', 'dashboard.pane.tooltip'), + $pane->getTitle() + ), + 'label' => $pane->getTitle(), + 'url' => clone($url), + 'urlParams' => array($this->tabParam => $key) + ) + ); + } + } + return $this->tabs; + } + + /** + * Return all panes of this dashboard + * + * @return array + */ + public function getPanes() + { + return $this->panes; + } + + + /** + * Creates a new empty pane with the given title + * + * @param string $title + * + * @return $this + */ + public function createPane($title) + { + $pane = new Pane($title); + $pane->setTitle($title); + $this->addPane($pane); + + return $this; + } + + /** + * Checks if the current dashboard has any panes + * + * @return bool + */ + public function hasPanes() + { + return ! empty($this->panes); + } + + /** + * Check if a panel exist + * + * @param string $pane + * @return bool + */ + public function hasPane($pane) + { + return $pane && array_key_exists($pane, $this->panes); + } + + /** + * Add a pane object to this dashboard + * + * @param Pane $pane The pane to add + * + * @return $this + */ + public function addPane(Pane $pane) + { + $this->panes[$pane->getName()] = $pane; + return $this; + } + + public function removePane($title) + { + if ($this->hasPane($title) === true) { + $pane = $this->getPane($title); + if ($pane->isUserWidget() === true) { + unset($this->panes[$pane->getName()]); + } else { + $pane->setDisabled(); + $pane->setUserWidget(); + } + } else { + throw new ProgrammingError('Pane not found: ' . $title); + } + } + + /** + * Return the pane with the provided name + * + * @param string $name The name of the pane to return + * + * @return Pane The pane or null if no pane with the given name exists + * @throws ProgrammingError + */ + public function getPane($name) + { + if (! array_key_exists($name, $this->panes)) { + throw new ProgrammingError( + 'Trying to retrieve invalid dashboard pane "%s"', + $name + ); + } + return $this->panes[$name]; + } + + /** + * Return an array with pane name=>title format used for comboboxes + * + * @return array + */ + public function getPaneKeyTitleArray() + { + $list = array(); + foreach ($this->panes as $name => $pane) { + $list[$name] = $pane->getTitle(); + } + return $list; + } + + /** + * @see Icinga\Web\Widget::render + */ + public function render() + { + if (empty($this->panes)) { + return ''; + } + + return $this->determineActivePane()->render(); + } + + /** + * Activates the default pane of this dashboard and returns its name + * + * @return mixed + */ + private function setDefaultPane() + { + $active = null; + + foreach ($this->panes as $key => $pane) { + if ($pane->getDisabled() === false) { + $active = $key; + break; + } + } + + if ($active !== null) { + $this->activate($active); + } + return $active; + } + + /** + * @see determineActivePane() + */ + public function getActivePane() + { + return $this->determineActivePane(); + } + + /** + * Determine the active pane either by the selected tab or the current request + * + * @throws \Icinga\Exception\ConfigurationError + * @throws \Icinga\Exception\ProgrammingError + * + * @return Pane The currently active pane + */ + public function determineActivePane() + { + $active = $this->getTabs()->getActiveName(); + if (! $active) { + if ($active = Url::fromRequest()->getParam($this->tabParam)) { + if ($this->hasPane($active)) { + $this->activate($active); + } else { + throw new ProgrammingError( + 'Try to get an inexistent pane.' + ); + } + } else { + $active = $this->setDefaultPane(); + } + } + + if (isset($this->panes[$active])) { + return $this->panes[$active]; + } + + throw new ConfigurationError('Could not determine active pane'); + } + + /** + * Setter for user object + * + * @param User $user + */ + public function setUser(User $user) + { + $this->user = $user; + } + + /** + * Getter for user object + * + * @return User + */ + public function getUser() + { + return $this->user; + } + + /** + * Get config file + * + * @return string + */ + public function getConfigFile() + { + if ($this->user === null) { + throw new ProgrammingError('Can\'t load dashboards. User is not set'); + } + return Config::resolvePath('dashboards/' . strtolower($this->user->getUsername()) . '/dashboard.ini'); + } +} diff --git a/library/Icinga/Web/Widget/Dashboard/Dashlet.php b/library/Icinga/Web/Widget/Dashboard/Dashlet.php new file mode 100644 index 0000000..2ba26df --- /dev/null +++ b/library/Icinga/Web/Widget/Dashboard/Dashlet.php @@ -0,0 +1,315 @@ +<?php +/* Icinga Web 2 | (c) 2013 Icinga Development Team | GPLv2+ */ + +namespace Icinga\Web\Widget\Dashboard; + +use Icinga\Web\Url; +use Icinga\Data\ConfigObject; +use Icinga\Exception\IcingaException; + +/** + * A dashboard pane dashlet + * + * This is the element displaying a specific view in icinga2web + * + */ +class Dashlet extends UserWidget +{ + /** + * The url of this Dashlet + * + * @var Url|null + */ + private $url; + + private $name; + + /** + * The title being displayed on top of the dashlet + * @var + */ + private $title; + + /** + * The pane containing this dashlet, needed for the 'remove button' + * @var Pane + */ + private $pane; + + /** + * The disabled option is used to "delete" default dashlets provided by modules + * + * @var bool + */ + private $disabled = false; + + /** + * The progress label being used + * + * @var string + */ + private $progressLabel; + + /** + * The template string used for rendering this widget + * + * @var string + */ + private $template =<<<'EOD' + + <div class="container" data-icinga-url="{URL}"> + <h1><a href="{FULL_URL}" aria-label="{TOOLTIP}" title="{TOOLTIP}" data-base-target="col1">{TITLE}</a></h1> + <p class="progress-label">{PROGRESS_LABEL}<span>.</span><span>.</span><span>.</span></p> + <noscript> + <div class="iframe-container"> + <iframe + src="{IFRAME_URL}" + frameborder="no" + title="{TITLE_PREFIX}{TITLE}"> + </iframe> + </div> + </noscript> + </div> +EOD; + + /** + * The template string used for rendering this widget in case of an error + * + * @var string + */ + private $errorTemplate = <<<'EOD' + + <div class="container"> + <h1 title="{TOOLTIP}">{TITLE}</h1> + <p class="error-message">{ERROR_MESSAGE}</p> + </div> +EOD; + + /** + * Create a new dashlet displaying the given url in the provided pane + * + * @param string $title The title to use for this dashlet + * @param Url|string $url The url this dashlet uses for displaying information + * @param Pane $pane The pane this Dashlet will be added to + */ + public function __construct($title, $url, Pane $pane) + { + $this->name = $title; + $this->title = $title; + $this->pane = $pane; + $this->url = $url; + } + + public function setName($name) + { + $this->name = $name; + return $this; + } + + public function getName() + { + return $this->name; + } + + /** + * Retrieve the dashlets title + * + * @return string + */ + public function getTitle() + { + return $this->title; + } + + /** + * @param string $title + */ + public function setTitle($title) + { + $this->title = $title; + } + + /** + * Retrieve the dashlets url + * + * @return Url|null + */ + public function getUrl() + { + if ($this->url !== null && ! $this->url instanceof Url) { + $this->url = Url::fromPath($this->url); + } + return $this->url; + } + + /** + * Set the dashlets URL + * + * @param string|Url $url The url to use, either as an Url object or as a path + * + * @return $this + */ + public function setUrl($url) + { + $this->url = $url; + return $this; + } + + /** + * Set the disabled property + * + * @param boolean $disabled + */ + public function setDisabled($disabled) + { + $this->disabled = $disabled; + } + + /** + * Get the disabled property + * + * @return boolean + */ + public function getDisabled() + { + return $this->disabled; + } + + /** + * Set the progress label to use + * + * @param string $label + * + * @return $this + */ + public function setProgressLabel($label) + { + $this->progressLabel = $label; + return $this; + } + + /** + * Return the progress label to use + * + * @return string + */ + public function getProgressLabe() + { + if ($this->progressLabel === null) { + return $this->view()->translate('Loading'); + } + + return $this->progressLabel; + } + + /** + * Return this dashlet's structure as array + * + * @return array + */ + public function toArray() + { + $array = array( + 'url' => $this->getUrl()->getRelativeUrl(), + 'title' => $this->getTitle() + ); + if ($this->getDisabled() === true) { + $array['disabled'] = 1; + } + return $array; + } + + /** + * @see Widget::render() + */ + public function render() + { + if ($this->disabled === true) { + return ''; + } + + $view = $this->view(); + + if (! $this->url) { + $searchTokens = array( + '{TOOLTIP}', + '{TITLE}', + '{ERROR_MESSAGE}' + ); + + $replaceTokens = array( + sprintf($view->translate('Show %s', 'dashboard.dashlet.tooltip'), $view->escape($this->getTitle())), + $view->escape($this->getTitle()), + $view->escape( + sprintf($view->translate('Cannot create dashboard dashlet "%s" without valid URL'), $this->title) + ) + ); + + return str_replace($searchTokens, $replaceTokens, $this->errorTemplate); + } + + $url = $this->getUrl(); + $url->setParam('showCompact', true); + $iframeUrl = clone $url; + $iframeUrl->setParam('isIframe'); + + $searchTokens = array( + '{URL}', + '{IFRAME_URL}', + '{FULL_URL}', + '{TOOLTIP}', + '{TITLE}', + '{TITLE_PREFIX}', + '{PROGRESS_LABEL}' + ); + + $replaceTokens = array( + $url, + $iframeUrl, + $url->getUrlWithout(['showCompact', 'limit', 'view']), + sprintf($view->translate('Show %s', 'dashboard.dashlet.tooltip'), $view->escape($this->getTitle())), + $view->escape($this->getTitle()), + $view->translate('Dashlet') . ': ', + $this->getProgressLabe() + ); + + return str_replace($searchTokens, $replaceTokens, $this->template); + } + + /** + * Create a @see Dashlet instance from the given Zend config, using the provided title + * + * @param $title The title for this dashlet + * @param ConfigObject $config The configuration defining url, parameters, height, width, etc. + * @param Pane $pane The pane this dashlet belongs to + * + * @return Dashlet A newly created Dashlet for use in the Dashboard + */ + public static function fromIni($title, ConfigObject $config, Pane $pane) + { + $height = null; + $width = null; + $url = $config->get('url'); + $parameters = $config->toArray(); + unset($parameters['url']); // otherwise there's an url = parameter in the Url + + $cmp = new Dashlet($title, Url::fromPath($url, $parameters), $pane); + return $cmp; + } + + /** + * @param \Icinga\Web\Widget\Dashboard\Pane $pane + */ + public function setPane(Pane $pane) + { + $this->pane = $pane; + } + + /** + * @return \Icinga\Web\Widget\Dashboard\Pane + */ + public function getPane() + { + return $this->pane; + } +} diff --git a/library/Icinga/Web/Widget/Dashboard/Pane.php b/library/Icinga/Web/Widget/Dashboard/Pane.php new file mode 100644 index 0000000..c8b14c5 --- /dev/null +++ b/library/Icinga/Web/Widget/Dashboard/Pane.php @@ -0,0 +1,335 @@ +<?php +/* Icinga Web 2 | (c) 2013 Icinga Development Team | GPLv2+ */ + +namespace Icinga\Web\Widget\Dashboard; + +use Icinga\Data\ConfigObject; +use Icinga\Web\Widget\AbstractWidget; +use Icinga\Exception\ProgrammingError; +use Icinga\Exception\ConfigurationError; + +/** + * A pane, displaying different Dashboard dashlets + */ +class Pane extends UserWidget +{ + /** + * The name of this pane, as defined in the ini file + * + * @var string + */ + private $name; + + /** + * The title of this pane, as displayed in the dashboard tabs + * + * @var string + */ + private $title; + + /** + * An array of @see Dashlets that are displayed in this pane + * + * @var array + */ + private $dashlets = array(); + + /** + * Disabled flag of a pane + * + * @var bool + */ + private $disabled = false; + + /** + * Create a new pane + * + * @param string $name The pane to create + */ + public function __construct($name) + { + $this->name = $name; + $this->title = $name; + } + + /** + * Set the name of this pane + * + * @param string $name + */ + public function setName($name) + { + $this->name = $name; + } + + /** + * Returns the name of this pane + * + * @return string + */ + public function getName() + { + return $this->name; + } + + /** + * Returns the title of this pane + * + * @return string + */ + public function getTitle() + { + return $this->title; + } + + /** + * Overwrite the title of this pane + * + * @param string $title The new title to use for this pane + * + * @return $this + */ + public function setTitle($title) + { + $this->title = $title; + return $this; + } + + /** + * Return true if a dashlet with the given title exists in this pane + * + * @param string $title The title of the dashlet to check for existence + * + * @return bool + */ + public function hasDashlet($title) + { + return array_key_exists($title, $this->dashlets); + } + + /** + * Checks if the current pane has any dashlets + * + * @return bool + */ + public function hasDashlets() + { + return ! empty($this->dashlets); + } + + /** + * Return a dashlet with the given name if existing + * + * @param string $title The title of the dashlet to return + * + * @return Dashlet The dashlet with the given title + * @throws ProgrammingError If the dashlet doesn't exist + */ + public function getDashlet($title) + { + if ($this->hasDashlet($title)) { + return $this->dashlets[$title]; + } + throw new ProgrammingError( + 'Trying to access invalid dashlet: %s', + $title + ); + } + + /** + * Removes the dashlet with the given title if it exists in this pane + * + * @param string $title The pane + * @return Pane $this + */ + public function removeDashlet($title) + { + if ($this->hasDashlet($title)) { + $dashlet = $this->getDashlet($title); + if ($dashlet->isUserWidget() === true) { + unset($this->dashlets[$title]); + } else { + $dashlet->setDisabled(true); + $dashlet->setUserWidget(); + } + } else { + throw new ProgrammingError('Dashlet does not exist: ' . $title); + } + return $this; + } + + /** + * Removes all or a given list of dashlets from this pane + * + * @param array $dashlets Optional list of dashlet titles + * @return Pane $this + */ + public function removeDashlets(array $dashlets = null) + { + if ($dashlets === null) { + $this->dashlets = array(); + } else { + foreach ($dashlets as $dashlet) { + $this->removeDashlet($dashlet); + } + } + return $this; + } + + /** + * Return all dashlets added at this pane + * + * @return array + */ + public function getDashlets() + { + return $this->dashlets; + } + + /** + * @see Widget::render + */ + public function render() + { + $dashlets = array_filter( + $this->dashlets, + function ($e) { + return ! $e->getDisabled(); + } + ); + return implode("\n", $dashlets) . "\n"; + } + + /** + * Create, add and return a new dashlet + * + * @param string $title + * @param string $url + * + * @return Dashlet + */ + public function createDashlet($title, $url = null) + { + $dashlet = new Dashlet($title, $url, $this); + $this->addDashlet($dashlet); + return $dashlet; + } + + /** + * Add a dashlet to this pane, optionally creating it if $dashlet is a string + * + * @param string|Dashlet $dashlet The dashlet object or title + * (if a new dashlet will be created) + * @param string|null $url An Url to be used when dashlet is a string + * + * @return $this + * @throws \Icinga\Exception\ConfigurationError + */ + public function addDashlet($dashlet, $url = null) + { + if ($dashlet instanceof Dashlet) { + $this->dashlets[$dashlet->getName()] = $dashlet; + } elseif (is_string($dashlet) && $url !== null) { + $this->createDashlet($dashlet, $url); + } else { + throw new ConfigurationError('Invalid dashlet added: %s', $dashlet); + } + return $this; + } + + /** + * Add new dashlets to existing dashlets + * + * @param array $dashlets + * @return $this + */ + public function addDashlets(array $dashlets) + { + /* @var $dashlet Dashlet */ + foreach ($dashlets as $dashlet) { + if (array_key_exists($dashlet->getName(), $this->dashlets)) { + if (preg_match('/_(\d+)$/', $dashlet->getName(), $m)) { + $name = preg_replace('/_\d+$/', $m[1]++, $dashlet->getName()); + } else { + $name = $dashlet->getName() . '_2'; + } + $this->dashlets[$name] = $dashlet; + } else { + $this->dashlets[$dashlet->getName()] = $dashlet; + } + } + + return $this; + } + + /** + * Add a dashlet to the current pane + * + * @param $title + * @param $url + * @return Dashlet + * + * @see addDashlet() + */ + public function add($title, $url = null) + { + $this->addDashlet($title, $url); + + return $this->dashlets[$title]; + } + + /** + * Return the this pane's structure as array + * + * @return array + */ + public function toArray() + { + $pane = array( + 'title' => $this->getTitle(), + ); + + if ($this->getDisabled() === true) { + $pane['disabled'] = 1; + } + + return $pane; + } + + /** + * Create a new pane with the title $title from the given configuration + * + * @param $title The title for this pane + * @param ConfigObject $config The configuration to use for setup + * + * @return Pane + */ + public static function fromIni($title, ConfigObject $config) + { + $pane = new Pane($title); + if ($config->get('title', false)) { + $pane->setTitle($config->get('title')); + } + return $pane; + } + + /** + * Setter for disabled + * + * @param boolean $disabled + */ + public function setDisabled($disabled = true) + { + $this->disabled = (bool) $disabled; + } + + /** + * Getter for disabled + * + * @return boolean + */ + public function getDisabled() + { + return $this->disabled; + } +} diff --git a/library/Icinga/Web/Widget/Dashboard/UserWidget.php b/library/Icinga/Web/Widget/Dashboard/UserWidget.php new file mode 100644 index 0000000..164d58b --- /dev/null +++ b/library/Icinga/Web/Widget/Dashboard/UserWidget.php @@ -0,0 +1,36 @@ +<?php +/* Icinga Web 2 | (c) 2014 Icinga Development Team | GPLv2+ */ + +namespace Icinga\Web\Widget\Dashboard; + +use Icinga\Web\Widget\AbstractWidget; + +abstract class UserWidget extends AbstractWidget +{ + /** + * Flag if widget is created by an user + * + * @var bool + */ + protected $userWidget = false; + + /** + * Set the user widget flag + * + * @param boolean $userWidget + */ + public function setUserWidget($userWidget = true) + { + $this->userWidget = (bool) $userWidget; + } + + /** + * Getter for user widget flag + * + * @return boolean + */ + public function isUserWidget() + { + return $this->userWidget; + } +} diff --git a/library/Icinga/Web/Widget/FilterEditor.php b/library/Icinga/Web/Widget/FilterEditor.php new file mode 100644 index 0000000..24f4b15 --- /dev/null +++ b/library/Icinga/Web/Widget/FilterEditor.php @@ -0,0 +1,811 @@ +<?php +/* Icinga Web 2 | (c) 2014 Icinga Development Team | GPLv2+ */ + +namespace Icinga\Web\Widget; + +use Icinga\Data\Filterable; +use Icinga\Data\FilterColumns; +use Icinga\Data\Filter\Filter; +use Icinga\Data\Filter\FilterExpression; +use Icinga\Data\Filter\FilterChain; +use Icinga\Data\Filter\FilterOr; +use Icinga\Web\Url; +use Icinga\Application\Icinga; +use Icinga\Exception\ProgrammingError; +use Icinga\Web\Notification; +use Exception; + +/** + * Filter + */ +class FilterEditor extends AbstractWidget +{ + /** + * The filter + * + * @var Filter + */ + private $filter; + + /** + * The query to filter + * + * @var Filterable + */ + protected $query; + + protected $url; + + protected $addTo; + + protected $cachedColumnSelect; + + protected $preserveParams = array(); + + protected $preservedParams = array(); + + protected $preservedUrl; + + protected $ignoreParams = array(); + + protected $searchColumns; + + /** + * @var string + */ + private $selectedIdx; + + /** + * Whether the filter control is visible + * + * @var bool + */ + protected $visible = true; + + /** + * Create a new FilterEditor + * + * @param Filter $filter Your filter + */ + public function __construct($props) + { + if (array_key_exists('filter', $props)) { + $this->setFilter($props['filter']); + } + if (array_key_exists('query', $props)) { + $this->setQuery($props['query']); + } + } + + public function setFilter(Filter $filter) + { + $this->filter = $filter; + return $this; + } + + public function getFilter() + { + if ($this->filter === null) { + $this->filter = Filter::fromQueryString((string) $this->url()->getParams()); + } + return $this->filter; + } + + /** + * Set columns to search in + * + * @param array $searchColumns + * + * @return $this + */ + public function setSearchColumns(array $searchColumns = null) + { + $this->searchColumns = $searchColumns; + return $this; + } + + public function setUrl($url) + { + $this->url = $url; + return $this; + } + + protected function url() + { + if ($this->url === null) { + $this->url = Url::fromRequest(); + } + return $this->url; + } + + protected function preservedUrl() + { + if ($this->preservedUrl === null) { + $this->preservedUrl = $this->url()->with($this->preservedParams); + } + return $this->preservedUrl; + } + + /** + * Set the query to filter + * + * @param Filterable $query + * + * @return $this + */ + public function setQuery(Filterable $query) + { + $this->query = $query; + return $this; + } + + public function ignoreParams() + { + $this->ignoreParams = func_get_args(); + return $this; + } + + public function preserveParams() + { + $this->preserveParams = func_get_args(); + return $this; + } + + /** + * Get whether the filter control is visible + * + * @return bool + */ + public function isVisible() + { + return $this->visible; + } + + /** + * Set whether the filter control is visible + * + * @param bool $visible + * + * @return $this + */ + public function setVisible($visible) + { + $this->visible = (bool) $visible; + + return $this; + } + + protected function redirectNow($url) + { + $response = Icinga::app()->getFrontController()->getResponse(); + $response->redirectAndExit($url); + } + + protected function mergeRootExpression($filter, $column, $sign, $expression) + { + $found = false; + if ($filter->isChain() && $filter->getOperatorName() === 'AND') { + foreach ($filter->filters() as $f) { + if ($f->isExpression() + && $f->getColumn() === $column + && $f->getSign() === $sign + ) { + $f->setExpression($expression); + $found = true; + break; + } + } + } elseif ($filter->isExpression()) { + if ($filter->getColumn() === $column && $filter->getSign() === $sign) { + $filter->setExpression($expression); + $found = true; + } + } + if (! $found) { + $filter = $filter->andFilter( + Filter::expression($column, $sign, $expression) + ); + } + return $filter; + } + + protected function resetSearchColumns(Filter &$filter) + { + if ($filter->isChain()) { + $filters = &$filter->filters(); + if (!($empty = empty($filters))) { + foreach ($filters as $k => &$f) { + if (false === $this->resetSearchColumns($f)) { + unset($filters[$k]); + } + } + } + return $empty || !empty($filters); + } + return $filter->isExpression() ? !( + in_array($filter->getColumn(), $this->searchColumns) + && + $filter->getSign() === '=' + ) : true; + } + + public function handleRequest($request) + { + $this->setUrl($request->getUrl()->without($this->ignoreParams)); + $params = $this->url()->getParams(); + + $preserve = array(); + foreach ($this->preserveParams as $key) { + if (null !== ($value = $params->shift($key))) { + $preserve[$key] = $value; + } + } + $this->preservedParams = $preserve; + + $add = $params->shift('addFilter'); + $remove = $params->shift('removeFilter'); + $strip = $params->shift('stripFilter'); + $modify = $params->shift('modifyFilter'); + + + + $search = null; + if ($request->isPost()) { + $search = $request->getPost('q'); + } + + if ($search === null) { + $search = $params->shift('q'); + } + + $filter = $this->getFilter(); + + if ($search !== null) { + if (strpos($search, '=') !== false) { + list($k, $v) = preg_split('/=/', $search); + $filter = $this->mergeRootExpression($filter, trim($k), '=', ltrim($v)); + } else { + if ($this->searchColumns === null && $this->query instanceof FilterColumns) { + $this->searchColumns = $this->query->getSearchColumns($search); + } + + if (! empty($this->searchColumns)) { + if (! $this->resetSearchColumns($filter)) { + $filter = Filter::matchAll(); + } + $filters = array(); + $search = trim($search); + foreach ($this->searchColumns as $searchColumn) { + $filters[] = Filter::expression($searchColumn, '=', "*$search*"); + } + $filter = $filter->andFilter(new FilterOr($filters)); + } else { + Notification::error(mt('monitoring', 'Cannot search here')); + return $this; + } + } + + $url = Url::fromRequest()->onlyWith($this->preserveParams); + $urlParams = $url->getParams(); + $url->setQueryString($filter->toQueryString()); + foreach ($urlParams->toArray(false) as $key => $value) { + $url->getParams()->addEncoded($key, $value); + } + + $this->redirectNow($url); + } + + if ($remove) { + $redirect = $this->url(); + if ($filter->getById($remove)->isRootNode()) { + $redirect->setQueryString(''); + } else { + $filter->removeId($remove); + $redirect->setQueryString($filter->toQueryString())->getParams()->add('modifyFilter'); + } + $this->redirectNow($redirect->addParams($preserve)); + } + + if ($strip) { + $redirect = $this->url(); + $subId = $strip . '-1'; + if ($filter->getId() === $strip) { + $filter = $filter->getById($strip . '-1'); + } else { + $filter->replaceById($strip, $filter->getById($strip . '-1')); + } + $redirect->setQueryString($filter->toQueryString())->getParams()->add('modifyFilter'); + $this->redirectNow($redirect->addParams($preserve)); + } + + + if ($modify) { + if ($request->isPost()) { + if ($request->get('cancel') === 'Cancel') { + $this->redirectNow($this->preservedUrl()->without('modifyFilter')); + } + if ($request->get('formUID') === 'FilterEditor') { + $filter = $this->applyChanges($request->getPost()); + $url = $this->url()->setQueryString($filter->toQueryString())->addParams($preserve); + $url->getParams()->add('modifyFilter'); + + $addFilter = $request->get('add_filter'); + if ($addFilter !== null) { + $url->setParam('addFilter', $addFilter); + } + + $removeFilter = $request->get('remove_filter'); + if ($removeFilter !== null) { + $url->setParam('removeFilter', $removeFilter); + } + + $this->redirectNow($url); + } + } + $this->url()->getParams()->add('modifyFilter'); + } + + if ($add) { + $this->addFilterToId($add); + } + + if ($this->query !== null && $request->isGet()) { + $this->query->applyFilter($this->getFilter()); + } + + return $this; + } + + protected function select($name, $list, $selected, $attributes = null) + { + $view = $this->view(); + if ($attributes === null) { + $attributes = ''; + } else { + $attributes = $view->propertiesToString($attributes); + } + $html = sprintf( + '<select name="%s"%s class="autosubmit">' . "\n", + $view->escape($name), + $attributes + ); + + foreach ($list as $k => $v) { + $active = ''; + if ($k === $selected) { + $active = ' selected="selected"'; + } + $html .= sprintf( + ' <option value="%s"%s>%s</option>' . "\n", + $view->escape($k), + $active, + $view->escape($v) + ); + } + $html .= '</select>' . "\n\n"; + return $html; + } + + protected function addFilterToId($id) + { + $this->addTo = $id; + return $this; + } + + protected function removeIndex($idx) + { + $this->selectedIdx = $idx; + return $this; + } + + protected function removeLink(Filter $filter) + { + return "<button type='submit' name='remove_filter' value='{$filter->getId()}'>" + . $this->view()->icon('trash', t('Remove this part of your filter')) + . '</button>'; + } + + protected function addLink(Filter $filter) + { + return "<button type='submit' name='add_filter' value='{$filter->getId()}'>" + . $this->view()->icon('plus', t('Add another filter')) + . '</button>'; + } + + protected function stripLink(Filter $filter) + { + return $this->view()->qlink( + '', + $this->preservedUrl()->with('stripFilter', $filter->getId()), + null, + array( + 'icon' => 'minus', + 'title' => t('Strip this filter') + ) + ); + } + + protected function cancelLink() + { + return $this->view()->qlink( + '', + $this->preservedUrl()->without('addFilter'), + null, + array( + 'icon' => 'cancel', + 'title' => t('Cancel this operation') + ) + ); + } + + protected function renderFilter($filter, $level = 0) + { + if ($level === 0 && $filter->isChain() && $filter->isEmpty()) { + return '<ul class="datafilter"><li class="active">' . $this->renderNewFilter() . '</li></ul>'; + } + + if ($filter instanceof FilterChain) { + return $this->renderFilterChain($filter, $level); + } elseif ($filter instanceof FilterExpression) { + return $this->renderFilterExpression($filter); + } else { + throw new ProgrammingError('Got a Filter being neither expression nor chain'); + } + } + + protected function renderFilterChain(FilterChain $filter, $level) + { + $html = '<span class="handle"> </span>' + . $this->selectOperator($filter) + . $this->removeLink($filter) + . ($filter->count() === 1 ? $this->stripLink($filter) : '') + . $this->addLink($filter); + + if ($filter->isEmpty() && ! $this->addTo) { + return $html; + } + + $parts = array(); + foreach ($filter->filters() as $f) { + $parts[] = '<li>' . $this->renderFilter($f, $level + 1) . '</li>'; + } + + if ($this->addTo && $this->addTo == $filter->getId()) { + $parts[] = '<li class="new-filter">' . $this->renderNewFilter() .$this->cancelLink(). '</li>'; + } + + $class = $level === 0 ? ' class="datafilter"' : ''; + $html .= sprintf( + "<ul%s>\n%s</ul>\n", + $class, + implode("", $parts) + ); + return $html; + } + + protected function renderFilterExpression(FilterExpression $filter) + { + if ($this->addTo && $this->addTo === $filter->getId()) { + return + preg_replace( + '/ class="autosubmit"/', + ' class="autofocus"', + $this->selectOperator() + ) + . '<ul><li>' + . $this->selectColumn($filter) + . $this->selectSign($filter) + . $this->text($filter) + . $this->removeLink($filter) + . $this->addLink($filter) + . '</li><li class="active">' + . $this->renderNewFilter() .$this->cancelLink() + . '</li></ul>' + ; + } else { + return $this->selectColumn($filter) + . $this->selectSign($filter) + . $this->text($filter) + . $this->removeLink($filter) + . $this->addLink($filter) + ; + } + } + + protected function text(Filter $filter = null) + { + $value = $filter === null ? '' : $filter->getExpression(); + if (is_array($value)) { + $value = '(' . implode('|', $value) . ')'; + } + return sprintf( + '<input type="text" name="%s" value="%s" />', + $this->elementId('value', $filter), + $this->view()->escape($value) + ); + } + + protected function renderNewFilter() + { + $html = $this->selectColumn() + . $this->selectSign() + . $this->text(); + + return preg_replace( + '/ class="autosubmit"/', + '', + $html + ); + } + + protected function arrayForSelect($array, $flip = false) + { + $res = array(); + foreach ($array as $k => $v) { + if (is_int($k)) { + $res[$v] = ucwords(str_replace('_', ' ', $v)); + } elseif ($flip) { + $res[$v] = $k; + } else { + $res[$k] = $v; + } + } + // sort($res); + return $res; + } + + protected function elementId($prefix, Filter $filter = null) + { + if ($filter === null) { + return $prefix . '_new_' . ($this->addTo ?: '0'); + } else { + return $prefix . '_' . $filter->getId(); + } + } + + protected function selectOperator(Filter $filter = null) + { + $ops = array( + 'AND' => 'AND', + 'OR' => 'OR', + 'NOT' => 'NOT' + ); + + return $this->select( + $this->elementId('operator', $filter), + $ops, + $filter === null ? null : $filter->getOperatorName(), + ['class' => 'filter-operator'] + ); + } + + protected function selectSign(Filter $filter = null) + { + $signs = array( + '=' => '=', + '!=' => '!=', + '>' => '>', + '<' => '<', + '>=' => '>=', + '<=' => '<=', + ); + + return $this->select( + $this->elementId('sign', $filter), + $signs, + $filter === null ? null : $filter->getSign(), + ['class' => 'filter-rule'] + ); + } + + public function setColumns(array $columns = null) + { + $this->cachedColumnSelect = $columns ? $this->arrayForSelect($columns) : null; + return $this; + } + + protected function selectColumn(Filter $filter = null) + { + $active = $filter === null ? null : $filter->getColumn(); + + if ($this->cachedColumnSelect === null && $this->query === null) { + return sprintf( + '<input type="text" name="%s" value="%s" />', + $this->elementId('column', $filter), + $this->view()->escape($active) // Escape attribute? + ); + } + + if ($this->cachedColumnSelect === null && $this->query instanceof FilterColumns) { + $this->cachedColumnSelect = $this->arrayForSelect($this->query->getFilterColumns(), true); + asort($this->cachedColumnSelect); + } elseif ($this->cachedColumnSelect === null) { + throw new ProgrammingError('No columns set nor does the query provide any'); + } + + $cols = $this->cachedColumnSelect; + if ($active && !isset($cols[$active])) { + $cols[$active] = str_replace('_', ' ', ucfirst(ltrim($active, '_'))); + } + + return $this->select($this->elementId('column', $filter), $cols, $active); + } + + protected function applyChanges($changes) + { + $filter = $this->filter; + $pairs = array(); + $addTo = null; + $add = array(); + foreach ($changes as $k => $v) { + if (preg_match('/^(column|value|sign|operator)((?:_new)?)_([\d-]+)$/', $k, $m)) { + if ($m[2] === '_new') { + if ($addTo !== null && $addTo !== $m[3]) { + throw new \Exception('F...U'); + } + $addTo = $m[3]; + $add[$m[1]] = $v; + } else { + $pairs[$m[3]][$m[1]] = $v; + } + } + } + + $operators = array(); + foreach ($pairs as $id => $fs) { + if (array_key_exists('operator', $fs)) { + $operators[$id] = $fs['operator']; + } else { + $f = $filter->getById($id); + $f->setColumn($fs['column']); + if ($f->getSign() !== $fs['sign']) { + if ($f->isRootNode()) { + $filter = $f->setSign($fs['sign']); + } else { + $filter->replaceById($id, $f->setSign($fs['sign'])); + } + } + $f->setExpression($fs['value']); + } + } + + krsort($operators, SORT_NATURAL); + foreach ($operators as $id => $operator) { + $f = $filter->getById($id); + if ($f->getOperatorName() !== $operator) { + if ($f->isRootNode()) { + $filter = $f->setOperatorName($operator); + } else { + $filter->replaceById($id, $f->setOperatorName($operator)); + } + } + } + + if ($addTo !== null) { + if ($addTo === '0') { + $filter = Filter::expression($add['column'], $add['sign'], $add['value']); + } else { + $parent = $filter->getById($addTo); + $f = Filter::expression($add['column'], $add['sign'], $add['value']); + if (isset($add['operator'])) { + switch ($add['operator']) { + case 'AND': + if ($parent->isExpression()) { + if ($parent->isRootNode()) { + $filter = Filter::matchAll(clone $parent, $f); + } else { + $filter = $filter->replaceById($addTo, Filter::matchAll(clone $parent, $f)); + } + } else { + $parent->addFilter(Filter::matchAll($f)); + } + break; + case 'OR': + if ($parent->isExpression()) { + if ($parent->isRootNode()) { + $filter = Filter::matchAny(clone $parent, $f); + } else { + $filter = $filter->replaceById($addTo, Filter::matchAny(clone $parent, $f)); + } + } else { + $parent->addFilter(Filter::matchAny($f)); + } + break; + case 'NOT': + if ($parent->isExpression()) { + if ($parent->isRootNode()) { + $filter = Filter::not(Filter::matchAll($parent, $f)); + } else { + $filter = $filter->replaceById($addTo, Filter::not(Filter::matchAll($parent, $f))); + } + } else { + $parent->addFilter(Filter::not($f)); + } + break; + } + } else { + $parent->addFilter($f); + } + } + } + + return $filter; + } + + public function renderSearch() + { + $preservedUrl = $this->preservedUrl(); + + $html = ' <form method="post" class="search inline" action="' + . $preservedUrl + . '"><input type="text" name="q" class="search search-input" value="" placeholder="' + . t('Search...') + . '" /></form>'; + + if ($this->filter->isEmpty()) { + $title = t('Filter this list'); + } else { + $title = t('Modify this filter'); + if (! $this->filter->isEmpty()) { + $title .= ': ' . $this->view()->escape($this->filter); + } + } + + return $html + . '<a href="' + . $preservedUrl->with('modifyFilter', ! $preservedUrl->getParam('modifyFilter')) + . '" aria-label="' + . $title + . '" title="' + . $title + . '">' + . '<i aria-hidden="true" class="icon-filter"></i>' + . '</a>'; + } + + public function render() + { + if (! $this->visible) { + return ''; + } + if (! $this->preservedUrl()->getParam('modifyFilter')) { + return '<div class="filter icinga-controls">' + . $this->renderSearch() + . $this->view()->escape($this->shorten($this->filter, 50)) + . '</div>'; + } + return '<div class="filter icinga-controls">' + . $this->renderSearch() + . '<form action="' + . Url::fromRequest() + . '" class="editor" method="POST">' + . '<input type="submit" name="submit" value="Apply" hidden/>' + . '<ul class="tree"><li>' + . $this->renderFilter($this->filter) + . '</li></ul>' + . '<div class="buttons">' + . '<input type="submit" name="cancel" value="Cancel" class="button btn-cancel" />' + . '<input type="submit" name="submit" value="Apply" class="button btn-primary"/>' + . '</div>' + . '<input type="hidden" name="formUID" value="FilterEditor">' + . '</form>' + . '</div>'; + } + + protected function shorten($string, $length) + { + if (strlen($string) > $length) { + return substr($string, 0, $length) . '...'; + } + return $string; + } + + public function __toString() + { + try { + return $this->render(); + } catch (Exception $e) { + return 'ERROR in FilterEditor: ' . $e->getMessage(); + } + } +} diff --git a/library/Icinga/Web/Widget/ItemList/MigrationFileListItem.php b/library/Icinga/Web/Widget/ItemList/MigrationFileListItem.php new file mode 100644 index 0000000..007a730 --- /dev/null +++ b/library/Icinga/Web/Widget/ItemList/MigrationFileListItem.php @@ -0,0 +1,92 @@ +<?php + +/* Icinga Web 2 | (c) 2023 Icinga GmbH | GPLv2+ */ + +namespace Icinga\Web\Widget\ItemList; + +use Icinga\Application\Hook\Common\DbMigrationStep; +use ipl\Html\Attributes; +use ipl\Html\BaseHtmlElement; +use ipl\Html\Html; +use ipl\Html\HtmlElement; +use ipl\Html\HtmlString; +use ipl\Html\Text; +use ipl\I18n\Translation; +use ipl\Web\Common\BaseListItem; +use ipl\Web\Widget\EmptyState; +use ipl\Web\Widget\Icon; + +class MigrationFileListItem extends BaseListItem +{ + use Translation; + + /** @var DbMigrationStep Just for type hint */ + protected $item; + + protected function assembleVisual(BaseHtmlElement $visual): void + { + if ($this->item->getLastState()) { + $visual->getAttributes()->add('class', 'upgrade-failed'); + $visual->addHtml(new Icon('circle-xmark')); + } + } + + protected function assembleTitle(BaseHtmlElement $title): void + { + $scriptPath = $this->item->getScriptPath(); + /** @var string $parentDirs */ + $parentDirs = substr($scriptPath, (int) strpos($scriptPath, 'schema')); + $parentDirs = substr($parentDirs, 0, strrpos($parentDirs, '/') + 1); + + $title->addHtml( + new HtmlElement('span', null, Text::create($parentDirs)), + new HtmlElement( + 'span', + Attributes::create(['class' => 'version']), + Text::create($this->item->getVersion() . '.sql') + ) + ); + + if ($this->item->getLastState()) { + $title->addHtml( + new HtmlElement( + 'span', + Attributes::create(['class' => 'upgrade-failed']), + Text::create($this->translate('Upgrade failed')) + ) + ); + } + } + + protected function assembleHeader(BaseHtmlElement $header): void + { + $header->addHtml($this->createTitle()); + } + + protected function assembleCaption(BaseHtmlElement $caption): void + { + if ($this->item->getDescription()) { + $caption->addHtml(Text::create($this->item->getDescription())); + } else { + $caption->addHtml(new EmptyState(Text::create($this->translate('No description provided.')))); + } + } + + protected function assembleFooter(BaseHtmlElement $footer): void + { + if ($this->item->getLastState()) { + $footer->addHtml( + new HtmlElement( + 'section', + Attributes::create(['class' => 'caption']), + new HtmlElement('pre', null, new HtmlString(Html::escape($this->item->getLastState()))) + ) + ); + } + } + + protected function assembleMain(BaseHtmlElement $main): void + { + $main->addHtml($this->createHeader(), $this->createCaption()); + } +} diff --git a/library/Icinga/Web/Widget/ItemList/MigrationList.php b/library/Icinga/Web/Widget/ItemList/MigrationList.php new file mode 100644 index 0000000..43699d3 --- /dev/null +++ b/library/Icinga/Web/Widget/ItemList/MigrationList.php @@ -0,0 +1,133 @@ +<?php + +/* Icinga Web 2 | (c) 2023 Icinga GmbH | GPLv2+ */ + +namespace Icinga\Web\Widget\ItemList; + +use Generator; +use Icinga\Application\Hook\Common\DbMigrationStep; +use Icinga\Application\Hook\DbMigrationHook; +use Icinga\Application\MigrationManager; +use Icinga\Forms\MigrationForm; +use ipl\I18n\Translation; +use ipl\Web\Common\BaseItemList; +use ipl\Web\Widget\EmptyStateBar; + +class MigrationList extends BaseItemList +{ + use Translation; + + protected $baseAttributes = ['class' => 'item-list']; + + /** @var Generator<DbMigrationHook> */ + protected $data; + + /** @var ?MigrationForm */ + protected $migrationForm; + + /** @var bool Whether to render minimal migration list items */ + protected $minimal = true; + + /** + * Create a new migration list + * + * @param Generator<DbMigrationHook>|array<DbMigrationStep|DbMigrationHook> $data + * + * @param ?MigrationForm $form + */ + public function __construct($data, MigrationForm $form = null) + { + parent::__construct($data); + + $this->migrationForm = $form; + } + + /** + * Set whether to render minimal migration list items + * + * @param bool $minimal + * + * @return $this + */ + public function setMinimal(bool $minimal): self + { + $this->minimal = $minimal; + + return $this; + } + + /** + * Get whether to render minimal migration list items + * + * @return bool + */ + public function isMinimal(): bool + { + return $this->minimal; + } + + protected function getItemClass(): string + { + if ($this->isMinimal()) { + return MigrationListItem::class; + } + + return MigrationFileListItem::class; + } + + protected function assemble(): void + { + $itemClass = $this->getItemClass(); + if (! $this->isMinimal()) { + $this->getAttributes()->add('class', 'file-list'); + } + + /** @var DbMigrationHook $data */ + foreach ($this->data as $data) { + /** @var MigrationFileListItem|MigrationListItem $item */ + $item = new $itemClass($data, $this); + if ($item instanceof MigrationListItem && $this->migrationForm) { + $migrateButton = $this->migrationForm->createElement( + 'submit', + sprintf('migrate-%s', $data->getModuleName()), + [ + 'required' => false, + 'label' => $this->translate('Migrate'), + 'title' => sprintf( + $this->translatePlural( + 'Migrate %d pending migration', + 'Migrate all %d pending migrations', + $data->count() + ), + $data->count() + ) + ] + ); + + $mm = MigrationManager::instance(); + if ($data->isModule() && $mm->hasMigrations(DbMigrationHook::DEFAULT_MODULE)) { + $migrateButton->getAttributes() + ->set('disabled', true) + ->set( + 'title', + $this->translate( + 'Please apply all the pending migrations of Icinga Web first or use the apply all' + . ' button instead.' + ) + ); + } + + $this->migrationForm->registerElement($migrateButton); + + $item->setMigrateButton($migrateButton); + } + + $this->addHtml($item); + } + + if ($this->isEmpty()) { + $this->setTag('div'); + $this->addHtml(new EmptyStateBar(t('No items found.'))); + } + } +} diff --git a/library/Icinga/Web/Widget/ItemList/MigrationListItem.php b/library/Icinga/Web/Widget/ItemList/MigrationListItem.php new file mode 100644 index 0000000..284ce4c --- /dev/null +++ b/library/Icinga/Web/Widget/ItemList/MigrationListItem.php @@ -0,0 +1,151 @@ +<?php + +/* Icinga Web 2 | (c) 2023 Icinga GmbH | GPLv2+ */ + +namespace Icinga\Web\Widget\ItemList; + +use Icinga\Application\Hook\Common\DbMigrationStep; +use Icinga\Application\Hook\DbMigrationHook; +use ipl\Html\Attributes; +use ipl\Html\BaseHtmlElement; +use ipl\Html\Contract\FormElement; +use ipl\Html\FormattedString; +use ipl\Html\Html; +use ipl\Html\HtmlElement; +use ipl\Html\HtmlString; +use ipl\Html\Text; +use ipl\I18n\Translation; +use ipl\Web\Common\BaseListItem; +use ipl\Web\Url; +use ipl\Web\Widget\EmptyState; +use ipl\Web\Widget\Icon; +use ipl\Web\Widget\Link; +use LogicException; + +class MigrationListItem extends BaseListItem +{ + use Translation; + + /** @var ?FormElement */ + protected $migrateButton; + + /** @var DbMigrationHook Just for type hint */ + protected $item; + + /** + * Set a migration form of this list item + * + * @param FormElement $migrateButton + * + * @return $this + */ + public function setMigrateButton(FormElement $migrateButton): self + { + $this->migrateButton = $migrateButton; + + return $this; + } + + protected function assembleTitle(BaseHtmlElement $title): void + { + $title->addHtml( + FormattedString::create( + t('%s ', '<name>'), + HtmlElement::create('span', ['class' => 'subject'], $this->item->getName()) + ) + ); + } + + protected function assembleHeader(BaseHtmlElement $header): void + { + if ($this->migrateButton === null) { + throw new LogicException('Please set the migrate submit button beforehand'); + } + + $header->addHtml($this->createTitle()); + $header->addHtml($this->migrateButton); + } + + protected function assembleCaption(BaseHtmlElement $caption): void + { + $migrations = $this->item->getMigrations(); + /** @var DbMigrationStep $migration */ + $migration = array_shift($migrations); + if ($migration->getLastState()) { + if ($migration->getDescription()) { + $caption->addHtml(Text::create($migration->getDescription())); + } else { + $caption->addHtml(new EmptyState(Text::create($this->translate('No description provided.')))); + } + + $scriptPath = $migration->getScriptPath(); + /** @var string $parentDirs */ + $parentDirs = substr($scriptPath, (int) strpos($scriptPath, 'schema')); + $parentDirs = substr($parentDirs, 0, strrpos($parentDirs, '/') + 1); + + $title = new HtmlElement('div', Attributes::create(['class' => 'title'])); + $title->addHtml( + new HtmlElement('span', null, Text::create($parentDirs)), + new HtmlElement( + 'span', + Attributes::create(['class' => 'version']), + Text::create($migration->getVersion() . '.sql') + ), + new HtmlElement( + 'span', + Attributes::create(['class' => 'upgrade-failed']), + Text::create($this->translate('Upgrade failed')) + ) + ); + + $error = new HtmlElement('div', Attributes::create([ + 'class' => 'collapsible', + 'data-visible-height' => '58', + ])); + $error->addHtml(new HtmlElement('pre', null, new HtmlString(Html::escape($migration->getLastState())))); + + $errorSection = new HtmlElement('div', Attributes::create(['class' => 'errors-section',])); + $errorSection->addHtml( + new HtmlElement('header', null, new Icon('circle-xmark', ['class' => 'status-icon']), $title), + $caption, + $error + ); + + $caption->prependWrapper($errorSection); + } + } + + protected function assembleFooter(BaseHtmlElement $footer): void + { + $footer->addHtml((new MigrationList($this->item->getLatestMigrations(3)))->setMinimal(false)); + if ($this->item->count() > 3) { + $footer->addHtml( + new Link( + sprintf($this->translate('Show all %d migrations'), $this->item->count()), + Url::fromPath( + 'migrations/migration', + [DbMigrationHook::MIGRATION_PARAM => $this->item->getModuleName()] + ), + [ + 'data-base-target' => '_next', + 'class' => 'show-more' + ] + ) + ); + } + } + + protected function assembleMain(BaseHtmlElement $main): void + { + $main->addHtml($this->createHeader()); + $caption = $this->createCaption(); + if (! $caption->isEmpty()) { + $main->addHtml($caption); + } + + $footer = $this->createFooter(); + if ($footer) { + $main->addHtml($footer); + } + } +} diff --git a/library/Icinga/Web/Widget/Limiter.php b/library/Icinga/Web/Widget/Limiter.php new file mode 100644 index 0000000..d127aca --- /dev/null +++ b/library/Icinga/Web/Widget/Limiter.php @@ -0,0 +1,54 @@ +<?php +/* Icinga Web 2 | (c) 2014 Icinga Development Team | GPLv2+ */ + +namespace Icinga\Web\Widget; + +use Icinga\Forms\Control\LimiterControlForm; + +/** + * Limiter control widget + */ +class Limiter extends AbstractWidget +{ + /** + * Default limit for this instance + * + * @var int|null + */ + protected $defaultLimit; + + /** + * Get the default limit + * + * @return int|null + */ + public function getDefaultLimit() + { + return $this->defaultLimit; + } + + /** + * Set the default limit + * + * @param int $defaultLimit + * + * @return $this + */ + public function setDefaultLimit($defaultLimit) + { + $this->defaultLimit = (int) $defaultLimit; + return $this; + } + + /** + * {@inheritdoc} + */ + public function render() + { + $control = new LimiterControlForm(); + $control + ->setDefaultLimit($this->defaultLimit) + ->handleRequest(); + return (string)$control; + } +} diff --git a/library/Icinga/Web/Widget/Paginator.php b/library/Icinga/Web/Widget/Paginator.php new file mode 100644 index 0000000..5f3ef04 --- /dev/null +++ b/library/Icinga/Web/Widget/Paginator.php @@ -0,0 +1,167 @@ +<?php +/* Icinga Web 2 | (c) 2015 Icinga Development Team | GPLv2+ */ + +namespace Icinga\Web\Widget; + +use Icinga\Data\Paginatable; +use Icinga\Exception\ProgrammingError; + +/** + * Paginator + */ +class Paginator extends AbstractWidget +{ + /** + * The query the paginator widget is created for + * + * @var Paginatable + */ + protected $query; + + /** + * The view script in use + * + * @var string|array + */ + protected $viewScript = array('mixedPagination.phtml', 'default'); + + /** + * Set the query to create the paginator widget for + * + * @param Paginatable $query + * + * @return $this + */ + public function setQuery(Paginatable $query) + { + $this->query = $query; + return $this; + } + + /** + * Set the view script to use + * + * @param string|array $script + * + * @return $this + */ + public function setViewScript($script) + { + $this->viewScript = $script; + return $this; + } + + /** + * Render this paginator + */ + public function render() + { + if ($this->query === null) { + throw new ProgrammingError('Need a query to create the paginator widget for'); + } + + $itemCountPerPage = $this->query->getLimit(); + if (! $itemCountPerPage) { + return ''; // No pagination required + } + + $totalItemCount = count($this->query); + $pageCount = (int) ceil($totalItemCount / $itemCountPerPage); + $currentPage = $this->query->hasOffset() ? ($this->query->getOffset() / $itemCountPerPage) + 1 : 1; + $pagesInRange = $this->getPages($pageCount, $currentPage); + $variables = array( + 'totalItemCount' => $totalItemCount, + 'pageCount' => $pageCount, + 'itemCountPerPage' => $itemCountPerPage, + 'first' => 1, + 'current' => $currentPage, + 'last' => $pageCount, + 'pagesInRange' => $pagesInRange, + 'firstPageInRange' => min($pagesInRange), + 'lastPageInRange' => max($pagesInRange) + ); + + if ($currentPage > 1) { + $variables['previous'] = $currentPage - 1; + } + + if ($currentPage < $pageCount) { + $variables['next'] = $currentPage + 1; + } + + if (is_array($this->viewScript)) { + if ($this->viewScript[1] !== null) { + return $this->view()->partial($this->viewScript[0], $this->viewScript[1], $variables); + } + + return $this->view()->partial($this->viewScript[0], $variables); + } + + return $this->view()->partial($this->viewScript, $variables); + } + + /** + * Returns an array of "local" pages given the page count and current page number + * + * @return array + */ + protected function getPages($pageCount, $currentPage) + { + $range = array(); + + if ($pageCount < 10) { + // Show all pages if we have less than 10 + for ($i = 1; $i < 10; $i++) { + if ($i > $pageCount) { + break; + } + + $range[$i] = $i; + } + } else { + // More than 10 pages: + foreach (array(1, 2) as $i) { + $range[$i] = $i; + } + + if ($currentPage < 6) { + // We are on page 1-5 from + for ($i = 1; $i <= 7; $i++) { + $range[$i] = $i; + } + } else { + // Current page > 5 + $range[] = '...'; + + if (($pageCount - $currentPage) < 5) { + // Less than 5 pages left + $start = 5 - ($pageCount - $currentPage); + } else { + $start = 1; + } + + for ($i = $currentPage - $start; $i < ($currentPage + (4 - $start)); $i++) { + if ($i > $pageCount) { + break; + } + + $range[$i] = $i; + } + } + + if ($currentPage < ($pageCount - 2)) { + $range[] = '...'; + } + + foreach (array($pageCount - 1, $pageCount) as $i) { + $range[$i] = $i; + } + } + + if (empty($range)) { + $range[] = 1; + } + + return $range; + } +} diff --git a/library/Icinga/Web/Widget/SearchDashboard.php b/library/Icinga/Web/Widget/SearchDashboard.php new file mode 100644 index 0000000..1ce4c46 --- /dev/null +++ b/library/Icinga/Web/Widget/SearchDashboard.php @@ -0,0 +1,111 @@ +<?php +/* Icinga Web 2 | (c) 2014 Icinga Development Team | GPLv2+ */ + +namespace Icinga\Web\Widget; + +use Zend_Controller_Action_Exception; +use Icinga\Application\Icinga; +use Icinga\Web\Url; + +/** + * Class SearchDashboard display multiple search views on a single search page + */ +class SearchDashboard extends Dashboard +{ + /** + * Name for the search pane + * + * @var string + */ + const SEARCH_PANE = 'search'; + + /** + * {@inheritdoc} + */ + public function getTabs() + { + if ($this->tabs === null) { + $this->tabs = new Tabs(); + $this->tabs->add( + 'search', + array( + 'title' => t('Show Search', 'dashboard.pane.tooltip'), + 'label' => t('Search'), + 'url' => Url::fromRequest() + ) + ); + } + return $this->tabs; + } + + /** + * Load all available search dashlets from modules + * + * @param string $searchString + * + * @return $this + */ + public function search($searchString = '') + { + $pane = $this->createPane(self::SEARCH_PANE)->getPane(self::SEARCH_PANE)->setTitle(t('Search')); + $this->activate(self::SEARCH_PANE); + + $manager = Icinga::app()->getModuleManager(); + $searchUrls = array(); + + foreach ($manager->getLoadedModules() as $module) { + if ($this->getUser()->can($manager::MODULE_PERMISSION_NS . $module->getName())) { + $moduleSearchUrls = $module->getSearchUrls(); + if (! empty($moduleSearchUrls)) { + if ($searchString === '') { + $pane->add(t('Ready to search'), 'search/hint'); + return $this; + } + $searchUrls = array_merge($searchUrls, $moduleSearchUrls); + } + } + } + + usort($searchUrls, array($this, 'compareSearchUrls')); + + foreach (array_reverse($searchUrls) as $searchUrl) { + $pane->createDashlet( + $searchUrl->title . ': ' . $searchString, + Url::fromPath($searchUrl->url, array('q' => $searchString)) + )->setProgressLabel(t('Searching')); + } + + return $this; + } + + /** + * Renders the output + * + * @return string + * + * @throws Zend_Controller_Action_Exception + */ + public function render() + { + if (! $this->getPane(self::SEARCH_PANE)->hasDashlets()) { + throw new Zend_Controller_Action_Exception(t('Page not found'), 404); + } + return parent::render(); + } + + /** + * Compare search URLs based on their priority + * + * @param object $a + * @param object $b + * + * @return int + */ + private function compareSearchUrls($a, $b) + { + if ($a->priority === $b->priority) { + return 0; + } + return ($a->priority < $b->priority) ? -1 : 1; + } +} diff --git a/library/Icinga/Web/Widget/SingleValueSearchControl.php b/library/Icinga/Web/Widget/SingleValueSearchControl.php new file mode 100644 index 0000000..470518c --- /dev/null +++ b/library/Icinga/Web/Widget/SingleValueSearchControl.php @@ -0,0 +1,200 @@ +<?php +/* Icinga Web 2 | (c) 2021 Icinga GmbH | GPLv2+ */ + +namespace Icinga\Web\Widget; + +use Icinga\Application\Icinga; +use ipl\Html\Attributes; +use ipl\Html\Form; +use ipl\Html\FormElement\InputElement; +use ipl\Html\HtmlElement; +use ipl\Web\Control\SearchBar\Suggestions; +use ipl\Web\Url; + +class SingleValueSearchControl extends Form +{ + /** @var string */ + const DEFAULT_SEARCH_PARAMETER = 'q'; + + protected $defaultAttributes = ['class' => 'icinga-controls inline']; + + /** @var string */ + protected $searchParameter = self::DEFAULT_SEARCH_PARAMETER; + + /** @var string */ + protected $inputLabel; + + /** @var string */ + protected $submitLabel; + + /** @var Url */ + protected $suggestionUrl; + + /** @var array */ + protected $metaDataNames; + + /** + * Set the search parameter to use + * + * @param string $name + * @return $this + */ + public function setSearchParameter($name) + { + $this->searchParameter = $name; + + return $this; + } + + /** + * Set the input's label + * + * @param string $label + * + * @return $this + */ + public function setInputLabel($label) + { + $this->inputLabel = $label; + + return $this; + } + + /** + * Set the submit button's label + * + * @param string $label + * + * @return $this + */ + public function setSubmitLabel($label) + { + $this->submitLabel = $label; + + return $this; + } + + /** + * Set the suggestion url + * + * @param Url $url + * + * @return $this + */ + public function setSuggestionUrl(Url $url) + { + $this->suggestionUrl = $url; + + return $this; + } + + /** + * Set names for which hidden meta data elements should be created + * + * @param string ...$names + * + * @return $this + */ + public function setMetaDataNames(...$names) + { + $this->metaDataNames = $names; + + return $this; + } + + protected function assemble() + { + $suggestionsId = Icinga::app()->getRequest()->protectId('single-value-suggestions'); + + $this->addElement( + 'text', + $this->searchParameter, + [ + 'required' => true, + 'minlength' => 1, + 'autocomplete' => 'off', + 'class' => 'search', + 'data-enrichment-type' => 'completion', + 'data-term-suggestions' => '#' . $suggestionsId, + 'data-suggest-url' => $this->suggestionUrl, + 'placeholder' => $this->inputLabel + ] + ); + + if (! empty($this->metaDataNames)) { + $fieldset = new HtmlElement('fieldset'); + foreach ($this->metaDataNames as $name) { + $hiddenElement = $this->createElement('hidden', $this->searchParameter . '-' . $name); + $this->registerElement($hiddenElement); + $fieldset->addHtml($hiddenElement); + } + + $this->getElement($this->searchParameter)->prependWrapper($fieldset); + } + + $this->addElement( + 'submit', + 'btn_sumit', + [ + 'label' => $this->submitLabel, + 'class' => 'btn-primary' + ] + ); + + $this->add(HtmlElement::create('div', [ + 'id' => $suggestionsId, + 'class' => 'search-suggestions' + ])); + } + + /** + * Create a list of search suggestions based on the given groups + * + * @param array $groups + * + * @return HtmlElement + */ + public static function createSuggestions(array $groups) + { + $ul = new HtmlElement('ul'); + foreach ($groups as list($name, $entries)) { + if ($name) { + if ($entries === false) { + $ul->addHtml(HtmlElement::create('li', ['class' => 'failure-message'], [ + HtmlElement::create('em', null, t('Can\'t search:')), + $name + ])); + continue; + } elseif (empty($entries)) { + $ul->addHtml(HtmlElement::create('li', ['class' => 'failure-message'], [ + HtmlElement::create('em', null, t('No results:')), + $name + ])); + continue; + } else { + $ul->addHtml( + HtmlElement::create('li', ['class' => Suggestions::SUGGESTION_TITLE_CLASS], $name) + ); + } + } + + $index = 0; + foreach ($entries as list($label, $metaData)) { + $attributes = [ + 'value' => $label, + 'type' => 'button', + 'tabindex' => -1 + ]; + foreach ($metaData as $key => $value) { + $attributes['data-' . $key] = $value; + } + + $liAtrs = ['class' => $index === 0 ? 'default' : null]; + $ul->addHtml(new HtmlElement('li', Attributes::create($liAtrs), new InputElement(null, $attributes))); + $index++; + } + } + + return $ul; + } +} diff --git a/library/Icinga/Web/Widget/SortBox.php b/library/Icinga/Web/Widget/SortBox.php new file mode 100644 index 0000000..72b6f58 --- /dev/null +++ b/library/Icinga/Web/Widget/SortBox.php @@ -0,0 +1,260 @@ +<?php +/* Icinga Web 2 | (c) 2013 Icinga Development Team | GPLv2+ */ + +namespace Icinga\Web\Widget; + +use Icinga\Application\Icinga; +use Icinga\Data\Sortable; +use Icinga\Data\SortRules; +use Icinga\Web\Form; +use Icinga\Web\Request; + +/** + * SortBox widget + * + * The "SortBox" Widget allows you to create a generic sort input for sortable views. It automatically creates a select + * box with all sort options and a dropbox with the sort direction. It also handles automatic submission of sorting + * changes and draws an additional submit button when JavaScript is disabled. + * + * The constructor takes a string for the component name and an array containing the select options, where the key is + * the value to be submitted and the value is the label that will be shown. You then should call setRequest in order + * to make sure the form is correctly populated when a request with a sort parameter is being made. + * + * Call setQuery in case you'll do not want to handle URL parameters manually, but to automatically apply the user's + * chosen sort rules on the given sortable query. This will also allow the SortBox to display the user the correct + * default sort rules if the given query provides already some sort rules. + */ +class SortBox extends AbstractWidget +{ + /** + * An array containing all sort columns with their associated labels + * + * @var array + */ + protected $sortFields; + + /** + * An array containing default sort directions for specific columns + * + * The first entry will be used as default sort column. + * + * @var array + */ + protected $sortDefaults; + + /** + * The name used to uniquely identfy the forms being created + * + * @var string + */ + protected $name; + + /** + * The request to fetch sort rules from + * + * @var Request + */ + protected $request; + + /** + * The query to apply sort rules on + * + * @var Sortable + */ + protected $query; + + /** + * Create a SortBox with the entries from $sortFields + * + * @param string $name The name for the SortBox + * @param array $sortFields An array containing the columns and their labels to be displayed in the SortBox + * @param array $sortDefaults An array containing default sort directions for specific columns + */ + public function __construct($name, array $sortFields, array $sortDefaults = null) + { + $this->name = $name; + $this->sortFields = $sortFields; + $this->sortDefaults = $sortDefaults; + } + + /** + * Create a SortBox + * + * @param string $name The name for the SortBox + * @param array $sortFields An array containing the columns and their labels to be displayed in the SortBox + * @param array $sortDefaults An array containing default sort directions for specific columns + * + * @return SortBox + */ + public static function create($name, array $sortFields, array $sortDefaults = null) + { + return new static($name, $sortFields, $sortDefaults); + } + + /** + * Set the request to fetch sort rules from + * + * @param Request $request + * + * @return $this + */ + public function setRequest($request) + { + $this->request = $request; + return $this; + } + + /** + * Set the query to apply sort rules on + * + * @param Sortable $query + * + * @return $this + */ + public function setQuery(Sortable $query) + { + $this->query = $query; + return $this; + } + + /** + * Return the default sort rule for the query + * + * @param string $column An optional column + * + * @return array An array of two values: $column, $direction + */ + protected function getSortDefaults($column = null) + { + $direction = null; + if (! empty($this->sortDefaults) && ($column === null || isset($this->sortDefaults[$column]))) { + if ($column === null) { + reset($this->sortDefaults); + $column = key($this->sortDefaults); + } + + $direction = $this->sortDefaults[$column]; + } elseif ($this->query !== null && $this->query instanceof SortRules) { + $sortRules = $this->query->getSortRules(); + if ($column === null) { + $column = key($sortRules); + } + + if ($column !== null && isset($sortRules[$column]['order'])) { + $direction = strtoupper($sortRules[$column]['order']) === Sortable::SORT_DESC ? 'desc' : 'asc'; + } + } elseif ($column === null) { + reset($this->sortFields); + $column = key($this->sortFields); + } + + return array($column, $direction); + } + + /** + * Apply the sort rules from the given or current request on the query + * + * @param Request $request + * + * @return $this + */ + public function handleRequest(Request $request = null) + { + if ($this->query !== null) { + if ($request === null) { + $request = Icinga::app()->getRequest(); + } + + if (! ($sort = $request->getParam('sort'))) { + list($sort, $dir) = $this->getSortDefaults(); + } else { + list($_, $dir) = $this->getSortDefaults($sort); + } + + $this->query->order($sort, $request->getParam('dir', $dir)); + } + + return $this; + } + + /** + * Render this SortBox as HTML + * + * @return string + */ + public function render() + { + $columnForm = new Form(); + $columnForm->setTokenDisabled(); + $columnForm->setName($this->name . '-column'); + $columnForm->setAttrib('class', 'icinga-controls inline'); + $columnForm->addElement( + 'select', + 'sort', + array( + 'autosubmit' => true, + 'label' => $this->view()->translate('Sort by'), + 'multiOptions' => $this->sortFields, + 'decorators' => array( + array('ViewHelper'), + array('Label') + ) + ) + ); + + $column = null; + if ($this->request) { + $url = $this->request->getUrl(); + if ($url->hasParam('sort')) { + $column = $url->getParam('sort'); + + if ($url->hasParam('dir')) { + $direction = $url->getParam('dir'); + } else { + list($_, $direction) = $this->getSortDefaults($column); + } + } elseif ($url->hasParam('dir')) { + $direction = $url->getParam('dir'); + list($column, $_) = $this->getSortDefaults(); + } + } + + if ($column === null) { + list($column, $direction) = $this->getSortDefaults(); + } + + // TODO(el): ToggleButton :) + $toggle = array('asc' => 'sort-name-down', 'desc' => 'sort-name-up'); + unset($toggle[isset($direction) ? strtolower($direction) : 'asc']); + $newDirection = key($toggle); + $icon = current($toggle); + + $orderForm = new Form(); + $orderForm->setTokenDisabled(); + $orderForm->setName($this->name . '-order'); + $orderForm->setAttrib('class', 'inline sort-direction-control'); + $orderForm->addElement( + 'hidden', + 'dir' + ); + $orderForm->addElement( + 'button', + 'btn_submit', + array( + 'ignore' => true, + 'type' => 'submit', + 'label' => $this->view()->icon($icon), + 'decorators' => array('ViewHelper'), + 'escape' => false, + 'class' => 'link-button spinner', + 'value' => 'submit', + 'title' => t('Change sort direction'), + ) + ); + + + $columnForm->populate(array('sort' => $column)); + $orderForm->populate(array('dir' => $newDirection)); + return '<div class="sort-control">' . $columnForm . $orderForm . '</div>'; + } +} diff --git a/library/Icinga/Web/Widget/Tab.php b/library/Icinga/Web/Widget/Tab.php new file mode 100644 index 0000000..a367f00 --- /dev/null +++ b/library/Icinga/Web/Widget/Tab.php @@ -0,0 +1,323 @@ +<?php +/* Icinga Web 2 | (c) 2013 Icinga Development Team | GPLv2+ */ + +namespace Icinga\Web\Widget; + +use Icinga\Web\Url; + +/** + * A single tab, usually used through the tabs widget + * + * Will generate an <li> list item, with an optional link and icon + * + * @property string $name Tab identifier + * @property string $title Tab title + * @property string $icon Icon URL, preferrably relative to the Icinga + * base URL + * @property string|URL $url Action URL, preferrably relative to the Icinga + * base URL + * @property string $urlParams Action URL Parameters + * + */ +class Tab extends AbstractWidget +{ + /** + * Whether this tab is currently active + * + * @var bool + */ + private $active = false; + + /** + * Default values for widget properties + * + * @var array + */ + private $name = null; + + /** + * The title displayed for this tab + * + * @var string + */ + private $title = ''; + + /** + * The label displayed for this tab + * + * @var string + */ + private $label = ''; + + /** + * The Url this tab points to + * + * @var Url|null + */ + private $url = null; + + /** + * The parameters for this tab's Url + * + * @var array + */ + private $urlParams = array(); + + /** + * The icon image to use for this tab or null if none + * + * @var string|null + */ + private $icon = null; + + /** + * The icon class to use if $icon is null + * + * @var string|null + */ + private $iconCls = null; + + /** + * Additional a tag attributes + * + * @var array + */ + private $tagParams; + + /** + * Whether to open the link target on a new page + * + * @var boolean + */ + private $targetBlank = false; + + /** + * Data base target that determines if the link will be opened in a side-bar or in the main container + * + * @var null + */ + private $baseTarget = null; + + /** + * Sets an icon image for this tab + * + * @param string $icon The url of the image to use + */ + public function setIcon($icon) + { + if (is_string($icon) && strpos($icon, '.') !== false) { + $icon = Url::fromPath($icon); + } + $this->icon = $icon; + } + + /** + * Set's an icon class that will be used in an <i> tag if no icon image is set + * + * @param string $iconCls The CSS class of the icon to use + */ + public function setIconCls($iconCls) + { + $this->iconCls = $iconCls; + } + + /** + * @param mixed $name + */ + public function setName($name) + { + $this->name = $name; + } + + /** + * @return mixed + */ + public function getName() + { + return $this->name; + } + + /** + * Set the tab label + * + * @param string $label + */ + public function setLabel($label) + { + $this->label = $label; + } + + /** + * Get the tab label + * + * @return string + */ + public function getLabel() + { + if (! $this->label) { + return $this->title; + } + + return $this->label; + } + + /** + * @param mixed $title + */ + public function setTitle($title) + { + $this->title = $title; + } + + /** + * Set the Url this tab points to + * + * @param string|Url $url The Url to use for this tab + */ + public function setUrl($url) + { + if (is_string($url)) { + $url = Url::fromPath($url); + } + $this->url = $url; + } + + /** + * Get the tab's target URL + * + * @return Url + */ + public function getUrl() + { + return $this->url; + } + + /** + * Set the parameters to be set for this tabs Url + * + * @param array $url The Url parameters to set + */ + public function setUrlParams(array $urlParams) + { + $this->urlParams = $urlParams; + } + + /** + * Set additional a tag attributes + * + * @param array $tagParams + */ + public function setTagParams(array $tagParams) + { + $this->tagParams = $tagParams; + } + + public function setTargetBlank($value = true) + { + $this->targetBlank = $value; + } + + public function setBaseTarget($value) + { + $this->baseTarget = $value; + } + + /** + * Create a new Tab with the given properties + * + * Allowed properties are all properties for which a setter exists + * + * @param array $properties An array of properties + */ + public function __construct(array $properties = array()) + { + foreach ($properties as $name => $value) { + $setter = 'set' . ucfirst($name); + if (method_exists($this, $setter)) { + $this->$setter($value); + } + } + } + + /** + * Set this tab active (default) or inactive + * + * This is usually done through the tabs container widget, therefore it + * is not a good idea to directly call this function + * + * @param bool $active Whether the tab should be active + * + * @return $this + */ + public function setActive($active = true) + { + $this->active = (bool) $active; + return $this; + } + + /** + * @see Widget::render() + */ + public function render() + { + $view = $this->view(); + $classes = array(); + if ($this->active) { + $classes[] = 'active'; + } + + $caption = $view->escape($this->getLabel()); + $tagParams = $this->tagParams; + if ($this->targetBlank) { + // add warning to links that open in new tabs to improve accessibility, as recommended by WCAG20 G201 + $caption .= '<span class="info-box display-on-hover"> opens in new window </span>'; + $tagParams['target'] ='_blank'; + } + + if ($this->title) { + if ($tagParams !== null) { + $tagParams['title'] = $this->title; + $tagParams['aria-label'] = $this->title; + } else { + $tagParams = array( + 'title' => $this->title, + 'aria-label' => $this->title + ); + } + } + + if ($this->baseTarget !== null) { + $tagParams['data-base-target'] = $this->baseTarget; + } + + if ($this->icon !== null) { + if (strpos($this->icon, '.') === false) { + $caption = $view->icon($this->icon) . $caption; + } else { + $caption = $view->img($this->icon, null, array('class' => 'icon')) . $caption; + } + } + + if ($this->url !== null) { + $this->url->overwriteParams($this->urlParams); + + if ($tagParams !== null) { + $params = $view->propertiesToString($tagParams); + } else { + $params = ''; + } + + $tab = sprintf( + '<a href="%s"%s>%s</a>', + $this->view()->escape($this->url->getAbsoluteUrl()), + $params, + $caption + ); + } else { + $tab = $caption; + } + + $class = empty($classes) ? '' : sprintf(' class="%s"', implode(' ', $classes)); + return '<li ' . $class . '>' . $tab . "</li>\n"; + } +} diff --git a/library/Icinga/Web/Widget/Tabextension/DashboardAction.php b/library/Icinga/Web/Widget/Tabextension/DashboardAction.php new file mode 100644 index 0000000..a3e6c43 --- /dev/null +++ b/library/Icinga/Web/Widget/Tabextension/DashboardAction.php @@ -0,0 +1,35 @@ +<?php +/* Icinga Web 2 | (c) 2013 Icinga Development Team | GPLv2+ */ + +namespace Icinga\Web\Widget\Tabextension; + +use Icinga\Web\Url; +use Icinga\Web\Widget\Tabs; + +/** + * Tabextension that allows to add the current URL to a dashboard + * + * Displayed as a dropdown field in the tabs + */ +class DashboardAction implements Tabextension +{ + /** + * Applies the dashboard actions to the provided tabset + * + * @param Tabs $tabs The tabs object to extend with + */ + public function apply(Tabs $tabs) + { + $tabs->addAsDropdown( + 'dashboard', + array( + 'icon' => 'dashboard', + 'label' => t('Add To Dashboard'), + 'url' => Url::fromPath('dashboard/new-dashlet'), + 'urlParams' => array( + 'url' => rawurlencode(Url::fromRequest()->getRelativeUrl()) + ) + ) + ); + } +} diff --git a/library/Icinga/Web/Widget/Tabextension/DashboardSettings.php b/library/Icinga/Web/Widget/Tabextension/DashboardSettings.php new file mode 100644 index 0000000..fc7412a --- /dev/null +++ b/library/Icinga/Web/Widget/Tabextension/DashboardSettings.php @@ -0,0 +1,39 @@ +<?php +/* Icinga Web 2 | (c) 2014 Icinga Development Team | GPLv2+ */ + +namespace Icinga\Web\Widget\Tabextension; + +use Icinga\Web\Url; +use Icinga\Web\Widget\Tabs; + +/** + * Dashboard settings + */ +class DashboardSettings implements Tabextension +{ + /** + * Apply this tabextension to the provided tabs + * + * @param Tabs $tabs The tabbar to modify + */ + public function apply(Tabs $tabs) + { + $tabs->addAsDropdown( + 'dashboard_add', + array( + 'icon' => 'dashboard', + 'label' => t('Add Dashlet'), + 'url' => Url::fromPath('dashboard/new-dashlet') + ) + ); + + $tabs->addAsDropdown( + 'dashboard_settings', + array( + 'icon' => 'dashboard', + 'label' => t('Settings'), + 'url' => Url::fromPath('dashboard/settings') + ) + ); + } +} diff --git a/library/Icinga/Web/Widget/Tabextension/MenuAction.php b/library/Icinga/Web/Widget/Tabextension/MenuAction.php new file mode 100644 index 0000000..d713892 --- /dev/null +++ b/library/Icinga/Web/Widget/Tabextension/MenuAction.php @@ -0,0 +1,35 @@ +<?php +/* Icinga Web 2 | (c) 2013 Icinga Development Team | GPLv2+ */ + +namespace Icinga\Web\Widget\Tabextension; + +use Icinga\Web\Url; +use Icinga\Web\Widget\Tabs; + +/** + * Tabextension that allows to add the current URL as menu entry + * + * Displayed as a dropdown field in the tabs + */ +class MenuAction implements Tabextension +{ + /** + * Applies the menu actions to the provided tabset + * + * @param Tabs $tabs The tabs object to extend with + */ + public function apply(Tabs $tabs) + { + $tabs->addAsDropdown( + 'menu-entry', + array( + 'icon' => 'menu', + 'label' => t('Add To Menu'), + 'url' => Url::fromPath('navigation/add'), + 'urlParams' => array( + 'url' => rawurlencode(Url::fromRequest()->getRelativeUrl()) + ) + ) + ); + } +} diff --git a/library/Icinga/Web/Widget/Tabextension/OutputFormat.php b/library/Icinga/Web/Widget/Tabextension/OutputFormat.php new file mode 100644 index 0000000..d5d83af --- /dev/null +++ b/library/Icinga/Web/Widget/Tabextension/OutputFormat.php @@ -0,0 +1,114 @@ +<?php +/* Icinga Web 2 | (c) 2013 Icinga Development Team | GPLv2+ */ + +namespace Icinga\Web\Widget\Tabextension; + +use Icinga\Application\Platform; +use Icinga\Application\Hook; +use Icinga\Web\Url; +use Icinga\Web\Widget\Tab; +use Icinga\Web\Widget\Tabs; + +/** + * Tabextension that offers different output formats for the user in the dropdown area + */ +class OutputFormat implements Tabextension +{ + /** + * PDF output type + */ + const TYPE_PDF = 'pdf'; + + /** + * JSON output type + */ + const TYPE_JSON = 'json'; + + /** + * CSV output type + */ + const TYPE_CSV = 'csv'; + + /** + * An array of tabs to be added to the dropdown area + * + * @var array + */ + private $tabs = array(); + + /** + * Create a new OutputFormat extender + * + * In general, it's assumed that all types are supported when an outputFormat extension + * is added, so this class offers to remove specific types instead of adding ones + * + * @param array $disabled An array of output types to <b>not</b> show. + */ + public function __construct(array $disabled = array()) + { + foreach ($this->getSupportedTypes() as $type => $tabConfig) { + if (!in_array($type, $disabled)) { + $tabConfig['url'] = Url::fromRequest(); + $tab = new Tab($tabConfig); + $tab->setTargetBlank(); + $this->tabs[] = $tab; + } + } + } + + /** + * Applies the format selectio to the provided tabset + * + * @param Tabs $tabs The tabs object to extend with + * + * @see Tabextension::apply() + */ + public function apply(Tabs $tabs) + { + foreach ($this->tabs as $tab) { + $tabs->addAsDropdown($tab->getName(), $tab); + } + } + + /** + * Return an array containing the tab definitions for all supported types + * + * Using array_keys on this array or isset allows to check whether a + * requested type is supported + * + * @return array + */ + public function getSupportedTypes() + { + $supportedTypes = array(); + + $pdfexport = Hook::has('Pdfexport'); + + if ($pdfexport || Platform::extensionLoaded('gd')) { + $supportedTypes[self::TYPE_PDF] = array( + 'name' => 'pdf', + 'label' => 'PDF', + 'icon' => 'file-pdf', + 'urlParams' => array('format' => 'pdf'), + ); + } + + $supportedTypes[self::TYPE_CSV] = array( + 'name' => 'csv', + 'label' => 'CSV', + 'icon' => 'file-excel', + 'urlParams' => array('format' => 'csv') + ); + + if (Platform::extensionLoaded('json')) { + $supportedTypes[self::TYPE_JSON] = array( + 'name' => 'json', + 'label' => 'JSON', + 'icon' => 'doc-text', + 'urlParams' => array('format' => 'json') + ); + } + + return $supportedTypes; + } +} diff --git a/library/Icinga/Web/Widget/Tabextension/Tabextension.php b/library/Icinga/Web/Widget/Tabextension/Tabextension.php new file mode 100644 index 0000000..ea49c4b --- /dev/null +++ b/library/Icinga/Web/Widget/Tabextension/Tabextension.php @@ -0,0 +1,25 @@ +<?php +/* Icinga Web 2 | (c) 2013 Icinga Development Team | GPLv2+ */ + +namespace Icinga\Web\Widget\Tabextension; + +use Icinga\Web\Widget\Tabs; + +/** + * Tabextension interface that allows to extend a tabbar with reusable components + * + * Tabs can be either extended by creating a `Tabextension` and calling the `apply()` method + * or by calling the `\Icinga\Web\Widget\Tabs` `extend()` method and providing + * a tab extension + * + * @see \Icinga\Web\Widget\Tabs::extend() + */ +interface Tabextension +{ + /** + * Apply this tabextension to the provided tabs + * + * @param Tabs $tabs The tabbar to modify + */ + public function apply(Tabs $tabs); +} diff --git a/library/Icinga/Web/Widget/Tabs.php b/library/Icinga/Web/Widget/Tabs.php new file mode 100644 index 0000000..9efa423 --- /dev/null +++ b/library/Icinga/Web/Widget/Tabs.php @@ -0,0 +1,453 @@ +<?php +/* Icinga Web 2 | (c) 2013 Icinga Development Team | GPLv2+ */ + +namespace Icinga\Web\Widget; + +use Exception; +use Icinga\Exception\Http\HttpNotFoundException; +use Icinga\Exception\ProgrammingError; +use Icinga\Web\Url; +use Icinga\Web\Widget\Tabextension\Tabextension; +use Icinga\Application\Icinga; +use Countable; + +/** + * Navigation tab widget + */ +class Tabs extends AbstractWidget implements Countable +{ + /** + * Template used for the base tabs + * + * @var string + */ + private $baseTpl = <<< 'EOT' +<ul class="tabs primary-nav nav"> + {TABS} + {DROPDOWN} + {REFRESH} + {CLOSE} +</ul> +EOT; + + /** + * Template used for the tabs dropdown + * + * @var string + */ + private $dropdownTpl = <<< 'EOT' +<li class="dropdown-nav-item"> + <a href="#" class="dropdown-toggle" title="{TITLE}" aria-label="{TITLE}"> + <i aria-hidden="true" class="icon-down-open"></i> + </a> + <ul class="nav"> + {TABS} + </ul> +</li> +EOT; + + /** + * Template used for the close-button + * + * @var string + */ + private $closeTpl = <<< 'EOT' +<li class="close-container-btn"> + <a href="#" title="{TITLE}" aria-label="{TITLE}" class="close-container-control"> + <i aria-hidden="true" class="icon-cancel"></i> + </a> +</li> +EOT; + + /** + * Template used for the refresh icon + * + * @var string + */ + private $refreshTpl = <<< 'EOT' +<li> + <a class="refresh-container-control spinner" href="{URL}" title="{TITLE}" aria-label="{LABEL}"> + <i aria-hidden="true" class="icon-cw"></i> + </a> +</li> +EOT; + + /** + * This is where single tabs added to this container will be stored + * + * @var array + */ + private $tabs = array(); + + /** + * The name of the currently activated tab + * + * @var string + */ + private $active; + + /** + * Array of tab names which should be displayed in a dropdown + * + * @var array + */ + private $dropdownTabs = array(); + + /** + * Whether only the close-button should by rendered for this tab + * + * @var bool + */ + private $closeButtonOnly = false; + + /** + * Whether the tabs should contain a close-button + * + * @var bool + */ + private $closeTab = true; + + /** + * CSS class name(s) for the <ul> element + * + * @var string + */ + private $tab_class; + + /** + * Set whether the current tab is closable + */ + public function hideCloseButton() + { + $this->closeTab = false; + } + + /** + * Activate the tab with the given name + * + * If another tab is currently active it will be deactivated + * + * @param string $name Name of the tab going to be activated + * + * @return $this + * + * @throws HttpNotFoundException When the tab w/ the given name does not exist + * + */ + public function activate($name) + { + if (! $this->has($name)) { + throw new HttpNotFoundException('Can\'t activate tab %s. Tab does not exist', $name); + } + + if ($this->active !== null) { + $this->tabs[$this->active]->setActive(false); + } + $this->get($name)->setActive(); + $this->active = $name; + + return $this; + } + + /** + * Return the name of the active tab + * + * @return string + */ + public function getActiveName() + { + return $this->active; + } + + /** + * Set the CSS class name(s) for the <ul> element + * + * @param string $name CSS class name(s) + * + * @return $this + */ + public function setClass($name) + { + $this->tab_class = $name; + return $this; + } + + /** + * Whether the given tab name exists + * + * @param string $name Tab name + * + * @return bool + */ + public function has($name) + { + return array_key_exists($name, $this->tabs); + } + + /** + * Whether the given tab name exists + * + * @param string $name The tab you're interested in + * + * @return Tab + * + * @throws ProgrammingError When the given tab name doesn't exist + */ + public function get($name) + { + if (!$this->has($name)) { + return null; + } + return $this->tabs[$name]; + } + + /** + * Add a new tab + * + * A unique tab name is required, the Tab itself can either be an array + * with tab properties or an instance of an existing Tab + * + * @param string $name The new tab name + * @param array|Tab $tab The tab itself of its properties + * + * @return $this + * + * @throws ProgrammingError When the tab name already exists + */ + public function add($name, $tab) + { + if ($this->has($name)) { + throw new ProgrammingError( + 'Cannot add a tab named "%s" twice"', + $name + ); + } + return $this->set($name, $tab); + } + + /** + * Set a tab + * + * A unique tab name is required, will be replaced in case it already + * exists. The tab can either be an array with tab properties or an instance + * of an existing Tab + * + * @param string $name The new tab name + * @param array|Tab $tab The tab itself of its properties + * + * @return $this + */ + public function set($name, $tab) + { + if ($tab instanceof Tab) { + $this->tabs[$name] = $tab; + } else { + $this->tabs[$name] = new Tab($tab + array('name' => $name)); + } + return $this; + } + + /** + * Remove a tab + * + * @param string $name + * + * @return $this + */ + public function remove($name) + { + if ($this->has($name)) { + unset($this->tabs[$name]); + if (($dropdownIndex = array_search($name, $this->dropdownTabs, true)) !== false) { + array_splice($this->dropdownTabs, $dropdownIndex, 1); + } + } + + return $this; + } + + /** + * Add a tab to the dropdown on the right side of the tab-bar. + * + * @param $name + * @param $tab + */ + public function addAsDropdown($name, $tab) + { + $this->set($name, $tab); + $this->dropdownTabs[] = $name; + $this->dropdownTabs = array_unique($this->dropdownTabs); + } + + /** + * Render the dropdown area with its tabs and return the resulting HTML + * + * @return mixed|string + */ + private function renderDropdownTabs() + { + if (empty($this->dropdownTabs)) { + return ''; + } + $tabs = ''; + foreach ($this->dropdownTabs as $tabname) { + $tab = $this->get($tabname); + if ($tab === null) { + continue; + } + $tabs .= $tab; + } + return str_replace(array('{TABS}', '{TITLE}'), array($tabs, t('Dropdown menu')), $this->dropdownTpl); + } + + /** + * Render all tabs, except the ones in dropdown area and return the resulting HTML + * + * @return string + */ + private function renderTabs() + { + $tabs = ''; + foreach ($this->tabs as $name => $tab) { + // ignore tabs added to dropdown + if (in_array($name, $this->dropdownTabs)) { + continue; + } + $tabs .= $tab; + } + return $tabs; + } + + private function renderCloseTab() + { + return str_replace('{TITLE}', t('Close container'), $this->closeTpl); + } + + private function renderRefreshTab() + { + $url = Url::fromRequest(); + $tab = $this->get($this->getActiveName()); + + if ($tab !== null) { + $label = $this->view()->escape( + $tab->getLabel() + ); + } + + if (! empty($label)) { + $caption = $label; + } else { + $caption = t('Content'); + } + + $label = sprintf(t('Refresh the %s'), $caption); + $title = $label; + + $tpl = str_replace( + array( + '{URL}', + '{TITLE}', + '{LABEL}' + ), + array( + $this->view()->escape($url->getAbsoluteUrl()), + $title, + $label + ), + $this->refreshTpl + ); + + return $tpl; + } + + /** + * Render to HTML + * + * @see Widget::render + */ + public function render() + { + if (empty($this->tabs) || true === $this->closeButtonOnly) { + $tabs = ''; + $drop = ''; + } else { + $tabs = $this->renderTabs(); + $drop = $this->renderDropdownTabs(); + } + $close = $this->closeTab ? $this->renderCloseTab() : ''; + $refresh = $this->renderRefreshTab(); + + return str_replace( + array( + '{TABS}', + '{DROPDOWN}', + '{REFRESH}', + '{CLOSE}' + ), + array( + $tabs, + $drop, + $refresh, + $close + ), + $this->baseTpl + ); + } + + public function __toString() + { + try { + $html = $this->render(); + } catch (Exception $e) { + return htmlspecialchars($e->getMessage()); + } + return $html; + } + + /** + * Return the number of tabs + * + * @return int + * + * @see Countable + */ + public function count(): int + { + return count($this->tabs); + } + + /** + * Return all tabs contained in this tab panel + * + * @return array + */ + public function getTabs() + { + return $this->tabs; + } + + /** + * Whether to hide all elements except of the close button + * + * @param bool $value + * @return Tabs fluent interface + */ + public function showOnlyCloseButton($value = true) + { + $this->closeButtonOnly = $value; + return $this; + } + + /** + * Apply a Tabextension on this tabs object + * + * @param Tabextension $tabextension + * + * @return $this + */ + public function extend(Tabextension $tabextension) + { + $tabextension->apply($this); + return $this; + } +} diff --git a/library/Icinga/Web/Widget/Widget.php b/library/Icinga/Web/Widget/Widget.php new file mode 100644 index 0000000..879858a --- /dev/null +++ b/library/Icinga/Web/Widget/Widget.php @@ -0,0 +1,24 @@ +<?php +/* Icinga Web 2 | (c) 2013 Icinga Development Team | GPLv2+ */ + +namespace Icinga\Web\Widget; + +use Icinga\Web\View; +use Zend_View_Abstract; + +/** + * Abstract class for reusable view elements that can be + * rendered to a view + * + */ +interface Widget +{ + /** + * Renders this widget via the given view and returns the + * HTML as a string + * + * @param \Zend_View_Abstract $view + * @return string + */ + // public function render(Zend_View_Abstract $view); +} diff --git a/library/Icinga/Web/Window.php b/library/Icinga/Web/Window.php new file mode 100644 index 0000000..158483a --- /dev/null +++ b/library/Icinga/Web/Window.php @@ -0,0 +1,125 @@ +<?php +/* Icinga Web 2 | (c) 2014 Icinga Development Team | GPLv2+ */ + +namespace Icinga\Web; + +use Icinga\Application\Icinga; +use Icinga\Web\Session\SessionNamespace; + +class Window +{ + const UNDEFINED = 'undefined'; + + /** @var Window */ + protected static $window; + + /** @var string */ + protected $id; + + /** @var string */ + protected $containerId; + + public function __construct($id) + { + $parts = explode('_', $id, 2); + if (isset($parts[1])) { + $this->id = $parts[0]; + $this->containerId = $id; + } else { + $this->id = $id; + } + } + + /** + * Get whether the window's ID is undefined + * + * @return bool + */ + public function isUndefined() + { + return $this->id === self::UNDEFINED; + } + + /** + * Get the window's ID + * + * @return string + */ + public function getId() + { + return $this->id; + } + + /** + * Get the container's ID + * + * @return string + */ + public function getContainerId() + { + return $this->containerId ?: $this->id; + } + + /** + * Return a window-aware session by using the given prefix + * + * @param string $prefix The prefix to use + * @param bool $reset Whether to reset any existing session-data + * + * @return SessionNamespace + */ + public function getSessionNamespace($prefix, $reset = false) + { + $session = Session::getSession(); + + $identifier = $prefix . '_' . $this->getId(); + if ($reset && $session->hasNamespace($identifier)) { + $session->removeNamespace($identifier); + } + + $namespace = $session->getNamespace($identifier); + $nsUndef = $prefix . '_' . self::UNDEFINED; + + if (! $reset && ! $this->isUndefined() && $session->hasNamespace($nsUndef)) { + // We may not have any window-id on the very first request. Now we add + // all values from the namespace, that has been created in this case, + // to the new one and remove it afterwards. + foreach ($session->getNamespace($nsUndef) as $name => $value) { + $namespace->set($name, $value); + } + + $session->removeNamespace($nsUndef); + } + + return $namespace; + } + + /** + * Generate a random string + * + * @return string + */ + public static function generateId() + { + $letters = 'abcefghijklmnopqrstuvwxyz'; + return substr(str_shuffle($letters), 0, 12); + } + + /** + * @return Window + */ + public static function getInstance() + { + if (! isset(static::$window)) { + $id = Icinga::app()->getRequest()->getHeader('X-Icinga-WindowId'); + if (empty($id) || $id === static::UNDEFINED) { + Icinga::app()->getResponse()->setOverrideWindowId(); + $id = static::generateId(); + } + + static::$window = new Window($id); + } + + return static::$window; + } +} diff --git a/library/Icinga/Web/Wizard.php b/library/Icinga/Web/Wizard.php new file mode 100644 index 0000000..9a1b8b6 --- /dev/null +++ b/library/Icinga/Web/Wizard.php @@ -0,0 +1,720 @@ +<?php +/* Icinga Web 2 | (c) 2014 Icinga Development Team | GPLv2+ */ + +namespace Icinga\Web; + +use Icinga\Forms\ConfigForm; +use Icinga\Module\Setup\Forms\ModulePage; +use LogicException; +use InvalidArgumentException; +use Icinga\Web\Session\SessionNamespace; +use Icinga\Web\Form\Decorator\ElementDoubler; + +/** + * Container and controller for form based wizards + */ +class Wizard +{ + /** + * An integer describing the wizard's forward direction + */ + const FORWARD = 0; + + /** + * An integer describing the wizard's backward direction + */ + const BACKWARD = 1; + + /** + * An integer describing that the wizard does not change its position + */ + const NO_CHANGE = 2; + + /** + * The name of the button to advance the wizard's position + */ + const BTN_NEXT = 'btn_next'; + + /** + * The name of the button to rewind the wizard's position + */ + const BTN_PREV = 'btn_prev'; + + /** + * The name and id of the element for showing the user an activity indicator when advancing the wizard + */ + const PROGRESS_ELEMENT = 'wizard_progress'; + + /** + * This wizard's parent + * + * @var Wizard + */ + protected $parent; + + /** + * The name of the wizard's current page + * + * @var string + */ + protected $currentPage; + + /** + * The pages being part of this wizard + * + * @var array + */ + protected $pages = array(); + + /** + * Initialize a new wizard + */ + public function __construct() + { + $this->init(); + } + + /** + * Run additional initialization routines + * + * Should be implemented by subclasses to add pages to the wizard. + */ + protected function init() + { + } + + /** + * Return this wizard's parent or null in case it has none + * + * @return Wizard|null + */ + public function getParent() + { + return $this->parent; + } + + /** + * Set this wizard's parent + * + * @param Wizard $wizard The parent wizard + * + * @return $this + */ + public function setParent(Wizard $wizard) + { + $this->parent = $wizard; + return $this; + } + + /** + * Return the pages being part of this wizard + * + * In case this is a nested wizard a flattened array of all contained pages is returned. + * + * @return array + */ + public function getPages() + { + $pages = array(); + foreach ($this->pages as $page) { + if ($page instanceof self) { + $pages = array_merge($pages, $page->getPages()); + } else { + $pages[] = $page; + } + } + + return $pages; + } + + /** + * Return the page with the given name + * + * Note that it's also possible to retrieve a nested wizard's page by using this method. + * + * @param string $name The name of the page to return + * + * @return ModulePage|Form|null The page or null in case there is no page with the given name + */ + public function getPage($name) + { + foreach ($this->getPages() as $page) { + if ($name === $page->getName()) { + return $page; + } + } + } + + /** + * Add a new page or wizard to this wizard + * + * @param Form|Wizard $page The page or wizard to add to the wizard + * + * @return $this + */ + public function addPage($page) + { + if (! $page instanceof Form && ! $page instanceof self) { + throw new InvalidArgumentException( + 'The $page argument must be an instance of Icinga\Web\Form ' + . 'or Icinga\Web\Wizard but is of type: ' . get_class($page) + ); + } elseif ($page instanceof self) { + $page->setParent($this); + } + + $this->pages[] = $page; + return $this; + } + + /** + * Add multiple pages or wizards to this wizard + * + * @param array $pages The pages or wizards to add to the wizard + * + * @return $this + */ + public function addPages(array $pages) + { + foreach ($pages as $page) { + $this->addPage($page); + } + + return $this; + } + + /** + * Assert that this wizard has any pages + * + * @throws LogicException In case this wizard has no pages + */ + protected function assertHasPages() + { + $pages = $this->getPages(); + if (count($pages) < 2) { + throw new LogicException("Although Chuck Norris can advance a wizard with less than two pages, you can't."); + } + } + + /** + * Return the current page of this wizard + * + * @return Form + * + * @throws LogicException In case the name of the current page currently being set is invalid + */ + public function getCurrentPage() + { + if ($this->parent) { + return $this->parent->getCurrentPage(); + } + + if ($this->currentPage === null) { + $this->assertHasPages(); + $pages = $this->getPages(); + $this->currentPage = $this->getSession()->get('current_page', $pages[0]->getName()); + } + + if (($page = $this->getPage($this->currentPage)) === null) { + throw new LogicException(sprintf('No page found with name "%s"', $this->currentPage)); + } + + return $page; + } + + /** + * Set the current page of this wizard + * + * @param Form $page The page to set as current page + * + * @return $this + */ + public function setCurrentPage(Form $page) + { + $this->currentPage = $page->getName(); + $this->getSession()->set('current_page', $this->currentPage); + return $this; + } + + /** + * Setup the given page that is either going to be displayed or validated + * + * Implement this method in a subclass to populate default values and/or other data required to process the form. + * + * @param Form $page The page to setup + * @param Request $request The current request + */ + public function setupPage(Form $page, Request $request) + { + } + + /** + * Process the given request using this wizard + * + * Validate the request data using the current page, update the wizard's + * position and redirect to the page's redirect url upon success. + * + * @param Request $request The request to be processed + * + * @return Request The request supposed to be processed + */ + public function handleRequest(Request $request = null) + { + $page = $this->getCurrentPage(); + + if (($wizard = $this->findWizard($page)) !== null) { + return $wizard->handleRequest($request); + } + + if ($request === null) { + $request = $page->getRequest(); + } + + $this->setupPage($page, $request); + $requestData = $this->getRequestData($page, $request); + if ($page->wasSent($requestData)) { + if (($requestedPage = $this->getRequestedPage($requestData)) !== null) { + $isValid = false; + $direction = $this->getDirection($request); + if ($direction === static::FORWARD && $page->isValid($requestData)) { + $isValid = true; + if ($this->isLastPage($page)) { + $this->setIsFinished(); + } + } elseif ($direction === static::BACKWARD) { + $page->populate($requestData); + $isValid = true; + } + + if ($isValid) { + $pageData = & $this->getPageData(); + $pageData[$page->getName()] = ConfigForm::transformEmptyValuesToNull($page->getValues()); + $this->setCurrentPage($this->getNewPage($requestedPage, $page)); + $page->getResponse()->redirectAndExit($page->getRedirectUrl()); + } + } elseif ($page->getValidatePartial()) { + $page->isValidPartial($requestData); + } else { + $page->populate($requestData); + } + } elseif (($pageData = $this->getPageData($page->getName())) !== null) { + $page->populate($pageData); + } + + return $request; + } + + /** + * Return the wizard for the given page or null if its not part of a wizard + * + * @param Form $page The page to return its wizard for + * + * @return Wizard|null + */ + protected function findWizard(Form $page) + { + foreach ($this->getWizards() as $wizard) { + if ($wizard->getPage($page->getName()) === $page) { + return $wizard; + } + } + } + + /** + * Return this wizard's child wizards + * + * @return array + */ + protected function getWizards() + { + $wizards = array(); + foreach ($this->pages as $pageOrWizard) { + if ($pageOrWizard instanceof self) { + $wizards[] = $pageOrWizard; + } + } + + return $wizards; + } + + /** + * Return the request data based on given form's request method + * + * @param Form $page The page to fetch the data for + * @param Request $request The request to fetch the data from + * + * @return array + */ + protected function getRequestData(Form $page, Request $request) + { + if (strtolower($request->getMethod()) === $page->getMethod()) { + return $request->{'get' . ($request->isPost() ? 'Post' : 'Query')}(); + } + + return array(); + } + + /** + * Return the name of the requested page + * + * @param array $requestData The request's data + * + * @return null|string The name of the requested page or null in case no page has been requested + */ + protected function getRequestedPage(array $requestData) + { + if ($this->parent) { + return $this->parent->getRequestedPage($requestData); + } + + if (isset($requestData[static::BTN_NEXT])) { + return $requestData[static::BTN_NEXT]; + } elseif (isset($requestData[static::BTN_PREV])) { + return $requestData[static::BTN_PREV]; + } + } + + /** + * Return the direction of this wizard using the given request + * + * @param Request $request The request to use + * + * @return int The direction @see Wizard::FORWARD @see Wizard::BACKWARD @see Wizard::NO_CHANGE + */ + protected function getDirection(Request $request = null) + { + if ($this->parent) { + return $this->parent->getDirection($request); + } + + $currentPage = $this->getCurrentPage(); + + if ($request === null) { + $request = $currentPage->getRequest(); + } + + $requestData = $this->getRequestData($currentPage, $request); + if (isset($requestData[static::BTN_NEXT])) { + return static::FORWARD; + } elseif (isset($requestData[static::BTN_PREV])) { + return static::BACKWARD; + } + + return static::NO_CHANGE; + } + + /** + * Return the new page to set as current page + * + * Permission is checked by verifying that the requested page or its previous page has page data available. + * The requested page is automatically permitted without any checks if the origin page is its previous + * page or one that occurs later in order. + * + * @param string $requestedPage The name of the requested page + * @param Form $originPage The origin page + * + * @return Form The new page + * + * @throws InvalidArgumentException In case the requested page does not exist or is not permitted yet + */ + protected function getNewPage($requestedPage, Form $originPage) + { + if ($this->parent) { + return $this->parent->getNewPage($requestedPage, $originPage); + } + + if (($page = $this->getPage($requestedPage)) !== null) { + $permitted = true; + + $pages = $this->getPages(); + if (! $this->hasPageData($requestedPage) && ($index = array_search($page, $pages, true)) > 0) { + $previousPage = $pages[$index - 1]; + if ($originPage === null || ($previousPage->getName() !== $originPage->getName() + && array_search($originPage, $pages, true) < $index)) { + $permitted = $this->hasPageData($previousPage->getName()); + } + } + + if ($permitted) { + return $page; + } + } + + throw new InvalidArgumentException( + sprintf('"%s" is either an unknown page or one you are not permitted to view', $requestedPage) + ); + } + + /** + * Return the next or previous page based on the given one + * + * @param Form $page The page to skip + * + * @return Form + */ + protected function skipPage(Form $page) + { + if ($this->parent) { + return $this->parent->skipPage($page); + } + + if ($this->hasPageData($page->getName())) { + $pageData = & $this->getPageData(); + unset($pageData[$page->getName()]); + } + + $pages = $this->getPages(); + if ($this->getDirection() === static::FORWARD) { + $nextPage = $pages[array_search($page, $pages, true) + 1]; + $newPage = $this->getNewPage($nextPage->getName(), $page); + } else { // $this->getDirection() === static::BACKWARD + $previousPage = $pages[array_search($page, $pages, true) - 1]; + $newPage = $this->getNewPage($previousPage->getName(), $page); + } + + return $newPage; + } + + /** + * Return whether the given page is this wizard's last page + * + * @param Form $page The page to check + * + * @return bool + */ + protected function isLastPage(Form $page) + { + if ($this->parent) { + return $this->parent->isLastPage($page); + } + + $pages = $this->getPages(); + return $page->getName() === end($pages)->getName(); + } + + /** + * Return whether all of this wizard's pages were visited by the user + * + * The base implementation just verifies that the very last page has page data available. + * + * @return bool + */ + public function isComplete() + { + $pages = $this->getPages(); + return $this->hasPageData($pages[count($pages) - 1]->getName()); + } + + /** + * Set whether this wizard has been completed + * + * @param bool $state Whether this wizard has been completed + * + * @return $this + */ + public function setIsFinished($state = true) + { + $this->getSession()->set('isFinished', $state); + return $this; + } + + /** + * Return whether this wizard has been completed + * + * @return bool + */ + public function isFinished() + { + return $this->getSession()->get('isFinished', false); + } + + /** + * Return the overall page data or one for a particular page + * + * Note that this method returns by reference so in order to update the + * returned array set this method's return value also by reference. + * + * @param string $pageName The page for which to return the data + * + * @return array + */ + public function & getPageData($pageName = null) + { + $session = $this->getSession(); + + if (false === isset($session->page_data)) { + $session->page_data = array(); + } + + $pageData = & $session->getByRef('page_data'); + if ($pageName !== null) { + $data = null; + if (isset($pageData[$pageName])) { + $data = & $pageData[$pageName]; + } + + return $data; + } + + return $pageData; + } + + /** + * Return whether there is any data for the given page + * + * @param string $pageName The name of the page to check + * + * @return bool + */ + public function hasPageData($pageName) + { + return $this->getPageData($pageName) !== null; + } + + /** + * Return a session to be used by this wizard + * + * @return SessionNamespace + */ + public function getSession() + { + if ($this->parent) { + return $this->parent->getSession(); + } + + return Session::getSession()->getNamespace(get_class($this)); + } + + /** + * Clear the session being used by this wizard + */ + public function clearSession() + { + $this->getSession()->clear(); + } + + /** + * Add buttons to the given page based on its position in the page-chain + * + * @param Form $page The page to add the buttons to + */ + protected function addButtons(Form $page) + { + $pages = $this->getPages(); + $index = array_search($page, $pages, true); + if ($index === 0) { + $page->addElement( + 'button', + static::BTN_NEXT, + array( + 'class' => 'control-button btn-primary', + 'type' => 'submit', + 'value' => $pages[1]->getName(), + 'label' => t('Next'), + 'decorators' => array('ViewHelper', 'Spinner') + ) + ); + } elseif ($index < count($pages) - 1) { + $page->addElement( + 'button', + static::BTN_PREV, + array( + 'class' => 'control-button', + 'type' => 'submit', + 'value' => $pages[$index - 1]->getName(), + 'label' => t('Back'), + 'decorators' => array('ViewHelper'), + 'formnovalidate' => 'formnovalidate' + ) + ); + $page->addElement( + 'button', + static::BTN_NEXT, + array( + 'class' => 'control-button btn-primary', + 'type' => 'submit', + 'value' => $pages[$index + 1]->getName(), + 'label' => t('Next'), + 'decorators' => array('ViewHelper') + ) + ); + } else { + $page->addElement( + 'button', + static::BTN_PREV, + array( + 'class' => 'control-button', + 'type' => 'submit', + 'value' => $pages[$index - 1]->getName(), + 'label' => t('Back'), + 'decorators' => array('ViewHelper'), + 'formnovalidate' => 'formnovalidate' + ) + ); + $page->addElement( + 'button', + static::BTN_NEXT, + array( + 'class' => 'control-button btn-primary', + 'type' => 'submit', + 'value' => $page->getName(), + 'label' => t('Finish'), + 'decorators' => array('ViewHelper') + ) + ); + } + + $page->setAttrib('data-progress-element', static::PROGRESS_ELEMENT); + $page->addElement( + 'note', + static::PROGRESS_ELEMENT, + array( + 'order' => 99, // Ensures that it's shown on the right even if a sub-class adds another button + 'decorators' => array( + 'ViewHelper', + array('Spinner', array('id' => static::PROGRESS_ELEMENT)) + ) + ) + ); + + $page->addDisplayGroup( + array(static::BTN_PREV, static::BTN_NEXT, static::PROGRESS_ELEMENT), + 'buttons', + array( + 'decorators' => array( + 'FormElements', + new ElementDoubler(array( + 'double' => static::BTN_NEXT, + 'condition' => static::BTN_PREV, + 'placement' => ElementDoubler::PREPEND, + 'attributes' => array('tabindex' => -1, 'class' => 'double') + )), + array('HtmlTag', array('tag' => 'div', 'class' => 'buttons')) + ) + ) + ); + } + + /** + * Return the current page of this wizard with appropriate buttons being added + * + * @return Form + */ + public function getForm() + { + $form = $this->getCurrentPage(); + $form->create(); // Make sure that buttons are displayed at the very bottom + $this->addButtons($form); + return $form; + } + + /** + * Return the current page of this wizard rendered as HTML + * + * @return string + */ + public function __toString() + { + return (string) $this->getForm(); + } +} |