<?php /** * @package akeebabackup * @copyright Copyright (c)2006-2022 Nicholas K. Dionysopoulos / Akeeba Ltd * @license GNU General Public License version 3, or later */ namespace Akeeba\Component\AkeebaBackup\Administrator\Model; defined('_JEXEC') or die; use Akeeba\Component\AkeebaBackup\Administrator\Model\Mixin\FetchDBO; use Exception; use Joomla\CMS\MVC\Factory\MVCFactoryInterface; use Joomla\CMS\MVC\Model\BaseDatabaseModel; use Joomla\CMS\Table\Extension; use Joomla\Database\DatabaseDriver; use Joomla\Database\ParameterType; use SimpleXMLElement; #[\AllowDynamicProperties] class UpdatesModel extends BaseDatabaseModel { use FetchDBO; /** @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 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; protected $extensionKey = 'pkg_akeebabackup'; public function __construct($config = [], MVCFactoryInterface $factory = null) { parent::__construct($config, $factory); $this->version = AKEEBABACKUP_VERSION; $this->updateSite = 'https://cdn.akeeba.com/updates/pkgakeebabackupcore.xml'; $this->updateSiteName = 'Akeeba Backup Core for Joomla!'; if (defined('AKEEBABACKUP_PRO') ? AKEEBABACKUP_PRO : 0) { $this->updateSite = 'https://cdn.akeeba.com/updates/pkgakeebabackuppro.xml'; $this->updateSiteName = 'Akeeba Backup Professional for Joomla!'; } $this->extension_id = $this->findExtensionId($this->extensionKey, 'package'); if (empty($this->extension_id)) { $this->createFakePackageExtension(); $this->extension_id = $this->findExtensionId($this->extensionKey, 'package'); } } /** * Refreshes the Joomla! update sites for this extension as needed * * @return void */ public function refreshUpdateSite(): void { if (empty($this->extension_id)) { return; } // Create the update site definition we want to store to the database $update_site = [ 'name' => $this->updateSiteName, 'type' => 'extension', 'location' => $this->updateSite, 'enabled' => 1, 'last_check_timestamp' => 0, // 'extra_query' => 'dlid=' . $this->getLicenseKey(), ]; // Get a reference to the db driver $db = $this->getDB(); // 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') . ' = :usid') ->bind(':usid', $id, ParameterType::INTEGER); try { $aSite = $db->setQuery($query)->loadObject() ?: null; } catch (Exception $e) { $aSite = null; } if (empty($aSite)) { // This update site no longer exists. 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; } // Is the extra_query missing from this update site but already have an extra_query from an older one? if (empty($aSite->extra_query) && !empty($update_site['extra_query'])) { $mustUpdate = true; $aSite->extra_query = $update_site['extra_query']; } // Update the update site if necessary if ($mustUpdate) { $db->updateObject('#__update_sites', $aSite, 'update_site_id', true); } continue; } // Try to carry forward the first extra query (download key) found in the old update sites. $update_site['extra_query'] = ($update_site['extra_query'] ?? '') ?: ($aSite->extra_query ?: ''); // In any other case we need to delete this update site, it's obsolete $deleteOldSites[] = $aSite->update_site_id; } if (!empty($deleteOldSites)) { try { // Delete update sites $query = $db->getQuery(true) ->delete('#__update_sites') ->whereIn($db->qn('update_site_id'), $deleteOldSites, ParameterType::INTEGER); $db->setQuery($query)->execute(); // Delete update sites to extension ID records $query = $db->getQuery(true) ->delete('#__update_sites_extensions') ->whereIn($db->qn('update_site_id'), $deleteOldSites, ParameterType::INTEGER); $db->setQuery($query)->execute(); } catch (\Exception $e) { // Do nothing on failure return; } } // Do we still need to create a new update site? if ($needNewUpdateSite) { $update_site['extra_query'] = $update_site['extra_query'] ?? ''; // 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); } } /** * Gets the update site Ids for our extension. * * @return array An array of IDs */ public function getUpdateSiteIds(): array { $db = $this->getDB(); $query = $db->getQuery(true) ->select($db->qn('update_site_id')) ->from($db->qn('#__update_sites_extensions')) ->where($db->qn('extension_id') . ' = :eid') ->bind(':eid', $this->extension_id, ParameterType::INTEGER); try { $ret = $db->setQuery($query)->loadColumn(0); } catch (Exception $e) { $ret = null; } return is_array($ret) ? $ret : []; } /** * Get the contents of all the update sites of the configured extension * * @return array|null */ public function getUpdateSites(): ?array { $updateSiteIDs = $this->getUpdateSiteIds(); $db = $this->getDB(); $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; } /** * 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 { // 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)) { return ''; } // Return the parsed results. return $this->getLicenseKeyFromExtraQuery($extra_query); } /** * Returns an object with the #__extensions table record for the current extension. * * @return object|null */ public function getExtensionObject() { [$extensionPrefix, $extensionName] = explode('_', $this->extensionKey); switch ($extensionPrefix) { default: case 'com': $type = 'component'; $name = $this->extensionKey; break; case 'pkg': $type = 'package'; $name = $this->extensionKey; break; } // Find the extension ID $db = $this->getDB(); $query = $db->getQuery(true) ->select('*') ->from($db->qn('#__extensions')) ->where($db->qn('type') . ' = :type') ->where($db->qn('element') . ' = :name') ->bind(':type', $type) ->bind(':name', $name); try { $db->setQuery($query); $extension = $db->loadObject(); } catch (Exception $e) { return null; } return $extension; } /** * 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)); } /** * 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; } /** * 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 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|bool False on failure */ protected function getManifestXML(string $element, string $type, int $client_id = 1, ?string $folder = null) { $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); } /** * Gets the ID of an extension * * @param string $element Extension element, e.g. com_foo, mod_foo, lib_foo, pkg_foo or foo (CAUTION: plugin, * file!) * @param string $type Extension type: component, module, library, package, plugin or file * @param null $folder Plugins: plugin folder. Modules: admin/site * * @return int Extension ID or 0 on failure */ private function findExtensionId(string $element, string $type = 'component', ?string $folder = null): int { $db = $this->getDB(); $query = $db->getQuery(true) ->select($db->qn('extension_id')) ->from($db->qn('#__extensions')) ->where($db->qn('element') . ' = :element') ->where($db->qn('type') . ' = :type') ->bind(':element', $element, ParameterType::STRING) ->bind(':type', $type, ParameterType::STRING); // Plugin? We should look for a folder if ($type == 'plugin') { $folder = $folder ?: 'system'; $query->where($db->qn('folder') . ' = ' . $db->q($folder)); } // Module? Use the folder to determine if it's site or admin module. if ($type == 'module') { $folder = $folder ?: 'site'; $query->where($db->qn('client_id') . ' = ' . $db->q(($folder == 'site') ? 0 : 1)); } try { $id = $db->setQuery($query, 0, 1)->loadResult(); } catch (Exception $e) { $id = 0; } return empty($id) ? 0 : (int) $id; } private function createFakePackageExtension() { /** @var DatabaseDriver $db */ $db = $this->getDB(); $manifestCacheJson = json_encode([ 'name' => 'Akeeba Backup for Joomla! package', 'type' => 'package', 'creationDate' => gmdate('Y-m-d'), 'author' => 'Nicholas K. Dionysopoulos', 'copyright' => sprintf('Copyright (c)2006-%d Akeeba Ltd / Nicholas K. Dionysopoulos', gmdate('Y')), 'authorEmail' => '', 'authorUrl' => 'https://www.akeeba.com', 'version' => $this->version, 'description' => sprintf('Akeeba Backup for Joomla! installation package v.%s', $this->version), 'group' => '', 'filename' => 'pkg_akeebabackup', ]); $extensionRecord = [ 'name' => 'Akeeba Backup for Joomla! package', 'type' => 'package', 'element' => 'pkg_akeebabackup', 'folder' => '', 'client_id' => 0, 'enabled' => 1, 'access' => 1, 'protected' => 0, 'manifest_cache' => $manifestCacheJson, 'params' => '{}', 'checked_out' => 0, 'checked_out_time' => null, 'state' => 0, ]; $extension = new Extension($db); $extension->save($extensionRecord); $this->createFakePackageManifest(); } private function createFakePackageManifest() { $path = sprintf("%s/manifests/packages/%s.xml", JPATH_ADMINISTRATOR, $this->extensionKey); if (file_exists($path)) { return; } $isPro = defined('AKEEBABACKUP_PRO') ? AKEEBABACKUP_PRO : 0; $proCore = $isPro ? 'pro' : 'core'; $dlid = $isPro ? '<dlid prefix="dlid=" suffix=""/>' : ''; $year = gmdate('Y'); $date = gmdate('Y-m-d'); $proPlugins = <<< END <file type="plugin" group="console" id="akeebabackup">plg_console_akeebabackup.zip</file> <file type="plugin" group="system" id="backuponupdate">plg_system_backuponupdate.zip</file> <file type="plugin" group="actionlog" id="akeebabackup">plg_actionlog_akeebabackup.zip</file> END; $proPlugins = $isPro ? $proPlugins : ''; $content = <<< XML <?xml version="1.0" encoding="utf-8"?> <extension version="3.9.0" type="package" method="upgrade"> $dlid <name>Akeeba Backup for Joomla! package</name> <author>Nicholas K. Dionysopoulos</author> <creationDate>$date</creationDate> <packagename>akeebabackup</packagename> <version>{$this->version}</version> <url>https://www.akeeba.com</url> <packager>Akeeba Ltd</packager> <packagerurl>https://www.akeeba.com</packagerurl> <copyright>Copyright (c)2006-$year Akeeba Ltd / Nicholas K. Dionysopoulos</copyright> <license>GNU GPL v3 or later</license> <description>Akeeba Backup for Joomla! installation package {$this->version}</description> <files> <file type="component" id="com_akeebabackup">com_akeebabackup-{$proCore}.zip</file> <file type="plugin" group="quickicon" id="akeebabackup">plg_quickicon_akeebabackup.zip</file> $proPlugins </files> <scriptfile>script.akeebabackup.php</scriptfile> </extension> XML; if (!@file_put_contents($content, $path)) { // File::write($path, $content); } } }