]> BookStack Code Mirror - bookstack/commitdiff
Added recycle bin auto-clear lifetime functionality
authorDan Brown <redacted>
Sat, 7 Nov 2020 13:58:23 +0000 (13:58 +0000)
committerDan Brown <redacted>
Sat, 7 Nov 2020 13:58:23 +0000 (13:58 +0000)
.env.example.complete
app/Config/app.php
app/Entities/Managers/TrashCan.php
app/Entities/Repos/BookRepo.php
app/Entities/Repos/BookshelfRepo.php
app/Entities/Repos/ChapterRepo.php
app/Entities/Repos/PageRepo.php
resources/lang/en/settings.php
resources/views/settings/maintenance.blade.php
tests/RecycleBinTest.php

index 39e7b43602b9da60820e1ef767640faec5393513..0e62b3ea6206a382b457321984c45d0a3994b7d2 100644 (file)
@@ -255,6 +255,14 @@ APP_VIEWS_BOOKSHELVES=grid
 # If set to 'false' a limit will not be enforced.
 REVISION_LIMIT=50
 
+# Recycle Bin Lifetime
+# The number of days that content will remain in the recycle bin before
+# being considered for auto-removal. It is not a guarantee that content will
+# be removed after this time.
+# Set to 0 for no recycle bin functionality.
+# Set to -1 for unlimited recycle bin lifetime.
+RECYCLE_BIN_LIFETIME=30
+
 # Allow <script> tags in page content
 # Note, if set to 'true' the page editor may still escape scripts.
 ALLOW_CONTENT_SCRIPTS=false
