PluginProbe
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO / 2.1.1
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO v2.1.1
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
thinkrank / includes / core / class-settings.php

class-settings.php in ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO 2.1.1, at includes/core/class-settings.php

751 lines 26.6 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3 /**
4 * Settings Class
5 *
6 * Handles plugin settings and configuration using WordPress options
7 *
8 * @package ThinkRank\Core
9 * @since 1.0.0
10 */
11
12 declare(strict_types=1);
13
14 namespace ThinkRank\Core;
15
16 // Prevent direct access
17 if (!defined('ABSPATH')) {
18 exit;
19 }
20
21 /**
22 * Settings Class
23 *
24 * Single Responsibility: Manage plugin settings and configuration
25 * Uses WordPress options for storage.
26 *
27 * Use Settings::instance() to get the shared instance rather than
28 * creating new instances — this ensures the internal cache is shared
29 * across all consumers and avoids redundant get_option() calls.
30 *
31 * @since 1.0.0
32 */
33 class Settings {
34
35 /**
36 * Canonical default AI model per provider.
37 *
38 * Single source of truth. Every call site that needs a provider's default
39 * model — the $defaults array below, the AI client constructors, the manager
40 * fallbacks, the content-brief generator and the usage-analytics endpoint —
41 * MUST reference these constants instead of repeating the literal, so the
42 * defaults can never silently drift out of sync (see issue #273).
43 *
44 * @since 1.28.0
45 */
46 const DEFAULT_OPENAI_MODEL = 'gpt-5-nano';
47 const DEFAULT_CLAUDE_MODEL = 'claude-sonnet-5';
48 const DEFAULT_GEMINI_MODEL = 'gemini-3.5-flash';
49 const DEFAULT_OPENROUTER_MODEL = 'openai/gpt-4o-mini';
50
51 /**
52 * Canonical default author-archive templates.
53 *
54 * Same single-source-of-truth rule as the model constants above: the
55 * defaults array, the REST endpoint, the get-settings ability and
56 * Author_Archives_Manager all read these instead of repeating the literal,
57 * which had already drifted — the title default hardcoded an en dash while
58 * the feature resolves %separator% from Site Identity (#318).
59 *
60 * @since 1.29.1
61 */
62 const DEFAULT_AUTHOR_ARCHIVES_TITLE = '%author_name% %separator% %site_title% %page%';
63 const DEFAULT_AUTHOR_ARCHIVES_META_DESC = 'Articles written by %author_name% on %site_title%';
64
65 /**
66 * String settings where a stored empty string is a real value, not "unset".
67 *
68 * get() normally treats '' the same as a missing option and returns the
69 * default, which is right for most keys — a blank API key or model name is
70 * never what the user meant. For these template fields it is the opposite:
71 * clearing the box means "render no template", and the consumers already
72 * branch on an empty value. Without the opt-out the save appeared to
73 * succeed and the default reappeared on the next request (#316).
74 *
75 * @since 1.29.1
76 * @var string[]
77 */
78 private const EMPTY_IS_A_VALUE = [
79 'author_archives_title',
80 'author_archives_meta_desc',
81 ];
82
83 /**
84 * Shared singleton instance
85 *
86 * @var Settings|null
87 */
88 private static ?Settings $instance = null;
89
90 /**
91 * Get the shared Settings instance
92 *
93 * Returns the instance registered in the main plugin's component
94 * container, or creates a standalone instance if the plugin hasn't
95 * loaded yet (e.g., during activation).
96 *
97 * @since 1.10.0
98 * @return Settings
99 */
100 public static function instance(): Settings {
101 if (self::$instance !== null) {
102 return self::$instance;
103 }
104
105 // Try to get from the main plugin container
106 if (function_exists('thinkrank')) {
107 $plugin = thinkrank();
108 $component = $plugin->get_component('settings');
109 if ($component instanceof self) {
110 self::$instance = $component;
111 return self::$instance;
112 }
113 }
114
115 // Fallback: create standalone instance (during activation, etc.)
116 self::$instance = new self();
117 return self::$instance;
118 }
119
120 /**
121 * Settings cache
122 *
123 * @var array
124 */
125 private array $cache = [];
126
127 /**
128 * Default settings
129 *
130 * @var array
131 */
132 private array $defaults = [
133 // AI Settings
134 'ai_provider' => 'openai',
135 'openai_api_key' => '',
136 'openai_model' => self::DEFAULT_OPENAI_MODEL,
137 'claude_api_key' => '',
138 'claude_model' => self::DEFAULT_CLAUDE_MODEL, // Recommended default (best speed/quality balance)
139 'gemini_api_key' => '',
140 'gemini_model' => self::DEFAULT_GEMINI_MODEL,
141 'openrouter_api_key' => '',
142 'openrouter_model' => self::DEFAULT_OPENROUTER_MODEL,
143 'max_tokens' => 1000,
144 'temperature' => 0.7,
145
146 // Google API Keys (encrypted)
147 'google_analytics_api_key' => '',
148 'google_search_console_api_key' => '',
149 'google_pagespeed_api_key' => '',
150
151 // Google OAuth Tokens (encrypted)
152 'google_access_token' => '',
153 'google_refresh_token' => '',
154 'google_token_expires_in' => 0,
155 'google_token_created' => 0,
156 'google_account_connected' => false,
157
158 // Google Analytics Pro Settings. Read/written by thinkrank-pro's
159 // GoogleAnalyticsSettings.js through the settings-management endpoint —
160 // no consumer exists in THIS repo, so don't dead-key these.
161 'ga_analytics_account_id' => '',
162 'ga_analytics_data_stream_id' => '',
163
164 // Performance Settings
165 'cache_duration' => 3600,
166 'max_requests_per_minute' => 10,
167 'enable_logging' => true,
168
169 // Integration Settings
170 'api_timeout' => 30,
171 'enable_rate_limiting' => true,
172 'auto_test_connections' => true,
173 'retry_failed_requests' => true,
174
175 // Social Platform Settings
176 // Public IDs (not encrypted)
177 'facebook_app_id' => '',
178 'facebook_admins' => '',
179 'youtube_channel_id' => '',
180 'whatsapp_business_id' => '',
181 // Verification codes (encrypted)
182 'pinterest_site_verification' => '',
183 'instagram_verification' => '',
184 'tiktok_verification' => '',
185
186 // SEO Settings
187 'auto_optimize' => false,
188 'seo_score_threshold' => 70,
189 'enable_meta_generation' => true,
190 'enable_schema_markup' => true,
191
192 // Auto AI Optimization (on-publish metadata fill; #248 P1)
193 'auto_ai_meta_enabled' => false,
194 'auto_ai_meta_post_types' => ['post'],
195
196 // Brand Visibility v2. The brand profile drives question generation
197 // and mention detection; per-platform keys let this feature query
198 // several assistants without changing the site-wide AI provider.
199 'bv_brand_name' => '',
200 'bv_variants' => [],
201 'bv_location' => '',
202 'bv_category' => '',
203 'bv_description' => '',
204 'bv_competitors' => [],
205 'bv_queries' => [],
206 'bv_platforms' => ['chatgpt'],
207 'bv_samples' => 1,
208 'bv_key_chatgpt' => '',
209 'bv_key_gemini' => '',
210 'bv_key_claude' => '',
211 'bv_key_perplexity' => '',
212 // Empty = use the platform's default model (see Brand_Visibility_Providers).
213 'bv_model_chatgpt' => '',
214 'bv_model_gemini' => '',
215 'bv_model_claude' => '',
216 'bv_model_perplexity' => '',
217
218 // AI Brand Visibility (BYO-key checks; #248 P1)
219 'brand_visibility_queries' => [],
220
221 // Author Archives Settings
222 'author_archives_enabled' => true,
223 'author_archives_index' => true,
224 'author_archives_show_empty' => false,
225 'author_archives_title' => self::DEFAULT_AUTHOR_ARCHIVES_TITLE,
226 'author_archives_meta_desc' => self::DEFAULT_AUTHOR_ARCHIVES_META_DESC,
227
228 // UI Settings
229 'show_welcome_message' => true,
230 'dashboard_widgets' => ['seo_score', 'ai_usage', 'recent_briefs'],
231 'editor_panel_position' => 'side',
232
233 // Advanced Settings
234 'debug_mode' => false,
235 'retry_attempts' => 3,
236 // Exposes the standalone Migration admin page for re-running SEO data
237 // imports after setup. Hidden by default; opt-in for advanced/support use.
238 'enable_migration_tools' => false,
239
240 // Privacy Settings
241 'data_retention_days' => 90,
242 'anonymize_logs' => true,
243 'share_usage_data' => false,
244
245 // Integration Settings
246 'google_analytics_id' => '',
247 'search_console_property' => '',
248
249 // GA4 Tracking Settings
250 'ga4_measurement_id' => '',
251 'ga4_auto_inject' => false,
252 'ga4_anonymize_ip' => false,
253 'ga4_exclude_admin' => false,
254 'ga4_tracking_verified' => false,
255 'ga4_last_verification' => '',
256
257 // SEO Analytics Settings
258 'seo_analytics_enabled' => false,
259 'seo_analytics_setup_completed' => false,
260 'seo_analytics_google_analytics_property_id' => '',
261 'seo_analytics_enable_ai_insights' => true,
262 'seo_analytics_enable_automated_alerts' => false,
263 'seo_analytics_enable_predictive_analysis' => false,
264 'seo_analytics_monitoring_frequency' => 3600,
265 'seo_analytics_alert_thresholds' => [],
266 'seo_analytics_report_schedule' => 'weekly',
267 'seo_analytics_data_retention_days' => 90,
268 'seo_analytics_cache_analytics_data' => true,
269
270 // Uninstall Settings
271 'keep_data_on_uninstall' => true,
272
273 // MCP (Model Context Protocol) Settings
274 'enable_mcp' => false,
275 ];
276
277 /**
278 * Encrypted settings keys
279 *
280 * @var array
281 */
282 private array $encrypted_keys = [
283 // AI API Keys
284 'openai_api_key',
285 'claude_api_key',
286 'gemini_api_key',
287 'openrouter_api_key',
288 // Google API Keys
289 'google_analytics_api_key',
290 'google_search_console_api_key',
291 'google_pagespeed_api_key',
292 'google_access_token',
293 'google_refresh_token',
294 // Social Platform Verification Codes (sensitive)
295 'pinterest_site_verification',
296 'instagram_verification',
297 'tiktok_verification',
298 ];
299
300 /**
301 * Initialize settings
302 *
303 * @return void
304 */
305 public function init(): void {
306 add_action('admin_init', [$this, 'register_settings']);
307 }
308
309 /**
310 * Register WordPress settings
311 *
312 * @return void
313 */
314 public function register_settings(): void {
315 register_setting('thinkrank_settings', 'thinkrank_settings', [
316 'sanitize_callback' => [$this, 'sanitize_settings'],
317 'default' => $this->defaults,
318 ]);
319 }
320
321 /**
322 * Warm the option cache for a batch of setting keys in one query.
323 *
324 * Every `thinkrank_*` option is autoload=off, so WordPress cannot serve
325 * them from `alloptions` and each get() below is its own round-trip. A
326 * caller that reads a known list of keys should prime it first: on a
327 * ~1ms managed-hosting round-trip, sixteen of those on an anonymous
328 * pageview is ~16ms spent on values the page may not use (#393).
329 *
330 * get() memoizes within the request, so only the first read of each key
331 * ever reaches the database — this is what collapses those first reads.
332 * Keys already memoized are left out of the batch.
333 *
334 * wp_prime_option_caches() is WP 6.4+; the plugin supports 6.0, so an
335 * older site simply keeps the previous behaviour.
336 *
337 * @since 2.1.0
338 *
339 * @param string[] $keys Setting keys, without the `thinkrank_` prefix.
340 * @return void
341 */
342 public function prime(array $keys): void {
343 if (!function_exists('wp_prime_option_caches')) {
344 return;
345 }
346
347 $unread = [];
348
349 foreach ($keys as $key) {
350 if (!isset($this->cache[$key])) {
351 $unread[] = 'thinkrank_' . $key;
352 }
353 }
354
355 if (!empty($unread)) {
356 wp_prime_option_caches($unread);
357 }
358 }
359
360 /**
361 * Get setting value
362 *
363 * @param string $key Setting key
364 * @param mixed $fallback Default value
365 * @param int $user_id User ID (0 for global, >0 for user-specific)
366 * @return mixed Setting value
367 */
368 public function get(string $key, $fallback = null, int $user_id = 0) {
369 // Check cache first
370 $cache_key = $user_id > 0 ? "user_{$user_id}_{$key}" : $key;
371
372 if (isset($this->cache[$cache_key])) {
373 return $this->maybe_decrypt($key, $this->cache[$cache_key]);
374 }
375
376 // Load from WordPress options or user meta
377 if ($user_id > 0) {
378 $value = get_user_meta($user_id, "thinkrank_{$key}", true);
379 } else {
380 $value = get_option("thinkrank_{$key}", null);
381 }
382
383
384
385 // Handle boolean settings with special logic for WordPress storage quirks
386 if (isset($this->defaults[$key]) && is_bool($this->defaults[$key])) {
387 // Convert string representations back to booleans
388 if ('1' === $value || 1 === $value || true === $value || 'true' === $value) {
389 $value = true;
390 } elseif ('0' === $value || 0 === $value || false === $value || 'false' === $value) {
391 $value = false;
392 } elseif (null === $value || '' === $value) {
393 // For boolean settings, empty string could mean false was stored
394 // null means option doesn't exist, empty string means it was stored as empty
395 if (null !== $value && $value === '') {
396 // Empty string exists in DB, this likely means false was stored
397 $value = false;
398 } else {
399 // Option doesn't exist, use default
400 $value = $fallback ?? $this->defaults[$key];
401 }
402 }
403 } else {
404 // Non-boolean settings: use default if not found.
405 //
406 // get_option() above is called with a null default, so null means
407 // "no row" while '' means a value was deliberately stored. For most
408 // keys we collapse the two — an empty string is treated as unset so
409 // the documented default applies. Keys in EMPTY_IS_A_VALUE opt out:
410 // there, clearing the field is a real choice the consumer honours,
411 // and folding it back into the default made the save look like it
412 // silently failed (#316).
413 $empty_is_unset = !in_array($key, self::EMPTY_IS_A_VALUE, true);
414
415 if (null === $value || ($empty_is_unset && '' === $value)) {
416 $value = $fallback ?? ($this->defaults[$key] ?? null);
417 }
418 }
419
420 // Cache the value
421 $this->cache[$cache_key] = $value;
422
423 return $this->maybe_decrypt($key, $value);
424 }
425
426 /**
427 * Set setting value
428 *
429 * @param string $key Setting key
430 * @param mixed $value Setting value
431 * @param int $user_id User ID (0 for global, >0 for user-specific)
432 * @return bool Success status
433 */
434 public function set(string $key, $value, int $user_id = 0): bool {
435 // Check if key exists in defaults
436 if (!array_key_exists($key, $this->defaults)) {
437 return false;
438 }
439
440
441
442 // Encrypt if needed
443 $encrypted_value = $this->maybe_encrypt($key, $value);
444
445 // Save to WordPress options or user meta
446 if ($user_id > 0) {
447 $result = update_user_meta($user_id, "thinkrank_{$key}", $encrypted_value);
448 } else {
449 $option_name = "thinkrank_{$key}";
450 $is_sensitive = in_array($key, $this->encrypted_keys, true);
451
452 // Determine if option exists
453 $existing = get_option($option_name, '__tr_not_set__');
454 if ($existing === '__tr_not_set__') {
455 // First-time save: set autoload=no for sensitive options
456 $autoload = $is_sensitive ? 'no' : 'yes';
457 $result = add_option($option_name, $encrypted_value, '', $autoload);
458 } else {
459 // Update existing option; enforce autoload=no for sensitive options
460 if ($is_sensitive) {
461 $result = update_option($option_name, $encrypted_value, 'no');
462 } else {
463 $result = update_option($option_name, $encrypted_value);
464 }
465 }
466
467 // Verify value actually saved (update_option returns false when unchanged)
468 $saved_value = get_option($option_name, 'NOT_FOUND');
469 // The loose branch is load-bearing: get_option() returns the stored
470 // STRING ('1', '0', '30') while $encrypted_value may be the original
471 // bool/int. On an unchanged re-save update_option() returns false, so
472 // this comparison is the only thing that marks the save successful —
473 // strict-only here made every unchanged-boolean re-save report failure.
474 $values_match = ($saved_value === $encrypted_value) ||
475 ($saved_value == $encrypted_value && $encrypted_value !== 'NOT_FOUND'); // phpcs:ignore Universal.Operators.StrictComparisons.LooseEqual -- intentional type-tolerant verify, see above.
476
477 // Consider it successful if the value was saved correctly
478 $result = $result || $values_match;
479 }
480
481 // Update cache
482 if ($result) {
483 $cache_key = $user_id > 0 ? "user_{$user_id}_{$key}" : $key;
484 $this->cache[$cache_key] = $encrypted_value;
485
486 // Invalidate bulk cache when individual setting changes
487 $bulk_cache_key = $user_id > 0 ? "bulk_user_{$user_id}" : 'bulk_global';
488 wp_cache_delete($bulk_cache_key, 'thinkrank_settings');
489 }
490
491 return $result !== false;
492 }
493
494 /**
495 * Delete setting
496 *
497 * @param string $key Setting key
498 * @param int $user_id User ID (0 for global, >0 for user-specific)
499 * @return bool Success status
500 */
501 public function delete(string $key, int $user_id = 0): bool {
502 // Remove from WordPress options or user meta
503 if ($user_id > 0) {
504 $result = delete_user_meta($user_id, "thinkrank_{$key}");
505 } else {
506 $result = delete_option("thinkrank_{$key}");
507 }
508
509 // Remove from cache
510 if ($result) {
511 $cache_key = $user_id > 0 ? "user_{$user_id}_{$key}" : $key;
512 unset($this->cache[$cache_key]);
513
514 // Also invalidate the bulk cache written by get_all(), or it serves
515 // stale values for up to 5 minutes after a delete.
516 $bulk_cache_key = $user_id > 0 ? "bulk_user_{$user_id}" : 'bulk_global';
517 wp_cache_delete($bulk_cache_key, 'thinkrank_settings');
518 }
519
520 return $result;
521 }
522
523 /**
524 * Get all settings (optimized with bulk caching)
525 *
526 * @param int $user_id User ID (0 for global)
527 * @return array All settings
528 */
529 public function get_all(int $user_id = 0): array {
530 // Check bulk cache first
531 $bulk_cache_key = $user_id > 0 ? "bulk_user_{$user_id}" : 'bulk_global';
532
533 $cached_settings = wp_cache_get($bulk_cache_key, 'thinkrank_settings');
534 if ($cached_settings !== false) {
535 return $cached_settings;
536 }
537
538 $settings = [];
539
540 foreach (array_keys($this->defaults) as $key) {
541 $settings[$key] = $this->get($key, null, $user_id);
542 }
543
544 // Cache all settings for 5 minutes
545 wp_cache_set($bulk_cache_key, $settings, 'thinkrank_settings', 300);
546
547 return $settings;
548 }
549
550 /**
551 * Reset settings to defaults
552 *
553 * @param int $user_id User ID (0 for global)
554 * @return bool Success status
555 */
556 public function reset(int $user_id = 0): bool {
557 $success = true;
558
559 foreach (array_keys($this->defaults) as $key) {
560 if (!$this->delete($key, $user_id)) {
561 $success = false;
562 }
563 }
564
565 // Clear cache — both the instance array and the bulk object cache
566 // written by get_all(), which would otherwise return stale pre-reset
567 // values for up to 5 minutes.
568 $this->cache = [];
569 $bulk_cache_key = $user_id > 0 ? "bulk_user_{$user_id}" : 'bulk_global';
570 wp_cache_delete($bulk_cache_key, 'thinkrank_settings');
571
572 return $success;
573 }
574
575 /**
576 * Sanitize settings
577 *
578 * @param array $settings Settings array
579 * @return array Sanitized settings
580 */
581 public function sanitize_settings(array $settings): array {
582 $sanitized = [];
583
584 foreach ($settings as $key => $value) {
585 $sanitized[$key] = $this->sanitize_setting($key, $value);
586 }
587
588 return $sanitized;
589 }
590
591 /**
592 * Sanitize individual setting
593 *
594 * @param string $key Setting key
595 * @param mixed $value Setting value
596 * @return mixed Sanitized value
597 */
598 private function sanitize_setting(string $key, $value) {
599 // Only keys that are declared array-typed may receive an array. For a
600 // scalar key an array/object value is malformed input, and the scalar
601 // sanitizers below would fatal on it, so fall back to the declared
602 // default instead of letting the bad value through.
603 if (is_array($value) || is_object($value)) {
604 if (!is_array($this->defaults[$key] ?? null)) {
605 return $this->defaults[$key] ?? '';
606 }
607 $value = (array) $value;
608 }
609
610 switch ($key) {
611 case 'openai_api_key':
612 case 'claude_api_key':
613 case 'gemini_api_key':
614 case 'openrouter_api_key':
615 return sanitize_text_field($value);
616
617 case 'ai_provider':
618 return sanitize_key($value);
619
620 case 'openai_model':
621 case 'claude_model':
622 case 'gemini_model':
623 case 'openrouter_model':
624 // Model ids may contain dots and slashes (e.g. "gpt-4.1" or
625 // "openai/gpt-4o-mini") and users can enter custom models, so
626 // sanitize_key() would corrupt them — use text-field sanitizing.
627 return sanitize_text_field($value);
628
629 case 'max_tokens':
630 case 'cache_duration':
631 case 'max_requests_per_minute':
632 case 'seo_score_threshold':
633 case 'api_timeout':
634 case 'retry_attempts':
635 case 'data_retention_days':
636 case 'monitoring_frequency':
637 return absint($value);
638
639 case 'temperature':
640 return (float) $value;
641
642 case 'dashboard_widgets':
643 return is_array($value) ? array_map('sanitize_key', $value) : [];
644
645 case 'seo_analytics_alert_thresholds':
646 // A threshold_name => value map where the values are numbers
647 // (e.g. traffic_drop_percentage => 20) as well as strings, so
648 // sanitize each value by its own type rather than forcing every
649 // one through sanitize_key() — that turned ints into strings.
650 if (!is_array($value)) {
651 return [];
652 }
653 $thresholds = [];
654 foreach ($value as $threshold_key => $threshold_value) {
655 if (is_array($threshold_value)) {
656 continue;
657 }
658 $thresholds[sanitize_key($threshold_key)] = is_numeric($threshold_value)
659 ? $threshold_value + 0
660 : sanitize_text_field((string) $threshold_value);
661 }
662 return $thresholds;
663
664 case 'google_analytics_property_id':
665 case 'seo_analytics_google_analytics_property_id':
666 case 'seo_analytics_report_schedule':
667 return sanitize_text_field($value);
668
669 case 'robots_txt_content':
670 // Multi-line content — sanitize_text_field() collapses newlines
671 // and would flatten the whole file onto a single line.
672 return sanitize_textarea_field($value);
673
674 default:
675 if (is_bool($value)) {
676 return (bool) $value;
677 } elseif (is_string($value)) {
678 return sanitize_text_field($value);
679 } elseif (is_array($value)) {
680 // Flat list/map of scalars; nested members are dropped rather
681 // than passed to a string sanitizer that would fatal on them.
682 $sanitized = [];
683 foreach ($value as $item_key => $item) {
684 if (is_array($item) || is_object($item)) {
685 continue;
686 }
687 $sanitized[is_string($item_key) ? sanitize_key($item_key) : $item_key] =
688 sanitize_text_field((string) $item);
689 }
690 return $sanitized;
691 }
692 return $value;
693 }
694 }
695
696 /**
697 * Marker prefix for values encrypted with the libsodium scheme below.
698 */
699 private const ENC_PREFIX = 'trenc:v1:';
700
701 /**
702 * Encrypt secret settings (API keys, OAuth tokens) at rest using libsodium
703 * authenticated encryption. Non-secret keys and environments without sodium
704 * fall back to plaintext so behaviour stays stable.
705 *
706 * @param string $key Setting key
707 * @param mixed $value Setting value
708 * @return mixed Encrypted payload (string) or original value
709 */
710 private function maybe_encrypt(string $key, $value) {
711 if (!in_array($key, $this->encrypted_keys, true) || !is_string($value) || '' === $value) {
712 return $value;
713 }
714
715 // Same scheme, same key, same prefix — it just lives in Secret_At_Rest
716 // now so the MCP pairing token can use it too (#396).
717 return Secret_At_Rest::encrypt($value);
718 }
719
720 /**
721 * Decrypt a value previously encrypted by maybe_encrypt. Values without the
722 * marker prefix are legacy plaintext and returned untouched (backward compat).
723 *
724 * @param string $key Setting key
725 * @param mixed $value Setting value
726 * @return mixed Decrypted or original value
727 */
728 private function maybe_decrypt(string $key, $value) {
729 if (!is_string($value) || strncmp($value, self::ENC_PREFIX, strlen(self::ENC_PREFIX)) !== 0) {
730 return $value; // legacy plaintext or non-string
731 }
732
733 $plain = Secret_At_Rest::decrypt($value);
734
735 if ('' === $plain) {
736 // We reach here only when sodium is available and a key was
737 // derivable, so this is a genuine failure: the auth salt changed
738 // (config rotated, or the site was migrated without wp-config) or
739 // the row is corrupt. The value is unrecoverable either way.
740 //
741 // Returning the ciphertext would send it upstream as a bearer
742 // token or API key, producing an opaque 401 far from the cause.
743 // Return empty so callers see "no credential" and can prompt for
744 // a reconnect instead.
745 return '';
746 }
747
748 return $plain;
749 }
750 }
751