first commit

This commit is contained in:
User A0264400
2026-04-01 23:20:16 +03:00
commit a766acdc90
23071 changed files with 4933189 additions and 0 deletions

View File

@@ -0,0 +1,126 @@
<?php
declare( strict_types=1 );
namespace Automattic\WooCommerce\Internal\Utilities;
/**
* A class of utilities for dealing with arrays.
*/
class ArrayUtil {
/**
* Determines if the given array is a list.
*
* An array is considered a list if its keys consist of consecutive numbers from 0 to count($array)-1.
*
* Polyfill for array_is_list() in PHP 8.1.
*
* @param array $arr The array being evaluated.
*
* @return bool True if array is a list, false otherwise.
*/
public static function array_is_list( array $arr ): bool {
if ( function_exists( 'array_is_list' ) ) {
return array_is_list( $arr );
}
if ( ( array() === $arr ) || ( array_values( $arr ) === $arr ) ) {
return true;
}
$next_key = -1;
foreach ( $arr as $k => $v ) {
if ( ++$next_key !== $k ) {
return false;
}
}
return true;
}
/**
* Merge two lists of associative arrays by a key.
*
* @param array $arr1 The first array.
* @param array $arr2 The second array.
* @param string $key The key to merge by.
*
* @return array The merged list sorted by the key values.
*/
public static function merge_by_key( array $arr1, array $arr2, string $key ): array {
$merged = array();
// Overwrite items in $arr1 with items in $arr2 if they have the same key entry value.
// The rest of items in $arr1 will be appended.
foreach ( $arr1 as $item1 ) {
$found = false;
foreach ( $arr2 as $item2 ) {
if ( $item1[ $key ] === $item2[ $key ] ) {
$merged[] = array_merge( $item1, $item2 );
$found = true;
break;
}
}
if ( ! $found ) {
$merged[] = $item1;
}
}
// Append items from $arr2 that are don't have a corresponding key entry value in $arr1.
foreach ( $arr2 as $item2 ) {
$found = false;
foreach ( $arr1 as $item1 ) {
if ( $item1[ $key ] === $item2[ $key ] ) {
$found = true;
break;
}
}
if ( ! $found ) {
$merged[] = $item2;
}
}
// Sort the merged list by the key values.
usort(
$merged,
function ( $a, $b ) use ( $key ) {
return $a[ $key ] <=> $b[ $key ];
}
);
return array_values( $merged );
}
/**
* Recursively filters null values from an array.
*
* This method removes all null values from the array, including nested arrays.
* Array keys are preserved for associative arrays. For lists (sequential numeric
* keys starting from 0), the array is reindexed to maintain the list structure.
*
* @param array $arr The array to filter.
*
* @return array The filtered array with null values removed.
*/
public static function filter_null_values_recursive( array $arr ): array {
$is_list = self::array_is_list( $arr );
$filtered = array();
foreach ( $arr as $key => $value ) {
// Skip null values.
if ( is_null( $value ) ) {
continue;
}
// Recursively filter nested arrays.
if ( is_array( $value ) ) {
$filtered[ $key ] = self::filter_null_values_recursive( $value );
} else {
$filtered[ $key ] = $value;
}
}
// Reindex if the original array was a list.
return $is_list ? array_values( $filtered ) : $filtered;
}
}

View File

@@ -0,0 +1,86 @@
<?php
declare( strict_types = 1 );
namespace Automattic\WooCommerce\Internal\Utilities;
/**
* Helper functions for working with blocks.
*/
class BlocksUtil {
/**
* Return blocks with their inner blocks flattened.
*
* @param array $blocks Array of blocks as returned by parse_blocks().
* @return array All blocks.
*/
public static function flatten_blocks( $blocks ) {
return array_reduce(
$blocks,
function ( $carry, $block ) {
array_push( $carry, array_diff_key( $block, array_flip( array( 'innerBlocks' ) ) ) );
if ( isset( $block['innerBlocks'] ) ) {
$inner_blocks = self::flatten_blocks( $block['innerBlocks'] );
return array_merge( $carry, $inner_blocks );
}
return $carry;
},
array()
);
}
/**
* Get all instances of the specified block from the widget area.
*
* @param string $block_name The name (id) of a block, e.g. `woocommerce/mini-cart`.
* @return array Array of blocks as returned by parse_blocks().
*/
public static function get_blocks_from_widget_area( $block_name ) {
$blocks = get_option( 'widget_block' );
if ( ! is_array( $blocks ) || empty( $blocks ) ) {
return array();
}
return array_reduce(
$blocks,
function ( $acc, $block ) use ( $block_name ) {
$parsed_blocks = ! empty( $block['content'] ) ? parse_blocks( $block['content'] ) : array();
if ( ! empty( $parsed_blocks ) && $block_name === $parsed_blocks[0]['blockName'] ) {
array_push( $acc, $parsed_blocks[0] );
}
return $acc;
},
array()
);
}
/**
* Get all instances of the specified block on a specific template part.
*
* @param string $block_name The name (id) of a block, e.g. `woocommerce/mini-cart`.
* @param string $template_part_slug The woo page to search, e.g. `header`.
* @return array Array of blocks as returned by parse_blocks().
*/
public static function get_block_from_template_part( $block_name, $template_part_slug ) {
$template = get_block_template( get_stylesheet() . '//' . $template_part_slug, 'wp_template_part' );
if ( ! $template || null === $template->content ) {
return array();
}
$blocks = parse_blocks( $template->content );
$flatten_blocks = self::flatten_blocks( $blocks );
return array_values(
array_filter(
$flatten_blocks,
function ( $block ) use ( $block_name ) {
return ( $block_name === $block['blockName'] );
}
)
);
}
}

View File

@@ -0,0 +1,203 @@
<?php
/**
* Utility functions meant for helping in migration from posts tables to custom order tables.
*/
namespace Automattic\WooCommerce\Internal\Utilities;
use Automattic\WooCommerce\Internal\DataStores\Orders\CustomOrdersTableController;
use Automattic\WooCommerce\Internal\DataStores\Orders\{ DataSynchronizer, OrdersTableDataStore };
use WC_Order;
use WP_Post;
/**
* Utility functions meant for helping in migration from posts tables to custom order tables.
*/
class COTMigrationUtil {
/**
* Custom order table controller.
*
* @var CustomOrdersTableController
*/
private $table_controller;
/**
* Data synchronizer.
*
* @var DataSynchronizer
*/
private $data_synchronizer;
/**
* Initialize method, invoked by the DI container.
*
* @internal Automatically called by the container.
* @param CustomOrdersTableController $table_controller Custom order table controller.
* @param DataSynchronizer $data_synchronizer Data synchronizer.
*
* @return void
*/
final public function init( CustomOrdersTableController $table_controller, DataSynchronizer $data_synchronizer ) {
$this->table_controller = $table_controller;
$this->data_synchronizer = $data_synchronizer;
}
/**
* Helper function to get screen name of orders page in wp-admin.
*
* @throws \Exception If called from outside of wp-admin.
*
* @return string
*/
public function get_order_admin_screen() : string {
if ( ! is_admin() ) {
throw new \Exception( 'This function should only be called in admin.' );
}
return $this->custom_orders_table_usage_is_enabled() && function_exists( 'wc_get_page_screen_id' )
? wc_get_page_screen_id( 'shop-order' )
: 'shop_order';
}
/**
* Helper function to get whether custom order tables are enabled or not.
*
* @return bool
*/
private function custom_orders_table_usage_is_enabled() : bool {
return $this->table_controller->custom_orders_table_usage_is_enabled();
}
/**
* Checks if posts and order custom table sync is enabled and there are no pending orders.
*
* @return bool
*/
public function is_custom_order_tables_in_sync() : bool {
if ( ! $this->data_synchronizer->data_sync_is_enabled() ) {
return false;
}
return ! $this->data_synchronizer->has_orders_pending_sync();
}
/**
* Gets value of a meta key from WC_Data object if passed, otherwise from the post object.
* This helper function support backward compatibility for meta box functions, when moving from posts based store to custom tables.
*
* @param WP_Post|null $post Post object, meta will be fetched from this only when `$data` is not passed.
* @param \WC_Data|null $data WC_Data object, will be preferred over post object when passed.
* @param string $key Key to fetch metadata for.
* @param bool $single Whether metadata is single.
*
* @return array|mixed|string Value of the meta key.
*/
public function get_post_or_object_meta( ?WP_Post $post, ?\WC_Data $data, string $key, bool $single ) {
if ( isset( $data ) ) {
if ( method_exists( $data, "get$key" ) ) {
return $data->{"get$key"}();
}
return $data->get_meta( $key, $single );
} else {
return isset( $post->ID ) ? get_post_meta( $post->ID, $key, $single ) : false;
}
}
/**
* Helper function to initialize the global $theorder object, mostly used during order meta boxes rendering.
*
* @param WC_Order|WP_Post $post_or_order_object Post or order object.
*
* @return bool|WC_Order|WC_Order_Refund WC_Order object.
*/
public function init_theorder_object( $post_or_order_object ) {
global $theorder;
if ( $theorder instanceof WC_Order ) {
return $theorder;
}
if ( $post_or_order_object instanceof WC_Order ) {
$theorder = $post_or_order_object;
} else {
$theorder = wc_get_order( $post_or_order_object->ID );
}
return $theorder;
}
/**
* Helper function to get ID from a post or order object.
*
* @param WP_Post/WC_Order $post_or_order_object WP_Post/WC_Order object to get ID for.
*
* @return int Order or post ID.
*/
public function get_post_or_order_id( $post_or_order_object ) : int {
if ( is_numeric( $post_or_order_object ) ) {
return (int) $post_or_order_object;
} elseif ( $post_or_order_object instanceof WC_Order ) {
return $post_or_order_object->get_id();
} elseif ( $post_or_order_object instanceof WP_Post ) {
return $post_or_order_object->ID;
}
return 0;
}
/**
* Checks if passed id, post or order object is a WC_Order object.
*
* @param int|WP_Post|WC_Order $order_id Order ID, post object or order object.
* @param string[] $types Types to match against.
*
* @return bool Whether the passed param is an order.
*/
public function is_order( $order_id, array $types = array( 'shop_order' ) ) : bool {
$order_id = $this->get_post_or_order_id( $order_id );
$order_data_store = \WC_Data_Store::load( 'order' );
return in_array( $order_data_store->get_order_type( $order_id ), $types, true );
}
/**
* Returns type pf passed id, post or order object.
*
* @param int|WP_Post|WC_Order $order_id Order ID, post object or order object.
*
* @return string|null Type of the order.
*/
public function get_order_type( $order_id ) {
$order_id = $this->get_post_or_order_id( $order_id );
$order_data_store = \WC_Data_Store::load( 'order' );
return $order_data_store->get_order_type( $order_id );
}
/**
* Get the name of the database table that's currently in use for orders.
*
* @return string
*/
public function get_table_for_orders() {
if ( $this->custom_orders_table_usage_is_enabled() ) {
$table_name = OrdersTableDataStore::get_orders_table_name();
} else {
global $wpdb;
$table_name = $wpdb->posts;
}
return $table_name;
}
/**
* Get the name of the database table that's currently in use for orders.
*
* @return string
*/
public function get_table_for_order_meta() {
if ( $this->custom_orders_table_usage_is_enabled() ) {
$table_name = OrdersTableDataStore::get_meta_table_name();
} else {
global $wpdb;
$table_name = $wpdb->postmeta;
}
return $table_name;
}
}

