PluginProbe
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO / 2.3.0
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO v2.3.0
2.7.0 2.6.0 2.5.0 2.4.0 2.3.0 2.2.0 2.1.1 2.1.0 2.0.2 2.0.1 2.0.0 1.32.0 1.31.0 1.30.0 1.29.0 1.28.0 1.27.0 1.26.0 1.25.0 trunk 1.0.0 1.0.1 1.0.2 1.1.0 1.10.0 All 48 releases
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.3.0, at includes/core/class-settings.php

965 lines 36.2 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 // Auto AI Optimization (on-publish metadata fill; #248 P1)
236 'auto_ai_meta_enabled' => false,
237 'auto_ai_meta_post_types' => ['post'],
238
239 // Brand Visibility v2. The brand profile drives question generation
240 // and mention detection; per-platform keys let this feature query
241 // several assistants without changing the site-wide AI provider.
242 'bv_brand_name' => '',
243 'bv_variants' => [],
244 'bv_location' => '',
245 'bv_category' => '',
246 'bv_description' => '',
247 'bv_competitors' => [],
248 'bv_queries' => [],
249 'bv_platforms' => ['chatgpt'],
250 'bv_samples' => 1,
251 'bv_key_chatgpt' => '',
252 'bv_key_gemini' => '',
253 'bv_key_claude' => '',
254 'bv_key_perplexity' => '',
255 // Empty = use the platform's default model (see Brand_Visibility_Providers).
256 'bv_model_chatgpt' => '',
257 'bv_model_gemini' => '',
258 'bv_model_claude' => '',
259 'bv_model_perplexity' => '',
260
261 // AI Brand Visibility (BYO-key checks; #248 P1)
262 'brand_visibility_queries' => [],
263
264 // Author Archives Settings
265 'author_archives_enabled' => true,
266 'author_archives_index' => true,
267 'author_archives_show_empty' => false,
268 'author_archives_title' => self::DEFAULT_AUTHOR_ARCHIVES_TITLE,
269 'author_archives_meta_desc' => self::DEFAULT_AUTHOR_ARCHIVES_META_DESC,
270
271 // UI Settings
272 'show_welcome_message' => true,
273 'dashboard_widgets' => ['seo_score', 'ai_usage', 'recent_briefs'],
274 'editor_panel_position' => 'side',
275
276 // Advanced Settings
277 'debug_mode' => false,
278 'retry_attempts' => 3,
279 // Exposes the standalone Migration admin page for re-running SEO data
280 // imports after setup. Hidden by default; opt-in for advanced/support use.
281 'enable_migration_tools' => false,
282 // Exposes ThinkRank's own export / restore. Off by default, like the
283 // migration toggle above: both are occasional, admin-only tools, and a
284 // menu item nobody asked for is a menu item in the way. Your data is
285 // never locked in — the switch is one click away in
286 // Settings > Import / Export, and turning it on immediately restores
287 // the screen and the menu item. Off hides the export card and, unless
288 // migration tools are on, the Import / Export menu item with it.
289 'enable_import_export' => false,
290
291 // Privacy Settings
292 'data_retention_days' => 90,
293 'anonymize_logs' => true,
294 'share_usage_data' => false,
295
296 // Integration Settings
297 'google_analytics_id' => '',
298 'search_console_property' => '',
299
300 // GA4 Tracking Settings
301 'ga4_measurement_id' => '',
302 'ga4_auto_inject' => false,
303 'ga4_anonymize_ip' => false,
304 'ga4_exclude_admin' => false,
305 'ga4_tracking_verified' => false,
306 'ga4_last_verification' => '',
307
308 // SEO Analytics Settings
309 'seo_analytics_enabled' => false,
310 'seo_analytics_setup_completed' => false,
311 'seo_analytics_google_analytics_property_id' => '',
312 'seo_analytics_enable_ai_insights' => true,
313 'seo_analytics_enable_automated_alerts' => false,
314 'seo_analytics_enable_predictive_analysis' => false,
315 'seo_analytics_monitoring_frequency' => 3600,
316 'seo_analytics_alert_thresholds' => [],
317 'seo_analytics_report_schedule' => 'weekly',
318 'seo_analytics_data_retention_days' => 90,
319 'seo_analytics_cache_analytics_data' => true,
320
321 // Uninstall Settings
322 'keep_data_on_uninstall' => true,
323
324 // MCP (Model Context Protocol) Settings
325 'enable_mcp' => false,
326 ];
327
328 /**
329 * Encrypted settings keys
330 *
331 * @var array
332 */
333 private array $encrypted_keys = [
334 // AI API Keys
335 'openai_api_key',
336 'claude_api_key',
337 'gemini_api_key',
338 'openrouter_api_key',
339 // Google API Keys
340 'google_analytics_api_key',
341 'google_search_console_api_key',
342 'google_pagespeed_api_key',
343 'google_access_token',
344 'google_refresh_token',
345 // Social Platform Verification Codes (sensitive)
346 'pinterest_site_verification',
347 'instagram_verification',
348 'tiktok_verification',
349 ];
350
351 /**
352 * Initialize settings
353 *
354 * @return void
355 */
356 public function init(): void {
357 add_action('admin_init', [$this, 'register_settings']);
358 // Admin-only: a front-end pageview can never need this migration, and
359 // the marker is a non-autoloaded option, so hooking it unconditionally
360 // bought one dedicated query on every request for the life of the
361 // install (#588).
362 if (is_admin()) {
363 add_action('init', [self::class, 'retire_seeded_ai_provider']);
364 }
365 }
366
367 /**
368 * Clear the OpenAI selection that older versions seeded on activation.
369 *
370 * Changing the default only helps installs created after the change. Every
371 * site activated before it still carries `ai_provider = 'openai'` written by
372 * Activator::set_default_options(), and still opens Settings warning about a
373 * missing key for a provider nobody picked — the whole complaint in #572.
374 *
375 * "OpenAI with no OpenAI key" is provably not a user's choice: the settings
376 * form refuses to save a provider without a key, so the only way to reach
377 * that state is the old activation seed. A site that genuinely chose OpenAI
378 * has a key and is left alone, as is any site on another provider.
379 *
380 * Version-gated so it runs once and never fights a user who later clears
381 * their key but keeps the provider selected.
382 *
383 * @since 2.1.3
384 *
385 * @return void
386 */
387 public static function retire_seeded_ai_provider(): void {
388 if (get_option(self::PROVIDER_MIGRATION_OPTION) === self::PROVIDER_MIGRATION_VERSION) {
389 self::promote_migration_marker_to_autoload();
390 return;
391 }
392
393 // Record first: a site that somehow fails the checks below must not
394 // re-test on every request for the rest of its life. Autoloaded on
395 // purpose — it is a short write-once flag that is read on every admin
396 // request, which is exactly what autoload is for; storing it
397 // non-autoloaded bought a dedicated query per request instead (#588).
398 update_option(self::PROVIDER_MIGRATION_OPTION, self::PROVIDER_MIGRATION_VERSION, true);
399
400 if (get_option('thinkrank_ai_provider', null) !== 'openai') {
401 return;
402 }
403
404 // Read the stored option directly rather than through Settings::get().
405 // Only emptiness matters here, and get() adds two things this decision
406 // must not depend on: a per-instance cache that may already be primed,
407 // and decryption that can yield '' for a key that is genuinely present.
408 // The raw option is non-empty whenever a key exists, encrypted or not.
409 if ('' !== (string) get_option('thinkrank_openai_api_key', '')) {
410 // A real choice, backed by a key. Leave it.
411 return;
412 }
413
414 // Write through the shared instance, not a throwaway one: set() refreshes
415 // only the cache of the object it is called on, so a private instance
416 // would leave the registered component serving the old value for the
417 // rest of this request — including to the admin page it localizes.
418 self::instance()->set('ai_provider', self::AI_PROVIDER_NONE);
419 }
420
421 /**
422 * Move a legacy migration marker into the autoloaded set.
423 *
424 * Sites that ran the migration on 2.1.3 wrote the marker with
425 * `autoload = false`, and the version gate above returns before the write
426 * that would correct it — so those installs keep paying a dedicated query
427 * to read a one-byte flag on every admin request, which is the cost #588
428 * was about. Promote it once.
429 *
430 * Free to test: alloptions is loaded from the object cache once per request
431 * regardless, and an autoloaded marker is in it, so the steady state after
432 * the promotion is a cache lookup and nothing else. wp_set_option_autoload()
433 * arrived in WP 6.4 and the plugin supports 6.0, hence the guard.
434 *
435 * @since 2.2.0
436 *
437 * @return void
438 */
439 private static function promote_migration_marker_to_autoload(): void {
440 if (!function_exists('wp_set_option_autoload') || !function_exists('wp_load_alloptions')) {
441 return;
442 }
443
444 if (array_key_exists(self::PROVIDER_MIGRATION_OPTION, wp_load_alloptions())) {
445 return;
446 }
447
448 wp_set_option_autoload(self::PROVIDER_MIGRATION_OPTION, true);
449 }
450
451 /**
452 * Register WordPress settings
453 *
454 * @return void
455 */
456 public function register_settings(): void {
457 register_setting('thinkrank_settings', 'thinkrank_settings', [
458 'sanitize_callback' => [$this, 'sanitize_settings'],
459 'default' => $this->defaults,
460 ]);
461 }
462
463 /**
464 * Warm the option cache for a batch of setting keys in one query.
465 *
466 * Every `thinkrank_*` option is autoload=off, so WordPress cannot serve
467 * them from `alloptions` and each get() below is its own round-trip. A
468 * caller that reads a known list of keys should prime it first: on a
469 * ~1ms managed-hosting round-trip, sixteen of those on an anonymous
470 * pageview is ~16ms spent on values the page may not use (#393).
471 *
472 * get() memoizes within the request, so only the first read of each key
473 * ever reaches the database — this is what collapses those first reads.
474 * Keys already memoized are left out of the batch.
475 *
476 * wp_prime_option_caches() is WP 6.4+; the plugin supports 6.0, so an
477 * older site simply keeps the previous behaviour.
478 *
479 * @since 2.1.0
480 *
481 * @param string[] $keys Setting keys, without the `thinkrank_` prefix.
482 * @return void
483 */
484 public function prime(array $keys): void {
485 if (!function_exists('wp_prime_option_caches')) {
486 return;
487 }
488
489 $unread = [];
490
491 foreach ($keys as $key) {
492 if (!isset($this->cache[$key])) {
493 $unread[] = 'thinkrank_' . $key;
494 }
495 }
496
497 if (!empty($unread)) {
498 wp_prime_option_caches($unread);
499 }
500 }
501
502 /**
503 * Get setting value
504 *
505 * @param string $key Setting key
506 * @param mixed $fallback Default value
507 * @param int $user_id User ID (0 for global, >0 for user-specific)
508 * @return mixed Setting value
509 */
510 public function get(string $key, $fallback = null, int $user_id = 0) {
511 // Check cache first
512 $cache_key = $user_id > 0 ? "user_{$user_id}_{$key}" : $key;
513
514 if (isset($this->cache[$cache_key])) {
515 return $this->maybe_decrypt($key, $this->cache[$cache_key]);
516 }
517
518 // Load from WordPress options or user meta
519 if ($user_id > 0) {
520 $value = get_user_meta($user_id, "thinkrank_{$key}", true);
521 } else {
522 $value = get_option("thinkrank_{$key}", null);
523 }
524
525
526
527 // Handle boolean settings with special logic for WordPress storage quirks
528 if (isset($this->defaults[$key]) && is_bool($this->defaults[$key])) {
529 // Convert string representations back to booleans
530 if ('1' === $value || 1 === $value || true === $value || 'true' === $value) {
531 $value = true;
532 } elseif ('0' === $value || 0 === $value || false === $value || 'false' === $value) {
533 $value = false;
534 } elseif (null === $value || '' === $value) {
535 // For boolean settings, empty string could mean false was stored
536 // null means option doesn't exist, empty string means it was stored as empty
537 if (null !== $value && $value === '') {
538 // Empty string exists in DB, this likely means false was stored
539 $value = false;
540 } else {
541 // Option doesn't exist, use default
542 $value = $fallback ?? $this->defaults[$key];
543 }
544 }
545 } else {
546 // Non-boolean settings: use default if not found.
547 //
548 // get_option() above is called with a null default, so null means
549 // "no row" while '' means a value was deliberately stored. For most
550 // keys we collapse the two — an empty string is treated as unset so
551 // the documented default applies. Keys in EMPTY_IS_A_VALUE opt out:
552 // there, clearing the field is a real choice the consumer honours,
553 // and folding it back into the default made the save look like it
554 // silently failed (#316).
555 $empty_is_unset = !in_array($key, self::EMPTY_IS_A_VALUE, true);
556
557 if (null === $value || ($empty_is_unset && '' === $value)) {
558 $value = $fallback ?? ($this->defaults[$key] ?? null);
559 }
560 }
561
562 // Cache the value
563 $this->cache[$cache_key] = $value;
564
565 return $this->maybe_decrypt($key, $value);
566 }
567
568 /**
569 * Set setting value
570 *
571 * @param string $key Setting key
572 * @param mixed $value Setting value
573 * @param int $user_id User ID (0 for global, >0 for user-specific)
574 * @return bool Success status
575 */
576 public function set(string $key, $value, int $user_id = 0): bool {
577 // Check if key exists in defaults
578 if (!array_key_exists($key, $this->defaults)) {
579 return false;
580 }
581
582 // Apply the declared per-key sanitizer before anything is stored. This
583 // is the path essentially every caller takes, so skipping it left the
584 // whole sanitize_setting() switch unreachable — max_tokens,
585 // cache_duration and temperature persisted whatever string arrived.
586 $value = $this->sanitize_setting($key, $value);
587
588 // Encrypt if needed
589 $encrypted_value = $this->maybe_encrypt($key, $value);
590
591 // Save to WordPress options or user meta
592 if ($user_id > 0) {
593 $result = update_user_meta($user_id, "thinkrank_{$key}", $encrypted_value);
594 } else {
595 $option_name = "thinkrank_{$key}";
596 $is_sensitive = in_array($key, $this->encrypted_keys, true);
597
598 // Determine if option exists
599 $existing = get_option($option_name, '__tr_not_set__');
600 if ($existing === '__tr_not_set__') {
601 // First-time save: set autoload=no for sensitive options
602 $autoload = $is_sensitive ? 'no' : 'yes';
603 $result = add_option($option_name, $encrypted_value, '', $autoload);
604 } else {
605 // Update existing option; enforce autoload=no for sensitive options
606 if ($is_sensitive) {
607 $result = update_option($option_name, $encrypted_value, 'no');
608 } else {
609 $result = update_option($option_name, $encrypted_value);
610 }
611 }
612
613 // Verify value actually saved (update_option returns false when unchanged)
614 $saved_value = get_option($option_name, 'NOT_FOUND');
615 // The loose branch is load-bearing: get_option() returns the stored
616 // STRING ('1', '0', '30') while $encrypted_value may be the original
617 // bool/int. On an unchanged re-save update_option() returns false, so
618 // this comparison is the only thing that marks the save successful —
619 // strict-only here made every unchanged-boolean re-save report failure.
620 $values_match = ($saved_value === $encrypted_value) ||
621 ($saved_value == $encrypted_value && $encrypted_value !== 'NOT_FOUND'); // phpcs:ignore Universal.Operators.StrictComparisons.LooseEqual -- intentional type-tolerant verify, see above.
622
623 // Consider it successful if the value was saved correctly
624 $result = $result || $values_match;
625 }
626
627 // Update cache
628 if ($result) {
629 $cache_key = $user_id > 0 ? "user_{$user_id}_{$key}" : $key;
630 $this->cache[$cache_key] = $encrypted_value;
631
632 // Invalidate bulk cache when individual setting changes
633 $bulk_cache_key = $user_id > 0 ? "bulk_user_{$user_id}" : 'bulk_global';
634 wp_cache_delete($bulk_cache_key, 'thinkrank_settings');
635 }
636
637 return $result !== false;
638 }
639
640 /**
641 * Delete setting
642 *
643 * @param string $key Setting key
644 * @param int $user_id User ID (0 for global, >0 for user-specific)
645 * @return bool Success status
646 */
647 public function delete(string $key, int $user_id = 0): bool {
648 // Remove from WordPress options or user meta
649 if ($user_id > 0) {
650 $result = delete_user_meta($user_id, "thinkrank_{$key}");
651 } else {
652 $result = delete_option("thinkrank_{$key}");
653 }
654
655 // Remove from cache
656 if ($result) {
657 $cache_key = $user_id > 0 ? "user_{$user_id}_{$key}" : $key;
658 unset($this->cache[$cache_key]);
659
660 // Also invalidate the bulk cache written by get_all(), or it serves
661 // stale values for up to 5 minutes after a delete.
662 $bulk_cache_key = $user_id > 0 ? "bulk_user_{$user_id}" : 'bulk_global';
663 wp_cache_delete($bulk_cache_key, 'thinkrank_settings');
664 }
665
666 return $result;
667 }
668
669 /**
670 * Setting keys whose values are encrypted at rest.
671 *
672 * Exposed so callers that must never emit a credential — the data exporter
673 * in particular — can filter against the same list this class encrypts
674 * with, instead of keeping a copy that silently drifts when a key is added.
675 *
676 * @since 2.2.0
677 *
678 * @return string[] Setting keys.
679 */
680 public function get_encrypted_keys(): array {
681 return $this->encrypted_keys;
682 }
683
684 /**
685 * Get all settings (optimized with bulk caching)
686 *
687 * @param int $user_id User ID (0 for global)
688 * @return array All settings
689 */
690 public function get_all(int $user_id = 0): array {
691 // Check bulk cache first
692 $bulk_cache_key = $user_id > 0 ? "bulk_user_{$user_id}" : 'bulk_global';
693
694 $cached_settings = wp_cache_get($bulk_cache_key, 'thinkrank_settings');
695 if ($cached_settings !== false) {
696 return $cached_settings;
697 }
698
699 $settings = [];
700
701 foreach (array_keys($this->defaults) as $key) {
702 $settings[$key] = $this->get($key, null, $user_id);
703 }
704
705 // Cache all settings for 5 minutes
706 wp_cache_set($bulk_cache_key, $settings, 'thinkrank_settings', 300);
707
708 return $settings;
709 }
710
711 /**
712 * Reset settings to defaults
713 *
714 * @param int $user_id User ID (0 for global)
715 * @return bool Success status
716 */
717 public function reset(int $user_id = 0): bool {
718 $success = true;
719
720 foreach (array_keys($this->defaults) as $key) {
721 if (!$this->delete($key, $user_id)) {
722 $success = false;
723 }
724 }
725
726 // Clear cache — both the instance array and the bulk object cache
727 // written by get_all(), which would otherwise return stale pre-reset
728 // values for up to 5 minutes.
729 $this->cache = [];
730 $bulk_cache_key = $user_id > 0 ? "bulk_user_{$user_id}" : 'bulk_global';
731 wp_cache_delete($bulk_cache_key, 'thinkrank_settings');
732
733 return $success;
734 }
735
736 /**
737 * Sanitize settings
738 *
739 * @param array $settings Settings array
740 * @return array Sanitized settings
741 */
742 public function sanitize_settings(array $settings): array {
743 $sanitized = [];
744
745 foreach ($settings as $key => $value) {
746 $sanitized[$key] = $this->sanitize_setting($key, $value);
747 }
748
749 return $sanitized;
750 }
751
752 /**
753 * Sanitize a nested array, preserving its shape.
754 *
755 * Scalars keep their type (an int threshold stays an int); strings are
756 * text-sanitized; objects are dropped, since no setting stores one.
757 *
758 * @since 2.2.0
759 * @param array $value Array to sanitize.
760 * @param int $depth Current recursion depth.
761 * @return array
762 */
763 private function sanitize_array_recursive(array $value, int $depth = 0): array {
764 // Settings are configuration, not arbitrary payloads; a cap keeps a
765 // malformed deep structure from recursing without bound.
766 if ($depth > 10) {
767 return [];
768 }
769
770 $sanitized = [];
771 foreach ($value as $item_key => $item) {
772 $key = is_string($item_key) ? sanitize_key($item_key) : $item_key;
773
774 if (is_array($item)) {
775 $sanitized[$key] = $this->sanitize_array_recursive($item, $depth + 1);
776 } elseif (is_object($item)) {
777 continue;
778 } elseif (is_bool($item) || is_int($item) || is_float($item)) {
779 $sanitized[$key] = $item;
780 } else {
781 $sanitized[$key] = sanitize_text_field((string) $item);
782 }
783 }
784
785 return $sanitized;
786 }
787
788 /**
789 * Sanitize individual setting
790 *
791 * @param string $key Setting key
792 * @param mixed $value Setting value
793 * @return mixed Sanitized value
794 */
795 private function sanitize_setting(string $key, $value) {
796 // Only keys that are declared array-typed may receive an array. For a
797 // scalar key an array/object value is malformed input, and the scalar
798 // sanitizers below would fatal on it, so fall back to the declared
799 // default instead of letting the bad value through.
800 if (is_array($value) || is_object($value)) {
801 if (!is_array($this->defaults[$key] ?? null)) {
802 return $this->defaults[$key] ?? '';
803 }
804 $value = (array) $value;
805 }
806
807 switch ($key) {
808 case 'openai_api_key':
809 case 'claude_api_key':
810 case 'gemini_api_key':
811 case 'openrouter_api_key':
812 return sanitize_text_field($value);
813
814 case 'ai_provider':
815 // sanitize_key() maps '' to '', which is AI_PROVIDER_NONE — the
816 // deliberate "no provider chosen" state, so it must survive here
817 // rather than being folded back into the default (#572).
818 $provider = sanitize_key($value);
819
820 return in_array($provider, self::selectable_ai_providers(), true)
821 ? $provider
822 : $this->defaults['ai_provider'];
823
824 case 'openai_model':
825 case 'claude_model':
826 case 'gemini_model':
827 case 'openrouter_model':
828 // Model ids may contain dots and slashes (e.g. "gpt-4.1" or
829 // "openai/gpt-4o-mini") and users can enter custom models, so
830 // sanitize_key() would corrupt them — use text-field sanitizing.
831 return sanitize_text_field($value);
832
833 case 'max_tokens':
834 case 'cache_duration':
835 case 'max_requests_per_minute':
836 case 'seo_score_threshold':
837 case 'api_timeout':
838 case 'retry_attempts':
839 case 'data_retention_days':
840 case 'monitoring_frequency':
841 return absint($value);
842
843 case 'temperature':
844 return (float) $value;
845
846 case 'dashboard_widgets':
847 return is_array($value) ? array_map('sanitize_key', $value) : [];
848
849 case 'seo_analytics_alert_thresholds':
850 // A threshold_name => value map where the values are numbers
851 // (e.g. traffic_drop_percentage => 20) as well as strings, so
852 // sanitize each value by its own type rather than forcing every
853 // one through sanitize_key() — that turned ints into strings.
854 if (!is_array($value)) {
855 return [];
856 }
857 $thresholds = [];
858 foreach ($value as $threshold_key => $threshold_value) {
859 if (is_array($threshold_value)) {
860 continue;
861 }
862 if (is_bool($threshold_value)) {
863 // Keep booleans as booleans. is_numeric() is false for
864 // one, so it used to fall to the string arm and a true
865 // came back as "1" — invisible while this ran only on
866 // the register_setting() path, now that set() routes
867 // every write through here it is a type change on save.
868 $thresholds[sanitize_key($threshold_key)] = $threshold_value;
869 continue;
870 }
871 $thresholds[sanitize_key($threshold_key)] = is_numeric($threshold_value)
872 ? $threshold_value + 0
873 : sanitize_text_field((string) $threshold_value);
874 }
875 return $thresholds;
876
877 case 'google_analytics_property_id':
878 case 'seo_analytics_google_analytics_property_id':
879 case 'seo_analytics_report_schedule':
880 return sanitize_text_field($value);
881
882 case 'robots_txt_content':
883 case 'bv_description':
884 // Multi-line content — sanitize_text_field() collapses newlines
885 // and would flatten the whole file onto a single line. set()
886 // routes every write through here, so a caller that chose
887 // sanitize_textarea_field() itself (Brand_Visibility_Endpoint
888 // does, for bv_description) is otherwise silently overridden
889 // by the default: arm below (#587).
890 return sanitize_textarea_field($value);
891
892 default:
893 if (is_bool($value)) {
894 return (bool) $value;
895 } elseif (is_string($value)) {
896 return sanitize_text_field($value);
897 } elseif (is_array($value)) {
898 // Recurse rather than drop. Skipping nested members was
899 // harmless while this ran only on the register_setting()
900 // path, but set() now routes every write through here and
901 // structured settings — bv_competitors is a list of
902 // ['name','url'] maps, bv_queries a list of ['text','type']
903 // — were being silently emptied on save.
904 return $this->sanitize_array_recursive($value);
905 }
906 return $value;
907 }
908 }
909
910 /**
911 * Marker prefix for values encrypted with the libsodium scheme below.
912 */
913 private const ENC_PREFIX = 'trenc:v1:';
914
915 /**
916 * Encrypt secret settings (API keys, OAuth tokens) at rest using libsodium
917 * authenticated encryption. Non-secret keys and environments without sodium
918 * fall back to plaintext so behaviour stays stable.
919 *
920 * @param string $key Setting key
921 * @param mixed $value Setting value
922 * @return mixed Encrypted payload (string) or original value
923 */
924 private function maybe_encrypt(string $key, $value) {
925 if (!in_array($key, $this->encrypted_keys, true) || !is_string($value) || '' === $value) {
926 return $value;
927 }
928
929 // Same scheme, same key, same prefix — it just lives in Secret_At_Rest
930 // now so the MCP pairing token can use it too (#396).
931 return Secret_At_Rest::encrypt($value);
932 }
933
934 /**
935 * Decrypt a value previously encrypted by maybe_encrypt. Values without the
936 * marker prefix are legacy plaintext and returned untouched (backward compat).
937 *
938 * @param string $key Setting key
939 * @param mixed $value Setting value
940 * @return mixed Decrypted or original value
941 */
942 private function maybe_decrypt(string $key, $value) {
943 if (!is_string($value) || strncmp($value, self::ENC_PREFIX, strlen(self::ENC_PREFIX)) !== 0) {
944 return $value; // legacy plaintext or non-string
945 }
946
947 $plain = Secret_At_Rest::decrypt($value);
948
949 if ('' === $plain) {
950 // We reach here only when sodium is available and a key was
951 // derivable, so this is a genuine failure: the auth salt changed
952 // (config rotated, or the site was migrated without wp-config) or
953 // the row is corrupt. The value is unrecoverable either way.
954 //
955 // Returning the ciphertext would send it upstream as a bearer
956 // token or API key, producing an opaque 401 far from the cause.
957 // Return empty so callers see "no credential" and can prompt for
958 // a reconnect instead.
959 return '';
960 }
961
962 return $plain;
963 }
964 }
965