'emails', 'type' => 'textarea', 'label' => __( 'Emails and domains', 'admin-powerups-for-woocommerce' ), 'description' => __( 'One per line. An address (name@example.com) or a whole domain (example.com, also blocks its subdomains). Gmail dots and +tags are ignored.', 'admin-powerups-for-woocommerce' ), 'default' => '', ), array( 'id' => 'phones', 'type' => 'textarea', 'label' => __( 'Phone numbers', 'admin-powerups-for-woocommerce' ), 'description' => __( 'One per line. Spaces, dashes and country codes don’t matter.', 'admin-powerups-for-woocommerce' ), 'default' => '', ), array( 'id' => 'ips', 'type' => 'textarea', 'label' => __( 'IP addresses', 'admin-powerups-for-woocommerce' ), 'description' => __( 'One per line. A single address or a range (203.0.113.0/24).', 'admin-powerups-for-woocommerce' ), 'default' => '', 'warning' => fn() => $this->ip_warning(), 'warning_icon' => 'circle-alert', ), array( 'id' => 'users', 'type' => 'search', 'source' => 'users', 'label' => __( 'Customer accounts', 'admin-powerups-for-woocommerce' ), 'description' => __( 'Blocks the account when logged in, and orders placed with its email.', 'admin-powerups-for-woocommerce' ), 'placeholder' => __( 'Search customers…', 'admin-powerups-for-woocommerce' ), 'default' => array(), ), array( 'id' => 'message', 'type' => 'text', 'label' => __( 'Message shoppers see', 'admin-powerups-for-woocommerce' ), 'description' => __( 'Keep it neutral: it doesn’t say why.', 'admin-powerups-for-woocommerce' ), 'default' => __( 'Sorry, we can’t accept this order online. Please contact us.', 'admin-powerups-for-woocommerce' ), ), array( 'id' => 'log', 'type' => 'toggle', 'label' => __( 'Keep a list of blocked attempts', 'admin-powerups-for-woocommerce' ), /* translators: %d: number of days. */ 'description' => sprintf( __( 'Shows on the Blocked attempts tab. Entries are deleted after %d days.', 'admin-powerups-for-woocommerce' ), Log::DAYS ), 'default' => true, ), ); } /** * IP field warning: the general caution, plus any lines that aren't an address or range. * * @since 1.0.0 * @return string */ public function ip_warning() { $warning = __( 'IP addresses change and can be shared, so this can stop innocent shoppers. It checks the address connecting to your site. Behind a proxy or CDN like Cloudflare, that’s the proxy’s: a developer can pass the real one with the apwc_blocklist_ip filter.', 'admin-powerups-for-woocommerce' ); $bad = array_filter( Matcher::lines( $this->setting( 'ips' ) ), static fn( $line ) => null === Matcher::network( $line ) ); if ( $bad ) { $warning .= ' ' . sprintf( /* translators: %s: list of ignored lines. */ __( 'Ignored (not an address, or a range wider than /8 or /16 for IPv6): %s', 'admin-powerups-for-woocommerce' ), implode( ', ', array_slice( $bad, 0, 10 ) ) ); } return $warning; } /** * {@inheritDoc} */ public function views() { return array( 'attempts' => __( 'Blocked attempts', 'admin-powerups-for-woocommerce' ) ); } /** * {@inheritDoc} */ public function shortcut() { return 'attempts'; } /** * Render the Blocked attempts tab. * * @since 1.0.0 * @param string $view View ID. * @return void */ public function render_view( $view ) { $entries = Log::all(); include __DIR__ . '/views/attempts.php'; } /** * {@inheritDoc} */ public function data() { return array_merge( parent::data(), array( array( 'id' => 'attempts', 'kind' => 'content', 'label' => __( 'Blocked attempts', 'admin-powerups-for-woocommerce' ), 'description' => __( 'Who was stopped at checkout, with their email and IP address.', 'admin-powerups-for-woocommerce' ), 'count' => static fn() => count( Log::all() ), 'summary' => static fn( $n ) => $n /* translators: %s: number of attempts. */ ? sprintf( _n( '%s attempt', '%s attempts', $n, 'admin-powerups-for-woocommerce' ), number_format_i18n( $n ) ) : __( 'Nothing stored', 'admin-powerups-for-woocommerce' ), 'delete' => static function () { delete_option( Log::OPTION ); Lock::clear( Log::LOCK ); return 0; }, ), Cleanup::job_history( 'jobs', array( self::PRUNE_HOOK ) ), ) ); } /** * {@inheritDoc} */ public function activate() { if ( function_exists( 'as_has_scheduled_action' ) && ! as_has_scheduled_action( self::PRUNE_HOOK, array(), Jobs::GROUP ) ) { as_schedule_recurring_action( time() + DAY_IN_SECONDS, DAY_IN_SECONDS, self::PRUNE_HOOK, array(), Jobs::GROUP ); } } /** * {@inheritDoc} */ public function deactivate() { if ( function_exists( 'as_unschedule_all_actions' ) ) { as_unschedule_all_actions( self::PRUNE_HOOK, array(), Jobs::GROUP ); } } /** * {@inheritDoc} */ public function boot() { add_action( 'woocommerce_after_checkout_validation', array( $this, 'classic_checkout' ), 50, 2 ); add_action( 'woocommerce_store_api_checkout_update_order_from_request', array( $this, 'block_checkout' ), 50, 2 ); add_action( 'woocommerce_before_pay_action', array( $this, 'pay_for_order' ), 50 ); add_action( self::PRUNE_HOOK, array( Log::class, 'prune' ) ); } /** * Classic checkout: add the message as an error. * * @since 1.0.0 * @param array $data Posted checkout data. * @param WP_Error $errors Errors. * @return void */ public function classic_checkout( $data, $errors ) { $data = (array) $data; $hit = $this->check( array( 'email' => (string) ( $data['billing_email'] ?? '' ), 'phones' => array( (string) ( $data['billing_phone'] ?? '' ), (string) ( $data['shipping_phone'] ?? '' ) ), ) ); if ( $hit && $errors instanceof WP_Error ) { $errors->add( 'apwc_blocked', $this->message() ); } } /** * Block checkout (Store API): stop the request with the message. * * @since 1.0.0 * @param \WC_Order $order Draft order. * @param \WP_REST_Request $request Request. * @return void * @throws RouteException When blocked. */ public function block_checkout( $order, $request = null ) { if ( ! $order instanceof \WC_Order ) { return; } if ( $request instanceof \WP_REST_Request && 'POST' !== $request->get_method() ) { return; // Draft updates while the shopper types (PUT): only placing the order counts. } $hit = $this->check( array( 'email' => $order->get_billing_email(), 'phones' => array( $order->get_billing_phone(), $order->get_shipping_phone() ), ) ); if ( $hit ) { throw new RouteException( 'apwc_blocked', esc_html( $this->message() ), 403 ); } } /** * Classic "pay for order" page: same check as checkout. * * @since 1.0.0 * @param \WC_Order $order Order being paid. * @return void */ public function pay_for_order( $order ) { if ( ! $order instanceof \WC_Order ) { return; } $hit = $this->check( array( 'email' => $order->get_billing_email(), 'phones' => array( $order->get_billing_phone(), $order->get_shipping_phone() ), ) ); if ( $hit ) { wc_add_notice( esc_html( $this->message() ), 'error' ); } } /** * The message shoppers see. * * @since 1.0.0 * @return string */ public function message() { $message = trim( (string) $this->setting( 'message' ) ); return '' !== $message ? $message : (string) $this->defaults()['message']; } /** * The shopper's IP address: the one connecting to the server (REMOTE_ADDR). Forwarded headers * (X-Forwarded-For, X-Real-IP) are ignored because anyone can send them. * * @since 1.0.0 * @return string '' when unknown (no valid REMOTE_ADDR and no valid filtered IP). */ public function client_ip() { $remote = isset( $_SERVER['REMOTE_ADDR'] ) ? filter_var( wp_unslash( $_SERVER['REMOTE_ADDR'] ), FILTER_VALIDATE_IP ) : ''; $remote = is_string( $remote ) ? $remote : ''; /** * Filter the IP address the blocklist checks and logs. It defaults to the address * connecting to the server (REMOTE_ADDR), since forwarded headers can be faked. Behind a * trusted proxy or CDN (e.g. Cloudflare), return the real client IP here. A value that * isn't one valid IP address (e.g. a whole "1.2.3.4, 5.6.7.8" header) is ignored and * REMOTE_ADDR is used, so a faulty filter never turns IP blocking off. * * @since 1.0.0 * @param string $ip IP address, '' when unknown. */ $ip = apply_filters( 'apwc_blocklist_ip', $remote ); $ip = is_string( $ip ) ? trim( $ip ) : ''; return false !== filter_var( $ip, FILTER_VALIDATE_IP ) ? $ip : $remote; } /** * Check an attempt against the lists; logs a match. * * @since 1.0.0 * @param array $attempt [ email, phones[], ip?, user? ]. * @return array|null The match ( type, match ) or null. */ public function check( array $attempt ) { $attempt = array_merge( array( 'email' => '', 'phones' => array(), 'ip' => $this->client_ip(), 'user' => get_current_user_id(), ), $attempt ); $hit = $this->find( $attempt ); /** * Filter whether a checkout attempt is blocked. Return a [ type, match ] array to block * (e.g. from your own list), or null to let it through. * * @since 1.0.0 * @param array|null $hit The blocklist match, or null. * @param array $attempt email, phones, ip, user. */ $hit = apply_filters( 'apwc_blocklist_match', $hit, $attempt ); if ( ! is_array( $hit ) ) { return null; } if ( $this->setting( 'log' ) ) { Log::add( array( 'type' => $hit['type'] ?? '', 'match' => $hit['match'] ?? '', 'email' => $attempt['email'], 'ip' => $attempt['ip'], 'user' => $attempt['user'], ) ); } /** * Fires when the blocklist stops a checkout. * * @since 1.0.0 * @param array $hit type (email|phone|ip|account), match. * @param array $attempt email, phones, ip, user. */ do_action( 'apwc_blocklist_blocked', $hit, $attempt ); return $hit; } /** * The first list entry an attempt matches. * * @param array $attempt Attempt. * @return array|null */ private function find( array $attempt ) { $users = array_map( 'intval', (array) $this->setting( 'users' ) ); if ( $attempt['user'] && in_array( (int) $attempt['user'], $users, true ) ) { return array( 'type' => 'account', 'match' => (string) $attempt['user'], ); } if ( '' !== $attempt['email'] ) { $match = Matcher::email( $attempt['email'], Matcher::lines( $this->setting( 'emails' ) ) ); if ( '' !== $match ) { return array( 'type' => 'email', 'match' => $match, ); } if ( $users ) { foreach ( $users as $id ) { $user = get_userdata( $id ); if ( $user && '' !== Matcher::email( $attempt['email'], array( $user->user_email ) ) ) { return array( 'type' => 'account', 'match' => (string) $id, ); } } } } $phones = Matcher::lines( $this->setting( 'phones' ) ); foreach ( (array) $attempt['phones'] as $phone ) { $match = $phones ? Matcher::phone( (string) $phone, $phones ) : ''; if ( '' !== $match ) { return array( 'type' => 'phone', 'match' => $match, ); } } $match = '' !== $attempt['ip'] ? Matcher::ip( $attempt['ip'], Matcher::lines( $this->setting( 'ips' ) ) ) : ''; return '' !== $match ? array( 'type' => 'ip', 'match' => $match, ) : null; } }