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
This commit is contained in:
Dan Brown 2025-01-13 14:30:53 +00:00
parent b9751807e7
commit 593645acfe
No known key found for this signature in database
GPG Key ID: 46D9F943C24A2EF9
12 changed files with 111 additions and 15 deletions

View File

@ -1,6 +1,7 @@
<?php <?php
use BookStack\App\Model; use BookStack\App\Model;
use BookStack\Facades\Theme;
use BookStack\Permissions\PermissionApplicator; use BookStack\Permissions\PermissionApplicator;
use BookStack\Settings\SettingService; use BookStack\Settings\SettingService;
use BookStack\Users\Models\User; use BookStack\Users\Models\User;
@ -88,8 +89,7 @@ function setting(string $key = null, $default = null)
*/ */
function theme_path(string $path = ''): ?string function theme_path(string $path = ''): ?string
{ {
$theme = config('view.theme'); $theme = Theme::getTheme();
if (!$theme) { if (!$theme) {
return null; return null;
} }

View File

@ -76,6 +76,6 @@ class BookExportController extends Controller
$book = $this->queries->findVisibleBySlugOrFail($bookSlug); $book = $this->queries->findVisibleBySlugOrFail($bookSlug);
$zip = $builder->buildForBook($book); $zip = $builder->buildForBook($book);
return $this->download()->streamedFileDirectly($zip, $bookSlug . '.zip', filesize($zip), true); return $this->download()->streamedFileDirectly($zip, $bookSlug . '.zip', true);
} }
} }

View File

@ -82,6 +82,6 @@ class ChapterExportController extends Controller
$chapter = $this->queries->findVisibleBySlugsOrFail($bookSlug, $chapterSlug); $chapter = $this->queries->findVisibleBySlugsOrFail($bookSlug, $chapterSlug);
$zip = $builder->buildForChapter($chapter); $zip = $builder->buildForChapter($chapter);
return $this->download()->streamedFileDirectly($zip, $chapterSlug . '.zip', filesize($zip), true); return $this->download()->streamedFileDirectly($zip, $chapterSlug . '.zip', true);
} }
} }

View File

@ -86,6 +86,6 @@ class PageExportController extends Controller
$page = $this->queries->findVisibleBySlugsOrFail($bookSlug, $pageSlug); $page = $this->queries->findVisibleBySlugsOrFail($bookSlug, $pageSlug);
$zip = $builder->buildForPage($page); $zip = $builder->buildForPage($page);
return $this->download()->streamedFileDirectly($zip, $pageSlug . '.zip', filesize($zip), true); return $this->download()->streamedFileDirectly($zip, $pageSlug . '.zip', true);
} }
} }

View File

@ -39,8 +39,9 @@ class DownloadResponseFactory
* Create a response that downloads the given file via a stream. * Create a response that downloads the given file via a stream.
* Has the option to delete the provided file once the stream is closed. * 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'); $stream = fopen($filePath, 'r');
if ($deleteAfter) { 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. * Get the common headers to provide for a download response.
*/ */

View File

@ -7,6 +7,13 @@ use Symfony\Component\HttpFoundation\Response;
class PreventResponseCaching class PreventResponseCaching
{ {
/**
* Paths to ignore when preventing response caching.
*/
protected array $ignoredPathPrefixes = [
'theme/',
];
/** /**
* Handle an incoming request. * Handle an incoming request.
* *
@ -20,6 +27,13 @@ class PreventResponseCaching
/** @var Response $response */ /** @var Response $response */
$response = $next($request); $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('Cache-Control', 'no-cache, no-store, private');
$response->headers->set('Expires', 'Sun, 12 Jul 2015 19:01:00 GMT'); $response->headers->set('Expires', 'Sun, 12 Jul 2015 19:01:00 GMT');

View File

@ -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;
}
}

View File

@ -15,6 +15,15 @@ class ThemeService
*/ */
protected array $listeners = []; 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, * Listen to a given custom theme event,
* setting up the action to be ran when the event occurs. * setting up the action to be ran when the event occurs.

View File

@ -3,12 +3,12 @@
namespace BookStack\Uploads; namespace BookStack\Uploads;
use BookStack\Exceptions\FileUploadException; use BookStack\Exceptions\FileUploadException;
use BookStack\Util\FilePathNormalizer;
use Exception; use Exception;
use Illuminate\Contracts\Filesystem\Filesystem as Storage; use Illuminate\Contracts\Filesystem\Filesystem as Storage;
use Illuminate\Filesystem\FilesystemManager; use Illuminate\Filesystem\FilesystemManager;
use Illuminate\Support\Facades\Log; use Illuminate\Support\Facades\Log;
use Illuminate\Support\Str; use Illuminate\Support\Str;
use League\Flysystem\WhitespacePathNormalizer;
use Symfony\Component\HttpFoundation\File\UploadedFile; use Symfony\Component\HttpFoundation\File\UploadedFile;
class FileStorage class FileStorage
@ -120,12 +120,13 @@ class FileStorage
*/ */
protected function adjustPathForStorageDisk(string $path): 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') { if ($this->getStorageDiskName() === 'local_secure_attachments') {
return $path; return $normalized;
} }
return 'uploads/files/' . $path; return 'uploads/files/' . $normalized;
} }
} }

