diff --git a/inc/class-product-versions.php b/inc/class-product-versions.php index d86b66e..83c70f0 100644 --- a/inc/class-product-versions.php +++ b/inc/class-product-versions.php @@ -55,6 +55,13 @@ class Product_Versions { */ const REMOTE_VERSION_CACHE_EXPIRATION = DAY_IN_SECONDS; + /** + * Cache expiration for failed remote archive inspections. + * + * @var int + */ + const REMOTE_VERSION_FAILURE_CACHE_EXPIRATION = HOUR_IN_SECONDS; + /** * Get all versions of a product by SKU. * @@ -86,11 +93,12 @@ public static function get_all_versions_by_product_id(int $product_id): array { return []; } - // Check cache + // Check persistent WordPress cache storage. $cache_key = 'versions_' . $product_id; - $cached = get_transient($cache_key); + $found = false; + $cached = self::get_cached_value($cache_key, $found); - if ($cached !== false) { + if ($found && is_array($cached)) { return $cached; } @@ -120,8 +128,8 @@ public static function get_all_versions_by_product_id(int $product_id): array { return version_compare($b['version'], $a['version']); }); - // Cache the results - set_transient($cache_key, $versions, self::CACHE_EXPIRATION); + // Cache the results, including an empty result, to avoid repeated inspection. + self::set_cached_value($cache_key, $versions, self::CACHE_EXPIRATION); return $versions; } @@ -156,9 +164,10 @@ private static function extract_version_from_file(\WC_Product $product, string $ $file->get_name(), $filepath ); - $cached_version_info = self::get_cached_remote_version_info($remote_cache_key); + $cache_found = false; + $cached_version_info = self::get_cached_remote_version_info($remote_cache_key, $cache_found); - if ($cached_version_info) { + if ($cache_found) { return $cached_version_info; } @@ -166,6 +175,8 @@ private static function extract_version_from_file(\WC_Product $product, string $ $tmp = self::download_remote_archive_for_inspection($filepath, $file->get_name()); if (null === $tmp) { + self::set_cached_remote_version_info($remote_cache_key, null); + return null; } @@ -174,6 +185,10 @@ private static function extract_version_from_file(\WC_Product $product, string $ try { if ( ! is_file($filepath) || ! is_readable($filepath)) { + if ($remote_cache_key) { + self::set_cached_remote_version_info($remote_cache_key, null); + } + return null; } @@ -184,31 +199,36 @@ private static function extract_version_from_file(\WC_Product $product, string $ $version_info = ['version' => $metadata['version'] ?? '0.0.0']; if ($remote_cache_key) { - set_transient($remote_cache_key, $version_info, self::REMOTE_VERSION_CACHE_EXPIRATION); + self::set_cached_remote_version_info($remote_cache_key, $version_info); } return $version_info; } catch (\Throwable $e) { // Fallback to filename extraction $version = self::extract_version_from_filename($file->get_name()); + $version_info = $version ? ['version' => $version] : null; + + if ($remote_cache_key) { + self::set_cached_remote_version_info($remote_cache_key, $version_info); + } - return $version ? ['version' => $version] : null; + return $version_info; } finally { self::delete_temporary_archive($tmp); } } /** - * Build a privacy-safe transient key for remote archive metadata. + * Build a privacy-safe object-cache key for remote archive metadata. * * The raw URL may contain signed download credentials, so only a hash is used - * in the transient name. + * in the cache key. * * @param int $product_id The product ID. * @param string $file_id The WooCommerce download file ID. * @param string $file_name The WooCommerce download file name. * @param string $url The remote archive URL. - * @return string The transient cache key. + * @return string The object-cache key. */ private static function get_remote_version_cache_key(int $product_id, string $file_id, string $file_name, string $url): string { @@ -220,20 +240,116 @@ private static function get_remote_version_cache_key(int $product_id, string $fi /** * Retrieve cached remote version metadata if it has the expected shape. * - * @param string $cache_key The transient cache key. - * @return array|null Cached version metadata or null when absent/invalid. + * A cached null version is a negative cache hit. This prevents unavailable or + * malformed archives from being downloaded again on every page request. + * + * @param string $cache_key The object-cache key. + * @param bool $found Set to true when valid positive or negative cache data exists. + * @return array|null Cached version metadata, or null for a miss/negative hit. */ - private static function get_cached_remote_version_info(string $cache_key): ?array { + private static function get_cached_remote_version_info(string $cache_key, bool &$found): ?array { + + $found = false; + $cached = self::get_cached_value($cache_key, $found); + + if ( ! $found || ! is_array($cached) || ! array_key_exists('version', $cached)) { + $found = false; - $cached = get_transient($cache_key); + return null; + } + + if (null === $cached['version']) { + return null; + } + + if ( ! is_string($cached['version'])) { + $found = false; - if ( ! is_array($cached) || ! isset($cached['version']) || ! is_string($cached['version'])) { return null; } return ['version' => $cached['version']]; } + /** + * Cache positive or negative remote archive inspection results. + * + * @param string $cache_key The object-cache key. + * @param array|null $version_info Version metadata, or null when inspection failed. + * @return void + */ + private static function set_cached_remote_version_info(string $cache_key, ?array $version_info): void { + + $expiration = null === $version_info + ? self::REMOTE_VERSION_FAILURE_CACHE_EXPIRATION + : self::REMOTE_VERSION_CACHE_EXPIRATION; + + self::set_cached_value($cache_key, $version_info ?? ['version' => null], $expiration); + } + + /** + * Retrieve a value from persistent WordPress cache storage. + * + * A dedicated object-cache group is used when a persistent backend is active. + * Transients provide cross-request persistence when WordPress uses its default + * request-local object cache. + * + * @param string $cache_key The cache key. + * @param bool $found Set to true when the cache key exists. + * @return mixed Cached value, or false on a miss. + */ + private static function get_cached_value(string $cache_key, bool &$found) { + + if (wp_using_ext_object_cache()) { + $object_cache_found = null; + $cached = wp_cache_get($cache_key, self::CACHE_GROUP, false, $object_cache_found); + $found = true === $object_cache_found; + + return $cached; + } + + $cached = get_transient(self::CACHE_GROUP . '_' . $cache_key); + $found = false !== $cached; + + return $cached; + } + + /** + * Store a value in persistent WordPress cache storage. + * + * @param string $cache_key The cache key. + * @param mixed $value The value to cache. + * @param int $expiration Cache lifetime in seconds. + * @return void + */ + private static function set_cached_value(string $cache_key, $value, int $expiration): void { + + if (wp_using_ext_object_cache()) { + wp_cache_set($cache_key, $value, self::CACHE_GROUP, $expiration); + + return; + } + + set_transient(self::CACHE_GROUP . '_' . $cache_key, $value, $expiration); + } + + /** + * Delete a value from persistent WordPress cache storage. + * + * @param string $cache_key The cache key. + * @return void + */ + private static function delete_cached_value(string $cache_key): void { + + if (wp_using_ext_object_cache()) { + wp_cache_delete($cache_key, self::CACHE_GROUP); + + return; + } + + delete_transient(self::CACHE_GROUP . '_' . $cache_key); + } + /** * Download a remote archive to a temporary file using bounded HTTP settings. * @@ -331,8 +447,8 @@ private static function extract_version_from_filename(string $filename): ?string return $matches[1]; } - // Match pattern from file name like "Plugin Name - 1.2.3" - if (preg_match('/ - (\d+\.\d+(?:\.\d+)?(?:-[a-zA-Z0-9.]+)?)$/i', $filename, $matches)) { + // Match display names like "Plugin Name - 1.2.3" or "Plugin Name v1.2.3". + if (preg_match('/(?:^|[\s-])v?(\d+\.\d+(?:\.\d+)?(?:-[a-zA-Z0-9.]+)?)$/i', $filename, $matches)) { return $matches[1]; } @@ -672,6 +788,29 @@ public static function get_latest_version_by_product_id(int $product_id, bool $i */ public static function clear_cache(int $product_id): void { - delete_transient('versions_' . $product_id); + self::delete_cached_value('versions_' . $product_id); + + $product = wc_get_product($product_id); + + if ( ! $product || ! $product->exists()) { + return; + } + + foreach ($product->get_downloads() as $file_id => $file) { + $file_info = \WC_Download_Handler::parse_file_path($product->get_file_download_path($file_id)); + + if (empty($file_info['remote_file'])) { + continue; + } + + $cache_key = self::get_remote_version_cache_key( + $product_id, + $file_id, + $file->get_name(), + $file_info['file_path'] + ); + + self::delete_cached_value($cache_key); + } } } diff --git a/tests/product-versions-remote-download-regression.php b/tests/product-versions-remote-download-regression.php index 5346c78..3740582 100644 --- a/tests/product-versions-remote-download-regression.php +++ b/tests/product-versions-remote-download-regression.php @@ -21,9 +21,13 @@ $GLOBALS['wu_http_calls'] = []; $GLOBALS['wu_http_mode'] = 'success'; + $GLOBALS['wu_test_object_cache'] = []; + $GLOBALS['wu_test_object_cache_ttls'] = []; + $GLOBALS['wu_test_package_metadata'] = ['version' => '3.2.1']; + $GLOBALS['wu_test_products'] = []; $GLOBALS['wu_test_transients'] = []; $GLOBALS['wu_test_transient_ttls'] = []; - $GLOBALS['wu_test_package_metadata'] = ['version' => '3.2.1']; + $GLOBALS['wu_using_ext_object_cache'] = true; $GLOBALS['wu_last_temp_file_created'] = null; class WP_Error { @@ -41,14 +45,21 @@ class WC_Product { */ private $download_path; + /** + * @var array + */ + private $downloads; + /** * @param int $id Product ID. * @param string $download_path Download path. + * @param array $downloads Product downloads. */ - public function __construct(int $id, string $download_path) { + public function __construct(int $id, string $download_path, array $downloads = []) { $this->id = $id; $this->download_path = $download_path; + $this->downloads = $downloads; } /** @@ -67,6 +78,30 @@ public function get_id(): int { return $this->id; } + + /** + * @return bool Whether the product exists. + */ + public function exists(): bool { + + return true; + } + + /** + * @return bool Whether the product is downloadable. + */ + public function is_downloadable(): bool { + + return true; + } + + /** + * @return array Product downloads. + */ + public function get_downloads(): array { + + return $this->downloads; + } } class WC_Product_Download { @@ -91,6 +126,23 @@ public function get_name(): string { return $this->name; } + + /** + * @return bool Whether the download is enabled. + */ + public function get_enabled(): bool { + + return true; + } + } + + /** + * @param int $product_id Product ID. + * @return WC_Product|null Product stub. + */ + function wc_get_product(int $product_id): ?WC_Product { + + return $GLOBALS['wu_test_products'][$product_id] ?? null; } class WC_Download_Handler { @@ -225,6 +277,56 @@ function wp_remote_retrieve_header(array $response, string $header): ?string { return $response['headers'][$header] ?? null; } + /** + * @param string $key Cache key. + * @param string $group Cache group. + * @param bool $force Whether to bypass local cache. + * @param bool $found Whether the key was found. + * @return mixed Cached value, or false when missing. + */ + function wp_cache_get(string $key, string $group = '', bool $force = false, ?bool &$found = null) { + + $found = array_key_exists($key, $GLOBALS['wu_test_object_cache'][$group] ?? []); + + return $found ? $GLOBALS['wu_test_object_cache'][$group][$key] : false; + } + + /** + * @param string $key Cache key. + * @param mixed $value Cached value. + * @param string $group Cache group. + * @param int $expiration Expiration seconds. + * @return bool Whether the value was stored. + */ + function wp_cache_set(string $key, $value, string $group = '', int $expiration = 0): bool { + + $GLOBALS['wu_test_object_cache'][$group][$key] = $value; + $GLOBALS['wu_test_object_cache_ttls'][$group][$key] = $expiration; + + return true; + } + + /** + * @param string $key Cache key. + * @param string $group Cache group. + * @return bool Whether the value was deleted. + */ + function wp_cache_delete(string $key, string $group = ''): bool { + + $found = array_key_exists($key, $GLOBALS['wu_test_object_cache'][$group] ?? []); + unset($GLOBALS['wu_test_object_cache'][$group][$key]); + + return $found; + } + + /** + * @return bool Whether a persistent object-cache backend is active. + */ + function wp_using_ext_object_cache(): bool { + + return $GLOBALS['wu_using_ext_object_cache']; + } + /** * @param string $key Transient key. * @return mixed Transient value, or false when missing. @@ -247,6 +349,18 @@ function set_transient(string $key, $value, int $expiration): bool { return true; } + + /** + * @param string $key Transient key. + * @return bool Whether the transient was deleted. + */ + function delete_transient(string $key): bool { + + $found = array_key_exists($key, $GLOBALS['wu_test_transients']); + unset($GLOBALS['wu_test_transients'][$key]); + + return $found; + } } namespace WP_Update_Server_Plugin { @@ -267,14 +381,22 @@ function assert_true(bool $condition, string $message): void { } /** - * @param string $mode HTTP mock mode. + * @param string $mode HTTP mock mode. + * @param bool $clear_cache Whether to clear the object cache. * @return void */ - function reset_remote_test_state(string $mode): void { + function reset_remote_test_state(string $mode, bool $clear_cache = false): void { $GLOBALS['wu_http_calls'] = []; $GLOBALS['wu_http_mode'] = $mode; $GLOBALS['wu_last_temp_file_created'] = null; + + if ($clear_cache) { + $GLOBALS['wu_test_object_cache'] = []; + $GLOBALS['wu_test_object_cache_ttls'] = []; + $GLOBALS['wu_test_transients'] = []; + $GLOBALS['wu_test_transient_ttls'] = []; + } } /** @@ -304,7 +426,8 @@ function invoke_extract_version(WC_Product $product, WC_Product_Download $file): '/\'limit_response_size\'\s*=>\s*self::REMOTE_ARCHIVE_MAX_BYTES/' => 'remote archive responses have a maximum byte limit', '/wp_remote_retrieve_response_code\s*\(\s*\$response\s*\)/' => 'remote archive responses validate HTTP status', '/wp_remote_retrieve_header\s*\(\s*\$response\s*,\s*\'content-length\'\s*\)/' => 'remote archive responses inspect content length', - '/set_transient\s*\(\s*\$remote_cache_key\s*,\s*\$version_info\s*,\s*self::REMOTE_VERSION_CACHE_EXPIRATION\s*\)/' => 'successful remote metadata extraction is cached', + '/wp_cache_set\s*\(/' => 'remote metadata uses the WordPress object cache', + '/self::CACHE_GROUP/' => 'version metadata uses a dedicated cache group', '/hash\s*\(\s*\'sha256\'\s*,\s*\$identity\s*\)/' => 'remote version cache keys hash URL-bearing identity data', ]; @@ -324,8 +447,13 @@ function invoke_extract_version(WC_Product $product, WC_Product_Download $file): $remote_url = 'https://github.com/Ultimate-Multisite/ultimate-update-server-plugin/issues/32'; $product = new WC_Product(123, $remote_url); $file = new WC_Product_Download('Private Archive.zip'); + $versioned_file = new WC_Product_Download('Ultimate Multisite: WooCommerce Integration v3.2.1'); - reset_remote_test_state('timeout'); + reset_remote_test_state('success', true); + assert_true(['version' => '3.2.1'] === invoke_extract_version($product, $versioned_file), 'versioned display names expose package metadata'); + assert_true(0 === count($GLOBALS['wu_http_calls']), 'versioned display names avoid remote archive inspection'); + + reset_remote_test_state('timeout', true); assert_true(null === invoke_extract_version($product, $file), 'timeout errors fail closed'); assert_true(1 === count($GLOBALS['wu_http_calls']), 'timeout path attempts one bounded HTTP request'); @@ -337,28 +465,71 @@ function invoke_extract_version(WC_Product $product, WC_Product_Download $file): assert_true(is_string($timeout_args['filename']) && '' !== $timeout_args['filename'], 'HTTP response has a temp filename'); assert_true( ! file_exists($timeout_args['filename']), 'timeout path removes temporary files'); - reset_remote_test_state('http_500'); + reset_remote_test_state('http_500', true); assert_true(null === invoke_extract_version($product, $file), 'HTTP non-2xx responses fail closed'); assert_true( ! file_exists($GLOBALS['wu_last_temp_file_created']), 'HTTP non-2xx path removes temporary files'); - reset_remote_test_state('oversized'); + reset_remote_test_state('oversized', true); assert_true(null === invoke_extract_version($product, $file), 'oversized responses fail closed'); assert_true( ! file_exists($GLOBALS['wu_last_temp_file_created']), 'oversized path removes temporary files'); - reset_remote_test_state('success'); + reset_remote_test_state('success', true); $result = invoke_extract_version($product, $file); assert_true(['version' => '3.2.1'] === $result, 'successful remote inspection extracts package metadata'); assert_true( ! file_exists($GLOBALS['wu_last_temp_file_created']), 'successful path removes temporary files'); - assert_true(1 === count($GLOBALS['wu_test_transients']), 'successful remote metadata is cached'); + $cache_group = \WP_Update_Server_Plugin\Product_Versions::CACHE_GROUP; + assert_true(1 === count($GLOBALS['wu_test_object_cache'][$cache_group]), 'successful remote metadata is cached'); - $cache_key = (string) array_key_first($GLOBALS['wu_test_transients']); + $cache_key = (string) array_key_first($GLOBALS['wu_test_object_cache'][$cache_group]); assert_true(false === strpos($cache_key, $remote_url), 'cache key does not expose the raw remote URL'); - assert_true(86400 === $GLOBALS['wu_test_transient_ttls'][$cache_key], 'remote metadata cache TTL is one day'); + assert_true(86400 === $GLOBALS['wu_test_object_cache_ttls'][$cache_group][$cache_key], 'remote metadata cache TTL is one day'); reset_remote_test_state('success'); $result = invoke_extract_version($product, $file); assert_true(['version' => '3.2.1'] === $result, 'cached remote inspection returns cached metadata'); assert_true(0 === count($GLOBALS['wu_http_calls']), 'cached remote inspection does not download again'); + reset_remote_test_state('timeout', true); + assert_true(null === invoke_extract_version($product, $file), 'failed remote inspection returns null'); + assert_true(1 === count($GLOBALS['wu_http_calls']), 'failed inspection attempts one bounded HTTP request'); + assert_true(3600 === $GLOBALS['wu_test_object_cache_ttls'][$cache_group][$cache_key], 'failed inspection is cached for one hour'); + + reset_remote_test_state('success'); + assert_true(null === invoke_extract_version($product, $file), 'negative cache hit returns null'); + assert_true(0 === count($GLOBALS['wu_http_calls']), 'negative cache hit does not download again'); + + $product = new WC_Product(123, $remote_url, ['download-1' => $file]); + $GLOBALS['wu_test_products'][123] = $product; + reset_remote_test_state('success', true); + $versions = \WP_Update_Server_Plugin\Product_Versions::get_all_versions_by_product_id(123); + assert_true(1 === count($GLOBALS['wu_http_calls']), 'uncached product versions inspect the remote archive once'); + assert_true('3.2.1' === $versions[0]['version'], 'product version inspection returns archive metadata'); + assert_true(3600 === $GLOBALS['wu_test_object_cache_ttls'][$cache_group]['versions_123'], 'product version list is cached for one hour'); + + reset_remote_test_state('success'); + $versions = \WP_Update_Server_Plugin\Product_Versions::get_all_versions_by_product_id(123); + assert_true('3.2.1' === $versions[0]['version'], 'cached product version list is returned'); + assert_true(0 === count($GLOBALS['wu_http_calls']), 'cached product version list avoids remote inspection'); + + \WP_Update_Server_Plugin\Product_Versions::clear_cache(123); + assert_true( ! isset($GLOBALS['wu_test_object_cache'][$cache_group]['versions_123']), 'product cache invalidation deletes the grouped object-cache entry'); + assert_true(empty($GLOBALS['wu_test_object_cache'][$cache_group]), 'product cache invalidation deletes remote metadata entries'); + + $GLOBALS['wu_using_ext_object_cache'] = false; + reset_remote_test_state('success', true); + $versions = \WP_Update_Server_Plugin\Product_Versions::get_all_versions_by_product_id(123); + $transient_key = $cache_group . '_versions_123'; + assert_true(1 === count($GLOBALS['wu_http_calls']), 'transient fallback inspects an uncached remote archive once'); + assert_true('3.2.1' === $versions[0]['version'], 'transient fallback returns archive metadata'); + assert_true(3600 === $GLOBALS['wu_test_transient_ttls'][$transient_key], 'product version list uses a one-hour transient fallback'); + + reset_remote_test_state('success'); + \WP_Update_Server_Plugin\Product_Versions::get_all_versions_by_product_id(123); + assert_true(0 === count($GLOBALS['wu_http_calls']), 'transient fallback avoids remote inspection across requests'); + + \WP_Update_Server_Plugin\Product_Versions::clear_cache(123); + assert_true( ! isset($GLOBALS['wu_test_transients'][$transient_key]), 'product cache invalidation deletes the transient fallback'); + assert_true(empty($GLOBALS['wu_test_transients']), 'product cache invalidation deletes transient remote metadata entries'); + fwrite(STDOUT, "Product_Versions remote archive regression checks passed.\n"); }