]> BookStack Code Mirror - bookstack/blob - app/Entities/EntityRepo.php
2031807eee02fb7ceb4347c18626e505ef0591d3
[bookstack] / app / Entities / EntityRepo.php
1 <?php namespace BookStack\Entities;
2
3 use BookStack\Entities\Book;
4 use BookStack\Entities\Bookshelf;
5 use BookStack\Entities\Chapter;
6 use BookStack\Entities\Entity;
7 use BookStack\Exceptions\NotFoundException;
8 use BookStack\Exceptions\NotifyException;
9 use BookStack\Entities\Page;
10 use BookStack\Entities\PageRevision;
11 use BookStack\Actions\TagRepo;
12 use BookStack\Uploads\AttachmentService;
13 use BookStack\Auth\Permissions\PermissionService;
14 use BookStack\Entities\SearchService;
15 use BookStack\Actions\ViewService;
16 use Carbon\Carbon;
17 use DOMDocument;
18 use DOMXPath;
19 use Illuminate\Support\Collection;
20
21 class EntityRepo
22 {
23     /**
24      * @var \BookStack\Entities\Bookshelf
25      */
26     public $bookshelf;
27
28     /**
29      * @var \BookStack\Entities\Book $book
30      */
31     public $book;
32
33     /**
34      * @var Chapter
35      */
36     public $chapter;
37
38     /**
39      * @var Page
40      */
41     public $page;
42
43     /**
44      * @var PageRevision
45      */
46     protected $pageRevision;
47
48     /**
49      * Base entity instances keyed by type
50      * @var []Entity
51      */
52     protected $entities;
53
54     /**
55      * @var PermissionService
56      */
57     protected $permissionService;
58
59     /**
60      * @var ViewService
61      */
62     protected $viewService;
63
64     /**
65      * @var \BookStack\Actions\TagRepo
66      */
67     protected $tagRepo;
68
69     /**
70      * @var SearchService
71      */
72     protected $searchService;
73
74     /**
75      * EntityRepo constructor.
76      * @param \BookStack\Entities\Bookshelf $bookshelf
77      * @param \BookStack\Entities\Book $book
78      * @param Chapter $chapter
79      * @param \BookStack\Entities\Page $page
80      * @param \BookStack\Entities\PageRevision $pageRevision
81      * @param ViewService $viewService
82      * @param PermissionService $permissionService
83      * @param \BookStack\Actions\TagRepo $tagRepo
84      * @param SearchService $searchService
85      */
86     public function __construct(
87         Bookshelf $bookshelf,
88         Book $book,
89         Chapter $chapter,
90         Page $page,
91         PageRevision $pageRevision,
92         ViewService $viewService,
93         PermissionService $permissionService,
94         TagRepo $tagRepo,
95         SearchService $searchService
96     ) {
97         $this->bookshelf = $bookshelf;
98         $this->book = $book;
99         $this->chapter = $chapter;
100         $this->page = $page;
101         $this->pageRevision = $pageRevision;
102         $this->entities = [
103             'bookshelf' => $this->bookshelf,
104             'page' => $this->page,
105             'chapter' => $this->chapter,
106             'book' => $this->book
107         ];
108         $this->viewService = $viewService;
109         $this->permissionService = $permissionService;
110         $this->tagRepo = $tagRepo;
111         $this->searchService = $searchService;
112     }
113
114     /**
115      * Get an entity instance via type.
116      * @param $type
117      * @return \BookStack\Entities\Entity
118      */
119     protected function getEntity($type)
120     {
121         return $this->entities[strtolower($type)];
122     }
123
124     /**
125      * Base query for searching entities via permission system
126      * @param string $type
127      * @param bool $allowDrafts
128      * @return \Illuminate\Database\Query\Builder
129      */
130     protected function entityQuery($type, $allowDrafts = false, $permission = 'view')
131     {
132         $q = $this->permissionService->enforceEntityRestrictions($type, $this->getEntity($type), $permission);
133         if (strtolower($type) === 'page' && !$allowDrafts) {
134             $q = $q->where('draft', '=', false);
135         }
136         return $q;
137     }
138
139     /**
140      * Check if an entity with the given id exists.
141      * @param $type
142      * @param $id
143      * @return bool
144      */
145     public function exists($type, $id)
146     {
147         return $this->entityQuery($type)->where('id', '=', $id)->exists();
148     }
149
150     /**
151      * Get an entity by ID
152      * @param string $type
153      * @param integer $id
154      * @param bool $allowDrafts
155      * @param bool $ignorePermissions
156      * @return \BookStack\Entities\Entity
157      */
158     public function getById($type, $id, $allowDrafts = false, $ignorePermissions = false)
159     {
160         if ($ignorePermissions) {
161             $entity = $this->getEntity($type);
162             return $entity->newQuery()->find($id);
163         }
164         return $this->entityQuery($type, $allowDrafts)->find($id);
165     }
166
167     /**
168      * Get an entity by its url slug.
169      * @param string $type
170      * @param string $slug
171      * @param string|bool $bookSlug
172      * @return \BookStack\Entities\Entity
173      * @throws NotFoundException
174      */
175     public function getBySlug($type, $slug, $bookSlug = false)
176     {
177         $q = $this->entityQuery($type)->where('slug', '=', $slug);
178
179         if (strtolower($type) === 'chapter' || strtolower($type) === 'page') {
180             $q = $q->where('book_id', '=', function ($query) use ($bookSlug) {
181                 $query->select('id')
182                     ->from($this->book->getTable())
183                     ->where('slug', '=', $bookSlug)->limit(1);
184             });
185         }
186         $entity = $q->first();
187         if ($entity === null) {
188             throw new NotFoundException(trans('errors.' . strtolower($type) . '_not_found'));
189         }
190         return $entity;
191     }
192
193
194     /**
195      * Search through page revisions and retrieve the last page in the
196      * current book that has a slug equal to the one given.
197      * @param string $pageSlug
198      * @param string $bookSlug
199      * @return null|Page
200      */
201     public function getPageByOldSlug($pageSlug, $bookSlug)
202     {
203         $revision = $this->pageRevision->where('slug', '=', $pageSlug)
204             ->whereHas('page', function ($query) {
205                 $this->permissionService->enforceEntityRestrictions('page', $query);
206             })
207             ->where('type', '=', 'version')
208             ->where('book_slug', '=', $bookSlug)
209             ->orderBy('created_at', 'desc')
210             ->with('page')->first();
211         return $revision !== null ? $revision->page : null;
212     }
213
214     /**
215      * Get all entities of a type with the given permission, limited by count unless count is false.
216      * @param string $type
217      * @param integer|bool $count
218      * @param string $permission
219      * @return Collection
220      */
221     public function getAll($type, $count = 20, $permission = 'view')
222     {
223         $q = $this->entityQuery($type, false, $permission)->orderBy('name', 'asc');
224         if ($count !== false) {
225             $q = $q->take($count);
226         }
227         return $q->get();
228     }
229
230     /**
231      * Get all entities in a paginated format
232      * @param $type
233      * @param int $count
234      * @return \Illuminate\Contracts\Pagination\LengthAwarePaginator
235      */
236     public function getAllPaginated($type, $count = 10)
237     {
238         return $this->entityQuery($type)->orderBy('name', 'asc')->paginate($count);
239     }
240
241     /**
242      * Get the most recently created entities of the given type.
243      * @param string $type
244      * @param int $count
245      * @param int $page
246      * @param bool|callable $additionalQuery
247      * @return Collection
248      */
249     public function getRecentlyCreated($type, $count = 20, $page = 0, $additionalQuery = false)
250     {
251         $query = $this->permissionService->enforceEntityRestrictions($type, $this->getEntity($type))
252             ->orderBy('created_at', 'desc');
253         if (strtolower($type) === 'page') {
254             $query = $query->where('draft', '=', false);
255         }
256         if ($additionalQuery !== false && is_callable($additionalQuery)) {
257             $additionalQuery($query);
258         }
259         return $query->skip($page * $count)->take($count)->get();
260     }
261
262     /**
263      * Get the most recently updated entities of the given type.
264      * @param string $type
265      * @param int $count
266      * @param int $page
267      * @param bool|callable $additionalQuery
268      * @return Collection
269      */
270     public function getRecentlyUpdated($type, $count = 20, $page = 0, $additionalQuery = false)
271     {
272         $query = $this->permissionService->enforceEntityRestrictions($type, $this->getEntity($type))
273             ->orderBy('updated_at', 'desc');
274         if (strtolower($type) === 'page') {
275             $query = $query->where('draft', '=', false);
276         }
277         if ($additionalQuery !== false && is_callable($additionalQuery)) {
278             $additionalQuery($query);
279         }
280         return $query->skip($page * $count)->take($count)->get();
281     }
282
283     /**
284      * Get the most recently viewed entities.
285      * @param string|bool $type
286      * @param int $count
287      * @param int $page
288      * @return mixed
289      */
290     public function getRecentlyViewed($type, $count = 10, $page = 0)
291     {
292         $filter = is_bool($type) ? false : $this->getEntity($type);
293         return $this->viewService->getUserRecentlyViewed($count, $page, $filter);
294     }
295
296     /**
297      * Get the latest pages added to the system with pagination.
298      * @param string $type
299      * @param int $count
300      * @return mixed
301      */
302     public function getRecentlyCreatedPaginated($type, $count = 20)
303     {
304         return $this->entityQuery($type)->orderBy('created_at', 'desc')->paginate($count);
305     }
306
307     /**
308      * Get the latest pages added to the system with pagination.
309      * @param string $type
310      * @param int $count
311      * @return mixed
312      */
313     public function getRecentlyUpdatedPaginated($type, $count = 20)
314     {
315         return $this->entityQuery($type)->orderBy('updated_at', 'desc')->paginate($count);
316     }
317
318     /**
319      * Get the most popular entities base on all views.
320      * @param string|bool $type
321      * @param int $count
322      * @param int $page
323      * @return mixed
324      */
325     public function getPopular($type, $count = 10, $page = 0)
326     {
327         $filter = is_bool($type) ? false : $this->getEntity($type);
328         return $this->viewService->getPopular($count, $page, $filter);
329     }
330
331     /**
332      * Get draft pages owned by the current user.
333      * @param int $count
334      * @param int $page
335      */
336     public function getUserDraftPages($count = 20, $page = 0)
337     {
338         return $this->page->where('draft', '=', true)
339             ->where('created_by', '=', user()->id)
340             ->orderBy('updated_at', 'desc')
341             ->skip($count * $page)->take($count)->get();
342     }
343
344     /**
345      * Get the child items for a chapter sorted by priority but
346      * with draft items floated to the top.
347      * @param \BookStack\Entities\Bookshelf $bookshelf
348      * @return \Illuminate\Database\Eloquent\Collection|static[]
349      */
350     public function getBookshelfChildren(Bookshelf $bookshelf)
351     {
352         return $this->permissionService->enforceEntityRestrictions('book', $bookshelf->books())->get();
353     }
354
355     /**
356      * Get all child objects of a book.
357      * Returns a sorted collection of Pages and Chapters.
358      * Loads the book slug onto child elements to prevent access database access for getting the slug.
359      * @param \BookStack\Entities\Book $book
360      * @param bool $filterDrafts
361      * @param bool $renderPages
362      * @return mixed
363      */
364     public function getBookChildren(Book $book, $filterDrafts = false, $renderPages = false)
365     {
366         $q = $this->permissionService->bookChildrenQuery($book->id, $filterDrafts, $renderPages)->get();
367         $entities = [];
368         $parents = [];
369         $tree = [];
370
371         foreach ($q as $index => $rawEntity) {
372             if ($rawEntity->entity_type ===  $this->page->getMorphClass()) {
373                 $entities[$index] = $this->page->newFromBuilder($rawEntity);
374                 if ($renderPages) {
375                     $entities[$index]->html = $rawEntity->html;
376                     $entities[$index]->html = $this->renderPage($entities[$index]);
377                 };
378             } else if ($rawEntity->entity_type === $this->chapter->getMorphClass()) {
379                 $entities[$index] = $this->chapter->newFromBuilder($rawEntity);
380                 $key = $entities[$index]->entity_type . ':' . $entities[$index]->id;
381                 $parents[$key] = $entities[$index];
382                 $parents[$key]->setAttribute('pages', collect());
383             }
384             if ($entities[$index]->chapter_id === 0 || $entities[$index]->chapter_id === '0') {
385                 $tree[] = $entities[$index];
386             }
387             $entities[$index]->book = $book;
388         }
389
390         foreach ($entities as $entity) {
391             if ($entity->chapter_id === 0 || $entity->chapter_id === '0') {
392                 continue;
393             }
394             $parentKey = $this->chapter->getMorphClass() . ':' . $entity->chapter_id;
395             if (!isset($parents[$parentKey])) {
396                 $tree[] = $entity;
397                 continue;
398             }
399             $chapter = $parents[$parentKey];
400             $chapter->pages->push($entity);
401         }
402
403         return collect($tree);
404     }
405
406     /**
407      * Get the child items for a chapter sorted by priority but
408      * with draft items floated to the top.
409      * @param \BookStack\Entities\Chapter $chapter
410      * @return \Illuminate\Database\Eloquent\Collection|static[]
411      */
412     public function getChapterChildren(Chapter $chapter)
413     {
414         return $this->permissionService->enforceEntityRestrictions('page', $chapter->pages())
415             ->orderBy('draft', 'DESC')->orderBy('priority', 'ASC')->get();
416     }
417
418
419     /**
420      * Get the next sequential priority for a new child element in the given book.
421      * @param \BookStack\Entities\Book $book
422      * @return int
423      */
424     public function getNewBookPriority(Book $book)
425     {
426         $lastElem = $this->getBookChildren($book)->pop();
427         return $lastElem ? $lastElem->priority + 1 : 0;
428     }
429
430     /**
431      * Get a new priority for a new page to be added to the given chapter.
432      * @param \BookStack\Entities\Chapter $chapter
433      * @return int
434      */
435     public function getNewChapterPriority(Chapter $chapter)
436     {
437         $lastPage = $chapter->pages('DESC')->first();
438         return $lastPage !== null ? $lastPage->priority + 1 : 0;
439     }
440
441     /**
442      * Find a suitable slug for an entity.
443      * @param string $type
444      * @param string $name
445      * @param bool|integer $currentId
446      * @param bool|integer $bookId Only pass if type is not a book
447      * @return string
448      */
449     public function findSuitableSlug($type, $name, $currentId = false, $bookId = false)
450     {
451         $slug = $this->nameToSlug($name);
452         while ($this->slugExists($type, $slug, $currentId, $bookId)) {
453             $slug .= '-' . substr(md5(rand(1, 500)), 0, 3);
454         }
455         return $slug;
456     }
457
458     /**
459      * Check if a slug already exists in the database.
460      * @param string $type
461      * @param string $slug
462      * @param bool|integer $currentId
463      * @param bool|integer $bookId
464      * @return bool
465      */
466     protected function slugExists($type, $slug, $currentId = false, $bookId = false)
467     {
468         $query = $this->getEntity($type)->where('slug', '=', $slug);
469         if (strtolower($type) === 'page' || strtolower($type) === 'chapter') {
470             $query = $query->where('book_id', '=', $bookId);
471         }
472         if ($currentId) {
473             $query = $query->where('id', '!=', $currentId);
474         }
475         return $query->count() > 0;
476     }
477
478     /**
479      * Updates entity restrictions from a request
480      * @param $request
481      * @param \BookStack\Entities\Entity $entity
482      */
483     public function updateEntityPermissionsFromRequest($request, Entity $entity)
484     {
485         $entity->restricted = $request->get('restricted', '') === 'true';
486         $entity->permissions()->delete();
487
488         if ($request->filled('restrictions')) {
489             foreach ($request->get('restrictions') as $roleId => $restrictions) {
490                 foreach ($restrictions as $action => $value) {
491                     $entity->permissions()->create([
492                         'role_id' => $roleId,
493                         'action'  => strtolower($action)
494                     ]);
495                 }
496             }
497         }
498
499         $entity->save();
500         $this->permissionService->buildJointPermissionsForEntity($entity);
501     }
502
503
504
505     /**
506      * Create a new entity from request input.
507      * Used for books and chapters.
508      * @param string $type
509      * @param array $input
510      * @param bool|Book $book
511      * @return \BookStack\Entities\Entity
512      */
513     public function createFromInput($type, $input = [], $book = false)
514     {
515         $isChapter = strtolower($type) === 'chapter';
516         $entityModel = $this->getEntity($type)->newInstance($input);
517         $entityModel->slug = $this->findSuitableSlug($type, $entityModel->name, false, $isChapter ? $book->id : false);
518         $entityModel->created_by = user()->id;
519         $entityModel->updated_by = user()->id;
520         $isChapter ? $book->chapters()->save($entityModel) : $entityModel->save();
521
522         if (isset($input['tags'])) {
523             $this->tagRepo->saveTagsToEntity($entityModel, $input['tags']);
524         }
525
526         $this->permissionService->buildJointPermissionsForEntity($entityModel);
527         $this->searchService->indexEntity($entityModel);
528         return $entityModel;
529     }
530
531     /**
532      * Update entity details from request input.
533      * Used for books and chapters
534      * @param string $type
535      * @param \BookStack\Entities\Entity $entityModel
536      * @param array $input
537      * @return \BookStack\Entities\Entity
538      */
539     public function updateFromInput($type, Entity $entityModel, $input = [])
540     {
541         if ($entityModel->name !== $input['name']) {
542             $entityModel->slug = $this->findSuitableSlug($type, $input['name'], $entityModel->id);
543         }
544         $entityModel->fill($input);
545         $entityModel->updated_by = user()->id;
546         $entityModel->save();
547
548         if (isset($input['tags'])) {
549             $this->tagRepo->saveTagsToEntity($entityModel, $input['tags']);
550         }
551
552         $this->permissionService->buildJointPermissionsForEntity($entityModel);
553         $this->searchService->indexEntity($entityModel);
554         return $entityModel;
555     }
556
557     /**
558      * Sync the books assigned to a shelf from a comma-separated list
559      * of book IDs.
560      * @param \BookStack\Entities\Bookshelf $shelf
561      * @param string $books
562      */
563     public function updateShelfBooks(Bookshelf $shelf, string $books)
564     {
565         $ids = explode(',', $books);
566
567         // Check books exist and match ordering
568         $bookIds = $this->entityQuery('book')->whereIn('id', $ids)->get(['id'])->pluck('id');
569         $syncData = [];
570         foreach ($ids as $index => $id) {
571             if ($bookIds->contains($id)) {
572                 $syncData[$id] = ['order' => $index];
573             }
574         }
575
576         $shelf->books()->sync($syncData);
577     }
578
579     /**
580      * Change the book that an entity belongs to.
581      * @param string $type
582      * @param integer $newBookId
583      * @param Entity $entity
584      * @param bool $rebuildPermissions
585      * @return \BookStack\Entities\Entity
586      */
587     public function changeBook($type, $newBookId, Entity $entity, $rebuildPermissions = false)
588     {
589         $entity->book_id = $newBookId;
590         // Update related activity
591         foreach ($entity->activity as $activity) {
592             $activity->book_id = $newBookId;
593             $activity->save();
594         }
595         $entity->slug = $this->findSuitableSlug($type, $entity->name, $entity->id, $newBookId);
596         $entity->save();
597
598         // Update all child pages if a chapter
599         if (strtolower($type) === 'chapter') {
600             foreach ($entity->pages as $page) {
601                 $this->changeBook('page', $newBookId, $page, false);
602             }
603         }
604
605         // Update permissions if applicable
606         if ($rebuildPermissions) {
607             $entity->load('book');
608             $this->permissionService->buildJointPermissionsForEntity($entity->book);
609         }
610
611         return $entity;
612     }
613
614     /**
615      * Alias method to update the book jointPermissions in the PermissionService.
616      * @param Book $book
617      */
618     public function buildJointPermissionsForBook(Book $book)
619     {
620         $this->permissionService->buildJointPermissionsForEntity($book);
621     }
622
623     /**
624      * Format a name as a url slug.
625      * @param $name
626      * @return string
627      */
628     protected function nameToSlug($name)
629     {
630         $slug = preg_replace('/[\+\/\\\?\@\}\{\.\,\=\[\]\#\&\!\*\'\;\:\$\%]/', '', mb_strtolower($name));
631         $slug = preg_replace('/\s{2,}/', ' ', $slug);
632         $slug = str_replace(' ', '-', $slug);
633         if ($slug === "") {
634             $slug = substr(md5(rand(1, 500)), 0, 5);
635         }
636         return $slug;
637     }
638
639     /**
640      * Get a new draft page instance.
641      * @param Book $book
642      * @param Chapter|bool $chapter
643      * @return \BookStack\Entities\Page
644      */
645     public function getDraftPage(Book $book, $chapter = false)
646     {
647         $page = $this->page->newInstance();
648         $page->name = trans('entities.pages_initial_name');
649         $page->created_by = user()->id;
650         $page->updated_by = user()->id;
651         $page->draft = true;
652
653         if ($chapter) {
654             $page->chapter_id = $chapter->id;
655         }
656
657         $book->pages()->save($page);
658         $page = $this->page->find($page->id);
659         $this->permissionService->buildJointPermissionsForEntity($page);
660         return $page;
661     }
662
663     /**
664      * Publish a draft page to make it a normal page.
665      * Sets the slug and updates the content.
666      * @param Page $draftPage
667      * @param array $input
668      * @return Page
669      */
670     public function publishPageDraft(Page $draftPage, array $input)
671     {
672         $draftPage->fill($input);
673
674         // Save page tags if present
675         if (isset($input['tags'])) {
676             $this->tagRepo->saveTagsToEntity($draftPage, $input['tags']);
677         }
678
679         $draftPage->slug = $this->findSuitableSlug('page', $draftPage->name, false, $draftPage->book->id);
680         $draftPage->html = $this->formatHtml($input['html']);
681         $draftPage->text = $this->pageToPlainText($draftPage);
682         $draftPage->draft = false;
683         $draftPage->revision_count = 1;
684
685         $draftPage->save();
686         $this->savePageRevision($draftPage, trans('entities.pages_initial_revision'));
687         $this->searchService->indexEntity($draftPage);
688         return $draftPage;
689     }
690
691     /**
692      * Create a copy of a page in a new location with a new name.
693      * @param \BookStack\Entities\Page $page
694      * @param \BookStack\Entities\Entity $newParent
695      * @param string $newName
696      * @return \BookStack\Entities\Page
697      */
698     public function copyPage(Page $page, Entity $newParent, $newName = '')
699     {
700         $newBook = $newParent->isA('book') ? $newParent : $newParent->book;
701         $newChapter = $newParent->isA('chapter') ? $newParent : null;
702         $copyPage = $this->getDraftPage($newBook, $newChapter);
703         $pageData = $page->getAttributes();
704
705         // Update name
706         if (!empty($newName)) {
707             $pageData['name'] = $newName;
708         }
709
710         // Copy tags from previous page if set
711         if ($page->tags) {
712             $pageData['tags'] = [];
713             foreach ($page->tags as $tag) {
714                 $pageData['tags'][] = ['name' => $tag->name, 'value' => $tag->value];
715             }
716         }
717
718         // Set priority
719         if ($newParent->isA('chapter')) {
720             $pageData['priority'] = $this->getNewChapterPriority($newParent);
721         } else {
722             $pageData['priority'] = $this->getNewBookPriority($newParent);
723         }
724
725         return $this->publishPageDraft($copyPage, $pageData);
726     }
727
728     /**
729      * Saves a page revision into the system.
730      * @param Page $page
731      * @param null|string $summary
732      * @return \BookStack\Entities\PageRevision
733      */
734     public function savePageRevision(Page $page, $summary = null)
735     {
736         $revision = $this->pageRevision->newInstance($page->toArray());
737         if (setting('app-editor') !== 'markdown') {
738             $revision->markdown = '';
739         }
740         $revision->page_id = $page->id;
741         $revision->slug = $page->slug;
742         $revision->book_slug = $page->book->slug;
743         $revision->created_by = user()->id;
744         $revision->created_at = $page->updated_at;
745         $revision->type = 'version';
746         $revision->summary = $summary;
747         $revision->revision_number = $page->revision_count;
748         $revision->save();
749
750         $revisionLimit = config('app.revision_limit');
751         if ($revisionLimit !== false) {
752             $revisionsToDelete = $this->pageRevision->where('page_id', '=', $page->id)
753                 ->orderBy('created_at', 'desc')->skip(intval($revisionLimit))->take(10)->get(['id']);
754             if ($revisionsToDelete->count() > 0) {
755                 $this->pageRevision->whereIn('id', $revisionsToDelete->pluck('id'))->delete();
756             }
757         }
758
759         return $revision;
760     }
761
762     /**
763      * Formats a page's html to be tagged correctly
764      * within the system.
765      * @param string $htmlText
766      * @return string
767      */
768     protected function formatHtml($htmlText)
769     {
770         if ($htmlText == '') {
771             return $htmlText;
772         }
773         libxml_use_internal_errors(true);
774         $doc = new DOMDocument();
775         $doc->loadHTML(mb_convert_encoding($htmlText, 'HTML-ENTITIES', 'UTF-8'));
776
777         $container = $doc->documentElement;
778         $body = $container->childNodes->item(0);
779         $childNodes = $body->childNodes;
780
781         // Ensure no duplicate ids are used
782         $idArray = [];
783
784         foreach ($childNodes as $index => $childNode) {
785             /** @var \DOMElement $childNode */
786             if (get_class($childNode) !== 'DOMElement') {
787                 continue;
788             }
789
790             // Overwrite id if not a BookStack custom id
791             if ($childNode->hasAttribute('id')) {
792                 $id = $childNode->getAttribute('id');
793                 if (strpos($id, 'bkmrk') === 0 && array_search($id, $idArray) === false) {
794                     $idArray[] = $id;
795                     continue;
796                 };
797             }
798
799             // Create an unique id for the element
800             // Uses the content as a basis to ensure output is the same every time
801             // the same content is passed through.
802             $contentId = 'bkmrk-' . substr(strtolower(preg_replace('/\s+/', '-', trim($childNode->nodeValue))), 0, 20);
803             $newId = urlencode($contentId);
804             $loopIndex = 0;
805             while (in_array($newId, $idArray)) {
806                 $newId = urlencode($contentId . '-' . $loopIndex);
807                 $loopIndex++;
808             }
809
810             $childNode->setAttribute('id', $newId);
811             $idArray[] = $newId;
812         }
813
814         // Generate inner html as a string
815         $html = '';
816         foreach ($childNodes as $childNode) {
817             $html .= $doc->saveHTML($childNode);
818         }
819
820         return $html;
821     }
822
823
824     /**
825      * Render the page for viewing, Parsing and performing features such as page transclusion.
826      * @param Page $page
827      * @param bool $ignorePermissions
828      * @return mixed|string
829      */
830     public function renderPage(Page $page, $ignorePermissions = false)
831     {
832         $content = $page->html;
833         if (!config('app.allow_content_scripts')) {
834             $content = $this->escapeScripts($content);
835         }
836
837         $matches = [];
838         preg_match_all("/{{@\s?([0-9].*?)}}/", $content, $matches);
839         if (count($matches[0]) === 0) {
840             return $content;
841         }
842
843         $topLevelTags = ['table', 'ul', 'ol'];
844         foreach ($matches[1] as $index => $includeId) {
845             $splitInclude = explode('#', $includeId, 2);
846             $pageId = intval($splitInclude[0]);
847             if (is_nan($pageId)) {
848                 continue;
849             }
850
851             $matchedPage = $this->getById('page', $pageId, false, $ignorePermissions);
852             if ($matchedPage === null) {
853                 $content = str_replace($matches[0][$index], '', $content);
854                 continue;
855             }
856
857             if (count($splitInclude) === 1) {
858                 $content = str_replace($matches[0][$index], $matchedPage->html, $content);
859                 continue;
860             }
861
862             $doc = new DOMDocument();
863             $doc->loadHTML(mb_convert_encoding('<body>'.$matchedPage->html.'</body>', 'HTML-ENTITIES', 'UTF-8'));
864             $matchingElem = $doc->getElementById($splitInclude[1]);
865             if ($matchingElem === null) {
866                 $content = str_replace($matches[0][$index], '', $content);
867                 continue;
868             }
869             $innerContent = '';
870             $isTopLevel = in_array(strtolower($matchingElem->nodeName), $topLevelTags);
871             if ($isTopLevel) {
872                 $innerContent .= $doc->saveHTML($matchingElem);
873             } else {
874                 foreach ($matchingElem->childNodes as $childNode) {
875                     $innerContent .= $doc->saveHTML($childNode);
876                 }
877             }
878             $content = str_replace($matches[0][$index], trim($innerContent), $content);
879         }
880
881         return $content;
882     }
883
884     /**
885      * Escape script tags within HTML content.
886      * @param string $html
887      * @return mixed
888      */
889     protected function escapeScripts(string $html)
890     {
891         $scriptSearchRegex = '/<script.*?>.*?<\/script>/ms';
892         $matches = [];
893         preg_match_all($scriptSearchRegex, $html, $matches);
894         if (count($matches) === 0) {
895             return $html;
896         }
897
898         foreach ($matches[0] as $match) {
899             $html = str_replace($match, htmlentities($match), $html);
900         }
901         return $html;
902     }
903
904     /**
905      * Get the plain text version of a page's content.
906      * @param \BookStack\Entities\Page $page
907      * @return string
908      */
909     public function pageToPlainText(Page $page)
910     {
911         $html = $this->renderPage($page);
912         return strip_tags($html);
913     }
914
915     /**
916      * Search for image usage within page content.
917      * @param $imageString
918      * @return mixed
919      */
920     public function searchForImage($imageString)
921     {
922         $pages = $this->entityQuery('page')->where('html', 'like', '%' . $imageString . '%')->get();
923         foreach ($pages as $page) {
924             $page->url = $page->getUrl();
925             $page->html = '';
926             $page->text = '';
927         }
928         return count($pages) > 0 ? $pages : false;
929     }
930
931     /**
932      * Parse the headers on the page to get a navigation menu
933      * @param String $pageContent
934      * @return array
935      */
936     public function getPageNav($pageContent)
937     {
938         if ($pageContent == '') {
939             return [];
940         }
941         libxml_use_internal_errors(true);
942         $doc = new DOMDocument();
943         $doc->loadHTML(mb_convert_encoding($pageContent, 'HTML-ENTITIES', 'UTF-8'));
944         $xPath = new DOMXPath($doc);
945         $headers = $xPath->query("//h1|//h2|//h3|//h4|//h5|//h6");
946
947         if (is_null($headers)) {
948             return [];
949         }
950
951         $tree = collect([]);
952         foreach ($headers as $header) {
953             $text = $header->nodeValue;
954             $tree->push([
955                 'nodeName' => strtolower($header->nodeName),
956                 'level' => intval(str_replace('h', '', $header->nodeName)),
957                 'link' => '#' . $header->getAttribute('id'),
958                 'text' => strlen($text) > 30 ? substr($text, 0, 27) . '...' : $text
959             ]);
960         }
961
962         // Normalise headers if only smaller headers have been used
963         if (count($tree) > 0) {
964             $minLevel = $tree->pluck('level')->min();
965             $tree = $tree->map(function ($header) use ($minLevel) {
966                 $header['level'] -= ($minLevel - 2);
967                 return $header;
968             });
969         }
970         return $tree->toArray();
971     }
972
973     /**
974      * Updates a page with any fillable data and saves it into the database.
975      * @param \BookStack\Entities\Page $page
976      * @param int $book_id
977      * @param array $input
978      * @return \BookStack\Entities\Page
979      */
980     public function updatePage(Page $page, $book_id, $input)
981     {
982         // Hold the old details to compare later
983         $oldHtml = $page->html;
984         $oldName = $page->name;
985
986         // Prevent slug being updated if no name change
987         if ($page->name !== $input['name']) {
988             $page->slug = $this->findSuitableSlug('page', $input['name'], $page->id, $book_id);
989         }
990
991         // Save page tags if present
992         if (isset($input['tags'])) {
993             $this->tagRepo->saveTagsToEntity($page, $input['tags']);
994         }
995
996         // Update with new details
997         $userId = user()->id;
998         $page->fill($input);
999         $page->html = $this->formatHtml($input['html']);
1000         $page->text = $this->pageToPlainText($page);
1001         if (setting('app-editor') !== 'markdown') {
1002             $page->markdown = '';
1003         }
1004         $page->updated_by = $userId;
1005         $page->revision_count++;
1006         $page->save();
1007
1008         // Remove all update drafts for this user & page.
1009         $this->userUpdatePageDraftsQuery($page, $userId)->delete();
1010
1011         // Save a revision after updating
1012         if ($oldHtml !== $input['html'] || $oldName !== $input['name'] || $input['summary'] !== null) {
1013             $this->savePageRevision($page, $input['summary']);
1014         }
1015
1016         $this->searchService->indexEntity($page);
1017
1018         return $page;
1019     }
1020
1021     /**
1022      * The base query for getting user update drafts.
1023      * @param \BookStack\Entities\Page $page
1024      * @param $userId
1025      * @return mixed
1026      */
1027     protected function userUpdatePageDraftsQuery(Page $page, $userId)
1028     {
1029         return $this->pageRevision->where('created_by', '=', $userId)
1030             ->where('type', 'update_draft')
1031             ->where('page_id', '=', $page->id)
1032             ->orderBy('created_at', 'desc');
1033     }
1034
1035     /**
1036      * Checks whether a user has a draft version of a particular page or not.
1037      * @param \BookStack\Entities\Page $page
1038      * @param $userId
1039      * @return bool
1040      */
1041     public function hasUserGotPageDraft(Page $page, $userId)
1042     {
1043         return $this->userUpdatePageDraftsQuery($page, $userId)->count() > 0;
1044     }
1045
1046     /**
1047      * Get the latest updated draft revision for a particular page and user.
1048      * @param Page $page
1049      * @param $userId
1050      * @return mixed
1051      */
1052     public function getUserPageDraft(Page $page, $userId)
1053     {
1054         return $this->userUpdatePageDraftsQuery($page, $userId)->first();
1055     }
1056
1057     /**
1058      * Get the notification message that informs the user that they are editing a draft page.
1059      * @param PageRevision $draft
1060      * @return string
1061      */
1062     public function getUserPageDraftMessage(PageRevision $draft)
1063     {
1064         $message = trans('entities.pages_editing_draft_notification', ['timeDiff' => $draft->updated_at->diffForHumans()]);
1065         if ($draft->page->updated_at->timestamp <= $draft->updated_at->timestamp) {
1066             return $message;
1067         }
1068         return $message . "\n" . trans('entities.pages_draft_edited_notification');
1069     }
1070
1071     /**
1072      * Check if a page is being actively editing.
1073      * Checks for edits since last page updated.
1074      * Passing in a minuted range will check for edits
1075      * within the last x minutes.
1076      * @param \BookStack\Entities\Page $page
1077      * @param null $minRange
1078      * @return bool
1079      */
1080     public function isPageEditingActive(Page $page, $minRange = null)
1081     {
1082         $draftSearch = $this->activePageEditingQuery($page, $minRange);
1083         return $draftSearch->count() > 0;
1084     }
1085
1086     /**
1087      * A query to check for active update drafts on a particular page.
1088      * @param Page $page
1089      * @param null $minRange
1090      * @return mixed
1091      */
1092     protected function activePageEditingQuery(Page $page, $minRange = null)
1093     {
1094         $query = $this->pageRevision->where('type', '=', 'update_draft')
1095             ->where('page_id', '=', $page->id)
1096             ->where('updated_at', '>', $page->updated_at)
1097             ->where('created_by', '!=', user()->id)
1098             ->with('createdBy');
1099
1100         if ($minRange !== null) {
1101             $query = $query->where('updated_at', '>=', Carbon::now()->subMinutes($minRange));
1102         }
1103
1104         return $query;
1105     }
1106
1107     /**
1108      * Restores a revision's content back into a page.
1109      * @param Page $page
1110      * @param Book $book
1111      * @param  int $revisionId
1112      * @return \BookStack\Entities\Page
1113      */
1114     public function restorePageRevision(Page $page, Book $book, $revisionId)
1115     {
1116         $page->revision_count++;
1117         $this->savePageRevision($page);
1118         $revision = $page->revisions()->where('id', '=', $revisionId)->first();
1119         $page->fill($revision->toArray());
1120         $page->slug = $this->findSuitableSlug('page', $page->name, $page->id, $book->id);
1121         $page->text = $this->pageToPlainText($page);
1122         $page->updated_by = user()->id;
1123         $page->save();
1124         $this->searchService->indexEntity($page);
1125         return $page;
1126     }
1127
1128
1129     /**
1130      * Save a page update draft.
1131      * @param Page $page
1132      * @param array $data
1133      * @return PageRevision|Page
1134      */
1135     public function updatePageDraft(Page $page, $data = [])
1136     {
1137         // If the page itself is a draft simply update that
1138         if ($page->draft) {
1139             $page->fill($data);
1140             if (isset($data['html'])) {
1141                 $page->text = $this->pageToPlainText($page);
1142             }
1143             $page->save();
1144             return $page;
1145         }
1146
1147         // Otherwise save the data to a revision
1148         $userId = user()->id;
1149         $drafts = $this->userUpdatePageDraftsQuery($page, $userId)->get();
1150
1151         if ($drafts->count() > 0) {
1152             $draft = $drafts->first();
1153         } else {
1154             $draft = $this->pageRevision->newInstance();
1155             $draft->page_id = $page->id;
1156             $draft->slug = $page->slug;
1157             $draft->book_slug = $page->book->slug;
1158             $draft->created_by = $userId;
1159             $draft->type = 'update_draft';
1160         }
1161
1162         $draft->fill($data);
1163         if (setting('app-editor') !== 'markdown') {
1164             $draft->markdown = '';
1165         }
1166
1167         $draft->save();
1168         return $draft;
1169     }
1170
1171     /**
1172      * Get a notification message concerning the editing activity on a particular page.
1173      * @param Page $page
1174      * @param null $minRange
1175      * @return string
1176      */
1177     public function getPageEditingActiveMessage(Page $page, $minRange = null)
1178     {
1179         $pageDraftEdits = $this->activePageEditingQuery($page, $minRange)->get();
1180
1181         $userMessage = $pageDraftEdits->count() > 1 ? trans('entities.pages_draft_edit_active.start_a', ['count' => $pageDraftEdits->count()]): trans('entities.pages_draft_edit_active.start_b', ['userName' => $pageDraftEdits->first()->createdBy->name]);
1182         $timeMessage = $minRange === null ? trans('entities.pages_draft_edit_active.time_a') : trans('entities.pages_draft_edit_active.time_b', ['minCount'=>$minRange]);
1183         return trans('entities.pages_draft_edit_active.message', ['start' => $userMessage, 'time' => $timeMessage]);
1184     }
1185
1186     /**
1187      * Change the page's parent to the given entity.
1188      * @param \BookStack\Entities\Page $page
1189      * @param \BookStack\Entities\Entity $parent
1190      */
1191     public function changePageParent(Page $page, Entity $parent)
1192     {
1193         $book = $parent->isA('book') ? $parent : $parent->book;
1194         $page->chapter_id = $parent->isA('chapter') ? $parent->id : 0;
1195         $page->save();
1196         if ($page->book->id !== $book->id) {
1197             $page = $this->changeBook('page', $book->id, $page);
1198         }
1199         $page->load('book');
1200         $this->permissionService->buildJointPermissionsForEntity($book);
1201     }
1202
1203     /**
1204      * Destroy a bookshelf instance
1205      * @param \BookStack\Entities\Bookshelf $shelf
1206      * @throws \Throwable
1207      */
1208     public function destroyBookshelf(Bookshelf $shelf)
1209     {
1210         $this->destroyEntityCommonRelations($shelf);
1211         $shelf->delete();
1212     }
1213
1214     /**
1215      * Destroy the provided book and all its child entities.
1216      * @param \BookStack\Entities\Book $book
1217      * @throws NotifyException
1218      * @throws \Throwable
1219      */
1220     public function destroyBook(Book $book)
1221     {
1222         foreach ($book->pages as $page) {
1223             $this->destroyPage($page);
1224         }
1225         foreach ($book->chapters as $chapter) {
1226             $this->destroyChapter($chapter);
1227         }
1228         $this->destroyEntityCommonRelations($book);
1229         $book->delete();
1230     }
1231
1232     /**
1233      * Destroy a chapter and its relations.
1234      * @param \BookStack\Entities\Chapter $chapter
1235      * @throws \Throwable
1236      */
1237     public function destroyChapter(Chapter $chapter)
1238     {
1239         if (count($chapter->pages) > 0) {
1240             foreach ($chapter->pages as $page) {
1241                 $page->chapter_id = 0;
1242                 $page->save();
1243             }
1244         }
1245         $this->destroyEntityCommonRelations($chapter);
1246         $chapter->delete();
1247     }
1248
1249     /**
1250      * Destroy a given page along with its dependencies.
1251      * @param Page $page
1252      * @throws NotifyException
1253      * @throws \Throwable
1254      */
1255     public function destroyPage(Page $page)
1256     {
1257         // Check if set as custom homepage
1258         $customHome = setting('app-homepage', '0:');
1259         if (intval($page->id) === intval(explode(':', $customHome)[0])) {
1260             throw new NotifyException(trans('errors.page_custom_home_deletion'), $page->getUrl());
1261         }
1262
1263         $this->destroyEntityCommonRelations($page);
1264
1265         // Delete Attached Files
1266         $attachmentService = app(AttachmentService::class);
1267         foreach ($page->attachments as $attachment) {
1268             $attachmentService->deleteFile($attachment);
1269         }
1270
1271         $page->delete();
1272     }
1273
1274     /**
1275      * Destroy or handle the common relations connected to an entity.
1276      * @param \BookStack\Entities\Entity $entity
1277      * @throws \Throwable
1278      */
1279     protected function destroyEntityCommonRelations(Entity $entity)
1280     {
1281         \Activity::removeEntity($entity);
1282         $entity->views()->delete();
1283         $entity->permissions()->delete();
1284         $entity->tags()->delete();
1285         $entity->comments()->delete();
1286         $this->permissionService->deleteJointPermissionsForEntity($entity);
1287         $this->searchService->deleteEntityTerms($entity);
1288     }
1289
1290     /**
1291      * Copy the permissions of a bookshelf to all child books.
1292      * Returns the number of books that had permissions updated.
1293      * @param \BookStack\Entities\Bookshelf $bookshelf
1294      * @return int
1295      * @throws \Throwable
1296      */
1297     public function copyBookshelfPermissions(Bookshelf $bookshelf)
1298     {
1299         $shelfPermissions = $bookshelf->permissions()->get(['role_id', 'action'])->toArray();
1300         $shelfBooks = $bookshelf->books()->get();
1301         $updatedBookCount = 0;
1302
1303         foreach ($shelfBooks as $book) {
1304             if (!userCan('restrictions-manage', $book)) {
1305                 continue;
1306             }
1307             $book->permissions()->delete();
1308             $book->restricted = $bookshelf->restricted;
1309             $book->permissions()->createMany($shelfPermissions);
1310             $book->save();
1311             $this->permissionService->buildJointPermissionsForEntity($book);
1312             $updatedBookCount++;
1313         }
1314
1315         return $updatedBookCount;
1316     }
1317 }