Skip to content

Commit 786a434

Browse files
authored
Merge pull request #5405 from BookStackApp/public_theme_files
Theme System: Public serving of files
2 parents b975180 + 25c4f4b commit 786a434

17 files changed

+183
-22
lines changed

app/App/helpers.php

+2-2
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
<?php
22

33
use BookStack\App\Model;
4+
use BookStack\Facades\Theme;
45
use BookStack\Permissions\PermissionApplicator;
56
use BookStack\Settings\SettingService;
67
use BookStack\Users\Models\User;
@@ -88,8 +89,7 @@ function setting(string $key = null, $default = null)
8889
*/
8990
function theme_path(string $path = ''): ?string
9091
{
91-
$theme = config('view.theme');
92-
92+
$theme = Theme::getTheme();
9393
if (!$theme) {
9494
return null;
9595
}

app/Exports/Controllers/BookExportController.php

+1-1
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,6 @@ public function zip(string $bookSlug, ZipExportBuilder $builder)
7676
$book = $this->queries->findVisibleBySlugOrFail($bookSlug);
7777
$zip = $builder->buildForBook($book);
7878

79-
return $this->download()->streamedFileDirectly($zip, $bookSlug . '.zip', filesize($zip), true);
79+
return $this->download()->streamedFileDirectly($zip, $bookSlug . '.zip', true);
8080
}
8181
}

app/Exports/Controllers/ChapterExportController.php

+1-1
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,6 @@ public function zip(string $bookSlug, string $chapterSlug, ZipExportBuilder $bui
8282
$chapter = $this->queries->findVisibleBySlugsOrFail($bookSlug, $chapterSlug);
8383
$zip = $builder->buildForChapter($chapter);
8484

85-
return $this->download()->streamedFileDirectly($zip, $chapterSlug . '.zip', filesize($zip), true);
85+
return $this->download()->streamedFileDirectly($zip, $chapterSlug . '.zip', true);
8686
}
8787
}

app/Exports/Controllers/PageExportController.php

+1-1
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,6 @@ public function zip(string $bookSlug, string $pageSlug, ZipExportBuilder $builde
8686
$page = $this->queries->findVisibleBySlugsOrFail($bookSlug, $pageSlug);
8787
$zip = $builder->buildForPage($page);
8888

89-
return $this->download()->streamedFileDirectly($zip, $pageSlug . '.zip', filesize($zip), true);
89+
return $this->download()->streamedFileDirectly($zip, $pageSlug . '.zip', true);
9090
}
9191
}

app/Http/DownloadResponseFactory.php

