Below is a differential between the unpatched vulnerable code and the patched update, for reference.
--- a/backup-backup/analyst/autoload.php
+++ b/backup-backup/analyst/autoload.php
@@ -9,9 +9,13 @@
require_once __DIR__ . '/src/Contracts/RequestorContract.php';
require_once __DIR__ . '/src/Contracts/TrackerContract.php';
require_once __DIR__ . '/src/Contracts/CacheContract.php';
+require_once __DIR__ . '/src/Contracts/StorageContract.php';
require_once __DIR__ . '/src/Core/AbstractFactory.php';
+require_once __DIR__ . '/src/Storage/DatabaseStorage.php';
+require_once __DIR__ . '/src/Storage/FileStorage.php';
+
require_once __DIR__ . '/src/Cache/DatabaseCache.php';
require_once __DIR__ . '/src/Account/Account.php';
--- a/backup-backup/analyst/src/Account/Account.php
+++ b/backup-backup/analyst/src/Account/Account.php
@@ -221,7 +221,7 @@
die;
}
- if (!isset($_POST['nonce']) || !wp_verify_nonce(sanitize_text_field($_POST['nonce']), 'analyst_opt_ajax_nonce')) {
+ if (!isset($_POST['analyst_nonce']) || !wp_verify_nonce(sanitize_text_field($_POST['analyst_nonce']), 'analyst_opt_ajax_nonce')) {
wp_send_json_error(['message' => 'invalid_nonce']);
die;
}
--- a/backup-backup/analyst/src/Account/AccountDataFactory.php
+++ b/backup-backup/analyst/src/Account/AccountDataFactory.php
@@ -3,6 +3,7 @@
namespace Account;
+use AnalystContractsStorageContract;
use AnalystCoreAbstractFactory;
/**
@@ -16,7 +17,7 @@
{
private static $instance;
- CONST OPTIONS_KEY = 'analyst_accounts_data';
+ CONST STORAGE_KEY = 'analyst_accounts_data';
/**
* @var AccountData[]
@@ -24,23 +25,48 @@
protected $accounts = [];
/**
- * Read factory from options or make fresh instance
+ * @var StorageContract
+ */
+ private static $primaryStorage;
+
+ /**
+ * @var StorageContract
+ */
+ private static $secondaryStorage;
+
+ /**
+ * Set the primary and secondary storage backends.
+ *
+ * @param StorageContract $primary
+ * @param StorageContract $secondary
+ */
+ public static function setStorageBackends(StorageContract $primary, StorageContract $secondary)
+ {
+ static::$primaryStorage = $primary;
+ static::$secondaryStorage = $secondary;
+ }
+
+ /**
+ * Read factory from storage or make fresh instance.
+ * Tries primary storage first, then falls back to secondary.
*
* @return static
*/
public static function instance()
{
if (!static::$instance) {
- $raw = get_option(self::OPTIONS_KEY);
+ static::$instance = static::loadFromStorage(static::$primaryStorage);
- // In case object is already unserialized
- // and instance of AccountDataFactory we
- // return it, in other case deal with
- // serialized string data
- if ($raw instanceof self) {
- static::$instance = $raw;
- } else {
- static::$instance = is_string($raw) ? static::unserialize($raw) : new self();
+ if (!static::$instance || empty(static::$instance->accounts)) {
+ $fallback = static::loadFromStorage(static::$secondaryStorage);
+
+ if ($fallback && !empty($fallback->accounts)) {
+ static::$instance = $fallback;
+ }
+ }
+
+ if (!static::$instance) {
+ static::$instance = new self();
}
}
@@ -48,15 +74,48 @@
}
/**
- * Sync this object data with cache
+ * Attempt to load the factory from a storage backend.
+ *
+ * @param StorageContract|null $storage
+ * @return static|null
+ */
+ private static function loadFromStorage($storage)
+ {
+ if (!$storage) {
+ return null;
+ }
+
+ $raw = $storage->get(self::STORAGE_KEY);
+
+ if ($raw instanceof self) {
+ return $raw;
+ }
+
+ if (is_string($raw) && !empty($raw)) {
+ return static::unserialize($raw);
+ }
+
+ return null;
+ }
+
+ /**
+ * Persist current state to both storage backends.
*/
public function sync()
{
- update_option(self::OPTIONS_KEY, serialize($this));
+ $serialized = serialize($this);
+
+ if (static::$primaryStorage) {
+ static::$primaryStorage->put(self::STORAGE_KEY, $serialized);
+ }
+
+ if (static::$secondaryStorage) {
+ static::$secondaryStorage->put(self::STORAGE_KEY, $serialized);
+ }
}
/**
- * Sync this instance data with cache
+ * Sync the singleton instance to storage.
*/
public static function syncData()
{
--- a/backup-backup/analyst/src/Analyst.php
+++ b/backup-backup/analyst/src/Analyst.php
@@ -7,8 +7,11 @@
use AccountAccount;
use AccountAccountDataFactory;
+use AnalystCacheDatabaseCache;
use AnalystContractsAnalystContract;
use AnalystContractsRequestorContract;
+use AnalystStorageDatabaseStorage;
+use AnalystStorageFileStorage;
class Analyst implements AnalystContract
{
@@ -65,6 +68,12 @@
protected function __construct()
{
+ $dbStorage = new DatabaseStorage();
+ $fileStorage = new FileStorage();
+
+ DatabaseCache::setStorageBackends($dbStorage, $fileStorage);
+ AccountDataFactory::setStorageBackends($dbStorage, $fileStorage);
+
$this->mutator = new Mutator();
$this->accountDataFactory = AccountDataFactory::instance();
--- a/backup-backup/analyst/src/Cache/DatabaseCache.php
+++ b/backup-backup/analyst/src/Cache/DatabaseCache.php
@@ -3,20 +3,52 @@
namespace AnalystCache;
use AnalystContractsCacheContract;
+use AnalystContractsStorageContract;
/**
* Class DatabaseCache
*
+ * In-memory key-value cache that persists to dual storage backends.
+ *
* @since 1.1.5
*/
class DatabaseCache implements CacheContract
{
- const OPTION_KEY = 'analyst_cache';
+ const STORAGE_KEY = 'analyst_cache';
protected static $instance;
/**
- * Get instance of db cache
+ * @var StorageContract
+ */
+ private static $primaryStorage;
+
+ /**
+ * @var StorageContract
+ */
+ private static $secondaryStorage;
+
+ /**
+ * Key value pairs
+ *
+ * @var array
+ */
+ protected $values = [];
+
+ /**
+ * Set the primary and secondary storage backends.
+ *
+ * @param StorageContract $primary
+ * @param StorageContract $secondary
+ */
+ public static function setStorageBackends(StorageContract $primary, StorageContract $secondary)
+ {
+ self::$primaryStorage = $primary;
+ self::$secondaryStorage = $secondary;
+ }
+
+ /**
+ * Get singleton instance.
*
* @return DatabaseCache
*/
@@ -30,35 +62,41 @@
}
/**
- * Key value pair
- *
- * @var array[]
- */
- protected $values = [];
-
- /**
* DatabaseCache constructor.
*/
public function __construct()
{
- $raw = get_option(self::OPTION_KEY, serialize([]));
+ $this->values = $this->loadValues(self::$primaryStorage);
- // Raw data may be an array already
- $this->values = is_array($raw) ? $raw : @unserialize($raw);
+ if (empty($this->values)) {
+ $this->values = $this->loadValues(self::$secondaryStorage);
+ }
+ }
- // In case serialization is failed
- // make sure values is an array
- if (!is_array($this->values)) {
- $this->values = [];
+ /**
+ * Load values from a storage backend.
+ *
+ * @param StorageContract|null $storage
+ * @return array
+ */
+ private function loadValues($storage)
+ {
+ if (!$storage) {
+ return [];
}
+
+ $raw = $storage->get(self::STORAGE_KEY, serialize([]));
+
+ $values = is_array($raw) ? $raw : @unserialize($raw);
+
+ return is_array($values) ? $values : [];
}
/**
- * Save value with given key
+ * Save value with given key.
*
* @param string $key
* @param string $value
- *
* @return static
*/
public function put($key, $value)
@@ -71,23 +109,21 @@
}
/**
- * Get value by given key
+ * Get value by given key.
*
- * @param $key
- *
- * @param null $default
- * @return string
+ * @param string $key
+ * @param mixed $default
+ * @return mixed
*/
public function get($key, $default = null)
{
- $value = isset($this->values[$key]) ? $this->values[$key] : $default;
-
- return $value;
+ return isset($this->values[$key]) ? $this->values[$key] : $default;
}
/**
- * @param $key
+ * Remove a value by key.
*
+ * @param string $key
* @return static
*/
public function delete($key)
@@ -102,23 +138,31 @@
}
/**
- * Update cache in DB
+ * Persist current values to both storage backends.
*/
protected function sync()
{
- update_option(self::OPTION_KEY, serialize($this->values));
+ $serialized = serialize($this->values);
+
+ if (self::$primaryStorage) {
+ self::$primaryStorage->put(self::STORAGE_KEY, $serialized);
+ }
+
+ if (self::$secondaryStorage) {
+ self::$secondaryStorage->put(self::STORAGE_KEY, $serialized);
+ }
}
/**
- * Should get value and remove it from cache
+ * Get a value and remove it from cache.
*
- * @param $key
- * @param null $default
+ * @param string $key
+ * @param mixed $default
* @return mixed
*/
public function pop($key, $default = null)
{
- $value = $this->get($key);
+ $value = $this->get($key, $default);
$this->delete($key);
--- a/backup-backup/analyst/src/Contracts/StorageContract.php
+++ b/backup-backup/analyst/src/Contracts/StorageContract.php
@@ -0,0 +1,40 @@
+<?php
+
+namespace AnalystContracts;
+
+if ( ! defined( 'ABSPATH' ) ) exit;
+
+/**
+ * Interface StorageContract
+ *
+ * Defines a simple key-value persistence layer.
+ * Implementations may use the database, filesystem, or any other backend.
+ */
+interface StorageContract
+{
+ /**
+ * Retrieve a value by key.
+ *
+ * @param string $key
+ * @param mixed $default
+ * @return mixed
+ */
+ public function get($key, $default = null);
+
+ /**
+ * Store a value under the given key.
+ *
+ * @param string $key
+ * @param mixed $value
+ * @return bool
+ */
+ public function put($key, $value);
+
+ /**
+ * Remove a value by key.
+ *
+ * @param string $key
+ * @return bool
+ */
+ public function delete($key);
+}
--- a/backup-backup/analyst/src/Mutator.php
+++ b/backup-backup/analyst/src/Mutator.php
@@ -90,7 +90,7 @@
wp_enqueue_style('analyst_custom', analyst_assets_url('/css/customize.css'));
wp_enqueue_script('analyst_custom', analyst_assets_url('/js/customize.js'));
wp_localize_script('analyst_custom', 'analyst_opt_localize', array(
- 'nonce' => wp_create_nonce('analyst_opt_ajax_nonce')
+ 'analyst_nonce' => wp_create_nonce('analyst_opt_ajax_nonce')
));
});
}
@@ -125,7 +125,7 @@
die;
}
- if (!isset($_POST['nonce']) || !wp_verify_nonce(sanitize_text_field($_POST['nonce']), 'analyst_opt_ajax_nonce')) {
+ if (!isset($_POST['analyst_nonce']) || !wp_verify_nonce(sanitize_text_field($_POST['analyst_nonce']), 'analyst_opt_ajax_nonce')) {
wp_send_json_error(['message' => 'invalid_nonce']);
die;
}
--- a/backup-backup/analyst/src/Storage/DatabaseStorage.php
+++ b/backup-backup/analyst/src/Storage/DatabaseStorage.php
@@ -0,0 +1,46 @@
+<?php
+
+namespace AnalystStorage;
+
+use AnalystContractsStorageContract;
+
+if ( ! defined( 'ABSPATH' ) ) exit;
+
+/**
+ * Class DatabaseStorage
+ *
+ * Persists key-value data using the WordPress wp_options table.
+ */
+class DatabaseStorage implements StorageContract
+{
+ /**
+ * @param string $key The wp_options option name.
+ * @param mixed $default
+ * @return mixed
+ */
+ public function get($key, $default = null)
+ {
+ $value = get_option($key, null);
+
+ return $value !== null ? $value : $default;
+ }
+
+ /**
+ * @param string $key The wp_options option name.
+ * @param mixed $value
+ * @return bool
+ */
+ public function put($key, $value)
+ {
+ return update_option($key, $value);
+ }
+
+ /**
+ * @param string $key The wp_options option name.
+ * @return bool
+ */
+ public function delete($key)
+ {
+ return delete_option($key);
+ }
+}
--- a/backup-backup/analyst/src/Storage/FileStorage.php
+++ b/backup-backup/analyst/src/Storage/FileStorage.php
@@ -0,0 +1,99 @@
+<?php
+
+namespace AnalystStorage;
+
+use AnalystContractsStorageContract;
+
+if ( ! defined( 'ABSPATH' ) ) exit;
+
+/**
+ * Class FileStorage
+ *
+ * Persists key-value data as individual dotname files inside wp-content.
+ * Each key maps to a file named ".analyst_{key}" in WP_CONTENT_DIR.
+ * Values are serialized and base64-encoded to safely store arbitrary data.
+ */
+class FileStorage implements StorageContract
+{
+ /**
+ * Base directory for storage files.
+ *
+ * @var string
+ */
+ protected $directory;
+
+ public function __construct()
+ {
+ $this->directory = rtrim(WP_CONTENT_DIR, '/\');
+ }
+
+ /**
+ * @param string $key
+ * @param mixed $default
+ * @return mixed
+ */
+ public function get($key, $default = null)
+ {
+ $filePath = $this->resolveFilePath($key);
+
+ if (!file_exists($filePath) || !is_readable($filePath)) {
+ return $default;
+ }
+
+ $encoded = @file_get_contents($filePath);
+
+ if ($encoded === false || $encoded === '') {
+ return $default;
+ }
+
+ $raw = base64_decode($encoded, true);
+
+ if ($raw === false) {
+ return $default;
+ }
+
+ return @unserialize($raw);
+ }
+
+ /**
+ * @param string $key
+ * @param mixed $value
+ * @return bool
+ */
+ public function put($key, $value)
+ {
+ $filePath = $this->resolveFilePath($key);
+
+ $encoded = base64_encode(serialize($value));
+
+ return @file_put_contents($filePath, $encoded, LOCK_EX) !== false;
+ }
+
+ /**
+ * @param string $key
+ * @return bool
+ */
+ public function delete($key)
+ {
+ $filePath = $this->resolveFilePath($key);
+
+ if (file_exists($filePath)) {
+ return @unlink($filePath);
+ }
+
+ return true;
+ }
+
+ /**
+ * Build the absolute file path for a given key.
+ *
+ * @param string $key
+ * @return string
+ */
+ private function resolveFilePath($key)
+ {
+ $safeKey = preg_replace('/[^a-zA-Z0-9_-]/', '_', $key);
+
+ return $this->directory . '/.analyst_' . $safeKey;
+ }
+}
--- a/backup-backup/analyst/templates/forms/deactivate.php
+++ b/backup-backup/analyst/templates/forms/deactivate.php
@@ -132,7 +132,7 @@
var data = {
action: 'analyst_plugin_deactivate_' + pluginId,
question: question,
- nonce: analyst_opt_localize.nonce
+ analyst_nonce: analyst_opt_localize.analyst_nonce
}
if (reason) {
--- a/backup-backup/analyst/templates/forms/install.php
+++ b/backup-backup/analyst/templates/forms/install.php
@@ -50,7 +50,7 @@
method: 'POST',
data: {
action: 'analyst_install_' + pluginId,
- nonce: analyst_opt_localize.nonce
+ analyst_nonce: analyst_opt_localize.analyst_nonce
},
success: function (data) {
if (data && !data.success) {
@@ -109,7 +109,7 @@
$.post(ajaxurl, {
action: 'analyst_skip_install_' + pluginId,
- nonce: analyst_opt_localize.nonce
+ analyst_nonce: analyst_opt_localize.analyst_nonce
}).done(function () {
$('#analyst-install-modal').hide()
})
--- a/backup-backup/analyst/templates/optin.php
+++ b/backup-backup/analyst/templates/optin.php
@@ -16,7 +16,7 @@
method: 'POST',
data: {
action: 'analyst_opt_in_' + pluginId,
- nonce: analyst_opt_localize.nonce
+ analyst_nonce: analyst_opt_localize.analyst_nonce
},
success: function () {
$('#analyst-opt-in-modal').hide()
--- a/backup-backup/analyst/templates/optout.php
+++ b/backup-backup/analyst/templates/optout.php
@@ -67,7 +67,7 @@
method: 'POST',
data: {
action: 'analyst_opt_out_' + pluginId,
- nonce: analyst_opt_localize.nonce
+ analyst_nonce: analyst_opt_localize.analyst_nonce
},
success: function (data) {
$(self).text('Opt out')
--- a/backup-backup/analyst/version.php
+++ b/backup-backup/analyst/version.php
@@ -2,7 +2,7 @@
return array(
// The sdk version
- 'sdk' => '1.3.30',
+ 'sdk' => '1.3.32',
// Minimum supported WordPress version
'wp' => '4.7',
--- a/backup-backup/backup-backup.php
+++ b/backup-backup/backup-backup.php
@@ -7,7 +7,7 @@
* Author URI: https://inisev.com
* Plugin URI: https://backupbliss.com
* Text Domain: backup-backup
- * Version: 2.1.1
+ * Version: 2.1.2
* * * * * * * * * * * * * * * * * * * * * * * * * * * * * * */
// Exit on direct access
@@ -21,7 +21,7 @@
define('BMI_DEBUG', false);
}
if (!defined('BMI_VERSION')) {
- define('BMI_VERSION', '2.1.1');
+ define('BMI_VERSION', '2.1.2');
}
if (!defined('BMI_ROOT_DIR')) {
define('BMI_ROOT_DIR', __DIR__);
--- a/backup-backup/includes/ajax.php
+++ b/backup-backup/includes/ajax.php
@@ -85,7 +85,7 @@
}
// Create background logs file
- $backgroundLogsPath = BMI_CONFIG_DIR . DIRECTORY_SEPARATOR . 'background-errors.log';
+ $backgroundLogsPath = BMI_CONFIG_DIR . DIRECTORY_SEPARATOR . 'background-errors.' . BMI_LOGS_SUFFIX . '.log';
if (!file_exists($backgroundLogsPath)) {
@touch($backgroundLogsPath);
}
@@ -1115,7 +1115,8 @@
{
$cron_shared = get_option('bmi_cron_new_domain_done', false);
- if ($cron_shared && !$force) return ['status' => 'success'];
+ if (($cron_shared || get_transient('bmi_cron_share_attempted')) && !$force) return ['status' => 'success'];
+ set_transient('bmi_cron_share_attempted', true, HOUR_IN_SECONDS);
$baseurl = home_url();
if (substr($baseurl, 0, 4) != 'http') {
if (is_ssl()) $baseurl = 'https://' . home_url();
@@ -2278,12 +2279,12 @@
}
// Remove too large logs
- $completeLogsPath = BMI_CONFIG_DIR . DIRECTORY_SEPARATOR . 'complete_logs.log';
+ $completeLogsPath = BMI_CONFIG_DIR . DIRECTORY_SEPARATOR . 'complete_logs.' . BMI_LOGS_SUFFIX . '.log';
if (file_exists($completeLogsPath) && (filesize($completeLogsPath) / 1024 / 1024) >= 3) {
@unlink($completeLogsPath);
}
- $backgroundLogsPath = BMI_CONFIG_DIR . DIRECTORY_SEPARATOR . 'background-errors.log';
+ $backgroundLogsPath = BMI_CONFIG_DIR . DIRECTORY_SEPARATOR . 'background-errors.' . BMI_LOGS_SUFFIX . '.log';
if (file_exists($backgroundLogsPath) && (filesize($backgroundLogsPath) / 1024 / 1024) >= 3) {
@unlink($backgroundLogsPath);
}
@@ -2411,7 +2412,7 @@
ini_set('display_errors', 1);
ini_set('error_reporting', E_ALL);
ini_set('log_errors', 1);
- ini_set('error_log', BMI_CONFIG_DIR . DIRECTORY_SEPARATOR . 'complete_logs.log');
+ ini_set('error_log', BMI_CONFIG_DIR . DIRECTORY_SEPARATOR . 'complete_logs.' . BMI_LOGS_SUFFIX . '.log');
}
// Double check for .space_check file
@@ -2948,7 +2949,7 @@
ini_set('display_errors', 1);
ini_set('error_reporting', E_ALL);
ini_set('log_errors', 1);
- ini_set('error_log', BMI_CONFIG_DIR . DIRECTORY_SEPARATOR . 'complete_logs.log');
+ ini_set('error_log', BMI_CONFIG_DIR . DIRECTORY_SEPARATOR . 'complete_logs.' . BMI_LOGS_SUFFIX . '.log');
}
@@ -3673,6 +3674,10 @@
$errors = 0;
$created = false;
+ if ($dir_path == WP_CONTENT_DIR . DIRECTORY_SEPARATOR . 'backup-migration') {
+ return ['status' => 'msg', 'why' => __('For security reasons, please add a random string to the end of the default backup directory path. This will help protect your backup files from unauthorized access.', 'backup-backup'), 'level' => 'warning'];
+ }
+
if (!preg_match("/^[a-zA-Z0-9_ -/.]+$/", $dir_path)) {
return ['status' => 'msg', 'why' => __('Entered directory/path name does not match allowed characters (Local Storage).', 'backup-backup'), 'level' => 'warning'];
}
@@ -3801,6 +3806,14 @@
}
+ if (isset($this->post['pcloud'])) {
+ $pcloudenabled = $this->post['pcloud'];
+ if (!Dashboardbmi_set_config('STORAGE::EXTERNAL::PCLOUD', $pcloudenabled)) {
+ $errors++;
+ }
+ }
+
+
}
if (is_writable($dir_path)) {
@@ -3876,7 +3889,7 @@
if (file_exists($tmp_cur_dir . DIRECTORY_SEPARATOR . 'index.html')) @unlink($tmp_cur_dir . DIRECTORY_SEPARATOR . 'index.html');
if (file_exists($tmp_cur_dir)) @rmdir($tmp_cur_dir);
- if (file_exists($cur_dir . DIRECTORY_SEPARATOR . 'complete_logs.log')) @unlink($cur_dir . DIRECTORY_SEPARATOR . 'complete_logs.log');
+ if (file_exists($cur_dir . DIRECTORY_SEPARATOR . 'complete_logs.' . BMI_LOGS_SUFFIX . '.log')) @unlink($cur_dir . DIRECTORY_SEPARATOR . 'complete_logs.' . BMI_LOGS_SUFFIX . '.log');
if (file_exists($cur_dir)) @rmdir($cur_dir);
if (is_dir($cur_dir) && file_exists($cur_dir)) {
@@ -3941,6 +3954,7 @@
$uninstall_config = $this->post['uninstall_config'] === 'true' ? true : false; // OTHER:UNINSTALL:CONFIGS
$uninstall_backups = $this->post['uninstall_backups'] === 'true' ? true : false; // OTHER:UNINSTALL:BACKUPS
$use_new_search_replace_engine = $this->post['use_new_search_replace_engine'] === 'true' ? true : false; // OTHER::NEW_SEARCH_REPLACE_ENGINE
+ $use_new_database_export_engine = $this->post['use_new_database_export_engine'] === 'true' ? true : false; // OTHER::NEW_DATABASE_EXPORT_ENGINE
if ($experiment_timeout_hard === true) {
$experiment_timeout = false;
@@ -4085,6 +4099,10 @@
Logger::error('Backup Other DB Search Replace New Engine Error');
$error++;
}
+ if (!Dashboardbmi_set_config('OTHER::NEW_DATABASE_EXPORT_ENGINE', $use_new_database_export_engine)) {
+ Logger::error('Backup Other DB New Export Engine Error');
+ $error++;
+ }
if (has_action('bmi_premium_other_options')) {
do_action('bmi_premium_other_options', $this->post);
@@ -4992,6 +5010,9 @@
case 'gdrive-issues':
delete_transient('bmip_gd_issue');
break;
+ case 'pcloud-issues':
+ update_option('bmip_pcloud_dismiss_issue', true);
+ break;
case 'backupbliss-issues':
$backupbliss->removeNotice("invalid_key");
$backupbliss->removeNotice("invalid_permission");
@@ -5063,7 +5084,7 @@
}
- $allowedFiles = ['wp-config.php', '.htaccess', '.litespeed', '.default.json', 'driveKeys.php', 'dropboxKeys.php', '.autologin.php', '.migrationFinished', 'onedriveKeys.php', 'awsKeys.php', 'wasabiKeys.php', 'backupblissKeys.php', 'sftpKeys.php'];
+ $allowedFiles = ['wp-config.php', '.htaccess', '.litespeed', '.default.json', 'driveKeys.php', 'dropboxKeys.php', '.autologin.php', '.migrationFinished', 'onedriveKeys.php', 'awsKeys.php', 'wasabiKeys.php', 'backupblissKeys.php', 'sftpKeys.php', 'pcloudKeys.php'];
foreach (glob(BMI_TMP . DIRECTORY_SEPARATOR . '.*') as $filename) {
$basename = basename($filename);
@@ -5147,7 +5168,7 @@
}
- $allowedFiles = ['wp-config.php', '.htaccess', '.litespeed', '.default.json', 'driveKeys.php', 'dropboxKeys.php', '.autologin.php', '.migrationFinished', 'onedriveKeys.php','awsKeys.php', 'wasabiKeys.php', 'backupblissKeys.php', 'sftpKeys.php'];
+ $allowedFiles = ['wp-config.php', '.htaccess', '.litespeed', '.default.json', 'driveKeys.php', 'dropboxKeys.php', '.autologin.php', '.migrationFinished', 'onedriveKeys.php','awsKeys.php', 'wasabiKeys.php', 'backupblissKeys.php', 'sftpKeys.php', 'pcloudKeys.php'];
foreach (glob(BMI_TMP . DIRECTORY_SEPARATOR . '.*') as $filename) {
$basename = basename($filename);
@@ -5230,35 +5251,35 @@
$pluginGlobalLogs = 'does_not_exist';
$backgroundErrors = 'does_not_exist';
- if (file_exists(BMI_BACKUPS . '/latest.log')) {
- $latestBackupLogs = file_get_contents(BMI_BACKUPS . '/latest.log');
+ if (file_exists(BMI_BACKUPS . '/latest.' . BMI_LOGS_SUFFIX . '.log')) {
+ $latestBackupLogs = file_get_contents(BMI_BACKUPS . '/latest.' . BMI_LOGS_SUFFIX . '.log');
}
- if (file_exists(BMI_BACKUPS . '/latest_progress.log')) {
- $latestBackupProgress = file_get_contents(BMI_BACKUPS . '/latest_progress.log');
+ if (file_exists(BMI_BACKUPS . '/latest_progress.' . BMI_LOGS_SUFFIX . '.log')) {
+ $latestBackupProgress = file_get_contents(BMI_BACKUPS . '/latest_progress.' . BMI_LOGS_SUFFIX . '.log');
}
- if (file_exists(BMI_BACKUPS . '/latest_migration.log')) {
- $latestRestorationLogs = file_get_contents(BMI_BACKUPS . '/latest_migration.log');
+ if (file_exists(BMI_BACKUPS . '/latest_migration.' . BMI_LOGS_SUFFIX . '.log')) {
+ $latestRestorationLogs = file_get_contents(BMI_BACKUPS . '/latest_migration.' . BMI_LOGS_SUFFIX . '.log');
}
- if (file_exists(BMI_BACKUPS . '/latest_migration_progress.log')) {
- $latestRestorationProgress = file_get_contents(BMI_BACKUPS . '/latest_migration_progress.log');
+ if (file_exists(BMI_BACKUPS . '/latest_migration_progress.' . BMI_LOGS_SUFFIX . '.log')) {
+ $latestRestorationProgress = file_get_contents(BMI_BACKUPS . '/latest_migration_progress.' . BMI_LOGS_SUFFIX . '.log');
}
- if (file_exists(BMI_STAGING . '/latest_staging.log')) {
- $latestStagingLogs = file_get_contents(BMI_STAGING . '/latest_staging.log');
+ if (file_exists(BMI_STAGING . '/latest_staging.' . BMI_LOGS_SUFFIX . '.log')) {
+ $latestStagingLogs = file_get_contents(BMI_STAGING . '/latest_staging.' . BMI_LOGS_SUFFIX . '.log');
}
- if (file_exists(BMI_STAGING . '/latest_staging_progress.log')) {
- $latestStagingProgress = file_get_contents(BMI_STAGING . '/latest_staging_progress.log');
+ if (file_exists(BMI_STAGING . '/latest_staging_progress.' . BMI_LOGS_SUFFIX . '.log')) {
+ $latestStagingProgress = file_get_contents(BMI_STAGING . '/latest_staging_progress.' . BMI_LOGS_SUFFIX . '.log');
}
if (file_exists(BMI_CONFIG_PATH)) {
$currentPluginConfig = substr(file_get_contents(BMI_CONFIG_PATH), 8);
}
- $completeLogsPath = BMI_CONFIG_DIR . DIRECTORY_SEPARATOR . 'complete_logs.log';
+ $completeLogsPath = BMI_CONFIG_DIR . DIRECTORY_SEPARATOR . 'complete_logs.' . BMI_LOGS_SUFFIX . '.log';
if (file_exists($completeLogsPath)) {
$fileSize = filesize($completeLogsPath);
if ($fileSize <= 65535) {
@@ -5280,7 +5301,7 @@
}
}
- $backgroundLogsPath = BMI_CONFIG_DIR . DIRECTORY_SEPARATOR . 'background-errors.log';
+ $backgroundLogsPath = BMI_CONFIG_DIR . DIRECTORY_SEPARATOR . 'background-errors.' . BMI_LOGS_SUFFIX . '.log';
if (file_exists($backgroundLogsPath)) {
if ((filesize($backgroundLogsPath) / 1024 / 1024) <= 4) {
$backgroundErrors = file_get_contents($backgroundLogsPath);
--- a/backup-backup/includes/backup-process.php
+++ b/backup-backup/includes/backup-process.php
@@ -11,6 +11,7 @@
use BMIPluginDatabaseBMI_Database as Database;
use BMIPluginDatabaseBMI_Database_Exporter as BetterDatabaseExport;
use BMIPluginBackup_Migration_Plugin as BMP;
+ use BMIPluginDatabaseExportDatabaseExportProcessor;
use BMIPluginBMI_Pro_Core as Pro_Core;
use BMIPlugin AS BMI;
@@ -140,7 +141,7 @@
ini_set('display_errors', 1);
ini_set('error_reporting', E_ALL);
ini_set('log_errors', 1);
- ini_set('error_log', BMI_CONFIG_DIR . '/background-errors.log');
+ ini_set('error_log', BMI_CONFIG_DIR . '/background-errors.' . BMI_LOGS_SUFFIX . '.log');
}
}
@@ -578,7 +579,7 @@
if (strpos($_manifest, 'file://') !== false) $_manifest = substr($_manifest, 7);
$log_file = fopen($logs, 'w');
- fwrite($log_file, file_get_contents(BMI_BACKUPS . DIRECTORY_SEPARATOR . 'latest.log'));
+ fwrite($log_file, file_get_contents(BMI_BACKUPS . DIRECTORY_SEPARATOR . 'latest.' . BMI_LOGS_SUFFIX . '.log'));
fclose($log_file);
$files = [$logs, $_manifest];
@@ -1273,15 +1274,22 @@
$this->output->log("Iterating database...", 'INFO');
}
- // Require Database Manager
- require_once BMI_INCLUDES . DIRECTORY_SEPARATOR . 'database' . DIRECTORY_SEPARATOR . 'better-backup-v3.php';
$database_file_dir = $this->fixSlashes((dirname($database_file))) . DIRECTORY_SEPARATOR;
$better_database_files_dir = $database_file_dir . 'db_tables';
$better_database_files_dir = str_replace('file:', 'file://', $better_database_files_dir);
if (!is_dir($better_database_files_dir)) @mkdir($better_database_files_dir, 0755, true);
- $db_exporter = new BetterDatabaseExport($better_database_files_dir, $this->output, $this->dbit, intval($this->backupstart));
+ $dbEngine = 3;
+ if (Dashboardbmi_get_config('OTHER::NEW_DATABASE_EXPORT_ENGINE')) {
+ require_once BMI_INCLUDES . DIRECTORY_SEPARATOR . 'database' . DIRECTORY_SEPARATOR . 'export' . DIRECTORY_SEPARATOR . 'class-database-export-processor.php';
+ $dbEngine = 4;
+ $db_exporter = new DatabaseExportProcessor($better_database_files_dir, $this->output, intval($this->backupstart));
+ } else {
+ // Require Database Manager
+ require_once BMI_INCLUDES . DIRECTORY_SEPARATOR . 'database' . DIRECTORY_SEPARATOR . 'better-backup-v3.php';
+ $db_exporter = new BetterDatabaseExport($better_database_files_dir, $this->output, $this->dbit, intval($this->backupstart));
+ }
$dbBatchingEnabled = false;
if (Dashboardbmi_get_config('OTHER:BACKUP:DB:BATCHING') == 'true') {
@@ -1294,7 +1302,11 @@
if (BMI_CLI_REQUEST === true || $dbBatchingEnabled === false) {
- $results = $db_exporter->export();
+ if ($dbEngine === 4) {
+ $results = $db_exporter->exportAll();
+ } else {
+ $results = $db_exporter->export();
+ }
$this->output->log("Database backup finished", 'SUCCESS');
$this->dbitJustFinished = true;
@@ -1303,7 +1315,16 @@
} else {
- $results = $db_exporter->export($this->dbit, $this->dblast);
+ if ($dbEngine === 4) {
+ $results = $db_exporter->exportBatch();
+ // Backward compatibility for v3 engine
+ $this->dbit += 1;
+ $results['batchingStep'] = $this->dbit;
+ $results['finishedQuery'] = $this->dblast;
+ $results['dumpCompleted'] = $results['status'] === DatabaseExportProcessor::STATUS_COMPLETED;
+ } else {
+ $results = $db_exporter->export($this->dbit, $this->dblast);
+ }
$this->dbit = intval($results['batchingStep']);
$this->dblast = intval($results['finishedQuery']);
--- a/backup-backup/includes/config.php
+++ b/backup-backup/includes/config.php
@@ -185,12 +185,19 @@
if ($key !== false) unset($current_patch[$key]);
update_option('bmi_hotfixes', $current_patch);
}
+
+ $bmiLogFilesSuffix = bmi_get_config('STORAGE::LOCAL::LOGS::SUFFIX', $newConfigStaticPath);
+ if ($bmiLogFilesSuffix == false) {
+ $bmiLogFilesSuffix = bmi_config_random_string(10);
+ bmi_set_config('STORAGE::LOCAL::LOGS::SUFFIX', $bmiLogFilesSuffix);
+ }
if (!defined('BMI_BACKUPS_ROOT')) define('BMI_BACKUPS_ROOT', $localStoragePath);
if (!defined('BMI_CONFIG_DIR')) define('BMI_CONFIG_DIR', $localStoragePath);
if (!defined('BMI_BACKUPS')) define('BMI_BACKUPS', $localStoragePath . DIRECTORY_SEPARATOR . 'backups');
if (!defined('BMI_STAGING')) define('BMI_STAGING', $localStoragePath . DIRECTORY_SEPARATOR . 'staging');
if (!defined('BMI_TMP')) define('BMI_TMP', BMI_BACKUPS_ROOT . DIRECTORY_SEPARATOR . 'tmp');
+ if (!defined('BMI_LOGS_SUFFIX')) define('BMI_LOGS_SUFFIX', $bmiLogFilesSuffix);
$bmi_initial_config_dirpath = $localStoragePath;
$bmi_initial_config_filepath = $newConfigStaticPath;
--- a/backup-backup/includes/dashboard/chapter/other_config.php
+++ b/backup-backup/includes/dashboard/chapter/other_config.php
@@ -550,6 +550,18 @@
</span>
</label>
</div>
+ <div class="lh40">
+ <label for="bmi-use-new-database-export-engine">
+ <input type="checkbox" id="bmi-use-new-database-export-engine"<?php bmi_try_checked('OTHER::NEW_DATABASE_EXPORT_ENGINE'); ?> />
+ <span class="relative">
+ <?php esc_html_e("Enable the optimized Database Export engine to reduce memory usage and speed up table dumping.", 'backup-backup'); ?>
+ <span class="bmi-info-icon tooltip" tooltip="<?php echo esc_attr__(
+ "The optimized export engine streams table data in memory-efficient chunks rather than loading entire tables at once. Unlike the Search & Replace engine which focuses on data transformation, this engine is specifically designed to minimize peak memory consumption and accelerate the raw data dumping process during backup.",
+ 'backup-backup'
+ ); ?>"></span>
+ </span>
+ </label>
+ </div>
<div class="lh40">
<label for="bmi-db-single-file-backup">
--- a/backup-backup/includes/dashboard/chapter/where_config.php
+++ b/backup-backup/includes/dashboard/chapter/where_config.php
@@ -131,6 +131,29 @@
</div>
</div>
<?php } ?>
+ <?php
+ if (has_action('bmi_pro_pcloud_template')) {
+ do_action('bmi_pro_pcloud_template');
+ } else {
+ ?>
+ <div class="tab2-item">
+ <div class="already_ready"></div>
+ <div class="bg_clock_day2">
+ <img src="<?php echo $this->get_asset('images', 'premium.svg') ?>" alt="crown" class="crown_img" height="30px" width="30px">
+ <?php echo BMI_ALREADY_IN_PRO; ?>
+ </div>
+ <div class="d-flex ia-center">
+ <img src="<?php echo $this->get_asset('images', 'pcloud.svg') ?>" alt="logo" class="tab2-img"> <span class="ml25 title_whereStored">pCloud</span>
+ <img src="<?php echo $this->get_asset('images', 'premium.svg') ?>" alt="logo" class="crown2">
+ </div>
+ <div class="ia-center">
+ <div class="b2 bmi-switch"><input type="checkbox" disabled="disabled" class="checkbox">
+ <div class="bmi-knobs"><span></span></div>
+ <div class="bmi-layer_str"></div>
+ </div>
+ </div>
+ </div>
+ <?php } ?>
<?php
if (has_action('bmi_pro_sftp_template')) {
--- a/backup-backup/includes/dashboard/templates/backup-row-template.php
+++ b/backup-backup/includes/dashboard/templates/backup-row-template.php
@@ -14,6 +14,7 @@
$clouds["ONEDRIVE"] = [ "name" => "OneDrive", "icon" => "one-drive-mono.svg" ];
$clouds["DROPBOX"] = [ "name" => "Dropbox", "icon" => "dropbox-mono.svg" ];
$clouds["FTP"] = [ "name" => "FTP", "icon" => "ftp-mono.svg" ];
+$clouds["PCLOUD"] = [ "name" => "pCloud", "icon" => "pcloud-mono.svg" ];
$clouds["AWS"] = [ "name" => "Amazon S3", "icon" => "amazon-s3-mono.svg" ];
$clouds["WASABI"] = [ "name" => "Wasabi", "icon" => "wasabi-s3-mono.svg" ];
$clouds["SFTP"] = [ "name" => "SFTP", "icon" => "sftp-mono.svg" ];
--- a/backup-backup/includes/database/export/class-database-export-engine.php
+++ b/backup-backup/includes/database/export/class-database-export-engine.php
@@ -0,0 +1,554 @@
+<?php
+
+namespace BMIPluginDatabaseExport;
+
+require_once __DIR__ . '/interface-database-export-repository.php';
+require_once __DIR__ . '/class-database-export-repository.php';
+require_once BMI_INCLUDES . '/progress/class-file-progress-storage.php';
+
+use BMIPluginBackup_Migration_Plugin as BMP;
+use BMIPluginProgressProgressStorageInterface;
+use BMIPluginProgressFileProgressStorage;
+/**
+ * Class DatabaseExportEngine
+ *
+ * Handles the export of a single database table to a SQL file.
+ * Supports resumable batch processing with key-based or offset-based pagination.
+ *
+ */
+class DatabaseExportEngine
+{
+ const STATUS_IN_PROGRESS = 'in_progress';
+ const STATUS_COMPLETED = 'completed';
+ const PAGINATION_KEY_BASED = 'key_based';
+ const PAGINATION_OFFSET_BASED = 'offset_based';
+
+ /**
+ * Default number of rows to fetch per batch.
+ */
+ const DEFAULT_PAGE_SIZE = 5000;
+
+ /**
+ * Default max bytes per SQL file chunk before flushing.
+ */
+ const DEFAULT_MAX_QUERY_SIZE = 1048576; // 1 MB
+
+ /**
+ * @var string The table name to export.
+ */
+ private $table;
+
+ /**
+ * @var DatabaseExportRepositoryInterface The database repository.
+ */
+ private $repository;
+
+ /**
+ * @var ProgressStorageInterface The state storage handler.
+ */
+ private $stateStorage;
+
+ /**
+ * @var int The number of rows to fetch per batch.
+ */
+ private $pageSize;
+
+ /**
+ * @var int The max query/insert size in bytes before writing to disk.
+ */
+ private $maxQuerySize;
+
+ /**
+ * @var string The storage directory for SQL output files.
+ */
+ private $outputDir;
+
+ /**
+ * @var string The time-based prefix for temporary table names.
+ */
+ private $tablePrefix;
+
+ /**
+ * Constructor.
+ *
+ * @param string $table The table name to export.
+ * @param string $outputDir The directory to write SQL files to.
+ * @param string $tablePrefix The time-based prefix for temp table names.
+ * @param DatabaseExportRepositoryInterface|null $repository Optional repository instance.
+ * @param ProgressStorageInterface|null $stateStorage Optional state storage instance.
+ * @param int|null $pageSize Optional rows per batch.
+ * @param int|null $maxQuerySize Optional max query size in bytes.
+ */
+ public function __construct(
+ $table,
+ $outputDir,
+ $tablePrefix,
+ $repository = null,
+ $stateStorage = null,
+ $pageSize = null,
+ $maxQuerySize = null
+ ) {
+ $this->table = $table;
+ $this->outputDir = rtrim($outputDir, DIRECTORY_SEPARATOR);
+ $this->tablePrefix = $tablePrefix;
+ $this->repository = $repository !== null ? $repository : new DatabaseExportRepository();
+ $this->stateStorage = $stateStorage !== null ? $stateStorage : new FileProgressStorage(
+ null,
+ 'db-export-' . preg_replace('/[^A-Za-z0-9_-]/', '', $table) . '.json'
+ );
+ $this->pageSize = $pageSize !== null ? $pageSize : (defined('BMI_DB_MAX_ROWS_PER_QUERY') ? BMI_DB_MAX_ROWS_PER_QUERY : self::DEFAULT_PAGE_SIZE);
+ $this->maxQuerySize = $maxQuerySize !== null ? $maxQuerySize : self::DEFAULT_MAX_QUERY_SIZE;
+ }
+
+ /**
+ * Returns the default structure for the export state.
+ *
+ * @param array $tableInfo Column info from the repository.
+ * @param int $rowCount Total row count.
+ * @return array The default state array.
+ */
+ private function getDefaultState($tableInfo, $rowCount)
+ {
+ return [
+ 'status' => self::STATUS_IN_PROGRESS,
+ 'table' => $this->table,
+ 'time' => [
+ 'start' => microtime(true),
+ 'end' => 0
+ ],
+ 'columns' => $tableInfo['columns'],
+ 'primary_key' => $tableInfo['primary_key'],
+ 'auto_increment_primary_key' => $tableInfo['auto_increment_primary_key'],
+ 'pagination_type' => $tableInfo['auto_increment_primary_key'] !== false
+ ? self::PAGINATION_KEY_BASED
+ : self::PAGINATION_OFFSET_BASED,
+ 'key_based' => [
+ 'last_processed_key' => 0,
+ ],
+ 'offset_based' => [
+ 'offset' => 0,
+ ],
+ 'rows_count' => $rowCount,
+ 'rows_exported' => 0,
+ 'total_batches' => (int) ceil($rowCount / $this->pageSize),
+ 'current_batch' => 0,
+ 'page_size' => $this->pageSize,
+ 'recipe_written' => false,
+ 'output_file' => $this->buildOutputFilePath(),
+ ];
+ }
+
+ /**
+ * Retrieves the latest state or initializes a new one.
+ *
+ * @return array The state array.
+ */
+ private function getOrInitState()
+ {
+ $stored = $this->stateStorage->load();
+ if ($stored !== null) {
+ return $stored;
+ }
+
+ $tableInfo = $this->repository->getTableColumnsInfo($this->table);
+ $rowCount = $this->getFilteredRowCount();
+
+ return $this->getDefaultState($tableInfo, $rowCount);
+ }
+
+ /**
+ * Gets the row count, applying exclusion filters if active.
+ *
+ * Delegates to the 'bmip_smart_exclusion_post_revisions_condition' filter hook
+ * to allow the pro plugin or other extensions to add WHERE conditions.
+ *
+ * @return int The row count.
+ */
+ private function getFilteredRowCount()
+ {
+ $conditions = $this->getExclusionConditions();
+
+ if (!empty($conditions)) {
+ return $this->repository->getRowCount($this->table, implode(' AND ', $conditions));
+ }
+
+ return $this->repository->getRowCount($this->table);
+ }
+
+ /**
+ * Gets exclusion conditions from WordPress filters.
+ *
+ * Delegates to the 'bmip_smart_exclusion_post_revisions_condition' filter hook,
+ * allowing the pro plugin or other extensions to add WHERE conditions
+ * for specific tables (e.g., excluding post revisions).
+ *
+ * @return array The WHERE conditions array.
+ */
+ private function getExclusionConditions()
+ {
+ $conditions = apply_filters('bmip_smart_exclusion_post_revisions_condition', [], $this->table);
+
+ return is_array($conditions) ? $conditions : [];
+ }
+
+ /**
+ * Exports one batch of the table.
+ *
+ * Call this method repeatedly until the returned status is 'completed'.
+ * Each call processes one page of rows and streams them directly to the output file.
+ *
+ * @return array{status: string, table: string, batch: int, total_batches: int, rows_exported: int, rows_count: int, output_file: string}
+ */
+ public function exportBatch()
+ {
+ $state = $this->getOrInitState();
+
+ // Write CREATE TABLE recipe on first batch
+ if (!$state['recipe_written']) {
+ $this->writeRecipe($state);
+ $state['recipe_written'] = true;
+ }
+
+ // Empty table — mark completed immediately
+ if ($state['rows_count'] === 0) {
+ return $this->completeExport($state);
+ }
+
+ // Fetch and stream one batch
+ $sql = $this->buildFetchQuery($state);
+ $this->repository->execute('SET foreign_key_checks = 0');
+
+ try {
+ $batchResult = $this->streamBatchToFile($sql, $state);
+ } finally {
+ $this->repository->execute('SET foreign_key_checks = 1');
+ }
+
+ // Update state from batch result
+ $state['rows_exported'] += $batchResult['rows_written'];
+ $state['current_batch']++;
+
+ // Update pagination cursor
+ if ($state['pagination_type'] === self::PAGINATION_KEY_BASED && $batchResult['last_key'] !== null) {
+ $state['key_based']['last_processed_key'] = $batchResult['last_key'];
+ } elseif ($state['pagination_type'] === self::PAGINATION_OFFSET_BASED) {
+ $state['offset_based']['offset'] += $batchResult['rows_written'];
+ }
+
+ // Check if export is complete
+ if ($batchResult['rows_written'] < $this->pageSize || $state['rows_exported'] >= $state['rows_count']) {
+ return $this->completeExport($state);
+ }
+
+ // Save progress and return in-progress
+ $this->stateStorage->save($state);
+ return $this->buildResult(self::STATUS_IN_PROGRESS, $state);
+ }
+
+ /**
+ * Writes the CREATE TABLE recipe to the output file.
+ *
+ * @param array $state The current state.
+ */
+ private function writeRecipe(&$state)
+ {
+ $createSql = $this->repository->getCreateTableStatement($this->table);
+ if ($createSql === null) {
+ return;
+ }
+
+ $prefixedTable = $this->tablePrefix . '_' . $this->table;
+ $escapedOriginal = $this->repository->escapeIdentifier($this->table);
+ $escapedPrefixed = $this->repository->escapeIdentifier($prefixedTable);
+
+ // Replace table name in CREATE statement
+ $createSql = str_replace($escapedOriginal, $escapedPrefixed, $createSql);
+
+ // Build recipe header
+ $recipe = "/* CUSTOM VARS START */n";
+ $recipe .= "/* REAL_TABLE_NAME: " . $escapedOriginal . "; */n";
+ $recipe .= "/* PRE_TABLE_NAME: " . $escapedPrefixed . "; */n";
+ $recipe .= "/* CUSTOM VARS END */nn";
+
+ // Make it IF NOT EXISTS and clean up formatting
+ $createStatement = 'CREATE TABLE IF NOT EXISTS ' . substr($createSql, 13);
+ $createStatement = str_replace("n ", '', $createStatement);
+ $createStatement = str_replace("n", '', $createStatement);
+
+ $recipe .= $createStatement . ";n";
+
+ $file = fopen($state['output_file'], 'w');
+ if ($file === false) {
+ throw new RuntimeException(
+ sprintf('Failed to open output file for recipe: %s', $state['output_file'])
+ );
+ }
+ fwrite($file, $recipe);
+ fclose($file);
+ }
+
+ /**
+ * Builds the SQL query for fetching a batch of rows.
+ *
+ * @param array $state The current state.
+ * @return string The SQL query.
+ */
+ private function buildFetchQuery($state)
+ {
+ $table = $this->repository->escapeIdentifier($this->table);
+ $sql = 'SELECT * FROM ' . $table;
+
+ $conditions = [];
+
+ // Pagination WHERE clause
+ if ($state['pagination_type'] === self::PAGINATION_KEY_BASED) {
+ $lastKey = (int) $state['key_based']['last_processed_key'];
+ $pk = $state['auto_increment_primary_key'];
+ $conditions[] = sprintf('`%s` > %d', str_replace('`', '``', $pk), $lastKey);
+ }
+
+ // Exclusion conditions (e.g., post revisions via filters)
+ $conditions = array_merge($conditions, $this->getExclusionConditions());
+
+ if (!empty($conditions)) {
+ $sql .= ' WHERE ' . implode(' AND ', $conditions);
+ }
+
+ // ORDER BY for key-based pagination
+ if ($state['pagination_type'] === self::PAGINATION_KEY_BASED) {
+ $pk = $state['auto_increment_primary_key'];
+ $sql .= sprintf(' ORDER BY `%s` ASC', str_replace('`', '``', $pk));
+ }
+
+ // LIMIT clause
+ if ($state['pagination_type'] === self::PAGINATION_KEY_BASED) {
+ $sql .= sprintf(' LIMIT %d', $this->pageSize);
+ } else {
+ $offset = (int) $state['offset_based']['offset'];
+ $sql .= sprintf(' LIMIT %d, %d', $offset, $this->pageSize);
+ }
+
+ return $sql;
+ }
+
+ /**
+ * Streams query results directly into the SQL output file.
+ *
+ * Instead of buffering all rows in memory (array_merge), this method
+ * writes INSERT statements to disk as rows are fetched from the DB.
+ * Uses strlen(serialize()) for accurate size tracking.
+ *
+ * @param string $sql The SELECT query.
+ * @param array $state The current state.
+ * @return array{rows_written: int, last_key: mixed, bytes_written: int}
+ */
+ private function streamBatchToFile($sql, &$state)
+ {
+ $file = fopen($state['output_file'], 'a+');
+ if ($file === false) {
+ throw new RuntimeException(
+ sprintf('Failed to open output file for writing: %s', $state['output_file'])
+ );
+ }
+
+ $columns = $state['columns'];
+ $prefixedTable = $this->tablePrefix . '_' . $this->table;
+ $insertPrefix = 'INSERT INTO ' . $this->repository->escapeIdentifier($prefixedTable) . ' ';
+
+ $rowsWritten = 0;
+ $bytesWritten = 0;
+ $lastKey = null;
+ $currentInsertSize = 0;
+ $insertOpen = false;
+ $columnHeaderWritten = false;
+ $columnHeader = '';
+ $pkIndex = $this->findPrimaryKeyIndex($state);
+
+ // Build column header once
+ $columnNames = [];
+ foreach ($columns as $col) {
+ $columnNames[] = $this->repository->escapeIdentifier($col['name']);
+ }
+ $columnHeader = '(' . implode(', ', $columnNames) . ')';
+
+ // Stream rows directly from DB to file via generator
+ $rows = $this->repository->fetchRows($sql, true);
+
+ // Warning: when useUnbuffered is true, the returned $rows is a generator that yields one row at a time.
+ // mysqli will not allow any other queries to run until the generator is fully consumed or destroyed.
+ // This means we cannot run any additional queries until this loop finishes.
+ // This is fine for our use case since we are only streaming data to file.
+ foreach ($rows as $row) {
+ $rowsWritten++;
+
+ // Track the last primary key value for key-based pagination
+ if ($pkIndex !== null && isset($row[$pkIndex])) {
+ $lastKey = $row[$pkIndex];
+ }
+
+ // Build the values tuple for this row
+ $values = $this->buildValuesTuple($row, $columns);
+ $rowSize = strlen(serialize($values));
+
+ // Decide whether to start a new INSERT or append to current one
+ if (!$insertOpen) {
+ // Start new INSERT statement
+ $stmt = $insertPrefix . $columnHeader . " VALUES (" . $values . ")";
+ $insertOpen = true;
+ $currentInsertSize = strlen($stmt);
+ } elseif (($currentInsertSize + $rowSize + 3) > $this->maxQuerySize) {
+ // Current INSERT too large, close it and start new one
+ $written = fwrite($file, ";n");
+ $bytesWritten += $written;
+
+ $stmt = $insertPrefix . $columnHeader . " VALUES (" . $values . ")";
+ $currentInsertSize = strlen($stmt);
+ } else {
+ // Append to current INSERT
+ $stmt = ",(" . $values . ")";
+ $currentInsertSize += strlen($stmt);
+ }
+
+ $written = fwrite($file, $stmt);
+ $bytesWritten += $written;
+ }
+
+ // Close the final INSERT statement
+ if ($insertOpen) {
+ $written = fwrite($file, ";n");
+ $bytesWritten += $written;
+ }
+
+ fclose($file);
+
+ return [
+ 'rows_written' => $rowsWritten,
+ 'last_key' => $lastKey,
+ 'bytes_written' => $bytesWritten,
+ ];
+ }
+
+ /**
+ * Builds the SQL values tuple string for a single row.
+ *
+ * Uses esc_sql/real_escape_string for string values.
+ * Handles NULL, numeric, and string types based on column metadata.
+ *
+ * @param array $row Numerically-indexed row data.
+ * @param array $columns Column metadata from getTableColumnsInfo().
+ * @return string The comma-separated values string (without outer parentheses).
+ */
+ private function buildValuesTuple($row, $columns)
+ {
+ $parts = [];
+ $columnCount = count($columns);
+
+ for ($i = 0; $i < $columnCount; $i++) {
+ $value = isset($row[$i]) ? $row[$i] : null;
+
+ if ($value === null) {
+ $parts[] = 'NULL';
+ } elseif ($columns[$i]['is_numeric'] && is_numeric($value)) {
+ if (strpos($value, '.') !== false || stripos($value, 'e') !== false) {
+ // Float value
+ $floatVal = floatval($value);
+ $parts[] = is_infinite($floatVal) ? (string) PHP_FLOAT_MAX : (string) $floatVal;
+ } else {
+ // Integer value
+ $intVal = intval($value);
+ $parts[] = (string) $intVal;
+ }
+ } else {
+ // String value — escape and quote
+ $parts[] = "'" . $this->repository->escape($value) . "'";
+ }
+ }
+
+ return implode(',', $parts);
+ }
+
+ /**
+ * Finds the index of the primary key column in the columns array.
+ *
+ * @param array $state The current state.
+ * @return int|null The index, or null if no auto-increment PK.
+ */
+ private function findPrimaryKeyIndex($state)
+ {
+ if ($state['auto_increment_primary_key'] === false) {
+ return null;
+ }
+
+ foreach ($state['columns'] as $index => $col) {
+ if ($col['name'] === $state['auto_increment_primary_key']) {
+ return $index;
+ }
+ }
+
+ return null;
+ }
+
+ /**
+ * Marks the export as completed and clears state storage.
+ *
+ * @param array $state The current state.
+ * @return array The result array with completed status.
+ */
+ private function completeExport(&$state)
+ {
+ $state['status'] = self::STATUS_COMPLETED;
+ $state['time']['end'] = microtime(true);
+ $this->stateStorage->clear();
+
+ return $this->buildResult(self::STATUS_COMPLETED, $state);
+ }
+
+ /**
+ * Builds the result array returned from exportBatch().
+ *
+ * @param string $status The export status.
+ * @param array $state The current state.
+ * @return array The result array.
+ */
+ private function buildResult($status, $state)
+ {
+ return [
+ 'status' => $status,
+ 'table' => $state['table'],
+ 'batch' => $state['current_batch'],
+ 'total_batches' => $state['total_batches'],
+ 'rows_exported' => $state['rows_exported'],
+ 'rows_count' => $state['rows_count'],
+ 'output_file' => $state['output_file'],
+ ];
+ }
+
+ /**
+ * Builds the output file path for the SQL dump.
+ *
+ * @return string The file path.
+ */
+ private function buildOutputFilePath()
+ {
+ $friendlyName = preg_replace('/[^A-Za-z0-9_-]/', '', $this->table);
+ return $this->outputDir . DIRECTORY_SEPARATOR . $friendlyName . '.sql';
+ }
+
+ /**
+ * Gets the output file path.
+ *
+ * @return string The output file path.
+ */
+ public function getOutputFilePath()
+ {
+ return $this->buildOutputFilePath();
+ }
+
+ /**
+ * Resets any stored state for this table (for clean re-export).
+ */
+ public function reset()
+ {
+ $this->stateStorage->clear();
+ }
+}
--- a/backup-backup/includes/database/export/class-database-export-processor.php
+++ b/backup-backup/includes/database/export/class-database-export-processor.php
@@ -0,0 +1,566 @@
+<?php
+
+namespace BMIPluginDatabaseExport;
+
+require_once __DIR__ . '/interface-database-export-repository.php';
+require_once __DIR__ . '/class-database-export-repository.php';
+require_once __DIR__ . '/class-database-export-engine.php';
+require_once BMI_INCLUDES . '/progress/class-file-progress-storage.php';
+
+use BMIPluginBMI_Logger as Logger;
+use BMIPluginDashboard as Dashboard;
+use BMIPluginStagingBMI_Staging as Staging;
+use BMIPluginProgressProgressStorageInterface;
+use BMIPluginProgressFileProgressStorage;
+
+/**
+ * Class DatabaseExportProcessor
+ *
+ * Orchestrates database export across all tables.
+ * Handles table discovery, exclusion rules, staging site filtering, progress tracking,
+ * and logging. Delegates per-table export to DatabaseExportEngine.
+ *
+ * Architecture:
+ *
+ * DatabaseExportProcessor (Orchestrator: tables loop, logging, progress)
+ * └── DatabaseExportEngine (Engine: single-table batching, pagination, streaming)
+ * ├── DatabaseExportRepository (Data: SQL queries, connections, escape)
+ * └── FileProgressStorage (State: file-based JSON persistence)
+ *
+ * Usage:
+ * $processor = new DatabaseExportProcessor($outputDir, $logger);
+ * do {
+ * $result = $processor->exportBatch();
+ * } while ($result['status'] !== 'completed');
+ * $files = $result['files'];
+ *
+ * Or for non-batched (all at once):
+ * $result = $processor->exportAll();
+ * $files = $result['files'];
+ */
+class DatabaseExportProcessor
+{
+ const STATUS_IN_PROGRESS = 'in_progress';
+ const STATUS_COMPLETED = 'completed';
+
+ /**
+ * @var string The output directory for SQL files.
+ */
+ private $outputDir;
+
+ /**
+ * @var object The logger instance.
+ */
+ private $logger;
+
+ /**
+ * @var DatabaseExportRepositoryInterface The shared repository instance.
+ */
+ private $repository;
+
+ /**
+ * @var ProgressStorageInterface The master state storage.
+ */
+ private $stateStorage;
+
+ /**
+ * @var callable|null Optional factory for creating engine instances (for testing).
+ */
+ private $engineFactory;
+
+ /**
+ * @var string The time-based prefix for temporary table names.
+ */
+ private $tablePrefix;
+
+ /**
+ * @var bool Whether debug mode is enabled.
+ */
+ private $debugEnabled;
+
+ /**
+ * Constructor.
+ *
+ * @param string $outputDir Directory to write SQL files.
+ * @param object $logger Logger instance.
+ * @param string|int|null $tablePrefix Optional time prefix. Defaults to current timestamp.
+ * @param DatabaseExportRepositoryInterface|null $repository Optional repository.
+ * @param ProgressStorageInterface|null $stateStorage Optional master state storage.
+ * @param callable|null $engineFactory Optional factory: fn(string $table) => DatabaseExportEngine
+ * @param bool|null $debugEnabled Optional debug mode flag. Defaults to false.
+ */
+ public function __construct(
+ $outputDir,
+ $logger,
+ $tablePrefix = null,
+ $repository = null,
+ $stateStorage = null,
+ $engineFactory = null,
+ $debugEnabled = null
+ ) {
+ $this->outputDir = rtrim($outputDir, DIRECTORY_SEPARATOR);
+ $this->logger = $logger;
+ $this->repository = $repository !== null ? $repository : new DatabaseExportRepository();
+ $this->stateStorage = $stateStorage !== null ? $stateStorage : new FileProgressStorage(
+ null,
+ 'db-export-master-state.json'
+ );
+ $this->engineFactory = $engineFactory;
+ $this->tablePrefix = $tablePrefix !== null ? (string) $tablePrefix : (string) time();
+ $this->debugEnabled = $debugEnabled !== null ? (bool) $debugEnabled : $this->isDebugMode();
+ }
+
+ /**
+ * Runs the entire export in one call (non-batched mode).
+ *
+ * Iterates all tables and exports them completely.
+ *
+ * @return array{status: string, files: array, total_tables: int, total_rows: int, total_queries: int, total_size_mb: float}
+ */
+ public function exportAll()
+ {
+ $this->logger->log('Starting full database export (non-batched mode)', 'STEP');
+ $startTime = microtime(true);
+
+ // Discover tables
+ $tables = $this->discoverTables();
+ $this->logger->log(
+ sprintf('Found %d tables to export', count($tables)),
+ 'INFO'
+ );
+
+ $files = [];
+ $totalRows = 0;
+ $totalQueries = 0;
+ $totalSizeMb = 0;
+
+ foreach ($tables as $index => $tableData) {
+ $tableName = $tableData['name'];
+ $this->logger->log(
+ sprintf(
+ 'Exporting table: %s (%d/%d, %.2f MB)',
+ $tableName,
+ $index + 1,
+ count($tables),
+ $tableData['size