Commit c4ea1f6c3ae for woocommerce
commit c4ea1f6c3aeeda5b35c93aec38c61178b9c6a590
Author: Luigi Teschio <gigitux@gmail.com>
Date: Wed Sep 23 11:52:15 2026 +0200
Fix downloads of local files with spaces in filenames (#68948)
* Fix downloads of local files with spaces in filenames
* Add changelog entry for local file download fix
* Clarify encoded-space download fallback
* improve comment
* Simplify encoded-space download regression test
* fix unit test
* add comment
diff --git a/plugins/woocommerce/changelog/fix-56097-download-files-with-spaces b/plugins/woocommerce/changelog/fix-56097-download-files-with-spaces
new file mode 100644
index 00000000000..565211f7d81
--- /dev/null
+++ b/plugins/woocommerce/changelog/fix-56097-download-files-with-spaces
@@ -0,0 +1,4 @@
+Significance: patch
+Type: fix
+
+Fix downloads of local product files with spaces in their filenames.
diff --git a/plugins/woocommerce/includes/class-wc-download-handler.php b/plugins/woocommerce/includes/class-wc-download-handler.php
index e2362b021be..932c8c6200d 100644
--- a/plugins/woocommerce/includes/class-wc-download-handler.php
+++ b/plugins/woocommerce/includes/class-wc-download-handler.php
@@ -318,17 +318,18 @@ class WC_Download_Handler {
* via filters we can still do the string replacement on a HTTP file.
*/
$replacements = array(
- $wp_uploads_url => $wp_uploads_dir,
- network_site_url( '/', 'https' ) => ABSPATH,
+ $wp_uploads_url => $wp_uploads_dir,
+ network_site_url( '/', 'https' ) => ABSPATH,
str_replace( 'https:', 'http:', network_site_url( '/', 'http' ) ) => ABSPATH,
- site_url( '/', 'https' ) => ABSPATH,
- str_replace( 'https:', 'http:', site_url( '/', 'http' ) ) => ABSPATH,
+ site_url( '/', 'https' ) => ABSPATH,
+ str_replace( 'https:', 'http:', site_url( '/', 'http' ) ) => ABSPATH,
);
- $count = 0;
- $file_path = str_replace( array_keys( $replacements ), array_values( $replacements ), $file_path, $count );
- $parsed_file_path = wp_parse_url( $file_path );
- $remote_file = null === $count || 0 === $count; // Remote file only if there were no replacements.
+ $count = 0;
+ $file_path = str_replace( array_keys( $replacements ), array_values( $replacements ), $file_path, $count );
+ $parsed_file_path = wp_parse_url( $file_path );
+ $remote_file = null === $count || 0 === $count; // Remote file only if there were no replacements.
+ $decoded_file_path = str_replace( '%20', ' ', $file_path );
// Paths that begin with '//' are always remote URLs.
if ( '//' === substr( $file_path, 0, 2 ) ) {
@@ -357,6 +358,10 @@ class WC_Download_Handler {
$remote_file = false;
$file_path = realpath( WP_CONTENT_DIR . substr( $file_path, strlen( $wp_content_dirname ) ) );
+ // A mapped local URL may encode spaces as "%20". Use the decoded path only when the literal path does not exist.
+ } elseif ( ! $remote_file && $decoded_file_path !== $file_path && ! file_exists( $file_path ) && file_exists( $decoded_file_path ) ) {
+ $file_path = $decoded_file_path;
+
// Check if we have an absolute path.
} elseif ( ( ! isset( $parsed_file_path['scheme'] ) || ! in_array( $parsed_file_path['scheme'], array( 'http', 'https', 'ftp' ), true ) ) && isset( $parsed_file_path['path'] ) ) {
$remote_file = false;
@@ -820,7 +825,7 @@ class WC_Download_Handler {
*
* @return string Content disposition value.
*/
- private static function get_content_disposition() : string {
+ private static function get_content_disposition(): string {
$disposition = 'attachment';
if ( 'yes' === get_option( 'woocommerce_downloads_deliver_inline' ) ) {
$disposition = 'inline';
@@ -907,7 +912,7 @@ class WC_Download_Handler {
echo $chunk; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- Download chunks are raw binary data and must not be HTML-escaped.
$output_sent = $output_sent || '' !== $chunk;
- $p = @ftell( $handle ); // phpcs:ignore Generic.PHP.NoSilencedErrors.Discouraged
+ $p = @ftell( $handle ); // phpcs:ignore Generic.PHP.NoSilencedErrors.Discouraged,WordPress.PHP.NoSilencedErrors.Discouraged
if ( ob_get_length() ) {
ob_flush();
diff --git a/plugins/woocommerce/tests/php/includes/class-wc-download-handler-tests.php b/plugins/woocommerce/tests/php/includes/class-wc-download-handler-tests.php
index 0992cfe5342..e9bcb2142da 100644
--- a/plugins/woocommerce/tests/php/includes/class-wc-download-handler-tests.php
+++ b/plugins/woocommerce/tests/php/includes/class-wc-download-handler-tests.php
@@ -26,6 +26,28 @@ class WC_Download_Handler_Tests extends \WC_Unit_Test_Case {
$this->assertFalse( $parsed_file_path['remote_file'] );
}
+ /**
+ * @testdox Encoded spaces in a local URL resolve to an existing file with spaces in its name.
+ */
+ public function test_parse_file_path_for_encoded_space_in_existing_file(): void {
+ $uploads = wp_upload_dir();
+ $filename = 'wc download ' . wp_generate_uuid4() . '.pdf';
+ $absolute_path = trailingslashit( $uploads['basedir'] ) . $filename;
+ $file_url = trailingslashit( $uploads['baseurl'] ) . rawurlencode( $filename );
+
+ // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_file_put_contents -- Test fixture in the uploads directory.
+ $this->assertNotFalse( file_put_contents( $absolute_path, 'download fixture' ) );
+
+ try {
+ $parsed_file_path = WC_Download_Handler::parse_file_path( $file_url );
+ $this->assertFalse( $parsed_file_path['remote_file'] );
+ $this->assertSame( $absolute_path, $parsed_file_path['file_path'] );
+ } finally {
+ // phpcs:ignore WordPress.WP.AlternativeFunctions.unlink_unlink -- Remove test fixtures from the uploads directory.
+ unlink( $absolute_path );
+ }
+ }
+
/**
* Test for local file with `file` protocol.
*/