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