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/api/class-integrations-endpoint.php +183 -229 1.10.02.7.0 View file →
@@ -52,8 +52,23 @@
52 52 */
53 53 protected $rest_base = 'integrations';
54 54
55 55 /**
56 + * Transient holding the verified Search Console property list.
57 + *
58 + * Deliberately a fixed key rather than one namespaced per account: a purge
59 + * has to be possible from paths that have already cleared the credentials
60 + * (disconnect, revoke), and those can no longer derive an account-specific
61 + * key. The account is instead fingerprinted inside the payload and checked
62 + * on read, so a cache written by one Google account can never be served to
63 + * another even if a purge is missed.
64 + *
65 + * @since 1.28.0
66 + * @var string
67 + */
68 + public const SITES_CACHE_KEY = 'thinkrank_gsc_sites_list';
69 +
70 + /**
56 71 * Settings instance
57 72 *
58 73 * @since 1.0.0
59 74 * @var Settings
@@ -65,9 +80,9 @@
65 80 *
66 81 * @since 1.0.0
67 82 */
68 83 public function __construct() {
69 - $this->settings = new Settings();
84 + $this->settings = Settings::instance();
70 85 }
71 86
72 87 /**
73 88 * Register API routes
@@ -87,9 +102,9 @@
87 102 ],
88 103 [
89 104 'methods' => 'POST',
90 105 'callback' => [$this, 'update_settings'],
91 - 'permission_callback' => [$this, 'check_manage_permissions'],
106 + 'permission_callback' => [$this, 'check_credential_permissions'],
92 107 'args' => $this->get_settings_args()
93 108 ]
94 109 ]
95 110 );
@@ -101,48 +116,13 @@
101 116 [
102 117 [
103 118 'methods' => 'POST',
104 119 'callback' => [$this, 'test_connections'],
105 - 'permission_callback' => [$this, 'check_manage_permissions']
120 + 'permission_callback' => [$this, 'check_credential_permissions']
106 121 ]
107 122 ]
108 123 );
109 124
110 - // Verify GA4 tracking
111 - register_rest_route(
112 - $this->namespace,
113 - '/' . $this->rest_base . '/verify-ga4-tracking',
114 - [
115 - [
116 - 'methods' => 'POST',
117 - 'callback' => [$this, 'verify_ga4_tracking'],
118 - 'permission_callback' => [$this, 'check_manage_permissions'],
119 - 'args' => [
120 - 'measurement_id' => [
121 - 'required' => true,
122 - 'type' => 'string',
123 - 'pattern' => '/^G-[A-Z0-9]{10}$/',
124 - 'sanitize_callback' => 'sanitize_text_field',
125 - 'description' => 'GA4 Measurement ID in format G-XXXXXXXXXX'
126 - ]
127 - ]
128 - ]
129 - ]
130 - );
131 -
132 - // Detect GA4 conflicts
133 - register_rest_route(
134 - $this->namespace,
135 - '/' . $this->rest_base . '/detect-ga4-conflicts',
136 - [
137 - [
138 - 'methods' => 'GET',
139 - 'callback' => [$this, 'detect_ga4_conflicts'],
140 - 'permission_callback' => [$this, 'check_read_permissions']
141 - ]
142 - ]
143 - );
144 -
145 125 // Get Search Console Sites
146 126 register_rest_route(
147 127 $this->namespace,
148 128 '/' . $this->rest_base . '/search-console/sites',
@@ -149,9 +129,16 @@
149 129 [
150 130 [
151 131 'methods' => 'GET',
152 132 'callback' => [$this, 'get_search_console_sites'],
153 - 'permission_callback' => [$this, 'check_manage_permissions']
133 + 'permission_callback' => [$this, 'check_manage_permissions'],
134 + 'args' => [
135 + 'refresh' => [
136 + 'description' => 'Bypass the cached property list and re-query Google.',
137 + 'type' => 'boolean',
138 + 'default' => false,
139 + ],
140 + ],
154 141 ]
155 142 ]
156 143 );
157 144
@@ -158,56 +145,14 @@
158 145 // Disconnect Google Account
159 146 register_rest_route($this->namespace, '/integrations/google/disconnect', [
160 147 'methods' => WP_REST_Server::CREATABLE,
161 148 'callback' => [$this, 'disconnect_google_account'],
162 - 'permission_callback' => [$this, 'check_manage_permissions'] // Changed to check_manage_permissions for consistency
149 + 'permission_callback' => [$this, 'check_credential_permissions']
163 150 ]);
164 151
165 - // Save Google OAuth Tokens
166 - register_rest_route(
167 - $this->namespace,
168 - '/' . $this->rest_base . '/save-google-token',
169 - [
170 - [
171 - 'methods' => 'POST',
172 - 'callback' => [$this, 'save_google_token'],
173 - 'permission_callback' => [$this, 'check_manage_permissions'],
174 - 'args' => [
175 - 'access_token' => [
176 - 'required' => true,
177 - 'type' => 'string',
178 - 'sanitize_callback' => 'sanitize_text_field',
179 - ],
180 - 'refresh_token' => [
181 - 'required' => false,
182 - 'type' => 'string',
183 - 'sanitize_callback' => 'sanitize_text_field',
184 - ],
185 - 'expires_in' => [
186 - 'required' => false,
187 - 'type' => 'integer',
188 - ],
189 - 'created' => [
190 - 'required' => false,
191 - 'type' => 'integer',
192 - ],
193 - ]
194 - ]
195 - ]
196 - );
197 -
198 - // Get Search Console Sites
199 - register_rest_route(
200 - $this->namespace,
201 - '/' . $this->rest_base . '/search-console/sites',
202 - [
203 - [
204 - 'methods' => 'GET',
205 - 'callback' => [$this, 'get_search_console_sites'],
206 - 'permission_callback' => [$this, 'check_manage_permissions']
207 - ]
208 - ]
209 - );
152 + // Note: there is no save-google-token route. Tokens are swapped
153 + // server-to-server in Google_OAuth_Proxy and never pass through the
154 + // browser, so there is nothing for the SPA to hand back.
210 155 }
211 156
212 157 /**
213 158 * Get integrations settings
@@ -346,22 +291,13 @@
346 291 $settings['google_analytics_api_key'] = $this->settings->get('google_analytics_api_key');
347 292 $settings['google_search_console_api_key'] = $this->settings->get('google_search_console_api_key');
348 293 $settings['google_pagespeed_api_key'] = $this->settings->get('google_pagespeed_api_key');
349 294
350 - // Get GA4 tracking settings (let Settings class handle defaults)
351 - $settings['ga4_measurement_id'] = $this->settings->get('ga4_measurement_id');
352 - $settings['ga4_auto_inject'] = $this->settings->get('ga4_auto_inject');
353 - $settings['ga4_anonymize_ip'] = $this->settings->get('ga4_anonymize_ip');
354 - $settings['ga4_exclude_admin'] = $this->settings->get('ga4_exclude_admin');
355 - $settings['ga4_tracking_verified'] = $this->settings->get('ga4_tracking_verified');
356 - $settings['ga4_last_verification'] = $this->settings->get('ga4_last_verification');
357 -
358 295 // Get other integration settings (let Settings class handle defaults)
359 296 $settings['api_timeout'] = $this->settings->get('api_timeout');
360 297 $settings['enable_rate_limiting'] = $this->settings->get('enable_rate_limiting');
361 298 $settings['cache_duration'] = $this->settings->get('cache_duration');
362 299 $settings['auto_test_connections'] = $this->settings->get('auto_test_connections');
363 - $settings['auto_test_connections'] = $this->settings->get('auto_test_connections');
364 300 $settings['retry_failed_requests'] = $this->settings->get('retry_failed_requests');
365 301 $settings['google_account_connected'] = $this->settings->get('google_account_connected');
366 302
367 303 // Mask API keys for security (like OpenAI/Claude keys)
@@ -403,36 +339,42 @@
403 339 */
404 340 private function sanitize_settings(array $settings): array {
405 341 $sanitized = [];
406 342
407 - // Sanitize API keys (only if not empty - don't overwrite with empty values)
408 - if (!empty($settings['google_analytics_api_key'])) {
409 - $sanitized['google_analytics_api_key'] = sanitize_text_field($settings['google_analytics_api_key']);
343 + // Sanitize API keys. Skip empty values AND the masked sentinel returned
344 + // by get_integrations_settings (mask_api_key appends 'XXXX'); resubmitting
345 + // the mask must not overwrite the real stored key.
346 + foreach (['google_analytics_api_key', 'google_search_console_api_key', 'google_pagespeed_api_key'] as $key_field) {
347 + if (!empty($settings[$key_field]) && !$this->is_masked_api_key($settings[$key_field])) {
348 + $sanitized[$key_field] = sanitize_text_field($settings[$key_field]);
349 + }
410 350 }
411 - if (!empty($settings['google_search_console_api_key'])) {
412 - $sanitized['google_search_console_api_key'] = sanitize_text_field($settings['google_search_console_api_key']);
351 +
352 + // A key the payload never mentioned is left alone rather than being
353 + // reset to a hard-coded default. These fallbacks used to fire on every
354 + // save, so a partial payload — or a setting the admin has no control
355 + // for, like retry_failed_requests — silently reverted to the default a
356 + // site owner had deliberately changed in code (#297).
357 + $numeric = ['api_timeout', 'cache_duration'];
358 +
359 + foreach ($numeric as $key) {
360 + if (array_key_exists($key, $settings)) {
361 + $sanitized[$key] = absint($settings[$key]);
362 + }
413 363 }
414 - if (!empty($settings['google_pagespeed_api_key'])) {
415 - $sanitized['google_pagespeed_api_key'] = sanitize_text_field($settings['google_pagespeed_api_key']);
416 - }
417 364
418 - // Sanitize numeric settings
419 - $sanitized['api_timeout'] = absint($settings['api_timeout'] ?? 30);
420 - $sanitized['cache_duration'] = absint($settings['cache_duration'] ?? 3600);
365 + $booleans = [
366 + 'enable_rate_limiting',
367 + 'auto_test_connections',
368 + 'retry_failed_requests',
369 + ];
421 370
422 - // Sanitize GA4 tracking settings
423 - $sanitized['ga4_measurement_id'] = sanitize_text_field($settings['ga4_measurement_id'] ?? '');
424 - $sanitized['ga4_auto_inject'] = isset($settings['ga4_auto_inject']) ? (bool) $settings['ga4_auto_inject'] : false;
425 - $sanitized['ga4_anonymize_ip'] = isset($settings['ga4_anonymize_ip']) ? (bool) $settings['ga4_anonymize_ip'] : false;
426 - $sanitized['ga4_exclude_admin'] = isset($settings['ga4_exclude_admin']) ? (bool) $settings['ga4_exclude_admin'] : false;
427 - $sanitized['ga4_tracking_verified'] = isset($settings['ga4_tracking_verified']) ? (bool) $settings['ga4_tracking_verified'] : false;
428 - $sanitized['ga4_last_verification'] = sanitize_text_field($settings['ga4_last_verification'] ?? '');
371 + foreach ($booleans as $key) {
372 + if (array_key_exists($key, $settings)) {
373 + $sanitized[$key] = (bool) $settings[$key];
374 + }
375 + }
429 376
430 - // Sanitize boolean settings
431 - $sanitized['enable_rate_limiting'] = isset($settings['enable_rate_limiting']) ? (bool) $settings['enable_rate_limiting'] : true;
432 - $sanitized['auto_test_connections'] = isset($settings['auto_test_connections']) ? (bool) $settings['auto_test_connections'] : true;
433 - $sanitized['retry_failed_requests'] = isset($settings['retry_failed_requests']) ? (bool) $settings['retry_failed_requests'] : true;
434 -
435 377 return $sanitized;
436 378 }
437 379
438 380 /**
@@ -455,8 +397,20 @@
455 397 return 'XXXX';
456 398 }
457 399
458 400 /**
401 + * Whether a submitted value is the masked sentinel produced by mask_api_key
402 + * (so we don't persist the mask over a real key).
403 + *
404 + * @since 1.0.0
405 + * @param string $value Submitted value
406 + * @return bool
407 + */
408 + private function is_masked_api_key(string $value): bool {
409 + return 'XXXX' === $value || str_ends_with($value, 'XXXX');
410 + }
411 +
412 + /**
459 413 * Test Google Analytics API connection
460 414 *
461 415 * Makes a real API call to Google's PageSpeed Insights API to verify
462 416 * that the API key is valid and has proper permissions.
@@ -795,56 +749,29 @@
795 749 ];
796 750 }
797 751
798 752 /**
799 - * Save Google OAuth Token
753 + * Disconnect Google Account
800 754 *
801 755 * @since 1.0.0
802 756 * @param WP_REST_Request $request Request object
803 757 * @return WP_REST_Response|WP_Error Response object
804 758 */
805 - public function save_google_token(WP_REST_Request $request): WP_REST_Response|WP_Error {
759 + public function disconnect_google_account(WP_REST_Request $request) {
806 760 try {
807 - $access_token = $request->get_param('access_token');
808 - $refresh_token = $request->get_param('refresh_token');
809 - $expires_in = $request->get_param('expires_in');
810 - $created = $request->get_param('created') ?: time();
811 -
812 - // Save to settings
813 - $this->settings->set('google_access_token', $access_token);
814 - if (!empty($refresh_token)) {
815 - $this->settings->set('google_refresh_token', $refresh_token);
761 + // Best-effort revoke at Google so the refresh token (which never
762 + // auto-expires) can't keep querying on the admin's behalf after
763 + // disconnect. Failure here must not block local cleanup.
764 + $token_to_revoke = $this->settings->get('google_refresh_token', '')
765 + ?: $this->settings->get('google_access_token', '');
766 + if (!empty($token_to_revoke)) {
767 + wp_remote_post('https://oauth2.googleapis.com/revoke', [
768 + 'timeout' => 10,
769 + 'headers' => ['Content-Type' => 'application/x-www-form-urlencoded'],
770 + 'body' => ['token' => $token_to_revoke],
771 + ]);
816 772 }
817 - if (!empty($expires_in)) {
818 - $this->settings->set('google_token_expires_in', $expires_in);
819 - }
820 - $this->settings->set('google_token_created', $created);
821 773
822 - // Mark as connected
823 - $this->settings->set('google_account_connected', true);
824 -
825 - return new WP_REST_Response([
826 - 'success' => true,
827 - 'message' => 'Google tokens saved successfully'
828 - ], 200);
829 - } catch (\Exception $e) {
830 - return new WP_Error(
831 - 'save_token_failed',
832 - 'Failed to save Google tokens: ' . $e->getMessage(),
833 - ['status' => 500]
834 - );
835 - }
836 - }
837 -
838 - /**
839 - * Disconnect Google Account
840 - *
841 - * @since 1.0.0
842 - * @param WP_REST_Request $request Request object
843 - * @return WP_REST_Response|WP_Error Response object
844 - */
845 - public function disconnect_google_account(WP_REST_Request $request): WP_REST_Response|WP_Error {
846 - try {
847 774 // Clear all Google-related settings
848 775 $this->settings->set('google_access_token', '');
849 776 $this->settings->set('google_refresh_token', '');
850 777 $this->settings->set('google_token_expires_in', '');
@@ -849,11 +776,25 @@
849 776 $this->settings->set('google_refresh_token', '');
850 777 $this->settings->set('google_token_expires_in', '');
851 778 $this->settings->set('google_token_created', '');
852 779 $this->settings->set('google_account_connected', false);
853 - // Also clear site selection
854 - $this->settings->set('google_search_console_site', '');
780 + // Also clear site selection. This targeted `google_search_console_site`,
781 + // which is not a declared setting — Settings::set() rejects unknown
782 + // keys, so the line never cleared anything and the selection
783 + // survived every disconnect. The property picker writes
784 + // `search_console_property`; clear that and the GA4 property beside
785 + // it, so a reconnect under a different Google account doesn't
786 + // inherit the previous account's selections.
787 + $this->settings->set('search_console_property', '');
788 + $this->settings->set('seo_analytics_google_analytics_property_id', '');
855 789
790 + // The cached property list belongs to the account we just dropped —
791 + // leaving it would serve those properties to whoever connects next.
792 + self::purge_search_console_sites_cache();
793 +
794 + // A deliberate disconnect is not a forced re-authorization.
795 + delete_option('thinkrank_google_reconnect_required');
796 +
856 797 return new WP_REST_Response([
857 798 'success' => true,
858 799 'message' => 'Google account disconnected successfully'
859 800 ], 200);
@@ -889,9 +830,9 @@
889 830 * @since 1.0.0
890 831 * @return bool Permission status
891 832 */
892 833 public function check_read_permissions(): bool {
893 - return current_user_can('manage_options');
834 + return \ThinkRank\Core\Capability_Manager::current_user_can('thinkrank_settings');
894 835 }
895 836
896 837 /**
897 838 * Check manage permissions
@@ -899,87 +840,60 @@
899 840 * @since 1.0.0
900 841 * @return bool Permission status
901 842 */
902 843 public function check_manage_permissions(): bool {
844 + return \ThinkRank\Core\Capability_Manager::current_user_can('thinkrank_settings');
845 + }
846 +
847 + /**
848 + * Check permissions for credential-managing operations.
849 + *
850 + * Writing provider API keys, disconnecting Google (a server-side token
851 + * revoke) and running live connection tests manage the site's third-party
852 + * credentials, so they require an administrator — `thinkrank_settings` is
853 + * delegatable to non-admin roles through the Role Manager. Mirrors the
854 + * pattern used by the AI-insights settings writes.
855 + *
856 + * @since 1.29.0
857 + * @return bool Permission status
858 + */
859 + public function check_credential_permissions(): bool {
903 860 return current_user_can('manage_options');
904 861 }
905 862
906 863 /**
907 - * Verify GA4 tracking
908 - * Following ThinkRank API response patterns
864 + * Fingerprint the currently connected Google account.
909 865 *
910 - * @since 1.0.0
911 - * @param WP_REST_Request $request Request object
912 - * @return WP_REST_Response|WP_Error Response object
866 + * Prefers the refresh token: it is issued once per authorization grant and
867 + * survives every access-token rotation, so the cache stays warm for a whole
868 + * connection but changes the moment a different account authorizes. Falls
869 + * back to the access token when no refresh token was granted, which merely
870 + * shortens the effective cache life to one token lifetime.
871 + *
872 + * @since 1.28.0
873 + * @return string Non-reversible fingerprint, empty string when disconnected.
913 874 */
914 - public function verify_ga4_tracking(WP_REST_Request $request): WP_REST_Response|WP_Error {
915 - try {
916 - $measurement_id = $request->get_param('measurement_id');
875 + private function get_google_account_fingerprint(): string {
876 + $token = $this->settings->get('google_refresh_token', '')
877 + ?: $this->settings->get('google_access_token', '');
917 878
918 - if (empty($measurement_id)) {
919 - return new WP_Error(
920 - 'missing_measurement_id',
921 - 'Measurement ID is required',
922 - ['status' => 400]
923 - );
924 - }
925 -
926 - // Load tracking manager
927 - if (!class_exists('ThinkRank\\Frontend\\Google_Analytics_Tracking_Manager')) {
928 - require_once THINKRANK_PLUGIN_DIR . 'includes/frontend/class-google-analytics-tracking-manager.php';
929 - }
930 -
931 - $tracking_manager = new \ThinkRank\Frontend\Google_Analytics_Tracking_Manager();
932 - $verification_result = $tracking_manager->verify_tracking($measurement_id);
933 -
934 - return new WP_REST_Response([
935 - 'success' => true,
936 - 'data' => $verification_result,
937 - 'message' => 'Tracking verification completed'
938 - ], 200);
939 - } catch (\Exception $e) {
940 - return new WP_Error(
941 - 'verification_failed',
942 - 'Tracking verification failed: ' . $e->getMessage(),
943 - ['status' => 500]
944 - );
945 - }
879 + return empty($token) ? '' : md5((string) $token);
946 880 }
947 881
948 882 /**
949 - * Detect GA4 conflicts
950 - * Following ThinkRank API response patterns
883 + * Drop the cached Search Console property list.
951 884 *
952 - * @since 1.0.0
953 - * @param WP_REST_Request $request Request object
954 - * @return WP_REST_Response|WP_Error Response object
885 + * Public and static so the OAuth paths — which run outside this controller
886 + * and after the credentials are gone — can invalidate the list on connect,
887 + * disconnect and revoke.
888 + *
889 + * @since 1.28.0
890 + * @return void
955 891 */
956 - public function detect_ga4_conflicts(WP_REST_Request $request): WP_REST_Response|WP_Error {
957 - try {
958 - // Load tracking manager
959 - if (!class_exists('ThinkRank\\Frontend\\Google_Analytics_Tracking_Manager')) {
960 - require_once THINKRANK_PLUGIN_DIR . 'includes/frontend/class-google-analytics-tracking-manager.php';
961 - }
892 + public static function purge_search_console_sites_cache(): void {
893 + delete_transient(self::SITES_CACHE_KEY);
894 + }
962 895
963 - $tracking_manager = new \ThinkRank\Frontend\Google_Analytics_Tracking_Manager();
964 - $conflicts = $tracking_manager->detect_existing_tracking();
965 -
966 - return new WP_REST_Response([
967 - 'success' => true,
968 - 'data' => [
969 - 'conflicts' => $conflicts,
970 - 'has_conflicts' => !empty($conflicts)
971 - ],
972 - 'message' => 'Conflict detection completed'
973 - ], 200);
974 - } catch (\Exception $e) {
975 - return new WP_Error(
976 - 'conflict_detection_failed',
977 - 'Conflict detection failed: ' . $e->getMessage(),
978 - ['status' => 500]
979 - );
980 - }
981 - }
982 896 /**
983 897 * Get Search Console Sites
984 898 *
985 899 * @since 1.0.0
@@ -984,8 +898,10 @@
984 898 *
985 899 * @since 1.0.0
986 900 * @param WP_REST_Request $request Request object
987 901 * @return WP_REST_Response Response object
902 + *
903 + * @throws \Exception On failure.
988 904 */
989 905 public function get_search_console_sites(WP_REST_Request $request): WP_REST_Response {
990 906 try {
991 907 // Ensure Analytics_Manager is loaded for proactive token refresh
@@ -1006,8 +922,26 @@
1006 922 'message' => 'Google account not connected'
1007 923 ], 401);
1008 924 }
1009 925
926 + // The verified-sites list changes rarely but costs a live Google
927 + // round-trip — serve from a 30-minute transient so the Google
928 + // Services screen doesn't hit Google on every render. The cache is
929 + // only honoured for the account that wrote it: reconnecting as a
930 + // different Google account must never hand back the previous
931 + // account's properties, which reads as "my site is missing".
932 + $account = $this->get_google_account_fingerprint();
933 + $cached_sites = get_transient(self::SITES_CACHE_KEY);
934 +
935 + if (
936 + !$request->get_param('refresh')
937 + && is_array($cached_sites)
938 + && isset($cached_sites['account'], $cached_sites['payload'])
939 + && hash_equals($account, (string) $cached_sites['account'])
940 + ) {
941 + return new WP_REST_Response($cached_sites['payload'], 200);
942 + }
943 +
1010 944 // Initialize Search Console Client
1011 945 if (!class_exists('ThinkRank\\Integrations\\Google_Search_Console_Client')) {
1012 946 require_once THINKRANK_PLUGIN_DIR . 'includes/integrations/class-google-search-console-client.php';
1013 947 }
@@ -1035,9 +969,12 @@
1035 969 break; // Success
1036 970 } catch (\Exception $e) {
1037 971 // Check for 401 error
1038 972 if ($e->getCode() === 401 && $retry_count < $max_retries) {
1039 - error_log('ThinkRank: 401 detected in get_search_console_sites. Forcing token refresh...');
973 + if ( defined( 'WP_DEBUG' ) && WP_DEBUG ) {
974 + // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log
975 + error_log('ThinkRank: 401 detected in get_search_console_sites. Forcing token refresh...');
976 + }
1040 977
1041 978 // Initialize Analytics Manager to handle token refresh
1042 979 $analytics_manager = new \ThinkRank\SEO\Analytics_Manager();
1043 980 $analytics_manager->refresh_access_token(true); // Force refresh
@@ -1068,15 +1005,32 @@
1068 1005 ];
1069 1006 }
1070 1007 }
1071 1008
1072 - return new WP_REST_Response([
1009 + $payload = [
1073 1010 'success' => true,
1074 1011 'data' => [
1075 1012 'sites' => $sites
1076 1013 ],
1077 1014 'message' => 'Search Console sites retrieved successfully'
1078 - ], 200);
1015 + ];
1016 +
1017 + // Cache successes only — errors must stay retryable. Recompute the
1018 + // fingerprint: the 401 retry above may have rotated the access
1019 + // token, and the cache must be stamped with the account it came
1020 + // from, not the one we started the request with.
1021 + if (!empty($sites)) {
1022 + set_transient(
1023 + self::SITES_CACHE_KEY,
1024 + [
1025 + 'account' => $this->get_google_account_fingerprint(),
1026 + 'payload' => $payload,
1027 + ],
1028 + 30 * MINUTE_IN_SECONDS
1029 + );
1030 + }
1031 +
1032 + return new WP_REST_Response($payload, 200);
1079 1033 } catch (\Exception $e) {
1080 1034 $code = $e->getCode();
1081 1035 $status = ($code >= 400 && $code < 600) ? $code : 500;
1082 1036