Implement webhook inspector
This commit is contained in:
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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'],
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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]);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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.
|
||||
*
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user