File Manager

Current Directory: /home/astoriaah/www/administrator/components/com_akeeba/BackupEngine/Core/Domain
Viewing File: /home/astoriaah/www/administrator/components/com_akeeba/BackupEngine/Core/Domain/Finalization.php
<?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\Core\Domain; defined('AKEEBAENGINE') || die(); use Akeeba\Engine\Base\Part; use Akeeba\Engine\Factory; use Akeeba\Engine\Platform; use Akeeba\Engine\Postproc\Base; use DateTime; use Exception; use Psr\Log\LogLevel; /** * Backup finalization domain */ class Finalization extends Part { /** @var array The finalisation actions we have to execute (FIFO queue) */ private $action_queue = []; /** @var array Additional objects which will also handle finalisation tasks */ private $action_handlers = []; /** @var string The current method, shifted from the action queye */ private $current_method = ''; /** @var array A list of all backup parts to process */ private $backup_parts = []; /** @var int The backup part we are currently processing */ private $backup_parts_index = -1; /** @var array Which remote files to remove */ private $remote_files_killlist = null; /** @var int How many finalisation steps I have in total */ private $steps_total = 0; /** @var int How many finalisation steps I have already done */ private $steps_done = 0; /** @var int How many finalisation substeps I have in total */ private $substeps_total = 0; /** @var int How many finalisation substeps I have already done */ private $substeps_done = 0; /** * Get the percentage of finalization steps done * * @return float */ public function getProgress() { if ($this->steps_total <= 0) { return 0; } $overall = $this->steps_done / $this->steps_total; $local = 0; if ($this->substeps_total > 0) { $local = $this->substeps_done / $this->substeps_total; } return $overall + ($local / $this->steps_total); } /** * Used by additional handler classes to relay their step to us * * @param string $step The current step */ public function relayStep($step) { $this->setStep($step); } /** * Used by additional handler classes to relay their substep to us * * @param string $substep The current sub-step */ public function relaySubstep($substep) { $this->setSubstep($substep); } /** * Implements the abstract method */ protected function _prepare() { // Make sure the break flag is not set $configuration = Factory::getConfiguration(); $configuration->get('volatile.breakflag', false); // Populate actions queue $this->action_queue = [ 'remove_temp_files', 'update_statistics', 'update_filesizes', 'run_post_processing', 'kickstart_post_processing', 'apply_quotas', 'apply_remote_quotas', 'mail_administrators', 'update_statistics', // Run it a second time to update the backup end time after post processing, emails, etc ;) ]; // Remove the Kickstart post-processing if we do not have the option set $uploadKickstart = $configuration->get('akeeba.advanced.uploadkickstart', 0); if (!$uploadKickstart) { unset ($this->action_queue['kickstart_post_processing']); } // Allow adding finalization action objects using the volatile.core.finalization.action_handlers array $customHandlers = $configuration->get('volatile.core.finalization.action_handlers', null); if (is_array($customHandlers) && !empty($customHandlers)) { foreach ($customHandlers as $handler) { $this->action_handlers[] = $handler; } } // Do we have a custom action queue set in volatile.core.finalization.action_queue? $customQueue = $configuration->get('volatile.core.finalization.action_queue', null); $customQueueBefore = $configuration->get('volatile.core.finalization.action_queue_before', null); if (is_array($customQueue) && !empty($customQueue)) { Factory::getLog()->debug('Overriding finalization action queue'); $this->action_queue = []; foreach ($customQueue as $action) { if (method_exists($this, $action)) { $this->action_queue[] = $action; } else { foreach ($this->action_handlers as $handler) { if (method_exists($handler, $action)) { $this->action_queue[] = $action; break; } } } } } if (is_array($customQueueBefore) && !empty($customQueueBefore)) { // Get all actions before run_post_processing $temp = []; $temp[] = array_shift($this->action_queue); $temp[] = array_shift($this->action_queue); $temp[] = array_shift($this->action_queue); // Add the custom handlers from volatile.core.finalization.action_handlers_before while (!empty($customQueueBefore)) { $action = array_pop($customQueueBefore); if (method_exists($this, $action)) { array_unshift($this->action_queue, $action); } else { foreach ($this->action_handlers as $handler) { if (method_exists($handler, $action)) { array_unshift($this->action_queue, $action); break; } } } } // Add back the handlers we shifted before foreach ($temp as $action) { array_unshift($this->action_queue, $action); } } Factory::getLog()->debug('Finalization action queue: ' . implode(', ', $this->action_queue)); $this->steps_total = count($this->action_queue); $this->steps_done = 0; $this->substeps_total = 0; $this->substeps_done = 0; // Seed the method $this->current_method = array_shift($this->action_queue); // Set ourselves to running state $this->setState(self::STATE_RUNNING); } /** * Implements the abstract method * * @return void */ protected function _run() { $configuration = Factory::getConfiguration(); if ($this->getState() == self::STATE_POSTRUN) { return; } $finished = (empty($this->action_queue)) && ($this->current_method == ''); if ($finished) { $this->setState(self::STATE_POSTRUN); return; } $this->setState(self::STATE_RUNNING); $timer = Factory::getTimer(); // Continue processing while we have still enough time and stuff to do while (($timer->getTimeLeft() > 0) && (!$finished) && (!$configuration->get('volatile.breakflag', false))) { $method = $this->current_method; if (method_exists($this, $method)) { Factory::getLog()->debug(__CLASS__ . "::_run() Running built-in method $method"); $status = $this->$method(); } else { $status = true; if (!empty($this->action_handlers)) { foreach ($this->action_handlers as $handler) { if (method_exists($handler, $method)) { Factory::getLog()->debug(__CLASS__ . "::_run() Running add-on method $method"); $status = $handler->$method($this); break; } } } } if ($status === true) { $this->current_method = ''; $this->steps_done++; $finished = (empty($this->action_queue)); if (!$finished) { $this->current_method = array_shift($this->action_queue); $this->substeps_total = 0; $this->substeps_done = 0; } } } if ($finished) { $this->setState(self::STATE_POSTRUN); $this->setStep(''); $this->setSubstep(''); } } /** * Implements the abstract method * * @return void */ protected function _finalize() { $this->setState(self::STATE_FINISHED); } /** * Sends an email to the administrators * * @return bool True on success */ protected function mail_administrators() { $this->setStep('Processing emails to administrators'); $this->setSubstep(''); // Skip email for back-end backups if (Platform::getInstance()->get_backup_origin() == 'backend') { return true; } // Is the feature enabled? if (Platform::getInstance()->get_platform_configuration_option('frontend_email_on_finish', 0) == 0) { return true; } Factory::getLog()->debug("Preparing to send e-mail to administrators"); $email = trim(Platform::getInstance()->get_platform_configuration_option('frontend_email_address', '')); if (!empty($email)) { Factory::getLog()->debug("Using pre-defined list of emails"); $emails = explode(',', $email); } else { Factory::getLog()->debug("Fetching list of Super Administrator emails"); $emails = Platform::getInstance()->get_administrator_emails(); } if (empty($emails)) { Factory::getLog()->debug("No email recipients found! Skipping email."); return true; } Factory::getLog()->debug("Creating email subject and body"); // Get the statistics $statistics = Factory::getStatistics(); $profileNumber = Platform::getInstance()->get_active_profile(); $profileName = Platform::getInstance()->get_profile_name($profileNumber); $statsRecord = $statistics->getRecord(); $partsCount = max(1, $statsRecord['multipart']); $allFilenames = $this->getPartFilenames($statsRecord); $filesList = implode("\n", array_map(function ($file) { return "\t" . $file; }, $allFilenames)); $totalSize = (int) ($statsRecord['total_size'] ?? 0); // Get the approximate part sizes and create a list of files and sizes $configuration = Factory::getConfiguration(); $partSize = $configuration->get('engine.archiver.common.part_size', 0); $lastPartSize = $totalSize - (($partsCount - 1) * $partSize); $partSizes = array_fill(0, $partsCount - 1, $partSize); $partSizes[] = $lastPartSize; $filesAndSizesList = implode("\n", array_map(function ($file, $size) { return sprintf("\t%s (approx. %s)", $file, $this->formatByteSize($size)); }, $allFilenames, $partSizes)); // Determine the upload to remote storage status $remoteStatus = ''; $failedUpdate = false; $postProcEngine = Factory::getConfiguration()->get('akeeba.advanced.postproc_engine'); if (!empty($postProcEngine) && ($postProcEngine != 'none')) { $remoteStatus = Platform::getInstance()->translate('COM_AKEEBA_EMAIL_POSTPROCESSING_SUCCESS'); if (empty($statsRecord['remote_filename'])) { $failedUpdate = true; $remoteStatus = Platform::getInstance()->translate('COM_AKEEBA_EMAIL_POSTPROCESSING_FAILED'); } } // Did the user ask to be emailed only on failed uploads but the upload has succeeded? if (!$failedUpdate && (Platform::getInstance()->get_platform_configuration_option('frontend_email_when', 'always') == 'failedupload')) { return true; } // Fetch user's preferences $subject = trim(Platform::getInstance()->get_platform_configuration_option('frontend_email_subject', '')); $body = trim(Platform::getInstance()->get_platform_configuration_option('frontend_email_body', '')); // Get a default subject or post-process a manually defined subject $subject = empty($subject) ? Platform::getInstance()->translate('COM_AKEEBA_COMMON_EMAIL_SUBJECT_OK') : Factory::getFilesystemTools()->replace_archive_name_variables($subject); // Do we need a default body? if (empty($body)) { $body = Platform::getInstance()->translate('COM_AKEEBA_COMMON_EMAIL_BODY_OK'); $body .= "\n\n"; $body .= sprintf(Platform::getInstance()->translate('COM_AKEEBA_COMMON_EMAIL_BODY_INFO'), $profileNumber, $partsCount); $body .= "\n\n"; $body .= $filesAndSizesList; } else { // Post-process the body $body = Factory::getFilesystemTools()->replace_archive_name_variables($body); $body = str_replace('[PROFILENUMBER]', $profileNumber, $body); $body = str_replace('[PROFILENAME]', $profileName, $body); $body = str_replace('[PARTCOUNT]', $partsCount, $body); $body = str_replace('[FILELIST]', $filesList, $body); $body = str_replace('[FILESIZESLIST]', $filesAndSizesList, $body); $body = str_replace('[REMOTESTATUS]', $remoteStatus, $body); $body = str_replace('[TOTALSIZE]', $this->formatByteSize($totalSize), $body); } // Post-process the subject (support the [REMOTESTATUS] variable) $subject = str_replace('[REMOTESTATUS]', $remoteStatus, $subject); // Sometimes $body contains literal \n instead of newlines $body = str_replace('\\n', "\n", $body); foreach ($emails as $email) { Factory::getLog()->debug("Sending email to $email"); Platform::getInstance()->send_email($email, $subject, $body); } return true; } /** * Removes temporary files * * @return bool True on success */ protected function remove_temp_files() { $this->setStep('Removing temporary files'); $this->setSubstep(''); Factory::getLog()->debug("Removing temporary files"); Factory::getTempFiles()->deleteTempFiles(); return true; } /** * Runs the writer's post-processing steps * * @return bool True on success */ protected function run_post_processing() { $this->setStep('Post-processing'); // Do not run if the archive engine doesn't produce archives $configuration = Factory::getConfiguration(); $this->setSubstep(''); $engine_name = $configuration->get('akeeba.advanced.postproc_engine'); Factory::getLog()->debug("Loading post-processing engine object ($engine_name)"); $post_proc = Factory::getPostprocEngine($engine_name); if (!is_object($post_proc) || !($post_proc instanceof Base)) { Factory::getLog()->debug("Post-processing engine “{$engine_name}” not found."); Factory::getLog()->debug("The post-processing engine has either been removed or you are trying to use a profile created with the Professional version of the backup software in the Core version which doesn't have this post-processing engine."); return true; } // Initialize the archive part list if required if (empty($this->backup_parts)) { Factory::getLog()->info('Initializing post-processing engine'); // Initialize the flag for multistep post-processing of parts $configuration->set('volatile.postproc.filename', null); $configuration->set('volatile.postproc.directory', null); // Populate array w/ absolute names of backup parts $statistics = Factory::getStatistics(); $stat = $statistics->getRecord(); $this->backup_parts = Factory::getStatistics()->get_all_filenames($stat, false); if (is_null($this->backup_parts)) { // No archive produced, or they are all already post-processed Factory::getLog()->info('No archive files found to post-process'); return true; } Factory::getLog()->debug(count($this->backup_parts) . ' files to process found'); $this->substeps_total = count($this->backup_parts); $this->substeps_done = 0; $this->backup_parts_index = 0; // If we have an empty array, do not run if (empty($this->backup_parts)) { return true; } // Break step before processing? if ($post_proc->recommendsBreakBefore() && !Factory::getConfiguration() ->get('akeeba.tuning.nobreak.finalization', 0) ) { Factory::getLog()->debug('Breaking step before post-processing run'); $configuration->set('volatile.breakflag', true); return false; } } // Make sure we don't accidentally break the step when not required to do so $configuration->set('volatile.breakflag', false); // Do we have a filename from the previous run of the post-proc engine? $filename = $configuration->get('volatile.postproc.filename', null); if (empty($filename)) { $filename = $this->backup_parts[$this->backup_parts_index]; Factory::getLog()->info('Beginning post processing file ' . $filename); } else { Factory::getLog()->info('Continuing post processing file ' . $filename); } $this->setStep('Post-processing'); $this->setSubstep(basename($filename)); $timer = Factory::getTimer(); $startTime = $timer->getRunningTime(); $processingException = null; try { $finishedProcessing = $post_proc->processPart($filename); } catch (Exception $e) { $finishedProcessing = false; $processingException = $e; } if (!is_null($processingException)) { Factory::getLog()->warning('Failed to process file ' . $filename); Factory::getLog()->warning('Error received from the post-processing engine:'); self::logErrorsFromException($processingException, LogLevel::WARNING); } elseif ($finishedProcessing === true) { // The post-processing of this file ended successfully Factory::getLog()->info('Finished post-processing file ' . $filename); $configuration->set('volatile.postproc.filename', null); } else { // More work required Factory::getLog()->info('More post-processing steps required for file ' . $filename); $configuration->set('volatile.postproc.filename', $filename); // Do we need to break the step? $endTime = $timer->getRunningTime(); $stepTime = $endTime - $startTime; $timeLeft = $timer->getTimeLeft(); // By default, we assume that we have enough time to run yet another step $configuration->set('volatile.breakflag', false); /** * However, if the last step took longer than the time we already have left on the timer we can predict * that we are running out of time, therefore we need to break the step. */ if ($timeLeft < $stepTime) { $configuration->set('volatile.breakflag', true); } } // Should we delete the file afterwards? $canAndShouldDeleteFileAfterwards = $configuration->get('engine.postproc.common.delete_after', false) && $post_proc->isFileDeletionAfterProcessingAdvisable(); if ($canAndShouldDeleteFileAfterwards && $finishedProcessing) { Factory::getLog()->debug('Deleting already processed file ' . $filename); Platform::getInstance()->unlink($filename); } elseif ($canAndShouldDeleteFileAfterwards && !$finishedProcessing) { Factory::getLog()->debug('Not removing the non-processed file ' . $filename); } else { Factory::getLog()->debug('Not removing processed file ' . $filename); } if ($finishedProcessing === true) { // Move the index forward if the part finished processing $this->backup_parts_index++; // Mark substep done $this->substeps_done++; // Break step after processing? if ($post_proc->recommendsBreakAfter() && !Factory::getConfiguration()->get('akeeba.tuning.nobreak.finalization', 0)) { $configuration->set('volatile.breakflag', true); } // If we just finished processing the first archive part, save its remote path in the statistics. if (($this->substeps_done == 1) || ($this->substeps_total == 0)) { if (!empty($post_proc->getRemotePath())) { $statistics = Factory::getStatistics(); $remote_filename = $engine_name . '://'; $remote_filename .= $post_proc->getRemotePath(); $data = [ 'remote_filename' => $remote_filename, ]; $remove_after = $configuration->get('engine.postproc.common.delete_after', false); if ($remove_after) { $data['filesexist'] = 0; } $statistics->setStatistics($data); } } // Are we past the end of the array (i.e. we're finished)? if ($this->backup_parts_index >= count($this->backup_parts)) { Factory::getLog()->info('Post-processing has finished for all files'); return true; } } if (!is_null($processingException)) { // If the post-processing failed, make sure we don't process anything else $this->backup_parts_index = count($this->backup_parts); Factory::getLog()->warning('Post-processing interrupted -- no more files will be transferred'); return true; } // Indicate we're not done yet return false; } /** * Runs the Kickstart post-processing step * * @return bool True on success */ protected function kickstart_post_processing() { $this->setStep('Post-processing Kickstart'); $configuration = Factory::getConfiguration(); $this->setSubstep(''); // Do not run if we are not told to upload Kickstart $uploadKickstart = $configuration->get('akeeba.advanced.uploadkickstart', 0); if (!$uploadKickstart) { Factory::getLog()->info("Getting ready to upload Kickstart"); return true; } $engine_name = $configuration->get('akeeba.advanced.postproc_engine'); Factory::getLog()->debug("Loading post-processing engine object ($engine_name)"); $post_proc = Factory::getPostprocEngine($engine_name); // Set $filename to kickstart's source file $filename = Platform::getInstance()->get_installer_images_path() . '/kickstart.txt'; // Post-process the file $this->setSubstep('kickstart.php'); if (!@file_exists($filename) || !is_file($filename)) { Factory::getLog()->warning('Failed to upload kickstart.php. Missing file ' . $filename); // Indicate we're done. return true; } $exception = null; $finishedProcessing = false; try { $finishedProcessing = $post_proc->processPart($filename, 'kickstart.php'); } catch (Exception $e) { $exception = $e; } if (!is_null($exception)) { Factory::getLog()->warning('Failed to upload kickstart.php'); Factory::getLog()->warning('Error received from the post-processing engine:'); self::logErrorsFromException($exception, LogLevel::WARNING); } elseif ($finishedProcessing === true) { // The post-processing of this file ended successfully Factory::getLog()->info('Finished uploading kickstart.php'); $configuration->set('volatile.postproc.filename', null); } // Indicate we're done return true; } /** * Updates the backup statistics record * * @return bool True on success */ protected function update_statistics() { $this->setStep('Updating backup record information'); $this->setSubstep(''); Factory::getLog()->debug("Updating statistics"); // We finished normally. Fetch the stats record $statistics = Factory::getStatistics(); $registry = Factory::getConfiguration(); $data = [ 'backupend' => Platform::getInstance()->get_timestamp_database(), 'status' => 'complete', 'multipart' => $registry->get('volatile.statistics.multipart', 0), ]; try { $result = $statistics->setStatistics($data); } catch (Exception $e) { $result = false; } if ($result === false) { // Most likely a "MySQL has gone away" issue... $configuration = Factory::getConfiguration(); $configuration->set('volatile.breakflag', true); return false; } /** * We could have handled it in $data above. However, if the schema has not been updated this function will * continue failing inifnitely, causing the backup to never end. */ $statistics->updateInStep(false); $stat = (object) $statistics->getRecord(); Platform::getInstance()->remove_duplicate_backup_records($stat->archivename); return true; } protected function update_filesizes() { $this->setStep('Updating file sizes'); $this->setSubstep(''); Factory::getLog()->debug("Updating statistics with file sizes"); // Fetch the stats record $statistics = Factory::getStatistics(); $record = $statistics->getRecord(); $filenames = $statistics->get_all_filenames($record); $filesize = 0.0; // Calculate file sizes of files remaining on the server if (!empty($filenames)) { foreach ($filenames as $file) { $size = @filesize($file); if ($size !== false) { $filesize += $size * 1.0; } } } // Get the part size in volatile storage, set from the immediate part uploading effected by the // "Process each part immediately" option, and add it to the total file size $config = Factory::getConfiguration(); $postProcImmediately = $config->get('engine.postproc.common.after_part', 0, false); $deleteAfter = $config->get('engine.postproc.common.delete_after', 0, false); $postProcEngine = $config->get('akeeba.advanced.postproc_engine', 'none'); if ($postProcImmediately && $deleteAfter && ($postProcEngine != 'none')) { $volatileTotalSize = Factory::getConfiguration()->get('volatile.engine.archiver.totalsize', 0); if ($volatileTotalSize) { $filesize += $volatileTotalSize; } } $data = [ 'total_size' => $filesize, ]; Factory::getLog()->debug("Total size of backup archive (in bytes): $filesize"); $statistics->setStatistics($data); return true; } /** * Applies the size and count quotas * * @return bool True on success * * @throws Exception */ protected function apply_quotas() { $this->setStep('Applying quotas'); $this->setSubstep(''); // If no quota settings are enabled, quit $registry = Factory::getConfiguration(); $useDayQuotas = $registry->get('akeeba.quota.maxage.enable'); $useCountQuotas = $registry->get('akeeba.quota.enable_count_quota'); $useSizeQuotas = $registry->get('akeeba.quota.enable_size_quota'); if (!($useDayQuotas || $useCountQuotas || $useSizeQuotas)) { $this->apply_obsolete_quotas(); Factory::getLog()->debug("No quotas were defined; old backup files will be kept intact"); return true; // No quota limits were requested } // Try to find the files to be deleted due to quota settings $statistics = Factory::getStatistics(); $latestBackupId = $statistics->getId(); // Get quota values $countQuota = $registry->get('akeeba.quota.count_quota'); $sizeQuota = $registry->get('akeeba.quota.size_quota'); $daysQuota = $registry->get('akeeba.quota.maxage.maxdays'); $preserveDay = $registry->get('akeeba.quota.maxage.keepday'); // Get valid-looking backup ID's $validIDs = Platform::getInstance()->get_valid_backup_records(true); // Create a list of valid files $allFiles = []; if (count($validIDs)) { foreach ($validIDs as $id) { $stat = Platform::getInstance()->get_statistics($id); // Exclude frozen record from quota management if (isset($stat['frozen']) && $stat['frozen']) { Factory::getLog()->debug(sprintf("Excluding frozen backup id %d from quota management", $id)); continue; } try { $backupstart = new DateTime($stat['backupstart']); $backupTS = $backupstart->format('U'); $backupDay = $backupstart->format('d'); } catch (Exception $e) { $backupTS = 0; $backupDay = 0; } // Get the log file name $tag = $stat['tag']; $backupId = $stat['backupid'] ?? ''; $logName = ''; if (!empty($backupId)) { $logName = 'akeeba.' . $tag . '.' . $backupId . '.log.php'; } // Multipart processing $filenames = Factory::getStatistics()->get_all_filenames($stat, true); if (!is_null($filenames)) { // Only process existing files $filesize = 0; foreach ($filenames as $filename) { $filesize += @filesize($filename); } $allFiles[] = [ 'id' => $id, 'filenames' => $filenames, 'size' => $filesize, 'backupstart' => $backupTS, 'day' => $backupDay, 'logname' => $logName, ]; } } } unset($validIDs); // If there are no files, exit early if (count($allFiles) == 0) { Factory::getLog()->debug("There were no old backup files to apply quotas on"); return true; } // Init arrays $killids = []; $killLogs = []; $ret = []; $leftover = []; // Do we need to apply maximum backup age quotas? if ($useDayQuotas) { $killDatetime = new DateTime(); $killDatetime->modify('-' . $daysQuota . ($daysQuota == 1 ? ' day' : ' days')); $killTS = $killDatetime->format('U'); foreach ($allFiles as $file) { if ($file['id'] == $latestBackupId) { continue; } // Is this on a preserve day? if ($preserveDay > 0) { if ($preserveDay == $file['day']) { $leftover[] = $file; continue; } } // Otherwise, check the timestamp if ($file['backupstart'] < $killTS) { $ret[] = $file['filenames']; $killids[] = $file['id']; if (!empty($file['logname'])) { $filePath = reset($file['filenames']); if (!empty($filePath)) { if (@file_exists(dirname($filePath) . '/' . $file['logname'])) { $killLogs[] = dirname($filePath) . '/' . $file['logname']; } elseif (@file_exists(dirname($filePath) . '/' . substr($file['logname'], 0, -4))) { /** * Bad host: the log file akeeba.tag.log.php may not exist but the akeeba.tag.log file * does. This code addresses this problem. */ $killLogs[] = dirname($filePath) . '/' . substr($file['logname'], 0, -4); } } } } else { $leftover[] = $file; } } } // Do we need to apply count quotas? if ($useCountQuotas && is_numeric($countQuota) && !($countQuota <= 0) && !$useDayQuotas) { // Are there more files than the quota limit? if (!(count($allFiles) > $countQuota)) { // No, effectively skip the quota checking $leftover = $allFiles; } else { Factory::getLog()->debug("Processing count quotas"); // Yes, apply the quota setting. Add to $ret all entries minus the last // $countQuota ones. $totalRecords = count($allFiles); $checkLimit = $totalRecords - $countQuota; // Only process if at least one file (current backup!) is to be left for ($count = 0; $count < $totalRecords; $count++) { $def = array_pop($allFiles); if ($def['id'] == $latestBackupId) { array_push($allFiles, $def); continue; } if (count($ret) < $checkLimit) { if ($latestBackupId != $def['id']) { $ret[] = $def['filenames']; $killids[] = $def['id']; if (!empty($def['logname'])) { $filePath = reset($def['filenames']); if (!empty($filePath)) { if (@file_exists(dirname($filePath) . '/' . $def['logname'])) { $killLogs[] = dirname($filePath) . '/' . $def['logname']; } elseif (@file_exists(dirname($filePath) . '/' . substr($def['logname'], 0, -4))) { /** * Bad host: the log file akeeba.tag.log.php may not exist but the akeeba.tag.log file * does. This code addresses this problem. */ $killLogs[] = dirname($filePath) . '/' . substr($def['logname'], 0, -4); } } } } } else { $leftover[] = $def; } } unset($allFiles); } } else { // No count quotas are applied $leftover = $allFiles; } // Do we need to apply size quotas? if ($useSizeQuotas && is_numeric($sizeQuota) && !($sizeQuota <= 0) && (count($leftover) > 0) && !$useDayQuotas) { Factory::getLog()->debug("Processing size quotas"); // OK, let's start counting bytes! $runningSize = 0; while (count($leftover) > 0) { // Each time, remove the last element of the backup array and calculate // running size. If it's over the limit, add the archive to the return array. $def = array_pop($leftover); $runningSize += $def['size']; if ($runningSize >= $sizeQuota) { if ($latestBackupId == $def['id']) { $runningSize -= $def['size']; } else { $ret[] = $def['filenames']; $killids[] = $def['id']; if (!empty($def['logname'])) { $filePath = reset($def['filenames']); if (!empty($filePath)) { if (@file_exists(dirname($filePath) . '/' . $def['logname'])) { $killLogs[] = dirname($filePath) . '/' . $def['logname']; } elseif (@file_exists(dirname($filePath) . '/' . substr($def['logname'], 0, -4))) { /** * Bad host: the log file akeeba.tag.log.php may not exist but the akeeba.tag.log file * does. This code addresses this problem. */ $killLogs[] = dirname($filePath) . '/' . substr($def['logname'], 0, -4); } } } } } } } // Convert the $ret 2-dimensional array to single dimensional $quotaFiles = []; foreach ($ret as $temp) { foreach ($temp as $filename) { $quotaFiles[] = $filename; } } // Update the statistics record with the removed remote files if (!empty($killids)) { foreach ($killids as $id) { $data = ['filesexist' => '0']; Platform::getInstance()->set_or_update_statistics($id, $data); } } // Apply quotas to backup archives if (count($quotaFiles) > 0) { Factory::getLog()->debug("Applying quotas"); foreach ($quotaFiles as $file) { if (!@Platform::getInstance()->unlink($file)) { Factory::getLog()->warning("Failed to remove old backup file " . $file); } } } // Apply quotas to log files if (!empty($killLogs)) { Factory::getLog()->debug("Removing obsolete log files"); foreach ($killLogs as $logPath) { @Platform::getInstance()->unlink($logPath); } } $this->apply_obsolete_quotas(); return true; } /** * Apply quotas for remotely stored files * * @return bool True on success */ protected function apply_remote_quotas() { $this->setStep('Applying remote storage quotas'); $this->setSubstep(''); // Make sure we are enabled $config = Factory::getConfiguration(); $enableRemote = $config->get('akeeba.quota.remote', 0); if (!$enableRemote) { return true; } // Get the list of files to kill if (empty($this->remote_files_killlist)) { Factory::getLog()->debug('Applying remote file quotas'); $this->remote_files_killlist = $this->get_remote_quotas(); if (empty($this->remote_files_killlist)) { Factory::getLog()->debug('No remote files to apply quotas to were found'); return true; } } // Remove the files $timer = Factory::getTimer(); while ($timer->getRunningTime() && (is_array($this->remote_files_killlist) || $this->remote_files_killlist instanceof \Countable ? count($this->remote_files_killlist) : 0)) { $filename = array_shift($this->remote_files_killlist); [$engineName, $path] = explode('://', $filename); $engine = Factory::getPostprocEngine($engineName); if (!$engine->supportsDelete()) { continue; } Factory::getLog()->debug("Removing $filename"); try { $engine->delete($path); } catch (Exception $e) { Factory::getLog()->debug("Removal failed: " . $e->getMessage()); $result = false; } } // Return false if we have more work to do or true if we're done if (is_array($this->remote_files_killlist) || $this->remote_files_killlist instanceof \Countable ? count($this->remote_files_killlist) : 0) { Factory::getLog()->debug("Remote file removal will continue in the next step"); return false; } else { Factory::getLog()->debug("Remote file quotas applied successfully"); $this->apply_obsolete_quotas(); return true; } } /** * Applies the size and count quotas * * @return array * * @throws Exception */ protected function get_remote_quotas() { // Get all records with a remote filename $allRecords = Platform::getInstance()->get_valid_remote_records(); // Bail out if no records found if (empty($allRecords)) { return []; } // Try to find the files to be deleted due to quota settings $statistics = Factory::getStatistics(); $latestBackupId = $statistics->getId(); // Filter out the current record $temp = []; foreach ($allRecords as $item) { if ($item['id'] == $latestBackupId) { continue; } // Skip frozen records if (isset($item['frozen']) && $item['frozen']) { continue; } $item['files'] = $this->get_remote_files($item['remote_filename'], $item['multipart']); $temp[] = $item; } $allRecords = $temp; // Bail out if only the current backup was included in the list if (count($allRecords) == 0) { return []; } // Get quota values $registry = Factory::getConfiguration(); $countQuota = $registry->get('akeeba.quota.count_quota'); $sizeQuota = $registry->get('akeeba.quota.size_quota'); $useCountQuotas = $registry->get('akeeba.quota.enable_count_quota'); $useSizeQuotas = $registry->get('akeeba.quota.enable_size_quota'); $useDayQuotas = $registry->get('akeeba.quota.maxage.enable'); $daysQuota = $registry->get('akeeba.quota.maxage.maxdays'); $preserveDay = $registry->get('akeeba.quota.maxage.keepday'); $leftover = []; $ret = []; $killids = []; if ($useDayQuotas) { $killDatetime = new DateTime(); $killDatetime->modify('-' . $daysQuota . ($daysQuota == 1 ? ' day' : ' days')); $killTS = $killDatetime->format('U'); foreach ($allRecords as $def) { $backupstart = new DateTime($def['backupstart']); $backupTS = $backupstart->format('U'); $backupDay = $backupstart->format('d'); // Is this on a preserve day? if ($preserveDay > 0) { if ($preserveDay == $backupDay) { $leftover[] = $def; continue; } } // Otherwise, check the timestamp if ($backupTS < $killTS) { $ret[] = $def['files']; $killids[] = $def['id']; } else { $leftover[] = $def; } } } // Do we need to apply count quotas? if ($useCountQuotas && ($countQuota >= 1) && !$useDayQuotas) { $countQuota--; // Are there more files than the quota limit? if (!(count($allRecords) > $countQuota)) { // No, effectively skip the quota checking $leftover = $allRecords; } else { Factory::getLog()->debug("Processing remote count quotas"); // Yes, apply the quota setting. $totalRecords = count($allRecords); for ($count = 0; $count <= $totalRecords; $count++) { $def = array_pop($allRecords); if (count($leftover) >= $countQuota) { $ret[] = $def['files']; $killids[] = $def['id']; } else { $leftover[] = $def; } } unset($allRecords); } } else { // No count quotas are applied $leftover = $allRecords; } // Do we need to apply size quotas? if ($useSizeQuotas && ($sizeQuota > 0) && (count($leftover) > 0) && !$useDayQuotas) { Factory::getLog()->debug("Processing remote size quotas"); // OK, let's start counting bytes! $runningSize = 0; while (count($leftover) > 0) { // Each time, remove the last element of the backup array and calculate // running size. If it's over the limit, add the archive to the $ret array. $def = array_pop($leftover); $runningSize += $def['total_size']; if ($runningSize >= $sizeQuota) { $ret[] = $def['files']; $killids[] = $def['id']; } } } // Convert the $ret 2-dimensional array to single dimensional $quotaFiles = []; foreach ($ret as $temp) { if (!is_array($temp) || empty($temp)) { continue; } foreach ($temp as $filename) { $quotaFiles[] = $filename; } } // Update the statistics record with the removed remote files if (!empty($killids)) { foreach ($killids as $id) { if (empty($id)) { continue; } $data = ['remote_filename' => '']; Platform::getInstance()->set_or_update_statistics($id, $data); } } return $quotaFiles; } /** * Get the full paths to all remote backup parts * * @param string $filename The full filename of the last part stored in the database * @param int $multipart How many parts does this archive consist of? * * @return array A list of the full paths of all remotely stored backup archive parts */ protected function get_remote_files($filename, $multipart) { $result = []; $extension = substr($filename, -3); $base = substr($filename, 0, -4); $result[] = $filename; if ($multipart > 1) { for ($i = 1; $i < $multipart; $i++) { $newExt = substr($extension, 0, 1) . sprintf('%02u', $i); $result[] = $base . '.' . $newExt; } } return $result; } /** * Keeps a maximum number of "obsolete" records * * @return void */ protected function apply_obsolete_quotas() { $this->setStep('Applying quota limit on obsolete backup records'); $this->setSubstep(''); $registry = Factory::getConfiguration(); $limit = $registry->get('akeeba.quota.obsolete_quota', 0); $limit = (int) $limit; if ($limit <= 0) { return; } $statsTable = Platform::getInstance()->tableNameStats; $db = Factory::getDatabase(Platform::getInstance()->get_platform_database_options()); $query = $db->getQuery(true) ->select([ $db->qn('id'), $db->qn('tag'), $db->qn('backupid'), $db->qn('absolute_path'), ]) ->from($db->qn($statsTable)) ->where($db->qn('profile_id') . ' = ' . $db->q(Platform::getInstance()->get_active_profile())) ->where($db->qn('status') . ' = ' . $db->q('complete')) ->where($db->qn('filesexist') . '=' . $db->q('0')) ->where( '(' . $db->qn('remote_filename') . '=' . $db->q('') . ' OR ' . $db->qn('remote_filename') . ' IS NULL' . ')' ) ->order($db->qn('id') . ' DESC'); $db->setQuery($query, $limit, 100000); $records = $db->loadAssocList(); if (empty($records)) { return; } $array = []; // Delete backup-specific log files if they exist and add the IDs of the records to delete in the $array foreach ($records as $stat) { $array[] = $stat['id']; // We can't delete logs if there is no backup ID in the record if (!isset($stat['backupid']) || empty($stat['backupid'])) { continue; } $logFileName = 'akeeba.' . $stat['tag'] . '.' . $stat['backupid'] . '.log.php'; $logPath = dirname($stat['absolute_path']) . '/' . $logFileName; if (@file_exists($logPath)) { @unlink($logPath); } /** * Transitional period: the log file akeeba.tag.log.php may not exist but the akeeba.tag.log does. This * addresses this transition. */ $logPath = dirname($stat['absolute_path']) . '/' . substr($logFileName, 0, -4); if (@file_exists($logPath)) { @unlink($logPath); } } $ids = []; foreach ($array as $id) { $ids[] = $db->q($id); } $ids = implode(',', $ids); $query = $db->getQuery(true) ->delete($db->qn($statsTable)) ->where($db->qn('id') . " IN ($ids)"); $db->setQuery($query); $db->query(); } /** * Formats a number of bytes in human readable format * * @param int|float $size The size in bytes to format, e.g. 8254862 * * @return string The human-readable representation of the byte size, e.g. "7.87 Mb" */ private function formatByteSize($size): string { $unit = ['b', 'KB', 'MB', 'GB', 'TB', 'PB']; return @round($size / 1024 ** ($i = floor(log($size, 1024))), 2) . ' ' . $unit[$i]; } private function getPartFilenames(array $statsRecord): array { $baseFile = basename($statsRecord['absolute_path'] ?? $statsRecord['archivename'] ?? $statsRecord['remote_filename'] ?? ''); if (empty($baseFile)) { return []; } $partsCount = max($statsRecord['multipart'] ?? 1, 1); if ($partsCount === 1) { return [$baseFile]; } $ret = []; $extension = substr($baseFile, strrpos($baseFile, '.')); $bareName = basename($baseFile, $extension); for ($i = 1; $i < $partsCount; $i++) { $ret[] = $bareName . substr($extension, 0, 2) . sprintf('%02d', $i); } $ret[] = $baseFile; return $ret; } }