View File

@ -2,9 +2,9 @@
namespace BookStack\Uploads; namespace BookStack\Uploads;
use BookStack\Util\FilePathNormalizer;
use Illuminate\Contracts\Filesystem\Filesystem; use Illuminate\Contracts\Filesystem\Filesystem;
use Illuminate\Filesystem\FilesystemAdapter; use Illuminate\Filesystem\FilesystemAdapter;
use League\Flysystem\WhitespacePathNormalizer;
use Symfony\Component\HttpFoundation\StreamedResponse; use Symfony\Component\HttpFoundation\StreamedResponse;
class ImageStorageDisk class ImageStorageDisk
@ -30,13 +30,14 @@ class ImageStorageDisk
*/ */
protected function adjustPathForDisk(string $path): string 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()) { if ($this->usingSecureImages()) {
return $path; return $normalized;
} }
return 'uploads/images/' . $path; return 'uploads/images/' . $normalized;
} }
/** /**

View File

@ -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);
}
}

View File

@ -13,12 +13,14 @@ use BookStack\Permissions\PermissionsController;
use BookStack\References\ReferenceController; use BookStack\References\ReferenceController;
use BookStack\Search\SearchController; use BookStack\Search\SearchController;
use BookStack\Settings as SettingControllers; use BookStack\Settings as SettingControllers;
use BookStack\Theming\ThemeController;
use BookStack\Uploads\Controllers as UploadControllers; use BookStack\Uploads\Controllers as UploadControllers;
use BookStack\Users\Controllers as UserControllers; use BookStack\Users\Controllers as UserControllers;
use Illuminate\Session\Middleware\StartSession; use Illuminate\Session\Middleware\StartSession;
use Illuminate\Support\Facades\Route; use Illuminate\Support\Facades\Route;
use Illuminate\View\Middleware\ShareErrorsFromSession; use Illuminate\View\Middleware\ShareErrorsFromSession;
// Status & Meta routes
Route::get('/status', [SettingControllers\StatusController::class, 'show']); Route::get('/status', [SettingControllers\StatusController::class, 'show']);
Route::get('/robots.txt', [MetaController::class, 'robots']); Route::get('/robots.txt', [MetaController::class, 'robots']);
Route::get('/favicon.ico', [MetaController::class, 'favicon']); 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::get('/password/reset/{token}', [AccessControllers\ResetPasswordController::class, 'showResetForm']);
Route::post('/password/reset', [AccessControllers\ResetPasswordController::class, 'reset'])->middleware('throttle:public'); 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/tinymce', 'help.tinymce');
Route::view('/help/wysiwyg', 'help.wysiwyg'); 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'); Route::fallback([MetaController::class, 'notFound'])->name('fallback');