'', 'plan' => '', 'trigger_display' => 'button', 'button_placement' => 'after-cart', 'show_size_finder' => true, 'button_align' => 'left', 'button_size' => 'md', 'size_finder_label' => '', 'embed_locale' => 'en', 'second_embed_locale' => '', 'asset_version' => '', ); } function entersize_normalize_trigger_display( $value ) { $value = is_string( $value ) ? strtolower( trim( $value ) ) : 'button'; return in_array( $value, array( 'text', 'button' ), true ) ? $value : 'button'; } function entersize_normalize_button_placement( $value ) { $value = is_string( $value ) ? strtolower( trim( $value ) ) : 'after-cart'; $allowed = array( 'before-size', 'after-size', 'before-cart', 'after-cart' ); return in_array( $value, $allowed, true ) ? $value : 'after-cart'; } function entersize_normalize_button_align( $value ) { $value = is_string( $value ) ? strtolower( trim( $value ) ) : 'left'; $allowed = array( 'left', 'center', 'right' ); return in_array( $value, $allowed, true ) ? $value : 'left'; } function entersize_normalize_button_size( $value ) { $value = is_string( $value ) ? strtolower( trim( $value ) ) : 'md'; return in_array( $value, array( 'sm', 'md', 'lg' ), true ) ? $value : 'md'; } function entersize_apply_button_style_status( &$options, $status ) { $changed = false; if ( isset( $status['button_size'] ) ) { $next_size = entersize_normalize_button_size( $status['button_size'] ); if ( ! isset( $options['button_size'] ) || $options['button_size'] !== $next_size ) { $options['button_size'] = $next_size; $changed = true; } } $next_finder_label = isset( $status['size_finder_label'] ) ? sanitize_text_field( (string) $status['size_finder_label'] ) : ''; if ( ! isset( $options['size_finder_label'] ) || $options['size_finder_label'] !== $next_finder_label ) { $options['size_finder_label'] = $next_finder_label; $changed = true; } return $changed; } function entersize_normalize_show_flag( $value ) { if ( false === $value || 0 === $value || '0' === $value || 'false' === $value ) { return false; } return true; } function entersize_normalize_show_size_finder( $value ) { return entersize_normalize_show_flag( $value ); } function entersize_placement_hook( $placement, $product = null ) { $placement = entersize_normalize_button_placement( $placement ); $is_variable = $product && is_a( $product, 'WC_Product' ) && $product->is_type( 'variable' ); switch ( $placement ) { case 'before-size': return $is_variable ? 'woocommerce_before_variations_form' : 'woocommerce_before_add_to_cart_form'; case 'after-size': return $is_variable ? 'woocommerce_after_variations_form' : 'woocommerce_before_add_to_cart_button'; case 'before-cart': return 'woocommerce_before_add_to_cart_button'; case 'after-cart': default: return 'woocommerce_after_add_to_cart_button'; } } function entersize_supported_locales() { return array( 'en', 'pl', 'de', 'es' ); } function entersize_match_embed_locale( $value ) { $value = is_string( $value ) ? strtolower( trim( $value ) ) : ''; if ( '' === $value ) { return ''; } $value = preg_split( '/[-_]/', $value )[0]; return in_array( $value, entersize_supported_locales(), true ) ? $value : ''; } function entersize_normalize_embed_locale( $value ) { $matched = entersize_match_embed_locale( $value ); return '' !== $matched ? $matched : 'en'; } function entersize_normalize_embed_second_locale( $value, $primary_locale = 'en' ) { if ( ! is_string( $value ) || '' === trim( $value ) ) { return ''; } if ( 'auto' === strtolower( trim( $value ) ) ) { return 'auto'; } $normalized = entersize_normalize_embed_locale( $value ); $primary = entersize_normalize_embed_locale( $primary_locale ); return $normalized === $primary ? '' : $normalized; } function entersize_read_html_locale() { $lang = ''; if ( function_exists( 'get_language_attributes' ) ) { $attrs = get_language_attributes( 'html' ); if ( is_string( $attrs ) && preg_match( '/lang=["\']([^"\']+)/', $attrs, $matches ) ) { $lang = $matches[1]; } } if ( '' === $lang && function_exists( 'determine_locale' ) ) { $lang = determine_locale(); } if ( '' === $lang ) { $lang = get_bloginfo( 'language' ); } if ( ! is_string( $lang ) || '' === trim( $lang ) ) { return ''; } return entersize_match_embed_locale( $lang ); } function entersize_resolve_embed_locale( $options ) { $primary = entersize_normalize_embed_locale( $options['embed_locale'] ?? 'en' ); $second_raw = is_string( $options['second_embed_locale'] ?? null ) ? strtolower( trim( $options['second_embed_locale'] ) ) : ''; if ( 'auto' === $second_raw ) { $html_locale = entersize_read_html_locale(); return '' !== $html_locale ? $html_locale : $primary; } $second = entersize_normalize_embed_second_locale( $second_raw, $primary ); if ( '' !== $second ) { $html_locale = entersize_read_html_locale(); if ( '' !== $html_locale && $html_locale === $second ) { return $second; } } return $primary; } function entersize_default_size_finder_label( $locale = 'en' ) { $labels = array( 'en' => 'Find your size', 'pl' => 'Znajdź swój rozmiar', 'de' => 'Finde deine Größe', 'es' => 'Encuentra tu talla', ); $locale = entersize_normalize_embed_locale( $locale ); return $labels[ $locale ] ?? $labels['en']; } function entersize_resolve_size_finder_label( $options ) { $custom = isset( $options['size_finder_label'] ) ? trim( (string) $options['size_finder_label'] ) : ''; if ( '' !== $custom ) { return $custom; } $locale = entersize_resolve_embed_locale( $options ); return entersize_default_size_finder_label( $locale ); } function entersize_backend_url() { $url = apply_filters( 'entersize_backend_url', ENTERSIZE_DEFAULT_BACKEND_URL ); if ( ! is_string( $url ) || '' === trim( $url ) ) { return ''; } return untrailingslashit( esc_url_raw( trim( $url ) ) ); } function entersize_get_options() { $options = get_option( ENTERSIZE_OPTION, array() ); return wp_parse_args( is_array( $options ) ? $options : array(), entersize_default_options() ); } function entersize_privacy_policy_content() { if ( ! function_exists( 'wp_add_privacy_policy_content' ) ) { return; } $content = '

' . esc_html__( 'This store uses Entersize to show size charts and size recommendations on product pages.', 'entersize' ) . '

' . '

' . esc_html__( 'When a product page loads, the product name, SKU, brands, categories and tags are sent to Entersize to find the right size chart.', 'entersize' ) . '

' . '

' . esc_html__( 'When you use the Size Finder, the details you enter (height, weight, age, gender, and optionally body shape and bra size), your fit preference, and your IP address are sent to Entersize to calculate a recommendation and to prevent abuse. Entersize does not receive your name, email or order details.', 'entersize' ) . '

' . '

' . sprintf( /* translators: %s: Entersize privacy policy URL. */ wp_kses( __( 'See the Entersize privacy policy for how this data is handled.', 'entersize' ), array( 'a' => array( 'href' => array() ) ) ), esc_url( 'https://entersize.com/privacy' ) ) . '

'; wp_add_privacy_policy_content( 'Entersize', wp_kses_post( $content ) ); } add_action( 'admin_init', 'entersize_privacy_policy_content' ); function entersize_admin_menu() { add_menu_page( __( 'Entersize', 'entersize' ), __( 'Entersize', 'entersize' ), entersize_manage_capability(), ENTERSIZE_ADMIN_PAGE, 'entersize_render_settings_page', entersize_menu_icon(), 81 ); } /** * A base64 SVG data URI lets WordPress recolor the icon to match the admin * color scheme, like a dashicon. */ function entersize_menu_icon() { $path = plugin_dir_path( __FILE__ ) . 'assets/images/entersize-icon.svg'; // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents -- Local plugin asset. $svg = is_readable( $path ) ? file_get_contents( $path ) : false; if ( ! $svg ) { return 'dashicons-editor-table'; } // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_encode -- Required for admin menu SVG icons. return 'data:image/svg+xml;base64,' . base64_encode( $svg ); } add_action( 'admin_menu', 'entersize_admin_menu' ); function entersize_manage_capability() { return class_exists( 'WooCommerce' ) ? 'manage_woocommerce' : 'manage_options'; } function entersize_admin_page_url() { return admin_url( 'admin.php?page=' . ENTERSIZE_ADMIN_PAGE ); } function entersize_is_settings_page() { // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Read-only admin page check. return isset( $_GET['page'] ) && ENTERSIZE_ADMIN_PAGE === sanitize_key( wp_unslash( $_GET['page'] ) ); } function entersize_connection_transient_key( $state ) { return 'entersize_conn_' . $state; } /** * The state travels through the browser; the verifier never leaves this * server except in the server-to-server exchange. Entersize only learns its * sha256 (the challenge), so a leaked return URL can't be swapped for the token. */ function entersize_create_connection_state() { $state = wp_generate_password( 32, false, false ); set_transient( entersize_connection_transient_key( $state ), array( 'user_id' => get_current_user_id(), 'verifier' => wp_generate_password( 64, false, false ), ), ENTERSIZE_CONNECTION_TTL ); return $state; } function entersize_get_connection_state( $state ) { if ( ! is_string( $state ) || ! preg_match( '/^[A-Za-z0-9]{32}$/', $state ) ) { return null; } $stored = get_transient( entersize_connection_transient_key( $state ) ); if ( ! is_array( $stored ) || empty( $stored['verifier'] ) || ! isset( $stored['user_id'] ) ) { return null; } return $stored; } /** * Returns the verifier when the state belongs to the current user, and * deletes it so it can only be used once. Empty string otherwise. */ function entersize_consume_connection_state( $state ) { $stored = entersize_get_connection_state( $state ); if ( ! $stored || get_current_user_id() !== (int) $stored['user_id'] ) { return ''; } delete_transient( entersize_connection_transient_key( $state ) ); return (string) $stored['verifier']; } function entersize_plugin_return_url() { return entersize_admin_page_url(); } function entersize_app_connect_url( $state ) { $backend_url = entersize_backend_url(); if ( empty( $backend_url ) || empty( $state ) ) { return ''; } return add_query_arg( array( 'platform' => 'woo', 'storeUrl' => home_url( '/' ), 'pluginReturnUrl' => entersize_plugin_return_url(), 'pluginConnectionState' => $state, ), trailingslashit( $backend_url ) . 'dashboard/integrations' ); } /** * Plain link to the app's integrations section. Disconnecting happens there; * the plugin only links out to it (no local state change on click). */ function entersize_app_integrations_url() { $backend_url = entersize_backend_url(); if ( empty( $backend_url ) ) { return ''; } return trailingslashit( $backend_url ) . 'dashboard/integrations'; } function entersize_app_dashboard_url() { $backend_url = entersize_backend_url(); if ( empty( $backend_url ) ) { return ''; } return trailingslashit( $backend_url ) . 'dashboard'; } function entersize_woo_icon_url() { return plugins_url( 'assets/images/woo.png', __FILE__ ); } function entersize_brand_icon_url() { return plugins_url( 'assets/images/entersize.png', __FILE__ ); } /** * Local button target. The button never links straight to the app; it links * back to this settings page with a nonce so a fresh connection state can be * minted at click time (see entersize_maybe_start_connection). This * keeps the state from going stale while the settings page sits open. */ function entersize_connect_action_url() { if ( empty( entersize_backend_url() ) ) { return ''; } return add_query_arg( array( 'entersize_connect' => '1', '_wpnonce' => wp_create_nonce( 'entersize_connect' ), ), entersize_admin_page_url() ); } function entersize_embed_status( $options ) { $backend_url = entersize_backend_url(); if ( empty( $backend_url ) || empty( $options['embed_token'] ) ) { return array( 'checked' => false, 'connected' => false, ); } $response = wp_remote_get( trailingslashit( $backend_url ) . 'api/embed/woo/status', array( 'timeout' => 5, 'headers' => array( 'Accept' => 'application/json', 'Authorization' => 'Bearer ' . $options['embed_token'], ), ) ); if ( is_wp_error( $response ) ) { return array( 'checked' => false, 'connected' => true, ); } $status = wp_remote_retrieve_response_code( $response ); if ( 200 === $status ) { $parsed = json_decode( wp_remote_retrieve_body( $response ), true ); if ( ! is_array( $parsed ) || empty( $parsed['connected'] ) ) { return array( 'checked' => false, 'connected' => true, ); } return array( 'checked' => true, 'connected' => true, 'plan' => isset( $parsed['plan'] ) ? sanitize_text_field( $parsed['plan'] ) : '', 'status' => isset( $parsed['status'] ) ? sanitize_text_field( $parsed['status'] ) : '', 'trigger_display' => isset( $parsed['triggerDisplay'] ) ? entersize_normalize_trigger_display( $parsed['triggerDisplay'] ) : 'button', 'button_placement' => isset( $parsed['triggerPlacement'] ) ? entersize_normalize_button_placement( $parsed['triggerPlacement'] ) : 'after-cart', 'show_size_finder' => array_key_exists( 'showSizeFinder', $parsed ) ? entersize_normalize_show_size_finder( $parsed['showSizeFinder'] ) : true, 'button_align' => isset( $parsed['buttonAlign'] ) ? entersize_normalize_button_align( $parsed['buttonAlign'] ) : 'left', 'button_size' => isset( $parsed['buttonSize'] ) ? entersize_normalize_button_size( $parsed['buttonSize'] ) : 'md', 'size_finder_label' => array_key_exists( 'sizeFinderLabel', $parsed ) && is_string( $parsed['sizeFinderLabel'] ) ? sanitize_text_field( $parsed['sizeFinderLabel'] ) : '', 'embed_locale' => isset( $parsed['locale'] ) ? entersize_normalize_embed_locale( $parsed['locale'] ) : 'en', 'second_embed_locale' => isset( $parsed['secondLocale'] ) ? entersize_normalize_embed_second_locale( (string) $parsed['secondLocale'], isset( $parsed['locale'] ) ? (string) $parsed['locale'] : 'en' ) : '', 'asset_version' => isset( $parsed['assetVersion'] ) && is_string( $parsed['assetVersion'] ) ? substr( preg_replace( '/[^A-Za-z0-9._-]/', '', $parsed['assetVersion'] ), 0, 64 ) : '', ); } if ( 401 === $status ) { return array( 'checked' => true, 'connected' => false, ); } return array( 'checked' => false, 'connected' => true, ); } function entersize_sync_embed_settings( $options, $force = false ) { if ( empty( $options['embed_token'] ) ) { return $options; } $cache_key = 'entersize_embed_settings_' . md5( $options['embed_token'] ); if ( ! $force && get_transient( $cache_key ) ) { return entersize_get_options(); } entersize_apply_embed_status( entersize_embed_status( $options ) ); set_transient( $cache_key, 1, 60 ); return entersize_get_options(); } /** * Caches the app's appearance settings locally. The app is the source of * truth; an unchecked status (backend unreachable, bad response) carries no * settings, so applying it would wipe the cached labels and locales. */ function entersize_apply_embed_status( $status ) { if ( empty( $status['checked'] ) || empty( $status['connected'] ) ) { return; } $options = entersize_get_options(); $next = $options; if ( isset( $status['trigger_display'] ) ) { $next['trigger_display'] = entersize_normalize_trigger_display( $status['trigger_display'] ); } if ( isset( $status['embed_locale'] ) ) { $next['embed_locale'] = entersize_normalize_embed_locale( $status['embed_locale'] ); } $next['second_embed_locale'] = isset( $status['second_embed_locale'] ) ? entersize_normalize_embed_second_locale( (string) $status['second_embed_locale'], $next['embed_locale'] ?? 'en' ) : ''; if ( isset( $status['button_placement'] ) ) { $next['button_placement'] = entersize_normalize_button_placement( $status['button_placement'] ); } if ( array_key_exists( 'show_size_finder', $status ) ) { $next['show_size_finder'] = entersize_normalize_show_size_finder( $status['show_size_finder'] ); } if ( isset( $status['button_align'] ) ) { $next['button_align'] = entersize_normalize_button_align( $status['button_align'] ); } entersize_apply_button_style_status( $next, $status ); if ( ! empty( $status['asset_version'] ) ) { $next['asset_version'] = $status['asset_version']; } if ( ! empty( $status['plan'] ) ) { $next['plan'] = $status['plan']; } if ( $next !== $options ) { entersize_save_options( $next ); } } // Options hold the embed token, so keep them out of the autoloaded set. function entersize_save_options( $options ) { update_option( ENTERSIZE_OPTION, $options, false ); } function entersize_clear_embed_token( $options ) { $options['embed_token'] = ''; $options['plan'] = ''; entersize_save_options( $options ); return entersize_get_options(); } // Swaps the one-time connection state for the store's embed token via a // server-to-server call to Entersize. The token never travels through the // browser URL. function entersize_exchange_connection_state( $state, $verifier ) { $backend_url = entersize_backend_url(); if ( empty( $backend_url ) || empty( $verifier ) ) { return ''; } $response = wp_remote_post( trailingslashit( $backend_url ) . 'api/embed/woo/plugin/exchange', array( 'timeout' => 10, 'headers' => array( 'Accept' => 'application/json', 'Content-Type' => 'application/json', ), 'body' => wp_json_encode( array( 'state' => $state, 'verifier' => $verifier, 'storeUrl' => home_url( '/' ), ) ), ) ); if ( is_wp_error( $response ) ) { return ''; } if ( 200 !== (int) wp_remote_retrieve_response_code( $response ) ) { return ''; } $parsed = json_decode( wp_remote_retrieve_body( $response ), true ); if ( ! is_array( $parsed ) || empty( $parsed['embedToken'] ) ) { return ''; } return sanitize_text_field( $parsed['embedToken'] ); } /** * Step 1 of the connect handshake. The button only redirects here; we mint a * fresh, short-lived connection state at click time and bounce the admin to the * Entersize app, which drives WooCommerce auth and sends the token back. No * manual click in the app, no stale state. */ function entersize_maybe_start_connection() { if ( ! is_admin() || ! entersize_is_settings_page() ) { return; } if ( ! isset( $_GET['entersize_connect'] ) ) { return; } if ( ! current_user_can( entersize_manage_capability() ) ) { return; } $nonce = isset( $_GET['_wpnonce'] ) ? sanitize_text_field( wp_unslash( $_GET['_wpnonce'] ) ) : ''; if ( ! $nonce || ! wp_verify_nonce( $nonce, 'entersize_connect' ) ) { wp_safe_redirect( add_query_arg( 'entersize_connect_error', 'invalid_connection', entersize_admin_page_url() ) ); exit; } $app_url = entersize_app_connect_url( entersize_create_connection_state() ); if ( empty( $app_url ) ) { wp_safe_redirect( entersize_admin_page_url() ); exit; } add_filter( 'allowed_redirect_hosts', 'entersize_allowed_redirect_hosts' ); wp_safe_redirect( esc_url_raw( $app_url ) ); exit; } function entersize_allowed_redirect_hosts( $hosts ) { $host = wp_parse_url( entersize_backend_url(), PHP_URL_HOST ); if ( is_string( $host ) && '' !== $host ) { $hosts[] = $host; } return $hosts; } add_action( 'admin_init', 'entersize_maybe_start_connection' ); function entersize_maybe_apply_connection() { if ( ! is_admin() || ! entersize_is_settings_page() ) { return; } if ( ! current_user_can( entersize_manage_capability() ) ) { return; } // phpcs:disable WordPress.Security.NonceVerification.Recommended -- The one-time connection state is bound to the current user and acts as the CSRF token. $state = ''; if ( isset( $_GET['entersize_connection'] ) ) { $state = sanitize_text_field( wp_unslash( $_GET['entersize_connection'] ) ); } // phpcs:enable WordPress.Security.NonceVerification.Recommended if ( '' === $state ) { return; } $verifier = entersize_consume_connection_state( $state ); if ( '' === $verifier ) { // The one-time state may already have been consumed by an earlier // successful return (e.g. a duplicate browser redirect). If the embed // token is already saved, treat this as success instead of an error. if ( entersize_is_configured() ) { wp_safe_redirect( add_query_arg( 'entersize_connected', '1', entersize_admin_page_url() ) ); exit; } wp_safe_redirect( add_query_arg( 'entersize_connect_error', 'invalid_connection', entersize_admin_page_url() ) ); exit; } $embed_token = entersize_exchange_connection_state( $state, $verifier ); if ( empty( $embed_token ) ) { wp_safe_redirect( add_query_arg( 'entersize_connect_error', 'exchange_failed', entersize_admin_page_url() ) ); exit; } $options = entersize_get_options(); $options['embed_token'] = $embed_token; entersize_save_options( $options ); wp_safe_redirect( add_query_arg( 'entersize_connected', '1', entersize_admin_page_url() ) ); exit; } add_action( 'admin_init', 'entersize_maybe_apply_connection' ); function entersize_maybe_migrate_options() { $stored_version = get_option( ENTERSIZE_DB_VERSION_OPTION, '' ); if ( is_string( $stored_version ) && version_compare( $stored_version, ENTERSIZE_VERSION, '>=' ) ) { return; } if ( function_exists( 'wp_set_option_autoload' ) ) { wp_set_option_autoload( ENTERSIZE_OPTION, false ); } update_option( ENTERSIZE_DB_VERSION_OPTION, ENTERSIZE_VERSION ); } add_action( 'admin_init', 'entersize_maybe_migrate_options' ); function entersize_admin_assets() { if ( ! entersize_is_settings_page() ) { return; } wp_enqueue_style( 'entersize-admin', plugins_url( 'assets/entersize.css', __FILE__ ), array(), ENTERSIZE_VERSION ); } add_action( 'admin_enqueue_scripts', 'entersize_admin_assets' ); function entersize_svg_allowed_html() { return array( 'svg' => array( 'xmlns' => true, 'width' => true, 'height' => true, 'viewbox' => true, 'fill' => true, 'aria-hidden' => true, 'focusable' => true, ), 'path' => array( 'd' => true, 'stroke' => true, 'stroke-linecap' => true, 'stroke-linejoin' => true, ), ); } function entersize_arrows_icon() { return ''; } function entersize_check_icon() { return ''; } function entersize_external_link_icon() { return ''; } function entersize_render_settings_page() { if ( ! current_user_can( entersize_manage_capability() ) ) { wp_die( esc_html__( 'You do not have permission to manage Entersize settings.', 'entersize' ) ); } $options = entersize_get_options(); $status = entersize_embed_status( $options ); $token_cleared = false; if ( $status['checked'] && ! $status['connected'] && ! empty( $options['embed_token'] ) ) { $options = entersize_clear_embed_token( $options ); $token_cleared = true; } entersize_apply_embed_status( $status ); $connect_url = entersize_connect_action_url(); $dashboard_url = entersize_app_dashboard_url(); $integrations_url = entersize_app_integrations_url(); $configured = entersize_is_configured(); // phpcs:disable WordPress.Security.NonceVerification.Recommended -- Display-only notice flags. $connect_error = isset( $_GET['entersize_connect_error'] ) ? sanitize_key( wp_unslash( $_GET['entersize_connect_error'] ) ) : ''; $just_connected = isset( $_GET['entersize_connected'] ) && '1' === sanitize_key( wp_unslash( $_GET['entersize_connected'] ) ); // phpcs:enable WordPress.Security.NonceVerification.Recommended ?>

array( 'href' => array(), 'target' => array(), 'rel' => array() ) ) ), esc_url( 'https://entersize.com/contact' ) ); ?>

