diff --git a/app/Console/Commands/CleanupImages.php b/app/Console/Commands/CleanupImages.php index 5eadf2751..e05508d5e 100644 --- a/app/Console/Commands/CleanupImages.php +++ b/app/Console/Commands/CleanupImages.php @@ -55,7 +55,7 @@ class CleanupImages extends Command } } - $deleted = $this->imageService->deleteUnusedImages($checkRevisions, ['gallery', 'drawio'], $dryRun); + $deleted = $this->imageService->deleteUnusedImages($checkRevisions, $dryRun); $deleteCount = count($deleted); if ($dryRun) { diff --git a/app/Http/Controllers/SettingController.php b/app/Http/Controllers/SettingController.php index e0e351458..d9d66042e 100644 --- a/app/Http/Controllers/SettingController.php +++ b/app/Http/Controllers/SettingController.php @@ -1,5 +1,6 @@ <?php namespace BookStack\Http\Controllers; +use BookStack\Services\ImageService; use Illuminate\Http\Request; use Illuminate\Http\Response; use Setting; @@ -13,7 +14,7 @@ class SettingController extends Controller public function index() { $this->checkPermission('settings-manage'); - $this->setPageTitle('Settings'); + $this->setPageTitle(trans('settings.settings')); // Get application version $version = trim(file_get_contents(base_path('version'))); @@ -43,4 +44,48 @@ class SettingController extends Controller session()->flash('success', trans('settings.settings_save_success')); return redirect('/settings'); } + + /** + * Show the page for application maintenance. + * @return \Illuminate\Contracts\View\Factory|\Illuminate\View\View + */ + public function showMaintenance() + { + $this->checkPermission('settings-manage'); + $this->setPageTitle(trans('settings.maint')); + + // Get application version + $version = trim(file_get_contents(base_path('version'))); + + return view('settings/maintenance', ['version' => $version]); + } + + /** + * Action to clean-up images in the system. + * @param Request $request + * @param ImageService $imageService + * @return \Illuminate\Http\RedirectResponse|\Illuminate\Routing\Redirector + */ + public function cleanupImages(Request $request, ImageService $imageService) + { + $this->checkPermission('settings-manage'); + + $checkRevisions = !($request->get('ignore_revisions', 'false') === 'true'); + $dryRun = !($request->has('confirm')); + + $imagesToDelete = $imageService->deleteUnusedImages($checkRevisions, $dryRun); + $deleteCount = count($imagesToDelete); + if ($deleteCount === 0) { + session()->flash('warning', trans('settings.maint_image_cleanup_nothing_found')); + return redirect('/settings/maintenance')->withInput(); + } + + if ($dryRun) { + session()->flash('cleanup-images-warning', trans('settings.maint_image_cleanup_warning', ['count' => $deleteCount])); + } else { + session()->flash('success', trans('settings.maint_image_cleanup_success', ['count' => $deleteCount])); + } + + return redirect('/settings/maintenance#image-cleanup')->withInput(); + } } diff --git a/app/Services/ImageService.php b/app/Services/ImageService.php index d1193ab4f..73a677ac2 100644 --- a/app/Services/ImageService.php +++ b/app/Services/ImageService.php @@ -306,11 +306,11 @@ class ImageService extends UploadService * * Returns the path of the images that would be/have been deleted. * @param bool $checkRevisions - * @param array $types * @param bool $dryRun + * @param array $types * @return array */ - public function deleteUnusedImages($checkRevisions = true, $types = ['gallery', 'drawio'], $dryRun = true) + public function deleteUnusedImages($checkRevisions = true, $dryRun = true, $types = ['gallery', 'drawio']) { $types = array_intersect($types, ['gallery', 'drawio']); $deletedPaths = []; diff --git a/resources/assets/icons/spanner.svg b/resources/assets/icons/spanner.svg new file mode 100644 index 000000000..8ab25a247 --- /dev/null +++ b/resources/assets/icons/spanner.svg @@ -0,0 +1,4 @@ +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"> + <path clip-rule="evenodd" fill="none" d="M0 0h24v24H0z"/> + <path d="M22.7 19l-9.1-9.1c.9-2.3.4-5-1.5-6.9-2-2-5-2.4-7.4-1.3L9 6 6 9 1.6 4.7C.4 7.1.9 10.1 2.9 12.1c1.9 1.9 4.6 2.4 6.9 1.5l9.1 9.1c.4.4 1 .4 1.4 0l2.3-2.3c.5-.4.5-1.1.1-1.4z"/> +</svg> diff --git a/resources/lang/en/settings.php b/resources/lang/en/settings.php index de4894280..3a3dbb9a4 100755 --- a/resources/lang/en/settings.php +++ b/resources/lang/en/settings.php @@ -50,6 +50,19 @@ return [ 'reg_confirm_restrict_domain_desc' => 'Enter a comma separated list of email domains you would like to restrict registration to. Users will be sent an email to confirm their address before being allowed to interact with the application. <br> Note that users will be able to change their email addresses after successful registration.', 'reg_confirm_restrict_domain_placeholder' => 'No restriction set', + /** + * Maintenance settings + */ + + 'maint' => 'Maintenance', + 'maint_image_cleanup' => 'Cleanup Images', + 'maint_image_cleanup_desc' => "Scans page & revision content to check which images and drawings are currently in use and which images are redundant. Ensure you create a full database and image backup before running this.", + 'maint_image_cleanup_ignore_revisions' => 'Ignore images in revisions', + 'maint_image_cleanup_run' => 'Run Cleanup', + 'maint_image_cleanup_warning' => ':count potentially unused images were found. Are you sure you want to delete these images?', + 'maint_image_cleanup_success' => ':count potentially unused images found and deleted!', + 'maint_image_cleanup_nothing_found' => 'No unused images found, Nothing deleted!', + /** * Role settings */ diff --git a/resources/views/settings/maintenance.blade.php b/resources/views/settings/maintenance.blade.php new file mode 100644 index 000000000..abf793ade --- /dev/null +++ b/resources/views/settings/maintenance.blade.php @@ -0,0 +1,49 @@ +@extends('simple-layout') + +@section('toolbar') + @include('settings/navbar', ['selected' => 'maintenance']) +@stop + +@section('body') +<div class="container small"> + + <div class="text-right text-muted container"> + <br> + BookStack @if(strpos($version, 'v') !== 0) version @endif {{ $version }} + </div> + + + <div class="card" id="image-cleanup"> + <h3>@icon('images') {{ trans('settings.maint_image_cleanup') }}</h3> + <div class="body"> + <div class="row"> + <div class="col-sm-6"> + <p class="small muted">{{ trans('settings.maint_image_cleanup_desc') }}</p> + </div> + <div class="col-sm-6"> + <form method="POST" action="{{ baseUrl('/settings/maintenance/cleanup-images') }}"> + {!! csrf_field() !!} + <input type="hidden" name="_method" value="DELETE"> + <div> + @if(session()->has('cleanup-images-warning')) + <p class="text neg"> + {{ session()->get('cleanup-images-warning') }} + </p> + <input type="hidden" name="ignore_revisions" value="{{ session()->getOldInput('ignore_revisions', 'false') }}"> + <input type="hidden" name="confirm" value="true"> + @else + <label> + <input type="checkbox" name="ignore_revisions" value="true"> + {{ trans('settings.maint_image_cleanup_ignore_revisions') }} + </label> + @endif + </div> + <button class="button outline">{{ trans('settings.maint_image_cleanup_run') }}</button> + </form> + </div> + </div> + </div> + </div> + +</div> +@stop diff --git a/resources/views/settings/navbar.blade.php b/resources/views/settings/navbar.blade.php index 0547b168c..f9db96894 100644 --- a/resources/views/settings/navbar.blade.php +++ b/resources/views/settings/navbar.blade.php @@ -2,6 +2,7 @@ <div class="col-md-12 setting-nav nav-tabs"> @if($currentUser->can('settings-manage')) <a href="{{ baseUrl('/settings') }}" @if($selected == 'settings') class="selected text-button" @endif>@icon('settings'){{ trans('settings.settings') }}</a> + <a href="{{ baseUrl('/settings/maintenance') }}" @if($selected == 'maintenance') class="selected text-button" @endif>@icon('spanner'){{ trans('settings.maint') }}</a> @endif @if($currentUser->can('users-manage')) <a href="{{ baseUrl('/settings/users') }}" @if($selected == 'users') class="selected text-button" @endif>@icon('users'){{ trans('settings.users') }}</a> diff --git a/routes/web.php b/routes/web.php index 7cad0c585..7c9a5a60e 100644 --- a/routes/web.php +++ b/routes/web.php @@ -152,6 +152,10 @@ Route::group(['middleware' => 'auth'], function () { Route::get('/', 'SettingController@index')->name('settings'); Route::post('/', 'SettingController@update'); + // Maintenance + Route::get('/maintenance', 'SettingController@showMaintenance'); + Route::delete('/maintenance/cleanup-images', 'SettingController@cleanupImages'); + // Users Route::get('/users', 'UserController@index'); Route::get('/users/create', 'UserController@create'); diff --git a/tests/ImageTest.php b/tests/ImageTest.php index 810fb3edf..0f1e772e9 100644 --- a/tests/ImageTest.php +++ b/tests/ImageTest.php @@ -2,6 +2,8 @@ use BookStack\Image; use BookStack\Page; +use BookStack\Repos\EntityRepo; +use BookStack\Services\ImageService; class ImageTest extends TestCase { @@ -234,4 +236,59 @@ class ImageTest extends TestCase ]); } + public function test_deleted_unused_images() + { + $page = Page::first(); + $admin = $this->getAdmin(); + $this->actingAs($admin); + + $imageName = 'unused-image.png'; + $relPath = $this->getTestImagePath('gallery', $imageName); + $this->deleteImage($relPath); + + $upload = $this->uploadImage($imageName, $page->id); + $upload->assertStatus(200); + $image = Image::where('type', '=', 'gallery')->first(); + + $entityRepo = app(EntityRepo::class); + $entityRepo->updatePage($page, $page->book_id, [ + 'name' => $page->name, + 'html' => $page->html . "<img src=\"{$image->url}\">", + 'summary' => '' + ]); + + // Ensure no images are reported as deletable + $imageService = app(ImageService::class); + $toDelete = $imageService->deleteUnusedImages(true, true); + $this->assertCount(0, $toDelete); + + // Save a revision of our page without the image; + $entityRepo->updatePage($page, $page->book_id, [ + 'name' => $page->name, + 'html' => "<p>Hello</p>", + 'summary' => '' + ]); + + // Ensure revision images are picked up okay + $imageService = app(ImageService::class); + $toDelete = $imageService->deleteUnusedImages(true, true); + $this->assertCount(0, $toDelete); + $toDelete = $imageService->deleteUnusedImages(false, true); + $this->assertCount(1, $toDelete); + + // Check image is found when revisions are destroyed + $page->revisions()->delete(); + $toDelete = $imageService->deleteUnusedImages(true, true); + $this->assertCount(1, $toDelete); + + // Check the image is deleted + $absPath = public_path($relPath); + $this->assertTrue(file_exists($absPath), "Existing uploaded file at path {$absPath} exists"); + $toDelete = $imageService->deleteUnusedImages(true, false); + $this->assertCount(1, $toDelete); + $this->assertFalse(file_exists($absPath)); + + $this->deleteImage($relPath); + } + } \ No newline at end of file