X-Git-Url: http://source.bookstackapp.com/bookstack/blobdiff_plain/edc7c12edfbe4cabcf6d9a5090d29bb947ef35fb..refs/pull/3598/head:/app/Entities/Tools/BookContents.php diff --git a/app/Entities/Tools/BookContents.php b/app/Entities/Tools/BookContents.php index 96142bb7f..6f11e8cbe 100644 --- a/app/Entities/Tools/BookContents.php +++ b/app/Entities/Tools/BookContents.php @@ -7,7 +7,6 @@ use BookStack\Entities\Models\BookChild; use BookStack\Entities\Models\Chapter; use BookStack\Entities\Models\Entity; use BookStack\Entities\Models\Page; -use BookStack\Exceptions\SortOperationException; use Illuminate\Support\Collection; class BookContents @@ -110,34 +109,52 @@ class BookContents * Sort the books content using the given sort map. * Returns a list of books that were involved in the operation. * - * @throws SortOperationException + * @returns Book[] */ - public function sortUsingMap(BookSortMap $sortMap): Collection + public function sortUsingMap(BookSortMap $sortMap): array { // Load models into map - $this->loadModelsIntoSortMap($sortMap); - $booksInvolved = $this->getBooksInvolvedInSort($sortMap); + $modelMap = $this->loadModelsFromSortMap($sortMap); + + // Sort our changes from our map to be chapters first + // Since they need to be process to ensure book alignment for child page changes. + $sortMapItems = $sortMap->all(); + usort($sortMapItems, function (BookSortMapItem $itemA, BookSortMapItem $itemB) { + $aScore = $itemA->type === 'page' ? 2 : 1; + $bScore = $itemB->type === 'page' ? 2 : 1; + + return $aScore - $bScore; + }); // Perform the sort - foreach ($sortMap->all() as $item) { - $this->applySortUpdates($item); + foreach ($sortMapItems as $item) { + $this->applySortUpdates($item, $modelMap); } - // Update permissions and activity. - $booksInvolved->each(function (Book $book) { + /** @var Book[] $booksInvolved */ + $booksInvolved = array_values(array_filter($modelMap, function (string $key) { + return strpos($key, 'book:') === 0; + }, ARRAY_FILTER_USE_KEY)); + + // Update permissions of books involved + foreach ($booksInvolved as $book) { $book->rebuildPermissions(); - }); + } return $booksInvolved; } /** * Using the given sort map item, detect changes for the related model - * and update it if required. + * and update it if required. Changes where permissions are lacking will + * be skipped and not throw an error. + * + * @param array $modelMap */ - protected function applySortUpdates(BookSortMapItem $sortMapItem): void + protected function applySortUpdates(BookSortMapItem $sortMapItem, array $modelMap): void { - $model = $sortMapItem->model; + /** @var BookChild $model */ + $model = $modelMap[$sortMapItem->type . ':' . $sortMapItem->id] ?? null; if (!$model) { return; } @@ -146,73 +163,152 @@ class BookContents $bookChanged = $model->book_id !== $sortMapItem->parentBookId; $chapterChanged = ($model instanceof Page) && $model->chapter_id !== $sortMapItem->parentChapterId; + // Stop if there's no change + if (!$priorityChanged && !$bookChanged && !$chapterChanged) { + return; + } + + $currentParentKey = 'book:' . $model->book_id; + if ($model instanceof Page && $model->chapter_id) { + $currentParentKey = 'chapter:' . $model->chapter_id; + } + + $currentParent = $modelMap[$currentParentKey] ?? null; + /** @var Book $newBook */ + $newBook = $modelMap['book:' . $sortMapItem->parentBookId] ?? null; + /** @var ?Chapter $newChapter */ + $newChapter = $sortMapItem->parentChapterId ? ($modelMap['chapter:' . $sortMapItem->parentChapterId] ?? null) : null; + + if (!$this->isSortChangePermissible($sortMapItem, $model, $currentParent, $newBook, $newChapter)) { + return; + } + + // Action the required changes if ($bookChanged) { - $model->changeBook($sortMapItem->parentBookId); + $model->changeBook($newBook->id); } if ($chapterChanged) { - $model->chapter_id = intval($sortMapItem->parentChapterId); - $model->save(); + $model->chapter_id = $newChapter->id ?? 0; } if ($priorityChanged) { $model->priority = $sortMapItem->sort; + } + + if ($chapterChanged || $priorityChanged) { $model->save(); } } /** - * Load models from the database into the given sort map. + * Check if the current user has permissions to apply the given sorting change. + * Is quite complex since items can gain a different parent change. Acts as a: + * - Update of old parent element (Change of content/order). + * - Update of sorted/moved element. + * - Deletion of element (Relative to parent upon move). + * - Creation of element within parent (Upon move to new parent). */ - protected function loadModelsIntoSortMap(BookSortMap $sortMap): void + protected function isSortChangePermissible(BookSortMapItem $sortMapItem, BookChild $model, ?Entity $currentParent, ?Entity $newBook, ?Entity $newChapter): bool { - $collection = collect($sortMap->all()); + // Stop if we can't see the current parent or new book. + if (!$currentParent || !$newBook) { + return false; + } - $keyMap = $collection->keyBy(function (BookSortMapItem $sortMapItem) { - return $sortMapItem->type . ':' . $sortMapItem->id; - }); + $hasNewParent = $newBook->id !== $model->book_id || ($model instanceof Page && $model->chapter_id !== ($sortMapItem->parentChapterId ?? 0)); + if ($model instanceof Chapter) { + $hasPermission = userCan('book-update', $currentParent) + && userCan('book-update', $newBook) + && userCan('chapter-update', $model) + && (!$hasNewParent || userCan('chapter-create', $newBook)) + && (!$hasNewParent || userCan('chapter-delete', $model)); - $pageIds = $collection->where('type', '=', 'page')->pluck('id'); - $chapterIds = $collection->where('type', '=', 'chapter')->pluck('id'); + if (!$hasPermission) { + return false; + } + } - $pages = Page::visible()->whereIn('id', $pageIds)->get(); - $chapters = Chapter::visible()->whereIn('id', $chapterIds)->get(); + if ($model instanceof Page) { + $parentPermission = ($currentParent instanceof Chapter) ? 'chapter-update' : 'book-update'; + $hasCurrentParentPermission = userCan($parentPermission, $currentParent); - foreach ($pages as $page) { - /** @var BookSortMapItem $sortItem */ - $sortItem = $keyMap->get('page:' . $page->id); - $sortItem->model = $page; - } + // This needs to check if there was an intended chapter location in the original sort map + // rather than inferring from the $newChapter since that variable may be null + // due to other reasons (Visibility). + $newParent = $sortMapItem->parentChapterId ? $newChapter : $newBook; + if (!$newParent) { + return false; + } - foreach ($chapters as $chapter) { - /** @var BookSortMapItem $sortItem */ - $sortItem = $keyMap->get('chapter:' . $chapter->id); - $sortItem->model = $chapter; + $hasPageEditPermission = userCan('page-update', $model); + $newParentInRightLocation = ($newParent instanceof Book || $newParent->book_id === $newBook->id); + $newParentPermission = ($newParent instanceof Chapter) ? 'chapter-update' : 'book-update'; + $hasNewParentPermission = userCan($newParentPermission, $newParent); + + $hasDeletePermissionIfMoving = (!$hasNewParent || userCan('page-delete', $model)); + $hasCreatePermissionIfMoving = (!$hasNewParent || userCan('page-create', $newParent)); + + $hasPermission = $hasCurrentParentPermission + && $newParentInRightLocation + && $hasNewParentPermission + && $hasPageEditPermission + && $hasDeletePermissionIfMoving + && $hasCreatePermissionIfMoving; + + if (!$hasPermission) { + return false; + } } + + return true; } /** - * Get the books involved in a sort. - * The given sort map should have its models loaded first. + * Load models from the database into the given sort map. * - * @throws SortOperationException + * @return array */ - protected function getBooksInvolvedInSort(BookSortMap $sortMap): Collection + protected function loadModelsFromSortMap(BookSortMap $sortMap): array { - $collection = collect($sortMap->all()); + $modelMap = []; + $ids = [ + 'chapter' => [], + 'page' => [], + 'book' => [], + ]; + + foreach ($sortMap->all() as $sortMapItem) { + $ids[$sortMapItem->type][] = $sortMapItem->id; + $ids['book'][] = $sortMapItem->parentBookId; + if ($sortMapItem->parentChapterId) { + $ids['chapter'][] = $sortMapItem->parentChapterId; + } + } + + $pages = Page::visible()->whereIn('id', array_unique($ids['page']))->get(Page::$listAttributes); + /** @var Page $page */ + foreach ($pages as $page) { + $modelMap['page:' . $page->id] = $page; + $ids['book'][] = $page->book_id; + if ($page->chapter_id) { + $ids['chapter'][] = $page->chapter_id; + } + } - $bookIdsInvolved = array_unique(array_merge( - [$this->book->id], - $collection->pluck('parentBookId')->values()->all(), - $collection->pluck('model.book_id')->values()->all(), - )); - - $books = Book::hasPermission('update')->whereIn('id', $bookIdsInvolved)->get(); + $chapters = Chapter::visible()->whereIn('id', array_unique($ids['chapter']))->get(); + /** @var Chapter $chapter */ + foreach ($chapters as $chapter) { + $modelMap['chapter:' . $chapter->id] = $chapter; + $ids['book'][] = $chapter->book_id; + } - if (count($books) !== count($bookIdsInvolved)) { - throw new SortOperationException('Could not find all books requested in sort operation'); + $books = Book::visible()->whereIn('id', array_unique($ids['book']))->get(); + /** @var Book $book */ + foreach ($books as $book) { + $modelMap['book:' . $book->id] = $book; } - return $books; + return $modelMap; } }