]> BookStack Code Mirror - bookstack/blob - app/Entities/Tools/TrashCan.php
Merge pull request #5844 from BookStackApp/user_ids
[bookstack] / app / Entities / Tools / TrashCan.php
1 <?php
2
3 namespace BookStack\Entities\Tools;
4
5 use BookStack\Entities\EntityProvider;
6 use BookStack\Entities\Models\Book;
7 use BookStack\Entities\Models\Bookshelf;
8 use BookStack\Entities\Models\Chapter;
9 use BookStack\Entities\Models\EntityContainerData;
10 use BookStack\Entities\Models\HasCoverInterface;
11 use BookStack\Entities\Models\Deletion;
12 use BookStack\Entities\Models\Entity;
13 use BookStack\Entities\Models\Page;
14 use BookStack\Entities\Queries\EntityQueries;
15 use BookStack\Exceptions\NotifyException;
16 use BookStack\Facades\Activity;
17 use BookStack\Uploads\AttachmentService;
18 use BookStack\Uploads\ImageService;
19 use BookStack\Util\DatabaseTransaction;
20 use Exception;
21 use Illuminate\Database\Eloquent\Builder;
22 use Illuminate\Support\Carbon;
23
24 class TrashCan
25 {
26     public function __construct(
27         protected EntityQueries $queries,
28     ) {
29     }
30
31     /**
32      * Send a shelf to the recycle bin.
33      *
34      * @throws NotifyException
35      */
36     public function softDestroyShelf(Bookshelf $shelf)
37     {
38         $this->ensureDeletable($shelf);
39         Deletion::createForEntity($shelf);
40         $shelf->delete();
41     }
42
43     /**
44      * Send a book to the recycle bin.
45      *
46      * @throws Exception
47      */
48     public function softDestroyBook(Book $book)
49     {
50         $this->ensureDeletable($book);
51         Deletion::createForEntity($book);
52
53         foreach ($book->pages as $page) {
54             $this->softDestroyPage($page, false);
55         }
56
57         foreach ($book->chapters as $chapter) {
58             $this->softDestroyChapter($chapter, false);
59         }
60
61         $book->delete();
62     }
63
64     /**
65      * Send a chapter to the recycle bin.
66      *
67      * @throws Exception
68      */
69     public function softDestroyChapter(Chapter $chapter, bool $recordDelete = true)
70     {
71         if ($recordDelete) {
72             $this->ensureDeletable($chapter);
73             Deletion::createForEntity($chapter);
74         }
75
76         if (count($chapter->pages) > 0) {
77             foreach ($chapter->pages as $page) {
78                 $this->softDestroyPage($page, false);
79             }
80         }
81
82         $chapter->delete();
83     }
84
85     /**
86      * Send a page to the recycle bin.
87      *
88      * @throws Exception
89      */
90     public function softDestroyPage(Page $page, bool $recordDelete = true)
91     {
92         if ($recordDelete) {
93             $this->ensureDeletable($page);
94             Deletion::createForEntity($page);
95         }
96
97         $page->delete();
98     }
99
100     /**
101      * Ensure the given entity is deletable.
102      * Is not for permissions, but logical conditions within the application.
103      * Will throw if not deletable.
104      *
105      * @throws NotifyException
106      */
107     protected function ensureDeletable(Entity $entity): void
108     {
109         $customHomeId = intval(explode(':', setting('app-homepage', '0:'))[0]);
110         $customHomeActive = setting('app-homepage-type') === 'page';
111         $removeCustomHome = false;
112
113         // Check custom homepage usage for pages
114         if ($entity instanceof Page && $entity->id === $customHomeId) {
115             if ($customHomeActive) {
116                 throw new NotifyException(trans('errors.page_custom_home_deletion'), $entity->getUrl());
117             }
118             $removeCustomHome = true;
119         }
120
121         // Check custom homepage usage within chapters or books
122         if ($entity instanceof Chapter || $entity instanceof Book) {
123             if ($entity->pages()->where('id', '=', $customHomeId)->exists()) {
124                 if ($customHomeActive) {
125                     throw new NotifyException(trans('errors.page_custom_home_deletion'), $entity->getUrl());
126                 }
127                 $removeCustomHome = true;
128             }
129         }
130
131         if ($removeCustomHome) {
132             setting()->remove('app-homepage');
133         }
134     }
135
136     /**
137      * Remove a bookshelf from the system.
138      *
139      * @throws Exception
140      */
141     protected function destroyShelf(Bookshelf $shelf): int
142     {
143         $this->destroyCommonRelations($shelf);
144         $shelf->books()->detach();
145         $shelf->forceDelete();
146
147         return 1;
148     }
149
150     /**
151      * Remove a book from the system.
152      * Destroys any child chapters and pages.
153      *
154      * @throws Exception
155      */
156     protected function destroyBook(Book $book): int
157     {
158         $count = 0;
159         $pages = $book->pages()->withTrashed()->get();
160         foreach ($pages as $page) {
161             $this->destroyPage($page);
162             $count++;
163         }
164
165         $chapters = $book->chapters()->withTrashed()->get();
166         foreach ($chapters as $chapter) {
167             $this->destroyChapter($chapter);
168             $count++;
169         }
170
171         $this->destroyCommonRelations($book);
172         $book->shelves()->detach();
173         $book->forceDelete();
174
175         return $count + 1;
176     }
177
178     /**
179      * Remove a chapter from the system.
180      * Destroys all pages within.
181      *
182      * @throws Exception
183      */
184     protected function destroyChapter(Chapter $chapter): int
185     {
186         $count = 0;
187         $pages = $chapter->pages()->withTrashed()->get();
188         foreach ($pages as $page) {
189             $this->destroyPage($page);
190             $count++;
191         }
192
193         $this->destroyCommonRelations($chapter);
194         $chapter->forceDelete();
195
196         return $count + 1;
197     }
198
199     /**
200      * Remove a page from the system.
201      *
202      * @throws Exception
203      */
204     protected function destroyPage(Page $page): int
205     {
206         $this->destroyCommonRelations($page);
207         $page->allRevisions()->delete();
208
209         // Delete Attached Files
210         $attachmentService = app()->make(AttachmentService::class);
211         foreach ($page->attachments as $attachment) {
212             $attachmentService->deleteFile($attachment);
213         }
214
215         // Remove use as a template
216         EntityContainerData::query()
217             ->where('default_template_id', '=', $page->id)
218             ->update(['default_template_id' => null]);
219
220         // TODO - Handle related images (uploaded_to for gallery/drawings).
221         //   Should maybe reset to null
222         //   But does that present visibility/permission issues if they used to retain their old
223         //   unused ID?
224         //   If so, might be better to leave them as-is like before, but ensure the maintenance
225         //   cleanup command/action can find these "orphaned" images and delete them.
226         //   But that would leave potential attachment to new pages on increment reset scenarios.
227         //   Need to review permission scenarios for null field values relative to storage options.
228
229         $page->forceDelete();
230
231         return 1;
232     }
233
234     /**
235      * Get the total counts of those that have been trashed
236      * but not yet fully deleted (In recycle bin).
237      */
238     public function getTrashedCounts(): array
239     {
240         $counts = [];
241
242         foreach ((new EntityProvider())->all() as $key => $instance) {
243             /** @var Builder<Entity> $query */
244             $query = $instance->newQuery();
245             $counts[$key] = $query->onlyTrashed()->count();
246         }
247
248         return $counts;
249     }
250
251     /**
252      * Destroy all items that have pending deletions.
253      *
254      * @throws Exception
255      */
256     public function empty(): int
257     {
258         $deletions = Deletion::all();
259         $deleteCount = 0;
260         foreach ($deletions as $deletion) {
261             $deleteCount += $this->destroyFromDeletion($deletion);
262         }
263
264         return $deleteCount;
265     }
266
267     /**
268      * Destroy an element from the given deletion model.
269      *
270      * @throws Exception
271      */
272     public function destroyFromDeletion(Deletion $deletion): int
273     {
274         // We directly load the deletable element here just to ensure it still
275         // exists in the event it has already been destroyed during this request.
276         $entity = $deletion->deletable()->first();
277         $count = 0;
278         if ($entity) {
279             $count = $this->destroyEntity($deletion->deletable);
280         }
281         $deletion->delete();
282
283         return $count;
284     }
285
286     /**
287      * Restore the content within the given deletion.
288      *
289      * @throws Exception
290      */
291     public function restoreFromDeletion(Deletion $deletion): int
292     {
293         $shouldRestore = true;
294         $restoreCount = 0;
295
296         if ($deletion->deletable instanceof Entity) {
297             $parent = $deletion->deletable->getParent();
298             if ($parent && $parent->trashed()) {
299                 $shouldRestore = false;
300             }
301         }
302
303         if ($deletion->deletable instanceof Entity && $shouldRestore) {
304             $restoreCount = $this->restoreEntity($deletion->deletable);
305         }
306
307         $deletion->delete();
308
309         return $restoreCount;
310     }
311
312     /**
313      * Automatically clear old content from the recycle bin
314      * depending on the configured lifetime.
315      * Returns the total number of deleted elements.
316      *
317      * @throws Exception
318      */
319     public function autoClearOld(): int
320     {
321         $lifetime = intval(config('app.recycle_bin_lifetime'));
322         if ($lifetime < 0) {
323             return 0;
324         }
325
326         $clearBeforeDate = Carbon::now()->addSeconds(10)->subDays($lifetime);
327         $deleteCount = 0;
328
329         $deletionsToRemove = Deletion::query()->where('created_at', '<', $clearBeforeDate)->get();
330         foreach ($deletionsToRemove as $deletion) {
331             $deleteCount += $this->destroyFromDeletion($deletion);
332         }
333
334         return $deleteCount;
335     }
336
337     /**
338      * Restore an entity so it is essentially un-deleted.
339      * Deletions on restored child elements will be removed during this restoration.
340      */
341     protected function restoreEntity(Entity $entity): int
342     {
343         $count = 1;
344         $entity->restore();
345
346         $restoreAction = function ($entity) use (&$count) {
347             if ($entity->deletions_count > 0) {
348                 $entity->deletions()->delete();
349             }
350
351             $entity->restore();
352             $count++;
353         };
354
355         if ($entity instanceof Chapter || $entity instanceof Book) {
356             $entity->pages()->withTrashed()->withCount('deletions')->get()->each($restoreAction);
357         }
358
359         if ($entity instanceof Book) {
360             $entity->chapters()->withTrashed()->withCount('deletions')->get()->each($restoreAction);
361         }
362
363         return $count;
364     }
365
366     /**
367      * Destroy the given entity.
368      * Returns the number of total entities destroyed in the operation.
369      *
370      * @throws Exception
371      */
372     public function destroyEntity(Entity $entity): int
373     {
374         $result = (new DatabaseTransaction(function () use ($entity) {
375             if ($entity instanceof Page) {
376                 return $this->destroyPage($entity);
377             } else if ($entity instanceof Chapter) {
378                 return $this->destroyChapter($entity);
379             } else if ($entity instanceof Book) {
380                 return $this->destroyBook($entity);
381             } else if ($entity instanceof Bookshelf) {
382                 return $this->destroyShelf($entity);
383             }
384             return null;
385         }))->run();
386
387         return $result ?? 0;
388     }
389
390     /**
391      * Update entity relations to remove or update outstanding connections.
392      */
393     protected function destroyCommonRelations(Entity $entity)
394     {
395         Activity::removeEntity($entity);
396         $entity->views()->delete();
397         $entity->permissions()->delete();
398         $entity->tags()->delete();
399         $entity->comments()->delete();
400         $entity->jointPermissions()->delete();
401         $entity->searchTerms()->delete();
402         $entity->deletions()->delete();
403         $entity->favourites()->delete();
404         $entity->watches()->delete();
405         $entity->referencesTo()->delete();
406         $entity->referencesFrom()->delete();
407
408         if ($entity instanceof HasCoverInterface && $entity->coverInfo()->exists()) {
409             $imageService = app()->make(ImageService::class);
410             $imageService->destroy($entity->coverInfo()->getImage());
411         }
412
413         $entity->relatedData()->delete();
414     }
415 }