Files
2026-04-28 15:13:50 +02:00

2754 lines
95 KiB
PHP
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
// exit if accessed directly
if ( ! defined( 'ABSPATH' ) )
exit;
/**
* Cookie_Notice_Welcome_API class.
*
* @class Cookie_Notice_Welcome_API
*/
class Cookie_Notice_Welcome_API {
/**
* Constructor.
*
* @return void
*/
public function __construct() {
// actions
add_action( 'init', [ $this, 'check_cron' ] );
add_action( 'cookie_notice_get_app_analytics', [ $this, 'get_app_analytics' ] );
add_action( 'cookie_notice_get_app_config', [ $this, 'get_app_config' ] );
add_action( 'wp_ajax_cn_api_request', [ $this, 'api_request' ] );
// React write hooks — only register when ui_mode is "react" (#2267).
$ui_mode = Cookie_Notice()->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: 1213 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 (04).
$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 ] );
}
}
}