Any host, no size limitation, no premium versions. * Version: 3.2.0 * Requires at least: 5.3 * Requires PHP: 7.4 * Author: 1ClickMigration * Author URI: https://1clickmigration.com/ * Text Domain: 1-click-migration */ namespace OCM; use Exception; use GuzzleHttp\Client; use GuzzleHttp\Psr7\Request; if (!defined('WPINC')) { die; } define('OCM_PLUGIN_ROOT', plugin_dir_path(__FILE__)); define('OCM_PLUGIN_WRITABLE_PATH', WP_CONTENT_DIR . '/tmp/'); define('OCM_MAIN_FILE', __FILE__); define('OCM_SET_TIME_LIMIT', 900); define('OCM_WORKER_TIME_BUDGET', 20); require_once __DIR__ . '/inc/handler/class-ocm-private-dir.php'; define('OCM_DEBUG_LOG_FILENAME', 'ocm_debug.log'); // This site's private directory (log, restore worker lock), inside the // .ocm-private shared with sibling installs. define('OCM_DEBUG_LOG_DIR', OCM_Private_Dir::site_dir_for(ABSPATH)); define('OCM_DEBUG_LOG_FILE', OCM_DEBUG_LOG_DIR . '/' . OCM_DEBUG_LOG_FILENAME); define('OCM_DEBUG_LOG_FILE_URL', ''); define('OCM_WP_CONFIG_FILE', ABSPATH . 'wp-config.php'); define('OCM_API_ENDPOINT', 'https://1clickmigration.com/api/'); require 'vendor/autoload.php'; require_once __DIR__ . '/inc/admin/class-admin-page.php'; require_once __DIR__ . '/inc/backup/class-ocm-backup.php'; require_once __DIR__ . '/inc/s3/class-ocm-s3.php'; require_once __DIR__ . '/inc/db/class-ocm-db-import.php'; require_once __DIR__ . '/inc/db/class-ocm-db.php'; require_once __DIR__ . '/inc/db/class-ocm-extra-domains.php'; require_once __DIR__ . '/inc/db/class-ocm-url-manifest.php'; require_once __DIR__ . '/inc/backup/class-ocm-restore-precheck.php'; require_once __DIR__ . '/inc/handler/class-error-handler.php'; require_once __DIR__ . '/inc/handler/class-ocm-debug-mode.php'; // If the function it's not available, require it. if ( ! function_exists( 'download_url' ) ) { require_once ABSPATH . 'wp-admin/includes/file.php'; } class One_Click_Migration { const MU_PLUGIN_ISOLATION_FILE = 'ocm-plugin-isolation.php'; const BACKUP_ISOLATION_TOKEN_OPTION = 'ocm_backup_isolation_token'; const BACKUP_ISOLATION_TOKEN_HASH_OPTION = 'ocm_backup_isolation_token_hash'; const BACKUP_ISOLATION_ALLOWED_PLUGINS_OPTION = 'ocm_backup_isolation_allowed_plugins'; /** * Seconds between two S3 copies of the debug log from one request * (filter: ocm_log_upload_interval; 0 uploads on every line). Each copy * PUTs the whole log, about 0.3-0.5 s on a typical host. */ const LOG_UPLOAD_INTERVAL = 10; /** * Lines that go to S3 as soon as they are written: the terminal markers * 1CmSyncers/monitor_lib.php alerts on (latestTerminalEvent()). Every line * after one of them in the same request goes at once too: the monitor reads * the lines that follow it (review_ask: none (files-only) / (stopped)). */ const LOG_UPLOAD_NOW_MARKERS = array( 'Backup completed', 'Backup failed:', 'Restore completed', 'Restore stopped', 'Restore failed:', 'Restore finished without:', ); /** @var OCM_BackgroundBackup */ public static $process_backup_single; /**@var OCM_BackgroundRestore */ public static $process_restore_single; /** @var int Last transfer_heartbeat() write, for its 10-second throttle. */ private static $last_transfer_heartbeat = 0; /** @var string Presigned log URL this request last uploaded the log to. */ private static $log_uploaded_url = ''; /** @var float microtime(true) when that upload finished. */ private static $log_uploaded_at = 0.0; /** * @var array|null Lines written but not yet in S3: the log URL, and the * local log's size and first bytes when they were written. */ private static $log_upload_pending = null; /** @var int 0 = no shutdown flush registered, 1 = registered, 2 = running. */ private static $log_shutdown_state = 0; /** @var bool This request logged a LOG_UPLOAD_NOW_MARKERS line. */ private static $log_terminal_written = false; /** @var string */ private static $version; /** @var int */ private static $default_max_execution_time; /** @var int */ private static $current_max_execution_time; private static $error_handler; public static $is_stop_and_reset; public function __construct() { // Move an upgrade-era public plugin log before any admin page or new // migration write can expose/use it. if (is_file(OCM_PLUGIN_ROOT . OCM_DEBUG_LOG_FILENAME)) { self::ensure_private_log_directory(); } $this->fire_hooks(); $this->get_version_from_php_doc(); register_activation_hook(__FILE__, array($this, 'install')); register_deactivation_hook(__FILE__, array($this, 'uninstall')); self::set_current_max_execution_time(); } public function fire_hooks() { add_action('admin_menu', array(OCM_Admin_Page::class, 'add_admin_page')); add_action('admin_init', array(__CLASS__, 'register_settings')); add_action('admin_init', array(OCM_Backup::class, 'remove_legacy_restore_workspace')); // add_action('admin_init', array(__CLASS__, 'ensure_mu_plugin_isolation_shim')); // disabled while debugging restore add_action('admin_enqueue_scripts', array(__CLASS__, 'admin_styles')); add_action('template_redirect', array(__CLASS__, 'handle_maintenance_mode')); add_action('admin_post_start_backup', array(OCM_Backup::class, 'start_backup')); add_action('wp_ajax_ocm_loopback_check', array(__CLASS__, 'loopback_check')); add_action('wp_ajax_nopriv_ocm_loopback_check', array(__CLASS__, 'loopback_check')); add_action('wp_ajax_ocm_prepare_restore', array(OCM_Restore_Precheck::class, 'prepare')); add_action('wp_ajax_ocm_apply_extra_domains', array(OCM_Backup::class, 'apply_extra_domains')); add_action('admin_post_start_restore', array(OCM_Backup::class, 'start_restore')); add_action(OCM_Backup::WORKER_WATCHDOG_HOOK, array(OCM_Backup::class, 'run_worker_watchdog')); add_action(OCM_Backup::RESTORE_WATCHDOG_HOOK, array(OCM_Backup::class, 'run_worker_watchdog')); add_action('admin_post_cancel_actions', array(OCM_Backup::class, 'cancel_actions')); add_action('admin_post_ocm_download_log', array(__CLASS__, 'download_log')); add_action('plugins_loaded', array(__CLASS__, 'init_background_jobs')); add_action('plugins_loaded', array(__CLASS__, 'init_handler')); add_action('rest_api_init', static function () { register_rest_route('ocm/v1', '/progress/', array( 'methods' => 'GET', 'callback' => array(One_Click_Migration::class, 'progress_endpoint'), 'permission_callback' => function() { return is_user_logged_in() && current_user_can('manage_options'); }, )); }); add_action( 'admin_notices', array(OCM_Backup::class, 'add_file_not_found_notice')); // Review ask (shared with the restore-success modal flags): permanent // dismiss handling on every admin screen + persistent admin notice. add_action('admin_init', array(__CLASS__, 'handle_review_dismiss')); add_action('admin_notices', array(__CLASS__, 'maybe_show_review_notice')); } public function get_version_from_php_doc() { if ($fp = fopen(__FILE__, 'rb')) { $file_data = fread($fp, 1024); if (preg_match("/Version: ([\d.]+)(\r|\n)/", $file_data, $matches)) { self::$version = $matches[1]; } fclose($fp); } } public static function get_version() { return self::$version; } public static function init_background_jobs() { require_once __DIR__ . '/inc/background/class-background-backup.php'; require_once __DIR__ . '/inc/background/class-background-restore.php'; require_once __DIR__ . '/inc/background/class-background-helper.php'; self::$process_backup_single = new OCM_BackgroundBackup(); self::$process_restore_single = new OCM_BackgroundRestore(); } public static function init_handler(){ Error_Handler::init_error_handler(); OCM_Debug_Mode::restore_if_active(); } public static function register_settings() { // The admin page renders fields directly via class-admin-page.php; we only need // register_setting() so that options.php accepts the form submission for ocm_user_email. register_setting('one-click-migration', 'ocm_user_email'); } public static function admin_styles($hook) { if ($hook !== 'tools_page_one-click-migration') { return; } wp_enqueue_style('custom_ocm_admin_css', plugins_url('css/admin-style.css', __FILE__), array(), self::$version . '.' . filemtime(__DIR__ . '/css/admin-style.css')); wp_enqueue_script('custom_ocm_admin_js', plugins_url('js/admin-script.js', __FILE__), array('jquery'), self::$version . '.' . filemtime(__DIR__ . '/js/admin-script.js'), true); $ocm_user_email = get_option('ocm_user_email'); $user_email = ''; $urlparts = parse_url(home_url()); $domain = $urlparts['host']; if($ocm_user_email){ $user_email = $ocm_user_email; } $timeout = One_Click_Migration::get_timeout(); $other_active_count = OCM_Admin_Page::other_active_plugins_count(); // Permanent dismiss of the in-plugin Review ask is handled on admin_init // (handle_review_dismiss), which runs before this hook, so the modal's // dismiss URL keeps working and the flags below already reflect it. $show_restore_success = false; $show_review_cta = false; $flag = (int) get_option('ocm_show_restore_success', 0); if ($flag > 0 && (time() - $flag) < DAY_IN_SECONDS * 2) { $show_restore_success = true; $show_review_cta = (bool) get_option('ocm_show_review_cta', false); delete_option('ocm_show_restore_success'); delete_option('ocm_show_review_cta'); } elseif (!empty($_GET['ocm_restore_done'])) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended $show_restore_success = true; // Query-param smoke path: honor stored CTA flag if present, else no Review ask. $show_review_cta = (bool) get_option('ocm_show_review_cta', false); delete_option('ocm_show_restore_success'); delete_option('ocm_show_review_cta'); } // Never show Review CTA if permanently dismissed. if ((bool) get_option('ocm_review_ask_dismissed', false)) { $show_review_cta = false; } $extra_domains = OCM_Extra_Domains::public_rows(get_option(OCM_Extra_Domains::OPTION, array())); $show_restore_partial = (bool) get_option('ocm_restore_was_partial', false); $show_restore_warned = (bool) get_option(OCM_Backup::OPTION_RESTORE_WAS_WARNED, false); wp_localize_script('custom_ocm_admin_js', 'siteData', array( // _locale=user: the poll's help text in the admin's own language, // like the rest of this page (WordPress 5.0+; ignored before). 'progressUrl' => add_query_arg('_locale', 'user', get_rest_url(null, 'ocm/v1/progress')), 'userEmail' => $user_email, 'domain' => $domain, 'ajaxurl' => admin_url('admin-ajax.php'), 'restNonce' => wp_create_nonce('wp_rest'), 'backupNonce' => wp_create_nonce('one_click_migration_start_backup'), 'restoreNonce' => wp_create_nonce('one_click_migration_start_restore'), 'prepareRestoreNonce' => wp_create_nonce('ocm_prepare_restore'), 'otherPluginsActive' => $other_active_count, 'pluginPageUrl' => admin_url('tools.php?page=one-click-migration'), 'showRestoreSuccess' => $show_restore_success ? 1 : 0, 'showRestorePartial' => $show_restore_partial ? 1 : 0, 'showRestoreWarned' => $show_restore_warned ? 1 : 0, 'showReviewCta' => $show_review_cta ? 1 : 0, 'dismissReviewUrl' => wp_nonce_url(admin_url('tools.php?page=one-click-migration&ocm_dismiss_review=1'), 'ocm_dismiss_review'), 'reviewUrl' => 'https://wordpress.org/plugins/1-click-migration/reviews/#new-post', 'supportUrl' => OCM_Backup::SUPPORT_FORUM_URL, 'extraDomains' => $extra_domains, 'extraDomainsNonce' => wp_create_nonce('ocm_apply_extra_domains'), 'i18n' => OCM_Admin_Page::script_strings(), )); } /** * Permanent dismiss of the Review ask (never ask again on this site). * Runs on admin_init so it works from any wp-admin screen; shared by the * restore-success modal ("Don't ask again" link) and the admin notice. */ public static function handle_review_dismiss() { if (empty($_GET['ocm_dismiss_review']) || empty($_GET['_wpnonce']) // phpcs:ignore WordPress.Security.NonceVerification.Recommended || !wp_verify_nonce(sanitize_text_field(wp_unslash($_GET['_wpnonce'])), 'ocm_dismiss_review') // phpcs:ignore WordPress.Security.NonceVerification.Recommended ) { return; } if (!current_user_can('manage_options')) { return; } update_option('ocm_review_ask_dismissed', 1, false); update_option('ocm_show_review_cta', 0, false); } /** * Persistent admin notice asking for a review after the first clean restore. * Gated on ocm_clean_restore_count (never consumed) rather than * ocm_show_review_cta, which the restore-success modal deletes one-shot. */ public static function maybe_show_review_notice() { if (!current_user_can('manage_options')) { return; } if ((int) get_option('ocm_clean_restore_count', 0) < 1) { return; } if ((bool) get_option('ocm_review_ask_dismissed', false)) { return; } $review_url = 'https://wordpress.org/plugins/1-click-migration/reviews/#new-post'; $dismiss_url = wp_nonce_url(add_query_arg('ocm_dismiss_review', '1'), 'ocm_dismiss_review'); ?>

