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

598 lines 18.7 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 * @since 1.0.0
28 */
29 class Settings {
30
31 /**
32 * Settings cache
33 *
34 * @var array
35 */
36 private array $cache = [];
37
38 /**
39 * Default settings
40 *
41 * @var array
42 */
43 private array $defaults = [
44 // AI Settings
45 'ai_provider' => 'openai',
46 'openai_api_key' => '',
47 'openai_model' => 'gpt-5-nano', // Default to GPT‑5‑nano
48 'claude_api_key' => '',
49 'claude_model' => 'claude-3-7-sonnet-latest', // Use latest stable Claude
50 'gemini_api_key' => '',
51 'gemini_model' => 'gemini-2.5-flash',
52 'max_tokens' => 1000,
53 'temperature' => 0.7,
54
55 // Google API Keys (encrypted)
56 'google_analytics_api_key' => '',
57 'google_search_console_api_key' => '',
58 'google_pagespeed_api_key' => '',
59
60 // Google OAuth Tokens (encrypted)
61 'google_access_token' => '',
62 'google_refresh_token' => '',
63 'google_token_expires_in' => 0,
64 'google_token_created' => 0,
65 'google_account_connected' => false,
66
67 // Google Analytics Pro Settings
68 'ga_analytics_account_id' => '',
69 'ga_analytics_data_stream_id' => '',
70
71 // Performance Settings
72 'cache_duration' => 3600,
73 'max_requests_per_minute' => 10,
74 'enable_logging' => true,
75
76 // Integration Settings
77 'api_timeout' => 30,
78 'enable_rate_limiting' => true,
79 'auto_test_connections' => true,
80 'retry_failed_requests' => true,
81
82 // Social Platform Settings
83 // Public IDs (not encrypted)
84 'facebook_app_id' => '',
85 'facebook_admins' => '',
86 'youtube_channel_id' => '',
87 'whatsapp_business_id' => '',
88 // Verification codes (encrypted)
89 'pinterest_site_verification' => '',
90 'instagram_verification' => '',
91 'tiktok_verification' => '',
92
93 // SEO Settings
94 'auto_optimize' => false,
95 'seo_score_threshold' => 70,
96 'enable_meta_generation' => true,
97 'enable_schema_markup' => true,
98
99 // Author Archives Settings
100 'author_archives_enabled' => true,
101 'author_archives_index' => true,
102 'author_archives_show_empty' => false,
103 'author_archives_title' => '%author_name% – %site_title% %page%',
104 'author_archives_meta_desc' => 'Articles written by %author_name% on %site_title%',
105
106 // UI Settings
107 'show_welcome_message' => true,
108 'dashboard_widgets' => ['seo_score', 'ai_usage', 'recent_briefs'],
109 'editor_panel_position' => 'side',
110
111 // Advanced Settings
112 'debug_mode' => false,
113 'retry_attempts' => 3,
114
115 // Privacy Settings
116 'data_retention_days' => 90,
117 'anonymize_logs' => true,
118 'share_usage_data' => false,
119
120 // Integration Settings
121 'google_analytics_id' => '',
122 'search_console_property' => '',
123
124 // GA4 Tracking Settings
125 'ga4_measurement_id' => '',
126 'ga4_auto_inject' => false,
127 'ga4_anonymize_ip' => false,
128 'ga4_exclude_admin' => false,
129 'ga4_tracking_verified' => false,
130 'ga4_last_verification' => '',
131
132 // SEO Analytics Settings
133 'seo_analytics_enabled' => false,
134 'seo_analytics_setup_completed' => false,
135 'seo_analytics_google_analytics_property_id' => '',
136 'search_console_property' => '',
137 'seo_analytics_enable_ai_insights' => true,
138 'seo_analytics_enable_automated_alerts' => false,
139 'seo_analytics_enable_predictive_analysis' => false,
140 'seo_analytics_monitoring_frequency' => 3600,
141 'seo_analytics_alert_thresholds' => [],
142 'seo_analytics_report_schedule' => 'weekly',
143 'seo_analytics_data_retention_days' => 90,
144 'seo_analytics_cache_analytics_data' => true,
145
146 // Uninstall Settings
147 'keep_data_on_uninstall' => true,
148 ];
149
150 /**
151 * Encrypted settings keys
152 *
153 * @var array
154 */
155 private array $encrypted_keys = [
156 // AI API Keys
157 'openai_api_key',
158 'claude_api_key',
159 'gemini_api_key',
160 // Google API Keys
161 'google_analytics_api_key',
162 'google_search_console_api_key',
163 'google_pagespeed_api_key',
164 'google_access_token',
165 'google_refresh_token',
166 // Social Platform Verification Codes (sensitive)
167 'pinterest_site_verification',
168 'instagram_verification',
169 'tiktok_verification',
170 ];
171
172 /**
173 * Initialize settings
174 *
175 * @return void
176 */
177 public function init(): void {
178 add_action('admin_init', [$this, 'register_settings']);
179 }
180
181 /**
182 * Register WordPress settings
183 *
184 * @return void
185 */
186 public function register_settings(): void {
187 register_setting('thinkrank_settings', 'thinkrank_settings', [
188 'sanitize_callback' => [$this, 'sanitize_settings'],
189 'default' => $this->defaults,
190 ]);
191 }
192
193 /**
194 * Get setting value
195 *
196 * @param string $key Setting key
197 * @param mixed $default Default value
198 * @param int $user_id User ID (0 for global, >0 for user-specific)
199 * @return mixed Setting value
200 */
201 public function get(string $key, $default = null, int $user_id = 0) {
202 // Check cache first
203 $cache_key = $user_id > 0 ? "user_{$user_id}_{$key}" : $key;
204
205 if (isset($this->cache[$cache_key])) {
206 return $this->maybe_decrypt($key, $this->cache[$cache_key]);
207 }
208
209 // Load from WordPress options or user meta
210 if ($user_id > 0) {
211 $value = get_user_meta($user_id, "thinkrank_{$key}", true);
212 } else {
213 $value = get_option("thinkrank_{$key}", null);
214 }
215
216
217
218 // Handle boolean settings with special logic for WordPress storage quirks
219 if (isset($this->defaults[$key]) && is_bool($this->defaults[$key])) {
220 // Convert string representations back to booleans
221 if ('1' === $value || 1 === $value || true === $value || 'true' === $value) {
222 $value = true;
223 } elseif ('0' === $value || 0 === $value || false === $value || 'false' === $value) {
224 $value = false;
225 } elseif (null === $value || '' === $value) {
226 // For boolean settings, empty string could mean false was stored
227 // Check if this setting was explicitly set by looking for the option
228 $option_exists = get_option("thinkrank_{$key}", 'OPTION_NOT_FOUND') !== 'OPTION_NOT_FOUND';
229 if ($option_exists && $value === '') {
230 // Empty string exists in DB, this likely means false was stored
231 $value = false;
232 } else {
233 // Option doesn't exist, use default
234 $value = $default ?? $this->defaults[$key];
235 }
236 }
237 } else {
238 // Non-boolean settings: use default if not found
239 if (null === $value || '' === $value) {
240 $value = $default ?? ($this->defaults[$key] ?? null);
241 }
242 }
243
244 // Cache the value
245 $this->cache[$cache_key] = $value;
246
247 return $this->maybe_decrypt($key, $value);
248 }
249
250 /**
251 * Set setting value
252 *
253 * @param string $key Setting key
254 * @param mixed $value Setting value
255 * @param int $user_id User ID (0 for global, >0 for user-specific)
256 * @return bool Success status
257 */
258 public function set(string $key, $value, int $user_id = 0): bool {
259 // Check if key exists in defaults
260 if (!array_key_exists($key, $this->defaults)) {
261 return false;
262 }
263
264
265
266 // Encrypt if needed
267 $encrypted_value = $this->maybe_encrypt($key, $value);
268
269 // Save to WordPress options or user meta
270 if ($user_id > 0) {
271 $result = update_user_meta($user_id, "thinkrank_{$key}", $encrypted_value);
272 } else {
273 $option_name = "thinkrank_{$key}";
274 $is_sensitive = in_array($key, $this->encrypted_keys, true);
275
276 // Determine if option exists
277 $existing = get_option($option_name, '__tr_not_set__');
278 if ($existing === '__tr_not_set__') {
279 // First-time save: set autoload=no for sensitive options
280 $autoload = $is_sensitive ? 'no' : 'yes';
281 $result = add_option($option_name, $encrypted_value, '', $autoload);
282 } else {
283 // Update existing option; enforce autoload=no for sensitive options
284 if ($is_sensitive) {
285 $result = update_option($option_name, $encrypted_value, 'no');
286 } else {
287 $result = update_option($option_name, $encrypted_value);
288 }
289 }
290
291 // Verify value actually saved (update_option returns false when unchanged)
292 $saved_value = get_option($option_name, 'NOT_FOUND');
293 $values_match = ($saved_value === $encrypted_value) ||
294 ($saved_value == $encrypted_value && $encrypted_value !== 'NOT_FOUND');
295
296 // Consider it successful if the value was saved correctly
297 $result = $result || $values_match;
298 }
299
300 // Update cache
301 if ($result) {
302 $cache_key = $user_id > 0 ? "user_{$user_id}_{$key}" : $key;
303 $this->cache[$cache_key] = $encrypted_value;
304
305 // Invalidate bulk cache when individual setting changes
306 $bulk_cache_key = $user_id > 0 ? "bulk_user_{$user_id}" : 'bulk_global';
307 wp_cache_delete($bulk_cache_key, 'thinkrank_settings');
308 }
309
310 return $result !== false;
311 }
312
313 /**
314 * Delete setting
315 *
316 * @param string $key Setting key
317 * @param int $user_id User ID (0 for global, >0 for user-specific)
318 * @return bool Success status
319 */
320 public function delete(string $key, int $user_id = 0): bool {
321 // Remove from WordPress options or user meta
322 if ($user_id > 0) {
323 $result = delete_user_meta($user_id, "thinkrank_{$key}");
324 } else {
325 $result = delete_option("thinkrank_{$key}");
326 }
327
328 // Remove from cache
329 if ($result) {
330 $cache_key = $user_id > 0 ? "user_{$user_id}_{$key}" : $key;
331 unset($this->cache[$cache_key]);
332 }
333
334 return $result;
335 }
336
337 /**
338 * Get all settings (optimized with bulk caching)
339 *
340 * @param int $user_id User ID (0 for global)
341 * @return array All settings
342 */
343 public function get_all(int $user_id = 0): array {
344 // Check bulk cache first
345 $bulk_cache_key = $user_id > 0 ? "bulk_user_{$user_id}" : 'bulk_global';
346
347 $cached_settings = wp_cache_get($bulk_cache_key, 'thinkrank_settings');
348 if ($cached_settings !== false) {
349 return $cached_settings;
350 }
351
352 $settings = [];
353
354 foreach (array_keys($this->defaults) as $key) {
355 $settings[$key] = $this->get($key, null, $user_id);
356 }
357
358 // Cache all settings for 5 minutes
359 wp_cache_set($bulk_cache_key, $settings, 'thinkrank_settings', 300);
360
361 return $settings;
362 }
363
364 /**
365 * Reset settings to defaults
366 *
367 * @param int $user_id User ID (0 for global)
368 * @return bool Success status
369 */
370 public function reset(int $user_id = 0): bool {
371 $success = true;
372
373 foreach (array_keys($this->defaults) as $key) {
374 if (!$this->delete($key, $user_id)) {
375 $success = false;
376 }
377 }
378
379 // Clear cache
380 $this->cache = [];
381
382 return $success;
383 }
384
385 /**
386 * Sanitize settings
387 *
388 * @param array $settings Settings array
389 * @return array Sanitized settings
390 */
391 public function sanitize_settings(array $settings): array {
392 $sanitized = [];
393
394 foreach ($settings as $key => $value) {
395 $sanitized[$key] = $this->sanitize_setting($key, $value);
396 }
397
398 return $sanitized;
399 }
400
401 /**
402 * Sanitize individual setting
403 *
404 * @param string $key Setting key
405 * @param mixed $value Setting value
406 * @return mixed Sanitized value
407 */
408 private function sanitize_setting(string $key, $value) {
409 switch ($key) {
410 case 'openai_api_key':
411 case 'claude_api_key':
412 case 'gemini_api_key':
413 return sanitize_text_field($value);
414
415 case 'ai_provider':
416 case 'openai_model':
417 case 'claude_model':
418 case 'gemini_model':
419 return sanitize_key($value);
420
421 case 'max_tokens':
422 case 'cache_duration':
423 case 'max_requests_per_minute':
424 case 'seo_score_threshold':
425 case 'api_timeout':
426 case 'retry_attempts':
427 case 'data_retention_days':
428 case 'monitoring_frequency':
429 return absint($value);
430
431 case 'temperature':
432 return (float) $value;
433
434 case 'dashboard_widgets':
435 case 'seo_analytics_alert_thresholds':
436 return is_array($value) ? array_map('sanitize_key', $value) : [];
437
438 case 'google_analytics_property_id':
439 case 'seo_analytics_google_analytics_property_id':
440 case 'seo_analytics_report_schedule':
441 return sanitize_text_field($value);
442
443 default:
444 if (is_bool($value)) {
445 return (bool) $value;
446 } elseif (is_string($value)) {
447 return sanitize_text_field($value);
448 } elseif (is_array($value)) {
449 return array_map('sanitize_text_field', $value);
450 }
451 return $value;
452 }
453 }
454
455 /**
456 * Encryption version for tracking
457 */
458 private const ENCRYPTION_VERSION = 2;
459
460 /**
461 * Maybe encrypt value using WordPress native encryption
462 *
463 * @param string $key Setting key
464 * @param mixed $value Setting value
465 * @return mixed Encrypted or original value
466 */
467 private function maybe_encrypt(string $key, $value) {
468 // Critical fix: disable custom encryption; store raw value without transformation
469 return $value;
470 }
471
472 /**
473 * Maybe decrypt value using WordPress native encryption
474 *
475 * @param string $key Setting key
476 * @param mixed $value Setting value
477 * @return mixed Decrypted or original value
478 */
479 private function maybe_decrypt(string $key, $value) {
480 if (!in_array($key, $this->encrypted_keys, true) || empty($value)) {
481 return $value;
482 }
483
484 // Backward compatibility: if value looks like our old encrypted payload, try to decrypt
485 $data = base64_decode((string) $value, true);
486 if ($data !== false && strlen($data) >= 33) {
487 $version = unpack('C', substr($data, 0, 1))[1] ?? null;
488 if ($version === self::ENCRYPTION_VERSION) {
489 $decrypted = $this->decrypt_value((string) $value);
490 // If decryption worked, return it; otherwise fall through and return original
491 if ($decrypted !== '') {
492 return $decrypted;
493 }
494 }
495 }
496
497 // Treat as already plain
498 return $value;
499 }
500
501 /**
502 * Encrypt value using WordPress native methods
503 *
504 * @param string $value Value to encrypt
505 * @return string Encrypted value (base64 encoded) or empty string on failure
506 * @throws \Exception When encryption process fails
507 */
508 private function encrypt_value(string $value): string {
509 try {
510 // Generate unique salt for this encryption
511 $salt = wp_generate_password(32, false);
512
513 // Create encryption key using WordPress salts
514 $auth_salt = wp_salt('auth');
515 $secure_salt = wp_salt('secure_auth');
516 $key = wp_hash($salt . $auth_salt . $secure_salt);
517
518 // XOR encryption
519 $encrypted = $this->xor_encrypt($value, $key);
520
521 // Version prefix + salt + encrypted data
522 $version = pack('C', self::ENCRYPTION_VERSION);
523 return base64_encode($version . $salt . $encrypted);
524 } catch (\Exception $e) {
525 // Silent failure on encryption error
526 return ''; // Return empty on encryption failure
527 }
528 }
529
530 /**
531 * Decrypt value using WordPress native methods
532 *
533 * @param string $encrypted_value Encrypted value to decrypt
534 * @return string Decrypted value or empty string on failure
535 * @throws \Exception When decryption process fails
536 */
537 private function decrypt_value(string $encrypted_value): string {
538 try {
539 $data = base64_decode($encrypted_value, true);
540 if (false === $data || strlen($data) < 33) {
541 return '';
542 }
543
544 // Extract version, salt, and encrypted data
545 $version = unpack('C', substr($data, 0, 1))[1];
546 $salt = substr($data, 1, 32);
547 $encrypted = substr($data, 33);
548
549 // Verify version
550 if ($version !== self::ENCRYPTION_VERSION) {
551 return '';
552 }
553
554 // Recreate encryption key
555 $auth_salt = wp_salt('auth');
556 $secure_salt = wp_salt('secure_auth');
557 $key = wp_hash($salt . $auth_salt . $secure_salt);
558
559 // Decrypt using XOR
560 return $this->xor_decrypt($encrypted, $key);
561 } catch (\Exception $e) {
562 // Silent failure on decryption error
563 return '';
564 }
565 }
566
567 /**
568 * XOR encryption helper
569 *
570 * @param string $data Data to encrypt
571 * @param string $key Encryption key
572 * @return string Encrypted data
573 */
574 private function xor_encrypt(string $data, string $key): string {
575 $result = '';
576 $key_length = strlen($key);
577 $data_length = strlen($data);
578
579 for ($i = 0; $i < $data_length; $i++) {
580 $result .= chr(ord($data[$i]) ^ ord($key[$i % $key_length]));
581 }
582
583 return $result;
584 }
585
586 /**
587 * XOR decryption helper (XOR is symmetric)
588 *
589 * @param string $encrypted Encrypted data
590 * @param string $key Encryption key
591 * @return string Decrypted data
592 */
593 private function xor_decrypt(string $encrypted, string $key): string {
594 // XOR is symmetric, so decryption is the same as encryption
595 return $this->xor_encrypt($encrypted, $key);
596 }
597 }
598