diff --git a/.env.example.complete b/.env.example.complete index c0fed8c4e..37b46fec2 100644 --- a/.env.example.complete +++ b/.env.example.complete @@ -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 diff --git a/.github/translators.txt b/.github/translators.txt index d85740798..050a2c427 100644 --- a/.github/translators.txt +++ b/.github/translators.txt @@ -126,7 +126,7 @@ Zenahr Barzani (Zenahr) :: German; Japanese; Dutch; German Informal tatsuya.info :: Japanese fadiapp :: Arabic Jakub Bouček (jakubboucek) :: Czech -Marco (cdrfun) :: German +Marco (cdrfun) :: German; German Informal 10935336 :: Chinese Simplified 孟繁阳 (FanyangMeng) :: Chinese Simplified Andrej Močan (andrejm) :: Slovenian diff --git a/app/Actions/ActivityLogger.php b/app/Actions/ActivityLogger.php new file mode 100644 index 000000000..0d1391b43 --- /dev/null +++ b/app/Actions/ActivityLogger.php @@ -0,0 +1,115 @@ +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); + } +} diff --git a/app/Actions/ActivityService.php b/app/Actions/ActivityQueries.php similarity index 51% rename from app/Actions/ActivityService.php rename to app/Actions/ActivityQueries.php index 73dc76de0..f900fbb05 100644 --- a/app/Actions/ActivityService.php +++ b/app/Actions/ActivityQueries.php @@ -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 { @@ -170,32 +109,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); - } } diff --git a/app/Actions/ActivityType.php b/app/Actions/ActivityType.php index 60b1630e0..8b5213a8b 100644 --- a/app/Actions/ActivityType.php +++ b/app/Actions/ActivityType.php @@ -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(); + } } diff --git a/app/Actions/CommentRepo.php b/app/Actions/CommentRepo.php index 8061c4542..2f2dd658a 100644 --- a/app/Actions/CommentRepo.php +++ b/app/Actions/CommentRepo.php @@ -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; } diff --git a/app/Actions/DispatchWebhookJob.php b/app/Actions/DispatchWebhookJob.php new file mode 100644 index 000000000..ece6b6f08 --- /dev/null +++ b/app/Actions/DispatchWebhookJob.php @@ -0,0 +1,112 @@ +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; + } +} diff --git a/app/Actions/Webhook.php b/app/Actions/Webhook.php new file mode 100644 index 000000000..2c0bd0f15 --- /dev/null +++ b/app/Actions/Webhook.php @@ -0,0 +1,75 @@ +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}"; + } +} diff --git a/app/Actions/WebhookTrackedEvent.php b/app/Actions/WebhookTrackedEvent.php new file mode 100644 index 000000000..6289581a2 --- /dev/null +++ b/app/Actions/WebhookTrackedEvent.php @@ -0,0 +1,18 @@ + env('QUEUE_CONNECTION', 'sync'), // Queue connection configuration diff --git a/app/Console/Commands/CreateAdmin.php b/app/Console/Commands/CreateAdmin.php index 149444420..8c273bc1f 100644 --- a/app/Console/Commands/CreateAdmin.php +++ b/app/Console/Commands/CreateAdmin.php @@ -4,6 +4,9 @@ namespace BookStack\Console\Commands; use BookStack\Auth\UserRepo; use Illuminate\Console\Command; +use Illuminate\Support\Facades\Validator; +use Illuminate\Validation\Rules\Password; +use Illuminate\Validation\Rules\Unique; use Symfony\Component\Console\Command\Command as SymfonyCommand; class CreateAdmin extends Command @@ -45,43 +48,33 @@ class CreateAdmin extends Command */ public function handle() { - $email = trim($this->option('email')); - if (empty($email)) { - $email = $this->ask('Please specify an email address for the new admin user'); + $details = $this->options(); + + if (empty($details['email'])) { + $details['email'] = $this->ask('Please specify an email address for the new admin user'); } - if (mb_strlen($email) < 5 || !filter_var($email, FILTER_VALIDATE_EMAIL)) { - $this->error('Invalid email address provided'); + if (empty($details['name'])) { + $details['name'] = $this->ask('Please specify a name for the new admin user'); + } + if (empty($details['password'])) { + $details['password'] = $this->ask('Please specify a password for the new admin user (8 characters min)'); + } + + $validator = Validator::make($details, [ + 'email' => ['required', 'email', 'min:5', new Unique('users', 'email')], + 'name' => ['required', 'min:2'], + 'password' => ['required', Password::default()], + ]); + + if ($validator->fails()) { + foreach ($validator->errors()->all() as $error) { + $this->error($error); + } return SymfonyCommand::FAILURE; } - if ($this->userRepo->getByEmail($email) !== null) { - $this->error('A user with the provided email already exists!'); - - return SymfonyCommand::FAILURE; - } - - $name = trim($this->option('name')); - if (empty($name)) { - $name = $this->ask('Please specify an name for the new admin user'); - } - if (mb_strlen($name) < 2) { - $this->error('Invalid name provided'); - - return SymfonyCommand::FAILURE; - } - - $password = trim($this->option('password')); - if (empty($password)) { - $password = $this->secret('Please specify a password for the new admin user'); - } - if (mb_strlen($password) < 5) { - $this->error('Invalid password provided, Must be at least 5 characters'); - - return SymfonyCommand::FAILURE; - } - - $user = $this->userRepo->create(['email' => $email, 'name' => $name, 'password' => $password]); + $user = $this->userRepo->create($validator->validated()); $this->userRepo->attachSystemRole($user, 'admin'); $this->userRepo->downloadAndAssignUserAvatar($user); $user->email_confirmed = true; diff --git a/app/Entities/Models/Chapter.php b/app/Entities/Models/Chapter.php index 08d6608a9..af4bbd8e3 100644 --- a/app/Entities/Models/Chapter.php +++ b/app/Entities/Models/Chapter.php @@ -18,7 +18,7 @@ class Chapter extends BookChild public $searchFactor = 1.2; - protected $fillable = ['name', 'description', 'priority', 'book_id']; + protected $fillable = ['name', 'description', 'priority']; protected $hidden = ['restricted', 'pivot', 'deleted_at']; /** diff --git a/app/Entities/Models/Entity.php b/app/Entities/Models/Entity.php index 0eb402284..b55334295 100644 --- a/app/Entities/Models/Entity.php +++ b/app/Entities/Models/Entity.php @@ -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}"; + } } diff --git a/app/Entities/Repos/BookRepo.php b/app/Entities/Repos/BookRepo.php index a692bbaf7..7c4b280a8 100644 --- a/app/Entities/Repos/BookRepo.php +++ b/app/Entities/Repos/BookRepo.php @@ -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(); } diff --git a/app/Entities/Repos/BookshelfRepo.php b/app/Entities/Repos/BookshelfRepo.php index 3146c7cba..ceabba59a 100644 --- a/app/Entities/Repos/BookshelfRepo.php +++ b/app/Entities/Repos/BookshelfRepo.php @@ -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(); } } diff --git a/app/Entities/Repos/ChapterRepo.php b/app/Entities/Repos/ChapterRepo.php index 68330dd57..87f9e9e40 100644 --- a/app/Entities/Repos/ChapterRepo.php +++ b/app/Entities/Repos/ChapterRepo.php @@ -5,6 +5,7 @@ namespace BookStack\Entities\Repos; use BookStack\Actions\ActivityType; use BookStack\Entities\Models\Book; use BookStack\Entities\Models\Chapter; +use BookStack\Entities\Models\Entity; use BookStack\Entities\Tools\BookContents; use BookStack\Entities\Tools\TrashCan; use BookStack\Exceptions\MoveOperationException; @@ -49,7 +50,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 +61,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 +75,7 @@ class ChapterRepo { $trashCan = new TrashCan(); $trashCan->softDestroyChapter($chapter); - Activity::addForEntity($chapter, ActivityType::CHAPTER_DELETE); + Activity::add(ActivityType::CHAPTER_DELETE, $chapter); $trashCan->autoClearOld(); } @@ -87,24 +88,36 @@ class ChapterRepo */ public function move(Chapter $chapter, string $parentIdentifier): Book { - $stringExploded = explode(':', $parentIdentifier); - $entityType = $stringExploded[0]; - $entityId = intval($stringExploded[1]); - - if ($entityType !== 'book') { - throw new MoveOperationException('Chapters can only be moved into books'); - } - /** @var Book $parent */ - $parent = Book::visible()->where('id', '=', $entityId)->first(); - if ($parent === null) { + $parent = $this->findParentByIdentifier($parentIdentifier); + if (is_null($parent)) { throw new MoveOperationException('Book to move chapter into not found'); } $chapter->changeBook($parent->id); $chapter->rebuildPermissions(); - Activity::addForEntity($chapter, ActivityType::CHAPTER_MOVE); + Activity::add(ActivityType::CHAPTER_MOVE, $chapter); return $parent; } + + /** + * Find a page parent entity via an identifier string in the format: + * {type}:{id} + * Example: (book:5). + * + * @throws MoveOperationException + */ + public function findParentByIdentifier(string $identifier): ?Book + { + $stringExploded = explode(':', $identifier); + $entityType = $stringExploded[0]; + $entityId = intval($stringExploded[1]); + + if ($entityType !== 'book') { + throw new MoveOperationException('Chapters can only be in books'); + } + + return Book::visible()->where('id', '=', $entityId)->first(); + } } diff --git a/app/Entities/Repos/PageRepo.php b/app/Entities/Repos/PageRepo.php index 24fc1e7dd..992946461 100644 --- a/app/Entities/Repos/PageRepo.php +++ b/app/Entities/Repos/PageRepo.php @@ -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,56 +341,19 @@ class PageRepo $page->changeBook($newBookId); $page->rebuildPermissions(); - Activity::addForEntity($page, ActivityType::PAGE_MOVE); + Activity::add(ActivityType::PAGE_MOVE, $page); return $parent; } /** - * Copy an existing page in the system. - * Optionally providing a new parent via string identifier and a new name. - * - * @throws MoveOperationException - * @throws PermissionsException - */ - public function copy(Page $page, string $parentIdentifier = null, string $newName = null): Page - { - $parent = $parentIdentifier ? $this->findParentByIdentifier($parentIdentifier) : $page->getParent(); - if ($parent === null) { - throw new MoveOperationException('Book or chapter to move page into not found'); - } - - if (!userCan('page-create', $parent)) { - throw new PermissionsException('User does not have permission to create a page within the new parent'); - } - - $copyPage = $this->getNewDraftPage($parent); - $pageData = $page->getAttributes(); - - // Update name - if (!empty($newName)) { - $pageData['name'] = $newName; - } - - // Copy tags from previous page if set - if ($page->tags) { - $pageData['tags'] = []; - foreach ($page->tags as $tag) { - $pageData['tags'][] = ['name' => $tag->name, 'value' => $tag->value]; - } - } - - return $this->publishDraft($copyPage, $pageData); - } - - /** - * Find a page parent entity via a identifier string in the format: + * Find a page parent entity via an identifier string in the format: * {type}:{id} * Example: (book:5). * * @throws MoveOperationException */ - protected function findParentByIdentifier(string $identifier): ?Entity + public function findParentByIdentifier(string $identifier): ?Entity { $stringExploded = explode(':', $identifier); $entityType = $stringExploded[0]; diff --git a/app/Entities/Tools/Cloner.php b/app/Entities/Tools/Cloner.php new file mode 100644 index 000000000..b4923b90a --- /dev/null +++ b/app/Entities/Tools/Cloner.php @@ -0,0 +1,147 @@ +pageRepo = $pageRepo; + $this->chapterRepo = $chapterRepo; + $this->bookRepo = $bookRepo; + $this->imageService = $imageService; + } + + /** + * Clone the given page into the given parent using the provided name. + */ + public function clonePage(Page $original, Entity $parent, string $newName): Page + { + $copyPage = $this->pageRepo->getNewDraftPage($parent); + $pageData = $original->getAttributes(); + + // Update name & tags + $pageData['name'] = $newName; + $pageData['tags'] = $this->entityTagsToInputArray($original); + + return $this->pageRepo->publishDraft($copyPage, $pageData); + } + + /** + * Clone the given page into the given parent using the provided name. + * Clones all child pages. + */ + public function cloneChapter(Chapter $original, Book $parent, string $newName): Chapter + { + $chapterDetails = $original->getAttributes(); + $chapterDetails['name'] = $newName; + $chapterDetails['tags'] = $this->entityTagsToInputArray($original); + + $copyChapter = $this->chapterRepo->create($chapterDetails, $parent); + + if (userCan('page-create', $copyChapter)) { + /** @var Page $page */ + foreach ($original->getVisiblePages() as $page) { + $this->clonePage($page, $copyChapter, $page->name); + } + } + + return $copyChapter; + } + + /** + * Clone the given book. + * Clones all child chapters & pages. + */ + public function cloneBook(Book $original, string $newName): Book + { + $bookDetails = $original->getAttributes(); + $bookDetails['name'] = $newName; + $bookDetails['tags'] = $this->entityTagsToInputArray($original); + + $copyBook = $this->bookRepo->create($bookDetails); + + $directChildren = $original->getDirectChildren(); + foreach ($directChildren as $child) { + if ($child instanceof Chapter && userCan('chapter-create', $copyBook)) { + $this->cloneChapter($child, $copyBook, $child->name); + } + + if ($child instanceof Page && !$child->draft && userCan('page-create', $copyBook)) { + $this->clonePage($child, $copyBook, $child->name); + } + } + + if ($original->cover) { + try { + $tmpImgFile = tmpfile(); + $uploadedFile = $this->imageToUploadedFile($original->cover, $tmpImgFile); + $this->bookRepo->updateCoverImage($copyBook, $uploadedFile, false); + } catch (\Exception $exception) { + } + } + + return $copyBook; + } + + /** + * Convert an image instance to an UploadedFile instance to mimic + * a file being uploaded. + */ + protected function imageToUploadedFile(Image $image, &$tmpFile): ?UploadedFile + { + $imgData = $this->imageService->getImageData($image); + $tmpImgFilePath = stream_get_meta_data($tmpFile)['uri']; + file_put_contents($tmpImgFilePath, $imgData); + + return new UploadedFile($tmpImgFilePath, basename($image->path)); + } + + /** + * Convert the tags on the given entity to the raw format + * that's used for incoming request data. + */ + protected function entityTagsToInputArray(Entity $entity): array + { + $tags = []; + + /** @var Tag $tag */ + foreach ($entity->tags as $tag) { + $tags[] = ['name' => $tag->name, 'value' => $tag->value]; + } + + return $tags; + } +} diff --git a/app/Entities/Tools/PermissionsUpdater.php b/app/Entities/Tools/PermissionsUpdater.php index 4e8351776..c771ee4b6 100644 --- a/app/Entities/Tools/PermissionsUpdater.php +++ b/app/Entities/Tools/PermissionsUpdater.php @@ -35,7 +35,7 @@ class PermissionsUpdater $entity->save(); $entity->rebuildPermissions(); - Activity::addForEntity($entity, ActivityType::PERMISSIONS_UPDATE); + Activity::add(ActivityType::PERMISSIONS_UPDATE, $entity); } /** diff --git a/app/Entities/Tools/SearchResultsFormatter.php b/app/Entities/Tools/SearchResultsFormatter.php index 5dbcfb663..00b9c0b26 100644 --- a/app/Entities/Tools/SearchResultsFormatter.php +++ b/app/Entities/Tools/SearchResultsFormatter.php @@ -57,17 +57,17 @@ class SearchResultsFormatter protected function highlightTagsContainingTerms(array $tags, array $terms): void { foreach ($tags as $tag) { - $tagName = strtolower($tag->name); - $tagValue = strtolower($tag->value); + $tagName = mb_strtolower($tag->name); + $tagValue = mb_strtolower($tag->value); foreach ($terms as $term) { - $termLower = strtolower($term); + $termLower = mb_strtolower($term); - if (strpos($tagName, $termLower) !== false) { + if (mb_strpos($tagName, $termLower) !== false) { $tag->setAttribute('highlight_name', true); } - if (strpos($tagValue, $termLower) !== false) { + if (mb_strpos($tagValue, $termLower) !== false) { $tag->setAttribute('highlight_value', true); } } @@ -84,17 +84,17 @@ class SearchResultsFormatter protected function getMatchPositions(string $text, array $terms): array { $matchRefs = []; - $text = strtolower($text); + $text = mb_strtolower($text); foreach ($terms as $term) { $offset = 0; - $term = strtolower($term); - $pos = strpos($text, $term, $offset); + $term = mb_strtolower($term); + $pos = mb_strpos($text, $term, $offset); while ($pos !== false) { - $end = $pos + strlen($term); + $end = $pos + mb_strlen($term); $matchRefs[$pos] = $end; $offset = $end; - $pos = strpos($text, $term, $offset); + $pos = mb_strpos($text, $term, $offset); } } @@ -141,7 +141,7 @@ class SearchResultsFormatter */ protected function formatTextUsingMatchPositions(array $matchPositions, string $originalText, int $targetLength): string { - $maxEnd = strlen($originalText); + $maxEnd = mb_strlen($originalText); $fetchAll = ($targetLength === 0); $contextLength = ($fetchAll ? 0 : 32); @@ -165,7 +165,7 @@ class SearchResultsFormatter $contextStart = $start; // Trims off '$startDiff' number of characters to bring it back to the start // if this current match zone. - $content = substr($content, 0, strlen($content) + $startDiff); + $content = mb_substr($content, 0, mb_strlen($content) + $startDiff); $contentTextLength += $startDiff; } @@ -176,16 +176,16 @@ class SearchResultsFormatter } elseif ($fetchAll) { // Or fill in gap since the previous match $fillLength = $contextStart - $lastEnd; - $content .= e(substr($originalText, $lastEnd, $fillLength)); + $content .= e(mb_substr($originalText, $lastEnd, $fillLength)); $contentTextLength += $fillLength; } // Add our content including the bolded matching text - $content .= e(substr($originalText, $contextStart, $start - $contextStart)); + $content .= e(mb_substr($originalText, $contextStart, $start - $contextStart)); $contentTextLength += $start - $contextStart; - $content .= '' . e(substr($originalText, $start, $end - $start)) . ''; + $content .= '' . e(mb_substr($originalText, $start, $end - $start)) . ''; $contentTextLength += $end - $start; - $content .= e(substr($originalText, $end, $contextEnd - $end)); + $content .= e(mb_substr($originalText, $end, $contextEnd - $end)); $contentTextLength += $contextEnd - $end; // Update our last end position @@ -204,7 +204,7 @@ class SearchResultsFormatter // Just copy out the content if we haven't moved along anywhere. if ($lastEnd === 0) { - $content = e(substr($originalText, 0, $targetLength)); + $content = e(mb_substr($originalText, 0, $targetLength)); $contentTextLength = $targetLength; $lastEnd = $targetLength; } @@ -213,7 +213,7 @@ class SearchResultsFormatter $remainder = $targetLength - $contentTextLength; if ($remainder > 10) { $padEndLength = min($maxEnd - $lastEnd, $remainder); - $content .= e(substr($originalText, $lastEnd, $padEndLength)); + $content .= e(mb_substr($originalText, $lastEnd, $padEndLength)); $lastEnd += $padEndLength; $contentTextLength += $padEndLength; } @@ -223,7 +223,7 @@ class SearchResultsFormatter $firstStart = $firstStart ?: 0; if (!$fetchAll && $remainder > 10 && $firstStart !== 0) { $padStart = max(0, $firstStart - $remainder); - $content = ($padStart === 0 ? '' : '...') . e(substr($originalText, $padStart, $firstStart - $padStart)) . substr($content, 4); + $content = ($padStart === 0 ? '' : '...') . e(mb_substr($originalText, $padStart, $firstStart - $padStart)) . mb_substr($content, 4); } // Add ellipsis if we're not at the end diff --git a/app/Facades/Activity.php b/app/Facades/Activity.php index 76493efd7..6c279a057 100644 --- a/app/Facades/Activity.php +++ b/app/Facades/Activity.php @@ -4,6 +4,9 @@ namespace BookStack\Facades; use Illuminate\Support\Facades\Facade; +/** + * @see \BookStack\Actions\ActivityLogger + */ class Activity extends Facade { /** diff --git a/app/Http/Controllers/AuditLogController.php b/app/Http/Controllers/AuditLogController.php index 11efbfc23..ec3f36975 100644 --- a/app/Http/Controllers/AuditLogController.php +++ b/app/Http/Controllers/AuditLogController.php @@ -20,6 +20,7 @@ class AuditLogController extends Controller 'date_from' => $request->get('date_from', ''), 'date_to' => $request->get('date_to', ''), 'user' => $request->get('user', ''), + 'ip' => $request->get('ip', ''), ]; $query = Activity::query() @@ -44,6 +45,9 @@ class AuditLogController extends Controller if ($listDetails['date_to']) { $query->where('created_at', '<=', $listDetails['date_to']); } + if ($listDetails['ip']) { + $query->where('ip', 'like', $listDetails['ip'] . '%'); + } $activities = $query->paginate(100); $activities->appends($listDetails); diff --git a/app/Http/Controllers/Auth/LoginController.php b/app/Http/Controllers/Auth/LoginController.php index 427d88a02..742e10472 100644 --- a/app/Http/Controllers/Auth/LoginController.php +++ b/app/Http/Controllers/Auth/LoginController.php @@ -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; diff --git a/app/Http/Controllers/Auth/RegisterController.php b/app/Http/Controllers/Auth/RegisterController.php index d4e7fcb8e..9399e8b7f 100644 --- a/app/Http/Controllers/Auth/RegisterController.php +++ b/app/Http/Controllers/Auth/RegisterController.php @@ -13,6 +13,7 @@ use Illuminate\Foundation\Auth\RegistersUsers; use Illuminate\Http\Request; use Illuminate\Support\Facades\Hash; use Illuminate\Support\Facades\Validator; +use Illuminate\Validation\Rules\Password; class RegisterController extends Controller { @@ -70,7 +71,7 @@ class RegisterController extends Controller return Validator::make($data, [ 'name' => ['required', 'min:2', 'max:255'], 'email' => ['required', 'email', 'max:255', 'unique:users'], - 'password' => ['required', 'min:8'], + 'password' => ['required', Password::default()], ]); } diff --git a/app/Http/Controllers/Auth/UserInviteController.php b/app/Http/Controllers/Auth/UserInviteController.php index df8262e22..27b20f831 100644 --- a/app/Http/Controllers/Auth/UserInviteController.php +++ b/app/Http/Controllers/Auth/UserInviteController.php @@ -11,6 +11,7 @@ use Exception; use Illuminate\Http\RedirectResponse; use Illuminate\Http\Request; use Illuminate\Routing\Redirector; +use Illuminate\Validation\Rules\Password; class UserInviteController extends Controller { @@ -55,7 +56,7 @@ class UserInviteController extends Controller public function setPassword(Request $request, string $token) { $this->validate($request, [ - 'password' => ['required', 'min:8'], + 'password' => ['required', Password::default()], ]); try { diff --git a/app/Http/Controllers/BookController.php b/app/Http/Controllers/BookController.php index 51cba642c..bc403c6d0 100644 --- a/app/Http/Controllers/BookController.php +++ b/app/Http/Controllers/BookController.php @@ -2,15 +2,18 @@ namespace BookStack\Http\Controllers; -use Activity; +use BookStack\Actions\ActivityQueries; use BookStack\Actions\ActivityType; use BookStack\Actions\View; use BookStack\Entities\Models\Bookshelf; use BookStack\Entities\Repos\BookRepo; use BookStack\Entities\Tools\BookContents; +use BookStack\Entities\Tools\Cloner; use BookStack\Entities\Tools\PermissionsUpdater; use BookStack\Entities\Tools\ShelfContext; use BookStack\Exceptions\ImageUploadException; +use BookStack\Exceptions\NotFoundException; +use BookStack\Facades\Activity; use Illuminate\Http\Request; use Illuminate\Validation\ValidationException; use Throwable; @@ -101,7 +104,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 +113,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 +131,7 @@ class BookController extends Controller 'current' => $book, 'bookChildren' => $bookChildren, 'bookParentShelves' => $bookParentShelves, - 'activity' => Activity::entityActivity($book, 20, 1), + 'activity' => $activities->entityActivity($book, 20, 1), ]); } @@ -224,4 +227,39 @@ class BookController extends Controller return redirect($book->getUrl()); } + + /** + * Show the view to copy a book. + * + * @throws NotFoundException + */ + public function showCopy(string $bookSlug) + { + $book = $this->bookRepo->getBySlug($bookSlug); + $this->checkOwnablePermission('book-view', $book); + + session()->flashInput(['name' => $book->name]); + + return view('books.copy', [ + 'book' => $book, + ]); + } + + /** + * Create a copy of a book within the requested target destination. + * + * @throws NotFoundException + */ + public function copy(Request $request, Cloner $cloner, string $bookSlug) + { + $book = $this->bookRepo->getBySlug($bookSlug); + $this->checkOwnablePermission('book-view', $book); + $this->checkPermission('book-create-all'); + + $newName = $request->get('name') ?: $book->name; + $bookCopy = $cloner->cloneBook($book, $newName); + $this->showSuccessNotification(trans('entities.books_copy_success')); + + return redirect($bookCopy->getUrl()); + } } diff --git a/app/Http/Controllers/BookSortController.php b/app/Http/Controllers/BookSortController.php index 0bd394778..010e74fa4 100644 --- a/app/Http/Controllers/BookSortController.php +++ b/app/Http/Controllers/BookSortController.php @@ -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()); diff --git a/app/Http/Controllers/BookshelfController.php b/app/Http/Controllers/BookshelfController.php index 32248ee46..9a7f78a85 100644 --- a/app/Http/Controllers/BookshelfController.php +++ b/app/Http/Controllers/BookshelfController.php @@ -2,7 +2,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 +101,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 +124,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, ]); diff --git a/app/Http/Controllers/ChapterController.php b/app/Http/Controllers/ChapterController.php index 9d2bd2489..7541ad0db 100644 --- a/app/Http/Controllers/ChapterController.php +++ b/app/Http/Controllers/ChapterController.php @@ -6,6 +6,7 @@ use BookStack\Actions\View; use BookStack\Entities\Models\Book; use BookStack\Entities\Repos\ChapterRepo; use BookStack\Entities\Tools\BookContents; +use BookStack\Entities\Tools\Cloner; use BookStack\Entities\Tools\NextPreviousContentLocator; use BookStack\Entities\Tools\PermissionsUpdater; use BookStack\Exceptions\MoveOperationException; @@ -190,6 +191,53 @@ class ChapterController extends Controller return redirect($chapter->getUrl()); } + /** + * Show the view to copy a chapter. + * + * @throws NotFoundException + */ + public function showCopy(string $bookSlug, string $chapterSlug) + { + $chapter = $this->chapterRepo->getBySlug($bookSlug, $chapterSlug); + $this->checkOwnablePermission('chapter-view', $chapter); + + session()->flashInput(['name' => $chapter->name]); + + return view('chapters.copy', [ + 'book' => $chapter->book, + 'chapter' => $chapter, + ]); + } + + /** + * Create a copy of a chapter within the requested target destination. + * + * @throws NotFoundException + * @throws Throwable + */ + public function copy(Request $request, Cloner $cloner, string $bookSlug, string $chapterSlug) + { + $chapter = $this->chapterRepo->getBySlug($bookSlug, $chapterSlug); + $this->checkOwnablePermission('chapter-view', $chapter); + + $entitySelection = $request->get('entity_selection') ?: null; + $newParentBook = $entitySelection ? $this->chapterRepo->findParentByIdentifier($entitySelection) : $chapter->getParent(); + + if (is_null($newParentBook)) { + $this->showErrorNotification(trans('errors.selected_book_not_found')); + + return redirect()->back(); + } + + $this->checkOwnablePermission('chapter-create', $newParentBook); + + $newName = $request->get('name') ?: $chapter->name; + $chapterCopy = $cloner->cloneChapter($chapter, $newParentBook, $newName); + $this->showSuccessNotification(trans('entities.chapters_copy_success')); + + return redirect($chapterCopy->getUrl()); + } + /** * Show the Restrictions view. * diff --git a/app/Http/Controllers/HomeController.php b/app/Http/Controllers/HomeController.php index df810a3cf..9e66a0640 100644 --- a/app/Http/Controllers/HomeController.php +++ b/app/Http/Controllers/HomeController.php @@ -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()) { diff --git a/app/Http/Controllers/MaintenanceController.php b/app/Http/Controllers/MaintenanceController.php index d6abe4682..f13266d7c 100644 --- a/app/Http/Controllers/MaintenanceController.php +++ b/app/Http/Controllers/MaintenanceController.php @@ -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(); diff --git a/app/Http/Controllers/PageController.php b/app/Http/Controllers/PageController.php index 7d5d93ffa..84187f745 100644 --- a/app/Http/Controllers/PageController.php +++ b/app/Http/Controllers/PageController.php @@ -6,6 +6,7 @@ use BookStack\Actions\View; use BookStack\Entities\Models\Page; use BookStack\Entities\Repos\PageRepo; use BookStack\Entities\Tools\BookContents; +use BookStack\Entities\Tools\Cloner; use BookStack\Entities\Tools\NextPreviousContentLocator; use BookStack\Entities\Tools\PageContent; use BookStack\Entities\Tools\PageEditActivity; @@ -447,26 +448,24 @@ class PageController extends Controller * @throws NotFoundException * @throws Throwable */ - public function copy(Request $request, string $bookSlug, string $pageSlug) + public function copy(Request $request, Cloner $cloner, string $bookSlug, string $pageSlug) { $page = $this->pageRepo->getBySlug($bookSlug, $pageSlug); $this->checkOwnablePermission('page-view', $page); - $entitySelection = $request->get('entity_selection', null) ?? null; - $newName = $request->get('name', null); - - try { - $pageCopy = $this->pageRepo->copy($page, $entitySelection, $newName); - } catch (Exception $exception) { - if ($exception instanceof PermissionsException) { - $this->showPermissionError(); - } + $entitySelection = $request->get('entity_selection') ?: null; + $newParent = $entitySelection ? $this->pageRepo->findParentByIdentifier($entitySelection) : $page->getParent(); + if (is_null($newParent)) { $this->showErrorNotification(trans('errors.selected_book_chapter_not_found')); return redirect()->back(); } + $this->checkOwnablePermission('page-create', $newParent); + + $newName = $request->get('name') ?: $page->name; + $pageCopy = $cloner->clonePage($page, $newParent, $newName); $this->showSuccessNotification(trans('entities.pages_copy_success')); return redirect($pageCopy->getUrl()); diff --git a/app/Http/Controllers/RoleController.php b/app/Http/Controllers/RoleController.php index 7ba52d486..7c72b5970 100644 --- a/app/Http/Controllers/RoleController.php +++ b/app/Http/Controllers/RoleController.php @@ -3,6 +3,7 @@ namespace BookStack\Http\Controllers; use BookStack\Auth\Permissions\PermissionsRepo; +use BookStack\Auth\Role; use BookStack\Exceptions\PermissionsException; use Exception; use Illuminate\Http\Request; @@ -23,7 +24,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(); @@ -34,11 +35,21 @@ class RoleController extends Controller /** * Show the form to create a new role. */ - public function create() + public function create(Request $request) { $this->checkPermission('user-roles-manage'); - return view('settings.roles.create'); + /** @var ?Role $role */ + $role = null; + if ($request->has('copy_from')) { + $role = Role::query()->find($request->get('copy_from')); + } + + if ($role) { + $role->display_name .= ' (' . trans('common.copy') . ')'; + } + + return view('settings.roles.create', ['role' => $role]); } /** @@ -49,7 +60,7 @@ class RoleController extends Controller $this->checkPermission('user-roles-manage'); $this->validate($request, [ 'display_name' => ['required', 'min:3', 'max:180'], - 'description' => 'max:180', + 'description' => ['max:180'], ]); $this->permissionsRepo->saveNewRole($request->all()); @@ -84,7 +95,7 @@ class RoleController extends Controller $this->checkPermission('user-roles-manage'); $this->validate($request, [ 'display_name' => ['required', 'min:3', 'max:180'], - 'description' => 'max:180', + 'description' => ['max:180'], ]); $this->permissionsRepo->updateRole($id, $request->all()); diff --git a/app/Http/Controllers/UserController.php b/app/Http/Controllers/UserController.php index 414bfefeb..a78f921f2 100644 --- a/app/Http/Controllers/UserController.php +++ b/app/Http/Controllers/UserController.php @@ -13,6 +13,7 @@ use BookStack\Uploads\ImageRepo; use Exception; use Illuminate\Http\Request; use Illuminate\Support\Str; +use Illuminate\Validation\Rules\Password; use Illuminate\Validation\ValidationException; class UserController extends Controller @@ -82,7 +83,7 @@ class UserController extends Controller $sendInvite = ($request->get('send_invite', 'false') === 'true'); if ($authMethod === 'standard' && !$sendInvite) { - $validationRules['password'] = ['required', 'min:6']; + $validationRules['password'] = ['required', Password::default()]; $validationRules['password-confirm'] = ['required', 'same:password']; } elseif ($authMethod === 'ldap' || $authMethod === 'saml2' || $authMethod === 'openid') { $validationRules['external_auth_id'] = ['required']; @@ -155,11 +156,11 @@ class UserController extends Controller $this->checkPermissionOrCurrentUser('users-manage', $id); $this->validate($request, [ - 'name' => 'min:2', + 'name' => ['min:2'], 'email' => ['min:2', 'email', 'unique:users,email,' . $id], - 'password' => ['min:6', 'required_with:password_confirm'], + 'password' => ['required_with:password_confirm', Password::default()], 'password-confirm' => ['same:password', 'required_with:password'], - 'setting' => 'array', + 'setting' => ['array'], 'profile_image' => array_merge(['nullable'], $this->getImageValidationRules()), ]); diff --git a/app/Http/Controllers/UserProfileController.php b/app/Http/Controllers/UserProfileController.php index 09ae4c1bd..63565f3b2 100644 --- a/app/Http/Controllers/UserProfileController.php +++ b/app/Http/Controllers/UserProfileController.php @@ -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); diff --git a/app/Http/Controllers/WebhookController.php b/app/Http/Controllers/WebhookController.php new file mode 100644 index 000000000..eca3002c6 --- /dev/null +++ b/app/Http/Controllers/WebhookController.php @@ -0,0 +1,124 @@ +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'); + } +} diff --git a/app/Providers/AuthServiceProvider.php b/app/Providers/AuthServiceProvider.php index 4a626e4fa..b301604a5 100644 --- a/app/Providers/AuthServiceProvider.php +++ b/app/Providers/AuthServiceProvider.php @@ -11,6 +11,7 @@ use BookStack\Auth\Access\LoginService; use BookStack\Auth\Access\RegistrationService; use Illuminate\Support\Facades\Auth; use Illuminate\Support\ServiceProvider; +use Illuminate\Validation\Rules\Password; class AuthServiceProvider extends ServiceProvider { @@ -21,6 +22,12 @@ class AuthServiceProvider extends ServiceProvider */ public function boot() { + // Password Configuration + Password::defaults(function () { + return Password::min(8); + }); + + // Custom guards Auth::extend('api-token', function ($app, $name, array $config) { return new ApiTokenGuard($app['request'], $app->make(LoginService::class)); }); diff --git a/app/Providers/CustomFacadeProvider.php b/app/Providers/CustomFacadeProvider.php index ca86b6607..0518af44f 100644 --- a/app/Providers/CustomFacadeProvider.php +++ b/app/Providers/CustomFacadeProvider.php @@ -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 () { diff --git a/database/factories/Actions/WebhookFactory.php b/database/factories/Actions/WebhookFactory.php new file mode 100644 index 000000000..205156793 --- /dev/null +++ b/database/factories/Actions/WebhookFactory.php @@ -0,0 +1,25 @@ + 'My webhook for ' . $this->faker->country(), + 'endpoint' => $this->faker->url, + 'active' => true, + ]; + } +} diff --git a/database/factories/Actions/WebhookTrackedEventFactory.php b/database/factories/Actions/WebhookTrackedEventFactory.php new file mode 100644 index 000000000..71b8774bc --- /dev/null +++ b/database/factories/Actions/WebhookTrackedEventFactory.php @@ -0,0 +1,23 @@ + Webhook::factory(), + 'event' => ActivityType::all()[array_rand(ActivityType::all())], + ]; + } +} diff --git a/database/factories/Auth/UserFactory.php b/database/factories/Auth/UserFactory.php index 77d63ac68..805782fd8 100644 --- a/database/factories/Auth/UserFactory.php +++ b/database/factories/Auth/UserFactory.php @@ -2,6 +2,7 @@ namespace Database\Factories\Auth; +use BookStack\Auth\User; use Illuminate\Database\Eloquent\Factories\Factory; use Illuminate\Support\Str; @@ -12,7 +13,7 @@ class UserFactory extends Factory * * @var string */ - protected $model = \BookStack\Auth\User::class; + protected $model = User::class; /** * Define the model's default state. @@ -26,7 +27,7 @@ class UserFactory extends Factory return [ 'name' => $name, 'email' => $this->faker->email, - 'slug' => \Illuminate\Support\Str::slug($name . '-' . \Illuminate\Support\Str::random(5)), + 'slug' => Str::slug($name . '-' . Str::random(5)), 'password' => Str::random(10), 'remember_token' => Str::random(10), 'email_confirmed' => 1, diff --git a/database/migrations/2021_11_26_070438_add_index_for_user_ip.php b/database/migrations/2021_11_26_070438_add_index_for_user_ip.php new file mode 100644 index 000000000..eebab7958 --- /dev/null +++ b/database/migrations/2021_11_26_070438_add_index_for_user_ip.php @@ -0,0 +1,32 @@ +index('ip', 'activities_ip_index'); + }); + } + + /** + * Reverse the migrations. + * + * @return void + */ + public function down() + { + Schema::table('activities', function (Blueprint $table) { + $table->dropIndex('activities_ip_index'); + }); + } +} diff --git a/database/migrations/2021_12_07_111343_create_webhooks_table.php b/database/migrations/2021_12_07_111343_create_webhooks_table.php new file mode 100644 index 000000000..be4fc539d --- /dev/null +++ b/database/migrations/2021_12_07_111343_create_webhooks_table.php @@ -0,0 +1,48 @@ +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'); + } +} diff --git a/database/migrations/2021_12_13_152024_create_jobs_table.php b/database/migrations/2021_12_13_152024_create_jobs_table.php new file mode 100644 index 000000000..1be9e8a80 --- /dev/null +++ b/database/migrations/2021_12_13_152024_create_jobs_table.php @@ -0,0 +1,36 @@ +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'); + } +} diff --git a/database/migrations/2021_12_13_152120_create_failed_jobs_table.php b/database/migrations/2021_12_13_152120_create_failed_jobs_table.php new file mode 100644 index 000000000..6aa6d743e --- /dev/null +++ b/database/migrations/2021_12_13_152120_create_failed_jobs_table.php @@ -0,0 +1,36 @@ +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'); + } +} diff --git a/resources/icons/webhooks.svg b/resources/icons/webhooks.svg new file mode 100644 index 000000000..fff081413 --- /dev/null +++ b/resources/icons/webhooks.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/resources/js/components/index.js b/resources/js/components/index.js index 010ee04ba..fe348aba7 100644 --- a/resources/js/components/index.js +++ b/resources/js/components/index.js @@ -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, }; diff --git a/resources/js/components/webhook-events.js b/resources/js/components/webhook-events.js new file mode 100644 index 000000000..aa50aa9d8 --- /dev/null +++ b/resources/js/components/webhook-events.js @@ -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; \ No newline at end of file diff --git a/resources/lang/en/activities.php b/resources/lang/en/activities.php index 50bda60bd..83a374d66 100644 --- a/resources/lang/en/activities.php +++ b/resources/lang/en/activities.php @@ -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', diff --git a/resources/lang/en/auth.php b/resources/lang/en/auth.php index 107585bf0..ad0d516bb 100644 --- a/resources/lang/en/auth.php +++ b/resources/lang/en/auth.php @@ -21,7 +21,7 @@ return [ 'email' => 'Email', 'password' => 'Password', 'password_confirm' => 'Confirm Password', - 'password_hint' => 'Must be over 7 characters', + 'password_hint' => 'Must be at least 8 characters', 'forgot_password' => 'Forgot Password?', 'remember_me' => 'Remember Me', 'ldap_email_hint' => 'Please enter an email to use for this account.', diff --git a/resources/lang/en/common.php b/resources/lang/en/common.php index 722bf00db..53db3cf40 100644 --- a/resources/lang/en/common.php +++ b/resources/lang/en/common.php @@ -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', diff --git a/resources/lang/en/entities.php b/resources/lang/en/entities.php index 5cf47629a..4e4bbccd3 100644 --- a/resources/lang/en/entities.php +++ b/resources/lang/en/entities.php @@ -143,6 +143,8 @@ return [ 'books_sort_chapters_last' => 'Chapters Last', 'books_sort_show_other' => 'Show Other Books', 'books_sort_save' => 'Save New Order', + 'books_copy' => 'Copy Book', + 'books_copy_success' => 'Book successfully copied', // Chapters 'chapter' => 'Chapter', @@ -161,6 +163,8 @@ return [ 'chapters_move' => 'Move Chapter', 'chapters_move_named' => 'Move Chapter :chapterName', 'chapter_move_success' => 'Chapter moved to :bookName', + 'chapters_copy' => 'Copy Chapter', + 'chapters_copy_success' => 'Chapter successfully copied', 'chapters_permissions' => 'Chapter Permissions', 'chapters_empty' => 'No pages are currently in this chapter.', 'chapters_permissions_active' => 'Chapter Permissions Active', @@ -332,4 +336,12 @@ return [ 'revision_restore_confirm' => 'Are you sure you want to restore this revision? The current page contents will be replaced.', 'revision_delete_success' => 'Revision deleted', 'revision_cannot_delete_latest' => 'Cannot delete the latest revision.', + + // Copy view + 'copy_consider' => 'Please consider the below when copying content.', + 'copy_consider_permissions' => 'Custom permission settings will not be copied.', + 'copy_consider_owner' => 'You will become the owner of all copied content.', + 'copy_consider_images' => 'Page image files will not be duplicated & the original images will retain their relation to the page they were originally uploaded to.', + 'copy_consider_attachments' => 'Page attachments will not be copied.', + 'copy_consider_access' => 'A change of location, owner or permissions may result in this content being accessible to those previously without access.', ]; diff --git a/resources/lang/en/settings.php b/resources/lang/en/settings.php index 688b0aad8..08d4e7294 100755 --- a/resources/lang/en/settings.php +++ b/resources/lang/en/settings.php @@ -174,7 +174,7 @@ return [ 'users_role' => 'User Roles', 'users_role_desc' => 'Select which roles this user will be assigned to. If a user is assigned to multiple roles the permissions from those roles will stack and they will receive all abilities of the assigned roles.', 'users_password' => 'User Password', - 'users_password_desc' => 'Set a password used to log-in to the application. This must be at least 6 characters long.', + 'users_password_desc' => 'Set a password used to log-in to the application. This must be at least 8 characters long.', 'users_send_invite_text' => 'You can choose to send this user an invitation email which allows them to set their own password otherwise you can set their password yourself.', 'users_send_invite_option' => 'Send user invite email', 'users_external_auth_id' => 'External Authentication ID', @@ -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. //!//////////////////////////////// diff --git a/resources/views/api-docs/parts/getting-started.blade.php b/resources/views/api-docs/parts/getting-started.blade.php index ca28a7d90..3bcf29dd4 100644 --- a/resources/views/api-docs/parts/getting-started.blade.php +++ b/resources/views/api-docs/parts/getting-started.blade.php @@ -1,5 +1,27 @@

Getting Started

+

+ This documentation covers use of the REST API.
+ Some alternative options for extension and customization can be found below: +

+ + + +
+
Authentication

To access the API a user has to have the "Access System API" permission enabled on one of their assigned roles. diff --git a/resources/views/books/copy.blade.php b/resources/views/books/copy.blade.php new file mode 100644 index 000000000..293397a97 --- /dev/null +++ b/resources/views/books/copy.blade.php @@ -0,0 +1,40 @@ +@extends('layouts.simple') + +@section('body') + +

+ +
+ @include('entities.breadcrumbs', ['crumbs' => [ + $book, + $book->getUrl('/copy') => [ + 'text' => trans('entities.books_copy'), + 'icon' => 'copy', + ] + ]]) +
+ +
+ +

{{ trans('entities.books_copy') }}

+ +
+ {!! csrf_field() !!} + +
+ + @include('form.text', ['name' => 'name']) +
+ + @include('entities.copy-considerations') + +
+ {{ trans('common.cancel') }} + +
+
+ +
+
+ +@stop diff --git a/resources/views/books/show.blade.php b/resources/views/books/show.blade.php index 25a6f69fa..5263bc810 100644 --- a/resources/views/books/show.blade.php +++ b/resources/views/books/show.blade.php @@ -110,6 +110,12 @@ {{ trans('common.sort') }} @endif + @if(userCan('book-create-all')) + + @icon('copy') + {{ trans('common.copy') }} + + @endif @if(userCan('restrictions-manage', $book)) @icon('lock') diff --git a/resources/views/chapters/copy.blade.php b/resources/views/chapters/copy.blade.php new file mode 100644 index 000000000..3fd5de1ff --- /dev/null +++ b/resources/views/chapters/copy.blade.php @@ -0,0 +1,50 @@ +@extends('layouts.simple') + +@section('body') + +
+ +
+ @include('entities.breadcrumbs', ['crumbs' => [ + $chapter->book, + $chapter, + $chapter->getUrl('/copy') => [ + 'text' => trans('entities.chapters_copy'), + 'icon' => 'copy', + ] + ]]) +
+ +
+ +

{{ trans('entities.chapters_copy') }}

+ +
+ {!! csrf_field() !!} + +
+ + @include('form.text', ['name' => 'name']) +
+ +
+ +
+ @include('entities.selector', ['name' => 'entity_selection', 'selectorSize' => 'large', 'entityTypes' => 'book', 'entityPermission' => 'chapter-create']) +
+
+ + @include('entities.copy-considerations') + +
+ {{ trans('common.cancel') }} + +
+
+ +
+
+ +@stop diff --git a/resources/views/chapters/show.blade.php b/resources/views/chapters/show.blade.php index 1646d4f18..edd39edde 100644 --- a/resources/views/chapters/show.blade.php +++ b/resources/views/chapters/show.blade.php @@ -108,6 +108,12 @@ {{ trans('common.edit') }} @endif + @if(userCanOnAny('chapter-create')) + + @icon('copy') + {{ trans('common.copy') }} + + @endif @if(userCan('chapter-update', $chapter) && userCan('chapter-delete', $chapter)) @icon('folder') diff --git a/resources/views/common/activity-item.blade.php b/resources/views/common/activity-item.blade.php index eebfb591a..89d44b152 100644 --- a/resources/views/common/activity-item.blade.php +++ b/resources/views/common/activity-item.blade.php @@ -24,8 +24,6 @@ "{{ $activity->entity->name }}" @endif - @if($activity->extra) "{{ $activity->extra }}" @endif -
@icon('time'){{ $activity->created_at->diffForHumans() }} diff --git a/resources/views/entities/copy-considerations.blade.php b/resources/views/entities/copy-considerations.blade.php new file mode 100644 index 000000000..6fe50ef98 --- /dev/null +++ b/resources/views/entities/copy-considerations.blade.php @@ -0,0 +1,15 @@ +

+ @icon('warning') {{ trans('entities.copy_consider') }} +

+ +
+ + +
\ No newline at end of file diff --git a/resources/views/form/errors.blade.php b/resources/views/form/errors.blade.php new file mode 100644 index 000000000..03cd4be88 --- /dev/null +++ b/resources/views/form/errors.blade.php @@ -0,0 +1,3 @@ +@if($errors->has($name)) +
{{ $errors->first($name) }}
+@endif \ No newline at end of file diff --git a/resources/views/pages/copy.blade.php b/resources/views/pages/copy.blade.php index 2f24d8165..9f249863a 100644 --- a/resources/views/pages/copy.blade.php +++ b/resources/views/pages/copy.blade.php @@ -37,6 +37,8 @@ + @include('entities.copy-considerations') +
{{ trans('common.cancel') }} diff --git a/resources/views/settings/audit.blade.php b/resources/views/settings/audit.blade.php index 84f180f3b..48e46a59d 100644 --- a/resources/views/settings/audit.blade.php +++ b/resources/views/settings/audit.blade.php @@ -10,7 +10,7 @@
-

{{ trans('settings.audit') }}

+

{{ trans('settings.audit') }}

{{ trans('settings.audit_desc') }}

@@ -41,12 +41,19 @@
@endforeach -
@include('form.user-select', ['user' => $listDetails['user'] ? \BookStack\Auth\User::query()->find($listDetails['user']) : null, 'name' => 'user', 'compact' => true])
+ + +
+ + @include('form.text', ['name' => 'ip', 'model' => (object) $listDetails]) + +
diff --git a/resources/views/settings/parts/navbar-with-version.blade.php b/resources/views/settings/parts/navbar-with-version.blade.php index 09af699a3..bec41146b 100644 --- a/resources/views/settings/parts/navbar-with-version.blade.php +++ b/resources/views/settings/parts/navbar-with-version.blade.php @@ -6,10 +6,12 @@ $version - Version of bookstack to display
@include('settings.parts.navbar', ['selected' => $selected])
-
-
- - BookStack @if(strpos($version, 'v') !== 0) version @endif {{ $version }} - -
+ +
+
+
+
+ + BookStack @if(strpos($version, 'v') !== 0) version @endif {{ $version }} +
\ No newline at end of file diff --git a/resources/views/settings/parts/navbar.blade.php b/resources/views/settings/parts/navbar.blade.php index a472196c5..f2fad378c 100644 --- a/resources/views/settings/parts/navbar.blade.php +++ b/resources/views/settings/parts/navbar.blade.php @@ -13,4 +13,7 @@ @if(userCan('user-roles-manage')) @icon('lock-open'){{ trans('settings.roles') }} @endif + @if(userCan('settings-manage')) + @icon('webhooks'){{ trans('settings.webhooks') }} + @endif \ No newline at end of file diff --git a/resources/views/settings/roles/create.blade.php b/resources/views/settings/roles/create.blade.php index f2edfa1c5..72afc60a8 100644 --- a/resources/views/settings/roles/create.blade.php +++ b/resources/views/settings/roles/create.blade.php @@ -8,9 +8,21 @@ @include('settings.parts.navbar', ['selected' => 'roles']) -
- @include('settings.roles.parts.form', ['title' => trans('settings.role_create')]) -
+
+

{{ trans('settings.role_create') }}

+ +
+ {{ csrf_field() }} + + @include('settings.roles.parts.form', ['role' => $role ?? null]) + +
+ {{ trans('common.cancel') }} + +
+
+ +
@stop diff --git a/resources/views/settings/roles/edit.blade.php b/resources/views/settings/roles/edit.blade.php index e2018d3e9..dda8db39d 100644 --- a/resources/views/settings/roles/edit.blade.php +++ b/resources/views/settings/roles/edit.blade.php @@ -7,10 +7,53 @@ @include('settings.parts.navbar', ['selected' => 'roles']) -
id}") }}" method="POST"> - - @include('settings.roles.parts.form', ['model' => $role, 'title' => trans('settings.role_edit'), 'icon' => 'edit']) -
+
+

