diff --git a/.gitignore b/.gitignore index 0a858681c..90b80e7b8 100644 --- a/.gitignore +++ b/.gitignore @@ -11,6 +11,7 @@ yarn-error.log /public/js /public/bower /public/build/ +/public/favicon.ico /storage/images _ide_helper.php /storage/debugbar diff --git a/app/Http/Controllers/HomeController.php b/app/Http/Controllers/HomeController.php index c3c8d1066..a82710523 100644 --- a/app/Http/Controllers/HomeController.php +++ b/app/Http/Controllers/HomeController.php @@ -10,6 +10,7 @@ use BookStack\Entities\Queries\TopFavourites; use BookStack\Entities\Repos\BookRepo; use BookStack\Entities\Repos\BookshelfRepo; use BookStack\Entities\Tools\PageContent; +use BookStack\Uploads\FaviconHandler; use BookStack\Util\SimpleListOptions; use Illuminate\Http\Request; @@ -127,4 +128,15 @@ class HomeController extends Controller { return response()->view('errors.404', [], 404); } + + /** + * Serve the application favicon. + * Ensures a 'favicon.ico' file exists at the web root location (if writable) to be served + * directly by the webserver in the future. + */ + public function favicon(FaviconHandler $favicons) + { + $exists = $favicons->restoreOriginalIfNotExists(); + return response()->file($exists ? $favicons->getPath() : $favicons->getOriginalPath()); + } } diff --git a/app/Settings/AppSettingsStore.php b/app/Settings/AppSettingsStore.php index 8d7b73c1c..e6fc466ba 100644 --- a/app/Settings/AppSettingsStore.php +++ b/app/Settings/AppSettingsStore.php @@ -2,16 +2,16 @@ namespace BookStack\Settings; +use BookStack\Uploads\FaviconHandler; use BookStack\Uploads\ImageRepo; use Illuminate\Http\Request; class AppSettingsStore { - protected ImageRepo $imageRepo; - - public function __construct(ImageRepo $imageRepo) - { - $this->imageRepo = $imageRepo; + public function __construct( + protected ImageRepo $imageRepo, + protected FaviconHandler $faviconHandler, + ) { } public function storeFromUpdateRequest(Request $request, string $category) @@ -39,6 +39,8 @@ class AppSettingsStore $icon = $this->imageRepo->saveNew($iconFile, 'system', 0, $size, $size); setting()->put('app-icon-' . $size, $icon->url); } + + $this->faviconHandler->saveForUploadedImage($iconFile); } // Clear icon image if requested @@ -49,6 +51,8 @@ class AppSettingsStore $this->destroyExistingSettingImage('app-icon-' . $size); setting()->remove('app-icon-' . $size); } + + $this->faviconHandler->restoreOriginal(); } } diff --git a/app/Uploads/FaviconHandler.php b/app/Uploads/FaviconHandler.php new file mode 100644 index 000000000..c637356e0 --- /dev/null +++ b/app/Uploads/FaviconHandler.php @@ -0,0 +1,110 @@ +<?php + +namespace BookStack\Uploads; + +use Illuminate\Http\UploadedFile; +use Intervention\Image\ImageManager; + +class FaviconHandler +{ + protected string $path; + + public function __construct( + protected ImageManager $imageTool + ) { + $this->path = public_path('favicon.ico'); + } + + /** + * Save the given UploadedFile instance as the application favicon. + */ + public function saveForUploadedImage(UploadedFile $file): void + { + if (!is_writeable($this->path)) { + return; + } + + $imageData = file_get_contents($file->getRealPath()); + $image = $this->imageTool->make($imageData); + $image->resize(32, 32); + $bmpData = $image->encode('png'); + $icoData = $this->pngToIco($bmpData, 32, 32); + + file_put_contents($this->path, $icoData); + } + + /** + * Restore the original favicon image. + * Returned boolean indicates if the copy occurred. + */ + public function restoreOriginal(): bool + { + $permissionItem = file_exists($this->path) ? $this->path : dirname($this->path); + if (!is_writeable($permissionItem)) { + return false; + } + + return copy($this->getOriginalPath(), $this->path); + } + + /** + * Restore the original favicon image if no favicon image is already in use. + * Returns a boolean to indicate if the file exists. + */ + public function restoreOriginalIfNotExists(): bool + { + if (file_exists($this->path)) { + return true; + } + + return $this->restoreOriginal(); + } + + /** + * Get the path to the favicon file. + */ + public function getPath(): string + { + return $this->path; + } + + /** + * Get the path of the original favicon copy. + */ + public function getOriginalPath(): string + { + return public_path('icon.ico'); + } + + /** + * Convert PNG image data to ICO file format. + * Built following the file format info from Wikipedia: + * https://en.wikipedia.org/wiki/ICO_(file_format) + */ + protected function pngToIco(string $bmpData, int $width, int $height): string + { + // ICO header + $header = pack('v', 0x00); // Reserved. Must always be 0 + $header .= pack('v', 0x01); // Specifies ico image + $header .= pack('v', 0x01); // Specifies number of images + + // ICO Image Directory + $entry = hex2bin(dechex($width)); // Image width + $entry .= hex2bin(dechex($height)); // Image height + $entry .= "\0"; // Color palette, typically 0 + $entry .= "\0"; // Reserved + + // Color planes, Appears to remain 1 for bmp image data + $entry .= pack('v', 0x01); + // Bits per pixel, can range from 1 to 32. From testing conversion + // via intervention from png typically provides this as 24. + $entry .= pack('v', 0x00); + // Size of the image data in bytes + $entry .= pack('V', strlen($bmpData)); + // Offset of the bmp data from file start + $entry .= pack('V', strlen($header) + strlen($entry) + 4); + + // Join & return the combined parts of the ICO image data + return $header . $entry . $bmpData; + } +} diff --git a/public/favicon.ico b/public/icon.ico similarity index 100% rename from public/favicon.ico rename to public/icon.ico diff --git a/routes/web.php b/routes/web.php index 95b4ae535..937dc0c6c 100644 --- a/routes/web.php +++ b/routes/web.php @@ -40,6 +40,7 @@ use Illuminate\View\Middleware\ShareErrorsFromSession; Route::get('/status', [StatusController::class, 'show']); Route::get('/robots.txt', [HomeController::class, 'robots']); +Route::get('/favicon.ico', [HomeController::class, 'favicon']); // Authenticated routes... Route::middleware('auth')->group(function () { diff --git a/tests/PublicActionTest.php b/tests/PublicActionTest.php index afc7fcef3..e21afdf33 100644 --- a/tests/PublicActionTest.php +++ b/tests/PublicActionTest.php @@ -155,6 +155,18 @@ class PublicActionTest extends TestCase $this->get('/robots.txt')->assertSee("User-agent: *\nDisallow: /"); } + public function test_default_favicon_file_created_upon_access() + { + $faviconPath = public_path('favicon.ico'); + if (file_exists($faviconPath)) { + unlink($faviconPath); + } + + $this->assertFileDoesNotExist($faviconPath); + $this->get('/favicon.ico'); + $this->assertFileExists($faviconPath); + } + public function test_public_view_then_login_redirects_to_previous_content() { $this->setSettings(['app-public' => 'true']); diff --git a/tests/Settings/SettingsTest.php b/tests/Settings/SettingsTest.php index 30bb50f7c..fb952585a 100644 --- a/tests/Settings/SettingsTest.php +++ b/tests/Settings/SettingsTest.php @@ -52,6 +52,10 @@ class SettingsTest extends TestCase $this->assertFalse(setting()->get('app-icon-128')); $this->assertFalse(setting()->get('app-icon-64')); $this->assertFalse(setting()->get('app-icon-32')); + $this->assertEquals( + file_get_contents(public_path('icon.ico')), + file_get_contents(public_path('favicon.ico')), + ); $prevFileCount = count(glob(dirname($expectedPath) . DIRECTORY_SEPARATOR . '*.png')); @@ -71,6 +75,11 @@ class SettingsTest extends TestCase $resp = $this->get('/'); $this->withHtml($resp)->assertElementCount('link[sizes][href*="my-app-icon"]', 6); + $this->assertNotEquals( + file_get_contents(public_path('icon.ico')), + file_get_contents(public_path('favicon.ico')), + ); + $reset = $this->post('/settings/customization', ['app_icon_reset' => 'true']); $reset->assertRedirect('/settings/customization'); @@ -81,5 +90,10 @@ class SettingsTest extends TestCase $this->assertFalse(setting()->get('app-icon-128')); $this->assertFalse(setting()->get('app-icon-64')); $this->assertFalse(setting()->get('app-icon-32')); + + $this->assertEquals( + file_get_contents(public_path('icon.ico')), + file_get_contents(public_path('favicon.ico')), + ); } }