prepare(). // phpcs:disable WordPress.DB.DirectDatabaseQuery, WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter const ORDERPING_DISCONNECT_PATH = '/wp-json/orderping/v1/disconnect'; const ORDERPING_DISCONNECT_TIMEOUT = 4; const ORDERPING_OPTION_PENDING_REVOKE = 'orderping_pending_revoke'; const ORDERPING_REVOKE_HOOK = 'orderping_retry_revoke'; /** * Sends a signed disconnect to the backend. Never throws; returns the HTTP status * (0 when the backend was not reached). * * @param string $reason button, deactivated or uninstalled. * @param array|null $conn Connection to revoke; defaults to the current one. * @return int */ function orderping_send_disconnect( $reason, $conn = null ) { $conn = null === $conn ? orderping_connection() : $conn; if ( null === $conn ) { return 0; } $body = wp_json_encode( array( 'type' => 'disconnect', 'event_id' => bin2hex( random_bytes( 8 ) ), 'contract_version' => ORDERPING_CONTRACT_VERSION, 'reason' => $reason, ) ); $path = '/woo/disconnect'; $timestamp = time(); $nonce = bin2hex( random_bytes( 16 ) ); $response = wp_remote_post( orderping_backend_url() . $path, array( 'timeout' => ORDERPING_DISCONNECT_TIMEOUT, 'redirection' => 0, 'headers' => array( 'Content-Type' => 'application/json', 'X-OP-Connection' => $conn['id'], 'X-OP-Timestamp' => (string) $timestamp, 'X-OP-Nonce' => $nonce, 'X-OP-Signature' => orderping_sign( $conn['secret'], 'site-to-cp', 'POST', $path, $timestamp, $nonce, $body ), ), 'body' => $body, ) ); return is_wp_error( $response ) ? 0 : (int) wp_remote_retrieve_response_code( $response ); } /** * Whether the backend has no more need of the notification: acknowledged, or * rejected as a client error (the secret is already invalid there). Timeouts, * rate limits and server errors are worth retrying. * * @param int $status HTTP status, 0 for transport failure. * @return bool */ function orderping_revoke_settled( $status ) { return 200 === $status || ( $status >= 400 && $status < 500 && 408 !== $status && 429 !== $status ); } /** * Action Scheduler callback: retries an unacknowledged disconnect with the kept * secret, backing off (capped at the last backoff step), until settled. */ function orderping_retry_revoke() { $pending = get_option( ORDERPING_OPTION_PENDING_REVOKE ); if ( ! is_array( $pending ) || empty( $pending['id'] ) || empty( $pending['secret'] ) ) { delete_option( ORDERPING_OPTION_PENDING_REVOKE ); return; } $status = orderping_send_disconnect( 'button', array( 'id' => (string) $pending['id'], 'secret' => (string) $pending['secret'] ) ); $attempts = (int) ( $pending['attempts'] ?? 0 ) + 1; if ( orderping_revoke_settled( $status ) ) { delete_option( ORDERPING_OPTION_PENDING_REVOKE ); return; } $pending['attempts'] = $attempts; update_option( ORDERPING_OPTION_PENDING_REVOKE, $pending, false ); orderping_schedule_revoke_retry( time() + orderping_backoff_seconds( $attempts ) ); } /** * Schedules one revoke retry. * * @param int $at Unix time. */ function orderping_schedule_revoke_retry( $at ) { // unique = true is right here: the revoke is global (no args), so one pending retry at most. if ( function_exists( 'as_schedule_single_action' ) ) { as_schedule_single_action( $at, ORDERPING_REVOKE_HOOK, array(), ORDERPING_AS_GROUP, true ); return; } wp_schedule_single_event( $at, ORDERPING_REVOKE_HOOK ); } /** * Drops queued work so nothing from before a disconnect is ever sent: unsent * outbox rows and pending Action Scheduler / cron actions. */ function orderping_discard_queued_work() { global $wpdb; $table = orderping_outbox_table(); // phpcs:ignore WordPress.DB.DirectDatabaseQuery, WordPress.DB.PreparedSQL.InterpolatedNotPrepared $wpdb->query( "DELETE FROM {$table} WHERE status IN ( 'pending', 'sending' )" ); foreach ( array( ORDERPING_DELIVER_HOOK, ORDERPING_UPDATE_HOOK ) as $hook ) { if ( function_exists( 'as_unschedule_all_actions' ) ) { as_unschedule_all_actions( $hook, array(), ORDERPING_AS_GROUP ); } wp_unschedule_hook( $hook ); } } /** * Forgets the pairing locally: secret, epoch, any half-finished pairing and * queued work. The instance ID is kept so a reconnect from this site maps to * the same backend record. */ function orderping_forget_connection() { delete_option( ORDERPING_OPTION_CONNECTION ); delete_option( ORDERPING_OPTION_PAIRING ); delete_option( ORDERPING_OPTION_ACCOUNT_EMAIL ); orderping_discard_queued_work(); } /** * Notifies the backend and stops sending locally regardless of the outcome. When * the backend was not reached the secret is kept for retries instead of dropped. * * @param string $reason button, deactivated or uninstalled. * @return bool Whether the backend was told (the revoke is settled). */ function orderping_disconnect( $reason ) { $conn = orderping_connection(); if ( null === $conn ) { // Nothing to revoke now; a revoke still pending from an earlier disconnect must survive. orderping_forget_connection(); return ! is_array( get_option( ORDERPING_OPTION_PENDING_REVOKE ) ); } $settled = orderping_revoke_settled( orderping_send_disconnect( $reason, $conn ) ); orderping_forget_connection(); if ( $settled ) { delete_option( ORDERPING_OPTION_PENDING_REVOKE ); return true; } update_option( ORDERPING_OPTION_PENDING_REVOKE, array( 'id' => $conn['id'], 'secret' => $conn['secret'], 'attempts' => 1, ), false ); orderping_schedule_revoke_retry( time() + orderping_backoff_seconds( 1 ) ); return false; } /** * Settings-page Disconnect button. */ function orderping_handle_disconnect() { if ( ! current_user_can( 'manage_woocommerce' ) ) { wp_die( esc_html__( 'You do not have permission to do this.', 'commerceping' ), 403 ); } check_admin_referer( 'orderping_disconnect' ); $acknowledged = orderping_disconnect( 'button' ); orderping_redirect_to_settings( $acknowledged ? 'disconnected' : 'disconnected_local' ); } /** * Backend-initiated revoke: a request signed for this connection makes the * plugin forget its secret. Verified before anything is removed. * * @param WP_REST_Request $request Request. * @return WP_REST_Response|WP_Error */ function orderping_rest_disconnect( WP_REST_Request $request ) { $conn = orderping_connection(); if ( null === $conn || $conn['id'] !== (string) $request->get_header( 'x_op_connection' ) ) { return new WP_Error( 'orderping_unauthorized', 'Unauthorized', array( 'status' => 401 ) ); } $body = $request->get_body(); $verdict = orderping_verify( $conn['secret'], 'cp-to-site', 'POST', ORDERPING_DISCONNECT_PATH, (string) $request->get_header( 'x_op_timestamp' ), (string) $request->get_header( 'x_op_nonce' ), (string) $request->get_header( 'x_op_signature' ), $body, time() ); if ( true !== $verdict ) { return new WP_Error( 'orderping_unauthorized', 'Unauthorized', array( 'status' => 401 ) ); } if ( ! orderping_claim_nonce( (string) $request->get_header( 'x_op_nonce' ) ) ) { return new WP_Error( 'orderping_replayed', 'Unauthorized', array( 'status' => 401 ) ); } $data = json_decode( $body, true ); if ( ! is_array( $data ) || ( $data['type'] ?? '' ) !== 'disconnect' ) { return new WP_Error( 'orderping_invalid', 'Invalid event', array( 'status' => 400 ) ); } orderping_forget_connection(); return new WP_REST_Response( array( 'status' => 'disconnected' ), 200 ); } /** * Plugin tables, in drop order. * * @return string[] */ function orderping_all_tables() { return array( orderping_nonce_table(), orderping_outbox_table(), orderping_confirmation_table(), orderping_confirm_action_table(), orderping_cancel_action_table(), ); } /** * Uninstall: tell the backend, then remove the plugin's options, tables and * scheduled work. Order notes and the confirmation meta already written to * orders are the merchant's order history and are kept. */ function orderping_uninstall() { global $wpdb; orderping_send_disconnect( 'uninstalled' ); orderping_discard_queued_work(); foreach ( array( ORDERPING_SWEEP_HOOK, 'orderping_prune_nonces', ORDERPING_REVOKE_HOOK ) as $hook ) { wp_clear_scheduled_hook( $hook ); } foreach ( orderping_all_tables() as $table ) { // phpcs:ignore WordPress.DB.DirectDatabaseQuery, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQL.NotPrepared $wpdb->query( "DROP TABLE IF EXISTS {$table}" ); } foreach ( array( ORDERPING_OPTION_CONNECTION, ORDERPING_OPTION_INSTANCE, ORDERPING_OPTION_PAIRING, ORDERPING_OPTION_DB_VERSION, ORDERPING_OPTION_PENDING_REVOKE, ORDERPING_OPTION_UPDATE_REQUIRED, ORDERPING_OPTION_PLUGIN_VERSION, ORDERPING_OPTION_ACCOUNT_EMAIL, 'orderping_pilot_phone' ) as $option ) { delete_option( $option ); } }