<?php namespace BookStack\Entities\Repos;
+use Activity;
use BookStack\Actions\TagRepo;
use BookStack\Actions\ViewService;
use BookStack\Auth\Permissions\PermissionService;
use BookStack\Exceptions\NotifyException;
use BookStack\Uploads\AttachmentService;
use DOMDocument;
+use DOMNode;
+use DOMXPath;
+use Illuminate\Contracts\Pagination\LengthAwarePaginator;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Http\Request;
use Illuminate\Support\Collection;
+use Throwable;
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)
{
* @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)
{
* @param string $type
* @param string $slug
* @param string|bool $bookSlug
- * @return \BookStack\Entities\Entity
+ * @return Entity
* @throws NotFoundException
*/
public function getBySlug($type, $slug, $bookSlug = false)
* @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';
/**
* 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);
}
/**
/**
* 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)
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
/**
* 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)
/**
* 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)
/**
* 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)
/**
* 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)
{
* @param string $type
* @param array $input
* @param bool|Book $book
- * @return \BookStack\Entities\Entity
+ * @return Entity
*/
public function createFromInput($type, $input = [], $book = false)
{
* Update entity details from request input.
* Used for books and chapters
* @param string $type
- * @param \BookStack\Entities\Entity $entityModel
+ * @param Entity $entityModel
* @param array $input
- * @return \BookStack\Entities\Entity
+ * @return Entity
*/
public function updateFromInput($type, Entity $entityModel, $input = [])
{
/**
* 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)
$shelf->books()->sync($syncData);
}
+ /**
+ * Append a Book to a BookShelf.
+ * @param Bookshelf $shelf
+ * @param Book $book
+ */
+ public function appendBookToShelf(Bookshelf $shelf, Book $book)
+ {
+ if ($shelf->contains($book)) {
+ return;
+ }
+
+ $maxOrder = $shelf->books()->max('order');
+ $shelf->books()->attach($book->id, ['order' => $maxOrder + 1]);
+ }
+
/**
* Change the book that an entity belongs to.
* @param string $type
* @param integer $newBookId
* @param Entity $entity
* @param bool $rebuildPermissions
- * @return \BookStack\Entities\Entity
+ * @return Entity
*/
public function changeBook($type, $newBookId, Entity $entity, $rebuildPermissions = false)
{
}
/**
- * Render the page for viewing, Parsing and performing features such as page transclusion.
+ * Render the page for viewing
* @param Page $page
- * @param bool $ignorePermissions
- * @return mixed|string
+ * @param bool $blankIncludes
+ * @return string
*/
- public function renderPage(Page $page, $ignorePermissions = false)
+ public function renderPage(Page $page, bool $blankIncludes = false) : string
{
$content = $page->html;
+
if (!config('app.allow_content_scripts')) {
$content = $this->escapeScripts($content);
}
- $matches = [];
- preg_match_all("/{{@\s?([0-9].*?)}}/", $content, $matches);
- if (count($matches[0]) === 0) {
- return $content;
+ if ($blankIncludes) {
+ $content = $this->blankPageIncludes($content);
+ } else {
+ $content = $this->parsePageIncludes($content);
}
+ return $content;
+ }
+
+ /**
+ * Remove any page include tags within the given HTML.
+ * @param string $html
+ * @return string
+ */
+ protected function blankPageIncludes(string $html) : string
+ {
+ return preg_replace("/{{@\s?([0-9].*?)}}/", '', $html);
+ }
+
+ /**
+ * Parse any include tags "{{@<page_id>#section}}" to be part of the page.
+ * @param string $html
+ * @return mixed|string
+ */
+ protected function parsePageIncludes(string $html) : string
+ {
+ $matches = [];
+ preg_match_all("/{{@\s?([0-9].*?)}}/", $html, $matches);
+
$topLevelTags = ['table', 'ul', 'ol'];
foreach ($matches[1] as $index => $includeId) {
$splitInclude = explode('#', $includeId, 2);
continue;
}
- $matchedPage = $this->getById('page', $pageId, false, $ignorePermissions);
+ $matchedPage = $this->getById('page', $pageId);
if ($matchedPage === null) {
- $content = str_replace($matches[0][$index], '', $content);
+ $html = str_replace($matches[0][$index], '', $html);
continue;
}
if (count($splitInclude) === 1) {
- $content = str_replace($matches[0][$index], $matchedPage->html, $content);
+ $html = str_replace($matches[0][$index], $matchedPage->html, $html);
continue;
}
$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) {
- $content = str_replace($matches[0][$index], '', $content);
+ $html = str_replace($matches[0][$index], '', $html);
continue;
}
$innerContent = '';
$innerContent .= $doc->saveHTML($childNode);
}
}
- $content = str_replace($matches[0][$index], trim($innerContent), $content);
+ libxml_clear_errors();
+ $html = str_replace($matches[0][$index], trim($innerContent), $html);
}
- return $content;
+ return $html;
}
/**
* Escape script tags within HTML content.
* @param string $html
- * @return mixed
+ * @return string
*/
- protected function escapeScripts(string $html)
+ protected function escapeScripts(string $html) : string
{
- $scriptSearchRegex = '/<script.*?>.*?<\/script>/ms';
- $matches = [];
- preg_match_all($scriptSearchRegex, $html, $matches);
- if (count($matches) === 0) {
+ if ($html == '') {
return $html;
}
- foreach ($matches[0] as $match) {
- $html = str_replace($match, htmlentities($match), $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('//body//*//script');
+ foreach ($scriptElems as $scriptElem) {
+ $scriptElem->parentNode->removeChild($scriptElem);
}
+
+ // Remove 'on*' attributes
+ $onAttributes = $xPath->query('//body//*/@*[starts-with(name(), \'on\')]');
+ foreach ($onAttributes as $attr) {
+ /** @var \DOMAttr $attr*/
+ $attrName = $attr->nodeName;
+ $attr->parentNode->removeAttribute($attrName);
+ }
+
+ $html = '';
+ $topElems = $doc->documentElement->childNodes->item(0)->childNodes;
+ foreach ($topElems as $child) {
+ $html .= $doc->saveHTML($child);
+ }
+
return $html;
}
*/
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 = '';
/**
* Destroy a bookshelf instance
- * @param \BookStack\Entities\Bookshelf $shelf
- * @throws \Throwable
+ * @param Bookshelf $shelf
+ * @throws Throwable
*/
public function destroyBookshelf(Bookshelf $shelf)
{
/**
* Destroy the provided book and all its child entities.
- * @param \BookStack\Entities\Book $book
+ * @param Book $book
* @throws NotifyException
- * @throws \Throwable
+ * @throws Throwable
*/
public function destroyBook(Book $book)
{
/**
* Destroy a chapter and its relations.
- * @param \BookStack\Entities\Chapter $chapter
- * @throws \Throwable
+ * @param Chapter $chapter
+ * @throws Throwable
*/
public function destroyChapter(Chapter $chapter)
{
* Destroy a given page along with its dependencies.
* @param Page $page
* @throws NotifyException
- * @throws \Throwable
+ * @throws Throwable
*/
public function destroyPage(Page $page)
{
/**
* 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();
/**
* 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)
{