diff options
author | Côme Chilliet <come.chilliet@nextcloud.com> | 2024-05-30 17:26:40 +0200 |
---|---|---|
committer | Côme Chilliet <91878298+come-nc@users.noreply.github.com> | 2024-06-11 14:10:29 +0200 |
commit | 144bdd73f9ca96174d7de8664b4026b65d3bdf07 (patch) | |
tree | 3998e2ec2bfb6c34c02dcb196813d577f5e7c2a4 | |
parent | 5dd9c2f8e85816f5c588d9539ec33b97d111287e (diff) | |
download | nextcloud-server-144bdd73f9ca96174d7de8664b4026b65d3bdf07.tar.gz nextcloud-server-144bdd73f9ca96174d7de8664b4026b65d3bdf07.zip |
feat: Add event filtering to webhooks
Signed-off-by: Côme Chilliet <come.chilliet@nextcloud.com>
-rw-r--r-- | apps/webhooks/composer/composer/autoload_classmap.php | 1 | ||||
-rw-r--r-- | apps/webhooks/composer/composer/autoload_static.php | 1 | ||||
-rw-r--r-- | apps/webhooks/lib/BackgroundJobs/WebhookCall.php | 7 | ||||
-rw-r--r-- | apps/webhooks/lib/Command/Index.php | 6 | ||||
-rw-r--r-- | apps/webhooks/lib/Controller/WebhooksController.php | 4 | ||||
-rw-r--r-- | apps/webhooks/lib/Db/WebhookListener.php | 1 | ||||
-rw-r--r-- | apps/webhooks/lib/Db/WebhookListenerMapper.php | 4 | ||||
-rw-r--r-- | apps/webhooks/lib/Listener/WebhooksEventListener.php | 30 | ||||
-rwxr-xr-x | apps/webhooks/lib/Migration/Version1000Date20240527153425.php | 3 | ||||
-rw-r--r-- | apps/webhooks/lib/Service/PHPMongoQuery.php | 340 | ||||
-rw-r--r-- | apps/webhooks/tests/Db/WebhookListenerMapperTest.php | 1 |
11 files changed, 390 insertions, 8 deletions
diff --git a/apps/webhooks/composer/composer/autoload_classmap.php b/apps/webhooks/composer/composer/autoload_classmap.php index 3411d0b6b96..efd4a43d58a 100644 --- a/apps/webhooks/composer/composer/autoload_classmap.php +++ b/apps/webhooks/composer/composer/autoload_classmap.php @@ -16,5 +16,6 @@ return array( 'OCA\\Webhooks\\Listener\\WebhooksEventListener' => $baseDir . '/../lib/Listener/WebhooksEventListener.php', 'OCA\\Webhooks\\Migration\\Version1000Date20240527153425' => $baseDir . '/../lib/Migration/Version1000Date20240527153425.php', 'OCA\\Webhooks\\ResponseDefinitions' => $baseDir . '/../lib/ResponseDefinitions.php', + 'OCA\\Webhooks\\Service\\PHPMongoQuery' => $baseDir . '/../lib/Service/PHPMongoQuery.php', 'OCA\\Webhooks\\Settings\\Admin' => $baseDir . '/../lib/Settings/Admin.php', ); diff --git a/apps/webhooks/composer/composer/autoload_static.php b/apps/webhooks/composer/composer/autoload_static.php index e631fdfb975..75182423ae6 100644 --- a/apps/webhooks/composer/composer/autoload_static.php +++ b/apps/webhooks/composer/composer/autoload_static.php @@ -31,6 +31,7 @@ class ComposerStaticInitWebhooks 'OCA\\Webhooks\\Listener\\WebhooksEventListener' => __DIR__ . '/..' . '/../lib/Listener/WebhooksEventListener.php', 'OCA\\Webhooks\\Migration\\Version1000Date20240527153425' => __DIR__ . '/..' . '/../lib/Migration/Version1000Date20240527153425.php', 'OCA\\Webhooks\\ResponseDefinitions' => __DIR__ . '/..' . '/../lib/ResponseDefinitions.php', + 'OCA\\Webhooks\\Service\\PHPMongoQuery' => __DIR__ . '/..' . '/../lib/Service/PHPMongoQuery.php', 'OCA\\Webhooks\\Settings\\Admin' => __DIR__ . '/..' . '/../lib/Settings/Admin.php', ); diff --git a/apps/webhooks/lib/BackgroundJobs/WebhookCall.php b/apps/webhooks/lib/BackgroundJobs/WebhookCall.php index 9b113a5c1fc..469b554a886 100644 --- a/apps/webhooks/lib/BackgroundJobs/WebhookCall.php +++ b/apps/webhooks/lib/BackgroundJobs/WebhookCall.php @@ -26,14 +26,11 @@ class WebhookCall extends QueuedJob { } protected function run($argument): void { - [$event, $userId, $webhookId] = $argument; + [$data, $webhookId] = $argument; $webhookListener = $this->mapper->getById($webhookId); $client = $this->clientService->newClient(); $options = []; - $options['body'] = json_encode([ - 'event' => $event, - 'userid' => $userId, - ]); + $options['body'] = json_encode($data); try { $response = $client->request($webhookListener->getHttpMethod(), $webhookListener->getUri(), $options); $statusCode = $response->getStatusCode(); diff --git a/apps/webhooks/lib/Command/Index.php b/apps/webhooks/lib/Command/Index.php index 900140c8f96..78feda3ec68 100644 --- a/apps/webhooks/lib/Command/Index.php +++ b/apps/webhooks/lib/Command/Index.php @@ -31,7 +31,11 @@ class Index extends Base { protected function execute(InputInterface $input, OutputInterface $output): int { $webhookListeners = array_map( - fn (WebhookListener $listener) => $listener->jsonSerialize(), + function (WebhookListener $listener): array { + $data = $listener->jsonSerialize(); + $data['eventFilter'] = json_encode($data['eventFilter']); + return $data; + }, $this->mapper->getAll() ); $this->writeTableInOutputFormat($input, $output, $webhookListeners); diff --git a/apps/webhooks/lib/Controller/WebhooksController.php b/apps/webhooks/lib/Controller/WebhooksController.php index 9d4c7c70279..46da32fd6e2 100644 --- a/apps/webhooks/lib/Controller/WebhooksController.php +++ b/apps/webhooks/lib/Controller/WebhooksController.php @@ -91,6 +91,7 @@ class WebhooksController extends OCSController { string $httpMethod, string $uri, string $event, + ?array $eventFilter, ?array $headers, ?string $authMethod, ?array $authData, @@ -101,6 +102,7 @@ class WebhooksController extends OCSController { $httpMethod, $uri, $event, + $eventFilter, $headers, $authMethod, $authData, @@ -142,6 +144,7 @@ class WebhooksController extends OCSController { string $httpMethod, string $uri, string $event, + ?array $eventFilter, ?array $headers, ?string $authMethod, ?array $authData, @@ -153,6 +156,7 @@ class WebhooksController extends OCSController { $httpMethod, $uri, $event, + $eventFilter, $headers, $authMethod, $authData, diff --git a/apps/webhooks/lib/Db/WebhookListener.php b/apps/webhooks/lib/Db/WebhookListener.php index 0d731e72d7f..a52042aa0a2 100644 --- a/apps/webhooks/lib/Db/WebhookListener.php +++ b/apps/webhooks/lib/Db/WebhookListener.php @@ -42,6 +42,7 @@ class WebhookListener extends Entity implements \JsonSerializable { $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', 'json'); diff --git a/apps/webhooks/lib/Db/WebhookListenerMapper.php b/apps/webhooks/lib/Db/WebhookListenerMapper.php index 773358d671c..5ce824893ab 100644 --- a/apps/webhooks/lib/Db/WebhookListenerMapper.php +++ b/apps/webhooks/lib/Db/WebhookListenerMapper.php @@ -61,6 +61,7 @@ class WebhookListenerMapper extends QBMapper { string $httpMethod, string $uri, string $event, + ?array $eventFilter, ?array $headers, ?string $authMethod, ?array $authData, @@ -71,6 +72,7 @@ class WebhookListenerMapper extends QBMapper { 'httpMethod' => $httpMethod, 'uri' => $uri, 'event' => $event, + 'eventFilter' => $eventFilter ?? [], 'headers' => $headers, 'authMethod' => $authMethod ?? 'none', 'authData' => $authData, @@ -85,6 +87,7 @@ class WebhookListenerMapper extends QBMapper { string $httpMethod, string $uri, string $event, + ?array $eventFilter, ?array $headers, ?string $authMethod, ?array $authData, @@ -96,6 +99,7 @@ class WebhookListenerMapper extends QBMapper { 'httpMethod' => $httpMethod, 'uri' => $uri, 'event' => $event, + 'eventFilter' => $eventFilter ?? [], 'headers' => $headers, 'authMethod' => $authMethod, 'authData' => $authData, diff --git a/apps/webhooks/lib/Listener/WebhooksEventListener.php b/apps/webhooks/lib/Listener/WebhooksEventListener.php index 8e935f1d0ce..5ea0012d4e9 100644 --- a/apps/webhooks/lib/Listener/WebhooksEventListener.php +++ b/apps/webhooks/lib/Listener/WebhooksEventListener.php @@ -11,9 +11,12 @@ namespace OCA\Webhooks\Listener; use OCA\Webhooks\BackgroundJobs\WebhookCall; use OCA\Webhooks\Db\WebhookListenerMapper; +use OCA\Webhooks\Service\PHPMongoQuery; use OCP\BackgroundJob\IJobList; use OCP\EventDispatcher\Event; use OCP\EventDispatcher\IEventListener; +use OCP\EventDispatcher\JsonSerializer; +use OCP\IUserSession; use Psr\Log\LoggerInterface; /** @@ -25,15 +28,31 @@ class WebhooksEventListener implements IEventListener { private WebhookListenerMapper $mapper, private IJobList $jobList, private LoggerInterface $logger, - private ?string $userId, + private IUserSession $userSession, ) { } public function handle(Event $event): void { $webhookListeners = $this->mapper->getByEvent($event::class); + /** @var IUser */ + $user = $this->userSession->getUser(); foreach ($webhookListeners as $webhookListener) { - $this->jobList->add(WebhookCall::class, [$this->serializeEvent($event), $this->userId, $webhookListener->getId(), time()]); + // TODO add group membership to be able to filter on it + $data = [ + 'event' => $this->serializeEvent($event), + 'user' => JsonSerializer::serializeUser($user), + 'time' => time(), + ]; + if ($this->filterMatch($webhookListener->getEventFilter(), $data)) { + $this->jobList->add( + WebhookCall::class, + [ + $data, + $webhookListener->getId(), + ] + ); + } } } @@ -61,4 +80,11 @@ class WebhooksEventListener implements IEventListener { return $data; } } + + private function filterMatch(array $filter, array $data): bool { + if ($filter === []) { + return true; + } + return PHPMongoQuery::executeQuery($filter, $data); + } } diff --git a/apps/webhooks/lib/Migration/Version1000Date20240527153425.php b/apps/webhooks/lib/Migration/Version1000Date20240527153425.php index 18f8b545ddf..b6c345a22e2 100755 --- a/apps/webhooks/lib/Migration/Version1000Date20240527153425.php +++ b/apps/webhooks/lib/Migration/Version1000Date20240527153425.php @@ -46,6 +46,9 @@ class Version1000Date20240527153425 extends SimpleMigrationStep { $table->addColumn('event', Types::TEXT, [ 'notnull' => true, ]); + $table->addColumn('event_filter', Types::TEXT, [ + 'notnull' => false, + ]); $table->addColumn('headers', Types::TEXT, [ 'notnull' => false, ]); diff --git a/apps/webhooks/lib/Service/PHPMongoQuery.php b/apps/webhooks/lib/Service/PHPMongoQuery.php new file mode 100644 index 00000000000..65ba5775763 --- /dev/null +++ b/apps/webhooks/lib/Service/PHPMongoQuery.php @@ -0,0 +1,340 @@ +<?php + +/** + * SPDX-FileCopyrightText: 2013 Akkroo Solutions Ltd + * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace OCA\Webhooks\Service; + +use Exception; + +/** + * PHPMongoQuery implements MongoDB queries in PHP, allowing developers to query + * a 'document' (an array containing data) against a Mongo query object, + * returning a boolean value for pass or fail + */ +abstract class PHPMongoQuery { + /** + * Execute a mongo query on a set of documents and return the documents that pass the query + * + * @param array $query A boolean value or an array defining a query + * @param array $documents The document to query + * @param array $options Any options: + * 'debug' - boolean - debug mode, verbose logging + * 'logger' - \Psr\LoggerInterface - A logger instance that implements {@link https://github.com/php-fig/fig-standards/blob/master/accepted/PSR-3-logger-interface.md#3-psrlogloggerinterface PSR-3} + * 'unknownOperatorCallback' - a callback to be called if an operator can't be found. The function definition is function($operator, $operatorValue, $field, $document). return true or false. + * @throws Exception + */ + public static function find(array $query, array $documents, array $options = []): array { + if(empty($documents) || empty($query)) { + return []; + } + $ret = []; + $options['_shouldLog'] = !empty($options['logger']) && $options['logger'] instanceof \Psr\Log\LoggerInterface; + $options['_debug'] = !empty($options['debug']); + foreach ($documents as $doc) { + if(static::_executeQuery($query, $doc, $options)) { + $ret[] = $doc; + } + } + return $ret; + } + + /** + * Execute a Mongo query on a document + * + * @param mixed $query A boolean value or an array defining a query + * @param array $document The document to query + * @param array $options Any options: + * 'debug' - boolean - debug mode, verbose logging + * 'logger' - \Psr\LoggerInterface - A logger instance that implements {@link https://github.com/php-fig/fig-standards/blob/master/accepted/PSR-3-logger-interface.md#3-psrlogloggerinterface PSR-3} + * 'unknownOperatorCallback' - a callback to be called if an operator can't be found. The function definition is function($operator, $operatorValue, $field, $document). return true or false. + * @throws Exception + */ + public static function executeQuery($query, array &$document, array $options = []): bool { + $options['_shouldLog'] = !empty($options['logger']) && $options['logger'] instanceof \Psr\Log\LoggerInterface; + $options['_debug'] = !empty($options['debug']); + if($options['_debug'] && $options['_shouldLog']) { + $options['logger']->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/webhooks/tests/Db/WebhookListenerMapperTest.php b/apps/webhooks/tests/Db/WebhookListenerMapperTest.php index eca6b750ae0..4481eb6661e 100644 --- a/apps/webhooks/tests/Db/WebhookListenerMapperTest.php +++ b/apps/webhooks/tests/Db/WebhookListenerMapperTest.php @@ -51,6 +51,7 @@ class WebhookListenerMapperTest extends TestCase { null, null, null, + null, ); $listener2 = $this->mapper->getById($listener1->getId()); |