prepare(). // phpcs:disable WordPress.DB.DirectDatabaseQuery, WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter const ORDERPING_OPTION_CONNECTION = 'orderping_connection'; const ORDERPING_OPTION_INSTANCE = 'orderping_instance_id'; const ORDERPING_OPTION_PAIRING = 'orderping_pairing'; // Set when the backend refused this plugin's contract version; holds that version. const ORDERPING_OPTION_UPDATE_REQUIRED = 'orderping_update_required'; const ORDERPING_OPTION_PLUGIN_VERSION = 'orderping_plugin_version'; // Display-only CommercePing account email, stored only from the pairing completion response. const ORDERPING_OPTION_ACCOUNT_EMAIL = 'orderping_account_email'; const ORDERPING_TRANSIENT_ACTIVATED = 'orderping_activation_redirect'; /** * Whether the backend has refused this plugin's event contract, so sending is blocked * until the plugin is updated. The flag is tied to the contract version it was set * for, so an update that bumps the contract clears it by itself. * * @return bool */ function orderping_update_required() { return (int) get_option( ORDERPING_OPTION_UPDATE_REQUIRED, 0 ) === ORDERPING_CONTRACT_VERSION; } /** * Backend base URL. Override with ORDERPING_BACKEND_URL in wp-config.php. * * @return string */ function orderping_backend_url() { $url = defined( 'ORDERPING_BACKEND_URL' ) ? ORDERPING_BACKEND_URL : 'https://app.commerceping.com'; /** * Filters the CommercePing backend base URL (staging, local rigs). Production is the default. * * @param string $url Base URL. */ $url = apply_filters( 'orderping_backend_base', $url ); return untrailingslashit( esc_url_raw( (string) $url ) ); } /** * Stable per-install identifier, created on first use. * * @return string */ function orderping_instance_id() { $id = get_option( ORDERPING_OPTION_INSTANCE ); if ( ! is_string( $id ) || ! preg_match( '/^[A-Za-z0-9_-]{8,128}$/', $id ) ) { $id = wp_generate_uuid4(); update_option( ORDERPING_OPTION_INSTANCE, $id, false ); } return $id; } /** * Paired connection, or null when not connected. * * @return array{id:string,secret:string,epoch:int}|null */ function orderping_connection() { $conn = get_option( ORDERPING_OPTION_CONNECTION ); if ( is_array( $conn ) && ! empty( $conn['id'] ) && ! empty( $conn['secret'] ) ) { return array( 'id' => (string) $conn['id'], 'secret' => (string) $conn['secret'], 'epoch' => (int) ( $conn['epoch'] ?? 1 ), ); } return null; } /** * Sends a signed JSON event to the backend. * * @param array $event Event payload. * @param array $bound Optional connection the event was captured under (id, epoch); a different * current connection means the event is stale and nothing is sent. * @return array{ok:bool,status:int,error?:string} */ function orderping_send_event( array $event, array $bound = array() ) { $conn = orderping_connection(); if ( null === $conn ) { return array( 'ok' => false, 'status' => 0, 'error' => 'not_connected' ); } if ( orderping_update_required() ) { return array( 'ok' => false, 'status' => 0, 'error' => 'update_required' ); } if ( $bound && ( $bound['id'] !== $conn['id'] || (int) $bound['epoch'] !== $conn['epoch'] ) ) { return array( 'ok' => false, 'status' => 0, 'error' => 'stale_connection' ); } $body = wp_json_encode( $event ); $path = '/woo/events'; $timestamp = time(); $nonce = bin2hex( random_bytes( 16 ) ); $response = wp_remote_post( orderping_backend_url() . $path, array( 'timeout' => 10, '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, ) ); if ( is_wp_error( $response ) ) { return array( 'ok' => false, 'status' => 0, 'error' => 'transport' ); } $status = (int) wp_remote_retrieve_response_code( $response ); if ( 426 === $status ) { // The backend no longer accepts this plugin's contract version: block sending, show update guidance. update_option( ORDERPING_OPTION_UPDATE_REQUIRED, ORDERPING_CONTRACT_VERSION, false ); return array( 'ok' => false, 'status' => 426, 'error' => 'update_required' ); } return array( 'ok' => 200 === $status, 'status' => $status ); } /** * Nonce table name. * * @return string */ function orderping_nonce_table() { global $wpdb; return $wpdb->prefix . 'orderping_nonces'; } /** * Creates the plugin tables (nonce and outbox). The nonce UNIQUE key is what makes a claim atomic. */ function orderping_install_tables() { global $wpdb; require_once ABSPATH . 'wp-admin/includes/upgrade.php'; $table = orderping_nonce_table(); $charset = $wpdb->get_charset_collate(); dbDelta( "CREATE TABLE {$table} ( nonce_hash char(64) NOT NULL, expires_at bigint(20) unsigned NOT NULL, PRIMARY KEY (nonce_hash), UNIQUE KEY nonce_hash_unique (nonce_hash), KEY expires_at (expires_at) ) {$charset};" ); orderping_install_outbox_table(); orderping_install_confirmation_tables(); orderping_install_cancel_tables(); } /** * Atomically claims a backend-request nonce: an insert against a unique key * that must affect exactly one row. A duplicate or any DB error is a failed claim. * * @param string $nonce Nonce header value (already validated). * @return bool True when the nonce was unused. */ function orderping_claim_nonce( $nonce ) { global $wpdb; $suppress = $wpdb->suppress_errors( true ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching $rows = $wpdb->insert( orderping_nonce_table(), array( 'nonce_hash' => hash( 'sha256', $nonce ), 'expires_at' => time() + ORDERPING_NONCE_TTL, ), array( '%s', '%d' ) ); $wpdb->suppress_errors( $suppress ); return 1 === $rows && 1 === (int) $wpdb->rows_affected; } /** * Daily cleanup of expired nonces. */ function orderping_prune_nonces() { global $wpdb; $table = orderping_nonce_table(); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared $wpdb->query( $wpdb->prepare( "DELETE FROM {$table} WHERE expires_at < %d", time() ) ); }