]> BookStack Code Mirror - bookstack/blob - app/Repos/PageRepo.php
Merge branch 'diff' of git://github.com/younes0/BookStack into younes0-diff
[bookstack] / app / Repos / PageRepo.php
1 <?php namespace BookStack\Repos;
2
3 use Activity;
4 use BookStack\Book;
5 use BookStack\Chapter;
6 use BookStack\Entity;
7 use BookStack\Exceptions\NotFoundException;
8 use Carbon\Carbon;
9 use DOMDocument;
10 use DOMXPath;
11 use Illuminate\Support\Str;
12 use BookStack\Page;
13 use BookStack\PageRevision;
14
15 class PageRepo extends EntityRepo
16 {
17
18     protected $pageRevision;
19     protected $tagRepo;
20
21     /**
22      * PageRepo constructor.
23      * @param PageRevision $pageRevision
24      * @param TagRepo $tagRepo
25      */
26     public function __construct(PageRevision $pageRevision, TagRepo $tagRepo)
27     {
28         $this->pageRevision = $pageRevision;
29         $this->tagRepo = $tagRepo;
30         parent::__construct();
31     }
32
33     /**
34      * Base query for getting pages, Takes restrictions into account.
35      * @param bool $allowDrafts
36      * @return mixed
37      */
38     private function pageQuery($allowDrafts = false)
39     {
40         $query = $this->permissionService->enforcePageRestrictions($this->page, 'view');
41         if (!$allowDrafts) {
42             $query = $query->where('draft', '=', false);
43         }
44         return $query;
45     }
46
47     /**
48      * Get a page via a specific ID.
49      * @param $id
50      * @param bool $allowDrafts
51      * @return mixed
52      */
53     public function getById($id, $allowDrafts = false)
54     {
55         return $this->pageQuery($allowDrafts)->findOrFail($id);
56     }
57
58     /**
59      * Get a page identified by the given slug.
60      * @param $slug
61      * @param $bookId
62      * @return mixed
63      * @throws NotFoundException
64      */
65     public function getBySlug($slug, $bookId)
66     {
67         $page = $this->pageQuery()->where('slug', '=', $slug)->where('book_id', '=', $bookId)->first();
68         if ($page === null) throw new NotFoundException('Page not found');
69         return $page;
70     }
71
72     /**
73      * Search through page revisions and retrieve
74      * the last page in the current book that
75      * has a slug equal to the one given.
76      * @param $pageSlug
77      * @param $bookSlug
78      * @return null | Page
79      */
80     public function findPageUsingOldSlug($pageSlug, $bookSlug)
81     {
82         $revision = $this->pageRevision->where('slug', '=', $pageSlug)
83             ->whereHas('page', function ($query) {
84                 $this->permissionService->enforcePageRestrictions($query);
85             })
86             ->where('type', '=', 'version')
87             ->where('book_slug', '=', $bookSlug)->orderBy('created_at', 'desc')
88             ->with('page')->first();
89         return $revision !== null ? $revision->page : null;
90     }
91
92     /**
93      * Get a new Page instance from the given input.
94      * @param $input
95      * @return Page
96      */
97     public function newFromInput($input)
98     {
99         $page = $this->page->fill($input);
100         return $page;
101     }
102
103     /**
104      * Count the pages with a particular slug within a book.
105      * @param $slug
106      * @param $bookId
107      * @return mixed
108      */
109     public function countBySlug($slug, $bookId)
110     {
111         return $this->page->where('slug', '=', $slug)->where('book_id', '=', $bookId)->count();
112     }
113
114     /**
115      * Publish a draft page to make it a normal page.
116      * Sets the slug and updates the content.
117      * @param Page $draftPage
118      * @param array $input
119      * @return Page
120      */
121     public function publishDraft(Page $draftPage, array $input)
122     {
123         $draftPage->fill($input);
124
125         // Save page tags if present
126         if (isset($input['tags'])) {
127             $this->tagRepo->saveTagsToEntity($draftPage, $input['tags']);
128         }
129
130         $draftPage->slug = $this->findSuitableSlug($draftPage->name, $draftPage->book->id);
131         $draftPage->html = $this->formatHtml($input['html']);
132         $draftPage->text = strip_tags($draftPage->html);
133         $draftPage->draft = false;
134
135         $draftPage->save();
136         $this->saveRevision($draftPage, 'Initial Publish');
137         
138         return $draftPage;
139     }
140
141     /**
142      * Get a new draft page instance.
143      * @param Book $book
144      * @param Chapter|bool $chapter
145      * @return static
146      */
147     public function getDraftPage(Book $book, $chapter = false)
148     {
149         $page = $this->page->newInstance();
150         $page->name = 'New Page';
151         $page->created_by = auth()->user()->id;
152         $page->updated_by = auth()->user()->id;
153         $page->draft = true;
154
155         if ($chapter) $page->chapter_id = $chapter->id;
156
157         $book->pages()->save($page);
158         $this->permissionService->buildJointPermissionsForEntity($page);
159         return $page;
160     }
161
162     /**
163      * Parse te headers on the page to get a navigation menu
164      * @param Page $page
165      * @return array
166      */
167     public function getPageNav(Page $page)
168     {
169         if ($page->html == '') return null;
170         libxml_use_internal_errors(true);
171         $doc = new DOMDocument();
172         $doc->loadHTML(mb_convert_encoding($page->html, 'HTML-ENTITIES', 'UTF-8'));
173         $xPath = new DOMXPath($doc);
174         $headers = $xPath->query("//h1|//h2|//h3|//h4|//h5|//h6");
175
176         if (is_null($headers)) return null;
177
178         $tree = [];
179         foreach ($headers as $header) {
180             $text = $header->nodeValue;
181             $tree[] = [
182                 'nodeName' => strtolower($header->nodeName),
183                 'level' => intval(str_replace('h', '', $header->nodeName)),
184                 'link' => '#' . $header->getAttribute('id'),
185                 'text' => strlen($text) > 30 ? substr($text, 0, 27) . '...' : $text
186             ];
187         }
188         return $tree;
189     }
190
191     /**
192      * Formats a page's html to be tagged correctly
193      * within the system.
194      * @param string $htmlText
195      * @return string
196      */
197     protected function formatHtml($htmlText)
198     {
199         if ($htmlText == '') return $htmlText;
200         libxml_use_internal_errors(true);
201         $doc = new DOMDocument();
202         $doc->loadHTML(mb_convert_encoding($htmlText, 'HTML-ENTITIES', 'UTF-8'));
203
204         $container = $doc->documentElement;
205         $body = $container->childNodes->item(0);
206         $childNodes = $body->childNodes;
207
208         // Ensure no duplicate ids are used
209         $idArray = [];
210
211         foreach ($childNodes as $index => $childNode) {
212             /** @var \DOMElement $childNode */
213             if (get_class($childNode) !== 'DOMElement') continue;
214
215             // Overwrite id if not a BookStack custom id
216             if ($childNode->hasAttribute('id')) {
217                 $id = $childNode->getAttribute('id');
218                 if (strpos($id, 'bkmrk') === 0 && array_search($id, $idArray) === false) {
219                     $idArray[] = $id;
220                     continue;
221                 };
222             }
223
224             // Create an unique id for the element
225             // Uses the content as a basis to ensure output is the same every time
226             // the same content is passed through.
227             $contentId = 'bkmrk-' . substr(strtolower(preg_replace('/\s+/', '-', trim($childNode->nodeValue))), 0, 20);
228             $newId = urlencode($contentId);
229             $loopIndex = 0;
230             while (in_array($newId, $idArray)) {
231                 $newId = urlencode($contentId . '-' . $loopIndex);
232                 $loopIndex++;
233             }
234
235             $childNode->setAttribute('id', $newId);
236             $idArray[] = $newId;
237         }
238
239         // Generate inner html as a string
240         $html = '';
241         foreach ($childNodes as $childNode) {
242             $html .= $doc->saveHTML($childNode);
243         }
244
245         return $html;
246     }
247
248
249     /**
250      * Gets pages by a search term.
251      * Highlights page content for showing in results.
252      * @param string $term
253      * @param array $whereTerms
254      * @param int $count
255      * @param array $paginationAppends
256      * @return mixed
257      */
258     public function getBySearch($term, $whereTerms = [], $count = 20, $paginationAppends = [])
259     {
260         $terms = $this->prepareSearchTerms($term);
261         $pageQuery = $this->permissionService->enforcePageRestrictions($this->page->fullTextSearchQuery(['name', 'text'], $terms, $whereTerms));
262         $pageQuery = $this->addAdvancedSearchQueries($pageQuery, $term);
263         $pages = $pageQuery->paginate($count)->appends($paginationAppends);
264
265         // Add highlights to page text.
266         $words = join('|', explode(' ', preg_quote(trim($term), '/')));
267         //lookahead/behind assertions ensures cut between words
268         $s = '\s\x00-/:-@\[-`{-~'; //character set for start/end of words
269
270         foreach ($pages as $page) {
271             preg_match_all('#(?<=[' . $s . ']).{1,30}((' . $words . ').{1,30})+(?=[' . $s . '])#uis', $page->text, $matches, PREG_SET_ORDER);
272             //delimiter between occurrences
273             $results = [];
274             foreach ($matches as $line) {
275                 $results[] = htmlspecialchars($line[0], 0, 'UTF-8');
276             }
277             $matchLimit = 6;
278             if (count($results) > $matchLimit) {
279                 $results = array_slice($results, 0, $matchLimit);
280             }
281             $result = join('... ', $results);
282
283             //highlight
284             $result = preg_replace('#' . $words . '#iu', "<span class=\"highlight\">\$0</span>", $result);
285             if (strlen($result) < 5) {
286                 $result = $page->getExcerpt(80);
287             }
288             $page->searchSnippet = $result;
289         }
290         return $pages;
291     }
292
293     /**
294      * Search for image usage.
295      * @param $imageString
296      * @return mixed
297      */
298     public function searchForImage($imageString)
299     {
300         $pages = $this->pageQuery()->where('html', 'like', '%' . $imageString . '%')->get();
301         foreach ($pages as $page) {
302             $page->url = $page->getUrl();
303             $page->html = '';
304             $page->text = '';
305         }
306         return count($pages) > 0 ? $pages : false;
307     }
308
309     /**
310      * Updates a page with any fillable data and saves it into the database.
311      * @param Page $page
312      * @param int $book_id
313      * @param string $input
314      * @return Page
315      */
316     public function updatePage(Page $page, $book_id, $input)
317     {
318         // Hold the old details to compare later
319         $oldHtml = $page->html;
320         $oldName = $page->name;
321
322         // Prevent slug being updated if no name change
323         if ($page->name !== $input['name']) {
324             $page->slug = $this->findSuitableSlug($input['name'], $book_id, $page->id);
325         }
326
327         // Save page tags if present
328         if (isset($input['tags'])) {
329             $this->tagRepo->saveTagsToEntity($page, $input['tags']);
330         }
331
332         // Update with new details
333         $userId = auth()->user()->id;
334         $page->fill($input);
335         $page->html = $this->formatHtml($input['html']);
336         $page->text = strip_tags($page->html);
337         if (setting('app-editor') !== 'markdown') $page->markdown = '';
338         $page->updated_by = $userId;
339         $page->save();
340
341         // Remove all update drafts for this user & page.
342         $this->userUpdateDraftsQuery($page, $userId)->delete();
343
344         // Save a revision after updating
345         if ($oldHtml !== $input['html'] || $oldName !== $input['name'] || $input['summary'] !== null) {
346             $this->saveRevision($page, $input['summary']);
347         }
348
349         return $page;
350     }
351
352     /**
353      * Restores a revision's content back into a page.
354      * @param Page $page
355      * @param Book $book
356      * @param  int $revisionId
357      * @return Page
358      */
359     public function restoreRevision(Page $page, Book $book, $revisionId)
360     {
361         $this->saveRevision($page);
362         $revision = $this->getRevisionById($revisionId);
363         $page->fill($revision->toArray());
364         $page->slug = $this->findSuitableSlug($page->name, $book->id, $page->id);
365         $page->text = strip_tags($page->html);
366         $page->updated_by = auth()->user()->id;
367         $page->save();
368         return $page;
369     }
370
371     /**
372      * Saves a page revision into the system.
373      * @param Page $page
374      * @param null|string $summary
375      * @return $this
376      */
377     public function saveRevision(Page $page, $summary = null)
378     {
379         $revision = $this->pageRevision->newInstance($page->toArray());
380         if (setting('app-editor') !== 'markdown') $revision->markdown = '';
381         $revision->page_id = $page->id;
382         $revision->slug = $page->slug;
383         $revision->book_slug = $page->book->slug;
384         $revision->created_by = auth()->user()->id;
385         $revision->created_at = $page->updated_at;
386         $revision->type = 'version';
387         $revision->summary = $summary;
388         $revision->save();
389
390         // Clear old revisions
391         if ($this->pageRevision->where('page_id', '=', $page->id)->count() > 50) {
392             $this->pageRevision->where('page_id', '=', $page->id)
393                 ->orderBy('created_at', 'desc')->skip(50)->take(5)->delete();
394         }
395
396         return $revision;
397     }
398
399     /**
400      * Save a page update draft.
401      * @param Page $page
402      * @param array $data
403      * @return PageRevision
404      */
405     public function saveUpdateDraft(Page $page, $data = [])
406     {
407         $userId = auth()->user()->id;
408         $drafts = $this->userUpdateDraftsQuery($page, $userId)->get();
409
410         if ($drafts->count() > 0) {
411             $draft = $drafts->first();
412         } else {
413             $draft = $this->pageRevision->newInstance();
414             $draft->page_id = $page->id;
415             $draft->slug = $page->slug;
416             $draft->book_slug = $page->book->slug;
417             $draft->created_by = $userId;
418             $draft->type = 'update_draft';
419         }
420
421         $draft->fill($data);
422         if (setting('app-editor') !== 'markdown') $draft->markdown = '';
423
424         $draft->save();
425         return $draft;
426     }
427
428     /**
429      * Update a draft page.
430      * @param Page $page
431      * @param array $data
432      * @return Page
433      */
434     public function updateDraftPage(Page $page, $data = [])
435     {
436         $page->fill($data);
437
438         if (isset($data['html'])) {
439             $page->text = strip_tags($data['html']);
440         }
441
442         $page->save();
443         return $page;
444     }
445
446     /**
447      * The base query for getting user update drafts.
448      * @param Page $page
449      * @param $userId
450      * @return mixed
451      */
452     private function userUpdateDraftsQuery(Page $page, $userId)
453     {
454         return $this->pageRevision->where('created_by', '=', $userId)
455             ->where('type', 'update_draft')
456             ->where('page_id', '=', $page->id)
457             ->orderBy('created_at', 'desc');
458     }
459
460     /**
461      * Checks whether a user has a draft version of a particular page or not.
462      * @param Page $page
463      * @param $userId
464      * @return bool
465      */
466     public function hasUserGotPageDraft(Page $page, $userId)
467     {
468         return $this->userUpdateDraftsQuery($page, $userId)->count() > 0;
469     }
470
471     /**
472      * Get the latest updated draft revision for a particular page and user.
473      * @param Page $page
474      * @param $userId
475      * @return mixed
476      */
477     public function getUserPageDraft(Page $page, $userId)
478     {
479         return $this->userUpdateDraftsQuery($page, $userId)->first();
480     }
481
482     /**
483      * Get the notification message that informs the user that they are editing a draft page.
484      * @param PageRevision $draft
485      * @return string
486      */
487     public function getUserPageDraftMessage(PageRevision $draft)
488     {
489         $message = 'You are currently editing a draft that was last saved ' . $draft->updated_at->diffForHumans() . '.';
490         if ($draft->page->updated_at->timestamp > $draft->updated_at->timestamp) {
491             $message .= "\n This page has been updated by since that time. It is recommended that you discard this draft.";
492         }
493         return $message;
494     }
495
496     /**
497      * Check if a page is being actively editing.
498      * Checks for edits since last page updated.
499      * Passing in a minuted range will check for edits
500      * within the last x minutes.
501      * @param Page $page
502      * @param null $minRange
503      * @return bool
504      */
505     public function isPageEditingActive(Page $page, $minRange = null)
506     {
507         $draftSearch = $this->activePageEditingQuery($page, $minRange);
508         return $draftSearch->count() > 0;
509     }
510
511     /**
512      * Get a notification message concerning the editing activity on
513      * a particular page.
514      * @param Page $page
515      * @param null $minRange
516      * @return string
517      */
518     public function getPageEditingActiveMessage(Page $page, $minRange = null)
519     {
520         $pageDraftEdits = $this->activePageEditingQuery($page, $minRange)->get();
521         $userMessage = $pageDraftEdits->count() > 1 ? $pageDraftEdits->count() . ' users have' : $pageDraftEdits->first()->createdBy->name . ' has';
522         $timeMessage = $minRange === null ? 'since the page was last updated' : 'in the last ' . $minRange . ' minutes';
523         $message = '%s started editing this page %s. Take care not to overwrite each other\'s updates!';
524         return sprintf($message, $userMessage, $timeMessage);
525     }
526
527     /**
528      * A query to check for active update drafts on a particular page.
529      * @param Page $page
530      * @param null $minRange
531      * @return mixed
532      */
533     private function activePageEditingQuery(Page $page, $minRange = null)
534     {
535         $query = $this->pageRevision->where('type', '=', 'update_draft')
536             ->where('page_id', '=', $page->id)
537             ->where('updated_at', '>', $page->updated_at)
538             ->where('created_by', '!=', auth()->user()->id)
539             ->with('createdBy');
540
541         if ($minRange !== null) {
542             $query = $query->where('updated_at', '>=', Carbon::now()->subMinutes($minRange));
543         }
544
545         return $query;
546     }
547
548     /**
549      * Gets a single revision via it's id.
550      * @param $id
551      * @return mixed
552      */
553     public function getRevisionById($id)
554     {
555         return $this->pageRevision->findOrFail($id);
556     }
557
558     /**
559      * Checks if a slug exists within a book already.
560      * @param            $slug
561      * @param            $bookId
562      * @param bool|false $currentId
563      * @return bool
564      */
565     public function doesSlugExist($slug, $bookId, $currentId = false)
566     {
567         $query = $this->page->where('slug', '=', $slug)->where('book_id', '=', $bookId);
568         if ($currentId) $query = $query->where('id', '!=', $currentId);
569         return $query->count() > 0;
570     }
571
572     /**
573      * Changes the related book for the specified page.
574      * Changes the book id of any relations to the page that store the book id.
575      * @param int $bookId
576      * @param Page $page
577      * @return Page
578      */
579     public function changeBook($bookId, Page $page)
580     {
581         $page->book_id = $bookId;
582         foreach ($page->activity as $activity) {
583             $activity->book_id = $bookId;
584             $activity->save();
585         }
586         $page->slug = $this->findSuitableSlug($page->name, $bookId, $page->id);
587         $page->save();
588         return $page;
589     }
590
591
592     /**
593      * Change the page's parent to the given entity.
594      * @param Page $page
595      * @param Entity $parent
596      */
597     public function changePageParent(Page $page, Entity $parent)
598     {
599         $book = $parent->isA('book') ? $parent : $parent->book;
600         $page->chapter_id = $parent->isA('chapter') ? $parent->id : 0;
601         $page->save();
602         $page = $this->changeBook($book->id, $page);
603         $page->load('book');
604         $this->permissionService->buildJointPermissionsForEntity($book);
605     }
606
607     /**
608      * Gets a suitable slug for the resource
609      * @param string $name
610      * @param int $bookId
611      * @param bool|false $currentId
612      * @return string
613      */
614     public function findSuitableSlug($name, $bookId, $currentId = false)
615     {
616         $slug = Str::slug($name);
617         if ($slug === "") $slug = substr(md5(rand(1, 500)), 0, 5);
618         while ($this->doesSlugExist($slug, $bookId, $currentId)) {
619             $slug .= '-' . substr(md5(rand(1, 500)), 0, 3);
620         }
621         return $slug;
622     }
623
624     /**
625      * Destroy a given page along with its dependencies.
626      * @param $page
627      */
628     public function destroy(Page $page)
629     {
630         Activity::removeEntity($page);
631         $page->views()->delete();
632         $page->tags()->delete();
633         $page->revisions()->delete();
634         $page->permissions()->delete();
635         $this->permissionService->deleteJointPermissionsForEntity($page);
636         $page->delete();
637     }
638
639     /**
640      * Get the latest pages added to the system.
641      * @param $count
642      */
643     public function getRecentlyCreatedPaginated($count = 20)
644     {
645         return $this->pageQuery()->orderBy('created_at', 'desc')->paginate($count);
646     }
647
648     /**
649      * Get the latest pages added to the system.
650      * @param $count
651      */
652     public function getRecentlyUpdatedPaginated($count = 20)
653     {
654         return $this->pageQuery()->orderBy('updated_at', 'desc')->paginate($count);
655     }
656
657 }