diff options
Diffstat (limited to '')
10 files changed, 2457 insertions, 0 deletions
diff --git a/library/Director/Web/Controller/ActionController.php b/library/Director/Web/Controller/ActionController.php new file mode 100644 index 0000000..6282a16 --- /dev/null +++ b/library/Director/Web/Controller/ActionController.php @@ -0,0 +1,253 @@ +<?php + +namespace Icinga\Module\Director\Web\Controller; + +use gipfl\Translation\StaticTranslator; +use Icinga\Application\Benchmark; +use Icinga\Data\Paginatable; +use Icinga\Exception\NotFoundError; +use Icinga\Exception\ProgrammingError; +use Icinga\Module\Director\Monitoring; +use Icinga\Module\Director\Web\Controller\Extension\CoreApi; +use Icinga\Module\Director\Web\Controller\Extension\DirectorDb; +use Icinga\Module\Director\Web\Controller\Extension\RestApi; +use Icinga\Module\Director\Web\Window; +use Icinga\Security\SecurityException; +use Icinga\Web\Controller; +use Icinga\Web\UrlParams; +use InvalidArgumentException; +use gipfl\IcingaWeb2\Translator; +use gipfl\IcingaWeb2\Link; +use gipfl\IcingaWeb2\Widget\ControlsAndContent; +use gipfl\IcingaWeb2\Controller\Extension\ControlsAndContentHelper; +use gipfl\IcingaWeb2\Zf1\SimpleViewRenderer; +use GuzzleHttp\Psr7\ServerRequest; +use Psr\Http\Message\ServerRequestInterface; + +abstract class ActionController extends Controller implements ControlsAndContent +{ + use DirectorDb; + use CoreApi; + use RestApi; + use ControlsAndContentHelper; + + protected $isApified = false; + + /** @var UrlParams Hint for IDE, somehow does not work in web */ + protected $params; + + /** @var Monitoring */ + private $monitoring; + + /** + * @throws SecurityException + * @throws \Icinga\Exception\AuthenticationException + * @throws \Icinga\Exception\NotFoundError + */ + public function init() + { + if (! $this->getRequest()->isApiRequest() + && $this->Config()->get('frontend', 'disabled', 'no') === 'yes' + ) { + throw new NotFoundError('Not found'); + } + $this->initializeTranslator(); + Benchmark::measure('Director base Controller init()'); + $this->checkForRestApiRequest(); + $this->checkDirectorPermissions(); + $this->checkSpecialDirectorPermissions(); + } + + protected function initializeTranslator() + { + StaticTranslator::set(new Translator('director')); + } + + public function getAuth() + { + return $this->Auth(); + } + + /** + * @codingStandardsIgnoreStart + * @return Window + */ + public function Window() + { + // @codingStandardsIgnoreEnd + if ($this->window === null) { + $this->window = new Window( + $this->_request->getHeader('X-Icinga-WindowId', Window::UNDEFINED) + ); + } + return $this->window; + } + + /** + * @throws SecurityException + */ + protected function checkDirectorPermissions() + { + $this->assertPermission('director/admin'); + } + + /** + * @throws SecurityException + */ + protected function checkSpecialDirectorPermissions() + { + if ($this->params->get('format') === 'sql') { + $this->assertPermission('director/showsql'); + } + } + + /** + * Assert that the current user has one of the given permission + * + * @param array $permissions Permission name list + * + * @return $this + * @throws SecurityException If the current user lacks the given permission + */ + protected function assertOneOfPermissions($permissions) + { + $auth = $this->Auth(); + + foreach ($permissions as $permission) { + if ($auth->hasPermission($permission)) { + return $this; + } + } + + throw new SecurityException( + 'Got none of the following permissions: %s', + implode(', ', $permissions) + ); + } + + /** + * @param int $interval + * @return $this + */ + public function setAutorefreshInterval($interval) + { + if (! $this->getRequest()->isApiRequest()) { + try { + parent::setAutorefreshInterval($interval); + } catch (ProgrammingError $e) { + throw new InvalidArgumentException($e->getMessage()); + } + } + + return $this; + } + + /** + * @return ServerRequestInterface + */ + protected function getServerRequest() + { + return ServerRequest::fromGlobals(); + } + + protected function applyPaginationLimits(Paginatable $paginatable, $limit = 25, $offset = null) + { + $limit = $this->params->get('limit', $limit); + $page = $this->params->get('page', $offset); + + $paginatable->limit($limit, $page > 0 ? ($page - 1) * $limit : 0); + + return $paginatable; + } + + protected function addAddLink($title, $url, $urlParams = null, $target = '_next') + { + $this->actions()->add(Link::create( + $this->translate('Add'), + $url, + $urlParams, + [ + 'class' => 'icon-plus', + 'title' => $title, + 'data-base-target' => $target + ] + )); + + return $this; + } + + protected function addBackLink($url, $urlParams = null) + { + $this->actions()->add(new Link( + $this->translate('back'), + $url, + $urlParams, + ['class' => 'icon-left-big'] + )); + + return $this; + } + + protected function sendUnsupportedMethod() + { + $method = strtoupper($this->getRequest()->getMethod()) ; + $response = $this->getResponse(); + $this->sendJsonError($response, sprintf( + 'Method %s is not supported', + $method + ), 422); // TODO: check response code + } + + /** + * @param string $permission + * @return $this + * @throws SecurityException + */ + public function assertPermission($permission) + { + parent::assertPermission($permission); + return $this; + } + + public function postDispatch() + { + Benchmark::measure('Director postDispatch'); + if ($this->view->content || $this->view->controls) { + $viewRenderer = new SimpleViewRenderer(); + $viewRenderer->replaceZendViewRenderer(); + $this->view = $viewRenderer->view; + // Hint -> $this->view->compact is the only way since v2.8.0 + if ($this->view->compact || $this->getOriginalUrl()->getParam('view') === 'compact') { + if ($this->view->controls) { + $this->controls()->getAttributes()->add('style', 'display: none;'); + } + } + } else { + $viewRenderer = null; + } + + $cType = $this->getResponse()->getHeader('Content-Type', true); + if ($this->getRequest()->isApiRequest() || ($cType !== null && $cType !== 'text/html')) { + $this->_helper->layout()->disableLayout(); + if ($viewRenderer) { + $viewRenderer->disable(); + } else { + $this->_helper->viewRenderer->setNoRender(true); + } + } + + parent::postDispatch(); // TODO: Change the autogenerated stub + } + + /** + * @return Monitoring + */ + protected function monitoring() + { + if ($this->monitoring === null) { + $this->monitoring = new Monitoring; + } + + return $this->monitoring; + } +} diff --git a/library/Director/Web/Controller/BranchHelper.php b/library/Director/Web/Controller/BranchHelper.php new file mode 100644 index 0000000..ac2a480 --- /dev/null +++ b/library/Director/Web/Controller/BranchHelper.php @@ -0,0 +1,76 @@ +<?php + +namespace Icinga\Module\Director\Web\Controller; + +use Icinga\Module\Director\Data\Db\DbObjectStore; +use Icinga\Module\Director\Data\Db\DbObjectTypeRegistry; +use Icinga\Module\Director\Db\Branch\Branch; +use Icinga\Module\Director\Db\Branch\BranchStore; +use Icinga\Module\Director\Db\Branch\BranchSupport; +use Icinga\Module\Director\Objects\IcingaObject; +use Icinga\Module\Director\Web\Widget\NotInBranchedHint; + +trait BranchHelper +{ + /** @var Branch */ + protected $branch; + + /** @var BranchStore */ + protected $branchStore; + + /** + * @return false|\Ramsey\Uuid\UuidInterface + */ + protected function getBranchUuid() + { + return $this->getBranch()->getUuid(); + } + + protected function getBranch() + { + if ($this->branch === null) { + /** @var ActionController $this */ + $this->branch = Branch::forRequest($this->getRequest(), $this->getBranchStore(), $this->Auth()); + } + + return $this->branch; + } + + /** + * @return BranchStore + */ + protected function getBranchStore() + { + if ($this->branchStore === null) { + $this->branchStore = new BranchStore($this->db()); + } + + return $this->branchStore; + } + + protected function hasBranch() + { + return $this->getBranchUuid() !== null; + } + + protected function enableStaticObjectLoader($table) + { + if (BranchSupport::existsForTableName($table)) { + IcingaObject::setDbObjectStore(new DbObjectStore($this->db(), $this->getBranch())); + } + } + + /** + * @param string $subject + * @return bool + */ + protected function showNotInBranch($subject) + { + if ($this->getBranch()->isBranch()) { + $this->content()->add(new NotInBranchedHint($subject, $this->getBranch(), $this->Auth())); + return true; + } + + return false; + } +} diff --git a/library/Director/Web/Controller/Extension/CoreApi.php b/library/Director/Web/Controller/Extension/CoreApi.php new file mode 100644 index 0000000..75cba50 --- /dev/null +++ b/library/Director/Web/Controller/Extension/CoreApi.php @@ -0,0 +1,46 @@ +<?php + +namespace Icinga\Module\Director\Web\Controller\Extension; + +use Icinga\Module\Director\Objects\IcingaEndpoint; +use Icinga\Module\Director\Core\CoreApi as Api; + +trait CoreApi +{ + /** @var Api */ + private $api; + + /** + * @return Api|null + */ + public function getApiIfAvailable() + { + if ($this->api === null) { + if ($this->db()->hasDeploymentEndpoint()) { + $endpoint = $this->db()->getDeploymentEndpoint(); + $this->api = $endpoint->api(); + } + } + + return $this->api; + } + + /** + * @param string $endpointName + * @return Api + */ + public function api($endpointName = null) + { + if ($this->api === null) { + if ($endpointName === null) { + $endpoint = $this->db()->getDeploymentEndpoint(); + } else { + $endpoint = IcingaEndpoint::load($endpointName, $this->db()); + } + + $this->api = $endpoint->api(); + } + + return $this->api; + } +} diff --git a/library/Director/Web/Controller/Extension/DirectorDb.php b/library/Director/Web/Controller/Extension/DirectorDb.php new file mode 100644 index 0000000..03bec81 --- /dev/null +++ b/library/Director/Web/Controller/Extension/DirectorDb.php @@ -0,0 +1,160 @@ +<?php + +namespace Icinga\Module\Director\Web\Controller\Extension; + +use Icinga\Module\Director\Db; +use Icinga\Module\Director\Web\Controller\ActionController; +use Icinga\Module\Director\Web\Window; +use RuntimeException; + +trait DirectorDb +{ + /** @var Db */ + private $db; + + protected function getDbResourceName() + { + if ($name = $this->getDbResourceNameFromRequest()) { + return $name; + } elseif ($name = $this->getPreferredDbResourceName()) { + return $name; + } else { + return $this->getFirstDbResourceName(); + } + } + + protected function getDbResourceNameFromRequest() + { + $param = 'dbResourceName'; + // We shouldn't access _POST and _GET. However, this trait is used + // in various places - and our Request is going to be replaced anyways. + // So, let's not over-engineer things, this is quick & dirty: + if (isset($_POST[$param])) { + $name = $_POST[$param]; + } elseif (isset($_GET[$param])) { + $name = $_GET[$param]; + } else { + return null; + } + + if (in_array($name, $this->listAllowedDbResourceNames())) { + return $name; + } else { + return null; + } + } + + protected function getPreferredDbResourceName() + { + return $this->getWindowSessionValue('db_resource'); + } + + protected function getFirstDbResourceName() + { + $names = $this->listAllowedDbResourceNames(); + if (empty($names)) { + return null; + } else { + return array_shift($names); + } + } + + protected function listAllowedDbResourceNames() + { + /** @var \Icinga\Authentication\Auth $auth */ + $auth = $this->Auth(); + + $available = $this->listAvailableDbResourceNames(); + if ($resourceNames = $auth->getRestrictions('director/db_resource')) { + $names = []; + foreach ($resourceNames as $rNames) { + foreach ($this->splitList($rNames) as $name) { + if (array_key_exists($name, $available)) { + $names[] = $name; + } + } + } + + return $names; + } else { + return $available; + } + } + + /** + * @param string $string + * @return array + */ + protected function splitList($string) + { + return preg_split('/\s*,\s*/', $string, -1, PREG_SPLIT_NO_EMPTY); + } + + protected function isMultiDbSetup() + { + return count($this->listAvailableDbResourceNames()) > 1; + } + + /** + * @return array + */ + protected function listAvailableDbResourceNames() + { + /** @var \Icinga\Application\Config $config */ + $config = $this->Config(); + $resources = $config->get('db', 'resources'); + if ($resources === null) { + $resource = $config->get('db', 'resource'); + if ($resource === null) { + return []; + } else { + return [$resource => $resource]; + } + } else { + $resources = $this->splitList($resources); + $resources = array_combine($resources, $resources); + // natsort doesn't work!? + ksort($resources, SORT_NATURAL); + if ($resource = $config->get('db', 'resource')) { + unset($resources[$resource]); + $resources = [$resource => $resource] + $resources; + } + + return $resources; + } + } + + protected function getWindowSessionValue($value, $default = null) + { + /** @var Window $window */ + $window = $this->Window(); + /** @var \Icinga\Web\Session\SessionNamespace $session */ + $session = $window->getSessionNamespace('director'); + + return $session->get($value, $default); + } + + /** + * + * @return Db + */ + public function db() + { + if ($this->db === null) { + $resourceName = $this->getDbResourceName(); + if ($resourceName) { + $this->db = Db::fromResourceName($resourceName); + } elseif ($this instanceof ActionController) { + if ($this->getRequest()->isApiRequest()) { + throw new RuntimeException('Icinga Director is not correctly configured'); + } else { + $this->redirectNow('director'); + } + } else { + throw new RuntimeException('Icinga Director is not correctly configured'); + } + } + + return $this->db; + } +} diff --git a/library/Director/Web/Controller/Extension/ObjectRestrictions.php b/library/Director/Web/Controller/Extension/ObjectRestrictions.php new file mode 100644 index 0000000..bedb3f1 --- /dev/null +++ b/library/Director/Web/Controller/Extension/ObjectRestrictions.php @@ -0,0 +1,48 @@ +<?php + +namespace Icinga\Module\Director\Web\Controller\Extension; + +use Icinga\Authentication\Auth; +use Icinga\Module\Director\Db; +use Icinga\Module\Director\Objects\IcingaObject; +use Icinga\Module\Director\Restriction\HostgroupRestriction; +use Icinga\Module\Director\Restriction\ObjectRestriction; + +trait ObjectRestrictions +{ + /** @var ObjectRestriction[] */ + private $objectRestrictions; + + /** + * @return ObjectRestriction[] + */ + public function getObjectRestrictions() + { + if ($this->objectRestrictions === null) { + $this->objectRestrictions = $this->loadObjectRestrictions($this->db(), $this->Auth()); + } + + return $this->objectRestrictions; + } + + /** + * @return ObjectRestriction[] + */ + protected function loadObjectRestrictions(Db $db, Auth $auth) + { + return [ + new HostgroupRestriction($db, $auth) + ]; + } + + public function allowsObject(IcingaObject $object) + { + foreach ($this->getObjectRestrictions() as $restriction) { + if (! $restriction->allows($object)) { + return false; + } + } + + return true; + } +} diff --git a/library/Director/Web/Controller/Extension/RestApi.php b/library/Director/Web/Controller/Extension/RestApi.php new file mode 100644 index 0000000..3158f49 --- /dev/null +++ b/library/Director/Web/Controller/Extension/RestApi.php @@ -0,0 +1,114 @@ +<?php + +namespace Icinga\Module\Director\Web\Controller\Extension; + +use Icinga\Exception\AuthenticationException; +use Icinga\Exception\NotFoundError; +use Icinga\Module\Director\Exception\JsonException; +use Icinga\Web\Response; +use InvalidArgumentException; +use Zend_Controller_Response_Exception; + +trait RestApi +{ + protected function isApified() + { + if (property_exists($this, 'isApified')) { + return $this->isApified; + } else { + return false; + } + } + + /** + * @return bool + */ + protected function sendNotFoundForRestApi() + { + /** @var \Icinga\Web\Request $request */ + $request = $this->getRequest(); + if ($request->isApiRequest()) { + $this->sendJsonError($this->getResponse(), 'Not found', 404); + return true; + } else { + return false; + } + } + + /** + * @return bool + */ + protected function sendNotFoundUnlessRestApi() + { + /** @var \Icinga\Web\Request $request */ + $request = $this->getRequest(); + if ($request->isApiRequest()) { + return false; + } else { + $this->sendJsonError($this->getResponse(), 'Not found', 404); + return true; + } + } + + /** + * @throws AuthenticationException + */ + protected function assertApiPermission() + { + if (! $this->hasPermission('director/api')) { + throw new AuthenticationException('You are not allowed to access this API'); + } + } + + /** + * @throws AuthenticationException + * @throws NotFoundError + */ + protected function checkForRestApiRequest() + { + /** @var \Icinga\Web\Request $request */ + $request = $this->getRequest(); + if ($request->isApiRequest()) { + $this->assertApiPermission(); + if (! $this->isApified()) { + throw new NotFoundError('No such API endpoint found'); + } + } + } + + /** + * @param Response $response + * @param $object + */ + protected function sendJson(Response $response, $object) + { + $response->setHeader('Content-Type', 'application/json', true); + echo json_encode($object, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) . "\n"; + } + + /** + * @param Response $response + * @param string $message + * @param int|null $code + */ + protected function sendJsonError(Response $response, $message, $code = null) + { + if ($code !== null) { + try { + $response->setHttpResponseCode((int) $code); + } catch (Zend_Controller_Response_Exception $e) { + throw new InvalidArgumentException($e->getMessage(), 0, $e); + } + } + + $this->sendJson($response, (object) ['error' => $message]); + } + + /** + * @return string + */ + protected function getLastJsonError() + { + return JsonException::getJsonErrorMessage(json_last_error()); + } +} diff --git a/library/Director/Web/Controller/Extension/SingleObjectApiHandler.php b/library/Director/Web/Controller/Extension/SingleObjectApiHandler.php new file mode 100644 index 0000000..bc51548 --- /dev/null +++ b/library/Director/Web/Controller/Extension/SingleObjectApiHandler.php @@ -0,0 +1,236 @@ +<?php + +namespace Icinga\Module\Director\Web\Controller\Extension; + +use Exception; +use Icinga\Exception\IcingaException; +use Icinga\Exception\InvalidPropertyException; +use Icinga\Exception\NotFoundError; +use Icinga\Module\Director\Forms\IcingaDeleteObjectForm; +use Icinga\Module\Director\Objects\IcingaObject; +use Icinga\Web\Request; +use Icinga\Web\Response; + +class SingleObjectApiHandler +{ + use DirectorDb; + + /** @var IcingaObject */ + private $object; + + /** @var string */ + private $type; + + /** @var Request */ + private $request; + + /** @var Response */ + private $response; + + /** @var \Icinga\Web\UrlParams */ + private $params; + + public function __construct($type, Request $request, Response $response) + { + $this->type = $type; + $this->request = $request; + $this->response = $response; + $this->params = $request->getUrl()->getParams(); + } + + public function runFailSafe() + { + try { + $this->loadObject(); + $this->run(); + } catch (NotFoundError $e) { + $this->sendJsonError($e->getMessage(), 404); + } catch (Exception $e) { + $response = $this->response; + if ($response->getHttpResponseCode() === 200) { + $response->setHttpResponseCode(500); + } + + $this->sendJsonError($e->getMessage()); + } + } + + protected function retrieveObject() + { + $this->requireObject(); + $this->sendJson( + $this->object->toPlainObject( + $this->params->shift('resolved'), + ! $this->params->shift('withNull'), + $this->params->shift('properties') + ) + ); + } + + protected function deleteObject() + { + $this->requireObject(); + $obj = $this->object->toPlainObject(false, true); + $form = new IcingaDeleteObjectForm(); + $form->setObject($this->object) + ->setRequest($this->request) + ->onSuccess(); + + $this->sendJson($obj); + } + + protected function storeObject() + { + $data = json_decode($this->request->getRawBody()); + + if ($data === null) { + $this->response->setHttpResponseCode(400); + throw new IcingaException( + 'Invalid JSON: %s' . $this->request->getRawBody(), + $this->getLastJsonError() + ); + } else { + $data = (array) $data; + } + + if ($object = $this->object) { + if ($this->request->getMethod() === 'POST') { + $object->setProperties($data); + } else { + $data = array_merge([ + 'object_type' => $object->object_type, + 'object_name' => $object->object_name + ], $data); + $object->replaceWith( + IcingaObject::createByType($this->type, $data, $db) + ); + } + } else { + $object = IcingaObject::createByType($this->type, $data, $db); + } + + if ($object->hasBeenModified()) { + $status = $object->hasBeenLoadedFromDb() ? 200 : 201; + $object->store(); + $this->response->setHttpResponseCode($status); + } else { + $this->response->setHttpResponseCode(304); + } + + $this->sendJson($object->toPlainObject(false, true)); + } + + public function run() + { + switch ($this->request->getMethod()) { + case 'DELETE': + $this->deleteObject(); + break; + + case 'POST': + case 'PUT': + $this->storeObject(); + break; + + case 'GET': + $this->retrieveObject(); + break; + + default: + $this->response->setHttpResponseCode(400); + throw new IcingaException( + 'Unsupported method: %s', + $this->request->getMethod() + ); + } + } + + protected function requireObject() + { + if (! $this->object) { + $this->response->setHttpResponseCode(404); + if (! $this->params->get('name')) { + throw new NotFoundError('You need to pass a "name" parameter to access a specific object'); + } else { + throw new NotFoundError('No such object available'); + } + } + } + + // TODO: just return json_last_error_msg() for PHP >= 5.5.0 + protected function getLastJsonError() + { + switch (json_last_error()) { + case JSON_ERROR_DEPTH: + return 'The maximum stack depth has been exceeded'; + case JSON_ERROR_CTRL_CHAR: + return 'Control character error, possibly incorrectly encoded'; + case JSON_ERROR_STATE_MISMATCH: + return 'Invalid or malformed JSON'; + case JSON_ERROR_SYNTAX: + return 'Syntax error'; + case JSON_ERROR_UTF8: + return 'Malformed UTF-8 characters, possibly incorrectly encoded'; + default: + return 'An error occured when parsing a JSON string'; + } + } + + protected function sendJson($object) + { + $this->response->setHeader('Content-Type', 'application/json', true); + $this->_helper->layout()->disableLayout(); + $this->_helper->viewRenderer->setNoRender(true); + echo json_encode($object, JSON_PRETTY_PRINT) . "\n"; + } + + protected function sendJsonError($message, $code = null) + { + $response = $this->response; + + if ($code !== null) { + $response->setHttpResponseCode((int) $code); + } + + $this->sendJson((object) ['error' => $message]); + } + + protected function loadObject() + { + if ($this->object === null) { + if ($name = $this->params->get('name')) { + $this->object = IcingaObject::loadByType( + $this->type, + $name, + $this->db() + ); + + if (! $this->allowsObject($this->object)) { + $this->object = null; + throw new NotFoundError('No such object available'); + } + } elseif ($id = $this->params->get('id')) { + $this->object = IcingaObject::loadByType( + $this->type, + (int) $id, + $this->db() + ); + } elseif ($this->request->isApiRequest()) { + if ($this->request->isGet()) { + $this->response->setHttpResponseCode(422); + + throw new InvalidPropertyException( + 'Cannot load object, missing parameters' + ); + } + } + } + + return $this->object; + } + + protected function allowsObject(IcingaObject $object) + { + return true; + } +} diff --git a/library/Director/Web/Controller/ObjectController.php b/library/Director/Web/Controller/ObjectController.php new file mode 100644 index 0000000..0c06937 --- /dev/null +++ b/library/Director/Web/Controller/ObjectController.php @@ -0,0 +1,733 @@ +<?php + +namespace Icinga\Module\Director\Web\Controller; + +use gipfl\Web\Widget\Hint; +use Icinga\Exception\IcingaException; +use Icinga\Exception\InvalidPropertyException; +use Icinga\Exception\NotFoundError; +use Icinga\Exception\ProgrammingError; +use Icinga\Module\Director\Data\Db\DbObjectTypeRegistry; +use Icinga\Module\Director\Db\Branch\Branch; +use Icinga\Module\Director\Db\Branch\BranchedObject; +use Icinga\Module\Director\Db\Branch\UuidLookup; +use Icinga\Module\Director\Deployment\DeploymentInfo; +use Icinga\Module\Director\DirectorObject\Automation\ExportInterface; +use Icinga\Module\Director\Exception\NestingError; +use Icinga\Module\Director\Forms\DeploymentLinkForm; +use Icinga\Module\Director\Forms\IcingaCloneObjectForm; +use Icinga\Module\Director\Forms\IcingaObjectFieldForm; +use Icinga\Module\Director\Objects\IcingaCommand; +use Icinga\Module\Director\Objects\IcingaObject; +use Icinga\Module\Director\Objects\IcingaObjectGroup; +use Icinga\Module\Director\Objects\IcingaService; +use Icinga\Module\Director\Objects\IcingaServiceSet; +use Icinga\Module\Director\RestApi\IcingaObjectHandler; +use Icinga\Module\Director\Web\Controller\Extension\ObjectRestrictions; +use Icinga\Module\Director\Web\Form\DirectorObjectForm; +use Icinga\Module\Director\Web\ObjectPreview; +use Icinga\Module\Director\Web\Table\ActivityLogTable; +use Icinga\Module\Director\Web\Table\BranchActivityTable; +use Icinga\Module\Director\Web\Table\GroupMemberTable; +use Icinga\Module\Director\Web\Table\IcingaObjectDatafieldTable; +use Icinga\Module\Director\Web\Tabs\ObjectTabs; +use Icinga\Module\Director\Web\Widget\BranchedObjectHint; +use gipfl\IcingaWeb2\Link; +use ipl\Html\Html; +use Ramsey\Uuid\Uuid; +use Ramsey\Uuid\UuidInterface; + +abstract class ObjectController extends ActionController +{ + use ObjectRestrictions; + use BranchHelper; + + /** @var IcingaObject */ + protected $object; + + /** @var bool This controller handles REST API requests */ + protected $isApified = true; + + /** @var array Allowed object types we are allowed to edit anyways */ + protected $allowedExternals = array( + 'apiuser', + 'endpoint' + ); + + protected $type; + + /** @var string|null */ + protected $objectBaseUrl; + + public function init() + { + parent::init(); + $this->enableStaticObjectLoader($this->getTableName()); + if ($this->getRequest()->isApiRequest()) { + $this->initializeRestApi(); + } else { + $this->initializeWebRequest(); + } + } + + protected function initializeRestApi() + { + $handler = new IcingaObjectHandler($this->getRequest(), $this->getResponse(), $this->db()); + try { + $this->loadOptionalObject(); + } catch (NotFoundError $e) { + // Silently ignore the error, the handler will complain + $handler->sendJsonError($e, 404); + // TODO: nice shutdown + exit; + } + + $handler->setApi($this->api()); + if ($this->object) { + $handler->setObject($this->object); + } + $handler->dispatch(); + // Hint: also here, hard exit. There is too much magic going on. + // Letting this bubble up smoothly would be "correct", but proved + // to be too fragile. Web 2, all kinds of pre/postDispatch magic, + // different view renderers - hard exit is the only safe bet right + // now. + exit; + } + + protected function initializeWebRequest() + { + $this->loadOptionalObject(); + if ($this->getRequest()->getActionName() === 'add') { + $this->addSingleTab( + sprintf($this->translate('Add %s'), ucfirst($this->getType())), + null, + 'add' + ); + } else { + $this->tabs(new ObjectTabs( + $this->getRequest()->getControllerName(), + $this->getAuth(), + $this->object + )); + } + if ($this->object !== null) { + $this->addDeploymentLink(); + } + } + + /** + * @throws NotFoundError + */ + public function indexAction() + { + if (! $this->getRequest()->isApiRequest()) { + $this->redirectToPreviewForExternals() + ->editAction(); + } + } + + public function addAction() + { + $this->tabs()->activate('add'); + $url = sprintf('director/%ss', $this->getPluralType()); + + $imports = $this->params->get('imports'); + $form = $this->loadObjectForm() + ->presetImports($imports) + ->setSuccessUrl($url); + + if ($oType = $this->params->get('type', 'object')) { + $form->setPreferredObjectType($oType); + } + if ($oType === 'template') { + if ($this->showNotInBranch($this->translate('Creating Templates'))) { + $this->addTitle($this->translate('Create a new Template')); + return; + } + + $this->addTemplate(); + } else { + $this->addObject(); + } + $branch = $this->getBranch(); + if ($branch->isBranch() && ! $this->getRequest()->isApiRequest()) { + $this->content()->add(new BranchedObjectHint($branch, $this->Auth())); + } + + $form->handleRequest(); + $this->content()->add($form); + } + + /** + * @throws NotFoundError + */ + public function editAction() + { + $object = $this->requireObject(); + $this->tabs()->activate('modify'); + $this->addObjectTitle(); + // Hint: Service Sets are 'templates' (as long as not being assigned to a host + if ($this->getTableName() !== 'icinga_service_set' + && $object->isTemplate() + && $this->showNotInBranch($this->translate('Modifying Templates')) + ) { + return; + } + if ($object->isApplyRule() && $this->showNotInBranch($this->translate('Modifying Apply Rules'))) { + return; + } + + $this->addObjectForm($object) + ->addActionClone() + ->addActionUsage() + ->addActionBasket(); + } + + /** + * @throws NotFoundError + * @throws \Icinga\Security\SecurityException + */ + public function renderAction() + { + $this->assertTypePermission() + ->assertPermission('director/showconfig'); + $this->tabs()->activate('render'); + $preview = new ObjectPreview($this->requireObject(), $this->getRequest()); + if ($this->object->isExternal()) { + $this->addActionClone(); + } + $this->addActionBasket(); + $preview->renderTo($this); + } + + /** + * @throws NotFoundError + */ + public function cloneAction() + { + $this->assertTypePermission(); + $object = $this->requireObject(); + $this->addTitle($this->translate('Clone: %s'), $object->getObjectName()) + ->addBackToObjectLink(); + + if ($object->isTemplate() && $this->showNotInBranch($this->translate('Cloning Templates'))) { + return; + } + + if ($object->isTemplate() && $this->showNotInBranch($this->translate('Cloning Apply Rules'))) { + return; + } + + $form = IcingaCloneObjectForm::load() + ->setBranch($this->getBranch()) + ->setObject($object) + ->setObjectBaseUrl($this->getObjectBaseUrl()) + ->handleRequest(); + + if ($object->isExternal()) { + $this->tabs()->activate('render'); + } else { + $this->tabs()->activate('modify'); + } + $this->content()->add($form); + } + + /** + * @throws NotFoundError + * @throws \Icinga\Security\SecurityException + */ + public function fieldsAction() + { + $this->assertPermission('director/admin'); + $object = $this->requireObject(); + $type = $this->getType(); + + $this->addTitle( + $this->translate('Custom fields: %s'), + $object->getObjectName() + ); + $this->tabs()->activate('fields'); + if ($this->showNotInBranch($this->translate('Managing Fields'))) { + return; + } + + try { + $this->addFieldsFormAndTable($object, $type); + } catch (NestingError $e) { + $this->content()->add(Hint::error($e->getMessage())); + } + } + + protected function addFieldsFormAndTable($object, $type) + { + $form = IcingaObjectFieldForm::load() + ->setDb($this->db()) + ->setIcingaObject($object); + + if ($id = $this->params->get('field_id')) { + $form->loadObject([ + "${type}_id" => $object->id, + 'datafield_id' => $id + ]); + + $this->actions()->add(Link::create( + $this->translate('back'), + $this->url()->without('field_id'), + null, + ['class' => 'icon-left-big'] + )); + } + $form->handleRequest(); + $this->content()->add($form); + $table = new IcingaObjectDatafieldTable($object); + $table->getAttributes()->set('data-base-target', '_self'); + $table->renderTo($this); + } + + /** + * @throws NotFoundError + * @throws \Icinga\Security\SecurityException + */ + public function historyAction() + { + $this + ->assertTypePermission() + ->assertPermission('director/audit') + ->setAutorefreshInterval(10) + ->tabs()->activate('history'); + + $name = $this->requireObject()->getObjectName(); + $this->addTitle($this->translate('Activity Log: %s'), $name); + + $db = $this->db(); + $objectTable = $this->object->getTableName(); + $table = (new ActivityLogTable($db)) + ->setLastDeployedId($db->getLastDeploymentActivityLogId()) + ->filterObject($objectTable, $name); + if ($host = $this->params->get('host')) { + $table->filterHost($host); + } + $this->showOptionalBranchActivity($table); + $table->renderTo($this); + } + + /** + * @throws NotFoundError + */ + public function membershipAction() + { + $object = $this->requireObject(); + if (! $object instanceof IcingaObjectGroup) { + throw new NotFoundError('Not Found'); + } + + $this + ->addTitle($this->translate('Group membership: %s'), $object->getObjectName()) + ->setAutorefreshInterval(15) + ->tabs()->activate('membership'); + + $type = substr($this->getType(), 0, -5); + GroupMemberTable::create($type, $this->db()) + ->setGroup($object) + ->renderTo($this); + } + + /** + * @return $this + * @throws NotFoundError + */ + protected function addObjectTitle() + { + $object = $this->requireObject(); + $name = $object->getObjectName(); + if ($object->isTemplate()) { + $this->addTitle($this->translate('Template: %s'), $name); + } else { + $this->addTitle($name); + } + + return $this; + } + + /** + * @return $this + * @throws NotFoundError + */ + protected function addActionUsage() + { + $type = $this->getType(); + $object = $this->requireObject(); + if ($object->isTemplate() && $type !== 'serviceSet') { + $this->actions()->add([ + Link::create( + $this->translate('Usage'), + "director/${type}template/usage", + ['name' => $object->getObjectName()], + ['class' => 'icon-sitemap'] + ) + ]); + } + + return $this; + } + + protected function addActionClone() + { + $this->actions()->add(Link::create( + $this->translate('Clone'), + $this->getObjectBaseUrl() . '/clone', + $this->object->getUrlParams(), + array('class' => 'icon-paste') + )); + + return $this; + } + + /** + * @return $this + */ + protected function addActionBasket() + { + if ($this->hasBasketSupport()) { + $object = $this->object; + if ($object instanceof ExportInterface) { + if ($object instanceof IcingaCommand) { + if ($object->isExternal()) { + $type = 'ExternalCommand'; + } elseif ($object->isTemplate()) { + $type = 'CommandTemplate'; + } else { + $type = 'Command'; + } + } elseif ($object instanceof IcingaServiceSet) { + $type = 'ServiceSet'; + } elseif ($object->isTemplate()) { + $type = ucfirst($this->getType()) . 'Template'; + } elseif ($object->isGroup()) { + $type = ucfirst($this->getType()); + } else { + // Command? Sure? + $type = ucfirst($this->getType()); + } + $this->actions()->add(Link::create( + $this->translate('Add to Basket'), + 'director/basket/add', + [ + 'type' => $type, + 'names' => $object->getUniqueIdentifier() + ], + ['class' => 'icon-tag'] + )); + } + } + + return $this; + } + + protected function addTemplate() + { + $this->assertPermission('director/admin'); + $this->addTitle( + $this->translate('Add new Icinga %s template'), + $this->getTranslatedType() + ); + } + + protected function addObject() + { + $this->assertTypePermission(); + $imports = $this->params->get('imports'); + if (is_string($imports) && strlen($imports)) { + $this->addTitle( + $this->translate('Add %s: %s'), + $this->getTranslatedType(), + $imports + ); + } else { + $this->addTitle( + $this->translate('Add new Icinga %s'), + $this->getTranslatedType() + ); + } + } + + protected function redirectToPreviewForExternals() + { + if ($this->object + && $this->object->isExternal() + && ! in_array($this->object->getShortTableName(), $this->allowedExternals) + ) { + $this->redirectNow( + $this->getRequest()->getUrl()->setPath(sprintf('director/%s/render', $this->getType())) + ); + } + + return $this; + } + + protected function getType() + { + if ($this->type === null) { + // Strip final 's' and upcase an eventual 'group' + $this->type = preg_replace( + array('/group$/', '/period$/', '/argument$/', '/apiuser$/', '/set$/'), + array('Group', 'Period', 'Argument', 'ApiUser', 'Set'), + $this->getRequest()->getControllerName() + ); + } + + return $this->type; + } + + protected function getPluralType() + { + return $this->getType() . 's'; + } + + protected function getTranslatedType() + { + return $this->translate(ucfirst($this->getType())); + } + + protected function assertTypePermission() + { + $type = strtolower($this->getPluralType()); + // TODO: Check getPluralType usage, fix it there. + if ($type === 'scheduleddowntimes') { + $type = 'scheduled-downtimes'; + } + + return $this->assertPermission("director/$type"); + } + + protected function loadOptionalObject() + { + if ($this->params->get('uuid') || null !== $this->params->get('name') || $this->params->get('id')) { + $this->loadObject(); + } + } + + /** + * @return ?UuidInterface + * @throws InvalidPropertyException + * @throws NotFoundError + */ + protected function getUuidFromUrl() + { + $key = null; + if ($uuid = $this->params->get('uuid')) { + $key = Uuid::fromString($uuid); + } elseif ($id = $this->params->get('id')) { + $key = (int) $id; + } elseif (null !== ($name = $this->params->get('name'))) { + $key = $name; + } + if ($key === null) { + $request = $this->getRequest(); + if ($request->isApiRequest() && $request->isGet()) { + $this->getResponse()->setHttpResponseCode(422); + + throw new InvalidPropertyException( + 'Cannot load object, missing parameters' + ); + } + + return null; + } + + return $this->requireUuid($key); + } + + protected function loadObject() + { + if ($this->object) { + throw new ProgrammingError('Loading an object twice is not very efficient'); + } + + $this->object = $this->loadSpecificObject($this->getTableName(), $this->getUuidFromUrl(), true); + } + + protected function loadSpecificObject($tableName, $key, $showHint = false) + { + $branch = $this->getBranch(); + $branchedObject = BranchedObject::load($this->db(), $tableName, $key, $branch); + $object = $branchedObject->getBranchedDbObject($this->db()); + assert($object instanceof IcingaObject); + $object->setBeingLoadedFromDb(); + if (! $this->allowsObject($object)) { + throw new NotFoundError('No such object available'); + } + if ($showHint && $branch->isBranch() && $object->isObject() && ! $this->getRequest()->isApiRequest()) { + $this->content()->add(new BranchedObjectHint($branch, $this->Auth(), $branchedObject)); + } + + return $object; + } + + protected function requireUuid($key) + { + if (! $key instanceof UuidInterface) { + $key = UuidLookup::findUuidForKey($key, $this->getTableName(), $this->db(), $this->getBranch()); + if ($key === null) { + throw new NotFoundError('No such object available'); + } + } + + return $key; + } + + protected function getTableName() + { + return DbObjectTypeRegistry::tableNameByType($this->getType()); + } + + protected function addDeploymentLink() + { + try { + $info = new DeploymentInfo($this->db()); + $info->setObject($this->object); + + if (! $this->getRequest()->isApiRequest()) { + if ($this->getBranch()->isBranch()) { + $this->actions()->add($this->linkToMergeBranch($this->getBranch())); + } else { + $this->actions()->add( + DeploymentLinkForm::create( + $this->db(), + $info, + $this->Auth(), + $this->api() + )->handleRequest() + ); + } + } + } catch (IcingaException $e) { + // pass (deployment may not be set up yet) + } + } + + protected function linkToMergeBranch(Branch $branch) + { + $link = Branch::requireHook()->linkToBranch($branch, $this->Auth(), $this->translate('Merge')); + if ($link instanceof Link) { + $link->addAttributes(['class' => 'icon-flapping']); + } + + return $link; + } + + protected function addBackToObjectLink() + { + $params = [ + 'uuid' => $this->object->getUniqueId()->toString(), + ]; + + if ($this->object instanceof IcingaService) { + if (($host = $this->object->get('host')) !== null) { + $params['host'] = $host; + } elseif (($set = $this->object->get('service_set')) !== null) { + $params['set'] = $set; + } + } + + $this->actions()->add(Link::create( + $this->translate('back'), + $this->getObjectBaseUrl(), + $params, + ['class' => 'icon-left-big'] + )); + + return $this; + } + + protected function addObjectForm(IcingaObject $object = null) + { + $form = $this->loadObjectForm($object); + $this->content()->add($form); + $form->handleRequest(); + return $this; + } + + protected function loadObjectForm(IcingaObject $object = null) + { + /** @var DirectorObjectForm $class */ + $class = sprintf( + 'Icinga\\Module\\Director\\Forms\\Icinga%sForm', + ucfirst($this->getType()) + ); + + $form = $class::load() + ->setDb($this->db()) + ->setAuth($this->Auth()); + + if ($object !== null) { + $form->setObject($object); + } + if (true || $form->supportsBranches()) { + $form->setBranch($this->getBranch()); + } + + $this->onObjectFormLoaded($form); + + return $form; + } + + protected function getObjectBaseUrl() + { + return $this->objectBaseUrl ?: 'director/' . strtolower($this->getType()); + } + + protected function hasBasketSupport() + { + return $this->object->isTemplate() || $this->object->isGroup(); + } + + protected function onObjectFormLoaded(DirectorObjectForm $form) + { + } + + /** + * @return IcingaObject + * @throws NotFoundError + */ + protected function requireObject() + { + if (! $this->object) { + $this->getResponse()->setHttpResponseCode(404); + if (null === $this->params->get('name')) { + throw new NotFoundError('You need to pass a "name" parameter to access a specific object'); + } else { + throw new NotFoundError('No such object available'); + } + } + + return $this->object; + } + + protected function showOptionalBranchActivity($activityTable) + { + $branch = $this->getBranch(); + if ($branch->isBranch() && (int) $this->params->get('page', '1') === 1) { + $table = new BranchActivityTable($branch->getUuid(), $this->db(), $this->object->getUniqueId()); + if (count($table) > 0) { + $this->content()->add(Hint::info(Html::sprintf($this->translate( + 'The following modifications are visible in this %s only...' + ), Branch::requireHook()->linkToBranch( + $branch, + $this->Auth(), + $this->translate('configuration branch') + )))); + $this->content()->add($table); + if (count($activityTable) === 0) { + return; + } + $this->content()->add(Html::tag('br')); + $this->content()->add(Hint::ok($this->translate( + '...and the modifications below are already in the main branch:' + ))); + $this->content()->add(Html::tag('br')); + } + } + } +} diff --git a/library/Director/Web/Controller/ObjectsController.php b/library/Director/Web/Controller/ObjectsController.php new file mode 100644 index 0000000..8c10b44 --- /dev/null +++ b/library/Director/Web/Controller/ObjectsController.php @@ -0,0 +1,548 @@ +<?php + +namespace Icinga\Module\Director\Web\Controller; + +use gipfl\IcingaWeb2\Table\ZfQueryBasedTable; +use Icinga\Data\Filter\FilterChain; +use Icinga\Data\Filter\FilterExpression; +use Icinga\Exception\NotFoundError; +use Icinga\Data\Filter\Filter; +use Icinga\Module\Director\Data\Db\DbObjectStore; +use Icinga\Module\Director\Data\Db\DbObjectTypeRegistry; +use Icinga\Module\Director\Forms\IcingaMultiEditForm; +use Icinga\Module\Director\Objects\IcingaCommand; +use Icinga\Module\Director\Objects\IcingaHost; +use Icinga\Module\Director\Objects\IcingaObject; +use Icinga\Module\Director\RestApi\IcingaObjectsHandler; +use Icinga\Module\Director\Web\ActionBar\ObjectsActionBar; +use Icinga\Module\Director\Web\ActionBar\TemplateActionBar; +use Icinga\Module\Director\Web\Form\FormLoader; +use Icinga\Module\Director\Web\Table\ApplyRulesTable; +use Icinga\Module\Director\Web\Table\ObjectSetTable; +use Icinga\Module\Director\Web\Table\ObjectsTable; +use Icinga\Module\Director\Web\Table\TemplatesTable; +use Icinga\Module\Director\Web\Tabs\ObjectsTabs; +use Icinga\Module\Director\Web\Tree\TemplateTreeRenderer; +use gipfl\IcingaWeb2\Link; +use Icinga\Module\Director\Web\Widget\AdditionalTableActions; +use Icinga\Module\Director\Web\Widget\BranchedObjectsHint; +use InvalidArgumentException; +use Ramsey\Uuid\Uuid; + +abstract class ObjectsController extends ActionController +{ + use BranchHelper; + + protected $isApified = true; + + /** @var ObjectsTable */ + protected $table; + + protected function checkDirectorPermissions() + { + if ($this->getRequest()->getActionName() !== 'sets') { + $this->assertPermission('director/' . $this->getPluralBaseType()); + } + } + + /** + * @return $this + * @throws \Icinga\Exception\Http\HttpNotFoundException + */ + protected function addObjectsTabs() + { + $tabName = $this->getRequest()->getActionName(); + if (substr($this->getType(), -5) === 'Group') { + $tabName = 'groups'; + } + $this->tabs(new ObjectsTabs( + $this->getBaseType(), + $this->Auth(), + $this->getBaseObjectUrl() + ))->activate($tabName); + + return $this; + } + + /** + * @return IcingaObjectsHandler + * @throws NotFoundError + */ + protected function apiRequestHandler() + { + $request = $this->getRequest(); + $table = $this->getTable(); + if ($request->getControllerName() === 'services' + && $host = $this->params->get('host') + ) { + $host = IcingaHost::load($host, $this->db()); + $table->getQuery()->where('o.host_id = ?', $host->get('id')); + } + + if ($request->getActionName() === 'templates') { + $table->filterObjectType('template'); + } elseif ($request->getActionName() === 'applyrules') { + $table->filterObjectType('apply'); + } + $search = $this->params->get('q'); + if ($search !== null && \strlen($search) > 0) { + $table->search($search); + } + + return (new IcingaObjectsHandler( + $request, + $this->getResponse(), + $this->db() + ))->setTable($table); + } + + /** + * @throws \Icinga\Exception\Http\HttpNotFoundException + * @throws NotFoundError + */ + public function indexAction() + { + if ($this->getRequest()->isApiRequest()) { + $this->apiRequestHandler()->dispatch(); + return; + } + + $type = $this->getType(); + if ($this->params->get('format') === 'json') { + $filename = sprintf( + "director-${type}_%s.json", + date('YmdHis') + ); + $this->getResponse()->setHeader('Content-disposition', "attachment; filename=$filename", true); + $this->apiRequestHandler()->dispatch(); + return; + } + + $this + ->addObjectsTabs() + ->setAutorefreshInterval(10) + ->addTitle($this->translate(ucfirst($this->getPluralType()))) + ->actions(new ObjectsActionBar($this->getBaseObjectUrl(), $this->url())); + + $this->content()->add(new BranchedObjectsHint($this->getBranch(), $this->Auth())); + + if ($type === 'command' && $this->params->get('type') === 'external_object') { + $this->tabs()->activate('external'); + } + + // Hint: might be used in controllers extending this + $this->table = $this->eventuallyFilterCommand($this->getTable()); + + $this->table->renderTo($this); + (new AdditionalTableActions($this->getAuth(), $this->url(), $this->table)) + ->appendTo($this->actions()); + } + + /** + * @return ObjectsTable + */ + protected function getTable() + { + $table = ObjectsTable::create($this->getType(), $this->db()) + ->setAuth($this->getAuth()) + ->setBranchUuid($this->getBranchUuid()) + ->setBaseObjectUrl($this->getBaseObjectUrl()); + + return $table; + } + + /** + * @return ApplyRulesTable + * @throws NotFoundError + */ + protected function getApplyRulesTable() + { + $table = new ApplyRulesTable($this->db()); + $table->setType($this->getType()) + ->setBaseObjectUrl($this->getBaseObjectUrl()); + $this->eventuallyFilterCommand($table); + + return $table; + } + + /** + * @throws NotFoundError + */ + public function edittemplatesAction() + { + $this->commonForEdit(); + } + + /** + * @throws NotFoundError + */ + public function editAction() + { + $this->commonForEdit(); + } + + /** + * @throws NotFoundError + */ + public function commonForEdit() + { + $type = ucfirst($this->getType()); + + if (empty($this->multiEdit)) { + throw new NotFoundError('Cannot edit multiple "%s" instances', $type); + } + + $objects = $this->loadMultiObjectsFromParams(); + if (empty($objects)) { + throw new NotFoundError('No "%s" instances have been loaded', $type); + } + $formName = 'icinga' . $type; + $form = IcingaMultiEditForm::load() + ->setBranch($this->getBranch()) + ->setObjects($objects) + ->pickElementsFrom($this->loadForm($formName), $this->multiEdit); + if ($type === 'Service') { + $form->setListUrl('director/services'); + } elseif ($type === 'Host') { + $form->setListUrl('director/hosts'); + } + + $form->handleRequest(); + + $this + ->addSingleTab($this->translate('Multiple objects')) + ->addTitle( + $this->translate('Modify %d objects'), + count($objects) + )->content()->add($form); + } + + /** + * Loads the TemplatesTable or the TemplateTreeRenderer + * + * Passing render=tree switches to the tree view. + * @throws \Icinga\Exception\Http\HttpNotFoundException + * @throws \Icinga\Security\SecurityException + * @throws NotFoundError + */ + public function templatesAction() + { + if ($this->getRequest()->isApiRequest()) { + $this->apiRequestHandler()->dispatch(); + return; + } + $type = $this->getType(); + + if ($this->params->get('format') === 'json') { + $filename = sprintf( + "director-${type}-templates_%s.json", + date('YmdHis') + ); + $this->getResponse()->setHeader('Content-disposition', "attachment; filename=$filename", true); + $this->apiRequestHandler()->dispatch(); + return; + } + + $shortType = IcingaObject::createByType($type)->getShortTableName(); + $this + ->assertPermission('director/admin') + ->addObjectsTabs() + ->setAutorefreshInterval(10) + ->addTitle( + $this->translate('All your %s Templates'), + $this->translate(ucfirst($type)) + ) + ->actions(new TemplateActionBar($shortType, $this->url())); + + if ($this->params->get('render') === 'tree') { + TemplateTreeRenderer::showType($shortType, $this, $this->db()); + } else { + $table = TemplatesTable::create($shortType, $this->db()); + $this->eventuallyFilterCommand($table); + $table->renderTo($this); + (new AdditionalTableActions($this->getAuth(), $this->url(), $table)) + ->appendTo($this->actions()); + } + } + + /** + * @return $this + * @throws \Icinga\Security\SecurityException + */ + protected function assertApplyRulePermission() + { + return $this->assertPermission('director/admin'); + } + + /** + * @throws \Icinga\Exception\Http\HttpNotFoundException + * @throws \Icinga\Security\SecurityException + * @throws NotFoundError + */ + public function applyrulesAction() + { + if ($this->getRequest()->isApiRequest()) { + $this->apiRequestHandler()->dispatch(); + return; + } + + $type = $this->getType(); + + if ($this->params->get('format') === 'json') { + $filename = sprintf( + "director-${type}-applyrules_%s.json", + date('YmdHis') + ); + $this->getResponse()->setHeader('Content-disposition', "attachment; filename=$filename", true); + $this->apiRequestHandler()->dispatch(); + return; + } + + $tType = $this->translate(ucfirst($type)); + $this + ->assertApplyRulePermission() + ->addObjectsTabs() + ->setAutorefreshInterval(10) + ->addTitle( + $this->translate('All your %s Apply Rules'), + $tType + ); + $baseUrl = 'director/' . $this->getBaseObjectUrl(); + $this->actions() + //->add($this->getBackToDashboardLink()) + ->add( + Link::create( + $this->translate('Add'), + "${baseUrl}/add", + ['type' => 'apply'], + [ + 'title' => sprintf( + $this->translate('Create a new %s Apply Rule'), + $tType + ), + 'class' => 'icon-plus', + 'data-base-target' => '_next' + ] + ) + ); + + $table = $this->getApplyRulesTable(); + $table->renderTo($this); + (new AdditionalTableActions($this->getAuth(), $this->url(), $table)) + ->appendTo($this->actions()); + } + + /** + * @throws NotFoundError + * @throws \Icinga\Exception\Http\HttpNotFoundException + * @throws \Icinga\Security\SecurityException + */ + public function setsAction() + { + $type = $this->getType(); + $tType = $this->translate(ucfirst($type)); + $this + ->assertPermission('director/' . $this->getBaseType() . 'sets') + ->addObjectsTabs() + ->requireSupportFor('Sets') + ->setAutorefreshInterval(10) + ->addTitle( + $this->translate('Icinga %s Sets'), + $tType + ); + + $this->actions()->add( + Link::create( + $this->translate('Add'), + "director/${type}set/add", + null, + [ + 'title' => sprintf( + $this->translate('Create a new %s Set'), + $tType + ), + 'class' => 'icon-plus', + 'data-base-target' => '_next' + ] + ) + ); + + ObjectSetTable::create($type, $this->db(), $this->getAuth()) + ->setBranch($this->getBranch()) + ->renderTo($this); + } + + /** + * @return array + * @throws NotFoundError + */ + protected function loadMultiObjectsFromParams() + { + $filter = Filter::fromQueryString($this->params->toString()); + $type = $this->getType(); + $objects = array(); + $db = $this->db(); + $class = DbObjectTypeRegistry::classByType($type); + $table = DbObjectTypeRegistry::tableNameByType($type); + $store = new DbObjectStore($db, $this->getBranch()); + + /** @var $filter FilterChain */ + foreach ($filter->filters() as $sub) { + /** @var $sub FilterChain */ + foreach ($sub->filters() as $ex) { + /** @var $ex FilterChain|FilterExpression */ + $col = $ex->getColumn(); + if ($ex->isExpression()) { + if ($col === 'name') { + $name = $ex->getExpression(); + if ($type === 'service') { + $key = [ + 'object_type' => 'template', + 'object_name' => $name + ]; + } else { + $key = $name; + } + $objects[$name] = $class::load($key, $db); + } elseif ($col === 'id') { + $name = $ex->getExpression(); + $objects[$name] = $class::load($name, $db); + } elseif ($col === 'uuid') { + $object = $store->load($table, Uuid::fromString($ex->getExpression())); + $objects[$object->getObjectName()] = $object; + } else { + throw new InvalidArgumentException("'$col' is no a valid key component for '$type'"); + } + } + } + } + + return $objects; + } + + /** + * @param string $name + * + * @return \Icinga\Module\Director\Web\Form\QuickForm + */ + public function loadForm($name) + { + $form = FormLoader::load($name, $this->Module()); + if ($this->getRequest()->isApiRequest()) { + // TODO: Ask form for API support? + $form->setApiRequest(); + } + + return $form; + } + + /** + * @param ZfQueryBasedTable $table + * @return ZfQueryBasedTable + * @throws NotFoundError + */ + protected function eventuallyFilterCommand(ZfQueryBasedTable $table) + { + if ($this->params->get('command')) { + $command = IcingaCommand::load($this->params->get('command'), $this->db()); + switch ($this->getBaseType()) { + case 'host': + case 'service': + $table->getQuery()->where( + $this->db()->getDbAdapter()->quoteInto( + '(o.check_command_id = ? OR o.event_command_id = ?)', + $command->getAutoincId() + ) + ); + break; + case 'notification': + $table->getQuery()->where( + 'o.command_id = ?', + $command->getAutoincId() + ); + break; + } + } + + return $table; + } + + /** + * @param $feature + * @return $this + * @throws NotFoundError + */ + protected function requireSupportFor($feature) + { + if ($this->supports($feature) !== true) { + throw new NotFoundError( + '%s does not support %s', + $this->getType(), + $feature + ); + } + + return $this; + } + + /** + * @param $feature + * @return bool + */ + protected function supports($feature) + { + $func = "supports$feature"; + return IcingaObject::createByType($this->getType())->$func(); + } + + /** + * @return string + */ + protected function getBaseType() + { + $type = $this->getType(); + if (substr($type, -5) === 'Group') { + return substr($type, 0, -5); + } else { + return $type; + } + } + + protected function getBaseObjectUrl() + { + return $this->getType(); + } + + /** + * @return string + */ + protected function getType() + { + // Strip final 's' and upcase an eventual 'group' + return preg_replace( + array('/group$/', '/period$/', '/argument$/', '/apiuser$/', '/dependencie$/', '/set$/'), + array('Group', 'Period', 'Argument', 'ApiUser', 'dependency', 'Set'), + str_replace( + 'template', + '', + substr($this->getRequest()->getControllerName(), 0, -1) + ) + ); + } + + /** + * @return string + */ + protected function getPluralType() + { + return preg_replace('/cys$/', 'cies', $this->getType() . 's'); + } + + /** + * @return string + */ + protected function getPluralBaseType() + { + return preg_replace('/cys$/', 'cies', $this->getBaseType() . 's'); + } +} diff --git a/library/Director/Web/Controller/TemplateController.php b/library/Director/Web/Controller/TemplateController.php new file mode 100644 index 0000000..c368a82 --- /dev/null +++ b/library/Director/Web/Controller/TemplateController.php @@ -0,0 +1,243 @@ +<?php + +namespace Icinga\Module\Director\Web\Controller; + +use gipfl\Web\Widget\Hint; +use Icinga\Module\Director\DirectorObject\Automation\ExportInterface; +use Icinga\Module\Director\Exception\NestingError; +use Icinga\Module\Director\Objects\IcingaCommand; +use Icinga\Module\Director\Objects\IcingaObject; +use Icinga\Module\Director\Web\Controller\Extension\DirectorDb; +use Icinga\Module\Director\Web\Table\ApplyRulesTable; +use Icinga\Module\Director\Web\Table\ObjectsTable; +use Icinga\Module\Director\Web\Table\TemplatesTable; +use Icinga\Module\Director\Web\Table\TemplateUsageTable; +use Icinga\Module\Director\Web\Tabs\ObjectTabs; +use Icinga\Module\Director\Web\Widget\UnorderedList; +use ipl\Html\FormattedString; +use ipl\Html\Html; +use gipfl\IcingaWeb2\Link; +use gipfl\IcingaWeb2\CompatController; + +abstract class TemplateController extends CompatController +{ + use DirectorDb; + + /** @var IcingaObject */ + protected $template; + + public function objectsAction() + { + $template = $this->requireTemplate(); + $plural = $this->getTranslatedPluralType(); + $this + ->addSingleTab($plural) + ->setAutorefreshInterval(10) + ->addTitle( + $this->translate('%s based on %s'), + $plural, + $template->getObjectName() + )->addBackToUsageLink($template); + + ObjectsTable::create($this->getType(), $this->db()) + ->setAuth($this->Auth()) + ->setBaseObjectUrl($this->getBaseObjectUrl()) + ->filterTemplate($template, $this->getInheritance()) + ->renderTo($this); + } + + public function applyrulesAction() + { + $type = $this->getType(); + $template = $this->requireTemplate(); + $this + ->addSingleTab(sprintf($this->translate('Applied %s'), $this->getTranslatedPluralType())) + ->setAutorefreshInterval(10) + ->addTitle( + $this->translate('Notification Apply Rules based on %s'), + $template->getObjectName() + )->addBackToUsageLink($template); + + ApplyRulesTable::create($type, $this->db()) + ->setBaseObjectUrl($this->getBaseObjectUrl()) + ->filterTemplate($template, $this->params->get('inheritance', 'direct')) + ->renderTo($this); + } + + public function templatesAction() + { + $template = $this->requireTemplate(); + $typeName = $this->getTranslatedType(); + $this + ->addSingleTab(sprintf($this->translate('%s Templates'), $typeName)) + ->setAutorefreshInterval(10) + ->addTitle( + $this->translate('%s templates based on %s'), + $typeName, + $template->getObjectName() + )->addBackToUsageLink($template); + + TemplatesTable::create($this->getType(), $this->db()) + ->filterTemplate($template, $this->getInheritance()) + ->renderTo($this); + } + + protected function getInheritance() + { + return $this->params->get('inheritance', 'direct'); + } + + protected function addBackToUsageLink(IcingaObject $template) + { + $type = $this->getType(); + $this->actions()->add( + Link::create( + $this->translate('Back'), + "director/${type}template/usage", + ['name' => $template->getObjectName()], + ['class' => 'icon-left-big'] + ) + ); + + return $this; + } + + public function usageAction() + { + $template = $this->requireTemplate(); + if (! $template->isTemplate() && $template instanceof IcingaCommand) { + $this->redirectNow($this->url()->setPath('director/command')); + } + $templateName = $template->getObjectName(); + + $type = $this->getType(); + $this->tabs(new ObjectTabs($type, $this->Auth(), $template))->activate('modify'); + $this + ->addTitle($this->translate('Template: %s'), $templateName) + ->setAutorefreshInterval(10); + + $this->actions()->add([ + Link::create( + $this->translate('Modify'), + "director/$type/edit", + ['uuid' => $template->getUniqueId()->toString()], + ['class' => 'icon-edit'] + ) + ]); + if ($template instanceof ExportInterface) { + $this->actions()->add(Link::create( + $this->translate('Add to Basket'), + 'director/basket/add', + [ + 'type' => ucfirst($this->getType()) . 'Template', + 'names' => $template->getUniqueIdentifier() + ], + ['class' => 'icon-tag'] + )); + } + + $list = new UnorderedList([], [ + 'class' => 'vertical-action-list' + ]); + + $auth = $this->Auth(); + + if ($type !== 'notification') { + $list->addItem(new FormattedString( + $this->translate('Create a new %s inheriting from this template'), + [Link::create( + $this->translate('Object'), + "director/$type/add", + ['imports' => $templateName, 'type' => 'object'] + )] + )); + } + if ($auth->hasPermission('director/admin')) { + $list->addItem(new FormattedString( + $this->translate('Create a new %s inheriting from this one'), + [Link::create( + $this->translate('Template'), + "director/$type/add", + ['imports' => $templateName, 'type' => 'template'] + )] + )); + } + if ($template->supportsApplyRules()) { + $list->addItem(new FormattedString( + $this->translate('Create a new %s inheriting from this template'), + [Link::create( + $this->translate('Apply Rule'), + "director/$type/add", + ['imports' => $templateName, 'type' => 'apply'] + )] + )); + } + + $typeName = $this->getTranslatedType(); + $this->content()->add(Html::sprintf( + $this->translate( + 'This is the "%s" %s Template. Based on this, you might want to:' + ), + $typeName, + $templateName + ))->add( + $list + )->add( + Html::tag('h2', null, $this->translate('Current Template Usage')) + ); + + try { + $this->content()->add( + TemplateUsageTable::forTemplate($template) + ); + } catch (NestingError $e) { + $this->content()->add(Hint::error($e->getMessage())); + } + } + + protected function getType() + { + return $this->template()->getShortTableName(); + } + + protected function getPluralType() + { + return preg_replace( + '/cys$/', + 'cies', + $this->template()->getShortTableName() . 's' + ); + } + + protected function getTranslatedType() + { + return $this->translate(ucfirst($this->getType())); + } + + protected function getTranslatedPluralType() + { + return $this->translate(ucfirst($this->getPluralType())); + } + + protected function getBaseObjectUrl() + { + return $this->getType(); + } + + /** + * @return IcingaObject + */ + protected function template() + { + if ($this->template === null) { + $this->template = $this->requireTemplate(); + } + + return $this->template; + } + + /** + * @return IcingaObject + */ + abstract protected function requireTemplate(); +} |