]> BookStack Code Mirror - bookstack/blob - app/Entities/Controllers/PageController.php
Merge pull request #5917 from BookStackApp/copy_references
[bookstack] / app / Entities / Controllers / PageController.php
1 <?php
2
3 namespace BookStack\Entities\Controllers;
4
5 use BookStack\Activity\Models\View;
6 use BookStack\Activity\Tools\CommentTree;
7 use BookStack\Activity\Tools\UserEntityWatchOptions;
8 use BookStack\Entities\Models\Book;
9 use BookStack\Entities\Models\Chapter;
10 use BookStack\Entities\Queries\EntityQueries;
11 use BookStack\Entities\Queries\PageQueries;
12 use BookStack\Entities\Repos\PageRepo;
13 use BookStack\Entities\Tools\BookContents;
14 use BookStack\Entities\Tools\Cloner;
15 use BookStack\Entities\Tools\NextPreviousContentLocator;
16 use BookStack\Entities\Tools\PageContent;
17 use BookStack\Entities\Tools\PageEditActivity;
18 use BookStack\Entities\Tools\PageEditorData;
19 use BookStack\Exceptions\NotFoundException;
20 use BookStack\Exceptions\PermissionsException;
21 use BookStack\Http\Controller;
22 use BookStack\Permissions\Permission;
23 use BookStack\References\ReferenceFetcher;
24 use Exception;
25 use Illuminate\Database\Eloquent\Relations\BelongsTo;
26 use Illuminate\Http\Request;
27 use Illuminate\Validation\ValidationException;
28 use Throwable;
29
30 class PageController extends Controller
31 {
32     public function __construct(
33         protected PageRepo $pageRepo,
34         protected PageQueries $queries,
35         protected EntityQueries $entityQueries,
36         protected ReferenceFetcher $referenceFetcher
37     ) {
38     }
39
40     /**
41      * Show the form for creating a new page.
42      *
43      * @throws Throwable
44      */
45     public function create(string $bookSlug, ?string $chapterSlug = null)
46     {
47         if ($chapterSlug) {
48             $parent = $this->entityQueries->chapters->findVisibleBySlugsOrFail($bookSlug, $chapterSlug);
49         } else {
50             $parent = $this->entityQueries->books->findVisibleBySlugOrFail($bookSlug);
51         }
52
53         $this->checkOwnablePermission(Permission::PageCreate, $parent);
54
55         // Redirect to draft edit screen if signed in
56         if ($this->isSignedIn()) {
57             $draft = $this->pageRepo->getNewDraftPage($parent);
58
59             return redirect($draft->getUrl());
60         }
61
62         // Otherwise show the edit view if they're a guest
63         $this->setPageTitle(trans('entities.pages_new'));
64
65         return view('pages.guest-create', ['parent' => $parent]);
66     }
67
68     /**
69      * Create a new page as a guest user.
70      *
71      * @throws ValidationException
72      */
73     public function createAsGuest(Request $request, string $bookSlug, ?string $chapterSlug = null)
74     {
75         $this->validate($request, [
76             'name' => ['required', 'string', 'max:255'],
77         ]);
78
79         if ($chapterSlug) {
80             $parent = $this->entityQueries->chapters->findVisibleBySlugsOrFail($bookSlug, $chapterSlug);
81         } else {
82             $parent = $this->entityQueries->books->findVisibleBySlugOrFail($bookSlug);
83         }
84
85         $this->checkOwnablePermission(Permission::PageCreate, $parent);
86
87         $page = $this->pageRepo->getNewDraftPage($parent);
88         $this->pageRepo->publishDraft($page, [
89             'name' => $request->get('name'),
90         ]);
91
92         return redirect($page->getUrl('/edit'));
93     }
94
95     /**
96      * Show form to continue editing a draft page.
97      *
98      * @throws NotFoundException
99      */
100     public function editDraft(Request $request, string $bookSlug, int $pageId)
101     {
102         $draft = $this->queries->findVisibleByIdOrFail($pageId);
103         $this->checkOwnablePermission(Permission::PageCreate, $draft->getParent());
104
105         $editorData = new PageEditorData($draft, $this->entityQueries, $request->query('editor', ''));
106         $this->setPageTitle(trans('entities.pages_edit_draft'));
107
108         return view('pages.edit', $editorData->getViewData());
109     }
110
111     /**
112      * Store a new page by changing a draft into a page.
113      *
114      * @throws NotFoundException
115      * @throws ValidationException
116      */
117     public function store(Request $request, string $bookSlug, int $pageId)
118     {
119         $this->validate($request, [
120             'name' => ['required', 'string', 'max:255'],
121         ]);
122
123         $draftPage = $this->queries->findVisibleByIdOrFail($pageId);
124         $this->checkOwnablePermission(Permission::PageCreate, $draftPage->getParent());
125
126         $page = $this->pageRepo->publishDraft($draftPage, $request->all());
127
128         return redirect($page->getUrl());
129     }
130
131     /**
132      * Display the specified page.
133      * If the page is not found via the slug the revisions are searched for a match.
134      *
135      * @throws NotFoundException
136      */
137     public function show(string $bookSlug, string $pageSlug)
138     {
139         try {
140             $page = $this->queries->findVisibleBySlugsOrFail($bookSlug, $pageSlug);
141         } catch (NotFoundException $e) {
142             $page = $this->entityQueries->findVisibleByOldSlugs('page', $pageSlug, $bookSlug);
143             if (is_null($page)) {
144                 throw $e;
145             }
146
147             return redirect($page->getUrl());
148         }
149
150         $pageContent = (new PageContent($page));
151         $page->html = $pageContent->render();
152         $pageNav = $pageContent->getNavigation($page->html);
153
154         $sidebarTree = (new BookContents($page->book))->getTree();
155         $commentTree = (new CommentTree($page));
156         $nextPreviousLocator = new NextPreviousContentLocator($page, $sidebarTree);
157
158         View::incrementFor($page);
159         $this->setPageTitle($page->getShortName());
160
161         return view('pages.show', [
162             'page'            => $page,
163             'book'            => $page->book,
164             'current'         => $page,
165             'sidebarTree'     => $sidebarTree,
166             'commentTree'     => $commentTree,
167             'pageNav'         => $pageNav,
168             'watchOptions'    => new UserEntityWatchOptions(user(), $page),
169             'next'            => $nextPreviousLocator->getNext(),
170             'previous'        => $nextPreviousLocator->getPrevious(),
171             'referenceCount'  => $this->referenceFetcher->getReferenceCountToEntity($page),
172         ]);
173     }
174
175     /**
176      * Get page from an ajax request.
177      *
178      * @throws NotFoundException
179      */
180     public function getPageAjax(int $pageId)
181     {
182         $page = $this->queries->findVisibleByIdOrFail($pageId);
183         $page->setHidden(array_diff($page->getHidden(), ['html', 'markdown']));
184         $page->makeHidden(['book']);
185
186         return response()->json($page);
187     }
188
189     /**
190      * Show the form for editing the specified page.
191      *
192      * @throws NotFoundException
193      */
194     public function edit(Request $request, string $bookSlug, string $pageSlug)
195     {
196         $page = $this->queries->findVisibleBySlugsOrFail($bookSlug, $pageSlug);
197         $this->checkOwnablePermission(Permission::PageUpdate, $page, $page->getUrl());
198
199         $editorData = new PageEditorData($page, $this->entityQueries, $request->query('editor', ''));
200         if ($editorData->getWarnings()) {
201             $this->showWarningNotification(implode("\n", $editorData->getWarnings()));
202         }
203
204         $this->setPageTitle(trans('entities.pages_editing_named', ['pageName' => $page->getShortName()]));
205
206         return view('pages.edit', $editorData->getViewData());
207     }
208
209     /**
210      * Update the specified page in storage.
211      *
212      * @throws ValidationException
213      * @throws NotFoundException
214      */
215     public function update(Request $request, string $bookSlug, string $pageSlug)
216     {
217         $this->validate($request, [
218             'name' => ['required', 'string', 'max:255'],
219         ]);
220         $page = $this->queries->findVisibleBySlugsOrFail($bookSlug, $pageSlug);
221         $this->checkOwnablePermission(Permission::PageUpdate, $page);
222
223         $this->pageRepo->update($page, $request->all());
224
225         return redirect($page->getUrl());
226     }
227
228     /**
229      * Save a draft update as a revision.
230      *
231      * @throws NotFoundException
232      */
233     public function saveDraft(Request $request, int $pageId)
234     {
235         $page = $this->queries->findVisibleByIdOrFail($pageId);
236         $this->checkOwnablePermission(Permission::PageUpdate, $page);
237
238         if (!$this->isSignedIn()) {
239             return $this->jsonError(trans('errors.guests_cannot_save_drafts'), 500);
240         }
241
242         $draft = $this->pageRepo->updatePageDraft($page, $request->only(['name', 'html', 'markdown']));
243         $warnings = (new PageEditActivity($page))->getWarningMessagesForDraft($draft);
244
245         return response()->json([
246             'status'    => 'success',
247             'message'   => trans('entities.pages_edit_draft_save_at'),
248             'warning'   => implode("\n", $warnings),
249             'timestamp' => $draft->updated_at->timestamp,
250         ]);
251     }
252
253     /**
254      * Redirect from a special link url which uses the page id rather than the name.
255      *
256      * @throws NotFoundException
257      */
258     public function redirectFromLink(int $pageId)
259     {
260         $page = $this->queries->findVisibleByIdOrFail($pageId);
261
262         return redirect($page->getUrl());
263     }
264
265     /**
266      * Show the deletion page for the specified page.
267      *
268      * @throws NotFoundException
269      */
270     public function showDelete(string $bookSlug, string $pageSlug)
271     {
272         $page = $this->queries->findVisibleBySlugsOrFail($bookSlug, $pageSlug);
273         $this->checkOwnablePermission(Permission::PageDelete, $page);
274         $this->setPageTitle(trans('entities.pages_delete_named', ['pageName' => $page->getShortName()]));
275         $usedAsTemplate =
276             $this->entityQueries->books->start()->where('default_template_id', '=', $page->id)->count() > 0 ||
277             $this->entityQueries->chapters->start()->where('default_template_id', '=', $page->id)->count() > 0;
278
279         return view('pages.delete', [
280             'book'    => $page->book,
281             'page'    => $page,
282             'current' => $page,
283             'usedAsTemplate' => $usedAsTemplate,
284         ]);
285     }
286
287     /**
288      * Show the deletion page for the specified page.
289      *
290      * @throws NotFoundException
291      */
292     public function showDeleteDraft(string $bookSlug, int $pageId)
293     {
294         $page = $this->queries->findVisibleByIdOrFail($pageId);
295         $this->checkOwnablePermission(Permission::PageUpdate, $page);
296         $this->setPageTitle(trans('entities.pages_delete_draft_named', ['pageName' => $page->getShortName()]));
297         $usedAsTemplate =
298             $this->entityQueries->books->start()->where('default_template_id', '=', $page->id)->count() > 0 ||
299             $this->entityQueries->chapters->start()->where('default_template_id', '=', $page->id)->count() > 0;
300
301         return view('pages.delete', [
302             'book'    => $page->book,
303             'page'    => $page,
304             'current' => $page,
305             'usedAsTemplate' => $usedAsTemplate,
306         ]);
307     }
308
309     /**
310      * Remove the specified page from storage.
311      *
312      * @throws NotFoundException
313      * @throws Throwable
314      */
315     public function destroy(string $bookSlug, string $pageSlug)
316     {
317         $page = $this->queries->findVisibleBySlugsOrFail($bookSlug, $pageSlug);
318         $this->checkOwnablePermission(Permission::PageDelete, $page);
319         $parent = $page->getParent();
320
321         $this->pageRepo->destroy($page);
322
323         return redirect($parent->getUrl());
324     }
325
326     /**
327      * Remove the specified draft page from storage.
328      *
329      * @throws NotFoundException
330      * @throws Throwable
331      */
332     public function destroyDraft(string $bookSlug, int $pageId)
333     {
334         $page = $this->queries->findVisibleByIdOrFail($pageId);
335         $book = $page->book;
336         $chapter = $page->chapter;
337         $this->checkOwnablePermission(Permission::PageUpdate, $page);
338
339         $this->pageRepo->destroy($page);
340
341         $this->showSuccessNotification(trans('entities.pages_delete_draft_success'));
342
343         if ($chapter && userCan(Permission::ChapterView, $chapter)) {
344             return redirect($chapter->getUrl());
345         }
346
347         return redirect($book->getUrl());
348     }
349
350     /**
351      * Show a listing of recently created pages.
352      */
353     public function showRecentlyUpdated()
354     {
355         $visibleBelongsScope = function (BelongsTo $query) {
356             $query->scopes('visible');
357         };
358
359         $pages = $this->queries->visibleForList()
360             ->addSelect('updated_by')
361             ->with(['updatedBy', 'book' => $visibleBelongsScope, 'chapter' => $visibleBelongsScope])
362             ->orderBy('updated_at', 'desc')
363             ->paginate(20)
364             ->setPath(url('/pages/recently-updated'));
365
366         $this->setPageTitle(trans('entities.recently_updated_pages'));
367
368         return view('common.detailed-listing-paginated', [
369             'title'         => trans('entities.recently_updated_pages'),
370             'entities'      => $pages,
371             'showUpdatedBy' => true,
372             'showPath'      => true,
373         ]);
374     }
375
376     /**
377      * Show the view to choose a new parent to move a page into.
378      *
379      * @throws NotFoundException
380      */
381     public function showMove(string $bookSlug, string $pageSlug)
382     {
383         $page = $this->queries->findVisibleBySlugsOrFail($bookSlug, $pageSlug);
384         $this->checkOwnablePermission(Permission::PageUpdate, $page);
385         $this->checkOwnablePermission(Permission::PageDelete, $page);
386
387         return view('pages.move', [
388             'book' => $page->book,
389             'page' => $page,
390         ]);
391     }
392
393     /**
394      * Does the action of moving the location of a page.
395      *
396      * @throws NotFoundException
397      * @throws Throwable
398      */
399     public function move(Request $request, string $bookSlug, string $pageSlug)
400     {
401         $page = $this->queries->findVisibleBySlugsOrFail($bookSlug, $pageSlug);
402         $this->checkOwnablePermission(Permission::PageUpdate, $page);
403         $this->checkOwnablePermission(Permission::PageDelete, $page);
404
405         $entitySelection = $request->get('entity_selection', null);
406         if ($entitySelection === null || $entitySelection === '') {
407             return redirect($page->getUrl());
408         }
409
410         try {
411             $this->pageRepo->move($page, $entitySelection);
412         } catch (PermissionsException $exception) {
413             $this->showPermissionError();
414         } catch (Exception $exception) {
415             $this->showErrorNotification(trans('errors.selected_book_chapter_not_found'));
416
417             return redirect($page->getUrl('/move'));
418         }
419
420         return redirect($page->getUrl());
421     }
422
423     /**
424      * Show the view to copy a page.
425      *
426      * @throws NotFoundException
427      */
428     public function showCopy(string $bookSlug, string $pageSlug)
429     {
430         $page = $this->queries->findVisibleBySlugsOrFail($bookSlug, $pageSlug);
431         session()->flashInput(['name' => $page->name]);
432
433         return view('pages.copy', [
434             'book' => $page->book,
435             'page' => $page,
436         ]);
437     }
438
439     /**
440      * Create a copy of a page within the requested target destination.
441      *
442      * @throws NotFoundException
443      * @throws Throwable
444      */
445     public function copy(Request $request, Cloner $cloner, string $bookSlug, string $pageSlug)
446     {
447         $page = $this->queries->findVisibleBySlugsOrFail($bookSlug, $pageSlug);
448         $this->checkOwnablePermission(Permission::PageView, $page);
449
450         $entitySelection = $request->get('entity_selection') ?: null;
451         $newParent = $entitySelection ? $this->entityQueries->findVisibleByStringIdentifier($entitySelection) : $page->getParent();
452
453         if (!$newParent instanceof Book && !$newParent instanceof Chapter) {
454             $this->showErrorNotification(trans('errors.selected_book_chapter_not_found'));
455
456             return redirect($page->getUrl('/copy'));
457         }
458
459         $this->checkOwnablePermission(Permission::PageCreate, $newParent);
460
461         $newName = $request->get('name') ?: $page->name;
462         $pageCopy = $cloner->clonePage($page, $newParent, $newName);
463         $this->showSuccessNotification(trans('entities.pages_copy_success'));
464
465         return redirect($pageCopy->getUrl());
466     }
467 }