aimeos / aimeos/pagible

Webhook & Event System: Notify external systems when content changes

未关闭
#111 0 条评论 0 个 reaction 已指派 0 人 在 GitHub 查看
主要语言
PHP
星标
586
派生
14
平均合并
4 小时 57 分钟
30 天内合并 PR
19

描述

## Context

Pagible CMS has no way to notify external systems when content changes. This prevents integrations with CDN purging, Slack notifications, CI/CD pipelines, and marketing tools. We're adding a hybrid event + webhook system split into two parts: a lightweight event system in core, and a separate `aimeos/pagible-webhooks` package for webhook delivery.

## Mono-Repo Structure

```
src/ ← existing core package (aimeos/pagible)
Events/CmsEvent.php ← NEW: event class + emit() helper
Concerns/Events.php ← NEW: trait for delete/restore/purge events
...existing code...

webhooks/ ← NEW separate package (aimeos/pagible-webhooks)
src/
Models/Webhook.php
Listeners/WebhookListener.php
GraphQL/Mutations/{Add,Save,Drop}Webhook.php
WebhookServiceProvider.php
database/migrations/
graphql/cms-webhook.graphql
admin/ ← Vue component for webhook management
composer.json
```

## Mono-Repo Packaging

The repo root IS `aimeos/pagible` — published directly to Packagist from the repo.

`aimeos/pagible-webhooks` is split from `webhooks/` into its own read-only repo via `splitsh/lite`. A GitHub Actions workflow runs on push to `master` (or on tag), pushing `webhooks/` to a separate `aimeos/pagible-webhooks` repo.

Add `/webhooks/ export-ignore` to `.gitattributes` so `webhooks/` is excluded from dist archives of the core package.

For local development, use Composer `path` repositories to symlink:
```json
{
"repositories": [
{ "type": "path", "url": "./webhooks" }
]
}
```

## What Changes in Core (`aimeos/pagible`)

### New files (2)
| File | Purpose |
|------|---------|
| `src/Events/CmsEvent.php` | Event class with `emit()` static helper |
| `src/Concerns/Events.php` | Trait that hooks into Eloquent model events (delete/restore/purge) |

### Modified files (7)
| File | Change |
|------|--------|
| `src/Models/Page.php` | Add `use Concerns\Events` trait + `CmsEvent::emit('page.published', $this)` in `publish()` |
| `src/Models/Element.php` | Add `use Concerns\Events` trait + `CmsEvent::emit('element.published', $this)` in `publish()` |
| `src/Models/File.php` | Add `use Concerns\Events` trait + `CmsEvent::emit('file.published', $this)` in `publish()` |
| `src/GraphQL/Mutations/MovePage.php` | Add `CmsEvent::emit('page.moved', $page)` |
| `src/Tools/MovePage.php` | Add `CmsEvent::emit('page.moved', $page)` |
| `src/Permission.php` | Add `config:webhook` permission bit |
| `composer.json` | Add `aimeos/pagible-webhooks` to `suggest` |

### Not changed in core
- No webhook model, migration, listener, GraphQL schema, config, or admin UI
- Core fires events with zero cost if no listener exists
- Events are useful beyond webhooks (custom listeners, logging, etc.)

## What's in the Webhook Package (`aimeos/pagible-webhooks`)

### `composer.json`
```json
{
"name": "aimeos/pagible-webhooks",
"type": "library",
"license": "LGPL-3.0-or-later",
"require": {
"aimeos/pagible": "^1.0",
"spatie/laravel-webhook-server": "^3.0"
},
"autoload": {
"psr-4": { "Aimeos\\Cms\\": "src/" }
},
"extra": {
"laravel": {
"providers": ["Aimeos\\Cms\\WebhookServiceProvider"]
}
}
}
```

