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 +124 -5 1.10.02.7.0 View file →
@@ -64,9 +64,9 @@
64 64 * @param string $api_key Google API key
65 65 * @param int $timeout Request timeout in seconds
66 66 * @param string|null $access_token OAuth Access Token (optional)
67 67 */
68 - public function __construct(string $api_key, int $timeout = 300, ?string $access_token = null) {
68 + public function __construct(string $api_key, int $timeout = 20, ?string $access_token = null) {
69 69 $this->api_key = $api_key;
70 70 $this->timeout = $timeout;
71 71 $this->access_token = $access_token;
72 72 $this->rate_limits = $this->get_rate_limits();
@@ -93,13 +93,20 @@
93 93 ],
94 94 'method' => $method
95 95 ];
96 96
97 - // Add OAuth Authorization header if token exists
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).
98 100 if (!empty($this->access_token)) {
99 101 $args['headers']['Authorization'] = 'Bearer ' . $this->access_token;
102 + } elseif (!empty($this->api_key)) {
103 + $args['headers']['x-goog-api-key'] = $this->api_key;
100 104 }
101 105
106 + // Defensive: never let a key travel in the query string.
107 + unset($params['key']);
108 +
102 109 if ($method === 'GET' && !empty($params)) {
103 110 $url .= '?' . http_build_query($params);
104 111 } elseif ($method === 'POST') {
105 112 $args['body'] = wp_json_encode($params);
@@ -108,9 +115,15 @@
108 115
109 116 $response = wp_remote_request($url, $args);
110 117
111 118 if (is_wp_error($response)) {
112 - 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 'Queries'".
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());
113 126 }
114 127
115 128 $status_code = wp_remote_retrieve_response_code($response);
116 129 $response_body = wp_remote_retrieve_body($response);
@@ -116,10 +129,33 @@
116 129 $response_body = wp_remote_retrieve_body($response);
117 130
118 131 if ($status_code >= 400) {
119 132 $error_data = json_decode($response_body, true);
133 + $error_data = is_array($error_data) ? $error_data : [];
120 134 $error_message = $error_data['error']['message'] ?? 'Unknown API error';
121 - throw new \Exception(sprintf('Google API error (%d): %s', (int) $status_code, esc_html($error_message)), (int) $status_code);
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);
122 158 }
123 159
124 160 $data = json_decode($response_body, true);
125 161
@@ -126,8 +162,15 @@
126 162 if (json_last_error() !== JSON_ERROR_NONE) {
127 163 throw new \Exception('Invalid JSON response from Google API');
128 164 }
129 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 +
130 173 // Update rate limit tracking after successful request
131 174 $this->update_rate_limit();
132 175
133 176 return $data;
@@ -166,9 +209,10 @@
166 209
167 210 $current_count = get_transient($rate_limit_key . '_count') ?: 0;
168 211
169 212 if ($current_count >= $this->rate_limits['max_requests_per_day']) {
170 - 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());
171 215 }
172 216 }
173 217
174 218 /**
@@ -205,8 +249,83 @@
205 249 *
206 250 * @return string Rate limit key
207 251 */
208 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 + }
209 328
210 329 /**
211 330 * Get rate limit error message
212 331 * Must be implemented by concrete classes