Skip to content

Commit 45ae03c

Browse files
committed
Theme Modules: Added install helper command
Not yet tested at all, either manually or via PHPUnit
1 parent aa0a8dd commit 45ae03c

File tree

7 files changed

+498
-38
lines changed

7 files changed

+498
-38
lines changed
Lines changed: 249 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,249 @@
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 Illuminate\Console\Command;
11+
use Illuminate\Support\Str;
12+
13+
class InstallModuleCommand extends Command
14+
{
15+
/**
16+
* The name and signature of the console command.
17+
*
18+
* @var string
19+
*/
20+
protected $signature = 'bookstack:install-module
21+
{location : The URL or path of the module file}';
22+
23+
/**
24+
* The console command description.
25+
*
26+
* @var string
27+
*/
28+
protected $description = 'Install a module to the currently configured theme';
29+
30+
protected array $cleanupActions = [];
31+
32+
/**
33+
* Execute the console command.
34+
*/
35+
public function handle(): int
36+
{
37+
$location = $this->argument('location');
38+
39+
// Get the ZIP file containing the module files
40+
$zipPath = $this->getPathToZip($location);
41+
if (!$zipPath) {
42+
$this->cleanup();
43+
return 1;
44+
}
45+
46+
// Validate module zip file (metadata, size, etc...) and get module instance
47+
$zip = new ThemeModuleZip($zipPath);
48+
$themeModule = $this->validateAndGetModuleInfoFromZip($zip);
49+
if (!$themeModule) {
50+
$this->cleanup();
51+
return 1;
52+
}
53+
54+
// Get the theme folder in use, attempting to create one if no active theme in use
55+
$themeFolder = $this->getThemeFolder();
56+
if (!$themeFolder) {
57+
$this->cleanup();
58+
return 1;
59+
}
60+
61+
// Get the modules folder of the theme, attempting to create it if not existing,
62+
// and create a new module manager instance.
63+
$moduleFolder = $this->getModuleFolder($themeFolder);
64+
$manager = new ThemeModuleManager($moduleFolder);
65+
66+
// Handle existing modules with the same name
67+
$exitingModulesWithName = $manager->getByName($themeModule->name);
68+
$shouldContinue = $this->handleExistingModulesWithSameName($exitingModulesWithName, $manager);
69+
if (!$shouldContinue) {
70+
$this->cleanup();
71+
return 1;
72+
}
73+
74+
// Extract module ZIP into the theme modules folder
75+
try {
76+
$newModule = $manager->addFromZip($themeModule->name, $zip);
77+
} catch (ThemeModuleException $exception) {
78+
$this->error("ERROR: Failed to install module with error: {$exception->getMessage()}");
79+
$this->cleanup();
80+
return 1;
81+
}
82+
83+
$this->info("Module {$newModule->name} ({$newModule->version}) successfully installed!");
84+
$this->info("It has been installed at {$moduleFolder}/{$newModule->folderName}.");
85+
$this->cleanup();
86+
return 0;
87+
}
88+
89+
protected function handleExistingModulesWithSameName(array $existingModules, ThemeModuleManager $manager): bool
90+
{
91+
if (count($existingModules) === 0) {
92+
return true;
93+
}
94+
95+
$this->warn("The following modules already exist with the same name:");
96+
foreach ($existingModules as $folder => $module) {
97+
$this->line("{$module->name} ({$folder}:{$module->version}) - {$module->description}}");
98+
}
99+
$this->line('');
100+
101+
$choices = ['Cancel Module Install', 'Add Alongside Existing'];
102+
if (count($existingModules) === 1) {
103+
$choices[] = 'Replace Existing Module';
104+
}
105+
$choice = $this->choice("What would you like to do?", $choices, 0, null, false);
106+
if ($choice === 'Cancel Module Install') {
107+
return false;
108+
}
109+
110+
if ($choice === 'Replace Existing Module') {
111+
$existingModuleFolder = array_key_first($existingModules);
112+
$this->info("Replacing existing module in {$existingModuleFolder} folder");
113+
$manager->deleteModuleFolder($existingModuleFolder);
114+
}
115+
116+
return true;
117+
}
118+
119+
protected function getModuleFolder(string $themeFolder): string|null
120+
{
121+
$path = $themeFolder . DIRECTORY_SEPARATOR . 'modules';
122+
if (!file_exists($path)) {
123+
if (!is_dir($path)) {
124+
$this->error("ERROR: Cannot create a modules folder, file already exists at {$path}");
125+
}
126+
127+
$created = mkdir($path, 0755, true);
128+
if (!$created) {
129+
$this->error("ERROR: Failed to create a modules folder at {$path}");
130+
}
131+
}
132+
133+
return $path;
134+
}
135+
136+
protected function getThemeFolder(): string|null
137+
{
138+
$path = theme_path('');
139+
if (!$path) {
140+
$shouldCreate = $this->confirm('No active theme folder found, would you like to create one?');
141+
if (!$shouldCreate) {
142+
return null;
143+
}
144+
145+
$folder = 'custom';
146+
while (file_exists(base_path("themes" . DIRECTORY_SEPARATOR . $folder))) {
147+
$folder = 'custom-' . Str::random(4);
148+
}
149+
150+
$path = base_path("themes/{$folder}");
151+
$created = mkdir($path, 0755, true);
152+
if (!$created) {
153+
$this->error('Failed to create a theme folder to use. This may be a permissions issue. Try manually configuring an active theme.');
154+
return null;
155+
}
156+
157+
$this->info("Created theme folder at {$path}");
158+
$this->warn("You will need to set APP_THEME={$folder} in your BookStack env configuration to enable this theme!");
159+
}
160+
161+
return $path;
162+
}
163+
164+
protected function validateAndGetModuleInfoFromZip(ThemeModuleZip $zip): ThemeModule|null
165+
{
166+
if (!$zip->exists()) {
167+
$this->error("ERROR: Cannot open ZIP file at {$zip->getPath()}");
168+
return null;
169+
}
170+
171+
if ($zip->getContentsSize() > (50 * 1024 * 1024)) {
172+
$this->error("ERROR: Module ZIP file is too large. Maximum size is 50MB.");
173+
return null;
174+
}
175+
176+
try {
177+
$themeModule = $zip->getModuleInstance();
178+
} catch (ThemeModuleException $exception) {
179+
$this->error("ERROR: Failed to read module metadata with error: {$exception->getMessage()}");
180+
return null;
181+
}
182+
183+
return $themeModule;
184+
}
185+
186+
protected function downloadModuleFile(string $location): string
187+
{
188+
$httpRequests = app()->make(HttpRequestService::class);
189+
$client = $httpRequests->buildClient(30);
190+
191+
$resp = $client->get($location, ['stream' => true]);
192+
193+
$tempFile = tempnam(sys_get_temp_dir(), 'bookstack_module_');
194+
$fileHandle = fopen($tempFile, 'w');
195+
196+
stream_copy_to_stream($resp->getBody()->detach(), $fileHandle);
197+
198+
fclose($fileHandle);
199+
200+
$this->cleanupActions[] = function () use ($tempFile) {
201+
unlink($tempFile);
202+
};
203+
204+
return $tempFile;
205+
}
206+
207+
protected function getPathToZip(string $location): string|null
208+
{
209+
$lowerLocation = strtolower($location);
210+
$isRemote = str_starts_with($lowerLocation, 'http://') || str_starts_with($lowerLocation, 'https://');
211+
212+
if ($isRemote) {
213+
// Warning about fetching from source
214+
$host = parse_url($location, PHP_URL_HOST);
215+
$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.");
216+
$trustHost = $this->confirm('Are you sure you trust this source?');
217+
if (!$trustHost) {
218+
return null;
219+
}
220+
221+
// Check if the connection is http. If so, warn the user.
222+
if (str_starts_with($lowerLocation, 'http://')) {
223+
$this->warn('You are downloading a module from an insecure HTTP source. We recommend using HTTPS sources.');
224+
if (!$this->confirm('Do you wish to continue?')) {
225+
return null;
226+
}
227+
}
228+
229+
// Download ZIP and get its location
230+
return $this->downloadModuleFile($location);
231+
}
232+
233+
// Validate file and get full location
234+
$zipPath = realpath($location);
235+
if (!$zipPath || !is_file($zipPath)) {
236+
$this->error("ERROR: Module file not found at {$location}");
237+
return null;
238+
}
239+
240+
return $zipPath;
241+
}
242+
243+
protected function cleanup(): void
244+
{
245+
foreach ($this->cleanupActions as $action) {
246+
$action();
247+
}
248+
}
249+
}