### `src/WebhookServiceProvider.php`
- Auto-discovered by Laravel (Spatie is always available since it's a hard requirement)
- Registers:
- `WebhookListener` for `CmsEvent`
- Migration from `database/migrations/`
- Publishes `graphql/cms-webhook.graphql` to `base_path('graphql')` (tag: `admin`)
- Admin UI assets (publishable)
- No Lighthouse namespace registration needed — `Aimeos\Cms\Models` and `Aimeos\Cms\GraphQL\Mutations` are already configured by the app for core
- Registers admin panel dynamically (see Admin UI section)

### `src/Models/Webhook.php`
- Table: `cms_webhooks`
- Uses `HasUuids`, `Aimeos\Cms\Concerns\Tenancy` traits
- Fields: `id` (UUID), `tenant_id`, `url`, `secret`, `events` (JSON array of exact event names), `status` (smallint), `failures` (int), `last_error` (JSON nullable), `editor`, timestamps

### `src/Listeners/WebhookListener.php`
- **Synchronous** — indexed DB query + Spatie job dispatch
- Listens for `Aimeos\Cms\Events\CmsEvent`
- Queries webhooks with `Webhook::withoutTenancy()->where('tenant_id', $event->tenantId)->where('status', 1)->get()` — bypasses Tenancy global scope to work in CLI context (`cms:publish` cron) where `Tenancy::value()` may be empty
- Also listens for Spatie's `FinalWebhookCallFailedEvent` to atomically `$webhook->increment('failures')`, set `last_error` (response code + body truncated to 64KB + timestamp), `Log::warning(...)`
- Passes `webhook_id` via Spatie's `->meta(['webhook_id' => $webhook->id])`
- Event matching: `in_array($event->event, $webhook->events)`

### `src/GraphQL/Mutations/{Add,Save,Drop}Webhook.php`
- `Permission::can('config:webhook', Auth::user())` check
- URL validation via `Aimeos\Cms\Utils::isValidUrl()`
- Editor set via `Auth::user()->name ?? request()->ip()`
- `addWebhook` auto-generates secret (`Str::random(32)`), returns it; queries return `secret: null`
- `addWebhook` enforces hard limit of 100 webhooks per tenant

### `database/migrations/xxxx_create_cms_webhooks_table.php`
```
cms_webhooks:
id uuid PK
tenant_id string
status smallint default 1
failures integer default 0
created_at timestamp
updated_at timestamp
editor string
secret string(32)
url string(500)
events json
last_error json nullable

INDEX: idx_cms_webhooks_tenant_status (tenant_id, status)
```

### `graphql/cms-webhook.graphql`
Published to `base_path('graphql/cms-webhook.graphql')` alongside core's `cms.graphql`. The app's main schema file must add `#import cms-webhook.graphql` (update cms:install command). Uses Lighthouse's `extend type` to add to existing schema:
```graphql
type Webhook {
id: ID!
status: Int!
failures: Int!
url: String!
secret: String
events: JSON!
last_error: JSON
editor: String
created_at: DateTime
updated_at: DateTime
}

input WebhookInput { url: String!, events: [String!]!, status: Int }

extend type Query {
webhook(id: ID!): Webhook @guard @find
webhooks: [Webhook!] @guard @paginate(defaultCount: 50)
}

extend type Mutation {
addWebhook(input: WebhookInput!): Webhook @guard
saveWebhook(id: ID!, input: WebhookInput!): Webhook @guard
dropWebhook(id: [ID!]!): [Webhook]! @guard
}
```

### Admin UI — `admin/src/views/WebhookList.vue`
- Single component with inline `v-dialog` for create/edit
- Shows: URL, events, status toggle, failure count, last error
- Secret shown in creation dialog only (copy-to-clipboard)
- Registered dynamically as a panel (assumes core supports dynamic panel registration)
- Nav entry gated by `config:webhook` permission

## Event System (in Core)

### `src/Events/CmsEvent.php`
```php
class CmsEvent {
public string $event;
public string $tenantId;
public array $data;
public string $editor;
public Carbon $timestamp;

public static function emit(string $event, Model $model): void {
$fn = fn() => event(new static($event, $model));
DB::transactionLevel() > 0 ? DB::afterCommit($fn) : $fn();
}

public function toPayload(): array { /* key fields per model type */ }
}
```

- `toPayload()` uses `$model->getAttributes()` and returns model-specific key fields (Page: id/name/tag/lang/path/domain; Element: id/name/lang/type; File: id/name/lang/mime) returned by `$model->toEvent()`
- Reads `$model->tenant_id` directly (works in CLI/cron without `Tenancy::$callback`)

### `src/Concerns/Events.php`
Laravel auto-calls `boot{TraitName}()` for each trait — no conflict with `Tenancy` trait (which uses `booted()`).
```php
trait Events
{
public static function bootEvents(): void
{
static::deleted( function( Model $model ) {
if( !$model->isForceDeleting() ) {
CmsEvent::emit( strtolower( class_basename( $model ) ) . '.deleted', $model );
}
});

static::restored( function( Model $model ) {
CmsEvent::emit(strtolower( class_basename( $model ) ) . '.restored', $model );
});

static::forceDeleted( function( Model $model ) {
CmsEvent::emit(strtolower( class_basename( $model ) ) . '.purged', $model );
});
}
}
```

Uses `strtolower( class_basename( $model ) )` to derive `'page'`, `'element'`, or `'file'` from the class where the trait is included. Nav is read-only and doesn't use the trait. No method to implement in models.

Note: `forceDelete()` triggers both `deleted` and `forceDeleted`. The `isForceDeleting()` guard prevents duplicate events.

### Event Types (14 total)

| Event | Models | Source |
|-------|--------|--------|
| `*.published` | page, element, file | Explicit call in `publish()` |
| `*.moved` | page only | Explicit call in MovePage mutation/tool |
| `*.deleted` | page, element, file | Events trait (Eloquent event) |
| `*.restored` | page, element, file | Events trait (Eloquent event) |
| `*.purged` | page, element, file | Events trait (Eloquent event) |

No wildcard matching — webhooks subscribe to exact event names. Simple `in_array()` check.

## Design Decisions

### Package boundary
- **Core** owns events + Events trait (always active, zero cost without listener)
- **Webhook package** owns delivery infrastructure (model, listener, GraphQL, UI)

### Security
- **SSRF**: Validate URLs with `Utils::isValidUrl()` (from core)
- **Secret**: `Str::random(32)`, returned only in `addWebhook` response
- **Limit**: Hard limit of 100 webhooks per tenant
- **Idempotency**: Spatie includes `uuid` per call
- **Atomic failure tracking**: `$webhook->increment('failures')` to prevent race conditions

### Performance
- No caching needed — tiny table, composite index, infrequent events
- Spatie manages its own queue/timeout/retries via `config/webhook-server.php`

### Delivery tracking
- On final failure: atomic increment `failures`, set `last_error`, `Log::warning(...)`
- On success: no action (failure count is cumulative)

### Activation
- Webhook package auto-discovered via Laravel provider auto-discovery
- Spatie is a hard requirement of the webhook package — always available when package is installed

## Webhook Payload

```json
{
"event": "page.published",
"tenant_id": "abc123",
"timestamp": "2026-03-20T12:00:00Z",
"editor": "user@example.com",
"data": { "id": "uuid", "name": "My Page", "tag": "my-page", "lang": "en", "path": "my-page", "domain": "example.com" }
}
```
Fields in `data` vary per model type.

## Implementation Order

### Phase 1: Core changes
1. Add `CmsEvent` event class with `emit()` static helper
2. Add `Concerns\Events` trait with `bootEvents()` for delete/restore/purge
3. Add `use Concerns\Events` to Page, Element, File models
4. Add `CmsEvent::emit()` in 5 explicit locations (3 publish + 2 move)
5. Add `config:webhook` permission bit to `Permission.php`

### Phase 2: Webhook package
6. Create `webhooks/` package structure + `composer.json`
7. `Webhook` model + migration
8. `WebhookListener` (CmsEvent → delivery, Spatie failure → tracking)
9. `WebhookServiceProvider` — registration
10. GraphQL schema (`cms-webhook.graphql`) + mutations
11. Admin UI (`WebhookList.vue` + dynamic panel registration)

### Phase 3: Integration
12. Add `aimeos/pagible-webhooks` to core's `suggest`
13. Add `.gitattributes` with `/webhooks/ export-ignore`
14. Add GitHub Actions workflow for `splitsh/lite` subtree split
15. Add `webhook-test` job to `.circleci/config.yml` (see below)
16. Tests for both packages

### CircleCI — `.circleci/config.yml`
Add a new job `webhook-test` using SQLite (same pattern as `php85-sqlite`):
```yaml
"webhook-test":
docker:
- image: aimeos/ci-php:8.5
environment:
DB_DRIVER: sqlite
steps:
- checkout
- restore_cache:
keys:
- php85-{{ checksum "composer.json" }}
- run: composer update -n --prefer-dist
- run: cd webhooks && composer update -n --prefer-dist
- save_cache:
key: php85-{{ checksum "composer.json" }}
paths: [./vendor]
- run: cd webhooks && ../vendor/bin/phpunit
```
Add `"webhook-test"` to the `unittest` workflow jobs list.

## Tests

All tests extend `Tests\TestAbstract` (Orchestra Testbench, SQLite in-memory, `RefreshDatabase`). GraphQL tests use `MakesGraphQLRequests` + `RefreshesSchemaCache` traits. User created with `cmseditor => PHP_INT_MAX` for full permissions.

### Core Tests — `tests/CmsEventTest.php`

| Test | What it verifies |
|------|-----------------|
| `testEmitOutsideTransaction` | `CmsEvent::emit()` dispatches event immediately when no transaction active |
| `testEmitInsideTransaction` | `CmsEvent::emit()` defers via `DB::afterCommit()`, event fires after commit |
| `testEmitInsideTransactionRollback` | Event does NOT fire if transaction rolls back |
| `testEventProperties` | `CmsEvent` constructor sets `event`, `tenantId`, `data`, `editor`, `timestamp` correctly |
| `testTenantIdFromModel` | `tenantId` reads `$model->tenant_id` directly (not `Tenancy::$callback`) |
| `testToPayloadPage` | `toPayload()` returns id, name, tag, lang, path for Page |
| `testToPayloadElement` | `toPayload()` returns id, name, lang, type for Element |
| `testToPayloadFile` | `toPayload()` returns id, name, lang, mime for File |

### Core Tests — `tests/EventsTraitTest.php`

| Test | What it verifies |
|------|-----------------|
| `testDeletedFiresEvent` | Soft delete dispatches `page.deleted` / `element.deleted` / `file.deleted` |
| `testRestoredFiresEvent` | Restore dispatches `page.restored` / `element.restored` / `file.restored` |
| `testForceDeletedFiresPurgedEvent` | `forceDelete()` dispatches `*.purged` |
| `testForceDeletedDoesNotFireDeletedEvent` | `isForceDeleting()` guard prevents duplicate `*.deleted` on `forceDelete()` |

Use `Event::fake([CmsEvent::class])` then `Event::assertDispatched(CmsEvent::class, fn($e) => $e->event === 'page.deleted')`.

### Core Tests — `tests/PermissionTest.php` (add to existing)

| Test | What it verifies |
|------|-----------------|
| `testCanConfigWebhook` | `config:webhook` permission bit works with `can()` / `add()` / `del()` |

### Webhook Package Tests — `webhooks/tests/WebhookTest.php`

| Test | What it verifies |
|------|-----------------|
| `testWebhookModel` | Model attributes, UUID, tenant scope, JSON casts for `events` and `last_error` |
| `testWebhookTenantIsolation` | Webhooks scoped to tenant via `Tenancy` trait |

### Webhook Package Tests — `webhooks/tests/WebhookListenerTest.php`

| Test | What it verifies |
|------|-----------------|
| `testDispatchesToMatchingWebhook` | `CmsEvent` with matching event name triggers `WebhookCall::create()` |
| `testSkipsNonMatchingWebhook` | Webhook subscribed to `page.published` ignores `element.deleted` |
| `testSkipsInactiveWebhook` | Webhook with `status = 0` is not triggered |
| `testTenantIsolation` | Listener only queries webhooks for `$event->tenantId`, not other tenants |
| `testPayloadFormat` | `WebhookCall` receives correct payload structure (event, tenant_id, timestamp, editor, data) |
| `testHmacSigning` | `WebhookCall` uses webhook's `secret` for HMAC signing |
| `testCustomHeaders` | `X-Cms-Event` and `X-Cms-Tenant` headers are set |
| `testFailureIncrement` | `FinalWebhookCallFailedEvent` increments `failures` atomically and sets `last_error` |
| `testFailureLogging` | `FinalWebhookCallFailedEvent` logs via `Log::warning()` |

Use `Queue::fake()` to capture dispatched `WebhookCall` jobs without executing HTTP requests.

### Webhook Package Tests — `webhooks/tests/GraphqlWebhookTest.php`

| Test | What it verifies |
|------|-----------------|
| `testAddWebhook` | Creates webhook, returns secret, sets editor |
| `testAddWebhookUrlValidation` | Rejects invalid/private URLs via `Utils::isValidUrl()` |
| `testAddWebhookLimit` | Rejects when tenant has 100 webhooks |
| `testAddWebhookPermission` | Denied without `config:webhook` permission |
| `testSaveWebhook` | Updates URL and events, secret unchanged |
| `testSaveWebhookSecretNotReturned` | `secret` is null in save/query responses |
| `testDropWebhook` | Deletes webhook(s) by ID |
| `testDropWebhookPermission` | Denied without `config:webhook` permission |
| `testQueryWebhooks` | Lists webhooks for current tenant only |
| `testQueryWebhookById` | Fetches single webhook |

### Integration Test — `webhooks/tests/WebhookIntegrationTest.php`

| Test | What it verifies |
|------|-----------------|
| `testPublishPageTriggersWebhook` | Create webhook subscribed to `page.published`, publish a page, assert `WebhookCall` dispatched with correct payload |
| `testDeletePageTriggersWebhook` | Soft-delete a page, assert `page.deleted` webhook dispatched |
| `testNoWebhooksShortCircuits` | Tenant with no webhooks — listener returns early after empty DB query |

## Verification

1. **Run core tests**: `vendor/bin/paratest -p 2 --exclude-group network` — no regressions from Events trait
2. **Run webhook tests**: `cd webhooks && ../vendor/bin/phpunit` — all webhook tests pass
3. **Manual test**: Register webhook via GraphQL pointing to request bin URL, publish a page, verify signed HTTP POST arrives

贡献指南

这个仓库没有索引到贡献指南

评估

这个 Issue 还没有评估数据。

把新 issue 发到你的邮箱

精选适合新手参与的 GitHub issue 摘要。