From a4a2f03ae4fa7e008334be94a4969c3cde879473 Mon Sep 17 00:00:00 2001 From: AstonishingStone Date: Wed, 28 May 2025 12:51:07 +0200 Subject: [PATCH] Added a new setting to configure mermaid version --- app/Config/setting-defaults.php | 1 + app/Plugins/MermaidProvider.php | 63 ++++++++++ app/Settings/Plugins/MermaidController.php | 57 +++++++++ lang/en/settings.php | 2 + .../views/exports/parts/mermaid-js.blade.php | 24 ++++ resources/views/layouts/base.blade.php | 3 + resources/views/layouts/export.blade.php | 2 + .../views/layouts/parts/mermaid-js.blade.php | 25 ++++ resources/views/layouts/plain.blade.php | 3 + .../categories/customization.blade.php | 84 ++++++++++++- routes/web.php | 30 ++--- tests/MermaidHeaderTest.php | 56 +++++++++ tests/Plugins/MermaidProviderTest.php | 111 ++++++++++++++++++ 13 files changed, 446 insertions(+), 15 deletions(-) create mode 100644 app/Plugins/MermaidProvider.php create mode 100644 app/Settings/Plugins/MermaidController.php create mode 100644 resources/views/exports/parts/mermaid-js.blade.php create mode 100644 resources/views/layouts/parts/mermaid-js.blade.php create mode 100644 tests/MermaidHeaderTest.php create mode 100644 tests/Plugins/MermaidProviderTest.php diff --git a/app/Config/setting-defaults.php b/app/Config/setting-defaults.php index 88c4612ca61..7b9210a176b 100644 --- a/app/Config/setting-defaults.php +++ b/app/Config/setting-defaults.php @@ -32,6 +32,7 @@ 'page-draft-color-dark' => '#a66ce8', 'app-custom-head' => false, 'registration-enabled' => false, + 'enable-mermaid' => 'disabled', // User-level default settings 'user' => [ diff --git a/app/Plugins/MermaidProvider.php b/app/Plugins/MermaidProvider.php new file mode 100644 index 00000000000..26551f687f0 --- /dev/null +++ b/app/Plugins/MermaidProvider.php @@ -0,0 +1,63 @@ + 'application/vnd.github+json', + 'User-Agent' => 'BookStack', + ])->get(MermaidProvider::MERMAID_REPOSITORY); + + if ($response->successful()) { + $versions = collect($response->json()) + ->pluck('name') + ->all(); + + Cache::put($cacheKey, $versions, now()->addHours(12)); + + return array_merge(['disabled', 'latest'], $versions); + } + + return ['disabled', 'latest']; + } + + /** + * Get the MermaidJS CDN URI to use. + */ + public function getMermaidJsCdnUri(): string + { + $mermaidJsVersion = setting('enable-mermaid'); + + if ($mermaidJsVersion === 'disabled') { + return ''; + } + + $localPath = public_path("mermaid/mermaid.min.js"); + + if (file_exists($localPath)) { + return asset("mermaid/mermaid.min.js"); + } + + return ''; + } + +} diff --git a/app/Settings/Plugins/MermaidController.php b/app/Settings/Plugins/MermaidController.php new file mode 100644 index 00000000000..79238d211f1 --- /dev/null +++ b/app/Settings/Plugins/MermaidController.php @@ -0,0 +1,57 @@ +input('version'); + + if (!$version || $version == 'disabled') { + return response()->json([ + 'success' => false, + 'message' => 'No version specified.' + ], Response::HTTP_BAD_REQUEST); + } + + $remoteUrl = sprintf(MermaidProvider::MERMAID_CDN, $version); + $localDirectory = public_path('mermaid'); + $localFilename = "mermaid.min.js"; + $localPath = $localDirectory . '/' . $localFilename; + + if (!File::exists($localDirectory)) { + File::makeDirectory($localDirectory, 0755, true); + } + + try { + $response = Http::get($remoteUrl); + + if ($response->successful()) { + File::put($localPath, $response->body()); + + return response()->json([ + 'success' => true, + 'path' => asset("mermaid/$localFilename") + ]); + } else { + return response()->json([ + 'success' => false, + 'message' => 'Remote file not found.' + ], Response::HTTP_NOT_FOUND); + } + } catch (\Exception $e) { + return response()->json([ + 'success' => false, + 'message' => $e->getMessage() + ], Response::HTTP_INTERNAL_SERVER_ERROR); + } + } +} diff --git a/lang/en/settings.php b/lang/en/settings.php index 82a4ade5df5..5a984d90639 100644 --- a/lang/en/settings.php +++ b/lang/en/settings.php @@ -46,6 +46,8 @@ 'app_disable_comments' => 'Disable Comments', 'app_disable_comments_toggle' => 'Disable comments', 'app_disable_comments_desc' => 'Disables comments across all pages in the application.
Existing comments are not shown.', + 'enable_mermaid' => 'Mermaid.js Version', + 'enable_mermaid_desc' => 'Select the version of Mermaid.js to use when rendering diagrams. This allows compatibility with different diagram syntax versions and helps ensure consistent behavior across pages. Updating to a newer version may enable new features or fix rendering issues, but could also affect existing diagrams.', // Color settings 'color_scheme' => 'Application Color Scheme', diff --git a/resources/views/exports/parts/mermaid-js.blade.php b/resources/views/exports/parts/mermaid-js.blade.php new file mode 100644 index 00000000000..876b6f39176 --- /dev/null +++ b/resources/views/exports/parts/mermaid-js.blade.php @@ -0,0 +1,24 @@ +@inject('mermaidProvider', 'BookStack\Plugins\MermaidProvider') + +@if(setting('enable-mermaid') != 'disabled') + + + +@endif \ No newline at end of file diff --git a/resources/views/layouts/base.blade.php b/resources/views/layouts/base.blade.php index a6d908fc06c..b060261d3cb 100644 --- a/resources/views/layouts/base.blade.php +++ b/resources/views/layouts/base.blade.php @@ -43,6 +43,9 @@ class="{{ setting()->getForCurrentUser('dark-mode-enabled') ? 'dark-mode ' : '' @stack('translations') + + + @include('layouts.parts.mermaid-js') getForCurrentUser('ui-shortcuts-enabled', false)) diff --git a/resources/views/layouts/export.blade.php b/resources/views/layouts/export.blade.php index 4a55e034c5d..4ffdca2fbf0 100644 --- a/resources/views/layouts/export.blade.php +++ b/resources/views/layouts/export.blade.php @@ -10,6 +10,8 @@ @include('exports.parts.styles', ['format' => $format, 'engine' => $engine ?? '']) @include('exports.parts.custom-head') + + @include('layouts.parts.mermaid-js') @include('layouts.parts.export-body-start') diff --git a/resources/views/layouts/parts/mermaid-js.blade.php b/resources/views/layouts/parts/mermaid-js.blade.php new file mode 100644 index 00000000000..c25eac038f6 --- /dev/null +++ b/resources/views/layouts/parts/mermaid-js.blade.php @@ -0,0 +1,25 @@ +@inject('mermaidProvider', 'BookStack\Plugins\MermaidProvider') + +@if(setting('enable-mermaid') != 'disabled' && !request()->routeIs('settings.category')) + + + + +@endif \ No newline at end of file diff --git a/resources/views/layouts/plain.blade.php b/resources/views/layouts/plain.blade.php index 37ebbe83d94..67d23faf191 100644 --- a/resources/views/layouts/plain.blade.php +++ b/resources/views/layouts/plain.blade.php @@ -15,6 +15,9 @@ class="@yield('document-class')"> @include('layouts.parts.custom-styles') @include('layouts.parts.custom-head') + + + @include('layouts.parts.mermaid-js') @yield('content') diff --git a/resources/views/settings/categories/customization.blade.php b/resources/views/settings/categories/customization.blade.php index 70a490298c7..070fca10839 100644 --- a/resources/views/settings/categories/customization.blade.php +++ b/resources/views/settings/categories/customization.blade.php @@ -1,5 +1,27 @@ @extends('settings.layout') +@inject('mermaidProvider', 'BookStack\Plugins\MermaidProvider') + + + + @section('card')