post_content, $tag ) ) { $found = true; } elseif ( has_shortcode( (string) $post->post_excerpt, $tag ) ) { $found = true; } } $found = (bool) apply_filters( 'entersize_has_shortcode', $found, $tag ); $cache[ $tag ] = $found; return $found; } function entersize_product_id_from_shortcode_content() { $post = get_post(); if ( ! $post ) { return 0; } $content = (string) $post->post_content . "\n" . (string) $post->post_excerpt; $tags = array(); foreach ( entersize_shortcode_tags() as $tag ) { $tags[] = preg_quote( $tag, '/' ); } $pattern = '/\[(?:' . implode( '|', $tags ) . ')\b[^\]]*(?:product_id|id)\s*=\s*[\'"]?(\d+)/i'; if ( preg_match( $pattern, $content, $match ) ) { return absint( $match[1] ); } return 0; } function entersize_resolve_shortcode_product_id( $atts ) { $atts = shortcode_atts( array( 'product_id' => 0, 'id' => 0, ), is_array( $atts ) ? $atts : array() ); $product_id = absint( $atts['product_id'] ); if ( ! $product_id ) { $product_id = absint( $atts['id'] ); } if ( $product_id ) { return $product_id; } if ( function_exists( 'wc_get_product' ) ) { $product = wc_get_product( get_the_ID() ); if ( $product ) { return $product->get_id(); } global $product; if ( $product && is_a( $product, 'WC_Product' ) ) { return $product->get_id(); } } return 0; } function entersize_should_enqueue_assets() { if ( ! entersize_is_configured() ) { return false; } if ( function_exists( 'is_product' ) && is_product() ) { return true; } return entersize_page_has_any_shortcode(); } function entersize_enqueue_assets() { if ( ! entersize_should_enqueue_assets() ) { return; } entersize_enqueue_storefront_assets(); } function entersize_enqueue_storefront_assets() { if ( ! entersize_is_configured() || wp_script_is( 'entersize-storefront', 'enqueued' ) ) { return; } $options = entersize_sync_embed_settings( entersize_get_options() ); $product = null; if ( function_exists( 'wc_get_product' ) ) { $product_id = 0; if ( function_exists( 'is_product' ) && is_product() ) { $product_id = absint( get_the_ID() ); } if ( ! $product_id ) { $product_id = entersize_product_id_from_shortcode_content(); } if ( $product_id ) { $product = wc_get_product( $product_id ); } } $backend_url = entersize_backend_url(); $fit_button_label = entersize_resolve_size_finder_label( $options ); // The popup and Size Finder are served by Entersize, so they update with // the app instead of with plugin releases. $embed_base = trailingslashit( $backend_url ) . 'embed/'; $embed_version = ! empty( $options['asset_version'] ) ? $options['asset_version'] : ENTERSIZE_VERSION; wp_enqueue_style( 'entersize-popup', $embed_base . 'size-chart.css', array(), $embed_version ); wp_enqueue_script( 'entersize-i18n', $embed_base . 'i18n.js', array(), $embed_version, array( 'in_footer' => true ) ); wp_enqueue_script( 'entersize-popup', $embed_base . 'size-chart.js', array( 'entersize-i18n' ), $embed_version, array( 'in_footer' => true ) ); wp_enqueue_style( 'entersize-storefront', plugins_url( 'assets/entersize.css', __FILE__ ), array(), ENTERSIZE_VERSION ); wp_enqueue_script( 'entersize-storefront', plugins_url( 'assets/entersize.js', __FILE__ ), array( 'entersize-popup' ), ENTERSIZE_VERSION, array( 'in_footer' => true ) ); wp_enqueue_script( 'entersize-fit-predictor', $embed_base . 'fit-predictor.js', array( 'entersize-i18n', 'entersize-storefront' ), $embed_version, array( 'in_footer' => true ) ); wp_localize_script( 'entersize-storefront', 'Entersize', array( 'assetBase' => esc_url_raw( $backend_url ), 'restBase' => esc_url_raw( rest_url( 'entersize/v1/chart/' ) ), 'fitRestBase' => esc_url_raw( rest_url( 'entersize/v1/fit-predictor/' ) ), 'product' => $product ? entersize_product_payload( $product ) : null, 'i18n' => array( 'close' => __( 'Close', 'entersize' ), 'fitTitle' => __( 'Size Finder', 'entersize' ), 'fitUnavailable' => __( 'No size chart for this product.', 'entersize' ), 'fitWidgetLoading' => __( 'Loading Size Finder…', 'entersize' ), 'fitButtonLabel' => $fit_button_label, ), ) ); } add_action( 'wp_enqueue_scripts', 'entersize_enqueue_assets' ); function entersize_render_controls( $args = array() ) { $args = wp_parse_args( $args, array( 'product_id' => 0, 'source' => 'auto', 'echo' => true, ) ); if ( ! entersize_is_configured() ) { return ''; } $product_id = absint( $args['product_id'] ); if ( ! $product_id ) { return ''; } $source = 'shortcode' === $args['source'] ? 'shortcode' : 'auto'; $options = entersize_sync_embed_settings( entersize_get_options() ); $trigger_display = entersize_normalize_trigger_display( $options['trigger_display'] ?? 'button' ); $fit_button_label = entersize_resolve_size_finder_label( $options ); $fit_class = 'text' === $trigger_display ? 'es-fit-button es-fit-button--text' : 'es-fit-button es-fit-button--button'; $button_align = entersize_normalize_button_align( $options['button_align'] ?? 'left' ); $button_size = entersize_normalize_button_size( $options['button_size'] ?? 'md' ); $button_classes = 'es-buttons es-buttons--' . $button_align . ' es-buttons--size-' . $button_size; ob_start(); ?>
array( 'class' => true, 'data-es-source' => true, ), 'button' => array( 'type' => true, 'class' => true, 'data-es-product-id' => true, 'data-es-source' => true, 'hidden' => true, ), ) ); return ''; } return $html; } function entersize_render_button() { if ( ! function_exists( 'is_product' ) || ! is_product() || ! entersize_is_configured() ) { return; } $product_id = absint( get_the_ID() ); if ( ! $product_id ) { return; } entersize_sync_embed_settings( entersize_get_options() ); if ( entersize_page_has_button_shortcode() ) { return; } entersize_render_controls( array( 'product_id' => $product_id, 'source' => 'auto', 'echo' => true, ) ); } function entersize_shortcode_size_finder( $atts ) { entersize_enqueue_storefront_assets(); $product_id = entersize_resolve_shortcode_product_id( $atts ); if ( ! $product_id ) { return ''; } return entersize_render_controls( array( 'product_id' => $product_id, 'source' => 'shortcode', 'echo' => false, ) ); } function entersize_register_shortcodes() { add_shortcode( ENTERSIZE_SHORTCODE_FINDER, 'entersize_shortcode_size_finder' ); } add_action( 'init', 'entersize_register_shortcodes' ); function entersize_register_button_hook() { if ( ! function_exists( 'is_product' ) || ! is_product() || ! entersize_is_configured() ) { return; } $options = entersize_sync_embed_settings( entersize_get_options() ); if ( entersize_page_has_button_shortcode() ) { return; } global $product; $placement = isset( $options['button_placement'] ) ? $options['button_placement'] : 'after-cart'; $hook = entersize_placement_hook( $placement, $product ); add_action( $hook, 'entersize_render_button', 20 ); } add_action( 'wp', 'entersize_register_button_hook' ); function entersize_terms( $product_id, $taxonomy ) { if ( ! taxonomy_exists( $taxonomy ) ) { return array(); } $terms = wc_get_product_terms( $product_id, $taxonomy, array( 'fields' => 'names' ) ); return is_wp_error( $terms ) ? array() : array_values( array_filter( array_map( 'strval', $terms ) ) ); } function entersize_product_payload( $product ) { $product_id = $product->get_id(); return array( 'id' => $product_id, 'name' => $product->get_name(), 'sku' => $product->get_sku(), 'categories' => entersize_terms( $product_id, 'product_cat' ), 'brands' => entersize_terms( $product_id, 'product_brand' ), 'tags' => entersize_terms( $product_id, 'product_tag' ), ); } /** * Visitor IP for throttling. These proxy routes run server-side, so without * forwarding this the Entersize backend sees only the store's server IP. * * Only REMOTE_ADDR is trusted: forwarding headers are client-controlled and * would let anyone rotate past the throttle. Sites behind a trusted proxy * (Cloudflare, a load balancer) can resolve the real IP through the * `entersize_client_ip` filter. */ function entersize_client_ip() { $ip = isset( $_SERVER['REMOTE_ADDR'] ) ? sanitize_text_field( wp_unslash( $_SERVER['REMOTE_ADDR'] ) ) : ''; $ip = apply_filters( 'entersize_client_ip', $ip ); if ( ! is_string( $ip ) ) { return ''; } $ip = trim( $ip ); return filter_var( $ip, FILTER_VALIDATE_IP ) ? $ip : ''; } /** * Coarse per-visitor throttle for the Size Finder proxy routes. Predictions are * metered and billable upstream, so the store should not forward an unbounded * stream of them. Returns a WP_Error when the visitor is over the limit. */ function entersize_throttle( $action, $limit, $window ) { // Visitors without a readable IP share one bucket instead of skipping the limit. $ip = entersize_client_ip(); if ( '' === $ip ) { $ip = 'unknown'; } $key = 'entersize_rl_' . md5( $action . '|' . $ip . '|' . floor( time() / $window ) ); $count = (int) get_transient( $key ); if ( $count >= $limit ) { return new WP_Error( 'entersize_rate_limited', __( 'Too many requests. Please try again in a moment.', 'entersize' ), array( 'status' => 429, 'reason' => 'rate_limited', ) ); } set_transient( $key, $count + 1, $window * 2 ); return null; } function entersize_proxy_post( $api_path, $body ) { $options = entersize_get_options(); $backend_url = entersize_backend_url(); if ( empty( $backend_url ) || empty( $options['embed_token'] ) ) { return new WP_Error( 'entersize_not_configured', __( 'Entersize is not configured.', 'entersize' ), array( 'status' => 503 ) ); } $headers = array( 'Accept' => 'application/json', 'Content-Type' => 'application/json', 'Authorization' => 'Bearer ' . $options['embed_token'], 'X-Entersize-Plugin-Version' => ENTERSIZE_VERSION, ); $client_ip = entersize_client_ip(); if ( '' !== $client_ip ) { $headers['X-Size-App-Client-Ip'] = $client_ip; } $response = wp_remote_post( trailingslashit( $backend_url ) . ltrim( $api_path, '/' ), array( 'timeout' => 8, 'headers' => $headers, 'body' => wp_json_encode( $body ), ) ); if ( is_wp_error( $response ) ) { return new WP_Error( 'entersize_backend_unreachable', __( 'Entersize could not be reached.', 'entersize' ), array( 'status' => 502 ) ); } $status = wp_remote_retrieve_response_code( $response ); $parsed = json_decode( wp_remote_retrieve_body( $response ), true ); if ( ! is_array( $parsed ) ) { return new WP_Error( 'entersize_bad_response', __( 'Entersize returned an invalid response.', 'entersize' ), array( 'status' => 502 ) ); } if ( $status >= 400 ) { $message = isset( $parsed['error'] ) && is_string( $parsed['error'] ) ? sanitize_text_field( $parsed['error'] ) : __( 'Entersize request failed.', 'entersize' ); $error_data = array( 'status' => $status ); if ( isset( $parsed['reason'] ) && is_string( $parsed['reason'] ) ) { $error_data['reason'] = sanitize_text_field( $parsed['reason'] ); } return new WP_Error( 'entersize_backend_error', $message, $error_data ); } return new WP_REST_Response( $parsed, 200 ); } function entersize_proxy_chart( WP_REST_Request $request ) { if ( ! function_exists( 'wc_get_product' ) ) { return new WP_Error( 'entersize_woo_missing', __( 'WooCommerce is not active.', 'entersize' ), array( 'status' => 503 ) ); } $product_id = absint( $request['product_id'] ); $product = wc_get_product( $product_id ); if ( ! $product || 'publish' !== $product->get_status() ) { return new WP_Error( 'entersize_product_not_found', __( 'Product not found.', 'entersize' ), array( 'status' => 404 ) ); } $options = entersize_get_options(); $cache_key = 'entersize_resolve_' . md5( $product_id . '|' . $options['embed_token'] ); $cached = get_transient( $cache_key ); if ( is_array( $cached ) ) { return new WP_REST_Response( $cached, 200 ); } $throttled = entersize_throttle( 'chart', 60, MINUTE_IN_SECONDS ); if ( is_wp_error( $throttled ) ) { return $throttled; } $response = entersize_proxy_post( 'api/embed/woo/resolve', array( 'product' => entersize_product_payload( $product ), ) ); if ( $response instanceof WP_REST_Response ) { set_transient( $cache_key, $response->get_data(), MINUTE_IN_SECONDS ); } return $response; } function entersize_proxy_fit_predict( WP_REST_Request $request ) { $throttled = entersize_throttle( 'fit_predict', 8, MINUTE_IN_SECONDS ); if ( is_wp_error( $throttled ) ) { return $throttled; } if ( ! function_exists( 'wc_get_product' ) ) { return new WP_Error( 'entersize_woo_missing', __( 'WooCommerce is not active.', 'entersize' ), array( 'status' => 503 ) ); } $params = entersize_size_finder_params( $request ); if ( is_wp_error( $params ) ) { return $params; } $product = isset( $params['product'] ) && is_array( $params['product'] ) ? $params['product'] : null; if ( ! $product || empty( $product['id'] ) ) { return new WP_Error( 'entersize_invalid_request', __( 'product is required.', 'entersize' ), array( 'status' => 400 ) ); } $wc_product = wc_get_product( absint( $product['id'] ) ); if ( ! $wc_product || 'publish' !== $wc_product->get_status() ) { return new WP_Error( 'entersize_product_not_found', __( 'Product not found.', 'entersize' ), array( 'status' => 404 ) ); } // Product data always comes from WooCommerce, never from the browser. unset( $params['product'] ); $body = entersize_sanitize_answers( $params ); $body['product'] = entersize_product_payload( $wc_product ); return entersize_proxy_post( 'api/embed/woo/fit-predictor/predict', $body ); } function entersize_proxy_fit_feedback( WP_REST_Request $request ) { $throttled = entersize_throttle( 'fit_feedback', 20, MINUTE_IN_SECONDS ); if ( is_wp_error( $throttled ) ) { return $throttled; } $params = entersize_size_finder_params( $request ); if ( is_wp_error( $params ) ) { return $params; } return entersize_proxy_post( 'api/embed/woo/fit-predictor/feedback', entersize_sanitize_answers( $params ) ); } function entersize_size_finder_params( WP_REST_Request $request ) { if ( strlen( (string) $request->get_body() ) > 16384 ) { return new WP_Error( 'entersize_payload_too_large', __( 'Request is too large.', 'entersize' ), array( 'status' => 413 ) ); } $params = $request->get_json_params(); return is_array( $params ) ? $params : array(); } /** * Size Finder answers are forwarded as-is so new questions in the widget need * no plugin update. Entersize validates the values; this only bounds the shape. */ function entersize_sanitize_answers( $value, $depth = 0 ) { if ( null === $value || is_bool( $value ) || is_int( $value ) || is_float( $value ) ) { return $value; } if ( is_string( $value ) ) { return mb_substr( sanitize_text_field( $value ), 0, 200 ); } if ( ! is_array( $value ) || $depth >= 3 ) { return null; } $out = array(); foreach ( array_slice( $value, 0, 50, true ) as $key => $item ) { if ( is_string( $key ) && ! preg_match( '/^[A-Za-z][A-Za-z0-9_]{0,63}$/', $key ) ) { continue; } $out[ $key ] = entersize_sanitize_answers( $item, $depth + 1 ); } return $out; } /** * Called server-to-server by Entersize before it binds a connection state to * a store, proving this site minted the state. Read-only: the state is still * consumed later by entersize_maybe_apply_connection. States are * 32 random alphanumerics, so this cannot be used to guess one. */ function entersize_rest_verify_connection( WP_REST_Request $request ) { $stored = entersize_get_connection_state( (string) $request->get_param( 'state' ) ); if ( ! $stored ) { return rest_ensure_response( array( 'plugin' => 'entersize', 'valid' => false, ) ); } return rest_ensure_response( array( 'plugin' => 'entersize', 'valid' => true, 'challenge' => hash( 'sha256', (string) $stored['verifier'] ), ) ); } /** * Called server-to-server by Entersize when a store disconnects, so the * WooCommerce API key it was given stops working. The caller must present the * full key and secret, and only keys created for Entersize can be deleted. */ function entersize_rest_revoke_key( WP_REST_Request $request ) { global $wpdb; $response = array( 'plugin' => 'entersize', 'revoked' => false, ); if ( ! is_ssl() || ! function_exists( 'wc_api_hash' ) ) { return rest_ensure_response( $response ); } $throttled = entersize_throttle( 'revoke_key', 10, MINUTE_IN_SECONDS ); if ( $throttled ) { return $throttled; } $consumer_key = (string) $request->get_param( 'consumer_key' ); $consumer_secret = (string) $request->get_param( 'consumer_secret' ); if ( '' === $consumer_key || '' === $consumer_secret || strlen( $consumer_key ) > 256 || strlen( $consumer_secret ) > 256 ) { return rest_ensure_response( $response ); } // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching $row = $wpdb->get_row( $wpdb->prepare( "SELECT key_id, consumer_secret, description FROM {$wpdb->prefix}woocommerce_api_keys WHERE consumer_key = %s", wc_api_hash( sanitize_text_field( $consumer_key ) ) ) ); if ( ! $row || ! hash_equals( (string) $row->consumer_secret, $consumer_secret ) || false === stripos( (string) $row->description, 'Entersize' ) ) { return rest_ensure_response( $response ); } // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching $deleted = $wpdb->delete( $wpdb->prefix . 'woocommerce_api_keys', array( 'key_id' => (int) $row->key_id ), array( '%d' ) ); $response['revoked'] = ( false !== $deleted && $deleted > 0 ); return rest_ensure_response( $response ); } function entersize_register_rest_routes() { $namespaces = array( 'entersize/v1' ); $routes = array( array( 'route' => '/connection/verify', 'methods' => WP_REST_Server::READABLE, 'callback' => 'entersize_rest_verify_connection', 'args' => array(), ), array( 'route' => '/connection/revoke-key', 'methods' => WP_REST_Server::CREATABLE, 'callback' => 'entersize_rest_revoke_key', 'args' => array(), ), array( 'route' => '/chart/(?P\d+)', 'methods' => WP_REST_Server::READABLE, 'callback' => 'entersize_proxy_chart', 'args' => array( 'product_id' => array( 'validate_callback' => function ( $param ) { return absint( $param ) > 0; }, ), ), ), array( 'route' => '/fit-predictor/predict', 'methods' => WP_REST_Server::CREATABLE, 'callback' => 'entersize_proxy_fit_predict', 'args' => array(), ), array( 'route' => '/fit-predictor/feedback', 'methods' => WP_REST_Server::CREATABLE, 'callback' => 'entersize_proxy_fit_feedback', 'args' => array(), ), ); foreach ( $namespaces as $namespace ) { foreach ( $routes as $route ) { $args = array( 'methods' => $route['methods'], 'callback' => $route['callback'], 'permission_callback' => '__return_true', ); if ( ! empty( $route['args'] ) ) { $args['args'] = $route['args']; } register_rest_route( $namespace, $route['route'], $args ); } } } add_action( 'rest_api_init', 'entersize_register_rest_routes' );