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);
+ }
}