1
0
mirror of https://github.com/BookStackApp/BookStack.git synced 2025-01-31 12:11:37 +01:00

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

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

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

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

View 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.
*/

View 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');

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 = [];
/**
* 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.

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

View 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;
}
/**

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\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');