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

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