diff --git a/app/Domains/Webhook/Jobs/DeliverWebhookJob.php b/app/Domains/Webhook/Jobs/DeliverWebhookJob.php new file mode 100644 index 0000000..6377a84 --- /dev/null +++ b/app/Domains/Webhook/Jobs/DeliverWebhookJob.php @@ -0,0 +1,73 @@ + $this->event, + 'payload' => $this->payload, + 'timestamp' => now()->toISOString(), + ], JSON_THROW_ON_ERROR); + + $signature = 'sha256=' . hash_hmac('sha256', $body, $this->webhook->secret); + + $host = parse_url($this->webhook->url, PHP_URL_HOST); + if ($this->isPrivateHost($host)) { + return; + } + + $response = Http::timeout(10) + ->withHeaders([ + 'Content-Type' => 'application/json', + 'X-Nimuli-Signature' => $signature, + 'X-Nimuli-Event' => $this->event, + ]) + ->post($this->webhook->url, json_decode($body, true, 512, JSON_THROW_ON_ERROR)); + + WebhookDelivery::create([ + 'webhook_id' => $this->webhook->id, + 'event' => $this->event, + 'payload' => json_decode($body, true, 512, JSON_THROW_ON_ERROR), + 'status' => $response->successful() ? 'success' : 'failed', + 'response_code' => $response->status(), + 'attempts' => max(1, $this->attempts()), + ]); + } + + private function isPrivateHost(?string $host): bool + { + if (! $host) { + return true; + } + + $ip = gethostbyname($host); + + return filter_var( + $ip, + FILTER_VALIDATE_IP, + FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE + ) === false; + } +} diff --git a/app/Domains/Webhook/Models/Webhook.php b/app/Domains/Webhook/Models/Webhook.php new file mode 100644 index 0000000..b9a3448 --- /dev/null +++ b/app/Domains/Webhook/Models/Webhook.php @@ -0,0 +1,34 @@ + 'array', + 'is_active' => 'boolean', + ]; + + protected static function newFactory(): \Database\Factories\WebhookFactory + { + return \Database\Factories\WebhookFactory::new(); + } + + public function deliveries(): \Illuminate\Database\Eloquent\Relations\HasMany + { + return $this->hasMany(WebhookDelivery::class); + } + + public function workspace(): \Illuminate\Database\Eloquent\Relations\BelongsTo + { + return $this->belongsTo(\App\Domains\Workspace\Models\Workspace::class); + } +} diff --git a/app/Domains/Webhook/Models/WebhookDelivery.php b/app/Domains/Webhook/Models/WebhookDelivery.php new file mode 100644 index 0000000..050cf8e --- /dev/null +++ b/app/Domains/Webhook/Models/WebhookDelivery.php @@ -0,0 +1,20 @@ + 'array', + 'delivered_at' => 'datetime', + ]; + + public function webhook(): \Illuminate\Database\Eloquent\Relations\BelongsTo + { + return $this->belongsTo(Webhook::class); + } +} diff --git a/app/Domains/Webhook/Services/WebhookDispatcher.php b/app/Domains/Webhook/Services/WebhookDispatcher.php new file mode 100644 index 0000000..1cf9edf --- /dev/null +++ b/app/Domains/Webhook/Services/WebhookDispatcher.php @@ -0,0 +1,20 @@ +where('is_active', true) + ->whereJsonContains('events', $event) + ->each(function (Webhook $webhook) use ($event, $payload) { + DeliverWebhookJob::dispatch($webhook, $event, $payload) + ->onQueue('default'); + }); + } +} diff --git a/database/factories/WebhookFactory.php b/database/factories/WebhookFactory.php new file mode 100644 index 0000000..8c7ea2b --- /dev/null +++ b/database/factories/WebhookFactory.php @@ -0,0 +1,27 @@ +create(); + $workspace = (new \App\Domains\Workspace\Actions\CreateWorkspace)->handle($user, ['name' => 'Test']); + + return [ + 'workspace_id' => $workspace->id, + 'url' => $this->faker->url(), + 'secret' => $this->faker->sha256(), + 'events' => ['click.recorded'], + 'is_active' => true, + ]; + } +} diff --git a/tests/Feature/Webhook/WebhookDispatchTest.php b/tests/Feature/Webhook/WebhookDispatchTest.php new file mode 100644 index 0000000..9c46da3 --- /dev/null +++ b/tests/Feature/Webhook/WebhookDispatchTest.php @@ -0,0 +1,25 @@ + Http::response('ok', 200)]); + + $webhook = Webhook::factory()->create([ + 'url' => 'https://example.com/hook', + 'secret' => 'mysecret', + 'events' => ['click.recorded'], + 'is_active' => true, + ]); + + // Run the job synchronously to test HTTP call + $job = new DeliverWebhookJob($webhook, 'click.recorded', ['link_id' => 1]); + $job->handle(); + + Http::assertSent(function ($request) { + return $request->hasHeader('X-Nimuli-Signature') + && str_starts_with($request->header('X-Nimuli-Signature')[0], 'sha256='); + }); +});