false, 'catalog_enabled' => false, 'widgets_enabled' => false, 'tracking_enabled' => false, 'orders_enabled' => false, 'publisher_id' => '', 'identity_field' => 'slug', 'site_url' => '', 'site_id' => '', 'generation' => '', 'retailer' => '', 'feed_token_hash' => '', 'order_endpoint' => '', 'order_secret' => '', ); } /** * Read connection settings with compatible defaults. */ public static function get() { return wp_parse_args( get_option( self::OPTION, array() ), self::defaults() ); } /** * Read non-autoloaded plugin state without this request's stale option cache. * * @param string $name Plugin option name. * @param mixed $fallback Missing-option default. * @return mixed Current persisted value. */ public static function fresh_option( $name, $fallback = false ) { wp_cache_delete( $name, 'options' ); wp_cache_delete( 'notoptions', 'options' ); return get_option( $name, $fallback ); } /** * Refresh binding and capability state before a new outbound request. * * @return array Current connection settings. */ public static function refresh() { return wp_parse_args( self::fresh_option( self::OPTION, array() ), self::defaults() ); } /** * Check merchant opt-in, site binding and capability switch. * * @param string $capability Optional capability name. */ public static function active( $capability = '' ) { $c = self::get(); if ( in_array( $capability, array( 'catalog', 'tracking', 'orders' ), true ) && ( ! function_exists( 'wc_get_product' ) || '' !== self::commerce_compatibility_error() ) ) { return false; } return ! is_multisite() && ! empty( $c['service_opt_in'] ) && '' !== $c['retailer'] && hash_equals( untrailingslashit( home_url() ), (string) $c['site_url'] ) && ( '' === $capability || ! empty( $c[ $capability . '_enabled' ] ) ); } /** * Fail closed for recognized unsupported commerce scope adapters. * * @return string Empty when the native single-store commerce scope is supported. */ public static function commerce_compatibility_error() { if ( is_multisite() ) { return 'unsupported_multisite'; } if ( defined( 'ICL_SITEPRESS_VERSION' ) || function_exists( 'pll_languages_list' ) ) { return 'unsupported_multilingual_catalog'; } if ( defined( 'WCML_VERSION' ) || class_exists( 'WOOCS' ) || class_exists( 'WOOMULTI_CURRENCY' ) ) { return 'unsupported_multicurrency_catalog'; } if ( class_exists( 'WC_Dynamic_Pricing' ) || defined( 'WC_DYNAMIC_PRICING_VERSION' ) ) { return 'unsupported_dynamic_pricing'; } return ''; } /** * Authorize the separate read-only catalog credential. * * @param WP_REST_Request $request Authenticated REST request. */ public static function authorize( $request ) { $c = self::get(); $header = (string) $request->get_header( 'authorization' ); if ( ! self::active() || ! preg_match( '/^Bearer ([a-f0-9]{64})$/D', $header, $m ) || empty( $c['feed_token_hash'] ) || ! hash_equals( $c['feed_token_hash'], hash( 'sha256', $m[1] ) ) ) { return new WP_Error( 'selectika_unauthorized', __( 'The Selectika connection is not authorized.', 'selectika' ), array( 'status' => 401 ) ); } return true; } /** * Resolve the configured Selectika catalog identity. * * @param WC_Product $product Native product object. */ public static function item_id( $product ) { $c = self::get(); return 'product_id' === $c['identity_field'] ? (string) $product->get_id() : $product->get_slug(); } /** * Encrypt the order credential using the site authentication salt. * * @param string $secret Signing credential. * @throws RuntimeException When encryption fails. */ public static function encrypt( $secret ) { $iv = random_bytes( 12 ); $tag = ''; $value = openssl_encrypt( $secret, 'aes-256-gcm', hash( 'sha256', wp_salt( 'auth' ), true ), OPENSSL_RAW_DATA, $iv, $tag ); if ( false === $value ) { throw new RuntimeException( 'Credential encryption failed.' ); } // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_encode -- Binary AES-GCM ciphertext encoding only; never executable code. return base64_encode( $iv . $tag . $value ); } /** * Decrypt the order credential, failing closed after salt changes. */ public static function order_secret() { $c = self::get(); return self::decrypt( $c['order_secret'] ); } /** * Decrypt a private short-lived credential value. * * @param string $encoded Binary ciphertext encoded for option storage. * @return string Plaintext, or empty when decryption fails. */ public static function decrypt( $encoded ) { // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_decode -- Decode encrypted credential bytes only. $bytes = base64_decode( $encoded, true ); if ( false === $bytes || strlen( $bytes ) < 29 ) { return ''; } $value = openssl_decrypt( substr( $bytes, 28 ), 'aes-256-gcm', hash( 'sha256', wp_salt( 'auth' ), true ), OPENSSL_RAW_DATA, substr( $bytes, 0, 12 ), substr( $bytes, 12, 16 ) ); return false === $value ? '' : $value; } /** * Describe authenticated connection readiness without exposing secrets. */ public static function descriptor() { $c = self::get(); return array( 'schema_version' => 1, 'site_url' => untrailingslashit( home_url() ), 'site_id' => get_current_blog_id(), 'site_uuid' => $c['site_id'], 'retailer' => $c['retailer'], 'publisher_id' => $c['publisher_id'], 'connection_generation' => $c['generation'], 'plugin_version' => SELECTIKA_VERSION, 'wordpress_version' => get_bloginfo( 'version' ), 'woocommerce_version' => defined( 'WC_VERSION' ) ? WC_VERSION : null, 'capabilities' => array( 'catalog' => self::active( 'catalog' ) && function_exists( 'wc_get_products' ), 'widgets' => self::active( 'widgets' ), 'tracking' => self::active( 'tracking' ), 'orders' => self::active( 'orders' ), 'description_transport' => function_exists( 'wc_get_product' ) ? 'wc/v3' : null, 'description_fields' => array( 'description', 'short_description' ), 'supported_product_types' => array( 'simple', 'variable' ), 'identity_field' => $c['identity_field'], 'feed_schema' => 1, 'multisite' => false, ), 'catalog' => Selectika_Catalog::status(), 'commerce_compatibility_error' => self::commerce_compatibility_error(), 'store_locale' => get_locale(), 'store_currency' => function_exists( 'get_woocommerce_currency' ) ? get_woocommerce_currency() : null, 'orders' => array( 'last_success' => get_option( 'selectika_orders_last_success', null ), 'error' => get_option( 'selectika_orders_error', '' ), 'signing_secret_configured' => '' !== self::order_secret(), 'ready' => self::active( 'orders' ) && '' !== self::order_secret() && '' !== $c['order_endpoint'], 'outbox' => Selectika_Orders::status(), ), ); } /** * Acquire a private lease with compare-and-delete stale recovery. * * @param string $name Plugin option name. * @param int $seconds Lease lifetime. * @return string|false Lease owner token, or false when another worker owns it. */ public static function acquire_lock( $name, $seconds ) { global $wpdb; $existing = get_option( $name, '' ); if ( $existing ) { $parts = explode( ':', (string) $existing, 2 ); if ( (int) $parts[0] > time() ) { return false; } // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- Atomic compare-and-delete prevents a stale worker deleting a new owner's lease; invalidate the non-autoloaded option cache immediately. $wpdb->delete( $wpdb->options, array( 'option_name' => $name, 'option_value' => $existing, ) ); wp_cache_delete( $name, 'options' ); } $owner = ( time() + $seconds ) . ':' . wp_generate_uuid4(); return add_option( $name, $owner, '', false ) ? $owner : false; } /** * Release only a lease still owned by this worker. * * @param string $name Plugin option name. * @param string $owner Owner token returned by acquire_lock. * @return void */ public static function release_lock( $name, $owner ) { global $wpdb; // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- Atomic owner comparison protects a replacement worker; invalidate the non-autoloaded option cache immediately. $wpdb->delete( $wpdb->options, array( 'option_name' => $name, 'option_value' => $owner, ) ); wp_cache_delete( $name, 'options' ); } }