argument('location'); // Get the ZIP file containing the module files $zipPath = $this->getPathToZip($location); if (!$zipPath) { $this->cleanup(); return 1; } // Validate module zip file (metadata, size, etc...) and get module instance $zip = new ThemeModuleZip($zipPath); $themeModule = $this->validateAndGetModuleInfoFromZip($zip); if (!$themeModule) { $this->cleanup(); return 1; } // Get the theme folder in use, attempting to create one if no active theme in use $themeFolder = $this->getThemeFolder(); if (!$themeFolder) { $this->cleanup(); return 1; } // Get the modules folder of the theme, attempting to create it if not existing, // and create a new module manager instance. $moduleFolder = $this->getModuleFolder($themeFolder); if (!$moduleFolder) { $this->cleanup(); return 1; } $manager = new ThemeModuleManager($moduleFolder); // Handle existing modules with the same name $exitingModulesWithName = $manager->getByName($themeModule->name); $shouldContinue = $this->handleExistingModulesWithSameName($exitingModulesWithName, $manager); if (!$shouldContinue) { $this->cleanup(); return 1; } // Extract module ZIP into the theme modules folder try { $newModule = $manager->addFromZip($themeModule->name, $zip); } catch (ThemeModuleException $exception) { $this->error("ERROR: Failed to install module with error: {$exception->getMessage()}"); $this->cleanup(); return 1; } $this->info("Module \"{$newModule->name}\" ({$newModule->version}) successfully installed!"); $this->info("Install location: {$moduleFolder}/{$newModule->folderName}"); $this->cleanup(); return 0; } protected function handleExistingModulesWithSameName(array $existingModules, ThemeModuleManager $manager): bool { if (count($existingModules) === 0) { return true; } $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(''); $choices = ['Cancel module install', 'Add alongside existing module']; if (count($existingModules) === 1) { $choices[] = 'Replace existing module'; } $choice = $this->choice("What would you like to do?", $choices, 0, null, false); if ($choice === 'Cancel module install') { return false; } if ($choice === 'Replace existing module') { $existingModuleFolder = array_key_first($existingModules); $this->info("Replacing existing module in {$existingModuleFolder} folder"); $manager->deleteModuleFolder($existingModuleFolder); } return true; } protected function getModuleFolder(string $themeFolder): string|null { $path = $themeFolder . DIRECTORY_SEPARATOR . 'modules'; if (file_exists($path) && !is_dir($path)) { $this->error("ERROR: Cannot create a modules folder, file already exists at {$path}"); return null; } if (!file_exists($path)) { $created = mkdir($path, 0755, true); if (!$created) { $this->error("ERROR: Failed to create a modules folder at {$path}"); return null; } } return $path; } protected function getThemeFolder(): string|null { $path = theme_path(''); if (!$path) { $shouldCreate = $this->confirm('No active theme folder found, would you like to create one?'); if (!$shouldCreate) { return null; } $folder = 'custom'; while (file_exists(base_path("themes" . DIRECTORY_SEPARATOR . $folder))) { $folder = 'custom-' . Str::random(4); } $path = base_path("themes/{$folder}"); $created = mkdir($path, 0755, true); if (!$created) { $this->error('Failed to create a theme folder to use. This may be a permissions issue. Try manually configuring an active theme'); return null; } $this->info("Created theme folder at {$path}"); $this->warn("You will need to set APP_THEME={$folder} in your BookStack env configuration to enable this theme!"); } return $path; } protected function validateAndGetModuleInfoFromZip(ThemeModuleZip $zip): ThemeModule|null { if (!$zip->exists()) { $this->error("ERROR: Cannot open ZIP file at {$zip->getPath()}"); return null; } if ($zip->getContentsSize() > (50 * 1024 * 1024)) { $this->error("ERROR: Module ZIP file is too large. Maximum size is 50MB"); return null; } try { $themeModule = $zip->getModuleInstance(); } catch (ThemeModuleException $exception) { $this->error("ERROR: Failed to read module metadata with error: {$exception->getMessage()}"); return null; } return $themeModule; } protected function downloadModuleFile(string $location): string|null { $httpRequests = app()->make(HttpRequestService::class); $client = $httpRequests->buildClient(30, ['stream' => true]); $originalHost = parse_url($location, PHP_URL_HOST); $currentLocation = $location; $maxRedirects = 3; $redirectCount = 0; // Follow redirects up to 3 times for the same hostname do { $resp = $client->sendRequest(new Request('GET', $currentLocation)); $statusCode = $resp->getStatusCode(); if ($statusCode >= 300 && $statusCode < 400 && $redirectCount < $maxRedirects) { $redirectLocation = $resp->getHeaderLine('Location'); if ($redirectLocation) { $redirectHost = parse_url($redirectLocation, PHP_URL_HOST); if ($redirectHost === $originalHost) { $currentLocation = $redirectLocation; $redirectCount++; continue; } } } break; } while (true); if ($resp->getStatusCode() >= 300) { $this->error("ERROR: Failed to download module from {$location}"); $this->error("Download failed with status code {$resp->getStatusCode()}"); return null; } $tempFile = tempnam(sys_get_temp_dir(), 'bookstack_module_'); $fileHandle = fopen($tempFile, 'w'); $respBody = $resp->getBody(); $size = 0; $maxSize = 50 * 1024 * 1024; while (!$respBody->eof()) { fwrite($fileHandle, $respBody->read(1024)); $size += 1024; if ($size > $maxSize) { fclose($fileHandle); unlink($tempFile); $this->error("ERROR: Module ZIP file is too large. Maximum size is 50MB"); return ''; } } fclose($fileHandle); $this->cleanupActions[] = function () use ($tempFile) { unlink($tempFile); }; return $tempFile; } protected function getPathToZip(string $location): string|null { $lowerLocation = strtolower($location); $isRemote = str_starts_with($lowerLocation, 'http://') || str_starts_with($lowerLocation, 'https://'); if ($isRemote) { // Warning about fetching from source $host = parse_url($location, PHP_URL_HOST); $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."); $trustHost = $this->confirm('Are you sure you trust this source?'); if (!$trustHost) { return null; } // Check if the connection is http. If so, warn the user. if (str_starts_with($lowerLocation, 'http://')) { $this->warn("You are downloading a module from an insecure HTTP source.\nWe recommend only using HTTPS sources to avoid various security risks."); if (!$this->confirm('Are you sure you want to continue without HTTPS?')) { return null; } } // Download ZIP and get its location return $this->downloadModuleFile($location); } // Validate file and get full location $zipPath = realpath($location); if (!$zipPath || !is_file($zipPath)) { $this->error("ERROR: Module file not found at {$location}"); return null; } return $zipPath; } protected function cleanup(): void { foreach ($this->cleanupActions as $action) { $action(); } } }