diff --git a/README.md b/README.md index 612ae0e..57829e8 100644 --- a/README.md +++ b/README.md @@ -1373,6 +1373,35 @@ Para que eso funcione, la comprobación de permisos es ahora una sola regla en (`register_permission_check_method => false`): la de spatie respondía antes y concedía el permiso sin preguntar al token. +### Webhooks salientes + +``` + /api/v1/webhooks CRUD (permiso webhooks.*, solo admin) +GET /api/v1/webhooks/{id}/deliveries qué se envió y cómo fue +POST /api/v1/webhooks/{id}/ping envío de prueba +POST /api/v1/webhook-deliveries/{id}/redeliver +``` + +Avisan a otro sistema de `application.received`, `content.published`, +`content.unpublished`, `review.requested` y `review.returned` (o `*`). Salen de +los propios registros —el historial ya distingue publicar de editar, venga del +panel, de la API o de un lote— y no de los controladores, así que no hay forma +de publicar algo sin que se avise. + +Cada envío es un POST JSON `{id, event, created_at, data}` firmado: la cabecera +`X-Webhook-Signature` es `sha256=` más el HMAC-SHA256 de `{timestamp}.{body}` +con el secreto, y `X-Webhook-Timestamp` va aparte para que un envío capturado +no se pueda repetir más tarde. El secreto se genera solo (o se rota con +`rotate_secret`), se guarda cifrado y sale una única vez. + +Las mismas defensas que el descargador de imágenes, porque la petición sale +desde dentro del stack: solo HTTPS, solo direcciones públicas —comprobado al +guardar y **otra vez en cada envío**, fijando la conexión a la dirección +comprobada—, sin seguir redirecciones. Cinco reintentos en unas dos horas y +media; veinte fallos seguidos lo apagan hasta que alguien lo vuelve a activar. +Los datos de quien escribe al buzón (nombre, correo, mensaje) solo viajan a los +webhooks con `include_personal_data`. + ### Lotes e importación ``` diff --git a/app/Http/Controllers/Api/V1/WebhookController.php b/app/Http/Controllers/Api/V1/WebhookController.php new file mode 100644 index 0000000..cdd2e5a --- /dev/null +++ b/app/Http/Controllers/Api/V1/WebhookController.php @@ -0,0 +1,146 @@ +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); + } +} diff --git a/app/Http/Requests/Api/V1/WebhookRequest.php b/app/Http/Requests/Api/V1/WebhookRequest.php new file mode 100644 index 0000000..22a8569 --- /dev/null +++ b/app/Http/Requests/Api/V1/WebhookRequest.php @@ -0,0 +1,53 @@ + + */ + 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'], + ]; + } +} diff --git a/app/Http/Resources/WebhookDeliveryResource.php b/app/Http/Resources/WebhookDeliveryResource.php new file mode 100644 index 0000000..123a3d7 --- /dev/null +++ b/app/Http/Resources/WebhookDeliveryResource.php @@ -0,0 +1,34 @@ + + */ + 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(), + ]; + } +} diff --git a/app/Http/Resources/WebhookResource.php b/app/Http/Resources/WebhookResource.php new file mode 100644 index 0000000..5f74af5 --- /dev/null +++ b/app/Http/Resources/WebhookResource.php @@ -0,0 +1,36 @@ + + */ + 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(), + ]; + } +} diff --git a/app/Jobs/DeliverWebhook.php b/app/Jobs/DeliverWebhook.php new file mode 100644 index 0000000..e8c13f9 --- /dev/null +++ b/app/Jobs/DeliverWebhook.php @@ -0,0 +1,152 @@ + + */ + 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(); + } +} diff --git a/app/Models/Webhook.php b/app/Models/Webhook.php new file mode 100644 index 0000000..fe1a440 --- /dev/null +++ b/app/Models/Webhook.php @@ -0,0 +1,80 @@ + false, + 'is_active' => true, + ]; + + protected function casts(): array + { + return [ + 'secret' => 'encrypted', + 'events' => 'array', + 'include_personal_data' => 'boolean', + 'is_active' => 'boolean', + 'last_delivered_at' => 'datetime', + ]; + } + + /** + * @return HasMany + */ + public function deliveries(): HasMany + { + return $this->hasMany(WebhookDelivery::class)->latest('id'); + } + + public function listensTo(string $event): bool + { + $events = $this->events ?? []; + + return in_array('*', $events, true) || in_array($event, $events, true); + } + + /** + * @param Builder<$this> $query + * @return Builder<$this> + */ + public function scopeActive(Builder $query): Builder + { + return $query->where('is_active', true); + } +} diff --git a/app/Models/WebhookDelivery.php b/app/Models/WebhookDelivery.php new file mode 100644 index 0000000..7571850 --- /dev/null +++ b/app/Models/WebhookDelivery.php @@ -0,0 +1,40 @@ + 'array', + 'attempts' => 'integer', + 'response_status' => 'integer', + 'delivered_at' => 'datetime', + ]; + } + + /** + * @return BelongsTo + */ + public function webhook(): BelongsTo + { + return $this->belongsTo(Webhook::class); + } +} diff --git a/app/Policies/WebhookPolicy.php b/app/Policies/WebhookPolicy.php new file mode 100644 index 0000000..1221c46 --- /dev/null +++ b/app/Policies/WebhookPolicy.php @@ -0,0 +1,13 @@ + Webhooks::applicationReceived($application)); + Revision::created(fn (Revision $revision) => Webhooks::revisionRecorded($revision)); + ReviewComment::created(fn (ReviewComment $comment) => Webhooks::reviewRecorded($comment)); + // Backs the "api" guard declared in config/auth.php. Auth::viaRequest('pireon', fn (Request $request) => $this->app ->make(PireonTokenGuard::class)($request)); diff --git a/app/Providers/AuthServiceProvider.php b/app/Providers/AuthServiceProvider.php index 34d8edf..2b50eb5 100644 --- a/app/Providers/AuthServiceProvider.php +++ b/app/Providers/AuthServiceProvider.php @@ -20,6 +20,7 @@ use App\Models\Talk; use App\Models\Technology; use App\Models\Testimonial; use App\Models\User; +use App\Models\Webhook; use App\Policies\ApplicationPolicy; use App\Policies\CallPolicy; use App\Policies\CategoryPolicy; @@ -37,6 +38,7 @@ use App\Policies\TalkPolicy; use App\Policies\TechnologyPolicy; use App\Policies\TestimonialPolicy; use App\Policies\UserPolicy; +use App\Policies\WebhookPolicy; use App\Support\Permissions; use Illuminate\Support\Facades\Gate; use Illuminate\Support\ServiceProvider; @@ -63,6 +65,7 @@ class AuthServiceProvider extends ServiceProvider Gate::policy(Repository::class, RepositoryPolicy::class); Gate::policy(User::class, UserPolicy::class); Gate::policy(Role::class, RolePolicy::class); + Gate::policy(Webhook::class, WebhookPolicy::class); // The one place a permission is granted — spatie's own gate check is // switched off (config/permission.php) so nothing answers before this. diff --git a/app/Support/Permissions.php b/app/Support/Permissions.php index 01bcfbe..a29c1b7 100644 --- a/app/Support/Permissions.php +++ b/app/Support/Permissions.php @@ -73,6 +73,10 @@ final class Permissions // the provider's rate limit, which is a shared resource. 'repositories' => ['view_any', 'view', 'create', 'update', 'delete', 'sync'], + // Where the site tells other systems what happened. A webhook sends + // data off this server, so it is an administrator's decision. + 'webhooks' => ['view_any', 'view', 'create', 'update', 'delete'], + 'users' => ['view_any', 'view', 'create', 'update', 'delete'], 'roles' => ['view_any', 'view', 'create', 'update', 'delete'], diff --git a/app/Support/Webhooks/PublicEndpoint.php b/app/Support/Webhooks/PublicEndpoint.php new file mode 100644 index 0000000..14a3aaa --- /dev/null +++ b/app/Support/Webhooks/PublicEndpoint.php @@ -0,0 +1,52 @@ +resolver->resolve($host); + + if ($addresses === []) { + return [null, 'unresolvable']; + } + + foreach ($addresses as $address) { + if (! filter_var($address, FILTER_VALIDATE_IP, FILTER_FLAG_GLOBAL_RANGE)) { + return [null, 'private']; + } + } + + return [$addresses[0], null]; + } +} diff --git a/app/Support/Webhooks/Webhooks.php b/app/Support/Webhooks/Webhooks.php new file mode 100644 index 0000000..5edd44a --- /dev/null +++ b/app/Support/Webhooks/Webhooks.php @@ -0,0 +1,127 @@ + $data + * @param array $personal Sent only to webhooks that opted into personal data. + */ + public static function dispatch(string $event, array $data, array $personal = []): void + { + $webhooks = Webhook::query()->active()->get()->filter(fn (Webhook $w) => $w->listensTo($event)); + + foreach ($webhooks as $webhook) { + $delivery = $webhook->deliveries()->create([ + 'uuid' => (string) Str::uuid(), + 'event' => $event, + 'payload' => $webhook->include_personal_data ? [...$data, ...$personal] : $data, + ]); + + DeliverWebhook::dispatch($delivery)->afterCommit(); + } + } + + public static function applicationReceived(Application $application): void + { + self::dispatch('application.received', [ + 'id' => $application->id, + 'kind' => $application->kind->value, + 'call_id' => $application->call_id, + 'subject' => $application->subject(), + 'locale' => $application->locale, + 'created_at' => $application->created_at?->toIso8601String(), + ], [ + 'name' => $application->name, + 'email' => $application->email, + 'organisation' => $application->organisation, + 'message' => $application->message, + ]); + } + + /** + * Publishing and unpublishing are already told apart by the history, for + * every kind of record and from wherever it happened; this listens there. + */ + public static function revisionRecorded(Revision $revision): void + { + $model = Relation::getMorphedModel($revision->revisionable_type) ?? $revision->revisionable_type; + $record = $model::query()->find($revision->revisionable_id); + + if ($record === null) { + return; + } + + $event = match (true) { + $revision->event === 'published' => 'content.published', + $revision->event === 'unpublished' => 'content.unpublished', + $revision->event === 'created' && method_exists($record, 'isPublishedNow') && $record->isPublishedNow() => 'content.published', + default => null, + }; + + if ($event !== null) { + self::dispatch($event, self::describe($record)); + } + } + + public static function reviewRecorded(ReviewComment $comment): void + { + $event = match ($comment->event->value) { + 'requested' => 'review.requested', + 'returned' => 'review.returned', + default => null, + }; + + $record = $comment->reviewable; + + if ($event === null || $record === null) { + return; + } + + self::dispatch($event, [ + ...self::describe($record), + 'by' => $comment->user_name, + 'note' => $comment->body, + ]); + } + + /** + * @return array + */ + protected static function describe(Model $record): array + { + $type = AdminContent::nameOf($record); + $siteType = AdminContent::PREVIEWABLE[$type] ?? null; + $locale = (string) config('locales.default'); + + return [ + 'type' => $type, + 'id' => $record->getKey(), + 'title' => ContentTypes::title($record), + 'url' => $siteType && method_exists($record, 'getTranslation') + ? (SiteUrls::absoluteAlternates($record, $siteType)[$locale] ?? null) + : null, + ]; + } +} diff --git a/database/factories/WebhookFactory.php b/database/factories/WebhookFactory.php new file mode 100644 index 0000000..1ea33fd --- /dev/null +++ b/database/factories/WebhookFactory.php @@ -0,0 +1,27 @@ + + */ +class WebhookFactory extends Factory +{ + public function definition(): array + { + return [ + 'name' => $this->faker->words(2, true), + 'url' => 'https://hooks.example.org/'.Str::random(8), + 'secret' => Str::random(40), + 'events' => ['*'], + 'include_personal_data' => false, + 'is_active' => true, + ]; + } +} diff --git a/database/migrations/2026_10_01_100000_create_webhooks_tables.php b/database/migrations/2026_10_01_100000_create_webhooks_tables.php new file mode 100644 index 0000000..211bd74 --- /dev/null +++ b/database/migrations/2026_10_01_100000_create_webhooks_tables.php @@ -0,0 +1,60 @@ +id(); + $table->string('name'); + $table->string('url', 2048); + + // Signs every delivery. Encrypted at rest: it is a credential. + $table->text('secret'); + + $table->jsonb('events'); + + // Submissions carry names, addresses and messages from people + // outside the collective; sending them elsewhere is opted into. + $table->boolean('include_personal_data')->default(false); + + $table->boolean('is_active')->default(true); + $table->unsignedSmallInteger('consecutive_failures')->default(0); + $table->timestamp('last_delivered_at')->nullable(); + $table->foreignId('created_by')->nullable()->constrained('users')->nullOnDelete(); + $table->timestamps(); + }); + + Schema::create('webhook_deliveries', function (Blueprint $table): void { + $table->id(); + $table->foreignId('webhook_id')->constrained()->cascadeOnDelete(); + $table->uuid('uuid')->unique(); + $table->string('event', 60); + $table->jsonb('payload'); + $table->string('status', 20)->default('pending'); + $table->unsignedSmallInteger('attempts')->default(0); + $table->unsignedSmallInteger('response_status')->nullable(); + $table->text('error')->nullable(); + $table->timestamp('delivered_at')->nullable(); + $table->timestamps(); + + $table->index(['webhook_id', 'created_at']); + }); + } + + public function down(): void + { + Schema::dropIfExists('webhook_deliveries'); + Schema::dropIfExists('webhooks'); + } +}; diff --git a/lang/ca/panel.php b/lang/ca/panel.php index dece882..f8bdc99 100644 --- a/lang/ca/panel.php +++ b/lang/ca/panel.php @@ -161,6 +161,7 @@ return [ 'redirects' => 'Redireccions', 'pages' => 'Pàgines', 'categories' => 'Categories', + 'webhooks' => 'Webhooks', 'users' => 'Usuaris', 'roles' => 'Rols', 'settings' => 'Configuració', diff --git a/lang/en/panel.php b/lang/en/panel.php index d12fca1..1024828 100644 --- a/lang/en/panel.php +++ b/lang/en/panel.php @@ -161,6 +161,7 @@ return [ 'redirects' => 'Redirects', 'pages' => 'Pages', 'categories' => 'Categories', + 'webhooks' => 'Webhooks', 'users' => 'Users', 'roles' => 'Roles', 'settings' => 'Settings', diff --git a/lang/es/panel.php b/lang/es/panel.php index 2ea16fa..9277939 100644 --- a/lang/es/panel.php +++ b/lang/es/panel.php @@ -161,6 +161,7 @@ return [ 'redirects' => 'Redirecciones', 'pages' => 'Páginas', 'categories' => 'Categorías', + 'webhooks' => 'Webhooks', 'users' => 'Usuarios', 'roles' => 'Roles', 'settings' => 'Ajustes', diff --git a/public/openapi.yaml b/public/openapi.yaml index 0146a98..d78361d 100644 --- a/public/openapi.yaml +++ b/public/openapi.yaml @@ -144,6 +144,8 @@ tags: description: What is wrong with the site, and what is left to translate - name: Batch description: Several operations in one, and importing records from JSON or CSV + - name: Webhooks + description: Telling other systems what happened here - name: Users description: Local user administration - name: Media @@ -3812,6 +3814,167 @@ paths: "403": { $ref: "#/components/responses/Forbidden" } "422": { $ref: "#/components/responses/ValidationError" } + /v1/webhooks: + get: + tags: [Webhooks] + summary: List webhooks + parameters: + - $ref: "#/components/parameters/PerPage" + responses: + "200": + description: Paginated webhooks + content: + application/json: + schema: { $ref: "#/components/schemas/WebhookCollection" } + "403": { $ref: "#/components/responses/Forbidden" } + post: + tags: [Webhooks] + summary: Create a webhook + description: | + Somewhere to POST to when something happens here. HTTPS to a public + address only — checked now and again at every delivery — and redirects are + not followed. The `secret` signing the deliveries is generated unless sent, + and is in this answer's `secret` once, never again. + + Each delivery is a JSON body `{id, event, created_at, data}` with headers + `X-Webhook-Event`, `X-Webhook-Delivery`, `X-Webhook-Timestamp` and + `X-Webhook-Signature`: `sha256=` and the HMAC-SHA256 of + `{timestamp}.{body}` with the secret. Failed deliveries are retried five + times over about two and a half hours; twenty failures in a row switch the + webhook off. + + Events: `application.received` (its sender's details only with + `include_personal_data`), `content.published`, `content.unpublished`, + `review.requested`, `review.returned` — or `*`. + requestBody: + required: true + content: + application/json: + schema: { $ref: "#/components/schemas/WebhookWrite" } + responses: + "201": + description: Webhook created + content: + application/json: + schema: + type: object + properties: + data: { $ref: "#/components/schemas/Webhook" } + "403": { $ref: "#/components/responses/Forbidden" } + "422": { $ref: "#/components/responses/ValidationError" } + + /v1/webhooks/{webhook}: + parameters: + - name: webhook + in: path + required: true + schema: { type: integer } + get: + tags: [Webhooks] + summary: Show a webhook + responses: + "200": + description: The webhook + content: + application/json: + schema: + type: object + properties: + data: { $ref: "#/components/schemas/Webhook" } + "403": { $ref: "#/components/responses/Forbidden" } + "404": { description: Not found } + patch: + tags: [Webhooks] + summary: Update a webhook + description: "Partial update. `rotate_secret` makes a new secret, in this answer's `secret` once. Switching it back on resets its failure count." + requestBody: + required: true + content: + application/json: + schema: { $ref: "#/components/schemas/WebhookWrite" } + responses: + "200": + description: Webhook updated + content: + application/json: + schema: + type: object + properties: + data: { $ref: "#/components/schemas/Webhook" } + "403": { $ref: "#/components/responses/Forbidden" } + "422": { $ref: "#/components/responses/ValidationError" } + delete: + tags: [Webhooks] + summary: Delete a webhook + responses: + "204": { description: Deleted } + "403": { $ref: "#/components/responses/Forbidden" } + + /v1/webhooks/{webhook}/deliveries: + get: + tags: [Webhooks] + summary: Deliveries of a webhook + description: Newest first. + parameters: + - name: webhook + in: path + required: true + schema: { type: integer } + - $ref: "#/components/parameters/PerPage" + - name: status + in: query + description: "`pending`, `succeeded` or `failed`." + schema: { type: string } + responses: + "200": + description: Paginated deliveries + content: + application/json: + schema: { $ref: "#/components/schemas/WebhookDeliveryCollection" } + "403": { $ref: "#/components/responses/Forbidden" } + + /v1/webhooks/{webhook}/ping: + post: + tags: [Webhooks] + summary: Send a test delivery + description: A `ping` event, to check the receiving end. + parameters: + - name: webhook + in: path + required: true + schema: { type: integer } + responses: + "202": + description: Ping queued + content: + application/json: + schema: + type: object + properties: + data: { $ref: "#/components/schemas/WebhookDelivery" } + "403": { $ref: "#/components/responses/Forbidden" } + + /v1/webhook-deliveries/{delivery}/redeliver: + post: + tags: [Webhooks] + summary: Send a delivery again + description: As a new delivery of the same event and payload. + parameters: + - name: delivery + in: path + required: true + schema: { type: integer } + responses: + "202": + description: Delivery queued + content: + application/json: + schema: + type: object + properties: + data: { $ref: "#/components/schemas/WebhookDelivery" } + "403": { $ref: "#/components/responses/Forbidden" } + /v1/media: get: tags: [Media] @@ -5640,6 +5803,66 @@ components: links: { $ref: "#/components/schemas/PaginationLinks" } meta: { $ref: "#/components/schemas/PaginationMeta" } + Webhook: + type: object + properties: + id: { type: integer } + name: { type: string } + url: { type: string, format: uri } + events: { type: array, items: { type: string } } + include_personal_data: { type: boolean } + is_active: { type: boolean } + consecutive_failures: { type: integer } + last_delivered_at: { type: string, format: date-time, nullable: true } + created_at: { type: string, format: date-time } + updated_at: { type: string, format: date-time } + + WebhookWrite: + type: object + required: [name, url, events] + properties: + name: { type: string, maxLength: 120 } + url: { type: string, format: uri, description: "HTTPS, to a public address." } + events: + type: array + items: { type: string, enum: ["*", application.received, content.published, content.unpublished, review.requested, review.returned] } + secret: { type: string, minLength: 16, description: On creation only; generated when left out. } + rotate_secret: { type: boolean, description: On update only. } + include_personal_data: { type: boolean, default: false } + is_active: { type: boolean } + + WebhookCollection: + type: object + properties: + data: + type: array + items: { $ref: "#/components/schemas/Webhook" } + links: { $ref: "#/components/schemas/PaginationLinks" } + meta: { $ref: "#/components/schemas/PaginationMeta" } + + WebhookDelivery: + type: object + properties: + id: { type: integer } + uuid: { type: string, description: "Sent as `X-Webhook-Delivery`." } + event: { type: string } + status: { type: string, enum: [pending, succeeded, failed] } + attempts: { type: integer } + response_status: { type: integer, nullable: true } + error: { type: string, nullable: true } + payload: { type: object, additionalProperties: true } + delivered_at: { type: string, format: date-time, nullable: true } + created_at: { type: string, format: date-time } + + WebhookDeliveryCollection: + type: object + properties: + data: + type: array + items: { $ref: "#/components/schemas/WebhookDelivery" } + links: { $ref: "#/components/schemas/PaginationLinks" } + meta: { $ref: "#/components/schemas/PaginationMeta" } + MediaFor: type: string enum: [pages, technologies, projects, members, clients, testimonials] diff --git a/routes/api.php b/routes/api.php index 331442b..f41fe91 100644 --- a/routes/api.php +++ b/routes/api.php @@ -38,6 +38,7 @@ use App\Http\Controllers\Api\V1\TechnologyController; use App\Http\Controllers\Api\V1\TestimonialController; use App\Http\Controllers\Api\V1\TokenController; use App\Http\Controllers\Api\V1\UserController; +use App\Http\Controllers\Api\V1\WebhookController; use App\Support\AdminContent; use Illuminate\Support\Facades\Route; @@ -260,6 +261,12 @@ Route::prefix('v1')->name('api.v1.')->group(function () { ->whereIn('resource', array_keys(AdminContent::WITH_HISTORY)) ->name('import'); + // Telling other systems what happened here. + Route::apiResource('webhooks', WebhookController::class); + Route::get('webhooks/{webhook}/deliveries', [WebhookController::class, 'deliveries'])->name('webhooks.deliveries'); + Route::post('webhooks/{webhook}/ping', [WebhookController::class, 'ping'])->name('webhooks.ping'); + Route::post('webhook-deliveries/{delivery}/redeliver', [WebhookController::class, 'redeliver'])->name('webhook-deliveries.redeliver'); + // The panel's health screen, and what is left to translate. Route::get('health', [HealthController::class, 'index'])->name('health'); Route::get('translations/pending', [HealthController::class, 'translations'])->name('translations.pending'); diff --git a/tests/Feature/Api/AdminApiContractTest.php b/tests/Feature/Api/AdminApiContractTest.php index 1f7c445..3ffad90 100644 --- a/tests/Feature/Api/AdminApiContractTest.php +++ b/tests/Feature/Api/AdminApiContractTest.php @@ -22,6 +22,7 @@ use App\Models\Talk; use App\Models\Technology; use App\Models\Testimonial; use App\Models\User; +use App\Models\Webhook; use App\Support\Maintenance; use Database\Seeders\RolePermissionSeeder; use Illuminate\Foundation\Testing\RefreshDatabase; @@ -94,6 +95,7 @@ class AdminApiContractTest extends TestCase 'repositories' => ['path' => '/v1/repositories'], 'media' => ['path' => '/v1/media'], 'tokens' => ['path' => '/v1/tokens'], + 'webhooks' => ['path' => '/v1/webhooks'], ]; } @@ -118,6 +120,8 @@ class AdminApiContractTest extends TestCase $media = Media::factory()->create(); $page->comment('Primera nota', $this->caller); ApiToken::issue($this->caller, 'CI', ['pages.*'], now()->addDay()); + $webhook = Webhook::factory()->create(['is_active' => false]); + $webhook->deliveries()->create(['uuid' => 'contract', 'event' => 'ping', 'payload' => []]); // One request, one documented path — the URL a real caller hits and // the path key that describes it, side by side rather than derived @@ -159,6 +163,9 @@ class AdminApiContractTest extends TestCase 'medium' => ["/v1/media/{$media->id}", '/v1/media/{media}'], 'revisions' => ["/v1/projects/{$project->id}/revisions", '/v1/projects/{project}/revisions'], 'tokens' => ['/v1/tokens', '/v1/tokens'], + 'webhooks' => ['/v1/webhooks', '/v1/webhooks'], + 'webhook' => ["/v1/webhooks/{$webhook->id}", '/v1/webhooks/{webhook}'], + 'deliveries' => ["/v1/webhooks/{$webhook->id}/deliveries", '/v1/webhooks/{webhook}/deliveries'], 'review thread' => ["/v1/pages/{$page->id}/reviews", '/v1/pages/{page}/reviews'], 'users' => ['/v1/users', '/v1/users'], 'user' => ["/v1/users/{$this->caller->id}", '/v1/users/{user}'], diff --git a/tests/Feature/Api/WebhookTest.php b/tests/Feature/Api/WebhookTest.php new file mode 100644 index 0000000..2ec8050 --- /dev/null +++ b/tests/Feature/Api/WebhookTest.php @@ -0,0 +1,219 @@ +seed(RolePermissionSeeder::class); + + $this->resolving([ + 'hooks.example.org' => ['93.184.216.34'], + 'internal.example.org' => ['10.0.0.5'], + ]); + } + + protected function actingWithRole(string $role): User + { + $user = User::factory()->create(); + $user->syncRoles([$role]); + + $this->withHeaders($this->apiHeadersFor($user)); + + return $user; + } + + /** + * @param array> $answers + */ + protected function resolving(array $answers): void + { + $this->app->instance(HostResolver::class, new class($answers) extends HostResolver + { + /** @param array> $answers */ + public function __construct(private array $answers) {} + + public function resolve(string $host): array + { + return $this->answers[$host] ?? []; + } + }); + } + + #[Test] + public function an_admin_registers_a_webhook_and_sees_its_secret_once(): void + { + $this->actingWithRole('admin'); + + $response = $this->postJson('/api/v1/webhooks', [ + 'name' => 'Avisos', + 'url' => 'https://hooks.example.org/pireon', + 'events' => ['application.received', 'content.published'], + ]) + ->assertCreated() + ->assertJsonPath('data.is_active', true) + ->assertJsonMissingPath('data.secret'); + + $this->assertSame(40, strlen($response->json('secret'))); + $this->assertSame($response->json('secret'), Webhook::query()->sole()->secret); + + $this->getJson('/api/v1/webhooks')->assertJsonMissingPath('data.0.secret'); + } + + #[Test] + public function a_webhook_only_points_at_public_https_addresses(): void + { + $this->actingWithRole('admin'); + + foreach (['http://hooks.example.org/x', 'https://internal.example.org/x', 'https://127.0.0.1/x', 'https://nowhere.example.org/x'] as $url) { + $this->postJson('/api/v1/webhooks', ['name' => 'X', 'url' => $url, 'events' => ['*']]) + ->assertUnprocessable() + ->assertJsonValidationErrors('url'); + } + } + + #[Test] + public function an_editor_cannot_manage_webhooks(): void + { + $this->actingWithRole('editor'); + + $this->getJson('/api/v1/webhooks')->assertForbidden(); + $this->postJson('/api/v1/webhooks', ['name' => 'X', 'url' => 'https://hooks.example.org/x', 'events' => ['*']]) + ->assertForbidden(); + } + + #[Test] + public function a_delivery_is_signed_with_the_secret(): void + { + Http::fake(['hooks.example.org/*' => Http::response('ok')]); + + $webhook = Webhook::factory()->create(['secret' => 'shh-a-secret-of-some-length', 'events' => ['content.published']]); + + Page::factory()->published()->create(); + + $delivery = WebhookDelivery::query()->sole(); + + $this->assertSame('content.published', $delivery->event); + $this->assertSame(WebhookDelivery::SUCCEEDED, $delivery->status); + $this->assertNotNull($webhook->fresh()->last_delivered_at); + + Http::assertSent(function (ClientRequest $request) { + $timestamp = $request->header('X-Webhook-Timestamp')[0]; + $expected = 'sha256='.hash_hmac('sha256', $timestamp.'.'.$request->body(), 'shh-a-secret-of-some-length'); + + return $request->header('X-Webhook-Signature')[0] === $expected + && $request['event'] === 'content.published' + && $request['data']['type'] === 'pages'; + }); + } + + #[Test] + public function personal_data_goes_only_where_it_was_asked_for(): void + { + Queue::fake(); + + $plain = Webhook::factory()->create(['events' => ['application.received']]); + $personal = Webhook::factory()->create(['events' => ['*'], 'include_personal_data' => true]); + + Application::factory()->create(['email' => 'ada@example.org']); + + $this->assertArrayNotHasKey('email', $plain->deliveries()->sole()->payload); + $this->assertSame('ada@example.org', $personal->deliveries()->sole()->payload['email']); + + Queue::assertPushed(DeliverWebhook::class, 2); + } + + #[Test] + public function a_webhook_that_keeps_failing_is_switched_off(): void + { + Http::fake(['hooks.example.org/*' => Http::response('nope', 500)]); + + $webhook = Webhook::factory()->create(['consecutive_failures' => Webhook::MAX_FAILURES - 1]); + $delivery = $webhook->deliveries()->create(['uuid' => 'u-1', 'event' => 'ping', 'payload' => []]); + + $job = new DeliverWebhook($delivery); + $job->failed(new \RuntimeException('nope')); + + $this->assertSame(WebhookDelivery::FAILED, $delivery->fresh()->status); + $this->assertFalse($webhook->fresh()->is_active); + } + + #[Test] + public function a_name_that_moved_somewhere_private_is_not_delivered_to(): void + { + Http::fake(); + + $webhook = Webhook::factory()->create(['url' => 'https://hooks.example.org/x']); + $delivery = $webhook->deliveries()->create(['uuid' => 'u-2', 'event' => 'ping', 'payload' => []]); + + $this->resolving(['hooks.example.org' => ['192.168.1.10']]); + + DeliverWebhook::dispatchSync($delivery); + + Http::assertNothingSent(); + $this->assertSame(WebhookDelivery::FAILED, $delivery->fresh()->status); + } + + #[Test] + public function deliveries_are_listed_pinged_and_sent_again(): void + { + Http::fake(['hooks.example.org/*' => Http::response('ok')]); + + $this->actingWithRole('admin'); + + $webhook = Webhook::factory()->create(); + + $this->postJson("/api/v1/webhooks/{$webhook->id}/ping")->assertAccepted()->assertJsonPath('data.event', 'ping'); + + $id = $this->getJson("/api/v1/webhooks/{$webhook->id}/deliveries") + ->assertOk() + ->assertJsonPath('data.0.status', 'succeeded') + ->json('data.0.id'); + + $this->postJson("/api/v1/webhook-deliveries/{$id}/redeliver")->assertAccepted(); + + $this->assertSame(2, $webhook->deliveries()->count()); + } + + #[Test] + public function rotating_the_secret_shows_the_new_one_once(): void + { + $this->actingWithRole('admin'); + + $webhook = Webhook::factory()->create(['secret' => 'old-secret-old-secret']); + + $new = $this->patchJson("/api/v1/webhooks/{$webhook->id}", ['rotate_secret' => true]) + ->assertOk() + ->json('secret'); + + $this->assertNotSame('old-secret-old-secret', $new); + $this->assertSame($new, $webhook->fresh()->secret); + } +}