+19-2
Original file line numberDiff line numberDiff line change
@@ -39,8 +39,9 @@ public function streamedDirectly($stream, string $fileName, int $fileSize): Stre
3939
* Create a response that downloads the given file via a stream.
4040
* Has the option to delete the provided file once the stream is closed.
4141
*/
42-
public function streamedFileDirectly(string $filePath, string $fileName, int $fileSize, bool $deleteAfter = false): StreamedResponse
42+
public function streamedFileDirectly(string $filePath, string $fileName, bool $deleteAfter = false): StreamedResponse
4343
{
44+
$fileSize = filesize($filePath);
4445
$stream = fopen($filePath, 'r');
4546

4647
if ($deleteAfter) {
@@ -69,7 +70,7 @@ public function streamedFileDirectly(string $filePath, string $fileName, int $fi
6970
public function streamedInline($stream, string $fileName, int $fileSize): StreamedResponse
7071
{
7172
$rangeStream = new RangeSupportedStream($stream, $fileSize, $this->request);
72-
$mime = $rangeStream->sniffMime();
73+
$mime = $rangeStream->sniffMime(pathinfo($fileName, PATHINFO_EXTENSION));
7374
$headers = array_merge($this->getHeaders($fileName, $fileSize, $mime), $rangeStream->getResponseHeaders());
7475

7576
return response()->stream(
@@ -79,6 +80,22 @@ public function streamedInline($stream, string $fileName, int $fileSize): Stream
7980
);
8081
}
8182

83+
/**
84+
* Create a response that provides the given file via a stream with detected content-type.
85+
* Has the option to delete the provided file once the stream is closed.
86+
*/
87+
public function streamedFileInline(string $filePath, ?string $fileName = null): StreamedResponse
88+
{
89+
$fileSize = filesize($filePath);
90+
$stream = fopen($filePath, 'r');
91+
92+
if ($fileName === null) {
93+
$fileName = basename($filePath);
94+
}
95+
96+
return $this->streamedInline($stream, $fileName, $fileSize);
97+
}
98+
8299
/**
83100
* Get the common headers to provide for a download response.
84101
*/

app/Http/Middleware/PreventResponseCaching.php

+14
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,13 @@
77

88
class PreventResponseCaching
99
{
10+
/**
11+
* Paths to ignore when preventing response caching.
12+
*/
13+
protected array $ignoredPathPrefixes = [
14+
'theme/',
15+
];
16+
1017
/**
1118
* Handle an incoming request.
1219
*
@@ -20,6 +27,13 @@ public function handle($request, Closure $next)
2027
/** @var Response $response */
2128
$response = $next($request);
2229

30+
$path = $request->path();
31+
foreach ($this->ignoredPathPrefixes as $ignoredPath) {
32+
if (str_starts_with($path, $ignoredPath)) {
33+
return $response;
34+
}
35+
}
36+
2337
$response->headers->set('Cache-Control', 'no-cache, no-store, private');
2438
$response->headers->set('Expires', 'Sun, 12 Jul 2015 19:01:00 GMT');
2539

app/Http/RangeSupportedStream.php

+2-2
Original file line numberDiff line numberDiff line change
@@ -32,12 +32,12 @@ public function __construct(
3232
/**
3333
* Sniff a mime type from the stream.
3434
*/
35-
public function sniffMime(): string
35+
public function sniffMime(string $extension = ''): string
3636
{
3737
$offset = min(2000, $this->fileSize);
3838
$this->sniffContent = fread($this->stream, $offset);
3939

40-
return (new WebSafeMimeSniffer())->sniff($this->sniffContent);
40+
return (new WebSafeMimeSniffer())->sniff($this->sniffContent, $extension);
4141
}
4242

4343
/**

app/Theming/ThemeController.php

+31
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
<?php
2+
3+
namespace BookStack\Theming;
4+
5+
use BookStack\Facades\Theme;
6+
use BookStack\Http\Controller;
7+
use BookStack\Util\FilePathNormalizer;
8+
9+
class ThemeController extends Controller
10+
{
11+
/**
12+
* Serve a public file from the configured theme.
13+
*/
14+
public function publicFile(string $theme, string $path)
15+
{
16+
$cleanPath = FilePathNormalizer::normalize($path);
17+
if ($theme !== Theme::getTheme() || !$cleanPath) {
18+
abort(404);
19+
}
20+
21+
$filePath = theme_path("public/{$cleanPath}");
22+
if (!file_exists($filePath)) {
23+
abort(404);
24+
}
25+
26+
$response = $this->download()->streamedFileInline($filePath);
27+
$response->setMaxAge(86400);
28+
29+
return $response;
30+
}
31+
}

app/Theming/ThemeService.php

+9
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,15 @@ class ThemeService
1515
*/
1616
protected array $listeners = [];
1717

18+
/**
19+
* Get the currently configured theme.
20+
* Returns an empty string if not configured.
21+
*/
22+
public function getTheme(): string
23+
{
24+
return config('view.theme') ?? '';
25+
}
26+
1827
/**
1928
* Listen to a given custom theme event,
2029
* setting up the action to be ran when the event occurs.

app/Uploads/FileStorage.php

+5-4
Original file line numberDiff line numberDiff line change
@@ -3,12 +3,12 @@
33
namespace BookStack\Uploads;
44

55
use BookStack\Exceptions\FileUploadException;
6+
use BookStack\Util\FilePathNormalizer;
67
use Exception;
78
use Illuminate\Contracts\Filesystem\Filesystem as Storage;
89
use Illuminate\Filesystem\FilesystemManager;
910
use Illuminate\Support\Facades\Log;
1011
use Illuminate\Support\Str;
11-
use League\Flysystem\WhitespacePathNormalizer;
1212
use Symfony\Component\HttpFoundation\File\UploadedFile;
1313

1414
class FileStorage
@@ -120,12 +120,13 @@ protected function getStorageDiskName(): string
120120
*/
121121
protected function adjustPathForStorageDisk(string $path): string
122122
{
123-
$path = (new WhitespacePathNormalizer())->normalizePath(str_replace('uploads/files/', '', $path));
123+
$trimmed = str_replace('uploads/files/', '', $path);
124+
$normalized = FilePathNormalizer::normalize($trimmed);
124125

125126
if ($this->getStorageDiskName() === 'local_secure_attachments') {
126-
return $path;
127+
return $normalized;
127128
}
128129

129-
return 'uploads/files/' . $path;
130+
return 'uploads/files/' . $normalized;
130131
}
131132
}

app/Uploads/ImageStorageDisk.php

+5-4
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,9 @@
22

33
namespace BookStack\Uploads;
44

5+
use BookStack\Util\FilePathNormalizer;
56
use Illuminate\Contracts\Filesystem\Filesystem;
67
use Illuminate\Filesystem\FilesystemAdapter;
7-
use League\Flysystem\WhitespacePathNormalizer;
88
use Symfony\Component\HttpFoundation\StreamedResponse;
99

1010
class ImageStorageDisk
@@ -30,13 +30,14 @@ public function usingSecureImages(): bool
3030
*/
3131
protected function adjustPathForDisk(string $path): string
3232
{
33-
$path = (new WhitespacePathNormalizer())->normalizePath(str_replace('uploads/images/', '', $path));
33+
$trimmed = str_replace('uploads/images/', '', $path);
34+
$normalized = FilePathNormalizer::normalize($trimmed);
3435

3536
if ($this->usingSecureImages()) {
36-
return $path;
37+
return $normalized;
3738
}
3839

39-
return 'uploads/images/' . $path;
40+
return 'uploads/images/' . $normalized;
4041
}
4142

4243
/**

app/Util/FilePathNormalizer.php

+17
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
<?php
2+
3+
namespace BookStack\Util;
4+
5+
use League\Flysystem\WhitespacePathNormalizer;
6+
7+
/**
8+
* Utility to normalize (potentially) user provided file paths
9+
* to avoid things like directory traversal.
10+
*/
11+
class FilePathNormalizer
12+
{
13+
public static function normalize(string $path): string
14+
{
15+
return (new WhitespacePathNormalizer())->normalizePath($path);
16+
}
17+
}

app/Util/WebSafeMimeSniffer.php

+14-2
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ class WebSafeMimeSniffer
1313
/**
1414
* @var string[]
1515
*/
16-
protected $safeMimes = [
16+
protected array $safeMimes = [
1717
'application/json',
1818
'application/octet-stream',
1919
'application/pdf',
@@ -48,16 +48,28 @@ class WebSafeMimeSniffer
4848
'video/av1',
4949
];
5050

51+
protected array $textTypesByExtension = [
52+
'css' => 'text/css',
53+
'js' => 'text/javascript',
54+
'json' => 'application/json',
55+
'csv' => 'text/csv',
56+
];
57+
5158
/**
5259
* Sniff the mime-type from the given file content while running the result
5360
* through an allow-list to ensure a web-safe result.
5461
* Takes the content as a reference since the value may be quite large.
62+
* Accepts an optional $extension which can be used for further guessing.
5563
*/
56-
public function sniff(string &$content): string
64+
public function sniff(string &$content, string $extension = ''): string
5765
{
5866
$fInfo = new finfo(FILEINFO_MIME_TYPE);
5967
$mime = $fInfo->buffer($content) ?: 'application/octet-stream';
6068

69+
if ($mime === 'text/plain' && $extension) {
70+
$mime = $this->textTypesByExtension[$extension] ?? 'text/plain';
71+
}
72+
6173
if (in_array($mime, $this->safeMimes)) {
6274
return $mime;
6375
}

dev/docs/logical-theme-system.md

+3-1
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,9 @@
22

33
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.
44

5-
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.
5+
This is part of the theme system alongside the [visual theme system](./visual-theme-system.md).
6+
7+
**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.
68

79
## Getting Started
810

dev/docs/visual-theme-system.md

+24-1
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,9 @@
22

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

5-
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.
5+
This is part of the theme system alongside the [logical theme system](./logical-theme-system.md).
6+
7+
**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.
68

79
## Getting Started
810

@@ -32,3 +34,24 @@ return [
3234
'search' => 'find',
3335
];
3436
```
37+
38+
## Publicly Accessible Files
39+
40+
As part of deeper customizations you may want to expose additional files
41+
(images, scripts, styles, etc...) as part of your theme, in a way so they're
42+
accessible in public web-space to browsers.
43+
44+
To achieve this, you can put files within a `themes/<theme_name>/public` folder.
45+
BookStack will serve any files within this folder from a `/theme/<theme_name>` base path.
46+
47+
As an example, if I had an image located at `themes/custom/public/cat.jpg`, I could access
48+
that image via the URL path `/theme/custom/cat.jpg`. That's assuming that `custom` is the currently
49+
configured application theme.
50+
51+
There are some considerations to these publicly served files:
52+
53+
- Only a predetermined range "web safe" content-types are currently served.
54+
- This limits running into potential insecure scenarios in serving problematic file types.
55+
- A static 1-day cache time it set on files served from this folder.
56+
- You can use alternative cache-breaking techniques (change of query string) upon changes if needed.
57+
- If required, you could likely override caching at the webserver level.

routes/web.php

+7-1
Original file line numberDiff line numberDiff line change
@@ -13,12 +13,14 @@
1313
use BookStack\References\ReferenceController;
1414
use BookStack\Search\SearchController;
1515
use BookStack\Settings as SettingControllers;
16+
use BookStack\Theming\ThemeController;
1617
use BookStack\Uploads\Controllers as UploadControllers;
1718
use BookStack\Users\Controllers as UserControllers;
1819
use Illuminate\Session\Middleware\StartSession;
1920
use Illuminate\Support\Facades\Route;
2021
use Illuminate\View\Middleware\ShareErrorsFromSession;
2122

23+
// Status & Meta routes
2224
Route::get('/status', [SettingControllers\StatusController::class, 'show']);
2325
Route::get('/robots.txt', [MetaController::class, 'robots']);
2426
Route::get('/favicon.ico', [MetaController::class, 'favicon']);
@@ -360,8 +362,12 @@
360362
Route::get('/password/reset/{token}', [AccessControllers\ResetPasswordController::class, 'showResetForm']);
361363
Route::post('/password/reset', [AccessControllers\ResetPasswordController::class, 'reset'])->middleware('throttle:public');
362364

363-
// Metadata routes
365+
// Help & Info routes
364366
Route::view('/help/tinymce', 'help.tinymce');
365367
Route::view('/help/wysiwyg', 'help.wysiwyg');
366368

369+
// Theme Routes
370+
Route::get('/theme/{theme}/{path}', [ThemeController::class, 'publicFile'])
371+
->where('path', '.*$');
372+
367373
Route::fallback([MetaController::class, 'notFound'])->name('fallback');

0 commit comments

Comments
 (0)