]> BookStack Code Mirror - bookstack/blob - app/Entities/Managers/PageContent.php
Updated styles to use logical properties/values
[bookstack] / app / Entities / Managers / PageContent.php
1 <?php namespace BookStack\Entities\Managers;
2
3 use BookStack\Entities\Page;
4 use DOMDocument;
5 use DOMElement;
6 use DOMNodeList;
7 use DOMXPath;
8
9 class PageContent
10 {
11
12     protected $page;
13
14     /**
15      * PageContent constructor.
16      */
17     public function __construct(Page $page)
18     {
19         $this->page = $page;
20     }
21
22     /**
23      * Update the content of the page with new provided HTML.
24      */
25     public function setNewHTML(string $html)
26     {
27         $this->page->html = $this->formatHtml($html);
28         $this->page->text = $this->toPlainText();
29     }
30
31     /**
32      * Formats a page's html to be tagged correctly within the system.
33      */
34     protected function formatHtml(string $htmlText): string
35     {
36         if ($htmlText == '') {
37             return $htmlText;
38         }
39
40         libxml_use_internal_errors(true);
41         $doc = new DOMDocument();
42         $doc->loadHTML(mb_convert_encoding($htmlText, 'HTML-ENTITIES', 'UTF-8'));
43
44         $container = $doc->documentElement;
45         $body = $container->childNodes->item(0);
46         $childNodes = $body->childNodes;
47
48         // Set ids on top-level nodes
49         $idMap = [];
50         foreach ($childNodes as $index => $childNode) {
51             $this->setUniqueId($childNode, $idMap);
52         }
53
54         // Ensure no duplicate ids within child items
55         $xPath = new DOMXPath($doc);
56         $idElems = $xPath->query('//body//*//*[@id]');
57         foreach ($idElems as $domElem) {
58             $this->setUniqueId($domElem, $idMap);
59         }
60
61         // Generate inner html as a string
62         $html = '';
63         foreach ($childNodes as $childNode) {
64             $html .= $doc->saveHTML($childNode);
65         }
66
67         return $html;
68     }
69
70     /**
71      * Set a unique id on the given DOMElement.
72      * A map for existing ID's should be passed in to check for current existence.
73      * @param DOMElement $element
74      * @param array $idMap
75      */
76     protected function setUniqueId($element, array &$idMap)
77     {
78         if (get_class($element) !== 'DOMElement') {
79             return;
80         }
81
82         // Overwrite id if not a BookStack custom id
83         $existingId = $element->getAttribute('id');
84         if (strpos($existingId, 'bkmrk') === 0 && !isset($idMap[$existingId])) {
85             $idMap[$existingId] = true;
86             return;
87         }
88
89         // Create an unique id for the element
90         // Uses the content as a basis to ensure output is the same every time
91         // the same content is passed through.
92         $contentId = 'bkmrk-' . mb_substr(strtolower(preg_replace('/\s+/', '-', trim($element->nodeValue))), 0, 20);
93         $newId = urlencode($contentId);
94         $loopIndex = 0;
95
96         while (isset($idMap[$newId])) {
97             $newId = urlencode($contentId . '-' . $loopIndex);
98             $loopIndex++;
99         }
100
101         $element->setAttribute('id', $newId);
102         $idMap[$newId] = true;
103     }
104
105     /**
106      * Get a plain-text visualisation of this page.
107      */
108     protected function toPlainText(): string
109     {
110         $html = $this->render(true);
111         return strip_tags($html);
112     }
113
114     /**
115      * Render the page for viewing
116      */
117     public function render(bool $blankIncludes = false) : string
118     {
119         $content = $this->page->html;
120
121         if (!config('app.allow_content_scripts')) {
122             $content = $this->escapeScripts($content);
123         }
124
125         if ($blankIncludes) {
126             $content = $this->blankPageIncludes($content);
127         } else {
128             $content = $this->parsePageIncludes($content);
129         }
130
131         return $content;
132     }
133
134     /**
135      * Parse the headers on the page to get a navigation menu
136      */
137     public function getNavigation(string $htmlContent): array
138     {
139         if (empty($htmlContent)) {
140             return [];
141         }
142
143         libxml_use_internal_errors(true);
144         $doc = new DOMDocument();
145         $doc->loadHTML(mb_convert_encoding($htmlContent, 'HTML-ENTITIES', 'UTF-8'));
146         $xPath = new DOMXPath($doc);
147         $headers = $xPath->query("//h1|//h2|//h3|//h4|//h5|//h6");
148
149         return $headers ? $this->headerNodesToLevelList($headers) : [];
150     }
151
152     /**
153      * Convert a DOMNodeList into an array of readable header attributes
154      * with levels normalised to the lower header level.
155      */
156     protected function headerNodesToLevelList(DOMNodeList $nodeList): array
157     {
158         $tree = collect($nodeList)->map(function ($header) {
159             $text = trim(str_replace("\xc2\xa0", '', $header->nodeValue));
160             $text = mb_substr($text, 0, 100);
161
162             return [
163                 'nodeName' => strtolower($header->nodeName),
164                 'level' => intval(str_replace('h', '', $header->nodeName)),
165                 'link' => '#' . $header->getAttribute('id'),
166                 'text' => $text,
167             ];
168         })->filter(function ($header) {
169             return mb_strlen($header['text']) > 0;
170         });
171
172         // Shift headers if only smaller headers have been used
173         $levelChange = ($tree->pluck('level')->min() - 1);
174         $tree = $tree->map(function ($header) use ($levelChange) {
175             $header['level'] -= ($levelChange);
176             return $header;
177         });
178
179         return $tree->toArray();
180     }
181
182     /**
183      * Remove any page include tags within the given HTML.
184      */
185     protected function blankPageIncludes(string $html) : string
186     {
187         return preg_replace("/{{@\s?([0-9].*?)}}/", '', $html);
188     }
189
190     /**
191      * Parse any include tags "{{@<page_id>#section}}" to be part of the page.
192      */
193     protected function parsePageIncludes(string $html) : string
194     {
195         $matches = [];
196         preg_match_all("/{{@\s?([0-9].*?)}}/", $html, $matches);
197
198         foreach ($matches[1] as $index => $includeId) {
199             $fullMatch = $matches[0][$index];
200             $splitInclude = explode('#', $includeId, 2);
201
202             // Get page id from reference
203             $pageId = intval($splitInclude[0]);
204             if (is_nan($pageId)) {
205                 continue;
206             }
207
208             // Find page and skip this if page not found
209             $matchedPage = Page::visible()->find($pageId);
210             if ($matchedPage === null) {
211                 $html = str_replace($fullMatch, '', $html);
212                 continue;
213             }
214
215             // If we only have page id, just insert all page html and continue.
216             if (count($splitInclude) === 1) {
217                 $html = str_replace($fullMatch, $matchedPage->html, $html);
218                 continue;
219             }
220
221             // Create and load HTML into a document
222             $innerContent = $this->fetchSectionOfPage($matchedPage, $splitInclude[1]);
223             $html = str_replace($fullMatch, trim($innerContent), $html);
224         }
225
226         return $html;
227     }
228
229
230     /**
231      * Fetch the content from a specific section of the given page.
232      */
233     protected function fetchSectionOfPage(Page $page, string $sectionId): string
234     {
235         $topLevelTags = ['table', 'ul', 'ol'];
236         $doc = new DOMDocument();
237         libxml_use_internal_errors(true);
238         $doc->loadHTML(mb_convert_encoding('<body>'.$page->html.'</body>', 'HTML-ENTITIES', 'UTF-8'));
239
240         // Search included content for the id given and blank out if not exists.
241         $matchingElem = $doc->getElementById($sectionId);
242         if ($matchingElem === null) {
243             return '';
244         }
245
246         // Otherwise replace the content with the found content
247         // Checks if the top-level wrapper should be included by matching on tag types
248         $innerContent = '';
249         $isTopLevel = in_array(strtolower($matchingElem->nodeName), $topLevelTags);
250         if ($isTopLevel) {
251             $innerContent .= $doc->saveHTML($matchingElem);
252         } else {
253             foreach ($matchingElem->childNodes as $childNode) {
254                 $innerContent .= $doc->saveHTML($childNode);
255             }
256         }
257         libxml_clear_errors();
258
259         return $innerContent;
260     }
261
262     /**
263      * Escape script tags within HTML content.
264      */
265     protected function escapeScripts(string $html) : string
266     {
267         if (empty($html)) {
268             return $html;
269         }
270
271         libxml_use_internal_errors(true);
272         $doc = new DOMDocument();
273         $doc->loadHTML(mb_convert_encoding($html, 'HTML-ENTITIES', 'UTF-8'));
274         $xPath = new DOMXPath($doc);
275
276         // Remove standard script tags
277         $scriptElems = $xPath->query('//script');
278         foreach ($scriptElems as $scriptElem) {
279             $scriptElem->parentNode->removeChild($scriptElem);
280         }
281
282         // Remove data or JavaScript iFrames
283         $badIframes = $xPath->query('//*[contains(@src, \'data:\')] | //*[contains(@src, \'javascript:\')] | //*[@srcdoc]');
284         foreach ($badIframes as $badIframe) {
285             $badIframe->parentNode->removeChild($badIframe);
286         }
287
288         // Remove 'on*' attributes
289         $onAttributes = $xPath->query('//@*[starts-with(name(), \'on\')]');
290         foreach ($onAttributes as $attr) {
291             /** @var \DOMAttr $attr*/
292             $attrName = $attr->nodeName;
293             $attr->parentNode->removeAttribute($attrName);
294         }
295
296         $html = '';
297         $topElems = $doc->documentElement->childNodes->item(0)->childNodes;
298         foreach ($topElems as $child) {
299             $html .= $doc->saveHTML($child);
300         }
301
302         return $html;
303     }
304 }