Current File : /var/www/vinorea/modules/autoupgrade/classes/Task/Backup/BackupDatabase.php
<?php

/**
 * Copyright since 2007 PrestaShop SA and Contributors
 * PrestaShop is an International Registered Trademark & Property of PrestaShop SA
 *
 * NOTICE OF LICENSE
 *
 * This source file is subject to the Academic Free License version 3.0
 * that is bundled with this package in the file LICENSE.md.
 * It is also available through the world-wide-web at this URL:
 * https://opensource.org/licenses/AFL-3.0
 * If you did not receive a copy of the license and are unable to
 * obtain it through the world-wide-web, please send an email
 * to license@prestashop.com so we can send you a copy immediately.
 *
 * @author    PrestaShop SA and Contributors <contact@prestashop.com>
 * @copyright Since 2007 PrestaShop SA and Contributors
 * @license   https://opensource.org/licenses/AFL-3.0 Academic Free License version 3.0
 */

namespace PrestaShop\Module\AutoUpgrade\Task\Backup;

use Exception;
use PDO;
use PrestaShop\Module\AutoUpgrade\Database\TableFilter;
use PrestaShop\Module\AutoUpgrade\Exceptions\UpgradeException;
use PrestaShop\Module\AutoUpgrade\Parameters\UpgradeFileNames;
use PrestaShop\Module\AutoUpgrade\Progress\Backlog;
use PrestaShop\Module\AutoUpgrade\Task\AbstractTask;
use PrestaShop\Module\AutoUpgrade\Task\ExitCode;
use PrestaShop\Module\AutoUpgrade\Task\TaskName;
use PrestaShop\Module\AutoUpgrade\Task\TaskType;
use PrestaShop\Module\AutoUpgrade\Tools14;
use PrestaShop\Module\AutoUpgrade\UpgradeContainer;

class BackupDatabase extends AbstractTask
{
    const TASK_TYPE = TaskType::TASK_TYPE_BACKUP;

    const MAX_SIZE_PER_INSERT_STMT = 950000;

    /**
     * @throws Exception
     */
    public function init(): void
    {
        $this->container->initPrestaShopCore();
    }

