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 +108 -3 1.27.02.7.0 View file →
@@ -115,9 +115,15 @@
115 115
116 116 $response = wp_remote_request($url, $args);
117 117
118 118 if (is_wp_error($response)) {
119 - 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());
120 126 }
121 127
122 128 $status_code = wp_remote_retrieve_response_code($response);
123 129 $response_body = wp_remote_retrieve_body($response);
@@ -123,10 +129,33 @@
123 129 $response_body = wp_remote_retrieve_body($response);
124 130
125 131 if ($status_code >= 400) {
126 132 $error_data = json_decode($response_body, true);
133 + $error_data = is_array($error_data) ? $error_data : [];
127 134 $error_message = $error_data['error']['message'] ?? 'Unknown API error';
128 - 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);
129 158 }
130 159
131 160 $data = json_decode($response_body, true);
132 161
@@ -180,9 +209,10 @@
180 209
181 210 $current_count = get_transient($rate_limit_key . '_count') ?: 0;
182 211
183 212 if ($current_count >= $this->rate_limits['max_requests_per_day']) {
184 - 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());
185 215 }
186 216 }
187 217
188 218 /**
@@ -219,8 +249,83 @@
219 249 *
220 250 * @return string Rate limit key
221 251 */
222 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 + }
223 328
224 329 /**
225 330 * Get rate limit error message
226 331 * Must be implemented by concrete classes