PluginProbe
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO / 2.7.0
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO v2.7.0
2.7.0 2.6.0 2.5.0 2.4.0 2.3.0 2.2.0 2.1.1 2.1.0 2.0.2 2.0.1 2.0.0 1.32.0 1.31.0 1.30.0 1.29.0 1.28.0 1.27.0 1.26.0 1.25.0 trunk 1.0.0 1.0.1 1.0.2 1.1.0 1.10.0 All 48 releases
← All changes | includes/core/class-settings.php +528 -182 1.0.12.7.0 View file →
@@ -1,5 +1,6 @@
1 1 <?php
2 +
2 3 /**
3 4 * Settings Class
4 5 *
5 6 * Handles plugin settings and configuration using WordPress options
@@ -16,25 +17,157 @@
16 17 if (!defined('ABSPATH')) {
17 18 exit;
18 19 }
19 20
20 -/**
21 +/**
21 22 * Settings Class
22 23 *
23 24 * Single Responsibility: Manage plugin settings and configuration
24 - * Uses WordPress options for storage
25 + * Uses WordPress options for storage.
25 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 + *
26 31 * @since 1.0.0
27 32 */
28 33 class Settings {
29 -
34 +
30 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 + /**
31 164 * Settings cache
32 165 *
33 166 * @var array
34 167 */
35 168 private array $cache = [];
36 -
169 +
37 170 /**
38 171 * Default settings
39 172 *
40 173 * @var array
@@ -40,15 +173,17 @@
40 173 * @var array
41 174 */
42 175 private array $defaults = [
43 176 // AI Settings
44 - 'ai_provider' => 'openai',
177 + 'ai_provider' => self::AI_PROVIDER_NONE,
45 178 'openai_api_key' => '',
46 - 'openai_model' => 'gpt-5-nano', // Default to GPT‑5‑nano
179 + 'openai_model' => self::DEFAULT_OPENAI_MODEL,
47 180 'claude_api_key' => '',
48 - 'claude_model' => 'claude-3-7-sonnet-latest', // Use latest stable Claude
181 + 'claude_model' => self::DEFAULT_CLAUDE_MODEL, // Recommended default (best speed/quality balance)
49 182 'gemini_api_key' => '',
50 - 'gemini_model' => 'gemini-2.5-flash',
183 + 'gemini_model' => self::DEFAULT_GEMINI_MODEL,
184 + 'openrouter_api_key' => '',
185 + 'openrouter_model' => self::DEFAULT_OPENROUTER_MODEL,
51 186 'max_tokens' => 1000,
52 187 'temperature' => 0.7,
53 188
54 189 // Google API Keys (encrypted)
@@ -55,8 +190,21 @@
55 190 'google_analytics_api_key' => '',
56 191 'google_search_console_api_key' => '',
57 192 'google_pagespeed_api_key' => '',
58 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 +
59 207 // Performance Settings
60 208 'cache_duration' => 3600,
61 209 'max_requests_per_minute' => 10,
62 210 'enable_logging' => true,
@@ -82,40 +230,49 @@
82 230 'auto_optimize' => false,
83 231 'seo_score_threshold' => 70,
84 232 'enable_meta_generation' => true,
85 233 'enable_schema_markup' => true,
86 -
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 +
87 242 // UI Settings
88 243 'show_welcome_message' => true,
89 244 'dashboard_widgets' => ['seo_score', 'ai_usage', 'recent_briefs'],
90 245 'editor_panel_position' => 'side',
91 -
246 +
92 247 // Advanced Settings
93 248 'debug_mode' => false,
94 249 'retry_attempts' => 3,
95 -
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 +
96 262 // Privacy Settings
97 263 'data_retention_days' => 90,
98 264 'anonymize_logs' => true,
99 265 'share_usage_data' => false,
100 -
266 +
101 267 // Integration Settings
102 268 'google_analytics_id' => '',
103 269 'search_console_property' => '',
104 270
105 - // GA4 Tracking Settings
106 - 'ga4_measurement_id' => '',
107 - 'ga4_auto_inject' => false,
108 - 'ga4_anonymize_ip' => false,
109 - 'ga4_exclude_admin' => false,
110 - 'ga4_tracking_verified' => false,
111 - 'ga4_last_verification' => '',
112 -
113 271 // SEO Analytics Settings
114 272 'seo_analytics_enabled' => false,
115 273 'seo_analytics_setup_completed' => false,
116 274 'seo_analytics_google_analytics_property_id' => '',
117 - 'search_console_property' => '',
118 275 'seo_analytics_enable_ai_insights' => true,
119 276 'seo_analytics_enable_automated_alerts' => false,
120 277 'seo_analytics_enable_predictive_analysis' => false,
121 278 'seo_analytics_monitoring_frequency' => 3600,
@@ -122,13 +279,16 @@
122 279 'seo_analytics_alert_thresholds' => [],
123 280 'seo_analytics_report_schedule' => 'weekly',
124 281 'seo_analytics_data_retention_days' => 90,
125 282 'seo_analytics_cache_analytics_data' => true,
126 -
283 +
127 284 // Uninstall Settings
128 285 'keep_data_on_uninstall' => true,
286 +
287 + // MCP (Model Context Protocol) Settings
288 + 'enable_mcp' => false,
129 289 ];
130 -
290 +
131 291 /**
132 292 * Encrypted settings keys
133 293 *
134 294 * @var array
@@ -137,18 +297,21 @@
137 297 // AI API Keys
138 298 'openai_api_key',
139 299 'claude_api_key',
140 300 'gemini_api_key',
301 + 'openrouter_api_key',
141 302 // Google API Keys
142 303 'google_analytics_api_key',
143 304 'google_search_console_api_key',
144 305 'google_pagespeed_api_key',
306 + 'google_access_token',
307 + 'google_refresh_token',
145 308 // Social Platform Verification Codes (sensitive)
146 309 'pinterest_site_verification',
147 310 'instagram_verification',
148 311 'tiktok_verification',
149 312 ];
150 -
313 +
151 314 /**
152 315 * Initialize settings
153 316 *
154 317 * @return void
@@ -154,11 +317,102 @@
154 317 * @return void
155 318 */
156 319 public function init(): void {
157 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 + }
158 328 }
159 -
329 +
160 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 + /**
161 415 * Register WordPress settings
162 416 *
163 417 * @return void
164 418 */
@@ -167,25 +421,64 @@
167 421 'sanitize_callback' => [$this, 'sanitize_settings'],
168 422 'default' => $this->defaults,
169 423 ]);
170 424 }
171 -
425 +
172 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 + /**
173 466 * Get setting value
174 467 *
175 468 * @param string $key Setting key
176 - * @param mixed $default Default value
469 + * @param mixed $fallback Default value
177 470 * @param int $user_id User ID (0 for global, >0 for user-specific)
178 471 * @return mixed Setting value
179 472 */
180 - public function get(string $key, $default = null, int $user_id = 0) {
473 + public function get(string $key, $fallback = null, int $user_id = 0) {
181 474 // Check cache first
182 475 $cache_key = $user_id > 0 ? "user_{$user_id}_{$key}" : $key;
183 -
476 +
184 477 if (isset($this->cache[$cache_key])) {
185 478 return $this->maybe_decrypt($key, $this->cache[$cache_key]);
186 479 }
187 -
480 +
188 481 // Load from WordPress options or user meta
189 482 if ($user_id > 0) {
190 483 $value = get_user_meta($user_id, "thinkrank_{$key}", true);
191 484 } else {
@@ -192,9 +485,9 @@
192 485 $value = get_option("thinkrank_{$key}", null);
193 486 }
194 487
195 488
196 -
489 +
197 490 // Handle boolean settings with special logic for WordPress storage quirks
198 491 if (isset($this->defaults[$key]) && is_bool($this->defaults[$key])) {
199 492 // Convert string representations back to booleans
200 493 if ('1' === $value || 1 === $value || true === $value || 'true' === $value) {
@@ -202,31 +495,40 @@
202 495 } elseif ('0' === $value || 0 === $value || false === $value || 'false' === $value) {
203 496 $value = false;
204 497 } elseif (null === $value || '' === $value) {
205 498 // For boolean settings, empty string could mean false was stored
206 - // Check if this setting was explicitly set by looking for the option
207 - $option_exists = get_option("thinkrank_{$key}", 'OPTION_NOT_FOUND') !== 'OPTION_NOT_FOUND';
208 - if ($option_exists && $value === '') {
499 + // null means option doesn't exist, empty string means it was stored as empty
500 + if (null !== $value && $value === '') {
209 501 // Empty string exists in DB, this likely means false was stored
210 502 $value = false;
211 503 } else {
212 504 // Option doesn't exist, use default
213 - $value = $default ?? $this->defaults[$key];
505 + $value = $fallback ?? $this->defaults[$key];
214 506 }
215 507 }
216 508 } else {
217 - // Non-boolean settings: use default if not found
218 - if (null === $value || '' === $value) {
219 - $value = $default ?? ($this->defaults[$key] ?? null);
509 + // Non-boolean settings: use default if not found.
510 + //
511 + // get_option() above is called with a null default, so null means
512 + // "no row" while '' means a value was deliberately stored. For most
513 + // keys we collapse the two — an empty string is treated as unset so
514 + // the documented default applies. Keys in EMPTY_IS_A_VALUE opt out:
515 + // there, clearing the field is a real choice the consumer honours,
516 + // and folding it back into the default made the save look like it
517 + // silently failed (#316).
518 + $empty_is_unset = !in_array($key, self::EMPTY_IS_A_VALUE, true);
519 +
520 + if (null === $value || ($empty_is_unset && '' === $value)) {
521 + $value = $fallback ?? ($this->defaults[$key] ?? null);
220 522 }
221 523 }
222 -
524 +
223 525 // Cache the value
224 526 $this->cache[$cache_key] = $value;
225 -
527 +
226 528 return $this->maybe_decrypt($key, $value);
227 529 }
228 -
530 +
229 531 /**
230 532 * Set setting value
231 533 *
232 534 * @param string $key Setting key
@@ -239,10 +541,14 @@
239 541 if (!array_key_exists($key, $this->defaults)) {
240 542 return false;
241 543 }
242 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);
243 550
244 -
245 551 // Encrypt if needed
246 552 $encrypted_value = $this->maybe_encrypt($key, $value);
247 553
248 554 // Save to WordPress options or user meta
@@ -268,10 +574,15 @@
268 574 }
269 575
270 576 // Verify value actually saved (update_option returns false when unchanged)
271 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.
272 583 $values_match = ($saved_value === $encrypted_value) ||
273 - ($saved_value == $encrypted_value && $encrypted_value !== 'NOT_FOUND');
584 + ($saved_value == $encrypted_value && $encrypted_value !== 'NOT_FOUND'); // phpcs:ignore Universal.Operators.StrictComparisons.LooseEqual -- intentional type-tolerant verify, see above.
274 585
275 586 // Consider it successful if the value was saved correctly
276 587 $result = $result || $values_match;
277 588 }
@@ -287,9 +598,9 @@
287 598 }
288 599
289 600 return $result !== false;
290 601 }
291 -
602 +
292 603 /**
293 604 * Delete setting
294 605 *
295 606 * @param string $key Setting key
@@ -302,19 +613,39 @@
302 613 $result = delete_user_meta($user_id, "thinkrank_{$key}");
303 614 } else {
304 615 $result = delete_option("thinkrank_{$key}");
305 616 }
306 -
617 +
307 618 // Remove from cache
308 619 if ($result) {
309 620 $cache_key = $user_id > 0 ? "user_{$user_id}_{$key}" : $key;
310 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');
311 627 }
312 -
628 +
313 629 return $result;
314 630 }
315 -
631 +
316 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 + /**
317 648 * Get all settings (optimized with bulk caching)
318 649 *
319 650 * @param int $user_id User ID (0 for global)
320 651 * @return array All settings
@@ -338,9 +669,9 @@
338 669 wp_cache_set($bulk_cache_key, $settings, 'thinkrank_settings', 300);
339 670
340 671 return $settings;
341 672 }
342 -
673 +
343 674 /**
344 675 * Reset settings to defaults
345 676 *
346 677 * @param int $user_id User ID (0 for global)
@@ -347,21 +678,25 @@
347 678 * @return bool Success status
348 679 */
349 680 public function reset(int $user_id = 0): bool {
350 681 $success = true;
351 -
682 +
352 683 foreach (array_keys($this->defaults) as $key) {
353 684 if (!$this->delete($key, $user_id)) {
354 685 $success = false;
355 686 }
356 687 }
357 -
358 - // Clear cache
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.
359 692 $this->cache = [];
360 -
693 + $bulk_cache_key = $user_id > 0 ? "bulk_user_{$user_id}" : 'bulk_global';
694 + wp_cache_delete($bulk_cache_key, 'thinkrank_settings');
695 +
361 696 return $success;
362 697 }
363 -
698 +
364 699 /**
365 700 * Sanitize settings
366 701 *
367 702 * @param array $settings Settings array
@@ -368,17 +703,53 @@
368 703 * @return array Sanitized settings
369 704 */
370 705 public function sanitize_settings(array $settings): array {
371 706 $sanitized = [];
372 -
707 +
373 708 foreach ($settings as $key => $value) {
374 709 $sanitized[$key] = $this->sanitize_setting($key, $value);
375 710 }
376 -
711 +
377 712 return $sanitized;
378 713 }
379 -
714 +
380 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 + /**
381 752 * Sanitize individual setting
382 753 *
383 754 * @param string $key Setting key
384 755 * @param mixed $value Setting value
@@ -384,20 +755,45 @@
384 755 * @param mixed $value Setting value
385 756 * @return mixed Sanitized value
386 757 */
387 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 +
388 770 switch ($key) {
389 771 case 'openai_api_key':
390 772 case 'claude_api_key':
391 773 case 'gemini_api_key':
774 + case 'openrouter_api_key':
392 775 return sanitize_text_field($value);
393 776
394 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 +
395 787 case 'openai_model':
396 788 case 'claude_model':
397 789 case 'gemini_model':
398 - return sanitize_key($value);
399 -
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 +
400 796 case 'max_tokens':
401 797 case 'cache_duration':
402 798 case 'max_requests_per_minute':
403 799 case 'seo_score_threshold':
@@ -405,21 +801,56 @@
405 801 case 'retry_attempts':
406 802 case 'data_retention_days':
407 803 case 'monitoring_frequency':
408 804 return absint($value);
409 -
805 +
410 806 case 'temperature':
411 807 return (float) $value;
412 -
808 +
413 809 case 'dashboard_widgets':
810 + return is_array($value) ? array_map('sanitize_key', $value) : [];
811 +
414 812 case 'seo_analytics_alert_thresholds':
415 - return is_array($value) ? array_map('sanitize_key', $value) : [];
813 + // A threshold_name => value map where the values are numbers
814 + // (e.g. traffic_drop_percentage => 20) as well as strings, so
815 + // sanitize each value by its own type rather than forcing every
816 + // one through sanitize_key() — that turned ints into strings.
817 + if (!is_array($value)) {
818 + return [];
819 + }
820 + $thresholds = [];
821 + foreach ($value as $threshold_key => $threshold_value) {
822 + if (is_array($threshold_value)) {
823 + continue;
824 + }
825 + if (is_bool($threshold_value)) {
826 + // Keep booleans as booleans. is_numeric() is false for
827 + // one, so it used to fall to the string arm and a true
828 + // came back as "1" — invisible while this ran only on
829 + // the register_setting() path, now that set() routes
830 + // every write through here it is a type change on save.
831 + $thresholds[sanitize_key($threshold_key)] = $threshold_value;
832 + continue;
833 + }
834 + $thresholds[sanitize_key($threshold_key)] = is_numeric($threshold_value)
835 + ? $threshold_value + 0
836 + : sanitize_text_field((string) $threshold_value);
837 + }
838 + return $thresholds;
416 839
417 840 case 'google_analytics_property_id':
418 841 case 'seo_analytics_google_analytics_property_id':
419 842 case 'seo_analytics_report_schedule':
420 843 return sanitize_text_field($value);
421 -
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 +
422 853 default:
423 854 if (is_bool($value)) {
424 855 return (bool) $value;
425 856 } elseif (is_string($value)) {
@@ -424,9 +855,14 @@
424 855 return (bool) $value;
425 856 } elseif (is_string($value)) {
426 857 return sanitize_text_field($value);
427 858 } elseif (is_array($value)) {
428 - return array_map('sanitize_text_field', $value);
859 + // Recurse rather than drop. Skipping nested members was
860 + // harmless while this ran only on the register_setting()
861 + // path, but set() now routes every write through here, and
862 + // structured settings (lists of maps) were being silently
863 + // emptied on save.
864 + return $this->sanitize_array_recursive($value);
429 865 }
430 866 return $value;
431 867 }
432 868 }
@@ -431,26 +867,34 @@
431 867 }
432 868 }
433 869
434 870 /**
435 - * Encryption version for tracking
871 + * Marker prefix for values encrypted with the libsodium scheme below.
436 872 */
437 - private const ENCRYPTION_VERSION = 2;
873 + private const ENC_PREFIX = 'trenc:v1:';
438 874
439 875 /**
440 - * Maybe encrypt value using WordPress native encryption
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.
441 879 *
442 880 * @param string $key Setting key
443 881 * @param mixed $value Setting value
444 - * @return mixed Encrypted or original value
882 + * @return mixed Encrypted payload (string) or original value
445 883 */
446 884 private function maybe_encrypt(string $key, $value) {
447 - // Critical fix: disable custom encryption; store raw value without transformation
448 - return $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);
449 892 }
450 893
451 894 /**
452 - * Maybe decrypt value using WordPress native encryption
895 + * Decrypt a value previously encrypted by maybe_encrypt. Values without the
896 + * marker prefix are legacy plaintext and returned untouched (backward compat).
453 897 *
454 898 * @param string $key Setting key
455 899 * @param mixed $value Setting value
456 900 * @return mixed Decrypted or original value
@@ -455,124 +899,26 @@
455 899 * @param mixed $value Setting value
456 900 * @return mixed Decrypted or original value
457 901 */
458 902 private function maybe_decrypt(string $key, $value) {
459 - if (!in_array($key, $this->encrypted_keys, true) || empty($value)) {
460 - return $value;
903 + if (!is_string($value) || strncmp($value, self::ENC_PREFIX, strlen(self::ENC_PREFIX)) !== 0) {
904 + return $value; // legacy plaintext or non-string
461 905 }
462 906
463 - // Backward compatibility: if value looks like our old encrypted payload, try to decrypt
464 - $data = base64_decode((string) $value, true);
465 - if ($data !== false && strlen($data) >= 33) {
466 - $version = unpack('C', substr($data, 0, 1))[1] ?? null;
467 - if ($version === self::ENCRYPTION_VERSION) {
468 - $decrypted = $this->decrypt_value((string) $value);
469 - // If decryption worked, return it; otherwise fall through and return original
470 - if ($decrypted !== '') {
471 - return $decrypted;
472 - }
473 - }
474 - }
907 + $plain = Secret_At_Rest::decrypt($value);
475 908
476 - // Treat as already plain
477 - return $value;
478 - }
479 -
480 - /**
481 - * Encrypt value using WordPress native methods
482 - *
483 - * @param string $value Value to encrypt
484 - * @return string Encrypted value (base64 encoded) or empty string on failure
485 - * @throws \Exception When encryption process fails
486 - */
487 - private function encrypt_value(string $value): string {
488 - try {
489 - // Generate unique salt for this encryption
490 - $salt = wp_generate_password(32, false);
491 -
492 - // Create encryption key using WordPress salts
493 - $auth_salt = wp_salt('auth');
494 - $secure_salt = wp_salt('secure_auth');
495 - $key = wp_hash($salt . $auth_salt . $secure_salt);
496 -
497 - // XOR encryption
498 - $encrypted = $this->xor_encrypt($value, $key);
499 -
500 - // Version prefix + salt + encrypted data
501 - $version = pack('C', self::ENCRYPTION_VERSION);
502 - return base64_encode($version . $salt . $encrypted);
503 -
504 - } catch (\Exception $e) {
505 - // Silent failure on encryption error
506 - return ''; // Return empty on encryption failure
507 - }
508 - }
509 -
510 - /**
511 - * Decrypt value using WordPress native methods
512 - *
513 - * @param string $encrypted_value Encrypted value to decrypt
514 - * @return string Decrypted value or empty string on failure
515 - * @throws \Exception When decryption process fails
516 - */
517 - private function decrypt_value(string $encrypted_value): string {
518 - try {
519 - $data = base64_decode($encrypted_value, true);
520 - if (false === $data || strlen($data) < 33) {
521 - return '';
522 - }
523 -
524 - // Extract version, salt, and encrypted data
525 - $version = unpack('C', substr($data, 0, 1))[1];
526 - $salt = substr($data, 1, 32);
527 - $encrypted = substr($data, 33);
528 -
529 - // Verify version
530 - if ($version !== self::ENCRYPTION_VERSION) {
531 - return '';
532 - }
533 -
534 - // Recreate encryption key
535 - $auth_salt = wp_salt('auth');
536 - $secure_salt = wp_salt('secure_auth');
537 - $key = wp_hash($salt . $auth_salt . $secure_salt);
538 -
539 - // Decrypt using XOR
540 - return $this->xor_decrypt($encrypted, $key);
541 -
542 - } catch (\Exception $e) {
543 - // Silent failure on decryption error
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.
544 919 return '';
545 920 }
546 - }
547 921
548 - /**
549 - * XOR encryption helper
550 - *
551 - * @param string $data Data to encrypt
552 - * @param string $key Encryption key
553 - * @return string Encrypted data
554 - */
555 - private function xor_encrypt(string $data, string $key): string {
556 - $result = '';
557 - $key_length = strlen($key);
558 - $data_length = strlen($data);
559 -
560 - for ($i = 0; $i < $data_length; $i++) {
561 - $result .= chr(ord($data[$i]) ^ ord($key[$i % $key_length]));
562 - }
563 -
564 - return $result;
565 - }
566 -
567 - /**
568 - * XOR decryption helper (XOR is symmetric)
569 - *
570 - * @param string $encrypted Encrypted data
571 - * @param string $key Encryption key
572 - * @return string Decrypted data
573 - */
574 - private function xor_decrypt(string $encrypted, string $key): string {
575 - // XOR is symmetric, so decryption is the same as encryption
576 - return $this->xor_encrypt($encrypted, $key);
922 + return $plain;
577 923 }
578 924 }