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

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