diff --git a/CHANGELOG.md b/CHANGELOG.md index c705695..3d73d36 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,6 @@ ## [1.0.0] - 2026-06-16 -- Incoming webhook management: token-protected ingress URLs with payload and header logging. +- Incoming webhook management: token-protected ingress URLs with payload and header logging. The incoming address is registered by the package, unauthenticated, since the sender holds only the token in the URL. +- Applications can use `RawPayload` to encode exact request bytes in JSON and decode them later. Incoming event payloads retain their existing parsed structure. - Signed, retried outbound webhook delivery: outgoing webhooks POST to a customer URL with an HMAC signature, delivered through a queued job with exponential-backoff retries. - Each delivery attempt is recorded with a stable delivery id for consumer-side deduplication; endpoints are auto-disabled after too many consecutive failures. - Webhooks are owned through a polymorphic `owner` relation so any model can own one, with `user_id` retained as the creator. diff --git a/README.md b/README.md index 4187253..8609123 100644 --- a/README.md +++ b/README.md @@ -46,6 +46,7 @@ return [ 'register_routes' => env('WEBHOOKS_REGISTER_ROUTES', true), 'route_prefix' => env('WEBHOOKS_ROUTE_PREFIX', ''), 'route_middleware' => ['auth:sanctum'], + 'ingress_middleware' => [], // Signed, retried outbound delivery. 'signing' => [ @@ -87,12 +88,58 @@ echo $webhook->url; // https://your-app.com/webhooks/ingress/{token} ### Webhook Ingress +`ingress_middleware` controls middleware on `POST /webhooks/ingress/{token}`. +It defaults to `[]` and does not inherit `route_middleware`, which protects +management routes. The receiving controller checks the URL token and requires an +active webhook; callers do not need a Sanctum login. + +To add throttling, set this in your published `config/webhooks.php`: + +```php +'ingress_middleware' => ['throttle:60,1'], +``` + +This applies Laravel's 60-request-per-minute throttle. Application middleware for +provider signature checks can also go in this list. The package does not verify +incoming signatures automatically. Application-wide middleware still applies. + +Both route groups use `route_prefix`; setting `register_routes` to `false` disables +both. See [route configuration](docs/customization.md#route-configuration). + Incoming webhooks are sent to a unique URL containing a secure token. When a webhook is triggered: 1. The token is validated. 2. The `trigger_count` and `last_triggered_at` fields are updated. 3. A `WebhookEvent` is recorded containing the payload and headers. 4. If `whilesmart/activities` is installed, an activity log is automatically created. +Incoming events keep the parsed request fields in `payload`. The package does not reserve +keys or choose an application-specific payload structure. + +### Keeping raw bodies in an application + +Applications with a custom incoming handler can use `RawPayload` to preserve exact +request bytes inside a JSON value: + +```php +use Whilesmart\Webhooks\Support\RawPayload; + +$envelope = RawPayload::encode($request->getContent()); +$event->payload = $envelope; +$event->save(); + +$originalBody = RawPayload::decode($event->payload); +``` + +The application chooses where to store this envelope and how its readers consume it. +The default package ingress does not use it. Encoding produces: + +```json +{"raw_body": "{\"event\": \"opened\"}", "encoding": "utf-8"} +``` + +Non-UTF-8 bodies use base64. Decoding returns the original bytes, or `null` for an +unsupported envelope. It does not parse JSON or form data and does not verify signatures. + ### Outbound Webhooks Outgoing webhooks deliver your application's events to a customer-supplied URL. Create one with `direction` set to `outgoing`, the destination `url`, and the `subscribed_events` it should receive (omit `subscribed_events` to receive all): diff --git a/config/webhooks.php b/config/webhooks.php index 4038d0e..1d9df1d 100644 --- a/config/webhooks.php +++ b/config/webhooks.php @@ -15,6 +15,8 @@ 'route_prefix' => env('WEBHOOKS_ROUTE_PREFIX', ''), 'route_middleware' => ['auth:sanctum'], + 'ingress_middleware' => [], + /* |-------------------------------------------------------------------------- | Outbound Delivery diff --git a/database/migrations/2024_01_01_000012_create_webhooks_table.php b/database/migrations/2024_01_01_000012_create_webhooks_table.php index f19651a..4a274c9 100644 --- a/database/migrations/2024_01_01_000012_create_webhooks_table.php +++ b/database/migrations/2024_01_01_000012_create_webhooks_table.php @@ -36,8 +36,8 @@ public function up(): void $table->timestamps(); $table->softDeletes(); - $table->index(['is_active']); - $table->index(['direction']); + $table->index(['owner_type', 'owner_id', 'direction', 'is_active'], 'webhooks_owner_dispatch'); + $table->index(['direction', 'is_active']); }); } diff --git a/docs/customization.md b/docs/customization.md index 201ba8d..f4a67d0 100644 --- a/docs/customization.md +++ b/docs/customization.md @@ -30,8 +30,32 @@ The package registers API routes automatically. You can customize the route pref 'register_routes' => env('WEBHOOKS_REGISTER_ROUTES', true), 'route_prefix' => env('WEBHOOKS_ROUTE_PREFIX', ''), 'route_middleware' => ['auth:sanctum'], +'ingress_middleware' => [], ``` +`route_middleware` applies to webhook management routes, including event listing. +`ingress_middleware` applies only to `POST /webhooks/ingress/{token}` and does not +inherit the management middleware. Its default is an empty list because webhook +senders authenticate with the URL token rather than a Sanctum login. The ingress +controller rejects unknown tokens and inactive webhooks. Global application +middleware still runs. + +Add middleware aliases or classes to the published configuration. For example, +Laravel's throttle middleware can limit incoming requests to 60 per minute: + +```php +'ingress_middleware' => ['throttle:60,1'], +``` + +Applications can also add middleware that checks a provider's signature against the +original request body. Incoming signature verification is not built into the package. +Adding `auth:sanctum` here requires senders to supply credentials accepted by the +application's Sanctum guard. + +`route_prefix` applies to both groups. For example, `api` makes the ingress path +`/api/webhooks/ingress/{token}`. Setting `register_routes` to `false` disables both +groups so the application can register its own routes. + ### Feature Flags You can enable or disable built-in support for Workspace scoping, Project scoping, and event tracking based on your needs: diff --git a/routes/ingress.php b/routes/ingress.php new file mode 100644 index 0000000..317353b --- /dev/null +++ b/routes/ingress.php @@ -0,0 +1,7 @@ +name('webhooks.ingress'); diff --git a/src/Support/RawPayload.php b/src/Support/RawPayload.php new file mode 100644 index 0000000..5941c8a --- /dev/null +++ b/src/Support/RawPayload.php @@ -0,0 +1,38 @@ + $isUtf8 ? $body : base64_encode($body), + 'encoding' => $isUtf8 ? 'utf-8' : 'base64', + ]; + } + + public static function decode(array $payload): ?string + { + $body = $payload['raw_body'] ?? null; + $encoding = $payload['encoding'] ?? null; + + if (! is_string($body)) { + return null; + } + + if ($encoding === 'utf-8') { + return $body; + } + + if ($encoding !== 'base64') { + return null; + } + + $decoded = base64_decode($body, true); + + return $decoded === false ? null : $decoded; + } +} diff --git a/src/WebhooksServiceProvider.php b/src/WebhooksServiceProvider.php index 09280e7..96c7520 100644 --- a/src/WebhooksServiceProvider.php +++ b/src/WebhooksServiceProvider.php @@ -40,5 +40,12 @@ protected function registerRoutes(): void ], function () { $this->loadRoutesFrom(__DIR__ . '/../routes/webhooks.php'); }); + + Route::group([ + 'prefix' => config('webhooks.route_prefix', 'api'), + 'middleware' => config('webhooks.ingress_middleware', []), + ], function () { + $this->loadRoutesFrom(__DIR__ . '/../routes/ingress.php'); + }); } } diff --git a/tests/Feature/IngressTest.php b/tests/Feature/IngressTest.php new file mode 100644 index 0000000..fd77ecd --- /dev/null +++ b/tests/Feature/IngressTest.php @@ -0,0 +1,54 @@ +incomingWebhook(); + $payload = ['action' => 'opened', 'raw_body' => 'application value', 'encoding' => 'application encoding']; + + $this->postJson("/webhooks/ingress/{$webhook->token}?source=customer", $payload)->assertStatus(202); + + $event = WebhookEvent::firstOrFail(); + + $this->assertEquals($payload + ['source' => 'customer'], $event->payload); + $this->assertEquals($event->payload, $event->toArray()['payload']); + } + + #[Test] + public function incoming_forms_keep_application_fields_at_the_top_level(): void + { + $webhook = $this->incomingWebhook(); + + $this->post("/webhooks/ingress/{$webhook->token}", ['action' => 'opened', 'number' => '7']) + ->assertStatus(202); + + $this->assertEquals(['action' => 'opened', 'number' => '7'], WebhookEvent::firstOrFail()->payload); + } + + private function incomingWebhook(): Webhook + { + $user = User::forceCreate([ + 'name' => 'Owner', + 'email' => 'owner+'.uniqid().'@example.com', + 'password' => bcrypt('secret'), + ]); + + return Webhook::create([ + 'owner_type' => $user->getMorphClass(), + 'owner_id' => $user->getKey(), + 'name' => 'Inbound', + 'direction' => Webhook::DIRECTION_INCOMING, + 'is_active' => true, + ]); + } +} diff --git a/tests/Unit/RawPayloadTest.php b/tests/Unit/RawPayloadTest.php new file mode 100644 index 0000000..631732d --- /dev/null +++ b/tests/Unit/RawPayloadTest.php @@ -0,0 +1,33 @@ +assertSame($body, $restored); + $this->assertSame(hash_hmac('sha256', $body, 'test-secret'), hash_hmac('sha256', $restored, 'test-secret')); + } + } + + #[Test] + public function ordinary_payloads_and_invalid_envelopes_are_not_decoded(): void + { + foreach ([['action' => 'opened'], ['raw_body' => 'text'], ['raw_body' => [], 'encoding' => 'utf-8'], + ['raw_body' => 'text', 'encoding' => 'unknown'], ['raw_body' => '!!!', 'encoding' => 'base64']] as $payload) { + $this->assertNull(RawPayload::decode($payload)); + } + } +}