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/core/class-settings.php +380 -75 1.26.02.7.0 View file →
@@ -32,10 +32,101 @@
32 32 */
33 33 class Settings {
34 34
35 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 + /**
63 + * AI providers this plugin supports.
64 + *
65 + * Single source of truth for the REST enum, the connection test's dispatch
66 + * and sanitize_setting(), so the three cannot disagree about what is legal.
67 + *
68 + * @since 2.2.0
69 + * @var string[]
70 + */
71 + public const SUPPORTED_AI_PROVIDERS = ['openai', 'claude', 'gemini', 'openrouter'];
72 +
73 + /**
74 + * The stored value meaning "the user has not chosen a provider yet".
75 + *
76 + * A fresh install ships with no provider selected: picking one is the
77 + * user's call, and pre-selecting OpenAI made the settings screen open with
78 + * a warning about a missing key for a provider nobody had asked for (#572).
79 + * Every write path accepts this alongside SUPPORTED_AI_PROVIDERS.
80 + *
81 + * @since 2.1.3
82 + */
83 + public const AI_PROVIDER_NONE = '';
84 +
85 + /**
86 + * One-time marker for {@see Settings::retire_seeded_ai_provider()}.
87 + */
88 + private const PROVIDER_MIGRATION_OPTION = 'thinkrank_ai_provider_migration';
89 + private const PROVIDER_MIGRATION_VERSION = '1';
90 +
91 + /**
92 + * Legal values for the `ai_provider` setting, including "not chosen".
93 + *
94 + * @since 2.1.3
95 + * @return string[]
96 + */
97 + public static function selectable_ai_providers(): array {
98 + return array_merge([self::AI_PROVIDER_NONE], self::SUPPORTED_AI_PROVIDERS);
99 + }
100 +
101 + const DEFAULT_AUTHOR_ARCHIVES_TITLE = '%author_name% %separator% %site_title% %page%';
102 + const DEFAULT_AUTHOR_ARCHIVES_META_DESC = 'Articles written by %author_name% on %site_title%';
103 +
104 + /**
105 + * String settings where a stored empty string is a real value, not "unset".
106 + *
107 + * get() normally treats '' the same as a missing option and returns the
108 + * default, which is right for most keys — a blank API key or model name is
109 + * never what the user meant. For these template fields it is the opposite:
110 + * clearing the box means "render no template", and the consumers already
111 + * branch on an empty value. Without the opt-out the save appeared to
112 + * succeed and the default reappeared on the next request (#316).
113 + *
114 + * @since 1.29.1
115 + * @var string[]
116 + */
117 + private const EMPTY_IS_A_VALUE = [
118 + 'author_archives_title',
119 + 'author_archives_meta_desc',
120 + // '' is the "no provider chosen" state, not "fall back to the default".
121 + // Without this, deselecting a provider would be undone by any caller
122 + // that passes its own fallback to get() (#572).
123 + 'ai_provider',
124 + ];
125 +
126 + /**
36 127 * Shared singleton instance
37 - *
128 + *
38 129 * @var Settings|null
39 130 */
40 131 private static ?Settings $instance = null;
41 132
@@ -82,17 +173,17 @@
82 173 * @var array
83 174 */
84 175 private array $defaults = [
85 176 // AI Settings
86 - 'ai_provider' => 'openai',
177 + 'ai_provider' => self::AI_PROVIDER_NONE,
87 178 'openai_api_key' => '',
88 - 'openai_model' => 'gpt-5-nano', // Default to GPT‑5‑nano
179 + 'openai_model' => self::DEFAULT_OPENAI_MODEL,
89 180 'claude_api_key' => '',
90 - 'claude_model' => 'claude-sonnet-5', // Recommended default (best speed/quality balance)
181 + 'claude_model' => self::DEFAULT_CLAUDE_MODEL, // Recommended default (best speed/quality balance)
91 182 'gemini_api_key' => '',
92 - 'gemini_model' => 'gemini-2.5-flash',
183 + 'gemini_model' => self::DEFAULT_GEMINI_MODEL,
93 184 'openrouter_api_key' => '',
94 - 'openrouter_model' => 'openai/gpt-4o-mini',
185 + 'openrouter_model' => self::DEFAULT_OPENROUTER_MODEL,
95 186 'max_tokens' => 1000,
96 187 'temperature' => 0.7,
97 188
98 189 // Google API Keys (encrypted)
@@ -106,9 +197,11 @@
106 197 'google_token_expires_in' => 0,
107 198 'google_token_created' => 0,
108 199 'google_account_connected' => false,
109 200
110 - // Google Analytics Pro Settings
201 + // Google Analytics Pro Settings. Read/written by thinkrank-pro's
202 + // GoogleAnalyticsSettings.js through the settings-management endpoint —
203 + // no consumer exists in THIS repo, so don't dead-key these.
111 204 'ga_analytics_account_id' => '',
112 205 'ga_analytics_data_stream_id' => '',
113 206
114 207 // Performance Settings
@@ -142,10 +235,10 @@
142 235 // Author Archives Settings
143 236 'author_archives_enabled' => true,
144 237 'author_archives_index' => true,
145 238 'author_archives_show_empty' => false,
146 - 'author_archives_title' => '%author_name% – %site_title% %page%',
147 - 'author_archives_meta_desc' => 'Articles written by %author_name% on %site_title%',
239 + 'author_archives_title' => self::DEFAULT_AUTHOR_ARCHIVES_TITLE,
240 + 'author_archives_meta_desc' => self::DEFAULT_AUTHOR_ARCHIVES_META_DESC,
148 241
149 242 // UI Settings
150 243 'show_welcome_message' => true,
151 244 'dashboard_widgets' => ['seo_score', 'ai_usage', 'recent_briefs'],
@@ -156,8 +249,16 @@
156 249 'retry_attempts' => 3,
157 250 // Exposes the standalone Migration admin page for re-running SEO data
158 251 // imports after setup. Hidden by default; opt-in for advanced/support use.
159 252 'enable_migration_tools' => false,
253 + // Exposes ThinkRank's own export / restore. Off by default, like the
254 + // migration toggle above: both are occasional, admin-only tools, and a
255 + // menu item nobody asked for is a menu item in the way. Your data is
256 + // never locked in — the switch is one click away in
257 + // Settings > Import / Export, and turning it on immediately restores
258 + // the screen and the menu item. Off hides the export card and, unless
259 + // migration tools are on, the Import / Export menu item with it.
260 + 'enable_import_export' => false,
160 261
161 262 // Privacy Settings
162 263 'data_retention_days' => 90,
163 264 'anonymize_logs' => true,
@@ -166,16 +267,8 @@
166 267 // Integration Settings
167 268 'google_analytics_id' => '',
168 269 'search_console_property' => '',
169 270
170 - // GA4 Tracking Settings
171 - 'ga4_measurement_id' => '',
172 - 'ga4_auto_inject' => false,
173 - 'ga4_anonymize_ip' => false,
174 - 'ga4_exclude_admin' => false,
175 - 'ga4_tracking_verified' => false,
176 - 'ga4_last_verification' => '',
177 -
178 271 // SEO Analytics Settings
179 272 'seo_analytics_enabled' => false,
180 273 'seo_analytics_setup_completed' => false,
181 274 'seo_analytics_google_analytics_property_id' => '',
@@ -224,11 +317,102 @@
224 317 * @return void
225 318 */
226 319 public function init(): void {
227 320 add_action('admin_init', [$this, 'register_settings']);
321 + // Admin-only: a front-end pageview can never need this migration, and
322 + // the marker is a non-autoloaded option, so hooking it unconditionally
323 + // bought one dedicated query on every request for the life of the
324 + // install (#588).
325 + if (is_admin()) {
326 + add_action('init', [self::class, 'retire_seeded_ai_provider']);
327 + }
228 328 }
229 329
230 330 /**
331 + * Clear the OpenAI selection that older versions seeded on activation.
332 + *
333 + * Changing the default only helps installs created after the change. Every
334 + * site activated before it still carries `ai_provider = 'openai'` written by
335 + * Activator::set_default_options(), and still opens Settings warning about a
336 + * missing key for a provider nobody picked — the whole complaint in #572.
337 + *
338 + * "OpenAI with no OpenAI key" is provably not a user's choice: the settings
339 + * form refuses to save a provider without a key, so the only way to reach
340 + * that state is the old activation seed. A site that genuinely chose OpenAI
341 + * has a key and is left alone, as is any site on another provider.
342 + *
343 + * Version-gated so it runs once and never fights a user who later clears
344 + * their key but keeps the provider selected.
345 + *
346 + * @since 2.1.3
347 + *
348 + * @return void
349 + */
350 + public static function retire_seeded_ai_provider(): void {
351 + if (get_option(self::PROVIDER_MIGRATION_OPTION) === self::PROVIDER_MIGRATION_VERSION) {
352 + self::promote_migration_marker_to_autoload();
353 + return;
354 + }
355 +
356 + // Record first: a site that somehow fails the checks below must not
357 + // re-test on every request for the rest of its life. Autoloaded on
358 + // purpose — it is a short write-once flag that is read on every admin
359 + // request, which is exactly what autoload is for; storing it
360 + // non-autoloaded bought a dedicated query per request instead (#588).
361 + update_option(self::PROVIDER_MIGRATION_OPTION, self::PROVIDER_MIGRATION_VERSION, true);
362 +
363 + if (get_option('thinkrank_ai_provider', null) !== 'openai') {
364 + return;
365 + }
366 +
367 + // Read the stored option directly rather than through Settings::get().
368 + // Only emptiness matters here, and get() adds two things this decision
369 + // must not depend on: a per-instance cache that may already be primed,
370 + // and decryption that can yield '' for a key that is genuinely present.
371 + // The raw option is non-empty whenever a key exists, encrypted or not.
372 + if ('' !== (string) get_option('thinkrank_openai_api_key', '')) {
373 + // A real choice, backed by a key. Leave it.
374 + return;
375 + }
376 +
377 + // Write through the shared instance, not a throwaway one: set() refreshes
378 + // only the cache of the object it is called on, so a private instance
379 + // would leave the registered component serving the old value for the
380 + // rest of this request — including to the admin page it localizes.
381 + self::instance()->set('ai_provider', self::AI_PROVIDER_NONE);
382 + }
383 +
384 + /**
385 + * Move a legacy migration marker into the autoloaded set.
386 + *
387 + * Sites that ran the migration on 2.1.3 wrote the marker with
388 + * `autoload = false`, and the version gate above returns before the write
389 + * that would correct it — so those installs keep paying a dedicated query
390 + * to read a one-byte flag on every admin request, which is the cost #588
391 + * was about. Promote it once.
392 + *
393 + * Free to test: alloptions is loaded from the object cache once per request
394 + * regardless, and an autoloaded marker is in it, so the steady state after
395 + * the promotion is a cache lookup and nothing else. wp_set_option_autoload()
396 + * arrived in WP 6.4 and the plugin supports 6.0, hence the guard.
397 + *
398 + * @since 2.2.0
399 + *
400 + * @return void
401 + */
402 + private static function promote_migration_marker_to_autoload(): void {
403 + if (!function_exists('wp_set_option_autoload') || !function_exists('wp_load_alloptions')) {
404 + return;
405 + }
406 +
407 + if (array_key_exists(self::PROVIDER_MIGRATION_OPTION, wp_load_alloptions())) {
408 + return;
409 + }
410 +
411 + wp_set_option_autoload(self::PROVIDER_MIGRATION_OPTION, true);
412 + }
413 +
414 + /**
231 415 * Register WordPress settings
232 416 *
233 417 * @return void
234 418 */
@@ -239,16 +423,55 @@
239 423 ]);
240 424 }
241 425
242 426 /**
427 + * Warm the option cache for a batch of setting keys in one query.
428 + *
429 + * Every `thinkrank_*` option is autoload=off, so WordPress cannot serve
430 + * them from `alloptions` and each get() below is its own round-trip. A
431 + * caller that reads a known list of keys should prime it first: on a
432 + * ~1ms managed-hosting round-trip, sixteen of those on an anonymous
433 + * pageview is ~16ms spent on values the page may not use (#393).
434 + *
435 + * get() memoizes within the request, so only the first read of each key
436 + * ever reaches the database — this is what collapses those first reads.
437 + * Keys already memoized are left out of the batch.
438 + *
439 + * wp_prime_option_caches() is WP 6.4+; the plugin supports 6.0, so an
440 + * older site simply keeps the previous behaviour.
441 + *
442 + * @since 2.1.0
443 + *
444 + * @param string[] $keys Setting keys, without the `thinkrank_` prefix.
445 + * @return void
446 + */
447 + public function prime(array $keys): void {
448 + if (!function_exists('wp_prime_option_caches')) {
449 + return;
450 + }
451 +
452 + $unread = [];
453 +
454 + foreach ($keys as $key) {
455 + if (!isset($this->cache[$key])) {
456 + $unread[] = 'thinkrank_' . $key;
457 + }
458 + }
459 +
460 + if (!empty($unread)) {
461 + wp_prime_option_caches($unread);
462 + }
463 + }
464 +
465 + /**
243 466 * Get setting value
244 467 *
245 468 * @param string $key Setting key
246 - * @param mixed $default Default value
469 + * @param mixed $fallback Default value
247 470 * @param int $user_id User ID (0 for global, >0 for user-specific)
248 471 * @return mixed Setting value
249 472 */
250 - public function get(string $key, $default = null, int $user_id = 0) {
473 + public function get(string $key, $fallback = null, int $user_id = 0) {
251 474 // Check cache first
252 475 $cache_key = $user_id > 0 ? "user_{$user_id}_{$key}" : $key;
253 476
254 477 if (isset($this->cache[$cache_key])) {
@@ -278,15 +501,25 @@
278 501 // Empty string exists in DB, this likely means false was stored
279 502 $value = false;
280 503 } else {
281 504 // Option doesn't exist, use default
282 - $value = $default ?? $this->defaults[$key];
505 + $value = $fallback ?? $this->defaults[$key];
283 506 }
284 507 }
285 508 } else {
286 - // Non-boolean settings: use default if not found
287 - if (null === $value || '' === $value) {
288 - $value = $default ?? ($this->defaults[$key] ?? null);
509 + // Non-boolean settings: use default if not found.
510 + //
511 + // get_option() above is called with a null default, so null means
512 + // "no row" while '' means a value was deliberately stored. For most
513 + // keys we collapse the two — an empty string is treated as unset so
514 + // the documented default applies. Keys in EMPTY_IS_A_VALUE opt out:
515 + // there, clearing the field is a real choice the consumer honours,
516 + // and folding it back into the default made the save look like it
517 + // silently failed (#316).
518 + $empty_is_unset = !in_array($key, self::EMPTY_IS_A_VALUE, true);
519 +
520 + if (null === $value || ($empty_is_unset && '' === $value)) {
521 + $value = $fallback ?? ($this->defaults[$key] ?? null);
289 522 }
290 523 }
291 524
292 525 // Cache the value
@@ -308,10 +541,14 @@
308 541 if (!array_key_exists($key, $this->defaults)) {
309 542 return false;
310 543 }
311 544
545 + // Apply the declared per-key sanitizer before anything is stored. This
546 + // is the path essentially every caller takes, so skipping it left the
547 + // whole sanitize_setting() switch unreachable — max_tokens,
548 + // cache_duration and temperature persisted whatever string arrived.
549 + $value = $this->sanitize_setting($key, $value);
312 550
313 -
314 551 // Encrypt if needed
315 552 $encrypted_value = $this->maybe_encrypt($key, $value);
316 553
317 554 // Save to WordPress options or user meta
@@ -337,10 +574,15 @@
337 574 }
338 575
339 576 // Verify value actually saved (update_option returns false when unchanged)
340 577 $saved_value = get_option($option_name, 'NOT_FOUND');
578 + // The loose branch is load-bearing: get_option() returns the stored
579 + // STRING ('1', '0', '30') while $encrypted_value may be the original
580 + // bool/int. On an unchanged re-save update_option() returns false, so
581 + // this comparison is the only thing that marks the save successful —
582 + // strict-only here made every unchanged-boolean re-save report failure.
341 583 $values_match = ($saved_value === $encrypted_value) ||
342 - ($saved_value == $encrypted_value && $encrypted_value !== 'NOT_FOUND');
584 + ($saved_value == $encrypted_value && $encrypted_value !== 'NOT_FOUND'); // phpcs:ignore Universal.Operators.StrictComparisons.LooseEqual -- intentional type-tolerant verify, see above.
343 585
344 586 // Consider it successful if the value was saved correctly
345 587 $result = $result || $values_match;
346 588 }
@@ -387,8 +629,23 @@
387 629 return $result;
388 630 }
389 631
390 632 /**
633 + * Setting keys whose values are encrypted at rest.
634 + *
635 + * Exposed so callers that must never emit a credential — the data exporter
636 + * in particular — can filter against the same list this class encrypts
637 + * with, instead of keeping a copy that silently drifts when a key is added.
638 + *
639 + * @since 2.2.0
640 + *
641 + * @return string[] Setting keys.
642 + */
643 + public function get_encrypted_keys(): array {
644 + return $this->encrypted_keys;
645 + }
646 +
647 + /**
391 648 * Get all settings (optimized with bulk caching)
392 649 *
393 650 * @param int $user_id User ID (0 for global)
394 651 * @return array All settings
@@ -455,8 +712,44 @@
455 712 return $sanitized;
456 713 }
457 714
458 715 /**
716 + * Sanitize a nested array, preserving its shape.
717 + *
718 + * Scalars keep their type (an int threshold stays an int); strings are
719 + * text-sanitized; objects are dropped, since no setting stores one.
720 + *
721 + * @since 2.2.0
722 + * @param array $value Array to sanitize.
723 + * @param int $depth Current recursion depth.
724 + * @return array
725 + */
726 + private function sanitize_array_recursive(array $value, int $depth = 0): array {
727 + // Settings are configuration, not arbitrary payloads; a cap keeps a
728 + // malformed deep structure from recursing without bound.
729 + if ($depth > 10) {
730 + return [];
731 + }
732 +
733 + $sanitized = [];
734 + foreach ($value as $item_key => $item) {
735 + $key = is_string($item_key) ? sanitize_key($item_key) : $item_key;
736 +
737 + if (is_array($item)) {
738 + $sanitized[$key] = $this->sanitize_array_recursive($item, $depth + 1);
739 + } elseif (is_object($item)) {
740 + continue;
741 + } elseif (is_bool($item) || is_int($item) || is_float($item)) {
742 + $sanitized[$key] = $item;
743 + } else {
744 + $sanitized[$key] = sanitize_text_field((string) $item);
745 + }
746 + }
747 +
748 + return $sanitized;
749 + }
750 +
751 + /**
459 752 * Sanitize individual setting
460 753 *
461 754 * @param string $key Setting key
462 755 * @param mixed $value Setting value
@@ -462,8 +755,19 @@
462 755 * @param mixed $value Setting value
463 756 * @return mixed Sanitized value
464 757 */
465 758 private function sanitize_setting(string $key, $value) {
759 + // Only keys that are declared array-typed may receive an array. For a
760 + // scalar key an array/object value is malformed input, and the scalar
761 + // sanitizers below would fatal on it, so fall back to the declared
762 + // default instead of letting the bad value through.
763 + if (is_array($value) || is_object($value)) {
764 + if (!is_array($this->defaults[$key] ?? null)) {
765 + return $this->defaults[$key] ?? '';
766 + }
767 + $value = (array) $value;
768 + }
769 +
466 770 switch ($key) {
467 771 case 'openai_api_key':
468 772 case 'claude_api_key':
469 773 case 'gemini_api_key':
@@ -470,10 +774,17 @@
470 774 case 'openrouter_api_key':
471 775 return sanitize_text_field($value);
472 776
473 777 case 'ai_provider':
474 - return sanitize_key($value);
778 + // sanitize_key() maps '' to '', which is AI_PROVIDER_NONE — the
779 + // deliberate "no provider chosen" state, so it must survive here
780 + // rather than being folded back into the default (#572).
781 + $provider = sanitize_key($value);
475 782
783 + return in_array($provider, self::selectable_ai_providers(), true)
784 + ? $provider
785 + : $this->defaults['ai_provider'];
786 +
476 787 case 'openai_model':
477 788 case 'claude_model':
478 789 case 'gemini_model':
479 790 case 'openrouter_model':
@@ -495,10 +806,37 @@
495 806 case 'temperature':
496 807 return (float) $value;
497 808
498 809 case 'dashboard_widgets':
810 + return is_array($value) ? array_map('sanitize_key', $value) : [];
811 +
499 812 case 'seo_analytics_alert_thresholds':
500 - return is_array($value) ? array_map('sanitize_key', $value) : [];
813 + // A threshold_name => value map where the values are numbers
814 + // (e.g. traffic_drop_percentage => 20) as well as strings, so
815 + // sanitize each value by its own type rather than forcing every
816 + // one through sanitize_key() — that turned ints into strings.
817 + if (!is_array($value)) {
818 + return [];
819 + }
820 + $thresholds = [];
821 + foreach ($value as $threshold_key => $threshold_value) {
822 + if (is_array($threshold_value)) {
823 + continue;
824 + }
825 + if (is_bool($threshold_value)) {
826 + // Keep booleans as booleans. is_numeric() is false for
827 + // one, so it used to fall to the string arm and a true
828 + // came back as "1" — invisible while this ran only on
829 + // the register_setting() path, now that set() routes
830 + // every write through here it is a type change on save.
831 + $thresholds[sanitize_key($threshold_key)] = $threshold_value;
832 + continue;
833 + }
834 + $thresholds[sanitize_key($threshold_key)] = is_numeric($threshold_value)
835 + ? $threshold_value + 0
836 + : sanitize_text_field((string) $threshold_value);
837 + }
838 + return $thresholds;
501 839
502 840 case 'google_analytics_property_id':
503 841 case 'seo_analytics_google_analytics_property_id':
504 842 case 'seo_analytics_report_schedule':
@@ -505,9 +843,12 @@
505 843 return sanitize_text_field($value);
506 844
507 845 case 'robots_txt_content':
508 846 // Multi-line content — sanitize_text_field() collapses newlines
509 - // and would flatten the whole file onto a single line.
847 + // and would flatten the whole file onto a single line. set()
848 + // routes every write through here, so a caller that chose
849 + // sanitize_textarea_field() itself is otherwise silently
850 + // overridden by the default: arm below (#587).
510 851 return sanitize_textarea_field($value);
511 852
512 853 default:
513 854 if (is_bool($value)) {
@@ -514,9 +855,14 @@
514 855 return (bool) $value;
515 856 } elseif (is_string($value)) {
516 857 return sanitize_text_field($value);
517 858 } elseif (is_array($value)) {
518 - return array_map('sanitize_text_field', $value);
859 + // Recurse rather than drop. Skipping nested members was
860 + // harmless while this ran only on the register_setting()
861 + // path, but set() now routes every write through here, and
862 + // structured settings (lists of maps) were being silently
863 + // emptied on save.
864 + return $this->sanitize_array_recursive($value);
519 865 }
520 866 return $value;
521 867 }
522 868 }
@@ -538,24 +884,12 @@
538 884 private function maybe_encrypt(string $key, $value) {
539 885 if (!in_array($key, $this->encrypted_keys, true) || !is_string($value) || '' === $value) {
540 886 return $value;
541 887 }
542 - if (!function_exists('sodium_crypto_secretbox')) {
543 - return $value; // sodium unavailable — store as-is
544 - }
545 888
546 - $enc_key = $this->encryption_key();
547 - if ('' === $enc_key) {
548 - return $value;
549 - }
550 -
551 - try {
552 - $nonce = random_bytes(SODIUM_CRYPTO_SECRETBOX_NONCEBYTES);
553 - $cipher = sodium_crypto_secretbox($value, $nonce, $enc_key);
554 - return self::ENC_PREFIX . base64_encode($nonce . $cipher);
555 - } catch (\Exception $e) {
556 - return $value;
557 - }
889 + // Same scheme, same key, same prefix — it just lives in Secret_At_Rest
890 + // now so the MCP pairing token can use it too (#396).
891 + return Secret_At_Rest::encrypt($value);
558 892 }
559 893
560 894 /**
561 895 * Decrypt a value previously encrypted by maybe_encrypt. Values without the
@@ -568,27 +902,12 @@
568 902 private function maybe_decrypt(string $key, $value) {
569 903 if (!is_string($value) || strncmp($value, self::ENC_PREFIX, strlen(self::ENC_PREFIX)) !== 0) {
570 904 return $value; // legacy plaintext or non-string
571 905 }
572 - if (!function_exists('sodium_crypto_secretbox_open')) {
573 - return $value;
574 - }
575 906
576 - $enc_key = $this->encryption_key();
577 - if ('' === $enc_key) {
578 - return $value;
579 - }
907 + $plain = Secret_At_Rest::decrypt($value);
580 908
581 - $decoded = base64_decode(substr($value, strlen(self::ENC_PREFIX)), true);
582 - if (false === $decoded || strlen($decoded) <= SODIUM_CRYPTO_SECRETBOX_NONCEBYTES) {
583 - return $value;
584 - }
585 -
586 - $nonce = substr($decoded, 0, SODIUM_CRYPTO_SECRETBOX_NONCEBYTES);
587 - $cipher = substr($decoded, SODIUM_CRYPTO_SECRETBOX_NONCEBYTES);
588 - $plain = sodium_crypto_secretbox_open($cipher, $nonce, $enc_key);
589 -
590 - if (false === $plain) {
909 + if ('' === $plain) {
591 910 // We reach here only when sodium is available and a key was
592 911 // derivable, so this is a genuine failure: the auth salt changed
593 912 // (config rotated, or the site was migrated without wp-config) or
594 913 // the row is corrupt. The value is unrecoverable either way.
@@ -600,20 +919,6 @@
600 919 return '';
601 920 }
602 921
603 922 return $plain;
604 - }
605 -
606 - /**
607 - * Derive a 32-byte encryption key from the site's auth salt.
608 - *
609 - * @return string Raw 32-byte key, or '' if salts are unavailable.
610 - */
611 - private function encryption_key(): string {
612 - if (!function_exists('wp_salt')) {
613 - return '';
614 - }
615 - // 32 raw bytes from a site-specific secret — SHA-256 output length
616 - // matches SODIUM_CRYPTO_SECRETBOX_KEYBYTES.
617 - return hash('sha256', 'thinkrank-settings|' . wp_salt('auth'), true);
618 923 }
619 924 }