diff --git a/app/App/helpers.php b/app/App/helpers.php index af6dbcfc3..941c267d6 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 de2385bb1..849024343 100644 --- a/app/Exports/Controllers/ChapterExportController.php +++ b/app/Exports/Controllers/ChapterExportController.php @@ -82,6 +82,6 @@ class ChapterExportController extends Controller $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 d7145411e..145dce9dd 100644 --- a/app/Exports/Controllers/PageExportController.php +++ b/app/Exports/Controllers/PageExportController.php @@ -86,6 +86,6 @@ class PageExportController extends Controller $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 d06e2bac4..01b3502d4 100644 --- a/app/Http/DownloadResponseFactory.php +++ b/app/Http/DownloadResponseFactory.php @@ -39,8 +39,9 @@ class DownloadResponseFactory * 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 @@ class DownloadResponseFactory ); } + /** + * 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 c763b5fc1..a40150444 100644 --- a/app/Http/Middleware/PreventResponseCaching.php +++ b/app/Http/Middleware/PreventResponseCaching.php @@ -7,6 +7,13 @@ use Symfony\Component\HttpFoundation\Response; class PreventResponseCaching { + /** + * Paths to ignore when preventing response caching. + */ + protected array $ignoredPathPrefixes = [ + 'theme/', + ]; + /** * Handle an incoming request. * @@ -20,6 +27,13 @@ class PreventResponseCaching /** @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 000000000..1eecc6974 --- /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 94e471217..639854d6a 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 6e4a210a1..70040725a 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 @@ class FileStorage */ 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 8df702e0d..da8bacb34 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 @@ class ImageStorageDisk */ 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 000000000..d55fb74f8 --- /dev/null +++ b/app/Util/FilePathNormalizer.php @@ -0,0 +1,17 @@ +normalizePath($path); + } +} diff --git a/routes/web.php b/routes/web.php index 318147ef5..5bb9622e7 100644 --- a/routes/web.php +++ b/routes/web.php @@ -13,12 +13,14 @@ use BookStack\Permissions\PermissionsController; 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::post('/password/email', [AccessControllers\ForgotPasswordController::clas 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');