Send signed outgoing webhooks
Fed by the records themselves, delivered only to public HTTPS addresses checked at every attempt, retried, and switched off after repeated failures. A submitter's details travel only to webhooks that opted into them. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
146
app/Http/Controllers/Api/V1/WebhookController.php
Normal file
146
app/Http/Controllers/Api/V1/WebhookController.php
Normal file
@@ -0,0 +1,146 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Controllers\Api\V1;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Requests\Api\V1\WebhookRequest;
|
||||
use App\Http\Resources\WebhookDeliveryResource;
|
||||
use App\Http\Resources\WebhookResource;
|
||||
use App\Jobs\DeliverWebhook;
|
||||
use App\Models\Webhook;
|
||||
use App\Models\WebhookDelivery;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
/**
|
||||
* Outgoing webhooks: where to send, which events, and how it has gone.
|
||||
*/
|
||||
class WebhookController extends Controller
|
||||
{
|
||||
public function index(Request $request): AnonymousResourceCollection
|
||||
{
|
||||
$this->authorize('viewAny', Webhook::class);
|
||||
|
||||
return WebhookResource::collection(
|
||||
Webhook::query()->latest('id')->paginate(perPage: min($request->integer('per_page', 25), 100)),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The secret is in the answer once, when it is made (or rotated), and
|
||||
* never again.
|
||||
*/
|
||||
public function store(WebhookRequest $request): JsonResponse
|
||||
{
|
||||
$this->authorize('create', Webhook::class);
|
||||
|
||||
$secret = $request->input('secret') ?: Str::random(40);
|
||||
|
||||
$webhook = new Webhook([...$request->validated(), 'secret' => $secret]);
|
||||
$webhook->forceFill(['created_by' => $request->user()->id])->save();
|
||||
|
||||
return WebhookResource::make($webhook->refresh())
|
||||
->additional(['secret' => $secret])
|
||||
->response()
|
||||
->setStatusCode(201);
|
||||
}
|
||||
|
||||
public function show(Webhook $webhook): WebhookResource
|
||||
{
|
||||
$this->authorize('view', $webhook);
|
||||
|
||||
return WebhookResource::make($webhook);
|
||||
}
|
||||
|
||||
/**
|
||||
* Switching it back on starts its failure count again.
|
||||
*/
|
||||
public function update(WebhookRequest $request, Webhook $webhook): JsonResponse
|
||||
{
|
||||
$this->authorize('update', $webhook);
|
||||
|
||||
$data = $request->validated();
|
||||
$rotated = $request->boolean('rotate_secret') ? Str::random(40) : null;
|
||||
unset($data['rotate_secret']);
|
||||
|
||||
$webhook->fill($data);
|
||||
|
||||
if ($rotated !== null) {
|
||||
$webhook->secret = $rotated;
|
||||
}
|
||||
|
||||
if ($webhook->isDirty('is_active') && $webhook->is_active) {
|
||||
$webhook->consecutive_failures = 0;
|
||||
}
|
||||
|
||||
$webhook->save();
|
||||
|
||||
return WebhookResource::make($webhook)
|
||||
->additional($rotated !== null ? ['secret' => $rotated] : [])
|
||||
->response();
|
||||
}
|
||||
|
||||
public function destroy(Webhook $webhook): JsonResponse
|
||||
{
|
||||
$this->authorize('delete', $webhook);
|
||||
|
||||
$webhook->delete();
|
||||
|
||||
return response()->json(status: 204);
|
||||
}
|
||||
|
||||
/**
|
||||
* Newest first. Filter: status.
|
||||
*/
|
||||
public function deliveries(Request $request, Webhook $webhook): AnonymousResourceCollection
|
||||
{
|
||||
$this->authorize('view', $webhook);
|
||||
|
||||
return WebhookDeliveryResource::collection(
|
||||
$webhook->deliveries()
|
||||
->when($request->filled('status'), fn ($q) => $q->where('status', $request->string('status')))
|
||||
->paginate(perPage: min($request->integer('per_page', 25), 100))
|
||||
->withQueryString(),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* A `ping`, to check the receiving end before anything real happens.
|
||||
*/
|
||||
public function ping(Webhook $webhook): JsonResponse
|
||||
{
|
||||
$this->authorize('update', $webhook);
|
||||
|
||||
$delivery = $webhook->deliveries()->create([
|
||||
'uuid' => (string) Str::uuid(),
|
||||
'event' => 'ping',
|
||||
'payload' => ['webhook' => $webhook->id, 'events' => $webhook->events],
|
||||
]);
|
||||
|
||||
DeliverWebhook::dispatch($delivery);
|
||||
|
||||
return WebhookDeliveryResource::make($delivery->refresh())->response()->setStatusCode(202);
|
||||
}
|
||||
|
||||
/**
|
||||
* Send one again, as a new delivery of the same event and payload.
|
||||
*/
|
||||
public function redeliver(WebhookDelivery $delivery): JsonResponse
|
||||
{
|
||||
$this->authorize('update', $delivery->webhook);
|
||||
|
||||
$again = $delivery->webhook->deliveries()->create([
|
||||
'uuid' => (string) Str::uuid(),
|
||||
'event' => $delivery->event,
|
||||
'payload' => $delivery->payload,
|
||||
]);
|
||||
|
||||
DeliverWebhook::dispatch($again);
|
||||
|
||||
return WebhookDeliveryResource::make($again->refresh())->response()->setStatusCode(202);
|
||||
}
|
||||
}
|
||||
53
app/Http/Requests/Api/V1/WebhookRequest.php
Normal file
53
app/Http/Requests/Api/V1/WebhookRequest.php
Normal file
@@ -0,0 +1,53 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Requests\Api\V1;
|
||||
|
||||
use App\Models\Webhook;
|
||||
use App\Support\Webhooks\PublicEndpoint;
|
||||
use Closure;
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
use Illuminate\Validation\Rule;
|
||||
|
||||
class WebhookRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function rules(): array
|
||||
{
|
||||
$creating = ! $this->route('webhook') instanceof Webhook;
|
||||
$sometimes = $creating ? 'required' : 'sometimes';
|
||||
|
||||
return [
|
||||
'name' => [$sometimes, 'string', 'max:120'],
|
||||
'url' => [
|
||||
$sometimes,
|
||||
'string',
|
||||
'url',
|
||||
'max:2048',
|
||||
// Refused now rather than at the first delivery; checked
|
||||
// again at every delivery all the same.
|
||||
function (string $attribute, mixed $value, Closure $fail): void {
|
||||
[, $reason] = app(PublicEndpoint::class)->check((string) $value);
|
||||
|
||||
if ($reason !== null) {
|
||||
$fail(__("rules.media.remote.{$reason}"));
|
||||
}
|
||||
},
|
||||
],
|
||||
'events' => [$sometimes, 'array', 'min:1'],
|
||||
'events.*' => ['string', 'distinct', Rule::in(['*', ...Webhook::EVENTS])],
|
||||
'secret' => [$creating ? 'sometimes' : 'prohibited', 'nullable', 'string', 'min:16', 'max:255'],
|
||||
'rotate_secret' => [$creating ? 'prohibited' : 'sometimes', 'boolean'],
|
||||
'include_personal_data' => ['sometimes', 'boolean'],
|
||||
'is_active' => ['sometimes', 'boolean'],
|
||||
];
|
||||
}
|
||||
}
|
||||
34
app/Http/Resources/WebhookDeliveryResource.php
Normal file
34
app/Http/Resources/WebhookDeliveryResource.php
Normal file
@@ -0,0 +1,34 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Resources;
|
||||
|
||||
use App\Models\WebhookDelivery;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Resources\Json\JsonResource;
|
||||
|
||||
/**
|
||||
* @mixin WebhookDelivery
|
||||
*/
|
||||
class WebhookDeliveryResource extends JsonResource
|
||||
{
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function toArray(Request $request): array
|
||||
{
|
||||
return [
|
||||
'id' => $this->id,
|
||||
'uuid' => $this->uuid,
|
||||
'event' => $this->event,
|
||||
'status' => $this->status,
|
||||
'attempts' => (int) $this->attempts,
|
||||
'response_status' => $this->response_status,
|
||||
'error' => $this->error,
|
||||
'payload' => (object) ($this->payload ?? []),
|
||||
'delivered_at' => $this->delivered_at?->toIso8601String(),
|
||||
'created_at' => $this->created_at?->toIso8601String(),
|
||||
];
|
||||
}
|
||||
}
|
||||
36
app/Http/Resources/WebhookResource.php
Normal file
36
app/Http/Resources/WebhookResource.php
Normal file
@@ -0,0 +1,36 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Resources;
|
||||
|
||||
use App\Models\Webhook;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Resources\Json\JsonResource;
|
||||
|
||||
/**
|
||||
* Never the secret.
|
||||
*
|
||||
* @mixin Webhook
|
||||
*/
|
||||
class WebhookResource extends JsonResource
|
||||
{
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function toArray(Request $request): array
|
||||
{
|
||||
return [
|
||||
'id' => $this->id,
|
||||
'name' => $this->name,
|
||||
'url' => $this->url,
|
||||
'events' => $this->events ?? [],
|
||||
'include_personal_data' => (bool) $this->include_personal_data,
|
||||
'is_active' => (bool) $this->is_active,
|
||||
'consecutive_failures' => (int) $this->consecutive_failures,
|
||||
'last_delivered_at' => $this->last_delivered_at?->toIso8601String(),
|
||||
'created_at' => $this->created_at?->toIso8601String(),
|
||||
'updated_at' => $this->updated_at?->toIso8601String(),
|
||||
];
|
||||
}
|
||||
}
|
||||
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;
|
||||
|
||||
use App\Auth\PireonTokenGuard;
|
||||
use App\Models\Application;
|
||||
use App\Models\ReviewComment;
|
||||
use App\Models\Revision;
|
||||
use App\Services\Git\GitManager;
|
||||
use App\Services\Pireon\PireonClient;
|
||||
use App\Services\Pireon\TokenStore;
|
||||
use App\Support\Webhooks\Webhooks;
|
||||
use Illuminate\Auth\Events\Logout;
|
||||
use Illuminate\Cache\RateLimiting\Limit;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
@@ -36,6 +40,12 @@ class AppServiceProvider extends ServiceProvider
|
||||
|
||||
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.
|
||||
Auth::viaRequest('pireon', fn (Request $request) => $this->app
|
||||
->make(PireonTokenGuard::class)($request));
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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'],
|
||||
|
||||
|
||||
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,
|
||||
];
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user