0 && $current > 0 && $current === $owner_id; } /** * True when GA4 is connected but no owner has been recorded yet -- e.g. * a site connected before ownership tracking was introduced. Used to * show the explicit "Claim this connection" prompt instead of the * property picker. */ public static function needs_owner_claim(): bool { return self::get_instance()->is_connected() && 0 === (int) get_option( self::OPTION_OWNER, 0 ); } /** * Lets the current admin explicitly claim an unclaimed GA4 connection. * Requires a deliberate click (nonce + manage_options), never happens * as a side effect of merely viewing the Settings page. */ public static function handle_claim_owner() { check_admin_referer( 'awbseo_ga4_claim_owner' ); if ( ! current_user_can( 'manage_options' ) ) wp_die( 'Forbidden' ); // Refuse to steal a connection someone already claimed. if ( 0 === (int) get_option( self::OPTION_OWNER, 0 ) ) { update_option( self::OPTION_OWNER, get_current_user_id(), false ); } wp_redirect( admin_url( 'admin.php?page=awbseo-settings#ga4' ) ); exit; } public static function get_instance() { if ( null === self::$instance ) self::$instance = new self(); return self::$instance; } public static function init() { add_action( 'admin_post_awbseo_ga4_connect', [ __CLASS__, 'handle_connect' ] ); add_action( 'admin_post_awbseo_ga4_disconnect', [ __CLASS__, 'handle_disconnect' ] ); add_action( 'admin_post_awbseo_ga4_detect_property', [ __CLASS__, 'handle_detect_property' ] ); add_action( 'admin_post_awbseo_ga4_set_property', [ __CLASS__, 'handle_set_property' ] ); add_action( 'admin_post_awbseo_ga4_claim_owner', [ __CLASS__, 'handle_claim_owner' ] ); // Note: GA4 callback is NOT registered as its own action. The broker // only accepts the pre-registered site_callback URL (same as GSC: // admin-post.php?action=awbseo_gsc_callback). AWBSEO_GSC::handle_callback() // detects the 'ga4' transient value and delegates here instead. } // ── OAuth Flow ──────────────────────────────────────────────────────────── /** * Starts the GA4 OAuth connect flow via the same broker used for GSC. * Scope: analytics.readonly (covers both Data API and Admin API). * * IMPORTANT: The OAuth broker validates site_callback against the one * pre-registered URL (action=awbseo_gsc_callback). So we reuse that * same callback endpoint and store 'ga4' (not '1') as the transient * value so AWBSEO_GSC::handle_callback() can route the response here. */ public static function handle_connect() { check_admin_referer( 'awbseo_ga4_connect' ); if ( ! current_user_can( 'manage_options' ) ) wp_die( 'Forbidden' ); // Reuse the GSC callback URL - the only site_callback the broker accepts. $site_callback = admin_url( 'admin-post.php?action=awbseo_gsc_callback' ); $token = bin2hex( random_bytes( 32 ) ); // Store under the GSC transient key prefix so handle_callback() finds it, // but with value 'ga4' so it knows to route to GA4 token storage. set_transient( 'awbseo_gsc_oauth_' . $token, 'ga4', 10 * MINUTE_IN_SECONDS ); $start = wp_remote_post( AWBSEO_OAUTH_BROKER . '/start', [ 'timeout' => 10, 'sslverify' => true, 'body' => [ 'site_callback' => $site_callback, 'nonce' => $token, ], ] ); if ( is_wp_error( $start ) || 200 !== wp_remote_retrieve_response_code( $start ) ) { $detail = is_wp_error( $start ) ? $start->get_error_message() : 'HTTP ' . wp_remote_retrieve_response_code( $start ) . ' - ' . wp_remote_retrieve_body( $start ); wp_redirect( add_query_arg( [ 'ga4_error' => 'broker_unreachable', 'ga4_detail' => rawurlencode( $detail ) ], admin_url( 'admin.php?page=awbseo-settings' ) ) ); exit; } $state = json_decode( wp_remote_retrieve_body( $start ), true )['state'] ?? ''; if ( ! $state ) { wp_redirect( admin_url( 'admin.php?page=awbseo-settings&ga4_error=broker_error' ) ); exit; } $oauth_url = add_query_arg( [ 'client_id' => AWBSEO_GSC_CLIENT_ID, // same Google app as GSC 'redirect_uri' => AWBSEO_OAUTH_BROKER . '/callback', 'response_type' => 'code', 'scope' => 'https://www.googleapis.com/auth/analytics.readonly', 'access_type' => 'offline', 'prompt' => 'consent', 'state' => $state, ], self::OAUTH_ENDPOINT ); wp_redirect( $oauth_url ); exit; } /** * Called by AWBSEO_GSC::handle_callback() when it detects the transient * value is 'ga4' (meaning this is a GA4 connect flow, not a GSC one). * The nonce has already been verified and deleted by the GSC handler. */ public static function store_tokens_from_gsc_callback() { if ( ! empty( $_POST['error'] ) ) { $err = sanitize_text_field( $_POST['error'] ); wp_redirect( admin_url( 'admin.php?page=awbseo-settings&ga4_error=' . rawurlencode( $err ) ) ); exit; } $tokens = [ 'access_token' => sanitize_text_field( $_POST['access_token'] ?? '' ), 'refresh_token' => sanitize_text_field( $_POST['refresh_token'] ?? '' ), 'expires_at' => time() + (int) ( $_POST['expires_in'] ?? 3600 ), ]; if ( ! $tokens['access_token'] ) { wp_redirect( admin_url( 'admin.php?page=awbseo-settings&ga4_error=no_token' ) ); exit; } update_option( self::OPTION_TOKENS, $tokens, false ); // Record which WP user connected GA4 - used to gate the property picker // so other admins (e.g. the client) cannot see the full account list. update_option( self::OPTION_OWNER, get_current_user_id(), false ); self::flush_transients(); // Auto-detect property after connecting. $property = self::detect_property(); if ( $property ) { update_option( self::OPTION_PROPERTY, $property ); } wp_redirect( admin_url( 'admin.php?page=awbseo-settings&ga4_connected=1' ) ); exit; } public static function handle_disconnect() { check_admin_referer( 'awbseo_ga4_disconnect' ); if ( ! current_user_can( 'manage_options' ) ) wp_die( 'Forbidden' ); delete_option( self::OPTION_TOKENS ); delete_option( self::OPTION_PROPERTY ); delete_option( self::OPTION_OWNER ); self::flush_transients(); wp_redirect( admin_url( 'admin.php?page=awbseo-settings&ga4_disconnected=1' ) ); exit; } public static function handle_detect_property() { check_admin_referer( 'awbseo_ga4_detect_property' ); if ( ! current_user_can( 'manage_options' ) ) wp_die( 'Forbidden' ); $property = self::detect_property(); if ( $property ) { update_option( self::OPTION_PROPERTY, $property ); wp_redirect( admin_url( 'admin.php?page=awbseo-settings&ga4_detected=1' ) ); } else { wp_redirect( admin_url( 'admin.php?page=awbseo-settings&ga4_error=no_property_found' ) ); } exit; } /** * Saves a manually selected GA4 property from the settings dropdown. */ public static function handle_set_property() { check_admin_referer( 'awbseo_ga4_set_property' ); if ( ! current_user_can( 'manage_options' ) ) wp_die( 'Forbidden' ); $property = sanitize_text_field( $_POST['ga4_property'] ?? '' ); // Normalize: accept bare numeric ID (e.g. "123456789") or full form. if ( $property && ! str_starts_with( $property, 'properties/' ) ) { $property = 'properties/' . $property; } if ( $property && $property !== 'properties/' ) { update_option( self::OPTION_PROPERTY, $property ); self::flush_transients(); wp_redirect( admin_url( 'admin.php?page=awbseo-settings&ga4_detected=1' ) ); } else { wp_redirect( admin_url( 'admin.php?page=awbseo-settings&ga4_error=no_property_found' ) ); } exit; } // ── Token Management ────────────────────────────────────────────────────── public function is_connected() { $tokens = get_option( self::OPTION_TOKENS, [] ); if ( empty( $tokens['access_token'] ) ) return false; // Token is still within its validity window - definitely connected. if ( ! empty( $tokens['expires_at'] ) && time() < $tokens['expires_at'] - 60 ) { return true; } // Token is expired. If we have a refresh token, get_access_token() can // renew it on demand - report connected so the caller can attempt a refresh. if ( ! empty( $tokens['refresh_token'] ) ) { return true; } // Expired with no refresh token - permanently unusable. Clear it so the // Settings page shows the Connect button instead of an unresolvable error. delete_option( self::OPTION_TOKENS ); return false; } public function get_property() { return get_option( self::OPTION_PROPERTY, '' ); } /** * Returns a valid access token, refreshing if expired. * Mirrors the same pattern as AWBSEO_GSC::get_access_token(). */ public function get_access_token() { $tokens = get_option( self::OPTION_TOKENS, [] ); if ( empty( $tokens['access_token'] ) ) return null; if ( ! empty( $tokens['expires_at'] ) && time() < $tokens['expires_at'] - 60 ) { return $tokens['access_token']; } if ( empty( $tokens['refresh_token'] ) ) return null; // Don't hammer the broker if a recent refresh attempt failed transiently. // The backoff transient is cleared automatically by flush_transients() // whenever the user successfully reconnects. if ( get_transient( 'awbseo_ga4_refresh_backoff' ) ) { return null; } $response = wp_remote_post( AWBSEO_OAUTH_BROKER . '/refresh', [ 'timeout' => 10, 'sslverify' => true, 'body' => [ 'refresh_token' => $tokens['refresh_token'] ], ] ); if ( is_wp_error( $response ) || 200 !== wp_remote_retrieve_response_code( $response ) ) { // Transient failure (network error, broker down). Throttle retries // and fall back to the stored access token - it may still be // accepted by Google for a short window, and if not, Google's own // 401 gives the user a clearer error than "no token on file". set_transient( 'awbseo_ga4_refresh_backoff', 1, 15 * MINUTE_IN_SECONDS ); return $tokens['access_token']; } $body = json_decode( wp_remote_retrieve_body( $response ), true ); if ( empty( $body['access_token'] ) ) { set_transient( 'awbseo_ga4_refresh_backoff', 1, 15 * MINUTE_IN_SECONDS ); return $tokens['access_token']; // same fallback } $tokens['access_token'] = $body['access_token']; $tokens['expires_at'] = time() + (int) ( $body['expires_in'] ?? 3600 ); update_option( self::OPTION_TOKENS, $tokens, false ); return $tokens['access_token']; } public static function flush_transients() { global $wpdb; $wpdb->query( "DELETE FROM {$wpdb->options} WHERE option_name LIKE '\_transient\_awbseo\_ga4\_%' OR option_name LIKE '\_transient\_timeout\_awbseo\_ga4\_%'" ); } // ── Property Detection ──────────────────────────────────────────────────── /** Stores the last error from list_properties() for display in the UI. */ private static $list_error = ''; /** Stores the last error from run_report() / get_sessions_by_source(). */ private static $data_error = ''; public static function get_list_error() { return self::$list_error; } public static function get_data_error() { return self::$data_error; } /** * Lists all GA4 properties the connected account has access to. * Returns array of [ 'id' => 'properties/123', 'name' => 'My Site (123)' ]. * On failure stores a human-readable error in self::$list_error. */ public function list_properties() { self::$list_error = ''; $token = $this->get_access_token(); if ( ! $token ) { self::$list_error = 'No valid access token - try reconnecting GA4.'; return []; } $response = wp_remote_get( self::ADMIN_API_BASE . 'accountSummaries?pageSize=200', [ 'headers' => [ 'Authorization' => 'Bearer ' . $token ], 'timeout' => 15 ] ); if ( is_wp_error( $response ) ) { self::$list_error = 'API request failed: ' . $response->get_error_message(); return []; } $code = wp_remote_retrieve_response_code( $response ); $raw = wp_remote_retrieve_body( $response ); if ( 200 !== $code ) { $decoded = json_decode( $raw, true ); self::$list_error = $decoded['error']['message'] ?? ( 'GA4 Admin API returned HTTP ' . $code . '. ' . 'Make sure the Google Analytics Admin API is enabled in your Google Cloud project for client ID ' . AWBSEO_GSC_CLIENT_ID . '.' ); return []; } $body = json_decode( $raw, true ); $properties = []; foreach ( $body['accountSummaries'] ?? [] as $account ) { foreach ( $account['propertySummaries'] ?? [] as $prop ) { $id = $prop['property'] ?? ''; // e.g. "properties/123456789" $name = $prop['displayName'] ?? $id; if ( $id ) { $properties[] = [ 'id' => $id, 'name' => $name . ' (' . str_replace( 'properties/', '', $id ) . ')', ]; } } } if ( empty( $properties ) && empty( $body['accountSummaries'] ) ) { self::$list_error = 'No GA4 properties found. This Google account may not have any GA4 properties, or the Analytics Admin API may not be enabled in your Google Cloud project.'; return []; } // Security: the connected Google account is frequently an agency // account with viewer access to many clients' GA4 properties at // once. Never let one client's property picker browse every other // client's property names -- only return properties whose // registered web data stream actually matches this WordPress // install's own domain. return self::filter_properties_to_this_domain( $properties, $token ); } /** * Restricts a raw property list (from accountSummaries) down to * properties whose registered web data stream domain matches this * site's own domain. Only falls back to the unfiltered list -- with a * visible warning -- when literally nothing matches, so a legitimately * connected site is never stranded by a stream URL that doesn't quite * match home_url() (a different subdomain, tracking via GTM, etc). */ private static function filter_properties_to_this_domain( array $properties, string $token ): array { $home_bare = self::bare_host( home_url() ); if ( ! $home_bare || empty( $properties ) ) { return $properties; } $cache_key = 'awbseo_ga4_domain_match_' . md5( $home_bare . '|' . wp_json_encode( wp_list_pluck( $properties, 'id' ) ) ); $cached = get_transient( $cache_key ); if ( is_array( $cached ) ) { return $cached; } $matched = []; foreach ( $properties as $prop ) { foreach ( self::get_web_stream_hosts( $prop['id'], $token ) as $stream_host ) { if ( $stream_host === $home_bare ) { $matched[] = $prop; break; } } } if ( empty( $matched ) ) { self::$list_error = "Couldn't automatically match a property to this site's domain ({$home_bare}) by its registered web data stream. Showing every property the connected Google account can access -- double-check you select the right one."; return $properties; } set_transient( $cache_key, $matched, 6 * HOUR_IN_SECONDS ); return $matched; } /** Returns the lowercase, www-stripped hostnames of a GA4 property's web data streams. */ private static function get_web_stream_hosts( string $property_id, string $token ): array { $response = wp_remote_get( self::ADMIN_API_BASE . $property_id . '/dataStreams?pageSize=100', [ 'headers' => [ 'Authorization' => 'Bearer ' . $token ], 'timeout' => 15 ] ); if ( is_wp_error( $response ) || 200 !== wp_remote_retrieve_response_code( $response ) ) { return []; } $body = json_decode( wp_remote_retrieve_body( $response ), true ); $hosts = []; foreach ( $body['dataStreams'] ?? [] as $stream ) { $uri = $stream['webStreamData']['defaultUri'] ?? ''; if ( $uri ) { $hosts[] = self::bare_host( $uri ); } } return array_filter( $hosts ); } /** Lowercase hostname with a leading "www." stripped, for loose domain comparisons. */ private static function bare_host( string $url ): string { $host = (string) wp_parse_url( $url, PHP_URL_HOST ); return strtolower( preg_replace( '/^www\\./i', '', $host ) ); } /** * Auto-detects the GA4 property matching this site's URL. * Falls back to the first property if URL matching finds nothing. */ public static function detect_property() { $instance = self::get_instance(); $properties = $instance->list_properties(); if ( empty( $properties ) ) return ''; $site_host = strtolower( parse_url( home_url(), PHP_URL_HOST ) ); foreach ( $properties as $prop ) { // GA4 property display names often contain the domain. $prop_lower = strtolower( $prop['name'] ); if ( strpos( $prop_lower, $site_host ) !== false ) { return $prop['id']; } } // IMPORTANT: never fall back to "the first property" here. The // connected Google account is often an agency account with access to // many unrelated clients' GA4 properties, and list_properties() is // already domain-filtered -- if nothing in that filtered list has a // display name containing this site's host, there is no confident // match. Silently picking properties[0] previously meant a brand new // site could end up auto-assigned to a completely different client's // analytics property with no indication anything was wrong. return ''; } // ── Data API ────────────────────────────────────────────────────────────── /** * Runs a GA4 Data API report for the given property. * $dimensions: [ 'sessionSource', 'sessionMedium' ] etc. * $metrics: [ 'sessions', 'totalUsers' ] etc. */ public function run_report( $property_id, array $dimensions, array $metrics, $days = 30 ) { self::$data_error = ''; $token = $this->get_access_token(); if ( ! $token ) { self::$data_error = 'No valid access token - reconnect GA4 in Settings.'; return null; } if ( ! $property_id ) { self::$data_error = 'No GA4 property selected - save a property in Settings.'; return null; } // Strip "properties/" prefix if already included - DATA_API_BASE ends with "properties/". $prop_numeric = str_replace( 'properties/', '', $property_id ); $url = self::DATA_API_BASE . $prop_numeric . ':runReport'; $payload = [ 'dateRanges' => [ [ 'startDate' => $days . 'daysAgo', 'endDate' => 'today' ] ], 'dimensions' => array_map( fn( $d ) => [ 'name' => $d ], $dimensions ), 'metrics' => array_map( fn( $m ) => [ 'name' => $m ], $metrics ), 'limit' => 50, 'orderBys' => [ [ 'metric' => [ 'metricName' => $metrics[0] ], 'desc' => true ] ], ]; $response = wp_remote_post( $url, [ 'timeout' => 20, 'headers' => [ 'Authorization' => 'Bearer ' . $token, 'Content-Type' => 'application/json', ], 'body' => wp_json_encode( $payload ), ] ); if ( is_wp_error( $response ) ) { self::$data_error = 'GA4 Data API request failed: ' . $response->get_error_message(); return null; } $code = wp_remote_retrieve_response_code( $response ); $raw = wp_remote_retrieve_body( $response ); if ( 200 !== $code ) { $decoded = json_decode( $raw, true ); self::$data_error = $decoded['error']['message'] ?? ( 'GA4 Data API returned HTTP ' . $code . ': ' . wp_strip_all_tags( $raw ) ); return null; } return json_decode( $raw, true ); } /** * Returns sessions grouped by source/medium, sorted by sessions desc. * Each row: [ 'source' => string, 'medium' => string, 'sessions' => int ] */ public function get_sessions_by_source( $days = 30 ) { $property = $this->get_property(); if ( ! $property ) return []; $cache_key = 'awbseo_ga4_src_' . md5( $property . $days ); $cached = get_transient( $cache_key ); if ( $cached ) return $cached; $report = $this->run_report( $property, [ 'sessionSource', 'sessionMedium' ], [ 'sessions' ], $days ); if ( empty( $report['rows'] ) ) return []; $rows = array_map( function ( $row ) { return [ 'source' => $row['dimensionValues'][0]['value'] ?? '', 'medium' => $row['dimensionValues'][1]['value'] ?? '', 'sessions' => (int) ( $row['metricValues'][0]['value'] ?? 0 ), ]; }, $report['rows'] ); set_transient( $cache_key, $rows, HOUR_IN_SECONDS ); return $rows; } /** * Returns sessions from known AI engine sources specifically. * Uses get_sessions_by_source() and filters by domain match against * the same engine domain list used by the Referer tracker. * * Returns: [ 'ChatGPT' => 42, 'Perplexity' => 18, ... ] sorted desc. */ public function get_ai_sessions( $days = 30 ) { $all_rows = $this->get_sessions_by_source( $days ); if ( empty( $all_rows ) ) return []; // Source domain → engine name (GA4 strips subdomain from source). $engine_map = [ 'chatgpt.com' => 'ChatGPT', 'chat.openai.com' => 'ChatGPT', 'openai.com' => 'ChatGPT', 'perplexity.ai' => 'Perplexity', 'gemini.google.com' => 'Google Gemini', 'aistudio.google.com' => 'Google Gemini', 'copilot.microsoft.com' => 'Microsoft Copilot', 'bing.com' => 'Microsoft Copilot', 'claude.ai' => 'Claude (Anthropic)', 'you.com' => 'You.com', 'meta.ai' => 'Meta AI', 'grok.com' => 'Grok (xAI)', 'x.com' => 'Grok (xAI)', 'kagi.com' => 'Kagi', 'phind.com' => 'Phind', 'poe.com' => 'Poe', ]; $totals = []; foreach ( $all_rows as $row ) { $src = strtolower( trim( $row['source'] ) ); // GA4 often strips subdomain (reports "openai.com" for chat.openai.com). foreach ( $engine_map as $domain => $engine ) { if ( $src === $domain || str_ends_with( $src, '.' . $domain ) ) { $totals[ $engine ] = ( $totals[ $engine ] ?? 0 ) + $row['sessions']; break; } } } arsort( $totals ); return $totals; } /** * Total sessions from AI engines for the period. */ public function get_ai_total( $days = 30 ) { return array_sum( $this->get_ai_sessions( $days ) ); } /** * Same >30%-of-letters-are-Arabic heuristic used elsewhere in the plugin * (on-page analyzer, schema manager, GSC's language breakdown) - * duplicated here rather than shared across classes so class-ga4.php * carries no dependency on any of them. */ private static function looks_arabic_text( $text ) { $text = (string) $text; if ( '' === trim( $text ) ) return false; preg_match_all( '/\p{L}/u', $text, $all ); $total = count( $all[0] ); if ( 0 === $total ) return false; preg_match_all( '/[\x{0600}-\x{06FF}\x{0750}-\x{077F}\x{08A0}-\x{08FF}]/u', $text, $ar ); return ( count( $ar[0] ) / $total ) > 0.30; } /** * Sessions split into Arabic vs non-Arabic buckets by the *content * language of the landing page* (its post title + body, same heuristic * the schema manager uses for inLanguage hygiene) rather than the URL * structure - this works whether or not the site uses language-specific * permalinks. A GA4 page path that doesn't resolve to a WordPress post * (an archive, the homepage, a redirect target) falls back to checking * the URL path itself for Arabic characters. * * Limited to the same top-50-rows window as every other page/session * method in this class, since they all go through run_report() below, * which hard-codes that limit. FREE. * * Returns: [ 'arabic' => [ 'sessions' => int, 'top_pages' => [...] ], 'other' => [ same shape ] ] * or [] if GA4 isn't connected/configured or returns no rows. */ public function get_sessions_by_language( $days = 30, $limit_per_language = 15 ) { $property = $this->get_property(); if ( ! $property ) return []; $cache_key = 'awbseo_ga4_lang_' . md5( $property . $days ); $cached = get_transient( $cache_key ); if ( $cached ) return $cached; $report = $this->run_report( $property, [ 'pagePath' ], [ 'sessions' ], $days ); if ( empty( $report['rows'] ) ) return []; $buckets = [ 'arabic' => [], 'other' => [] ]; foreach ( $report['rows'] as $row ) { $path = $row['dimensionValues'][0]['value'] ?? ''; $sessions = (int) ( $row['metricValues'][0]['value'] ?? 0 ); if ( '' === $path ) continue; $post_id = url_to_postid( home_url( $path ) ); if ( $post_id ) { $post = get_post( $post_id ); $is_ar = $post ? self::looks_arabic_text( wp_strip_all_tags( $post->post_title . ' ' . $post->post_content ) ) : false; } else { // No matching post (archive, homepage, etc.) - fall back to // checking the URL path itself for Arabic characters. $is_ar = self::looks_arabic_text( urldecode( $path ) ); } $bucket = $is_ar ? 'arabic' : 'other'; $buckets[ $bucket ][] = [ 'path' => $path, 'sessions' => $sessions ]; } $result = []; foreach ( $buckets as $lang => $lang_rows ) { usort( $lang_rows, fn( $a, $b ) => $b['sessions'] <=> $a['sessions'] ); $result[ $lang ] = [ 'sessions' => array_sum( array_column( $lang_rows, 'sessions' ) ), 'top_pages' => array_slice( $lang_rows, 0, $limit_per_language ), ]; } set_transient( $cache_key, $result, HOUR_IN_SECONDS ); return $result; } }