index 8a1d175284851708caa41dff5e44ef6b4cb8a1a1..24ed5799b062d7f9d2b8431756255d3055583678 100755 (executable)
@@ -31,6 +31,13 @@ return [
     // If set to false then a limit will not be enforced.
     'revision_limit' => env('REVISION_LIMIT', 50),
 
+    // The number of days that content will remain in the recycle bin before
+    // being considered for auto-removal. It is not a guarantee that content will
+    // be removed after this time.
+    // Set to 0 for no recycle bin functionality.
+    // Set to -1 for unlimited recycle bin lifetime.
+    'recycle_bin_lifetime' => env('RECYCLE_BIN_LIFETIME', 30),
+
     // Allow <script> tags to entered within page content.
     // <script> tags are escaped by default.
     // Even when overridden the WYSIWYG editor may still escape script content.
index 686918ce26cdd8c0d6ce4dc3987dac456dcea89c..48768ab9375ee81acb1a99e6ce19a60e3de112d2 100644 (file)
@@ -13,6 +13,7 @@ use BookStack\Facades\Activity;
 use BookStack\Uploads\AttachmentService;
 use BookStack\Uploads\ImageService;
 use Exception;
+use Illuminate\Support\Carbon;
 
 class TrashCan
 {
@@ -231,6 +232,30 @@ class TrashCan
         return $restoreCount;
     }
 
+    /**
+     * Automatically clear old content from the recycle bin
+     * depending on the configured lifetime.
+     * Returns the total number of deleted elements.
+     * @throws Exception
+     */
+    public function autoClearOld(): int
+    {
+        $lifetime = intval(config('app.recycle_bin_lifetime'));
+        if ($lifetime < 0) {
+            return 0;
+        }
+
+        $clearBeforeDate = Carbon::now()->addSeconds(10)->subDays($lifetime);
+        $deleteCount = 0;
+
+        $deletionsToRemove = Deletion::query()->where('created_at', '<', $clearBeforeDate)->get();
+        foreach ($deletionsToRemove as $deletion) {
+            $deleteCount += $this->destroyFromDeletion($deletion);
+        }
+
+        return $deleteCount;
+    }
+
     /**
      * Restore an entity so it is essentially un-deleted.
      * Deletions on restored child elements will be removed during this restoration.
index bb1895b36a3b62a0948322efc8803ef0efb3cff2..b0ea7cb87f1bd612c068f591af23ccef8906c793 100644 (file)
@@ -129,5 +129,6 @@ class BookRepo
     {
         $trashCan = new TrashCan();
         $trashCan->softDestroyBook($book);
+        $trashCan->autoClearOld();
     }
 }
index eb1536da75afa8fa5fbec5121ad31b1a5e33bcad..49fb75f4cfebd47d3cd70701c45a8061027ee009 100644 (file)
@@ -175,5 +175,6 @@ class BookshelfRepo
     {
         $trashCan = new TrashCan();
         $trashCan->softDestroyShelf($shelf);
+        $trashCan->autoClearOld();
     }
 }
index c1573f5db85ff48c3775783b7d27ed74da73620b..60599eac8231017fb85fb49cd2c029a6abb1bd53 100644 (file)
@@ -74,6 +74,7 @@ class ChapterRepo
     {
         $trashCan = new TrashCan();
         $trashCan->softDestroyChapter($chapter);
+        $trashCan->autoClearOld();
     }
 
     /**
index 3b9b1f34c9fb50dcfaeaa3699cb9ba7e22600eaa..80c8afe9813e873ac835516d9785aa3ba8fa64bc 100644 (file)
@@ -266,6 +266,7 @@ class PageRepo
     {
         $trashCan = new TrashCan();
         $trashCan->softDestroyPage($page);
+        $trashCan->autoClearOld();
     }
 
     /**
index b9d91e18c1ae49e429c27404526404fd0dc00f36..269c775ba868639e4de9abf9a73f91fb5e9e2ef7 100755 (executable)
@@ -80,7 +80,7 @@ return [
     'maint_send_test_email_mail_subject' => 'Test Email',
     'maint_send_test_email_mail_greeting' => 'Email delivery seems to work!',
     'maint_send_test_email_mail_text' => 'Congratulations! As you received this email notification, your email settings seem to be configured properly.',
-    'maint_recycle_bin_desc' => 'Items deleted remain in the recycle bin until it is emptied. Open the recycle bin to restore or permanently remove items.',
+    'maint_recycle_bin_desc' => 'Deleted shelves, books, chapters & pages are sent to the recycle bin so they can be restored or permanently deleted. Older items in the recycle bin may be automatically removed after a while depending on system configuration.',
     'maint_recycle_bin_open' => 'Open Recycle Bin',
 
     // Recycle Bin
index 804112c91cb43f5ff65ccff6b2a12cb1892e0c9f..13a8930a12106876efd81e7bc65bf6177b702dac 100644 (file)
@@ -5,6 +5,24 @@
 
     @include('settings.navbar-with-version', ['selected' => 'maintenance'])
 
+    <div class="card content-wrap auto-height pb-xl">
+        <h2 class="list-heading">{{ trans('settings.recycle_bin') }}</h2>
+        <div class="grid half gap-xl">
+            <div>
+                <p class="small text-muted">{{ trans('settings.maint_recycle_bin_desc') }}</p>
+            </div>
+            <div>
+                <div class="grid half no-gap mb-m">
+                    <p class="mb-xs text-bookshelf">@icon('bookshelf'){{ trans('entities.shelves') }}: {{ $recycleStats['bookshelf'] }}</p>
+                    <p class="mb-xs text-book">@icon('book'){{ trans('entities.books') }}: {{ $recycleStats['book'] }}</p>
+                    <p class="mb-xs text-chapter">@icon('chapter'){{ trans('entities.chapters') }}: {{ $recycleStats['chapter'] }}</p>
+                    <p class="mb-xs text-page">@icon('page'){{ trans('entities.pages') }}: {{ $recycleStats['page'] }}</p>
+                </div>
+                <a href="{{ url('/settings/recycle-bin') }}" class="button outline">{{ trans('settings.maint_recycle_bin_open') }}</a>
+            </div>
+        </div>
+    </div>
+
     <div id="image-cleanup" class="card content-wrap auto-height">
         <h2 class="list-heading">{{ trans('settings.maint_image_cleanup') }}</h2>
         <div class="grid half gap-xl">
@@ -15,7 +33,7 @@
                 <form method="POST" action="{{ url('/settings/maintenance/cleanup-images') }}">
                     {!! csrf_field()  !!}
                     <input type="hidden" name="_method" value="DELETE">
-                    <div>
+                    <div class="mb-s">
                         @if(session()->has('cleanup-images-warning'))
                             <p class="text-neg">
                                 {{ session()->get('cleanup-images-warning') }}
         </div>
     </div>
 
-    <div class="card content-wrap auto-height pb-xl">
-        <h2 class="list-heading">{{ trans('settings.recycle_bin') }}</h2>
-        <div class="grid half gap-xl">
-            <div>
-                <p class="small text-muted">{{ trans('settings.maint_recycle_bin_desc') }}</p>
-                <div class="grid half no-gap">
-                    <p class="mb-xs text-bookshelf">@icon('bookshelf'){{ trans('entities.shelves') }}: {{ $recycleStats['bookshelf'] }}</p>
-                    <p class="mb-xs text-book">@icon('book'){{ trans('entities.books') }}: {{ $recycleStats['book'] }}</p>
-                    <p class="mb-xs text-chapter">@icon('chapter'){{ trans('entities.chapters') }}: {{ $recycleStats['chapter'] }}</p>
-                    <p class="mb-xs text-page">@icon('page'){{ trans('entities.pages') }}: {{ $recycleStats['page'] }}</p>
-                </div>
-            </div>
-            <div>
-                <a href="{{ url('/settings/recycle-bin') }}" class="button outline">{{ trans('settings.maint_recycle_bin_open') }}</a>
-            </div>
-        </div>
-    </div>
-
 </div>
 @stop
index 6a10f271b5b0ec0e37729eb469bc6339a5296acb..ce8b644bd216a200503066adea829c437b00d654 100644 (file)
@@ -3,6 +3,8 @@
 use BookStack\Entities\Book;
 use BookStack\Entities\Deletion;
 use BookStack\Entities\Page;
+use DB;
+use Illuminate\Support\Carbon;
 
 class RecycleBinTest extends TestCase
 {
@@ -39,11 +41,11 @@ class RecycleBinTest extends TestCase
         $this->giveUserPermissions($editor, ['settings-manage']);
 
         foreach($routes as $route) {
-            \DB::beginTransaction();
+            DB::beginTransaction();
             [$method, $url] = explode(':', $route);
             $resp = $this->call($method, $url);
             $this->assertNotPermissionError($resp);
-            \DB::rollBack();
+            DB::rollBack();
         }
 
     }
@@ -93,15 +95,15 @@ class RecycleBinTest extends TestCase
         $this->asEditor()->delete($book->getUrl());
         $deletion = Deletion::query()->firstOrFail();
 
-        $this->assertEquals($book->pages->count(), \DB::table('pages')->where('book_id', '=', $book->id)->whereNotNull('deleted_at')->count());
-        $this->assertEquals($book->chapters->count(), \DB::table('chapters')->where('book_id', '=', $book->id)->whereNotNull('deleted_at')->count());
+        $this->assertEquals($book->pages->count(), DB::table('pages')->where('book_id', '=', $book->id)->whereNotNull('deleted_at')->count());
+        $this->assertEquals($book->chapters->count(), DB::table('chapters')->where('book_id', '=', $book->id)->whereNotNull('deleted_at')->count());
 
         $restoreReq = $this->asAdmin()->post("/settings/recycle-bin/{$deletion->id}/restore");
         $restoreReq->assertRedirect('/settings/recycle-bin');
         $this->assertTrue(Deletion::query()->count() === 0);
 
-        $this->assertEquals($book->pages->count(), \DB::table('pages')->where('book_id', '=', $book->id)->whereNull('deleted_at')->count());
-        $this->assertEquals($book->chapters->count(), \DB::table('chapters')->where('book_id', '=', $book->id)->whereNull('deleted_at')->count());
+        $this->assertEquals($book->pages->count(), DB::table('pages')->where('book_id', '=', $book->id)->whereNull('deleted_at')->count());
+        $this->assertEquals($book->chapters->count(), DB::table('chapters')->where('book_id', '=', $book->id)->whereNull('deleted_at')->count());
 
         $itemCount = 1 + $book->pages->count() + $book->chapters->count();
         $redirectReq = $this->get('/settings/recycle-bin');
@@ -154,4 +156,47 @@ class RecycleBinTest extends TestCase
             'extra' => $page->name,
         ]);
     }
+
+    public function test_auto_clear_functionality_works()
+    {
+        config()->set('app.recycle_bin_lifetime', 5);
+        $page = Page::query()->firstOrFail();
+        $otherPage = Page::query()->where('id', '!=', $page->id)->firstOrFail();
+
+        $this->asEditor()->delete($page->getUrl());
+        $this->assertDatabaseHas('pages', ['id' => $page->id]);
+        $this->assertEquals(1, Deletion::query()->count());
+
+        Carbon::setTestNow(Carbon::now()->addDays(6));
+        $this->asEditor()->delete($otherPage->getUrl());
+        $this->assertEquals(1, Deletion::query()->count());
+
+        $this->assertDatabaseMissing('pages', ['id' => $page->id]);
+    }
+
+    public function test_auto_clear_functionality_with_negative_time_keeps_forever()
+    {
+        config()->set('app.recycle_bin_lifetime', -1);
+        $page = Page::query()->firstOrFail();
+        $otherPage = Page::query()->where('id', '!=', $page->id)->firstOrFail();
+
+        $this->asEditor()->delete($page->getUrl());
+        $this->assertEquals(1, Deletion::query()->count());
+
+        Carbon::setTestNow(Carbon::now()->addDays(6000));
+        $this->asEditor()->delete($otherPage->getUrl());
+        $this->assertEquals(2, Deletion::query()->count());
+
+        $this->assertDatabaseHas('pages', ['id' => $page->id]);
+    }
+
+    public function test_auto_clear_functionality_with_zero_time_deletes_instantly()
+    {
+        config()->set('app.recycle_bin_lifetime', 0);
+        $page = Page::query()->firstOrFail();
+
+        $this->asEditor()->delete($page->getUrl());
+        $this->assertDatabaseMissing('pages', ['id' => $page->id]);
+        $this->assertEquals(0, Deletion::query()->count());
+    }
 }
\ No newline at end of file