diff --git a/.env.example b/.env.example index c0660ea..05a34e5 100644 --- a/.env.example +++ b/.env.example @@ -33,7 +33,7 @@ SESSION_ENCRYPT=false SESSION_PATH=/ SESSION_DOMAIN=null -BROADCAST_CONNECTION=log +BROADCAST_CONNECTION=reverb FILESYSTEM_DISK=local QUEUE_CONNECTION=database @@ -63,3 +63,19 @@ AWS_BUCKET= AWS_USE_PATH_STYLE_ENDPOINT=false VITE_APP_NAME="${APP_NAME}" + +REVERB_APP_ID=local +REVERB_APP_KEY=local +REVERB_APP_SECRET=local +REVERB_HOST=localhost +REVERB_PORT=8080 +REVERB_SCHEME=http + +VITE_REVERB_APP_KEY="${REVERB_APP_KEY}" +VITE_REVERB_HOST="${REVERB_HOST}" +VITE_REVERB_PORT="${REVERB_PORT}" +VITE_REVERB_SCHEME="${REVERB_SCHEME}" + +WEBHOOK_ANONYMOUS_TTL_DAYS=7 +WEBHOOK_REQUEST_RETENTION_DAYS=7 +WEBHOOK_RATE_LIMIT_PER_MINUTE=60 diff --git a/app/Actions/CaptureWebhookRequestAction.php b/app/Actions/CaptureWebhookRequestAction.php new file mode 100644 index 0000000..e6ec3fe --- /dev/null +++ b/app/Actions/CaptureWebhookRequestAction.php @@ -0,0 +1,105 @@ +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|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(); + } + } +} diff --git a/app/Actions/ConfigureWebhookEndpointAction.php b/app/Actions/ConfigureWebhookEndpointAction.php new file mode 100644 index 0000000..1cac20a --- /dev/null +++ b/app/Actions/ConfigureWebhookEndpointAction.php @@ -0,0 +1,81 @@ +> $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> $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.', + ]); + } + } + } + } +} diff --git a/app/Actions/CreateAnonymousEndpointAction.php b/app/Actions/CreateAnonymousEndpointAction.php new file mode 100644 index 0000000..5f28645 --- /dev/null +++ b/app/Actions/CreateAnonymousEndpointAction.php @@ -0,0 +1,23 @@ + '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' => '{}', + ]); + } +} diff --git a/app/Actions/CreatePrivateEndpointAction.php b/app/Actions/CreatePrivateEndpointAction.php new file mode 100644 index 0000000..43033fe --- /dev/null +++ b/app/Actions/CreatePrivateEndpointAction.php @@ -0,0 +1,25 @@ + $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' => '{}', + ]); + } +} diff --git a/app/Actions/DeleteWebhookRequestAction.php b/app/Actions/DeleteWebhookRequestAction.php new file mode 100644 index 0000000..a5413aa --- /dev/null +++ b/app/Actions/DeleteWebhookRequestAction.php @@ -0,0 +1,17 @@ +loadMissing('endpoint'); + Gate::forUser($user)->authorize('delete', $webhookRequest); + $webhookRequest->delete(); + } +} diff --git a/app/Actions/Fortify/CreateNewUser.php b/app/Actions/Fortify/CreateNewUser.php new file mode 100644 index 0000000..9747a0f --- /dev/null +++ b/app/Actions/Fortify/CreateNewUser.php @@ -0,0 +1,25 @@ + ['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'], + ]); + } +} diff --git a/app/Actions/Fortify/ResetUserPassword.php b/app/Actions/Fortify/ResetUserPassword.php new file mode 100644 index 0000000..98f946d --- /dev/null +++ b/app/Actions/Fortify/ResetUserPassword.php @@ -0,0 +1,21 @@ + ['required', 'string', 'confirmed', 'min:8'], + ])->validate(); + + $user->forceFill([ + 'password' => $input['password'], + ])->save(); + } +} diff --git a/app/Console/Commands/PruneWebhookData.php b/app/Console/Commands/PruneWebhookData.php new file mode 100644 index 0000000..adc9760 --- /dev/null +++ b/app/Console/Commands/PruneWebhookData.php @@ -0,0 +1,63 @@ +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(); + } +} diff --git a/app/Events/WebhookRequestReceived.php b/app/Events/WebhookRequestReceived.php new file mode 100644 index 0000000..c9052b2 --- /dev/null +++ b/app/Events/WebhookRequestReceived.php @@ -0,0 +1,58 @@ +isPublic = $isPublic; + } + + /** + * @return array + */ + 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, + ]; + } +} diff --git a/app/Http/Controllers/EndpointController.php b/app/Http/Controllers/EndpointController.php new file mode 100644 index 0000000..8debfac --- /dev/null +++ b/app/Http/Controllers/EndpointController.php @@ -0,0 +1,56 @@ +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.'); + } +} diff --git a/app/Http/Controllers/InspectorController.php b/app/Http/Controllers/InspectorController.php new file mode 100644 index 0000000..8d6b64d --- /dev/null +++ b/app/Http/Controllers/InspectorController.php @@ -0,0 +1,20 @@ +acceptsRequests(), 404); + Gate::authorize('view', $endpoint); + + return view('inspector-page', ['endpoint' => $endpoint]); + } +} diff --git a/app/Livewire/Dashboard.php b/app/Livewire/Dashboard.php new file mode 100644 index 0000000..ee6d6ed --- /dev/null +++ b/app/Livewire/Dashboard.php @@ -0,0 +1,69 @@ +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); + } +} diff --git a/app/Livewire/Inspector.php b/app/Livewire/Inspector.php new file mode 100644 index 0000000..ad1595e --- /dev/null +++ b/app/Livewire/Inspector.php @@ -0,0 +1,189 @@ +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 + */ + 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 $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; + } +} diff --git a/app/Models/User.php b/app/Models/User.php index f6ba1d2..6743e34 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -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 */ use HasFactory, Notifiable; + public function webhookEndpoints(): HasMany + { + return $this->hasMany(WebhookEndpoint::class); + } + /** * Get the attributes that should be cast. * diff --git a/app/Models/WebhookEndpoint.php b/app/Models/WebhookEndpoint.php new file mode 100644 index 0000000..cb57fc6 --- /dev/null +++ b/app/Models/WebhookEndpoint.php @@ -0,0 +1,120 @@ + */ + 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 + */ + 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); + } +} diff --git a/app/Models/WebhookRequest.php b/app/Models/WebhookRequest.php new file mode 100644 index 0000000..73c78bc --- /dev/null +++ b/app/Models/WebhookRequest.php @@ -0,0 +1,50 @@ + */ + 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 + */ + 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'); + } +} diff --git a/app/Policies/WebhookEndpointPolicy.php b/app/Policies/WebhookEndpointPolicy.php new file mode 100644 index 0000000..bbb0e52 --- /dev/null +++ b/app/Policies/WebhookEndpointPolicy.php @@ -0,0 +1,40 @@ +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(); + } +} diff --git a/app/Policies/WebhookRequestPolicy.php b/app/Policies/WebhookRequestPolicy.php new file mode 100644 index 0000000..c6dc2f7 --- /dev/null +++ b/app/Policies/WebhookRequestPolicy.php @@ -0,0 +1,41 @@ +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); + } +} diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index 452e6b6..02f1cb1 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -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)); + }); } } diff --git a/app/Providers/FortifyServiceProvider.php b/app/Providers/FortifyServiceProvider.php new file mode 100644 index 0000000..ca0373f --- /dev/null +++ b/app/Providers/FortifyServiceProvider.php @@ -0,0 +1,35 @@ + 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); + } +} diff --git a/bootstrap/app.php b/bootstrap/app.php index 4b327d2..dd227ec 100644 --- a/bootstrap/app.php +++ b/bootstrap/app.php @@ -10,10 +10,13 @@ return Application::configure(basePath: dirname(__DIR__)) web: __DIR__.'/../routes/web.php', api: __DIR__.'/../routes/api.php', commands: __DIR__.'/../routes/console.php', + channels: __DIR__.'/../routes/channels.php', health: '/up', ) ->withMiddleware(function (Middleware $middleware): void { - // + $middleware->validateCsrfTokens(except: [ + 'hook/*', + ]); }) ->withExceptions(function (Exceptions $exceptions): void { $exceptions->shouldRenderJsonWhen( diff --git a/bootstrap/providers.php b/bootstrap/providers.php index fc94ae6..5ffd769 100644 --- a/bootstrap/providers.php +++ b/bootstrap/providers.php @@ -1,7 +1,9 @@ =5.3" + }, + "require-dev": { + "phpunit/phpunit": "^9.6 || ^5.7 || ^4.8.36" + }, + "type": "library", + "autoload": { + "psr-4": { + "Clue\\Redis\\Protocol\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Christian Lück", + "email": "christian@lueck.tv" + } + ], + "description": "A streaming Redis protocol (RESP) parser and serializer written in pure PHP.", + "homepage": "https://github.com/clue/redis-protocol", + "keywords": [ + "parser", + "protocol", + "redis", + "resp", + "serializer", + "streaming" + ], + "support": { + "issues": "https://github.com/clue/redis-protocol/issues", + "source": "https://github.com/clue/redis-protocol/tree/v0.3.2" + }, + "funding": [ + { + "url": "https://clue.engineering/support", + "type": "custom" + }, + { + "url": "https://github.com/clue", + "type": "github" + } + ], + "time": "2024-08-07T11:06:28+00:00" + }, + { + "name": "clue/redis-react", + "version": "v2.8.0", + "source": { + "type": "git", + "url": "https://github.com/clue/reactphp-redis.git", + "reference": "84569198dfd5564977d2ae6a32de4beb5a24bdca" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/clue/reactphp-redis/zipball/84569198dfd5564977d2ae6a32de4beb5a24bdca", + "reference": "84569198dfd5564977d2ae6a32de4beb5a24bdca", + "shasum": "" + }, + "require": { + "clue/redis-protocol": "^0.3.2", + "evenement/evenement": "^3.0 || ^2.0 || ^1.0", + "php": ">=5.3", + "react/event-loop": "^1.2", + "react/promise": "^3.2 || ^2.0 || ^1.1", + "react/promise-timer": "^1.11", + "react/socket": "^1.16" + }, + "require-dev": { + "clue/block-react": "^1.5", + "phpunit/phpunit": "^9.6 || ^5.7 || ^4.8.36" + }, + "type": "library", + "autoload": { + "psr-4": { + "Clue\\React\\Redis\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Christian Lück", + "email": "christian@clue.engineering" + } + ], + "description": "Async Redis client implementation, built on top of ReactPHP.", + "homepage": "https://github.com/clue/reactphp-redis", + "keywords": [ + "async", + "client", + "database", + "reactphp", + "redis" + ], + "support": { + "issues": "https://github.com/clue/reactphp-redis/issues", + "source": "https://github.com/clue/reactphp-redis/tree/v2.8.0" + }, + "funding": [ + { + "url": "https://clue.engineering/support", + "type": "custom" + }, + { + "url": "https://github.com/clue", + "type": "github" + } + ], + "time": "2025-01-03T16:18:33+00:00" + }, + { + "name": "dasprid/enum", + "version": "1.0.7", + "source": { + "type": "git", + "url": "https://github.com/DASPRiD/Enum.git", + "reference": "b5874fa9ed0043116c72162ec7f4fb50e02e7cce" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/DASPRiD/Enum/zipball/b5874fa9ed0043116c72162ec7f4fb50e02e7cce", + "reference": "b5874fa9ed0043116c72162ec7f4fb50e02e7cce", + "shasum": "" + }, + "require": { + "php": ">=7.1 <9.0" + }, + "require-dev": { + "phpunit/phpunit": "^7 || ^8 || ^9 || ^10 || ^11", + "squizlabs/php_codesniffer": "*" + }, + "type": "library", + "autoload": { + "psr-4": { + "DASPRiD\\Enum\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-2-Clause" + ], + "authors": [ + { + "name": "Ben Scholzen 'DASPRiD'", + "email": "mail@dasprids.de", + "homepage": "https://dasprids.de/", + "role": "Developer" + } + ], + "description": "PHP 7.1 enum implementation", + "keywords": [ + "enum", + "map" + ], + "support": { + "issues": "https://github.com/DASPRiD/Enum/issues", + "source": "https://github.com/DASPRiD/Enum/tree/1.0.7" + }, + "time": "2025-09-16T12:23:56+00:00" + }, { "name": "dflydev/dot-access-data", "version": "v3.0.3", @@ -209,6 +444,54 @@ }, "time": "2024-07-08T12:26:09+00:00" }, + { + "name": "doctrine/deprecations", + "version": "1.1.6", + "source": { + "type": "git", + "url": "https://github.com/doctrine/deprecations.git", + "reference": "d4fe3e6fd9bb9e72557a19674f44d8ac7db4c6ca" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/doctrine/deprecations/zipball/d4fe3e6fd9bb9e72557a19674f44d8ac7db4c6ca", + "reference": "d4fe3e6fd9bb9e72557a19674f44d8ac7db4c6ca", + "shasum": "" + }, + "require": { + "php": "^7.1 || ^8.0" + }, + "conflict": { + "phpunit/phpunit": "<=7.5 || >=14" + }, + "require-dev": { + "doctrine/coding-standard": "^9 || ^12 || ^14", + "phpstan/phpstan": "1.4.10 || 2.1.30", + "phpstan/phpstan-phpunit": "^1.0 || ^2", + "phpunit/phpunit": "^7.5 || ^8.5 || ^9.6 || ^10.5 || ^11.5 || ^12.4 || ^13.0", + "psr/log": "^1 || ^2 || ^3" + }, + "suggest": { + "psr/log": "Allows logging deprecations via PSR-3 logger implementation" + }, + "type": "library", + "autoload": { + "psr-4": { + "Doctrine\\Deprecations\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "A small layer on top of trigger_error(E_USER_DEPRECATED) or PSR-3 logging with options to disable all deprecations or selectively for packages.", + "homepage": "https://www.doctrine-project.org/", + "support": { + "issues": "https://github.com/doctrine/deprecations/issues", + "source": "https://github.com/doctrine/deprecations/tree/1.1.6" + }, + "time": "2026-02-07T07:09:04+00:00" + }, { "name": "doctrine/inflector", "version": "2.1.0", @@ -507,6 +790,53 @@ ], "time": "2025-03-06T22:45:56+00:00" }, + { + "name": "evenement/evenement", + "version": "v3.0.2", + "source": { + "type": "git", + "url": "https://github.com/igorw/evenement.git", + "reference": "0a16b0d71ab13284339abb99d9d2bd813640efbc" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/igorw/evenement/zipball/0a16b0d71ab13284339abb99d9d2bd813640efbc", + "reference": "0a16b0d71ab13284339abb99d9d2bd813640efbc", + "shasum": "" + }, + "require": { + "php": ">=7.0" + }, + "require-dev": { + "phpunit/phpunit": "^9 || ^6" + }, + "type": "library", + "autoload": { + "psr-4": { + "Evenement\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Igor Wiedler", + "email": "igor@wiedler.ch" + } + ], + "description": "Événement is a very simple event dispatching library for PHP", + "keywords": [ + "event-dispatcher", + "event-emitter" + ], + "support": { + "issues": "https://github.com/igorw/evenement/issues", + "source": "https://github.com/igorw/evenement/tree/v3.0.2" + }, + "time": "2023-08-08T05:53:35+00:00" + }, { "name": "fruitcake/php-cors", "version": "v1.4.0", @@ -1123,6 +1453,70 @@ }, "time": "2026-07-17T14:28:57+00:00" }, + { + "name": "laravel/fortify", + "version": "v1.37.3", + "source": { + "type": "git", + "url": "https://github.com/laravel/fortify.git", + "reference": "66b9503330e7c18b3edebb9c3b4087037826573b" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/laravel/fortify/zipball/66b9503330e7c18b3edebb9c3b4087037826573b", + "reference": "66b9503330e7c18b3edebb9c3b4087037826573b", + "shasum": "" + }, + "require": { + "bacon/bacon-qr-code": "^3.0", + "ext-json": "*", + "illuminate/console": "^11.0|^12.0|^13.0", + "illuminate/support": "^11.0|^12.0|^13.0", + "laravel/passkeys": "^0.2.0", + "php": "^8.2", + "pragmarx/google2fa": "^9.0" + }, + "require-dev": { + "orchestra/testbench": "^9.15|^10.8|^11.0", + "phpstan/phpstan": "^1.10" + }, + "type": "library", + "extra": { + "laravel": { + "providers": [ + "Laravel\\Fortify\\FortifyServiceProvider" + ] + }, + "branch-alias": { + "dev-master": "1.x-dev" + } + }, + "autoload": { + "psr-4": { + "Laravel\\Fortify\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Taylor Otwell", + "email": "taylor@laravel.com" + } + ], + "description": "Backend controllers and scaffolding for Laravel authentication.", + "keywords": [ + "auth", + "laravel" + ], + "support": { + "issues": "https://github.com/laravel/fortify/issues", + "source": "https://github.com/laravel/fortify" + }, + "time": "2026-06-29T16:22:02+00:00" + }, { "name": "laravel/framework", "version": "v13.23.0", @@ -1424,6 +1818,74 @@ }, "time": "2026-07-21T13:23:52+00:00" }, + { + "name": "laravel/passkeys", + "version": "v0.2.1", + "source": { + "type": "git", + "url": "https://github.com/laravel/passkeys-server.git", + "reference": "a76656ada41b2b4a591f075eddae5ddc67e8ab9c" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/laravel/passkeys-server/zipball/a76656ada41b2b4a591f075eddae5ddc67e8ab9c", + "reference": "a76656ada41b2b4a591f075eddae5ddc67e8ab9c", + "shasum": "" + }, + "require": { + "illuminate/contracts": "^11.0|^12.0|^13.0", + "illuminate/database": "^11.0|^12.0|^13.0", + "illuminate/http": "^11.0|^12.0|^13.0", + "illuminate/routing": "^11.0|^12.0|^13.0", + "illuminate/support": "^11.0|^12.0|^13.0", + "php": "^8.2", + "web-auth/webauthn-lib": "5.3.x" + }, + "require-dev": { + "laravel/pint": "^1.28.0", + "orchestra/testbench": "^9.0|^10.0|^11.0", + "pestphp/pest": "^3.0|^4.0", + "phpstan/phpstan": "^2.0", + "rector/rector": "^2.3" + }, + "type": "library", + "extra": { + "laravel": { + "providers": [ + "Laravel\\Passkeys\\PasskeysServiceProvider" + ] + } + }, + "autoload": { + "psr-4": { + "Laravel\\Passkeys\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Taylor Otwell", + "email": "taylor@laravel.com" + } + ], + "description": "Passwordless authentication using WebAuthn/passkeys for Laravel", + "homepage": "https://github.com/laravel/passkeys-server", + "keywords": [ + "Authentication", + "Passwordless", + "laravel", + "passkeys", + "webauthn" + ], + "support": { + "issues": "https://github.com/laravel/passkeys-server/issues", + "source": "https://github.com/laravel/passkeys-server" + }, + "time": "2026-05-18T16:26:00+00:00" + }, { "name": "laravel/prompts", "version": "v0.3.21", @@ -1483,6 +1945,85 @@ }, "time": "2026-06-26T00:11:25+00:00" }, + { + "name": "laravel/reverb", + "version": "v1.11.0", + "source": { + "type": "git", + "url": "https://github.com/laravel/reverb.git", + "reference": "dca414f38e0f7acc237890ca18edfb5f3d535f86" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/laravel/reverb/zipball/dca414f38e0f7acc237890ca18edfb5f3d535f86", + "reference": "dca414f38e0f7acc237890ca18edfb5f3d535f86", + "shasum": "" + }, + "require": { + "clue/redis-react": "^2.6", + "guzzlehttp/psr7": "^2.6", + "illuminate/console": "^10.47|^11.0|^12.0|^13.0", + "illuminate/contracts": "^10.47|^11.0|^12.0|^13.0", + "illuminate/http": "^10.47|^11.0|^12.0|^13.0", + "illuminate/support": "^10.47|^11.0|^12.0|^13.0", + "laravel/prompts": "^0.1.15|^0.2.0|^0.3.0", + "php": "^8.2", + "pusher/pusher-php-server": "^7.2", + "ratchet/rfc6455": "^0.4", + "react/promise-timer": "^1.10", + "react/socket": "^1.14", + "symfony/console": "^6.0|^7.0|^8.0", + "symfony/http-foundation": "^6.3|^7.0|^8.0" + }, + "require-dev": { + "orchestra/testbench": "^8.36|^9.15|^10.8|^11.0", + "pestphp/pest": "^2.0|^3.0|^4.0", + "phpstan/phpstan": "^1.10", + "ratchet/pawl": "^0.4.1", + "react/async": "^4.2", + "react/http": "^1.9" + }, + "type": "library", + "extra": { + "laravel": { + "providers": [ + "Laravel\\Reverb\\ApplicationManagerServiceProvider", + "Laravel\\Reverb\\ReverbServiceProvider" + ] + } + }, + "autoload": { + "psr-4": { + "Laravel\\Reverb\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Taylor Otwell", + "email": "taylor@laravel.com" + }, + { + "name": "Joe Dixon", + "email": "joe@laravel.com" + } + ], + "description": "Laravel Reverb provides a real-time WebSocket communication backend for Laravel applications.", + "keywords": [ + "WebSockets", + "laravel", + "real-time", + "websocket" + ], + "support": { + "issues": "https://github.com/laravel/reverb/issues", + "source": "https://github.com/laravel/reverb/tree/v1.11.0" + }, + "time": "2026-06-25T02:41:17+00:00" + }, { "name": "laravel/roster", "version": "v0.5.1", @@ -2296,6 +2837,82 @@ ], "time": "2026-03-08T20:05:35+00:00" }, + { + "name": "livewire/livewire", + "version": "v4.3.5", + "source": { + "type": "git", + "url": "https://github.com/livewire/livewire.git", + "reference": "7ef4b2a876c71744e86463079dd506b26eeab624" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/livewire/livewire/zipball/7ef4b2a876c71744e86463079dd506b26eeab624", + "reference": "7ef4b2a876c71744e86463079dd506b26eeab624", + "shasum": "" + }, + "require": { + "illuminate/database": "^10.0|^11.0|^12.0|^13.0", + "illuminate/routing": "^10.0|^11.0|^12.0|^13.0", + "illuminate/support": "^10.0|^11.0|^12.0|^13.0", + "illuminate/validation": "^10.0|^11.0|^12.0|^13.0", + "laravel/prompts": "^0.1.24|^0.2|^0.3", + "league/mime-type-detection": "^1.9", + "php": "^8.1", + "symfony/console": "^6.0|^7.0|^8.0", + "symfony/http-kernel": "^6.2|^7.0|^8.0" + }, + "require-dev": { + "calebporzio/sushi": "^2.1", + "laravel/framework": "^10.15.0|^11.0|^12.0|^13.0", + "mockery/mockery": "^1.3.1", + "orchestra/testbench": "^8.21.0|^9.0|^10.0|^11.0", + "orchestra/testbench-dusk": "^8.24|^9.1|^10.0|^11.0", + "phpunit/phpunit": "^10.4|^11.5|^12.5", + "psy/psysh": "^0.11.22|^0.12" + }, + "type": "library", + "extra": { + "laravel": { + "aliases": { + "Livewire": "Livewire\\Livewire" + }, + "providers": [ + "Livewire\\LivewireServiceProvider" + ] + } + }, + "autoload": { + "files": [ + "src/helpers.php" + ], + "psr-4": { + "Livewire\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Caleb Porzio", + "email": "calebporzio@gmail.com" + } + ], + "description": "A front-end framework for Laravel.", + "support": { + "issues": "https://github.com/livewire/livewire/issues", + "source": "https://github.com/livewire/livewire/tree/v4.3.5" + }, + "funding": [ + { + "url": "https://github.com/livewire", + "type": "github" + } + ], + "time": "2026-08-03T04:09:44+00:00" + }, { "name": "monolog/monolog", "version": "3.10.0", @@ -2806,6 +3423,251 @@ ], "time": "2026-02-16T23:10:27+00:00" }, + { + "name": "paragonie/constant_time_encoding", + "version": "v3.1.3", + "source": { + "type": "git", + "url": "https://github.com/paragonie/constant_time_encoding.git", + "reference": "d5b01a39b3415c2cd581d3bd3a3575c1ebbd8e77" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/paragonie/constant_time_encoding/zipball/d5b01a39b3415c2cd581d3bd3a3575c1ebbd8e77", + "reference": "d5b01a39b3415c2cd581d3bd3a3575c1ebbd8e77", + "shasum": "" + }, + "require": { + "php": "^8" + }, + "require-dev": { + "infection/infection": "^0", + "nikic/php-fuzzer": "^0", + "phpunit/phpunit": "^9|^10|^11", + "vimeo/psalm": "^4|^5|^6" + }, + "type": "library", + "autoload": { + "psr-4": { + "ParagonIE\\ConstantTime\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Paragon Initiative Enterprises", + "email": "security@paragonie.com", + "homepage": "https://paragonie.com", + "role": "Maintainer" + }, + { + "name": "Steve 'Sc00bz' Thomas", + "email": "steve@tobtu.com", + "homepage": "https://www.tobtu.com", + "role": "Original Developer" + } + ], + "description": "Constant-time Implementations of RFC 4648 Encoding (Base-64, Base-32, Base-16)", + "keywords": [ + "base16", + "base32", + "base32_decode", + "base32_encode", + "base64", + "base64_decode", + "base64_encode", + "bin2hex", + "encoding", + "hex", + "hex2bin", + "rfc4648" + ], + "support": { + "email": "info@paragonie.com", + "issues": "https://github.com/paragonie/constant_time_encoding/issues", + "source": "https://github.com/paragonie/constant_time_encoding" + }, + "time": "2025-09-24T15:06:41+00:00" + }, + { + "name": "phpdocumentor/reflection-common", + "version": "2.2.0", + "source": { + "type": "git", + "url": "https://github.com/phpDocumentor/ReflectionCommon.git", + "reference": "1d01c49d4ed62f25aa84a747ad35d5a16924662b" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/phpDocumentor/ReflectionCommon/zipball/1d01c49d4ed62f25aa84a747ad35d5a16924662b", + "reference": "1d01c49d4ed62f25aa84a747ad35d5a16924662b", + "shasum": "" + }, + "require": { + "php": "^7.2 || ^8.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-2.x": "2.x-dev" + } + }, + "autoload": { + "psr-4": { + "phpDocumentor\\Reflection\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Jaap van Otterdijk", + "email": "opensource@ijaap.nl" + } + ], + "description": "Common reflection classes used by phpdocumentor to reflect the code structure", + "homepage": "http://www.phpdoc.org", + "keywords": [ + "FQSEN", + "phpDocumentor", + "phpdoc", + "reflection", + "static analysis" + ], + "support": { + "issues": "https://github.com/phpDocumentor/ReflectionCommon/issues", + "source": "https://github.com/phpDocumentor/ReflectionCommon/tree/2.x" + }, + "time": "2020-06-27T09:03:43+00:00" + }, + { + "name": "phpdocumentor/reflection-docblock", + "version": "6.0.3", + "source": { + "type": "git", + "url": "https://github.com/phpDocumentor/ReflectionDocBlock.git", + "reference": "7bae67520aa9f5ecc506d646810bd40d9da54582" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/phpDocumentor/ReflectionDocBlock/zipball/7bae67520aa9f5ecc506d646810bd40d9da54582", + "reference": "7bae67520aa9f5ecc506d646810bd40d9da54582", + "shasum": "" + }, + "require": { + "doctrine/deprecations": "^1.1", + "ext-filter": "*", + "php": "^7.4 || ^8.0", + "phpdocumentor/reflection-common": "^2.2", + "phpdocumentor/type-resolver": "^2.0", + "phpstan/phpdoc-parser": "^2.0", + "webmozart/assert": "^1.9.1 || ^2" + }, + "require-dev": { + "mockery/mockery": "~1.3.5 || ~1.6.0", + "phpstan/extension-installer": "^1.1", + "phpstan/phpstan": "^1.8", + "phpstan/phpstan-mockery": "^1.1", + "phpstan/phpstan-webmozart-assert": "^1.2", + "phpunit/phpunit": "^9.5", + "psalm/phar": "^5.26", + "shipmonk/dead-code-detector": "^0.5.1" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "5.x-dev" + } + }, + "autoload": { + "psr-4": { + "phpDocumentor\\Reflection\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Mike van Riel", + "email": "me@mikevanriel.com" + }, + { + "name": "Jaap van Otterdijk", + "email": "opensource@ijaap.nl" + } + ], + "description": "With this component, a library can provide support for annotations via DocBlocks or otherwise retrieve information that is embedded in a DocBlock.", + "support": { + "issues": "https://github.com/phpDocumentor/ReflectionDocBlock/issues", + "source": "https://github.com/phpDocumentor/ReflectionDocBlock/tree/6.0.3" + }, + "time": "2026-03-18T20:49:53+00:00" + }, + { + "name": "phpdocumentor/type-resolver", + "version": "2.0.0", + "source": { + "type": "git", + "url": "https://github.com/phpDocumentor/TypeResolver.git", + "reference": "327a05bbee54120d4786a0dc67aad30226ad4cf9" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/phpDocumentor/TypeResolver/zipball/327a05bbee54120d4786a0dc67aad30226ad4cf9", + "reference": "327a05bbee54120d4786a0dc67aad30226ad4cf9", + "shasum": "" + }, + "require": { + "doctrine/deprecations": "^1.0", + "php": "^7.4 || ^8.0", + "phpdocumentor/reflection-common": "^2.0", + "phpstan/phpdoc-parser": "^2.0" + }, + "require-dev": { + "ext-tokenizer": "*", + "phpbench/phpbench": "^1.2", + "phpstan/extension-installer": "^1.4", + "phpstan/phpstan": "^2.1", + "phpstan/phpstan-phpunit": "^2.0", + "phpunit/phpunit": "^9.5", + "psalm/phar": "^4" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-1.x": "1.x-dev", + "dev-2.x": "2.x-dev" + } + }, + "autoload": { + "psr-4": { + "phpDocumentor\\Reflection\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Mike van Riel", + "email": "me@mikevanriel.com" + } + ], + "description": "A PSR-5 based resolver of Class names, Types and Structural Element Names", + "support": { + "issues": "https://github.com/phpDocumentor/TypeResolver/issues", + "source": "https://github.com/phpDocumentor/TypeResolver/tree/2.0.0" + }, + "time": "2026-01-06T21:53:42+00:00" + }, { "name": "phpoption/phpoption", "version": "1.9.5", @@ -2881,6 +3743,105 @@ ], "time": "2025-12-27T19:41:33+00:00" }, + { + "name": "phpstan/phpdoc-parser", + "version": "2.3.3", + "source": { + "type": "git", + "url": "https://github.com/phpstan/phpdoc-parser.git", + "reference": "fb19eedd2bb67ff8cf7a5502ad329e701d6398a3" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/phpstan/phpdoc-parser/zipball/fb19eedd2bb67ff8cf7a5502ad329e701d6398a3", + "reference": "fb19eedd2bb67ff8cf7a5502ad329e701d6398a3", + "shasum": "" + }, + "require": { + "php": "^7.4 || ^8.0" + }, + "require-dev": { + "doctrine/annotations": "^2.0", + "nikic/php-parser": "^5.3.0", + "php-parallel-lint/php-parallel-lint": "^1.2", + "phpstan/extension-installer": "^1.0", + "phpstan/phpstan": "^2.0", + "phpstan/phpstan-phpunit": "^2.0", + "phpstan/phpstan-strict-rules": "^2.0", + "phpunit/phpunit": "^9.6", + "symfony/process": "^5.2" + }, + "type": "library", + "autoload": { + "psr-4": { + "PHPStan\\PhpDocParser\\": [ + "src/" + ] + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "PHPDoc parser with support for nullable, intersection and generic types", + "support": { + "issues": "https://github.com/phpstan/phpdoc-parser/issues", + "source": "https://github.com/phpstan/phpdoc-parser/tree/2.3.3" + }, + "time": "2026-07-08T07:01:06+00:00" + }, + { + "name": "pragmarx/google2fa", + "version": "v9.0.0", + "source": { + "type": "git", + "url": "https://github.com/antonioribeiro/google2fa.git", + "reference": "e6bc62dd6ae83acc475f57912e27466019a1f2cf" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/antonioribeiro/google2fa/zipball/e6bc62dd6ae83acc475f57912e27466019a1f2cf", + "reference": "e6bc62dd6ae83acc475f57912e27466019a1f2cf", + "shasum": "" + }, + "require": { + "paragonie/constant_time_encoding": "^1.0|^2.0|^3.0", + "php": "^7.1|^8.0" + }, + "require-dev": { + "phpstan/phpstan": "^1.9", + "phpunit/phpunit": "^7.5.15|^8.5|^9.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "PragmaRX\\Google2FA\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Antonio Carlos Ribeiro", + "email": "acr@antoniocarlosribeiro.com", + "role": "Creator & Designer" + } + ], + "description": "A One Time Password Authentication package, compatible with Google Authenticator.", + "keywords": [ + "2fa", + "Authentication", + "Two Factor Authentication", + "google2fa" + ], + "support": { + "issues": "https://github.com/antonioribeiro/google2fa/issues", + "source": "https://github.com/antonioribeiro/google2fa/tree/v9.0.0" + }, + "time": "2025-09-19T22:51:08+00:00" + }, { "name": "psr/clock", "version": "1.0.0", @@ -3372,6 +4333,66 @@ }, "time": "2026-06-29T15:41:09+00:00" }, + { + "name": "pusher/pusher-php-server", + "version": "7.2.8", + "source": { + "type": "git", + "url": "https://github.com/pusher/pusher-http-php.git", + "reference": "4aa139ed2a2a805cd265449b691198beee1309d2" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/pusher/pusher-http-php/zipball/4aa139ed2a2a805cd265449b691198beee1309d2", + "reference": "4aa139ed2a2a805cd265449b691198beee1309d2", + "shasum": "" + }, + "require": { + "ext-curl": "*", + "ext-json": "*", + "guzzlehttp/guzzle": "^7.2", + "php": "^7.3|^8.0", + "psr/log": "^1.0|^2.0|^3.0" + }, + "require-dev": { + "overtrue/phplint": "^2.3", + "phpunit/phpunit": "^9.3" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "5.0-dev" + } + }, + "autoload": { + "psr-4": { + "Pusher\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "Library for interacting with the Pusher REST API", + "keywords": [ + "events", + "messaging", + "php-pusher-server", + "publish", + "push", + "pusher", + "real time", + "real-time", + "realtime", + "rest", + "trigger" + ], + "support": { + "issues": "https://github.com/pusher/pusher-http-php/issues", + "source": "https://github.com/pusher/pusher-http-php/tree/7.2.8" + }, + "time": "2026-05-18T13:11:36+00:00" + }, { "name": "ralouphie/getallheaders", "version": "3.0.3", @@ -3570,6 +4591,775 @@ }, "time": "2026-06-18T03:57:49+00:00" }, + { + "name": "ratchet/rfc6455", + "version": "v0.4.1", + "source": { + "type": "git", + "url": "https://github.com/ratchetphp/RFC6455.git", + "reference": "9b05f371219cbaf9748b505f139617dd0715592b" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/ratchetphp/RFC6455/zipball/9b05f371219cbaf9748b505f139617dd0715592b", + "reference": "9b05f371219cbaf9748b505f139617dd0715592b", + "shasum": "" + }, + "require": { + "php": ">=7.4", + "psr/http-factory-implementation": "^1.0", + "symfony/polyfill-php80": "^1.15" + }, + "require-dev": { + "guzzlehttp/psr7": "^2.7", + "phpunit/phpunit": "^9.5", + "react/socket": "^1.3" + }, + "type": "library", + "autoload": { + "psr-4": { + "Ratchet\\RFC6455\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Chris Boden", + "email": "cboden@gmail.com", + "role": "Developer" + }, + { + "name": "Matt Bonneau", + "role": "Developer" + } + ], + "description": "RFC6455 WebSocket protocol handler", + "homepage": "http://socketo.me", + "keywords": [ + "WebSockets", + "rfc6455", + "websocket" + ], + "support": { + "chat": "https://gitter.im/reactphp/reactphp", + "issues": "https://github.com/ratchetphp/RFC6455/issues", + "source": "https://github.com/ratchetphp/RFC6455/tree/v0.4.1" + }, + "time": "2026-06-06T14:34:23+00:00" + }, + { + "name": "react/cache", + "version": "v1.2.0", + "source": { + "type": "git", + "url": "https://github.com/reactphp/cache.git", + "reference": "d47c472b64aa5608225f47965a484b75c7817d5b" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/reactphp/cache/zipball/d47c472b64aa5608225f47965a484b75c7817d5b", + "reference": "d47c472b64aa5608225f47965a484b75c7817d5b", + "shasum": "" + }, + "require": { + "php": ">=5.3.0", + "react/promise": "^3.0 || ^2.0 || ^1.1" + }, + "require-dev": { + "phpunit/phpunit": "^9.5 || ^5.7 || ^4.8.35" + }, + "type": "library", + "autoload": { + "psr-4": { + "React\\Cache\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Christian Lück", + "email": "christian@clue.engineering", + "homepage": "https://clue.engineering/" + }, + { + "name": "Cees-Jan Kiewiet", + "email": "reactphp@ceesjankiewiet.nl", + "homepage": "https://wyrihaximus.net/" + }, + { + "name": "Jan Sorgalla", + "email": "jsorgalla@gmail.com", + "homepage": "https://sorgalla.com/" + }, + { + "name": "Chris Boden", + "email": "cboden@gmail.com", + "homepage": "https://cboden.dev/" + } + ], + "description": "Async, Promise-based cache interface for ReactPHP", + "keywords": [ + "cache", + "caching", + "promise", + "reactphp" + ], + "support": { + "issues": "https://github.com/reactphp/cache/issues", + "source": "https://github.com/reactphp/cache/tree/v1.2.0" + }, + "funding": [ + { + "url": "https://opencollective.com/reactphp", + "type": "open_collective" + } + ], + "time": "2022-11-30T15:59:55+00:00" + }, + { + "name": "react/dns", + "version": "v1.14.0", + "source": { + "type": "git", + "url": "https://github.com/reactphp/dns.git", + "reference": "7562c05391f42701c1fccf189c8225fece1cd7c3" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/reactphp/dns/zipball/7562c05391f42701c1fccf189c8225fece1cd7c3", + "reference": "7562c05391f42701c1fccf189c8225fece1cd7c3", + "shasum": "" + }, + "require": { + "php": ">=5.3.0", + "react/cache": "^1.0 || ^0.6 || ^0.5", + "react/event-loop": "^1.2", + "react/promise": "^3.2 || ^2.7 || ^1.2.1" + }, + "require-dev": { + "phpunit/phpunit": "^9.6 || ^5.7 || ^4.8.36", + "react/async": "^4.3 || ^3 || ^2", + "react/promise-timer": "^1.11" + }, + "type": "library", + "autoload": { + "psr-4": { + "React\\Dns\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Christian Lück", + "email": "christian@clue.engineering", + "homepage": "https://clue.engineering/" + }, + { + "name": "Cees-Jan Kiewiet", + "email": "reactphp@ceesjankiewiet.nl", + "homepage": "https://wyrihaximus.net/" + }, + { + "name": "Jan Sorgalla", + "email": "jsorgalla@gmail.com", + "homepage": "https://sorgalla.com/" + }, + { + "name": "Chris Boden", + "email": "cboden@gmail.com", + "homepage": "https://cboden.dev/" + } + ], + "description": "Async DNS resolver for ReactPHP", + "keywords": [ + "async", + "dns", + "dns-resolver", + "reactphp" + ], + "support": { + "issues": "https://github.com/reactphp/dns/issues", + "source": "https://github.com/reactphp/dns/tree/v1.14.0" + }, + "funding": [ + { + "url": "https://opencollective.com/reactphp", + "type": "open_collective" + } + ], + "time": "2025-11-18T19:34:28+00:00" + }, + { + "name": "react/event-loop", + "version": "v1.6.0", + "source": { + "type": "git", + "url": "https://github.com/reactphp/event-loop.git", + "reference": "ba276bda6083df7e0050fd9b33f66ad7a4ac747a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/reactphp/event-loop/zipball/ba276bda6083df7e0050fd9b33f66ad7a4ac747a", + "reference": "ba276bda6083df7e0050fd9b33f66ad7a4ac747a", + "shasum": "" + }, + "require": { + "php": ">=5.3.0" + }, + "require-dev": { + "phpunit/phpunit": "^9.6 || ^5.7 || ^4.8.36" + }, + "suggest": { + "ext-pcntl": "For signal handling support when using the StreamSelectLoop" + }, + "type": "library", + "autoload": { + "psr-4": { + "React\\EventLoop\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Christian Lück", + "email": "christian@clue.engineering", + "homepage": "https://clue.engineering/" + }, + { + "name": "Cees-Jan Kiewiet", + "email": "reactphp@ceesjankiewiet.nl", + "homepage": "https://wyrihaximus.net/" + }, + { + "name": "Jan Sorgalla", + "email": "jsorgalla@gmail.com", + "homepage": "https://sorgalla.com/" + }, + { + "name": "Chris Boden", + "email": "cboden@gmail.com", + "homepage": "https://cboden.dev/" + } + ], + "description": "ReactPHP's core reactor event loop that libraries can use for evented I/O.", + "keywords": [ + "asynchronous", + "event-loop" + ], + "support": { + "issues": "https://github.com/reactphp/event-loop/issues", + "source": "https://github.com/reactphp/event-loop/tree/v1.6.0" + }, + "funding": [ + { + "url": "https://opencollective.com/reactphp", + "type": "open_collective" + } + ], + "time": "2025-11-17T20:46:25+00:00" + }, + { + "name": "react/promise", + "version": "v3.3.0", + "source": { + "type": "git", + "url": "https://github.com/reactphp/promise.git", + "reference": "23444f53a813a3296c1368bb104793ce8d88f04a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/reactphp/promise/zipball/23444f53a813a3296c1368bb104793ce8d88f04a", + "reference": "23444f53a813a3296c1368bb104793ce8d88f04a", + "shasum": "" + }, + "require": { + "php": ">=7.1.0" + }, + "require-dev": { + "phpstan/phpstan": "1.12.28 || 1.4.10", + "phpunit/phpunit": "^9.6 || ^7.5" + }, + "type": "library", + "autoload": { + "files": [ + "src/functions_include.php" + ], + "psr-4": { + "React\\Promise\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Jan Sorgalla", + "email": "jsorgalla@gmail.com", + "homepage": "https://sorgalla.com/" + }, + { + "name": "Christian Lück", + "email": "christian@clue.engineering", + "homepage": "https://clue.engineering/" + }, + { + "name": "Cees-Jan Kiewiet", + "email": "reactphp@ceesjankiewiet.nl", + "homepage": "https://wyrihaximus.net/" + }, + { + "name": "Chris Boden", + "email": "cboden@gmail.com", + "homepage": "https://cboden.dev/" + } + ], + "description": "A lightweight implementation of CommonJS Promises/A for PHP", + "keywords": [ + "promise", + "promises" + ], + "support": { + "issues": "https://github.com/reactphp/promise/issues", + "source": "https://github.com/reactphp/promise/tree/v3.3.0" + }, + "funding": [ + { + "url": "https://opencollective.com/reactphp", + "type": "open_collective" + } + ], + "time": "2025-08-19T18:57:03+00:00" + }, + { + "name": "react/promise-timer", + "version": "v1.11.0", + "source": { + "type": "git", + "url": "https://github.com/reactphp/promise-timer.git", + "reference": "4f70306ed66b8b44768941ca7f142092600fafc1" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/reactphp/promise-timer/zipball/4f70306ed66b8b44768941ca7f142092600fafc1", + "reference": "4f70306ed66b8b44768941ca7f142092600fafc1", + "shasum": "" + }, + "require": { + "php": ">=5.3", + "react/event-loop": "^1.2", + "react/promise": "^3.2 || ^2.7.0 || ^1.2.1" + }, + "require-dev": { + "phpunit/phpunit": "^9.6 || ^5.7 || ^4.8.36" + }, + "type": "library", + "autoload": { + "files": [ + "src/functions_include.php" + ], + "psr-4": { + "React\\Promise\\Timer\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Christian Lück", + "email": "christian@clue.engineering", + "homepage": "https://clue.engineering/" + }, + { + "name": "Cees-Jan Kiewiet", + "email": "reactphp@ceesjankiewiet.nl", + "homepage": "https://wyrihaximus.net/" + }, + { + "name": "Jan Sorgalla", + "email": "jsorgalla@gmail.com", + "homepage": "https://sorgalla.com/" + }, + { + "name": "Chris Boden", + "email": "cboden@gmail.com", + "homepage": "https://cboden.dev/" + } + ], + "description": "A trivial implementation of timeouts for Promises, built on top of ReactPHP.", + "homepage": "https://github.com/reactphp/promise-timer", + "keywords": [ + "async", + "event-loop", + "promise", + "reactphp", + "timeout", + "timer" + ], + "support": { + "issues": "https://github.com/reactphp/promise-timer/issues", + "source": "https://github.com/reactphp/promise-timer/tree/v1.11.0" + }, + "funding": [ + { + "url": "https://opencollective.com/reactphp", + "type": "open_collective" + } + ], + "time": "2024-06-04T14:27:45+00:00" + }, + { + "name": "react/socket", + "version": "v1.17.0", + "source": { + "type": "git", + "url": "https://github.com/reactphp/socket.git", + "reference": "ef5b17b81f6f60504c539313f94f2d826c5faa08" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/reactphp/socket/zipball/ef5b17b81f6f60504c539313f94f2d826c5faa08", + "reference": "ef5b17b81f6f60504c539313f94f2d826c5faa08", + "shasum": "" + }, + "require": { + "evenement/evenement": "^3.0 || ^2.0 || ^1.0", + "php": ">=5.3.0", + "react/dns": "^1.13", + "react/event-loop": "^1.2", + "react/promise": "^3.2 || ^2.6 || ^1.2.1", + "react/stream": "^1.4" + }, + "require-dev": { + "phpunit/phpunit": "^9.6 || ^5.7 || ^4.8.36", + "react/async": "^4.3 || ^3.3 || ^2", + "react/promise-stream": "^1.4", + "react/promise-timer": "^1.11" + }, + "type": "library", + "autoload": { + "psr-4": { + "React\\Socket\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Christian Lück", + "email": "christian@clue.engineering", + "homepage": "https://clue.engineering/" + }, + { + "name": "Cees-Jan Kiewiet", + "email": "reactphp@ceesjankiewiet.nl", + "homepage": "https://wyrihaximus.net/" + }, + { + "name": "Jan Sorgalla", + "email": "jsorgalla@gmail.com", + "homepage": "https://sorgalla.com/" + }, + { + "name": "Chris Boden", + "email": "cboden@gmail.com", + "homepage": "https://cboden.dev/" + } + ], + "description": "Async, streaming plaintext TCP/IP and secure TLS socket server and client connections for ReactPHP", + "keywords": [ + "Connection", + "Socket", + "async", + "reactphp", + "stream" + ], + "support": { + "issues": "https://github.com/reactphp/socket/issues", + "source": "https://github.com/reactphp/socket/tree/v1.17.0" + }, + "funding": [ + { + "url": "https://opencollective.com/reactphp", + "type": "open_collective" + } + ], + "time": "2025-11-19T20:47:34+00:00" + }, + { + "name": "react/stream", + "version": "v1.4.0", + "source": { + "type": "git", + "url": "https://github.com/reactphp/stream.git", + "reference": "1e5b0acb8fe55143b5b426817155190eb6f5b18d" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/reactphp/stream/zipball/1e5b0acb8fe55143b5b426817155190eb6f5b18d", + "reference": "1e5b0acb8fe55143b5b426817155190eb6f5b18d", + "shasum": "" + }, + "require": { + "evenement/evenement": "^3.0 || ^2.0 || ^1.0", + "php": ">=5.3.8", + "react/event-loop": "^1.2" + }, + "require-dev": { + "clue/stream-filter": "~1.2", + "phpunit/phpunit": "^9.6 || ^5.7 || ^4.8.36" + }, + "type": "library", + "autoload": { + "psr-4": { + "React\\Stream\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Christian Lück", + "email": "christian@clue.engineering", + "homepage": "https://clue.engineering/" + }, + { + "name": "Cees-Jan Kiewiet", + "email": "reactphp@ceesjankiewiet.nl", + "homepage": "https://wyrihaximus.net/" + }, + { + "name": "Jan Sorgalla", + "email": "jsorgalla@gmail.com", + "homepage": "https://sorgalla.com/" + }, + { + "name": "Chris Boden", + "email": "cboden@gmail.com", + "homepage": "https://cboden.dev/" + } + ], + "description": "Event-driven readable and writable streams for non-blocking I/O in ReactPHP", + "keywords": [ + "event-driven", + "io", + "non-blocking", + "pipe", + "reactphp", + "readable", + "stream", + "writable" + ], + "support": { + "issues": "https://github.com/reactphp/stream/issues", + "source": "https://github.com/reactphp/stream/tree/v1.4.0" + }, + "funding": [ + { + "url": "https://opencollective.com/reactphp", + "type": "open_collective" + } + ], + "time": "2024-06-11T12:45:25+00:00" + }, + { + "name": "spomky-labs/cbor-php", + "version": "3.3.0", + "source": { + "type": "git", + "url": "https://github.com/Spomky-Labs/cbor-php.git", + "reference": "013d13da69cf28b1ae501887daceccc850ca1c76" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/Spomky-Labs/cbor-php/zipball/013d13da69cf28b1ae501887daceccc850ca1c76", + "reference": "013d13da69cf28b1ae501887daceccc850ca1c76", + "shasum": "" + }, + "require": { + "brick/math": "^0.9|^0.10|^0.11|^0.12|^0.13|^0.14|^0.15|^0.16|^0.17|^0.18", + "ext-mbstring": "*", + "php": ">=8.0" + }, + "require-dev": { + "ext-json": "*", + "roave/security-advisories": "dev-latest", + "symfony/error-handler": "^6.4|^7.1|^8.0", + "symfony/var-dumper": "^6.4|^7.1|^8.0" + }, + "suggest": { + "ext-bcmath": "GMP or BCMath extensions will drastically improve the library performance. BCMath extension needed to handle the Big Float and Decimal Fraction Tags", + "ext-gmp": "GMP or BCMath extensions will drastically improve the library performance" + }, + "type": "library", + "autoload": { + "psr-4": { + "CBOR\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Florent Morselli", + "homepage": "https://github.com/Spomky" + }, + { + "name": "All contributors", + "homepage": "https://github.com/Spomky-Labs/cbor-php/contributors" + } + ], + "description": "CBOR Encoder/Decoder for PHP", + "keywords": [ + "Concise Binary Object Representation", + "RFC7049", + "cbor" + ], + "support": { + "issues": "https://github.com/Spomky-Labs/cbor-php/issues", + "source": "https://github.com/Spomky-Labs/cbor-php/tree/3.3.0" + }, + "funding": [ + { + "url": "https://github.com/Spomky", + "type": "github" + }, + { + "url": "https://www.patreon.com/FlorentMorselli", + "type": "patreon" + } + ], + "time": "2026-07-15T18:56:27+00:00" + }, + { + "name": "spomky-labs/pki-framework", + "version": "1.5.0", + "source": { + "type": "git", + "url": "https://github.com/Spomky-Labs/pki-framework.git", + "reference": "e0d61661962560c1cedfef02b51b431e720aae78" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/Spomky-Labs/pki-framework/zipball/e0d61661962560c1cedfef02b51b431e720aae78", + "reference": "e0d61661962560c1cedfef02b51b431e720aae78", + "shasum": "" + }, + "require": { + "brick/math": "^0.10|^0.11|^0.12|^0.13|^0.14|^0.15|^0.16|^0.17|^0.18", + "ext-mbstring": "*", + "php": ">=8.1" + }, + "require-dev": { + "ekino/phpstan-banned-code": "^1.0|^2.0|^3.0", + "ext-gmp": "*", + "ext-openssl": "*", + "infection/infection": "^0.28|^0.29|^0.31", + "php-parallel-lint/php-parallel-lint": "^1.3", + "phpstan/extension-installer": "^1.3|^2.0", + "phpstan/phpstan": "^1.8|^2.0", + "phpstan/phpstan-deprecation-rules": "^1.0|^2.0", + "phpstan/phpstan-phpunit": "^1.1|^2.0", + "phpstan/phpstan-strict-rules": "^1.3|^2.0", + "phpunit/phpunit": "^10.1|^11.0|^12.0", + "rector/rector": "^1.0|^2.0", + "roave/security-advisories": "dev-latest", + "symfony/string": "^6.4|^7.0|^8.0", + "symfony/var-dumper": "^6.4|^7.0|^8.0", + "symplify/easy-coding-standard": "^12.0 || ^13.0" + }, + "suggest": { + "ext-bcmath": "For better performance (or GMP)", + "ext-gmp": "For better performance (or BCMath)", + "ext-openssl": "For OpenSSL based cyphering" + }, + "type": "library", + "autoload": { + "psr-4": { + "SpomkyLabs\\Pki\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Joni Eskelinen", + "email": "jonieske@gmail.com", + "role": "Original developer" + }, + { + "name": "Florent Morselli", + "email": "florent.morselli@spomky-labs.com", + "role": "Spomky-Labs PKI Framework developer" + } + ], + "description": "A PHP framework for managing Public Key Infrastructures. It comprises X.509 public key certificates, attribute certificates, certification requests and certification path validation.", + "homepage": "https://github.com/spomky-labs/pki-framework", + "keywords": [ + "DER", + "Private Key", + "ac", + "algorithm identifier", + "asn.1", + "asn1", + "attribute certificate", + "certificate", + "certification request", + "cryptography", + "csr", + "decrypt", + "ec", + "encrypt", + "pem", + "pkcs", + "public key", + "rsa", + "sign", + "signature", + "verify", + "x.509", + "x.690", + "x509", + "x690" + ], + "support": { + "issues": "https://github.com/Spomky-Labs/pki-framework/issues", + "source": "https://github.com/Spomky-Labs/pki-framework/tree/1.5.0" + }, + "funding": [ + { + "url": "https://github.com/Spomky", + "type": "github" + }, + { + "url": "https://www.patreon.com/FlorentMorselli", + "type": "patreon" + } + ], + "time": "2026-07-16T10:28:45+00:00" + }, { "name": "symfony/clock", "version": "v8.1.0", @@ -5453,6 +7243,173 @@ ], "time": "2026-05-29T05:06:50+00:00" }, + { + "name": "symfony/property-access", + "version": "v8.1.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/property-access.git", + "reference": "9261ef060f26cc7b728f67f141ba19b98a6209a9" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/property-access/zipball/9261ef060f26cc7b728f67f141ba19b98a6209a9", + "reference": "9261ef060f26cc7b728f67f141ba19b98a6209a9", + "shasum": "" + }, + "require": { + "php": ">=8.4.1", + "symfony/property-info": "^7.4.4|^8.0.4" + }, + "require-dev": { + "symfony/cache": "^7.4|^8.0", + "symfony/var-exporter": "^7.4|^8.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\PropertyAccess\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Provides functions to read and write from/to an object or array using a simple string notation", + "homepage": "https://symfony.com", + "keywords": [ + "access", + "array", + "extraction", + "index", + "injection", + "object", + "property", + "property-path", + "reflection" + ], + "support": { + "source": "https://github.com/symfony/property-access/tree/v8.1.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-05-29T05:06:50+00:00" + }, + { + "name": "symfony/property-info", + "version": "v8.1.2", + "source": { + "type": "git", + "url": "https://github.com/symfony/property-info.git", + "reference": "289ef0d4f2b9bd5245ac2604564289a8673837b9" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/property-info/zipball/289ef0d4f2b9bd5245ac2604564289a8673837b9", + "reference": "289ef0d4f2b9bd5245ac2604564289a8673837b9", + "shasum": "" + }, + "require": { + "php": ">=8.4.1", + "symfony/string": "^7.4|^8.0", + "symfony/type-info": "^7.4.7|^8.0.7" + }, + "conflict": { + "phpdocumentor/reflection-docblock": "<5.2|>=7", + "phpdocumentor/type-resolver": "<1.5.1" + }, + "require-dev": { + "phpdocumentor/reflection-docblock": "^5.2|^6.0", + "phpstan/phpdoc-parser": "^1.0|^2.0", + "symfony/cache": "^7.4|^8.0", + "symfony/dependency-injection": "^7.4|^8.0", + "symfony/serializer": "^7.4|^8.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\PropertyInfo\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Kévin Dunglas", + "email": "dunglas@gmail.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Extracts information about PHP class' properties using metadata of popular sources", + "homepage": "https://symfony.com", + "keywords": [ + "doctrine", + "phpdoc", + "property", + "symfony", + "type", + "validator" + ], + "support": { + "source": "https://github.com/symfony/property-info/tree/v8.1.2" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-07-22T15:42:13+00:00" + }, { "name": "symfony/routing", "version": "v8.1.2", @@ -5533,6 +7490,105 @@ ], "time": "2026-07-22T15:42:13+00:00" }, + { + "name": "symfony/serializer", + "version": "v8.1.3", + "source": { + "type": "git", + "url": "https://github.com/symfony/serializer.git", + "reference": "6bd396438ba6c36800224e2beaf3f8f2d7439343" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/serializer/zipball/6bd396438ba6c36800224e2beaf3f8f2d7439343", + "reference": "6bd396438ba6c36800224e2beaf3f8f2d7439343", + "shasum": "" + }, + "require": { + "php": ">=8.4.1", + "symfony/deprecation-contracts": "^2.5|^3", + "symfony/polyfill-ctype": "^1.8" + }, + "conflict": { + "phpdocumentor/reflection-docblock": "<5.2|>=7", + "phpdocumentor/type-resolver": "<1.5.1", + "symfony/property-access": "<8.1", + "symfony/property-info": "<7.4.15", + "symfony/type-info": "<7.4" + }, + "require-dev": { + "phpdocumentor/reflection-docblock": "^5.2|^6.0", + "phpstan/phpdoc-parser": "^1.0|^2.0", + "seld/jsonlint": "^1.10", + "symfony/cache": "^7.4|^8.0", + "symfony/config": "^7.4|^8.0", + "symfony/console": "^7.4|^8.0", + "symfony/dependency-injection": "^7.4|^8.0", + "symfony/error-handler": "^7.4|^8.0", + "symfony/filesystem": "^7.4|^8.0", + "symfony/form": "^7.4|^8.0", + "symfony/http-foundation": "^7.4|^8.0", + "symfony/http-kernel": "^7.4|^8.0", + "symfony/messenger": "^7.4|^8.0", + "symfony/mime": "^7.4|^8.0", + "symfony/property-access": "^8.1", + "symfony/property-info": "^7.4.15|~8.0.15|^8.1.2", + "symfony/translation-contracts": "^2.5|^3", + "symfony/type-info": "^7.4|^8.0", + "symfony/uid": "^7.4|^8.0", + "symfony/validator": "^7.4|^8.0", + "symfony/var-dumper": "^7.4|^8.0", + "symfony/var-exporter": "^7.4|^8.0", + "symfony/yaml": "^7.4|^8.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Serializer\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Handles serializing and deserializing data structures, including object graphs, into array structures or other formats like XML and JSON.", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/serializer/tree/v8.1.3" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-07-29T14:11:26+00:00" + }, { "name": "symfony/service-contracts", "version": "v3.7.1", @@ -5885,6 +7941,88 @@ ], "time": "2026-06-05T06:23:12+00:00" }, + { + "name": "symfony/type-info", + "version": "v8.1.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/type-info.git", + "reference": "9f24df8a79781b9b9f030fea7dfd2f3bd1e7e7e7" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/type-info/zipball/9f24df8a79781b9b9f030fea7dfd2f3bd1e7e7e7", + "reference": "9f24df8a79781b9b9f030fea7dfd2f3bd1e7e7e7", + "shasum": "" + }, + "require": { + "php": ">=8.4.1", + "psr/container": "^1.1|^2.0" + }, + "conflict": { + "phpstan/phpdoc-parser": "<1.30" + }, + "require-dev": { + "phpstan/phpdoc-parser": "^1.30|^2.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\TypeInfo\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Mathias Arlaud", + "email": "mathias.arlaud@gmail.com" + }, + { + "name": "Baptiste LEDUC", + "email": "baptiste.leduc@gmail.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Extracts PHP types information.", + "homepage": "https://symfony.com", + "keywords": [ + "PHPStan", + "phpdoc", + "symfony", + "type" + ], + "support": { + "source": "https://github.com/symfony/type-info/tree/v8.1.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-05-29T05:06:50+00:00" + }, { "name": "symfony/uid", "version": "v8.1.0", @@ -6338,6 +8476,229 @@ } ], "time": "2026-04-26T05:33:54+00:00" + }, + { + "name": "web-auth/cose-lib", + "version": "4.6.0", + "source": { + "type": "git", + "url": "https://github.com/web-auth/cose-lib.git", + "reference": "3afe04df137baf97c5c3e28c5ee6f05536405148" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/web-auth/cose-lib/zipball/3afe04df137baf97c5c3e28c5ee6f05536405148", + "reference": "3afe04df137baf97c5c3e28c5ee6f05536405148", + "shasum": "" + }, + "require": { + "brick/math": "^0.9|^0.10|^0.11|^0.12|^0.13|^0.14|^0.15|^0.16|^0.17|^0.18", + "ext-json": "*", + "ext-openssl": "*", + "php": ">=8.1", + "spomky-labs/pki-framework": "^1.0" + }, + "require-dev": { + "spomky-labs/cbor-php": "^3.2.2" + }, + "suggest": { + "ext-bcmath": "For better performance, please install either GMP (recommended) or BCMath extension", + "ext-gmp": "For better performance, please install either GMP (recommended) or BCMath extension", + "spomky-labs/cbor-php": "For COSE Signature support" + }, + "type": "library", + "autoload": { + "psr-4": { + "Cose\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Florent Morselli", + "homepage": "https://github.com/Spomky" + }, + { + "name": "All contributors", + "homepage": "https://github.com/web-auth/cose/contributors" + } + ], + "description": "CBOR Object Signing and Encryption (COSE) For PHP", + "homepage": "https://github.com/web-auth", + "keywords": [ + "COSE", + "RFC8152" + ], + "support": { + "issues": "https://github.com/web-auth/cose-lib/issues", + "source": "https://github.com/web-auth/cose-lib/tree/4.6.0" + }, + "funding": [ + { + "url": "https://github.com/Spomky", + "type": "github" + }, + { + "url": "https://www.patreon.com/FlorentMorselli", + "type": "patreon" + } + ], + "time": "2026-07-16T10:19:49+00:00" + }, + { + "name": "web-auth/webauthn-lib", + "version": "5.3.5", + "source": { + "type": "git", + "url": "https://github.com/web-auth/webauthn-lib.git", + "reference": "9e0986d999f4102e24ac8a598d3a80d98b56c19f" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/web-auth/webauthn-lib/zipball/9e0986d999f4102e24ac8a598d3a80d98b56c19f", + "reference": "9e0986d999f4102e24ac8a598d3a80d98b56c19f", + "shasum": "" + }, + "require": { + "ext-json": "*", + "ext-openssl": "*", + "paragonie/constant_time_encoding": "^2.6|^3.0", + "php": ">=8.2", + "phpdocumentor/reflection-docblock": "^5.3|^6.0", + "psr/clock": "^1.0", + "psr/event-dispatcher": "^1.0", + "psr/log": "^1.0|^2.0|^3.0", + "spomky-labs/cbor-php": "^3.0", + "spomky-labs/pki-framework": "^1.0", + "symfony/clock": "^6.4|^7.0|^8.0", + "symfony/deprecation-contracts": "^3.2", + "symfony/property-access": "^6.4|^7.0|^8.0", + "symfony/property-info": "^6.4|^7.0|^8.0", + "symfony/serializer": "^6.4|^7.0|^8.0", + "symfony/uid": "^6.4|^7.0|^8.0", + "web-auth/cose-lib": "^4.2.3" + }, + "suggest": { + "psr/log-implementation": "Recommended to receive logs from the library", + "symfony/event-dispatcher": "Recommended to use dispatched events", + "web-token/jwt-library": "Mandatory for fetching Metadata Statement from distant sources" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/web-auth/webauthn-framework", + "name": "web-auth/webauthn-framework" + } + }, + "autoload": { + "psr-4": { + "Webauthn\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Florent Morselli", + "homepage": "https://github.com/Spomky" + }, + { + "name": "All contributors", + "homepage": "https://github.com/web-auth/webauthn-library/contributors" + } + ], + "description": "FIDO2/Webauthn Support For PHP", + "homepage": "https://github.com/web-auth", + "keywords": [ + "FIDO2", + "fido", + "webauthn" + ], + "support": { + "source": "https://github.com/web-auth/webauthn-lib/tree/5.3.5" + }, + "funding": [ + { + "url": "https://github.com/Spomky", + "type": "github" + }, + { + "url": "https://www.patreon.com/FlorentMorselli", + "type": "patreon" + } + ], + "time": "2026-05-31T15:00:08+00:00" + }, + { + "name": "webmozart/assert", + "version": "2.4.1", + "source": { + "type": "git", + "url": "https://github.com/webmozarts/assert.git", + "reference": "2ccb7c2e821038c03a3e6e1700c570c158c55f70" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/webmozarts/assert/zipball/2ccb7c2e821038c03a3e6e1700c570c158c55f70", + "reference": "2ccb7c2e821038c03a3e6e1700c570c158c55f70", + "shasum": "" + }, + "require": { + "ext-ctype": "*", + "ext-date": "*", + "ext-filter": "*", + "php": "^8.2" + }, + "suggest": { + "ext-intl": "", + "ext-simplexml": "", + "ext-spl": "" + }, + "type": "library", + "extra": { + "psalm": { + "pluginClass": "Webmozart\\Assert\\PsalmPlugin" + }, + "branch-alias": { + "dev-master": "2.0-dev", + "dev-feature/2-0": "2.0-dev" + } + }, + "autoload": { + "psr-4": { + "Webmozart\\Assert\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Bernhard Schussek", + "email": "bschussek@gmail.com" + }, + { + "name": "Woody Gilk", + "email": "woody.gilk@gmail.com" + } + ], + "description": "Assertions to validate method input/output with nice error messages.", + "keywords": [ + "assert", + "check", + "validate" + ], + "support": { + "issues": "https://github.com/webmozarts/assert/issues", + "source": "https://github.com/webmozarts/assert/tree/2.4.1" + }, + "time": "2026-06-15T15:31:57+00:00" } ], "packages-dev": [ diff --git a/config/broadcasting.php b/config/broadcasting.php new file mode 100644 index 0000000..ebc3fb9 --- /dev/null +++ b/config/broadcasting.php @@ -0,0 +1,82 @@ + env('BROADCAST_CONNECTION', 'null'), + + /* + |-------------------------------------------------------------------------- + | Broadcast Connections + |-------------------------------------------------------------------------- + | + | Here you may define all of the broadcast connections that will be used + | to broadcast events to other systems or over WebSockets. Samples of + | each available type of connection are provided inside this array. + | + */ + + 'connections' => [ + + 'reverb' => [ + 'driver' => 'reverb', + 'key' => env('REVERB_APP_KEY'), + 'secret' => env('REVERB_APP_SECRET'), + 'app_id' => env('REVERB_APP_ID'), + 'options' => [ + 'host' => env('REVERB_HOST'), + 'port' => env('REVERB_PORT', 443), + 'scheme' => env('REVERB_SCHEME', 'https'), + 'useTLS' => env('REVERB_SCHEME', 'https') === 'https', + ], + 'client_options' => [ + // Guzzle client options: https://docs.guzzlephp.org/en/stable/request-options.html + ], + ], + + 'pusher' => [ + 'driver' => 'pusher', + 'key' => env('PUSHER_APP_KEY'), + 'secret' => env('PUSHER_APP_SECRET'), + 'app_id' => env('PUSHER_APP_ID'), + 'options' => [ + 'cluster' => env('PUSHER_APP_CLUSTER'), + 'host' => env('PUSHER_HOST') ?: 'api-'.env('PUSHER_APP_CLUSTER', 'mt1').'.pusher.com', + 'port' => env('PUSHER_PORT', 443), + 'scheme' => env('PUSHER_SCHEME', 'https'), + 'encrypted' => true, + 'useTLS' => env('PUSHER_SCHEME', 'https') === 'https', + ], + 'client_options' => [ + // Guzzle client options: https://docs.guzzlephp.org/en/stable/request-options.html + ], + ], + + 'ably' => [ + 'driver' => 'ably', + 'key' => env('ABLY_KEY'), + ], + + 'log' => [ + 'driver' => 'log', + ], + + 'null' => [ + 'driver' => 'null', + ], + + ], + +]; diff --git a/config/fortify.php b/config/fortify.php new file mode 100644 index 0000000..a2bb9c5 --- /dev/null +++ b/config/fortify.php @@ -0,0 +1,170 @@ + 'web', + + /* + |-------------------------------------------------------------------------- + | Fortify Password Broker + |-------------------------------------------------------------------------- + | + | Here you may specify which password broker Fortify can use when a user + | is resetting their password. This configured value should match one + | of your password brokers setup in your "auth" configuration file. + | + */ + + 'passwords' => 'users', + + /* + |-------------------------------------------------------------------------- + | Username / Email + |-------------------------------------------------------------------------- + | + | This value defines which model attribute should be considered as your + | application's "username" field. Typically, this might be the email + | address of the users but you are free to change this value here. + | + | Out of the box, Fortify expects forgot password and reset password + | requests to have a field named 'email'. If the application uses + | another name for the field you may define it below as needed. + | + */ + + 'username' => 'email', + + 'email' => 'email', + + /* + |-------------------------------------------------------------------------- + | Lowercase Usernames + |-------------------------------------------------------------------------- + | + | This value defines whether usernames should be lowercased before saving + | them in the database, as some database system string fields are case + | sensitive. You may disable this for your application if necessary. + | + */ + + 'lowercase_usernames' => true, + + /* + |-------------------------------------------------------------------------- + | Home Path + |-------------------------------------------------------------------------- + | + | Here you may configure the path where users will get redirected during + | authentication or password reset when the operations are successful + | and the user is authenticated. You are free to change this value. + | + */ + + 'home' => '/dashboard', + + /* + |-------------------------------------------------------------------------- + | Fortify Routes Prefix / Subdomain + |-------------------------------------------------------------------------- + | + | Here you may specify which prefix Fortify will assign to all the routes + | that it registers with the application. If necessary, you may change + | subdomain under which all of the Fortify routes will be available. + | + */ + + 'prefix' => '', + + 'domain' => null, + + /* + |-------------------------------------------------------------------------- + | Fortify Routes Middleware + |-------------------------------------------------------------------------- + | + | Here you may specify which middleware Fortify will assign to the routes + | that it registers with the application. If necessary, you may change + | these middleware but typically this provided default is preferred. + | + */ + + 'middleware' => ['web'], + + /* + |-------------------------------------------------------------------------- + | Rate Limiting + |-------------------------------------------------------------------------- + | + | By default, Fortify will throttle logins to five requests per minute for + | every email and IP address combination. However, if you would like to + | specify a custom rate limiter to call then you may specify it here. + | + */ + + 'limiters' => [ + 'login' => 'login', + 'two-factor' => 'two-factor', + 'passkeys' => 'passkeys', + ], + + /* + |-------------------------------------------------------------------------- + | Register View Routes + |-------------------------------------------------------------------------- + | + | Here you may specify if the routes returning views should be disabled as + | you may not need them when building your own application. This may be + | especially true if you're writing a custom single-page application. + | + */ + + 'views' => true, + + /* + |-------------------------------------------------------------------------- + | Passkeys + |-------------------------------------------------------------------------- + | + | These settings configure Fortify's passkey (WebAuthn) support. Passkeys + | allow users to sign in without needing to remember credentials since + | they use public-key cryptography - making them immune to breaches. + | + */ + + 'passkeys' => [ + 'relying_party_id' => parse_url(config('app.url'), PHP_URL_HOST), + 'allowed_origins' => [config('app.url')], + 'timeout' => 60000, + ], + + /* + |-------------------------------------------------------------------------- + | Features + |-------------------------------------------------------------------------- + | + | Some of the Fortify features are optional. You may disable the features + | by removing them from this array. You're free to only remove some of + | these features or you can even remove all of these if you need to. + | + */ + + 'features' => [ + Features::registration(), + Features::resetPasswords(), + Features::emailVerification(), + ], + +]; diff --git a/config/reverb.php b/config/reverb.php new file mode 100644 index 0000000..91f3880 --- /dev/null +++ b/config/reverb.php @@ -0,0 +1,102 @@ + env('REVERB_SERVER', 'reverb'), + + /* + |-------------------------------------------------------------------------- + | Reverb Servers + |-------------------------------------------------------------------------- + | + | Here you may define details for each of the supported Reverb servers. + | Each server has its own configuration options that are defined in + | the array below. You should ensure all the options are present. + | + */ + + 'servers' => [ + + 'reverb' => [ + 'host' => env('REVERB_SERVER_HOST', '0.0.0.0'), + 'port' => env('REVERB_SERVER_PORT', 8080), + 'path' => env('REVERB_SERVER_PATH', ''), + 'hostname' => env('REVERB_HOST'), + 'options' => [ + 'tls' => [], + ], + 'max_request_size' => env('REVERB_MAX_REQUEST_SIZE', 10_000), + 'scaling' => [ + 'enabled' => env('REVERB_SCALING_ENABLED', false), + 'channel' => env('REVERB_SCALING_CHANNEL', 'reverb'), + 'server' => [ + 'url' => env('REDIS_URL'), + 'host' => env('REDIS_HOST', '127.0.0.1'), + 'port' => env('REDIS_PORT', '6379'), + 'username' => env('REDIS_USERNAME'), + 'password' => env('REDIS_PASSWORD'), + 'database' => env('REDIS_DB', '0'), + 'timeout' => env('REDIS_TIMEOUT', 60), + ], + ], + 'pulse_ingest_interval' => env('REVERB_PULSE_INGEST_INTERVAL', 15), + 'telescope_ingest_interval' => env('REVERB_TELESCOPE_INGEST_INTERVAL', 15), + ], + + ], + + /* + |-------------------------------------------------------------------------- + | Reverb Applications + |-------------------------------------------------------------------------- + | + | Here you may define how Reverb applications are managed. If you choose + | to use the "config" provider, you may define an array of apps which + | your server will support, including their connection credentials. + | + */ + + 'apps' => [ + + 'provider' => 'config', + + 'apps' => [ + [ + 'key' => env('REVERB_APP_KEY'), + 'secret' => env('REVERB_APP_SECRET'), + 'app_id' => env('REVERB_APP_ID'), + 'options' => [ + 'host' => env('REVERB_HOST'), + 'port' => env('REVERB_PORT', 443), + 'scheme' => env('REVERB_SCHEME', 'https'), + 'useTLS' => env('REVERB_SCHEME', 'https') === 'https', + ], + 'allowed_origins' => ['*'], + 'ping_interval' => env('REVERB_APP_PING_INTERVAL', 60), + 'activity_timeout' => env('REVERB_APP_ACTIVITY_TIMEOUT', 30), + 'max_connections' => env('REVERB_APP_MAX_CONNECTIONS'), + 'max_message_size' => env('REVERB_APP_MAX_MESSAGE_SIZE', 10_000), + 'accept_client_events_from' => env('REVERB_APP_ACCEPT_CLIENT_EVENTS_FROM', 'members'), + 'rate_limiting' => [ + 'enabled' => env('REVERB_APP_RATE_LIMITING_ENABLED', false), + 'max_attempts' => env('REVERB_APP_RATE_LIMIT_MAX_ATTEMPTS', 60), + 'decay_seconds' => env('REVERB_APP_RATE_LIMIT_DECAY_SECONDS', 60), + 'terminate_on_limit' => env('REVERB_APP_RATE_LIMIT_TERMINATE', false), + ], + ], + ], + + ], + +]; diff --git a/config/webhooks.php b/config/webhooks.php new file mode 100644 index 0000000..f11e977 --- /dev/null +++ b/config/webhooks.php @@ -0,0 +1,11 @@ + (int) env('WEBHOOK_ANONYMOUS_TTL_DAYS', 7), + 'request_retention_days' => (int) env('WEBHOOK_REQUEST_RETENTION_DAYS', 7), + 'max_requests_per_endpoint' => 1000, + 'max_body_bytes' => 1024 * 1024, + 'max_response_body_bytes' => 1024 * 1024, + 'rate_limit_per_minute' => (int) env('WEBHOOK_RATE_LIMIT_PER_MINUTE', 60), + 'page_size' => 25, +]; diff --git a/database/factories/WebhookEndpointFactory.php b/database/factories/WebhookEndpointFactory.php new file mode 100644 index 0000000..b9cbef1 --- /dev/null +++ b/database/factories/WebhookEndpointFactory.php @@ -0,0 +1,62 @@ + + */ +class WebhookEndpointFactory extends Factory +{ + /** + * Define the model's default state. + * + * @return array + */ + public function definition(): array + { + $token = Str::random(48); + + return [ + 'user_id' => null, + 'name' => 'Test endpoint', + 'token' => $token, + 'token_hash' => WebhookEndpoint::tokenHash($token), + 'is_public' => true, + 'is_active' => true, + 'expires_at' => now()->addDays((int) config('webhooks.anonymous_ttl_days', 7)), + 'response_status' => 200, + 'response_headers' => ['Content-Type' => 'application/json'], + 'response_body' => '{}', + 'last_request_at' => null, + ]; + } + + public function ownedBy(User $user): static + { + return $this->state(fn (array $attributes): array => [ + 'user_id' => $user->getKey(), + 'name' => 'Private endpoint', + 'is_public' => false, + 'expires_at' => null, + ]); + } + + public function expired(): static + { + return $this->state(fn (array $attributes): array => [ + 'expires_at' => now()->subMinute(), + ]); + } + + public function inactive(): static + { + return $this->state(fn (array $attributes): array => [ + 'is_active' => false, + ]); + } +} diff --git a/database/factories/WebhookRequestFactory.php b/database/factories/WebhookRequestFactory.php new file mode 100644 index 0000000..8fb941a --- /dev/null +++ b/database/factories/WebhookRequestFactory.php @@ -0,0 +1,43 @@ + + */ +class WebhookRequestFactory extends Factory +{ + /** + * Define the model's default state. + * + * @return array + */ + public function definition(): array + { + return [ + 'webhook_endpoint_id' => WebhookEndpoint::factory(), + 'method' => 'POST', + 'request_uri' => '/hook/test', + 'headers' => ['content-type' => ['application/json']], + 'query_parameters' => [], + 'body' => '{}', + 'json_payload' => [], + 'content_type' => 'application/json', + 'body_size' => 2, + 'ip_address' => '127.0.0.1', + 'user_agent' => 'Webhook Inspector Test', + 'received_at' => now(), + ]; + } + + public function forEndpoint(WebhookEndpoint $endpoint): static + { + return $this->state(fn (array $attributes): array => [ + 'webhook_endpoint_id' => $endpoint->getKey(), + ]); + } +} diff --git a/database/migrations/2026_08_04_141731_create_webhook_endpoints_table.php b/database/migrations/2026_08_04_141731_create_webhook_endpoints_table.php new file mode 100644 index 0000000..076cbdd --- /dev/null +++ b/database/migrations/2026_08_04_141731_create_webhook_endpoints_table.php @@ -0,0 +1,41 @@ +ulid('id')->primary(); + $table->foreignId('user_id')->nullable()->constrained()->nullOnDelete(); + $table->string('name')->nullable(); + $table->text('token'); + $table->char('token_hash', 64)->unique(); + $table->boolean('is_public')->default(true); + $table->boolean('is_active')->default(true); + $table->timestamp('expires_at')->nullable()->index(); + $table->unsignedSmallInteger('response_status')->default(200); + $table->longText('response_headers')->nullable(); + $table->longText('response_body')->nullable(); + $table->timestamp('last_request_at')->nullable()->index(); + $table->timestamps(); + + $table->index(['user_id', 'created_at']); + $table->index(['is_public', 'expires_at']); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('webhook_endpoints'); + } +}; diff --git a/database/migrations/2026_08_04_141732_create_webhook_requests_table.php b/database/migrations/2026_08_04_141732_create_webhook_requests_table.php new file mode 100644 index 0000000..e3b191d --- /dev/null +++ b/database/migrations/2026_08_04_141732_create_webhook_requests_table.php @@ -0,0 +1,44 @@ +ulid('id')->primary(); + $table->foreignUlid('webhook_endpoint_id') + ->constrained('webhook_endpoints') + ->cascadeOnDelete(); + $table->string('method', 10); + $table->text('request_uri'); + $table->longText('headers')->nullable(); + $table->longText('query_parameters')->nullable(); + $table->longText('body')->nullable(); + $table->longText('json_payload')->nullable(); + $table->string('content_type')->nullable(); + $table->unsignedInteger('body_size')->default(0); + $table->ipAddress('ip_address')->nullable(); + $table->text('user_agent')->nullable(); + $table->timestamp('received_at')->index(); + $table->timestamps(); + + $table->index(['webhook_endpoint_id', 'received_at']); + $table->index(['webhook_endpoint_id', 'method', 'received_at']); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('webhook_requests'); + } +}; diff --git a/package-lock.json b/package-lock.json index dddaffb..d5c2a9e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -4,6 +4,10 @@ "requires": true, "packages": { "": { + "dependencies": { + "laravel-echo": "^2.4.0", + "pusher-js": "^8.6.0" + }, "devDependencies": { "@tailwindcss/vite": "^4.0.0", "concurrently": "^9.0.1", @@ -856,6 +860,27 @@ "jiti": "lib/jiti-cli.mjs" } }, + "node_modules/laravel-echo": { + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/laravel-echo/-/laravel-echo-2.4.0.tgz", + "integrity": "sha512-8w0fAGSNt6THfbNyqdKc29bhfeNpJg13CGx2fcLgoX0/f0mTJm/AIkYTTakmcr9pc42ZB68cSoE00j4/xNaFGQ==", + "license": "MIT", + "engines": { + "node": ">=20" + }, + "peerDependencies": { + "pusher-js": "*", + "socket.io-client": "*" + }, + "peerDependenciesMeta": { + "pusher-js": { + "optional": true + }, + "socket.io-client": { + "optional": true + } + } + }, "node_modules/laravel-vite-plugin": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/laravel-vite-plugin/-/laravel-vite-plugin-3.1.3.tgz", @@ -1234,6 +1259,15 @@ "node": "^10 || ^12 || >=14" } }, + "node_modules/pusher-js": { + "version": "8.6.0", + "resolved": "https://registry.npmjs.org/pusher-js/-/pusher-js-8.6.0.tgz", + "integrity": "sha512-wShJPfCS/kYkCBVzVW67wa9cnQIgHTszEK2XHNrFkOgGruuGw081aERAxfRjfdFU+WcIt8x6dvbwkTW4iZuQ8Q==", + "license": "MIT", + "dependencies": { + "tweetnacl": "^1.0.3" + } + }, "node_modules/require-directory": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", @@ -1409,6 +1443,12 @@ "dev": true, "license": "0BSD" }, + "node_modules/tweetnacl": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/tweetnacl/-/tweetnacl-1.0.3.tgz", + "integrity": "sha512-6rt+RN7aOi1nGMyC4Xa5DdYiukl2UWCbcJft7YhxReBGQD7OAM8Pbxw6YMo4r2diNEA8FEmu32YOn9rhaiE5yw==", + "license": "Unlicense" + }, "node_modules/vite": { "version": "8.2.0", "resolved": "https://registry.npmjs.org/vite/-/vite-8.2.0.tgz", diff --git a/package.json b/package.json index 49c869e..e47f472 100644 --- a/package.json +++ b/package.json @@ -12,5 +12,9 @@ "laravel-vite-plugin": "^3.1", "tailwindcss": "^4.0.0", "vite": "^8.0.0" + }, + "dependencies": { + "laravel-echo": "^2.4.0", + "pusher-js": "^8.6.0" } } diff --git a/resources/css/app.css b/resources/css/app.css index 54b247e..f4eaaa2 100644 --- a/resources/css/app.css +++ b/resources/css/app.css @@ -2,6 +2,8 @@ @source '../../vendor/laravel/framework/src/Illuminate/Pagination/resources/views/*.blade.php'; @source '../../storage/framework/views/*.php'; +@source '../views'; +@source '../js'; @theme { --font-sans: 'Instrument Sans', ui-sans-serif, system-ui, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', diff --git a/resources/js/app.js b/resources/js/app.js index 8337712..c5ffd7d 100644 --- a/resources/js/app.js +++ b/resources/js/app.js @@ -1 +1,9 @@ // + +/** + * Echo exposes an expressive API for subscribing to channels and listening + * for events that are broadcast by Laravel. Echo and event broadcasting + * allow your team to quickly build robust real-time web applications. + */ + +import './echo'; diff --git a/resources/js/echo.js b/resources/js/echo.js new file mode 100644 index 0000000..9349afa --- /dev/null +++ b/resources/js/echo.js @@ -0,0 +1,14 @@ +import Echo from 'laravel-echo'; + +import Pusher from 'pusher-js'; +window.Pusher = Pusher; + +window.Echo = new Echo({ + broadcaster: 'reverb', + key: import.meta.env.VITE_REVERB_APP_KEY, + wsHost: import.meta.env.VITE_REVERB_HOST, + wsPort: import.meta.env.VITE_REVERB_PORT ?? 80, + wssPort: import.meta.env.VITE_REVERB_PORT ?? 443, + forceTLS: (import.meta.env.VITE_REVERB_SCHEME ?? 'https') === 'https', + enabledTransports: ['ws', 'wss'], +}); diff --git a/resources/views/auth/forgot-password.blade.php b/resources/views/auth/forgot-password.blade.php new file mode 100644 index 0000000..b213dc6 --- /dev/null +++ b/resources/views/auth/forgot-password.blade.php @@ -0,0 +1,26 @@ +@extends('layouts.auth') + +@section('content') +
+

Reset your password

+

Enter your email and we will send you a link to choose a new password.

+
+ + @if (session('status')) +
{{ session('status') }}
+ @endif + @if ($errors->any()) +
{{ $errors->first() }}
+ @endif + +
+ @csrf +
+ + +
+ +
+ +

Back to login

+@endsection diff --git a/resources/views/auth/login.blade.php b/resources/views/auth/login.blade.php new file mode 100644 index 0000000..66ae56a --- /dev/null +++ b/resources/views/auth/login.blade.php @@ -0,0 +1,36 @@ +@extends('layouts.auth') + +@section('content') +
+

Welcome back

+

Sign in to manage your private webhook endpoints.

+
+ + @if ($errors->any()) +
{{ $errors->first() }}
+ @endif + +
+ @csrf +
+ + +
+
+
+ + @if (Route::has('password.request')) + Forgot password? + @endif +
+ +
+ + +
+ +

No account? Create one

+@endsection diff --git a/resources/views/auth/register.blade.php b/resources/views/auth/register.blade.php new file mode 100644 index 0000000..becb782 --- /dev/null +++ b/resources/views/auth/register.blade.php @@ -0,0 +1,35 @@ +@extends('layouts.auth') + +@section('content') +
+

Create your workspace

+

Private endpoints stay connected to your account.

+
+ + @if ($errors->any()) +
{{ $errors->first() }}
+ @endif + +
+ @csrf +
+ + +
+
+ + +
+
+ + +
+
+ + +
+ +
+ +

Already registered? Log in

+@endsection diff --git a/resources/views/auth/reset-password.blade.php b/resources/views/auth/reset-password.blade.php new file mode 100644 index 0000000..8eb23d3 --- /dev/null +++ b/resources/views/auth/reset-password.blade.php @@ -0,0 +1,30 @@ +@extends('layouts.auth') + +@section('content') +
+

Choose a new password

+

Your new password must be at least eight characters.

+
+ + @if ($errors->any()) +
{{ $errors->first() }}
+ @endif + +
+ @csrf + +
+ + +
+
+ + +
+
+ + +
+ +
+@endsection diff --git a/resources/views/auth/verify-email.blade.php b/resources/views/auth/verify-email.blade.php new file mode 100644 index 0000000..eb3de3c --- /dev/null +++ b/resources/views/auth/verify-email.blade.php @@ -0,0 +1,22 @@ +@extends('layouts.auth') + +@section('content') +
+

Verify your email

+

We sent a verification link to your email address. Open it to unlock your private workspace.

+
+ + @if (session('status') === 'verification-link-sent') +
A new verification link has been sent.
+ @endif + +
+ @csrf + +
+ +
+ @csrf + +
+@endsection diff --git a/resources/views/dashboard-page.blade.php b/resources/views/dashboard-page.blade.php new file mode 100644 index 0000000..e6411f4 --- /dev/null +++ b/resources/views/dashboard-page.blade.php @@ -0,0 +1,7 @@ +@extends('layouts.app') + +@section('content') +
+ +
+@endsection diff --git a/resources/views/inspector-page.blade.php b/resources/views/inspector-page.blade.php new file mode 100644 index 0000000..695cd58 --- /dev/null +++ b/resources/views/inspector-page.blade.php @@ -0,0 +1,7 @@ +@extends('layouts.app') + +@section('content') +
+ +
+@endsection diff --git a/resources/views/layouts/app.blade.php b/resources/views/layouts/app.blade.php new file mode 100644 index 0000000..9bd8086 --- /dev/null +++ b/resources/views/layouts/app.blade.php @@ -0,0 +1,56 @@ + + + + + + + {{ $title ?? config('app.name', 'Webhook Inspector') }} + + @if (file_exists(public_path('build/manifest.json')) || file_exists(public_path('hot'))) + @vite(['resources/css/app.css', 'resources/js/app.js']) + @endif + + @livewireStyles + + +
+
+ + WI + Webhook Inspector + + + +
+
+ + @if (session('status')) +
+
+ {{ session('status') }} +
+
+ @endif + +
+ @yield('content') +
+ + @livewireScripts + + diff --git a/resources/views/layouts/auth.blade.php b/resources/views/layouts/auth.blade.php new file mode 100644 index 0000000..7fb14c5 --- /dev/null +++ b/resources/views/layouts/auth.blade.php @@ -0,0 +1,24 @@ + + + + + + + {{ $title ?? 'Authentication · Webhook Inspector' }} + + @if (file_exists(public_path('build/manifest.json')) || file_exists(public_path('hot'))) + @vite(['resources/css/app.css', 'resources/js/app.js']) + @endif + + +
+ + WI + Webhook Inspector + +
+ @yield('content') +
+
+ + diff --git a/resources/views/livewire/dashboard.blade.php b/resources/views/livewire/dashboard.blade.php new file mode 100644 index 0000000..75a7f72 --- /dev/null +++ b/resources/views/livewire/dashboard.blade.php @@ -0,0 +1,69 @@ +
+
+
+

Private workspace

+

Your endpoints

+

Create durable URLs for integrations you own and keep their request history for seven days.

+
+ +
+ + @if ($showCreateForm) +
+
+
+ + + @error('endpointName')

{{ $message }}

@enderror +
+ +
+
+ @endif + + @if ($endpoints->isEmpty()) +
+
//
+

No private endpoints yet

+

Create one when you need a stable webhook URL for a project or integration.

+
+ @else +
+ @foreach ($endpoints as $endpoint) + + @endforeach +
+ @endif +
diff --git a/resources/views/livewire/inspector.blade.php b/resources/views/livewire/inspector.blade.php new file mode 100644 index 0000000..a612d5c --- /dev/null +++ b/resources/views/livewire/inspector.blade.php @@ -0,0 +1,190 @@ +
+
+
+
+ {{ $endpoint->is_public ? 'Home' : 'Dashboard' }} + / + Inspector + {{ $endpoint->is_public ? 'Temporary' : 'Private' }} +
+

{{ $endpoint->name ?: 'Webhook endpoint' }}

+

{{ $endpoint->id }}

+
+ +
+ @if ($endpoint->is_public) +
+ @csrf + @method('DELETE') + +
+ @else + + @endif +
+
+ +
+
+
+
+

Incoming requests

+

New requests are stored immediately and appear here live.

+
+
+ + + + +
+
+ + @if ($webhookRequests->isEmpty()) +
+

waiting for request...

+

Send a request to the webhook URL below to see it here.

+
+ @else +
+ + + + + + + + + + + + @foreach ($webhookRequests as $webhookRequest) + + + + + + + + @endforeach + +
MethodURITypeSizeReceived
{{ $webhookRequest->method }}{{ $webhookRequest->request_uri }}{{ $webhookRequest->content_type ?: '—' }}{{ $webhookRequest->body_size }} B{{ $webhookRequest->received_at?->diffForHumans() }}
+
+
{{ $webhookRequests->links() }}
+ @endif +
+ + +
+ + @if ($selectedRequest) +
+
+
+
{{ $selectedRequest->method }}{{ $selectedRequest->id }}
+

{{ $selectedRequest->request_uri }}

+
+
+ + +
+
+ +
+
+

Request metadata

+
+
Received
{{ $selectedRequest->received_at?->format('Y-m-d H:i:s T') }}
+
Content-Type
{{ $selectedRequest->content_type ?: '—' }}
+
Size
{{ $selectedRequest->body_size }} bytes
+
IP address
{{ $selectedRequest->ip_address ?: '—' }}
+
User-Agent
{{ $selectedRequest->user_agent ?: '—' }}
+
+
+ +
+

Query parameters

+
{{ json_encode($selectedRequest->query_parameters ?? [], JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES) }}
+
+
+ +
+
+

Headers

+
+ @forelse ($selectedRequest->headers ?? [] as $name => $values) +
{{ $name }}:{{ implode(', ', (array) $values) }}
+ @empty + No headers captured. + @endforelse +
+
+
+

Body

+
{{ $selectedRequest->body ?? '' }}
+
+
+ + @if ($selectedRequest->json_payload !== null) +
+

Parsed JSON

+
{{ json_encode($selectedRequest->json_payload, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES) }}
+
+ @endif +
+ @endif +
diff --git a/resources/views/welcome.blade.php b/resources/views/welcome.blade.php index 26e294a..557802d 100644 --- a/resources/views/welcome.blade.php +++ b/resources/views/welcome.blade.php @@ -1,223 +1,65 @@ - - - - - +@extends('layouts.app') - {{ config('app.name', 'Laravel') }} +@section('content') +
+
- @fonts - - - @if (file_exists(public_path('build/manifest.json')) || file_exists(public_path('hot'))) - @vite(['resources/css/app.css', 'resources/js/app.js']) - @else - - @endif - - -
- @if (Route::has('login')) - - @endif -
-
-
-
-

Let's get started

-

With so many options available to you,
we suggest you start with the following:

- - - -

- v{{ app()->version() }} - - View changelog - - - - -

+ @endguest
-
- {{-- Laravel Logo --}} - - - - - - - - - +
- {{-- 13 --}} - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
+
+
+
+ + Incoming request +
+ LIVE
-
+
+
POST/hook/••••••••••••
+
+
content-type
+
application/json
+
body
+
{ "event": "payment.created" }
+
+
127.0.0.1just now
+
+
- @if (Route::has('login')) - - @endif - - +
+ @foreach ([['Temporary URLs', 'Start without an account.'], ['Full request context', 'Headers, query and raw body.'], ['Live updates', 'Requests appear as they arrive.']] as [$title, $description]) +
+

{{ $title }}

+

{{ $description }}

+
+ @endforeach +
+ +@endsection diff --git a/routes/channels.php b/routes/channels.php new file mode 100644 index 0000000..65bbbea --- /dev/null +++ b/routes/channels.php @@ -0,0 +1,17 @@ +getKey() === $id; +}); + +Broadcast::channel('webhooks.{endpointId}', function (User $user, string $endpointId): bool { + $endpoint = WebhookEndpoint::query()->find($endpointId); + + return $endpoint instanceof WebhookEndpoint + && ! $endpoint->is_public + && (string) $endpoint->user_id === (string) $user->getKey(); +}); diff --git a/routes/console.php b/routes/console.php index 3c9adf1..fe2a9ba 100644 --- a/routes/console.php +++ b/routes/console.php @@ -2,7 +2,13 @@ use Illuminate\Foundation\Inspiring; use Illuminate\Support\Facades\Artisan; +use Illuminate\Support\Facades\Schedule; Artisan::command('inspire', function () { $this->comment(Inspiring::quote()); })->purpose('Display an inspiring quote'); + +Schedule::command('webhooks:prune') + ->hourly() + ->withoutOverlapping() + ->onOneServer(); diff --git a/routes/web.php b/routes/web.php index 86a06c5..631be58 100644 --- a/routes/web.php +++ b/routes/web.php @@ -1,7 +1,27 @@ name('home'); + +Route::post('/inspectors', [EndpointController::class, 'storeAnonymous']) + ->name('inspectors.store'); + +Route::any('/hook/{token}', [EndpointController::class, 'receive']) + ->middleware('throttle:webhooks') + ->where('token', '[A-Za-z0-9]+') + ->name('webhooks.receive'); + +Route::get('/inspect/{token}', [InspectorController::class, 'show']) + ->where('token', '[A-Za-z0-9]+') + ->name('inspect.show'); + +Route::delete('/inspect/{token}', [EndpointController::class, 'destroy']) + ->where('token', '[A-Za-z0-9]+') + ->name('inspect.destroy'); + +Route::view('/dashboard', 'dashboard-page') + ->middleware(['auth', 'verified']) + ->name('dashboard'); diff --git a/tests/Feature/AuthenticationTest.php b/tests/Feature/AuthenticationTest.php new file mode 100644 index 0000000..02a3a08 --- /dev/null +++ b/tests/Feature/AuthenticationTest.php @@ -0,0 +1,55 @@ +post(route('register.store'), [ + 'name' => 'Ada Lovelace', + 'email' => 'ada@example.com', + 'password' => 'password', + 'password_confirmation' => 'password', + ]); + + $user = User::query()->where('email', 'ada@example.com')->firstOrFail(); + + $response->assertRedirect('/dashboard'); + $this->assertAuthenticatedAs($user); + $this->assertNull($user->email_verified_at); + Notification::assertSentTo($user, VerifyEmail::class); + } + + public function test_an_unverified_user_cannot_open_the_dashboard(): void + { + $user = User::factory()->unverified()->create(); + + $this->actingAs($user)->get(route('dashboard')) + ->assertRedirect(route('verification.notice')); + } + + public function test_a_verified_user_can_log_in_and_log_out(): void + { + $user = User::factory()->create(['password' => 'password']); + + $this->post(route('login.store'), [ + 'email' => $user->email, + 'password' => 'password', + ])->assertRedirect('/dashboard'); + $this->assertAuthenticatedAs($user); + + $this->post(route('logout'))->assertRedirect('/'); + $this->assertGuest(); + } +} diff --git a/tests/Feature/WebhookEndpointTest.php b/tests/Feature/WebhookEndpointTest.php new file mode 100644 index 0000000..6b83e07 --- /dev/null +++ b/tests/Feature/WebhookEndpointTest.php @@ -0,0 +1,236 @@ +post(route('inspectors.store')); + $endpoint = WebhookEndpoint::query()->latest()->firstOrFail(); + + $response->assertRedirect(route('inspect.show', ['token' => $endpoint->token])); + $this->assertTrue($endpoint->is_public); + $this->assertNull($endpoint->user_id); + $this->assertNotEmpty($endpoint->token_hash); + $this->assertTrue($endpoint->expires_at->isFuture()); + } + + public function test_all_supported_http_methods_are_captured(): void + { + $endpoint = WebhookEndpoint::factory()->create(); + Event::fake(); + + foreach (['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'] as $method) { + $response = $this->call($method, $this->hookUrl($endpoint), [], [], [], [ + 'CONTENT_TYPE' => 'application/json', + ], '{"method":"'.$method.'"}'); + + $response->assertOk()->assertContent('{}'); + } + + $this->assertSame(6, $endpoint->requests()->count()); + $this->assertSame( + ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'], + $endpoint->requests()->oldest('received_at')->pluck('method')->all(), + ); + Event::assertDispatched(WebhookRequestReceived::class, 6); + } + + public function test_request_metadata_and_json_payload_are_stored(): void + { + $endpoint = WebhookEndpoint::factory()->create(); + $response = $this->withHeaders([ + 'X-Request-ID' => 'req-123', + 'User-Agent' => 'Inspector Test Client', + ])->postJson($this->hookUrl($endpoint).'?source=checkout&source=retry', [ + 'event' => 'payment.created', + 'amount' => 1299, + ]); + + $response->assertOk(); + $webhookRequest = $endpoint->requests()->firstOrFail(); + + $this->assertSame('POST', $webhookRequest->method); + $this->assertStringContainsString('source=checkout', $webhookRequest->request_uri); + $this->assertSame(['source' => 'retry'], $webhookRequest->query_parameters); + $this->assertSame('payment.created', $webhookRequest->json_payload['event']); + $this->assertSame(1299, $webhookRequest->json_payload['amount']); + $this->assertSame('application/json', $webhookRequest->content_type); + $this->assertSame(strlen((string) $webhookRequest->body), $webhookRequest->body_size); + $this->assertSame('req-123', $webhookRequest->headers['x-request-id'][0]); + $this->assertSame('Inspector Test Client', $webhookRequest->user_agent); + $this->assertNotNull($endpoint->fresh()->last_request_at); + } + + public function test_invalid_json_and_form_data_are_retained_without_a_parsed_payload(): void + { + $endpoint = WebhookEndpoint::factory()->create(); + + $this->call('POST', $this->hookUrl($endpoint), ['name' => 'Ada'], [], [], [ + 'CONTENT_TYPE' => 'application/x-www-form-urlencoded', + ]); + $this->call('POST', $this->hookUrl($endpoint), [], [], [], [ + 'CONTENT_TYPE' => 'application/json', + ], '{invalid-json'); + + $requests = $endpoint->requests()->oldest('received_at')->get(); + + $this->assertCount(2, $requests); + $this->assertNull($requests[0]->json_payload); + $this->assertSame('application/x-www-form-urlencoded', $requests[0]->content_type); + $this->assertNull($requests[1]->json_payload); + $this->assertSame('{invalid-json', $requests[1]->body); + } + + public function test_a_private_endpoint_returns_its_configured_response(): void + { + $user = User::factory()->create(); + $endpoint = WebhookEndpoint::factory()->ownedBy($user)->create(); + + app(ConfigureWebhookEndpointAction::class)->handle( + $user, + $endpoint, + 202, + [ + 'Content-Type' => 'text/plain', + 'X-Inspector' => 'accepted', + ], + 'queued', + ); + + $response = $this->post($this->hookUrl($endpoint), ['ignored' => true]); + + $response->assertStatus(202) + ->assertContent('queued') + ->assertHeader('Content-Type', 'text/plain; charset=UTF-8') + ->assertHeader('X-Inspector', 'accepted'); + } + + public function test_unknown_expired_and_disabled_endpoints_return_not_found(): void + { + $expiredEndpoint = WebhookEndpoint::factory()->expired()->create(); + $disabledEndpoint = WebhookEndpoint::factory()->inactive()->create(); + + $this->post($this->hookUrl($expiredEndpoint))->assertNotFound(); + $this->post($this->hookUrl($disabledEndpoint))->assertNotFound(); + $this->post('/hook/does-not-exist')->assertNotFound(); + } + + public function test_requests_larger_than_the_limit_return_413_and_are_not_saved(): void + { + $endpoint = WebhookEndpoint::factory()->create(); + $body = str_repeat('x', (int) config('webhooks.max_body_bytes') + 1); + + $response = $this->call('POST', $this->hookUrl($endpoint), [], [], [], [ + 'CONTENT_TYPE' => 'text/plain', + ], $body); + + $response->assertStatus(413); + $this->assertSame(0, $endpoint->requests()->count()); + } + + public function test_the_webhook_rate_limit_returns_429(): void + { + config(['webhooks.rate_limit_per_minute' => 1]); + $endpoint = WebhookEndpoint::factory()->create(); + RateLimiter::clear('webhook:'.$this->token($endpoint).':127.0.0.1'); + + $this->post($this->hookUrl($endpoint))->assertOk(); + $this->post($this->hookUrl($endpoint))->assertStatus(429); + } + + public function test_response_headers_reject_header_injection(): void + { + $this->expectException(ValidationException::class); + + $user = User::factory()->create(); + $endpoint = WebhookEndpoint::factory()->ownedBy($user)->create(); + + app(ConfigureWebhookEndpointAction::class)->handle( + $user, + $endpoint, + 200, + ['X-Bad' => "ok\r\nX-Injected: true"], + '{}', + ); + } + + public function test_response_body_size_is_limited_in_bytes(): void + { + config(['webhooks.max_response_body_bytes' => 3]); + + $this->expectException(ValidationException::class); + + $user = User::factory()->create(); + $endpoint = WebhookEndpoint::factory()->ownedBy($user)->create(); + + app(ConfigureWebhookEndpointAction::class)->handle( + $user, + $endpoint, + 200, + ['Content-Type' => 'text/plain'], + 'ää', + ); + } + + public function test_broadcast_event_exposes_only_ids_and_uses_the_correct_channel_visibility(): void + { + $publicEvent = new WebhookRequestReceived('public-endpoint', 'request-1', true); + $privateEvent = new WebhookRequestReceived('private-endpoint', 'request-2', false); + + $publicChannel = $publicEvent->broadcastOn()[0]; + $privateChannel = $privateEvent->broadcastOn()[0]; + + $this->assertInstanceOf(Channel::class, $publicChannel); + $this->assertNotInstanceOf(PrivateChannel::class, $publicChannel); + $this->assertSame('webhooks.public-endpoint', $publicChannel->name); + $this->assertInstanceOf(PrivateChannel::class, $privateChannel); + $this->assertSame('private-webhooks.private-endpoint', $privateChannel->name); + $this->assertSame( + ['endpointId' => 'public-endpoint', 'requestId' => 'request-1'], + $publicEvent->broadcastWith(), + ); + } + + public function test_prune_command_removes_old_data_expired_endpoints_and_excess_requests(): void + { + $endpoint = WebhookEndpoint::factory()->create(); + WebhookRequest::factory()->forEndpoint($endpoint)->create([ + 'received_at' => now()->subDays(8), + ]); + WebhookRequest::factory()->forEndpoint($endpoint)->count(1001)->create(); + $expiredEndpoint = WebhookEndpoint::factory()->expired()->create(); + + $this->artisan('webhooks:prune')->assertExitCode(0); + + $this->assertModelMissing($expiredEndpoint); + $this->assertSame(1000, $endpoint->requests()->count()); + $this->assertFalse($endpoint->requests()->where('received_at', '<', now()->subDays(7))->exists()); + } + + private function hookUrl(WebhookEndpoint $endpoint): string + { + return route('webhooks.receive', ['token' => $this->token($endpoint)]); + } + + private function token(WebhookEndpoint $endpoint): string + { + return (string) $endpoint->token; + } +} diff --git a/tests/Feature/WebhookInspectorTest.php b/tests/Feature/WebhookInspectorTest.php new file mode 100644 index 0000000..695d805 --- /dev/null +++ b/tests/Feature/WebhookInspectorTest.php @@ -0,0 +1,131 @@ +create(); + $otherUser = User::factory()->create(); + $endpoint = WebhookEndpoint::factory()->ownedBy($owner)->create(); + + $this->get(route('inspect.show', ['token' => $endpoint->token]))->assertForbidden(); + $this->actingAs($otherUser)->get(route('inspect.show', ['token' => $endpoint->token]))->assertForbidden(); + $this->actingAs($owner)->get(route('inspect.show', ['token' => $endpoint->token]))->assertOk(); + } + + public function test_private_endpoints_can_only_be_deleted_by_the_owner(): void + { + $owner = User::factory()->create(); + $otherUser = User::factory()->create(); + $endpoint = WebhookEndpoint::factory()->ownedBy($owner)->create(); + + $this->actingAs($otherUser) + ->delete(route('inspect.destroy', ['token' => $endpoint->token])) + ->assertForbidden(); + + $this->assertModelExists($endpoint); + + $this->actingAs($owner) + ->delete(route('inspect.destroy', ['token' => $endpoint->token])) + ->assertRedirect(route('home')); + + $this->assertModelMissing($endpoint); + } + + public function test_livewire_inspector_can_filter_search_select_and_delete_requests(): void + { + $endpoint = WebhookEndpoint::factory()->create(); + $getRequest = WebhookRequest::factory()->forEndpoint($endpoint)->create([ + 'method' => 'GET', + 'request_uri' => '/hook/test?search=visible', + 'body' => '', + 'body_size' => 25, + ]); + WebhookRequest::factory()->forEndpoint($endpoint)->create([ + 'method' => 'POST', + 'request_uri' => '/hook/other', + ]); + + Livewire::test(Inspector::class, ['endpoint' => $endpoint]) + ->assertSee('/hook/test?search=visible') + ->set('methodFilter', 'POST') + ->assertSee('/hook/other') + ->assertDontSee('/hook/test?search=visible') + ->set('methodFilter', 'ALL') + ->set('search', 'visible') + ->assertSee('/hook/test?search=visible') + ->assertDontSee('/hook/other') + ->call('selectRequest', $getRequest->getKey()) + ->assertSee('<script>alert(1)</script>', false) + ->assertDontSee('', false) + ->call('deleteRequest', $getRequest->getKey()); + + $this->assertModelMissing($getRequest); + } + + public function test_private_endpoints_can_be_created_from_the_dashboard(): void + { + $user = User::factory()->create(); + + Livewire::actingAs($user) + ->test(Dashboard::class) + ->set('endpointName', 'Payments') + ->call('saveEndpoint') + ->assertRedirect(); + + $endpoint = $user->webhookEndpoints()->firstOrFail(); + $this->assertFalse($endpoint->is_public); + $this->assertSame('Payments', $endpoint->name); + $this->assertNull($endpoint->expires_at); + } + + public function test_private_endpoint_response_can_be_configured_from_the_inspector(): void + { + $user = User::factory()->create(); + $endpoint = WebhookEndpoint::factory()->ownedBy($user)->create(); + + Livewire::actingAs($user) + ->test(Inspector::class, ['endpoint' => $endpoint]) + ->set('responseStatus', 202) + ->set('responseHeadersJson', '{"Content-Type":"text/plain","X-Inspector":"accepted"}') + ->set('responseBody', 'queued') + ->call('saveResponse') + ->assertSet('responseSaved', true); + + $endpoint->refresh(); + + $this->assertSame(202, $endpoint->response_status); + $this->assertSame( + ['Content-Type' => 'text/plain', 'X-Inspector' => 'accepted'], + $endpoint->response_headers, + ); + $this->assertSame('queued', $endpoint->response_body); + } + + public function test_request_body_is_escaped_in_the_inspector(): void + { + $endpoint = WebhookEndpoint::factory()->create(); + $webhookRequest = WebhookRequest::factory()->forEndpoint($endpoint)->create([ + 'body' => '', + 'body_size' => 29, + ]); + + Livewire::test(Inspector::class, ['endpoint' => $endpoint]) + ->call('selectRequest', $webhookRequest->getKey()) + ->assertDontSee('', false) + ->assertSee('<img src=x onerror=alert(1)>', false); + } +} diff --git a/vite.config.js b/vite.config.js index 1fd66d5..f35b4e7 100644 --- a/vite.config.js +++ b/vite.config.js @@ -1,6 +1,5 @@ import { defineConfig } from 'vite'; import laravel from 'laravel-vite-plugin'; -import { bunny } from 'laravel-vite-plugin/fonts'; import tailwindcss from '@tailwindcss/vite'; export default defineConfig({ @@ -8,11 +7,6 @@ export default defineConfig({ laravel({ input: ['resources/css/app.css', 'resources/js/app.js'], refresh: true, - fonts: [ - bunny('Instrument Sans', { - weights: [400, 500, 600], - }), - ], }), tailwindcss(), ],