{{ trans('settings.role_edit') }}

+ +
id}") }}" method="POST"> + {{ csrf_field() }} + {{ method_field('PUT') }} + + @include('settings.roles.parts.form', ['role' => $role]) + + +
+ +
+ + +
+

{{ trans('settings.role_users') }}

+ @if(count($role->users ?? []) > 0) +
+ @foreach($role->users as $user) +
+
+ {{ $user->name }} +
+
+ @if(userCan('users-manage') || user()->id == $user->id) + id}") }}"> + @endif + {{ $user->name }} + @if(userCan('users-manage') || user()->id == $user->id) + + @endif +
+
+ @endforeach +
+ @else +

+ {{ trans('settings.role_users_none') }} +

+ @endif +
@stop diff --git a/resources/views/settings/roles/parts/form.blade.php b/resources/views/settings/roles/parts/form.blade.php index 9cea9e1fb..a15117e5e 100644 --- a/resources/views/settings/roles/parts/form.blade.php +++ b/resources/views/settings/roles/parts/form.blade.php @@ -1,267 +1,224 @@ -{!! csrf_field() !!} - -
-

{{ $title }}

- -
- -
-
- -
-
-
- - @include('form.text', ['name' => 'display_name']) -
-
- - @include('form.text', ['name' => 'description']) -
-
- @include('form.checkbox', ['name' => 'mfa_enforced', 'label' => trans('settings.role_mfa_enforced') ]) -
- - @if(in_array(config('auth.method'), ['ldap', 'saml2', 'oidc'])) -
- - @include('form.text', ['name' => 'external_auth_id']) -
- @endif -
-
- -
- - {{ trans('common.toggle_all') }} - -
-
-
@include('settings.roles.parts.checkbox', ['permission' => 'restrictions-manage-all', 'label' => trans('settings.role_manage_entity_permissions')])
-
@include('settings.roles.parts.checkbox', ['permission' => 'restrictions-manage-own', 'label' => trans('settings.role_manage_own_entity_permissions')])
-
@include('settings.roles.parts.checkbox', ['permission' => 'templates-manage', 'label' => trans('settings.role_manage_page_templates')])
-
@include('settings.roles.parts.checkbox', ['permission' => 'access-api', 'label' => trans('settings.role_access_api')])
-
@include('settings.roles.parts.checkbox', ['permission' => 'content-export', 'label' => trans('settings.role_export_content')])
-
-
-
@include('settings.roles.parts.checkbox', ['permission' => 'settings-manage', 'label' => trans('settings.role_manage_settings')])
-
@include('settings.roles.parts.checkbox', ['permission' => 'users-manage', 'label' => trans('settings.role_manage_users')])
-
@include('settings.roles.parts.checkbox', ['permission' => 'user-roles-manage', 'label' => trans('settings.role_manage_roles')])
-

