]> BookStack Code Mirror - bookstack/blobdiff - app/Entities/Repos/EntityRepo.php
Show bookshelves that a book belongs to on a book view
[bookstack] / app / Entities / Repos / EntityRepo.php
index 15340c90693706340234ab7b959a1cfbaab41d1f..0dd0fbb0af650f0e3c050171a48673a030a55ee3 100644 (file)
@@ -1,10 +1,12 @@
 <?php namespace BookStack\Entities\Repos;
 
+use Activity;
 use BookStack\Actions\TagRepo;
 use BookStack\Actions\ViewService;
 use BookStack\Auth\Permissions\PermissionService;
 use BookStack\Auth\User;
 use BookStack\Entities\Book;
+use BookStack\Entities\BookChild;
 use BookStack\Entities\Bookshelf;
 use BookStack\Entities\Chapter;
 use BookStack\Entities\Entity;
@@ -15,9 +17,13 @@ use BookStack\Exceptions\NotFoundException;
 use BookStack\Exceptions\NotifyException;
 use BookStack\Uploads\AttachmentService;
 use DOMDocument;
+use DOMXPath;
+use Illuminate\Contracts\Pagination\LengthAwarePaginator;
 use Illuminate\Database\Eloquent\Builder;
+use Illuminate\Database\Query\Builder as QueryBuilder;
 use Illuminate\Http\Request;
 use Illuminate\Support\Collection;
