mirror of
https://github.com/BookStackApp/BookStack.git
synced 2024-11-23 01:12:47 +08:00
Merge branch 'webhooks'
This commit is contained in:
commit
a3ead5062a
|
@ -100,8 +100,7 @@ MEMCACHED_SERVERS=127.0.0.1:11211:100
|
|||
REDIS_SERVERS=127.0.0.1:6379:0
|
||||
|
||||
# Queue driver to use
|
||||
# Queue not really currently used but may be configurable in the future.
|
||||
# Would advise not to change this for now.
|
||||
# Can be 'sync', 'database' or 'redis'
|
||||
QUEUE_CONNECTION=sync
|
||||
|
||||
# Storage system to use
|
||||
|
|
115
app/Actions/ActivityLogger.php
Normal file
115
app/Actions/ActivityLogger.php
Normal file
|
@ -0,0 +1,115 @@
|
|||
<?php
|
||||
|
||||
namespace BookStack\Actions;
|
||||
|
||||
use BookStack\Auth\Permissions\PermissionService;
|
||||
use BookStack\Entities\Models\Entity;
|
||||
use BookStack\Interfaces\Loggable;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
class ActivityLogger
|
||||
{
|
||||
protected $permissionService;
|
||||
|
||||
public function __construct(PermissionService $permissionService)
|
||||
{
|
||||
$this->permissionService = $permissionService;
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a generic activity event to the database.
|
||||
*
|
||||
* @param string|Loggable $detail
|
||||
*/
|
||||
public function add(string $type, $detail = '')
|
||||
{
|
||||
$detailToStore = ($detail instanceof Loggable) ? $detail->logDescriptor() : $detail;
|
||||
|
||||
$activity = $this->newActivityForUser($type);
|
||||
$activity->detail = $detailToStore;
|
||||
|
||||
if ($detail instanceof Entity) {
|
||||
$activity->entity_id = $detail->id;
|
||||
$activity->entity_type = $detail->getMorphClass();
|
||||
}
|
||||
|
||||
$activity->save();
|
||||
$this->setNotification($type);
|
||||
$this->dispatchWebhooks($type, $detail);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a new activity instance for the current user.
|
||||
*/
|
||||
protected function newActivityForUser(string $type): Activity
|
||||
{
|
||||
$ip = request()->ip() ?? '';
|
||||
|
||||
return (new Activity())->forceFill([
|
||||
'type' => strtolower($type),
|
||||
'user_id' => user()->id,
|
||||
'ip' => config('app.env') === 'demo' ? '127.0.0.1' : $ip,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes the entity attachment from each of its activities
|
||||
* and instead uses the 'extra' field with the entities name.
|
||||
* Used when an entity is deleted.
|
||||
*/
|
||||
public function removeEntity(Entity $entity)
|
||||
{
|
||||
$entity->activity()->update([
|
||||
'detail' => $entity->name,
|
||||
'entity_id' => null,
|
||||
'entity_type' => null,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Flashes a notification message to the session if an appropriate message is available.
|
||||
*/
|
||||
protected function setNotification(string $type): void
|
||||
{
|
||||
$notificationTextKey = 'activities.' . $type . '_notification';
|
||||
if (trans()->has($notificationTextKey)) {
|
||||
$message = trans($notificationTextKey);
|
||||
session()->flash('success', $message);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param string|Loggable $detail
|
||||
*/
|
||||
protected function dispatchWebhooks(string $type, $detail): void
|
||||
{
|
||||
$webhooks = Webhook::query()
|
||||
->whereHas('trackedEvents', function(Builder $query) use ($type) {
|
||||
$query->where('event', '=', $type)
|
||||
->orWhere('event', '=', 'all');
|
||||
})
|
||||
->where('active', '=', true)
|
||||
->get();
|
||||
|
||||
foreach ($webhooks as $webhook) {
|
||||
dispatch(new DispatchWebhookJob($webhook, $type, $detail));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Log out a failed login attempt, Providing the given username
|
||||
* as part of the message if the '%u' string is used.
|
||||
*/
|
||||
public function logFailedLogin(string $username)
|
||||
{
|
||||
$message = config('logging.failed_login.message');
|
||||
if (!$message) {
|
||||
return;
|
||||
}
|
||||
|
||||
$message = str_replace('%u', $username, $message);
|
||||
$channel = config('logging.failed_login.channel');
|
||||
Log::channel($channel)->warning($message);
|
||||
}
|
||||
}
|
|
@ -8,84 +8,25 @@ use BookStack\Entities\Models\Book;
|
|||
use BookStack\Entities\Models\Chapter;
|
||||
use BookStack\Entities\Models\Entity;
|
||||
use BookStack\Entities\Models\Page;
|
||||
use BookStack\Interfaces\Loggable;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Database\Eloquent\Relations\Relation;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
class ActivityService
|
||||
class ActivityQueries
|
||||
{
|
||||
protected $activity;
|
||||
protected $permissionService;
|
||||
|
||||
public function __construct(Activity $activity, PermissionService $permissionService)
|
||||
public function __construct(PermissionService $permissionService)
|
||||
{
|
||||
$this->activity = $activity;
|
||||
$this->permissionService = $permissionService;
|
||||
}
|
||||
|
||||
/**
|
||||
* Add activity data to database for an entity.
|
||||
*/
|
||||
public function addForEntity(Entity $entity, string $type)
|
||||
{
|
||||
$activity = $this->newActivityForUser($type);
|
||||
$entity->activity()->save($activity);
|
||||
$this->setNotification($type);
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a generic activity event to the database.
|
||||
*
|
||||
* @param string|Loggable $detail
|
||||
*/
|
||||
public function add(string $type, $detail = '')
|
||||
{
|
||||
if ($detail instanceof Loggable) {
|
||||
$detail = $detail->logDescriptor();
|
||||
}
|
||||
|
||||
$activity = $this->newActivityForUser($type);
|
||||
$activity->detail = $detail;
|
||||
$activity->save();
|
||||
$this->setNotification($type);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a new activity instance for the current user.
|
||||
*/
|
||||
protected function newActivityForUser(string $type): Activity
|
||||
{
|
||||
$ip = request()->ip() ?? '';
|
||||
|
||||
return $this->activity->newInstance()->forceFill([
|
||||
'type' => strtolower($type),
|
||||
'user_id' => user()->id,
|
||||
'ip' => config('app.env') === 'demo' ? '127.0.0.1' : $ip,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes the entity attachment from each of its activities
|
||||
* and instead uses the 'extra' field with the entities name.
|
||||
* Used when an entity is deleted.
|
||||
*/
|
||||
public function removeEntity(Entity $entity)
|
||||
{
|
||||
$entity->activity()->update([
|
||||
'detail' => $entity->name,
|
||||
'entity_id' => null,
|
||||
'entity_type' => null,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the latest activity.
|
||||
*/
|
||||
public function latest(int $count = 20, int $page = 0): array
|
||||
{
|
||||
$activityList = $this->permissionService
|
||||
->filterRestrictedEntityRelations($this->activity->newQuery(), 'activities', 'entity_id', 'entity_type')
|
||||
->filterRestrictedEntityRelations(Activity::query(), 'activities', 'entity_id', 'entity_type')
|
||||
->orderBy('created_at', 'desc')
|
||||
->with(['user', 'entity'])
|
||||
->skip($count * $page)
|
||||
|
@ -111,7 +52,7 @@ class ActivityService
|
|||
$queryIds[(new Page())->getMorphClass()] = $entity->pages()->scopes('visible')->pluck('id');
|
||||
}
|
||||
|
||||
$query = $this->activity->newQuery();
|
||||
$query = Activity::query();
|
||||
$query->where(function (Builder $query) use ($queryIds) {
|
||||
foreach ($queryIds as $morphClass => $idArr) {
|
||||
$query->orWhere(function (Builder $innerQuery) use ($morphClass, $idArr) {
|
||||
|
@ -138,7 +79,7 @@ class ActivityService
|
|||
public function userActivity(User $user, int $count = 20, int $page = 0): array
|
||||
{
|
||||
$activityList = $this->permissionService
|
||||
->filterRestrictedEntityRelations($this->activity->newQuery(), 'activities', 'entity_id', 'entity_type')
|
||||
->filterRestrictedEntityRelations(Activity::query(), 'activities', 'entity_id', 'entity_type')
|
||||
->orderBy('created_at', 'desc')
|
||||
->where('user_id', '=', $user->id)
|
||||
->skip($count * $page)
|
||||
|
@ -152,8 +93,6 @@ class ActivityService
|
|||
* Filters out similar activity.
|
||||
*
|
||||
* @param Activity[] $activities
|
||||
*
|
||||
* @return array
|
||||
*/
|
||||
protected function filterSimilar(iterable $activities): array
|
||||
{
|
||||
|
@ -171,31 +110,4 @@ class ActivityService
|
|||
return $newActivity;
|
||||
}
|
||||
|
||||
/**
|
||||
* Flashes a notification message to the session if an appropriate message is available.
|
||||
*/
|
||||
protected function setNotification(string $type)
|
||||
{
|
||||
$notificationTextKey = 'activities.' . $type . '_notification';
|
||||
if (trans()->has($notificationTextKey)) {
|
||||
$message = trans($notificationTextKey);
|
||||
session()->flash('success', $message);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Log out a failed login attempt, Providing the given username
|
||||
* as part of the message if the '%u' string is used.
|
||||
*/
|
||||
public function logFailedLogin(string $username)
|
||||
{
|
||||
$message = config('logging.failed_login.message');
|
||||
if (!$message) {
|
||||
return;
|
||||
}
|
||||
|
||||
$message = str_replace('%u', $username, $message);
|
||||
$channel = config('logging.failed_login.channel');
|
||||
Log::channel($channel)->warning($message);
|
||||
}
|
||||
}
|
||||
}
|
|
@ -53,4 +53,16 @@ class ActivityType
|
|||
|
||||
const MFA_SETUP_METHOD = 'mfa_setup_method';
|
||||
const MFA_REMOVE_METHOD = 'mfa_remove_method';
|
||||
|
||||
const WEBHOOK_CREATE = 'webhook_create';
|
||||
const WEBHOOK_UPDATE = 'webhook_update';
|
||||
const WEBHOOK_DELETE = 'webhook_delete';
|
||||
|
||||
/**
|
||||
* Get all the possible values.
|
||||
*/
|
||||
public static function all(): array
|
||||
{
|
||||
return (new \ReflectionClass(static::class))->getConstants();
|
||||
}
|
||||
}
|
||||
|
|
|
@ -45,7 +45,7 @@ class CommentRepo
|
|||
$comment->parent_id = $parent_id;
|
||||
|
||||
$entity->comments()->save($comment);
|
||||
ActivityService::addForEntity($entity, ActivityType::COMMENTED_ON);
|
||||
ActivityService::add(ActivityType::COMMENTED_ON, $entity);
|
||||
|
||||
return $comment;
|
||||
}
|
||||
|
|
109
app/Actions/DispatchWebhookJob.php
Normal file
109
app/Actions/DispatchWebhookJob.php
Normal file
|
@ -0,0 +1,109 @@
|
|||
<?php
|
||||
|
||||
namespace BookStack\Actions;
|
||||
|
||||
use BookStack\Auth\User;
|
||||
use BookStack\Entities\Models\Entity;
|
||||
use BookStack\Interfaces\Loggable;
|
||||
use BookStack\Model;
|
||||
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;
|
||||
|
||||
class DispatchWebhookJob implements ShouldQueue
|
||||
{
|
||||
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
|
||||
|
||||
/**
|
||||
* @var Webhook
|
||||
*/
|
||||
protected $webhook;
|
||||
|
||||
/**
|
||||
* @var string
|
||||
*/
|
||||
protected $event;
|
||||
|
||||
/**
|
||||
* @var string|Loggable
|
||||
*/
|
||||
protected $detail;
|
||||
|
||||
/**
|
||||
* @var User
|
||||
*/
|
||||
protected $initiator;
|
||||
|
||||
/**
|
||||
* @var int
|
||||
*/
|
||||
protected $initiatedTime;
|
||||
|
||||
/**
|
||||
* Create a new job instance.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function __construct(Webhook $webhook, string $event, $detail)
|
||||
{
|
||||
$this->webhook = $webhook;
|
||||
$this->event = $event;
|
||||
$this->detail = $detail;
|
||||
$this->initiator = user();
|
||||
$this->initiatedTime = time();
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute the job.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function handle()
|
||||
{
|
||||
$response = Http::asJson()
|
||||
->withOptions(['allow_redirects' => ['strict' => true]])
|
||||
->timeout(3)
|
||||
->post($this->webhook->endpoint, $this->buildWebhookData());
|
||||
|
||||
if ($response->failed()) {
|
||||
Log::error("Webhook call to endpoint {$this->webhook->endpoint} failed with status {$response->status()}");
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
75
app/Actions/Webhook.php
Normal file
75
app/Actions/Webhook.php
Normal file
|
@ -0,0 +1,75 @@
|
|||
<?php
|
||||
|
||||
namespace BookStack\Actions;
|
||||
|
||||
use BookStack\Interfaces\Loggable;
|
||||
use Illuminate\Database\Eloquent\Collection;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
|
||||
/**
|
||||
* @property int $id
|
||||
* @property string $name
|
||||
* @property string $endpoint
|
||||
* @property Collection $trackedEvents
|
||||
* @property bool $active
|
||||
*/
|
||||
class Webhook extends Model implements Loggable
|
||||
{
|
||||
protected $fillable = ['name', 'endpoint'];
|
||||
|
||||
use HasFactory;
|
||||
|
||||
/**
|
||||
* Define the tracked event relation a webhook.
|
||||
*/
|
||||
public function trackedEvents(): HasMany
|
||||
{
|
||||
return $this->hasMany(WebhookTrackedEvent::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the tracked events for a webhook from the given list of event types.
|
||||
*/
|
||||
public function updateTrackedEvents(array $events): void
|
||||
{
|
||||
$this->trackedEvents()->delete();
|
||||
|
||||
$eventsToStore = array_intersect($events, array_values(ActivityType::all()));
|
||||
if (in_array('all', $events)) {
|
||||
$eventsToStore = ['all'];
|
||||
}
|
||||
|
||||
$trackedEvents = [];
|
||||
foreach ($eventsToStore as $event) {
|
||||
$trackedEvents[] = new WebhookTrackedEvent(['event' => $event]);
|
||||
}
|
||||
|
||||
$this->trackedEvents()->saveMany($trackedEvents);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if this webhook tracks the given event.
|
||||
*/
|
||||
public function tracksEvent(string $event): bool
|
||||
{
|
||||
return $this->trackedEvents->pluck('event')->contains($event);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a URL for this webhook within the settings interface.
|
||||
*/
|
||||
public function getUrl(string $path = ''): string
|
||||
{
|
||||
return url('/settings/webhooks/' . $this->id . '/' . ltrim($path, '/'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the string descriptor for this item.
|
||||
*/
|
||||
public function logDescriptor(): string
|
||||
{
|
||||
return "({$this->id}) {$this->name}";
|
||||
}
|
||||
}
|
18
app/Actions/WebhookTrackedEvent.php
Normal file
18
app/Actions/WebhookTrackedEvent.php
Normal file
|
@ -0,0 +1,18 @@
|
|||
<?php
|
||||
|
||||
namespace BookStack\Actions;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
/**
|
||||
* @property int $id
|
||||
* @property int $webhook_id
|
||||
* @property string $event
|
||||
*/
|
||||
class WebhookTrackedEvent extends Model
|
||||
{
|
||||
protected $fillable = ['event'];
|
||||
|
||||
use HasFactory;
|
||||
}
|
|
@ -2,7 +2,6 @@
|
|||
|
||||
namespace BookStack\Auth;
|
||||
|
||||
use Activity;
|
||||
use BookStack\Entities\EntityProvider;
|
||||
use BookStack\Entities\Models\Book;
|
||||
use BookStack\Entities\Models\Bookshelf;
|
||||
|
@ -218,14 +217,6 @@ class UserRepo
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the latest activity for a user.
|
||||
*/
|
||||
public function getActivity(User $user, int $count = 20, int $page = 0): array
|
||||
{
|
||||
return Activity::userActivity($user, $count, $page);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the recently created content for this given user.
|
||||
*/
|
||||
|
|
|
@ -11,7 +11,7 @@
|
|||
return [
|
||||
|
||||
// Default driver to use for the queue
|
||||
// Options: null, sync, redis
|
||||
// Options: sync, database, redis
|
||||
'default' => env('QUEUE_CONNECTION', 'sync'),
|
||||
|
||||
// Queue connection configuration
|
||||
|
|
|
@ -14,6 +14,7 @@ use BookStack\Entities\Tools\SlugGenerator;
|
|||
use BookStack\Facades\Permissions;
|
||||
use BookStack\Interfaces\Deletable;
|
||||
use BookStack\Interfaces\Favouritable;
|
||||
use BookStack\Interfaces\Loggable;
|
||||
use BookStack\Interfaces\Sluggable;
|
||||
use BookStack\Interfaces\Viewable;
|
||||
use BookStack\Model;
|
||||
|
@ -45,7 +46,7 @@ use Illuminate\Database\Eloquent\SoftDeletes;
|
|||
* @method static Builder withLastView()
|
||||
* @method static Builder withViewCount()
|
||||
*/
|
||||
abstract class Entity extends Model implements Sluggable, Favouritable, Viewable, Deletable
|
||||
abstract class Entity extends Model implements Sluggable, Favouritable, Viewable, Deletable, Loggable
|
||||
{
|
||||
use SoftDeletes;
|
||||
use HasCreatorAndUpdater;
|
||||
|
@ -321,4 +322,12 @@ abstract class Entity extends Model implements Sluggable, Favouritable, Viewable
|
|||
->where('user_id', '=', user()->id)
|
||||
->exists();
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
public function logDescriptor(): string
|
||||
{
|
||||
return "({$this->id}) {$this->name}";
|
||||
}
|
||||
}
|
||||
|
|
|
@ -91,7 +91,7 @@ class BookRepo
|
|||
{
|
||||
$book = new Book();
|
||||
$this->baseRepo->create($book, $input);
|
||||
Activity::addForEntity($book, ActivityType::BOOK_CREATE);
|
||||
Activity::add(ActivityType::BOOK_CREATE, $book);
|
||||
|
||||
return $book;
|
||||
}
|
||||
|
@ -102,7 +102,7 @@ class BookRepo
|
|||
public function update(Book $book, array $input): Book
|
||||
{
|
||||
$this->baseRepo->update($book, $input);
|
||||
Activity::addForEntity($book, ActivityType::BOOK_UPDATE);
|
||||
Activity::add(ActivityType::BOOK_UPDATE, $book);
|
||||
|
||||
return $book;
|
||||
}
|
||||
|
@ -127,7 +127,7 @@ class BookRepo
|
|||
{
|
||||
$trashCan = new TrashCan();
|
||||
$trashCan->softDestroyBook($book);
|
||||
Activity::addForEntity($book, ActivityType::BOOK_DELETE);
|
||||
Activity::add(ActivityType::BOOK_DELETE, $book);
|
||||
|
||||
$trashCan->autoClearOld();
|
||||
}
|
||||
|
|
|
@ -90,7 +90,7 @@ class BookshelfRepo
|
|||
$shelf = new Bookshelf();
|
||||
$this->baseRepo->create($shelf, $input);
|
||||
$this->updateBooks($shelf, $bookIds);
|
||||
Activity::addForEntity($shelf, ActivityType::BOOKSHELF_CREATE);
|
||||
Activity::add(ActivityType::BOOKSHELF_CREATE, $shelf);
|
||||
|
||||
return $shelf;
|
||||
}
|
||||
|
@ -106,7 +106,7 @@ class BookshelfRepo
|
|||
$this->updateBooks($shelf, $bookIds);
|
||||
}
|
||||
|
||||
Activity::addForEntity($shelf, ActivityType::BOOKSHELF_UPDATE);
|
||||
Activity::add(ActivityType::BOOKSHELF_UPDATE, $shelf);
|
||||
|
||||
return $shelf;
|
||||
}
|
||||
|
@ -177,7 +177,7 @@ class BookshelfRepo
|
|||
{
|
||||
$trashCan = new TrashCan();
|
||||
$trashCan->softDestroyShelf($shelf);
|
||||
Activity::addForEntity($shelf, ActivityType::BOOKSHELF_DELETE);
|
||||
Activity::add(ActivityType::BOOKSHELF_DELETE, $shelf);
|
||||
$trashCan->autoClearOld();
|
||||
}
|
||||
}
|
||||
|
|
|
@ -49,7 +49,7 @@ class ChapterRepo
|
|||
$chapter->book_id = $parentBook->id;
|
||||
$chapter->priority = (new BookContents($parentBook))->getLastPriority() + 1;
|
||||
$this->baseRepo->create($chapter, $input);
|
||||
Activity::addForEntity($chapter, ActivityType::CHAPTER_CREATE);
|
||||
Activity::add(ActivityType::CHAPTER_CREATE, $chapter);
|
||||
|
||||
return $chapter;
|
||||
}
|
||||
|
@ -60,7 +60,7 @@ class ChapterRepo
|
|||
public function update(Chapter $chapter, array $input): Chapter
|
||||
{
|
||||
$this->baseRepo->update($chapter, $input);
|
||||
Activity::addForEntity($chapter, ActivityType::CHAPTER_UPDATE);
|
||||
Activity::add(ActivityType::CHAPTER_UPDATE, $chapter);
|
||||
|
||||
return $chapter;
|
||||
}
|
||||
|
@ -74,7 +74,7 @@ class ChapterRepo
|
|||
{
|
||||
$trashCan = new TrashCan();
|
||||
$trashCan->softDestroyChapter($chapter);
|
||||
Activity::addForEntity($chapter, ActivityType::CHAPTER_DELETE);
|
||||
Activity::add(ActivityType::CHAPTER_DELETE, $chapter);
|
||||
$trashCan->autoClearOld();
|
||||
}
|
||||
|
||||
|
@ -103,7 +103,7 @@ class ChapterRepo
|
|||
|
||||
$chapter->changeBook($parent->id);
|
||||
$chapter->rebuildPermissions();
|
||||
Activity::addForEntity($chapter, ActivityType::CHAPTER_MOVE);
|
||||
Activity::add(ActivityType::CHAPTER_MOVE, $chapter);
|
||||
|
||||
return $parent;
|
||||
}
|
||||
|
|
|
@ -171,7 +171,7 @@ class PageRepo
|
|||
$draft->indexForSearch();
|
||||
$draft->refresh();
|
||||
|
||||
Activity::addForEntity($draft, ActivityType::PAGE_CREATE);
|
||||
Activity::add(ActivityType::PAGE_CREATE, $draft);
|
||||
|
||||
return $draft;
|
||||
}
|
||||
|
@ -205,7 +205,7 @@ class PageRepo
|
|||
$this->savePageRevision($page, $summary);
|
||||
}
|
||||
|
||||
Activity::addForEntity($page, ActivityType::PAGE_UPDATE);
|
||||
Activity::add(ActivityType::PAGE_UPDATE, $page);
|
||||
|
||||
return $page;
|
||||
}
|
||||
|
@ -281,7 +281,7 @@ class PageRepo
|
|||
{
|
||||
$trashCan = new TrashCan();
|
||||
$trashCan->softDestroyPage($page);
|
||||
Activity::addForEntity($page, ActivityType::PAGE_DELETE);
|
||||
Activity::add(ActivityType::PAGE_DELETE, $page);
|
||||
$trashCan->autoClearOld();
|
||||
}
|
||||
|
||||
|
@ -312,7 +312,7 @@ class PageRepo
|
|||
$summary = trans('entities.pages_revision_restored_from', ['id' => strval($revisionId), 'summary' => $revision->summary]);
|
||||
$this->savePageRevision($page, $summary);
|
||||
|
||||
Activity::addForEntity($page, ActivityType::PAGE_RESTORE);
|
||||
Activity::add(ActivityType::PAGE_RESTORE, $page);
|
||||
|
||||
return $page;
|
||||
}
|
||||
|
@ -341,7 +341,7 @@ class PageRepo
|
|||
$page->changeBook($newBookId);
|
||||
$page->rebuildPermissions();
|
||||
|
||||
Activity::addForEntity($page, ActivityType::PAGE_MOVE);
|
||||
Activity::add(ActivityType::PAGE_MOVE, $page);
|
||||
|
||||
return $parent;
|
||||
}
|
||||
|
|
|
@ -35,7 +35,7 @@ class PermissionsUpdater
|
|||
$entity->save();
|
||||
$entity->rebuildPermissions();
|
||||
|
||||
Activity::addForEntity($entity, ActivityType::PERMISSIONS_UPDATE);
|
||||
Activity::add(ActivityType::PERMISSIONS_UPDATE, $entity);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
@ -2,11 +2,11 @@
|
|||
|
||||
namespace BookStack\Http\Controllers\Auth;
|
||||
|
||||
use Activity;
|
||||
use BookStack\Auth\Access\LoginService;
|
||||
use BookStack\Auth\Access\SocialAuthService;
|
||||
use BookStack\Exceptions\LoginAttemptEmailNeededException;
|
||||
use BookStack\Exceptions\LoginAttemptException;
|
||||
use BookStack\Facades\Activity;
|
||||
use BookStack\Http\Controllers\Controller;
|
||||
use Illuminate\Foundation\Auth\AuthenticatesUsers;
|
||||
use Illuminate\Http\Request;
|
||||
|
|
|
@ -3,6 +3,7 @@
|
|||
namespace BookStack\Http\Controllers;
|
||||
|
||||
use Activity;
|
||||
use BookStack\Actions\ActivityQueries;
|
||||
use BookStack\Actions\ActivityType;
|
||||
use BookStack\Actions\View;
|
||||
use BookStack\Entities\Models\Bookshelf;
|
||||
|
@ -101,7 +102,7 @@ class BookController extends Controller
|
|||
|
||||
if ($bookshelf) {
|
||||
$bookshelf->appendBook($book);
|
||||
Activity::addForEntity($bookshelf, ActivityType::BOOKSHELF_UPDATE);
|
||||
Activity::add(ActivityType::BOOKSHELF_UPDATE, $bookshelf);
|
||||
}
|
||||
|
||||
return redirect($book->getUrl());
|
||||
|
@ -110,7 +111,7 @@ class BookController extends Controller
|
|||
/**
|
||||
* Display the specified book.
|
||||
*/
|
||||
public function show(Request $request, string $slug)
|
||||
public function show(Request $request, ActivityQueries $activities, string $slug)
|
||||
{
|
||||
$book = $this->bookRepo->getBySlug($slug);
|
||||
$bookChildren = (new BookContents($book))->getTree(true);
|
||||
|
@ -128,7 +129,7 @@ class BookController extends Controller
|
|||
'current' => $book,
|
||||
'bookChildren' => $bookChildren,
|
||||
'bookParentShelves' => $bookParentShelves,
|
||||
'activity' => Activity::entityActivity($book, 20, 1),
|
||||
'activity' => $activities->entityActivity($book, 20, 1),
|
||||
]);
|
||||
}
|
||||
|
||||
|
|
|
@ -71,7 +71,7 @@ class BookSortController extends Controller
|
|||
|
||||
// Rebuild permissions and add activity for involved books.
|
||||
$booksInvolved->each(function (Book $book) {
|
||||
Activity::addForEntity($book, ActivityType::BOOK_SORT);
|
||||
Activity::add(ActivityType::BOOK_SORT, $book);
|
||||
});
|
||||
|
||||
return redirect($book->getUrl());
|
||||
|
|
|
@ -3,6 +3,7 @@
|
|||
namespace BookStack\Http\Controllers;
|
||||
|
||||
use Activity;
|
||||
use BookStack\Actions\ActivityQueries;
|
||||
use BookStack\Actions\View;
|
||||
use BookStack\Entities\Models\Book;
|
||||
use BookStack\Entities\Repos\BookshelfRepo;
|
||||
|
@ -101,7 +102,7 @@ class BookshelfController extends Controller
|
|||
*
|
||||
* @throws NotFoundException
|
||||
*/
|
||||
public function show(string $slug)
|
||||
public function show(ActivityQueries $activities, string $slug)
|
||||
{
|
||||
$shelf = $this->bookshelfRepo->getBySlug($slug);
|
||||
$this->checkOwnablePermission('book-view', $shelf);
|
||||
|
@ -124,7 +125,7 @@ class BookshelfController extends Controller
|
|||
'shelf' => $shelf,
|
||||
'sortedVisibleShelfBooks' => $sortedVisibleShelfBooks,
|
||||
'view' => $view,
|
||||
'activity' => Activity::entityActivity($shelf, 20, 1),
|
||||
'activity' => $activities->entityActivity($shelf, 20, 1),
|
||||
'order' => $order,
|
||||
'sort' => $sort,
|
||||
]);
|
||||
|
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
namespace BookStack\Http\Controllers;
|
||||
|
||||
use Activity;
|
||||
use BookStack\Actions\ActivityQueries;
|
||||
use BookStack\Entities\Models\Book;
|
||||
use BookStack\Entities\Models\Page;
|
||||
use BookStack\Entities\Queries\RecentlyViewed;
|
||||
|
@ -16,9 +16,9 @@ class HomeController extends Controller
|
|||
/**
|
||||
* Display the homepage.
|
||||
*/
|
||||
public function index()
|
||||
public function index(ActivityQueries $activities)
|
||||
{
|
||||
$activity = Activity::latest(10);
|
||||
$activity = $activities->latest(10);
|
||||
$draftPages = [];
|
||||
|
||||
if ($this->isSignedIn()) {
|
||||
|
|
|
@ -67,7 +67,7 @@ class MaintenanceController extends Controller
|
|||
$this->logActivity(ActivityType::MAINTENANCE_ACTION_RUN, 'send-test-email');
|
||||
|
||||
try {
|
||||
user()->notify(new TestEmail());
|
||||
user()->notifyNow(new TestEmail());
|
||||
$this->showSuccessNotification(trans('settings.maint_send_test_email_success', ['address' => user()->email]));
|
||||
} catch (\Exception $exception) {
|
||||
$errorMessage = trans('errors.maintenance_test_email_failure') . "\n" . $exception->getMessage();
|
||||
|
|
|
@ -23,7 +23,7 @@ class RoleController extends Controller
|
|||
/**
|
||||
* Show a listing of the roles in the system.
|
||||
*/
|
||||
public function list()
|
||||
public function index()
|
||||
{
|
||||
$this->checkPermission('user-roles-manage');
|
||||
$roles = $this->permissionsRepo->getAllRoles();
|
||||
|
|
|
@ -2,6 +2,7 @@
|
|||
|
||||
namespace BookStack\Http\Controllers;
|
||||
|
||||
use BookStack\Actions\ActivityQueries;
|
||||
use BookStack\Auth\UserRepo;
|
||||
|
||||
class UserProfileController extends Controller
|
||||
|
@ -9,11 +10,11 @@ class UserProfileController extends Controller
|
|||
/**
|
||||
* Show the user profile page.
|
||||
*/
|
||||
public function show(UserRepo $repo, string $slug)
|
||||
public function show(UserRepo $repo, ActivityQueries $activities, string $slug)
|
||||
{
|
||||
$user = $repo->getBySlug($slug);
|
||||
|
||||
$userActivity = $repo->getActivity($user);
|
||||
$userActivity = $activities->userActivity($user);
|
||||
$recentlyCreated = $repo->getRecentlyCreated($user, 5);
|
||||
$assetCounts = $repo->getAssetCounts($user);
|
||||
|
||||
|
|
119
app/Http/Controllers/WebhookController.php
Normal file
119
app/Http/Controllers/WebhookController.php
Normal file
|
@ -0,0 +1,119 @@
|
|||
<?php
|
||||
|
||||
namespace BookStack\Http\Controllers;
|
||||
|
||||
use BookStack\Actions\ActivityType;
|
||||
use BookStack\Actions\Webhook;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class WebhookController extends Controller
|
||||
{
|
||||
public function __construct()
|
||||
{
|
||||
$this->middleware([
|
||||
'can:settings-manage',
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Show all webhooks configured in the system.
|
||||
*/
|
||||
public function index()
|
||||
{
|
||||
$webhooks = Webhook::query()
|
||||
->orderBy('name', 'desc')
|
||||
->with('trackedEvents')
|
||||
->get();
|
||||
return view('settings.webhooks.index', ['webhooks' => $webhooks]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Show the view for creating a new webhook in the system.
|
||||
*/
|
||||
public function create()
|
||||
{
|
||||
return view('settings.webhooks.create');
|
||||
}
|
||||
|
||||
/**
|
||||
* Store a new webhook in the system.
|
||||
*/
|
||||
public function store(Request $request)
|
||||
{
|
||||
$validated = $this->validate($request, [
|
||||
'name' => ['required', 'max:150'],
|
||||
'endpoint' => ['required', 'url', 'max:500'],
|
||||
'events' => ['required', 'array'],
|
||||
'active' => ['required'],
|
||||
]);
|
||||
|
||||
$webhook = new Webhook($validated);
|
||||
$webhook->active = $validated['active'] === 'true';
|
||||
$webhook->save();
|
||||
$webhook->updateTrackedEvents(array_values($validated['events']));
|
||||
|
||||
$this->logActivity(ActivityType::WEBHOOK_CREATE, $webhook);
|
||||
return redirect('/settings/webhooks');
|
||||
}
|
||||
|
||||
/**
|
||||
* Show the view to edit an existing webhook.
|
||||
*/
|
||||
public function edit(string $id)
|
||||
{
|
||||
/** @var Webhook $webhook */
|
||||
$webhook = Webhook::query()
|
||||
->with('trackedEvents')
|
||||
->findOrFail($id);
|
||||
|
||||
return view('settings.webhooks.edit', ['webhook' => $webhook]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update an existing webhook with the provided request data.
|
||||
*/
|
||||
public function update(Request $request, string $id)
|
||||
{
|
||||
$validated = $this->validate($request, [
|
||||
'name' => ['required', 'max:150'],
|
||||
'endpoint' => ['required', 'url', 'max:500'],
|
||||
'events' => ['required', 'array'],
|
||||
'active' => ['required'],
|
||||
]);
|
||||
|
||||
/** @var Webhook $webhook */
|
||||
$webhook = Webhook::query()->findOrFail($id);
|
||||
|
||||
$webhook->active = $validated['active'] === 'true';
|
||||
$webhook->fill($validated)->save();
|
||||
$webhook->updateTrackedEvents($validated['events']);
|
||||
|
||||
$this->logActivity(ActivityType::WEBHOOK_UPDATE, $webhook);
|
||||
return redirect('/settings/webhooks');
|
||||
}
|
||||
|
||||
/**
|
||||
* Show the view to delete a webhook.
|
||||
*/
|
||||
public function delete(string $id)
|
||||
{
|
||||
/** @var Webhook $webhook */
|
||||
$webhook = Webhook::query()->findOrFail($id);
|
||||
return view('settings.webhooks.delete', ['webhook' => $webhook]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Destroy a webhook from the system.
|
||||
*/
|
||||
public function destroy(string $id)
|
||||
{
|
||||
/** @var Webhook $webhook */
|
||||
$webhook = Webhook::query()->findOrFail($id);
|
||||
|
||||
$webhook->trackedEvents()->delete();
|
||||
$webhook->delete();
|
||||
|
||||
$this->logActivity(ActivityType::WEBHOOK_DELETE, $webhook);
|
||||
return redirect('/settings/webhooks');
|
||||
}
|
||||
}
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
namespace BookStack\Providers;
|
||||
|
||||
use BookStack\Actions\ActivityService;
|
||||
use BookStack\Actions\ActivityLogger;
|
||||
use BookStack\Auth\Permissions\PermissionService;
|
||||
use BookStack\Theming\ThemeService;
|
||||
use BookStack\Uploads\ImageService;
|
||||
|
@ -28,7 +28,7 @@ class CustomFacadeProvider extends ServiceProvider
|
|||
public function register()
|
||||
{
|
||||
$this->app->singleton('activity', function () {
|
||||
return $this->app->make(ActivityService::class);
|
||||
return $this->app->make(ActivityLogger::class);
|
||||
});
|
||||
|
||||
$this->app->singleton('images', function () {
|
||||
|
|
26
database/factories/Actions/WebhookFactory.php
Normal file
26
database/factories/Actions/WebhookFactory.php
Normal file
|
@ -0,0 +1,26 @@
|
|||
<?php
|
||||
|
||||
namespace Database\Factories\Actions;
|
||||
|
||||
use BookStack\Actions\Webhook;
|
||||
use Illuminate\Database\Eloquent\Factories\Factory;
|
||||
|
||||
class WebhookFactory extends Factory
|
||||
{
|
||||
|
||||
protected $model = Webhook::class;
|
||||
|
||||
/**
|
||||
* Define the model's default state.
|
||||
*
|
||||
* @return array
|
||||
*/
|
||||
public function definition()
|
||||
{
|
||||
return [
|
||||
'name' => 'My webhook for ' . $this->faker->country(),
|
||||
'endpoint' => $this->faker->url,
|
||||
'active' => true,
|
||||
];
|
||||
}
|
||||
}
|
23
database/factories/Actions/WebhookTrackedEventFactory.php
Normal file
23
database/factories/Actions/WebhookTrackedEventFactory.php
Normal file
|
@ -0,0 +1,23 @@
|
|||
<?php
|
||||
|
||||
namespace Database\Factories\Actions;
|
||||
|
||||
use BookStack\Actions\ActivityType;
|
||||
use BookStack\Actions\Webhook;
|
||||
use Illuminate\Database\Eloquent\Factories\Factory;
|
||||
|
||||
class WebhookTrackedEventFactory extends Factory
|
||||
{
|
||||
/**
|
||||
* Define the model's default state.
|
||||
*
|
||||
* @return array
|
||||
*/
|
||||
public function definition()
|
||||
{
|
||||
return [
|
||||
'webhook_id' => Webhook::factory(),
|
||||
'event' => ActivityType::all()[array_rand(ActivityType::all())],
|
||||
];
|
||||
}
|
||||
}
|
|
@ -0,0 +1,48 @@
|
|||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
class CreateWebhooksTable extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function up()
|
||||
{
|
||||
Schema::create('webhooks', function (Blueprint $table) {
|
||||
$table->increments('id');
|
||||
$table->string('name', 150);
|
||||
$table->boolean('active');
|
||||
$table->string('endpoint', 500);
|
||||
$table->timestamps();
|
||||
|
||||
$table->index('name');
|
||||
$table->index('active');
|
||||
});
|
||||
|
||||
Schema::create('webhook_tracked_events', function (Blueprint $table) {
|
||||
$table->increments('id');
|
||||
$table->integer('webhook_id');
|
||||
$table->string('event', 50);
|
||||
$table->timestamps();
|
||||
|
||||
$table->index('event');
|
||||
$table->index('webhook_id');
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function down()
|
||||
{
|
||||
Schema::dropIfExists('webhooks');
|
||||
Schema::dropIfExists('webhook_tracked_events');
|
||||
}
|
||||
}
|
36
database/migrations/2021_12_13_152024_create_jobs_table.php
Normal file
36
database/migrations/2021_12_13_152024_create_jobs_table.php
Normal file
|
@ -0,0 +1,36 @@
|
|||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
class CreateJobsTable extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function up()
|
||||
{
|
||||
Schema::create('jobs', function (Blueprint $table) {
|
||||
$table->bigIncrements('id');
|
||||
$table->string('queue')->index();
|
||||
$table->longText('payload');
|
||||
$table->unsignedTinyInteger('attempts');
|
||||
$table->unsignedInteger('reserved_at')->nullable();
|
||||
$table->unsignedInteger('available_at');
|
||||
$table->unsignedInteger('created_at');
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function down()
|
||||
{
|
||||
Schema::dropIfExists('jobs');
|
||||
}
|
||||
}
|
|
@ -0,0 +1,36 @@
|
|||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
class CreateFailedJobsTable extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function up()
|
||||
{
|
||||
Schema::create('failed_jobs', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->string('uuid')->unique();
|
||||
$table->text('connection');
|
||||
$table->text('queue');
|
||||
$table->longText('payload');
|
||||
$table->longText('exception');
|
||||
$table->timestamp('failed_at')->useCurrent();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function down()
|
||||
{
|
||||
Schema::dropIfExists('failed_jobs');
|
||||
}
|
||||
}
|
1
resources/icons/webhooks.svg
Normal file
1
resources/icons/webhooks.svg
Normal file
|
@ -0,0 +1 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M10,15l5.88,0c0.27-0.31,0.67-0.5,1.12-0.5c0.83,0,1.5,0.67,1.5,1.5c0,0.83-0.67,1.5-1.5,1.5c-0.44,0-0.84-0.19-1.12-0.5 l-3.98,0c-0.46,2.28-2.48,4-4.9,4c-2.76,0-5-2.24-5-5c0-2.42,1.72-4.44,4-4.9l0,2.07C4.84,13.58,4,14.7,4,16c0,1.65,1.35,3,3,3 s3-1.35,3-3V15z M12.5,4c1.65,0,3,1.35,3,3h2c0-2.76-2.24-5-5-5l0,0c-2.76,0-5,2.24-5,5c0,1.43,0.6,2.71,1.55,3.62l-2.35,3.9 C6.02,14.66,5.5,15.27,5.5,16c0,0.83,0.67,1.5,1.5,1.5s1.5-0.67,1.5-1.5c0-0.16-0.02-0.31-0.07-0.45l3.38-5.63 C10.49,9.61,9.5,8.42,9.5,7C9.5,5.35,10.85,4,12.5,4z M17,13c-0.64,0-1.23,0.2-1.72,0.54l-3.05-5.07C11.53,8.35,11,7.74,11,7 c0-0.83,0.67-1.5,1.5-1.5S14,6.17,14,7c0,0.15-0.02,0.29-0.06,0.43l2.19,3.65C16.41,11.03,16.7,11,17,11l0,0c2.76,0,5,2.24,5,5 c0,2.76-2.24,5-5,5c-1.85,0-3.47-1.01-4.33-2.5l2.67,0C15.82,18.82,16.39,19,17,19c1.65,0,3-1.35,3-3S18.65,13,17,13z"/></svg>
|
After Width: | Height: | Size: 903 B |
|
@ -50,6 +50,7 @@ import templateManager from "./template-manager.js"
|
|||
import toggleSwitch from "./toggle-switch.js"
|
||||
import triLayout from "./tri-layout.js"
|
||||
import userSelect from "./user-select.js"
|
||||
import webhookEvents from "./webhook-events";
|
||||
import wysiwygEditor from "./wysiwyg-editor.js"
|
||||
|
||||
const componentMapping = {
|
||||
|
@ -105,6 +106,7 @@ const componentMapping = {
|
|||
"toggle-switch": toggleSwitch,
|
||||
"tri-layout": triLayout,
|
||||
"user-select": userSelect,
|
||||
"webhook-events": webhookEvents,
|
||||
"wysiwyg-editor": wysiwygEditor,
|
||||
};
|
||||
|
||||
|
|
32
resources/js/components/webhook-events.js
Normal file
32
resources/js/components/webhook-events.js
Normal file
|
@ -0,0 +1,32 @@
|
|||
|
||||
/**
|
||||
* Webhook Events
|
||||
* Manages dynamic selection control in the webhook form interface.
|
||||
* @extends {Component}
|
||||
*/
|
||||
class WebhookEvents {
|
||||
|
||||
setup() {
|
||||
this.checkboxes = this.$el.querySelectorAll('input[type="checkbox"]');
|
||||
this.allCheckbox = this.$el.querySelector('input[type="checkbox"][value="all"]');
|
||||
|
||||
this.$el.addEventListener('change', event => {
|
||||
if (event.target.checked && event.target === this.allCheckbox) {
|
||||
this.deselectIndividualEvents();
|
||||
} else if (event.target.checked) {
|
||||
this.allCheckbox.checked = false;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
deselectIndividualEvents() {
|
||||
for (const checkbox of this.checkboxes) {
|
||||
if (checkbox !== this.allCheckbox) {
|
||||
checkbox.checked = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
export default WebhookEvents;
|
|
@ -7,41 +7,41 @@ return [
|
|||
|
||||
// Pages
|
||||
'page_create' => 'created page',
|
||||
'page_create_notification' => 'Page Successfully Created',
|
||||
'page_create_notification' => 'Page successfully created',
|
||||
'page_update' => 'updated page',
|
||||
'page_update_notification' => 'Page Successfully Updated',
|
||||
'page_update_notification' => 'Page successfully updated',
|
||||
'page_delete' => 'deleted page',
|
||||
'page_delete_notification' => 'Page Successfully Deleted',
|
||||
'page_delete_notification' => 'Page successfully deleted',
|
||||
'page_restore' => 'restored page',
|
||||
'page_restore_notification' => 'Page Successfully Restored',
|
||||
'page_restore_notification' => 'Page successfully restored',
|
||||
'page_move' => 'moved page',
|
||||
|
||||
// Chapters
|
||||
'chapter_create' => 'created chapter',
|
||||
'chapter_create_notification' => 'Chapter Successfully Created',
|
||||
'chapter_create_notification' => 'Chapter successfully created',
|
||||
'chapter_update' => 'updated chapter',
|
||||
'chapter_update_notification' => 'Chapter Successfully Updated',
|
||||
'chapter_update_notification' => 'Chapter successfully updated',
|
||||
'chapter_delete' => 'deleted chapter',
|
||||
'chapter_delete_notification' => 'Chapter Successfully Deleted',
|
||||
'chapter_delete_notification' => 'Chapter successfully deleted',
|
||||
'chapter_move' => 'moved chapter',
|
||||
|
||||
// Books
|
||||
'book_create' => 'created book',
|
||||
'book_create_notification' => 'Book Successfully Created',
|
||||
'book_create_notification' => 'Book successfully created',
|
||||
'book_update' => 'updated book',
|
||||
'book_update_notification' => 'Book Successfully Updated',
|
||||
'book_update_notification' => 'Book successfully updated',
|
||||
'book_delete' => 'deleted book',
|
||||
'book_delete_notification' => 'Book Successfully Deleted',
|
||||
'book_delete_notification' => 'Book successfully deleted',
|
||||
'book_sort' => 'sorted book',
|
||||
'book_sort_notification' => 'Book Successfully Re-sorted',
|
||||
'book_sort_notification' => 'Book successfully re-sorted',
|
||||
|
||||
// Bookshelves
|
||||
'bookshelf_create' => 'created Bookshelf',
|
||||
'bookshelf_create_notification' => 'Bookshelf Successfully Created',
|
||||
'bookshelf_create' => 'created bookshelf',
|
||||
'bookshelf_create_notification' => 'Bookshelf successfully created',
|
||||
'bookshelf_update' => 'updated bookshelf',
|
||||
'bookshelf_update_notification' => 'Bookshelf Successfully Updated',
|
||||
'bookshelf_update_notification' => 'Bookshelf successfully updated',
|
||||
'bookshelf_delete' => 'deleted bookshelf',
|
||||
'bookshelf_delete_notification' => 'Bookshelf Successfully Deleted',
|
||||
'bookshelf_delete_notification' => 'Bookshelf successfully deleted',
|
||||
|
||||
// Favourites
|
||||
'favourite_add_notification' => '":name" has been added to your favourites',
|
||||
|
@ -51,6 +51,14 @@ return [
|
|||
'mfa_setup_method_notification' => 'Multi-factor method successfully configured',
|
||||
'mfa_remove_method_notification' => 'Multi-factor method successfully removed',
|
||||
|
||||
// Webhooks
|
||||
'webhook_create' => 'created webhook',
|
||||
'webhook_create_notification' => 'Webhook successfully created',
|
||||
'webhook_update' => 'updated webhook',
|
||||
'webhook_update_notification' => 'Webhook successfully updated',
|
||||
'webhook_delete' => 'deleted webhook',
|
||||
'webhook_delete_notification' => 'Webhook successfully deleted',
|
||||
|
||||
// Other
|
||||
'commented_on' => 'commented on',
|
||||
'permissions_update' => 'updated permissions',
|
||||
|
|
|
@ -71,6 +71,9 @@ return [
|
|||
'list_view' => 'List View',
|
||||
'default' => 'Default',
|
||||
'breadcrumb' => 'Breadcrumb',
|
||||
'status' => 'Status',
|
||||
'status_active' => 'Active',
|
||||
'status_inactive' => 'Inactive',
|
||||
|
||||
// Header
|
||||
'header_menu_expand' => 'Expand Header Menu',
|
||||
|
|
|
@ -233,6 +233,28 @@ return [
|
|||
'user_api_token_delete_confirm' => 'Are you sure you want to delete this API token?',
|
||||
'user_api_token_delete_success' => 'API token successfully deleted',
|
||||
|
||||
// Webhooks
|
||||
'webhooks' => 'Webhooks',
|
||||
'webhooks_create' => 'Create New Webhook',
|
||||
'webhooks_none_created' => 'No webhooks have yet been created.',
|
||||
'webhooks_edit' => 'Edit Webhook',
|
||||
'webhooks_save' => 'Save Webhook',
|
||||
'webhooks_details' => 'Webhook Details',
|
||||
'webhooks_details_desc' => 'Provide a user friendly name and a POST endpoint as a location for the webhook data to be sent to.',
|
||||
'webhooks_events' => 'Webhook Events',
|
||||
'webhooks_events_desc' => 'Select all the events that should trigger this webhook to be called.',
|
||||
'webhooks_events_warning' => 'Keep in mind that these events will be triggered for all selected events, even if custom permissions are applied. Ensure that use of this webhook won\'t expose confidential content.',
|
||||
'webhooks_events_all' => 'All system events',
|
||||
'webhooks_name' => 'Webhook Name',
|
||||
'webhooks_endpoint' => 'Webhook Endpoint',
|
||||
'webhooks_active' => 'Webhook Active',
|
||||
'webhook_events_table_header' => 'Events',
|
||||
'webhooks_delete' => 'Delete Webhook',
|
||||
'webhooks_delete_warning' => 'This will fully delete this webhook, with the name \':webhookName\', from the system.',
|
||||
'webhooks_delete_confirm' => 'Are you sure you want to delete this webhook?',
|
||||
'webhooks_format_example' => 'Webhook Format Example',
|
||||
'webhooks_format_example_desc' => 'Webhook data is sent as a POST request to the configured endpoint as JSON following the format below. The "related_item" and "url" properties are optional and will depend on the type of event triggered.',
|
||||
|
||||
//! If editing translations files directly please ignore this in all
|
||||
//! languages apart from en. Content will be auto-copied from en.
|
||||
//!////////////////////////////////
|
||||
|
|
|
@ -1,5 +1,27 @@
|
|||
<h1 class="list-heading text-capitals mb-l">Getting Started</h1>
|
||||
|
||||
<p class="mb-none">
|
||||
This documentation covers use of the REST API. <br>
|
||||
Some alternative options for extension and customization can be found below:
|
||||
</p>
|
||||
|
||||
<ul>
|
||||
<li>
|
||||
<a href="{{ url('/settings/webhooks') }}" target="_blank" rel="noopener noreferrer">Webhooks</a> -
|
||||
HTTP POST calls upon events occurring in BookStack.
|
||||
</li>
|
||||
<li>
|
||||
<a href="https://github.com/BookStackApp/BookStack/blob/master/dev/docs/visual-theme-system.md" target="_blank" rel="noopener noreferrer">Visual Theme System</a> -
|
||||
Methods to override views, translations and icons within BookStack.
|
||||
</li>
|
||||
<li>
|
||||
<a href="https://github.com/BookStackApp/BookStack/blob/master/dev/docs/logical-theme-system.md" target="_blank" rel="noopener noreferrer">Logical Theme System</a> -
|
||||
Methods to extend back-end functionality within BookStack.
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<hr>
|
||||
|
||||
<h5 id="authentication" class="text-mono mb-m">Authentication</h5>
|
||||
<p>
|
||||
To access the API a user has to have the <em>"Access System API"</em> permission enabled on one of their assigned roles.
|
||||
|
|
|
@ -24,8 +24,6 @@
|
|||
"{{ $activity->entity->name }}"
|
||||
@endif
|
||||
|
||||
@if($activity->extra) "{{ $activity->extra }}" @endif
|
||||
|
||||
<br>
|
||||
|
||||
<span class="text-muted"><small>@icon('time'){{ $activity->created_at->diffForHumans() }}</small></span>
|
||||
|
|
3
resources/views/form/errors.blade.php
Normal file
3
resources/views/form/errors.blade.php
Normal file
|
@ -0,0 +1,3 @@
|
|||
@if($errors->has($name))
|
||||
<div class="text-neg text-small">{{ $errors->first($name) }}</div>
|
||||
@endif
|
|
@ -10,7 +10,7 @@
|
|||
</div>
|
||||
|
||||
<div class="card content-wrap auto-height">
|
||||
<h2 class="list-heading">{{ trans('settings.audit') }}</h2>
|
||||
<h1 class="list-heading">{{ trans('settings.audit') }}</h1>
|
||||
<p class="text-muted">{{ trans('settings.audit_desc') }}</p>
|
||||
|
||||
<div class="flex-container-row">
|
||||
|
|
|
@ -6,10 +6,12 @@ $version - Version of bookstack to display
|
|||
<div class="py-m flex fit-content">
|
||||
@include('settings.parts.navbar', ['selected' => $selected])
|
||||
</div>
|
||||
<div class="flex"></div>
|
||||
<div class="text-right p-m flex fit-content">
|
||||
<a target="_blank" rel="noopener noreferrer" href="https://github.com/BookStackApp/BookStack/releases">
|
||||
BookStack @if(strpos($version, 'v') !== 0) version @endif {{ $version }}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="px-s">
|
||||
<hr class="darker m-none">
|
||||
</div>
|
||||
<div class="py-l px-m flex fit-content">
|
||||
<a target="_blank" rel="noopener noreferrer" href="https://github.com/BookStackApp/BookStack/releases">
|
||||
BookStack @if(strpos($version, 'v') !== 0) version @endif {{ $version }}
|
||||
</a>
|
||||
</div>
|
|
@ -13,4 +13,7 @@
|
|||
@if(userCan('user-roles-manage'))
|
||||
<a href="{{ url('/settings/roles') }}" @if($selected == 'roles') class="active" @endif>@icon('lock-open'){{ trans('settings.roles') }}</a>
|
||||
@endif
|
||||
@if(userCan('settings-manage'))
|
||||
<a href="{{ url('/settings/webhooks') }}" @if($selected == 'webhooks') class="active" @endif>@icon('webhooks'){{ trans('settings.webhooks') }}</a>
|
||||
@endif
|
||||
</nav>
|
18
resources/views/settings/webhooks/create.blade.php
Normal file
18
resources/views/settings/webhooks/create.blade.php
Normal file
|
@ -0,0 +1,18 @@
|
|||
@extends('layouts.simple')
|
||||
|
||||
@section('body')
|
||||
|
||||
<div class="container small">
|
||||
|
||||
<div class="py-m">
|
||||
@include('settings.parts.navbar', ['selected' => 'webhooks'])
|
||||
</div>
|
||||
|
||||
<form action="{{ url("/settings/webhooks/create") }}" method="POST">
|
||||
@include('settings.webhooks.parts.form', ['title' => trans('settings.webhooks_create')])
|
||||
</form>
|
||||
|
||||
@include('settings.webhooks.parts.format-example')
|
||||
</div>
|
||||
|
||||
@stop
|
39
resources/views/settings/webhooks/delete.blade.php
Normal file
39
resources/views/settings/webhooks/delete.blade.php
Normal file
|
@ -0,0 +1,39 @@
|
|||
@extends('layouts.simple')
|
||||
|
||||
@section('body')
|
||||
<div class="container small">
|
||||
|
||||
<div class="py-m">
|
||||
@include('settings.parts.navbar', ['selected' => 'webhooks'])
|
||||
</div>
|
||||
|
||||
<div class="card content-wrap auto-height">
|
||||
<h1 class="list-heading"> {{ trans('settings.webhooks_delete') }}</h1>
|
||||
|
||||
<p>{{ trans('settings.webhooks_delete_warning', ['webhookName' => $webhook->name]) }}</p>
|
||||
|
||||
|
||||
<form action="{{ $webhook->getUrl() }}" method="POST">
|
||||
{!! csrf_field() !!}
|
||||
{!! method_field('DELETE') !!}
|
||||
|
||||
<div class="grid half v-center">
|
||||
<div>
|
||||
<p class="text-neg">
|
||||
<strong>{{ trans('settings.webhooks_delete_confirm') }}</strong>
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<div class="form-group text-right">
|
||||
<a href="{{ $webhook->getUrl() }}" class="button outline">{{ trans('common.cancel') }}</a>
|
||||
<button type="submit" class="button">{{ trans('common.confirm') }}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
</form>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
@stop
|
18
resources/views/settings/webhooks/edit.blade.php
Normal file
18
resources/views/settings/webhooks/edit.blade.php
Normal file
|
@ -0,0 +1,18 @@
|
|||
@extends('layouts.simple')
|
||||
|
||||
@section('body')
|
||||
|
||||
<div class="container small">
|
||||
<div class="py-m">
|
||||
@include('settings.parts.navbar', ['selected' => 'webhooks'])
|
||||
</div>
|
||||
|
||||
<form action="{{ $webhook->getUrl() }}" method="POST">
|
||||
{!! method_field('PUT') !!}
|
||||
@include('settings.webhooks.parts.form', ['model' => $webhook, 'title' => trans('settings.webhooks_edit')])
|
||||
</form>
|
||||
|
||||
@include('settings.webhooks.parts.format-example')
|
||||
</div>
|
||||
|
||||
@stop
|
59
resources/views/settings/webhooks/index.blade.php
Normal file
59
resources/views/settings/webhooks/index.blade.php
Normal file
|
@ -0,0 +1,59 @@
|
|||
@extends('layouts.simple')
|
||||
|
||||
@section('body')
|
||||
|
||||
<div class="container small">
|
||||
|
||||
<div class="py-m">
|
||||
@include('settings.parts.navbar', ['selected' => 'webhooks'])
|
||||
</div>
|
||||
|
||||
<div class="card content-wrap auto-height">
|
||||
|
||||
<div class="grid half v-center">
|
||||
<h1 class="list-heading">{{ trans('settings.webhooks') }}</h1>
|
||||
|
||||
<div class="text-right">
|
||||
<a href="{{ url("/settings/webhooks/create") }}"
|
||||
class="button outline">{{ trans('settings.webhooks_create') }}</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if(count($webhooks) > 0)
|
||||
|
||||
<table class="table">
|
||||
<tr>
|
||||
<th>{{ trans('common.name') }}</th>
|
||||
<th>{{ trans('settings.webhook_events_table_header') }}</th>
|
||||
<th>{{ trans('common.status') }}</th>
|
||||
</tr>
|
||||
@foreach($webhooks as $webhook)
|
||||
<tr>
|
||||
<td>
|
||||
<a href="{{ $webhook->getUrl() }}">{{ $webhook->name }}</a> <br>
|
||||
<span class="small text-muted italic">{{ $webhook->endpoint }}</span>
|
||||
</td>
|
||||
<td>
|
||||
@if($webhook->tracksEvent('all'))
|
||||
{{ trans('settings.webhooks_events_all') }}
|
||||
@else
|
||||
{{ $webhook->trackedEvents->count() }}
|
||||
@endif
|
||||
</td>
|
||||
<td>
|
||||
{{ trans('common.status_' . ($webhook->active ? 'active' : 'inactive')) }}
|
||||
</td>
|
||||
</tr>
|
||||
@endforeach
|
||||
</table>
|
||||
@else
|
||||
<p class="text-muted empty-text px-none">
|
||||
{{ trans('settings.webhooks_none_created') }}
|
||||
</p>
|
||||
@endif
|
||||
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@stop
|
75
resources/views/settings/webhooks/parts/form.blade.php
Normal file
75
resources/views/settings/webhooks/parts/form.blade.php
Normal file
|
@ -0,0 +1,75 @@
|
|||
{!! csrf_field() !!}
|
||||
|
||||
<div class="card content-wrap auto-height">
|
||||
<h1 class="list-heading">{{ $title }}</h1>
|
||||
|
||||
<div class="setting-list">
|
||||
|
||||
<div class="grid half">
|
||||
<div>
|
||||
<label class="setting-list-label">{{ trans('settings.webhooks_details') }}</label>
|
||||
<p class="small">{{ trans('settings.webhooks_details_desc') }}</p>
|
||||
<div>
|
||||
@include('form.toggle-switch', [
|
||||
'name' => 'active',
|
||||
'value' => old('active') ?? $model->active ?? true,
|
||||
'label' => trans('settings.webhooks_active'),
|
||||
])
|
||||
@include('form.errors', ['name' => 'active'])
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="form-group">
|
||||
<label for="name">{{ trans('settings.webhooks_name') }}</label>
|
||||
@include('form.text', ['name' => 'name'])
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="endpoint">{{ trans('settings.webhooks_endpoint') }}</label>
|
||||
@include('form.text', ['name' => 'endpoint'])
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div component="webhook-events">
|
||||
<label class="setting-list-label">{{ trans('settings.webhooks_events') }}</label>
|
||||
@include('form.errors', ['name' => 'events'])
|
||||
|
||||
<p class="small">{{ trans('settings.webhooks_events_desc') }}</p>
|
||||
<p class="text-warn small">{{ trans('settings.webhooks_events_warning') }}</p>
|
||||
|
||||
<div class="toggle-switch-list">
|
||||
@include('form.custom-checkbox', [
|
||||
'name' => 'events[]',
|
||||
'value' => 'all',
|
||||
'label' => trans('settings.webhooks_events_all'),
|
||||
'checked' => old('events') ? in_array('all', old('events')) : (isset($webhook) ? $webhook->tracksEvent('all') : false),
|
||||
])
|
||||
</div>
|
||||
|
||||
<hr class="my-s">
|
||||
|
||||
<div class="dual-column-content toggle-switch-list">
|
||||
@foreach(\BookStack\Actions\ActivityType::all() as $activityType)
|
||||
<div>
|
||||
@include('form.custom-checkbox', [
|
||||
'name' => 'events[]',
|
||||
'value' => $activityType,
|
||||
'label' => $activityType,
|
||||
'checked' => old('events') ? in_array($activityType, old('events')) : (isset($webhook) ? $webhook->tracksEvent($activityType) : false),
|
||||
])
|
||||
</div>
|
||||
@endforeach
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<div class="form-group text-right">
|
||||
<a href="{{ url("/settings/webhooks") }}" class="button outline">{{ trans('common.cancel') }}</a>
|
||||
@if ($webhook->id ?? false)
|
||||
<a href="{{ $webhook->getUrl('/delete') }}" class="button outline">{{ trans('settings.webhooks_delete') }}</a>
|
||||
@endif
|
||||
<button type="submit" class="button">{{ trans('settings.webhooks_save') }}</button>
|
||||
</div>
|
||||
|
||||
</div>
|
|
@ -0,0 +1,34 @@
|
|||
<div component="code-highlighter" class="card content-wrap auto-height">
|
||||
<h2 class="list-heading">{{ trans('settings.webhooks_format_example') }}</h2>
|
||||
<p>{{ trans('settings.webhooks_format_example_desc') }}</p>
|
||||
<pre><code class="language-json">{
|
||||
"event": "page_update",
|
||||
"text": "Benny updated page \"My wonderful updated page\"",
|
||||
"triggered_at": "2021-12-11T22:25:10.000000Z",
|
||||
"triggered_by": {
|
||||
"id": 1,
|
||||
"name": "Benny",
|
||||
"slug": "benny"
|
||||
},
|
||||
"triggered_by_profile_url": "https://bookstack.local/user/benny",
|
||||
"webhook_id": 2,
|
||||
"webhook_name": "My page update webhook",
|
||||
"url": "https://bookstack.local/books/my-awesome-book/page/my-wonderful-updated-page",
|
||||
"related_item": {
|
||||
"id": 2432,
|
||||
"book_id": 13,
|
||||
"chapter_id": 554,
|
||||
"name": "My wonderful updated page",
|
||||
"slug": "my-wonderful-updated-page",
|
||||
"priority": 2,
|
||||
"created_at": "2021-12-11T21:53:24.000000Z",
|
||||
"updated_at": "2021-12-11T22:25:10.000000Z",
|
||||
"created_by": 1,
|
||||
"updated_by": 1,
|
||||
"draft": false,
|
||||
"revision_count": 9,
|
||||
"template": false,
|
||||
"owned_by": 1
|
||||
}
|
||||
}</code></pre>
|
||||
</div>
|
|
@ -29,7 +29,11 @@ use BookStack\Http\Controllers\UserApiTokenController;
|
|||
use BookStack\Http\Controllers\UserController;
|
||||
use BookStack\Http\Controllers\UserProfileController;
|
||||
use BookStack\Http\Controllers\UserSearchController;
|
||||
use BookStack\Http\Controllers\WebhookController;
|
||||
use BookStack\Http\Middleware\VerifyCsrfToken;
|
||||
use Illuminate\Session\Middleware\StartSession;
|
||||
use Illuminate\Support\Facades\Route;
|
||||
use Illuminate\View\Middleware\ShareErrorsFromSession;
|
||||
|
||||
Route::get('/status', [StatusController::class, 'show']);
|
||||
Route::get('/robots.txt', [HomeController::class, 'robots']);
|
||||
|
@ -244,13 +248,22 @@ Route::middleware('auth')->group(function () {
|
|||
Route::delete('/settings/users/{userId}/api-tokens/{tokenId}', [UserApiTokenController::class, 'destroy']);
|
||||
|
||||
// Roles
|
||||
Route::get('/settings/roles', [RoleController::class, 'list']);
|
||||
Route::get('/settings/roles', [RoleController::class, 'index']);
|
||||
Route::get('/settings/roles/new', [RoleController::class, 'create']);
|
||||
Route::post('/settings/roles/new', [RoleController::class, 'store']);
|
||||
Route::get('/settings/roles/delete/{id}', [RoleController::class, 'showDelete']);
|
||||
Route::delete('/settings/roles/delete/{id}', [RoleController::class, 'delete']);
|
||||
Route::get('/settings/roles/{id}', [RoleController::class, 'edit']);
|
||||
Route::put('/settings/roles/{id}', [RoleController::class, 'update']);
|
||||
|
||||
// Webhooks
|
||||
Route::get('/settings/webhooks', [WebhookController::class, 'index']);
|
||||
Route::get('/settings/webhooks/create', [WebhookController::class, 'create']);
|
||||
Route::post('/settings/webhooks/create', [WebhookController::class, 'store']);
|
||||
Route::get('/settings/webhooks/{id}', [WebhookController::class, 'edit']);
|
||||
Route::put('/settings/webhooks/{id}', [WebhookController::class, 'update']);
|
||||
Route::get('/settings/webhooks/{id}/delete', [WebhookController::class, 'delete']);
|
||||
Route::delete('/settings/webhooks/{id}', [WebhookController::class, 'destroy']);
|
||||
});
|
||||
|
||||
// MFA routes
|
||||
|
@ -291,9 +304,9 @@ Route::post('/saml2/logout', [Auth\Saml2Controller::class, 'logout']);
|
|||
Route::get('/saml2/metadata', [Auth\Saml2Controller::class, 'metadata']);
|
||||
Route::get('/saml2/sls', [Auth\Saml2Controller::class, 'sls']);
|
||||
Route::post('/saml2/acs', [Auth\Saml2Controller::class, 'startAcs'])->withoutMiddleware([
|
||||
\Illuminate\Session\Middleware\StartSession::class,
|
||||
\Illuminate\View\Middleware\ShareErrorsFromSession::class,
|
||||
\BookStack\Http\Middleware\VerifyCsrfToken::class,
|
||||
StartSession::class,
|
||||
ShareErrorsFromSession::class,
|
||||
VerifyCsrfToken::class,
|
||||
]);
|
||||
Route::get('/saml2/acs', [Auth\Saml2Controller::class, 'processAcs']);
|
||||
|
||||
|
|
|
@ -1,9 +1,9 @@
|
|||
<?php
|
||||
|
||||
namespace Tests;
|
||||
namespace Tests\Actions;
|
||||
|
||||
use BookStack\Actions\Activity;
|
||||
use BookStack\Actions\ActivityService;
|
||||
use BookStack\Actions\ActivityLogger;
|
||||
use BookStack\Actions\ActivityType;
|
||||
use BookStack\Auth\UserRepo;
|
||||
use BookStack\Entities\Models\Chapter;
|
||||
|
@ -11,16 +11,19 @@ use BookStack\Entities\Models\Page;
|
|||
use BookStack\Entities\Repos\PageRepo;
|
||||
use BookStack\Entities\Tools\TrashCan;
|
||||
use Carbon\Carbon;
|
||||
use Tests\TestCase;
|
||||
use function app;
|
||||
use function config;
|
||||
|
||||
class AuditLogTest extends TestCase
|
||||
{
|
||||
/** @var ActivityService */
|
||||
/** @var ActivityLogger */
|
||||
protected $activityService;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
$this->activityService = app(ActivityService::class);
|
||||
$this->activityService = app(ActivityLogger::class);
|
||||
}
|
||||
|
||||
public function test_only_accessible_with_right_permissions()
|
||||
|
@ -46,7 +49,7 @@ class AuditLogTest extends TestCase
|
|||
$admin = $this->getAdmin();
|
||||
$this->actingAs($admin);
|
||||
$page = Page::query()->first();
|
||||
$this->activityService->addForEntity($page, ActivityType::PAGE_CREATE);
|
||||
$this->activityService->add(ActivityType::PAGE_CREATE, $page);
|
||||
$activity = Activity::query()->orderBy('id', 'desc')->first();
|
||||
|
||||
$resp = $this->get('settings/audit');
|
||||
|
@ -61,7 +64,7 @@ class AuditLogTest extends TestCase
|
|||
$this->actingAs($this->getAdmin());
|
||||
$page = Page::query()->first();
|
||||
$pageName = $page->name;
|
||||
$this->activityService->addForEntity($page, ActivityType::PAGE_CREATE);
|
||||
$this->activityService->add(ActivityType::PAGE_CREATE, $page);
|
||||
|
||||
app(PageRepo::class)->destroy($page);
|
||||
app(TrashCan::class)->empty();
|
||||
|
@ -76,7 +79,7 @@ class AuditLogTest extends TestCase
|
|||
$viewer = $this->getViewer();
|
||||
$this->actingAs($viewer);
|
||||
$page = Page::query()->first();
|
||||
$this->activityService->addForEntity($page, ActivityType::PAGE_CREATE);
|
||||
$this->activityService->add(ActivityType::PAGE_CREATE, $page);
|
||||
|
||||
$this->actingAs($this->getAdmin());
|
||||
app(UserRepo::class)->destroy($viewer);
|
||||
|
@ -89,7 +92,7 @@ class AuditLogTest extends TestCase
|
|||
{
|
||||
$this->actingAs($this->getAdmin());
|
||||
$page = Page::query()->first();
|
||||
$this->activityService->addForEntity($page, ActivityType::PAGE_CREATE);
|
||||
$this->activityService->add(ActivityType::PAGE_CREATE, $page);
|
||||
|
||||
$resp = $this->get('settings/audit');
|
||||
$resp->assertSeeText($page->name);
|
||||
|
@ -102,7 +105,7 @@ class AuditLogTest extends TestCase
|
|||
{
|
||||
$this->actingAs($this->getAdmin());
|
||||
$page = Page::query()->first();
|
||||
$this->activityService->addForEntity($page, ActivityType::PAGE_CREATE);
|
||||
$this->activityService->add(ActivityType::PAGE_CREATE, $page);
|
||||
|
||||
$yesterday = (Carbon::now()->subDay()->format('Y-m-d'));
|
||||
$tomorrow = (Carbon::now()->addDay()->format('Y-m-d'));
|
||||
|
@ -126,11 +129,11 @@ class AuditLogTest extends TestCase
|
|||
$editor = $this->getEditor();
|
||||
$this->actingAs($admin);
|
||||
$page = Page::query()->first();
|
||||
$this->activityService->addForEntity($page, ActivityType::PAGE_CREATE);
|
||||
$this->activityService->add(ActivityType::PAGE_CREATE, $page);
|
||||
|
||||
$this->actingAs($editor);
|
||||
$chapter = Chapter::query()->first();
|
||||
$this->activityService->addForEntity($chapter, ActivityType::CHAPTER_UPDATE);
|
||||
$this->activityService->add(ActivityType::CHAPTER_UPDATE, $chapter);
|
||||
|
||||
$resp = $this->actingAs($admin)->get('settings/audit?user=' . $admin->id);
|
||||
$resp->assertSeeText($page->name);
|
116
tests/Actions/WebhookCallTest.php
Normal file
116
tests/Actions/WebhookCallTest.php
Normal file
|
@ -0,0 +1,116 @@
|
|||
<?php
|
||||
|
||||
namespace Tests\Actions;
|
||||
|
||||
use BookStack\Actions\ActivityLogger;
|
||||
use BookStack\Actions\ActivityType;
|
||||
use BookStack\Actions\DispatchWebhookJob;
|
||||
use BookStack\Actions\Webhook;
|
||||
use BookStack\Auth\User;
|
||||
use BookStack\Entities\Models\Page;
|
||||
use Illuminate\Http\Client\Request;
|
||||
use Illuminate\Support\Facades\Bus;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Tests\TestCase;
|
||||
|
||||
class WebhookCallTest extends TestCase
|
||||
{
|
||||
|
||||
public function test_webhook_listening_to_all_called_on_event()
|
||||
{
|
||||
$this->newWebhook([], ['all']);
|
||||
Bus::fake();
|
||||
$this->runEvent(ActivityType::ROLE_CREATE);
|
||||
Bus::assertDispatched(DispatchWebhookJob::class);
|
||||
}
|
||||
|
||||
public function test_webhook_listening_to_specific_event_called_on_event()
|
||||
{
|
||||
$this->newWebhook([], [ActivityType::ROLE_UPDATE]);
|
||||
Bus::fake();
|
||||
$this->runEvent(ActivityType::ROLE_UPDATE);
|
||||
Bus::assertDispatched(DispatchWebhookJob::class);
|
||||
}
|
||||
|
||||
public function test_webhook_listening_to_specific_event_not_called_on_other_event()
|
||||
{
|
||||
$this->newWebhook([], [ActivityType::ROLE_UPDATE]);
|
||||
Bus::fake();
|
||||
$this->runEvent(ActivityType::ROLE_CREATE);
|
||||
Bus::assertNotDispatched(DispatchWebhookJob::class);
|
||||
}
|
||||
|
||||
public function test_inactive_webhook_not_called_on_event()
|
||||
{
|
||||
$this->newWebhook(['active' => false], ['all']);
|
||||
Bus::fake();
|
||||
$this->runEvent(ActivityType::ROLE_CREATE);
|
||||
Bus::assertNotDispatched(DispatchWebhookJob::class);
|
||||
}
|
||||
|
||||
public function test_failed_webhook_call_logs_error()
|
||||
{
|
||||
$logger = $this->withTestLogger();
|
||||
Http::fake([
|
||||
'*' => Http::response('', 500),
|
||||
]);
|
||||
$this->newWebhook(['active' => true, 'endpoint' => 'https://wh.example.com'], ['all']);
|
||||
|
||||
$this->runEvent(ActivityType::ROLE_CREATE);
|
||||
|
||||
$this->assertTrue($logger->hasError('Webhook call to endpoint https://wh.example.com failed with status 500'));
|
||||
}
|
||||
|
||||
public function test_webhook_call_data_format()
|
||||
{
|
||||
Http::fake([
|
||||
'*' => Http::response('', 200),
|
||||
]);
|
||||
$webhook = $this->newWebhook(['active' => true, 'endpoint' => 'https://wh.example.com'], ['all']);
|
||||
/** @var Page $page */
|
||||
$page = Page::query()->first();
|
||||
$editor = $this->getEditor();
|
||||
|
||||
$this->runEvent(ActivityType::PAGE_UPDATE, $page, $editor);
|
||||
|
||||
Http::assertSent(function(Request $request) use ($editor, $page, $webhook) {
|
||||
$reqData = $request->data();
|
||||
return $request->isJson()
|
||||
&& $reqData['event'] === 'page_update'
|
||||
&& $reqData['text'] === ($editor->name . ' updated page "' . $page->name . '"')
|
||||
&& is_string($reqData['triggered_at'])
|
||||
&& $reqData['triggered_by']['name'] === $editor->name
|
||||
&& $reqData['triggered_by_profile_url'] === $editor->getProfileUrl()
|
||||
&& $reqData['webhook_id'] === $webhook->id
|
||||
&& $reqData['webhook_name'] === $webhook->name
|
||||
&& $reqData['url'] === $page->getUrl()
|
||||
&& $reqData['related_item']['name'] === $page->name;
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
protected function runEvent(string $event, $detail = '', ?User $user = null)
|
||||
{
|
||||
if (is_null($user)) {
|
||||
$user = $this->getEditor();
|
||||
}
|
||||
|
||||
$this->actingAs($user);
|
||||
|
||||
$activityLogger = $this->app->make(ActivityLogger::class);
|
||||
$activityLogger->add($event, $detail);
|
||||
}
|
||||
|
||||
protected function newWebhook(array $attrs = [], array $events = ['all']): Webhook
|
||||
{
|
||||
/** @var Webhook $webhook */
|
||||
$webhook = Webhook::factory()->create($attrs);
|
||||
|
||||
foreach ($events as $event) {
|
||||
$webhook->trackedEvents()->create(['event' => $event]);
|
||||
}
|
||||
|
||||
return $webhook;
|
||||
}
|
||||
|
||||
}
|
173
tests/Actions/WebhookManagementTest.php
Normal file
173
tests/Actions/WebhookManagementTest.php
Normal file
|
@ -0,0 +1,173 @@
|
|||
<?php
|
||||
|
||||
namespace Tests\Actions;
|
||||
|
||||
use BookStack\Actions\ActivityType;
|
||||
use BookStack\Actions\Webhook;
|
||||
use Tests\TestCase;
|
||||
|
||||
class WebhookManagementTest extends TestCase
|
||||
{
|
||||
|
||||
public function test_index_view()
|
||||
{
|
||||
$webhook = $this->newWebhook([
|
||||
'name' => 'My awesome webhook',
|
||||
'endpoint' => 'https://example.com/donkey/webhook',
|
||||
], ['all']);
|
||||
|
||||
$resp = $this->asAdmin()->get('/settings/webhooks');
|
||||
$resp->assertOk();
|
||||
$resp->assertElementContains('a[href$="/settings/webhooks/create"]', 'Create New Webhook');
|
||||
$resp->assertElementExists('a[href="' . $webhook->getUrl() . '"]', $webhook->name);
|
||||
$resp->assertSee($webhook->endpoint);
|
||||
$resp->assertSee('All system events');
|
||||
$resp->assertSee('Active');
|
||||
}
|
||||
|
||||
public function test_create_view()
|
||||
{
|
||||
$resp = $this->asAdmin()->get('/settings/webhooks/create');
|
||||
$resp->assertOk();
|
||||
$resp->assertSee('Create New Webhook');
|
||||
$resp->assertElementContains('form[action$="/settings/webhooks/create"] button', 'Save Webhook');
|
||||
}
|
||||
|
||||
public function test_store()
|
||||
{
|
||||
$resp = $this->asAdmin()->post('/settings/webhooks/create', [
|
||||
'name' => 'My first webhook',
|
||||
'endpoint' => 'https://example.com/webhook',
|
||||
'events' => ['all'],
|
||||
'active' => 'true'
|
||||
]);
|
||||
|
||||
$resp->assertRedirect('/settings/webhooks');
|
||||
$this->assertActivityExists(ActivityType::WEBHOOK_CREATE);
|
||||
|
||||
$resp = $this->followRedirects($resp);
|
||||
$resp->assertSee('Webhook successfully created');
|
||||
|
||||
$this->assertDatabaseHas('webhooks', [
|
||||
'name' => 'My first webhook',
|
||||
'endpoint' => 'https://example.com/webhook',
|
||||
'active' => true,
|
||||
]);
|
||||
|
||||
/** @var Webhook $webhook */
|
||||
$webhook = Webhook::query()->where('name', '=', 'My first webhook')->first();
|
||||
$this->assertDatabaseHas('webhook_tracked_events', [
|
||||
'webhook_id' => $webhook->id,
|
||||
'event' => 'all',
|
||||
]);
|
||||
}
|
||||
|
||||
public function test_edit_view()
|
||||
{
|
||||
$webhook = $this->newWebhook();
|
||||
|
||||
$resp = $this->asAdmin()->get('/settings/webhooks/' . $webhook->id);
|
||||
$resp->assertOk();
|
||||
$resp->assertSee('Edit Webhook');
|
||||
$resp->assertElementContains('form[action="' . $webhook->getUrl() . '"] button', 'Save Webhook');
|
||||
$resp->assertElementContains('a[href="' . $webhook->getUrl('/delete') . '"]', 'Delete Webhook');
|
||||
$resp->assertElementExists('input[type="checkbox"][value="all"][name="events[]"]');
|
||||
}
|
||||
|
||||
public function test_update()
|
||||
{
|
||||
$webhook = $this->newWebhook();
|
||||
|
||||
$resp = $this->asAdmin()->put('/settings/webhooks/' . $webhook->id, [
|
||||
'name' => 'My updated webhook',
|
||||
'endpoint' => 'https://example.com/updated-webhook',
|
||||
'events' => [ActivityType::PAGE_CREATE, ActivityType::PAGE_UPDATE],
|
||||
'active' => 'true'
|
||||
]);
|
||||
$resp->assertRedirect('/settings/webhooks');
|
||||
|
||||
$resp = $this->followRedirects($resp);
|
||||
$resp->assertSee('Webhook successfully updated');
|
||||
|
||||
$this->assertDatabaseHas('webhooks', [
|
||||
'id' => $webhook->id,
|
||||
'name' => 'My updated webhook',
|
||||
'endpoint' => 'https://example.com/updated-webhook',
|
||||
'active' => true,
|
||||
]);
|
||||
|
||||
$trackedEvents = $webhook->trackedEvents()->get();
|
||||
$this->assertCount(2, $trackedEvents);
|
||||
$this->assertEquals(['page_create', 'page_update'], $trackedEvents->pluck('event')->values()->all());
|
||||
|
||||
$this->assertActivityExists(ActivityType::WEBHOOK_UPDATE);
|
||||
}
|
||||
|
||||
public function test_delete_view()
|
||||
{
|
||||
$webhook = $this->newWebhook(['name' => 'Webhook to delete']);
|
||||
|
||||
$resp = $this->asAdmin()->get('/settings/webhooks/' . $webhook->id . '/delete');
|
||||
$resp->assertOk();
|
||||
$resp->assertSee('Delete Webhook');
|
||||
$resp->assertSee('This will fully delete this webhook, with the name \'Webhook to delete\', from the system.');
|
||||
$resp->assertElementContains('form[action$="/settings/webhooks/' . $webhook->id . '"]', 'Delete');
|
||||
}
|
||||
|
||||
public function test_destroy()
|
||||
{
|
||||
$webhook = $this->newWebhook();
|
||||
|
||||
$resp = $this->asAdmin()->delete('/settings/webhooks/' . $webhook->id);
|
||||
$resp->assertRedirect('/settings/webhooks');
|
||||
|
||||
$resp = $this->followRedirects($resp);
|
||||
$resp->assertSee('Webhook successfully deleted');
|
||||
|
||||
$this->assertDatabaseMissing('webhooks', ['id' => $webhook->id]);
|
||||
$this->assertDatabaseMissing('webhook_tracked_events', ['webhook_id' => $webhook->id]);
|
||||
|
||||
$this->assertActivityExists(ActivityType::WEBHOOK_DELETE);
|
||||
}
|
||||
|
||||
public function test_settings_manage_permission_required_for_webhook_routes()
|
||||
{
|
||||
$editor = $this->getEditor();
|
||||
$this->actingAs($editor);
|
||||
|
||||
$routes = [
|
||||
['GET', '/settings/webhooks'],
|
||||
['GET', '/settings/webhooks/create'],
|
||||
['POST', '/settings/webhooks/create'],
|
||||
['GET', '/settings/webhooks/1'],
|
||||
['PUT', '/settings/webhooks/1'],
|
||||
['DELETE', '/settings/webhooks/1'],
|
||||
['GET', '/settings/webhooks/1/delete'],
|
||||
];
|
||||
|
||||
foreach ($routes as [$method, $endpoint]) {
|
||||
$resp = $this->call($method, $endpoint);
|
||||
$this->assertPermissionError($resp);
|
||||
}
|
||||
|
||||
$this->giveUserPermissions($editor, ['settings-manage']);
|
||||
|
||||
foreach ($routes as [$method, $endpoint]) {
|
||||
$resp = $this->call($method, $endpoint);
|
||||
$this->assertNotPermissionError($resp);
|
||||
}
|
||||
}
|
||||
|
||||
protected function newWebhook(array $attrs = [], array $events = ['all']): Webhook
|
||||
{
|
||||
/** @var Webhook $webhook */
|
||||
$webhook = Webhook::factory()->create($attrs);
|
||||
|
||||
foreach ($events as $event) {
|
||||
$webhook->trackedEvents()->create(['event' => $event]);
|
||||
}
|
||||
|
||||
return $webhook;
|
||||
}
|
||||
|
||||
}
|
|
@ -4,6 +4,8 @@ namespace Tests\Commands;
|
|||
|
||||
use BookStack\Actions\ActivityType;
|
||||
use BookStack\Entities\Models\Page;
|
||||
use BookStack\Facades\Activity;
|
||||
use Illuminate\Support\Facades\Artisan;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Tests\TestCase;
|
||||
|
||||
|
@ -12,8 +14,9 @@ class ClearActivityCommandTest extends TestCase
|
|||
public function test_clear_activity_command()
|
||||
{
|
||||
$this->asEditor();
|
||||
$page = Page::first();
|
||||
\Activity::addForEntity($page, ActivityType::PAGE_UPDATE);
|
||||
/** @var Page $page */
|
||||
$page = Page::query()->first();
|
||||
Activity::add(ActivityType::PAGE_UPDATE, $page);
|
||||
|
||||
$this->assertDatabaseHas('activities', [
|
||||
'type' => 'page_update',
|
||||
|
@ -22,7 +25,7 @@ class ClearActivityCommandTest extends TestCase
|
|||
]);
|
||||
|
||||
DB::rollBack();
|
||||
$exitCode = \Artisan::call('bookstack:clear-activity');
|
||||
$exitCode = Artisan::call('bookstack:clear-activity');
|
||||
DB::beginTransaction();
|
||||
$this->assertTrue($exitCode === 0, 'Command executed successfully');
|
||||
|
||||
|
|
|
@ -1,10 +1,13 @@
|
|||
<?php
|
||||
|
||||
namespace Tests;
|
||||
|
||||
use Exception;
|
||||
use Illuminate\Cache\ArrayStore;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Session;
|
||||
use Tests\TestCase;
|
||||
use Mockery;
|
||||
|
||||
class StatusTest extends TestCase
|
||||
{
|
||||
|
|
|
@ -64,8 +64,8 @@ class UserProfileTest extends TestCase
|
|||
$newUser = User::factory()->create();
|
||||
$this->actingAs($newUser);
|
||||
$entities = $this->createEntityChainBelongingToUser($newUser, $newUser);
|
||||
Activity::addForEntity($entities['book'], ActivityType::BOOK_UPDATE);
|
||||
Activity::addForEntity($entities['page'], ActivityType::PAGE_CREATE);
|
||||
Activity::add(ActivityType::BOOK_UPDATE, $entities['book']);
|
||||
Activity::add(ActivityType::PAGE_CREATE, $entities['page']);
|
||||
|
||||
$this->asAdmin()->get('/user/' . $newUser->slug)
|
||||
->assertElementContains('#recent-user-activity', 'updated book')
|
||||
|
@ -78,8 +78,8 @@ class UserProfileTest extends TestCase
|
|||
$newUser = User::factory()->create();
|
||||
$this->actingAs($newUser);
|
||||
$entities = $this->createEntityChainBelongingToUser($newUser, $newUser);
|
||||
Activity::addForEntity($entities['book'], ActivityType::BOOK_UPDATE);
|
||||
Activity::addForEntity($entities['page'], ActivityType::PAGE_CREATE);
|
||||
Activity::add(ActivityType::BOOK_UPDATE, $entities['book']);
|
||||
Activity::add(ActivityType::PAGE_CREATE, $entities['page']);
|
||||
|
||||
$linkSelector = '#recent-activity a[href$="/user/' . $newUser->slug . '"]';
|
||||
$this->asAdmin()->get('/')
|
||||
|
|
Loading…
Reference in New Issue
Block a user