<?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\UpgradeHandler; use Akeeba\Component\AkeebaBackup\Administrator\Helper\ComponentParams; use Akeeba\Component\AkeebaBackup\Administrator\Model\UpgradeModel; use Akeeba\Engine\Factory; use Akeeba\Engine\Platform; use Exception; use Joomla\CMS\Component\ComponentHelper; use Joomla\CMS\Date\Date; use Joomla\CMS\Filesystem\Folder; use Joomla\CMS\Utility\BufferStreamHandler; use Joomla\Component\Installer\Administrator\Helper\InstallerHelper; use Joomla\Database\DatabaseDriver; use Joomla\Database\DatabaseInterface; use Joomla\Database\ParameterType; use RuntimeException; use Throwable; /** * Custom UpgradeModel handler for migrating from Akeeba Backup 7/8 to 9+ * * @since 9.0.0 */ class MigrateSettings { /** * The UpgradeModel instance we belong to. * * @var UpgradeModel * @since 9.0.0 */ private $upgradeModel; /** * Joomla database driver object * * @var DatabaseInterface|DatabaseDriver * @since 9.0.0 */ private $dbo; /** * Constructor. * * @param UpgradeModel $upgradeModel The UpgradeModel instance we belong to * * @since 9.0.0 */ public function __construct(UpgradeModel $upgradeModel) { $this->upgradeModel = $upgradeModel; $this->dbo = $upgradeModel->getDbo(); } /** * Do I need to migrate settings from pkg_akeeba? * * This will return true only if com_akeeba is installed AND I have not already migrated the settings. * * @return bool * @since 9.0.0 */ public function onNeedsMigration(): bool { $cParams = ComponentHelper::getParams('com_akeebabackup'); $alreadyMigrated = $cParams->get('migrated_from_pkg_akeeba', 0) == 1; $hasPkgAkeeba = !empty($this->upgradeModel->getExtensionId('com_akeeba')); return $hasPkgAkeeba && !$alreadyMigrated; } /** * Do we have a compatible, old version of Akeeba Backup (com_akeeba) already installed on the site? * * This tries to load the version of the already installed version from the manifest cache of the old component. A * version is compatible if it's Akeeba Backup 7 or 8 or a dev release we created anytime after January 1st, 2020 * i.e. after we had announced Akeeba Backup 7 as an upcoming release. * * @return bool * @since 9.0.0 */ public function onHasCompatibleAkeebaVersion(): bool { // Get the extension ID for com_akeeba $eid = $this->upgradeModel->getExtensionId('com_akeeba'); if (empty($eid)) { return false; } // Get the cached manifest of the old component (com_akeeba) $db = $this->dbo; $query = $db->getQuery(true) ->select([ $db->quoteName('manifest_cache'), ]) ->from($db->quoteName('#__extensions')) ->where($db->quoteName('type') . ' = ' . $db->quote('component')) ->where($db->quoteName('element') . ' = ' . $db->quote('com_akeeba')); try { $manifestCacheJson = $db->setQuery($query)->loadResult(); } catch (Exception $e) { return false; } // JSON decode the manifest $manifest = @json_decode($manifestCacheJson, true); if (empty($manifest)) { return false; } // Get the creation date and version $creationDate = $manifest['creationDate']; $version = $manifest['version']; // If it's version 7.0.0 or later we're golden. if (version_compare($version, '7.0.0', 'ge')) { return true; } try { $date = new Date($creationDate); } catch (Exception $e) { return false; } // If we have a dev release it must be published after January 1st, 2020, i.e. the year must be 2020 or later. if ($date->year >= 2020) { return true; } // All checks failed. This is an old version. return false; } /** * Migrate from Akeeba Backup 7.x / 8.x * * @return bool Always true * @since 9.0.0 */ public function onMigrateSettings(): bool { $this->migrateDownloadKey(); $this->migrateComponentOptions(); $this->migrateEngineKey(); $this->migrateDatabaseData(); $this->migrateDefaultBackupFolder(); return true; } /** * Migrates the component options from com_akeeba to com_akeebabackup and mark everything as migrated. * * @return void * @since 9.0.0 */ private function migrateComponentOptions(): void { $oldParams = ComponentHelper::getParams('com_akeeba'); $cParams = ComponentHelper::getParams('com_akeebabackup'); foreach ($oldParams->getIterator() as $key => $value) { $cParams->set($key, $value); } $cParams->set('migrated_from_pkg_akeeba', 1); ComponentParams::save($cParams); } /** * Migrate the Akeeba Engine settings encryption key from com_akeeba to com_akeebabackup. * * @return void * @since 9.0.0 */ private function migrateEngineKey(): void { $oldKey = JPATH_ADMINISTRATOR . '/components/com_akeeba/BackupEngine/serverkey.php'; $newKey = JPATH_ADMINISTRATOR . '/components/com_akeebabackup/engine/serverkey.php'; if (!@file_exists($oldKey) || !@is_file($oldKey) || !@is_readable($oldKey)) { return; } if (!@copy($oldKey, $newKey)) { // File::copy($oldKey, $newKey); } } /** * Migrate the database data from com_akeeba to com_akeebabackup. * * @return void * @since 9.0.0 */ private function migrateDatabaseData(): void { // First, we will mass-migrate the data $tableMap = [ '#__ak_profiles' => '#__akeebabackup_profiles', '#__ak_stats' => '#__akeebabackup_backups', '#__ak_storage' => '#__akeebabackup_storage', ]; $this->dbo->transactionStart(); foreach ($tableMap as $oldTable => $newTable) { // Remove all data from the existing table $this->dbo->truncateTable($newTable); // Create and run an INSERT INTO ... SELECT query to make the migrate run as fast as possible. $outerQuery = $this->dbo->getQuery(true) ->select('*') ->from($this->dbo->quoteName($oldTable)); $innerQuery = 'INSERT INTO ' . $this->dbo->quoteName($newTable) . ' ' . ((string) $outerQuery); $this->dbo->setQuery($innerQuery)->execute(); } try { $this->dbo->transactionCommit(); } catch (Exception $e) { } } private function migrateDefaultBackupFolder(): void { $oldFolder = 'components/com_akeeba/backup'; $newFolder = 'components/com_akeebabackup/backup'; // STEP 0 - Load the migrated server key using our neat in-memory patching trick. We'll need it in step 1 below. $this->reloadEncryptionKey(); // STEP 1 — Replace com_akeeba to com_akeebabackup in the absolute output directory path of each backup profile. foreach ($this->getBackupProfileIDs() as $profile) { // Load the profile configuration try { Platform::getInstance()->load_configuration($profile); $config = Factory::getConfiguration(); } catch (Throwable $e) { // Your database is broken :( continue; } $outputDirectory = $config->get('akeeba.basic.output_directory', '[DEFAULT_OUTPUT]'); if (strpos($outputDirectory, $oldFolder) === false) { continue; } $outputDirectory = str_replace($oldFolder, $newFolder, $outputDirectory); try { Platform::getInstance()->save_configuration($profile); } catch (Throwable $e) { // Your database is broken! continue; } } // STEP 2 — Replace com_akeeba to com_akeebabackup in the absolute file path of every backup record. $db = $this->dbo; $query = $db->getQuery(true) ->update($db->quoteName('#__akeebabackup_backups')) ->set( $db->quoteName('absolute_path') . ' = REPLACE(' . $db->quoteName('absolute_path') . ', :find, :replace' . ')' ) ->bind(':find', $oldFolder) ->bind(':replace', $newFolder); $db->setQuery($query)->execute(); // STEP 3 — Move all backup archives, log files and so on $sourceDir = JPATH_ADMINISTRATOR . '/' . $oldFolder; $destDir = JPATH_ADMINISTRATOR . '/' . $newFolder; foreach (Folder::files($sourceDir) as $fileName) { $sourceFile = $sourceDir . '/' . $fileName; $destFile = $destDir . '/' . $fileName; if (!@rename($sourceFile, $destFile)) { // File::move($sourceFile, $destFile); } } } /** * Returns the IDs of all backup profiles * * @return array * @since 9.0.0 */ private function getBackupProfileIDs(): array { $db = $this->dbo; $query = $db->getQuery(true) ->select($db->quoteName('id')) ->from($db->quoteName('#__akeebabackup_profiles')); $profiles = $db->setQuery($query)->loadColumn() ?: []; return $profiles; } private function reloadEncryptionKey() { $newKey = JPATH_ADMINISTRATOR . '/components/com_akeebabackup/engine/serverkey.php'; $contents = @file_get_contents($newKey); if ($contents === false) { return; } $contents = str_replace('AKEEBA_SERVERKEY', 'AKEEBA_MIGRATED_SERVERKEY', $contents); BufferStreamHandler::stream_register(); file_put_contents('buffer://serverkey.php', $contents); require_once 'buffer://serverkey.php'; if (!defined('AKEEBA_MIGRATED_SERVERKEY')) { [$junk, $toProcess] = explode("'AKEEBA_MIGRATED_SERVERKEY',", $contents); $toProcess = trim($toProcess, " \t\n;php?>)"); $toProcess = trim($toProcess, '\''); define('AKEEBA_MIGRATED_SERVERKEY', $toProcess); } /** @noinspection PhpUndefinedConstantInspection */ $key = @base64_decode(AKEEBA_MIGRATED_SERVERKEY); if (empty($key)) { return; } $secSettings = Factory::getSecureSettings(); $secSettings->setKey($key); } private function migrateDownloadKey(): void { $oldEid = $this->getExtensionId('pkg_akeeba', 'package', 0); $newEid = $this->getExtensionId('pkg_akeebabackup', 'package', 0); if (empty($oldEid) || empty($newEid)) { return; } $oldDLKey = $this->getDownloadKey($oldEid); $newDLKey = $this->getDownloadKey($newEid); // If I already have a key do nothing if ($newDLKey['valid']) { return; } // If the old version does not have a key or does not support keys the Joomla 4 fall back to component options $dlid = $oldDLKey['value'] ?? null; if (!$oldDLKey['valid'] || !$oldDLKey['supported']) { $dlid = ComponentHelper::getParams('com_akeeba')->get('update_dlid', null); } // No Download ID to migrate? Okay, then. if (empty($dlid)) { return; } $this->setDownloadKey($newEid, $dlid); } private function getDownloadKey(int $extension_id): array { // Get the extension record $db = $this->dbo; $extension = new \Joomla\CMS\Table\Extension($db); $extension->load($extension_id); // Joomla expects the extra_query key in the object. This comes from the update site $extension->set('extra_query', $this->getExtraQuery($extension_id)); // Use the InstallerHelper::getDownloadKey to get the current download key return InstallerHelper::getDownloadKey($extension); } private function setDownloadKey(int $extension_id, ?string $downloadKey) { $dlKeyInfo = $this->getDownloadKey($extension_id); if (!($dlKeyInfo['supported'] ?? false)) { throw new RuntimeException('The extension does not support Download Keys'); } $extraQuery = empty($downloadKey) ? null : ($dlKeyInfo['prefix'] . $downloadKey . $dlKeyInfo['suffix']); $this->setExtraQuery($extension_id, $extraQuery); } private function getExtraQuery(int $extension_id): ?string { $db = $this->dbo; $query = $db->getQuery(true) ->select($db->quoteName('update_site_id')) ->from($db->quoteName('#__update_sites_extensions')) ->where($db->quoteName('extension_id') . ' = :eid') ->bind(':eid', $extension_id, ParameterType::INTEGER); $usid = $db->setQuery($query)->loadResult() ?: null; if (empty($usid)) { return null; } $query = $db->getQuery(true) ->select($db->quoteName('extra_query')) ->from($db->quoteName('#__update_sites')) ->where($db->quoteName('update_site_id') . ' = :usid') ->bind(':usid', $usid, ParameterType::INTEGER); return $db->setQuery($query)->loadResult() ?: null; } private function setExtraQuery(int $extension_id, ?string $extraQuery): void { $db = $this->dbo; $query = $db->getQuery(true) ->select($db->quoteName('update_site_id')) ->from($db->quoteName('#__update_sites_extensions')) ->where($db->quoteName('extension_id') . ' = :eid') ->bind(':eid', $extension_id, ParameterType::INTEGER); $usid = $db->setQuery($query)->loadResult() ?: null; if (empty($usid)) { throw new RuntimeException('Cannot find the update site for the extension'); } $query = $db->getQuery(true) ->update($db->quoteName('#__update_sites')) ->set($db->quoteName('extra_query') . ' = ' . $db->quote($extraQuery)) ->where($db->quoteName('update_site_id') . ' = :usid') ->bind(':usid', $usid, ParameterType::INTEGER); $db->setQuery($query)->execute(); } private function getExtensionId($element, $type, $clientId): ?int { $db = $this->dbo; $query = $db->getQuery(true) ->select($db->quoteName('extension_id')) ->from($db->quoteName('#__extensions')) ->where($db->quoteName('type') . ' = :type') ->where($db->quoteName('element') . ' = :element') ->where($db->quoteName('client_id') . ' = :client_id'); $query->bind(':type', $type, ParameterType::STRING); $query->bind(':element', $element, ParameterType::STRING); $query->bind(':client_id', $clientId, ParameterType::INTEGER); return $db->setQuery($query)->loadResult() ?: null; } }