+use Throwable;
 
 class EntityRepo
 {
@@ -74,7 +80,7 @@ class EntityRepo
      * @param string $type
      * @param bool $allowDrafts
      * @param string $permission
-     * @return \Illuminate\Database\Query\Builder
+     * @return QueryBuilder
      */
     protected function entityQuery($type, $allowDrafts = false, $permission = 'view')
     {
@@ -102,7 +108,7 @@ class EntityRepo
      * @param integer $id
      * @param bool $allowDrafts
      * @param bool $ignorePermissions
-     * @return \BookStack\Entities\Entity
+     * @return Entity
      */
     public function getById($type, $id, $allowDrafts = false, $ignorePermissions = false)
     {
@@ -120,7 +126,7 @@ class EntityRepo
      * @param []int $ids
      * @param bool $allowDrafts
      * @param bool $ignorePermissions
-     * @return \Illuminate\Database\Eloquent\Builder[]|\Illuminate\Database\Eloquent\Collection|Collection
+     * @return Builder[]|\Illuminate\Database\Eloquent\Collection|Collection
      */
     public function getManyById($type, $ids, $allowDrafts = false, $ignorePermissions = false)
     {
@@ -137,25 +143,29 @@ class EntityRepo
      * Get an entity by its url slug.
      * @param string $type
      * @param string $slug
-     * @param string|bool $bookSlug
-     * @return \BookStack\Entities\Entity
+     * @param string|null $bookSlug
+     * @return Entity
      * @throws NotFoundException
      */
-    public function getBySlug($type, $slug, $bookSlug = false)
+    public function getEntityBySlug(string $type, string $slug, string $bookSlug = null): Entity
     {
-        $q = $this->entityQuery($type)->where('slug', '=', $slug);
+        $type = strtolower($type);
+        $query = $this->entityQuery($type)->where('slug', '=', $slug);
 
-        if (strtolower($type) === 'chapter' || strtolower($type) === 'page') {
-            $q = $q->where('book_id', '=', function ($query) use ($bookSlug) {
+        if ($type === 'chapter' || $type === 'page') {
+            $query = $query->where('book_id', '=', function (QueryBuilder $query) use ($bookSlug) {
                 $query->select('id')
                     ->from($this->entityProvider->book->getTable())
                     ->where('slug', '=', $bookSlug)->limit(1);
             });
         }
-        $entity = $q->first();
+
+        $entity = $query->first();
+
         if ($entity === null) {
-            throw new NotFoundException(trans('errors.' . strtolower($type) . '_not_found'));
+            throw new NotFoundException(trans('errors.' . $type . '_not_found'));
         }
+
         return $entity;
     }
 
@@ -182,15 +192,26 @@ class EntityRepo
      * @param int $count
      * @param string $sort
      * @param string $order
-     * @return \Illuminate\Contracts\Pagination\LengthAwarePaginator
+     * @param null|callable $queryAddition
+     * @return LengthAwarePaginator
      */
-    public function getAllPaginated($type, int $count = 10, string $sort = 'name', string $order = 'asc')
+    public function getAllPaginated($type, int $count = 10, string $sort = 'name', string $order = 'asc', $queryAddition = null)
     {
         $query = $this->entityQuery($type);
         $query = $this->addSortToQuery($query, $sort, $order);
+        if ($queryAddition) {
+            $queryAddition($query);
+        }
         return $query->paginate($count);
     }
 
+    /**
+     * Add sorting operations to an entity query.
+     * @param Builder $query
+     * @param string $sort
+     * @param string $order
+     * @return Builder
+     */
     protected function addSortToQuery(Builder $query, string $sort = 'name', string $order = 'asc')
     {
         $order = ($order === 'asc') ? 'asc' : 'desc';
@@ -282,15 +303,14 @@ class EntityRepo
 
     /**
      * Get the most popular entities base on all views.
-     * @param string|bool $type
+     * @param string $type
      * @param int $count
      * @param int $page
      * @return mixed
      */
-    public function getPopular($type, $count = 10, $page = 0)
+    public function getPopular(string $type, int $count = 10, int $page = 0)
     {
-        $filter = is_bool($type) ? false : $this->entityProvider->get($type);
-        return $this->viewService->getPopular($count, $page, $filter);
+        return $this->viewService->getPopular($count, $page, $type);
     }
 
     /**
@@ -322,7 +342,7 @@ class EntityRepo
     /**
      * Get the child items for a chapter sorted by priority but
      * with draft items floated to the top.
-     * @param \BookStack\Entities\Bookshelf $bookshelf
+     * @param Bookshelf $bookshelf
      * @return \Illuminate\Database\Eloquent\Collection|static[]
      */
     public function getBookshelfChildren(Bookshelf $bookshelf)
@@ -330,11 +350,23 @@ class EntityRepo
         return $this->permissionService->enforceEntityRestrictions('book', $bookshelf->books())->get();
     }
 
+    /**
+     * Get the direct children of a book.
+     * @param Book $book
+     * @return \Illuminate\Database\Eloquent\Collection
+     */
+    public function getBookDirectChildren(Book $book)
+    {
+        $pages = $this->permissionService->enforceEntityRestrictions('page', $book->directPages())->get();
+        $chapters = $this->permissionService->enforceEntityRestrictions('chapters', $book->chapters())->get();
+        return collect()->concat($pages)->concat($chapters)->sortBy('priority')->sortByDesc('draft');
+    }
+
     /**
      * Get all child objects of a book.
      * Returns a sorted collection of Pages and Chapters.
      * Loads the book slug onto child elements to prevent access database access for getting the slug.
-     * @param \BookStack\Entities\Book $book
+     * @param Book $book
      * @param bool $filterDrafts
      * @param bool $renderPages
      * @return mixed
@@ -381,10 +413,21 @@ class EntityRepo
         return collect($tree);
     }
 
+
+    /**
+     * Get the bookshelves that a book is contained in.
+     * @param Book $book
+     * @return \Illuminate\Database\Eloquent\Collection|static[]
+     */
+    public function getBookParentShelves(Book $book)
+    {
+        return $this->permissionService->enforceEntityRestrictions('shelf', $book->shelves())->get();
+    }
+
     /**
      * Get the child items for a chapter sorted by priority but
      * with draft items floated to the top.
-     * @param \BookStack\Entities\Chapter $chapter
+     * @param Chapter $chapter
      * @return \Illuminate\Database\Eloquent\Collection|static[]
      */
     public function getChapterChildren(Chapter $chapter)
@@ -396,7 +439,7 @@ class EntityRepo
 
     /**
      * Get the next sequential priority for a new child element in the given book.
-     * @param \BookStack\Entities\Book $book
+     * @param Book $book
      * @return int
      */
     public function getNewBookPriority(Book $book)
@@ -407,7 +450,7 @@ class EntityRepo
 
     /**
      * Get a new priority for a new page to be added to the given chapter.
-     * @param \BookStack\Entities\Chapter $chapter
+     * @param Chapter $chapter
      * @return int
      */
     public function getNewChapterPriority(Chapter $chapter)
@@ -433,31 +476,12 @@ class EntityRepo
         return $slug;
     }
 
-    /**
-     * Check if a slug already exists in the database.
-     * @param string $type
-     * @param string $slug
-     * @param bool|integer $currentId
-     * @param bool|integer $bookId
-     * @return bool
-     */
-    protected function slugExists($type, $slug, $currentId = false, $bookId = false)
-    {
-        $query = $this->entityProvider->get($type)->where('slug', '=', $slug);
-        if (strtolower($type) === 'page' || strtolower($type) === 'chapter') {
-            $query = $query->where('book_id', '=', $bookId);
-        }
-        if ($currentId) {
-            $query = $query->where('id', '!=', $currentId);
-        }
-        return $query->count() > 0;
-    }
 
     /**
      * Updates entity restrictions from a request
      * @param Request $request
-     * @param \BookStack\Entities\Entity $entity
-     * @throws \Throwable
+     * @param Entity $entity
+     * @throws Throwable
      */
     public function updateEntityPermissionsFromRequest(Request $request, Entity $entity)
     {
@@ -465,70 +489,73 @@ class EntityRepo
         $entity->permissions()->delete();
 
         if ($request->filled('restrictions')) {
-            foreach ($request->get('restrictions') as $roleId => $restrictions) {
-                foreach ($restrictions as $action => $value) {
-                    $entity->permissions()->create([
+            $entityPermissionData = collect($request->get('restrictions'))->flatMap(function($restrictions, $roleId) {
+                return collect($restrictions)->keys()->map(function($action) use ($roleId) {
+                    return [
                         'role_id' => $roleId,
-                        'action'  => strtolower($action)
-                    ]);
-                }
-            }
+                        'action' => strtolower($action),
+                    ] ;
+                });
+            });
+
+            $entity->permissions()->createMany($entityPermissionData);
         }
 
         $entity->save();
-        $this->permissionService->buildJointPermissionsForEntity($entity);
+        $entity->rebuildPermissions();
     }
 
 
-
     /**
      * Create a new entity from request input.
      * Used for books and chapters.
      * @param string $type
      * @param array $input
-     * @param bool|Book $book
-     * @return \BookStack\Entities\Entity
+     * @param Book|null $book
+     * @return Entity
      */
-    public function createFromInput($type, $input = [], $book = false)
+    public function createFromInput(string $type, array $input = [], Book $book = null)
     {
-        $isChapter = strtolower($type) === 'chapter';
         $entityModel = $this->entityProvider->get($type)->newInstance($input);
-        $entityModel->slug = $this->findSuitableSlug($type, $entityModel->name, false, $isChapter ? $book->id : false);
         $entityModel->created_by = user()->id;
         $entityModel->updated_by = user()->id;
-        $isChapter ? $book->chapters()->save($entityModel) : $entityModel->save();
+
+        if ($book) {
+            $entityModel->book_id = $book->id;
+        }
+
+        $entityModel->refreshSlug();
+        $entityModel->save();
 
         if (isset($input['tags'])) {
             $this->tagRepo->saveTagsToEntity($entityModel, $input['tags']);
         }
 
-        $this->permissionService->buildJointPermissionsForEntity($entityModel);
+        $entityModel->rebuildPermissions();
         $this->searchService->indexEntity($entityModel);
         return $entityModel;
     }
 
     /**
      * Update entity details from request input.
-     * Used for books and chapters
-     * @param string $type
-     * @param \BookStack\Entities\Entity $entityModel
-     * @param array $input
-     * @return \BookStack\Entities\Entity
+     * Used for shelves, books and chapters.
      */
-    public function updateFromInput($type, Entity $entityModel, $input = [])
+    public function updateFromInput(Entity $entityModel, array $input): Entity
     {
-        if ($entityModel->name !== $input['name']) {
-            $entityModel->slug = $this->findSuitableSlug($type, $input['name'], $entityModel->id);
-        }
         $entityModel->fill($input);
         $entityModel->updated_by = user()->id;
+
+        if ($entityModel->isDirty('name')) {
+            $entityModel->refreshSlug();
+        }
+
         $entityModel->save();
 
         if (isset($input['tags'])) {
             $this->tagRepo->saveTagsToEntity($entityModel, $input['tags']);
         }
 
-        $this->permissionService->buildJointPermissionsForEntity($entityModel);
+        $entityModel->rebuildPermissions();
         $this->searchService->indexEntity($entityModel);
         return $entityModel;
     }
@@ -536,7 +563,7 @@ class EntityRepo
     /**
      * Sync the books assigned to a shelf from a comma-separated list
      * of book IDs.
-     * @param \BookStack\Entities\Bookshelf $shelf
+     * @param Bookshelf $shelf
      * @param string $books
      */
     public function updateShelfBooks(Bookshelf $shelf, string $books)
@@ -557,62 +584,24 @@ class EntityRepo
 
     /**
      * Change the book that an entity belongs to.
-     * @param string $type
-     * @param integer $newBookId
-     * @param Entity $entity
-     * @param bool $rebuildPermissions
-     * @return \BookStack\Entities\Entity
      */
-    public function changeBook($type, $newBookId, Entity $entity, $rebuildPermissions = false)
+    public function changeBook(BookChild $bookChild, int $newBookId): Entity
     {
-        $entity->book_id = $newBookId;
+        $bookChild->book_id = $newBookId;
+        $bookChild->refreshSlug();
+        $bookChild->save();
+
         // Update related activity
-        foreach ($entity->activity as $activity) {
-            $activity->book_id = $newBookId;
-            $activity->save();
-        }
-        $entity->slug = $this->findSuitableSlug($type, $entity->name, $entity->id, $newBookId);
-        $entity->save();
+        $bookChild->activity()->update(['book_id' => $newBookId]);
 
         // Update all child pages if a chapter
-        if (strtolower($type) === 'chapter') {
-            foreach ($entity->pages as $page) {
-                $this->changeBook('page', $newBookId, $page, false);
+        if ($bookChild->isA('chapter')) {
+            foreach ($bookChild->pages as $page) {
+                $this->changeBook($page, $newBookId);
             }
         }
 
-        // Update permissions if applicable
-        if ($rebuildPermissions) {
-            $entity->load('book');
-            $this->permissionService->buildJointPermissionsForEntity($entity->book);
-        }
-
-        return $entity;
-    }
-
-    /**
-     * Alias method to update the book jointPermissions in the PermissionService.
-     * @param Book $book
-     */
-    public function buildJointPermissionsForBook(Book $book)
-    {
-        $this->permissionService->buildJointPermissionsForEntity($book);
-    }
-
-    /**
-     * Format a name as a url slug.
-     * @param $name
-     * @return string
-     */
-    protected function nameToSlug($name)
-    {
-        $slug = preg_replace('/[\+\/\\\?\@\}\{\.\,\=\[\]\#\&\!\*\'\;\:\$\%]/', '', mb_strtolower($name));
-        $slug = preg_replace('/\s{2,}/', ' ', $slug);
-        $slug = str_replace(' ', '-', $slug);
-        if ($slug === "") {
-            $slug = substr(md5(rand(1, 500)), 0, 5);
-        }
-        return $slug;
+        return $bookChild;
     }
 
     /**
@@ -678,6 +667,7 @@ class EntityRepo
             }
 
             $doc = new DOMDocument();
+            libxml_use_internal_errors(true);
             $doc->loadHTML(mb_convert_encoding('<body>'.$matchedPage->html.'</body>', 'HTML-ENTITIES', 'UTF-8'));
             $matchingElem = $doc->getElementById($splitInclude[1]);
             if ($matchingElem === null) {
@@ -693,6 +683,7 @@ class EntityRepo
                     $innerContent .= $doc->saveHTML($childNode);
                 }
             }
+            libxml_clear_errors();
             $html = str_replace($matches[0][$index], trim($innerContent), $html);
         }
 
@@ -706,13 +697,41 @@ class EntityRepo
      */
     protected function escapeScripts(string $html) : string
     {
-        $scriptSearchRegex = '/<script.*?>.*?<\/script>/ms';
-        $matches = [];
-        preg_match_all($scriptSearchRegex, $html, $matches);
+        if ($html == '') {
+            return $html;
+        }
+
+        libxml_use_internal_errors(true);
+        $doc = new DOMDocument();
+        $doc->loadHTML(mb_convert_encoding($html, 'HTML-ENTITIES', 'UTF-8'));
+        $xPath = new DOMXPath($doc);
+
+        // Remove standard script tags
+        $scriptElems = $xPath->query('//script');
+        foreach ($scriptElems as $scriptElem) {
+            $scriptElem->parentNode->removeChild($scriptElem);
+        }
+
+        // Remove data or JavaScript iFrames
+        $badIframes = $xPath->query('//*[contains(@src, \'data:\')] | //*[contains(@src, \'javascript:\')] | //*[@srcdoc]');
+        foreach ($badIframes as $badIframe) {
+            $badIframe->parentNode->removeChild($badIframe);
+        }
+
+        // Remove 'on*' attributes
+        $onAttributes = $xPath->query('//@*[starts-with(name(), \'on\')]');
+        foreach ($onAttributes as $attr) {
+            /** @var \DOMAttr $attr*/
+            $attrName = $attr->nodeName;
+            $attr->parentNode->removeAttribute($attrName);
+        }
 
-        foreach ($matches[0] as $match) {
-            $html = str_replace($match, htmlentities($match), $html);
+        $html = '';
+        $topElems = $doc->documentElement->childNodes->item(0)->childNodes;
+        foreach ($topElems as $child) {
+            $html .= $doc->saveHTML($child);
         }
+
         return $html;
     }
 
@@ -723,7 +742,7 @@ class EntityRepo
      */
     public function searchForImage($imageString)
     {
-        $pages = $this->entityQuery('page')->where('html', 'like', '%' . $imageString . '%')->get();
+        $pages = $this->entityQuery('page')->where('html', 'like', '%' . $imageString . '%')->get(['id', 'name', 'slug', 'book_id']);
         foreach ($pages as $page) {
             $page->url = $page->getUrl();
             $page->html = '';
@@ -734,8 +753,8 @@ class EntityRepo
 
     /**
      * Destroy a bookshelf instance
-     * @param \BookStack\Entities\Bookshelf $shelf
-     * @throws \Throwable
+     * @param Bookshelf $shelf
+     * @throws Throwable
      */
     public function destroyBookshelf(Bookshelf $shelf)
     {
@@ -743,28 +762,10 @@ class EntityRepo
         $shelf->delete();
     }
 
-    /**
-     * Destroy the provided book and all its child entities.
-     * @param \BookStack\Entities\Book $book
-     * @throws NotifyException
-     * @throws \Throwable
-     */
-    public function destroyBook(Book $book)
-    {
-        foreach ($book->pages as $page) {
-            $this->destroyPage($page);
-        }
-        foreach ($book->chapters as $chapter) {
-            $this->destroyChapter($chapter);
-        }
-        $this->destroyEntityCommonRelations($book);
-        $book->delete();
-    }
-
     /**
      * Destroy a chapter and its relations.
-     * @param \BookStack\Entities\Chapter $chapter
-     * @throws \Throwable
+     * @param Chapter $chapter
+     * @throws Throwable
      */
     public function destroyChapter(Chapter $chapter)
     {
@@ -782,14 +783,17 @@ class EntityRepo
      * Destroy a given page along with its dependencies.
      * @param Page $page
      * @throws NotifyException
-     * @throws \Throwable
+     * @throws Throwable
      */
     public function destroyPage(Page $page)
     {
-        // Check if set as custom homepage
+        // Check if set as custom homepage & remove setting if not used or throw error if active
         $customHome = setting('app-homepage', '0:');
         if (intval($page->id) === intval(explode(':', $customHome)[0])) {
-            throw new NotifyException(trans('errors.page_custom_home_deletion'), $page->getUrl());
+            if (setting('app-homepage-type') === 'page') {
+                throw new NotifyException(trans('errors.page_custom_home_deletion'), $page->getUrl());
+            }
+            setting()->remove('app-homepage');
         }
 
         $this->destroyEntityCommonRelations($page);
@@ -805,12 +809,12 @@ class EntityRepo
 
     /**
      * Destroy or handle the common relations connected to an entity.
-     * @param \BookStack\Entities\Entity $entity
-     * @throws \Throwable
+     * @param Entity $entity
+     * @throws Throwable
      */
     protected function destroyEntityCommonRelations(Entity $entity)
     {
-        \Activity::removeEntity($entity);
+        Activity::removeEntity($entity);
         $entity->views()->delete();
         $entity->permissions()->delete();
         $entity->tags()->delete();
@@ -822,9 +826,9 @@ class EntityRepo
     /**
      * Copy the permissions of a bookshelf to all child books.
      * Returns the number of books that had permissions updated.
-     * @param \BookStack\Entities\Bookshelf $bookshelf
+     * @param Bookshelf $bookshelf
      * @return int
-     * @throws \Throwable
+     * @throws Throwable
      */
     public function copyBookshelfPermissions(Bookshelf $bookshelf)
     {
@@ -832,6 +836,7 @@ class EntityRepo
         $shelfBooks = $bookshelf->books()->get();
         $updatedBookCount = 0;
 
+        /** @var Book $book */
         foreach ($shelfBooks as $book) {
             if (!userCan('restrictions-manage', $book)) {
                 continue;
@@ -840,7 +845,7 @@ class EntityRepo
             $book->restricted = $bookshelf->restricted;
             $book->permissions()->createMany($shelfPermissions);
             $book->save();
-            $this->permissionService->buildJointPermissionsForEntity($book);
+            $book->rebuildPermissions();
             $updatedBookCount++;
         }