File: /home/royaltuning/www/public/wp-content/plugins/cookie-notice/includes/react-admin-ajax.php
<?php
// exit if accessed directly
if ( ! defined( 'ABSPATH' ) )
exit;
/**
* Cookie_Notice_React_Admin_Ajax class.
*
* Provides the PHP AJAX backend for the React admin UI.
* Registers six wp_ajax_ actions consumed by the React admin bundle.
*
* @class Cookie_Notice_React_Admin_Ajax
* @package Cookie_Notice
*/
class Cookie_Notice_React_Admin_Ajax {
/**
* Class constructor.
*
* @return void
*/
public function __construct() {
// Read-only hooks — always available regardless of ui_mode.
add_action( 'wp_ajax_cn_react_dashboard', [ $this, 'get_dashboard' ] );
add_action( 'wp_ajax_cn_react_config', [ $this, 'get_config' ] );
add_action( 'wp_ajax_cn_react_consent_logs', [ $this, 'get_consent_logs' ] );
add_action( 'wp_ajax_cn_react_export_consent_logs', [ $this, 'export_consent_logs' ] );
add_action( 'wp_ajax_cn_get_api_environment', [ $this, 'get_api_environment' ] );
// Write hooks — only register when ui_mode is "react" (#2267).
// In legacy mode the PHP form path handles writes; registering these
// would allow stale React JS (cached by a CDN or browser) to race
// against the legacy form submit.
$ui_mode = Cookie_Notice()->options['general']['ui_mode'] ?? 'legacy';
if ( $ui_mode === 'react' ) {
add_action( 'wp_ajax_cn_react_script_update', [ $this, 'update_script' ] );
add_action( 'wp_ajax_cn_react_save_options', [ $this, 'save_options' ] );
add_action( 'wp_ajax_cn_react_rescan_scripts', [ $this, 'rescan_scripts' ] );
add_action( 'wp_ajax_cn_react_rule_values', [ $this, 'get_rule_values' ] );
}
// Mode-agnostic state hooks — welcome dismissal and setup wizard
// completion must work regardless of ui_mode.
add_action( 'wp_ajax_cn_react_dismiss_welcome', [ $this, 'dismiss_welcome' ] );
add_action( 'wp_ajax_cn_react_complete_setup_wizard', [ $this, 'complete_setup_wizard' ] );
// Dev harness only — CN_DEV_MODE is NOT an environment switch (it does not control
// which API environment the plugin targets). Use CN_APP_HOST_URL, CN_APP_WIDGET_URL,
// CN_ACCOUNT_API_URL etc. for that. CN_DEV_MODE enables developer-only UI tooling
// (usage override, dev_reset) and should never be set on production or staging servers.
if ( defined( 'CN_DEV_MODE' ) && CN_DEV_MODE ) {
add_action( 'wp_ajax_cn_react_dev_reset', [ $this, 'dev_reset' ] );
add_action( 'wp_ajax_cn_react_test_set_option', [ $this, 'test_set_option' ] );
add_action( 'wp_ajax_cn_react_test_get_option', [ $this, 'test_get_option' ] );
}
}
/**
* Verify request nonce and capability.
*
* Sends a JSON error and exits when the check fails, so handlers can call
* this at the top without needing to check the return value.
*
* @return void
*/
private function verify_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.' ] );
}
}
/**
* Return dashboard data for the Protection tab.
*
* @return void
*/
public function get_dashboard() {
$this->verify_request();
$cn = Cookie_Notice();
// --- Read cached analytics option ---
// Single source: cookie_notice_app_analytics (refreshed hourly via welcome-api.php cron).
// ⚠️ Multisite pattern: use site_option ONLY when network-active with global_override.
// Do NOT simplify to is_multisite() alone — pattern matches welcome-api.php get_app_config().
$network = $cn->is_network_options();
$analytics_raw = $network
? get_site_option( 'cookie_notice_app_analytics', [] )
: get_option( 'cookie_notice_app_analytics', [] );
// --- Cycle usage (visits vs threshold) ---
// Read from cached analytics option; CN_DEV_MODE overrides for UI testing.
$visits = ! empty( $analytics_raw['cycleUsage']->visits ) ? (int) $analytics_raw['cycleUsage']->visits : 0;
$threshold = ! empty( $analytics_raw['cycleUsage']->threshold ) ? (int) $analytics_raw['cycleUsage']->threshold : 0;
// CN_DEV_MODE: honour cn_usage=0-100 (forwarded as POST field by fetchDashboard
// since admin-ajax.php is a POST endpoint and $_GET params from the page URL
// are not available here).
if ( defined( 'CN_DEV_MODE' ) && CN_DEV_MODE && isset( $_POST['cn_usage'] ) ) {
$pct = max( 0, min( 100, (int) $_POST['cn_usage'] ) );
$threshold = $threshold > 0 ? $threshold : 1000;
$visits = (int) round( $threshold * ( $pct / 100 ) );
}
// --- ConsentStats breakdown ---
$level_totals = [ 1 => 0, 2 => 0, 3 => 0 ];
if ( ! empty( $analytics_raw['consentActivities'] ) && is_array( $analytics_raw['consentActivities'] ) ) {
foreach ( $analytics_raw['consentActivities'] as $entry ) {
$lvl = (int) $entry->consentlevel;
if ( isset( $level_totals[ $lvl ] ) ) {
$level_totals[ $lvl ] += (int) $entry->totalrecd;
}
}
}
$consent_breakdown = $this->compute_consent_breakdown( $level_totals );
// Regulations saved locally by cn_api_request?configure action.
// Exposed here so Protection.jsx LAWS card can display them without a
// Designer API round-trip. (#1897)
$reg_keys = $network
? get_site_option( 'cookie_notice_app_regulations', [] )
: get_option( 'cookie_notice_app_regulations', [] );
$regulations = array_fill_keys( (array) $reg_keys, true );
// Language codes saved locally by react_apply_languages() on successful API write. (#1966)
// Always includes 'en' (default) + any additional codes the user configured.
$saved_languages = $network
? get_site_option( 'cookie_notice_app_languages', [] )
: get_option( 'cookie_notice_app_languages', [] );
$language = array_values( array_unique( array_merge( [ 'en' ], (array) $saved_languages ) ) );
// Platform account email from login token (#2168).
// Stored in cookie_notice_app_token transient as ->email after successful login.
// Used in PortalBridgeModal to tell the user which email to sign in with.
// Returns empty string when not connected (token not set or expired).
$data_token = $network
? get_site_transient( 'cookie_notice_app_token' )
: get_transient( 'cookie_notice_app_token' );
$account_email = ! empty( $data_token->email ) ? sanitize_email( $data_token->email ) : '';
// Banner design fields cached by get_app_config() — React computes
// the active template on the fly by matching against PRESETS.
$design = $network
? get_site_option( 'cookie_notice_app_design', [] )
: get_option( 'cookie_notice_app_design', [] );
wp_send_json_success( [
'analytics' => [
'cycleUsage' => [
'visits' => $visits,
'threshold' => $threshold,
],
],
'consentBreakdown' => $consent_breakdown,
'domainUrl' => home_url(),
'appId' => $cn->options['general']['app_id'],
'activatedAt' => isset( $cn->status_data['activation_datetime'] ) ? $cn->status_data['activation_datetime'] : 0,
'consentCount' => $consent_breakdown['total'],
'accountEmail' => $account_email,
'appConfig' => [
'regulations' => $regulations,
'language' => $language,
'design' => $design,
],
] );
}
/**
* Return blocking/consent configuration data.
*
* Reads the cached Designer API config from the cookie_notice_app_blocking WP option
* (populated by welcome-api.php get_app_config() on admin page load, on the
* 24h cron, or via "Pull Configuration" button). Falls back to an empty stub
* for new installs.
*
* @return void
*/
public function get_config() {
$this->verify_request();
$cn = Cookie_Notice();
// ⚠️ Same multisite pattern as get_dashboard() — see comment there.
$network = $cn->is_network_options();
$blocking = $network
? get_site_option( 'cookie_notice_app_blocking', [] )
: get_option( 'cookie_notice_app_blocking', [] );
wp_send_json_success( $this->build_blocking_response( $blocking ) );
}
/**
* Return paginated consent log entries.
*
* Calls the Transactional API via welcome-api.php for the requested date,
* maps each record to the shape expected by ConsentLogTable.jsx, then
* applies in-PHP pagination (10 records per page).
*
* POST params accepted:
* page int Page number (1-based, default 1)
* start_date string Date to fetch logs for (Y-m-d, default today)
* sort string Sort column key (ignored server-side — API returns ordered data)
* order string 'asc' | 'desc' (ignored server-side)
*
* @return void
*/
public function get_consent_logs() {
$this->verify_request();
$page = isset( $_POST['page'] ) ? max( 1, absint( $_POST['page'] ) ) : 1;
$start_date = isset( $_POST['start_date'] ) ? sanitize_text_field( $_POST['start_date'] ) : date( 'Y-m-d' );
$end_date = isset( $_POST['end_date'] ) ? sanitize_text_field( $_POST['end_date'] ) : $start_date;
$per_page = 10;
// Validate date formats (Y-m-d).
$dt = DateTime::createFromFormat( 'Y-m-d', $start_date );
if ( ! $dt || $dt->format( 'Y-m-d' ) !== $start_date ) {
$start_date = date( 'Y-m-d' );
}
$dt_end = DateTime::createFromFormat( 'Y-m-d', $end_date );
if ( ! $dt_end || $dt_end->format( 'Y-m-d' ) !== $end_date || $end_date < $start_date ) {
$end_date = $start_date;
}
$cn = Cookie_Notice();
// Server-side range cap — free = 7 days, pro = 90 days.
$max_range = ( $cn->get_subscription() === 'pro' ) ? 90 : 7;
$range = (int) ( ( new DateTime( $end_date ) )->diff( new DateTime( $start_date ) )->days );
if ( $range > $max_range ) {
$end_date = ( new DateTime( $start_date ) )->modify( "+{$max_range} days" )->format( 'Y-m-d' );
}
$empty_breakdown = [ 'total' => 0, 'acceptRate' => 0, 'customRate' => 0, 'rejectRate' => 0, 'levelLabels' => $this->get_level_labels() ];
// No app_id means not connected — return empty gracefully.
if ( empty( $cn->options['general']['app_id'] ) ) {
wp_send_json_success( [
'logs' => [],
'total' => 0,
'page' => $page,
'totalPages' => 0,
'consentBreakdown' => $empty_breakdown,
] );
return;
}
// Single API call for the full date range (Transactional API handles range via EndDate).
$raw = $cn->welcome_api->get_cookie_consent_logs( $start_date, $end_date );
if ( ! is_array( $raw ) || empty( $raw ) ) {
wp_send_json_success( [
'logs' => [],
'total' => 0,
'page' => $page,
'totalPages' => 0,
'consentBreakdown' => $empty_breakdown,
] );
return;
}
// Transform raw API records into UI-ready log entries.
$result = $this->transform_consent_logs( $raw, $cn );
$logs = $result['logs'];
$total = count( $logs );
$total_pages = (int) ceil( $total / $per_page );
$offset = ( $page - 1 ) * $per_page;
$paged = array_slice( $logs, $offset, $per_page );
wp_send_json_success( [
'logs' => $paged,
'total' => $total,
'page' => $page,
'totalPages' => $total_pages,
'consentBreakdown' => $result['consent_breakdown'],
] );
}
/**
* Add, edit, or remove a script provider.
*
* For 'edit' operations, updates the provider's CategoryID and propagates
* the change to all patterns belonging to that provider.
*
* @return void
*/
public function update_script() {
$this->verify_request();
$operation = isset( $_POST['operation'] ) ? sanitize_text_field( $_POST['operation'] ) : '';
if ( ! in_array( $operation, [ 'add', 'edit', 'remove' ], true ) ) {
wp_send_json_error( [ 'error' => 'Invalid operation.' ] );
}
if ( $operation === 'edit' ) {
$provider_id = isset( $_POST['provider_id'] ) ? sanitize_text_field( $_POST['provider_id'] ) : '';
$category_id = isset( $_POST['category_id'] ) ? absint( $_POST['category_id'] ) : 0;
if ( empty( $provider_id ) ) {
wp_send_json_error( [ 'error' => 'Missing provider_id.' ] );
}
if ( ! in_array( $category_id, [ 1, 2, 3, 4 ], true ) ) {
wp_send_json_error( [ 'error' => 'Invalid category_id.' ] );
}
$cn = Cookie_Notice();
$network = $cn->is_network_options();
$blocking = $network
? get_site_option( 'cookie_notice_app_blocking', [] )
: get_option( 'cookie_notice_app_blocking', [] );
if ( empty( $blocking ) || ! isset( $blocking['providers'] ) ) {
wp_send_json_error( [ 'error' => 'No blocking configuration found.' ] );
}
// Update the provider's CategoryID.
$found = false;
foreach ( $blocking['providers'] as &$provider ) {
$pid = is_object( $provider ) ? $provider->ProviderID : ( isset( $provider['ProviderID'] ) ? $provider['ProviderID'] : '' );
if ( (string) $pid === (string) $provider_id ) {
if ( is_object( $provider ) ) {
$provider->CategoryID = $category_id;
} else {
$provider['CategoryID'] = $category_id;
}
$found = true;
break;
}
}
unset( $provider );
if ( ! $found ) {
wp_send_json_error( [ 'error' => 'Provider not found.' ] );
}
// Propagate CategoryID to all patterns belonging to this provider.
if ( isset( $blocking['patterns'] ) && is_array( $blocking['patterns'] ) ) {
foreach ( $blocking['patterns'] as &$pattern ) {
$pat_pid = is_object( $pattern ) ? $pattern->ProviderID : ( isset( $pattern['ProviderID'] ) ? $pattern['ProviderID'] : '' );
if ( (string) $pat_pid === (string) $provider_id ) {
if ( is_object( $pattern ) ) {
$pattern->CategoryID = $category_id;
} else {
$pattern['CategoryID'] = $category_id;
}
}
}
unset( $pattern );
}
// Save back.
if ( $network ) {
update_site_option( 'cookie_notice_app_blocking', $blocking );
} else {
update_option( 'cookie_notice_app_blocking', $blocking );
}
}
if ( $operation === 'add' ) {
$provider_name = isset( $_POST['provider_name'] ) ? sanitize_text_field( $_POST['provider_name'] ) : '';
$provider_url = isset( $_POST['provider_url'] ) ? esc_url_raw( $_POST['provider_url'] ) : '';
$category_id = isset( $_POST['category_id'] ) ? absint( $_POST['category_id'] ) : 0;
$description = isset( $_POST['description'] ) ? sanitize_text_field( $_POST['description'] ) : '';
$script_patterns = isset( $_POST['script_patterns'] ) && is_array( $_POST['script_patterns'] ) ? $_POST['script_patterns'] : [];
$iframe_patterns = isset( $_POST['iframe_patterns'] ) && is_array( $_POST['iframe_patterns'] ) ? $_POST['iframe_patterns'] : [];
if ( empty( $provider_name ) ) {
wp_send_json_error( [ 'error' => 'Provider name is required.' ] );
}
if ( ! in_array( $category_id, [ 1, 2, 3, 4 ], true ) ) {
wp_send_json_error( [ 'error' => 'Invalid category_id.' ] );
}
$cn = Cookie_Notice();
$network = $cn->is_network_options();
$blocking = $network
? get_site_option( 'cookie_notice_app_blocking', [] )
: get_option( 'cookie_notice_app_blocking', [] );
if ( ! is_array( $blocking ) ) {
$blocking = [];
}
if ( ! isset( $blocking['providers'] ) ) {
$blocking['providers'] = [];
}
if ( ! isset( $blocking['patterns'] ) ) {
$blocking['patterns'] = [];
}
// Generate a unique provider ID from the name + timestamp.
$provider_id = 'custom-' . sanitize_title( $provider_name ) . '-' . time();
// Append the new provider.
$blocking['providers'][] = (object) [
'ProviderID' => $provider_id,
'ProviderName' => $provider_name,
'ProviderURL' => $provider_url,
'CategoryID' => $category_id,
'IsCustom' => true,
];
// Find current max CookieID so new patterns get unique IDs.
$max_cookie_id = 0;
foreach ( $blocking['patterns'] as $p ) {
$cid = is_object( $p ) ? (int) $p->CookieID : (int) ( isset( $p['CookieID'] ) ? $p['CookieID'] : 0 );
if ( $cid > $max_cookie_id ) {
$max_cookie_id = $cid;
}
}
// Append script patterns.
foreach ( $script_patterns as $pattern_str ) {
$pattern_str = sanitize_text_field( stripslashes( $pattern_str ) );
if ( empty( $pattern_str ) ) {
continue;
}
$max_cookie_id++;
$blocking['patterns'][] = (object) [
'CookieID' => $max_cookie_id,
'ProviderID' => $provider_id,
'CategoryID' => $category_id,
'PatternType' => 'script',
'PatternFormat' => 'wildcard',
'Pattern' => $pattern_str,
];
}
// Append iframe patterns.
foreach ( $iframe_patterns as $pattern_str ) {
$pattern_str = sanitize_text_field( stripslashes( $pattern_str ) );
if ( empty( $pattern_str ) ) {
continue;
}
$max_cookie_id++;
$blocking['patterns'][] = (object) [
'CookieID' => $max_cookie_id,
'ProviderID' => $provider_id,
'CategoryID' => $category_id,
'PatternType' => 'iframe',
'PatternFormat' => 'wildcard',
'Pattern' => $pattern_str,
];
}
if ( $network ) {
update_site_option( 'cookie_notice_app_blocking', $blocking );
} else {
update_option( 'cookie_notice_app_blocking', $blocking );
}
wp_send_json_success( [
'message' => 'Script provider added.',
'provider_id' => $provider_id,
] );
}
if ( $operation === 'remove' ) {
$provider_id = isset( $_POST['provider_id'] ) ? sanitize_text_field( $_POST['provider_id'] ) : '';
if ( empty( $provider_id ) ) {
wp_send_json_error( [ 'error' => 'Missing provider_id.' ] );
}
$cn = Cookie_Notice();
$network = $cn->is_network_options();
$blocking = $network
? get_site_option( 'cookie_notice_app_blocking', [] )
: get_option( 'cookie_notice_app_blocking', [] );
if ( empty( $blocking ) || ! isset( $blocking['providers'] ) ) {
wp_send_json_error( [ 'error' => 'No blocking configuration found.' ] );
}
// Remove the provider entry.
$blocking['providers'] = array_values( array_filter( $blocking['providers'], function( $p ) use ( $provider_id ) {
$pid = is_object( $p ) ? $p->ProviderID : ( isset( $p['ProviderID'] ) ? $p['ProviderID'] : '' );
return (string) $pid !== (string) $provider_id;
} ) );
// Remove all patterns belonging to this provider.
if ( isset( $blocking['patterns'] ) && is_array( $blocking['patterns'] ) ) {
$blocking['patterns'] = array_values( array_filter( $blocking['patterns'], function( $p ) use ( $provider_id ) {
$pid = is_object( $p ) ? $p->ProviderID : ( isset( $p['ProviderID'] ) ? $p['ProviderID'] : '' );
return (string) $pid !== (string) $provider_id;
} ) );
}
if ( $network ) {
update_site_option( 'cookie_notice_app_blocking', $blocking );
} else {
update_option( 'cookie_notice_app_blocking', $blocking );
}
wp_send_json_success( [ 'message' => 'Script provider removed.' ] );
}
wp_send_json_success( [ 'message' => 'Script provider updated.' ] );
}
/**
* Transform raw API consent log records into structured log entries.
*
* Shared by get_consent_logs() (paginated table) and export_consent_logs() (CSV).
* Returns both the transformed log entries and the consent breakdown stats.
*
* @param array $raw Raw records from the Transactional API.
* @param Cookie_Notice_Main $cn Plugin instance.
* @return array { 'logs' => array, 'consent_breakdown' => array }
*/
private function transform_consent_logs( $raw, $cn ) {
// Compute consent breakdown from real-time data.
$level_counts = [ 1 => 0, 2 => 0, 3 => 0 ];
foreach ( $raw as $record ) {
$lvl = isset( $record->ev_consentlevel ) ? (int) $record->ev_consentlevel : 0;
if ( isset( $level_counts[ $lvl ] ) ) {
$level_counts[ $lvl ]++;
}
}
$consent_breakdown = $this->compute_consent_breakdown( $level_counts );
// Consent level integer → human label (matches ConsentLogTable pill styles).
$labels = $this->get_level_labels();
$level_map = [
1 => $labels['level1'],
2 => $labels['level2'],
3 => $labels['level3'],
];
$logs = [];
foreach ( $raw as $record ) {
$categories = [];
if ( ! empty( $record->ev_essential ) )
$categories[] = 'Essential';
if ( ! empty( $record->ev_analytics ) )
$categories[] = 'Analytics';
if ( ! empty( $record->ev_marketing ) )
$categories[] = 'Marketing';
if ( ! empty( $record->ev_functional ) )
$categories[] = 'Functional';
$level = isset( $record->ev_consentlevel ) ? (int) $record->ev_consentlevel : 0;
// Format timestamp to readable date/time.
$date_str = '';
if ( ! empty( $record->timestamp ) ) {
try {
$ts = new DateTime( $record->timestamp );
$date_str = $ts->format( 'Y-m-d H:i' ) . ' GMT';
} catch ( Exception $e ) {
$date_str = $record->timestamp;
}
}
$logs[] = [
'id' => isset( $record->ev_eventdetails_consentid ) ? $record->ev_eventdetails_consentid : '',
'level' => isset( $level_map[ $level ] ) ? $level_map[ $level ] : $labels['level2'],
'levelNum' => $level,
'categories' => $categories,
'date' => $date_str,
'ip' => isset( $record->rj_ip ) ? $record->rj_ip : '',
];
}
return [
'logs' => $logs,
'consent_breakdown' => $consent_breakdown,
];
}
/**
* Export consent logs as a downloadable CSV.
*
* Reuses transform_consent_logs() for data transformation, then formats
* the result as CSV and returns it as a string for browser download.
* Pro-only: enforced server-side (client-side TierGate is not sufficient).
*
* POST params accepted:
* start_date string Range start (Y-m-d, default today)
* end_date string Range end (Y-m-d, default start_date)
*
* @return void
*/
public function export_consent_logs() {
$this->verify_request();
$cn = Cookie_Notice();
// Server-side Pro gate — TierGate in React is client-only.
if ( $cn->get_subscription() !== 'pro' ) {
wp_send_json_error( [ 'error' => 'CSV export requires a Pro subscription.' ] );
return;
}
$start_date = isset( $_POST['start_date'] ) ? sanitize_text_field( $_POST['start_date'] ) : date( 'Y-m-d' );
$end_date = isset( $_POST['end_date'] ) ? sanitize_text_field( $_POST['end_date'] ) : $start_date;
// Validate date formats (Y-m-d).
$dt = DateTime::createFromFormat( 'Y-m-d', $start_date );
if ( ! $dt || $dt->format( 'Y-m-d' ) !== $start_date ) {
$start_date = date( 'Y-m-d' );
}
$dt_end = DateTime::createFromFormat( 'Y-m-d', $end_date );
if ( ! $dt_end || $dt_end->format( 'Y-m-d' ) !== $end_date || $end_date < $start_date ) {
$end_date = $start_date;
}
// Server-side range cap — Pro = 90 days.
$range = (int) ( ( new DateTime( $end_date ) )->diff( new DateTime( $start_date ) )->days );
if ( $range > 90 ) {
$end_date = ( new DateTime( $start_date ) )->modify( '+90 days' )->format( 'Y-m-d' );
}
// No app_id means not connected — return empty.
if ( empty( $cn->options['general']['app_id'] ) ) {
wp_send_json_success( [ 'csv' => '', 'count' => 0 ] );
return;
}
$raw = $cn->welcome_api->get_cookie_consent_logs( $start_date, $end_date );
if ( ! is_array( $raw ) || empty( $raw ) ) {
wp_send_json_success( [ 'csv' => '', 'count' => 0 ] );
return;
}
$result = $this->transform_consent_logs( $raw, $cn );
$logs = $result['logs'];
// Build CSV string.
$csv_lines = [];
$csv_lines[] = 'Consent ID,Level,Date,IP,Categories';
foreach ( $logs as $log ) {
$csv_lines[] = sprintf(
'"%s","%s","%s","%s","%s"',
str_replace( '"', '""', $log['id'] ),
str_replace( '"', '""', $log['level'] ),
str_replace( '"', '""', $log['date'] ),
str_replace( '"', '""', $log['ip'] ),
str_replace( '"', '""', implode( '; ', $log['categories'] ) )
);
}
wp_send_json_success( [
'csv' => implode( "\n", $csv_lines ),
'count' => count( $logs ),
] );
}
/**
* Rescan scripts from the Designer API.
*
* Forces a fresh fetch of the app blocking config from the remote
* Designer API, then returns the updated blocking data in the same
* shape as get_config().
*
* @return void
*/
public function rescan_scripts() {
$this->verify_request();
$cn = Cookie_Notice();
// Force a fresh sync from the Designer API.
$cn->welcome_api->get_app_config( '', true );
// Re-read the now-updated local cache and return it.
$network = $cn->is_network_options();
$blocking = $network
? get_site_option( 'cookie_notice_app_blocking', [] )
: get_option( 'cookie_notice_app_blocking', [] );
// CN_DEV_MODE: inject sample trackers when the real scan returns empty,
// so the UI can be tested without real third-party scripts on the page.
if ( defined( 'CN_DEV_MODE' ) && CN_DEV_MODE && empty( $blocking['providers'] ) ) {
$sample_providers = [
(object) [ 'ProviderID' => 'google-analytics', 'ProviderName' => 'Google Analytics', 'ProviderURL' => 'analytics.google.com', 'CategoryID' => 0 ],
(object) [ 'ProviderID' => 'hotjar', 'ProviderName' => 'Hotjar', 'ProviderURL' => 'hotjar.com', 'CategoryID' => 0 ],
(object) [ 'ProviderID' => 'meta-pixel', 'ProviderName' => 'Meta Pixel', 'ProviderURL' => 'facebook.com', 'CategoryID' => 0 ],
(object) [ 'ProviderID' => 'hubspot', 'ProviderName' => 'HubSpot', 'ProviderURL' => 'hubspot.com', 'CategoryID' => 1 ],
(object) [ 'ProviderID' => 'linkedin-insight', 'ProviderName' => 'LinkedIn Insight', 'ProviderURL' => 'linkedin.com', 'CategoryID' => 0 ],
];
if ( ! is_array( $blocking ) ) {
$blocking = [];
}
$blocking['providers'] = $sample_providers;
}
wp_send_json_success( $this->build_blocking_response( $blocking ) );
}
/**
* Save welcome modal dismissal timestamp.
*
* Called when the user closes the modal or clicks "Don't protect my business".
* Stores the timestamp so the modal won't re-appear for 30 days.
*
* @return void
*/
public function dismiss_welcome() {
$this->verify_request();
$cn = Cookie_Notice();
if ( $cn->is_network_admin() )
update_site_option( 'cookie_notice_welcome_dismissed', current_time( 'mysql' ) );
else
update_option( 'cookie_notice_welcome_dismissed', current_time( 'mysql' ) );
wp_send_json_success();
}
/**
* Mark the setup wizard as complete.
*
* Called when the user finishes (or skips) the FirstRunSetup wizard on the
* Settings tab. Persists a flag so the wizard doesn't re-appear.
*
* @return void
*/
public function complete_setup_wizard() {
$this->verify_request();
$cn = Cookie_Notice();
if ( $cn->is_network_admin() )
update_site_option( 'cookie_notice_setup_wizard_complete', true );
else
update_option( 'cookie_notice_setup_wizard_complete', true );
wp_send_json_success();
}
/**
* DEV ONLY: Reset all plugin onboarding state to simulate a fresh activation.
* Only registered as an AJAX action when CN_DEV_MODE is true.
*/
public function dev_reset() {
if ( ! defined( 'CN_DEV_MODE' ) || ! CN_DEV_MODE ) {
wp_send_json_error( [ 'error' => 'Not available outside CN_DEV_MODE.' ] );
}
$this->verify_request();
$cn = Cookie_Notice();
// --- Step 1: Delete the API-side app record BEFORE clearing WP options. (#1956)
//
// After a successful use_license or register+configure flow, the Account API creates
// an Application row for this domain. Deleting WP options alone does NOT remove it:
// - The app record consumes a subscription slot (distorts availablelicense counts)
// - Orphan apps accumulate across test runs
//
// We capture the current app_id from WP options, authenticate as the test account
// (whose credentials are defined via CN_DEV_TEST_EMAIL + CN_DEV_TEST_PASSWORD
// constants, falling back to env vars), then call POST /api/account/app/delete.
//
// This is best-effort: login or delete failures are logged but do NOT block the
// WP options reset — the reset must always succeed regardless of API availability.
$current_app_id = ! empty( $cn->options['general']['app_id'] ) ? $cn->options['general']['app_id'] : '';
if ( ! empty( $current_app_id ) ) {
$test_email = defined( 'CN_DEV_TEST_EMAIL' ) ? CN_DEV_TEST_EMAIL : getenv( 'CN_DEV_TEST_EMAIL' );
$test_password = defined( 'CN_DEV_TEST_PASSWORD' ) ? CN_DEV_TEST_PASSWORD : getenv( 'CN_DEV_TEST_PASSWORD' );
if ( ! empty( $test_email ) && ! empty( $test_password ) ) {
// Login to get a Bearer token, then delete the app.
$welcome_api = Cookie_Notice()->welcome;
$login_result = $welcome_api->request( 'login', [
'AdminID' => $test_email,
'Password' => $test_password,
] );
if ( ! empty( $login_result->data->token ) ) {
// Store the full data object (not just the token string) — request() reads
// $data_token->token so the shape must match what login normally stores.
set_transient( 'cookie_notice_app_token', $login_result->data, HOUR_IN_SECONDS );
$delete_result = $welcome_api->request( 'app_delete', [
'AppID' => $current_app_id,
] );
if ( $cn->options['general']['debug_mode'] ) {
error_log( '[Cookie Notice] dev_reset - app_delete result for ' . $current_app_id . ': ' . wp_json_encode( $delete_result ) );
}
} else {
if ( $cn->options['general']['debug_mode'] ) {
error_log( '[Cookie Notice] dev_reset - login failed for ' . $test_email . ', skipping app_delete.' );
}
}
}
}
// --- Step 2: Clear WP options (always runs regardless of API result above).
delete_option( 'cookie_notice_welcome_dismissed' );
delete_option( 'cookie_notice_setup_wizard_complete' );
$options = $cn->options['general'];
$options['app_id'] = '';
$options['app_key'] = '';
if ( is_multisite() ) {
update_site_option( 'cookie_notice_options', $options );
} else {
update_option( 'cookie_notice_options', $options );
}
$default_data = $cn->defaults['data'];
if ( is_multisite() ) {
update_site_option( 'cookie_notice_status', $default_data );
} else {
update_option( 'cookie_notice_status', $default_data );
}
// Clear transient caches
delete_transient( 'cookie_notice_app_quick_config' );
delete_site_transient( 'cookie_notice_app_quick_config' );
delete_transient( 'cookie_notice_app_token' );
delete_site_transient( 'cookie_notice_app_token' );
$deleted_app = ! empty( $current_app_id ) ? $current_app_id : null;
wp_send_json_success( [
'message' => 'Plugin reset to fresh-activation state.',
'deleted_app' => $deleted_app,
] );
}
/**
* DEV ONLY: Set a single allowlisted WP option by name.
* Used by Playwright tests to set fixture state without Docker/WP-CLI.
* Only registered as an AJAX action when CN_DEV_MODE is true.
*
* POST fields:
* option_name — one of the allowlisted option names below
* option_value — string value to store
*/
public function test_set_option() {
if ( ! defined( 'CN_DEV_MODE' ) || ! CN_DEV_MODE ) {
wp_send_json_error( [ 'error' => 'Not available outside CN_DEV_MODE.' ] );
}
$this->verify_request();
// Allowlist — only options the test suite legitimately needs to set.
$allowed = [
'cookie_notice_ui_mode',
'cookie_notice_status',
'cookie_notice_setup_wizard_complete',
'cookie_notice_welcome_dismissed',
'cookie_notice_options',
];
$option_name = isset( $_POST['option_name'] ) ? sanitize_key( $_POST['option_name'] ) : '';
if ( ! in_array( $option_name, $allowed, true ) ) {
wp_send_json_error( [ 'error' => 'Option not in allowlist: ' . $option_name ] );
}
// cookie_notice_options is stored as a PHP array — decode JSON input.
$raw_value = isset( $_POST['option_value'] ) ? wp_unslash( $_POST['option_value'] ) : '';
if ( $option_name === 'cookie_notice_options' ) {
$option_value = json_decode( $raw_value, true );
if ( ! is_array( $option_value ) ) {
wp_send_json_error( [ 'error' => 'cookie_notice_options must be valid JSON object.' ] );
}
} else {
$option_value = sanitize_text_field( $raw_value );
}
update_option( $option_name, $option_value );
wp_send_json_success( [ 'option' => $option_name, 'value' => $option_value ] );
}
/**
* DEV ONLY: Read a single allowlisted WP option by name.
* Used by Playwright tests to inspect persisted state without Docker/WP-CLI.
* Only registered as an AJAX action when CN_DEV_MODE is true.
*
* POST fields:
* option_name — one of the allowlisted option names below
*/
public function test_get_option() {
if ( ! defined( 'CN_DEV_MODE' ) || ! CN_DEV_MODE ) {
wp_send_json_error( [ 'error' => 'Not available outside CN_DEV_MODE.' ] );
}
$this->verify_request();
// Allowlist — only options the test suite legitimately needs to read.
$allowed = [
'cookie_notice_options',
'cookie_notice_status',
'cookie_notice_ui_mode',
'cookie_notice_setup_wizard_complete',
'cookie_notice_welcome_dismissed',
'cookie_notice_app_blocking',
'cookie_notice_app_design',
];
$option_name = isset( $_POST['option_name'] ) ? sanitize_key( $_POST['option_name'] ) : '';
if ( ! in_array( $option_name, $allowed, true ) ) {
wp_send_json_error( [ 'error' => 'Option not in allowlist: ' . $option_name ] );
}
$value = get_option( $option_name );
// Serialize arrays/objects so the test can inspect them as a string.
if ( is_array( $value ) || is_object( $value ) ) {
$value = wp_json_encode( $value );
}
wp_send_json_success( [ 'option' => $option_name, 'value' => (string) $value ] );
}
/**
* Save plugin options submitted from the React admin UI.
*
* Reads each recognized POST field, sanitizes it, and merges it into the
* existing options array before persisting via update_option() (single-site)
* or update_site_option() (network).
*
* @return void
*/
public function save_options() {
$this->verify_request();
$cn = Cookie_Notice();
$options = $cn->options['general'];
// Boolean fields.
$bool_fields = [
'refuse_opt',
'revoke_cookies',
'on_scroll',
'on_click',
'redirection',
'see_more',
'bot_detection',
'amp_support',
'caching_compatibility',
'debug_mode',
'conditional_active',
'deactivation_delete',
'app_blocking',
];
foreach ( $bool_fields as $field ) {
if ( isset( $_POST[ $field ] ) ) {
$options[ $field ] = (bool) $_POST[ $field ];
}
}
// Server-side threshold enforcement: cap app_blocking to false when
// the free-plan visit limit is exceeded, matching settings.php:1965.
if ( ! empty( $options['app_blocking'] ) && $cn->threshold_exceeded() ) {
$options['app_blocking'] = false;
}
// Text fields.
$text_fields = [
'message_text',
'accept_text',
'refuse_text',
'revoke_text',
'revoke_message_text',
'css_class',
];
foreach ( $text_fields as $field ) {
if ( isset( $_POST[ $field ] ) ) {
$options[ $field ] = sanitize_text_field( $_POST[ $field ] );
}
}
// Connection credential fields — sanitize_key strips to lowercase alphanumeric + dashes/underscores.
if ( isset( $_POST['app_id'] ) ) {
$options['app_id'] = sanitize_key( $_POST['app_id'] );
}
if ( isset( $_POST['app_key'] ) ) {
$options['app_key'] = sanitize_key( $_POST['app_key'] );
}
// Script blocking code fields — these can contain <script> tags,
// so use wp_unslash only (admin-only, manage_options cap verified).
if ( isset( $_POST['refuse_code'] ) ) {
$options['refuse_code'] = wp_unslash( $_POST['refuse_code'] );
}
if ( isset( $_POST['refuse_code_head'] ) ) {
$options['refuse_code_head'] = wp_unslash( $_POST['refuse_code_head'] );
}
// Excluded script handles — newline-separated string from React textarea → stored as array.
if ( isset( $_POST['excluded_handles'] ) ) {
$options['excluded_handles'] = array_values( array_filter( array_map( 'sanitize_text_field', explode( "\n", $_POST['excluded_handles'] ) ) ) );
}
// Conditional rules — JSON string from React → validated nested array.
if ( isset( $_POST['conditional_rules'] ) ) {
$raw_rules = json_decode( wp_unslash( $_POST['conditional_rules'] ), true );
if ( is_array( $raw_rules ) ) {
$settings = Cookie_Notice()->settings;
$group_id = 1;
$rules = [];
foreach ( $raw_rules as $group ) {
if ( ! is_array( $group ) || empty( $group ) ) {
continue;
}
$rule_id = 1;
foreach ( $group as $rule ) {
if ( ! is_array( $rule ) ) {
continue;
}
$param = sanitize_key( $rule['param'] ?? '' );
$operator = sanitize_key( $rule['operator'] ?? '' );
$value = $param === 'taxonomy_archive'
? ( $rule['value'] ?? '' )
: sanitize_key( $rule['value'] ?? '' );
if ( $param && $operator && $value !== '' && $settings->check_rule( $param, $operator, $value ) ) {
$rules[ $group_id ][ $rule_id++ ] = [
'param' => $param,
'operator' => $operator,
'value' => $value,
];
}
}
if ( ! empty( $rules[ $group_id ] ) ) {
$group_id++;
}
}
$options['conditional_rules'] = $rules;
} else {
$options['conditional_rules'] = [];
}
}
// Select fields — value must be one of the allowed options.
$select_fields = [
'revoke_cookies_opt' => [ 'automatic', 'manual' ],
'time' => [ 'hour', 'day', 'week', 'month', '3months', '6months', 'year', 'infinity' ],
'time_rejected' => [ 'hour', 'day', 'week', 'month', '3months', '6months', 'year', 'infinity' ],
'link_target' => [ '_blank', '_self' ],
'link_position' => [ 'banner', 'message' ],
'position' => [ 'top', 'bottom', 'left', 'right', 'popup' ],
'displayType' => [ 'fixed', 'floating' ],
'hide_effect' => [ 'none', 'fade', 'slide' ],
'script_placement' => [ 'header', 'footer' ],
'conditional_display' => [ 'hide', 'show' ],
'ui_mode' => [ 'react', 'legacy' ],
];
foreach ( $select_fields as $field => $allowed ) {
if ( isset( $_POST[ $field ] ) ) {
$value = sanitize_text_field( $_POST[ $field ] );
if ( in_array( $value, $allowed, true ) ) {
$options[ $field ] = $value;
}
}
}
// Number fields.
if ( isset( $_POST['on_scroll_offset'] ) ) {
$options['on_scroll_offset'] = absint( $_POST['on_scroll_offset'] );
}
// Nested colors array — text, button, bar, bar_opacity.
$color_fields = [ 'text', 'button', 'bar' ];
foreach ( $color_fields as $color_field ) {
$post_key = 'color_' . $color_field;
if ( isset( $_POST[ $post_key ] ) ) {
$val = sanitize_hex_color( $_POST[ $post_key ] );
if ( $val ) {
$options['colors'][ $color_field ] = $val;
}
}
}
// bar_opacity lives inside the nested colors array; clamp to 50–100.
if ( isset( $_POST['bar_opacity'] ) ) {
$bar_opacity = absint( $_POST['bar_opacity'] );
$bar_opacity = max( 50, min( 100, $bar_opacity ) );
$options['colors']['bar_opacity'] = $bar_opacity;
}
// Nested see_more_opt array.
if ( isset( $_POST['see_more_opt'] ) && is_array( $_POST['see_more_opt'] ) ) {
$raw = $_POST['see_more_opt'];
if ( isset( $raw['text'] ) ) {
$options['see_more_opt']['text'] = sanitize_text_field( $raw['text'] );
}
if ( isset( $raw['link_type'] ) ) {
$link_type = sanitize_text_field( $raw['link_type'] );
if ( in_array( $link_type, [ 'page', 'custom' ], true ) ) {
$options['see_more_opt']['link_type'] = $link_type;
}
}
if ( isset( $raw['id'] ) ) {
$options['see_more_opt']['id'] = absint( $raw['id'] );
}
if ( isset( $raw['link'] ) ) {
$options['see_more_opt']['link'] = esc_url_raw( $raw['link'] );
}
if ( isset( $raw['sync'] ) ) {
$options['see_more_opt']['sync'] = (bool) $raw['sync'];
}
}
// Enforce field ownership partition (#2264) — strip any key that is
// not declared in Cookie_Notice::$plugin_owned_fields. Nested sub-arrays
// (colors, see_more_opt, conditional_rules) are already in the allowlist.
$allowed = Cookie_Notice::$plugin_owned_fields;
foreach ( array_keys( $options ) as $key ) {
if ( ! in_array( $key, $allowed, true ) ) {
unset( $options[ $key ] );
}
}
// Persist — network vs. single-site.
if ( isset( $_POST['cn_network'] ) && $_POST['cn_network'] ) {
update_site_option( 'cookie_notice_options', $options );
} else {
update_option( 'cookie_notice_options', $options );
}
wp_send_json_success( [ 'message' => __( 'Settings saved.', 'cookie-notice' ) ] );
}
/**
* Return the active API environment URLs.
*
* Used by integration tests to verify that the WP instance is targeting
* stage APIs before any live API calls are made. Always registered —
* does not require CN_DEV_MODE.
*
* @return void
*/
/**
* Return conditional display rule values for a given parameter type.
*
* Called when the user changes the param dropdown in the rule builder.
* Returns a flat array of { value, label } objects (and optionally grouped).
*
* @return void
*/
public function get_rule_values() {
$this->verify_request();
$param = isset( $_POST['param'] ) ? sanitize_key( $_POST['param'] ) : '';
if ( ! $param ) {
wp_send_json_error( [ 'message' => 'Missing param' ] );
}
$values = [];
switch ( $param ) {
case 'page_type':
$values = [
[ 'value' => 'front', 'label' => __( 'Front Page', 'cookie-notice' ) ],
[ 'value' => 'home', 'label' => __( 'Home Page', 'cookie-notice' ) ],
];
break;
case 'page':
$pages = get_pages( [ 'post_status' => [ 'publish', 'private', 'future' ] ] );
$front = (int) get_option( 'page_on_front' );
$blog = (int) get_option( 'page_for_posts' );
foreach ( $pages as $page ) {
if ( $page->ID === $front || $page->ID === $blog ) {
continue;
}
$values[] = [ 'value' => (string) $page->ID, 'label' => $page->post_title ];
}
break;
case 'post_type':
$types = get_post_types( [ 'public' => true ], 'objects' );
foreach ( $types as $type ) {
$values[] = [ 'value' => $type->name, 'label' => $type->labels->singular_name ];
}
break;
case 'post_type_archive':
$types = get_post_types( [ 'public' => true, 'has_archive' => true ], 'objects' );
foreach ( $types as $type ) {
$values[] = [ 'value' => $type->name, 'label' => $type->labels->singular_name ];
}
break;
case 'user_type':
$values = [
[ 'value' => 'logged_in', 'label' => __( 'Logged in', 'cookie-notice' ) ],
[ 'value' => 'guest', 'label' => __( 'Guest', 'cookie-notice' ) ],
];
break;
case 'taxonomy_archive':
$taxonomies = get_taxonomies( [ 'public' => true ], 'objects' );
foreach ( $taxonomies as $taxonomy ) {
$terms = get_terms( [ 'taxonomy' => $taxonomy->name, 'hide_empty' => false ] );
if ( is_wp_error( $terms ) || empty( $terms ) ) {
continue;
}
$group = [
'group' => $taxonomy->labels->name,
'items' => [],
];
foreach ( $terms as $term ) {
$group['items'][] = [
'value' => $term->term_id . '|' . $taxonomy->name,
'label' => $term->name,
];
}
$values[] = $group;
}
break;
}
wp_send_json_success( [ 'values' => $values ] );
}
public function get_api_environment() {
$this->verify_request();
$cn = Cookie_Notice();
wp_send_json_success( [
'host' => $cn->get_url( 'host' ),
'account_api' => $cn->get_url( 'account_api' ),
'designer_api' => $cn->get_url( 'designer_api' ),
'transactional_api' => $cn->get_url( 'transactional_api' ),
'widget' => $cn->get_url( 'widget' ),
] );
}
/**
* Build the standardised blocking + config response shape.
*
* Shared by get_config() and rescan_scripts() to avoid maintaining the
* 7-key blocking object in two places.
*
* @param array $blocking Raw blocking option (cookie_notice_app_blocking).
* @return array { 'blocking' => [...], 'config' => object }
*/
private function build_blocking_response( $blocking ) {
if ( empty( $blocking ) ) {
return [
'blocking' => [
'providers' => [],
'patterns' => [],
'google_consent_default' => null,
'facebook_consent_default' => null,
'microsoft_consent_default' => null,
'gpc_support' => false,
'do_not_track' => false,
],
'config' => new stdClass(),
];
}
$config = isset( $blocking['banner_config'] ) && is_array( $blocking['banner_config'] )
? $blocking['banner_config']
: new stdClass();
return [
'blocking' => [
'providers' => isset( $blocking['providers'] ) ? $blocking['providers'] : [],
'patterns' => isset( $blocking['patterns'] ) ? $blocking['patterns'] : [],
'google_consent_default' => isset( $blocking['google_consent_default'] ) ? $blocking['google_consent_default'] : null,
'facebook_consent_default' => isset( $blocking['facebook_consent_default'] ) ? $blocking['facebook_consent_default'] : null,
'microsoft_consent_default' => isset( $blocking['microsoft_consent_default'] ) ? $blocking['microsoft_consent_default'] : null,
'gpc_support' => ! empty( $blocking['gpc_support'] ),
'do_not_track' => ! empty( $blocking['do_not_track'] ),
'lastUpdated' => isset( $blocking['lastUpdated'] ) ? $blocking['lastUpdated'] : '',
],
'config' => $config,
];
}
/**
* Compute consent breakdown (accept/custom/reject rates) from level totals.
*
* Shared by get_dashboard() and transform_consent_logs().
*
* @param array $level_totals Associative [ 1 => reject_count, 2 => custom_count, 3 => accept_count ].
* @return array { 'total' => int, 'acceptRate' => int, 'customRate' => int, 'rejectRate' => int, 'levelLabels' => array }
*/
private function compute_consent_breakdown( $level_totals ) {
$total = array_sum( $level_totals );
return [
'total' => $total,
'acceptRate' => $total > 0 ? round( $level_totals[3] / $total * 100 ) : 0,
'customRate' => $total > 0 ? round( $level_totals[2] / $total * 100 ) : 0,
'rejectRate' => $total > 0 ? round( $level_totals[1] / $total * 100 ) : 0,
'levelLabels' => $this->get_level_labels(),
];
}
/**
* Read customer-configured consent level labels from cached Designer API data.
*
* Labels are cached in cookie_notice_app_design by get_app_config() from
* DefaultUserTextJSON. Falls back to platform defaults if not yet cached.
*
* @return array { 'level1' => string, 'level2' => string, 'level3' => string }
*/
private function get_level_labels() {
$cn = Cookie_Notice();
$network = $cn->is_network_options();
$design = $network
? get_site_option( 'cookie_notice_app_design', [] )
: get_option( 'cookie_notice_app_design', [] );
return [
'level1' => ! empty( $design['levelNameText_1'] ) ? $design['levelNameText_1'] : 'Private',
'level2' => ! empty( $design['levelNameText_2'] ) ? $design['levelNameText_2'] : 'Balanced',
'level3' => ! empty( $design['levelNameText_3'] ) ? $design['levelNameText_3'] : 'Personalized',
];
}
}