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:
29
README.md
29
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
|
(`register_permission_check_method => false`): la de spatie respondía antes y
|
||||||
concedía el permiso sin preguntar al token.
|
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
|
### Lotes e importación
|
||||||
|
|
||||||
```
|
```
|
||||||
|
|||||||
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(),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
152
app/Jobs/DeliverWebhook.php
Normal file
152
app/Jobs/DeliverWebhook.php
Normal file
@@ -0,0 +1,152 @@
|
|||||||
|
<?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();
|
||||||
|
}
|
||||||
|
}
|
||||||
80
app/Models/Webhook.php
Normal file
80
app/Models/Webhook.php
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use Illuminate\Database\Eloquent\Attributes\Fillable;
|
||||||
|
use Illuminate\Database\Eloquent\Attributes\Hidden;
|
||||||
|
use Illuminate\Database\Eloquent\Builder;
|
||||||
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Somewhere to tell when something happens here.
|
||||||
|
*/
|
||||||
|
#[Fillable(['name', 'url', 'secret', 'events', 'include_personal_data', 'is_active'])]
|
||||||
|
#[Hidden(['secret'])]
|
||||||
|
class Webhook extends Model
|
||||||
|
{
|
||||||
|
use HasFactory;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What can be subscribed to. `*` is all of them.
|
||||||
|
*/
|
||||||
|
public const EVENTS = [
|
||||||
|
'application.received',
|
||||||
|
'content.published',
|
||||||
|
'content.unpublished',
|
||||||
|
'review.requested',
|
||||||
|
'review.returned',
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Switched off after this many failed deliveries in a row, so a dead
|
||||||
|
* endpoint does not keep a queue busy forever. Switching it back on is a
|
||||||
|
* person's decision.
|
||||||
|
*/
|
||||||
|
public const MAX_FAILURES = 20;
|
||||||
|
|
||||||
|
protected $attributes = [
|
||||||
|
'include_personal_data' => 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<WebhookDelivery, $this>
|
||||||
|
*/
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
40
app/Models/WebhookDelivery.php
Normal file
40
app/Models/WebhookDelivery.php
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use Illuminate\Database\Eloquent\Attributes\Fillable;
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One attempt to tell a webhook about one event, and how it went.
|
||||||
|
*/
|
||||||
|
#[Fillable(['uuid', 'event', 'payload', 'status', 'attempts', 'response_status', 'error', 'delivered_at'])]
|
||||||
|
class WebhookDelivery extends Model
|
||||||
|
{
|
||||||
|
public const PENDING = 'pending';
|
||||||
|
|
||||||
|
public const SUCCEEDED = 'succeeded';
|
||||||
|
|
||||||
|
public const FAILED = 'failed';
|
||||||
|
|
||||||
|
protected function casts(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'payload' => 'array',
|
||||||
|
'attempts' => 'integer',
|
||||||
|
'response_status' => 'integer',
|
||||||
|
'delivered_at' => 'datetime',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return BelongsTo<Webhook, $this>
|
||||||
|
*/
|
||||||
|
public function webhook(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(Webhook::class);
|
||||||
|
}
|
||||||
|
}
|
||||||
13
app/Policies/WebhookPolicy.php
Normal file
13
app/Policies/WebhookPolicy.php
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Policies;
|
||||||
|
|
||||||
|
class WebhookPolicy extends ResourcePolicy
|
||||||
|
{
|
||||||
|
protected function resource(): string
|
||||||
|
{
|
||||||
|
return 'webhooks';
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,9 +5,13 @@ declare(strict_types=1);
|
|||||||
namespace App\Providers;
|
namespace App\Providers;
|
||||||
|
|
||||||
use App\Auth\PireonTokenGuard;
|
use App\Auth\PireonTokenGuard;
|
||||||
|
use App\Models\Application;
|
||||||
|
use App\Models\ReviewComment;
|
||||||
|
use App\Models\Revision;
|
||||||
use App\Services\Git\GitManager;
|
use App\Services\Git\GitManager;
|
||||||
use App\Services\Pireon\PireonClient;
|
use App\Services\Pireon\PireonClient;
|
||||||
use App\Services\Pireon\TokenStore;
|
use App\Services\Pireon\TokenStore;
|
||||||
|
use App\Support\Webhooks\Webhooks;
|
||||||
use Illuminate\Auth\Events\Logout;
|
use Illuminate\Auth\Events\Logout;
|
||||||
use Illuminate\Cache\RateLimiting\Limit;
|
use Illuminate\Cache\RateLimiting\Limit;
|
||||||
use Illuminate\Database\Eloquent\Model;
|
use Illuminate\Database\Eloquent\Model;
|
||||||
@@ -36,6 +40,12 @@ class AppServiceProvider extends ServiceProvider
|
|||||||
|
|
||||||
public function boot(): void
|
public function boot(): void
|
||||||
{
|
{
|
||||||
|
// Outgoing webhooks, fed by the records themselves so the panel, the
|
||||||
|
// API and a batch all announce the same things the same way.
|
||||||
|
Application::created(fn (Application $application) => 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.
|
// Backs the "api" guard declared in config/auth.php.
|
||||||
Auth::viaRequest('pireon', fn (Request $request) => $this->app
|
Auth::viaRequest('pireon', fn (Request $request) => $this->app
|
||||||
->make(PireonTokenGuard::class)($request));
|
->make(PireonTokenGuard::class)($request));
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ use App\Models\Talk;
|
|||||||
use App\Models\Technology;
|
use App\Models\Technology;
|
||||||
use App\Models\Testimonial;
|
use App\Models\Testimonial;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
|
use App\Models\Webhook;
|
||||||
use App\Policies\ApplicationPolicy;
|
use App\Policies\ApplicationPolicy;
|
||||||
use App\Policies\CallPolicy;
|
use App\Policies\CallPolicy;
|
||||||
use App\Policies\CategoryPolicy;
|
use App\Policies\CategoryPolicy;
|
||||||
@@ -37,6 +38,7 @@ use App\Policies\TalkPolicy;
|
|||||||
use App\Policies\TechnologyPolicy;
|
use App\Policies\TechnologyPolicy;
|
||||||
use App\Policies\TestimonialPolicy;
|
use App\Policies\TestimonialPolicy;
|
||||||
use App\Policies\UserPolicy;
|
use App\Policies\UserPolicy;
|
||||||
|
use App\Policies\WebhookPolicy;
|
||||||
use App\Support\Permissions;
|
use App\Support\Permissions;
|
||||||
use Illuminate\Support\Facades\Gate;
|
use Illuminate\Support\Facades\Gate;
|
||||||
use Illuminate\Support\ServiceProvider;
|
use Illuminate\Support\ServiceProvider;
|
||||||
@@ -63,6 +65,7 @@ class AuthServiceProvider extends ServiceProvider
|
|||||||
Gate::policy(Repository::class, RepositoryPolicy::class);
|
Gate::policy(Repository::class, RepositoryPolicy::class);
|
||||||
Gate::policy(User::class, UserPolicy::class);
|
Gate::policy(User::class, UserPolicy::class);
|
||||||
Gate::policy(Role::class, RolePolicy::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
|
// The one place a permission is granted — spatie's own gate check is
|
||||||
// switched off (config/permission.php) so nothing answers before this.
|
// switched off (config/permission.php) so nothing answers before this.
|
||||||
|
|||||||
@@ -73,6 +73,10 @@ final class Permissions
|
|||||||
// the provider's rate limit, which is a shared resource.
|
// the provider's rate limit, which is a shared resource.
|
||||||
'repositories' => ['view_any', 'view', 'create', 'update', 'delete', 'sync'],
|
'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'],
|
'users' => ['view_any', 'view', 'create', 'update', 'delete'],
|
||||||
'roles' => ['view_any', 'view', 'create', 'update', 'delete'],
|
'roles' => ['view_any', 'view', 'create', 'update', 'delete'],
|
||||||
|
|
||||||
|
|||||||
52
app/Support/Webhooks/PublicEndpoint.php
Normal file
52
app/Support/Webhooks/PublicEndpoint.php
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Support\Webhooks;
|
||||||
|
|
||||||
|
use App\Services\Media\HostResolver;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether a URL is somewhere this server may send a request to: HTTPS, and a
|
||||||
|
* host whose every address is public.
|
||||||
|
*
|
||||||
|
* The same rule the image downloader follows, for the same reason — the
|
||||||
|
* request leaves from inside the stack, and without it a webhook pointed at
|
||||||
|
* `http://postgres:5432` or the cloud's metadata service would be a way of
|
||||||
|
* reaching them.
|
||||||
|
*/
|
||||||
|
final class PublicEndpoint
|
||||||
|
{
|
||||||
|
public function __construct(protected HostResolver $resolver) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The address to connect to, or why not: `https_only`, `unresolvable` or
|
||||||
|
* `private`.
|
||||||
|
*
|
||||||
|
* @return array{0: ?string, 1: ?string} [address, reason]
|
||||||
|
*/
|
||||||
|
public function check(string $url): array
|
||||||
|
{
|
||||||
|
$parts = parse_url($url);
|
||||||
|
$host = $parts['host'] ?? null;
|
||||||
|
|
||||||
|
if (($parts['scheme'] ?? null) !== 'https' || blank($host)) {
|
||||||
|
return [null, 'https_only'];
|
||||||
|
}
|
||||||
|
|
||||||
|
$host = trim((string) $host, '[]');
|
||||||
|
$addresses = filter_var($host, FILTER_VALIDATE_IP) ? [$host] : $this->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];
|
||||||
|
}
|
||||||
|
}
|
||||||
127
app/Support/Webhooks/Webhooks.php
Normal file
127
app/Support/Webhooks/Webhooks.php
Normal file
@@ -0,0 +1,127 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Support\Webhooks;
|
||||||
|
|
||||||
|
use App\Jobs\DeliverWebhook;
|
||||||
|
use App\Models\Application;
|
||||||
|
use App\Models\ReviewComment;
|
||||||
|
use App\Models\Revision;
|
||||||
|
use App\Models\Webhook;
|
||||||
|
use App\Support\AdminContent;
|
||||||
|
use App\Support\ContentTypes;
|
||||||
|
use App\Support\SiteUrls;
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\Relation;
|
||||||
|
use Illuminate\Support\Str;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Turning what happens here into deliveries for whoever listens.
|
||||||
|
*
|
||||||
|
* Fed by model events rather than by the controllers, so a page published from
|
||||||
|
* the panel, the API or a batch is announced the same way.
|
||||||
|
*/
|
||||||
|
final class Webhooks
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $data
|
||||||
|
* @param array<string, mixed> $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<string, mixed>
|
||||||
|
*/
|
||||||
|
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,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
27
database/factories/WebhookFactory.php
Normal file
27
database/factories/WebhookFactory.php
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Database\Factories;
|
||||||
|
|
||||||
|
use App\Models\Webhook;
|
||||||
|
use Illuminate\Database\Eloquent\Factories\Factory;
|
||||||
|
use Illuminate\Support\Str;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @extends Factory<Webhook>
|
||||||
|
*/
|
||||||
|
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,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Outgoing webhooks: telling another system that something happened here —
|
||||||
|
* a submission arrived, a page went live — instead of it having to ask.
|
||||||
|
*/
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::create('webhooks', function (Blueprint $table): void {
|
||||||
|
$table->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');
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -161,6 +161,7 @@ return [
|
|||||||
'redirects' => 'Redireccions',
|
'redirects' => 'Redireccions',
|
||||||
'pages' => 'Pàgines',
|
'pages' => 'Pàgines',
|
||||||
'categories' => 'Categories',
|
'categories' => 'Categories',
|
||||||
|
'webhooks' => 'Webhooks',
|
||||||
'users' => 'Usuaris',
|
'users' => 'Usuaris',
|
||||||
'roles' => 'Rols',
|
'roles' => 'Rols',
|
||||||
'settings' => 'Configuració',
|
'settings' => 'Configuració',
|
||||||
|
|||||||
@@ -161,6 +161,7 @@ return [
|
|||||||
'redirects' => 'Redirects',
|
'redirects' => 'Redirects',
|
||||||
'pages' => 'Pages',
|
'pages' => 'Pages',
|
||||||
'categories' => 'Categories',
|
'categories' => 'Categories',
|
||||||
|
'webhooks' => 'Webhooks',
|
||||||
'users' => 'Users',
|
'users' => 'Users',
|
||||||
'roles' => 'Roles',
|
'roles' => 'Roles',
|
||||||
'settings' => 'Settings',
|
'settings' => 'Settings',
|
||||||
|
|||||||
@@ -161,6 +161,7 @@ return [
|
|||||||
'redirects' => 'Redirecciones',
|
'redirects' => 'Redirecciones',
|
||||||
'pages' => 'Páginas',
|
'pages' => 'Páginas',
|
||||||
'categories' => 'Categorías',
|
'categories' => 'Categorías',
|
||||||
|
'webhooks' => 'Webhooks',
|
||||||
'users' => 'Usuarios',
|
'users' => 'Usuarios',
|
||||||
'roles' => 'Roles',
|
'roles' => 'Roles',
|
||||||
'settings' => 'Ajustes',
|
'settings' => 'Ajustes',
|
||||||
|
|||||||
@@ -144,6 +144,8 @@ tags:
|
|||||||
description: What is wrong with the site, and what is left to translate
|
description: What is wrong with the site, and what is left to translate
|
||||||
- name: Batch
|
- name: Batch
|
||||||
description: Several operations in one, and importing records from JSON or CSV
|
description: Several operations in one, and importing records from JSON or CSV
|
||||||
|
- name: Webhooks
|
||||||
|
description: Telling other systems what happened here
|
||||||
- name: Users
|
- name: Users
|
||||||
description: Local user administration
|
description: Local user administration
|
||||||
- name: Media
|
- name: Media
|
||||||
@@ -3812,6 +3814,167 @@ paths:
|
|||||||
"403": { $ref: "#/components/responses/Forbidden" }
|
"403": { $ref: "#/components/responses/Forbidden" }
|
||||||
"422": { $ref: "#/components/responses/ValidationError" }
|
"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:
|
/v1/media:
|
||||||
get:
|
get:
|
||||||
tags: [Media]
|
tags: [Media]
|
||||||
@@ -5640,6 +5803,66 @@ components:
|
|||||||
links: { $ref: "#/components/schemas/PaginationLinks" }
|
links: { $ref: "#/components/schemas/PaginationLinks" }
|
||||||
meta: { $ref: "#/components/schemas/PaginationMeta" }
|
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:
|
MediaFor:
|
||||||
type: string
|
type: string
|
||||||
enum: [pages, technologies, projects, members, clients, testimonials]
|
enum: [pages, technologies, projects, members, clients, testimonials]
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ use App\Http\Controllers\Api\V1\TechnologyController;
|
|||||||
use App\Http\Controllers\Api\V1\TestimonialController;
|
use App\Http\Controllers\Api\V1\TestimonialController;
|
||||||
use App\Http\Controllers\Api\V1\TokenController;
|
use App\Http\Controllers\Api\V1\TokenController;
|
||||||
use App\Http\Controllers\Api\V1\UserController;
|
use App\Http\Controllers\Api\V1\UserController;
|
||||||
|
use App\Http\Controllers\Api\V1\WebhookController;
|
||||||
use App\Support\AdminContent;
|
use App\Support\AdminContent;
|
||||||
use Illuminate\Support\Facades\Route;
|
use Illuminate\Support\Facades\Route;
|
||||||
|
|
||||||
@@ -260,6 +261,12 @@ Route::prefix('v1')->name('api.v1.')->group(function () {
|
|||||||
->whereIn('resource', array_keys(AdminContent::WITH_HISTORY))
|
->whereIn('resource', array_keys(AdminContent::WITH_HISTORY))
|
||||||
->name('import');
|
->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.
|
// The panel's health screen, and what is left to translate.
|
||||||
Route::get('health', [HealthController::class, 'index'])->name('health');
|
Route::get('health', [HealthController::class, 'index'])->name('health');
|
||||||
Route::get('translations/pending', [HealthController::class, 'translations'])->name('translations.pending');
|
Route::get('translations/pending', [HealthController::class, 'translations'])->name('translations.pending');
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ use App\Models\Talk;
|
|||||||
use App\Models\Technology;
|
use App\Models\Technology;
|
||||||
use App\Models\Testimonial;
|
use App\Models\Testimonial;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
|
use App\Models\Webhook;
|
||||||
use App\Support\Maintenance;
|
use App\Support\Maintenance;
|
||||||
use Database\Seeders\RolePermissionSeeder;
|
use Database\Seeders\RolePermissionSeeder;
|
||||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
@@ -94,6 +95,7 @@ class AdminApiContractTest extends TestCase
|
|||||||
'repositories' => ['path' => '/v1/repositories'],
|
'repositories' => ['path' => '/v1/repositories'],
|
||||||
'media' => ['path' => '/v1/media'],
|
'media' => ['path' => '/v1/media'],
|
||||||
'tokens' => ['path' => '/v1/tokens'],
|
'tokens' => ['path' => '/v1/tokens'],
|
||||||
|
'webhooks' => ['path' => '/v1/webhooks'],
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -118,6 +120,8 @@ class AdminApiContractTest extends TestCase
|
|||||||
$media = Media::factory()->create();
|
$media = Media::factory()->create();
|
||||||
$page->comment('Primera nota', $this->caller);
|
$page->comment('Primera nota', $this->caller);
|
||||||
ApiToken::issue($this->caller, 'CI', ['pages.*'], now()->addDay());
|
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
|
// One request, one documented path — the URL a real caller hits and
|
||||||
// the path key that describes it, side by side rather than derived
|
// 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}'],
|
'medium' => ["/v1/media/{$media->id}", '/v1/media/{media}'],
|
||||||
'revisions' => ["/v1/projects/{$project->id}/revisions", '/v1/projects/{project}/revisions'],
|
'revisions' => ["/v1/projects/{$project->id}/revisions", '/v1/projects/{project}/revisions'],
|
||||||
'tokens' => ['/v1/tokens', '/v1/tokens'],
|
'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'],
|
'review thread' => ["/v1/pages/{$page->id}/reviews", '/v1/pages/{page}/reviews'],
|
||||||
'users' => ['/v1/users', '/v1/users'],
|
'users' => ['/v1/users', '/v1/users'],
|
||||||
'user' => ["/v1/users/{$this->caller->id}", '/v1/users/{user}'],
|
'user' => ["/v1/users/{$this->caller->id}", '/v1/users/{user}'],
|
||||||
|
|||||||
219
tests/Feature/Api/WebhookTest.php
Normal file
219
tests/Feature/Api/WebhookTest.php
Normal file
@@ -0,0 +1,219 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Tests\Feature\Api;
|
||||||
|
|
||||||
|
use App\Jobs\DeliverWebhook;
|
||||||
|
use App\Models\Application;
|
||||||
|
use App\Models\Page;
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Models\Webhook;
|
||||||
|
use App\Models\WebhookDelivery;
|
||||||
|
use App\Services\Media\HostResolver;
|
||||||
|
use Database\Seeders\RolePermissionSeeder;
|
||||||
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
use Illuminate\Http\Client\Request as ClientRequest;
|
||||||
|
use Illuminate\Support\Facades\Http;
|
||||||
|
use Illuminate\Support\Facades\Queue;
|
||||||
|
use PHPUnit\Framework\Attributes\Test;
|
||||||
|
use Tests\FakesPireon;
|
||||||
|
use Tests\TestCase;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Outgoing webhooks: managed through the API, fed by what happens to records,
|
||||||
|
* delivered signed and only to public addresses.
|
||||||
|
*/
|
||||||
|
class WebhookTest extends TestCase
|
||||||
|
{
|
||||||
|
use FakesPireon, RefreshDatabase;
|
||||||
|
|
||||||
|
protected function setUp(): void
|
||||||
|
{
|
||||||
|
parent::setUp();
|
||||||
|
|
||||||
|
$this->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<string, array<int, string>> $answers
|
||||||
|
*/
|
||||||
|
protected function resolving(array $answers): void
|
||||||
|
{
|
||||||
|
$this->app->instance(HostResolver::class, new class($answers) extends HostResolver
|
||||||
|
{
|
||||||
|
/** @param array<string, array<int, string>> $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);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user