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