Файловый менеджер - Редактировать - /home/tuudkjt/globeasy/wp-includes/ID3/Internal.tar
Назад
Integrations/WPConsentAPI.php 0000777 00000004721 15251706115 0012153 0 ustar 00 <?php declare( strict_types=1 ); namespace Automattic\WooCommerce\Internal\Integrations; use Automattic\Jetpack\Constants; use Automattic\WooCommerce\Internal\Traits\ScriptDebug; use WP_CONSENT_API; /** * Class WPConsentAPI * * @since 8.5.0 */ class WPConsentAPI { use ScriptDebug; /** * Identifier of the consent category used for order attribution. * * @var string */ public static $consent_category = 'marketing'; /** * Register the consent API. * * @return void */ public function register() { add_action( 'init', function() { $this->on_init(); }, 20 // After OrderAttributionController. ); } /** * Register our hooks on init. * * @return void */ protected function on_init() { // Include integration to WP Consent Level API if available. if ( ! $this->is_wp_consent_api_active() ) { return; } $plugin = plugin_basename( WC_PLUGIN_FILE ); add_filter( "wp_consent_api_registered_{$plugin}", '__return_true' ); add_action( 'wp_enqueue_scripts', function() { $this->enqueue_consent_api_scripts(); } ); /** * Modify the "allowTracking" flag consent if the user has consented to marketing. * * Wp-consent-api will initialize the modules on "init" with priority 9, * So this code needs to be run after that. */ add_filter( 'wc_order_attribution_allow_tracking', function() { return function_exists( 'wp_has_consent' ) && wp_has_consent( self::$consent_category ); } ); } /** * Check if WP Cookie Consent API is active * * @return bool */ protected function is_wp_consent_api_active() { return class_exists( WP_CONSENT_API::class ); } /** * Enqueue JS for integration with WP Consent Level API * * @return void */ private function enqueue_consent_api_scripts() { wp_enqueue_script( 'wp-consent-api-integration', plugins_url( "assets/js/frontend/wp-consent-api-integration{$this->get_script_suffix()}.js", WC_PLUGIN_FILE ), array( 'wp-consent-api', 'wc-order-attribution' ), Constants::get_constant( 'WC_VERSION' ), true ); // Add data for the script above. `wp_enqueue_script` API does not allow data attributes, // so we need a separate script tag and pollute the global scope. wp_add_inline_script( 'wp-consent-api-integration', sprintf( 'window.wc_order_attribution.params.consentCategory = %s;', wp_json_encode( self::$consent_category, JSON_HEX_TAG | JSON_UNESCAPED_SLASHES ) ), 'before' ); } } Integrations/WPPostsImporter.php 0000777 00000003617 15251706115 0013045 0 ustar 00 <?php declare( strict_types=1 ); namespace Automattic\WooCommerce\Internal\Integrations; /** * Class WPPostsImporter * * @since 10.1.0 */ class WPPostsImporter { /** * Register the WP Posts importer. * * @return void */ public function register() { add_action( 'wp_import_posts', array( $this, 'register_product_attribute_taxonomies' ), 100, 1 ); } /** * Register product attribute taxonomies when importing posts via the WXR importer. * * @since 10.1.0 * * @param array $posts The posts to process. * @return array */ public function register_product_attribute_taxonomies( $posts ) { if ( ! is_array( $posts ) || empty( $posts ) ) { return $posts; } foreach ( $posts as $post ) { if ( 'product' !== $post['post_type'] || empty( $post['terms'] ) ) { continue; } foreach ( $post['terms'] as $term ) { if ( ! strstr( $term['domain'], 'pa_' ) ) { continue; } if ( taxonomy_exists( $term['domain'] ) ) { continue; } $attribute_name = wc_attribute_taxonomy_slug( $term['domain'] ); // Create the taxonomy. if ( ! in_array( $attribute_name, wc_get_attribute_taxonomies(), true ) ) { wc_create_attribute( array( 'name' => $attribute_name, 'slug' => $attribute_name, 'type' => 'select', 'order_by' => 'menu_order', 'has_archives' => false, ) ); } // Register the taxonomy so that the import works. register_taxonomy( $term['domain'], // phpcs:ignore apply_filters( 'woocommerce_taxonomy_objects_' . $term['domain'], array( 'product' ) ), // phpcs:ignore apply_filters( 'woocommerce_taxonomy_args_' . $term['domain'], array( 'hierarchical' => true, 'show_ui' => false, 'query_var' => true, 'rewrite' => false, ) ) ); } } return $posts; } } Utilities/URLException.php 0000777 00000000277 15251706115 0011571 0 ustar 00 <?php namespace Automattic\WooCommerce\Internal\Utilities; use Exception; /** * Used to represent a problem encountered when processing a URL. */ class URLException extends Exception {} Utilities/PluginInstaller.php 0000777 00000034013 15251706115 0012357 0 ustar 00 <?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 ); } } } Utilities/FilesystemUtil.php 0000777 00000012666 15251706115 0012237 0 ustar 00 <?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 ) ) ); } } Utilities/LegacyRestApiStub.php 0000777 00000015227 15251706115 0012603 0 ustar 00 <?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 ); } } Utilities/URL.php 0000777 00000032153 15251706115 0007710 0 ustar 00 <?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; } } Utilities/Users.php 0000777 00000022520 15251706115 0010344 0 ustar 00 <?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 ); } } Utilities/Types.php 0000777 00000003744 15251706115 0010356 0 ustar 00 <?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 ) ); } } Utilities/DatabaseUtil.php 0000777 00000040143 15251706115 0011606 0 ustar 00 <?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'" ) ); } } Utilities/ArrayUtil.php 0000777 00000006103 15251706115 0011156 0 ustar 00 <?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; } } Utilities/BlocksUtil.php 0000777 00000004557 15251706115 0011330 0 ustar 00 <?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'] ); } ) ); } } Utilities/HtmlSanitizer.php 0000777 00000006143 15251706115 0012043 0 ustar 00 <?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; } } Utilities/COTMigrationUtil.php 0000777 00000014046 15251706115 0012404 0 ustar 00 <?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; } } Utilities/ProductUtil.php 0000777 00000002250 15251706115 0011517 0 ustar 00 <?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 ); } } } } Utilities/WebhookUtil.php 0000777 00000012571 15251706115 0011504 0 ustar 00 <?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§ion=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; } } OrderCouponDataMigrator.php 0000777 00000020520 15251706115 0012024 0 ustar 00 <?php namespace Automattic\WooCommerce\Internal; use Automattic\WooCommerce\Internal\BatchProcessing\BatchProcessingController; use Automattic\WooCommerce\Internal\BatchProcessing\BatchProcessorInterface; use Automattic\WooCommerce\Utilities\StringUtil; use \Exception; /** * This class is intended to be used with BatchProcessingController and converts verbose * 'coupon_data' metadata entries in coupon line items (corresponding to coupons applied to orders) * into simplified 'coupon_info' entries. See WC_Coupon::get_short_info. * * Additionally, this class manages the "Convert order coupon data" tool. */ class OrderCouponDataMigrator implements BatchProcessorInterface, RegisterHooksInterface { /** * Register hooks for the class. */ public function register() { add_filter( 'woocommerce_debug_tools', array( $this, 'handle_woocommerce_debug_tools' ), 999, 1 ); } /** * Get a user-friendly name for this processor. * * @return string Name of the processor. */ public function get_name(): string { return "Coupon line item 'coupon_data' to 'coupon_info' metadata migrator"; } /** * Get a user-friendly description for this processor. * * @return string Description of what this processor does. */ public function get_description(): string { return "Migrates verbose metadata about coupons applied to an order ('coupon_data' metadata key in coupon line items) to simplified metadata ('coupon_info' keys)"; } /** * Get the total number of pending items that require processing. * * @return int Number of items pending processing. */ public function get_total_pending_count(): int { global $wpdb; return $wpdb->get_var( $wpdb->prepare( "SELECT COUNT(*) FROM {$wpdb->prefix}woocommerce_order_itemmeta WHERE meta_key=%s", 'coupon_data' ) ); } /** * Returns the next batch of items that need to be processed. * A batch in this context is a list of 'meta_id' values from the wp_woocommerce_order_itemmeta table. * * @param int $size Maximum size of the batch to be returned. * * @return array Batch of items to process, containing $size or less items. */ public function get_next_batch_to_process( int $size ): array { global $wpdb; $meta_ids = $wpdb->get_col( $wpdb->prepare( "SELECT meta_id FROM {$wpdb->prefix}woocommerce_order_itemmeta WHERE meta_key=%s ORDER BY meta_id ASC LIMIT %d", 'coupon_data', $size ) ); return array_map( 'absint', $meta_ids ); } /** * Process data for the supplied batch. See the convert_item method. * * @throw \Exception Something went wrong while processing the batch. * * @param array $batch Batch to process, as returned by 'get_next_batch_to_process'. */ public function process_batch( array $batch ): void { global $wpdb; if ( empty( $batch ) ) { return; } $meta_ids = StringUtil::to_sql_list( $batch ); $meta_ids_and_values = $wpdb->get_results( //phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared "SELECT meta_id,meta_value FROM {$wpdb->prefix}woocommerce_order_itemmeta WHERE meta_id IN $meta_ids", ARRAY_N ); foreach ( $meta_ids_and_values as $meta_id_and_value ) { try { $this->convert_item( (int) $meta_id_and_value[0], $meta_id_and_value[1] ); } catch ( Exception $ex ) { wc_get_logger()->error( StringUtil::class_name_without_namespace( self::class ) . ": when converting meta row with id {$meta_id_and_value[0]}: {$ex->getMessage()}" ); } } } /** * Convert one verbose 'coupon_data' entry into a simplified 'coupon_info' entry. * * The existing database row is updated in place, both the 'meta_key' and the 'meta_value' columns. * * @param int $meta_id Value of 'meta_id' of the row being converted. * @param string $meta_value Value of 'meta_value' of the row being converted. * @throws Exception Database error. */ private function convert_item( int $meta_id, string $meta_value ) { global $wpdb; //phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.serialize_unserialize $coupon_data = unserialize( $meta_value ); $temp_coupon = new \WC_Coupon(); $temp_coupon->set_props( $coupon_data ); //phpcs:disable WordPress.DB.SlowDBQuery $wpdb->update( "{$wpdb->prefix}woocommerce_order_itemmeta", array( 'meta_key' => 'coupon_info', 'meta_value' => $temp_coupon->get_short_info(), ), array( 'meta_id' => $meta_id ) ); //phpcs:enable WordPress.DB.SlowDBQuery if ( $wpdb->last_error ) { throw new Exception( $wpdb->last_error ); } } /** * Default (preferred) batch size to pass to 'get_next_batch_to_process'. * * @return int Default batch size. */ public function get_default_batch_size(): int { return 1000; } /** * Add the tool to start or stop the background process that converts order coupon metadata entries. * * @param array $tools Old tools array. * @return array Updated tools array. * * @internal For exclusive usage of WooCommerce core, backwards compatibility not guaranteed. */ public function handle_woocommerce_debug_tools( array $tools ): array { $batch_processor = wc_get_container()->get( BatchProcessingController::class ); $pending_count = $this->get_total_pending_count(); if ( 0 === $pending_count ) { $tools['start_convert_order_coupon_data'] = array( 'name' => __( 'Start converting order coupon data to the simplified format', 'woocommerce' ), 'button' => __( 'Start converting', 'woocommerce' ), 'disabled' => true, 'desc' => __( 'This will convert <code>coupon_data</code> order item meta entries to simplified <code>coupon_info</code> entries. The conversion will happen overtime in the background (via Action Scheduler). There are currently no entries to convert.', 'woocommerce' ), ); } elseif ( $batch_processor->is_enqueued( self::class ) ) { $tools['stop_convert_order_coupon_data'] = array( 'name' => __( 'Stop converting order coupon data to the simplified format', 'woocommerce' ), 'button' => __( 'Stop converting', 'woocommerce' ), 'desc' => /* translators: %d=count of entries pending conversion */ sprintf( __( 'This will stop the background process that converts <code>coupon_data</code> order item meta entries to simplified <code>coupon_info</code> entries. There are currently %d entries that can be converted.', 'woocommerce' ), $pending_count ), 'callback' => array( $this, 'dequeue' ), ); } else { $tools['start_converting_order_coupon_data'] = array( 'name' => __( 'Convert order coupon data to the simplified format', 'woocommerce' ), 'button' => __( 'Start converting', 'woocommerce' ), 'desc' => /* translators: %d=count of entries pending conversion */ sprintf( __( 'This will convert <code>coupon_data</code> order item meta entries to simplified <code>coupon_info</code> entries. The conversion will happen overtime in the background (via Action Scheduler). There are currently %d entries that can be converted.', 'woocommerce' ), $pending_count ), 'callback' => array( $this, 'enqueue' ), ); } return $tools; } /** * Start the background process for coupon data conversion. * * @return string Informative string to show after the tool is triggered in UI. * * @internal For exclusive usage of WooCommerce core, backwards compatibility not guaranteed. */ public function enqueue(): string { $batch_processor = wc_get_container()->get( BatchProcessingController::class ); if ( $batch_processor->is_enqueued( self::class ) ) { return __( 'Background process for coupon meta conversion already started, nothing done.', 'woocommerce' ); } $batch_processor->enqueue_processor( self::class ); return __( 'Background process for coupon meta conversion started', 'woocommerce' ); } /** * Stop the background process for coupon data conversion. * * @return string Informative string to show after the tool is triggered in UI. * * @internal For exclusive usage of WooCommerce core, backwards compatibility not guaranteed. */ public function dequeue(): string { $batch_processor = wc_get_container()->get( BatchProcessingController::class ); if ( ! $batch_processor->is_enqueued( self::class ) ) { return __( 'Background process for coupon meta conversion not started, nothing done.', 'woocommerce' ); } $batch_processor->remove_processor( self::class ); return __( 'Background process for coupon meta conversion stopped', 'woocommerce' ); } } FraudProtection/SessionClearanceManager.php 0000777 00000013460 15251706115 0015117 0 ustar 00 <?php /** * SessionClearanceManager class file. */ declare( strict_types=1 ); namespace Automattic\WooCommerce\Internal\FraudProtection; defined( 'ABSPATH' ) || exit; /** * Manages session clearance state for fraud protection. * * This class handles the session status tracking for fraud protection decisions, * managing three possible states: pending, allowed, and blocked. It integrates * with WooCommerce sessions and uses the FraudProtectionController logging helper * to maintain consistent audit logs. * * @since 10.5.0 * @internal This class is part of the internal API and is subject to change without notice. */ class SessionClearanceManager { /** * Session key for storing clearance status. */ private const SESSION_KEY = '_fraud_protection_clearance_status'; /** * Session status: pending clearance. */ public const STATUS_PENDING = 'pending'; /** * Session status: allowed. */ public const STATUS_ALLOWED = 'allowed'; /** * Session status: blocked. */ public const STATUS_BLOCKED = 'blocked'; /** * Default session status. */ public const DEFAULT_STATUS = self::STATUS_ALLOWED; /** * Check if the current session is allowed. * * @return bool True if session is allowed, false otherwise. */ public function is_session_allowed(): bool { $status = $this->get_session_status(); return self::STATUS_ALLOWED === $status; } /** * Check if the current session is blocked. * * @return bool True if session is blocked, false otherwise. */ public function is_session_blocked(): bool { $status = $this->get_session_status(); return self::STATUS_BLOCKED === $status; } /** * Mark the current session as allowed. * * @return void */ public function allow_session(): void { $this->set_session_status( self::STATUS_ALLOWED ); $this->log_session_update_event( 'allowed' ); } /** * Mark the current session as pending (challenge required). * * @return void */ public function challenge_session(): void { $this->set_session_status( self::STATUS_PENDING ); $this->log_session_update_event( 'challenged' ); } /** * Mark the current session as blocked. * * @return void */ public function block_session(): void { $this->set_session_status( self::STATUS_BLOCKED ); $this->log_session_update_event( 'blocked' ); $this->empty_cart(); } /** * Get the current session clearance status. * * @return string One of: pending, allowed, blocked. */ public function get_session_status(): string { if ( ! $this->is_session_available() ) { return self::DEFAULT_STATUS; } $status = WC()->session->get( self::SESSION_KEY, self::DEFAULT_STATUS ); // Validate status value - return default for invalid values. if ( ! in_array( $status, array( self::STATUS_PENDING, self::STATUS_ALLOWED, self::STATUS_BLOCKED ), true ) ) { return self::DEFAULT_STATUS; } return $status; } /** * Set the session clearance status. * * @param string $status One of: pending, allowed, blocked. * @return void */ private function set_session_status( string $status ): void { if ( ! $this->is_session_available() ) { return; } WC()->session->set( self::SESSION_KEY, $status ); // Ensure session cookie is set so the session persists across page loads. // This is important because fraud protection may set session status before // any cart action triggers the cookie to be set. // Skip cookie setting if headers have already been sent (e.g., in test environment). if ( WC()->session instanceof \WC_Session_Handler ) { WC()->session->set_customer_session_cookie( true ); } } /** * Reset the session clearance status to default (allowed). * * @return void */ public function reset_session(): void { $this->set_session_status( self::DEFAULT_STATUS ); } /** * Ensure cart and session are available. * * Loads cart if not already loaded, which initializes session for both * traditional (cookie) and Store API (token) flows. * * @return void */ public function ensure_cart_loaded(): void { if ( ! did_action( 'woocommerce_load_cart_from_session' ) && function_exists( 'wc_load_cart' ) ) { WC()->call_function( 'wc_load_cart' ); } } /** * Check if WooCommerce session is available. * * @return bool True if session is available. */ private function is_session_available(): bool { $this->ensure_cart_loaded(); return WC()->session instanceof \WC_Session; } /** * Get a unique identifier for the current session. * * @return string Session identifier. */ public function get_session_id(): string { if ( ! $this->is_session_available() ) { return 'no-session'; } // Use or generate a stable session ID for tracking consistency. $fraud_customer_session_id = WC()->session->get( '_fraud_protection_customer_session_id' ); if ( ! $fraud_customer_session_id ) { $fraud_customer_session_id = WC()->call_function( 'wc_rand_hash', 'customer_', 30 ); WC()->session->set( '_fraud_protection_customer_session_id', $fraud_customer_session_id ); } return $fraud_customer_session_id; } /** * Empty the cart. * * @return void */ private function empty_cart(): void { if ( function_exists( 'WC' ) && WC()->cart ) { WC()->cart->empty_cart(); } } /** * Log a session update event using FraudProtectionController's logging helper. * * @param string $action The action taken (allowed, challenged, or blocked). * @return void */ private function log_session_update_event( string $action ): void { $session_id = $this->get_session_id(); $user_id = get_current_user_id(); $user_info = $user_id ? "User: {$user_id}" : 'User: guest'; $timestamp = current_time( 'mysql' ); $message = sprintf( 'Session updated: %s | %s | Action: %s | Timestamp: %s', $session_id, $user_info, $action, $timestamp ); FraudProtectionController::log( 'info', $message ); } } FraudProtection/SessionDataCollector.php 0000777 00000052665 15251706115 0014501 0 ustar 00 <?php /** * SessionDataCollector class file. */ declare( strict_types=1 ); namespace Automattic\WooCommerce\Internal\FraudProtection; defined( 'ABSPATH' ) || exit; /** * Collects comprehensive session and order data for fraud protection analysis. * * This class provides manual data collection for fraud protection events, gathering * session, customer, order, address, and payment information in the exact nested format * required by the WPCOM fraud protection service. All data collection is designed to * degrade gracefully when fields are unavailable, ensuring checkout never fails due to * missing fraud protection data. * * @since 10.5.0 * @internal This class is part of the internal API and is subject to change without notice. */ class SessionDataCollector { /** * SessionClearanceManager instance. * * @var SessionClearanceManager */ private SessionClearanceManager $session_clearance_manager; /** * Initialize with dependencies. * * @internal * * @param SessionClearanceManager $session_clearance_manager The session clearance manager instance. */ final public function init( SessionClearanceManager $session_clearance_manager ): void { $this->session_clearance_manager = $session_clearance_manager; } /** * Collect comprehensive session and order data for fraud protection. * * This method is called manually at specific points in the checkout/payment flow * to gather all relevant data for fraud analysis. It returns data in the nested * format expected by the WPCOM fraud protection service. * * @since 10.5.0 * * @param string|null $event_type Optional event type identifier (e.g., 'checkout_started', 'payment_attempt'). * @param array $event_data Optional event-specific additional context data (may include 'order_id'). * @return array Nested array containing all collected fraud protection data. */ public function collect( ?string $event_type = null, array $event_data = array() ): array { // Ensure cart and session are loaded. $this->session_clearance_manager->ensure_cart_loaded(); // Extract order ID from event_data if provided. // There seem to be no universal way to get order id from session data, so we may start with passing it as a parameter when calling this method. $order_id_from_event = $event_data['order_id'] ?? null; return array( 'event_type' => $event_type, 'timestamp' => gmdate( 'Y-m-d H:i:s' ), 'wc_version' => WC()->version, 'session' => $this->get_session_data(), 'customer' => $this->get_customer_data(), 'order' => $this->get_order_data( $order_id_from_event ), 'shipping_address' => $this->get_shipping_address(), 'billing_address' => $this->get_billing_address(), 'event_data' => $event_data, ); } /** * Get current billing country from customer data. * * Reuses the same logic as get_billing_address() but returns only the country. * Tries WC_Customer first, falls back to session data, with graceful error handling. * * @since 10.5.0 * * @return string|null Current billing country code or null if unavailable. */ public function get_current_billing_country(): ?string { try { if ( WC()->customer instanceof \WC_Customer ) { $country = WC()->customer->get_billing_country(); return ! empty( $country ) ? \sanitize_text_field( $country ) : null; } elseif ( WC()->session instanceof \WC_Session ) { $customer_data = WC()->session->get( 'customer' ); if ( is_array( $customer_data ) && ! empty( $customer_data['country'] ) ) { return \sanitize_text_field( $customer_data['country'] ); } } } catch ( \Exception $e ) { // phpcs:ignore Generic.CodeAnalysis.EmptyStatement.DetectedCatch // Graceful degradation. } return null; } /** * Get current shipping country from customer data. * * Reuses the same logic as get_shipping_address() but returns only the country. * Tries WC_Customer first, falls back to session data, with graceful error handling. * * @since 10.5.0 * * @return string|null Current shipping country code or null if unavailable. */ public function get_current_shipping_country(): ?string { try { if ( WC()->customer instanceof \WC_Customer ) { $country = WC()->customer->get_shipping_country(); return ! empty( $country ) ? \sanitize_text_field( $country ) : null; } elseif ( WC()->session instanceof \WC_Session ) { $customer_data = WC()->session->get( 'customer' ); if ( is_array( $customer_data ) && ! empty( $customer_data['shipping_country'] ) ) { return \sanitize_text_field( $customer_data['shipping_country'] ); } } } catch ( \Exception $e ) { // phpcs:ignore Generic.CodeAnalysis.EmptyStatement.DetectedCatch // Graceful degradation. } return null; } /** * Get session data including session ID, IP address, email, and user agent. * * Collects session identification and tracking data with graceful degradation * for unavailable fields. Email collection follows the fallback chain: * logged-in user email → session customer data → WC_Customer billing email. * * @since 10.5.0 * * @return array Session data array with 6 keys. */ private function get_session_data(): array { try { $session_id = $this->session_clearance_manager->get_session_id(); $ip_address = $this->get_ip_address(); $email = $this->get_email(); $user_agent = $this->get_user_agent(); /** * $is_user_session is flag that we have a real browser session vs API-based interaction. * We start with a very basic check, but we might need a more sophisticated way to detect it in the future. */ $is_user_session = 'no-session' !== $session_id; return array( 'session_id' => $session_id, 'ip_address' => $ip_address, 'email' => $email, 'ja3_hash' => null, 'user_agent' => $user_agent, 'is_user_session' => $is_user_session, ); } catch ( \Exception $e ) { // Graceful degradation - return structure with null values. return array( 'session_id' => null, 'ip_address' => null, 'email' => null, 'ja3_hash' => null, 'user_agent' => null, 'is_user_session' => false, ); } } /** * Get customer data including name, billing email, and order history. * * Collects customer identification and history data with graceful degradation. * Tries WC_Customer object first, then falls back to session data if values are empty. * Includes lifetime_order_count which counts all orders regardless of status. * * @since 10.5.0 * * @return array Customer data array with 4 keys. */ private function get_customer_data(): array { $customer_data = array( 'first_name' => null, 'last_name' => null, 'billing_email' => null, 'lifetime_order_count' => 0, ); try { $lifetime_order_count = 0; // Try WC_Customer object first. if ( WC()->customer instanceof \WC_Customer ) { if ( WC()->customer->get_id() > 0 ) { // We need to reload the customer so it uses the correct data store to count the orders. $customer = new \WC_Customer( WC()->customer->get_id() ); $lifetime_order_count = $customer->get_order_count(); } $customer_data = array_merge( $customer_data, array( 'first_name' => \sanitize_text_field( WC()->customer->get_billing_first_name() ), 'last_name' => \sanitize_text_field( WC()->customer->get_billing_last_name() ), 'billing_email' => \sanitize_email( \WC()->customer->get_billing_email() ), 'lifetime_order_count' => $lifetime_order_count, ) ); } elseif ( WC()->session instanceof \WC_Session ) { // Fallback to session customer data if WC_Customer not available. $customer_session_data = WC()->session->get( 'customer' ); if ( is_array( $customer_session_data ) ) { $customer_data = array_merge( $customer_data, array( 'first_name' => \sanitize_text_field( $customer_session_data['first_name'] ?? null ), 'last_name' => \sanitize_text_field( $customer_session_data['last_name'] ?? null ), 'billing_email' => \sanitize_email( $customer_session_data['email'] ?? null ), ) ); } } } catch ( \Exception $e ) { // phpcs:ignore Generic.CodeAnalysis.EmptyStatement.DetectedCatch // Graceful degradation - return as much data as possible. } return $customer_data; } /** * Get order data including totals, currency, cart hash, and cart items. * * Collects comprehensive order information from the cart with graceful degradation. * Calculates shipping_tax_rate from shipping tax and shipping total. Sets customer_id * to 'guest' for non-logged-in users. * * @since 10.5.0 * * @param int|null $order_id_from_event Optional order ID from event data. * @return array Order data array with 11 keys including items array. */ private function get_order_data( ?int $order_id_from_event = null ): array { try { // Initialize default values. $order_id = $order_id_from_event; $customer_id = 'guest'; $total = 0; $items_total = 0; $shipping_total = 0; $tax_total = 0; $shipping_tax_rate = null; $discount_total = 0; $currency = WC()->call_function( 'get_woocommerce_currency' ); $cart_hash = null; $items = array(); // Get customer ID from WooCommerce customer object if available. // We don't need to fallback to session data here, because customer id won't be stored there. if ( WC()->customer instanceof \WC_Customer ) { $id = WC()->customer->get_id(); if ( $id ) { $customer_id = $id; } } // Get cart data if available. if ( WC()->cart instanceof \WC_Cart ) { $items_total = (float) WC()->cart->get_subtotal(); $shipping_total = (float) WC()->cart->get_shipping_total(); $tax_total = (float) WC()->cart->get_cart_contents_tax(); $discount_total = (float) WC()->cart->get_discount_total(); $cart_hash = WC()->cart->get_cart_hash(); $items = $this->get_cart_items(); $total = (float) WC()->cart->get_total( 'edit' ); // Calculate shipping_tax_rate. $shipping_tax = (float) WC()->cart->get_shipping_tax(); if ( $shipping_total > 0 && $shipping_tax > 0 ) { $shipping_tax_rate = $shipping_tax / $shipping_total; } } return array( 'order_id' => $order_id, 'customer_id' => $customer_id, 'total' => $total, 'items_total' => $items_total, 'shipping_total' => $shipping_total, 'tax_total' => $tax_total, 'shipping_tax_rate' => $shipping_tax_rate, 'discount_total' => $discount_total, 'currency' => $currency, 'cart_hash' => $cart_hash, 'items' => $items, ); } catch ( \Exception $e ) { // Graceful degradation - return structure with default values. return array( 'order_id' => null, 'customer_id' => 'guest', 'total' => 0, 'items_total' => 0, 'shipping_total' => 0, 'tax_total' => 0, 'shipping_tax_rate' => null, 'discount_total' => 0, 'currency' => WC()->call_function( 'get_woocommerce_currency' ), 'cart_hash' => null, 'items' => array(), ); } } /** * Get cart items with detailed product information. * * Iterates through cart items and extracts comprehensive product data including * name, description, category, SKU, pricing, quantities, and WooCommerce-specific * attributes. Returns array of item objects with 12 fields each. * * @since 10.5.0 * * @return array Array of cart item objects with detailed product information. */ private function get_cart_items(): array { $items = array(); try { if ( ! WC()->cart instanceof \WC_Cart ) { return $items; } foreach ( WC()->cart->get_cart() as $cart_item ) { try { $product = $cart_item['data'] ?? null; if ( ! $product instanceof \WC_Product ) { continue; } $quantity = $cart_item['quantity'] ?? 1; // Calculate per-unit amounts. $unit_price = (float) $product->get_price(); $line_tax = $cart_item['line_tax'] ?? 0; $unit_tax_amount = $quantity > 0 ? ( (float) $line_tax / $quantity ) : 0; $line_discount = $cart_item['line_subtotal'] - $cart_item['line_total']; $unit_discount_amount = $quantity > 0 ? ( (float) $line_discount / $quantity ) : 0; $category = $this->get_product_category_names( $product ); $items[] = array( 'name' => $product->get_name() ? $product->get_name() : null, 'description' => $product->get_description() ? $product->get_description() : null, 'category' => $category, 'sku' => $product->get_sku() ? $product->get_sku() : null, 'quantity' => $quantity, 'unit_price' => $unit_price, 'unit_tax_amount' => $unit_tax_amount, 'unit_discount_amount' => $unit_discount_amount, 'product_type' => $product->get_type() ? $product->get_type() : null, 'is_virtual' => $product->is_virtual(), 'is_downloadable' => $product->is_downloadable(), 'attributes' => $product->get_attributes() ? $product->get_attributes() : array(), ); } catch ( \Exception $e ) { // Skip this item if there's an error, continue with next item. continue; } } } catch ( \Exception $e ) { // Return empty array on error. return array(); } return $items; } /** * Get billing address from customer data. * * Collects billing address fields from WC_Customer object with graceful degradation. * Returns array with 6 address fields, sanitized with sanitize_text_field(). * * @since 10.5.0 * * @return array Billing address array with 6 keys. */ private function get_billing_address(): array { $billing_data = array( 'first_name' => null, 'last_name' => null, 'address' => null, 'address_1' => null, 'address_2' => null, 'city' => null, 'state' => null, 'country' => null, 'phone' => null, 'postcode' => null, ); try { // Try WC_Customer object first. if ( WC()->customer instanceof \WC_Customer ) { $billing_data = array_merge( $billing_data, array( 'first_name' => \sanitize_text_field( WC()->customer->get_billing_first_name() ), 'last_name' => \sanitize_text_field( WC()->customer->get_billing_last_name() ), 'address_1' => \sanitize_text_field( WC()->customer->get_billing_address_1() ), 'address_2' => \sanitize_text_field( WC()->customer->get_billing_address_2() ), 'city' => \sanitize_text_field( WC()->customer->get_billing_city() ), 'state' => \sanitize_text_field( WC()->customer->get_billing_state() ), 'country' => \sanitize_text_field( WC()->customer->get_billing_country() ), 'phone' => \sanitize_text_field( WC()->customer->get_billing_phone() ), 'postcode' => \sanitize_text_field( WC()->customer->get_billing_postcode() ), ) ); } elseif ( WC()->session instanceof \WC_Session ) { // Fallback to session customer data if WC_Customer not available. $customer_data = WC()->session->get( 'customer' ); if ( is_array( $customer_data ) ) { $billing_data = array_merge( $billing_data, array( 'first_name' => \sanitize_text_field( $customer_data['first_name'] ?? null ), 'last_name' => \sanitize_text_field( $customer_data['last_name'] ?? null ), 'address' => \sanitize_text_field( $customer_data['address'] ?? null ), 'address_1' => \sanitize_text_field( $customer_data['address_1'] ?? null ), 'address_2' => \sanitize_text_field( $customer_data['address_2'] ?? null ), 'city' => \sanitize_text_field( $customer_data['city'] ?? null ), 'state' => \sanitize_text_field( $customer_data['state'] ?? null ), 'country' => \sanitize_text_field( $customer_data['country'] ?? null ), 'phone' => \sanitize_text_field( $customer_data['phone'] ?? null ), 'postcode' => \sanitize_text_field( $customer_data['postcode'] ?? null ), ) ); } } } catch ( \Exception $e ) { // phpcs:ignore Generic.CodeAnalysis.EmptyStatement.DetectedCatch // Graceful degradation - prevents any errors from being thrown. } return $billing_data; } /** * Get shipping address from customer data. * * Collects shipping address fields from WC_Customer object with graceful degradation. * Returns array with 6 address fields, sanitized with sanitize_text_field(). * * @since 10.5.0 * * @return array Shipping address array with 6 keys. */ private function get_shipping_address(): array { $shipping_data = array( 'first_name' => null, 'last_name' => null, 'address' => null, 'address_1' => null, 'address_2' => null, 'city' => null, 'state' => null, 'postcode' => null, 'country' => null, ); try { if ( WC()->customer instanceof \WC_Customer ) { $shipping_data = array_merge( $shipping_data, array( 'first_name' => \sanitize_text_field( WC()->customer->get_shipping_first_name() ), 'last_name' => \sanitize_text_field( WC()->customer->get_shipping_last_name() ), 'address_1' => \sanitize_text_field( WC()->customer->get_shipping_address_1() ), 'address_2' => \sanitize_text_field( WC()->customer->get_shipping_address_2() ), 'city' => \sanitize_text_field( WC()->customer->get_shipping_city() ), 'state' => \sanitize_text_field( WC()->customer->get_shipping_state() ), 'postcode' => \sanitize_text_field( WC()->customer->get_shipping_postcode() ), 'country' => \sanitize_text_field( WC()->customer->get_shipping_country() ), ) ); } elseif ( WC()->session instanceof \WC_Session ) { // Fallback to session customer data if WC_Customer not available. $customer_data = WC()->session->get( 'customer' ); if ( is_array( $customer_data ) ) { $shipping_data = array_merge( $shipping_data, array( 'first_name' => \sanitize_text_field( $customer_data['shipping_first_name'] ?? null ), 'last_name' => \sanitize_text_field( $customer_data['shipping_last_name'] ?? null ), 'address_1' => \sanitize_text_field( $customer_data['shipping_address_1'] ?? null ), 'address_2' => \sanitize_text_field( $customer_data['shipping_address_2'] ?? null ), 'city' => \sanitize_text_field( $customer_data['shipping_city'] ?? null ), 'state' => \sanitize_text_field( $customer_data['shipping_state'] ?? null ), 'postcode' => \sanitize_text_field( $customer_data['shipping_postcode'] ?? null ), 'country' => \sanitize_text_field( $customer_data['shipping_country'] ?? null ), ) ); } } } catch ( \Exception $e ) { // phpcs:ignore Generic.CodeAnalysis.EmptyStatement.DetectedCatch // Graceful degradation - returns as much data as possible. } return $shipping_data; } /** * Get client IP address using WooCommerce geolocation utility. * * @since 10.5.0 * * @return string|null IP address or null if not available. */ private function get_ip_address(): ?string { if ( class_exists( 'WC_Geolocation' ) ) { $ip = \WC_Geolocation::get_ip_address(); return $ip ? $ip : null; } return null; } /** * Get customer email with fallback chain. * * Tries logged-in user email first, then WC_Customer billing email, * then session customer data as fallback. * * @since 10.5.0 * * @return string|null Email address or null if not available. */ private function get_email(): ?string { // Try logged-in user first. if ( \is_user_logged_in() ) { $user = \wp_get_current_user(); if ( $user && $user->user_email ) { return \sanitize_email( $user->user_email ); } } // Try WC_Customer object. if ( WC()->customer instanceof \WC_Customer ) { $email = WC()->customer->get_billing_email(); if ( $email ) { return \sanitize_email( $email ); } } // Fallback to session customer data if WC_Customer not available. if ( WC()->session instanceof \WC_Session ) { $customer_data = WC()->session->get( 'customer' ); if ( is_array( $customer_data ) && ! empty( $customer_data['email'] ) ) { return \sanitize_email( $customer_data['email'] ); } } return null; } /** * Get user agent string from HTTP headers. * * @since 10.5.0 * * @return string|null User agent or null if not available. */ private function get_user_agent(): ?string { if ( isset( $_SERVER['HTTP_USER_AGENT'] ) ) { return sanitize_text_field( wp_unslash( $_SERVER['HTTP_USER_AGENT'] ) ); } return null; } /** * Get product category names as comma-separated list. * * Uses WooCommerce helper with caching for better performance. * Returns all categories for the product, not just the primary one. * * @since 10.5.0 * * @param \WC_Product $product The product object. * @return string|null Comma-separated category names or null if none. */ private function get_product_category_names( \WC_Product $product ): ?string { $terms = WC()->call_function( 'wc_get_product_terms', $product->get_id(), 'product_cat' ); if ( empty( $terms ) || ! is_array( $terms ) ) { return null; } $category_names = array_map( function ( $term ) { return $term->name; }, $terms ); return implode( ', ', $category_names ); } } FraudProtection/JetpackConnectionManager.php 0000777 00000005574 15251706115 0015306 0 ustar 00 <?php /** * JetpackConnectionManager class file. */ declare( strict_types=1 ); namespace Automattic\WooCommerce\Internal\FraudProtection; use Automattic\WooCommerce\Internal\Jetpack\JetpackConnection; defined( 'ABSPATH' ) || exit; /** * Manages Jetpack connection status and validation for fraud protection. * * Provides centralized methods to check connection status, validate requirements, * and handle connection-related errors gracefully. * * @since 10.5.0 */ class JetpackConnectionManager { /** * Get the Jetpack blog ID. * * @return int|null Blog ID if available, null otherwise. */ public function get_blog_id(): ?int { // Get blog ID from Jetpack options. $blog_id = \Jetpack_Options::get_option( 'id' ); return $blog_id ? (int) $blog_id : null; } /** * Get connection status with detailed error information. * * Returns an array with connection status and any error details. * * @return array { * Connection status information. * * @type bool $connected Whether the site is connected. * @type string $error Error message if not connected. * @type string $error_code Error code if not connected. * @type int $blog_id Blog ID if available. * } */ public function get_connection_status(): array { $status = array( 'connected' => false, 'error' => '', 'error_code' => '', 'blog_id' => null, ); // Check if connected. if ( ! JetpackConnection::get_manager()->is_connected() ) { $status['error'] = __( 'Site is not connected to WordPress.com. Please connect your site to enable fraud protection.', 'woocommerce' ); $status['error_code'] = 'not_connected'; return $status; } // Get blog ID. $blog_id = $this->get_blog_id(); if ( ! $blog_id ) { $status['error'] = __( 'Jetpack blog ID not found. Please reconnect your site to WordPress.com.', 'woocommerce' ); $status['error_code'] = 'no_blog_id'; return $status; } // All checks passed. $status['connected'] = true; $status['blog_id'] = $blog_id; return $status; } /** * Get the Jetpack authorization URL for connecting the site. * * @param string $redirect_url URL to redirect to after authorization. * @return string|null Authorization URL or null on error. */ public function get_authorization_url( string $redirect_url = '' ): ?string { // If no redirect URL provided, use current admin URL. if ( empty( $redirect_url ) ) { $redirect_url = admin_url( 'admin.php?page=wc-settings&tab=advanced§ion=features' ); } $authorization_data = JetpackConnection::get_authorization_url( $redirect_url, 'woocommerce-fraud-protection' ); if ( ! $authorization_data['success'] ) { FraudProtectionController::log( 'error', 'Failed to get Jetpack authorization URL.', $authorization_data['errors'] ); return null; } return $authorization_data['url']; } } FraudProtection/AdminSettingsHandler.php 0000777 00000011177 15251706115 0014455 0 ustar 00 <?php /** * AdminSettingsHandler class file. */ declare( strict_types=1 ); namespace Automattic\WooCommerce\Internal\FraudProtection; defined( 'ABSPATH' ) || exit; /** * Handles admin settings for fraud protection. * * @since 10.5.0 */ class AdminSettingsHandler { /** * Jetpack connection manager instance. * * @var JetpackConnectionManager */ private $connection_manager; /** * Register hooks. */ public function register(): void { add_filter( 'woocommerce_get_settings_advanced', array( $this, 'add_jetpack_connection_field' ), 100, 2 ); add_action( 'woocommerce_admin_field_jetpack_connection', array( $this, 'handle_output_jetpack_connection_field' ), 10, 1 ); } /** * Initialize the class with dependencies. * * @internal * * @param JetpackConnectionManager $connection_manager Jetpack connection manager instance. * @return void */ final public function init( JetpackConnectionManager $connection_manager ): void { $this->connection_manager = $connection_manager; } /** * Add Jetpack connection field to fraud protection settings. * * @internal * * @param array $settings Existing settings. * @param string $current_section Current section name. * @return array Modified settings. */ public function add_jetpack_connection_field( $settings, $current_section ): array { // Only add on the features section. if ( 'features' !== $current_section ) { return $settings; } // Check if field already exists to prevent duplicates. foreach ( $settings as $setting ) { if ( isset( $setting['id'] ) && 'woocommerce_fraud_protection_jetpack_connection' === $setting['id'] ) { return $settings; } } // Find the fraud_protection field and add Jetpack connection field after it. $new_settings = array(); foreach ( $settings as $setting ) { $new_settings[] = $setting; // Add Jetpack connection field after fraud_protection checkbox. if ( isset( $setting['id'] ) && 'woocommerce_feature_fraud_protection_enabled' === $setting['id'] ) { $new_settings[] = array( 'id' => 'woocommerce_fraud_protection_jetpack_connection', 'type' => 'jetpack_connection', 'title' => __( 'Jetpack Connection', 'woocommerce' ), 'desc' => __( 'Connect your site to Jetpack to enable fraud protection features.', 'woocommerce' ), ); } } return $new_settings; } /** * Output the Jetpack connection field. * * @internal * * @param array $value Field configuration. * @return void */ public function handle_output_jetpack_connection_field( $value ): void { // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.Found // Only show Jetpack connection when fraud protection is enabled. if ( 'yes' !== get_option( 'woocommerce_feature_fraud_protection_enabled', 'no' ) ) { return; } $this->output_jetpack_connection_status(); } /** * Output the Jetpack connection status and button. * * @internal * * @return void */ private function output_jetpack_connection_status(): void { // Get connection status from connection manager. $connection_status = $this->connection_manager->get_connection_status(); ?> <tr valign="top"> <th scope="row" class="titledesc"> <label><?php esc_html_e( 'Jetpack Connection', 'woocommerce' ); ?></label> </th> <td class="forminp forminp-button"> <?php if ( ! $connection_status['connected'] ) : ?> <?php // Get authorization URL for connecting. $redirect_url = admin_url( 'admin.php?page=wc-settings&tab=advanced§ion=features' ); $connection_url = $this->connection_manager->get_authorization_url( $redirect_url ); // If we couldn't get authorization URL, show error message. if ( ! $connection_url ) : ?> <p class="description" style="color: #dc3232;"> <?php echo esc_html( $connection_status['error'] ); ?> </p> <?php else : ?> <a href="<?php echo esc_url( $connection_url ); ?>" class="button button-secondary jetpack_connection_button"> <?php esc_html_e( 'Connect to Jetpack', 'woocommerce' ); ?> </a> <p class="description"> <?php esc_html_e( 'Connect your site to Jetpack to enable fraud protection features.', 'woocommerce' ); ?> </p> <?php endif; ?> <?php else : ?> <span class="dashicons dashicons-yes-alt" style="color: #46b450;"></span> <span><?php esc_html_e( 'Connected to Jetpack', 'woocommerce' ); ?></span> <p class="description"> <?php printf( /* translators: %d: Blog ID */ esc_html__( 'Site ID: %d', 'woocommerce' ), (int) $connection_status['blog_id'] ); ?> </p> <?php endif; ?> </td> </tr> <?php } } FraudProtection/FraudProtectionTracker.php 0000777 00000004202 15251706115 0015021 0 ustar 00 <?php /** * FraudProtectionTracker class file. */ declare( strict_types=1 ); namespace Automattic\WooCommerce\Internal\FraudProtection; defined( 'ABSPATH' ) || exit; /** * Centralized fraud protection event tracker. * * This class provides a unified interface for tracking fraud protection events. * It logs events for the fraud protection service using already-collected data. * * @since 10.5.0 * @internal This class is part of the internal API and is subject to change without notice. */ class FraudProtectionTracker { /** * Track fraud protection event with already-collected data. * * This method accepts fully-collected event data (including session context) * and logs it for the fraud protection service. * * The method implements graceful degradation - any errors during tracking * will be logged but will not break the functionality. * * @param string $event_type Event type identifier (e.g., 'cart_item_added'). * @param array $collected_data Fully-collected event data including session context. * @return void */ public function track_event( string $event_type, array $collected_data ): void { try { // phpcs:ignore Generic.Commenting.Todo.TaskFound // TODO: Once EventTracker/API client is implemented (WOOSUBS-1249), call it here: // $event_tracker = wc_get_container()->get( EventTracker::class ); // $event_tracker->track( $event_type, $collected_data ); // // For now, log the event for debugging and verification. FraudProtectionController::log( 'info', sprintf( 'Fraud protection event tracked: %s | Session ID: %s', $event_type, $collected_data['session']['session_id'] ?? 'N/A' ), array( 'event_type' => $event_type, 'collected_data' => $collected_data, ) ); } catch ( \Exception $e ) { // Gracefully handle errors - fraud protection should never break functionality. FraudProtectionController::log( 'error', sprintf( 'Failed to track fraud protection event: %s | Error: %s', $event_type, $e->getMessage() ), array( 'event_type' => $event_type, 'exception' => $e, ) ); } } } FraudProtection/ApiClient.php 0000777 00000014422 15251706115 0012252 0 ustar 00 <?php /** * ApiClient class file. */ declare( strict_types=1 ); namespace Automattic\WooCommerce\Internal\FraudProtection; use Automattic\Jetpack\Connection\Client as Jetpack_Connection_Client; defined( 'ABSPATH' ) || exit; /** * Handles communication with the WPCOM fraud protection endpoint. * * Uses Jetpack Connection for authenticated requests to the WPCOM endpoint * to get fraud protection decisions (allow, block, or challenge). * * This class implements a fail-open pattern: if the endpoint is unreachable, * times out, or returns an error, it returns an "allow" decision to ensure * legitimate transactions are never blocked due to service issues. * * @since 10.5.0 * @internal This class is part of the internal API and is subject to change without notice. */ class ApiClient { /** * Default timeout for API requests in seconds. */ private const DEFAULT_TIMEOUT = 30; /** * WPCOM API version. */ private const WPCOM_API_VERSION = '2'; /** * WPCOM fraud protection events endpoint path within Transact platform. */ private const EVENTS_ENDPOINT = 'transact/fraud_protection/events'; /** * Decision type: allow session. */ public const DECISION_ALLOW = 'allow'; /** * Decision type: block session. */ public const DECISION_BLOCK = 'block'; /** * Decision type: challenge session. */ public const DECISION_CHALLENGE = 'challenge'; /** * Valid decision values that can be returned by the API. * * @var array<string> */ public const VALID_DECISIONS = array( self::DECISION_ALLOW, self::DECISION_BLOCK, ); /** * Send a fraud protection event and get a decision from WPCOM endpoint. * * Implements fail-open pattern: if the endpoint is unreachable or times out, * returns "allow" decision and logs the error. * * @since 10.5.0 * * @param string $event_type Type of event being sent (e.g., 'cart_updated', 'checkout_started'). * @param array<string, mixed> $event_data Event data to send to the endpoint. * @return string Decision: "allow" or "block". */ public function send_event( string $event_type, array $event_data ): string { $payload = array_merge( array( 'event_type' => $event_type ), array_filter( $event_data, fn( $value ) => null !== $value ) ); FraudProtectionController::log( 'info', sprintf( 'Sending fraud protection event: %s', $event_type ), array( 'payload' => $payload ) ); $response = $this->make_request( 'POST', self::EVENTS_ENDPOINT, $payload ); if ( is_wp_error( $response ) ) { $error_data = $response->get_error_data() ?? array(); $error_data = is_array( $error_data ) ? $error_data : array( 'error' => $error_data ); FraudProtectionController::log( 'error', sprintf( 'Event track request failed: %s. Failing open with "allow" decision.', $response->get_error_message() ), $error_data ); return self::DECISION_ALLOW; } if ( ! isset( $response['decision'] ) ) { FraudProtectionController::log( 'error', 'Response missing "decision" field. Failing open with "allow" decision.', array( 'response' => $response ) ); return self::DECISION_ALLOW; } $decision = $response['decision']; if ( ! in_array( $decision, self::VALID_DECISIONS, true ) ) { FraudProtectionController::log( 'error', sprintf( 'Invalid decision value "%s". Failing open with "allow" decision.', $decision ), array( 'response' => $response ) ); return self::DECISION_ALLOW; } $session = is_array( $event_data['session'] ?? null ) ? $event_data['session'] : array(); $session_id = $session['session_id'] ?? 'unknown'; FraudProtectionController::log( 'info', sprintf( 'Fraud decision received: %s | Event: %s | Session: %s', $decision, $event_type, $session_id ), array( 'response' => $response ) ); return $decision; } /** * Make an HTTP request to a WPCOM endpoint via Jetpack Connection. * * @param string $method HTTP method (GET, POST, etc.). * @param string $path Endpoint path (relative to sites/{blog_id}/). * @param array<string, mixed> $payload Request payload. * @return array<string, mixed>|\WP_Error Parsed JSON response or WP_Error on failure. */ private function make_request( string $method, string $path, array $payload ) { if ( ! class_exists( Jetpack_Connection_Client::class ) ) { return new \WP_Error( 'jetpack_not_available', 'Jetpack Connection is not available' ); } $blog_id = $this->get_blog_id(); if ( ! $blog_id ) { return new \WP_Error( 'no_blog_id', 'Jetpack blog ID not found. Is the site connected to WordPress.com?' ); } $full_path = sprintf( 'sites/%d/%s', $blog_id, $path ); $body = \wp_json_encode( $payload ); if ( false === $body ) { return new \WP_Error( 'json_encode_error', 'Failed to encode payload', array( 'payload' => $payload ) ); } $response = Jetpack_Connection_Client::wpcom_json_api_request_as_blog( $full_path, self::WPCOM_API_VERSION, array( 'headers' => array( 'Content-Type' => 'application/json' ), 'method' => $method, 'timeout' => self::DEFAULT_TIMEOUT, ), $body, 'wpcom' ); if ( is_wp_error( $response ) ) { return $response; } /** * Type assertion for PHPStan - Jetpack returns array on success. * * @var array $response */ $response_code = wp_remote_retrieve_response_code( $response ); $response_body = wp_remote_retrieve_body( $response ); $data = json_decode( $response_body, true ); if ( $response_code >= 300 ) { return new \WP_Error( 'api_error', sprintf( 'Endpoint %s returned status code %d', "$method $path", $response_code ), array( 'response' => JSON_ERROR_NONE === json_last_error() ? $data : $response_body ) ); } if ( JSON_ERROR_NONE !== json_last_error() || ! is_array( $data ) ) { return new \WP_Error( 'json_decode_error', sprintf( 'Failed to decode JSON response: %s', json_last_error_msg() ), array( 'response' => $response_body ) ); } return $data; } /** * Get the Jetpack blog ID. * * @return int|false Blog ID or false if not available. */ private function get_blog_id() { if ( ! class_exists( \Jetpack_Options::class ) ) { return false; } return \Jetpack_Options::get_option( 'id' ); } } FraudProtection/FraudProtectionController.php 0000777 00000011211 15251706115 0015547 0 ustar 00 <?php /** * FraudProtectionController class file. */ declare( strict_types=1 ); namespace Automattic\WooCommerce\Internal\FraudProtection; use Automattic\WooCommerce\Internal\Features\FeaturesController; use Automattic\WooCommerce\Internal\RegisterHooksInterface; defined( 'ABSPATH' ) || exit; /** * Main controller for fraud protection features. * * This class orchestrates all fraud protection components and ensures * zero-impact when the feature flag is disabled. * * @since 10.5.0 * @internal This class is part of the internal API and is subject to change without notice. */ class FraudProtectionController implements RegisterHooksInterface { /** * Features controller instance. * * @var FeaturesController */ private FeaturesController $features_controller; /** * Jetpack connection manager instance. * * @var JetpackConnectionManager */ private JetpackConnectionManager $connection_manager; /** * Blocked session notice instance. * * @var BlockedSessionNotice */ private BlockedSessionNotice $blocked_session_notice; /** * Register hooks. */ public function register(): void { add_action( 'init', array( $this, 'on_init' ) ); add_action( 'admin_notices', array( $this, 'on_admin_notices' ) ); } /** * Initialize the instance, runs when the instance is created by the dependency injection container. * * @internal * * @param FeaturesController $features_controller The instance of FeaturesController to use. * @param JetpackConnectionManager $connection_manager The instance of JetpackConnectionManager to use. * @param BlockedSessionNotice $blocked_session_notice The instance of BlockedSessionNotice to use. */ final public function init( FeaturesController $features_controller, JetpackConnectionManager $connection_manager, BlockedSessionNotice $blocked_session_notice ): void { $this->features_controller = $features_controller; $this->connection_manager = $connection_manager; $this->blocked_session_notice = $blocked_session_notice; } /** * Hook into WordPress on init. * * @internal */ public function on_init(): void { // Bail if the feature is not enabled. if ( ! $this->feature_is_enabled() ) { return; } $this->blocked_session_notice->register(); } /** * Display admin notice when Jetpack connection is not available. * * @internal */ public function on_admin_notices(): void { // Only show if feature is enabled. if ( ! $this->feature_is_enabled() ) { return; } // Only show on WooCommerce settings page. $screen = get_current_screen(); if ( ! $screen || 'woocommerce_page_wc-settings' !== $screen->id ) { return; } $connection_status = $this->connection_manager->get_connection_status(); if ( $connection_status['connected'] ) { return; } $settings_url = admin_url( 'admin.php?page=wc-settings&tab=advanced§ion=features' ); ?> <div class="notice notice-warning is-dismissible"> <p> <strong><?php esc_html_e( 'Fraud protection warning:', 'woocommerce' ); ?></strong> <?php echo esc_html( $connection_status['error'] ); ?> </p> <p> <?php printf( /* translators: %s: Settings page URL */ wp_kses_post( __( 'Fraud protection will fail open and allow all sessions until connected. <a href="%s">Connect to Jetpack</a>', 'woocommerce' ) ), esc_url( $settings_url ) ); ?> </p> </div> <?php } /** * Check if fraud protection feature is enabled. * * This method can be used by other fraud protection classes to check * the feature flag status. Returns false (fail-open) if init hasn't run yet. * * @return bool True if enabled, false if not enabled or init hasn't run yet. */ public function feature_is_enabled(): bool { // Fail-open: don't block if init hasn't run yet to avoid FeaturesController translation notices. if ( ! did_action( 'init' ) ) { return false; } return $this->features_controller->feature_is_enabled( 'fraud_protection' ); } /** * Log helper method for consistent logging across all fraud protection components. * * This static method ensures all fraud protection logs are written with * the same 'woo-fraud-protection' source for easy filtering in WooCommerce logs. * * @param string $level Log level (emergency, alert, critical, error, warning, notice, info, debug). * @param string $message Log message. * @param array $context Optional context data. * * @return void */ public static function log( string $level, string $message, array $context = array() ): void { wc_get_logger()->log( $level, $message, array_merge( $context, array( 'source' => 'woo-fraud-protection' ) ) ); } } FraudProtection/DecisionHandler.php 0000777 00000011475 15251706115 0013442 0 ustar 00 <?php /** * DecisionHandler class file. */ declare( strict_types=1 ); namespace Automattic\WooCommerce\Internal\FraudProtection; defined( 'ABSPATH' ) || exit; /** * Handles fraud protection decision application. * * This class is responsible for: * - Applying extension override filters for whitelisting * - Coordinating with SessionClearanceManager to apply decisions * * @since 10.5.0 * @internal This class is part of the internal API and is subject to change without notice. */ class DecisionHandler { /** * Session clearance manager instance. * * @var SessionClearanceManager */ private SessionClearanceManager $session_manager; /** * Initialize with dependencies. * * @internal * * @param SessionClearanceManager $session_manager The session clearance manager instance. */ final public function init( SessionClearanceManager $session_manager ): void { $this->session_manager = $session_manager; } /** * Apply a fraud protection decision. * * This method processes a decision from the API, applies any override filters, * validates the result, and updates the session status accordingly. * * The input decision is expected to be pre-validated by ApiClient. * * The decision flow: * 1. Apply the `woocommerce_fraud_protection_decision` filter for overrides * 2. Validate the filtered decision (third-party filters may return invalid values) * 3. Update session status via SessionClearanceManager * * @since 10.5.0 * * @param string $decision The decision from the API (allow, block). * @param array<string, mixed> $session_data The session data that was sent to the API. * @return string The final applied decision after any filter overrides. */ public function apply_decision( string $decision, array $session_data ): string { // Validate input decision and fail open if invalid. if ( ! $this->is_valid_decision( $decision ) ) { FraudProtectionController::log( 'warning', sprintf( 'Invalid decision "%s" received. Defaulting to "allow".', $decision ), array( 'session_data' => $session_data ) ); $decision = ApiClient::DECISION_ALLOW; } $original_decision = $decision; /** * Filters the fraud protection decision before it is applied. * * This filter allows extensions to override fraud protection decisions * to implement custom whitelisting logic. Common use cases: * - Whitelist specific users (e.g., admins, trusted customers) * - Whitelist specific conditions (e.g., certain IP ranges, logged-in users) * - Integrate with external fraud detection services * * Note: This filter can only change the decision to ApiClient::VALID_DECISIONS. * Any other value will be rejected and the original decision will be used. * * @since 10.5.0 * * @param string $decision The decision from the API (allow, block). * @param array<string, mixed> $session_data The session data that was analyzed. */ $decision = apply_filters( 'woocommerce_fraud_protection_decision', $decision, $session_data ); // Validate filtered decision (third-party filters may return invalid values). if ( ! $this->is_valid_decision( $decision ) ) { FraudProtectionController::log( 'warning', sprintf( 'Filter `woocommerce_fraud_protection_decision` returned invalid decision "%s". Using original decision "%s".', $decision, $original_decision ), array( 'original_decision' => $original_decision, 'filtered_decision' => $decision, 'session_data' => $session_data, ) ); $decision = $original_decision; } // Log if decision was overridden. if ( $decision !== $original_decision ) { FraudProtectionController::log( 'info', sprintf( 'Decision overridden by filter `woocommerce_fraud_protection_decision`: "%s" -> "%s"', $original_decision, $decision ), array( 'original_decision' => $original_decision, 'final_decision' => $decision, 'session_data' => $session_data, ) ); } // Apply the decision to the session. $this->update_session_status( $decision ); return $decision; } /** * Check if a decision value is valid. * * @param mixed $decision The decision to validate. * @return bool True if valid, false otherwise. */ private function is_valid_decision( $decision ): bool { if ( ! is_string( $decision ) ) { return false; } return in_array( $decision, ApiClient::VALID_DECISIONS, true ); } /** * Update the session status based on the decision. * * @param string $decision The validated decision to apply. * @return void */ private function update_session_status( string $decision ): void { switch ( $decision ) { case ApiClient::DECISION_ALLOW: $this->session_manager->allow_session(); break; case ApiClient::DECISION_BLOCK: $this->session_manager->block_session(); break; } } } FraudProtection/FraudProtectionDispatcher.php 0000777 00000011016 15251706115 0015515 0 ustar 00 <?php /** * FraudProtectionDispatcher class file. */ declare( strict_types=1 ); namespace Automattic\WooCommerce\Internal\FraudProtection; defined( 'ABSPATH' ) || exit; /** * Centralized fraud protection event dispatcher. * * This class provides a unified interface for dispatching fraud protection events. * It coordinates data collection and transmission for fraud protection events by * orchestrating ApiClient and DecisionHandler components. * * @since 10.5.0 * @internal This class is part of the internal API and is subject to change without notice. */ class FraudProtectionDispatcher { /** * API client instance. * * @var ApiClient */ private ApiClient $api_client; /** * Decision handler instance. * * @var DecisionHandler */ private DecisionHandler $decision_handler; /** * Fraud protection controller instance. * * @var FraudProtectionController */ private FraudProtectionController $fraud_protection_controller; /** * Session data collector instance. * * @var SessionDataCollector */ private SessionDataCollector $data_collector; /** * Initialize with dependencies. * * @internal * * @param ApiClient $api_client The API client instance. * @param DecisionHandler $decision_handler The decision handler instance. * @param FraudProtectionController $fraud_protection_controller The fraud protection controller instance. * @param SessionDataCollector $data_collector The session data collector instance. */ final public function init( ApiClient $api_client, DecisionHandler $decision_handler, FraudProtectionController $fraud_protection_controller, SessionDataCollector $data_collector ): void { $this->api_client = $api_client; $this->decision_handler = $decision_handler; $this->fraud_protection_controller = $fraud_protection_controller; $this->data_collector = $data_collector; } /** * Dispatch fraud protection event. * * This method collects session data and dispatches it to the fraud protection service. * It orchestrates the following flow: * 1. Check if feature is enabled (fail-open if not) * 2. Collect comprehensive session data via SessionDataCollector * 3. Apply extension data filter to allow custom data * 4. Send event to API and get decision * 5. Apply decision via DecisionHandler * * The method implements graceful degradation - any errors during tracking * will be logged but will not break the functionality. * * @param string $event_type Event type identifier (e.g., 'cart_item_added'). * @param array $event_data Optional event-specific data to include with session data. * @return void */ public function dispatch_event( string $event_type, array $event_data = array() ): void { try { // Check if feature is enabled - fail-open if not. if ( ! $this->fraud_protection_controller->feature_is_enabled() ) { FraudProtectionController::log( 'debug', sprintf( 'Fraud protection event not dispatched (feature disabled): %s', $event_type ), array( 'event_type' => $event_type ) ); return; } // Collect comprehensive session data. $collected_data = $this->data_collector->collect( $event_type, $event_data ); /** * Filters the fraud protection event data before sending to the API. * * This filter allows extensions to modify or add custom data to fraud protection * events. Common use cases include: * - Adding custom payment gateway data * - Adding subscription-specific context * - Adding custom risk signals * * @since 10.5.0 * * @param array $collected_data Fully-collected event data including session context. * @param string $event_type Event type identifier (e.g., 'cart_item_added'). */ $collected_data = apply_filters( 'woocommerce_fraud_protection_event_data', $collected_data, $event_type ); // Send event to API and get decision. $decision = $this->api_client->send_event( $event_type, $collected_data ); // Apply decision via DecisionHandler. $this->decision_handler->apply_decision( $decision, $collected_data ); } catch ( \Exception $e ) { // Gracefully handle errors - fraud protection should never break functionality. FraudProtectionController::log( 'error', sprintf( 'Failed to dispatch fraud protection event: %s | Error: %s', $event_type, $e->getMessage() ), array( 'event_type' => $event_type, 'exception' => $e, ) ); } } } FraudProtection/CheckoutEventTracker.php 0000777 00000021152 15251706115 0014463 0 ustar 00 <?php /** * CheckoutEventTracker class file. */ declare( strict_types=1 ); namespace Automattic\WooCommerce\Internal\FraudProtection; defined( 'ABSPATH' ) || exit; /** * Tracks checkout events for fraud protection analysis. * * This class provides methods to track both WooCommerce Blocks (Store API) and traditional * shortcode checkout events for fraud protection event dispatching. * Event-specific data is passed to the dispatcher which handles session data collection internally. * * @since 10.5.0 * @internal This class is part of the internal API and is subject to change without notice. */ class CheckoutEventTracker { /** * Fraud protection dispatcher instance. * * @var FraudProtectionDispatcher */ private FraudProtectionDispatcher $dispatcher; /** * Session data collector instance. * * @var SessionDataCollector */ private SessionDataCollector $session_data_collector; /** * Initialize with dependencies. * * @internal * * @param FraudProtectionDispatcher $dispatcher The fraud protection dispatcher instance. * @param SessionDataCollector $session_data_collector The session data collector instance. */ final public function init( FraudProtectionDispatcher $dispatcher, SessionDataCollector $session_data_collector ): void { $this->dispatcher = $dispatcher; $this->session_data_collector = $session_data_collector; } /** * Track checkout page loaded event. * * Triggers fraud protection event dispatching when the checkout page is initially loaded. * This captures the initial session state before any user interactions. * * @internal * @return void */ public function track_checkout_page_loaded(): void { // Track the page load event. Session data will be collected by the dispatcher. $this->dispatcher->dispatch_event( 'checkout_page_loaded', array() ); } /** * Track Store API customer update event (WooCommerce Blocks checkout). * * Triggered when customer information is updated via the Store API endpoint * /wc/store/v1/cart/update-customer during Blocks checkout flow. * * @internal * @return void */ public function track_blocks_checkout_update(): void { // At this point we don't have any payment or shipping data, so we pass an empty array. $this->dispatcher->dispatch_event( 'checkout_update', array() ); } /** * Track shortcode checkout field update event. * * Triggered when checkout fields are updated via AJAX (woocommerce_update_order_review). * Only dispatches event when billing or shipping country changes to reduce unnecessary API calls. * * @internal * * @param string $posted_data Serialized checkout form data. * @return void */ public function track_shortcode_checkout_field_update( $posted_data ): void { // Parse the posted data to extract relevant fields. $data = array(); if ( $posted_data ) { parse_str( $posted_data, $data ); } // Get current customer countries using SessionDataCollector. $current_billing_country = $this->session_data_collector->get_current_billing_country(); $current_shipping_country = $this->session_data_collector->get_current_shipping_country(); // Get posted countries. $posted_billing_country = $data['billing_country'] ?? ''; $posted_shipping_country = $data['shipping_country'] ?? ''; // Check if billing country changed. $billing_changed = ! empty( $posted_billing_country ) && $posted_billing_country !== $current_billing_country; // Check if shipping country changed. $ship_to_different = ! empty( $data['ship_to_different_address'] ); if ( $ship_to_different ) { // User wants different shipping address - check if shipping country changed. $shipping_changed = ! empty( $posted_shipping_country ) && $posted_shipping_country !== $current_shipping_country; } else { // User wants same address for billing and shipping. // If current shipping country exists and differs from billing country, it's a change. $effective_billing_country = ! empty( $posted_billing_country ) ? $posted_billing_country : $current_billing_country; $shipping_changed = ! empty( $current_shipping_country ) && $current_shipping_country !== $effective_billing_country; } // Only dispatch if either country changed. if ( $billing_changed || $shipping_changed ) { $event_data = $this->format_checkout_event_data( 'field_update', $data ); $this->dispatcher->dispatch_event( 'checkout_update', $event_data ); } } /** * Build checkout event-specific data. * * Prepares the checkout event data including action type and any changed fields. * * @param string $action Action type (field_update, store_api_update). * @param array $collected_event_data Posted form data or event context (may include session data). * @return array Checkout event data. */ private function format_checkout_event_data( string $action, array $collected_event_data ): array { $event_data = array( 'action' => $action ); // Extract and merge all checkout field groups. $event_data = array_merge( $event_data, $this->extract_billing_fields( $collected_event_data ), $this->extract_shipping_fields( $collected_event_data ), $this->extract_payment_method( $collected_event_data ), ); return $event_data; } /** * Extract billing fields from posted data. * * @param array $posted_data Posted form data. * @return array Billing fields. */ private function extract_billing_fields( array $posted_data ): array { $field_map = array( 'billing_email' => 'sanitize_email', 'billing_first_name' => 'sanitize_text_field', 'billing_last_name' => 'sanitize_text_field', 'billing_country' => 'sanitize_text_field', 'billing_address_1' => 'sanitize_text_field', 'billing_address_2' => 'sanitize_text_field', 'billing_city' => 'sanitize_text_field', 'billing_state' => 'sanitize_text_field', 'billing_postcode' => 'sanitize_text_field', 'billing_phone' => 'sanitize_text_field', ); $extracted_fields = $this->extract_fields_by_map( $field_map, $posted_data ); // Store API uses 'email' instead of 'billing_email'. if ( empty( $extracted_fields['billing_email'] ) && ! empty( $posted_data['email'] ) ) { $extracted_fields['email'] = sanitize_email( $posted_data['email'] ); } return $extracted_fields; } /** * Extract shipping fields from posted data. * * @param array $posted_data Posted form data. * @return array Shipping fields. */ private function extract_shipping_fields( array $posted_data ): array { if ( ! isset( $posted_data['ship_to_different_address'] ) || ! $posted_data['ship_to_different_address'] ) { return array(); } $field_map = array( 'shipping_first_name' => 'sanitize_text_field', 'shipping_last_name' => 'sanitize_text_field', 'shipping_country' => 'sanitize_text_field', 'shipping_address_1' => 'sanitize_text_field', 'shipping_address_2' => 'sanitize_text_field', 'shipping_city' => 'sanitize_text_field', 'shipping_state' => 'sanitize_text_field', 'shipping_postcode' => 'sanitize_text_field', ); return $this->extract_fields_by_map( $field_map, $posted_data ); } /** * Extract and sanitize fields from posted data using a field map. * * Generic extraction method that iterates through a field map and extracts * non-empty fields from posted data, applying the appropriate sanitization * function to each field. * * @param array $field_map Map of field names to sanitization functions. * @param array $posted_data Posted form data. * @return array Extracted and sanitized fields. */ private function extract_fields_by_map( array $field_map, array $posted_data ): array { $extracted_fields = array(); foreach ( $field_map as $field_name => $sanitize_function ) { if ( ! empty( $posted_data[ $field_name ] ) ) { $extracted_fields[ $field_name ] = $sanitize_function( wp_unslash( $posted_data[ $field_name ] ) ); } } return $extracted_fields; } /** * Extract payment method data from posted data. * * Extracts payment method ID and retrieves the readable gateway name. * * @param array $posted_data Posted form data. * @return array Payment method data with ID and name, or empty array if not found. */ private function extract_payment_method( array $posted_data ): array { $payment_data = array(); if ( ! empty( $posted_data['payment_method'] ) ) { $payment_gateway_name = WC()->payment_gateways()->get_payment_gateway_name_by_id( $posted_data['payment_method'] ); $payment_data['payment'] = array( 'payment_gateway_type' => $posted_data['payment_method'], 'payment_gateway_name' => $payment_gateway_name, ); } return $payment_data; } } FraudProtection/BlockedSessionNotice.php 0000777 00000010520 15251706115 0014446 0 ustar 00 <?php /** * BlockedSessionNotice class file. */ declare( strict_types=1 ); namespace Automattic\WooCommerce\Internal\FraudProtection; use Automattic\WooCommerce\Internal\RegisterHooksInterface; defined( 'ABSPATH' ) || exit; /** * Handles blocked session messaging for fraud protection. * * This class provides: * - Hook into shortcode checkout to display blocked notice * - Message generation for both HTML (shortcode) and plaintext (Store API) contexts * * Note: Store API (block checkout) and payment gateway filtering are handled * directly in WC Core classes (Checkout.php and WC_Payment_Gateways). * * @since 10.5.0 * @internal This class is part of the internal API and is subject to change without notice. */ class BlockedSessionNotice implements RegisterHooksInterface { /** * Session clearance manager instance. * * @var SessionClearanceManager */ private SessionClearanceManager $session_manager; /** * Initialize with dependencies. * * @internal * * @param SessionClearanceManager $session_manager The session clearance manager instance. */ final public function init( SessionClearanceManager $session_manager ): void { $this->session_manager = $session_manager; } /** * Register hooks for displaying blocked notice. * * This method should only be called when fraud protection is enabled. * * @return void */ public function register(): void { add_action( 'woocommerce_before_checkout_form', array( $this, 'display_checkout_blocked_notice' ), 1, 0 ); add_action( 'before_woocommerce_add_payment_method', array( $this, 'display_generic_blocked_notice' ), 1, 0 ); } /** * Display blocked notice on shortcode checkout page. * * Shows a checkout-specific message explaining that the purchase cannot be * completed online and provides contact information for support. * * @internal * * @return void */ public function display_checkout_blocked_notice(): void { if ( ! $this->session_manager->is_session_blocked() ) { return; } wc_print_notice( $this->get_message_html( 'checkout' ), 'error' ); } /** * Display blocked notice for non-checkout pages. * * Shows a generic message explaining that the request cannot be * processed online and provides contact information for support. * * @internal * * @return void */ public function display_generic_blocked_notice(): void { if ( ! $this->session_manager->is_session_blocked() ) { return; } wc_print_notice( $this->get_message_html(), 'error' ); } /** * Get the blocked session message as HTML. * * Includes a mailto link for the support email. * * @param string $context Message context: 'checkout' for purchase-specific message, 'generic' for general use. * @return string HTML message with mailto link. */ public function get_message_html( string $context = 'generic' ): string { $email = WC()->mailer()->get_from_address(); if ( 'checkout' === $context ) { return sprintf( /* translators: %1$s: mailto link, %2$s: email address */ __( 'We are unable to process this request online. Please <a href="%1$s">contact support (%2$s)</a> to complete your purchase.', 'woocommerce' ), esc_url( 'mailto:' . $email ), esc_html( $email ) ); } return sprintf( /* translators: %1$s: mailto link, %2$s: email address */ __( 'We are unable to process this request online. Please <a href="%1$s">contact support (%2$s)</a> for assistance.', 'woocommerce' ), esc_url( 'mailto:' . $email ), esc_html( $email ) ); } /** * Get the blocked session message as plaintext. * * Used by Store API responses where HTML is not supported. * * @param string $context Message context: 'checkout' for purchase-specific message, 'generic' for general use. * @return string Plaintext message with email address. */ public function get_message_plaintext( string $context = 'generic' ): string { $email = WC()->mailer()->get_from_address(); if ( 'checkout' === $context ) { return sprintf( /* translators: %s: support email address */ __( 'We are unable to process this request online. Please contact support (%s) to complete your purchase.', 'woocommerce' ), $email ); } return sprintf( /* translators: %s: support email address */ __( 'We are unable to process this request online. Please contact support (%s) for assistance.', 'woocommerce' ), $email ); } } FraudProtection/CartEventTracker.php 0000777 00000013364 15251706115 0013615 0 ustar 00 <?php /** * CartEventTracker class file. */ declare( strict_types=1 ); namespace Automattic\WooCommerce\Internal\FraudProtection; defined( 'ABSPATH' ) || exit; /** * Tracks cart events for fraud protection analysis. * * This class provides methods to track cart events (add, update, remove, restore) * for fraud protection event dispatching. Event-specific data is passed * to the dispatcher which handles session data collection internally. * * @since 10.5.0 * @internal This class is part of the internal API and is subject to change without notice. */ class CartEventTracker { /** * Fraud protection dispatcher instance. * * @var FraudProtectionDispatcher */ private FraudProtectionDispatcher $dispatcher; /** * Initialize with dependencies. * * @internal * * @param FraudProtectionDispatcher $dispatcher The fraud protection dispatcher instance. */ final public function init( FraudProtectionDispatcher $dispatcher ): void { $this->dispatcher = $dispatcher; } /** * Track cart page loaded event. * * Triggers fraud protection event dispatching when the cart page is initially loaded. * This captures the initial session state before any user interactions. * * @internal * @return void */ public function track_cart_page_loaded(): void { // Track the page load event. Session data will be collected by the dispatcher. $this->dispatcher->dispatch_event( 'cart_page_loaded', array() ); } /** * Track cart item added event. * * Triggers fraud protection event dispatching when an item is added to the cart. * * @internal * * @param string $cart_item_key Cart item key. * @param int $product_id Product ID. * @param int $quantity Quantity added. * @param int $variation_id Variation ID. * @return void */ public function track_cart_item_added( $cart_item_key, $product_id, $quantity, $variation_id ): void { $event_data = $this->build_cart_event_data( 'item_added', $product_id, $quantity, $variation_id ); // Trigger event dispatching. $this->dispatcher->dispatch_event( 'cart_item_added', $event_data ); } /** * Track cart item quantity updated event. * * Triggers fraud protection event dispatching when cart item quantity is updated. * * @internal * * @param string $cart_item_key Cart item key. * @param int $quantity New quantity. * @param int $old_quantity Old quantity. * @param object $cart Cart object. * @return void */ public function track_cart_item_updated( $cart_item_key, $quantity, $old_quantity, $cart ): void { $cart_item = $cart->cart_contents[ $cart_item_key ] ?? null; if ( (int) $quantity === (int) $old_quantity || ! $cart_item ) { return; } $product_id = $cart_item['product_id'] ?? 0; $variation_id = $cart_item['variation_id'] ?? 0; $event_data = $this->build_cart_event_data( 'item_updated', $product_id, (int) $quantity, $variation_id ); // Add old quantity for context. $event_data['old_quantity'] = (int) $old_quantity; // Trigger event dispatching. $this->dispatcher->dispatch_event( 'cart_item_updated', $event_data ); } /** * Track cart item removed event. * * Triggers fraud protection event dispatching when an item is removed from the cart. * * @internal * * @param string $cart_item_key Cart item key. * @param object $cart Cart object. * @return void */ public function track_cart_item_removed( $cart_item_key, $cart ): void { $cart_item = $cart->removed_cart_contents[ $cart_item_key ] ?? null; if ( ! $cart_item ) { return; } $product_id = $cart_item['product_id'] ?? 0; $variation_id = $cart_item['variation_id'] ?? 0; $quantity = $cart_item['quantity'] ?? 0; $event_data = $this->build_cart_event_data( 'item_removed', $product_id, $quantity, $variation_id ); // Trigger event dispatching. $this->dispatcher->dispatch_event( 'cart_item_removed', $event_data ); } /** * Track cart item restored event. * * Triggers fraud protection event dispatching when a removed item is restored to the cart. * * @internal * * @param string $cart_item_key Cart item key. * @param object $cart Cart object. * @return void */ public function track_cart_item_restored( $cart_item_key, $cart ): void { $cart_item = $cart->cart_contents[ $cart_item_key ] ?? null; if ( ! $cart_item ) { return; } $product_id = $cart_item['product_id'] ?? 0; $variation_id = $cart_item['variation_id'] ?? 0; $quantity = $cart_item['quantity'] ?? 0; $event_data = $this->build_cart_event_data( 'item_restored', $product_id, $quantity, $variation_id ); // Trigger event dispatching. $this->dispatcher->dispatch_event( 'cart_item_restored', $event_data ); } /** * Build cart event-specific data. * * Prepares the cart event data including action type, product details, * and current cart state. This data will be merged with comprehensive * session data during event dispatching. * * @param string $action Action type (item_added, item_updated, item_removed, item_restored). * @param int $product_id Product ID. * @param int $quantity Quantity. * @param int $variation_id Variation ID. * @return array Cart event data. */ private function build_cart_event_data( string $action, int $product_id, int $quantity, int $variation_id ): array { $cart_item_count = 0; // Get current cart item count if cart is available. if ( WC()->cart instanceof \WC_Cart ) { $cart_item_count = WC()->cart->get_cart_contents_count(); } return array( 'action' => $action, 'product_id' => $product_id, 'quantity' => $quantity, 'variation_id' => $variation_id, 'cart_item_count' => $cart_item_count, ); } } FraudProtection/PaymentMethodEventTracker.php 0000777 00000006372 15251706115 0015503 0 ustar 00 <?php /** * PaymentMethodEventTracker class file. */ declare( strict_types=1 ); namespace Automattic\WooCommerce\Internal\FraudProtection; defined( 'ABSPATH' ) || exit; /** * Tracks payment method events for fraud protection analysis. * * This class provides methods to track events for adding payment methods in My Account page * for fraud protection. * Event-specific data is passed to the dispatcher which handles session data collection internally. * * @since 10.5.0 * @internal This class is part of the internal API and is subject to change without notice. */ class PaymentMethodEventTracker { /** * Fraud protection dispatcher instance. * * @var FraudProtectionDispatcher */ private FraudProtectionDispatcher $dispatcher; /** * Initialize with dependencies. * * @internal * * @param FraudProtectionDispatcher $dispatcher The fraud protection dispatcher instance. */ final public function init( FraudProtectionDispatcher $dispatcher ): void { $this->dispatcher = $dispatcher; } /** * Track add payment method page loaded event. * * Triggers fraud protection event dispatching when the add payment method page is initially loaded. * This captures the initial session state before any user interactions. * * @internal * @return void */ public function track_add_payment_method_page_loaded(): void { // Track the page load event. Session data will be collected by the dispatcher. $this->dispatcher->dispatch_event( 'add_payment_method_page_loaded', array() ); } /** * Track payment method added event. * * Triggers fraud protection event tracking when a payment method is added. * * @internal * * @param int $token_id The newly created token ID. * @param \WC_Payment_Token $token The payment token object. */ public function track_payment_method_added( $token_id, $token ): void { $event_data = $this->build_payment_method_event_data( 'added', $token ); // Trigger event dispatching. $this->dispatcher->dispatch_event( 'payment_method_added', $event_data ); } /** * Build payment method event-specific data. * * Extracts relevant information from the payment token object including * token type, gateway ID, user ID, and card details for card tokens. * This data will be merged with comprehensive session data during event tracking. * * @param string $action Action type (added, updated, set_default, deleted, add_failed). * @param \WC_Payment_Token $token The payment token object. * @return array Payment method event data. */ private function build_payment_method_event_data( string $action, \WC_Payment_Token $token ): array { $event_data = array( 'action' => $action, 'token_id' => $token->get_id(), 'token_type' => $token->get_type(), 'gateway_id' => $token->get_gateway_id(), 'user_id' => $token->get_user_id(), 'is_default' => $token->is_default(), ); // Add card-specific details if this is a credit card token. if ( $token instanceof \WC_Payment_Token_CC ) { $event_data['card_type'] = $token->get_card_type(); $event_data['card_last4'] = $token->get_last4(); $event_data['expiry_month'] = $token->get_expiry_month(); $event_data['expiry_year'] = $token->get_expiry_year(); } return $event_data; } } Traits/ScriptDebug.php 0000777 00000001072 15251706115 0010750 0 ustar 00 <?php declare( strict_types=1 ); namespace Automattic\WooCommerce\Internal\Traits; use Automattic\Jetpack\Constants; /** * Trait ScriptDebug * * @since 8.5.0 */ trait ScriptDebug { /** * Get the script suffix based on the SCRIPT_DEBUG constant. * * @return string */ protected function get_script_suffix(): string { return $this->is_script_debug_enabled() ? '' : '.min'; } /** * Check if SCRIPT_DEBUG is enabled. * * @return bool */ protected function is_script_debug_enabled(): bool { return Constants::is_true( 'SCRIPT_DEBUG' ); } } Traits/RestApiCache.php 0000777 00000126526 15251706115 0011044 0 ustar 00 <?php declare(strict_types=1); namespace Automattic\WooCommerce\Internal\Traits; use Automattic\WooCommerce\Internal\Caches\VersionStringGenerator; use Automattic\WooCommerce\Internal\Features\FeaturesController; use Automattic\WooCommerce\Proxies\LegacyProxy; use Automattic\WooCommerce\Utilities\CallbackUtil; use WP_REST_Request; use WP_REST_Response; /** * This trait provides caching capabilities for REST API endpoints using the WordPress cache. * * - The output of all the REST API endpoints whose callback declaration is wrapped * in a call to 'with_cache' will be cached using wp_cache_* functions. * - Response headers are cached together with the response data, excluding certain fixed * headers (like Set-Cookie) and optionally others specified via configuration * (per-controller or per-endpoint). * - For the purposes of caching, a request is uniquely identified by its route, * HTTP method, query string, and user ID. * - The VersionStringGenerator class is used to track versions of entities included * in the responses (an "entity" is any object that is uniquely identified by type and id * and contributes with information to be included in the response), * so that when those entities change, the relevant cached responses become invalid. * Modification of entity versions must be done externally by the code that modifies * those entities (via calls to VersionStringGenerator::generate_version). * - Various parameters (cached outputs TTL, entity type for a given response, hooks that affect * the response) can be configured globally for the controller (via overriding protected methods) * or per-endpoint (via arguments passed to with_cache). * - Caching can be disabled for a given request by adding a '_skip_cache=true|1' * to the query string. * - A X-WC-Cache HTTP header is added to responses to indicate cache status: * HIT, MISS, or SKIP. * * Additionally to caching, this trait also handles the sending of appropriate * Cache-Control and ETag headers to instruct clients and proxies on how to cache responses. * The ETag is generated based on the cached response data and cache key, and a request * containing an If-None-Match header with a matching ETag will receive a 304 Not Modified response. * * Usage: Wrap endpoint callbacks with the `with_cache()` method when registering routes. * * Example: * * class WC_REST_Products_Controller extends WC_REST_Products_V2_Controller { * use RestApiCache; * * public function __construct() { * parent::__construct(); * $this->initialize_rest_api_cache(); // REQUIRED * } * * protected function get_default_response_entity_type(): ?string { * return 'product'; // REQUIRED (or specify entity_type in each with_cache call) * } * * public function register_routes() { * register_rest_route( * $this->namespace, * '/' . $this->rest_base . '/(?P<id>[\d]+)', * array( * 'methods' => WP_REST_Server::READABLE, * 'callback' => $this->with_cache( * array( $this, 'get_item' ), * array( * // String, optional if get_default_response_entity_type() is overridden. * 'entity_type' => 'product', * // Optional int, defaults to the controller's get_ttl_for_cached_response(). * 'cache_ttl' => HOUR_IN_SECONDS, * // Optional array, defaults to the controller's get_hooks_relevant_to_caching(). * 'relevant_hooks' => array( 'filter_name_1', 'filter_name_2' ), * // Optional bool, defaults to the controller's response_cache_vary_by_user(). * 'vary_by_user' => true, * // Optional array, defaults to the controller's get_response_headers_to_include_in_caching(). * 'include_headers' => array( 'X-Custom-Header' ), * // Optional array, defaults to the controller's get_response_headers_to_exclude_from_caching(). * 'exclude_headers' => array( 'X-Private-Header' ), * // Optional, this will be passed to all the caching-related methods. * 'endpoint_id' => 'get_product' * ) * ), * ) * ); * } * } * * Override these methods in your controller as needed: * - get_default_response_entity_type(): Default entity type for endpoints without explicit config. * - response_cache_vary_by_user(): Whether cache should be user-specific. * - get_hooks_relevant_to_caching(): Hook names to track for cache invalidation. * - get_ttl_for_cached_response(): TTL for cached outputs in seconds. * - get_response_headers_to_include_in_caching(): Headers to include in cache (false = use exclusion mode). * - get_response_headers_to_exclude_from_caching(): Headers to exclude from cache (when in exclusion mode). * * Cache invalidation happens when: * - Entity versions change (tracked via VersionStringGenerator). * - Hook callbacks change * (if the `get_hooks_relevant_to_caching()` call result or the 'relevant_hooks' array isn't empty). * - Cached response TTL expires. * * NOTE: This caching mechanism uses the WordPress cache (wp_cache_* functions). * By default caching is only enabled when an external object cache is enabled * (checked via call to VersionStringGenerator::can_use()), so the cache is persistent * across requests and not just for the current request. * * @since 10.5.0 */ trait RestApiCache { /** * Cache group name for REST API responses. * * @var string */ private static string $cache_group = 'woocommerce_rest_api_cache'; /** * Response headers that are always excluded from caching. * * @var array */ private static array $always_excluded_headers = array( 'X-WC-Cache', 'Set-Cookie', 'Date', 'Expires', 'Last-Modified', 'Age', 'ETag', 'Cache-Control', 'Pragma', ); /** * The instance of VersionStringGenerator to use, or null if caching is disabled. * * @var VersionStringGenerator|null */ private ?VersionStringGenerator $version_string_generator = null; /** * Whether we are currently handling a cached endpoint. * * @var bool */ private $is_handling_cached_endpoint = false; /** * Whether the REST API caching feature is enabled. * * @var bool */ private bool $rest_api_caching_feature_enabled = false; /** * Initialize the trait. * This MUST be called from the controller's constructor. */ protected function initialize_rest_api_cache(): void { // Guard against early instantiation before WooCommerce is fully initialized. // Some third-party plugins instantiate REST controllers during plugin loading, // before the WooCommerce container is available. if ( ! function_exists( 'wc_get_container' ) ) { return; } $features_controller = wc_get_container()->get( FeaturesController::class ); $this->rest_api_caching_feature_enabled = $features_controller->feature_is_enabled( 'rest_api_caching' ); if ( ! $this->rest_api_caching_feature_enabled ) { return; } $generator = wc_get_container()->get( VersionStringGenerator::class ); $backend_caching_enabled = 'yes' === get_option( 'woocommerce_rest_api_enable_backend_caching', 'no' ); $this->version_string_generator = ( $backend_caching_enabled && $generator->can_use() ) ? $generator : null; add_filter( 'rest_send_nocache_headers', array( $this, 'handle_rest_send_nocache_headers' ), 10, 1 ); } /** * Wrap an endpoint callback declaration with caching logic. * Usage: `'callback' => $this->with_cache( array( $this, 'endpoint_callback_method' ) )` * `'callback' => $this->with_cache( array( $this, 'endpoint_callback_method' ), [ 'entity_type' => 'product' ] )` * * @param callable $callback The original endpoint callback. * @param array $config Caching configuration: * - entity_type: string (falls back to get_default_response_entity_type()). * - vary_by_user: bool (defaults to response_cache_vary_by_user()). * - endpoint_id: string|null (optional friendly identifier for the endpoint). * - cache_ttl: int (defaults to get_ttl_for_cached_response()). * - relevant_hooks: array (defaults to get_hooks_relevant_to_caching()). * - include_headers: array|false (defaults to get_response_headers_to_include_in_caching()). * - exclude_headers: array (defaults to get_response_headers_to_exclude_from_caching()). * @return callable Wrapped callback. */ protected function with_cache( callable $callback, array $config = array() ): callable { return $this->rest_api_caching_feature_enabled ? fn( $request ) => $this->handle_cacheable_request( $request, $callback, $config ) : fn( $request ) => call_user_func( $callback, $request ); } /** * Handle a request with caching logic. * * Strategy: * - If backend caching is enabled: Try to use cached response if available, otherwise execute * the callback and cache the response. * - If only cache headers are enabled: Execute the callback, generate ETag, and return 304 * if the client's ETag matches. * * @param WP_REST_Request<array<string, mixed>> $request The request object. * @param callable $callback The original endpoint callback. * @param array $config Caching configuration specified for the endpoint. * * @return WP_REST_Response|\WP_Error The response. */ private function handle_cacheable_request( WP_REST_Request $request, callable $callback, array $config ) { // phpcs:ignore Squiz.Commenting.FunctionComment.IncorrectTypeHint $backend_caching_enabled = ! is_null( $this->version_string_generator ); $cache_headers_enabled = 'yes' === get_option( 'woocommerce_rest_api_enable_cache_headers', 'yes' ); if ( ! $backend_caching_enabled && ! $cache_headers_enabled ) { return call_user_func( $callback, $request ); } $cached_config = null; $should_skip_cache = ! $this->should_use_cache_for_request( $request ); if ( ! $should_skip_cache ) { $cached_config = $this->build_cache_config( $request, $config ); $should_skip_cache = is_null( $cached_config ); } if ( $should_skip_cache || is_null( $cached_config ) ) { $response = call_user_func( $callback, $request ); if ( ! is_wp_error( $response ) ) { $response = rest_ensure_response( $response ); $response->header( 'X-WC-Cache', 'SKIP' ); } return $response; } $this->is_handling_cached_endpoint = true; if ( $backend_caching_enabled ) { $cached_response = $this->get_cached_response( $request, $cached_config, $cache_headers_enabled ); if ( $cached_response ) { $cached_response->header( 'X-WC-Cache', 'HIT' ); return $cached_response; } } $authoritative_response = call_user_func( $callback, $request ); return $backend_caching_enabled ? $this->maybe_cache_response( $request, $authoritative_response, $cached_config, $cache_headers_enabled ) : $this->maybe_add_cache_headers( $request, $authoritative_response, $cached_config ); } /** * Check if caching should be used for a particular incoming request. * * @param WP_REST_Request<array<string, mixed>> $request The request object. * * @return bool True if caching should be used, false otherwise. */ private function should_use_cache_for_request( WP_REST_Request $request ): bool { // phpcs:ignore Squiz.Commenting.FunctionComment.IncorrectTypeHint $skip_cache = $request->get_param( '_skip_cache' ); $should_cache = ! ( 'true' === $skip_cache || '1' === $skip_cache ); /** * Filter whether to enable response caching for a given REST API controller. * * @since 10.5.0 * * @param bool $enable_caching Whether to enable response caching (result of !_skip_cache evaluation). * @param object $controller The controller instance. * @param WP_REST_Request<array<string, mixed>> $request The request object. * @return bool True to enable response caching, false to disable. */ return apply_filters( 'woocommerce_rest_api_enable_response_caching', $should_cache, $this, $request ); } /** * Build the output cache entry configuration from the request and per-endpoint config. * * @param WP_REST_Request<array<string, mixed>> $request The request object. * @param array $config Raw configuration array passed to with_cache. * * @return array|null Normalized cache config with keys: endpoint_id, entity_type, vary_by_user, cache_ttl, relevant_hooks, include_headers, exclude_headers, cache_key. Returns null if entity type is not available. * * @throws \InvalidArgumentException If include_headers is not false or an array. */ private function build_cache_config( WP_REST_Request $request, array $config ): ?array { // phpcs:ignore Squiz.Commenting.FunctionComment.IncorrectTypeHint $endpoint_id = $config['endpoint_id'] ?? null; $entity_type = $config['entity_type'] ?? $this->get_default_response_entity_type(); $vary_by_user = $config['vary_by_user'] ?? $this->response_cache_vary_by_user( $request, $endpoint_id ); if ( ! $entity_type ) { $legacy_proxy = wc_get_container()->get( LegacyProxy::class ); $legacy_proxy->call_function( 'wc_doing_it_wrong', __METHOD__, 'No entity type provided and no default entity type available. Skipping cache.', '10.5.0' ); return null; } $include_headers = $config['include_headers'] ?? $this->get_response_headers_to_include_in_caching( $request, $endpoint_id ); if ( false !== $include_headers && ! is_array( $include_headers ) ) { throw new \InvalidArgumentException( 'include_headers must be either false or an array, ' . gettype( $include_headers ) . ' given.' // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped ); } return array( 'endpoint_id' => $endpoint_id, 'entity_type' => $entity_type, 'vary_by_user' => $vary_by_user, 'cache_ttl' => $config['cache_ttl'] ?? $this->get_ttl_for_cached_response( $request, $endpoint_id ), 'relevant_hooks' => $config['relevant_hooks'] ?? $this->get_hooks_relevant_to_caching( $request, $endpoint_id ), 'include_headers' => $include_headers, 'exclude_headers' => $config['exclude_headers'] ?? $this->get_response_headers_to_exclude_from_caching( $request, $endpoint_id ), 'cache_key' => $this->get_key_for_cached_response( $request, $entity_type, $vary_by_user, $endpoint_id ), ); } /** * Cache the response if it's successful and optionally add cache headers. * * Only caches responses with 2xx status codes. Always adds the X-WC-Cache header * with value MISS if the response was cached, or SKIP if it was not cached. * * Supports both WP_REST_Response objects and raw data (which will be wrapped in a response object). * Error objects are returned as-is without caching. * * @param WP_REST_Request<array<string, mixed>> $request The request object. * @param WP_REST_Response|\WP_Error|array|object $response The response to potentially cache. * @param array $cached_config Caching configuration from build_cache_config(). * @param bool $add_cache_headers Whether to add cache control headers. * * @return WP_REST_Response|\WP_Error The response with appropriate cache headers. */ private function maybe_cache_response( WP_REST_Request $request, $response, array $cached_config, bool $add_cache_headers ) { // phpcs:ignore Squiz.Commenting.FunctionComment.IncorrectTypeHint if ( is_wp_error( $response ) ) { return $response; } $response = rest_ensure_response( $response ); $cached = false; $status = $response->get_status(); if ( $status >= 200 && $status <= 299 ) { $data = $response->get_data(); $entity_ids = is_array( $data ) ? $this->extract_entity_ids_from_response( $data, $request, $cached_config['endpoint_id'] ) : array(); $response_headers = $response->get_headers(); $cacheable_headers = $this->get_headers_to_cache( $response_headers, $cached_config['include_headers'], $cached_config['exclude_headers'], $request, $response, $cached_config['endpoint_id'] ); $etag_data = is_array( $data ) ? $this->get_data_for_etag( $data, $request, $cached_config['endpoint_id'] ) : $data; $etag = '"' . md5( $cached_config['cache_key'] . wp_json_encode( $etag_data ) ) . '"'; $this->store_cached_response( $cached_config['cache_key'], $data, $status, $cached_config['entity_type'], $entity_ids, $cached_config['cache_ttl'], $cached_config['relevant_hooks'], $cacheable_headers, $etag ); $cached = true; } $response->header( 'X-WC-Cache', $cached ? 'MISS' : 'SKIP' ); return $add_cache_headers ? $this->maybe_add_cache_headers( $request, $response, $cached_config ) : $response; } /** * Add cache control headers to a response. * * This method generates an ETag from the response data and returns a 304 Not Modified * if the client's If-None-Match header matches. It can be used both with and without * backend caching. * * @param WP_REST_Request<array<string, mixed>> $request The request object. * @param WP_REST_Response|\WP_Error|array|object $response The response to add headers to. * @param array $cached_config Caching configuration from build_cache_config(). * * @return WP_REST_Response|\WP_Error The response with cache headers. */ private function maybe_add_cache_headers( WP_REST_Request $request, $response, array $cached_config ) { // phpcs:ignore Squiz.Commenting.FunctionComment.IncorrectTypeHint if ( is_wp_error( $response ) ) { return $response; } $response = rest_ensure_response( $response ); $status = $response->get_status(); if ( $status < 200 || $status > 299 ) { return $response; } $response_data = $response->get_data(); $response_etag_data = is_array( $response_data ) ? $this->get_data_for_etag( $response_data, $request, $cached_config['endpoint_id'] ) : $response_data; $response_etag = '"' . md5( $cached_config['cache_key'] . wp_json_encode( $response_etag_data ) ) . '"'; $request_etag = $request->get_header( 'if-none-match' ); $legacy_proxy = wc_get_container()->get( LegacyProxy::class ); $is_user_logged_in = $legacy_proxy->call_function( 'is_user_logged_in' ); $cache_visibility = $cached_config['vary_by_user'] && $is_user_logged_in ? 'private' : 'public'; $cache_control_value = $cache_visibility . ', must-revalidate, max-age=' . $cached_config['cache_ttl']; if ( $request_etag === $response_etag ) { $not_modified_response = $this->create_not_modified_response( $response_etag, $cache_control_value, $request, $cached_config['endpoint_id'] ); if ( $not_modified_response ) { return $not_modified_response; } } $response->header( 'ETag', $response_etag ); $response->header( 'Cache-Control', $cache_control_value ); if ( ! array_key_exists( 'X-WC-Cache', $response->get_headers() ) ) { $response->header( 'X-WC-Cache', 'HEADERS' ); } return $response; } /** * Create a 304 Not Modified response if allowed by filters. * * @param string $etag The ETag value. * @param string $cache_control_value The Cache-Control header value. * @param WP_REST_Request<array<string, mixed>> $request The request object. * @param string|null $endpoint_id The endpoint identifier. * * @return WP_REST_Response|null 304 response if allowed, null otherwise. */ private function create_not_modified_response( string $etag, string $cache_control_value, WP_REST_Request $request, ?string $endpoint_id ): ?WP_REST_Response { // phpcs:ignore Squiz.Commenting.FunctionComment.IncorrectTypeHint $response = new WP_REST_Response( null, 304 ); $response->header( 'ETag', $etag ); $response->header( 'Cache-Control', $cache_control_value ); $response->header( 'X-WC-Cache', 'MATCH' ); /** * Filter the 304 Not Modified response before sending. * * @since 10.5.0 * * @param WP_REST_Response|false $response The 304 response object, or false to prevent sending it. * @param WP_REST_Request $request The request object. * @param string|null $endpoint_id The endpoint identifier. */ $filtered_response = apply_filters( 'woocommerce_rest_api_not_modified_response', $response, $request, $endpoint_id ); return false === $filtered_response ? null : rest_ensure_response( $filtered_response ); } /** * Get the default type for entities included in responses. * * This can be customized per-endpoint via the config array * passed to with_cache() ('entity_type' key). * * @return string|null Entity type (e.g., 'product', 'order'), or null if no controller-wide default. */ protected function get_default_response_entity_type(): ?string { return null; } /** * Get data for ETag generation. * * Override in classes to exclude fields that change on each request * (e.g., random recommendations, timestamps). * * @param array $data Response data. * @param WP_REST_Request<array<string, mixed>> $request The request object. * @param string|null $endpoint_id Optional friendly identifier for the endpoint. * * @return array Cleaned data for ETag generation. */ protected function get_data_for_etag( array $data, WP_REST_Request $request, ?string $endpoint_id = null ): array { // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.FoundAfterLastUsed, Squiz.Commenting.FunctionComment.IncorrectTypeHint return $data; } /** * Whether the response cache should vary by user. * * When true, each user gets their own cached version of the response. * When false, the same cached response is shared across all users. * * This can be customized per-endpoint via the config array * passed to with_cache() ('vary_by_user' key). * * @param WP_REST_Request<array<string, mixed>> $request The request object. * @param string|null $endpoint_id Optional friendly identifier for the endpoint. * * @return bool True to make cache user-specific, false otherwise. */ protected function response_cache_vary_by_user( WP_REST_Request $request, ?string $endpoint_id = null ): bool { // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.FoundAfterLastUsed, Squiz.Commenting.FunctionComment.IncorrectTypeHint return true; } /** * Get the cache TTL (time to live) for cached responses. * * This can be customized per-endpoint via the config array * passed to with_cache() ('cache_ttl' key). * * @param WP_REST_Request<array<string, mixed>> $request The request object. * @param string|null $endpoint_id Optional friendly identifier for the endpoint. * * @return int Cache TTL in seconds. */ protected function get_ttl_for_cached_response( WP_REST_Request $request, ?string $endpoint_id = null ): int { // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.FoundAfterLastUsed, Squiz.Commenting.FunctionComment.IncorrectTypeHint return HOUR_IN_SECONDS; } /** * Get the names of hooks (filters and actions) that can customize the response. * * All the existing instances of add_action/add_filter for these hooks * will be included in the information that gets cached together with the response, * and if any of these has changed when the cached response is retrieved, * the cache entry will be invalidated. * * This can be customized per-endpoint via the config array * passed to with_cache() ('relevant_hooks' key). * * @param WP_REST_Request<array<string, mixed>> $request Request object. * @param string|null $endpoint_id Optional friendly identifier for the endpoint. * * @return array Array of hook names to track. */ protected function get_hooks_relevant_to_caching( WP_REST_Request $request, ?string $endpoint_id = null ): array { // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.FoundAfterLastUsed, Squiz.Commenting.FunctionComment.IncorrectTypeHint return array(); } /** * Get the names of response headers to include in caching. * * When this returns an array, ONLY the headers whose names are returned * will be included in the cache (subject to always-excluded headers). * When this returns false, all headers will be included except those returned * by get_response_headers_to_exclude_from_caching(). * * This can be customized per-endpoint via the config array * passed to with_cache() ('include_headers' key). * * @param WP_REST_Request<array<string, mixed>> $request Request object. * @param string|null $endpoint_id Optional friendly identifier for the endpoint. * * @return array|false Array of header names to include (case-insensitive), or false to use exclusion logic. */ protected function get_response_headers_to_include_in_caching( WP_REST_Request $request, ?string $endpoint_id = null ) { // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.FoundAfterLastUsed, Squiz.Commenting.FunctionComment.IncorrectTypeHint return false; } /** * Get the names of response headers to exclude from caching. * * These headers will not be stored in the cache, in addition to the * always-excluded headers (X-WC-Cache, Set-Cookie, Date, Expires, Last-Modified, * Age, ETag, Cache-Control, Pragma). * * This is only used when get_response_headers_to_include_in_caching() returns false. * * This can be customized per-endpoint via the config array * passed to with_cache() ('exclude_headers' key). * * @param WP_REST_Request<array<string, mixed>> $request Request object. * @param string|null $endpoint_id Optional friendly identifier for the endpoint. * * @return array Array of header names to exclude (case-insensitive). */ protected function get_response_headers_to_exclude_from_caching( WP_REST_Request $request, ?string $endpoint_id = null ): array { // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.FoundAfterLastUsed, Squiz.Commenting.FunctionComment.IncorrectTypeHint return array(); } /** * Extract entity IDs from response data. * * This implementation assumes the response is either: * - An array with an 'id' field (single item) * - An array of arrays each having an 'id' field (collection) * * Controllers can override this method to customize entity ID extraction. * * @param array $response_data Response data. * @param WP_REST_Request<array<string, mixed>> $request The request object. * @param string|null $endpoint_id Optional friendly identifier for the endpoint. * * @return array Array of entity IDs. */ protected function extract_entity_ids_from_response( array $response_data, WP_REST_Request $request, ?string $endpoint_id = null ): array { // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.FoundAfterLastUsed, Squiz.Commenting.FunctionComment.IncorrectTypeHint $ids = array(); if ( isset( $response_data[0] ) && is_array( $response_data[0] ) ) { foreach ( $response_data as $item ) { if ( isset( $item['id'] ) ) { $ids[] = $item['id']; } } } elseif ( isset( $response_data['id'] ) ) { $ids[] = $response_data['id']; } // Filter out false values but keep 0 and empty strings as they could be valid IDs. // Note: null values can't exist here because isset() checks above exclude them. return array_unique( array_filter( $ids, fn ( $id ) => false !== $id ) ); } /** * Filter response headers to get only those that should be cached. * * The filtering process follows these steps: * 1. If $include_headers is an array, only those headers are included (case-insensitive). * If $include_headers is false, all headers are included except those in $exclude_headers. * 2. Always-excluded headers (X-WC-Cache, Set-Cookie, Date, etc.) are removed. * 3. The woocommerce_rest_api_cached_headers filter is applied, receiving both the candidate * headers list and all available headers. This allows filters to both add and remove * headers from the caching list. * 4. Always-excluded headers are enforced again post-filter to prevent filters from * re-introducing dangerous headers like Set-Cookie. * 5. Only headers from the response that are in the filtered list are returned. * * @param array $nominal_headers Response headers. * @param array|false $include_headers Header names to include (false to use exclusion logic). * @param array $exclude_headers Header names to exclude (case-insensitive). * @param WP_REST_Request<array<string, mixed>> $request The request object. * @param WP_REST_Response $response The response object. * @param string|null $endpoint_id Optional friendly identifier for the endpoint. * * @return array Filtered headers array. */ private function get_headers_to_cache( array $nominal_headers, $include_headers, array $exclude_headers, WP_REST_Request $request, WP_REST_Response $response, ?string $endpoint_id ): array { // phpcs:ignore Squiz.Commenting.FunctionComment.IncorrectTypeHint // Step 1: Determine which headers to consider based on include/exclude. if ( false !== $include_headers ) { $include_headers_lowercase = array_map( 'strtolower', $include_headers ); $headers_to_cache = array_filter( $nominal_headers, fn( $name ) => in_array( strtolower( $name ), $include_headers_lowercase, true ), ARRAY_FILTER_USE_KEY ); } else { $exclude_headers_lowercase = array_map( 'strtolower', $exclude_headers ); $headers_to_cache = array_filter( $nominal_headers, fn( $name ) => ! in_array( strtolower( $name ), $exclude_headers_lowercase, true ), ARRAY_FILTER_USE_KEY ); } // Step 2: Remove always-excluded headers. $always_exclude_lowercase = array_map( 'strtolower', self::$always_excluded_headers ); $headers_to_cache = array_filter( $headers_to_cache, fn( $name ) => ! in_array( strtolower( $name ), $always_exclude_lowercase, true ), ARRAY_FILTER_USE_KEY ); // Step 3: Apply filter to header names. $cached_header_names = array_keys( $headers_to_cache ); $all_header_names = array_keys( $nominal_headers ); /** * Filter the list of response header names to cache. * * @since 10.5.0 * * @param array $cached_header_names Candidate list of header names to cache. * @param array $all_header_names All header names available in the response. * @param WP_REST_Request $request The request object. * @param WP_REST_Response $response The response object. * @param string|null $endpoint_id Optional friendly identifier for the endpoint. * @param object $controller The controller instance. * * @return array Filtered list of header names to cache. */ $filtered_header_names = apply_filters( 'woocommerce_rest_api_cached_headers', $cached_header_names, $all_header_names, $request, $response, $endpoint_id, $this ); // Step 4: Enforce always-excluded headers post-filter. $filtered_header_names_lowercase = array_map( 'strtolower', $filtered_header_names ); $reintroduced_headers = array_filter( $filtered_header_names, fn( $name ) => in_array( strtolower( $name ), $always_exclude_lowercase, true ) ); if ( ! empty( $reintroduced_headers ) ) { $legacy_proxy = wc_get_container()->get( LegacyProxy::class ); $legacy_proxy->call_function( 'wc_doing_it_wrong', __METHOD__, sprintf( /* translators: %s: comma-separated list of header names */ 'The woocommerce_rest_api_cached_headers filter attempted to cache always-excluded headers: %s. These headers have been removed for security reasons.', implode( ', ', $reintroduced_headers ) ), '10.5.0' ); $filtered_header_names_lowercase = array_filter( $filtered_header_names_lowercase, fn( $name ) => ! in_array( $name, $always_exclude_lowercase, true ) ); } // Step 5: Return only the headers that are in the filtered list. return array_filter( $nominal_headers, fn( $name ) => in_array( strtolower( $name ), $filtered_header_names_lowercase, true ), ARRAY_FILTER_USE_KEY ); } /** * Get cache key information that uniquely identifies a request. * * @param WP_REST_Request<array<string, mixed>> $request The request object. * @param bool $vary_by_user Whether to include user ID in cache key. * @param string|null $endpoint_id Optional friendly identifier for the endpoint. * * @return array Array of cache key information parts. */ protected function get_key_info_for_cached_response( WP_REST_Request $request, bool $vary_by_user = false, ?string $endpoint_id = null ): array { // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.FoundAfterLastUsed, Squiz.Commenting.FunctionComment.IncorrectTypeHint $request_query_params = $request->get_query_params(); if ( is_array( $request_query_params ) ) { ksort( $request_query_params ); } $cache_key_parts = array( $request->get_route(), $request->get_method(), wp_json_encode( $request_query_params ), ); if ( $vary_by_user ) { $legacy_proxy = wc_get_container()->get( LegacyProxy::class ); // @phpstan-ignore-next-line argument.type -- get_current_user_id returns int at runtime. $user_id = intval( $legacy_proxy->call_function( 'get_current_user_id' ) ); $cache_key_parts[] = "user_{$user_id}"; } return $cache_key_parts; } /** * Generate a cache key for a given request. * * @param WP_REST_Request<array<string, mixed>> $request The request object. * @param string $entity_type The entity type. * @param bool $vary_by_user Whether to include user ID in cache key. * @param string|null $endpoint_id Optional friendly identifier for the endpoint. * * @return string Cache key. */ private function get_key_for_cached_response( WP_REST_Request $request, string $entity_type, bool $vary_by_user = false, ?string $endpoint_id = null ): string { // phpcs:ignore Squiz.Commenting.FunctionComment.IncorrectTypeHint $cache_key_parts = $this->get_key_info_for_cached_response( $request, $vary_by_user, $endpoint_id ); /** * Filter the information used to generate the cache key for a REST API request. * * Allows customization of what uniquely identifies a request for caching purposes. * * @since 10.5.0 * * @param array $cache_key_parts Array of cache key information parts. * @param WP_REST_Request<array<string, mixed>> $request The request object. * @param bool $vary_by_user Whether user ID is included in cache key. * @param string|null $endpoint_id Optional friendly identifier for the endpoint (passed to with_cache). * @param object $controller The controller instance. * * @return array Filtered cache key information parts. */ $cache_key_parts = apply_filters( 'woocommerce_rest_api_cache_key_info', $cache_key_parts, $request, $vary_by_user, $endpoint_id, $this ); $request_hash = md5( implode( '-', $cache_key_parts ) ); return "wc_rest_api_cache_{$entity_type}-{$request_hash}"; } /** * Generate a hash based on the actual usages of the hooks that affect the response. * * @param array $hook_names Array of hook names to track. * * @return string Hooks hash. */ private function generate_hooks_hash( array $hook_names ): string { if ( empty( $hook_names ) ) { return ''; } $cache_hash_data = array(); foreach ( $hook_names as $hook_name ) { $signatures = CallbackUtil::get_hook_callback_signatures( $hook_name ); if ( ! empty( $signatures ) ) { $cache_hash_data[ $hook_name ] = $signatures; } } /** * Filter the data used to generate the hooks hash for REST API response caching. * * @since 10.5.0 * * @param array $cache_hash_data Hook callbacks data used for hash generation. * @param array $hook_names Hook names being tracked. * @param object $controller Controller instance. */ $cache_hash_data = apply_filters( 'woocommerce_rest_api_cache_hooks_hash_data', $cache_hash_data, $hook_names, $this ); $json = wp_json_encode( $cache_hash_data ); return md5( false === $json ? '' : $json ); } /** * Get a cached response, but only if it's valid (otherwise the cached response will be invalidated). * * @param WP_REST_Request<array<string, mixed>> $request The request object. * @param array $cached_config Built caching configuration from build_cache_config(). * @param bool $cache_headers_enabled Whether to add cache control headers. * * @return WP_REST_Response|null Cached response, or null if not available or has been invalidated. */ private function get_cached_response( WP_REST_Request $request, array $cached_config, bool $cache_headers_enabled ): ?WP_REST_Response { // phpcs:ignore Squiz.Commenting.FunctionComment.IncorrectTypeHint $cache_key = $cached_config['cache_key']; $entity_type = $cached_config['entity_type']; $cache_ttl = $cached_config['cache_ttl']; $relevant_hooks = $cached_config['relevant_hooks']; $found = false; $cached = wp_cache_get( $cache_key, self::$cache_group, false, $found ); if ( ! $found || ! is_array( $cached ) || ! array_key_exists( 'data', $cached ) || ! isset( $cached['entity_versions'], $cached['created_at'] ) ) { return null; } $legacy_proxy = wc_get_container()->get( LegacyProxy::class ); $current_time = $legacy_proxy->call_function( 'time' ); $expiration_time = $cached['created_at'] + $cache_ttl; if ( $current_time >= $expiration_time ) { wp_cache_delete( $cache_key, self::$cache_group ); return null; } if ( ! empty( $relevant_hooks ) ) { $current_hooks_hash = $this->generate_hooks_hash( $relevant_hooks ); $cached_hooks_hash = $cached['hooks_hash'] ?? ''; if ( $current_hooks_hash !== $cached_hooks_hash ) { wp_cache_delete( $cache_key, self::$cache_group ); return null; } } if ( ! is_null( $this->version_string_generator ) ) { foreach ( $cached['entity_versions'] as $entity_id => $cached_version ) { $version_id = "{$entity_type}_{$entity_id}"; $current_version = $this->version_string_generator->get_version( $version_id ); if ( $current_version !== $cached_version ) { wp_cache_delete( $cache_key, self::$cache_group ); return null; } } } // At this point the cached response is valid. // Check if client sent an ETag and it matches - if so, return 304 Not Modified. $cached_etag = $cached['etag'] ?? ''; $request_etag = $request->get_header( 'if-none-match' ); $response_headers = array(); if ( $cache_headers_enabled ) { $legacy_proxy = wc_get_container()->get( LegacyProxy::class ); $is_user_logged_in = $legacy_proxy->call_function( 'is_user_logged_in' ); $cache_visibility = $cached_config['vary_by_user'] && $is_user_logged_in ? 'private' : 'public'; if ( ! empty( $cached_etag ) ) { $response_headers['ETag'] = $cached_etag; } $response_headers['Cache-Control'] = $cache_visibility . ', must-revalidate, max-age=' . $cache_ttl; // If the server adds a 'Date' header by itself there will be two such headers in the response. // To help disambiguate them, we add also an 'X-WC-Date' header with the proper value. // @phpstan-ignore-next-line argument.type -- created_at is int, stored by store_cached_response. $created_at = gmdate( 'D, d M Y H:i:s', intval( $cached['created_at'] ) ) . ' GMT'; $response_headers['Date'] = $created_at; $response_headers['X-WC-Date'] = $created_at; if ( ! empty( $cached_etag ) && $request_etag === $cached_etag ) { $cache_control = $response_headers['Cache-Control']; $not_modified_response = $this->create_not_modified_response( $cached_etag, $cache_control, $request, $cached_config['endpoint_id'] ); if ( $not_modified_response ) { $not_modified_response->header( 'Date', $response_headers['Date'] ); $not_modified_response->header( 'X-WC-Date', $response_headers['X-WC-Date'] ); return $not_modified_response; } } } $response = new WP_REST_Response( $cached['data'], $cached['status_code'] ?? 200 ); foreach ( $response_headers as $name => $value ) { $response->header( $name, $value ); } if ( ! empty( $cached['headers'] ) ) { foreach ( $cached['headers'] as $name => $value ) { $response->header( $name, $value ); } } return $response; } /** * Store a response in cache. * * @param string $cache_key The cache key. * @param mixed $data The response data to cache. * @param int $status_code The HTTP status code of the response. * @param string $entity_type The entity type. * @param array $entity_ids Array of entity IDs in the response. * @param int $cache_ttl Cache TTL in seconds. * @param array $relevant_hooks Hook names to track for invalidation. * @param array $headers Response headers to cache. * @param string $etag ETag for the response. */ private function store_cached_response( string $cache_key, $data, int $status_code, string $entity_type, array $entity_ids, int $cache_ttl, array $relevant_hooks, array $headers = array(), string $etag = '' ): void { $entity_versions = array(); if ( ! is_null( $this->version_string_generator ) ) { foreach ( $entity_ids as $entity_id ) { $version_id = "{$entity_type}_{$entity_id}"; $version = $this->version_string_generator->get_version( $version_id ); if ( $version ) { $entity_versions[ $entity_id ] = $version; } } } $legacy_proxy = wc_get_container()->get( LegacyProxy::class ); $cache_data = array( 'data' => $data, 'entity_versions' => $entity_versions, 'created_at' => $legacy_proxy->call_function( 'time' ), ); if ( 200 !== $status_code ) { $cache_data['status_code'] = $status_code; } if ( ! empty( $relevant_hooks ) ) { $cache_data['hooks_hash'] = $this->generate_hooks_hash( $relevant_hooks ); } if ( ! empty( $headers ) ) { $cache_data['headers'] = $headers; } if ( ! empty( $etag ) ) { $cache_data['etag'] = $etag; } wp_cache_set( $cache_key, $cache_data, self::$cache_group, $cache_ttl ); } /** * Handle rest_send_nocache_headers filter to prevent WordPress from overriding our cache headers. * * @internal * * @param bool $send_no_cache_headers Whether to send no-cache headers. * * @return bool False if we're handling caching for this request, original value otherwise. */ public function handle_rest_send_nocache_headers( bool $send_no_cache_headers ): bool { if ( ! $this->is_handling_cached_endpoint ) { return $send_no_cache_headers; } $this->is_handling_cached_endpoint = false; return false; } } Traits/AccessiblePrivateMethods.php 0000777 00000006621 15251706115 0013456 0 ustar 00 <?php declare( strict_types=1 ); namespace Automattic\WooCommerce\Internal\Traits; /** * DON'T USE THIS TRAIT. It's DEPRECATED and will be REMOVED in a future version of WooCommerce. * * If you have class methods that are public solely because they are the target of WordPress hooks, * make the methods public and mark them with an @internal annotation. * * @deprecated 9.6.0 Make the hook target methods public and mark them with an @internal annotation. This trait will be REMOVED in a future version of WooCommerce. */ trait AccessiblePrivateMethods { // phpcs:disable private $_accessible_private_methods = array(); private static $_accessible_static_private_methods = array(); protected static function add_action( string $hook_name, $callback, int $priority = 10, int $accepted_args = 1 ): void { self::process_callback_before_hooking( $callback ); add_action( $hook_name, $callback, $priority, $accepted_args ); } protected static function add_filter( string $hook_name, $callback, int $priority = 10, int $accepted_args = 1 ): void { self::process_callback_before_hooking( $callback ); add_filter( $hook_name, $callback, $priority, $accepted_args ); } protected static function process_callback_before_hooking( $callback ): void { if ( ! is_array( $callback ) || count( $callback ) < 2 ) { return; } $first_item = $callback[0]; if ( __CLASS__ === $first_item ) { static::mark_static_method_as_accessible( $callback[1] ); } elseif ( is_object( $first_item ) && get_class( $first_item ) === __CLASS__ ) { $first_item->mark_method_as_accessible( $callback[1] ); } } protected function mark_method_as_accessible( string $method_name ): bool { if ( method_exists( $this, $method_name ) ) { $this->_accessible_private_methods[ $method_name ] = $method_name; return true; } return false; } protected static function mark_static_method_as_accessible( string $method_name ): bool { if ( method_exists( __CLASS__, $method_name ) ) { static::$_accessible_static_private_methods[ $method_name ] = $method_name; return true; } return false; } public function __call( $name, $arguments ) { if ( isset( $this->_accessible_private_methods[ $name ] ) ) { return call_user_func_array( array( $this, $name ), $arguments ); } elseif ( is_callable( array( 'parent', '__call' ) ) ) { return parent::__call( $name, $arguments ); } elseif ( method_exists( $this, $name ) ) { throw new \Error( 'Call to private method ' . get_class( $this ) . '::' . $name ); } else { throw new \Error( 'Call to undefined method ' . get_class( $this ) . '::' . $name ); } } public static function __callStatic( $name, $arguments ) { if ( isset( static::$_accessible_static_private_methods[ $name ] ) ) { return call_user_func_array( array( __CLASS__, $name ), $arguments ); } elseif ( is_callable( array( 'parent', '__callStatic' ) ) ) { return parent::__callStatic( $name, $arguments ); } elseif ( 'add_action' === $name || 'add_filter' === $name ) { $proper_method_name = 'add_static_' . substr( $name, 4 ); throw new \Error( __CLASS__ . '::' . $name . " can't be called statically, did you mean '$proper_method_name'?" ); } elseif ( method_exists( __CLASS__, $name ) ) { throw new \Error( 'Call to private method ' . __CLASS__ . '::' . $name ); } else { throw new \Error( 'Call to undefined method ' . __CLASS__ . '::' . $name ); } } // phpcs:enable } Traits/OrderAttributionMeta.php 0000777 00000023716 15251706115 0012655 0 ustar 00 <?php declare( strict_types=1 ); namespace Automattic\WooCommerce\Internal\Traits; use Automattic\WooCommerce\Vendor\Detection\MobileDetect; use Exception; use WC_Meta_Data; use WC_Order; use WP_Post; /** * Trait OrderAttributionMeta * * @since 8.5.0 * * phpcs:disable Generic.Commenting.DocComment.MissingShort */ trait OrderAttributionMeta { /** * The default fields and their sourcebuster accessors, * to show in the source data metabox. * * @var string[] * */ private $default_fields = array( // main fields. 'source_type' => 'current.typ', 'referrer' => 'current_add.rf', // utm fields. 'utm_campaign' => 'current.cmp', 'utm_source' => 'current.src', 'utm_medium' => 'current.mdm', 'utm_content' => 'current.cnt', 'utm_id' => 'current.id', 'utm_term' => 'current.trm', 'utm_source_platform' => 'current.plt', 'utm_creative_format' => 'current.fmt', 'utm_marketing_tactic' => 'current.tct', // additional fields. 'session_entry' => 'current_add.ep', 'session_start_time' => 'current_add.fd', 'session_pages' => 'session.pgs', 'session_count' => 'udata.vst', 'user_agent' => 'udata.uag', ); /** @var array */ private $fields = array(); /** * Cached `array_keys( $fields )`. * * @var array * */ private $field_names = array(); /** @var string */ private $field_prefix = ''; /** * Get the device type based on the other meta fields. * * @param array $values The meta values. * * @return string The device type. */ protected function get_device_type( array $values ): string { $detector = new MobileDetect( array(), $values['user_agent'] ); if ( $detector->isMobile() ) { return 'Mobile'; } elseif ( $detector->isTablet() ) { return 'Tablet'; } else { return 'Desktop'; } } /** * Set the fields and the field prefix. * * @return void */ private function set_fields_and_prefix() { /** * Filter the fields to show in the source data metabox. * * @since 8.5.0 * * @param string[] $fields The fields to show. */ $this->fields = (array) apply_filters( 'wc_order_attribution_tracking_fields', $this->default_fields ); $this->field_names = array_keys( $this->fields ); $this->set_field_prefix(); } /** * Set the meta prefix for our fields. * * @return void */ private function set_field_prefix(): void { /** * Filter the prefix for the meta fields. * * @since 8.5.0 * * @param string $prefix The prefix for the meta fields. */ $prefix = (string) apply_filters( 'wc_order_attribution_tracking_field_prefix', 'wc_order_attribution_' ); // Remove leading and trailing underscores. $prefix = trim( $prefix, '_' ); // Ensure the prefix ends with _, and set the prefix. $this->field_prefix = "{$prefix}_"; } /** * Filter an order's meta data to only the keys that we care about. * * Sets the origin value based on the source type. * * @param WC_Meta_Data[] $meta The meta data. * * @return array */ private function filter_meta_data( array $meta ): array { $return = array(); $prefix = $this->get_meta_prefixed_field_name( '' ); foreach ( $meta as $item ) { if ( str_starts_with( $item->key, $prefix ) ) { $return[ $this->unprefix_meta_field_name( $item->key ) ] = $item->value; } } // Determine the device type from the user agent. if ( ! array_key_exists( 'device_type', $return ) && array_key_exists( 'user_agent', $return ) ) { $return['device_type'] = $this->get_device_type( $return ); } // Determine the origin based on source type and referrer. $source_type = $return['source_type'] ?? ''; $source = $return['utm_source'] ?? ''; $return['origin'] = $this->get_origin_label( $source_type, $source, true ); return $return; } /** * Get the field name with the appropriate prefix. * * @param string $name Field name. * * @return string The prefixed field name. */ private function get_prefixed_field_name( $name ): string { return "{$this->field_prefix}{$name}"; } /** * Get the field name with the meta prefix. * * @param string $name The field name. * * @return string The prefixed field name. */ private function get_meta_prefixed_field_name( string $name ): string { return "_{$this->get_prefixed_field_name( $name )}"; } /** * Remove the meta prefix from the field name. * * @param string $name The prefixed fieldname . * * @return string */ private function unprefix_meta_field_name( string $name ): string { return str_replace( "_{$this->field_prefix}", '', $name ); } /** * Get the order object with HPOS compatibility. * * @param WC_Order|WP_Post|int $post_or_order The post ID or object. * * @return WC_Order The order object * @throws Exception When the order isn't found. */ private function get_hpos_order_object( $post_or_order ) { // If we've already got an order object, just return it. if ( $post_or_order instanceof WC_Order ) { return $post_or_order; } // If we have a post ID, get the post object. if ( is_numeric( $post_or_order ) ) { $post_or_order = wc_get_order( $post_or_order ); } // Throw an exception if we don't have an order object. if ( ! $post_or_order instanceof WC_Order ) { throw new Exception( __( 'Order not found.', 'woocommerce' ) ); } return $post_or_order; } /** * Map posted, prefixed values to field values. * Used for the classic forms. * * @param array $raw_values The raw values from the POST form. * * @return array */ private function get_unprefixed_field_values( array $raw_values = array() ): array { $values = array(); // Look through each field in POST data. foreach ( $this->field_names as $field_name ) { $values[ $field_name ] = $raw_values[ $this->get_prefixed_field_name( $field_name ) ] ?? '(none)'; } return $values; } /** * Map submitted values to meta values. * * @param array $raw_values The raw (unprefixed) values from the submitted data. * * @return array */ private function get_source_values( array $raw_values = array() ): array { $values = array(); // Look through each field in given data. foreach ( $this->field_names as $field_name ) { $value = sanitize_text_field( wp_unslash( $raw_values[ $field_name ] ) ); if ( '(none)' === $value ) { continue; } $values[ $field_name ] = $value; } // Set the device type if possible using the user agent. if ( array_key_exists( 'user_agent', $values ) && ! empty( $values['user_agent'] ) ) { $values['device_type'] = $this->get_device_type( $values ); } return $values; } /** * Get the label for the Order origin with placeholder where appropriate. Can be * translated (for DB / display) or untranslated (for Tracks). * * @param string $source_type The source type. * @param string $source The source. * @param bool $translated Whether the label should be translated. * * @return string */ private function get_origin_label( string $source_type, string $source, bool $translated = true ): string { // Set up the label based on the source type. switch ( $source_type ) { case 'utm': $label = $translated ? /* translators: %s is the source value */ __( 'Source: %s', 'woocommerce' ) : 'Source: %s'; break; case 'organic': $label = $translated ? /* translators: %s is the source value */ __( 'Organic: %s', 'woocommerce' ) : 'Organic: %s'; break; case 'referral': $label = $translated ? /* translators: %s is the source value */ __( 'Referral: %s', 'woocommerce' ) : 'Referral: %s'; break; case 'typein': $label = ''; $source = $translated ? __( 'Direct', 'woocommerce' ) : 'Direct'; break; case 'mobile_app': $label = ''; $source = $translated ? __( 'Mobile app', 'woocommerce' ) : 'Mobile app'; break; case 'admin': $label = ''; $source = $translated ? __( 'Web admin', 'woocommerce' ) : 'Web admin'; break; case 'pos': $label = ''; $source = $translated ? __( 'Point of Sale', 'woocommerce' ) : 'Point of Sale'; break; default: $label = ''; $source = $translated ? __( 'Unknown', 'woocommerce' ) : 'Unknown'; break; } /** * Filter the formatted source for the order origin. * * @since 8.5.0 * * @param string $formatted_source The formatted source. * @param string $source The source. */ $formatted_source = apply_filters( 'wc_order_attribution_origin_formatted_source', ucfirst( trim( $source, '()' ) ), $source ); /** * Filter the label for the order origin. * * This label should have a %s placeholder for the formatted source to be inserted * via sprintf(). * * @since 8.5.0 * * @param string $label The label for the order origin. * @param string $source_type The source type. * @param string $source The source. * @param string $formatted_source The formatted source. */ $label = (string) apply_filters( 'wc_order_attribution_origin_label', $label, $source_type, $source, $formatted_source ); if ( false === strpos( $label, '%' ) ) { return $formatted_source; } return sprintf( $label, $formatted_source ); } /** * Get the description for the order attribution field. * * @param string $field_name The field name. * * @return string */ private function get_field_description( string $field_name ): string { /* translators: %s is the field name */ $description = sprintf( __( 'Order attribution field: %s', 'woocommerce' ), $field_name ); /** * Filter the description for the order attribution field. * * @since 8.5.0 * * @param string $description The description for the order attribution field. * @param string $field_name The field name. */ return (string) apply_filters( 'wc_order_attribution_field_description', $description, $field_name ); } } Settings/OptionSanitizer.php 0000777 00000003432 15251706115 0012232 0 ustar 00 <?php /** * FormatValidator class. */ namespace Automattic\WooCommerce\Internal\Settings; defined( 'ABSPATH' ) || exit; /** * This class handles sanitization of core options that need to conform to certain format. * * @since 6.6.0 */ class OptionSanitizer { /** * OptionSanitizer constructor. */ public function __construct() { // Sanitize color options. $color_options = array( 'woocommerce_email_base_color', 'woocommerce_email_background_color', 'woocommerce_email_body_background_color', 'woocommerce_email_text_color', ); foreach ( $color_options as $option_name ) { add_filter( "woocommerce_admin_settings_sanitize_option_{$option_name}", array( $this, 'sanitize_color_option' ), 10, 2 ); } // Cast "Out of stock threshold" field to absolute integer to prevent storing empty value. add_filter( 'woocommerce_admin_settings_sanitize_option_woocommerce_notify_no_stock_amount', 'absint' ); } /** * Sanitizes values for options of type 'color' before persisting to the database. * Falls back to previous/default value for the option if given an invalid value. * * @since 6.6.0 * @param string $value Option value. * @param array $option Option data. * @return string Color in hex format. * * @internal For exclusive usage of WooCommerce core, backwards compatibility not guaranteed. */ public function sanitize_color_option( $value, $option ) { $value = sanitize_hex_color( $value ); // If invalid, try the current value. if ( ! $value && ! empty( $option['id'] ) ) { $value = sanitize_hex_color( get_option( $option['id'] ) ); } // If still invalid, try the default. if ( ! $value && ! empty( $option['default'] ) ) { $value = sanitize_hex_color( $option['default'] ); } return (string) $value; } } Settings/PointOfSaleDefaultSettings.php 0000777 00000002365 15251706115 0014306 0 ustar 00 <?php /** * Default settings for Point of Sale. * * @package WooCommerce\Internal\Settings */ declare(strict_types=1); namespace Automattic\WooCommerce\Internal\Settings; if ( ! defined( 'ABSPATH' ) ) { exit; // Exit if accessed directly. } /** * PointOfSaleDefaultSettings class. */ class PointOfSaleDefaultSettings { /** * Get default store email. * * @return string */ public static function get_default_store_email() { return get_option( 'admin_email' ); } /** * Get default store name. * * @return string */ public static function get_default_store_name() { return get_bloginfo( 'name' ); } /** * Get default store address. * * @return string */ public static function get_default_store_address() { if ( ! WC() || ! WC()->countries ) { return ''; } return wp_specialchars_decode( WC()->countries->get_formatted_address( array( 'address_1' => WC()->countries->get_base_address(), 'address_2' => WC()->countries->get_base_address_2(), 'city' => WC()->countries->get_base_city(), 'state' => WC()->countries->get_base_state(), 'postcode' => WC()->countries->get_base_postcode(), 'country' => WC()->countries->get_base_country(), ), "\n" ) ); } } ProductImage/MatchImageBySKU.php 0000777 00000003653 15251706115 0012536 0 ustar 00 <?php /** * MatchImageBySKU class file. */ namespace Automattic\WooCommerce\Internal\ProductImage; defined( 'ABSPATH' ) || exit; /** * Class for the product image matching by SKU. */ class MatchImageBySKU { /** * The name of the setting for this feature. * * @var string */ private $setting_name = 'woocommerce_product_match_featured_image_by_sku'; /** * MatchImageBySKU constructor. */ public function __construct() { $this->init_hooks(); } /** * Initialize the hooks used by the class. */ private function init_hooks() { add_filter( 'woocommerce_get_settings_products', array( $this, 'add_product_image_sku_setting' ), 110, 2 ); } /** * Is this feature enabled. * * @since 8.3.0 * @return bool */ public function is_enabled() { return wc_string_to_bool( get_option( $this->setting_name ) ); } /** * Handler for 'woocommerce_get_settings_products', adds the settings related to the product image SKU matching table. * * @param array $settings Original settings configuration array. * @param string $section_id Settings section identifier. * @return array New settings configuration array. * * @internal For exclusive usage of WooCommerce core, backwards compatibility not guaranteed. */ public function add_product_image_sku_setting( array $settings, string $section_id ): array { if ( 'advanced' !== $section_id ) { return $settings; } $settings[] = array( 'title' => __( 'Product image matching by SKU', 'woocommerce' ), 'type' => 'title', ); $settings[] = array( 'title' => __( 'Match images', 'woocommerce' ), 'desc' => __( 'Set product featured image when uploaded image file name matches product SKU.', 'woocommerce' ), 'id' => $this->setting_name, 'default' => 'no', 'type' => 'checkbox', 'checkboxgroup' => 'start', ); $settings[] = array( 'type' => 'sectionend' ); return $settings; } } AssignDefaultCategory.php 0000777 00000003721 15251706115 0011521 0 ustar 00 <?php /** * AssignDefaultCategory class file. */ namespace Automattic\WooCommerce\Internal; defined( 'ABSPATH' ) || exit; /** * Class to assign default category to products. */ class AssignDefaultCategory { /** * Class initialization, to be executed when the class is resolved by the container. * * @internal */ final public function init() { add_action( 'wc_schedule_update_product_default_cat', array( $this, 'maybe_assign_default_product_cat' ) ); } /** * When a product category is deleted, we need to check * if the product has no categories assigned. Then assign * it a default category. We delay this with a scheduled * action job to not block the response. * * @return void */ public function schedule_action() { WC()->queue()->schedule_single( time(), 'wc_schedule_update_product_default_cat', array(), 'wc_update_product_default_cat' ); } /** * Assigns default product category for products * that have no categories. * * @return void */ public function maybe_assign_default_product_cat() { global $wpdb; $default_category = get_option( 'default_product_cat', 0 ); if ( $default_category ) { $affected_rows = $wpdb->query( $wpdb->prepare( "INSERT INTO {$wpdb->term_relationships} (object_id, term_taxonomy_id) SELECT DISTINCT posts.ID, %s FROM {$wpdb->posts} posts LEFT JOIN ( SELECT object_id FROM {$wpdb->term_relationships} term_relationships LEFT JOIN {$wpdb->term_taxonomy} term_taxonomy ON term_relationships.term_taxonomy_id = term_taxonomy.term_taxonomy_id WHERE term_taxonomy.taxonomy = 'product_cat' ) AS tax_query ON posts.ID = tax_query.object_id WHERE posts.post_type = 'product' AND tax_query.object_id IS NULL", $default_category ) ); if ( $affected_rows > 0 ) { wp_cache_flush(); delete_transient( 'wc_term_counts' ); wp_update_term_count_now( array( $default_category ), 'product_cat' ); } } } } Brands.php 0000777 00000002424 15251706115 0006502 0 ustar 00 <?php /** * Brands class file. */ declare( strict_types = 1); namespace Automattic\WooCommerce\Internal; defined( 'ABSPATH' ) || exit; /** * Class to initiate Brands functionality in core. */ class Brands { /** * Class initialization * * @internal */ final public static function init() { if ( ! self::is_enabled() ) { return; } include_once WC_ABSPATH . 'includes/class-wc-brands.php'; include_once WC_ABSPATH . 'includes/class-wc-brands-coupons.php'; include_once WC_ABSPATH . 'includes/class-wc-brands-brand-settings-manager.php'; include_once WC_ABSPATH . 'includes/wc-brands-functions.php'; if ( is_admin() ) { include_once WC_ABSPATH . 'includes/admin/class-wc-admin-brands.php'; } } /** * As of WooCommerce 9.6, Brands is enabled for all users. * * @return bool */ public static function is_enabled() { return true; } /** * If WooCommerce Brands gets activated forcibly, without WooCommerce active (e.g. via '--skip-plugins'), * remove WooCommerce Brands initialization functions early on in the 'plugins_loaded' timeline. */ public static function prepare() { if ( ! self::is_enabled() ) { return; } if ( function_exists( 'wc_brands_init' ) ) { remove_action( 'plugins_loaded', 'wc_brands_init', 1 ); } } } Logging/SafeGlobalFunctionProxy.php 0000777 00000010417 15251706115 0013427 0 ustar 00 <?php declare(strict_types=1); namespace Automattic\WooCommerce\Internal\Logging; /** * SafeGlobalFunctionProxy Class * * This class creates a wrapper for non-built-in functions for safety. * * @since 9.4.0 * @package Automattic\WooCommerce\Internal\Logging */ class SafeGlobalFunctionProxy { /** * Load missing function if we know where to find it. * Modify this file to add more functions to the map. * * @param string $name The name of the function to load. * @return void * @throws \Exception If the function is missing and could not be loaded. */ private static function maybe_load_missing_function( $name ) { $function_map = array( 'wp_parse_url' => ABSPATH . WPINC . '/http.php', 'home_url' => ABSPATH . WPINC . '/link-template.php', 'get_bloginfo' => ABSPATH . WPINC . '/general-template.php', 'get_option' => ABSPATH . WPINC . '/option.php', 'get_site_transient' => ABSPATH . WPINC . '/option.php', 'set_site_transient' => ABSPATH . WPINC . '/option.php', 'wp_safe_remote_post' => ABSPATH . WPINC . '/http.php', 'is_wp_error' => ABSPATH . WPINC . '/load.php', 'get_plugin_updates' => array( ABSPATH . 'wp-admin/includes/update.php', ABSPATH . 'wp-admin/includes/plugin.php' ), 'wp_get_environment_type' => ABSPATH . WPINC . '/load.php', 'wp_json_encode' => ABSPATH . WPINC . '/functions.php', 'wc_get_logger' => WC_ABSPATH . 'includes/class-wc-logger.php', 'wc_print_r' => WC_ABSPATH . 'includes/wc-core-functions.php', ); if ( ! function_exists( $name ) ) { if ( isset( $function_map[ $name ] ) ) { $files = (array) $function_map[ $name ]; foreach ( $files as $file ) { require_once $file; } } else { throw new \Exception( sprintf( 'Function %s does not exist and could not be loaded.', esc_html( $name ) ) ); } } } /** * Proxy for trapping all calls on SafeGlobalFunctionProxy. * Use this for calling WP and WC global functions safely. * Example usage: * * SafeGlobalFunctionProxy::wp_parse_url('https://example.com', PHP_URL_PATH); * * @since 9.4.0 * @param string $name The name of the function to call. * @param array $arguments The arguments to pass to the function. * @return mixed The result of the function call, or null if an error occurs. */ public static function __callStatic( $name, $arguments ) { // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_set_error_handler -- Custom error handler is necessary to convert errors to exceptions set_error_handler( static function ( int $type, string $message, string $file, int $line ) { if ( __FILE__ === $file ) { // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_debug_backtrace -- Used to adjust file and line number for accurate error reporting $trace = debug_backtrace( DEBUG_BACKTRACE_IGNORE_ARGS, 3 ); $file = $trace[2]['file'] ?? $file; $line = $trace[2]['line'] ?? $line; } $sanitized_message = filter_var( $message, FILTER_SANITIZE_FULL_SPECIAL_CHARS ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- $message sanitised above. we don't want to rely on esc_html since it's not a PHP built-in throw new \ErrorException( $sanitized_message, 0, $type, $file, $line ); } ); try { self::maybe_load_missing_function( $name ); $results = call_user_func_array( $name, $arguments ); } catch ( \Throwable $e ) { self::log_wrapper_error( $name, $e->getMessage(), $arguments ); $results = null; } finally { restore_error_handler(); } return $results; } /** * Log wrapper function errors to "local logging" for debugging. * * @param string $function_name The name of the wrapped function. * @param string $error_message The error message. * @param array $context Additional context for the error. */ protected static function log_wrapper_error( $function_name, $error_message, $context = array() ) { self::maybe_load_missing_function( 'wc_get_logger' ); wc_get_logger()->error( '[Wrapper function error] ' . sprintf( 'Error in %s: %s', $function_name, $error_message ), array_merge( array( 'function' => $function_name, 'source' => 'remote-logging', ), $context ) ); } } Logging/OrderLogsDeletionProcessor.php 0000777 00000021133 15251706115 0014141 0 ustar 00 <?php declare( strict_types=1 ); namespace Automattic\WooCommerce\Internal\Logging; use Automattic\WooCommerce\Internal\BatchProcessing\BatchProcessorInterface; use Automattic\WooCommerce\Internal\DataStores\Orders\CustomOrdersTableController; use Automattic\WooCommerce\Internal\DataStores\Orders\DataSynchronizer; use Automattic\WooCommerce\Proxies\LegacyProxy; use Automattic\WooCommerce\Utilities\StringUtil; /** * Batch processor for deleting log entries of completed orders. * It only works when either HPOS is enabled or the orders data store is the old CPT-based one, * because otherwise the ability to query orders by meta key is not guaranteed. */ class OrderLogsDeletionProcessor implements BatchProcessorInterface { /** * Constant representing the default size of the batches to process. */ public const DEFAULT_BATCH_SIZE = 1000; /** * True if HPOS is enabled. * * @var bool */ private bool $hpos_in_use = false; /** * True if HPOS is disabled and the orders data store in use is the old CPT one. * * @var bool */ private bool $cpt_in_use = false; /** * The instance of LegacyProxy to use. * * @var LegacyProxy */ private LegacyProxy $legacy_proxy; /** * The instance of DataSynchronizer to use. * * @var DataSynchronizer */ private DataSynchronizer $data_synchronizer; /** * Initialize the instance. * This is invoked by the dependency injection container. * * @param CustomOrdersTableController $hpos_controller The instance of CustomOrdersTableController to use. * @param LegacyProxy $legacy_proxy The instance of LegacyProxy to use. * @param DataSynchronizer $data_synchronizer The instance of DataSynchronizer to use. * * @internal */ final public function init( CustomOrdersTableController $hpos_controller, LegacyProxy $legacy_proxy, DataSynchronizer $data_synchronizer ) { $this->hpos_in_use = $hpos_controller->custom_orders_table_usage_is_enabled(); if ( ! $this->hpos_in_use ) { $this->cpt_in_use = \WC_Order_Data_Store_CPT::class === \WC_Data_Store::load( 'order' )->get_current_class_name(); } $this->legacy_proxy = $legacy_proxy; $this->data_synchronizer = $data_synchronizer; } /** * Get the name of the processor. * * @return string */ public function get_name(): string { return 'Order logs deletion process'; } /** * Get a description of the processor. * * @return string */ public function get_description(): string { return 'Deletes debug logs of completed orders.'; } /** * Get the default batch size for this processor. * * @return int */ public function get_default_batch_size(): int { return self::DEFAULT_BATCH_SIZE; } /** * Get the total count of entries pending processing. * * @return int */ public function get_total_pending_count(): int { if ( $this->hpos_in_use ) { return $this->get_total_pending_count_hpos(); } elseif ( $this->cpt_in_use ) { return $this->get_total_pending_count_cpt(); } else { $this->throw_doing_it_wrong( StringUtil::class_name_without_namespace( __CLASS__ ) . '::' . __FUNCTION__ ); return 0; } } /** * Get the total count of entries pending processing, HPOS version. * * @return int */ private function get_total_pending_count_hpos(): int { global $wpdb; return (int) $wpdb->get_var( $wpdb->prepare( "SELECT COUNT(*) FROM {$wpdb->prefix}wc_orders_meta WHERE meta_key = %s", '_debug_log_source_pending_deletion' ) ); } /** * Get the total count of entries pending processing, CPT datastore version. * * @return int */ private function get_total_pending_count_cpt(): int { global $wpdb; return (int) $wpdb->get_var( $wpdb->prepare( "SELECT COUNT(*) FROM {$wpdb->postmeta} pm INNER JOIN {$wpdb->posts} p ON pm.post_id = p.ID WHERE pm.meta_key = %s AND p.post_type = %s", '_debug_log_source_pending_deletion', 'shop_order' ) ); } /** * Get the next batch of items to process. * An item will be an associative array of 'order_id' and 'meta_value'. * * @param int $size Maximum size of the batch to return. * @return array */ public function get_next_batch_to_process( int $size ): array { if ( $this->hpos_in_use ) { return $this->get_next_batch_to_process_hpos( $size ); } elseif ( $this->cpt_in_use ) { return $this->get_next_batch_to_process_cpt( $size ); } else { $this->throw_doing_it_wrong( StringUtil::class_name_without_namespace( __CLASS__ ) . '::' . __FUNCTION__ ); return array(); } } /** * Get the next batch of items to process, HPOS version. * * @param int $size Maximum size of the batch to return. * @return array */ private function get_next_batch_to_process_hpos( int $size ): array { global $wpdb; return $wpdb->get_results( $wpdb->prepare( "SELECT order_id, meta_value FROM {$wpdb->prefix}wc_orders_meta WHERE meta_key = %s ORDER BY order_id LIMIT %d", '_debug_log_source_pending_deletion', $size ), ARRAY_A ); } /** * Get the next batch of items to process, CPT datastore version. * * @param int $size Maximum size of the batch to return. * @return array */ private function get_next_batch_to_process_cpt( int $size ): array { global $wpdb; return $wpdb->get_results( $wpdb->prepare( "SELECT p.ID as order_id, pm.meta_value FROM {$wpdb->postmeta} pm INNER JOIN {$wpdb->posts} p ON pm.post_id = p.ID WHERE pm.meta_key = %s AND p.post_type = 'shop_order' ORDER BY p.ID LIMIT %d", '_debug_log_source_pending_deletion', $size ), ARRAY_A ); } /** * Process a batch of items. * Items are expected to be in the format returned by get_next_batch_to_process. * * @param array $batch Batch of items to process. * @throws \Exception Invalid input. */ public function process_batch( array $batch ): void { if ( empty( $batch ) ) { return; } if ( ! $this->hpos_in_use && ! $this->cpt_in_use ) { $this->throw_doing_it_wrong( StringUtil::class_name_without_namespace( __CLASS__ ) . '::' . __FUNCTION__ ); return; } $logger = $this->legacy_proxy->call_function( 'wc_get_logger' ); foreach ( $batch as $item ) { if ( ! is_array( $item ) || ! isset( $item['meta_value'] ) || ! isset( $item['order_id'] ) ) { throw new \Exception( "\$batch must be an array of arrays, each having a 'meta_value' key and an 'order_id' key" ); } $logger->clear( $item['meta_value'] ); } $order_ids = array_map( 'absint', array_column( $batch, 'order_id' ) ); // Delete from the authoritative meta table. $this->delete_debug_log_source_meta_entries( true, $order_ids ); if ( $this->data_synchronizer->data_sync_is_enabled() ) { // When HPOS data sync is enabled we need to manually delete the entries in the backup meta table too, // otherwise the next sync process will restore the rows we just deleted from the authoritative meta table. $this->delete_debug_log_source_meta_entries( false, $order_ids ); } } /** * Delete meta entries for the given order IDs. * * @param bool $from_authoritative_table True to delete from the authoritative table, false for the backup table. * @param array $order_ids Array of order IDs to delete. */ private function delete_debug_log_source_meta_entries( bool $from_authoritative_table, array $order_ids ): void { global $wpdb; $use_hpos_table = $this->hpos_in_use === $from_authoritative_table; $table_name = $use_hpos_table ? "{$wpdb->prefix}wc_orders_meta" : $wpdb->postmeta; $id_column_name = $use_hpos_table ? 'order_id' : 'post_id'; $placeholders = implode( ',', array_fill( 0, count( $order_ids ), '%d' ) ); // phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared $wpdb->query( $wpdb->prepare( "DELETE FROM {$table_name} WHERE {$id_column_name} IN ({$placeholders}) AND meta_key = %s", array_merge( $order_ids, array( '_debug_log_source_pending_deletion' ) ) ) ); // phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared } /** * Throw a "doing it wrong" error. * * @param string $function_name Class and function name to include in the error. */ private function throw_doing_it_wrong( string $function_name ) { $this->legacy_proxy->call_function( 'wc_doing_it_wrong', $function_name, "This processor shouldn't be enqueued when the orders data store in use is neither the HPOS one nor the CPT one. Just delete the order debug logs directly.", '10.3.0' ); } } Logging/RemoteLogger.php 0000777 00000051430 15251706115 0011253 0 ustar 00 <?php declare( strict_types = 1 ); namespace Automattic\WooCommerce\Internal\Logging; use Automattic\WooCommerce\Utilities\FeaturesUtil; use Automattic\WooCommerce\Utilities\StringUtil; use Automattic\WooCommerce\Internal\McStats; use Jetpack_Options; use WC_Rate_Limiter; use WC_Log_Levels; use WC_Site_Tracking; /** * WooCommerce Remote Logger * * The WooCommerce remote logger class adds functionality to log WooCommerce errors remotely based on if the customer opted in and several other conditions. * * No personal information is logged, only error information and relevant context. * * @class RemoteLogger * @since 9.2.0 * @package WooCommerce\Classes */ class RemoteLogger extends \WC_Log_Handler { const LOG_ENDPOINT = 'https://public-api.wordpress.com/rest/v1.1/logstash'; const RATE_LIMIT_ID = 'woocommerce_remote_logging'; const RATE_LIMIT_DELAY = 60; // 1 minute. const WC_NEW_VERSION_TRANSIENT = 'woocommerce_new_version'; /** * Handle a log entry. * * @param int $timestamp Log timestamp. * @param string $level emergency|alert|critical|error|warning|notice|info|debug. * @param string $message Log message. * @param array $context Additional information for log handlers. * * @throws \Exception If the remote logging fails. The error is caught and logged locally. * * @return bool False if value was not handled and true if value was handled. */ public function handle( $timestamp, $level, $message, $context ) { try { if ( ! $this->should_handle( $level, $message, $context ) ) { return false; } return $this->log( $level, $message, $context ); } catch ( \Throwable $e ) { // Log the error to the local logger so we can investigate. SafeGlobalFunctionProxy::wc_get_logger()->error( 'Failed to handle the log: ' . $e->getMessage(), array( 'source' => 'remote-logging' ) ); return false; } } /** * Get formatted log data to be sent to the remote logging service. * * This method formats the log data by sanitizing the message, adding default fields, and including additional context * such as backtrace, tags, and extra attributes. It also integrates with WC_Tracks to include blog and store details. * The formatted log data is then filtered before being sent to the remote logging service. * * @param string $level Log level (e.g., 'error', 'warning', 'info'). * @param string $message Log message to be recorded. * @param array $context Optional. Additional information for log handlers, such as 'backtrace', 'tags', 'extra', and 'error'. * * @return array Formatted log data ready to be sent to the remote logging service. */ public function get_formatted_log( $level, $message, $context = array() ) { $log_data = array( // Default fields. 'feature' => 'woocommerce_core', 'severity' => $level, 'message' => $this->sanitize( $message ), 'host' => SafeGlobalFunctionProxy::wp_parse_url( SafeGlobalFunctionProxy::home_url(), PHP_URL_HOST ) ?? 'Unable to retrieve host', 'tags' => array( 'woocommerce', 'php' ), 'properties' => array( 'wc_version' => $this->get_wc_version(), 'php_version' => phpversion(), 'wp_version' => SafeGlobalFunctionProxy::get_bloginfo( 'version' ) ?? 'Unable to retrieve wp version', 'request_uri' => $this->sanitize_request_uri( filter_input( INPUT_SERVER, 'REQUEST_URI', FILTER_SANITIZE_URL ) ), 'store_id' => SafeGlobalFunctionProxy::get_option( \WC_Install::STORE_ID_OPTION, null ) ?? 'Unable to retrieve store id', ), ); $blog_id = class_exists( 'Jetpack_Options' ) ? Jetpack_Options::get_option( 'id' ) : null; if ( ! empty( $blog_id ) && is_int( $blog_id ) ) { $log_data['blog_id'] = $blog_id; } if ( isset( $context['backtrace'] ) ) { if ( is_array( $context['backtrace'] ) || is_string( $context['backtrace'] ) ) { $log_data['trace'] = $this->sanitize_trace( $context['backtrace'] ); } elseif ( true === $context['backtrace'] ) { $log_data['trace'] = $this->sanitize_trace( self::get_backtrace() ); } unset( $context['backtrace'] ); } if ( isset( $context['tags'] ) && is_array( $context['tags'] ) ) { $log_data['tags'] = array_merge( $log_data['tags'], $context['tags'] ); unset( $context['tags'] ); } if ( isset( $context['error']['file'] ) && is_string( $context['error']['file'] ) && '' !== $context['error']['file'] ) { $log_data['file'] = $this->normalize_paths( $context['error']['file'] ); unset( $context['error']['file'] ); } $extra_attrs = $context['extra'] ?? array(); unset( $context['extra'] ); unset( $context['remote-logging'] ); // Merge the extra attributes with the remaining context since we can't send arbitrary fields to Logstash. $log_data['extra'] = array_merge( $extra_attrs, $context ); /** * Filters the formatted log data before sending it to the remote logging service. * Returning a non-array value will prevent the log from being sent. * * @since 9.2.0 * * @param array $log_data The formatted log data. * @param string $level The log level (e.g., 'error', 'warning'). * @param string $message The log message. * @param array $context The original context array. * * @return array The filtered log data. */ return apply_filters( 'woocommerce_remote_logger_formatted_log_data', $log_data, $level, $message, $context ); } /** * Determines if remote logging is allowed based on the following conditions: * * 1. The feature flag for remote error logging is enabled. * 2. The user has opted into tracking/logging. * 3. The store is allowed to log based on the variant assignment percentage. * 4. The current WooCommerce version is the latest so we don't log errors that might have been fixed in a newer version. * * @return bool */ public function is_remote_logging_allowed() { if ( ! FeaturesUtil::feature_is_enabled( 'remote_logging' ) ) { return false; } if ( ! WC_Site_Tracking::is_tracking_enabled() ) { return false; } if ( ! $this->should_current_version_be_logged() ) { return false; } return true; } /** * Determine whether to handle or ignore log. * * @param string $level emergency|alert|critical|error|warning|notice|info|debug. * @param string $message Log message to be recorded. * @param array $context Additional information for log handlers. * * @return bool True if the log should be handled. */ protected function should_handle( $level, $message, $context ) { // Ignore logs that are not opted in for remote logging. if ( ! isset( $context['remote-logging'] ) || false === $context['remote-logging'] ) { return false; } if ( ! $this->is_remote_logging_allowed() ) { return false; } if ( $this->is_third_party_error( (string) $message, (array) $context ) ) { return false; } // Record fatal error stats. if ( WC_Log_Levels::get_level_severity( $level ) >= WC_Log_Levels::get_level_severity( WC_Log_Levels::CRITICAL ) ) { try { $mc_stats = wc_get_container()->get( McStats::class ); $mc_stats->add( 'error', 'critical-errors' ); $mc_stats->do_server_side_stats(); } catch ( \Throwable $e ) { error_log( 'Warning: Failed to record fatal error stats: ' . $e->getMessage() ); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log } } if ( WC_Rate_Limiter::retried_too_soon( self::RATE_LIMIT_ID ) ) { // Log locally that the remote logging is throttled. SafeGlobalFunctionProxy::wc_get_logger()->warning( 'Remote logging throttled.', array( 'source' => 'remote-logging' ) ); return false; } return true; } /** * Send the log to the remote logging service. * * @param string $level Log level (e.g., 'error', 'warning', 'info'). * @param string $message Log message to be recorded. * @param array $context Optional. Additional information for log handlers, such as 'backtrace', 'tags', 'extra', and 'error'. * * @throws \Exception|\Error If the remote logging fails. The error is caught and logged locally. * @return bool */ private function log( $level, $message, $context ) { $log_data = $this->get_formatted_log( $level, $message, $context ); // Ensure the log data is valid. if ( ! is_array( $log_data ) || empty( $log_data['message'] ) || empty( $log_data['feature'] ) ) { return false; } $body = SafeGlobalFunctionProxy::wp_json_encode( array( 'params' => SafeGlobalFunctionProxy::wp_json_encode( $log_data ) ) ); if ( is_null( $body ) ) { // if the json encoding fails the API will reject the API call so let's not bother. throw new \Error( 'Remote Logger encountered error while attempting to JSON encode $log_data' ); } WC_Rate_Limiter::set_rate_limit( self::RATE_LIMIT_ID, self::RATE_LIMIT_DELAY ); if ( $this->is_dev_or_local_environment() ) { return false; } $response = SafeGlobalFunctionProxy::wp_safe_remote_post( self::LOG_ENDPOINT, array( 'body' => $body, 'timeout' => 3, 'headers' => array( 'Content-Type' => 'application/json', ), 'blocking' => false, ) ); if ( is_null( $response ) ) { // SafeGlobalFunctionProxy will return a null if an error occurs within, so there will be a separate log entry with the details. SafeGlobalFunctionProxy::wc_get_logger()->error( 'Failed to call wp_safe_remote_post while sending the log to the remote logging service.', array( 'source' => 'remote-logging' ) ); return false; } $is_api_call_error = SafeGlobalFunctionProxy::is_wp_error( $response ); if ( $is_api_call_error ) { SafeGlobalFunctionProxy::wc_get_logger()->error( 'Failed to send the log to the remote logging service: ' . $response->get_error_message(), array( 'source' => 'remote-logging' ) ); return false; } elseif ( is_null( $is_api_call_error ) ) { SafeGlobalFunctionProxy::wc_get_logger()->error( 'Failed to parse the response after sending log to the remote logging service. ', array( 'source' => 'remote-logging' ) ); return false; } return true; } /** * Check if the current WooCommerce version is the latest. * * @return bool */ private function should_current_version_be_logged() { $new_version = SafeGlobalFunctionProxy::get_site_transient( self::WC_NEW_VERSION_TRANSIENT ) ?? ''; if ( false === $new_version ) { $new_version = $this->fetch_new_woocommerce_version(); // Cache the new version for a week since we want to keep logging in with the same version for a while even if the new version is available. SafeGlobalFunctionProxy::set_site_transient( self::WC_NEW_VERSION_TRANSIENT, $new_version, WEEK_IN_SECONDS ); } if ( ! is_string( $new_version ) || '' === $new_version ) { // If the new version is not available, we consider the current version to be the latest. return true; } // If the current version is the latest, we don't want to log errors. return version_compare( $this->get_wc_version(), $new_version, '>=' ); } /** * Get the current WooCommerce version reliably through a series of fallbacks * * @return string The current WooCommerce version. */ private function get_wc_version() { if ( class_exists( '\Automattic\Jetpack\Constants' ) && method_exists( '\Automattic\Jetpack\Constants', 'get_constant' ) ) { $wc_version = \Automattic\Jetpack\Constants::get_constant( 'WC_VERSION' ); if ( $wc_version ) { return $wc_version; } } if ( defined( 'WC_VERSION' ) ) { return WC_VERSION; } if ( function_exists( 'WC' ) ) { return WC()->version; } // Return null since none of the above worked. return null; } /** * Check if the error exclusively contains third-party stack frames for fatal-errors source context. * * @param string $message The error message. * @param array $context The error context. * * @return bool */ protected function is_third_party_error( string $message, array $context ): bool { // Only check for fatal-errors source context. if ( ! isset( $context['source'] ) || 'fatal-errors' !== $context['source'] ) { return false; } $wc_plugin_dir = StringUtil::normalize_local_path_slashes( WC_ABSPATH ); // Check if the error message contains the WooCommerce plugin directory. if ( str_contains( $message, $wc_plugin_dir ) ) { return false; } // Without a backtrace, it's impossible to ascertain if the error is third-party. To avoid logging numerous irrelevant errors, we'll consider it a third-party error and ignore it. if ( isset( $context['backtrace'] ) && is_array( $context['backtrace'] ) ) { $wp_includes_dir = StringUtil::normalize_local_path_slashes( ABSPATH . WPINC ); $wp_admin_dir = StringUtil::normalize_local_path_slashes( ABSPATH . 'wp-admin' ); // Find the first relevant frame that is not from WordPress core and not empty. $relevant_frame = null; foreach ( $context['backtrace'] as $frame ) { if ( empty( $frame ) || ! is_string( $frame ) ) { continue; } // Skip frames from WordPress core. if ( strpos( $frame, $wp_includes_dir ) !== false || strpos( $frame, $wp_admin_dir ) !== false ) { continue; } $relevant_frame = $frame; break; } // Check if the relevant frame is from WooCommerce. if ( $relevant_frame && strpos( $relevant_frame, $wc_plugin_dir ) !== false ) { return false; } } if ( ! function_exists( 'apply_filters' ) ) { require_once ABSPATH . WPINC . '/plugin.php'; } /** * Filter to allow other plugins to overwrite the result of the third-party error check for remote logging. * * @since 9.2.0 * * @param bool $is_third_party_error The result of the third-party error check. * @param string $message The error message. * @param array $context The error context. */ return apply_filters( 'woocommerce_remote_logging_is_third_party_error', true, $message, $context ); } /** * Fetch the new version of WooCommerce from the WordPress API. * * @return string|null New version if an update is available, null otherwise. */ private function fetch_new_woocommerce_version() { $plugin_updates = SafeGlobalFunctionProxy::get_plugin_updates(); // Check if WooCommerce plugin update information is available. if ( ! is_array( $plugin_updates ) || ! isset( $plugin_updates[ WC_PLUGIN_BASENAME ] ) ) { return null; } $wc_plugin_update = $plugin_updates[ WC_PLUGIN_BASENAME ]; // Ensure the update object exists and has the required information. if ( ! $wc_plugin_update || ! isset( $wc_plugin_update->update->new_version ) ) { return null; } $new_version = $wc_plugin_update->update->new_version; return is_string( $new_version ) ? $new_version : null; } /** * Sanitize the content to exclude sensitive data. * * The trace is sanitized by: * * 1. Remove the absolute path to the plugin directory based on WC_ABSPATH. This is more accurate than using WP_PLUGIN_DIR when the plugin is symlinked. * 2. Remove the absolute path to the WordPress root directory. * 3. Redact potential user data such as email addresses and phone numbers. * * For example, the trace: * * /var/www/html/wp-content/plugins/woocommerce/includes/class-wc-remote-logger.php on line 123 * will be sanitized to: **\/woocommerce/includes/class-wc-remote-logger.php on line 123 * * Additionally, any user data like email addresses or phone numbers will be redacted. * * @param string $content The content to sanitize. * * @return string The sanitized content. */ private function sanitize( $content ) { if ( ! is_string( $content ) ) { return $content; } $sanitized = $this->normalize_paths( $content ); $sanitized = $this->redact_user_data( $sanitized ); if ( ! function_exists( 'apply_filters' ) ) { require_once ABSPATH . WPINC . '/plugin.php'; } /** * Filter the sanitized log content before it's sent to the remote logging service. * * @since 9.5.0 * * @param string $sanitized The sanitized content. * @param string $content The original content. */ return apply_filters( 'woocommerce_remote_logger_sanitized_content', $sanitized, $content ); } /** * Normalize file paths by replacing absolute paths with relative ones. * * @param string $content The content containing paths to normalize. * * @return string The content with normalized paths. */ private function normalize_paths( string $content ): string { $plugin_path = StringUtil::normalize_local_path_slashes( trailingslashit( dirname( WC_ABSPATH ) ) ); $wp_path = StringUtil::normalize_local_path_slashes( trailingslashit( ABSPATH ) ); return str_replace( array( $plugin_path, $wp_path ), array( './', './' ), $content ); } /** * Sanitize the error trace to exclude sensitive data. * * @param array|string $trace The error trace. * @return string The sanitized trace. */ private function sanitize_trace( $trace ): string { if ( is_string( $trace ) ) { return $this->sanitize( $trace ); } if ( ! is_array( $trace ) ) { return ''; } $sanitized_trace = array_map( function ( $trace_item ) { if ( is_array( $trace_item ) && isset( $trace_item['file'] ) ) { $trace_item['file'] = $this->sanitize( $trace_item['file'] ); return $trace_item; } return $this->sanitize( $trace_item ); }, $trace ); $is_array_by_file = isset( $sanitized_trace[0]['file'] ); if ( $is_array_by_file ) { return SafeGlobalFunctionProxy::wc_print_r( $sanitized_trace, true ); } return implode( "\n", $sanitized_trace ); } /** * Redact potential user data from the content. * * @param string $content The content to redact. * @return string The redacted message. */ private function redact_user_data( $content ) { // Redact email addresses. $content = preg_replace( '/[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}/', '[redacted_email]', $content ); // Redact potential IP addresses. $content = preg_replace( '/\b(?:\d{1,3}\.){3}\d{1,3}\b/', '[redacted_ip]', $content ); // Redact potential credit card numbers. $content = preg_replace( '/(\d{4}[- ]?){3}\d{4}/', '[redacted_credit_card]', $content ); // API key redaction patterns. $api_patterns = array( '/\b[A-Za-z0-9]{32,40}\b/', // Generic API key. '/\b[0-9a-f]{32}\b/i', // 32 hex characters. '/\b(?:[A-Z0-9]{4}-){3,7}[A-Z0-9]{4}\b/i', // Segmented API key (e.g., XXXX-XXXX-XXXX-XXXX). '/\bsk_[A-Za-z0-9]{24,}\b/i', // Stripe keys (starts with sk_). ); foreach ( $api_patterns as $pattern ) { $content = preg_replace( $pattern, '[redacted_api_key]', $content ); } /** * Redact potential phone numbers. * * This will match patterns like: * +1 (123) 456 7890 (with parentheses around area code) * +44-123-4567-890 (with area code, no parentheses) * 1234567890 (10 consecutive digits, no area code) * (123) 456-7890 (area code in parentheses, groups) * +91 12345 67890 (international format with space) */ $content = preg_replace( '/(?:(?:\+?\d{1,3}[-\s]?)?\(?\d{3}\)?[-\s]?\d{3}[-\s]?\d{4}|\b\d{10,11}\b)/', '[redacted_phone]', $content ); return $content; } /** * Check if the current environment is development or local. * * Creates a helper method so we can easily mock this in tests. * * @return bool */ protected function is_dev_or_local_environment() { return in_array( SafeGlobalFunctionProxy::wp_get_environment_type() ?? 'production', array( 'development', 'local' ), true ); } /** * Sanitize the request URI to only allow certain query parameters. * * @param string $request_uri The request URI to sanitize. * @return string The sanitized request URI. */ private function sanitize_request_uri( $request_uri ) { $default_whitelist = array( 'path', 'page', 'step', 'task', 'tab', 'section', 'status', 'post_type', 'taxonomy', 'action', ); /** * Filter to allow other plugins to whitelist request_uri query parameter values for unmasked remote logging. * * @since 9.4.0 * * @param string $default_whitelist The default whitelist of query parameters. */ $whitelist = apply_filters( 'woocommerce_remote_logger_request_uri_whitelist', $default_whitelist ); $parsed_url = SafeGlobalFunctionProxy::wp_parse_url( $request_uri ); if ( ! is_array( $parsed_url ) || ! isset( $parsed_url['query'] ) ) { return $request_uri; } parse_str( $parsed_url['query'], $query_params ); foreach ( $query_params as $key => &$value ) { if ( ! in_array( $key, $whitelist, true ) ) { $value = 'xxxxxx'; } } $parsed_url['query'] = http_build_query( $query_params ); return $this->build_url( $parsed_url ); } /** * Build a URL from its parsed components. * * @param array $parsed_url The parsed URL components. * @return string The built URL. */ private function build_url( $parsed_url ) { $path = $parsed_url['path'] ?? ''; $query = isset( $parsed_url['query'] ) ? "?{$parsed_url['query']}" : ''; $fragment = isset( $parsed_url['fragment'] ) ? "#{$parsed_url['fragment']}" : ''; return "$path$query$fragment"; } } StockNotifications/Config.php 0000777 00000011715 15251706115 0012316 0 ustar 00 <?php declare( strict_types = 1 ); namespace Automattic\WooCommerce\Internal\StockNotifications; use Automattic\WooCommerce\Enums\ProductType; use Automattic\WooCommerce\Enums\ProductStockStatus; use Automattic\WooCommerce\Enums\ProductStatus; /** * Configuration class for stock notifications. */ class Config { /** * Runtime cache for supported product types. * * @var array<string> */ private static $supported_product_types; /** * Runtime cache for supported product statuses. * * @var array<string> */ private static $supported_product_statuses; /** * Runtime cache for eligible stock statuses. * * @var array<string> */ private static $eligible_stock_statuses; /** * Runtime cache for verification expiration time threshold. * * @var int */ private static $verification_expiration_time_threshold; /** * Get the supported product types. * * @return array<string> */ public static function get_supported_product_types(): array { if ( is_array( self::$supported_product_types ) ) { return self::$supported_product_types; } /** * Filter: woocommerce_customer_stock_notifications_supported_product_types * * @since 10.2.0 * * @param array $product_types Product types. */ self::$supported_product_types = (array) apply_filters( 'woocommerce_customer_stock_notifications_supported_product_types', array( ProductType::SIMPLE, ProductType::VARIABLE, ProductType::VARIATION, ) ); return self::$supported_product_types; } /** * Get the supported product stock statuses. * * @return array<string> */ public static function get_supported_product_statuses(): array { if ( is_array( self::$supported_product_statuses ) ) { return self::$supported_product_statuses; } /** * Filter: woocommerce_customer_stock_notifications_supported_product_stock_statuses * * @since 10.2.0 * * @param array $product_stock_statuses Product stock statuses. */ self::$supported_product_statuses = (array) apply_filters( 'woocommerce_customer_stock_notifications_supported_product_stock_statuses', array( ProductStatus::PUBLISH, ) ); return self::$supported_product_statuses; } /** * Get the eligible stock statuses that trigger sending notifications. * * @return array<string> */ public static function get_eligible_stock_statuses(): array { if ( is_array( self::$eligible_stock_statuses ) ) { return self::$eligible_stock_statuses; } /** * Filter: woocommerce_customer_stock_notifications_supported_stock_statuses * * @since 10.2.0 * * @param array $stock_statuses Stock statuses. */ self::$eligible_stock_statuses = (array) apply_filters( 'woocommerce_customer_stock_notifications_supported_stock_statuses', array( ProductStockStatus::IN_STOCK, ProductStockStatus::ON_BACKORDER, ) ); return self::$eligible_stock_statuses; } /** * Get the metadata name for product-level signups. * * @return string */ public static function get_product_signups_meta_key(): string { return 'customer_stock_notifications_enable_signups'; } /** * Check if signups are allowed. * * @return bool */ public static function allows_signups(): bool { return 'yes' === get_option( 'woocommerce_customer_stock_notifications_allow_signups', 'no' ); } /** * Check if double opt-in is required. * * @return bool */ public static function requires_double_opt_in(): bool { return 'yes' === get_option( 'woocommerce_customer_stock_notifications_require_double_opt_in', 'no' ); } /** * Check if an account is required. * * @return bool */ public static function requires_account(): bool { return 'yes' === get_option( 'woocommerce_customer_stock_notifications_require_account', 'no' ); } /** * Check if an account is created on signup. * * @return bool */ public static function creates_account_on_signup(): bool { return 'yes' === get_option( 'woocommerce_customer_stock_notifications_create_account_on_signup', 'no' ); } /** * How long to keep pending notifications before deleting them (in days). * * @return int */ public static function get_unverified_deletion_days_threshold(): int { return absint( get_option( 'woocommerce_customer_stock_notifications_unverified_deletions_days_threshold', 0 ) ); } /** * Returns verification codes expiration time threshold (in seconds). * * @return int */ public static function get_verification_expiration_time_threshold(): int { if ( ! is_null( self::$verification_expiration_time_threshold ) ) { return self::$verification_expiration_time_threshold; } /** * Filter the verification codes expiration time (in seconds). * * @param int $threshold * @since 10.2.0 */ self::$verification_expiration_time_threshold = (int) apply_filters( 'woocommerce_customer_stock_notifications_verification_expiration_time_threshold', HOUR_IN_SECONDS ); return self::$verification_expiration_time_threshold; } } StockNotifications/StockSyncController.php 0000777 00000012512 15251706115 0015071 0 ustar 00 <?php declare( strict_types = 1 ); namespace Automattic\WooCommerce\Internal\StockNotifications; use Automattic\WooCommerce\Internal\StockNotifications\Utilities\EligibilityService; use Automattic\WooCommerce\Internal\StockNotifications\AsyncTasks\JobManager; use WC_Product; /** * The controller for the stock events. */ class StockSyncController { /** * The queue using product IDs as keys. * * @var array<int, bool> */ private array $queue = array(); /** * The eligibility service instance. * * @var EligibilityService */ private EligibilityService $eligibility_service; /** * The job manager instance. * * @var JobManager */ private JobManager $job_manager; /** * Logger instance. * * @var \WC_Logger_Interface */ protected $logger; /** * Init. * * @internal * * @param EligibilityService $eligibility_service The eligibility service instance. * @param JobManager $job_manager The job manager instance. */ final public function init( EligibilityService $eligibility_service, JobManager $job_manager ): void { $this->logger = \wc_get_logger(); $this->eligibility_service = $eligibility_service; $this->job_manager = $job_manager; } /** * Constructor. */ public function __construct() { // Event handlers. add_action( 'woocommerce_product_set_stock_status', array( $this, 'handle_product_stock_status_change' ), 100, 3 ); add_action( 'woocommerce_variation_set_stock_status', array( $this, 'handle_product_stock_status_change' ), 100, 3 ); // Process the queue on shutdown. add_action( 'shutdown', array( $this, 'process_queue' ) ); // Output the admin notice. add_action( 'admin_notices', array( $this, 'output_admin_notice' ) ); } /** * Handle product stock status changes. * * @param int $product_id The product ID. * @param string $stock_status The new stock status. * @param WC_Product|null $product The product object (optional). * @return void */ public function handle_product_stock_status_change( $product_id, $stock_status, $product = null ) { try { if ( ! $this->eligibility_service->is_stock_status_eligible( $stock_status ) ) { return; } if ( null === $product ) { $product = \wc_get_product( $product_id ); } if ( ! is_a( $product, 'WC_Product' ) ) { return; } if ( ! $this->eligibility_service->is_product_eligible( $product ) ) { return; } if ( ! $this->eligibility_service->has_active_notifications( $product ) ) { return; } // Add to queue. $target_product_ids = $this->eligibility_service->get_target_product_ids( $product ); foreach ( $target_product_ids as $target_product_id ) { $this->queue[ $target_product_id ] = true; } $this->store_admin_notice( $product->get_id() ); } catch ( \Throwable $e ) { $this->logger->error( sprintf( 'StockSyncController: Failed to process product %d: %s', $product_id, $e->getMessage() ), array( 'source' => 'wc-customer-stock-notifications' ) ); } } /** * Process the product IDs in the queue. * * Called on shutdown to schedule Action Scheduler jobs * for each product ID in the queue. * * @return void */ public function process_queue(): void { if ( empty( $this->queue ) || ! is_array( $this->queue ) ) { $this->queue = array(); return; } $product_ids = array_filter( array_keys( $this->queue ) ); if ( empty( $product_ids ) ) { return; } foreach ( $product_ids as $product_id ) { $this->job_manager->schedule_initial_job_for_product( $product_id ); } /** * Allows for additional processing of the product IDs after they have been queued. * * @since 10.2.0 * * @param array $product_ids The product IDs to process. */ do_action( 'woocommerce_customer_stock_notifications_product_sync', $product_ids ); $this->queue = array(); } /** * Store the admin notice. * * @param int $product_id The product ID to sync. * @return void */ private function store_admin_notice( $product_id ): void { if ( ! is_admin() || ! function_exists( 'wp_admin_notice' ) ) { return; } /* translators: 1 = URL of the Back in Stock Notifications page */ $notice_message = sprintf( __( 'Back-in-stock notifications for this product are now being processed. Subscribed customers will receive these emails over the next few minutes. You can monitor or manage individual subscriptions on the <a href="%s">Stock Notifications page</a>.', 'woocommerce' ), sprintf( admin_url( 'admin.php?page=wc-customer-stock-notifications&customer_stock_notifications_product_filter=%d&status=active_customer_stock_notifications&filter_action=Filter' ), $product_id ) ); update_option( 'wc_customer_stock_notifications_product_sync_notice', $notice_message ); } /** * Add admin notices. * * @return void */ public function output_admin_notice(): void { if ( ! function_exists( 'wp_admin_notice' ) ) { return; } $notice_message = get_option( 'wc_customer_stock_notifications_product_sync_notice' ); if ( empty( $notice_message ) ) { return; } \wp_admin_notice( $notice_message, array( 'type' => 'info', 'id' => 'woocommerce_customer_stock_notifications_product_sync_notice', 'dismissible' => false, ) ); delete_option( 'wc_customer_stock_notifications_product_sync_notice' ); } } StockNotifications/Emails/CustomerStockNotificationVerifyEmail.php 0000777 00000020473 15251706115 0021635 0 ustar 00 <?php declare( strict_types = 1 ); namespace Automattic\WooCommerce\Internal\StockNotifications\Emails; use Automattic\WooCommerce\Internal\StockNotifications\Config; use Automattic\WooCommerce\Internal\StockNotifications\Notification; use Automattic\WooCommerce\Internal\StockNotifications\Factory; use WC_Email; /** * Back in stock notification email class. */ class CustomerStockNotificationVerifyEmail extends WC_Email { /** * Constructor. */ public function __construct() { $this->id = 'customer_stock_notification_verify'; $this->customer_email = true; $this->title = __( 'Back in stock sign-up verification', 'woocommerce' ); $this->description = __( 'Verification e-mail sent to customers, as part of the double opt-in sign-up process.', 'woocommerce' ); $this->template_html = 'emails/customer-stock-notification-verify.php'; $this->template_plain = 'emails/plain/customer-stock-notification-verify.php'; $this->placeholders = array( '{product_name}' => '', '{site_title}' => '', ); add_action( 'woocommerce_email_stock_notification_verify_notification', array( $this, 'trigger' ), 10, 1 ); // Call parent constructor. parent::__construct(); } /** * Get email subject. * * @return string */ public function get_default_subject() { return __( 'Join the "{product_name}" waitlist.', 'woocommerce' ); } /** * Get email heading. * * @return string */ public function get_default_heading() { return __( 'Confirm sign-up', 'woocommerce' ); } /** * Get default email content. * * @return string */ public function get_default_intro_content() { return __( 'Please follow the link below to complete the sign-up process and join the "{product_name}" waitlist.', 'woocommerce' ); } /** * Default content to show below main email content. * * @return string */ public function get_default_additional_content() { return __( 'Thanks for shopping with us.', 'woocommerce' ); } /** * Get email content. * * @return string */ public function get_intro_content() { /** * Allows modifying the email introduction content. * * @since 10.2.0 * * @return string */ return apply_filters( 'woocommerce_email_stock_notification_intro_content', $this->format_string( $this->get_option_or_transient( 'intro_content', $this->get_default_intro_content() ) ), $this->object, $this ); } /** * Get content html. * * @return string */ public function get_content_html() { return wc_get_template_html( $this->template_html, array_merge( $this->get_additional_template_args(), array( 'notification' => $this->object, 'product' => $this->object->get_product(), 'email_heading' => $this->get_heading(), 'intro_content' => $this->get_intro_content(), 'additional_content' => $this->get_additional_content(), 'plain_text' => false, 'email' => $this, ), ), ); } /** * Get content plain. * * @return string */ public function get_content_plain() { return wc_get_template_html( $this->template_plain, array_merge( $this->get_additional_template_args(), array( 'notification' => $this->object, 'product' => $this->object->get_product(), 'email_heading' => $this->get_heading(), 'intro_content' => $this->get_intro_content(), 'additional_content' => $this->get_additional_content(), 'plain_text' => true, 'email' => $this, ), ), ); } /** * Get template args. * * @return array */ private function get_additional_template_args(): array { $notification = $this->object; $product = $notification->get_product(); /** * Filter the button text. * * @since 10.2.0 * * @param string $button_text The button text. * @param Notification $notification The notification object. * @param WC_Product $product The product object. */ $verification_button_text = apply_filters( 'woocommerce_email_stock_notification_verify_button_text', _x( 'Confirm', 'Stock Notification confirm notification', 'woocommerce' ), $notification, $product ); $verification_key = $notification->get_verification_key( true ); $expiration_threshold = Config::get_verification_expiration_time_threshold(); $expiration_threshold_text = sprintf( /* translators: %s is the time duration in minutes */ _n( '%s minute', '%s minutes', $expiration_threshold / 60, 'woocommerce' ), floor( $expiration_threshold / 60 ) ); return array( 'verification_button_text' => $verification_button_text, 'verification_expiration_threshold' => $expiration_threshold_text, 'verification_link' => add_query_arg( array( 'email_link_action_key' => $verification_key, 'notification_id' => $notification->get_id(), ), get_option( 'siteurl' ) ), ); } /** * Trigger the sending of this email. * * @param Notification|int $notification The notification object or ID. */ public function trigger( $notification ) { $this->setup_locale(); if ( is_numeric( $notification ) ) { $notification = Factory::get_notification( $notification ); } if ( ! $notification instanceof Notification ) { return; } $product = $notification->get_product(); if ( ! $product || ! is_a( $product, 'WC_Product' ) ) { return; } $this->maybe_setup_notification_locale( $notification ); $this->prepare_email( $notification ); if ( $this->is_enabled() && $this->get_recipient() ) { $this->send( $this->get_recipient(), $this->get_subject(), $this->get_content(), $this->get_headers(), $this->get_attachments() ); } $this->maybe_restore_notification_locale( $notification ); $this->restore_locale(); } /** * Prepares the email based on the notification data. * * @param Notification $notification Notification. * @return void */ public function prepare_email( Notification $notification ): void { $this->object = $notification; $this->recipient = $notification->get_user_email(); $product = $notification->get_product(); $this->placeholders['{product_name}'] = preg_replace( $this->plain_search, $this->plain_replace, $product->get_name() ); $this->placeholders['{site_title}'] = preg_replace( $this->plain_search, $this->plain_replace, $this->get_blogname() ); } /** * Setup notification locale if necessary based on notification meta. * * @param Notification $notification Notification object. */ private function maybe_setup_notification_locale( $notification ) { $customer_locale = $notification->get_meta( '_customer_locale' ); if ( ! empty( $customer_locale ) ) { switch_to_locale( $customer_locale ); } } /** * Restore locale if previously switched. * * @param Notification $notification Notification object. */ private function maybe_restore_notification_locale( $notification ) { $customer_locale = $notification->get_meta( '_customer_locale' ); if ( ! empty( $customer_locale ) ) { restore_previous_locale(); } } /** * Initialize Settings Form Fields. * * @return void */ public function init_form_fields() { parent::init_form_fields(); if ( ! is_array( $this->form_fields ) ) { return; } /* translators: %s: list of placeholders */ $placeholder_text = sprintf( __( 'Available placeholders: %s', 'woocommerce' ), '<code>' . esc_html( implode( '</code>, <code>', array_keys( $this->placeholders ) ) ) . '</code>' ); $intro_content_field = array( 'title' => __( 'Email content', 'woocommerce' ), 'description' => __( 'Text to appear below the main e-mail header.', 'woocommerce' ) . ' ' . $placeholder_text, 'css' => 'width: 400px; height: 75px;', 'placeholder' => $this->get_default_intro_content(), 'type' => 'textarea', 'desc_tip' => true, ); // Find `heading` key. $inject_index = array_search( 'heading', array_keys( $this->form_fields ), true ); if ( $inject_index ) { ++$inject_index; } else { $inject_index = 0; } // Inject. $this->form_fields = array_slice( $this->form_fields, 0, $inject_index, true ) + array( 'intro_content' => $intro_content_field ) + array_slice( $this->form_fields, $inject_index, count( $this->form_fields ) - $inject_index, true ); } } StockNotifications/Emails/EmailActionController.php 0000777 00000013604 15251706115 0016553 0 ustar 00 <?php declare( strict_types=1 ); namespace Automattic\WooCommerce\Internal\StockNotifications\Emails; use Automattic\WooCommerce\Internal\StockNotifications\Enums\NotificationCancellationSource; use Automattic\WooCommerce\Internal\StockNotifications\Enums\NotificationStatus; use Automattic\WooCommerce\Internal\StockNotifications\Factory; use Automattic\WooCommerce\Internal\StockNotifications\Notification; /** * Class EmailActionController * * Handles email actions such as verification and unsubscribe. * * @package Automattic\WooCommerce\Internal\StockNotifications\Emails */ class EmailActionController { /** * EmailActionController constructor. * * Initializes the controller by adding actions to process verification and unsubscribe actions from requests. */ public function __construct() { add_action( 'template_redirect', array( $this, 'maybe_process_email_action' ) ); } /** * This method checks if the request contains indicators to process an action from an email link. */ public function maybe_process_email_action(): void { // phpcs:ignore WordPress.Security.NonceVerification.Recommended if ( ! isset( $_GET['notification_id'] ) || ! isset( $_GET['email_link_action_key'] ) ) { return; } // phpcs:ignore WordPress.Security.NonceVerification.Recommended $notification_id = absint( wp_unslash( $_GET['notification_id'] ) ); // phpcs:ignore WordPress.Security.NonceVerification.Recommended $action_key = sanitize_text_field( wp_unslash( $_GET['email_link_action_key'] ) ); $this->validate_and_maybe_process_request( $notification_id, $action_key ); } /** * Checks request parameters and processes the notification based on the action key. * * @param int $notification_id The ID of the notification to process. * @param string $email_link_action_key The action key from the email link. * @return void */ public function validate_and_maybe_process_request( int $notification_id, string $email_link_action_key ): void { if ( empty( $email_link_action_key ) || empty( $notification_id ) ) { return; } $notification = $this->get_notification_to_be_processed( $notification_id ); if ( ! $notification ) { return; } $action_key = $notification->get_meta( 'email_link_action_key' ); if ( strpos( $action_key, ':' ) !== false ) { $this->process_verification_action( $notification, $email_link_action_key ); } else { $this->process_unsubscribe_action( $notification, $email_link_action_key ); } } /** * If the verification key matches, it updates the notification status to active. * * @param Notification $notification The notification to process. * @param string $action_key The action key to verify. * @return void */ private function process_verification_action( Notification $notification, string $action_key ): void { if ( $notification->check_verification_key( $action_key ) ) { $notification->set_status( NotificationStatus::ACTIVE ); $notification->set_date_confirmed( time() ); $notification->save(); // We need session for notices to work. if ( ! WC()->session->has_session() ) { // Generate a random customer ID. WC()->session->set_customer_session_cookie( true ); } $product = wc_get_product( $notification->get_product_id() ); /* translators: %s is product name */ $notice_text = sprintf( esc_html__( 'Successfully verified stock notifications for "%s".', 'woocommerce' ), $product->get_name() ); wc_add_notice( $notice_text ); /** * `woocommerce_customer_stock_notification_verified_redirect_url` filter. * * @since 10.2.0 * * @param string $url * @return string */ $url = apply_filters( 'woocommerce_customer_stock_notification_verified_redirect_url', get_permalink( wc_get_page_id( 'shop' ) ) ); wp_safe_redirect( $url ); } } /** * If the unsubscribe key matches, it updates the notification status to cancelled. * * @param Notification $notification The Notification to process. * @param string $action_key The action key to verify. * @return void */ private function process_unsubscribe_action( Notification $notification, string $action_key ): void { if ( $notification->check_unsubscribe_key( $action_key ) ) { $notification->set_status( NotificationStatus::CANCELLED ); $notification->set_cancellation_source( NotificationCancellationSource::USER ); $notification->set_date_cancelled( time() ); $notification->save(); // We need session for notices to work. if ( ! WC()->session->has_session() ) { // Generate a random customer ID. WC()->session->set_customer_session_cookie( true ); } $product = wc_get_product( $notification->get_product_id() ); /* translators: %2$s product name, %1$s user email */ $notice_text = sprintf( esc_html__( 'Successfully unsubscribed %1$s. You will not receive a notification when "%2$s" becomes available.', 'woocommerce' ), $notification->get_user_email(), $product->get_name() ); wc_add_notice( $notice_text ); /** * `woocommerce_customer_stock_notification_unsubscribe_redirect_url` filter. * * @since 10.2.0 * * @param string $url * @return string */ $url = apply_filters( 'woocommerce_customer_stock_notification_unsubscribe_redirect_url', get_permalink( wc_get_page_id( 'shop' ) ) ); wp_safe_redirect( $url ); } } /** * Retrieves the notification to be processed based on the provided notification ID and action key. * * @param int $notification_id The ID of the notification to process. * @return Notification|false The notification object if found and has an action key, null otherwise. */ private function get_notification_to_be_processed( int $notification_id ): ?Notification { $notification = Factory::get_notification( (int) $notification_id ); if ( ! $notification ) { return false; } if ( empty( $notification->get_meta( 'email_link_action_key' ) ) ) { return false; } return $notification; } } StockNotifications/Emails/EmailTemplatesController.php 0000777 00000007422 15251706115 0017275 0 ustar 00 <?php declare( strict_types = 1 ); namespace Automattic\WooCommerce\Internal\StockNotifications\Emails; use Automattic\WooCommerce\Internal\StockNotifications\Notification; /** * Email templates controller. */ class EmailTemplatesController { /** * Initialize the class. * * @internal * * @return void */ final public function init() { add_action( 'init', array( $this, 'register_template_hooks' ) ); } /** * Add template hooks. * * @internal */ public function register_template_hooks() { add_action( 'woocommerce_email_stock_notification_product', array( $this, 'email_product_image' ), 10, 3 ); add_action( 'woocommerce_email_stock_notification_product', array( $this, 'email_product_title' ), 20, 3 ); add_action( 'woocommerce_email_stock_notification_product', array( $this, 'email_product_attributes' ), 30, 3 ); add_action( 'woocommerce_email_stock_notification_product', array( $this, 'email_product_price' ), 40, 3 ); } /** * Email product image. * * @param WC_Product $product The product object. * @param Notification $notification The notification object. * @param bool $plain_text Whether the email is plain text. */ public function email_product_image( $product, $notification, $plain_text = false ) { if ( $plain_text ) { return; } $image = wp_get_attachment_image_src( $product->get_image_id(), 'woocommerce_thumbnail' ); $image_src = is_array( $image ) && isset( $image[0] ) ? $image[0] : ''; ob_start(); if ( $image_src ) { ?> <div id="notification__product__image"> <img src="<?php echo esc_attr( $image_src ); ?>" alt="<?php echo esc_attr( $product->get_title() ); ?>" width="220"/> </div> <?php } $html = ob_get_clean(); echo wp_kses_post( $html ); } /** * Email product title. * * @param WC_Product $product The product object. * @param Notification $notification The notification object. * @param bool $plain_text Whether the email is plain text. */ public function email_product_title( $product, $notification, $plain_text = false ) { if ( $plain_text ) { return; } ob_start(); ?> <div id="notification__product__title"><?php echo esc_html( $product->get_name() ); ?></div> <?php $html = ob_get_clean(); echo wp_kses_post( $html ); } /** * Email product attributes. * * @param WC_Product $product The product object. * @param Notification $notification The notification object. * @param bool $plain_text Whether the email is plain text. */ public function email_product_attributes( $product, $notification, $plain_text = false ) { if ( $plain_text ) { return; } $formatted_variation_list = $notification->get_product_formatted_variation_list( false ); if ( empty( $formatted_variation_list ) ) { return; } // Convert list to HTML table for better rendering. $formatted_variation_list = strtr( $formatted_variation_list, array( '<dl' => '<table', '<dd' => '<tr><th', '<dt' => '<tr><td', 'dl>' => 'table>', 'dd>' => 'th></tr>', 'dt>' => 'td></tr>', ) ); ob_start(); ?> <div id="notification__product__attributes"><?php echo wp_kses_post( $formatted_variation_list ); ?></div> <?php $html = ob_get_clean(); echo wp_kses_post( $html ); } /** * Email product price. * * @param WC_Product $product The product object. * @param Notification $notification The notification object. * @param bool $plain_text Whether the email is plain text. */ public function email_product_price( $product, $notification, $plain_text = false ) { if ( $plain_text ) { return; } ob_start(); ?> <div id="notification__product__price"><?php echo wp_kses_post( $product->get_price_html() ); ?></div> <?php $html = ob_get_clean(); echo wp_kses_post( $html ); } } StockNotifications/Emails/CustomerStockNotificationVerifiedEmail.php 0000777 00000016735 15251706115 0022134 0 ustar 00 <?php declare( strict_types = 1 ); namespace Automattic\WooCommerce\Internal\StockNotifications\Emails; use Automattic\WooCommerce\Internal\StockNotifications\Notification; use Automattic\WooCommerce\Internal\StockNotifications\Factory; use WC_Email; /** * Back in stock notification email class. */ class CustomerStockNotificationVerifiedEmail extends WC_Email { /** * Constructor. */ public function __construct() { $this->id = 'customer_stock_notification_verified'; $this->customer_email = true; $this->title = __( 'Back in stock sign-up confirmation', 'woocommerce' ); $this->description = __( 'Email sent to customers after completing the sign-up process successfully.', 'woocommerce' ); $this->template_html = 'emails/customer-stock-notification-verified.php'; $this->template_plain = 'emails/plain/customer-stock-notification-verified.php'; $this->placeholders = array( '{product_name}' => '', '{site_title}' => '', ); add_action( 'woocommerce_email_stock_notification_verified_notification', array( $this, 'trigger' ), 10, 1 ); // Call parent constructor. parent::__construct(); } /** * Get email subject. * * @return string */ public function get_default_subject() { return __( 'You have joined the "{product_name}" waitlist.', 'woocommerce' ); } /** * Get email heading. * * @return string */ public function get_default_heading() { return __( 'Sign-up successful', 'woocommerce' ); } /** * Get default email content. * * @return string */ public function get_default_intro_content() { return __( 'Thanks for joining the waitlist! You will hear from us again when "{product_name}" is back in stock.', 'woocommerce' ); } /** * Default content to show below main email content. * * @return string */ public function get_default_additional_content() { return __( 'Thanks for shopping with us.', 'woocommerce' ); } /** * Get email content. * * @return string */ public function get_intro_content() { /** * Allows modifying the email introduction content. * * @since 10.2.0 * * @return string */ return apply_filters( 'woocommerce_email_stock_notification_intro_content', $this->format_string( $this->get_option_or_transient( 'intro_content', $this->get_default_intro_content() ) ), $this->object, $this ); } /** * Get content html. * * @return string */ public function get_content_html() { return wc_get_template_html( $this->template_html, array_merge( $this->get_additional_template_args(), array( 'notification' => $this->object, 'product' => $this->object->get_product(), 'email_heading' => $this->get_heading(), 'intro_content' => $this->get_intro_content(), 'additional_content' => $this->get_additional_content(), 'plain_text' => false, 'email' => $this, ), ), ); } /** * Get content plain. * * @return string */ public function get_content_plain() { return wc_get_template_html( $this->template_plain, array_merge( $this->get_additional_template_args(), array( 'notification' => $this->object, 'product' => $this->object->get_product(), 'email_heading' => $this->get_heading(), 'intro_content' => $this->get_intro_content(), 'additional_content' => $this->get_additional_content(), 'plain_text' => true, 'email' => $this, ), ), ); } /** * Get template args. * * @return array */ private function get_additional_template_args(): array { $notification = $this->object; $unsubscribe_key = $notification->get_unsubscribe_key( true ); $user = get_user_by( 'email', $notification->get_user_email() ); $is_guest = ! is_a( $user, 'WP_User' ); return array( 'is_guest' => $is_guest, 'unsubscribe_link' => add_query_arg( array( 'email_link_action_key' => $unsubscribe_key, 'notification_id' => $notification->get_id(), ), get_option( 'siteurl' ) ), ); } /** * Trigger the sending of this email. * * @param Notification|int $notification The notification object or ID. */ public function trigger( $notification ) { $this->setup_locale(); if ( is_numeric( $notification ) ) { $notification = Factory::get_notification( $notification ); } if ( ! $notification instanceof Notification ) { return; } $product = $notification->get_product(); if ( ! $product || ! is_a( $product, 'WC_Product' ) ) { return; } $this->maybe_setup_notification_locale( $notification ); $this->prepare_email( $notification ); if ( $this->is_enabled() && $this->get_recipient() ) { $this->send( $this->get_recipient(), $this->get_subject(), $this->get_content(), $this->get_headers(), $this->get_attachments() ); } $this->maybe_restore_notification_locale( $notification ); $this->restore_locale(); } /** * Prepares the email based on the notification data. * * @param Notification $notification Notification. * @return void */ public function prepare_email( Notification $notification ): void { $this->object = $notification; $this->recipient = $notification->get_user_email(); $product = $notification->get_product(); $this->placeholders['{product_name}'] = preg_replace( $this->plain_search, $this->plain_replace, $product->get_name() ); $this->placeholders['{site_title}'] = preg_replace( $this->plain_search, $this->plain_replace, $this->get_blogname() ); } /** * Setup notification locale if necessary based on notification meta. * * @param Notification $notification Notification object. */ private function maybe_setup_notification_locale( $notification ) { $customer_locale = $notification->get_meta( '_customer_locale' ); if ( ! empty( $customer_locale ) ) { switch_to_locale( $customer_locale ); } } /** * Restore locale if previously switched. * * @param Notification $notification Notification object. */ private function maybe_restore_notification_locale( $notification ) { $customer_locale = $notification->get_meta( '_customer_locale' ); if ( ! empty( $customer_locale ) ) { restore_previous_locale(); } } /** * Initialize Settings Form Fields. * * @return void */ public function init_form_fields() { parent::init_form_fields(); if ( ! is_array( $this->form_fields ) ) { return; } /* translators: %s: list of placeholders */ $placeholder_text = sprintf( __( 'Available placeholders: %s', 'woocommerce' ), '<code>' . esc_html( implode( '</code>, <code>', array_keys( $this->placeholders ) ) ) . '</code>' ); $intro_content_field = array( 'title' => __( 'Email content', 'woocommerce' ), 'description' => __( 'Text to appear below the main e-mail header.', 'woocommerce' ) . ' ' . $placeholder_text, 'css' => 'width: 400px; height: 75px;', 'placeholder' => $this->get_default_intro_content(), 'type' => 'textarea', 'desc_tip' => true, ); // Find `heading` key. $inject_index = array_search( 'heading', array_keys( $this->form_fields ), true ); if ( $inject_index ) { ++$inject_index; } else { $inject_index = 0; } // Inject. $this->form_fields = array_slice( $this->form_fields, 0, $inject_index, true ) + array( 'intro_content' => $intro_content_field ) + array_slice( $this->form_fields, $inject_index, count( $this->form_fields ) - $inject_index, true ); } } StockNotifications/Emails/CustomerStockNotificationEmail.php 0000777 00000020472 15251706115 0020447 0 ustar 00 <?php declare( strict_types = 1 ); namespace Automattic\WooCommerce\Internal\StockNotifications\Emails; use Automattic\WooCommerce\Internal\StockNotifications\Notification; use Automattic\WooCommerce\Internal\StockNotifications\Factory; use WC_Email; /** * Back in stock notification email class. */ class CustomerStockNotificationEmail extends WC_Email { /** * Constructor. */ public function __construct() { $this->id = 'customer_stock_notification'; $this->customer_email = true; $this->title = __( 'Back in stock notification', 'woocommerce' ); $this->description = __( 'Email sent to signed-up customers when a product is back in stock.', 'woocommerce' ); $this->template_html = 'emails/customer-stock-notification.php'; $this->template_plain = 'emails/plain/customer-stock-notification.php'; $this->placeholders = array( '{product_name}' => '', '{site_title}' => '', ); // Call parent constructor. parent::__construct(); } /** * Get email subject. * * @return string */ public function get_default_subject() { return __( '"{product_name}" is back in stock!', 'woocommerce' ); } /** * Get email heading. * * @return string */ public function get_default_heading() { return __( 'It\'s back in stock!', 'woocommerce' ); } /** * Get default email content. * * @return string */ public function get_default_intro_content() { return __( 'Great news: "{product_name}" is now available for purchase.', 'woocommerce' ); } /** * Default content to show below main email content. * * @return string */ public function get_default_additional_content() { return __( 'Thanks for shopping with us.', 'woocommerce' ); } /** * Get email content. * * @return string */ public function get_intro_content() { /** * Allows modifying the email introduction content. * * @since 10.2.0 * * @return string */ return apply_filters( 'woocommerce_email_stock_notification_intro_content', $this->format_string( $this->get_option_or_transient( 'intro_content', $this->get_default_intro_content() ) ), $this->object, $this ); } /** * Get content html. * * @return string */ public function get_content_html() { return wc_get_template_html( $this->template_html, array_merge( $this->get_additional_template_args(), array( 'notification' => $this->object, 'product' => $this->object->get_product(), 'email_heading' => $this->get_heading(), 'intro_content' => $this->get_intro_content(), 'additional_content' => $this->get_additional_content(), 'plain_text' => false, 'email' => $this, ), ), ); } /** * Get content plain. * * @return string */ public function get_content_plain() { return wc_get_template_html( $this->template_plain, array_merge( $this->get_additional_template_args(), array( 'notification' => $this->object, 'product' => $this->object->get_product(), 'email_heading' => $this->get_heading(), 'intro_content' => $this->get_intro_content(), 'additional_content' => $this->get_additional_content(), 'plain_text' => true, 'email' => $this, ), ), ); } /** * Get template args. * * @return array */ private function get_additional_template_args(): array { $notification = $this->object; $product = $notification->get_product(); /** * Filter the button text. * * @since 10.2.0 * * @param string $button_text The button text. * @param Notification $notification The notification object. * @param WC_Product $product The product object. */ $button_text = apply_filters( 'woocommerce_email_stock_notification_button_text', _x( 'Shop Now', 'Email notification', 'woocommerce' ), $notification, $product ); $query_args = array( 'utm_source' => 'back-in-stock-notifications', 'utm_medium' => 'email', ); /** * Filter the button href. * * @since 10.2.0 * * @param string $button_href The button href. * @param Notification $notification The notification object. * @param WC_Product $product The product object. */ $button_link = apply_filters( 'woocommerce_email_stock_notification_button_link', add_query_arg( $query_args, $notification->get_product_permalink() ), $notification, $product ); $unsubscribe_key = $notification->get_unsubscribe_key( true ); $user = get_user_by( 'email', $notification->get_user_email() ); $is_guest = ! is_a( $user, 'WP_User' ); return array( 'button_text' => $button_text, 'button_link' => $button_link, 'unsubscribe_link' => add_query_arg( array( 'email_link_action_key' => $unsubscribe_key, 'notification_id' => $notification->get_id(), ), get_option( 'siteurl' ) ), 'is_guest' => $is_guest, ); } /** * Trigger the sending of this email. * * @param Notification|int $notification The notification object or ID. */ public function trigger( $notification ) { $this->setup_locale(); if ( is_numeric( $notification ) ) { $notification = Factory::get_notification( $notification ); } if ( ! $notification instanceof Notification ) { return; } $product = $notification->get_product(); if ( ! $product || ! is_a( $product, 'WC_Product' ) ) { return; } $this->maybe_setup_notification_locale( $notification ); $this->prepare_email( $notification ); if ( $this->is_enabled() && $this->get_recipient() ) { $this->send( $this->get_recipient(), $this->get_subject(), $this->get_content(), $this->get_headers(), $this->get_attachments() ); } $this->maybe_restore_notification_locale( $notification ); $this->restore_locale(); } /** * Prepares the email based on the notification data. * * @param Notification $notification Notification. * @return void */ public function prepare_email( Notification $notification ): void { $this->object = $notification; $this->recipient = $notification->get_user_email(); $product = $notification->get_product(); $this->placeholders['{product_name}'] = preg_replace( $this->plain_search, $this->plain_replace, $product->get_name() ); $this->placeholders['{site_title}'] = preg_replace( $this->plain_search, $this->plain_replace, $this->get_blogname() ); } /** * Setup notification locale if necessary based on notification meta. * * @param Notification $notification Notification object. */ private function maybe_setup_notification_locale( $notification ) { $customer_locale = $notification->get_meta( '_customer_locale' ); if ( ! empty( $customer_locale ) ) { switch_to_locale( $customer_locale ); } } /** * Restore locale if previously switched. * * @param Notification $notification Notification object. */ private function maybe_restore_notification_locale( $notification ) { $customer_locale = $notification->get_meta( '_customer_locale' ); if ( ! empty( $customer_locale ) ) { restore_previous_locale(); } } /** * Initialize Settings Form Fields. * * @return void */ public function init_form_fields() { parent::init_form_fields(); if ( ! is_array( $this->form_fields ) ) { return; } /* translators: %s: list of placeholders */ $placeholder_text = sprintf( __( 'Available placeholders: %s', 'woocommerce' ), '<code>' . esc_html( implode( '</code>, <code>', array_keys( $this->placeholders ) ) ) . '</code>' ); $intro_content_field = array( 'title' => __( 'Email content', 'woocommerce' ), 'description' => __( 'Text to appear below the main e-mail header.', 'woocommerce' ) . ' ' . $placeholder_text, 'css' => 'width: 400px; height: 75px;', 'placeholder' => $this->get_default_intro_content(), 'type' => 'textarea', 'desc_tip' => true, ); // Find `heading` key. $inject_index = array_search( 'heading', array_keys( $this->form_fields ), true ); if ( $inject_index ) { ++$inject_index; } else { $inject_index = 0; } // Inject. $this->form_fields = array_slice( $this->form_fields, 0, $inject_index, true ) + array( 'intro_content' => $intro_content_field ) + array_slice( $this->form_fields, $inject_index, count( $this->form_fields ) - $inject_index, true ); } } StockNotifications/Emails/EmailManager.php 0000777 00000020123 15251706115 0014636 0 ustar 00 <?php declare( strict_types = 1 ); namespace Automattic\WooCommerce\Internal\StockNotifications\Emails; use Automattic\WooCommerce\Internal\StockNotifications\Notification; use Automattic\WooCommerce\Internal\StockNotifications\Factory; use Automattic\WooCommerce\Internal\StockNotifications\Emails\CustomerStockNotificationEmail; use Automattic\WooCommerce\Internal\StockNotifications\Emails\CustomerStockNotificationVerifyEmail; use Automattic\WooCommerce\Internal\StockNotifications\Emails\CustomerStockNotificationVerifiedEmail; use Automattic\WooCommerce\Internal\StockNotifications\Emails\EmailTemplatesController; /** * Emails manager. */ class EmailManager { /** * List of all core email IDs. * * @var array */ public static $email_ids = array( 'customer_stock_notification', 'customer_stock_notification_verify', 'customer_stock_notification_verified', ); /** * Initialize the emails. * * @internal * * @return void */ final public function init() { // Setup email hooks & handlers. add_filter( 'woocommerce_email_classes', array( $this, 'email_classes' ) ); // Add "transactional" emails. add_action( 'woocommerce_email_actions', array( $this, 'add_transactional_emails' ) ); // Setup styles. add_filter( 'woocommerce_email_styles', array( $this, 'add_stylesheets' ), 10, 2 ); // Preview. add_filter( 'woocommerce_prepare_email_for_preview', array( $this, 'prepare_email_for_preview' ) ); add_filter( 'woocommerce_email_preview_email_content_setting_ids', array( $this, 'add_intro_content_to_preview_settings' ), 10, 2 ); // Restore customer's context while rendering the emails. add_action( 'woocommerce_email_stock_notification_product', array( $this, 'maybe_restore_customer_tax_location_data' ), 9 ); // Register email templates. $container = wc_get_container(); $container->get( EmailTemplatesController::class ); } /** * Registers custom emails classes. * * @param array $emails Array of email classes. * @return array */ public function email_classes( $emails ) { $emails['WC_Email_Customer_Stock_Notification'] = new CustomerStockNotificationEmail(); $emails['WC_Email_Customer_Stock_Notification_Verify'] = new CustomerStockNotificationVerifyEmail(); $emails['WC_Email_Customer_Stock_Notification_Verified'] = new CustomerStockNotificationVerifiedEmail(); return $emails; } /** * Adds transactional emails. * * Stock notifications are sent via a custom AS job. * Additionally, two transactional emails are dispatched during the signup and verification processes, * which need to be included in the actions array to support deferred email functionality. * * @hook woocommerce_defer_transactional_emails * * @param array $actions The list of actions. * @return array */ public function add_transactional_emails( $actions ) { if ( ! is_array( $actions ) ) { return $actions; } $actions[] = 'woocommerce_customer_stock_notification_verify'; $actions[] = 'woocommerce_customer_stock_notification_verified'; return $actions; } /** * Restore customer tax location data from notification's metadata * to display product prices in emails using the customer's tax location, if applicable. * * @param Notification $notification The notification object. * @return void */ public function maybe_restore_customer_tax_location_data( $notification ) { // No need if stores displaying price excluding tax. if ( 'incl' !== get_option( 'woocommerce_tax_display_shop' ) ) { return; } // Check if for some reason (e.g., 3PD), a WC_Customer is already assigned into the BG process's context. if ( ! empty( WC()->customer ) ) { return; } // Get the recorded customer data, if any. $location = $notification->get_meta( '_customer_location_data' ); if ( empty( $location ) || ! is_array( $location ) || 4 !== count( $location ) ) { return; } // Restore the tax location. add_filter( 'woocommerce_get_tax_location', function () use ( $location ) { return $location; } ); } /** * Prints CSS in the emails. * * @param string $css The CSS to print. * @param WC_Email $email (Optional) The email object. * @return string */ public function add_stylesheets( $css, $email = null ) { /** * `woocommerce_email_stock_notification_emails_to_style` filter. * * @since 10.2.0 * * @return array */ if ( ( is_null( $email ) || ! in_array( $email->id, (array) apply_filters( 'woocommerce_email_stock_notification_emails_to_style', self::$email_ids ), true ) ) ) { return $css; } // General text. $text = get_option( 'woocommerce_email_text_color' ); // Primary color. $base = get_option( 'woocommerce_email_base_color' ); /** * `woocommerce_email_stock_notification_base_text_color` filter. * * @since 10.2.0 * * @return string */ $base_text = (string) apply_filters( 'woocommerce_email_stock_notification_base_text_color', wc_light_or_dark( $base, '#202020', '#ffffff' ), $email ); ob_start(); ?> #header_wrapper h1 { line-height: 1em !important; } #notification__container { color: <?php echo esc_attr( $text ); ?> !important; padding: 20px 20px; text-align: center; font-family: "Helvetica Neue", Helvetica, Roboto, Arial, sans-serif; width: 100%; } #notification__into_content { margin-bottom: 48px; color: <?php echo esc_attr( $text ); ?> !important; } #notification__product__image { text-align: center; margin-bottom: 20px; width: 100%; } #notification__product__image img { margin-right: 0; width: 220px; } #notification__product__title { font-size: 16px; font-weight: bold; line-height: 130%; margin-bottom: 5px; color: <?php echo esc_attr( $text ); ?> !important; } #notification__product__attributes table { width: 100%; padding: 0; margin: 0; color: <?php echo esc_attr( $text ); ?> !important; } #notification__product__attributes th, #notification__product__attributes td { color: <?php echo esc_attr( $text ); ?> !important; padding: 4px !important; text-align: center; } #notification__product__price { margin-bottom: 20px; color: <?php echo esc_attr( $text ); ?> !important; } #notification__action_button { text-decoration: none; display: inline-block; background: <?php echo esc_attr( $base ); ?>; color: <?php echo esc_attr( $base_text ); ?> !important; border: 10px solid <?php echo esc_attr( $base ); ?>; } #notification__verification_expiration { font-size: 0.8em; margin-top: 20px; color: <?php echo esc_attr( $text ); ?>; } #notification__footer { text-align: center; margin-top: 20px; color: <?php echo esc_attr( $text ); ?>; } #notification__unsubscribe_link { color: <?php echo esc_attr( $text ); ?>; } #notification__product__price .screen-reader-text { display: none; } <?php $css .= ob_get_clean(); return $css; } /** * Register intro_content email fields to be watched by WooCommerce's live email preview. * * @param array $setting_ids The email content setting IDs. * @param string $email_id The email ID. * @return array */ public function add_intro_content_to_preview_settings( $setting_ids, $email_id ) { if ( in_array( $email_id, self::$email_ids, true ) ) { $setting_ids[] = "woocommerce_{$email_id}_intro_content"; } return $setting_ids; } /** * Prepares the email for preview. * * @param \WC_Email $email The email object being previewed. * @return \WC_Email */ public function prepare_email_for_preview( $email ) { if ( ! in_array( $email->id, self::$email_ids, true ) ) { return $email; } $notification = Factory::create_dummy_notification(); $email->prepare_email( $notification ); return $email; } /** * Send a stock notification email. * * @param Notification $notification The notification object. * @return void */ public function send_stock_notification_email( Notification $notification ) { $emails = WC()->mailer()->get_emails(); if ( isset( $emails['WC_Email_Customer_Stock_Notification'] ) ) { $emails['WC_Email_Customer_Stock_Notification']->trigger( $notification ); } } } StockNotifications/Privacy/PrivacyEraser.php 0000777 00000005271 15251706115 0015305 0 ustar 00 <?php declare( strict_types=1 ); namespace Automattic\WooCommerce\Internal\StockNotifications\Privacy; use Automattic\WooCommerce\Internal\StockNotifications\Enums\NotificationCancellationSource; use Automattic\WooCommerce\Internal\StockNotifications\Factory; use Automattic\WooCommerce\Internal\StockNotifications\Enums\NotificationStatus; use Automattic\WooCommerce\Internal\StockNotifications\NotificationQuery; /** * Privacy eraser for WooCommerce Customer Stock Notifications. * * This class handles the erasure of customer stock notification data for users * who request their personal data to be erased. */ class PrivacyEraser extends \WC_Abstract_Privacy { /** * Constructor. */ public function __construct() { parent::__construct(); add_action( 'init', array( $this, 'register_erasers_exporters' ) ); } /** * Register the eraser for stock notifications. */ public function register_erasers_exporters() { $this->add_eraser( 'woocommerce-customer-stock-notifications', __( 'WooCommerce Customer Stock Notifications', 'woocommerce' ), array( $this, 'erase_notification_data' ) ); } /** * Erase customer stock notification data for a given email address. * * This method anonymizes the user email and sets the status of the notifications to 'cancelled'. * * @param string $email_address The email address to erase data for. * * @return array Response containing the status of the operation and messages. */ public static function erase_notification_data( string $email_address ): array { $response = array( 'items_removed' => false, 'items_retained' => false, 'messages' => array(), 'done' => true, ); $notifications = NotificationQuery::get_notifications( array( 'user_email' => $email_address, ) ); foreach ( $notifications as $notification_id ) { $notification = Factory::get_notification( $notification_id ); $anonymous_email = wp_privacy_anonymize_data( 'email', $email_address ); $notification->set_user_email( $anonymous_email ); $notification->set_user_id( 0 ); $notification->set_status( NotificationStatus::CANCELLED ); $notification->set_cancellation_source( NotificationCancellationSource::USER ); $notification->set_date_cancelled( current_time( 'mysql' ) ); $notification->update_meta_data( '_anonymized', 'yes' ); $notification->update_meta_data( 'email_link_action_key', '' ); $notification->save(); $response['messages'][] = sprintf( /* translators: %d the numeric product ID */ __( 'Removed back-in-stock notification for product id: %d', 'woocommerce' ), $notification->get_product_id() ); $response['items_removed'] = true; } return $response; } } StockNotifications/Admin/MenusController.php 0000777 00000005663 15251706115 0015301 0 ustar 00 <?php declare( strict_types = 1 ); namespace Automattic\WooCommerce\Internal\StockNotifications\Admin; /** * Menus controller for Customer Stock Notifications. */ class MenusController { /** * Notifications page. * * @var NotificationsPage */ private $notifications_page; /** * Init. * * @internal * * @param NotificationsPage $notifications_page Notifications page. * @return void */ final public function init( NotificationsPage $notifications_page ): void { $this->notifications_page = $notifications_page; } /** * Constructor. */ public function __construct() { add_action( 'admin_menu', array( $this, 'add_menu' ), 10 ); add_filter( 'woocommerce_screen_ids', array( $this, 'add_screen_ids' ) ); add_filter( 'set-screen-option', array( $this, 'set_screen_option' ), 10, 3 ); } /** * Add Stock Notifications menu item. * * @return bool|void */ public function add_menu() { if ( ! current_user_can( 'manage_woocommerce' ) ) { return false; } $dashboard_page = add_submenu_page( 'woocommerce', __( 'Stock Notifications', 'woocommerce' ), __( 'Notifications', 'woocommerce' ), 'manage_woocommerce', 'wc-customer-stock-notifications', array( $this, 'notifications_page' ) ); add_action( "load-$dashboard_page", array( $this, 'add_screen_options' ) ); } /** * Add screen options support. * * @return void */ public function add_screen_options(): void { $screen = get_current_screen(); if ( ! $screen ) { return; } add_screen_option( 'per_page', array( 'label' => __( 'Notifications per page', 'woocommerce' ), 'default' => 10, 'option' => 'stock_notifications_per_page', ) ); } /** * Save screen options. * * @param int $status The status of the screen option. * @param string $option The option name. * @param int $value The value of the screen option. * * @return int */ public function set_screen_option( $status, $option, $value ): int { if ( 'stock_notifications_per_page' === $option ) { return (int) $value; } return $status; } /** * Displays the Notifications list table. */ public function notifications_page() { $action = isset( $_GET['notification_action'] ) ? sanitize_text_field( wp_unslash( $_GET['notification_action'] ) ) : ''; // phpcs:ignore WordPress.Security.NonceVerification.Recommended if ( ! in_array( $action, array( 'create', 'edit' ), true ) ) { $action = ''; } switch ( $action ) { case 'create': $this->notifications_page->create(); break; case 'edit': $this->notifications_page->edit(); break; default: $this->notifications_page->output(); break; } } /** * Add screen id to WooCommerce. * * @param array $screen_ids List of screen IDs. * @return array */ public static function add_screen_ids( $screen_ids ): array { $screen_ids[] = 'woocommerce_page_wc-customer-stock-notifications'; return $screen_ids; } } StockNotifications/Admin/NotificationsPage.php 0000777 00000005332 15251706115 0015545 0 ustar 00 <?php declare( strict_types = 1 ); namespace Automattic\WooCommerce\Internal\StockNotifications\Admin; use Automattic\WooCommerce\Internal\StockNotifications\Admin\ListTable; use Automattic\WooCommerce\Internal\StockNotifications\Admin\NotificationCreatePage; use Automattic\WooCommerce\Internal\StockNotifications\Admin\NotificationEditPage; /** * Notifications admin page for Customer Stock Notifications. */ class NotificationsPage { /** * Page URL. * * @const PAGE_URL */ const PAGE_URL = 'admin.php?page=wc-customer-stock-notifications'; /** * Notices option name. */ const ADMIN_NOTICE_OPTION_NAME = 'wc_customer_stock_notifications_admin_notice'; /** * Render page. */ public function output() { $table = wc_get_container()->get( ListTable::class ); $table->process_actions(); $this->output_admin_notice(); $table->prepare_items(); include __DIR__ . '/Templates/html-admin-notifications.php'; } /** * Create notification. */ public function create() { $create_page = new NotificationCreatePage(); $create_page->output(); $this->output_admin_notice(); } /** * Edit notification. */ public function edit() { $edit_page = new NotificationEditPage(); $edit_page->output(); $this->output_admin_notice(); } /** * Add a notice to the admin notices. * * @param string $message The notice message. * @param string $type The notice type (optional). * @return void */ public static function add_notice( $message, $type = 'info' ) { if ( empty( $message ) ) { return; } $notice_data = get_option( self::ADMIN_NOTICE_OPTION_NAME ); if ( false !== $notice_data ) { return; } if ( ! in_array( $type, array( 'error', 'warning', 'success', 'info' ), true ) ) { $type = 'info'; } $notice_data = array( 'message' => $message, 'type' => $type, ); update_option( self::ADMIN_NOTICE_OPTION_NAME, $notice_data ); } /** * Display admin notices. * * @return void */ public function output_admin_notice(): void { if ( ! function_exists( 'wp_admin_notice' ) ) { return; } $notice_data = get_option( self::ADMIN_NOTICE_OPTION_NAME ); if ( false === $notice_data ) { return; } // Check if invalid data. if ( empty( $notice_data ) || ! is_array( $notice_data ) || empty( $notice_data['message'] ) ) { delete_option( self::ADMIN_NOTICE_OPTION_NAME ); return; } $type = in_array( $notice_data['type'], array( 'error', 'warning', 'success', 'info' ), true ) ? $notice_data['type'] : 'info'; \wp_admin_notice( $notice_data['message'], array( 'type' => $type, 'id' => self::ADMIN_NOTICE_OPTION_NAME, 'dismissible' => false, ) ); delete_option( self::ADMIN_NOTICE_OPTION_NAME ); } } StockNotifications/Admin/SettingsController.php 0000777 00000022116 15251706115 0016002 0 ustar 00 <?php declare( strict_types = 1 ); namespace Automattic\WooCommerce\Internal\StockNotifications\Admin; use Automattic\WooCommerce\Internal\StockNotifications\Config; /** * Settings controller for Customer Stock Notifications. */ class SettingsController { /** * Constructor. */ public function __construct() { // Add a 'Customer stock notifications' section to Products settings. add_filter( 'woocommerce_get_sections_products', array( $this, 'add_customer_stock_notifications_section' ), 100, 1 ); // Add the Customer Stock Notifications settings. add_filter( 'woocommerce_get_settings_products', array( $this, 'add_customer_stock_notifications_settings' ), 100, 2 ); // Display admin notices about incompatible settings combinations. add_action( 'admin_notices', array( $this, 'output_admin_notices' ) ); // Display and save product-level stock notifications option. add_action( 'woocommerce_product_options_stock_status', array( $this, 'add_disable_stock_notifications_checkbox' ), 20 ); add_action( 'woocommerce_admin_process_product_object', array( $this, 'process_product_object' ) ); } /** * Add a 'Customer stock notifications' section to Products settings. * * @param array $sections Products settings sections. * @return array New Products settings sections. */ public function add_customer_stock_notifications_section( $sections ) { if ( ! is_array( $sections ) ) { return $sections; } $section_title = __( 'Customer stock notifications', 'woocommerce' ); // Add 'Customer stock notifications' section to the Products tab, after Inventory. $inventory_index = array_search( 'inventory', array_keys( $sections ), true ); if ( false !== $inventory_index ) { $sections = array_slice( $sections, 0, $inventory_index + 1, true ) + array( 'customer_stock_notifications' => $section_title ) + array_slice( $sections, $inventory_index + 1, null, true ); } else { $sections['customer_stock_notifications'] = $section_title; } return $sections; } /** * Add the Customer Stock Notifications settings. * * @param array $settings Original settings. * @param string $section_id Settings section identifier. * @return array New settings. */ public function add_customer_stock_notifications_settings( $settings, $section_id ) { if ( ! is_array( $settings ) ) { return $settings; } if ( 'customer_stock_notifications' !== $section_id ) { return $settings; } /** * Filter the Customer Stock Notifications settings. * * @since 10.2.0 * * @param array $default_customer_stock_notifications_settings The default Customer Stock Notifications settings. */ $stock_notification_settings = apply_filters( 'woocommerce_customer_stock_notifications_settings', array( array( 'title' => __( 'Customer stock notifications', 'woocommerce' ), 'type' => 'title', 'desc' => '', 'id' => 'product_customer_stock_notifications_options', ), array( 'title' => __( 'Allow sign-ups', 'woocommerce' ), 'desc' => __( 'Let customers sign up to be notified when products in your store are restocked.', 'woocommerce' ), 'id' => 'woocommerce_customer_stock_notifications_allow_signups', 'default' => 'no', 'type' => 'checkbox', ), array( 'title' => __( 'Require double opt-in to sign up', 'woocommerce' ), 'desc' => __( 'To complete the sign-up process, customers must follow a verification link sent to their e-mail after submitting the sign-up form.', 'woocommerce' ), 'id' => 'woocommerce_customer_stock_notifications_require_double_opt_in', 'default' => 'no', 'type' => 'checkbox', ), array( 'title' => __( 'Delete unverified notification sign-ups after (in days)', 'woocommerce' ), 'desc' => __( 'Controls how long the plugin will store unverified notification sign-ups in the database. Enter zero, or leave this field empty if you would like to store expired sign-up requests indefinitey.', 'woocommerce' ), 'id' => 'woocommerce_customer_stock_notifications_unverified_deletions_days_threshold', 'default' => Config::get_unverified_deletion_days_threshold(), 'type' => 'number', ), array( 'title' => __( 'Guest sign-up', 'woocommerce' ), 'desc' => __( 'Customers must be logged in to sign up for stock notifications.', 'woocommerce' ), 'id' => 'woocommerce_customer_stock_notifications_require_account', 'default' => 'no', 'type' => 'checkbox', 'desc_tip' => __( 'When enabled, guests will be redirected to a login page to complete the sign-up process.', 'woocommerce' ), 'checkboxgroup' => 'start', 'hide_if_checked' => 'option', ), array( 'desc' => __( 'Create an account when guests sign up for stock notifications.', 'woocommerce' ), 'id' => 'woocommerce_customer_stock_notifications_create_account_on_signup', 'default' => 'no', 'type' => 'checkbox', 'checkboxgroup' => 'end', 'hide_if_checked' => 'yes', 'autoload' => true, ), array( 'type' => 'sectionend', 'id' => 'product_customer_stock_notifications_options', ), ) ); $settings = array_merge( $settings, $stock_notification_settings ); return $settings; } /** * Display admin notices about incompatible settings combinations. * * @return void */ public function output_admin_notices() { // Only show notices on the Customer Stock Notifications settings page. $screen = get_current_screen(); if ( ! $screen || 'woocommerce_page_wc-settings' !== $screen->id || ! isset( $_GET['section'] ) || 'customer_stock_notifications' !== $_GET['section'] ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended return; } if ( 'no' === get_option( 'woocommerce_registration_generate_password', 'no' ) && 'yes' === get_option( 'woocommerce_customer_stock_notifications_create_account_on_signup', 'no' ) ) { wp_admin_notice( sprintf( /* translators: %s settings page link */ __( 'WooCommerce is currently <a href="%s">configured</a> to create new accounts without generating passwords automatically. Guests who sign up to receive stock notifications will need to reset their password before they can log into their new account.', 'woocommerce' ), esc_url( admin_url( 'admin.php?page=wc-settings&tab=account' ) ) ), array( 'id' => 'message', 'type' => 'warning', 'dismissible' => false, ) ); } if ( 'yes' === get_option( 'woocommerce_hide_out_of_stock_items' ) && Config::allows_signups() ) { wp_admin_notice( sprintf( /* translators: %s settings page link */ __( 'WooCommerce is currently <a href="%s">configured</a> to hide out-of-stock products from your catalog. Customers will not be able sign up for back-in-stock notifications while this option is enabled.', 'woocommerce' ), esc_url( admin_url( 'admin.php?page=wc-settings&tab=products§ion=inventory' ) ) ), array( 'id' => 'message', 'type' => 'warning', 'dismissible' => false, ) ); } } /** * Setting to allow admins disabling bis on product level. * * @return void */ public function add_disable_stock_notifications_checkbox() { if ( ! Config::allows_signups() ) { return; } global $product_object; if ( ! is_a( $product_object, 'WC_Product' ) ) { return; } $enable_signups = 'no' !== $product_object->get_meta( Config::get_product_signups_meta_key() ) ? 'yes' : 'no'; wp_nonce_field( 'woocommerce-customer-stock-notifications-edit-product', 'customer_stock_notifications_edit_product_security' ); woocommerce_wp_checkbox( array( 'id' => Config::get_product_signups_meta_key(), 'label' => __( 'Stock notifications', 'woocommerce' ), 'value' => $enable_signups, 'wrapper_class' => implode( ' ', array_map( function ( $type ) { return 'show_if_' . $type; }, Config::get_supported_product_types() ) ), 'description' => __( 'Let customers sign up to be notified when this product is restocked', 'woocommerce' ), ) ); } /** * Save product settings meta. * * @param WC_Product $product The product object. * @return void */ public static function process_product_object( $product ) { if ( ! Config::allows_signups() ) { return; } if ( ! is_a( $product, 'WC_Product' ) ) { return; } if ( ! $product->is_type( Config::get_supported_product_types() ) ) { return; } $posted_is_enabled = isset( $_POST[ Config::get_product_signups_meta_key() ] ); $current_value = $product->get_meta( Config::get_product_signups_meta_key() ); if ( ( $posted_is_enabled && 'no' === $current_value ) || ( ! $posted_is_enabled && 'yes' === $current_value ) ) { check_admin_referer( 'woocommerce-customer-stock-notifications-edit-product', 'customer_stock_notifications_edit_product_security' ); $product->update_meta_data( Config::get_product_signups_meta_key(), $posted_is_enabled ? 'yes' : 'no' ); } } } StockNotifications/Admin/NotificationEditPage.php 0000777 00000010723 15251706115 0016170 0 ustar 00 <?php declare( strict_types = 1 ); namespace Automattic\WooCommerce\Internal\StockNotifications\Admin; use Automattic\WooCommerce\Internal\StockNotifications\Notification; use Automattic\WooCommerce\Internal\StockNotifications\Enums\NotificationStatus; use Automattic\WooCommerce\Internal\StockNotifications\Admin\NotificationsPage; use Automattic\WooCommerce\Internal\StockNotifications\Factory; use Automattic\WooCommerce\Internal\StockNotifications\Emails\EmailManager; use Automattic\WooCommerce\Internal\StockNotifications\Admin\ListTable; use Automattic\WooCommerce\Internal\StockNotifications\Enums\NotificationCancellationSource; /** * Notification create page for Customer Stock Notifications. */ class NotificationEditPage { /** * Render page. */ public function output() { $table = new ListTable(); $notification_id = isset( $_GET['notification_id'] ) ? absint( wp_unslash( $_GET['notification_id'] ) ) : 0; // phpcs:ignore WordPress.Security.NonceVerification.Recommended if ( $notification_id ) { $notification = Factory::get_notification( $notification_id ); } if ( ! $notification instanceof Notification ) { $notice_message = __( 'Notification not found.', 'woocommerce' ); NotificationsPage::add_notice( $notice_message, 'error' ); wp_safe_redirect( admin_url( NotificationsPage::PAGE_URL ) ); exit; } $this->process_edit_form( $notification ); $table->process_delete_action(); $signed_up_customers = $table->data_store->query( array( 'product_id' => $notification->get_product_id(), 'return' => 'count', ) ); include __DIR__ . '/Templates/html-admin-notification-edit.php'; } /** * Update notification. * * @param Notification $notification The notification object. * @return void */ public function process_edit_form( Notification $notification ) { if ( empty( $_POST ) || empty( $_POST['wc_customer_stock_notification_action'] ) ) { return; } check_admin_referer( 'woocommerce-customer-stock-notification-edit', 'customer_stock_notification_edit_security' ); $action = wc_clean( wp_unslash( $_POST['wc_customer_stock_notification_action'] ) ); switch ( $action ) { case 'activate_notification': $notification->set_status( NotificationStatus::ACTIVE ); $result = $notification->save(); if ( is_wp_error( $result ) ) { $notice_message = $result->get_error_message(); NotificationsPage::add_notice( $notice_message, 'error' ); } else { $notice_message = __( 'Notification updated.', 'woocommerce' ); NotificationsPage::add_notice( $notice_message, 'success' ); } break; case 'cancel_notification': $notification->set_status( NotificationStatus::CANCELLED ); $notification->set_date_cancelled( time() ); $notification->set_date_notified( NotificationCancellationSource::ADMIN ); $result = $notification->save(); if ( is_wp_error( $result ) ) { $notice_message = $result->get_error_message(); NotificationsPage::add_notice( $notice_message, 'error' ); } else { $notice_message = __( 'Notification updated.', 'woocommerce' ); NotificationsPage::add_notice( $notice_message, 'success' ); } break; case 'send_notification': $product = $notification->get_product(); if ( ! $product || ! $product->is_in_stock() ) { $notice_message = __( 'Failed to send notification. Please make sure that the listed product is available.', 'woocommerce' ); NotificationsPage::add_notice( $notice_message, 'error' ); } else { $email_manager = new EmailManager(); $email_manager->send_stock_notification_email( $notification ); $notification->set_status( NotificationStatus::SENT ); $notification->set_date_notified( time() ); $notification->save(); // translators: %s user email. $notice_message = sprintf( __( 'Notification sent to "%s".', 'woocommerce' ), $notification->get_user_email() ); NotificationsPage::add_notice( $notice_message, 'success' ); } break; case 'send_verification_email': // translators: %s user email. $notice_message = sprintf( __( 'Verification email sent to "%s".', 'woocommerce' ), $notification->get_user_email() ); NotificationsPage::add_notice( $notice_message, 'success' ); break; } // Construct edit url. $edit_url = add_query_arg( array( 'notification_action' => 'edit', 'notification_id' => $notification->get_id(), ), NotificationsPage::PAGE_URL ); wp_safe_redirect( $edit_url ); exit; } } StockNotifications/Admin/AdminManager.php 0000777 00000004115 15251706115 0014460 0 ustar 00 <?php declare( strict_types = 1 ); namespace Automattic\WooCommerce\Internal\StockNotifications\Admin; use Automattic\WooCommerce\Internal\StockNotifications\Admin\MenusController; use Automattic\WooCommerce\Internal\StockNotifications\Admin\SettingsController; use Automattic\Jetpack\Constants; /** * Admin controller for Customer Stock Notifications. */ class AdminManager { /** * Initialize admin components. * * @internal * * @return void */ final public function __construct() { // Enqueue scripts. add_action( 'admin_enqueue_scripts', array( $this, 'admin_resources' ), 11 ); $container = wc_get_container(); $container->get( MenusController::class ); $container->get( SettingsController::class ); } /** * Admin scripts. * * @return void */ public static function admin_resources() { $screen = get_current_screen(); $screen_id = $screen ? $screen->id : ''; $suffix = Constants::is_true( 'SCRIPT_DEBUG' ) ? '' : '.min'; $version = Constants::get_constant( 'WC_VERSION' ); wp_register_script( 'wc-admin-customer-stock-notifications', WC()->plugin_url() . '/assets/js/admin/wc-customer-stock-notifications' . $suffix . '.js', array( 'jquery' ), $version, true ); $params = array( 'i18n_wc_delete_notification_warning' => __( 'Delete this notification permanently?', 'woocommerce' ), 'i18n_wc_bulk_delete_notifications_warning' => __( 'Delete the selected notifications permanently?', 'woocommerce' ), ); /* * Enqueue specific styles & scripts. */ if ( ! in_array( $screen_id, array( 'woocommerce_page_wc-customer-stock-notifications', 'woocommerce_page_wc-settings' ), true ) ) { return; } //phpcs:ignore WordPress.Security.NonceVerification.Recommended if ( 'woocommerce_page_wc-settings' === $screen_id && isset( $_GET['section'] ) && 'customer_stock_notifications' !== $_GET['section'] ) { return; } wp_enqueue_script( 'wc-admin-customer-stock-notifications' ); wp_localize_script( 'wc-admin-customer-stock-notifications', 'wc_admin_customer_stock_notifications_params', $params ); } } StockNotifications/Admin/NotificationCreatePage.php 0000777 00000007532 15251706115 0016512 0 ustar 00 <?php declare( strict_types = 1 ); namespace Automattic\WooCommerce\Internal\StockNotifications\Admin; use Automattic\WooCommerce\Internal\StockNotifications\Notification; use Automattic\WooCommerce\Internal\StockNotifications\Enums\NotificationStatus; use Automattic\WooCommerce\Internal\StockNotifications\Admin\NotificationsPage; /** * Notification create page for Customer Stock Notifications. */ class NotificationCreatePage { /** * Render page. */ public function output() { $this->process_create_form(); include __DIR__ . '/Templates/html-admin-notification-create.php'; } /** * Create and save notification. */ public function process_create_form() { if ( empty( $_POST ) ) { return; } check_admin_referer( 'woocommerce-customer-stock-notification-create', 'customer_stock_notification_create_security' ); if ( ! isset( $_POST['save'] ) ) { return; } if ( ! isset( $_POST['product_id'] ) || empty( $_POST['product_id'] ) ) { NotificationsPage::add_notice( __( 'Please select a product.', 'woocommerce' ), 'error' ); return; } if ( empty( $_POST['user_id'] ) && empty( $_POST['user_email'] ) ) { NotificationsPage::add_notice( __( 'Please select a customer.', 'woocommerce' ), 'error' ); return; } // Posted data. $posted_data = array(); $posted_data['product_id'] = absint( wp_unslash( $_POST['product_id'] ) ); if ( isset( $_POST['user_id'] ) && ! empty( $_POST['user_id'] ) ) { $posted_data['user_id'] = absint( wp_unslash( $_POST['user_id'] ) ); if ( 0 === $posted_data['user_id'] ) { NotificationsPage::add_notice( __( 'Please select a customer.', 'woocommerce' ), 'error' ); return; } $user = get_user_by( 'id', $posted_data['user_id'] ); $posted_data['user_email'] = is_a( $user, 'WP_User' ) ? $user->user_email : ''; } elseif ( isset( $_POST['user_email'] ) && ! empty( $_POST['user_email'] ) ) { $posted_data['user_email'] = sanitize_text_field( wp_unslash( $_POST['user_email'] ) ); if ( ! filter_var( $posted_data['user_email'], FILTER_VALIDATE_EMAIL ) ) { NotificationsPage::add_notice( __( 'Please enter a valid email address.', 'woocommerce' ), 'error' ); return; } $user = get_user_by( 'email', $posted_data['user_email'] ); $posted_data['user_id'] = is_a( $user, 'WP_User' ) ? $user->ID : 0; } // Check if a notification already exists for the same product and customer. $notification_ids = \WC_Data_Store::load( 'stock_notification' )->query( $posted_data ); if ( count( $notification_ids ) > 0 ) { $notice_message = sprintf( // translators: %s: notification edit url. __( 'A <a href="%s">notification</a> for the same product and customer already exists in your database.', 'woocommerce' ), admin_url( NotificationsPage::PAGE_URL . '¬ification_action=edit¬ification_id=' . $notification_ids[0] ) ); NotificationsPage::add_notice( $notice_message, 'error' ); return; } // Save notification. $notification = new Notification(); $notification->set_status( NotificationStatus::ACTIVE ); $notification->set_product_id( $posted_data['product_id'] ); $notification->set_user_id( $posted_data['user_id'] ); $notification->set_user_email( $posted_data['user_email'] ); $result = $notification->save(); if ( is_wp_error( $result ) ) { $notice_message = $result->get_error_message(); NotificationsPage::add_notice( $notice_message, 'error' ); return; } else { $notice_message = __( 'Notification created.', 'woocommerce' ); NotificationsPage::add_notice( $notice_message, 'success' ); // Construct edit url. $edit_url = add_query_arg( array( 'notification_action' => 'edit', 'notification_id' => $notification->get_id(), ), NotificationsPage::PAGE_URL ); wp_safe_redirect( $edit_url ); exit; } } } StockNotifications/Admin/ListTable.php 0000777 00000063614 15251706115 0014031 0 ustar 00 <?php declare( strict_types = 1 ); namespace Automattic\WooCommerce\Internal\StockNotifications\Admin; use Automattic\WooCommerce\Internal\DataStores\StockNotifications\StockNotificationsDataStore; use Automattic\WooCommerce\Internal\StockNotifications\Enums\NotificationStatus; use Automattic\WooCommerce\Internal\StockNotifications\Notification; use Automattic\WooCommerce\Internal\StockNotifications\Factory; use Automattic\WooCommerce\Internal\StockNotifications\Admin\NotificationsPage; use Automattic\WooCommerce\Internal\StockNotifications\Utilities\EligibilityService; /** * Notifications list table for Customer Stock Notifications. */ class ListTable extends \WP_List_Table { /** * Total view records. * * @var int */ public $total_items = 0; /** * Total active records. * * @var int */ public $total_active_items = 0; /** * Total pending records. * * @var int */ public $total_pending_items = 0; /** * Total cancelled records. * * @var int */ public $total_cancelled_items = 0; /** * Total sent records. * * @var int */ public $total_sent_items = 0; /** * Has stock notifications. * * @var bool */ public $has_stock_notifications = false; /** * Data store. * * @var StockNotificationsDataStore */ public $data_store; /** * Eligibility service. * * @var EligibilityService */ public $eligibility_service; /** * Init. * * @internal * * @param EligibilityService $eligibility_service Eligibility service. */ final public function init( EligibilityService $eligibility_service ) { $this->eligibility_service = $eligibility_service; } /** * Constructor. * * @return void */ public function __construct() { $this->data_store = \WC_Data_Store::load( 'stock_notification' ); $this->has_stock_notifications = $this->data_store->query( array( 'return' => 'count' ) ) > 0; parent::__construct( array( 'singular' => 'woocommerce_stock_notification', 'plural' => 'woocommerce_stock_notifications', ) ); } /** * Handles the checkbox column output. * * @param Notification $notification The notification object. * @return void */ public function column_cb( $notification ) { ?><label class="screen-reader-text" for="cb-select-<?php echo absint( $notification->get_id() ); ?>"> <?php /* translators: %s: Notification code */ printf( esc_html__( 'Select %s', 'woocommerce' ), esc_html( $notification->get_id() ) ); ?> </label> <input id="cb-select-<?php echo absint( $notification->get_id() ); ?>" type="checkbox" name="notification[]" value="<?php echo absint( $notification->get_id() ); ?>" /> <?php } /** * Handles the title column output. * * @param Notification $notification The notification object. * @return void */ public function column_id( $notification ) { $actions = array( 'edit' => sprintf( '<a href="' . admin_url( NotificationsPage::PAGE_URL . '¬ification_action=edit¬ification_id=%d' ) . '">%s</a>', $notification->get_id(), __( 'Edit', 'woocommerce' ) ), 'delete' => sprintf( '<a href="' . wp_nonce_url( admin_url( NotificationsPage::PAGE_URL . '¬ification_action=delete¬ification_id=%d' ), 'delete_customer_stock_notification' ) . '">%s</a>', $notification->get_id(), __( 'Delete', 'woocommerce' ) ), ); $title = $notification->get_id(); printf( '<a class="row-title" href="%s" aria-label="%s">#%s</a>%s', esc_url( admin_url( NotificationsPage::PAGE_URL . '¬ification_action=edit¬ification_id=' . $notification->get_id() ) ), /* translators: %s: Notification code */ sprintf( esc_attr__( '“%s” (Edit)', 'woocommerce' ), esc_attr( $title ) ), esc_html( $title ), wp_kses_post( $this->row_actions( $actions ) ) ); } /** * Handles the status column output. * * @param Notification $notification The notification object. * @return void */ public function column_status( $notification ) { if ( $notification->get_status() === NotificationStatus::PENDING ) { $status = 'cancelled'; $label = _x( 'Pending', 'stock notification status', 'woocommerce' ); } elseif ( $notification->get_status() === NotificationStatus::CANCELLED ) { $status = 'cancelled'; $label = _x( 'Cancelled', 'stock notification status', 'woocommerce' ); } elseif ( $notification->get_status() === NotificationStatus::SENT ) { $status = 'cancelled'; $label = _x( 'Sent', 'stock notification status', 'woocommerce' ); } else { $status = 'completed'; $label = _x( 'Active', 'stock notification status', 'woocommerce' ); } printf( '<mark class="order-status %s"><span>%s</span></mark>', esc_attr( sanitize_html_class( 'status-' . $status ) ), esc_html( $label ) ); } /** * Handles the redeemed user column output. * * @param Notification $notification The notification object. * @return void */ public function column_user( $notification ) { if ( $notification->get_user_id() ) { $user = get_user_by( 'id', $notification->get_user_id() ); } if ( isset( $user ) && $user ) { printf( '<a href="%s" target="_blank">%s</a>', esc_url( get_edit_user_link( $user->ID ) ), esc_html( $user->display_name ) ); } else { echo esc_html( $notification->get_user_email() ); } } /** * Handles the product column output. * * @param Notification $notification The notification object. * @return void */ public function column_product( $notification ) { $product = $notification->get_product(); if ( ! is_a( $product, 'WC_Product' ) ) { echo '—'; return; } $name = $product->get_name(); $formatted_variation_list = $this->get_product_formatted_variation_list( true ); if ( $formatted_variation_list ) { /* translators: product name, identifier */ $name .= '<span class="description">' . $formatted_variation_list . '</span>'; } echo wp_kses_post( sprintf( '<a target="_blank" href="' . admin_url( 'post.php?post=%d&action=edit' ) . '">%s</a>', $product->get_parent_id() ? absint( $product->get_parent_id() ) : absint( $product->get_id() ), $name ) ); } /** * Handles the product SKU output. * * @param Notification $notification The notification object. * @return void */ public function column_sku( $notification ) { $product = $notification->get_product(); $sku = false; if ( is_a( $product, 'WC_Product' ) ) { $sku = $product->get_sku(); } if ( $sku ) { echo wp_kses_post( $sku ); } else { echo '—'; } } /** * Handles the notification date column output. * * @param Notification $notification The notification object. * @return void */ public function column_date_created_gmt( $notification ) { $date_created = $notification->get_date_created(); if ( ! $date_created ) { $t_time = __( '—', 'woocommerce' ); $h_time = $t_time; } else { $date_created = $date_created->getTimestamp(); $t_time = date_i18n( _x( 'Y/m/d g:i:s a', 'list table date hover format', 'woocommerce' ), $date_created ); $h_time = date_i18n( wc_date_format(), $date_created ); } echo '<span title="' . esc_attr( $t_time ) . '">' . esc_html( $h_time ) . '</span>'; } /** * Message to be displayed when there are no items. * * @return void */ public function no_items() { ?> <p class="main"> <?php esc_html_e( 'No Notifications found', 'woocommerce' ); ?> </p> <?php } /** * Get a list of columns. The format is: * 'internal-name' => 'Title' */ public function get_columns() { $columns = array(); $columns['cb'] = '<input type="checkbox" />'; $columns['id'] = _x( 'Notification', 'column_name', 'woocommerce' ); $columns['status'] = _x( 'Status', 'column_name', 'woocommerce' ); $columns['user'] = _x( 'User/Email', 'column_name', 'woocommerce' ); $columns['product'] = _x( 'Product', 'column_name', 'woocommerce' ); $columns['sku'] = _x( 'SKU', 'column_name', 'woocommerce' ); $columns['date_created_gmt'] = _x( 'Signed Up', 'column_name', 'woocommerce' ); return $columns; } /** * Return sortable columns. * * @return array */ public function get_sortable_columns() { $sortable_columns = array( 'id' => array( 'id', true ), 'product' => array( 'product_id', true ), ); return $sortable_columns; } /** * Returns bulk actions. * * @return array */ protected function get_bulk_actions() { $actions = array(); $actions['enable'] = __( 'Activate', 'woocommerce' ); $actions['cancel'] = __( 'Cancel', 'woocommerce' ); $actions['delete'] = __( 'Delete permanently', 'woocommerce' ); return $actions; } /** * Query the DB and attach items. * * @return void */ public function prepare_items() { $per_page = (int) get_user_meta( get_current_user_id(), 'stock_notifications_per_page', true ); $per_page = $per_page > 0 ? $per_page : 10; // Table columns. $columns = $this->get_columns(); $hidden = array(); $sortable = $this->get_sortable_columns(); $this->_column_headers = array( $columns, $hidden, $sortable ); // Setup params. $paged = isset( $_REQUEST['paged'] ) ? max( 0, (int) wp_unslash( $_REQUEST['paged'] ) - 1 ) : 0; // phpcs:ignore WordPress.Security.NonceVerification.Recommended, WordPress.Security.ValidatedSanitizedInput.InputNotSanitized $orderby = ( isset( $_REQUEST['orderby'] ) && in_array( wp_unslash( $_REQUEST['orderby'] ), array_keys( $this->get_sortable_columns() ), true ) ) ? wc_clean( wp_unslash( $_REQUEST['orderby'] ) ) : 'id'; // phpcs:ignore WordPress.Security.NonceVerification.Recommended $order = ( isset( $_REQUEST['order'] ) && in_array( wp_unslash( $_REQUEST['order'] ), array( 'asc', 'desc' ), true ) ) ? wc_clean( wp_unslash( $_REQUEST['order'] ) ) : 'desc'; // phpcs:ignore WordPress.Security.NonceVerification.Recommended // Query args. $query_args = array( 'order_by' => array( $orderby => $order ), 'limit' => $per_page, 'offset' => $paged * $per_page, ); // Search. if ( isset( $_REQUEST['s'] ) && ! empty( $_REQUEST['s'] ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended $query_args['user_email'] = wc_clean( wp_unslash( $_REQUEST['s'] ) ); // phpcs:ignore WordPress.Security.NonceVerification.Recommended } // Views. if ( ! empty( $_REQUEST['status'] ) && 'active_customer_stock_notifications' === $_REQUEST['status'] ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended $query_args['status'] = NotificationStatus::ACTIVE; } elseif ( ! empty( $_REQUEST['status'] ) && 'sent_customer_stock_notifications' === $_REQUEST['status'] ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended $query_args['status'] = NotificationStatus::SENT; } elseif ( ! empty( $_REQUEST['status'] ) && 'cancelled_customer_stock_notifications' === $_REQUEST['status'] ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended $query_args['status'] = NotificationStatus::CANCELLED; } elseif ( ! empty( $_REQUEST['status'] ) && 'pending_customer_stock_notifications' === $_REQUEST['status'] ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended $query_args['status'] = NotificationStatus::PENDING; } // Filters. if ( ! empty( $_GET['m'] ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended $filter = absint( wp_unslash( $_GET['m'] ) ); // phpcs:ignore WordPress.Security.NonceVerification.Recommended $month = substr( (string) $filter, 4, 6 ); $year = substr( (string) $filter, 0, 4 ); // This will break at year 10.000 AC :). $start_timestamp = mktime( 0, 0, 0, (int) $month, 1, (int) $year ); $query_args['start_date'] = gmdate( 'Y-m-d H:i:s', $start_timestamp ); $end_timestamp = mktime( 0, 0, 0, (int) $month + 1, 1, (int) $year ); $query_args['end_date'] = gmdate( 'Y-m-d H:i:s', $end_timestamp ); } if ( ! empty( $_GET['customer_stock_notifications_product_filter'] ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended $filter = absint( wp_unslash( $_GET['customer_stock_notifications_product_filter'] ) ); // phpcs:ignore WordPress.Security.NonceVerification.Recommended $product = wc_get_product( $filter ); if ( $product instanceof \WC_Product ) { $target_ids = $this->eligibility_service->get_target_product_ids( $product ); $query_args['product_id'] = $target_ids; } else { NotificationsPage::add_notice( __( 'Invalid product selected.', 'woocommerce' ), 'error' ); } } if ( ! empty( $_GET['customer_stock_notifications_customer_filter'] ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended $filter = absint( wp_unslash( $_GET['customer_stock_notifications_customer_filter'] ) ); // phpcs:ignore WordPress.Security.NonceVerification.Recommended $query_args['user_id'] = array( $filter ); } $query_args['return'] = 'objects'; $this->items = $this->data_store->query( $query_args ); // Count total items. $query_args['return'] = 'count'; unset( $query_args['limit'] ); unset( $query_args['offset'] ); $this->total_items = $this->data_store->query( $query_args ); // Count active. $query_args['status'] = NotificationStatus::ACTIVE; $this->total_active_items = $this->data_store->query( $query_args ); // Count sent. $query_args['status'] = NotificationStatus::SENT; $this->total_sent_items = $this->data_store->query( $query_args ); // Count cancelled. $query_args['status'] = NotificationStatus::CANCELLED; $this->total_cancelled_items = $this->data_store->query( $query_args ); // Count pending. $query_args['status'] = NotificationStatus::PENDING; $this->total_pending_items = $this->data_store->query( $query_args ); // Configure pagination. $this->set_pagination_args( array( 'total_items' => $this->total_items, // Total items defined above. 'per_page' => $per_page, // Per page constant defined at top of method. 'total_pages' => ceil( $this->total_items / $per_page ), // Calculate pages count. ) ); } /** * Display table extra nav. * * @param string $which top|bottom. * @return void */ public function extra_tablenav( $which ) { if ( 'top' === $which && ! is_singular() ) { ?> <div class="alignleft actions"> <?php $this->render_filters(); submit_button( __( 'Filter', 'woocommerce' ), '', 'filter_action', false, array( 'id' => 'post-query-submit' ) ); ?> </div> <?php } } /** * Display table filters. * * @return void */ protected function render_filters() { $this->display_months_dropdown(); $this->display_customer_dropdown(); $this->display_product_dropdown(); } /** * Display product filter. * * @return void */ protected function display_product_dropdown() { $product_string = ''; $product_id = ''; if ( ! empty( $_GET['customer_stock_notifications_product_filter'] ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended $product_id = wc_clean( wp_unslash( $_GET['customer_stock_notifications_product_filter'] ) ); // phpcs:ignore WordPress.Security.NonceVerification.Recommended $product = wc_get_product( absint( $product_id ) ); if ( $product ) { $product_string = sprintf( /* translators: 1: product title 2: product ID */ esc_html__( '%1$s (#%2$s)', 'woocommerce' ), $product->get_parent_id() ? $product->get_name() : $product->get_title(), absint( $product->get_id() ) ); } } ?> <select class="wc-product-search" name="customer_stock_notifications_product_filter" data-placeholder="<?php esc_attr_e( 'Select product…', 'woocommerce' ); ?>" data-allow_clear="true" id="customer_stock_notifications_product_filter"> <?php if ( $product_string && $product_id ) { ?> <option value="<?php echo esc_attr( $product_id ); ?>" selected="selected"><?php echo wp_kses_post( htmlspecialchars( $product_string, ENT_COMPAT ) ); ?></option> <?php } ?> </select> <?php } /** * Display customer filter. * * @return void */ protected function display_customer_dropdown() { $user_string = ''; $user_id = ''; if ( ! empty( $_GET['customer_stock_notifications_customer_filter'] ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended $user_id = wc_clean( wp_unslash( $_GET['customer_stock_notifications_customer_filter'] ) ); // phpcs:ignore WordPress.Security.NonceVerification.Recommended $user = get_user_by( 'id', absint( $user_id ) ); if ( $user ) { $user_string = sprintf( /* translators: 1: user display name 2: user ID 3: user email */ esc_html__( '%1$s (#%2$s – %3$s)', 'woocommerce' ), $user->display_name, absint( $user->ID ), $user->user_email ); } } ?> <select class="wc-customer-search" name="customer_stock_notifications_customer_filter" data-placeholder="<?php esc_attr_e( 'Select customer…', 'woocommerce' ); ?>" data-allow_clear="true" id="customer_stock_notifications_customer_filter"> <?php if ( $user_string && $user_id ) { ?> <option value="<?php echo esc_attr( $user_id ); ?>" selected="selected"><?php echo wp_kses_post( htmlspecialchars( $user_string, ENT_COMPAT ) ); ?></option> <?php } ?> </select> <?php } /** * Items of the `subsubsub` status menu. * * @return array */ protected function get_views() { $status_links = array(); // All view. $class = ! empty( $_REQUEST['status'] ) && 'all_customer_stock_notifications' === $_REQUEST['status'] ? 'current' : ''; // phpcs:ignore WordPress.Security.NonceVerification.Recommended $all_inner_html = sprintf( /* translators: %s: Notifications count */ _nx( 'All <span class="count">(%s)</span>', 'All <span class="count">(%s)</span>', $this->total_items, 'notifications_status', 'woocommerce' ), number_format_i18n( $this->total_items ) ); $status_links['all'] = $this->get_link( array( 'status' => 'all_customer_stock_notifications' ), $all_inner_html, $class ); // Active view. $class = ! empty( $_REQUEST['status'] ) && 'active_customer_stock_notifications' === $_REQUEST['status'] ? 'current' : ''; // phpcs:ignore WordPress.Security.NonceVerification.Recommended $active_inner_html = sprintf( /* translators: %s: Notifications count */ _nx( 'Active <span class="count">(%s)</span>', 'Active <span class="count">(%s)</span>', $this->total_active_items, 'notifications_status', 'woocommerce' ), number_format_i18n( $this->total_active_items ) ); $status_links['active'] = $this->get_link( array( 'status' => 'active_customer_stock_notifications' ), $active_inner_html, $class ); // Sent view. $class = ! empty( $_REQUEST['status'] ) && 'sent_customer_stock_notifications' === $_REQUEST['status'] ? 'current' : ''; // phpcs:ignore WordPress.Security.NonceVerification.Recommended $sent_inner_html = sprintf( /* translators: %s: Notifications count */ _nx( 'Sent <span class="count">(%s)</span>', 'Sent <span class="count">(%s)</span>', $this->total_sent_items, 'notifications_status', 'woocommerce' ), number_format_i18n( $this->total_sent_items ) ); $status_links['sent'] = $this->get_link( array( 'status' => 'sent_customer_stock_notifications' ), $sent_inner_html, $class ); // Cancelled view. $class = ! empty( $_REQUEST['status'] ) && 'cancelled_customer_stock_notifications' === $_REQUEST['status'] ? 'current' : ''; // phpcs:ignore WordPress.Security.NonceVerification.Recommended $cancelled_inner_html = sprintf( /* translators: %s: Notifications count */ _nx( 'Cancelled <span class="count">(%s)</span>', 'Cancelled <span class="count">(%s)</span>', $this->total_cancelled_items, 'notifications_status', 'woocommerce' ), number_format_i18n( $this->total_cancelled_items ) ); $status_links['cancelled'] = $this->get_link( array( 'status' => 'cancelled_customer_stock_notifications' ), $cancelled_inner_html, $class ); // Pending view. $class = ! empty( $_REQUEST['status'] ) && 'pending_customer_stock_notifications' === $_REQUEST['status'] ? 'current' : ''; // phpcs:ignore WordPress.Security.NonceVerification.Recommended $pending_inner_html = sprintf( /* translators: %s: Notifications count */ _nx( 'Pending <span class="count">(%s)</span>', 'Pending <span class="count">(%s)</span>', $this->total_pending_items, 'notifications_status', 'woocommerce' ), number_format_i18n( $this->total_pending_items ) ); $status_links['pending'] = $this->get_link( array( 'status' => 'pending_customer_stock_notifications' ), $pending_inner_html, $class ); return $status_links; } /** * Construct a link string from args. * * @param array $args Arguments for the link. * @param string $label Link label. * @param string $css_class CSS class. * @return string */ protected function get_link( $args, $label, $css_class = '' ) { $url = add_query_arg( $args ); $class_html = ''; $aria_current = ''; if ( ! empty( $css_class ) ) { $class_html = sprintf( ' class="%s"', esc_attr( $css_class ) ); if ( 'current' === $css_class ) { $aria_current = ' aria-current="page"'; } } return sprintf( '<a href="%s"%s%s>%s</a>', esc_url( $url ), $class_html, $aria_current, $label ); } /** * Display dates dropdown filter. * * @return void */ protected function display_months_dropdown() { global $wp_locale; $months = $this->data_store->get_distinct_dates(); if ( ! is_array( $months ) ) { return; } $month_count = count( $months ); if ( $month_count < 1 ) { return; } $m = isset( $_GET['m'] ) ? (int) wp_unslash( $_GET['m'] ) : 0; // phpcs:ignore WordPress.Security.NonceVerification.Recommended, WordPress.Security.ValidatedSanitizedInput.InputNotSanitized ?> <label for="filter-by-date" class="screen-reader-text"><?php esc_html_e( 'Filter by date', 'woocommerce' ); ?></label> <select name="m" id="filter-by-date"> <option<?php selected( $m, 0 ); ?> value="0"><?php esc_html_e( 'All dates', 'woocommerce' ); ?></option> <?php foreach ( $months as $arc_row ) { if ( 0 === (int) $arc_row->year || 0 === (int) $arc_row->month ) { continue; } $month = zeroise( $arc_row->month, 2 ); $year = $arc_row->year; printf( "<option %s value='%s'>%s</option>\n", selected( $m, $year . $month, false ), esc_attr( $arc_row->year . $month ), /* translators: %1$s: month %2$s: year */ sprintf( esc_html__( '%1$s %2$d', 'woocommerce' ), esc_html( $wp_locale->get_month( $month ) ), esc_html( $year ) ) ); } ?> </select> <?php } /** * Process actions. */ public function process_actions(): void { $this->process_delete_action(); $this->process_bulk_action(); } /** * Process delete action. * * @return void */ public function process_delete_action(): void { $action = isset( $_GET['notification_action'] ) ? wc_clean( wp_unslash( $_GET['notification_action'] ) ) : ''; // phpcs:ignore WordPress.Security.NonceVerification.Recommended if ( 'delete' !== $action ) { return; } $notification_id = isset( $_GET['notification_id'] ) ? absint( $_GET['notification_id'] ) : 0; if ( ! $notification_id ) { return; } check_admin_referer( 'delete_customer_stock_notification' ); try { $notification = Factory::get_notification( $notification_id ); $this->data_store->delete( $notification ); $notice_message = __( 'Notification deleted.', 'woocommerce' ); NotificationsPage::add_notice( $notice_message, 'success' ); } catch ( \Exception $e ) { $notice_message = __( 'Notification not found.', 'woocommerce' ); NotificationsPage::add_notice( $notice_message, 'error' ); } wp_safe_redirect( admin_url( NotificationsPage::PAGE_URL ) ); exit(); } /** * Process bulk actions. * * @return void */ private function process_bulk_action() { if ( ! $this->current_action() ) { return; } check_admin_referer( 'bulk-' . $this->_args['plural'] ); $notifications = isset( $_GET['notification'] ) && is_array( $_GET['notification'] ) ? array_map( 'absint', $_GET['notification'] ) : array(); if ( empty( $notifications ) ) { return; } $redirect_url = NotificationsPage::PAGE_URL; if ( 'enable' === $this->current_action() ) { foreach ( $notifications as $id ) { $notification = Factory::get_notification( $id ); $notification->set_status( NotificationStatus::ACTIVE ); $this->data_store->update( $notification ); } $notice_message = sprintf( /* translators: %s: Notifications count */ _nx( '%s notification updated.', '%s notifications updated.', count( $notifications ), 'notifications_status', 'woocommerce' ), count( $notifications ) ); NotificationsPage::add_notice( $notice_message, 'success' ); } elseif ( 'cancel' === $this->current_action() ) { foreach ( $notifications as $id ) { $notification = Factory::get_notification( $id ); $notification->set_status( NotificationStatus::CANCELLED ); $this->data_store->update( $notification ); } $notice_message = sprintf( /* translators: %s: Notifications count */ _nx( '%s notification updated.', '%s notifications updated.', count( $notifications ), 'notifications_status', 'woocommerce' ), count( $notifications ) ); NotificationsPage::add_notice( $notice_message, 'success' ); } elseif ( 'delete' === $this->current_action() ) { foreach ( $notifications as $id ) { $notification = Factory::get_notification( $id ); $this->data_store->delete( $notification ); } $notice_message = sprintf( /* translators: %s: Notifications count */ _nx( '%s notification deleted.', '%s notifications deleted.', count( $notifications ), 'notifications_status', 'woocommerce' ), count( $notifications ) ); NotificationsPage::add_notice( $notice_message, 'success' ); } wp_safe_redirect( $redirect_url ); exit(); } } StockNotifications/Admin/Templates/html-product-data-admin.php 0000777 00000003061 15251706115 0020511 0 ustar 00 <?php /** * Admin View: Stock Notifications selected product * * @since 10.2.0 */ declare( strict_types = 1 ); if ( ! defined( 'ABSPATH' ) ) { exit; } $image = wp_get_attachment_image_src( $product->get_image_id(), 'woocommerce_thumbnail' ); $image_src = is_array( $image ) && isset( $image[0] ) ? $image[0] : ''; $stock_availability = $product->get_availability(); $identifier = '#' . $product->get_id(); if ( ! empty( $product->get_sku() ) ) { $identifier = $product->get_sku(); } ?> <img src="<?php echo esc_attr( $image_src ? $image_src : wc_placeholder_img_src() ); ?>" alt="<?php echo esc_attr( $product->get_name() ); ?>"> <div class="product-details"> <p class="product-details__title"> <?php echo esc_html( $product->get_name() ); ?> <span> <?php printf( '(%s)', esc_html( $identifier ) ); ?> </span> <a target="_blank" href="<?php echo esc_url( admin_url( sprintf( 'post.php?post=%d&action=edit', $product->get_parent_id() ? $product->get_parent_id() : $product->get_id() ) ) ); ?>"><span class="dashicons dashicons-external"></span></a> </p> <span class="product-details__price"> <?php echo wp_kses_post( $product->get_price_html( 'edit' ) ); ?> </span> <span class="product-details__stock-status <?php echo esc_attr( $stock_availability['class'] ); ?>"> <?php if ( empty( $stock_availability['availability'] ) && 'in-stock' === $stock_availability['class'] ) { echo esc_html__( 'In stock', 'woocommerce' ); } else { echo esc_html( $stock_availability['availability'] ); } ?> </span> </div> StockNotifications/Admin/Templates/html-admin-notification-create.php 0000777 00000013561 15251706115 0022057 0 ustar 00 <?php /** * Admin View: Notification create * * @since 10.2.0 */ declare( strict_types = 1 ); if ( ! defined( 'ABSPATH' ) ) { exit; } use Automattic\WooCommerce\Internal\StockNotifications\Admin\NotificationsPage; ?> <div class="wrap woocommerce-customer-stock-notifications"> <h1 class="wp-heading-inline"><?php esc_html_e( 'Add Notification', 'woocommerce' ); ?></h1> <a href="<?php echo esc_url( NotificationsPage::PAGE_URL ); ?>" class="page-title-action"><?php esc_html_e( 'View All', 'woocommerce' ); ?></a> <hr class="wp-header-end"> <form method="POST" id="edit-notification-form"> <?php wp_nonce_field( 'woocommerce-customer-stock-notification-create', 'customer_stock_notification_create_security' ); ?> <div id="poststuff"> <div id="post-body" class="columns-2"> <!-- SIDEBAR --> <div id="postbox-container-1" class="postbox-container"> <div id="woocommerce-order-actions" class="postbox"> <h2 class="hndle ui-sortable-handle"><span><?php esc_html_e( 'Notification actions', 'woocommerce' ); ?></span></h2> <div class="inside"> <ul class="order_actions submitbox"> <li class="wide" id="actions"> <select name="wc_customer_stock_notification_action" disabled="disabled"> <option value=""><?php esc_html_e( 'Choose an action...', 'woocommerce' ); ?></option> </select> <button class="button wc-reload" disabled="disabled"><span><?php esc_html_e( 'Apply', 'woocommerce' ); ?></span></button> </li> <li class="wide"> <button type="submit" class="button save_order button-primary" name="save" value="<?php esc_attr_e( 'Create', 'woocommerce' ); ?>"><?php esc_html_e( 'Create', 'woocommerce' ); ?></button> </li> </ul> </div> </div><!-- .postbox --> </div><!-- #container1 --> <!-- MAIN --> <div id="postbox-container-2" class="postbox-container"> <div id="notification-data" class="postbox notification-data notification-data--create"> <div class="notification-data__row notification-data__row--columns"> <div class="notification-data__header-column"> <h2 class="notification-data__header"> <?php esc_html_e( 'Notification details', 'woocommerce' ); ?> </h2> </div> </div><!-- #row --> <div class="notification-data__row notification-data__row--columns"> <div class="notification-data__form-field"> <label><?php esc_html_e( 'Customer', 'woocommerce' ); ?></label> <?php $user_string = ''; $user_id = 0; // phpcs:disable WordPress.Security.NonceVerification.Recommended if ( ! empty( $_REQUEST['user_id'] ) ) { $user_id = absint( wp_unslash( $_REQUEST['user_id'] ) ); if ( $user_id > 0 ) { $user = get_user_by( 'id', absint( $user_id ) ); if ( $user ) { $user_string = sprintf( /* translators: 1: user display name 2: user ID 3: user email */ esc_html__( '%1$s (#%2$s – %3$s)', 'woocommerce' ), $user->display_name, absint( $user->ID ), $user->user_email ); } } } $email = isset( $_REQUEST['user_email'] ) ? sanitize_text_field( wp_unslash( $_REQUEST['user_email'] ) ) : ''; // phpcs:ignore WordPress.Security.NonceVerification.Recommended ?> <select class="wc-customer-search" name="user_id" data-placeholder="<?php esc_attr_e( 'Search for a customer…', 'woocommerce' ); ?>" data-allow_clear="true"> <?php if ( $user_string && $user_id ) { ?> <option value="<?php echo esc_attr( $user_id ); ?>" selected="selected"><?php echo wp_kses_post( htmlspecialchars( $user_string, ENT_COMPAT ) ); ?><option> <?php } ?> </select> <div class="divider"></div> <span class="or_relation_label"><?php esc_html_e( '— or —', 'woocommerce' ); ?></span> <input type="email" class="or_relation_label__input" placeholder="<?php esc_html_e( 'Enter customer e-mail…', 'woocommerce' ); ?>" name="user_email" value="<?php echo esc_attr( $email ); ?>"/> <div class="wp-clearfix"></div> </div> <div class="notification-data__form-field"> <label><?php esc_html_e( 'Product', 'woocommerce' ); ?></label> <?php $product_string = ''; $product_id = 0; // phpcs:disable WordPress.Security.NonceVerification.Recommended if ( ! empty( $_REQUEST['product_id'] ) ) { $product_id = absint( wp_unslash( $_REQUEST['product_id'] ) ); if ( $product_id > 0 ) { $product = wc_get_product( $product_id ); if ( is_a( $product, 'WC_Product' ) ) { $product_string = sprintf( /* translators: 1: product title 2: product ID */ esc_html__( '%1$s (#%2$s)', 'woocommerce' ), $product->get_parent_id() ? $product->get_name() : $product->get_title(), absint( $product->get_id() ) ); } } } // phpcs:enable WordPress.Security.NonceVerification.Recommended $excluded_product_types = array_diff( array_keys( wc_get_product_types() ), array( 'simple', 'variable' ) ); ?> <select class="wc-product-search" name="product_id" data-action="woocommerce_json_search_products_and_variations" data-exclude_type="<?php echo esc_attr( implode( ',', $excluded_product_types ) ); ?>" data-display_stock="true"data-placeholder="<?php esc_attr_e( 'Select product…', 'woocommerce' ); ?>" data-allow_clear="true"> <?php if ( $product_string && $product_id ) { ?> <option value="<?php echo esc_attr( $product_id ); ?>" selected="selected"><?php echo wp_kses_post( htmlspecialchars( $product_string, ENT_COMPAT ) ); ?><option> <?php } ?> </select> </div> </div><!-- #row --> </div><!-- .postbox --> </div><!-- #container2 --> </div><!-- #post-body --> </div> </form> </div>