View File

@@ -0,0 +1,440 @@
<?php
/**
* DatabaseUtil class file.
*/
namespace Automattic\WooCommerce\Internal\Utilities;
use DateTime;
use DateTimeZone;
use Vtiful\Kernel\Format;
/**
* A class of utilities for dealing with the database.
*/
class DatabaseUtil {
/**
* Wrapper for the WordPress dbDelta function, allows to execute a series of SQL queries.
*
* @param string $queries The SQL queries to execute.
* @param bool $execute Ture to actually execute the queries, false to only simulate the execution.
* @return array The result of the execution (or simulation) from dbDelta.
*/
public function dbdelta( string $queries = '', bool $execute = true ): array {
require_once ABSPATH . 'wp-admin/includes/upgrade.php';
return dbDelta( $queries, $execute );
}
/**
* Given a set of table creation SQL statements, check which of the tables are currently missing in the database.
*
* @param string $creation_queries The SQL queries to execute ("CREATE TABLE" statements, same format as for dbDelta).
* @return array An array containing the names of the tables that currently don't exist in the database.
*/
public function get_missing_tables( string $creation_queries ): array {
global $wpdb;
$suppress_errors = $wpdb->suppress_errors( true );
$dbdelta_output = $this->dbdelta( $creation_queries, false );
$wpdb->suppress_errors( $suppress_errors );
$parsed_output = $this->parse_dbdelta_output( $dbdelta_output );
return $parsed_output['created_tables'];
}
/**
* Parses the output given by dbdelta and returns information about it.
*
* @param array $dbdelta_output The output from the execution of dbdelta.
* @return array[] An array containing a 'created_tables' key whose value is an array with the names of the tables that have been (or would have been) created.
*/
public function parse_dbdelta_output( array $dbdelta_output ): array {
$created_tables = array();
foreach ( $dbdelta_output as $table_name => $result ) {
if ( "Created table $table_name" === $result ) {
$created_tables[] = str_replace( '(', '', $table_name );
}
}
return array( 'created_tables' => $created_tables );
}
/**
* Drops a database table.
*
* @param string $table_name The name of the table to drop.
* @param bool $add_prefix True if the table name passed needs to be prefixed with $wpdb->prefix before processing.
* @return bool True on success, false on error.
*/
public function drop_database_table( string $table_name, bool $add_prefix = false ) {
global $wpdb;
if ( $add_prefix ) {
$table_name = $wpdb->prefix . $table_name;
}
//phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
return $wpdb->query( "DROP TABLE IF EXISTS `{$table_name}`" );
}
/**
* Drops a table index, if both the table and the index exist.
*
* @param string $table_name The name of the table that contains the index.
* @param string $index_name The name of the index to be dropped.
* @return bool True if the index has been dropped, false if either the table or the index don't exist.
*/
public function drop_table_index( string $table_name, string $index_name ): bool {
global $wpdb;
if ( empty( $this->get_index_columns( $table_name, $index_name ) ) ) {
return false;
}
// phpcs:ignore WordPress.DB.PreparedSQL
$wpdb->query( "ALTER TABLE $table_name DROP INDEX $index_name" );
return true;
}
/**
* Create a primary key for a table, only if the table doesn't have a primary key already.
*
* @param string $table_name Table name.
* @param array $columns An array with the index column names.
* @return bool True if the key has been created, false if the table already had a primary key.
*/
public function create_primary_key( string $table_name, array $columns ) {
global $wpdb;
if ( ! empty( $this->get_index_columns( $table_name ) ) ) {
return false;
}
// phpcs:ignore WordPress.DB.PreparedSQL
$wpdb->query( "ALTER TABLE $table_name ADD PRIMARY KEY(`" . join( '`,`', $columns ) . '`)' );
return true;
}
/**
* Get the columns of a given table index, or of the primary key.
*
* @param string $table_name Table name.
* @param string $index_name Index name, empty string for the primary key.
* @return array The index columns. Empty array if the table or the index don't exist.
*/
public function get_index_columns( string $table_name, string $index_name = '' ): array {
global $wpdb;
if ( empty( $index_name ) ) {
$index_name = 'PRIMARY';
}
// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
$results = $wpdb->get_results( $wpdb->prepare( "SHOW INDEX FROM $table_name WHERE Key_name = %s", $index_name ) );
if ( empty( $results ) ) {
return array();
}
return array_column( $results, 'Column_name' );
}
/**
* Formats an object value of type `$type` for inclusion in the database.
*
* @param mixed $value Raw value.
* @param string $type Data type.
* @return mixed
* @throws \Exception When an invalid type is passed.
*/
public function format_object_value_for_db( $value, string $type ) {
switch ( $type ) {
case 'decimal':
$value = wc_format_decimal( $value, false, true );
break;
case 'int':
$value = (int) $value;
break;
case 'bool':
$value = wc_string_to_bool( $value );
break;
case 'string':
$value = strval( $value );
break;
case 'date':
// Date properties are converted to the WP timezone (see WC_Data::set_date_prop() method), however
// for our own tables we persist dates in GMT.
$value = $value ? ( new DateTime( $value ) )->setTimezone( new DateTimeZone( '+00:00' ) )->format( 'Y-m-d H:i:s' ) : null;
break;
case 'date_epoch':
$value = $value ? ( new DateTime( "@{$value}" ) )->format( 'Y-m-d H:i:s' ) : null;
break;
default:
throw new \Exception( esc_html( 'Invalid type received: ' . $type ) );
}
return $value;
}
/**
* Returns the `$wpdb` placeholder to use for data type `$type`.
*
* @param string $type Data type.
* @return string
* @throws \Exception When an invalid type is passed.
*/
public function get_wpdb_format_for_type( string $type ) {
static $wpdb_placeholder_for_type = array(
'int' => '%d',
'decimal' => '%f',
'string' => '%s',
'date' => '%s',
'date_epoch' => '%s',
'bool' => '%d',
);
if ( ! isset( $wpdb_placeholder_for_type[ $type ] ) ) {
throw new \Exception( esc_html( 'Invalid column type: ' . $type ) );
}
return $wpdb_placeholder_for_type[ $type ];
}
/**
* Generates ON DUPLICATE KEY UPDATE clause to be used in migration.
*
* @param array $columns List of column names.
*
* @return string SQL clause for INSERT...ON DUPLICATE KEY UPDATE
*/
public function generate_on_duplicate_statement_clause( array $columns ): string {
$update_value_statements = array();
foreach ( $columns as $column ) {
$update_value_statements[] = "`$column` = VALUES( `$column` )";
}
$update_value_clause = implode( ', ', $update_value_statements );
return "ON DUPLICATE KEY UPDATE $update_value_clause";
}
/**
* Hybrid of $wpdb->update and $wpdb->insert. It will try to update a row, and if it doesn't exist, it will insert it. This needs unique constraints to be set on the table on all ID columns.
*
* You can use this function only when:
* 1. There is only one unique constraint on the table. The constraint can contain multiple columns, but it must be the only one unique constraint.
* 2. The complete unique constraint must be part of the $data array.
* 3. You do not need the LAST_INSERT_ID() value.
*
* @param string $table_name Table name.
* @param array $data Unescaped data to update (in column => value pairs).
* @param array $format An array of formats to be mapped to each of the values in $data.
*
* @return int Returns the value of DB's ON DUPLICATE KEY UPDATE clause.
*/
public function insert_on_duplicate_key_update( $table_name, $data, $format ): int {
global $wpdb;
if ( empty( $data ) ) {
return 0;
}
$columns = array_keys( $data );
$value_format = array();
$values = array();
$index = 0;
// Directly use NULL for placeholder if the value is NULL, since otherwise $wpdb->prepare will convert it to empty string.
foreach ( $data as $key => $value ) {
if ( is_null( $value ) ) {
$value_format[] = 'NULL';
} else {
$values[] = $value;
$value_format[] = $format[ $index ];
}
++$index;
}
$column_clause = '`' . implode( '`, `', $columns ) . '`';
$value_format_clause = implode( ', ', $value_format );
$on_duplicate_clause = $this->generate_on_duplicate_statement_clause( $columns );
// phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare -- Values are escaped in $wpdb->prepare.
$sql = $wpdb->prepare(
"
INSERT INTO $table_name ( $column_clause )
VALUES ( $value_format_clause )
$on_duplicate_clause
",
$values
);
// phpcs:enable
// phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- $sql is prepared.
return $wpdb->query( $sql );
}
/**
* Hybrid of $wpdb->update and $wpdb->insert. It will try to update a row, and if it doesn't exist, it will insert it. Unlike `insert_on_duplicate_key_update` it does not require a unique constraint, but also does not guarantee uniqueness on its own.
*
* When a unique constraint is present, it will perform better than the `insert_on_duplicate_key_update` since it needs fewer locks.
*
* Note that it will only update at max just 1 database row, unlike `wpdb->update` which updates everything that matches the `$where` criteria. This is also why it needs a primary_key_column.
*
* @param string $table_name Table Name.
* @param array $data Data to insert update in array($column_name => $value) format.
* @param array $where Update conditions in array($column_name => $value) format. Conditions will be joined by AND.
* @param array $format Format strings for data. Unlike $wpdb->update/insert, this method won't guess the format, and has to be provided explicitly.
* @param array $where_format Format strings for where conditions. Unlike $wpdb->update/insert, this method won't guess the format, and has to be provided explicitly.
* @param string $primary_key_column Name of the Primary key column.
* @param string $primary_key_format Format for primary key.
*
* @return bool|int Number of rows affected. Boolean false on error.
*/
public function insert_or_update( $table_name, $data, $where, $format, $where_format, $primary_key_column = 'id', $primary_key_format = '%d' ) {
global $wpdb;
if ( empty( $data ) || empty( $where ) ) {
return 0;
}
// Build select query.
$values = array();
$index = 0;
$conditions = array();
foreach ( $where as $column => $value ) {
if ( is_null( $value ) ) {
$conditions[] = "`$column` IS NULL";
continue;
}
$conditions[] = "`$column` = " . $where_format[ $index ];
$values[] = $value;
++$index;
}
$conditions = implode( ' AND ', $conditions );
// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare -- $primary_key_column and $table_name are hardcoded. $conditions is being prepared.
$query = $wpdb->prepare( "SELECT `$primary_key_column` FROM `$table_name` WHERE $conditions LIMIT 1", $values );
// phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- $query is prepared above.
$row_id = $wpdb->get_var( $query );
if ( $row_id ) {
// Update the row.
$result = $wpdb->update( $table_name, $data, array( $primary_key_column => $row_id ), $format, array( $primary_key_format ) );
} else {
// Insert the row.
$result = $wpdb->insert( $table_name, $data, $format );
}
return $result;
}
/**
* Get max index length.
*
* @return int Max index length.
*/
public function get_max_index_length(): int {
/**
* Filters the maximum index length in the database.
*
* Indexes have a maximum size of 767 bytes. Historically, we haven't need to be concerned about that.
* As of WP 4.2, however, they moved to utf8mb4, which uses 4 bytes per character. This means that an index which
* used to have room for floor(767/3) = 255 characters, now only has room for floor(767/4) = 191 characters.
*
* Additionally, MyISAM engine also limits the index size to 1000 bytes. We add this filter so that interested folks on InnoDB engine can increase the size till allowed 3071 bytes.
*
* @param int $max_index_length Maximum index length. Default 191.
*
* @since 8.0.0
*/
$max_index_length = apply_filters( 'woocommerce_database_max_index_length', 191 );
// Index length cannot be more than 768, which is 3078 bytes in utf8mb4 and max allowed by InnoDB engine.
return min( absint( $max_index_length ), 767 );
}
/**
* Create a fulltext index on order address table.
*
* @return void
*/
public function create_fts_index_order_address_table(): void {
global $wpdb;
$address_table = $wpdb->prefix . 'wc_order_addresses';
// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- $address_table is hardcoded.
$wpdb->query( "CREATE FULLTEXT INDEX order_addresses_fts ON $address_table (first_name, last_name, company, address_1, address_2, city, state, postcode, country, email, phone)" );
}
/**
* Helper method to drop the fulltext index on order address table.
*
* @since 9.4.0
*
* @return void
*/
public function drop_fts_index_order_address_table(): void {
global $wpdb;
$address_table = $wpdb->prefix . 'wc_order_addresses';
// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- $address_table is hardcoded.
$wpdb->query( "ALTER TABLE $address_table DROP INDEX order_addresses_fts;" );
}
/**
* Sanitize FTS Search params to remove relevancy operators for performance, and add partial matches. Useful when the sorting is already happening based on some other conditions, so relevancy calculation is not needed.
*
* @since 9.4.0
*
* @param string $param Search term.
*
* @return string Sanitized search term.
*/
public function sanitise_boolean_fts_search_term( string $param ): string {
// Remove any operator to prevent incorrect query and fatals, such as search starting with `++`. We can allow this in the future if we have proper validation for FTS search operators.
// Space is allowed to provide multiple words.
$sanitized_param = preg_replace( '/[^\p{L}\p{N}_]+/u', ' ', $param );
if ( $sanitized_param !== $param ) {
$param = str_replace( '"', '', $param );
return '"' . $param . '"';
}
// Split the search phrase into words so that we can add operators when needed.
$words = explode( ' ', $param );
$sanitized_words = array();
foreach ( $words as $word ) {
// Add `*` as suffix to every term so that partial matches happens.
$word = $word . '*';
$sanitized_words[] = $word;
}
return implode( ' ', $sanitized_words );
}
/**
* Check if fulltext index with key `order_addresses_fts` on order address table exists.
*
* @return bool
*/
public function fts_index_on_order_address_table_exists(): bool {
global $wpdb;
$address_table = $wpdb->prefix . 'wc_order_addresses';
// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- $address_table is hardcoded.
return ! empty( $wpdb->get_results( "SHOW INDEX FROM $address_table WHERE Key_name = 'order_addresses_fts'" ) );
}
/**
* Create a fulltext index on order item table.
*
* @return void
*/
public function create_fts_index_order_item_table(): void {
global $wpdb;
$order_item_table = $wpdb->prefix . 'woocommerce_order_items';
// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- $order_item_table is hardcoded.
$wpdb->query( "CREATE FULLTEXT INDEX order_item_fts ON $order_item_table (order_item_name)" );
}
/**
* Check if fulltext index with key `order_item_fts` on order item table exists.
*
* @return bool
*/
public function fts_index_on_order_item_table_exists(): bool {
global $wpdb;
$order_item_table = $wpdb->prefix . 'woocommerce_order_items';
// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- $order_item_table is hardcoded.
return ! empty( $wpdb->get_results( "SHOW INDEX FROM $order_item_table WHERE Key_name = 'order_item_fts'" ) );
}
}