    /**
     * @throws Exception
     */
    public function run(): int
    {
        $this->stepDone = false;
        $this->next = TaskName::TASK_BACKUP_DATABASE;
        $start_time = time();
        $time_elapsed = 0;

        $state = $this->container->getBackupState();

        $db = $this->container->getDb();
        $dbLink = $db->connect();

        if (!$this->container->getFileStorage()->exists(UpgradeFileNames::DB_TABLES_TO_BACKUP_LIST)) {
            return $this->warmUp();
        }

        $tablesToBackup = Backlog::fromContents($this->container->getFileStorage()->load(UpgradeFileNames::DB_TABLES_TO_BACKUP_LIST));

        $numberOfSyncedTables = 0;
        $fp = false;
        $backupfile = null;

        // MAIN BACKUP LOOP //
        $written = 0;
        while ($this->isRemainingTimeEnough($time_elapsed)
            && $tablesToBackup->getRemainingTotal()
        ) {
            // Recover table partially synced
            $table = $state->getBackupTable();
            if (null === $table) {
                // Or get the next one to sync
                $table = $tablesToBackup->getNext();
                $state->setBackupLoopLimit(0);
            }

            if ($written > $this->container->getUpdateConfiguration()->getMaxSizeToWritePerCall()) {
                // In the previous loop execution, we reached the limit of data to store in a single file.
                // We reset the stream
                $written = 0;
                if (is_resource($fp)) {
                    fclose($fp);
                }
            }

            if ($written === 0) {
                // increment dbStep will increment the number in filename
                $state->setDbStep($state->getDbStep() + 1);

                $backupfile = $this->container->getProperty(UpgradeContainer::BACKUP_PATH) . DIRECTORY_SEPARATOR . $state->getBackupName() . DIRECTORY_SEPARATOR . $state->getBackupDbFilename();
                $backupfile = preg_replace('#_XXXXXX_#', '_' . str_pad(strval($state->getDbStep()), 6, '0', STR_PAD_LEFT) . '_', $backupfile);

                // start init file
                $fp = $this->openPartialBackupFile($backupfile);

                $written += fwrite($fp, '/* Backup ' . $state->getDbStep() . ' for ' . Tools14::getHttpHost() . __PS_BASE_URI__ . "\n *  at " . date('r') . "\n */\n");
                $written += fwrite($fp, "\n" . 'SET SESSION sql_mode = \'\';' . "\n\n");
                $written += fwrite($fp, "\n" . 'SET NAMES \'utf8\';' . "\n\n");
                $written += fwrite($fp, "\n" . 'SET FOREIGN_KEY_CHECKS=0;' . "\n\n");
                // end init file
            }

            // start schema : drop & create table only
            if (null === $state->getBackupTable()) {
                // Export the table schema
                $schema = $db->executeS('SHOW CREATE TABLE `' . $table . '`', true, false);

                if (count($schema) != 1 ||
                    !(isset($schema[0]['Table'], $schema[0]['Create Table'])
                        || isset($schema[0]['View'], $schema[0]['Create View']))) {
                    fclose($fp);
                    if ($this->container->getFileSystem()->exists($backupfile)) {
                        $this->container->getFileSystem()->remove($backupfile);
                    }
                    $this->logger->error($this->translator->trans('An error occurred while backing up. Unable to obtain the schema of %s', [$table]));
                    $this->logger->info($this->translator->trans('Error during database backup.'));
                    $this->next = TaskName::TASK_ERROR;
                    $this->setErrorFlag();

                    return ExitCode::FAIL;
                }

                // case view
                if (isset($schema[0]['View'])) {
                    $written += fwrite($fp, '/* Scheme for view' . $schema[0]['View'] . " */\n");
                    // If some *upgrade* transform a table in a view, drop both just in case
                    $written += fwrite($fp, 'DROP TABLE IF EXISTS `' . $schema[0]['View'] . '`;' . "\n");
                    $written += fwrite($fp, 'DROP VIEW IF EXISTS `' . $schema[0]['View'] . '`;' . "\n");
                    $written += fwrite($fp, preg_replace('#DEFINER=[^\s]+\s#', 'DEFINER=CURRENT_USER ', $schema[0]['Create View']) . ";\n\n");

                    $ignore_stats_table[] = $schema[0]['View'];
                // There is no data to sync -> setBackupTable is not set.
                }
                // case table
                elseif (isset($schema[0]['Table'])) {
                    // Case common table
                    $written += fwrite($fp, '/* Scheme for table ' . $schema[0]['Table'] . " */\n");
                    // If some *upgrade* transform a table in a view, drop both just in case
                    $written += fwrite($fp, 'DROP TABLE IF EXISTS `' . $schema[0]['Table'] . '`;' . "\n");
                    $written += fwrite($fp, 'DROP VIEW IF EXISTS `' . $schema[0]['Table'] . '`;' . "\n");
                    // CREATE TABLE
                    $written += fwrite($fp, $schema[0]['Create Table'] . ";\n\n");
                    // schema created, now we need to create the missing vars
                    $state->setBackupTable($table);
                }
            }
            // end of schema

            $i = 0;

            // POPULATE TABLE
            if ($state->getBackupTable()) {
                $backup_loop_limit = $state->getBackupLoopLimit();

                $dbLink->setAttribute(PDO::MYSQL_ATTR_USE_BUFFERED_QUERY, false);
                /** @see https://dev.mysql.com/doc/refman/8.4/en/select.html specifies a large LIMIT value to get the whole table */
                $data = $dbLink->prepare('SELECT * FROM `' . $table . '` LIMIT ' . (int) $backup_loop_limit . ',18446744073709551615');
                $data->execute();

                $insertStmtSize = 0;

                while (($row = $data->fetch(PDO::FETCH_ASSOC)) && $this->isRemainingTimeEnough($time_elapsed)) {
                    if (!$insertStmtSize) {
                        $written += fwrite($fp, 'INSERT INTO `' . $table . "` VALUES\n");
                    }

                    if ($i && $insertStmtSize) {
                        $s = "),\n(";
                    } else {
                        $s = '(';
                    }
                    ++$i;

                    // this starts a row
                    foreach ($row as $value) {
                        if ($value === null) {
                            $s .= 'NULL,';
                        } else {
                            $s .= "'" . $db->escape($value, true) . "',";
                        }
                    }
                    $s = rtrim($s, ',');

                    $writtenBytes = fwrite($fp, $s);
                    $written += $writtenBytes;
                    $insertStmtSize += $writtenBytes;

                    // If we reach the size limit of a single INSERT INTO statement, we close the list and start a new one.
                    if ($insertStmtSize >= self::MAX_SIZE_PER_INSERT_STMT) {
                        $written += fwrite($fp, ");\n");
                        $insertStmtSize = 0;
                    }
                    fflush($fp);
                    $time_elapsed = time() - $start_time;
                }

                if ($i && $insertStmtSize) {
                    $written += fwrite($fp, ");\n");
                }
            }

            if (!empty($row)) {
                // Still data to store, prepare state
                $state->setBackupLoopLimit($state->getBackupLoopLimit() + $i);
            } else {
                // Sync is complete for the table
                ++$numberOfSyncedTables;
                $this->logger->debug($this->translator->trans('%s table has been saved.', [$table]));
                $state->setBackupTable(null);
            }

            $time_elapsed = time() - $start_time;
        }

        // end of loop
        if (is_resource($fp)) {
            $written += fwrite($fp, "\n" . 'SET FOREIGN_KEY_CHECKS=1;' . "\n\n");
            fclose($fp);
            $fp = null;
        }

        $state->setProgressPercentage(
            $this->container->getCompletionCalculator()->computePercentage($tablesToBackup, self::class, BackupComplete::class)
        );
        $this->container->getFileStorage()->save($tablesToBackup->dump(), UpgradeFileNames::DB_TABLES_TO_BACKUP_LIST);

        if ($numberOfSyncedTables) {
            $this->logger->info($this->translator->trans('%s tables have been saved.', [$numberOfSyncedTables]));
        }

        if ($tablesToBackup->getRemainingTotal()) {
            $this->next = TaskName::TASK_BACKUP_DATABASE;
            $this->stepDone = false;
            if ($numberOfSyncedTables) {
                $this->logger->info($this->translator->trans('Database backup: %s table(s) left...', [$tablesToBackup->getRemainingTotal() + !empty($row)]));
            }

            return ExitCode::SUCCESS;
        }
        $state
            ->setBackupLoopLimit(null)
            ->setBackupTable(null);

        $this->stepDone = true;
        // reset dbStep at the end of this step
        $state->setDbStep(0);

        $this->logger->info($this->translator->trans('Database backup done in filename %s.', [$state->getBackupName()]));
        $this->next = TaskName::TASK_BACKUP_COMPLETE;

        return ExitCode::SUCCESS;
    }

