]> BookStack Code Mirror - bookstack/blobdiff - app/Entities/Tools/PageContent.php
Customization: Added parent tag classes
[bookstack] / app / Entities / Tools / PageContent.php
index 7f4d695febac8d0880d357aa2e857e807f605dc5..d2f5de65c3e58cd83e6fb37dd650bc780581734b 100644 (file)
@@ -3,14 +3,18 @@
 namespace BookStack\Entities\Tools;
 
 use BookStack\Entities\Models\Page;
+use BookStack\Entities\Queries\PageQueries;
 use BookStack\Entities\Tools\Markdown\MarkdownToHtml;
 use BookStack\Exceptions\ImageUploadException;
 use BookStack\Facades\Theme;
 use BookStack\Theming\ThemeEvents;
 use BookStack\Uploads\ImageRepo;
 use BookStack\Uploads\ImageService;
+use BookStack\Users\Models\User;
 use BookStack\Util\HtmlContentFilter;
 use BookStack\Util\HtmlDocument;
+use BookStack\Util\WebSafeMimeSniffer;
+use Closure;
 use DOMElement;
 use DOMNode;
 use DOMNodeList;
@@ -18,17 +22,20 @@ use Illuminate\Support\Str;
 
 class PageContent
 {
+    protected PageQueries $pageQueries;
+
     public function __construct(
         protected Page $page
     ) {
+        $this->pageQueries = app()->make(PageQueries::class);
     }
 
     /**
      * Update the content of the page with new provided HTML.
      */
-    public function setNewHTML(string $html): void
+    public function setNewHTML(string $html, User $updater): void
     {
-        $html = $this->extractBase64ImagesFromHtml($html);
+        $html = $this->extractBase64ImagesFromHtml($html, $updater);
         $this->page->html = $this->formatHtml($html);
         $this->page->text = $this->toPlainText();
         $this->page->markdown = '';
@@ -37,9 +44,9 @@ class PageContent
     /**
      * Update the content of the page with new provided Markdown content.
      */
-    public function setNewMarkdown(string $markdown): void
+    public function setNewMarkdown(string $markdown, User $updater): void
     {
-        $markdown = $this->extractBase64ImagesFromMarkdown($markdown);
+        $markdown = $this->extractBase64ImagesFromMarkdown($markdown, $updater);
         $this->page->markdown = $markdown;
         $html = (new MarkdownToHtml($markdown))->convert();
         $this->page->html = $this->formatHtml($html);
@@ -49,7 +56,7 @@ class PageContent
     /**
      * Convert all base64 image data to saved images.
      */
-    protected function extractBase64ImagesFromHtml(string $htmlText): string
+    protected function extractBase64ImagesFromHtml(string $htmlText, User $updater): string
     {
         if (empty($htmlText) || !str_contains($htmlText, 'data:image')) {
             return $htmlText;
@@ -59,9 +66,10 @@ class PageContent
 
         // Get all img elements with image data blobs
         $imageNodes = $doc->queryXPath('//img[contains(@src, \'data:image\')]');
+        /** @var DOMElement $imageNode */
         foreach ($imageNodes as $imageNode) {
             $imageSrc = $imageNode->getAttribute('src');
-            $newUrl = $this->base64ImageUriToUploadedImageUrl($imageSrc);
+            $newUrl = $this->base64ImageUriToUploadedImageUrl($imageSrc, $updater);
             $imageNode->setAttribute('src', $newUrl);
         }
 
@@ -75,7 +83,7 @@ class PageContent
      * Attempting to capture the whole data uri using regex can cause PHP
      * PCRE limits to be hit with larger, multi-MB, files.
      */
-    protected function extractBase64ImagesFromMarkdown(string $markdown): string
+    protected function extractBase64ImagesFromMarkdown(string $markdown, User $updater): string
     {
         $matches = [];
         $contentLength = strlen($markdown);
@@ -93,7 +101,7 @@ class PageContent
                 $dataUri .= $char;
             }
 
-            $newUrl = $this->base64ImageUriToUploadedImageUrl($dataUri);
+            $newUrl = $this->base64ImageUriToUploadedImageUrl($dataUri, $updater);
             $replacements[] = [$dataUri, $newUrl];
         }
 
@@ -108,16 +116,28 @@ class PageContent
      * Parse the given base64 image URI and return the URL to the created image instance.
      * Returns an empty string if the parsed URI is invalid or causes an error upon upload.
      */
-    protected function base64ImageUriToUploadedImageUrl(string $uri): string
+    protected function base64ImageUriToUploadedImageUrl(string $uri, User $updater): string
     {
         $imageRepo = app()->make(ImageRepo::class);
         $imageInfo = $this->parseBase64ImageUri($uri);
 
+        // Validate user has permission to create images
+        if (!$updater->can('image-create-all')) {
+            return '';
+        }
+
         // Validate extension and content
         if (empty($imageInfo['data']) || !ImageService::isExtensionSupported($imageInfo['extension'])) {
             return '';
         }
 
+        // Validate content looks like an image via sniffing mime type
+        $mimeSniffer = new WebSafeMimeSniffer();
+        $mime = $mimeSniffer->sniff($imageInfo['data']);
+        if (!str_starts_with($mime, 'image/')) {
+            return '';
+        }
+
         // Validate that the content is not over our upload limit
         $uploadLimitBytes = (config('app.upload_limit') * 1000000);
         if (strlen($imageInfo['data']) > $uploadLimitBytes) {
@@ -282,21 +302,20 @@ class PageContent
         }
 
         $doc = new HtmlDocument($html);
-
-        $contentProvider = function (int $id) use ($blankIncludes) {
-            if ($blankIncludes) {
-                return '';
-            }
-            return Page::visible()->find($id)->html ?? '';
-        };
-
+        $contentProvider = $this->getContentProviderClosure($blankIncludes);
         $parser = new PageIncludeParser($doc, $contentProvider);
-        $nodesAdded = 1;
 
+        $nodesAdded = 1;
         for ($includeDepth = 0; $includeDepth < 3 && $nodesAdded !== 0; $includeDepth++) {
             $nodesAdded = $parser->parse();
         }
 
+        if ($includeDepth > 1) {
+            $idMap = [];
+            $changeMap = [];
+            $this->updateIdsRecursively($doc->getBody(), 0, $idMap, $changeMap);
+        }
+
         if (!config('app.allow_content_scripts')) {
             HtmlContentFilter::removeScriptsFromDocument($doc);
         }
@@ -304,6 +323,40 @@ class PageContent
         return $doc->getBodyInnerHtml();
     }
 
+    /**
+     * Get the closure used to fetch content for page includes.
+     */
+    protected function getContentProviderClosure(bool $blankIncludes): Closure
+    {
+        $contextPage = $this->page;
+        $queries = $this->pageQueries;
+
+        return function (PageIncludeTag $tag) use ($blankIncludes, $contextPage, $queries): PageIncludeContent {
+            if ($blankIncludes) {
+                return PageIncludeContent::fromHtmlAndTag('', $tag);
+            }
+
+            $matchedPage = $queries->findVisibleById($tag->getPageId());
+            $content = PageIncludeContent::fromHtmlAndTag($matchedPage->html ?? '', $tag);
+
+            if (Theme::hasListeners(ThemeEvents::PAGE_INCLUDE_PARSE)) {
+                $themeReplacement = Theme::dispatch(
+                    ThemeEvents::PAGE_INCLUDE_PARSE,
+                    $tag->tagContent,
+                    $content->toHtml(),
+                    clone $contextPage,
+                    $matchedPage ? (clone $matchedPage) : null,
+                );
+
+                if ($themeReplacement !== null) {
+                    $content = PageIncludeContent::fromInlineHtml(strval($themeReplacement));
+                }
+            }
+
+            return $content;
+        };
+    }
+
     /**
      * Parse the headers on the page to get a navigation menu.
      */
@@ -326,7 +379,7 @@ class PageContent
     protected function headerNodesToLevelList(DOMNodeList $nodeList): array
     {
         $tree = collect($nodeList)->map(function (DOMElement $header) {
-            $text = trim(str_replace("\xc2\xa0", '', $header->nodeValue));
+            $text = trim(str_replace("\xc2\xa0", ' ', $header->nodeValue));
             $text = mb_substr($text, 0, 100);
 
             return [