<?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\Dispatcher\Mixin\TriggerEvent; use Akeeba\Engine\Base\Part; use Akeeba\Engine\Core\Timer; use Akeeba\Engine\Factory; use Akeeba\Engine\Platform; use Akeeba\Engine\Util\PushMessages; use Akeeba\WebPush\WebPush\WebPush; use DateTimeZone; use DirectoryIterator; use Exception; use Joomla\CMS\Application\CMSApplication; use Joomla\CMS\Date\Date; use Joomla\CMS\Factory as JoomlaFactory; use Joomla\CMS\Language\Text; use Joomla\CMS\MVC\Factory\MVCFactoryInterface; use Joomla\CMS\MVC\Model\BaseDatabaseModel; use Joomla\CMS\User\User; use Psr\Log\LogLevel; use RuntimeException; #[\AllowDynamicProperties] class BackupModel extends BaseDatabaseModel { use TriggerEvent; /** * Convert the old, plaintext log files (.log) into their .log.php counterparts. * * @param int $timeOut Maximum time, in seconds, to spend doing this conversion. * * @return void * * @since 7.0.3 */ public function convertLogFiles($timeOut = 10) { $registry = Factory::getConfiguration(); $logDir = $registry->get('akeeba.basic.output_directory', '[DEFAULT_OUTPUT]', true); $timer = new Timer($timeOut, 75); // Part I. Remove these obsolete files first $killFiles = [ 'akeeba.log', 'akeeba.backend.log', 'akeeba.frontend.log', 'akeeba.cli.log', 'akeeba.json.log', ]; foreach ($killFiles as $fileName) { $path = $logDir . '/' . $fileName; if (@is_file($path)) { @unlink($path); } } if ($timer->getTimeLeft() <= 0.01) { return; } // Part II. Convert .log files. try { $di = new DirectoryIterator($logDir); } catch (Exception $e) { return; } foreach ($di as $file) { try { if (!$file->isFile()) { continue; } $baseName = $file->getFilename(); if (substr($baseName, 0, 7) !== 'akeeba.') { continue; } if (substr($baseName, -4) !== '.log') { continue; } $this->convertLogFile($file->getPathname()); if ($timer->getTimeLeft() <= 0.01) { return; } } catch (Exception $e) { /** * Someone did something stupid, like using the site's root as the backup output directory while having * an open_basedir restriction. Sorry, mate, you get insecure junk. We had warned you. You didn't heed * the warning. That's your problem now. */ } } } /** * Get the default backup description. * * The default description is "Backup taken on DATE TIME" where DATE TIME is the current timestamp in the most * specific timezone. The timezone order, from least to most specific, is: * * UTC (fallback) * * Server Timezone from Joomla's Global Configuration * * Timezone from the current user's profile (only applicable to backend backups) * * Forced backup timezone * * @param string $format Date and time format. Default: DATE_FORMAT_LC2 plus the abbreviated timezone * * @return string */ public function getDefaultDescription(string $format = ''): string { // If no date format is specified we use DATE_FORMAT_LC2 plus the abbreviated timezone if (empty($format)) { $format = Text::_('DATE_FORMAT_LC2') . ' T'; } // Get the most specific Joomla timezone (UTC, overridden by server timezone, overridden by user timezone) $joomlaTimezone = JoomlaFactory::getApplication()->get('offset', 'UTC'); if (!JoomlaFactory::getApplication()->isClient('cli')) { $user = JoomlaFactory::getApplication()->getIdentity() ?? (new User()); if (!$user->guest) { $joomlaTimezone = $user->getParam('timezone', $joomlaTimezone); } } $timezone = $joomlaTimezone; // The forced timezone overrides everything else $forcedTZ = Platform::getInstance()->get_platform_configuration_option('forced_backup_timezone', 'AKEEBA/DEFAULT'); if (!empty($forcedTZ) && ($forcedTZ != 'AKEEBA/DEFAULT')) { $timezone = $forcedTZ; } // Convert the current date and time to the selected timezone $dateNow = new Date(); $tz = new DateTimeZone($timezone); $dateNow->setTimezone($tz); return Text::_('COM_AKEEBABACKUP_BACKUP_DEFAULT_DESCRIPTION') . ' ' . $dateNow->format($format, true); } /** * Get the profile used to take the last backup for the specified tag * * @param string $tag The backup tag a.k.a. backup origin (backend, frontend, json, ...) * @param string|null $backupId (optional) The Backup ID * * @return int The profile ID of the latest backup taken with the specified tag / backup ID */ public function getLastBackupProfile(string $tag, ?string $backupId = null): int { $filters = [ ['field' => 'tag', 'value' => $tag], ]; if (!empty($backupId)) { $filters[] = ['field' => 'backupid', 'value' => $backupId]; } $statList = Platform::getInstance()->get_statistics_list([ 'filters' => $filters, 'order' => [ 'by' => 'id', 'order' => 'DESC', ], ] ); if (is_array($statList)) { $stat = array_pop($statList); return (int) $stat['profile_id']; } // Backup entry not found. If backupId was specified, try without a backup ID if (!empty($backupId)) { return $this->getLastBackupProfile($tag); } // Else, return the default backup profile return 1; } /** * Send a push notification for a failed backup * * State variables expected (MUST be set): * errorMessage The error message * * @return void */ public function pushFail() { $this->initialiseWebPush(); $errorMessage = $this->getState('errorMessage'); $platform = Platform::getInstance(); $key = 'COM_AKEEBABACKUP_PUSH_ENDBACKUP_FAIL_BODY_WITH_MESSAGE'; if (empty($errorMessage)) { $key = 'COM_AKEEBABACKUP_PUSH_ENDBACKUP_FAIL_BODY'; } $pushSubject = sprintf( $platform->translate('COM_AKEEBABACKUP_PUSH_ENDBACKUP_FAIL_SUBJECT'), $platform->get_site_name(), $platform->get_host() ); $pushDetails = sprintf( $platform->translate($key), $platform->get_site_name(), $platform->get_host(), $errorMessage ); $push = new PushMessages(); $push->message($pushSubject, $pushDetails); } /** * Starts or step a backup process. Set the state variable "ajax" to the task you want to execute OR call the * relevant public method directly. * * @return array An Akeeba Engine return array * @throws Exception * * @noinspection PhpUnused */ public function runBackup(): array { $this->initialiseWebPush(); if (!defined('AKEEBADEBUG') && JoomlaFactory::getApplication()->get('debug', false)) { define('AKEEBADEBUG', 1); } $ret_array = []; $ajaxTask = $this->getState('ajax'); switch ($ajaxTask) { // Start a new backup case 'start': $ret_array = $this->startBackup(); break; // Step through a backup case 'step': $ret_array = $this->stepBackup(); break; // Send a push notification for backup failure case 'pushFail': $this->pushFail(); break; default: break; } return $ret_array; } /** * Starts a new backup. * * State variables expected * * backupid The ID of the backup. If none is set up we will create a new one in the form id123 * tag The backup tag, e.g. "frontend". If none is set up we'll get it through the Platform. * description The description of the backup (optional) * comment The comment of the backup (optional) * jpskey JPS password * angiekey ANGIE password * * @param array $overrides Configuration overrides * * @return array An Akeeba Engine return array * @throws Exception */ public function startBackup(array $overrides = []): array { $this->initialiseWebPush(); // Get information from the model state $tag = $this->getState('tag', null); $description = $this->getState('description', ''); $comment = $this->getState('comment', ''); $jpskey = $this->getState('jpskey', null); $angiekey = $this->getState('angiekey', null); $backupId = $this->getBackupId(); $profile = JoomlaFactory::getApplication()->getSession()->get('akeebabackup.profile', defined('AKEEBA_PROFILE') ? AKEEBA_PROFILE : 1); // Use the default description if none specified $description = $description ?: $this->getDefaultDescription(); // Try resetting the engine try { Factory::resetState([ 'maxrun' => 0, ]); } catch (Exception $e) { // This will die if the output directory is invalid. Let it die, then. } // Remove any stale memory files left over from the previous step if (empty($tag)) { $tag = Platform::getInstance()->get_backup_origin(); } $tempVarsTag = $tag; $tempVarsTag .= empty($backupId) ? '' : ('.' . $backupId); Factory::getFactoryStorage()->reset($tempVarsTag); Factory::nuke(); Factory::getLog()->log(LogLevel::DEBUG, " -- Resetting Akeeba Engine factory ($tag.$backupId)"); Platform::getInstance()->load_configuration(); // Autofix the output directory /** @var ConfigurationwizardModel $confWizModel */ $confWizModel = $this->getMVCFactory()->createModel('Configurationwizard', 'Administrator'); $confWizModel->autofixDirectories(); // Rebase Off-site Folder Inclusion filters to use site path variables /** @var IncludefoldersModel $incFoldersModel */ $incFoldersModel = $this->getMVCFactory()->createModel('Includefolders', 'Administrator'); if (is_object($incFoldersModel) && method_exists($incFoldersModel, 'rebaseFiltersToSiteDirs')) { $incFoldersModel->rebaseFiltersToSiteDirs(); } // Should I apply any configuration overrides? if (is_array($overrides) && !empty($overrides)) { $config = Factory::getConfiguration(); $protectedKeys = $config->getProtectedKeys(); $config->resetProtectedKeys(); foreach ($overrides as $k => $v) { $config->set($k, $v); } $config->setProtectedKeys($protectedKeys); } // Check if there are critical issues preventing the backup if (!Factory::getConfigurationChecks()->getShortStatus()) { $configChecks = Factory::getConfigurationChecks()->getDetailedStatus(); foreach ($configChecks as $checkItem) { if ($checkItem['severity'] != 'critical') { continue; } return [ 'HasRun' => 0, 'Domain' => 'init', 'Step' => '', 'Substep' => '', 'Error' => 'Failed configuration check Q' . $checkItem['code'] . ': ' . $checkItem['description'] . '. Please refer to https://www.akeeba.com/documentation/warnings/q' . $checkItem['code'] . '.html for more information and troubleshooting instructions.', 'Warnings' => [], 'Progress' => 0, ]; } } // Set up Kettenrad $options = [ 'description' => $description, 'comment' => $comment, 'jpskey' => $jpskey, 'angiekey' => $angiekey, ]; if (is_null($jpskey)) { unset ($options['jpskey']); } if (is_null($angiekey)) { unset ($options['angiekey']); } $kettenrad = Factory::getKettenrad(); $kettenrad->setBackupId($backupId); $kettenrad->setup($options); $this->setState('backupid', $backupId); /** * Convert log files in the backup output directory * * This removes the obsolete, default log files (akeeba.(backend|frontend|cli|json).log and converts the old .log * files into their .php counterparts. * * We are doing this when loading the the Control Panel page but ALSO when taking a new backup because some * people might be installing updates and taking backups automatically, without visiting the Control Panel * except in rare cases. */ $this->convertLogFiles(3); /** * We need to run tick() twice in the first backup step. * * The first tick() will reset the backup engine and start a new backup. However, no backup record is created * at this point. This means that Factory::loadState() cannot find a backup record, therefore it cannot read * the backup profile being used, therefore it will assume it's profile #1. * * The second tick() creates the backup record without doing much else, fixing this issue. * * However, if you have conservative settings where the min exec time is MORE than the max exec time the second * tick would never run. Therefore we need to tell the first tick to ignore the time settings (since it only * takes a few milliseconds to execute anyway) and then apply the time settings on the second tick (which also * only takes a few milliseconds). This is why we have setIgnoreMinimumExecutionTime before and after the first * tick. DO NOT REMOVE THESE. * * Furthermore, if the first tick reaches the end of backup or an error condition we MUST NOT run the second * tick() since the engine state will be invalid. Hence the check for the state that performs a hard break. This * could happen if you have a sufficiently high max execution time, no break between steps and we fail to * execute any step, e.g. the installer image is missing, a database error occurred or we can not list the files * and directories to back up. * * THEREFORE, DO NOT REMOVE THE LOOP OR THE if-BLOCK IN IT, THEY ARE THERE FOR A GOOD REASON! */ $kettenrad->setIgnoreMinimumExecutionTime(true); for ($i = 0; $i < 2; $i++) { $kettenrad->tick(); if (in_array($kettenrad->getState(), [Part::STATE_FINISHED, Part::STATE_ERROR])) { break; } $kettenrad->setIgnoreMinimumExecutionTime(false); } $ret_array = $kettenrad->getStatusArray(); // Notify the actionlog plugin $statistics = Factory::getStatistics(); $this->triggerEvent('onStart', [$statistics->getId(), $profile]); try { Factory::saveState($tag, $backupId); } catch (RuntimeException $e) { $ret_array['Error'] = $e->getMessage(); } return $ret_array; } /** * Steps through a backup. * * State variables expected (MUST be set): * backupid The ID of the backup. * tag The backup tag, e.g. "frontend". * profile (optional) The profile ID of the backup. * * @param bool $requireBackupId Should the backup ID be required? * * @return array An Akeeba Engine return array * @throws Exception */ public function stepBackup($requireBackupId = true) { $this->initialiseWebPush(); // Get information from the model state $tag = $this->getState('tag', defined('AKEEBA_BACKUP_ORIGIN') ? AKEEBA_BACKUP_ORIGIN : null); $backupId = $this->getState('backupid', null); // populateState() pushes the current profile number into the state. $profile = max(0, (int) $this->getState('profile', 0)) ?: $this->getLastBackupProfile($tag, $backupId); // Set the active profile JoomlaFactory::getApplication()->getSession()->set('akeebabackup.profile', $profile); if (!defined('AKEEBA_PROFILE')) { define('AKEEBA_PROFILE', $profile); } // Run a backup step $ret_array = [ 'HasRun' => 0, 'Domain' => 'init', 'Step' => '', 'Substep' => '', 'Error' => '', 'Warnings' => [], 'Progress' => 0, ]; try { // Reload the configuration Platform::getInstance()->load_configuration($profile); // Load the engine from storage Factory::loadState($tag, $backupId, $requireBackupId); // Set the backup ID and run a backup step $kettenrad = Factory::getKettenrad(); $kettenrad->tick(); $ret_array = $kettenrad->getStatusArray(); } catch (Exception $e) { $ret_array['Error'] = $e->getMessage(); } try { if (empty($ret_array['Error']) && ($ret_array['HasRun'] != 1)) { Factory::saveState($tag, $backupId); } } catch (RuntimeException $e) { $ret_array['Error'] = $e->getMessage(); } if (!empty($ret_array['Error']) || ($ret_array['HasRun'] == 1)) { /** * Do not nuke the Factory if we're trying to resume after an error. * * When the resume after error (retry) feature is enabled AND we are performing a backend backup we MUST * leave the factory storage intact so we can actually resume the backup. If we were to nuke the Factory * the resume would report that it cannot load the saved factory and lead to a failed backup. */ $config = Factory::getConfiguration(); if (JoomlaFactory::getApplication()->isClient('administrator') && $config->get('akeeba.advanced.autoresume', 1)) { // We are about to resume; abort. return $ret_array; } // Clean up Factory::nuke(); $tempVarsTag = $tag; $tempVarsTag .= empty($backupId) ? '' : ('.' . $backupId); Factory::getFactoryStorage()->reset($tempVarsTag); } return $ret_array; } /** * Converts a log file from .log to .log.php * * @param string $filePath * * @return void * * @since 7.0.3 */ protected function convertLogFile(string $filePath): void { // The name of the converted log file is the same with the extension .php appended to it. $newFile = $filePath . '.php'; // If the new log file exists I should return immediately if (@file_exists($newFile)) { return; } // Try to open the converted log file (.log.php) $fp = @fopen($newFile, 'w'); if ($fp === false) { return; } // Try to open the source log file (.log) $sourceFP = @fopen($filePath, 'r'); if ($sourceFP === false) { @fclose($fp); return; } // Write the die statement to the source log file fwrite($fp, '<' . '?' . 'php die(); ' . '?' . ">\n"); // Copy data, 512KB at a time while (!feof($sourceFP)) { $chunk = @fread($sourceFP, 524288); if ($chunk === false) { break; } $result = fwrite($fp, $chunk); if ($result === false) { break; } } // Close both files @fclose($sourceFP); @fclose($fp); // Delete the original (.log) file @unlink($filePath); } /** * Method to auto-populate the state. * * This method should only be called once per instantiation and is designed * to be called on the first call to the getState() method unless the * configuration flag to ignore the request is set. * * @return void * * @note Calling getState in this method will result in recursion. * @throws Exception * @since 9.0.0 */ protected function populateState() { /** @var CMSApplication $app */ $app = JoomlaFactory::getApplication(); $input = $app->input; $profile = (int) $app->getSession()->get('akeebabackup.profile', 1); $profile = defined('AKEEBA_PROFILE') ? AKEEBA_PROFILE : $profile; $profile = max($profile, 1); $stateVariables = [ 'tag' => $input->get('tag', null, 'string'), 'backupId' => $input->get('backupid', null, 'string'), 'description' => $input->get('description', '', 'string'), 'comment' => $input->get('comment', '', 'html'), 'jpskey' => $input->get('jpskey', null, 'raw'), 'angiekey' => $input->get('angiekey', null, 'raw'), 'profile' => $input->get('profile', $profile, 'int'), 'ajax' => $input->get('ajax', '', 'cmd'), 'errorMessage' => $input->get('errorMessage', '', 'raw'), ]; foreach ($stateVariables as $k => $v) { $this->setState($k, $v); } } /** * Get a new backup ID string. * * In the past we were trying to get the next backup record ID using two methods: * - Querying the information_schema.tables metadata table. In many cases we saw this returning the wrong value, * even though the MySQL documentation said this should return the next autonumber (WTF?) * - Doing a MAX(id) on the table and adding 1. This didn't work correctly if the latest records were deleted by the * user. * * However, the backup ID does not need to be the same as the backup record ID. It only needs to be *unique*. So * this time around we are using a simple, unique ID based on the current GMT date and time. * * @return string */ private function getBackupId(): string { $microtime = explode(' ', microtime(false)); $microseconds = (int) ($microtime[0] * 1000000); return 'id-' . gmdate('Ymd-His') . '-' . $microseconds; } /** * Make sure we can load the Web Push helper, if needed and not already loaded * * @return void * @since 9.3.1 */ private function initialiseWebPush() { $pushPreference = Platform::getInstance()->get_platform_configuration_option('push_preference', '0'); if ($pushPreference !== 'webpush') { return; } if (!class_exists(WebPush::class)) { \JLoader::registerNamespace('Akeeba\\WebPush', JPATH_ADMINISTRATOR . '/components/com_akeebabackup/webpush'); } } }