Theme: Added handling for functions.php file load error

This adds specific handling for functions.php error loading to re-throw
errors wrapped in a more descriptive message, to make it clear the error
is due to an issue in their functions.php file.

Decided to throw and stop, rather than ignore & continue, to be on the
safe side in the event auth-level (or other security level) customizations
have been made via functions.php.

Adds test to cover.
Closes #4504
This commit is contained in:
Dan Brown 2023-09-12 12:34:02 +01:00
parent 8e3f8de627
commit 6e098905d4
No known key found for this signature in database
GPG key ID: 46D9F943C24A2EF9
4 changed files with 39 additions and 10 deletions

View file

@ -0,0 +1,7 @@
<?php
namespace BookStack\Exceptions;
class ThemeException extends \Exception
{
}

View file

@ -3,19 +3,23 @@
namespace BookStack\Theming; namespace BookStack\Theming;
use BookStack\Access\SocialAuthService; use BookStack\Access\SocialAuthService;
use BookStack\Exceptions\ThemeException;
use Illuminate\Console\Application; use Illuminate\Console\Application;
use Illuminate\Console\Application as Artisan; use Illuminate\Console\Application as Artisan;
use Symfony\Component\Console\Command\Command; use Symfony\Component\Console\Command\Command;
class ThemeService class ThemeService
{ {
protected $listeners = []; /**
* @var array<string, callable[]>
*/
protected array $listeners = [];
/** /**
* 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.
*/ */
public function listen(string $event, callable $action) public function listen(string $event, callable $action): void
{ {
if (!isset($this->listeners[$event])) { if (!isset($this->listeners[$event])) {
$this->listeners[$event] = []; $this->listeners[$event] = [];
@ -31,10 +35,8 @@ class ThemeService
* *
* If a callback returns a non-null value, this method will * If a callback returns a non-null value, this method will
* stop and return that value itself. * stop and return that value itself.
*
* @return mixed
*/ */
public function dispatch(string $event, ...$args) public function dispatch(string $event, ...$args): mixed
{ {
foreach ($this->listeners[$event] ?? [] as $action) { foreach ($this->listeners[$event] ?? [] as $action) {
$result = call_user_func_array($action, $args); $result = call_user_func_array($action, $args);
@ -49,7 +51,7 @@ class ThemeService
/** /**
* Register a new custom artisan command to be available. * Register a new custom artisan command to be available.
*/ */
public function registerCommand(Command $command) public function registerCommand(Command $command): void
{ {
Artisan::starting(function (Application $application) use ($command) { Artisan::starting(function (Application $application) use ($command) {
$application->addCommands([$command]); $application->addCommands([$command]);
@ -59,18 +61,22 @@ class ThemeService
/** /**
* Read any actions from the set theme path if the 'functions.php' file exists. * Read any actions from the set theme path if the 'functions.php' file exists.
*/ */
public function readThemeActions() public function readThemeActions(): void
{ {
$themeActionsFile = theme_path('functions.php'); $themeActionsFile = theme_path('functions.php');
if ($themeActionsFile && file_exists($themeActionsFile)) { if ($themeActionsFile && file_exists($themeActionsFile)) {
try {
require $themeActionsFile; require $themeActionsFile;
} catch (\Error $exception) {
throw new ThemeException("Failed loading theme functions file at \"{$themeActionsFile}\" with error: {$exception->getMessage()}");
}
} }
} }
/** /**
* @see SocialAuthService::addSocialDriver * @see SocialAuthService::addSocialDriver
*/ */
public function addSocialDriver(string $driverName, array $config, string $socialiteHandler, callable $configureForRedirect = null) public function addSocialDriver(string $driverName, array $config, string $socialiteHandler, callable $configureForRedirect = null): void
{ {
$socialAuthService = app()->make(SocialAuthService::class); $socialAuthService = app()->make(SocialAuthService::class);
$socialAuthService->addSocialDriver($driverName, $config, $socialiteHandler, $configureForRedirect); $socialAuthService->addSocialDriver($driverName, $config, $socialiteHandler, $configureForRedirect);

View file

@ -65,7 +65,9 @@
</div> </div>
@yield('bottom') @yield('bottom')
@if($cspNonce ?? false)
<script src="{{ versioned_asset('dist/app.js') }}" nonce="{{ $cspNonce }}"></script> <script src="{{ versioned_asset('dist/app.js') }}" nonce="{{ $cspNonce }}"></script>
@endif
@yield('scripts') @yield('scripts')
@include('layouts.parts.base-body-end') @include('layouts.parts.base-body-end')

View file

@ -8,6 +8,7 @@ use BookStack\Activity\Models\Webhook;
use BookStack\Entities\Models\Book; use BookStack\Entities\Models\Book;
use BookStack\Entities\Models\Page; use BookStack\Entities\Models\Page;
use BookStack\Entities\Tools\PageContent; use BookStack\Entities\Tools\PageContent;
use BookStack\Exceptions\ThemeException;
use BookStack\Facades\Theme; use BookStack\Facades\Theme;
use BookStack\Theming\ThemeEvents; use BookStack\Theming\ThemeEvents;
use BookStack\Users\Models\User; use BookStack\Users\Models\User;
@ -51,6 +52,19 @@ class ThemeTest extends TestCase
}); });
} }
public function test_theme_functions_loads_errors_are_caught_and_logged()
{
$this->usingThemeFolder(function ($themeFolder) {
$functionsFile = theme_path('functions.php');
file_put_contents($functionsFile, "<?php\n\\BookStack\\Biscuits::eat();");
$this->expectException(ThemeException::class);
$this->expectExceptionMessageMatches('/Failed loading theme functions file at ".*?" with error: Class "BookStack\\\\Biscuits" not found/');
$this->runWithEnv('APP_THEME', $themeFolder, fn() => null);
});
}
public function test_event_commonmark_environment_configure() public function test_event_commonmark_environment_configure()
{ {
$callbackCalled = false; $callbackCalled = false;