diff --git a/composer.json b/composer.json index 0b97f9bd..11482eb8 100644 --- a/composer.json +++ b/composer.json @@ -33,6 +33,7 @@ "spatie/laravel-data": "^4.11", "spatie/laravel-query-builder": "^5.5", "spatie/laravel-settings": "^3.2", + "spatie/laravel-webhook-server": "^3.8", "timacdonald/json-api": "^1.0.0-beta.4", "twig/twig": "^3.0" }, diff --git a/config/cachet.php b/config/cachet.php index 03253912..760f3584 100644 --- a/config/cachet.php +++ b/config/cachet.php @@ -107,4 +107,21 @@ | */ 'docker' => env('CACHET_DOCKER', false), + + /* + |-------------------------------------------------------------------------- + | Cachet Webhooks + |-------------------------------------------------------------------------- + | + | Configure how Cachet sends webhooks for events. + | + */ + 'webhooks' => [ + 'queue_connection' => env('CACHET_WEBHOOK_QUEUE_CONNECTION', 'default'), + 'queue_name' => env('CACHET_WEBHOOK_QUEUE_NAME', 'webhooks'), + + 'logs' => [ + 'prune_logs_after_days' => 30, + ], + ], ]; diff --git a/database/factories/WebhookAttemptFactory.php b/database/factories/WebhookAttemptFactory.php new file mode 100644 index 00000000..bc3dc003 --- /dev/null +++ b/database/factories/WebhookAttemptFactory.php @@ -0,0 +1,33 @@ + + */ +class WebhookAttemptFactory extends Factory +{ + protected $model = WebhookAttempt::class; + + /** + * Define the model's default state. + * + * @return array + */ + public function definition(): array + { + return [ + 'subscription_id' => WebhookSubscription::factory(), + 'event' => WebhookEventEnum::component_created, + 'attempt' => 0, + 'payload' => [], + 'response_code' => 200, + 'transfer_time' => 0.1, + ]; + } +} diff --git a/database/factories/WebhookSubscriptionFactory.php b/database/factories/WebhookSubscriptionFactory.php new file mode 100644 index 00000000..a6ffeff4 --- /dev/null +++ b/database/factories/WebhookSubscriptionFactory.php @@ -0,0 +1,51 @@ + + */ +class WebhookSubscriptionFactory extends Factory +{ + protected $model = WebhookSubscription::class; + + /** + * Define the model's default state. + * + * @return array + */ + public function definition(): array + { + return [ + 'url' => fake()->url, + 'secret' => fake()->randomAscii(), + 'description' => fake()->sentence(), + 'send_all_events' => false, + 'selected_events' => [], + ]; + } + + /** + * Create a webhook subscription that is enabled for all events + */ + public function allEvents(): self + { + return $this->state([ + 'send_all_events' => true, + ]); + } + + /** + * Create a webhook subscription that is only enabled + * for the given events. + */ + public function selectedEvents(array $events): self + { + return $this->state([ + 'selected_events' => $events, + ]); + } +} diff --git a/database/migrations/2025_01_02_193833_create_webhook_subscriptions_table.php b/database/migrations/2025_01_02_193833_create_webhook_subscriptions_table.php new file mode 100644 index 00000000..86e66380 --- /dev/null +++ b/database/migrations/2025_01_02_193833_create_webhook_subscriptions_table.php @@ -0,0 +1,34 @@ +id(); + $table->string('url'); + $table->string('secret'); + $table->string('description')->nullable(); + $table->boolean('send_all_events')->default(true); + $table->json('selected_events')->nullable(); + $table->decimal('success_rate_24h', 5, 2)->nullable(); + + $table->timestamps(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('webhook_subscriptions'); + } +}; diff --git a/database/migrations/2025_01_03_144743_create_webhook_attempts_table.php b/database/migrations/2025_01_03_144743_create_webhook_attempts_table.php new file mode 100644 index 00000000..fbc463e6 --- /dev/null +++ b/database/migrations/2025_01_03_144743_create_webhook_attempts_table.php @@ -0,0 +1,34 @@ +id(); + $table->foreignIdFor(WebhookSubscription::class, 'subscription_id')->onDelete('cascade'); + $table->string('event'); + $table->unsignedTinyInteger('attempt'); + $table->json('payload'); + $table->unsignedSmallInteger('response_code')->nullable(); + $table->unsignedTinyInteger('transfer_time')->nullable(); + $table->timestamps(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::drop('webhook_attempts'); + } +}; diff --git a/resources/lang/en/navigation.php b/resources/lang/en/navigation.php index bedf2e94..6ae17832 100644 --- a/resources/lang/en/navigation.php +++ b/resources/lang/en/navigation.php @@ -7,6 +7,7 @@ 'manage_cachet' => 'Manage Cachet', 'manage_customization' => 'Manage Customization', 'manage_theme' => 'Manage Theme', + 'manage_webhooks' => 'Manage Webhooks', ], ], diff --git a/resources/lang/en/webhook.php b/resources/lang/en/webhook.php new file mode 100644 index 00000000..2696e891 --- /dev/null +++ b/resources/lang/en/webhook.php @@ -0,0 +1,30 @@ + 'Webhook|Webhooks', + 'event_selection' => [ + 'all' => 'Send all events', + 'selected' => 'Only send selected events', + ], + 'form' => [ + 'url_label' => 'Payload URL', + 'url_helper' => 'Events will POST to this URL.', + 'secret_label' => 'Secret', + 'secret_helper' => 'The payload will be signed with this secret. See *webhook documentation* for more information.', + 'description_label' => 'Description', + 'event_selection_label' => 'Send all events?', + 'events_label' => 'Events', + 'edit_secret_label' => 'Edit secret', + 'update_secret_label' => 'Update secret', + ], + 'attempts' => [ + 'heading' => 'Attempts', + 'empty_state' => 'No attempts have been made to this webhook yet', + ], + 'list' => [ + 'headers' => [ + 'url' => 'Payload URL', + 'success_rate_24h' => 'Success rate (24h)', + ], + ], +]; diff --git a/resources/views/filament/pages/settings/edit-webhook-subscription.blade.php b/resources/views/filament/pages/settings/edit-webhook-subscription.blade.php new file mode 100644 index 00000000..5e935a02 --- /dev/null +++ b/resources/views/filament/pages/settings/edit-webhook-subscription.blade.php @@ -0,0 +1,14 @@ + + + {{ $this->form }} + + + + + {{ view('cachet::filament.widgets.webhook-attempts', [ + 'attempts' => $this->record->attempts, + ]) }} + diff --git a/resources/views/filament/widgets/webhook-attempts.blade.php b/resources/views/filament/widgets/webhook-attempts.blade.php new file mode 100644 index 00000000..638f62f0 --- /dev/null +++ b/resources/views/filament/widgets/webhook-attempts.blade.php @@ -0,0 +1,24 @@ + +
+ @forelse($attempts as $attempt) +
+
!$attempt->isSuccess(), + 'bg-primary-100 text-primary-500' => $attempt->isSuccess(), + 'flex-shrink-0 whitespace-nowrap px-1 py-1 rounded-md font-semibold' => true, + ])> + @if ($attempt->isSuccess()) + + @else + + @endif +
+ +
{{ $attempt->event }}
+
{{ $attempt->created_at?->toDateTimeString() }}
+
+ @empty +
{{ __('cachet::webhook.attempts.empty_state') }}
+ @endforelse +
+
\ No newline at end of file diff --git a/src/Actions/Webhook/DispatchWebhooks.php b/src/Actions/Webhook/DispatchWebhooks.php new file mode 100644 index 00000000..67c37559 --- /dev/null +++ b/src/Actions/Webhook/DispatchWebhooks.php @@ -0,0 +1,33 @@ +event = $event; + $this->eventName = $this->event->getWebhookEventName(); + + $payload = $this->event->getWebhookPayload(); + + foreach ($this->getWebhookSubscriptionsForEvent() as $webhookSubscription) { + $webhookSubscription->makeCall($this->eventName, $payload)->dispatch(); + } + } + + /** + * @return Collection + */ + private function getWebhookSubscriptionsForEvent(): Collection + { + return WebhookSubscription::whereEvent($this->eventName)->get(); + } +} diff --git a/src/Cachet.php b/src/Cachet.php index 115ea839..324a2018 100644 --- a/src/Cachet.php +++ b/src/Cachet.php @@ -15,6 +15,11 @@ class Cachet */ public const USER_AGENT = 'Cachet/3.0 (+https://docs.cachethq.io)'; + /** + * The user agent used by Cachet's webhooks. + */ + public const WEBHOOK_USER_AGENT = 'Cachet/3.0 Webhook (+https://docs.cachethq.io)'; + /** * Get the current user using `cachet.guard`. */ diff --git a/src/CachetCoreServiceProvider.php b/src/CachetCoreServiceProvider.php index 92f55c40..22254cc7 100644 --- a/src/CachetCoreServiceProvider.php +++ b/src/CachetCoreServiceProvider.php @@ -3,6 +3,8 @@ namespace Cachet; use BladeUI\Icons\Factory; +use Cachet\Listeners\SendWebhookListener; +use Cachet\Listeners\WebhookCallEventListener; use Cachet\Models\Incident; use Cachet\Models\Schedule; use Cachet\Settings\AppSettings; @@ -14,10 +16,13 @@ use Illuminate\Foundation\Console\AboutCommand; use Illuminate\Routing\Router; use Illuminate\Support\Facades\Blade; +use Illuminate\Support\Facades\Event; use Illuminate\Support\Facades\Http; use Illuminate\Support\Facades\RateLimiter; use Illuminate\Support\Facades\Route; use Illuminate\Support\ServiceProvider; +use Spatie\WebhookServer\Events\WebhookCallFailedEvent; +use Spatie\WebhookServer\Events\WebhookCallSucceededEvent; class CachetCoreServiceProvider extends ServiceProvider { @@ -56,6 +61,9 @@ public function boot(): void $this->registerPublishing(); $this->registerBladeComponents(); + Event::listen('Cachet\Events\Incidents\*', SendWebhookListener::class); + Event::listen([WebhookCallSucceededEvent::class, WebhookCallFailedEvent::class], WebhookCallEventListener::class); + Http::globalRequestMiddleware(fn ($request) => $request->withHeader( 'User-Agent', Cachet::USER_AGENT )); diff --git a/src/Concerns/SendsWebhook.php b/src/Concerns/SendsWebhook.php new file mode 100644 index 00000000..88ed2b6d --- /dev/null +++ b/src/Concerns/SendsWebhook.php @@ -0,0 +1,19 @@ +component->toArray(); + } + + public function getWebhookEventName(): WebhookEventEnum + { + return WebhookEventEnum::component_created; + } } diff --git a/src/Events/Components/ComponentDeleted.php b/src/Events/Components/ComponentDeleted.php index 595b3b6f..227a1e8f 100644 --- a/src/Events/Components/ComponentDeleted.php +++ b/src/Events/Components/ComponentDeleted.php @@ -2,6 +2,8 @@ namespace Cachet\Events\Components; +use Cachet\Concerns\SendsWebhook; +use Cachet\Enums\WebhookEventEnum; use Cachet\Models\Component; use Illuminate\Broadcasting\InteractsWithSockets; use Illuminate\Broadcasting\PrivateChannel; @@ -10,7 +12,7 @@ class ComponentDeleted { - use Dispatchable, InteractsWithSockets, SerializesModels; + use Dispatchable, InteractsWithSockets, SerializesModels, SendsWebhook; /** * Create a new event instance. @@ -31,4 +33,14 @@ public function broadcastOn(): array new PrivateChannel('channel-name'), ]; } + + public function getWebhookPayload(): array + { + return $this->component->toArray(); + } + + public function getWebhookEventName(): WebhookEventEnum + { + return WebhookEventEnum::component_deleted; + } } diff --git a/src/Events/Components/ComponentStatusWasChanged.php b/src/Events/Components/ComponentStatusWasChanged.php index a2ca7445..a46b87c1 100644 --- a/src/Events/Components/ComponentStatusWasChanged.php +++ b/src/Events/Components/ComponentStatusWasChanged.php @@ -2,7 +2,9 @@ namespace Cachet\Events\Components; +use Cachet\Concerns\SendsWebhook; use Cachet\Enums\ComponentStatusEnum; +use Cachet\Enums\WebhookEventEnum; use Cachet\Models\Component; use Illuminate\Broadcasting\InteractsWithSockets; use Illuminate\Broadcasting\PrivateChannel; @@ -11,7 +13,7 @@ class ComponentStatusWasChanged { - use Dispatchable, InteractsWithSockets, SerializesModels; + use Dispatchable, InteractsWithSockets, SerializesModels, SendsWebhook; /** * Create a new event instance. @@ -32,4 +34,18 @@ public function broadcastOn(): array new PrivateChannel('channel-name'), ]; } + + public function getWebhookPayload(): array + { + return [ + 'component_id' => $this->component->getKey(), + 'old_status' => $this->oldStatus->value, + 'new_status' => $this->newStatus->value, + ]; + } + + public function getWebhookEventName(): WebhookEventEnum + { + return WebhookEventEnum::component_status_changed; + } } diff --git a/src/Events/Components/ComponentUpdated.php b/src/Events/Components/ComponentUpdated.php index fea36a82..28d7438c 100644 --- a/src/Events/Components/ComponentUpdated.php +++ b/src/Events/Components/ComponentUpdated.php @@ -2,6 +2,8 @@ namespace Cachet\Events\Components; +use Cachet\Concerns\SendsWebhook; +use Cachet\Enums\WebhookEventEnum; use Cachet\Models\Component; use Illuminate\Broadcasting\InteractsWithSockets; use Illuminate\Broadcasting\PrivateChannel; @@ -10,7 +12,7 @@ class ComponentUpdated { - use Dispatchable, InteractsWithSockets, SerializesModels; + use Dispatchable, InteractsWithSockets, SerializesModels, SendsWebhook; /** * Create a new event instance. @@ -31,4 +33,14 @@ public function broadcastOn(): array new PrivateChannel('channel-name'), ]; } + + public function getWebhookPayload(): array + { + return $this->component->toArray(); + } + + public function getWebhookEventName(): WebhookEventEnum + { + return WebhookEventEnum::component_updated; + } } diff --git a/src/Events/Incidents/IncidentCreated.php b/src/Events/Incidents/IncidentCreated.php index b3464aae..45b9b30f 100644 --- a/src/Events/Incidents/IncidentCreated.php +++ b/src/Events/Incidents/IncidentCreated.php @@ -2,6 +2,8 @@ namespace Cachet\Events\Incidents; +use Cachet\Concerns\SendsWebhook; +use Cachet\Enums\WebhookEventEnum; use Cachet\Models\Incident; use Illuminate\Broadcasting\InteractsWithSockets; use Illuminate\Broadcasting\PrivateChannel; @@ -10,7 +12,7 @@ class IncidentCreated { - use Dispatchable, InteractsWithSockets, SerializesModels; + use Dispatchable, InteractsWithSockets, SerializesModels, SendsWebhook; /** * Create a new event instance. @@ -31,4 +33,14 @@ public function broadcastOn(): array new PrivateChannel('channel-name'), ]; } + + public function getWebhookPayload(): array + { + return $this->incident->toArray(); + } + + public function getWebhookEventName(): WebhookEventEnum + { + return WebhookEventEnum::incident_created; + } } diff --git a/src/Events/Incidents/IncidentDeleted.php b/src/Events/Incidents/IncidentDeleted.php index 45b7a895..9f6302e5 100644 --- a/src/Events/Incidents/IncidentDeleted.php +++ b/src/Events/Incidents/IncidentDeleted.php @@ -2,6 +2,8 @@ namespace Cachet\Events\Incidents; +use Cachet\Concerns\SendsWebhook; +use Cachet\Enums\WebhookEventEnum; use Cachet\Models\Incident; use Illuminate\Broadcasting\InteractsWithSockets; use Illuminate\Broadcasting\PrivateChannel; @@ -10,7 +12,7 @@ class IncidentDeleted { - use Dispatchable, InteractsWithSockets, SerializesModels; + use Dispatchable, InteractsWithSockets, SerializesModels, SendsWebhook; /** * Create a new event instance. @@ -20,6 +22,11 @@ public function __construct(public Incident $incident) // } + public function getWebhookPayload(): array + { + return $this->incident->toArray(); + } + /** * Get the channels the event should broadcast on. * @@ -31,4 +38,9 @@ public function broadcastOn(): array new PrivateChannel('channel-name'), ]; } + + public function getWebhookEventName(): WebhookEventEnum + { + return WebhookEventEnum::incident_deleted; + } } diff --git a/src/Events/Incidents/IncidentUpdated.php b/src/Events/Incidents/IncidentUpdated.php index 92b41904..7322e7ea 100644 --- a/src/Events/Incidents/IncidentUpdated.php +++ b/src/Events/Incidents/IncidentUpdated.php @@ -2,6 +2,8 @@ namespace Cachet\Events\Incidents; +use Cachet\Concerns\SendsWebhook; +use Cachet\Enums\WebhookEventEnum; use Cachet\Models\Incident; use Illuminate\Broadcasting\InteractsWithSockets; use Illuminate\Broadcasting\PrivateChannel; @@ -10,14 +12,14 @@ class IncidentUpdated { - use Dispatchable, InteractsWithSockets, SerializesModels; + use Dispatchable, InteractsWithSockets, SerializesModels, SendsWebhook; /** * Create a new event instance. */ public function __construct(public Incident $incident) { - // + } /** @@ -31,4 +33,14 @@ public function broadcastOn(): array new PrivateChannel('channel-name'), ]; } + + public function getWebhookPayload(): array + { + return $this->incident->toArray(); + } + + public function getWebhookEventName(): WebhookEventEnum + { + return WebhookEventEnum::incident_updated; + } } diff --git a/src/Events/Metrics/MetricCreated.php b/src/Events/Metrics/MetricCreated.php index 5b8851b0..cae0619a 100644 --- a/src/Events/Metrics/MetricCreated.php +++ b/src/Events/Metrics/MetricCreated.php @@ -2,6 +2,8 @@ namespace Cachet\Events\Metrics; +use Cachet\Concerns\SendsWebhook; +use Cachet\Enums\WebhookEventEnum; use Cachet\Models\Metric; use Illuminate\Broadcasting\InteractsWithSockets; use Illuminate\Broadcasting\PrivateChannel; @@ -10,7 +12,7 @@ class MetricCreated { - use Dispatchable, InteractsWithSockets, SerializesModels; + use Dispatchable, InteractsWithSockets, SerializesModels, SendsWebhook; /** * Create a new event instance. @@ -31,4 +33,14 @@ public function broadcastOn(): array new PrivateChannel('channel-name'), ]; } + + public function getWebhookPayload(): array + { + return $this->metric->toArray(); + } + + public function getWebhookEventName(): WebhookEventEnum + { + return WebhookEventEnum::metric_created; + } } diff --git a/src/Events/Metrics/MetricDeleted.php b/src/Events/Metrics/MetricDeleted.php index d41b4dac..adc66af4 100644 --- a/src/Events/Metrics/MetricDeleted.php +++ b/src/Events/Metrics/MetricDeleted.php @@ -2,6 +2,8 @@ namespace Cachet\Events\Metrics; +use Cachet\Concerns\SendsWebhook; +use Cachet\Enums\WebhookEventEnum; use Cachet\Models\Metric; use Illuminate\Broadcasting\InteractsWithSockets; use Illuminate\Broadcasting\PrivateChannel; @@ -10,7 +12,7 @@ class MetricDeleted { - use Dispatchable, InteractsWithSockets, SerializesModels; + use Dispatchable, InteractsWithSockets, SerializesModels, SendsWebhook; /** * Create a new event instance. @@ -31,4 +33,14 @@ public function broadcastOn(): array new PrivateChannel('channel-name'), ]; } + + public function getWebhookPayload(): array + { + return $this->metric->toArray(); + } + + public function getWebhookEventName(): WebhookEventEnum + { + return WebhookEventEnum::metric_deleted; + } } diff --git a/src/Events/Metrics/MetricPointCreated.php b/src/Events/Metrics/MetricPointCreated.php index 07c3e7e5..dc82fe69 100644 --- a/src/Events/Metrics/MetricPointCreated.php +++ b/src/Events/Metrics/MetricPointCreated.php @@ -2,6 +2,8 @@ namespace Cachet\Events\Metrics; +use Cachet\Concerns\SendsWebhook; +use Cachet\Enums\WebhookEventEnum; use Cachet\Models\MetricPoint; use Illuminate\Broadcasting\Channel; use Illuminate\Broadcasting\InteractsWithSockets; @@ -11,7 +13,7 @@ class MetricPointCreated { - use Dispatchable, InteractsWithSockets, SerializesModels; + use Dispatchable, InteractsWithSockets, SerializesModels, SendsWebhook; /** * Create a new event instance. @@ -32,4 +34,14 @@ public function broadcastOn(): array new PrivateChannel('channel-name'), ]; } + + public function getWebhookPayload(): array + { + return $this->metric->toArray(); + } + + public function getWebhookEventName(): WebhookEventEnum + { + return WebhookEventEnum::metric_point_created; + } } diff --git a/src/Events/Metrics/MetricPointDeleted.php b/src/Events/Metrics/MetricPointDeleted.php index ef263b91..1a8e70c8 100644 --- a/src/Events/Metrics/MetricPointDeleted.php +++ b/src/Events/Metrics/MetricPointDeleted.php @@ -2,6 +2,8 @@ namespace Cachet\Events\Metrics; +use Cachet\Concerns\SendsWebhook; +use Cachet\Enums\WebhookEventEnum; use Cachet\Models\MetricPoint; use Illuminate\Broadcasting\Channel; use Illuminate\Broadcasting\InteractsWithSockets; @@ -11,7 +13,7 @@ class MetricPointDeleted { - use Dispatchable, InteractsWithSockets, SerializesModels; + use Dispatchable, InteractsWithSockets, SerializesModels, SendsWebhook; /** * Create a new event instance. @@ -32,4 +34,14 @@ public function broadcastOn(): array new PrivateChannel('channel-name'), ]; } + + public function getWebhookPayload(): array + { + return $this->metric->toArray(); + } + + public function getWebhookEventName(): WebhookEventEnum + { + return WebhookEventEnum::metric_point_deleted; + } } diff --git a/src/Events/Metrics/MetricUpdated.php b/src/Events/Metrics/MetricUpdated.php index 2f523500..2bec8114 100644 --- a/src/Events/Metrics/MetricUpdated.php +++ b/src/Events/Metrics/MetricUpdated.php @@ -2,6 +2,8 @@ namespace Cachet\Events\Metrics; +use Cachet\Concerns\SendsWebhook; +use Cachet\Enums\WebhookEventEnum; use Cachet\Models\Metric; use Illuminate\Broadcasting\InteractsWithSockets; use Illuminate\Broadcasting\PrivateChannel; @@ -10,7 +12,7 @@ class MetricUpdated { - use Dispatchable, InteractsWithSockets, SerializesModels; + use Dispatchable, InteractsWithSockets, SerializesModels, SendsWebhook; /** * Create a new event instance. @@ -31,4 +33,14 @@ public function broadcastOn(): array new PrivateChannel('channel-name'), ]; } + + public function getWebhookPayload(): array + { + return $this->metric->toArray(); + } + + public function getWebhookEventName(): WebhookEventEnum + { + return WebhookEventEnum::metric_updated; + } } diff --git a/src/Events/Subscribers/SubscriberCreated.php b/src/Events/Subscribers/SubscriberCreated.php index 168aecbb..23139f89 100644 --- a/src/Events/Subscribers/SubscriberCreated.php +++ b/src/Events/Subscribers/SubscriberCreated.php @@ -2,6 +2,8 @@ namespace Cachet\Events\Subscribers; +use Cachet\Concerns\SendsWebhook; +use Cachet\Enums\WebhookEventEnum; use Cachet\Models\Subscriber; use Illuminate\Broadcasting\InteractsWithSockets; use Illuminate\Broadcasting\PrivateChannel; @@ -10,7 +12,7 @@ class SubscriberCreated { - use Dispatchable, InteractsWithSockets, SerializesModels; + use Dispatchable, InteractsWithSockets, SerializesModels, SendsWebhook; /** * Create a new event instance. @@ -31,4 +33,14 @@ public function broadcastOn(): array new PrivateChannel('channel-name'), ]; } + + public function getWebhookPayload(): array + { + return $this->subscriber->toArray(); + } + + public function getWebhookEventName(): WebhookEventEnum + { + return WebhookEventEnum::subscriber_created; + } } diff --git a/src/Events/Subscribers/SubscriberUnsubscribed.php b/src/Events/Subscribers/SubscriberUnsubscribed.php index 1b27cdb9..899ec86e 100644 --- a/src/Events/Subscribers/SubscriberUnsubscribed.php +++ b/src/Events/Subscribers/SubscriberUnsubscribed.php @@ -2,6 +2,8 @@ namespace Cachet\Events\Subscribers; +use Cachet\Concerns\SendsWebhook; +use Cachet\Enums\WebhookEventEnum; use Cachet\Models\Subscriber; use Illuminate\Broadcasting\InteractsWithSockets; use Illuminate\Broadcasting\PrivateChannel; @@ -10,7 +12,7 @@ class SubscriberUnsubscribed { - use Dispatchable, InteractsWithSockets, SerializesModels; + use Dispatchable, InteractsWithSockets, SerializesModels, SendsWebhook; /** * Create a new event instance. @@ -31,4 +33,14 @@ public function broadcastOn(): array new PrivateChannel('channel-name'), ]; } + + public function getWebhookPayload(): array + { + return $this->subscriber->toArray(); + } + + public function getWebhookEventName(): WebhookEventEnum + { + return WebhookEventEnum::subscriber_unsubscribed; + } } diff --git a/src/Events/Subscribers/SubscriberVerified.php b/src/Events/Subscribers/SubscriberVerified.php index 1afbb57f..c4d932b5 100644 --- a/src/Events/Subscribers/SubscriberVerified.php +++ b/src/Events/Subscribers/SubscriberVerified.php @@ -2,6 +2,8 @@ namespace Cachet\Events\Subscribers; +use Cachet\Concerns\SendsWebhook; +use Cachet\Enums\WebhookEventEnum; use Cachet\Models\Subscriber; use Illuminate\Broadcasting\InteractsWithSockets; use Illuminate\Broadcasting\PrivateChannel; @@ -10,7 +12,7 @@ class SubscriberVerified { - use Dispatchable, InteractsWithSockets, SerializesModels; + use Dispatchable, InteractsWithSockets, SerializesModels, SendsWebhook; /** * Create a new event instance. @@ -31,4 +33,14 @@ public function broadcastOn(): array new PrivateChannel('channel-name'), ]; } + + public function getWebhookPayload(): array + { + return $this->subscriber->toArray(); + } + + public function getWebhookEventName(): WebhookEventEnum + { + return WebhookEventEnum::subscriber_verified; + } } diff --git a/src/Filament/Resources/WebhookSubscriptionResource.php b/src/Filament/Resources/WebhookSubscriptionResource.php new file mode 100644 index 00000000..e4c6614c --- /dev/null +++ b/src/Filament/Resources/WebhookSubscriptionResource.php @@ -0,0 +1,151 @@ +label(__('cachet::webhook.form.secret_label')) + ->helperText( + TextWithLink::make( + text: __('cachet::webhook.form.secret_helper'), + url: 'https://docs.cachethq.io/v3.x/guide/webhooks', + ) + ) + ->password() + ->revealable() + ->required() + ->maxLength(255) + ->columnSpanFull() + ->autocomplete(false); + } + + public static function form(Form $form): Form + { + return $form + ->schema([ + Section::make()->schema([ + Forms\Components\TextInput::make('url') + ->label(__('cachet::webhook.form.url_label')) + ->helperText(__('cachet::webhook.form.url_helper')) + ->required() + ->url() + ->maxLength(255) + ->columnSpanFull() + ->autocomplete(false), + + self::secretField() + ->visibleOn(['create']), + + Actions::make([ + Action::make('edit_secret') + ->label(__('cachet::webhook.form.edit_secret_label')) + ->modal() + ->form([ + self::secretField() + ]) + ->action(function (array $data, WebhookSubscription $webhookSubscription) { + $webhookSubscription->update($data); + }) + ->modalSubmitActionLabel(__('cachet::webhook.form.update_secret_label')) + ])->visibleOn(['edit']), + + Forms\Components\TextInput::make('description') + ->label(__('cachet::webhook.form.description_label')) + ->maxLength(255) + ->columnSpanFull() + ->autocomplete(false), + Forms\Components\Toggle::make('send_all_events') + ->label(__('cachet::webhook.form.event_selection_label')) + ->default(true) + ->inline() + ->reactive() + ->columnSpanFull(), + Forms\Components\Section::make()->columns(2)->schema([ + Forms\Components\CheckboxList::make('selected_events') + ->label(__('cachet::webhook.form.events_label')) + ->options(WebhookEventEnum::class) + ->columnSpanFull(), + ]) + ->visible(fn (Get $get) => !$get('send_all_events')), + ])->columnSpan(4), + ])->columns(4); + } + + public static function table(Table $table): Table + { + return $table + ->columns([ + Tables\Columns\TextColumn::make('url') + ->label(__('cachet::webhook.list.headers.url')) + ->searchable(), + Tables\Columns\TextColumn::make('success_rate_24h') + ->label(__('cachet::webhook.list.headers.success_rate_24h')), + ]) + ->filters([ + // + ]) + ->actions([ + Tables\Actions\EditAction::make(), + ]) + ->bulkActions([ + Tables\Actions\BulkActionGroup::make([ + Tables\Actions\DeleteBulkAction::make(), + ]), + ]); + } + + public static function getRelations(): array + { + return [ + // + ]; + } + + public static function getPages(): array + { + return [ + 'index' => Pages\ListWebhookSubscriptions::route('/'), + 'create' => Pages\CreateWebhookSubscription::route('/create'), + 'edit' => Pages\EditWebhookSubscription::route('/{record}/edit'), + ]; + } + + public static function getModelLabel(): string + { + return trans_choice('cachet::webhook.resource_label', 1); + } + + public static function getPluralModelLabel(): string + { + return trans_choice('cachet::webhook.resource_label', 2); + } +} diff --git a/src/Filament/Resources/WebhookSubscriptionResource/Pages/CreateWebhookSubscription.php b/src/Filament/Resources/WebhookSubscriptionResource/Pages/CreateWebhookSubscription.php new file mode 100644 index 00000000..e70bfe32 --- /dev/null +++ b/src/Filament/Resources/WebhookSubscriptionResource/Pages/CreateWebhookSubscription.php @@ -0,0 +1,12 @@ +dispatcher->handle($eventInstance); + } + } +} diff --git a/src/Listeners/WebhookCallEventListener.php b/src/Listeners/WebhookCallEventListener.php new file mode 100644 index 00000000..7f2929cb --- /dev/null +++ b/src/Listeners/WebhookCallEventListener.php @@ -0,0 +1,21 @@ + $event->meta['subscription_id'], + 'event' => $event->meta['event'], + 'attempt' => $event->attempt, + 'payload' => json_encode($event->payload), + 'response_code' => $event->response?->getStatusCode(), + 'transfer_time' => $event->transferStats?->getTransferTime(), + ]); + } +} diff --git a/src/Models/WebhookAttempt.php b/src/Models/WebhookAttempt.php new file mode 100644 index 00000000..4887078e --- /dev/null +++ b/src/Models/WebhookAttempt.php @@ -0,0 +1,63 @@ +subscription; + + $subscription->recalculateSuccessRate()->save(); + }); + } + + protected $casts = [ + 'payload' => 'json', + 'event' => WebhookEventEnum::class, + ]; + + public function subscription(): BelongsTo + { + return $this->belongsTo(WebhookSubscription::class, 'subscription_id'); + } + + public function isSuccess(): bool + { + return $this->response_code >= 200 && $this->response_code < 300; + } + + public function scopeWhereSuccessful(Builder $builder) + { + return $builder->where('response_code', '>=', 200)->where('response_code', '<', 300); + } + + public function prunable() + { + return self::where('created_at', '<', now()->subDays( + config('cachet.webhooks.logs.prune_logs_after_days', 30) + )); + } +} diff --git a/src/Models/WebhookSubscription.php b/src/Models/WebhookSubscription.php new file mode 100644 index 00000000..0151780c --- /dev/null +++ b/src/Models/WebhookSubscription.php @@ -0,0 +1,103 @@ + */ + use HasFactory; + + /** @var list */ + protected $fillable = [ + 'url', + 'secret', + 'description', + 'send_all_events', + 'selected_events', + ]; + + protected $hidden = [ + 'secret', + ]; + + /** + * @var array + */ + protected $casts = [ + 'selected_events' => AsEnumCollection::class . ':' . WebhookEventEnum::class, + ]; + + /** + * Scope to subscriptions that are enabled for the given event. + */ + public function scopeWhereEvent(Builder $builder, WebhookEventEnum $event) + { + return $builder->where(function ($query) use ($event) { + $query->where('send_all_events', true) + ->orWhereJsonContains('selected_events', $event->value); + }); + } + + /** + * Get the attempts for this subscription. + */ + public function attempts(): HasMany + { + return $this->hasMany(WebhookAttempt::class, 'subscription_id')->latest(); + } + + /** + * Make a webhook call to this subscriber for the given event and payload. + */ + public function makeCall(WebhookEventEnum $event, array $payload): WebhookCall + { + return WebhookCall::create() + ->url($this->url) + ->withHeaders([ + 'User-Agent' => Cachet::WEBHOOK_USER_AGENT, + ]) + ->payload([ + 'event' => $event->value, + 'body' => $payload, + ]) + ->meta([ + 'subscription_id' => $this->getKey(), + 'event' => $event->value, + ]) + ->onConnection(config('cachet.webhooks.queue.connection')) + ->onQueue(config('cachet.webhooks.queue.name')) + ->useSecret($this->secret); + } + + /** + * Formats the success rate as a percentage. + */ + public function getSuccessRate24hAttribute($value) + { + return number_format(($value ?? 0) * 100, 2) . '%'; + } + + /** + * Recalculate the success rate for the last 24 hours based on the attempts. + */ + public function recalculateSuccessRate(): self + { + $attempts24hQuery = $this->attempts()->where('created_at', '>=', now()->subDay()); + $totalAttempts24h = $attempts24hQuery->count(); + $successfulAttempts24h = $attempts24hQuery->whereSuccessful()->count(); + + $this->success_rate_24h = $totalAttempts24h > 0 ? $successfulAttempts24h / $totalAttempts24h : 0; + + return $this; + } +} diff --git a/src/View/Htmlable/TextWithLink.php b/src/View/Htmlable/TextWithLink.php new file mode 100644 index 00000000..233d3751 --- /dev/null +++ b/src/View/Htmlable/TextWithLink.php @@ -0,0 +1,33 @@ +url\" target=\"_blank\" rel=\"nofollow noopener\">$1", + $this->text, + ); + } + + public function toHtml(): string + { + return Blade::render($this->replaceAsterisksWithComponent()); + } +} diff --git a/tests/Architecture/ViewTest.php b/tests/Architecture/ViewTest.php index dd757320..fbaa141e 100644 --- a/tests/Architecture/ViewTest.php +++ b/tests/Architecture/ViewTest.php @@ -1,8 +1,14 @@ expect('Cachet\View') +test('view component test') + ->expect('Cachet\View\Components') ->toBeClasses() ->toExtend(Component::class); + +test('view htmlable test') + ->expect('Cachet\View\Htmlable') + ->toBeClasses() + ->toImplement(Htmlable::class); diff --git a/tests/Unit/Listeners/SendWebhookListenerTest.php b/tests/Unit/Listeners/SendWebhookListenerTest.php new file mode 100644 index 00000000..9d3a5498 --- /dev/null +++ b/tests/Unit/Listeners/SendWebhookListenerTest.php @@ -0,0 +1,40 @@ +makePartial(); + + $event = new class { + use SendsWebhook; + + public function getWebhookPayload(): array + { + return []; + } + + public function getWebhookEventName(): WebhookEventEnum + { + return WebhookEventEnum::component_updated; + } + }; + + app(SendWebhookListener::class)->handle($event::class, [$event]); + + $dispatchWebhooks->shouldHaveReceived('handle')->with($event); +}); + +it('will not send if the event doesn\'t implement the SendsWebhook trait', function () { + $dispatchWebhooks = mock(DispatchWebhooks::class); + + $event = new class {}; + + app(SendWebhookListener::class)->handle($event::class, [$event]); + + $dispatchWebhooks->shouldNotHaveReceived('handle'); +}); \ No newline at end of file diff --git a/tests/Unit/Models/WebhookSubscriptionTest.php b/tests/Unit/Models/WebhookSubscriptionTest.php new file mode 100644 index 00000000..57a85958 --- /dev/null +++ b/tests/Unit/Models/WebhookSubscriptionTest.php @@ -0,0 +1,23 @@ +hasAttempts(2)->create(); + + expect($subscription->attempts)->toHaveCount(2); +}); + +it('can scope based on given event', function () { + WebhookSubscription::factory()->sequence( + ['send_all_events' => true], + ['send_all_events' => false, 'selected_events' => [WebhookEventEnum::component_created]], + ['send_all_events' => false, 'selected_events' => [WebhookEventEnum::component_created, WebhookEventEnum::component_updated]], + )->count(3)->create(); + + expect(WebhookSubscription::query()->count())->toBe(3) + ->and(WebhookSubscription::query()->whereEvent(WebhookEventEnum::component_created)->count())->toBe(3) + ->and(WebhookSubscription::query()->whereEvent(WebhookEventEnum::component_updated)->count())->toBe(2) + ->and(WebhookSubscription::query()->whereEvent(WebhookEventEnum::component_status_changed)->count())->toBe(1); +});