PluginProbe
Search Atlas SEO – OTTO AI SEO Automation for WordPress / 2.6.25
Search Atlas SEO – OTTO AI SEO Automation for WordPress v2.6.25
2.7.1 2.7.2 2.7.0 2.6.26 2.6.25 2.6.24 2.6.23 2.6.22 2.6.21 2.6.20 2.6.19 2.6.18 2.6.17 2.6.16 2.6.15 2.6.14 2.6.13 2.6.12 2.6.11 2.6.10 2.6.9 2.6.8 2.6.7 2.6.6 2.6.5 All 141 releases
metasync / includes / class-metasync-api-key-monitor.php

class-metasync-api-key-monitor.php in Search Atlas SEO – OTTO AI SEO Automation for WordPress 2.6.25, at includes/class-metasync-api-key-monitor.php

412 lines 14.4 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 // If this file is called directly, abort.
3 if (!defined('ABSPATH')) {
4 exit;
5 }
6
7
8 /**
9 * API Key Change Monitor for MetaSync Plugin
10 *
11 * @link https://searchatlas.com
12 * @since 1.0.0
13 *
14 * @package Metasync
15 * @subpackage Metasync/includes
16 */
17
18 /**
19 * Centralized API Key Change Monitor
20 *
21 * This class monitors all API key changes and ensures the backend is notified
22 * via Heartbeat API whenever the Plugin Auth Token or Search Atlas API Key changes.
23 *
24 * @since 1.0.0
25 * @package Metasync
26 * @subpackage Metasync/includes
27 * @author Engineering Team <[email protected]>
28 */
29 class Metasync_API_Key_Monitor
30 {
31 /**
32 * Instance of this class
33 */
34 private static $instance = null;
35
36 /**
37 * Flag to prevent infinite loops during monitoring
38 */
39 private $monitoring_active = false;
40
41 /**
42 * Get singleton instance
43 */
44 public static function get_instance()
45 {
46 if (self::$instance === null) {
47 self::$instance = new self();
48 }
49 return self::$instance;
50 }
51
52 /**
53 * Initialize the monitor
54 */
55 public function __construct()
56 {
57 // Only initialize once
58 if (self::$instance !== null) {
59 return;
60 }
61
62 $this->init_hooks();
63 }
64
65 /**
66 * Initialize WordPress hooks
67 */
68 private function init_hooks()
69 {
70 // Monitor option changes (catches direct database updates)
71 add_action('update_option_metasync_options', array($this, 'on_option_update'), 10, 2);
72
73 // Monitor option additions (for fresh installs)
74 add_action('add_option_metasync_options', array($this, 'on_option_add'), 10, 2);
75
76 // Provide centralized heartbeat trigger method
77 add_action('metasync_api_key_changed', array($this, 'trigger_heartbeat_for_key_change'), 10, 3);
78 }
79
80 /**
81 * Handle option updates
82 */
83 public function on_option_update($old_value, $new_value)
84 {
85 $this->analyze_and_respond_to_changes($old_value, $new_value, 'option_update');
86 }
87
88 /**
89 * Handle option additions (fresh installs)
90 */
91 public function on_option_add($option_name, $new_value)
92 {
93 // For new installations, treat as change from empty state
94 $this->analyze_and_respond_to_changes(array(), $new_value, 'option_add');
95 }
96
97 /**
98 * Analyze API key changes and trigger appropriate responses
99 */
100 private function analyze_and_respond_to_changes($old_value, $new_value, $trigger_context = 'unknown')
101 {
102 // Prevent infinite loops
103 if ($this->monitoring_active) {
104 return;
105 }
106
107 try {
108 $this->monitoring_active = true;
109
110 // Normalize values
111 $old_value = is_array($old_value) ? $old_value : array();
112 $new_value = is_array($new_value) ? $new_value : array();
113
114 // Extract API keys
115 $changes = $this->detect_api_key_changes($old_value, $new_value);
116
117 if (!empty($changes['changes_detected'])) {
118 $this->log_changes($changes, $trigger_context);
119 $this->trigger_heartbeat_response($changes, $trigger_context);
120 }
121
122 } catch (Exception $e) {
123 error_log('MetaSync API Key Monitor Error: ' . $e->getMessage());
124 } finally {
125 $this->monitoring_active = false;
126 }
127 }
128
129 /**
130 * Detect API key changes between old and new values
131 */
132 private function detect_api_key_changes($old_value, $new_value)
133 {
134 // Extract values
135 $old_plugin_auth_token = $old_value['general']['apikey'] ?? '';
136 $new_plugin_auth_token = $new_value['general']['apikey'] ?? '';
137
138 // The Search Atlas API key is encrypted at rest with a random IV, so the
139 // stored ciphertext differs on every write even when the plaintext is
140 // unchanged. Decrypt both sides before comparing to avoid false positives.
141 $old_searchatlas_api_key = Metasync::decrypt_api_key($old_value['general']['searchatlas_api_key'] ?? '');
142 $new_searchatlas_api_key = Metasync::decrypt_api_key($new_value['general']['searchatlas_api_key'] ?? '');
143 if ($old_searchatlas_api_key === false) {
144 $old_searchatlas_api_key = '';
145 }
146 if ($new_searchatlas_api_key === false) {
147 $new_searchatlas_api_key = '';
148 }
149
150 // Analyze changes
151 $plugin_token_changed = $old_plugin_auth_token !== $new_plugin_auth_token;
152 $searchatlas_key_changed = $old_searchatlas_api_key !== $new_searchatlas_api_key;
153
154 return array(
155 'changes_detected' => $plugin_token_changed || $searchatlas_key_changed,
156 'plugin_auth_token' => array(
157 'changed' => $plugin_token_changed,
158 'old' => $old_plugin_auth_token,
159 'new' => $new_plugin_auth_token,
160 'action' => $this->determine_change_action($old_plugin_auth_token, $new_plugin_auth_token)
161 ),
162 // Store only masked representations of the Search Atlas key so the
163 // cleartext value is never logged or passed to downstream hooks. The
164 // 'changed'/'action' fields are derived from the decrypted plaintext
165 // above before masking.
166 'searchatlas_api_key' => array(
167 'changed' => $searchatlas_key_changed,
168 'old' => $this->mask_api_key($old_searchatlas_api_key),
169 'new' => $this->mask_api_key($new_searchatlas_api_key),
170 'action' => $this->determine_change_action($old_searchatlas_api_key, $new_searchatlas_api_key)
171 )
172 );
173 }
174
175 /**
176 * Mask a secret for logging: keep only the last 4 characters.
177 *
178 * Returns '' for an empty value so existing empty() checks keep working.
179 *
180 * @param string $value
181 * @return string
182 */
183 private function mask_api_key($value)
184 {
185 $value = (string) $value;
186 if ($value === '') {
187 return '';
188 }
189 return str_repeat('•', 4) . substr($value, -4);
190 }
191
192 /**
193 * Determine the type of change (added, removed, changed)
194 */
195 private function determine_change_action($old_value, $new_value)
196 {
197 if (empty($old_value) && !empty($new_value)) {
198 return 'added';
199 } elseif (!empty($old_value) && empty($new_value)) {
200 return 'removed';
201 } elseif (!empty($old_value) && !empty($new_value) && $old_value !== $new_value) {
202 return 'changed';
203 }
204 return 'none';
205 }
206
207 /**
208 * Log API key changes
209 */
210 private function log_changes($changes, $context)
211 {
212 $log_messages = array();
213
214 if ($changes['plugin_auth_token']['changed']) {
215 $token_change = $changes['plugin_auth_token'];
216 $log_messages[] = sprintf('Plugin Auth Token %s: %s → %s',
217 $token_change['action'],
218 empty($token_change['old']) ? '(empty)' : substr($token_change['old'], 0, 8) . '...',
219 empty($token_change['new']) ? '(empty)' : substr($token_change['new'], 0, 8) . '...'
220 );
221 }
222
223 if ($changes['searchatlas_api_key']['changed']) {
224 $key_change = $changes['searchatlas_api_key'];
225 // 'old'/'new' are already masked (last 4 chars only) by detect_api_key_changes.
226 $log_messages[] = sprintf('Search Atlas API Key %s: %s → %s',
227 $key_change['action'],
228 empty($key_change['old']) ? '(empty)' : $key_change['old'],
229 empty($key_change['new']) ? '(empty)' : $key_change['new']
230 );
231 }
232
233 if (!empty($log_messages)) {
234 // Use centralized logging for consistent formatting
235 $details = array(
236 'context' => $context,
237 'changes' => implode(' | ', $log_messages)
238 );
239
240 if (class_exists('Metasync')) {
241 Metasync::log_api_key_event('API key change detected', 'multiple', $details, 'info');
242 } else {
243 // Fallback to basic error_log if main class not available
244 error_log('MetaSync API Key Monitor [' . $context . ']: ' . implode(' | ', $log_messages));
245 }
246 }
247 }
248
249 /**
250 * Trigger heartbeat response based on changes
251 */
252 private function trigger_heartbeat_response($changes, $context)
253 {
254 // Determine the most appropriate heartbeat trigger message
255 $heartbeat_context = $this->build_heartbeat_context($changes, $context);
256
257 // Fire the centralized action for heartbeat triggering (always fire this for other plugins to hook into)
258 do_action('metasync_api_key_changed', $changes, $context, $heartbeat_context);
259
260 // Only trigger immediate heartbeat if Search Atlas API key is present
261 // This follows the business rule that heartbeat API should only be called when Search Atlas API key is set
262 if ($this->should_trigger_heartbeat($changes)) {
263 do_action('metasync_heartbeat_state_key_pending'); // PR3: burst mode
264 do_action('metasync_trigger_immediate_heartbeat', $heartbeat_context);
265 } else {
266 $this->log_heartbeat_skip_reason($changes, $heartbeat_context);
267 }
268 }
269
270 /**
271 * Determine if heartbeat should be triggered based on API key availability
272 * Heartbeat API should only be called when Search Atlas API Key is present
273 */
274 private function should_trigger_heartbeat($changes)
275 {
276 // Get current Search Atlas API key (decrypted; false → unusable, treat as empty)
277 $current_searchatlas_key = Metasync::get_searchatlas_api_key();
278 if ($current_searchatlas_key === false) {
279 $current_searchatlas_key = '';
280 }
281
282 // Always trigger heartbeat if Search Atlas API key is being changed
283 if ($changes['searchatlas_api_key']['changed']) {
284 // If the new Search Atlas API key is not empty, trigger heartbeat
285 if (!empty($changes['searchatlas_api_key']['new'])) {
286 return true;
287 }
288 // If Search Atlas API key was removed, still trigger to update backend status
289 if (!empty($changes['searchatlas_api_key']['old'])) {
290 return true;
291 }
292 }
293
294 // For Plugin Auth Token changes, only trigger if Search Atlas API key exists
295 if ($changes['plugin_auth_token']['changed']) {
296 if (!empty($current_searchatlas_key)) {
297 return true;
298 }
299 // Plugin Auth Token changed but no Search Atlas API key present
300 return false;
301 }
302
303 // No changes that require heartbeat
304 return false;
305 }
306
307 /**
308 * Log why heartbeat trigger was skipped
309 */
310 private function log_heartbeat_skip_reason($changes, $context)
311 {
312 $skip_reasons = array();
313
314 if ($changes['plugin_auth_token']['changed']) {
315 $skip_reasons[] = 'Plugin Auth Token changed but Search Atlas API key not configured';
316 }
317
318 if ($changes['searchatlas_api_key']['changed'] && empty($changes['searchatlas_api_key']['new'])) {
319 $skip_reasons[] = 'Search Atlas API key was removed';
320 }
321
322 if (!empty($skip_reasons)) {
323 error_log('MetaSync API Key Monitor: Skipping heartbeat trigger - ' . implode(' | ', $skip_reasons) . ' - Context: ' . $context);
324 }
325 }
326
327 /**
328 * Build descriptive context for heartbeat trigger
329 */
330 private function build_heartbeat_context($changes, $context)
331 {
332 $context_parts = array('API Key Monitor');
333
334 if ($changes['plugin_auth_token']['changed']) {
335 $context_parts[] = 'Plugin Auth Token ' . $changes['plugin_auth_token']['action'];
336 }
337
338 if ($changes['searchatlas_api_key']['changed']) {
339 $context_parts[] = 'Search Atlas API Key ' . $changes['searchatlas_api_key']['action'];
340 }
341
342 $context_parts[] = '(' . $context . ')';
343
344 return implode(' - ', $context_parts);
345 }
346
347 /**
348 * Handle centralized heartbeat trigger action
349 */
350 public function trigger_heartbeat_for_key_change($changes, $context, $heartbeat_context)
351 {
352 // This method can be extended to handle specific logic based on the type of change
353 // For now, it serves as a central point for other plugins/components to hook into
354
355 // Log the centralized trigger
356
357 }
358
359 /**
360 * Manual API key change notification (for programmatic triggers)
361 */
362 public static function notify_api_key_change($key_type, $old_value, $new_value, $context = 'manual')
363 {
364 $instance = self::get_instance();
365
366 // Build simulated change array
367 if ($key_type === 'plugin_auth_token') {
368 $changes = array(
369 'changes_detected' => true,
370 'plugin_auth_token' => array(
371 'changed' => true,
372 'old' => $old_value,
373 'new' => $new_value,
374 'action' => $instance->determine_change_action($old_value, $new_value)
375 ),
376 'searchatlas_api_key' => array('changed' => false)
377 );
378 } elseif ($key_type === 'searchatlas_api_key') {
379 $changes = array(
380 'changes_detected' => true,
381 'plugin_auth_token' => array('changed' => false),
382 'searchatlas_api_key' => array(
383 'changed' => true,
384 'old' => $old_value,
385 'new' => $new_value,
386 'action' => $instance->determine_change_action($old_value, $new_value)
387 )
388 );
389 } else {
390 return false;
391 }
392
393 $instance->log_changes($changes, $context);
394 $instance->trigger_heartbeat_response($changes, $context);
395
396 return true;
397 }
398
399 /**
400 * Get monitoring status for debugging
401 */
402 public function get_monitoring_status()
403 {
404 return array(
405 'class_loaded' => true,
406 'hooks_active' => has_action('update_option_metasync_options', array($this, 'on_option_update')),
407 'monitoring_active' => $this->monitoring_active,
408 'instance_initialized' => self::$instance !== null
409 );
410 }
411 }
412