]> BookStack Code Mirror - bookstack/blob - app/Repos/PageRepo.php
Merge branch 'custom_role_system'
[bookstack] / app / Repos / PageRepo.php
1 <?php namespace BookStack\Repos;
2
3
4 use Activity;
5 use BookStack\Book;
6 use BookStack\Chapter;
7 use BookStack\Exceptions\NotFoundException;
8 use BookStack\Services\RestrictionService;
9 use Illuminate\Http\Request;
10 use Illuminate\Support\Facades\Auth;
11 use Illuminate\Support\Facades\Log;
12 use Illuminate\Support\Str;
13 use BookStack\Page;
14 use BookStack\PageRevision;
15 use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
16
17 class PageRepo
18 {
19     protected $page;
20     protected $pageRevision;
21     protected $restrictionService;
22
23     /**
24      * PageRepo constructor.
25      * @param Page $page
26      * @param PageRevision $pageRevision
27      * @param RestrictionService $restrictionService
28      */
29     public function __construct(Page $page, PageRevision $pageRevision, RestrictionService $restrictionService)
30     {
31         $this->page = $page;
32         $this->pageRevision = $pageRevision;
33         $this->restrictionService = $restrictionService;
34     }
35
36     /**
37      * Base query for getting pages, Takes restrictions into account.
38      * @return mixed
39      */
40     private function pageQuery()
41     {
42         return $this->restrictionService->enforcePageRestrictions($this->page, 'view');
43     }
44
45     /**
46      * Get a page via a specific ID.
47      * @param $id
48      * @return mixed
49      */
50     public function getById($id)
51     {
52         return $this->pageQuery()->findOrFail($id);
53     }
54
55     /**
56      * Get a page identified by the given slug.
57      * @param $slug
58      * @param $bookId
59      * @return mixed
60      * @throws NotFoundException
61      */
62     public function getBySlug($slug, $bookId)
63     {
64         $page = $this->pageQuery()->where('slug', '=', $slug)->where('book_id', '=', $bookId)->first();
65         if ($page === null) throw new NotFoundException('Page not found');
66         return $page;
67     }
68
69     /**
70      * Search through page revisions and retrieve
71      * the last page in the current book that
72      * has a slug equal to the one given.
73      * @param $pageSlug
74      * @param $bookSlug
75      * @return null | Page
76      */
77     public function findPageUsingOldSlug($pageSlug, $bookSlug)
78     {
79         $revision = $this->pageRevision->where('slug', '=', $pageSlug)
80             ->whereHas('page', function($query) {
81                 $this->restrictionService->enforcePageRestrictions($query);
82             })
83             ->where('book_slug', '=', $bookSlug)->orderBy('created_at', 'desc')
84             ->with('page')->first();
85         return $revision !== null ? $revision->page : null;
86     }
87
88     /**
89      * Get a new Page instance from the given input.
90      * @param $input
91      * @return Page
92      */
93     public function newFromInput($input)
94     {
95         $page = $this->page->fill($input);
96         return $page;
97     }
98
99     /**
100      * Count the pages with a particular slug within a book.
101      * @param $slug
102      * @param $bookId
103      * @return mixed
104      */
105     public function countBySlug($slug, $bookId)
106     {
107         return $this->page->where('slug', '=', $slug)->where('book_id', '=', $bookId)->count();
108     }
109
110     /**
111      * Save a new page into the system.
112      * Input validation must be done beforehand.
113      * @param array $input
114      * @param Book  $book
115      * @param int   $chapterId
116      * @return Page
117      */
118     public function saveNew(array $input, Book $book, $chapterId = null)
119     {
120         $page = $this->newFromInput($input);
121         $page->slug = $this->findSuitableSlug($page->name, $book->id);
122
123         if ($chapterId) $page->chapter_id = $chapterId;
124
125         $page->html = $this->formatHtml($input['html']);
126         $page->text = strip_tags($page->html);
127         $page->created_by = auth()->user()->id;
128         $page->updated_by = auth()->user()->id;
129
130         $book->pages()->save($page);
131         return $page;
132     }
133
134     /**
135      * Formats a page's html to be tagged correctly
136      * within the system.
137      * @param string $htmlText
138      * @return string
139      */
140     protected function formatHtml($htmlText)
141     {
142         if($htmlText == '') return $htmlText;
143         libxml_use_internal_errors(true);
144         $doc = new \DOMDocument();
145         $doc->loadHTML(mb_convert_encoding($htmlText, 'HTML-ENTITIES', 'UTF-8'));
146
147         $container = $doc->documentElement;
148         $body = $container->childNodes->item(0);
149         $childNodes = $body->childNodes;
150
151         // Ensure no duplicate ids are used
152         $idArray = [];
153
154         foreach ($childNodes as $index => $childNode) {
155             /** @var \DOMElement $childNode */
156             if (get_class($childNode) !== 'DOMElement') continue;
157
158             // Overwrite id if not a BookStack custom id
159             if ($childNode->hasAttribute('id')) {
160                 $id = $childNode->getAttribute('id');
161                 if (strpos($id, 'bkmrk') === 0 && array_search($id, $idArray) === false) {
162                     $idArray[] = $id;
163                     continue;
164                 };
165             }
166
167             // Create an unique id for the element
168             // Uses the content as a basis to ensure output is the same every time
169             // the same content is passed through.
170             $contentId = 'bkmrk-' . substr(strtolower(preg_replace('/\s+/', '-', trim($childNode->nodeValue))), 0, 20);
171             $newId = urlencode($contentId);
172             $loopIndex = 0;
173             while (in_array($newId, $idArray)) {
174                 $newId = urlencode($contentId . '-' . $loopIndex);
175                 $loopIndex++;
176             }
177
178             $childNode->setAttribute('id', $newId);
179             $idArray[] = $newId;
180         }
181
182         // Generate inner html as a string
183         $html = '';
184         foreach ($childNodes as $childNode) {
185             $html .= $doc->saveHTML($childNode);
186         }
187
188         return $html;
189     }
190
191
192     /**
193      * Gets pages by a search term.
194      * Highlights page content for showing in results.
195      * @param string $term
196      * @param array $whereTerms
197      * @param int $count
198      * @param array $paginationAppends
199      * @return mixed
200      */
201     public function getBySearch($term, $whereTerms = [], $count = 20, $paginationAppends = [])
202     {
203         preg_match_all('/"(.*?)"/', $term, $matches);
204         if (count($matches[1]) > 0) {
205             $terms = $matches[1];
206             $term = trim(preg_replace('/"(.*?)"/', '', $term));
207         } else {
208             $terms = [];
209         }
210         if (!empty($term)) {
211             $terms = array_merge($terms, explode(' ', $term));
212         }
213         $pages = $this->restrictionService->enforcePageRestrictions($this->page->fullTextSearchQuery(['name', 'text'], $terms, $whereTerms))
214             ->paginate($count)->appends($paginationAppends);
215
216         // Add highlights to page text.
217         $words = join('|', explode(' ', preg_quote(trim($term), '/')));
218         //lookahead/behind assertions ensures cut between words
219         $s = '\s\x00-/:-@\[-`{-~'; //character set for start/end of words
220
221         foreach ($pages as $page) {
222             preg_match_all('#(?<=[' . $s . ']).{1,30}((' . $words . ').{1,30})+(?=[' . $s . '])#uis', $page->text, $matches, PREG_SET_ORDER);
223             //delimiter between occurrences
224             $results = [];
225             foreach ($matches as $line) {
226                 $results[] = htmlspecialchars($line[0], 0, 'UTF-8');
227             }
228             $matchLimit = 6;
229             if (count($results) > $matchLimit) {
230                 $results = array_slice($results, 0, $matchLimit);
231             }
232             $result = join('... ', $results);
233
234             //highlight
235             $result = preg_replace('#' . $words . '#iu', "<span class=\"highlight\">\$0</span>", $result);
236             if (strlen($result) < 5) {
237                 $result = $page->getExcerpt(80);
238             }
239             $page->searchSnippet = $result;
240         }
241         return $pages;
242     }
243
244     /**
245      * Search for image usage.
246      * @param $imageString
247      * @return mixed
248      */
249     public function searchForImage($imageString)
250     {
251         $pages = $this->pageQuery()->where('html', 'like', '%' . $imageString . '%')->get();
252         foreach ($pages as $page) {
253             $page->url = $page->getUrl();
254             $page->html = '';
255             $page->text = '';
256         }
257         return count($pages) > 0 ? $pages : false;
258     }
259
260     /**
261      * Updates a page with any fillable data and saves it into the database.
262      * @param Page   $page
263      * @param int    $book_id
264      * @param string $input
265      * @return Page
266      */
267     public function updatePage(Page $page, $book_id, $input)
268     {
269         // Save a revision before updating
270         if ($page->html !== $input['html'] || $page->name !== $input['name']) {
271             $this->saveRevision($page);
272         }
273
274         // Prevent slug being updated if no name change
275         if ($page->name !== $input['name']) {
276             $page->slug = $this->findSuitableSlug($input['name'], $book_id, $page->id);
277         }
278
279         // Update with new details
280         $page->fill($input);
281         $page->html = $this->formatHtml($input['html']);
282         $page->text = strip_tags($page->html);
283         $page->updated_by = auth()->user()->id;
284         $page->save();
285         return $page;
286     }
287
288     /**
289      * Restores a revision's content back into a page.
290      * @param Page $page
291      * @param Book $book
292      * @param  int $revisionId
293      * @return Page
294      */
295     public function restoreRevision(Page $page, Book $book, $revisionId)
296     {
297         $this->saveRevision($page);
298         $revision = $this->getRevisionById($revisionId);
299         $page->fill($revision->toArray());
300         $page->slug = $this->findSuitableSlug($page->name, $book->id, $page->id);
301         $page->text = strip_tags($page->html);
302         $page->updated_by = auth()->user()->id;
303         $page->save();
304         return $page;
305     }
306
307     /**
308      * Saves a page revision into the system.
309      * @param Page $page
310      * @return $this
311      */
312     public function saveRevision(Page $page)
313     {
314         $revision = $this->pageRevision->fill($page->toArray());
315         $revision->page_id = $page->id;
316         $revision->slug = $page->slug;
317         $revision->book_slug = $page->book->slug;
318         $revision->created_by = auth()->user()->id;
319         $revision->created_at = $page->updated_at;
320         $revision->save();
321         // Clear old revisions
322         if ($this->pageRevision->where('page_id', '=', $page->id)->count() > 50) {
323             $this->pageRevision->where('page_id', '=', $page->id)
324                 ->orderBy('created_at', 'desc')->skip(50)->take(5)->delete();
325         }
326         return $revision;
327     }
328
329     /**
330      * Gets a single revision via it's id.
331      * @param $id
332      * @return mixed
333      */
334     public function getRevisionById($id)
335     {
336         return $this->pageRevision->findOrFail($id);
337     }
338
339     /**
340      * Checks if a slug exists within a book already.
341      * @param            $slug
342      * @param            $bookId
343      * @param bool|false $currentId
344      * @return bool
345      */
346     public function doesSlugExist($slug, $bookId, $currentId = false)
347     {
348         $query = $this->page->where('slug', '=', $slug)->where('book_id', '=', $bookId);
349         if ($currentId) $query = $query->where('id', '!=', $currentId);
350         return $query->count() > 0;
351     }
352
353     /**
354      * Changes the related book for the specified page.
355      * Changes the book id of any relations to the page that store the book id.
356      * @param int  $bookId
357      * @param Page $page
358      * @return Page
359      */
360     public function changeBook($bookId, Page $page)
361     {
362         $page->book_id = $bookId;
363         foreach ($page->activity as $activity) {
364             $activity->book_id = $bookId;
365             $activity->save();
366         }
367         $page->slug = $this->findSuitableSlug($page->name, $bookId, $page->id);
368         $page->save();
369         return $page;
370     }
371
372     /**
373      * Gets a suitable slug for the resource
374      * @param            $name
375      * @param            $bookId
376      * @param bool|false $currentId
377      * @return string
378      */
379     public function findSuitableSlug($name, $bookId, $currentId = false)
380     {
381         $slug = Str::slug($name);
382         while ($this->doesSlugExist($slug, $bookId, $currentId)) {
383             $slug .= '-' . substr(md5(rand(1, 500)), 0, 3);
384         }
385         return $slug;
386     }
387
388     /**
389      * Destroy a given page along with its dependencies.
390      * @param $page
391      */
392     public function destroy($page)
393     {
394         Activity::removeEntity($page);
395         $page->views()->delete();
396         $page->revisions()->delete();
397         $page->restrictions()->delete();
398         $page->delete();
399     }
400
401     /**
402      * Get the latest pages added to the system.
403      * @param $count
404      */
405     public function getRecentlyCreatedPaginated($count = 20)
406     {
407         return $this->pageQuery()->orderBy('created_at', 'desc')->paginate($count);
408     }
409
410     /**
411      * Get the latest pages added to the system.
412      * @param $count
413      */
414     public function getRecentlyUpdatedPaginated($count = 20)
415     {
416         return $this->pageQuery()->orderBy('updated_at', 'desc')->paginate($count);
417     }
418
419     /**
420      * Updates pages restrictions from a request
421      * @param $request
422      * @param $page
423      */
424     public function updateRestrictionsFromRequest($request, $page)
425     {
426         // TODO - extract into shared repo
427         $page->restricted = $request->has('restricted') && $request->get('restricted') === 'true';
428         $page->restrictions()->delete();
429         if ($request->has('restrictions')) {
430             foreach($request->get('restrictions') as $roleId => $restrictions) {
431                 foreach ($restrictions as $action => $value) {
432                     $page->restrictions()->create([
433                         'role_id' => $roleId,
434                         'action'  => strtolower($action)
435                     ]);
436                 }
437             }
438         }
439         $page->save();
440     }
441
442 }