diff --git a/plugins/woocommerce/includes/admin/settings/class-wc-settings-products.php b/plugins/woocommerce/includes/admin/settings/class-wc-settings-products.php index 813f108ee02..e37d37d90eb 100644 --- a/plugins/woocommerce/includes/admin/settings/class-wc-settings-products.php +++ b/plugins/woocommerce/includes/admin/settings/class-wc-settings-products.php @@ -446,6 +446,20 @@ class WC_Settings_Products extends WC_Settings_Page { ), ), + array( + 'title' => __( 'Count partial downloads', 'woocommerce' ), + 'desc' => __( 'Count downloads even if only part of a file is fetched.', 'woocommerce' ), + 'id' => 'woocommerce_downloads_count_partial', + 'type' => 'checkbox', + 'default' => 'yes', + 'desc_tip' => sprintf( + /* Translators: 1: opening link tag 2: closing link tag. */ + __( 'Repeat fetches made within a reasonable window of time (by default, 30 minutes) will not be counted twice. This is a generally reasonably way to enforce download limits in relation to ranged requests. %1$sLearn more.%2$s', 'woocommerce' ), + '', + '' + ), + ), + array( 'type' => 'sectionend', 'id' => 'digital_download_options', diff --git a/plugins/woocommerce/includes/class-wc-download-handler.php b/plugins/woocommerce/includes/class-wc-download-handler.php index 82571a9cf65..cb9d5d940a0 100644 --- a/plugins/woocommerce/includes/class-wc-download-handler.php +++ b/plugins/woocommerce/includes/class-wc-download-handler.php @@ -8,12 +8,19 @@ * @version 2.2.0 */ +use Automattic\WooCommerce\Internal\Traits\AccessiblePrivateMethods; defined( 'ABSPATH' ) || exit; /** * Download handler class. */ class WC_Download_Handler { + use AccessiblePrivateMethods; + + /** + * The hook used for deferred tracking of partial download attempts. + */ + public const TRACK_DOWNLOAD_CALLBACK = 'track_partial_download'; /** * Hook in methods. @@ -25,6 +32,7 @@ class WC_Download_Handler { add_action( 'woocommerce_download_file_redirect', array( __CLASS__, 'download_file_redirect' ), 10, 2 ); add_action( 'woocommerce_download_file_xsendfile', array( __CLASS__, 'download_file_xsendfile' ), 10, 2 ); add_action( 'woocommerce_download_file_force', array( __CLASS__, 'download_file_force' ), 10, 2 ); + self::add_action( self::TRACK_DOWNLOAD_CALLBACK, array( __CLASS__, 'track_download' ), 10, 3 ); } /** @@ -135,9 +143,13 @@ class WC_Download_Handler { // Track the download in logs and change remaining/counts. $current_user_id = get_current_user_id(); $ip_address = WC_Geolocation::get_ip_address(); - if ( ! $download_range['is_range_request'] ) { - $download->track_download( $current_user_id > 0 ? $current_user_id : null, ! empty( $ip_address ) ? $ip_address : null ); - } + + self::track_download( + $download, + $current_user_id > 0 ? $current_user_id : null, + ! empty( $ip_address ) ? $ip_address : null, + $download_range['is_range_request'] + ); self::download( $file_path, $download->get_product_id() ); } @@ -695,6 +707,76 @@ class WC_Download_Handler { } wp_die( $message, $title, array( 'response' => $status ) ); // WPCS: XSS ok. } + + /** + * Takes care of tracking download requests, with support for deferring tracking in the case of + * partial (ranged request) downloads. + * + * @param WC_Customer_Download|int $download The download to be tracked. + * @param int|null $user_id The user ID, if known. + * @param string|null $user_ip_address The download IP address, if known. + * @param bool $defer If tracking the download should be deferred. + * + * @return void + * @throws Exception If the active version of Action Scheduler is less than 3.6.0. + */ + private static function track_download( $download, $user_id = null, $user_ip_address = null, bool $defer = false ): void { + try { + // If we were supplied with an integer, convert it to a download object. + $download = new WC_Customer_Download( $download ); + + // In simple cases, we can track the download immediately. + if ( ! $defer ) { + $download->track_download( $user_id, $user_ip_address ); + return; + } + + // Counting of partial downloads may be disabled by the site operator. + if ( get_option( 'woocommerce_downloads_count_partial', 'yes' ) !== 'yes' ) { + return; + } + + /** + * Determines how long the window of time is for tracking unique download attempts, in relation to + * partial (ranged) download requests. + * + * @since 9.2.0 + * + * @param int $window_in_seconds Non-negative number of seconds. Defaults to 1800 (30 minutes). + * @param int $download_permission_id References the download permission being tracked. + */ + $window = absint( apply_filters( 'woocommerce_partial_download_tracking_window', 30 * MINUTE_IN_SECONDS, $download->get_id() ) ); + + // If we do not have Action Scheduler 3.6.0+ (this would be an unexpected scenario) then we cannot + // track partial downloads, because we require support for unique actions. + if ( version_compare( ActionScheduler_Versions::instance()->latest_version(), '3.6.0', '<' ) ) { + throw new Exception( 'Support for unique scheduled actions is not currently available.' ); + } + + as_schedule_single_action( + time() + $window, + self::TRACK_DOWNLOAD_CALLBACK, + array( + $download->get_id(), + $user_id, + $user_ip_address, + ), + 'woocommerce', + true + ); + } catch ( Exception $e ) { + wc_get_logger()->error( + 'There was a problem while tracking a product download.', + array( + 'error' => $e->getMessage(), + 'id' => $download->get_id(), + 'user_id' => $user_id, + 'ip' => $user_ip_address, + 'deferred' => $defer ? 'yes' : 'no', + ) + ); + } + } } WC_Download_Handler::init(); 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 3f4c7cae3cf..53d04b481a7 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 @@ -74,7 +74,7 @@ class WC_Download_Handler_Tests extends \WC_Unit_Test_Case { $approved_directories->add_approved_directory( 'https://always.trusted' ); $approved_directory_rule_id = $approved_directories->add_approved_directory( 'https://new.supplier' ); - $product = WC_Helper_Product::create_downloadable_product( + list( $product, $order ) = $this->build_downloadable_product_and_order_one( array( array( 'name' => 'Book 1', @@ -87,12 +87,7 @@ class WC_Download_Handler_Tests extends \WC_Unit_Test_Case { ) ); - $customer = WC_Helper_Customer::create_customer(); - $email = 'admin@example.org'; - $order = WC_Helper_Order::create_order( $customer->get_id(), $product ); - $order->set_status( 'completed' ); - $order->save(); - + $email = 'admin@example.org'; $product_id = $product->get_id(); $downloads = $product->get_downloads(); $download_keys = array_keys( $downloads ); @@ -139,6 +134,111 @@ class WC_Download_Handler_Tests extends \WC_Unit_Test_Case { self::restore_download_handlers(); } + /** + * @testdox The remaining downloads count should iterate accurately. + */ + public function test_downloads_remaining_count(): void { + self::remove_download_handlers(); + // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_file_put_contents -- Ok for unit tests. + file_put_contents( WP_CONTENT_DIR . '/uploads/woocommerce_uploads/supersheet-123.ods', str_pad( '', 100 ) ); + + list( $product, $order ) = $this->build_downloadable_product_and_order_one( + array( + array( + 'name' => 'Supersheet 123', + 'file' => content_url( 'uploads/woocommerce_uploads/supersheet-123.ods' ), + ), + ) + ); + + $product_id = $product->get_id(); + $downloads = $product->get_downloads(); + $download_keys = array_keys( $downloads ); + $email = 'admin@example.org'; + $download = current( WC_Data_Store::load( 'customer-download' )->get_downloads( array( 'product_id' => $product_id ) ) ); + + $download->set_downloads_remaining( 10 ); + $download->save(); + + // phpcs:disable WordPress.Security.NonceVerification.Recommended WordPress.Security.ValidatedSanitizedInput.InputNotValidated + $_GET = array( + 'download_file' => $product_id, + 'order' => $order->get_order_key(), + 'email' => $email, + 'uid' => hash( 'sha256', $email ), + 'key' => $download_keys[0], + ); + + WC_Download_Handler::download_product(); + $download = new WC_Customer_Download( $download->get_id() ); + $this->assertEquals( + 9, + $download->get_downloads_remaining(), + 'In relation to "normal" download requests, we should see a reduction in the downloads remaining count.' + ); + + // Let's simulate a ranged request (partial download). + $_SERVER['HTTP_RANGE'] = 'bytes=10-50'; + WC_Download_Handler::download_product(); + $download = new WC_Customer_Download( $download->get_id() ); + $this->assertEquals( + 9, + $download->get_downloads_remaining(), + 'In relation to "ranged" (partial) download requests, we should not see an immediate reduction in the downloads remaining count.' + ); + + // Repeat (HTTP_RANGE is still set). + WC_Download_Handler::download_product(); + $download = new WC_Customer_Download( $download->get_id() ); + $this->assertEquals( + 9, + $download->get_downloads_remaining(), + 'In relation to "ranged" (partial) download requests, we should not see an immediate reduction in the downloads remaining count.' + ); + + // Find the deferred download tracking action. + $deferred_download_tracker = current( + WC_Queue::instance()->search( + array( 'hook' => WC_Download_Handler::TRACK_DOWNLOAD_CALLBACK ) + ) + ); + + // Let it do it's thing, and confirm that a further decrement happened (of just 1 unit). + do_action_ref_array( $deferred_download_tracker->get_hook(), $deferred_download_tracker->get_args() ); + $download = new WC_Customer_Download( $download->get_id() ); + $this->assertEquals( + 8, + $download->get_downloads_remaining(), + 'In relation to "ranged" (partial) download requests, the deferred update to the downloads remaining count functioned as expected.' + ); + + self::restore_download_handlers(); + } + + /** + * Creates a downloadable product, and then places (and completes) an order for that + * object. + * + * @param array[] $downloadable_files Array of arrays, with each inner array specifying the 'name' and 'file'. + * + * @return array { + * WC_Product, + * WC_Order + * } + */ + private function build_downloadable_product_and_order_one( array $downloadable_files ): array { + $product = WC_Helper_Product::create_downloadable_product( $downloadable_files ); + $customer = WC_Helper_Customer::create_customer(); + $order = WC_Helper_Order::create_order( $customer->get_id(), $product ); + $order->set_status( 'completed' ); + $order->save(); + + return array( + $product, + $order, + ); + } + /** * Unregister download handlers to prevent unwanted output and side-effects. */ diff --git a/plugins/woocommerce/tests/php/includes/settings/class-wc-settings-products-test.php b/plugins/woocommerce/tests/php/includes/settings/class-wc-settings-products-test.php index 800618d4c8a..b98e3cde301 100644 --- a/plugins/woocommerce/tests/php/includes/settings/class-wc-settings-products-test.php +++ b/plugins/woocommerce/tests/php/includes/settings/class-wc-settings-products-test.php @@ -148,6 +148,7 @@ class WC_Settings_Products_Test extends WC_Settings_Unit_Test_Case { 'woocommerce_downloads_grant_access_after_payment' => 'checkbox', 'woocommerce_downloads_add_hash_to_filename' => 'checkbox', 'woocommerce_downloads_deliver_inline' => 'checkbox', + 'woocommerce_downloads_count_partial' => 'checkbox', ); $this->assertEquals( $expected, $settings_ids_and_types );