Compare commits

...

3 Commits

Author SHA1 Message Date
Hugo Sales 7027633ed5
[PLUGIN][WebHooks] Make request method configurable
This way, PUT can be used, which doesn't seem to be the standard, so isn't the default, but which makes sense to me, as it doesn't have a response, which we don't care about anyway
2022-03-24 00:51:00 +00:00
Hugo Sales 48b42c539c
[PLUGINS][WebHooks] Use ActivityPub to serialize the activity, so the object is included 2022-03-24 00:51:00 +00:00
Hugo Sales d41a67a9f9
[PLUGIN][WebHooks] Add WebHooks plugin, which allows for sending a POST request to an external resource when a notification or a follow occurs 2022-03-24 00:51:00 +00:00
7 changed files with 331 additions and 0 deletions

View File

@ -0,0 +1,107 @@
<?php
declare(strict_types = 1);
// {{{ License
// This file is part of GNU social - https://www.gnu.org/software/social
//
// GNU social is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// GNU social is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with GNU social. If not, see <http://www.gnu.org/licenses/>.
// }}}
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'));
}
}

View File

@ -0,0 +1,112 @@
<?php
declare(strict_types = 1);
// {{{ License
// This file is part of GNU social - https://www.gnu.org/software/social
//
// GNU social is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// GNU social is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with GNU social. If not, see <http://www.gnu.org/licenses/>.
// }}}
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'],
],
];
}
}

View File

@ -0,0 +1,102 @@
<?php
declare(strict_types = 1);
// {{{ License
// This file is part of GNU social - https://www.gnu.org/software/social
//
// GNU social is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// GNU social is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with GNU social. If not, see <http://www.gnu.org/licenses/>.
// }}}
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 Exception;
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<Actor $sender, Activity $activity, array $effective_targets, ?string $reason> $args
*/
public function onQueueWebhook(string $type, string $target, Actor $actor, array $args)
{
switch ($type) {
case 'notifications':
[$sender, $activity, $targets, $reason] = $args;
$data = [
'type' => 'notification',
'activity' => '%activity%',
'actor' => ['id' => $sender->getId(), 'nickname' => $sender->getNickname()],
'targets' => F\map(array_values($targets), fn (Actor $actor) => ['id' => $actor->getId(), 'nickname' => $actor->getNickname()]),
'reason' => $reason,
];
// toJson(Activity) is already JSON (hopefully that's obvious :') ), so replace it after converting the rest to JSON
$json = str_replace('"activity":"%activity%"', '"activity":' . \Plugin\ActivityPub\Util\Model\Activity::toJson($activity), json_encode($data));
Log::debug("WebHooks: POST {$target} on behalf of actor {$actor->getId()} ({$actor->getNickname()})", [$data, ['json' => $json]]);
try {
$method = Common::config('plugin_webhooks', 'method');
HTTPClient::{$method}($target, ['body' => $json, 'headers' => ['content-type' => 'application/json', 'user-agent' => 'GNU social']]);
} catch (Exception $e) {
Log::debug("WebHooks: Failed POST {$target} on behalf of actor {$actor->getId()} ({$actor->getNickname()})", [$e]);
}
return Event::stop;
default:
throw new ServerException("Webhook notification handler for event {$type} not implemented");
}
}
}

View File

@ -0,0 +1,3 @@
<div>
{{ form(form_view) }}
</div>

View File

@ -135,6 +135,9 @@ parameters:
max_px: 64000
max_file_size: 4000000
plugin_webhooks:
method: post
theme:
server:
ssl:

View File

@ -33,6 +33,10 @@
<li>
{{ macros.settings_details_container('Notifications', 'Enable/disable notifications (Email, XMPP, Replies...)', 'notifications', tabbed_forms_notify, _context) }}
</li>
<li>
{% set other_tabs = handle_event('PopulateSettingsTabs', app.request, 'others') %}
{{ macros.settings_details_container('Others', 'Other settings (plugins, etc.)', 'settings-other-details', other_tabs, _context) }}
</li>
</ul>
</nav>
{% endblock body %}