Skip to content

Commit 5e12b67

Browse files
authored
Merge pull request #5998 from BookStackApp/further_theme_development
Further theme system developments
2 parents 46dcc30 + 057d7be commit 5e12b67

File tree

55 files changed

+2586
-1096
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

55 files changed

+2586
-1096
lines changed

app/App/Providers/ThemeServiceProvider.php

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@
44

55
use BookStack\Theming\ThemeEvents;
66
use BookStack\Theming\ThemeService;
7+
use BookStack\Theming\ThemeViews;
8+
use Illuminate\Support\Facades\Blade;
79
use Illuminate\Support\ServiceProvider;
810

911
class ThemeServiceProvider extends ServiceProvider
@@ -24,7 +26,26 @@ public function boot(): void
2426
{
2527
// Boot up the theme system
2628
$themeService = $this->app->make(ThemeService::class);
29+
$viewFactory = $this->app->make('view');
30+
$themeViews = new ThemeViews($viewFactory->getFinder());
31+
32+
// Use a custom include so that we can insert theme views before/after includes.
33+
// This is done, even if no theme is active, so that view caching does not create problems
34+
// when switching between themes or when switching a theme on/off.
35+
$viewFactory->share('__themeViews', $themeViews);
36+
Blade::directive('include', function ($expression) {
37+
return "<?php echo \$__themeViews->handleViewInclude({$expression}, array_diff_key(get_defined_vars(), ['__data' => 1, '__path' => 1])); ?>";
38+
});
39+
40+
if (!$themeService->getTheme()) {
41+
return;
42+
}
43+
44+
$themeService->loadModules();
2745
$themeService->readThemeActions();
2846
$themeService->dispatch(ThemeEvents::APP_BOOT, $this->app);
47+
48+
$themeViews->registerViewPathsForTheme($themeService->getModules());
49+
$themeService->dispatch(ThemeEvents::THEME_REGISTER_VIEWS, $themeViews);
2950
}
3051
}

app/App/helpers.php

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -81,8 +81,7 @@ function setting(?string $key = null, mixed $default = null): mixed
8181