View File

@@ -0,0 +1,175 @@
<?php
declare( strict_types = 1 );
namespace Automattic\WooCommerce\Internal\Utilities;
use Automattic\Jetpack\Constants;
use Automattic\WooCommerce\Proxies\LegacyProxy;
use Exception;
use WP_Filesystem_Base;
/**
* FilesystemUtil class.
*/
class FilesystemUtil {
/**
* Wrapper to retrieve the class instance contained in the $wp_filesystem global, after initializing if necessary.
*
* @return WP_Filesystem_Base
* @throws Exception Thrown when the filesystem fails to initialize.
*/
public static function get_wp_filesystem(): WP_Filesystem_Base {
global $wp_filesystem;
if ( ! $wp_filesystem instanceof WP_Filesystem_Base ) {
$initialized = self::initialize_wp_filesystem();
if ( false === $initialized ) {
throw new Exception( 'The WordPress filesystem could not be initialized.' );
}
}
return $wp_filesystem;
}
/**
* Get the WP filesystem method, with a fallback to 'direct' if no FS_METHOD constant exists and there are not FTP related options/credentials set.
*
* @return string|false The name of the WP filesystem method to use.
*/
public static function get_wp_filesystem_method_or_direct() {
$proxy = wc_get_container()->get( LegacyProxy::class );
if ( ! self::constant_exists( 'FS_METHOD' ) && false === $proxy->call_function( 'get_option', 'ftp_credentials' ) && ! self::constant_exists( 'FTP_HOST' ) ) {
return 'direct';
}
$method = $proxy->call_function( 'get_filesystem_method' );
if ( $method ) {
return $method;
}
return 'direct';
}
/**
* Check if a constant exists and is not null.
*
* @param string $name Constant name.
* @return bool True if the constant exists and its value is not null.
*/
private static function constant_exists( string $name ): bool {
return Constants::is_defined( $name ) && ! is_null( Constants::get_constant( $name ) );
}
/**
* Recursively creates a directory (if it doesn't exist) and adds an empty index.html and a .htaccess to prevent
* directory listing.
*
* @since 9.3.0
*
* @param string $path Directory to create.
* @param bool $allow_file_access Whether to allow file access while preventing directory listing. Default false (deny all access).
* @throws \Exception In case of error.
*/
public static function mkdir_p_not_indexable( string $path, bool $allow_file_access = false ): void {
$wp_fs = self::get_wp_filesystem();
if ( $wp_fs->is_dir( $path ) ) {
return;
}
if ( ! wp_mkdir_p( $path ) ) {
throw new \Exception( esc_html( sprintf( 'Could not create directory: %s.', wp_basename( $path ) ) ) );
}
$htaccess_content = $allow_file_access ? 'Options -Indexes' : 'deny from all';
$files = array(
'.htaccess' => $htaccess_content,
'index.html' => '',
);
foreach ( $files as $name => $content ) {
$wp_fs->put_contents( trailingslashit( $path ) . $name, $content );
}
}
/**
* Wrapper to initialize the WP filesystem with defined credentials if they are available.
*
* @return bool True if the $wp_filesystem global was successfully initialized.
*/
protected static function initialize_wp_filesystem(): bool {
global $wp_filesystem;
if ( $wp_filesystem instanceof WP_Filesystem_Base ) {
return true;
}
require_once ABSPATH . 'wp-admin/includes/file.php';
$method = self::get_wp_filesystem_method_or_direct();
$initialized = false;
if ( 'direct' === $method ) {
$initialized = WP_Filesystem();
} elseif ( false !== $method ) {
// See https://core.trac.wordpress.org/changeset/56341.
ob_start();
$credentials = request_filesystem_credentials( '' );
ob_end_clean();
$initialized = $credentials && WP_Filesystem( $credentials );
}
return is_null( $initialized ) ? false : $initialized;
}
/**
* Validate that a file path is a valid upload path.
*
* @param string $path The path to validate.
* @throws \Exception If the file path is not a valid upload path.
*/
public static function validate_upload_file_path( string $path ): void {
$wp_filesystem = self::get_wp_filesystem();
// File must exist and be readable.
$is_valid_file = $wp_filesystem->is_readable( $path );
// Check that file is within an allowed location.
if ( $is_valid_file ) {
$is_valid_file = self::file_is_in_directory( $path, $wp_filesystem->abspath() );
if ( ! $is_valid_file ) {
$upload_dir = wp_get_upload_dir();
$is_valid_file = false === $upload_dir['error'] && self::file_is_in_directory( $path, $upload_dir['basedir'] );
}
}
if ( ! $is_valid_file ) {
throw new \Exception( esc_html__( 'File path is not a valid upload path.', 'woocommerce' ) );
}
}
/**
* Check if a given file is inside a given directory.
*
* @param string $file_path The full path of the file to check.
* @param string $directory The path of the directory to check.
* @return bool True if the file is inside the directory.
*/
private static function file_is_in_directory( string $file_path, string $directory ): bool {
// Extract protocol if it exists.
$protocol = '';
if ( preg_match( '#^([a-z0-9]+://)#i', $file_path, $matches ) ) {
$protocol = $matches[1];
$file_path = preg_replace( '#^[a-z0-9]+://#i', '', $file_path );
}
$file_path = (string) new URL( $file_path ); // This resolves '/../' sequences.
$file_path = preg_replace( '/^file:\\/\\//', $protocol, $file_path );
$file_path = preg_replace( '/^file:\\/\\//', '', $file_path );
return 0 === stripos( wp_normalize_path( $file_path ), trailingslashit( wp_normalize_path( $directory ) ) );
}
}

