Files
landing-api/app/Jobs/DeliverWebhook.php
Josep Piguillem 9bdbf834f6 Send signed outgoing webhooks
Fed by the records themselves, delivered only to public HTTPS addresses
checked at every attempt, retried, and switched off after repeated failures.
A submitter's details travel only to webhooks that opted into them.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-01 09:42:39 +02:00

153 lines
4.8 KiB
PHP

<?php
declare(strict_types=1);
namespace App\Jobs;
use App\Models\Webhook;
use App\Models\WebhookDelivery;
use App\Support\Webhooks\PublicEndpoint;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Http\Client\ConnectionException;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Str;
use RuntimeException;
/**
* Sends one delivery, signed, and retries it with growing pauses.
*
* The receiver checks `X-Webhook-Signature`: `sha256=` and the HMAC of
* `{timestamp}.{body}` with the webhook's secret, the timestamp being
* `X-Webhook-Timestamp` — so a captured delivery cannot be replayed later
* under a new date.
*/
class DeliverWebhook implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public int $tries = 5;
public function __construct(public WebhookDelivery $delivery) {}
/**
* @return array<int, int>
*/
public function backoff(): array
{
return [60, 300, 1800, 7200];
}
public function handle(PublicEndpoint $endpoint): void
{
$delivery = $this->delivery;
$webhook = $delivery->webhook;
if ($webhook === null || ! $webhook->is_active) {
return;
}
$delivery->increment('attempts');
$body = (string) json_encode([
'id' => $delivery->uuid,
'event' => $delivery->event,
'created_at' => $delivery->created_at?->toIso8601String(),
'data' => $delivery->payload,
], JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
[$address, $refused] = $endpoint->check($webhook->url);
// Checked again at every attempt, not only when the URL was saved: a
// name can be pointed somewhere else in between.
if ($refused !== null) {
$this->failed(new RuntimeException(__("rules.media.remote.{$refused}")));
$this->delete();
return;
}
$parts = parse_url($webhook->url);
$timestamp = (string) now()->timestamp;
try {
$response = Http::withOptions([
'allow_redirects' => false,
'connect_timeout' => 5,
'timeout' => 10,
'curl' => [CURLOPT_RESOLVE => [sprintf(
'%s:%d:%s',
trim((string) $parts['host'], '[]'),
(int) ($parts['port'] ?? 443),
str_contains((string) $address, ':') ? "[{$address}]" : $address,
)]],
])
->withHeaders([
'Content-Type' => 'application/json',
'X-Webhook-Event' => $delivery->event,
'X-Webhook-Delivery' => $delivery->uuid,
'X-Webhook-Timestamp' => $timestamp,
'X-Webhook-Signature' => 'sha256='.hash_hmac('sha256', "{$timestamp}.{$body}", (string) $webhook->secret),
])
->withUserAgent(sprintf('%s/1.0 (+%s; webhooks)', Str::slug((string) config('app.name')) ?: 'landing', rtrim((string) config('app.url'), '/')))
->withBody($body, 'application/json')
->post($webhook->url);
} catch (ConnectionException $e) {
$this->retryOrFail($e->getMessage(), null);
return;
}
if ($response->successful()) {
$delivery->update([
'status' => WebhookDelivery::SUCCEEDED,
'response_status' => $response->status(),
'error' => null,
'delivered_at' => now(),
]);
$webhook->forceFill(['consecutive_failures' => 0, 'last_delivered_at' => now()])->save();
return;
}
$this->retryOrFail(Str::limit($response->body(), 500), $response->status());
}
public function failed(?\Throwable $e): void
{
$this->delivery->update([
'status' => WebhookDelivery::FAILED,
'error' => Str::limit((string) $e?->getMessage(), 500),
]);
$webhook = $this->delivery->webhook;
if ($webhook !== null) {
$failures = $webhook->consecutive_failures + 1;
$webhook->forceFill([
'consecutive_failures' => $failures,
'is_active' => $failures < Webhook::MAX_FAILURES && $webhook->is_active,
])->save();
}
}
protected function retryOrFail(string $error, ?int $status): void
{
$this->delivery->update(['response_status' => $status, 'error' => $error]);
if ($this->attempts() < $this->tries) {
$this->release($this->backoff()[$this->attempts() - 1] ?? 7200);
return;
}
$this->failed(new RuntimeException($error));
$this->delete();
}
}