options['general']['ui_mode'] ?? 'legacy'; if ( $ui_mode === 'react' ) { add_action( 'wp_ajax_cn_react_update_design', [ $this, 'react_update_design' ] ); add_action( 'wp_ajax_cn_react_apply_template', [ $this, 'react_apply_template' ] ); add_action( 'wp_ajax_cn_react_apply_languages', [ $this, 'react_apply_languages' ] ); } } /** * Ajax API request. * * @return void */ public function api_request() { // check capabilities if ( ! current_user_can( apply_filters( 'cn_manage_cookie_notice_cap', 'manage_options' ) ) ) wp_die( __( 'You do not have permission to access this page.', 'cookie-notice' ) ); // check main nonce if ( ! check_ajax_referer( 'cookie-notice-welcome', 'nonce' ) ) wp_die( __( 'You do not have permission to access this page.', 'cookie-notice' ) ); // get request $request = isset( $_POST['request'] ) ? sanitize_key( $_POST['request'] ) : ''; // no valid request? if ( ! in_array( $request, [ 'register', 'login', 'configure', 'select_plan', 'payment', 'get_bt_init_token', 'use_license', 'sync_config' ], true ) ) wp_die( __( 'You do not have permission to access this page.', 'cookie-notice' ) ); $special_actions = [ 'register', 'login', 'configure', 'payment' ]; // payment nonce if ( $request === 'payment' ) $nonce = isset( $_POST['cn_payment_nonce'] ) ? sanitize_key( $_POST['cn_payment_nonce'] ) : ''; // special nonce elseif ( in_array( $request, $special_actions, true ) ) $nonce = isset( $_POST['cn_nonce'] ) ? sanitize_key( $_POST['cn_nonce'] ) : ''; // check additional nonce if ( in_array( $request, $special_actions, true ) && ! wp_verify_nonce( $nonce, 'cn_api_' . $request ) ) wp_die( __( 'You do not have permission to access this page.', 'cookie-notice' ) ); $errors = []; $response = false; // get main instance $cn = Cookie_Notice(); // get site language $locale = get_locale(); $locale_code = explode( '_', $locale ); // check network $network = $cn->is_network_admin(); // get app token data if ( $network ) $data_token = get_site_transient( 'cookie_notice_app_token' ); else $data_token = get_transient( 'cookie_notice_app_token' ); $admin_email = ! empty( $data_token->email ) ? $data_token->email : ''; $app_id = $cn->options['general']['app_id']; $params = []; switch ( $request ) { case 'use_license': $subscriptionID = isset( $_POST['subscriptionID'] ) ? (int) $_POST['subscriptionID'] : 0; // security: validate subscriptionID is in the session allowlist set during login $allowed_subs = $network ? get_site_transient( 'cookie_notice_app_subscriptions' ) : get_transient( 'cookie_notice_app_subscriptions' ); $allowed_ids = is_array( $allowed_subs ) ? array_column( $allowed_subs, 'subscriptionid' ) : []; if ( ! in_array( $subscriptionID, array_map( 'intval', $allowed_ids ), true ) ) { $response = [ 'error' => esc_html__( 'Invalid subscription.', 'cookie-notice' ) ]; break; } $result = $this->request( 'assign_subscription', [ 'AppID' => $app_id, 'subscriptionID' => $subscriptionID ] ); // errors? if ( ! empty( $result->message ) ) { $response = [ 'error' => $result->message ]; break; } // update WP subscription tier to 'pro' (mirrors the payment case) $status_data = $cn->defaults['data']; if ( $network ) { $status_data = get_site_option( 'cookie_notice_status', $status_data ); $status_data['subscription'] = 'pro'; // get activation timestamp $timestamp = $cn->get_cc_activation_datetime(); // update activation timestamp only for new cookie compliance activations $status_data['activation_datetime'] = $timestamp === 0 ? time() : $timestamp; update_site_option( 'cookie_notice_status', $status_data ); } else { $status_data = get_option( 'cookie_notice_status', $status_data ); $status_data['subscription'] = 'pro'; // get activation timestamp $timestamp = $cn->get_cc_activation_datetime(); // update activation timestamp only for new cookie compliance activations $status_data['activation_datetime'] = $timestamp === 0 ? time() : $timestamp; update_option( 'cookie_notice_status', $status_data ); } // License assignment (use_license): do not CLEAR setup_wizard_complete on // existing sites — that would send already-configured domains back to // FirstRunSetup and make them appear as Free on reload (#1893). // // For brand-new domains the option was never written, so the wizard would // fire unnecessarily for existing subscribers assigning a new slot. // Set the flag only if it hasn't been set before — new domain case. if ( $network ) { if ( ! get_site_option( 'cookie_notice_setup_wizard_complete', false ) ) { update_site_option( 'cookie_notice_setup_wizard_complete', true ); } } else { if ( ! get_option( 'cookie_notice_setup_wizard_complete', false ) ) { update_option( 'cookie_notice_setup_wizard_complete', true ); } } $response = $result; break; case 'get_bt_init_token': $result = $this->request( 'get_token' ); // is token available? if ( ! empty( $result->token ) ) $response = [ 'token' => $result->token ]; break; case 'payment': $error = [ 'error' => esc_html__( 'Unexpected error occurred. Please try again later.', 'cookie-notice' ) ]; // empty data? if ( empty( $_POST['payment_nonce'] ) || empty( $_POST['plan'] ) || empty( $_POST['method'] ) ) { $response = $error; break; } // validate plan and payment method $available_plans = [ 'compliance_monthly_notrial', 'compliance_monthly_5', 'compliance_monthly_10', 'compliance_monthly_20', 'compliance_yearly_notrial', 'compliance_yearly_5', 'compliance_yearly_10', 'compliance_yearly_20' ]; $available_payment_methods = [ 'credit_card', 'paypal' ]; $plan = sanitize_key( $_POST['plan'] ); if ( ! in_array( $_POST['plan'], $available_plans, true ) ) $plan = false; $method = sanitize_key( $_POST['method'] ); if ( ! in_array( $_POST['method'], $available_payment_methods, true ) ) $method = false; // valid plan and payment method? if ( empty( $plan ) || empty( $method ) ) { $response = [ 'error' => esc_html__( 'Empty plan or payment method data.', 'cookie-notice' ) ]; break; } $result = $this->request( 'get_customer', [ 'AppID' => $app_id, 'PlanId' => $plan ] ); // user found? if ( ! empty( $result->id ) ) { $customer = $result; // create user } else { $result = $this->request( 'create_customer', [ 'AppID' => $app_id, 'AdminID' => $admin_email, // remove later - AdminID from API response 'PlanId' => $plan, 'paymentMethodNonce' => sanitize_key( $_POST['payment_nonce'] ) ] ); if ( ! empty( $result->success ) ) $customer = $result->customer; else $customer = $result; } // user created/received? if ( empty( $customer->id ) ) { $response = [ 'error' => esc_html__( 'Unable to create customer data.', 'cookie-notice' ) ]; break; } // selected payment method $payment_method = false; // get payment identifier (email or 4 digits) $identifier = isset( $_POST['cn_payment_identifier'] ) ? sanitize_text_field( $_POST['cn_payment_identifier'] ) : ''; // customer available payment methods $payment_methods = ! empty( $customer->paymentMethods ) ? $customer->paymentMethods : []; // try to find payment method if ( ! empty( $payment_methods ) && is_array( $payment_methods ) ) { foreach ( $payment_methods as $pm ) { // paypal if ( isset( $pm->email ) && $pm->email === $identifier ) $payment_method = $pm; // credit card elseif ( isset( $pm->last4 ) && $pm->last4 === $identifier ) $payment_method = $pm; } } // if payment method was not identified, create it if ( ! $payment_method ) { $result = $this->request( 'create_payment_method', [ 'AppID' => $app_id, 'paymentMethodNonce' => sanitize_key( $_POST['payment_nonce'] ) ] ); // payment method created successfully? if ( ! empty( $result->success ) ) { $payment_method = $result->paymentMethod; } else { $response = [ 'error' => esc_html__( 'Unable to create payment mehotd.', 'cookie-notice' ) ]; break; } } if ( ! isset( $payment_method->token ) ) { $response = [ 'error' => esc_html__( 'No payment method token.', 'cookie-notice' ) ]; break; } // @todo: check if subscription exists $subscription = $this->request( 'create_subscription', [ 'AppID' => $app_id, 'PlanId' => $plan, 'paymentMethodToken' => $payment_method->token ] ); // subscription assigned? if ( ! empty( $subscription->error ) ) { $response = $subscription->error; break; } $status_data = $cn->defaults['data']; // update app status if ( $network ) { $status_data = get_site_option( 'cookie_notice_status', $status_data ); $status_data['subscription'] = 'pro'; // get activation timestamp $timestamp = $cn->get_cc_activation_datetime(); // update activation timestamp only for new cookie compliance activations $status_data['activation_datetime'] = $timestamp === 0 ? time() : $timestamp; update_site_option( 'cookie_notice_status', $status_data ); } else { $status_data = get_option( 'cookie_notice_status', $status_data ); $status_data['subscription'] = 'pro'; // get activation timestamp $timestamp = $cn->get_cc_activation_datetime(); // update activation timestamp only for new cookie compliance activations $status_data['activation_datetime'] = $timestamp === 0 ? time() : $timestamp; update_option( 'cookie_notice_status', $status_data ); } // Only show FirstRunSetup if the user has never completed it. // Free→Pro upgrades: the wizard was already done — don't clear the flag // or they'll see FirstRunSetup and remain appearing as Free on reload. // New activations (flag not set): leave it unset so the wizard fires. // (no-op: delete_option is intentionally removed for the upgrade path) $response = $app_id; break; case 'register': // check terms $terms = isset( $_POST['terms'] ); // no terms? if ( ! $terms ) { $response = [ 'error' => esc_html__( 'Please accept the Terms of Service to proceed.', 'cookie-notice' ) ]; break; } // check email $email = isset( $_POST['email'] ) ? is_email( $_POST['email'] ) : false; // empty email? if ( ! $email ) { $response = [ 'error' => esc_html__( 'Email is not allowed to be empty.', 'cookie-notice' ) ]; break; } // check passwords $pass = ! empty( $_POST['pass'] ) ? stripslashes( $_POST['pass'] ) : ''; $pass2 = ! empty( $_POST['pass2'] ) ? stripslashes( $_POST['pass2'] ) : ''; // empty password? if ( ! $pass || ! is_string( $pass ) ) { $response = [ 'error' => esc_html__( 'Password is not allowed to be empty.', 'cookie-notice' ) ]; break; } // invalid password? if ( preg_match( '/^(?=.*[A-Z])(?=.*\d)[\w !"#$%&\'()*\+,\-.\/:;<=>?@\[\]^\`\{\|\}\~\\\\]{8,}$/', $pass ) !== 1 ) { $response = [ 'error' => esc_html__( 'The password contains illegal characters or does not meet the conditions.', 'cookie-notice' ) ]; break; } // no match? if ( $pass !== $pass2 ) { $response = [ 'error' => esc_html__( 'Passwords do not match.', 'cookie-notice' ) ]; break; } $params = [ 'AdminID' => $email, 'Password' => $pass, 'Language' => ! empty( $_POST['language'] ) ? sanitize_key( $_POST['language'] ) : 'en' ]; $response = $this->request( 'register', $params ); // errors? if ( ! empty( $response->error ) ) break; // errors? if ( ! empty( $response->message ) ) { // normalize duplicate-email to machine-readable key for React recovery UI if ( ! empty( $response->i18n_msg ) && strpos( $response->i18n_msg, 'api_account_status_' ) === 0 ) $response = [ 'error' => 'email_exists' ]; else $response->error = $response->message; break; } // ok, so log in now $params = [ 'AdminID' => $email, 'Password' => $pass ]; $response = $this->request( 'login', $params ); // errors? if ( ! empty( $response->error ) ) break; // errors? if ( ! empty( $response->message ) ) { $response->error = $response->message; break; } // token in response? if ( empty( $response->data->token ) ) { $response = [ 'error' => esc_html__( 'Unexpected error occurred. Please try again later.', 'cookie-notice' ) ]; break; } // set token if ( $network ) set_site_transient( 'cookie_notice_app_token', $response->data, DAY_IN_SECONDS ); else set_transient( 'cookie_notice_app_token', $response->data, DAY_IN_SECONDS ); // multisite? if ( is_multisite() ) { switch_to_blog( 1 ); $site_title = get_bloginfo( 'name' ); $site_url = network_site_url(); $site_description = get_bloginfo( 'description' ); restore_current_blog(); } else { $site_title = get_bloginfo( 'name' ); $site_url = get_home_url(); $site_description = get_bloginfo( 'description' ); } // create new app, no need to check existing $params = [ 'DomainName' => $site_title, 'DomainUrl' => $site_url ]; if ( ! empty( $site_description ) ) $params['DomainDescription'] = $site_description; $response = $this->request( 'app_create', $params ); // If domain already registered, fetch existing app via list_apps and reuse it. if ( ! empty( $response->i18n_msg ) && $response->i18n_msg === 'domain_url_already_exist' ) { $list_response = $this->request( 'list_apps' ); $existing_app = null; $site_normalized = strtolower( preg_replace( '/^www\./', '', trim( str_replace( [ 'http://', 'https://' ], '', $site_url ), '/' ) ) ); if ( ! empty( $list_response->data ) && is_array( $list_response->data ) ) { foreach ( $list_response->data as $app ) { $app_normalized = strtolower( preg_replace( '/^www\./', '', trim( str_replace( [ 'http://', 'https://' ], '', $app->DomainUrl ?? '' ), '/' ) ) ); if ( $app_normalized === $site_normalized ) { $existing_app = $app; break; } } } if ( ! empty( $existing_app->AppID ) && ! empty( $existing_app->SecretKey ) ) { $response = (object) [ 'data' => $existing_app ]; } else { $response->error = $response->message; break; } } // errors? if ( ! empty( $response->error ) || ( ! empty( $response->message ) && empty( $response->data ) ) ) { if ( empty( $response->error ) ) $response->error = $response->message; break; } // data in response? if ( empty( $response->data->AppID ) || empty( $response->data->SecretKey ) ) { $response = [ 'error' => esc_html__( 'Unexpected error occurred. Please try again later.', 'cookie-notice' ) ]; break; } else { $app_id = $response->data->AppID; $secret_key = $response->data->SecretKey; } // update options: app id and secret key $cn->options['general'] = wp_parse_args( [ 'app_id' => $app_id, 'app_key' => $secret_key ], $cn->options['general'] ); if ( $network ) { $cn->options['general']['global_override'] = true; update_site_option( 'cookie_notice_options', $cn->options['general'] ); // get options $app_config = get_site_transient( 'cookie_notice_app_quick_config' ); } else { update_option( 'cookie_notice_options', $cn->options['general'] ); // get options $app_config = get_transient( 'cookie_notice_app_quick_config' ); } // create quick config $params = ! empty( $app_config ) && is_array( $app_config ) ? $app_config : []; // cast to objects if ( $params ) { $new_params = []; foreach ( $params as $key => $array ) { $object = new stdClass(); foreach ( $array as $subkey => $value ) { $new_params[$key] = $object; $new_params[$key]->{$subkey} = $value; } } $params = $new_params; } $params['AppID'] = $app_id; // @todo When mutliple default languages are supported $params['DefaultLanguage'] = 'en'; if ( ! array_key_exists( 'text', $params ) ) $params['text'] = new stdClass(); // add privacy policy url $params['text']->privacyPolicyUrl = get_privacy_policy_url(); // add translations if needed if ( $locale_code[0] !== 'en' ) $params['Languages'] = [ $locale_code[0] ]; $response = $this->request( 'quick_config', $params ); $status_data = $cn->defaults['data']; if ( $response->status === 200 ) { // notify publish app $params = [ 'AppID' => $app_id ]; $response = $this->request( 'notify_app', $params ); if ( $response->status === 200 ) { $response = true; $status_data['status'] = 'active'; $status_data['activation_datetime'] = time(); // update app status if ( $network ) update_site_option( 'cookie_notice_status', $status_data ); else update_option( 'cookie_notice_status', $status_data ); // Auto-populate tracker/blocking config from Designer API (#2130). $this->get_app_config( $app_id, true, true ); } else { $status_data['status'] = 'pending'; // update app status if ( $network ) update_site_option( 'cookie_notice_status', $status_data ); else update_option( 'cookie_notice_status', $status_data ); // errors? if ( ! empty( $response->error ) ) break; // errors? if ( ! empty( $response->message ) ) { $response->error = $response->message; break; } } } else { $status_data['status'] = 'pending'; // update app status if ( $network ) update_site_option( 'cookie_notice_status', $status_data ); else update_option( 'cookie_notice_status', $status_data ); // errors? if ( ! empty( $response->error ) ) { $response->error = $response->error; break; } // errors? if ( ! empty( $response->message ) ) { $response->error = $response->message; break; } } break; case 'login': // check email $email = isset( $_POST['email'] ) ? is_email( $_POST['email'] ) : false; // invalid email? if ( ! $email ) { $response = [ 'error' => esc_html__( 'Email is not allowed to be empty.', 'cookie-notice' ) ]; break; } // check password $pass = ! empty( $_POST['pass'] ) ? preg_replace( '/[^\w !"#$%&\'()*\+,\-.\/:;<=>?@\[\]^\`\{\|\}\~\\\\]/', '', $_POST['pass'] ) : ''; // empty password? if ( ! $pass ) { $response = [ 'error' => esc_html__( 'Password is not allowed to be empty.', 'cookie-notice' ) ]; break; } $params = [ 'AdminID' => $email, 'Password' => $pass ]; $response = $this->request( $request, $params ); // errors? if ( ! empty( $response->error ) ) break; // errors? if ( ! empty( $response->message ) ) { $response->error = $response->message; break; } // token in response? if ( empty( $response->data->token ) ) { $response = [ 'error' => esc_html__( 'Unexpected error occurred. Please try again later.', 'cookie-notice' ) ]; break; } // set token if ( $network ) set_site_transient( 'cookie_notice_app_token', $response->data, DAY_IN_SECONDS ); else set_transient( 'cookie_notice_app_token', $response->data, DAY_IN_SECONDS ); // get apps and check if one for the current domain already exists $response = $this->request( 'list_apps', [] ); // errors? if ( ! empty( $response->message ) ) { $response->error = $response->message; break; } $apps_list = []; $app_exists = false; // multisite? if ( is_multisite() ) { switch_to_blog( 1 ); $site_title = get_bloginfo( 'name' ); $site_url = network_site_url(); $site_description = get_bloginfo( 'description' ); restore_current_blog(); } else { $site_title = get_bloginfo( 'name' ); $site_url = get_home_url(); $site_description = get_bloginfo( 'description' ); } // apps added, check if current one exists if ( ! empty( $response->data ) ) { $apps_list = (array) $response->data; // normalize site URL once before the loop: lowercase, strip protocol, strip www, strip trailing slash $site_normalized = strtolower( preg_replace( '/^www\./', '', trim( str_replace( [ 'http://', 'https://' ], '', $site_url ), '/' ) ) ); foreach ( $apps_list as $index => $app ) { $app_domain = strtolower( preg_replace( '/^www\./', '', trim( str_replace( [ 'http://', 'https://' ], '', $app->DomainUrl ), '/' ) ) ); if ( $app_domain === $site_normalized ) { $app_exists = $app; break; } } } // track whether this domain already existed before login $app_was_preexisting = (bool) $app_exists; // if no app, create one if ( ! $app_exists ) { // create new app $params = [ 'DomainName' => $site_title, 'DomainUrl' => $site_url, ]; if ( ! empty( $site_description ) ) $params['DomainDescription'] = $site_description; $response = $this->request( 'app_create', $params ); // errors? if ( ! empty( $response->message ) ) { $response->error = $response->message; break; } $app_exists = $response->data; } // check if we have the valid app data if ( empty( $app_exists->AppID ) || empty( $app_exists->SecretKey ) ) { $response = [ 'error' => esc_html__( 'Unexpected error occurred. Please try again later.', 'cookie-notice' ) ]; break; } // get subscriptions $subscriptions = []; $params = [ 'AppID' => $app_exists->AppID ]; $response = $this->request( 'get_subscriptions', $params ); // errors? if ( ! empty( $response->error ) ) { $response->error = $response->error; break; } else $subscriptions = map_deep( (array) $response->data, [ $this, 'sanitize_preserve_bools' ] ); // set subscriptions data if ( $network ) set_site_transient( 'cookie_notice_app_subscriptions', $subscriptions, DAY_IN_SECONDS ); else set_transient( 'cookie_notice_app_subscriptions', $subscriptions, DAY_IN_SECONDS ); // determine subscription tier: // - pre-existing domain: preserve its current tier from WP options (Designer API is authoritative) // availablelicense reflects account-level available slots, NOT this domain's plan // If WP options were cleared (e.g. reset), fall back to API-side SubscriptionType // - brand-new domain: always starts as 'basic' (free by default, payment upgrades it) if ( $app_was_preexisting ) { $existing_status = $network ? get_site_option( 'cookie_notice_status', $cn->defaults['data'] ) : get_option( 'cookie_notice_status', $cn->defaults['data'] ); $subscription_tier = ! empty( $existing_status['subscription'] ) && in_array( $existing_status['subscription'], [ 'basic', 'pro' ], true ) ? $existing_status['subscription'] : 'basic'; // WP options cleared but API knows the domain has a subscription — derive tier from API if ( $subscription_tier === 'basic' && ! empty( $app_exists->SubscriptionID ) ) { $subscription_tier = 'pro'; } } else { $subscription_tier = 'basic'; } // update options: app ID and secret key $cn->options['general'] = wp_parse_args( [ 'app_id' => $app_exists->AppID, 'app_key' => $app_exists->SecretKey ], $cn->options['general'] ); if ( $network ) { $cn->options['general']['global_override'] = true; update_site_option( 'cookie_notice_options', $cn->options['general'] ); } else { update_option( 'cookie_notice_options', $cn->options['general'] ); } // Pre-existing domains already have their configuration in the Designer API. // Only call quick_config for new domains to avoid overwriting existing // regulations and settings with defaults. $status_data = $cn->defaults['data']; $status_data['subscription'] = $subscription_tier; if ( ! $app_was_preexisting ) { // Apply pre-configure settings from transient (mirrors register flow). // Transient is set by the configure wizard when the user hasn't yet connected. $app_config = $network ? get_site_transient( 'cookie_notice_app_quick_config' ) : get_transient( 'cookie_notice_app_quick_config' ); // create quick config $params = ! empty( $app_config ) && is_array( $app_config ) ? $app_config : []; // cast arrays to objects if ( $params ) { $new_params = []; foreach ( $params as $key => $array ) { $object = new stdClass(); foreach ( $array as $subkey => $value ) { $new_params[$key] = $object; $new_params[$key]->{$subkey} = $value; } } $params = $new_params; } $params['AppID'] = $app_exists->AppID; $params['DefaultLanguage'] = 'en'; if ( ! array_key_exists( 'text', $params ) ) $params['text'] = new stdClass(); // add privacy policy url $params['text']->privacyPolicyUrl = get_privacy_policy_url(); // add translations if needed if ( $locale_code[0] !== 'en' ) $params['Languages'] = [ $locale_code[0] ]; $response = $this->request( 'quick_config', $params ); if ( $response->status !== 200 ) { $status_data['status'] = 'pending'; // update app status if ( $network ) update_site_option( 'cookie_notice_status', $status_data ); else update_option( 'cookie_notice_status', $status_data ); // errors? if ( ! empty( $response->error ) ) break; // errors? if ( ! empty( $response->message ) ) { $response->error = $response->message; break; } } } // Notify / activate the app (both new and pre-existing domains) $params = [ 'AppID' => $app_exists->AppID ]; $response = $this->request( 'notify_app', $params ); // Idempotent: "App was already active" means the API app record is already Active // (StatusID != Inactive). This happens when WP options were cleared but the API-side // app persists from a prior login. Treat it as success — the app IS active. $notify_already_active = ! empty( $response->message ) && strpos( $response->message, 'already active' ) !== false; if ( $response->status === 200 || $notify_already_active ) { $response = true; $status_data['status'] = 'active'; // get activation timestamp $timestamp = $cn->get_cc_activation_datetime(); // update activation timestamp only for new cookie compliance activations $status_data['activation_datetime'] = $timestamp === 0 ? time() : $timestamp; // update app status if ( $network ) update_site_option( 'cookie_notice_status', $status_data ); else update_option( 'cookie_notice_status', $status_data ); // Sync config from Designer API for all domains (new + pre-existing) // so the Protection tab shows current tracker data (#2130, #2186). $this->get_app_config( $app_exists->AppID, true, true ); } else { $status_data['status'] = 'pending'; // update app status if ( $network ) update_site_option( 'cookie_notice_status', $status_data ); else update_option( 'cookie_notice_status', $status_data ); // errors? if ( ! empty( $response->error ) ) break; // errors? if ( ! empty( $response->message ) ) { $response->error = $response->message; break; } } // all ok, return subscriptions + fresh nonce // A fresh nonce is generated here (after authentication completes) so React // can use it for subsequent AJAX calls (e.g. use_license). The welcomeNonce // in cnReactData was generated at page load, before login state changed — // WP nonces are seeded by user identity so the original may no longer verify. $response = (object) []; $response->subscriptions = $subscriptions; $response->fresh_nonce = wp_create_nonce( 'cookie-notice-welcome' ); // Tell React whether this domain already has a subscription assigned // so it can skip the LicenseSelectStep for already-subscribed domains. $response->app_has_subscription = $app_was_preexisting && ! empty( $app_exists->SubscriptionID ); break; case 'configure': $fields = [ 'cn_position', 'cn_color_primary', 'cn_color_background', 'cn_color_border', 'cn_color_text', 'cn_color_heading', 'cn_color_button_text', 'cn_laws', 'cn_naming', 'cn_on_scroll', 'cn_on_click', 'cn_ui_blocking', 'cn_revoke_consent' ]; $options = []; // loop through potential config form fields foreach ( $fields as $field ) { switch ( $field ) { case 'cn_position': // sanitize position $position = isset( $_POST[$field] ) ? sanitize_key( $_POST[$field] ) : ''; // valid position? Only include if explicitly provided — omitting lets // patch_by_app deep-merge preserve the portal's current value (#ISSUE-1). if ( in_array( $position, [ 'bottom', 'top', 'left', 'right', 'center' ], true ) ) $options['design']['position'] = $position; break; case 'cn_color_primary': $color = isset( $_POST[$field] ) ? sanitize_hex_color( $_POST[$field] ) : ''; if ( ! empty( $color ) ) $options['design']['primaryColor'] = $color; break; case 'cn_color_background': $color = isset( $_POST[$field] ) ? sanitize_hex_color( $_POST[$field] ) : ''; if ( ! empty( $color ) ) $options['design']['bannerColor'] = $color; break; case 'cn_color_border': $color = isset( $_POST[$field] ) ? sanitize_hex_color( $_POST[$field] ) : ''; if ( ! empty( $color ) ) $options['design']['borderColor'] = $color; break; case 'cn_color_text': $color = isset( $_POST[$field] ) ? sanitize_hex_color( $_POST[$field] ) : ''; if ( ! empty( $color ) ) $options['design']['textColor'] = $color; break; case 'cn_color_heading': $color = isset( $_POST[$field] ) ? sanitize_hex_color( $_POST[$field] ) : ''; if ( ! empty( $color ) ) $options['design']['headingColor'] = $color; break; case 'cn_color_button_text': $color = isset( $_POST[$field] ) ? sanitize_hex_color( $_POST[$field] ) : ''; if ( ! empty( $color ) ) $options['design']['btnTextColor'] = $color; break; case 'cn_laws': $new_options = []; // any data? if ( ! empty( $_POST[$field] ) && is_array( $_POST[$field] ) ) { $options['regulations'] = array_map( 'sanitize_text_field', $_POST[$field] ); foreach ( $options['regulations'] as $law ) { if ( in_array( $law, [ 'gdpr', 'ccpa', 'otherus', 'ukpecr', 'lgpd', 'pipeda', 'popia' ], true ) ) $new_options[$law] = true; } } $options['regulations'] = $new_options; // Persist selected law keys to a dedicated WP option so // get_dashboard() and cnReactData can expose them to the // Protection tab LAWS card without a Designer API round-trip. // (#1897 — LAWS card always showed "No laws selected") $saved_law_keys = array_keys( $new_options ); if ( $network ) update_site_option( 'cookie_notice_app_regulations', $saved_law_keys ); else update_option( 'cookie_notice_app_regulations', $saved_law_keys ); // GDPR & others $options['config']['privacyPolicyLink'] = true; // CCPA & Other US if ( array_key_exists( 'ccpa', $options['regulations'] ) || array_key_exists( 'otherus', $options['regulations'] ) ) $options['config']['dontSellLink'] = true; else $options['config']['dontSellLink'] = false; // Build geolocationRules based on selected laws $geolocation_rules = []; foreach ( $options['regulations'] as $law => $enabled ) { if ( ! $enabled ) continue; // CCPA/otherus: Do Not Sell pattern if ( in_array( $law, [ 'ccpa', 'otherus' ], true ) ) { $geolocation_rules[] = [ 'name' => $law, 'display' => true, 'blocking' => false, 'revoke' => false, 'privacy' => false, 'doNotSell' => true, ]; } else { // GDPR/LGPD/UKPECR/PIPEDA/POPIA: full blocking $geolocation_rules[] = [ 'name' => $law, 'display' => true, 'blocking' => true, 'revoke' => true, 'privacy' => true, 'doNotSell' => false, ]; } } if ( ! empty( $geolocation_rules ) ) $options['config']['geolocationRules'] = $geolocation_rules; // ── Auto-set compliance settings based on selected laws (#2143) ────────── // // Opt-in consent laws (GDPR, UKPECR, LGPD, POPIA) require prior explicit // consent — implied consent via scroll/click/close is not valid under any // of these frameworks. Apply the strictest safe defaults when any are selected. $opt_in_laws = [ 'gdpr', 'ukpecr', 'lgpd', 'popia' ]; $has_opt_in = ! empty( array_intersect( array_keys( $options['regulations'] ), $opt_in_laws ) ); $has_ccpa_us = array_key_exists( 'ccpa', $options['regulations'] ) || array_key_exists( 'otherus', $options['regulations'] ); $has_pipeda = array_key_exists( 'pipeda', $options['regulations'] ); // Designer API config keys — sent via the existing patch_by_app PATCH call below. if ( $has_opt_in ) { // Scroll/click/close are not valid consent signals under GDPR, UKPECR, LGPD, POPIA. $options['config']['onScroll'] = false; $options['config']['onClick'] = false; // onClose: net-new key — no cn_on_close handler exists; written directly to config. $options['config']['onClose'] = false; $options['config']['revokeConsent'] = true; } // GDPR only: cookie walls (uiBlocking) are non-compliant per EDPB guidance. if ( array_key_exists( 'gdpr', $options['regulations'] ) ) { $options['config']['uiBlocking'] = false; } // CCPA/OTHERUS: CPRA mandates honoring GPC browser signals. // gpcSupportMode is normally set via the consent_raw handler; this sets it // directly in the config object which patch_by_app sends as a top-level key. if ( $has_ccpa_us ) { $options['config']['gpcSupportMode'] = true; } // PIPEDA: express consent requires ability to revoke — send revokeConsent to Designer API // to match the WP-side revoke_cookies=true set below (#2146). if ( $has_pipeda ) { $options['config']['revokeConsent'] = true; } // ── WP-side options (cookie_notice_options) ───────────────────────────── // The configure case does not normally touch WP options — this is new. // Use $cn->options['general'] (already loaded + merged with defaults) // to avoid clobbering multi_array_merge'd sub-keys. if ( $has_opt_in || $has_ccpa_us || $has_pipeda ) { $wp_options = $cn->options['general']; if ( $has_opt_in ) { // Disable implied consent toggles; enable refuse + revoke + policy link. $wp_options['on_scroll'] = false; $wp_options['on_click'] = false; $wp_options['refuse_opt'] = true; $wp_options['revoke_cookies'] = true; $wp_options['see_more'] = true; // Cap cookie expiry to max allowed: 12–13 months (GDPR/UKPECR EDPB guidance). $wp_options['time'] = 'year'; $wp_options['time_rejected'] = '6months'; } elseif ( $has_ccpa_us || $has_pipeda ) { // Opt-out / express-consent laws: revoke + privacy link minimum. $wp_options['revoke_cookies'] = true; $wp_options['see_more'] = true; // PIPEDA also requires a refuse option (express consent implies ability to decline). if ( $has_pipeda ) $wp_options['refuse_opt'] = true; } if ( $network ) update_site_option( 'cookie_notice_options', $wp_options ); else update_option( 'cookie_notice_options', $wp_options ); } // ── End auto-set compliance settings (#2143) ───────────────────────── break; case 'cn_naming': if ( ! isset( $_POST[$field] ) ) break; $naming = (int) $_POST[$field]; $naming = in_array( $naming, [ 1, 2, 3 ] ) ? $naming : 1; // english only for now $level_names = [ 1 => [ 1 => 'Private', 2 => 'Balanced', 3 => 'Personalized' ], 2 => [ 1 => 'Silver', 2 => 'Gold', 3 => 'Platinum' ], 3 => [ 1 => 'Reject All', 2 => 'Accept Some', 3 => 'Accept All' ] ]; $options['text'] = [ 'levelNameText_1' => $level_names[$naming][1], 'levelNameText_2' => $level_names[$naming][2], 'levelNameText_3' => $level_names[$naming][3] ]; break; case 'cn_on_scroll': if ( isset( $_POST[$field] ) ) $options['config']['onScroll'] = true; break; case 'cn_on_click': if ( isset( $_POST[$field] ) ) $options['config']['onClick'] = true; break; case 'cn_ui_blocking': if ( isset( $_POST[$field] ) ) $options['config']['uiBlocking'] = true; break; case 'cn_revoke_consent': $options['config']['revokeConsent'] = isset( $_POST[$field] ); break; } } // Normalise regulations: move into config with explicit false for // every deselected law. Both patch_by_app (mergeWith deep-merge) // and quick_config (dto.config?.regulations) read it from config. // Top-level regulations is kept in the quick schema for backward // compat with legacy callers, but new code only sends via config. $all_laws = [ 'gdpr', 'ccpa', 'otherus', 'ukpecr', 'lgpd', 'pipeda', 'popia' ]; $selected = isset( $options['regulations'] ) ? $options['regulations'] : []; $full_regs = []; foreach ( $all_laws as $law ) { $full_regs[ $law ] = ! empty( $selected[ $law ] ); } $options['config']['regulations'] = $full_regs; unset( $options['regulations'] ); // set options if ( $network ) set_site_transient( 'cookie_notice_app_quick_config', $options, DAY_IN_SECONDS ); else set_transient( 'cookie_notice_app_quick_config', $options, DAY_IN_SECONDS ); // For connected apps: PATCH the Designer API immediately (#1913 — #1917). // The transient is retained for the register/login initial-creation path. // DevMode mock IDs are skipped — get_write_request_type() returns 'devmode'. if ( ! empty( $app_id ) ) { $write_type = $this->get_write_request_type( $app_id ); if ( $write_type !== 'devmode' ) { // Cast transient arrays to stdClass objects for JSON encoding. $patch_params = [ 'AppID' => $app_id ]; foreach ( $options as $key => $value ) { if ( is_array( $value ) ) { $obj = new stdClass(); foreach ( $value as $sub_key => $sub_val ) { $obj->{$sub_key} = $sub_val; } $patch_params[ $key ] = $obj; } else { $patch_params[ $key ] = $value; } } $patch_result = $this->request( 'patch_by_app', $patch_params ); // Design record not yet created — fall back to quick_config to seed it. // The API returns { i18n_msg: 'user_design_update_id_not_found', status: 400 } (HTTP 200) // when no record exists, so check i18n_msg — not statusCode/404. // Also restore DefaultLanguage which patch_by_app doesn't accept but quick_config requires. if ( is_object( $patch_result ) && isset( $patch_result->i18n_msg ) && $patch_result->i18n_msg === 'user_design_update_id_not_found' ) { $patch_params['DefaultLanguage'] = 'en'; $patch_result = $this->request( 'quick_config', $patch_params ); } // #2160: Surface API errors back to the caller $api_error = ''; if ( is_object( $patch_result ) && isset( $patch_result->error ) ) { $api_error = $patch_result->error; } elseif ( is_array( $patch_result ) && isset( $patch_result['error'] ) ) { $api_error = $patch_result['error']; } if ( ! empty( $api_error ) ) { $response = [ 'error' => __( 'Your laws were saved locally but could not be applied to your live site. Please try again or visit the portal.', 'cookie-notice' ), 'apiSync' => false ]; break; } // Pull confirmed state from portal — portal is SoT. // Updates cookie_notice_app_blocking, cookie_notice_app_regulations, // cookie_notice_app_design, cookie_notice_status. // Do NOT assign return value to $response — configure success // intentionally returns $response = false (initial value). // LawSelectorPanel checks only for json.error; false has none. $this->get_app_config( $app_id, true, true ); } } break; case 'select_plan': break; case 'sync_config': // force update configuration from Designer API $status_data = $this->get_app_config( $app_id, true, true ); // use global_override-aware check for data operations (not is_network_admin) $network_options = $cn->is_network_options(); // get the blocking data with timestamp if ( $network_options ) $blocking = get_site_option( 'cookie_notice_app_blocking', [] ); else $blocking = get_option( 'cookie_notice_app_blocking', [] ); // debug: include blocking data in response when debug mode is enabled $debug = $cn->options['general']['debug_mode'] ? [ 'app_id' => $app_id, 'status_data' => $status_data, 'blocking' => $blocking, 'providers_count' => ! empty( $blocking['providers'] ) ? count( $blocking['providers'] ) : 0, 'patterns_count' => ! empty( $blocking['patterns'] ) ? count( $blocking['patterns'] ) : 0, ] : null; // check if sync was successful if ( ! empty( $status_data ) && is_array( $status_data ) && ! empty( $status_data['status'] ) && $status_data['status'] === 'active' ) { // set cache purge transient to force widget to refresh if ( $network_options ) set_site_transient( 'cookie_notice_config_update', time(), DAY_IN_SECONDS ); else set_transient( 'cookie_notice_config_update', time(), DAY_IN_SECONDS ); $response = [ 'success' => true, 'message' => esc_html__( 'Configuration synced successfully.', 'cookie-notice' ), 'timestamp' => ! empty( $blocking['lastUpdated'] ) ? $blocking['lastUpdated'] : '' ]; } else { $response = [ 'error' => esc_html__( 'Failed to sync configuration. Please check your app ID and try again.', 'cookie-notice' ) ]; } if ( $debug ) $response['debug'] = $debug; break; } echo wp_json_encode( $response ); exit; } /** * Callback for map_deep that leaves booleans (and null) untouched. * sanitize_text_field casts non-strings to string first, which turns * true → "1" and false → "", corrupting BannerConfigJSON booleans like * gpcSupportMode on every get_app_config() round-trip. Use this callback * whenever the decoded payload contains real booleans we need to preserve. * * @param mixed $value * @return mixed */ public function sanitize_preserve_bools( $value ) { if ( is_bool( $value ) || is_null( $value ) ) { return $value; } return sanitize_text_field( $value ); } /** * API request. * * @param string $request The requested action. * @param array $params Parameters for the API action. * @return string|array */ private function request( $request = '', $params = [] ) { // get main instance $cn = Cookie_Notice(); // request arguments $api_args = [ 'timeout' => 60, 'headers' => [ 'x-api-key' => $cn->get_api_key() ] ]; // request parameters $api_params = []; // whether data should be send in json $json = false; // whether application id is required $require_app_id = false; // is it network admin area $network = $cn->is_network_admin(); // get app token data if ( $network ) $data_token = get_site_transient( 'cookie_notice_app_token' ); else $data_token = get_transient( 'cookie_notice_app_token' ); // check api token $api_token = ! empty( $data_token->token ) ? $data_token->token : ''; switch ( $request ) { case 'register': $api_url = $cn->get_url( 'account_api', '/api/account/account/registration' ); $api_args['method'] = 'POST'; break; case 'login': $api_url = $cn->get_url( 'account_api', '/api/account/account/login' ); $api_args['method'] = 'POST'; break; case 'list_apps': $api_url = $cn->get_url( 'account_api', '/api/account/app/list' ); $api_args['method'] = 'GET'; $api_args['headers'] = array_merge( $api_args['headers'], [ 'Authorization' => 'Bearer ' . $api_token ] ); break; case 'app_create': $api_url = $cn->get_url( 'account_api', '/api/account/app/add' ); $api_args['method'] = 'POST'; $api_args['headers'] = array_merge( $api_args['headers'], [ 'Authorization' => 'Bearer ' . $api_token ] ); break; // DEV ONLY: Delete an app record from the Account API by AppID. // Used by dev_reset() (CN_DEV_MODE only) so test runs don't orphan app slots. // Requires a Bearer token (obtained via login) to pass the auth middleware. case 'app_delete': $api_url = $cn->get_url( 'account_api', '/api/account/app/delete' ); $api_args['method'] = 'POST'; $api_args['headers'] = array_merge( $api_args['headers'], [ 'Authorization' => 'Bearer ' . $api_token ] ); break; case 'get_analytics': $require_app_id = true; $api_url = $cn->get_url( 'transactional_api', '/api/transactional/analytics/analytics-data' ); $api_args['method'] = 'GET'; $diff_data = $cn->settings->get_analytics_app_data(); if ( ! empty( $diff_data ) ) { $app_data = [ 'app-id' => $diff_data['id'], 'app-secret-key' => $diff_data['key'] ]; } else { $app_data = [ 'app-id' => $cn->options['general']['app_id'], 'app-secret-key' => $cn->options['general']['app_key'] ]; } $api_args['headers'] = array_merge( $api_args['headers'], $app_data ); break; case 'get_cookie_consent_logs': $require_app_id = true; $api_url = $cn->get_url( 'transactional_api', '/api/transactional/analytics/consent-logs' ); $api_args['method'] = 'POST'; $api_args['headers']['app-id'] = $cn->options['general']['app_id']; $api_args['headers']['app-secret-key'] = $cn->options['general']['app_key']; break; case 'get_privacy_consent_logs': $require_app_id = true; $api_url = $cn->get_url( 'transactional_api', '/api/transactional/privacy/consent-logs' ); $api_args['method'] = 'POST'; $api_args['headers']['app-id'] = $cn->options['general']['app_id']; $api_args['headers']['app-secret-key'] = $cn->options['general']['app_key']; break; case 'get_config': $require_app_id = true; $api_url = $cn->get_url( 'designer_api', '/api/designer/user-design-live' ); $api_args['method'] = 'GET'; break; case 'quick_config': $require_app_id = true; $json = true; $api_url = $cn->get_url( 'designer_api', '/api/designer/user-design/quick' ); $api_args['method'] = 'POST'; $api_args['headers'] = array_merge( $api_args['headers'], [ 'Authorization' => 'Bearer ' . $api_token, 'Content-Type' => 'application/json; charset=utf-8' ] ); break; // PATCH /user-design/by-app/:AppID — partial update for connected apps (#1913). // AppID is pulled from params and placed in the URL; remaining params go in the body. // Used by react_update_design(), react_apply_template(), react_apply_languages(), // and the configure (laws/wizard) flow — replaces quick_config for existing apps. case 'patch_by_app': $patch_app_id = isset( $params['AppID'] ) ? $params['AppID'] : ''; unset( $params['AppID'] ); // AppID goes in URL, not body. $require_app_id = false; // We handle the empty-check ourselves below. $json = true; $api_url = $cn->get_url( 'designer_api', '/api/designer/user-design/by-app/' . rawurlencode( $patch_app_id ) ); $api_args['method'] = 'PATCH'; // Designer API /by-app endpoint uses authenticateApp middleware — // expects app-id + app-secret-key headers (NOT Bearer token). // Both are stored in WP options from the register/login flow. $network = $cn->is_network_admin(); $patch_app_key = $network ? $cn->network_options['general']['app_key'] : $cn->options['general']['app_key']; $api_args['headers'] = array_merge( $api_args['headers'], [ 'app-id' => $patch_app_id, 'app-secret-key' => $patch_app_key, 'Content-Type' => 'application/json; charset=utf-8', ] ); break; case 'notify_app': $require_app_id = true; $json = true; $api_url = $cn->get_url( 'account_api', '/api/account/app/notifyAppPublished' ); $api_args['method'] = 'POST'; $api_args['headers'] = array_merge( $api_args['headers'], [ 'Authorization' => 'Bearer ' . $api_token, 'Content-Type' => 'application/json; charset=utf-8' ] ); break; // braintree init token case 'get_token': $api_url = $cn->get_url( 'account_api', '/api/account/braintree' ); $api_args['method'] = 'GET'; $api_args['headers'] = array_merge( $api_args['headers'], [ 'Authorization' => 'Bearer ' . $api_token ] ); break; // braintree get customer case 'get_customer': $require_app_id = true; $json = true; $api_url = $cn->get_url( 'account_api', '/api/account/braintree/findcustomer' ); $api_args['method'] = 'POST'; $api_args['data_format'] = 'body'; $api_args['headers'] = array_merge( $api_args['headers'], [ 'Authorization' => 'Bearer ' . $api_token, 'Content-Type' => 'application/json; charset=utf-8' ] ); break; // braintree create customer in vault case 'create_customer': $require_app_id = true; $json = true; $api_url = $cn->get_url( 'account_api', '/api/account/braintree/createcustomer' ); $api_args['method'] = 'POST'; $api_args['headers'] = array_merge( $api_args['headers'], [ 'Authorization' => 'Bearer ' . $api_token, 'Content-Type' => 'application/json; charset=utf-8' ] ); break; // braintree get subscriptions case 'get_subscriptions': $require_app_id = true; $json = true; $api_url = $cn->get_url( 'account_api', '/api/account/braintree/subscriptionlists' ); $api_args['method'] = 'POST'; $api_args['headers'] = array_merge( $api_args['headers'], [ 'Authorization' => 'Bearer ' . $api_token, 'Content-Type' => 'application/json; charset=utf-8' ] ); break; // braintree create subscription case 'create_subscription': $require_app_id = true; $json = true; $api_url = $cn->get_url( 'account_api', '/api/account/braintree/createsubscription' ); $api_args['method'] = 'POST'; $api_args['headers'] = array_merge( $api_args['headers'], [ 'Authorization' => 'Bearer ' . $api_token, 'Content-Type' => 'application/json; charset=utf-8' ] ); break; // braintree assign subscription case 'assign_subscription': $require_app_id = true; $json = true; $api_url = $cn->get_url( 'account_api', '/api/account/braintree/assignsubscription' ); $api_args['method'] = 'POST'; $api_args['headers'] = array_merge( $api_args['headers'], [ 'Authorization' => 'Bearer ' . $api_token, 'Content-Type' => 'application/json; charset=utf-8' ] ); break; // braintree create payment method case 'create_payment_method': $require_app_id = true; $json = true; $api_url = $cn->get_url( 'account_api', '/api/account/braintree/createpaymentmethod' ); $api_args['method'] = 'POST'; $api_args['headers'] = array_merge( $api_args['headers'], [ 'Authorization' => 'Bearer ' . $api_token, 'Content-Type' => 'application/json; charset=utf-8' ] ); break; } // check if app id is required to avoid unneeded requests if ( $require_app_id ) { $empty_app_id = false; // check app id if ( array_key_exists( 'AppID', $params ) && is_string( $params['AppID'] ) ) { $app_id = trim( $params['AppID'] ); // empty app id? if ( $app_id === '' ) $empty_app_id = true; } else $empty_app_id = true; if ( $empty_app_id ) return [ 'error' => esc_html__( '"AppID" is not allowed to be empty.', 'cookie-notice' ) ]; } if ( ! empty( $params ) && is_array( $params ) ) { foreach ( $params as $key => $param ) { if ( is_object( $param ) ) $api_params[$key] = $param; elseif ( is_array( $param ) ) $api_params[$key] = array_map( 'sanitize_text_field', $param ); elseif ( $key === 'Password' && ( $request === 'register' || $request === 'login' ) ) $api_params[$key] = preg_replace( '/[^\w !"#$%&\'()*\+,\-.\/:;<=>?@\[\]^\`\{\|\}\~\\\\]/', '', $param ); else $api_params[$key] = sanitize_text_field( $param ); } // for GET requests, append params as query string instead of body if ( $api_args['method'] === 'GET' ) $api_url = add_query_arg( $api_params, $api_url ); elseif ( $json ) $api_args['body'] = wp_json_encode( $api_params ); else $api_args['body'] = $api_params; } $response = wp_remote_request( $api_url, $api_args ); if ( is_wp_error( $response ) ) $result = [ 'error' => $response->get_error_message() ]; else { $content_type = wp_remote_retrieve_header( $response, 'Content-Type' ); // html response, means error if ( $content_type == 'text/html' ) $result = [ 'error' => esc_html__( 'Unexpected error occurred. Please try again later.', 'cookie-notice' ) ]; else { $result = wp_remote_retrieve_body( $response ); // detect json or array $result = is_array( $result ) ? $result : json_decode( $result ); } } return $result; } /** * Check whether WP Cron needs to add new task. * * @return void */ public function check_cron() { // get main instance $cn = Cookie_Notice(); if ( is_multisite() && $cn->is_plugin_network_active() && $cn->network_options['general']['global_override'] ) { $app_id = $cn->network_options['general']['app_id']; $app_key = $cn->network_options['general']['app_key']; } else { $app_id = $cn->options['general']['app_id']; $app_key = $cn->options['general']['app_key']; } // compliance active only if ( $app_id !== '' && $app_key !== '' ) { if ( $cn->get_status() === 'active' ) $recurrence = 'daily'; else $recurrence = 'hourly'; // set schedule if needed if ( ! wp_next_scheduled( 'cookie_notice_get_app_analytics' ) ) wp_schedule_event( time(), 'hourly', 'cookie_notice_get_app_analytics' ); // set schedule if needed if ( ! wp_next_scheduled( 'cookie_notice_get_app_config' ) ) wp_schedule_event( time(), $recurrence, 'cookie_notice_get_app_config' ); } else { // remove schedule if needed if ( wp_next_scheduled( 'cookie_notice_get_app_analytics' ) ) wp_clear_scheduled_hook( 'cookie_notice_get_app_analytics' ); // remove schedule if needed if ( wp_next_scheduled( 'cookie_notice_get_app_config' ) ) wp_clear_scheduled_hook( 'cookie_notice_get_app_config' ); } } /** * Get privacy consent logs. * * @return string|array */ public function get_privacy_consent_logs() { // get main instance $cn = Cookie_Notice(); // threshold-gated: free users cannot access consent logs when over 1K visits (#1851) if ( $cn->threshold_exceeded() ) return []; // get consent logs for specific date $result = $this->request( 'get_privacy_consent_logs', [ 'AppID' => $cn->options['general']['app_id'], 'AppSecretKey' => $cn->options['general']['app_key'], 'Latest' => 100 ] ); // message? if ( ! empty( $result->message ) ) $result = $result->message; // error? elseif ( ! empty( $result->error ) ) $result = $result->error; // valid data? elseif ( ! empty( $result->data ) ) $result = $result->data; else $result = []; return $result; } /** * Get cookie consent logs. * * @param string $date Start date (Y-m-d). * @param string $end_date Optional end date (Y-m-d). Omit for single-day query. * * @return string|array */ public function get_cookie_consent_logs( $date, $end_date = '' ) { // get main instance $cn = Cookie_Notice(); // threshold-gated: free users cannot access consent logs when over 1K visits (#1851) if ( $cn->threshold_exceeded() ) return []; $params = [ 'AppID' => $cn->options['general']['app_id'], 'AppSecretKey' => $cn->options['general']['app_key'], 'Date' => $date, ]; if ( $end_date !== '' && $end_date !== $date ) { $params['EndDate'] = $end_date; } $result = $this->request( 'get_cookie_consent_logs', $params ); // message? if ( ! empty( $result->message ) ) $result = $result->message; // error? elseif ( ! empty( $result->error ) ) $result = $result->error; // valid data? elseif ( ! empty( $result->data ) ) $result = $result->data; else $result = []; return $result; } /** * Get app analytics. * * @param string $app_id * @param bool $force_update * @param bool $force_action * * @return void */ public function get_app_analytics( $app_id = '', $force_update = false, $force_action = true ) { // get main instance $cn = Cookie_Notice(); $allow_one_cron_per_hour = false; if ( is_multisite() && $cn->is_plugin_network_active() && $cn->network_options['general']['global_override'] ) { if ( empty( $app_id ) ) $app_id = $cn->network_options['general']['app_id']; $network = true; $allow_one_cron_per_hour = true; } else { if ( empty( $app_id ) ) $app_id = $cn->options['general']['app_id']; $network = false; } // in global override mode allow only one cron per hour if ( $allow_one_cron_per_hour && ! $force_update ) { $analytics = get_site_option( 'cookie_notice_app_analytics', [] ); // analytics data? if ( ! empty( $analytics ) ) { $updated = strtotime( $analytics['lastUpdated'] ); // last updated less than an hour? if ( $updated !== false && current_time( 'timestamp', true ) - $updated < 3600 ) return; } } $response = $this->request( 'get_analytics', [ 'AppID' => $app_id ] ); // get analytics if ( ! empty( $response->data ) ) { $result = map_deep( (array) $response->data, [ $this, 'sanitize_preserve_bools' ] ); // add time updated $result['lastUpdated'] = date( 'Y-m-d H:i:s', current_time( 'timestamp', true ) ); // get default status data $status_data = $cn->defaults['data']; // update status $status_data['status'] = $cn->get_status(); // update subscription $status_data['subscription'] = $cn->get_subscription(); // update activation timestamp $status_data['activation_datetime'] = $cn->get_cc_activation_datetime(); if ( $status_data['status'] === 'active' && $status_data['subscription'] === 'basic' ) { $threshold = ! empty( $result['cycleUsage']->threshold ) ? (int) $result['cycleUsage']->threshold : 0; $visits = ! empty( $result['cycleUsage']->visits ) ? (int) $result['cycleUsage']->visits : 0; if ( $visits >= $threshold && $threshold > 0 ) $status_data['threshold_exceeded'] = true; } if ( $network ) { update_site_option( 'cookie_notice_app_analytics', $result ); update_site_option( 'cookie_notice_status', $status_data ); } else { update_option( 'cookie_notice_app_analytics', $result, false ); update_option( 'cookie_notice_status', $status_data, false ); } // get current status data $status_data_old = $cn->get_status_data(); // update status data $cn->set_status_data(); // only when status data changed if ( $force_action && $status_data_old !== $status_data ) { do_action( 'cn_configuration_updated', 'analytics', [ 'status' => $status_data ] ); } } } /** * Get app config. * * @param string $app_id * @param bool $force_update * @param bool $force_action * * @return void|array */ public function get_app_config( $app_id = '', $force_update = false, $force_action = true ) { // get main instance $cn = Cookie_Notice(); $allow_one_cron_per_hour = false; if ( is_multisite() && $cn->is_plugin_network_active() && $cn->network_options['general']['global_override'] ) { if ( empty( $app_id ) ) $app_id = $cn->network_options['general']['app_id']; $network = true; $allow_one_cron_per_hour = true; } else { if ( empty( $app_id ) ) $app_id = $cn->options['general']['app_id']; $network = false; } // in global override mode allow only one cron per hour if ( $allow_one_cron_per_hour && ! $force_update ) { $blocking = get_site_option( 'cookie_notice_app_blocking', [] ); // analytics data? if ( ! empty( $blocking ) ) { $updated = strtotime( $blocking['lastUpdated'] ); // last updated less than an hour? if ( $updated !== false && current_time( 'timestamp', true ) - $updated < 3600 ) return; } } // get config $response = $this->request( 'get_config', [ 'AppID' => $app_id ] ); // debug: log raw Designer API response if ( $cn->options['general']['debug_mode'] ) { error_log( '[Cookie Notice] get_app_config - AppID: ' . $app_id ); error_log( '[Cookie Notice] get_app_config - Designer API response: ' . wp_json_encode( $response ) ); } // get status data $status_data = $cn->defaults['data']; // get config if ( ! empty( $response->data ) ) { // sanitize data foreach ( (array) $response->data as $index => $value ) { // custom patterns if ( $index === 'DefaultCookieJSON' ) { foreach ( $value as $p_index => $pattern ) { $pattern->IsCustom = (bool) $pattern->IsCustom; $pattern->CookieID = is_int( $pattern->CookieID ) ? $pattern->CookieID : sanitize_text_field( $pattern->CookieID ); $pattern->CategoryID = (int) $pattern->CategoryID; $pattern->ProviderID = is_int( $pattern->ProviderID ) ? $pattern->ProviderID : sanitize_text_field( $pattern->ProviderID ); $pattern->PatternType = sanitize_text_field( $pattern->PatternType ); $pattern->PatternFormat = sanitize_text_field( $pattern->PatternFormat ); $pattern->Pattern = stripslashes( sanitize_text_field( $pattern->Pattern ) ); // add pattern $result_raw[$index][$p_index] = $pattern; } // custom providers } elseif ( $index === 'DefaultProviderJSON' ) { foreach ( $value as $p_index => $provider ) { $provider->IsCustom = (bool) $provider->IsCustom; $provider->CategoryID = (int) $provider->CategoryID; $provider->ProviderID = is_int( $provider->ProviderID ) ? $provider->ProviderID : sanitize_text_field( $provider->ProviderID ); $provider->ProviderURL = stripslashes( sanitize_text_field( $provider->ProviderURL ) ); $provider->ProviderName = sanitize_text_field( $provider->ProviderName ); // add provider $result_raw[$index][$p_index] = $provider; } } else $result_raw[$index] = map_deep( $value, [ $this, 'sanitize_preserve_bools' ] ); } // set status $status_data['status'] = 'active'; // get activation timestamp $timestamp = $cn->get_cc_activation_datetime(); // update activation timestamp only for new cookie compliance activations $status_data['activation_datetime'] = $timestamp === 0 ? time() : $timestamp; // check subscription if ( ! empty( $result_raw['SubscriptionType'] ) ) $status_data['subscription'] = $cn->check_subscription( strtolower( $result_raw['SubscriptionType'] ) ); if ( $status_data['subscription'] === 'basic' ) { // get analytics data options if ( $network ) $analytics = get_site_option( 'cookie_notice_app_analytics', [] ); else $analytics = get_option( 'cookie_notice_app_analytics', [] ); if ( ! empty( $analytics ) ) { $threshold = ! empty( $analytics['cycleUsage']->threshold ) ? (int) $analytics['cycleUsage']->threshold : 0; $visits = ! empty( $analytics['cycleUsage']->visits ) ? (int) $analytics['cycleUsage']->visits : 0; if ( $visits >= $threshold && $threshold > 0 ) $status_data['threshold_exceeded'] = true; } } // process blocking data $result = [ 'categories' => ! empty( $result_raw['DefaultCategoryJSON'] ) && is_array( $result_raw['DefaultCategoryJSON'] ) ? $result_raw['DefaultCategoryJSON'] : [], 'providers' => ! empty( $result_raw['DefaultProviderJSON'] ) && is_array( $result_raw['DefaultProviderJSON'] ) ? $result_raw['DefaultProviderJSON'] : [], 'patterns' => ! empty( $result_raw['DefaultCookieJSON'] ) && is_array( $result_raw['DefaultCookieJSON'] ) ? $result_raw['DefaultCookieJSON'] : [], 'google_consent_default' => [], 'lastUpdated' => date( 'Y-m-d H:i:s', current_time( 'timestamp', true ) ) ]; if ( ! empty( $result_raw['BannerConfigJSON'] ) && is_object( $result_raw['BannerConfigJSON'] ) ) { $gcm = isset( $result_raw['BannerConfigJSON']->googleConsentMode ) ? (int) $result_raw['BannerConfigJSON']->googleConsentMode : 0; // is google consent mode enabled? (free + threshold-gated: #1851) if ( $gcm === 1 && ! $cn->threshold_exceeded() ) { $result['google_consent_default']['ad_storage'] = isset( $result_raw['BannerConfigJSON']->googleConsentMapAdStorage ) ? (int) $result_raw['BannerConfigJSON']->googleConsentMapAdStorage : 4; $result['google_consent_default']['analytics_storage'] = isset( $result_raw['BannerConfigJSON']->googleConsentMapAnalytics ) ? (int) $result_raw['BannerConfigJSON']->googleConsentMapAnalytics : 3; $result['google_consent_default']['functionality_storage'] = isset( $result_raw['BannerConfigJSON']->googleConsentMapFunctionality ) ? (int) $result_raw['BannerConfigJSON']->googleConsentMapFunctionality : 2; $result['google_consent_default']['personalization_storage'] = isset( $result_raw['BannerConfigJSON']->googleConsentMapPersonalization ) ? (int) $result_raw['BannerConfigJSON']->googleConsentMapPersonalization : 2; $result['google_consent_default']['security_storage'] = isset( $result_raw['BannerConfigJSON']->googleConsentMapSecurity ) ? (int) $result_raw['BannerConfigJSON']->googleConsentMapSecurity : 2; $result['google_consent_default']['ad_personalization'] = isset( $result_raw['BannerConfigJSON']->googleConsentMapAdPersonalization ) ? (int) $result_raw['BannerConfigJSON']->googleConsentMapAdPersonalization : 4; $result['google_consent_default']['ad_user_data'] = isset( $result_raw['BannerConfigJSON']->googleConsentMapAdUserData ) ? (int) $result_raw['BannerConfigJSON']->googleConsentMapAdUserData : 4; } $fcm = isset( $result_raw['BannerConfigJSON']->facebookConsentMode ) ? (int) $result_raw['BannerConfigJSON']->facebookConsentMode : 0; // is facebook consent mode enabled? (pro-only: #1851) if ( $fcm === 1 && $status_data['subscription'] === 'pro' ) { $result['facebook_consent_default']['consent'] = isset( $result_raw['BannerConfigJSON']->facebookConsentMapConsent ) ? (int) $result_raw['BannerConfigJSON']->facebookConsentMapConsent : 4; } $mcm = isset( $result_raw['BannerConfigJSON']->microsoftConsentMode ) ? (int) $result_raw['BannerConfigJSON']->microsoftConsentMode : 0; // is microsoft consent mode enabled? (pro-only: #1851) if ( $mcm === 1 && $status_data['subscription'] === 'pro' ) { $result['microsoft_consent_default']['ad_storage'] = isset( $result_raw['BannerConfigJSON']->microsoftConsentMapAdStorage ) ? (int) $result_raw['BannerConfigJSON']->microsoftConsentMapAdStorage : 4; $result['microsoft_consent_default']['analytics_storage'] = isset( $result_raw['BannerConfigJSON']->microsoftConsentMapAnalyticsStorage ) ? (int) $result_raw['BannerConfigJSON']->microsoftConsentMapAnalyticsStorage : 3; } // Browser signal modes (cherry-picked for backward compat with Protection tab components). $result['gpc_support'] = ! empty( $result_raw['BannerConfigJSON']->gpcSupportMode ); $result['do_not_track'] = ! empty( $result_raw['BannerConfigJSON']->doNotTrackMode ); // Raw BannerConfigJSON — full behavioral config from Designer API. // React admin reads all fields directly from this (camelCase, same keys as API). // Eliminates per-field extraction: new Designer API fields are automatically // available in the plugin UI after a config pull. $result['banner_config'] = json_decode( wp_json_encode( $result_raw['BannerConfigJSON'] ), true ); } if ( $network ) { $blocking_data = get_site_option( 'cookie_notice_app_blocking', [] ); update_site_option( 'cookie_notice_app_blocking', $result ); } else { $blocking_data = get_option( 'cookie_notice_app_blocking', [] ); update_option( 'cookie_notice_app_blocking', $result, false ); } // Sync regulations from Designer API to local WP option (#2186). // Keeps the Protection tab's law card in sync with what the Admin Portal shows. if ( ! empty( $result_raw['BannerConfigJSON'] ) && isset( $result_raw['BannerConfigJSON']->regulations ) ) { $api_regs = (array) $result_raw['BannerConfigJSON']->regulations; $active_reg_keys = array_keys( array_filter( $api_regs ) ); if ( $network ) update_site_option( 'cookie_notice_app_regulations', $active_reg_keys ); else update_option( 'cookie_notice_app_regulations', $active_reg_keys ); } // Cache visual design fields from Designer API response. // position, bannerColor, primaryColor live in UserDesignJSON (visual design), // NOT BannerConfigJSON (behavioral config). Reading from BannerConfigJSON // returned empty strings and caused "No template" on cron refresh (#2261). if ( ! empty( $result_raw['UserDesignJSON'] ) && is_object( $result_raw['UserDesignJSON'] ) ) { $udj = $result_raw['UserDesignJSON']; $design = [ 'position' => isset( $udj->position ) ? (string) $udj->position : '', 'displayType' => isset( $udj->displayType ) ? (string) $udj->displayType : '', 'bannerColor' => isset( $udj->bannerColor ) ? (string) $udj->bannerColor : '', 'primaryColor' => isset( $udj->primaryColor ) ? (string) $udj->primaryColor : '', ]; // Cache consent level labels from DefaultUserTextJSON so the React // admin can show the customer-configured names on ConsentStats cards // and audit log pills instead of hardcoded Accept/Custom/Reject. if ( ! empty( $result_raw['DefaultUserTextJSON'] ) && is_object( $result_raw['DefaultUserTextJSON'] ) ) { $utj = $result_raw['DefaultUserTextJSON']; $design['levelNameText_1'] = isset( $utj->levelNameText_1 ) ? (string) $utj->levelNameText_1 : ''; $design['levelNameText_2'] = isset( $utj->levelNameText_2 ) ? (string) $utj->levelNameText_2 : ''; $design['levelNameText_3'] = isset( $utj->levelNameText_3 ) ? (string) $utj->levelNameText_3 : ''; } if ( $network ) update_site_option( 'cookie_notice_app_design', $design ); else update_option( 'cookie_notice_app_design', $design, false ); } // debug: log what gets stored if ( $cn->options['general']['debug_mode'] ) { error_log( '[Cookie Notice] get_app_config - Stored providers count: ' . count( $result['providers'] ) ); error_log( '[Cookie Notice] get_app_config - Stored patterns count: ' . count( $result['patterns'] ) ); error_log( '[Cookie Notice] get_app_config - Stored blocking data: ' . wp_json_encode( $result ) ); } } else { if ( $cn->options['general']['debug_mode'] ) { error_log( '[Cookie Notice] get_app_config - No data in response. Error: ' . ( ! empty( $response->error ) ? $response->error : 'unknown' ) ); } if ( ! empty( $response->error ) ) { if ( $response->error == 'App is not published yet' ) $status_data['status'] = 'pending'; else $status_data['status'] = ''; } } if ( $network ) update_site_option( 'cookie_notice_status', $status_data ); else update_option( 'cookie_notice_status', $status_data, false ); // get current status data $status_data_old = $cn->get_status_data(); // update status data $cn->set_status_data(); // check blocking data if ( isset( $blocking_data, $result ) ) { // do not compare dates unset( $blocking_data['lastUpdated'] ); unset( $result['lastUpdated'] ); // simple comparing, objects inside $blocking_data_updated = $blocking_data != $result; } else $blocking_data_updated = false; // only when status data or blocking data changed if ( $force_action && ( $status_data_old !== $status_data || $blocking_data_updated ) ) { do_action( 'cn_configuration_updated', 'config', [ 'status' => $status_data, 'blocking' => empty( $result ) ? [] : $result ] ); } return $status_data; } /** * AJAX: Apply a design template preset via quick_config. * * POST fields: template (minimal|standard|bold|popup|panel|compact) * Syncs full design schema to portal + saves position/displayType to WP options. * * @return void */ public function react_apply_template() { $this->verify_react_request(); $cn = Cookie_Notice(); $app_id = $cn->options['general']['app_id']; if ( empty( $app_id ) ) { wp_send_json_error( [ 'error' => 'No app connected.' ] ); } $template = isset( $_POST['template'] ) ? sanitize_key( $_POST['template'] ) : ''; // Full design presets — position/color/typography synced to portal. // Colors match TemplatePresets.jsx PRESETS array. $presets = [ 'minimal' => [ 'position' => 'left', 'displayType' => 'floating', 'bannerColor' => '#f0f0f0', 'primaryColor' => '#20c19e', 'textColor' => '#434f58', 'headingColor' => '#434f58', 'btnTextColor' => '#ffffff', 'btnBorderRadius' => '25px', 'animation' => 'fade', 'bannerOpacity' => 0.97, 'revokePosition' => 'bottom-left', 'showBulletPoints' => true, ], 'standard' => [ 'position' => 'bottom', 'displayType' => 'floating', 'bannerColor' => '#2d3436', 'primaryColor' => '#20c19e', 'textColor' => '#ffffff', 'headingColor' => '#ffffff', 'btnTextColor' => '#ffffff', 'btnBorderRadius' => '25px', 'animation' => 'fade', 'bannerOpacity' => 0.97, 'revokePosition' => 'bottom-left', 'showBulletPoints' => true, ], 'bold' => [ 'position' => 'top', 'displayType' => 'fixed', 'bannerColor' => '#1a1a2e', 'primaryColor' => '#20c19e', 'textColor' => '#ffffff', 'headingColor' => '#ffffff', 'btnTextColor' => '#ffffff', 'btnBorderRadius' => '6px', 'animation' => 'slide', 'bannerOpacity' => 1.0, 'revokePosition' => 'bottom-right', 'showBulletPoints' => false, ], 'popup' => [ 'position' => 'center', 'displayType' => 'floating', 'bannerColor' => '#2c3e50', 'primaryColor' => '#20c19e', 'textColor' => '#ffffff', 'headingColor' => '#ffffff', 'btnTextColor' => '#ffffff', 'btnBorderRadius' => '25px', 'animation' => 'fade', 'bannerOpacity' => 0.97, 'revokePosition' => 'bottom-left', 'showBulletPoints' => true, ], 'panel' => [ 'position' => 'right', 'displayType' => 'floating', 'bannerColor' => '#34495e', 'primaryColor' => '#3498db', 'textColor' => '#ffffff', 'headingColor' => '#ffffff', 'btnTextColor' => '#ffffff', 'btnBorderRadius' => '25px', 'animation' => 'fade', 'bannerOpacity' => 0.97, 'revokePosition' => 'bottom-left', 'showBulletPoints' => true, ], 'compact' => [ 'position' => 'top', 'displayType' => 'floating', 'bannerColor' => '#1a1a2e', 'primaryColor' => '#e67e22', 'textColor' => '#ffffff', 'headingColor' => '#ffffff', 'btnTextColor' => '#ffffff', 'btnBorderRadius' => '6px', 'animation' => 'slide', 'bannerOpacity' => 1.0, 'revokePosition' => 'bottom-right', 'showBulletPoints' => false, ], ]; if ( ! isset( $presets[ $template ] ) ) { wp_send_json_error( [ 'error' => 'Invalid template name.' ] ); } $preset = $presets[ $template ]; // Build design object for quick_config (exclude displayType — WP option, not portal field) $design = new stdClass(); foreach ( $preset as $key => $value ) { if ( $key === 'displayType' ) continue; $design->{$key} = $value; } $params = [ 'AppID' => $app_id, 'DefaultLanguage' => 'en', 'text' => (object) [ 'privacyPolicyUrl' => get_privacy_policy_url() ], 'design' => $design, ]; $write_type = $this->get_write_request_type( $app_id ); // PATCH /by-app endpoint does not accept DefaultLanguage -- strip it. if ( $write_type === 'patch_by_app' ) { unset( $params['DefaultLanguage'] ); } // DevMode mock ID — return synthetic success so the UI can be tested without a real API. if ( $write_type === 'devmode' ) { $network = $cn->is_network_admin(); // Merge visual design fields (position, displayType, colors) from preset. // #2265: API-owned fields write to cookie_notice_app_design only — never cookie_notice_options. $existing_design = $network ? get_site_option( 'cookie_notice_app_design', [] ) : get_option( 'cookie_notice_app_design', [] ); $updated_design = array_merge( $existing_design, [ 'position' => $preset['position'], 'displayType' => $preset['displayType'], 'bannerColor' => $preset['bannerColor'], 'primaryColor' => $preset['primaryColor'], ] ); if ( $network ) { update_site_option( 'cookie_notice_app_design', $updated_design ); } else { update_option( 'cookie_notice_app_design', $updated_design, false ); } wp_send_json_success( [ 'status' => 200, 'template' => $template, 'dev_mode' => true ] ); return; } $result = $this->request( $write_type, $params ); // Design record not yet created — fall back to quick_config to seed it. // The API returns { i18n_msg: 'user_design_update_id_not_found', status: 400 } (HTTP 200) // when no record exists, so check i18n_msg — not statusCode/404. // Also restore DefaultLanguage which patch_by_app doesn't accept but quick_config requires. if ( is_object( $result ) && isset( $result->i18n_msg ) && $result->i18n_msg === 'user_design_update_id_not_found' ) { $params['DefaultLanguage'] = 'en'; $result = $this->request( 'quick_config', $params ); } if ( is_object( $result ) && isset( $result->status ) && $result->status === 200 ) { // #2265: API-owned fields write to cookie_notice_app_design only — never cookie_notice_options. $network = $cn->is_network_admin(); // Merge visual design fields (position, displayType, colors) from preset. $existing_design = $network ? get_site_option( 'cookie_notice_app_design', [] ) : get_option( 'cookie_notice_app_design', [] ); $updated_design = array_merge( $existing_design, [ 'position' => $preset['position'], 'displayType' => $preset['displayType'], 'bannerColor' => $preset['bannerColor'], 'primaryColor' => $preset['primaryColor'], ] ); if ( $network ) { update_site_option( 'cookie_notice_app_design', $updated_design ); } else { update_option( 'cookie_notice_app_design', $updated_design, false ); } // Pull confirmed state from portal — makes portal unambiguous SoT. // Updates cookie_notice_app_blocking, cookie_notice_app_regulations, // cookie_notice_app_design, cookie_notice_status. // Fires cn_configuration_updated → clears page caches (WP Rocket etc). // Does NOT set cookie_notice_config_update transient (widget CDN cache). $this->get_app_config( $app_id, true, true ); // Re-assert preset design values after get_app_config() — the portal may return // empty position/color fields (BannerConfigJSON cherry-picks) which would // overwrite our just-saved preset and cause matchTemplate() to return null // on the next page load ("No template" false negative — #2261). // This write is authoritative: we know what template was just applied. if ( $network ) update_site_option( 'cookie_notice_app_design', $updated_design ); else update_option( 'cookie_notice_app_design', $updated_design, false ); wp_send_json_success( [ 'status' => 200, 'template' => $template ] ); } else { $error = 'Template apply failed.'; if ( is_array( $result ) && ! empty( $result['error'] ) ) $error = $result['error']; elseif ( is_object( $result ) && ! empty( $result->message ) ) $error = $result->message; elseif ( is_object( $result ) && ! empty( $result->error ) ) $error = $result->error; elseif ( is_object( $result ) && ! empty( $result->i18n_msg ) ) $error = 'API error: ' . $result->i18n_msg; elseif ( $result === null ) $error = 'No response from API — check connection.'; wp_send_json_error( [ 'error' => $error, 'apiSync' => false ] ); } } /** * Verify React admin AJAX request (nonce + capability). * * @return void Dies on failure. */ private function verify_react_request() { check_ajax_referer( 'cn_react_nonce', 'nonce' ); if ( ! current_user_can( apply_filters( 'cn_manage_cookie_notice_cap', 'manage_options' ) ) ) wp_send_json_error( [ 'error' => 'Insufficient permissions.' ] ); } /** * Determine whether a write should use PATCH /by-app/:AppID (connected app, existing design) * or fall back to quick_config (initial creation). * * Returns 'patch_by_app' for connected FREE/PRO users with a real AppID. * Returns 'quick_config' for BASIC/unconnected, or DevMode mock IDs (cn-dev-*). * DevMode mock IDs bypass the API entirely — AJAX returns synthetic success so the UI * can be tested without hitting a real API. * * @param string $app_id The AppID being written. * @return string 'patch_by_app' | 'quick_config' | 'devmode' */ private function get_write_request_type( $app_id ) { // DevMode mock IDs — never hit the real API. if ( defined( 'CN_DEV_MODE' ) && CN_DEV_MODE && strpos( $app_id, 'cn-dev-' ) === 0 ) return 'devmode'; // Connected app with a real ID — use the PATCH update endpoint. return 'patch_by_app'; } /** * AJAX: Push design updates to Designer API via quick_config. * * POST fields: design[position], design[displayType], design[bannerColor], etc. * Requires connected app (app_id in WP options). * * @return void */ public function react_update_design() { $this->verify_react_request(); $cn = Cookie_Notice(); $app_id = $cn->options['general']['app_id']; if ( empty( $app_id ) ) { wp_send_json_error( [ 'error' => 'No app connected.' ] ); } $design_raw = isset( $_POST['design'] ) && is_array( $_POST['design'] ) ? $_POST['design'] : []; $config_raw = isset( $_POST['config'] ) && is_array( $_POST['config'] ) ? $_POST['config'] : []; $consent_raw = isset( $_POST['consentConfig'] ) && is_array( $_POST['consentConfig'] ) ? $_POST['consentConfig'] : []; if ( empty( $design_raw ) && empty( $config_raw ) && empty( $consent_raw ) ) { wp_send_json_error( [ 'error' => 'No update data provided.' ] ); } // Allowed design fields with sanitization $allowed_fields = [ 'position' => 'sanitize_key', 'displayType' => 'sanitize_key', 'bannerColor' => 'sanitize_hex_color', 'primaryColor' => 'sanitize_hex_color', 'textColor' => 'sanitize_hex_color', 'headingColor' => 'sanitize_hex_color', 'btnTextColor' => 'sanitize_hex_color', 'btnBorderRadius' => 'sanitize_text_field', 'animation' => 'sanitize_key', 'bannerOpacity' => 'sanitize_text_field', 'revokePosition' => 'sanitize_key', 'showBulletPoints' => null, // boolean ]; // Note: googleConsentMode / facebookConsentMode / microsoftConsentMode are NOT design fields. // The PATCH /by-app endpoint rejects them in design{}. They belong in config{} as booleans. // They are handled below alongside gpcSupportMode / doNotTrackMode. $design = new stdClass(); foreach ( $allowed_fields as $field => $sanitizer ) { if ( ! array_key_exists( $field, $design_raw ) ) continue; if ( $field === 'showBulletPoints' ) { $design->{$field} = filter_var( $design_raw[ $field ], FILTER_VALIDATE_BOOLEAN ); } elseif ( $sanitizer ) { $design->{$field} = call_user_func( $sanitizer, $design_raw[ $field ] ); } } // Validate position — translate 'popup' → 'center' (portal label vs CSS class) if ( isset( $design->position ) ) { if ( $design->position === 'popup' ) { $design->position = 'center'; } elseif ( ! in_array( $design->position, [ 'bottom', 'top', 'left', 'right', 'center' ], true ) ) { $design->position = 'bottom'; } } // Validate displayType if ( isset( $design->displayType ) && ! in_array( $design->displayType, [ 'floating', 'fixed' ], true ) ) $design->displayType = 'floating'; // Validate animation if ( isset( $design->animation ) && ! in_array( $design->animation, [ 'fade', 'slide', 'none' ], true ) ) $design->animation = 'fade'; // Validate bannerOpacity if ( isset( $design->bannerOpacity ) ) { $opacity = (float) $design->bannerOpacity; $design->bannerOpacity = max( 0.0, min( 1.0, $opacity ) ); } // Build config object from allowed behavior fields $config_allowed = [ 'revokeConsent', 'revokeMethod', 'onScroll', 'onScrollOffset', 'onClick', 'reloading' ]; $config = new stdClass(); foreach ( $config_allowed as $f ) { if ( isset( $config_raw[ $f ] ) ) $config->$f = sanitize_text_field( $config_raw[ $f ] ); } // Merge consent mode fields into config{} — the Designer API stores all of these // under BannerConfigJSON, not a separate consentConfig key. The PATCH /by-app endpoint // rejects a top-level consentConfig key entirely. // // Field name mapping (JS POST key → API config key): // gpcSupport → gpcSupportMode (boolean) // doNotTrack → doNotTrackMode (boolean) // All GCM/Facebook/Microsoft map fields keep their names, as integers. // Map/level fields: must be integers (0–4). $consent_int_fields = [ 'googleConsentMapAdStorage', 'googleConsentMapAnalytics', 'googleConsentMapFunctionality', 'googleConsentMapPersonalization', 'googleConsentMapSecurity', 'googleConsentMapAdPersonalization', 'googleConsentMapAdUserData', 'facebookConsentMapConsent', 'microsoftConsentMapAdStorage', 'microsoftConsentMapAnalyticsStorage', ]; foreach ( $consent_int_fields as $f ) { if ( isset( $consent_raw[ $f ] ) ) $config->$f = (int) $consent_raw[ $f ]; } // IMPORTANT: Toggle fields MUST use (bool)(int) — NOT bare (int). // wp_json_encode((int)1) = JSON 1 (integer) — API silently drops it. // wp_json_encode((bool)true) = JSON true (boolean) — API persists it. // See commit 8ff1432 for the original fix. Do NOT revert to (int). $consent_bool_fields = [ 'microsoftConsentModePixie', 'microsoftConsentModeClarity' ]; foreach ( $consent_bool_fields as $f ) { if ( isset( $consent_raw[ $f ] ) ) $config->$f = (bool) (int) $consent_raw[ $f ]; } // gpcSupport → gpcSupportMode (bool) if ( isset( $consent_raw['gpcSupport'] ) ) $config->gpcSupportMode = (bool) (int) $consent_raw['gpcSupport']; // doNotTrack → doNotTrackMode (bool) if ( isset( $consent_raw['doNotTrack'] ) ) $config->doNotTrackMode = (bool) (int) $consent_raw['doNotTrack']; // Consent mode flags (google/facebook/microsoft) — sent in design_raw from the React POST // but must be placed in config{} as booleans. The PATCH /by-app endpoint rejects them in design{}. foreach ( [ 'googleConsentMode', 'facebookConsentMode', 'microsoftConsentMode' ] as $mode_field ) { if ( isset( $design_raw[ $mode_field ] ) ) $config->$mode_field = (bool) (int) $design_raw[ $mode_field ]; } // Build params — only include non-empty objects $params = [ 'AppID' => $app_id, 'DefaultLanguage' => 'en', 'text' => (object) [ 'privacyPolicyUrl' => get_privacy_policy_url() ], ]; if ( ! empty( (array) $design ) ) $params['design'] = $design; if ( ! empty( (array) $config ) ) $params['config'] = $config; $write_type = $this->get_write_request_type( $app_id ); // PATCH /by-app endpoint does not accept DefaultLanguage -- strip it. if ( $write_type === 'patch_by_app' ) { unset( $params['DefaultLanguage'] ); } // DevMode mock ID — return synthetic success so the UI can be tested without a real API. if ( $write_type === 'devmode' ) { wp_send_json_success( [ 'status' => 200, 'dev_mode' => true ] ); return; } $result = $this->request( $write_type, $params ); // Temporary diagnostic — log raw API response for consent mode debugging. error_log( 'react_update_design API result: ' . var_export( $result, true ) ); // Design record not yet created — fall back to quick_config to seed it. // The API returns { i18n_msg: 'user_design_update_id_not_found', status: 400 } (HTTP 200) // when no record exists, so check i18n_msg — not statusCode/404. // Also restore DefaultLanguage which patch_by_app doesn't accept but quick_config requires. if ( is_object( $result ) && isset( $result->i18n_msg ) && $result->i18n_msg === 'user_design_update_id_not_found' ) { $params['DefaultLanguage'] = 'en'; $result = $this->request( 'quick_config', $params ); } if ( is_object( $result ) && isset( $result->status ) && $result->status === 200 ) { // Pull confirmed state from portal — makes portal unambiguous SoT. // Updates cookie_notice_app_blocking (GCM/GPC signal maps), // fires cn_configuration_updated → clears page caches. // Does NOT set cookie_notice_config_update transient (widget CDN cache). $this->get_app_config( $app_id, true, true ); wp_send_json_success( [ 'status' => 200 ] ); } else { $error = 'Design update failed.'; if ( is_array( $result ) && ! empty( $result['error'] ) ) $error = $result['error']; elseif ( is_object( $result ) && ! empty( $result->message ) ) $error = $result->message; elseif ( is_object( $result ) && ! empty( $result->error ) ) $error = $result->error; elseif ( is_object( $result ) && ! empty( $result->i18n_msg ) ) $error = 'API error: ' . $result->i18n_msg; elseif ( $result === null ) $error = 'No response from API — check connection.'; wp_send_json_error( [ 'error' => $error, 'apiSync' => false ] ); } } /** * AJAX: Apply languages via quick_config. * * POST fields: languages[] (array of language codes) * Server-side enforcement of free plan 1-language limit. * * @return void */ public function react_apply_languages() { $this->verify_react_request(); $cn = Cookie_Notice(); $app_id = $cn->options['general']['app_id']; if ( empty( $app_id ) ) { wp_send_json_error( [ 'error' => 'No app connected.' ] ); } $languages_raw = isset( $_POST['languages'] ) && is_array( $_POST['languages'] ) ? $_POST['languages'] : []; // Sanitize and validate language codes (2-letter ISO 639-1) $allowed_languages = [ 'fr', 'es', 'de', 'it', 'el', 'nl', 'pt', 'pl', 'sv' ]; $languages = []; foreach ( $languages_raw as $lang ) { $lang = sanitize_key( $lang ); if ( in_array( $lang, $allowed_languages, true ) ) $languages[] = $lang; } // Free plan: enforce 1-language limit $subscription = $cn->get_subscription(); $status = $cn->get_status(); $is_free = ( $status === 'active' && $subscription === 'basic' ); if ( $is_free && count( $languages ) > 1 ) $languages = array_slice( $languages, 0, 1 ); $params = [ 'AppID' => $app_id, 'DefaultLanguage' => 'en', 'languages' => $languages, ]; $write_type = $this->get_write_request_type( $app_id ); // PATCH /by-app endpoint does not accept DefaultLanguage -- strip it. if ( $write_type === 'patch_by_app' ) { unset( $params['DefaultLanguage'] ); } // DevMode mock ID — return synthetic success so the UI can be tested without a real API. if ( $write_type === 'devmode' ) { wp_send_json_success( [ 'status' => 200, 'languages' => $languages, 'dev_mode' => true ] ); return; } $result = $this->request( $write_type, $params ); // Design record not yet created — fall back to quick_config to seed it. // The API returns { i18n_msg: 'user_design_update_id_not_found', status: 400 } (HTTP 200) // when no record exists, so check i18n_msg — not statusCode/404. // Also restore DefaultLanguage which patch_by_app doesn't accept but quick_config requires. if ( is_object( $result ) && isset( $result->i18n_msg ) && $result->i18n_msg === 'user_design_update_id_not_found' ) { $params['DefaultLanguage'] = 'en'; $result = $this->request( 'quick_config', $params ); } if ( is_object( $result ) && isset( $result->status ) && $result->status === 200 ) { // Persist applied languages locally so the dashboard can reflect the real count. $network = is_multisite() && $cn->is_plugin_network_active() && $cn->network_options['general']['global_override']; if ( $network ) update_site_option( 'cookie_notice_app_languages', $languages ); else update_option( 'cookie_notice_app_languages', $languages, false ); wp_send_json_success( [ 'status' => 200, 'languages' => $languages ] ); } else { $error = 'Language update failed.'; if ( is_array( $result ) && ! empty( $result['error'] ) ) $error = $result['error']; elseif ( is_object( $result ) && ! empty( $result->message ) ) $error = $result->message; wp_send_json_error( [ 'error' => $error, 'apiSync' => false ] ); } } }