wp-update-server-plugin/inc/class-product-versions.php
David Stone 6e443d7164 Fix ensure_download_permissions to scope by order_id
The function was checking if the user had ANY permission for a file
across all orders, which prevented creating order-specific permissions
when the user had the file from a different order.

Changed to filter by order_id so each order gets its own complete set
of download permissions, fixing composer downloads that use order-specific
authentication tokens.
2026-03-03 19:24:49 -07:00

517 lines
15 KiB
PHP

<?php
/**
* Product Versions Handler
*
* Handles retrieval of all versions for downloadable products.
*
* @package WP_Update_Server_Plugin
*/
namespace WP_Update_Server_Plugin;
use Automattic\WooCommerce\Proxies\LegacyProxy;
class Product_Versions {
/**
* Cache group for version data.
*
* @var string
*/
const CACHE_GROUP = 'wu_product_versions';
/**
* Cache expiration in seconds (1 hour).
*
* @var int
*/
const CACHE_EXPIRATION = HOUR_IN_SECONDS;
/**
* Get all versions of a product by SKU.
*
* @param string $sku The product SKU.
* @return array Array of version data: [['version' => '2.0.0', 'file_id' => 'abc', 'name' => '...'], ...]
*/
public static function get_all_versions(string $sku): array {
$product_id = wc_get_product_id_by_sku($sku);
if ( ! $product_id) {
return [];
}
return self::get_all_versions_by_product_id($product_id);
}
/**
* Get all versions of a product by product ID.
*
* @param int $product_id The product ID.
* @return array Array of version data.
*/
public static function get_all_versions_by_product_id(int $product_id): array {
$product = wc_get_product($product_id);
if ( ! $product || ! $product->exists() || ! $product->is_downloadable()) {
return [];
}
// Check cache
$cache_key = 'versions_' . $product_id;
$cached = get_transient($cache_key);
if ($cached !== false) {
return $cached;
}
$versions = [];
$files = $product->get_downloads();
foreach ($files as $file_id => $file) {
if ( ! $file->get_enabled()) {
continue;
}
$version_info = self::extract_version_from_file($product, $file_id, $file);
if ($version_info) {
$versions[] = array_merge(
$version_info,
[
'file_id' => $file_id,
'name' => $file->get_name(),
]
);
}
}
// Sort by version descending (latest first)
usort($versions, function ($a, $b) {
return version_compare($b['version'], $a['version']);
});
// Cache the results
set_transient($cache_key, $versions, self::CACHE_EXPIRATION);
return $versions;
}
/**
* Extract version information from a download file.
*
* @param \WC_Product $product The product.
* @param string $file_id The file ID.
* @param \WC_Product_Download $file The download file.
* @return array|null Version info or null if extraction failed.
*/
private static function extract_version_from_file(\WC_Product $product, string $file_id, \WC_Product_Download $file): ?array {
$file_info = \WC_Download_Handler::parse_file_path($product->get_file_download_path($file_id));
$filepath = $file_info['file_path'];
// Handle remote files
if ($file_info['remote_file']) {
// Try to extract version from filename first to avoid downloading
$version = self::extract_version_from_filename($file->get_name());
if ($version) {
return ['version' => $version];
}
// Download temporarily if we need to inspect the archive
require_once ABSPATH . 'wp-admin/includes/file.php';
$tmp = wp_tempnam($file->get_name());
// phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents
file_put_contents($tmp, file_get_contents($filepath));
$filepath = $tmp;
}
if ( ! is_file($filepath) || ! is_readable($filepath)) {
return null;
}
// Try to get version from Wpup_Package
try {
$package = \Wpup_Package::fromArchive($filepath);
$metadata = $package->getMetadata();
$version_info = ['version' => $metadata['version'] ?? '0.0.0'];
// Clean up temp file if we created one
if (isset($tmp) && $filepath === $tmp && file_exists($tmp)) {
wp_delete_file($tmp);
}
return $version_info;
} catch (\Exception $e) {
// Fallback to filename extraction
$version = self::extract_version_from_filename($file->get_name());
// Clean up temp file if we created one
if (isset($tmp) && file_exists($tmp)) {
wp_delete_file($tmp);
}
return $version ? ['version' => $version] : null;
}
}
/**
* Extract version from filename.
*
* @param string $filename The filename.
* @return string|null The version or null.
*/
private static function extract_version_from_filename(string $filename): ?string {
// Match patterns like: plugin-name-1.2.3.zip or plugin-name-v1.2.3.zip
if (preg_match('/-v?(\d+\.\d+(?:\.\d+)?(?:-[a-zA-Z0-9.]+)?)\.zip$/i', $filename, $matches)) {
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)) {
return $matches[1];
}
return null;
}
/**
* Get user's accessible products with all their versions.
*
* @param int $user_id The user ID.
* @return array Array of products with versions and download URLs.
*/
public static function get_user_products_with_versions(int $user_id): array {
/** @var \WC_Customer_Download_Data_Store $downloads_data_store */
$downloads_data_store = wc_get_container()->get(LegacyProxy::class)->get_instance_of(\WC_Data_Store::class, 'customer-download');
// Get all download permissions for this user
$permissions = $downloads_data_store->get_downloads([
'user_id' => $user_id,
]);
// Group by product, keeping first permission per product for reference
$products_map = [];
$reference_permissions = [];
foreach ($permissions as $permission) {
$product_id = $permission->get_product_id();
if ( ! isset($reference_permissions[$product_id])) {
$reference_permissions[$product_id] = $permission;
}
}
foreach ($reference_permissions as $product_id => $permission) {
$product = wc_get_product($product_id);
if ( ! $product || ! $product->exists()) {
continue;
}
$versions = self::get_all_versions_by_product_id($product_id);
// Ensure the user has download permissions for all current files
self::ensure_download_permissions($product, $permission);
// Build download URLs for each version
$versions_with_urls = [];
foreach ($versions as $version_data) {
$versions_with_urls[] = array_merge(
$version_data,
[
'download_url' => self::get_version_download_url_by_permission($permission, $version_data['file_id']),
]
);
}
$products_map[$product_id] = [
'product_id' => $product_id,
'name' => $product->get_name(),
'sku' => $product->get_sku(),
'icon' => Product_Icon::get_product_icon($product_id, 'thumbnail'),
'type' => self::get_product_type($product_id),
'requires' => get_post_meta($product_id, '_requires_wp', true),
'tested_up_to' => get_post_meta($product_id, '_tested_up_to', true),
'versions' => $versions_with_urls,
'latest_version' => ! empty($versions_with_urls) ? $versions_with_urls[0]['version'] : null,
];
}
return array_values($products_map);
}
/**
* Ensure a user has download permissions for all current files on a product.
*
* When new downloadable files are added to a product after purchase,
* WooCommerce does not automatically grant existing customers permission
* to download them. This method creates the missing permission records
* so customers always have access to all versions of products they own.
*
* @param \WC_Product $product The product.
* @param \WC_Customer_Download $permission An existing permission for reference (order, user, etc.).
* @return void
*/
public static function ensure_download_permissions(\WC_Product $product, \WC_Customer_Download $permission): void {
$product_id = $product->get_id();
$order_id = $permission->get_order_id();
$order = wc_get_order($order_id);
if ( ! $order) {
return;
}
$files = $product->get_downloads();
if (empty($files)) {
return;
}
/** @var \WC_Customer_Download_Data_Store $downloads_data_store */
$downloads_data_store = wc_get_container()->get(LegacyProxy::class)->get_instance_of(\WC_Data_Store::class, 'customer-download');
// Get all existing permissions for this user + product + order
$existing = $downloads_data_store->get_downloads([
'user_id' => $permission->get_user_id(),
'product_id' => $product_id,
'order_id' => $order_id,
]);
$existing_file_ids = [];
foreach ($existing as $perm) {
$existing_file_ids[$perm->get_download_id()] = true;
}
// Capture the access_expires from the reference permission so new
// permissions inherit the same subscription expiry window instead of
// getting a fresh window calculated from now.
$reference_expires = $permission->get_access_expires();
// Grant permissions for any files the user doesn't have yet
foreach ($files as $file_id => $file) {
if (isset($existing_file_ids[$file_id])) {
continue;
}
wc_downloadable_file_permission($file_id, $product, $order);
// Sync the expiry to match the existing permission's subscription window.
// wc_downloadable_file_permission() calculates expiry from now, which
// would bypass the yearly subscription limit.
$new_permissions = $downloads_data_store->get_downloads([
'user_id' => $permission->get_user_id(),
'product_id' => $product_id,
'download_id' => $file_id,
'orderby' => 'permission_id',
'order' => 'DESC',
'limit' => 1,
]);
if ( ! empty($new_permissions)) {
$new_perm = $new_permissions[0];
$new_perm->set_access_expires($reference_expires);
$new_perm->save();
}
}
}
/**
* Get download URL for a specific version using permission.
*
* @param \WC_Customer_Download $permission The download permission.
* @param string $file_id The specific file ID for the version.
* @return string The download URL.
*/
private static function get_version_download_url_by_permission(\WC_Customer_Download $permission, string $file_id): string {
return add_query_arg(
[
'download_file' => $permission->get_product_id(),
'order' => $permission->get_order_key(),
'email' => rawurlencode($permission->get_user_email()),
'key' => $file_id,
],
home_url('/')
);
}
/**
* Get download URL for specific product version.
*
* @param int $user_id The user ID.
* @param string $sku The product SKU.
* @param string $version The version to download.
* @return string|null The download URL or null if not accessible.
*/
public static function get_version_download_url(int $user_id, string $sku, string $version): ?string {
$product_id = wc_get_product_id_by_sku($sku);
if ( ! $product_id) {
return null;
}
/** @var \WC_Customer_Download_Data_Store $downloads_data_store */
$downloads_data_store = wc_get_container()->get(LegacyProxy::class)->get_instance_of(\WC_Data_Store::class, 'customer-download');
$permissions = $downloads_data_store->get_downloads([
'product_id' => $product_id,
'user_id' => $user_id,
'limit' => 1,
]);
if (empty($permissions)) {
return null;
}
$permission = $permissions[0];
// Ensure user has permissions for all current files
$product = wc_get_product($product_id);
if ($product) {
self::ensure_download_permissions($product, $permission);
}
// Find the file ID for the requested version
$versions = self::get_all_versions_by_product_id($product_id);
foreach ($versions as $version_data) {
if ($version_data['version'] === $version) {
return self::get_version_download_url_by_permission($permission, $version_data['file_id']);
}
}
return null;
}
/**
* Determine product type (plugin or theme).
*
* @param int $product_id The product ID.
* @return string 'plugin' or 'theme'.
*/
private static function get_product_type(int $product_id): string {
// Check product tags first
$terms = get_the_terms($product_id, 'product_tag');
if ( ! empty($terms) && ! is_wp_error($terms)) {
foreach ($terms as $term) {
if ($term->slug === 'theme' || $term->slug === 'themes') {
return 'theme';
}
}
}
// Check product meta
$type_meta = get_post_meta($product_id, '_product_type_software', true);
if ($type_meta === 'theme') {
return 'theme';
}
return 'plugin';
}
/**
* Check if a version string is a pre-release (alpha, beta, rc, etc.).
*
* @param string $version The version string.
* @return bool True if the version is a pre-release.
*/
public static function is_prerelease(string $version): bool {
return (bool) preg_match('/-(?:alpha|beta|rc|dev|preview)\b/i', $version);
}
/**
* Get the latest version for a product, optionally filtering out pre-releases.
*
* When $include_prerelease is true, a pre-release like 1.0.0-beta.1 is considered
* newer than 1.0.0 (its base version). PHP's version_compare treats pre-release
* suffixes as lower, so we need custom logic here.
*
* @param int $product_id The product ID.
* @param bool $include_prerelease Whether to include pre-release versions.
* @return array|null Latest version data or null if none found.
*/
public static function get_latest_version_by_product_id(int $product_id, bool $include_prerelease = false): ?array {
$versions = self::get_all_versions_by_product_id($product_id);
if (empty($versions)) {
return null;
}
if (! $include_prerelease) {
foreach ($versions as $version_data) {
if (! self::is_prerelease($version_data['version'])) {
return $version_data;
}
}
return null;
}
// When including pre-releases, we need to find the true latest.
// PHP's version_compare sorts 1.0.0 > 1.0.0-beta.1, but semver says
// 1.0.0-beta.1 is a pre-release OF 1.0.0, meaning it was published
// before/after the stable and should be considered newer when opted in.
// Strategy: find the latest stable, then check if any pre-release has
// a base version >= that stable version. If so, the pre-release wins.
$latest_stable = null;
$latest_prerelease = null;
foreach ($versions as $version_data) {
if (self::is_prerelease($version_data['version'])) {
if (! $latest_prerelease) {
$latest_prerelease = $version_data;
}
} else {
if (! $latest_stable) {
$latest_stable = $version_data;
}
}
}
// Only pre-releases exist
if (! $latest_stable) {
return $latest_prerelease;
}
// No pre-releases exist
if (! $latest_prerelease) {
return $latest_stable;
}
// Both exist — extract the base version from the pre-release
$prerelease_base = preg_replace('/-(?:alpha|beta|rc|dev|preview)[\w.]*/i', '', $latest_prerelease['version']);
// If the pre-release's base version >= the stable version, the pre-release is newer
if (version_compare($prerelease_base, $latest_stable['version'], '>=')) {
return $latest_prerelease;
}
return $latest_stable;
}
/**
* Clear version cache for a product.
*
* @param int $product_id The product ID.
* @return void
*/
public static function clear_cache(int $product_id): void {
delete_transient('versions_' . $product_id);
}
}