{{ trans('settings.roles_system_warning') }}

-
-
-
+
+
- -

{{ trans('settings.role_asset_desc') }}

- - @if (isset($role) && $role->system_name === 'admin') -

{{ trans('settings.role_asset_admins') }}

- @endif - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
- {{ trans('common.toggle_all') }} - {{ trans('common.create') }}{{ trans('common.view') }}{{ trans('common.edit') }}{{ trans('common.delete') }}
-
{{ trans('entities.shelves_long') }}
- {{ trans('common.toggle_all') }} -
- @include('settings.roles.parts.checkbox', ['permission' => 'bookshelf-create-all', 'label' => trans('settings.role_all')]) - - @include('settings.roles.parts.checkbox', ['permission' => 'bookshelf-view-own', 'label' => trans('settings.role_own')]) -
- @include('settings.roles.parts.checkbox', ['permission' => 'bookshelf-view-all', 'label' => trans('settings.role_all')]) -
- @include('settings.roles.parts.checkbox', ['permission' => 'bookshelf-update-own', 'label' => trans('settings.role_own')]) -
- @include('settings.roles.parts.checkbox', ['permission' => 'bookshelf-update-all', 'label' => trans('settings.role_all')]) -
- @include('settings.roles.parts.checkbox', ['permission' => 'bookshelf-delete-own', 'label' => trans('settings.role_own')]) -
- @include('settings.roles.parts.checkbox', ['permission' => 'bookshelf-delete-all', 'label' => trans('settings.role_all')]) -
-
{{ trans('entities.books') }}
- {{ trans('common.toggle_all') }} -
- @include('settings.roles.parts.checkbox', ['permission' => 'book-create-all', 'label' => trans('settings.role_all')]) - - @include('settings.roles.parts.checkbox', ['permission' => 'book-view-own', 'label' => trans('settings.role_own')]) -
- @include('settings.roles.parts.checkbox', ['permission' => 'book-view-all', 'label' => trans('settings.role_all')]) -
- @include('settings.roles.parts.checkbox', ['permission' => 'book-update-own', 'label' => trans('settings.role_own')]) -
- @include('settings.roles.parts.checkbox', ['permission' => 'book-update-all', 'label' => trans('settings.role_all')]) -
- @include('settings.roles.parts.checkbox', ['permission' => 'book-delete-own', 'label' => trans('settings.role_own')]) -
- @include('settings.roles.parts.checkbox', ['permission' => 'book-delete-all', 'label' => trans('settings.role_all')]) -
-
{{ trans('entities.chapters') }}
- {{ trans('common.toggle_all') }} -
- @include('settings.roles.parts.checkbox', ['permission' => 'chapter-create-own', 'label' => trans('settings.role_own')]) -
- @include('settings.roles.parts.checkbox', ['permission' => 'chapter-create-all', 'label' => trans('settings.role_all')]) -
- @include('settings.roles.parts.checkbox', ['permission' => 'chapter-view-own', 'label' => trans('settings.role_own')]) -
- @include('settings.roles.parts.checkbox', ['permission' => 'chapter-view-all', 'label' => trans('settings.role_all')]) -
- @include('settings.roles.parts.checkbox', ['permission' => 'chapter-update-own', 'label' => trans('settings.role_own')]) -
- @include('settings.roles.parts.checkbox', ['permission' => 'chapter-update-all', 'label' => trans('settings.role_all')]) -
- @include('settings.roles.parts.checkbox', ['permission' => 'chapter-delete-own', 'label' => trans('settings.role_own')]) -
- @include('settings.roles.parts.checkbox', ['permission' => 'chapter-delete-all', 'label' => trans('settings.role_all')]) -
-
{{ trans('entities.pages') }}
- {{ trans('common.toggle_all') }} -
- @include('settings.roles.parts.checkbox', ['permission' => 'page-create-own', 'label' => trans('settings.role_own')]) -
- @include('settings.roles.parts.checkbox', ['permission' => 'page-create-all', 'label' => trans('settings.role_all')]) -
- @include('settings.roles.parts.checkbox', ['permission' => 'page-view-own', 'label' => trans('settings.role_own')]) -
- @include('settings.roles.parts.checkbox', ['permission' => 'page-view-all', 'label' => trans('settings.role_all')]) -
- @include('settings.roles.parts.checkbox', ['permission' => 'page-update-own', 'label' => trans('settings.role_own')]) -
- @include('settings.roles.parts.checkbox', ['permission' => 'page-update-all', 'label' => trans('settings.role_all')]) -
- @include('settings.roles.parts.checkbox', ['permission' => 'page-delete-own', 'label' => trans('settings.role_own')]) -
- @include('settings.roles.parts.checkbox', ['permission' => 'page-delete-all', 'label' => trans('settings.role_all')]) -
-
{{ trans('entities.images') }}
- {{ trans('common.toggle_all') }} -
@include('settings.roles.parts.checkbox', ['permission' => 'image-create-all', 'label' => '']){{ trans('settings.role_controlled_by_asset') }} - @include('settings.roles.parts.checkbox', ['permission' => 'image-update-own', 'label' => trans('settings.role_own')]) -
- @include('settings.roles.parts.checkbox', ['permission' => 'image-update-all', 'label' => trans('settings.role_all')]) -
- @include('settings.roles.parts.checkbox', ['permission' => 'image-delete-own', 'label' => trans('settings.role_own')]) -
- @include('settings.roles.parts.checkbox', ['permission' => 'image-delete-all', 'label' => trans('settings.role_all')]) -
-
{{ trans('entities.attachments') }}
- {{ trans('common.toggle_all') }} -
@include('settings.roles.parts.checkbox', ['permission' => 'attachment-create-all', 'label' => '']){{ trans('settings.role_controlled_by_asset') }} - @include('settings.roles.parts.checkbox', ['permission' => 'attachment-update-own', 'label' => trans('settings.role_own')]) -
- @include('settings.roles.parts.checkbox', ['permission' => 'attachment-update-all', 'label' => trans('settings.role_all')]) -
- @include('settings.roles.parts.checkbox', ['permission' => 'attachment-delete-own', 'label' => trans('settings.role_own')]) -
- @include('settings.roles.parts.checkbox', ['permission' => 'attachment-delete-all', 'label' => trans('settings.role_all')]) -
-
{{ trans('entities.comments') }}
- {{ trans('common.toggle_all') }} -
@include('settings.roles.parts.checkbox', ['permission' => 'comment-create-all', 'label' => '']){{ trans('settings.role_controlled_by_asset') }} - @include('settings.roles.parts.checkbox', ['permission' => 'comment-update-own', 'label' => trans('settings.role_own')]) -
- @include('settings.roles.parts.checkbox', ['permission' => 'comment-update-all', 'label' => trans('settings.role_all')]) -
- @include('settings.roles.parts.checkbox', ['permission' => 'comment-delete-own', 'label' => trans('settings.role_own')]) -
- @include('settings.roles.parts.checkbox', ['permission' => 'comment-delete-all', 'label' => trans('settings.role_all')]) -
+
-
+
+
+ + @include('form.text', ['name' => 'display_name', 'model' => $role]) +
+
+ + @include('form.text', ['name' => 'description', 'model' => $role]) +
+
+ @include('form.checkbox', ['name' => 'mfa_enforced', 'label' => trans('settings.role_mfa_enforced'), 'model' => $role ]) +
-
- {{ trans('common.cancel') }} - @if (isset($role) && $role->id) - id}") }}" class="button outline">{{ trans('settings.role_delete') }} - @endif - -
- -
- -
-

