Implement webhook inspector

This commit is contained in:
2026-08-04 17:09:39 +02:00
parent b021836c14
commit 75d8b25b64
57 changed files with 5110 additions and 230 deletions
+105
View File
@@ -0,0 +1,105 @@
<?php
namespace App\Actions;
use App\Events\WebhookRequestReceived;
use App\Models\WebhookEndpoint;
use App\Models\WebhookRequest;
use Illuminate\Database\Eloquent\ModelNotFoundException;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
use Symfony\Component\HttpKernel\Exception\HttpException;
class CaptureWebhookRequestAction
{
public function handle(WebhookEndpoint $endpoint, Request $request): WebhookRequest
{
$body = $request->getContent();
$bodySize = strlen($body);
if ($bodySize > (int) config('webhooks.max_body_bytes', 1024 * 1024)) {
throw new HttpException(413, 'The webhook request body is too large.');
}
$webhookRequest = DB::transaction(function () use ($endpoint, $request, $body, $bodySize): WebhookRequest {
$lockedEndpoint = WebhookEndpoint::query()
->whereKey($endpoint->getKey())
->lockForUpdate()
->first();
if (! $lockedEndpoint instanceof WebhookEndpoint || ! $lockedEndpoint->acceptsRequests()) {
throw (new ModelNotFoundException)->setModel(WebhookEndpoint::class, [$endpoint->getKey()]);
}
$webhookRequest = WebhookRequest::create([
'webhook_endpoint_id' => $lockedEndpoint->getKey(),
'method' => Str::upper($request->method()),
'request_uri' => $request->getRequestUri(),
'headers' => $request->headers->all(),
'query_parameters' => $request->query(),
'body' => $body,
'json_payload' => $this->parseJsonPayload($body, $request->header('Content-Type')),
'content_type' => $request->header('Content-Type'),
'body_size' => $bodySize,
'ip_address' => $request->ip(),
'user_agent' => $request->userAgent(),
'received_at' => now(),
]);
$lockedEndpoint->forceFill([
'last_request_at' => $webhookRequest->received_at,
])->save();
$this->pruneExcessRequests($lockedEndpoint);
return $webhookRequest;
});
WebhookRequestReceived::dispatch(
(string) $endpoint->getKey(),
(string) $webhookRequest->getKey(),
);
return $webhookRequest;
}
/**
* @return array<string, mixed>|null
*/
private function parseJsonPayload(string $body, ?string $contentType): ?array
{
if ($body === '' || $contentType === null) {
return null;
}
$normalizedContentType = Str::lower($contentType);
if (! Str::contains($normalizedContentType, ['application/json', '+json'])) {
return null;
}
try {
$payload = json_decode($body, true, 512, JSON_THROW_ON_ERROR);
} catch (\JsonException) {
return null;
}
return is_array($payload) ? $payload : null;
}
private function pruneExcessRequests(WebhookEndpoint $endpoint): void
{
$requestIds = $endpoint->requests()
->select('id')
->orderByDesc('received_at')
->orderByDesc('id')
->skip((int) config('webhooks.max_requests_per_endpoint', 1000))
->take(1_000_000_000)
->pluck('id');
if ($requestIds->isNotEmpty()) {
WebhookRequest::query()->whereKey($requestIds)->delete();
}
}
}
@@ -0,0 +1,81 @@
<?php
namespace App\Actions;
use App\Models\User;
use App\Models\WebhookEndpoint;
use Illuminate\Support\Facades\Gate;
use Illuminate\Support\Facades\Validator;
use Illuminate\Validation\ValidationException;
class ConfigureWebhookEndpointAction
{
/**
* @param array<string, string|array<int, string>> $headers
*/
public function handle(
?User $user,
WebhookEndpoint $endpoint,
int $status,
array $headers,
string $body,
): WebhookEndpoint {
Gate::forUser($user)->authorize('update', $endpoint);
$maxResponseBodyBytes = (int) config('webhooks.max_response_body_bytes', 1024 * 1024);
if (strlen($body) > $maxResponseBodyBytes) {
throw ValidationException::withMessages([
'response_body' => "The response body may not exceed {$maxResponseBodyBytes} bytes.",
]);
}
Validator::make([
'status' => $status,
'body' => $body,
], [
'status' => ['integer', 'between:100,599'],
'body' => ['string'],
])->validate();
$this->validateHeaders($headers);
$endpoint->forceFill([
'response_status' => $status,
'response_headers' => $headers,
'response_body' => $body,
])->save();
return $endpoint->refresh();
}
/**
* @param array<string, string|array<int, string>> $headers
*/
private function validateHeaders(array $headers): void
{
if (count($headers) > 50) {
throw ValidationException::withMessages([
'response_headers' => 'A maximum of 50 response headers is allowed.',
]);
}
foreach ($headers as $name => $value) {
if (! is_string($name) || preg_match("/^[!#$%&'*+.^_`|~0-9A-Za-z-]+$/", $name) !== 1) {
throw ValidationException::withMessages([
'response_headers' => 'Every response header name must be a valid HTTP header name.',
]);
}
$values = is_array($value) ? $value : [$value];
foreach ($values as $headerValue) {
if (! is_string($headerValue) || preg_match('/[\r\n]/', $headerValue) === 1 || strlen($headerValue) > 8192) {
throw ValidationException::withMessages([
'response_headers' => 'Response headers must not contain line breaks and may not exceed 8 KiB.',
]);
}
}
}
}
}
@@ -0,0 +1,23 @@
<?php
namespace App\Actions;
use App\Models\WebhookEndpoint;
class CreateAnonymousEndpointAction
{
public function handle(): WebhookEndpoint
{
$token = WebhookEndpoint::generateToken();
return WebhookEndpoint::create([
'name' => 'Anonymous test endpoint',
'token' => $token,
'token_hash' => WebhookEndpoint::tokenHash($token),
'is_public' => true,
'expires_at' => now()->addDays((int) config('webhooks.anonymous_ttl_days', 7)),
'response_headers' => ['Content-Type' => 'application/json'],
'response_body' => '{}',
]);
}
}
@@ -0,0 +1,25 @@
<?php
namespace App\Actions;
use App\Models\User;
use App\Models\WebhookEndpoint;
class CreatePrivateEndpointAction
{
public function handle(User $user, ?string $name = null): WebhookEndpoint
{
$token = WebhookEndpoint::generateToken();
return WebhookEndpoint::create([
'user_id' => $user->getKey(),
'name' => $name ?: 'Private endpoint',
'token' => $token,
'token_hash' => WebhookEndpoint::tokenHash($token),
'is_public' => false,
'expires_at' => null,
'response_headers' => ['Content-Type' => 'application/json'],
'response_body' => '{}',
]);
}
}
@@ -0,0 +1,17 @@
<?php
namespace App\Actions;
use App\Models\User;
use App\Models\WebhookRequest;
use Illuminate\Support\Facades\Gate;
class DeleteWebhookRequestAction
{
public function handle(?User $user, WebhookRequest $webhookRequest): void
{
$webhookRequest->loadMissing('endpoint');
Gate::forUser($user)->authorize('delete', $webhookRequest);
$webhookRequest->delete();
}
}
+25
View File
@@ -0,0 +1,25 @@
<?php
namespace App\Actions\Fortify;
use App\Models\User;
use Illuminate\Support\Facades\Validator;
use Laravel\Fortify\Contracts\CreatesNewUsers;
class CreateNewUser implements CreatesNewUsers
{
public function create(array $input): User
{
Validator::make($input, [
'name' => ['required', 'string', 'max:255'],
'email' => ['required', 'string', 'lowercase', 'email', 'max:255', 'unique:users'],
'password' => ['required', 'string', 'confirmed', 'min:8'],
])->validate();
return User::create([
'name' => $input['name'],
'email' => $input['email'],
'password' => $input['password'],
]);
}
}
+21
View File
@@ -0,0 +1,21 @@
<?php
namespace App\Actions\Fortify;
use App\Models\User;
use Illuminate\Support\Facades\Validator;
use Laravel\Fortify\Contracts\ResetsUserPasswords;
class ResetUserPassword implements ResetsUserPasswords
{
public function reset(User $user, array $input): void
{
Validator::make($input, [
'password' => ['required', 'string', 'confirmed', 'min:8'],
])->validate();
$user->forceFill([
'password' => $input['password'],
])->save();
}
}
+63
View File
@@ -0,0 +1,63 @@
<?php
namespace App\Console\Commands;
use App\Models\WebhookEndpoint;
use App\Models\WebhookRequest;
use Illuminate\Console\Attributes\Description;
use Illuminate\Console\Attributes\Signature;
use Illuminate\Console\Command;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Support\Carbon;
#[Signature('webhooks:prune')]
#[Description('Remove expired endpoints and old webhook requests')]
class PruneWebhookData extends Command
{
public function handle(): int
{
$now = now();
$deletedRequests = WebhookRequest::query()
->where('received_at', '<', $now->copy()->subDays((int) config('webhooks.request_retention_days', 7)))
->delete();
$this->pruneEndpointLimits();
$deletedEndpoints = $this->deleteExpiredAnonymousEndpoints($now);
$this->info("Deleted {$deletedRequests} old requests and {$deletedEndpoints} expired endpoints.");
return self::SUCCESS;
}
private function pruneEndpointLimits(): void
{
WebhookEndpoint::query()
->select('id')
->chunkById(100, function (Collection $endpoints): void {
foreach ($endpoints as $endpoint) {
$requestIds = WebhookRequest::query()
->whereBelongsTo($endpoint, 'endpoint')
->select('id')
->orderByDesc('received_at')
->orderByDesc('id')
->skip((int) config('webhooks.max_requests_per_endpoint', 1000))
->take(1_000_000_000)
->pluck('id');
if ($requestIds->isNotEmpty()) {
WebhookRequest::query()->whereKey($requestIds)->delete();
}
}
});
}
private function deleteExpiredAnonymousEndpoints(Carbon $now): int
{
return WebhookEndpoint::query()
->public()
->whereNull('user_id')
->whereNotNull('expires_at')
->where('expires_at', '<', $now)
->delete();
}
}
+58
View File
@@ -0,0 +1,58 @@
<?php
namespace App\Events;
use App\Models\WebhookEndpoint;
use Illuminate\Broadcasting\Channel;
use Illuminate\Broadcasting\InteractsWithSockets;
use Illuminate\Broadcasting\PrivateChannel;
use Illuminate\Contracts\Broadcasting\ShouldBroadcast;
use Illuminate\Contracts\Events\ShouldDispatchAfterCommit;
use Illuminate\Foundation\Events\Dispatchable;
class WebhookRequestReceived implements ShouldBroadcast, ShouldDispatchAfterCommit
{
use Dispatchable, InteractsWithSockets;
private ?bool $isPublic;
public function __construct(
public readonly string $endpointId,
public readonly string $requestId,
?bool $isPublic = null,
) {
$this->isPublic = $isPublic;
}
/**
* @return array<int, Channel>
*/
public function broadcastOn(): array
{
$isPublic = $this->isPublic ?? WebhookEndpoint::query()
->whereKey($this->endpointId)
->value('is_public');
return [
$isPublic
? new Channel('webhooks.'.$this->endpointId)
: new PrivateChannel('webhooks.'.$this->endpointId),
];
}
public function broadcastAs(): string
{
return 'webhook.request.received';
}
/**
* @return array{endpointId: string, requestId: string}
*/
public function broadcastWith(): array
{
return [
'endpointId' => $this->endpointId,
'requestId' => $this->requestId,
];
}
}
@@ -0,0 +1,56 @@
<?php
namespace App\Http\Controllers;
use App\Actions\CaptureWebhookRequestAction;
use App\Actions\CreateAnonymousEndpointAction;
use App\Models\WebhookEndpoint;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Http\Response;
use Illuminate\Support\Facades\Gate;
class EndpointController extends Controller
{
public function storeAnonymous(CreateAnonymousEndpointAction $createAnonymousEndpoint): RedirectResponse
{
$endpoint = $createAnonymousEndpoint->handle();
return redirect()->route('inspect.show', ['token' => $endpoint->token]);
}
public function receive(
Request $request,
string $token,
CaptureWebhookRequestAction $captureWebhookRequest,
): Response|JsonResponse {
$endpoint = WebhookEndpoint::findByToken($token);
abort_unless($endpoint instanceof WebhookEndpoint && $endpoint->acceptsRequests(), 404);
if (strlen($request->getContent()) > (int) config('webhooks.max_body_bytes', 1024 * 1024)) {
return response()->json(['message' => 'The webhook request body is too large.'], 413);
}
$captureWebhookRequest->handle($endpoint, $request);
$endpoint->refresh();
return response(
$endpoint->response_body ?? '{}',
$endpoint->response_status,
$endpoint->response_headers ?? ['Content-Type' => 'application/json'],
);
}
public function destroy(string $token): RedirectResponse
{
$endpoint = WebhookEndpoint::findByToken($token);
abort_unless($endpoint instanceof WebhookEndpoint, 404);
Gate::authorize('delete', $endpoint);
$endpoint->delete();
return redirect()->route('home')->with('status', 'Endpoint deleted.');
}
}
@@ -0,0 +1,20 @@
<?php
namespace App\Http\Controllers;
use App\Models\WebhookEndpoint;
use Illuminate\Support\Facades\Gate;
use Illuminate\View\View;
class InspectorController extends Controller
{
public function show(string $token): View
{
$endpoint = WebhookEndpoint::findByToken($token);
abort_unless($endpoint instanceof WebhookEndpoint && $endpoint->acceptsRequests(), 404);
Gate::authorize('view', $endpoint);
return view('inspector-page', ['endpoint' => $endpoint]);
}
}
+69
View File
@@ -0,0 +1,69 @@
<?php
namespace App\Livewire;
use App\Actions\CreatePrivateEndpointAction;
use App\Models\User;
use App\Models\WebhookEndpoint;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Gate;
use Illuminate\View\View;
use Livewire\Component;
class Dashboard extends Component
{
public string $endpointName = '';
public bool $showCreateForm = false;
public function saveEndpoint(CreatePrivateEndpointAction $createPrivateEndpoint): void
{
$this->validate([
'endpointName' => ['nullable', 'string', 'max:100'],
]);
/** @var User $user */
$user = Auth::user();
$endpoint = $createPrivateEndpoint->handle($user, $this->endpointName ?: null);
$this->redirect($endpoint->publicUrl(), navigate: true);
}
public function deleteEndpoint(string $endpointId): void
{
$endpoint = $this->ownedEndpoint($endpointId);
Gate::authorize('delete', $endpoint);
$endpoint->delete();
}
public function toggleEndpoint(string $endpointId): void
{
$endpoint = $this->ownedEndpoint($endpointId);
Gate::authorize('update', $endpoint);
$endpoint->update(['is_active' => ! $endpoint->is_active]);
}
public function render(): View
{
/** @var User $user */
$user = Auth::user();
return view('livewire.dashboard', [
'endpoints' => WebhookEndpoint::query()
->ownedBy($user)
->withCount('requests')
->latest()
->get(),
]);
}
private function ownedEndpoint(string $endpointId): WebhookEndpoint
{
/** @var User $user */
$user = Auth::user();
return WebhookEndpoint::query()
->ownedBy($user)
->findOrFail($endpointId);
}
}
+189
View File
@@ -0,0 +1,189 @@
<?php
namespace App\Livewire;
use App\Actions\ConfigureWebhookEndpointAction;
use App\Actions\DeleteWebhookRequestAction;
use App\Models\User;
use App\Models\WebhookEndpoint;
use App\Models\WebhookRequest;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Gate;
use Illuminate\Validation\ValidationException;
use Illuminate\View\View;
use Livewire\Attributes\Locked;
use Livewire\Attributes\Url;
use Livewire\Component;
use Livewire\WithPagination;
class Inspector extends Component
{
use WithPagination;
#[Locked]
public string $endpointId = '';
#[Url]
public string $search = '';
#[Url]
public string $methodFilter = 'ALL';
#[Url]
public ?string $selectedRequestId = null;
public int $responseStatus = 200;
public string $responseHeadersJson = '{}';
public string $responseBody = '{}';
public bool $responseSaved = false;
public function mount(WebhookEndpoint $endpoint): void
{
Gate::authorize('view', $endpoint);
$this->endpointId = (string) $endpoint->getKey();
$this->responseStatus = $endpoint->response_status;
$this->responseHeadersJson = json_encode(
$endpoint->response_headers ?? [],
JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES,
) ?: '{}';
$this->responseBody = $endpoint->response_body ?? '{}';
}
/**
* @return array<string, string>
*/
public function getListeners(): array
{
$endpoint = $this->endpoint();
$prefix = $endpoint->is_public ? 'echo' : 'echo-private';
return [
$prefix.':webhooks.'.$this->endpointId.',.webhook.request.received' => 'requestReceived',
];
}
/**
* @param array<string, mixed> $event
*/
public function requestReceived(array $event = []): void
{
if (($event['endpointId'] ?? null) === $this->endpointId) {
$this->resetPage();
}
}
public function updatedSearch(): void
{
$this->resetPage();
}
public function updatedMethodFilter(): void
{
$this->resetPage();
}
public function selectRequest(string $requestId): void
{
$webhookRequest = $this->endpoint()->requests()->findOrFail($requestId);
Gate::authorize('view', $webhookRequest);
$this->selectedRequestId = $webhookRequest->getKey();
}
public function clearSelectedRequest(): void
{
$this->selectedRequestId = null;
}
public function deleteRequest(string $requestId, DeleteWebhookRequestAction $deleteWebhookRequest): void
{
$webhookRequest = $this->endpoint()->requests()->findOrFail($requestId);
$deleteWebhookRequest->handle(Auth::user(), $webhookRequest);
if ($this->selectedRequestId === $requestId) {
$this->selectedRequestId = null;
}
}
public function saveResponse(ConfigureWebhookEndpointAction $configureWebhookEndpoint): void
{
$this->validate([
'responseStatus' => ['required', 'integer', 'between:100,599'],
'responseBody' => ['string'],
'responseHeadersJson' => ['required', 'json'],
]);
$headers = json_decode($this->responseHeadersJson, true);
if (! is_array($headers)) {
throw ValidationException::withMessages([
'responseHeadersJson' => 'Response headers must be a JSON object.',
]);
}
/** @var User|null $user */
$user = Auth::user();
$configureWebhookEndpoint->handle(
$user,
$this->endpoint(),
$this->responseStatus,
$headers,
$this->responseBody,
);
$this->responseSaved = true;
}
public function deleteEndpoint(): void
{
$endpoint = $this->endpoint();
Gate::authorize('delete', $endpoint);
$endpoint->delete();
$this->redirectRoute('home');
}
public function render(): View
{
$endpoint = $this->endpoint();
$requestQuery = $endpoint->requests()
->select(['id', 'method', 'request_uri', 'content_type', 'body_size', 'ip_address', 'received_at'])
->when($this->methodFilter !== 'ALL', fn ($query) => $query->where('method', $this->methodFilter))
->when($this->search !== '', function ($query): void {
$search = '%'.$this->search.'%';
$query->where(function ($query) use ($search): void {
$query->where('request_uri', 'like', $search)
->orWhere('ip_address', 'like', $search)
->orWhere('user_agent', 'like', $search);
});
})
->latest('received_at');
$selectedRequest = null;
if ($this->selectedRequestId !== null) {
$selectedRequest = $endpoint->requests()->find($this->selectedRequestId);
if ($selectedRequest instanceof WebhookRequest) {
Gate::authorize('view', $selectedRequest);
}
}
return view('livewire.inspector', [
'endpoint' => $endpoint,
'webhookRequests' => $requestQuery->paginate((int) config('webhooks.page_size', 25)),
'selectedRequest' => $selectedRequest,
]);
}
private function endpoint(): WebhookEndpoint
{
$endpoint = WebhookEndpoint::query()->findOrFail($this->endpointId);
Gate::authorize('view', $endpoint);
return $endpoint;
}
}
+8 -2
View File
@@ -2,21 +2,27 @@
namespace App\Models;
// use Illuminate\Contracts\Auth\MustVerifyEmail;
use Database\Factories\UserFactory;
use Illuminate\Contracts\Auth\MustVerifyEmail;
use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Attributes\Hidden;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Foundation\Auth\User as Authenticatable;
use Illuminate\Notifications\Notifiable;
#[Fillable(['name', 'email', 'password'])]
#[Hidden(['password', 'remember_token'])]
class User extends Authenticatable
class User extends Authenticatable implements MustVerifyEmail
{
/** @use HasFactory<UserFactory> */
use HasFactory, Notifiable;
public function webhookEndpoints(): HasMany
{
return $this->hasMany(WebhookEndpoint::class);
}
/**
* Get the attributes that should be cast.
*
+120
View File
@@ -0,0 +1,120 @@
<?php
namespace App\Models;
use Database\Factories\WebhookEndpointFactory;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Concerns\HasUlids;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Support\Str;
class WebhookEndpoint extends Model
{
/** @use HasFactory<WebhookEndpointFactory> */
use HasFactory, HasUlids;
protected $fillable = [
'user_id',
'name',
'token',
'token_hash',
'is_public',
'is_active',
'expires_at',
'response_status',
'response_headers',
'response_body',
'last_request_at',
];
protected $hidden = [
'token',
'token_hash',
];
protected $attributes = [
'is_public' => true,
'is_active' => true,
'response_status' => 200,
'response_body' => '{}',
];
/**
* @return array<string, string>
*/
protected function casts(): array
{
return [
'token' => 'encrypted',
'response_headers' => 'encrypted:array',
'is_public' => 'boolean',
'is_active' => 'boolean',
'expires_at' => 'datetime',
'last_request_at' => 'datetime',
'response_status' => 'integer',
];
}
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
public function requests(): HasMany
{
return $this->hasMany(WebhookRequest::class);
}
public function scopeOwnedBy(Builder $query, User $user): Builder
{
return $query->whereBelongsTo($user);
}
public function scopePublic(Builder $query): Builder
{
return $query->where('is_public', true);
}
public function scopeActive(Builder $query): Builder
{
return $query->where('is_active', true);
}
public static function tokenHash(string $token): string
{
return hash('sha256', $token);
}
public static function findByToken(string $token): ?self
{
return static::query()->where('token_hash', static::tokenHash($token))->first();
}
public function acceptsRequests(): bool
{
return $this->is_active && ! $this->isExpired();
}
public function isExpired(): bool
{
return $this->expires_at !== null && $this->expires_at->isPast();
}
public function publicUrl(): string
{
return route('inspect.show', ['token' => $this->token]);
}
public function webhookUrl(): string
{
return route('webhooks.receive', ['token' => $this->token]);
}
public static function generateToken(): string
{
return Str::random(48);
}
}
+50
View File
@@ -0,0 +1,50 @@
<?php
namespace App\Models;
use Database\Factories\WebhookRequestFactory;
use Illuminate\Database\Eloquent\Concerns\HasUlids;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class WebhookRequest extends Model
{
/** @use HasFactory<WebhookRequestFactory> */
use HasFactory, HasUlids;
protected $fillable = [
'webhook_endpoint_id',
'method',
'request_uri',
'headers',
'query_parameters',
'body',
'json_payload',
'content_type',
'body_size',
'ip_address',
'user_agent',
'received_at',
];
/**
* @return array<string, string>
*/
protected function casts(): array
{
return [
'headers' => 'encrypted:array',
'query_parameters' => 'encrypted:array',
'body' => 'encrypted',
'json_payload' => 'encrypted:array',
'body_size' => 'integer',
'received_at' => 'datetime',
];
}
public function endpoint(): BelongsTo
{
return $this->belongsTo(WebhookEndpoint::class, 'webhook_endpoint_id');
}
}
+40
View File
@@ -0,0 +1,40 @@
<?php
namespace App\Policies;
use App\Models\User;
use App\Models\WebhookEndpoint;
class WebhookEndpointPolicy
{
public function viewAny(?User $user): bool
{
return $user !== null;
}
public function view(?User $user, WebhookEndpoint $webhookEndpoint): bool
{
return $webhookEndpoint->is_public || $this->isOwner($user, $webhookEndpoint);
}
public function create(?User $user): bool
{
return $user !== null;
}
public function update(?User $user, WebhookEndpoint $webhookEndpoint): bool
{
return ! $webhookEndpoint->is_public && $this->isOwner($user, $webhookEndpoint);
}
public function delete(?User $user, WebhookEndpoint $webhookEndpoint): bool
{
return ($webhookEndpoint->is_public && $webhookEndpoint->user_id === null)
|| $this->isOwner($user, $webhookEndpoint);
}
private function isOwner(?User $user, WebhookEndpoint $webhookEndpoint): bool
{
return $user !== null && (string) $webhookEndpoint->user_id === (string) $user->getKey();
}
}
+41
View File
@@ -0,0 +1,41 @@
<?php
namespace App\Policies;
use App\Models\User;
use App\Models\WebhookRequest;
class WebhookRequestPolicy
{
public function viewAny(?User $user): bool
{
return $user !== null;
}
public function view(?User $user, WebhookRequest $webhookRequest): bool
{
return $this->canAccessEndpoint($user, $webhookRequest);
}
public function create(?User $user): bool
{
return false;
}
public function update(?User $user, WebhookRequest $webhookRequest): bool
{
return false;
}
public function delete(?User $user, WebhookRequest $webhookRequest): bool
{
return $this->canAccessEndpoint($user, $webhookRequest);
}
private function canAccessEndpoint(?User $user, WebhookRequest $webhookRequest): bool
{
$webhookRequest->loadMissing('endpoint');
return (new WebhookEndpointPolicy)->view($user, $webhookRequest->endpoint);
}
}
+26 -1
View File
@@ -2,6 +2,16 @@
namespace App\Providers;
use App\Models\WebhookEndpoint;
use App\Models\WebhookRequest;
use App\Policies\WebhookEndpointPolicy;
use App\Policies\WebhookRequestPolicy;
use Illuminate\Cache\RateLimiting\Limit;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Gate;
use Illuminate\Support\Facades\RateLimiter;
use Illuminate\Support\ServiceProvider;
class AppServiceProvider extends ServiceProvider
@@ -19,6 +29,21 @@ class AppServiceProvider extends ServiceProvider
*/
public function boot(): void
{
//
Model::preventLazyLoading(! app()->isProduction());
Gate::policy(WebhookEndpoint::class, WebhookEndpointPolicy::class);
Gate::policy(WebhookRequest::class, WebhookRequestPolicy::class);
RateLimiter::for('login', function (Request $request): Limit {
return Limit::perMinute(5)->by($request->string('email')->lower()->value().'|'.$request->ip());
});
RateLimiter::for('webhooks', function (Request $request): Limit {
return Limit::perMinute((int) config('webhooks.rate_limit_per_minute', 60))
->by('webhook:'.(string) $request->route('token').':'.$request->ip())
->response(fn (Request $request, array $headers): JsonResponse => response()->json([
'message' => 'Too many webhook requests. Try again later.',
], 429, $headers));
});
}
}
+35
View File
@@ -0,0 +1,35 @@
<?php
namespace App\Providers;
use App\Actions\Fortify\CreateNewUser;
use App\Actions\Fortify\ResetUserPassword;
use Illuminate\Http\Request;
use Illuminate\Support\ServiceProvider;
use Laravel\Fortify\Fortify;
class FortifyServiceProvider extends ServiceProvider
{
/**
* Register services.
*/
public function register(): void
{
//
}
/**
* Bootstrap services.
*/
public function boot(): void
{
Fortify::loginView(fn () => view('auth.login'));
Fortify::registerView(fn () => view('auth.register'));
Fortify::requestPasswordResetLinkView(fn () => view('auth.forgot-password'));
Fortify::resetPasswordView(fn (Request $request) => view('auth.reset-password', ['request' => $request]));
Fortify::verifyEmailView(fn () => view('auth.verify-email'));
Fortify::createUsersUsing(CreateNewUser::class);
Fortify::resetUserPasswordsUsing(ResetUserPassword::class);
}
}