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