diff --git a/README.md b/README.md index 273a6fd..a5e315e 100644 --- a/README.md +++ b/README.md @@ -1456,6 +1456,14 @@ concedía el permiso sin preguntar al token. ### Webhooks salientes +En el panel, **Administración → Webhooks** (solo con `webhooks.*`): alta con la +URL y los eventos marcados, y al guardar sale **el secreto una única vez**, en +un aviso que no se cierra solo. En la ficha, «Enviar prueba» manda un `ping`, +«Cambiar secreto» genera otro (el anterior deja de valer en el acto) y abajo +están **los envíos**: estado, respuesta, intentos, error, el contenido enviado y +«Reenviar». El menú se marca en rojo si alguno se apagó por fallos. Lo mismo +por API: + ``` /api/v1/webhooks CRUD (permiso webhooks.*, solo admin) GET /api/v1/webhooks/{id}/deliveries qué se envió y cómo fue diff --git a/app/Filament/Resources/Webhooks/Pages/CreateWebhook.php b/app/Filament/Resources/Webhooks/Pages/CreateWebhook.php new file mode 100644 index 0000000..9161b27 --- /dev/null +++ b/app/Filament/Resources/Webhooks/Pages/CreateWebhook.php @@ -0,0 +1,44 @@ +secret = Str::random(40); + + return [...$data, 'secret' => $this->secret]; + } + + protected function afterCreate(): void + { + $this->record->forceFill(['created_by' => Auth::id()])->save(); + + WebhookResource::showSecret($this->secret); + } + + protected function getCreatedNotification(): null + { + return null; + } + + protected function getRedirectUrl(): string + { + return WebhookResource::getUrl('edit', ['record' => $this->record]); + } +} diff --git a/app/Filament/Resources/Webhooks/Pages/EditWebhook.php b/app/Filament/Resources/Webhooks/Pages/EditWebhook.php new file mode 100644 index 0000000..24acbb9 --- /dev/null +++ b/app/Filament/Resources/Webhooks/Pages/EditWebhook.php @@ -0,0 +1,35 @@ +record->is_active) { + $this->record->consecutive_failures = 0; + } + + return $data; + } +} diff --git a/app/Filament/Resources/Webhooks/Pages/ListWebhooks.php b/app/Filament/Resources/Webhooks/Pages/ListWebhooks.php new file mode 100644 index 0000000..cc5577c --- /dev/null +++ b/app/Filament/Resources/Webhooks/Pages/ListWebhooks.php @@ -0,0 +1,21 @@ +defaultSort('id', 'desc') + ->poll('10s') + ->columns([ + TextColumn::make('event') + ->label(__('panel.webhooks.deliveries.event')) + ->badge() + ->color('gray'), + + TextColumn::make('status') + ->label(__('panel.webhooks.deliveries.status')) + ->badge() + ->formatStateUsing(fn (string $state) => __("panel.webhooks.deliveries.statuses.{$state}")) + ->color(fn (string $state) => match ($state) { + WebhookDelivery::SUCCEEDED => 'success', + WebhookDelivery::FAILED => 'danger', + default => 'warning', + }), + + TextColumn::make('response_status') + ->label(__('panel.webhooks.deliveries.response')) + ->placeholder('—'), + + TextColumn::make('attempts') + ->label(__('panel.webhooks.deliveries.attempts')), + + TextColumn::make('error') + ->label(__('panel.webhooks.deliveries.error')) + ->limit(60) + ->tooltip(fn (WebhookDelivery $record) => $record->error) + ->placeholder('—') + ->color('danger'), + + TextColumn::make('created_at') + ->label(__('panel.webhooks.deliveries.created_at')) + ->since() + ->dateTimeTooltip(), + ]) + ->filters([ + SelectFilter::make('status') + ->label(__('panel.webhooks.deliveries.status')) + ->options(collect([WebhookDelivery::PENDING, WebhookDelivery::SUCCEEDED, WebhookDelivery::FAILED]) + ->mapWithKeys(fn (string $s) => [$s => __("panel.webhooks.deliveries.statuses.{$s}")]) + ->all()), + ]) + ->recordActions([ + Action::make('payload') + ->label(__('panel.webhooks.deliveries.payload')) + ->icon('heroicon-o-code-bracket') + ->color('gray') + ->modalHeading(fn (WebhookDelivery $record) => $record->event.' · '.$record->uuid) + ->modalSubmitAction(false) + ->modalContent(fn (WebhookDelivery $record) => new HtmlString( + '
' + .e((string) json_encode($record->payload, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE)) + .'' + )), + + Action::make('redeliver') + ->label(__('panel.webhooks.deliveries.redeliver')) + ->icon('heroicon-o-arrow-path') + ->requiresConfirmation() + ->authorize(fn () => $this->getOwnerRecord() && auth()->user()?->can('update', $this->getOwnerRecord())) + ->action(function (WebhookDelivery $record): void { + Webhooks::redeliver($record); + + Notification::make()->title(__('panel.webhooks.deliveries.redelivered'))->success()->send(); + }), + ]); + } +} diff --git a/app/Filament/Resources/Webhooks/WebhookResource.php b/app/Filament/Resources/Webhooks/WebhookResource.php new file mode 100644 index 0000000..4be31fa --- /dev/null +++ b/app/Filament/Resources/Webhooks/WebhookResource.php @@ -0,0 +1,229 @@ +where('is_active', false) + ->where('consecutive_failures', '>=', Webhook::MAX_FAILURES) + ->count(); + + return $off > 0 ? (string) $off : null; + } + + public static function getNavigationBadgeColor(): ?string + { + return 'danger'; + } + + public static function form(Schema $schema): Schema + { + return $schema->components([ + Section::make() + ->columns(2) + ->schema([ + TextInput::make('name') + ->label(__('panel.webhooks.fields.name')) + ->required() + ->maxLength(120), + + TextInput::make('url') + ->label(__('panel.webhooks.fields.url')) + ->helperText(__('panel.webhooks.fields.url_hint')) + ->required() + ->url() + ->maxLength(2048) + ->rule(new PublicHttpsUrl), + + CheckboxList::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()) + ->required() + ->columns(2) + ->columnSpanFull(), + + Toggle::make('include_personal_data') + ->label(__('panel.webhooks.fields.include_personal_data')) + ->helperText(__('panel.webhooks.fields.include_personal_data_hint')), + + Toggle::make('is_active') + ->label(__('panel.webhooks.fields.is_active')) + ->default(true), + ]), + ]); + } + + public static function table(Table $table): Table + { + return $table + ->columns([ + TextColumn::make('name') + ->label(__('panel.webhooks.fields.name')) + ->weight('bold') + ->description(fn (Webhook $record) => $record->url) + ->searchable(), + + TextColumn::make('events') + ->label(__('panel.webhooks.fields.events')) + ->badge() + ->color('gray'), + + IconColumn::make('is_active') + ->label(__('panel.webhooks.fields.is_active')) + ->boolean(), + + TextColumn::make('consecutive_failures') + ->label(__('panel.webhooks.fields.failures')) + ->color(fn (int $state) => $state > 0 ? 'danger' : 'gray'), + + TextColumn::make('last_delivered_at') + ->label(__('panel.webhooks.fields.last_delivered_at')) + ->since() + ->dateTimeTooltip() + ->placeholder('—'), + ]) + ->recordActions([ + self::pingAction(), + EditAction::make(), + DeleteAction::make(), + ]); + } + + public static function eventLabel(string $event): string + { + $key = str_replace('.', '_', $event); + + return __("panel.webhooks.events.{$key}"); + } + + public static function pingAction(): Action + { + return Action::make('ping') + ->label(__('panel.webhooks.actions.ping')) + ->icon('heroicon-o-paper-airplane') + ->color('gray') + ->authorize('update') + ->action(function (Webhook $record): void { + Webhooks::ping($record); + + Notification::make() + ->title(__('panel.webhooks.actions.ping_sent')) + ->body(__('panel.webhooks.actions.ping_hint')) + ->success() + ->send(); + }); + } + + /** + * A new secret, shown once. The old one stops being valid at once, so the + * receiving end has to be updated straight after. + */ + public static function rotateAction(): Action + { + return Action::make('rotate_secret') + ->label(__('panel.webhooks.actions.rotate')) + ->icon('heroicon-o-key') + ->color('warning') + ->requiresConfirmation() + ->modalDescription(__('panel.webhooks.actions.rotate_hint')) + ->authorize('update') + ->action(function (Webhook $record): void { + $secret = Str::random(40); + $record->forceFill(['secret' => $secret])->save(); + + self::showSecret($secret); + }); + } + + public static function showSecret(string $secret): void + { + Notification::make() + ->title(__('panel.webhooks.secret.title')) + ->body(__('panel.webhooks.secret.body', ['secret' => $secret])) + ->warning() + ->persistent() + ->send(); + } + + public static function getRelations(): array + { + return [ + DeliveriesRelationManager::class, + ]; + } + + public static function getPages(): array + { + return [ + 'index' => ListWebhooks::route('/'), + 'create' => CreateWebhook::route('/create'), + 'edit' => EditWebhook::route('/{record}/edit'), + ]; + } +} diff --git a/app/Http/Controllers/Api/V1/WebhookController.php b/app/Http/Controllers/Api/V1/WebhookController.php index cdd2e5a..7273a51 100644 --- a/app/Http/Controllers/Api/V1/WebhookController.php +++ b/app/Http/Controllers/Api/V1/WebhookController.php @@ -8,9 +8,9 @@ use App\Http\Controllers\Controller; use App\Http\Requests\Api\V1\WebhookRequest; use App\Http\Resources\WebhookDeliveryResource; use App\Http\Resources\WebhookResource; -use App\Jobs\DeliverWebhook; use App\Models\Webhook; use App\Models\WebhookDelivery; +use App\Support\Webhooks\Webhooks; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; use Illuminate\Http\Resources\Json\AnonymousResourceCollection; @@ -115,15 +115,9 @@ class WebhookController extends Controller { $this->authorize('update', $webhook); - $delivery = $webhook->deliveries()->create([ - 'uuid' => (string) Str::uuid(), - 'event' => 'ping', - 'payload' => ['webhook' => $webhook->id, 'events' => $webhook->events], - ]); + $delivery = Webhooks::ping($webhook); - DeliverWebhook::dispatch($delivery); - - return WebhookDeliveryResource::make($delivery->refresh())->response()->setStatusCode(202); + return WebhookDeliveryResource::make($delivery)->response()->setStatusCode(202); } /** @@ -133,14 +127,8 @@ class WebhookController extends Controller { $this->authorize('update', $delivery->webhook); - $again = $delivery->webhook->deliveries()->create([ - 'uuid' => (string) Str::uuid(), - 'event' => $delivery->event, - 'payload' => $delivery->payload, - ]); + $again = Webhooks::redeliver($delivery); - DeliverWebhook::dispatch($again); - - return WebhookDeliveryResource::make($again->refresh())->response()->setStatusCode(202); + return WebhookDeliveryResource::make($again)->response()->setStatusCode(202); } } diff --git a/app/Http/Requests/Api/V1/WebhookRequest.php b/app/Http/Requests/Api/V1/WebhookRequest.php index 22a8569..e3d15e5 100644 --- a/app/Http/Requests/Api/V1/WebhookRequest.php +++ b/app/Http/Requests/Api/V1/WebhookRequest.php @@ -5,8 +5,7 @@ declare(strict_types=1); namespace App\Http\Requests\Api\V1; use App\Models\Webhook; -use App\Support\Webhooks\PublicEndpoint; -use Closure; +use App\Rules\PublicHttpsUrl; use Illuminate\Foundation\Http\FormRequest; use Illuminate\Validation\Rule; @@ -34,13 +33,7 @@ class WebhookRequest extends FormRequest 'max:2048', // Refused now rather than at the first delivery; checked // again at every delivery all the same. - function (string $attribute, mixed $value, Closure $fail): void { - [, $reason] = app(PublicEndpoint::class)->check((string) $value); - - if ($reason !== null) { - $fail(__("rules.media.remote.{$reason}")); - } - }, + new PublicHttpsUrl, ], 'events' => [$sometimes, 'array', 'min:1'], 'events.*' => ['string', 'distinct', Rule::in(['*', ...Webhook::EVENTS])], diff --git a/app/Rules/PublicHttpsUrl.php b/app/Rules/PublicHttpsUrl.php new file mode 100644 index 0000000..c382db6 --- /dev/null +++ b/app/Rules/PublicHttpsUrl.php @@ -0,0 +1,25 @@ +check((string) $value); + + if ($reason !== null) { + $fail(__("rules.media.remote.{$reason}")); + } + } +} diff --git a/app/Support/Webhooks/Webhooks.php b/app/Support/Webhooks/Webhooks.php index 5edd44a..84f8991 100644 --- a/app/Support/Webhooks/Webhooks.php +++ b/app/Support/Webhooks/Webhooks.php @@ -9,6 +9,7 @@ use App\Models\Application; use App\Models\ReviewComment; use App\Models\Revision; use App\Models\Webhook; +use App\Models\WebhookDelivery; use App\Support\AdminContent; use App\Support\ContentTypes; use App\Support\SiteUrls; @@ -43,6 +44,38 @@ final class Webhooks } } + /** + * A `ping`, to check the receiving end before anything real happens. + */ + public static function ping(Webhook $webhook): WebhookDelivery + { + $delivery = $webhook->deliveries()->create([ + 'uuid' => (string) Str::uuid(), + 'event' => 'ping', + 'payload' => ['webhook' => $webhook->id, 'events' => $webhook->events], + ]); + + DeliverWebhook::dispatch($delivery); + + return $delivery->refresh(); + } + + /** + * One sent again, as a new delivery of the same event and payload. + */ + public static function redeliver(WebhookDelivery $delivery): WebhookDelivery + { + $again = $delivery->webhook->deliveries()->create([ + 'uuid' => (string) Str::uuid(), + 'event' => $delivery->event, + 'payload' => $delivery->payload, + ]); + + DeliverWebhook::dispatch($again); + + return $again->refresh(); + } + public static function applicationReceived(Application $application): void { self::dispatch('application.received', [ diff --git a/lang/ca/panel.php b/lang/ca/panel.php index 4b3f832..3f021f0 100644 --- a/lang/ca/panel.php +++ b/lang/ca/panel.php @@ -146,6 +146,58 @@ return [ 'select_all' => 'Selecciona-ho tot', ], + 'webhooks' => [ + 'label' => 'Webhook', + 'plural' => 'Webhooks', + 'fields' => [ + 'name' => 'Nom', + 'url' => 'URL', + 'url_hint' => 'HTTPS i una adreça pública: no s\'accepten localhost, IPs privades ni noms interns.', + 'events' => 'Esdeveniments', + 'include_personal_data' => 'Incloure dades personals', + 'include_personal_data_hint' => 'Només afecta «Sol·licitud rebuda»: afegeix el nom, el correu i el missatge de qui va escriure.', + 'is_active' => 'Actiu', + 'failures' => 'Errors seguits', + 'last_delivered_at' => 'Últim enviament correcte', + ], + 'events' => [ + '*' => 'Tots', + 'application_received' => 'Sol·licitud rebuda a la bústia', + 'content_published' => 'Contingut publicat', + 'content_unpublished' => 'Contingut retirat del web', + 'review_requested' => 'Revisió demanada', + 'review_returned' => 'Revisió retornada amb canvis', + ], + 'actions' => [ + 'ping' => 'Enviar prova', + 'ping_sent' => 'Prova en camí', + 'ping_hint' => 'Mira el resultat a «Enviaments», més avall a la fitxa.', + 'rotate' => 'Canviar secret', + 'rotate_hint' => 'El secret actual deixa de valer a l\'acte: actualitza el servei que rep els avisos just després.', + ], + 'secret' => [ + 'title' => 'Desa aquest secret', + 'body' => 'Signa cada enviament i no es tornarà a mostrar: :secret', + ], + 'deliveries' => [ + 'title' => 'Enviaments', + 'event' => 'Esdeveniment', + 'status' => 'Estat', + 'response' => 'Resposta', + 'attempts' => 'Intents', + 'error' => 'Error', + 'created_at' => 'Quan', + 'payload' => 'Veure contingut', + 'redeliver' => 'Reenviar', + 'redelivered' => 'Reenviament en camí', + 'statuses' => [ + 'pending' => 'Pendent', + 'succeeded' => 'Lliurat', + 'failed' => 'Fallit', + ], + ], + ], + 'tags' => [ 'label' => 'Etiqueta', 'plural' => 'Etiquetes', diff --git a/lang/en/panel.php b/lang/en/panel.php index b9c7a57..768addc 100644 --- a/lang/en/panel.php +++ b/lang/en/panel.php @@ -146,6 +146,58 @@ return [ 'select_all' => 'Select all', ], + 'webhooks' => [ + 'label' => 'Webhook', + 'plural' => 'Webhooks', + 'fields' => [ + 'name' => 'Name', + 'url' => 'URL', + 'url_hint' => 'HTTPS and a public address: localhost, private IPs and internal names are refused.', + 'events' => 'Events', + 'include_personal_data' => 'Include personal data', + 'include_personal_data_hint' => 'Only for "Submission received": adds the name, e-mail and message of whoever wrote.', + 'is_active' => 'Active', + 'failures' => 'Failures in a row', + 'last_delivered_at' => 'Last successful delivery', + ], + 'events' => [ + '*' => 'All', + 'application_received' => 'Submission received in the inbox', + 'content_published' => 'Content published', + 'content_unpublished' => 'Content taken off the site', + 'review_requested' => 'Review requested', + 'review_returned' => 'Review returned for changes', + ], + 'actions' => [ + 'ping' => 'Send a test', + 'ping_sent' => 'Test on its way', + 'ping_hint' => 'See how it went under "Deliveries", further down.', + 'rotate' => 'Change secret', + 'rotate_hint' => 'The current secret stops working at once: update the receiving service straight after.', + ], + 'secret' => [ + 'title' => 'Keep this secret', + 'body' => 'It signs every delivery and will not be shown again: :secret', + ], + 'deliveries' => [ + 'title' => 'Deliveries', + 'event' => 'Event', + 'status' => 'Status', + 'response' => 'Response', + 'attempts' => 'Attempts', + 'error' => 'Error', + 'created_at' => 'When', + 'payload' => 'View content', + 'redeliver' => 'Send again', + 'redelivered' => 'Sending again', + 'statuses' => [ + 'pending' => 'Pending', + 'succeeded' => 'Delivered', + 'failed' => 'Failed', + ], + ], + ], + 'tags' => [ 'label' => 'Tag', 'plural' => 'Tags', diff --git a/lang/es/panel.php b/lang/es/panel.php index cd5cc27..995a6d4 100644 --- a/lang/es/panel.php +++ b/lang/es/panel.php @@ -146,6 +146,58 @@ return [ 'select_all' => 'Seleccionar todos', ], + 'webhooks' => [ + 'label' => 'Webhook', + 'plural' => 'Webhooks', + 'fields' => [ + 'name' => 'Nombre', + 'url' => 'URL', + 'url_hint' => 'HTTPS y una dirección pública: no se aceptan localhost, IPs privadas ni nombres internos.', + 'events' => 'Eventos', + 'include_personal_data' => 'Incluir datos personales', + 'include_personal_data_hint' => 'Solo afecta a «Solicitud recibida»: añade el nombre, el correo y el mensaje de quien escribió.', + 'is_active' => 'Activo', + 'failures' => 'Fallos seguidos', + 'last_delivered_at' => 'Último envío correcto', + ], + 'events' => [ + '*' => 'Todos', + 'application_received' => 'Solicitud recibida en el buzón', + 'content_published' => 'Contenido publicado', + 'content_unpublished' => 'Contenido retirado de la web', + 'review_requested' => 'Revisión pedida', + 'review_returned' => 'Revisión devuelta con cambios', + ], + 'actions' => [ + 'ping' => 'Enviar prueba', + 'ping_sent' => 'Prueba en camino', + 'ping_hint' => 'Mira el resultado en «Envíos», más abajo en la ficha.', + 'rotate' => 'Cambiar secreto', + 'rotate_hint' => 'El secreto actual deja de valer en el acto: actualiza el servicio que recibe los avisos justo después.', + ], + 'secret' => [ + 'title' => 'Guarda este secreto', + 'body' => 'Firma cada envío y no se volverá a mostrar: :secret', + ], + 'deliveries' => [ + 'title' => 'Envíos', + 'event' => 'Evento', + 'status' => 'Estado', + 'response' => 'Respuesta', + 'attempts' => 'Intentos', + 'error' => 'Error', + 'created_at' => 'Cuándo', + 'payload' => 'Ver contenido', + 'redeliver' => 'Reenviar', + 'redelivered' => 'Reenvío en camino', + 'statuses' => [ + 'pending' => 'Pendiente', + 'succeeded' => 'Entregado', + 'failed' => 'Fallido', + ], + ], + ], + 'tags' => [ 'label' => 'Etiqueta', 'plural' => 'Etiquetas', diff --git a/tests/Feature/Panel/WebhookPanelTest.php b/tests/Feature/Panel/WebhookPanelTest.php new file mode 100644 index 0000000..5a8a6f0 --- /dev/null +++ b/tests/Feature/Panel/WebhookPanelTest.php @@ -0,0 +1,132 @@ +seed(RolePermissionSeeder::class); + + $this->app->instance(HostResolver::class, new class extends HostResolver + { + public function resolve(string $host): array + { + return ['hooks.example.org' => ['93.184.216.34'], 'internal.example.org' => ['10.0.0.5']][$host] ?? []; + } + }); + } + + #[Test] + public function an_admin_registers_one_and_sees_its_secret_once(): void + { + $this->actingAs($admin = $this->userWithRoles('admin')); + + Livewire::test(CreateWebhook::class) + ->fillForm([ + 'name' => 'Avisos', + 'url' => 'https://hooks.example.org/pireon', + 'events' => ['application.received'], + ]) + ->call('create') + ->assertHasNoFormErrors(); + + $webhook = Webhook::query()->sole(); + + $this->assertSame(40, strlen($webhook->secret)); + $this->assertSame($admin->id, $webhook->created_by); + + Notification::assertNotified( + Notification::make() + ->title(__('panel.webhooks.secret.title')) + ->body(__('panel.webhooks.secret.body', ['secret' => $webhook->secret])) + ->warning() + ->persistent(), + ); + } + + #[Test] + public function an_address_inside_the_network_is_refused(): void + { + $this->actingAs($this->userWithRoles('admin')); + + Livewire::test(CreateWebhook::class) + ->fillForm(['name' => 'X', 'url' => 'https://internal.example.org/x', 'events' => ['*']]) + ->call('create') + ->assertHasFormErrors(['url']); + + $this->assertSame(0, Webhook::query()->count()); + } + + #[Test] + public function a_test_is_sent_and_shows_among_the_deliveries(): void + { + Http::fake(['hooks.example.org/*' => Http::response('ok')]); + + $this->actingAs($this->userWithRoles('admin')); + + $webhook = Webhook::factory()->create(['url' => 'https://hooks.example.org/x']); + + Livewire::test(EditWebhook::class, ['record' => $webhook->getRouteKey()]) + ->callAction('ping'); + + Livewire::test(DeliveriesRelationManager::class, ['ownerRecord' => $webhook, 'pageClass' => EditWebhook::class]) + ->assertCanSeeTableRecords($webhook->deliveries()->get()) + ->assertSee('ping'); + } + + #[Test] + public function rotating_the_secret_shows_the_new_one(): void + { + $this->actingAs($this->userWithRoles('admin')); + + $webhook = Webhook::factory()->create(['secret' => 'old-secret-old-secret']); + + Livewire::test(EditWebhook::class, ['record' => $webhook->getRouteKey()]) + ->callAction('rotate_secret'); + + $new = $webhook->fresh()->secret; + + $this->assertNotSame('old-secret-old-secret', $new); + Notification::assertNotified( + Notification::make() + ->title(__('panel.webhooks.secret.title')) + ->body(__('panel.webhooks.secret.body', ['secret' => $new])) + ->warning() + ->persistent(), + ); + } + + #[Test] + public function an_editor_does_not_see_them(): void + { + $this->actingAs($this->userWithRoles('editor')); + + $this->get(WebhookResource::getUrl('index'))->assertForbidden(); + } +}