    protected function warmUp(): int
    {
        $state = $this->container->getBackupState();

        $state->setProgressPercentage(
            $this->container->getCompletionCalculator()->getBasePercentageOfTask(self::class)
        );

        $relative_backup_path = str_replace(_PS_ROOT_DIR_, '', $this->container->getProperty(UpgradeContainer::BACKUP_PATH));
        $report = '';
        if (!\ConfigurationTest::test_dir($relative_backup_path, false, $report)) {
            $this->logger->error($this->translator->trans('Backup directory is not writable (%path%).', ['%path%' => $this->container->getProperty(UpgradeContainer::BACKUP_PATH)]));
            $this->next = TaskName::TASK_ERROR;
            $this->setErrorFlag();

            return ExitCode::FAIL;
        }

        if (!is_dir($this->container->getProperty(UpgradeContainer::BACKUP_PATH) . DIRECTORY_SEPARATOR . $state->getBackupName())) {
            $this->container->getFileSystem()->mkdir($this->container->getProperty(UpgradeContainer::BACKUP_PATH) . DIRECTORY_SEPARATOR . $state->getBackupName());
        }
        $state->setDbStep(0);
        $listOfTables = $this->filterTablesToSync(
            $this->container->getDb()->executeS('SHOW TABLES LIKE "' . _DB_PREFIX_ . '%"', true, false)
        );

        if (empty($listOfTables)) {
            throw (new UpgradeException($this->translator->trans('No valid tables were found to back up. Backup of database canceled.')))->setSeverity(UpgradeException::SEVERITY_ERROR);
        }

        $tablesToBackup = new Backlog($listOfTables, count($listOfTables));

        $this->container->getFileStorage()->save($tablesToBackup->dump(), UpgradeFileNames::DB_TABLES_TO_BACKUP_LIST);

        return ExitCode::SUCCESS;
    }

    /**
     * @param array<array<string, string>> $listOfTables
     *
     * @internal Method is public for unit tests
     *
     * @return string[]
     */
    public function filterTablesToSync(array $listOfTables): array
    {
        return array_filter(array_map('current', $listOfTables), function ($table) {
            // Skip tables which do not start with _DB_PREFIX_
            if (strlen($table) <= strlen(_DB_PREFIX_) || strncmp($table, _DB_PREFIX_, strlen(_DB_PREFIX_)) !== 0) {
                return false;
            }

            // Ignore stat tables
            if (in_array($table, TableFilter::tablesToIgnore())) {
                return false;
            }

            return true;
        });
    }

    // MANAGEMENT OF BACKUP FILE RESOURCE

    /**
     * @return resource
     *
     * @throws Exception if file already exists or cannot be written
     */
    private function openPartialBackupFile(string $backupfile)
    {
        // Figure out what compression is available and open the file
        if ($this->container->getFileSystem()->exists($backupfile)) {
            throw (new UpgradeException($this->translator->trans('Backup file %s already exists. Operation aborted.', [$backupfile])))->setSeverity(UpgradeException::SEVERITY_ERROR);
        }

        if (function_exists('bzopen')) {
            $backupfile .= '.bz2';
            $fp = bzopen($backupfile, 'w');
        } elseif (function_exists('gzopen')) {
            $backupfile .= '.gz';
            $fp = gzopen($backupfile, 'w');
        } else {
            $fp = fopen($backupfile, 'w');
        }

        if ($fp === false) {
            throw (new UpgradeException($this->translator->trans('Unable to create backup database file %s.', [addslashes($backupfile)])))->setSeverity(UpgradeException::SEVERITY_ERROR);
        }

        return $fp;
    }

    private function isRemainingTimeEnough(int $elapsedTime): bool
    {
        $timeAllowed = (int) @ini_get('max_execution_time');

        if ($timeAllowed <= 0) {
            return true;
        }

        // Remove 5 seconds on the allowed time to make sure we have time to save data and close files.
        return $elapsedTime < $timeAllowed - 5;
    }
}