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..8384484ad62 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) { @@ -69,7 +70,7 @@ public function streamedFileDirectly(string $filePath, string $fileName, int $fi 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( @@ -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/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/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/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/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. 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'); 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