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