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

667 lines 22.3 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 $default 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, $default = 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 = $default ?? $this->defaults[$key];
330 }
331 }
332 } else {
333 // Non-boolean settings: use default if not found
334 if (null === $value || '' === $value) {
335 $value = $default ?? ($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 $values_match = ($saved_value === $encrypted_value) ||
389 ($saved_value == $encrypted_value && $encrypted_value !== 'NOT_FOUND');
390
391 // Consider it successful if the value was saved correctly
392 $result = $result || $values_match;
393 }
394
395 // Update cache
396 if ($result) {
397 $cache_key = $user_id > 0 ? "user_{$user_id}_{$key}" : $key;
398 $this->cache[$cache_key] = $encrypted_value;
399
400 // Invalidate bulk cache when individual setting changes
401 $bulk_cache_key = $user_id > 0 ? "bulk_user_{$user_id}" : 'bulk_global';
402 wp_cache_delete($bulk_cache_key, 'thinkrank_settings');
403 }
404
405 return $result !== false;
406 }
407
408 /**
409 * Delete setting
410 *
411 * @param string $key Setting key
412 * @param int $user_id User ID (0 for global, >0 for user-specific)
413 * @return bool Success status
414 */
415 public function delete(string $key, int $user_id = 0): bool {
416 // Remove from WordPress options or user meta
417 if ($user_id > 0) {
418 $result = delete_user_meta($user_id, "thinkrank_{$key}");
419 } else {
420 $result = delete_option("thinkrank_{$key}");
421 }
422
423 // Remove from cache
424 if ($result) {
425 $cache_key = $user_id > 0 ? "user_{$user_id}_{$key}" : $key;
426 unset($this->cache[$cache_key]);
427
428 // Also invalidate the bulk cache written by get_all(), or it serves
429 // stale values for up to 5 minutes after a delete.
430 $bulk_cache_key = $user_id > 0 ? "bulk_user_{$user_id}" : 'bulk_global';
431 wp_cache_delete($bulk_cache_key, 'thinkrank_settings');
432 }
433
434 return $result;
435 }
436
437 /**
438 * Get all settings (optimized with bulk caching)
439 *
440 * @param int $user_id User ID (0 for global)
441 * @return array All settings
442 */
443 public function get_all(int $user_id = 0): array {
444 // Check bulk cache first
445 $bulk_cache_key = $user_id > 0 ? "bulk_user_{$user_id}" : 'bulk_global';
446
447 $cached_settings = wp_cache_get($bulk_cache_key, 'thinkrank_settings');
448 if ($cached_settings !== false) {
449 return $cached_settings;
450 }
451
452 $settings = [];
453
454 foreach (array_keys($this->defaults) as $key) {
455 $settings[$key] = $this->get($key, null, $user_id);
456 }
457
458 // Cache all settings for 5 minutes
459 wp_cache_set($bulk_cache_key, $settings, 'thinkrank_settings', 300);
460
461 return $settings;
462 }
463
464 /**
465 * Reset settings to defaults
466 *
467 * @param int $user_id User ID (0 for global)
468 * @return bool Success status
469 */
470 public function reset(int $user_id = 0): bool {
471 $success = true;
472
473 foreach (array_keys($this->defaults) as $key) {
474 if (!$this->delete($key, $user_id)) {
475 $success = false;
476 }
477 }
478
479 // Clear cache — both the instance array and the bulk object cache
480 // written by get_all(), which would otherwise return stale pre-reset
481 // values for up to 5 minutes.
482 $this->cache = [];
483 $bulk_cache_key = $user_id > 0 ? "bulk_user_{$user_id}" : 'bulk_global';
484 wp_cache_delete($bulk_cache_key, 'thinkrank_settings');
485
486 return $success;
487 }
488
489 /**
490 * Sanitize settings
491 *
492 * @param array $settings Settings array
493 * @return array Sanitized settings
494 */
495 public function sanitize_settings(array $settings): array {
496 $sanitized = [];
497
498 foreach ($settings as $key => $value) {
499 $sanitized[$key] = $this->sanitize_setting($key, $value);
500 }
501
502 return $sanitized;
503 }
504
505 /**
506 * Sanitize individual setting
507 *
508 * @param string $key Setting key
509 * @param mixed $value Setting value
510 * @return mixed Sanitized value
511 */
512 private function sanitize_setting(string $key, $value) {
513 switch ($key) {
514 case 'openai_api_key':
515 case 'claude_api_key':
516 case 'gemini_api_key':
517 case 'openrouter_api_key':
518 return sanitize_text_field($value);
519
520 case 'ai_provider':
521 return sanitize_key($value);
522
523 case 'openai_model':
524 case 'claude_model':
525 case 'gemini_model':
526 case 'openrouter_model':
527 // Model ids may contain dots and slashes (e.g. "gpt-4.1" or
528 // "openai/gpt-4o-mini") and users can enter custom models, so
529 // sanitize_key() would corrupt them — use text-field sanitizing.
530 return sanitize_text_field($value);
531
532 case 'max_tokens':
533 case 'cache_duration':
534 case 'max_requests_per_minute':
535 case 'seo_score_threshold':
536 case 'api_timeout':
537 case 'retry_attempts':
538 case 'data_retention_days':
539 case 'monitoring_frequency':
540 return absint($value);
541
542 case 'temperature':
543 return (float) $value;
544
545 case 'dashboard_widgets':
546 case 'seo_analytics_alert_thresholds':
547 return is_array($value) ? array_map('sanitize_key', $value) : [];
548
549 case 'google_analytics_property_id':
550 case 'seo_analytics_google_analytics_property_id':
551 case 'seo_analytics_report_schedule':
552 return sanitize_text_field($value);
553
554 case 'robots_txt_content':
555 // Multi-line content — sanitize_text_field() collapses newlines
556 // and would flatten the whole file onto a single line.
557 return sanitize_textarea_field($value);
558
559 default:
560 if (is_bool($value)) {
561 return (bool) $value;
562 } elseif (is_string($value)) {
563 return sanitize_text_field($value);
564 } elseif (is_array($value)) {
565 return array_map('sanitize_text_field', $value);
566 }
567 return $value;
568 }
569 }
570
571 /**
572 * Marker prefix for values encrypted with the libsodium scheme below.
573 */
574 private const ENC_PREFIX = 'trenc:v1:';
575
576 /**
577 * Encrypt secret settings (API keys, OAuth tokens) at rest using libsodium
578 * authenticated encryption. Non-secret keys and environments without sodium
579 * fall back to plaintext so behaviour stays stable.
580 *
581 * @param string $key Setting key
582 * @param mixed $value Setting value
583 * @return mixed Encrypted payload (string) or original value
584 */
585 private function maybe_encrypt(string $key, $value) {
586 if (!in_array($key, $this->encrypted_keys, true) || !is_string($value) || '' === $value) {
587 return $value;
588 }
589 if (!function_exists('sodium_crypto_secretbox')) {
590 return $value; // sodium unavailable — store as-is
591 }
592
593 $enc_key = $this->encryption_key();
594 if ('' === $enc_key) {
595 return $value;
596 }
597
598 try {
599 $nonce = random_bytes(SODIUM_CRYPTO_SECRETBOX_NONCEBYTES);
600 $cipher = sodium_crypto_secretbox($value, $nonce, $enc_key);
601 return self::ENC_PREFIX . base64_encode($nonce . $cipher);
602 } catch (\Exception $e) {
603 return $value;
604 }
605 }
606
607 /**
608 * Decrypt a value previously encrypted by maybe_encrypt. Values without the
609 * marker prefix are legacy plaintext and returned untouched (backward compat).
610 *
611 * @param string $key Setting key
612 * @param mixed $value Setting value
613 * @return mixed Decrypted or original value
614 */
615 private function maybe_decrypt(string $key, $value) {
616 if (!is_string($value) || strncmp($value, self::ENC_PREFIX, strlen(self::ENC_PREFIX)) !== 0) {
617 return $value; // legacy plaintext or non-string
618 }
619 if (!function_exists('sodium_crypto_secretbox_open')) {
620 return $value;
621 }
622
623 $enc_key = $this->encryption_key();
624 if ('' === $enc_key) {
625 return $value;
626 }
627
628 $decoded = base64_decode(substr($value, strlen(self::ENC_PREFIX)), true);
629 if (false === $decoded || strlen($decoded) <= SODIUM_CRYPTO_SECRETBOX_NONCEBYTES) {
630 return $value;
631 }
632
633 $nonce = substr($decoded, 0, SODIUM_CRYPTO_SECRETBOX_NONCEBYTES);
634 $cipher = substr($decoded, SODIUM_CRYPTO_SECRETBOX_NONCEBYTES);
635 $plain = sodium_crypto_secretbox_open($cipher, $nonce, $enc_key);
636
637 if (false === $plain) {
638 // We reach here only when sodium is available and a key was
639 // derivable, so this is a genuine failure: the auth salt changed
640 // (config rotated, or the site was migrated without wp-config) or
641 // the row is corrupt. The value is unrecoverable either way.
642 //
643 // Returning the ciphertext would send it upstream as a bearer
644 // token or API key, producing an opaque 401 far from the cause.
645 // Return empty so callers see "no credential" and can prompt for
646 // a reconnect instead.
647 return '';
648 }
649
650 return $plain;
651 }
652
653 /**
654 * Derive a 32-byte encryption key from the site's auth salt.
655 *
656 * @return string Raw 32-byte key, or '' if salts are unavailable.
657 */
658 private function encryption_key(): string {
659 if (!function_exists('wp_salt')) {
660 return '';
661 }
662 // 32 raw bytes from a site-specific secret — SHA-256 output length
663 // matches SODIUM_CRYPTO_SECRETBOX_KEYBYTES.
664 return hash('sha256', 'thinkrank-settings|' . wp_salt('auth'), true);
665 }
666 }
667