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:
127
app/Support/Webhooks/Webhooks.php
Normal file
127
app/Support/Webhooks/Webhooks.php
Normal 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,
|
||||
];
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user