options; $wpdb->query("DELETE FROM {$table} WHERE option_name IN ( 'ocm_presigned_urls', 'ocm_bucket_key', 'ocm_bucket_id', 'ocm_aes_key', 'ocm_restore_code_plain', 'ocm_action_start_backup', 'ocm_action_start_restore', 'ocm_is_stopped', 'ocm_show_restore_success', 'ocm_show_review_cta', 'ocm_extra_domain_candidates', 'ocm_restore_url_plan', 'ocm_clean_restore_count', 'ocm_review_ask_dismissed', 'ocm_user_email', 'ocm_user_password', 'backup_steps', 'restore_steps', 'ocm_excluded_folders', 'ocm_skipped_folders', 'ocm_log_merged_bucket' )"); // 2. Delete all remaining plugin options self::delete_options(); // 3. Clean up background process batch rows and transients via direct SQL $wpdb->query("DELETE FROM {$table} WHERE option_name LIKE 'wp_ocm_background_%'"); $wpdb->query("DELETE FROM {$table} WHERE option_name LIKE '_site_transient_wp_ocm_background_%'"); $wpdb->query("DELETE FROM {$table} WHERE option_name LIKE '_site_transient_timeout_wp_ocm_background_%'"); $wpdb->query("DELETE FROM {$table} WHERE option_name = 'backup_steps'"); $wpdb->query("DELETE FROM {$table} WHERE option_name = 'restore_steps'"); // 3. Delete the local debug log file $log_file = defined('OCM_DEBUG_LOG_FILE') ? OCM_DEBUG_LOG_FILE : (plugin_dir_path(__FILE__) . 'ocm_debug.log'); if (file_exists($log_file)) { @unlink($log_file); } // 4. Disable maintenance mode if it was left on $maintenance_file = ABSPATH . '.maintenance'; if (file_exists($maintenance_file)) { @unlink($maintenance_file); } // self::ensure_mu_plugin_isolation_shim(); // disabled while debugging restore self::remove_mu_plugin_isolation_shim(); flush_rewrite_rules(); } public static function delete_options(){ // Clean up the setting, if there's an ocm_key in the table delete_option('ocm_user_email'); delete_option('ocm_user_password'); delete_option('ocm_is_stopped'); delete_option('ocm_zip_failover_job'); delete_option('ocm_action_start_backup'); delete_option('ocm_action_start_restore'); delete_option('backup_steps'); delete_option('restore_steps'); delete_option('ocm_presigned_urls'); delete_option('ocm_upload_file'); delete_option('ocm_download_auto_try_nb_db'); delete_option('ocm_download_auto_try_nb_themes'); delete_option('ocm_download_auto_try_nb_plugins'); delete_option('ocm_download_auto_try_nb_uploads'); delete_option('ocm_upload_auto_try_nb_db'); delete_option('ocm_upload_auto_try_nb_themes'); delete_option('ocm_upload_auto_try_nb_plugins'); delete_option('ocm_upload_auto_try_nb_uploads'); delete_option('ocm_excluded_folders'); delete_option('ocm_skipped_folders'); delete_option('ocm_log_download'); delete_option('ocm_log_url'); delete_option('ocm_bucket_key'); delete_option('ocm_bucket_id'); delete_option('ocm_aes_key'); delete_option('ocm_restore_code_plain'); delete_option('ocm_extra_domain_candidates'); delete_option('ocm_restore_url_plan'); delete_option('ocm_active_backup_run'); delete_option('ocm_active_restore_run'); delete_option('ocm_backup_uploaded_parts'); self::clear_backup_plugin_isolation(); delete_option('ocm_backup_compress_retry_db'); delete_option('ocm_backup_compress_retry_themes'); delete_option('ocm_backup_compress_retry_plugins'); delete_option('ocm_backup_compress_retry_uploads'); delete_option('ocm_backup_encrypt_retry_db'); delete_option('ocm_backup_encrypt_retry_themes'); delete_option('ocm_backup_encrypt_retry_plugins'); delete_option('ocm_backup_encrypt_retry_uploads'); delete_option('ocm_backup_upload_retry_db'); delete_option('ocm_backup_upload_retry_themes'); delete_option('ocm_backup_upload_retry_plugins'); delete_option('ocm_backup_upload_retry_uploads'); delete_option('ocm_restore_download_retry_db'); delete_option('ocm_restore_download_retry_themes'); delete_option('ocm_restore_download_retry_plugins'); delete_option('ocm_restore_download_retry_uploads'); delete_option('ocm_restore_decrypt_retry_db'); delete_option('ocm_restore_decrypt_retry_themes'); delete_option('ocm_restore_decrypt_retry_plugins'); delete_option('ocm_restore_decrypt_retry_uploads'); delete_option('ocm_restore_extract_retry_db'); delete_option('ocm_restore_extract_retry_themes'); delete_option('ocm_restore_extract_retry_plugins'); delete_option('ocm_restore_extract_retry_uploads'); delete_option('ocm_restore_child_delete_retry_db'); delete_option('ocm_restore_child_delete_retry_themes'); delete_option('ocm_restore_child_delete_retry_plugins'); delete_option('ocm_restore_child_delete_retry_uploads'); // Clear additional options that might cause redirect loops delete_option('ocm_s3_bucket'); delete_option('ocm_s3_region'); delete_option('ocm_s3_access_key'); delete_option('ocm_s3_secret_key'); delete_option('ocm_backup_id'); delete_option('ocm_backup_password'); delete_option('ocm_backup_completed_steps'); delete_option('ocm_restore_completed_steps'); delete_option('ocm_maintenance_mode'); delete_option('ocm_worker_heartbeat'); delete_option('ocm_worker_watchdog_last'); delete_option('ocm_worker_revival_count'); delete_option('ocm_dispatch_blocked'); delete_option('ocm_backup_missing_parts'); delete_option('ocm_backup_excluded_parts'); delete_option('ocm_backup_parts_known'); } public static function uninstall() { // Use stop_and_reset for complete cleanup on deactivation self::stop_and_reset(); self::remove_mu_plugin_isolation_shim(); } /** * Recursively delete a directory without logging (for safe deactivation) * * @param string $dir Directory path to delete * @return bool True on success, false on failure */ private static function delete_directory_recursive($dir) { if (!is_dir($dir)) { return false; } $items = @scandir($dir); if ($items === false) { return false; } foreach ($items as $item) { if ($item === '.' || $item === '..') { continue; } $path = $dir . '/' . $item; if (is_dir($path)) { self::delete_directory_recursive($path); } else { @unlink($path); } } return @rmdir($dir); } /** * The debug log is mirrored to S3 and consumed by JSON APIs (Lightsail * get_logs/view_logs) and Mailgun alerts. A single binary/ciphertext blob * in one entry used to make json_encode() of the whole log return false, * so every entry must stay valid UTF-8, control-char free and short. * Never pass file contents, ciphertext or credentials to write_to_log. */ public static function sanitize_log_message($string) { $string = (string) $string; // Strip control characters except tab/newline (CR normalised away). $string = preg_replace('/[\x00-\x08\x0B-\x1F\x7F]/', '', $string); // Cap runaway entries (a 2MB chunk dump is never useful in a log line). $max_bytes = 2000; if (strlen($string) > $max_bytes) { $string = substr($string, 0, $max_bytes) . ' [log entry truncated]'; } // Replace invalid UTF-8 byte sequences so JSON consumers never choke. if (!preg_match('//u', $string)) { if (function_exists('mb_convert_encoding')) { $converted = @mb_convert_encoding($string, 'UTF-8', 'UTF-8'); $string = is_string($converted) ? $converted : preg_replace('/[\x80-\xFF]+/', '?', $string); } elseif (function_exists('iconv')) { $converted = @iconv('UTF-8', 'UTF-8//IGNORE', $string); $string = is_string($converted) ? $converted : preg_replace('/[\x80-\xFF]+/', '?', $string); } else { $string = preg_replace('/[\x80-\xFF]+/', '?', $string); } } return $string; } public static function write_to_log($string) { // sleep(1); $string = self::sanitize_log_message($string); $file = OCM_DEBUG_LOG_FILE; $urls = get_option('ocm_presigned_urls'); if (!self::ensure_private_log_directory()) { error_log('[1-Click Migration] Could not create private log directory.'); return; } if (preg_match('/Error:/', $string)) { self::reset_actions_start_mark(); } // Log essential messages to WordPress debug.log if (defined('WP_DEBUG_LOG') && WP_DEBUG_LOG) { $essential_patterns = [ 'Backup started', 'Restore started', 'Restore completed', 'Backup completed', 'Error:', 'Uploads have been restored', 'Themes have been restored', 'Database backup started', 'Database has been restored' ]; foreach ($essential_patterns as $pattern) { if (strpos($string, $pattern) !== false) { error_log('[1-Click Migration] ' . $string); break; } } } $timestamp = date('m/d/YTH:i:sa'); $line = $timestamp . ' - ' . $string; if($urls){ $log_url = is_object($urls) && !empty($urls->log) ? (string) $urls->log : ''; // Lines held back for another log URL go there before this line is // appended, so that copy never carries lines written under this one. if (self::$log_upload_pending && self::$log_upload_pending['url'] !== $log_url) { self::flush_log_upload(); } // If ocm_debug doesn't exist, make sure to grab the latest from S3 so we can append. // Download to memory first; only write the local file on 200 so a failed download // does not leave an empty file that suppresses later download attempts. if (isset($urls->log_download) && !file_exists($file)) { try { $client = new Client(); $response = $client->get($urls->log_download, ['http_errors' => false]); if ($response->getStatusCode() === 200) { $body = (string) $response->getBody(); if ($body !== '') { file_put_contents($file, $body); } } } catch (Exception $e) { // Presigned URL may have expired — leave no local file so the next // write_to_log call can retry the download once fresh URLs land. error_log('[1-Click Migration] Could not download existing log from S3: ' . $e->getMessage()); } } $log_file = fopen($file, 'ab'); fwrite($log_file, $line . PHP_EOL); fclose($log_file); // Every S3 copy PUTs the whole log, so copy at most every // LOG_UPLOAD_INTERVAL seconds; flush_log_upload() sends the rest. if ($log_url !== '') { if (self::log_upload_due($log_url, $string)) { self::upload_log($log_url, self::$log_shutdown_state !== 2); } else { self::defer_log_upload($log_url); } } } if(!$urls){ if (self::$log_upload_pending) { self::flush_log_upload(); } $log_file = fopen($file, 'ab'); fwrite($log_file, $line . PHP_EOL); fclose($log_file); } } /** * Upload log lines write_to_log() held back. Called before a worker * dispatches the next one, before the restore code is emailed, before the * local log is deleted and at shutdown, so no line stays out of S3. */ public static function flush_log_upload() { $pending = self::$log_upload_pending; if (!$pending) { return; } self::$log_upload_pending = null; // The log only grows during a run. A shorter file or other first bytes // mean another request deleted or restarted it: never send that log // to this run's URL. $file = OCM_DEBUG_LOG_FILE; clearstatcache(true, $file); if (!is_file($file) || (int) filesize($file) < $pending['size'] || self::read_log_head($pending['head_len']) !== $pending['head'] ) { return; } self::upload_log($pending['url'], false); } /** Shutdown callback. Lines logged after it ran (later shutdown callbacks) upload at once. */ public static function flush_log_upload_on_shutdown() { self::$log_shutdown_state = 2; self::flush_log_upload(); } private static function log_upload_due($url, $message) { foreach (self::LOG_UPLOAD_NOW_MARKERS as $marker) { if (strpos($message, $marker) !== false) { self::$log_terminal_written = true; return true; } } // The first line for a URL in a request uploads at once, as before: // that keeps the no_bucket redirect on the request's first write. if (self::$log_terminal_written || self::$log_shutdown_state === 2 || $url !== self::$log_uploaded_url ) { return true; } $interval = function_exists('apply_filters') ? (float) apply_filters('ocm_log_upload_interval', self::LOG_UPLOAD_INTERVAL) : (float) self::LOG_UPLOAD_INTERVAL; return microtime(true) - self::$log_uploaded_at >= $interval; } private static function defer_log_upload($url) { $file = OCM_DEBUG_LOG_FILE; clearstatcache(true, $file); $size = (int) @filesize($file); if (!self::$log_upload_pending) { $head_len = min($size, 512); self::$log_upload_pending = array( 'url' => $url, 'head_len' => $head_len, 'head' => self::read_log_head($head_len), ); } self::$log_upload_pending['size'] = $size; if (self::$log_shutdown_state === 0) { self::$log_shutdown_state = 1; register_shutdown_function(array(__CLASS__, 'flush_log_upload_on_shutdown')); } } private static function read_log_head($length) { if ($length <= 0) { return ''; } $fh = @fopen(OCM_DEBUG_LOG_FILE, 'rb'); if (!$fh) { return false; } $head = (string) fread($fh, $length); fclose($fh); return $head; } /** * PUT the whole local log to the presigned log URL. A 404 means the bucket * is gone: drop the local log and, unless called from a flush, send the * admin to the no_bucket notice. */ private static function upload_log($url, $may_redirect) { self::$log_upload_pending = null; $body = @file_get_contents(OCM_DEBUG_LOG_FILE); if ($body === false) { return; } try { $request = new Request( 'PUT', $url, [], $body ); $client = new Client(); $client->send($request); } catch (Exception $e) { if ($e->getCode() === 404) { self::delete_log_file(); if ($may_redirect) { wp_safe_redirect(admin_url('tools.php?page=one-click-migration&message=no_bucket')); exit; } return; } // Try again at the next upload, hand-over or shutdown. self::defer_log_upload($url); } self::$log_uploaded_url = $url; self::$log_uploaded_at = microtime(true); } public static function ensure_private_log_directory() { static $ready = false; if ($ready) { return true; } if (!OCM_Private_Dir::ensure(OCM_DEBUG_LOG_DIR)) { return false; } // The plugin-folder log was this site's own, so it moves in. The one // older versions kept in the shared .ocm-private root stays put. $legacy_file = OCM_PLUGIN_ROOT . OCM_DEBUG_LOG_FILENAME; if (is_file($legacy_file) && !is_file(OCM_DEBUG_LOG_FILE)) { if (!@rename($legacy_file, OCM_DEBUG_LOG_FILE) && @copy($legacy_file, OCM_DEBUG_LOG_FILE)) { @unlink($legacy_file); } } $ready = true; return true; } public static function download_log() { if (!current_user_can('manage_options')) { wp_die(esc_html__('You do not have sufficient permissions to access this log.', '1-click-migration')); } check_admin_referer('ocm_download_log'); self::ensure_private_log_directory(); if (!is_file(OCM_DEBUG_LOG_FILE) || !is_readable(OCM_DEBUG_LOG_FILE)) { wp_die(esc_html__('Migration log not found.', '1-click-migration')); } nocache_headers(); header('Content-Type: text/plain; charset=UTF-8'); header('Content-Disposition: attachment; filename="' . OCM_DEBUG_LOG_FILENAME . '"'); header('X-Content-Type-Options: nosniff'); // A few plain lines for the person who downloads it. The log has no // restore code, keys or signed links, but it has the email address and // server details, so it's not for posting whole in public. echo '# ' . __('1-Click Migration log: the technical details of the last backup or restore on this site.', '1-click-migration') . "\n"; echo '# ' . sprintf( /* translators: %s: support forum URL */ __('Need help? Ask in the support forum: %s', '1-click-migration'), OCM_Backup::SUPPORT_FORUM_URL ) . "\n"; echo '# ' . __('This log includes your email address and server details, so share only the lines we ask for. It never includes your restore code.', '1-click-migration') . "\n#\n"; readfile(OCM_DEBUG_LOG_FILE); exit; } public static function set_default_max_execution_time($v) { self::$default_max_execution_time = (int) $v; } public static function get_default_max_execution_time() { return self::$default_max_execution_time; } public static function set_current_max_execution_time() { self::set_default_max_execution_time(ini_get('max_execution_time')); $isApache = extension_loaded('apache2handler') || (strpos(getenv('SERVER_SOFTWARE'),'Apache') !== false); if ($isApache) { if ( (int) ini_get('max_execution_time') < 300 ) @set_time_limit(OCM_SET_TIME_LIMIT); } else if ( (int) ini_get('max_execution_time') < 60 ) @set_time_limit( 60 ); self::$current_max_execution_time = (int) ini_get('max_execution_time'); } public static function get_current_max_execution_time() { return self::$current_max_execution_time; } public static function get_timeout() { return (self::get_current_max_execution_time()-5) > 0 ? self::get_current_max_execution_time()-5 : OCM_SET_TIME_LIMIT; } /** * Seconds a backup or restore worker works before it hands over to a new one. * * set_current_max_execution_time() raises PHP's own limit, but the server * can still kill the request sooner: PHP-FPM's request_terminate_timeout * sends SIGTERM (no shutdown functions run), and those limits are often * 30 s, near php.ini's max_execution_time. So stay well under both. * * @return int */ public static function get_worker_time_budget() { $budget = min(self::get_timeout(), OCM_WORKER_TIME_BUDGET); $ini_limit = (int) self::get_default_max_execution_time(); if ($ini_limit > 0) { $budget = min($budget, max(10, $ini_limit - 10)); } return max(5, (int) apply_filters('ocm_worker_time_budget', $budget)); } public static function ensure_mu_plugin_isolation_shim() { // Plugin isolation disabled while debugging restore. Actively remove any // existing shim file so a stale copy doesn't keep firing on admin loads. self::remove_mu_plugin_isolation_shim(); return false; } public static function remove_mu_plugin_isolation_shim() { $dir = defined('WPMU_PLUGIN_DIR') ? WPMU_PLUGIN_DIR : WP_CONTENT_DIR . '/mu-plugins'; $file = trailingslashit($dir) . self::MU_PLUGIN_ISOLATION_FILE; if (is_file($file)) { @unlink($file); } } public static function start_backup_plugin_isolation() { if (!self::ensure_mu_plugin_isolation_shim()) { self::clear_backup_plugin_isolation(); return false; } try { $token = bin2hex(random_bytes(32)); } catch (Exception $e) { $token = wp_generate_password(64, false, false); } update_option(self::BACKUP_ISOLATION_TOKEN_OPTION, $token, false); update_option(self::BACKUP_ISOLATION_TOKEN_HASH_OPTION, hash_hmac('sha256', $token, wp_salt('auth')), false); update_option(self::BACKUP_ISOLATION_ALLOWED_PLUGINS_OPTION, array(plugin_basename(OCM_MAIN_FILE)), false); return true; } public static function get_backup_plugin_isolation_token() { $token = get_option(self::BACKUP_ISOLATION_TOKEN_OPTION, ''); return is_string($token) ? $token : ''; } public static function clear_backup_plugin_isolation() { delete_option(self::BACKUP_ISOLATION_TOKEN_OPTION); delete_option(self::BACKUP_ISOLATION_TOKEN_HASH_OPTION); delete_option(self::BACKUP_ISOLATION_ALLOWED_PLUGINS_OPTION); } private static function get_mu_plugin_isolation_shim_contents() { return <<<'PHP' '1-click-migration', 'status' => 'ok', )); } public static function reset_actions_start_mark() { update_option('ocm_action_start_backup', false); update_option('ocm_action_start_restore', false); // Reset watchdog state so the next run starts with a fresh clock // instead of inheriting a stale heartbeat from this one. self::reset_worker_watchdog(); } /** * Forget the watchdog's heartbeat, last pump and revival count. A run's * kickoff calls this too: the loopback request that cancel_all_process() * dispatches after a run ends writes a heartbeat after the cleanup above, * and the next run would see it as a stall at once (a needless revival, * and the "taking longer than expected" notice for the whole run). * * @return void */ public static function reset_worker_watchdog() { delete_option('ocm_worker_heartbeat'); delete_option('ocm_worker_watchdog_last'); delete_option('ocm_worker_revival_count'); } public static function cancel_all_process() { self::clear_backup_plugin_isolation(); // Load the background helper class before using it if (!class_exists('OCM\OCM_BackgroundHelper')) { require_once __DIR__ . '/inc/background/class-background-helper.php'; } OCM_BackgroundHelper::delete_all_batch_process(); // Only call cancel methods if the process objects exist if (self::$process_backup_single) { self::$process_backup_single->cancel_all_process(); } if (self::$process_restore_single) { self::$process_restore_single->cancel_all_process(); } // The queues are already gone and run ownership stops any worker still // running, so the "cancelled" flag cancel() just set has nothing left to // do. Left set, the next run's first dispatched worker deletes that run's // queue whenever the cancel's own request hasn't arrived yet. self::clear_background_cancel_status(); } /** * Clear both workers' "cancelled" flags (see clear_cancel_status()). * * @return void */ public static function clear_background_cancel_status() { foreach (array(self::$process_backup_single, self::$process_restore_single) as $process) { if (is_object($process) && method_exists($process, 'clear_cancel_status')) { $process->clear_cancel_status(); } } } public static function stop_and_reset() { OCM_Debug_Mode::disable(); One_Click_Migration::write_to_log("stop and reset started"); // Disable maintenance mode when stopping the process self::disable_maintenance_mode(); // Unlock any locked processes before cleanup if (self::$process_backup_single) { self::$process_backup_single->unlock_process(); } if (self::$process_restore_single) { self::$process_restore_single->unlock_process(); } // Clear all queues and batches if (self::$process_backup_single) { self::$process_backup_single->cancel_all_process(); self::$process_backup_single->cancel_scheduled_event(); } if (self::$process_restore_single) { self::$process_restore_single->cancel_all_process(); self::$process_restore_single->cancel_scheduled_event(); } // A backup's system zip can outlive its worker and would keep writing // into the work dir deleted below; stop it while its record exists. OCM_Backup::stop_zip_failover_job(); // Delete all options and batch data self::delete_options(); // Load the background helper class before using it if (!class_exists('OCM\OCM_BackgroundHelper')) { require_once __DIR__ . '/inc/background/class-background-helper.php'; } OCM_BackgroundHelper::delete_all_batch_process(); // Clear specific process status options that might be stuck delete_option('wp_ocm_background_backup_status'); delete_option('wp_ocm_background_restore_status'); delete_site_transient('wp_ocm_background_backup_process_lock'); delete_site_transient('wp_ocm_background_restore_process_lock'); // Drop leftover staging tables from an interrupted restore. if (class_exists('OCM\\OCM_SQL_Utils')) { global $wpdb; if (isset($wpdb) && is_object($wpdb)) { \OCM\OCM_SQL_Utils::drop_stale_temp_tables($wpdb, null, isset($wpdb->base_prefix) ? $wpdb->base_prefix : null); } delete_option(\OCM\OCM_SQL_Utils::OPTION_STAGING_PREFIX); } // Delete the restore workspace if(!WP_DEBUG){ OCM_Backup::delete_restore_workspaces('Deleting restore workspace'); } // Clear backup work files (private work dir, then the tmp folder) OCM_Backup::delete_private_backup_work_dirs('Deleting TMP Folder'); if (is_dir(OCM_PLUGIN_WRITABLE_PATH)) { OCM_Backup::deleteDir(OCM_PLUGIN_WRITABLE_PATH, 'Deleting TMP Folder', null); } // Make sure we don't leave un-necessary routes laying around flush_rewrite_rules(); One_Click_Migration::write_to_log("Stop & Reset Finished"); } public static function clear_log_file(){ $file = OCM_DEBUG_LOG_FILE; if (file_exists($file)) { $fh = fopen($file, 'w'); $file_content = ""; fwrite($fh, $file_content); fclose($fh); } } public static function delete_log_file() { // Lines this request held back still belong in the old log's S3 copy. self::flush_log_upload(); $file = OCM_DEBUG_LOG_FILE; if (file_exists($file)) { unlink($file); } } /** * Pull the backup-side log from S3 and prepend it to whatever the restore * has already written locally. Idempotent for a given bucket via the * ocm_log_merged_bucket option, so it's safe to call repeatedly. */ public static function merge_backup_log_from_s3() { $urls = get_option('ocm_presigned_urls'); $bucket_id = get_option('ocm_bucket_id'); if (!$urls || !is_object($urls) || empty($urls->log_download) || !$bucket_id) { return; } if (get_option('ocm_log_merged_bucket') === $bucket_id) { return; } try { $client = new Client(); $response = $client->get($urls->log_download, ['http_errors' => false, 'timeout' => 15]); if ($response->getStatusCode() !== 200) { return; } $remote = (string) $response->getBody(); if ($remote === '') { update_option('ocm_log_merged_bucket', $bucket_id, false); return; } self::extract_missing_parts_from_backup_log($remote, $bucket_id); OCM_SQL_Utils::store_backup_plugin_version_from_log($remote); $file = OCM_DEBUG_LOG_FILE; $local = file_exists($file) ? (string) @file_get_contents($file) : ''; $combined = rtrim($remote, "\r\n") . PHP_EOL . $local; file_put_contents($file, $combined); update_option('ocm_log_merged_bucket', $bucket_id, false); } catch (Exception $e) { error_log('[1-Click Migration] merge_backup_log_from_s3 failed: ' . $e->getMessage()); } } /** * Detect parts the backup itself could not include and persist them for the * restore flow. Prefers the structured BACKUP_PARTS marker; falls back to the * skip notices for backups made by older plugin versions. Only the remote * (backup-side) log is inspected so this site's own older runs cannot leak in. */ public static function extract_missing_parts_from_backup_log($remote_log, $bucket_id) { $missing = array(); $excluded_at_backup = array(); $structured = false; if (preg_match_all('/BACKUP_PARTS bucket=' . preg_quote($bucket_id, '/') . ' ([a-z_= ]+)/', $remote_log, $matches)) { $structured = true; $line = end($matches[1]); foreach (explode(' ', trim($line)) as $pair) { $kv = explode('=', $pair, 2); if (count($kv) !== 2) { continue; } $part = strtolower($kv[0]); $status = $kv[1]; // Skipped (timeout / failure) parts warn on restore. User-excluded // parts at backup time are intentional and must not warn. if ($status === 'skipped') { $missing[] = $part; } elseif ($status === 'excluded') { $excluded_at_backup[] = $part; } } } elseif (preg_match_all('/Notice: File (\w+) was skipped due to timeout/i', $remote_log, $matches)) { foreach ($matches[1] as $part) { $missing[] = strtolower($part); } } $missing = array_values(array_unique($missing)); $excluded_at_backup = array_values(array_unique($excluded_at_backup)); update_option('ocm_backup_missing_parts', $missing, false); update_option('ocm_backup_excluded_parts', $excluded_at_backup, false); update_option('ocm_backup_parts_known', $structured ? 1 : 0, false); return $missing; } /** * Record that a background worker is alive. The progress poller treats a * stale heartbeat as a stalled worker and resumes it inline, so every worker * entry point must refresh this. */ public static function worker_heartbeat() { update_option('ocm_worker_heartbeat', time(), false); } /** * Heartbeat from inside one long call (a download or decrypt) that doesn't * return to the worker loop. At most every 10 seconds it refreshes the * heartbeat and the worker's process lock, so the watchdog never takes a * busy worker for a stalled one and starts a second one (#79). * * @param \WP_Background_Process|null $process The worker running the call. */ public static function transfer_heartbeat($process = null) { $now = time(); if ($now - self::$last_transfer_heartbeat < 10) { return; } self::$last_transfer_heartbeat = $now; self::worker_heartbeat(); // Never call lock_process() from here: in an older library copy loaded // by another plugin it is protected, and calling it is a fatal (#127). if (is_object($process) && is_callable(array($process, 'refresh_process_lock'))) { $process->refresh_process_lock(); } } /** * Enable WordPress maintenance mode (custom implementation) */ public static function enable_maintenance_mode() { // Set option to indicate maintenance mode is active update_option('ocm_maintenance_mode', true); One_Click_Migration::write_to_log('Maintenance mode enabled'); return true; } /** * Handle maintenance mode with admin bypass */ public static function handle_maintenance_mode() { // Check if maintenance mode is active if (!get_option('ocm_maintenance_mode', false)) { return; } // Allow admin users and REST API requests to bypass maintenance mode if (is_admin() || defined('REST_REQUEST') || (is_user_logged_in() && current_user_can('manage_options'))) { return; } // Show maintenance page for frontend visitors status_header(503); nocache_headers(); echo ' ' . esc_html__('Site Maintenance', '1-click-migration') . '
🛠️

' . esc_html__('Site Under Maintenance', '1-click-migration') . '

' . esc_html__('Site is currently being restored. Please check back in a few minutes.', '1-click-migration') . '

' . esc_html__('We apologize for any inconvenience. Please check back shortly.', '1-click-migration') . '

'; exit; } /** * Disable WordPress maintenance mode */ public static function disable_maintenance_mode() { // Remove the custom maintenance mode option delete_option('ocm_maintenance_mode'); // Also clean up the .maintenance file if it exists $maintenance_file = ABSPATH . '.maintenance'; if (file_exists($maintenance_file)) { unlink($maintenance_file); } One_Click_Migration::write_to_log('Maintenance mode disabled'); return true; } } new One_Click_Migration(); add_filter('plugin_action_links_' . plugin_basename(__FILE__), static function ($links) { $links[] = '' . esc_html__('Settings', '1-click-migration') . ''; return $links; });