plugin_name = $plugin_name; } public function get_table_name() { global $wpdb; return $wpdb->prefix . 'daau_stats_events'; } public function maybe_install() { $installed_version = get_option($this->plugin_name . '-stats-schema-version', ''); if ($installed_version !== self::SCHEMA_VERSION || ! $this->table_exists()) { $this->install_schema(); } } public function install_schema() { global $wpdb; require_once ABSPATH . 'wp-admin/includes/upgrade.php'; $table = $this->get_table_name(); $charset_collate = $wpdb->get_charset_collate(); $sql = "CREATE TABLE {$table} ( id BIGINT(20) UNSIGNED NOT NULL AUTO_INCREMENT, event_time_utc DATETIME NOT NULL, event_date DATE NOT NULL, visit_id VARCHAR(64) NOT NULL, visitor_hash CHAR(64) NOT NULL, event_type VARCHAR(20) NOT NULL, adblock_status VARCHAR(12) NOT NULL DEFAULT 'unknown', popup_id BIGINT(20) UNSIGNED NOT NULL DEFAULT 0, page_path VARCHAR(255) NOT NULL DEFAULT '', device_type VARCHAR(20) NOT NULL DEFAULT 'other', browser_family VARCHAR(40) NOT NULL DEFAULT 'other', PRIMARY KEY (id), UNIQUE KEY visit_event (visit_id,event_type), KEY event_date (event_date), KEY status_date (adblock_status,event_date), KEY type_date (event_type,event_date), KEY popup_date (popup_id,event_date), KEY page_date (page_path(191),event_date) ) {$charset_collate};"; dbDelta($sql); update_option($this->plugin_name . '-stats-schema-version', self::SCHEMA_VERSION); } public function schedule_cleanup() { if (! wp_next_scheduled('daau_stats_cleanup')) { wp_schedule_event(time() + HOUR_IN_SECONDS, 'daily', 'daau_stats_cleanup'); } } public function cleanup_old_events() { global $wpdb; if (! $this->table_exists()) { return; } $retention_days = max(1, absint(apply_filters('daau_stats_retention_days', 7))); $days_to_subtract = max(0, $retention_days - 1); $cutoff_date = gmdate('Y-m-d', strtotime("-{$days_to_subtract} days", current_time('timestamp', true))); $table = $this->get_table_name(); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- Scheduled mutation of the plugin's private analytics table. $wpdb->query( $wpdb->prepare('DELETE FROM %i WHERE event_date < %s', $table, $cutoff_date) ); } public function clear_all_events() { global $wpdb; if (! $this->table_exists()) { return true; } $table = $this->get_table_name(); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- Explicit administrator-requested purge of the plugin's private analytics table. $result = $wpdb->query($wpdb->prepare('DELETE FROM %i', $table)); return $result !== false; } public function ajax_track_event() { $this->maybe_install(); $nonce = isset($_POST['nonce']) ? sanitize_text_field(wp_unslash($_POST['nonce'])) : ''; if (! wp_verify_nonce($nonce, 'track-event')) { wp_send_json_error(array('message' => esc_html__('Invalid nonce.', 'adunblocker')), 403); } if (! $this->allow_public_event_request()) { wp_send_json_error(array('message' => esc_html__('Too many analytics requests. Please retry shortly.', 'adunblocker')), 429); } $payload = $this->sanitize_tracking_payload($_POST); if (is_wp_error($payload)) { wp_send_json_error(array('message' => $payload->get_error_message()), 400); } $result = $this->insert_event($payload); if ($result === false) { wp_send_json_error(array('message' => esc_html__('Failed to record event.', 'adunblocker')), 500); } wp_send_json_success(array('recorded' => $result)); } public function ajax_stats_overview() { if (! $this->can_manage_stats()) { wp_send_json_error(array('message' => esc_html__('Access denied.', 'adunblocker')), 403); } $nonce = isset($_POST['nonce']) ? sanitize_text_field(wp_unslash($_POST['nonce'])) : ''; if (! wp_verify_nonce($nonce, 'stats-overview')) { wp_send_json_error(array('message' => esc_html__('Invalid nonce.', 'adunblocker')), 403); } $this->maybe_install(); $trend = $this->get_detection_trend(7); $blocked = array_sum($trend['blocked']); $unblocked = array_sum($trend['unblocked']); $unknown = array_sum($trend['unknown']); $conclusive_checks = $blocked + $unblocked; $total_checks = $conclusive_checks + $unknown; $blocked_rate = $conclusive_checks > 0 ? round(($blocked / $conclusive_checks) * 100, 2) : 0; $blocked_visitors = $this->get_unique_event_count(self::EVENT_DETECTION, 7, 'blocked'); $popup_shown = $this->get_unique_event_count(self::EVENT_POPUP_SHOWN, 7); $popup_closed = $this->get_unique_event_count(self::EVENT_POPUP_CLOSED, 7); $adblocker_disabled = $this->get_unique_event_count(self::EVENT_ADBLOCKER_DISABLED, 7, 'unblocked'); $disabled_rate = $blocked_visitors > 0 ? round(($adblocker_disabled / $blocked_visitors) * 100, 2) : 0; wp_send_json_success(array( 'cards' => array( 'total_checks' => (int) $total_checks, 'blocked' => (int) $blocked, 'unblocked' => (int) $unblocked, 'unknown' => (int) $unknown, 'blocked_rate' => (float) $blocked_rate, 'blocked_visitors' => (int) $blocked_visitors, 'popup_shown' => (int) $popup_shown, 'popup_closed' => (int) $popup_closed, 'adblocker_disabled' => (int) $adblocker_disabled, 'disabled_rate' => (float) $disabled_rate, ), 'trend' => array( 'labels' => $trend['labels'], 'blocked' => $trend['blocked'], 'unblocked' => $trend['unblocked'], 'unknown' => $trend['unknown'], ), 'popup_templates' => $this->get_popup_template_performance(7), )); } public function ajax_clear_stats() { if (! $this->can_manage_stats()) { wp_send_json_error(array('message' => esc_html__('Access denied.', 'adunblocker')), 403); } $nonce = isset($_POST['nonce']) ? sanitize_text_field(wp_unslash($_POST['nonce'])) : ''; if (! wp_verify_nonce($nonce, 'clear-stats')) { wp_send_json_error(array('message' => esc_html__('Invalid nonce.', 'adunblocker')), 403); } $cleared = $this->clear_all_events(); if (! $cleared) { wp_send_json_error(array('message' => esc_html__('Unable to clear statistics.', 'adunblocker')), 500); } wp_send_json_success(array('cleared' => true)); } public function get_detection_trend($days) { global $wpdb; $days = max(1, absint($days)); $labels = $this->build_date_series($days); $blocked = array_fill(0, count($labels), 0); $unblocked = array_fill(0, count($labels), 0); $unknown = array_fill(0, count($labels), 0); if (! $this->table_exists()) { return array( 'labels' => $labels, 'blocked' => $blocked, 'unblocked' => $unblocked, 'unknown' => $unknown, ); } $table = $this->get_table_name(); $since = $labels[0]; // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- Live aggregate over the plugin's private analytics table. $rows = $wpdb->get_results( $wpdb->prepare( "SELECT event_date, adblock_status, COUNT(*) AS total FROM %i WHERE event_type = %s AND event_date >= %s GROUP BY event_date, adblock_status", $table, self::EVENT_DETECTION, $since ), ARRAY_A ); $position = array_flip($labels); foreach ($rows as $row) { $date_key = $row['event_date']; if (! isset($position[$date_key])) { continue; } $index = $position[$date_key]; $status = $row['adblock_status']; $count = (int) $row['total']; if ($status === 'blocked') { $blocked[$index] = $count; continue; } if ($status === 'unblocked') { $unblocked[$index] = $count; continue; } if ($status === 'unknown') { $unknown[$index] = $count; } } return array( 'labels' => $labels, 'blocked' => $blocked, 'unblocked' => $unblocked, 'unknown' => $unknown, ); } public function get_popup_template_performance($days) { global $wpdb; if (! $this->table_exists()) { return array(); } $days = max(1, absint($days)); $labels = $this->build_date_series($days); $since = $labels[0]; $table = $this->get_table_name(); $event_types = array(self::EVENT_POPUP_SHOWN, self::EVENT_POPUP_CLOSED, self::EVENT_ADBLOCKER_DISABLED); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- Live aggregate over the plugin's private analytics table. $rows = $wpdb->get_results( $wpdb->prepare( "SELECT popup_id, COUNT(DISTINCT CASE WHEN event_type = %s THEN visitor_hash END) AS shown, COUNT(DISTINCT CASE WHEN event_type = %s THEN visitor_hash END) AS closed, COUNT(DISTINCT CASE WHEN event_type = %s THEN visitor_hash END) AS disabled FROM %i WHERE event_date >= %s AND event_type IN (%s, %s, %s) GROUP BY popup_id", self::EVENT_POPUP_SHOWN, self::EVENT_POPUP_CLOSED, self::EVENT_ADBLOCKER_DISABLED, $table, $since, $event_types[0], $event_types[1], $event_types[2] ), ARRAY_A ); if (empty($rows)) { return array(); } $performance = array(); foreach ($rows as $row) { $popup_id = isset($row['popup_id']) ? absint($row['popup_id']) : 0; $shown = isset($row['shown']) ? (int) $row['shown'] : 0; $closed = isset($row['closed']) ? (int) $row['closed'] : 0; $disabled = isset($row['disabled']) ? (int) $row['disabled'] : 0; if ($shown <= 0 && $closed <= 0 && $disabled <= 0) { continue; } $performance[] = array( 'popup_id' => $popup_id, 'popup_name' => $this->get_popup_template_name($popup_id), 'shown' => $shown, 'closed' => $closed, 'disabled' => $disabled, 'close_rate' => $shown > 0 ? round(($closed / $shown) * 100, 2) : 0, 'disabled_rate' => $shown > 0 ? round(($disabled / $shown) * 100, 2) : 0, ); } usort($performance, array($this, 'sort_popup_template_performance')); return array_slice($performance, 0, 20); } private function get_unique_event_count($event_type, $days, $adblock_status = '') { global $wpdb; if (! $this->table_exists()) { return 0; } $labels = $this->build_date_series(max(1, absint($days))); $table = $this->get_table_name(); $since = $labels[0]; $event_type = sanitize_key($event_type); $adblock_status = sanitize_key($adblock_status); if ($adblock_status !== '') { // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- Live aggregate over the plugin's private analytics table. return (int) $wpdb->get_var( $wpdb->prepare( "SELECT COUNT(DISTINCT visitor_hash) FROM %i WHERE event_type = %s AND adblock_status = %s AND event_date >= %s", $table, $event_type, $adblock_status, $since ) ); } // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- Live aggregate over the plugin's private analytics table. return (int) $wpdb->get_var( $wpdb->prepare( "SELECT COUNT(DISTINCT visitor_hash) FROM %i WHERE event_type = %s AND event_date >= %s", $table, $event_type, $since ) ); } private function get_popup_template_name($popup_id) { $popup_id = absint($popup_id); if ($popup_id <= 0) { return __('Legacy popup', 'adunblocker'); } $post = get_post($popup_id); if ($post && $post->post_type === 'daau_popup') { $title = get_the_title($post); if (is_string($title) && trim($title) !== '') { return $title; } } return sprintf( /* translators: %d: Popup template post ID. */ __('Popup #%d', 'adunblocker'), $popup_id ); } private function sort_popup_template_performance($a, $b) { $a_disabled = isset($a['disabled']) ? (int) $a['disabled'] : 0; $b_disabled = isset($b['disabled']) ? (int) $b['disabled'] : 0; if ($a_disabled !== $b_disabled) { return $b_disabled - $a_disabled; } $a_rate = isset($a['disabled_rate']) ? (float) $a['disabled_rate'] : 0; $b_rate = isset($b['disabled_rate']) ? (float) $b['disabled_rate'] : 0; if ($a_rate === $b_rate) { $a_shown = isset($a['shown']) ? (int) $a['shown'] : 0; $b_shown = isset($b['shown']) ? (int) $b['shown'] : 0; return $b_shown - $a_shown; } return ($a_rate < $b_rate) ? 1 : -1; } private function sanitize_tracking_payload($payload) { $event_type = isset($payload['event_type']) ? sanitize_key(wp_unslash($payload['event_type'])) : ''; $allowed_events = array( self::EVENT_DETECTION, self::EVENT_POPUP_SHOWN, self::EVENT_POPUP_CLOSED, self::EVENT_ADBLOCKER_DISABLED, ); if (! in_array($event_type, $allowed_events, true)) { return new WP_Error('invalid_event_type', esc_html__('Invalid event type.', 'adunblocker')); } $adblock_status = isset($payload['adblock_status']) ? sanitize_key(wp_unslash($payload['adblock_status'])) : 'unknown'; $allowed_statuses = array('blocked', 'unblocked', 'unknown'); if (! in_array($adblock_status, $allowed_statuses, true)) { $adblock_status = ($event_type === self::EVENT_DETECTION) ? 'unknown' : 'blocked'; } if (in_array($event_type, array(self::EVENT_POPUP_SHOWN, self::EVENT_POPUP_CLOSED), true)) { $adblock_status = 'blocked'; } if ($event_type === self::EVENT_ADBLOCKER_DISABLED) { $adblock_status = 'unblocked'; } $visit_id = isset($payload['visit_id']) ? sanitize_text_field(wp_unslash($payload['visit_id'])) : ''; if (! preg_match('/^[A-Za-z0-9._-]{8,64}$/', $visit_id)) { return new WP_Error('invalid_visit_id', esc_html__('Invalid visit identifier.', 'adunblocker')); } $visitor_id = isset($payload['visitor_id']) ? sanitize_text_field(wp_unslash($payload['visitor_id'])) : ''; if (! preg_match('/^[A-Za-z0-9._-]{8,128}$/', $visitor_id)) { return new WP_Error('invalid_visitor_id', esc_html__('Invalid visitor identifier.', 'adunblocker')); } $page_path_raw = isset($payload['page_path']) ? wp_unslash($payload['page_path']) : '/'; $page_path = $this->normalize_page_path($page_path_raw); if ($page_path === '') { $page_path = '/'; } $popup_id = isset($payload['popup_id']) ? absint(wp_unslash($payload['popup_id'])) : 0; $user_agent = isset($_SERVER['HTTP_USER_AGENT']) ? sanitize_text_field(wp_unslash($_SERVER['HTTP_USER_AGENT'])) : ''; return array( 'event_type' => $event_type, 'adblock_status' => $adblock_status, 'visit_id' => substr($visit_id, 0, 64), 'visitor_hash' => hash('sha256', $visitor_id . '|' . wp_salt('auth')), 'popup_id' => $popup_id, 'page_path' => substr($page_path, 0, 255), 'device_type' => $this->detect_device_type($user_agent), 'browser_family' => $this->detect_browser_family($user_agent), ); } private function allow_public_event_request() { $remote_address = isset($_SERVER['REMOTE_ADDR']) ? sanitize_text_field(wp_unslash($_SERVER['REMOTE_ADDR'])) : 'unknown'; $fingerprint = hash('sha256', $remote_address . '|stats|' . wp_salt('nonce')); $transient_key = 'daau_stats_rl_' . substr($fingerprint, 0, 24); $count = absint(get_transient($transient_key)); if ($count >= 120) { return false; } set_transient($transient_key, $count + 1, self::RATE_LIMIT_WINDOW); return true; } private function insert_event($payload) { global $wpdb; if (! $this->table_exists()) { return false; } $table = $this->get_table_name(); $event_time_utc = gmdate('Y-m-d H:i:s'); $event_date = gmdate('Y-m-d'); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- Event write to the plugin's private analytics table. $result = $wpdb->query($wpdb->prepare( "INSERT IGNORE INTO %i (event_time_utc, event_date, visit_id, visitor_hash, event_type, adblock_status, popup_id, page_path, device_type, browser_family) VALUES (%s, %s, %s, %s, %s, %s, %d, %s, %s, %s)", $table, $event_time_utc, $event_date, $payload['visit_id'], $payload['visitor_hash'], $payload['event_type'], $payload['adblock_status'], $payload['popup_id'], $payload['page_path'], $payload['device_type'], $payload['browser_family'] )); if ($result === false) { return false; } return $result > 0; } private function normalize_page_path($path) { $path = trim((string) $path); if ($path === '') { return '/'; } $parsed_path = wp_parse_url($path, PHP_URL_PATH); if ($parsed_path === false || $parsed_path === null) { $parsed_path = $path; } $parsed_path = sanitize_text_field($parsed_path); if ($parsed_path === '') { return '/'; } if ($parsed_path[0] !== '/') { $parsed_path = '/' . $parsed_path; } return $parsed_path; } private function detect_device_type($user_agent) { $ua = strtolower((string) $user_agent); if ($ua === '') { return 'other'; } if (preg_match('/ipad|tablet/', $ua)) { return 'tablet'; } if (preg_match('/mobile|iphone|android/', $ua)) { return 'mobile'; } return 'desktop'; } private function detect_browser_family($user_agent) { $ua = strtolower((string) $user_agent); if ($ua === '') { return 'other'; } if (strpos($ua, 'edg/') !== false) { return 'edge'; } if (strpos($ua, 'opr/') !== false || strpos($ua, 'opera') !== false) { return 'opera'; } if (strpos($ua, 'chrome/') !== false) { return 'chrome'; } if (strpos($ua, 'safari/') !== false && strpos($ua, 'chrome/') === false) { return 'safari'; } if (strpos($ua, 'firefox/') !== false) { return 'firefox'; } if (strpos($ua, 'msie') !== false || strpos($ua, 'trident/') !== false) { return 'ie'; } return 'other'; } private function build_date_series($days) { $labels = array(); $days = max(1, absint($days)); $oldest_offset = $days - 1; for ($offset = $oldest_offset; $offset >= 0; $offset--) { $labels[] = gmdate('Y-m-d', strtotime("-{$offset} days", current_time('timestamp', true))); } return $labels; } private function can_manage_stats() { $cap = (is_multisite() && is_network_admin()) ? 'manage_network_options' : 'manage_options'; return current_user_can($cap); } private function table_exists() { global $wpdb; $table = $this->get_table_name(); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- Schema existence check for the plugin's private table. $found = $wpdb->get_var($wpdb->prepare('SHOW TABLES LIKE %s', $wpdb->esc_like($table))); return $found === $table; } }