app/Theming/ThemeModule.php

Lines changed: 12 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -2,46 +2,44 @@
22

33
namespace BookStack\Theming;
44

5-
use BookStack\Exceptions\ThemeException;
6-
75
readonly class ThemeModule
86
{
97
public function __construct(
108
public string $name,
119
public string $description,
12-
public string $folderName,
1310
public string $version,
11+
public string $folderName,
1412
) {
1513
}
1614

1715
/**
1816
* Create a ThemeModule instance from JSON data.
1917
*
20-
* @throws ThemeException
18+
* @throws ThemeModuleException
2119
*/
2220
public static function fromJson(array $data, string $folderName): self
2321
{
2422
if (empty($data['name']) || !is_string($data['name'])) {
25-
throw new ThemeException("Module in folder \"{$folderName}\" is missing a valid 'name' property");
23+
throw new ThemeModuleException("Module in folder \"{$folderName}\" is missing a valid 'name' property");
2624
}
2725

2826
if (!isset($data['description']) || !is_string($data['description'])) {
29-
throw new ThemeException("Module in folder \"{$folderName}\" is missing a valid 'description' property");
27+
throw new ThemeModuleException("Module in folder \"{$folderName}\" is missing a valid 'description' property");
3028
}
3129

3230
if (!isset($data['version']) || !is_string($data['version'])) {
33-
throw new ThemeException("Module in folder \"{$folderName}\" is missing a valid 'version' property");
31+
throw new ThemeModuleException("Module in folder \"{$folderName}\" is missing a valid 'version' property");
3432
}
3533

3634
if (!preg_match('/^v?\d+\.\d+\.\d+(-.*)?$/', $data['version'])) {
37-
throw new ThemeException("Module in folder \"{$folderName}\" has an invalid 'version' format. Expected semantic version format like '1.0.0' or 'v1.0.0'");
35+
throw new ThemeModuleException("Module in folder \"{$folderName}\" has an invalid 'version' format. Expected semantic version format like '1.0.0' or 'v1.0.0'");
3836
}
3937

4038
return new self(
4139
name: $data['name'],
4240
description: $data['description'],
43-
folderName: $folderName,
4441
version: $data['version'],
42+
folderName: $folderName,
4543
);
4644
}
4745

@@ -53,4 +51,9 @@ public function path($path = ''): string
5351
$component = trim($path, '/');
5452
return theme_path("modules/{$this->folderName}/{$component}");
5553
}
54+
55+
public function getVersion(): string
56+
{
57+
return str_starts_with($this->version, 'v') ? $this->version : 'v' . $this->version;
58+
}
5659
}
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
<?php
2+
3+
namespace BookStack\Theming;
4+
5+
class ThemeModuleException extends \Exception
6+
{
7+
}

0 commit comments

Comments
 (0)