feat: Add event filtering to webhooks

Signed-off-by: Côme Chilliet <come.chilliet@nextcloud.com>
This commit is contained in:
Côme Chilliet 2024-05-30 17:26:40 +02:00 committed by Côme Chilliet
parent 5dd9c2f8e8
commit 144bdd73f9
11 changed files with 390 additions and 8 deletions

View file

@ -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',
);

View file

@ -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',
);

View file

@ -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();

View file

@ -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);

View file

@ -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,

View file

@ -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');

View file

@ -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,

View file

@ -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);
}
}

View file

@ -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,
]);

View file

@ -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);
}
}

View file

@ -51,6 +51,7 @@ class WebhookListenerMapperTest extends TestCase {
null,
null,
null,
null,
);
$listener2 = $this->mapper->getById($listener1->getId());