]> BookStack Code Mirror - bookstack/commitdiff
Themes: Added route to serve public theme files
authorDan Brown <redacted>
Mon, 13 Jan 2025 14:30:53 +0000 (14:30 +0000)
committerDan Brown <redacted>
Mon, 13 Jan 2025 14:34:44 +0000 (14:34 +0000)
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

12 files changed:
app/App/helpers.php
app/Exports/Controllers/BookExportController.php
app/Exports/Controllers/ChapterExportController.php
app/Exports/Controllers/PageExportController.php
app/Http/DownloadResponseFactory.php
app/Http/Middleware/PreventResponseCaching.php
app/Theming/ThemeController.php [new file with mode: 0644]
app/Theming/ThemeService.php
app/Uploads/FileStorage.php
app/Uploads/ImageStorageDisk.php
app/Util/FilePathNormalizer.php [new file with mode: 0644]
routes/web.php

index af6dbcfc39787da06e9d9387685733e932da17b6..941c267d6cd1950c183f65303915996a35917c89 100644 (file)
@@ -1,6 +1,7 @@
 <?php
 
 use BookStack\App\Model;
+use BookStack\Facades\Theme;
 use BookStack\Permissions\PermissionApplicator;
 use BookStack\Settings\SettingService;
 use BookStack\Users\Models\User;
@@ -88,8 +89,7 @@ function setting(string $key = null, $default = null)
  */
 function theme_path(string $path = ''): ?string
 {
-    $theme = config('view.theme');
-
+    $theme = Theme::getTheme();
     if (!$theme) {
         return null;
     }
index b6b1006bd61371b633cfb1a9c7f9f80a710345f2..67247598c318b8e8de6e681994b5a4c9190c8c71 100644 (file)
@@ -76,6 +76,6 @@ class BookExportController extends Controller
         $book = $this->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);
     }
 }
index de2385bb11fff1ba0fa9b0d30c3353702ebef541..8490243439a05b9ad18aaec2325d67af368118f6 100644 (file)
@@ -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);
     }
 }
index d7145411eaad52c3c4ef8e5a5c5381864bb7e690..145dce9dd0fc230cc44eee0fc24e01a3c12895c1 100644 (file)
@@ -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);
     }
 }
index d06e2bac44d99ef37640149bb30cf63fc1faa42a..01b3502d4b1ad47a8f856aca82b92f266cb6ae9a 100644 (file)
@@ -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.
      */
index c763b5fc1bbe4e43d61d62217bc2c682ffdce1fb..a40150444b5fb5d2527ff593aa82ccffece1b205 100644 (file)
@@ -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 (file)
index 0000000..1eecc69
--- /dev/null
@@ -0,0 +1,31 @@
+<?php
+
+namespace BookStack\Theming;
+
+use BookStack\Facades\Theme;
+use BookStack\Http\Controller;
+use BookStack\Util\FilePathNormalizer;
+
+class ThemeController extends Controller
+{
+    /**
+     * Serve a public file from the configured theme.
+     */
+    public function publicFile(string $theme, string $path)
+    {
+        $cleanPath = FilePathNormalizer::normalize($path);
+        if ($theme !== Theme::getTheme() || !$cleanPath) {
+            abort(404);
+        }
+
+        $filePath = theme_path("public/{$cleanPath}");
+        if (!file_exists($filePath)) {
+            abort(404);
+        }
+
+        $response = $this->download()->streamedFileInline($filePath);
+        $response->setMaxAge(86400);
+
+        return $response;
+    }
+}
index 94e4712176b2463acd942ff6336352b948744f55..639854d6ad10e5879358640d5b52a1167b1dce91 100644 (file)
@@ -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.
index 6e4a210a162eb325d79d4138dc2227e39dc84a6b..70040725a3df118027225d494d445815b175bd88 100644 (file)
@@ -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;
     }
 }
index 8df702e0d94183b23248a441935e6723d199c4d1..da8bacb3447d3ee82df5ab7d0c40ffce9adacb60 100644 (file)
@@ -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 (file)
index 0000000..d55fb74
--- /dev/null
@@ -0,0 +1,17 @@
+<?php
+
+namespace BookStack\Util;
+
+use League\Flysystem\WhitespacePathNormalizer;
+
+/**
+ * Utility to normalize (potentially) user provided file paths
+ * to avoid things like directory traversal.
+ */
+class FilePathNormalizer
+{
+    public static function normalize(string $path): string
+    {
+        return (new WhitespacePathNormalizer())->normalizePath($path);
+    }
+}
index 318147ef518cbfe088bd202a2071d946364d769d..5bb9622e7372b96171029eb80d08854bcda3289e 100644 (file)
@@ -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');