Skip to content

Commit

Permalink
Themes: Added route to serve public theme files
Browse files Browse the repository at this point in the history
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
  • Loading branch information
ssddanbrown committed Jan 13, 2025
1 parent b975180 commit 593645a
Show file tree
Hide file tree
Showing 12 changed files with 111 additions and 15 deletions.
4 changes: 2 additions & 2 deletions app/App/helpers.php
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -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;
}
Expand Down
2 changes: 1 addition & 1 deletion app/Exports/Controllers/BookExportController.php
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,6 @@ public function zip(string $bookSlug, ZipExportBuilder $builder)
$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);
}
}
2 changes: 1 addition & 1 deletion app/Exports/Controllers/ChapterExportController.php
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
}
2 changes: 1 addition & 1 deletion app/Exports/Controllers/PageExportController.php
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
}
19 changes: 18 additions & 1 deletion app/Http/DownloadResponseFactory.php
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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.
*/
Expand Down
14 changes: 14 additions & 0 deletions app/Http/Middleware/PreventResponseCaching.php
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,13 @@

class PreventResponseCaching
{
/**
* Paths to ignore when preventing response caching.
*/
protected array $ignoredPathPrefixes = [
'theme/',
];

/**
* Handle an incoming request.
*
Expand All @@ -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');

Expand Down
31 changes: 31 additions & 0 deletions app/Theming/ThemeController.php
Original file line number Diff line number Diff line change
@@ -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;
}
}
9 changes: 9 additions & 0 deletions app/Theming/ThemeService.php
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
9 changes: 5 additions & 4 deletions app/Uploads/FileStorage.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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;
}
}
9 changes: 5 additions & 4 deletions app/Uploads/ImageStorageDisk.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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;
}

/**
Expand Down
17 changes: 17 additions & 0 deletions app/Util/FilePathNormalizer.php
Original file line number Diff line number Diff line change
@@ -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);
}
}
8 changes: 7 additions & 1 deletion routes/web.php
Original file line number Diff line number Diff line change
Expand Up @@ -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']);
Expand Down Expand Up @@ -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');

0 comments on commit 593645a

Please sign in to comment.