Added approval feature for downloadable files

This commit is contained in:
Peter Fabian 2022-03-18 10:26:32 +01:00
parent 6e9ed9a83a
commit 59b941b239
33 changed files with 2896 additions and 61 deletions

View File

@ -10,6 +10,7 @@ if ( ! defined( 'ABSPATH' ) ) {
}
use Automattic\WooCommerce\Internal\ProductAttributesLookup\LookupDataStore as ProductAttributesLookupDataStore;
use Automattic\WooCommerce\Internal\ProductDownloads\ApprovedDirectories\Register as Download_Directories;
/**
* Legacy product contains all deprecated methods for this class and can be
@ -1224,25 +1225,14 @@ class WC_Product extends WC_Abstract_Legacy_Product {
$download_object->set_file( $download['file'] );
}
// Validate the file extension.
if ( ! $download_object->is_allowed_filetype() ) {
try {
$download_object->check_is_valid();
$downloads[ $download_object->get_id() ] = $download_object;
} catch ( Exception $e ) {
if ( $this->get_object_read() ) {
/* translators: %1$s: Downloadable file */
$errors[] = sprintf( __( 'The downloadable file %1$s cannot be used as it does not have an allowed file type. Allowed types include: %2$s', 'woocommerce' ), '<code>' . basename( $download_object->get_file() ) . '</code>', '<code>' . implode( ', ', array_keys( $download_object->get_allowed_mime_types() ) ) . '</code>' );
$errors[] = $e->getMessage();
}
continue;
}
// Validate the file exists.
if ( ! $download_object->file_exists() ) {
if ( $this->get_object_read() ) {
/* translators: %s: Downloadable file */
$errors[] = sprintf( __( 'The downloadable file %s cannot be used as it does not exist on the server.', 'woocommerce' ), '<code>' . $download_object->get_file() . '</code>' );
}
continue;
}
$downloads[ $download_object->get_id() ] = $download_object;
}
if ( $errors ) {

View File

@ -193,6 +193,7 @@ if ( ! class_exists( 'WC_Admin_Assets', false ) ) :
'i18n_sale_less_than_regular_error' => __( 'Please enter in a value less than the regular price.', 'woocommerce' ),
'i18n_delete_product_notice' => __( 'This product has produced sales and may be linked to existing orders. Are you sure you want to delete it?', 'woocommerce' ),
'i18n_remove_personal_data_notice' => __( 'This action cannot be reversed. Are you sure you wish to erase personal data from the selected orders?', 'woocommerce' ),
'i18n_confirm_delete' => __( 'Are you sure you wish to delete this item?', 'woocommerce' ),
'decimal_point' => $decimal,
'mon_decimal_point' => wc_get_price_decimal_separator(),
'ajax_url' => admin_url( 'admin-ajax.php' ),

View File

@ -7,6 +7,7 @@
*/
use Automattic\Jetpack\Constants;
use Automattic\WooCommerce\Internal\Utilities\Users;
defined( 'ABSPATH' ) || exit;
@ -28,18 +29,19 @@ class WC_Admin_Notices {
* @var array
*/
private static $core_notices = array(
'update' => 'update_notice',
'template_files' => 'template_file_check_notice',
'legacy_shipping' => 'legacy_shipping_notice',
'no_shipping_methods' => 'no_shipping_methods_notice',
'regenerating_thumbnails' => 'regenerating_thumbnails_notice',
'regenerating_lookup_table' => 'regenerating_lookup_table_notice',
'no_secure_connection' => 'secure_connection_notice',
WC_PHP_MIN_REQUIREMENTS_NOTICE => 'wp_php_min_requirements_notice',
'maxmind_license_key' => 'maxmind_missing_license_key_notice',
'redirect_download_method' => 'redirect_download_method_notice',
'uploads_directory_is_unprotected' => 'uploads_directory_is_unprotected_notice',
'base_tables_missing' => 'base_tables_missing_notice',
'update' => 'update_notice',
'template_files' => 'template_file_check_notice',
'legacy_shipping' => 'legacy_shipping_notice',
'no_shipping_methods' => 'no_shipping_methods_notice',
'regenerating_thumbnails' => 'regenerating_thumbnails_notice',
'regenerating_lookup_table' => 'regenerating_lookup_table_notice',
'no_secure_connection' => 'secure_connection_notice',
WC_PHP_MIN_REQUIREMENTS_NOTICE => 'wp_php_min_requirements_notice',
'maxmind_license_key' => 'maxmind_missing_license_key_notice',
'redirect_download_method' => 'redirect_download_method_notice',
'uploads_directory_is_unprotected' => 'uploads_directory_is_unprotected_notice',
'base_tables_missing' => 'base_tables_missing_notice',
'download_directories_sync_complete' => 'download_directories_sync_complete',
);
/**
@ -526,6 +528,24 @@ class WC_Admin_Notices {
}
}
/**
* Notice about the completion of the product downloads sync, with further advice for the site operator.
*/
public static function download_directories_sync_complete() {
$notice_dismissed = apply_filters(
'woocommerce_hide_download_directories_sync_complete',
get_user_meta( get_current_user_id(), 'download_directories_sync_complete', true )
);
if ( $notice_dismissed ) {
self::remove_notice( 'download_directories_sync_complete' );
}
if ( Users::is_site_administrator() ) {
include __DIR__ . '/views/html-notice-download-dir-sync-complete.php';
}
}
/**
* Display MaxMind missing license key notice.
*

View File

@ -6,6 +6,7 @@
*/
use Automattic\Jetpack\Constants;
use Automattic\WooCommerce\Internal\ProductDownloads\ApprovedDirectories\Register as Download_Directories;
defined( 'ABSPATH' ) || exit;
@ -804,6 +805,11 @@ if ( 0 < count( $dropins_mu_plugins['mu_plugins'] ) ) :
<td class="help"><?php echo wc_help_tip( esc_html__( 'Is your site connected to WooCommerce.com?', 'woocommerce' ) ); /* phpcs:ignore WordPress.XSS.EscapeOutput.OutputNotEscaped */ ?></td>
<td><?php echo 'yes' === $settings['woocommerce_com_connected'] ? '<mark class="yes"><span class="dashicons dashicons-yes"></span></mark>' : '<mark class="no">&ndash;</mark>'; ?></td>
</tr>
<tr>
<td data-export-label="Enforce Approved Product Download Directories"><?php esc_html_e( 'Enforce Approved Product Download Directories', 'woocommerce' ); ?>:</td>
<td class="help"><?php echo wc_help_tip( esc_html__( 'Is your site enforcing the use of Approved Product Download Directories?', 'woocommerce' ) ); /* phpcs:ignore WordPress.XSS.EscapeOutput.OutputNotEscaped */ ?></td>
<td><?php echo wc_get_container()->get( Download_Directories::class )->get_mode() === Download_Directories::MODE_ENABLED ? '<mark class="yes"><span class="dashicons dashicons-yes"></span></mark>' : '<mark class="no">&ndash;</mark>'; ?></td>
</tr>
</tbody>
</table>
<table class="wc_status_table widefat" cellspacing="0">

View File

@ -0,0 +1,27 @@
<?php
/**
* Admin View: Notice - Product downloads directories sync complete.
*/
defined( 'ABSPATH' ) || exit;
?>
<div id="message" class="updated woocommerce-message">
<a class="woocommerce-message-close notice-dismiss" href="<?php echo esc_url( wp_nonce_url( add_query_arg( 'wc-hide-notice', 'download_directories_sync_complete' ), 'woocommerce_hide_notices_nonce', '_wc_notice_nonce' ) ); ?>"><?php _e( 'Dismiss', 'woocommerce' ); ?></a>
<p>
<?php
$settings_screen_link = '<a href="' . esc_url( get_admin_url( null, 'admin.php?page=wc-settings&tab=products&section=download_urls' ) ) . '">';
$documentation_link = '<a href="https://woocommerce.com/document/approved-download-directories">';
$closing_link = '</a>';
printf(
/* translators: %1$s and %3$s are HTML (opening link tags). %2$s is also HTML (closing link tag). */
esc_html__( 'The %1$sApproved Product Download Directories list%2$s has been updated. To protect your site, please review the list and make any changes that might be required. For more information, please refer to %3$sthis guide%2$s.', 'woocommerce' ),
$settings_screen_link,
$closing_link,
$documentation_link
);
?>
</p>
</div>

View File

@ -8,6 +8,8 @@
use Automattic\Jetpack\Constants;
use Automattic\WooCommerce\Internal\ProductAttributesLookup\DataRegenerator;
use Automattic\WooCommerce\Internal\ProductDownloads\ApprovedDirectories\Register as Download_Directories;
use Automattic\WooCommerce\Internal\ProductDownloads\ApprovedDirectories\Synchronize as Download_Directories_Sync;
use Automattic\WooCommerce\Internal\Utilities\DatabaseUtil;
use Automattic\WooCommerce\Internal\WCCom\ConnectionHelper as WCConnectionHelper;
@ -184,6 +186,7 @@ class WC_Install {
),
'6.4.0' => array(
'wc_update_640_add_primary_key_to_product_attributes_lookup_table',
'wc_update_640_approved_download_directories',
'wc_update_640_db_version',
),
);
@ -681,6 +684,9 @@ class WC_Install {
// Define initial tax classes.
WC_Tax::create_tax_class( __( 'Reduced rate', 'woocommerce' ) );
WC_Tax::create_tax_class( __( 'Zero rate', 'woocommerce' ) );
// For new installs, setup and enable Approved Product Download Directories.
wc_get_container()->get( Download_Directories_Sync::class )->init_feature( false, true );
}
}
@ -1084,6 +1090,13 @@ CREATE TABLE {$wpdb->prefix}wc_rate_limits (
UNIQUE KEY rate_limit_key (rate_limit_key($max_index_length))
) $collate;
$product_attributes_lookup_table_creation_sql
CREATE TABLE {$wpdb->prefix}wc_product_download_directories (
url_id BIGINT UNSIGNED NOT NULL auto_increment,
url varchar(256) NOT NULL,
enabled TINYINT(1) NOT NULL DEFAULT 0,
PRIMARY KEY (url_id),
KEY `url` (`url`)
) $collate;
";
return $tables;
@ -1100,6 +1113,7 @@ $product_attributes_lookup_table_creation_sql
$tables = array(
"{$wpdb->prefix}wc_download_log",
"{$wpdb->prefix}wc_product_download_directories",
"{$wpdb->prefix}wc_product_meta_lookup",
"{$wpdb->prefix}wc_tax_rate_classes",
"{$wpdb->prefix}wc_webhooks",

View File

@ -8,6 +8,8 @@
*/
use Automattic\Jetpack\Constants;
use Automattic\WooCommerce\Internal\ProductDownloads\ApprovedDirectories\Register as Download_Directories;
use Automattic\WooCommerce\Internal\Utilities\URL;
defined( 'ABSPATH' ) || exit;
@ -91,6 +93,44 @@ class WC_Product_Download implements ArrayAccess {
return pathinfo( $parsed_url, PATHINFO_EXTENSION );
}
/**
* Confirms that the download is of an allowed filetype, that it exists and that it is
* contained within an approved directory. Used before adding to a product's list of
* downloads.
*
* @internal
* @throws Exception If the download is determined to be invalid.
*
* @param bool $auto_add_to_approved_directory_list If the download is not already in the approved directory list, automatically add it if possible.
*/
public function check_is_valid( bool $auto_add_to_approved_directory_list = true ) {
$download_file = $this->get_file();
if ( ! $this->is_allowed_filetype() ) {
throw new Exception(
sprintf(
/* translators: %1$s: Downloadable file */
__( 'The downloadable file %1$s cannot be used as it does not have an allowed file type. Allowed types include: %2$s', 'woocommerce' ),
'<code>' . basename( $download_file ) . '</code>',
'<code>' . implode( ', ', array_keys( $this->get_allowed_mime_types() ) ) . '</code>'
)
);
}
// Validate the file exists.
if ( ! $this->file_exists() ) {
throw new Exception(
sprintf(
/* translators: %s: Downloadable file */
__( 'The downloadable file %s cannot be used as it does not exist on the server.', 'woocommerce' ),
'<code>' . $download_file . '</code>'
)
);
}
$this->approved_directory_checks( $auto_add_to_approved_directory_list );
}
/**
* Check if file is allowed.
*
@ -143,6 +183,59 @@ class WC_Product_Download implements ArrayAccess {
return apply_filters( 'woocommerce_downloadable_file_exists', file_exists( $file_url ), $this->get_file() );
}
/**
* Confirms that the download exists within an approved directory.
*
* If it is not within an approved directory but the current user has sufficient
* capabilities, then the method will try to add the download's directory to the
* approved directory list.
*
* @throws Exception If the download is not in an approved directory.
*
* @param bool $auto_add_to_approved_directory_list If the download is not already in the approved directory list, automatically add it if possible.
*/
private function approved_directory_checks( bool $auto_add_to_approved_directory_list = true ) {
$download_directories = wc_get_container()->get( Download_Directories::class );
if ( $download_directories->get_mode() !== Download_Directories::MODE_ENABLED ) {
return;
}
$download_file = $this->get_file();
$is_site_administrator = is_multisite() ? current_user_can( 'manage_sites' ) : current_user_can( 'manage_options' );
$valid_storage_directory = $download_directories->is_valid_path( $download_file );
if ( ! $valid_storage_directory && $auto_add_to_approved_directory_list ) {
try {
// Add the parent URL to the approved directories list, but *do not enable it* unless the current user is a site admin.
$download_directories->add_approved_directory( ( new URL( $download_file ) )->get_parent_url(), $is_site_administrator );
} catch ( Exception $e ) {
/* translators: %s: Downloadable file */
throw new Exception(
sprintf(
/* translators: %1$s is the downloadable file path, %2$s is an opening link tag, %3%s is a closing link tag. */
__( 'The downloadable file %1$s cannot be used: it is not located in an approved directory. Please contact a site administrator and request their approval. %2$sLearn more.%3$s', 'woocommerce' ),
'<code>' . $download_file . '</code>',
'<a href="https://woocommerce.com/documentation/approved-download-directories">', // @todo update to working link (see https://github.com/Automattic/woocommerce/issues/181)
'</a>'
)
);
}
}
if ( ! $valid_storage_directory && ! $is_site_administrator ) {
throw new Exception(
sprintf(
/* translators: %1$s is the downloadable file path, %2$s is an opening link tag, %3%s is a closing link tag. */
__( 'The downloadable file %1$s cannot be used: it is not located in an approved directory. Please contact a site administrator for help. %2$sLearn more.%3$s', 'woocommerce' ),
'<code>' . $download_file . '</code>',
'<a href="https://woocommerce.com/documentation/approved-download-directories">', // @todo update to working link (see https://github.com/Automattic/woocommerce/issues/181)
'</a>'
)
);
}
}
/*
|--------------------------------------------------------------------------
| Setters

View File

@ -13,6 +13,7 @@ use Automattic\WooCommerce\Internal\DataStores\Orders\CustomOrdersTableControlle
use Automattic\WooCommerce\Internal\DownloadPermissionsAdjuster;
use Automattic\WooCommerce\Internal\ProductAttributesLookup\DataRegenerator;
use Automattic\WooCommerce\Internal\ProductAttributesLookup\LookupDataStore;
use Automattic\WooCommerce\Internal\ProductDownloads\ApprovedDirectories\Register as ProductDownloadDirectories;
use Automattic\WooCommerce\Internal\RestockRefundedItemsAdjuster;
use Automattic\WooCommerce\Proxies\LegacyProxy;
@ -212,6 +213,7 @@ final class WooCommerce {
add_action( 'woocommerce_updated', array( $this, 'add_woocommerce_inbox_variant' ) );
// These classes set up hooks on instantiation.
wc_get_container()->get( ProductDownloadDirectories::class );
wc_get_container()->get( DownloadPermissionsAdjuster::class );
wc_get_container()->get( AssignDefaultCategory::class );
wc_get_container()->get( DataRegenerator::class );

View File

@ -21,6 +21,8 @@ defined( 'ABSPATH' ) || exit;
use Automattic\WooCommerce\Internal\AssignDefaultCategory;
use Automattic\WooCommerce\Internal\ProductAttributesLookup\DataRegenerator;
use Automattic\WooCommerce\Internal\ProductAttributesLookup\LookupDataStore;
use Automattic\WooCommerce\Internal\ProductDownloads\ApprovedDirectories\Register as Download_Directories;
use Automattic\WooCommerce\Internal\ProductDownloads\ApprovedDirectories\Synchronize as Download_Directories_Sync;
/**
* Update file paths for 2.0
@ -2373,6 +2375,15 @@ function wc_update_630_db_version() {
WC_Install::update_db_version( '6.3.0' );
}
/**
* Add the standard WooCommerce upload directories to the Approved Product Download Directories list
* and start populating it based on existing product download URLs, but do not enable the feature
* (for existing installations, a site admin should review and make a conscious decision to enable).
*/
function wc_update_640_approved_download_directories() {
wc_get_container()->get( Download_Directories_Sync::class )->init_feature( true, false );
}
/**
* Create the primary key for the product attributes lookup table if it doesn't exist already.
*

View File

@ -286,3 +286,17 @@
text-decoration: none !important;
}
}
@mixin table-marks() {
mark {
background: transparent none;
}
mark.yes {
color: $green;
}
mark.no {
color: #999;
}
}

View File

@ -1086,17 +1086,7 @@ table.wc_status_table {
margin-bottom: 0.5em;
}
mark {
background: transparent none;
}
mark.yes {
color: $green;
}
mark.no {
color: #999;
}
@include table-marks();
mark.error,
.red {
@ -1113,6 +1103,13 @@ table.wc_status_table {
}
}
table.wp-list-table.urls {
td,
th {
@include table-marks();
}
}
table.wc_status_table--tools {
td,
@ -4293,7 +4290,7 @@ img.help_tip {
margin: 1.5em 0 1em;
}
.subsubsub {
.subsubsub:not(.list-table-filters) {
margin: -8px 0 0;
}

View File

@ -208,7 +208,13 @@
'keepAlive': true
} ).css( 'cursor', 'help' );
});
});
})
.on( 'click', '.wc-confirm-delete', function( event ) {
if ( ! window.confirm( woocommerce_admin.i18n_confirm_delete ) ) {
event.stopPropagation();
}
} );
// Tooltips
$( document.body ).trigger( 'init_tooltips' );

View File

@ -10,6 +10,7 @@ use Automattic\WooCommerce\Internal\DependencyManagement\ServiceProviders\Downlo
use Automattic\WooCommerce\Internal\DependencyManagement\ServiceProviders\AssignDefaultCategoryServiceProvider;
use Automattic\WooCommerce\Internal\DependencyManagement\ServiceProviders\OrdersDataStoreServiceProvider;
use Automattic\WooCommerce\Internal\DependencyManagement\ServiceProviders\ProductAttributesLookupServiceProvider;
use Automattic\WooCommerce\Internal\DependencyManagement\ServiceProviders\ProductDownloadsServiceProvider;
use Automattic\WooCommerce\Internal\DependencyManagement\ServiceProviders\ProxiesServiceProvider;
use Automattic\WooCommerce\Internal\DependencyManagement\ServiceProviders\RestockRefundedItemsAdjusterServiceProvider;
use Automattic\WooCommerce\Internal\DependencyManagement\ServiceProviders\UtilsClassesServiceProvider;
@ -42,6 +43,7 @@ final class Container implements \Psr\Container\ContainerInterface {
DownloadPermissionsAdjusterServiceProvider::class,
OrdersDataStoreServiceProvider::class,
ProductAttributesLookupServiceProvider::class,
ProductDownloadsServiceProvider::class,
ProxiesServiceProvider::class,
RestockRefundedItemsAdjusterServiceProvider::class,
UtilsClassesServiceProvider::class,

View File

@ -0,0 +1,36 @@
<?php
namespace Automattic\WooCommerce\Internal\DependencyManagement\ServiceProviders;
use Automattic\WooCommerce\Internal\DependencyManagement\AbstractServiceProvider;
use Automattic\WooCommerce\Internal\ProductDownloads\ApprovedDirectories\Admin\SyncUI;
use Automattic\WooCommerce\Internal\ProductDownloads\ApprovedDirectories\Admin\UI;
use Automattic\WooCommerce\Internal\ProductDownloads\ApprovedDirectories\Register;
use Automattic\WooCommerce\Internal\ProductDownloads\ApprovedDirectories\Synchronize;
/**
* Service provider for the Product Downloads-related services.
*/
class ProductDownloadsServiceProvider extends AbstractServiceProvider {
/**
* The classes/interfaces that are serviced by this service provider.
*
* @var array
*/
protected $provides = array(
Register::class,
Sync::class,
SyncUI::class,
UI::class,
);
/**
* Register the classes.
*/
public function register() {
$this->share( Register::class );
$this->share( Synchronize::class )->addArgument( Register::class );
$this->share( SyncUI::class )->addArgument( Register::class );
$this->share( UI::class )->addArgument( Register::class );
}
}

View File

@ -0,0 +1,122 @@
<?php
namespace Automattic\WooCommerce\Internal\ProductDownloads\ApprovedDirectories\Admin;
use Automattic\WooCommerce\Internal\ProductDownloads\ApprovedDirectories\Register;
use Automattic\WooCommerce\Internal\ProductDownloads\ApprovedDirectories\Synchronize;
use Automattic\WooCommerce\Internal\Utilities\Users;
/**
* Adds tools to the Status > Tools page that can be used to (re-)initiate or stop a synchronization process
* for Approved Download Directories.
*/
class SyncUI {
/**
* The active register of approved directories.
*
* @var Register
*/
private $register;
/**
* Sets up UI controls for product download URLs.
*
* @internal
*
* @param Register $register Register of approved directories.
*/
final public function init( Register $register ) {
$this->register = $register;
}
/**
* Performs any work needed to add hooks and otherwise integrate with the wider system,
* except in the case where the current user is not a site administrator, no hooks will
* be initialized.
*/
final public function init_hooks() {
if ( ! Users::is_site_administrator() ) {
return;
}
add_filter( 'woocommerce_debug_tools', array( $this, 'add_tools' ) );
}
/**
* Adds Approved Directory list-related entries to the tools page.
*
* @param array $tools Admin tool definitions.
*
* @return array
*/
public function add_tools( array $tools ): array {
$sync = wc_get_container()->get( Synchronize::class );
if ( ! $sync->in_progress() ) {
// Provide tools to trigger a fresh scan (migration) and to clear the Approved Directories list.
$tools['approved_directories_sync'] = array(
'name' => __( 'Synchronize approved download directories', 'woocommerce' ),
'desc' => __( 'Updates the list of Approved Product Download Directories. Note that triggering this tool does not impact whether the Approved Download Directories list is enabled or not.', 'woocommerce' ),
'button' => __( 'Update', 'woocommerce' ),
'callback' => array( $this, 'trigger_sync' ),
'requires_refresh' => true,
);
$tools['approved_directories_clear'] = array(
'name' => __( 'Empty the approved download directories list', 'woocommerce' ),
'desc' => __( 'Removes all existing entries from the Approved Product Download Directories list.', 'woocommerce' ),
'button' => __( 'Clear', 'woocommerce' ),
'callback' => array( $this, 'clear_existing_entries' ),
'requires_refresh' => true,
);
} else {
// Or if a scan (migration) is already in progress, offer a means of cancelling it.
$tools['cancel_directories_scan'] = array(
'name' => __( 'Cancel synchronization of approved directories', 'woocommerce' ),
'desc' => sprintf(
/* translators: %d is an integer between 0-100 representing the percentage complete of the current scan. */
__( 'The Approved Product Download Directories list is currently being synchronized with the product catalog (%d%% complete). If you need to, you can cancel it.', 'woocommerce' ),
$sync->get_progress()
),
'button' => __( 'Cancel', 'woocommerce' ),
'callback' => array( $this, 'cancel_sync' ),
);
}
return $tools;
}
/**
* Triggers a new migration.
*/
public function trigger_sync() {
$this->security_check();
wc_get_container()->get( Synchronize::class )->start();
}
/**
* Clears all existing rules from the Approved Directories list.
*/
public function clear_existing_entries() {
$this->security_check();
$this->register->delete_all();
}
/**
* If a migration is in progress, this will attempt to cancel it.
*/
public function cancel_sync() {
$this->security_check();
wc_get_logger()->log( 'info', __( 'Approved Download Directories sync: scan has been cancelled.', 'woocommerce' ) );
wc_get_container()->get( Synchronize::class )->stop();
}
/**
* Makes sure the user has appropriate permissions and that we have a valid nonce.
*/
private function security_check() {
if ( ! Users::is_site_administrator() ) {
wp_die( esc_html__( 'You do not have permission to modify the list of approved directories for product downloads.', 'woocommerce' ) );
}
}
}

View File

@ -0,0 +1,330 @@
<?php
namespace Automattic\WooCommerce\Internal\ProductDownloads\ApprovedDirectories\Admin;
use Automattic\WooCommerce\Internal\ProductDownloads\ApprovedDirectories\Register;
use Automattic\WooCommerce\Internal\ProductDownloads\ApprovedDirectories\StoredUrl;
use WP_List_Table;
use WP_Screen;
/**
* Admin list table used to render our current list of approved directories.
*/
class Table extends WP_List_Table {
/**
* Initialize the webhook table list.
*/
public function __construct() {
parent::__construct(
array(
'singular' => 'url',
'plural' => 'urls',
'ajax' => false,
)
);
add_filter( 'manage_woocommerce_page_wc-settings_columns', array( $this, 'get_columns' ) );
$this->items_per_page();
set_screen_options();
}
/**
* Sets up an items-per-page control.
*/
private function items_per_page() {
add_screen_option(
'per_page',
array(
'default' => 20,
'option' => 'edit_approved_directories_per_page',
)
);
add_filter( 'set_screen_option_edit_approved_directories_per_page', array( $this, 'set_items_per_page' ), 10, 3 );
}
/**
* Saves the items-per-page setting.
*
* @param mixed $default The default value.
* @param string $option The option being configured.
* @param int $value The submitted option value.
*
* @return mixed
*/
public function set_items_per_page( $default, string $option, int $value ) {
return 'edit_approved_directories_per_page' === $option ? absint( $value ) : $default;
}
/**
* No items found text.
*/
public function no_items() {
esc_html_e( 'No approved directory URLs found.', 'woocommerce' );
}
/**
* Displays the list of views available on this table.
*/
public function render_views() {
$register = wc_get_container()->get( Register::class );
$enabled_count = $register->count( true );
$disabled_count = $register->count( false );
$all_count = $enabled_count + $disabled_count;
// phpcs:ignore WordPress.Security.NonceVerification.Recommended
$selected_view = isset( $_REQUEST['view'] ) ? sanitize_text_field( wp_unslash( $_REQUEST['view'] ) ) : 'all';
$all_url = esc_url( add_query_arg( 'view', 'all', $this->get_base_url() ) );
$all_class = 'all' === $selected_view ? 'class="current"' : '';
$all_text = sprintf(
/* translators: %s is the count of approved directory list entries. */
_nx(
'All <span class="count">(%s)</span>',
'All <span class="count">(%s)</span>',
$all_count,
'Approved product download directory views',
'woocommerce'
),
$all_count
);
$enabled_url = esc_url( add_query_arg( 'view', 'enabled', $this->get_base_url() ) );
$enabled_class = 'enabled' === $selected_view ? 'class="current"' : '';
$enabled_text = sprintf(
/* translators: %s is the count of enabled approved directory list entries. */
_nx(
'Enabled <span class="count">(%s)</span>',
'Enabled <span class="count">(%s)</span>',
$enabled_count,
'Approved product download directory views',
'woocommerce'
),
$enabled_count
);
$disabled_url = esc_url( add_query_arg( 'view', 'disabled', $this->get_base_url() ) );
$disabled_class = 'disabled' === $selected_view ? 'class="current"' : '';
$disabled_text = sprintf(
/* translators: %s is the count of disabled directory list entries. */
_nx(
'Disabled <span class="count">(%s)</span>',
'Disabled <span class="count">(%s)</span>',
$disabled_count,
'Approved product download directory views',
'woocommerce'
),
$disabled_count
);
$views = array(
'all' => "<a href='{$all_url}' {$all_class}>{$all_text}</a>",
'enabled' => "<a href='{$enabled_url}' {$enabled_class}>{$enabled_text}</a>",
'disabled' => "<a href='{$disabled_url}' {$disabled_class}>{$disabled_text}</a>",
);
$this->screen->render_screen_reader_content( 'heading_views' );
echo '<ul class="subsubsub list-table-filters">';
foreach ( $views as $slug => $view ) {
$views[ $slug ] = "<li class='{$slug}'>{$view}";
}
// phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
echo implode( ' | </li>', $views ) . "</li>\n";
echo '</ul>';
}
/**
* Get list columns.
*
* @return array
*/
public function get_columns() {
return array(
'cb' => '<input type="checkbox" />',
'title' => _x( 'URL', 'Approved product download directories', 'woocommerce' ),
'enabled' => _x( 'Enabled', 'Approved product download directories', 'woocommerce' ),
);
}
/**
* Checklist column, used for selecting items for processing by a bulk action.
*
* @param StoredUrl $item The approved directory information for the current row.
*
* @return string
*/
public function column_cb( $item ) {
return sprintf( '<input type="checkbox" name="%1$s[]" value="%2$s" />', esc_attr( $this->_args['singular'] ), esc_attr( $item->get_id() ) );
}
/**
* URL column.
*
* @param StoredUrl $item The approved directory information for the current row.
*
* @return string
*/
public function column_title( $item ) {
$id = (int) $item->get_id();
$url = esc_html( $item->get_url() );
$enabled = $item->is_enabled();
$edit_url = esc_url( $this->get_action_url( 'edit', $id ) );
$enable_disable_url = esc_url( $enabled ? $this->get_action_url( 'disable', $id ) : $this->get_action_url( 'enable', $id ) );
$enable_disable_text = esc_html( $enabled ? __( 'Disable', 'woocommerce' ) : __( 'Enable', 'woocommerce' ) );
$delete_url = esc_url( $this->get_action_url( 'delete', $id ) );
$edit_link = "<a href='{$edit_url}'>" . esc_html_x( 'Edit', 'Product downloads list', 'woocommerce' ) . '</a>';
$enable_disable_link = "<a href='{$enable_disable_url}'>{$enable_disable_text}</a>";
$delete_link = "<a href='{$delete_url}' class='submitdelete wc-confirm-delete'>" . esc_html_x( 'Delete permanently', 'Product downloads list', 'woocommerce' ) . '</a>';
$url_link = "<a href='{$edit_url}'>{$url}</a>";
return "
<strong>{$url_link}</strong>
<div class='row-actions'>
<span class='id'>ID: {$id}</span> |
<span class='edit'>{$edit_link}</span> |
<span class='enable-disable'>{$enable_disable_link}</span> |
<span class='delete'><a class='submitdelete'>{$delete_link}</a></span>
</div>
";
}
/**
* Rule-is-enabled column.
*
* @param StoredUrl $item The approved directory information for the current row.
*
* @return string
*/
public function column_enabled( StoredUrl $item ): string {
return $item->is_enabled()
? '<mark class="yes" title="' . esc_html__( 'Enabled', 'woocommerce' ) . '"><span class="dashicons dashicons-yes"></span></mark>'
: '<mark class="no" title="' . esc_html__( 'Disabled', 'woocommerce' ) . '">&ndash;</mark>';
}
/**
* Get bulk actions.
*
* @return array
*/
protected function get_bulk_actions() {
return array(
'enable' => __( 'Enable rule', 'woocommerce' ),
'disable' => __( 'Disable rule', 'woocommerce' ),
'delete' => __( 'Delete permanently', 'woocommerce' ),
);
}
/**
* Builds an action URL (ie, to edit or delete a row).
*
* @param string $action The action to be created.
* @param int $id The ID that is the subject of the action.
* @param string $nonce_action Action used to add a nonce to the URL.
*
* @return string
*/
public function get_action_url( string $action, int $id, string $nonce_action = 'modify_approved_directories' ): string {
return add_query_arg(
array(
'check' => wp_create_nonce( $nonce_action ),
'action' => $action,
'url' => $id,
),
$this->get_base_url()
);
}
/**
* Supplies the 'base' admin URL for this admin table.
*
* @return string
*/
public function get_base_url(): string {
return add_query_arg(
array(
'page' => 'wc-settings',
'tab' => 'products',
'section' => 'download_urls',
),
admin_url( 'admin.php' )
);
}
/**
* Generate the table navigation above or below the table.
* Included to remove extra nonce input.
*
* @param string $which The location of the extra table nav markup: 'top' or 'bottom'.
*/
protected function display_tablenav( $which ) {
$directories = wc_get_container()->get( Register::class );
echo '<div class="tablenav ' . esc_attr( $which ) . '">';
if ( $this->has_items() ) {
echo '<div class="alignleft actions bulkactions">';
$this->bulk_actions( $which );
if ( $directories->count( false ) > 0 ) {
echo '<a href="' . esc_url( $this->get_action_url( 'enable-all', 0 ) ) . '" class="wp-core-ui button">' . esc_html_x( 'Enable All', 'Approved product download directories', 'woocommerce' ) . '</a> ';
}
if ( $directories->count( true ) > 0 ) {
echo '<a href="' . esc_url( $this->get_action_url( 'disable-all', 0 ) ) . '" class="wp-core-ui button">' . esc_html_x( 'Disable All', 'Approved product download directories', 'woocommerce' ) . '</a>';
}
echo '</div>';
}
$this->pagination( $which );
echo '<br class="clear" />';
echo '</div>';
}
/**
* Prepare table list items.
*/
public function prepare_items() {
// phpcs:disable WordPress.Security.NonceVerification.Recommended
// phpcs:disable WordPress.Security.NonceVerification.Missing
$current_page = $this->get_pagenum();
$per_page = $this->get_items_per_page( 'edit_approved_directories_per_page' );
$search = sanitize_text_field( wp_unslash( $_REQUEST['s'] ?? '' ) );
switch ( $_REQUEST['view'] ?? '' ) {
case 'enabled':
$enabled = true;
break;
case 'disabled':
$enabled = false;
break;
default:
$enabled = null;
break;
}
// phpcs:enable
$approved_directories = wc_get_container()->get( Register::class )->list(
array(
'page' => $current_page,
'per_page' => $per_page,
'search' => $search,
'enabled' => $enabled,
)
);
$this->items = $approved_directories['approved_directories'];
// Set the pagination.
$this->set_pagination_args(
array(
'total_items' => $approved_directories['total_urls'],
'total_pages' => $approved_directories['total_pages'],
'per_page' => $per_page,
)
);
}
}

View File

@ -0,0 +1,462 @@
<?php
namespace Automattic\WooCommerce\Internal\ProductDownloads\ApprovedDirectories\Admin;
use Automattic\WooCommerce\Internal\ProductDownloads\ApprovedDirectories\Register;
use Automattic\WooCommerce\Internal\Utilities\Users;
use Exception;
use WC_Admin_Settings;
/**
* Manages user interactions for product download URL safety.
*/
class UI {
/**
* The active register of approved directories.
*
* @var Register
*/
private $register;
/**
* The WP_List_Table instance used to display approved directories.
*
* @var Table
*/
private $table;
/**
* Sets up UI controls for product download URLs.
*
* @internal
*
* @param Register $register Register of approved directories.
*/
final public function init( Register $register ) {
$this->register = $register;
}
/**
* Performs any work needed to add hooks and otherwise integrate with the wider system,
* except in the case where the current user is not a site administrator, no hooks will
* be initialized.
*/
final public function init_hooks() {
if ( ! Users::is_site_administrator() ) {
return;
}
add_filter( 'woocommerce_get_sections_products', array( $this, 'add_section' ) );
add_action( 'load-woocommerce_page_wc-settings', array( $this, 'setup' ) );
add_action( 'woocommerce_settings_products', array( $this, 'render' ) );
}
/**
* Injects our new settings section (when approved directory rules are disabled, it will not show).
*
* @param array $sections Other admin settings sections.
*
* @return array
*/
public function add_section( array $sections ): array {
$sections['download_urls'] = __( 'Approved download directories', 'woocommerce' );
return $sections;
}
/**
* Sets up the table, renders any notices and processes actions as needed.
*/
public function setup() {
if ( ! $this->is_download_urls_screen() ) {
return;
}
$this->table = new Table();
$this->admin_notices();
$this->handle_search();
$this->process_actions();
}
/**
* Renders the UI.
*/
public function render() {
if ( null === $this->table || ! $this->is_download_urls_screen() ) {
return;
}
// phpcs:disable WordPress.Security.NonceVerification.Recommended
if ( isset( $_REQUEST['action'] ) && 'edit' === $_REQUEST['action'] && isset( $_REQUEST['url'] ) ) {
$this->edit_screen( (int) $_REQUEST['url'] );
return;
}
// phpcs:enable
// Show list table.
$this->table->prepare_items();
wp_nonce_field( 'modify_approved_directories', 'check' );
$this->display_title();
$this->table->render_views();
$this->table->search_box( _x( 'Search', 'Approved Directory URLs', 'woocommerce' ), 'download_url_search' );
$this->table->display();
}
/**
* Indicates if we are currently on the download URLs admin screen.
*
* @return bool
*/
private function is_download_urls_screen(): bool {
// phpcs:disable WordPress.Security.NonceVerification.Recommended
return isset( $_GET['tab'] )
&& 'products' === $_GET['tab']
&& isset( $_GET['section'] )
&& 'download_urls' === $_GET['section'];
// phpcs:enable
}
/**
* Process bulk and single-row actions.
*/
private function process_actions() {
// phpcs:disable WordPress.Security.NonceVerification.Recommended
$ids = isset( $_REQUEST['url'] ) ? array_map( 'absint', (array) $_REQUEST['url'] ) : array();
if ( empty( $ids ) || empty( $_REQUEST['action'] ) ) {
return;
}
$this->security_check();
$action = sanitize_text_field( wp_unslash( $_REQUEST['action'] ) );
switch ( $action ) {
case 'edit':
$this->process_edits( current( $ids ) );
break;
case 'delete':
case 'enable':
case 'disable':
$this->process_bulk_actions( $ids, $action );
break;
case 'enable-all':
case 'disable-all':
$this->process_all_actions( $action );
break;
case 'turn-on':
case 'turn-off':
$this->process_on_off( $action );
break;
}
// phpcs:enable
}
/**
* Support pagination across search results.
*
* In the context of the WC settings screen, form data is submitted by the post method: that poses
* a problem for the default WP_List_Table pagination logic which expects the search value to live
* as part of the URL query. This method is a simple shim to bridge the resulting gap.
*/
private function handle_search() {
// phpcs:disable WordPress.Security.NonceVerification.Missing
// phpcs:disable WordPress.Security.NonceVerification.Recommended
// If a search value has not been POSTed, or if it was POSTed but is already equal to the
// same value in the URL query, we need take no further action.
if ( empty( $_POST['s'] ) || sanitize_text_field( wp_unslash( $_GET['s'] ?? '' ) ) === $_POST['s'] ) {
return;
}
wp_safe_redirect(
add_query_arg(
array(
'paged' => absint( $_GET['paged'] ?? 1 ),
's' => sanitize_text_field( wp_unslash( $_POST['s'] ) ),
),
$this->table->get_base_url()
)
);
// phpcs:enable
exit;
}
/**
* Handles updating or adding a new URL to the list of approved directories.
*
* @param int $url_id The ID of the rule to be edited/created. Zero if we are creating a new entry.
*/
private function process_edits( int $url_id ) {
// phpcs:disable WordPress.Security.NonceVerification.Missing
$url = esc_url_raw( wp_unslash( $_POST['approved_directory_url'] ?? '' ) );
$enabled = (bool) sanitize_text_field( wp_unslash( $_POST['approved_directory_enabled'] ?? '' ) );
if ( empty( $url ) ) {
return;
}
$redirect_url = add_query_arg( 'id', $url_id, $this->table->get_action_url( 'edit', $url_id ) );
try {
$upserted = 0 === $url_id
? $this->register->add_approved_directory( $url, $enabled )
: $this->register->update_approved_directory( $url_id, $url, $enabled );
if ( is_integer( $upserted ) ) {
$redirect_url = add_query_arg( 'url', $upserted, $redirect_url );
}
$redirect_url = add_query_arg( 'edit-status', 0 === $url_id ? 'added' : 'updated', $redirect_url );
} catch ( Exception $e ) {
$redirect_url = add_query_arg(
array(
'edit-status' => 'failure',
'submitted-url' => $url,
),
$redirect_url
);
}
wp_safe_redirect( $redirect_url );
exit;
// phpcs:enable WordPress.Security.NonceVerification.Missing
}
/**
* Processes actions that can be applied in bulk (requests to delete, enable
* or disable).
*
* @param int[] $ids The ID(s) to be updates.
* @param string $action The action to be applied.
*/
private function process_bulk_actions( array $ids, string $action ) {
$deletes = 0;
$enabled = 0;
$disabled = 0;
$register = wc_get_container()->get( Register::class );
foreach ( $ids as $id ) {
if ( 'delete' === $action && $register->delete_by_id( $id ) ) {
$deletes++;
} elseif ( 'enable' === $action && $register->enable_by_id( $id ) ) {
$enabled++;
} elseif ( 'disable' === $action && $register->disable_by_id( $id ) ) {
$disabled ++;
}
}
$fails = count( $ids ) - $deletes - $enabled - $disabled;
$redirect = $this->table->get_base_url();
if ( $deletes ) {
$redirect = add_query_arg( 'deleted-ids', $deletes, $redirect );
} elseif ( $enabled ) {
$redirect = add_query_arg( 'enabled-ids', $enabled, $redirect );
} elseif ( $disabled ) {
$redirect = add_query_arg( 'disabled-ids', $disabled, $redirect );
}
if ( $fails ) {
$redirect = add_query_arg( 'bulk-fails', $fails, $redirect );
}
wp_safe_redirect( $redirect );
exit;
}
/**
* Handles the enable/disable-all actions.
*
* @param string $action The action to be applied.
*/
private function process_all_actions( string $action ) {
$register = wc_get_container()->get( Register::class );
$redirect = $this->table->get_base_url();
switch ( $action ) {
case 'enable-all':
$redirect = add_query_arg( 'enabled-all', (int) $register->enable_all(), $redirect );
break;
case 'disable-all':
$redirect = add_query_arg( 'disabled-all', (int) $register->disable_all(), $redirect );
break;
}
wp_safe_redirect( $redirect );
exit;
}
/**
* Handles turning on/off the entire approved download directory system (vs enabling
* and disabling of individual rules).
*
* @param string $action Whether the feature should be turned on or off.
*/
private function process_on_off( string $action ) {
switch ( $action ) {
case 'turn-on':
$this->register->set_mode( Register::MODE_ENABLED );
break;
case 'turn-off':
$this->register->set_mode( Register::MODE_DISABLED );
break;
}
}
/**
* Displays the screen title, etc.
*/
private function display_title() {
$turn_on_off = $this->register->get_mode() === Register::MODE_ENABLED
? '<a href="' . esc_url( $this->table->get_action_url( 'turn-off', 0 ) ) . '" class="page-title-action">' . esc_html_x( 'Stop Enforcing Rules', 'Approved product download directories', 'woocommerce' ) . '</a>'
: '<a href="' . esc_url( $this->table->get_action_url( 'turn-on', 0 ) ) . '" class="page-title-action">' . esc_html_x( 'Start Enforcing Rules', 'Approved product download directories', 'woocommerce' ) . '</a>';
?>
<h2 class='wc-table-list-header'>
<?php esc_html_e( 'Approved Download Directories', 'woocommerce' ); ?>
<a href='<?php echo esc_url( $this->table->get_action_url( 'edit', 0 ) ); ?>' class='page-title-action'><?php esc_html_e( 'Add New', 'woocommerce' ); ?></a>
<?php echo $turn_on_off; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?>
</h2>
<?php
}
/**
* Renders the editor screen for approved directory URLs.
*
* @param int $url_id The ID of the rule to be edited (may be zero for new rules).
*/
private function edit_screen( int $url_id ) {
$this->security_check();
$existing = $this->register->get_by_id( $url_id );
if ( 0 !== $url_id && ! $existing ) {
WC_Admin_Settings::add_error( _x( 'The provided ID was invalid.', 'Approved product download directories', 'woocommerce' ) );
WC_Admin_Settings::show_messages();
return;
}
$title = $existing
? __( 'Edit Approved Directory', 'woocommerce' )
: __( 'Add New Approved Directory', 'woocommerce' );
// phpcs:disable WordPress.Security.NonceVerification.Recommended
$submitted = sanitize_text_field( wp_unslash( $_GET['submitted-url'] ?? '' ) );
$existing_url = $existing ? $existing->get_url() : '';
$enabled = $existing ? $existing->is_enabled() : true;
// phpcs:enable
?>
<h2 class='wc-table-list-header'>
<?php echo esc_html( $title ); ?>
<?php if ( $existing ) : ?>
<a href="<?php echo esc_url( $this->table->get_action_url( 'edit', 0 ) ); ?>" class="page-title-action"><?php esc_html_e( 'Add New', 'woocommerce' ); ?></a>
<?php endif; ?>
<a href="<?php echo esc_url( $this->table->get_base_url() ); ?> " class="page-title-action"><?php esc_html_e( 'Cancel', 'woocommerce' ); ?></a>
</h2>
<table class='form-table'>
<tbody>
<tr valign='top'>
<th scope='row' class='titledesc'>
<label for='approved_directory_url'> <?php echo esc_html_x( 'Directory URL', 'Approved product download directories', 'woocommerce' ); ?> </label>
</th>
<td class='forminp'>
<input name='approved_directory_url' id='approved_directory_url' type='text' class='input-text regular-input' value='<?php echo esc_attr( empty( $submitted ) ? $existing_url : $submitted ); ?>'>
</td>
</tr>
<tr valign='top'>
<th scope='row' class='titledesc'>
<label for='approved_directory_enabled'> <?php echo esc_html_x( 'Enabled', 'Approved product download directories', 'woocommerce' ); ?> </label>
</th>
<td class='forminp'>
<input name='approved_directory_enabled' id='approved_directory_enabled' type='checkbox' value='1' <?php checked( true, $enabled ); ?>'>
</td>
</tr>
</tbody>
</table>
<input name='id' id='approved_directory_id' type='hidden' value='{$url_id}'>
<?php
}
/**
* Displays any admin notices that might be needed.
*/
private function admin_notices() {
// phpcs:disable WordPress.Security.NonceVerification.Recommended
$successfully_deleted = isset( $_GET['deleted-ids'] ) ? (int) $_GET['deleted-ids'] : 0;
$successfully_enabled = isset( $_GET['enabled-ids'] ) ? (int) $_GET['enabled-ids'] : 0;
$successfully_disabled = isset( $_GET['disabled-ids'] ) ? (int) $_GET['disabled-ids'] : 0;
$failed_updates = isset( $_GET['bulk-fails'] ) ? (int) $_GET['bulk-fails'] : 0;
$edit_status = sanitize_text_field( wp_unslash( $_GET['edit-status'] ?? '' ) );
$edit_url = esc_attr( sanitize_text_field( wp_unslash( $_GET['submitted-url'] ?? '' ) ) );
// phpcs:enable
if ( $successfully_deleted ) {
WC_Admin_Settings::add_message(
sprintf(
/* translators: %d: count */
_n( '%d approved directory URL deleted.', '%d approved directory URLs deleted.', $successfully_deleted, 'woocommerce' ),
$successfully_deleted
)
);
} elseif ( $successfully_enabled ) {
WC_Admin_Settings::add_message(
sprintf(
/* translators: %d: count */
_n( '%d approved directory URL enabled.', '%d approved directory URLs enabled.', $successfully_enabled, 'woocommerce' ),
$successfully_enabled
)
);
} elseif ( $successfully_disabled ) {
WC_Admin_Settings::add_message(
sprintf(
/* translators: %d: count */
_n( '%d approved directory URL disabled.', '%d approved directory URLs disabled.', $successfully_disabled, 'woocommerce' ),
$successfully_disabled
)
);
}
if ( $failed_updates ) {
WC_Admin_Settings::add_error(
sprintf(
/* translators: %d: count */
_n( '%d URL could not be updated.', '%d URLs could not be updated.', $failed_updates, 'woocommerce' ),
$failed_updates
)
);
}
if ( 'added' === $edit_status ) {
WC_Admin_Settings::add_message( __( 'URL was successfully added.', 'woocommerce' ) );
}
if ( 'updated' === $edit_status ) {
WC_Admin_Settings::add_message( __( 'URL was successfully updated.', 'woocommerce' ) );
}
if ( 'failure' === $edit_status && ! empty( $edit_url ) ) {
WC_Admin_Settings::add_error(
sprintf(
/* translators: %s is the submitted URL. */
__( '"%s" could not be saved. Please review, ensure it is a valid URL and try again.', 'woocommerce' ),
$edit_url
)
);
}
}
/**
* Makes sure the user has appropriate permissions and that we have a valid nonce.
*/
private function security_check() {
if ( ! Users::is_site_administrator() || ! wp_verify_nonce( sanitize_text_field( wp_unslash( $_REQUEST['check'] ?? '' ) ), 'modify_approved_directories' ) ) {
wp_die( esc_html__( 'You do not have permission to modify the list of approved directories for product downloads.', 'woocommerce' ) );
}
}
}

View File

@ -0,0 +1,14 @@
<?php
namespace Automattic\WooCommerce\Internal\ProductDownloads\ApprovedDirectories;
use Exception;
/**
* Encapsulates a problem encountered while an operation relating to approved directories
* was performed.
*/
class ApprovedDirectoriesException extends Exception {
const INVALID_URL = 1;
const DB_ERROR = 2;
}

View File

@ -0,0 +1,507 @@
<?php
namespace Automattic\WooCommerce\Internal\ProductDownloads\ApprovedDirectories;
use Automattic\WooCommerce\Internal\ProductDownloads\ApprovedDirectories\Admin\SyncUI;
use Automattic\WooCommerce\Internal\ProductDownloads\ApprovedDirectories\Admin\UI;
use Automattic\WooCommerce\Internal\Utilities\URL;
use Automattic\WooCommerce\Internal\Utilities\URLException;
/**
* Maintains and manages the list of approved directories, within which product downloads can
* be stored.
*/
class Register {
/**
* Used to indicate the current mode.
*/
const MODES = array(
self::MODE_DISABLED,
self::MODE_ENABLED,
);
const MODE_DISABLED = 'disabled';
const MODE_ENABLED = 'enabled';
/**
* Name of the option used to store the current mode. See self::MODES for a
* list of acceptable values for the actual option.
*
* @var string
*/
private $mode_option = 'wc_downloads_approved_directories_mode';
/**
* Sets up the approved directories sub-system.
*
* @internal
*/
final public function init() {
add_action(
'admin_init',
function () {
wc_get_container()->get( SyncUI::class )->init_hooks();
wc_get_container()->get( UI::class )->init_hooks();
}
);
add_action(
'before_woocommerce_init',
function() {
if ( get_option( Synchronize::SYNC_TASK_PAGE ) > 0 ) {
wc_get_container()->get( Synchronize::class )->init_hooks();
}
}
);
}
/**
* Supplies the name of the database table used to store approved directories.
*
* @return string
*/
public function get_table(): string {
global $wpdb;
return $wpdb->prefix . 'wc_product_download_directories';
}
/**
* Returns a string indicating the current mode.
*
* May be one of: 'disabled', 'enabled', 'migrating'.
*
* @return string
*/
public function get_mode(): string {
$current_mode = get_option( $this->mode_option, self::MODE_DISABLED );
return in_array( $current_mode, self::MODES, true ) ? $current_mode : self::MODE_DISABLED;
}
/**
* Sets the mode. This effectively controls if approved directories are enforced or not.
*
* May be one of: 'disabled', 'enabled', 'migrating'.
*
* @param string $mode One of the values contained within self::MODES.
*
* @return bool
*/
public function set_mode( string $mode ): bool {
if ( ! in_array( $mode, self::MODES, true ) ) {
return false;
}
update_option( $this->mode_option, $mode );
return get_option( $this->mode_option ) === $mode;
}
/**
* Adds a new URL path.
*
* On success (or if the URL was already added) returns the URL ID, or else
* returns boolean false.
*
* @throws URLException If the URL was invalid.
* @throws ApprovedDirectoriesException If the operation could not be performed.
*
* @param string $url The URL of the approved directory.
* @param bool $enabled If the rule is enabled.
*
* @return int
*/
public function add_approved_directory( string $url, bool $enabled = true ): int {
$url = $this->prepare_url_for_upsert( $url );
$existing = $this->get_by_url( $url );
if ( $existing ) {
return $existing->get_id();
}
global $wpdb;
$insert_fields = array(
'url' => $url,
'enabled' => (int) $enabled,
);
if ( false !== $wpdb->insert( $this->get_table(), $insert_fields ) ) {
return $wpdb->insert_id;
}
throw new ApprovedDirectoriesException( __( 'URL could not be added (probable database error).', 'woocommerce' ), ApprovedDirectoriesException::DB_ERROR );
}
/**
* Updates an existing approved directory.
*
* On success or if there is an existing entry for the same URL, returns true.
*
* @throws ApprovedDirectoriesException If the operation could not be performed.
* @throws URLException If the URL was invalid.
*
* @param int $id The ID of the approved directory to be updated.
* @param string $url The new URL for the specified option.
* @param bool $enabled If the rule is enabled.
*
* @return bool
*/
public function update_approved_directory( int $id, string $url, bool $enabled = true ): bool {
$url = $this->prepare_url_for_upsert( $url );
$existing_path = $this->get_by_url( $url );
// No need to go any further if the URL is already listed and nothing has changed.
if ( $existing_path && $existing_path->get_url() === $url && $enabled === $existing_path->is_enabled() ) {
return true;
}
global $wpdb;
$fields = array(
'url' => $url,
'enabled' => (int) $enabled,
);
if ( false === $wpdb->update( $this->get_table(), $fields, array( 'url_id' => $id ) ) ) {
throw new ApprovedDirectoriesException( __( 'URL could not be updated (probable database error).', 'woocommerce' ), ApprovedDirectoriesException::DB_ERROR );
}
return true;
}
/**
* Indicates if the specified URL is already an approved directory.
*
* @param string $url The URL to check.
*
* @return bool
*/
public function approved_directory_exists( string $url ): bool {
return (bool) $this->get_by_url( $url );
}
/**
* Returns the path identified by $id, or false if it does not exist.
*
* @param int $id The ID of the rule we are looking for.
*
* @return StoredUrl|false
*/
public function get_by_id( int $id ) {
global $wpdb;
$table = $this->get_table();
// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
$result = $wpdb->get_row( $wpdb->prepare( "SELECT * FROM {$table} WHERE url_id = %d", array( $id ) ) );
if ( ! $result ) {
return false;
}
return new StoredUrl( $result->url_id, $result->url, $result->enabled );
}
/**
* Returns the path identified by $url, or false if it does not exist.
*
* @param string $url The URL of the rule we are looking for.
*
* @return StoredUrl|false
*/
public function get_by_url( string $url ) {
global $wpdb;
$table = $this->get_table();
$url = trailingslashit( $url );
// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
$result = $wpdb->get_row( $wpdb->prepare( "SELECT * FROM {$table} WHERE url = %s", array( $url ) ) );
if ( ! $result ) {
return false;
}
return new StoredUrl( $result->url_id, $result->url, $result->enabled );
}
/**
* Indicates if the URL is within an approved directory. The approved directory must be enabled
* (it is possible for individual approved directories to be disabled).
*
* For instance, for 'https://storage.king/12345/ebook.pdf' to be valid then 'https://storage.king/12345'
* would need to be within our register.
*
* If the provided URL is a filepath it can be passed in without the 'file://' scheme.
*
* @throws URLException If the provided URL is badly formed.
*
* @param string $download_url The URL to check.
*
* @return bool
*/
public function is_valid_path( string $download_url ): bool {
global $wpdb;
$parent_directories = array();
foreach ( ( new URL( $this->normalize_url( $download_url ) ) )->get_all_parent_urls() as $parent ) {
$parent_directories[] = "'" . esc_sql( $parent ) . "'";
}
if ( empty( $parent_directories ) ) {
return false;
}
$parent_directories = join( ',', $parent_directories );
$table = $this->get_table();
// Look for a rule that matches the start of the download URL being tested. Since rules describe parent
// directories, we also ensure it ends with a trailing slash.
//
// phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared
$matches = (int) $wpdb->get_var(
"
SELECT COUNT(*)
FROM {$table}
WHERE enabled = 1
AND url IN ( {$parent_directories} )
"
);
// phpcs:enable
return $matches > 0;
}
/**
* Used when a URL string is prepared before potentially adding it to the database.
*
* It will be normalized and trailing-slashed; a length check will also be performed.
*
* @throws ApprovedDirectoriesException If the operation could not be performed.
* @throws URLException If the URL was invalid.
*
* @param string $url The string URL to be normalized and trailing-slashed.
*
* @return string
*/
private function prepare_url_for_upsert( string $url ): string {
$url = trailingslashit( $this->normalize_url( $url ) );
if ( mb_strlen( $url ) > 256 ) {
throw new ApprovedDirectoriesException( __( 'Approved directory URLs cannot be longer than 256 characters.', 'woocommerce' ), ApprovedDirectoriesException::INVALID_URL );
}
return $url;
}
/**
* Normalizes the provided URL, by trimming whitespace per normal PHP conventions
* and removing any trailing slashes. If it lacks a scheme, the file scheme is
* assumed and prepended.
*
* @throws URLException If the URL is badly formed.
*
* @param string $url The URL to be normalized.
*
* @return string
*/
private function normalize_url( string $url ): string {
$url = untrailingslashit( trim( $url ) );
return ( new URL( $url ) )->get_url();
}
/**
* Lists currently approved directories.
*
* Returned array will have the following structure:
*
* [
* 'total_urls' => 12345,
* 'total_pages' => 123,
* 'urls' => [], # StoredUrl[]
* ]
*
* @param array $args {
* Controls pagination and ordering.
*
* @type null|bool $enabled Controls if only enabled (true), disabled (false) or all rules (null) should be listed.
* @type string $order Ordering ('ASC' for ascending, 'DESC' for descending).
* @type string $order_by Field to order by (one of 'url_id' or 'url').
* @type int $page The page of results to retrieve.
* @type int $per_page The number of results to retrieve per page.
* @type string $search Term to search for.
* }
*
* @return array
*/
public function list( array $args ): array {
global $wpdb;
$args = array_merge(
array(
'enabled' => null,
'order' => 'ASC',
'order_by' => 'url',
'page' => 1,
'per_page' => 20,
'search' => '',
),
$args
);
$table = $this->get_table();
$paths = array();
$order = in_array( $args['order'], array( 'ASC', 'DESC' ), true ) ? $args['order'] : 'ASC';
$order_by = in_array( $args['order_by'], array( 'url_id', 'url' ), true ) ? $args['order_by'] : 'url';
$page = absint( $args['page'] );
$per_page = absint( $args['per_page'] );
$enabled = is_bool( $args['enabled'] ) ? $args['enabled'] : null;
$search = '%' . $wpdb->esc_like( sanitize_text_field( $args['search'] ) ) . '%';
if ( $page < 1 ) {
$page = 1;
}
if ( $per_page < 1 ) {
$per_page = 1;
}
$where = array();
$where_sql = '';
if ( ! empty( $search ) ) {
$where[] = $wpdb->prepare( 'url LIKE %s', $search );
}
if ( is_bool( $enabled ) ) {
$where[] = 'enabled = ' . (int) $enabled;
}
if ( ! empty( $where ) ) {
$where_sql = 'WHERE ' . join( ' AND ', $where );
}
// phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared
$results = $wpdb->get_results(
$wpdb->prepare(
"
SELECT url_id, url, enabled
FROM {$table}
{$where_sql}
ORDER BY {$order_by} {$order}
LIMIT %d, %d
",
( $page - 1 ) * $per_page,
$per_page
)
);
$total_rows = (int) $wpdb->get_var( "SELECT COUNT( * ) FROM {$table} {$where_sql}" );
// phpcs:enable
foreach ( $results as $single_result ) {
$paths[] = new StoredUrl( $single_result->url_id, $single_result->url, $single_result->enabled );
}
return array(
'total_urls' => $total_rows,
'total_pages' => (int) ceil( $total_rows / $per_page ),
'approved_directories' => $paths,
);
}
/**
* Delete the approved directory identitied by the supplied ID.
*
* @param int $id The ID of the rule to be deleted.
*
* @return bool
*/
public function delete_by_id( int $id ): bool {
global $wpdb;
$table = $this->get_table();
return (bool) $wpdb->delete( $table, array( 'url_id' => $id ) );
}
/**
* Delete the entirev approved directory list.
*
* @return bool
*/
public function delete_all(): bool {
global $wpdb;
$table = $this->get_table();
// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
return (bool) $wpdb->query( "DELETE FROM $table" );
}
/**
* Enable the approved directory identitied by the supplied ID.
*
* @param int $id The ID of the rule to be deleted.
*
* @return bool
*/
public function enable_by_id( int $id ): bool {
global $wpdb;
$table = $this->get_table();
return (bool) $wpdb->update( $table, array( 'enabled' => 1 ), array( 'url_id' => $id ) );
}
/**
* Disable the approved directory identitied by the supplied ID.
*
* @param int $id The ID of the rule to be deleted.
*
* @return bool
*/
public function disable_by_id( int $id ): bool {
global $wpdb;
$table = $this->get_table();
return (bool) $wpdb->update( $table, array( 'enabled' => 0 ), array( 'url_id' => $id ) );
}
/**
* Enables all Approved Download Directory rules in a single operation.
*
* @return bool
*/
public function enable_all(): bool {
global $wpdb;
$table = $this->get_table();
// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
return (bool) $wpdb->query( "UPDATE {$table} SET enabled = 1" );
}
/**
* Disables all Approved Download Directory rules in a single operation.
*
* @return bool
*/
public function disable_all(): bool {
global $wpdb;
$table = $this->get_table();
// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
return (bool) $wpdb->query( "UPDATE {$table} SET enabled = 0" );
}
/**
* Indicates the number of approved directories that are enabled (or disabled, if optional
* param $enabled is set to false).
*
* @param bool $enabled Controls whether enabled or disabled directory rules are counted.
*
* @return int
*/
public function count( bool $enabled = true ): int {
global $wpdb;
$table = $this->get_table();
return (int) $wpdb->get_var(
$wpdb->prepare(
// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
"SELECT COUNT(*) FROM {$table} WHERE enabled = %d",
$enabled ? 1 : 0
)
);
}
}

View File

@ -0,0 +1,69 @@
<?php
namespace Automattic\WooCommerce\Internal\ProductDownloads\ApprovedDirectories;
/**
* Representation of an approved directory URL, bundling the ID and URL in a single entity.
*/
class StoredUrl {
/**
* The approved directory ID.
*
* @var int
*/
private $id;
/**
* The approved directory URL.
*
* @var string
*/
private $url;
/**
* If the individual rule is enabled or disabled.
*
* @var bool
*/
private $enabled;
/**
* Sets up the approved directory rule.
*
* @param int $id The approved directory ID.
* @param string $url The approved directory URL.
* @param bool $enabled Indicates if the approved directory rule is enabled.
*/
public function __construct( int $id, string $url, bool $enabled ) {
$this->id = $id;
$this->url = $url;
$this->enabled = $enabled;
}
/**
* Supplies the ID of the approved directory.
*
* @return int
*/
public function get_id(): int {
return $this->id;
}
/**
* Supplies the approved directory URL.
*
* @return string
*/
public function get_url(): string {
return $this->url;
}
/**
* Indicates if this rule is enabled or not (rules can be temporarily disabled).
*
* @return bool
*/
public function is_enabled(): bool {
return $this->enabled;
}
}

View File

@ -0,0 +1,255 @@
<?php
namespace Automattic\WooCommerce\Internal\ProductDownloads\ApprovedDirectories;
use Exception;
use Automattic\WooCommerce\Internal\Utilities\URL;
use WC_Admin_Notices;
use WC_Product;
use WC_Queue_Interface;
/**
* Ensures that any downloadable files have a corresponding entry in the Approved Product
* Download Directories list.
*/
class Synchronize {
/**
* Scheduled action hook used to facilitate scanning the product catalog for downloadable products.
*/
const SYNC_TASK = 'woocommerce_download_dir_sync';
/**
* The group under which synchronization tasks run (our standard 'woocommerce-db-updates' group).
*/
const SYNC_TASK_GROUP = 'woocommerce-db-updates';
/**
* Used to track progress throughout the sync process.
*/
const SYNC_TASK_PAGE = 'wc_product_download_dir_sync_page';
/**
* Used to record an estimation of progress on the current synchronization process. 0 means 0%,
* 100 means 100%.
*
* @param int
*/
const SYNC_TASK_PROGRESS = 'wc_product_download_dir_sync_progress';
/**
* Number of downloadable products to be processed in each atomic sync task.
*/
const SYNC_TASK_BATCH_SIZE = 20;
/**
* WC Queue.
*
* @var WC_Queue_Interface
*/
private $queue;
/**
* Register of approved directories.
*
* @var Register
*/
private $register;
/**
* Sets up our checks and controls for downloadable asset URLs, as appropriate for
* the current approved download directory mode.
*
* @internal
* @throws Exception If the WC_Queue instance cannot be obtained.
*
* @param Register $register The active approved download directories instance in use.
*/
final public function init( Register $register ) {
$this->queue = WC()->get_instance_of( WC_Queue_Interface::class );
$this->register = $register;
}
/**
* Performs any work needed to add hooks and otherwise integrate with the wider system.
*/
final public function init_hooks() {
add_action( self::SYNC_TASK, array( $this, 'run' ) );
}
/**
* Initializes the Approved Download Directories feature, typically following an update or
* during initial installation.
*
* @param bool $synchronize Synchronize with existing product downloads. Not needed in a fresh installation.
* @param bool $enable_feature Enable (default) or disable the feature.
*/
public function init_feature( bool $synchronize = true, bool $enable_feature = true ) {
try {
$this->add_default_directories();
if ( $synchronize ) {
$this->start();
}
} catch ( Exception $e ) {
wc_get_logger()->log( 'warning', __( 'It was not possible to synchronize download directories following the update to 6.4.0.', 'woocommerce' ) );
}
$this->register->set_mode(
$enable_feature ? Register::MODE_ENABLED : Register::MODE_DISABLED
);
}
/**
* By default we add the woocommerce_uploads directory (file path plus web URL) to the list
* of approved download directories.
*
* @throws Exception If the default directories cannot be added to the Approved List.
*/
public function add_default_directories() {
$upload_dir = wp_get_upload_dir();
$this->register->add_approved_directory( $upload_dir['basedir'] . '/woocommerce_uploads' );
$this->register->add_approved_directory( $upload_dir['baseurl'] . '/woocommerce_uploads' );
}
/**
* Starts the synchronization process.
*
* @return bool
*/
public function start(): bool {
if ( null !== $this->queue->get_next( self::SYNC_TASK ) ) {
wc_get_logger()->log( 'warning', __( 'Synchronization of approved product download directories is already in progress.', 'woocommerce' ) );
return false;
}
update_option( self::SYNC_TASK_PAGE, 1 );
$this->queue->schedule_single( time(), self::SYNC_TASK, array(), self::SYNC_TASK_GROUP );
wc_get_logger()->log( 'info', __( 'Approved Download Directories sync: new scan scheduled.', 'woocommerce' ) );
return true;
}
/**
* Runs the syncronization task.
*/
public function run() {
$products = $this->get_next_set_of_downloadable_products();
foreach ( $products as $product ) {
$this->process_product( $product );
}
// Detect if we have reached the end of the task.
if ( count( $products ) < self::SYNC_TASK_BATCH_SIZE ) {
wc_get_logger()->log( 'info', __( 'Approved Download Directories sync: scan is complete!', 'woocommerce' ) );
$this->stop();
} else {
wc_get_logger()->log(
'info',
sprintf(
/* translators: %1$d is the current batch in the synchronization task, %2$d is the percent complete. */
__( 'Approved Download Directories sync: completed batch %1$d (%2$d%% complete).', 'woocommerce' ),
(int) get_option( self::SYNC_TASK_PAGE, 2 ) - 1,
$this->get_progress()
)
);
$this->queue->schedule_single( time() + 1, self::SYNC_TASK, array(), self::SYNC_TASK_GROUP );
}
}
/**
* Stops/cancels the current synchronization task.
*/
public function stop() {
WC_Admin_Notices::add_notice( 'download_directories_sync_complete', true );
delete_option( self::SYNC_TASK_PAGE );
delete_option( self::SYNC_TASK_PROGRESS );
$this->queue->cancel( self::SYNC_TASK );
}
/**
* Queries for the next batch of downloadable products, applying logic to ensure we only fetch those that actually
* have downloadable files (a downloadable product can be created that does not have downloadable files and/or
* downloadable files can be removed from existing downloadable products).
*
* @return array
*/
private function get_next_set_of_downloadable_products(): array {
$query_filter = function ( array $query ): array {
$query['meta_query'][] = array(
'key' => '_downloadable_files',
'compare' => 'EXISTS',
);
return $query;
};
$page = (int) get_option( self::SYNC_TASK_PAGE, 1 );
add_filter( 'woocommerce_product_data_store_cpt_get_products_query', $query_filter );
$products = wc_get_products(
array(
'limit' => self::SYNC_TASK_BATCH_SIZE,
'page' => $page,
'paginate' => true,
)
);
remove_filter( 'woocommerce_product_data_store_cpt_get_products_query', $query_filter );
$progress = $products->max_num_pages > 0 ? (int) ( ( $page / $products->max_num_pages ) * 100 ) : 1;
update_option( self::SYNC_TASK_PAGE, $page + 1 );
update_option( self::SYNC_TASK_PROGRESS, $progress );
return $products->products;
}
/**
* Processes an individual downloadable product, adding the parent paths for any downloadable files to the
* Approved Download Directories list.
*
* Any such paths will be added with the disabled flag set, because we want a site administrator to review
* and approve first.
*
* @param WC_Product $product The product we wish to examine for downloadable file paths.
*/
private function process_product( WC_Product $product ) {
$downloads = $product->get_downloads();
foreach ( $downloads as $downloadable ) {
$parent_url = _x( 'invalid URL', 'Approved product download URLs migration', 'woocommerce' );
try {
$parent_url = ( new URL( $downloadable->get_file() ) )->get_parent_url();
$this->register->add_approved_directory( $parent_url, false );
} catch ( Exception $e ) {
wc_get_logger()->log(
'error',
sprintf(
/* translators: %s is a URL, %d is a product ID. */
__( 'Product download migration: %1$s (for product %1$d) could not be added to the list of approved download directories.', 'woocommerce' ),
$parent_url,
$product->get_id()
)
);
}
}
}
/**
* Indicates if a synchronization of product download directories is in progress.
*
* @return bool
*/
public function in_progress(): bool {
return (bool) get_option( self::SYNC_TASK_PAGE, false );
}
/**
* Returns a value between 0 and 100 representing the percentage complete of the current sync.
*
* @return int
*/
public function get_progress(): int {
return min( 100, max( 0, (int) get_option( self::SYNC_TASK_PROGRESS, 0 ) ) );
}
}

View File

@ -0,0 +1,262 @@
<?php
namespace Automattic\WooCommerce\Internal\Utilities;
/**
* Provides an easy method of assessing URLs, including filepaths (which will be silently
* converted to a file:// URL if provided).
*/
class URL {
/**
* Components of the URL being assessed.
*
* The keys match those potentially returned by the parse_url() function, except
* that they are always defined and 'drive' (Windows drive letter) has been added.
*
* @var string|null[]
*/
private $components = array(
'drive' => null,
'fragment' => null,
'host' => null,
'pass' => null,
'path' => null,
'port' => null,
'query' => null,
'scheme' => null,
'user' => null,
);
/**
* If the URL (or filepath) is absolute.
*
* @var bool
*/
private $is_absolute;
/**
* The components of the URL's path.
*
* For instance, in the case of "file:///srv/www/wp.site" (noting that a file URL has
* no host component) this would contain:
*
* [ "srv", "www", "wp.site" ]
*
* In the case of a non-file URL such as "https://example.com/foo/bar/baz" (noting the
* host is not part of the path) it would contain:
*
* [ "foo", "bar", "baz" ]
*
* @var array
*/
private $path_parts = array();
/**
* The URL.
*
* @var string
*/
private $url;
/**
* Creates and processes the provided URL (or filepath).
*
* @throws URLException If the URL (or filepath) is seriously malformed.
*
* @param string $url The URL (or filepath).
*/
public function __construct( string $url ) {
$this->url = $url;
$this->preprocess();
$this->process_path();
}
/**
* Makes all slashes forward slashes, converts filepaths to file:// URLs, and
* other processing to help with comprehension of filepaths.
*
* @throws URLException If the URL is seriously malformed.
*/
private function preprocess() {
// For consistency, all slashes should be forward slashes.
$this->url = str_replace( '\\', '/', $this->url );
// Windows: capture the drive letter if provided.
if ( preg_match( '#^(file://)?([a-z]):/(?!/).*#i', $this->url, $matches ) ) {
$this->components['drive'] = $matches[2];
}
// If there is no scheme, assume and prepend "file://".
if ( ! preg_match( '#^[a-z]+://#i', $this->url ) ) {
$this->url = 'file://' . $this->url;
}
$parsed_components = wp_parse_url( $this->url );
// If we received a really badly formed URL, let's go no further.
if ( false === $parsed_components ) {
throw new URLException(
sprintf(
/* translators: %s is the URL. */
__( '%s is not a valid URL.', 'woocommerce' ),
$this->url
)
);
}
// File URLs cannot have a host. However, the initial path segment *or* the Windows drive letter
// (if present) may be incorrectly be interpreted as the host name.
if ( 'file' === $parsed_components['scheme'] && ! empty( $parsed_components['host'] ) ) {
// If we do not have a drive letter, then simply merge the host and the path together.
if ( null === $this->components['drive'] ) {
$parsed_components['path'] = $parsed_components['host'] . ( $parsed_components['path'] ?? '' );
}
// Always unset the host in this situation.
unset( $parsed_components['host'] );
}
$this->components = array_merge( $this->components, $parsed_components );
}
/**
* Simplifies the path if possible, by resolving directory traversals to the extent possible
* without touching the filesystem.
*/
private function process_path() {
$segments = explode( '/', $this->components['path'] );
$this->is_absolute = substr( $this->components['path'], 0, 1 ) === '/';
// Clean the path.
foreach ( $segments as $part ) {
// Drop empty segments.
if ( strlen( $part ) === 0 ) {
continue;
}
// Directory traversals created with percent-encoding syntax should also be detected.
$is_traversal = str_ireplace( '%2e', '.', $part ) === '..';
// Unwind directory traversals.
if ( $is_traversal && count( $this->path_parts ) > 0 ) {
$this->path_parts = array_slice( $this->path_parts, 0, count( $this->path_parts ) - 1 );
continue;
}
// Retain this part of the path.
$this->path_parts[] = $part;
}
// Reform the path from the processed segments, appending a leading slash if it is absolute and restoring
// the Windows drive letter if we have one.
$this->components['path'] = ( $this->is_absolute ? '/' : '' ) . implode( '/', $this->path_parts );
}
/**
* Returns the processed URL as a string.
*
* @return string
*/
public function __toString(): string {
return $this->get_url();
}
/**
* Returns all possible parent URLs for the current URL.
*
* @return string[]
*/
public function get_all_parent_urls(): array {
$max_parent = count( $this->path_parts );
$parents = array();
for ( $level = 1; $level <= $max_parent; $level++ ) {
$parents[] = $this->get_parent_url( $level );
}
return $parents;
}
/**
* Outputs the parent URL.
*
* For example, if $this->get_url() returns "https://example.com/foo/bar/baz" then
* this method will return "https://example.com/foo/bar/".
*
* When a grand-parent is needed, the optional $level parameter can be used. By default
* this is set to 1 (parent). 2 will yield the grand-parent, 3 will yield the great
* grand-parent, etc.
*
* @param int $level Used to indicate the level of parent.
*
* @return string
*/
public function get_parent_url( int $level = 1 ): string {
if ( $level < 1 ) {
$level = 1;
}
$parent_path = implode( '/', array_slice( $this->path_parts, 0, count( $this->path_parts ) - $level ) ) . '/';
// For absolute paths, apply a leading slash (does not apply if we have a root path).
if ( $this->is_absolute && 0 !== strpos( $parent_path, '/' ) ) {
$parent_path = '/' . $parent_path;
}
return $this->get_url( $this->get_path( $parent_path ) );
}
/**
* Outputs the processed URL.
*
* Borrows from https://www.php.net/manual/en/function.parse-url.php#106731
*
* @param string $path_override If provided this will be used as the URL path.
*
* @return string
*/
public function get_url( string $path_override = null ): string {
$scheme = null !== $this->components['scheme'] ? $this->components['scheme'] . '://' : '';
$host = null !== $this->components['host'] ? $this->components['host'] : '';
$port = null !== $this->components['port'] ? ':' . $this->components['port'] : '';
$user = null !== $this->components['user'] ? $this->components['user'] : '';
$pass = null !== $this->components['pass'] ? ':' . $this->components['pass'] : '';
$user_pass = ( ! empty( $user ) || ! empty( $pass ) ) ? $user . $pass . '@' : '';
$path = $path_override ?? $this->get_path();
$query = null !== $this->components['query'] ? '?' . $this->components['query'] : '';
$fragment = null !== $this->components['fragment'] ? '#' . $this->components['fragment'] : '';
return $scheme . $user_pass . $host . $port . $path . $query . $fragment;
}
/**
* Outputs the path. Especially useful if it was a a regular filepath that was passed in originally.
*
* @param string $path_override If provided this will be used as the URL path. Does not impact drive letter.
*
* @return string
*/
public function get_path( string $path_override = null ): string {
return ( $this->components['drive'] ? $this->components['drive'] . ':' : '' ) . ( $path_override ?? $this->components['path'] );
}
/**
* Indicates if the URL or filepath was absolute.
*
* @return bool True if absolute, else false.
*/
public function is_absolute(): bool {
return $this->is_absolute;
}
/**
* Indicates if the URL or filepath was relative.
*
* @return bool True if relative, else false.
*/
public function is_relative(): bool {
return ! $this->is_absolute;
}
}

View File

@ -0,0 +1,10 @@
<?php
namespace Automattic\WooCommerce\Internal\Utilities;
use Exception;
/**
* Used to represent a problem encountered when processing a URL.
*/
class URLException extends Exception {}

View File

@ -0,0 +1,28 @@
<?php
namespace Automattic\WooCommerce\Internal\Utilities;
/**
* Helper functions for working with users.
*/
class Users {
/**
* Indicates if the user qualifies as site administrator.
*
* In the context of multisite networks, this means that they must have the `manage_sites`
* capability. In all other cases, they must have the `manage_options` capability.
*
* @param int $user_id Optional, used to specify a specific user (otherwise we look at the current user).
*
* @return bool
*/
public static function is_site_administrator( int $user_id = 0 ): bool {
$user = 0 === $user_id ? wp_get_current_user() : get_user_by( 'id', $user_id );
if ( false === $user ) {
return false;
}
return is_multisite() ? $user->has_cap( 'manage_sites' ) : $user->has_cap( 'manage_options' );
}
}

View File

@ -58,6 +58,41 @@ class WC_Helper_Product {
}
}
/**
* Create a downloadable product.
*
* @since 6.4.0
*
* @param array $downloads An array of arrays (each containing a 'name' and 'file' key) or WC_Product_Download objects.
* @param bool $save Save or return object.
*
* @return WC_Product_Simple|false
*/
public static function create_downloadable_product( array $downloads = array(), $save = true ) {
$product = new WC_Product_Simple();
$product->set_props(
array(
'name' => 'Downloadable Product',
'regular_price' => 10,
'price' => 10,
'manage_stock' => false,
'tax_status' => 'taxable',
'downloadable' => true,
'virtual' => false,
'stock_status' => 'instock',
)
);
$product->set_downloads( $downloads );
if ( $save ) {
$product->save();
return \wc_get_product( $product->get_id() );
} else {
return $product;
}
}
/**
* Create external product.
*

View File

@ -5,10 +5,21 @@
* @package WooCommerce\Tests\Customer
*/
use Automattic\WooCommerce\Internal\ProductDownloads\ApprovedDirectories\Register as Download_Directories;
/**
* WC_Tests_Customer_Functions class.
*/
class WC_Tests_Customer_Functions extends WC_Unit_Test_Case {
/**
* Perform any common setup work.
*/
public function setUp() {
parent::setUp();
// For these tests, we are not concerned with Approved Download Directory functionality.
wc_get_container()->get( Download_Directories::class )->set_mode( Download_Directories::MODE_DISABLED );
}
/**
* Set illegal login

View File

@ -17,20 +17,29 @@ class WC_Tests_Product_CSV_Importer extends WC_Unit_Test_Case {
*/
protected $csv_file = '';
/**
* @var WC_Product_CSV_Importer
*/
private $sut;
/**
* Load up the importer classes since they aren't loaded by default.
*/
public function setUp() {
parent::setUp();
// Callback used by WP_HTTP_TestCase to decide whether to perform HTTP requests or to provide a mocked response.
$this->http_responder = array( $this, 'mock_http_responses' );
$this->csv_file = dirname( __FILE__ ) . '/sample.csv';
$bootstrap = WC_Unit_Tests_Bootstrap::instance();
require_once $bootstrap->plugin_dir . '/includes/import/class-wc-product-csv-importer.php';
require_once $bootstrap->plugin_dir . '/includes/admin/importers/class-wc-product-csv-importer-controller.php';
// Callback used by WP_HTTP_TestCase to decide whether to perform HTTP requests or to provide a mocked response.
$this->http_responder = array( $this, 'mock_http_responses' );
$this->csv_file = dirname( __FILE__ ) . '/sample.csv';
$this->sut = new WC_Product_CSV_Importer( $this->csv_file, array(
'mapping' => $this->get_csv_mapped_items(),
'parse' => true,
'prevent_timeouts' => false,
) );
}
/**
@ -91,25 +100,39 @@ class WC_Tests_Product_CSV_Importer extends WC_Unit_Test_Case {
}
/**
* Test import.
*
* @since 3.1.0
* @requires PHP 5.4
* @testdox Test import as triggered by an admin user.
*/
public function test_import() {
$args = array(
'mapping' => $this->get_csv_mapped_items(),
'parse' => true,
'prevent_timeouts' => false,
);
public function test_import_for_admin_users() {
// In most cases, an admin user will run the import.
wp_set_current_user( self::factory()->user->create( array( 'role' => 'administrator' ) ) );
$results = $this->sut->import();
$importer = new WC_Product_CSV_Importer( $this->csv_file, $args );
$results = $importer->import();
$this->assertEquals( 7, count( $results['imported'] ) );
$this->assertEquals( 0, count( $results['failed'] ) );
$this->assertEquals( 0, count( $results['updated'] ) );
$this->assertEquals( 0, count( $results['skipped'] ) );
$this->assertEquals(
7,
count( $results['imported'] ) ,
'One import item references a downloadable file stored in an unapproved location: if the import is triggered by an admin user, that location will be automatically approved.'
);
}
/**
* @testdox Test import as triggered by a shop manager (or other non-admin user).
*/
public function test_import_for_shop_managers() {
// In some cases, a shop manager may run the import.
wp_set_current_user( self::factory()->user->create( array( 'role' => 'shop_manager' ) ) );
$results = $this->sut->import();
$this->assertEquals( 0, count( $results['updated'] ) );
$this->assertEquals( 0, count( $results['skipped'] ) );
$this->assertEquals( 6, count( $results['imported'] ) );
$this->assertEquals(
1,
count( $results['failed'] ),
'One import item references a downloadable file stored in an unapproved location: if the import is triggered by a non-admin, that item cannot be imported.'
);
}
/**

View File

@ -0,0 +1,60 @@
<?php
use Automattic\WooCommerce\Internal\ProductDownloads\ApprovedDirectories\Register as Download_Directories;
/**
* Tests relating to the WC_Abstract_Product class.
*/
class WC_Abstract_Product_Test extends WC_Unit_Test_Case {
/**
* @testdox Ensure that individual Downloadable Products follow the rules regarding Approved Download Directories.
*/
public function test_fetching_of_approved_downloads() {
/**
* @var Download_Directories $download_directories
*/
$download_directories = wc_get_container()->get( Download_Directories::class );
$download_directories->set_mode( Download_Directories::MODE_ENABLED );
$download_directories->add_approved_directory( 'https://always.trusted/' );
$problematic_file_source_id = $download_directories->add_approved_directory( 'https://new.supplier/' );
$product = WC_Helper_Product::create_downloadable_product( array(
array(
'name' => 'Book 1',
'file' => 'https://always.trusted/123.pdf'
),
array(
'name' => 'Book 2',
'file' => 'https://new.supplier/456.pdf'
),
) );
$this->assertCount(
2,
wc_get_product( $product->get_id() )->get_downloads(),
'If we load the downloadable product and all of its downloads are stored in trusted directories, we expect to fetch all of them.'
);
$download_directories->disable_by_id( $problematic_file_source_id );
$this->assertCount(
1,
wc_get_product( $product->get_id() )->get_downloads(),
'If a trusted download directory is disabled, we expect any individual download files from that location will not be listed.'
);
$this->assertEquals(
'Book 1',
current( wc_get_product( $product->get_id() )->get_downloads() )->get_name(),
'Only individual download files that are stored in trusted locations will be fetched.'
);
$download_directories->set_mode( Download_Directories::MODE_DISABLED );
$this->assertCount(
2,
wc_get_product( $product->get_id() )->get_downloads(),
'If the Approved Download Directories system is completely disabled, we expect all product downloads to be fetched irrespective of where they are stored.'
);
}
}

View File

@ -1,10 +1,10 @@
<?php
use Automattic\WooCommerce\Internal\ProductDownloads\ApprovedDirectories\Register as Download_Directories;
/**
* Class WC_Product_Download_Test
*/
class WC_Product_Download_Test extends WC_Unit_Test_Case {
/**
* Test for file without extension.
*/
@ -35,4 +35,32 @@ class WC_Product_Download_Test extends WC_Unit_Test_Case {
$download->set_file( $file_path_with_period_at_end );
$this->assertEquals( false, $download->is_allowed_filetype() );
}
/**
* Test that download URLs are automatically added to the approved directories list (for
* "admin"-level users) but that they are not automatically added in other cases.
*/
public function test_allowed_directory_rules_are_enforced() {
/** @var Download_Directories $download_directories */
$download_directories = wc_get_container()->get( Download_Directories::class );
$download_directories->set_mode( Download_Directories::MODE_ENABLED );
$non_admin_user = wp_insert_user( array( 'user_login' => uniqid(), 'role' => 'editor', 'user_pass' => 'x' ) );
$admin_user = wp_insert_user( array( 'user_login' => uniqid(), 'role' => 'administrator', 'user_pass' => 'x' ) );
$ebook_url = 'https://external.site/books/ultimate-guide-to-stuff.pdf';
$podcast_url = 'https://external.site/podcasts/ultimate-guide-to-stuff.mp3';
wp_set_current_user( $admin_user );
$download = new WC_Product_Download();
$download->set_file( $ebook_url );
$this->assertFalse( $download_directories->is_valid_path( $ebook_url ), 'Verify ebook path has not been added prior to next test.' );
$download->check_is_valid();
$this->assertTrue( $download_directories->is_valid_path( $ebook_url ), 'Verify ebook path was automatically added by the last operation.' );
wp_set_current_user( $non_admin_user );
$download = new WC_Product_Download();
$download->set_file( $podcast_url );
$this->expectExceptionMessage( 'cannot be used: it is not located in an approved directory' );
$download->check_is_valid();
}
}

View File

@ -6,6 +6,7 @@
namespace Automattic\WooCommerce\Tests\Internal;
use Automattic\WooCommerce\Internal\DownloadPermissionsAdjuster;
use Automattic\WooCommerce\Internal\ProductDownloads\ApprovedDirectories\Register as Download_Directories;
use Automattic\WooCommerce\RestApi\UnitTests\Helpers\ProductHelper;
/**
@ -42,6 +43,9 @@ class DownloadPermissionsAdjusterTest extends \WC_Unit_Test_Case {
10,
2
);
// In these tests, we are not directly concerned with Approved Download Directory functionality.
wc_get_container()->get( Download_Directories::class )->set_mode( Download_Directories::MODE_DISABLED );
}
/**

View File

@ -0,0 +1,134 @@
<?php
namespace Automattic\WooCommerce\Tests\Internal\ProductDownloads;
use Automattic\WooCommerce\Internal\ProductDownloads\ApprovedDirectories\Register;
use WC_Unit_Test_Case;
/**
* Tests for the Product Downloads Whitelist.
*/
class RegisterTest extends WC_Unit_Test_Case {
/**
* @var Register
*/
private static $sut;
/**
* Create a Whitelist with representative paths including a URL with a port number,
* a regular URL and an absolute filepath.
*/
public static function setUpBeforeClass() {
parent::setUpBeforeClass();
static::$sut = new Register();
static::$sut->add_approved_directory( 'http://localhost:5000' );
static::$sut->add_approved_directory( 'https://foo.bar/assets/' );
static::$sut->add_approved_directory( '/absolute/filepath' );
static::$sut->add_approved_directory( '/absolute/disabled/filepath', false );
static::$sut->add_approved_directory( 'C:\\Program\\Data\\Assets' );
}
/**
* @testdox Test a range of filepaths and URLs that we expect to be seen as valid.
*/
public function test_valid_paths() {
$this->assertTrue(
static::$sut->is_valid_path( 'https://foo.bar/assets/my/nested/document.xml' ),
'URL validates if it is subordinate to a whitelisted URL path.'
);
$this->assertTrue(
static::$sut->is_valid_path( 'http://localhost:5000/direct-child.doc' ),
'URL validates if it is subordinate to a whitelisted URL path (including one with a port number).'
);
$this->assertTrue(
static::$sut->is_valid_path( '/absolute/filepath/my-file.pdf' ),
'Filepath validates if it is subordinate to a whitelisted path (where the path being tested does not explicitly use the "file://" scheme).'
);
$this->assertTrue(
static::$sut->is_valid_path( 'file:///absolute/filepath/my-file.pdf' ),
'Filepath validates if it is subordinate to a whitelisted path (where the path being tested explicitly uses the "file://" scheme).'
);
$this->assertTrue(
static::$sut->is_valid_path( 'C:\\Program\\Data\\Assets\\downloadable.exe' ),
'Filepath validates if it is subordinate to a whitelisted path (Windows-style path complete with drive letter).'
);
$this->assertTrue(
static::$sut->is_valid_path( 'C:\\Program\\Data/Assets/downloadable.exe' ),
'Filepath validates if it is subordinate to a whitelisted path (Windows-style path complete with drive letter, but with Unix style separators).'
);
$this->assertTrue(
static::$sut->is_valid_path( '/absolute/filepath/../filepath/good.xml' ),
'Filepaths containing directory traversals validate so long as the resolved path is still subordinate to a whitelisted path.'
);
}
/**
* @testdox Test a range of filepaths and URLs that we expect to be seen as invalid.
*/
public function test_invalid_paths() {
$this->assertFalse(
static::$sut->is_valid_path( 'http://foo.bar/assets/my/nested/document.xml' ),
'URL should not validate as the ("http://") scheme means it is different to any of the current whitelisted paths.'
);
$this->assertFalse(
static::$sut->is_valid_path( 'http://localhost:50001/some-file.pdf' ),
'URL should not validate as the port number means it belongs to a distinct, non-whitelisted URL path.'
);
$this->assertFalse(
static::$sut->is_valid_path( 'file://absolute/filepath/directory/my-file.pdf' ),
'Filepath will not validate is it does not belong to a whitelisted path (note that the tested path is relative).'
);
$this->assertFalse(
static::$sut->is_valid_path( '/absolute/filepath/../../filepath/good.xml' ),
'Filepaths containing directory traversals do not validate if the resolved path is no longer subordinate to a whitelisted path.'
);
$this->assertFalse(
static::$sut->is_valid_path( '/absolute/disabled/filepath/guide.pdf' ),
'Filepath will not validate if the corresponding rule has been disabled.'
);
}
/**
* @testdox Ensure adding individual paths works as expected.
*/
public function test_adding_single_path() {
$this->assertEquals(
static::$sut->get_by_url( 'http://localhost:5000' )->get_id(),
static::$sut->add_approved_directory( 'http://localhost:5000' ),
'If the path was already added, adding it a second time (without trailing slash) will result in the existing row number (rule ID) being returned.'
);
$this->assertEquals(
static::$sut->get_by_url( 'http://localhost:5000' )->get_id(),
static::$sut->add_approved_directory( 'http://localhost:5000/' ),
'If the path was already added, adding it a second time (with trailing slash) will result in the existing row number (rule ID) being returned.'
);
}
/**
* @testdox Ensure individual approved directories can be enabled and disabled.
*/
public function test_enabling_disabling_individual_rules() {
$approved_directory_rule = static::$sut->get_by_url( 'https://foo.bar/assets/' );
$approved_directory_id = $approved_directory_rule->get_id();
$this->assertTrue( $approved_directory_rule->is_enabled() );
static::$sut->disable_by_id( $approved_directory_id );
$this->assertFalse( static::$sut->get_by_url( 'https://foo.bar/assets/' )->is_enabled() );
static::$sut->enable_by_id( $approved_directory_id );
$this->assertTrue( static::$sut->get_by_url( 'https://foo.bar/assets/' )->is_enabled() );
}
}

View File

@ -0,0 +1,85 @@
<?php
namespace Automattic\WooCommerce\Tests\Internal\ProductDownloads;
use Automattic\WooCommerce\Internal\ProductDownloads\ApprovedDirectories\Synchronize;
use Automattic\WooCommerce\Internal\ProductDownloads\ApprovedDirectories\Register;
use Automattic\WooCommerce\Proxies\LegacyProxy;
use Automattic\WooCommerce\RestApi\UnitTests\Helpers\ProductHelper;
use Automattic\WooCommerce\Testing\Tools\FakeQueue;
use WC_Queue_Interface;
use WC_Unit_Test_Case;
/**
* Tests for the Product Downloads Allowed Directories synchronization utility.
*/
class SynchronizeTest extends WC_Unit_Test_Case {
/**
* @var Synchronize
*/
private $sut;
/**
* Create subject under test.
*/
public function setUp() {
parent::setUp();
$this->sut = wc_get_container()->get( Synchronize::class );
}
/**
* @testdox Ensure basic controls to start and stop synchronization behave as expected.
*/
public function test_basic_synchronization_controls() {
$this->sut->start();
$this->assertTrue(
$this->sut->in_progress(),
'We can successfully start synchronizing and verify it is in progress.'
);
$this->assertFalse(
$this->sut->start(),
'If a download directory synchronization process is already in progress, additional concurrent sync processes cannot be created.'
);
$this->assertFalse(
$this->sut->start(),
'Synchronization process can be cancelled before it completes.'
);
$this->sut->stop();
$this->assertNull(
wc_get_container()->get( LegacyProxy::class )->get_instance_of( WC_Queue_Interface::class )->get_next( Synchronize::SYNC_TASK ),
'Once synchronization has been cancelled, any related scheduled actions will also have been cleaned up.'
);
}
/**
* @testdox Verify expected logging and clean-up take place during and following synchronization of download directories.
*/
public function test_sync_process() {
$logged_messages = array();
$log_watcher = function ( string $logged_message ) use ( &$logged_messages ) {
$logged_messages[] = $logged_message;
};
add_filter( 'woocommerce_logger_log_message', $log_watcher );
$this->sut->start();
$this->sut->run();
remove_filter( 'woocommerce_logger_log_message', $log_watcher );
$this->assertTrue(
! get_option( Synchronize::SYNC_TASK_PAGE ) && ! get_option( Synchronize::SYNC_TASK_PROGRESS ),
'Once synchronization has completed, any temporary options used to hold state will have been deleted.'
);
$this->assertContains(
'Approved Download Directories sync: scan is complete!',
$logged_messages,
'We expect that completion of the synchronization process will have been recorded in the log.'
);
}
}

View File

@ -0,0 +1,177 @@
<?php
namespace Automattic\WooCommerce\Tests\Internal\Utilities;
use Automattic\WooCommerce\Internal\Utilities\URL;
use WC_Unit_Test_Case;
/**
* A collection of tests for the filepath utility class.
*/
class URLTest extends WC_Unit_Test_Case {
public function test_if_absolute_or_relative() {
$this->assertTrue(
( new URL( '/etc/foo/bar' ) )->is_absolute() ,
'Correctly determines if a Unix-style path is absolute.'
);
$this->assertTrue(
( new URL( 'c:\\Windows\Programs\Item' ) )->is_absolute(),
'Correctly determines if a Windows-style path is absolute.'
);
$this->assertTrue(
( new URL( 'wp-content/uploads/thing.pdf' ) )->is_relative(),
'Correctly determines if a filepath is relative.'
);
}
public function test_directory_traversal_resolution() {
$this->assertEquals(
'/var/foo/foobar',
( new URL( '/var/foo/bar/baz/../../foobar' ) )->get_path(),
'Correctly resolves a path containing a directory traversal.'
);
$this->assertEquals(
'/bazbar',
( new URL( '/var/foo/../../../../bazbar' ) )->get_path(),
'Correctly resolves a path containing a directory traversal, even if the traversals attempt to backtrack beyond the root directory.'
);
$this->assertEquals(
'../should/remain/relative',
( new URL( 'relative/../../should/remain/relative' ) )->get_path(),
'Simplifies a relative path containing directory traversals to the extent possible (without inspecting the filesystem).'
);
}
public function test_can_get_normalized_string_representation() {
$this->assertEquals(
'foo/bar/baz',
( new URL( 'foo/bar//baz' ) )->get_path(),
'Empty segments are discarded, remains as a relative path.'
);
$this->assertEquals(
'/foo/ /bar/ /baz/foobarbaz',
( new URL( '///foo/ /bar/ /baz//foobarbaz' ) )->get_path(),
'Empty segments are discarded, non-empty segments containing only whitespace are preserved, remains as an absolute path.'
);
$this->assertEquals(
'c:/Windows/Server/HTTP/dump.xml',
( new URL( 'c:\\Windows\Server\HTTP\dump.xml' ) )->get_path(),
'String representations of Windows filepaths have forward slash separators and preserve the drive letter.'
);
}
public function test_can_get_normalized_url_representation() {
$this->assertEquals(
'file://relative/path',
( new URL( 'relative/path' ) )->get_url(),
'Can obtain a URL representation of a relative filepath, even when the initial string was a plain filepath.'
);
$this->assertEquals(
'file:///absolute/path',
( new URL( '/absolute/path' ) )->get_url(),
'Can obtain a URL representation of an absolute filepath, even when the initial string was a plain filepath.'
);
$this->assertEquals(
'file:///etc/foo/bar',
( new URL( 'file:///etc/foo/bar' ) )->get_url(),
'Can obtain a URL representation of a filepath, when the source filepath was also expressed as a URL.'
);
}
public function test_handling_of_percent_encoded_periods() {
$this->assertEquals(
'https://foo.bar/asset.txt',
( new URL( 'https://foo.bar/parent/.%2e/asset.txt' ) )->get_url(),
'Directory traversals expressed using percent-encoding are still resolved (lowercase, one encoded period).'
);
$this->assertEquals(
'https://foo.bar/asset.txt',
( new URL( 'https://foo.bar/parent/%2E./asset.txt' ) )->get_url(),
'Directory traversals expressed using percent-encoding are still resolved (uppercase, one encoded period).'
);
$this->assertEquals(
'https://foo.bar/asset.txt',
( new URL( 'https://foo.bar/parent/%2E%2e/asset.txt' ) )->get_url(),
'Directory traversals expressed using percent-encoding are still resolved (mixed case, both periods encoded).'
);
$this->assertEquals(
'https://foo.bar/parent/%2E.%2fasset.txt',
( new URL( 'https://foo.bar/parent/%2E.%2fasset.txt' ) )->get_url(),
'If the forward slash after a double period is URL encoded, there is no directory traversal (since this means the slash is a part of the segment and is not a separator).'
);
$this->assertEquals(
'file:///var/www/network/%2econfig',
( new URL( '/var/www/network/%2econfig' ) )->get_url(),
'Use of percent-encoding in URLs is accepted and unnecessary conversion does not take place.'
);
}
public function test_can_obtain_parent_url() {
$this->assertEquals(
'file:///',
( new URL( '/' ) )->get_parent_url(),
'The parent of root directory "/" is "/".'
);
$this->assertEquals(
'file:///var/',
( new URL( '/var/dev/' ) )->get_parent_url(),
'The parent URL will be trailingslashed.'
);
$this->assertEquals(
'https://example.com/',
( new URL( 'https://example.com' ) )->get_parent_url(),
'The host name (for non-file URLs) is distinct from the path and will not be removed.'
);
}
public function test_can_obtain_all_parent_urls() {
$this->assertEquals(
array(
'https://local.web/wp-content/uploads/woocommerce_uploads/pdf_bucket/',
'https://local.web/wp-content/uploads/woocommerce_uploads/',
'https://local.web/wp-content/uploads/',
'https://local.web/wp-content/',
'https://local.web/',
),
( new URL( 'https://local.web/wp-content/uploads/woocommerce_uploads/pdf_bucket/secret-sauce.pdf' ) )->get_all_parent_urls(),
'All parent URLs can be derived, but the host name is never stripped.'
);
$this->assertEquals(
array(
'file:///srv/websites/my.wp.site/public/',
'file:///srv/websites/my.wp.site/',
'file:///srv/websites/',
'file:///srv/',
'file:///',
),
( new URL( '/srv/websites/my.wp.site/public/test-file.doc' ) )->get_all_parent_urls(),
'All parent URLs can be derived for a filepath, up to and including the root directory.'
);
$this->assertEquals(
array(
'file://C:/Documents/Web/TestSite/',
'file://C:/Documents/Web/',
'file://C:/Documents/',
'file://C:/',
),
( new URL( 'C:\\Documents\\Web\\TestSite\\BackgroundTrack.mp3' ) )->get_all_parent_urls(),
'All parent URLs can be derived for a filepath, up to and including the root directory plus drive letter (Windows).'
);
}
}