<?php
use BookStack\App\Model;
+use BookStack\Facades\Theme;
use BookStack\Permissions\PermissionApplicator;
use BookStack\Settings\SettingService;
use BookStack\Users\Models\User;
*/
function theme_path(string $path = ''): ?string
{
- $theme = config('view.theme');
-
+ $theme = Theme::getTheme();
if (!$theme) {
return null;
}
$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);
}
}
$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);
}
}
$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);
}
}
* 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) {
);
}
+ /**
+ * 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.
*/
class PreventResponseCaching
{
+ /**
+ * Paths to ignore when preventing response caching.
+ */
+ protected array $ignoredPathPrefixes = [
+ 'theme/',
+ ];
+
/**
* Handle an incoming request.
*
/** @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');
--- /dev/null
+<?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;
+ }
+}
*/
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.
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
*/
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;
}
}
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
*/
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;
}
/**
--- /dev/null
+<?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);
+ }
+}
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']);
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');