Added recycle bin auto-clear lifetime functionality

This commit is contained in:
Dan Brown 2020-11-07 13:58:23 +00:00
parent 68b1d87ebe
commit ec3aeb3315
No known key found for this signature in database
GPG Key ID: 46D9F943C24A2EF9
10 changed files with 115 additions and 26 deletions

View File

@ -255,6 +255,14 @@ APP_VIEWS_BOOKSHELVES=grid
# If set to 'false' a limit will not be enforced. # If set to 'false' a limit will not be enforced.
REVISION_LIMIT=50 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 # Allow <script> tags in page content
# Note, if set to 'true' the page editor may still escape scripts. # Note, if set to 'true' the page editor may still escape scripts.
ALLOW_CONTENT_SCRIPTS=false ALLOW_CONTENT_SCRIPTS=false

View File

@ -31,6 +31,13 @@ return [
// If set to false then a limit will not be enforced. // If set to false then a limit will not be enforced.
'revision_limit' => env('REVISION_LIMIT', 50), '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. // Allow <script> tags to entered within page content.
// <script> tags are escaped by default. // <script> tags are escaped by default.
// Even when overridden the WYSIWYG editor may still escape script content. // Even when overridden the WYSIWYG editor may still escape script content.

View File

@ -13,6 +13,7 @@ use BookStack\Facades\Activity;
use BookStack\Uploads\AttachmentService; use BookStack\Uploads\AttachmentService;
use BookStack\Uploads\ImageService; use BookStack\Uploads\ImageService;
use Exception; use Exception;
use Illuminate\Support\Carbon;
class TrashCan class TrashCan
{ {
@ -231,6 +232,30 @@ class TrashCan
return $restoreCount; 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. * Restore an entity so it is essentially un-deleted.
* Deletions on restored child elements will be removed during this restoration. * Deletions on restored child elements will be removed during this restoration.

View File

@ -129,5 +129,6 @@ class BookRepo
{ {
$trashCan = new TrashCan(); $trashCan = new TrashCan();
$trashCan->softDestroyBook($book); $trashCan->softDestroyBook($book);
$trashCan->autoClearOld();
} }
} }

View File

@ -175,5 +175,6 @@ class BookshelfRepo
{ {
$trashCan = new TrashCan(); $trashCan = new TrashCan();
$trashCan->softDestroyShelf($shelf); $trashCan->softDestroyShelf($shelf);
$trashCan->autoClearOld();
} }
} }

View File

@ -74,6 +74,7 @@ class ChapterRepo
{ {
$trashCan = new TrashCan(); $trashCan = new TrashCan();
$trashCan->softDestroyChapter($chapter); $trashCan->softDestroyChapter($chapter);
$trashCan->autoClearOld();
} }
/** /**

View File

@ -266,6 +266,7 @@ class PageRepo
{ {
$trashCan = new TrashCan(); $trashCan = new TrashCan();
$trashCan->softDestroyPage($page); $trashCan->softDestroyPage($page);
$trashCan->autoClearOld();
} }
/** /**

View File

@ -80,7 +80,7 @@ return [
'maint_send_test_email_mail_subject' => 'Test Email', 'maint_send_test_email_mail_subject' => 'Test Email',
'maint_send_test_email_mail_greeting' => 'Email delivery seems to work!', '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_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', 'maint_recycle_bin_open' => 'Open Recycle Bin',
// Recycle Bin // Recycle Bin

View File

@ -5,6 +5,24 @@
@include('settings.navbar-with-version', ['selected' => 'maintenance']) @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"> <div id="image-cleanup" class="card content-wrap auto-height">
<h2 class="list-heading">{{ trans('settings.maint_image_cleanup') }}</h2> <h2 class="list-heading">{{ trans('settings.maint_image_cleanup') }}</h2>
<div class="grid half gap-xl"> <div class="grid half gap-xl">
@ -15,7 +33,7 @@
<form method="POST" action="{{ url('/settings/maintenance/cleanup-images') }}"> <form method="POST" action="{{ url('/settings/maintenance/cleanup-images') }}">
{!! csrf_field() !!} {!! csrf_field() !!}
<input type="hidden" name="_method" value="DELETE"> <input type="hidden" name="_method" value="DELETE">
<div> <div class="mb-s">
@if(session()->has('cleanup-images-warning')) @if(session()->has('cleanup-images-warning'))
<p class="text-neg"> <p class="text-neg">
{{ session()->get('cleanup-images-warning') }} {{ session()->get('cleanup-images-warning') }}
@ -50,23 +68,5 @@
</div> </div>
</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> </div>
@stop @stop

View File

@ -3,6 +3,8 @@
use BookStack\Entities\Book; use BookStack\Entities\Book;
use BookStack\Entities\Deletion; use BookStack\Entities\Deletion;
use BookStack\Entities\Page; use BookStack\Entities\Page;
use DB;
use Illuminate\Support\Carbon;
class RecycleBinTest extends TestCase class RecycleBinTest extends TestCase
{ {
@ -39,11 +41,11 @@ class RecycleBinTest extends TestCase
$this->giveUserPermissions($editor, ['settings-manage']); $this->giveUserPermissions($editor, ['settings-manage']);
foreach($routes as $route) { foreach($routes as $route) {
\DB::beginTransaction(); DB::beginTransaction();
[$method, $url] = explode(':', $route); [$method, $url] = explode(':', $route);
$resp = $this->call($method, $url); $resp = $this->call($method, $url);
$this->assertNotPermissionError($resp); $this->assertNotPermissionError($resp);
\DB::rollBack(); DB::rollBack();
} }
} }
@ -93,15 +95,15 @@ class RecycleBinTest extends TestCase
$this->asEditor()->delete($book->getUrl()); $this->asEditor()->delete($book->getUrl());
$deletion = Deletion::query()->firstOrFail(); $deletion = Deletion::query()->firstOrFail();
$this->assertEquals($book->pages->count(), \DB::table('pages')->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()); $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 = $this->asAdmin()->post("/settings/recycle-bin/{$deletion->id}/restore");
$restoreReq->assertRedirect('/settings/recycle-bin'); $restoreReq->assertRedirect('/settings/recycle-bin');
$this->assertTrue(Deletion::query()->count() === 0); $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->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->chapters->count(), DB::table('chapters')->where('book_id', '=', $book->id)->whereNull('deleted_at')->count());
$itemCount = 1 + $book->pages->count() + $book->chapters->count(); $itemCount = 1 + $book->pages->count() + $book->chapters->count();
$redirectReq = $this->get('/settings/recycle-bin'); $redirectReq = $this->get('/settings/recycle-bin');
@ -154,4 +156,47 @@ class RecycleBinTest extends TestCase
'extra' => $page->name, '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());
}
} }