Implement webhook inspector
This commit is contained in:
+17
-1
@@ -33,7 +33,7 @@ SESSION_ENCRYPT=false
|
||||
SESSION_PATH=/
|
||||
SESSION_DOMAIN=null
|
||||
|
||||
BROADCAST_CONNECTION=log
|
||||
BROADCAST_CONNECTION=reverb
|
||||
FILESYSTEM_DISK=local
|
||||
QUEUE_CONNECTION=database
|
||||
|
||||
@@ -63,3 +63,19 @@ AWS_BUCKET=
|
||||
AWS_USE_PATH_STYLE_ENDPOINT=false
|
||||
|
||||
VITE_APP_NAME="${APP_NAME}"
|
||||
|
||||
REVERB_APP_ID=local
|
||||
REVERB_APP_KEY=local
|
||||
REVERB_APP_SECRET=local
|
||||
REVERB_HOST=localhost
|
||||
REVERB_PORT=8080
|
||||
REVERB_SCHEME=http
|
||||
|
||||
VITE_REVERB_APP_KEY="${REVERB_APP_KEY}"
|
||||
VITE_REVERB_HOST="${REVERB_HOST}"
|
||||
VITE_REVERB_PORT="${REVERB_PORT}"
|
||||
VITE_REVERB_SCHEME="${REVERB_SCHEME}"
|
||||
|
||||
WEBHOOK_ANONYMOUS_TTL_DAYS=7
|
||||
WEBHOOK_REQUEST_RETENTION_DAYS=7
|
||||
WEBHOOK_RATE_LIMIT_PER_MINUTE=60
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
<?php
|
||||
|
||||
namespace App\Actions;
|
||||
|
||||
use App\Events\WebhookRequestReceived;
|
||||
use App\Models\WebhookEndpoint;
|
||||
use App\Models\WebhookRequest;
|
||||
use Illuminate\Database\Eloquent\ModelNotFoundException;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Str;
|
||||
use Symfony\Component\HttpKernel\Exception\HttpException;
|
||||
|
||||
class CaptureWebhookRequestAction
|
||||
{
|
||||
public function handle(WebhookEndpoint $endpoint, Request $request): WebhookRequest
|
||||
{
|
||||
$body = $request->getContent();
|
||||
$bodySize = strlen($body);
|
||||
|
||||
if ($bodySize > (int) config('webhooks.max_body_bytes', 1024 * 1024)) {
|
||||
throw new HttpException(413, 'The webhook request body is too large.');
|
||||
}
|
||||
|
||||
$webhookRequest = DB::transaction(function () use ($endpoint, $request, $body, $bodySize): WebhookRequest {
|
||||
$lockedEndpoint = WebhookEndpoint::query()
|
||||
->whereKey($endpoint->getKey())
|
||||
->lockForUpdate()
|
||||
->first();
|
||||
|
||||
if (! $lockedEndpoint instanceof WebhookEndpoint || ! $lockedEndpoint->acceptsRequests()) {
|
||||
throw (new ModelNotFoundException)->setModel(WebhookEndpoint::class, [$endpoint->getKey()]);
|
||||
}
|
||||
|
||||
$webhookRequest = WebhookRequest::create([
|
||||
'webhook_endpoint_id' => $lockedEndpoint->getKey(),
|
||||
'method' => Str::upper($request->method()),
|
||||
'request_uri' => $request->getRequestUri(),
|
||||
'headers' => $request->headers->all(),
|
||||
'query_parameters' => $request->query(),
|
||||
'body' => $body,
|
||||
'json_payload' => $this->parseJsonPayload($body, $request->header('Content-Type')),
|
||||
'content_type' => $request->header('Content-Type'),
|
||||
'body_size' => $bodySize,
|
||||
'ip_address' => $request->ip(),
|
||||
'user_agent' => $request->userAgent(),
|
||||
'received_at' => now(),
|
||||
]);
|
||||
|
||||
$lockedEndpoint->forceFill([
|
||||
'last_request_at' => $webhookRequest->received_at,
|
||||
])->save();
|
||||
|
||||
$this->pruneExcessRequests($lockedEndpoint);
|
||||
|
||||
return $webhookRequest;
|
||||
});
|
||||
|
||||
WebhookRequestReceived::dispatch(
|
||||
(string) $endpoint->getKey(),
|
||||
(string) $webhookRequest->getKey(),
|
||||
);
|
||||
|
||||
return $webhookRequest;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>|null
|
||||
*/
|
||||
private function parseJsonPayload(string $body, ?string $contentType): ?array
|
||||
{
|
||||
if ($body === '' || $contentType === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$normalizedContentType = Str::lower($contentType);
|
||||
|
||||
if (! Str::contains($normalizedContentType, ['application/json', '+json'])) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
$payload = json_decode($body, true, 512, JSON_THROW_ON_ERROR);
|
||||
} catch (\JsonException) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return is_array($payload) ? $payload : null;
|
||||
}
|
||||
|
||||
private function pruneExcessRequests(WebhookEndpoint $endpoint): void
|
||||
{
|
||||
$requestIds = $endpoint->requests()
|
||||
->select('id')
|
||||
->orderByDesc('received_at')
|
||||
->orderByDesc('id')
|
||||
->skip((int) config('webhooks.max_requests_per_endpoint', 1000))
|
||||
->take(1_000_000_000)
|
||||
->pluck('id');
|
||||
|
||||
if ($requestIds->isNotEmpty()) {
|
||||
WebhookRequest::query()->whereKey($requestIds)->delete();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
<?php
|
||||
|
||||
namespace App\Actions;
|
||||
|
||||
use App\Models\User;
|
||||
use App\Models\WebhookEndpoint;
|
||||
use Illuminate\Support\Facades\Gate;
|
||||
use Illuminate\Support\Facades\Validator;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
|
||||
class ConfigureWebhookEndpointAction
|
||||
{
|
||||
/**
|
||||
* @param array<string, string|array<int, string>> $headers
|
||||
*/
|
||||
public function handle(
|
||||
?User $user,
|
||||
WebhookEndpoint $endpoint,
|
||||
int $status,
|
||||
array $headers,
|
||||
string $body,
|
||||
): WebhookEndpoint {
|
||||
Gate::forUser($user)->authorize('update', $endpoint);
|
||||
|
||||
$maxResponseBodyBytes = (int) config('webhooks.max_response_body_bytes', 1024 * 1024);
|
||||
|
||||
if (strlen($body) > $maxResponseBodyBytes) {
|
||||
throw ValidationException::withMessages([
|
||||
'response_body' => "The response body may not exceed {$maxResponseBodyBytes} bytes.",
|
||||
]);
|
||||
}
|
||||
|
||||
Validator::make([
|
||||
'status' => $status,
|
||||
'body' => $body,
|
||||
], [
|
||||
'status' => ['integer', 'between:100,599'],
|
||||
'body' => ['string'],
|
||||
])->validate();
|
||||
|
||||
$this->validateHeaders($headers);
|
||||
|
||||
$endpoint->forceFill([
|
||||
'response_status' => $status,
|
||||
'response_headers' => $headers,
|
||||
'response_body' => $body,
|
||||
])->save();
|
||||
|
||||
return $endpoint->refresh();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, string|array<int, string>> $headers
|
||||
*/
|
||||
private function validateHeaders(array $headers): void
|
||||
{
|
||||
if (count($headers) > 50) {
|
||||
throw ValidationException::withMessages([
|
||||
'response_headers' => 'A maximum of 50 response headers is allowed.',
|
||||
]);
|
||||
}
|
||||
|
||||
foreach ($headers as $name => $value) {
|
||||
if (! is_string($name) || preg_match("/^[!#$%&'*+.^_`|~0-9A-Za-z-]+$/", $name) !== 1) {
|
||||
throw ValidationException::withMessages([
|
||||
'response_headers' => 'Every response header name must be a valid HTTP header name.',
|
||||
]);
|
||||
}
|
||||
|
||||
$values = is_array($value) ? $value : [$value];
|
||||
|
||||
foreach ($values as $headerValue) {
|
||||
if (! is_string($headerValue) || preg_match('/[\r\n]/', $headerValue) === 1 || strlen($headerValue) > 8192) {
|
||||
throw ValidationException::withMessages([
|
||||
'response_headers' => 'Response headers must not contain line breaks and may not exceed 8 KiB.',
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
<?php
|
||||
|
||||
namespace App\Actions;
|
||||
|
||||
use App\Models\WebhookEndpoint;
|
||||
|
||||
class CreateAnonymousEndpointAction
|
||||
{
|
||||
public function handle(): WebhookEndpoint
|
||||
{
|
||||
$token = WebhookEndpoint::generateToken();
|
||||
|
||||
return WebhookEndpoint::create([
|
||||
'name' => 'Anonymous test endpoint',
|
||||
'token' => $token,
|
||||
'token_hash' => WebhookEndpoint::tokenHash($token),
|
||||
'is_public' => true,
|
||||
'expires_at' => now()->addDays((int) config('webhooks.anonymous_ttl_days', 7)),
|
||||
'response_headers' => ['Content-Type' => 'application/json'],
|
||||
'response_body' => '{}',
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
<?php
|
||||
|
||||
namespace App\Actions;
|
||||
|
||||
use App\Models\User;
|
||||
use App\Models\WebhookEndpoint;
|
||||
|
||||
class CreatePrivateEndpointAction
|
||||
{
|
||||
public function handle(User $user, ?string $name = null): WebhookEndpoint
|
||||
{
|
||||
$token = WebhookEndpoint::generateToken();
|
||||
|
||||
return WebhookEndpoint::create([
|
||||
'user_id' => $user->getKey(),
|
||||
'name' => $name ?: 'Private endpoint',
|
||||
'token' => $token,
|
||||
'token_hash' => WebhookEndpoint::tokenHash($token),
|
||||
'is_public' => false,
|
||||
'expires_at' => null,
|
||||
'response_headers' => ['Content-Type' => 'application/json'],
|
||||
'response_body' => '{}',
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
<?php
|
||||
|
||||
namespace App\Actions;
|
||||
|
||||
use App\Models\User;
|
||||
use App\Models\WebhookRequest;
|
||||
use Illuminate\Support\Facades\Gate;
|
||||
|
||||
class DeleteWebhookRequestAction
|
||||
{
|
||||
public function handle(?User $user, WebhookRequest $webhookRequest): void
|
||||
{
|
||||
$webhookRequest->loadMissing('endpoint');
|
||||
Gate::forUser($user)->authorize('delete', $webhookRequest);
|
||||
$webhookRequest->delete();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
<?php
|
||||
|
||||
namespace App\Actions\Fortify;
|
||||
|
||||
use App\Models\User;
|
||||
use Illuminate\Support\Facades\Validator;
|
||||
use Laravel\Fortify\Contracts\CreatesNewUsers;
|
||||
|
||||
class CreateNewUser implements CreatesNewUsers
|
||||
{
|
||||
public function create(array $input): User
|
||||
{
|
||||
Validator::make($input, [
|
||||
'name' => ['required', 'string', 'max:255'],
|
||||
'email' => ['required', 'string', 'lowercase', 'email', 'max:255', 'unique:users'],
|
||||
'password' => ['required', 'string', 'confirmed', 'min:8'],
|
||||
])->validate();
|
||||
|
||||
return User::create([
|
||||
'name' => $input['name'],
|
||||
'email' => $input['email'],
|
||||
'password' => $input['password'],
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
<?php
|
||||
|
||||
namespace App\Actions\Fortify;
|
||||
|
||||
use App\Models\User;
|
||||
use Illuminate\Support\Facades\Validator;
|
||||
use Laravel\Fortify\Contracts\ResetsUserPasswords;
|
||||
|
||||
class ResetUserPassword implements ResetsUserPasswords
|
||||
{
|
||||
public function reset(User $user, array $input): void
|
||||
{
|
||||
Validator::make($input, [
|
||||
'password' => ['required', 'string', 'confirmed', 'min:8'],
|
||||
])->validate();
|
||||
|
||||
$user->forceFill([
|
||||
'password' => $input['password'],
|
||||
])->save();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Models\WebhookEndpoint;
|
||||
use App\Models\WebhookRequest;
|
||||
use Illuminate\Console\Attributes\Description;
|
||||
use Illuminate\Console\Attributes\Signature;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Database\Eloquent\Collection;
|
||||
use Illuminate\Support\Carbon;
|
||||
|
||||
#[Signature('webhooks:prune')]
|
||||
#[Description('Remove expired endpoints and old webhook requests')]
|
||||
class PruneWebhookData extends Command
|
||||
{
|
||||
public function handle(): int
|
||||
{
|
||||
$now = now();
|
||||
$deletedRequests = WebhookRequest::query()
|
||||
->where('received_at', '<', $now->copy()->subDays((int) config('webhooks.request_retention_days', 7)))
|
||||
->delete();
|
||||
|
||||
$this->pruneEndpointLimits();
|
||||
$deletedEndpoints = $this->deleteExpiredAnonymousEndpoints($now);
|
||||
|
||||
$this->info("Deleted {$deletedRequests} old requests and {$deletedEndpoints} expired endpoints.");
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
private function pruneEndpointLimits(): void
|
||||
{
|
||||
WebhookEndpoint::query()
|
||||
->select('id')
|
||||
->chunkById(100, function (Collection $endpoints): void {
|
||||
foreach ($endpoints as $endpoint) {
|
||||
$requestIds = WebhookRequest::query()
|
||||
->whereBelongsTo($endpoint, 'endpoint')
|
||||
->select('id')
|
||||
->orderByDesc('received_at')
|
||||
->orderByDesc('id')
|
||||
->skip((int) config('webhooks.max_requests_per_endpoint', 1000))
|
||||
->take(1_000_000_000)
|
||||
->pluck('id');
|
||||
|
||||
if ($requestIds->isNotEmpty()) {
|
||||
WebhookRequest::query()->whereKey($requestIds)->delete();
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private function deleteExpiredAnonymousEndpoints(Carbon $now): int
|
||||
{
|
||||
return WebhookEndpoint::query()
|
||||
->public()
|
||||
->whereNull('user_id')
|
||||
->whereNotNull('expires_at')
|
||||
->where('expires_at', '<', $now)
|
||||
->delete();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
<?php
|
||||
|
||||
namespace App\Events;
|
||||
|
||||
use App\Models\WebhookEndpoint;
|
||||
use Illuminate\Broadcasting\Channel;
|
||||
use Illuminate\Broadcasting\InteractsWithSockets;
|
||||
use Illuminate\Broadcasting\PrivateChannel;
|
||||
use Illuminate\Contracts\Broadcasting\ShouldBroadcast;
|
||||
use Illuminate\Contracts\Events\ShouldDispatchAfterCommit;
|
||||
use Illuminate\Foundation\Events\Dispatchable;
|
||||
|
||||
class WebhookRequestReceived implements ShouldBroadcast, ShouldDispatchAfterCommit
|
||||
{
|
||||
use Dispatchable, InteractsWithSockets;
|
||||
|
||||
private ?bool $isPublic;
|
||||
|
||||
public function __construct(
|
||||
public readonly string $endpointId,
|
||||
public readonly string $requestId,
|
||||
?bool $isPublic = null,
|
||||
) {
|
||||
$this->isPublic = $isPublic;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, Channel>
|
||||
*/
|
||||
public function broadcastOn(): array
|
||||
{
|
||||
$isPublic = $this->isPublic ?? WebhookEndpoint::query()
|
||||
->whereKey($this->endpointId)
|
||||
->value('is_public');
|
||||
|
||||
return [
|
||||
$isPublic
|
||||
? new Channel('webhooks.'.$this->endpointId)
|
||||
: new PrivateChannel('webhooks.'.$this->endpointId),
|
||||
];
|
||||
}
|
||||
|
||||
public function broadcastAs(): string
|
||||
{
|
||||
return 'webhook.request.received';
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{endpointId: string, requestId: string}
|
||||
*/
|
||||
public function broadcastWith(): array
|
||||
{
|
||||
return [
|
||||
'endpointId' => $this->endpointId,
|
||||
'requestId' => $this->requestId,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Actions\CaptureWebhookRequestAction;
|
||||
use App\Actions\CreateAnonymousEndpointAction;
|
||||
use App\Models\WebhookEndpoint;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Response;
|
||||
use Illuminate\Support\Facades\Gate;
|
||||
|
||||
class EndpointController extends Controller
|
||||
{
|
||||
public function storeAnonymous(CreateAnonymousEndpointAction $createAnonymousEndpoint): RedirectResponse
|
||||
{
|
||||
$endpoint = $createAnonymousEndpoint->handle();
|
||||
|
||||
return redirect()->route('inspect.show', ['token' => $endpoint->token]);
|
||||
}
|
||||
|
||||
public function receive(
|
||||
Request $request,
|
||||
string $token,
|
||||
CaptureWebhookRequestAction $captureWebhookRequest,
|
||||
): Response|JsonResponse {
|
||||
$endpoint = WebhookEndpoint::findByToken($token);
|
||||
|
||||
abort_unless($endpoint instanceof WebhookEndpoint && $endpoint->acceptsRequests(), 404);
|
||||
|
||||
if (strlen($request->getContent()) > (int) config('webhooks.max_body_bytes', 1024 * 1024)) {
|
||||
return response()->json(['message' => 'The webhook request body is too large.'], 413);
|
||||
}
|
||||
|
||||
$captureWebhookRequest->handle($endpoint, $request);
|
||||
$endpoint->refresh();
|
||||
|
||||
return response(
|
||||
$endpoint->response_body ?? '{}',
|
||||
$endpoint->response_status,
|
||||
$endpoint->response_headers ?? ['Content-Type' => 'application/json'],
|
||||
);
|
||||
}
|
||||
|
||||
public function destroy(string $token): RedirectResponse
|
||||
{
|
||||
$endpoint = WebhookEndpoint::findByToken($token);
|
||||
|
||||
abort_unless($endpoint instanceof WebhookEndpoint, 404);
|
||||
Gate::authorize('delete', $endpoint);
|
||||
$endpoint->delete();
|
||||
|
||||
return redirect()->route('home')->with('status', 'Endpoint deleted.');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\WebhookEndpoint;
|
||||
use Illuminate\Support\Facades\Gate;
|
||||
use Illuminate\View\View;
|
||||
|
||||
class InspectorController extends Controller
|
||||
{
|
||||
public function show(string $token): View
|
||||
{
|
||||
$endpoint = WebhookEndpoint::findByToken($token);
|
||||
|
||||
abort_unless($endpoint instanceof WebhookEndpoint && $endpoint->acceptsRequests(), 404);
|
||||
Gate::authorize('view', $endpoint);
|
||||
|
||||
return view('inspector-page', ['endpoint' => $endpoint]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
<?php
|
||||
|
||||
namespace App\Livewire;
|
||||
|
||||
use App\Actions\CreatePrivateEndpointAction;
|
||||
use App\Models\User;
|
||||
use App\Models\WebhookEndpoint;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Support\Facades\Gate;
|
||||
use Illuminate\View\View;
|
||||
use Livewire\Component;
|
||||
|
||||
class Dashboard extends Component
|
||||
{
|
||||
public string $endpointName = '';
|
||||
|
||||
public bool $showCreateForm = false;
|
||||
|
||||
public function saveEndpoint(CreatePrivateEndpointAction $createPrivateEndpoint): void
|
||||
{
|
||||
$this->validate([
|
||||
'endpointName' => ['nullable', 'string', 'max:100'],
|
||||
]);
|
||||
|
||||
/** @var User $user */
|
||||
$user = Auth::user();
|
||||
$endpoint = $createPrivateEndpoint->handle($user, $this->endpointName ?: null);
|
||||
|
||||
$this->redirect($endpoint->publicUrl(), navigate: true);
|
||||
}
|
||||
|
||||
public function deleteEndpoint(string $endpointId): void
|
||||
{
|
||||
$endpoint = $this->ownedEndpoint($endpointId);
|
||||
Gate::authorize('delete', $endpoint);
|
||||
$endpoint->delete();
|
||||
}
|
||||
|
||||
public function toggleEndpoint(string $endpointId): void
|
||||
{
|
||||
$endpoint = $this->ownedEndpoint($endpointId);
|
||||
Gate::authorize('update', $endpoint);
|
||||
$endpoint->update(['is_active' => ! $endpoint->is_active]);
|
||||
}
|
||||
|
||||
public function render(): View
|
||||
{
|
||||
/** @var User $user */
|
||||
$user = Auth::user();
|
||||
|
||||
return view('livewire.dashboard', [
|
||||
'endpoints' => WebhookEndpoint::query()
|
||||
->ownedBy($user)
|
||||
->withCount('requests')
|
||||
->latest()
|
||||
->get(),
|
||||
]);
|
||||
}
|
||||
|
||||
private function ownedEndpoint(string $endpointId): WebhookEndpoint
|
||||
{
|
||||
/** @var User $user */
|
||||
$user = Auth::user();
|
||||
|
||||
return WebhookEndpoint::query()
|
||||
->ownedBy($user)
|
||||
->findOrFail($endpointId);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,189 @@
|
||||
<?php
|
||||
|
||||
namespace App\Livewire;
|
||||
|
||||
use App\Actions\ConfigureWebhookEndpointAction;
|
||||
use App\Actions\DeleteWebhookRequestAction;
|
||||
use App\Models\User;
|
||||
use App\Models\WebhookEndpoint;
|
||||
use App\Models\WebhookRequest;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Support\Facades\Gate;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
use Illuminate\View\View;
|
||||
use Livewire\Attributes\Locked;
|
||||
use Livewire\Attributes\Url;
|
||||
use Livewire\Component;
|
||||
use Livewire\WithPagination;
|
||||
|
||||
class Inspector extends Component
|
||||
{
|
||||
use WithPagination;
|
||||
|
||||
#[Locked]
|
||||
public string $endpointId = '';
|
||||
|
||||
#[Url]
|
||||
public string $search = '';
|
||||
|
||||
#[Url]
|
||||
public string $methodFilter = 'ALL';
|
||||
|
||||
#[Url]
|
||||
public ?string $selectedRequestId = null;
|
||||
|
||||
public int $responseStatus = 200;
|
||||
|
||||
public string $responseHeadersJson = '{}';
|
||||
|
||||
public string $responseBody = '{}';
|
||||
|
||||
public bool $responseSaved = false;
|
||||
|
||||
public function mount(WebhookEndpoint $endpoint): void
|
||||
{
|
||||
Gate::authorize('view', $endpoint);
|
||||
|
||||
$this->endpointId = (string) $endpoint->getKey();
|
||||
$this->responseStatus = $endpoint->response_status;
|
||||
$this->responseHeadersJson = json_encode(
|
||||
$endpoint->response_headers ?? [],
|
||||
JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES,
|
||||
) ?: '{}';
|
||||
$this->responseBody = $endpoint->response_body ?? '{}';
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, string>
|
||||
*/
|
||||
public function getListeners(): array
|
||||
{
|
||||
$endpoint = $this->endpoint();
|
||||
$prefix = $endpoint->is_public ? 'echo' : 'echo-private';
|
||||
|
||||
return [
|
||||
$prefix.':webhooks.'.$this->endpointId.',.webhook.request.received' => 'requestReceived',
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $event
|
||||
*/
|
||||
public function requestReceived(array $event = []): void
|
||||
{
|
||||
if (($event['endpointId'] ?? null) === $this->endpointId) {
|
||||
$this->resetPage();
|
||||
}
|
||||
}
|
||||
|
||||
public function updatedSearch(): void
|
||||
{
|
||||
$this->resetPage();
|
||||
}
|
||||
|
||||
public function updatedMethodFilter(): void
|
||||
{
|
||||
$this->resetPage();
|
||||
}
|
||||
|
||||
public function selectRequest(string $requestId): void
|
||||
{
|
||||
$webhookRequest = $this->endpoint()->requests()->findOrFail($requestId);
|
||||
Gate::authorize('view', $webhookRequest);
|
||||
$this->selectedRequestId = $webhookRequest->getKey();
|
||||
}
|
||||
|
||||
public function clearSelectedRequest(): void
|
||||
{
|
||||
$this->selectedRequestId = null;
|
||||
}
|
||||
|
||||
public function deleteRequest(string $requestId, DeleteWebhookRequestAction $deleteWebhookRequest): void
|
||||
{
|
||||
$webhookRequest = $this->endpoint()->requests()->findOrFail($requestId);
|
||||
$deleteWebhookRequest->handle(Auth::user(), $webhookRequest);
|
||||
|
||||
if ($this->selectedRequestId === $requestId) {
|
||||
$this->selectedRequestId = null;
|
||||
}
|
||||
}
|
||||
|
||||
public function saveResponse(ConfigureWebhookEndpointAction $configureWebhookEndpoint): void
|
||||
{
|
||||
$this->validate([
|
||||
'responseStatus' => ['required', 'integer', 'between:100,599'],
|
||||
'responseBody' => ['string'],
|
||||
'responseHeadersJson' => ['required', 'json'],
|
||||
]);
|
||||
|
||||
$headers = json_decode($this->responseHeadersJson, true);
|
||||
|
||||
if (! is_array($headers)) {
|
||||
throw ValidationException::withMessages([
|
||||
'responseHeadersJson' => 'Response headers must be a JSON object.',
|
||||
]);
|
||||
}
|
||||
|
||||
/** @var User|null $user */
|
||||
$user = Auth::user();
|
||||
$configureWebhookEndpoint->handle(
|
||||
$user,
|
||||
$this->endpoint(),
|
||||
$this->responseStatus,
|
||||
$headers,
|
||||
$this->responseBody,
|
||||
);
|
||||
|
||||
$this->responseSaved = true;
|
||||
}
|
||||
|
||||
public function deleteEndpoint(): void
|
||||
{
|
||||
$endpoint = $this->endpoint();
|
||||
Gate::authorize('delete', $endpoint);
|
||||
$endpoint->delete();
|
||||
$this->redirectRoute('home');
|
||||
}
|
||||
|
||||
public function render(): View
|
||||
{
|
||||
$endpoint = $this->endpoint();
|
||||
$requestQuery = $endpoint->requests()
|
||||
->select(['id', 'method', 'request_uri', 'content_type', 'body_size', 'ip_address', 'received_at'])
|
||||
->when($this->methodFilter !== 'ALL', fn ($query) => $query->where('method', $this->methodFilter))
|
||||
->when($this->search !== '', function ($query): void {
|
||||
$search = '%'.$this->search.'%';
|
||||
|
||||
$query->where(function ($query) use ($search): void {
|
||||
$query->where('request_uri', 'like', $search)
|
||||
->orWhere('ip_address', 'like', $search)
|
||||
->orWhere('user_agent', 'like', $search);
|
||||
});
|
||||
})
|
||||
->latest('received_at');
|
||||
|
||||
$selectedRequest = null;
|
||||
|
||||
if ($this->selectedRequestId !== null) {
|
||||
$selectedRequest = $endpoint->requests()->find($this->selectedRequestId);
|
||||
|
||||
if ($selectedRequest instanceof WebhookRequest) {
|
||||
Gate::authorize('view', $selectedRequest);
|
||||
}
|
||||
}
|
||||
|
||||
return view('livewire.inspector', [
|
||||
'endpoint' => $endpoint,
|
||||
'webhookRequests' => $requestQuery->paginate((int) config('webhooks.page_size', 25)),
|
||||
'selectedRequest' => $selectedRequest,
|
||||
]);
|
||||
}
|
||||
|
||||
private function endpoint(): WebhookEndpoint
|
||||
{
|
||||
$endpoint = WebhookEndpoint::query()->findOrFail($this->endpointId);
|
||||
Gate::authorize('view', $endpoint);
|
||||
|
||||
return $endpoint;
|
||||
}
|
||||
}
|
||||
+8
-2
@@ -2,21 +2,27 @@
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
// use Illuminate\Contracts\Auth\MustVerifyEmail;
|
||||
use Database\Factories\UserFactory;
|
||||
use Illuminate\Contracts\Auth\MustVerifyEmail;
|
||||
use Illuminate\Database\Eloquent\Attributes\Fillable;
|
||||
use Illuminate\Database\Eloquent\Attributes\Hidden;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
use Illuminate\Foundation\Auth\User as Authenticatable;
|
||||
use Illuminate\Notifications\Notifiable;
|
||||
|
||||
#[Fillable(['name', 'email', 'password'])]
|
||||
#[Hidden(['password', 'remember_token'])]
|
||||
class User extends Authenticatable
|
||||
class User extends Authenticatable implements MustVerifyEmail
|
||||
{
|
||||
/** @use HasFactory<UserFactory> */
|
||||
use HasFactory, Notifiable;
|
||||
|
||||
public function webhookEndpoints(): HasMany
|
||||
{
|
||||
return $this->hasMany(WebhookEndpoint::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the attributes that should be cast.
|
||||
*
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Database\Factories\WebhookEndpointFactory;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Database\Eloquent\Concerns\HasUlids;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
class WebhookEndpoint extends Model
|
||||
{
|
||||
/** @use HasFactory<WebhookEndpointFactory> */
|
||||
use HasFactory, HasUlids;
|
||||
|
||||
protected $fillable = [
|
||||
'user_id',
|
||||
'name',
|
||||
'token',
|
||||
'token_hash',
|
||||
'is_public',
|
||||
'is_active',
|
||||
'expires_at',
|
||||
'response_status',
|
||||
'response_headers',
|
||||
'response_body',
|
||||
'last_request_at',
|
||||
];
|
||||
|
||||
protected $hidden = [
|
||||
'token',
|
||||
'token_hash',
|
||||
];
|
||||
|
||||
protected $attributes = [
|
||||
'is_public' => true,
|
||||
'is_active' => true,
|
||||
'response_status' => 200,
|
||||
'response_body' => '{}',
|
||||
];
|
||||
|
||||
/**
|
||||
* @return array<string, string>
|
||||
*/
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'token' => 'encrypted',
|
||||
'response_headers' => 'encrypted:array',
|
||||
'is_public' => 'boolean',
|
||||
'is_active' => 'boolean',
|
||||
'expires_at' => 'datetime',
|
||||
'last_request_at' => 'datetime',
|
||||
'response_status' => 'integer',
|
||||
];
|
||||
}
|
||||
|
||||
public function user(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class);
|
||||
}
|
||||
|
||||
public function requests(): HasMany
|
||||
{
|
||||
return $this->hasMany(WebhookRequest::class);
|
||||
}
|
||||
|
||||
public function scopeOwnedBy(Builder $query, User $user): Builder
|
||||
{
|
||||
return $query->whereBelongsTo($user);
|
||||
}
|
||||
|
||||
public function scopePublic(Builder $query): Builder
|
||||
{
|
||||
return $query->where('is_public', true);
|
||||
}
|
||||
|
||||
public function scopeActive(Builder $query): Builder
|
||||
{
|
||||
return $query->where('is_active', true);
|
||||
}
|
||||
|
||||
public static function tokenHash(string $token): string
|
||||
{
|
||||
return hash('sha256', $token);
|
||||
}
|
||||
|
||||
public static function findByToken(string $token): ?self
|
||||
{
|
||||
return static::query()->where('token_hash', static::tokenHash($token))->first();
|
||||
}
|
||||
|
||||
public function acceptsRequests(): bool
|
||||
{
|
||||
return $this->is_active && ! $this->isExpired();
|
||||
}
|
||||
|
||||
public function isExpired(): bool
|
||||
{
|
||||
return $this->expires_at !== null && $this->expires_at->isPast();
|
||||
}
|
||||
|
||||
public function publicUrl(): string
|
||||
{
|
||||
return route('inspect.show', ['token' => $this->token]);
|
||||
}
|
||||
|
||||
public function webhookUrl(): string
|
||||
{
|
||||
return route('webhooks.receive', ['token' => $this->token]);
|
||||
}
|
||||
|
||||
public static function generateToken(): string
|
||||
{
|
||||
return Str::random(48);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Database\Factories\WebhookRequestFactory;
|
||||
use Illuminate\Database\Eloquent\Concerns\HasUlids;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
class WebhookRequest extends Model
|
||||
{
|
||||
/** @use HasFactory<WebhookRequestFactory> */
|
||||
use HasFactory, HasUlids;
|
||||
|
||||
protected $fillable = [
|
||||
'webhook_endpoint_id',
|
||||
'method',
|
||||
'request_uri',
|
||||
'headers',
|
||||
'query_parameters',
|
||||
'body',
|
||||
'json_payload',
|
||||
'content_type',
|
||||
'body_size',
|
||||
'ip_address',
|
||||
'user_agent',
|
||||
'received_at',
|
||||
];
|
||||
|
||||
/**
|
||||
* @return array<string, string>
|
||||
*/
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'headers' => 'encrypted:array',
|
||||
'query_parameters' => 'encrypted:array',
|
||||
'body' => 'encrypted',
|
||||
'json_payload' => 'encrypted:array',
|
||||
'body_size' => 'integer',
|
||||
'received_at' => 'datetime',
|
||||
];
|
||||
}
|
||||
|
||||
public function endpoint(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(WebhookEndpoint::class, 'webhook_endpoint_id');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
<?php
|
||||
|
||||
namespace App\Policies;
|
||||
|
||||
use App\Models\User;
|
||||
use App\Models\WebhookEndpoint;
|
||||
|
||||
class WebhookEndpointPolicy
|
||||
{
|
||||
public function viewAny(?User $user): bool
|
||||
{
|
||||
return $user !== null;
|
||||
}
|
||||
|
||||
public function view(?User $user, WebhookEndpoint $webhookEndpoint): bool
|
||||
{
|
||||
return $webhookEndpoint->is_public || $this->isOwner($user, $webhookEndpoint);
|
||||
}
|
||||
|
||||
public function create(?User $user): bool
|
||||
{
|
||||
return $user !== null;
|
||||
}
|
||||
|
||||
public function update(?User $user, WebhookEndpoint $webhookEndpoint): bool
|
||||
{
|
||||
return ! $webhookEndpoint->is_public && $this->isOwner($user, $webhookEndpoint);
|
||||
}
|
||||
|
||||
public function delete(?User $user, WebhookEndpoint $webhookEndpoint): bool
|
||||
{
|
||||
return ($webhookEndpoint->is_public && $webhookEndpoint->user_id === null)
|
||||
|| $this->isOwner($user, $webhookEndpoint);
|
||||
}
|
||||
|
||||
private function isOwner(?User $user, WebhookEndpoint $webhookEndpoint): bool
|
||||
{
|
||||
return $user !== null && (string) $webhookEndpoint->user_id === (string) $user->getKey();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
<?php
|
||||
|
||||
namespace App\Policies;
|
||||
|
||||
use App\Models\User;
|
||||
use App\Models\WebhookRequest;
|
||||
|
||||
class WebhookRequestPolicy
|
||||
{
|
||||
public function viewAny(?User $user): bool
|
||||
{
|
||||
return $user !== null;
|
||||
}
|
||||
|
||||
public function view(?User $user, WebhookRequest $webhookRequest): bool
|
||||
{
|
||||
return $this->canAccessEndpoint($user, $webhookRequest);
|
||||
}
|
||||
|
||||
public function create(?User $user): bool
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
public function update(?User $user, WebhookRequest $webhookRequest): bool
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
public function delete(?User $user, WebhookRequest $webhookRequest): bool
|
||||
{
|
||||
return $this->canAccessEndpoint($user, $webhookRequest);
|
||||
}
|
||||
|
||||
private function canAccessEndpoint(?User $user, WebhookRequest $webhookRequest): bool
|
||||
{
|
||||
$webhookRequest->loadMissing('endpoint');
|
||||
|
||||
return (new WebhookEndpointPolicy)->view($user, $webhookRequest->endpoint);
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,16 @@
|
||||
|
||||
namespace App\Providers;
|
||||
|
||||
use App\Models\WebhookEndpoint;
|
||||
use App\Models\WebhookRequest;
|
||||
use App\Policies\WebhookEndpointPolicy;
|
||||
use App\Policies\WebhookRequestPolicy;
|
||||
use Illuminate\Cache\RateLimiting\Limit;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Gate;
|
||||
use Illuminate\Support\Facades\RateLimiter;
|
||||
use Illuminate\Support\ServiceProvider;
|
||||
|
||||
class AppServiceProvider extends ServiceProvider
|
||||
@@ -19,6 +29,21 @@ class AppServiceProvider extends ServiceProvider
|
||||
*/
|
||||
public function boot(): void
|
||||
{
|
||||
//
|
||||
Model::preventLazyLoading(! app()->isProduction());
|
||||
|
||||
Gate::policy(WebhookEndpoint::class, WebhookEndpointPolicy::class);
|
||||
Gate::policy(WebhookRequest::class, WebhookRequestPolicy::class);
|
||||
|
||||
RateLimiter::for('login', function (Request $request): Limit {
|
||||
return Limit::perMinute(5)->by($request->string('email')->lower()->value().'|'.$request->ip());
|
||||
});
|
||||
|
||||
RateLimiter::for('webhooks', function (Request $request): Limit {
|
||||
return Limit::perMinute((int) config('webhooks.rate_limit_per_minute', 60))
|
||||
->by('webhook:'.(string) $request->route('token').':'.$request->ip())
|
||||
->response(fn (Request $request, array $headers): JsonResponse => response()->json([
|
||||
'message' => 'Too many webhook requests. Try again later.',
|
||||
], 429, $headers));
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
<?php
|
||||
|
||||
namespace App\Providers;
|
||||
|
||||
use App\Actions\Fortify\CreateNewUser;
|
||||
use App\Actions\Fortify\ResetUserPassword;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\ServiceProvider;
|
||||
use Laravel\Fortify\Fortify;
|
||||
|
||||
class FortifyServiceProvider extends ServiceProvider
|
||||
{
|
||||
/**
|
||||
* Register services.
|
||||
*/
|
||||
public function register(): void
|
||||
{
|
||||
//
|
||||
}
|
||||
|
||||
/**
|
||||
* Bootstrap services.
|
||||
*/
|
||||
public function boot(): void
|
||||
{
|
||||
Fortify::loginView(fn () => view('auth.login'));
|
||||
Fortify::registerView(fn () => view('auth.register'));
|
||||
Fortify::requestPasswordResetLinkView(fn () => view('auth.forgot-password'));
|
||||
Fortify::resetPasswordView(fn (Request $request) => view('auth.reset-password', ['request' => $request]));
|
||||
Fortify::verifyEmailView(fn () => view('auth.verify-email'));
|
||||
|
||||
Fortify::createUsersUsing(CreateNewUser::class);
|
||||
Fortify::resetUserPasswordsUsing(ResetUserPassword::class);
|
||||
}
|
||||
}
|
||||
+4
-1
@@ -10,10 +10,13 @@ return Application::configure(basePath: dirname(__DIR__))
|
||||
web: __DIR__.'/../routes/web.php',
|
||||
api: __DIR__.'/../routes/api.php',
|
||||
commands: __DIR__.'/../routes/console.php',
|
||||
channels: __DIR__.'/../routes/channels.php',
|
||||
health: '/up',
|
||||
)
|
||||
->withMiddleware(function (Middleware $middleware): void {
|
||||
//
|
||||
$middleware->validateCsrfTokens(except: [
|
||||
'hook/*',
|
||||
]);
|
||||
})
|
||||
->withExceptions(function (Exceptions $exceptions): void {
|
||||
$exceptions->shouldRenderJsonWhen(
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
<?php
|
||||
|
||||
use App\Providers\AppServiceProvider;
|
||||
use App\Providers\FortifyServiceProvider;
|
||||
|
||||
return [
|
||||
AppServiceProvider::class,
|
||||
FortifyServiceProvider::class,
|
||||
];
|
||||
|
||||
+5
-2
@@ -8,9 +8,12 @@
|
||||
"require": {
|
||||
"php": "^8.3",
|
||||
"laravel/boost": "^2.4",
|
||||
"laravel/fortify": "^1.37",
|
||||
"laravel/framework": "^13.8",
|
||||
"laravel/reverb": "^1.11",
|
||||
"laravel/sanctum": "^4.0",
|
||||
"laravel/tinker": "^3.0"
|
||||
"laravel/tinker": "^3.0",
|
||||
"livewire/livewire": "^4.3"
|
||||
},
|
||||
"require-dev": {
|
||||
"fakerphp/faker": "^1.23",
|
||||
@@ -44,7 +47,7 @@
|
||||
],
|
||||
"dev": [
|
||||
"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": [
|
||||
"@php artisan config:clear --ansi @no_additional_args",
|
||||
|
||||
Generated
+2362
-1
File diff suppressed because it is too large
Load Diff
@@ -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',
|
||||
],
|
||||
|
||||
],
|
||||
|
||||
];
|
||||
@@ -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(),
|
||||
],
|
||||
|
||||
];
|
||||
@@ -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),
|
||||
],
|
||||
],
|
||||
],
|
||||
|
||||
],
|
||||
|
||||
];
|
||||
@@ -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');
|
||||
}
|
||||
};
|
||||
Generated
+40
@@ -4,6 +4,10 @@
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"dependencies": {
|
||||
"laravel-echo": "^2.4.0",
|
||||
"pusher-js": "^8.6.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/vite": "^4.0.0",
|
||||
"concurrently": "^9.0.1",
|
||||
@@ -856,6 +860,27 @@
|
||||
"jiti": "lib/jiti-cli.mjs"
|
||||
}
|
||||
},
|
||||
"node_modules/laravel-echo": {
|
||||
"version": "2.4.0",
|
||||
"resolved": "https://registry.npmjs.org/laravel-echo/-/laravel-echo-2.4.0.tgz",
|
||||
"integrity": "sha512-8w0fAGSNt6THfbNyqdKc29bhfeNpJg13CGx2fcLgoX0/f0mTJm/AIkYTTakmcr9pc42ZB68cSoE00j4/xNaFGQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"pusher-js": "*",
|
||||
"socket.io-client": "*"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"pusher-js": {
|
||||
"optional": true
|
||||
},
|
||||
"socket.io-client": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/laravel-vite-plugin": {
|
||||
"version": "3.1.3",
|
||||
"resolved": "https://registry.npmjs.org/laravel-vite-plugin/-/laravel-vite-plugin-3.1.3.tgz",
|
||||
@@ -1234,6 +1259,15 @@
|
||||
"node": "^10 || ^12 || >=14"
|
||||
}
|
||||
},
|
||||
"node_modules/pusher-js": {
|
||||
"version": "8.6.0",
|
||||
"resolved": "https://registry.npmjs.org/pusher-js/-/pusher-js-8.6.0.tgz",
|
||||
"integrity": "sha512-wShJPfCS/kYkCBVzVW67wa9cnQIgHTszEK2XHNrFkOgGruuGw081aERAxfRjfdFU+WcIt8x6dvbwkTW4iZuQ8Q==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"tweetnacl": "^1.0.3"
|
||||
}
|
||||
},
|
||||
"node_modules/require-directory": {
|
||||
"version": "2.1.1",
|
||||
"resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz",
|
||||
@@ -1409,6 +1443,12 @@
|
||||
"dev": true,
|
||||
"license": "0BSD"
|
||||
},
|
||||
"node_modules/tweetnacl": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/tweetnacl/-/tweetnacl-1.0.3.tgz",
|
||||
"integrity": "sha512-6rt+RN7aOi1nGMyC4Xa5DdYiukl2UWCbcJft7YhxReBGQD7OAM8Pbxw6YMo4r2diNEA8FEmu32YOn9rhaiE5yw==",
|
||||
"license": "Unlicense"
|
||||
},
|
||||
"node_modules/vite": {
|
||||
"version": "8.2.0",
|
||||
"resolved": "https://registry.npmjs.org/vite/-/vite-8.2.0.tgz",
|
||||
|
||||
@@ -12,5 +12,9 @@
|
||||
"laravel-vite-plugin": "^3.1",
|
||||
"tailwindcss": "^4.0.0",
|
||||
"vite": "^8.0.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"laravel-echo": "^2.4.0",
|
||||
"pusher-js": "^8.6.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
|
||||
@source '../../vendor/laravel/framework/src/Illuminate/Pagination/resources/views/*.blade.php';
|
||||
@source '../../storage/framework/views/*.php';
|
||||
@source '../views';
|
||||
@source '../js';
|
||||
|
||||
@theme {
|
||||
--font-sans: 'Instrument Sans', ui-sans-serif, system-ui, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji',
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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>
|
||||
@@ -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
@@ -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();
|
||||
});
|
||||
@@ -2,7 +2,13 @@
|
||||
|
||||
use Illuminate\Foundation\Inspiring;
|
||||
use Illuminate\Support\Facades\Artisan;
|
||||
use Illuminate\Support\Facades\Schedule;
|
||||
|
||||
Artisan::command('inspire', function () {
|
||||
$this->comment(Inspiring::quote());
|
||||
})->purpose('Display an inspiring quote');
|
||||
|
||||
Schedule::command('webhooks:prune')
|
||||
->hourly()
|
||||
->withoutOverlapping()
|
||||
->onOneServer();
|
||||
|
||||
+23
-3
@@ -1,7 +1,27 @@
|
||||
<?php
|
||||
|
||||
use App\Http\Controllers\EndpointController;
|
||||
use App\Http\Controllers\InspectorController;
|
||||
use Illuminate\Support\Facades\Route;
|
||||
|
||||
Route::get('/', function () {
|
||||
return view('welcome');
|
||||
});
|
||||
Route::view('/', 'welcome')->name('home');
|
||||
|
||||
Route::post('/inspectors', [EndpointController::class, 'storeAnonymous'])
|
||||
->name('inspectors.store');
|
||||
|
||||
Route::any('/hook/{token}', [EndpointController::class, 'receive'])
|
||||
->middleware('throttle:webhooks')
|
||||
->where('token', '[A-Za-z0-9]+')
|
||||
->name('webhooks.receive');
|
||||
|
||||
Route::get('/inspect/{token}', [InspectorController::class, 'show'])
|
||||
->where('token', '[A-Za-z0-9]+')
|
||||
->name('inspect.show');
|
||||
|
||||
Route::delete('/inspect/{token}', [EndpointController::class, 'destroy'])
|
||||
->where('token', '[A-Za-z0-9]+')
|
||||
->name('inspect.destroy');
|
||||
|
||||
Route::view('/dashboard', 'dashboard-page')
|
||||
->middleware(['auth', 'verified'])
|
||||
->name('dashboard');
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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('<script>alert(1)</script>', 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('<img src=x onerror=alert(1)>', false);
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,5 @@
|
||||
import { defineConfig } from 'vite';
|
||||
import laravel from 'laravel-vite-plugin';
|
||||
import { bunny } from 'laravel-vite-plugin/fonts';
|
||||
import tailwindcss from '@tailwindcss/vite';
|
||||
|
||||
export default defineConfig({
|
||||
@@ -8,11 +7,6 @@ export default defineConfig({
|
||||
laravel({
|
||||
input: ['resources/css/app.css', 'resources/js/app.js'],
|
||||
refresh: true,
|
||||
fonts: [
|
||||
bunny('Instrument Sans', {
|
||||
weights: [400, 500, 600],
|
||||
}),
|
||||
],
|
||||
}),
|
||||
tailwindcss(),
|
||||
],
|
||||
|
||||
Reference in New Issue
Block a user