2026-02-03 20:43:01 +00:00
< ? php
namespace BookStack\Console\Commands ;
use BookStack\Http\HttpRequestService ;
use BookStack\Theming\ThemeModule ;
use BookStack\Theming\ThemeModuleException ;
use BookStack\Theming\ThemeModuleManager ;
use BookStack\Theming\ThemeModuleZip ;
2026-02-05 17:49:35 +00:00
use GuzzleHttp\Psr7\Request ;
2026-02-03 20:43:01 +00:00
use Illuminate\Console\Command ;
use Illuminate\Support\Str ;
class InstallModuleCommand extends Command
{
/**
* The name and signature of the console command .
*
* @ var string
*/
protected $signature = ' bookstack : install - module
{ location : The URL or path of the module file } ' ;
/**
* The console command description .
*
* @ var string
*/
protected $description = 'Install a module to the currently configured theme' ;
protected array $cleanupActions = [];
/**
* Execute the console command .
*/
public function handle () : int
{
$location = $this -> 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 );
2026-02-05 17:49:35 +00:00
if ( ! $moduleFolder ) {
$this -> cleanup ();
return 1 ;
}
2026-02-03 20:43:01 +00:00
$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 ;
}
2026-02-05 17:49:35 +00:00
$this -> info ( " Module \" { $newModule -> name } \" ( { $newModule -> version } ) successfully installed! " );
$this -> info ( " Install location: { $moduleFolder } / { $newModule -> folderName } " );
2026-02-03 20:43:01 +00:00
$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 ) {
2026-02-05 17:49:35 +00:00
$this -> line ( " { $module -> name } ( { $folder } : { $module -> version } ) - { $module -> description } " );
2026-02-03 20:43:01 +00:00
}
$this -> line ( '' );
2026-02-05 17:49:35 +00:00
$choices = [ 'Cancel module install' , 'Add alongside existing module' ];
2026-02-03 20:43:01 +00:00
if ( count ( $existingModules ) === 1 ) {
2026-02-05 17:49:35 +00:00
$choices [] = 'Replace existing module' ;
2026-02-03 20:43:01 +00:00
}
$choice = $this -> choice ( " What would you like to do? " , $choices , 0 , null , false );
2026-02-05 17:49:35 +00:00
if ( $choice === 'Cancel module install' ) {
2026-02-03 20:43:01 +00:00
return false ;
}
2026-02-05 17:49:35 +00:00
if ( $choice === 'Replace existing module' ) {
2026-02-03 20:43:01 +00:00
$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' ;
2026-02-05 17:49:35 +00:00
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 )) {
2026-02-03 20:43:01 +00:00
$created = mkdir ( $path , 0755 , true );
if ( ! $created ) {
$this -> error ( " ERROR: Failed to create a modules folder at { $path } " );
2026-02-05 17:49:35 +00:00
return null ;
2026-02-03 20:43:01 +00:00
}
}
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 ) {
2026-02-05 17:49:35 +00:00
$this -> error ( 'Failed to create a theme folder to use. This may be a permissions issue. Try manually configuring an active theme' );
2026-02-03 20:43:01 +00:00
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 )) {
2026-02-05 17:49:35 +00:00
$this -> error ( " ERROR: Module ZIP file is too large. Maximum size is 50MB " );
2026-02-03 20:43:01 +00:00
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 ;
}
2026-02-05 17:49:35 +00:00
protected function downloadModuleFile ( string $location ) : string | null
2026-02-03 20:43:01 +00:00
{
$httpRequests = app () -> make ( HttpRequestService :: class );
2026-02-05 17:49:35 +00:00
$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 ;
}
}
}
2026-02-03 20:43:01 +00:00
2026-02-05 17:49:35 +00:00
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 ;
}
2026-02-03 20:43:01 +00:00
$tempFile = tempnam ( sys_get_temp_dir (), 'bookstack_module_' );
$fileHandle = fopen ( $tempFile , 'w' );
2026-02-05 17:49:35 +00:00
$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 '' ;
}
}
2026-02-03 20:43:01 +00:00
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 );
2026-02-05 17:49:35 +00:00
$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. \n You should only install modules from trusted sources. " );
2026-02-03 20:43:01 +00:00
$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://' )) {
2026-02-05 17:49:35 +00:00
$this -> warn ( " You are downloading a module from an insecure HTTP source. \n We recommend only using HTTPS sources to avoid various security risks. " );
if ( ! $this -> confirm ( 'Are you sure you want to continue without HTTPS?' )) {
2026-02-03 20:43:01 +00:00
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 ();
}
}
}