PluginProbe
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO / 2.10.0
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO v2.10.0
2.10.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 All 51 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.10.0, at includes/core/class-settings.php

1,073 lines 41.3 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 * 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 /**
63 * Canonical default author-archive templates.
64 *
65 * Same single-source-of-truth rule as the model constants above: the
66 * defaults array, the REST endpoint, the get-settings ability and
67 * Author_Archives_Manager all read these instead of repeating the literal,
68 * which had already drifted — the title default hardcoded an en dash while
69 * the feature resolves %separator% from Site Identity (#318).
70 *
71 * @since 1.29.1
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
144 const DEFAULT_AUTHOR_ARCHIVES_TITLE = '%author_name% %separator% %site_title% %page%';
145 const DEFAULT_AUTHOR_ARCHIVES_META_DESC = 'Articles written by %author_name% on %site_title%';
146
147 /**
148 * String settings where a stored empty string is a real value, not "unset".
149 *
150 * get() normally treats '' the same as a missing option and returns the
151 * default, which is right for most keys — a blank API key or model name is
152 * never what the user meant. For these template fields it is the opposite:
153 * clearing the box means "render no template", and the consumers already
154 * branch on an empty value. Without the opt-out the save appeared to
155 * succeed and the default reappeared on the next request (#316).
156 *
157 * @since 1.29.1
158 * @var string[]
159 */
160 private const EMPTY_IS_A_VALUE = [
161 'author_archives_title',
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',
167 ];
168
169 /**
170 * Shared singleton instance
171 *
172 * @var Settings|null
173 */
174 private static ?Settings $instance = null;
175
176 /**
177 * Get the shared Settings instance
178 *
179 * Returns the instance registered in the main plugin's component
180 * container, or creates a standalone instance if the plugin hasn't
181 * loaded yet (e.g., during activation).
182 *
183 * @since 1.10.0
184 * @return Settings
185 */
186 public static function instance(): Settings {
187 if (self::$instance !== null) {
188 return self::$instance;
189 }
190
191 // Try to get from the main plugin container
192 if (function_exists('thinkrank')) {
193 $plugin = thinkrank();
194 $component = $plugin->get_component('settings');
195 if ($component instanceof self) {
196 self::$instance = $component;
197 return self::$instance;
198 }
199 }
200
201 // Fallback: create standalone instance (during activation, etc.)
202 self::$instance = new self();
203 return self::$instance;
204 }
205
206 /**
207 * Settings cache
208 *
209 * @var array
210 */
211 private array $cache = [];
212
213 /**
214 * Default settings
215 *
216 * @var array
217 */
218 private array $defaults = [
219 // AI Settings
220 'ai_provider' => self::AI_PROVIDER_NONE,
221 'openai_api_key' => '',
222 'openai_model' => self::DEFAULT_OPENAI_MODEL,
223 'claude_api_key' => '',
224 'claude_model' => self::DEFAULT_CLAUDE_MODEL, // Recommended default (best speed/quality balance)
225 'gemini_api_key' => '',
226 'gemini_model' => self::DEFAULT_GEMINI_MODEL,
227 'openrouter_api_key' => '',
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,
239 'max_tokens' => 1000,
240 'temperature' => 0.7,
241
242 // Google API Keys (encrypted)
243 'google_analytics_api_key' => '',
244 'google_search_console_api_key' => '',
245 'google_pagespeed_api_key' => '',
246
247 // Google OAuth Tokens (encrypted)
248 'google_access_token' => '',
249 'google_refresh_token' => '',
250 'google_token_expires_in' => 0,
251 'google_token_created' => 0,
252 'google_account_connected' => false,
253
254 // Google Analytics Pro Settings. Read/written by thinkrank-pro's
255 // GoogleAnalyticsSettings.js through the settings-management endpoint —
256 // no consumer exists in THIS repo, so don't dead-key these.
257 'ga_analytics_account_id' => '',
258 'ga_analytics_data_stream_id' => '',
259
260 // Performance Settings
261 'cache_duration' => 3600,
262 'max_requests_per_minute' => 0,
263 'enable_logging' => true,
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
271 // Integration Settings
272 'api_timeout' => 30,
273 'enable_rate_limiting' => true,
274 'auto_test_connections' => true,
275 'retry_failed_requests' => true,
276
277 // Social Platform Settings
278 // Public IDs (not encrypted)
279 'facebook_app_id' => '',
280 'facebook_admins' => '',
281 'youtube_channel_id' => '',
282 'whatsapp_business_id' => '',
283 // Verification codes (encrypted)
284 'pinterest_site_verification' => '',
285 'instagram_verification' => '',
286 'tiktok_verification' => '',
287
288 // SEO Settings
289 'auto_optimize' => false,
290 'seo_score_threshold' => 70,
291 'enable_meta_generation' => true,
292 'enable_schema_markup' => true,
293
294 // Author Archives Settings
295 'author_archives_enabled' => true,
296 'author_archives_index' => true,
297 'author_archives_show_empty' => false,
298 'author_archives_title' => self::DEFAULT_AUTHOR_ARCHIVES_TITLE,
299 'author_archives_meta_desc' => self::DEFAULT_AUTHOR_ARCHIVES_META_DESC,
300
301 // UI Settings
302 'show_welcome_message' => true,
303 'dashboard_widgets' => ['seo_score', 'ai_usage', 'recent_briefs'],
304 'editor_panel_position' => 'side',
305
306 // Advanced Settings
307 'debug_mode' => false,
308 'retry_attempts' => 3,
309 // Exposes the standalone Migration admin page for re-running SEO data
310 // imports after setup. Hidden by default; opt-in for advanced/support use.
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,
320
321 // Privacy Settings
322 'data_retention_days' => 90,
323 'anonymize_logs' => true,
324 'share_usage_data' => false,
325
326 // Integration Settings
327 'google_analytics_id' => '',
328 'search_console_property' => '',
329
330 // SEO Analytics Settings
331 'seo_analytics_enabled' => false,
332 'seo_analytics_setup_completed' => false,
333 'seo_analytics_google_analytics_property_id' => '',
334 'seo_analytics_enable_ai_insights' => true,
335 'seo_analytics_enable_automated_alerts' => false,
336 'seo_analytics_enable_predictive_analysis' => false,
337 'seo_analytics_monitoring_frequency' => 3600,
338 'seo_analytics_alert_thresholds' => [],
339 'seo_analytics_report_schedule' => 'weekly',
340 'seo_analytics_data_retention_days' => 90,
341 'seo_analytics_cache_analytics_data' => true,
342
343 // Uninstall Settings
344 'keep_data_on_uninstall' => true,
345
346 // MCP (Model Context Protocol) Settings
347 'enable_mcp' => false,
348 ];
349
350 /**
351 * Encrypted settings keys
352 *
353 * @var array
354 */
355 private array $encrypted_keys = [
356 // AI API Keys
357 'openai_api_key',
358 'claude_api_key',
359 'gemini_api_key',
360 'openrouter_api_key',
361 'openai_compatible_api_key',
362 // Google API Keys
363 'google_analytics_api_key',
364 'google_search_console_api_key',
365 'google_pagespeed_api_key',
366 'google_access_token',
367 'google_refresh_token',
368 // Social Platform Verification Codes (sensitive)
369 'pinterest_site_verification',
370 'instagram_verification',
371 'tiktok_verification',
372 ];
373
374 /**
375 * Initialize settings
376 *
377 * @return void
378 */
379 public function init(): void {
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 }
404 }
405
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 /**
518 * Register WordPress settings
519 *
520 * @return void
521 */
522 public function register_settings(): void {
523 register_setting('thinkrank_settings', 'thinkrank_settings', [
524 'sanitize_callback' => [$this, 'sanitize_settings'],
525 'default' => $this->defaults,
526 ]);
527 }
528
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 /**
569 * Get setting value
570 *
571 * @param string $key Setting key
572 * @param mixed $fallback Default value
573 * @param int $user_id User ID (0 for global, >0 for user-specific)
574 * @return mixed Setting value
575 */
576 public function get(string $key, $fallback = null, int $user_id = 0) {
577 // Check cache first
578 $cache_key = $user_id > 0 ? "user_{$user_id}_{$key}" : $key;
579
580 if (isset($this->cache[$cache_key])) {
581 return $this->maybe_decrypt($key, $this->cache[$cache_key]);
582 }
583
584 // Load from WordPress options or user meta
585 if ($user_id > 0) {
586 $value = get_user_meta($user_id, "thinkrank_{$key}", true);
587 } else {
588 $value = get_option("thinkrank_{$key}", null);
589 }
590
591
592
593 // Handle boolean settings with special logic for WordPress storage quirks
594 if (isset($this->defaults[$key]) && is_bool($this->defaults[$key])) {
595 // Convert string representations back to booleans
596 if ('1' === $value || 1 === $value || true === $value || 'true' === $value) {
597 $value = true;
598 } elseif ('0' === $value || 0 === $value || false === $value || 'false' === $value) {
599 $value = false;
600 } elseif (null === $value || '' === $value) {
601 // For boolean settings, empty string could mean false was stored
602 // null means option doesn't exist, empty string means it was stored as empty
603 if (null !== $value && $value === '') {
604 // Empty string exists in DB, this likely means false was stored
605 $value = false;
606 } else {
607 // Option doesn't exist, use default
608 $value = $fallback ?? $this->defaults[$key];
609 }
610 }
611 } else {
612 // Non-boolean settings: use default if not found.
613 //
614 // get_option() above is called with a null default, so null means
615 // "no row" while '' means a value was deliberately stored. For most
616 // keys we collapse the two — an empty string is treated as unset so
617 // the documented default applies. Keys in EMPTY_IS_A_VALUE opt out:
618 // there, clearing the field is a real choice the consumer honours,
619 // and folding it back into the default made the save look like it
620 // silently failed (#316).
621 $empty_is_unset = !in_array($key, self::EMPTY_IS_A_VALUE, true);
622
623 if (null === $value || ($empty_is_unset && '' === $value)) {
624 $value = $fallback ?? ($this->defaults[$key] ?? null);
625 }
626 }
627
628 // Cache the value
629 $this->cache[$cache_key] = $value;
630
631 return $this->maybe_decrypt($key, $value);
632 }
633
634 /**
635 * Set setting value
636 *
637 * @param string $key Setting key
638 * @param mixed $value Setting value
639 * @param int $user_id User ID (0 for global, >0 for user-specific)
640 * @return bool Success status
641 */
642 public function set(string $key, $value, int $user_id = 0): bool {
643 // Check if key exists in defaults
644 if (!array_key_exists($key, $this->defaults)) {
645 return false;
646 }
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);
653
654 // Encrypt if needed
655 $encrypted_value = $this->maybe_encrypt($key, $value);
656
657 // Save to WordPress options or user meta
658 if ($user_id > 0) {
659 $result = update_user_meta($user_id, "thinkrank_{$key}", $encrypted_value);
660 } else {
661 $option_name = "thinkrank_{$key}";
662 $is_sensitive = in_array($key, $this->encrypted_keys, true);
663
664 // Determine if option exists
665 $existing = get_option($option_name, '__tr_not_set__');
666 if ($existing === '__tr_not_set__') {
667 // First-time save: set autoload=no for sensitive options
668 $autoload = $is_sensitive ? 'no' : 'yes';
669 $result = add_option($option_name, $encrypted_value, '', $autoload);
670 } else {
671 // Update existing option; enforce autoload=no for sensitive options
672 if ($is_sensitive) {
673 $result = update_option($option_name, $encrypted_value, 'no');
674 } else {
675 $result = update_option($option_name, $encrypted_value);
676 }
677 }
678
679 // Verify value actually saved (update_option returns false when unchanged)
680 $saved_value = get_option($option_name, 'NOT_FOUND');
681 // The loose branch is load-bearing: get_option() returns the stored
682 // STRING ('1', '0', '30') while $encrypted_value may be the original
683 // bool/int. On an unchanged re-save update_option() returns false, so
684 // this comparison is the only thing that marks the save successful —
685 // strict-only here made every unchanged-boolean re-save report failure.
686 $values_match = ($saved_value === $encrypted_value) ||
687 ($saved_value == $encrypted_value && $encrypted_value !== 'NOT_FOUND'); // phpcs:ignore Universal.Operators.StrictComparisons.LooseEqual -- intentional type-tolerant verify, see above.
688
689 // Consider it successful if the value was saved correctly
690 $result = $result || $values_match;
691 }
692
693 // Update cache
694 if ($result) {
695 $cache_key = $user_id > 0 ? "user_{$user_id}_{$key}" : $key;
696 $this->cache[$cache_key] = $encrypted_value;
697
698 // Invalidate bulk cache when individual setting changes
699 $bulk_cache_key = $user_id > 0 ? "bulk_user_{$user_id}" : 'bulk_global';
700 wp_cache_delete($bulk_cache_key, 'thinkrank_settings');
701 }
702
703 return $result !== false;
704 }
705
706 /**
707 * Delete setting
708 *
709 * @param string $key Setting key
710 * @param int $user_id User ID (0 for global, >0 for user-specific)
711 * @return bool Success status
712 */
713 public function delete(string $key, int $user_id = 0): bool {
714 // Remove from WordPress options or user meta
715 if ($user_id > 0) {
716 $result = delete_user_meta($user_id, "thinkrank_{$key}");
717 } else {
718 $result = delete_option("thinkrank_{$key}");
719 }
720
721 // Remove from cache
722 if ($result) {
723 $cache_key = $user_id > 0 ? "user_{$user_id}_{$key}" : $key;
724 unset($this->cache[$cache_key]);
725
726 // Also invalidate the bulk cache written by get_all(), or it serves
727 // stale values for up to 5 minutes after a delete.
728 $bulk_cache_key = $user_id > 0 ? "bulk_user_{$user_id}" : 'bulk_global';
729 wp_cache_delete($bulk_cache_key, 'thinkrank_settings');
730 }
731
732 return $result;
733 }
734
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 /**
751 * Get all settings (optimized with bulk caching)
752 *
753 * @param int $user_id User ID (0 for global)
754 * @return array All settings
755 */
756 public function get_all(int $user_id = 0): array {
757 // Check bulk cache first
758 $bulk_cache_key = $user_id > 0 ? "bulk_user_{$user_id}" : 'bulk_global';
759
760 $cached_settings = wp_cache_get($bulk_cache_key, 'thinkrank_settings');
761 if ($cached_settings !== false) {
762 return $cached_settings;
763 }
764
765 $settings = [];
766
767 foreach (array_keys($this->defaults) as $key) {
768 $settings[$key] = $this->get($key, null, $user_id);
769 }
770
771 // Cache all settings for 5 minutes
772 wp_cache_set($bulk_cache_key, $settings, 'thinkrank_settings', 300);
773
774 return $settings;
775 }
776
777 /**
778 * Reset settings to defaults
779 *
780 * @param int $user_id User ID (0 for global)
781 * @return bool Success status
782 */
783 public function reset(int $user_id = 0): bool {
784 $success = true;
785
786 foreach (array_keys($this->defaults) as $key) {
787 if (!$this->delete($key, $user_id)) {
788 $success = false;
789 }
790 }
791
792 // Clear cache — both the instance array and the bulk object cache
793 // written by get_all(), which would otherwise return stale pre-reset
794 // values for up to 5 minutes.
795 $this->cache = [];
796 $bulk_cache_key = $user_id > 0 ? "bulk_user_{$user_id}" : 'bulk_global';
797 wp_cache_delete($bulk_cache_key, 'thinkrank_settings');
798
799 return $success;
800 }
801
802 /**
803 * Sanitize settings
804 *
805 * @param array $settings Settings array
806 * @return array Sanitized settings
807 */
808 public function sanitize_settings(array $settings): array {
809 $sanitized = [];
810
811 foreach ($settings as $key => $value) {
812 $sanitized[$key] = $this->sanitize_setting($key, $value);
813 }
814
815 return $sanitized;
816 }
817
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 /**
855 * Sanitize individual setting
856 *
857 * @param string $key Setting key
858 * @param mixed $value Setting value
859 * @return mixed Sanitized value
860 */
861 private function sanitize_setting(string $key, $value) {
862 // Only keys that are declared array-typed may receive an array. For a
863 // scalar key an array/object value is malformed input, and the scalar
864 // sanitizers below would fatal on it, so fall back to the declared
865 // default instead of letting the bad value through.
866 if (is_array($value) || is_object($value)) {
867 if (!is_array($this->defaults[$key] ?? null)) {
868 return $this->defaults[$key] ?? '';
869 }
870 $value = (array) $value;
871 }
872
873 switch ($key) {
874 case 'openai_api_key':
875 case 'claude_api_key':
876 case 'gemini_api_key':
877 case 'openrouter_api_key':
878 case 'openai_compatible_api_key':
879 return sanitize_text_field($value);
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
916 case 'ai_provider':
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);
921
922 return in_array($provider, self::selectable_ai_providers(), true)
923 ? $provider
924 : $this->defaults['ai_provider'];
925
926 case 'openai_model':
927 case 'claude_model':
928 case 'gemini_model':
929 case 'openrouter_model':
930 case 'openai_compatible_model':
931 // Model ids may contain dots and slashes (e.g. "gpt-4.1" or
932 // "openai/gpt-4o-mini") and users can enter custom models, so
933 // sanitize_key() would corrupt them — use text-field sanitizing.
934 return sanitize_text_field($value);
935
936 case 'max_tokens':
937 case 'cache_duration':
938 case 'max_requests_per_minute':
939 case 'ai_daily_request_limit':
940 case 'seo_score_threshold':
941 case 'api_timeout':
942 case 'retry_attempts':
943 case 'data_retention_days':
944 case 'monitoring_frequency':
945 return absint($value);
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
954 case 'temperature':
955 return (float) $value;
956
957 case 'dashboard_widgets':
958 return is_array($value) ? array_map('sanitize_key', $value) : [];
959
960 case 'seo_analytics_alert_thresholds':
961 // A threshold_name => value map where the values are numbers
962 // (e.g. traffic_drop_percentage => 20) as well as strings, so
963 // sanitize each value by its own type rather than forcing every
964 // one through sanitize_key() — that turned ints into strings.
965 if (!is_array($value)) {
966 return [];
967 }
968 $thresholds = [];
969 foreach ($value as $threshold_key => $threshold_value) {
970 if (is_array($threshold_value)) {
971 continue;
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 }
982 $thresholds[sanitize_key($threshold_key)] = is_numeric($threshold_value)
983 ? $threshold_value + 0
984 : sanitize_text_field((string) $threshold_value);
985 }
986 return $thresholds;
987
988 case 'google_analytics_property_id':
989 case 'seo_analytics_google_analytics_property_id':
990 case 'seo_analytics_report_schedule':
991 return sanitize_text_field($value);
992
993 case 'robots_txt_content':
994 // Multi-line content — sanitize_text_field() collapses newlines
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).
999 return sanitize_textarea_field($value);
1000
1001 default:
1002 if (is_bool($value)) {
1003 return (bool) $value;
1004 } elseif (is_string($value)) {
1005 return sanitize_text_field($value);
1006 } elseif (is_array($value)) {
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);
1013 }
1014 return $value;
1015 }
1016 }
1017
1018 /**
1019 * Marker prefix for values encrypted with the libsodium scheme below.
1020 */
1021 private const ENC_PREFIX = 'trenc:v1:';
1022
1023 /**
1024 * Encrypt secret settings (API keys, OAuth tokens) at rest using libsodium
1025 * authenticated encryption. Non-secret keys and environments without sodium
1026 * fall back to plaintext so behaviour stays stable.
1027 *
1028 * @param string $key Setting key
1029 * @param mixed $value Setting value
1030 * @return mixed Encrypted payload (string) or original value
1031 */
1032 private function maybe_encrypt(string $key, $value) {
1033 if (!in_array($key, $this->encrypted_keys, true) || !is_string($value) || '' === $value) {
1034 return $value;
1035 }
1036
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);
1040 }
1041
1042 /**
1043 * Decrypt a value previously encrypted by maybe_encrypt. Values without the
1044 * marker prefix are legacy plaintext and returned untouched (backward compat).
1045 *
1046 * @param string $key Setting key
1047 * @param mixed $value Setting value
1048 * @return mixed Decrypted or original value
1049 */
1050 private function maybe_decrypt(string $key, $value) {
1051 if (!is_string($value) || strncmp($value, self::ENC_PREFIX, strlen(self::ENC_PREFIX)) !== 0) {
1052 return $value; // legacy plaintext or non-string
1053 }
1054
1055 $plain = Secret_At_Rest::decrypt($value);
1056
1057 if ('' === $plain) {
1058 // We reach here only when sodium is available and a key was
1059 // derivable, so this is a genuine failure: the auth salt changed
1060 // (config rotated, or the site was migrated without wp-config) or
1061 // the row is corrupt. The value is unrecoverable either way.
1062 //
1063 // Returning the ciphertext would send it upstream as a bearer
1064 // token or API key, producing an opaque 401 far from the cause.
1065 // Return empty so callers see "no credential" and can prompt for
1066 // a reconnect instead.
1067 return '';
1068 }
1069
1070 return $plain;
1071 }
1072 }
1073