PluginProbe
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO / 2.7.0
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO v2.7.0
2.7.0 2.6.0 2.5.0 2.4.0 2.3.0 2.2.0 2.1.1 2.1.0 2.0.2 2.0.1 2.0.0 1.32.0 1.31.0 1.30.0 1.29.0 1.28.0 1.27.0 1.26.0 1.25.0 trunk 1.0.0 1.0.1 1.0.2 1.1.0 1.10.0 All 48 releases
← All changes | includes/integrations/class-google-api-base-client.php +138 -4 1.0.02.7.0 View file →
@@ -1,5 +1,6 @@
1 1 <?php
2 +
2 3 /**
3 4 * Google API Base Client Class
4 5 *
5 6 * Abstract base class for Google API integrations providing common functionality
@@ -36,8 +37,15 @@
36 37 */
37 38 protected string $api_key;
38 39
39 40 /**
41 + * OAuth Access Token
42 + *
43 + * @var string|null
44 + */
45 + protected ?string $access_token = null;
46 +
47 + /**
40 48 * Request timeout in seconds
41 49 *
42 50 * @var int
43 51 */
@@ -54,12 +62,14 @@
54 62 * Constructor
55 63 *
56 64 * @param string $api_key Google API key
57 65 * @param int $timeout Request timeout in seconds
66 + * @param string|null $access_token OAuth Access Token (optional)
58 67 */
59 - public function __construct(string $api_key, int $timeout = 30) {
68 + public function __construct(string $api_key, int $timeout = 20, ?string $access_token = null) {
60 69 $this->api_key = $api_key;
61 70 $this->timeout = $timeout;
71 + $this->access_token = $access_token;
62 72 $this->rate_limits = $this->get_rate_limits();
63 73 }
64 74
65 75 /**
@@ -83,8 +93,20 @@
83 93 ],
84 94 'method' => $method
85 95 ];
86 96
97 + // Add OAuth Authorization header if token exists; otherwise fall back to
98 + // the API key sent in the x-goog-api-key HEADER (never the query string,
99 + // which is logged by servers, proxies and referrers).
100 + if (!empty($this->access_token)) {
101 + $args['headers']['Authorization'] = 'Bearer ' . $this->access_token;
102 + } elseif (!empty($this->api_key)) {
103 + $args['headers']['x-goog-api-key'] = $this->api_key;
104 + }
105 +
106 + // Defensive: never let a key travel in the query string.
107 + unset($params['key']);
108 +
87 109 if ($method === 'GET' && !empty($params)) {
88 110 $url .= '?' . http_build_query($params);
89 111 } elseif ($method === 'POST') {
90 112 $args['body'] = wp_json_encode($params);
@@ -93,9 +115,15 @@
93 115
94 116 $response = wp_remote_request($url, $args);
95 117
96 118 if (is_wp_error($response)) {
97 - throw new \Exception('API request failed: ' . esc_html($response->get_error_message()));
119 + // Not esc_html()'d: an exception message is data, not output. It is
120 + // JSON-encoded to the REST layer and rendered as text by React, so
121 + // escaping here only smuggled entities into what the user reads —
122 + // Google's own wording is full of quotes, and the Performance panels
123 + // displayed them as "quota metric &#039;Queries&#039;".
124 + // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Message is JSON data for the REST layer, escaped at render time by React.
125 + throw new \Exception('API request failed: ' . $response->get_error_message());
98 126 }
99 127
100 128 $status_code = wp_remote_retrieve_response_code($response);
101 129 $response_body = wp_remote_retrieve_body($response);
@@ -101,10 +129,33 @@
101 129 $response_body = wp_remote_retrieve_body($response);
102 130
103 131 if ($status_code >= 400) {
104 132 $error_data = json_decode($response_body, true);
133 + $error_data = is_array($error_data) ? $error_data : [];
105 134 $error_message = $error_data['error']['message'] ?? 'Unknown API error';
106 - throw new \Exception(sprintf('Google API error (%d): %s', (int) $status_code, esc_html($error_message)));
135 +
136 + // A scope failure reads as "Request had insufficient authentication
137 + // scopes." — Google-internal wording that told the user nothing and
138 + // reached the MCP client verbatim (#674). Say what to do instead.
139 + $actionable = self::actionable_auth_message((int) $status_code, $error_data);
140 +
141 + if (null !== $actionable) {
142 + // Google's own sentence is kept after the instruction: support
143 + // needs the upstream wording to tell a scope failure from a
144 + // revoked grant, and the user needs the instruction first.
145 + // Built on its own line so the phpcs:ignore below sits on the
146 + // `throw` itself. The annotation only suppresses the next line,
147 + // and on a multi-line throw the reported violation is the
148 + // argument line, not the `throw` — so the ignore missed it and
149 + // Plugin Check failed on a sniff the repo standard does not run.
150 + $message = sprintf('%s (Google said: %s)', $actionable, $error_message);
151 +
152 + // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Same as above: Google's wording is data, not markup; escaping it leaks entities into the UI.
153 + throw new \Exception($message, (int) $status_code);
154 + }
155 +
156 + // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Same as above: Google's wording is data, not markup; escaping it leaks entities into the UI.
157 + throw new \Exception(sprintf('Google API error (%d): %s', (int) $status_code, $error_message), (int) $status_code);
107 158 }
108 159
109 160 $data = json_decode($response_body, true);
110 161
@@ -111,8 +162,15 @@
111 162 if (json_last_error() !== JSON_ERROR_NONE) {
112 163 throw new \Exception('Invalid JSON response from Google API');
113 164 }
114 165
166 + // A valid-but-scalar body (null/number/string from a proxy/WAF/CDN on a
167 + // 2xx) would violate this method's : array return type; reject it here so
168 + // it surfaces as a catchable \Exception, not an uncatchable TypeError.
169 + if (!is_array($data)) {
170 + throw new \Exception('Unexpected non-array response from Google API');
171 + }
172 +
115 173 // Update rate limit tracking after successful request
116 174 $this->update_rate_limit();
117 175
118 176 return $data;
@@ -151,9 +209,10 @@
151 209
152 210 $current_count = get_transient($rate_limit_key . '_count') ?: 0;
153 211
154 212 if ($current_count >= $this->rate_limits['max_requests_per_day']) {
155 - throw new \Exception(esc_html($this->get_rate_limit_error_message()));
213 + // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Plugin-authored message, rendered as text by the admin app.
214 + throw new \Exception($this->get_rate_limit_error_message());
156 215 }
157 216 }
158 217
159 218 /**
@@ -190,8 +249,83 @@
190 249 *
191 250 * @return string Rate limit key
192 251 */
193 252 abstract protected function get_rate_limit_key(): string;
253 +
254 + /**
255 + * Turn an authorization failure into an instruction, or null to pass through.
256 + *
257 + * Google answers a missing scope with "Request had insufficient
258 + * authentication scopes." — accurate, and useless to the person reading it.
259 + * It reached the MCP client and the Performance panels verbatim, with
260 + * nothing to say that reconnecting the Google account is the fix (#674).
261 + *
262 + * The distinction that matters is refresh versus re-consent. A 401 is a
263 + * stale access token and Analytics_Manager already refreshes and retries it
264 + * silently. A scope 403 is not retryable: refreshing returns a token with
265 + * the same scopes, so only granting consent again can change the outcome —
266 + * which is why this says "reconnect", not "try again".
267 + *
268 + * Returns null for every other failure, so quota, rate-limit and genuine
269 + * permission errors keep Google's wording, which is informative for them.
270 + *
271 + * @since 2.7.0
272 + *
273 + * @param int $status_code HTTP status.
274 + * @param array $error_data Decoded error body.
275 + * @return string|null Instruction to lead with, or null to pass through.
276 + */
277 + protected static function actionable_auth_message(int $status_code, array $error_data): ?string {
278 + $error = is_array($error_data['error'] ?? null) ? $error_data['error'] : [];
279 + $message = (string) ($error['message'] ?? '');
280 +
281 + // google.rpc.ErrorInfo, which is what the newer APIs return.
282 + $reasons = [];
283 + foreach ((array) ($error['details'] ?? []) as $detail) {
284 + if (is_array($detail) && isset($detail['reason'])) {
285 + $reasons[] = (string) $detail['reason'];
286 + }
287 + }
288 +
289 + // The older errors[] shape, still used by Search Console.
290 + foreach ((array) ($error['errors'] ?? []) as $legacy) {
291 + if (is_array($legacy) && isset($legacy['reason'])) {
292 + $reasons[] = (string) $legacy['reason'];
293 + }
294 + }
295 +
296 + $scope_failure = 403 === $status_code
297 + && (
298 + in_array('ACCESS_TOKEN_SCOPE_INSUFFICIENT', $reasons, true)
299 + || in_array('insufficientPermissions', $reasons, true)
300 + || 1 === preg_match('/insufficient (authentication scopes|permission)/i', $message)
301 + );
302 +
303 + if ($scope_failure) {
304 + return __(
305 + 'The connected Google account is missing a permission this feature needs. Reconnect it under Essential SEO > Integrations and approve every permission Google asks for. Refreshing or retrying will not help, because the existing grant cannot gain a permission it was never given.',
306 + 'thinkrank'
307 + );
308 + }
309 +
310 + // A revoked or withdrawn grant. Analytics_Manager treats invalid_grant
311 + // as terminal already; this is the same condition seen from the API
312 + // side, where the refresh-and-retry loop has nothing left to try.
313 + $revoked = in_array($status_code, [401, 403], true)
314 + && (
315 + in_array('ACCESS_TOKEN_EXPIRED', $reasons, true)
316 + || 1 === preg_match('/invalid[_ ]grant|token has been expired or revoked/i', $message)
317 + );
318 +
319 + if ($revoked) {
320 + return __(
321 + 'The Google connection is no longer valid: access was revoked, or the grant expired. Reconnect the account under Essential SEO > Integrations.',
322 + 'thinkrank'
323 + );
324 + }
325 +
326 + return null;
327 + }
194 328
195 329 /**
196 330 * Get rate limit error message
197 331 * Must be implemented by concrete classes