Send a webhook for every change a person makes to a record
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:
@@ -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')
|
||||
|
||||
@@ -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'],
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user