*/
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;
+ }
+
}
<?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
{
*/
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;
+ }
}
@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