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