# 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
// 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.
use BookStack\Uploads\AttachmentService;
use BookStack\Uploads\ImageService;
use Exception;
+use Illuminate\Support\Carbon;
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.
{
$trashCan = new TrashCan();
$trashCan->softDestroyBook($book);
+ $trashCan->autoClearOld();
}
}
{
$trashCan = new TrashCan();
$trashCan->softDestroyShelf($shelf);
+ $trashCan->autoClearOld();
}
}
{
$trashCan = new TrashCan();
$trashCan->softDestroyChapter($chapter);
+ $trashCan->autoClearOld();
}
/**
{
$trashCan = new TrashCan();
$trashCan->softDestroyPage($page);
+ $trashCan->autoClearOld();
}
/**
'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
@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">
<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
use BookStack\Entities\Book;
use BookStack\Entities\Deletion;
use BookStack\Entities\Page;
+use DB;
+use Illuminate\Support\Carbon;
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();
}
}
$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');
'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