/public/js
/public/bower
/public/build/
+/public/favicon.ico
/storage/images
_ide_helper.php
/storage/debugbar
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;
{
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());
+ }
}
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)
$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
$this->destroyExistingSettingImage('app-icon-' . $size);
setting()->remove('app-icon-' . $size);
}
+
+ $this->faviconHandler->restoreOriginal();
}
}
--- /dev/null
+<?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;
+ }
+}
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 () {
$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']);
$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'));
$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');
$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')),
+ );
}
}