]> BookStack Code Mirror - bookstack/commitdiff
Merge branch 'v23-10' into development
authorDan Brown <redacted>
Sun, 3 Dec 2023 18:57:07 +0000 (18:57 +0000)
committerDan Brown <redacted>
Sun, 3 Dec 2023 18:57:07 +0000 (18:57 +0000)
1  2 
app/Entities/Tools/PageContent.php
tests/Entity/PageContentTest.php
tests/ThemeTest.php

index 99070ae8935eaf033496fcd54bc40c533012bedb,1bde7b6ce16c4485d2d37028b1726690ad5584c8..552427f4626d474557d3f45fb24d1fa2d5fe01a4
@@@ -9,12 -9,14 +9,14 @@@ 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 DOMDocument;
 +use BookStack\Util\HtmlDocument;
+ use BookStack\Util\WebSafeMimeSniffer;
 +use Closure;
  use DOMElement;
  use DOMNode;
  use DOMNodeList;
 -use DOMXPath;
  use Illuminate\Support\Str;
  
  class PageContent
@@@ -27,9 -29,9 +29,9 @@@
      /**
       * 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 = '';
@@@ -38,9 -40,9 +40,9 @@@
      /**
       * 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);
      /**
       * 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;
          }
  
 -        $doc = $this->loadDocumentFromHtml($htmlText);
 -        $container = $doc->documentElement;
 -        $body = $container->childNodes->item(0);
 -        $childNodes = $body->childNodes;
 -        $xPath = new DOMXPath($doc);
 +        $doc = new HtmlDocument($htmlText);
  
          // Get all img elements with image data blobs
 -        $imageNodes = $xPath->query('//img[contains(@src, \'data:image\')]');
 +        $imageNodes = $doc->queryXPath('//img[contains(@src, \'data:image\')]');
          foreach ($imageNodes as $imageNode) {
              $imageSrc = $imageNode->getAttribute('src');
-             $newUrl = $this->base64ImageUriToUploadedImageUrl($imageSrc);
+             $newUrl = $this->base64ImageUriToUploadedImageUrl($imageSrc, $updater);
              $imageNode->setAttribute('src', $newUrl);
          }
  
 -        // Generate inner html as a string
 -        $html = '';
 -        foreach ($childNodes as $childNode) {
 -            $html .= $doc->saveHTML($childNode);
 -        }
 -
 -        return $html;
 +        return $doc->getBodyInnerHtml();
      }
  
      /**
@@@ -76,7 -88,7 +78,7 @@@
       * 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);
                  $dataUri .= $char;
              }
  
-             $newUrl = $this->base64ImageUriToUploadedImageUrl($dataUri);
+             $newUrl = $this->base64ImageUriToUploadedImageUrl($dataUri, $updater);
              $replacements[] = [$dataUri, $newUrl];
          }
  
       * 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) {
              return $htmlText;
          }
  
 -        $doc = $this->loadDocumentFromHtml($htmlText);
 -        $container = $doc->documentElement;
 -        $body = $container->childNodes->item(0);
 -        $childNodes = $body->childNodes;
 -        $xPath = new DOMXPath($doc);
 +        $doc = new HtmlDocument($htmlText);
  
          // Map to hold used ID references
          $idMap = [];
          // Map to hold changing ID references
          $changeMap = [];
  
 -        $this->updateIdsRecursively($body, 0, $idMap, $changeMap);
 -        $this->updateLinks($xPath, $changeMap);
 -
 -        // Generate inner html as a string
 -        $html = '';
 -        foreach ($childNodes as $childNode) {
 -            $html .= $doc->saveHTML($childNode);
 -        }
 +        $this->updateIdsRecursively($doc->getBody(), 0, $idMap, $changeMap);
 +        $this->updateLinks($doc, $changeMap);
  
 -        // Perform required string-level tweaks
 +        // Generate inner html as a string & perform required string-level tweaks
 +        $html = $doc->getBodyInnerHtml();
          $html = str_replace(' ', '&nbsp;', $html);
  
          return $html;
       * Update the all links in the given xpath to apply requires changes within the
       * given $changeMap array.
       */
 -    protected function updateLinks(DOMXPath $xpath, array $changeMap): void
 +    protected function updateLinks(HtmlDocument $doc, array $changeMap): void
      {
          if (empty($changeMap)) {
              return;
          }
  
 -        $links = $xpath->query('//body//*//*[@href]');
 +        $links = $doc->queryXPath('//body//*//*[@href]');
          /** @var DOMElement $domElem */
          foreach ($links as $domElem) {
              $href = ltrim($domElem->getAttribute('href'), '#');
       */
      public function render(bool $blankIncludes = false): string
      {
 -        $content = $this->page->html ?? '';
 +        $html = $this->page->html ?? '';
 +
 +        if (empty($html)) {
 +            return $html;
 +        }
 +
 +        $doc = new HtmlDocument($html);
 +        $contentProvider = $this->getContentProviderClosure($blankIncludes);
 +        $parser = new PageIncludeParser($doc, $contentProvider);
 +
 +        $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')) {
 -            $content = HtmlContentFilter::removeScripts($content);
 +            HtmlContentFilter::removeScriptsFromDocument($doc);
          }
  
 -        if ($blankIncludes) {
 -            $content = $this->blankPageIncludes($content);
 -        } else {
 -            for ($includeDepth = 0; $includeDepth < 3; $includeDepth++) {
 -                $content = $this->parsePageIncludes($content);
 +        return $doc->getBodyInnerHtml();
 +    }
 +
 +    /**
 +     * Get the closure used to fetch content for page includes.
 +     */
 +    protected function getContentProviderClosure(bool $blankIncludes): Closure
 +    {
 +        $contextPage = $this->page;
 +
 +        return function (PageIncludeTag $tag) use ($blankIncludes, $contextPage): PageIncludeContent {
 +            if ($blankIncludes) {
 +                return PageIncludeContent::fromHtmlAndTag('', $tag);
 +            }
 +
 +            $matchedPage = Page::visible()->find($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;
 +            return $content;
 +        };
      }
  
      /**
              return [];
          }
  
 -        $doc = $this->loadDocumentFromHtml($htmlContent);
 -        $xPath = new DOMXPath($doc);
 -        $headers = $xPath->query('//h1|//h2|//h3|//h4|//h5|//h6');
 +        $doc = new HtmlDocument($htmlContent);
 +        $headers = $doc->queryXPath('//h1|//h2|//h3|//h4|//h5|//h6');
  
 -        return $headers ? $this->headerNodesToLevelList($headers) : [];
 +        return $headers->count() === 0 ? [] : $this->headerNodesToLevelList($headers);
      }
  
      /**
  
          return $tree->toArray();
      }
 -
 -    /**
 -     * Remove any page include tags within the given HTML.
 -     */
 -    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.
 -     */
 -    protected function parsePageIncludes(string $html): string
 -    {
 -        $matches = [];
 -        preg_match_all("/{{@\s?([0-9].*?)}}/", $html, $matches);
 -
 -        foreach ($matches[1] as $index => $includeId) {
 -            $fullMatch = $matches[0][$index];
 -            $splitInclude = explode('#', $includeId, 2);
 -
 -            // Get page id from reference
 -            $pageId = intval($splitInclude[0]);
 -            if (is_nan($pageId)) {
 -                continue;
 -            }
 -
 -            // Find page to use, and default replacement to empty string for non-matches.
 -            /** @var ?Page $matchedPage */
 -            $matchedPage = Page::visible()->find($pageId);
 -            $replacement = '';
 -
 -            if ($matchedPage && count($splitInclude) === 1) {
 -                // If we only have page id, just insert all page html and continue.
 -                $replacement = $matchedPage->html;
 -            } elseif ($matchedPage && count($splitInclude) > 1) {
 -                // Otherwise, if our include tag defines a section, load that specific content
 -                $innerContent = $this->fetchSectionOfPage($matchedPage, $splitInclude[1]);
 -                $replacement = trim($innerContent);
 -            }
 -
 -            $themeReplacement = Theme::dispatch(
 -                ThemeEvents::PAGE_INCLUDE_PARSE,
 -                $includeId,
 -                $replacement,
 -                clone $this->page,
 -                $matchedPage ? (clone $matchedPage) : null,
 -            );
 -
 -            // Perform the content replacement
 -            $html = str_replace($fullMatch, $themeReplacement ?? $replacement, $html);
 -        }
 -
 -        return $html;
 -    }
 -
 -    /**
 -     * Fetch the content from a specific section of the given page.
 -     */
 -    protected function fetchSectionOfPage(Page $page, string $sectionId): string
 -    {
 -        $topLevelTags = ['table', 'ul', 'ol', 'pre'];
 -        $doc = $this->loadDocumentFromHtml($page->html);
 -
 -        // Search included content for the id given and blank out if not exists.
 -        $matchingElem = $doc->getElementById($sectionId);
 -        if ($matchingElem === null) {
 -            return '';
 -        }
 -
 -        // Otherwise replace the content with the found content
 -        // Checks if the top-level wrapper should be included by matching on tag types
 -        $innerContent = '';
 -        $isTopLevel = in_array(strtolower($matchingElem->nodeName), $topLevelTags);
 -        if ($isTopLevel) {
 -            $innerContent .= $doc->saveHTML($matchingElem);
 -        } else {
 -            foreach ($matchingElem->childNodes as $childNode) {
 -                $innerContent .= $doc->saveHTML($childNode);
 -            }
 -        }
 -        libxml_clear_errors();
 -
 -        return $innerContent;
 -    }
 -
 -    /**
 -     * Create and load a DOMDocument from the given html content.
 -     */
 -    protected function loadDocumentFromHtml(string $html): DOMDocument
 -    {
 -        libxml_use_internal_errors(true);
 -        $doc = new DOMDocument();
 -        $html = '<?xml encoding="utf-8" ?><body>' . $html . '</body>';
 -        $doc->loadHTML($html);
 -
 -        return $doc;
 -    }
  }
index 958598fda8f400bc51654f3e50537e1dc0b9c7c4,78b739512ba1115d6b800d4a05d47fb4a87e7bf1..28897c14d2f20fd8dd6c1d9761afe1ad093500c1
@@@ -57,6 -57,38 +57,6 @@@ class PageContentTest extends TestCas
          $this->assertEquals('', $page->text);
      }
  
 -    public function test_page_includes_do_not_break_tables()
 -    {
 -        $page = $this->entities->page();
 -        $secondPage = $this->entities->page();
 -
 -        $content = '<table id="table"><tbody><tr><td>test</td></tr></tbody></table>';
 -        $secondPage->html = $content;
 -        $secondPage->save();
 -
 -        $page->html = "{{@{$secondPage->id}#table}}";
 -        $page->save();
 -
 -        $pageResp = $this->asEditor()->get($page->getUrl());
 -        $pageResp->assertSee($content, false);
 -    }
 -
 -    public function test_page_includes_do_not_break_code()
 -    {
 -        $page = $this->entities->page();
 -        $secondPage = $this->entities->page();
 -
 -        $content = '<pre id="bkmrk-code"><code>var cat = null;</code></pre>';
 -        $secondPage->html = $content;
 -        $secondPage->save();
 -
 -        $page->html = "{{@{$secondPage->id}#bkmrk-code}}";
 -        $page->save();
 -
 -        $pageResp = $this->asEditor()->get($page->getUrl());
 -        $pageResp->assertSee($content, false);
 -    }
 -
      public function test_page_includes_rendered_on_book_export()
      {
          $page = $this->entities->page();
          $this->withHtml($pageResp)->assertElementNotContains('#bkmrk-test', 'Hello Barry Hello Barry Hello Barry Hello Barry Hello Barry ' . $tag);
      }
  
 +    public function test_page_includes_to_nonexisting_pages_does_not_error()
 +    {
 +        $page = $this->entities->page();
 +        $missingId = Page::query()->max('id') + 1;
 +        $tag = "{{@{$missingId}}}";
 +        $page->html = '<p id="bkmrk-test">Hello Barry ' . $tag . '</p>';
 +        $page->save();
 +
 +        $pageResp = $this->asEditor()->get($page->getUrl());
 +        $pageResp->assertOk();
 +        $pageResp->assertSee('Hello Barry');
 +    }
 +
      public function test_page_content_scripts_removed_by_default()
      {
          $this->asEditor();
          }
      }
  
+     public function test_base64_images_within_html_blanked_if_no_image_create_permission()
+     {
+         $editor = $this->users->editor();
+         $page = $this->entities->page();
+         $this->permissions->removeUserRolePermissions($editor, ['image-create-all']);
+         $this->actingAs($editor)->put($page->getUrl(), [
+             'name' => $page->name,
+             'html' => '<p>test<img src="data:image/jpeg;base64,' . $this->base64Jpeg . '"/></p>',
+         ]);
+         $page->refresh();
+         $this->assertStringMatchesFormat('%A<p%A>test<img src="">%A</p>%A', $page->html);
+     }
+     public function test_base64_images_within_html_blanked_if_content_does_not_appear_like_an_image()
+     {
+         $page = $this->entities->page();
+         $imgContent = base64_encode('file://test/a/b/c');
+         $this->asEditor()->put($page->getUrl(), [
+             'name' => $page->name,
+             'html' => '<p>test<img src="data:image/jpeg;base64,' . $imgContent . '"/></p>',
+         ]);
+         $page->refresh();
+         $this->assertStringMatchesFormat('%A<p%A>test<img src="">%A</p>%A', $page->html);
+     }
      public function test_base64_images_get_extracted_from_markdown_page_content()
      {
          $this->asEditor();
          ini_set('pcre.backtrack_limit', '500');
          ini_set('pcre.recursion_limit', '500');
  
-         $content = str_repeat('a', 5000);
+         $content = str_repeat(base64_decode($this->base64Jpeg), 50);
          $base64Content = base64_encode($content);
  
          $this->put($page->getUrl(), [
          $this->assertStringContainsString('<img src=""', $page->refresh()->html);
      }
  
+     public function test_base64_images_within_markdown_blanked_if_no_image_create_permission()
+     {
+         $editor = $this->users->editor();
+         $page = $this->entities->page();
+         $this->permissions->removeUserRolePermissions($editor, ['image-create-all']);
+         $this->actingAs($editor)->put($page->getUrl(), [
+             'name' => $page->name,
+             'markdown' => 'test ![test](data:image/jpeg;base64,' . $this->base64Jpeg . ')',
+         ]);
+         $this->assertStringContainsString('<img src=""', $page->refresh()->html);
+     }
+     public function test_base64_images_within_markdown_blanked_if_content_does_not_appear_like_an_image()
+     {
+         $page = $this->entities->page();
+         $imgContent = base64_encode('file://test/a/b/c');
+         $this->asEditor()->put($page->getUrl(), [
+             'name' => $page->name,
+             'markdown' => 'test ![test](data:image/jpeg;base64,' . $imgContent . ')',
+         ]);
+         $page->refresh();
+         $this->assertStringContainsString('<img src=""', $page->refresh()->html);
+     }
      public function test_nested_headers_gets_assigned_an_id()
      {
          $page = $this->entities->page();
diff --combined tests/ThemeTest.php
index 73b90143949a12b3b127761d94739e5ec81cbe42,f6706595bf273015c663ba637fc5f43cdb8f42a2..a7a46521a6ee916b16f3d0860bc92f73e37bbb16
@@@ -78,7 -78,7 +78,7 @@@ class ThemeTest extends TestCas
  
          $page = $this->entities->page();
          $content = new PageContent($page);
-         $content->setNewMarkdown('# test');
+         $content->setNewMarkdown('# test', $this->users->editor());
  
          $this->assertTrue($callbackCalled);
      }
          $this->assertEquals($otherPage->id, $args[3]->id);
      }
  
 +    public function test_event_routes_register_web_and_web_auth()
 +    {
 +        $functionsContent = <<<'END'
 +<?php
 +use BookStack\Theming\ThemeEvents;
 +use BookStack\Facades\Theme;
 +use Illuminate\Routing\Router;
 +Theme::listen(ThemeEvents::ROUTES_REGISTER_WEB, function (Router $router) {
 +    $router->get('/cat', fn () => 'cat')->name('say.cat');
 +});
 +Theme::listen(ThemeEvents::ROUTES_REGISTER_WEB_AUTH, function (Router $router) {
 +    $router->get('/dog', fn () => 'dog')->name('say.dog');
 +});
 +END;
 +
 +        $this->usingThemeFolder(function () use ($functionsContent) {
 +
 +            $functionsFile = theme_path('functions.php');
 +            file_put_contents($functionsFile, $functionsContent);
 +
 +            $app = $this->createApplication();
 +            /** @var \Illuminate\Routing\Router $router */
 +            $router = $app->get('router');
 +
 +            /** @var \Illuminate\Routing\Route $catRoute */
 +            $catRoute = $router->getRoutes()->getRoutesByName()['say.cat'];
 +            $this->assertEquals(['web'], $catRoute->middleware());
 +
 +            /** @var \Illuminate\Routing\Route $dogRoute */
 +            $dogRoute = $router->getRoutes()->getRoutesByName()['say.dog'];
 +            $this->assertEquals(['web', 'auth'], $dogRoute->middleware());
 +        });
 +    }
 +
      public function test_add_social_driver()
      {
          Theme::addSocialDriver('catnet', [