'read_private_shop_orders', 'product' => 'read_private_products', 'coupon' => 'read_private_shop_coupons', 'customer' => 'list_users', ); /** * Failures WooCommerce allows before it disables a webhook (`woocommerce_max_webhook_delivery_failures`). * * @since 1.0.0 * @return int */ public static function max_failures(): int { /** This filter is documented in woocommerce/includes/class-wc-webhook.php */ return max( 0, (int) apply_filters( 'woocommerce_max_webhook_delivery_failures', 5 ) ); // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound -- WooCommerce's own filter, read the same way WC_Webhook does. } /** * Webhook counts by status, straight from WooCommerce. * * @since 1.0.0 * @return array Status => count. */ public static function counts(): array { try { // The data store's own methods are reached through WC_Data_Store's __call(). $counts = (array) call_user_func( array( WC_Data_Store::load( 'webhook' ), 'get_count_webhooks_by_status' ) ); } catch ( \Exception $e ) { return array(); } return array_map( 'intval', $counts ); } /** * Webhooks as display rows, most urgent first: disabled, then failing, then the newest. One * query picks the IDs, so an old disabled webhook is never pushed off the list by newer ones. * * @since 1.0.0 * @param int $limit Most rows (1–LIST_MAX). * @return array> */ public static function rows( int $limit = self::LIST_MAX ): array { global $wpdb; $limit = max( 1, min( self::LIST_MAX, $limit ) ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- WooCommerce's webhook table, read on demand from the tool tab; WooCommerce's search can't sort by urgency. $ids = $wpdb->get_col( $wpdb->prepare( "SELECT webhook_id FROM %i ORDER BY ( status = 'disabled' ) DESC, ( failure_count > 0 ) DESC, webhook_id DESC LIMIT %d", $wpdb->prefix . 'wc_webhooks', $limit ) ); $webhooks = array(); foreach ( (array) $ids as $id ) { $webhook = self::get( (int) $id ); if ( $webhook ) { $webhooks[] = $webhook; } } $users = array_values( array_unique( array_filter( array_map( static fn( $w ) => (int) $w->get_user_id( 'edit' ), $webhooks ) ) ) ); if ( $users ) { cache_users( $users ); // One query for every owner check below. } return array_map( array( self::class, 'row' ), $webhooks ); } /** * A webhook, or null when it doesn't exist (WooCommerce throws for a missing ID). * * @since 1.0.0 * @param int $id Webhook ID. * @return WC_Webhook|null */ public static function get( int $id ) { if ( $id <= 0 ) { return null; } try { $webhook = new WC_Webhook( $id ); } catch ( \Exception $e ) { return null; } return $webhook->get_id() ? $webhook : null; } /** * One display row. The delivery URL is reduced to its host; the secret is never read. * * @since 1.0.0 * @param WC_Webhook $webhook Webhook. * @return array */ public static function row( WC_Webhook $webhook ): array { $status = (string) $webhook->get_status( 'edit' ); $failures = max( 0, (int) $webhook->get_failure_count( 'edit' ) ); $user_id = (int) $webhook->get_user_id( 'edit' ); $owner = self::owner_state( $user_id, (string) $webhook->get_topic( 'edit' ) ); $owner_ok = 'ok' === $owner; $pending = (bool) $webhook->get_pending_delivery( 'edit' ); $max = self::max_failures(); return array( 'id' => (int) $webhook->get_id(), 'name' => (string) $webhook->get_name( 'edit' ), 'status' => $status, 'topic' => (string) $webhook->get_topic( 'edit' ), 'host' => self::host( (string) $webhook->get_delivery_url() ), 'failures' => $failures, 'max' => $max, 'pending' => $pending, 'owner_ok' => $owner_ok, 'owner' => $owner, 'resource' => self::resource( (string) $webhook->get_topic( 'edit' ) ), 'health' => self::health( $status, $failures, $pending, $owner ), 'edit_url' => admin_url( 'admin.php?page=wc-settings&tab=advanced§ion=webhooks&edit-webhook=' . (int) $webhook->get_id() ), 'auto_disabled' => 'disabled' === $status && $failures > $max, ); } /** * Health of a webhook, most urgent state first: disabled; owner missing or owner can't read * the resource (WooCommerce builds payloads as the owner, so the payload is an error); * paused (no deliveries, so leftover failures don't count); failing; waiting (no successful * test ping yet); healthy. * * @since 1.0.0 * @param string $status WooCommerce status (active, paused, disabled). * @param int $failures Failures in a row. * @param bool $pending Test ping not answered with 200 yet. * @param string $owner Owner state from owner_state(): ok, missing or access. * @return string One of HEALTH. */ public static function health( string $status, int $failures, bool $pending, string $owner = 'ok' ): string { if ( 'disabled' === $status ) { return 'disabled'; } if ( 'missing' === $owner ) { return 'owner-missing'; } if ( 'access' === $owner ) { return 'owner-access'; } if ( 'active' !== $status ) { return 'paused'; } if ( $failures > 0 ) { return 'failing'; } return $pending ? 'waiting' : 'healthy'; } /** * The resource a topic is about (order, product, coupon, customer), or '' for action topics * and anything else. * * @since 1.0.0 * @param string $topic Topic, e.g. order.created. * @return string */ public static function resource( string $topic ): string { $resource = strtolower( (string) strtok( $topic, '.' ) ); return isset( self::RESOURCE_CAPS[ $resource ] ) ? $resource : ''; } /** * Whether the owner can build the payload: `missing` (no such user), `access` (not a member of * this site on multisite and not a super admin, or without the capability to read the * topic's resource) or `ok`. Action topics only need the user to exist. * * @since 1.0.0 * @param int $user_id Owner. * @param string $topic Topic. * @return string */ public static function owner_state( int $user_id, string $topic ): string { if ( $user_id <= 0 || false === get_userdata( $user_id ) ) { return 'missing'; } if ( is_multisite() && ! is_super_admin( $user_id ) && ! is_user_member_of_blog( $user_id ) ) { return 'access'; } $resource = self::resource( $topic ); if ( '' !== $resource && ! user_can( $user_id, self::RESOURCE_CAPS[ $resource ] ) ) { return 'access'; } return 'ok'; } /** * Host of a delivery URL (lowercase), or '' when there is none. * * @since 1.0.0 * @param string $url Delivery URL. * @return string */ public static function host( string $url ): string { $host = wp_parse_url( $url, PHP_URL_HOST ); return is_string( $host ) ? strtolower( $host ) : ''; } /** * Re-enable a disabled webhook: status active, failure count 0, saved through WooCommerce. * WooCommerce's data store sends its test ping on that save when the webhook hasn't had a * successful ping yet (`pending_delivery`); that ping's outcome is recorded and reported. * `pending_delivery` itself is never changed here. * * @since 1.0.0 * @param int $id Webhook ID. * @return array{webhook: WC_Webhook, pinged: bool, ping: array{code: int, error: string, ms: int}|null}|WP_Error */ public static function enable( int $id ) { $webhook = self::get( $id ); if ( ! $webhook ) { return self::missing(); } if ( 'disabled' !== $webhook->get_status( 'edit' ) ) { return new WP_Error( 'apwc_webhook_not_disabled', __( 'This webhook isn’t disabled. Nothing changed.', 'admin-powerups-for-woocommerce' ), array( 'status' => 409 ) ); } $pinged = (bool) $webhook->get_pending_delivery( 'edit' ); $listener = new PingListener( (string) $webhook->get_delivery_url() ); $listener->start(); try { // set_props(): WooCommerce documents set_failure_count()'s argument as bool, but it's a count. $webhook->set_props( array( 'status' => 'active', 'failure_count' => 0, ) ); $webhook->save(); } finally { $listener->stop(); } $fresh = self::get( $id ); /** * Fires after Webhook health re-enabled a disabled webhook. * * @since 1.0.0 * @param int $id Webhook ID. */ do_action( 'apwc_webhook_reenabled', $id ); return array( 'webhook' => $fresh ? $fresh : $webhook, 'pinged' => $pinged, 'ping' => $pinged ? $listener->result() : null, ); } /** * Send WooCommerce's test ping (`WC_Webhook::deliver_ping()`: POST `webhook_id=N` through * `wp_safe_remote_post()`, success only on HTTP 200, which also clears `pending_delivery`). * The failure count and the delivery log aren't touched. * * @since 1.0.0 * @param int $id Webhook ID. * @return array{webhook: WC_Webhook, ok: bool, code: int, ms: int, error: string}|WP_Error */ public static function test( int $id ) { $webhook = self::get( $id ); if ( ! $webhook ) { return self::missing(); } $url = (string) $webhook->get_delivery_url(); $listener = new PingListener( $url ); $listener->start(); try { $result = $webhook->deliver_ping(); } finally { $listener->stop(); } $seen = $listener->result(); $code = $seen ? (int) $seen['code'] : ( true === $result ? 200 : 0 ); $error = ''; if ( is_wp_error( $result ) ) { $error = '' !== ( $seen['error'] ?? '' ) ? (string) $seen['error'] : PingListener::redact( (string) $result->get_error_message(), $url ); } /** * Fires after Webhook health sent WooCommerce's test ping. * * @since 1.0.0 * @param int $id Webhook ID. * @param true|WP_Error $result True on HTTP 200, else a WP_Error whose message has the delivery URL reduced to its host. * @param int $code HTTP status code, 0 when the request failed. */ do_action( 'apwc_webhook_test_sent', $id, true === $result ? true : new WP_Error( is_wp_error( $result ) ? $result->get_error_code() : 'error', $error ), $code ); $fresh = self::get( $id ); return array( 'webhook' => $fresh ? $fresh : $webhook, 'ok' => true === $result, 'code' => $code, 'ms' => $seen ? (int) $seen['ms'] : 0, 'error' => $error, ); } /** * 404 for a webhook that doesn't exist (deleted since the page loaded). * * @since 1.0.0 * @return WP_Error */ private static function missing(): WP_Error { return new WP_Error( 'apwc_webhook_missing', __( 'This webhook doesn’t exist any more. Reload the page.', 'admin-powerups-for-woocommerce' ), array( 'status' => 404 ) ); } }