]> BookStack Code Mirror - bookstack/blob - app/Entities/Tools/BookContents.php
Added additional permission checks and tests for book sorts
[bookstack] / app / Entities / Tools / BookContents.php
1 <?php
2
3 namespace BookStack\Entities\Tools;
4
5 use BookStack\Entities\Models\Book;
6 use BookStack\Entities\Models\BookChild;
7 use BookStack\Entities\Models\Chapter;
8 use BookStack\Entities\Models\Entity;
9 use BookStack\Entities\Models\Page;
10 use Illuminate\Support\Collection;
11
12 class BookContents
13 {
14     /**
15      * @var Book
16      */
17     protected $book;
18
19     /**
20      * BookContents constructor.
21      */
22     public function __construct(Book $book)
23     {
24         $this->book = $book;
25     }
26
27     /**
28      * Get the current priority of the last item
29      * at the top-level of the book.
30      */
31     public function getLastPriority(): int
32     {
33         $maxPage = Page::visible()->where('book_id', '=', $this->book->id)
34             ->where('draft', '=', false)
35             ->where('chapter_id', '=', 0)->max('priority');
36         $maxChapter = Chapter::visible()->where('book_id', '=', $this->book->id)
37             ->max('priority');
38
39         return max($maxChapter, $maxPage, 1);
40     }
41
42     /**
43      * Get the contents as a sorted collection tree.
44      */
45     public function getTree(bool $showDrafts = false, bool $renderPages = false): Collection
46     {
47         $pages = $this->getPages($showDrafts, $renderPages);
48         $chapters = Chapter::visible()->where('book_id', '=', $this->book->id)->get();
49         $all = collect()->concat($pages)->concat($chapters);
50         $chapterMap = $chapters->keyBy('id');
51         $lonePages = collect();
52
53         $pages->groupBy('chapter_id')->each(function ($pages, $chapter_id) use ($chapterMap, &$lonePages) {
54             $chapter = $chapterMap->get($chapter_id);
55             if ($chapter) {
56                 $chapter->setAttribute('visible_pages', collect($pages)->sortBy($this->bookChildSortFunc()));
57             } else {
58                 $lonePages = $lonePages->concat($pages);
59             }
60         });
61
62         $chapters->whereNull('visible_pages')->each(function (Chapter $chapter) {
63             $chapter->setAttribute('visible_pages', collect([]));
64         });
65
66         $all->each(function (Entity $entity) use ($renderPages) {
67             $entity->setRelation('book', $this->book);
68
69             if ($renderPages && $entity instanceof Page) {
70                 $entity->html = (new PageContent($entity))->render();
71             }
72         });
73
74         return collect($chapters)->concat($lonePages)->sortBy($this->bookChildSortFunc());
75     }
76
77     /**
78      * Function for providing a sorting score for an entity in relation to the
79      * other items within the book.
80      */
81     protected function bookChildSortFunc(): callable
82     {
83         return function (Entity $entity) {
84             if (isset($entity['draft']) && $entity['draft']) {
85                 return -100;
86             }
87
88             return $entity['priority'] ?? 0;
89         };
90     }
91
92     /**
93      * Get the visible pages within this book.
94      */
95     protected function getPages(bool $showDrafts = false, bool $getPageContent = false): Collection
96     {
97         $query = Page::visible()
98             ->select($getPageContent ? Page::$contentAttributes : Page::$listAttributes)
99             ->where('book_id', '=', $this->book->id);
100
101         if (!$showDrafts) {
102             $query->where('draft', '=', false);
103         }
104
105         return $query->get();
106     }
107
108     /**
109      * Sort the books content using the given sort map.
110      * Returns a list of books that were involved in the operation.
111      *
112      * @returns Book[]
113      */
114     public function sortUsingMap(BookSortMap $sortMap): array
115     {
116         // Load models into map
117         $modelMap = $this->loadModelsFromSortMap($sortMap);
118
119         // Sort our changes from our map to be chapters first
120         // Since they need to be process to ensure book alignment for child page changes.
121         $sortMapItems = $sortMap->all();
122         usort($sortMapItems, function(BookSortMapItem $itemA, BookSortMapItem $itemB) {
123             $aScore = $itemA->type === 'page' ? 2 : 1;
124             $bScore = $itemB->type === 'page' ? 2 : 1;
125             return $aScore - $bScore;
126         });
127
128         // Perform the sort
129         foreach ($sortMapItems as $item) {
130             $this->applySortUpdates($item, $modelMap);
131         }
132
133         /** @var Book[] $booksInvolved */
134         $booksInvolved = array_values(array_filter($modelMap, function (string $key) {
135             return strpos($key, 'book:') === 0;
136         }, ARRAY_FILTER_USE_KEY));
137
138         // Update permissions of books involved
139         foreach ($booksInvolved as $book) {
140             $book->rebuildPermissions();
141         }
142
143         return $booksInvolved;
144     }
145
146     /**
147      * Using the given sort map item, detect changes for the related model
148      * and update it if required. Changes where permissions are lacking will
149      * be skipped and not throw an error.
150      *
151      * @param array<string, Entity> $modelMap
152      */
153     protected function applySortUpdates(BookSortMapItem $sortMapItem, array $modelMap): void
154     {
155         /** @var BookChild $model */
156         $model = $modelMap[$sortMapItem->type . ':' . $sortMapItem->id] ?? null;
157         if (!$model) {
158             return;
159         }
160
161         $priorityChanged = $model->priority !== $sortMapItem->sort;
162         $bookChanged = $model->book_id !== $sortMapItem->parentBookId;
163         $chapterChanged = ($model instanceof Page) && $model->chapter_id !== $sortMapItem->parentChapterId;
164
165         // Stop if there's no change
166         if (!$priorityChanged && !$bookChanged && !$chapterChanged) {
167             return;
168         }
169
170         $currentParentKey =  'book:' . $model->book_id;
171         if ($model instanceof Page && $model->chapter_id) {
172              $currentParentKey = 'chapter:' . $model->chapter_id;
173         }
174
175         $currentParent = $modelMap[$currentParentKey] ?? null;
176         /** @var Book $newBook */
177         $newBook = $modelMap['book:' . $sortMapItem->parentBookId] ?? null;
178         /** @var ?Chapter $newChapter */
179         $newChapter = $sortMapItem->parentChapterId ? ($modelMap['chapter:' . $sortMapItem->parentChapterId] ?? null) : null;
180
181         if (!$this->isSortChangePermissible($sortMapItem, $model, $currentParent, $newBook, $newChapter)) {
182             return;
183         }
184
185         // Action the required changes
186         if ($bookChanged) {
187             $model->changeBook($newBook->id);
188         }
189
190         if ($chapterChanged) {
191             $model->chapter_id = $newChapter->id ?? 0;
192         }
193
194         if ($priorityChanged) {
195             $model->priority = $sortMapItem->sort;
196         }
197
198         if ($chapterChanged || $priorityChanged) {
199             $model->save();
200         }
201     }
202
203     /**
204      * Check if the current user has permissions to apply the given sorting change.
205      * Is quite complex since items can gain a different parent change. Acts as a:
206      * - Update of old parent element (Change of content/order).
207      * - Update of sorted/moved element.
208      * - Deletion of element (Relative to parent upon move).
209      * - Creation of element within parent (Upon move to new parent).
210      */
211     protected function isSortChangePermissible(BookSortMapItem $sortMapItem, BookChild $model, ?Entity $currentParent, ?Entity $newBook, ?Entity $newChapter): bool
212     {
213         // Stop if we can't see the current parent or new book.
214         if (!$currentParent || !$newBook) {
215             return false;
216         }
217
218         $hasNewParent = $newBook->id !== $model->book_id || ($model instanceof Page && $model->chapter_id !== ($sortMapItem->parentChapterId ?? 0));
219         if ($model instanceof Chapter) {
220             $hasPermission = userCan('book-update', $currentParent)
221                 && userCan('book-update', $newBook)
222                 && userCan('chapter-update', $model)
223                 && (!$hasNewParent || userCan('chapter-create', $newBook))
224                 && (!$hasNewParent || userCan('chapter-delete', $model));
225
226             if (!$hasPermission) {
227                 return false;
228             }
229         }
230
231         if ($model instanceof Page) {
232             $parentPermission = ($currentParent instanceof Chapter) ? 'chapter-update' : 'book-update';
233             $hasCurrentParentPermission = userCan($parentPermission, $currentParent);
234
235             // This needs to check if there was an intended chapter location in the original sort map
236             // rather than inferring from the $newChapter since that variable may be null
237             // due to other reasons (Visibility).
238             $newParent = $sortMapItem->parentChapterId ? $newChapter : $newBook;
239             if (!$newParent) {
240                 return false;
241             }
242
243             $hasPageEditPermission = userCan('page-update', $model);
244             $newParentInRightLocation = ($newParent instanceof Book || $newParent->book_id === $newBook->id);
245             $newParentPermission = ($newParent instanceof Chapter) ? 'chapter-update' : 'book-update';
246             $hasNewParentPermission = userCan($newParentPermission, $newParent);
247
248             $hasDeletePermissionIfMoving = (!$hasNewParent || userCan('page-delete', $model));
249             $hasCreatePermissionIfMoving = (!$hasNewParent || userCan('page-create', $newParent));
250
251             $hasPermission = $hasCurrentParentPermission
252                 && $newParentInRightLocation
253                 && $hasNewParentPermission
254                 && $hasPageEditPermission
255                 && $hasDeletePermissionIfMoving
256                 && $hasCreatePermissionIfMoving;
257
258             if (!$hasPermission) {
259                 return false;
260             }
261         }
262
263         return true;
264     }
265
266     /**
267      * Load models from the database into the given sort map.
268      * @return array<string, Entity>
269      */
270     protected function loadModelsFromSortMap(BookSortMap $sortMap): array
271     {
272         $modelMap = [];
273         $ids = [
274             'chapter' => [],
275             'page' => [],
276             'book' => [],
277         ];
278
279         foreach ($sortMap->all() as $sortMapItem) {
280             $ids[$sortMapItem->type][] = $sortMapItem->id;
281             $ids['book'][] = $sortMapItem->parentBookId;
282             if ($sortMapItem->parentChapterId) {
283                 $ids['chapter'][] = $sortMapItem->parentChapterId;
284             }
285         }
286
287         $pages = Page::visible()->whereIn('id', array_unique($ids['page']))->get(Page::$listAttributes);
288         /** @var Page $page */
289         foreach ($pages as $page) {
290             $modelMap['page:' . $page->id] = $page;
291             $ids['book'][] = $page->book_id;
292             if ($page->chapter_id) {
293                 $ids['chapter'][] = $page->chapter_id;
294             }
295         }
296
297         $chapters = Chapter::visible()->whereIn('id', array_unique($ids['chapter']))->get();
298         /** @var Chapter $chapter */
299         foreach ($chapters as $chapter) {
300             $modelMap['chapter:' . $chapter->id] = $chapter;
301             $ids['book'][] = $chapter->book_id;
302         }
303
304         $books = Book::visible()->whereIn('id', array_unique($ids['book']))->get();
305         /** @var Book $book */
306         foreach ($books as $book) {
307             $modelMap['book:' . $book->id] = $book;
308         }
309
310         return $modelMap;
311     }
312 }