PluginProbe
404 Solution / 4.1.13
404 Solution v4.1.13
4.3.5 4.3.4 4.3.3 4.3.2 4.3.1 4.3.0 4.2.0 4.1.19 4.1.18 4.1.17 4.1.16 4.1.15 4.1.13 4.1.12 4.1.11 4.1.10 4.1.9 4.1.8 4.1.7 4.1.6 4.1.5 4.1.4 4.1.3 trunk 2.30.0 All 109 releases
404-solution / includes / GoogleSearchConsole.php

GoogleSearchConsole.php in 404 Solution 4.1.13, at includes/GoogleSearchConsole.php

906 lines 39.4 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3 if (!defined('ABSPATH')) {
4 exit;
5 }
6
7 /**
8 * Google Search Console integration.
9 *
10 * Connects to the Search Console API to surface search traffic data
11 * for URLs that generate 404s, helping admins understand which broken
12 * URLs were actually getting real search traffic.
13 *
14 * Setup requires:
15 * 1. A Google Cloud project with the Search Console API enabled.
16 * 2. OAuth 2.0 credentials (Client ID + Client Secret).
17 * 3. The redirect URI registered in Google Cloud must match the OAuth
18 * callback URL shown in the plugin settings.
19 */
20 class ABJ_404_Solution_GoogleSearchConsole {
21
22 const OPTION_KEY = 'abj404_gsc_settings';
23 const TOKEN_OPTION_KEY = 'abj404_gsc_token';
24 const ERROR_OPTION_KEY = 'abj404_gsc_last_error';
25 const TRANSIENT_KEY = 'abj404_gsc_data';
26 const TRANSIENT_TTL = 90000; // ~25 hours — survives one missed nightly cron run
27
28 const CRON_HOOK = 'abj404_gsc_fetch_cron';
29 const BACKGROUND_REFRESH_HOOK = 'abj404_gsc_background_refresh';
30 const LOCK_TRANSIENT_KEY = 'abj404_gsc_fetch_lock';
31 const LOCK_TTL = 900; // 15-minute lock to prevent overlapping fetches
32 const LAST_FETCH_OPTION_KEY = 'abj404_gsc_last_fetch_time';
33 const STALE_THRESHOLD = 72000; // 20 hours — triggers background refresh
34
35 const OAUTH_AUTH_URL = 'https://accounts.google.com/o/oauth2/v2/auth';
36 const OAUTH_TOKEN_URL = 'https://oauth2.googleapis.com/token';
37 const API_BASE_URL = 'https://www.googleapis.com/webmasters/v3';
38 const SCOPE = 'https://www.googleapis.com/auth/webmasters.readonly';
39
40 /** Base URL of the centralized OAuth proxy Worker. */
41 const CENTRALIZED_AUTH_URL = 'https://404-solution-auth.forethought-studio.com';
42
43 /** @var ABJ_404_Solution_Logging */
44 private $logger;
45
46 /** @param ABJ_404_Solution_Logging $logger */
47 public function __construct($logger) {
48 $this->logger = $logger;
49 }
50
51 // -------------------------------------------------------------------------
52 // Settings helpers
53 // -------------------------------------------------------------------------
54
55 /**
56 * Get the stored GSC settings (client_id, client_secret, site_url).
57 * @return array{client_id: string, client_secret: string, site_url: string}
58 */
59 public function getSettings(): array {
60 $raw = get_option(self::OPTION_KEY, array());
61 if (!is_array($raw)) {
62 $raw = array();
63 }
64 return array(
65 'client_id' => isset($raw['client_id']) && is_string($raw['client_id']) ? $raw['client_id'] : '',
66 'client_secret' => isset($raw['client_secret']) && is_string($raw['client_secret']) ? $raw['client_secret'] : '',
67 'site_url' => isset($raw['site_url']) && is_string($raw['site_url']) ? $raw['site_url'] : home_url('/'),
68 );
69 }
70
71 /**
72 * Save GSC settings. Returns an error message string or '' on success.
73 * @param array<string, mixed> $postData
74 * @return string
75 */
76 public function saveSettings(array $postData): string {
77 $clientId = isset($postData['gsc_client_id']) ? sanitize_text_field((string)(is_scalar($postData['gsc_client_id']) ? $postData['gsc_client_id'] : '')) : '';
78 $clientSecret = isset($postData['gsc_client_secret']) ? sanitize_text_field((string)(is_scalar($postData['gsc_client_secret']) ? $postData['gsc_client_secret'] : '')) : '';
79 $siteUrl = isset($postData['gsc_site_url']) ? esc_url_raw((string)(is_scalar($postData['gsc_site_url']) ? $postData['gsc_site_url'] : '')) : home_url('/');
80
81 update_option(self::OPTION_KEY, array(
82 'client_id' => $clientId,
83 'client_secret' => $clientSecret,
84 'site_url' => $siteUrl,
85 ), false);
86
87 // Saving new credentials starts fresh — clear any previous OAuth error.
88 $this->clearLastOAuthError();
89 return '';
90 }
91
92 /**
93 * Whether centralized OAuth mode is active (no per-user Google Cloud credentials needed).
94 *
95 * When true, the plugin uses a published OAuth app proxied through a Cloudflare Worker
96 * at CENTRALIZED_AUTH_URL instead of requiring each site owner to create their own
97 * Google Cloud project.
98 *
99 * @return bool
100 */
101 public function isCentralizedMode(): bool {
102 $s = $this->getSettings();
103 // Centralized mode is ON unless the user has entered their own credentials.
104 // The explicit flag allows toggling back from custom → centralized.
105 if ($s['client_id'] !== '' && $s['client_secret'] !== '') {
106 return false;
107 }
108 return true;
109 }
110
111 /**
112 * Is the integration configured (credentials entered or centralized mode)?
113 * @return bool
114 */
115 public function isConfigured(): bool {
116 if ($this->isCentralizedMode()) {
117 return true;
118 }
119 $s = $this->getSettings();
120 return $s['client_id'] !== '' && $s['client_secret'] !== '';
121 }
122
123 /**
124 * Is an access token available (OAuth has been authorized)?
125 * @return bool
126 */
127 public function isAuthorized(): bool {
128 $token = get_option(self::TOKEN_OPTION_KEY, false);
129 if (!is_array($token) || empty($token['access_token'])) {
130 return false;
131 }
132 // Treat token as valid if it doesn't have an expiry or expiry is in the future.
133 if (!empty($token['expires_at']) && (int)$token['expires_at'] < time()) {
134 return $this->refreshToken();
135 }
136 return true;
137 }
138
139 // -------------------------------------------------------------------------
140 // OAuth flow
141 // -------------------------------------------------------------------------
142
143 /**
144 * Build the Google OAuth 2.0 authorization URL.
145 *
146 * In centralized mode the user is sent to the Cloudflare Worker's /authorize
147 * endpoint which in turn redirects to Google. In custom-credentials mode the
148 * user is sent directly to Google.
149 *
150 * @return string
151 */
152 public function buildAuthUrl(): string {
153 if ($this->isCentralizedMode()) {
154 $params = array(
155 'site_callback_url' => $this->getCallbackUrl(),
156 'nonce' => wp_create_nonce('abj404_gsc_oauth'),
157 'scope' => self::SCOPE,
158 );
159 return self::CENTRALIZED_AUTH_URL . '/authorize?' . http_build_query($params);
160 }
161
162 $s = $this->getSettings();
163 $params = array(
164 'client_id' => $s['client_id'],
165 'redirect_uri' => $this->getCallbackUrl(),
166 'response_type' => 'code',
167 'scope' => self::SCOPE,
168 'access_type' => 'offline',
169 'prompt' => 'consent',
170 'state' => wp_create_nonce('abj404_gsc_oauth'),
171 );
172 return self::OAUTH_AUTH_URL . '?' . http_build_query($params);
173 }
174
175 /**
176 * The OAuth callback URL that must be registered in Google Cloud.
177 * @return string
178 */
179 public function getCallbackUrl(): string {
180 return admin_url('admin-ajax.php?action=abj404_gsc_oauth_callback');
181 }
182
183 /**
184 * Store tokens received directly from the centralized OAuth callback.
185 *
186 * In centralized mode, the Worker exchanges the auth code for tokens and
187 * passes them back as URL parameters — so there is no code-for-token
188 * exchange on the plugin side. This method stores the pre-exchanged tokens.
189 *
190 * @param string $accessToken
191 * @param string $refreshToken
192 * @param int $expiresIn Seconds until the access token expires.
193 * @return void
194 */
195 public function storeCentralizedTokens(string $accessToken, string $refreshToken, int $expiresIn): void {
196 $token = array(
197 'access_token' => $accessToken,
198 'token_type' => 'Bearer',
199 'expires_at' => $expiresIn > 0 ? (time() + $expiresIn - 60) : 0,
200 'refresh_token' => $refreshToken,
201 );
202 update_option(self::TOKEN_OPTION_KEY, $token, false);
203 $this->clearLastOAuthError();
204 }
205
206 /**
207 * Exchange an authorization code for tokens. Returns '' on success, error on failure.
208 *
209 * In centralized mode this should not be called — tokens arrive directly from
210 * the Worker callback via storeCentralizedTokens(). An early return guards against
211 * accidental invocation.
212 *
213 * @param string $code
214 * @return string
215 */
216 public function exchangeCodeForToken(string $code): string {
217 if ($this->isCentralizedMode()) {
218 return 'Code exchange is not used in centralized mode.';
219 }
220 $s = $this->getSettings();
221 $response = wp_remote_post(self::OAUTH_TOKEN_URL, array(
222 'body' => array(
223 'code' => $code,
224 'client_id' => $s['client_id'],
225 'client_secret' => $s['client_secret'],
226 'redirect_uri' => $this->getCallbackUrl(),
227 'grant_type' => 'authorization_code',
228 ),
229 'timeout' => 15,
230 ));
231
232 if (is_wp_error($response)) {
233 return $response->get_error_message();
234 }
235
236 $body = json_decode(wp_remote_retrieve_body($response), true);
237 if (!is_array($body) || empty($body['access_token'])) {
238 $error = (is_array($body) && isset($body['error_description'])) ? $body['error_description'] : __('OAuth token exchange failed.', '404-solution');
239 return is_string($error) ? $error : __('OAuth token exchange failed.', '404-solution');
240 }
241
242 $token = array(
243 'access_token' => $body['access_token'],
244 'token_type' => isset($body['token_type']) ? $body['token_type'] : 'Bearer',
245 'expires_at' => isset($body['expires_in']) ? (time() + (int)$body['expires_in'] - 60) : 0,
246 'refresh_token' => isset($body['refresh_token']) ? $body['refresh_token'] : '',
247 );
248 update_option(self::TOKEN_OPTION_KEY, $token, false);
249 $this->clearLastOAuthError(); // authorization succeeded — clear any previous error
250 return '';
251 }
252
253 /**
254 * Refresh the access token using the stored refresh token.
255 *
256 * In centralized mode the refresh request is sent to the Worker's /refresh
257 * endpoint (which holds the client_secret). In custom mode the request goes
258 * directly to Google's token endpoint.
259 *
260 * @return bool true if token refreshed successfully.
261 */
262 private function refreshToken(): bool {
263 $token = get_option(self::TOKEN_OPTION_KEY, false);
264 if (!is_array($token) || empty($token['refresh_token'])) {
265 return false;
266 }
267
268 if ($this->isCentralizedMode()) {
269 return $this->refreshTokenViaCentralized($token);
270 }
271
272 $s = $this->getSettings();
273 $response = wp_remote_post(self::OAUTH_TOKEN_URL, array(
274 'body' => array(
275 'refresh_token' => $token['refresh_token'],
276 'client_id' => $s['client_id'],
277 'client_secret' => $s['client_secret'],
278 'grant_type' => 'refresh_token',
279 ),
280 'timeout' => 15,
281 ));
282
283 if (is_wp_error($response)) {
284 return false;
285 }
286
287 $body = json_decode(wp_remote_retrieve_body($response), true);
288 if (!is_array($body) || empty($body['access_token'])) {
289 return false;
290 }
291
292 $token['access_token'] = $body['access_token'];
293 $token['expires_at'] = isset($body['expires_in']) ? (time() + (int)$body['expires_in'] - 60) : 0;
294 update_option(self::TOKEN_OPTION_KEY, $token, false);
295 return true;
296 }
297
298 /**
299 * Refresh the access token via the centralized Worker's /refresh endpoint.
300 *
301 * @param array<string, mixed> $token Current stored token array.
302 * @return bool true if token refreshed successfully.
303 */
304 private function refreshTokenViaCentralized(array $token): bool {
305 $response = wp_remote_post(self::CENTRALIZED_AUTH_URL . '/refresh', array(
306 'headers' => array('Content-Type' => 'application/json'),
307 'body' => (string)wp_json_encode(array(
308 'refresh_token' => $token['refresh_token'],
309 )),
310 'timeout' => 15,
311 ));
312
313 if (is_wp_error($response)) {
314 return false;
315 }
316
317 $body = json_decode(wp_remote_retrieve_body($response), true);
318 if (!is_array($body) || empty($body['access_token'])) {
319 return false;
320 }
321
322 $token['access_token'] = $body['access_token'];
323 $token['expires_at'] = isset($body['expires_in']) ? (time() + (int)$body['expires_in'] - 60) : 0;
324 update_option(self::TOKEN_OPTION_KEY, $token, false);
325 return true;
326 }
327
328 /**
329 * Revoke authorization and delete stored tokens.
330 * @return void
331 */
332 public function revokeAuthorization(): void {
333 delete_option(self::TOKEN_OPTION_KEY);
334 delete_option(self::OPTION_KEY);
335 delete_transient(self::TRANSIENT_KEY);
336 $this->clearLastOAuthError();
337 }
338
339 // -------------------------------------------------------------------------
340 // API queries
341 // -------------------------------------------------------------------------
342
343 /**
344 * Fetch search analytics data for a list of URLs.
345 * Returns cached data if available, otherwise fetches from the API.
346 *
347 * In the nightly-cron architecture this method is primarily used as
348 * a fallback; the main fetch path is fetchAndCacheGscData().
349 *
350 * @param string[] $urls Relative or absolute URLs to query
351 * @param int $days Number of days (max 16 months back)
352 * @return array<int, array<string, mixed>>
353 */
354 public function getSearchAnalyticsForUrls(array $urls, int $days = 90): array {
355 if (!$this->isAuthorized() || empty($urls)) {
356 return array();
357 }
358
359 $cached = get_transient(self::TRANSIENT_KEY);
360 if (is_array($cached)) {
361 return $cached;
362 }
363
364 $allRows = $this->doFetchFromApi($urls, $days);
365 set_transient(self::TRANSIENT_KEY, $allRows, self::TRANSIENT_TTL);
366 update_option(self::LAST_FETCH_OPTION_KEY, time(), false);
367 return $allRows;
368 }
369
370 /**
371 * Query the GSC Search Analytics API for each URL individually.
372 *
373 * GSC API does not support OR-filtering in dimensionFilterGroups,
374 * so each URL must be queried one at a time.
375 *
376 * @param string[] $urls Relative or absolute URLs to query
377 * @param int $days Number of days to look back
378 * @return array<int, array<string, mixed>>
379 */
380 private function doFetchFromApi(array $urls, int $days = 90): array {
381 $s = $this->getSettings();
382 $token = get_option(self::TOKEN_OPTION_KEY, false);
383 if (!is_array($token) || empty($token['access_token'])) {
384 return array();
385 }
386
387 $siteUrl = $s['site_url'];
388 $endDate = date('Y-m-d');
389 $startTimestamp = strtotime("-{$days} days");
390 $startDate = date('Y-m-d', $startTimestamp !== false ? $startTimestamp : 0);
391
392 $urls = array_slice($urls, 0, 500);
393 $allRows = array();
394
395 foreach ($urls as $url) {
396 $absoluteUrl = (strpos($url, 'http') === 0) ? $url : rtrim(home_url('/'), '/') . '/' . ltrim($url, '/');
397
398 $body = array(
399 'startDate' => $startDate,
400 'endDate' => $endDate,
401 'dimensions' => array('page'),
402 'dimensionFilterGroups' => array(
403 array(
404 'filters' => array(
405 array(
406 'dimension' => 'page',
407 'operator' => 'equals',
408 'expression' => $absoluteUrl,
409 ),
410 ),
411 ),
412 ),
413 'rowLimit' => 1000,
414 );
415
416 $encodedSiteUrl = urlencode($siteUrl);
417 $response = wp_remote_post(
418 self::API_BASE_URL . "/sites/{$encodedSiteUrl}/searchAnalytics/query",
419 array(
420 'headers' => array(
421 'Authorization' => 'Bearer ' . $token['access_token'],
422 'Content-Type' => 'application/json',
423 ),
424 'body' => (string)wp_json_encode($body),
425 'timeout' => 20,
426 )
427 );
428
429 if (is_wp_error($response)) {
430 $this->logger->warn('GSC API transport error: ' . $response->get_error_message());
431 break;
432 }
433
434 $httpCode = (int) wp_remote_retrieve_response_code($response);
435 if ($httpCode !== 200) {
436 $this->logger->warn('GSC API returned HTTP ' . $httpCode . ': ' . wp_remote_retrieve_body($response));
437 break;
438 }
439
440 $data = json_decode(wp_remote_retrieve_body($response), true);
441 if (!is_array($data) || empty($data['rows'])) {
442 continue;
443 }
444
445 foreach ($data['rows'] as $row) {
446 if (!is_array($row)) {
447 continue;
448 }
449 $allRows[] = array(
450 'url' => isset($row['keys'][0]) ? $row['keys'][0] : '',
451 'clicks' => isset($row['clicks']) ? (int)$row['clicks'] : 0,
452 'impressions' => isset($row['impressions']) ? (int)$row['impressions'] : 0,
453 'position' => isset($row['position']) ? round((float)$row['position'], 1) : 0.0,
454 );
455 }
456 }
457
458 // Sort by clicks descending
459 usort($allRows, function ($a, $b) {
460 return $b['clicks'] - $a['clicks'];
461 });
462
463 return $allRows;
464 }
465
466 /**
467 * Fetch GSC data and cache it. Called by the nightly cron and background refresh.
468 *
469 * Uses a lock transient to prevent overlapping fetches.
470 *
471 * @return void
472 */
473 public function fetchAndCacheGscData(): void {
474 if (!$this->isAuthorized()) {
475 return;
476 }
477
478 // Prevent overlapping fetches
479 if (get_transient(self::LOCK_TRANSIENT_KEY)) {
480 return;
481 }
482 set_transient(self::LOCK_TRANSIENT_KEY, '1', self::LOCK_TTL);
483
484 try {
485 $urls = $this->getUrlsToQuery();
486
487 $allRows = $this->doFetchFromApi($urls);
488 set_transient(self::TRANSIENT_KEY, $allRows, self::TRANSIENT_TTL);
489 update_option(self::LAST_FETCH_OPTION_KEY, time(), false);
490 } finally {
491 delete_transient(self::LOCK_TRANSIENT_KEY);
492 }
493 }
494
495 /**
496 * Get the list of 404 URLs to query from the logs table.
497 *
498 * Extracted as a protected method so tests can override without needing
499 * a full DataAccess/database stack.
500 *
501 * @return string[]
502 */
503 protected function getUrlsToQuery(): array {
504 $dao = abj_service('data_access');
505 return $dao->getDistinctLoggedUrls();
506 }
507
508 /**
509 * Return cached GSC data, or false if the cache is empty.
510 *
511 * @return array<int, array<string, mixed>>|false
512 */
513 public function getCachedData() {
514 $cached = get_transient(self::TRANSIENT_KEY);
515 return is_array($cached) ? $cached : false;
516 }
517
518 /**
519 * Whether a background refresh should be triggered.
520 *
521 * True when the last fetch was more than STALE_THRESHOLD seconds ago
522 * or no fetch has ever run.
523 *
524 * @return bool
525 */
526 public function isRefreshNeeded(): bool {
527 $lastFetch = get_option(self::LAST_FETCH_OPTION_KEY, 0);
528 $lastFetchTime = is_numeric($lastFetch) ? (int)$lastFetch : 0;
529 return (time() - $lastFetchTime) > self::STALE_THRESHOLD;
530 }
531
532 /**
533 * Schedule an immediate single-event background refresh via WP-Cron.
534 *
535 * Guards against scheduling when a fetch is already locked or already scheduled.
536 *
537 * @return void
538 */
539 public function scheduleBackgroundRefresh(): void {
540 if (get_transient(self::LOCK_TRANSIENT_KEY)) {
541 return;
542 }
543 if (wp_next_scheduled(self::BACKGROUND_REFRESH_HOOK)) {
544 return;
545 }
546 wp_schedule_single_event(time(), self::BACKGROUND_REFRESH_HOOK);
547 }
548
549 /**
550 * Fetch top N 404 URLs that also have GSC search traffic.
551 * Correlates captured 404s with GSC data.
552 *
553 * @param array<string> $capturedUrls Array of captured 404 URL strings
554 * @param int $days Number of days for GSC data
555 * @return array<int, array<string, mixed>> Rows with url, clicks, impressions, position
556 */
557 public function getTrafficDataForCaptured404s(array $capturedUrls, int $days = 90): array {
558 if (empty($capturedUrls)) {
559 return array();
560 }
561 $data = $this->getSearchAnalyticsForUrls($capturedUrls, $days);
562 return array_filter($data, function ($row) {
563 return is_array($row) && isset($row['clicks']) && $row['clicks'] > 0;
564 });
565 }
566
567 // -------------------------------------------------------------------------
568 // OAuth error persistence (survives the post-OAuth redirect)
569 // -------------------------------------------------------------------------
570
571 /**
572 * Persist an OAuth error so it is visible after the page redirect.
573 * @param string $message
574 * @return void
575 */
576 public function setLastOAuthError(string $message): void {
577 update_option(self::ERROR_OPTION_KEY, $message, false);
578 }
579
580 /**
581 * Retrieve the last stored OAuth error, or '' if none.
582 * @return string
583 */
584 public function getLastOAuthError(): string {
585 $v = get_option(self::ERROR_OPTION_KEY, '');
586 return is_string($v) ? $v : '';
587 }
588
589 /**
590 * Clear any stored OAuth error (called on successful authorization and on revoke).
591 * @return void
592 */
593 public function clearLastOAuthError(): void {
594 delete_option(self::ERROR_OPTION_KEY);
595 }
596
597 // -------------------------------------------------------------------------
598 // State machine
599 // -------------------------------------------------------------------------
600
601 /**
602 * Determine the current UI state of the GSC integration.
603 *
604 * In centralized mode the 'not_configured' state is skipped because
605 * no per-user credentials are needed — the state starts at
606 * 'configured_not_connected' until the user authorizes.
607 *
608 * @return string 'not_configured'|'configured_not_connected'|'error'|'connected'
609 */
610 public function getState(): string {
611 if (!$this->isConfigured()) {
612 return 'not_configured';
613 }
614 if ($this->isAuthorized()) {
615 return 'connected';
616 }
617 if ($this->getLastOAuthError() !== '') {
618 return 'error';
619 }
620 return 'configured_not_connected';
621 }
622
623 // -------------------------------------------------------------------------
624 // Admin UI
625 // -------------------------------------------------------------------------
626
627 /**
628 * Render the inner content for the GSC settings/status card.
629 * Callers wrap this via echoOptionsSection() for card + collapse support.
630 *
631 * Data is fetched by the nightly cron — this method only reads cached data.
632 *
633 * @param string[] $capturedUrls Deprecated — no longer used. Kept for backward compatibility.
634 * @return string HTML
635 */
636 public function renderAdminSection(array $capturedUrls = []): string {
637 switch ($this->getState()) {
638 case 'not_configured':
639 return $this->renderNotConfiguredState();
640 case 'configured_not_connected':
641 return $this->renderConfiguredNotConnectedState();
642 case 'error':
643 return $this->renderErrorState();
644 default: // 'connected'
645 return $this->renderConnectedState();
646 }
647 }
648
649 /**
650 * State: no custom credentials entered and centralized mode not yet authorized.
651 *
652 * In centralized mode (default) this state is actually skipped because
653 * isConfigured() returns true. This render method now only appears when
654 * the user is in custom-credentials mode with empty credentials — which
655 * shouldn't normally happen since centralized is the default. Kept for
656 * the "use your own credentials" advanced flow.
657 *
658 * Shows a simple "Connect" button for centralized mode and an expandable
659 * advanced section for custom credentials.
660 *
661 * @return string
662 */
663 private function renderNotConfiguredState(): string {
664 $callbackUrl = $this->getCallbackUrl();
665 $s = $this->getSettings();
666 $copiedLabel = esc_js(__('Copied!', '404-solution'));
667
668 $html = '<p>' . esc_html__('Connect to Google Search Console to see which broken URLs were getting real search traffic.', '404-solution') . '</p>';
669
670 // Advanced: custom credentials toggle + wizard
671 $html .= '<details class="abj404-gsc-advanced">';
672 $html .= '<summary style="cursor:pointer;margin-top:12px;color:#646970;">' . esc_html__('Advanced: use your own Google Cloud credentials', '404-solution') . '</summary>';
673 $html .= '<div style="margin-top:10px;">';
674
675 $html .= '<p><strong>' . esc_html__('Setup steps:', '404-solution') . '</strong></p>';
676 $html .= '<ol class="abj404-wizard-steps">';
677 $html .= '<li>' . sprintf(esc_html__('Create a project in %s.', '404-solution'), '<a href="https://console.cloud.google.com/" target="_blank" rel="noopener">Google Cloud Console</a>') . '</li>';
678 $html .= '<li>' . esc_html__('Enable the "Google Search Console API".', '404-solution') . '</li>';
679 $html .= '<li>' . esc_html__('Create OAuth 2.0 credentials (Web application type).', '404-solution') . '</li>';
680 $html .= '<li>' . esc_html__('Add this Authorized Redirect URI to your OAuth client:', '404-solution');
681 $html .= '<div class="abj404-copy-uri-wrap">';
682 $html .= '<code id="abj404-gsc-callback-uri" class="abj404-gsc-callback-code">' . esc_html($callbackUrl) . '</code>';
683 $html .= '<button type="button" class="abj404-btn abj404-btn-secondary abj404-copy-btn" onclick="abj404CopyGscUri(this)">' . esc_html__('Copy', '404-solution') . '</button>';
684 $html .= '</div>';
685 $html .= '</li>';
686 $html .= '<li>' . esc_html__('Enter your Client ID and Client Secret below.', '404-solution') . '</li>';
687 $html .= '</ol>';
688
689 // Tiny inline script: copy button handler (admin-only page, no CSP concerns)
690 $html .= '<script>';
691 $html .= 'function abj404CopyGscUri(btn){';
692 $html .= 'var code=document.getElementById(\'abj404-gsc-callback-uri\');';
693 $html .= 'if(!code||!navigator.clipboard)return;';
694 $html .= 'navigator.clipboard.writeText(code.textContent.trim()).then(function(){';
695 $html .= 'var orig=btn.textContent;';
696 $html .= 'btn.textContent=\'' . $copiedLabel . '\';';
697 $html .= 'btn.classList.add(\'abj404-copy-btn--done\');';
698 $html .= 'setTimeout(function(){btn.textContent=orig;btn.classList.remove(\'abj404-copy-btn--done\');},2000);';
699 $html .= '});';
700 $html .= '}';
701 $html .= '</script>';
702
703 $nonceField = wp_nonce_field('abj404_gsc_save', '_wpnonce_gsc', true, false);
704 $html .= '<form method="POST">';
705 $html .= $nonceField;
706 $html .= '<input type="hidden" name="action" value="saveGscSettings">';
707 $html .= '<div class="abj404-form-group">';
708 $html .= '<label class="abj404-form-label" for="gsc_client_id">' . esc_html__('Client ID', '404-solution') . '</label>';
709 $html .= '<input type="text" name="gsc_client_id" id="gsc_client_id" class="abj404-form-input" value="' . esc_attr($s['client_id']) . '">';
710 $html .= '</div>';
711 $html .= '<div class="abj404-form-group">';
712 $html .= '<label class="abj404-form-label" for="gsc_client_secret">' . esc_html__('Client Secret', '404-solution') . '</label>';
713 $html .= '<input type="password" name="gsc_client_secret" id="gsc_client_secret" class="abj404-form-input" value="' . esc_attr($s['client_secret']) . '">';
714 $html .= '</div>';
715 $html .= '<div class="abj404-form-group">';
716 $html .= '<label class="abj404-form-label" for="gsc_site_url">' . esc_html__('Search Console Site URL', '404-solution') . '</label>';
717 $html .= '<input type="url" name="gsc_site_url" id="gsc_site_url" class="abj404-form-input" value="' . esc_attr($s['site_url']) . '">';
718 $html .= '<p class="abj404-form-help">' . esc_html__('The site URL as registered in Search Console (e.g. https://example.com/).', '404-solution') . '</p>';
719 $html .= '</div>';
720 $html .= '<button type="submit" class="abj404-btn abj404-btn-primary">' . esc_html__('Save Credentials', '404-solution') . '</button>';
721 $html .= '</form>';
722
723 $html .= '</div>'; // end details inner div
724 $html .= '</details>';
725
726 return $html;
727 }
728
729 /**
730 * State: configured but OAuth not yet completed.
731 *
732 * In centralized mode: shows a friendly "Connect to Google Search Console" button
733 * plus an advanced link to use custom credentials.
734 * In custom mode: shows the existing "Credentials saved — authorization required" UI.
735 *
736 * @return string
737 */
738 private function renderConfiguredNotConnectedState(): string {
739 $authUrl = $this->buildAuthUrl();
740 $revokeUrl = wp_nonce_url(admin_url('admin-ajax.php?action=abj404_gsc_revoke'), 'abj404_gsc_revoke');
741
742 if ($this->isCentralizedMode()) {
743 $html = '<p>' . esc_html__('Connect to Google Search Console to see which broken URLs were getting real search traffic.', '404-solution') . '</p>';
744 $html .= '<a href="' . esc_url($authUrl) . '" class="abj404-btn abj404-btn-primary">' . esc_html__('Connect to Google Search Console', '404-solution') . '</a>';
745
746 // Advanced: use your own credentials
747 $html .= '<details class="abj404-gsc-advanced" style="margin-top:16px;">';
748 $html .= '<summary style="cursor:pointer;color:#646970;">' . esc_html__('Advanced: use your own Google Cloud credentials', '404-solution') . '</summary>';
749 $html .= '<div style="margin-top:10px;">';
750 $html .= $this->renderCustomCredentialsForm();
751 $html .= '</div>';
752 $html .= '</details>';
753
754 return $html;
755 }
756
757 $html = '<div class="abj404-gsc-status abj404-gsc-status--amber">';
758 $html .= esc_html__('Credentials saved — authorization required', '404-solution');
759 $html .= '</div>';
760 $html .= '<p>' . esc_html__('Click the button below to authorize access to your Search Console data.', '404-solution') . '</p>';
761 $html .= '<a href="' . esc_url($authUrl) . '" class="abj404-btn abj404-btn-primary">' . esc_html__('Authorize with Google', '404-solution') . '</a>';
762 $html .= ' <a href="' . esc_url($revokeUrl) . '" class="abj404-btn abj404-btn-secondary">' . esc_html__('Remove Credentials', '404-solution') . '</a>';
763
764 return $html;
765 }
766
767 /**
768 * Render the custom credentials form used in the "Advanced" expandable section.
769 * Extracted to avoid duplication between renderNotConfiguredState and
770 * renderConfiguredNotConnectedState.
771 *
772 * @return string HTML
773 */
774 private function renderCustomCredentialsForm(): string {
775 $callbackUrl = $this->getCallbackUrl();
776 $s = $this->getSettings();
777 $copiedLabel = esc_js(__('Copied!', '404-solution'));
778
779 $html = '<p><strong>' . esc_html__('Setup steps:', '404-solution') . '</strong></p>';
780 $html .= '<ol class="abj404-wizard-steps">';
781 $html .= '<li>' . sprintf(esc_html__('Create a project in %s.', '404-solution'), '<a href="https://console.cloud.google.com/" target="_blank" rel="noopener">Google Cloud Console</a>') . '</li>';
782 $html .= '<li>' . esc_html__('Enable the "Google Search Console API".', '404-solution') . '</li>';
783 $html .= '<li>' . esc_html__('Create OAuth 2.0 credentials (Web application type).', '404-solution') . '</li>';
784 $html .= '<li>' . esc_html__('Add this Authorized Redirect URI to your OAuth client:', '404-solution');
785 $html .= '<div class="abj404-copy-uri-wrap">';
786 $html .= '<code id="abj404-gsc-callback-uri" class="abj404-gsc-callback-code">' . esc_html($callbackUrl) . '</code>';
787 $html .= '<button type="button" class="abj404-btn abj404-btn-secondary abj404-copy-btn" onclick="abj404CopyGscUri(this)">' . esc_html__('Copy', '404-solution') . '</button>';
788 $html .= '</div>';
789 $html .= '</li>';
790 $html .= '<li>' . esc_html__('Enter your Client ID and Client Secret below.', '404-solution') . '</li>';
791 $html .= '</ol>';
792
793 // Tiny inline script: copy button handler (admin-only page, no CSP concerns)
794 $html .= '<script>';
795 $html .= 'function abj404CopyGscUri(btn){';
796 $html .= 'var code=document.getElementById(\'abj404-gsc-callback-uri\');';
797 $html .= 'if(!code||!navigator.clipboard)return;';
798 $html .= 'navigator.clipboard.writeText(code.textContent.trim()).then(function(){';
799 $html .= 'var orig=btn.textContent;';
800 $html .= 'btn.textContent=\'' . $copiedLabel . '\';';
801 $html .= 'btn.classList.add(\'abj404-copy-btn--done\');';
802 $html .= 'setTimeout(function(){btn.textContent=orig;btn.classList.remove(\'abj404-copy-btn--done\');},2000);';
803 $html .= '});';
804 $html .= '}';
805 $html .= '</script>';
806
807 $nonceField = wp_nonce_field('abj404_gsc_save', '_wpnonce_gsc', true, false);
808 $html .= '<form method="POST">';
809 $html .= $nonceField;
810 $html .= '<input type="hidden" name="action" value="saveGscSettings">';
811 $html .= '<div class="abj404-form-group">';
812 $html .= '<label class="abj404-form-label" for="gsc_client_id">' . esc_html__('Client ID', '404-solution') . '</label>';
813 $html .= '<input type="text" name="gsc_client_id" id="gsc_client_id" class="abj404-form-input" value="' . esc_attr($s['client_id']) . '">';
814 $html .= '</div>';
815 $html .= '<div class="abj404-form-group">';
816 $html .= '<label class="abj404-form-label" for="gsc_client_secret">' . esc_html__('Client Secret', '404-solution') . '</label>';
817 $html .= '<input type="password" name="gsc_client_secret" id="gsc_client_secret" class="abj404-form-input" value="' . esc_attr($s['client_secret']) . '">';
818 $html .= '</div>';
819 $html .= '<div class="abj404-form-group">';
820 $html .= '<label class="abj404-form-label" for="gsc_site_url">' . esc_html__('Search Console Site URL', '404-solution') . '</label>';
821 $html .= '<input type="url" name="gsc_site_url" id="gsc_site_url" class="abj404-form-input" value="' . esc_attr($s['site_url']) . '">';
822 $html .= '<p class="abj404-form-help">' . esc_html__('The site URL as registered in Search Console (e.g. https://example.com/).', '404-solution') . '</p>';
823 $html .= '</div>';
824 $html .= '<button type="submit" class="abj404-btn abj404-btn-primary">' . esc_html__('Save Credentials', '404-solution') . '</button>';
825 $html .= '</form>';
826
827 return $html;
828 }
829
830 /**
831 * State: fully connected. Shows a green status pill + traffic data table.
832 *
833 * Reads only from the transient cache — never calls the GSC API directly.
834 * The cache is populated by the nightly cron or a background refresh.
835 *
836 * @return string
837 */
838 private function renderConnectedState(): string {
839 $revokeUrl = wp_nonce_url(admin_url('admin-ajax.php?action=abj404_gsc_revoke'), 'abj404_gsc_revoke');
840
841 $html = '<div class="abj404-gsc-status abj404-gsc-status--green">';
842 $html .= esc_html__('Connected to Google Search Console', '404-solution');
843 $html .= '</div>';
844 $html .= '<p>' . esc_html__('Search traffic data for your captured 404 URLs is shown below. Data is refreshed nightly.', '404-solution') . '</p>';
845 $html .= '<a href="' . esc_url($revokeUrl) . '" class="abj404-btn abj404-btn-secondary">' . esc_html__('Disconnect', '404-solution') . '</a>';
846
847 $cached = $this->getCachedData();
848
849 if (is_array($cached) && !empty($cached)) {
850 $html .= '<h4>' . esc_html__('404 URLs with Search Traffic (last 90 days)', '404-solution') . '</h4>';
851 $html .= '<table class="abj404-table" style="margin-top:8px;">';
852 $html .= '<thead><tr>';
853 $html .= '<th>' . esc_html__('URL', '404-solution') . '</th>';
854 $html .= '<th>' . esc_html__('Clicks', '404-solution') . '</th>';
855 $html .= '<th>' . esc_html__('Impressions', '404-solution') . '</th>';
856 $html .= '<th>' . esc_html__('Avg. Position', '404-solution') . '</th>';
857 $html .= '</tr></thead><tbody>';
858 foreach (array_slice($cached, 0, 25) as $row) {
859 if (!is_array($row)) {
860 continue;
861 }
862 $html .= '<tr>';
863 $rowUrl = isset($row['url']) && is_scalar($row['url']) ? (string)$row['url'] : '';
864 $rowClicks = isset($row['clicks']) && is_scalar($row['clicks']) ? (string)$row['clicks'] : '0';
865 $rowImpressions = isset($row['impressions']) && is_scalar($row['impressions']) ? (string)$row['impressions'] : '0';
866 $rowPosition = isset($row['position']) && is_scalar($row['position']) ? (string)$row['position'] : '';
867 $html .= '<td>' . esc_html($rowUrl) . '</td>';
868 $html .= '<td>' . esc_html($rowClicks) . '</td>';
869 $html .= '<td>' . esc_html($rowImpressions) . '</td>';
870 $html .= '<td>' . esc_html($rowPosition) . '</td>';
871 $html .= '</tr>';
872 }
873 $html .= '</tbody></table>';
874 } elseif ($this->isRefreshNeeded()) {
875 $html .= '<p style="margin-top:12px;color:#646970;">' . esc_html__('GSC data is being fetched in the background. Reload this page in a few minutes.', '404-solution') . '</p>';
876 } else {
877 $html .= '<p style="margin-top:12px;color:#646970;">' . esc_html__('No search traffic data found for your captured 404 URLs in the last 90 days.', '404-solution') . '</p>';
878 }
879
880 return $html;
881 }
882
883 /**
884 * State: authorization was attempted but failed.
885 * Shows a danger box with the error message + Try Again / Remove Credentials buttons.
886 * @return string
887 */
888 private function renderErrorState(): string {
889 $error = $this->getLastOAuthError();
890 $authUrl = $this->buildAuthUrl();
891 $revokeUrl = wp_nonce_url(admin_url('admin-ajax.php?action=abj404_gsc_revoke'), 'abj404_gsc_revoke');
892
893 $html = '<div class="abj404-gsc-error-box">';
894 $html .= '<strong>' . esc_html__('Authorization failed', '404-solution') . '</strong>';
895 if ($error !== '') {
896 $html .= '<p>' . esc_html($error) . '</p>';
897 }
898 $html .= '</div>';
899 $html .= '<p>' . esc_html__("Click 'Try Again' to retry authorization, or 'Remove Credentials' to start over.", '404-solution') . '</p>';
900 $html .= '<a href="' . esc_url($authUrl) . '" class="abj404-btn abj404-btn-primary">' . esc_html__('Try Again', '404-solution') . '</a>';
901 $html .= ' <a href="' . esc_url($revokeUrl) . '" class="abj404-btn abj404-btn-secondary">' . esc_html__('Remove Credentials', '404-solution') . '</a>';
902
903 return $html;
904 }
905 }
906