Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ yarn-error.log
/public/js
/public/bower
/public/build/
/public/favicon.ico
/storage/images
_ide_helper.php
/storage/debugbar
Expand Down
12 changes: 12 additions & 0 deletions app/Http/Controllers/HomeController.php
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
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;

Expand Down Expand Up @@ -127,4 +128,15 @@ public function notFound()
{
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());
}
}
14 changes: 9 additions & 5 deletions app/Settings/AppSettingsStore.php
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -39,6 +39,8 @@ protected function updateAppIcon(Request $request): void
$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
Expand All @@ -49,6 +51,8 @@ protected function updateAppIcon(Request $request): void
$this->destroyExistingSettingImage('app-icon-' . $size);
setting()->remove('app-icon-' . $size);
}

$this->faviconHandler->restoreOriginal();
}
}

Expand Down
110 changes: 110 additions & 0 deletions app/Uploads/FaviconHandler.php
Original file line number Diff line number Diff line change
@@ -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;
}
}
File renamed without changes.
1 change: 1 addition & 0 deletions routes/web.php
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@

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 () {
Expand Down
12 changes: 12 additions & 0 deletions tests/PublicActionTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -155,6 +155,18 @@ public function test_robots_effected_by_setting()
$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']);
Expand Down
14 changes: 14 additions & 0 deletions tests/Settings/SettingsTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,10 @@ public function test_updating_and_removing_app_icon()
$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'));

Expand All @@ -71,6 +75,11 @@ public function test_updating_and_removing_app_icon()
$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');

Expand All @@ -81,5 +90,10 @@ public function test_updating_and_removing_app_icon()
$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')),
);
}
}