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;
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.
*
*/
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 {
$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;
- }
}
--- /dev/null
+<?php
+
+namespace BookStack\Actions;
+
+use BookStack\Auth\User;
+use BookStack\Entities\Models\Entity;
+use BookStack\Entities\Models\Page;
+use BookStack\Interfaces\Loggable;
+use BookStack\Model;
+use Illuminate\Support\Carbon;
+
+class WebhookFormatter
+{
+ protected Webhook $webhook;
+ protected string $event;
+ protected User $initiator;
+ protected int $initiatedTime;
+
+ /**
+ * @var string|Loggable
+ */
+ protected $detail;
+
+ /**
+ * @var array{condition: callable(string, Model):bool, format: callable(Model):void}[]
+ */
+ protected $modelFormatters = [];
+
+ public function __construct(string $event, Webhook $webhook, $detail, User $initiator, int $initiatedTime)
+ {
+ $this->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
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
{
->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.
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.
*/
/**
* Class PageRevision.
*
+ * @property mixed $id
* @property int $page_id
* @property string $slug
* @property string $book_slug
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.
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'));
* @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';
}
"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
+ }
}
}</code></pre>
</div>
\ No newline at end of file
--- /dev/null
+<?php
+
+namespace Tests\Actions;
+
+use BookStack\Actions\ActivityType;
+use BookStack\Actions\Webhook;
+use BookStack\Actions\WebhookFormatter;
+use BookStack\Entities\Models\Book;
+use BookStack\Entities\Models\Chapter;
+use BookStack\Entities\Models\Page;
+use Illuminate\Support\Arr;
+use Tests\TestCase;
+
+class WebhookFormatTesting extends TestCase
+{
+ public function test_entity_events_show_related_user_info()
+ {
+ $events = [
+ ActivityType::BOOK_UPDATE => 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
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
$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);
}