Skip to content

Commit

Permalink
Merge pull request #5405 from BookStackApp/public_theme_files
Browse files Browse the repository at this point in the history
Theme System: Public serving of files
  • Loading branch information
ssddanbrown authored Jan 14, 2025
2 parents b975180 + 25c4f4b commit 786a434
Show file tree
Hide file tree
Showing 17 changed files with 183 additions and 22 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);
}
}
21 changes: 19 additions & 2 deletions 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 @@ -69,7 +70,7 @@ public function streamedFileDirectly(string $filePath, string $fileName, int $fi
public function streamedInline($stream, string $fileName, int $fileSize): StreamedResponse
{
$rangeStream = new RangeSupportedStream($stream, $fileSize, $this->request);
$mime = $rangeStream->sniffMime();
$mime = $rangeStream->sniffMime(pathinfo($fileName, PATHINFO_EXTENSION));
$headers = array_merge($this->getHeaders($fileName, $fileSize, $mime), $rangeStream->getResponseHeaders());

return response()->stream(
Expand All @@ -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
4 changes: 2 additions & 2 deletions app/Http/RangeSupportedStream.php
Original file line number Diff line number Diff line change
Expand Up @@ -32,12 +32,12 @@ public function __construct(
/**
* Sniff a mime type from the stream.
*/
public function sniffMime(): string
public function sniffMime(string $extension = ''): string
{
$offset = min(2000, $this->fileSize);
$this->sniffContent = fread($this->stream, $offset);

return (new WebSafeMimeSniffer())->sniff($this->sniffContent);
return (new WebSafeMimeSniffer())->sniff($this->sniffContent, $extension);
}

/**
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);
}
}
16 changes: 14 additions & 2 deletions app/Util/WebSafeMimeSniffer.php
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ class WebSafeMimeSniffer
/**
* @var string[]
*/
protected $safeMimes = [
protected array $safeMimes = [
'application/json',
'application/octet-stream',
'application/pdf',
Expand Down Expand Up @@ -48,16 +48,28 @@ class WebSafeMimeSniffer
'video/av1',
];

protected array $textTypesByExtension = [
'css' => 'text/css',
'js' => 'text/javascript',
'json' => 'application/json',
'csv' => 'text/csv',
];

/**
* Sniff the mime-type from the given file content while running the result
* through an allow-list to ensure a web-safe result.
* Takes the content as a reference since the value may be quite large.
* Accepts an optional $extension which can be used for further guessing.
*/
public function sniff(string &$content): string
public function sniff(string &$content, string $extension = ''): string
{
$fInfo = new finfo(FILEINFO_MIME_TYPE);
$mime = $fInfo->buffer($content) ?: 'application/octet-stream';

if ($mime === 'text/plain' && $extension) {
$mime = $this->textTypesByExtension[$extension] ?? 'text/plain';
}

if (in_array($mime, $this->safeMimes)) {
return $mime;
}
Expand Down
4 changes: 3 additions & 1 deletion dev/docs/logical-theme-system.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,9 @@

BookStack allows logical customization via the theme system which enables you to add, or extend, functionality within the PHP side of the system without needing to alter the core application files.

WARNING: This system is currently in alpha so may incur changes. Once we've gathered some feedback on usage we'll look to removing this warning. This system will be considered semi-stable in the future. The `Theme::` system will be kept maintained but specific customizations or deeper app/framework usage using this system will not be supported nor considered in any way stable. Customizations using this system should be checked after updates.
This is part of the theme system alongside the [visual theme system](./visual-theme-system.md).

**Note:** This system is considered semi-stable. The `Theme::` system is kept maintained but specific customizations or deeper app/framework usage using this system will not be supported nor considered in any way stable. Customizations using this system should be checked after updates.

## Getting Started

Expand Down
25 changes: 24 additions & 1 deletion dev/docs/visual-theme-system.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,9 @@

BookStack allows visual customization via the theme system which enables you to extensively customize views, translation text & icons.

This theme system itself is maintained and supported but usages of this system, including the files you are able to override, are not considered stable and may change upon any update. You should test any customizations made after updates.
This is part of the theme system alongside the [logical theme system](./logical-theme-system.md).

**Note:** This theme system itself is maintained and supported but usages of this system, including the files you are able to override, are not considered stable and may change upon any update. You should test any customizations made after updates.

## Getting Started

Expand Down Expand Up @@ -32,3 +34,24 @@ return [
'search' => 'find',
];
```

## Publicly Accessible Files

As part of deeper customizations you may want to expose additional files
(images, scripts, styles, etc...) as part of your theme, in a way so they're
accessible in public web-space to browsers.

To achieve this, you can put files within a `themes/<theme_name>/public` folder.
BookStack will serve any files within this folder from a `/theme/<theme_name>` base path.

As an example, if I had an image located at `themes/custom/public/cat.jpg`, I could access
that image via the URL path `/theme/custom/cat.jpg`. That's assuming that `custom` is the currently
configured application theme.

There are some considerations to these publicly served files:

- Only a predetermined range "web safe" content-types are currently served.
- This limits running into potential insecure scenarios in serving problematic file types.
- A static 1-day cache time it set on files served from this folder.
- You can use alternative cache-breaking techniques (change of query string) upon changes if needed.
- If required, you could likely override caching at the webserver level.
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');
Loading

0 comments on commit 786a434

Please sign in to comment.