{{ trans('settings.role_users') }}

- @if(count($role->users ?? []) > 0) -
- @foreach($role->users as $user) -
-
- {{ $user->name }} -
-
- @if(userCan('users-manage') || user()->id == $user->id) - id}") }}"> - @endif - {{ $user->name }} - @if(userCan('users-manage') || user()->id == $user->id) - - @endif -
+ @if(in_array(config('auth.method'), ['ldap', 'saml2', 'oidc'])) +
+ + @include('form.text', ['name' => 'external_auth_id', 'model' => $role])
- @endforeach + @endif
- @else -

- {{ trans('settings.role_users_none') }} -

- @endif -
+
+ +
+ + {{ trans('common.toggle_all') }} + +
+
+
@include('settings.roles.parts.checkbox', ['permission' => 'restrictions-manage-all', 'label' => trans('settings.role_manage_entity_permissions')])
+
@include('settings.roles.parts.checkbox', ['permission' => 'restrictions-manage-own', 'label' => trans('settings.role_manage_own_entity_permissions')])
+
@include('settings.roles.parts.checkbox', ['permission' => 'templates-manage', 'label' => trans('settings.role_manage_page_templates')])
+
@include('settings.roles.parts.checkbox', ['permission' => 'access-api', 'label' => trans('settings.role_access_api')])
+
@include('settings.roles.parts.checkbox', ['permission' => 'content-export', 'label' => trans('settings.role_export_content')])
+
+
+
@include('settings.roles.parts.checkbox', ['permission' => 'settings-manage', 'label' => trans('settings.role_manage_settings')])
+
@include('settings.roles.parts.checkbox', ['permission' => 'users-manage', 'label' => trans('settings.role_manage_users')])
+
@include('settings.roles.parts.checkbox', ['permission' => 'user-roles-manage', 'label' => trans('settings.role_manage_roles')])
+

