From: Dan Brown Date: Wed, 23 Jun 2021 19:11:07 +0000 (+0100) Subject: Merge branch 'create-content-meta-tags' of https://github.com/james-geiger/BookStack... X-Git-Tag: v21.08~1^2~35 X-Git-Url: http://source.bookstackapp.com/bookstack/commitdiff_plain/58fa7679bccafd00f9a50bcd4a87e96876331b03?hp=-c Merge branch 'create-content-meta-tags' of https://github.com/james-geiger/BookStack into james-geiger-create-content-meta-tags --- 58fa7679bccafd00f9a50bcd4a87e96876331b03 diff --combined app/Entities/Models/Page.php index 93fb21893,89ed26ea8..6e521b2b8 --- a/app/Entities/Models/Page.php +++ b/app/Entities/Models/Page.php @@@ -40,7 -40,7 +40,7 @@@ class Page extends BookChil */ public function scopeVisible(Builder $query): Builder { - $query = Permissions::enforceDraftVisiblityOnQuery($query); + $query = Permissions::enforceDraftVisibilityOnQuery($query); return parent::scopeVisible($query); } @@@ -75,23 -75,11 +75,23 @@@ /** * Get the associated page revisions, ordered by created date. - * @return mixed + * Only provides actual saved page revision instances, Not drafts. + */ + public function revisions(): HasMany + { + return $this->allRevisions() + ->where('type', '=', 'version') + ->orderBy('created_at', 'desc') + ->orderBy('id', 'desc'); + } + + /** + * Get all revision instances assigned to this page. + * Includes all types of revisions. */ - public function revisions() + public function allRevisions(): HasMany { - return $this->hasMany(PageRevision::class)->where('type', '=', 'version')->orderBy('created_at', 'desc')->orderBy('id', 'desc'); + return $this->hasMany(PageRevision::class); } /** @@@ -133,9 -121,28 +133,28 @@@ */ public function forJsonDisplay(): Page { - $refreshed = $this->refresh()->unsetRelations()->load(['tags', 'createdBy', 'updatedBy']); + $refreshed = $this->refresh()->unsetRelations()->load(['tags', 'createdBy', 'updatedBy', 'ownedBy']); $refreshed->setHidden(array_diff($refreshed->getHidden(), ['html', 'markdown'])); $refreshed->html = (new PageContent($refreshed))->render(); return $refreshed; } + + /** + * Returns URL to a cover image for the page. + */ + public function getCoverImage() + { + //$default = $this->book->getBookCover(); + $default = url('/https/source.bookstackapp.com/logo.png'); + + $firstImage = (new PageContent($this))->fetchFirstImage(); + + try { + $cover = $firstImage ? $firstImage : $default; + } catch (\Exception $err) { + $cover = $default; + } + return $cover; + } + } diff --combined app/Entities/Tools/PageContent.php index 381ef172b,84506f671..d178dc040 --- a/app/Entities/Tools/PageContent.php +++ b/app/Entities/Tools/PageContent.php @@@ -1,20 -1,10 +1,20 @@@ extractBase64Images($this->page, $html); $this->page->html = $this->formatHtml($html); $this->page->text = $this->toPlainText(); $this->page->markdown = ''; @@@ -56,74 -45,23 +56,74 @@@ */ protected function markdownToHtml(string $markdown): string { - $converter = new CommonMarkConverter(); + $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); return $converter->convertToHtml($markdown); } + /** + * Convert all base64 image data to saved images + */ + public function extractBase64Images(Page $page, string $htmlText): string + { + if (empty($htmlText) || strpos($htmlText, 'data:image') === false) { + return $htmlText; + } + + $doc = $this->loadDocumentFromHtml($htmlText); + $container = $doc->documentElement; + $body = $container->childNodes->item(0); + $childNodes = $body->childNodes; + $xPath = new DOMXPath($doc); + $imageRepo = app()->make(ImageRepo::class); + $allowedExtensions = ['jpg', 'jpeg', 'png', 'gif', 'webp']; + + // Get all img elements with image data blobs + $imageNodes = $xPath->query('//img[contains(@src, \'data:image\')]'); + foreach ($imageNodes as $imageNode) { + $imageSrc = $imageNode->getAttribute('src'); + [$dataDefinition, $base64ImageData] = explode(',', $imageSrc, 2); + $extension = strtolower(preg_split('/[\/;]/', $dataDefinition)[1] ?? 'png'); + + // Validate extension + if (!in_array($extension, $allowedExtensions)) { + $imageNode->setAttribute('src', ''); + continue; + } + + // Save image from data with a random name + $imageName = 'embedded-image-' . Str::random(8) . '.' . $extension; + try { + $image = $imageRepo->saveNewFromData($imageName, base64_decode($base64ImageData), 'gallery', $page->id); + $imageNode->setAttribute('src', $image->url); + } catch (ImageUploadException $exception) { + $imageNode->setAttribute('src', ''); + } + } + + // Generate inner html as a string + $html = ''; + foreach ($childNodes as $childNode) { + $html .= $doc->saveHTML($childNode); + } + + return $html; + } + /** * Formats a page's html to be tagged correctly within the system. */ protected function formatHtml(string $htmlText): string { - if ($htmlText == '') { + if (empty($htmlText)) { return $htmlText; } - libxml_use_internal_errors(true); - $doc = new DOMDocument(); - $doc->loadHTML(mb_convert_encoding($htmlText, 'HTML-ENTITIES', 'UTF-8')); - + $doc = $this->loadDocumentFromHtml($htmlText); $container = $doc->documentElement; $body = $container->childNodes->item(0); $childNodes = $body->childNodes; @@@ -162,7 -100,7 +162,7 @@@ protected function updateLinks(DOMXPath $xpath, string $old, string $new) { $old = str_replace('"', '', $old); - $matchingLinks = $xpath->query('//body//*//*[@href="'.$old.'"]'); + $matchingLinks = $xpath->query('//body//*//*[@href="' . $old . '"]'); foreach ($matchingLinks as $domElem) { $domElem->setAttribute('href', $new); } @@@ -215,12 -153,12 +215,12 @@@ /** * Render the page for viewing */ - public function render(bool $blankIncludes = false) : string + public function render(bool $blankIncludes = false): string { $content = $this->page->html; if (!config('app.allow_content_scripts')) { - $content = $this->escapeScripts($content); + $content = HtmlContentFilter::removeScripts($content); } if ($blankIncludes) { @@@ -241,7 -179,9 +241,7 @@@ return []; } - libxml_use_internal_errors(true); - $doc = new DOMDocument(); - $doc->loadHTML(mb_convert_encoding($htmlContent, 'HTML-ENTITIES', 'UTF-8')); + $doc = $this->loadDocumentFromHtml($htmlContent); $xPath = new DOMXPath($doc); $headers = $xPath->query("//h1|//h2|//h3|//h4|//h5|//h6"); @@@ -281,7 -221,7 +281,7 @@@ /** * Remove any page include tags within the given HTML. */ - protected function blankPageIncludes(string $html) : string + protected function blankPageIncludes(string $html): string { return preg_replace("/{{@\s?([0-9].*?)}}/", '', $html); } @@@ -289,7 -229,7 +289,7 @@@ /** * Parse any include tags "{{@#section}}" to be part of the page. */ - protected function parsePageIncludes(string $html) : string + protected function parsePageIncludes(string $html): string { $matches = []; preg_match_all("/{{@\s?([0-9].*?)}}/", $html, $matches); @@@ -332,7 -272,9 +332,7 @@@ protected function fetchSectionOfPage(Page $page, string $sectionId): string { $topLevelTags = ['table', 'ul', 'ol']; - $doc = new DOMDocument(); - libxml_use_internal_errors(true); - $doc->loadHTML(mb_convert_encoding(''.$page->html.'', 'HTML-ENTITIES', 'UTF-8')); + $doc = $this->loadDocumentFromHtml($page->html); // Search included content for the id given and blank out if not exists. $matchingElem = $doc->getElementById($sectionId); @@@ -357,14 -299,77 +357,28 @@@ } /** - * Escape script tags within HTML content. + * Create and load a DOMDocument from the given html content. */ - protected function escapeScripts(string $html) : string + protected function loadDocumentFromHtml(string $html): DOMDocument { - if (empty($html)) { - return $html; - } - libxml_use_internal_errors(true); $doc = new DOMDocument(); + $html = '' . $html . ''; $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 clickable links to JavaScript URI - $badLinks = $xPath->query('//*[contains(@href, \'javascript:\')]'); - foreach ($badLinks as $badLink) { - $badLink->parentNode->removeChild($badLink); - } - - // Remove forms with calls to JavaScript URI - $badForms = $xPath->query('//*[contains(@action, \'javascript:\')] | //*[contains(@formaction, \'javascript:\')]'); - foreach ($badForms as $badForm) { - $badForm->parentNode->removeChild($badForm); - } - - // Remove meta tag to prevent external redirects - $metaTags = $xPath->query('//meta[contains(@content, \'url\')]'); - foreach ($metaTags as $metaTag) { - $metaTag->parentNode->removeChild($metaTag); - } - - // 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); - } - - $html = ''; - $topElems = $doc->documentElement->childNodes->item(0)->childNodes; - foreach ($topElems as $child) { - $html .= $doc->saveHTML($child); - } - - return $html; + return $doc; } + + /** + * Retrieve first image in page content and return the source URL. + */ + public function fetchFirstImage() + { + $htmlContent = $this->page->html; + + $dom = new \DomDocument(); + $dom->loadHTML($htmlContent); + $images = $dom->getElementsByTagName('img'); + + return $images->length > 0 ? $images[0]->getAttribute('src') : null; + } } diff --combined resources/views/base.blade.php index 0734466be,b7dc83d98..dc665a888 --- a/resources/views/base.blade.php +++ b/resources/views/base.blade.php @@@ -11,6 -11,12 +11,12 @@@ + + + + @stack('social-meta') + + @@@ -28,16 -34,13 +34,16 @@@ + @include('common.parts.skip-to-content') @include('partials.notifications') @include('common.header') -
+
@yield('content')
+ @include('common.footer') + diff --combined resources/views/pages/show.blade.php index 6e84c44a8,35f0d1229..398c8a853 --- a/resources/views/pages/show.blade.php +++ b/resources/views/pages/show.blade.php @@@ -1,5 -1,10 +1,10 @@@ @extends('tri-layout') + @push('social-meta') + + + @endpush + @section('body') + @include('partials.entity-sibling-navigation', ['next' => $next, 'previous' => $previous]) + @if ($commentsEnabled) - @stop