shell bypass 403
<?php /** * @package FOF * @copyright Copyright (c)2010-2022 Nicholas K. Dionysopoulos / Akeeba Ltd * @license GNU General Public License version 3, or later */ namespace FOF40\Update; defined('_JEXEC') || die; use Exception; use FOF40\Container\Container; use FOF40\JoomlaAbstraction\CacheCleaner; use FOF40\Model\Model; use Joomla\CMS\Component\ComponentHelper as JComponentHelper; use Joomla\CMS\Updater\Updater as JUpdater; use SimpleXMLElement; /** * A helper Model to interact with Joomla!'s extensions update feature */ class Update extends Model { /** @var JUpdater The Joomla! updater object */ protected $updater; /** @var int The extension_id of this component */ protected $extension_id = 0; /** @var string The currently installed version, as reported by the #__extensions table */ protected $version = 'dev'; /** @var string The name of the component e.g. com_something */ protected $component = 'com_foobar'; /** @var string The URL to the component's update XML stream */ protected $updateSite; /** @var string The name to the component's update site (description of the update XML stream) */ protected $updateSiteName; /** @var string The extra query to append to (commercial) components' download URLs */ protected $extraQuery; /** @var string The component Options key which stores a copy of the Download ID */ protected $paramsKey = 'update_dlid'; /** * Caches the extension names to IDs so we don't query the database too many times. * * @var array */ private $extensionIds = []; /** * Public constructor. Initialises the protected members as well. Useful $config keys: * update_component The component name, e.g. com_foobar * update_version The default version if the manifest cache is unreadable * update_site The URL to the component's update XML stream * update_extraquery The extra query to append to (commercial) components' download URLs * update_sitename The update site's name (description) * update_paramskey The component parameters key which holds the license key in J3 (and a copy of it in J4) * * @param array|Container|\ArrayAccess $config */ public function __construct($config = []) { $container = Container::getInstance('com_FOOBAR'); if (isset($config['update_container']) && is_object($config['update_container']) && ($config['update_container'] instanceof Container)) { $container = $config['update_container']; } parent::__construct($container); // Get an instance of the updater class $this->updater = JUpdater::getInstance(); $this->component = $config['update_component'] ?? $this->input->getCmd('option', ''); // Get the component version if (isset($config['update_version'])) { $this->version = $config['update_version']; } // Get the update site if (isset($config['update_site'])) { $this->updateSite = $config['update_site']; } // Get the extra query if (isset($config['update_extraquery'])) { $this->extraQuery = $config['update_extraquery']; } // Get the extra query if (isset($config['update_sitename'])) { $this->updateSiteName = $config['update_sitename']; } // Get the extra query if (isset($config['update_paramskey'])) { $this->paramsKey = $config['update_paramskey']; } // Get the extension type $extension = $this->getExtensionObject(); if (is_object($extension)) { $this->extension_id = $extension->extension_id; if (empty($this->version) || ($this->version == 'dev')) { $data = json_decode($extension->manifest_cache, true); if (isset($data['version'])) { $this->version = $data['version']; } } } } /** * Gets the license key for a paid extension. * * On Joomla! 3 or when $forceLegacy is true we look in the component Options. * * On Joomla! 4 we use the information in the dlid element of the extension's XML manifest to parse the extra_query * fields of all configured update sites of the extension. This is the same thing Joomla does when it tries to * determine the license key of our extension when installing updates. If the extension is missing, it has no * associated update sites, the update sites are missing / rebuilt / disassociated from the extension or the * extra_query of all update site records is empty we parse the $extraQuery set in the constructor, if any. Also * note that on Joomla 4 mode if the extension does not exist, does not have a manifest or does not have a valid * dlid element in its manifest we will end up returning an empty string, just like Joomla! itself would have done * when installing updates. * * @param bool $forceLegacy Should I always retrieve the legacy license key, even in J4? * * @return string */ public function getLicenseKey(bool $forceLegacy = false): string { $legacyParamsKey = $this->getLegacyParamsKey(); // Joomla 3 (Legacy): Download ID stored in the component options if ($forceLegacy || !version_compare(JVERSION, '3.999.999', 'gt')) { return $this->container->params->get($legacyParamsKey, ''); } // Joomla! 4. We need to parse the extra_query of the update sites to get the correct Download ID. $updateSites = $this->getUpdateSites(); $extra_query = array_reduce($updateSites, function ($extra_query, $updateSite) { if (!empty($extra_query)) { return $extra_query; } return $updateSite['extra_query']; }, ''); // Fall back to legacy extra query if (empty($extra_query)) { $extra_query = $this->extraQuery; } // Return the parsed results. return $this->getLicenseKeyFromExtraQuery($extra_query); } /** * Get the contents of all the update sites of the configured extension * * @return array|null */ public function getUpdateSites(): ?array { $updateSiteIDs = $this->getUpdateSiteIds(); $db = $this->container->db; $query = $db->getQuery(true) ->select('*') ->from($db->qn('#__update_sites')) ->where($db->qn('update_site_id') . ' IN (' . implode(', ', $updateSiteIDs) . ')'); try { $db->setQuery($query); $ret = $db->loadAssocList('update_site_id'); } catch (Exception $e) { $ret = null; } return empty($ret) ? [] : $ret; } public function setLicenseKey(string $licenseKey) { $legacyParamsKey = $this->getLegacyParamsKey(); // Sanitize and validate the license key. If it's not valid we set an empty license key. $licenseKey = $this->sanitizeLicenseKey($licenseKey); if (!$this->isValidLicenseKey($licenseKey)) { $licenseKey = ''; } // Update $this->extraQuery. $this->extraQuery = $this->getExtraQueryString($licenseKey); // Save the license key in the component options ($legacyParamsKey) $this->container->params->set($legacyParamsKey, $licenseKey); $this->container->params->save(); // Apply the new extra_query to the update site $this->refreshUpdateSite(); } /** * Copies a Joomla 3 license key from the Options storage to Joomla 4 download key storage (the extra_query column * of the #__update_sites table). * * This method does nothing on Joomla 3. * * @return void */ public function upgradeLicenseKey(): void { // Only applies to Joomla! 4 if (!version_compare(JVERSION, '3.999.999', 'gt')) { return; } // Make sure we DO have a legacy license key $legacyKey = $this->getLicenseKey(true); if (empty($legacyKey)) { return; } // Make sure we DO NOT have a J4 key. If we do, the J4 key wins and gets backported to legacy storage. $licenseKey = $this->getLicenseKey(); if (!empty($licenseKey)) { $this->backportLicenseKey(); return; } // Save the legacy key as non-legacy. This updates the #__update_sites record, applying the license key. $this->setLicenseKey($legacyKey); } /** * Copies a Joomla 4 license key from the download key storage (the extra_query column of the #__update_sites table) * to the legacy Options storage. * * This method does nothing on Joomla 3. * * @return void */ public function backportLicenseKey(): void { $legacyParamsKey = $this->getLegacyParamsKey(); // Only applies to Joomla! 4 if (!version_compare(JVERSION, '3.999.999', 'gt')) { return; } // Make sure we DO have a J4 key $licenseKey = $this->getLicenseKey(); if (empty($licenseKey)) { return; } // Make sure that the legacy key is NOT the same as the J4 key $legacyKey = $this->getLicenseKey(true); if ($legacyKey === $licenseKey) { return; } // Save the license key to the legacy storage (component options) $this->container->params->set($legacyParamsKey, $licenseKey); $this->container->params->save(); } /** * Get an extra query string based on the dlid element of the XML manifest file of the extension. * * If the extension does not exist, the manifest does not exist or it does not have a dlid element we fall back to * the legacy implementation of extra_query (getExtraQueryStringLegacy) * * @param string $licenseKey * * @return string */ public function getExtraQueryString(string $licenseKey): string { // Make sure the (sanitized) license key is valid. Otherwise we return an empty string. $licenseKey = $this->sanitizeLicenseKey($licenseKey); if (!$this->isValidLicenseKey($licenseKey)) { return ''; } // Get a fallback extra query using the legacy method $fallbackExtraQuery = $this->getExtraQueryStringLegacy($licenseKey); // Get the extension XML manifest. If the extension or the manifest don't exist use the fallback extra_query. $extension = $this->getExtensionObject(); if (!$extension) { return $fallbackExtraQuery; } $installXmlFile = $this->getManifestXML( $extension->element, $extension->type, (int) $extension->client_id, $extension->folder ); if (!$installXmlFile) { return $fallbackExtraQuery; } // If the manifest does not have the dlid element return the fallback extra_query. if (!isset($installXmlFile->dlid)) { return $fallbackExtraQuery; } $prefix = (string) $installXmlFile->dlid['prefix']; $suffix = (string) $installXmlFile->dlid['suffix']; return $prefix . $this->sanitizeLicenseKey($licenseKey) . $suffix; } /** * Retrieves the update information of the component, returning an array with the following keys: * * hasUpdate True if an update is available * version The version of the available update * infoURL The URL to the download page of the update * * @param bool $force Set to true if you want to forcibly reload the update information * * @return array See the method description for more information */ public function getUpdates(bool $force = false): array { $db = $this->container->db; // Default response (no update) $updateResponse = [ 'hasUpdate' => false, 'version' => '', 'infoURL' => '', ]; if (empty($this->extension_id)) { return $updateResponse; } // If we had to update the version number stored in the database then we should force reload the updates if ($this->updatedCachedVersionNumber()) { $force = true; } // If we are forcing the reload, set the last_check_timestamp to 0 // and remove cached component update info in order to force a reload if ($force) { // Find the update site IDs $updateSiteIds = $this->getUpdateSiteIds(); if (empty($updateSiteIds)) { return $updateResponse; } // Set the last_check_timestamp to 0 $query = $db->getQuery(true) ->update($db->qn('#__update_sites')) ->set($db->qn('last_check_timestamp') . ' = ' . $db->q('0')) ->where($db->qn('update_site_id') . ' IN (' . implode(', ', $updateSiteIds) . ')'); $db->setQuery($query); $db->execute(); // Remove cached component update info from #__updates $query = $db->getQuery(true) ->delete($db->qn('#__updates')) ->where($db->qn('update_site_id') . ' IN (' . implode(', ', $updateSiteIds) . ')'); $db->setQuery($query); $db->execute(); } // Use the update cache timeout specified in com_installer $comInstallerParams = JComponentHelper::getParams('com_installer', false); $timeout = 3600 * $comInstallerParams->get('cachetimeout', '6'); // Load any updates from the network into the #__updates table $this->updater->findUpdates($this->extension_id, $timeout); // Get the update record from the database $query = $db->getQuery(true) ->select('*') ->from($db->qn('#__updates')) ->where($db->qn('extension_id') . ' = ' . $db->q($this->extension_id)); $db->setQuery($query); $updateRecord = $db->loadObject(); // If we have an update record in the database return the information found there if (is_object($updateRecord)) { $updateResponse = [ 'hasUpdate' => true, 'version' => $updateRecord->version, 'infoURL' => $updateRecord->infourl, ]; } return $updateResponse; } /** * Gets the update site Ids for our extension. * * @return array An array of IDs */ public function getUpdateSiteIds(): array { $db = $this->container->db; $query = $db->getQuery(true) ->select($db->qn('update_site_id')) ->from($db->qn('#__update_sites_extensions')) ->where($db->qn('extension_id') . ' = ' . $db->q($this->extension_id)); $db->setQuery($query); try { $ret = $db->loadColumn(0); } catch (Exception $e) { $ret = null; } return is_array($ret) ? $ret : []; } /** * Get the currently installed version as reported by the #__extensions table * * @return string */ public function getVersion(): string { return $this->version; } /** * Override the currently installed version as reported by the #__extensions table * * @param string $version */ public function setVersion(string $version): void { $this->version = $version; } /** * Refreshes the Joomla! update sites for this extension as needed * * @return void */ public function refreshUpdateSite(): void { if (empty($this->extension_id)) { return; } /** * Record whether I have made any changes to the update sites or the updates themselves so I can clear Joomla's * undocumented cache. */ $madeChanges = [ 'updateSites' => false, 'updates' => false, ]; /** * Create the update site definition we want to see in (or store into) the database. * * Since this is called primarily by our own software I am taking the liberty of setting up an extra_query if * none is provided. The code here will prefer a manually set up extra_query. If none is present we will create * one. There are two parts handled by $this->getExtraQueryString($this->getLicenseKey()). * * **License key**. On Joomla 3 we read it from the component's Options. On Joomla 4 we try to first read it * from the #__update_sites table and the extra_query format in the XML manifest. If either is not present we * will fall back to the component itself. * * **Extra query format**. We try to read the format from the XML manifest of the extension. If it's not set up * we fall back to the Akeeba-comaptible format dlid=YOUR_DOWNLOAD_ID_HERE. * * This provides maximum flexibility and works with both Joomla 4 and Joomla 3 equally. */ $update_site = [ 'name' => $this->updateSiteName, 'type' => 'extension', 'location' => $this->updateSite, 'enabled' => 1, 'last_check_timestamp' => 0, 'extra_query' => $this->extraQuery ?? $this->getExtraQueryString($this->getLicenseKey()), ]; // Get a reference to the db driver $db = $this->container->db; // Get the #__update_sites columns $columns = $db->getTableColumns('#__update_sites', true); if (!array_key_exists('extra_query', $columns)) { unset($update_site['extra_query']); } // Get the update sites for our extension $updateSiteIds = $this->getUpdateSiteIds(); if (empty($updateSiteIds)) { $updateSiteIds = []; } /** @var boolean $needNewUpdateSite Do I need to create a new update site? */ $needNewUpdateSite = true; /** @var int[] $deleteOldSites Old Site IDs to delete */ $deleteOldSites = []; // Loop through all update sites foreach ($updateSiteIds as $id) { $query = $db->getQuery(true) ->select('*') ->from($db->qn('#__update_sites')) ->where($db->qn('update_site_id') . ' = ' . $db->q($id)); $db->setQuery($query); $aSite = $db->loadObject(); if (empty($aSite)) { // Update site does not exist?! continue; } // We have an update site that looks like ours if ($needNewUpdateSite && ($aSite->name == $update_site['name']) && ($aSite->location == $update_site['location'])) { $needNewUpdateSite = false; $mustUpdate = false; // Is it enabled? If not, enable it. if (!$aSite->enabled) { $mustUpdate = true; $aSite->enabled = 1; } // Do we have the extra_query property (J 3.2+) and does it match? if (property_exists($aSite, 'extra_query') && isset($update_site['extra_query']) && ($aSite->extra_query != $update_site['extra_query'])) { $mustUpdate = true; $aSite->extra_query = $update_site['extra_query']; } // Update the update site if necessary if ($mustUpdate) { $madeChanges['updateSites'] = true; $db->updateObject('#__update_sites', $aSite, 'update_site_id', true); } // Make changes to any #__updates records linked to this update site ID $madeChanges['updates'] = $this->fixUpdates((int) $id, $aSite->extra_query); continue; } // In any other case we need to delete this update site, it's obsolete. $deleteOldSites[] = $aSite->update_site_id; } if (!empty($deleteOldSites)) { try { $obsoleteIDsQuoted = array_map([$db, 'quote'], $deleteOldSites); // Delete update sites $query = $db->getQuery(true) ->delete('#__update_sites') ->where($db->qn('update_site_id') . ' IN (' . implode(',', $obsoleteIDsQuoted) . ')'); $db->setQuery($query)->execute(); // Delete update sites to extension ID records $query = $db->getQuery(true) ->delete('#__update_sites_extensions') ->where($db->qn('update_site_id') . ' IN (' . implode(',', $obsoleteIDsQuoted) . ')'); $db->setQuery($query)->execute(); } catch (\Exception $e) { // Do nothing on failure } finally { $madeChanges['updateSites'] = true; } // Clear the caches for #__update_sites and #__updates if necessary if ($madeChanges['updateSites'] || $madeChanges['updates']) { CacheCleaner::clearCacheGroups([ '_system', 'com_installer', 'com_modules', 'com_plugins', 'mod_menu', ]); } } // Do we still need to create a new update site? if ($needNewUpdateSite) { // No update sites defined. Create a new one. $newSite = (object) $update_site; $db->insertObject('#__update_sites', $newSite); $id = $db->insertid(); $updateSiteExtension = (object) [ 'update_site_id' => $id, 'extension_id' => $this->extension_id, ]; $db->insertObject('#__update_sites_extensions', $updateSiteExtension); } // Finally, adopt my extensions $this->adoptMyExtensions(); } /** * Removes any update sites which go by the same name or the same location as our update site but do not match the * extension ID. */ public function removeObsoleteUpdateSites(): void { $db = $this->container->db; // Get update site IDs $updateSiteIDs = $this->getUpdateSiteIds(); // Find update sites where the name OR the location matches BUT they are not one of the update site IDs $query = $db->getQuery(true) ->select($db->qn('update_site_id')) ->from($db->qn('#__update_sites')) ->where( '((' . $db->qn('name') . ' = ' . $db->q($this->updateSiteName) . ') OR ' . '(' . $db->qn('location') . ' = ' . $db->q($this->updateSite) . '))' ); if (!empty($updateSiteIDs)) { $updateSitesQuoted = array_map([$db, 'quote'], $updateSiteIDs); $query->where($db->qn('update_site_id') . ' NOT IN (' . implode(',', $updateSitesQuoted) . ')'); } try { $ids = $db->setQuery($query)->loadColumn(); if (!empty($ids)) { $obsoleteIDsQuoted = array_map([$db, 'quote'], $ids); // Delete update sites $query = $db->getQuery(true) ->delete('#__update_sites') ->where($db->qn('update_site_id') . ' IN (' . implode(',', $obsoleteIDsQuoted) . ')'); $db->setQuery($query)->execute(); // Delete update sites to extension ID records $query = $db->getQuery(true) ->delete('#__update_sites_extensions') ->where($db->qn('update_site_id') . ' IN (' . implode(',', $obsoleteIDsQuoted) . ')'); $db->setQuery($query)->execute(); } } catch (\Exception $e) { // Do nothing on failure return; } } /** * Makes sure that the version number cached in the #__extensions table is consistent with the version number set in * this model. * * @return bool True if we updated the version number cached in the #__extensions table. * * @since 3.1.2 */ public function updatedCachedVersionNumber(): bool { $extension = $this->getExtensionObject(); if (!is_object($extension)) { return false; } $data = json_decode($extension->manifest_cache, true); $mustUpdate = true; if (isset($data['version'])) { $mustUpdate = $this->version != $data['version']; } if (!$mustUpdate) { return false; } // The cached version is wrong; let's update it $data['version'] = $this->version; $extension->manifest_cache = json_encode($data); $db = $this->container->db; return $db->updateObject('#__extensions', $extension, ['extension_id']); } /** * Returns an object with the #__extensions table record for the current extension. * * @return object|null */ public function getExtensionObject() { [$extensionPrefix, $extensionName] = explode('_', $this->component); switch ($extensionPrefix) { default: case 'com': $type = 'component'; $name = $this->component; break; case 'pkg': $type = 'package'; $name = $this->component; break; } // Find the extension ID $db = $this->container->db; $query = $db->getQuery(true) ->select('*') ->from($db->qn('#__extensions')) ->where($db->qn('type') . ' = ' . $db->q($type)) ->where($db->qn('element') . ' = ' . $db->q($name)); try { $db->setQuery($query); $extension = $db->loadObject(); } catch (Exception $e) { return null; } return $extension; } /** * Is the provided string a valid license key? * * YOU SHOULD OVERRIDE THIS METHOD. The default implementation checks for valid Download IDs in the format used by * Akeeba software. * * @param string $licenseKey * * @return bool */ public function isValidLicenseKey(string $licenseKey): bool { return preg_match('/^(\d{1,}:)?[0-9a-f]{32}$/i', $licenseKey) === 1; } /** * Sanitizes the license key. * * YOU SHOULD OVERRIDE THIS METHOD. The default implementation returns a lowercase string with all characters except * letters, numbers and colons removed. * * @param string $licenseKey * * @return string The sanitized license key */ public function sanitizeLicenseKey(string $licenseKey): string { return strtolower(preg_replace("/[^a-zA-Z0-9:]/", "", $licenseKey)); } /** * Adopt the extensions included in the package. * * This modifies the package_id column of the #__extensions table for the records of the extensions declared in the * new package's manifest. This allows you to use Discover to install new extensions without leaving them “orphan” * of a package in the #__extensions table, something which could cause problems when running Joomla! Update. * * @return void */ public function adoptMyExtensions(): void { // Get the extension ID of the new package $newPackageId = $this->extension_id; if (empty($newPackageId)) { return; } // Get the extension IDs $extensionIDs = array_map([$this, 'getExtensionId'], $this->getExtensionsFromPackage($this->component)); $extensionIDs = array_filter($extensionIDs, function ($x) { return !empty($x); }); if (empty($extensionIDs)) { return; } // Reassign all extensions $db = $this->container->db; $query = $db->getQuery(true) ->update($db->quoteName('#__extensions')) ->set($db->qn('package_id') . ' = ' . $db->q($newPackageId)) ->where($db->qn('extension_id') . 'IN(' . implode(', ', array_map([$db, 'q'], $extensionIDs)) . ')'); $db->setQuery($query)->execute(); } /** * Returns the extension ID for a Joomla extension given its name. * * This is deliberately public so that custom handlers can use it without having to reimplement it. * * @param string $extension The extension name, e.g. `plg_system_example`. * * @return int|null The extension ID or null if no such extension exists */ public function getExtensionId(string $extension): ?int { if (isset($this->extensionIds[$extension])) { return $this->extensionIds[$extension]; } $this->extensionIds[$extension] = null; $criteria = $this->extensionNameToCriteria($extension); if (empty($criteria)) { return $this->extensionIds[$extension]; } $db = $this->container->db; $query = $db->getQuery(true) ->select($db->quoteName('extension_id')) ->from($db->quoteName('#__extensions')); foreach ($criteria as $key => $value) { $query->where($db->qn($key) . ' = ' . $db->q($value)); } try { $this->extensionIds[$extension] = $db->setQuery($query)->loadResult(); } catch (\RuntimeException $e) { return null; } return $this->extensionIds[$extension]; } /** * Returns the component Options key which holds a copy of the license key * * @return string */ protected function getLegacyParamsKey(): string { if (!empty($this->paramsKey)) { return $this->paramsKey; } $this->paramsKey = 'update_dlid'; return $this->paramsKey; } /** * Extract the download ID from an extra_query based on the prefix and suffix information stored in the dlid element * of the extension's XML manifest file. * * @param string $extra_query * * @return string */ protected function getLicenseKeyFromExtraQuery(?string $extra_query): string { $extra_query = trim($extra_query ?? ''); if (empty($extra_query)) { return ''; } // Get the extension XML manifest. If the extension or the manifest don't exist return an empty string. $extension = $this->getExtensionObject(); if (!$extension) { return ''; } $installXmlFile = $this->getManifestXML( $extension->element, $extension->type, (int) $extension->client_id, $extension->folder ); if (!$installXmlFile) { return ''; } // If the manifest does not have a dlid element return an empty string. if (!isset($installXmlFile->dlid)) { return ''; } // Naive parsing of the extra_query, the same way Joomla does. $prefix = (string) $installXmlFile->dlid['prefix']; $suffix = (string) $installXmlFile->dlid['suffix']; $licenseKey = substr($extra_query, strlen($prefix)); if ($licenseKey === false) { return ''; } if ($suffix !== '') { $licenseKey = substr($licenseKey, 0, -strlen($suffix)); } return ($licenseKey === false) ? '' : $licenseKey; } /** * Get a legacy extra query string. Do NOT call this directly. Call getExtraQueryString() instead. * * YOU SHOULD OVERRIDE THIS METHOD. This returns dlid=SANITIZED_LICENSE_KEY which is what Akeeba Release System, * used to deliver all Akeeba extensions, expects. * * @param string $licenseKey The license key * * @return string The extra_query string to append to a download URL to implement the license key */ protected function getExtraQueryStringLegacy(string $licenseKey): string { if (empty($licenseKey) || !$this->isValidLicenseKey($licenseKey)) { return ''; } return 'dlid=' . $this->sanitizeLicenseKey($licenseKey); } /** * Get the manifest XML file of a given extension. * * @param string $element element of an extension * @param string $type type of an extension * @param integer $client_id client_id of an extension * @param string $folder folder of an extension * * @return SimpleXMLElement */ protected function getManifestXML(string $element, string $type, int $client_id = 1, ?string $folder = null): SimpleXMLElement { $path = ($client_id !== 0) ? JPATH_ADMINISTRATOR : JPATH_ROOT; switch ($type) { case 'component': $path .= '/components/' . $element . '/' . substr($element, 4) . '.xml'; break; case 'plugin': $path .= '/plugins/' . $folder . '/' . $element . '/' . $element . '.xml'; break; case 'module': $path .= '/modules/' . $element . '/' . $element . '.xml'; break; case 'template': $path .= '/templates/' . $element . '/templateDetails.xml'; break; case 'library': $path = JPATH_ADMINISTRATOR . '/manifests/libraries/' . $element . '.xml'; break; case 'file': $path = JPATH_ADMINISTRATOR . '/manifests/files/' . $element . '.xml'; break; case 'package': $path = JPATH_ADMINISTRATOR . '/manifests/packages/' . $element . '.xml'; } return simplexml_load_file($path); } /** * Fix updates Joomla has already found. * * Joomla sometimes has a stroke and clears the extra_query column of the #__update_sites. This means that it copies * over an empty extra_query to the #__updates table. Even though we have fixed the #__update_sites already, the * fact that it was previously broken means that Joomla STILL doesn't see the extra query for the udpates it has * already found, therefore trying to install them will fail with a 403 error. * * This method addresses this madness. It trawls the #__updates table for any updates already found with the given * update site ID and makes sure that their extra_query matches the one it should have been using. If not, it is * updated. * * If at least one update record has been updated it returns true so that refresh_update_site() can then clear the * undocumented query caches of Joomla. If we don't do that then EVEN THOUGH we have fixed BOTH the #__update_sites * AND the #__updates records for our extension Joomla would STILL fail to download the updates with a 403, since it * would be using the cached updates from the undocumented query cache. * * The fact that Joomla was trying to tell me that somehow it's my problem that it does all these stupid things is * unconscionable. Still, I am proving ONCE AGAIN that I can work around Joomla's major bugs, even the ones that are * left unfixed after a decade of me reporting them privately to the project. What's worse is that my public report * on March 6th, 2021 was met with open hostility and threats instead of the project writing the total of 10 lines * of code to address it. This is absolutely insane. No problem, though, here I am working around Joomla's bugs, as * I have been doing since 2006... * * @param int $updateSiteId * @param string|null $extraQuery * * @return bool */ private function fixUpdates(int $updateSiteId, ?string $extraQuery): bool { // Make sure the update site ID is valid. if ($updateSiteId <= 0) { return false; } // If the extra query is empty there's no reason for me to do anything. Bye! if (empty($extraQuery)) { return false; } // Try to get the update records. try { $db = $this->container->db; $query = $db->getQuery(true) ->select([ $db->qn('update_id'), $db->qn('extra_query'), ]) ->from($db->qn('#__updates')) ->where($db->qn('update_site_id') . ' = ' . $db->q($updateSiteId)); $updates = $db->setQuery($query)->loadObjectList(); } catch (Exception $e) { return false; } // Do I even have any udpates found...? if (empty($updates)) { return false; } // Process each udpate record. $madeChanges = false; foreach ($updates as $update) { // The extra query matches. Nothing to do here. if ($update->extra_query == $extraQuery) { continue; } try { $query = $db->getQuery(true) ->update($db->qn('#__updates')) ->set($db->qn('extra_query') . ' = ' . $db->q($extraQuery)) ->where($db->qn('update_id') . ' = ' . $db->q($update->update_id)); $db->setQuery($query)->execute(); } catch (Exception $e) { // Well, sometimes Joomla bites the bullet... continue; } } return $madeChanges; } /** * Get the list of extensions included in a package * * @param string $package * * @return array */ private function getExtensionsFromPackage(string $package): array { $extensions = []; $xml = $this->getPackageXMLManifest($package); if (is_null($xml)) { return $extensions; } foreach ($xml->xpath('//files/file') as $fileField) { $extension = $this->xmlNodeToExtensionName($fileField); if (is_null($extension)) { continue; } $extensions[] = $extension; } return $extensions; } /** * Gets a SimpleXMLElement representation of the cached manifest of the extension. * * @param string $package * * @return SimpleXMLElement|null */ private function getPackageXMLManifest(string $package): ?SimpleXMLElement { $filePath = $this->getCachedManifestPath($package); if (!@file_exists($filePath) || !@is_readable($filePath)) { return null; } $xmlContent = @file_get_contents($filePath); if (empty($xmlContent)) { return null; } return new SimpleXMLElement($xmlContent); } /** * Get the absolute filesystem path * * @param string $package * * @return string */ private function getCachedManifestPath(string $package): string { return JPATH_MANIFESTS . '/packages/' . $package . '.xml'; } /** * Take a SimpleXMLElement `<file>` node of the package manifest and return the corresponding Joomla extension name * * @param SimpleXMLElement $fileField The `<file>` node of the package manifest * * @return string|null The extension name, null if it cannot be determined. */ private function xmlNodeToExtensionName(SimpleXMLElement $fileField): ?string { $type = (string) $fileField->attributes()->type; $id = (string) $fileField->attributes()->id; switch ($type) { case 'component': case 'file': case 'library': $extension = $id; break; case 'plugin': $group = (string) $fileField->attributes()->group ?? 'system'; $extension = 'plg_' . $group . '_' . $id; break; case 'module': $client = (string) $fileField->attributes()->client ?? 'site'; $extension = (($client != 'site') ? 'a' : '') . $id; break; default: $extension = null; break; } return $extension; } /** * Convert a Joomla extension name to `#__extensions` table query criteria. * * The following kinds of extensions are supported: * * `pkg_something` Package type extension * * `com_something` Component * * `plg_folder_something` Plugins * * `mod_something` Site modules * * `amod_something` Administrator modules. THIS IS CUSTOM. * * `file_something` File type extension * * `lib_something` Library type extension * * @param string $extensionName * * @return string[] */ private function extensionNameToCriteria(string $extensionName): array { $parts = explode('_', $extensionName, 3); switch ($parts[0]) { case 'pkg': return [ 'type' => 'package', 'element' => $extensionName, ]; case 'com': return [ 'type' => 'component', 'element' => $extensionName, ]; case 'plg': return [ 'type' => 'plugin', 'folder' => $parts[1], 'element' => $parts[2], ]; case 'mod': return [ 'type' => 'module', 'element' => $extensionName, 'client_id' => 0, ]; // That's how we note admin modules case 'amod': return [ 'type' => 'module', 'element' => substr($extensionName, 1), 'client_id' => 1, ]; case 'file': return [ 'type' => 'file', 'element' => $extensionName, ]; case 'lib': return [ 'type' => 'library', 'element' => $parts[1], ]; } return []; } }