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