* @license GNU General Public License version 2 or later; see LICENSE.txt */ namespace Joomla\Component\Installer\Administrator\Model; \defined('_JEXEC') or die; use Joomla\CMS\Component\ComponentHelper; use Joomla\CMS\Language\Text; use Joomla\CMS\MVC\Factory\MVCFactoryInterface; use Joomla\CMS\Schema\ChangeSet; use Joomla\CMS\Table\Extension; use Joomla\CMS\Version; use Joomla\Component\Installer\Administrator\Helper\InstallerHelper; use Joomla\Database\DatabaseQuery; use Joomla\Database\Exception\ExecutionFailureException; use Joomla\Database\ParameterType; use Joomla\Registry\Registry; \JLoader::register('JoomlaInstallerScript', JPATH_ADMINISTRATOR . '/components/com_admin/script.php'); /** * Installer Database Model * * @since 1.6 */ class DatabaseModel extends InstallerModel { /** * Set the model context * * @var string * * @since 4.0.0 */ protected $_context = 'com_installer.discover'; /** * ChangeSet of all extensions * * @var array * * @since 4.0.0 */ private $changeSetList = array(); /** * Total of errors * * @var integer * * @since 4.0.0 */ private $errorCount = 0; /** * Constructor. * * @param array $config An optional associative array of configuration settings. * @param MVCFactoryInterface $factory The factory. * * @see ListModel * @since 4.0.0 */ public function __construct($config = array(), MVCFactoryInterface $factory = null) { if (empty($config['filter_fields'])) { $config['filter_fields'] = array( 'update_site_name', 'name', 'client_id', 'client', 'client_translated', 'status', 'type', 'type_translated', 'folder', 'folder_translated', 'extension_id' ); } parent::__construct($config, $factory); } /** * Method to return the total number of errors in all the extensions, saved in cache. * * @return integer * * @throws \Exception * * @since 4.0.0 */ public function getErrorCount() { return $this->errorCount; } /** * Method to populate the schema cache. * * @param integer $cid The extension ID to get the schema for * * @return void * * @throws \Exception * * @since 4.0.0 */ private function fetchSchemaCache($cid = 0) { // We already have it if (array_key_exists($cid, $this->changeSetList)) { return; } // Add the ID to the state so it can be used for filtering if ($cid) { $this->setState('filter.extension_id', $cid); } // With the parent::save it can get the limit and we need to make sure it gets all extensions $results = $this->_getList($this->getListQuery()); foreach ($results as $result) { $errorMessages = array(); $errorCount = 0; if (strcmp($result->element, 'joomla') === 0) { $result->element = 'com_admin'; if (!$this->getDefaultTextFilters()) { $errorMessages[] = Text::_('COM_INSTALLER_MSG_DATABASE_FILTER_ERROR'); $errorCount++; } } $db = $this->getDatabase(); if ($result->type === 'component') { $basePath = JPATH_ADMINISTRATOR . '/components/' . $result->element; } elseif ($result->type === 'plugin') { $basePath = JPATH_PLUGINS . '/' . $result->folder . '/' . $result->element; } elseif ($result->type === 'module') { // Typehint to integer to normalise some DBs returning strings and others integers if ((int) $result->client_id === 1) { $basePath = JPATH_ADMINISTRATOR . '/modules/' . $result->element; } elseif ((int) $result->client_id === 0) { $basePath = JPATH_SITE . '/modules/' . $result->element; } else { // Module with unknown client id!? - bail continue; } } elseif ($result->type === 'file' && $result->element === 'com_admin') { // Specific bodge for the Joomla CMS special database check which points to com_admin $basePath = JPATH_ADMINISTRATOR . '/components/' . $result->element; } else { // Unknown extension type (library, files etc which don't have known SQL paths right now) continue; } // Search the standard SQL Path for the SQL Updates and then if not there check the configuration of the XML // file. This just gives us a small performance win of not parsing the XML every time. $folderTmp = $basePath . '/sql/updates/'; if (!file_exists($folderTmp)) { $installationXML = InstallerHelper::getInstallationXML( $result->element, $result->type, $result->client_id, $result->type === 'plugin' ? $result->folder : null ); if ($installationXML !== null) { $folderTmp = (string) $installationXML->update->schemas->schemapath[0]; $a = explode('/', $folderTmp); array_pop($a); $folderTmp = $basePath . '/' . implode('/', $a); } } // Can't find the folder still - give up now and move on. if (!file_exists($folderTmp)) { continue; } $changeSet = new ChangeSet($db, $folderTmp); // If the version in the #__schemas is different // than the update files, add to problems message $schema = $changeSet->getSchema(); // If the schema is empty we couldn't find any update files. Just ignore the extension. if (empty($schema)) { continue; } if ($result->version_id !== $schema) { $errorMessages[] = Text::sprintf('COM_INSTALLER_MSG_DATABASE_SCHEMA_ERROR', $result->version_id, $schema); $errorCount++; } // If the version in the manifest_cache is different than the // version in the installation xml, add to problems message $compareUpdateMessage = $this->compareUpdateVersion($result); if ($compareUpdateMessage) { $errorMessages[] = $compareUpdateMessage; $errorCount++; } // If there are errors in the database, add to the problems message $errors = $changeSet->check(); $errorsMessage = $this->getErrorsMessage($errors); if ($errorsMessage) { $errorMessages = array_merge($errorMessages, $errorsMessage); $errorCount++; } // Number of database tables Checked and Skipped $errorMessages = array_merge($errorMessages, $this->getOtherInformationMessage($changeSet->getStatus())); // Set the total number of errors $this->errorCount += $errorCount; // Collect the extension details $this->changeSetList[$result->extension_id] = array( 'folderTmp' => $folderTmp, 'errorsMessage' => $errorMessages, 'errorsCount' => $errorCount, 'results' => $changeSet->getStatus(), 'schema' => $schema, 'extension' => $result ); } } /** * Method to auto-populate the model state. * * Note. Calling getState in this method will result in recursion. * * @param string $ordering An optional ordering field. * @param string $direction An optional direction (asc|desc). * * @return void * * @since 1.6 */ protected function populateState($ordering = 'name', $direction = 'asc') { $this->setState('filter.search', $this->getUserStateFromRequest($this->context . '.filter.search', 'filter_search', '', 'string')); $this->setState('filter.client_id', $this->getUserStateFromRequest($this->context . '.filter.client_id', 'filter_client_id', null, 'int')); $this->setState('filter.type', $this->getUserStateFromRequest($this->context . '.filter.type', 'filter_type', '', 'string')); $this->setState('filter.folder', $this->getUserStateFromRequest($this->context . '.filter.folder', 'filter_folder', '', 'string')); parent::populateState($ordering, $direction); } /** * Fixes database problems. * * @param array $cids List of the selected extensions to fix * * @return void|boolean * * @throws \Exception * * @since 4.0.0 */ public function fix($cids = array()) { $db = $this->getDatabase(); foreach ($cids as $i => $cid) { // Load the database issues $this->fetchSchemaCache($cid); $changeSet = $this->changeSetList[$cid]; $changeSet['changeset'] = new ChangeSet($db, $changeSet['folderTmp']); $changeSet['changeset']->fix(); $this->fixSchemaVersion($changeSet['changeset'], $changeSet['extension']->extension_id); $this->fixUpdateVersion($changeSet['extension']->extension_id); if ($changeSet['extension']->element === 'com_admin') { $installer = new \JoomlaInstallerScript(); $installer->deleteUnexistingFiles(); $this->fixDefaultTextFilters(); /* * Finally, if the schema updates succeeded, make sure the database table is * converted to utf8mb4 or, if not supported by the server, compatible to it. */ $statusArray = $changeSet['changeset']->getStatus(); if (count($statusArray['error']) == 0) { $installer->convertTablesToUtf8mb4(false); } } } } /** * Gets the changeset array. * * @return array Array with the information of the versions problems, errors and the extensions itself * * @throws \Exception * * @since 4.0.0 */ public function getItems() { $this->fetchSchemaCache(); $results = parent::getItems(); $results = $this->mergeSchemaCache($results); return $results; } /** * Method to get the database query * * @return DatabaseQuery The database query * * @since 4.0.0 */ protected function getListQuery() { $db = $this->getDatabase(); $query = $db->getQuery(true) ->select( $db->quoteName( [ 'extensions.client_id', 'extensions.element', 'extensions.extension_id', 'extensions.folder', 'extensions.manifest_cache', 'extensions.name', 'extensions.type', 'schemas.version_id' ] ) ) ->from( $db->quoteName( '#__schemas', 'schemas' ) ) ->join( 'INNER', $db->quoteName('#__extensions', 'extensions'), $db->quoteName('schemas.extension_id') . ' = ' . $db->quoteName('extensions.extension_id') ); $type = $this->getState('filter.type'); $clientId = $this->getState('filter.client_id'); $extensionId = $this->getState('filter.extension_id'); $folder = $this->getState('filter.folder'); if ($type) { $query->where($db->quoteName('extensions.type') . ' = :type') ->bind(':type', $type); } if ($clientId != '') { $clientId = (int) $clientId; $query->where($db->quoteName('extensions.client_id') . ' = :clientid') ->bind(':clientid', $clientId, ParameterType::INTEGER); } if ($extensionId != '') { $extensionId = (int) $extensionId; $query->where($db->quoteName('extensions.extension_id') . ' = :extensionid') ->bind(':extensionid', $extensionId, ParameterType::INTEGER); } if ($folder != '' && in_array($type, array('plugin', 'library', ''))) { $folder = $folder === '*' ? '' : $folder; $query->where($db->quoteName('extensions.folder') . ' = :folder') ->bind(':folder', $folder); } // Process search filter (update site id). $search = $this->getState('filter.search'); if (!empty($search) && stripos($search, 'id:') === 0) { $ids = (int) substr($search, 3); $query->where($db->quoteName('schemas.extension_id') . ' = :eid') ->bind(':eid', $ids, ParameterType::INTEGER); } return $query; } /** * Merge the items that will be visible with the changeSet information in cache * * @param array $results extensions returned from parent::getItems(). * * @return array the changeSetList of the merged items * * @since 4.0.0 */ protected function mergeSchemaCache($results) { $changeSetList = $this->changeSetList; $finalResults = array(); foreach ($results as $result) { if (array_key_exists($result->extension_id, $changeSetList) && $changeSetList[$result->extension_id]) { $finalResults[] = $changeSetList[$result->extension_id]; } } return $finalResults; } /** * Get version from #__schemas table. * * @param integer $extensionId id of the extensions. * * @return mixed the return value from the query, or null if the query fails. * * @throws \Exception * * @since 4.0.0 */ public function getSchemaVersion($extensionId) { $db = $this->getDatabase(); $extensionId = (int) $extensionId; $query = $db->getQuery(true) ->select($db->quoteName('version_id')) ->from($db->quoteName('#__schemas')) ->where($db->quoteName('extension_id') . ' = :extensionid') ->bind(':extensionid', $extensionId, ParameterType::INTEGER); $db->setQuery($query); return $db->loadResult(); } /** * Fix schema version if wrong. * * @param ChangeSet $changeSet Schema change set. * @param integer $extensionId ID of the extensions. * * @return mixed string schema version if success, false if fail. * * @throws \Exception * * @since 4.0.0 */ public function fixSchemaVersion($changeSet, $extensionId) { // Get correct schema version -- last file in array. $schema = $changeSet->getSchema(); // Check value. If ok, don't do update. if ($schema == $this->getSchemaVersion($extensionId)) { return $schema; } // Delete old row. $extensionId = (int) $extensionId; $db = $this->getDatabase(); $query = $db->getQuery(true) ->delete($db->quoteName('#__schemas')) ->where($db->quoteName('extension_id') . ' = :extensionid') ->bind(':extensionid', $extensionId, ParameterType::INTEGER); $db->setQuery($query)->execute(); // Add new row. $query->clear() ->insert($db->quoteName('#__schemas')) ->columns($db->quoteName('extension_id') . ',' . $db->quoteName('version_id')) ->values(':extensionid, :schema') ->bind(':extensionid', $extensionId, ParameterType::INTEGER) ->bind(':schema', $schema); $db->setQuery($query); try { $db->execute(); } catch (ExecutionFailureException $e) { return false; } return $schema; } /** * Get current version from #__extensions table. * * @param object $extension data from #__extensions of a single extension. * * @return mixed string message with the errors with the update version or null if none * * @since 4.0.0 */ public function compareUpdateVersion($extension) { $updateVersion = json_decode($extension->manifest_cache)->version; if ($extension->element === 'com_admin') { $extensionVersion = JVERSION; } else { $installationXML = InstallerHelper::getInstallationXML( $extension->element, $extension->type, $extension->client_id, $extension->type === 'plugin' ? $extension->folder : null ); $extensionVersion = (string) $installationXML->version; } if (version_compare($extensionVersion, $updateVersion) != 0) { return Text::sprintf('COM_INSTALLER_MSG_DATABASE_UPDATEVERSION_ERROR', $updateVersion, $extension->name, $extensionVersion); } return null; } /** * Get a message of the tables skipped and checked * * @param array $status status of of the update files * * @return array Messages with the errors with the update version * * @since 4.0.0 */ private function getOtherInformationMessage($status) { $problemsMessage = array(); $problemsMessage[] = Text::sprintf('COM_INSTALLER_MSG_DATABASE_CHECKED_OK', count($status['ok'])); $problemsMessage[] = Text::sprintf('COM_INSTALLER_MSG_DATABASE_SKIPPED', count($status['skipped'])); return $problemsMessage; } /** * Get a message with all errors found in a given extension * * @param array $errors data from #__extensions of a single extension. * * @return array List of messages with the errors in the database * * @since 4.0.0 */ private function getErrorsMessage($errors) { $errorMessages = array(); foreach ($errors as $line => $error) { $key = 'COM_INSTALLER_MSG_DATABASE_' . $error->queryType; $messages = $error->msgElements; $file = basename($error->file); $message0 = isset($messages[0]) ? $messages[0] : ' '; $message1 = isset($messages[1]) ? $messages[1] : ' '; $message2 = isset($messages[2]) ? $messages[2] : ' '; $errorMessages[] = Text::sprintf($key, $file, $message0, $message1, $message2); } return $errorMessages; } /** * Fix Joomla version in #__extensions table if wrong (doesn't equal \JVersion short version). * * @param integer $extensionId id of the extension * * @return mixed string update version if success, false if fail. * * @since 4.0.0 */ public function fixUpdateVersion($extensionId) { $table = new Extension($this->getDatabase()); $table->load($extensionId); $cache = new Registry($table->manifest_cache); $updateVersion = $cache->get('version'); if ($table->get('type') === 'file' && $table->get('element') === 'joomla') { $extensionVersion = new Version(); $extensionVersion = $extensionVersion->getShortVersion(); } else { $installationXML = InstallerHelper::getInstallationXML( $table->get('element'), $table->get('type'), $table->get('client_id'), $table->get('type') === 'plugin' ? $table->get('folder') : null ); $extensionVersion = (string) $installationXML->version; } if ($updateVersion === $extensionVersion) { return $updateVersion; } $cache->set('version', $extensionVersion); $table->set('manifest_cache', $cache->toString()); if ($table->store()) { return $extensionVersion; } return false; } /** * For version 2.5.x only * Check if com_config parameters are blank. * * @return string default text filters (if any). * * @since 4.0.0 */ public function getDefaultTextFilters() { $table = new Extension($this->getDatabase()); $table->load($table->find(array('name' => 'com_config'))); return $table->params; } /** * For version 2.5.x only * Check if com_config parameters are blank. If so, populate with com_content text filters. * * @return void * * @since 4.0.0 */ private function fixDefaultTextFilters() { $table = new Extension($this->getDatabase()); $table->load($table->find(array('name' => 'com_config'))); // Check for empty $config and non-empty content filters. if (!$table->params) { // Get filters from com_content and store if you find them. $contentParams = ComponentHelper::getComponent('com_content')->getParams(); if ($contentParams->get('filters')) { $newParams = new Registry(); $newParams->set('filters', $contentParams->get('filters')); $table->params = (string) $newParams; $table->store(); } } } }