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