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

925 lines 34.7 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3 /**
4 * Settings Class
5 *
6 * Handles plugin settings and configuration using WordPress options
7 *
8 * @package ThinkRank\Core
9 * @since 1.0.0
10 */
11
12 declare(strict_types=1);
13
14 namespace ThinkRank\Core;
15
16 // Prevent direct access
17 if (!defined('ABSPATH')) {
18 exit;
19 }
20
21 /**
22 * Settings Class
23 *
24 * Single Responsibility: Manage plugin settings and configuration
25 * Uses WordPress options for storage.
26 *
27 * Use Settings::instance() to get the shared instance rather than
28 * creating new instances — this ensures the internal cache is shared
29 * across all consumers and avoids redundant get_option() calls.
30 *
31 * @since 1.0.0
32 */
33 class Settings {
34
35 /**
36 * Canonical default AI model per provider.
37 *
38 * Single source of truth. Every call site that needs a provider's default
39 * model — the $defaults array below, the AI client constructors, the manager
40 * fallbacks, the content-brief generator and the usage-analytics endpoint —
41 * MUST reference these constants instead of repeating the literal, so the
42 * defaults can never silently drift out of sync (see issue #273).
43 *
44 * @since 1.28.0
45 */
46 const DEFAULT_OPENAI_MODEL = 'gpt-5-nano';
47 const DEFAULT_CLAUDE_MODEL = 'claude-sonnet-5';
48 const DEFAULT_GEMINI_MODEL = 'gemini-3.5-flash';
49 const DEFAULT_OPENROUTER_MODEL = 'openai/gpt-4o-mini';
50
51 /**
52 * Canonical default author-archive templates.
53 *
54 * Same single-source-of-truth rule as the model constants above: the
55 * defaults array, the REST endpoint, the get-settings ability and
56 * Author_Archives_Manager all read these instead of repeating the literal,
57 * which had already drifted — the title default hardcoded an en dash while
58 * the feature resolves %separator% from Site Identity (#318).
59 *
60 * @since 1.29.1
61 */
62 /**
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 /**
127 * Shared singleton instance
128 *
129 * @var Settings|null
130 */
131 private static ?Settings $instance = null;
132
133 /**
134 * Get the shared Settings instance
135 *
136 * Returns the instance registered in the main plugin's component
137 * container, or creates a standalone instance if the plugin hasn't
138 * loaded yet (e.g., during activation).
139 *
140 * @since 1.10.0
141 * @return Settings
142 */
143 public static function instance(): Settings {
144 if (self::$instance !== null) {
145 return self::$instance;
146 }
147
148 // Try to get from the main plugin container
149 if (function_exists('thinkrank')) {
150 $plugin = thinkrank();
151 $component = $plugin->get_component('settings');
152 if ($component instanceof self) {
153 self::$instance = $component;
154 return self::$instance;
155 }
156 }
157
158 // Fallback: create standalone instance (during activation, etc.)
159 self::$instance = new self();
160 return self::$instance;
161 }
162
163 /**
164 * Settings cache
165 *
166 * @var array
167 */
168 private array $cache = [];
169
170 /**
171 * Default settings
172 *
173 * @var array
174 */
175 private array $defaults = [
176 // AI Settings
177 'ai_provider' => self::AI_PROVIDER_NONE,
178 'openai_api_key' => '',
179 'openai_model' => self::DEFAULT_OPENAI_MODEL,
180 'claude_api_key' => '',
181 'claude_model' => self::DEFAULT_CLAUDE_MODEL, // Recommended default (best speed/quality balance)
182 'gemini_api_key' => '',
183 'gemini_model' => self::DEFAULT_GEMINI_MODEL,
184 'openrouter_api_key' => '',
185 'openrouter_model' => self::DEFAULT_OPENROUTER_MODEL,
186 'max_tokens' => 1000,
187 'temperature' => 0.7,
188
189 // Google API Keys (encrypted)
190 'google_analytics_api_key' => '',
191 'google_search_console_api_key' => '',
192 'google_pagespeed_api_key' => '',
193
194 // Google OAuth Tokens (encrypted)
195 'google_access_token' => '',
196 'google_refresh_token' => '',
197 'google_token_expires_in' => 0,
198 'google_token_created' => 0,
199 'google_account_connected' => false,
200
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.
204 'ga_analytics_account_id' => '',
205 'ga_analytics_data_stream_id' => '',
206
207 // Performance Settings
208 'cache_duration' => 3600,
209 'max_requests_per_minute' => 10,
210 'enable_logging' => true,
211
212 // Integration Settings
213 'api_timeout' => 30,
214 'enable_rate_limiting' => true,
215 'auto_test_connections' => true,
216 'retry_failed_requests' => true,
217
218 // Social Platform Settings
219 // Public IDs (not encrypted)
220 'facebook_app_id' => '',
221 'facebook_admins' => '',
222 'youtube_channel_id' => '',
223 'whatsapp_business_id' => '',
224 // Verification codes (encrypted)
225 'pinterest_site_verification' => '',
226 'instagram_verification' => '',
227 'tiktok_verification' => '',
228
229 // SEO Settings
230 'auto_optimize' => false,
231 'seo_score_threshold' => 70,
232 'enable_meta_generation' => true,
233 'enable_schema_markup' => true,
234
235 // Author Archives Settings
236 'author_archives_enabled' => true,
237 'author_archives_index' => true,
238 'author_archives_show_empty' => false,
239 'author_archives_title' => self::DEFAULT_AUTHOR_ARCHIVES_TITLE,
240 'author_archives_meta_desc' => self::DEFAULT_AUTHOR_ARCHIVES_META_DESC,
241
242 // UI Settings
243 'show_welcome_message' => true,
244 'dashboard_widgets' => ['seo_score', 'ai_usage', 'recent_briefs'],
245 'editor_panel_position' => 'side',
246
247 // Advanced Settings
248 'debug_mode' => false,
249 'retry_attempts' => 3,
250 // Exposes the standalone Migration admin page for re-running SEO data
251 // imports after setup. Hidden by default; opt-in for advanced/support use.
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,
261
262 // Privacy Settings
263 'data_retention_days' => 90,
264 'anonymize_logs' => true,
265 'share_usage_data' => false,
266
267 // Integration Settings
268 'google_analytics_id' => '',
269 'search_console_property' => '',
270
271 // SEO Analytics Settings
272 'seo_analytics_enabled' => false,
273 'seo_analytics_setup_completed' => false,
274 'seo_analytics_google_analytics_property_id' => '',
275 'seo_analytics_enable_ai_insights' => true,
276 'seo_analytics_enable_automated_alerts' => false,
277 'seo_analytics_enable_predictive_analysis' => false,
278 'seo_analytics_monitoring_frequency' => 3600,
279 'seo_analytics_alert_thresholds' => [],
280 'seo_analytics_report_schedule' => 'weekly',
281 'seo_analytics_data_retention_days' => 90,
282 'seo_analytics_cache_analytics_data' => true,
283
284 // Uninstall Settings
285 'keep_data_on_uninstall' => true,
286
287 // MCP (Model Context Protocol) Settings
288 'enable_mcp' => false,
289 ];
290
291 /**
292 * Encrypted settings keys
293 *
294 * @var array
295 */
296 private array $encrypted_keys = [
297 // AI API Keys
298 'openai_api_key',
299 'claude_api_key',
300 'gemini_api_key',
301 'openrouter_api_key',
302 // Google API Keys
303 'google_analytics_api_key',
304 'google_search_console_api_key',
305 'google_pagespeed_api_key',
306 'google_access_token',
307 'google_refresh_token',
308 // Social Platform Verification Codes (sensitive)
309 'pinterest_site_verification',
310 'instagram_verification',
311 'tiktok_verification',
312 ];
313
314 /**
315 * Initialize settings
316 *
317 * @return void
318 */
319 public function init(): void {
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 }
328 }
329
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 /**
415 * Register WordPress settings
416 *
417 * @return void
418 */
419 public function register_settings(): void {
420 register_setting('thinkrank_settings', 'thinkrank_settings', [
421 'sanitize_callback' => [$this, 'sanitize_settings'],
422 'default' => $this->defaults,
423 ]);
424 }
425
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 /**
466 * Get setting value
467 *
468 * @param string $key Setting key
469 * @param mixed $fallback Default value
470 * @param int $user_id User ID (0 for global, >0 for user-specific)
471 * @return mixed Setting value
472 */
473 public function get(string $key, $fallback = null, int $user_id = 0) {
474 // Check cache first
475 $cache_key = $user_id > 0 ? "user_{$user_id}_{$key}" : $key;
476
477 if (isset($this->cache[$cache_key])) {
478 return $this->maybe_decrypt($key, $this->cache[$cache_key]);
479 }
480
481 // Load from WordPress options or user meta
482 if ($user_id > 0) {
483 $value = get_user_meta($user_id, "thinkrank_{$key}", true);
484 } else {
485 $value = get_option("thinkrank_{$key}", null);
486 }
487
488
489
490 // Handle boolean settings with special logic for WordPress storage quirks
491 if (isset($this->defaults[$key]) && is_bool($this->defaults[$key])) {
492 // Convert string representations back to booleans
493 if ('1' === $value || 1 === $value || true === $value || 'true' === $value) {
494 $value = true;
495 } elseif ('0' === $value || 0 === $value || false === $value || 'false' === $value) {
496 $value = false;
497 } elseif (null === $value || '' === $value) {
498 // For boolean settings, empty string could mean false was stored
499 // null means option doesn't exist, empty string means it was stored as empty
500 if (null !== $value && $value === '') {
501 // Empty string exists in DB, this likely means false was stored
502 $value = false;
503 } else {
504 // Option doesn't exist, use default
505 $value = $fallback ?? $this->defaults[$key];
506 }
507 }
508 } else {
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);
522 }
523 }
524
525 // Cache the value
526 $this->cache[$cache_key] = $value;
527
528 return $this->maybe_decrypt($key, $value);
529 }
530
531 /**
532 * Set setting value
533 *
534 * @param string $key Setting key
535 * @param mixed $value Setting value
536 * @param int $user_id User ID (0 for global, >0 for user-specific)
537 * @return bool Success status
538 */
539 public function set(string $key, $value, int $user_id = 0): bool {
540 // Check if key exists in defaults
541 if (!array_key_exists($key, $this->defaults)) {
542 return false;
543 }
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);
550
551 // Encrypt if needed
552 $encrypted_value = $this->maybe_encrypt($key, $value);
553
554 // Save to WordPress options or user meta
555 if ($user_id > 0) {
556 $result = update_user_meta($user_id, "thinkrank_{$key}", $encrypted_value);
557 } else {
558 $option_name = "thinkrank_{$key}";
559 $is_sensitive = in_array($key, $this->encrypted_keys, true);
560
561 // Determine if option exists
562 $existing = get_option($option_name, '__tr_not_set__');
563 if ($existing === '__tr_not_set__') {
564 // First-time save: set autoload=no for sensitive options
565 $autoload = $is_sensitive ? 'no' : 'yes';
566 $result = add_option($option_name, $encrypted_value, '', $autoload);
567 } else {
568 // Update existing option; enforce autoload=no for sensitive options
569 if ($is_sensitive) {
570 $result = update_option($option_name, $encrypted_value, 'no');
571 } else {
572 $result = update_option($option_name, $encrypted_value);
573 }
574 }
575
576 // Verify value actually saved (update_option returns false when unchanged)
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.
583 $values_match = ($saved_value === $encrypted_value) ||
584 ($saved_value == $encrypted_value && $encrypted_value !== 'NOT_FOUND'); // phpcs:ignore Universal.Operators.StrictComparisons.LooseEqual -- intentional type-tolerant verify, see above.
585
586 // Consider it successful if the value was saved correctly
587 $result = $result || $values_match;
588 }
589
590 // Update cache
591 if ($result) {
592 $cache_key = $user_id > 0 ? "user_{$user_id}_{$key}" : $key;
593 $this->cache[$cache_key] = $encrypted_value;
594
595 // Invalidate bulk cache when individual setting changes
596 $bulk_cache_key = $user_id > 0 ? "bulk_user_{$user_id}" : 'bulk_global';
597 wp_cache_delete($bulk_cache_key, 'thinkrank_settings');
598 }
599
600 return $result !== false;
601 }
602
603 /**
604 * Delete setting
605 *
606 * @param string $key Setting key
607 * @param int $user_id User ID (0 for global, >0 for user-specific)
608 * @return bool Success status
609 */
610 public function delete(string $key, int $user_id = 0): bool {
611 // Remove from WordPress options or user meta
612 if ($user_id > 0) {
613 $result = delete_user_meta($user_id, "thinkrank_{$key}");
614 } else {
615 $result = delete_option("thinkrank_{$key}");
616 }
617
618 // Remove from cache
619 if ($result) {
620 $cache_key = $user_id > 0 ? "user_{$user_id}_{$key}" : $key;
621 unset($this->cache[$cache_key]);
622
623 // Also invalidate the bulk cache written by get_all(), or it serves
624 // stale values for up to 5 minutes after a delete.
625 $bulk_cache_key = $user_id > 0 ? "bulk_user_{$user_id}" : 'bulk_global';
626 wp_cache_delete($bulk_cache_key, 'thinkrank_settings');
627 }
628
629 return $result;
630 }
631
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 /**
648 * Get all settings (optimized with bulk caching)
649 *
650 * @param int $user_id User ID (0 for global)
651 * @return array All settings
652 */
653 public function get_all(int $user_id = 0): array {
654 // Check bulk cache first
655 $bulk_cache_key = $user_id > 0 ? "bulk_user_{$user_id}" : 'bulk_global';
656
657 $cached_settings = wp_cache_get($bulk_cache_key, 'thinkrank_settings');
658 if ($cached_settings !== false) {
659 return $cached_settings;
660 }
661
662 $settings = [];
663
664 foreach (array_keys($this->defaults) as $key) {
665 $settings[$key] = $this->get($key, null, $user_id);
666 }
667
668 // Cache all settings for 5 minutes
669 wp_cache_set($bulk_cache_key, $settings, 'thinkrank_settings', 300);
670
671 return $settings;
672 }
673
674 /**
675 * Reset settings to defaults
676 *
677 * @param int $user_id User ID (0 for global)
678 * @return bool Success status
679 */
680 public function reset(int $user_id = 0): bool {
681 $success = true;
682
683 foreach (array_keys($this->defaults) as $key) {
684 if (!$this->delete($key, $user_id)) {
685 $success = false;
686 }
687 }
688
689 // Clear cache — both the instance array and the bulk object cache
690 // written by get_all(), which would otherwise return stale pre-reset
691 // values for up to 5 minutes.
692 $this->cache = [];
693 $bulk_cache_key = $user_id > 0 ? "bulk_user_{$user_id}" : 'bulk_global';
694 wp_cache_delete($bulk_cache_key, 'thinkrank_settings');
695
696 return $success;
697 }
698
699 /**
700 * Sanitize settings
701 *
702 * @param array $settings Settings array
703 * @return array Sanitized settings
704 */
705 public function sanitize_settings(array $settings): array {
706 $sanitized = [];
707
708 foreach ($settings as $key => $value) {
709 $sanitized[$key] = $this->sanitize_setting($key, $value);
710 }
711
712 return $sanitized;
713 }
714
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 /**
752 * Sanitize individual setting
753 *
754 * @param string $key Setting key
755 * @param mixed $value Setting value
756 * @return mixed Sanitized value
757 */
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
770 switch ($key) {
771 case 'openai_api_key':
772 case 'claude_api_key':
773 case 'gemini_api_key':
774 case 'openrouter_api_key':
775 return sanitize_text_field($value);
776
777 case 'ai_provider':
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);
782
783 return in_array($provider, self::selectable_ai_providers(), true)
784 ? $provider
785 : $this->defaults['ai_provider'];
786
787 case 'openai_model':
788 case 'claude_model':
789 case 'gemini_model':
790 case 'openrouter_model':
791 // Model ids may contain dots and slashes (e.g. "gpt-4.1" or
792 // "openai/gpt-4o-mini") and users can enter custom models, so
793 // sanitize_key() would corrupt them — use text-field sanitizing.
794 return sanitize_text_field($value);
795
796 case 'max_tokens':
797 case 'cache_duration':
798 case 'max_requests_per_minute':
799 case 'seo_score_threshold':
800 case 'api_timeout':
801 case 'retry_attempts':
802 case 'data_retention_days':
803 case 'monitoring_frequency':
804 return absint($value);
805
806 case 'temperature':
807 return (float) $value;
808
809 case 'dashboard_widgets':
810 return is_array($value) ? array_map('sanitize_key', $value) : [];
811
812 case 'seo_analytics_alert_thresholds':
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;
839
840 case 'google_analytics_property_id':
841 case 'seo_analytics_google_analytics_property_id':
842 case 'seo_analytics_report_schedule':
843 return sanitize_text_field($value);
844
845 case 'robots_txt_content':
846 // Multi-line content — sanitize_text_field() collapses newlines
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).
851 return sanitize_textarea_field($value);
852
853 default:
854 if (is_bool($value)) {
855 return (bool) $value;
856 } elseif (is_string($value)) {
857 return sanitize_text_field($value);
858 } elseif (is_array($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);
865 }
866 return $value;
867 }
868 }
869
870 /**
871 * Marker prefix for values encrypted with the libsodium scheme below.
872 */
873 private const ENC_PREFIX = 'trenc:v1:';
874
875 /**
876 * Encrypt secret settings (API keys, OAuth tokens) at rest using libsodium
877 * authenticated encryption. Non-secret keys and environments without sodium
878 * fall back to plaintext so behaviour stays stable.
879 *
880 * @param string $key Setting key
881 * @param mixed $value Setting value
882 * @return mixed Encrypted payload (string) or original value
883 */
884 private function maybe_encrypt(string $key, $value) {
885 if (!in_array($key, $this->encrypted_keys, true) || !is_string($value) || '' === $value) {
886 return $value;
887 }
888
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);
892 }
893
894 /**
895 * Decrypt a value previously encrypted by maybe_encrypt. Values without the
896 * marker prefix are legacy plaintext and returned untouched (backward compat).
897 *
898 * @param string $key Setting key
899 * @param mixed $value Setting value
900 * @return mixed Decrypted or original value
901 */
902 private function maybe_decrypt(string $key, $value) {
903 if (!is_string($value) || strncmp($value, self::ENC_PREFIX, strlen(self::ENC_PREFIX)) !== 0) {
904 return $value; // legacy plaintext or non-string
905 }
906
907 $plain = Secret_At_Rest::decrypt($value);
908
909 if ('' === $plain) {
910 // We reach here only when sodium is available and a key was
911 // derivable, so this is a genuine failure: the auth salt changed
912 // (config rotated, or the site was migrated without wp-config) or
913 // the row is corrupt. The value is unrecoverable either way.
914 //
915 // Returning the ciphertext would send it upstream as a bearer
916 // token or API key, producing an opaque 401 far from the cause.
917 // Return empty so callers see "no credential" and can prompt for
918 // a reconnect instead.
919 return '';
920 }
921
922 return $plain;
923 }
924 }
925