View File

@@ -0,0 +1,102 @@
<?php
namespace Automattic\WooCommerce\Internal\Utilities;
/**
* Utility for re-using WP Kses-based sanitization rules.
*/
class HtmlSanitizer {
/**
* Rules for allowing minimal HTML (breaks, images, paragraphs and spans) without any links.
*/
public const LOW_HTML_BALANCED_TAGS_NO_LINKS = array(
'pre_processors' => array(
'stripslashes',
'force_balance_tags',
),
'wp_kses_rules' => array(
'br' => true,
'img' => array(
'alt' => true,
'class' => true,
'src' => true,
'title' => true,
),
'p' => array(
'class' => true,
),
'span' => array(
'class' => true,
'title' => true,
),
),
);
/**
* Sanitizes a chunk of HTML, by following the same rules as `wp_kses_post()` but also allowing
* the style element to be supplied.
*
* @param string $html The HTML to be sanitized.
*
* @return string
*/
public function styled_post_content( string $html ): string {
$rules = wp_kses_allowed_html( 'post' );
$rules['style'] = true;
return wp_kses( $html, $rules );
}
/**
* Sanitizes the HTML according to the provided rules.
*
* @see wp_kses()
*
* @param string $html HTML string to be sanitized.
* @param array $sanitizer_rules {
* Optional and defaults to self::TRIMMED_BALANCED_LOW_HTML_NO_LINKS. Otherwise, one or more of the following
* keys should be set.
*
* @type array $pre_processors Callbacks to run before invoking `wp_kses()`.
* @type array $wp_kses_rules Element names and attributes to allow, per `wp_kses()`.
* }
*
* @return string
*/
public function sanitize( string $html, array $sanitizer_rules = self::LOW_HTML_BALANCED_TAGS_NO_LINKS ): string {
if ( isset( $sanitizer_rules['pre_processors'] ) && is_array( $sanitizer_rules['pre_processors'] ) ) {
$html = $this->apply_string_callbacks( $sanitizer_rules['pre_processors'], $html );
}
// If no KSES rules are specified, assume all HTML should be stripped.
$kses_rules = isset( $sanitizer_rules['wp_kses_rules'] ) && is_array( $sanitizer_rules['wp_kses_rules'] )
? $sanitizer_rules['wp_kses_rules']
: array();
return wp_kses( $html, $kses_rules );
}
/**
* Applies callbacks used to process the string before and after wp_kses().
*
* If a callback is invalid we will short-circuit and return an empty string, on the grounds that it is better to
* output nothing than risky HTML. We also call the problem out via _doing_it_wrong() to highlight the problem (and
* increase the chances of this being caught during development).
*
* @param callable[] $callbacks The callbacks used to mutate the string.
* @param string $string The string being processed.
*
* @return string
*/
private function apply_string_callbacks( array $callbacks, string $string ): string {
foreach ( $callbacks as $callback ) {
if ( ! is_callable( $callback ) ) {
_doing_it_wrong( __CLASS__ . '::apply', esc_html__( 'String processors must be an array of valid callbacks.', 'woocommerce' ), esc_html( WC()->version ) );
return '';
}
$string = (string) $callback( $string );
}
return $string;
}
}

View File

@@ -0,0 +1,195 @@
<?php
namespace Automattic\WooCommerce\Internal\Utilities;
use Automattic\WooCommerce\Internal\RegisterHooksInterface;
use Automattic\WooCommerce\Utilities\RestApiUtil;
/**
* The Legacy REST API was removed in WooCommerce 9.0 and is now available as a dedicated extension.
* A stub is kept in WooCommerce core that acts when the extension is not installed and has two purposes:
*
* 1. Return a "The WooCommerce API is disabled on this site" error for any request to the Legacy REST API endpoints.
*
* 2. Provide the not-endpoint related utility methods that were previously supplied by the WC_API class,
* this is achieved by setting the value of WooCommerce::api (typically accessed via 'WC()->api') to an instance of this class.
*
* DO NOT add any additional public method to this class unless the method existed with the same signature in the old WC_API class.
*
* See: https://developer.woocommerce.com/2023/10/03/the-legacy-rest-api-will-move-to-a-dedicated-extension-in-woocommerce-9-0/
*/
class LegacyRestApiStub implements RegisterHooksInterface {
/**
* The instance of RestApiUtil to use.
*
* @var RestApiUtil
*/
private RestApiUtil $rest_api_util;
/**
* Set up the Legacy REST API endpoints stub.
*/
public function register() {
add_action( 'init', array( __CLASS__, 'add_rewrite_rules_for_legacy_rest_api_stub' ), 0 );
add_action( 'query_vars', array( __CLASS__, 'add_query_vars_for_legacy_rest_api_stub' ), 0 );
add_action( 'parse_request', array( __CLASS__, 'parse_legacy_rest_api_request' ), 0 );
}
/**
* Initialize the class dependencies.
*
* @internal
* @param RestApiUtil $rest_api_util The instance of RestApiUtil to use.
*/
final public function init( RestApiUtil $rest_api_util ) {
$this->rest_api_util = $rest_api_util;
}
/**
* Add the necessary rewrite rules for the Legacy REST API
* (either the dedicated extension if it's installed, or the stub otherwise).
*
* @internal For exclusive usage of WooCommerce core, backwards compatibility not guaranteed.
*/
public static function add_rewrite_rules_for_legacy_rest_api_stub() {
add_rewrite_rule( '^wc-api/v([1-3]{1})/?$', 'index.php?wc-api-version=$matches[1]&wc-api-route=/', 'top' );
add_rewrite_rule( '^wc-api/v([1-3]{1})(.*)?', 'index.php?wc-api-version=$matches[1]&wc-api-route=$matches[2]', 'top' );
add_rewrite_endpoint( 'wc-api', EP_ALL );
}
/**
* Add the necessary request query variables for the Legacy REST API
* (either the dedicated extension if it's installed, or the stub otherwise).
*
* @param array $vars The query variables array to extend.
* @return array The extended query variables array.
*
* @internal For exclusive usage of WooCommerce core, backwards compatibility not guaranteed.
*/
public static function add_query_vars_for_legacy_rest_api_stub( $vars ) {
$vars[] = 'wc-api-version';
$vars[] = 'wc-api-route';
$vars[] = 'wc-api';
return $vars;
}
/**
* Process an incoming request for the Legacy REST API.
*
* If the dedicated Legacy REST API extension is installed and active, this method does nothing.
* Otherwise it returns a "The WooCommerce API is disabled on this site" error,
* unless the request contains a "wc-api" variable and the appropriate
* "woocommerce_api_*" hook is set.
*
* @internal For exclusive usage of WooCommerce core, backwards compatibility not guaranteed.
*/
public static function parse_legacy_rest_api_request() {
global $wp;
// The WC_Legacy_REST_API_Plugin class existence means that the Legacy REST API extension is installed and active.
if ( class_exists( 'WC_Legacy_REST_API_Plugin' ) ) {
return;
}
self::maybe_process_wc_api_query_var();
// phpcs:disable WordPress.Security.NonceVerification.Recommended, WordPress.Security.ValidatedSanitizedInput
if ( ! empty( $_GET['wc-api-version'] ) ) {
$wp->query_vars['wc-api-version'] = $_GET['wc-api-version'];
}
if ( ! empty( $_GET['wc-api-route'] ) ) {
$wp->query_vars['wc-api-route'] = $_GET['wc-api-route'];
}
if ( ! empty( $wp->query_vars['wc-api-version'] ) && ! empty( $wp->query_vars['wc-api-route'] ) ) {
header(
sprintf(
'Content-Type: %s; charset=%s',
isset( $_GET['_jsonp'] ) ? 'application/javascript' : 'application/json',
get_option( 'blog_charset' )
)
);
status_header( 404 );
echo wp_json_encode(
array(
'errors' => array(
'code' => 'woocommerce_api_disabled',
'message' => 'The WooCommerce API is disabled on this site',
),
)
);
exit;
}
// phpcs:enable WordPress.Security.NonceVerification.Recommended, WordPress.Security.ValidatedSanitizedInput
}
/**
* Process a "wc-api" variable if present in the query, by triggering the appropriate hooks.
*/
private static function maybe_process_wc_api_query_var() {
global $wp;
// phpcs:disable WordPress.Security.NonceVerification.Recommended
if ( ! empty( $_GET['wc-api'] ) ) {
$wp->query_vars['wc-api'] = sanitize_key( wp_unslash( $_GET['wc-api'] ) );
}
// phpcs:enable WordPress.Security.NonceVerification.Recommended
// wc-api endpoint requests.
if ( ! empty( $wp->query_vars['wc-api'] ) ) {
// Buffer, we won't want any output here.
ob_start();
// No cache headers.
wc_nocache_headers();
// Clean the API request.
$api_request = strtolower( wc_clean( $wp->query_vars['wc-api'] ) );
// Make sure gateways are available for request.
WC()->payment_gateways();
// phpcs:disable WooCommerce.Commenting.CommentHooks.HookCommentWrongStyle
// Trigger generic action before request hook.
do_action( 'woocommerce_api_request', $api_request );
// Is there actually something hooked into this API request? If not trigger 400 - Bad request.
status_header( has_action( 'woocommerce_api_' . $api_request ) ? 200 : 400 );
// Trigger an action which plugins can hook into to fulfill the request.
do_action( 'woocommerce_api_' . $api_request );
// phpcs:enable WooCommerce.Commenting.CommentHooks.HookCommentWrongStyle
// Done, clear buffer and exit.
ob_end_clean();
die( '-1' );
}
}
/**
* Get data from a WooCommerce API endpoint.
* This method used to be part of the WooCommerce Legacy REST API.
*
* @since 9.1.0
*
* @param string $endpoint Endpoint.
* @param array $params Params to pass with request.
* @return array|\WP_Error
*/
public function get_endpoint_data( $endpoint, $params = array() ) {
wc_doing_it_wrong(
'get_endpoint_data',
"'WC()->api->get_endpoint_data' is deprecated, please use the following instead: wc_get_container()->get(Automattic\WooCommerce\Utilities\RestApiUtil::class)->get_endpoint_data",
'9.1.0'
);
return $this->rest_api_util->get_endpoint_data( $endpoint, $params );
}
}

