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

@@ -1472,7 +1472,21 @@ POST /api/v1/webhook-deliveries/{id}/redeliver
```
Avisan a otro sistema de `application.received`, `content.published`,
`content.unpublished`, `review.requested` y `review.returned` (o `*`). Salen de
`content.unpublished`, `review.requested` y `review.returned`, y además de
**cualquier cambio que haga una persona en una ficha**: `{tipo}.created`,
`.updated`, `.deleted` y `.restored` para páginas, categorías, etiquetas,
proyectos, miembros, servicios, clientes, testimonios, charlas, tecnologías,
convocatorias, redirecciones, repositorios, medios y el buzón. Se puede
suscribir a uno, a `*`, a todo un tipo (`projects.*`) o a una acción en
cualquiera (`*.deleted`).
Esos eventos salen del [registro de auditoría](#registro-de-auditoría), así
que dicen lo mismo que él —qué ficha, quién, por dónde (panel, API, token) y,
al editar, qué campos cambiaron con su valor anterior y el nuevo— y dejan fuera
lo mismo: lo que hace la aplicación sola y los secretos. Del buzón solo se
cuenta cómo se está atendiendo, salvo con `include_personal_data`. Usuarios,
tokens, webhooks y ajustes no se envían nunca: son la seguridad de la
aplicación, no su contenido. Salen de
los propios registros —el historial ya distingue publicar de editar, venga del
panel, de la API o de un lote— y no de los controladores, así que no hay forma
de publicar algo sin que se avise.

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.

View File

@@ -148,6 +148,36 @@ return [
'webhooks' => [
'label' => 'Webhook',
'events_hint' => 'Pots triar-ne diversos. «Tot de…» inclou qualsevol canvi en aquest tipus de fitxa.',
'any_record' => 'Qualsevol fitxa · :action',
'all_of' => 'Tot de :resource',
'groups' => [
'everything' => 'General',
'notices' => 'Avisos',
],
'actions_on' => [
'created' => 'Creada',
'updated' => 'Editada',
'deleted' => 'Esborrada',
'restored' => 'Recuperada',
],
'resources' => [
'pages' => 'Pàgines',
'categories' => 'Categories',
'tags' => 'Etiquetes',
'projects' => 'Projectes',
'members' => 'Membres',
'services' => 'Serveis',
'clients' => 'Clients',
'testimonials' => 'Testimonis',
'talks' => 'Xerrades',
'technologies' => 'Tecnologies',
'calls' => 'Convocatòries',
'redirects' => 'Redireccions',
'repositories' => 'Repositoris',
'media' => 'Mitjans',
'applications' => 'Bústia',
],
'plural' => 'Webhooks',
'fields' => [
'name' => 'Nom',

View File

@@ -44,6 +44,10 @@ return [
'too_many' => 'Com a molt :max files per importació.',
],
'webhooks' => [
'event' => '«:event» no és un esdeveniment al qual subscriure\'s.',
],
'tokens' => [
'ability' => '«:ability» no és un permís que tinguis, així que un token teu no el pot tenir.',
'machine' => 'Els tokens els gestiona una persona, no un altre token.',

View File

@@ -148,6 +148,36 @@ return [
'webhooks' => [
'label' => 'Webhook',
'events_hint' => 'Pick as many as you like. "All of…" covers any change to that kind of record.',
'any_record' => 'Any record · :action',
'all_of' => 'All of :resource',
'groups' => [
'everything' => 'General',
'notices' => 'Notices',
],
'actions_on' => [
'created' => 'Created',
'updated' => 'Edited',
'deleted' => 'Deleted',
'restored' => 'Restored',
],
'resources' => [
'pages' => 'Pages',
'categories' => 'Categories',
'tags' => 'Tags',
'projects' => 'Projects',
'members' => 'Members',
'services' => 'Services',
'clients' => 'Clients',
'testimonials' => 'Testimonials',
'talks' => 'Talks',
'technologies' => 'Technologies',
'calls' => 'Calls',
'redirects' => 'Redirects',
'repositories' => 'Repositories',
'media' => 'Media',
'applications' => 'Inbox',
],
'plural' => 'Webhooks',
'fields' => [
'name' => 'Name',

View File

@@ -44,6 +44,10 @@ return [
'too_many' => 'At most :max rows per import.',
],
'webhooks' => [
'event' => '":event" is not an event that can be subscribed to.',
],
'tokens' => [
'ability' => '":ability" is not a permission you hold, so a token of yours cannot hold it.',
'machine' => 'Tokens are managed by a person, not by another token.',

View File

@@ -148,6 +148,36 @@ return [
'webhooks' => [
'label' => 'Webhook',
'events_hint' => 'Puedes elegir varios. «Todo de…» incluye cualquier cambio en ese tipo de ficha.',
'any_record' => 'Cualquier ficha · :action',
'all_of' => 'Todo de :resource',
'groups' => [
'everything' => 'General',
'notices' => 'Avisos',
],
'actions_on' => [
'created' => 'Creada',
'updated' => 'Editada',
'deleted' => 'Borrada',
'restored' => 'Recuperada',
],
'resources' => [
'pages' => 'Páginas',
'categories' => 'Categorías',
'tags' => 'Etiquetas',
'projects' => 'Proyectos',
'members' => 'Miembros',
'services' => 'Servicios',
'clients' => 'Clientes',
'testimonials' => 'Testimonios',
'talks' => 'Charlas',
'technologies' => 'Tecnologías',
'calls' => 'Convocatorias',
'redirects' => 'Redirecciones',
'repositories' => 'Repositorios',
'media' => 'Medios',
'applications' => 'Buzón',
],
'plural' => 'Webhooks',
'fields' => [
'name' => 'Nombre',

View File

@@ -44,6 +44,10 @@ return [
'too_many' => 'Como mucho :max filas por importación.',
],
'webhooks' => [
'event' => '«:event» no es un evento al que suscribirse.',
],
'tokens' => [
'ability' => '«:ability» no es un permiso que tengas, así que un token tuyo no puede tenerlo.',
'machine' => 'Los tokens los gestiona una persona, no otro token.',

View File

@@ -3945,9 +3945,26 @@ paths:
times over about two and a half hours; twenty failures in a row switch the
webhook off.
Events: `application.received` (its sender's details only with
`include_personal_data`), `content.published`, `content.unpublished`,
`review.requested`, `review.returned` — or `*`.
Events with a meaning of their own: `application.received` (its
sender's details only with `include_personal_data`),
`content.published`, `content.unpublished`, `review.requested`,
`review.returned`.
And every change a person makes to a record, as `{resource}.{action}`
— `projects.created`, `pages.updated`, `media.deleted`,
`calls.restored` — for pages, categories, tags, projects, members,
services, clients, testimonials, talks, technologies, calls,
redirects, repositories, media and applications. Its `data` says
what (`type`, `id`, `title`, `url` when it is on the site), who
(`by`), how they came in (`via`: panel, api, token) and, on an
update, `changes` as field → `{old, new}`. What the application does
by itself — a sync, a scheduled publication — is not sent. Changes to
an application carry only how it is handled unless
`include_personal_data`. Users, tokens, webhooks and settings are
never sent.
Subscribe to an event, to `*`, to all of one kind of record
(`projects.*`) or to one action on any (`*.deleted`).
requestBody:
required: true
content:
@@ -6200,7 +6217,10 @@ components:
url: { type: string, format: uri, description: "HTTPS, to a public address." }
events:
type: array
items: { type: string, enum: ["*", application.received, content.published, content.unpublished, review.requested, review.returned] }
items:
type: string
description: "An event, `*`, `{resource}.*` or `*.{action}`."
example: projects.*
secret: { type: string, minLength: 16, description: On creation only; generated when left out. }
rotate_secret: { type: boolean, description: On update only. }
include_personal_data: { type: boolean, default: false }

View File

@@ -0,0 +1,158 @@
<?php
declare(strict_types=1);
namespace Tests\Feature\Api;
use App\Enums\ApplicationStatus;
use App\Models\Application;
use App\Models\Page;
use App\Models\Project;
use App\Models\Repository;
use App\Models\User;
use App\Models\Webhook;
use App\Models\WebhookDelivery;
use App\Services\Media\HostResolver;
use Database\Seeders\RolePermissionSeeder;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Queue;
use PHPUnit\Framework\Attributes\Test;
use Tests\FakesPireon;
use Tests\TestCase;
/**
* `{resource}.{action}`: every change a person makes to a record, as a
* webhook event, with wildcards to subscribe broadly.
*/
class WebhookRecordEventsTest extends TestCase
{
use FakesPireon, RefreshDatabase;
protected function setUp(): void
{
parent::setUp();
$this->seed(RolePermissionSeeder::class);
Queue::fake();
$this->app->instance(HostResolver::class, new class extends HostResolver
{
public function resolve(string $host): array
{
return ['93.184.216.34'];
}
});
}
protected function actingWithRole(string $role): User
{
$user = User::factory()->create();
$user->syncRoles([$role]);
$this->withHeaders($this->apiHeadersFor($user));
return $user;
}
#[Test]
public function wildcards_cover_a_kind_of_record_or_an_action(): void
{
$webhook = Webhook::factory()->make(['events' => ['projects.*', '*.deleted']]);
$this->assertTrue($webhook->listensTo('projects.updated'));
$this->assertTrue($webhook->listensTo('pages.deleted'));
$this->assertFalse($webhook->listensTo('pages.updated'));
$this->assertFalse($webhook->listensTo('content.published'));
}
#[Test]
public function editing_a_project_says_what_changed_who_did_it_and_how(): void
{
$editor = $this->actingWithRole('editor');
$webhook = Webhook::factory()->create(['events' => ['projects.*']]);
$project = Project::factory()->create(['status' => 'active']);
$this->patchJson("/api/v1/projects/{$project->id}", ['status' => 'paused'])->assertOk();
$delivery = $webhook->deliveries()->where('event', 'projects.updated')->sole();
$this->assertSame($project->id, $delivery->payload['id']);
$this->assertSame(['old' => 'active', 'new' => 'paused'], $delivery->payload['changes']['status']);
$this->assertSame($editor->id, $delivery->payload['by']['id']);
$this->assertSame('api', $delivery->payload['via']);
}
#[Test]
public function creating_and_deleting_are_events_too(): void
{
$this->actingWithRole('editor');
$webhook = Webhook::factory()->create(['events' => ['pages.created', '*.deleted']]);
$id = $this->postJson('/api/v1/pages', ['title' => ['es' => 'Hola'], 'slug' => ['es' => 'hola']])->json('data.id');
$this->deleteJson("/api/v1/pages/{$id}")->assertNoContent();
$this->assertSame(['pages.deleted', 'pages.created'], $webhook->deliveries()->pluck('event')->all());
$this->assertNull($webhook->deliveries()->first()->payload['url']);
}
#[Test]
public function what_the_application_does_by_itself_is_not_announced(): void
{
$webhook = Webhook::factory()->create(['events' => ['*']]);
Repository::factory()->create()->forceFill(['stars' => 10])->save();
$this->assertSame(0, $webhook->deliveries()->count());
}
#[Test]
public function security_records_cannot_be_subscribed_to(): void
{
$this->actingWithRole('admin');
foreach (['users.updated', 'tokens.*', 'settings.updated', 'webhooks.created', 'projects.exploded'] as $event) {
$this->postJson('/api/v1/webhooks', ['name' => 'X', 'url' => 'https://hooks.example.org/x', 'events' => [$event]])
->assertUnprocessable()
->assertJsonValidationErrors('events.0');
}
$this->postJson('/api/v1/webhooks', ['name' => 'X', 'url' => 'https://hooks.example.org/x', 'events' => ['projects.*', '*.restored']])
->assertCreated();
}
#[Test]
public function the_inbox_tells_how_it_is_handled_and_its_words_only_when_asked(): void
{
$this->actingWithRole('editor');
$plain = Webhook::factory()->create(['events' => ['applications.updated']]);
$personal = Webhook::factory()->create(['events' => ['applications.*'], 'include_personal_data' => true]);
$application = Application::factory()->create(['email' => 'ada@example.org']);
$this->patchJson("/api/v1/applications/{$application->id}", ['status' => 'contacted', 'notes' => 'Llamar el lunes'])->assertOk();
$sent = $plain->deliveries()->latest('id')->first()->payload;
$full = $personal->deliveries()->latest('id')->first()->payload;
$this->assertArrayNotHasKey('notes', $sent['changes'] ?? []);
$this->assertArrayNotHasKey('email', $sent);
$this->assertSame('ada@example.org', $full['email']);
$this->assertSame(ApplicationStatus::Contacted->value, $application->fresh()->status->value);
}
#[Test]
public function the_events_answer_as_queued_deliveries(): void
{
$this->actingWithRole('editor');
Webhook::factory()->create(['events' => ['*']]);
$page = Page::factory()->create();
$this->patchJson("/api/v1/pages/{$page->id}", ['is_featured' => ! $page->is_featured])->assertOk();
$this->assertTrue(WebhookDelivery::query()->where('event', 'pages.updated')->exists());
}
}