Themes: Added route to serve public theme files
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
This commit is contained in:
		
							parent
							
								
									b9751807e7
								
							
						
					
					
						commit
						593645acfe
					
				| 
						 | 
					@ -1,6 +1,7 @@
 | 
				
			||||||
<?php
 | 
					<?php
 | 
				
			||||||
 | 
					
 | 
				
			||||||
use BookStack\App\Model;
 | 
					use BookStack\App\Model;
 | 
				
			||||||
 | 
					use BookStack\Facades\Theme;
 | 
				
			||||||
use BookStack\Permissions\PermissionApplicator;
 | 
					use BookStack\Permissions\PermissionApplicator;
 | 
				
			||||||
use BookStack\Settings\SettingService;
 | 
					use BookStack\Settings\SettingService;
 | 
				
			||||||
use BookStack\Users\Models\User;
 | 
					use BookStack\Users\Models\User;
 | 
				
			||||||
| 
						 | 
					@ -88,8 +89,7 @@ function setting(string $key = null, $default = null)
 | 
				
			||||||
 */
 | 
					 */
 | 
				
			||||||
function theme_path(string $path = ''): ?string
 | 
					function theme_path(string $path = ''): ?string
 | 
				
			||||||
{
 | 
					{
 | 
				
			||||||
    $theme = config('view.theme');
 | 
					    $theme = Theme::getTheme();
 | 
				
			||||||
 | 
					 | 
				
			||||||
    if (!$theme) {
 | 
					    if (!$theme) {
 | 
				
			||||||
        return null;
 | 
					        return null;
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -76,6 +76,6 @@ class BookExportController extends Controller
 | 
				
			||||||
        $book = $this->queries->findVisibleBySlugOrFail($bookSlug);
 | 
					        $book = $this->queries->findVisibleBySlugOrFail($bookSlug);
 | 
				
			||||||
        $zip = $builder->buildForBook($book);
 | 
					        $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);
 | 
					        $chapter = $this->queries->findVisibleBySlugsOrFail($bookSlug, $chapterSlug);
 | 
				
			||||||
        $zip = $builder->buildForChapter($chapter);
 | 
					        $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);
 | 
					        $page = $this->queries->findVisibleBySlugsOrFail($bookSlug, $pageSlug);
 | 
				
			||||||
        $zip = $builder->buildForPage($page);
 | 
					        $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.
 | 
					     * Create a response that downloads the given file via a stream.
 | 
				
			||||||
     * Has the option to delete the provided file once the stream is closed.
 | 
					     * 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');
 | 
					        $stream = fopen($filePath, 'r');
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        if ($deleteAfter) {
 | 
					        if ($deleteAfter) {
 | 
				
			||||||
| 
						 | 
					@ -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.
 | 
					     * Get the common headers to provide for a download response.
 | 
				
			||||||
     */
 | 
					     */
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -7,6 +7,13 @@ use Symfony\Component\HttpFoundation\Response;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
class PreventResponseCaching
 | 
					class PreventResponseCaching
 | 
				
			||||||
{
 | 
					{
 | 
				
			||||||
 | 
					    /**
 | 
				
			||||||
 | 
					     * Paths to ignore when preventing response caching.
 | 
				
			||||||
 | 
					     */
 | 
				
			||||||
 | 
					    protected array $ignoredPathPrefixes = [
 | 
				
			||||||
 | 
					        'theme/',
 | 
				
			||||||
 | 
					    ];
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    /**
 | 
					    /**
 | 
				
			||||||
     * Handle an incoming request.
 | 
					     * Handle an incoming request.
 | 
				
			||||||
     *
 | 
					     *
 | 
				
			||||||
| 
						 | 
					@ -20,6 +27,13 @@ class PreventResponseCaching
 | 
				
			||||||
        /** @var Response $response */
 | 
					        /** @var Response $response */
 | 
				
			||||||
        $response = $next($request);
 | 
					        $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('Cache-Control', 'no-cache, no-store, private');
 | 
				
			||||||
        $response->headers->set('Expires', 'Sun, 12 Jul 2015 19:01:00 GMT');
 | 
					        $response->headers->set('Expires', 'Sun, 12 Jul 2015 19:01:00 GMT');
 | 
				
			||||||
 | 
					
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -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 = [];
 | 
					    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,
 | 
					     * Listen to a given custom theme event,
 | 
				
			||||||
     * setting up the action to be ran when the event occurs.
 | 
					     * setting up the action to be ran when the event occurs.
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -3,12 +3,12 @@
 | 
				
			||||||
namespace BookStack\Uploads;
 | 
					namespace BookStack\Uploads;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
use BookStack\Exceptions\FileUploadException;
 | 
					use BookStack\Exceptions\FileUploadException;
 | 
				
			||||||
 | 
					use BookStack\Util\FilePathNormalizer;
 | 
				
			||||||
use Exception;
 | 
					use Exception;
 | 
				
			||||||
use Illuminate\Contracts\Filesystem\Filesystem as Storage;
 | 
					use Illuminate\Contracts\Filesystem\Filesystem as Storage;
 | 
				
			||||||
use Illuminate\Filesystem\FilesystemManager;
 | 
					use Illuminate\Filesystem\FilesystemManager;
 | 
				
			||||||
use Illuminate\Support\Facades\Log;
 | 
					use Illuminate\Support\Facades\Log;
 | 
				
			||||||
use Illuminate\Support\Str;
 | 
					use Illuminate\Support\Str;
 | 
				
			||||||
use League\Flysystem\WhitespacePathNormalizer;
 | 
					 | 
				
			||||||
use Symfony\Component\HttpFoundation\File\UploadedFile;
 | 
					use Symfony\Component\HttpFoundation\File\UploadedFile;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
class FileStorage
 | 
					class FileStorage
 | 
				
			||||||
| 
						 | 
					@ -120,12 +120,13 @@ class FileStorage
 | 
				
			||||||
     */
 | 
					     */
 | 
				
			||||||
    protected function adjustPathForStorageDisk(string $path): 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') {
 | 
					        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;
 | 
					namespace BookStack\Uploads;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					use BookStack\Util\FilePathNormalizer;
 | 
				
			||||||
use Illuminate\Contracts\Filesystem\Filesystem;
 | 
					use Illuminate\Contracts\Filesystem\Filesystem;
 | 
				
			||||||
use Illuminate\Filesystem\FilesystemAdapter;
 | 
					use Illuminate\Filesystem\FilesystemAdapter;
 | 
				
			||||||
use League\Flysystem\WhitespacePathNormalizer;
 | 
					 | 
				
			||||||
use Symfony\Component\HttpFoundation\StreamedResponse;
 | 
					use Symfony\Component\HttpFoundation\StreamedResponse;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
class ImageStorageDisk
 | 
					class ImageStorageDisk
 | 
				
			||||||
| 
						 | 
					@ -30,13 +30,14 @@ class ImageStorageDisk
 | 
				
			||||||
     */
 | 
					     */
 | 
				
			||||||
    protected function adjustPathForDisk(string $path): string
 | 
					    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()) {
 | 
					        if ($this->usingSecureImages()) {
 | 
				
			||||||
            return $path;
 | 
					            return $normalized;
 | 
				
			||||||
        }
 | 
					        }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        return 'uploads/images/' . $path;
 | 
					        return 'uploads/images/' . $normalized;
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    /**
 | 
					    /**
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -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,12 +13,14 @@ use BookStack\Permissions\PermissionsController;
 | 
				
			||||||
use BookStack\References\ReferenceController;
 | 
					use BookStack\References\ReferenceController;
 | 
				
			||||||
use BookStack\Search\SearchController;
 | 
					use BookStack\Search\SearchController;
 | 
				
			||||||
use BookStack\Settings as SettingControllers;
 | 
					use BookStack\Settings as SettingControllers;
 | 
				
			||||||
 | 
					use BookStack\Theming\ThemeController;
 | 
				
			||||||
use BookStack\Uploads\Controllers as UploadControllers;
 | 
					use BookStack\Uploads\Controllers as UploadControllers;
 | 
				
			||||||
use BookStack\Users\Controllers as UserControllers;
 | 
					use BookStack\Users\Controllers as UserControllers;
 | 
				
			||||||
use Illuminate\Session\Middleware\StartSession;
 | 
					use Illuminate\Session\Middleware\StartSession;
 | 
				
			||||||
use Illuminate\Support\Facades\Route;
 | 
					use Illuminate\Support\Facades\Route;
 | 
				
			||||||
use Illuminate\View\Middleware\ShareErrorsFromSession;
 | 
					use Illuminate\View\Middleware\ShareErrorsFromSession;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					// Status & Meta routes
 | 
				
			||||||
Route::get('/status', [SettingControllers\StatusController::class, 'show']);
 | 
					Route::get('/status', [SettingControllers\StatusController::class, 'show']);
 | 
				
			||||||
Route::get('/robots.txt', [MetaController::class, 'robots']);
 | 
					Route::get('/robots.txt', [MetaController::class, 'robots']);
 | 
				
			||||||
Route::get('/favicon.ico', [MetaController::class, 'favicon']);
 | 
					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::get('/password/reset/{token}', [AccessControllers\ResetPasswordController::class, 'showResetForm']);
 | 
				
			||||||
Route::post('/password/reset', [AccessControllers\ResetPasswordController::class, 'reset'])->middleware('throttle:public');
 | 
					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/tinymce', 'help.tinymce');
 | 
				
			||||||
Route::view('/help/wysiwyg', 'help.wysiwyg');
 | 
					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');
 | 
					Route::fallback([MetaController::class, 'notFound'])->name('fallback');
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
		Loading…
	
		Reference in New Issue