Merge pull request #4032 from BookStackApp/favicon

Generate favicon.ico file
This commit is contained in:
Dan Brown 2023-02-09 21:37:38 +00:00 committed by GitHub
commit 646f8f60c0
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
8 changed files with 159 additions and 5 deletions

1
.gitignore vendored
View File

@ -11,6 +11,7 @@ yarn-error.log
/public/js /public/js
/public/bower /public/bower
/public/build/ /public/build/
/public/favicon.ico
/storage/images /storage/images
_ide_helper.php _ide_helper.php
/storage/debugbar /storage/debugbar

View File

@ -10,6 +10,7 @@ use BookStack\Entities\Queries\TopFavourites;
use BookStack\Entities\Repos\BookRepo; use BookStack\Entities\Repos\BookRepo;
use BookStack\Entities\Repos\BookshelfRepo; use BookStack\Entities\Repos\BookshelfRepo;
use BookStack\Entities\Tools\PageContent; use BookStack\Entities\Tools\PageContent;
use BookStack\Uploads\FaviconHandler;
use BookStack\Util\SimpleListOptions; use BookStack\Util\SimpleListOptions;
use Illuminate\Http\Request; use Illuminate\Http\Request;
@ -127,4 +128,15 @@ class HomeController extends Controller
{ {
return response()->view('errors.404', [], 404); 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());
}
} }

View File

@ -2,16 +2,16 @@
namespace BookStack\Settings; namespace BookStack\Settings;
use BookStack\Uploads\FaviconHandler;
use BookStack\Uploads\ImageRepo; use BookStack\Uploads\ImageRepo;
use Illuminate\Http\Request; use Illuminate\Http\Request;
class AppSettingsStore class AppSettingsStore
{ {
protected ImageRepo $imageRepo; public function __construct(
protected ImageRepo $imageRepo,
public function __construct(ImageRepo $imageRepo) protected FaviconHandler $faviconHandler,
{ ) {
$this->imageRepo = $imageRepo;
} }
public function storeFromUpdateRequest(Request $request, string $category) public function storeFromUpdateRequest(Request $request, string $category)
@ -39,6 +39,8 @@ class AppSettingsStore
$icon = $this->imageRepo->saveNew($iconFile, 'system', 0, $size, $size); $icon = $this->imageRepo->saveNew($iconFile, 'system', 0, $size, $size);
setting()->put('app-icon-' . $size, $icon->url); setting()->put('app-icon-' . $size, $icon->url);
} }
$this->faviconHandler->saveForUploadedImage($iconFile);
} }
// Clear icon image if requested // Clear icon image if requested
@ -49,6 +51,8 @@ class AppSettingsStore
$this->destroyExistingSettingImage('app-icon-' . $size); $this->destroyExistingSettingImage('app-icon-' . $size);
setting()->remove('app-icon-' . $size); setting()->remove('app-icon-' . $size);
} }
$this->faviconHandler->restoreOriginal();
} }
} }

View File

@ -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;
}
}

View File

Before

Width:  |  Height:  |  Size: 11 KiB

After

Width:  |  Height:  |  Size: 11 KiB

View File

@ -40,6 +40,7 @@ use Illuminate\View\Middleware\ShareErrorsFromSession;
Route::get('/status', [StatusController::class, 'show']); Route::get('/status', [StatusController::class, 'show']);
Route::get('/robots.txt', [HomeController::class, 'robots']); Route::get('/robots.txt', [HomeController::class, 'robots']);
Route::get('/favicon.ico', [HomeController::class, 'favicon']);
// Authenticated routes... // Authenticated routes...
Route::middleware('auth')->group(function () { Route::middleware('auth')->group(function () {

View File

@ -155,6 +155,18 @@ class PublicActionTest extends TestCase
$this->get('/robots.txt')->assertSee("User-agent: *\nDisallow: /"); $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() public function test_public_view_then_login_redirects_to_previous_content()
{ {
$this->setSettings(['app-public' => 'true']); $this->setSettings(['app-public' => 'true']);

View File

@ -52,6 +52,10 @@ class SettingsTest extends TestCase
$this->assertFalse(setting()->get('app-icon-128')); $this->assertFalse(setting()->get('app-icon-128'));
$this->assertFalse(setting()->get('app-icon-64')); $this->assertFalse(setting()->get('app-icon-64'));
$this->assertFalse(setting()->get('app-icon-32')); $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')); $prevFileCount = count(glob(dirname($expectedPath) . DIRECTORY_SEPARATOR . '*.png'));
@ -71,6 +75,11 @@ class SettingsTest extends TestCase
$resp = $this->get('/'); $resp = $this->get('/');
$this->withHtml($resp)->assertElementCount('link[sizes][href*="my-app-icon"]', 6); $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 = $this->post('/settings/customization', ['app_icon_reset' => 'true']);
$reset->assertRedirect('/settings/customization'); $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-128'));
$this->assertFalse(setting()->get('app-icon-64')); $this->assertFalse(setting()->get('app-icon-64'));
$this->assertFalse(setting()->get('app-icon-32')); $this->assertFalse(setting()->get('app-icon-32'));
$this->assertEquals(
file_get_contents(public_path('icon.ico')),
file_get_contents(public_path('favicon.ico')),
);
} }
} }