8282
/**
8383
* Get a path to a theme resource.
84-
* Returns null if a theme is not configured and
85-
* therefore a full path is not available for use.
84+
* Returns null if a theme is not configured, and therefore a full path is not available for use.
8685
*/
8786
function theme_path(string $path = ''): ?string
8887
{

app/Config/view.php

Lines changed: 1 addition & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -8,12 +8,6 @@
88
* Do not edit this file unless you're happy to maintain any changes yourself.
99
*/
1010

11-
// Join up possible view locations
12-
$viewPaths = [realpath(base_path('resources/views'))];
13-
if ($theme = env('APP_THEME', false)) {
14-
array_unshift($viewPaths, base_path('themes/' . $theme));
15-
}
16-
1711
return [
1812

1913
// App theme
@@ -26,7 +20,7 @@
2620
// Most templating systems load templates from disk. Here you may specify
2721
// an array of paths that should be checked for your views. Of course
2822
// the usual Laravel view path has already been registered for you.
29-
'paths' => $viewPaths,
23+
'paths' => [realpath(base_path('resources/views'))],
3024

3125
// Compiled View Path
3226
// This option determines where all the compiled Blade templates will be
Lines changed: 305 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,305 @@
1+
<?php
2+
3+
namespace BookStack\Console\Commands;
4+
5+
use BookStack\Http\HttpRequestService;
6+
use BookStack\Theming\ThemeModule;
7+
use BookStack\Theming\ThemeModuleException;
8+
use BookStack\Theming\ThemeModuleManager;
9+
use BookStack\Theming\ThemeModuleZip;
10+
use GuzzleHttp\Psr7\Request;
11+
use Illuminate\Console\Command;
12+
use Illuminate\Support\Str;
13+
14+
class InstallModuleCommand extends Command
15+
{
16+
/**
17+
* The name and signature of the console command.
18+
*
19+
* @var string
20+
*/
21+
protected $signature = 'bookstack:install-module
22+
{location : The URL or path of the module file}';
23+
24+
/**
25+
* The console command description.
26+
*
27+
* @var string
28+
*/
29+
protected $description = 'Install a module to the currently configured theme';
30+
31+
protected array $cleanupActions = [];
32+
33+
/**
34+
* Execute the console command.
35+
*/
36+
public function handle(): int
37+
{
38+
$location = $this->argument('location');
39+
40+
// Get the ZIP file containing the module files
41+
$zipPath = $this->getPathToZip($location);
42+
if (!$zipPath) {
43+
$this->cleanup();
44+
return 1;
45+
}
46+
47+
// Validate module zip file (metadata, size, etc...) and get module instance
48+
$zip = new ThemeModuleZip($zipPath);
49+
$themeModule = $this->validateAndGetModuleInfoFromZip($zip);
50+
if (!$themeModule) {
51+
$this->cleanup();
52+
return 1;
53+
}
54+
55+
// Get the theme folder in use, attempting to create one if no active theme in use
56+
$themeFolder = $this->getThemeFolder();
57+
if (!$themeFolder) {
58+
$this->cleanup();
59+
return 1;
60+
}
61+
62+
// Get the modules folder of the theme, attempting to create it if not existing,
63+
// and create a new module manager instance.
64+
$moduleFolder = $this->getModuleFolder($themeFolder);
65+
if (!$moduleFolder) {
66+
$this->cleanup();
67+
return 1;
68+
}
69+
70+
$manager = new ThemeModuleManager($moduleFolder);
71+
72+
// Handle existing modules with the same name
73+
$exitingModulesWithName = $manager->getByName($themeModule->name);
74+
$shouldContinue = $this->handleExistingModulesWithSameName($exitingModulesWithName, $manager);
75+
if (!$shouldContinue) {
76+
$this->cleanup();
77+
return 1;
78+
}
79+
80+
// Extract module ZIP into the theme modules folder
81+
try {
82+
$newModule = $manager->addFromZip($themeModule->name, $zip);
83+
} catch (ThemeModuleException $exception) {
84+
$this->error("ERROR: Failed to install module with error: {$exception->getMessage()}");
85+
$this->cleanup();
86+
return 1;
87+
}
88+
89+
$this->info("Module \"{$newModule->name}\" ({$newModule->getVersion()}) successfully installed!");
90+
$this->info("Install location: {$moduleFolder}/{$newModule->folderName}");
91+
$this->cleanup();
92+
return 0;
93+
}
94+
95+
/**
96+
* @param ThemeModule[] $existingModules
97+
*/
98+
protected function handleExistingModulesWithSameName(array $existingModules, ThemeModuleManager $manager): bool
99+
{
100+
if (count($existingModules) === 0) {
101+
return true;
102+
}
103+
104+
$this->warn("The following modules already exist with the same name:");
105+
foreach ($existingModules as $folder => $module) {
106+
$this->line("{$module->name} ({$folder}:{$module->getVersion()}) - {$module->description}");
107+
}
108+
$this->line('');
109+
110+
$choices = ['Cancel module install', 'Add alongside existing module'];
111+
if (count($existingModules) === 1) {
112+
$choices[] = 'Replace existing module';
113+
}
114+
$choice = $this->choice("What would you like to do?", $choices, 0, null, false);
115+
if ($choice === 'Cancel module install') {
116+
return false;
117+
}
118+
119+
if ($choice === 'Replace existing module') {
120+
$existingModuleFolder = array_key_first($existingModules);
121+
$this->info("Replacing existing module in {$existingModuleFolder} folder");
122+
$manager->deleteModuleFolder($existingModuleFolder);
123+
}
124+
125+
return true;
126+
}
127+
128+
protected function getModuleFolder(string $themeFolder): string|null
129+
{
130+
$path = $themeFolder . DIRECTORY_SEPARATOR . 'modules';
131+
132+
if (file_exists($path) && !is_dir($path)) {
133+
$this->error("ERROR: Cannot create a modules folder, file already exists at {$path}");
134+
return null;
135+
}
136+
137+
if (!file_exists($path)) {
138+
$created = mkdir($path, 0755, true);
139+
if (!$created) {
140+
$this->error("ERROR: Failed to create a modules folder at {$path}");
141+
return null;
142+
}
143+
}
144+
145+
return $path;
146+
}
147+
148+
protected function getThemeFolder(): string|null
149+
{
150+
$path = theme_path('');
151+
if (!$path || !is_dir($path)) {
152+
$shouldCreate = $this->confirm('No active theme folder found, would you like to create one?');
153+
if (!$shouldCreate) {
154+
return null;
155+
}
156+
157+
$folder = 'custom';
158+
while (file_exists(base_path("themes" . DIRECTORY_SEPARATOR . $folder))) {
159+
$folder = 'custom-' . Str::random(4);
160+
}
161+
162+
$path = base_path("themes/{$folder}");
163+
$created = mkdir($path, 0755, true);
164+
if (!$created) {
165+
$this->error('Failed to create a theme folder to use. This may be a permissions issue. Try manually configuring an active theme');
166+
return null;
167+
}
168+
169+
$this->info("Created theme folder at {$path}");
170+
$this->warn("You will need to set APP_THEME={$folder} in your BookStack env configuration to enable this theme!");
171+
}
172+
173+
return $path;
174+
}
175+
176+
protected function validateAndGetModuleInfoFromZip(ThemeModuleZip $zip): ThemeModule|null
177+
{
178+
if (!$zip->exists()) {
179+
$this->error("ERROR: Cannot open ZIP file at {$zip->getPath()}");
180+
return null;
181+
}
182+
183+
if ($zip->getContentsSize() > (50 * 1024 * 1024)) {
184+
$this->error("ERROR: Module ZIP file contents are too large. Maximum size is 50MB");
185+
return null;
186+
}
187+
188+
try {
189+
$themeModule = $zip->getModuleInstance();
190+
} catch (ThemeModuleException $exception) {
191+
$this->error("ERROR: Failed to read module metadata with error: {$exception->getMessage()}");
192+
return null;
193+
}
194+
195+
return $themeModule;
196+
}
197+
198+
protected function downloadModuleFile(string $location): string|null
199+
{
200+
$httpRequests = app()->make(HttpRequestService::class);
201+
$client = $httpRequests->buildClient(30, ['stream' => true]);
202+
$originalUrl = parse_url($location);
203+
$currentLocation = $location;
204+
$maxRedirects = 3;
205+
$redirectCount = 0;
206+
207+
// Follow redirects up to 3 times for the same hostname
208+
do {
209+
$resp = $client->sendRequest(new Request('GET', $currentLocation));
210+
$statusCode = $resp->getStatusCode();
211+
212+
if ($statusCode >= 300 && $statusCode < 400 && $redirectCount < $maxRedirects) {
213+
$redirectLocation = $resp->getHeaderLine('Location');
214+
if ($redirectLocation) {
215+
$redirectUrl = parse_url($redirectLocation);
216+
if (
217+
($originalUrl['host'] ?? '') === ($redirectUrl['host'] ?? '')
218+
&& ($originalUrl['scheme'] ?? '') === ($redirectUrl['scheme'] ?? '')
219+
&& ($originalUrl['port'] ?? '') === ($redirectUrl['port'] ?? '')
220+
) {
221+
$currentLocation = $redirectLocation;
222+
$redirectCount++;
223+
continue;
224+
}
225+
}
226+
}
227+
228+
break;
229+
} while (true);
230+
231+
if ($resp->getStatusCode() >= 300) {
232+
$this->error("ERROR: Failed to download module from {$location}");
233+
$this->error("Download failed with status code {$resp->getStatusCode()}");
234+
return null;
235+
}
236+
237+
$tempFile = tempnam(sys_get_temp_dir(), 'bookstack_module_');
238+
$fileHandle = fopen($tempFile, 'w');
239+
$respBody = $resp->getBody();
240+
$size = 0;
241+
$maxSize = 50 * 1024 * 1024;
242+
243+
while (!$respBody->eof()) {
244+
fwrite($fileHandle, $respBody->read(1024));
245+
$size += 1024;
246+
if ($size > $maxSize) {
247+
fclose($fileHandle);
248+
unlink($tempFile);
249+
$this->error("ERROR: Module ZIP file is too large. Maximum size is 50MB");
250+
return '';
251+
}
252+
}
253+
254+
fclose($fileHandle);
255+
256+
$this->cleanupActions[] = function () use ($tempFile) {
257+
unlink($tempFile);
258+
};
259+
260+
return $tempFile;
261+
}
262+
263+
protected function getPathToZip(string $location): string|null
264+
{
265+
$lowerLocation = strtolower($location);
266+
$isRemote = str_starts_with($lowerLocation, 'http://') || str_starts_with($lowerLocation, 'https://');
267+
268+
if ($isRemote) {
269+
// Warning about fetching from source
270+
$host = parse_url($location, PHP_URL_HOST);
271+
$this->warn("This will download a module from {$host}. Modules can contain code which would have the ability to do anything on the BookStack host server.\nYou should only install modules from trusted sources.");
272+
$trustHost = $this->confirm('Are you sure you trust this source?');
273+
if (!$trustHost) {
274+
return null;
275+
}
276+
277+
// Check if the connection is http. If so, warn the user.
278+
if (str_starts_with($lowerLocation, 'http://')) {
279+
$this->warn("You are downloading a module from an insecure HTTP source.\nWe recommend only using HTTPS sources to avoid various security risks.");
280+
if (!$this->confirm('Are you sure you want to continue without HTTPS?')) {
281+
return null;
282+
}
283+
}
284+
285+
// Download ZIP and get its location
286+
return $this->downloadModuleFile($location);
287+
}
288+
289+
// Validate file and get full location
290+
$zipPath = realpath($location);
291+
if (!$zipPath || !is_file($zipPath)) {
292+
$this->error("ERROR: Module file not found at {$location}");
293+
return null;
294+
}
295+
296+
return $zipPath;
297+
}
298+
299+
protected function cleanup(): void
300+
{
301+
foreach ($this->cleanupActions as $action) {
302+
$action();
303+
}
304+
}
305+
}

app/Theming/ThemeController.php

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,21 +5,22 @@
55
use BookStack\Facades\Theme;
66
use BookStack\Http\Controller;
77
use BookStack\Util\FilePathNormalizer;
8+
use Symfony\Component\HttpFoundation\StreamedResponse;
89

910
class ThemeController extends Controller
1011
{
1112
/**
1213
* Serve a public file from the configured theme.
1314
*/
14-
public function publicFile(string $theme, string $path)
15+
public function publicFile(string $theme, string $path): StreamedResponse
1516
{
1617
$cleanPath = FilePathNormalizer::normalize($path);
1718
if ($theme !== Theme::getTheme() || !$cleanPath) {
1819
abort(404);
1920
}
2021

21-
$filePath = theme_path("public/{$cleanPath}");
22-
if (!file_exists($filePath)) {
22+
$filePath = Theme::findFirstFile("public/{$cleanPath}");
23+
if (!$filePath) {
2324
abort(404);
2425
}
2526

0 commit comments

Comments
 (0)