1 <?php namespace BookStack\Entities\Repos;
4 use BookStack\Actions\TagRepo;
5 use BookStack\Actions\ViewService;
6 use BookStack\Auth\Permissions\PermissionService;
7 use BookStack\Auth\User;
8 use BookStack\Entities\Book;
9 use BookStack\Entities\Bookshelf;
10 use BookStack\Entities\Chapter;
11 use BookStack\Entities\Entity;
12 use BookStack\Entities\EntityProvider;
13 use BookStack\Entities\Page;
14 use BookStack\Entities\SearchService;
15 use BookStack\Exceptions\NotFoundException;
16 use BookStack\Exceptions\NotifyException;
17 use BookStack\Uploads\AttachmentService;
21 use Illuminate\Contracts\Pagination\LengthAwarePaginator;
22 use Illuminate\Database\Eloquent\Builder;
23 use Illuminate\Http\Request;
24 use Illuminate\Support\Collection;
33 protected $entityProvider;
36 * @var PermissionService
38 protected $permissionService;
43 protected $viewService;
53 protected $searchService;
56 * EntityRepo constructor.
57 * @param EntityProvider $entityProvider
58 * @param ViewService $viewService
59 * @param PermissionService $permissionService
60 * @param TagRepo $tagRepo
61 * @param SearchService $searchService
63 public function __construct(
64 EntityProvider $entityProvider,
65 ViewService $viewService,
66 PermissionService $permissionService,
68 SearchService $searchService
70 $this->entityProvider = $entityProvider;
71 $this->viewService = $viewService;
72 $this->permissionService = $permissionService;
73 $this->tagRepo = $tagRepo;
74 $this->searchService = $searchService;
78 * Base query for searching entities via permission system
80 * @param bool $allowDrafts
81 * @param string $permission
82 * @return \Illuminate\Database\Query\Builder
84 protected function entityQuery($type, $allowDrafts = false, $permission = 'view')
86 $q = $this->permissionService->enforceEntityRestrictions($type, $this->entityProvider->get($type), $permission);
87 if (strtolower($type) === 'page' && !$allowDrafts) {
88 $q = $q->where('draft', '=', false);
94 * Check if an entity with the given id exists.
99 public function exists($type, $id)
101 return $this->entityQuery($type)->where('id', '=', $id)->exists();
105 * Get an entity by ID
106 * @param string $type
108 * @param bool $allowDrafts
109 * @param bool $ignorePermissions
112 public function getById($type, $id, $allowDrafts = false, $ignorePermissions = false)
114 $query = $this->entityQuery($type, $allowDrafts);
116 if ($ignorePermissions) {
117 $query = $this->entityProvider->get($type)->newQuery();
120 return $query->find($id);
124 * @param string $type
126 * @param bool $allowDrafts
127 * @param bool $ignorePermissions
128 * @return Builder[]|\Illuminate\Database\Eloquent\Collection|Collection
130 public function getManyById($type, $ids, $allowDrafts = false, $ignorePermissions = false)
132 $query = $this->entityQuery($type, $allowDrafts);
134 if ($ignorePermissions) {
135 $query = $this->entityProvider->get($type)->newQuery();
138 return $query->whereIn('id', $ids)->get();
142 * Get an entity by its url slug.
143 * @param string $type
144 * @param string $slug
145 * @param string|bool $bookSlug
147 * @throws NotFoundException
149 public function getBySlug($type, $slug, $bookSlug = false)
151 $q = $this->entityQuery($type)->where('slug', '=', $slug);
153 if (strtolower($type) === 'chapter' || strtolower($type) === 'page') {
154 $q = $q->where('book_id', '=', function ($query) use ($bookSlug) {
156 ->from($this->entityProvider->book->getTable())
157 ->where('slug', '=', $bookSlug)->limit(1);
160 $entity = $q->first();
161 if ($entity === null) {
162 throw new NotFoundException(trans('errors.' . strtolower($type) . '_not_found'));
169 * Get all entities of a type with the given permission, limited by count unless count is false.
170 * @param string $type
171 * @param integer|bool $count
172 * @param string $permission
175 public function getAll($type, $count = 20, $permission = 'view')
177 $q = $this->entityQuery($type, false, $permission)->orderBy('name', 'asc');
178 if ($count !== false) {
179 $q = $q->take($count);
185 * Get all entities in a paginated format
188 * @param string $sort
189 * @param string $order
190 * @param null|callable $queryAddition
191 * @return LengthAwarePaginator
193 public function getAllPaginated($type, int $count = 10, string $sort = 'name', string $order = 'asc', $queryAddition = null)
195 $query = $this->entityQuery($type);
196 $query = $this->addSortToQuery($query, $sort, $order);
197 if ($queryAddition) {
198 $queryAddition($query);
200 return $query->paginate($count);
204 * Add sorting operations to an entity query.
205 * @param Builder $query
206 * @param string $sort
207 * @param string $order
210 protected function addSortToQuery(Builder $query, string $sort = 'name', string $order = 'asc')
212 $order = ($order === 'asc') ? 'asc' : 'desc';
213 $propertySorts = ['name', 'created_at', 'updated_at'];
215 if (in_array($sort, $propertySorts)) {
216 return $query->orderBy($sort, $order);
223 * Get the most recently created entities of the given type.
224 * @param string $type
227 * @param bool|callable $additionalQuery
230 public function getRecentlyCreated($type, $count = 20, $page = 0, $additionalQuery = false)
232 $query = $this->permissionService->enforceEntityRestrictions($type, $this->entityProvider->get($type))
233 ->orderBy('created_at', 'desc');
234 if (strtolower($type) === 'page') {
235 $query = $query->where('draft', '=', false);
237 if ($additionalQuery !== false && is_callable($additionalQuery)) {
238 $additionalQuery($query);
240 return $query->skip($page * $count)->take($count)->get();
244 * Get the most recently updated entities of the given type.
245 * @param string $type
248 * @param bool|callable $additionalQuery
251 public function getRecentlyUpdated($type, $count = 20, $page = 0, $additionalQuery = false)
253 $query = $this->permissionService->enforceEntityRestrictions($type, $this->entityProvider->get($type))
254 ->orderBy('updated_at', 'desc');
255 if (strtolower($type) === 'page') {
256 $query = $query->where('draft', '=', false);
258 if ($additionalQuery !== false && is_callable($additionalQuery)) {
259 $additionalQuery($query);
261 return $query->skip($page * $count)->take($count)->get();
265 * Get the most recently viewed entities.
266 * @param string|bool $type
271 public function getRecentlyViewed($type, $count = 10, $page = 0)
273 $filter = is_bool($type) ? false : $this->entityProvider->get($type);
274 return $this->viewService->getUserRecentlyViewed($count, $page, $filter);
278 * Get the latest pages added to the system with pagination.
279 * @param string $type
283 public function getRecentlyCreatedPaginated($type, $count = 20)
285 return $this->entityQuery($type)->orderBy('created_at', 'desc')->paginate($count);
289 * Get the latest pages added to the system with pagination.
290 * @param string $type
294 public function getRecentlyUpdatedPaginated($type, $count = 20)
296 return $this->entityQuery($type)->orderBy('updated_at', 'desc')->paginate($count);
300 * Get the most popular entities base on all views.
301 * @param string $type
306 public function getPopular(string $type, int $count = 10, int $page = 0)
308 return $this->viewService->getPopular($count, $page, $type);
312 * Get draft pages owned by the current user.
317 public function getUserDraftPages($count = 20, $page = 0)
319 return $this->entityProvider->page->where('draft', '=', true)
320 ->where('created_by', '=', user()->id)
321 ->orderBy('updated_at', 'desc')
322 ->skip($count * $page)->take($count)->get();
326 * Get the number of entities the given user has created.
327 * @param string $type
331 public function getUserTotalCreated(string $type, User $user)
333 return $this->entityProvider->get($type)
334 ->where('created_by', '=', $user->id)->count();
338 * Get the child items for a chapter sorted by priority but
339 * with draft items floated to the top.
340 * @param Bookshelf $bookshelf
341 * @return \Illuminate\Database\Eloquent\Collection|static[]
343 public function getBookshelfChildren(Bookshelf $bookshelf)
345 return $this->permissionService->enforceEntityRestrictions('book', $bookshelf->books())->get();
349 * Get the direct children of a book.
351 * @return \Illuminate\Database\Eloquent\Collection
353 public function getBookDirectChildren(Book $book)
355 $pages = $this->permissionService->enforceEntityRestrictions('page', $book->directPages())->get();
356 $chapters = $this->permissionService->enforceEntityRestrictions('chapters', $book->chapters())->get();
357 return collect()->concat($pages)->concat($chapters)->sortBy('priority')->sortByDesc('draft');
361 * Get all child objects of a book.
362 * Returns a sorted collection of Pages and Chapters.
363 * Loads the book slug onto child elements to prevent access database access for getting the slug.
365 * @param bool $filterDrafts
366 * @param bool $renderPages
369 public function getBookChildren(Book $book, $filterDrafts = false, $renderPages = false)
371 $q = $this->permissionService->bookChildrenQuery($book->id, $filterDrafts, $renderPages)->get();
376 foreach ($q as $index => $rawEntity) {
377 if ($rawEntity->entity_type === $this->entityProvider->page->getMorphClass()) {
378 $entities[$index] = $this->entityProvider->page->newFromBuilder($rawEntity);
380 $entities[$index]->html = $rawEntity->html;
381 $entities[$index]->html = $this->renderPage($entities[$index]);
383 } else if ($rawEntity->entity_type === $this->entityProvider->chapter->getMorphClass()) {
384 $entities[$index] = $this->entityProvider->chapter->newFromBuilder($rawEntity);
385 $key = $entities[$index]->entity_type . ':' . $entities[$index]->id;
386 $parents[$key] = $entities[$index];
387 $parents[$key]->setAttribute('pages', collect());
389 if ($entities[$index]->chapter_id === 0 || $entities[$index]->chapter_id === '0') {
390 $tree[] = $entities[$index];
392 $entities[$index]->book = $book;
395 foreach ($entities as $entity) {
396 if ($entity->chapter_id === 0 || $entity->chapter_id === '0') {
399 $parentKey = $this->entityProvider->chapter->getMorphClass() . ':' . $entity->chapter_id;
400 if (!isset($parents[$parentKey])) {
404 $chapter = $parents[$parentKey];
405 $chapter->pages->push($entity);
408 return collect($tree);
412 * Get the child items for a chapter sorted by priority but
413 * with draft items floated to the top.
414 * @param Chapter $chapter
415 * @return \Illuminate\Database\Eloquent\Collection|static[]
417 public function getChapterChildren(Chapter $chapter)
419 return $this->permissionService->enforceEntityRestrictions('page', $chapter->pages())
420 ->orderBy('draft', 'DESC')->orderBy('priority', 'ASC')->get();
425 * Get the next sequential priority for a new child element in the given book.
429 public function getNewBookPriority(Book $book)
431 $lastElem = $this->getBookChildren($book)->pop();
432 return $lastElem ? $lastElem->priority + 1 : 0;
436 * Get a new priority for a new page to be added to the given chapter.
437 * @param Chapter $chapter
440 public function getNewChapterPriority(Chapter $chapter)
442 $lastPage = $chapter->pages('DESC')->first();
443 return $lastPage !== null ? $lastPage->priority + 1 : 0;
447 * Find a suitable slug for an entity.
448 * @param string $type
449 * @param string $name
450 * @param bool|integer $currentId
451 * @param bool|integer $bookId Only pass if type is not a book
454 public function findSuitableSlug($type, $name, $currentId = false, $bookId = false)
456 $slug = $this->nameToSlug($name);
457 while ($this->slugExists($type, $slug, $currentId, $bookId)) {
458 $slug .= '-' . substr(md5(rand(1, 500)), 0, 3);
464 * Check if a slug already exists in the database.
465 * @param string $type
466 * @param string $slug
467 * @param bool|integer $currentId
468 * @param bool|integer $bookId
471 protected function slugExists($type, $slug, $currentId = false, $bookId = false)
473 $query = $this->entityProvider->get($type)->where('slug', '=', $slug);
474 if (strtolower($type) === 'page' || strtolower($type) === 'chapter') {
475 $query = $query->where('book_id', '=', $bookId);
478 $query = $query->where('id', '!=', $currentId);
480 return $query->count() > 0;
484 * Updates entity restrictions from a request
485 * @param Request $request
486 * @param Entity $entity
489 public function updateEntityPermissionsFromRequest(Request $request, Entity $entity)
491 $entity->restricted = $request->get('restricted', '') === 'true';
492 $entity->permissions()->delete();
494 if ($request->filled('restrictions')) {
495 foreach ($request->get('restrictions') as $roleId => $restrictions) {
496 foreach ($restrictions as $action => $value) {
497 $entity->permissions()->create([
498 'role_id' => $roleId,
499 'action' => strtolower($action)
506 $this->permissionService->buildJointPermissionsForEntity($entity);
512 * Create a new entity from request input.
513 * Used for books and chapters.
514 * @param string $type
515 * @param array $input
516 * @param bool|Book $book
519 public function createFromInput($type, $input = [], $book = false)
521 $isChapter = strtolower($type) === 'chapter';
522 $entityModel = $this->entityProvider->get($type)->newInstance($input);
523 $entityModel->slug = $this->findSuitableSlug($type, $entityModel->name, false, $isChapter ? $book->id : false);
524 $entityModel->created_by = user()->id;
525 $entityModel->updated_by = user()->id;
526 $isChapter ? $book->chapters()->save($entityModel) : $entityModel->save();
528 if (isset($input['tags'])) {
529 $this->tagRepo->saveTagsToEntity($entityModel, $input['tags']);
532 $this->permissionService->buildJointPermissionsForEntity($entityModel);
533 $this->searchService->indexEntity($entityModel);
538 * Update entity details from request input.
539 * Used for books and chapters
540 * @param string $type
541 * @param Entity $entityModel
542 * @param array $input
545 public function updateFromInput($type, Entity $entityModel, $input = [])
547 if ($entityModel->name !== $input['name']) {
548 $entityModel->slug = $this->findSuitableSlug($type, $input['name'], $entityModel->id);
550 $entityModel->fill($input);
551 $entityModel->updated_by = user()->id;
552 $entityModel->save();
554 if (isset($input['tags'])) {
555 $this->tagRepo->saveTagsToEntity($entityModel, $input['tags']);
558 $this->permissionService->buildJointPermissionsForEntity($entityModel);
559 $this->searchService->indexEntity($entityModel);
564 * Sync the books assigned to a shelf from a comma-separated list
566 * @param Bookshelf $shelf
567 * @param string $books
569 public function updateShelfBooks(Bookshelf $shelf, string $books)
571 $ids = explode(',', $books);
573 // Check books exist and match ordering
574 $bookIds = $this->entityQuery('book')->whereIn('id', $ids)->get(['id'])->pluck('id');
576 foreach ($ids as $index => $id) {
577 if ($bookIds->contains($id)) {
578 $syncData[$id] = ['order' => $index];
582 $shelf->books()->sync($syncData);
586 * Append a Book to a BookShelf.
587 * @param Bookshelf $shelf
590 public function appendBookToShelf(Bookshelf $shelf, Book $book)
592 if ($shelf->contains($book)) {
596 $maxOrder = $shelf->books()->max('order');
597 $shelf->books()->attach($book->id, ['order' => $maxOrder + 1]);
601 * Change the book that an entity belongs to.
602 * @param string $type
603 * @param integer $newBookId
604 * @param Entity $entity
605 * @param bool $rebuildPermissions
608 public function changeBook($type, $newBookId, Entity $entity, $rebuildPermissions = false)
610 $entity->book_id = $newBookId;
611 // Update related activity
612 foreach ($entity->activity as $activity) {
613 $activity->book_id = $newBookId;
616 $entity->slug = $this->findSuitableSlug($type, $entity->name, $entity->id, $newBookId);
619 // Update all child pages if a chapter
620 if (strtolower($type) === 'chapter') {
621 foreach ($entity->pages as $page) {
622 $this->changeBook('page', $newBookId, $page, false);
626 // Update permissions if applicable
627 if ($rebuildPermissions) {
628 $entity->load('book');
629 $this->permissionService->buildJointPermissionsForEntity($entity->book);
636 * Alias method to update the book jointPermissions in the PermissionService.
639 public function buildJointPermissionsForBook(Book $book)
641 $this->permissionService->buildJointPermissionsForEntity($book);
645 * Format a name as a url slug.
649 protected function nameToSlug($name)
651 $slug = preg_replace('/[\+\/\\\?\@\}\{\.\,\=\[\]\#\&\!\*\'\;\:\$\%]/', '', mb_strtolower($name));
652 $slug = preg_replace('/\s{2,}/', ' ', $slug);
653 $slug = str_replace(' ', '-', $slug);
655 $slug = substr(md5(rand(1, 500)), 0, 5);
661 * Render the page for viewing
663 * @param bool $blankIncludes
666 public function renderPage(Page $page, bool $blankIncludes = false) : string
668 $content = $page->html;
670 if (!config('app.allow_content_scripts')) {
671 $content = $this->escapeScripts($content);
674 if ($blankIncludes) {
675 $content = $this->blankPageIncludes($content);
677 $content = $this->parsePageIncludes($content);
684 * Remove any page include tags within the given HTML.
685 * @param string $html
688 protected function blankPageIncludes(string $html) : string
690 return preg_replace("/{{@\s?([0-9].*?)}}/", '', $html);
694 * Parse any include tags "{{@<page_id>#section}}" to be part of the page.
695 * @param string $html
696 * @return mixed|string
698 protected function parsePageIncludes(string $html) : string
701 preg_match_all("/{{@\s?([0-9].*?)}}/", $html, $matches);
703 $topLevelTags = ['table', 'ul', 'ol'];
704 foreach ($matches[1] as $index => $includeId) {
705 $splitInclude = explode('#', $includeId, 2);
706 $pageId = intval($splitInclude[0]);
707 if (is_nan($pageId)) {
711 $matchedPage = $this->getById('page', $pageId);
712 if ($matchedPage === null) {
713 $html = str_replace($matches[0][$index], '', $html);
717 if (count($splitInclude) === 1) {
718 $html = str_replace($matches[0][$index], $matchedPage->html, $html);
722 $doc = new DOMDocument();
723 libxml_use_internal_errors(true);
724 $doc->loadHTML(mb_convert_encoding('<body>'.$matchedPage->html.'</body>', 'HTML-ENTITIES', 'UTF-8'));
725 $matchingElem = $doc->getElementById($splitInclude[1]);
726 if ($matchingElem === null) {
727 $html = str_replace($matches[0][$index], '', $html);
731 $isTopLevel = in_array(strtolower($matchingElem->nodeName), $topLevelTags);
733 $innerContent .= $doc->saveHTML($matchingElem);
735 foreach ($matchingElem->childNodes as $childNode) {
736 $innerContent .= $doc->saveHTML($childNode);
739 libxml_clear_errors();
740 $html = str_replace($matches[0][$index], trim($innerContent), $html);
747 * Escape script tags within HTML content.
748 * @param string $html
751 protected function escapeScripts(string $html) : string
757 libxml_use_internal_errors(true);
758 $doc = new DOMDocument();
759 $doc->loadHTML(mb_convert_encoding($html, 'HTML-ENTITIES', 'UTF-8'));
760 $xPath = new DOMXPath($doc);
762 // Remove standard script tags
763 $scriptElems = $xPath->query('//script');
764 foreach ($scriptElems as $scriptElem) {
765 $scriptElem->parentNode->removeChild($scriptElem);
768 // Remove data or JavaScript iFrames
769 $badIframes = $xPath->query('//*[contains(@src, \'data:\')] | //*[contains(@src, \'javascript:\')] | //*[@srcdoc]');
770 foreach ($badIframes as $badIframe) {
771 $badIframe->parentNode->removeChild($badIframe);
774 // Remove 'on*' attributes
775 $onAttributes = $xPath->query('//@*[starts-with(name(), \'on\')]');
776 foreach ($onAttributes as $attr) {
777 /** @var \DOMAttr $attr*/
778 $attrName = $attr->nodeName;
779 $attr->parentNode->removeAttribute($attrName);
783 $topElems = $doc->documentElement->childNodes->item(0)->childNodes;
784 foreach ($topElems as $child) {
785 $html .= $doc->saveHTML($child);
792 * Search for image usage within page content.
793 * @param $imageString
796 public function searchForImage($imageString)
798 $pages = $this->entityQuery('page')->where('html', 'like', '%' . $imageString . '%')->get(['id', 'name', 'slug', 'book_id']);
799 foreach ($pages as $page) {
800 $page->url = $page->getUrl();
804 return count($pages) > 0 ? $pages : false;
808 * Destroy a bookshelf instance
809 * @param Bookshelf $shelf
812 public function destroyBookshelf(Bookshelf $shelf)
814 $this->destroyEntityCommonRelations($shelf);
819 * Destroy the provided book and all its child entities.
821 * @throws NotifyException
824 public function destroyBook(Book $book)
826 foreach ($book->pages as $page) {
827 $this->destroyPage($page);
829 foreach ($book->chapters as $chapter) {
830 $this->destroyChapter($chapter);
832 $this->destroyEntityCommonRelations($book);
837 * Destroy a chapter and its relations.
838 * @param Chapter $chapter
841 public function destroyChapter(Chapter $chapter)
843 if (count($chapter->pages) > 0) {
844 foreach ($chapter->pages as $page) {
845 $page->chapter_id = 0;
849 $this->destroyEntityCommonRelations($chapter);
854 * Destroy a given page along with its dependencies.
856 * @throws NotifyException
859 public function destroyPage(Page $page)
861 // Check if set as custom homepage & remove setting if not used or throw error if active
862 $customHome = setting('app-homepage', '0:');
863 if (intval($page->id) === intval(explode(':', $customHome)[0])) {
864 if (setting('app-homepage-type') === 'page') {
865 throw new NotifyException(trans('errors.page_custom_home_deletion'), $page->getUrl());
867 setting()->remove('app-homepage');
870 $this->destroyEntityCommonRelations($page);
872 // Delete Attached Files
873 $attachmentService = app(AttachmentService::class);
874 foreach ($page->attachments as $attachment) {
875 $attachmentService->deleteFile($attachment);
882 * Destroy or handle the common relations connected to an entity.
883 * @param Entity $entity
886 protected function destroyEntityCommonRelations(Entity $entity)
888 Activity::removeEntity($entity);
889 $entity->views()->delete();
890 $entity->permissions()->delete();
891 $entity->tags()->delete();
892 $entity->comments()->delete();
893 $this->permissionService->deleteJointPermissionsForEntity($entity);
894 $this->searchService->deleteEntityTerms($entity);
898 * Copy the permissions of a bookshelf to all child books.
899 * Returns the number of books that had permissions updated.
900 * @param Bookshelf $bookshelf
904 public function copyBookshelfPermissions(Bookshelf $bookshelf)
906 $shelfPermissions = $bookshelf->permissions()->get(['role_id', 'action'])->toArray();
907 $shelfBooks = $bookshelf->books()->get();
908 $updatedBookCount = 0;
910 foreach ($shelfBooks as $book) {
911 if (!userCan('restrictions-manage', $book)) {
914 $book->permissions()->delete();
915 $book->restricted = $bookshelf->restricted;
916 $book->permissions()->createMany($shelfPermissions);
918 $this->permissionService->buildJointPermissionsForEntity($book);
922 return $updatedBookCount;