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

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