View File

@@ -0,0 +1,322 @@
<?php
namespace Automattic\WooCommerce\Internal\Utilities;
use Automattic\WooCommerce\Internal\RegisterHooksInterface;
use Automattic\WooCommerce\Utilities\{ PluginUtil, StringUtil };
/**
* This class allows installing a plugin programmatically.
*
* Information about plugins installed in that way will be stored in a 'woocommerce_autoinstalled_plugins' option,
* and a notice will be shown under the plugin name in the plugins list indicating that it was automatically
* installed (these notices can be disabled with the 'woocommerce_show_autoinstalled_plugin_notices' hook).
*
* Currently it's only possible to install new plugins, not to upgrade or reinstall already installed plugins.
*
* The 'upgrader_process_complete' hook is used to remove the autoinstall information from any plugin that is later
* upgraded or reinstalled by any means other than the usage of this class.
*/
class PluginInstaller implements RegisterHooksInterface {
/**
* Flag indicating that a plugin install is in progress, so the upgrader_process_complete hook must be ignored.
*
* @var bool
*/
private bool $installing_plugin = false;
/**
* Attach hooks used by the class.
*/
public function register() {
add_action( 'after_plugin_row', array( $this, 'handle_plugin_list_rows' ), 10, 2 );
add_action( 'upgrader_process_complete', array( $this, 'handle_upgrader_process_complete' ), 10, 2 );
}
/**
* Programmatically installs a plugin. Upgrade/reinstall of already existing plugins is not supported.
* The plugin source must be the WordPress.org plugins directory.
*
* $metadata can contain anything, but the following keys are recognized by the code that renders the notice
* in the plugins list:
*
* - 'installed_by': defaults to 'WooCommerce' if not present.
* - 'info_link': if present, a "More information" link will be included in the notice.
*
* If 'installed_by' is supplied and it's not 'WooCommerce' (case-insensitive), an exception will be thrown
* if the code calling this method is not in a WooCommerce core file (in 'includes' or in 'src').
*
* Information about plugins successfully installed with this method will be kept in an option named
* 'woocommerce_autoinstalled_plugins'. Keys will be the plugin name and values will be associative arrays
* with these keys: 'plugin_name', 'version', 'date' and 'metadata' (same meaning as in the returned array).
*
* A log entry will be created with the result of the process and all the installer messages
* (source: 'plugin_auto_installs'). In multisite this log entry will be created on each site.
*
* The returned array will contain the following (only 'install_ok' and 'messages' if the installation fails):
*
* - 'install_ok', a boolean.
* - 'messages', all the messages generated by the installer.
* - 'plugin_name', in the form of 'directory/file.php' (taken from the instance of PluginInstaller used).
* - 'version', of the plugin that has been installed.
* - 'date', ISO-formatted installation date.
* - 'metadata', as supplied (except the 'plugin_name' key) and only if not empty.
*
* If the plugin is already in the process of being installed (can happen in multisite), the returned array
* will contain only one key: 'already_installing', with a value of true.
*
* @param string $plugin_url URL or file path of the plugin to install.
* @param array $metadata Metadata to store if the installation succeeds.
* @return array Information about the installation result.
* @throws \InvalidArgumentException Source doesn't start with 'https://downloads.wordpress.org/', or installer name is 'WooCommerce' but caller is not WooCommerce core code.
*/
public function install_plugin( string $plugin_url, array $metadata = array() ): array {
$this->installing_plugin = true;
$plugins_being_installed = get_site_option( 'woocommerce_autoinstalling_plugins', array() );
if ( in_array( $plugin_url, $plugins_being_installed, true ) ) {
return array( 'already_installing' => true );
}
$plugins_being_installed[] = $plugin_url;
update_site_option( 'woocommerce_autoinstalling_plugins', $plugins_being_installed );
try {
return $this->install_plugin_core( $plugin_url, $metadata );
} finally {
$plugins_being_installed = array_diff( $plugins_being_installed, array( $plugin_url ) );
if ( empty( $plugins_being_installed ) ) {
delete_site_option( 'woocommerce_autoinstalling_plugins' );
} else {
update_site_option( 'woocommerce_autoinstalling_plugins', $plugins_being_installed );
}
$this->installing_plugin = false;
}
}
/**
* Core version of 'install_plugin' (it doesn't handle the $installing_plugin flag).
*
* @param string $plugin_url URL or file path of the plugin to install.
* @param array $metadata Metadata to store if the installation succeeds.
* @return array Information about the installation result.
* @throws \InvalidArgumentException Source doesn't start with 'https://downloads.wordpress.org/', or installer name is 'WooCommerce' but caller is not WooCommerce core code.
*/
private function install_plugin_core( string $plugin_url, array $metadata ): array {
if ( ! StringUtil::starts_with( $plugin_url, 'https://downloads.wordpress.org/', false ) ) {
throw new \InvalidArgumentException( "Only installs from the WordPress.org plugins directory (plugin URL starting with 'https://downloads.wordpress.org/') are allowed." );
}
$installed_by = $metadata['installed_by'] ?? 'WooCommerce';
if ( 0 === strcasecmp( 'WooCommerce', $installed_by ) ) {
// phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_debug_backtrace
$calling_file = StringUtil::normalize_local_path_slashes( debug_backtrace()[1]['file'] ?? '' ); // [1], not [0], because the immediate caller is the install_plugin method.
if ( ! StringUtil::starts_with( $calling_file, StringUtil::normalize_local_path_slashes( WC_ABSPATH . 'includes/' ) ) && ! StringUtil::starts_with( $calling_file, StringUtil::normalize_local_path_slashes( WC_ABSPATH . 'src/' ) ) ) {
throw new \InvalidArgumentException( "If the value of 'installed_by' is 'WooCommerce', the caller of the method must be a WooCommerce core class or function." );
}
}
if ( ! class_exists( \Automatic_Upgrader_Skin::class ) ) {
include_once ABSPATH . 'wp-admin/includes/class-wp-upgrader-skin.php';
include_once ABSPATH . 'wp-admin/includes/class-automatic-upgrader-skin.php';
}
$skin = new \Automatic_Upgrader_Skin();
if ( ! class_exists( \Plugin_Upgrader::class ) ) {
include_once ABSPATH . 'wp-admin/includes/class-wp-upgrader.php';
}
$upgrader = new \Plugin_Upgrader( $skin );
$install_ok = $upgrader->install( $plugin_url );
$result = array( 'messages' => $skin->get_upgrade_messages() );
if ( $install_ok ) {
if ( ! function_exists( 'get_plugins' ) ) {
require_once ABSPATH . 'wp-admin/includes/plugin.php';
}
$plugin_name = $upgrader->plugin_info();
$plugin_version = get_plugins()[ $plugin_name ]['Version'];
$result['plugin_name'] = $plugin_name;
$plugin_data = array(
'version' => $plugin_version,
'date' => current_time( 'mysql' ),
);
if ( ! empty( $metadata ) ) {
$plugin_data['metadata'] = $metadata;
}
$auto_installed_plugins = get_site_option( 'woocommerce_autoinstalled_plugins', array() );
$auto_installed_plugins[ $plugin_name ] = $plugin_data;
update_site_option( 'woocommerce_autoinstalled_plugins', $auto_installed_plugins );
$auto_installed_plugins_history = get_site_option( 'woocommerce_history_of_autoinstalled_plugins', array() );
if ( ! isset( $auto_installed_plugins_history[ $plugin_name ] ) ) {
$auto_installed_plugins_history[ $plugin_name ] = $plugin_data;
update_site_option( 'woocommerce_history_of_autoinstalled_plugins', $auto_installed_plugins_history );
}
$post_install = function () use ( $plugin_name, $plugin_version, $installed_by, $plugin_url, $plugin_data ) {
$log_context = array(
'source' => 'plugin_auto_installs',
'recorded_data' => $plugin_data,
);
wc_get_logger()->info( "Plugin $plugin_name v{$plugin_version} installed by $installed_by, source: $plugin_url", $log_context );
};
} else {
$messages = $skin->get_upgrade_messages();
$post_install = function () use ( $plugin_url, $installed_by, $messages ) {
$log_context = array(
'source' => 'plugin_auto_installs',
'installer_messages' => $messages,
);
wc_get_logger()->error( "$installed_by failed to install plugin from source: $plugin_url", $log_context );
};
}
if ( is_multisite() ) {
// We log the install in the main site, unless the main site doesn't have WooCommerce installed;
// in that case we fallback to logging in the current site.
switch_to_blog( get_main_site_id() );
if ( self::woocommerce_is_active_in_current_site() ) {
$post_install();
restore_current_blog();
} else {
restore_current_blog();
$post_install();
}
} else {
$post_install();
}
$result['install_ok'] = $install_ok ?? false;
return $result;
}
/**
* Check if WooCommerce is installed and active in the current blog.
* This is useful for multisite installs when a blog other than the one running this code is selected with 'switch_to_blog'.
*
* @return bool True if WooCommerce is installed and active in the current blog, false otherwise.
*/
private static function woocommerce_is_active_in_current_site(): bool {
$active_valid_plugins = wc_get_container()->get( PluginUtil::class )->get_all_active_valid_plugins();
return ! empty(
array_filter(
$active_valid_plugins,
fn( $plugin ) => substr_compare( $plugin, '/woocommerce.php', -strlen( '/woocommerce.php' ) ) === 0
)
);
}
/**
* Handler for the 'plugin_list_rows' hook, it will display a notice under the name of the plugins
* that have been installed using this class (unless the 'woocommerce_show_autoinstalled_plugin_notices' filter
* returns false) in the plugins list page.
*
* @param string $plugin_file Name of the plugin.
* @param array $plugin_data Plugin data.
*
* @internal For exclusive usage of WooCommerce core, backwards compatibility not guaranteed.
*/
public function handle_plugin_list_rows( $plugin_file, $plugin_data ) {
global $wp_list_table;
if ( is_null( $wp_list_table ) ) {
return;
}
/**
* Filter to suppress the notice about autoinstalled plugins in the plugins list page.
*
* @since 8.8.0
*
* @param bool $display_notice Whether notices should be displayed or not.
* @returns bool
*/
if ( ! apply_filters( 'woocommerce_show_autoinstalled_plugin_notices', '__return_true' ) ) {
return;
}
$auto_installed_plugins_info = get_site_option( 'woocommerce_autoinstalled_plugins', array() );
$current_plugin_info = $auto_installed_plugins_info[ $plugin_file ] ?? null;
if ( is_null( $current_plugin_info ) || $current_plugin_info['version'] !== $plugin_data['Version'] ) {
return;
}
$installed_by = $current_plugin_info['metadata']['installed_by'] ?? 'WooCommerce';
$info_link = $current_plugin_info['metadata']['info_link'] ?? null;
if ( $info_link ) {
/* translators: 1 = who installed the plugin, 2 = ISO-formatted date and time, 3 = URL */
$message = sprintf( __( 'Plugin installed by %1$s on %2$s. <a target="_blank" href="%3$s">More information</a>', 'woocommerce' ), $installed_by, $current_plugin_info['date'], $info_link );
} else {
/* translators: 1 = who installed the plugin, 2 = ISO-formatted date and time */
$message = sprintf( __( 'Plugin installed by %1$s on %2$s.', 'woocommerce' ), $installed_by, $current_plugin_info['date'] );
}
$columns_count = $wp_list_table->get_column_count();
$is_active = is_plugin_active( $plugin_file );
$is_active_class = $is_active ? 'active' : 'inactive';
$is_active_td_style = $is_active ? "style='border-left: 4px solid #72aee6;'" : '';
// phpcs:disable WordPress.Security.EscapeOutput.OutputNotEscaped
?>
<tr class='plugin-update-tr update <?php echo $is_active_class; ?>' data-plugin='<?php echo $plugin_file; ?>' data-plugin-row-type='feature-incomp-warn'>
<td colspan='<?php echo $columns_count; ?>' class='plugin-update'<?php echo $is_active_td_style; ?>>
<div class='notice inline notice-success notice-alt'>
<p>
<?php echo $message; ?>
</p>
</div>
</td>
</tr>
<?php
// phpcs:enable WordPress.Security.EscapeOutput.OutputNotEscaped
}
/**
* Handler for the 'upgrader_process_complete' hook. It's used to remove the autoinstalled plugin information
* for plugins that are upgraded or reinstalled manually (or more generally, by using any install method
* other than this class).
*
* @param \WP_Upgrader $upgrader The upgrader class that has performed the plugin upgrade/reinstall.
* @param array $hook_extra Extra information about the upgrade process.
*
* @internal For exclusive usage of WooCommerce core, backwards compatibility not guaranteed.
*/
public function handle_upgrader_process_complete( \WP_Upgrader $upgrader, array $hook_extra ) {
if ( $this->installing_plugin || ! ( $upgrader instanceof \Plugin_Upgrader ) || ( 'plugin' !== ( $hook_extra['type'] ?? null ) ) ) {
return;
}
$auto_installed_plugins = get_site_option( 'woocommerce_autoinstalled_plugins' );
if ( ! $auto_installed_plugins ) {
return;
}
if ( $hook_extra['bulk'] ?? false ) {
$updated_plugin_names = $hook_extra['plugins'] ?? array();
} else {
$updated_plugin_names = array( $upgrader->plugin_info() );
}
$auto_installed_plugin_names = array_keys( $auto_installed_plugins );
$updated_auto_installed_plugin_names = array_intersect( $auto_installed_plugin_names, $updated_plugin_names );
if ( empty( $updated_auto_installed_plugin_names ) ) {
return;
}
$new_auto_installed_plugins = array_diff_key( $auto_installed_plugins, array_flip( $updated_auto_installed_plugin_names ) );
if ( empty( $new_auto_installed_plugins ) ) {
delete_site_option( 'woocommerce_autoinstalled_plugins' );
} else {
update_site_option( 'woocommerce_autoinstalled_plugins', $new_auto_installed_plugins );
}
}
}

