From d41a67a9f91a09d49c907d1abecae12828f2bc33 Mon Sep 17 00:00:00 2001 From: Hugo Sales Date: Wed, 23 Mar 2022 22:57:44 +0000 Subject: [PATCH] [PLUGIN][WebHooks] Add WebHooks plugin, which allows for sending a POST request to an external resource when a notification or a follow occurs --- plugins/WebHooks/Controller/WebHooks.php | 107 +++++++++++++++++ plugins/WebHooks/Entity/WebHook.php | 112 ++++++++++++++++++ plugins/WebHooks/WebHooks.php | 94 +++++++++++++++ .../templates/webhooks/settings.html.twig | 3 + .../WebHooks/templates/webhooks/settings.twig | 0 templates/settings/base.html.twig | 4 + 6 files changed, 320 insertions(+) create mode 100644 plugins/WebHooks/Controller/WebHooks.php create mode 100644 plugins/WebHooks/Entity/WebHook.php create mode 100644 plugins/WebHooks/WebHooks.php create mode 100644 plugins/WebHooks/templates/webhooks/settings.html.twig create mode 100644 plugins/WebHooks/templates/webhooks/settings.twig diff --git a/plugins/WebHooks/Controller/WebHooks.php b/plugins/WebHooks/Controller/WebHooks.php new file mode 100644 index 0000000000..abcda1dad4 --- /dev/null +++ b/plugins/WebHooks/Controller/WebHooks.php @@ -0,0 +1,107 @@ +. + +// }}} + +namespace Plugin\WebHooks\Controller; + +use App\Core\Controller; +use App\Core\DB\DB; +use App\Core\Form; +use function App\Core\I18n\_m; +use App\Core\Router\Router; +use App\Util\Common; +use App\Util\Exception\ClientException; +use Functional as F; +use Plugin\WebHooks as P; +use Plugin\WebHooks\Entity as E; +use Symfony\Component\Form\Extension\Core\Type\SubmitType; +use Symfony\Component\Form\Extension\Core\Type\TextType; +use Symfony\Component\HttpFoundation\Request; + +class WebHooks extends Controller +{ + public static function setup() + { + $user = Common::ensureLoggedIn(); + $hooks = F\reindex(DB::findBy(E\WebHook::class, ['actor_id' => $user->getId()]), fn (E\WebHook $wh) => $wh->getEvent()); + $form = Form::create([ + ['notifications', TextType::class, ['label' => _m('Trigger this hook when I recieve a notification'), 'data' => ($hooks['notifications'] ?? null)?->getTarget()]], + ['follow', TextType::class, ['label' => _m('Trigger this hook when someone starts following me'), 'data' => ($hooks['follow'] ?? null)?->getTarget()]], + ['save_webhooks', SubmitType::class, ['label' => _m('Submit')]], + ], form_options: ['action' => Router::url(P\WebHooks::controller_route)]); + + return [ + '_template' => 'webhooks/settings.html.twig', + 'form_view' => $form->createView(), + 'form' => $form, + 'hooks' => $hooks, + ]; + } + + public function onPost(Request $request) + { + $get_response = self::setup(); + $form = $get_response['form']; + $hooks = $get_response['hooks']; + + $user = Common::user(); + if (\is_null($user)) { + return Form::forceRedirect($form, $request); + } + + $form->handleRequest($request); + if ($form->isSubmitted() && $form->isValid()) { + $data = $form->getData(); + unset($data['_next']); + $error = false; + foreach ($data as $key => $value) { + if ($value !== '') { + $parts = parse_url($value); + if ($parts === false || ($parts['scheme'] ?? null) !== 'https' || ($parts['host'] ?? null) === Common::config('site', 'server')) { + $error = true; + break; + } else { + if (!isset($hooks[$key])) { + DB::persist(E\WebHook::create([ + 'actor_id' => $user->getId(), + 'event' => $key, + 'target' => $value, + ])); + } else { + $hooks[$key]->setTarget($value); + } + } + } else { + $error = true; + } + } + + if (!$error) { + DB::flush(); + return Form::forceRedirect($form, $request); + } else { + throw new ClientException(_m('Invalid form submission')); + } + } + throw new ClientException(_m('Don\'t GET this page')); + } +} diff --git a/plugins/WebHooks/Entity/WebHook.php b/plugins/WebHooks/Entity/WebHook.php new file mode 100644 index 0000000000..61d211f38f --- /dev/null +++ b/plugins/WebHooks/Entity/WebHook.php @@ -0,0 +1,112 @@ +. +// }}} + +namespace Plugin\WebHooks\Entity; + +use App\Core\Entity; +use DateTimeInterface; + +class WebHook extends Entity +{ + // {{{ Autocode + // @codeCoverageIgnoreStart + private int $actor_id; + private string $event; + private string $target; + private DateTimeInterface $created; + private DateTimeInterface $modified; + + public function setActorId(int $actor_id): self + { + $this->actor_id = $actor_id; + return $this; + } + + public function getActorId(): int + { + return $this->actor_id; + } + + public function setEvent(string $event): self + { + $this->event = mb_substr($event, 0, 32); + return $this; + } + + public function getEvent(): string + { + return $this->event; + } + + public function setTarget(string $target): self + { + $this->target = $target; + return $this; + } + + public function getTarget(): string + { + return $this->target; + } + + public function setCreated(DateTimeInterface $created): self + { + $this->created = $created; + return $this; + } + + public function getCreated(): DateTimeInterface + { + return $this->created; + } + + public function setModified(DateTimeInterface $modified): self + { + $this->modified = $modified; + return $this; + } + + public function getModified(): DateTimeInterface + { + return $this->modified; + } + + // @codeCoverageIgnoreEnd + // }}} Autocode + + public static function schemaDef(): array + { + return [ + 'name' => 'webhook', + 'fields' => [ + 'actor_id' => ['type' => 'int', 'not null' => true, 'actor who made this hook'], + 'event' => ['type' => 'varchar', 'length' => 32, 'not null' => true, 'description' => 'name of the event this is a hook for'], + 'target' => ['type' => 'text', 'not null' => true, 'description' => 'the target URL to POST to'], + 'created' => ['type' => 'datetime', 'not null' => true, 'description' => 'date this record was created'], + 'modified' => ['type' => 'timestamp', 'not null' => true, 'description' => 'date this record was modified'], + ], + 'primary key' => ['actor_id', 'event'], + 'indexes' => [ + 'webhook_actor_id_idx' => ['actor_id'], + ], + ]; + } +} diff --git a/plugins/WebHooks/WebHooks.php b/plugins/WebHooks/WebHooks.php new file mode 100644 index 0000000000..36c62d1ae8 --- /dev/null +++ b/plugins/WebHooks/WebHooks.php @@ -0,0 +1,94 @@ +. +// }}} + +namespace Plugin\WebHooks; + +use App\Core\DB\DB; +use App\Core\Event; +use App\Core\HTTPClient; +use App\Core\Log; +use App\Core\Modules\Plugin; +use App\Core\Queue\Queue; +use App\Core\Router\RouteLoader; +use App\Entity\Activity; +use App\Entity\Actor; +use App\Util\Exception\ServerException; +use Functional as F; +use Plugin\WebHooks\Controller as C; +use Plugin\WebHooks\Entity as E; +use Symfony\Component\HttpFoundation\Request; + +class WebHooks extends Plugin +{ + public const controller_route = 'webhook'; + + public function onAddRoute(RouteLoader $r) + { + $r->connect(self::controller_route, '/webhook-settings', C\WebHooks::class); + } + + public function onPopulateSettingsTabs(Request $request, string $section, array &$tabs): bool + { + if ($section === 'others') { + $tabs[] = [ + 'title' => 'Web Hooks', + 'desc' => 'Add hooks that run when an internal event occurs, allowing your third party resource to react', + 'id' => 'settings-webhooks', + 'controller' => C\WebHooks::setup(), + ]; + } + return Event::next; + } + + public function onNewNotificationEnd(Actor $sender, Activity $activity, array $effective_targets, ?string $reason) + { + foreach ($effective_targets as $actor) { + $target = DB::findOneBy(E\WebHook::class, ['actor_id' => $actor->getId(), 'event' => 'notifications'], return_null: true)?->getTarget(); + if (!\is_null($target)) { + Queue::enqueue(['notifications', $target, $actor, [$sender, $activity, $effective_targets, $reason]], queue: 'webhook'); + } + } + return Event::next; + } + + /** + * @param array $args + */ + public function onQueueWebhook(string $type, string $target, Actor $actor, array $args) + { + switch ($type) { + case 'notifications': + [$sender, $activity, $targets, $reason] = $args; + $data = [ + 'type' => 'notification', + 'actor' => ['id' => $sender->getId(), 'nickname' => $sender->getNickname()], + 'activity' => ['id' => $activity->getId(), 'object_type' => $activity->getObjectType(), 'object_id' => $activity->getObjectId(), 'verb' => $activity->getVerb()], + 'targets' => F\map(array_values($targets), fn (Actor $actor) => ['id' => $actor->getId(), 'nickname' => $actor->getNickname()]), + 'reason' => $reason, + ]; + Log::debug("WebHook: POST {$target} on behalf of actor {$actor->getId()} ({$actor->getNickname()})", [$data, ['json' => json_encode($data)]]); + HTTPClient::post($target, ['json' => json_encode($data)]); + return Event::stop; + default: + throw new ServerException("Webhook notification handler for event {$type} not implemented"); + } + } +} diff --git a/plugins/WebHooks/templates/webhooks/settings.html.twig b/plugins/WebHooks/templates/webhooks/settings.html.twig new file mode 100644 index 0000000000..0d5ba54e8a --- /dev/null +++ b/plugins/WebHooks/templates/webhooks/settings.html.twig @@ -0,0 +1,3 @@ +
+ {{ form(form_view) }} +
diff --git a/plugins/WebHooks/templates/webhooks/settings.twig b/plugins/WebHooks/templates/webhooks/settings.twig new file mode 100644 index 0000000000..e69de29bb2 diff --git a/templates/settings/base.html.twig b/templates/settings/base.html.twig index ebc4e37e98..c2250cf76c 100644 --- a/templates/settings/base.html.twig +++ b/templates/settings/base.html.twig @@ -33,6 +33,10 @@
  • {{ macros.settings_details_container('Notifications', 'Enable/disable notifications (Email, XMPP, Replies...)', 'notifications', tabbed_forms_notify, _context) }}
  • +
  • + {% set other_tabs = handle_event('PopulateSettingsTabs', app.request, 'others') %} + {{ macros.settings_details_container('Others', 'Other settings (plugins, etc.)', 'settings-other-details', other_tabs, _context) }} +
  • {% endblock body %}