1<?php 2 3namespace Roundcube\Composer; 4 5use Composer\Installer\LibraryInstaller; 6use Composer\Package\Version\VersionParser; 7use Composer\Package\PackageInterface; 8use Composer\Repository\InstalledRepositoryInterface; 9use Composer\Util\ProcessExecutor; 10use Composer\Util\Filesystem; 11use React\Promise\PromiseInterface; 12 13/** 14 * @category Plugins 15 * @package PluginInstaller 16 * @author Till Klampaeckel <till@php.net> 17 * @author Thomas Bruederli <thomas@roundcube.net> 18 * @author Philip Weir <roundcube@tehinterweb.co.uk> 19 * @license GPL-3.0+ 20 * @version GIT: <git_id> 21 * @link http://github.com/roundcube/plugin-installer 22 */ 23class ExtensionInstaller extends LibraryInstaller 24{ 25 protected $composer_type; 26 27 /** 28 * {@inheritDoc} 29 */ 30 public function getInstallPath(PackageInterface $package) 31 { 32 static $vendorDir; 33 if ($vendorDir === null) { 34 $vendorDir = $this->getVendorDir(); 35 } 36 37 return sprintf('%s/%s', $vendorDir, $this->getPackageName($package)); 38 } 39 40 /** 41 * {@inheritDoc} 42 */ 43 public function install(InstalledRepositoryInterface $repo, PackageInterface $package) 44 { 45 // initialize Roundcube environment 46 define('INSTALL_PATH', getcwd() . '/'); 47 include_once(INSTALL_PATH . 'program/include/clisetup.php'); 48 49 $this->rcubeVersionCheck($package); 50 51 $self = $this; 52 $postInstall = function() use ($self, $package) { 53 $config_file = $self->rcubeConfigFile(); 54 $package_name = $self->getPackageName($package); 55 $package_dir = $self->getVendorDir() . DIRECTORY_SEPARATOR . $package_name; 56 $extra = $package->getExtra(); 57 58 if (is_writeable($config_file) && php_sapi_name() == 'cli' && $this->confirmInstall($package_name)) { 59 $self->rcubeAlterConfig($package_name); 60 } 61 62 // copy config.inc.php.dist -> config.inc.php 63 if (is_file($package_dir . DIRECTORY_SEPARATOR . 'config.inc.php.dist')) { 64 $config_exists = false; 65 $alt_config_file = $self->rcubeConfigFile($package_name . '.inc.php'); 66 67 if (is_file($package_dir . DIRECTORY_SEPARATOR . 'config.inc.php')) { 68 $config_exists = true; 69 } 70 elseif (is_file($alt_config_file)) { 71 $config_exists = true; 72 } 73 74 if (!$config_exists && is_writeable($package_dir)) { 75 $self->io->write("<info>Creating package config file</info>"); 76 copy($package_dir . DIRECTORY_SEPARATOR . 'config.inc.php.dist', $package_dir . DIRECTORY_SEPARATOR . 'config.inc.php'); 77 } 78 } 79 80 // initialize database schema 81 if (!empty($extra['roundcube']['sql-dir'])) { 82 if ($sqldir = realpath($package_dir . DIRECTORY_SEPARATOR . $extra['roundcube']['sql-dir'])) { 83 $self->io->write("<info>Running database initialization script for $package_name</info>"); 84 85 $roundcube_version = self::versionNormalize(RCMAIL_VERSION); 86 if (self::versionCompare($roundcube_version, '1.2.0', '>=')) { 87 \rcmail_utils::db_init($sqldir); 88 } 89 else { 90 throw new \Exception("Database initialization failed. Roundcube 1.2.0 or above required."); 91 } 92 } 93 } 94 95 // run post-install script 96 if (!empty($extra['roundcube']['post-install-script'])) { 97 $self->rcubeRunScript($extra['roundcube']['post-install-script'], $package); 98 } 99 }; 100 101 $promise = parent::install($repo, $package); 102 103 // Composer v2 might return a promise here 104 if ($promise instanceof PromiseInterface) { 105 return $promise->then($postInstall); 106 } 107 108 // If not, execute the code right away (composer v1, or v2 without async) 109 $postInstall(); 110 } 111 112 /** 113 * {@inheritDoc} 114 */ 115 public function update(InstalledRepositoryInterface $repo, PackageInterface $initial, PackageInterface $target) 116 { 117 // initialize Roundcube environment 118 define('INSTALL_PATH', getcwd() . '/'); 119 include_once(INSTALL_PATH . 'program/include/clisetup.php'); 120 121 $this->rcubeVersionCheck($target); 122 123 $self = $this; 124 $extra = $target->getExtra(); 125 $fs = new Filesystem(); 126 127 // backup persistent files e.g. config.inc.php 128 $package_name = $self->getPackageName($initial); 129 $package_dir = $self->getVendorDir() . DIRECTORY_SEPARATOR . $package_name; 130 $temp_dir = $package_dir . '-' . sprintf('%010d%010d', mt_rand(), mt_rand()); 131 132 // make a backup of existing files (for restoring persistent files) 133 $fs->copy($package_dir, $temp_dir); 134 135 $postUpdate = function() use ($self, $target, $extra, $fs, $temp_dir) { 136 $package_name = $self->getPackageName($target); 137 $package_dir = $self->getVendorDir() . DIRECTORY_SEPARATOR . $package_name; 138 139 // restore persistent files 140 $persistent_files = !empty($extra['roundcube']['persistent-files']) ? $extra['roundcube']['persistent-files'] : ['config.inc.php']; 141 foreach ($persistent_files as $file) { 142 $path = $temp_dir . DIRECTORY_SEPARATOR . $file; 143 if (is_readable($path)) { 144 if ($fs->copy($path, $package_dir . DIRECTORY_SEPARATOR . $file)) { 145 $self->io->write("<info>Restored $package_name/$file</info>"); 146 } 147 else { 148 throw new \Exception("Restoring " . $file . " failed."); 149 } 150 } 151 } 152 // remove backup folder 153 $fs->remove($temp_dir); 154 155 // update database schema 156 if (!empty($extra['roundcube']['sql-dir'])) { 157 if ($sqldir = realpath($package_dir . DIRECTORY_SEPARATOR . $extra['roundcube']['sql-dir'])) { 158 $self->io->write("<info>Updating database schema for $package_name</info>"); 159 160 $roundcube_version = self::versionNormalize(RCMAIL_VERSION); 161 if (self::versionCompare($roundcube_version, '1.2.0', '>=')) { 162 \rcmail_utils::db_update($sqldir, $package_name); 163 } 164 else { 165 throw new \Exception("Database update failed. Roundcube 1.2.0 or above required."); 166 } 167 } 168 } 169 170 // run post-update script 171 if (!empty($extra['roundcube']['post-update-script'])) { 172 $self->rcubeRunScript($extra['roundcube']['post-update-script'], $target); 173 } 174 }; 175 176 $promise = parent::update($repo, $initial, $target); 177 178 // Composer v2 might return a promise here 179 if ($promise instanceof PromiseInterface) { 180 return $promise->then($postUpdate); 181 } 182 183 // If not, execute the code right away (composer v1, or v2 without async) 184 $postUpdate(); 185 } 186 187 /** 188 * {@inheritDoc} 189 */ 190 public function uninstall(InstalledRepositoryInterface $repo, PackageInterface $package) 191 { 192 // initialize Roundcube environment 193 define('INSTALL_PATH', getcwd() . '/'); 194 include_once(INSTALL_PATH . 'program/include/clisetup.php'); 195 196 $self = $this; 197 $config = $self->composer->getConfig()->get('roundcube'); 198 199 $postUninstall = function() use ($self, $package, $config) { 200 // post-uninstall: deactivate package 201 $package_name = $self->getPackageName($package); 202 $package_dir = $self->getVendorDir() . DIRECTORY_SEPARATOR . $package_name; 203 204 $self->rcubeAlterConfig($package_name, false); 205 206 // run post-uninstall script 207 $extra = $package->getExtra(); 208 if (!empty($extra['roundcube']['post-uninstall-script'])) { 209 $self->rcubeRunScript($extra['roundcube']['post-uninstall-script'], $package); 210 } 211 212 // remove package folder 213 if (!empty($config['uninstall-remove-folder'])) { 214 $fs = new Filesystem(); 215 $fs->remove($package_dir); 216 $self->io->write("<info>Removed $package_name files</info>"); 217 } 218 }; 219 220 $promise = parent::uninstall($repo, $package); 221 222 // Composer v2 might return a promise here 223 if ($promise instanceof PromiseInterface) { 224 return $promise->then($postUninstall); 225 } 226 227 // If not, execute the code right away (composer v1, or v2 without async) 228 $postUninstall(); 229 } 230 231 /** 232 * {@inheritDoc} 233 */ 234 public function supports($packageType) 235 { 236 return $packageType === $this->composer_type; 237 } 238 239 /** 240 * Setup vendor directory to one of these two: 241 * 242 * @return string 243 */ 244 public function getVendorDir() 245 { 246 return getcwd(); 247 } 248 249 /** 250 * Extract the (valid) package name from the package object 251 */ 252 protected function getPackageName(PackageInterface $package) 253 { 254 @list($vendor, $packageName) = explode('/', $package->getPrettyName()); 255 256 return strtr($packageName, '-', '_'); 257 } 258 259 /** 260 * Check version requirements from the "extra" block of a package 261 * against the local Roundcube version 262 */ 263 private function rcubeVersionCheck($package) 264 { 265 // read rcube version from iniset 266 $rcubeVersion = self::versionNormalize(RCMAIL_VERSION); 267 268 if (empty($rcubeVersion)) { 269 throw new \Exception("Unable to find a Roundcube installation in $rootdir"); 270 } 271 272 $extra = $package->getExtra(); 273 274 if (!empty($extra['roundcube'])) { 275 foreach (['min-version' => '>=', 'max-version' => '<='] as $key => $operator) { 276 if (!empty($extra['roundcube'][$key])) { 277 $version = self::versionNormalize($extra['roundcube'][$key]); 278 if (!self::versionCompare($rcubeVersion, $version, $operator)) { 279 throw new \Exception("Version check failed! " . $package->getName() . " requires Roundcube version $operator $version, $rcubeVersion was detected."); 280 } 281 } 282 } 283 } 284 } 285 286 /** 287 * Add or remove the given package to the Roundcube config. 288 */ 289 private function rcubeAlterConfig($package_name, $add = true) 290 { 291 $config_file = $this->rcubeConfigFile(); 292 @include($config_file); 293 $success = false; 294 $varname = '$config'; 295 296 if (empty($config) && !empty($rcmail_config)) { 297 $config = $rcmail_config; 298 $varname = '$rcmail_config'; 299 } 300 301 if (!empty($config) && is_writeable($config_file)) { 302 $config_template = @file_get_contents($config_file) ?: ''; 303 304 if ($config = $this->getConfig($package_name, $config, $add)) { 305 list($config_name, $config_val) = $config; 306 $count = 0; 307 308 if (empty($config_val)) { 309 $new_config = preg_replace( 310 "/(\\$varname\['$config_name'\])\s+=\s+(.+);/Uims", 311 "", 312 $config_template, -1, $count); 313 } 314 else { 315 $new_config = preg_replace( 316 "/(\\$varname\['$config_name'\])\s+=\s+(.+);/Uims", 317 "\\1 = " . $config_val, 318 $config_template, -1, $count); 319 } 320 321 // config option does not exist yet, add it... 322 if (!$count) { 323 $var_txt = "\n{$varname}['$config_name'] = $config_val\n"; 324 $new_config = str_replace('?>', $var_txt . '?>', $config_template, $count); 325 326 if (!$count) { 327 $new_config = $config_template . $var_txt; 328 } 329 } 330 331 $success = file_put_contents($config_file, $new_config); 332 } 333 } 334 335 if ($success && php_sapi_name() == 'cli') { 336 $this->io->write("<info>Updated local config at $config_file</info>"); 337 } 338 339 return $success; 340 } 341 342 /** 343 * Ask the user to confirm installation 344 */ 345 protected function confirmInstall($package_name) 346 { 347 return false; 348 } 349 350 /** 351 * Generate Roundcube config entry 352 */ 353 protected function getConfig($package_name, $cur_config, $add) 354 { 355 return false; 356 } 357 358 /** 359 * Helper method to get an absolute path to the local Roundcube config file 360 */ 361 private function rcubeConfigFile($file = 'config.inc.php') 362 { 363 $config = new \rcube_config(); 364 $paths = $config->resolve_paths($file); 365 $path = getcwd() . '/config/' . $file; 366 367 foreach ($paths as $fpath) { 368 if ($fpath && is_file($fpath) && is_readable($fpath)) { 369 $path = $fpath; 370 break; 371 } 372 } 373 374 return realpath($path); 375 } 376 377 /** 378 * Run the given script file 379 */ 380 private function rcubeRunScript($script, PackageInterface $package) 381 { 382 $package_name = $this->getPackageName($package); 383 $package_type = $package->getType(); 384 $package_dir = $this->getVendorDir() . DIRECTORY_SEPARATOR . $package_name; 385 386 // check for executable shell script 387 if (($scriptfile = realpath($package_dir . DIRECTORY_SEPARATOR . $script)) && is_executable($scriptfile)) { 388 $script = $scriptfile; 389 } 390 391 // run PHP script in Roundcube context 392 if ($scriptfile && preg_match('/\.php$/', $scriptfile)) { 393 $incdir = realpath(getcwd() . '/program/include'); 394 include_once($incdir . '/iniset.php'); 395 include($scriptfile); 396 } 397 // attempt to execute the given string as shell commands 398 else { 399 $process = new ProcessExecutor($this->io); 400 $exitCode = $process->execute($script, $output, $package_dir); 401 if ($exitCode !== 0) { 402 throw new \RuntimeException('Error executing script: '. $process->getErrorOutput(), $exitCode); 403 } 404 } 405 } 406 407 /** 408 * normalize Roundcube version string 409 */ 410 private static function versionNormalize($version) 411 { 412 $parser = new VersionParser; 413 414 return $parser->normalize(str_replace('-git', '.999', $version)); 415 } 416 417 /** 418 * version_compare() wrapper, originally from composer/semver 419 */ 420 private static function versionCompare($a, $b, $operator, $compareBranches = false) 421 { 422 $aIsBranch = 'dev-' === substr($a, 0, 4); 423 $bIsBranch = 'dev-' === substr($b, 0, 4); 424 425 if ($aIsBranch && $bIsBranch) { 426 return $operator === '==' && $a === $b; 427 } 428 429 // when branches are not comparable, we make sure dev branches never match anything 430 if (!$compareBranches && ($aIsBranch || $bIsBranch)) { 431 return false; 432 } 433 434 return version_compare($a, $b, $operator); 435 } 436} 437