mirror of
https://github.com/BookStackApp/BookStack.git
synced 2025-02-24 05:39:39 +08:00
Merge pull request #5405 from BookStackApp/public_theme_files
Some checks failed
analyse-php / build (push) Has been cancelled
lint-php / build (push) Has been cancelled
test-migrations / build (8.2) (push) Has been cancelled
test-migrations / build (8.3) (push) Has been cancelled
test-migrations / build (8.4) (push) Has been cancelled
test-php / build (8.2) (push) Has been cancelled
test-php / build (8.3) (push) Has been cancelled
test-php / build (8.4) (push) Has been cancelled
Some checks failed
analyse-php / build (push) Has been cancelled
lint-php / build (push) Has been cancelled
test-migrations / build (8.2) (push) Has been cancelled
test-migrations / build (8.3) (push) Has been cancelled
test-migrations / build (8.4) (push) Has been cancelled
test-php / build (8.2) (push) Has been cancelled
test-php / build (8.3) (push) Has been cancelled
test-php / build (8.4) (push) Has been cancelled
Theme System: Public serving of files
This commit is contained in:
commit
786a434c03
@ -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;
|
||||
}
|
||||
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
@ -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) {
|
||||
@ -69,7 +70,7 @@ class DownloadResponseFactory
|
||||
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(
|
||||
@ -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.
|
||||
*/
|
||||
|
@ -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');
|
||||
|
||||
|
@ -32,12 +32,12 @@ class RangeSupportedStream
|
||||
/**
|
||||
* 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);
|
||||
}
|
||||
|
||||
/**
|
||||
|
31
app/Theming/ThemeController.php
Normal file
31
app/Theming/ThemeController.php
Normal 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;
|
||||
}
|
||||
}
|
@ -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.
|
||||
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
17
app/Util/FilePathNormalizer.php
Normal file
17
app/Util/FilePathNormalizer.php
Normal 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);
|
||||
}
|
||||
}
|
@ -13,7 +13,7 @@ class WebSafeMimeSniffer
|
||||
/**
|
||||
* @var string[]
|
||||
*/
|
||||
protected $safeMimes = [
|
||||
protected array $safeMimes = [
|
||||
'application/json',
|
||||
'application/octet-stream',
|
||||
'application/pdf',
|
||||
@ -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;
|
||||
}
|
||||
|
@ -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
|
||||
|
||||
|
@ -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
|
||||
|
||||
@ -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.
|
||||
|
@ -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');
|
||||
|
@ -464,6 +464,34 @@ END;
|
||||
});
|
||||
}
|
||||
|
||||
public function test_public_folder_contents_accessible_via_route()
|
||||
{
|
||||
$this->usingThemeFolder(function (string $themeFolderName) {
|
||||
$publicDir = theme_path('public');
|
||||
mkdir($publicDir, 0777, true);
|
||||
|
||||
$text = 'some-text ' . md5(random_bytes(5));
|
||||
$css = "body { background-color: tomato !important; }";
|
||||
file_put_contents("{$publicDir}/file.txt", $text);
|
||||
file_put_contents("{$publicDir}/file.css", $css);
|
||||
copy($this->files->testFilePath('test-image.png'), "{$publicDir}/image.png");
|
||||
|
||||
$resp = $this->asAdmin()->get("/theme/{$themeFolderName}/file.txt");
|
||||
$resp->assertStreamedContent($text);
|
||||
$resp->assertHeader('Content-Type', 'text/plain; charset=UTF-8');
|
||||
$resp->assertHeader('Cache-Control', 'max-age=86400, private');
|
||||
|
||||
$resp = $this->asAdmin()->get("/theme/{$themeFolderName}/image.png");
|
||||
$resp->assertHeader('Content-Type', 'image/png');
|
||||
$resp->assertHeader('Cache-Control', 'max-age=86400, private');
|
||||
|
||||
$resp = $this->asAdmin()->get("/theme/{$themeFolderName}/file.css");
|
||||
$resp->assertStreamedContent($css);
|
||||
$resp->assertHeader('Content-Type', 'text/css; charset=UTF-8');
|
||||
$resp->assertHeader('Cache-Control', 'max-age=86400, private');
|
||||
});
|
||||
}
|
||||
|
||||
protected function usingThemeFolder(callable $callback)
|
||||
{
|
||||
// Create a folder and configure a theme
|
||||
|
Loading…
x
Reference in New Issue
Block a user