Skip to content

Commit f7890c2

Browse files
committed
Theme Modules: Fixes and improvements after manual testing
- Added (limited) redirect handling to module downloads. - Adjusted wording/text for consistency and clarity. - Fixed scenarios where process was not stopped on error. - Fixed module folder creation check/logic. - Added better failed request handling to module downloads. - Updated download response streaming to monitor/limit download size.
1 parent 45ae03c commit f7890c2

File tree

2 files changed

+72
-23
lines changed

2 files changed

+72
-23
lines changed

app/Console/Commands/InstallModuleCommand.php

Lines changed: 70 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
use BookStack\Theming\ThemeModuleException;
88
use BookStack\Theming\ThemeModuleManager;
99
use BookStack\Theming\ThemeModuleZip;
10+
use GuzzleHttp\Psr7\Request;
1011
use Illuminate\Console\Command;
1112
use Illuminate\Support\Str;
1213

@@ -61,6 +62,11 @@ public function handle(): int
6162
// Get the modules folder of the theme, attempting to create it if not existing,
6263
// and create a new module manager instance.
6364
$moduleFolder = $this->getModuleFolder($themeFolder);
65+
if (!$moduleFolder) {
66+
$this->cleanup();
67+
return 1;
68+
}
69+
6470
$manager = new ThemeModuleManager($moduleFolder);
6571

6672
// Handle existing modules with the same name
@@ -80,8 +86,8 @@ public function handle(): int
8086
return 1;
8187
}
8288

