diff --git a/app/Http/Controllers/SettingController.php b/app/Http/Controllers/SettingController.php index f5e48ca4c..1e13d7cb7 100644 --- a/app/Http/Controllers/SettingController.php +++ b/app/Http/Controllers/SettingController.php @@ -4,20 +4,14 @@ namespace BookStack\Http\Controllers; use BookStack\Actions\ActivityType; use BookStack\Auth\User; +use BookStack\Settings\AppSettingsStore; use BookStack\Uploads\ImageRepo; use Illuminate\Http\Request; class SettingController extends Controller { - protected ImageRepo $imageRepo; - protected array $settingCategories = ['features', 'customization', 'registration']; - public function __construct(ImageRepo $imageRepo) - { - $this->imageRepo = $imageRepo; - } - /** * Handle requests to the settings index path. */ @@ -48,37 +42,17 @@ class SettingController extends Controller /** * Update the specified settings in storage. */ - public function update(Request $request, string $category) + public function update(Request $request, AppSettingsStore $store, string $category) { $this->ensureCategoryExists($category); $this->preventAccessInDemoMode(); $this->checkPermission('settings-manage'); $this->validate($request, [ - 'app_logo' => array_merge(['nullable'], $this->getImageValidationRules()), + 'app_logo' => ['nullable', ...$this->getImageValidationRules()], + 'app_icon' => ['nullable', ...$this->getImageValidationRules()], ]); - // Cycles through posted settings and update them - foreach ($request->all() as $name => $value) { - $key = str_replace('setting-', '', trim($name)); - if (strpos($name, 'setting-') !== 0) { - continue; - } - setting()->put($key, $value); - } - - // Update logo image if set - if ($category === 'customization' && $request->hasFile('app_logo')) { - $logoFile = $request->file('app_logo'); - $this->imageRepo->destroyByType('system'); - $image = $this->imageRepo->saveNew($logoFile, 'system', 0, null, 86); - setting()->put('app-logo', $image->url); - } - - // Clear logo image if requested - if ($category === 'customization' && $request->get('app_logo_reset', null)) { - $this->imageRepo->destroyByType('system'); - setting()->remove('app-logo'); - } + $store->storeFromUpdateRequest($request, $category); $this->logActivity(ActivityType::SETTINGS_UPDATE, $category); $this->showSuccessNotification(trans('settings.settings_save_success')); diff --git a/app/Settings/AppSettingsStore.php b/app/Settings/AppSettingsStore.php new file mode 100644 index 000000000..8d7b73c1c --- /dev/null +++ b/app/Settings/AppSettingsStore.php @@ -0,0 +1,91 @@ +imageRepo = $imageRepo; + } + + public function storeFromUpdateRequest(Request $request, string $category) + { + $this->storeSimpleSettings($request); + if ($category === 'customization') { + $this->updateAppLogo($request); + $this->updateAppIcon($request); + } + } + + protected function updateAppIcon(Request $request): void + { + $sizes = [180, 128, 64, 32]; + + // Update icon image if set + if ($request->hasFile('app_icon')) { + $iconFile = $request->file('app_icon'); + $this->destroyExistingSettingImage('app-icon'); + $image = $this->imageRepo->saveNew($iconFile, 'system', 0, 256, 256); + setting()->put('app-icon', $image->url); + + foreach ($sizes as $size) { + $this->destroyExistingSettingImage('app-icon-' . $size); + $icon = $this->imageRepo->saveNew($iconFile, 'system', 0, $size, $size); + setting()->put('app-icon-' . $size, $icon->url); + } + } + + // Clear icon image if requested + if ($request->get('app_icon_reset')) { + $this->destroyExistingSettingImage('app-icon'); + setting()->remove('app-icon'); + foreach ($sizes as $size) { + $this->destroyExistingSettingImage('app-icon-' . $size); + setting()->remove('app-icon-' . $size); + } + } + } + + protected function updateAppLogo(Request $request): void + { + // Update logo image if set + if ($request->hasFile('app_logo')) { + $logoFile = $request->file('app_logo'); + $this->destroyExistingSettingImage('app-logo'); + $image = $this->imageRepo->saveNew($logoFile, 'system', 0, null, 86); + setting()->put('app-logo', $image->url); + } + + // Clear logo image if requested + if ($request->get('app_logo_reset')) { + $this->destroyExistingSettingImage('app-logo'); + setting()->remove('app-logo'); + } + } + + protected function storeSimpleSettings(Request $request): void + { + foreach ($request->all() as $name => $value) { + if (strpos($name, 'setting-') !== 0) { + continue; + } + + $key = str_replace('setting-', '', trim($name)); + setting()->put($key, $value); + } + } + + protected function destroyExistingSettingImage(string $settingKey) + { + $existingVal = setting()->get($settingKey); + if ($existingVal) { + $this->imageRepo->destroyByUrlAndType($existingVal, 'system'); + } + } +} diff --git a/app/Settings/SettingService.php b/app/Settings/SettingService.php index 9f0a41ea2..d1bac164d 100644 --- a/app/Settings/SettingService.php +++ b/app/Settings/SettingService.php @@ -12,15 +12,11 @@ use Illuminate\Contracts\Cache\Repository as Cache; */ class SettingService { - protected $setting; - protected $cache; - protected $localCache = []; + protected Setting $setting; + protected Cache $cache; + protected array $localCache = []; + protected string $cachePrefix = 'setting-'; - protected $cachePrefix = 'setting-'; - - /** - * SettingService constructor. - */ public function __construct(Setting $setting, Cache $cache) { $this->setting = $setting; diff --git a/app/Uploads/ImageRepo.php b/app/Uploads/ImageRepo.php index 8770402ad..2c643a58b 100644 --- a/app/Uploads/ImageRepo.php +++ b/app/Uploads/ImageRepo.php @@ -123,7 +123,10 @@ class ImageRepo public function saveNew(UploadedFile $uploadFile, string $type, int $uploadedTo = 0, int $resizeWidth = null, int $resizeHeight = null, bool $keepRatio = true): Image { $image = $this->imageService->saveNewFromUpload($uploadFile, $type, $uploadedTo, $resizeWidth, $resizeHeight, $keepRatio); - $this->loadThumbs($image); + + if ($type !== 'system') { + $this->loadThumbs($image); + } return $image; } @@ -180,13 +183,17 @@ class ImageRepo } /** - * Destroy all images of a certain type. + * Destroy images that have a specific URL and type combination. * * @throws Exception */ - public function destroyByType(string $imageType): void + public function destroyByUrlAndType(string $url, string $imageType): void { - $images = Image::query()->where('type', '=', $imageType)->get(); + $images = Image::query() + ->where('url', '=', $url) + ->where('type', '=', $imageType) + ->get(); + foreach ($images as $image) { $this->destroyImage($image); } diff --git a/public/icon-128.png b/public/icon-128.png new file mode 100644 index 000000000..46cc2811b Binary files /dev/null and b/public/icon-128.png differ diff --git a/public/icon-32.png b/public/icon-32.png new file mode 100644 index 000000000..7307ed8f1 Binary files /dev/null and b/public/icon-32.png differ diff --git a/public/icon-64.png b/public/icon-64.png new file mode 100644 index 000000000..854d80faa Binary files /dev/null and b/public/icon-64.png differ diff --git a/public/icon.png b/public/icon.png new file mode 100644 index 000000000..b9f0125a8 Binary files /dev/null and b/public/icon.png differ diff --git a/resources/lang/en/settings.php b/resources/lang/en/settings.php index f4204dd68..023cf1beb 100755 --- a/resources/lang/en/settings.php +++ b/resources/lang/en/settings.php @@ -33,7 +33,9 @@ return [ 'app_custom_html_desc' => 'Any content added here will be inserted into the bottom of the section of every page. This is handy for overriding styles or adding analytics code.', 'app_custom_html_disabled_notice' => 'Custom HTML head content is disabled on this settings page to ensure any breaking changes can be reverted.', 'app_logo' => 'Application Logo', - 'app_logo_desc' => 'This image should be 43px in height.
Large images will be scaled down.', + 'app_logo_desc' => 'This is used in the application header bar, among other areas. This image should be 86px in height. Large images will be scaled down.', + 'app_icon' => 'Application Icon', + 'app_icon_desc' => 'This icon is used for browser tabs and shortcut icons. This should be a 256px square PNG image.', 'app_primary_color' => 'Application Primary Color', 'app_primary_color_desc' => 'Sets the primary color for the application including the banner, buttons, and links.', 'app_homepage' => 'Application Homepage', diff --git a/resources/views/layouts/base.blade.php b/resources/views/layouts/base.blade.php index 76d220952..e0a6f46d0 100644 --- a/resources/views/layouts/base.blade.php +++ b/resources/views/layouts/base.blade.php @@ -6,10 +6,11 @@ {{ isset($pageTitle) ? $pageTitle . ' | ' : '' }}{{ setting('app-name') }} + - + @@ -20,6 +21,14 @@ + + + + + + + + @yield('head') diff --git a/resources/views/settings/customization.blade.php b/resources/views/settings/customization.blade.php index 3748267df..aa37c30c9 100644 --- a/resources/views/settings/customization.blade.php +++ b/resources/views/settings/customization.blade.php @@ -53,6 +53,22 @@ +
+
+ +

{{ trans('settings.app_icon_desc') }}

+
+
+ @include('form.image-picker', [ + 'removeValue' => 'none', + 'defaultImage' => url('/icon.png'), + 'currentImage' => setting('app-icon'), + 'name' => 'app_icon', + 'imageClass' => 'logo-image', + ]) +
+
+
diff --git a/tests/Settings/SettingsTest.php b/tests/Settings/SettingsTest.php index e2ac6f27c..1161a466e 100644 --- a/tests/Settings/SettingsTest.php +++ b/tests/Settings/SettingsTest.php @@ -2,10 +2,14 @@ namespace Tests\Settings; +use Illuminate\Support\Facades\Storage; use Tests\TestCase; +use Tests\Uploads\UsesImages; class SettingsTest extends TestCase { + use UsesImages; + public function test_settings_endpoint_redirects_to_settings_view() { $resp = $this->asAdmin()->get('/settings'); @@ -40,4 +44,46 @@ class SettingsTest extends TestCase $resp->assertStatus(404); $resp->assertSee('Page Not Found'); } + + public function test_updating_and_removing_app_icon() + { + $this->asAdmin(); + $galleryFile = $this->getTestImage('my-app-icon.png'); + $expectedPath = public_path('uploads/images/system/' . date('Y-m') . '/my-app-icon.png'); + + $this->assertFalse(setting()->get('app-icon')); + $this->assertFalse(setting()->get('app-icon-180')); + $this->assertFalse(setting()->get('app-icon-128')); + $this->assertFalse(setting()->get('app-icon-64')); + $this->assertFalse(setting()->get('app-icon-32')); + + $prevFileCount = count(glob(dirname($expectedPath) . DIRECTORY_SEPARATOR . '*.png')); + + $upload = $this->call('POST', '/settings/customization', [], [], ['app_icon' => $galleryFile], []); + $upload->assertRedirect('/settings/customization'); + + $this->assertTrue(file_exists($expectedPath), 'Uploaded image not found at path: ' . $expectedPath); + $this->assertStringContainsString('my-app-icon', setting()->get('app-icon')); + $this->assertStringContainsString('my-app-icon', setting()->get('app-icon-180')); + $this->assertStringContainsString('my-app-icon', setting()->get('app-icon-128')); + $this->assertStringContainsString('my-app-icon', setting()->get('app-icon-64')); + $this->assertStringContainsString('my-app-icon', setting()->get('app-icon-32')); + + $newFileCount = count(glob(dirname($expectedPath) . DIRECTORY_SEPARATOR . '*.png')); + $this->assertEquals(5, $newFileCount - $prevFileCount); + + $resp = $this->get('/'); + $this->withHtml($resp)->assertElementCount('link[sizes][href*="my-app-icon"]', 6); + + $reset = $this->post('/settings/customization', ['app_icon_reset' => 'true']); + $reset->assertRedirect('/settings/customization'); + + $resetFileCount = count(glob(dirname($expectedPath) . DIRECTORY_SEPARATOR . '*.png')); + $this->assertEquals($prevFileCount, $resetFileCount); + $this->assertFalse(setting()->get('app-icon')); + $this->assertFalse(setting()->get('app-icon-180')); + $this->assertFalse(setting()->get('app-icon-128')); + $this->assertFalse(setting()->get('app-icon-64')); + $this->assertFalse(setting()->get('app-icon-32')); + } }