From 593645acfe8521db97d7469c92546c8529703969 Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Mon, 13 Jan 2025 14:30:53 +0000 Subject: [PATCH 1/3] Themes: Added route to serve public theme files Allows files to be placed within a "public" folder within a theme directory which the contents of will served by BookStack for access. - Only "web safe" content-types are provided. - A static 1 day cache time it set on served files. For #3904 --- app/App/helpers.php | 4 +-- .../Controllers/BookExportController.php | 2 +- .../Controllers/ChapterExportController.php | 2 +- .../Controllers/PageExportController.php | 2 +- app/Http/DownloadResponseFactory.php | 19 +++++++++++- .../Middleware/PreventResponseCaching.php | 14 +++++++++ app/Theming/ThemeController.php | 31 +++++++++++++++++++ app/Theming/ThemeService.php | 9 ++++++ app/Uploads/FileStorage.php | 9 +++--- app/Uploads/ImageStorageDisk.php | 9 +++--- app/Util/FilePathNormalizer.php | 17 ++++++++++ routes/web.php | 8 ++++- 12 files changed, 111 insertions(+), 15 deletions(-) create mode 100644 app/Theming/ThemeController.php create mode 100644 app/Util/FilePathNormalizer.php diff --git a/app/App/helpers.php b/app/App/helpers.php index af6dbcfc397..941c267d6cd 100644 --- a/app/App/helpers.php +++ b/app/App/helpers.php @@ -1,6 +1,7 @@ queries->findVisibleBySlugOrFail($bookSlug); $zip = $builder->buildForBook($book); - return $this->download()->streamedFileDirectly($zip, $bookSlug . '.zip', filesize($zip), true); + return $this->download()->streamedFileDirectly($zip, $bookSlug . '.zip', true); } } diff --git a/app/Exports/Controllers/ChapterExportController.php b/app/Exports/Controllers/ChapterExportController.php index de2385bb11f..8490243439a 100644 --- a/app/Exports/Controllers/ChapterExportController.php +++ b/app/Exports/Controllers/ChapterExportController.php @@ -82,6 +82,6 @@ public function zip(string $bookSlug, string $chapterSlug, ZipExportBuilder $bui $chapter = $this->queries->findVisibleBySlugsOrFail($bookSlug, $chapterSlug); $zip = $builder->buildForChapter($chapter); - return $this->download()->streamedFileDirectly($zip, $chapterSlug . '.zip', filesize($zip), true); + return $this->download()->streamedFileDirectly($zip, $chapterSlug . '.zip', true); } } diff --git a/app/Exports/Controllers/PageExportController.php b/app/Exports/Controllers/PageExportController.php index d7145411eaa..145dce9dd0f 100644 --- a/app/Exports/Controllers/PageExportController.php +++ b/app/Exports/Controllers/PageExportController.php @@ -86,6 +86,6 @@ public function zip(string $bookSlug, string $pageSlug, ZipExportBuilder $builde $page = $this->queries->findVisibleBySlugsOrFail($bookSlug, $pageSlug); $zip = $builder->buildForPage($page); - return $this->download()->streamedFileDirectly($zip, $pageSlug . '.zip', filesize($zip), true); + return $this->download()->streamedFileDirectly($zip, $pageSlug . '.zip', true); } } diff --git a/app/Http/DownloadResponseFactory.php b/app/Http/DownloadResponseFactory.php index d06e2bac44d..01b3502d4b1 100644 --- a/app/Http/DownloadResponseFactory.php +++ b/app/Http/DownloadResponseFactory.php @@ -39,8 +39,9 @@ public function streamedDirectly($stream, string $fileName, int $fileSize): Stre * Create a response that downloads the given file via a stream. * Has the option to delete the provided file once the stream is closed. */ - public function streamedFileDirectly(string $filePath, string $fileName, int $fileSize, bool $deleteAfter = false): StreamedResponse + public function streamedFileDirectly(string $filePath, string $fileName, bool $deleteAfter = false): StreamedResponse { + $fileSize = filesize($filePath); $stream = fopen($filePath, 'r'); if ($deleteAfter) { @@ -79,6 +80,22 @@ public function streamedInline($stream, string $fileName, int $fileSize): Stream ); } + /** + * Create a response that provides the given file via a stream with detected content-type. + * Has the option to delete the provided file once the stream is closed. + */ + public function streamedFileInline(string $filePath, ?string $fileName = null): StreamedResponse + { + $fileSize = filesize($filePath); + $stream = fopen($filePath, 'r'); + + if ($fileName === null) { + $fileName = basename($filePath); + } + + return $this->streamedInline($stream, $fileName, $fileSize); + } + /** * Get the common headers to provide for a download response. */ diff --git a/app/Http/Middleware/PreventResponseCaching.php b/app/Http/Middleware/PreventResponseCaching.php index c763b5fc1bb..a40150444b5 100644 --- a/app/Http/Middleware/PreventResponseCaching.php +++ b/app/Http/Middleware/PreventResponseCaching.php @@ -7,6 +7,13 @@ class PreventResponseCaching { + /** + * Paths to ignore when preventing response caching. + */ + protected array $ignoredPathPrefixes = [ + 'theme/', + ]; + /** * Handle an incoming request. * @@ -20,6 +27,13 @@ public function handle($request, Closure $next) /** @var Response $response */ $response = $next($request); + $path = $request->path(); + foreach ($this->ignoredPathPrefixes as $ignoredPath) { + if (str_starts_with($path, $ignoredPath)) { + return $response; + } + } + $response->headers->set('Cache-Control', 'no-cache, no-store, private'); $response->headers->set('Expires', 'Sun, 12 Jul 2015 19:01:00 GMT'); diff --git a/app/Theming/ThemeController.php b/app/Theming/ThemeController.php new file mode 100644 index 00000000000..1eecc697428 --- /dev/null +++ b/app/Theming/ThemeController.php @@ -0,0 +1,31 @@ +download()->streamedFileInline($filePath); + $response->setMaxAge(86400); + + return $response; + } +} diff --git a/app/Theming/ThemeService.php b/app/Theming/ThemeService.php index 94e4712176b..639854d6ad1 100644 --- a/app/Theming/ThemeService.php +++ b/app/Theming/ThemeService.php @@ -15,6 +15,15 @@ class ThemeService */ protected array $listeners = []; + /** + * Get the currently configured theme. + * Returns an empty string if not configured. + */ + public function getTheme(): string + { + return config('view.theme') ?? ''; + } + /** * Listen to a given custom theme event, * setting up the action to be ran when the event occurs. diff --git a/app/Uploads/FileStorage.php b/app/Uploads/FileStorage.php index 6e4a210a162..70040725a3d 100644 --- a/app/Uploads/FileStorage.php +++ b/app/Uploads/FileStorage.php @@ -3,12 +3,12 @@ namespace BookStack\Uploads; use BookStack\Exceptions\FileUploadException; +use BookStack\Util\FilePathNormalizer; use Exception; use Illuminate\Contracts\Filesystem\Filesystem as Storage; use Illuminate\Filesystem\FilesystemManager; use Illuminate\Support\Facades\Log; use Illuminate\Support\Str; -use League\Flysystem\WhitespacePathNormalizer; use Symfony\Component\HttpFoundation\File\UploadedFile; class FileStorage @@ -120,12 +120,13 @@ protected function getStorageDiskName(): string */ protected function adjustPathForStorageDisk(string $path): string { - $path = (new WhitespacePathNormalizer())->normalizePath(str_replace('uploads/files/', '', $path)); + $trimmed = str_replace('uploads/files/', '', $path); + $normalized = FilePathNormalizer::normalize($trimmed); if ($this->getStorageDiskName() === 'local_secure_attachments') { - return $path; + return $normalized; } - return 'uploads/files/' . $path; + return 'uploads/files/' . $normalized; } } diff --git a/app/Uploads/ImageStorageDisk.php b/app/Uploads/ImageStorageDisk.php index 8df702e0d94..da8bacb3447 100644 --- a/app/Uploads/ImageStorageDisk.php +++ b/app/Uploads/ImageStorageDisk.php @@ -2,9 +2,9 @@ namespace BookStack\Uploads; +use BookStack\Util\FilePathNormalizer; use Illuminate\Contracts\Filesystem\Filesystem; use Illuminate\Filesystem\FilesystemAdapter; -use League\Flysystem\WhitespacePathNormalizer; use Symfony\Component\HttpFoundation\StreamedResponse; class ImageStorageDisk @@ -30,13 +30,14 @@ public function usingSecureImages(): bool */ protected function adjustPathForDisk(string $path): string { - $path = (new WhitespacePathNormalizer())->normalizePath(str_replace('uploads/images/', '', $path)); + $trimmed = str_replace('uploads/images/', '', $path); + $normalized = FilePathNormalizer::normalize($trimmed); if ($this->usingSecureImages()) { - return $path; + return $normalized; } - return 'uploads/images/' . $path; + return 'uploads/images/' . $normalized; } /** diff --git a/app/Util/FilePathNormalizer.php b/app/Util/FilePathNormalizer.php new file mode 100644 index 00000000000..d55fb74f879 --- /dev/null +++ b/app/Util/FilePathNormalizer.php @@ -0,0 +1,17 @@ +normalizePath($path); + } +} diff --git a/routes/web.php b/routes/web.php index 318147ef518..5bb9622e737 100644 --- a/routes/web.php +++ b/routes/web.php @@ -13,12 +13,14 @@ use BookStack\References\ReferenceController; use BookStack\Search\SearchController; use BookStack\Settings as SettingControllers; +use BookStack\Theming\ThemeController; use BookStack\Uploads\Controllers as UploadControllers; use BookStack\Users\Controllers as UserControllers; use Illuminate\Session\Middleware\StartSession; use Illuminate\Support\Facades\Route; use Illuminate\View\Middleware\ShareErrorsFromSession; +// Status & Meta routes Route::get('/status', [SettingControllers\StatusController::class, 'show']); Route::get('/robots.txt', [MetaController::class, 'robots']); Route::get('/favicon.ico', [MetaController::class, 'favicon']); @@ -360,8 +362,12 @@ Route::get('/password/reset/{token}', [AccessControllers\ResetPasswordController::class, 'showResetForm']); Route::post('/password/reset', [AccessControllers\ResetPasswordController::class, 'reset'])->middleware('throttle:public'); -// Metadata routes +// Help & Info routes Route::view('/help/tinymce', 'help.tinymce'); Route::view('/help/wysiwyg', 'help.wysiwyg'); +// Theme Routes +Route::get('/theme/{theme}/{path}', [ThemeController::class, 'publicFile']) + ->where('path', '.*$'); + Route::fallback([MetaController::class, 'notFound'])->name('fallback'); From 481580be172a4813ee98ad1b945d12d731e71cdb Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Mon, 13 Jan 2025 16:51:07 +0000 Subject: [PATCH 2/3] Themes: Added testing and better mime sniffing for public serving Existing mime sniffer wasn't great at distinguishing between plaintext file types, so added a custom extension based mapping for common web formats that may be expected to be used with this. --- app/Http/DownloadResponseFactory.php | 2 +- app/Http/RangeSupportedStream.php | 4 ++-- app/Util/WebSafeMimeSniffer.php | 16 ++++++++++++++-- tests/ThemeTest.php | 28 ++++++++++++++++++++++++++++ 4 files changed, 45 insertions(+), 5 deletions(-) diff --git a/app/Http/DownloadResponseFactory.php b/app/Http/DownloadResponseFactory.php index 01b3502d4b1..8384484ad62 100644 --- a/app/Http/DownloadResponseFactory.php +++ b/app/Http/DownloadResponseFactory.php @@ -70,7 +70,7 @@ public function streamedFileDirectly(string $filePath, string $fileName, bool $d public function streamedInline($stream, string $fileName, int $fileSize): StreamedResponse { $rangeStream = new RangeSupportedStream($stream, $fileSize, $this->request); - $mime = $rangeStream->sniffMime(); + $mime = $rangeStream->sniffMime(pathinfo($fileName, PATHINFO_EXTENSION)); $headers = array_merge($this->getHeaders($fileName, $fileSize, $mime), $rangeStream->getResponseHeaders()); return response()->stream( diff --git a/app/Http/RangeSupportedStream.php b/app/Http/RangeSupportedStream.php index fce1e9acce3..c4b00778939 100644 --- a/app/Http/RangeSupportedStream.php +++ b/app/Http/RangeSupportedStream.php @@ -32,12 +32,12 @@ public function __construct( /** * Sniff a mime type from the stream. */ - public function sniffMime(): string + public function sniffMime(string $extension = ''): string { $offset = min(2000, $this->fileSize); $this->sniffContent = fread($this->stream, $offset); - return (new WebSafeMimeSniffer())->sniff($this->sniffContent); + return (new WebSafeMimeSniffer())->sniff($this->sniffContent, $extension); } /** diff --git a/app/Util/WebSafeMimeSniffer.php b/app/Util/WebSafeMimeSniffer.php index b182d8ac19b..4a82de85d25 100644 --- a/app/Util/WebSafeMimeSniffer.php +++ b/app/Util/WebSafeMimeSniffer.php @@ -13,7 +13,7 @@ class WebSafeMimeSniffer /** * @var string[] */ - protected $safeMimes = [ + protected array $safeMimes = [ 'application/json', 'application/octet-stream', 'application/pdf', @@ -48,16 +48,28 @@ class WebSafeMimeSniffer 'video/av1', ]; + protected array $textTypesByExtension = [ + 'css' => 'text/css', + 'js' => 'text/javascript', + 'json' => 'application/json', + 'csv' => 'text/csv', + ]; + /** * Sniff the mime-type from the given file content while running the result * through an allow-list to ensure a web-safe result. * Takes the content as a reference since the value may be quite large. + * Accepts an optional $extension which can be used for further guessing. */ - public function sniff(string &$content): string + public function sniff(string &$content, string $extension = ''): string { $fInfo = new finfo(FILEINFO_MIME_TYPE); $mime = $fInfo->buffer($content) ?: 'application/octet-stream'; + if ($mime === 'text/plain' && $extension) { + $mime = $this->textTypesByExtension[$extension] ?? 'text/plain'; + } + if (in_array($mime, $this->safeMimes)) { return $mime; } diff --git a/tests/ThemeTest.php b/tests/ThemeTest.php index 837b94eee72..b3c85d8f724 100644 --- a/tests/ThemeTest.php +++ b/tests/ThemeTest.php @@ -464,6 +464,34 @@ public function test_custom_settings_category_page_can_be_added_via_view_file() }); } + public function test_public_folder_contents_accessible_via_route() + { + $this->usingThemeFolder(function (string $themeFolderName) { + $publicDir = theme_path('public'); + mkdir($publicDir, 0777, true); + + $text = 'some-text ' . md5(random_bytes(5)); + $css = "body { background-color: tomato !important; }"; + file_put_contents("{$publicDir}/file.txt", $text); + file_put_contents("{$publicDir}/file.css", $css); + copy($this->files->testFilePath('test-image.png'), "{$publicDir}/image.png"); + + $resp = $this->asAdmin()->get("/theme/{$themeFolderName}/file.txt"); + $resp->assertStreamedContent($text); + $resp->assertHeader('Content-Type', 'text/plain; charset=UTF-8'); + $resp->assertHeader('Cache-Control', 'max-age=86400, private'); + + $resp = $this->asAdmin()->get("/theme/{$themeFolderName}/image.png"); + $resp->assertHeader('Content-Type', 'image/png'); + $resp->assertHeader('Cache-Control', 'max-age=86400, private'); + + $resp = $this->asAdmin()->get("/theme/{$themeFolderName}/file.css"); + $resp->assertStreamedContent($css); + $resp->assertHeader('Content-Type', 'text/css; charset=UTF-8'); + $resp->assertHeader('Cache-Control', 'max-age=86400, private'); + }); + } + protected function usingThemeFolder(callable $callback) { // Create a folder and configure a theme From 25c4f4b02ba06f66f5239de48ae005f895146f8d Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Tue, 14 Jan 2025 14:53:10 +0000 Subject: [PATCH 3/3] Themes: Documented public file serving --- dev/docs/logical-theme-system.md | 4 +++- dev/docs/visual-theme-system.md | 25 ++++++++++++++++++++++++- 2 files changed, 27 insertions(+), 2 deletions(-) diff --git a/dev/docs/logical-theme-system.md b/dev/docs/logical-theme-system.md index 139055b3db2..84bd26a5387 100644 --- a/dev/docs/logical-theme-system.md +++ b/dev/docs/logical-theme-system.md @@ -2,7 +2,9 @@ BookStack allows logical customization via the theme system which enables you to add, or extend, functionality within the PHP side of the system without needing to alter the core application files. -WARNING: This system is currently in alpha so may incur changes. Once we've gathered some feedback on usage we'll look to removing this warning. This system will be considered semi-stable in the future. The `Theme::` system will be kept maintained but specific customizations or deeper app/framework usage using this system will not be supported nor considered in any way stable. Customizations using this system should be checked after updates. +This is part of the theme system alongside the [visual theme system](./visual-theme-system.md). + +**Note:** This system is considered semi-stable. The `Theme::` system is kept maintained but specific customizations or deeper app/framework usage using this system will not be supported nor considered in any way stable. Customizations using this system should be checked after updates. ## Getting Started diff --git a/dev/docs/visual-theme-system.md b/dev/docs/visual-theme-system.md index 6e7105a9ed0..8a76ddb00e0 100644 --- a/dev/docs/visual-theme-system.md +++ b/dev/docs/visual-theme-system.md @@ -2,7 +2,9 @@ BookStack allows visual customization via the theme system which enables you to extensively customize views, translation text & icons. -This theme system itself is maintained and supported but usages of this system, including the files you are able to override, are not considered stable and may change upon any update. You should test any customizations made after updates. +This is part of the theme system alongside the [logical theme system](./logical-theme-system.md). + +**Note:** This theme system itself is maintained and supported but usages of this system, including the files you are able to override, are not considered stable and may change upon any update. You should test any customizations made after updates. ## Getting Started @@ -32,3 +34,24 @@ return [ 'search' => 'find', ]; ``` + +## Publicly Accessible Files + +As part of deeper customizations you may want to expose additional files +(images, scripts, styles, etc...) as part of your theme, in a way so they're +accessible in public web-space to browsers. + +To achieve this, you can put files within a `themes//public` folder. +BookStack will serve any files within this folder from a `/theme/` base path. + +As an example, if I had an image located at `themes/custom/public/cat.jpg`, I could access +that image via the URL path `/theme/custom/cat.jpg`. That's assuming that `custom` is the currently +configured application theme. + +There are some considerations to these publicly served files: + +- Only a predetermined range "web safe" content-types are currently served. + - This limits running into potential insecure scenarios in serving problematic file types. +- A static 1-day cache time it set on files served from this folder. + - You can use alternative cache-breaking techniques (change of query string) upon changes if needed. + - If required, you could likely override caching at the webserver level.