83-
$this->info("Module {$newModule->name} ({$newModule->version}) successfully installed!");
84-
$this->info("It has been installed at {$moduleFolder}/{$newModule->folderName}.");
89+
$this->info("Module \"{$newModule->name}\" ({$newModule->version}) successfully installed!");
90+
$this->info("Install location: {$moduleFolder}/{$newModule->folderName}");
8591
$this->cleanup();
8692
return 0;
8793
}
@@ -94,20 +100,20 @@ protected function handleExistingModulesWithSameName(array $existingModules, The
94100

95101
$this->warn("The following modules already exist with the same name:");
96102
foreach ($existingModules as $folder => $module) {
97-
$this->line("{$module->name} ({$folder}:{$module->version}) - {$module->description}}");
103+
$this->line("{$module->name} ({$folder}:{$module->version}) - {$module->description}");
98104
}
99105
$this->line('');
100106

101-
$choices = ['Cancel Module Install', 'Add Alongside Existing'];
107+
$choices = ['Cancel module install', 'Add alongside existing module'];
102108
if (count($existingModules) === 1) {
103-
$choices[] = 'Replace Existing Module';
109+
$choices[] = 'Replace existing module';
104110
}
105111
$choice = $this->choice("What would you like to do?", $choices, 0, null, false);
106-
if ($choice === 'Cancel Module Install') {
112+
if ($choice === 'Cancel module install') {
107113
return false;
108114
}
109115

110-
if ($choice === 'Replace Existing Module') {
116+
if ($choice === 'Replace existing module') {
111117
$existingModuleFolder = array_key_first($existingModules);
112118
$this->info("Replacing existing module in {$existingModuleFolder} folder");
113119
$manager->deleteModuleFolder($existingModuleFolder);
@@ -119,14 +125,17 @@ protected function handleExistingModulesWithSameName(array $existingModules, The
119125
protected function getModuleFolder(string $themeFolder): string|null
120126
{
121127
$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-
}
126128

129+
if (file_exists($path) && !is_dir($path)) {
130+
$this->error("ERROR: Cannot create a modules folder, file already exists at {$path}");
131+
return null;
132+
}
133+
134+
if (!file_exists($path)) {
127135
$created = mkdir($path, 0755, true);
128136
if (!$created) {
129137
$this->error("ERROR: Failed to create a modules folder at {$path}");
138+
return null;
130139
}
131140
}
132141

@@ -150,7 +159,7 @@ protected function getThemeFolder(): string|null
150159
$path = base_path("themes/{$folder}");
151160
$created = mkdir($path, 0755, true);
152161
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.');
162+
$this->error('Failed to create a theme folder to use. This may be a permissions issue. Try manually configuring an active theme');
154163
return null;
155164
}
156165

@@ -169,7 +178,7 @@ protected function validateAndGetModuleInfoFromZip(ThemeModuleZip $zip): ThemeMo
169178
}
170179

171180
if ($zip->getContentsSize() > (50 * 1024 * 1024)) {
172-
$this->error("ERROR: Module ZIP file is too large. Maximum size is 50MB.");
181+
$this->error("ERROR: Module ZIP file is too large. Maximum size is 50MB");
173182
return null;
174183
}
175184

@@ -183,17 +192,57 @@ protected function validateAndGetModuleInfoFromZip(ThemeModuleZip $zip): ThemeMo
183192
return $themeModule;
184193
}
185194

186-
protected function downloadModuleFile(string $location): string
195+
protected function downloadModuleFile(string $location): string|null
187196
{
188197
$httpRequests = app()->make(HttpRequestService::class);
189-
$client = $httpRequests->buildClient(30);
198+
$client = $httpRequests->buildClient(30, ['stream' => true]);
199+
$originalHost = parse_url($location, PHP_URL_HOST);
200+
$currentLocation = $location;
201+
$maxRedirects = 3;
202+
$redirectCount = 0;
203+
204+
// Follow redirects up to 3 times for the same hostname
205+
do {
206+
$resp = $client->sendRequest(new Request('GET', $currentLocation));
207+
$statusCode = $resp->getStatusCode();
208+
209+
if ($statusCode >= 300 && $statusCode < 400 && $redirectCount < $maxRedirects) {
210+
$redirectLocation = $resp->getHeaderLine('Location');
211+
if ($redirectLocation) {
212+
$redirectHost = parse_url($redirectLocation, PHP_URL_HOST);
213+
if ($redirectHost === $originalHost) {
214+
$currentLocation = $redirectLocation;
215+
$redirectCount++;
216+
continue;
217+
}
218+
}
219+
}
190220

191-
$resp = $client->get($location, ['stream' => true]);
221+
break;
222+
} while (true);
223+
224+
if ($resp->getStatusCode() >= 300) {
225+
$this->error("ERROR: Failed to download module from {$location}");
226+
$this->error("Download failed with status code {$resp->getStatusCode()}");
227+
return null;
228+
}
192229

193230
$tempFile = tempnam(sys_get_temp_dir(), 'bookstack_module_');
194231
$fileHandle = fopen($tempFile, 'w');
195-
196-
stream_copy_to_stream($resp->getBody()->detach(), $fileHandle);
232+
$respBody = $resp->getBody();
233+
$size = 0;
234+
$maxSize = 50 * 1024 * 1024;
235+
236+
while (!$respBody->eof()) {
237+
fwrite($fileHandle, $respBody->read(1024));
238+
$size += 1024;
239+
if ($size > $maxSize) {
240+
fclose($fileHandle);
241+
unlink($tempFile);
242+
$this->error("ERROR: Module ZIP file is too large. Maximum size is 50MB");
243+
return '';
244+
}
245+
}
197246

198247
fclose($fileHandle);
199248

@@ -212,16 +261,16 @@ protected function getPathToZip(string $location): string|null
212261
if ($isRemote) {
213262
// Warning about fetching from source
214263
$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.");
264+
$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.");
216265
$trustHost = $this->confirm('Are you sure you trust this source?');
217266
if (!$trustHost) {
218267
return null;
219268
}
220269

221270
// Check if the connection is http. If so, warn the user.
222271
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?')) {
272+
$this->warn("You are downloading a module from an insecure HTTP source.\nWe recommend only using HTTPS sources to avoid various security risks.");
273+
if (!$this->confirm('Are you sure you want to continue without HTTPS?')) {
225274
return null;
226275
}
227276
}

app/Theming/ThemeModuleManager.php

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ public function __construct(
1919
*/
2020
public function getByName(string $name): array
2121
{
22-
return array_filter($this->load(), fn(ThemeModule $module) => $module->getName() === $name);
22+
return array_filter($this->load(), fn(ThemeModule $module) => $module->name === $name);
2323
}
2424

2525
public function deleteModuleFolder(string $moduleFolderName): void
@@ -55,7 +55,7 @@ public function addFromZip(string $name, ThemeModuleZip $zip): ThemeModule
5555

5656
$module = $this->loadFromFolder($folderName);
5757
if (!$module) {
58-
throw new ThemeModuleException("Failed to load module from zip file after extraction.");
58+
throw new ThemeModuleException("Failed to load module from zip file after extraction");
5959
}
6060

6161
return $module;

0 commit comments

Comments
 (0)