shell bypass 403
<?php /** * @package Joomla.Administrator * @subpackage com_joomlaupdate * * @copyright (C) 2012 Open Source Matters, Inc. <https://www.joomla.org> * @license GNU General Public License version 2 or later; see LICENSE.txt */ namespace Joomla\Component\Joomlaupdate\Administrator\Model; use Joomla\CMS\Authentication\Authentication; use Joomla\CMS\Component\ComponentHelper; use Joomla\CMS\Extension\ExtensionHelper; use Joomla\CMS\Factory; use Joomla\CMS\Filesystem\File as FileCMS; use Joomla\CMS\Filter\InputFilter; use Joomla\CMS\Http\Http; use Joomla\CMS\Http\HttpFactory; use Joomla\CMS\Installer\Installer; use Joomla\CMS\Language\Text; use Joomla\CMS\Log\Log; use Joomla\CMS\MVC\Factory\MVCFactoryInterface; use Joomla\CMS\MVC\Model\BaseDatabaseModel; use Joomla\CMS\Plugin\PluginHelper; use Joomla\CMS\Updater\Update; use Joomla\CMS\Updater\Updater; use Joomla\CMS\User\UserHelper; use Joomla\CMS\Version; use Joomla\Database\ParameterType; use Joomla\Filesystem\File; use Joomla\Registry\Registry; use Joomla\Utilities\ArrayHelper; // phpcs:disable PSR1.Files.SideEffects \defined('_JEXEC') or die; // phpcs:enable PSR1.Files.SideEffects /** * Joomla! update overview Model * * @since 2.5.4 */ class UpdateModel extends BaseDatabaseModel { /** * @var array $updateInformation null * Holds the update information evaluated in getUpdateInformation. * * @since 3.10.0 */ private $updateInformation = null; /** * Constructor * * @param array $config An array of configuration options. * @param ?MVCFactoryInterface $factory The factory. * * @since 4.4.0 * @throws \Exception */ public function __construct($config = [], MVCFactoryInterface $factory = null) { parent::__construct($config, $factory); // Register a logger for update process $options = [ 'format' => '{DATE}\t{TIME}\t{LEVEL}\t{CODE}\t{MESSAGE}', 'text_file' => 'joomla_update.php', ]; Log::addLogger($options, Log::ALL, ['Update', 'databasequery', 'jerror']); } /** * Detects if the Joomla! update site currently in use matches the one * configured in this component. If they don't match, it changes it. * * @return void * * @since 2.5.4 */ public function applyUpdateSite() { // Determine the intended update URL. $params = ComponentHelper::getParams('com_joomlaupdate'); switch ($params->get('updatesource', 'nochange')) { // "Minor & Patch Release for Current version AND Next Major Release". case 'next': $updateURL = 'https://update.joomla.org/core/sts/list_sts.xml'; break; // "Testing" case 'testing': $updateURL = 'https://update.joomla.org/core/test/list_test.xml'; break; // "Custom" // @todo: check if the customurl is valid and not just "not empty". case 'custom': if (trim($params->get('customurl', '')) != '') { $updateURL = trim($params->get('customurl', '')); } else { Factory::getApplication()->enqueueMessage(Text::_('COM_JOOMLAUPDATE_CONFIG_UPDATESOURCE_CUSTOM_ERROR'), 'error'); return; } break; /** * "Minor & Patch Release for Current version (recommended and default)". * The commented "case" below are for documenting where 'default' and legacy options falls * case 'default': * case 'lts': * case 'sts': (It's shown as "Default" because that option does not exist any more) * case 'nochange': */ default: $updateURL = 'https://update.joomla.org/core/list.xml'; } $id = ExtensionHelper::getExtensionRecord('joomla', 'file')->extension_id; $db = version_compare(JVERSION, '4.2.0', 'lt') ? $this->getDbo() : $this->getDatabase(); $query = $db->getQuery(true) ->select($db->quoteName('us') . '.*') ->from($db->quoteName('#__update_sites_extensions', 'map')) ->join( 'INNER', $db->quoteName('#__update_sites', 'us'), $db->quoteName('us.update_site_id') . ' = ' . $db->quoteName('map.update_site_id') ) ->where($db->quoteName('map.extension_id') . ' = :id') ->bind(':id', $id, ParameterType::INTEGER); $db->setQuery($query); $update_site = $db->loadObject(); if ($update_site->location != $updateURL) { // Modify the database record. $update_site->last_check_timestamp = 0; $update_site->location = $updateURL; $db->updateObject('#__update_sites', $update_site, 'update_site_id'); // Remove cached updates. $query->clear() ->delete($db->quoteName('#__updates')) ->where($db->quoteName('extension_id') . ' = :id') ->bind(':id', $id, ParameterType::INTEGER); $db->setQuery($query); $db->execute(); } } /** * Makes sure that the Joomla! update cache is up-to-date. * * @param boolean $force Force reload, ignoring the cache timeout. * * @return void * * @since 2.5.4 */ public function refreshUpdates($force = false) { if ($force) { $cache_timeout = 0; } else { $update_params = ComponentHelper::getParams('com_installer'); $cache_timeout = (int) $update_params->get('cachetimeout', 6); $cache_timeout = 3600 * $cache_timeout; } $updater = Updater::getInstance(); $minimumStability = Updater::STABILITY_STABLE; $comJoomlaupdateParams = ComponentHelper::getParams('com_joomlaupdate'); if (in_array($comJoomlaupdateParams->get('updatesource', 'nochange'), ['testing', 'custom'])) { $minimumStability = $comJoomlaupdateParams->get('minimum_stability', Updater::STABILITY_STABLE); } $reflection = new \ReflectionObject($updater); $reflectionMethod = $reflection->getMethod('findUpdates'); $methodParameters = $reflectionMethod->getParameters(); if (count($methodParameters) >= 4) { // Reinstall support is available in Updater $updater->findUpdates(ExtensionHelper::getExtensionRecord('joomla', 'file')->extension_id, $cache_timeout, $minimumStability, true); } else { $updater->findUpdates(ExtensionHelper::getExtensionRecord('joomla', 'file')->extension_id, $cache_timeout, $minimumStability); } } /** * Makes sure that the Joomla! Update Component Update is in the database and check if there is a new version. * * @return boolean True if there is an update else false * * @since 4.0.0 */ public function getCheckForSelfUpdate() { $db = version_compare(JVERSION, '4.2.0', 'lt') ? $this->getDbo() : $this->getDatabase(); $query = $db->getQuery(true) ->select($db->quoteName('extension_id')) ->from($db->quoteName('#__extensions')) ->where($db->quoteName('element') . ' = ' . $db->quote('com_joomlaupdate')); $db->setQuery($query); try { // Get the component extension ID $joomlaUpdateComponentId = $db->loadResult(); } catch (\RuntimeException $e) { // Something is wrong here! $joomlaUpdateComponentId = 0; Factory::getApplication()->enqueueMessage($e->getMessage(), 'error'); } // Try the update only if we have an extension id if ($joomlaUpdateComponentId != 0) { // Always force to check for an update! $cache_timeout = 0; $updater = Updater::getInstance(); $updater->findUpdates($joomlaUpdateComponentId, $cache_timeout, Updater::STABILITY_STABLE); // Fetch the update information from the database. $query = $db->getQuery(true) ->select('*') ->from($db->quoteName('#__updates')) ->where($db->quoteName('extension_id') . ' = :id') ->bind(':id', $joomlaUpdateComponentId, ParameterType::INTEGER); $db->setQuery($query); try { $joomlaUpdateComponentObject = $db->loadObject(); } catch (\RuntimeException $e) { // Something is wrong here! $joomlaUpdateComponentObject = null; Factory::getApplication()->enqueueMessage($e->getMessage(), 'error'); } return !empty($joomlaUpdateComponentObject); } return false; } /** * Returns an array with the Joomla! update information. * * @return array * * @since 2.5.4 */ public function getUpdateInformation() { if ($this->updateInformation) { return $this->updateInformation; } // Initialise the return array. $this->updateInformation = [ 'installed' => \JVERSION, 'latest' => null, 'object' => null, 'hasUpdate' => false, 'current' => JVERSION, // This is deprecated please use 'installed' or JVERSION directly ]; // Fetch the update information from the database. $id = ExtensionHelper::getExtensionRecord('joomla', 'file')->extension_id; $db = version_compare(JVERSION, '4.2.0', 'lt') ? $this->getDbo() : $this->getDatabase(); $query = $db->getQuery(true) ->select('*') ->from($db->quoteName('#__updates')) ->where($db->quoteName('extension_id') . ' = :id') ->bind(':id', $id, ParameterType::INTEGER); $db->setQuery($query); $updateObject = $db->loadObject(); if (is_null($updateObject)) { // We have not found any update in the database - we seem to be running the latest version. $this->updateInformation['latest'] = \JVERSION; return $this->updateInformation; } // Check whether this is a valid update or not if (version_compare($updateObject->version, JVERSION, '<')) { // This update points to an outdated version. We should not offer to update to this. $this->updateInformation['latest'] = JVERSION; return $this->updateInformation; } $minimumStability = Updater::STABILITY_STABLE; $comJoomlaupdateParams = ComponentHelper::getParams('com_joomlaupdate'); if (in_array($comJoomlaupdateParams->get('updatesource', 'nochange'), ['testing', 'custom'])) { $minimumStability = $comJoomlaupdateParams->get('minimum_stability', Updater::STABILITY_STABLE); } // Fetch the full update details from the update details URL. $update = new Update(); $update->loadFromXml($updateObject->detailsurl, $minimumStability); // Make sure we use the current information we got from the detailsurl $this->updateInformation['object'] = $update; $this->updateInformation['latest'] = $updateObject->version; // Check whether this is an update or not. if (version_compare($this->updateInformation['latest'], JVERSION, '>')) { $this->updateInformation['hasUpdate'] = true; } return $this->updateInformation; } /** * Removes all of the updates from the table and enable all update streams. * * @return boolean Result of operation. * * @since 3.0 */ public function purge() { $db = version_compare(JVERSION, '4.2.0', 'lt') ? $this->getDbo() : $this->getDatabase(); // Modify the database record $update_site = new \stdClass(); $update_site->last_check_timestamp = 0; $update_site->enabled = 1; $update_site->update_site_id = 1; $db->updateObject('#__update_sites', $update_site, 'update_site_id'); $query = $db->getQuery(true) ->delete($db->quoteName('#__updates')) ->where($db->quoteName('update_site_id') . ' = 1'); $db->setQuery($query); if ($db->execute()) { $this->_message = Text::_('COM_JOOMLAUPDATE_CHECKED_UPDATES'); return true; } else { $this->_message = Text::_('COM_JOOMLAUPDATE_FAILED_TO_CHECK_UPDATES'); return false; } } /** * Downloads the update package to the site. * * @return array * * @since 2.5.4 */ public function download() { $updateInfo = $this->getUpdateInformation(); $packageURL = trim($updateInfo['object']->downloadurl->_data); $sources = $updateInfo['object']->get('downloadSources', []); // We have to manually follow the redirects here so we set the option to false. $httpOptions = new Registry(); $httpOptions->set('follow_location', false); try { $head = HttpFactory::getHttp($httpOptions)->head($packageURL); } catch (\RuntimeException $e) { // Passing false here -> download failed message $response['basename'] = false; return $response; } // Follow the Location headers until the actual download URL is known while (isset($head->headers['location'])) { $packageURL = (string) $head->headers['location'][0]; try { $head = HttpFactory::getHttp($httpOptions)->head($packageURL); } catch (\RuntimeException $e) { // Passing false here -> download failed message $response['basename'] = false; return $response; } } // Remove protocol, path and query string from URL $basename = basename($packageURL); if (strpos($basename, '?') !== false) { $basename = substr($basename, 0, strpos($basename, '?')); } // Find the path to the temp directory and the local package. $tempdir = (string) InputFilter::getInstance( [], [], InputFilter::ONLY_BLOCK_DEFINED_TAGS, InputFilter::ONLY_BLOCK_DEFINED_ATTRIBUTES ) ->clean(Factory::getApplication()->get('tmp_path'), 'path'); $target = $tempdir . '/' . $basename; $response = []; // Do we have a cached file? $exists = is_file($target); if (!$exists) { // Not there, let's fetch it. $mirror = 0; while (!($download = $this->downloadPackage($packageURL, $target)) && isset($sources[$mirror])) { $name = $sources[$mirror]; $packageURL = trim($name->url); $mirror++; } $response['basename'] = $download; } else { // Is it a 0-byte file? If so, re-download please. $filesize = @filesize($target); if (empty($filesize)) { $mirror = 0; while (!($download = $this->downloadPackage($packageURL, $target)) && isset($sources[$mirror])) { $name = $sources[$mirror]; $packageURL = trim($name->url); $mirror++; } $response['basename'] = $download; } // Yes, it's there, skip downloading. $response['basename'] = $basename; } $response['check'] = $this->isChecksumValid($target, $updateInfo['object']); return $response; } /** * Return the result of the checksum of a package with the SHA256/SHA384/SHA512 tags in the update server manifest * * @param string $packagefile Location of the package to be installed * @param Update $updateObject The Update Object * * @return boolean False in case the validation did not work; true in any other case. * * @note This method has been forked from (JInstallerHelper::isChecksumValid) so it * does not depend on an up-to-date InstallerHelper at the update time * * @since 3.9.0 */ private function isChecksumValid($packagefile, $updateObject) { $hashes = ['sha256', 'sha384', 'sha512']; foreach ($hashes as $hash) { if ($updateObject->get($hash, false)) { $hashPackage = hash_file($hash, $packagefile); $hashRemote = $updateObject->$hash->_data; if ($hashPackage !== $hashRemote) { // Return false in case the hash did not match return false; } } } // Well nothing was provided or all worked return true; } /** * Downloads a package file to a specific directory * * @param string $url The URL to download from * @param string $target The directory to store the file * * @return boolean True on success * * @since 2.5.4 */ protected function downloadPackage($url, $target) { try { Log::add(Text::sprintf('COM_JOOMLAUPDATE_UPDATE_LOG_URL', $url), Log::INFO, 'Update'); } catch (\RuntimeException $exception) { // Informational log only } // Make sure the target does not exist. if (is_file($target)) { File::delete($target); } // Download the package try { $result = HttpFactory::getHttp([], ['curl', 'stream'])->get($url); } catch (\RuntimeException $e) { return false; } if (!$result || ($result->code != 200 && $result->code != 310)) { return false; } // Fix Indirect Modification of Overloaded Property $body = $result->body; // Write the file to disk File::write($target, $body); return basename($target); } /** * Backwards compatibility. Use createUpdateFile() instead. * * @param null $basename The basename of the file to create * * @return boolean * @since 2.5.1 * * @deprecated 4.3 will be removed in 6.0 * Use "createUpdateFile" instead * Example: $updateModel->createUpdateFile($basename); */ public function createRestorationFile($basename = null): bool { return $this->createUpdateFile($basename); } /** * Create the update.php file and trigger onJoomlaBeforeUpdate event. * * The onJoomlaBeforeUpdate event stores the core files for which overrides have been defined. * This will be compared in the onJoomlaAfterUpdate event with the current filesystem state, * thereby determining how many and which overrides need to be checked and possibly updated * after Joomla installed an update. * * @param string $basename Optional base path to the file. * * @return boolean True if successful; false otherwise. * * @since 2.5.4 */ public function createUpdateFile($basename = null): bool { // Load overrides plugin. PluginHelper::importPlugin('installer'); // Get a password $password = UserHelper::genRandomPassword(32); $app = Factory::getApplication(); // Trigger event before joomla update. $app->triggerEvent('onJoomlaBeforeUpdate'); // Get the absolute path to site's root. $siteroot = JPATH_SITE; // If the package name is not specified, get it from the update info. if (empty($basename)) { $updateInfo = $this->getUpdateInformation(); $packageURL = $updateInfo['object']->downloadurl->_data; $basename = basename($packageURL); } // Get the package name. $config = $app->getConfig(); $tempdir = $config->get('tmp_path'); $file = $tempdir . '/' . $basename; $filesize = @filesize($file); $app->setUserState('com_joomlaupdate.password', $password); $app->setUserState('com_joomlaupdate.filesize', $filesize); $data = "<?php\ndefined('_JOOMLA_UPDATE') or die('Restricted access');\n"; $data .= '$extractionSetup = [' . "\n"; $data .= <<<ENDDATA 'security.password' => '$password', 'setup.sourcefile' => '$file', 'setup.destdir' => '$siteroot', ENDDATA; $data .= '];'; // Remove the old file, if it's there... $configpath = JPATH_COMPONENT_ADMINISTRATOR . '/update.php'; if (is_file($configpath)) { if (!File::delete($configpath)) { File::invalidateFileCache($configpath); @unlink($configpath); } } // Write new file. First try with File. $result = File::write($configpath, $data); // In case File used FTP but direct access could help. if (!$result) { if (function_exists('file_put_contents')) { $result = @file_put_contents($configpath, $data); if ($result !== false) { $result = true; } } else { $fp = @fopen($configpath, 'wt'); if ($fp !== false) { $result = @fwrite($fp, $data); if ($result !== false) { $result = true; } @fclose($fp); } } } return $result; } /** * Finalise the upgrade. * * This method will do the following: * * Run the schema update SQL files. * * Run the Joomla post-update script. * * Update the manifest cache and #__extensions entry for Joomla itself. * * It performs essentially the same function as InstallerFile::install() without the file copy. * * @return boolean True on success. * * @since 2.5.4 */ public function finaliseUpgrade() { Log::add(Text::_('COM_JOOMLAUPDATE_UPDATE_LOG_FINALISE'), Log::INFO, 'Update'); $installer = Installer::getInstance(); $manifest = $installer->isManifest(JPATH_MANIFESTS . '/files/joomla.xml'); if ($manifest === false) { $installer->abort(Text::_('JLIB_INSTALLER_ABORT_DETECTMANIFEST')); return false; } $installer->manifest = $manifest; $installer->setUpgrade(true); $installer->setOverwrite(true); $db = version_compare(JVERSION, '4.2.0', 'lt') ? $this->getDbo() : $this->getDatabase(); $installer->extension = new \Joomla\CMS\Table\Extension($db); $installer->extension->load(ExtensionHelper::getExtensionRecord('joomla', 'file')->extension_id); $installer->setAdapter($installer->extension->type); $installer->setPath('manifest', JPATH_MANIFESTS . '/files/joomla.xml'); $installer->setPath('source', JPATH_MANIFESTS . '/files'); $installer->setPath('extension_root', JPATH_ROOT); // Run the script file. \JLoader::register('JoomlaInstallerScript', JPATH_ADMINISTRATOR . '/components/com_admin/script.php'); $msg = ''; $manifestClass = new \JoomlaInstallerScript(); $manifestClass->setErrorCollector(function (string $context, \Throwable $error) { $this->collectError($context, $error); }); // Run Installer preflight try { ob_start(); if ($manifestClass->preflight('update', $installer) === false) { $this->collectError('JoomlaInstallerScript::preflight', new \Exception('Script::preflight finished with "false" result.')); $installer->abort( Text::sprintf( 'JLIB_INSTALLER_ABORT_INSTALL_CUSTOM_INSTALL_FAILURE', Text::_('JLIB_INSTALLER_INSTALL') ) ); return false; } // Append messages. $msg .= ob_get_contents(); ob_end_clean(); } catch (\Throwable $e) { $this->collectError('JoomlaInstallerScript::preflight', $e); return false; } // Get a database connector object. $db = version_compare(JVERSION, '4.2.0', 'lt') ? $this->getDbo() : $this->getDatabase(); /* * Check to see if a file extension by the same name is already installed. * If it is, then update the table because if the files aren't there * we can assume that it was (badly) uninstalled. * If it isn't, add an entry to extensions. */ $query = $db->getQuery(true) ->select($db->quoteName('extension_id')) ->from($db->quoteName('#__extensions')) ->where($db->quoteName('type') . ' = ' . $db->quote('file')) ->where($db->quoteName('element') . ' = ' . $db->quote('joomla')); $db->setQuery($query); try { $db->execute(); } catch (\RuntimeException $e) { $this->collectError('Extension check', $e); // Install failed, roll back changes. $installer->abort( Text::sprintf('JLIB_INSTALLER_ABORT_FILE_ROLLBACK', Text::_('JLIB_INSTALLER_UPDATE'), $e->getMessage()) ); return false; } $id = $db->loadResult(); $row = new \Joomla\CMS\Table\Extension($db); if ($id) { // Load the entry and update the manifest_cache. $row->load($id); // Update name. $row->set('name', 'files_joomla'); // Update manifest. $row->manifest_cache = $installer->generateManifestCache(); if (!$row->store()) { $this->collectError('Update the manifest_cache', new \Exception('Update the manifest_cache finished with "false" result.')); // Install failed, roll back changes. $installer->abort( Text::sprintf('JLIB_INSTALLER_ABORT_FILE_ROLLBACK', Text::_('JLIB_INSTALLER_UPDATE'), $row->getError()) ); return false; } } else { // Add an entry to the extension table with a whole heap of defaults. $row->set('name', 'files_joomla'); $row->set('type', 'file'); $row->set('element', 'joomla'); // There is no folder for files so leave it blank. $row->set('folder', ''); $row->set('enabled', 1); $row->set('protected', 0); $row->set('access', 0); $row->set('client_id', 0); $row->set('params', ''); $row->set('manifest_cache', $installer->generateManifestCache()); if (!$row->store()) { $this->collectError('Write the manifest_cache', new \Exception('Writing the manifest_cache finished with "false" result.')); // Install failed, roll back changes. $installer->abort(Text::sprintf('JLIB_INSTALLER_ABORT_FILE_INSTALL_ROLLBACK', $row->getError())); return false; } // Set the insert id. $row->set('extension_id', $db->insertid()); // Since we have created a module item, we add it to the installation step stack // so that if we have to rollback the changes we can undo it. $installer->pushStep(['type' => 'extension', 'extension_id' => $row->extension_id]); } $result = $installer->parseSchemaUpdates($manifest->update->schemas, $row->extension_id); if ($result === false) { $this->collectError('installer::parseSchemaUpdates', new \Exception('installer::parseSchemaUpdates finished with "false" result.')); // Install failed, rollback changes (message already logged by the installer). $installer->abort(); return false; } // Reinitialise the installer's extensions table's properties. $installer->extension->getFields(true); try { ob_start(); if ($manifestClass->update($installer) === false) { $this->collectError('JoomlaInstallerScript::update', new \Exception('Script::update finished with "false" result.')); // Install failed, rollback changes. $installer->abort( Text::sprintf( 'JLIB_INSTALLER_ABORT_INSTALL_CUSTOM_INSTALL_FAILURE', Text::_('JLIB_INSTALLER_INSTALL') ) ); return false; } // Append messages. $msg .= ob_get_contents(); ob_end_clean(); } catch (\Throwable $e) { $this->collectError('JoomlaInstallerScript::update', $e); return false; } // Clobber any possible pending updates. $update = new \Joomla\CMS\Table\Update($db); $uid = $update->find( ['element' => 'joomla', 'type' => 'file', 'client_id' => '0', 'folder' => ''] ); if ($uid) { $update->delete($uid); } // And now we run the postflight. try { ob_start(); $manifestClass->postflight('update', $installer); // Append messages. $msg .= ob_get_contents(); ob_end_clean(); } catch (\Throwable $e) { $this->collectError('JoomlaInstallerScript::postflight', $e); return false; } if ($msg) { $installer->set('extension_message', $msg); } return true; } /** * Removes the extracted package file and trigger onJoomlaAfterUpdate event. * * The onJoomlaAfterUpdate event compares the stored list of files previously overridden with * the updated core files, finding out which files have changed during the update, thereby * determining how many and which override files need to be checked and possibly updated after * the Joomla update. * * @return void * * @since 2.5.4 */ public function cleanUp() { try { Log::add(Text::_('COM_JOOMLAUPDATE_UPDATE_LOG_CLEANUP'), Log::INFO, 'Update'); } catch (\RuntimeException $exception) { // Informational log only } // Load overrides plugin. PluginHelper::importPlugin('installer'); $app = Factory::getApplication(); // Trigger event after joomla update. $app->triggerEvent('onJoomlaAfterUpdate'); // Remove the update package. $tempdir = $app->get('tmp_path'); $file = $app->getUserState('com_joomlaupdate.file', null); if (is_file($tempdir . '/' . $file)) { File::delete($tempdir . '/' . $file); } // Remove the update.php file used in Joomla 4.0.3 and later. if (is_file(JPATH_COMPONENT_ADMINISTRATOR . '/update.php')) { File::delete(JPATH_COMPONENT_ADMINISTRATOR . '/update.php'); } // Remove the legacy restoration.php file (when updating from Joomla 4.0.2 and earlier). if (is_file(JPATH_COMPONENT_ADMINISTRATOR . '/restoration.php')) { File::delete(JPATH_COMPONENT_ADMINISTRATOR . '/restoration.php'); } // Remove the legacy restore_finalisation.php file used in Joomla 4.0.2 and earlier. if (is_file(JPATH_COMPONENT_ADMINISTRATOR . '/restore_finalisation.php')) { File::delete(JPATH_COMPONENT_ADMINISTRATOR . '/restore_finalisation.php'); } // Remove joomla.xml from the site's root. if (is_file(JPATH_ROOT . '/joomla.xml')) { File::delete(JPATH_ROOT . '/joomla.xml'); } // Unset the update filename from the session. $app = Factory::getApplication(); $app->setUserState('com_joomlaupdate.file', null); $oldVersion = $app->getUserState('com_joomlaupdate.oldversion'); // Trigger event after joomla update. $app->triggerEvent('onJoomlaAfterUpdate', [$oldVersion]); $app->setUserState('com_joomlaupdate.oldversion', null); try { Log::add(Text::sprintf('COM_JOOMLAUPDATE_UPDATE_LOG_COMPLETE', \JVERSION), Log::INFO, 'Update'); } catch (\RuntimeException $exception) { // Informational log only } } /** * Uploads what is presumably an update ZIP file under a mangled name in the temporary directory. * * @return void * * @since 3.6.0 */ public function upload() { // Get the uploaded file information. $input = Factory::getApplication()->getInput(); // Do not change the filter type 'raw'. We need this to let files containing PHP code to upload. See \JInputFiles::get. $userfile = $input->files->get('install_package', null, 'raw'); // Make sure that file uploads are enabled in php. if (!(bool) ini_get('file_uploads')) { throw new \RuntimeException(Text::_('COM_INSTALLER_MSG_INSTALL_WARNINSTALLFILE'), 500); } // Make sure that zlib is loaded so that the package can be unpacked. if (!extension_loaded('zlib')) { throw new \RuntimeException(Text::_('COM_INSTALLER_MSG_INSTALL_WARNINSTALLZLIB'), 500); } // If there is no uploaded file, we have a problem... if (!is_array($userfile)) { throw new \RuntimeException(Text::_('COM_INSTALLER_MSG_INSTALL_NO_FILE_SELECTED'), 500); } // Is the PHP tmp directory missing? if ($userfile['error'] && ($userfile['error'] == UPLOAD_ERR_NO_TMP_DIR)) { throw new \RuntimeException( Text::_('COM_INSTALLER_MSG_INSTALL_WARNINSTALLUPLOADERROR') . '<br>' . Text::_('COM_INSTALLER_MSG_WARNINGS_PHPUPLOADNOTSET'), 500 ); } // Is the max upload size too small in php.ini? if ($userfile['error'] && ($userfile['error'] == UPLOAD_ERR_INI_SIZE)) { throw new \RuntimeException( Text::_('COM_INSTALLER_MSG_INSTALL_WARNINSTALLUPLOADERROR') . '<br>' . Text::_('COM_INSTALLER_MSG_WARNINGS_SMALLUPLOADSIZE'), 500 ); } // Check if there was a different problem uploading the file. if ($userfile['error'] || $userfile['size'] < 1) { throw new \RuntimeException(Text::_('COM_INSTALLER_MSG_INSTALL_WARNINSTALLUPLOADERROR'), 500); } // Check the uploaded file (throws RuntimeException when a check failed) if (\extension_loaded('zip')) { $this->checkPackageFileZip($userfile['tmp_name'], $userfile['name']); } else { $this->checkPackageFileNoZip($userfile['tmp_name'], $userfile['name']); } // Build the appropriate paths. $tmp_dest = tempnam(Factory::getApplication()->get('tmp_path'), 'ju'); $tmp_src = $userfile['tmp_name']; // Move uploaded file. $result = FileCMS::upload($tmp_src, $tmp_dest, false, true); if (!$result) { throw new \RuntimeException(Text::_('COM_INSTALLER_MSG_INSTALL_WARNINSTALLUPLOADERROR'), 500); } Factory::getApplication()->setUserState('com_joomlaupdate.temp_file', $tmp_dest); } /** * Checks the super admin credentials are valid for the currently logged in users * * @param array $credentials The credentials to authenticate the user with * * @return boolean * * @since 3.6.0 */ public function captiveLogin($credentials) { // Make sure the username matches $username = $credentials['username'] ?? null; $user = $this->getCurrentUser(); if (strtolower($user->username) != strtolower($username)) { return false; } // Make sure the user is authorised if (!$user->authorise('core.admin')) { return false; } // Get the global Authentication object. $authenticate = Authentication::getInstance(); $response = $authenticate->authenticate($credentials); if ($response->status !== Authentication::STATUS_SUCCESS) { return false; } return true; } /** * Does the captive (temporary) file we uploaded before still exist? * * @return boolean * * @since 3.6.0 */ public function captiveFileExists() { $file = Factory::getApplication()->getUserState('com_joomlaupdate.temp_file', null); if (empty($file) || !is_file($file)) { return false; } return true; } /** * Remove the captive (temporary) file we uploaded before and the . * * @return void * * @since 3.6.0 */ public function removePackageFiles() { $files = [ Factory::getApplication()->getUserState('com_joomlaupdate.temp_file', null), Factory::getApplication()->getUserState('com_joomlaupdate.file', null), ]; foreach ($files as $file) { if ($file !== null && is_file($file)) { File::delete($file); } } } /** * Gets PHP options. * @todo: Outsource, build common code base for pre install and pre update check * * @return array Array of PHP config options * * @since 3.10.0 */ public function getPhpOptions() { $options = []; /* * Check the PHP Version. It is already checked in Update. * A Joomla! Update which is not supported by current PHP * version is not shown. So this check is actually unnecessary. */ $option = new \stdClass(); $option->label = Text::sprintf('INSTL_PHP_VERSION_NEWER', $this->getTargetMinimumPHPVersion()); $option->state = $this->isPhpVersionSupported(); $option->notice = null; $options[] = $option; // Check for zlib support. $option = new \stdClass(); $option->label = Text::_('INSTL_ZLIB_COMPRESSION_SUPPORT'); $option->state = extension_loaded('zlib'); $option->notice = null; $options[] = $option; // Check for XML support. $option = new \stdClass(); $option->label = Text::_('INSTL_XML_SUPPORT'); $option->state = extension_loaded('xml'); $option->notice = null; $options[] = $option; // Check for mbstring options. if (extension_loaded('mbstring')) { // Check for default MB language. $option = new \stdClass(); $option->label = Text::_('INSTL_MB_LANGUAGE_IS_DEFAULT'); $option->state = strtolower(ini_get('mbstring.language')) === 'neutral'; $option->notice = $option->state ? null : Text::_('INSTL_NOTICEMBLANGNOTDEFAULT'); $options[] = $option; // Check for MB function overload. $option = new \stdClass(); $option->label = Text::_('INSTL_MB_STRING_OVERLOAD_OFF'); $option->state = ini_get('mbstring.func_overload') == 0; $option->notice = $option->state ? null : Text::_('INSTL_NOTICEMBSTRINGOVERLOAD'); $options[] = $option; } // Check for a missing native parse_ini_file implementation. $option = new \stdClass(); $option->label = Text::_('INSTL_PARSE_INI_FILE_AVAILABLE'); $option->state = $this->getIniParserAvailability(); $option->notice = null; $options[] = $option; // Check for missing native json_encode / json_decode support. $option = new \stdClass(); $option->label = Text::_('INSTL_JSON_SUPPORT_AVAILABLE'); $option->state = function_exists('json_encode') && function_exists('json_decode'); $option->notice = null; $options[] = $option; $updateInformation = $this->getUpdateInformation(); // Check if configured database is compatible with the next major version of Joomla $nextMajorVersion = Version::MAJOR_VERSION + 1; if (version_compare($updateInformation['latest'], (string) $nextMajorVersion, '>=')) { $option = new \stdClass(); $option->label = Text::sprintf('INSTL_DATABASE_SUPPORTED', $this->getConfiguredDatabaseType()); $option->state = $this->isDatabaseTypeSupported(); $option->notice = null; $options[] = $option; } // Check if database structure is up to date $option = new \stdClass(); $option->label = Text::_('COM_JOOMLAUPDATE_VIEW_DEFAULT_DATABASE_STRUCTURE_TITLE'); $option->state = $this->getDatabaseSchemaCheck(); $option->notice = $option->state ? null : Text::_('COM_JOOMLAUPDATE_VIEW_DEFAULT_DATABASE_STRUCTURE_NOTICE'); $options[] = $option; return $options; } /** * Gets PHP Settings. * @todo: Outsource, build common code base for pre install and pre update check * * @return array * * @since 3.10.0 */ public function getPhpSettings() { $settings = []; // Check for display errors. $setting = new \stdClass(); $setting->label = Text::_('INSTL_DISPLAY_ERRORS'); $setting->state = (bool) ini_get('display_errors'); $setting->recommended = false; $settings[] = $setting; // Check for file uploads. $setting = new \stdClass(); $setting->label = Text::_('INSTL_FILE_UPLOADS'); $setting->state = (bool) ini_get('file_uploads'); $setting->recommended = true; $settings[] = $setting; // Check for output buffering. $setting = new \stdClass(); $setting->label = Text::_('INSTL_OUTPUT_BUFFERING'); $setting->state = (int) ini_get('output_buffering') !== 0; $setting->recommended = false; $settings[] = $setting; // Check for session auto-start. $setting = new \stdClass(); $setting->label = Text::_('INSTL_SESSION_AUTO_START'); $setting->state = (bool) ini_get('session.auto_start'); $setting->recommended = false; $settings[] = $setting; // Check for native ZIP support. $setting = new \stdClass(); $setting->label = Text::_('INSTL_ZIP_SUPPORT_AVAILABLE'); $setting->state = function_exists('zip_open') && function_exists('zip_read'); $setting->recommended = true; $settings[] = $setting; // Check for GD support $setting = new \stdClass(); $setting->label = Text::sprintf('INSTL_EXTENSION_AVAILABLE', 'GD'); $setting->state = extension_loaded('gd'); $setting->recommended = true; $settings[] = $setting; // Check for iconv support $setting = new \stdClass(); $setting->label = Text::sprintf('INSTL_EXTENSION_AVAILABLE', 'iconv'); $setting->state = function_exists('iconv'); $setting->recommended = true; $settings[] = $setting; // Check for intl support $setting = new \stdClass(); $setting->label = Text::sprintf('INSTL_EXTENSION_AVAILABLE', 'intl'); $setting->state = function_exists('transliterator_transliterate'); $setting->recommended = true; $settings[] = $setting; return $settings; } /** * Returns the configured database type id (mysqli or sqlsrv or ...) * * @return string * * @since 3.10.0 */ private function getConfiguredDatabaseType() { return Factory::getApplication()->get('dbtype'); } /** * Returns true, if J! version is < 4 or current configured * database type is compatible with the update. * * @return boolean * * @since 3.10.0 */ public function isDatabaseTypeSupported() { $updateInformation = $this->getUpdateInformation(); $nextMajorVersion = Version::MAJOR_VERSION + 1; // Check if configured database is compatible with Joomla 4 if (version_compare($updateInformation['latest'], (string) $nextMajorVersion, '>=')) { $unsupportedDatabaseTypes = ['sqlsrv', 'sqlazure']; $currentDatabaseType = $this->getConfiguredDatabaseType(); return !in_array($currentDatabaseType, $unsupportedDatabaseTypes); } return true; } /** * Returns true, if current installed php version is compatible with the update. * * @return boolean * * @since 3.10.0 */ public function isPhpVersionSupported() { return version_compare(PHP_VERSION, $this->getTargetMinimumPHPVersion(), '>='); } /** * Returns the PHP minimum version for the update. * Returns JOOMLA_MINIMUM_PHP, if there is no information given. * * @return string * * @since 3.10.0 */ private function getTargetMinimumPHPVersion() { $updateInformation = $this->getUpdateInformation(); return isset($updateInformation['object']->php_minimum) ? $updateInformation['object']->php_minimum->_data : JOOMLA_MINIMUM_PHP; } /** * Checks the availability of the parse_ini_file and parse_ini_string functions. * @todo: Outsource, build common code base for pre install and pre update check * * @return boolean True if the method exists. * * @since 3.10.0 */ public function getIniParserAvailability() { $disabledFunctions = ini_get('disable_functions'); if (!empty($disabledFunctions)) { // Attempt to detect them in the PHP INI disable_functions variable. $disabledFunctions = explode(',', trim($disabledFunctions)); $numberOfDisabledFunctions = count($disabledFunctions); for ($i = 0; $i < $numberOfDisabledFunctions; $i++) { $disabledFunctions[$i] = trim($disabledFunctions[$i]); } $result = !in_array('parse_ini_string', $disabledFunctions); } else { // Attempt to detect their existence; even pure PHP implementations of them will trigger a positive response, though. $result = function_exists('parse_ini_string'); } return $result; } /** * Check if database structure is up to date * * @return boolean True if ok, false if not. * * @since 3.10.0 */ private function getDatabaseSchemaCheck(): bool { $mvcFactory = $this->bootComponent('com_installer')->getMVCFactory(); /** @var \Joomla\Component\Installer\Administrator\Model\DatabaseModel $model */ $model = $mvcFactory->createModel('Database', 'Administrator'); // Check if no default text filters found if (!$model->getDefaultTextFilters()) { return false; } $coreExtensionInfo = \Joomla\CMS\Extension\ExtensionHelper::getExtensionRecord('joomla', 'file'); $cache = new \Joomla\Registry\Registry($coreExtensionInfo->manifest_cache); $updateVersion = $cache->get('version'); // Check if database update version does not match CMS version if (version_compare($updateVersion, JVERSION) != 0) { return false; } // Ensure we only get information for core $model->setState('filter.extension_id', $coreExtensionInfo->extension_id); // We're filtering by a single extension which must always exist - so can safely access this through // element 0 of the array $changeInformation = $model->getItems()[0]; // Check if schema errors found if ($changeInformation['errorsCount'] !== 0) { return false; } // Check if database schema version does not match CMS version if ($model->getSchemaVersion($coreExtensionInfo->extension_id) != $changeInformation['schema']) { return false; } // No database problems found return true; } /** * Gets an array containing all installed extensions, that are not core extensions. * * @return array name,version,updateserver * * @since 3.10.0 */ public function getNonCoreExtensions() { $db = version_compare(JVERSION, '4.2.0', 'lt') ? $this->getDbo() : $this->getDatabase(); $query = $db->getQuery(true); $query->select( [ $db->quoteName('ex.name'), $db->quoteName('ex.extension_id'), $db->quoteName('ex.manifest_cache'), $db->quoteName('ex.type'), $db->quoteName('ex.folder'), $db->quoteName('ex.element'), $db->quoteName('ex.client_id'), ] ) ->from($db->quoteName('#__extensions', 'ex')) ->where($db->quoteName('ex.package_id') . ' = 0') ->whereNotIn($db->quoteName('ex.extension_id'), ExtensionHelper::getCoreExtensionIds()); $db->setQuery($query); $rows = $db->loadObjectList(); foreach ($rows as $extension) { $decode = json_decode($extension->manifest_cache); // Remove unused fields so they do not cause javascript errors during pre-update check unset($decode->description); unset($decode->copyright); unset($decode->creationDate); $this->translateExtensionName($extension); $extension->version = isset($decode->version) ? $decode->version : Text::_('COM_JOOMLAUPDATE_PREUPDATE_UNKNOWN_EXTENSION_MANIFESTCACHE_VERSION'); unset($extension->manifest_cache); $extension->manifest_cache = $decode; } return $rows; } /** * Gets an array containing all installed and enabled plugins, that are not core plugins. * * @param array $folderFilter Limit the list of plugins to a specific set of folder values * * @return array name,version,updateserver * * @since 3.10.0 */ public function getNonCorePlugins($folderFilter = ['system', 'user', 'authentication', 'actionlog', 'multifactorauth']) { $db = version_compare(JVERSION, '4.2.0', 'lt') ? $this->getDbo() : $this->getDatabase(); $query = $db->getQuery(true); $query->select( $db->quoteName('ex.name') . ', ' . $db->quoteName('ex.extension_id') . ', ' . $db->quoteName('ex.manifest_cache') . ', ' . $db->quoteName('ex.type') . ', ' . $db->quoteName('ex.folder') . ', ' . $db->quoteName('ex.element') . ', ' . $db->quoteName('ex.client_id') . ', ' . $db->quoteName('ex.package_id') )->from( $db->quoteName('#__extensions', 'ex') )->where( $db->quoteName('ex.type') . ' = ' . $db->quote('plugin') )->where( $db->quoteName('ex.enabled') . ' = 1' )->whereNotIn( $db->quoteName('ex.extension_id'), ExtensionHelper::getCoreExtensionIds() ); if (count($folderFilter) > 0) { $folderFilter = array_map([$db, 'quote'], $folderFilter); $query->where($db->quoteName('folder') . ' IN (' . implode(',', $folderFilter) . ')'); } $db->setQuery($query); $rows = $db->loadObjectList(); foreach ($rows as $plugin) { $decode = json_decode($plugin->manifest_cache); // Remove unused fields so they do not cause javascript errors during pre-update check unset($decode->description); unset($decode->copyright); unset($decode->creationDate); $this->translateExtensionName($plugin); $plugin->version = $decode->version ?? Text::_('COM_JOOMLAUPDATE_PREUPDATE_UNKNOWN_EXTENSION_MANIFESTCACHE_VERSION'); unset($plugin->manifest_cache); $plugin->manifest_cache = $decode; } return $rows; } /** * Called by controller's fetchExtensionCompatibility, which is called via AJAX. * * @param string $extensionID The ID of the checked extension * @param string $joomlaTargetVersion Target version of Joomla * * @return object * * @since 3.10.0 */ public function fetchCompatibility($extensionID, $joomlaTargetVersion) { $updateSites = $this->getUpdateSitesInfo($extensionID); if (empty($updateSites)) { return (object) ['state' => 2]; } foreach ($updateSites as $updateSite) { if ($updateSite['type'] === 'collection') { $updateFileUrls = $this->getCollectionDetailsUrls($updateSite, $joomlaTargetVersion); foreach ($updateFileUrls as $updateFileUrl) { $compatibleVersions = $this->checkCompatibility($updateFileUrl, $joomlaTargetVersion); // Return the compatible versions return (object) ['state' => 1, 'compatibleVersions' => $compatibleVersions]; } } else { $compatibleVersions = $this->checkCompatibility($updateSite['location'], $joomlaTargetVersion); // Return the compatible versions return (object) ['state' => 1, 'compatibleVersions' => $compatibleVersions]; } } // In any other case we mark this extension as not compatible return (object) ['state' => 0]; } /** * Returns records with update sites and extension information for a given extension ID. * * @param int $extensionID The extension ID * * @return array * * @since 3.10.0 */ private function getUpdateSitesInfo($extensionID) { $id = (int) $extensionID; $db = version_compare(JVERSION, '4.2.0', 'lt') ? $this->getDbo() : $this->getDatabase(); $query = $db->getQuery(true); $query->select( [ $db->quoteName('us.type'), $db->quoteName('us.location'), $db->quoteName('e.element', 'ext_element'), $db->quoteName('e.type', 'ext_type'), $db->quoteName('e.folder', 'ext_folder'), ] ) ->from($db->quoteName('#__update_sites', 'us')) ->join( 'LEFT', $db->quoteName('#__update_sites_extensions', 'ue'), $db->quoteName('ue.update_site_id') . ' = ' . $db->quoteName('us.update_site_id') ) ->join( 'LEFT', $db->quoteName('#__extensions', 'e'), $db->quoteName('e.extension_id') . ' = ' . $db->quoteName('ue.extension_id') ) ->where($db->quoteName('e.extension_id') . ' = :id') ->bind(':id', $id, ParameterType::INTEGER); $db->setQuery($query); $result = $db->loadAssocList(); if (!is_array($result)) { return []; } return $result; } /** * Method to get details URLs from a collection update site for given extension and Joomla target version. * * @param array $updateSiteInfo The update site and extension information record to process * @param string $joomlaTargetVersion The Joomla! version to test against, * * @return array An array of URLs. * * @since 3.10.0 */ private function getCollectionDetailsUrls($updateSiteInfo, $joomlaTargetVersion) { $return = []; $http = new Http(); try { $response = $http->get($updateSiteInfo['location']); } catch (\RuntimeException $e) { $response = null; } if ($response === null || $response->code !== 200) { return $return; } $updateSiteXML = simplexml_load_string($response->body); foreach ($updateSiteXML->extension as $extension) { $attribs = new \stdClass(); $attribs->element = ''; $attribs->type = ''; $attribs->folder = ''; $attribs->targetplatformversion = ''; foreach ($extension->attributes() as $key => $value) { $attribs->$key = (string) $value; } if ( $attribs->element === $updateSiteInfo['ext_element'] && $attribs->type === $updateSiteInfo['ext_type'] && $attribs->folder === $updateSiteInfo['ext_folder'] && preg_match('/^' . $attribs->targetplatformversion . '/', $joomlaTargetVersion) ) { $return[] = (string) $extension['detailsurl']; } } return $return; } /** * Method to check non core extensions for compatibility. * * @param string $updateFileUrl The items update XML url. * @param string $joomlaTargetVersion The Joomla! version to test against * * @return array An array of strings with compatible version numbers * * @since 3.10.0 */ private function checkCompatibility($updateFileUrl, $joomlaTargetVersion) { $minimumStability = ComponentHelper::getParams('com_installer')->get('minimum_stability', Updater::STABILITY_STABLE); $update = new Update(); $update->set('jversion.full', $joomlaTargetVersion); $update->loadFromXml($updateFileUrl, $minimumStability); $compatibleVersions = $update->get('compatibleVersions'); // Check if old version of the updater library if (!isset($compatibleVersions)) { $downloadUrl = $update->get('downloadurl'); $updateVersion = $update->get('version'); return empty($downloadUrl) || empty($downloadUrl->_data) || empty($updateVersion) ? [] : [$updateVersion->_data]; } usort($compatibleVersions, 'version_compare'); return $compatibleVersions; } /** * Translates an extension name * * @param object &$item The extension of which the name needs to be translated * * @return void * * @since 3.10.0 */ protected function translateExtensionName(&$item) { // @todo: Cleanup duplicated code. from com_installer/models/extension.php $lang = Factory::getLanguage(); $path = $item->client_id ? JPATH_ADMINISTRATOR : JPATH_SITE; $extension = $item->element; $source = JPATH_SITE; switch ($item->type) { case 'component': $extension = $item->element; $source = $path . '/components/' . $extension; break; case 'module': $extension = $item->element; $source = $path . '/modules/' . $extension; break; case 'file': $extension = 'files_' . $item->element; break; case 'library': $extension = 'lib_' . $item->element; break; case 'plugin': $extension = 'plg_' . $item->folder . '_' . $item->element; $source = JPATH_PLUGINS . '/' . $item->folder . '/' . $item->element; break; case 'template': $extension = 'tpl_' . $item->element; $source = $path . '/templates/' . $item->element; } $lang->load("$extension.sys", JPATH_ADMINISTRATOR) || $lang->load("$extension.sys", $source); $lang->load($extension, JPATH_ADMINISTRATOR) || $lang->load($extension, $source); // Translate the extension name if possible $item->name = strip_tags(Text::_($item->name)); } /** * Checks whether a given template is active * * @param string $template The template name to be checked * * @return boolean * * @since 3.10.4 */ public function isTemplateActive($template) { $db = version_compare(JVERSION, '4.2.0', 'lt') ? $this->getDbo() : $this->getDatabase(); $query = $db->getQuery(true); $query->select( $db->quoteName( [ 'id', 'home', ] ) )->from( $db->quoteName('#__template_styles') )->where( $db->quoteName('template') . ' = :template' )->bind(':template', $template, ParameterType::STRING); $templates = $db->setQuery($query)->loadObjectList(); $home = array_filter( $templates, function ($value) { return $value->home > 0; } ); $ids = ArrayHelper::getColumn($templates, 'id'); $menu = false; if (count($ids)) { $query = $db->getQuery(true); $query->select( 'COUNT(*)' )->from( $db->quoteName('#__menu') )->whereIn( $db->quoteName('template_style_id'), $ids ); $menu = $db->setQuery($query)->loadResult() > 0; } return $home || $menu; } /** * Collect errors that happened during update. * * @param string $context A context/place where error happened * @param \Throwable $error The error that occurred * * @return void * * @since 4.4.0 */ public function collectError(string $context, \Throwable $error) { // Store error for further processing by controller $this->setError($error); // Log it Log::add( sprintf( 'An error has occurred while running "%s". Code: %s. Message: %s.', $context, $error->getCode(), $error->getMessage() ), Log::ERROR, 'Update' ); if (JDEBUG) { $trace = $error->getFile() . ':' . $error->getLine() . PHP_EOL . $error->getTraceAsString(); Log::add(sprintf('An error trace: %s.', $trace), Log::DEBUG, 'Update'); } } /** * Check the update package with ZipArchive class from zip PHP extension * * @param string $filePath Full path to the uploaded update package (temporary file) to test * @param string $packageName Name of the selected update package * * @return void * * @since 4.4.0 * @throws \RuntimeException */ private function checkPackageFileZip(string $filePath, $packageName) { $zipArchive = new \ZipArchive(); if ($zipArchive->open($filePath) !== true) { throw new \RuntimeException(Text::sprintf('COM_JOOMLAUPDATE_VIEW_UPLOAD_ERROR_PACKAGE_OPEN', $packageName), 500); } if ($zipArchive->locateName('installation/index.php') !== false) { throw new \RuntimeException(Text::sprintf('COM_JOOMLAUPDATE_VIEW_UPLOAD_ERROR_INSTALL_PACKAGE', $packageName), 500); } $manifestFile = $zipArchive->getFromName('administrator/manifests/files/joomla.xml'); if ($manifestFile === false) { throw new \RuntimeException(Text::sprintf('COM_JOOMLAUPDATE_VIEW_UPLOAD_ERROR_NO_MANIFEST_FILE', $packageName), 500); } $this->checkManifestXML($manifestFile, $packageName); } /** * Check the update package without using the ZipArchive class from zip PHP extension * * @param string $filePath Full path to the uploaded update package (temporary file) to test * @param string $packageName Name of the selected update package * * @return void * * @see https://pkware.cachefly.net/webdocs/casestudies/APPNOTE.TXT * @since 4.4.0 * @throws \RuntimeException */ private function checkPackageFileNoZip(string $filePath, $packageName) { // The file must exist and be readable if (!file_exists($filePath) || !is_readable($filePath)) { throw new \RuntimeException(Text::sprintf('COM_JOOMLAUPDATE_VIEW_UPLOAD_ERROR_PACKAGE_OPEN', $packageName), 500); } // The file must be at least 1KiB (anything less is not even a real file!) $filesize = filesize($filePath); if ($filesize < 1024) { throw new \RuntimeException(Text::sprintf('COM_JOOMLAUPDATE_VIEW_UPLOAD_ERROR_PACKAGE_OPEN', $packageName), 500); } // Open the file $fp = @fopen($filePath, 'rb'); if ($fp === false) { throw new \RuntimeException(Text::sprintf('COM_JOOMLAUPDATE_VIEW_UPLOAD_ERROR_PACKAGE_OPEN', $packageName), 500); } // Read chunks of max. 1MiB size $readsize = min($filesize, 1048576); // Signature of a file header inside a ZIP central directory header $headerSignature = pack('V', 0x02014b50); // File name size signature of the 'installation/index.php' file $sizeSignatureIndexPhp = pack('v', 0x0016); // File name size signature of the 'administrator/manifests/files/joomla.xml' file $sizeSignatureJoomlaXml = pack('v', 0x0028); $headerFound = false; $headerInfo = false; // Read chunks from the end to the start of the file $readStart = $filesize - $readsize; while ($readsize > 0 && fseek($fp, $readStart) === 0) { $fileChunk = fread($fp, $readsize); if ($fileChunk === false || strlen($fileChunk) !== $readsize) { @fclose($fp); throw new \RuntimeException(Text::sprintf('COM_JOOMLAUPDATE_VIEW_UPLOAD_ERROR_PACKAGE_OPEN', $packageName), 500); } $posFirstHeader = strpos($fileChunk, $headerSignature); if ($posFirstHeader === false) { break; } $headerFound = true; $offset = 0; // Look for installation/index.php while (($pos = strpos($fileChunk, 'installation/index.php', $offset)) !== false) { // Check if entry is a central directory file header and the file name is exactly 22 bytes long if (substr($fileChunk, $pos - 46, 4) == $headerSignature && substr($fileChunk, $pos - 18, 2) == $sizeSignatureIndexPhp) { @fclose($fp); throw new \RuntimeException(Text::sprintf('COM_JOOMLAUPDATE_VIEW_UPLOAD_ERROR_INSTALL_PACKAGE', $packageName), 500); } $offset = $pos + 22; } $offset = 0; // Look for administrator/manifests/files/joomla.xml if not found yet while ($headerInfo === false && ($pos = strpos($fileChunk, 'administrator/manifests/files/joomla.xml', $offset)) !== false) { // Check if entry is inside a ZIP central directory header and the file name is exactly 40 bytes long if (substr($fileChunk, $pos - 46, 4) == $headerSignature && substr($fileChunk, $pos - 18, 2) == $sizeSignatureJoomlaXml) { $headerInfo = unpack('VOffset', substr($fileChunk, $pos - 4, 4)); break; } $offset = $pos + 40; } // Done as all file content has been read if ($readStart === 0) { break; } // Calculate read start and read size for previous chunk in the file $readEnd = $readStart + $posFirstHeader; $readStart = max($readEnd - $readsize, 0); $readsize = $readEnd - $readStart; } // If no central directory file header found at all it's not a valid ZIP file if (!$headerFound) { @fclose($fp); throw new \RuntimeException(Text::sprintf('COM_JOOMLAUPDATE_VIEW_UPLOAD_ERROR_PACKAGE_OPEN', $packageName), 500); } // If no central directory file header found for the manifest XML file it's not a valid Joomla package if (!$headerInfo) { @fclose($fp); throw new \RuntimeException(Text::sprintf('COM_JOOMLAUPDATE_VIEW_UPLOAD_ERROR_NO_MANIFEST_FILE', $packageName), 500); } // Read the local file header of the manifest XML file fseek($fp, $headerInfo['Offset']); $localHeader = fread($fp, 30); $localHeaderInfo = unpack('VSig/vVersion/vBitFlag/vMethod/VTime/VCRC32/VCompressed/VUncompressed/vNameLength/vExtraLength', $localHeader); // Check for empty manifest file if (!$localHeaderInfo['Compressed']) { @fclose($fp); throw new \RuntimeException(Text::sprintf('COM_JOOMLAUPDATE_VIEW_UPLOAD_ERROR_NO_MANIFEST_FILE', $packageName), 500); } // Read the compressed manifest XML file content fseek($fp, $localHeaderInfo['NameLength'] + $localHeaderInfo['ExtraLength'], SEEK_CUR); $manifestFileCompressed = fread($fp, $localHeaderInfo['Compressed']); // Close package file @fclose($fp); // Uncompress the manifest XML file content $manifestFile = ''; switch ($localHeaderInfo['Method']) { case 0: // Uncompressed $manifestFile = $manifestFileCompressed; break; case 8: // Deflated $manifestFile = gzinflate($manifestFileCompressed); break; default: // Unsupported break; } if (!$manifestFile) { throw new \RuntimeException(Text::sprintf('COM_JOOMLAUPDATE_VIEW_UPLOAD_ERROR_NO_MANIFEST_FILE', $packageName), 500); } $this->checkManifestXML($manifestFile, $packageName); } /** * Check content of manifest XML file in update package * * @param string $manifest Content of the manifest XML file * @param string $packageName Name of the selected update package * * @return void * * @since 4.4.0 * @throws \RuntimeException */ private function checkManifestXML(string $manifest, $packageName) { $manifestXml = simplexml_load_string($manifest); if (!$manifestXml) { throw new \RuntimeException(Text::sprintf('COM_JOOMLAUPDATE_VIEW_UPLOAD_ERROR_NO_VERSION_FOUND', $packageName), 500); } $versionPackage = (string) $manifestXml->version ?: ''; if (!$versionPackage) { throw new \RuntimeException(Text::sprintf('COM_JOOMLAUPDATE_VIEW_UPLOAD_ERROR_NO_VERSION_FOUND', $packageName), 500); } $currentVersion = JVERSION; // Remove special version suffix for pull request patched packages if (($pos = strpos($currentVersion, '+pr.')) !== false) { $currentVersion = substr($currentVersion, 0, $pos); } if (version_compare($versionPackage, $currentVersion, 'lt')) { throw new \RuntimeException(Text::sprintf('COM_JOOMLAUPDATE_VIEW_UPLOAD_ERROR_DOWNGRADE', $packageName, $versionPackage, $currentVersion), 500); } } }