{{ trans('settings.roles_system_warning') }}

+
+
+
+ +
+ +

{{ trans('settings.role_asset_desc') }}

+ + @if (isset($role) && $role->system_name === 'admin') +

{{ trans('settings.role_asset_admins') }}

+ @endif + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ {{ trans('common.toggle_all') }} + {{ trans('common.create') }}{{ trans('common.view') }}{{ trans('common.edit') }}{{ trans('common.delete') }}
+
{{ trans('entities.shelves_long') }}
+ {{ trans('common.toggle_all') }} +
+ @include('settings.roles.parts.checkbox', ['permission' => 'bookshelf-create-all', 'label' => trans('settings.role_all')]) + + @include('settings.roles.parts.checkbox', ['permission' => 'bookshelf-view-own', 'label' => trans('settings.role_own')]) +
+ @include('settings.roles.parts.checkbox', ['permission' => 'bookshelf-view-all', 'label' => trans('settings.role_all')]) +
+ @include('settings.roles.parts.checkbox', ['permission' => 'bookshelf-update-own', 'label' => trans('settings.role_own')]) +
+ @include('settings.roles.parts.checkbox', ['permission' => 'bookshelf-update-all', 'label' => trans('settings.role_all')]) +
+ @include('settings.roles.parts.checkbox', ['permission' => 'bookshelf-delete-own', 'label' => trans('settings.role_own')]) +
+ @include('settings.roles.parts.checkbox', ['permission' => 'bookshelf-delete-all', 'label' => trans('settings.role_all')]) +
+
{{ trans('entities.books') }}
+ {{ trans('common.toggle_all') }} +
+ @include('settings.roles.parts.checkbox', ['permission' => 'book-create-all', 'label' => trans('settings.role_all')]) + + @include('settings.roles.parts.checkbox', ['permission' => 'book-view-own', 'label' => trans('settings.role_own')]) +
+ @include('settings.roles.parts.checkbox', ['permission' => 'book-view-all', 'label' => trans('settings.role_all')]) +
+ @include('settings.roles.parts.checkbox', ['permission' => 'book-update-own', 'label' => trans('settings.role_own')]) +
+ @include('settings.roles.parts.checkbox', ['permission' => 'book-update-all', 'label' => trans('settings.role_all')]) +
+ @include('settings.roles.parts.checkbox', ['permission' => 'book-delete-own', 'label' => trans('settings.role_own')]) +
+ @include('settings.roles.parts.checkbox', ['permission' => 'book-delete-all', 'label' => trans('settings.role_all')]) +
+
{{ trans('entities.chapters') }}
+ {{ trans('common.toggle_all') }} +
+ @include('settings.roles.parts.checkbox', ['permission' => 'chapter-create-own', 'label' => trans('settings.role_own')]) +
+ @include('settings.roles.parts.checkbox', ['permission' => 'chapter-create-all', 'label' => trans('settings.role_all')]) +
+ @include('settings.roles.parts.checkbox', ['permission' => 'chapter-view-own', 'label' => trans('settings.role_own')]) +
+ @include('settings.roles.parts.checkbox', ['permission' => 'chapter-view-all', 'label' => trans('settings.role_all')]) +
+ @include('settings.roles.parts.checkbox', ['permission' => 'chapter-update-own', 'label' => trans('settings.role_own')]) +
+ @include('settings.roles.parts.checkbox', ['permission' => 'chapter-update-all', 'label' => trans('settings.role_all')]) +
+ @include('settings.roles.parts.checkbox', ['permission' => 'chapter-delete-own', 'label' => trans('settings.role_own')]) +
+ @include('settings.roles.parts.checkbox', ['permission' => 'chapter-delete-all', 'label' => trans('settings.role_all')]) +
+
{{ trans('entities.pages') }}
+ {{ trans('common.toggle_all') }} +
+ @include('settings.roles.parts.checkbox', ['permission' => 'page-create-own', 'label' => trans('settings.role_own')]) +
+ @include('settings.roles.parts.checkbox', ['permission' => 'page-create-all', 'label' => trans('settings.role_all')]) +
+ @include('settings.roles.parts.checkbox', ['permission' => 'page-view-own', 'label' => trans('settings.role_own')]) +
+ @include('settings.roles.parts.checkbox', ['permission' => 'page-view-all', 'label' => trans('settings.role_all')]) +
+ @include('settings.roles.parts.checkbox', ['permission' => 'page-update-own', 'label' => trans('settings.role_own')]) +
+ @include('settings.roles.parts.checkbox', ['permission' => 'page-update-all', 'label' => trans('settings.role_all')]) +
+ @include('settings.roles.parts.checkbox', ['permission' => 'page-delete-own', 'label' => trans('settings.role_own')]) +
+ @include('settings.roles.parts.checkbox', ['permission' => 'page-delete-all', 'label' => trans('settings.role_all')]) +
+
{{ trans('entities.images') }}
+ {{ trans('common.toggle_all') }} +
@include('settings.roles.parts.checkbox', ['permission' => 'image-create-all', 'label' => '']){{ trans('settings.role_controlled_by_asset') }} + @include('settings.roles.parts.checkbox', ['permission' => 'image-update-own', 'label' => trans('settings.role_own')]) +
+ @include('settings.roles.parts.checkbox', ['permission' => 'image-update-all', 'label' => trans('settings.role_all')]) +
+ @include('settings.roles.parts.checkbox', ['permission' => 'image-delete-own', 'label' => trans('settings.role_own')]) +
+ @include('settings.roles.parts.checkbox', ['permission' => 'image-delete-all', 'label' => trans('settings.role_all')]) +
+
{{ trans('entities.attachments') }}
+ {{ trans('common.toggle_all') }} +
@include('settings.roles.parts.checkbox', ['permission' => 'attachment-create-all', 'label' => '']){{ trans('settings.role_controlled_by_asset') }} + @include('settings.roles.parts.checkbox', ['permission' => 'attachment-update-own', 'label' => trans('settings.role_own')]) +
+ @include('settings.roles.parts.checkbox', ['permission' => 'attachment-update-all', 'label' => trans('settings.role_all')]) +
+ @include('settings.roles.parts.checkbox', ['permission' => 'attachment-delete-own', 'label' => trans('settings.role_own')]) +
+ @include('settings.roles.parts.checkbox', ['permission' => 'attachment-delete-all', 'label' => trans('settings.role_all')]) +
+
{{ trans('entities.comments') }}
+ {{ trans('common.toggle_all') }} +
@include('settings.roles.parts.checkbox', ['permission' => 'comment-create-all', 'label' => '']){{ trans('settings.role_controlled_by_asset') }} + @include('settings.roles.parts.checkbox', ['permission' => 'comment-update-own', 'label' => trans('settings.role_own')]) +
+ @include('settings.roles.parts.checkbox', ['permission' => 'comment-update-all', 'label' => trans('settings.role_all')]) +
+ @include('settings.roles.parts.checkbox', ['permission' => 'comment-delete-own', 'label' => trans('settings.role_own')]) +
+ @include('settings.roles.parts.checkbox', ['permission' => 'comment-delete-all', 'label' => trans('settings.role_all')]) +
+
+
\ No newline at end of file diff --git a/resources/views/settings/webhooks/create.blade.php b/resources/views/settings/webhooks/create.blade.php new file mode 100644 index 000000000..4f20dd077 --- /dev/null +++ b/resources/views/settings/webhooks/create.blade.php @@ -0,0 +1,18 @@ +@extends('layouts.simple') + +@section('body') + +
+ +
+ @include('settings.parts.navbar', ['selected' => 'webhooks']) +
+ +
+ @include('settings.webhooks.parts.form', ['title' => trans('settings.webhooks_create')]) +
+ + @include('settings.webhooks.parts.format-example') +
+ +@stop diff --git a/resources/views/settings/webhooks/delete.blade.php b/resources/views/settings/webhooks/delete.blade.php new file mode 100644 index 000000000..65560f65f --- /dev/null +++ b/resources/views/settings/webhooks/delete.blade.php @@ -0,0 +1,39 @@ +@extends('layouts.simple') + +@section('body') +
+ +
+ @include('settings.parts.navbar', ['selected' => 'webhooks']) +
+ +
+

