Implement webhook inspector

This commit is contained in:
2026-08-04 17:09:39 +02:00
parent b021836c14
commit 75d8b25b64
57 changed files with 5110 additions and 230 deletions
+17 -1
View File
@@ -33,7 +33,7 @@ SESSION_ENCRYPT=false
SESSION_PATH=/ SESSION_PATH=/
SESSION_DOMAIN=null SESSION_DOMAIN=null
BROADCAST_CONNECTION=log BROADCAST_CONNECTION=reverb
FILESYSTEM_DISK=local FILESYSTEM_DISK=local
QUEUE_CONNECTION=database QUEUE_CONNECTION=database
@@ -63,3 +63,19 @@ AWS_BUCKET=
AWS_USE_PATH_STYLE_ENDPOINT=false AWS_USE_PATH_STYLE_ENDPOINT=false
VITE_APP_NAME="${APP_NAME}" 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
+105
View File
@@ -0,0 +1,105 @@
<?php
namespace App\Actions;
use App\Events\WebhookRequestReceived;
use App\Models\WebhookEndpoint;
use App\Models\WebhookRequest;
use Illuminate\Database\Eloquent\ModelNotFoundException;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
use Symfony\Component\HttpKernel\Exception\HttpException;
class CaptureWebhookRequestAction
{
public function handle(WebhookEndpoint $endpoint, Request $request): WebhookRequest
{
$body = $request->getContent();
$bodySize = strlen($body);
if ($bodySize > (int) config('webhooks.max_body_bytes', 1024 * 1024)) {
throw new HttpException(413, 'The webhook request body is too large.');
}
$webhookRequest = DB::transaction(function () use ($endpoint, $request, $body, $bodySize): WebhookRequest {
$lockedEndpoint = WebhookEndpoint::query()
->whereKey($endpoint->getKey())
->lockForUpdate()
->first();
if (! $lockedEndpoint instanceof WebhookEndpoint || ! $lockedEndpoint->acceptsRequests()) {
throw (new ModelNotFoundException)->setModel(WebhookEndpoint::class, [$endpoint->getKey()]);
}
$webhookRequest = WebhookRequest::create([
'webhook_endpoint_id' => $lockedEndpoint->getKey(),
'method' => Str::upper($request->method()),
'request_uri' => $request->getRequestUri(),
'headers' => $request->headers->all(),
'query_parameters' => $request->query(),
'body' => $body,
'json_payload' => $this->parseJsonPayload($body, $request->header('Content-Type')),
'content_type' => $request->header('Content-Type'),
'body_size' => $bodySize,
'ip_address' => $request->ip(),
'user_agent' => $request->userAgent(),
'received_at' => now(),
]);
$lockedEndpoint->forceFill([
'last_request_at' => $webhookRequest->received_at,
])->save();
$this->pruneExcessRequests($lockedEndpoint);
return $webhookRequest;
});
WebhookRequestReceived::dispatch(
(string) $endpoint->getKey(),
(string) $webhookRequest->getKey(),
);
return $webhookRequest;
}
/**
* @return array<string, mixed>|null
*/
private function parseJsonPayload(string $body, ?string $contentType): ?array
{
if ($body === '' || $contentType === null) {
return null;
}
$normalizedContentType = Str::lower($contentType);
if (! Str::contains($normalizedContentType, ['application/json', '+json'])) {
return null;
}
try {
$payload = json_decode($body, true, 512, JSON_THROW_ON_ERROR);
} catch (\JsonException) {
return null;
}
return is_array($payload) ? $payload : null;
}
private function pruneExcessRequests(WebhookEndpoint $endpoint): void
{
$requestIds = $endpoint->requests()
->select('id')
->orderByDesc('received_at')
->orderByDesc('id')
->skip((int) config('webhooks.max_requests_per_endpoint', 1000))
->take(1_000_000_000)
->pluck('id');
if ($requestIds->isNotEmpty()) {
WebhookRequest::query()->whereKey($requestIds)->delete();
}
}
}
@@ -0,0 +1,81 @@
<?php
namespace App\Actions;
use App\Models\User;
use App\Models\WebhookEndpoint;
use Illuminate\Support\Facades\Gate;
use Illuminate\Support\Facades\Validator;
use Illuminate\Validation\ValidationException;
class ConfigureWebhookEndpointAction
{
/**
* @param array<string, string|array<int, string>> $headers
*/
public function handle(
?User $user,
WebhookEndpoint $endpoint,
int $status,
array $headers,
string $body,
): WebhookEndpoint {
Gate::forUser($user)->authorize('update', $endpoint);
$maxResponseBodyBytes = (int) config('webhooks.max_response_body_bytes', 1024 * 1024);
if (strlen($body) > $maxResponseBodyBytes) {
throw ValidationException::withMessages([
'response_body' => "The response body may not exceed {$maxResponseBodyBytes} bytes.",
]);
}
Validator::make([
'status' => $status,
'body' => $body,
], [
'status' => ['integer', 'between:100,599'],
'body' => ['string'],
])->validate();
$this->validateHeaders($headers);
$endpoint->forceFill([
'response_status' => $status,
'response_headers' => $headers,
'response_body' => $body,
])->save();
return $endpoint->refresh();
}
/**
* @param array<string, string|array<int, string>> $headers
*/
private function validateHeaders(array $headers): void
{
if (count($headers) > 50) {
throw ValidationException::withMessages([
'response_headers' => 'A maximum of 50 response headers is allowed.',
]);
}
foreach ($headers as $name => $value) {
if (! is_string($name) || preg_match("/^[!#$%&'*+.^_`|~0-9A-Za-z-]+$/", $name) !== 1) {
throw ValidationException::withMessages([
'response_headers' => 'Every response header name must be a valid HTTP header name.',
]);
}
$values = is_array($value) ? $value : [$value];
foreach ($values as $headerValue) {
if (! is_string($headerValue) || preg_match('/[\r\n]/', $headerValue) === 1 || strlen($headerValue) > 8192) {
throw ValidationException::withMessages([
'response_headers' => 'Response headers must not contain line breaks and may not exceed 8 KiB.',
]);
}
}
}
}
}
@@ -0,0 +1,23 @@
<?php
namespace App\Actions;
use App\Models\WebhookEndpoint;
class CreateAnonymousEndpointAction
{
public function handle(): WebhookEndpoint
{
$token = WebhookEndpoint::generateToken();
return WebhookEndpoint::create([
'name' => 'Anonymous test endpoint',
'token' => $token,
'token_hash' => WebhookEndpoint::tokenHash($token),
'is_public' => true,
'expires_at' => now()->addDays((int) config('webhooks.anonymous_ttl_days', 7)),
'response_headers' => ['Content-Type' => 'application/json'],
'response_body' => '{}',
]);
}
}
@@ -0,0 +1,25 @@
<?php
namespace App\Actions;
use App\Models\User;
use App\Models\WebhookEndpoint;
class CreatePrivateEndpointAction
{
public function handle(User $user, ?string $name = null): WebhookEndpoint
{
$token = WebhookEndpoint::generateToken();
return WebhookEndpoint::create([
'user_id' => $user->getKey(),
'name' => $name ?: 'Private endpoint',
'token' => $token,
'token_hash' => WebhookEndpoint::tokenHash($token),
'is_public' => false,
'expires_at' => null,
'response_headers' => ['Content-Type' => 'application/json'],
'response_body' => '{}',
]);
}
}
@@ -0,0 +1,17 @@
<?php
namespace App\Actions;
use App\Models\User;
use App\Models\WebhookRequest;
use Illuminate\Support\Facades\Gate;
class DeleteWebhookRequestAction
{
public function handle(?User $user, WebhookRequest $webhookRequest): void
{
$webhookRequest->loadMissing('endpoint');
Gate::forUser($user)->authorize('delete', $webhookRequest);
$webhookRequest->delete();
}
}
+25
View File
@@ -0,0 +1,25 @@
<?php
namespace App\Actions\Fortify;
use App\Models\User;
use Illuminate\Support\Facades\Validator;
use Laravel\Fortify\Contracts\CreatesNewUsers;
class CreateNewUser implements CreatesNewUsers
{
public function create(array $input): User
{
Validator::make($input, [
'name' => ['required', 'string', 'max:255'],
'email' => ['required', 'string', 'lowercase', 'email', 'max:255', 'unique:users'],
'password' => ['required', 'string', 'confirmed', 'min:8'],
])->validate();
return User::create([
'name' => $input['name'],
'email' => $input['email'],
'password' => $input['password'],
]);
}
}
+21
View File
@@ -0,0 +1,21 @@
<?php
namespace App\Actions\Fortify;
use App\Models\User;
use Illuminate\Support\Facades\Validator;
use Laravel\Fortify\Contracts\ResetsUserPasswords;
class ResetUserPassword implements ResetsUserPasswords
{
public function reset(User $user, array $input): void
{
Validator::make($input, [
'password' => ['required', 'string', 'confirmed', 'min:8'],
])->validate();
$user->forceFill([
'password' => $input['password'],
])->save();
}
}
+63
View File
@@ -0,0 +1,63 @@
<?php
namespace App\Console\Commands;
use App\Models\WebhookEndpoint;
use App\Models\WebhookRequest;
use Illuminate\Console\Attributes\Description;
use Illuminate\Console\Attributes\Signature;
use Illuminate\Console\Command;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Support\Carbon;
#[Signature('webhooks:prune')]
#[Description('Remove expired endpoints and old webhook requests')]
class PruneWebhookData extends Command
{
public function handle(): int
{
$now = now();
$deletedRequests = WebhookRequest::query()
->where('received_at', '<', $now->copy()->subDays((int) config('webhooks.request_retention_days', 7)))
->delete();
$this->pruneEndpointLimits();
$deletedEndpoints = $this->deleteExpiredAnonymousEndpoints($now);
$this->info("Deleted {$deletedRequests} old requests and {$deletedEndpoints} expired endpoints.");
return self::SUCCESS;
}
private function pruneEndpointLimits(): void
{
WebhookEndpoint::query()
->select('id')
->chunkById(100, function (Collection $endpoints): void {
foreach ($endpoints as $endpoint) {
$requestIds = WebhookRequest::query()
->whereBelongsTo($endpoint, 'endpoint')
->select('id')
->orderByDesc('received_at')
->orderByDesc('id')
->skip((int) config('webhooks.max_requests_per_endpoint', 1000))
->take(1_000_000_000)
->pluck('id');
if ($requestIds->isNotEmpty()) {
WebhookRequest::query()->whereKey($requestIds)->delete();
}
}
});
}
private function deleteExpiredAnonymousEndpoints(Carbon $now): int
{
return WebhookEndpoint::query()
->public()
->whereNull('user_id')
->whereNotNull('expires_at')
->where('expires_at', '<', $now)
->delete();
}
}
+58
View File
@@ -0,0 +1,58 @@
<?php
namespace App\Events;
use App\Models\WebhookEndpoint;
use Illuminate\Broadcasting\Channel;
use Illuminate\Broadcasting\InteractsWithSockets;
use Illuminate\Broadcasting\PrivateChannel;
use Illuminate\Contracts\Broadcasting\ShouldBroadcast;
use Illuminate\Contracts\Events\ShouldDispatchAfterCommit;
use Illuminate\Foundation\Events\Dispatchable;
class WebhookRequestReceived implements ShouldBroadcast, ShouldDispatchAfterCommit
{
use Dispatchable, InteractsWithSockets;
private ?bool $isPublic;
public function __construct(
public readonly string $endpointId,
public readonly string $requestId,
?bool $isPublic = null,
) {
$this->isPublic = $isPublic;
}
/**
* @return array<int, Channel>
*/
public function broadcastOn(): array
{
$isPublic = $this->isPublic ?? WebhookEndpoint::query()
->whereKey($this->endpointId)
->value('is_public');
return [
$isPublic
? new Channel('webhooks.'.$this->endpointId)
: new PrivateChannel('webhooks.'.$this->endpointId),
];
}
public function broadcastAs(): string
{
return 'webhook.request.received';
}
/**
* @return array{endpointId: string, requestId: string}
*/
public function broadcastWith(): array
{
return [
'endpointId' => $this->endpointId,
'requestId' => $this->requestId,
];
}
}
@@ -0,0 +1,56 @@
<?php
namespace App\Http\Controllers;
use App\Actions\CaptureWebhookRequestAction;
use App\Actions\CreateAnonymousEndpointAction;
use App\Models\WebhookEndpoint;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Http\Response;
use Illuminate\Support\Facades\Gate;
class EndpointController extends Controller
{
public function storeAnonymous(CreateAnonymousEndpointAction $createAnonymousEndpoint): RedirectResponse
{
$endpoint = $createAnonymousEndpoint->handle();
return redirect()->route('inspect.show', ['token' => $endpoint->token]);
}
public function receive(
Request $request,
string $token,
CaptureWebhookRequestAction $captureWebhookRequest,
): Response|JsonResponse {
$endpoint = WebhookEndpoint::findByToken($token);
abort_unless($endpoint instanceof WebhookEndpoint && $endpoint->acceptsRequests(), 404);
if (strlen($request->getContent()) > (int) config('webhooks.max_body_bytes', 1024 * 1024)) {
return response()->json(['message' => 'The webhook request body is too large.'], 413);
}
$captureWebhookRequest->handle($endpoint, $request);
$endpoint->refresh();
return response(
$endpoint->response_body ?? '{}',
$endpoint->response_status,
$endpoint->response_headers ?? ['Content-Type' => 'application/json'],
);
}
public function destroy(string $token): RedirectResponse
{
$endpoint = WebhookEndpoint::findByToken($token);
abort_unless($endpoint instanceof WebhookEndpoint, 404);
Gate::authorize('delete', $endpoint);
$endpoint->delete();
return redirect()->route('home')->with('status', 'Endpoint deleted.');
}
}
@@ -0,0 +1,20 @@
<?php
namespace App\Http\Controllers;
use App\Models\WebhookEndpoint;
use Illuminate\Support\Facades\Gate;
use Illuminate\View\View;
class InspectorController extends Controller
{
public function show(string $token): View
{
$endpoint = WebhookEndpoint::findByToken($token);
abort_unless($endpoint instanceof WebhookEndpoint && $endpoint->acceptsRequests(), 404);
Gate::authorize('view', $endpoint);
return view('inspector-page', ['endpoint' => $endpoint]);
}
}
+69
View File
@@ -0,0 +1,69 @@
<?php
namespace App\Livewire;
use App\Actions\CreatePrivateEndpointAction;
use App\Models\User;
use App\Models\WebhookEndpoint;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Gate;
use Illuminate\View\View;
use Livewire\Component;
class Dashboard extends Component
{
public string $endpointName = '';
public bool $showCreateForm = false;
public function saveEndpoint(CreatePrivateEndpointAction $createPrivateEndpoint): void
{
$this->validate([
'endpointName' => ['nullable', 'string', 'max:100'],
]);
/** @var User $user */
$user = Auth::user();
$endpoint = $createPrivateEndpoint->handle($user, $this->endpointName ?: null);
$this->redirect($endpoint->publicUrl(), navigate: true);
}
public function deleteEndpoint(string $endpointId): void
{
$endpoint = $this->ownedEndpoint($endpointId);
Gate::authorize('delete', $endpoint);
$endpoint->delete();
}
public function toggleEndpoint(string $endpointId): void
{
$endpoint = $this->ownedEndpoint($endpointId);
Gate::authorize('update', $endpoint);
$endpoint->update(['is_active' => ! $endpoint->is_active]);
}
public function render(): View
{
/** @var User $user */
$user = Auth::user();
return view('livewire.dashboard', [
'endpoints' => WebhookEndpoint::query()
->ownedBy($user)
->withCount('requests')
->latest()
->get(),
]);
}
private function ownedEndpoint(string $endpointId): WebhookEndpoint
{
/** @var User $user */
$user = Auth::user();
return WebhookEndpoint::query()
->ownedBy($user)
->findOrFail($endpointId);
}
}
+189
View File
@@ -0,0 +1,189 @@
<?php
namespace App\Livewire;
use App\Actions\ConfigureWebhookEndpointAction;
use App\Actions\DeleteWebhookRequestAction;
use App\Models\User;
use App\Models\WebhookEndpoint;
use App\Models\WebhookRequest;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Gate;
use Illuminate\Validation\ValidationException;
use Illuminate\View\View;
use Livewire\Attributes\Locked;
use Livewire\Attributes\Url;
use Livewire\Component;
use Livewire\WithPagination;
class Inspector extends Component
{
use WithPagination;
#[Locked]
public string $endpointId = '';
#[Url]
public string $search = '';
#[Url]
public string $methodFilter = 'ALL';
#[Url]
public ?string $selectedRequestId = null;
public int $responseStatus = 200;
public string $responseHeadersJson = '{}';
public string $responseBody = '{}';
public bool $responseSaved = false;
public function mount(WebhookEndpoint $endpoint): void
{
Gate::authorize('view', $endpoint);
$this->endpointId = (string) $endpoint->getKey();
$this->responseStatus = $endpoint->response_status;
$this->responseHeadersJson = json_encode(
$endpoint->response_headers ?? [],
JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES,
) ?: '{}';
$this->responseBody = $endpoint->response_body ?? '{}';
}
/**
* @return array<string, string>
*/
public function getListeners(): array
{
$endpoint = $this->endpoint();
$prefix = $endpoint->is_public ? 'echo' : 'echo-private';
return [
$prefix.':webhooks.'.$this->endpointId.',.webhook.request.received' => 'requestReceived',
];
}
/**
* @param array<string, mixed> $event
*/
public function requestReceived(array $event = []): void
{
if (($event['endpointId'] ?? null) === $this->endpointId) {
$this->resetPage();
}
}
public function updatedSearch(): void
{
$this->resetPage();
}
public function updatedMethodFilter(): void
{
$this->resetPage();
}
public function selectRequest(string $requestId): void
{
$webhookRequest = $this->endpoint()->requests()->findOrFail($requestId);
Gate::authorize('view', $webhookRequest);
$this->selectedRequestId = $webhookRequest->getKey();
}
public function clearSelectedRequest(): void
{
$this->selectedRequestId = null;
}
public function deleteRequest(string $requestId, DeleteWebhookRequestAction $deleteWebhookRequest): void
{
$webhookRequest = $this->endpoint()->requests()->findOrFail($requestId);
$deleteWebhookRequest->handle(Auth::user(), $webhookRequest);
if ($this->selectedRequestId === $requestId) {
$this->selectedRequestId = null;
}
}
public function saveResponse(ConfigureWebhookEndpointAction $configureWebhookEndpoint): void
{
$this->validate([
'responseStatus' => ['required', 'integer', 'between:100,599'],
'responseBody' => ['string'],
'responseHeadersJson' => ['required', 'json'],
]);
$headers = json_decode($this->responseHeadersJson, true);
if (! is_array($headers)) {
throw ValidationException::withMessages([
'responseHeadersJson' => 'Response headers must be a JSON object.',
]);
}
/** @var User|null $user */
$user = Auth::user();
$configureWebhookEndpoint->handle(
$user,
$this->endpoint(),
$this->responseStatus,
$headers,
$this->responseBody,
);
$this->responseSaved = true;
}
public function deleteEndpoint(): void
{
$endpoint = $this->endpoint();
Gate::authorize('delete', $endpoint);
$endpoint->delete();
$this->redirectRoute('home');
}
public function render(): View
{
$endpoint = $this->endpoint();
$requestQuery = $endpoint->requests()
->select(['id', 'method', 'request_uri', 'content_type', 'body_size', 'ip_address', 'received_at'])
->when($this->methodFilter !== 'ALL', fn ($query) => $query->where('method', $this->methodFilter))
->when($this->search !== '', function ($query): void {
$search = '%'.$this->search.'%';
$query->where(function ($query) use ($search): void {
$query->where('request_uri', 'like', $search)
->orWhere('ip_address', 'like', $search)
->orWhere('user_agent', 'like', $search);
});
})
->latest('received_at');
$selectedRequest = null;
if ($this->selectedRequestId !== null) {
$selectedRequest = $endpoint->requests()->find($this->selectedRequestId);
if ($selectedRequest instanceof WebhookRequest) {
Gate::authorize('view', $selectedRequest);
}
}
return view('livewire.inspector', [
'endpoint' => $endpoint,
'webhookRequests' => $requestQuery->paginate((int) config('webhooks.page_size', 25)),
'selectedRequest' => $selectedRequest,
]);
}
private function endpoint(): WebhookEndpoint
{
$endpoint = WebhookEndpoint::query()->findOrFail($this->endpointId);
Gate::authorize('view', $endpoint);
return $endpoint;
}
}
+8 -2
View File
@@ -2,21 +2,27 @@
namespace App\Models; namespace App\Models;
// use Illuminate\Contracts\Auth\MustVerifyEmail;
use Database\Factories\UserFactory; use Database\Factories\UserFactory;
use Illuminate\Contracts\Auth\MustVerifyEmail;
use Illuminate\Database\Eloquent\Attributes\Fillable; use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Attributes\Hidden; use Illuminate\Database\Eloquent\Attributes\Hidden;
use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Foundation\Auth\User as Authenticatable; use Illuminate\Foundation\Auth\User as Authenticatable;
use Illuminate\Notifications\Notifiable; use Illuminate\Notifications\Notifiable;
#[Fillable(['name', 'email', 'password'])] #[Fillable(['name', 'email', 'password'])]
#[Hidden(['password', 'remember_token'])] #[Hidden(['password', 'remember_token'])]
class User extends Authenticatable class User extends Authenticatable implements MustVerifyEmail
{ {
/** @use HasFactory<UserFactory> */ /** @use HasFactory<UserFactory> */
use HasFactory, Notifiable; use HasFactory, Notifiable;
public function webhookEndpoints(): HasMany
{
return $this->hasMany(WebhookEndpoint::class);
}
/** /**
* Get the attributes that should be cast. * Get the attributes that should be cast.
* *
+120
View File
@@ -0,0 +1,120 @@
<?php
namespace App\Models;
use Database\Factories\WebhookEndpointFactory;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Concerns\HasUlids;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Support\Str;
class WebhookEndpoint extends Model
{
/** @use HasFactory<WebhookEndpointFactory> */
use HasFactory, HasUlids;
protected $fillable = [
'user_id',
'name',
'token',
'token_hash',
'is_public',
'is_active',
'expires_at',
'response_status',
'response_headers',
'response_body',
'last_request_at',
];
protected $hidden = [
'token',
'token_hash',
];
protected $attributes = [
'is_public' => true,
'is_active' => true,
'response_status' => 200,
'response_body' => '{}',
];
/**
* @return array<string, string>
*/
protected function casts(): array
{
return [
'token' => 'encrypted',
'response_headers' => 'encrypted:array',
'is_public' => 'boolean',
'is_active' => 'boolean',
'expires_at' => 'datetime',
'last_request_at' => 'datetime',
'response_status' => 'integer',
];
}
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
public function requests(): HasMany
{
return $this->hasMany(WebhookRequest::class);
}
public function scopeOwnedBy(Builder $query, User $user): Builder
{
return $query->whereBelongsTo($user);
}
public function scopePublic(Builder $query): Builder
{
return $query->where('is_public', true);
}
public function scopeActive(Builder $query): Builder
{
return $query->where('is_active', true);
}
public static function tokenHash(string $token): string
{
return hash('sha256', $token);
}
public static function findByToken(string $token): ?self
{
return static::query()->where('token_hash', static::tokenHash($token))->first();
}
public function acceptsRequests(): bool
{
return $this->is_active && ! $this->isExpired();
}
public function isExpired(): bool
{
return $this->expires_at !== null && $this->expires_at->isPast();
}
public function publicUrl(): string
{
return route('inspect.show', ['token' => $this->token]);
}
public function webhookUrl(): string
{
return route('webhooks.receive', ['token' => $this->token]);
}
public static function generateToken(): string
{
return Str::random(48);
}
}
+50
View File
@@ -0,0 +1,50 @@
<?php
namespace App\Models;
use Database\Factories\WebhookRequestFactory;
use Illuminate\Database\Eloquent\Concerns\HasUlids;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class WebhookRequest extends Model
{
/** @use HasFactory<WebhookRequestFactory> */
use HasFactory, HasUlids;
protected $fillable = [
'webhook_endpoint_id',
'method',
'request_uri',
'headers',
'query_parameters',
'body',
'json_payload',
'content_type',
'body_size',
'ip_address',
'user_agent',
'received_at',
];
/**
* @return array<string, string>
*/
protected function casts(): array
{
return [
'headers' => 'encrypted:array',
'query_parameters' => 'encrypted:array',
'body' => 'encrypted',
'json_payload' => 'encrypted:array',
'body_size' => 'integer',
'received_at' => 'datetime',
];
}
public function endpoint(): BelongsTo
{
return $this->belongsTo(WebhookEndpoint::class, 'webhook_endpoint_id');
}
}
+40
View File
@@ -0,0 +1,40 @@
<?php
namespace App\Policies;
use App\Models\User;
use App\Models\WebhookEndpoint;
class WebhookEndpointPolicy
{
public function viewAny(?User $user): bool
{
return $user !== null;
}
public function view(?User $user, WebhookEndpoint $webhookEndpoint): bool
{
return $webhookEndpoint->is_public || $this->isOwner($user, $webhookEndpoint);
}
public function create(?User $user): bool
{
return $user !== null;
}
public function update(?User $user, WebhookEndpoint $webhookEndpoint): bool
{
return ! $webhookEndpoint->is_public && $this->isOwner($user, $webhookEndpoint);
}
public function delete(?User $user, WebhookEndpoint $webhookEndpoint): bool
{
return ($webhookEndpoint->is_public && $webhookEndpoint->user_id === null)
|| $this->isOwner($user, $webhookEndpoint);
}
private function isOwner(?User $user, WebhookEndpoint $webhookEndpoint): bool
{
return $user !== null && (string) $webhookEndpoint->user_id === (string) $user->getKey();
}
}
+41
View File
@@ -0,0 +1,41 @@
<?php
namespace App\Policies;
use App\Models\User;
use App\Models\WebhookRequest;
class WebhookRequestPolicy
{
public function viewAny(?User $user): bool
{
return $user !== null;
}
public function view(?User $user, WebhookRequest $webhookRequest): bool
{
return $this->canAccessEndpoint($user, $webhookRequest);
}
public function create(?User $user): bool
{
return false;
}
public function update(?User $user, WebhookRequest $webhookRequest): bool
{
return false;
}
public function delete(?User $user, WebhookRequest $webhookRequest): bool
{
return $this->canAccessEndpoint($user, $webhookRequest);
}
private function canAccessEndpoint(?User $user, WebhookRequest $webhookRequest): bool
{
$webhookRequest->loadMissing('endpoint');
return (new WebhookEndpointPolicy)->view($user, $webhookRequest->endpoint);
}
}
+26 -1
View File
@@ -2,6 +2,16 @@
namespace App\Providers; 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; use Illuminate\Support\ServiceProvider;
class AppServiceProvider extends ServiceProvider class AppServiceProvider extends ServiceProvider
@@ -19,6 +29,21 @@ class AppServiceProvider extends ServiceProvider
*/ */
public function boot(): void public function boot(): void
{ {
// Model::preventLazyLoading(! app()->isProduction());
Gate::policy(WebhookEndpoint::class, WebhookEndpointPolicy::class);
Gate::policy(WebhookRequest::class, WebhookRequestPolicy::class);
RateLimiter::for('login', function (Request $request): Limit {
return Limit::perMinute(5)->by($request->string('email')->lower()->value().'|'.$request->ip());
});
RateLimiter::for('webhooks', function (Request $request): Limit {
return Limit::perMinute((int) config('webhooks.rate_limit_per_minute', 60))
->by('webhook:'.(string) $request->route('token').':'.$request->ip())
->response(fn (Request $request, array $headers): JsonResponse => response()->json([
'message' => 'Too many webhook requests. Try again later.',
], 429, $headers));
});
} }
} }
+35
View File
@@ -0,0 +1,35 @@
<?php
namespace App\Providers;
use App\Actions\Fortify\CreateNewUser;
use App\Actions\Fortify\ResetUserPassword;
use Illuminate\Http\Request;
use Illuminate\Support\ServiceProvider;
use Laravel\Fortify\Fortify;
class FortifyServiceProvider extends ServiceProvider
{
/**
* Register services.
*/
public function register(): void
{
//
}
/**
* Bootstrap services.
*/
public function boot(): void
{
Fortify::loginView(fn () => view('auth.login'));
Fortify::registerView(fn () => view('auth.register'));
Fortify::requestPasswordResetLinkView(fn () => view('auth.forgot-password'));
Fortify::resetPasswordView(fn (Request $request) => view('auth.reset-password', ['request' => $request]));
Fortify::verifyEmailView(fn () => view('auth.verify-email'));
Fortify::createUsersUsing(CreateNewUser::class);
Fortify::resetUserPasswordsUsing(ResetUserPassword::class);
}
}
+4 -1
View File
@@ -10,10 +10,13 @@ return Application::configure(basePath: dirname(__DIR__))
web: __DIR__.'/../routes/web.php', web: __DIR__.'/../routes/web.php',
api: __DIR__.'/../routes/api.php', api: __DIR__.'/../routes/api.php',
commands: __DIR__.'/../routes/console.php', commands: __DIR__.'/../routes/console.php',
channels: __DIR__.'/../routes/channels.php',
health: '/up', health: '/up',
) )
->withMiddleware(function (Middleware $middleware): void { ->withMiddleware(function (Middleware $middleware): void {
// $middleware->validateCsrfTokens(except: [
'hook/*',
]);
}) })
->withExceptions(function (Exceptions $exceptions): void { ->withExceptions(function (Exceptions $exceptions): void {
$exceptions->shouldRenderJsonWhen( $exceptions->shouldRenderJsonWhen(
+2
View File
@@ -1,7 +1,9 @@
<?php <?php
use App\Providers\AppServiceProvider; use App\Providers\AppServiceProvider;
use App\Providers\FortifyServiceProvider;
return [ return [
AppServiceProvider::class, AppServiceProvider::class,
FortifyServiceProvider::class,
]; ];
+5 -2
View File
@@ -8,9 +8,12 @@
"require": { "require": {
"php": "^8.3", "php": "^8.3",
"laravel/boost": "^2.4", "laravel/boost": "^2.4",
"laravel/fortify": "^1.37",
"laravel/framework": "^13.8", "laravel/framework": "^13.8",
"laravel/reverb": "^1.11",
"laravel/sanctum": "^4.0", "laravel/sanctum": "^4.0",
"laravel/tinker": "^3.0" "laravel/tinker": "^3.0",
"livewire/livewire": "^4.3"
}, },
"require-dev": { "require-dev": {
"fakerphp/faker": "^1.23", "fakerphp/faker": "^1.23",
@@ -44,7 +47,7 @@
], ],
"dev": [ "dev": [
"Composer\\Config::disableProcessTimeout", "Composer\\Config::disableProcessTimeout",
"npx concurrently -c \"#93c5fd,#c4b5fd,#fb7185,#fdba74\" \"php artisan serve\" \"php artisan queue:listen --tries=1 --timeout=0\" \"php artisan pail --timeout=0\" \"npm run dev\" --names=server,queue,logs,vite --kill-others" "npx concurrently -c \"#93c5fd,#c4b5fd,#fb7185,#fdba74,#67e8f9\" \"php artisan serve\" \"php artisan queue:listen --tries=1 --timeout=0\" \"php artisan pail --timeout=0\" \"php artisan reverb:start\" \"npm run dev\" --names=server,queue,logs,reverb,vite --kill-others"
], ],
"test": [ "test": [
"@php artisan config:clear --ansi @no_additional_args", "@php artisan config:clear --ansi @no_additional_args",
Generated
+2362 -1
View File
File diff suppressed because it is too large Load Diff
+82
View File
@@ -0,0 +1,82 @@
<?php
return [
/*
|--------------------------------------------------------------------------
| Default Broadcaster
|--------------------------------------------------------------------------
|
| This option controls the default broadcaster that will be used by the
| framework when an event needs to be broadcast. You may set this to
| any of the connections defined in the "connections" array below.
|
| Supported: "reverb", "pusher", "ably", "redis", "log", "null"
|
*/
'default' => 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',
],
],
];
+170
View File
@@ -0,0 +1,170 @@
<?php
use Laravel\Fortify\Features;
return [
/*
|--------------------------------------------------------------------------
| Fortify Guard
|--------------------------------------------------------------------------
|
| Here you may specify which authentication guard Fortify will use while
| authenticating users. This value should correspond with one of your
| guards that is already present in your "auth" configuration file.
|
*/
'guard' => '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(),
],
];
+102
View File
@@ -0,0 +1,102 @@
<?php
return [
/*
|--------------------------------------------------------------------------
| Default Reverb Server
|--------------------------------------------------------------------------
|
| This option controls the default server used by Reverb to handle
| incoming messages as well as broadcasting message to all your
| connected clients. At this time only "reverb" is supported.
|
*/
'default' => 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),
],
],
],
],
];
+11
View File
@@ -0,0 +1,11 @@
<?php
return [
'anonymous_ttl_days' => (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,
];
@@ -0,0 +1,62 @@
<?php
namespace Database\Factories;
use App\Models\User;
use App\Models\WebhookEndpoint;
use Illuminate\Database\Eloquent\Factories\Factory;
use Illuminate\Support\Str;
/**
* @extends Factory<WebhookEndpoint>
*/
class WebhookEndpointFactory extends Factory
{
/**
* Define the model's default state.
*
* @return array<string, mixed>
*/
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,
]);
}
}
@@ -0,0 +1,43 @@
<?php
namespace Database\Factories;
use App\Models\WebhookEndpoint;
use App\Models\WebhookRequest;
use Illuminate\Database\Eloquent\Factories\Factory;
/**
* @extends Factory<WebhookRequest>
*/
class WebhookRequestFactory extends Factory
{
/**
* Define the model's default state.
*
* @return array<string, mixed>
*/
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(),
]);
}
}
@@ -0,0 +1,41 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('webhook_endpoints', function (Blueprint $table) {
$table->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');
}
};
@@ -0,0 +1,44 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('webhook_requests', function (Blueprint $table) {
$table->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');
}
};
+40
View File
@@ -4,6 +4,10 @@
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"dependencies": {
"laravel-echo": "^2.4.0",
"pusher-js": "^8.6.0"
},
"devDependencies": { "devDependencies": {
"@tailwindcss/vite": "^4.0.0", "@tailwindcss/vite": "^4.0.0",
"concurrently": "^9.0.1", "concurrently": "^9.0.1",
@@ -856,6 +860,27 @@
"jiti": "lib/jiti-cli.mjs" "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": { "node_modules/laravel-vite-plugin": {
"version": "3.1.3", "version": "3.1.3",
"resolved": "https://registry.npmjs.org/laravel-vite-plugin/-/laravel-vite-plugin-3.1.3.tgz", "resolved": "https://registry.npmjs.org/laravel-vite-plugin/-/laravel-vite-plugin-3.1.3.tgz",
@@ -1234,6 +1259,15 @@
"node": "^10 || ^12 || >=14" "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": { "node_modules/require-directory": {
"version": "2.1.1", "version": "2.1.1",
"resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz",
@@ -1409,6 +1443,12 @@
"dev": true, "dev": true,
"license": "0BSD" "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": { "node_modules/vite": {
"version": "8.2.0", "version": "8.2.0",
"resolved": "https://registry.npmjs.org/vite/-/vite-8.2.0.tgz", "resolved": "https://registry.npmjs.org/vite/-/vite-8.2.0.tgz",
+4
View File
@@ -12,5 +12,9 @@
"laravel-vite-plugin": "^3.1", "laravel-vite-plugin": "^3.1",
"tailwindcss": "^4.0.0", "tailwindcss": "^4.0.0",
"vite": "^8.0.0" "vite": "^8.0.0"
},
"dependencies": {
"laravel-echo": "^2.4.0",
"pusher-js": "^8.6.0"
} }
} }
+2
View File
@@ -2,6 +2,8 @@
@source '../../vendor/laravel/framework/src/Illuminate/Pagination/resources/views/*.blade.php'; @source '../../vendor/laravel/framework/src/Illuminate/Pagination/resources/views/*.blade.php';
@source '../../storage/framework/views/*.php'; @source '../../storage/framework/views/*.php';
@source '../views';
@source '../js';
@theme { @theme {
--font-sans: 'Instrument Sans', ui-sans-serif, system-ui, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', --font-sans: 'Instrument Sans', ui-sans-serif, system-ui, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji',
+8
View File
@@ -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';
+14
View File
@@ -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'],
});
@@ -0,0 +1,26 @@
@extends('layouts.auth')
@section('content')
<div class="mb-6">
<h1 class="text-2xl font-semibold tracking-tight text-white">Reset your password</h1>
<p class="mt-2 text-sm leading-6 text-slate-400">Enter your email and we will send you a link to choose a new password.</p>
</div>
@if (session('status'))
<div class="mb-5 rounded-xl border border-emerald-300/20 bg-emerald-300/10 px-4 py-3 text-sm text-emerald-200">{{ session('status') }}</div>
@endif
@if ($errors->any())
<div class="mb-5 rounded-xl border border-rose-300/20 bg-rose-300/10 px-4 py-3 text-sm text-rose-200">{{ $errors->first() }}</div>
@endif
<form action="{{ route('password.email') }}" method="POST" class="space-y-5">
@csrf
<div>
<label for="email" class="text-sm font-medium text-slate-200">Email</label>
<input id="email" name="email" type="email" value="{{ old('email') }}" required autofocus autocomplete="email" class="mt-2 w-full rounded-xl border border-white/10 bg-slate-950/70 px-4 py-3 text-sm text-white outline-none focus:border-cyan-300/60 focus:ring-2 focus:ring-cyan-300/20">
</div>
<button type="submit" class="w-full rounded-xl bg-cyan-400 px-4 py-3 font-semibold text-slate-950 transition hover:bg-cyan-300">Email reset link</button>
</form>
<p class="mt-6 text-center text-sm text-slate-500"><a href="{{ route('login') }}" class="text-cyan-300 hover:text-cyan-200">Back to login</a></p>
@endsection
+36
View File
@@ -0,0 +1,36 @@
@extends('layouts.auth')
@section('content')
<div class="mb-6">
<h1 class="text-2xl font-semibold tracking-tight text-white">Welcome back</h1>
<p class="mt-2 text-sm text-slate-400">Sign in to manage your private webhook endpoints.</p>
</div>
@if ($errors->any())
<div class="mb-5 rounded-xl border border-rose-300/20 bg-rose-300/10 px-4 py-3 text-sm text-rose-200">{{ $errors->first() }}</div>
@endif
<form action="{{ route('login') }}" method="POST" class="space-y-5">
@csrf
<div>
<label for="email" class="text-sm font-medium text-slate-200">Email</label>
<input id="email" name="email" type="email" value="{{ old('email') }}" required autofocus autocomplete="email" class="mt-2 w-full rounded-xl border border-white/10 bg-slate-950/70 px-4 py-3 text-sm text-white outline-none placeholder:text-slate-600 focus:border-cyan-300/60 focus:ring-2 focus:ring-cyan-300/20">
</div>
<div>
<div class="flex items-center justify-between gap-4">
<label for="password" class="text-sm font-medium text-slate-200">Password</label>
@if (Route::has('password.request'))
<a href="{{ route('password.request') }}" class="text-xs text-cyan-300 hover:text-cyan-200">Forgot password?</a>
@endif
</div>
<input id="password" name="password" type="password" required autocomplete="current-password" class="mt-2 w-full rounded-xl border border-white/10 bg-slate-950/70 px-4 py-3 text-sm text-white outline-none focus:border-cyan-300/60 focus:ring-2 focus:ring-cyan-300/20">
</div>
<label class="flex items-center gap-3 text-sm text-slate-400">
<input name="remember" type="checkbox" value="1" class="size-4 rounded border-white/20 bg-slate-900 text-cyan-400 focus:ring-cyan-300/30">
Remember me
</label>
<button type="submit" class="w-full rounded-xl bg-cyan-400 px-4 py-3 font-semibold text-slate-950 transition hover:bg-cyan-300">Log in</button>
</form>
<p class="mt-6 text-center text-sm text-slate-500">No account? <a href="{{ route('register') }}" class="text-cyan-300 hover:text-cyan-200">Create one</a></p>
@endsection
+35
View File
@@ -0,0 +1,35 @@
@extends('layouts.auth')
@section('content')
<div class="mb-6">
<h1 class="text-2xl font-semibold tracking-tight text-white">Create your workspace</h1>
<p class="mt-2 text-sm text-slate-400">Private endpoints stay connected to your account.</p>
</div>
@if ($errors->any())
<div class="mb-5 rounded-xl border border-rose-300/20 bg-rose-300/10 px-4 py-3 text-sm text-rose-200">{{ $errors->first() }}</div>
@endif
<form action="{{ route('register') }}" method="POST" class="space-y-5">
@csrf
<div>
<label for="name" class="text-sm font-medium text-slate-200">Name</label>
<input id="name" name="name" type="text" value="{{ old('name') }}" required autofocus autocomplete="name" class="mt-2 w-full rounded-xl border border-white/10 bg-slate-950/70 px-4 py-3 text-sm text-white outline-none focus:border-cyan-300/60 focus:ring-2 focus:ring-cyan-300/20">
</div>
<div>
<label for="email" class="text-sm font-medium text-slate-200">Email</label>
<input id="email" name="email" type="email" value="{{ old('email') }}" required autocomplete="email" class="mt-2 w-full rounded-xl border border-white/10 bg-slate-950/70 px-4 py-3 text-sm text-white outline-none focus:border-cyan-300/60 focus:ring-2 focus:ring-cyan-300/20">
</div>
<div>
<label for="password" class="text-sm font-medium text-slate-200">Password</label>
<input id="password" name="password" type="password" required autocomplete="new-password" class="mt-2 w-full rounded-xl border border-white/10 bg-slate-950/70 px-4 py-3 text-sm text-white outline-none focus:border-cyan-300/60 focus:ring-2 focus:ring-cyan-300/20">
</div>
<div>
<label for="password_confirmation" class="text-sm font-medium text-slate-200">Confirm password</label>
<input id="password_confirmation" name="password_confirmation" type="password" required autocomplete="new-password" class="mt-2 w-full rounded-xl border border-white/10 bg-slate-950/70 px-4 py-3 text-sm text-white outline-none focus:border-cyan-300/60 focus:ring-2 focus:ring-cyan-300/20">
</div>
<button type="submit" class="w-full rounded-xl bg-cyan-400 px-4 py-3 font-semibold text-slate-950 transition hover:bg-cyan-300">Create account</button>
</form>
<p class="mt-6 text-center text-sm text-slate-500">Already registered? <a href="{{ route('login') }}" class="text-cyan-300 hover:text-cyan-200">Log in</a></p>
@endsection
@@ -0,0 +1,30 @@
@extends('layouts.auth')
@section('content')
<div class="mb-6">
<h1 class="text-2xl font-semibold tracking-tight text-white">Choose a new password</h1>
<p class="mt-2 text-sm text-slate-400">Your new password must be at least eight characters.</p>
</div>
@if ($errors->any())
<div class="mb-5 rounded-xl border border-rose-300/20 bg-rose-300/10 px-4 py-3 text-sm text-rose-200">{{ $errors->first() }}</div>
@endif
<form action="{{ route('password.update') }}" method="POST" class="space-y-5">
@csrf
<input type="hidden" name="token" value="{{ request()->route('token') }}">
<div>
<label for="email" class="text-sm font-medium text-slate-200">Email</label>
<input id="email" name="email" type="email" value="{{ request()->email ?: old('email') }}" required autofocus autocomplete="email" class="mt-2 w-full rounded-xl border border-white/10 bg-slate-950/70 px-4 py-3 text-sm text-white outline-none focus:border-cyan-300/60 focus:ring-2 focus:ring-cyan-300/20">
</div>
<div>
<label for="password" class="text-sm font-medium text-slate-200">New password</label>
<input id="password" name="password" type="password" required autocomplete="new-password" class="mt-2 w-full rounded-xl border border-white/10 bg-slate-950/70 px-4 py-3 text-sm text-white outline-none focus:border-cyan-300/60 focus:ring-2 focus:ring-cyan-300/20">
</div>
<div>
<label for="password_confirmation" class="text-sm font-medium text-slate-200">Confirm password</label>
<input id="password_confirmation" name="password_confirmation" type="password" required autocomplete="new-password" class="mt-2 w-full rounded-xl border border-white/10 bg-slate-950/70 px-4 py-3 text-sm text-white outline-none focus:border-cyan-300/60 focus:ring-2 focus:ring-cyan-300/20">
</div>
<button type="submit" class="w-full rounded-xl bg-cyan-400 px-4 py-3 font-semibold text-slate-950 transition hover:bg-cyan-300">Reset password</button>
</form>
@endsection
@@ -0,0 +1,22 @@
@extends('layouts.auth')
@section('content')
<div class="mb-6">
<h1 class="text-2xl font-semibold tracking-tight text-white">Verify your email</h1>
<p class="mt-2 text-sm leading-6 text-slate-400">We sent a verification link to your email address. Open it to unlock your private workspace.</p>
</div>
@if (session('status') === 'verification-link-sent')
<div class="mb-5 rounded-xl border border-emerald-300/20 bg-emerald-300/10 px-4 py-3 text-sm text-emerald-200">A new verification link has been sent.</div>
@endif
<form action="{{ route('verification.send') }}" method="POST">
@csrf
<button type="submit" class="w-full rounded-xl bg-cyan-400 px-4 py-3 font-semibold text-slate-950 transition hover:bg-cyan-300">Resend verification email</button>
</form>
<form action="{{ route('logout') }}" method="POST" class="mt-4 text-center">
@csrf
<button type="submit" class="text-sm text-slate-500 transition hover:text-slate-300">Log out</button>
</form>
@endsection
+7
View File
@@ -0,0 +1,7 @@
@extends('layouts.app')
@section('content')
<div class="mx-auto max-w-7xl px-6 py-10 lg:px-8">
<livewire:dashboard />
</div>
@endsection
+7
View File
@@ -0,0 +1,7 @@
@extends('layouts.app')
@section('content')
<div class="mx-auto max-w-7xl px-6 py-10 lg:px-8">
<livewire:inspector :endpoint="$endpoint" />
</div>
@endsection
+56
View File
@@ -0,0 +1,56 @@
<!DOCTYPE html>
<html lang="{{ str_replace('_', '-', app()->getLocale()) }}" class="bg-slate-950">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="csrf-token" content="{{ csrf_token() }}">
<title>{{ $title ?? config('app.name', 'Webhook Inspector') }}</title>
@if (file_exists(public_path('build/manifest.json')) || file_exists(public_path('hot')))
@vite(['resources/css/app.css', 'resources/js/app.js'])
@endif
@livewireStyles
</head>
<body class="min-h-screen bg-slate-950 font-sans text-slate-100 antialiased">
<header class="border-b border-white/10 bg-slate-950/90 backdrop-blur">
<div class="mx-auto flex max-w-7xl items-center justify-between gap-6 px-6 py-4 lg:px-8">
<a href="{{ route('home') }}" class="flex items-center gap-3 font-semibold tracking-tight text-white">
<span class="flex size-9 items-center justify-center rounded-xl bg-cyan-400 font-mono text-sm font-bold text-slate-950">WI</span>
<span>Webhook Inspector</span>
</a>
<nav class="flex items-center gap-4 text-sm text-slate-300">
@auth
<a href="{{ route('dashboard') }}" class="transition hover:text-cyan-300">Dashboard</a>
<form action="{{ route('logout') }}" method="POST">
@csrf
<button type="submit" class="rounded-lg border border-white/10 px-3 py-2 transition hover:border-cyan-300/50 hover:text-cyan-300">Log out</button>
</form>
@else
@if (Route::has('login'))
<a href="{{ route('login') }}" class="transition hover:text-cyan-300">Log in</a>
@endif
@if (Route::has('register'))
<a href="{{ route('register') }}" class="rounded-lg bg-cyan-400 px-3 py-2 font-medium text-slate-950 transition hover:bg-cyan-300">Create account</a>
@endif
@endauth
</nav>
</div>
</header>
@if (session('status'))
<div class="mx-auto max-w-7xl px-6 pt-6 lg:px-8">
<div class="rounded-xl border border-emerald-400/30 bg-emerald-400/10 px-4 py-3 text-sm text-emerald-200">
{{ session('status') }}
</div>
</div>
@endif
<main>
@yield('content')
</main>
@livewireScripts
</body>
</html>
+24
View File
@@ -0,0 +1,24 @@
<!DOCTYPE html>
<html lang="{{ str_replace('_', '-', app()->getLocale()) }}" class="bg-slate-950">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="csrf-token" content="{{ csrf_token() }}">
<title>{{ $title ?? 'Authentication · Webhook Inspector' }}</title>
@if (file_exists(public_path('build/manifest.json')) || file_exists(public_path('hot')))
@vite(['resources/css/app.css', 'resources/js/app.js'])
@endif
</head>
<body class="flex min-h-screen items-center justify-center bg-slate-950 px-6 py-12 font-sans text-slate-100 antialiased">
<div class="w-full max-w-md">
<a href="{{ route('home') }}" class="mb-8 flex items-center justify-center gap-3 font-semibold tracking-tight text-white">
<span class="flex size-9 items-center justify-center rounded-xl bg-cyan-400 font-mono text-sm font-bold text-slate-950">WI</span>
<span>Webhook Inspector</span>
</a>
<section class="rounded-2xl border border-white/10 bg-white/[0.04] p-6 shadow-2xl shadow-black/20 sm:p-8">
@yield('content')
</section>
</div>
</body>
</html>
@@ -0,0 +1,69 @@
<div class="space-y-8">
<div class="flex flex-col justify-between gap-5 sm:flex-row sm:items-end">
<div>
<p class="text-sm font-medium uppercase tracking-[0.18em] text-cyan-300">Private workspace</p>
<h1 class="mt-2 text-3xl font-semibold tracking-tight text-white">Your endpoints</h1>
<p class="mt-2 max-w-2xl text-slate-400">Create durable URLs for integrations you own and keep their request history for seven days.</p>
</div>
<button type="button" wire:click="$toggle('showCreateForm')" class="rounded-xl bg-cyan-400 px-4 py-2.5 text-sm font-semibold text-slate-950 transition hover:bg-cyan-300">
{{ $showCreateForm ? 'Close form' : 'New endpoint' }}
</button>
</div>
@if ($showCreateForm)
<form wire:submit="saveEndpoint" class="rounded-2xl border border-cyan-300/20 bg-cyan-300/[0.06] p-5 sm:p-6">
<div class="flex flex-col gap-4 sm:flex-row sm:items-end">
<div class="flex-1">
<label for="endpoint-name" class="text-sm font-medium text-slate-200">Name <span class="text-slate-500">(optional)</span></label>
<input id="endpoint-name" type="text" wire:model="endpointName" maxlength="100" placeholder="Payments callback" class="mt-2 w-full rounded-xl border border-white/10 bg-slate-950/70 px-4 py-3 text-sm text-white outline-none placeholder:text-slate-600 focus:border-cyan-300/60 focus:ring-2 focus:ring-cyan-300/20">
@error('endpointName') <p class="mt-2 text-sm text-rose-300">{{ $message }}</p> @enderror
</div>
<button type="submit" class="rounded-xl bg-white px-4 py-3 text-sm font-semibold text-slate-950 transition hover:bg-cyan-50">Create private URL</button>
</div>
</form>
@endif
@if ($endpoints->isEmpty())
<div class="rounded-2xl border border-dashed border-white/15 bg-white/[0.02] px-6 py-16 text-center">
<div class="mx-auto flex size-12 items-center justify-center rounded-2xl bg-white/10 font-mono text-sm text-cyan-300">//</div>
<h2 class="mt-5 text-lg font-medium text-white">No private endpoints yet</h2>
<p class="mx-auto mt-2 max-w-md text-sm leading-6 text-slate-400">Create one when you need a stable webhook URL for a project or integration.</p>
</div>
@else
<div class="grid gap-4 lg:grid-cols-2">
@foreach ($endpoints as $endpoint)
<article wire:key="endpoint-{{ $endpoint->id }}" class="rounded-2xl border border-white/10 bg-white/[0.04] p-5 shadow-xl shadow-black/10 sm:p-6">
<div class="flex items-start justify-between gap-4">
<div class="min-w-0">
<div class="flex items-center gap-2">
<span class="size-2 rounded-full {{ $endpoint->is_active ? 'bg-emerald-400' : 'bg-slate-500' }}"></span>
<h2 class="truncate font-medium text-white">{{ $endpoint->name ?: 'Private endpoint' }}</h2>
</div>
<p class="mt-2 font-mono text-xs text-slate-500">{{ $endpoint->id }}</p>
</div>
<span class="rounded-full border border-white/10 px-2.5 py-1 text-xs text-slate-400">{{ $endpoint->requests_count }} requests</span>
</div>
<div class="mt-5 space-y-3 rounded-xl border border-white/10 bg-slate-950/60 p-4 text-xs">
<div>
<div class="mb-1 uppercase tracking-wider text-slate-600">Webhook URL</div>
<a href="{{ $endpoint->webhookUrl() }}" class="block truncate font-mono text-cyan-300 hover:text-cyan-200">{{ $endpoint->webhookUrl() }}</a>
</div>
<div>
<div class="mb-1 uppercase tracking-wider text-slate-600">Inspector</div>
<a href="{{ $endpoint->publicUrl() }}" class="block truncate font-mono text-slate-300 hover:text-white">{{ $endpoint->publicUrl() }}</a>
</div>
</div>
<div class="mt-5 flex flex-wrap items-center gap-3 text-sm">
<a href="{{ $endpoint->publicUrl() }}" class="rounded-lg bg-white px-3 py-2 font-medium text-slate-950 transition hover:bg-cyan-50">Open inspector</a>
<button type="button" wire:click="toggleEndpoint('{{ $endpoint->id }}')" class="rounded-lg border border-white/10 px-3 py-2 text-slate-300 transition hover:border-white/25 hover:text-white">
{{ $endpoint->is_active ? 'Disable' : 'Enable' }}
</button>
<button type="button" wire:click="deleteEndpoint('{{ $endpoint->id }}')" wire:confirm="Delete this endpoint and all captured requests?" class="rounded-lg px-3 py-2 text-rose-300 transition hover:bg-rose-400/10">Delete</button>
</div>
</article>
@endforeach
</div>
@endif
</div>
@@ -0,0 +1,190 @@
<div wire:poll.15s class="space-y-6">
<div class="flex flex-col justify-between gap-6 lg:flex-row lg:items-end">
<div class="min-w-0">
<div class="flex flex-wrap items-center gap-3 text-sm text-slate-400">
<a href="{{ $endpoint->is_public ? route('home') : route('dashboard') }}" class="transition hover:text-cyan-300">{{ $endpoint->is_public ? 'Home' : 'Dashboard' }}</a>
<span class="text-slate-700">/</span>
<span class="text-slate-500">Inspector</span>
<span class="rounded-full border px-2.5 py-1 text-xs {{ $endpoint->is_public ? 'border-amber-300/25 bg-amber-300/10 text-amber-200' : 'border-cyan-300/25 bg-cyan-300/10 text-cyan-200' }}">{{ $endpoint->is_public ? 'Temporary' : 'Private' }}</span>
</div>
<h1 class="mt-3 truncate text-3xl font-semibold tracking-tight text-white">{{ $endpoint->name ?: 'Webhook endpoint' }}</h1>
<p class="mt-2 font-mono text-xs text-slate-500">{{ $endpoint->id }}</p>
</div>
<div class="flex flex-wrap items-center gap-3">
@if ($endpoint->is_public)
<form action="{{ route('inspect.destroy', ['token' => $endpoint->token]) }}" method="POST">
@csrf
@method('DELETE')
<button type="submit" class="rounded-xl border border-rose-300/20 px-4 py-2.5 text-sm font-medium text-rose-300 transition hover:bg-rose-400/10" onclick="return confirm('Delete this endpoint and all captured requests?')">Delete URL</button>
</form>
@else
<button type="button" wire:click="deleteEndpoint" wire:confirm="Delete this endpoint and all captured requests?" class="rounded-xl border border-rose-300/20 px-4 py-2.5 text-sm font-medium text-rose-300 transition hover:bg-rose-400/10">Delete URL</button>
@endif
</div>
</div>
<div class="grid gap-4 xl:grid-cols-[1fr_20rem]">
<section class="min-w-0 rounded-2xl border border-white/10 bg-white/[0.04] p-5 sm:p-6">
<div class="flex flex-col justify-between gap-4 border-b border-white/10 pb-5 sm:flex-row sm:items-center">
<div>
<h2 class="font-medium text-white">Incoming requests</h2>
<p class="mt-1 text-sm text-slate-500">New requests are stored immediately and appear here live.</p>
</div>
<div class="flex flex-col gap-2 sm:flex-row">
<label class="sr-only" for="request-search">Search requests</label>
<input id="request-search" type="search" wire:model.live.debounce.350ms="search" placeholder="Search URI, IP, agent" class="rounded-lg border border-white/10 bg-slate-950/70 px-3 py-2 text-sm text-white outline-none placeholder:text-slate-600 focus:border-cyan-300/60">
<label class="sr-only" for="method-filter">Filter method</label>
<select id="method-filter" wire:model.live="methodFilter" class="rounded-lg border border-white/10 bg-slate-950/70 px-3 py-2 text-sm text-white outline-none focus:border-cyan-300/60">
@foreach (['ALL', 'GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'] as $method)
<option value="{{ $method }}">{{ $method === 'ALL' ? 'All methods' : $method }}</option>
@endforeach
</select>
</div>
</div>
@if ($webhookRequests->isEmpty())
<div class="py-16 text-center">
<p class="font-mono text-sm text-slate-500">waiting for request...</p>
<p class="mt-2 text-sm text-slate-600">Send a request to the webhook URL below to see it here.</p>
</div>
@else
<div class="mt-5 overflow-x-auto">
<table class="w-full min-w-[38rem] text-left text-sm">
<thead class="text-xs uppercase tracking-wider text-slate-600">
<tr>
<th class="pb-3 pr-4 font-medium">Method</th>
<th class="pb-3 pr-4 font-medium">URI</th>
<th class="pb-3 pr-4 font-medium">Type</th>
<th class="pb-3 pr-4 font-medium">Size</th>
<th class="pb-3 text-right font-medium">Received</th>
</tr>
</thead>
<tbody class="divide-y divide-white/5">
@foreach ($webhookRequests as $webhookRequest)
<tr wire:key="request-{{ $webhookRequest->id }}" class="group cursor-pointer transition hover:bg-white/[0.03]" wire:click="selectRequest('{{ $webhookRequest->id }}')">
<td class="py-4 pr-4 align-top"><span class="rounded-md bg-cyan-300/10 px-2 py-1 font-mono text-xs font-medium text-cyan-200">{{ $webhookRequest->method }}</span></td>
<td class="max-w-[24rem] truncate py-4 pr-4 font-mono text-xs text-slate-300">{{ $webhookRequest->request_uri }}</td>
<td class="py-4 pr-4 text-xs text-slate-500">{{ $webhookRequest->content_type ?: '—' }}</td>
<td class="py-4 pr-4 font-mono text-xs text-slate-500">{{ $webhookRequest->body_size }} B</td>
<td class="py-4 text-right text-xs text-slate-500">{{ $webhookRequest->received_at?->diffForHumans() }}</td>
</tr>
@endforeach
</tbody>
</table>
</div>
<div class="mt-5">{{ $webhookRequests->links() }}</div>
@endif
</section>
<aside class="space-y-4">
<section class="rounded-2xl border border-white/10 bg-white/[0.04] p-5">
<h2 class="font-medium text-white">Send requests here</h2>
<p class="mt-1 text-sm text-slate-500">Any HTTP method is accepted.</p>
<div class="mt-4 space-y-3">
<div>
<div class="mb-1 text-[11px] uppercase tracking-wider text-slate-600">Webhook URL</div>
<code class="block break-all rounded-lg bg-slate-950/80 p-3 text-xs leading-5 text-cyan-200">{{ $endpoint->webhookUrl() }}</code>
</div>
<div>
<div class="mb-1 text-[11px] uppercase tracking-wider text-slate-600">Example</div>
<code class="block break-all rounded-lg bg-slate-950/80 p-3 text-xs leading-5 text-slate-400">curl -X POST -d '{"event":"test"}' {{ $endpoint->webhookUrl() }}</code>
</div>
</div>
</section>
@if (! $endpoint->is_public)
<section class="rounded-2xl border border-white/10 bg-white/[0.04] p-5">
<div class="flex items-start justify-between gap-3">
<div>
<h2 class="font-medium text-white">Response</h2>
<p class="mt-1 text-sm text-slate-500">Configure what the sender receives.</p>
</div>
@if ($responseSaved)
<span class="text-xs text-emerald-300">Saved</span>
@endif
</div>
<form wire:submit="saveResponse" class="mt-5 space-y-4">
<div>
<label for="response-status" class="text-xs font-medium uppercase tracking-wider text-slate-500">Status</label>
<input id="response-status" type="number" wire:model="responseStatus" min="100" max="599" class="mt-2 w-full rounded-lg border border-white/10 bg-slate-950/70 px-3 py-2 text-sm text-white outline-none focus:border-cyan-300/60">
@error('responseStatus') <p class="mt-1 text-xs text-rose-300">{{ $message }}</p> @enderror
</div>
<div>
<label for="response-headers" class="text-xs font-medium uppercase tracking-wider text-slate-500">Headers (JSON)</label>
<textarea id="response-headers" wire:model="responseHeadersJson" rows="4" spellcheck="false" class="mt-2 w-full rounded-lg border border-white/10 bg-slate-950/70 px-3 py-2 font-mono text-xs text-white outline-none focus:border-cyan-300/60">{{ $responseHeadersJson }}</textarea>
@error('responseHeadersJson') <p class="mt-1 text-xs text-rose-300">{{ $message }}</p> @enderror
</div>
<div>
<label for="response-body" class="text-xs font-medium uppercase tracking-wider text-slate-500">Body</label>
<textarea id="response-body" wire:model="responseBody" rows="5" spellcheck="false" class="mt-2 w-full rounded-lg border border-white/10 bg-slate-950/70 px-3 py-2 font-mono text-xs text-white outline-none focus:border-cyan-300/60">{{ $responseBody }}</textarea>
@error('responseBody') <p class="mt-1 text-xs text-rose-300">{{ $message }}</p> @enderror
</div>
<button type="submit" class="w-full rounded-lg bg-white px-3 py-2.5 text-sm font-semibold text-slate-950 transition hover:bg-cyan-50">Save response</button>
</form>
</section>
@else
<section class="rounded-2xl border border-amber-300/15 bg-amber-300/[0.05] p-5 text-sm leading-6 text-amber-100/80">
This temporary endpoint expires after {{ $endpoint->expires_at?->diffForHumans(null, true) }}. The default response is <code class="font-mono text-amber-200">200 {}</code>.
</section>
@endif
</aside>
</div>
@if ($selectedRequest)
<section class="rounded-2xl border border-white/10 bg-white/[0.04] p-5 sm:p-6">
<div class="flex flex-col justify-between gap-4 border-b border-white/10 pb-5 sm:flex-row sm:items-start">
<div>
<div class="flex items-center gap-3"><span class="rounded-md bg-cyan-300/10 px-2 py-1 font-mono text-xs font-medium text-cyan-200">{{ $selectedRequest->method }}</span><span class="font-mono text-xs text-slate-500">{{ $selectedRequest->id }}</span></div>
<h2 class="mt-3 break-all font-mono text-sm text-white">{{ $selectedRequest->request_uri }}</h2>
</div>
<div class="flex items-center gap-3">
<button type="button" wire:click="deleteRequest('{{ $selectedRequest->id }}')" wire:confirm="Delete this request?" class="text-sm text-rose-300 transition hover:text-rose-200">Delete request</button>
<button type="button" wire:click="clearSelectedRequest" class="rounded-lg border border-white/10 px-3 py-2 text-sm text-slate-300 transition hover:border-white/25 hover:text-white">Close</button>
</div>
</div>
<div class="mt-6 grid gap-6 lg:grid-cols-2">
<div>
<h3 class="text-xs font-medium uppercase tracking-wider text-slate-500">Request metadata</h3>
<dl class="mt-3 divide-y divide-white/5 rounded-xl border border-white/10 bg-slate-950/40 px-4 text-sm">
<div class="flex justify-between gap-4 py-3"><dt class="text-slate-500">Received</dt><dd class="text-right text-slate-200">{{ $selectedRequest->received_at?->format('Y-m-d H:i:s T') }}</dd></div>
<div class="flex justify-between gap-4 py-3"><dt class="text-slate-500">Content-Type</dt><dd class="max-w-[65%] break-all text-right font-mono text-xs text-slate-200">{{ $selectedRequest->content_type ?: '—' }}</dd></div>
<div class="flex justify-between gap-4 py-3"><dt class="text-slate-500">Size</dt><dd class="text-right text-slate-200">{{ $selectedRequest->body_size }} bytes</dd></div>
<div class="flex justify-between gap-4 py-3"><dt class="text-slate-500">IP address</dt><dd class="text-right font-mono text-xs text-slate-200">{{ $selectedRequest->ip_address ?: '—' }}</dd></div>
<div class="flex justify-between gap-4 py-3"><dt class="text-slate-500">User-Agent</dt><dd class="max-w-[65%] break-all text-right text-xs text-slate-200">{{ $selectedRequest->user_agent ?: '—' }}</dd></div>
</dl>
</div>
<div>
<h3 class="text-xs font-medium uppercase tracking-wider text-slate-500">Query parameters</h3>
<pre class="mt-3 max-h-48 overflow-auto rounded-xl border border-white/10 bg-slate-950/70 p-4 font-mono text-xs leading-6 text-slate-300">{{ json_encode($selectedRequest->query_parameters ?? [], JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES) }}</pre>
</div>
</div>
<div class="mt-6 grid gap-6 lg:grid-cols-2">
<div>
<h3 class="text-xs font-medium uppercase tracking-wider text-slate-500">Headers</h3>
<div class="mt-3 max-h-72 overflow-auto rounded-xl border border-white/10 bg-slate-950/70 p-4 font-mono text-xs leading-6">
@forelse ($selectedRequest->headers ?? [] as $name => $values)
<div class="flex gap-3"><span class="shrink-0 text-cyan-300">{{ $name }}:</span><span class="break-all text-slate-300">{{ implode(', ', (array) $values) }}</span></div>
@empty
<span class="text-slate-600">No headers captured.</span>
@endforelse
</div>
</div>
<div>
<h3 class="text-xs font-medium uppercase tracking-wider text-slate-500">Body</h3>
<pre class="mt-3 max-h-72 overflow-auto rounded-xl border border-white/10 bg-slate-950/70 p-4 font-mono text-xs leading-6 text-emerald-200">{{ $selectedRequest->body ?? '' }}</pre>
</div>
</div>
@if ($selectedRequest->json_payload !== null)
<div class="mt-6">
<h3 class="text-xs font-medium uppercase tracking-wider text-slate-500">Parsed JSON</h3>
<pre class="mt-3 max-h-72 overflow-auto rounded-xl border border-white/10 bg-slate-950/70 p-4 font-mono text-xs leading-6 text-emerald-200">{{ json_encode($selectedRequest->json_payload, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES) }}</pre>
</div>
@endif
</section>
@endif
</div>
File diff suppressed because one or more lines are too long
+17
View File
@@ -0,0 +1,17 @@
<?php
use App\Models\User;
use App\Models\WebhookEndpoint;
use Illuminate\Support\Facades\Broadcast;
Broadcast::channel('App.Models.User.{id}', function (User $user, string $id): bool {
return (string) $user->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();
});
+6
View File
@@ -2,7 +2,13 @@
use Illuminate\Foundation\Inspiring; use Illuminate\Foundation\Inspiring;
use Illuminate\Support\Facades\Artisan; use Illuminate\Support\Facades\Artisan;
use Illuminate\Support\Facades\Schedule;
Artisan::command('inspire', function () { Artisan::command('inspire', function () {
$this->comment(Inspiring::quote()); $this->comment(Inspiring::quote());
})->purpose('Display an inspiring quote'); })->purpose('Display an inspiring quote');
Schedule::command('webhooks:prune')
->hourly()
->withoutOverlapping()
->onOneServer();
+23 -3
View File
@@ -1,7 +1,27 @@
<?php <?php
use App\Http\Controllers\EndpointController;
use App\Http\Controllers\InspectorController;
use Illuminate\Support\Facades\Route; use Illuminate\Support\Facades\Route;
Route::get('/', function () { Route::view('/', 'welcome')->name('home');
return view('welcome');
}); 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');
+55
View File
@@ -0,0 +1,55 @@
<?php
namespace Tests\Feature;
use App\Models\User;
use Illuminate\Auth\Notifications\VerifyEmail;
use Illuminate\Foundation\Testing\LazilyRefreshDatabase;
use Illuminate\Support\Facades\Notification;
use Tests\TestCase;
class AuthenticationTest extends TestCase
{
use LazilyRefreshDatabase;
public function test_a_user_can_register_and_is_asked_to_verify_email(): void
{
Notification::fake();
$response = $this->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();
}
}
+236
View File
@@ -0,0 +1,236 @@
<?php
namespace Tests\Feature;
use App\Actions\ConfigureWebhookEndpointAction;
use App\Events\WebhookRequestReceived;
use App\Models\User;
use App\Models\WebhookEndpoint;
use App\Models\WebhookRequest;
use Illuminate\Broadcasting\Channel;
use Illuminate\Broadcasting\PrivateChannel;
use Illuminate\Foundation\Testing\LazilyRefreshDatabase;
use Illuminate\Support\Facades\Event;
use Illuminate\Support\Facades\RateLimiter;
use Illuminate\Validation\ValidationException;
use Tests\TestCase;
class WebhookEndpointTest extends TestCase
{
use LazilyRefreshDatabase;
public function test_a_guest_can_create_an_anonymous_endpoint(): void
{
$response = $this->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;
}
}
+131
View File
@@ -0,0 +1,131 @@
<?php
namespace Tests\Feature;
use App\Livewire\Dashboard;
use App\Livewire\Inspector;
use App\Models\User;
use App\Models\WebhookEndpoint;
use App\Models\WebhookRequest;
use Illuminate\Foundation\Testing\LazilyRefreshDatabase;
use Livewire\Livewire;
use Tests\TestCase;
class WebhookInspectorTest extends TestCase
{
use LazilyRefreshDatabase;
public function test_private_endpoints_are_only_visible_to_the_owner(): void
{
$owner = User::factory()->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' => '<script>alert(1)</script>',
'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('&lt;script&gt;alert(1)&lt;/script&gt;', false)
->assertDontSee('<script>alert(1)</script>', 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' => '<img src=x onerror=alert(1)>',
'body_size' => 29,
]);
Livewire::test(Inspector::class, ['endpoint' => $endpoint])
->call('selectRequest', $webhookRequest->getKey())
->assertDontSee('<img src=x onerror=alert(1)>', false)
->assertSee('&lt;img src=x onerror=alert(1)&gt;', false);
}
}
-6
View File
@@ -1,6 +1,5 @@
import { defineConfig } from 'vite'; import { defineConfig } from 'vite';
import laravel from 'laravel-vite-plugin'; import laravel from 'laravel-vite-plugin';
import { bunny } from 'laravel-vite-plugin/fonts';
import tailwindcss from '@tailwindcss/vite'; import tailwindcss from '@tailwindcss/vite';
export default defineConfig({ export default defineConfig({
@@ -8,11 +7,6 @@ export default defineConfig({
laravel({ laravel({
input: ['resources/css/app.css', 'resources/js/app.js'], input: ['resources/css/app.css', 'resources/js/app.js'],
refresh: true, refresh: true,
fonts: [
bunny('Instrument Sans', {
weights: [400, 500, 600],
}),
],
}), }),
tailwindcss(), tailwindcss(),
], ],