From 19bc3ed1e3f52a9d9cd0a540e7e754a2fa16eb54 Mon Sep 17 00:00:00 2001 From: Côme Chilliet Date: Mon, 10 Jun 2024 17:35:07 +0200 Subject: chore(webhooks): Rename webhooks application to webhook_listeners MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit There is already a webhooks application in the appstore Signed-off-by: Côme Chilliet --- apps/webhook_listeners/lib/AppInfo/Application.php | 55 ++++ .../lib/BackgroundJobs/WebhookCall.php | 60 ++++ .../webhook_listeners/lib/Command/ListWebhooks.php | 43 +++ .../lib/Controller/WebhooksController.php | 220 +++++++++++++ apps/webhook_listeners/lib/Db/AuthMethod.php | 15 + apps/webhook_listeners/lib/Db/WebhookListener.php | 103 +++++++ .../lib/Db/WebhookListenerMapper.php | 207 +++++++++++++ .../lib/Listener/WebhooksEventListener.php | 71 +++++ .../Migration/Version1000Date20240527153425.php | 72 +++++ apps/webhook_listeners/lib/ResponseDefinitions.php | 26 ++ .../lib/Service/PHPMongoQuery.php | 340 +++++++++++++++++++++ apps/webhook_listeners/lib/Settings/Admin.php | 60 ++++ 12 files changed, 1272 insertions(+) create mode 100644 apps/webhook_listeners/lib/AppInfo/Application.php create mode 100644 apps/webhook_listeners/lib/BackgroundJobs/WebhookCall.php create mode 100644 apps/webhook_listeners/lib/Command/ListWebhooks.php create mode 100644 apps/webhook_listeners/lib/Controller/WebhooksController.php create mode 100644 apps/webhook_listeners/lib/Db/AuthMethod.php create mode 100644 apps/webhook_listeners/lib/Db/WebhookListener.php create mode 100644 apps/webhook_listeners/lib/Db/WebhookListenerMapper.php create mode 100644 apps/webhook_listeners/lib/Listener/WebhooksEventListener.php create mode 100755 apps/webhook_listeners/lib/Migration/Version1000Date20240527153425.php create mode 100644 apps/webhook_listeners/lib/ResponseDefinitions.php create mode 100644 apps/webhook_listeners/lib/Service/PHPMongoQuery.php create mode 100644 apps/webhook_listeners/lib/Settings/Admin.php (limited to 'apps/webhook_listeners/lib') diff --git a/apps/webhook_listeners/lib/AppInfo/Application.php b/apps/webhook_listeners/lib/AppInfo/Application.php new file mode 100644 index 00000000000..d1ffa5db49b --- /dev/null +++ b/apps/webhook_listeners/lib/AppInfo/Application.php @@ -0,0 +1,55 @@ +injectFn($this->registerRuleListeners(...)); + } + + private function registerRuleListeners( + IEventDispatcher $dispatcher, + ContainerInterface $container, + LoggerInterface $logger, + ): void { + /** @var WebhookListenerMapper */ + $mapper = $container->get(WebhookListenerMapper::class); + + /* Listen to all events with at least one webhook configured */ + $configuredEvents = $mapper->getAllConfiguredEvents(); + foreach ($configuredEvents as $eventName) { + $logger->debug("Listening to {$eventName}"); + $dispatcher->addServiceListener( + $eventName, + WebhooksEventListener::class, + -1, + ); + } + } +} diff --git a/apps/webhook_listeners/lib/BackgroundJobs/WebhookCall.php b/apps/webhook_listeners/lib/BackgroundJobs/WebhookCall.php new file mode 100644 index 00000000000..9689d4cb585 --- /dev/null +++ b/apps/webhook_listeners/lib/BackgroundJobs/WebhookCall.php @@ -0,0 +1,60 @@ +mapper->getById($webhookId); + $client = $this->clientService->newClient(); + $options = [ + 'verify' => $this->certificateManager->getAbsoluteBundlePath(), + 'headers' => $webhookListener->getHeaders() ?? [], + 'body' => json_encode($data), + ]; + try { + switch ($webhookListener->getAuthMethodEnum()) { + case AuthMethod::None: + break; + case AuthMethod::Header: + $authHeaders = $webhookListener->getAuthDataClear(); + $options['headers'] = array_merge($options['headers'], $authHeaders); + break; + } + $response = $client->request($webhookListener->getHttpMethod(), $webhookListener->getUri(), $options); + $statusCode = $response->getStatusCode(); + if ($statusCode >= 200 && $statusCode < 300) { + $this->logger->debug('Webhook returned status code '.$statusCode, ['body' => $response->getBody()]); + } else { + $this->logger->warning('Webhook returned unexpected status code '.$statusCode, ['body' => $response->getBody()]); + } + } catch (\Exception $e) { + $this->logger->error('Webhook call failed: '.$e->getMessage(), ['exception' => $e]); + } + } +} diff --git a/apps/webhook_listeners/lib/Command/ListWebhooks.php b/apps/webhook_listeners/lib/Command/ListWebhooks.php new file mode 100644 index 00000000000..157097f3f15 --- /dev/null +++ b/apps/webhook_listeners/lib/Command/ListWebhooks.php @@ -0,0 +1,43 @@ +setName('webhook_listeners:list') + ->setDescription('Lists configured webhook listeners'); + } + + protected function execute(InputInterface $input, OutputInterface $output): int { + $webhookListeners = array_map( + fn (WebhookListener $listener): array => array_map( + fn (string|array|null $value): ?string => (is_array($value) ? json_encode($value) : $value), + $listener->jsonSerialize() + ), + $this->mapper->getAll() + ); + $this->writeTableInOutputFormat($input, $output, $webhookListeners); + return static::SUCCESS; + } +} diff --git a/apps/webhook_listeners/lib/Controller/WebhooksController.php b/apps/webhook_listeners/lib/Controller/WebhooksController.php new file mode 100644 index 00000000000..88a6e473d85 --- /dev/null +++ b/apps/webhook_listeners/lib/Controller/WebhooksController.php @@ -0,0 +1,220 @@ + + * + * 200: Webhook registrations returned + */ + #[ApiRoute(verb: 'GET', url: '/api/v1/webhooks')] + #[AuthorizedAdminSetting(settings:'OCA\WebhookListeners\Settings\Admin')] + public function index(): DataResponse { + $webhookListeners = $this->mapper->getAll(); + + return new DataResponse($webhookListeners); + } + + /** + * Get details on a registered webhook + * + * @param int $id id of the webhook + * + * @return DataResponse + * + * 200: Webhook registration returned + */ + #[ApiRoute(verb: 'GET', url: '/api/v1/webhooks/{id}')] + #[AuthorizedAdminSetting(settings:'OCA\WebhookListeners\Settings\Admin')] + public function show(int $id): DataResponse { + return new DataResponse($this->mapper->getById($id)); + } + + /** + * Register a new webhook + * + * @param string $httpMethod HTTP method to use to contact the webhook + * @param string $uri Webhook URI endpoint + * @param string $event Event class name to listen to + * @param ?array $eventFilter Mongo filter to apply to the serialized data to decide if firing + * @param ?array $headers Array of headers to send + * @param "none"|"headers"|null $authMethod Authentication method to use + * @param ?array $authData Array of data for authentication + * + * @return DataResponse + * + * 200: Webhook registration returned + * + * @throws OCSBadRequestException Bad request + * @throws OCSForbiddenException Insufficient permissions + * @throws OCSException Other error + */ + #[ApiRoute(verb: 'POST', url: '/api/v1/webhooks')] + #[AuthorizedAdminSetting(settings:'OCA\WebhookListeners\Settings\Admin')] + public function create( + string $httpMethod, + string $uri, + string $event, + ?array $eventFilter, + ?array $headers, + ?string $authMethod, + #[\SensitiveParameter] + ?array $authData, + ): DataResponse { + $appId = null; + if ($this->session->get('app_api') === true) { + $appId = $this->request->getHeader('EX-APP-ID'); + } + try { + $webhookListener = $this->mapper->addWebhookListener( + $appId, + $this->userId, + $httpMethod, + $uri, + $event, + $eventFilter, + $headers, + AuthMethod::from($authMethod ?? AuthMethod::None->value), + $authData, + ); + return new DataResponse($webhookListener); + } catch (\UnexpectedValueException $e) { + throw new OCSBadRequestException($e->getMessage(), $e); + } catch (\DomainException $e) { + throw new OCSForbiddenException($e->getMessage(), $e); + } catch (\Exception $e) { + $this->logger->error('Error when inserting webhook', ['exception' => $e]); + throw new OCSException('An internal error occurred', $e->getCode(), $e); + } + } + + /** + * Update an existing webhook registration + * + * @param int $id id of the webhook + * @param string $httpMethod HTTP method to use to contact the webhook + * @param string $uri Webhook URI endpoint + * @param string $event Event class name to listen to + * @param ?array $eventFilter Mongo filter to apply to the serialized data to decide if firing + * @param ?array $headers Array of headers to send + * @param "none"|"headers"|null $authMethod Authentication method to use + * @param ?array $authData Array of data for authentication + * + * @return DataResponse + * + * 200: Webhook registration returned + * + * @throws OCSBadRequestException Bad request + * @throws OCSForbiddenException Insufficient permissions + * @throws OCSException Other error + */ + #[ApiRoute(verb: 'POST', url: '/api/v1/webhooks/{id}')] + #[AuthorizedAdminSetting(settings:'OCA\WebhookListeners\Settings\Admin')] + public function update( + int $id, + string $httpMethod, + string $uri, + string $event, + ?array $eventFilter, + ?array $headers, + ?string $authMethod, + #[\SensitiveParameter] + ?array $authData, + ): DataResponse { + $appId = null; + if ($this->session->get('app_api') === true) { + $appId = $this->request->getHeader('EX-APP-ID'); + } + try { + $webhookListener = $this->mapper->updateWebhookListener( + $id, + $appId, + $this->userId, + $httpMethod, + $uri, + $event, + $eventFilter, + $headers, + AuthMethod::from($authMethod ?? AuthMethod::None->value), + $authData, + ); + return new DataResponse($webhookListener); + } catch (\UnexpectedValueException $e) { + throw new OCSBadRequestException($e->getMessage(), $e); + } catch (\DomainException $e) { + throw new OCSForbiddenException($e->getMessage(), $e); + } catch (\Exception $e) { + $this->logger->error('Error when updating flow with id ' . $id, ['exception' => $e]); + throw new OCSException('An internal error occurred', $e->getCode(), $e); + } + } + + /** + * Remove an existing webhook registration + * + * @param int $id id of the webhook + * + * @return DataResponse + * + * 200: Boolean returned whether something was deleted FIXME + * + * @throws OCSBadRequestException Bad request + * @throws OCSForbiddenException Insufficient permissions + * @throws OCSException Other error + */ + #[ApiRoute(verb: 'DELETE', url: '/api/v1/webhooks/{id}')] + #[AuthorizedAdminSetting(settings:'OCA\WebhookListeners\Settings\Admin')] + public function destroy(int $id): DataResponse { + try { + $deleted = $this->mapper->deleteById($id); + return new DataResponse($deleted); + } catch (\UnexpectedValueException $e) { + throw new OCSBadRequestException($e->getMessage(), $e); + } catch (\DomainException $e) { + throw new OCSForbiddenException($e->getMessage(), $e); + } catch (\Exception $e) { + $this->logger->error('Error when deleting flow with id ' . $id, ['exception' => $e]); + throw new OCSException('An internal error occurred', $e->getCode(), $e); + } + } +} diff --git a/apps/webhook_listeners/lib/Db/AuthMethod.php b/apps/webhook_listeners/lib/Db/AuthMethod.php new file mode 100644 index 00000000000..ab8bff76eb7 --- /dev/null +++ b/apps/webhook_listeners/lib/Db/AuthMethod.php @@ -0,0 +1,15 @@ +crypto = $crypto; + $this->addType('appId', 'string'); + $this->addType('userId', 'string'); + $this->addType('httpMethod', 'string'); + $this->addType('uri', 'string'); + $this->addType('event', 'string'); + $this->addType('eventFilter', 'json'); + $this->addType('headers', 'json'); + $this->addType('authMethod', 'string'); + $this->addType('authData', 'string'); + } + + public function getAuthMethodEnum(): AuthMethod { + return AuthMethod::from(parent::getAuthMethod()); + } + + public function getAuthDataClear(): array { + if ($this->authData === null) { + return []; + } + return json_decode($this->crypto->decrypt($this->getAuthData()), associative:true, flags:JSON_THROW_ON_ERROR); + } + + public function setAuthDataClear( + #[\SensitiveParameter] + ?array $data + ): void { + if ($data === null) { + if ($this->getAuthMethodEnum() === AuthMethod::Header) { + throw new \UnexpectedValueException('Header auth method needs an associative array of headers as auth data'); + } + $this->setAuthData(null); + return; + } + $this->setAuthData($this->crypto->encrypt(json_encode($data))); + } + + public function jsonSerialize(): array { + $fields = array_keys($this->getFieldTypes()); + return array_combine( + $fields, + array_map( + fn ($field) => $this->getter($field), + $fields + ) + ); + } +} diff --git a/apps/webhook_listeners/lib/Db/WebhookListenerMapper.php b/apps/webhook_listeners/lib/Db/WebhookListenerMapper.php new file mode 100644 index 00000000000..97e01062f2f --- /dev/null +++ b/apps/webhook_listeners/lib/Db/WebhookListenerMapper.php @@ -0,0 +1,207 @@ + + */ +class WebhookListenerMapper extends QBMapper { + public const TABLE_NAME = 'webhook_listeners'; + + private const EVENTS_CACHE_KEY = 'eventsUsedInWebhooks'; + + private ?ICache $cache = null; + + public function __construct( + IDBConnection $db, + ICacheFactory $cacheFactory, + ) { + parent::__construct($db, self::TABLE_NAME, WebhookListener::class); + if ($cacheFactory->isAvailable()) { + $this->cache = $cacheFactory->createDistributed(); + } + } + + /** + * @throws DoesNotExistException + * @throws MultipleObjectsReturnedException + * @throws Exception + */ + public function getById(int $id): WebhookListener { + $qb = $this->db->getQueryBuilder(); + + $qb->select('*') + ->from($this->getTableName()) + ->where($qb->expr()->eq('id', $qb->createNamedParameter($id, IQueryBuilder::PARAM_INT))); + + return $this->findEntity($qb); + } + + /** + * @throws Exception + * @return WebhookListener[] + */ + public function getAll(): array { + $qb = $this->db->getQueryBuilder(); + + $qb->select('*') + ->from($this->getTableName()); + + return $this->findEntities($qb); + } + + /** + * @throws Exception + */ + public function addWebhookListener( + ?string $appId, + string $userId, + string $httpMethod, + string $uri, + string $event, + ?array $eventFilter, + ?array $headers, + AuthMethod $authMethod, + #[\SensitiveParameter] + ?array $authData, + ): WebhookListener { + /* Remove any superfluous antislash */ + $event = ltrim($event, '\\'); + if (!class_exists($event) || !is_a($event, IWebhookCompatibleEvent::class, true)) { + throw new \UnexpectedValueException("$event is not an event class compatible with webhooks"); + } + $webhookListener = WebhookListener::fromParams( + [ + 'appId' => $appId, + 'userId' => $userId, + 'httpMethod' => $httpMethod, + 'uri' => $uri, + 'event' => $event, + 'eventFilter' => $eventFilter ?? [], + 'headers' => $headers, + 'authMethod' => $authMethod->value, + ] + ); + $webhookListener->setAuthDataClear($authData); + $this->cache?->remove(self::EVENTS_CACHE_KEY); + return $this->insert($webhookListener); + } + + /** + * @throws Exception + */ + public function updateWebhookListener( + int $id, + ?string $appId, + string $userId, + string $httpMethod, + string $uri, + string $event, + ?array $eventFilter, + ?array $headers, + AuthMethod $authMethod, + #[\SensitiveParameter] + ?array $authData, + ): WebhookListener { + /* Remove any superfluous antislash */ + $event = ltrim($event, '\\'); + if (!class_exists($event) || !is_a($event, IWebhookCompatibleEvent::class, true)) { + throw new \UnexpectedValueException("$event is not an event class compatible with webhooks"); + } + $webhookListener = WebhookListener::fromParams( + [ + 'id' => $id, + 'appId' => $appId, + 'userId' => $userId, + 'httpMethod' => $httpMethod, + 'uri' => $uri, + 'event' => $event, + 'eventFilter' => $eventFilter ?? [], + 'headers' => $headers, + 'authMethod' => $authMethod->value, + ] + ); + $webhookListener->setAuthDataClear($authData); + $this->cache?->remove(self::EVENTS_CACHE_KEY); + return $this->update($webhookListener); + } + + /** + * @throws Exception + */ + public function deleteById(int $id): bool { + $qb = $this->db->getQueryBuilder(); + + $qb->delete($this->getTableName()) + ->where($qb->expr()->eq('id', $qb->createNamedParameter($id, IQueryBuilder::PARAM_INT))); + + return ($qb->executeStatement() > 0); + } + + /** + * @throws Exception + * @return list + */ + private function getAllConfiguredEventsFromDatabase(): array { + $qb = $this->db->getQueryBuilder(); + + $qb->selectDistinct('event') + ->from($this->getTableName()); + + $result = $qb->executeQuery(); + + $configuredEvents = []; + + while (($event = $result->fetchOne()) !== false) { + $configuredEvents[] = $event; + } + + return $configuredEvents; + } + + /** + * List all events with at least one webhook configured, with cache + * @throws Exception + * @return list + */ + public function getAllConfiguredEvents(): array { + $events = $this->cache?->get(self::EVENTS_CACHE_KEY); + if ($events !== null) { + return json_decode($events); + } + $events = $this->getAllConfiguredEventsFromDatabase(); + // cache for 5 minutes + $this->cache?->set(self::EVENTS_CACHE_KEY, json_encode($events), 300); + return $events; + } + + /** + * @throws Exception + */ + public function getByEvent(string $event): array { + $qb = $this->db->getQueryBuilder(); + + $qb->select('*') + ->from($this->getTableName()) + ->where($qb->expr()->eq('event', $qb->createNamedParameter($event, IQueryBuilder::PARAM_STR))); + + return $this->findEntities($qb); + } +} diff --git a/apps/webhook_listeners/lib/Listener/WebhooksEventListener.php b/apps/webhook_listeners/lib/Listener/WebhooksEventListener.php new file mode 100644 index 00000000000..72d48d790e1 --- /dev/null +++ b/apps/webhook_listeners/lib/Listener/WebhooksEventListener.php @@ -0,0 +1,71 @@ + + */ +class WebhooksEventListener implements IEventListener { + public function __construct( + private WebhookListenerMapper $mapper, + private IJobList $jobList, + private LoggerInterface $logger, + private IUserSession $userSession, + ) { + } + + public function handle(Event $event): void { + $webhookListeners = $this->mapper->getByEvent($event::class); + $user = $this->userSession->getUser(); + + foreach ($webhookListeners as $webhookListener) { + // TODO add group membership to be able to filter on it + $data = [ + 'event' => $this->serializeEvent($event), + 'user' => (is_null($user) ? null : JsonSerializer::serializeUser($user)), + 'time' => time(), + ]; + if ($this->filterMatch($webhookListener->getEventFilter(), $data)) { + $this->jobList->add( + WebhookCall::class, + [ + $data, + $webhookListener->getId(), + ] + ); + } + } + } + + private function serializeEvent(IWebhookCompatibleEvent $event): array { + $data = $event->getWebhookSerializable(); + $data['class'] = $event::class; + return $data; + } + + private function filterMatch(array $filter, array $data): bool { + if ($filter === []) { + return true; + } + return PHPMongoQuery::executeQuery($filter, $data); + } +} diff --git a/apps/webhook_listeners/lib/Migration/Version1000Date20240527153425.php b/apps/webhook_listeners/lib/Migration/Version1000Date20240527153425.php new file mode 100755 index 00000000000..44f2476dd44 --- /dev/null +++ b/apps/webhook_listeners/lib/Migration/Version1000Date20240527153425.php @@ -0,0 +1,72 @@ +hasTable(WebhookListenerMapper::TABLE_NAME)) { + $table = $schema->createTable(WebhookListenerMapper::TABLE_NAME); + $table->addColumn('id', Types::BIGINT, [ + 'autoincrement' => true, + 'notnull' => true, + 'length' => 4, + ]); + $table->addColumn('app_id', Types::STRING, [ + 'notnull' => false, + 'length' => 64, + ]); + $table->addColumn('user_id', Types::STRING, [ + 'notnull' => true, + 'length' => 64, + ]); + $table->addColumn('http_method', Types::STRING, [ + 'notnull' => true, + 'length' => 32, + ]); + $table->addColumn('uri', Types::STRING, [ + 'notnull' => true, + 'length' => 4000, + ]); + $table->addColumn('event', Types::TEXT, [ + 'notnull' => true, + ]); + $table->addColumn('event_filter', Types::TEXT, [ + 'notnull' => false, + ]); + $table->addColumn('headers', Types::TEXT, [ + 'notnull' => false, + ]); + $table->addColumn('auth_method', Types::STRING, [ + 'notnull' => true, + 'length' => 16, + 'default' => '', + ]); + $table->addColumn('auth_data', Types::TEXT, [ + 'notnull' => false, + ]); + $table->setPrimaryKey(['id']); + return $schema; + } + return null; + } +} diff --git a/apps/webhook_listeners/lib/ResponseDefinitions.php b/apps/webhook_listeners/lib/ResponseDefinitions.php new file mode 100644 index 00000000000..3b9965c20a3 --- /dev/null +++ b/apps/webhook_listeners/lib/ResponseDefinitions.php @@ -0,0 +1,26 @@ +, + * headers?: array, + * authMethod: string, + * authData?: array, + * } + */ +class ResponseDefinitions { +} diff --git a/apps/webhook_listeners/lib/Service/PHPMongoQuery.php b/apps/webhook_listeners/lib/Service/PHPMongoQuery.php new file mode 100644 index 00000000000..e8e52615008 --- /dev/null +++ b/apps/webhook_listeners/lib/Service/PHPMongoQuery.php @@ -0,0 +1,340 @@ +debug('executeQuery called', ['query' => $query, 'document' => $document, 'options' => $options]); + } + + if(!is_array($query)) { + return (bool)$query; + } + + return self::_executeQuery($query, $document, $options); + } + + /** + * Internal execute query + * + * This expects an array from the query and has an additional logical operator (for the root query object the logical operator is always $and so this is not required) + * + * @throws Exception + */ + private static function _executeQuery(array $query, array &$document, array $options = [], string $logicalOperator = '$and'): bool { + if($logicalOperator !== '$and' && (!count($query) || !isset($query[0]))) { + throw new Exception($logicalOperator.' requires nonempty array'); + } + if($options['_debug'] && $options['_shouldLog']) { + $options['logger']->debug('_executeQuery called', ['query' => $query, 'document' => $document, 'logicalOperator' => $logicalOperator]); + } + + // for the purpose of querying documents, we are going to specify that an indexed array is an array which + // only contains numeric keys, is sequential, the first key is zero, and not empty. This will allow us + // to detect an array of key->vals that have numeric IDs vs an array of queries (where keys were not specified) + $queryIsIndexedArray = !empty($query) && array_is_list($query); + + foreach($query as $k => $q) { + $pass = true; + if(is_string($k) && substr($k, 0, 1) === '$') { + // key is an operator at this level, except $not, which can be at any level + if($k === '$not') { + $pass = !self::_executeQuery($q, $document, $options); + } else { + $pass = self::_executeQuery($q, $document, $options, $k); + } + } elseif($logicalOperator === '$and') { // special case for $and + if($queryIsIndexedArray) { // $q is an array of query objects + $pass = self::_executeQuery($q, $document, $options); + } elseif(is_array($q)) { // query is array, run all queries on field. All queries must match. e.g { 'age': { $gt: 24, $lt: 52 } } + $pass = self::_executeQueryOnElement($q, $k, $document, $options); + } else { + // key value means equality + $pass = self::_executeOperatorOnElement('$e', $q, $k, $document, $options); + } + } else { // $q is array of query objects e.g '$or' => [{'fullName' => 'Nick'}] + $pass = self::_executeQuery($q, $document, $options, '$and'); + } + switch($logicalOperator) { + case '$and': // if any fail, query fails + if(!$pass) { + return false; + } + break; + case '$or': // if one succeeds, query succeeds + if($pass) { + return true; + } + break; + case '$nor': // if one succeeds, query fails + if($pass) { + return false; + } + break; + default: + if($options['_shouldLog']) { + $options['logger']->warning('_executeQuery could not find logical operator', ['query' => $query, 'document' => $document, 'logicalOperator' => $logicalOperator]); + } + return false; + } + } + switch($logicalOperator) { + case '$and': // all succeeded, query succeeds + return true; + case '$or': // all failed, query fails + return false; + case '$nor': // all failed, query succeeded + return true; + default: + if($options['_shouldLog']) { + $options['logger']->warning('_executeQuery could not find logical operator', ['query' => $query, 'document' => $document, 'logicalOperator' => $logicalOperator]); + } + return false; + } + } + + /** + * Execute a query object on an element + * + * @throws Exception + */ + private static function _executeQueryOnElement(array $query, string $element, array &$document, array $options = []): bool { + if($options['_debug'] && $options['_shouldLog']) { + $options['logger']->debug('_executeQueryOnElement called', ['query' => $query, 'element' => $element, 'document' => $document]); + } + // iterate through query operators + foreach($query as $op => $opVal) { + if(!self::_executeOperatorOnElement($op, $opVal, $element, $document, $options)) { + return false; + } + } + return true; + } + + /** + * Check if an operator is equal to a value + * + * Equality includes direct equality, regular expression match, and checking if the operator value is one of the values in an array value + * + * @param mixed $v + * @param mixed $operatorValue + */ + private static function _isEqual($v, $operatorValue): bool { + if (is_array($v) && is_array($operatorValue)) { + return $v == $operatorValue; + } + if(is_array($v)) { + return in_array($operatorValue, $v); + } + if(is_string($operatorValue) && preg_match('/^\/(.*?)\/([a-z]*)$/i', $operatorValue, $matches)) { + return (bool)preg_match('/'.$matches[1].'/'.$matches[2], $v); + } + return $operatorValue === $v; + } + + /** + * Execute a Mongo Operator on an element + * + * @param string $operator The operator to perform + * @param mixed $operatorValue The value to provide the operator + * @param string $element The target element. Can be an object path eg price.shoes + * @param array $document The document in which to find the element + * @param array $options Options + * @throws Exception Exceptions on invalid operators, invalid unknown operator callback, and invalid operator values + */ + private static function _executeOperatorOnElement(string $operator, $operatorValue, string $element, array &$document, array $options = []): bool { + if($options['_debug'] && $options['_shouldLog']) { + $options['logger']->debug('_executeOperatorOnElement called', ['operator' => $operator, 'operatorValue' => $operatorValue, 'element' => $element, 'document' => $document]); + } + + if($operator === '$not') { + return !self::_executeQueryOnElement($operatorValue, $element, $document, $options); + } + + $elementSpecifier = explode('.', $element); + $v = & $document; + $exists = true; + foreach($elementSpecifier as $index => $es) { + if(empty($v)) { + $exists = false; + break; + } + if(isset($v[0])) { + // value from document is an array, so we need to iterate through array and test the query on all elements of the array + // if any elements match, then return true + $newSpecifier = implode('.', array_slice($elementSpecifier, $index)); + foreach($v as $item) { + if(self::_executeOperatorOnElement($operator, $operatorValue, $newSpecifier, $item, $options)) { + return true; + } + } + return false; + } + if(isset($v[$es])) { + $v = & $v[$es]; + } else { + $exists = false; + break; + } + } + + switch($operator) { + case '$all': + if(!$exists) { + return false; + } + if(!is_array($operatorValue)) { + throw new Exception('$all requires array'); + } + if(count($operatorValue) === 0) { + return false; + } + if(!is_array($v)) { + if(count($operatorValue) === 1) { + return $v === $operatorValue[0]; + } + return false; + } + return count(array_intersect($v, $operatorValue)) === count($operatorValue); + case '$e': + if(!$exists) { + return false; + } + return self::_isEqual($v, $operatorValue); + case '$in': + if(!$exists) { + return false; + } + if(!is_array($operatorValue)) { + throw new Exception('$in requires array'); + } + if(count($operatorValue) === 0) { + return false; + } + if(is_array($v)) { + return count(array_intersect($v, $operatorValue)) > 0; + } + return in_array($v, $operatorValue); + case '$lt': return $exists && $v < $operatorValue; + case '$lte': return $exists && $v <= $operatorValue; + case '$gt': return $exists && $v > $operatorValue; + case '$gte': return $exists && $v >= $operatorValue; + case '$ne': return (!$exists && $operatorValue !== null) || ($exists && !self::_isEqual($v, $operatorValue)); + case '$nin': + if(!$exists) { + return true; + } + if(!is_array($operatorValue)) { + throw new Exception('$nin requires array'); + } + if(count($operatorValue) === 0) { + return true; + } + if(is_array($v)) { + return count(array_intersect($v, $operatorValue)) === 0; + } + return !in_array($v, $operatorValue); + + case '$exists': return ($operatorValue && $exists) || (!$operatorValue && !$exists); + case '$mod': + if(!$exists) { + return false; + } + if(!is_array($operatorValue)) { + throw new Exception('$mod requires array'); + } + if(count($operatorValue) !== 2) { + throw new Exception('$mod requires two parameters in array: divisor and remainder'); + } + return $v % $operatorValue[0] === $operatorValue[1]; + + default: + if(empty($options['unknownOperatorCallback']) || !is_callable($options['unknownOperatorCallback'])) { + throw new Exception('Operator '.$operator.' is unknown'); + } + + $res = call_user_func($options['unknownOperatorCallback'], $operator, $operatorValue, $element, $document); + if($res === null) { + throw new Exception('Operator '.$operator.' is unknown'); + } + if(!is_bool($res)) { + throw new Exception('Return value of unknownOperatorCallback must be boolean, actual value '.$res); + } + return $res; + } + throw new Exception('Didn\'t return in switch'); + } + + /** + * Get the fields this query depends on + * + * @param array query The query to analyse + * @return array An array of fields this query depends on + */ + public static function getDependentFields(array $query) { + $fields = []; + foreach($query as $k => $v) { + if(is_array($v)) { + $fields = array_merge($fields, static::getDependentFields($v)); + } + if(is_int($k) || $k[0] === '$') { + continue; + } + $fields[] = $k; + } + return array_unique($fields); + } +} diff --git a/apps/webhook_listeners/lib/Settings/Admin.php b/apps/webhook_listeners/lib/Settings/Admin.php new file mode 100644 index 00000000000..e5e0d00221c --- /dev/null +++ b/apps/webhook_listeners/lib/Settings/Admin.php @@ -0,0 +1,60 @@ +appName, '') extends TemplateResponse { + public function render(): string { + return ''; + } + }; + } + + public function getSection(): ?string { + return 'admindelegation'; + } + + /** + * @return int whether the form should be rather on the top or bottom of + * the admin section. The forms are arranged in ascending order of the + * priority values. It is required to return a value between 0 and 100. + * + * E.g.: 70 + */ + public function getPriority(): int { + return 0; + } + + public function getName(): string { + return $this->l10n->t('Webhooks'); + } + + public function getAuthorizedAppConfig(): array { + return []; + } +} -- cgit v1.2.3