diff --git a/inc/functions/exporter.php b/inc/functions/exporter.php index 14520a85..19f4d02e 100644 --- a/inc/functions/exporter.php +++ b/inc/functions/exporter.php @@ -59,6 +59,79 @@ function wu_exporter_export(int $site_id, array $options = [], bool $async = fal return \WP_Ultimo\Site_Exporter\Site_Exporter::get_instance()->handle_site_export($site_id, $options); } +/** + * Exports the entire network. + * + * Returns the export filename string on synchronous success, `true` on + * asynchronous (background) success, or a WP_Error on failure. + * + * @since 2.5.0 + * + * @param array $included_blog_ids Array of blog IDs to include. + * @param int $main_site_blog_id The designated main site blog ID (for reassignment). + * @param array $options Export options (include_plugins, include_themes, include_uploads, include_mu_plugins). + * @param bool $async If we should generate the export file asynchronously. + * @return string|true|\WP_Error Filename on sync success, true on async success, WP_Error on failure. + */ +function wu_exporter_export_network(array $included_blog_ids, int $main_site_blog_id, array $options = [], bool $async = false) { + + if ($async) { + if (! function_exists('wu_enqueue_async_action')) { + return new \WP_Error('not-enabled', __('The network exporter requires async action support.', 'ultimate-multisite')); + } + + $hash = wu_exporter_add_pending_network($included_blog_ids, $main_site_blog_id, $options); + + wu_enqueue_async_action( + 'wu_export_network', + [ + 'included_blog_ids' => $included_blog_ids, + 'main_site_blog_id' => $main_site_blog_id, + 'options' => $options, + 'hash' => $hash, + ], + 'site-exporter' + ); + + return true; + } + + /* + * For the synchronous path, call the exporter directly so errors can + * be captured and returned to the caller. + */ + return \WP_Ultimo\Site_Exporter\Network_Exporter::get_instance()->export($included_blog_ids, $main_site_blog_id, $options); +} + +/** + * Add a pending network export to the database. + * + * @since 2.5.0 + * + * @param array $included_blog_ids Array of blog IDs to include. + * @param int $main_site_blog_id The designated main site blog ID. + * @param array $options Export options. + * @return string Hash for tracking. + */ +function wu_exporter_add_pending_network(array $included_blog_ids, int $main_site_blog_id, array $options): string { + + global $wpdb; + + $hash = wp_hash(wp_json_encode($included_blog_ids) . $main_site_blog_id . microtime()); + + $data = [ + 'hash' => $hash, + 'included_blog_ids' => maybe_serialize($included_blog_ids), + 'main_site_blog_id' => $main_site_blog_id, + 'options' => maybe_serialize($options), + 'created_at' => current_time('mysql'), + ]; + + $wpdb->insert($wpdb->base_prefix . 'wu_pending_network_exports', $data); + + return $hash; +} + /** * Gets a list of all the exports generated to date. * @@ -252,7 +325,7 @@ function wu_exporter_get_pending(): array { // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- $table is built from $wpdb->base_prefix, not user input. $query = $wpdb->prepare("SELECT meta_key, meta_value as options FROM {$table} WHERE meta_key LIKE %s", $like); - $results = $wpdb->get_results($query); // phpcs:ignore WordPress.DB.DirectDatabaseQuery + $results = $wpdb->get_results($query); // phpcs:ignore WordPress.DB.DirectDatabaseQuery,WordPress.DB.PreparedSQL.NotPrepared return array_map( function ($item) { diff --git a/inc/site-exporter/class-network-exporter.php b/inc/site-exporter/class-network-exporter.php new file mode 100644 index 00000000..6c8c53f8 --- /dev/null +++ b/inc/site-exporter/class-network-exporter.php @@ -0,0 +1,834 @@ +included_blog_ids = $included_blog_ids; + $this->designated_main_site_blog_id = $main_site_blog_id; + $this->options = wp_parse_args( + $options, + [ + 'include_plugins' => true, + 'include_themes' => true, + 'include_uploads' => true, + 'include_mu_plugins' => true, + ] + ); + + // Validate inputs + $validation_error = $this->validate_inputs(); + if (is_wp_error($validation_error)) { + return $validation_error; + } + + // Create staging directory + $this->staging_dir = $this->create_staging_directory(); + if (is_wp_error($this->staging_dir)) { + return $this->staging_dir; + } + + try { + // Step 1: Export network-level SQL (wp_blogs, wp_site, wp_sitemeta, etc.) + $this->export_network_sql(); + + // Step 2: Export global users and usermeta + $this->export_users(); + + // Step 3: Export each included site using mu-migration + $this->export_sites(); + + // Step 4: Export network-shared wp-content (plugins, themes, mu-plugins, uploads) + $this->export_network_content(); + + // Step 5: Create network.json manifest + $this->create_network_manifest(); + + // Step 6: Create the final ZIP + $zip_result = $this->create_zip(); + if (is_wp_error($zip_result)) { + return $zip_result; + } + + // Cleanup staging directory + $this->cleanup_staging_directory(); + + return $this->result_filename; + } catch (\Exception $e) { + $this->cleanup_staging_directory(); + return new \WP_Error('export_failed', $e->getMessage()); + } + } + + /** + * Validate inputs before export. + * + * @since 2.5.0 + * @return true|\WP_Error + */ + protected function validate_inputs() { + + if (empty($this->included_blog_ids)) { + return new \WP_Error('no_sites_selected', __('No sites selected for export.', 'ultimate-multisite')); + } + + if (! in_array($this->designated_main_site_blog_id, $this->included_blog_ids, true)) { + return new \WP_Error( + 'main_site_not_included', + __('The designated main site must be in the list of included sites.', 'ultimate-multisite') + ); + } + + return true; + } + + /** + * Create staging directory under sys_get_temp_dir(). + * + * @since 2.5.0 + * @return string|\WP_Error + */ + protected function create_staging_directory() { + + $tmp_dir = rtrim(sys_get_temp_dir(), DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR; + $rand = wp_rand(10000, 99999); + + $staging_dir = $tmp_dir . 'wu-network-export-' . $rand . DIRECTORY_SEPARATOR; + + if (! mkdir($staging_dir, 0755, true)) { + return new \WP_Error('staging_dir_failed', __('Failed to create staging directory.', 'ultimate-multisite')); + } + + return $staging_dir; + } + + /** + * Export network-level SQL (wp_blogs, wp_site, wp_sitemeta, etc.). + * + * @since 2.5.0 + * @return void + */ + protected function export_network_sql() { + + global $wpdb; + + $sql_file = $this->staging_dir . 'network.sql'; + + $sql_content = "-- Network Export SQL\n"; + $sql_content .= '-- Exported at: ' . current_time('mysql') . "\n\n"; + + // Export wp_blogs - only included blog IDs + $blog_ids = array_map('intval', $this->included_blog_ids); + $blog_placeholders = implode(',', array_fill(0, count($blog_ids), '%d')); + $blogs = $wpdb->get_results( + $wpdb->prepare( + // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- $blog_placeholders is built from count($blog_ids), not user input. + "SELECT * FROM {$wpdb->blogs} WHERE blog_id IN ($blog_placeholders)", + $blog_ids + ), + ARRAY_A + ); + + if (! empty($blogs)) { + $sql_content .= "-- wp_blogs\n"; + $sql_content .= $this->array_to_insert_sql($wpdb->blogs, $blogs) . "\n\n"; + } + + // Export wp_site + $site = $wpdb->get_row("SELECT * FROM {$wpdb->site}", ARRAY_A); + if ($site) { + $sql_content .= "-- wp_site\n"; + $sql_content .= $this->array_to_insert_sql($wpdb->site, [$site]) . "\n\n"; + } + + // Export wp_sitemeta - whitelist keys + $sitemeta_keys = $this->get_sitemeta_whitelist(); + if (! empty($sitemeta_keys)) { + $placeholders = implode(',', array_fill(0, count($sitemeta_keys), '%s')); + $sitemeta = $wpdb->get_results( + $wpdb->prepare( + // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- $placeholders is built from count($sitemeta_keys), not user input. + "SELECT * FROM {$wpdb->sitemeta} WHERE meta_key IN ($placeholders)", + $sitemeta_keys + ), + ARRAY_A + ); + + if (! empty($sitemeta)) { + $sql_content .= "-- wp_sitemeta\n"; + $sql_content .= $this->array_to_insert_sql($wpdb->sitemeta, $sitemeta) . "\n\n"; + } + } + + // Export wp_signups if it exists + if ($wpdb->get_var("SHOW TABLES LIKE '{$wpdb->prefix}signups'") === $wpdb->prefix . 'signups') { + $signups = $wpdb->get_results("SELECT * FROM {$wpdb->signups}", ARRAY_A); + if (! empty($signups)) { + $sql_content .= "-- wp_signups\n"; + $sql_content .= $this->array_to_insert_sql($wpdb->prefix . 'signups', $signups) . "\n\n"; + } + } + + // Export wp_registration_log if it exists + if ($wpdb->get_var("SHOW TABLES LIKE '{$wpdb->prefix}registration_log'") === $wpdb->prefix . 'registration_log') { + $reg_log = $wpdb->get_results("SELECT * FROM {$wpdb->registration_log}", ARRAY_A); + if (! empty($reg_log)) { + $sql_content .= "-- wp_registration_log\n"; + $sql_content .= $this->array_to_insert_sql($wpdb->prefix . 'registration_log', $reg_log) . "\n\n"; + } + } + + // Export wp_blogmeta if it exists (BerlinDB) + if ($wpdb->get_var("SHOW TABLES LIKE '{$wpdb->prefix}blogmeta'") === $wpdb->prefix . 'blogmeta') { + $blog_ids_list = implode(',', array_map('intval', $this->included_blog_ids)); + $blogmeta = $wpdb->get_results( + // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- $blog_ids_list is built from intval blog IDs, not user input. + $wpdb->prepare("SELECT * FROM {$wpdb->blogmeta} WHERE blog_id IN ({$blog_ids_list})"), + ARRAY_A + ); + + if (! empty($blogmeta)) { + $sql_content .= "-- wp_blogmeta\n"; + $sql_content .= $this->array_to_insert_sql($wpdb->prefix . 'blogmeta', $blogmeta) . "\n\n"; + } + } + + // Export BerlinDB tables (WP_Ultimo tables) - these are global + $this->export_berlinbd_tables($sql_content); + + file_put_contents($sql_file, $sql_content); + } + + /** + * Export BerlinDB tables (WP_Ultimo global tables). + * + * @since 2.5.0 + * @param string &$sql_content SQL content to append to. + * @return void + */ + protected function export_berlinbd_tables(&$sql_content) { + + global $wpdb; + + // Try to detect WP_Ultimo tables + $wu_tables = [ + 'wu_customers', + 'wu_memberships', + 'wu_products', + 'wu_subscriptions', + 'wu_payment_plans', + 'wu_api_keys', + 'wu_webhooks', + ]; + + foreach ($wu_tables as $table) { + $full_table = $wpdb->prefix . $table; + // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- $full_table is built from $wpdb->prefix, not user input. + if ($wpdb->get_var("SHOW TABLES LIKE '$full_table'") === $full_table) { + $rows = $wpdb->get_results( + // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- $full_table is built from $wpdb->prefix, not user input. + "SELECT * FROM {$full_table}", + ARRAY_A + ); + if (! empty($rows)) { + $sql_content .= "-- {$full_table}\n"; + $sql_content .= $this->array_to_insert_sql($full_table, $rows) . "\n\n"; + } + } + } + } + + /** + * Get whitelist of sitemeta keys to include. + * + * @since 2.5.0 + * @return array + */ + protected function get_sitemeta_whitelist() { + + $default_keys = [ + 'site_admins', + 'registration', + 'add_new_users', + 'fileupload_maxk', + 'siteurl', + 'home', + 'blogname', + 'blogdescription', + 'admin_email', + 'active_plugins', + 'themes', + 'template', + 'stylesheet', + 'comment_registration', + 'users_can_register', + 'admin_language', + 'language', + 'timezone', + 'date_format', + 'time_format', + 'start_of_week', + 'mu_plugins', + 'activated_plugins', + 'blog_public', + 'permalink', + 'category_base', + 'tag_base', + 'show_avatars', + 'avatar_rating', + 'avatar_default', + 'medium_size_w', + 'medium_size_h', + 'avatar_default', + 'large_size_w', + 'large_size_h', + 'thumbnail_size_w', + 'thumbnail_size_h', + 'thumbnail_crop', + 'medium_large_size_w', + 'medium_large_size_h', + 'global_terms_enabled', + 'ms_files_rewriting', + 'upload_filetypes', + 'blog_upload_space', + 'max_upload_size', + 'post_count', + 'default_comment_status', + 'default_ping_status', + 'default_pingback_flag', + 'comment_moderation', + 'comment_whitelist', + 'comment_max_links', + 'moderation_keys', + 'active_sitewide_plugins', + 'network_type', + 'wpmu_signup', + 'new_blog_template', + 'new_blogmeta', + ]; + + /** + * Filter the whitelist of sitemeta keys to include in network export. + * + * @since 2.5.0 + * @param array $keys Default whitelist keys. + * @return array + */ + return apply_filters('wu_network_export_sitemeta_keys', $default_keys); + } + + /** + * Export global users and usermeta. + * + * @since 2.5.0 + * @return void + */ + protected function export_users() { + + global $wpdb; + + // Export wp_users - all users (global in multisite) + $users = $wpdb->get_results("SELECT * FROM {$wpdb->users}", ARRAY_A); + + if (! empty($users)) { + $csv_file = $this->staging_dir . 'users.csv'; + $this->array_to_csv($csv_file, $users); + } + + // Export wp_usermeta - filter out blog-prefixed capability rows for excluded blogs + $excluded_blog_ids = array_diff( + get_sites( + [ + 'fields' => 'ids', + 'number' => PHP_INT_MAX, + ] + ), + $this->included_blog_ids + ); + + $meta_key_where = ''; + if (! empty($excluded_blog_ids)) { + $excluded_prefixes = array_map( + function ($blog_id) use ($wpdb) { + return $wpdb->prepare('meta_key NOT LIKE %s', $wpdb->base_prefix . $blog_id . '_%'); + }, + $excluded_blog_ids + ); + $meta_key_where = 'WHERE ' . implode(' AND ', $excluded_prefixes); + } + + $usermeta = $wpdb->get_results( + // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- $meta_key_where is built from excluded blog IDs, not user input. + "SELECT * FROM {$wpdb->usermeta} {$meta_key_where}", + ARRAY_A + ); + + if (! empty($usermeta)) { + $csv_file = $this->staging_dir . 'usermeta.csv'; + $this->array_to_csv($csv_file, $usermeta); + } + } + + /** + * Export each included site using mu-migration. + * + * @since 2.5.0 + * @return void + */ + protected function export_sites() { + + $sites_dir = $this->staging_dir . 'sites' . DIRECTORY_SEPARATOR; + mkdir($sites_dir, 0755, true); + + $export_command = new ExportCommand(); + + foreach ($this->included_blog_ids as $blog_id) { + $site_dir = $sites_dir . $blog_id . DIRECTORY_SEPARATOR; + mkdir($site_dir, 0755, true); + + // Create a temp zip file for this site + $tmp_dir = rtrim(sys_get_temp_dir(), DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR; + $rand = wp_rand(10000, 99999); + $site_zip = $tmp_dir . 'mu-migration-site-' . $blog_id . '-' . $rand . '.zip'; + + // Build assoc_args for mu-migration + $assoc_args = [ + 'blog_id' => $blog_id, + ]; + + if ($this->options['include_plugins']) { + $assoc_args['plugins'] = true; + } + + if ($this->options['include_themes']) { + $assoc_args['themes'] = true; + } + + if ($this->options['include_uploads']) { + $assoc_args['uploads'] = true; + } + + // Call mu-migration export all + // We need to temporarily change the output file + $export_command->all([$site_zip], $assoc_args, false); + + // If the zip was created, extract it to the sites directory + if (file_exists($site_zip)) { + $this->extract_zip_to_directory($site_zip, $site_dir); + unlink($site_zip); + } + } + } + + /** + * Extract ZIP to a directory. + * + * @since 2.5.0 + * @param string $zip_file Path to ZIP file. + * @param string $dest_dir Destination directory. + * @return void + */ + protected function extract_zip_to_directory($zip_file, $dest_dir) { + + if (! class_exists('ZipArchive')) { + return; + } + + $zip = new \ZipArchive(); + $result = $zip->open($zip_file); + + if (true === $result) { + $zip->extractTo($dest_dir); + $zip->close(); + } + } + + /** + * Export network-shared wp-content (plugins, themes, mu-plugins, uploads). + * + * @since 2.5.0 + * @return void + */ + protected function export_network_content() { + + $wp_content_dir = $this->staging_dir . 'wp-content' . DIRECTORY_SEPARATOR; + mkdir($wp_content_dir, 0755, true); + + // Always include mu-plugins if present + if ($this->options['include_mu_plugins']) { + $mu_plugins_dir = WPMU_PLUGIN_DIR; + if (is_dir($mu_plugins_dir)) { + $dest = $wp_content_dir . 'mu-plugins'; + $this->copy_directory($mu_plugins_dir, $dest); + } + } + + // Include network-active plugins + if ($this->options['include_plugins']) { + $plugins_dir = WP_PLUGIN_DIR; + if (is_dir($plugins_dir)) { + $network_plugins = get_site_option('active_sitewide_plugins', []); + if (! empty($network_plugins)) { + $dest = $wp_content_dir . 'plugins'; + foreach ($network_plugins as $plugin => $timestamp) { + $plugin_dir = dirname($plugins_dir . '/' . $plugin); + if (is_dir($plugin_dir)) { + $this->copy_directory($plugin_dir, $dest . '/' . basename($plugin_dir)); + } + } + } + } + } + + // Include network-installed themes + if ($this->options['include_themes']) { + $themes_dir = get_theme_root(); + if (is_dir($themes_dir)) { + $dest = $wp_content_dir . 'themes'; + $this->copy_directory($themes_dir, $dest); + } + } + + // Include network-root uploads (not per-site) + if ($this->options['include_uploads']) { + $upload_dir = wp_upload_dir(); + $base_dir = $upload_dir['basedir']; + + // Only include the base uploads dir, not per-site dirs + // Per-site uploads are included in each site's bundle + if (is_dir($base_dir)) { + $dest = $wp_content_dir . 'uploads'; + $this->copy_directory($base_dir, $dest); + } + } + } + + /** + * Copy directory recursively. + * + * @since 2.5.0 + * @param string $src Source directory. + * @param string $dest Destination directory. + * @return void + */ + protected function copy_directory($src, $dest) { + + if (! is_dir($src)) { + return; + } + + if (! is_dir($dest)) { + mkdir($dest, 0755, true); + } + + $dir = opendir($src); + while (($file = readdir($dir)) !== false) { + if ('.' === $file || '..' === $file) { + continue; + } + + $src_path = $src . DIRECTORY_SEPARATOR . $file; + $dest_path = $dest . DIRECTORY_SEPARATOR . $file; + + if (is_dir($src_path)) { + $this->copy_directory($src_path, $dest_path); + } else { + copy($src_path, $dest_path); + } + } + closedir($dir); + } + + /** + * Create network.json manifest. + * + * @since 2.5.0 + * @return void + */ + protected function create_network_manifest() { + + global $wpdb, $wp_version; + + $manifest = [ + 'format_version' => 1, + 'exported_at' => current_time('mysql', true), + 'exported_by_plugin_version' => defined('WP_ULTIMO_VERSION') ? WP_ULTIMO_VERSION : 'unknown', + 'source' => [ + 'url' => network_site_url(), + 'is_subdomain_install' => defined('SUBDOMAIN_INSTALL') ? SUBDOMAIN_INSTALL : false, + 'main_site_blog_id' => defined('BLOG_ID_CURRENT_SITE') ? BLOG_ID_CURRENT_SITE : 1, + 'db_prefix' => $wpdb->prefix, + 'wp_version' => $wp_version, + 'php_version' => PHP_VERSION, + ], + 'designated_main_site_blog_id' => $this->designated_main_site_blog_id, + 'included_blog_ids' => $this->included_blog_ids, + 'excluded_blog_ids' => array_diff( + get_sites( + [ + 'fields' => 'ids', + 'number' => PHP_INT_MAX, + ] + ), + $this->included_blog_ids + ), + 'network_active_plugins' => array_keys(get_site_option('active_sitewide_plugins', [])), + 'sitemeta_keys_restored' => $this->get_sitemeta_whitelist(), + 'options' => $this->options, + ]; + + $json_file = $this->staging_dir . 'network.json'; + file_put_contents($json_file, wp_json_encode($manifest, JSON_PRETTY_PRINT)); + } + + /** + * Create the final ZIP file. + * + * @since 2.5.0 + * @return string|\WP_Error + */ + protected function create_zip() { + + $upload_dir = wp_upload_dir(); + $export_dir = wu_maybe_create_folder('wu-site-exports'); + + $date_str = gmdate('Y-m-d'); + $time_str = time(); + $rand = wp_rand(1000, 9999); + + $filename = "wu-network-export-{$date_str}-{$time_str}-{$rand}.zip"; + $zip_path = trailingslashit($export_dir) . $filename; + + // Use ZipArchive to create the final ZIP + if (! class_exists('ZipArchive')) { + return new \WP_Error('zip_not_available', __('ZIP extension not available.', 'ultimate-multisite')); + } + + $zip = new \ZipArchive(); + $result = $zip->open($zip_path, \ZipArchive::CREATE | \ZipArchive::OVERWRITE); + + if (true !== $result) { + return new \WP_Error('zip_create_failed', __('Failed to create ZIP file.', 'ultimate-multisite')); + } + + // Add all files from staging directory + $this->add_directory_to_zip($zip, $this->staging_dir, ''); + + $zip->close(); + + $this->result_filename = $filename; + + return $filename; + } + + /** + * Add directory contents to ZIP recursively. + * + * @since 2.5.0 + * @param ZipArchive $zip ZipArchive instance. + * @param string $source_dir Source directory. + * @param string $zip_path Current path in ZIP. + * @return void + */ + protected function add_directory_to_zip($zip, $source_dir, $zip_path) { + + $files = new \RecursiveIteratorIterator( + new \RecursiveDirectoryIterator($source_dir), + \RecursiveIteratorIterator::LEAVES_ONLY + ); + + foreach ($files as $file) { + if ($file->isDir()) { + continue; + } + + $file_path = $file->getRealPath(); + $relative_path = substr($file_path, strlen($this->staging_dir)); + + $zip->addFile($file_path, ltrim($relative_path, '/')); + } + } + + /** + * Cleanup staging directory. + * + * @since 2.5.0 + * @return void + */ + protected function cleanup_staging_directory() { + + if (! empty($this->staging_dir) && is_dir($this->staging_dir)) { + $this->delete_directory($this->staging_dir); + } + } + + /** + * Delete directory recursively. + * + * @since 2.5.0 + * @param string $dir Directory to delete. + * @return void + */ + protected function delete_directory($dir) { + + if (! is_dir($dir)) { + return; + } + + $files = array_diff(scandir($dir), ['.', '..']); + foreach ($files as $file) { + $path = $dir . DIRECTORY_SEPARATOR . $file; + if (is_dir($path)) { + $this->delete_directory($path); + } else { + unlink($path); + } + } + rmdir($dir); + } + + /** + * Convert array to INSERT SQL statements. + * + * @since 2.5.0 + * @param string $table Table name. + * @param array $rows Array of row data. + * @return string + */ + protected function array_to_insert_sql($table, $rows) { + + if (empty($rows)) { + return ''; + } + + global $wpdb; + + $sql = ''; + $columns = array_keys($rows[0]); + $column_list = implode( + ', ', + array_map( + function ($col) use ($wpdb) { + return '`' . $wpdb->escape($col) . '`'; + }, + $columns + ) + ); + + foreach ($rows as $row) { + $values = array_map( + function ($value) use ($wpdb) { + if (is_null($value)) { + return 'NULL'; + } + return "'" . $wpdb->escape($value) . "'"; + }, + $row + ); + + $sql .= "INSERT INTO `{$table}` ({$column_list}) VALUES (" . implode(', ', $values) . ");\n"; + } + + return $sql; + } + + /** + * Convert array to CSV file. + * + * @since 2.5.0 + * @param string $csv_file Path to CSV file. + * @param array $rows Array of row data. + * @return void + */ + protected function array_to_csv($csv_file, $rows) { + + if (empty($rows)) { + return; + } + + $fp = fopen($csv_file, 'w'); + + // Write header + fputcsv($fp, array_keys($rows[0])); + + // Write data + foreach ($rows as $row) { + fputcsv($fp, $row); + } + + fclose($fp); + } +} diff --git a/inc/site-exporter/class-site-exporter.php b/inc/site-exporter/class-site-exporter.php index 200e4aae..29d47b3e 100644 --- a/inc/site-exporter/class-site-exporter.php +++ b/inc/site-exporter/class-site-exporter.php @@ -67,7 +67,6 @@ public function init(): void { * @return void */ public function setup(): void { - /* * Register the mu-migration WP-CLI commands early so WP-CLI discovers * them during the command bootstrap phase. @@ -92,6 +91,8 @@ public function setup(): void { add_action('wu_export_site', [$this, 'handle_site_export'], 10, 3); + add_action('wu_export_network', [$this, 'handle_network_export'], 10, 3); + add_action('wu_import_site', [$this, 'handle_site_import']); add_filter('wu_site_exporter_files_to_zip', [$this, 'maybe_exclude_wp_ultimo_plugins']); @@ -116,6 +117,10 @@ public function setup(): void { add_filter('wu_site_bulk_actions', [$this, 'add_bulk_export_action']); add_action('wu_handle_bulk_action_form_site_export', [$this, 'handle_bulk_export'], 10, 3); + // Network export (GH#1149) + add_filter('wu_site_bulk_actions', [$this, 'add_bulk_network_export_action']); + add_action('wu_handle_bulk_action_form_network_export', [$this, 'handle_bulk_network_export'], 10, 3); + // Authenticated file download handler (GH#1010) Export_Download_Handler::get_instance()->init(); @@ -1097,7 +1102,7 @@ public function handle_direct_export_request(): void { $new_url = sanitize_text_field(wp_unslash($_POST['new_url'] ?? get_site_url())); } - if ( empty($zip_url) || ( is_multisite() && empty($new_url) ) ) { + if ( empty($zip_url) || (is_multisite() && empty($new_url)) ) { wp_safe_redirect( add_query_arg( [ @@ -1206,7 +1211,6 @@ public function display_export_notices(): void { * @return void */ public function enqueue_wp_sites_scripts(string $hook): void { - /* * The Ultimate Multisite Sites list page (wp-ultimo-sites) hosts the * "Import Site" wubox modal which contains an "Upload ZIP File" button @@ -1323,6 +1327,15 @@ public function register_forms(): void { 'capability' => 'manage_network', ] ); + + wu_register_form( + 'export_network', + [ + 'render' => [$this, 'render_export_network_modal'], + 'handler' => [$this, 'handle_export_network_modal'], + 'capability' => 'manage_network', + ] + ); } /** @@ -1460,6 +1473,221 @@ public function handle_export_site_modal(): void { ); } + /** + * Add network export to bulk actions. + * + * @since 2.5.0 + * @param array $actions Bulk actions. + * @return array + */ + public function add_bulk_network_export_action($actions) { + + $actions['network_export'] = __('Export Network', 'ultimate-multisite'); + + return $actions; + } + + /** + * Handle bulk network export. + * + * @since 2.5.0 + * @param string $action Bulk action. + * @param array $ids Site IDs. + * @return void + */ + public function handle_bulk_network_export($action, $ids) { + + if ('network_export' !== $action) { + return; + } + + // Redirect to the network export modal with pre-selected sites + $site_ids = implode(',', array_map('intval', $ids)); + + wp_safe_redirect( + add_query_arg( + [ + 'page' => 'wp-ultimo-sites', + 'action' => 'export_network', + 'selected' => $site_ids, + ], + network_admin_url('admin.php') + ) + ); + exit; + } + + /** + * Renders the export network modal. + * + * @since 2.5.0 + * @return void + */ + public function render_export_network_modal(): void { + + $selected_ids = wu_request('selected', ''); + $selected_array = $selected_ids ? array_map('intval', explode(',', $selected_ids)) : []; + + // Get all sites in the network + $sites = get_sites( + [ + 'number' => PHP_INT_MAX, + 'fields' => 'ids', + ] + ); + + $site_options = []; + foreach ($sites as $site_id) { + $site = get_site($site_id); + if ($site) { + $site_options[ $site_id ] = $site->domain . $site->path; + } + } + + $fields = [ + 'included_sites' => [ + 'type' => 'checkboxes', + 'title' => __('Sites to Include', 'ultimate-multisite'), + 'desc' => __('Select all sites to include in the network export.', 'ultimate-multisite'), + 'options' => $site_options, + 'value' => $selected_array ?: array_keys($site_options), + 'html_attr' => [ + 'data-wu-select-all' => 'included_sites', + ], + ], + 'main_site' => [ + 'type' => 'radio', + 'title' => __('Main Site', 'ultimate-multisite'), + 'desc' => __('Select which site should become the main site after import. This is required if the current main site is excluded.', 'ultimate-multisite'), + 'options' => $site_options, + 'value' => defined('BLOG_ID_CURRENT_SITE') ? BLOG_ID_CURRENT_SITE : 1, + 'html_attr' => [ + 'data-wu-show-if' => json_encode( + [ + 'condition' => 'NOT', + 'variable' => 'included_sites', + 'value' => (string) (defined('BLOG_ID_CURRENT_SITE') ? BLOG_ID_CURRENT_SITE : 1), + ] + ), + ], + ], + 'include_plugins' => [ + 'type' => 'toggle', + 'title' => __('Include Network Plugins', 'ultimate-multisite'), + 'desc' => __('Include network-active plugins in the export.', 'ultimate-multisite'), + 'value' => true, + ], + 'include_themes' => [ + 'type' => 'toggle', + 'title' => __('Include Themes', 'ultimate-multisite'), + 'desc' => __('Include all network-installed themes.', 'ultimate-multisite'), + 'value' => true, + ], + 'include_uploads' => [ + 'type' => 'toggle', + 'title' => __('Include Uploads', 'ultimate-multisite'), + 'desc' => __('Include media files from the network uploads folder and all sites.', 'ultimate-multisite'), + 'value' => true, + ], + 'include_mu_plugins' => [ + 'type' => 'toggle', + 'title' => __('Include Must-Use Plugins', 'ultimate-multisite'), + 'desc' => __('Include must-use plugins (mu-plugins).', 'ultimate-multisite'), + 'value' => true, + ], + 'background_run' => [ + 'type' => 'toggle', + 'title' => __('Run in Background', 'ultimate-multisite'), + 'desc' => __('For large networks, run the export as a background process.', 'ultimate-multisite'), + 'value' => true, + ], + 'submit_button' => [ + 'type' => 'submit', + 'title' => __('Export Network', 'ultimate-multisite'), + 'value' => 'save', + 'classes' => 'button button-primary wu-w-full', + 'wrapper_classes' => 'wu-items-end wu-text-right', + ], + ]; + + $form = new \WP_Ultimo\UI\Form( + 'export_network', + $fields, + [ + 'views' => 'admin-pages/fields', + 'classes' => 'wu-modal-form wu-widget-list wu-striped wu-m-0 wu-mt-0', + 'field_wrapper_classes' => 'wu-w-full wu-box-border wu-items-center wu-flex wu-justify-between wu-p-4 wu-m-0 wu-border-t wu-border-l-0 wu-border-r-0 wu-border-b-0 wu-border-gray-300 wu-border-solid', + ] + ); + + $form->render(); + } + + /** + * Handles the export network modal submission. + * + * @since 2.5.0 + * @return void + */ + public function handle_export_network_modal(): void { + + $included_sites = wu_request('included_sites', []); + $main_site = (int) wu_request('main_site', 0); + $background = (bool) wu_request('background_run', false); + + $options = [ + 'include_plugins' => (bool) wu_request('include_plugins', true), + 'include_themes' => (bool) wu_request('include_themes', true), + 'include_uploads' => (bool) wu_request('include_uploads', true), + 'include_mu_plugins' => (bool) wu_request('include_mu_plugins', true), + ]; + + // Validate: at least one site selected + if (empty($included_sites)) { + wp_send_json_error(new \WP_Error('no_sites_selected', __('Please select at least one site to export.', 'ultimate-multisite'))); + } + + // Validate: main site must be in included sites + if (! in_array($main_site, array_map('intval', $included_sites), true)) { + wp_send_json_error(new \WP_Error('main_site_not_included', __('The selected main site must be in the list of included sites.', 'ultimate-multisite'))); + } + + $export_result = wu_exporter_export_network( + array_map('intval', $included_sites), + $main_site, + $options, + $background + ); + + if (is_wp_error($export_result)) { + wp_send_json_error($export_result); + } + + if (! $background && is_string($export_result)) { + /* + * Synchronous export succeeded — the filename was returned. + * Send a download_url so wubox.js can close the modal and + * immediately trigger the ZIP download without a page redirect. + */ + wp_send_json_success( + [ + 'download_url' => wu_exporter_get_raw_download_url($export_result), + ] + ); + } + + /* + * Background export queued. Redirect to the sites list and pass + * message=network_export_started so display_export_notices() displays + * the correct success banner. + */ + wp_send_json_success( + [ + 'redirect_url' => wu_network_admin_url('wp-ultimo-sites', ['message' => 'network_export_started']), + ] + ); + } + /** * Renders the import site modal. * @@ -1968,7 +2196,7 @@ public function maybe_exclude_wp_ultimo_plugins(array $files_to_zip): array { } if (! $excluded) { - $full_path = trailingslashit($plugins_folder) . $entry; + $full_path = trailingslashit($plugins_folder) . $entry; $files_to_zip[ 'wp-content/plugins/' . $entry ] = $full_path; } } @@ -2087,6 +2315,61 @@ public function handle_site_export(int $site_id, array $options = [], string $ha return $export_name; } + /** + * Handles the network export (async). + * + * @since 2.5.0 + * + * @param array $included_blog_ids Array of blog IDs to include. + * @param int $main_site_blog_id The designated main site blog ID. + * @param array $options Export options. + * @param string $hash Hash for tracking. + * @return string|\WP_Error The export filename on success, WP_Error on failure. + */ + public function handle_network_export(array $included_blog_ids, int $main_site_blog_id, array $options = [], string $hash = '') { + + $this->load_dependencies(); + + $start = microtime(true); + + try { + $export_result = \WP_Ultimo\Site_Exporter\Network_Exporter::get_instance()->export( + $included_blog_ids, + $main_site_blog_id, + $options + ); + } catch (\Exception $e) { + // Log the exception for server admins and return a user-friendly error. + error_log('WP Ultimo network export error: ' . $e->getMessage()); + + return new \WP_Error( + 'export-failed', + __('The network export failed due to a server error. Please check server logs for details.', 'ultimate-multisite') + ); + } + + if (is_wp_error($export_result)) { + return $export_result; + } + + if (! $export_result) { + return new \WP_Error( + 'export-failed', + __('The network export file could not be created. Please check server permissions and available disk space, then try again.', 'ultimate-multisite') + ); + } + + $time = microtime(true) - $start; + + wu_exporter_save_generation_time($export_result, $time); + + if ( ! empty($hash)) { + wu_exporter_delete_transient("wu_pending_network_export_{$hash}"); + } + + return $export_result; + } + /** * Handles the site import. * @@ -2156,7 +2439,7 @@ public function handle_site_import(): bool { wu_exporter_delete_transient("wu_pending_site_import_{$hash}"); - $delete_file = !empty($options['delete_file']); + $delete_file = ! empty($options['delete_file']); if ($delete_file) { $attachment_id = attachment_url_to_postid($options['zip_url']); diff --git a/tests/WP_Ultimo/Site_Exporter/Network_Exporter_Test.php b/tests/WP_Ultimo/Site_Exporter/Network_Exporter_Test.php new file mode 100644 index 00000000..e437fe1d --- /dev/null +++ b/tests/WP_Ultimo/Site_Exporter/Network_Exporter_Test.php @@ -0,0 +1,190 @@ +exporter = Network_Exporter::get_instance(); + } + + /** + * Test singleton returns correct instance. + */ + public function test_singleton_returns_correct_instance(): void { + + $this->assertInstanceOf(Network_Exporter::class, $this->exporter); + } + + /** + * Test singleton returns same instance on repeated calls. + */ + public function test_singleton_returns_same_instance(): void { + + $this->assertSame(Network_Exporter::get_instance(), Network_Exporter::get_instance()); + } + + /** + * Test that export fails when no sites are selected. + */ + public function test_network_export_refuses_empty_included_blog_ids(): void { + + $result = $this->exporter->export([], 1, []); + + $this->assertWPError($result); + $this->assertEquals('no_sites_selected', $result->get_error_code()); + } + + /** + * Test that export fails when main site is not in included list. + */ + public function test_network_export_refuses_main_site_not_in_included(): void { + + // Create a site to have at least one site in the network + $site_id = $this->factory->blog->create(); + + // Try to export with main_site_blog_id not in included_blog_ids + $result = $this->exporter->export([$site_id], 1, []); + + $this->assertWPError($result); + $this->assertEquals('main_site_not_included', $result->get_error_code()); + } + + /** + * Test sitemeta whitelist contains expected keys. + */ + public function test_sitemeta_whitelist_contains_expected_keys(): void { + + $reflection = new \ReflectionClass($this->exporter); + $method = $reflection->getMethod('get_sitemeta_whitelist'); + $method->setAccessible(true); + + $keys = $method->invoke($this->exporter); + + $this->assertContains('site_admins', $keys, 'site_admins must be in whitelist'); + $this->assertContains('registration', $keys, 'registration must be in whitelist'); + $this->assertContains('active_plugins', $keys, 'active_plugins must be in whitelist'); + } + + /** + * Test that network.json manifest has required fields. + */ + public function test_network_json_schema_has_required_fields(): void { + + // Create a site + $site_id = $this->factory->blog->create(); + + // Use reflection to test the manifest creation + $reflection = new \ReflectionClass($this->exporter); + $method = $reflection->getMethod('create_network_manifest'); + $method->setAccessible(true); + + // Set up the exporter state + $reflection_property = $reflection->getProperty('included_blog_ids'); + $reflection_property->setAccessible(true); + $reflection_property->setValue($this->exporter, [$site_id]); + + $reflection_property = $reflection->getProperty('designated_main_site_blog_id'); + $reflection_property->setAccessible(true); + $reflection_property->setValue($this->exporter, $site_id); + + $reflection_property = $reflection->getProperty('options'); + $reflection_property->setAccessible(true); + $reflection_property->setValue($this->exporter, [ + 'include_plugins' => true, + 'include_themes' => true, + 'include_uploads' => true, + 'include_mu_plugins' => true, + ]); + + // Create a temp staging dir + $tmp_dir = rtrim(sys_get_temp_dir(), DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR; + $rand = wp_rand(10000, 99999); + $staging_dir = $tmp_dir . 'wu-network-export-test-' . $rand . DIRECTORY_SEPARATOR; + mkdir($staging_dir, 0755, true); + + $reflection_property = $reflection->getProperty('staging_dir'); + $reflection_property->setAccessible(true); + $reflection_property->setValue($this->exporter, $staging_dir); + + // Call the method + $method->invoke($this->exporter); + + // Check the manifest file + $manifest_file = $staging_dir . 'network.json'; + $this->assertFileExists($manifest_file); + + $manifest = json_decode(file_get_contents($manifest_file), true); + + // Verify required fields + $this->assertArrayHasKey('format_version', $manifest, 'format_version must be in manifest'); + $this->assertArrayHasKey('exported_at', $manifest, 'exported_at must be in manifest'); + $this->assertArrayHasKey('exported_by_plugin_version', $manifest, 'exported_by_plugin_version must be in manifest'); + $this->assertArrayHasKey('source', $manifest, 'source must be in manifest'); + $this->assertArrayHasKey('designated_main_site_blog_id', $manifest, 'designated_main_site_blog_id must be in manifest'); + $this->assertArrayHasKey('included_blog_ids', $manifest, 'included_blog_ids must be in manifest'); + $this->assertArrayHasKey('options', $manifest, 'options must be in manifest'); + + // Verify source sub-fields + $this->assertArrayHasKey('url', $manifest['source'], 'source.url must be in manifest'); + $this->assertArrayHasKey('is_subdomain_install', $manifest['source'], 'source.is_subdomain_install must be in manifest'); + $this->assertArrayHasKey('main_site_blog_id', $manifest['source'], 'source.main_site_blog_id must be in manifest'); + $this->assertArrayHasKey('db_prefix', $manifest['source'], 'source.db_prefix must be in manifest'); + $this->assertArrayHasKey('wp_version', $manifest['source'], 'source.wp_version must be in manifest'); + + // Cleanup - delete staging directory directly (protected method can't be called from test) + if (is_dir($staging_dir)) { + $this->delete_directory($staging_dir); + } + } + + /** + * Delete directory recursively (for test cleanup). + * + * @param string $dir Directory to delete. + */ + private function delete_directory($dir): void { + + if (! is_dir($dir)) { + return; + } + + $files = array_diff(scandir($dir), ['.', '..']); + foreach ($files as $file) { + $path = $dir . DIRECTORY_SEPARATOR . $file; + if (is_dir($path)) { + $this->delete_directory($path); + } else { + unlink($path); + } + } + rmdir($dir); + } +} + + diff --git a/tests/WP_Ultimo/Site_Exporter_Test.php b/tests/WP_Ultimo/Site_Exporter_Test.php index 982658e4..4bf1a45e 100644 --- a/tests/WP_Ultimo/Site_Exporter_Test.php +++ b/tests/WP_Ultimo/Site_Exporter_Test.php @@ -70,7 +70,7 @@ public function test_singleton_returns_same_instance(): void { /** * Regression test for GH#1009 — circular dependency. * - * maybe_add_schedule() MUST register wu_site_every_minute regardless of + * The maybe_add_schedule() method must register wu_site_every_minute regardless of * whether there are pending imports. Previously it returned early when no * imports were pending, causing wp_schedule_event() to fail silently because * the custom interval was never registered. @@ -209,7 +209,7 @@ public function test_wp_schedule_event_succeeds_with_no_pending_imports(): void */ public function test_cron_schedules_filter_is_registered(): void { - $priority = has_filter('cron_schedules', [ $this->exporter, 'maybe_add_schedule' ]); + $priority = has_filter('cron_schedules', [$this->exporter, 'maybe_add_schedule']); $this->assertNotFalse( $priority, @@ -222,11 +222,45 @@ public function test_cron_schedules_filter_is_registered(): void { */ public function test_wu_import_site_action_is_registered(): void { - $priority = has_action('wu_import_site', [ $this->exporter, 'handle_site_import' ]); + $priority = has_action('wu_import_site', [$this->exporter, 'handle_site_import']); $this->assertNotFalse( $priority, 'handle_site_import must be registered as a wu_import_site action callback' ); } + + /** + * Test that the wu_export_network action is hooked to handle_network_export. + */ + public function test_wu_export_network_action_is_registered(): void { + + $priority = has_action('wu_export_network', [$this->exporter, 'handle_network_export']); + + $this->assertNotFalse( + $priority, + 'handle_network_export must be registered as a wu_export_network action callback' + ); + } + + /** + * Test that the network export form is registered. + */ + public function test_network_export_form_is_registered(): void { + + $forms = \WP_Ultimo\Managers\Form_Manager::get_instance()->get_registered_forms(); + + $this->assertArrayHasKey('export_network', $forms, 'export_network form must be registered'); + $this->assertEquals('manage_network', $forms['export_network']['capability']); + } + + /** + * Test that the bulk network export action is added. + */ + public function test_bulk_network_export_action_is_added(): void { + + $actions = apply_filters('wu_site_bulk_actions', []); + + $this->assertArrayHasKey('network_export', $actions, 'network_export bulk action must be added'); + } }