* @license GNU General Public License, version 2 (GPL-2.0) * * For full copyright and license information, please see * the docs/CREDITS.txt file. * */ namespace phpbb\composer; use Composer\Composer; use Composer\Factory; use Composer\IO\IOInterface; use Composer\IO\NullIO; use Composer\Json\JsonFile; use Composer\Package\BasePackage; use Composer\Package\CompletePackage; use Composer\Repository\ComposerRepository; use Composer\Semver\Constraint\ConstraintInterface; use Composer\Util\RemoteFilesystem; use phpbb\composer\io\null_io; use phpbb\config\config; use phpbb\exception\runtime_exception; use phpbb\filesystem\filesystem; use phpbb\request\request; use Seld\JsonLint\ParsingException; use phpbb\filesystem\helper as filesystem_helper; /** * Class to install packages through composer while freezing core dependencies. */ class installer { const PHPBB_TYPES = 'phpbb-extension,phpbb-style,phpbb-language'; /** * @var array Repositories to look packages from */ protected $repositories = []; /** * @var bool Indicates whether packagist usage is allowed or not */ protected $packagist = false; /** * @var string Composer filename used to manage the packages */ protected $composer_filename = 'composer-ext.json'; /** * @var string Directory where to install packages vendors */ protected $packages_vendor_dir = 'vendor-ext/'; /** * @var string Minimum stability */ protected $minimum_stability = 'stable'; /** * @var string phpBB root path */ protected $root_path; /** * @var string Stores the original working directory in case it has been changed through move_to_root() */ private $original_cwd; /** * @var array Stores the content of the ext json file before generate_ext_json_file() overrides it */ private $ext_json_file_backup; /** * @var request phpBB request object */ private $request; /** * @param string $root_path phpBB root path * @param filesystem $filesystem Filesystem object * @param request $request phpBB request object * @param config $config Config object */ public function __construct($root_path, filesystem $filesystem, request $request, config $config = null) { if ($config) { $repositories = json_decode($config['exts_composer_repositories'], true); if (is_array($repositories) && !empty($repositories)) { $this->repositories = (array) $repositories; } $this->packagist = (bool) $config['exts_composer_packagist']; $this->composer_filename = $config['exts_composer_json_file']; $this->packages_vendor_dir = $config['exts_composer_vendor_dir']; $this->minimum_stability = $config['exts_composer_minimum_stability']; } $this->root_path = $root_path; $this->request = $request; putenv('COMPOSER_HOME=' . filesystem_helper::realpath($root_path) . '/store/composer'); } /** * Update the current installed set of packages * * @param array $packages Packages to install. * Each entry may be a name or an array associating a version constraint to a name * @param array $whitelist White-listed packages (packages that can be installed/updated/removed) * @param IOInterface $io IO object used for the output * * @throws runtime_exception */ public function install(array $packages, $whitelist, IOInterface $io = null) { $this->wrap(function() use ($packages, $whitelist, $io) { $this->do_install($packages, $whitelist, $io); }); } /** * Update the current installed set of packages * * /!\ Doesn't change the current working directory * * @param array $packages Packages to install. * Each entry may be a name or an array associating a version constraint to a name * @param array $whitelist White-listed packages (packages that can be installed/updated/removed) * @param IOInterface $io IO object used for the output * * @throws runtime_exception */ protected function do_install(array $packages, $whitelist, IOInterface $io = null) { if (!$io) { $io = new null_io(); } $this->generate_ext_json_file($packages); $composer = Factory::create($io, $this->get_composer_ext_json_filename(), false); $install = \Composer\Installer::create($io, $composer); $composer->getDownloadManager()->setOutputProgress(false); $install ->setVerbose(true) ->setPreferSource(false) ->setPreferDist(true) ->setDevMode(false) ->setUpdate(true) ->setUpdateWhitelist($whitelist) ->setWhitelistDependencies(false) ->setIgnorePlatformRequirements(false) ->setOptimizeAutoloader(true) ->setDumpAutoloader(true) ->setPreferStable(true) ->setRunScripts(false) ->setDryRun(false); try { $result = $install->run(); } catch (\Exception $e) { $this->restore_ext_json_file(); throw new runtime_exception('COMPOSER_CANNOT_INSTALL', [], $e); } if ($result !== 0) { $this->restore_ext_json_file(); throw new runtime_exception($io->get_composer_error(), []); } } /** * Returns the list of currently installed packages * * @param string|array $types Returns only the packages with the given type(s) * * @return array The installed packages associated to their version. * * @throws runtime_exception */ public function get_installed_packages($types) { return $this->wrap(function() use ($types) { return $this->do_get_installed_packages($types); }); } /** * Returns the list of currently installed packages * * /!\ Doesn't change the current working directory * * @param string|array $types Returns only the packages with the given type(s) * * @return array The installed packages associated to their version. */ protected function do_get_installed_packages($types) { $types = (array) $types; try { $io = new NullIO(); $composer = Factory::create($io, $this->get_composer_ext_json_filename(), false); $installed = []; /** @var \Composer\Package\Link[] $required_links */ $required_links = $composer->getPackage()->getRequires(); $installed_packages = $composer->getRepositoryManager()->getLocalRepository()->getCanonicalPackages(); foreach ($installed_packages as $package) { if (in_array($package->getType(), $types, true)) { $version = array_key_exists($package->getName(), $required_links) ? $required_links[$package->getName()]->getPrettyConstraint() : '*'; $installed[$package->getName()] = $version; } } return $installed; } catch (\Exception $e) { return []; } } /** * Gets the list of the available packages of the configured type in the configured repositories * * /!\ Doesn't change the current working directory * * @param string $type Returns only the packages with the given type * * @return array The name of the available packages, associated to their definition. Ordered by name. * * @throws runtime_exception */ public function get_available_packages($type) { return $this->wrap(function() use ($type) { return $this->do_get_available_packages($type); }); } /** * Gets the list of the available packages of the configured type in the configured repositories * * @param string $type Returns only the packages with the given type * * @return array The name of the available packages, associated to their definition. Ordered by name. */ protected function do_get_available_packages($type) { try { $this->generate_ext_json_file($this->do_get_installed_packages(explode(',', self::PHPBB_TYPES))); $io = new NullIO(); $composer = Factory::create($io, $this->get_composer_ext_json_filename(), false); /** @var ConstraintInterface $core_constraint */ $core_constraint = $composer->getPackage()->getRequires()['phpbb/phpbb']->getConstraint(); $core_stability = $composer->getPackage()->getMinimumStability(); $available = []; $compatible_packages = []; $repositories = $composer->getRepositoryManager()->getRepositories(); /** @var \Composer\Repository\RepositoryInterface $repository */ foreach ($repositories as $repository) { try { if ($repository instanceof ComposerRepository && $repository->hasProviders()) { // Special case for packagist which exposes an api to retrieve all packages of a given type. // For the others composer repositories with providers we can't do anything. It would be too slow. $r = new \ReflectionObject($repository); $repo_url = $r->getProperty('url'); $repo_url->setAccessible(true); if ($repo_url->getValue($repository) === 'http://packagist.org') { $url = 'https://packagist.org/packages/list.json?type=' . $type; $rfs = new RemoteFilesystem($io); $hostname = parse_url($url, PHP_URL_HOST) ?: $url; $json = $rfs->getContents($hostname, $url, false); /** @var \Composer\Package\PackageInterface $package */ foreach (JsonFile::parseJson($json, $url)['packageNames'] as $package) { $versions = $repository->findPackages($package); $compatible_packages = $this->get_compatible_versions($compatible_packages, $core_constraint, $core_stability, $package, $versions); } } } else { // Pre-filter repo packages by their type $packages = []; /** @var \Composer\Package\PackageInterface $package */ foreach ($repository->getPackages() as $package) { if ($package->getType() === $type) { $packages[$package->getName()][] = $package; } } // Filter the compatibles versions foreach ($packages as $package => $versions) { $compatible_packages = $this->get_compatible_versions($compatible_packages, $core_constraint, $core_stability, $package, $versions); } } } catch (\Exception $e) { // If a repo fails, just skip it. continue; } } foreach ($compatible_packages as $name => $versions) { // Determine the highest version of the package /** @var CompletePackage $highest_version */ $highest_version = null; /** @var CompletePackage $version */ foreach ($versions as $version) { if (!$highest_version || version_compare($version->getVersion(), $highest_version->getVersion(), '>')) { $highest_version = $version; } } // Generates the entry $available[$name] = []; $available[$name]['name'] = $highest_version->getPrettyName(); $available[$name]['display_name'] = $highest_version->getExtra()['display-name']; $available[$name]['composer_name'] = $highest_version->getName(); $available[$name]['version'] = $highest_version->getPrettyVersion(); if ($version instanceof CompletePackage) { $available[$name]['description'] = $highest_version->getDescription(); $available[$name]['url'] = $highest_version->getHomepage(); $available[$name]['authors'] = $highest_version->getAuthors(); } else { $available[$name]['description'] = ''; $available[$name]['url'] = ''; $available[$name]['authors'] = []; } } usort($available, function($a, $b) { return strcasecmp($a['display_name'], $b['display_name']); }); return $available; } catch (\Exception $e) { return []; } } /** * Checks the requirements of the manager and returns true if it can be used. * * @return bool */ public function check_requirements() { $filesystem = new \phpbb\filesystem\filesystem(); return $filesystem->is_writable([ $this->root_path . $this->composer_filename, $this->root_path . $this->packages_vendor_dir, $this->root_path . substr($this->composer_filename, 0, -5) . '.lock', ]); } /** * Updates $compatible_packages with the versions of $versions compatibles with the $core_constraint * * @param array $compatible_packages List of compatibles versions * @param ConstraintInterface $core_constraint Constraint against the phpBB version * @param string $core_stability Core stability * @param string $package_name Considered package * @param array $versions List of available versions * * @return array */ private function get_compatible_versions(array $compatible_packages, ConstraintInterface $core_constraint, $core_stability, $package_name, array $versions) { $core_stability_value = BasePackage::$stabilities[$core_stability]; /** @var \Composer\Package\PackageInterface $version */ foreach ($versions as $version) { try { if (BasePackage::$stabilities[$version->getStability()] > $core_stability_value) { continue; } if (array_key_exists('phpbb/phpbb', $version->getRequires())) { /** @var ConstraintInterface $package_constraint */ $package_constraint = $version->getRequires()['phpbb/phpbb']->getConstraint(); if (!$package_constraint->matches($core_constraint)) { continue; } } $compatible_packages[$package_name][] = $version; } catch (\Exception $e) { // Do nothing (to log when a true debug logger is available) } } return $compatible_packages; } /** * Generates and write the json file used to install the set of packages * * @param array $packages Packages to update. * Each entry may be a name or an array associating a version constraint to a name */ protected function generate_ext_json_file(array $packages) { $io = new NullIO(); $composer = Factory::create($io, null, false); $core_packages = $this->get_core_packages($composer); // The composer/installers package must be installed on his own and not provided by the existing autoloader $core_replace = $core_packages; unset($core_replace['composer/installers']); $ext_json_data = [ 'require' => array_merge( ['php' => $this->get_core_php_requirement($composer)], $core_packages, $this->get_extra_dependencies(), $packages), 'replace' => $core_replace, 'repositories' => $this->get_composer_repositories(), 'config' => [ 'vendor-dir'=> $this->packages_vendor_dir, ], 'minimum-stability' => $this->minimum_stability, ]; $this->ext_json_file_backup = null; $json_file = new JsonFile($this->get_composer_ext_json_filename()); try { $ext_json_file_backup = $json_file->read(); } catch (ParsingException $e) { $ext_json_file_backup = '{}'; $lockFile = new JsonFile(substr($this->get_composer_ext_json_filename(), 0, -5) . '.lock'); $lockFile->write([]); } $json_file->write($ext_json_data); $this->ext_json_file_backup = $ext_json_file_backup; } /** * Restore the json file overridden by generate_ext_json_file() */ protected function restore_ext_json_file() { if ($this->ext_json_file_backup) { try { $json_file = new JsonFile($this->get_composer_ext_json_filename()); $json_file->write($this->ext_json_file_backup); } catch (\Exception $e) { } $this->ext_json_file_backup = null; } } /** * Get the core installed packages * * @param Composer $composer Composer object to load the dependencies * @return array The core packages with their version */ protected function get_core_packages(Composer $composer) { $core_deps = []; $packages = $composer->getRepositoryManager()->getLocalRepository()->getCanonicalPackages(); foreach ($packages as $package) { $core_deps[$package->getName()] = $package->getPrettyVersion(); } $core_deps['phpbb/phpbb'] = PHPBB_VERSION; return $core_deps; } /** * Get the PHP version required by the core * * @param Composer $composer Composer object to load the dependencies * @return string The PHP version required by the core */ protected function get_core_php_requirement(Composer $composer) { return $composer->getLocker()->getLockData()['platform']['php']; } /** * Generate the repositories entry of the packages json file * * @return array repositories entry */ protected function get_composer_repositories() { $repositories = []; if (!$this->packagist) { $repositories[]['packagist'] = false; } foreach ($this->repositories as $repository) { if (preg_match('#^' . get_preg_expression('url') . '$#iu', $repository)) { $repositories[] = [ 'type' => 'composer', 'url' => $repository, ]; } } return $repositories; } /** * Get the name of the json file used for the packages. * * @return string The json filename */ protected function get_composer_ext_json_filename() { return $this->composer_filename; } /** * Get extra dependencies required to install the packages * * @return array Array of composer dependencies */ protected function get_extra_dependencies() { return []; } /** * Sets the customs repositories * * @param array $repositories An array of composer repositories to use */ public function set_repositories(array $repositories) { $this->repositories = $repositories; } /** * Allow or disallow packagist * * @param boolean $packagist */ public function set_packagist($packagist) { $this->packagist = $packagist; } /** * Sets the name of the managed packages' json file * * @param string $composer_filename */ public function set_composer_filename($composer_filename) { $this->composer_filename = $composer_filename; } /** * Sets the location of the managed packages' vendors * * @param string $packages_vendor_dir */ public function set_packages_vendor_dir($packages_vendor_dir) { $this->packages_vendor_dir = $packages_vendor_dir; } /** * Sets the phpBB root path * * @param string $root_path */ public function set_root_path($root_path) { $this->root_path = $root_path; } /** * Change the current directory to phpBB root */ protected function move_to_root() { if ($this->original_cwd === null) { $this->original_cwd = getcwd(); chdir($this->root_path); } } /** * Restore the current working directory if move_to_root() have been called */ protected function restore_cwd() { if ($this->original_cwd) { chdir($this->original_cwd); $this->original_cwd = null; } } /** * Wraps a callable in order to adjust the context needed by composer * * @param callable $callable * * @return mixed */ protected function wrap(callable $callable) { // The composer installers works with a path relative to the current directory $this->move_to_root(); // The composer installers uses some super globals $super_globals = $this->request->super_globals_disabled(); $this->request->enable_super_globals(); try { return $callable(); } finally { $this->restore_cwd(); if ($super_globals) { $this->request->disable_super_globals(); } } } }