{{ trans('settings.app_customization') }}

@@ -165,12 +187,72 @@ class="simple-code-input">{{ setting('app-custom-head', '') }} +
+
+ +

{{ trans('settings.enable_mermaid_desc') }}

+
+
+ +
+
+ +
- +
+ + @endsection @section('after-content') diff --git a/routes/web.php b/routes/web.php index ea3efe1ac77..8d25f343e3c 100644 --- a/routes/web.php +++ b/routes/web.php @@ -1,25 +1,26 @@ name('settings'); Route::get('/settings/{category}', [SettingControllers\SettingController::class, 'category'])->name('settings.category'); Route::post('/settings/{category}', [SettingControllers\SettingController::class, 'update']); + Route::post('/settings/plugins/mermaid/download', [SettingControllers\Plugins\MermaidController::class, 'store'])->name('settings.plugins.mermaid.download'); }); // MFA routes diff --git a/tests/MermaidHeaderTest.php b/tests/MermaidHeaderTest.php new file mode 100644 index 00000000000..6818cfb2c9c --- /dev/null +++ b/tests/MermaidHeaderTest.php @@ -0,0 +1,56 @@ +setSettings(['enable-mermaid' => '1111']); + + $page = $this->entities->pageWithinChapter(); + $resp = $this->asAdmin()->get($page->getUrl()); + $scriptHeader = $this->getCspHeader($resp, 'script-src'); + + $nonce = app()->make(CspService::class)->getNonce(); + $this->assertStringContainsString('nonce-'.$nonce, $scriptHeader); + $resp->assertSee( + '', + false + ); + } + public function test_script_mermaid_not_include_if_setting_value_is_disabled() + { + $this->setSettings(['enable-mermaid' => 'disabled']); + + $page = $this->entities->pageWithinChapter(); + $resp = $this->asAdmin()->get($page->getUrl()); + $scriptHeader = $this->getCspHeader($resp, 'script-src'); + + $nonce = app()->make(CspService::class)->getNonce(); + $this->assertStringContainsString('nonce-'.$nonce, $scriptHeader); + $resp->assertDontSee( + '', + false + ); + } + + /** + * Get the value of the first CSP header of the given type. + */ + protected function getCspHeader(TestResponse $resp, string $type): string + { + $cspHeaders = explode('; ', $resp->headers->get('Content-Security-Policy')); + + foreach ($cspHeaders as $cspHeader) { + if (strpos($cspHeader, $type) === 0) { + return $cspHeader; + } + } + + return ''; + } +} \ No newline at end of file diff --git a/tests/Plugins/MermaidProviderTest.php b/tests/Plugins/MermaidProviderTest.php new file mode 100644 index 00000000000..519643923d1 --- /dev/null +++ b/tests/Plugins/MermaidProviderTest.php @@ -0,0 +1,111 @@ + Http::response([ + ['name' => 'v10.0.0'], + ['name' => 'v9.4.0'], + ]), + ]); + + $provider = new MermaidProvider(); + $versions = $provider->getMermaidVersions(); + + $this->assertContains('disabled', $versions); + $this->assertContains('latest', $versions); + $this->assertContains('v10.0.0', $versions); + $this->assertContains('v9.4.0', $versions); + } + + public function test_returns_cached_version_if_available() + { + Cache::shouldReceive('get') + ->once() + ->with('git_mermaid_versions') + ->andReturn(['v1.0.0', 'v2.0.0']); + + $provider = new MermaidProvider(); + $versions = $provider->getMermaidVersions(); + + $this->assertEquals(['disabled', 'latest', 'v1.0.0', 'v2.0.0'], $versions); + } + + public function test_fetches_versions_and_caches_on_successful_response() + { + Cache::shouldReceive('get') + ->once() + ->with('git_mermaid_versions') + ->andReturn(null); + + Cache::shouldReceive('put') + ->once() + ->withArgs(function ($key, $value, $ttl) { + return $key === 'git_mermaid_versions' + && in_array('v10.0.0', $value) + && $ttl->greaterThan(now()); + }); + + Http::fake([ + MermaidProvider::MERMAID_REPOSITORY => Http::response([ + ['name' => 'v10.0.0'], + ['name' => 'v9.4.0'], + ]), + ]); + + $provider = new MermaidProvider(); + $versions = $provider->getMermaidVersions(); + + $this->assertContains('v10.0.0', $versions); + $this->assertContains('disabled', $versions); + $this->assertContains('latest', $versions); + } + + public function test_does_not_cache_on_failed_response() + { + Cache::shouldReceive('get') + ->once() + ->with('git_mermaid_versions') + ->andReturn(null); + + Cache::shouldReceive('put')->never(); + + Http::fake([ + '*' => Http::response(null, 500), + ]); + + $provider = new MermaidProvider(); + $versions = $provider->getMermaidVersions(); + + $this->assertEquals(['disabled', 'latest'], $versions); + } + + public function test_returns_correct_mermaid_cdn_uri() + { + $this->setSettings(['enable-mermaid' => '10.1.0']); + + $provider = new MermaidProvider(); + $uri = $provider->getMermaidJsCdnUri(); + + $this->assertEquals('http://localhost/mermaid/mermaid.min.js', $uri); + } + + public function test_returns_empty_string_if_mermaid_disabled() + { + $this->setSettings(['enable-mermaid' => 'disabled']); + + $provider = new MermaidProvider(); + $uri = $provider->getMermaidJsCdnUri(); + + $this->assertEquals('', $uri); + } +} \ No newline at end of file