diff --git a/app/Console/Commands/InstallModuleCommand.php b/app/Console/Commands/InstallModuleCommand.php index 20252525df8..114bfb105d8 100644 --- a/app/Console/Commands/InstallModuleCommand.php +++ b/app/Console/Commands/InstallModuleCommand.php @@ -213,15 +213,23 @@ protected function downloadModuleFile(string $location): string|null $redirectLocation = $resp->getHeaderLine('Location'); if ($redirectLocation) { $redirectUrl = parse_url($redirectLocation); - if ( - ($originalUrl['host'] ?? '') === ($redirectUrl['host'] ?? '') + $redirectOriginMatches = ($originalUrl['host'] ?? '') === ($redirectUrl['host'] ?? '') && ($originalUrl['scheme'] ?? '') === ($redirectUrl['scheme'] ?? '') - && ($originalUrl['port'] ?? '') === ($redirectUrl['port'] ?? '') - ) { - $currentLocation = $redirectLocation; - $redirectCount++; - continue; + && ($originalUrl['port'] ?? '') === ($redirectUrl['port'] ?? ''); + + if (!$redirectOriginMatches) { + $redirectOrigin = ($redirectUrl['scheme'] ?? '') . '://' . ($redirectUrl['host'] ?? '') . (isset($redirectUrl['port']) ? ':' . $redirectUrl['port'] : ''); + $this->info("The download URL is redirecting to a different site: {$redirectOrigin}"); + $shouldContinue = $this->confirm("Do you trust downloading the module from this site?"); + if (!$shouldContinue) { + $this->error("Stopping module installation"); + return null; + } } + + $currentLocation = $redirectLocation; + $redirectCount++; + continue; } } diff --git a/app/Theming/ThemeModuleZip.php b/app/Theming/ThemeModuleZip.php index 7029fa0c6a0..4785abadbff 100644 --- a/app/Theming/ThemeModuleZip.php +++ b/app/Theming/ThemeModuleZip.php @@ -15,7 +15,41 @@ public function extractTo(string $destinationPath): void { $zip = new ZipArchive(); $zip->open($this->path); - $zip->extractTo($destinationPath); + $prefix = $this->getZipContentPrefix($zip); + + for ($i = 0; $i < $zip->numFiles; $i++) { + $name = $zip->getNameIndex($i); + $entryIsDir = str_ends_with($name, "/"); + if ($entryIsDir) { + continue; + } + + $stream = $zip->getStreamIndex($i); + + if ($prefix) { + if (!str_starts_with($name, $prefix) || $name === $prefix) { + continue; + } + $name = str_replace($prefix, '', $name); + } + + $targetPath = $destinationPath . DIRECTORY_SEPARATOR . $name; + $targetPathDir = dirname($targetPath); + if (!is_dir($targetPathDir)) { + $dirCreated = mkdir($targetPathDir, 0777, true); + if (!$dirCreated) { + throw new ThemeModuleException("Failed to create directory {$targetPathDir} when extracting module files"); + } + } + + $targetFile = fopen($targetPath, 'w'); + $written = stream_copy_to_stream($stream, $targetFile); + if (!$written) { + throw new ThemeModuleException("Failed to write to {$targetPath} when extracting module files"); + } + fclose($targetFile); + } + $zip->close(); } @@ -31,7 +65,8 @@ public function getModuleInstance(): ThemeModule throw new ThemeModuleException("Unable to open zip file at {$this->path}"); } - $moduleJsonText = $zip->getFromName('bookstack-module.json'); + $prefix = $this->getZipContentPrefix($zip); + $moduleJsonText = $zip->getFromName("{$prefix}bookstack-module.json"); $zip->close(); if ($moduleJsonText === false) { @@ -95,4 +130,20 @@ public function getContentsSize(): int return $totalSize; } + + protected function getZipContentPrefix(ZipArchive $zip): string + { + $index = $zip->locateName('bookstack-module.json', ZipArchive::FL_NODIR); + if ($index === false) { + return ''; + } + + $location = $zip->getNameIndex($index); + $pathParts = explode('/', $location); + if (count($pathParts) !== 2) { + return ''; + } + + return $pathParts[0] . '/'; + } } diff --git a/dev/docs/theme-system-modules.md b/dev/docs/theme-system-modules.md index 8aa9370ed26..0086ac9c013 100644 --- a/dev/docs/theme-system-modules.md +++ b/dev/docs/theme-system-modules.md @@ -66,6 +66,7 @@ Here are some general best practices when it comes to creating modules: ### Distribution Format Modules are expected to be distributed as a compressed ZIP file, where the ZIP contents follow that of a module folder. +Contents may optionally be placed within a nested folder inside the ZIP. BookStack provides a `php artisan bookstack:install-module` command which allows modules to be installed from these ZIP files, either from a local path or from a web URL. Currently, there's a hardcoded total filesize limit of 50MB for module contents installed via this method. diff --git a/tests/Commands/InstallModuleCommandTest.php b/tests/Commands/InstallModuleCommandTest.php index 8ffc4ead3a0..c085c49077d 100644 --- a/tests/Commands/InstallModuleCommandTest.php +++ b/tests/Commands/InstallModuleCommandTest.php @@ -96,18 +96,44 @@ public function test_remote_module_install_follows_redirects() }); } - public function test_remote_module_install_does_not_follow_redirects_to_different_origin() + public function test_remote_module_install_prompts_on_following_redirects_to_different_origin() { $this->usingThemeFolder(function () { $zip = $this->getModuleZipPath(); $http = $this->mockHttpClient([ new Response(302, ['Location' => 'http://example.com/a-test-module.zip']), + new Response(301, ['Location' => 'https://a.example.com:8080/a-test-module.zip']), new Response(200, ['Content-Length' => filesize($zip)], file_get_contents($zip)) ]); $this->artisan('bookstack:install-module', ['location' => 'https://example.com/test-module.zip']) ->expectsConfirmation('Are you sure you trust this source?', 'yes') + ->expectsOutput('The download URL is redirecting to a different site: http://example.com') + ->expectsConfirmation('Do you trust downloading the module from this site?', 'yes') + ->expectsOutput('The download URL is redirecting to a different site: https://a.example.com:8080') + ->expectsConfirmation('Do you trust downloading the module from this site?', 'yes') + ->assertExitCode(0); + + $this->assertEquals(3, $http->requestCount()); + $this->assertEquals('https', $http->requestAt(0)->getUri()->getScheme()); + $this->assertEquals('http', $http->requestAt(1)->getUri()->getScheme()); + $this->assertEquals('a.example.com', $http->requestAt(2)->getUri()->getHost()); + }); + } + + public function test_remote_module_install_redirect_origin_prompt_rejection() + { + $this->usingThemeFolder(function () { + $http = $this->mockHttpClient([ + new Response(302, ['Location' => 'http://example.com/a-test-module.zip']), + new Response(301, ['Location' => 'https://a.example.com:8080/a-test-module.zip']), + ]); + + $this->artisan('bookstack:install-module', ['location' => 'https://example.com/test-module.zip']) + ->expectsConfirmation('Are you sure you trust this source?', 'yes') + ->expectsOutput('The download URL is redirecting to a different site: http://example.com') + ->expectsConfirmation('Do you trust downloading the module from this site?', 'no') ->assertExitCode(1); $this->assertEquals(1, $http->requestCount()); @@ -115,6 +141,26 @@ public function test_remote_module_install_does_not_follow_redirects_to_differen }); } + public function test_remote_module_install_has_redirect_limit() + { + $this->usingThemeFolder(function () { + $http = $this->mockHttpClient([ + new Response(302, ['Location' => 'https://example.com/a-test-module.zip']), + new Response(302, ['Location' => 'https://example.com/b-test-module.zip']), + new Response(302, ['Location' => 'https://example.com/c-test-module.zip']), + new Response(302, ['Location' => 'https://example.com/d-test-module.zip']), + ]); + + $this->artisan('bookstack:install-module', ['location' => 'https://example.com/test-module.zip']) + ->expectsConfirmation('Are you sure you trust this source?', 'yes') + ->expectsOutput('ERROR: Failed to download module from https://example.com/test-module.zip') + ->assertExitCode(1); + + $this->assertEquals(4, $http->requestCount()); + $this->assertEquals('/c-test-module.zip', $http->requestAt(3)->getUri()->getPath()); + }); + } + public function test_remote_module_install_download_failures_are_announced_to_user() { $this->usingThemeFolder(function () { @@ -175,6 +221,35 @@ public function test_run_with_invalid_module_data_has_early_exit() ->assertExitCode(1); } + public function test_module_zip_when_files_in_nested_directory() + { + $this->usingThemeFolder(function ($themeFolder) { + $zip = new ZipArchive(); + $zipFile = tempnam(sys_get_temp_dir(), 'bs-test-module'); + $zip->open($zipFile, ZipArchive::CREATE); + + $zip->addEmptyDir('mod'); + $zip->addFromString('mod/bookstack-module.json', json_encode($metadata ?? [ + 'name' => 'Test Module', + 'description' => 'A test module for BookStack', + 'version' => '1.0.0', + ])); + $zip->addFromString('mod/functions.php', 'addEmptyDir('mod/a'); + $zip->addFromString('mod/a/cat.txt', 'Meow'); + $zip->close(); + + $this->artisan('bookstack:install-module', ['location' => $zipFile]) + ->expectsConfirmation('Are you sure you want to install this module?', 'yes') + ->assertExitCode(0); + + $modulePath = glob(theme_path('modules/*'), GLOB_ONLYDIR)[0]; + $this->assertFileExists($modulePath . '/a/cat.txt'); + $contents = file_get_contents($modulePath . '/a/cat.txt'); + $this->assertEquals('Meow', $contents); + }); + } + public function test_local_module_install_without_active_theme_can_setup_theme_folder() { $zip = $this->getModuleZipPath();