diff --git a/app/Actions/DispatchWebhookJob.php b/app/Actions/DispatchWebhookJob.php index 8f78150a9..2d805228c 100644 --- a/app/Actions/DispatchWebhookJob.php +++ b/app/Actions/DispatchWebhookJob.php @@ -3,17 +3,14 @@ namespace BookStack\Actions; use BookStack\Auth\User; -use BookStack\Entities\Models\Entity; use BookStack\Facades\Theme; use BookStack\Interfaces\Loggable; -use BookStack\Model; use BookStack\Theming\ThemeEvents; use Illuminate\Bus\Queueable; use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Foundation\Bus\Dispatchable; use Illuminate\Queue\InteractsWithQueue; use Illuminate\Queue\SerializesModels; -use Illuminate\Support\Carbon; use Illuminate\Support\Facades\Http; use Illuminate\Support\Facades\Log; @@ -24,31 +21,16 @@ class DispatchWebhookJob implements ShouldQueue use Queueable; use SerializesModels; - /** - * @var Webhook - */ - protected $webhook; - - /** - * @var string - */ - protected $event; + protected Webhook $webhook; + protected string $event; + protected User $initiator; + protected int $initiatedTime; /** * @var string|Loggable */ protected $detail; - /** - * @var User - */ - protected $initiator; - - /** - * @var int - */ - protected $initiatedTime; - /** * Create a new job instance. * @@ -70,8 +52,8 @@ class DispatchWebhookJob implements ShouldQueue */ public function handle() { - $themeResponse = Theme::dispatch(ThemeEvents::WEBHOOK_CALL_BEFORE, $this->event, $this->webhook, $this->detail); - $webhookData = $themeResponse ?? $this->buildWebhookData(); + $themeResponse = Theme::dispatch(ThemeEvents::WEBHOOK_CALL_BEFORE, $this->event, $this->webhook, $this->detail, $this->initiator, $this->initiatedTime); + $webhookData = $themeResponse ?? WebhookFormatter::getDefault($this->event, $this->webhook, $this->detail, $this->initiator, $this->initiatedTime)->format(); $lastError = null; try { @@ -97,36 +79,4 @@ class DispatchWebhookJob implements ShouldQueue $this->webhook->save(); } - - protected function buildWebhookData(): array - { - $textParts = [ - $this->initiator->name, - trans('activities.' . $this->event), - ]; - - if ($this->detail instanceof Entity) { - $textParts[] = '"' . $this->detail->name . '"'; - } - - $data = [ - 'event' => $this->event, - 'text' => implode(' ', $textParts), - 'triggered_at' => Carbon::createFromTimestampUTC($this->initiatedTime)->toISOString(), - 'triggered_by' => $this->initiator->attributesToArray(), - 'triggered_by_profile_url' => $this->initiator->getProfileUrl(), - 'webhook_id' => $this->webhook->id, - 'webhook_name' => $this->webhook->name, - ]; - - if (method_exists($this->detail, 'getUrl')) { - $data['url'] = $this->detail->getUrl(); - } - - if ($this->detail instanceof Model) { - $data['related_item'] = $this->detail->attributesToArray(); - } - - return $data; - } } diff --git a/app/Actions/WebhookFormatter.php b/app/Actions/WebhookFormatter.php new file mode 100644 index 000000000..48b1a3929 --- /dev/null +++ b/app/Actions/WebhookFormatter.php @@ -0,0 +1,123 @@ +webhook = $webhook; + $this->event = $event; + $this->initiator = $initiator; + $this->initiatedTime = $initiatedTime; + $this->detail = is_object($detail) ? clone $detail : $detail; + } + + public function format(): array + { + $data = [ + 'event' => $this->event, + 'text' => $this->formatText(), + 'triggered_at' => Carbon::createFromTimestampUTC($this->initiatedTime)->toISOString(), + 'triggered_by' => $this->initiator->attributesToArray(), + 'triggered_by_profile_url' => $this->initiator->getProfileUrl(), + 'webhook_id' => $this->webhook->id, + 'webhook_name' => $this->webhook->name, + ]; + + if (method_exists($this->detail, 'getUrl')) { + $data['url'] = $this->detail->getUrl(); + } + + if ($this->detail instanceof Model) { + $data['related_item'] = $this->formatModel(); + } + + return $data; + } + + /** + * @param callable(string, Model):bool $condition + * @param callable(Model):void $format + */ + public function addModelFormatter(callable $condition, callable $format): void + { + $this->modelFormatters[] = [ + 'condition' => $condition, + 'format' => $format, + ]; + } + + public function addDefaultModelFormatters(): void + { + // Load entity owner, creator, updater details + $this->addModelFormatter( + fn($event, $model) => ($model instanceof Entity), + fn($model) => $model->load(['ownedBy', 'createdBy', 'updatedBy']) + ); + + // Load revision detail for page update and create events + $this->addModelFormatter( + fn($event, $model) => ($model instanceof Page && ($event === ActivityType::PAGE_CREATE || $event === ActivityType::PAGE_UPDATE)), + fn($model) => $model->load('currentRevision') + ); + } + + protected function formatModel(): array + { + /** @var Model $model */ + $model = $this->detail; + $model->unsetRelations(); + + foreach ($this->modelFormatters as $formatter) { + if ($formatter['condition']($this->event, $model)) { + $formatter['format']($model); + } + } + + return $model->toArray(); + } + + protected function formatText(): string + { + $textParts = [ + $this->initiator->name, + trans('activities.' . $this->event), + ]; + + if ($this->detail instanceof Entity) { + $textParts[] = '"' . $this->detail->name . '"'; + } + + return implode(' ', $textParts); + } + + public static function getDefault(string $event, Webhook $webhook, $detail, User $initiator, int $initiatedTime): self + { + $instance = new static($event, $webhook, $detail, $initiator, $initiatedTime); + $instance->addDefaultModelFormatters(); + return $instance; + } +} \ No newline at end of file diff --git a/app/Entities/Models/Page.php b/app/Entities/Models/Page.php index c28b9a305..c8217af57 100644 --- a/app/Entities/Models/Page.php +++ b/app/Entities/Models/Page.php @@ -10,19 +10,22 @@ use Illuminate\Database\Eloquent\Collection; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\HasMany; +use Illuminate\Database\Eloquent\Relations\HasOne; /** * Class Page. * - * @property int $chapter_id - * @property string $html - * @property string $markdown - * @property string $text - * @property bool $template - * @property bool $draft - * @property int $revision_count - * @property Chapter $chapter - * @property Collection $attachments + * @property int $chapter_id + * @property string $html + * @property string $markdown + * @property string $text + * @property bool $template + * @property bool $draft + * @property int $revision_count + * @property Chapter $chapter + * @property Collection $attachments + * @property Collection $revisions + * @property PageRevision $currentRevision */ class Page extends BookChild { @@ -82,6 +85,19 @@ class Page extends BookChild ->orderBy('id', 'desc'); } + /** + * Get the current revision for the page if existing. + * + * @return PageRevision|null + */ + public function currentRevision(): HasOne + { + return $this->hasOne(PageRevision::class) + ->where('type', '=', 'version') + ->orderBy('created_at', 'desc') + ->orderBy('id', 'desc'); + } + /** * Get all revision instances assigned to this page. * Includes all types of revisions. @@ -117,16 +133,6 @@ class Page extends BookChild return url('/' . implode('/', $parts)); } - /** - * Get the current revision for the page if existing. - * - * @return PageRevision|null - */ - public function getCurrentRevision() - { - return $this->revisions()->first(); - } - /** * Get this page for JSON display. */ diff --git a/app/Entities/Models/PageRevision.php b/app/Entities/Models/PageRevision.php index 4daf50536..aacc94586 100644 --- a/app/Entities/Models/PageRevision.php +++ b/app/Entities/Models/PageRevision.php @@ -10,6 +10,7 @@ use Illuminate\Database\Eloquent\Relations\BelongsTo; /** * Class PageRevision. * + * @property mixed $id * @property int $page_id * @property string $slug * @property string $book_slug @@ -27,6 +28,7 @@ use Illuminate\Database\Eloquent\Relations\BelongsTo; class PageRevision extends Model { protected $fillable = ['name', 'html', 'text', 'markdown', 'summary']; + protected $hidden = ['html', 'markdown', 'restricted', 'text']; /** * Get the user that created the page revision. diff --git a/app/Http/Controllers/PageRevisionController.php b/app/Http/Controllers/PageRevisionController.php index d595a6e26..c6a4926d2 100644 --- a/app/Http/Controllers/PageRevisionController.php +++ b/app/Http/Controllers/PageRevisionController.php @@ -124,11 +124,8 @@ class PageRevisionController extends Controller throw new NotFoundException("Revision #{$revId} not found"); } - // Get the current revision for the page - $currentRevision = $page->getCurrentRevision(); - - // Check if its the latest revision, cannot delete latest revision. - if (intval($currentRevision->id) === intval($revId)) { + // Check if it's the latest revision, cannot delete the latest revision. + if (intval($page->currentRevision->id ?? null) === intval($revId)) { $this->showErrorNotification(trans('entities.revision_cannot_delete_latest')); return redirect($page->getUrl('/revisions')); diff --git a/app/Theming/ThemeEvents.php b/app/Theming/ThemeEvents.php index 48073416c..ce99c817c 100644 --- a/app/Theming/ThemeEvents.php +++ b/app/Theming/ThemeEvents.php @@ -93,6 +93,8 @@ class ThemeEvents * @param string $event * @param \BookStack\Actions\Webhook $webhook * @param string|\BookStack\Interfaces\Loggable $detail + * @param \BookStack\Auth\User $initiator + * @param int $initiatedTime */ const WEBHOOK_CALL_BEFORE = 'webhook_call_before'; } diff --git a/resources/views/settings/webhooks/parts/format-example.blade.php b/resources/views/settings/webhooks/parts/format-example.blade.php index 135d3193b..e10a6c69e 100644 --- a/resources/views/settings/webhooks/parts/format-example.blade.php +++ b/resources/views/settings/webhooks/parts/format-example.blade.php @@ -23,12 +23,37 @@ "priority": 2, "created_at": "2021-12-11T21:53:24.000000Z", "updated_at": "2021-12-11T22:25:10.000000Z", - "created_by": 1, - "updated_by": 1, + "created_by": { + "id": 1, + "name": "Benny", + "slug": "benny" + }, + "updated_by": { + "id": 1, + "name": "Benny", + "slug": "benny" + }, "draft": false, "revision_count": 9, "template": false, - "owned_by": 1 + "owned_by": { + "id": 1, + "name": "Benny", + "slug": "benny" + }, + "current_revision": { + "id": 597, + "page_id": 2598, + "name": "My wonderful updated page", + "created_by": 1, + "created_at": "2021-12-11T21:53:24.000000Z", + "updated_at": "2021-12-11T21:53:24.000000Z", + "slug": "my-wonderful-updated-page", + "book_slug": "my-awesome-book", + "type": "version", + "summary": "Updated the title and fixed some spelling", + "revision_number": 2 + } } } \ No newline at end of file diff --git a/tests/Actions/WebhookFormatTesting.php b/tests/Actions/WebhookFormatTesting.php new file mode 100644 index 000000000..56a569ca9 --- /dev/null +++ b/tests/Actions/WebhookFormatTesting.php @@ -0,0 +1,52 @@ + Book::query()->first(), + ActivityType::CHAPTER_CREATE => Chapter::query()->first(), + ActivityType::PAGE_MOVE => Page::query()->first(), + ]; + + foreach ($events as $event => $entity) { + $data = $this->getWebhookData($event, $entity); + + $this->assertEquals($entity->createdBy->name, Arr::get($data, 'related_item.created_by.name')); + $this->assertEquals($entity->updatedBy->id, Arr::get($data, 'related_item.updated_by.id')); + $this->assertEquals($entity->ownedBy->slug, Arr::get($data, 'related_item.owned_by.slug')); + } + } + + public function test_page_create_and_update_events_show_revision_info() + { + /** @var Page $page */ + $page = Page::query()->first(); + $this->asEditor()->put($page->getUrl(), ['name' => 'Updated page', 'html' => 'new page html', 'summary' => 'Update a']); + + $data = $this->getWebhookData(ActivityType::PAGE_UPDATE, $page); + $this->assertEquals($page->currentRevision->id, Arr::get($data, 'related_item.current_revision.id')); + $this->assertEquals($page->currentRevision->type, Arr::get($data, 'related_item.current_revision.type')); + $this->assertEquals('Update a', Arr::get($data, 'related_item.current_revision.summary')); + } + + protected function getWebhookData(string $event, $detail): array + { + $webhook = Webhook::factory()->make(); + $user = $this->getEditor(); + $formatter = WebhookFormatter::getDefault($event, $webhook, $detail, $user, time()); + return $formatter->format(); + } +} \ No newline at end of file diff --git a/tests/Entity/PageRevisionTest.php b/tests/Entity/PageRevisionTest.php index 2ed7d3b41..fc6678788 100644 --- a/tests/Entity/PageRevisionTest.php +++ b/tests/Entity/PageRevisionTest.php @@ -144,13 +144,14 @@ class PageRevisionTest extends TestCase public function test_revision_deletion() { - $page = Page::first(); + /** @var Page $page */ + $page = Page::query()->first(); $this->asEditor()->put($page->getUrl(), ['name' => 'Updated page', 'html' => 'new page html', 'summary' => 'Update a']); - $page = Page::find($page->id); + $page->refresh(); $this->asEditor()->put($page->getUrl(), ['name' => 'Updated page', 'html' => 'new page html', 'summary' => 'Update a']); - $page = Page::find($page->id); + $page->refresh(); $beforeRevisionCount = $page->revisions->count(); // Delete the first revision @@ -158,18 +159,17 @@ class PageRevisionTest extends TestCase $resp = $this->asEditor()->delete($revision->getUrl('/delete/')); $resp->assertRedirect($page->getUrl('/revisions')); - $page = Page::find($page->id); + $page->refresh(); $afterRevisionCount = $page->revisions->count(); $this->assertTrue($beforeRevisionCount === ($afterRevisionCount + 1)); // Try to delete the latest revision $beforeRevisionCount = $page->revisions->count(); - $currentRevision = $page->getCurrentRevision(); - $resp = $this->asEditor()->delete($currentRevision->getUrl('/delete/')); + $resp = $this->asEditor()->delete($page->currentRevision->getUrl('/delete/')); $resp->assertRedirect($page->getUrl('/revisions')); - $page = Page::find($page->id); + $page->refresh(); $afterRevisionCount = $page->revisions->count(); $this->assertTrue($beforeRevisionCount === $afterRevisionCount); }