View File

@@ -0,0 +1,47 @@
<?php
declare(strict_types=1);
namespace Automattic\WooCommerce\Internal\Utilities;
/**
* Class with general utility methods related to products.
*/
class ProductUtil {
/**
* Delete the transients related to a specific product.
* If the product is a variation, delete the transients for the parent too.
*
* @param WC_Product|int $product_or_id The product or the product id.
* @return void
*/
public function delete_product_specific_transients( $product_or_id ) {
$parent_id = 0;
if ( $product_or_id instanceof \WC_Product ) {
$product = $product_or_id;
$product_id = $product->get_id();
} else {
$product_id = $product_or_id;
$product = wc_get_product( $product_id );
}
if ( $product instanceof \WC_Product_Variation ) {
$parent_id = $product->get_parent_id();
}
$product_specific_transient_names = array(
'wc_product_children_',
'wc_var_prices_',
'wc_related_',
'wc_child_has_weight_',
'wc_child_has_dimensions_',
);
foreach ( $product_specific_transient_names as $transient ) {
delete_transient( $transient . $product_id );
if ( $parent_id ) {
delete_transient( $transient . $parent_id );
}
}
}
}

View File

@@ -0,0 +1,65 @@
<?php
declare( strict_types = 1 );
namespace Automattic\WooCommerce\Internal\Utilities;
use InvalidArgumentException;
/**
* Utilities to help ensure type safety.
*/
class Types {
/**
* Checks if $thing is an instance of $desired_type.
*
* If the check succeeds, $thing will be returned without further modification. If the check fails, then either
* an exception will be thrown or, if an $on_failure callback was supplied, it will be invoked to either generate
* an appropriate return value or to throw a more specific exception.
*
* Please note that the failure handler will be passed two arguments:
*
* $on_failure( $object, $desired_type )
*
* @since 9.1.0
* @throws InvalidArgumentException If $object does not match $desired_type, and an $on_failure callback was not supplied.
*
* @param mixed $thing The value or reference to be assessed.
* @param string $desired_type What we expect the return type to be, if it is not a WP_Error.
* @param ?callable $on_failure If provided, and if evaluation fails, this will be invoked to generate a return value.
*
* @return mixed
*/
public static function ensure_instance_of( $thing, string $desired_type, ?callable $on_failure = null ) {
// If everything looks good, return early.
if ( $thing instanceof $desired_type ) {
return $thing;
}
// Summarize the error for use in logging and in case we have to throw an exception.
$summary = sprintf(
'Object was not of expected type %1$s.',
$desired_type
);
// Otherwise, let's log the problem so the site operator has a record of where things went wrong.
$logger = wc_get_logger();
if ( $logger ) {
$logger->error(
$summary,
array(
'source' => 'wc-type-check-utility',
'backtrace' => true,
)
);
}
// Invoke the $on_failure handler, if specified.
if ( null !== $on_failure ) {
return $on_failure( $thing, $desired_type );
}
throw new InvalidArgumentException( esc_html( $summary ) );
}
}

View File

