Send signed outgoing webhooks

Fed by the records themselves, delivered only to public HTTPS addresses
checked at every attempt, retried, and switched off after repeated failures.
A submitter's details travel only to webhooks that opted into them.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-01 09:42:39 +02:00
parent f93775e549
commit 9bdbf834f6
23 changed files with 1325 additions and 0 deletions

View File

@@ -73,6 +73,10 @@ final class Permissions
// the provider's rate limit, which is a shared resource.
'repositories' => ['view_any', 'view', 'create', 'update', 'delete', 'sync'],
// Where the site tells other systems what happened. A webhook sends
// data off this server, so it is an administrator's decision.
'webhooks' => ['view_any', 'view', 'create', 'update', 'delete'],
'users' => ['view_any', 'view', 'create', 'update', 'delete'],
'roles' => ['view_any', 'view', 'create', 'update', 'delete'],

View File

@@ -0,0 +1,52 @@
<?php
declare(strict_types=1);
namespace App\Support\Webhooks;
use App\Services\Media\HostResolver;
/**
* Whether a URL is somewhere this server may send a request to: HTTPS, and a
* host whose every address is public.
*
* The same rule the image downloader follows, for the same reason — the
* request leaves from inside the stack, and without it a webhook pointed at
* `http://postgres:5432` or the cloud's metadata service would be a way of
* reaching them.
*/
final class PublicEndpoint
{
public function __construct(protected HostResolver $resolver) {}
/**
* The address to connect to, or why not: `https_only`, `unresolvable` or
* `private`.
*
* @return array{0: ?string, 1: ?string} [address, reason]
*/
public function check(string $url): array
{
$parts = parse_url($url);
$host = $parts['host'] ?? null;
if (($parts['scheme'] ?? null) !== 'https' || blank($host)) {
return [null, 'https_only'];
}
$host = trim((string) $host, '[]');
$addresses = filter_var($host, FILTER_VALIDATE_IP) ? [$host] : $this->resolver->resolve($host);
if ($addresses === []) {
return [null, 'unresolvable'];
}
foreach ($addresses as $address) {
if (! filter_var($address, FILTER_VALIDATE_IP, FILTER_FLAG_GLOBAL_RANGE)) {
return [null, 'private'];
}
}
return [$addresses[0], null];
}
}

View File

@@ -0,0 +1,127 @@
<?php
declare(strict_types=1);
namespace App\Support\Webhooks;
use App\Jobs\DeliverWebhook;
use App\Models\Application;
use App\Models\ReviewComment;
use App\Models\Revision;
use App\Models\Webhook;
use App\Support\AdminContent;
use App\Support\ContentTypes;
use App\Support\SiteUrls;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\Relation;
use Illuminate\Support\Str;
/**
* Turning what happens here into deliveries for whoever listens.
*
* Fed by model events rather than by the controllers, so a page published from
* the panel, the API or a batch is announced the same way.
*/
final class Webhooks
{
/**
* @param array<string, mixed> $data
* @param array<string, mixed> $personal Sent only to webhooks that opted into personal data.
*/
public static function dispatch(string $event, array $data, array $personal = []): void
{
$webhooks = Webhook::query()->active()->get()->filter(fn (Webhook $w) => $w->listensTo($event));
foreach ($webhooks as $webhook) {
$delivery = $webhook->deliveries()->create([
'uuid' => (string) Str::uuid(),
'event' => $event,
'payload' => $webhook->include_personal_data ? [...$data, ...$personal] : $data,
]);
DeliverWebhook::dispatch($delivery)->afterCommit();
}
}
public static function applicationReceived(Application $application): void
{
self::dispatch('application.received', [
'id' => $application->id,
'kind' => $application->kind->value,
'call_id' => $application->call_id,
'subject' => $application->subject(),
'locale' => $application->locale,
'created_at' => $application->created_at?->toIso8601String(),
], [
'name' => $application->name,
'email' => $application->email,
'organisation' => $application->organisation,
'message' => $application->message,
]);
}
/**
* Publishing and unpublishing are already told apart by the history, for
* every kind of record and from wherever it happened; this listens there.
*/
public static function revisionRecorded(Revision $revision): void
{
$model = Relation::getMorphedModel($revision->revisionable_type) ?? $revision->revisionable_type;
$record = $model::query()->find($revision->revisionable_id);
if ($record === null) {
return;
}
$event = match (true) {
$revision->event === 'published' => 'content.published',
$revision->event === 'unpublished' => 'content.unpublished',
$revision->event === 'created' && method_exists($record, 'isPublishedNow') && $record->isPublishedNow() => 'content.published',
default => null,
};
if ($event !== null) {
self::dispatch($event, self::describe($record));
}
}
public static function reviewRecorded(ReviewComment $comment): void
{
$event = match ($comment->event->value) {
'requested' => 'review.requested',
'returned' => 'review.returned',
default => null,
};
$record = $comment->reviewable;
if ($event === null || $record === null) {
return;
}
self::dispatch($event, [
...self::describe($record),
'by' => $comment->user_name,
'note' => $comment->body,
]);
}
/**
* @return array<string, mixed>
*/
protected static function describe(Model $record): array
{
$type = AdminContent::nameOf($record);
$siteType = AdminContent::PREVIEWABLE[$type] ?? null;
$locale = (string) config('locales.default');
return [
'type' => $type,
'id' => $record->getKey(),
'title' => ContentTypes::title($record),
'url' => $siteType && method_exists($record, 'getTranslation')
? (SiteUrls::absoluteAlternates($record, $siteType)[$locale] ?? null)
: null,
];
}
}