Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
96 changes: 83 additions & 13 deletions inc/site-exporter/class-export-download-handler.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
}
}
}
Loading
Loading