@@ -0,0 +1,380 @@
<?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;
/**
* If the URL (or filepath) represents a directory other than the root directory.
*
* This is useful at different points in the process, when deciding whether to re-apply
* a trailing slash at the end of processing or when we need to calculate how many
* directory traversals are needed to form a (grand-)parent URL.
*
* @var bool
*/
private $is_non_root_directory;
/**
* 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://". An exception is made for cases where the URL simply
* starts with exactly two forward slashes, which indicates 'any scheme' (most commonly, that is used when
* there is freedom to switch between 'http' and 'https').
*/
if ( ! preg_match( '#^[a-z]+://#i', $this->url ) && ! preg_match( '#^//(?!/)#', $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
)
);
}
$this->components = array_merge( $this->components, $parsed_components );
// 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' === $this->components['scheme'] && ! empty( $this->components['host'] ) ) {
// If we do not have a drive letter, then simply merge the host and the path together.
if ( null === $this->components['drive'] ) {
$this->components['path'] = $this->components['host'] . ( $this->components['path'] ?? '' );
}
// Restore the host to null in this situation.
$this->components['host'] = null;
}
}
/**
* 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 ) === '/' || ! empty( $this->components['host'] );
$this->is_non_root_directory = substr( $this->components['path'], -1, 1 ) === '/' && strlen( $this->components['path'] ) > 1;
$resolve_traversals = 'file' !== $this->components['scheme'] || $this->is_absolute;
$retain_traversals = false;
// Clean the path.
foreach ( $segments as $part ) {
// Drop empty segments.
if ( strlen( $part ) === 0 || '.' === $part ) {
continue;
}
// Directory traversals created with percent-encoding syntax should also be detected.
$is_traversal = str_ireplace( '%2e', '.', $part ) === '..';
// Resolve directory traversals (if allowed: see further comment relating to this).
if ( $resolve_traversals && $is_traversal ) {
if ( count( $this->path_parts ) > 0 && ! $retain_traversals ) {
$this->path_parts = array_slice( $this->path_parts, 0, count( $this->path_parts ) - 1 );
continue;
} elseif ( $this->is_absolute ) {
continue;
}
}
/*
* Consider allowing directory traversals to be resolved (ie, the process that converts 'foo/bar/../baz' to
* 'foo/baz').
*
* 1. For this decision point, we are only concerned with relative filepaths (in all other cases,
* $resolve_traversals will already be true).
* 2. This is a 'one time' and unidirectional operation. We only wish to flip from false to true, and we
* never wish to do this more than once.
* 3. We only flip the switch after we have examined all leading '..' traversal segments.
*/
if ( false === $resolve_traversals && '..' !== $part && 'file' === $this->components['scheme'] && ! $this->is_absolute ) {
$resolve_traversals = true;
}
/*
* Set a flag indicating that traversals should be retained. This is done to ensure we don't prematurely
* discard traversals at the start of the path.
*/
$retain_traversals = $resolve_traversals && '..' === $part;
// Retain this part of the path.
$this->path_parts[] = $part;
}
// Protect against empty relative paths.
if ( count( $this->path_parts ) === 0 && ! $this->is_absolute ) {
$this->path_parts = array( '.' );
$this->is_non_root_directory = true;
}
// 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 ) . ( $this->is_non_root_directory ? '/' : '' );
}
/**
* 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();
/*
* If we are looking at a relative path that begins with at least one traversal (example: "../../foo")
* then we should only return one parent URL (otherwise, we'd potentially have to return an infinite
* number of parent URLs since we can't know how far the tree extends).
*/
if ( $max_parent > 0 && ! $this->is_absolute && '..' === $this->path_parts[0] ) {
$max_parent = 1;
}
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.
*
* If a level is specified that exceeds the number of path segments, this method will
* return false.
*
* @param int $level Used to indicate the level of parent.
*
* @return string|false
*/
public function get_parent_url( int $level = 1 ) {
if ( $level < 1 ) {
$level = 1;
}
$parts_count = count( $this->path_parts );
$parent_path_parts_to_keep = $parts_count - $level;
/*
* With the exception of file URLs, we do not allow obtaining (grand-)parent directories that require
* us to describe them using directory traversals. For example, given "http://hostname/foo/bar/baz.png" we do
* not permit determining anything more than 2 levels up (we cannot go beyond "http://hostname/").
*/
if ( 'file' !== $this->components['scheme'] && $parent_path_parts_to_keep < 0 ) {
return false;
}
// In the specific case of an absolute filepath describing the root directory, there can be no parent.
if ( 'file' === $this->components['scheme'] && $this->is_absolute && empty( $this->path_parts ) ) {
return false;
}
// Handle cases where the path starts with one or more 'dot segments'. Since the path has already been
// processed, we can be confident that any such segments are at the start of the path.
if ( $parts_count > 0 && ( '.' === $this->path_parts[0] || '..' === $this->path_parts[0] ) ) {
// Determine the index of the last dot segment (ex: given the path '/../../foo' it would be 1).
$single_dots = array_keys( $this->path_parts, '.', true );
$double_dots = array_keys( $this->path_parts, '..', true );
$max_dot_index = max( array_merge( $single_dots, $double_dots ) );
// Prepend the required number of traversals and discard unnecessary trailing segments.
$last_traversal = $max_dot_index + ( $this->is_non_root_directory ? 1 : 0 );
$parent_path = str_repeat( '../', $level ) . join( '/', array_slice( $this->path_parts, 0, $last_traversal ) );
} elseif ( $parent_path_parts_to_keep < 0 ) {
// For relative filepaths only, we use traversals to describe the requested parent.
$parent_path = untrailingslashit( str_repeat( '../', $parent_path_parts_to_keep * -1 ) );
} else {
// Otherwise, in a very simple case, we just remove existing parts.
$parent_path = implode( '/', array_slice( $this->path_parts, 0, $parent_path_parts_to_keep ) );
}
if ( $this->is_relative() && '' === $parent_path ) {
$parent_path = '.';
}
// Append a trailing slash, since a parent is always a directory. The only exception is the current working directory.
$parent_path .= '/';
// 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;
}
// Form the parent URL (ditching the query and fragment, if set).
$parent_url = $this->get_url(
array(
'path' => $parent_path,
'query' => null,
'fragment' => null,
)
);
// We process the parent URL through a fresh instance of this class, for consistency.
return ( new self( $parent_url ) )->get_url();
}
/**
* Outputs the processed URL.
*
* Borrows from https://www.php.net/manual/en/function.parse-url.php#106731
*
* @param array $component_overrides If provided, these will override values set in $this->components.
*
* @return string
*/
public function get_url( array $component_overrides = array() ): string {
$components = array_merge( $this->components, $component_overrides );
$scheme = null !== $components['scheme'] ? $components['scheme'] . '://' : '//';
$host = null !== $components['host'] ? $components['host'] : '';
$port = null !== $components['port'] ? ':' . $components['port'] : '';
$path = $this->get_path( $components['path'] );
// Special handling for hostless URLs (typically, filepaths) referencing the current working directory.
if ( '' === $host && ( '' === $path || '.' === $path ) ) {
$path = './';
}
$user = null !== $components['user'] ? $components['user'] : '';
$pass = null !== $components['pass'] ? ':' . $components['pass'] : '';
$user_pass = ( ! empty( $user ) || ! empty( $pass ) ) ? $user . $pass . '@' : '';
$query = null !== $components['query'] ? '?' . $components['query'] : '';
$fragment = null !== $components['fragment'] ? '#' . $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|null $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,215 @@
<?php
namespace Automattic\WooCommerce\Internal\Utilities;
use Automattic\WooCommerce\Proxies\LegacyProxy;
use WP_Error, WP_User;
/**
* 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' );
}
/**
* Get a user from a valid user ID, but only if the active user is able to see them.
*
* In a multisite context, that may mean that they both must be members of the current blog, or else the active
* user must either have special permissions (manage_network_users) or else a special legacy mode
* (woocommerce_network_wide_customers) is enabled.
*
* @param int $user_id The ID of the desired user.
* @param int|null $requesting_user_id The ID of the user making the request. Optional, defaults to the current user.
*
* @return WP_User|WP_Error
*/
public static function get_user_in_current_site( $user_id, ?int $requesting_user_id = null ) {
// User ID is expected to be an integer. Cast it if we can (avoiding additional runtime warnings), else treat it as 0.
$user_id = is_numeric( $user_id ) ? (int) $user_id : 0;
$legacy_proxy = wc_get_container()->get( LegacyProxy::class );
$requesting_user_id = $requesting_user_id > 0 ? $requesting_user_id : wp_get_current_user()->ID;
$error = new WP_Error( 'wc_user_invalid_id', __( 'Invalid user ID.', 'woocommerce' ) );
if ( $user_id <= 0 ) {
return $error;
}
$user = get_userdata( $user_id );
if ( ! $user instanceof WP_User || ! $user->exists() ) {
return $error;
}
if (
$legacy_proxy->call_function( 'is_multisite' )
&& ! $legacy_proxy->call_function( 'is_user_member_of_blog', $user->ID )
&& ! $legacy_proxy->call_function( 'user_can', $requesting_user_id, 'manage_network_users' )
&& get_site_option( 'woocommerce_network_wide_customers', 'no' ) !== 'yes'
) {
return $error;
}
return $user;
}
/**
* Check if the email is valid.
*
* @param int $order_id Order ID.
* @param string $supplied_email Supplied email.
* @param string $context Context in which we are checking the email.
* @return bool
*/
public static function should_user_verify_order_email( $order_id, $supplied_email = null, $context = 'view' ) {
$order = wc_get_order( $order_id );
$billing_email = $order->get_billing_email();
$customer_id = $order->get_customer_id();
// If we do not have a billing email for the order (could happen in the order is created manually, or if the
// requirement for this has been removed from the checkout flow), email verification does not make sense.
if ( empty( $billing_email ) ) {
return false;
}
// No verification step is needed if the user is logged in and is already associated with the order.
if ( $customer_id && get_current_user_id() === $customer_id ) {
return false;
}
/**
* Controls the grace period within which we do not require any sort of email verification step before rendering
* the 'order received' or 'order pay' pages.
*
* To eliminate the grace period, set to zero (or to a negative value). Note that this filter is not invoked
* at all if email verification is deemed to be unnecessary (in other words, it cannot be used to force
* verification in *all* cases).
*
* @since 8.0.0
*
* @param int $grace_period Time in seconds after an order is placed before email verification may be required.
* @param WC_Order $this The order for which this grace period is being assessed.
* @param string $context Indicates the context in which we might verify the email address. Typically 'order-pay' or 'order-received'.
*/
$verification_grace_period = (int) apply_filters( 'woocommerce_order_email_verification_grace_period', 10 * MINUTE_IN_SECONDS, $order, $context );
$date_created = $order->get_date_created();
// We do not need to verify the email address if we are within the grace period immediately following order creation.
if (
is_a( $date_created, \WC_DateTime::class, true )
&& time() - $date_created->getTimestamp() <= $verification_grace_period
) {
return false;
}
$session = wc()->session;
$session_email = '';
if ( is_a( $session, \WC_Session::class ) ) {
$customer = $session->get( 'customer' );
$session_email = is_array( $customer ) && isset( $customer['email'] ) ? $customer['email'] : '';
}
// Email verification is required if the user cannot be identified, or if they supplied an email address but the nonce check failed.
$can_view_orders = current_user_can( 'read_private_shop_orders' );
$session_email_match = $session_email === $billing_email;
$supplied_email_match = $supplied_email === $billing_email;
$email_verification_required = ! $session_email_match && ! $supplied_email_match && ! $can_view_orders;
/**
* Provides an opportunity to override the (potential) requirement for shoppers to verify their email address
* before we show information such as the order summary, or order payment page.
*
* Note that this hook is not always triggered, therefore it is (for example) unsuitable as a way of forcing
* email verification across all order confirmation/order payment scenarios. Instead, the filter primarily
* exists as a way to *remove* the email verification step.
*
* @since 7.9.0
*
* @param bool $email_verification_required If email verification is required.
* @param WC_Order $order The relevant order.
* @param string $context The context under which we are performing this check.
*/
return (bool) apply_filters( 'woocommerce_order_email_verification_required', $email_verification_required, $order, $context );
}
/**
* Site-specific method of retrieving the requested user meta.
*
* This is a multisite-aware wrapper around WordPress's own `get_user_meta()` function, and works by prefixing the
* supplied meta key with a blog-specific meta key.
*
* @param int $user_id User ID.
* @param string $key Optional. The meta key to retrieve. By default, returns data for all keys.
* @param bool $single Optional. Whether to return a single value. This parameter has no effect if `$key` is not
* specified. Default false.
*
* @return mixed An array of values if `$single` is false. The value of meta data field if `$single` is true.
* False for an invalid `$user_id` (non-numeric, zero, or negative value). An empty string if a valid
* but non-existing user ID is passed.
*/
public static function get_site_user_meta( int $user_id, string $key = '', bool $single = false ) {
global $wpdb;
$site_specific_key = $key . '_' . rtrim( $wpdb->get_blog_prefix( get_current_blog_id() ), '_' );
return get_user_meta( $user_id, $site_specific_key, true );
}
/**
* Site-specific means of updating user meta.
*
* This is a multisite-aware wrapper around WordPress's own `update_user_meta()` function, and works by prefixing
* the supplied meta key with a blog-specific meta key.
*
* @param int $user_id User ID.
* @param string $meta_key Metadata key.
* @param mixed $meta_value Metadata value. Must be serializable if non-scalar.
* @param mixed $prev_value Optional. Previous value to check before updating. If specified, only update existing
* metadata entries with this value. Otherwise, update all entries. Default empty.
*
* @return int|bool Meta ID if the key didn't exist, true on successful update, false on failure or if the value
* passed to the function is the same as the one that is already in the database.
*/
public static function update_site_user_meta( int $user_id, string $meta_key, $meta_value, $prev_value = '' ) {
global $wpdb;
$site_specific_key = $meta_key . '_' . rtrim( $wpdb->get_blog_prefix( get_current_blog_id() ), '_' );
return update_user_meta( $user_id, $site_specific_key, $meta_value, $prev_value );
}
/**
* Site-specific means of deleting user meta.
*
* This is a multisite-aware wrapper around WordPress's own `delete_user_meta()` function, and works by prefixing
* the supplied meta key with a blog-specific meta key.
*
* @param int $user_id User ID.
* @param string $meta_key Metadata name.
* @param mixed $meta_value Optional. Metadata value. If provided, rows will only be removed that match the value.
* Must be serializable if non-scalar. Default empty.
*
* @return bool True on success, false on failure.
* /
*/
public static function delete_site_user_meta( $user_id, $meta_key, $meta_value = '' ) {
global $wpdb;
$site_specific_key = $meta_key . '_' . rtrim( $wpdb->get_blog_prefix(), '_' );
return delete_user_meta( $user_id, $site_specific_key, $meta_value );
}
}