{{ trans('settings.webhooks_delete') }}

+ +

{{ trans('settings.webhooks_delete_warning', ['webhookName' => $webhook->name]) }}

+ + +
+ {!! csrf_field() !!} + {!! method_field('DELETE') !!} + +
+
+

+ {{ trans('settings.webhooks_delete_confirm') }} +

+
+
+
+ {{ trans('common.cancel') }} + +
+
+
+ + +
+
+ +
+@stop diff --git a/resources/views/settings/webhooks/edit.blade.php b/resources/views/settings/webhooks/edit.blade.php new file mode 100644 index 000000000..3b297eb7b --- /dev/null +++ b/resources/views/settings/webhooks/edit.blade.php @@ -0,0 +1,18 @@ +@extends('layouts.simple') + +@section('body') + +
+
+ @include('settings.parts.navbar', ['selected' => 'webhooks']) +
+ +
+ {!! method_field('PUT') !!} + @include('settings.webhooks.parts.form', ['model' => $webhook, 'title' => trans('settings.webhooks_edit')]) +
+ + @include('settings.webhooks.parts.format-example') +
+ +@stop diff --git a/resources/views/settings/webhooks/index.blade.php b/resources/views/settings/webhooks/index.blade.php new file mode 100644 index 000000000..d6423b6fb --- /dev/null +++ b/resources/views/settings/webhooks/index.blade.php @@ -0,0 +1,59 @@ +@extends('layouts.simple') + +@section('body') + +
+ +
+ @include('settings.parts.navbar', ['selected' => 'webhooks']) +
+ +
+ +
+

