diff --git a/app/Console/Commands/InstallModuleCommand.php b/app/Console/Commands/InstallModuleCommand.php index b43fb6b4a..dc3c9363e 100644 --- a/app/Console/Commands/InstallModuleCommand.php +++ b/app/Console/Commands/InstallModuleCommand.php @@ -86,12 +86,15 @@ class InstallModuleCommand extends Command return 1; } - $this->info("Module \"{$newModule->name}\" ({$newModule->version}) successfully installed!"); + $this->info("Module \"{$newModule->name}\" ({$newModule->getVersion()}) successfully installed!"); $this->info("Install location: {$moduleFolder}/{$newModule->folderName}"); $this->cleanup(); return 0; } + /** + * @param ThemeModule[] $existingModules + */ protected function handleExistingModulesWithSameName(array $existingModules, ThemeModuleManager $manager): bool { if (count($existingModules) === 0) { @@ -100,7 +103,7 @@ class InstallModuleCommand extends Command $this->warn("The following modules already exist with the same name:"); foreach ($existingModules as $folder => $module) { - $this->line("{$module->name} ({$folder}:{$module->version}) - {$module->description}"); + $this->line("{$module->name} ({$folder}:{$module->getVersion()}) - {$module->description}"); } $this->line(''); @@ -145,7 +148,7 @@ class InstallModuleCommand extends Command protected function getThemeFolder(): string|null { $path = theme_path(''); - if (!$path) { + if (!$path || !is_dir($path)) { $shouldCreate = $this->confirm('No active theme folder found, would you like to create one?'); if (!$shouldCreate) { return null; @@ -178,7 +181,7 @@ class InstallModuleCommand extends Command } if ($zip->getContentsSize() > (50 * 1024 * 1024)) { - $this->error("ERROR: Module ZIP file is too large. Maximum size is 50MB"); + $this->error("ERROR: Module ZIP file contents are too large. Maximum size is 50MB"); return null; } @@ -196,7 +199,7 @@ class InstallModuleCommand extends Command { $httpRequests = app()->make(HttpRequestService::class); $client = $httpRequests->buildClient(30, ['stream' => true]); - $originalHost = parse_url($location, PHP_URL_HOST); + $originalUrl = parse_url($location); $currentLocation = $location; $maxRedirects = 3; $redirectCount = 0; @@ -209,8 +212,12 @@ class InstallModuleCommand extends Command if ($statusCode >= 300 && $statusCode < 400 && $redirectCount < $maxRedirects) { $redirectLocation = $resp->getHeaderLine('Location'); if ($redirectLocation) { - $redirectHost = parse_url($redirectLocation, PHP_URL_HOST); - if ($redirectHost === $originalHost) { + $redirectUrl = parse_url($redirectLocation); + if ( + ($originalUrl['host'] ?? '') === ($redirectUrl['host'] ?? '') + && ($originalUrl['scheme'] ?? '') === ($redirectUrl['scheme'] ?? '') + && ($originalUrl['port'] ?? '') === ($redirectUrl['port'] ?? '') + ) { $currentLocation = $redirectLocation; $redirectCount++; continue; diff --git a/app/Theming/ThemeModuleZip.php b/app/Theming/ThemeModuleZip.php index f6d9432b0..7029fa0c6 100644 --- a/app/Theming/ThemeModuleZip.php +++ b/app/Theming/ThemeModuleZip.php @@ -2,6 +2,8 @@ namespace BookStack\Theming; +use ZipArchive; + readonly class ThemeModuleZip { public function __construct( @@ -11,7 +13,7 @@ readonly class ThemeModuleZip public function extractTo(string $destinationPath): void { - $zip = new \ZipArchive(); + $zip = new ZipArchive(); $zip->open($this->path); $zip->extractTo($destinationPath); $zip->close(); @@ -23,7 +25,7 @@ readonly class ThemeModuleZip */ public function getModuleInstance(): ThemeModule { - $zip = new \ZipArchive(); + $zip = new ZipArchive(); $open = $zip->open($this->path); if ($open !== true) { throw new ThemeModuleException("Unable to open zip file at {$this->path}"); @@ -61,10 +63,13 @@ readonly class ThemeModuleZip return false; } - $zip = new \ZipArchive(); - $open = $zip->open($this->path); - $zip->close(); - return $open === true; + $zip = new ZipArchive(); + $open = $zip->open($this->path, ZipArchive::RDONLY); + if ($open === true) { + $zip->close(); + return true; + } + return false; } /** @@ -72,7 +77,7 @@ readonly class ThemeModuleZip */ public function getContentsSize(): int { - $zip = new \ZipArchive(); + $zip = new ZipArchive(); if ($zip->open($this->path) !== true) { return 0; diff --git a/tests/Commands/InstallModuleCommandTest.php b/tests/Commands/InstallModuleCommandTest.php new file mode 100644 index 000000000..0872efc3f --- /dev/null +++ b/tests/Commands/InstallModuleCommandTest.php @@ -0,0 +1,289 @@ +usingThemeFolder(function () { + $zip = $this->getModuleZipPath(); + $expectedInstallPath = theme_path('modules/test-module'); + $this->artisan('bookstack:install-module', ['location' => $zip]) + ->expectsOutput('Module "Test Module" (v1.0.0) successfully installed!') + ->expectsOutput("Install location: {$expectedInstallPath}") + ->assertExitCode(0); + + $this->assertDirectoryExists($expectedInstallPath); + $this->assertFileExists($expectedInstallPath . '/bookstack-module.json'); + }); + } + + public function test_remote_module_install_with_active_theme() + { + $this->usingThemeFolder(function () { + $zip = $this->getModuleZipPath(); + + $http = $this->mockHttpClient([ + new Response(200, ['Content-Length' => filesize($zip)], file_get_contents($zip)) + ]); + $expectedInstallPath = theme_path('modules/test-module'); + + $this->artisan('bookstack:install-module', ['location' => 'https://example.com/test-module.zip']) + ->expectsOutput("This will download a module from example.com. 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.") + ->expectsConfirmation('Are you sure you trust this source?', 'yes') + ->expectsOutput('Module "Test Module" (v1.0.0) successfully installed!') + ->expectsOutput("Install location: {$expectedInstallPath}") + ->assertExitCode(0); + + $this->assertEquals(1, $http->requestCount()); + $request = $http->requestAt(0); + $this->assertEquals('/test-module.zip', $request->getUri()->getPath()); + + $this->assertDirectoryExists($expectedInstallPath); + $this->assertFileExists($expectedInstallPath . '/bookstack-module.json'); + }); + } + + public function test_remote_http_module_warns_and_prompts_users() + { + $this->usingThemeFolder(function () { + $zip = $this->getModuleZipPath(); + + $http = $this->mockHttpClient([ + new Response(200, ['Content-Length' => filesize($zip)], file_get_contents($zip)) + ]); + $expectedInstallPath = theme_path('modules/test-module'); + + $this->artisan('bookstack:install-module', ['location' => 'http://example.com/test-module.zip']) + ->expectsOutput("This will download a module from example.com. 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.") + ->expectsConfirmation('Are you sure you trust this source?', 'yes') + ->expectsOutput("You are downloading a module from an insecure HTTP source.\nWe recommend only using HTTPS sources to avoid various security risks.") + ->expectsConfirmation('Are you sure you want to continue without HTTPS?', 'yes') + ->expectsOutput('Module "Test Module" (v1.0.0) successfully installed!') + ->expectsOutput("Install location: {$expectedInstallPath}") + ->assertExitCode(0); + + $request = $http->requestAt(0); + $this->assertEquals('/test-module.zip', $request->getUri()->getPath()); + }); + } + + public function test_remote_module_install_follows_redirects() + { + $this->usingThemeFolder(function () { + $zip = $this->getModuleZipPath(); + + $http = $this->mockHttpClient([ + new Response(302, ['Location' => 'https://example.com/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') + ->assertExitCode(0); + + $this->assertEquals(2, $http->requestCount()); + $this->assertEquals('/test-module.zip', $http->requestAt(0)->getUri()->getPath()); + $this->assertEquals('/a-test-module.zip', $http->requestAt(1)->getUri()->getPath()); + }); + } + + public function test_remote_module_install_does_not_follow_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(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') + ->assertExitCode(1); + + $this->assertEquals(1, $http->requestCount()); + $this->assertEquals('https', $http->requestAt(0)->getUri()->getScheme()); + }); + } + + public function test_remote_module_install_download_failures_are_announced_to_user() + { + $this->usingThemeFolder(function () { + $http = $this->mockHttpClient([ + new Response(404), + ]); + + $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') + ->expectsOutput('Download failed with status code 404') + ->assertExitCode(1); + $this->assertEquals(1, $http->requestCount()); + }); + } + + public function test_run_with_invalid_path_exits_early() + { + $this->artisan('bookstack:install-module', ['location' => '/not-found.zip']) + ->expectsOutput('ERROR: Module file not found at /not-found.zip') + ->assertExitCode(1); + } + + public function test_run_with_invalid_zip_has_early_exit() + { + $zip = $this->getModuleZipPath(); + file_put_contents($zip, 'invalid zip'); + + $this->artisan('bookstack:install-module', ['location' => $zip]) + ->expectsOutput("ERROR: Cannot open ZIP file at {$zip}") + ->assertExitCode(1); + } + + public function test_run_with_large_zip_has_early_exit() + { + $zip = $this->getModuleZipPath(null, [ + 'large-file.txt' => str_repeat('a', 1024 * 1024 * 51) + ]); + + $this->artisan('bookstack:install-module', ['location' => $zip]) + ->expectsOutput("ERROR: Module ZIP file contents are too large. Maximum size is 50MB") + ->assertExitCode(1); + } + + public function test_run_with_invalid_module_data_has_early_exit() + { + $zip = $this->getModuleZipPath([ + 'name' => 'Invalid Module', + 'description' => 'A module with invalid data', + 'version' => 'dog', + ]); + + $this->artisan('bookstack:install-module', ['location' => $zip]) + ->expectsOutput("ERROR: Failed to read module metadata with error: Module in folder \"_temp\" has an invalid 'version' format. Expected semantic version format like '1.0.0' or 'v1.0.0'") + ->assertExitCode(1); + } + + public function test_local_module_install_without_active_theme_can_setup_theme_folder() + { + $zip = $this->getModuleZipPath(); + $expectedThemePath = base_path('themes/custom'); + File::deleteDirectory($expectedThemePath); + + $this->artisan('bookstack:install-module', ['location' => $zip]) + ->expectsConfirmation('No active theme folder found, would you like to create one?', 'yes') + ->expectsOutput("Created theme folder at {$expectedThemePath}") + ->expectsOutput("You will need to set APP_THEME=custom in your BookStack env configuration to enable this theme!") + ->expectsOutput('Module "Test Module" (v1.0.0) successfully installed!') + ->assertExitCode(0); + + $this->assertDirectoryExists($expectedThemePath . '/modules/test-module'); + + File::deleteDirectory($expectedThemePath); + } + + public function test_local_module_install_with_active_theme_and_conflicting_modules_file_causes_early_exit() + { + $this->usingThemeFolder(function () { + $zip = $this->getModuleZipPath(); + File::put(theme_path('modules'), '{}'); + + $this->artisan('bookstack:install-module', ['location' => $zip]) + ->expectsOutput("ERROR: Cannot create a modules folder, file already exists at " . theme_path('modules')) + ->assertExitCode(1); + }); + } + + public function test_single_existing_module_with_same_name_replace() + { + $this->usingThemeFolder(function () { + $original = $this->createModuleFolderInCurrentTheme(['name' => 'Test Module', 'description' => 'cat', 'version' => '1.0.0']); + $new = $this->getModuleZipPath(['name' => 'Test Module', 'description' => '', 'version' => '2.0.0']); + + $this->artisan('bookstack:install-module', ['location' => $new]) + ->expectsOutput('The following modules already exist with the same name:') + ->expectsOutput('Test Module (test-module:v1.0.0) - cat') + ->expectsChoice('What would you like to do?', 'Replace existing module', ['Cancel module install', 'Add alongside existing module', 'Replace existing module']) + ->expectsOutput("Replacing existing module in test-module folder") + ->assertExitCode(0); + + $this->assertFileExists($original . '/bookstack-module.json'); + $metadata = json_decode(file_get_contents($original . '/bookstack-module.json'), true); + $this->assertEquals('2.0.0', $metadata['version']); + }); + } + + public function test_single_existing_module_with_same_name_cancel() + { + $this->usingThemeFolder(function () { + $original = $this->createModuleFolderInCurrentTheme(['name' => 'Test Module', 'description' => 'cat', 'version' => '1.0.0']); + $new = $this->getModuleZipPath(['name' => 'Test Module', 'description' => '', 'version' => '2.0.0']); + + $this->artisan('bookstack:install-module', ['location' => $new]) + ->expectsOutput('The following modules already exist with the same name:') + ->expectsOutput('Test Module (test-module:v1.0.0) - cat') + ->expectsChoice('What would you like to do?', 'Cancel module install', ['Cancel module install', 'Add alongside existing module', 'Replace existing module']) + ->assertExitCode(1); + + $this->assertFileExists($original . '/bookstack-module.json'); + $metadata = json_decode(file_get_contents($original . '/bookstack-module.json'), true); + $this->assertEquals('1.0.0', $metadata['version']); + }); + } + + public function test_single_existing_module_with_same_name_add() + { + $this->usingThemeFolder(function () { + $original = $this->createModuleFolderInCurrentTheme(['name' => 'Test Module', 'description' => 'cat', 'version' => '1.0.0']); + $new = $this->getModuleZipPath(['name' => 'Test Module', 'description' => '', 'version' => '2.0.0']); + + $this->artisan('bookstack:install-module', ['location' => $new]) + ->expectsOutput('The following modules already exist with the same name:') + ->expectsOutput('Test Module (test-module:v1.0.0) - cat') + ->expectsChoice('What would you like to do?', 'Add alongside existing module', ['Cancel module install', 'Add alongside existing module', 'Replace existing module']) + ->assertExitCode(0); + + $dirs = File::directories(theme_path('modules/')); + $this->assertCount(2, $dirs); + }); + } + + protected function createModuleFolderInCurrentTheme(array|null $metadata = null, array $extraFiles = []): string + { + $original = $this->getModuleZipPath($metadata, $extraFiles); + $targetPath = theme_path('modules/test-module'); + mkdir($targetPath, 0777, true); + $originalZip = new ZipArchive(); + $originalZip->open($original); + $originalZip->extractTo($targetPath); + $originalZip->close(); + + return $targetPath; + } + + protected function getModuleZipPath(array|null $metadata = null, array $extraFiles = []): string + { + $zip = new ZipArchive(); + $tmpFile = tempnam(sys_get_temp_dir(), 'bs-test-module'); + $zip->open($tmpFile, ZipArchive::CREATE); + + $zip->addFromString('bookstack-module.json', json_encode($metadata ?? [ + 'name' => 'Test Module', + 'description' => 'A test module for BookStack', + 'version' => '1.0.0', + ])); + + foreach ($extraFiles as $path => $contents) { + $zip->addFromString($path, $contents); + } + + $zip->close(); + return $tmpFile; + } +}