diff --git a/administrator/components/com_installer/src/Model/UpdateModel.php b/administrator/components/com_installer/src/Model/UpdateModel.php index 017493bc597e9..2a2092394f3eb 100644 --- a/administrator/components/com_installer/src/Model/UpdateModel.php +++ b/administrator/components/com_installer/src/Model/UpdateModel.php @@ -10,6 +10,7 @@ namespace Joomla\Component\Installer\Administrator\Model; +use Joomla\CMS\Event\Installer\AfterPackageDownloadFailedEvent; use Joomla\CMS\Extension\ExtensionHelper; use Joomla\CMS\Factory; use Joomla\CMS\Installer\Installer; @@ -440,7 +441,22 @@ private function install($update) // Was the package downloaded? if (!$p_file) { - Factory::getApplication()->enqueueMessage(Text::sprintf('COM_INSTALLER_PACKAGE_DOWNLOAD_FAILED', $url), 'error'); + $dispatcher = $this->getDispatcher(); + PluginHelper::importPlugin('installer', null, true, $dispatcher); + $event = new AfterPackageDownloadFailedEvent('onInstallerPackageDownloadFailed', [ + 'url' => $url, + 'message' => true, + 'type' => 'error', + ]); + $dispatcher->dispatch('onInstallerPackageDownloadFailed', $event); + $message = $event->getArgument('message', true); + $type = $event->getArgument('type', 'error'); + + if ($message === true) { + InstallerHelper::enqueueDownloadFailMessage($url); + } elseif ($message !== false) { + Factory::getApplication()->enqueueMessage($message, $type); + } return false; } diff --git a/administrator/language/en-GB/lib_joomla.ini b/administrator/language/en-GB/lib_joomla.ini index f9ea658e0291d..06543b05dd274 100644 --- a/administrator/language/en-GB/lib_joomla.ini +++ b/administrator/language/en-GB/lib_joomla.ini @@ -581,6 +581,7 @@ JLIB_INSTALLER_ERROR_DEPRECATED_FORMAT="Deprecated install format (client=\"both JLIB_INSTALLER_ERROR_DISCOVER_INSTALL_UNSUPPORTED="A %s extension can not be installed using the discover method. Please install this extension from Extension Manager: Install." JLIB_INSTALLER_ERROR_DOWNGRADE="Sorry! You cannot downgrade from version %s to %s" JLIB_INSTALLER_ERROR_DOWNLOAD_SERVER_CONNECT="Error connecting to the server: %s" +JLIB_INSTALLER_ERROR_DOWNLOAD_SERVER_MESSAGE="Error downloading the package: %1$s. %2$s" JLIB_INSTALLER_ERROR_EXTENSION_INVALID_CLIENT_IDENTIFIER="Invalid client identifier specified in extension manifest." JLIB_INSTALLER_ERROR_FAIL_COPY_FILE="JInstaller: :Install: Failed to copy file %1$s to %2$s" JLIB_INSTALLER_ERROR_FAIL_COPY_FOLDER="JInstaller: :Install: Failed to copy folder %1$s to %2$s" diff --git a/libraries/src/Event/Installer/AfterPackageDownloadEvent.php b/libraries/src/Event/Installer/AfterPackageDownloadEvent.php new file mode 100644 index 0000000000000..ecbc4e0bc23df --- /dev/null +++ b/libraries/src/Event/Installer/AfterPackageDownloadEvent.php @@ -0,0 +1,25 @@ + + * @license GNU General Public License version 2 or later; see LICENSE.txt + */ + +namespace Joomla\CMS\Event\Installer; + +use Joomla\Event\Event; + +// phpcs:disable PSR1.Files.SideEffects +\defined('_JEXEC') or die; +// phpcs:enable PSR1.Files.SideEffects + +/** + * Class for Installer events + * + * @since __DEPLOY_VERSION__ + */ +class AfterPackageDownloadEvent extends Event +{ +} diff --git a/libraries/src/Event/Installer/AfterPackageDownloadFailedEvent.php b/libraries/src/Event/Installer/AfterPackageDownloadFailedEvent.php new file mode 100644 index 0000000000000..1ef53004bde99 --- /dev/null +++ b/libraries/src/Event/Installer/AfterPackageDownloadFailedEvent.php @@ -0,0 +1,25 @@ + + * @license GNU General Public License version 2 or later; see LICENSE.txt + */ + +namespace Joomla\CMS\Event\Installer; + +use Joomla\Event\Event; + +// phpcs:disable PSR1.Files.SideEffects +\defined('_JEXEC') or die; +// phpcs:enable PSR1.Files.SideEffects + +/** + * Class for Installer events + * + * @since __DEPLOY_VERSION__ + */ +class AfterPackageDownloadFailedEvent extends Event +{ +} diff --git a/libraries/src/Installer/InstallerHelper.php b/libraries/src/Installer/InstallerHelper.php index 5914d16910d25..cf80968a2d720 100644 --- a/libraries/src/Installer/InstallerHelper.php +++ b/libraries/src/Installer/InstallerHelper.php @@ -10,6 +10,7 @@ namespace Joomla\CMS\Installer; use Joomla\Archive\Archive; +use Joomla\CMS\Event\Installer\AfterPackageDownloadEvent; use Joomla\CMS\Event\Installer\BeforePackageDownloadEvent; use Joomla\CMS\Factory; use Joomla\CMS\Language\Text; @@ -57,6 +58,15 @@ abstract class InstallerHelper */ public const HASH_NOT_PROVIDED = 2; + /** + * Message displayed when download fails. + * + * @var string + + * @since __DEPLOY_VERSION__ + */ + protected static $downloadFailMessage = 'COM_INSTALLER_PACKAGE_DOWNLOAD_FAILED'; + /** * Downloads a package * @@ -74,7 +84,10 @@ public static function downloadPackage($url, $target = false) ini_set('user_agent', $version->getUserAgent('Installer')); // Load installer plugins, and allow URL and headers modification - $headers = []; + $headers = []; + $headers['accept'] = 'application/json, application/zip'; + + // Load installer plugins, and allow URL and headers modification $dispatcher = Factory::getApplication()->getDispatcher(); PluginHelper::importPlugin('installer', null, true, $dispatcher); $event = new BeforePackageDownloadEvent('onInstallerBeforePackageDownload', [ @@ -85,16 +98,60 @@ public static function downloadPackage($url, $target = false) $url = $event->getArgument('url', $url); $headers = $event->getArgument('headers', $headers); + if (empty($url)) { + // Any logging and messaging of this are the responsibility of the event handlers. + return false; + } + + // In the XML returned by the update server the downloadurl value may include optional values using escape fields. + // In the simplest case these values would be the Joomla version and the PHP version. + // Additionally, the value of a Joomla Text Override label could be used as a security token. + // https://...?task=product.download&element=plg_test_test2&file_id=400&updatetype=1&j={joomla_version}&p={php_version}&t={DEVELOPER_UPDATE_TOKEN} + $url = str_replace( + ['{joomla_version}', '{php_version}'], + [JVERSION, PHP_VERSION], + $url + ); + + preg_match('/{[^}]+}/', $url, $matches); + foreach ($matches as $match) { + $url = str_replace($match, Text::_(trim($match, '{}')), $url); + } + try { $response = (new HttpFactory())->getHttp()->get($url, $headers); + + // Convert keys of headers to lowercase, to accommodate for case variations + $headers = array_change_key_case($response->getHeaders(), CASE_LOWER); } catch (\RuntimeException $exception) { - Log::add(Text::sprintf('JLIB_INSTALLER_ERROR_DOWNLOAD_SERVER_CONNECT', $exception->getMessage()), Log::WARNING, 'jerror'); + $response = $exception; + } + // Load installer plugins, and check response + $dispatcher = Factory::getApplication()->getDispatcher(); + PluginHelper::importPlugin('installer', null, true, $dispatcher); + $event = new AfterPackageDownloadEvent('onInstallerAfterPackageDownload', [ + 'url' => &$url, + 'response' => &$response, + 'headers' => &$headers, + 'return' => true, + ]); + $dispatcher->dispatch('onInstallerAfterPackageDownload', $event); + $return = $event->getArgument('return'); + + if ($return === false) { return false; } - // Convert keys of headers to lowercase, to accommodate for case variations - $headers = array_change_key_case($response->getHeaders(), CASE_LOWER); + if ($return !== true) { + return $return; + } + + if ($response instanceof \Exception) { + Log::add(Text::sprintf('JLIB_INSTALLER_ERROR_DOWNLOAD_SERVER_CONNECT', $exception->getMessage()), Log::WARNING, 'jerror'); + + return false; + } if (302 == $response->getStatusCode() && !empty($headers['location'])) { return self::downloadPackage($headers['location']); @@ -103,29 +160,82 @@ public static function downloadPackage($url, $target = false) if (200 != $response->getStatusCode()) { Log::add(Text::sprintf('JLIB_INSTALLER_ERROR_DOWNLOAD_SERVER_CONNECT', $response->getStatusCode()), Log::WARNING, 'jerror'); + if (403 === $response->getStatusCode()) { + $body = (string) $response->getBody(); + + // Show response message, but ignore default server error pages + if ($body && !str_contains($body, '')) { + Log::add(Text::sprintf('JLIB_INSTALLER_ERROR_DOWNLOAD_SERVER_MESSAGE', $url, $body), Log::WARNING, 'jerror'); + } + } + return false; } - // Parse the Content-Disposition header to get the file name if ( - !empty($headers['content-disposition']) - && preg_match("/\s*filename\s?=\s?(.*)/", $headers['content-disposition'][0], $parts) + !empty($headers['content-type']) + && !empty($headers['content-type'][0]) + && strpos($headers['content-type'][0], 'application/json') !== false ) { - $flds = explode(';', $parts[1]); - $target = trim($flds[0], '"'); - } + $response = json_decode((string)$response->getBody(), true); + + /* + Typically this is used for an error response which will look like this: + array ( + 'message' => 'plg_test_test2: The subscription key you provided is expired or invalid. Please purchase a new subscription key.', + 'type' => 'info', 'success' => false, 'error' => true, 'downloadfailmessage' => '', + ) + Json data can be used as an alternative success response in which case it will include package and target data. + */ + + if (isset($response['downloadfailmessage'])) { + // Empty string will disable the download fail message. + // The response message may be used on errors. Allows the message type to be customised. + self::$downloadFailMessage = $response['downloadfailmessage']; + } - $tmpPath = Factory::getApplication()->get('tmp_path'); + if (!empty($response['message'])) { + Factory::getApplication()->enqueueMessage($response['message'], $response['type'] ?? 'info'); + + if (!empty($response['error'])) { + return false; + } + } - // Set the target path if not given - if (!$target) { - $target = $tmpPath . '/' . self::getFilenameFromUrl($url); + if ( + !empty($response['error']) + || empty($response['package']) + || empty($response['target']) + ) { + Log::add(Text::sprintf('JLIB_INSTALLER_ERROR_DOWNLOAD_SERVER_CONNECT', ''), Log::WARNING, 'jerror'); + + return false; + } + + $body = base64_decode($response['package']); + $target = $response['target']; } else { - $target = $tmpPath . '/' . basename($target); - } + // Parse the Content-Disposition header to get the file name + if ( + !empty($headers['content-disposition']) + && preg_match("/\s*filename\s?=\s?(.*)/", $headers['content-disposition'][0], $parts) + ) { + $flds = explode(';', $parts[1]); + $target = trim($flds[0], '"'); + } + + $tmpPath = Factory::getApplication()->get('tmp_path'); - // Fix Indirect Modification of Overloaded Property - $body = (string) $response->getBody(); + // Set the target path if not given + if (!$target) { + $target = $tmpPath . '/' . self::getFilenameFromUrl($url); + } else { + $target = $tmpPath . '/' . basename($target); + } + + // Fix Indirect Modification of Overloaded Property + $body = (string) $response->getBody(); + } // Write buffer to file File::write($target, $body); @@ -139,6 +249,24 @@ public static function downloadPackage($url, $target = false) return basename($target); } + /* + * Enqueues the download fail message (if any). + * + * @param string $url URL of file to download + * + * @return void + * + * @since __DEPLOY_VERSION__ + */ + public static function enqueueDownloadFailMessage($url) + { + if (empty(self::$downloadFailMessage)) { + return; + } + + Factory::getApplication()->enqueueMessage(Text::sprintf('COM_INSTALLER_PACKAGE_DOWNLOAD_FAILED', $url), 'error'); + } + /** * Unpacks a file and verifies it as a Joomla element package * Supports .gz .tar .tar.gz and .zip diff --git a/libraries/src/Updater/Adapter/ExtensionAdapter.php b/libraries/src/Updater/Adapter/ExtensionAdapter.php index 4853e05ba32f7..afe93bf2d3b4b 100644 --- a/libraries/src/Updater/Adapter/ExtensionAdapter.php +++ b/libraries/src/Updater/Adapter/ExtensionAdapter.php @@ -109,7 +109,8 @@ protected function _endElement($parser, $name) // Check that the product matches and that the version matches (optionally a regexp) if ( - $product == $this->currentUpdate->targetplatform['NAME'] + !empty($this->currentUpdate->targetplatform) + && $product == $this->currentUpdate->targetplatform['NAME'] && preg_match('/^' . $this->currentUpdate->targetplatform['VERSION'] . '/', JVERSION) ) { // Check if PHP version supported via tag, assume true if tag isn't present diff --git a/libraries/src/Updater/UpdateAdapter.php b/libraries/src/Updater/UpdateAdapter.php index 3c699f9a1bd68..ca805e1fa21c6 100644 --- a/libraries/src/Updater/UpdateAdapter.php +++ b/libraries/src/Updater/UpdateAdapter.php @@ -300,6 +300,11 @@ protected function getUpdateSiteResponse($options = []) $newUrl = $event->getArgument('url', $url); $headers = $event->getArgument('headers', $headers); + if (empty($newUrl)) { + // Any logging and messaging of this are the responsibility of the event handlers. + return false; + } + // Http transport throws an exception when there's no response. try { $http = (new HttpFactory())->getHttp($httpOption); diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon index 64a406ebf4b41..94ad35d2685b6 100644 --- a/phpstan-baseline.neon +++ b/phpstan-baseline.neon @@ -11875,7 +11875,7 @@ parameters: This interface will be removed without replacement as the Joomla 3\.x compatibility layer will be removed$# ''' identifier: method.deprecatedInterface - count: 1 + count: 2 path: libraries/src/Installer/InstallerHelper.php -