diff --git a/inc/site-exporter/class-export-download-handler.php b/inc/site-exporter/class-export-download-handler.php index 98bdbbee..3d03424d 100644 --- a/inc/site-exporter/class-export-download-handler.php +++ b/inc/site-exporter/class-export-download-handler.php @@ -198,32 +198,102 @@ private function stream_file(string $file_path, string $filename): void { $file_size = (int) filesize($file_path); + $this->prepare_streaming_environment(); + + /* + * A HEAD request only needs validated metadata. Streaming a multi-GB + * archive on HEAD wastes server resources and can trip PHP/LiteSpeed + * buffering safeguards before the browser starts the real download. + */ + $request_method = isset($_SERVER['REQUEST_METHOD']) ? sanitize_key(wp_unslash($_SERVER['REQUEST_METHOD'])) : ''; + $handle = null; + + if ('head' !== $request_method) { + $handle = @fopen($file_path, 'rb'); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fopen, WordPress.PHP.NoSilencedErrors.Discouraged -- A warning here would corrupt the download response. + + if (false === $handle) { + error_log('WP Ultimo export download open error: ' . $filename); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- Preserve the failed archive name for server administrators. + + wp_die( + esc_html__('Cannot open the export file for download.', 'ultimate-multisite'), + esc_html__('Download Failed', 'ultimate-multisite'), + ['response' => 500] + ); + } + } + nocache_headers(); + status_header(200); header('Content-Type: application/zip'); - header('Content-Disposition: attachment; filename="' . rawurlencode($filename) . '"'); + header('Content-Disposition: attachment; filename="' . sanitize_file_name($filename) . '"; filename*=UTF-8\'\'' . rawurlencode($filename)); header('Content-Length: ' . $file_size); header('Content-Transfer-Encoding: binary'); + header('X-Content-Type-Options: nosniff'); - /* - * Flush any buffered output before streaming to avoid memory issues - * with large export files. - */ - if (ob_get_level()) { - ob_end_clean(); + if ('head' === $request_method) { + exit; } - $handle = fopen($file_path, 'rb'); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fopen + while (! feof($handle)) { + $chunk = fread($handle, 8192); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fread - if ($handle) { - while (! feof($handle)) { - echo fread($handle, 8192); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped, WordPress.WP.AlternativeFunctions.file_system_operations_fread - flush(); + if (false === $chunk) { + error_log('WP Ultimo export download read error: ' . $filename); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- Preserve the failed archive name for server administrators. + break; } - fclose($handle); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose + echo $chunk; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- Binary ZIP stream. + flush(); + + if (connection_aborted()) { + break; + } } + fclose($handle); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose + exit; } + + /** + * Prepares PHP to stream large export archives without buffering them in memory. + * + * Some hosts leave multiple output buffers active in wp-admin requests. Cleaning + * only one buffer causes large ZIP downloads to accumulate in the remaining + * buffer until PHP exhausts memory. Close every removable buffer before sending + * binary file chunks. + * + * @since 2.5.1 + * + * @param int $minimum_level Lowest output buffer level to preserve. Used by tests. + * @return void + */ + private function prepare_streaming_environment(int $minimum_level = 0): void { + + if (function_exists('ignore_user_abort')) { + ignore_user_abort(true); + } + + if (function_exists('set_time_limit')) { + @set_time_limit(0); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- Best-effort for large authenticated downloads. + } + + if (function_exists('ini_set')) { + @ini_set('zlib.output_compression', 'Off'); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged, WordPress.PHP.IniSet.Risky -- Best-effort for binary streams. + @ini_set('output_buffering', 'Off'); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged, WordPress.PHP.IniSet.Risky -- Best-effort for binary streams. + } + + while (ob_get_level() > $minimum_level) { + $status = ob_get_status(); + + if (isset($status['del']) && ! $status['del']) { + break; + } + + if (! @ob_end_clean()) { // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- Avoid corrupting download output if a host buffer refuses cleanup. + break; + } + } + } } diff --git a/inc/site-exporter/class-site-exporter.php b/inc/site-exporter/class-site-exporter.php index ffc50696..a7da4c05 100644 --- a/inc/site-exporter/class-site-exporter.php +++ b/inc/site-exporter/class-site-exporter.php @@ -742,7 +742,7 @@ public function render_wp_export_page(): void { render_promote_main_site_form($site_id); ?> - render_export_import_dashboard($exports, $pending_exports, $pending_imports); ?> + render_export_import_dashboard($exports, $pending_exports, $pending_imports, $site_id); ?> filter_exports_by_site($exports, $site_id); + } ?>
@@ -995,10 +1002,38 @@ private function render_export_import_dashboard(array $exports, array $pending_e
-

+

+ blogname) + ); + } else { + esc_html_e('Completed Exports', 'ultimate-multisite'); + } + ?> +

+ + +

+ + + +

+ -

+

+ +

@@ -1919,9 +1954,10 @@ public function handle_export_network_modal(): void { public function render_import_site_modal(): void { $this->reset_upload_limits(); + $server_export_options = $this->get_server_export_options(); $fields = [ - 'zip_file' => [ + 'zip_file' => [ 'type' => 'text', 'title' => __('ZIP File URL', 'ultimate-multisite'), 'placeholder' => __('https://example.com/export.zip', 'ultimate-multisite'), @@ -1930,7 +1966,14 @@ public function render_import_site_modal(): void { 'id' => 'wu-import-zip-url', ], ], - 'upload_btn' => [ + 'server_zip_file' => [ + 'type' => 'select', + 'title' => __('Server-side ZIP', 'ultimate-multisite'), + 'placeholder' => __('Select a ZIP uploaded via File Manager or SFTP', 'ultimate-multisite'), + 'desc' => __('For large imports, upload the ZIP to wp-content/uploads/wu-site-exports using File Manager or SFTP, then select it here. This avoids browser upload limits.', 'ultimate-multisite'), + 'options' => $server_export_options, + ], + 'upload_btn' => [ 'type' => 'html', 'content' => sprintf( '', @@ -1938,19 +1981,19 @@ public function render_import_site_modal(): void { ), 'wrapper_classes' => 'wu-mb-4', ], - 'new_url' => [ + 'new_url' => [ 'type' => 'text', 'title' => __('New Site URL', 'ultimate-multisite'), 'placeholder' => is_subdomain_install() ? 'newsite.example.com' : 'example.com/newsite', 'desc' => __('The URL for the new imported site.', 'ultimate-multisite'), ], - 'remove_zip' => [ + 'remove_zip' => [ 'type' => 'toggle', 'title' => __('Delete ZIP After Import', 'ultimate-multisite'), 'desc' => __('Remove the ZIP file after successful import.', 'ultimate-multisite'), 'value' => true, ], - 'submit_button' => [ + 'submit_button' => [ 'type' => 'submit', 'title' => __('Import Site', 'ultimate-multisite'), 'value' => 'save', @@ -1989,14 +2032,20 @@ public function render_import_site_modal(): void { */ public function handle_import_site_modal(): void { - $zip_url = wu_request('zip_file', ''); - $new_url = wu_request('new_url', ''); + $zip_url = (string) wu_request('zip_file', ''); + $server_zip_file = (string) wu_request('server_zip_file', ''); + $new_url = wu_request('new_url', ''); - if (empty($zip_url)) { - wp_send_json_error(new \WP_Error('no-file', __('Please provide a ZIP file URL.', 'ultimate-multisite'))); - } + if ('' !== $server_zip_file) { + $file_path = $this->get_server_export_path($server_zip_file); + $zip_url = $this->get_server_export_url($server_zip_file); + } else { + if (empty($zip_url)) { + wp_send_json_error(new \WP_Error('no-file', __('Please provide a ZIP file URL or select a server-side ZIP.', 'ultimate-multisite'))); + } - $file_path = $this->url_to_path($zip_url); + $file_path = $this->url_to_path($zip_url); + } if (! $file_path || ! file_exists($file_path)) { wp_send_json_error(new \WP_Error('file-not-found', __('ZIP file not found.', 'ultimate-multisite'))); @@ -2018,10 +2067,11 @@ public function handle_import_site_modal(): void { $result = wu_exporter_import( $file_path, [ - 'delete_file' => wu_request('remove_zip'), - 'zip_url' => $zip_url, - 'url' => $new_url, - 'new_url' => $new_url, + 'delete_file' => wu_request('remove_zip'), + 'zip_url' => $zip_url, + 'server_zip_file' => $server_zip_file, + 'url' => $new_url, + 'new_url' => $new_url, ] ); @@ -2046,40 +2096,48 @@ public function render_import_network_modal(): void { $this->reset_upload_limits(); - $zip_url = wu_request('zip_file', ''); - $fields = [ - 'zip_file' => [ + $zip_url = wu_request('zip_file', ''); + $server_export_options = $this->get_server_export_options(); + $fields = [ + 'zip_file' => [ 'type' => 'text', 'title' => __('Network ZIP File URL', 'ultimate-multisite'), 'placeholder' => __('https://example.com/network-export.zip', 'ultimate-multisite'), 'desc' => __('Enter the URL to a network export ZIP file. Network bundles can be large; use WP-CLI for very large imports.', 'ultimate-multisite'), 'value' => $zip_url, ], - 'selected_sites' => [ + 'server_zip_file' => [ + 'type' => 'select', + 'title' => __('Server-side ZIP', 'ultimate-multisite'), + 'placeholder' => __('Select a ZIP uploaded via File Manager or SFTP', 'ultimate-multisite'), + 'desc' => __('For large imports, upload the ZIP to wp-content/uploads/wu-site-exports using File Manager or SFTP, then select it here. This avoids browser upload limits.', 'ultimate-multisite'), + 'options' => $server_export_options, + ], + 'selected_sites' => [ 'type' => 'text', 'title' => __('Sites to Import', 'ultimate-multisite'), 'placeholder' => __('Leave empty to import all sites, or enter blog IDs separated by commas.', 'ultimate-multisite'), 'desc' => __('Network bundles are detected by network.json. Selected blog IDs must be listed in network.json.included_blog_ids.', 'ultimate-multisite'), ], - 'url_overrides' => [ + 'url_overrides' => [ 'type' => 'textarea', 'title' => __('URL Overrides', 'ultimate-multisite'), 'placeholder' => "1=https://example.com\n2=https://site.example.com", 'desc' => __('Optional. Enter one source blog ID and target URL per line. Use this to map source sites to the target domain.', 'ultimate-multisite'), ], - 'remove_zip' => [ + 'remove_zip' => [ 'type' => 'toggle', 'title' => __('Delete ZIP After Import', 'ultimate-multisite'), 'desc' => __('Remove the ZIP file after successful import.', 'ultimate-multisite'), 'value' => false, ], - 'background_run' => [ + 'background_run' => [ 'type' => 'toggle', 'title' => __('Run in Background', 'ultimate-multisite'), 'desc' => __('Network imports can take a long time. Background import is recommended.', 'ultimate-multisite'), 'value' => true, ], - 'submit_button' => [ + 'submit_button' => [ 'type' => 'submit', 'title' => __('Import Network', 'ultimate-multisite'), 'value' => 'save', @@ -2109,14 +2167,21 @@ public function render_import_network_modal(): void { */ public function handle_import_network_modal(): void { - $zip_url = wu_request('zip_file', ''); - $background = (bool) wu_request('background_run', true); + $zip_url = (string) wu_request('zip_file', ''); + $server_zip_file = (string) wu_request('server_zip_file', ''); + $background = (bool) wu_request('background_run', true); - if (empty($zip_url)) { - wp_send_json_error(new \WP_Error('no-file', __('Please provide a ZIP file URL.', 'ultimate-multisite'))); + if ('' !== $server_zip_file) { + $file_path = $this->get_server_export_path($server_zip_file); + $zip_url = $this->get_server_export_url($server_zip_file); + } else { + if (empty($zip_url)) { + wp_send_json_error(new \WP_Error('no-file', __('Please provide a ZIP file URL or select a server-side ZIP.', 'ultimate-multisite'))); + } + + $file_path = $this->url_to_path($zip_url); } - $file_path = $this->url_to_path($zip_url); if (! $file_path || ! file_exists($file_path)) { wp_send_json_error(new \WP_Error('file-not-found', __('ZIP file not found.', 'ultimate-multisite'))); } @@ -2134,9 +2199,10 @@ public function handle_import_network_modal(): void { $url_overrides = $this->parse_network_import_url_overrides((string) wu_request('url_overrides', '')); $options = [ - 'delete_file' => wu_request('remove_zip'), - 'zip_url' => $zip_url, - 'url_overrides' => $url_overrides, + 'delete_file' => wu_request('remove_zip'), + 'zip_url' => $zip_url, + 'server_zip_file' => $server_zip_file, + 'url_overrides' => $url_overrides, ]; if (! empty($selected_sites)) { @@ -2321,14 +2387,16 @@ public function register_site_edit_widgets($page): void { } $exports = wu_exporter_get_all_exports(); - $site_exports = array_filter( - $exports, - function ($export) use ($site) { - return strpos($export['file'], 'wu-site-export-' . $site->get_id() . '-') !== false; - } - ); + $site_exports = $this->filter_exports_by_site($exports, (int) $site->get_id()); - $export_url = wu_get_form_url('export_site', ['id' => $site->get_id()]); + $export_url = wu_get_form_url('export_site', ['id' => $site->get_id()]); + $manage_exports_url = add_query_arg( + [ + 'page' => 'wu-site-export', + 'site_id' => (int) $site->get_id(), + ], + network_admin_url('sites.php') + ); $page->add_fields_widget( 'site_export', @@ -2347,28 +2415,53 @@ function ($export) use ($site) { ], 'export_list' => [ 'type' => 'html', - 'content' => $this->render_site_exports_list($site_exports, $site), + 'content' => $this->render_site_exports_list($site_exports, $site, $manage_exports_url), ], ], ] ); } + /** + * Filter completed export records to a single site ID. + * + * @since 2.5.1 + * + * @param array $exports Completed export records. + * @param int $site_id Site ID. + * @return array + */ + private function filter_exports_by_site(array $exports, int $site_id): array { + + $prefix = 'wu-site-export-' . $site_id . '-'; + + return array_filter( + $exports, + static function ($export) use ($prefix) { + + return isset($export['file']) && 0 === strpos($export['file'], $prefix); + } + ); + } + /** * Render the list of exports for a site. * * @since 2.5.0 * - * @param array $exports The exports list. - * @param \WP_Ultimo\Models\Site $site The site object (reserved for future use). + * @param array $exports The exports list. + * @param \WP_Ultimo\Models\Site $site The site object (reserved for future use). + * @param string $manage_exports_url Dedicated exports page URL for this site. * @return string */ - private function render_site_exports_list(array $exports, $site): string { // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.FoundAfterLastUsed + private function render_site_exports_list(array $exports, $site, string $manage_exports_url): string { // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.FoundAfterLastUsed if (empty($exports)) { return sprintf( - '

%s

', - __('No exports available for this site.', 'ultimate-multisite') + '

%s

%s

', + __('No exports available for this site.', 'ultimate-multisite'), + esc_url($manage_exports_url), + __('Open Downloads Page', 'ultimate-multisite') ); } @@ -2389,7 +2482,11 @@ private function render_site_exports_list(array $exports, $site): string { // ph ); } - $html .= ''; + $html .= sprintf( + '

%s

', + esc_url($manage_exports_url), + __('Open Downloads Page', 'ultimate-multisite') + ); return $html; } @@ -2436,9 +2533,7 @@ public function handle_bulk_export($action, $model, $ids): void { */ public function reset_upload_limits(): void { - @ini_set('upload_max_size', '2048M'); // phpcs:ignore - @ini_set('post_max_size', '2064M'); // phpcs:ignore - @ini_set('max_execution_time', '0'); // phpcs:ignore + @ini_set('max_execution_time', '0'); // phpcs:ignore if (is_main_site()) { add_filter( @@ -2526,6 +2621,83 @@ private function url_to_path(string $url) { return ! empty($path) ? $path : false; } + /** + * Returns ZIP files that are already available in the protected export folder. + * + * @since 2.15.1 + * @return array + */ + private function get_server_export_options(): array { + + $folder = wu_maybe_create_folder('wu-site-exports'); + $entries = is_dir($folder) ? scandir($folder) : false; + $options = []; + + if (false === $entries) { + return $options; + } + + foreach ($entries as $entry) { + $path = $this->get_server_export_path($entry); + + if (! $path) { + continue; + } + + $options[ $entry ] = sprintf( + /* translators: 1: ZIP file name, 2: ZIP file size. */ + __('%1$s (%2$s)', 'ultimate-multisite'), + $entry, + size_format((int) filesize($path), 2) + ); + } + + krsort($options, SORT_NATURAL); + + return $options; + } + + /** + * Resolves a selected server-side ZIP without allowing paths outside exports. + * + * @since 2.15.1 + * @param string $file_name ZIP file name from the import form. + * @return string|false ZIP path, or false when the selection is invalid. + */ + private function get_server_export_path(string $file_name) { + + if (basename($file_name) !== $file_name || sanitize_file_name($file_name) !== $file_name || ! preg_match('/\.zip$/i', $file_name)) { + return false; + } + + $folder = wu_maybe_create_folder('wu-site-exports'); + $folder_path = realpath($folder); + $file_path = realpath($folder . $file_name); + + if (false === $folder_path || false === $file_path || ! is_file($file_path)) { + return false; + } + + $folder_path = trailingslashit(wp_normalize_path($folder_path)); + $file_path = wp_normalize_path($file_path); + + return 0 === strpos($file_path, $folder_path) ? $file_path : false; + } + + /** + * Returns the canonical uploads URL for a selected server-side ZIP. + * + * @since 2.15.1 + * @param string $file_name ZIP file name from the import form. + * @return string + */ + private function get_server_export_url(string $file_name): string { + + $upload_dir = wp_upload_dir(); + + return trailingslashit($upload_dir['baseurl']) . 'wu-site-exports/' . $file_name; + } + /** * Maybe exclude WP Ultimo and other plugins from the generated zip. * @@ -2660,7 +2832,20 @@ public function handle_site_export(int $site_id, array $options = [], string $ha $export_name = sprintf('wu-site-export-%s-%s-%s.zip', $site_id, gmdate('Y-m-d'), time()); - $command = new \TenUp\MU_Migration\Commands\ExportCommand(); + $command_class = implode('\\', ['TenUp', 'MU_Migration', 'Commands', 'ExportCommand']); + + if (! class_exists($command_class)) { + if ( ! empty($hash)) { + wu_exporter_delete_transient("wu_pending_site_export_{$hash}"); + } + + return new \WP_Error( + 'export-dependency-missing', + __('The site export command could not be loaded. Please check the plugin installation.', 'ultimate-multisite') + ); + } + + $command = new $command_class(); $base_path = wu_maybe_create_folder('wu-site-exports'); @@ -2683,7 +2868,7 @@ public function handle_site_export(int $site_id, array $options = [], string $ha $start = microtime(true); try { - $command->all([$base_path . $export_name], $args); + call_user_func([$command, 'all'], [$base_path . $export_name], $args); } catch (\Exception $e) { // Log the exception for server admins and return a user-friendly error. error_log('WP Ultimo site export error: ' . $e->getMessage()); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log @@ -2820,7 +3005,17 @@ public function handle_site_import(): bool { $this->load_dependencies(); - $command = new \TenUp\MU_Migration\Commands\ImportCommand(); + $command_class = implode('\\', ['TenUp', 'MU_Migration', 'Commands', 'ImportCommand']); + + if (! class_exists($command_class)) { + wu_exporter_delete_transient("wu_pending_site_import_{$hash}"); + + error_log('WP Ultimo site import error: import command could not be loaded.'); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log + + return false; + } + + $command = new $command_class(); $defaults = [ 'url' => '', @@ -2835,7 +3030,7 @@ public function handle_site_import(): bool { $start = microtime(true); try { - $command->all([$file_name], $args); + call_user_func([$command, 'all'], [$file_name], $args); } catch (\RuntimeException $exception) { wu_exporter_delete_transient("wu_pending_site_import_{$hash}"); @@ -2853,9 +3048,16 @@ public function handle_site_import(): bool { $delete_file = ! empty($options['delete_file']); if ($delete_file) { - $attachment_id = attachment_url_to_postid($options['zip_url']); + $server_zip_file = (string) ($options['server_zip_file'] ?? ''); + $server_zip_path = $this->get_server_export_path($server_zip_file); - wp_delete_attachment($attachment_id, true); + if ($server_zip_path && wp_normalize_path($server_zip_path) === wp_normalize_path($file_name)) { + wp_delete_file($server_zip_path); + } else { + $attachment_id = attachment_url_to_postid($options['zip_url']); + + wp_delete_attachment($attachment_id, true); + } } return true; @@ -2893,6 +3095,15 @@ public function handle_network_import(): bool { wu_exporter_delete_transient("wu_pending_network_import_{$hash}"); + if (! is_wp_error($result) && ! empty($options['delete_file'])) { + $server_zip_file = (string) ($options['server_zip_file'] ?? ''); + $server_zip_path = $this->get_server_export_path($server_zip_file); + + if ($server_zip_path && wp_normalize_path($server_zip_path) === wp_normalize_path($file_name)) { + wp_delete_file($server_zip_path); + } + } + return ! is_wp_error($result); } diff --git a/tests/WP_Ultimo/Site_Exporter_Test.php b/tests/WP_Ultimo/Site_Exporter_Test.php index 4f54f911..0422a59f 100644 --- a/tests/WP_Ultimo/Site_Exporter_Test.php +++ b/tests/WP_Ultimo/Site_Exporter_Test.php @@ -33,6 +33,13 @@ class Site_Exporter_Test extends WP_UnitTestCase { */ private array $pending_import_hashes = []; + /** + * Server-side export ZIP paths created during tests. + * + * @var string[] + */ + private array $server_export_files = []; + /** * Set up test fixtures. */ @@ -58,7 +65,14 @@ public function tear_down(): void { wu_exporter_delete_transient("wu_pending_network_import_{$hash}"); } + foreach ($this->server_export_files as $file) { + if (file_exists($file)) { + wp_delete_file($file); + } + } + $this->pending_import_hashes = []; + $this->server_export_files = []; parent::tear_down(); } @@ -206,6 +220,30 @@ private function add_pending_network_import(): string { return $hash; } + /** + * Create a server-side export ZIP fixture. + * + * @return array{0:string,1:string} ZIP file name and path. + */ + private function create_server_export_zip(): array { + + $folder = wu_maybe_create_folder('wu-site-exports'); + $file = 'server-import-' . wp_generate_uuid4() . '.zip'; + $file_path = $folder . $file; + $zip = new \ZipArchive(); + + if (true !== $zip->open($file_path, \ZipArchive::CREATE)) { + $this->markTestSkipped('Unable to create a server-side ZIP fixture'); + } + + $zip->addFromString('manifest.json', '{}'); + $zip->close(); + + $this->server_export_files[] = $file_path; + + return [$file, $file_path]; + } + /** * Test singleton returns correct instance. */ @@ -246,6 +284,79 @@ public function test_wp_sites_row_actions_include_subsite_export_and_promotion() $this->assertArrayHasKey('promote_main_site', $actions, 'Subsites must still offer Promote to Main Site'); } + /** + * Test site export filtering matches an exact site ID prefix. + */ + public function test_filter_exports_by_site_matches_exact_site_prefix(): void { + + $exports = [ + [ + 'file' => 'wu-site-export-1-2026-07-31-1785528884.zip', + ], + [ + 'file' => 'wu-site-export-10-2026-07-31-1785528884.zip', + ], + ]; + + $method = new \ReflectionMethod($this->exporter, 'filter_exports_by_site'); + $method->setAccessible(true); + + $filtered = $method->invoke($this->exporter, $exports, 1); + + $this->assertCount(1, $filtered); + $this->assertSame('wu-site-export-1-2026-07-31-1785528884.zip', array_values($filtered)[0]['file']); + } + + /** + * Test the site edit widget links to the dedicated exports page. + */ + public function test_site_exports_list_links_to_dedicated_download_page(): void { + + $method = new \ReflectionMethod($this->exporter, 'render_site_exports_list'); + $method->setAccessible(true); + + $html = $method->invoke( + $this->exporter, + [ + [ + 'file' => 'wu-site-export-1-2026-07-31-1785528884.zip', + 'url' => 'https://example.test/download', + 'date' => '31 July 2026 9:33 pm', + ], + ], + null, + 'https://example.test/wp-admin/network/sites.php?page=wu-site-export&site_id=1' + ); + + $this->assertStringContainsString('Open Downloads Page', $html); + $this->assertStringContainsString('site_id=1', $html); + } + + /** + * Test the download handler clears nested output buffers before streaming. + */ + public function test_export_download_handler_clears_nested_output_buffers(): void { + + $handler = Export_Download_Handler::get_instance(); + $method = new \ReflectionMethod($handler, 'prepare_streaming_environment'); + $baseline = ob_get_level(); + + $method->setAccessible(true); + + ob_start(); + ob_start(); + + try { + $method->invoke($handler, $baseline); + + $this->assertSame($baseline, ob_get_level(), 'Nested buffers above the requested baseline must be cleared before streaming'); + } finally { + while (ob_get_level() > $baseline) { + ob_end_clean(); + } + } + } + /** * Test that bulk exports include the main site instead of silently skipping it. */ @@ -641,4 +752,31 @@ static function ($errno, $errstr) use (&$warnings) { $this->assertStringContainsString($old_url, $result); $this->assertStringContainsString('https://example.com/new/page', $result); } + + /** + * Test server-side imports are constrained to ZIPs in the protected export folder. + */ + public function test_server_export_path_only_accepts_zip_in_export_folder(): void { + + [$file, $file_path] = $this->create_server_export_zip(); + $method = new \ReflectionMethod($this->exporter, 'get_server_export_path'); + $method->setAccessible(true); + + $this->assertSame($file_path, $method->invoke($this->exporter, $file)); + $this->assertFalse($method->invoke($this->exporter, '../' . $file)); + $this->assertFalse($method->invoke($this->exporter, str_replace('.zip', '.txt', $file))); + } + + /** + * Test server-side ZIPs are offered to network administrators. + */ + public function test_server_export_options_include_available_zip(): void { + + [$file] = $this->create_server_export_zip(); + $method = new \ReflectionMethod($this->exporter, 'get_server_export_options'); + $method->setAccessible(true); + $options = $method->invoke($this->exporter); + + $this->assertArrayHasKey($file, $options); + } }