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>
This commit is contained in:
146
app/Http/Controllers/Api/V1/WebhookController.php
Normal file
146
app/Http/Controllers/Api/V1/WebhookController.php
Normal file
@@ -0,0 +1,146 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Controllers\Api\V1;
|
||||
|
||||
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 Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
/**
|
||||
* Outgoing webhooks: where to send, which events, and how it has gone.
|
||||
*/
|
||||
class WebhookController extends Controller
|
||||
{
|
||||
public function index(Request $request): AnonymousResourceCollection
|
||||
{
|
||||
$this->authorize('viewAny', Webhook::class);
|
||||
|
||||
return WebhookResource::collection(
|
||||
Webhook::query()->latest('id')->paginate(perPage: min($request->integer('per_page', 25), 100)),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The secret is in the answer once, when it is made (or rotated), and
|
||||
* never again.
|
||||
*/
|
||||
public function store(WebhookRequest $request): JsonResponse
|
||||
{
|
||||
$this->authorize('create', Webhook::class);
|
||||
|
||||
$secret = $request->input('secret') ?: Str::random(40);
|
||||
|
||||
$webhook = new Webhook([...$request->validated(), 'secret' => $secret]);
|
||||
$webhook->forceFill(['created_by' => $request->user()->id])->save();
|
||||
|
||||
return WebhookResource::make($webhook->refresh())
|
||||
->additional(['secret' => $secret])
|
||||
->response()
|
||||
->setStatusCode(201);
|
||||
}
|
||||
|
||||
public function show(Webhook $webhook): WebhookResource
|
||||
{
|
||||
$this->authorize('view', $webhook);
|
||||
|
||||
return WebhookResource::make($webhook);
|
||||
}
|
||||
|
||||
/**
|
||||
* Switching it back on starts its failure count again.
|
||||
*/
|
||||
public function update(WebhookRequest $request, Webhook $webhook): JsonResponse
|
||||
{
|
||||
$this->authorize('update', $webhook);
|
||||
|
||||
$data = $request->validated();
|
||||
$rotated = $request->boolean('rotate_secret') ? Str::random(40) : null;
|
||||
unset($data['rotate_secret']);
|
||||
|
||||
$webhook->fill($data);
|
||||
|
||||
if ($rotated !== null) {
|
||||
$webhook->secret = $rotated;
|
||||
}
|
||||
|
||||
if ($webhook->isDirty('is_active') && $webhook->is_active) {
|
||||
$webhook->consecutive_failures = 0;
|
||||
}
|
||||
|
||||
$webhook->save();
|
||||
|
||||
return WebhookResource::make($webhook)
|
||||
->additional($rotated !== null ? ['secret' => $rotated] : [])
|
||||
->response();
|
||||
}
|
||||
|
||||
public function destroy(Webhook $webhook): JsonResponse
|
||||
{
|
||||
$this->authorize('delete', $webhook);
|
||||
|
||||
$webhook->delete();
|
||||
|
||||
return response()->json(status: 204);
|
||||
}
|
||||
|
||||
/**
|
||||
* Newest first. Filter: status.
|
||||
*/
|
||||
public function deliveries(Request $request, Webhook $webhook): AnonymousResourceCollection
|
||||
{
|
||||
$this->authorize('view', $webhook);
|
||||
|
||||
return WebhookDeliveryResource::collection(
|
||||
$webhook->deliveries()
|
||||
->when($request->filled('status'), fn ($q) => $q->where('status', $request->string('status')))
|
||||
->paginate(perPage: min($request->integer('per_page', 25), 100))
|
||||
->withQueryString(),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* A `ping`, to check the receiving end before anything real happens.
|
||||
*/
|
||||
public function ping(Webhook $webhook): JsonResponse
|
||||
{
|
||||
$this->authorize('update', $webhook);
|
||||
|
||||
$delivery = $webhook->deliveries()->create([
|
||||
'uuid' => (string) Str::uuid(),
|
||||
'event' => 'ping',
|
||||
'payload' => ['webhook' => $webhook->id, 'events' => $webhook->events],
|
||||
]);
|
||||
|
||||
DeliverWebhook::dispatch($delivery);
|
||||
|
||||
return WebhookDeliveryResource::make($delivery->refresh())->response()->setStatusCode(202);
|
||||
}
|
||||
|
||||
/**
|
||||
* Send one again, as a new delivery of the same event and payload.
|
||||
*/
|
||||
public function redeliver(WebhookDelivery $delivery): JsonResponse
|
||||
{
|
||||
$this->authorize('update', $delivery->webhook);
|
||||
|
||||
$again = $delivery->webhook->deliveries()->create([
|
||||
'uuid' => (string) Str::uuid(),
|
||||
'event' => $delivery->event,
|
||||
'payload' => $delivery->payload,
|
||||
]);
|
||||
|
||||
DeliverWebhook::dispatch($again);
|
||||
|
||||
return WebhookDeliveryResource::make($again->refresh())->response()->setStatusCode(202);
|
||||
}
|
||||
}
|
||||
53
app/Http/Requests/Api/V1/WebhookRequest.php
Normal file
53
app/Http/Requests/Api/V1/WebhookRequest.php
Normal file
@@ -0,0 +1,53 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Requests\Api\V1;
|
||||
|
||||
use App\Models\Webhook;
|
||||
use App\Support\Webhooks\PublicEndpoint;
|
||||
use Closure;
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
use Illuminate\Validation\Rule;
|
||||
|
||||
class WebhookRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function rules(): array
|
||||
{
|
||||
$creating = ! $this->route('webhook') instanceof Webhook;
|
||||
$sometimes = $creating ? 'required' : 'sometimes';
|
||||
|
||||
return [
|
||||
'name' => [$sometimes, 'string', 'max:120'],
|
||||
'url' => [
|
||||
$sometimes,
|
||||
'string',
|
||||
'url',
|
||||
'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}"));
|
||||
}
|
||||
},
|
||||
],
|
||||
'events' => [$sometimes, 'array', 'min:1'],
|
||||
'events.*' => ['string', 'distinct', Rule::in(['*', ...Webhook::EVENTS])],
|
||||
'secret' => [$creating ? 'sometimes' : 'prohibited', 'nullable', 'string', 'min:16', 'max:255'],
|
||||
'rotate_secret' => [$creating ? 'prohibited' : 'sometimes', 'boolean'],
|
||||
'include_personal_data' => ['sometimes', 'boolean'],
|
||||
'is_active' => ['sometimes', 'boolean'],
|
||||
];
|
||||
}
|
||||
}
|
||||
34
app/Http/Resources/WebhookDeliveryResource.php
Normal file
34
app/Http/Resources/WebhookDeliveryResource.php
Normal file
@@ -0,0 +1,34 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Resources;
|
||||
|
||||
use App\Models\WebhookDelivery;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Resources\Json\JsonResource;
|
||||
|
||||
/**
|
||||
* @mixin WebhookDelivery
|
||||
*/
|
||||
class WebhookDeliveryResource extends JsonResource
|
||||
{
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function toArray(Request $request): array
|
||||
{
|
||||
return [
|
||||
'id' => $this->id,
|
||||
'uuid' => $this->uuid,
|
||||
'event' => $this->event,
|
||||
'status' => $this->status,
|
||||
'attempts' => (int) $this->attempts,
|
||||
'response_status' => $this->response_status,
|
||||
'error' => $this->error,
|
||||
'payload' => (object) ($this->payload ?? []),
|
||||
'delivered_at' => $this->delivered_at?->toIso8601String(),
|
||||
'created_at' => $this->created_at?->toIso8601String(),
|
||||
];
|
||||
}
|
||||
}
|
||||
36
app/Http/Resources/WebhookResource.php
Normal file
36
app/Http/Resources/WebhookResource.php
Normal file
@@ -0,0 +1,36 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Resources;
|
||||
|
||||
use App\Models\Webhook;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Resources\Json\JsonResource;
|
||||
|
||||
/**
|
||||
* Never the secret.
|
||||
*
|
||||
* @mixin Webhook
|
||||
*/
|
||||
class WebhookResource extends JsonResource
|
||||
{
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function toArray(Request $request): array
|
||||
{
|
||||
return [
|
||||
'id' => $this->id,
|
||||
'name' => $this->name,
|
||||
'url' => $this->url,
|
||||
'events' => $this->events ?? [],
|
||||
'include_personal_data' => (bool) $this->include_personal_data,
|
||||
'is_active' => (bool) $this->is_active,
|
||||
'consecutive_failures' => (int) $this->consecutive_failures,
|
||||
'last_delivered_at' => $this->last_delivered_at?->toIso8601String(),
|
||||
'created_at' => $this->created_at?->toIso8601String(),
|
||||
'updated_at' => $this->updated_at?->toIso8601String(),
|
||||
];
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user