namespace BookStack\Entities\Tools;
use BookStack\Entities\Models\Page;
-use BookStack\Entities\Tools\Markdown\CustomListItemRenderer;
-use BookStack\Entities\Tools\Markdown\CustomStrikeThroughExtension;
+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 DOMDocument;
+use BookStack\Util\HtmlDocument;
+use BookStack\Util\WebSafeMimeSniffer;
+use Closure;
+use DOMElement;
+use DOMNode;
use DOMNodeList;
-use DOMXPath;
use Illuminate\Support\Str;
-use League\CommonMark\Block\Element\ListItem;
-use League\CommonMark\CommonMarkConverter;
-use League\CommonMark\Environment;
-use League\CommonMark\Extension\Table\TableExtension;
-use League\CommonMark\Extension\TaskList\TaskListExtension;
class PageContent
{
- protected $page;
+ protected PageQueries $pageQueries;
- /**
- * PageContent constructor.
- */
- public function __construct(Page $page)
- {
- $this->page = $page;
+ 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)
+ 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 = '';
/**
* Update the content of the page with new provided Markdown content.
*/
- public function setNewMarkdown(string $markdown)
+ public function setNewMarkdown(string $markdown, User $updater): void
{
- $markdown = $this->extractBase64ImagesFromMarkdown($markdown);
+ $markdown = $this->extractBase64ImagesFromMarkdown($markdown, $updater);
$this->page->markdown = $markdown;
- $html = $this->markdownToHtml($markdown);
+ $html = (new MarkdownToHtml($markdown))->convert();
$this->page->html = $this->formatHtml($html);
$this->page->text = $this->toPlainText();
}
- /**
- * Convert the given Markdown content to a HTML string.
- */
- protected function markdownToHtml(string $markdown): string
- {
- $environment = Environment::createCommonMarkEnvironment();
- $environment->addExtension(new TableExtension());
- $environment->addExtension(new TaskListExtension());
- $environment->addExtension(new CustomStrikeThroughExtension());
- $environment = Theme::dispatch(ThemeEvents::COMMONMARK_ENVIRONMENT_CONFIGURE, $environment) ?? $environment;
- $converter = new CommonMarkConverter([], $environment);
-
- $environment->addBlockRenderer(ListItem::class, new CustomListItemRenderer(), 10);
-
- return $converter->convertToHtml($markdown);
- }
-
/**
* 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) || strpos($htmlText, 'data:image') === false) {
+ 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\')]');
+ /** @var DOMElement $imageNode */
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();
}
/**
* Convert all inline base64 content to uploaded image files.
+ * Regex is used to locate the start of data-uri definitions then
+ * manual looping over content is done to parse the whole data uri.
+ * 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)
+ protected function extractBase64ImagesFromMarkdown(string $markdown, User $updater): string
{
$matches = [];
- preg_match_all('/!\[.*?]\(.*?(data:image\/.*?)[)"\s]/', $markdown, $matches);
+ $contentLength = strlen($markdown);
+ $replacements = [];
+ preg_match_all('/!\[.*?]\(.*?(data:image\/.{1,6};base64,)/', $markdown, $matches, PREG_OFFSET_CAPTURE);
+
+ foreach ($matches[1] as $base64MatchPair) {
+ [$dataUri, $index] = $base64MatchPair;
+
+ for ($i = strlen($dataUri) + $index; $i < $contentLength; $i++) {
+ $char = $markdown[$i];
+ if ($char === ')' || $char === ' ' || $char === "\n" || $char === '"') {
+ break;
+ }
+ $dataUri .= $char;
+ }
+
+ $newUrl = $this->base64ImageUriToUploadedImageUrl($dataUri, $updater);
+ $replacements[] = [$dataUri, $newUrl];
+ }
- foreach ($matches[1] as $base64Match) {
- $newUrl = $this->base64ImageUriToUploadedImageUrl($base64Match);
- $markdown = str_replace($base64Match, $newUrl, $markdown);
+ foreach ($replacements as [$dataUri, $newUrl]) {
+ $markdown = str_replace($dataUri, $newUrl, $markdown);
}
return $markdown;
* 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) {
/**
* Parse a base64 image URI into the data and extension.
*
- * @return array{extension: array, data: string}
+ * @return array{extension: string, data: string}
*/
protected function parseBase64ImageUri(string $uri): array
{
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);
- // Set ids on top-level nodes
+ // Map to hold used ID references
$idMap = [];
- foreach ($childNodes as $index => $childNode) {
- [$oldId, $newId] = $this->setUniqueId($childNode, $idMap);
- if ($newId && $newId !== $oldId) {
- $this->updateLinks($xPath, '#' . $oldId, '#' . $newId);
- }
- }
+ // Map to hold changing ID references
+ $changeMap = [];
- // Ensure no duplicate ids within child items
- $idElems = $xPath->query('//body//*//*[@id]');
- foreach ($idElems as $domElem) {
- [$oldId, $newId] = $this->setUniqueId($domElem, $idMap);
- if ($newId && $newId !== $oldId) {
- $this->updateLinks($xPath, '#' . $oldId, '#' . $newId);
- }
- }
+ $this->updateIdsRecursively($doc->getBody(), 0, $idMap, $changeMap);
+ $this->updateLinks($doc, $changeMap);
- // Generate inner html as a string
- $html = '';
- foreach ($childNodes as $childNode) {
- $html .= $doc->saveHTML($childNode);
- }
+ // Generate inner html as a string & perform required string-level tweaks
+ $html = $doc->getBodyInnerHtml();
+ $html = str_replace(' ', ' ', $html);
return $html;
}
/**
- * Update the all links to the $old location to instead point to $new.
+ * For the given DOMNode, traverse its children recursively and update IDs
+ * where required (Top-level, headers & elements with IDs).
+ * Will update the provided $changeMap array with changes made, where keys are the old
+ * ids and the corresponding values are the new ids.
+ */
+ protected function updateIdsRecursively(DOMNode $element, int $depth, array &$idMap, array &$changeMap): void
+ {
+ /* @var DOMNode $child */
+ foreach ($element->childNodes as $child) {
+ if ($child instanceof DOMElement && ($depth === 0 || in_array($child->nodeName, ['h1', 'h2', 'h3', 'h4', 'h5', 'h6']) || $child->getAttribute('id'))) {
+ [$oldId, $newId] = $this->setUniqueId($child, $idMap);
+ if ($newId && $newId !== $oldId && !isset($idMap[$oldId])) {
+ $changeMap[$oldId] = $newId;
+ }
+ }
+
+ if ($child->hasChildNodes()) {
+ $this->updateIdsRecursively($child, $depth + 1, $idMap, $changeMap);
+ }
+ }
+ }
+
+ /**
+ * Update the all links in the given xpath to apply requires changes within the
+ * given $changeMap array.
*/
- protected function updateLinks(DOMXPath $xpath, string $old, string $new)
+ protected function updateLinks(HtmlDocument $doc, array $changeMap): void
{
- $old = str_replace('"', '', $old);
- $matchingLinks = $xpath->query('//body//*//*[@href="' . $old . '"]');
- foreach ($matchingLinks as $domElem) {
- $domElem->setAttribute('href', $new);
+ if (empty($changeMap)) {
+ return;
+ }
+
+ $links = $doc->queryXPath('//body//*//*[@href]');
+ /** @var DOMElement $domElem */
+ foreach ($links as $domElem) {
+ $href = ltrim($domElem->getAttribute('href'), '#');
+ $newHref = $changeMap[$href] ?? null;
+ if ($newHref) {
+ $domElem->setAttribute('href', '#' . $newHref);
+ }
}
}
/**
* Set a unique id on the given DOMElement.
- * A map for existing ID's should be passed in to check for current existence.
+ * A map for existing ID's should be passed in to check for current existence,
+ * and this will be updated with any new IDs set upon elements.
* Returns a pair of strings in the format [old_id, new_id].
*/
- protected function setUniqueId(\DOMNode $element, array &$idMap): array
+ protected function setUniqueId(DOMNode $element, array &$idMap): array
{
- if (get_class($element) !== 'DOMElement') {
+ if (!$element instanceof DOMElement) {
return ['', ''];
}
// Stop if there's an existing valid id that has not already been used.
$existingId = $element->getAttribute('id');
- if (strpos($existingId, 'bkmrk') === 0 && !isset($idMap[$existingId])) {
+ if (str_starts_with($existingId, 'bkmrk') && !isset($idMap[$existingId])) {
$idMap[$existingId] = true;
return [$existingId, $existingId];
}
- // Create an unique id for the element
+ // Create a unique id for the element
// Uses the content as a basis to ensure output is the same every time
// the same content is passed through.
$contentId = 'bkmrk-' . mb_substr(strtolower(preg_replace('/\s+/', '-', trim($element->nodeValue))), 0, 20);
$newId = urlencode($contentId);
- $loopIndex = 0;
+ $loopIndex = 1;
while (isset($idMap[$newId])) {
$newId = urlencode($contentId . '-' . $loopIndex);
*/
public function render(bool $blankIncludes = false): string
{
- $content = $this->page->html ?? '';
+ $html = $this->page->html ?? '';
- if (!config('app.allow_content_scripts')) {
- $content = HtmlContentFilter::removeScripts($content);
+ 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 ($blankIncludes) {
- $content = $this->blankPageIncludes($content);
- } else {
- $content = $this->parsePageIncludes($content);
+ if (!config('app.allow_content_scripts')) {
+ HtmlContentFilter::removeScriptsFromDocument($doc);
}
- return $content;
+ 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;
+ };
}
/**
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);
}
/**
*/
protected function headerNodesToLevelList(DOMNodeList $nodeList): array
{
- $tree = collect($nodeList)->map(function ($header) {
- $text = trim(str_replace("\xc2\xa0", '', $header->nodeValue));
+ $tree = collect($nodeList)->map(function (DOMElement $header) {
+ $text = trim(str_replace("\xc2\xa0", ' ', $header->nodeValue));
$text = mb_substr($text, 0, 100);
return [
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 and skip this if page not found
- /** @var ?Page $matchedPage */
- $matchedPage = Page::visible()->find($pageId);
- if ($matchedPage === null) {
- $html = str_replace($fullMatch, '', $html);
- continue;
- }
-
- // If we only have page id, just insert all page html and continue.
- if (count($splitInclude) === 1) {
- $html = str_replace($fullMatch, $matchedPage->html, $html);
- continue;
- }
-
- // Create and load HTML into a document
- $innerContent = $this->fetchSectionOfPage($matchedPage, $splitInclude[1]);
- $html = str_replace($fullMatch, trim($innerContent), $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 = '<body>' . $html . '</body>';
- $doc->loadHTML(mb_convert_encoding($html, 'HTML-ENTITIES', 'UTF-8'));
-
- return $doc;
- }
}