View File

@@ -0,0 +1,163 @@
<?php
/**
* WebhookUtil class file.
*/
namespace Automattic\WooCommerce\Internal\Utilities;
use WC_Cache_Helper;
/**
* Class with utility methods for dealing with webhooks.
*/
class WebhookUtil {
/**
* Creates a new instance of the class.
*/
public function __construct() {
add_action( 'deleted_user', array( $this, 'reassign_webhooks_to_new_user_id' ), 10, 2 );
add_action( 'delete_user_form', array( $this, 'maybe_render_user_with_webhooks_warning' ), 10, 2 );
}
/**
* Whenever a user is deleted, re-assign their webhooks to the new user.
*
* If re-assignment isn't selected during deletion, assign the webhooks to user_id 0,
* so that an admin can edit and re-save them in order to get them to be assigned to a valid user.
*
* @param int $old_user_id ID of the deleted user.
* @param int|null $new_user_id ID of the user to reassign existing data to, or null if no re-assignment is requested.
*
* @return void
* @since 7.8.0
*
* @internal For exclusive usage of WooCommerce core, backwards compatibility not guaranteed.
*/
public function reassign_webhooks_to_new_user_id( int $old_user_id, ?int $new_user_id ): void {
$webhook_ids = $this->get_webhook_ids_for_user( $old_user_id );
foreach ( $webhook_ids as $webhook_id ) {
$webhook = new \WC_Webhook( $webhook_id );
$webhook->set_user_id( $new_user_id ?? 0 );
$webhook->save();
}
}
/**
* When users are about to be deleted show an informative text if they have webhooks assigned.
*
* @param \WP_User $current_user The current logged in user.
* @param array $userids Array with the ids of the users that are about to be deleted.
* @return void
* @since 7.8.0
*
* @internal For exclusive usage of WooCommerce core, backwards compatibility not guaranteed.
*/
public function maybe_render_user_with_webhooks_warning( \WP_User $current_user, array $userids ): void {
global $wpdb;
$at_least_one_user_with_webhooks = false;
foreach ( $userids as $user_id ) {
$webhook_ids = $this->get_webhook_ids_for_user( $user_id );
if ( empty( $webhook_ids ) ) {
continue;
}
$at_least_one_user_with_webhooks = true;
$user_data = get_userdata( $user_id );
$user_login = false === $user_data ? '' : $user_data->user_login;
$webhooks_count = count( $webhook_ids );
$text = sprintf(
/* translators: 1 = user id, 2 = user login, 3 = webhooks count */
_nx(
'User #%1$s %2$s has created %3$d WooCommerce webhook.',
'User #%1$s %2$s has created %3$d WooCommerce webhooks.',
$webhooks_count,
'user webhook count',
'woocommerce'
),
$user_id,
$user_login,
$webhooks_count
);
echo '<p>' . esc_html( $text ) . '</p>';
}
if ( ! $at_least_one_user_with_webhooks ) {
return;
}
$webhooks_settings_url = esc_url_raw( admin_url( 'admin.php?page=wc-settings&tab=advanced&section=webhooks' ) );
// This block of code is copied from WordPress' users.php.
// phpcs:disable WooCommerce.Commenting.CommentHooks, WordPress.DB.PreparedSQL.NotPrepared
$users_have_content = (bool) apply_filters( 'users_have_additional_content', false, $userids );
if ( ! $users_have_content ) {
if ( $wpdb->get_var( "SELECT ID FROM {$wpdb->posts} WHERE post_author IN( " . implode( ',', $userids ) . ' ) LIMIT 1' ) ) {
$users_have_content = true;
} elseif ( $wpdb->get_var( "SELECT link_id FROM {$wpdb->links} WHERE link_owner IN( " . implode( ',', $userids ) . ' ) LIMIT 1' ) ) {
$users_have_content = true;
}
}
// phpcs:enable WooCommerce.Commenting.CommentHooks, WordPress.DB.PreparedSQL.NotPrepared
if ( $users_have_content ) {
$text = __( 'If the "Delete all content" option is selected, the affected WooCommerce webhooks will <b>not</b> be deleted and will be attributed to user id 0.<br/>', 'woocommerce' );
} else {
$text = __( 'The affected WooCommerce webhooks will <b>not</b> be deleted and will be attributed to user id 0.<br/>', 'woocommerce' );
}
$text .= sprintf(
/* translators: 1 = url of the WooCommerce webhooks settings page */
__( 'After that they can be reassigned to the logged-in user by going to the <a href="%1$s">WooCommerce webhooks settings page</a> and re-saving them.', 'woocommerce' ),
$webhooks_settings_url
);
echo '<p>' . wp_kses_post( $text ) . '</p>';
}
/**
* Get the ids of the webhooks assigned to a given user.
*
* @param int $user_id User id.
* @return int[] Array of webhook ids.
*/
private function get_webhook_ids_for_user( int $user_id ): array {
$data_store = \WC_Data_Store::load( 'webhook' );
return $data_store->search_webhooks(
array(
'user_id' => $user_id,
)
);
}
/**
* Gets the count of webhooks that are configured to use the Legacy REST API to compose their payloads.
*
* @param bool $clear_cache If true, the previously cached value of the count will be discarded if it exists.
*
* @return int
*/
public function get_legacy_webhooks_count( bool $clear_cache = false ): int {
global $wpdb;
$cache_key = WC_Cache_Helper::get_cache_prefix( 'webhooks' ) . 'legacy_count';
if ( $clear_cache ) {
wp_cache_delete( $cache_key, 'webhooks' );
}
$count = wp_cache_get( $cache_key, 'webhooks' );
if ( false === $count ) {
$count = absint( $wpdb->get_var( "SELECT count( webhook_id ) FROM {$wpdb->prefix}wc_webhooks WHERE `api_version` < 1;" ) );
wp_cache_add( $cache_key, $count, 'webhooks' );
}
return $count;
}
}