]> BookStack Code Mirror - bookstack/commitdiff
Merge branch 'create-content-meta-tags' of https://github.com/james-geiger/BookStack...
authorDan Brown <redacted>
Wed, 23 Jun 2021 19:11:07 +0000 (20:11 +0100)
committerDan Brown <redacted>
Wed, 23 Jun 2021 19:11:07 +0000 (20:11 +0100)
1  2 
app/Entities/Models/Page.php
app/Entities/Tools/PageContent.php
resources/views/base.blade.php
resources/views/books/show.blade.php
resources/views/chapters/show.blade.php
resources/views/pages/show.blade.php
resources/views/shelves/show.blade.php

index 93fb218932cf6e9f2796fce7f3aaac8586c43490,89ed26ea87768bab4dc661daa0f215831724ca6d..6e521b2b83460928a7a343cd2a8f2cc3c3abb349
@@@ -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);
      }
  
  
      /**
       * 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);
      }
  
      /**
       */
      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('/logo.png');
+         $firstImage = (new PageContent($this))->fetchFirstImage();
+         try {
+             $cover = $firstImage ? $firstImage : $default;
+         } catch (\Exception $err) {
+             $cover = $default;
+         }
+         return $cover;
+     }
+     
  }
index 381ef172b47105939740a31ae807a7fbff865f10,84506f6718aedc7bbb7e48aead482e47df78beef..d178dc040c075e923bd64889b7954ba4270f8fb6
@@@ -1,20 -1,10 +1,20 @@@
  <?php namespace BookStack\Entities\Tools;
  
  use BookStack\Entities\Models\Page;
 +use BookStack\Entities\Tools\Markdown\CustomStrikeThroughExtension;
 +use BookStack\Exceptions\ImageUploadException;
 +use BookStack\Facades\Theme;
 +use BookStack\Theming\ThemeEvents;
 +use BookStack\Util\HtmlContentFilter;
 +use BookStack\Uploads\ImageRepo;
  use DOMDocument;
  use DOMNodeList;
  use DOMXPath;
 +use Illuminate\Support\Str;
  use League\CommonMark\CommonMarkConverter;
 +use League\CommonMark\Environment;
 +use League\CommonMark\Extension\Table\TableExtension;
 +use League\CommonMark\Extension\TaskList\TaskListExtension;
  
  class PageContent
  {
@@@ -34,7 -24,6 +34,7 @@@
       */
      public function setNewHTML(string $html)
      {
 +        $html = $this->extractBase64Images($this->page, $html);
          $this->page->html = $this->formatHtml($html);
          $this->page->text = $this->toPlainText();
          $this->page->markdown = '';
       */
      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;
      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);
          }
      /**
       * 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) {
              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");
  
      /**
       * 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);
      }
      /**
       * Parse any include tags "{{@<page_id>#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);
      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('<body>'.$page->html.'</body>', '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);
      }
  
      /**
 -     * 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 = '<body>' . $html . '</body>';
          $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;
+     }
  }
index 0734466be7cc81b2cc91e00b860047a118fa8ba1,b7dc83d987200dc2cdc274504ec22f964a635172..dc665a888046ddb8d05d46440f5600c119a5a692
      <meta name="base-url" content="{{ url('/') }}">
      <meta charset="utf-8">
  
+     <!-- Social Cards Meta -->
+     <meta property="og:title" content="{{ isset($pageTitle) ? $pageTitle . ' | ' : '' }}{{ setting('app-name') }}">
+     <meta property="og:url" content="{{ url()->current() }}">
+     @stack('social-meta')
+     
      <!-- Styles and Fonts -->
      <link rel="stylesheet" href="{{ versioned_asset('dist/styles.css') }}">
      <link rel="stylesheet" media="print" href="{{ versioned_asset('dist/print-styles.css') }}">
  </head>
  <body class="@yield('body-class')">
  
 +    @include('common.parts.skip-to-content')
      @include('partials.notifications')
      @include('common.header')
  
 -    <div id="content" class="block">
 +    <div id="content" components="@yield('content-components')" class="block">
          @yield('content')
      </div>
  
 +    @include('common.footer')
 +
      <div back-to-top class="primary-background print-hidden">
          <div class="inner">
              @icon('chevron-up') <span>{{ trans('common.back_to_top') }}</span>
index b850127ff5f595220df52722ba0ecc75180d2ec2,4782da3b9fc789e50173dfa64f052144c0fbda41..69a945cbebf6013f9c15fe9345b87dc0423824de
@@@ -6,6 -6,11 +6,11 @@@
      option:entity-search:entity-type="book"
  @stop
  
+ @push('social-meta')
+     <meta property="og:description" content="{{ Str::limit($book->description, 100, '...') }}">
+     <meta property="og:image" content="{{ $book->getBookCover() }}">
+ @endpush
  @section('body')
  
      <div class="mb-s">
  
              <hr class="primary-background">
  
 +            @if(signedInUser())
 +                @include('partials.entity-favourite-action', ['entity' => $book])
 +            @endif
              @include('partials.entity-export-menu', ['entity' => $book])
          </div>
      </div>
index 803536e9facda9a0dde17550bc709371e7efd0e6,32d7943ed83d3dbc9a8317d2e5fa31ee7185373b..a70f0f9f2ab504693c483d1d0e1fcf4f4a47e561
@@@ -6,6 -6,11 +6,11 @@@
      option:entity-search:entity-type="chapter"
  @stop
  
+ @push('social-meta')
+     <meta property="og:description" content="{{ Str::limit($chapter->description, 100) }}">
+     <meta property="og:image" content="{{ $chapter->book->getBookCover() }}">
+ @endpush
  @section('body')
  
      <div class="mb-m print-hidden">
@@@ -52,8 -57,6 +57,8 @@@
          @include('partials.entity-search-results')
      </main>
  
 +    @include('partials.entity-sibling-navigation', ['next' => $next, 'previous' => $previous])
 +
  @stop
  
  @section('right')
  
              <hr class="primary-background"/>
  
 +            @if(signedInUser())
 +                @include('partials.entity-favourite-action', ['entity' => $chapter])
 +            @endif
              @include('partials.entity-export-menu', ['entity' => $chapter])
          </div>
      </div>
index 6e84c44a86d8768f3c224308b95a67b0a7feae72,35f0d122931ff7cd04f1685de6368814c3f786ab..398c8a8530e3180408d8f49b13be0d7bc99bdc73
@@@ -1,5 -1,10 +1,10 @@@
  @extends('tri-layout')
  
+ @push('social-meta')
+     <meta property="og:description" content="{{ Str::limit($page->text, 100, '...') }}">
+     <meta property="og:image" content="{{ $page->getCoverImage() }}">
+ @endpush
  @section('body')
  
      <div class="mb-m print-hidden">
          </div>
      </main>
  
 +    @include('partials.entity-sibling-navigation', ['next' => $next, 'previous' => $previous])
 +
      @if ($commentsEnabled)
 -        <div class="container small p-none comments-container mb-l print-hidden">
 +        @if(($previous || $next))
 +            <div class="px-xl">
 +                <hr class="darker">
 +            </div>
 +        @endif
 +
 +        <div class="px-xl comments-container mb-l print-hidden">
              @include('comments.comments', ['page' => $page])
              <div class="clearfix"></div>
          </div>
@@@ -57,7 -54,7 +62,7 @@@
                  <div class="sidebar-page-nav menu">
                      @foreach($pageNav as $navItem)
                          <li class="page-nav-item h{{ $navItem['level'] }}">
 -                            <a href="{{ $navItem['link'] }}" class="limit-text block">{{ $navItem['text'] }}</a>
 +                            <a href="{{ $navItem['link'] }}" class="text-limit-lines-1 block">{{ $navItem['text'] }}</a>
                              <div class="primary-background sidebar-page-nav-bullet"></div>
                          </li>
                      @endforeach
  
              <hr class="primary-background"/>
  
 -            {{--Export--}}
 +            @if(signedInUser())
 +                @include('partials.entity-favourite-action', ['entity' => $page])
 +            @endif
              @include('partials.entity-export-menu', ['entity' => $page])
          </div>
  
index 431fa54cc260a406d350b4d83f47c7b53eaa2775,01e9e6629a55f4f66ccd8b9aae3b9ab7c085d9da..1205e8320f9e9618c732393edca32f119a397af4
@@@ -1,5 -1,10 +1,10 @@@
  @extends('tri-layout')
  
+ @push('social-meta')
+     <meta property="og:description" content="{{ Str::limit($shelf->description, 100) }}">
+     <meta property="og:image" content="{{ $shelf->getBookCover() }}">
+ @endpush
  @section('body')
  
      <div class="mb-s">
      </div>
  
      <main class="card content-wrap">
 -        <h1 class="break-text">{{$shelf->name}}</h1>
 +
 +        <div class="flex-container-row wrap v-center">
 +            <h1 class="flex fit-content break-text">{{ $shelf->name }}</h1>
 +            <div class="flex"></div>
 +            <div class="flex fit-content text-m-right my-m ml-m">
 +                @include('partials.sort', ['options' => [
 +                    'default' => trans('common.sort_default'),
 +                    'name' => trans('common.sort_name'),
 +                    'created_at' => trans('common.sort_created_at'),
 +                    'updated_at' => trans('common.sort_updated_at'),
 +                ], 'order' => $order, 'sort' => $sort, 'type' => 'shelf_books'])
 +            </div>
 +        </div>
 +
          <div class="book-content">
              <p class="text-muted">{!! nl2br(e($shelf->description)) !!}</p>
 -            @if(count($shelf->visibleBooks) > 0)
 +            @if(count($sortedVisibleShelfBooks) > 0)
                  @if($view === 'list')
                      <div class="entity-list">
 -                        @foreach($shelf->visibleBooks as $book)
 +                        @foreach($sortedVisibleShelfBooks as $book)
                              @include('books.list-item', ['book' => $book])
                          @endforeach
                      </div>
                  @else
                      <div class="grid third">
 -                        @foreach($shelf->visibleBooks as $key => $book)
 -                            @include('books.grid-item', ['book' => $book])
 +                        @foreach($sortedVisibleShelfBooks as $book)
 +                            @include('partials.entity-grid-item', ['entity' => $book])
                          @endforeach
                      </div>
                  @endif
                  </a>
              @endif
  
 +            @if(signedInUser())
 +                <hr class="primary-background">
 +                @include('partials.entity-favourite-action', ['entity' => $shelf])
 +            @endif
 +
          </div>
      </div>
  @stop