]> BookStack Code Mirror - bookstack/blob - app/Entities/Repos/PageRepo.php
Update passwords.php
[bookstack] / app / Entities / Repos / PageRepo.php
1 <?php namespace BookStack\Entities\Repos;
2
3 use BookStack\Entities\Book;
4 use BookStack\Entities\Chapter;
5 use BookStack\Entities\Entity;
6 use BookStack\Entities\Page;
7 use BookStack\Entities\PageRevision;
8 use Carbon\Carbon;
9 use DOMDocument;
10 use DOMElement;
11 use DOMXPath;
12 use Illuminate\Support\Collection;
13
14 class PageRepo extends EntityRepo
15 {
16
17     /**
18      * Get page by slug.
19      * @param string $pageSlug
20      * @param string $bookSlug
21      * @return Page
22      * @throws \BookStack\Exceptions\NotFoundException
23      */
24     public function getPageBySlug(string $pageSlug, string $bookSlug)
25     {
26         return $this->getBySlug('page', $pageSlug, $bookSlug);
27     }
28
29     /**
30      * Search through page revisions and retrieve the last page in the
31      * current book that has a slug equal to the one given.
32      * @param string $pageSlug
33      * @param string $bookSlug
34      * @return null|Page
35      */
36     public function getPageByOldSlug(string $pageSlug, string $bookSlug)
37     {
38         $revision = $this->entityProvider->pageRevision->where('slug', '=', $pageSlug)
39             ->whereHas('page', function ($query) {
40                 $this->permissionService->enforceEntityRestrictions('page', $query);
41             })
42             ->where('type', '=', 'version')
43             ->where('book_slug', '=', $bookSlug)
44             ->orderBy('created_at', 'desc')
45             ->with('page')->first();
46         return $revision !== null ? $revision->page : null;
47     }
48
49     /**
50      * Updates a page with any fillable data and saves it into the database.
51      * @param Page $page
52      * @param int $book_id
53      * @param array $input
54      * @return Page
55      * @throws \Exception
56      */
57     public function updatePage(Page $page, int $book_id, array $input)
58     {
59         // Hold the old details to compare later
60         $oldHtml = $page->html;
61         $oldName = $page->name;
62
63         // Prevent slug being updated if no name change
64         if ($page->name !== $input['name']) {
65             $page->slug = $this->findSuitableSlug('page', $input['name'], $page->id, $book_id);
66         }
67
68         // Save page tags if present
69         if (isset($input['tags'])) {
70             $this->tagRepo->saveTagsToEntity($page, $input['tags']);
71         }
72
73         if (isset($input['template']) && userCan('templates-manage')) {
74             $page->template = ($input['template'] === 'true');
75         }
76
77         // Update with new details
78         $userId = user()->id;
79         $page->fill($input);
80         $page->html = $this->formatHtml($input['html']);
81         $page->text = $this->pageToPlainText($page);
82         if (setting('app-editor') !== 'markdown') {
83             $page->markdown = '';
84         }
85         $page->updated_by = $userId;
86         $page->revision_count++;
87         $page->save();
88
89         // Remove all update drafts for this user & page.
90         $this->userUpdatePageDraftsQuery($page, $userId)->delete();
91
92         // Save a revision after updating
93         $summary = $input['summary'] ?? null;
94         if ($oldHtml !== $input['html'] || $oldName !== $input['name'] || $summary !== null) {
95             $this->savePageRevision($page, $summary);
96         }
97
98         $this->searchService->indexEntity($page);
99
100         return $page;
101     }
102
103     /**
104      * Saves a page revision into the system.
105      * @param Page $page
106      * @param null|string $summary
107      * @return PageRevision
108      * @throws \Exception
109      */
110     public function savePageRevision(Page $page, string $summary = null)
111     {
112         $revision = $this->entityProvider->pageRevision->newInstance($page->toArray());
113         if (setting('app-editor') !== 'markdown') {
114             $revision->markdown = '';
115         }
116         $revision->page_id = $page->id;
117         $revision->slug = $page->slug;
118         $revision->book_slug = $page->book->slug;
119         $revision->created_by = user()->id;
120         $revision->created_at = $page->updated_at;
121         $revision->type = 'version';
122         $revision->summary = $summary;
123         $revision->revision_number = $page->revision_count;
124         $revision->save();
125
126         $revisionLimit = config('app.revision_limit');
127         if ($revisionLimit !== false) {
128             $revisionsToDelete = $this->entityProvider->pageRevision->where('page_id', '=', $page->id)
129                 ->orderBy('created_at', 'desc')->skip(intval($revisionLimit))->take(10)->get(['id']);
130             if ($revisionsToDelete->count() > 0) {
131                 $this->entityProvider->pageRevision->whereIn('id', $revisionsToDelete->pluck('id'))->delete();
132             }
133         }
134
135         return $revision;
136     }
137
138     /**
139      * Formats a page's html to be tagged correctly within the system.
140      * @param string $htmlText
141      * @return string
142      */
143     protected function formatHtml(string $htmlText)
144     {
145         if ($htmlText == '') {
146             return $htmlText;
147         }
148
149         libxml_use_internal_errors(true);
150         $doc = new DOMDocument();
151         $doc->loadHTML(mb_convert_encoding($htmlText, 'HTML-ENTITIES', 'UTF-8'));
152
153         $container = $doc->documentElement;
154         $body = $container->childNodes->item(0);
155         $childNodes = $body->childNodes;
156
157         // Set ids on top-level nodes
158         $idMap = [];
159         foreach ($childNodes as $index => $childNode) {
160             $this->setUniqueId($childNode, $idMap);
161         }
162
163         // Ensure no duplicate ids within child items
164         $xPath = new DOMXPath($doc);
165         $idElems = $xPath->query('//body//*//*[@id]');
166         foreach ($idElems as $domElem) {
167             $this->setUniqueId($domElem, $idMap);
168         }
169
170         // Generate inner html as a string
171         $html = '';
172         foreach ($childNodes as $childNode) {
173             $html .= $doc->saveHTML($childNode);
174         }
175
176         return $html;
177     }
178
179     /**
180      * Set a unique id on the given DOMElement.
181      * A map for existing ID's should be passed in to check for current existence.
182      * @param DOMElement $element
183      * @param array $idMap
184      */
185     protected function setUniqueId($element, array &$idMap)
186     {
187         if (get_class($element) !== 'DOMElement') {
188             return;
189         }
190
191         // Overwrite id if not a BookStack custom id
192         $existingId = $element->getAttribute('id');
193         if (strpos($existingId, 'bkmrk') === 0 && !isset($idMap[$existingId])) {
194             $idMap[$existingId] = true;
195             return;
196         }
197
198         // Create an unique id for the element
199         // Uses the content as a basis to ensure output is the same every time
200         // the same content is passed through.
201         $contentId = 'bkmrk-' . mb_substr(strtolower(preg_replace('/\s+/', '-', trim($element->nodeValue))), 0, 20);
202         $newId = urlencode($contentId);
203         $loopIndex = 0;
204
205         while (isset($idMap[$newId])) {
206             $newId = urlencode($contentId . '-' . $loopIndex);
207             $loopIndex++;
208         }
209
210         $element->setAttribute('id', $newId);
211         $idMap[$newId] = true;
212     }
213
214     /**
215      * Get the plain text version of a page's content.
216      * @param \BookStack\Entities\Page $page
217      * @return string
218      */
219     protected function pageToPlainText(Page $page) : string
220     {
221         $html = $this->renderPage($page, true);
222         return strip_tags($html);
223     }
224
225     /**
226      * Get a new draft page instance.
227      * @param Book $book
228      * @param Chapter|null $chapter
229      * @return \BookStack\Entities\Page
230      * @throws \Throwable
231      */
232     public function getDraftPage(Book $book, Chapter $chapter = null)
233     {
234         $page = $this->entityProvider->page->newInstance();
235         $page->name = trans('entities.pages_initial_name');
236         $page->created_by = user()->id;
237         $page->updated_by = user()->id;
238         $page->draft = true;
239
240         if ($chapter) {
241             $page->chapter_id = $chapter->id;
242         }
243
244         $book->pages()->save($page);
245         $page = $this->entityProvider->page->find($page->id);
246         $this->permissionService->buildJointPermissionsForEntity($page);
247         return $page;
248     }
249
250     /**
251      * Save a page update draft.
252      * @param Page $page
253      * @param array $data
254      * @return PageRevision|Page
255      */
256     public function updatePageDraft(Page $page, array $data = [])
257     {
258         // If the page itself is a draft simply update that
259         if ($page->draft) {
260             $page->fill($data);
261             if (isset($data['html'])) {
262                 $page->text = $this->pageToPlainText($page);
263             }
264             $page->save();
265             return $page;
266         }
267
268         // Otherwise save the data to a revision
269         $userId = user()->id;
270         $drafts = $this->userUpdatePageDraftsQuery($page, $userId)->get();
271
272         if ($drafts->count() > 0) {
273             $draft = $drafts->first();
274         } else {
275             $draft = $this->entityProvider->pageRevision->newInstance();
276             $draft->page_id = $page->id;
277             $draft->slug = $page->slug;
278             $draft->book_slug = $page->book->slug;
279             $draft->created_by = $userId;
280             $draft->type = 'update_draft';
281         }
282
283         $draft->fill($data);
284         if (setting('app-editor') !== 'markdown') {
285             $draft->markdown = '';
286         }
287
288         $draft->save();
289         return $draft;
290     }
291
292     /**
293      * Publish a draft page to make it a normal page.
294      * Sets the slug and updates the content.
295      * @param Page $draftPage
296      * @param array $input
297      * @return Page
298      * @throws \Exception
299      */
300     public function publishPageDraft(Page $draftPage, array $input)
301     {
302         $draftPage->fill($input);
303
304         // Save page tags if present
305         if (isset($input['tags'])) {
306             $this->tagRepo->saveTagsToEntity($draftPage, $input['tags']);
307         }
308
309         if (isset($input['template']) && userCan('templates-manage')) {
310             $draftPage->template = ($input['template'] === 'true');
311         }
312
313         $draftPage->slug = $this->findSuitableSlug('page', $draftPage->name, false, $draftPage->book->id);
314         $draftPage->html = $this->formatHtml($input['html']);
315         $draftPage->text = $this->pageToPlainText($draftPage);
316         $draftPage->draft = false;
317         $draftPage->revision_count = 1;
318
319         $draftPage->save();
320         $this->savePageRevision($draftPage, trans('entities.pages_initial_revision'));
321         $this->searchService->indexEntity($draftPage);
322         return $draftPage;
323     }
324
325     /**
326      * The base query for getting user update drafts.
327      * @param Page $page
328      * @param $userId
329      * @return mixed
330      */
331     protected function userUpdatePageDraftsQuery(Page $page, int $userId)
332     {
333         return $this->entityProvider->pageRevision->where('created_by', '=', $userId)
334             ->where('type', 'update_draft')
335             ->where('page_id', '=', $page->id)
336             ->orderBy('created_at', 'desc');
337     }
338
339     /**
340      * Get the latest updated draft revision for a particular page and user.
341      * @param Page $page
342      * @param $userId
343      * @return PageRevision|null
344      */
345     public function getUserPageDraft(Page $page, int $userId)
346     {
347         return $this->userUpdatePageDraftsQuery($page, $userId)->first();
348     }
349
350     /**
351      * Get the notification message that informs the user that they are editing a draft page.
352      * @param PageRevision $draft
353      * @return string
354      */
355     public function getUserPageDraftMessage(PageRevision $draft)
356     {
357         $message = trans('entities.pages_editing_draft_notification', ['timeDiff' => $draft->updated_at->diffForHumans()]);
358         if ($draft->page->updated_at->timestamp <= $draft->updated_at->timestamp) {
359             return $message;
360         }
361         return $message . "\n" . trans('entities.pages_draft_edited_notification');
362     }
363
364     /**
365      * A query to check for active update drafts on a particular page.
366      * @param Page $page
367      * @param int $minRange
368      * @return mixed
369      */
370     protected function activePageEditingQuery(Page $page, int $minRange = null)
371     {
372         $query = $this->entityProvider->pageRevision->where('type', '=', 'update_draft')
373             ->where('page_id', '=', $page->id)
374             ->where('updated_at', '>', $page->updated_at)
375             ->where('created_by', '!=', user()->id)
376             ->with('createdBy');
377
378         if ($minRange !== null) {
379             $query = $query->where('updated_at', '>=', Carbon::now()->subMinutes($minRange));
380         }
381
382         return $query;
383     }
384
385     /**
386      * Check if a page is being actively editing.
387      * Checks for edits since last page updated.
388      * Passing in a minuted range will check for edits
389      * within the last x minutes.
390      * @param Page $page
391      * @param int $minRange
392      * @return bool
393      */
394     public function isPageEditingActive(Page $page, int $minRange = null)
395     {
396         $draftSearch = $this->activePageEditingQuery($page, $minRange);
397         return $draftSearch->count() > 0;
398     }
399
400     /**
401      * Get a notification message concerning the editing activity on a particular page.
402      * @param Page $page
403      * @param int $minRange
404      * @return string
405      */
406     public function getPageEditingActiveMessage(Page $page, int $minRange = null)
407     {
408         $pageDraftEdits = $this->activePageEditingQuery($page, $minRange)->get();
409
410         $userMessage = $pageDraftEdits->count() > 1 ? trans('entities.pages_draft_edit_active.start_a', ['count' => $pageDraftEdits->count()]): trans('entities.pages_draft_edit_active.start_b', ['userName' => $pageDraftEdits->first()->createdBy->name]);
411         $timeMessage = $minRange === null ? trans('entities.pages_draft_edit_active.time_a') : trans('entities.pages_draft_edit_active.time_b', ['minCount'=>$minRange]);
412         return trans('entities.pages_draft_edit_active.message', ['start' => $userMessage, 'time' => $timeMessage]);
413     }
414
415     /**
416      * Parse the headers on the page to get a navigation menu
417      * @param string $pageContent
418      * @return array
419      */
420     public function getPageNav(string $pageContent)
421     {
422         if ($pageContent == '') {
423             return [];
424         }
425         libxml_use_internal_errors(true);
426         $doc = new DOMDocument();
427         $doc->loadHTML(mb_convert_encoding($pageContent, 'HTML-ENTITIES', 'UTF-8'));
428         $xPath = new DOMXPath($doc);
429         $headers = $xPath->query("//h1|//h2|//h3|//h4|//h5|//h6");
430
431         if (is_null($headers)) {
432             return [];
433         }
434
435         $tree = collect($headers)->map(function($header) {
436             $text = trim(str_replace("\xc2\xa0", '', $header->nodeValue));
437             $text = mb_substr($text, 0, 100);
438
439             return [
440                 'nodeName' => strtolower($header->nodeName),
441                 'level' => intval(str_replace('h', '', $header->nodeName)),
442                 'link' => '#' . $header->getAttribute('id'),
443                 'text' => $text,
444             ];
445         })->filter(function($header) {
446             return mb_strlen($header['text']) > 0;
447         });
448
449         // Shift headers if only smaller headers have been used
450         $levelChange = ($tree->pluck('level')->min() - 1);
451         $tree = $tree->map(function ($header) use ($levelChange) {
452             $header['level'] -= ($levelChange);
453             return $header;
454         });
455
456         return $tree->toArray();
457     }
458
459     /**
460      * Restores a revision's content back into a page.
461      * @param Page $page
462      * @param Book $book
463      * @param  int $revisionId
464      * @return Page
465      * @throws \Exception
466      */
467     public function restorePageRevision(Page $page, Book $book, int $revisionId)
468     {
469         $page->revision_count++;
470         $this->savePageRevision($page);
471         $revision = $page->revisions()->where('id', '=', $revisionId)->first();
472         $page->fill($revision->toArray());
473         $page->slug = $this->findSuitableSlug('page', $page->name, $page->id, $book->id);
474         $page->text = $this->pageToPlainText($page);
475         $page->updated_by = user()->id;
476         $page->save();
477         $this->searchService->indexEntity($page);
478         return $page;
479     }
480
481     /**
482      * Change the page's parent to the given entity.
483      * @param Page $page
484      * @param Entity $parent
485      * @throws \Throwable
486      */
487     public function changePageParent(Page $page, Entity $parent)
488     {
489         $book = $parent->isA('book') ? $parent : $parent->book;
490         $page->chapter_id = $parent->isA('chapter') ? $parent->id : 0;
491         $page->save();
492         if ($page->book->id !== $book->id) {
493             $page = $this->changeBook('page', $book->id, $page);
494         }
495         $page->load('book');
496         $this->permissionService->buildJointPermissionsForEntity($book);
497     }
498
499     /**
500      * Create a copy of a page in a new location with a new name.
501      * @param \BookStack\Entities\Page $page
502      * @param \BookStack\Entities\Entity $newParent
503      * @param string $newName
504      * @return \BookStack\Entities\Page
505      * @throws \Throwable
506      */
507     public function copyPage(Page $page, Entity $newParent, string $newName = '')
508     {
509         $newBook = $newParent->isA('book') ? $newParent : $newParent->book;
510         $newChapter = $newParent->isA('chapter') ? $newParent : null;
511         $copyPage = $this->getDraftPage($newBook, $newChapter);
512         $pageData = $page->getAttributes();
513
514         // Update name
515         if (!empty($newName)) {
516             $pageData['name'] = $newName;
517         }
518
519         // Copy tags from previous page if set
520         if ($page->tags) {
521             $pageData['tags'] = [];
522             foreach ($page->tags as $tag) {
523                 $pageData['tags'][] = ['name' => $tag->name, 'value' => $tag->value];
524             }
525         }
526
527         // Set priority
528         if ($newParent->isA('chapter')) {
529             $pageData['priority'] = $this->getNewChapterPriority($newParent);
530         } else {
531             $pageData['priority'] = $this->getNewBookPriority($newParent);
532         }
533
534         return $this->publishPageDraft($copyPage, $pageData);
535     }
536
537     /**
538      * Get pages that have been marked as templates.
539      * @param int $count
540      * @param int $page
541      * @param string $search
542      * @return \Illuminate\Contracts\Pagination\LengthAwarePaginator
543      */
544     public function getPageTemplates(int $count = 10, int $page = 1,  string $search = '')
545     {
546         $query = $this->entityQuery('page')
547             ->where('template', '=', true)
548             ->orderBy('name', 'asc')
549             ->skip( ($page - 1) * $count)
550             ->take($count);
551
552         if ($search) {
553             $query->where('name', 'like', '%' . $search . '%');
554         }
555
556         $paginator = $query->paginate($count, ['*'], 'page', $page);
557         $paginator->withPath('/templates');
558
559         return $paginator;
560     }
561 }
Morty Proxy This is a proxified and sanitized view of the page, visit original site.