{{ trans('settings.webhooks') }}

+ + +
+ + @if(count($webhooks) > 0) + + + + + + + + @foreach($webhooks as $webhook) + + + + + + @endforeach +
{{ trans('common.name') }}{{ trans('settings.webhook_events_table_header') }}{{ trans('common.status') }}
+ {{ $webhook->name }}
+ {{ $webhook->endpoint }} +
+ @if($webhook->tracksEvent('all')) + {{ trans('settings.webhooks_events_all') }} + @else + {{ $webhook->trackedEvents->count() }} + @endif + + {{ trans('common.status_' . ($webhook->active ? 'active' : 'inactive')) }} +
+ @else +

+ {{ trans('settings.webhooks_none_created') }} +

+ @endif + + +
+
+ +@stop diff --git a/resources/views/settings/webhooks/parts/form.blade.php b/resources/views/settings/webhooks/parts/form.blade.php new file mode 100644 index 000000000..458b6767b --- /dev/null +++ b/resources/views/settings/webhooks/parts/form.blade.php @@ -0,0 +1,75 @@ +{!! csrf_field() !!} + +
+

{{ $title }}

+ +
+ +
+
+ +

{{ trans('settings.webhooks_details_desc') }}

+
+ @include('form.toggle-switch', [ + 'name' => 'active', + 'value' => old('active') ?? $model->active ?? true, + 'label' => trans('settings.webhooks_active'), + ]) + @include('form.errors', ['name' => 'active']) +
+
+
+
+ + @include('form.text', ['name' => 'name']) +
+
+ + @include('form.text', ['name' => 'endpoint']) +
+
+
+ +
+ + @include('form.errors', ['name' => 'events']) + +

{{ trans('settings.webhooks_events_desc') }}

+

{{ trans('settings.webhooks_events_warning') }}

+ +
+ @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), + ]) +
+ +
+ +
+ @foreach(\BookStack\Actions\ActivityType::all() as $activityType) +
+ @include('form.custom-checkbox', [ + 'name' => 'events[]', + 'value' => $activityType, + 'label' => $activityType, + 'checked' => old('events') ? in_array($activityType, old('events')) : (isset($webhook) ? $webhook->tracksEvent($activityType) : false), + ]) +
+ @endforeach +
+
+ +
+ +
+ {{ trans('common.cancel') }} + @if ($webhook->id ?? false) + {{ trans('settings.webhooks_delete') }} + @endif + +
+ +
diff --git a/resources/views/settings/webhooks/parts/format-example.blade.php b/resources/views/settings/webhooks/parts/format-example.blade.php new file mode 100644 index 000000000..135d3193b --- /dev/null +++ b/resources/views/settings/webhooks/parts/format-example.blade.php @@ -0,0 +1,34 @@ +
+

{{ trans('settings.webhooks_format_example') }}

+

{{ trans('settings.webhooks_format_example_desc') }}

