<?php /** * Akeeba Engine * * @package akeebaengine * @copyright Copyright (c)2006-2022 Nicholas K. Dionysopoulos / Akeeba Ltd * @license GNU General Public License version 3, or later */ namespace Akeeba\Engine; defined('AKEEBAENGINE') || die(); use Akeeba\Engine\Base\Part; use Akeeba\Engine\Core\Database; use Akeeba\Engine\Core\Filters; use Akeeba\Engine\Core\Kettenrad; use Akeeba\Engine\Core\Timer; use Akeeba\Engine\Driver\Base; use Akeeba\Engine\Postproc\PostProcInterface; use Akeeba\Engine\Util\ConfigurationCheck; use Akeeba\Engine\Util\CRC32; use Akeeba\Engine\Util\Encrypt; use Akeeba\Engine\Util\EngineParameters; use Akeeba\Engine\Util\FactoryStorage; use Akeeba\Engine\Util\FileLister; use Akeeba\Engine\Util\FileSystem; use Akeeba\Engine\Util\Logger; use Akeeba\Engine\Util\PushMessages; use Akeeba\Engine\Util\RandomValue; use Akeeba\Engine\Util\SecureSettings; use Akeeba\Engine\Util\Statistics; use Akeeba\Engine\Util\TemporaryFiles; use Exception; use RuntimeException; // Try to kill errors display if (function_exists('ini_set') && !defined('AKEEBADEBUG')) { ini_set('display_errors', false); } // Make sure the class autoloader is loaded require_once __DIR__ . '/Autoloader.php'; /** * The Akeeba Engine Factory class * * This class is responsible for instantiating all Akeeba Engine classes */ abstract class Factory { /** * The absolute path to Akeeba Engine's installation * * @var string */ private static $root; /** * Partial class names of the loaded engines e.g. 'archiver' => 'Archiver\\Jpa'. Survives serialization. * * @var array */ private static $engineClassnames = []; /** * A list of instantiated objects which will persist after serialisation / unserialisation * * @var array */ private static $objectList = []; /** * A list of instantiated objects which will NOT persist after serialisation / unserialisation * * @var array */ private static $temporaryObjectList = []; /** * The class to use for push messages * * @since 9.3.1 * @var string */ private static $pushClassName = 'Util\\PushMessages'; /** * Gets a serialized snapshot of the Factory for safekeeping (hibernate) * * @return string The serialized snapshot of the Factory */ public static function serialize() { // Call _onSerialize in all objects known to the factory foreach (static::$objectList as $class_name => $object) { if (method_exists($object, '_onSerialize')) { call_user_func([$object, '_onSerialize']); } } // Serialise an array with all the engine information $engineInfo = [ 'root' => static::$root, 'objectList' => static::$objectList, 'engineClassnames' => static::$engineClassnames, 'pushClassname' => static::$pushClassName, ]; // Serialize the factory return serialize($engineInfo); } /** * Regenerates the full Factory state from a serialized snapshot (resume) * * @param string $serialized_data The serialized snapshot to resume from * * @return void */ public static function unserialize($serialized_data) { static::nuke(); $engineInfo = unserialize($serialized_data); static::$root = $engineInfo['root'] ?? ''; static::$objectList = $engineInfo['objectList'] ?? []; static::$engineClassnames = $engineInfo['engineClassnames'] ?? []; static::$pushClassName = $engineInfo['pushClassname'] ?? 'Utils\\PushMessages'; static::$temporaryObjectList = []; } /** * Reset the internal factory state, freeing all previously created objects * * @return void */ public static function nuke() { foreach (static::$objectList as $key => $object) { $object = null; } foreach (static::$temporaryObjectList as $key => $object) { $object = null; } static::$objectList = []; static::$temporaryObjectList = []; } /** * Saves the engine state to temporary storage * * @param string $tag The backup origin to save. Leave empty to get from already loaded Kettenrad instance. * @param string $backupId The backup ID to save. Leave empty to get from already loaded Kettenrad instance. * * @return void * * @throws RuntimeException When the state save fails for any reason */ public static function saveState($tag = null, $backupId = null) { $kettenrad = static::getKettenrad(); $tag = $tag ?: $kettenrad->getTag(); $backupId = $backupId ?: $kettenrad->getBackupId(); $saveTag = rtrim($tag . '.' . ($backupId ?: ''), '.'); $ret = $kettenrad->getStatusArray(); if ($ret['HasRun'] == 1) { Factory::getLog()->debug("Will not save a finished Kettenrad instance"); return; } Factory::getLog()->debug("Saving Kettenrad instance $tag"); // Save a Factory snapshot $factoryStorage = static::getFactoryStorage(); $logger = static::getLog(); $logger->resetWarnings(); $serializedFactoryData = static::serialize(); $memoryFileExtension = 'php'; $result = $factoryStorage->set($serializedFactoryData, $saveTag, $memoryFileExtension); /** * Some hosts, such as WPEngine, do not allow us to save the memory files in .php files. In this case we use the * far more insecure .dat extension. */ if ($result === false) { $memoryFileExtension = 'dat'; $result = $factoryStorage->set($serializedFactoryData, $saveTag, $memoryFileExtension); } if ($result === false) { $saveKey = $factoryStorage->get_storage_filename($saveTag, $memoryFileExtension); $errorMessage = "Cannot save factory state in storage, storage filename $saveKey"; $logger->error($errorMessage); throw new RuntimeException($errorMessage); } } /** * Loads the engine state from the storage (if it exists). * * When failIfMissing is true (default) an exception will be thrown if the memory file / database record is no * longer there. This is a clear indication of an issue with the storage engine, e.g. the host deleting the memory * files in the middle of the backup step. Therefore we'll switch the storage engine type before throwing the * exception. * * When failIfMissing is false we do NOT throw an exception. Instead, we do a hard reset of the backup factory. This * is required by the resetState method when we ask it to reset multiple origins at once. * * @param string $tag The backup origin to load * @param string $backupId The backup ID to load * @param bool $failIfMissing Throw an exception if the memory data is no longer there * * @return void */ public static function loadState($tag = null, $backupId = null, $failIfMissing = true) { $tag = $tag ?: (defined('AKEEBA_BACKUP_ORIGIN') ? AKEEBA_BACKUP_ORIGIN : 'backend'); $loadTag = rtrim($tag . '.' . ($backupId ?: ''), '.'); // In order to load anything, we need to have the correct profile loaded. Let's assume // that the latest backup record in this tag has the correct profile number set. $config = static::getConfiguration(); if (empty($config->activeProfile)) { $profile = Platform::getInstance()->get_active_profile(); if (empty($profile) || ($profile <= 1)) { // Only bother loading a configuration if none has been already loaded $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) ?? []; $profile = $stat['profile_id'] ?? 1; } } Platform::getInstance()->load_configuration($profile); } $profile = $config->activeProfile; Factory::getLog()->open($loadTag); Factory::getLog()->debug("Kettenrad :: Attempting to load from database ($tag) [$loadTag]"); $serialized_factory = static::getFactoryStorage()->get($loadTag); if ($serialized_factory === false) { if ($failIfMissing) { throw new RuntimeException("Akeeba Engine detected a problem while saving temporary data. Please restart your backup.", 500); } // There is no serialized factory. Nuke the in-memory factory. Factory::getLog()->debug(" -- Stored Akeeba Factory ($tag) [$loadTag] not found - hard reset"); static::nuke(); Platform::getInstance()->load_configuration($profile); } Factory::getLog()->debug(" -- Loaded stored Akeeba Factory ($tag) [$loadTag]"); static::unserialize($serialized_factory); unset($serialized_factory); } // ======================================================================== // Public factory interface // ======================================================================== /** * Resets the engine state, wiping out any pending backups and/or stale temporary data. * * The configuration parameters are: * * * global `bool` True to reset all backups, regardless of the origin or profile ID * * log `bool` True to log our actions (default: false) * * maxrun `int` Only backup records older than this number of seconds will be reset (default: 180) * * Special considerations: * * * If global = true all backups from all origins are taken into account to determine which ones are stuck (over * the maxrun threshold since their last database entry). * * * If global = false only backups from the current backup origin are taken into account. * * * If global = false AND the current origin is 'backend' all pending and idle backups with the 'backup' origin are * considered stuck regardless of their age. In other words, maxrun is effectively set to 0. The idea is that only * a single person, from a single browser, should be taking backend backups at a time. Resetting single origin * backups is only ever meant to be called by the consumer when starting a backup. * * * Corollary to the above: starting a frontend, CLI or JSON API backup with the same backup profile DOES NOT reset * a previously failed backup if the new backup starts less than 'maxrun' seconds since the last step of the * failed backup started. * * * The time information for the backup age is taken from the database, namely the backupend field. If no time * is recorded for the last step we use the backupstart field instead. * * @param array $config Configuration parameters for the reset operation * * @return void * @throws Exception */ public static function resetState($config = []) { $default_config = [ 'global' => true, 'log' => false, 'maxrun' => 180, ]; $config = (object) array_merge($default_config, $config); // Pause logging if so desired if (!$config->log) { Factory::getLog()->pause(); } // Get the origin to clear, depending on the 'global' setting $originTag = $config->global ? null : Platform::getInstance()->get_backup_origin(); // Cache the factory before proceeding $factory = static::serialize(); // Get all running backups for the selected origin (or all origins, if global was false). $runningList = Platform::getInstance()->get_running_backups($originTag); // Sanity check if (!is_array($runningList)) { $runningList = []; } // If the current origin is 'backend' we assume maxrun = 0 per the method docblock notes. $maxRun = ($originTag == 'backend') ? 0 : $config->maxrun; // Filter out entries by backup age $now = time(); $cutOff = $now - $maxRun; $runningList = array_filter($runningList, function (array $running) use ($cutOff, $maxRun) { // No cutoff time: include all currently running backup records if ($maxRun == 0) { return true; } // Try to get the last backup tick timestamp try { $backupTickTime = !empty($running['backupend']) ? $running['backupend'] : $running['backupstart']; $tz = new \DateTimeZone('UTC'); $tstamp = (new \DateTime($backupTickTime, $tz))->getTimestamp(); } catch (Exception $e) { $tstamp = Factory::getLog()->getLastTimestamp($running['origin']); } if (is_null($tstamp)) { return false; } // Only include still running backups whose last tick was BEFORE the cutoff time return $tstamp <= $cutOff; }); // Mark running backups as failed foreach ($runningList as $running) { // Delete the failed backup's leftover archive parts $filenames = Factory::getStatistics()->get_all_filenames($running, false); $filenames = is_null($filenames) ? [] : $filenames; $totalSize = 0; foreach ($filenames as $failedArchive) { if (!@file_exists($failedArchive)) { continue; } $totalSize += (int) @filesize($failedArchive); Platform::getInstance()->unlink($failedArchive); } // Mark the backup failed $running['status'] = 'fail'; $running['instep'] = 0; $running['total_size'] = empty($running['total_size']) ? $totalSize : $running['total_size']; $running['multipart'] = 0; Platform::getInstance()->set_or_update_statistics($running['id'], $running); // Remove the temporary data $backupId = isset($running['backupid']) ? ('.' . $running['backupid']) : ''; self::removeTemporaryData($running['origin'] . $backupId); } // Reload the factory static::unserialize($factory); unset($factory); // Unpause logging if it was previously paused if (!$config->log) { Factory::getLog()->unpause(); } } /** * Returns an Akeeba Configuration object * * @return Configuration The Akeeba Configuration object */ public static function getConfiguration() { return static::getObjectInstance('Configuration'); } /** * Returns a statistics object, used to track current backup's progress * * @return Statistics */ public static function getStatistics() { return static::getObjectInstance('Util\\Statistics'); } /** * Returns the currently configured archiver engine * * @param bool $reset Should I try to forcible create a new instance? * * @return Archiver\Base */ public static function getArchiverEngine($reset = false) { return static::getEngineInstance( 'archiver', 'akeeba.advanced.archiver_engine', 'Archiver\\', 'Archiver\\Jpa', $reset ); } /** * Returns the currently configured dump engine * * @param boolean $reset Should I try to forcible create a new instance? * * @return Dump\Base */ public static function getDumpEngine($reset = false) { return static::getEngineInstance( 'dump', 'akeeba.advanced.dump_engine', 'Dump\\', 'Dump\\Native', $reset ); } /** * Returns the filesystem scanner engine instance * * @param bool $reset Should I try to forcible create a new instance? * * @return Scan\Base The scanner engine */ public static function getScanEngine($reset = false) { return static::getEngineInstance( 'scan', 'akeeba.advanced.scan_engine', 'Scan\\', 'Scan\\Large', $reset ); } /** * Returns the current post-processing engine. If no class is specified we * return the post-processing engine configured in akeeba.advanced.postproc_engine * * @param string $engine The name of the post-processing class to forcibly return * * @return PostProcInterface */ public static function getPostprocEngine($engine = null) { if (!is_null($engine)) { static::$engineClassnames['postproc'] = 'Postproc\\' . ucfirst($engine); return static::getObjectInstance(static::$engineClassnames['postproc']); } return static::getEngineInstance( 'postproc', 'akeeba.advanced.postproc_engine', 'Postproc\\', 'Postproc\\None', true ); } // ======================================================================== // Core objects which are part of the engine state // ======================================================================== /** * Returns an instance of the Filters feature class * * @return Filters The Filters feature class' object instance */ public static function getFilters() { return static::getObjectInstance('Core\\Filters'); } /** * Returns an instance of the specified filter group class. Do note that it does not * work with platform filter classes. They are handled internally by AECoreFilters. * * @param string $filter_name The filter class to load, without AEFilter prefix * * @return Filter\Base The filter class' object instance */ public static function getFilterObject($filter_name) { return static::getObjectInstance('Filter\\' . ucfirst($filter_name)); } /** * Loads an engine domain class and returns its associated object * * @param string $domain_name The name of the domain, e.g. installer for AECoreDomainInstaller * * @return Part */ public static function getDomainObject($domain_name) { return static::getObjectInstance('Core\\Domain\\' . ucfirst($domain_name)); } /** * Returns a database connection object. It's an alias of AECoreDatabase::getDatabase() * * @param array $options Options to use when instantiating the database connection * * @return Base */ public static function getDatabase($options = null) { if (is_null($options)) { $options = Platform::getInstance()->get_platform_database_options(); } if (isset($options['username']) && !isset($options['user'])) { $options['user'] = $options['username']; } return Database::getDatabase($options); } /** * Returns a database connection object. It's an alias of AECoreDatabase::getDatabase() * * @param array $options Options to use when instantiating the database connection * * @return void */ public static function unsetDatabase($options = null) { if (is_null($options)) { $options = Platform::getInstance()->get_platform_database_options(); } $db = Database::getDatabase($options); $db->close(); Database::unsetDatabase($options); } /** * Get the a reference to the Akeeba Engine's timer * * @return Timer */ public static function getTimer() { return static::getObjectInstance('Core\\Timer'); } /** * Get a reference to Akeeba Engine's main controller called Kettenrad * * @return Kettenrad */ public static function getKettenrad() { return static::getObjectInstance('Core\\Kettenrad'); } /** * Returns an instance of the factory storage class (formerly Tempvars) * * @return FactoryStorage */ public static function getFactoryStorage() { return static::getTempObjectInstance('Util\\FactoryStorage'); } /** * Returns an instance of the encryption class * * @return Encrypt */ public static function getEncryption() { return static::getTempObjectInstance('Util\\Encrypt'); } /** * Returns an instance of the crypto-safe random value generator class * * @return RandomValue */ public static function getRandval() { return static::getTempObjectInstance('Util\\RandomValue'); } /** * Returns an instance of the filesystem tools class * * @return FileSystem */ public static function getFilesystemTools() { return static::getTempObjectInstance('Util\\FileSystem'); } /** * Returns an instance of the filesystem tools class * * @return FileLister */ public static function getFileLister() { return static::getTempObjectInstance('Util\\FileLister'); } // ======================================================================== // Temporary objects which are not part of the engine state // ======================================================================== /** * Returns an instance of the engine parameters provider which provides information on scripting, GUI configuration * elements and engine parts * * @return EngineParameters */ public static function getEngineParamsProvider() { return static::getTempObjectInstance('Util\\EngineParameters'); } /** * Returns an instance of the log object * * @return Logger */ public static function getLog() { return static::getTempObjectInstance('Util\\Logger'); } /** * Returns an instance of the configuration checks object * * @return ConfigurationCheck */ public static function getConfigurationChecks() { return static::getTempObjectInstance('Util\\ConfigurationCheck'); } /** * Returns an instance of the secure settings handling object * * @return SecureSettings */ public static function getSecureSettings() { return static::getTempObjectInstance('Util\\SecureSettings'); } /** * Returns an instance of the secure settings handling object * * @return TemporaryFiles */ public static function getTempFiles() { return static::getTempObjectInstance('Util\\TemporaryFiles'); } /** * Get the connector object for push messages * * @return PushMessages */ public static function getPush() { return static::getObjectInstance(self::$pushClassName); } /** * Set the push notifications helper class to use with this factory * * @param string $className The classname to use * * @since 9.3.1 */ public static function setPushClass(string $className) { self::$pushClassName = $className; } /** * Returns the absolute path to Akeeba Engine's installation * * @return string */ public static function getAkeebaRoot() { if (empty(static::$root)) { static::$root = __DIR__; } return static::$root; } /** * @param string $engineType Engine type, e.g. 'archiver', 'postproc', ... * @param string $configKey Profile config key with configured engine e.g. 'akeeba.advanced.archiver_engine' * @param string $prefix Prefix for engine classes, e.g. 'Archiver\\' * @param string $fallback Fallback class if the configured one doesn't exist e.g. 'Archiver\\Jpa'. Empty for * no fallback. * @param bool $reset Should I force-reload the engine? Default: false. * * @return mixed The Singleton engine object instance */ protected static function getEngineInstance($engineType, $configKey, $prefix, $fallback, $reset = false) { if (!$reset && !empty(static::$engineClassnames[$engineType])) { return static::getObjectInstance(static::$engineClassnames[$engineType]); } // Unset the existing engine object if (!empty(static::$engineClassnames[$engineType])) { static::unsetObjectInstance(static::$engineClassnames[$engineType]); } // Get the engine name from the backup profile, construct a class name and check if it exists $registry = static::getConfiguration(); $engine = $registry->get($configKey); static::$engineClassnames[$engineType] = $prefix . ucfirst($engine); $object = static::getObjectInstance(static::$engineClassnames[$engineType]); // If the engine object does not exist, fall back to the default if (!empty($fallback) && $object === false) { static::unsetObjectInstance(static::$engineClassnames[$engineType]); static::$engineClassnames[$engineType] = $fallback; } return static::getObjectInstance(static::$engineClassnames[$engineType]); } /** * Internal function which instantiates an object of a class named $class_name. * * @param string $class_name * * @return mixed */ protected static function getObjectInstance($class_name) { $class_name = trim($class_name, '\\'); if (isset(static::$objectList[$class_name])) { return static::$objectList[$class_name]; } static::$objectList[$class_name] = false; $searchClass = '\\Akeeba\\Engine\\' . $class_name; if (class_exists($searchClass)) { static::$objectList[$class_name] = new $searchClass; } elseif (class_exists($class_name)) { static::$objectList[$class_name] = new $class_name; } return static::$objectList[$class_name]; } // ======================================================================== // Handy functions // ======================================================================== /** * Internal function which removes the object of the class named $class_name * * @param string $class_name * * @return void */ protected static function unsetObjectInstance($class_name) { if (isset(static::$objectList[$class_name])) { static::$objectList[$class_name] = null; unset(static::$objectList[$class_name]); } } /** * Internal function which instantiates an object of a class named $class_name. This is a temporary instance which * will not survive serialisation and subsequent unserialisation. * * @param string $class_name * * @return mixed */ protected static function getTempObjectInstance($class_name) { $class_name = trim($class_name, '\\'); if (!isset(static::$temporaryObjectList[$class_name])) { static::$temporaryObjectList[$class_name] = false; $searchClassname = '\\Akeeba\\Engine\\' . $class_name; if (class_exists($searchClassname)) { static::$temporaryObjectList[$class_name] = new $searchClassname; } } return static::$temporaryObjectList[$class_name]; } /** * Remote the temporary data for a specific backup tag. * * @param string $originTag The backup tag to reset e.g. 'backend.id123' or 'frontend'. * * @return void */ protected static function removeTemporaryData($originTag) { static::loadState($originTag, null, false); // Remove temporary files Factory::getTempFiles()->deleteTempFiles(); // Delete any stale temporary data static::getFactoryStorage()->reset($originTag); } } /** * Timeout handler. It is registered as a global PHP shutdown function. * * If a PHP reports a timeout we will log this before letting PHP kill us. */ function AkeebaTimeoutTrap() { if (connection_status() >= 2) { Factory::getLog()->error('Akeeba Engine has timed out'); } } register_shutdown_function("\\Akeeba\\Engine\\AkeebaTimeoutTrap");