$settings['include_database'], 'include_plugins' => $settings['include_plugins'], 'include_themes' => $settings['include_themes'], 'include_uploads' => $settings['include_uploads'], 'compression_level' => $settings['compression_level'], 'label' => '', 'trigger' => 'manual', ); return array_merge($options, array_intersect_key((array) $overrides, $options)); } /** * Create a backup. $backup_id: record created beforehand (0 = create one). */ public function create_backup($backup_id = 0, $overrides = array()) { $options = self::resolve_options($overrides); $label = sanitize_file_name((string) $options['label']); $backup_name = ($label ? $label : 'backup') . '-' . gmdate('Y-m-d-H-i-s'); $file_name = $backup_name . '-' . strtolower(wp_generate_password(8, false, false)); $has_files = $options['include_plugins'] || $options['include_themes'] || $options['include_uploads']; $type = $options['include_database'] ? ($has_files ? 'full' : 'database') : 'files'; try { self::raise_limits(); if (!class_exists('ZipArchive')) { throw new Exception(esc_html__('The PHP ZipArchive extension is not available on this server.', 'enmigrate-pro')); } if (!$options['include_database'] && !$has_files) { throw new Exception(esc_html__('Nothing selected to back up.', 'enmigrate-pro')); } WPMigPro_FS::protect_dir(WPMIGPRO_TEMP_DIR); WPMigPro_FS::protect_dir(WPMIGPRO_BACKUPS_DIR); $this->backup_id = $backup_id ? intval($backup_id) : $this->create_backup_record($backup_name, $type); $this->update_backup_record(array('backup_name' => $backup_name, 'backup_type' => $type, 'status' => 'processing', 'progress' => 1)); update_option('wpmigpro_backup_log', array(), false); WPMigPro_Logger::log("Starting backup: $backup_name ($type)"); // Phase 1: database (0-30%) $db_result = array('file' => '', 'tables' => 0, 'rows' => 0); if ($options['include_database']) { $this->step(__('Exporting database', 'enmigrate-pro'), __('Reading tables...', 'enmigrate-pro')); $db_result = WPMigPro_Database::export_database($file_name, array($this, 'log_line')); if (!$db_result['success']) { throw new Exception(esc_html($db_result['message'])); } /* translators: 1: number of tables, 2: number of rows */ $this->log_line(sprintf(__('Database ready: %1$d tables, %2$d rows', 'enmigrate-pro'), $db_result['tables'], $db_result['rows'])); } $this->update_backup_record(array('progress' => 30)); // Phase 2: files straight into the archive (30-98%) $this->step(__('Compressing files', 'enmigrate-pro'), __('Scanning wp-content...', 'enmigrate-pro')); $archive_file = WPMIGPRO_BACKUPS_DIR . '/' . $file_name . '.wpb'; $this->create_archive($archive_file, $db_result, $options); $this->step(__('Finishing', 'enmigrate-pro'), __('Removing temporary files...', 'enmigrate-pro')); if ($db_result['file']) { WPMigPro_FS::delete($db_result['file']); } clearstatcache(); $size = filesize($archive_file); $this->update_backup_record(array( 'file_path' => $archive_file, 'file_size' => $size, 'status' => 'completed', 'progress' => 100, )); /* translators: %s: archive size, e.g. 120 MB */ $this->log_line(sprintf(__('Backup completed (%s)', 'enmigrate-pro'), size_format($size, 2))); WPMigPro_Logger::log("Backup completed: $backup_name"); $manager = new WPMigPro_Backup_Manager(); $manager->cleanup_old_backups(WPMigPro_Plugin::get_settings()['keep_backups']); /** * Fires when a backup archive is ready. * * @param int $backup_id Backup record ID. * @param string $archive_file Full path of the .wpb archive. * @param array $options Backup options, including 'trigger' (manual, auto...). */ do_action('wpmigpro_backup_completed', $this->backup_id, $archive_file, $options); return array( 'success' => true, 'backup_id' => $this->backup_id, 'backup_name' => $backup_name, 'file' => $archive_file, 'size' => $size, 'message' => __('Backup created successfully', 'enmigrate-pro'), ); } catch (Exception $e) { WPMigPro_Logger::log('Backup error: ' . $e->getMessage(), 'error'); $this->log_line('ERROR: ' . $e->getMessage()); if ($this->backup_id) { $this->update_backup_record(array('status' => 'failed', 'progress' => 0)); } WPMigPro_FS::delete(WPMIGPRO_TEMP_DIR . '/' . $file_name . '-database.sql'); /** * Fires when a backup fails. * * @param int $backup_id Backup record ID (0 if none was created). * @param string $message Error message. * @param array $options Backup options. */ do_action('wpmigpro_backup_failed', $this->backup_id, $e->getMessage(), $options); return array('success' => false, 'message' => $e->getMessage()); } } /** * Backups and restores can take minutes; the host's defaults are often 30s / 128MB. */ public static function raise_limits() { if (function_exists('set_time_limit')) { set_time_limit(0); // phpcs:ignore Squiz.PHP.DiscouragedFunctions.Discouraged -- long-running backup/restore job. } wp_raise_memory_limit('admin'); ignore_user_abort(true); } /** * Create backup database record */ public function create_backup_record($backup_name, $type = 'full') { global $wpdb; // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery -- plugin's own table. $wpdb->insert($wpdb->prefix . 'wpmigpro_backups', array( 'backup_name' => $backup_name, 'backup_type' => $type, 'file_path' => '', 'status' => 'processing', 'progress' => 0, 'created_at' => current_time('mysql'), )); return $wpdb->insert_id; } private function update_backup_record($data) { global $wpdb; // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- plugin's own table. $wpdb->update($wpdb->prefix . 'wpmigpro_backups', $data, array('id' => $this->backup_id)); } private function step($step, $details = '') { update_option('wpmigpro_backup_step', $step, false); update_option('wpmigpro_backup_details', $details, false); $this->log_line($step . ($details ? ' - ' . $details : '')); } /** * Append a line to the live log shown in the backup modal. */ public function log_line($message) { $log = get_option('wpmigpro_backup_log', array()); if (!is_array($log)) { $log = array(); } $log[] = '[' . wp_date('H:i:s') . '] ' . $message; update_option('wpmigpro_backup_log', array_slice($log, -300), false); update_option('wpmigpro_backup_details', $message, false); } /** * Should this top-level wp-content entry be in the backup? */ private function include_top($top, $options) { if (in_array($top, self::excluded_content_dirs(), true)) { return false; } if ($top === 'plugins' || $top === 'mu-plugins') { return (bool) $options['include_plugins']; } if ($top === 'themes') { return (bool) $options['include_themes']; } if ($top === 'uploads') { return (bool) $options['include_uploads']; } // languages, fonts, drop-ins, other folders: with any file backup return $options['include_plugins'] || $options['include_themes'] || $options['include_uploads']; } /** * List every file under wp-content that goes into the backup. */ private function collect_files($options) { $root = wp_normalize_path(WP_CONTENT_DIR); $files = array(); $self = $this; $dir_iterator = new RecursiveDirectoryIterator($root, FilesystemIterator::SKIP_DOTS); $filter = new RecursiveCallbackFilterIterator($dir_iterator, function ($current) use ($root, $options, $self) { $relative = ltrim(substr(wp_normalize_path($current->getPathname()), strlen($root)), '/'); if (strpos($relative, '/') === false && !$self->include_top_public($relative, $options)) { return false; } if ($current->isLink() && $current->isDir()) { return false; // avoid symlink loops } /** * Filters whether a file or folder of wp-content is left out of the backup. * * @param bool $exclude Default false. * @param string $relative Path relative to wp-content. */ return !apply_filters('wpmigpro_exclude_path', false, $relative); }); $iterator = new RecursiveIteratorIterator($filter, RecursiveIteratorIterator::SELF_FIRST); foreach ($iterator as $item) { $relative = ltrim(substr(wp_normalize_path($item->getPathname()), strlen($root)), '/'); if ($item->isDir()) { $files[] = array($relative, true); } elseif ($item->isFile() && $item->isReadable()) { $files[] = array($relative, false); } } return $files; } /** * @internal Closure access to include_top() (PHP 7.4 compatible). */ public function include_top_public($top, $options) { return $this->include_top($top, $options); } /** * Build the .wpb archive directly from wp-content (no intermediate copy). */ private function create_archive($archive_file, $db_info, $options) { global $wpdb; $has_files = $options['include_plugins'] || $options['include_themes'] || $options['include_uploads']; $files = $has_files ? $this->collect_files($options) : array(); $total = count($files); if ($has_files) { /* translators: %d: number of files and folders */ $this->log_line(sprintf(__('%d files and folders found in wp-content', 'enmigrate-pro'), $total)); } $zip = new ZipArchive(); if ($zip->open($archive_file, ZipArchive::CREATE | ZipArchive::OVERWRITE) !== true) { throw new Exception(esc_html__('Cannot create the backup archive.', 'enmigrate-pro')); } $password = self::archive_password(); $encrypt = $password !== '' && method_exists($zip, 'setEncryptionName'); if ($encrypt) { $zip->setPassword($password); $this->log_line(__('The archive is encrypted with AES-256', 'enmigrate-pro')); } if ($db_info['file']) { $zip->addFile($db_info['file'], 'database.sql'); if ($encrypt) { $zip->setEncryptionName('database.sql', ZipArchive::EM_AES_256); } } // Already-compressed formats are stored as-is: recompressing them wastes a lot of time. $store_ext = array('jpg', 'jpeg', 'png', 'gif', 'webp', 'avif', 'mp4', 'mov', 'webm', 'mp3', 'ogg', 'zip', 'gz', 'tgz', 'rar', '7z', 'woff', 'woff2', 'pdf', 'wpb'); $can_set_compression = method_exists($zip, 'setCompressionName'); $level = max(1, min(9, (int) $options['compression_level'])); $root = WP_CONTENT_DIR; $last_top = ''; $done = 0; $bytes = 0; foreach ($files as $entry) { list($relative, $is_dir) = $entry; $zip_path = 'files/wp-content/' . $relative; $top = strtok($relative, '/'); if ($top !== $last_top) { /* translators: %s: folder name inside wp-content */ $this->log_line(sprintf(__('Compressing wp-content/%s', 'enmigrate-pro'), $top)); $last_top = $top; } if ($is_dir) { $zip->addEmptyDir($zip_path); } else { $full = $root . '/' . $relative; if ($zip->addFile($full, $zip_path)) { $bytes += (int) filesize($full); if ($encrypt) { $zip->setEncryptionName($zip_path, ZipArchive::EM_AES_256); } if ($can_set_compression) { $ext = strtolower(pathinfo($relative, PATHINFO_EXTENSION)); if (in_array($ext, $store_ext, true)) { $zip->setCompressionName($zip_path, ZipArchive::CM_STORE); } else { $zip->setCompressionName($zip_path, ZipArchive::CM_DEFLATE, $level); } } } else { WPMigPro_Logger::log("Exporter: cannot add $full", 'warning'); } } $done++; if ($done % 500 === 0) { // Files are actually read when the archive is closed, so this phase only gets to 60%. $this->update_backup_record(array('progress' => 30 + (int) (($done / max(1, $total)) * 30))); /* translators: 1: files added, 2: total files */ update_option('wpmigpro_backup_details', sprintf(__('Adding files %1$d/%2$d', 'enmigrate-pro'), $done, $total), false); } } $metadata = array( 'format_version' => self::FORMAT_VERSION, 'plugin_version' => WPMIGPRO_VERSION, 'site_url' => site_url(), 'home_url' => home_url(), 'table_prefix' => $wpdb->prefix, 'abspath' => wp_normalize_path(ABSPATH), 'content_dir' => wp_normalize_path(WP_CONTENT_DIR), 'content_url' => content_url(), 'wordpress_version' => get_bloginfo('version'), 'php_version' => phpversion(), 'active_theme' => get_option('stylesheet'), 'active_plugins' => get_option('active_plugins', array()), 'includes' => array( 'database' => (bool) $options['include_database'], 'plugins' => (bool) $options['include_plugins'], 'themes' => (bool) $options['include_themes'], 'uploads' => (bool) $options['include_uploads'], ), 'tables' => $db_info['tables'], 'rows' => $db_info['rows'], 'files' => $total, 'files_bytes' => $bytes, 'backup_date' => current_time('mysql'), 'encrypted' => $encrypt, ); $zip->addFromString('metadata.json', wp_json_encode($metadata, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES)); $this->update_backup_record(array('progress' => 60)); $this->step( __('Writing .wpb archive', 'enmigrate-pro'), /* translators: %s: uncompressed size */ sprintf(__('Compressing %s, this can take a few minutes...', 'enmigrate-pro'), size_format($bytes, 2)) ); if (method_exists($zip, 'registerProgressCallback')) { $self = $this; $zip->registerProgressCallback(0.02, function ($rate) use ($self) { $self->set_progress(60 + (int) ($rate * 38)); }); } if (!$zip->close()) { throw new Exception(esc_html__('Error writing the backup archive:', 'enmigrate-pro') . ' ' . esc_html($zip->getStatusString())); } if ($has_files) { /* translators: 1: number of files, 2: uncompressed size */ $this->log_line(sprintf(__('Archive created: %1$d files (%2$s uncompressed)', 'enmigrate-pro'), $total, size_format($bytes, 2))); } } public function set_progress($progress) { $this->update_backup_record(array('progress' => min(98, $progress))); } }