]> BookStack Code Mirror - bookstack/blob - app/Entities/Repos/EntityRepo.php
Commented APP_URL by default to prevent upgrade path issues
[bookstack] / app / Entities / Repos / EntityRepo.php
1 <?php namespace BookStack\Entities\Repos;
2
3 use BookStack\Actions\TagRepo;
4 use BookStack\Actions\ViewService;
5 use BookStack\Auth\Permissions\PermissionService;
6 use BookStack\Auth\User;
7 use BookStack\Entities\Book;
8 use BookStack\Entities\Bookshelf;
9 use BookStack\Entities\Chapter;
10 use BookStack\Entities\Entity;
11 use BookStack\Entities\EntityProvider;
12 use BookStack\Entities\Page;
13 use BookStack\Entities\SearchService;
14 use BookStack\Exceptions\NotFoundException;
15 use BookStack\Exceptions\NotifyException;
16 use BookStack\Uploads\AttachmentService;
17 use DOMDocument;
18 use Illuminate\Http\Request;
19 use Illuminate\Support\Collection;
20
21 class EntityRepo
22 {
23
24     /**
25      * @var EntityProvider
26      */
27     protected $entityProvider;
28
29     /**
30      * @var PermissionService
31      */
32     protected $permissionService;
33
34     /**
35      * @var ViewService
36      */
37     protected $viewService;
38
39     /**
40      * @var TagRepo
41      */
42     protected $tagRepo;
43
44     /**
45      * @var SearchService
46      */
47     protected $searchService;
48
49     /**
50      * EntityRepo constructor.
51      * @param EntityProvider $entityProvider
52      * @param ViewService $viewService
53      * @param PermissionService $permissionService
54      * @param TagRepo $tagRepo
55      * @param SearchService $searchService
56      */
57     public function __construct(
58         EntityProvider $entityProvider,
59         ViewService $viewService,
60         PermissionService $permissionService,
61         TagRepo $tagRepo,
62         SearchService $searchService
63     ) {
64         $this->entityProvider = $entityProvider;
65         $this->viewService = $viewService;
66         $this->permissionService = $permissionService;
67         $this->tagRepo = $tagRepo;
68         $this->searchService = $searchService;
69     }
70
71     /**
72      * Base query for searching entities via permission system
73      * @param string $type
74      * @param bool $allowDrafts
75      * @param string $permission
76      * @return \Illuminate\Database\Query\Builder
77      */
78     protected function entityQuery($type, $allowDrafts = false, $permission = 'view')
79     {
80         $q = $this->permissionService->enforceEntityRestrictions($type, $this->entityProvider->get($type), $permission);
81         if (strtolower($type) === 'page' && !$allowDrafts) {
82             $q = $q->where('draft', '=', false);
83         }
84         return $q;
85     }
86
87     /**
88      * Check if an entity with the given id exists.
89      * @param $type
90      * @param $id
91      * @return bool
92      */
93     public function exists($type, $id)
94     {
95         return $this->entityQuery($type)->where('id', '=', $id)->exists();
96     }
97
98     /**
99      * Get an entity by ID
100      * @param string $type
101      * @param integer $id
102      * @param bool $allowDrafts
103      * @param bool $ignorePermissions
104      * @return \BookStack\Entities\Entity
105      */
106     public function getById($type, $id, $allowDrafts = false, $ignorePermissions = false)
107     {
108         $query = $this->entityQuery($type, $allowDrafts);
109
110         if ($ignorePermissions) {
111             $query = $this->entityProvider->get($type)->newQuery();
112         }
113
114         return $query->find($id);
115     }
116
117     /**
118      * @param string $type
119      * @param []int $ids
120      * @param bool $allowDrafts
121      * @param bool $ignorePermissions
122      * @return \Illuminate\Database\Eloquent\Builder[]|\Illuminate\Database\Eloquent\Collection|Collection
123      */
124     public function getManyById($type, $ids, $allowDrafts = false, $ignorePermissions = false)
125     {
126         $query = $this->entityQuery($type, $allowDrafts);
127
128         if ($ignorePermissions) {
129             $query = $this->entityProvider->get($type)->newQuery();
130         }
131
132         return $query->whereIn('id', $ids)->get();
133     }
134
135     /**
136      * Get an entity by its url slug.
137      * @param string $type
138      * @param string $slug
139      * @param string|bool $bookSlug
140      * @return \BookStack\Entities\Entity
141      * @throws NotFoundException
142      */
143     public function getBySlug($type, $slug, $bookSlug = false)
144     {
145         $q = $this->entityQuery($type)->where('slug', '=', $slug);
146
147         if (strtolower($type) === 'chapter' || strtolower($type) === 'page') {
148             $q = $q->where('book_id', '=', function ($query) use ($bookSlug) {
149                 $query->select('id')
150                     ->from($this->entityProvider->book->getTable())
151                     ->where('slug', '=', $bookSlug)->limit(1);
152             });
153         }
154         $entity = $q->first();
155         if ($entity === null) {
156             throw new NotFoundException(trans('errors.' . strtolower($type) . '_not_found'));
157         }
158         return $entity;
159     }
160
161
162     /**
163      * Get all entities of a type with the given permission, limited by count unless count is false.
164      * @param string $type
165      * @param integer|bool $count
166      * @param string $permission
167      * @return Collection
168      */
169     public function getAll($type, $count = 20, $permission = 'view')
170     {
171         $q = $this->entityQuery($type, false, $permission)->orderBy('name', 'asc');
172         if ($count !== false) {
173             $q = $q->take($count);
174         }
175         return $q->get();
176     }
177
178     /**
179      * Get all entities in a paginated format
180      * @param $type
181      * @param int $count
182      * @return \Illuminate\Contracts\Pagination\LengthAwarePaginator
183      */
184     public function getAllPaginated($type, $count = 10)
185     {
186         return $this->entityQuery($type)->orderBy('name', 'asc')->paginate($count);
187     }
188
189     /**
190      * Get the most recently created entities of the given type.
191      * @param string $type
192      * @param int $count
193      * @param int $page
194      * @param bool|callable $additionalQuery
195      * @return Collection
196      */
197     public function getRecentlyCreated($type, $count = 20, $page = 0, $additionalQuery = false)
198     {
199         $query = $this->permissionService->enforceEntityRestrictions($type, $this->entityProvider->get($type))
200             ->orderBy('created_at', 'desc');
201         if (strtolower($type) === 'page') {
202             $query = $query->where('draft', '=', false);
203         }
204         if ($additionalQuery !== false && is_callable($additionalQuery)) {
205             $additionalQuery($query);
206         }
207         return $query->skip($page * $count)->take($count)->get();
208     }
209
210     /**
211      * Get the most recently updated entities of the given type.
212      * @param string $type
213      * @param int $count
214      * @param int $page
215      * @param bool|callable $additionalQuery
216      * @return Collection
217      */
218     public function getRecentlyUpdated($type, $count = 20, $page = 0, $additionalQuery = false)
219     {
220         $query = $this->permissionService->enforceEntityRestrictions($type, $this->entityProvider->get($type))
221             ->orderBy('updated_at', 'desc');
222         if (strtolower($type) === 'page') {
223             $query = $query->where('draft', '=', false);
224         }
225         if ($additionalQuery !== false && is_callable($additionalQuery)) {
226             $additionalQuery($query);
227         }
228         return $query->skip($page * $count)->take($count)->get();
229     }
230
231     /**
232      * Get the most recently viewed entities.
233      * @param string|bool $type
234      * @param int $count
235      * @param int $page
236      * @return mixed
237      */
238     public function getRecentlyViewed($type, $count = 10, $page = 0)
239     {
240         $filter = is_bool($type) ? false : $this->entityProvider->get($type);
241         return $this->viewService->getUserRecentlyViewed($count, $page, $filter);
242     }
243
244     /**
245      * Get the latest pages added to the system with pagination.
246      * @param string $type
247      * @param int $count
248      * @return mixed
249      */
250     public function getRecentlyCreatedPaginated($type, $count = 20)
251     {
252         return $this->entityQuery($type)->orderBy('created_at', 'desc')->paginate($count);
253     }
254
255     /**
256      * Get the latest pages added to the system with pagination.
257      * @param string $type
258      * @param int $count
259      * @return mixed
260      */
261     public function getRecentlyUpdatedPaginated($type, $count = 20)
262     {
263         return $this->entityQuery($type)->orderBy('updated_at', 'desc')->paginate($count);
264     }
265
266     /**
267      * Get the most popular entities base on all views.
268      * @param string|bool $type
269      * @param int $count
270      * @param int $page
271      * @return mixed
272      */
273     public function getPopular($type, $count = 10, $page = 0)
274     {
275         $filter = is_bool($type) ? false : $this->entityProvider->get($type);
276         return $this->viewService->getPopular($count, $page, $filter);
277     }
278
279     /**
280      * Get draft pages owned by the current user.
281      * @param int $count
282      * @param int $page
283      * @return Collection
284      */
285     public function getUserDraftPages($count = 20, $page = 0)
286     {
287         return $this->entityProvider->page->where('draft', '=', true)
288             ->where('created_by', '=', user()->id)
289             ->orderBy('updated_at', 'desc')
290             ->skip($count * $page)->take($count)->get();
291     }
292
293     /**
294      * Get the number of entities the given user has created.
295      * @param string $type
296      * @param User $user
297      * @return int
298      */
299     public function getUserTotalCreated(string $type, User $user)
300     {
301         return $this->entityProvider->get($type)
302             ->where('created_by', '=', $user->id)->count();
303     }
304
305     /**
306      * Get the child items for a chapter sorted by priority but
307      * with draft items floated to the top.
308      * @param \BookStack\Entities\Bookshelf $bookshelf
309      * @return \Illuminate\Database\Eloquent\Collection|static[]
310      */
311     public function getBookshelfChildren(Bookshelf $bookshelf)
312     {
313         return $this->permissionService->enforceEntityRestrictions('book', $bookshelf->books())->get();
314     }
315
316     /**
317      * Get all child objects of a book.
318      * Returns a sorted collection of Pages and Chapters.
319      * Loads the book slug onto child elements to prevent access database access for getting the slug.
320      * @param \BookStack\Entities\Book $book
321      * @param bool $filterDrafts
322      * @param bool $renderPages
323      * @return mixed
324      */
325     public function getBookChildren(Book $book, $filterDrafts = false, $renderPages = false)
326     {
327         $q = $this->permissionService->bookChildrenQuery($book->id, $filterDrafts, $renderPages)->get();
328         $entities = [];
329         $parents = [];
330         $tree = [];
331
332         foreach ($q as $index => $rawEntity) {
333             if ($rawEntity->entity_type ===  $this->entityProvider->page->getMorphClass()) {
334                 $entities[$index] = $this->entityProvider->page->newFromBuilder($rawEntity);
335                 if ($renderPages) {
336                     $entities[$index]->html = $rawEntity->html;
337                     $entities[$index]->html = $this->renderPage($entities[$index]);
338                 };
339             } else if ($rawEntity->entity_type === $this->entityProvider->chapter->getMorphClass()) {
340                 $entities[$index] = $this->entityProvider->chapter->newFromBuilder($rawEntity);
341                 $key = $entities[$index]->entity_type . ':' . $entities[$index]->id;
342                 $parents[$key] = $entities[$index];
343                 $parents[$key]->setAttribute('pages', collect());
344             }
345             if ($entities[$index]->chapter_id === 0 || $entities[$index]->chapter_id === '0') {
346                 $tree[] = $entities[$index];
347             }
348             $entities[$index]->book = $book;
349         }
350
351         foreach ($entities as $entity) {
352             if ($entity->chapter_id === 0 || $entity->chapter_id === '0') {
353                 continue;
354             }
355             $parentKey = $this->entityProvider->chapter->getMorphClass() . ':' . $entity->chapter_id;
356             if (!isset($parents[$parentKey])) {
357                 $tree[] = $entity;
358                 continue;
359             }
360             $chapter = $parents[$parentKey];
361             $chapter->pages->push($entity);
362         }
363
364         return collect($tree);
365     }
366
367     /**
368      * Get the child items for a chapter sorted by priority but
369      * with draft items floated to the top.
370      * @param \BookStack\Entities\Chapter $chapter
371      * @return \Illuminate\Database\Eloquent\Collection|static[]
372      */
373     public function getChapterChildren(Chapter $chapter)
374     {
375         return $this->permissionService->enforceEntityRestrictions('page', $chapter->pages())
376             ->orderBy('draft', 'DESC')->orderBy('priority', 'ASC')->get();
377     }
378
379
380     /**
381      * Get the next sequential priority for a new child element in the given book.
382      * @param \BookStack\Entities\Book $book
383      * @return int
384      */
385     public function getNewBookPriority(Book $book)
386     {
387         $lastElem = $this->getBookChildren($book)->pop();
388         return $lastElem ? $lastElem->priority + 1 : 0;
389     }
390
391     /**
392      * Get a new priority for a new page to be added to the given chapter.
393      * @param \BookStack\Entities\Chapter $chapter
394      * @return int
395      */
396     public function getNewChapterPriority(Chapter $chapter)
397     {
398         $lastPage = $chapter->pages('DESC')->first();
399         return $lastPage !== null ? $lastPage->priority + 1 : 0;
400     }
401
402     /**
403      * Find a suitable slug for an entity.
404      * @param string $type
405      * @param string $name
406      * @param bool|integer $currentId
407      * @param bool|integer $bookId Only pass if type is not a book
408      * @return string
409      */
410     public function findSuitableSlug($type, $name, $currentId = false, $bookId = false)
411     {
412         $slug = $this->nameToSlug($name);
413         while ($this->slugExists($type, $slug, $currentId, $bookId)) {
414             $slug .= '-' . substr(md5(rand(1, 500)), 0, 3);
415         }
416         return $slug;
417     }
418
419     /**
420      * Check if a slug already exists in the database.
421      * @param string $type
422      * @param string $slug
423      * @param bool|integer $currentId
424      * @param bool|integer $bookId
425      * @return bool
426      */
427     protected function slugExists($type, $slug, $currentId = false, $bookId = false)
428     {
429         $query = $this->entityProvider->get($type)->where('slug', '=', $slug);
430         if (strtolower($type) === 'page' || strtolower($type) === 'chapter') {
431             $query = $query->where('book_id', '=', $bookId);
432         }
433         if ($currentId) {
434             $query = $query->where('id', '!=', $currentId);
435         }
436         return $query->count() > 0;
437     }
438
439     /**
440      * Updates entity restrictions from a request
441      * @param Request $request
442      * @param \BookStack\Entities\Entity $entity
443      * @throws \Throwable
444      */
445     public function updateEntityPermissionsFromRequest(Request $request, Entity $entity)
446     {
447         $entity->restricted = $request->get('restricted', '') === 'true';
448         $entity->permissions()->delete();
449
450         if ($request->filled('restrictions')) {
451             foreach ($request->get('restrictions') as $roleId => $restrictions) {
452                 foreach ($restrictions as $action => $value) {
453                     $entity->permissions()->create([
454                         'role_id' => $roleId,
455                         'action'  => strtolower($action)
456                     ]);
457                 }
458             }
459         }
460
461         $entity->save();
462         $this->permissionService->buildJointPermissionsForEntity($entity);
463     }
464
465
466
467     /**
468      * Create a new entity from request input.
469      * Used for books and chapters.
470      * @param string $type
471      * @param array $input
472      * @param bool|Book $book
473      * @return \BookStack\Entities\Entity
474      */
475     public function createFromInput($type, $input = [], $book = false)
476     {
477         $isChapter = strtolower($type) === 'chapter';
478         $entityModel = $this->entityProvider->get($type)->newInstance($input);
479         $entityModel->slug = $this->findSuitableSlug($type, $entityModel->name, false, $isChapter ? $book->id : false);
480         $entityModel->created_by = user()->id;
481         $entityModel->updated_by = user()->id;
482         $isChapter ? $book->chapters()->save($entityModel) : $entityModel->save();
483
484         if (isset($input['tags'])) {
485             $this->tagRepo->saveTagsToEntity($entityModel, $input['tags']);
486         }
487
488         $this->permissionService->buildJointPermissionsForEntity($entityModel);
489         $this->searchService->indexEntity($entityModel);
490         return $entityModel;
491     }
492
493     /**
494      * Update entity details from request input.
495      * Used for books and chapters
496      * @param string $type
497      * @param \BookStack\Entities\Entity $entityModel
498      * @param array $input
499      * @return \BookStack\Entities\Entity
500      */
501     public function updateFromInput($type, Entity $entityModel, $input = [])
502     {
503         if ($entityModel->name !== $input['name']) {
504             $entityModel->slug = $this->findSuitableSlug($type, $input['name'], $entityModel->id);
505         }
506         $entityModel->fill($input);
507         $entityModel->updated_by = user()->id;
508         $entityModel->save();
509
510         if (isset($input['tags'])) {
511             $this->tagRepo->saveTagsToEntity($entityModel, $input['tags']);
512         }
513
514         $this->permissionService->buildJointPermissionsForEntity($entityModel);
515         $this->searchService->indexEntity($entityModel);
516         return $entityModel;
517     }
518
519     /**
520      * Sync the books assigned to a shelf from a comma-separated list
521      * of book IDs.
522      * @param \BookStack\Entities\Bookshelf $shelf
523      * @param string $books
524      */
525     public function updateShelfBooks(Bookshelf $shelf, string $books)
526     {
527         $ids = explode(',', $books);
528
529         // Check books exist and match ordering
530         $bookIds = $this->entityQuery('book')->whereIn('id', $ids)->get(['id'])->pluck('id');
531         $syncData = [];
532         foreach ($ids as $index => $id) {
533             if ($bookIds->contains($id)) {
534                 $syncData[$id] = ['order' => $index];
535             }
536         }
537
538         $shelf->books()->sync($syncData);
539     }
540
541     /**
542      * Change the book that an entity belongs to.
543      * @param string $type
544      * @param integer $newBookId
545      * @param Entity $entity
546      * @param bool $rebuildPermissions
547      * @return \BookStack\Entities\Entity
548      */
549     public function changeBook($type, $newBookId, Entity $entity, $rebuildPermissions = false)
550     {
551         $entity->book_id = $newBookId;
552         // Update related activity
553         foreach ($entity->activity as $activity) {
554             $activity->book_id = $newBookId;
555             $activity->save();
556         }
557         $entity->slug = $this->findSuitableSlug($type, $entity->name, $entity->id, $newBookId);
558         $entity->save();
559
560         // Update all child pages if a chapter
561         if (strtolower($type) === 'chapter') {
562             foreach ($entity->pages as $page) {
563                 $this->changeBook('page', $newBookId, $page, false);
564             }
565         }
566
567         // Update permissions if applicable
568         if ($rebuildPermissions) {
569             $entity->load('book');
570             $this->permissionService->buildJointPermissionsForEntity($entity->book);
571         }
572
573         return $entity;
574     }
575
576     /**
577      * Alias method to update the book jointPermissions in the PermissionService.
578      * @param Book $book
579      */
580     public function buildJointPermissionsForBook(Book $book)
581     {
582         $this->permissionService->buildJointPermissionsForEntity($book);
583     }
584
585     /**
586      * Format a name as a url slug.
587      * @param $name
588      * @return string
589      */
590     protected function nameToSlug($name)
591     {
592         $slug = preg_replace('/[\+\/\\\?\@\}\{\.\,\=\[\]\#\&\!\*\'\;\:\$\%]/', '', mb_strtolower($name));
593         $slug = preg_replace('/\s{2,}/', ' ', $slug);
594         $slug = str_replace(' ', '-', $slug);
595         if ($slug === "") {
596             $slug = substr(md5(rand(1, 500)), 0, 5);
597         }
598         return $slug;
599     }
600
601     /**
602      * Render the page for viewing
603      * @param Page $page
604      * @param bool $blankIncludes
605      * @return string
606      */
607     public function renderPage(Page $page, bool $blankIncludes = false) : string
608     {
609         $content = $page->html;
610
611         if (!config('app.allow_content_scripts')) {
612             $content = $this->escapeScripts($content);
613         }
614
615         if ($blankIncludes) {
616             $content = $this->blankPageIncludes($content);
617         } else {
618             $content = $this->parsePageIncludes($content);
619         }
620
621         return $content;
622     }
623
624     /**
625      * Remove any page include tags within the given HTML.
626      * @param string $html
627      * @return string
628      */
629     protected function blankPageIncludes(string $html) : string
630     {
631         return preg_replace("/{{@\s?([0-9].*?)}}/", '', $html);
632     }
633
634     /**
635      * Parse any include tags "{{@<page_id>#section}}" to be part of the page.
636      * @param string $html
637      * @return mixed|string
638      */
639     protected function parsePageIncludes(string $html) : string
640     {
641         $matches = [];
642         preg_match_all("/{{@\s?([0-9].*?)}}/", $html, $matches);
643
644         $topLevelTags = ['table', 'ul', 'ol'];
645         foreach ($matches[1] as $index => $includeId) {
646             $splitInclude = explode('#', $includeId, 2);
647             $pageId = intval($splitInclude[0]);
648             if (is_nan($pageId)) {
649                 continue;
650             }
651
652             $matchedPage = $this->getById('page', $pageId);
653             if ($matchedPage === null) {
654                 $html = str_replace($matches[0][$index], '', $html);
655                 continue;
656             }
657
658             if (count($splitInclude) === 1) {
659                 $html = str_replace($matches[0][$index], $matchedPage->html, $html);
660                 continue;
661             }
662
663             $doc = new DOMDocument();
664             $doc->loadHTML(mb_convert_encoding('<body>'.$matchedPage->html.'</body>', 'HTML-ENTITIES', 'UTF-8'));
665             $matchingElem = $doc->getElementById($splitInclude[1]);
666             if ($matchingElem === null) {
667                 $html = str_replace($matches[0][$index], '', $html);
668                 continue;
669             }
670             $innerContent = '';
671             $isTopLevel = in_array(strtolower($matchingElem->nodeName), $topLevelTags);
672             if ($isTopLevel) {
673                 $innerContent .= $doc->saveHTML($matchingElem);
674             } else {
675                 foreach ($matchingElem->childNodes as $childNode) {
676                     $innerContent .= $doc->saveHTML($childNode);
677                 }
678             }
679             $html = str_replace($matches[0][$index], trim($innerContent), $html);
680         }
681
682         return $html;
683     }
684
685     /**
686      * Escape script tags within HTML content.
687      * @param string $html
688      * @return string
689      */
690     protected function escapeScripts(string $html) : string
691     {
692         $scriptSearchRegex = '/<script.*?>.*?<\/script>/ms';
693         $matches = [];
694         preg_match_all($scriptSearchRegex, $html, $matches);
695
696         foreach ($matches[0] as $match) {
697             $html = str_replace($match, htmlentities($match), $html);
698         }
699         return $html;
700     }
701
702     /**
703      * Search for image usage within page content.
704      * @param $imageString
705      * @return mixed
706      */
707     public function searchForImage($imageString)
708     {
709         $pages = $this->entityQuery('page')->where('html', 'like', '%' . $imageString . '%')->get();
710         foreach ($pages as $page) {
711             $page->url = $page->getUrl();
712             $page->html = '';
713             $page->text = '';
714         }
715         return count($pages) > 0 ? $pages : false;
716     }
717
718     /**
719      * Destroy a bookshelf instance
720      * @param \BookStack\Entities\Bookshelf $shelf
721      * @throws \Throwable
722      */
723     public function destroyBookshelf(Bookshelf $shelf)
724     {
725         $this->destroyEntityCommonRelations($shelf);
726         $shelf->delete();
727     }
728
729     /**
730      * Destroy the provided book and all its child entities.
731      * @param \BookStack\Entities\Book $book
732      * @throws NotifyException
733      * @throws \Throwable
734      */
735     public function destroyBook(Book $book)
736     {
737         foreach ($book->pages as $page) {
738             $this->destroyPage($page);
739         }
740         foreach ($book->chapters as $chapter) {
741             $this->destroyChapter($chapter);
742         }
743         $this->destroyEntityCommonRelations($book);
744         $book->delete();
745     }
746
747     /**
748      * Destroy a chapter and its relations.
749      * @param \BookStack\Entities\Chapter $chapter
750      * @throws \Throwable
751      */
752     public function destroyChapter(Chapter $chapter)
753     {
754         if (count($chapter->pages) > 0) {
755             foreach ($chapter->pages as $page) {
756                 $page->chapter_id = 0;
757                 $page->save();
758             }
759         }
760         $this->destroyEntityCommonRelations($chapter);
761         $chapter->delete();
762     }
763
764     /**
765      * Destroy a given page along with its dependencies.
766      * @param Page $page
767      * @throws NotifyException
768      * @throws \Throwable
769      */
770     public function destroyPage(Page $page)
771     {
772         // Check if set as custom homepage
773         $customHome = setting('app-homepage', '0:');
774         if (intval($page->id) === intval(explode(':', $customHome)[0])) {
775             throw new NotifyException(trans('errors.page_custom_home_deletion'), $page->getUrl());
776         }
777
778         $this->destroyEntityCommonRelations($page);
779
780         // Delete Attached Files
781         $attachmentService = app(AttachmentService::class);
782         foreach ($page->attachments as $attachment) {
783             $attachmentService->deleteFile($attachment);
784         }
785
786         $page->delete();
787     }
788
789     /**
790      * Destroy or handle the common relations connected to an entity.
791      * @param \BookStack\Entities\Entity $entity
792      * @throws \Throwable
793      */
794     protected function destroyEntityCommonRelations(Entity $entity)
795     {
796         \Activity::removeEntity($entity);
797         $entity->views()->delete();
798         $entity->permissions()->delete();
799         $entity->tags()->delete();
800         $entity->comments()->delete();
801         $this->permissionService->deleteJointPermissionsForEntity($entity);
802         $this->searchService->deleteEntityTerms($entity);
803     }
804
805     /**
806      * Copy the permissions of a bookshelf to all child books.
807      * Returns the number of books that had permissions updated.
808      * @param \BookStack\Entities\Bookshelf $bookshelf
809      * @return int
810      * @throws \Throwable
811      */
812     public function copyBookshelfPermissions(Bookshelf $bookshelf)
813     {
814         $shelfPermissions = $bookshelf->permissions()->get(['role_id', 'action'])->toArray();
815         $shelfBooks = $bookshelf->books()->get();
816         $updatedBookCount = 0;
817
818         foreach ($shelfBooks as $book) {
819             if (!userCan('restrictions-manage', $book)) {
820                 continue;
821             }
822             $book->permissions()->delete();
823             $book->restricted = $bookshelf->restricted;
824             $book->permissions()->createMany($shelfPermissions);
825             $book->save();
826             $this->permissionService->buildJointPermissionsForEntity($book);
827             $updatedBookCount++;
828         }
829
830         return $updatedBookCount;
831     }
832 }