Commit 81536ec9820 for woocommerce

commit 81536ec9820f7d8bf0b6683a1162265ef81ea4f2
Author: Jaclyn Chen <watertranquil@gmail.com>
Date:   Wed Jun 10 10:49:01 2026 +0800

    Allow POS catalog product feed files to be served (#65563)

    * Restore allow_file_access option and use it for POS catalog feeds

    * Add changelog entry for POS catalog feed file access

    * Refresh feed directory .htaccess in place for existing installs

    * Add unit tests for mkdir_p_not_indexable file access flag

    * Test htaccess refresh through public feed API instead of reflection

    * Extract .htaccess directives into FilesystemUtil constants

    * Refresh feed .htaccess with native file ops, not WP_Filesystem

    * Cache feed upload dir per instance instead of per process

    * Limit feed .htaccess refresh to the legacy deny-from-all directive

diff --git a/plugins/woocommerce/changelog/add-pos-catalog-feed-file-access b/plugins/woocommerce/changelog/add-pos-catalog-feed-file-access
new file mode 100644
index 00000000000..fea8988f71d
--- /dev/null
+++ b/plugins/woocommerce/changelog/add-pos-catalog-feed-file-access
@@ -0,0 +1,4 @@
+Significance: patch
+Type: add
+
+Allow POS catalog product feed files to be served by enabling file access on the feed directory while preventing directory listing.
diff --git a/plugins/woocommerce/src/Internal/ProductFeed/Storage/JsonFileFeed.php b/plugins/woocommerce/src/Internal/ProductFeed/Storage/JsonFileFeed.php
index 83a63ef680b..c2b896b94a7 100644
--- a/plugins/woocommerce/src/Internal/ProductFeed/Storage/JsonFileFeed.php
+++ b/plugins/woocommerce/src/Internal/ProductFeed/Storage/JsonFileFeed.php
@@ -82,6 +82,13 @@ class JsonFileFeed implements FeedInterface {
 	 */
 	private $is_temp_filepath = false;

+	/**
+	 * Cached upload directory details (path and URL), resolved once per feed instance.
+	 *
+	 * @var array|null
+	 */
+	private $prepared_upload_dir = null;
+
 	/**
 	 * Constructor.
 	 *
@@ -262,18 +269,22 @@ class JsonFileFeed implements FeedInterface {
 	 * @throws Exception If the upload directory cannot be created.
 	 */
 	private function get_upload_dir(): array {
-		// Only generate everything once.
-		static $prepared;
-		if ( isset( $prepared ) ) {
-			return $prepared;
+		// Resolve once per feed instance.
+		if ( null !== $this->prepared_upload_dir ) {
+			return $this->prepared_upload_dir;
 		}

 		$upload_dir     = wp_upload_dir( null, true );
 		$directory_path = $upload_dir['basedir'] . DIRECTORY_SEPARATOR . self::UPLOAD_DIR . DIRECTORY_SEPARATOR;

-		// Try to create the directory if it does not exist.
+		// Create the directory if it does not exist, allowing file access so the generated feed
+		// files can be served by URL while directory listing stays disabled. If the directory
+		// already exists, refresh its .htaccess in place so installs created before file access
+		// was enabled also serve feeds correctly.
 		if ( ! is_dir( $directory_path ) ) {
-			FilesystemUtil::mkdir_p_not_indexable( $directory_path );
+			FilesystemUtil::mkdir_p_not_indexable( $directory_path, true );
+		} else {
+			$this->ensure_feed_dir_file_access( $directory_path );
 		}

 		// `mkdir_p_not_indexable()` returns `void`, we have to check again.
@@ -292,10 +303,44 @@ class JsonFileFeed implements FeedInterface {
 		$directory_url = $upload_dir['baseurl'] . '/' . self::UPLOAD_DIR . '/';

 		// Follow the format, returned by `wp_upload_dir()`.
-		$prepared = array(
+		$this->prepared_upload_dir = array(
 			'path' => $directory_path,
 			'url'  => $directory_url,
 		);
-		return $prepared;
+		return $this->prepared_upload_dir;
+	}
+
+	/**
+	 * Ensures an existing feed directory allows file access while preventing directory listing.
+	 *
+	 * Installs created before file access was enabled have a `deny from all` .htaccess in this
+	 * directory, which blocks feed downloads. This replaces only that known legacy directive (or
+	 * recreates a missing file); any other content — the already-correct directive, or custom rules
+	 * a site or host may have added — is left untouched.
+	 *
+	 * Native file functions are used here (like the feed writes elsewhere in this class) rather
+	 * than WP_Filesystem: the directory is local, and routing through a possibly FTP/SSH-backed
+	 * filesystem could fail to initialize and leave the old `deny from all` in place even though
+	 * the feed file itself was written natively. Failures are ignored so this can never interrupt
+	 * feed generation.
+	 *
+	 * @param string $directory_path The feed directory path (trailing-slashed).
+	 * @return void
+	 */
+	private function ensure_feed_dir_file_access( string $directory_path ): void {
+		$htaccess_path = $directory_path . '.htaccess';
+
+		// phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged
+		$current_content = is_file( $htaccess_path ) ? trim( (string) @file_get_contents( $htaccess_path ) ) : '';
+
+		// Only upgrade the known legacy `deny from all` directive or recreate a missing file.
+		// Leave anything else (already correct, or custom rules) untouched.
+		if ( '' !== $current_content && FilesystemUtil::HTACCESS_DENY_ALL !== $current_content ) {
+			return;
+		}
+
+		// Best effort: a failure here must never interrupt feed generation.
+		// phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged
+		@file_put_contents( $htaccess_path, FilesystemUtil::HTACCESS_ALLOW_FILE_ACCESS );
 	}
 }
diff --git a/plugins/woocommerce/src/Internal/Utilities/FilesystemUtil.php b/plugins/woocommerce/src/Internal/Utilities/FilesystemUtil.php
index f8de3511d62..7e154a91430 100644
--- a/plugins/woocommerce/src/Internal/Utilities/FilesystemUtil.php
+++ b/plugins/woocommerce/src/Internal/Utilities/FilesystemUtil.php
@@ -13,6 +13,16 @@ use WP_Filesystem_Base;
  */
 class FilesystemUtil {

+	/**
+	 * `.htaccess` directive that prevents directory listing while still allowing direct access to files.
+	 */
+	public const HTACCESS_ALLOW_FILE_ACCESS = 'Options -Indexes';
+
+	/**
+	 * `.htaccess` directive that denies all access to a directory's contents.
+	 */
+	public const HTACCESS_DENY_ALL = 'deny from all';
+
 	/**
 	 * Transient key for tracking FTP filesystem initialization failures.
 	 */
@@ -77,9 +87,10 @@ class FilesystemUtil {
 	 * @since 9.3.0
 	 *
 	 * @param string $path Directory to create.
+	 * @param bool   $allow_file_access Whether to allow file access while preventing directory listing. Default false (deny all access).
 	 * @throws \Exception In case of error.
 	 */
-	public static function mkdir_p_not_indexable( string $path ): void {
+	public static function mkdir_p_not_indexable( string $path, bool $allow_file_access = false ): void {
 		$wp_fs = self::get_wp_filesystem();

 		if ( $wp_fs->is_dir( $path ) ) {
@@ -90,8 +101,10 @@ class FilesystemUtil {
 			throw new \Exception( esc_html( sprintf( 'Could not create directory: %s.', wp_basename( $path ) ) ) );
 		}

+		$htaccess_content = $allow_file_access ? self::HTACCESS_ALLOW_FILE_ACCESS : self::HTACCESS_DENY_ALL;
+
 		$files = array(
-			'.htaccess'  => 'deny from all',
+			'.htaccess'  => $htaccess_content,
 			'index.html' => '',
 		);

diff --git a/plugins/woocommerce/tests/php/src/Internal/ProductFeed/Storage/JsonFileFeedTest.php b/plugins/woocommerce/tests/php/src/Internal/ProductFeed/Storage/JsonFileFeedTest.php
index 2f13234fd7d..9dd0feb5aa1 100644
--- a/plugins/woocommerce/tests/php/src/Internal/ProductFeed/Storage/JsonFileFeedTest.php
+++ b/plugins/woocommerce/tests/php/src/Internal/ProductFeed/Storage/JsonFileFeedTest.php
@@ -192,6 +192,84 @@ class JsonFileFeedTest extends \WC_Unit_Test_Case {
 		}
 	}

+	/**
+	 * @testdox Should refresh an existing feed directory's .htaccess to allow file access.
+	 */
+	public function test_existing_feed_dir_htaccess_is_refreshed_for_file_access(): void {
+		// Simulate an install created before file access was enabled: the directory already
+		// exists with a `deny from all` .htaccess that would block feed downloads.
+		$directory = wp_upload_dir()['basedir'] . '/product-feeds';
+		wp_mkdir_p( $directory );
+		file_put_contents( $directory . '/.htaccess', 'deny from all' );
+
+		// Drive the public feed API; get_file_url() resolves the upload directory, which refreshes
+		// the .htaccess in place when the directory already exists.
+		$feed = new JsonFileFeed( 'test-feed' );
+		$feed->start();
+		$feed->end();
+		$feed->get_file_url();
+
+		$this->assertSame(
+			'Options -Indexes',
+			trim( (string) file_get_contents( $directory . '/.htaccess' ) ),
+			'Generating a feed into an existing directory should refresh its .htaccess to allow file access.'
+		);
+	}
+
+	/**
+	 * @testdox Should refresh the feed directory's .htaccess even when WP_Filesystem is unavailable.
+	 *
+	 * Guards the existing-install fix against re-introducing a WP_Filesystem dependency: on installs
+	 * with a broken (e.g. FTP) filesystem, the refresh must still run via native file functions.
+	 */
+	public function test_existing_feed_dir_htaccess_is_refreshed_without_wp_filesystem(): void {
+		$directory = wp_upload_dir()['basedir'] . '/product-feeds';
+		wp_mkdir_p( $directory );
+		file_put_contents( $directory . '/.htaccess', 'deny from all' );
+
+		// Force WP_Filesystem initialization to fail; the refresh must not depend on it.
+		$broken_method = fn() => 'this-method-does-not-exist';
+		add_filter( 'filesystem_method', $broken_method );
+
+		try {
+			$feed = new JsonFileFeed( 'test-feed' );
+			$feed->start();
+			$feed->end();
+			$feed->get_file_url();
+
+			$this->assertSame(
+				'Options -Indexes',
+				trim( (string) file_get_contents( $directory . '/.htaccess' ) ),
+				'The .htaccess refresh must succeed without a usable WP_Filesystem.'
+			);
+		} finally {
+			remove_filter( 'filesystem_method', $broken_method );
+		}
+	}
+
+	/**
+	 * @testdox Should leave a custom .htaccess in the feed directory untouched.
+	 */
+	public function test_existing_feed_dir_custom_htaccess_is_preserved(): void {
+		// A site/host may have placed their own rules in the feed directory; only the known legacy
+		// `deny from all` should be upgraded, never custom content.
+		$directory      = wp_upload_dir()['basedir'] . '/product-feeds';
+		$custom_content = "# Custom rules\nHeader set X-Test 1";
+		wp_mkdir_p( $directory );
+		file_put_contents( $directory . '/.htaccess', $custom_content );
+
+		$feed = new JsonFileFeed( 'test-feed' );
+		$feed->start();
+		$feed->end();
+		$feed->get_file_url();
+
+		$this->assertSame(
+			$custom_content,
+			file_get_contents( $directory . '/.htaccess' ),
+			'Custom .htaccess content must be preserved, not overwritten by the refresh.'
+		);
+	}
+
 	/**
 	 * Gets the directory for feed files, but also deletes it.
 	 *
diff --git a/plugins/woocommerce/tests/php/src/Internal/Utilities/FilesystemUtilTest.php b/plugins/woocommerce/tests/php/src/Internal/Utilities/FilesystemUtilTest.php
index a9e389d6cf5..de2cc844a0a 100644
--- a/plugins/woocommerce/tests/php/src/Internal/Utilities/FilesystemUtilTest.php
+++ b/plugins/woocommerce/tests/php/src/Internal/Utilities/FilesystemUtilTest.php
@@ -287,4 +287,108 @@ class FilesystemUtilTest extends WC_Unit_Test_Case {

 		$wp_filesystem = $original_wp_filesystem; // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited
 	}
+
+	/**
+	 * @testdox 'mkdir_p_not_indexable' writes the expected .htaccess based on the allow_file_access flag.
+	 *
+	 * @testWith [false, "deny from all"]
+	 *           [true, "Options -Indexes"]
+	 *
+	 * @param bool   $allow_file_access Whether file access should be allowed.
+	 * @param string $expected_htaccess The expected .htaccess content.
+	 */
+	public function test_mkdir_p_not_indexable_writes_expected_htaccess( bool $allow_file_access, string $expected_htaccess ): void {
+		$callback = fn() => 'direct';
+		add_filter( 'filesystem_method', $callback );
+
+		$dir = trailingslashit( get_temp_dir() ) . 'wc-mkdir-not-indexable-' . ( $allow_file_access ? 'allow' : 'deny' );
+		$this->delete_test_dir( $dir );
+
+		try {
+			FilesystemUtil::mkdir_p_not_indexable( $dir, $allow_file_access );
+
+			$wp_fs = FilesystemUtil::get_wp_filesystem();
+			$this->assertDirectoryExists( $dir, 'The directory should be created.' );
+			$this->assertSame(
+				$expected_htaccess,
+				trim( (string) $wp_fs->get_contents( trailingslashit( $dir ) . '.htaccess' ) ),
+				'The .htaccess content should reflect the allow_file_access flag.'
+			);
+			$this->assertTrue(
+				$wp_fs->exists( trailingslashit( $dir ) . 'index.html' ),
+				'An empty index.html should be created to prevent directory listing.'
+			);
+		} finally {
+			$this->delete_test_dir( $dir );
+			remove_filter( 'filesystem_method', $callback );
+		}
+	}
+
+	/**
+	 * @testdox 'mkdir_p_not_indexable' defaults to denying all access when no flag is passed.
+	 */
+	public function test_mkdir_p_not_indexable_defaults_to_deny_all(): void {
+		$callback = fn() => 'direct';
+		add_filter( 'filesystem_method', $callback );
+
+		$dir = trailingslashit( get_temp_dir() ) . 'wc-mkdir-not-indexable-default';
+		$this->delete_test_dir( $dir );
+
+		try {
+			FilesystemUtil::mkdir_p_not_indexable( $dir );
+
+			$wp_fs = FilesystemUtil::get_wp_filesystem();
+			$this->assertSame(
+				'deny from all',
+				trim( (string) $wp_fs->get_contents( trailingslashit( $dir ) . '.htaccess' ) ),
+				'Omitting the allow_file_access argument should keep the deny-all default.'
+			);
+		} finally {
+			$this->delete_test_dir( $dir );
+			remove_filter( 'filesystem_method', $callback );
+		}
+	}
+
+	/**
+	 * @testdox 'mkdir_p_not_indexable' leaves an existing directory's .htaccess untouched.
+	 */
+	public function test_mkdir_p_not_indexable_does_not_overwrite_existing_directory(): void {
+		$callback = fn() => 'direct';
+		add_filter( 'filesystem_method', $callback );
+
+		$dir = trailingslashit( get_temp_dir() ) . 'wc-mkdir-not-indexable-existing';
+		$this->delete_test_dir( $dir );
+
+		try {
+			// First call creates the directory with the deny-all default.
+			FilesystemUtil::mkdir_p_not_indexable( $dir );
+
+			// A later call requesting file access must not rewrite the existing .htaccess.
+			FilesystemUtil::mkdir_p_not_indexable( $dir, true );
+
+			$wp_fs = FilesystemUtil::get_wp_filesystem();
+			$this->assertSame(
+				'deny from all',
+				trim( (string) $wp_fs->get_contents( trailingslashit( $dir ) . '.htaccess' ) ),
+				'An existing directory should keep its original .htaccess.'
+			);
+		} finally {
+			$this->delete_test_dir( $dir );
+			remove_filter( 'filesystem_method', $callback );
+		}
+	}
+
+	/**
+	 * Removes a test directory and its contents if it exists.
+	 *
+	 * @param string $dir The directory to delete.
+	 * @return void
+	 */
+	private function delete_test_dir( string $dir ): void {
+		if ( ! is_dir( $dir ) ) {
+			return;
+		}
+
+		FilesystemUtil::get_wp_filesystem()->rmdir( $dir, true );
+	}
 }