diff options
author | Côme Chilliet <come.chilliet@nextcloud.com> | 2024-06-10 17:35:07 +0200 |
---|---|---|
committer | Côme Chilliet <91878298+come-nc@users.noreply.github.com> | 2024-06-11 14:10:29 +0200 |
commit | 19bc3ed1e3f52a9d9cd0a540e7e754a2fa16eb54 (patch) | |
tree | d067d7bb3ac0c31f149ce69b5c11b1266617878c /apps/webhook_listeners/lib | |
parent | 9449f6438d1ef3127f57601ebdd9d5d24fa6d16e (diff) | |
download | nextcloud-server-19bc3ed1e3f52a9d9cd0a540e7e754a2fa16eb54.tar.gz nextcloud-server-19bc3ed1e3f52a9d9cd0a540e7e754a2fa16eb54.zip |
chore(webhooks): Rename webhooks application to webhook_listeners
There is already a webhooks application in the appstore
Signed-off-by: Côme Chilliet <come.chilliet@nextcloud.com>
Diffstat (limited to 'apps/webhook_listeners/lib')
12 files changed, 1272 insertions, 0 deletions
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 @@ +<?php + +declare(strict_types=1); + +/** + * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace OCA\WebhookListeners\AppInfo; + +use OCA\WebhookListeners\Db\WebhookListenerMapper; +use OCA\WebhookListeners\Listener\WebhooksEventListener; +use OCP\AppFramework\App; +use OCP\AppFramework\Bootstrap\IBootContext; +use OCP\AppFramework\Bootstrap\IBootstrap; +use OCP\AppFramework\Bootstrap\IRegistrationContext; +use OCP\EventDispatcher\IEventDispatcher; +use Psr\Container\ContainerInterface; +use Psr\Log\LoggerInterface; + +class Application extends App implements IBootstrap { + public const APP_ID = 'webhook_listeners'; + + public function __construct() { + parent::__construct(self::APP_ID); + } + + public function register(IRegistrationContext $context): void { + } + + public function boot(IBootContext $context): void { + $context->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 @@ +<?php + +declare(strict_types=1); + +/** + * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace OCA\WebhookListeners\BackgroundJobs; + +use OCA\WebhookListeners\Db\AuthMethod; +use OCA\WebhookListeners\Db\WebhookListenerMapper; +use OCP\AppFramework\Utility\ITimeFactory; +use OCP\BackgroundJob\QueuedJob; +use OCP\Http\Client\IClientService; +use OCP\ICertificateManager; +use Psr\Log\LoggerInterface; + +class WebhookCall extends QueuedJob { + public function __construct( + private IClientService $clientService, + private ICertificateManager $certificateManager, + private WebhookListenerMapper $mapper, + private LoggerInterface $logger, + ITimeFactory $timeFactory, + ) { + parent::__construct($timeFactory); + } + + protected function run($argument): void { + [$data, $webhookId] = $argument; + $webhookListener = $this->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 @@ +<?php + +declare(strict_types=1); + +/** + * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace OCA\WebhookListeners\Command; + +use OC\Core\Command\Base; +use OCA\WebhookListeners\Db\WebhookListener; +use OCA\WebhookListeners\Db\WebhookListenerMapper; +use Symfony\Component\Console\Input\InputInterface; +use Symfony\Component\Console\Output\OutputInterface; + +class ListWebhooks extends Base { + public function __construct( + private WebhookListenerMapper $mapper, + ) { + parent::__construct(); + } + + protected function configure(): void { + parent::configure(); + $this + ->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 @@ +<?php + +declare(strict_types=1); + +/** + * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace OCA\WebhookListeners\Controller; + +use OCA\WebhookListeners\Db\AuthMethod; +use OCA\WebhookListeners\Db\WebhookListenerMapper; +use OCA\WebhookListeners\ResponseDefinitions; +use OCP\AppFramework\Http\Attribute\ApiRoute; +use OCP\AppFramework\Http\Attribute\AuthorizedAdminSetting; +use OCP\AppFramework\Http\Attribute\OpenAPI; +use OCP\AppFramework\Http\DataResponse; +use OCP\AppFramework\OCS\OCSBadRequestException; +use OCP\AppFramework\OCS\OCSException; +use OCP\AppFramework\OCS\OCSForbiddenException; +use OCP\AppFramework\OCSController; +use OCP\IRequest; +use OCP\ISession; +use Psr\Log\LoggerInterface; + +/** + * @psalm-import-type WebhooksListenerInfo from ResponseDefinitions + */ +#[OpenAPI(scope: OpenAPI::SCOPE_ADMINISTRATION)] +class WebhooksController extends OCSController { + public function __construct( + string $appName, + IRequest $request, + private LoggerInterface $logger, + private WebhookListenerMapper $mapper, + private ?string $userId, + private ISession $session, + ) { + parent::__construct($appName, $request); + } + + /** + * List registered webhooks + * + * @return DataResponse<Http::STATUS_OK, WebhooksListenerInfo[], array{}> + * + * 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<Http::STATUS_OK, WebhooksListenerInfo, array{}> + * + * 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<string,mixed> $eventFilter Mongo filter to apply to the serialized data to decide if firing + * @param ?array<string,string> $headers Array of headers to send + * @param "none"|"headers"|null $authMethod Authentication method to use + * @param ?array<string,mixed> $authData Array of data for authentication + * + * @return DataResponse<Http::STATUS_OK, WebhooksListenerInfo, array{}> + * + * 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<string,mixed> $eventFilter Mongo filter to apply to the serialized data to decide if firing + * @param ?array<string,string> $headers Array of headers to send + * @param "none"|"headers"|null $authMethod Authentication method to use + * @param ?array<string,mixed> $authData Array of data for authentication + * + * @return DataResponse<Http::STATUS_OK, WebhooksListenerInfo, array{}> + * + * 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<Http::STATUS_OK, bool, array{}> + * + * 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 @@ +<?php + +declare(strict_types=1); + +/** + * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace OCA\WebhookListeners\Db; + +enum AuthMethod: string { + case None = 'none'; + case Header = 'header'; +} diff --git a/apps/webhook_listeners/lib/Db/WebhookListener.php b/apps/webhook_listeners/lib/Db/WebhookListener.php new file mode 100644 index 00000000000..0d08082666f --- /dev/null +++ b/apps/webhook_listeners/lib/Db/WebhookListener.php @@ -0,0 +1,103 @@ +<?php + +declare(strict_types=1); + +/** + * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace OCA\WebhookListeners\Db; + +use OCP\AppFramework\Db\Entity; +use OCP\Security\ICrypto; + +/** + * @method void setUserId(string $userId) + * @method string getUserId() + * @method ?array getHeaders() + */ +class WebhookListener extends Entity implements \JsonSerializable { + /** @var ?string id of the app_api application who added the webhook listener */ + protected $appId; + + /** @var string id of the user who added the webhook listener */ + protected $userId; + + /** @var string */ + protected $httpMethod; + + /** @var string */ + protected $uri; + + /** @var string */ + protected $event; + + /** @var array */ + protected $eventFilter; + + /** @var ?array */ + protected $headers; + + /** @var ?string */ + protected $authMethod; + + /** @var ?string */ + protected $authData; + + private ICrypto $crypto; + + public function __construct( + ?ICrypto $crypto = null, + ) { + if ($crypto === null) { + $crypto = \OCP\Server::get(ICrypto::class); + } + $this->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 @@ +<?php + +declare(strict_types=1); + +/** + * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace OCA\WebhookListeners\Db; + +use OCP\AppFramework\Db\DoesNotExistException; +use OCP\AppFramework\Db\MultipleObjectsReturnedException; +use OCP\AppFramework\Db\QBMapper; +use OCP\DB\Exception; +use OCP\DB\QueryBuilder\IQueryBuilder; +use OCP\EventDispatcher\IWebhookCompatibleEvent; +use OCP\ICache; +use OCP\ICacheFactory; +use OCP\IDBConnection; + +/** + * @template-extends QBMapper<WebhookListener> + */ +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<string> + */ + 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<string> + */ + 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 @@ +<?php + +declare(strict_types=1); + +/** + * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace OCA\WebhookListeners\Listener; + +use OCA\WebhookListeners\BackgroundJobs\WebhookCall; +use OCA\WebhookListeners\Db\WebhookListenerMapper; +use OCA\WebhookListeners\Service\PHPMongoQuery; +use OCP\BackgroundJob\IJobList; +use OCP\EventDispatcher\Event; +use OCP\EventDispatcher\IEventListener; +use OCP\EventDispatcher\IWebhookCompatibleEvent; +use OCP\EventDispatcher\JsonSerializer; +use OCP\IUserSession; +use Psr\Log\LoggerInterface; + +/** + * The class to handle the share events + * @template-implements IEventListener<IWebhookCompatibleEvent> + */ +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 @@ +<?php + +declare(strict_types=1); + +/** + * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace OCA\WebhookListeners\Migration; + +use Closure; +use OCA\WebhookListeners\Db\WebhookListenerMapper; +use OCP\DB\ISchemaWrapper; +use OCP\DB\Types; +use OCP\Migration\IOutput; +use OCP\Migration\SimpleMigrationStep; + +class Version1000Date20240527153425 extends SimpleMigrationStep { + /** + * @param Closure(): ISchemaWrapper $schemaClosure + */ + public function changeSchema(IOutput $output, Closure $schemaClosure, array $options): ?ISchemaWrapper { + /** @var ISchemaWrapper $schema */ + $schema = $schemaClosure(); + + if (!$schema->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 @@ +<?php + +declare(strict_types=1); + +/** + * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace OCA\WebhookListeners; + +/** + * @psalm-type WebhooksListenerInfo = array{ + * id: string, + * userId: string, + * httpMethod: string, + * uri: string, + * event?: string, + * eventFilter?: array<string,mixed>, + * headers?: array<string,string>, + * authMethod: string, + * authData?: array<string,mixed>, + * } + */ +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 @@ +<?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\WebhookListeners\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/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 @@ +<?php + +declare(strict_types=1); + +/** + * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace OCA\WebhookListeners\Settings; + +use OCP\AppFramework\Http\TemplateResponse; +use OCP\IL10N; +use OCP\Settings\IDelegatedSettings; + +/** + * Empty settings class, used only for admin delegation for now as there is no UI + */ +class Admin implements IDelegatedSettings { + + public function __construct( + protected string $appName, + private IL10N $l10n, + ) { + } + + /** + * Empty template response + */ + public function getForm(): TemplateResponse { + return new class($this->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 []; + } +} |