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

755 lines 27.0 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 const DEFAULT_AUTHOR_ARCHIVES_TITLE = '%author_name% %separator% %site_title% %page%';
63 const DEFAULT_AUTHOR_ARCHIVES_META_DESC = 'Articles written by %author_name% on %site_title%';
64
65 /**
66 * String settings where a stored empty string is a real value, not "unset".
67 *
68 * get() normally treats '' the same as a missing option and returns the
69 * default, which is right for most keys — a blank API key or model name is
70 * never what the user meant. For these template fields it is the opposite:
71 * clearing the box means "render no template", and the consumers already
72 * branch on an empty value. Without the opt-out the save appeared to
73 * succeed and the default reappeared on the next request (#316).
74 *
75 * @since 1.29.1
76 * @var string[]
77 */
78 private const EMPTY_IS_A_VALUE = [
79 'author_archives_title',
80 'author_archives_meta_desc',
81 ];
82
83 /**
84 * Shared singleton instance
85 *
86 * @var Settings|null
87 */
88 private static ?Settings $instance = null;
89
90 /**
91 * Get the shared Settings instance
92 *
93 * Returns the instance registered in the main plugin's component
94 * container, or creates a standalone instance if the plugin hasn't
95 * loaded yet (e.g., during activation).
96 *
97 * @since 1.10.0
98 * @return Settings
99 */
100 public static function instance(): Settings {
101 if (self::$instance !== null) {
102 return self::$instance;
103 }
104
105 // Try to get from the main plugin container
106 if (function_exists('thinkrank')) {
107 $plugin = thinkrank();
108 $component = $plugin->get_component('settings');
109 if ($component instanceof self) {
110 self::$instance = $component;
111 return self::$instance;
112 }
113 }
114
115 // Fallback: create standalone instance (during activation, etc.)
116 self::$instance = new self();
117 return self::$instance;
118 }
119
120 /**
121 * Settings cache
122 *
123 * @var array
124 */
125 private array $cache = [];
126
127 /**
128 * Default settings
129 *
130 * @var array
131 */
132 private array $defaults = [
133 // AI Settings
134 'ai_provider' => 'openai',
135 'openai_api_key' => '',
136 'openai_model' => self::DEFAULT_OPENAI_MODEL,
137 'claude_api_key' => '',
138 'claude_model' => self::DEFAULT_CLAUDE_MODEL, // Recommended default (best speed/quality balance)
139 'gemini_api_key' => '',
140 'gemini_model' => self::DEFAULT_GEMINI_MODEL,
141 'openrouter_api_key' => '',
142 'openrouter_model' => self::DEFAULT_OPENROUTER_MODEL,
143 'max_tokens' => 1000,
144 'temperature' => 0.7,
145
146 // Google API Keys (encrypted)
147 'google_analytics_api_key' => '',
148 'google_search_console_api_key' => '',
149 'google_pagespeed_api_key' => '',
150
151 // Google OAuth Tokens (encrypted)
152 'google_access_token' => '',
153 'google_refresh_token' => '',
154 'google_token_expires_in' => 0,
155 'google_token_created' => 0,
156 'google_account_connected' => false,
157
158 // Google Analytics Pro Settings. Read/written by thinkrank-pro's
159 // GoogleAnalyticsSettings.js through the settings-management endpoint —
160 // no consumer exists in THIS repo, so don't dead-key these.
161 'ga_analytics_account_id' => '',
162 'ga_analytics_data_stream_id' => '',
163
164 // Performance Settings
165 'cache_duration' => 3600,
166 'max_requests_per_minute' => 10,
167 'enable_logging' => true,
168
169 // Integration Settings
170 'api_timeout' => 30,
171 'enable_rate_limiting' => true,
172 'auto_test_connections' => true,
173 'retry_failed_requests' => true,
174
175 // Social Platform Settings
176 // Public IDs (not encrypted)
177 'facebook_app_id' => '',
178 'facebook_admins' => '',
179 'youtube_channel_id' => '',
180 'whatsapp_business_id' => '',
181 // Verification codes (encrypted)
182 'pinterest_site_verification' => '',
183 'instagram_verification' => '',
184 'tiktok_verification' => '',
185
186 // SEO Settings
187 'auto_optimize' => false,
188 'seo_score_threshold' => 70,
189 'enable_meta_generation' => true,
190 'enable_schema_markup' => true,
191
192 // Auto AI Optimization (on-publish metadata fill; #248 P1)
193 'auto_ai_meta_enabled' => false,
194 'auto_ai_meta_post_types' => ['post'],
195
196 // Brand Visibility v2. The brand profile drives question generation
197 // and mention detection; per-platform keys let this feature query
198 // several assistants without changing the site-wide AI provider.
199 'bv_brand_name' => '',
200 'bv_variants' => [],
201 'bv_location' => '',
202 'bv_category' => '',
203 'bv_description' => '',
204 'bv_competitors' => [],
205 'bv_queries' => [],
206 'bv_platforms' => ['chatgpt'],
207 'bv_samples' => 1,
208 'bv_key_chatgpt' => '',
209 'bv_key_gemini' => '',
210 'bv_key_claude' => '',
211 'bv_key_perplexity' => '',
212 // Empty = use the platform's default model (see Brand_Visibility_Providers).
213 'bv_model_chatgpt' => '',
214 'bv_model_gemini' => '',
215 'bv_model_claude' => '',
216 'bv_model_perplexity' => '',
217
218 // AI Brand Visibility (BYO-key checks; #248 P1)
219 'brand_visibility_queries' => [],
220
221 // Author Archives Settings
222 'author_archives_enabled' => true,
223 'author_archives_index' => true,
224 'author_archives_show_empty' => false,
225 'author_archives_title' => self::DEFAULT_AUTHOR_ARCHIVES_TITLE,
226 'author_archives_meta_desc' => self::DEFAULT_AUTHOR_ARCHIVES_META_DESC,
227
228 // UI Settings
229 'show_welcome_message' => true,
230 'dashboard_widgets' => ['seo_score', 'ai_usage', 'recent_briefs'],
231 'editor_panel_position' => 'side',
232
233 // Advanced Settings
234 'debug_mode' => false,
235 'retry_attempts' => 3,
236 // Exposes the standalone Migration admin page for re-running SEO data
237 // imports after setup. Hidden by default; opt-in for advanced/support use.
238 'enable_migration_tools' => false,
239
240 // Privacy Settings
241 'data_retention_days' => 90,
242 'anonymize_logs' => true,
243 'share_usage_data' => false,
244
245 // Integration Settings
246 'google_analytics_id' => '',
247 'search_console_property' => '',
248
249 // GA4 Tracking Settings
250 'ga4_measurement_id' => '',
251 'ga4_auto_inject' => false,
252 'ga4_anonymize_ip' => false,
253 'ga4_exclude_admin' => false,
254 'ga4_tracking_verified' => false,
255 'ga4_last_verification' => '',
256
257 // SEO Analytics Settings
258 'seo_analytics_enabled' => false,
259 'seo_analytics_setup_completed' => false,
260 'seo_analytics_google_analytics_property_id' => '',
261 'seo_analytics_enable_ai_insights' => true,
262 'seo_analytics_enable_automated_alerts' => false,
263 'seo_analytics_enable_predictive_analysis' => false,
264 'seo_analytics_monitoring_frequency' => 3600,
265 'seo_analytics_alert_thresholds' => [],
266 'seo_analytics_report_schedule' => 'weekly',
267 'seo_analytics_data_retention_days' => 90,
268 'seo_analytics_cache_analytics_data' => true,
269
270 // Uninstall Settings
271 'keep_data_on_uninstall' => true,
272
273 // MCP (Model Context Protocol) Settings
274 'enable_mcp' => false,
275 ];
276
277 /**
278 * Encrypted settings keys
279 *
280 * @var array
281 */
282 private array $encrypted_keys = [
283 // AI API Keys
284 'openai_api_key',
285 'claude_api_key',
286 'gemini_api_key',
287 'openrouter_api_key',
288 // Google API Keys
289 'google_analytics_api_key',
290 'google_search_console_api_key',
291 'google_pagespeed_api_key',
292 'google_access_token',
293 'google_refresh_token',
294 // Social Platform Verification Codes (sensitive)
295 'pinterest_site_verification',
296 'instagram_verification',
297 'tiktok_verification',
298 ];
299
300 /**
301 * Initialize settings
302 *
303 * @return void
304 */
305 public function init(): void {
306 add_action('admin_init', [$this, 'register_settings']);
307 }
308
309 /**
310 * Register WordPress settings
311 *
312 * @return void
313 */
314 public function register_settings(): void {
315 register_setting('thinkrank_settings', 'thinkrank_settings', [
316 'sanitize_callback' => [$this, 'sanitize_settings'],
317 'default' => $this->defaults,
318 ]);
319 }
320
321 /**
322 * Get setting value
323 *
324 * @param string $key Setting key
325 * @param mixed $fallback Default value
326 * @param int $user_id User ID (0 for global, >0 for user-specific)
327 * @return mixed Setting value
328 */
329 public function get(string $key, $fallback = null, int $user_id = 0) {
330 // Check cache first
331 $cache_key = $user_id > 0 ? "user_{$user_id}_{$key}" : $key;
332
333 if (isset($this->cache[$cache_key])) {
334 return $this->maybe_decrypt($key, $this->cache[$cache_key]);
335 }
336
337 // Load from WordPress options or user meta
338 if ($user_id > 0) {
339 $value = get_user_meta($user_id, "thinkrank_{$key}", true);
340 } else {
341 $value = get_option("thinkrank_{$key}", null);
342 }
343
344
345
346 // Handle boolean settings with special logic for WordPress storage quirks
347 if (isset($this->defaults[$key]) && is_bool($this->defaults[$key])) {
348 // Convert string representations back to booleans
349 if ('1' === $value || 1 === $value || true === $value || 'true' === $value) {
350 $value = true;
351 } elseif ('0' === $value || 0 === $value || false === $value || 'false' === $value) {
352 $value = false;
353 } elseif (null === $value || '' === $value) {
354 // For boolean settings, empty string could mean false was stored
355 // null means option doesn't exist, empty string means it was stored as empty
356 if (null !== $value && $value === '') {
357 // Empty string exists in DB, this likely means false was stored
358 $value = false;
359 } else {
360 // Option doesn't exist, use default
361 $value = $fallback ?? $this->defaults[$key];
362 }
363 }
364 } else {
365 // Non-boolean settings: use default if not found.
366 //
367 // get_option() above is called with a null default, so null means
368 // "no row" while '' means a value was deliberately stored. For most
369 // keys we collapse the two — an empty string is treated as unset so
370 // the documented default applies. Keys in EMPTY_IS_A_VALUE opt out:
371 // there, clearing the field is a real choice the consumer honours,
372 // and folding it back into the default made the save look like it
373 // silently failed (#316).
374 $empty_is_unset = !in_array($key, self::EMPTY_IS_A_VALUE, true);
375
376 if (null === $value || ($empty_is_unset && '' === $value)) {
377 $value = $fallback ?? ($this->defaults[$key] ?? null);
378 }
379 }
380
381 // Cache the value
382 $this->cache[$cache_key] = $value;
383
384 return $this->maybe_decrypt($key, $value);
385 }
386
387 /**
388 * Set setting value
389 *
390 * @param string $key Setting key
391 * @param mixed $value Setting value
392 * @param int $user_id User ID (0 for global, >0 for user-specific)
393 * @return bool Success status
394 */
395 public function set(string $key, $value, int $user_id = 0): bool {
396 // Check if key exists in defaults
397 if (!array_key_exists($key, $this->defaults)) {
398 return false;
399 }
400
401
402
403 // Encrypt if needed
404 $encrypted_value = $this->maybe_encrypt($key, $value);
405
406 // Save to WordPress options or user meta
407 if ($user_id > 0) {
408 $result = update_user_meta($user_id, "thinkrank_{$key}", $encrypted_value);
409 } else {
410 $option_name = "thinkrank_{$key}";
411 $is_sensitive = in_array($key, $this->encrypted_keys, true);
412
413 // Determine if option exists
414 $existing = get_option($option_name, '__tr_not_set__');
415 if ($existing === '__tr_not_set__') {
416 // First-time save: set autoload=no for sensitive options
417 $autoload = $is_sensitive ? 'no' : 'yes';
418 $result = add_option($option_name, $encrypted_value, '', $autoload);
419 } else {
420 // Update existing option; enforce autoload=no for sensitive options
421 if ($is_sensitive) {
422 $result = update_option($option_name, $encrypted_value, 'no');
423 } else {
424 $result = update_option($option_name, $encrypted_value);
425 }
426 }
427
428 // Verify value actually saved (update_option returns false when unchanged)
429 $saved_value = get_option($option_name, 'NOT_FOUND');
430 // The loose branch is load-bearing: get_option() returns the stored
431 // STRING ('1', '0', '30') while $encrypted_value may be the original
432 // bool/int. On an unchanged re-save update_option() returns false, so
433 // this comparison is the only thing that marks the save successful —
434 // strict-only here made every unchanged-boolean re-save report failure.
435 $values_match = ($saved_value === $encrypted_value) ||
436 ($saved_value == $encrypted_value && $encrypted_value !== 'NOT_FOUND'); // phpcs:ignore Universal.Operators.StrictComparisons.LooseEqual -- intentional type-tolerant verify, see above.
437
438 // Consider it successful if the value was saved correctly
439 $result = $result || $values_match;
440 }
441
442 // Update cache
443 if ($result) {
444 $cache_key = $user_id > 0 ? "user_{$user_id}_{$key}" : $key;
445 $this->cache[$cache_key] = $encrypted_value;
446
447 // Invalidate bulk cache when individual setting changes
448 $bulk_cache_key = $user_id > 0 ? "bulk_user_{$user_id}" : 'bulk_global';
449 wp_cache_delete($bulk_cache_key, 'thinkrank_settings');
450 }
451
452 return $result !== false;
453 }
454
455 /**
456 * Delete setting
457 *
458 * @param string $key Setting key
459 * @param int $user_id User ID (0 for global, >0 for user-specific)
460 * @return bool Success status
461 */
462 public function delete(string $key, int $user_id = 0): bool {
463 // Remove from WordPress options or user meta
464 if ($user_id > 0) {
465 $result = delete_user_meta($user_id, "thinkrank_{$key}");
466 } else {
467 $result = delete_option("thinkrank_{$key}");
468 }
469
470 // Remove from cache
471 if ($result) {
472 $cache_key = $user_id > 0 ? "user_{$user_id}_{$key}" : $key;
473 unset($this->cache[$cache_key]);
474
475 // Also invalidate the bulk cache written by get_all(), or it serves
476 // stale values for up to 5 minutes after a delete.
477 $bulk_cache_key = $user_id > 0 ? "bulk_user_{$user_id}" : 'bulk_global';
478 wp_cache_delete($bulk_cache_key, 'thinkrank_settings');
479 }
480
481 return $result;
482 }
483
484 /**
485 * Get all settings (optimized with bulk caching)
486 *
487 * @param int $user_id User ID (0 for global)
488 * @return array All settings
489 */
490 public function get_all(int $user_id = 0): array {
491 // Check bulk cache first
492 $bulk_cache_key = $user_id > 0 ? "bulk_user_{$user_id}" : 'bulk_global';
493
494 $cached_settings = wp_cache_get($bulk_cache_key, 'thinkrank_settings');
495 if ($cached_settings !== false) {
496 return $cached_settings;
497 }
498
499 $settings = [];
500
501 foreach (array_keys($this->defaults) as $key) {
502 $settings[$key] = $this->get($key, null, $user_id);
503 }
504
505 // Cache all settings for 5 minutes
506 wp_cache_set($bulk_cache_key, $settings, 'thinkrank_settings', 300);
507
508 return $settings;
509 }
510
511 /**
512 * Reset settings to defaults
513 *
514 * @param int $user_id User ID (0 for global)
515 * @return bool Success status
516 */
517 public function reset(int $user_id = 0): bool {
518 $success = true;
519
520 foreach (array_keys($this->defaults) as $key) {
521 if (!$this->delete($key, $user_id)) {
522 $success = false;
523 }
524 }
525
526 // Clear cache — both the instance array and the bulk object cache
527 // written by get_all(), which would otherwise return stale pre-reset
528 // values for up to 5 minutes.
529 $this->cache = [];
530 $bulk_cache_key = $user_id > 0 ? "bulk_user_{$user_id}" : 'bulk_global';
531 wp_cache_delete($bulk_cache_key, 'thinkrank_settings');
532
533 return $success;
534 }
535
536 /**
537 * Sanitize settings
538 *
539 * @param array $settings Settings array
540 * @return array Sanitized settings
541 */
542 public function sanitize_settings(array $settings): array {
543 $sanitized = [];
544
545 foreach ($settings as $key => $value) {
546 $sanitized[$key] = $this->sanitize_setting($key, $value);
547 }
548
549 return $sanitized;
550 }
551
552 /**
553 * Sanitize individual setting
554 *
555 * @param string $key Setting key
556 * @param mixed $value Setting value
557 * @return mixed Sanitized value
558 */
559 private function sanitize_setting(string $key, $value) {
560 // Only keys that are declared array-typed may receive an array. For a
561 // scalar key an array/object value is malformed input, and the scalar
562 // sanitizers below would fatal on it, so fall back to the declared
563 // default instead of letting the bad value through.
564 if (is_array($value) || is_object($value)) {
565 if (!is_array($this->defaults[$key] ?? null)) {
566 return $this->defaults[$key] ?? '';
567 }
568 $value = (array) $value;
569 }
570
571 switch ($key) {
572 case 'openai_api_key':
573 case 'claude_api_key':
574 case 'gemini_api_key':
575 case 'openrouter_api_key':
576 return sanitize_text_field($value);
577
578 case 'ai_provider':
579 return sanitize_key($value);
580
581 case 'openai_model':
582 case 'claude_model':
583 case 'gemini_model':
584 case 'openrouter_model':
585 // Model ids may contain dots and slashes (e.g. "gpt-4.1" or
586 // "openai/gpt-4o-mini") and users can enter custom models, so
587 // sanitize_key() would corrupt them — use text-field sanitizing.
588 return sanitize_text_field($value);
589
590 case 'max_tokens':
591 case 'cache_duration':
592 case 'max_requests_per_minute':
593 case 'seo_score_threshold':
594 case 'api_timeout':
595 case 'retry_attempts':
596 case 'data_retention_days':
597 case 'monitoring_frequency':
598 return absint($value);
599
600 case 'temperature':
601 return (float) $value;
602
603 case 'dashboard_widgets':
604 return is_array($value) ? array_map('sanitize_key', $value) : [];
605
606 case 'seo_analytics_alert_thresholds':
607 // A threshold_name => value map where the values are numbers
608 // (e.g. traffic_drop_percentage => 20) as well as strings, so
609 // sanitize each value by its own type rather than forcing every
610 // one through sanitize_key() — that turned ints into strings.
611 if (!is_array($value)) {
612 return [];
613 }
614 $thresholds = [];
615 foreach ($value as $threshold_key => $threshold_value) {
616 if (is_array($threshold_value)) {
617 continue;
618 }
619 $thresholds[sanitize_key($threshold_key)] = is_numeric($threshold_value)
620 ? $threshold_value + 0
621 : sanitize_text_field((string) $threshold_value);
622 }
623 return $thresholds;
624
625 case 'google_analytics_property_id':
626 case 'seo_analytics_google_analytics_property_id':
627 case 'seo_analytics_report_schedule':
628 return sanitize_text_field($value);
629
630 case 'robots_txt_content':
631 // Multi-line content — sanitize_text_field() collapses newlines
632 // and would flatten the whole file onto a single line.
633 return sanitize_textarea_field($value);
634
635 default:
636 if (is_bool($value)) {
637 return (bool) $value;
638 } elseif (is_string($value)) {
639 return sanitize_text_field($value);
640 } elseif (is_array($value)) {
641 // Flat list/map of scalars; nested members are dropped rather
642 // than passed to a string sanitizer that would fatal on them.
643 $sanitized = [];
644 foreach ($value as $item_key => $item) {
645 if (is_array($item) || is_object($item)) {
646 continue;
647 }
648 $sanitized[is_string($item_key) ? sanitize_key($item_key) : $item_key] =
649 sanitize_text_field((string) $item);
650 }
651 return $sanitized;
652 }
653 return $value;
654 }
655 }
656
657 /**
658 * Marker prefix for values encrypted with the libsodium scheme below.
659 */
660 private const ENC_PREFIX = 'trenc:v1:';
661
662 /**
663 * Encrypt secret settings (API keys, OAuth tokens) at rest using libsodium
664 * authenticated encryption. Non-secret keys and environments without sodium
665 * fall back to plaintext so behaviour stays stable.
666 *
667 * @param string $key Setting key
668 * @param mixed $value Setting value
669 * @return mixed Encrypted payload (string) or original value
670 */
671 private function maybe_encrypt(string $key, $value) {
672 if (!in_array($key, $this->encrypted_keys, true) || !is_string($value) || '' === $value) {
673 return $value;
674 }
675 if (!function_exists('sodium_crypto_secretbox')) {
676 return $value; // sodium unavailable — store as-is
677 }
678
679 $enc_key = $this->encryption_key();
680 if ('' === $enc_key) {
681 return $value;
682 }
683
684 try {
685 $nonce = random_bytes(SODIUM_CRYPTO_SECRETBOX_NONCEBYTES);
686 $cipher = sodium_crypto_secretbox($value, $nonce, $enc_key);
687 // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_encode -- transport encoding for a sodium ciphertext, not obfuscation.
688 return self::ENC_PREFIX . base64_encode($nonce . $cipher);
689 } catch (\Exception $e) {
690 return $value;
691 }
692 }
693
694 /**
695 * Decrypt a value previously encrypted by maybe_encrypt. Values without the
696 * marker prefix are legacy plaintext and returned untouched (backward compat).
697 *
698 * @param string $key Setting key
699 * @param mixed $value Setting value
700 * @return mixed Decrypted or original value
701 */
702 private function maybe_decrypt(string $key, $value) {
703 if (!is_string($value) || strncmp($value, self::ENC_PREFIX, strlen(self::ENC_PREFIX)) !== 0) {
704 return $value; // legacy plaintext or non-string
705 }
706 if (!function_exists('sodium_crypto_secretbox_open')) {
707 return $value;
708 }
709
710 $enc_key = $this->encryption_key();
711 if ('' === $enc_key) {
712 return $value;
713 }
714
715 // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_decode -- decodes our own ciphertext envelope; strict mode is on.
716 $decoded = base64_decode(substr($value, strlen(self::ENC_PREFIX)), true);
717 if (false === $decoded || strlen($decoded) <= SODIUM_CRYPTO_SECRETBOX_NONCEBYTES) {
718 return $value;
719 }
720
721 $nonce = substr($decoded, 0, SODIUM_CRYPTO_SECRETBOX_NONCEBYTES);
722 $cipher = substr($decoded, SODIUM_CRYPTO_SECRETBOX_NONCEBYTES);
723 $plain = sodium_crypto_secretbox_open($cipher, $nonce, $enc_key);
724
725 if (false === $plain) {
726 // We reach here only when sodium is available and a key was
727 // derivable, so this is a genuine failure: the auth salt changed
728 // (config rotated, or the site was migrated without wp-config) or
729 // the row is corrupt. The value is unrecoverable either way.
730 //
731 // Returning the ciphertext would send it upstream as a bearer
732 // token or API key, producing an opaque 401 far from the cause.
733 // Return empty so callers see "no credential" and can prompt for
734 // a reconnect instead.
735 return '';
736 }
737
738 return $plain;
739 }
740
741 /**
742 * Derive a 32-byte encryption key from the site's auth salt.
743 *
744 * @return string Raw 32-byte key, or '' if salts are unavailable.
745 */
746 private function encryption_key(): string {
747 if (!function_exists('wp_salt')) {
748 return '';
749 }
750 // 32 raw bytes from a site-specific secret — SHA-256 output length
751 // matches SODIUM_CRYPTO_SECRETBOX_KEYBYTES.
752 return hash('sha256', 'thinkrank-settings|' . wp_salt('auth'), true);
753 }
754 }
755