Send a webhook for every change a person makes to a record
Some checks failed
Tests / lint (push) Has been cancelled
Tests / test (push) Has been cancelled
Tests / test-postgres (push) Has been cancelled
Tests / mcp (push) Has been cancelled

Events are {resource}.created, .updated, .deleted and .restored for content,
the inbox, redirects, repositories and media, taken from the audit log so the
two agree on what happened. Subscriptions take wildcards. Users, tokens,
webhooks and settings are never sent, nor what the application does by
itself; the inbox's own words only with personal data enabled.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-01 14:32:54 +02:00
parent d4cf4a66b7
commit 9390db18c2
15 changed files with 499 additions and 22 deletions

View File

@@ -15,7 +15,7 @@ use BackedEnum;
use Filament\Actions\Action;
use Filament\Actions\DeleteAction;
use Filament\Actions\EditAction;
use Filament\Forms\Components\CheckboxList;
use Filament\Forms\Components\Select;
use Filament\Forms\Components\TextInput;
use Filament\Forms\Components\Toggle;
use Filament\Notifications\Notification;
@@ -96,16 +96,13 @@ class WebhookResource extends Resource
->maxLength(2048)
->rule(new PublicHttpsUrl),
CheckboxList::make('events')
Select::make('events')
->label(__('panel.webhooks.fields.events'))
->options(fn () => collect(['*', ...Webhook::EVENTS])
->mapWithKeys(fn (string $event) => [$event => self::eventLabel($event)])
->all())
->descriptions(fn () => collect(['*', ...Webhook::EVENTS])
->mapWithKeys(fn (string $event) => [$event => $event])
->all())
->helperText(__('panel.webhooks.events_hint'))
->multiple()
->searchable()
->options(fn () => self::eventOptions())
->required()
->columns(2)
->columnSpanFull(),
Toggle::make('include_personal_data')
@@ -162,6 +159,43 @@ class WebhookResource extends Resource
return __("panel.webhooks.events.{$key}");
}
/**
* Grouped for the select: everything, the events with a meaning of their
* own, any record by action, then each kind of record.
*
* @return array<string, array<string, string>>
*/
public static function eventOptions(): array
{
$groups = [
__('panel.webhooks.groups.everything') => [
'*' => __('panel.webhooks.events.*'),
...collect(Webhook::ACTIONS)->mapWithKeys(fn (string $action) => [
"*.{$action}" => __('panel.webhooks.any_record', ['action' => self::actionLabel($action)]),
])->all(),
],
__('panel.webhooks.groups.notices') => collect(Webhook::EVENTS)
->mapWithKeys(fn (string $event) => [$event => self::eventLabel($event)])
->all(),
];
foreach (Webhook::RESOURCES as $resource) {
$groups[__("panel.webhooks.resources.{$resource}")] = [
"{$resource}.*" => __('panel.webhooks.all_of', ['resource' => __("panel.webhooks.resources.{$resource}")]),
...collect(Webhook::ACTIONS)->mapWithKeys(fn (string $action) => [
"{$resource}.{$action}" => __("panel.webhooks.resources.{$resource}").' · '.self::actionLabel($action),
])->all(),
];
}
return $groups;
}
protected static function actionLabel(string $action): string
{
return __("panel.webhooks.actions_on.{$action}");
}
public static function pingAction(): Action
{
return Action::make('ping')

View File

@@ -6,8 +6,8 @@ namespace App\Http\Requests\Api\V1;
use App\Models\Webhook;
use App\Rules\PublicHttpsUrl;
use Closure;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
class WebhookRequest extends FormRequest
{
@@ -36,7 +36,15 @@ class WebhookRequest extends FormRequest
new PublicHttpsUrl,
],
'events' => [$sometimes, 'array', 'min:1'],
'events.*' => ['string', 'distinct', Rule::in(['*', ...Webhook::EVENTS])],
'events.*' => [
'string',
'distinct',
function (string $attribute, mixed $value, Closure $fail): void {
if (! is_string($value) || ! Webhook::isSubscribable($value)) {
$fail(__('rules.webhooks.event', ['event' => (string) (is_scalar($value) ? $value : '')]));
}
},
],
'secret' => [$creating ? 'sometimes' : 'prohibited', 'nullable', 'string', 'min:16', 'max:255'],
'rotate_secret' => [$creating ? 'prohibited' : 'sometimes', 'boolean'],
'include_personal_data' => ['sometimes', 'boolean'],

View File

@@ -10,6 +10,7 @@ use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Support\Str;
/**
* Somewhere to tell when something happens here.
@@ -21,7 +22,7 @@ class Webhook extends Model
use HasFactory;
/**
* What can be subscribed to. `*` is all of them.
* Events with a meaning of their own, whatever record they are about.
*/
public const EVENTS = [
'application.received',
@@ -31,6 +32,53 @@ class Webhook extends Model
'review.returned',
];
/**
* Kinds of record whose every change can be subscribed to, as
* `{resource}.{action}`. Not users, tokens, webhooks or settings: those
* are the application's security, not its content, and do not leave it.
*/
public const RESOURCES = [
'pages', 'categories', 'tags', 'projects', 'members', 'services',
'clients', 'testimonials', 'talks', 'technologies', 'calls',
'redirects', 'repositories', 'media', 'applications',
];
public const ACTIONS = ['created', 'updated', 'deleted', 'restored'];
/**
* Every event that is ever sent.
*
* @return array<int, string>
*/
public static function events(): array
{
$changes = [];
foreach (self::RESOURCES as $resource) {
foreach (self::ACTIONS as $action) {
$changes[] = "{$resource}.{$action}";
}
}
return [...self::EVENTS, ...$changes];
}
/**
* What a webhook may subscribe to: an event, `*`, everything about one
* kind of record (`projects.*`) or one action on any (`*.deleted`).
*/
public static function isSubscribable(string $pattern): bool
{
if ($pattern === '*' || in_array($pattern, self::events(), true)) {
return true;
}
[$resource, $action] = array_pad(explode('.', $pattern, 2), 2, null);
return ($action === '*' && in_array($resource, self::RESOURCES, true))
|| ($resource === '*' && in_array($action, self::ACTIONS, true));
}
/**
* Switched off after this many failed deliveries in a row, so a dead
* endpoint does not keep a queue busy forever. Switching it back on is a
@@ -64,9 +112,13 @@ class Webhook extends Model
public function listensTo(string $event): bool
{
$events = $this->events ?? [];
foreach ($this->events ?? [] as $pattern) {
if ($pattern === $event || Str::is($pattern, $event)) {
return true;
}
}
return in_array('*', $events, true) || in_array($event, $events, true);
return false;
}
/**

View File

@@ -6,6 +6,7 @@ namespace App\Providers;
use App\Auth\PireonTokenGuard;
use App\Models\Application;
use App\Models\AuditLog;
use App\Models\ReviewComment;
use App\Models\Revision;
use App\Models\User;
@@ -64,6 +65,7 @@ class AppServiceProvider extends ServiceProvider
Application::created(fn (Application $application) => Webhooks::applicationReceived($application));
Revision::created(fn (Revision $revision) => Webhooks::revisionRecorded($revision));
ReviewComment::created(fn (ReviewComment $comment) => Webhooks::reviewRecorded($comment));
AuditLog::created(fn (AuditLog $entry) => Webhooks::auditRecorded($entry));
// The audit log: every change a person makes to an audited record,
// role changes, and signing in to the panel.

View File

@@ -76,19 +76,19 @@ final class Auditor
/**
* @param array<string, mixed>|null $changes
*/
public static function record(string $action, ?Model $subject = null, ?array $changes = null, ?User $actor = null): void
public static function record(string $action, ?Model $subject = null, ?array $changes = null, ?User $actor = null): ?AuditLog
{
$actor ??= Auth::user();
if (! $actor instanceof User) {
return;
return null;
}
$request = app()->bound('request') ? request() : null;
$route = $request?->route();
$token = $actor->currentAccessToken();
AuditLog::query()->create([
return AuditLog::query()->create([
'user_id' => $actor->id,
'user_name' => $actor->name,
'via' => self::via($route?->getName(), $token !== null),

View File

@@ -6,11 +6,14 @@ namespace App\Support\Webhooks;
use App\Jobs\DeliverWebhook;
use App\Models\Application;
use App\Models\AuditLog;
use App\Models\Member;
use App\Models\ReviewComment;
use App\Models\Revision;
use App\Models\Webhook;
use App\Models\WebhookDelivery;
use App\Support\AdminContent;
use App\Support\Audit\Auditor;
use App\Support\ContentTypes;
use App\Support\SiteUrls;
use Illuminate\Database\Eloquent\Model;
@@ -93,6 +96,90 @@ final class Webhooks
]);
}
/**
* `projects.created`, `pages.updated`, `media.deleted`…: whatever a person
* did to a record, from the audit log — which already leaves out what the
* application does by itself, and already masks secrets, so the two
* cannot disagree about what happened.
*/
public static function auditRecorded(AuditLog $entry): void
{
[$resource, $action] = array_pad(explode('.', $entry->action, 2), 2, null);
if (! in_array($resource, Webhook::RESOURCES, true) || ! in_array($action, Webhook::ACTIONS, true)) {
return;
}
$record = self::find($resource, $entry->subject_id);
$changes = $entry->changes;
$data = [
'type' => $resource,
'id' => is_numeric($entry->subject_id) ? (int) $entry->subject_id : $entry->subject_id,
'title' => $entry->subject_label,
'action' => $action,
'by' => ['id' => $entry->user_id, 'name' => $entry->user_name],
'via' => $entry->via,
'changes' => $changes,
'url' => $action === 'deleted' || $record === null ? null : self::publicUrl($record),
];
$personal = [];
// A submission is somebody else's words: what is sent about it by
// default is how it is being handled, nothing they wrote.
if ($resource === 'applications') {
$data['title'] = $record instanceof Application ? $record->subject() : null;
$data['changes'] = $changes === null ? null : array_intersect_key($changes, array_flip(['status', 'handled_by', 'handled_at']));
$personal = [
'name' => $record?->name,
'email' => $record?->email,
'changes' => $changes,
];
}
self::dispatch($entry->action, $data, $personal);
}
protected static function find(string $resource, ?string $id): ?Model
{
$model = array_search($resource, Auditor::AUDITED, true);
if ($model === false || $id === null) {
return null;
}
$query = $model::query();
if (method_exists($model, 'bootSoftDeletes')) {
$query->withTrashed();
}
return $query->find($id);
}
/**
* Where it can be seen on the site, when it can be.
*/
protected static function publicUrl(Model $record): ?string
{
if (method_exists($record, 'isPublishedNow') && ! $record->isPublishedNow()) {
return null;
}
if ($record instanceof Member) {
return $record->is_public
? rtrim((string) config('site.url'), '/').SiteUrls::path('member', (string) config('locales.default'), $record->slug)
: null;
}
$siteType = AdminContent::PREVIEWABLE[AdminContent::nameOf($record) ?? ''] ?? null;
return $siteType
? (SiteUrls::absoluteAlternates($record, $siteType)[(string) config('locales.default')] ?? null)
: null;
}
/**
* Publishing and unpublishing are already told apart by the history, for
* every kind of record and from wherever it happened; this listens there.