PluginProbe
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO / 2.9.0
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO v2.9.0
2.9.0 2.8.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 All 50 releases
← All changes | includes/core/class-settings.php +419 -101 2.0.02.9.0 View file →
@@ -48,8 +48,19 @@
48 48 const DEFAULT_GEMINI_MODEL = 'gemini-3.5-flash';
49 49 const DEFAULT_OPENROUTER_MODEL = 'openai/gpt-4o-mini';
50 50
51 51 /**
52 + * Default request timeout for the OpenAI-compatible provider, in seconds.
53 + *
54 + * Deliberately higher than the 120s the hosted providers get: a local model
55 + * on CPU routinely takes minutes for a content brief, and a timeout there
56 + * is never retried (see OpenAI_Client::request_with_retry()).
57 + *
58 + * @since 2.8.0
59 + */
60 + const DEFAULT_OPENAI_COMPATIBLE_TIMEOUT = 120;
61 +
62 + /**
52 63 * Canonical default author-archive templates.
53 64 *
54 65 * Same single-source-of-truth rule as the model constants above: the
55 66 * defaults array, the REST endpoint, the get-settings ability and
@@ -58,8 +69,79 @@
58 69 * the feature resolves %separator% from Site Identity (#318).
59 70 *
60 71 * @since 1.29.1
61 72 */
73 + /**
74 + * AI providers this plugin supports.
75 + *
76 + * Single source of truth for the REST enum, the connection test's dispatch
77 + * and sanitize_setting(), so the three cannot disagree about what is legal.
78 + *
79 + * @since 2.2.0
80 + * @var string[]
81 + */
82 + public const SUPPORTED_AI_PROVIDERS = ['openai', 'claude', 'gemini', 'openrouter', 'openai_compatible'];
83 +
84 + /**
85 + * The stored value meaning "the user has not chosen a provider yet".
86 + *
87 + * A fresh install ships with no provider selected: picking one is the
88 + * user's call, and pre-selecting OpenAI made the settings screen open with
89 + * a warning about a missing key for a provider nobody had asked for (#572).
90 + * Every write path accepts this alongside SUPPORTED_AI_PROVIDERS.
91 + *
92 + * @since 2.1.3
93 + */
94 + public const AI_PROVIDER_NONE = '';
95 +
96 + /**
97 + * One-time marker for {@see Settings::retire_seeded_ai_provider()}.
98 + */
99 + private const PROVIDER_MIGRATION_OPTION = 'thinkrank_ai_provider_migration';
100 + private const PROVIDER_MIGRATION_VERSION = '1';
101 +
102 + /**
103 + * Legal values for the `ai_provider` setting, including "not chosen".
104 + *
105 + * @since 2.1.3
106 + * @return string[]
107 + */
108 + public static function selectable_ai_providers(): array {
109 + return array_merge([self::AI_PROVIDER_NONE], self::SUPPORTED_AI_PROVIDERS);
110 + }
111 +
112 + /**
113 + * Is the selected AI provider configured well enough to run a request?
114 + *
115 + * One answer for the whole plugin. The admin menu notice, the metabox
116 + * "Generate with AI" button and every generator used to ask their own
117 + * version of this as an inline OR over the API key settings, which the
118 + * OpenAI-compatible provider invalidates: a local Ollama or LM Studio
119 + * server has no key and is configured by base URL + model id (#721).
120 + *
121 + * Provider-aware on purpose. A stored key for a provider the site did not
122 + * select never made AI features work, so counting it only produced enabled
123 + * buttons that fail on click.
124 + *
125 + * @since 2.8.0
126 + *
127 + * @return bool
128 + */
129 + public function has_ai_provider_configured(): bool {
130 + $provider = (string) $this->get('ai_provider', self::AI_PROVIDER_NONE);
131 +
132 + if (self::AI_PROVIDER_NONE === $provider) {
133 + return false;
134 + }
135 +
136 + if ('openai_compatible' === $provider) {
137 + return '' !== trim((string) $this->get('openai_compatible_base_url', ''))
138 + && '' !== trim((string) $this->get('openai_compatible_model', ''));
139 + }
140 +
141 + return !empty($this->get($provider . '_api_key'));
142 + }
143 +
62 144 const DEFAULT_AUTHOR_ARCHIVES_TITLE = '%author_name% %separator% %site_title% %page%';
63 145 const DEFAULT_AUTHOR_ARCHIVES_META_DESC = 'Articles written by %author_name% on %site_title%';
64 146
65 147 /**
@@ -77,8 +159,12 @@
77 159 */
78 160 private const EMPTY_IS_A_VALUE = [
79 161 'author_archives_title',
80 162 'author_archives_meta_desc',
163 + // '' is the "no provider chosen" state, not "fall back to the default".
164 + // Without this, deselecting a provider would be undone by any caller
165 + // that passes its own fallback to get() (#572).
166 + 'ai_provider',
81 167 ];
82 168
83 169 /**
84 170 * Shared singleton instance
@@ -130,9 +216,9 @@
130 216 * @var array
131 217 */
132 218 private array $defaults = [
133 219 // AI Settings
134 - 'ai_provider' => 'openai',
220 + 'ai_provider' => self::AI_PROVIDER_NONE,
135 221 'openai_api_key' => '',
136 222 'openai_model' => self::DEFAULT_OPENAI_MODEL,
137 223 'claude_api_key' => '',
138 224 'claude_model' => self::DEFAULT_CLAUDE_MODEL, // Recommended default (best speed/quality balance)
@@ -139,8 +225,18 @@
139 225 'gemini_api_key' => '',
140 226 'gemini_model' => self::DEFAULT_GEMINI_MODEL,
141 227 'openrouter_api_key' => '',
142 228 'openrouter_model' => self::DEFAULT_OPENROUTER_MODEL,
229 + // OpenAI-compatible endpoint (Ollama, LM Studio, vLLM, Azure OpenAI,
230 + // Groq, a company gateway…). No default URL or model: this provider
231 + // does nothing until the administrator names a server (#721).
232 + 'openai_compatible_base_url' => '',
233 + 'openai_compatible_api_key' => '',
234 + 'openai_compatible_model' => '',
235 + 'openai_compatible_timeout' => self::DEFAULT_OPENAI_COMPATIBLE_TIMEOUT,
236 + 'openai_compatible_supports_images' => false,
237 + 'openai_compatible_json_mode' => false,
238 + 'openai_compatible_price_per_million' => 0.0,
143 239 'max_tokens' => 1000,
144 240 'temperature' => 0.7,
145 241
146 242 // Google API Keys (encrypted)
@@ -162,11 +258,17 @@
162 258 'ga_analytics_data_stream_id' => '',
163 259
164 260 // Performance Settings
165 261 'cache_duration' => 3600,
166 - 'max_requests_per_minute' => 10,
262 + 'max_requests_per_minute' => 0,
167 263 'enable_logging' => true,
168 264
265 + // AI spend controls (#448). Both are neutral by default so an upgrade
266 + // changes nothing: 0 means no daily ceiling, and AI is not paused.
267 + // Enforced by ThinkRank\AI\Spend_Guard at the provider HTTP boundary.
268 + 'ai_daily_request_limit' => 0,
269 + 'ai_paused' => false,
270 +
169 271 // Integration Settings
170 272 'api_timeout' => 30,
171 273 'enable_rate_limiting' => true,
172 274 'auto_test_connections' => true,
@@ -188,37 +290,8 @@
188 290 'seo_score_threshold' => 70,
189 291 'enable_meta_generation' => true,
190 292 'enable_schema_markup' => true,
191 293
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 294 // Author Archives Settings
222 295 'author_archives_enabled' => true,
223 296 'author_archives_index' => true,
224 297 'author_archives_show_empty' => false,
@@ -235,8 +308,16 @@
235 308 'retry_attempts' => 3,
236 309 // Exposes the standalone Migration admin page for re-running SEO data
237 310 // imports after setup. Hidden by default; opt-in for advanced/support use.
238 311 'enable_migration_tools' => false,
312 + // Exposes ThinkRank's own export / restore. Off by default, like the
313 + // migration toggle above: both are occasional, admin-only tools, and a
314 + // menu item nobody asked for is a menu item in the way. Your data is
315 + // never locked in — the switch is one click away in
316 + // Settings > Import / Export, and turning it on immediately restores
317 + // the screen and the menu item. Off hides the export card and, unless
318 + // migration tools are on, the Import / Export menu item with it.
319 + 'enable_import_export' => false,
239 320
240 321 // Privacy Settings
241 322 'data_retention_days' => 90,
242 323 'anonymize_logs' => true,
@@ -245,16 +326,8 @@
245 326 // Integration Settings
246 327 'google_analytics_id' => '',
247 328 'search_console_property' => '',
248 329
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 330 // SEO Analytics Settings
258 331 'seo_analytics_enabled' => false,
259 332 'seo_analytics_setup_completed' => false,
260 333 'seo_analytics_google_analytics_property_id' => '',
@@ -284,8 +357,9 @@
284 357 'openai_api_key',
285 358 'claude_api_key',
286 359 'gemini_api_key',
287 360 'openrouter_api_key',
361 + 'openai_compatible_api_key',
288 362 // Google API Keys
289 363 'google_analytics_api_key',
290 364 'google_search_console_api_key',
291 365 'google_pagespeed_api_key',
@@ -303,11 +377,145 @@
303 377 * @return void
304 378 */
305 379 public function init(): void {
306 380 add_action('admin_init', [$this, 'register_settings']);
381 + // Every value below is read from THIS site's options, and the cache is
382 + // keyed by setting name alone. On multisite a switch_to_blog() leaves
383 + // the previous site's values sitting in it, so anything that switches
384 + // reads the wrong site's configuration (#516). Nothing in the plugin
385 + // switched blogs before the OAuth discovery resolver did, which is why
386 + // this had never bitten.
387 + //
388 + // Registered against the CLASS, not $this. init() runs on the object in
389 + // the component container, while every consumer reads through
390 + // Settings::instance() — and those are not always the same object: a
391 + // caller that resolves the singleton while load_components() is still
392 + // building the array gets a standalone instance, which is then memoized
393 + // for the rest of the request. Hooking $this there registers the flush
394 + // on an object nobody reads through, which is exactly the shape of bug
395 + // a source-scanning test cannot see.
396 + add_action('switch_blog', [self::class, 'flush_instance_cache']);
397 + // Admin-only: a front-end pageview can never need this migration, and
398 + // the marker is a non-autoloaded option, so hooking it unconditionally
399 + // bought one dedicated query on every request for the life of the
400 + // install (#588).
401 + if (is_admin()) {
402 + add_action('init', [self::class, 'retire_seeded_ai_provider']);
403 + }
307 404 }
308 405
309 406 /**
407 + * Drop every memoized setting value.
408 + *
409 + * Called on `switch_blog` so a blog switch cannot serve the previous
410 + * site's configuration, and available to any caller that switches
411 + * explicitly. Cheap: the next read repopulates from the options cache.
412 + *
413 + * @since 2.9.0
414 + * @return void
415 + */
416 + public function flush_cache(): void {
417 + $this->cache = [];
418 + }
419 +
420 + /**
421 + * Drop the memo on the instance consumers actually read through.
422 + *
423 + * Resolved at fire time rather than registration time, so it always acts on
424 + * whatever Settings::instance() currently returns.
425 + *
426 + * @since 2.9.0
427 + * @return void
428 + */
429 + public static function flush_instance_cache(): void {
430 + self::instance()->flush_cache();
431 + }
432 +
433 + /**
434 + * Clear the OpenAI selection that older versions seeded on activation.
435 + *
436 + * Changing the default only helps installs created after the change. Every
437 + * site activated before it still carries `ai_provider = 'openai'` written by
438 + * Activator::set_default_options(), and still opens Settings warning about a
439 + * missing key for a provider nobody picked — the whole complaint in #572.
440 + *
441 + * "OpenAI with no OpenAI key" is provably not a user's choice: the settings
442 + * form refuses to save a provider without a key, so the only way to reach
443 + * that state is the old activation seed. A site that genuinely chose OpenAI
444 + * has a key and is left alone, as is any site on another provider.
445 + *
446 + * Version-gated so it runs once and never fights a user who later clears
447 + * their key but keeps the provider selected.
448 + *
449 + * @since 2.1.3
450 + *
451 + * @return void
452 + */
453 + public static function retire_seeded_ai_provider(): void {
454 + if (get_option(self::PROVIDER_MIGRATION_OPTION) === self::PROVIDER_MIGRATION_VERSION) {
455 + self::promote_migration_marker_to_autoload();
456 + return;
457 + }
458 +
459 + // Record first: a site that somehow fails the checks below must not
460 + // re-test on every request for the rest of its life. Autoloaded on
461 + // purpose — it is a short write-once flag that is read on every admin
462 + // request, which is exactly what autoload is for; storing it
463 + // non-autoloaded bought a dedicated query per request instead (#588).
464 + update_option(self::PROVIDER_MIGRATION_OPTION, self::PROVIDER_MIGRATION_VERSION, true);
465 +
466 + if (get_option('thinkrank_ai_provider', null) !== 'openai') {
467 + return;
468 + }
469 +
470 + // Read the stored option directly rather than through Settings::get().
471 + // Only emptiness matters here, and get() adds two things this decision
472 + // must not depend on: a per-instance cache that may already be primed,
473 + // and decryption that can yield '' for a key that is genuinely present.
474 + // The raw option is non-empty whenever a key exists, encrypted or not.
475 + if ('' !== (string) get_option('thinkrank_openai_api_key', '')) {
476 + // A real choice, backed by a key. Leave it.
477 + return;
478 + }
479 +
480 + // Write through the shared instance, not a throwaway one: set() refreshes
481 + // only the cache of the object it is called on, so a private instance
482 + // would leave the registered component serving the old value for the
483 + // rest of this request — including to the admin page it localizes.
484 + self::instance()->set('ai_provider', self::AI_PROVIDER_NONE);
485 + }
486 +
487 + /**
488 + * Move a legacy migration marker into the autoloaded set.
489 + *
490 + * Sites that ran the migration on 2.1.3 wrote the marker with
491 + * `autoload = false`, and the version gate above returns before the write
492 + * that would correct it — so those installs keep paying a dedicated query
493 + * to read a one-byte flag on every admin request, which is the cost #588
494 + * was about. Promote it once.
495 + *
496 + * Free to test: alloptions is loaded from the object cache once per request
497 + * regardless, and an autoloaded marker is in it, so the steady state after
498 + * the promotion is a cache lookup and nothing else. wp_set_option_autoload()
499 + * arrived in WP 6.4 and the plugin supports 6.0, hence the guard.
500 + *
501 + * @since 2.2.0
502 + *
503 + * @return void
504 + */
505 + private static function promote_migration_marker_to_autoload(): void {
506 + if (!function_exists('wp_set_option_autoload') || !function_exists('wp_load_alloptions')) {
507 + return;
508 + }
509 +
510 + if (array_key_exists(self::PROVIDER_MIGRATION_OPTION, wp_load_alloptions())) {
511 + return;
512 + }
513 +
514 + wp_set_option_autoload(self::PROVIDER_MIGRATION_OPTION, true);
515 + }
516 +
517 + /**
310 518 * Register WordPress settings
311 519 *
312 520 * @return void
313 521 */
@@ -318,8 +526,47 @@
318 526 ]);
319 527 }
320 528
321 529 /**
530 + * Warm the option cache for a batch of setting keys in one query.
531 + *
532 + * Every `thinkrank_*` option is autoload=off, so WordPress cannot serve
533 + * them from `alloptions` and each get() below is its own round-trip. A
534 + * caller that reads a known list of keys should prime it first: on a
535 + * ~1ms managed-hosting round-trip, sixteen of those on an anonymous
536 + * pageview is ~16ms spent on values the page may not use (#393).
537 + *
538 + * get() memoizes within the request, so only the first read of each key
539 + * ever reaches the database — this is what collapses those first reads.
540 + * Keys already memoized are left out of the batch.
541 + *
542 + * wp_prime_option_caches() is WP 6.4+; the plugin supports 6.0, so an
543 + * older site simply keeps the previous behaviour.
544 + *
545 + * @since 2.1.0
546 + *
547 + * @param string[] $keys Setting keys, without the `thinkrank_` prefix.
548 + * @return void
549 + */
550 + public function prime(array $keys): void {
551 + if (!function_exists('wp_prime_option_caches')) {
552 + return;
553 + }
554 +
555 + $unread = [];
556 +
557 + foreach ($keys as $key) {
558 + if (!isset($this->cache[$key])) {
559 + $unread[] = 'thinkrank_' . $key;
560 + }
561 + }
562 +
563 + if (!empty($unread)) {
564 + wp_prime_option_caches($unread);
565 + }
566 + }
567 +
568 + /**
322 569 * Get setting value
323 570 *
324 571 * @param string $key Setting key
325 572 * @param mixed $fallback Default value
@@ -397,10 +644,14 @@
397 644 if (!array_key_exists($key, $this->defaults)) {
398 645 return false;
399 646 }
400 647
648 + // Apply the declared per-key sanitizer before anything is stored. This
649 + // is the path essentially every caller takes, so skipping it left the
650 + // whole sanitize_setting() switch unreachable — max_tokens,
651 + // cache_duration and temperature persisted whatever string arrived.
652 + $value = $this->sanitize_setting($key, $value);
401 653
402 -
403 654 // Encrypt if needed
404 655 $encrypted_value = $this->maybe_encrypt($key, $value);
405 656
406 657 // Save to WordPress options or user meta
@@ -481,8 +732,23 @@
481 732 return $result;
482 733 }
483 734
484 735 /**
736 + * Setting keys whose values are encrypted at rest.
737 + *
738 + * Exposed so callers that must never emit a credential — the data exporter
739 + * in particular — can filter against the same list this class encrypts
740 + * with, instead of keeping a copy that silently drifts when a key is added.
741 + *
742 + * @since 2.2.0
743 + *
744 + * @return string[] Setting keys.
745 + */
746 + public function get_encrypted_keys(): array {
747 + return $this->encrypted_keys;
748 + }
749 +
750 + /**
485 751 * Get all settings (optimized with bulk caching)
486 752 *
487 753 * @param int $user_id User ID (0 for global)
488 754 * @return array All settings
@@ -549,8 +815,44 @@
549 815 return $sanitized;
550 816 }
551 817
552 818 /**
819 + * Sanitize a nested array, preserving its shape.
820 + *
821 + * Scalars keep their type (an int threshold stays an int); strings are
822 + * text-sanitized; objects are dropped, since no setting stores one.
823 + *
824 + * @since 2.2.0
825 + * @param array $value Array to sanitize.
826 + * @param int $depth Current recursion depth.
827 + * @return array
828 + */
829 + private function sanitize_array_recursive(array $value, int $depth = 0): array {
830 + // Settings are configuration, not arbitrary payloads; a cap keeps a
831 + // malformed deep structure from recursing without bound.
832 + if ($depth > 10) {
833 + return [];
834 + }
835 +
836 + $sanitized = [];
837 + foreach ($value as $item_key => $item) {
838 + $key = is_string($item_key) ? sanitize_key($item_key) : $item_key;
839 +
840 + if (is_array($item)) {
841 + $sanitized[$key] = $this->sanitize_array_recursive($item, $depth + 1);
842 + } elseif (is_object($item)) {
843 + continue;
844 + } elseif (is_bool($item) || is_int($item) || is_float($item)) {
845 + $sanitized[$key] = $item;
846 + } else {
847 + $sanitized[$key] = sanitize_text_field((string) $item);
848 + }
849 + }
850 +
851 + return $sanitized;
852 + }
853 +
854 + /**
553 855 * Sanitize individual setting
554 856 *
555 857 * @param string $key Setting key
556 858 * @param mixed $value Setting value
@@ -572,17 +874,61 @@
572 874 case 'openai_api_key':
573 875 case 'claude_api_key':
574 876 case 'gemini_api_key':
575 877 case 'openrouter_api_key':
878 + case 'openai_compatible_api_key':
576 879 return sanitize_text_field($value);
577 880
881 + case 'openai_compatible_base_url':
882 + // Validation (scheme, SSRF guard) belongs to the write path that
883 + // can report a reason to the user; a value that never went
884 + // through it must not become a URL we fetch, so an invalid one
885 + // is stored as empty — which disables the provider — rather
886 + // than silently kept.
887 + $url = esc_url_raw(trim((string) $value));
888 + if ('' === $url) {
889 + return '';
890 + }
891 + $validated = \ThinkRank\AI\Endpoint_URL_Validator::validate($url);
892 +
893 + return is_wp_error($validated) ? '' : $validated;
894 +
895 + case 'openai_compatible_timeout':
896 + // Below 10s nothing local ever finishes; above 600s PHP-FPM
897 + // kills the request first.
898 + return max(10, min(600, absint($value)));
899 +
900 + case 'openai_compatible_price_per_million':
901 + return max(0.0, (float) $value);
902 +
903 + case 'openai_compatible_supports_images':
904 + case 'openai_compatible_json_mode':
905 + // Each toggle decides whether we send a field (a vision
906 + // payload, response_format) a server may reject, so coerce the '0'/'false' a form
907 + // post can send rather than storing a truthy string (the
908 + // default: arm keeps strings as strings, and '0' is truthy to
909 + // nobody but PHP's loose rules).
910 + if (is_string($value)) {
911 + return !in_array(strtolower(trim($value)), ['', '0', 'false', 'no', 'off'], true);
912 + }
913 +
914 + return (bool) $value;
915 +
578 916 case 'ai_provider':
579 - return sanitize_key($value);
917 + // sanitize_key() maps '' to '', which is AI_PROVIDER_NONE — the
918 + // deliberate "no provider chosen" state, so it must survive here
919 + // rather than being folded back into the default (#572).
920 + $provider = sanitize_key($value);
580 921
922 + return in_array($provider, self::selectable_ai_providers(), true)
923 + ? $provider
924 + : $this->defaults['ai_provider'];
925 +
581 926 case 'openai_model':
582 927 case 'claude_model':
583 928 case 'gemini_model':
584 929 case 'openrouter_model':
930 + case 'openai_compatible_model':
585 931 // Model ids may contain dots and slashes (e.g. "gpt-4.1" or
586 932 // "openai/gpt-4o-mini") and users can enter custom models, so
587 933 // sanitize_key() would corrupt them — use text-field sanitizing.
588 934 return sanitize_text_field($value);
@@ -589,8 +935,9 @@
589 935
590 936 case 'max_tokens':
591 937 case 'cache_duration':
592 938 case 'max_requests_per_minute':
939 + case 'ai_daily_request_limit':
593 940 case 'seo_score_threshold':
594 941 case 'api_timeout':
595 942 case 'retry_attempts':
596 943 case 'data_retention_days':
@@ -596,8 +943,15 @@
596 943 case 'data_retention_days':
597 944 case 'monitoring_frequency':
598 945 return absint($value);
599 946
947 + case 'ai_paused':
948 + // The kill switch arrives from REST as a real boolean, from a
949 + // form post as "1"/"0", and from WP-CLI as "true"/"false". The
950 + // default arm's sanitize_text_field() would turn "false" into a
951 + // truthy string and silently pause a site that asked to resume.
952 + return rest_sanitize_boolean($value);
953 +
600 954 case 'temperature':
601 955 return (float) $value;
602 956
603 957 case 'dashboard_widgets':
@@ -615,8 +969,17 @@
615 969 foreach ($value as $threshold_key => $threshold_value) {
616 970 if (is_array($threshold_value)) {
617 971 continue;
618 972 }
973 + if (is_bool($threshold_value)) {
974 + // Keep booleans as booleans. is_numeric() is false for
975 + // one, so it used to fall to the string arm and a true
976 + // came back as "1" — invisible while this ran only on
977 + // the register_setting() path, now that set() routes
978 + // every write through here it is a type change on save.
979 + $thresholds[sanitize_key($threshold_key)] = $threshold_value;
980 + continue;
981 + }
619 982 $thresholds[sanitize_key($threshold_key)] = is_numeric($threshold_value)
620 983 ? $threshold_value + 0
621 984 : sanitize_text_field((string) $threshold_value);
622 985 }
@@ -628,9 +991,12 @@
628 991 return sanitize_text_field($value);
629 992
630 993 case 'robots_txt_content':
631 994 // Multi-line content — sanitize_text_field() collapses newlines
632 - // and would flatten the whole file onto a single line.
995 + // and would flatten the whole file onto a single line. set()
996 + // routes every write through here, so a caller that chose
997 + // sanitize_textarea_field() itself is otherwise silently
998 + // overridden by the default: arm below (#587).
633 999 return sanitize_textarea_field($value);
634 1000
635 1001 default:
636 1002 if (is_bool($value)) {
@@ -637,19 +1003,14 @@
637 1003 return (bool) $value;
638 1004 } elseif (is_string($value)) {
639 1005 return sanitize_text_field($value);
640 1006 } elseif (is_array($value)) {
641 - // Flat list/map of scalars; nested members are dropped rather
642 - // than passed to a string sanitizer that would fatal on them.
643 - $sanitized = [];
644 - foreach ($value as $item_key => $item) {
645 - if (is_array($item) || is_object($item)) {
646 - continue;
647 - }
648 - $sanitized[is_string($item_key) ? sanitize_key($item_key) : $item_key] =
649 - sanitize_text_field((string) $item);
650 - }
651 - return $sanitized;
1007 + // Recurse rather than drop. Skipping nested members was
1008 + // harmless while this ran only on the register_setting()
1009 + // path, but set() now routes every write through here, and
1010 + // structured settings (lists of maps) were being silently
1011 + // emptied on save.
1012 + return $this->sanitize_array_recursive($value);
652 1013 }
653 1014 return $value;
654 1015 }
655 1016 }
@@ -671,25 +1032,12 @@
671 1032 private function maybe_encrypt(string $key, $value) {
672 1033 if (!in_array($key, $this->encrypted_keys, true) || !is_string($value) || '' === $value) {
673 1034 return $value;
674 1035 }
675 - if (!function_exists('sodium_crypto_secretbox')) {
676 - return $value; // sodium unavailable — store as-is
677 - }
678 1036
679 - $enc_key = $this->encryption_key();
680 - if ('' === $enc_key) {
681 - return $value;
682 - }
683 -
684 - try {
685 - $nonce = random_bytes(SODIUM_CRYPTO_SECRETBOX_NONCEBYTES);
686 - $cipher = sodium_crypto_secretbox($value, $nonce, $enc_key);
687 - // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_encode -- transport encoding for a sodium ciphertext, not obfuscation.
688 - return self::ENC_PREFIX . base64_encode($nonce . $cipher);
689 - } catch (\Exception $e) {
690 - return $value;
691 - }
1037 + // Same scheme, same key, same prefix — it just lives in Secret_At_Rest
1038 + // now so the MCP pairing token can use it too (#396).
1039 + return Secret_At_Rest::encrypt($value);
692 1040 }
693 1041
694 1042 /**
695 1043 * Decrypt a value previously encrypted by maybe_encrypt. Values without the
@@ -702,28 +1050,12 @@
702 1050 private function maybe_decrypt(string $key, $value) {
703 1051 if (!is_string($value) || strncmp($value, self::ENC_PREFIX, strlen(self::ENC_PREFIX)) !== 0) {
704 1052 return $value; // legacy plaintext or non-string
705 1053 }
706 - if (!function_exists('sodium_crypto_secretbox_open')) {
707 - return $value;
708 - }
709 1054
710 - $enc_key = $this->encryption_key();
711 - if ('' === $enc_key) {
712 - return $value;
713 - }
1055 + $plain = Secret_At_Rest::decrypt($value);
714 1056
715 - // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_decode -- decodes our own ciphertext envelope; strict mode is on.
716 - $decoded = base64_decode(substr($value, strlen(self::ENC_PREFIX)), true);
717 - if (false === $decoded || strlen($decoded) <= SODIUM_CRYPTO_SECRETBOX_NONCEBYTES) {
718 - return $value;
719 - }
720 -
721 - $nonce = substr($decoded, 0, SODIUM_CRYPTO_SECRETBOX_NONCEBYTES);
722 - $cipher = substr($decoded, SODIUM_CRYPTO_SECRETBOX_NONCEBYTES);
723 - $plain = sodium_crypto_secretbox_open($cipher, $nonce, $enc_key);
724 -
725 - if (false === $plain) {
1057 + if ('' === $plain) {
726 1058 // We reach here only when sodium is available and a key was
727 1059 // derivable, so this is a genuine failure: the auth salt changed
728 1060 // (config rotated, or the site was migrated without wp-config) or
729 1061 // the row is corrupt. The value is unrecoverable either way.
@@ -735,20 +1067,6 @@
735 1067 return '';
736 1068 }
737 1069
738 1070 return $plain;
739 - }
740 -
741 - /**
742 - * Derive a 32-byte encryption key from the site's auth salt.
743 - *
744 - * @return string Raw 32-byte key, or '' if salts are unavailable.
745 - */
746 - private function encryption_key(): string {
747 - if (!function_exists('wp_salt')) {
748 - return '';
749 - }
750 - // 32 raw bytes from a site-specific secret — SHA-256 output length
751 - // matches SODIUM_CRYPTO_SECRETBOX_KEYBYTES.
752 - return hash('sha256', 'thinkrank-settings|' . wp_salt('auth'), true);
753 1071 }
754 1072 }