+
{
+    "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
+    }
+}
+
\ No newline at end of file diff --git a/routes/web.php b/routes/web.php index c924ed68c..73cc3dc66 100644 --- a/routes/web.php +++ b/routes/web.php @@ -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']); @@ -76,6 +80,8 @@ Route::middleware('auth')->group(function () { Route::get('/books/{bookSlug}/permissions', [BookController::class, 'showPermissions']); Route::put('/books/{bookSlug}/permissions', [BookController::class, 'permissions']); Route::get('/books/{slug}/delete', [BookController::class, 'showDelete']); + Route::get('/books/{bookSlug}/copy', [BookController::class, 'showCopy']); + Route::post('/books/{bookSlug}/copy', [BookController::class, 'copy']); Route::get('/books/{bookSlug}/sort', [BookSortController::class, 'show']); Route::put('/books/{bookSlug}/sort', [BookSortController::class, 'update']); Route::get('/books/{bookSlug}/export/html', [BookExportController::class, 'html']); @@ -123,6 +129,8 @@ Route::middleware('auth')->group(function () { Route::put('/books/{bookSlug}/chapter/{chapterSlug}', [ChapterController::class, 'update']); Route::get('/books/{bookSlug}/chapter/{chapterSlug}/move', [ChapterController::class, 'showMove']); Route::put('/books/{bookSlug}/chapter/{chapterSlug}/move', [ChapterController::class, 'move']); + Route::get('/books/{bookSlug}/chapter/{chapterSlug}/copy', [ChapterController::class, 'showCopy']); + Route::post('/books/{bookSlug}/chapter/{chapterSlug}/copy', [ChapterController::class, 'copy']); Route::get('/books/{bookSlug}/chapter/{chapterSlug}/edit', [ChapterController::class, 'edit']); Route::get('/books/{bookSlug}/chapter/{chapterSlug}/permissions', [ChapterController::class, 'showPermissions']); Route::get('/books/{bookSlug}/chapter/{chapterSlug}/export/pdf', [ChapterExportController::class, 'pdf']); @@ -244,13 +252,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 +308,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']); diff --git a/tests/AuditLogTest.php b/tests/Actions/AuditLogTest.php similarity index 77% rename from tests/AuditLogTest.php rename to tests/Actions/AuditLogTest.php index f909cd79a..8266fd972 100644 --- a/tests/AuditLogTest.php +++ b/tests/Actions/AuditLogTest.php @@ -1,9 +1,10 @@ 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); @@ -166,6 +169,32 @@ class AuditLogTest extends TestCase $resp->assertSee('192.123.45.1'); } + public function test_ip_address_is_searchable() + { + config()->set('app.proxies', '*'); + $editor = $this->getEditor(); + /** @var Page $page */ + $page = Page::query()->first(); + + $this->actingAs($editor)->put($page->getUrl(), [ + 'name' => 'Updated page', + 'html' => '

Updated content

', + ], [ + 'X-Forwarded-For' => '192.123.45.1', + ])->assertRedirect($page->refresh()->getUrl()); + + $this->actingAs($editor)->put($page->getUrl(), [ + 'name' => 'Updated page', + 'html' => '

Updated content

', + ], [ + 'X-Forwarded-For' => '192.122.45.1', + ])->assertRedirect($page->refresh()->getUrl()); + + $resp = $this->asAdmin()->get('/settings/audit?&ip=192.123'); + $resp->assertSee('192.123.45.1'); + $resp->assertDontSee('192.122.45.1'); + } + public function test_ip_address_not_logged_in_demo_mode() { config()->set('app.proxies', '*'); diff --git a/tests/Actions/WebhookCallTest.php b/tests/Actions/WebhookCallTest.php new file mode 100644 index 000000000..7abf3fdb9 --- /dev/null +++ b/tests/Actions/WebhookCallTest.php @@ -0,0 +1,114 @@ +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; + } +} diff --git a/tests/Actions/WebhookManagementTest.php b/tests/Actions/WebhookManagementTest.php new file mode 100644 index 000000000..206913c74 --- /dev/null +++ b/tests/Actions/WebhookManagementTest.php @@ -0,0 +1,171 @@ +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; + } +} diff --git a/tests/Commands/ClearActivityCommandTest.php b/tests/Commands/ClearActivityCommandTest.php index 172e6c6ae..71baa0ca6 100644 --- a/tests/Commands/ClearActivityCommandTest.php +++ b/tests/Commands/ClearActivityCommandTest.php @@ -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'); diff --git a/tests/Entity/BookTest.php b/tests/Entity/BookTest.php index 2894fbb98..7f102a17e 100644 --- a/tests/Entity/BookTest.php +++ b/tests/Entity/BookTest.php @@ -3,10 +3,15 @@ namespace Tests\Entity; use BookStack\Entities\Models\Book; +use BookStack\Entities\Models\BookChild; +use BookStack\Entities\Repos\BookRepo; use Tests\TestCase; +use Tests\Uploads\UsesImages; class BookTest extends TestCase { + use UsesImages; + public function test_create() { $book = Book::factory()->make([ @@ -204,4 +209,88 @@ class BookTest extends TestCase $this->assertEquals('parta-partb-partc', $book->slug); } + + public function test_show_view_has_copy_button() + { + /** @var Book $book */ + $book = Book::query()->first(); + $resp = $this->asEditor()->get($book->getUrl()); + + $resp->assertElementContains("a[href=\"{$book->getUrl('/copy')}\"]", 'Copy'); + } + + public function test_copy_view() + { + /** @var Book $book */ + $book = Book::query()->first(); + $resp = $this->asEditor()->get($book->getUrl('/copy')); + + $resp->assertOk(); + $resp->assertSee('Copy Book'); + $resp->assertElementExists("input[name=\"name\"][value=\"{$book->name}\"]"); + } + + public function test_copy() + { + /** @var Book $book */ + $book = Book::query()->whereHas('chapters')->whereHas('pages')->first(); + $resp = $this->asEditor()->post($book->getUrl('/copy'), ['name' => 'My copy book']); + + /** @var Book $copy */ + $copy = Book::query()->where('name', '=', 'My copy book')->first(); + + $resp->assertRedirect($copy->getUrl()); + $this->assertEquals($book->getDirectChildren()->count(), $copy->getDirectChildren()->count()); + } + + public function test_copy_does_not_copy_non_visible_content() + { + /** @var Book $book */ + $book = Book::query()->whereHas('chapters')->whereHas('pages')->first(); + + // Hide child content + /** @var BookChild $page */ + foreach ($book->getDirectChildren() as $child) { + $child->restricted = true; + $child->save(); + $this->regenEntityPermissions($child); + } + + $this->asEditor()->post($book->getUrl('/copy'), ['name' => 'My copy book']); + /** @var Book $copy */ + $copy = Book::query()->where('name', '=', 'My copy book')->first(); + + $this->assertEquals(0, $copy->getDirectChildren()->count()); + } + + public function test_copy_does_not_copy_pages_or_chapters_if_user_cant_create() + { + /** @var Book $book */ + $book = Book::query()->whereHas('chapters')->whereHas('directPages')->whereHas('chapters')->first(); + $viewer = $this->getViewer(); + $this->giveUserPermissions($viewer, ['book-create-all']); + + $this->actingAs($viewer)->post($book->getUrl('/copy'), ['name' => 'My copy book']); + /** @var Book $copy */ + $copy = Book::query()->where('name', '=', 'My copy book')->first(); + + $this->assertEquals(0, $copy->pages()->count()); + $this->assertEquals(0, $copy->chapters()->count()); + } + + public function test_copy_clones_cover_image_if_existing() + { + /** @var Book $book */ + $book = Book::query()->first(); + $bookRepo = $this->app->make(BookRepo::class); + $coverImageFile = $this->getTestImage('cover.png'); + $bookRepo->updateCoverImage($book, $coverImageFile); + + $this->asEditor()->post($book->getUrl('/copy'), ['name' => 'My copy book']); + + /** @var Book $copy */ + $copy = Book::query()->where('name', '=', 'My copy book')->first(); + $this->assertNotNull($copy->cover); + $this->assertNotEquals($book->cover->id, $copy->cover->id); + } } diff --git a/tests/Entity/ChapterTest.php b/tests/Entity/ChapterTest.php index 9868dc030..f099ca2bb 100644 --- a/tests/Entity/ChapterTest.php +++ b/tests/Entity/ChapterTest.php @@ -4,6 +4,7 @@ namespace Tests\Entity; use BookStack\Entities\Models\Book; use BookStack\Entities\Models\Chapter; +use BookStack\Entities\Models\Page; use Tests\TestCase; class ChapterTest extends TestCase @@ -54,4 +55,95 @@ class ChapterTest extends TestCase $redirectReq = $this->get($deleteReq->baseResponse->headers->get('location')); $redirectReq->assertNotificationContains('Chapter Successfully Deleted'); } + + public function test_show_view_has_copy_button() + { + /** @var Chapter $chapter */ + $chapter = Chapter::query()->first(); + + $resp = $this->asEditor()->get($chapter->getUrl()); + $resp->assertElementContains("a[href$=\"{$chapter->getUrl('/copy')}\"]", 'Copy'); + } + + public function test_copy_view() + { + /** @var Chapter $chapter */ + $chapter = Chapter::query()->first(); + + $resp = $this->asEditor()->get($chapter->getUrl('/copy')); + $resp->assertOk(); + $resp->assertSee('Copy Chapter'); + $resp->assertElementExists("input[name=\"name\"][value=\"{$chapter->name}\"]"); + $resp->assertElementExists('input[name="entity_selection"]'); + } + + public function test_copy() + { + /** @var Chapter $chapter */ + $chapter = Chapter::query()->whereHas('pages')->first(); + /** @var Book $otherBook */ + $otherBook = Book::query()->where('id', '!=', $chapter->book_id)->first(); + + $resp = $this->asEditor()->post($chapter->getUrl('/copy'), [ + 'name' => 'My copied chapter', + 'entity_selection' => 'book:' . $otherBook->id, + ]); + + /** @var Chapter $newChapter */ + $newChapter = Chapter::query()->where('name', '=', 'My copied chapter')->first(); + + $resp->assertRedirect($newChapter->getUrl()); + $this->assertEquals($otherBook->id, $newChapter->book_id); + $this->assertEquals($chapter->pages->count(), $newChapter->pages->count()); + } + + public function test_copy_does_not_copy_non_visible_pages() + { + /** @var Chapter $chapter */ + $chapter = Chapter::query()->whereHas('pages')->first(); + + // Hide pages to all non-admin roles + /** @var Page $page */ + foreach ($chapter->pages as $page) { + $page->restricted = true; + $page->save(); + $this->regenEntityPermissions($page); + } + + $this->asEditor()->post($chapter->getUrl('/copy'), [ + 'name' => 'My copied chapter', + ]); + + /** @var Chapter $newChapter */ + $newChapter = Chapter::query()->where('name', '=', 'My copied chapter')->first(); + $this->assertEquals(0, $newChapter->pages()->count()); + } + + public function test_copy_does_not_copy_pages_if_user_cant_page_create() + { + /** @var Chapter $chapter */ + $chapter = Chapter::query()->whereHas('pages')->first(); + $viewer = $this->getViewer(); + $this->giveUserPermissions($viewer, ['chapter-create-all']); + + // Lacking permission results in no copied pages + $this->actingAs($viewer)->post($chapter->getUrl('/copy'), [ + 'name' => 'My copied chapter', + ]); + + /** @var Chapter $newChapter */ + $newChapter = Chapter::query()->where('name', '=', 'My copied chapter')->first(); + $this->assertEquals(0, $newChapter->pages()->count()); + + $this->giveUserPermissions($viewer, ['page-create-all']); + + // Having permission rules in copied pages + $this->actingAs($viewer)->post($chapter->getUrl('/copy'), [ + 'name' => 'My copied again chapter', + ]); + + /** @var Chapter $newChapter2 */ + $newChapter2 = Chapter::query()->where('name', '=', 'My copied again chapter')->first(); + $this->assertEquals($chapter->pages()->count(), $newChapter2->pages()->count()); + } } diff --git a/tests/Entity/EntitySearchTest.php b/tests/Entity/EntitySearchTest.php index 2b7bfd08d..ab5777e98 100644 --- a/tests/Entity/EntitySearchTest.php +++ b/tests/Entity/EntitySearchTest.php @@ -403,6 +403,17 @@ class EntitySearchTest extends TestCase $search->assertElementContains('.tag-value.highlight', 'MeowieCat'); } + public function test_match_highlighting_works_with_multibyte_content() + { + $this->newPage([ + 'name' => 'Test Page', + 'html' => '

На мен ми трябва нещо добро test

', + ]); + + $search = $this->asEditor()->get('/search?term=' . urlencode('На мен ми трябва нещо добро')); + $search->assertSee('На мен ми трябва нещо добро test', false); + } + public function test_html_entities_in_item_details_remains_escaped_in_search_results() { $this->newPage(['name' => 'My TestPageContent', 'html' => '

My supercool <great> TestPageContent page

']); diff --git a/tests/Permissions/RolesTest.php b/tests/Permissions/RolesTest.php index c880bdd00..f69b5603c 100644 --- a/tests/Permissions/RolesTest.php +++ b/tests/Permissions/RolesTest.php @@ -163,6 +163,23 @@ class RolesTest extends TestCase $this->assertEquals($this->user->id, $roleA->users()->first()->id); } + public function test_copy_role_button_shown() + { + /** @var Role $role */ + $role = Role::query()->first(); + $resp = $this->asAdmin()->get("/settings/roles/{$role->id}"); + $resp->assertElementContains('a[href$="/roles/new?copy_from=' . $role->id . '"]', 'Copy'); + } + + public function test_copy_from_param_on_create_prefills_with_other_role_data() + { + /** @var Role $role */ + $role = Role::query()->first(); + $resp = $this->asAdmin()->get("/settings/roles/new?copy_from={$role->id}"); + $resp->assertOk(); + $resp->assertElementExists('input[name="display_name"][value="' . ($role->display_name . ' (Copy)') . '"]'); + } + public function test_manage_user_permission() { $this->actingAs($this->user)->get('/settings/users')->assertRedirect('/'); diff --git a/tests/StatusTest.php b/tests/StatusTest.php index 37b1b15a1..82c377615 100644 --- a/tests/StatusTest.php +++ b/tests/StatusTest.php @@ -1,10 +1,13 @@ app[Dispatcher::class] = $mockDispatcher; $exception = new \Exception('A random error occurred when testing an email'); - $mockDispatcher->shouldReceive('send')->andThrow($exception); + $mockDispatcher->shouldReceive('sendNow')->andThrow($exception); $admin = $this->getAdmin(); $sendReq = $this->actingAs($admin)->post('/settings/maintenance/send-test-email'); diff --git a/tests/Uploads/AvatarTest.php b/tests/Uploads/AvatarTest.php index d10b5cfc6..650f5b4ea 100644 --- a/tests/Uploads/AvatarTest.php +++ b/tests/Uploads/AvatarTest.php @@ -11,16 +11,16 @@ class AvatarTest extends TestCase { use UsesImages; - protected function createUserRequest($user) + protected function createUserRequest($user): User { $this->asAdmin()->post('/settings/users/create', [ 'name' => $user->name, 'email' => $user->email, - 'password' => 'testing', - 'password-confirm' => 'testing', + 'password' => 'testing101', + 'password-confirm' => 'testing101', ]); - return User::where('email', '=', $user->email)->first(); + return User::query()->where('email', '=', $user->email)->first(); } protected function assertImageFetchFrom(string $url) diff --git a/tests/User/UserProfileTest.php b/tests/User/UserProfileTest.php index c3888f8c5..869368975 100644 --- a/tests/User/UserProfileTest.php +++ b/tests/User/UserProfileTest.php @@ -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('/')