PluginProbe
Search Atlas SEO – OTTO AI SEO Automation for WordPress / trunk
Search Atlas SEO – OTTO AI SEO Automation for WordPress vtrunk
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 2.6.4 2.6.3 2.5.23 All 138 releases
metasync / includes / class-metasync-admin-ajax.php

class-metasync-admin-ajax.php in Search Atlas SEO – OTTO AI SEO Automation for WordPress trunk, at includes/class-metasync-admin-ajax.php

1,523 lines 65.7 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 if (!defined('ABSPATH')) {
3 exit;
4 }
5
6 class Metasync_Admin_Ajax
7 {
8 private static $instance = null;
9
10 private $db_redirection = null;
11
12 public static function instance()
13 {
14 if (null === self::$instance) {
15 self::$instance = new self();
16 }
17 return self::$instance;
18 }
19
20 private function __construct() {}
21
22 /**
23 * Discard request-local recovery state after acquiring the shared lock.
24 *
25 * A request can wait for another recovery/settings request while its
26 * WordPress option or transient cache still contains the pre-lock state.
27 * Refreshing here makes every decision under the lock use current storage.
28 */
29 public function refresh_recovery_state_cache($transient_key = '')
30 {
31 if (!function_exists('wp_cache_delete')) {
32 return;
33 }
34
35 wp_cache_delete('alloptions', 'options');
36 wp_cache_delete(Metasync::option_name, 'options');
37 wp_cache_delete('metasync_pw_reset_generation', 'options');
38
39 if ($transient_key !== '') {
40 wp_cache_delete($transient_key, 'transient');
41 wp_cache_delete('_transient_' . $transient_key, 'options');
42 wp_cache_delete('_transient_timeout_' . $transient_key, 'options');
43 }
44 }
45
46 public function acquire_recovery_lock(&$owner)
47 {
48 $owner = bin2hex(random_bytes(16));
49 $key = 'metasync_pw_recovery_rate_lock';
50 if (add_option($key, array('owner' => $owner, 'time' => time()), '', false)) return true;
51 $held = get_option($key, array());
52 $held_time = is_array($held) ? (int) ($held['time'] ?? 0) : (int) $held;
53 // Settings requests may run for up to five minutes. Keep the lease
54 // comfortably beyond that bound; shutdown cleanup releases it
55 // promptly during normal completion and handled failures.
56 if ($held_time > 0 && time() - $held_time > 900) {
57 $replacement = array('owner' => $owner, 'time' => time());
58 global $wpdb;
59 if (isset($wpdb) && is_object($wpdb) && method_exists($wpdb, 'query') && method_exists($wpdb, 'prepare')) {
60 $serialize = function ($value) { return function_exists('maybe_serialize') ? maybe_serialize($value) : serialize($value); };
61 $wpdb->query($wpdb->prepare(
62 "UPDATE {$wpdb->options} SET option_value = %s WHERE option_name = %s AND option_value = %s",
63 $serialize($replacement), $key, $serialize($held)
64 ));
65 if (function_exists('wp_cache_delete')) wp_cache_delete($key, 'options');
66 } else {
67 update_option($key, $replacement, false);
68 }
69 $claimed = get_option($key, array());
70 return is_array($claimed) && isset($claimed['owner']) && hash_equals((string) $claimed['owner'], (string) $owner);
71 }
72 return false;
73 }
74
75 public function release_recovery_lock($owner)
76 {
77 $key = 'metasync_pw_recovery_rate_lock';
78 $held = get_option($key, array());
79 if (is_array($held) && isset($held['owner']) && hash_equals((string) $held['owner'], (string) $owner)) {
80 global $wpdb;
81 if (isset($wpdb) && is_object($wpdb) && method_exists($wpdb, 'query') && method_exists($wpdb, 'prepare')) {
82 $serialized = function_exists('maybe_serialize') ? maybe_serialize($held) : serialize($held);
83 $wpdb->query($wpdb->prepare(
84 "DELETE FROM {$wpdb->options} WHERE option_name = %s AND option_value = %s",
85 $key,
86 $serialized
87 ));
88 if (function_exists('wp_cache_delete')) wp_cache_delete($key, 'options');
89 return;
90 }
91
92 // Test/bootstrap fallback. Production uses the owner-checked SQL
93 // delete above so expired-owner cleanup cannot remove a new lock.
94 delete_option($key);
95 }
96 }
97
98 private function persist_recovery_password($encrypted)
99 {
100 for ($attempt = 0; $attempt < 3; $attempt++) {
101 $options = Metasync::get_option();
102 if (!is_array($options)) $options = array();
103 if (!isset($options['whitelabel']) || !is_array($options['whitelabel'])) $options['whitelabel'] = array();
104 $options['whitelabel']['settings_password'] = $encrypted;
105 Metasync_Settings_Registration::authorize_recovery_password_write(true);
106 try {
107 $saved = Metasync::set_option($options);
108 } finally {
109 Metasync_Settings_Registration::authorize_recovery_password_write(false);
110 }
111 if ($saved) {
112 $stored = Metasync::get_option('whitelabel');
113 if (is_array($stored) && isset($stored['settings_password']) && hash_equals((string) $encrypted, (string) $stored['settings_password'])) return true;
114 }
115 }
116 return false;
117 }
118
119 private function get_db_redirection()
120 {
121 if (null === $this->db_redirection) {
122 $this->db_redirection = new Metasync_Redirection_Database();
123 }
124 return $this->db_redirection;
125 }
126
127 public function ajax_import_external_data()
128 {
129 $execution_time = Metasync_Settings_Fields::instance()->get_execution_setting('max_execution_time');
130 if (function_exists('set_time_limit')) {
131 @set_time_limit($execution_time);
132 }
133
134 Metasync_Settings_Fields::instance()->apply_memory_limit();
135
136 check_ajax_referer('metasync_import_external_data', 'nonce');
137
138 if (!Metasync::current_user_has_plugin_access()) {
139 wp_send_json_error(['message' => 'Insufficient permissions.']);
140 }
141
142 $type = isset($_POST['type']) ? sanitize_text_field($_POST['type']) : '';
143 $plugin = isset($_POST['plugin']) ? sanitize_text_field($_POST['plugin']) : '';
144 $offset = isset($_POST['offset']) ? intval($_POST['offset']) : 0;
145
146 if (empty($type) || empty($plugin)) {
147 wp_send_json_error(['message' => 'Missing required parameters.']);
148 }
149
150 require_once plugin_dir_path(dirname(__FILE__)) . 'includes/class-metasync-external-importer.php';
151 $importer = new Metasync_External_Importer($this->get_db_redirection());
152 $result = ['success' => false, 'message' => 'Unknown import type.'];
153
154 switch ($type) {
155 case 'redirections':
156 $result = $importer->import_redirections($plugin);
157 break;
158 case 'sitemap':
159 $result = $importer->import_sitemap($plugin);
160 break;
161 case 'robots':
162 $result = $importer->import_robots($plugin);
163 break;
164 case 'indexation':
165 $result = $importer->import_indexation($plugin, ['batch_size' => 50, 'offset' => $offset]);
166 break;
167 case 'schema':
168 $result = $importer->import_schema($plugin);
169 break;
170 }
171
172 if ($result['success']) {
173 wp_send_json_success($result);
174 } else {
175 wp_send_json_error($result);
176 }
177 }
178
179 public function ajax_import_seo_metadata()
180 {
181 $execution_time = Metasync_Settings_Fields::instance()->get_execution_setting('max_execution_time');
182 if (function_exists('set_time_limit')) {
183 @set_time_limit($execution_time);
184 }
185
186 Metasync_Settings_Fields::instance()->apply_memory_limit();
187
188 check_ajax_referer('metasync_import_seo_metadata', 'nonce');
189
190 if (!Metasync::current_user_has_plugin_access()) {
191 wp_send_json_error(['message' => 'Insufficient permissions.']);
192 }
193
194 $plugin = isset($_POST['plugin']) ? sanitize_text_field($_POST['plugin']) : '';
195 $import_titles = isset($_POST['import_titles']) ? (bool) intval($_POST['import_titles']) : true;
196 $import_descriptions = isset($_POST['import_descriptions']) ? (bool) intval($_POST['import_descriptions']) : true;
197 $import_social_text = isset($_POST['import_social_text']) ? (bool) intval($_POST['import_social_text']) : true;
198 $import_social_images = isset($_POST['import_social_images']) ? (bool) intval($_POST['import_social_images']) : true;
199 $overwrite_existing = isset($_POST['overwrite_existing']) ? (bool) intval($_POST['overwrite_existing']) : false;
200 $offset = isset($_POST['offset']) ? intval($_POST['offset']) : 0;
201
202 if (empty($plugin)) {
203 wp_send_json_error(['message' => 'Missing required plugin parameter.']);
204 }
205
206 require_once plugin_dir_path(dirname(__FILE__)) . 'includes/class-metasync-external-importer.php';
207 $importer = new Metasync_External_Importer($this->get_db_redirection());
208
209 $options = [
210 'import_titles' => $import_titles,
211 'import_descriptions' => $import_descriptions,
212 'import_social_text' => $import_social_text,
213 'import_social_images' => $import_social_images,
214 'overwrite_existing' => $overwrite_existing,
215 'batch_size' => 50,
216 'offset' => $offset
217 ];
218
219 $result = $importer->import_seo_metadata($plugin, $options);
220
221 if ($result['success']) {
222 wp_send_json_success($result);
223 } else {
224 wp_send_json_error($result);
225 }
226 }
227
228 public function lgSendCustomerParams()
229 {
230 check_ajax_referer('metasync_nonce', 'nonce');
231 if (!current_user_can('manage_options')) {
232 wp_send_json_error('Unauthorized');
233 return;
234 }
235 $sync_request = new Metasync_Sync_Requests();
236
237 # use the existing apikey for backward compatibility
238 $general_options = Metasync::get_option('general') ?? [];
239 $token = $general_options['apikey'] ?? null;
240
241 # declare the call context explicitly so only the Settings
242 # "Sync Now" button consumes/stamps the 5-minute manual cooldown.
243 $is_heartbeat_tick = filter_var($_POST['is_heart_beat'] ?? false, FILTER_VALIDATE_BOOLEAN);
244
245 # get the response
246 $response = $sync_request->SyncCustomerParams($token, $is_heartbeat_tick ? 'heartbeat' : 'manual');
247
248 // Check if response is a throttling error object
249 if (is_object($response) && isset($response->throttled) && $response->throttled === true) {
250 wp_send_json($response);
251 wp_die();
252 }
253
254 // Check if response is null/false (other error cases)
255 if ($response === null || $response === false) {
256 wp_send_json(['error' => 'Sync failed - no response from sync method', 'detail' => 'The sync method returned null or false']);
257 wp_die();
258 }
259
260 $responseBody = wp_remote_retrieve_body($response);
261 $responseCode = wp_remote_retrieve_response_code($response);
262
263 if ($responseCode == 200) {
264 # Standardize on current_time('mysql') (WP local timezone) so the
265 # value matches what the cron heartbeat writes — otherwise the
266 # displayed timestamp flickers between two formats depending on
267 # which path wrote last.
268 $send_auth_token_timestamp = Metasync::get_option();
269 $send_auth_token_timestamp['general']['send_auth_token_timestamp'] = current_time('mysql');
270 Metasync::set_option($send_auth_token_timestamp);
271
272 Metasync_Heartbeat_Manager::instance()->update_heartbeat_cache_after_sync(true, 'Sync Now - successful data sync');
273
274 $result = json_decode($responseBody);
275 if ( ! is_object( $result ) ) {
276 $result = new stdClass();
277 }
278 $timestamp = Metasync::get_option('general')['send_auth_token_timestamp'] ?? '';
279 $result->send_auth_token_timestamp = $timestamp;
280 $result->send_auth_token_diffrence = Metasync_Settings_Fields::instance()->time_elapsed_string($timestamp);
281 wp_send_json($result);
282 wp_die();
283 } else {
284 Metasync_Heartbeat_Manager::instance()->update_heartbeat_cache_after_sync(false, 'Sync Now - failed data sync');
285 }
286
287 $result = json_decode($responseBody);
288 wp_send_json($result);
289 wp_die();
290 }
291
292 public function ajax_update_db_structure()
293 {
294 if (!Metasync::current_user_has_plugin_access()) {
295 wp_die('Insufficient permissions');
296 }
297
298 if (empty($_POST['nonce']) || !wp_verify_nonce(sanitize_text_field(wp_unslash($_POST['nonce'])), 'metasync_update_db_nonce')) {
299 wp_die('Security check failed');
300 }
301
302 try {
303 $this->get_db_redirection()->force_table_update();
304
305 wp_send_json_success('Database structure updated successfully');
306 } catch (Exception $e) {
307 wp_send_json_error('Database update failed: ' . $e->getMessage());
308 }
309 }
310
311 public function ajax_save_wizard_progress()
312 {
313 check_ajax_referer('metasync_wizard', 'nonce');
314
315 if (!Metasync::current_user_has_plugin_access()) {
316 wp_send_json_error(array('message' => 'Insufficient permissions'));
317 }
318
319 $step = isset($_POST['step']) ? intval($_POST['step']) : 0;
320 $raw_data = isset($_POST['data']) ? $_POST['data'] : array();
321
322 // Allowlist top-level wizard data keys to prevent mass assignment
323 $allowed_wizard_keys = array('verification', 'seo_settings', 'schema');
324 $data = is_array($raw_data) ? array_intersect_key($raw_data, array_flip($allowed_wizard_keys)) : array();
325
326 $options = get_option('metasync_options', array());
327
328 if (isset($data['verification'])) {
329 if (!isset($options['general'])) {
330 $options['general'] = array();
331 }
332 $options['general']['google_verification'] = sanitize_text_field($data['verification']['google']);
333 $options['general']['bing_verification'] = sanitize_text_field($data['verification']['bing']);
334 }
335
336 if (isset($data['seo_settings'])) {
337 if (!isset($options['seo_controls'])) {
338 $options['seo_controls'] = array();
339 }
340
341 $options['seo_controls']['index_date_archives'] = $data['seo_settings']['date_archives'] ? 'false' : 'true';
342 $options['seo_controls']['index_author_archives'] = $data['seo_settings']['author_archives'] ? 'false' : 'true';
343 $options['seo_controls']['index_category_archives'] = $data['seo_settings']['category_archives'] ? 'false' : 'true';
344 $options['seo_controls']['index_tag_archives'] = $data['seo_settings']['tag_archives'] ? 'false' : 'true';
345 }
346
347 if (isset($data['schema'])) {
348 if (!isset($options['general'])) {
349 $options['general'] = array();
350 }
351 $options['general']['enable_schema_markup'] = $data['schema']['enabled'];
352 $options['general']['default_schema_type'] = sanitize_text_field($data['schema']['default_type']);
353 }
354
355 update_option('metasync_options', $options);
356
357 wp_send_json_success(array('message' => 'Progress saved'));
358 }
359
360 public function ajax_complete_wizard()
361 {
362 check_ajax_referer('metasync_wizard', 'nonce');
363
364 if (!Metasync::current_user_has_plugin_access()) {
365 wp_send_json_error(array('message' => 'Insufficient permissions'));
366 }
367
368 update_option('metasync_wizard_completed', array(
369 'completed' => true,
370 'completed_at' => current_time('mysql'),
371 'completed_by' => get_current_user_id(),
372 'version' => METASYNC_VERSION
373 ));
374
375 $user_id = get_current_user_id();
376 delete_transient("metasync_wizard_state_{$user_id}");
377
378 wp_send_json_success(array('message' => 'Wizard completed'));
379 }
380
381 public function ajax_validate_robots()
382 {
383 check_ajax_referer('metasync_nonce', 'nonce');
384 if (!Metasync::current_user_has_plugin_access()) {
385 wp_send_json_error('Insufficient permissions');
386 return;
387 }
388
389 require_once plugin_dir_path(dirname(__FILE__)) . 'robots-txt/class-metasync-robots-txt.php';
390 $robots_txt = Metasync_Robots_Txt::get_instance();
391
392 $content = isset($_POST['content']) ? wp_unslash($_POST['content']) : '';
393 $validation = $robots_txt->validate_content($content);
394
395 wp_send_json_success($validation);
396 }
397
398 public function ajax_get_default_robots()
399 {
400 check_ajax_referer('metasync_nonce', 'nonce');
401 if (!Metasync::current_user_has_plugin_access()) {
402 wp_send_json_error('Insufficient permissions');
403 return;
404 }
405
406 require_once plugin_dir_path(dirname(__FILE__)) . 'robots-txt/class-metasync-robots-txt.php';
407 $robots_txt = Metasync_Robots_Txt::get_instance();
408
409 wp_send_json_success(array(
410 'content' => $robots_txt->get_default_robots_content()
411 ));
412 }
413
414 public function ajax_preview_robots_backup()
415 {
416 check_ajax_referer('metasync_nonce', 'nonce');
417 if (!Metasync::current_user_has_plugin_access()) {
418 wp_send_json_error('Insufficient permissions');
419 return;
420 }
421
422 $backup_id = isset($_POST['backup_id']) ? intval($_POST['backup_id']) : 0;
423
424 if (!$backup_id) {
425 wp_send_json_error('Invalid backup ID');
426 return;
427 }
428
429 require_once plugin_dir_path(dirname(__FILE__)) . 'robots-txt/class-metasync-robots-txt-database.php';
430 $database = Metasync_Robots_Txt_Database::get_instance();
431
432 $backup = $database->get_backup($backup_id);
433
434 if (!$backup) {
435 wp_send_json_error('Backup not found');
436 return;
437 }
438
439 wp_send_json_success(array(
440 'content' => $backup['content'],
441 'created_at' => get_date_from_gmt($backup['created_at'], get_option('date_format') . ' ' . get_option('time_format')),
442 'created_by_name' => isset($backup['created_by_name']) ? $backup['created_by_name'] : ''
443 ));
444 }
445
446 public function ajax_delete_robots_backup()
447 {
448 if (!Metasync::current_user_has_plugin_access()) {
449 wp_send_json_error(array('message' => esc_html__('Insufficient permissions', 'metasync')));
450 return;
451 }
452
453 if (!isset($_POST['nonce']) || !wp_verify_nonce($_POST['nonce'], 'metasync_delete_robots_backup')) {
454 wp_send_json_error(array('message' => esc_html__('Security check failed', 'metasync')));
455 return;
456 }
457
458 $backup_id = isset($_POST['backup_id']) ? intval($_POST['backup_id']) : 0;
459
460 if (!$backup_id) {
461 wp_send_json_error(array('message' => esc_html__('Invalid backup ID', 'metasync')));
462 return;
463 }
464
465 require_once plugin_dir_path(dirname(__FILE__)) . 'robots-txt/class-metasync-robots-txt.php';
466 $robots_txt = Metasync_Robots_Txt::get_instance();
467
468 $result = $robots_txt->delete_backup($backup_id);
469
470 if ($result) {
471 wp_send_json_success(array('message' => esc_html__('Backup deleted successfully!', 'metasync')));
472 } else {
473 wp_send_json_error(array('message' => esc_html__('Failed to delete backup.', 'metasync')));
474 }
475 }
476
477 public function ajax_restore_robots_backup()
478 {
479 if (!Metasync::current_user_has_plugin_access()) {
480 wp_send_json_error(array('message' => esc_html__('Insufficient permissions', 'metasync')));
481 return;
482 }
483
484 if (!isset($_POST['nonce']) || !wp_verify_nonce($_POST['nonce'], 'metasync_restore_robots_backup')) {
485 wp_send_json_error(array('message' => esc_html__('Security check failed', 'metasync')));
486 return;
487 }
488
489 $backup_id = isset($_POST['backup_id']) ? intval($_POST['backup_id']) : 0;
490
491 if (!$backup_id) {
492 wp_send_json_error(array('message' => esc_html__('Invalid backup ID', 'metasync')));
493 return;
494 }
495
496 require_once plugin_dir_path(dirname(__FILE__)) . 'robots-txt/class-metasync-robots-txt.php';
497 $robots_txt = Metasync_Robots_Txt::get_instance();
498
499 $result = $robots_txt->restore_backup($backup_id);
500
501 if (is_wp_error($result)) {
502 wp_send_json_error(array('message' => $result->get_error_message()));
503 } else {
504 $current_content = $robots_txt->read_robots_file();
505
506 if (is_wp_error($current_content)) {
507 wp_send_json_error(array('message' => $current_content->get_error_message()));
508 } else {
509 wp_send_json_success(array(
510 'message' => esc_html__('robots.txt restored from backup successfully!', 'metasync'),
511 'content' => $current_content
512 ));
513 }
514 }
515 }
516
517 public function ajax_get_robots_backups()
518 {
519 if (!Metasync::current_user_has_plugin_access()) {
520 wp_send_json_error(array('message' => esc_html__('Insufficient permissions', 'metasync')));
521 return;
522 }
523
524 if (!isset($_POST['nonce']) || !wp_verify_nonce($_POST['nonce'], 'metasync_get_robots_backups')) {
525 wp_send_json_error(array('message' => esc_html__('Security check failed', 'metasync')));
526 return;
527 }
528
529 require_once plugin_dir_path(dirname(__FILE__)) . 'robots-txt/class-metasync-robots-txt.php';
530 $robots_txt = Metasync_Robots_Txt::get_instance();
531
532 $backup_per_page = Metasync_Robots_Txt_Database::BACKUPS_PER_PAGE;
533 $backup_total = $robots_txt->get_backup_count();
534 $backup_total_pages = max(1, (int) ceil($backup_total / $backup_per_page));
535
536 $backup_page = isset($_POST['page']) ? max(1, intval($_POST['page'])) : 1;
537 if ($backup_page > $backup_total_pages) {
538 $backup_page = $backup_total_pages;
539 }
540
541 $offset = ($backup_page - 1) * $backup_per_page;
542 $backups = $robots_txt->get_backup_history($backup_per_page, $offset);
543
544 // Render the shared list + pagination partial to a string.
545 ob_start();
546 require plugin_dir_path(dirname(__FILE__)) . 'robots-txt/views/backup-history.php';
547 $html = ob_get_clean();
548
549 wp_send_json_success(array(
550 'html' => $html,
551 'total' => $backup_total,
552 'page' => $backup_page,
553 'total_pages' => $backup_total_pages,
554 ));
555 }
556
557 public function ajax_create_redirect_from_404()
558 {
559 if (empty($_POST['nonce']) || !wp_verify_nonce(sanitize_text_field(wp_unslash($_POST['nonce'])), 'metasync_404_redirect')) {
560 wp_die('Security check failed');
561 }
562
563 if (!Metasync::current_user_has_plugin_access()) {
564 wp_die('Insufficient permissions');
565 }
566
567 $uri = sanitize_text_field($_POST['uri']);
568 $redirect_url = sanitize_url($_POST['redirect_url']);
569
570 if (empty($uri) || empty($redirect_url)) {
571 wp_send_json_error('Missing required parameters');
572 }
573
574 require_once plugin_dir_path(dirname(__FILE__)) . '404-monitor/class-metasync-404-monitor-database.php';
575 require_once plugin_dir_path(dirname(__FILE__)) . '404-monitor/class-metasync-404-monitor.php';
576 $db_404 = new Metasync_Error_Monitor_Database();
577 $monitor_404 = new Metasync_Error_Monitor($db_404);
578
579 $result = $monitor_404->create_redirection_from_404($uri, $redirect_url, 'Created from 404 suggestion');
580
581 if ($result) {
582 wp_send_json_success('Redirect created successfully');
583 } else {
584 wp_send_json_error('Failed to create redirect');
585 }
586 }
587
588 /**
589 * Manual "Host Blocking Test" — GET leg.
590 *
591 * The probe itself lives in Metasync_Host_Blocking_Check so the manual button and the
592 * automatic activation/weekly check share one implementation.
593 */
594 public function ajax_test_host_blocking_get()
595 {
596 $this->send_host_blocking_result('GET');
597 }
598
599 /**
600 * Manual "Host Blocking Test" — POST leg.
601 */
602 public function ajax_test_host_blocking_post()
603 {
604 $this->send_host_blocking_result('POST');
605 }
606
607 /**
608 * Shared auth + response wrapper for both manual host blocking legs.
609 *
610 * @param string $method 'GET' or 'POST'.
611 */
612 private function send_host_blocking_result($method)
613 {
614 check_ajax_referer('metasync_nonce', 'nonce');
615 if (!Metasync::current_user_has_plugin_access()) {
616 wp_send_json_error('Insufficient permissions');
617 return;
618 }
619
620 wp_send_json_success(Metasync_Host_Blocking_Check::get_instance()->run_check($method));
621 }
622
623 public function execute_transient_cleanup()
624 {
625 global $wpdb;
626
627 $cleanup_stats = array(
628 'expired_transients' => 0,
629 'plugin_transients' => 0,
630 'rate_limit_transients' => 0,
631 'telemetry_transients' => 0,
632 'start_time' => microtime(true)
633 );
634
635 try {
636 delete_expired_transients(true);
637 $cleanup_stats['expired_transients'] = 'cleaned_by_wordpress';
638
639 $plugin_transients = $wpdb->get_results(
640 "SELECT option_name FROM {$wpdb->options} WHERE option_name LIKE '_transient_metasync_%'",
641 ARRAY_A
642 );
643
644 foreach ($plugin_transients as $transient) {
645 $transient_name = str_replace('_transient_', '', $transient['option_name']);
646 delete_transient($transient_name);
647 $cleanup_stats['plugin_transients']++;
648 }
649
650 $rate_limit_transients = $wpdb->get_results(
651 "SELECT option_name FROM {$wpdb->options} WHERE option_name LIKE '_transient_sa_connect_rate_limit_%'",
652 ARRAY_A
653 );
654
655 foreach ($rate_limit_transients as $transient) {
656 $transient_name = str_replace('_transient_', '', $transient['option_name']);
657 delete_transient($transient_name);
658 $cleanup_stats['rate_limit_transients']++;
659 }
660
661 $telemetry_transients = $wpdb->get_results(
662 "SELECT option_name FROM {$wpdb->options} WHERE option_name LIKE '_transient_metasync_telemetry_%'",
663 ARRAY_A
664 );
665
666 foreach ($telemetry_transients as $transient) {
667 $transient_name = str_replace('_transient_', '', $transient['option_name']);
668 delete_transient($transient_name);
669 $cleanup_stats['telemetry_transients']++;
670 }
671
672 $sa_connect_success_transients = $wpdb->get_results(
673 "SELECT option_name FROM {$wpdb->options} WHERE option_name LIKE '_transient_metasync_sa_connect_success_%'",
674 ARRAY_A
675 );
676
677 foreach ($sa_connect_success_transients as $transient) {
678 $transient_name = str_replace('_transient_', '', $transient['option_name']);
679 delete_transient($transient_name);
680 $cleanup_stats['sa_connect_success_transients'] = ($cleanup_stats['sa_connect_success_transients'] ?? 0) + 1;
681 }
682
683 $cleanup_stats['execution_time'] = round((microtime(true) - $cleanup_stats['start_time']) * 1000, 2);
684 $cleanup_stats['next_run'] = wp_next_scheduled('metasync_cleanup_transients') ?
685 date('Y-m-d H:i:s T', wp_next_scheduled('metasync_cleanup_transients')) : 'N/A';
686
687 error_log('MetaSync: Transient cleanup completed - ' . json_encode($cleanup_stats));
688
689 } catch (Exception $e) {
690 error_log('MetaSync: Transient cleanup failed - ' . $e->getMessage());
691 }
692 }
693
694 public function ajax_submit_issue_report()
695 {
696 try {
697 # Verify nonce
698 if (!isset($_POST['nonce']) || !wp_verify_nonce(sanitize_text_field(wp_unslash($_POST['nonce'])), 'metasync_report_issue')) {
699 wp_send_json_error(array('message' => 'Security verification failed.'));
700 return;
701 }
702
703 # Check user capabilities
704 if (!Metasync::current_user_has_plugin_access()) {
705 wp_send_json_error(array('message' => 'Insufficient permissions.'));
706 return;
707 }
708
709 # Get and validate form data
710 $issue_message = isset($_POST['issue_message']) ? sanitize_textarea_field(wp_unslash($_POST['issue_message'])) : '';
711 $issue_severity = isset($_POST['issue_severity']) ? sanitize_text_field(wp_unslash($_POST['issue_severity'])) : 'warning';
712 $include_user_info = isset($_POST['include_user_info']) && sanitize_text_field(wp_unslash($_POST['include_user_info'])) === 'true';
713
714 # Validate severity level
715 $valid_severity_levels = array('info', 'warning', 'error', 'fatal');
716 if (!in_array($issue_severity, $valid_severity_levels, true)) {
717 $issue_severity = 'warning';
718 }
719
720 # Validate message length
721 if (empty($issue_message) || strlen($issue_message) < 10) {
722 wp_send_json_error(array('message' => 'Please provide a more detailed description (at least 10 characters).'));
723 return;
724 }
725 if (strlen($issue_message) > 1000) {
726 wp_send_json_error(array('message' => 'Message is too long. Please limit to 1000 characters.'));
727 return;
728 }
729
730 # Handle file upload if present
731 $attachment = null;
732 if (!empty($_FILES['issue_attachment']['tmp_name'])) {
733 # Validate file type using server-side MIME detection (not client-supplied type)
734 $tmp_name = $_FILES['issue_attachment']['tmp_name'];
735 $filename = sanitize_file_name($_FILES['issue_attachment']['name']);
736 $file_info = wp_check_filetype_and_ext($tmp_name, $filename);
737 if (!$file_info['ext']) {
738 wp_send_json_error(array('message' => 'Invalid file type. Please upload a JPEG, PNG, GIF, or WebP image.'));
739 return;
740 }
741 $allowed_extensions = array('jpg', 'jpeg', 'png', 'gif', 'webp');
742 if (!in_array($file_info['ext'], $allowed_extensions, true)) {
743 wp_send_json_error(array('message' => 'Invalid file type. Please upload a JPEG, PNG, GIF, or WebP image.'));
744 return;
745 }
746 $file_type = $file_info['type'];
747
748 # Validate file size (5MB max)
749 $max_size = 5 * 1024 * 1024; // 5MB
750 if ($_FILES['issue_attachment']['size'] > $max_size) {
751 wp_send_json_error(array('message' => 'File size exceeds 5MB. Please choose a smaller file.'));
752 return;
753 }
754
755 # Read file contents
756 $file_contents = file_get_contents($tmp_name);
757 if ($file_contents !== false) {
758 $attachment = array(
759 'filename' => $filename,
760 'data' => $file_contents,
761 'content_type' => $file_type
762 );
763 }
764 }
765
766 # Get general options (same way as used throughout the plugin)
767 $general_options = Metasync::get_option('general');
768 if (!is_array($general_options)) {
769 $general_options = array();
770 }
771
772 $project_uuid = isset($general_options['otto_pixel_uuid']) ? sanitize_text_field($general_options['otto_pixel_uuid']) : '';
773
774 # Always use standardized title format for Sentry prioritization
775 $issue_title = !empty($project_uuid) ? 'Client Report ' . $project_uuid : 'Client Report (UUID Not Configured)';
776
777 # Collect system information with error handling
778 $active_plugins = get_option('active_plugins');
779 $plugin_count = is_array($active_plugins) ? count($active_plugins) : 0;
780
781 $active_theme = wp_get_theme();
782 $theme_name = is_object($active_theme) ? $active_theme->get('Name') : get_template();
783
784 $system_context = array(
785 'report_type' => 'manual_client_report',
786 'website_url' => esc_url_raw(home_url()),
787 'site_title' => sanitize_text_field(get_bloginfo('name')),
788 'admin_email' => sanitize_email(get_bloginfo('admin_email')),
789 'plugin_version' => defined('METASYNC_VERSION') ? METASYNC_VERSION : '1.0.0',
790 'plugin_name' => 'Search Engine Labs SEO (MetaSync)',
791 'wordpress_version' => get_bloginfo('version'),
792 'php_version' => PHP_VERSION,
793 'active_theme' => $theme_name,
794 'memory_limit' => ini_get('memory_limit'),
795 'multisite' => is_multisite(),
796 'project_uuid' => $project_uuid,
797 'active_plugins' => $plugin_count,
798 'report_timestamp' => current_time('mysql'),
799 'severity_level' => $issue_severity
800 );
801
802 # Add user information if requested
803 if ($include_user_info) {
804 $current_user = wp_get_current_user();
805 if ($current_user && $current_user->ID > 0) {
806 $system_context['reporter'] = array(
807 'username' => sanitize_user($current_user->user_login),
808 'email' => sanitize_email($current_user->user_email),
809 'display_name' => sanitize_text_field($current_user->display_name),
810 'roles' => is_array($current_user->roles) ? $current_user->roles : array()
811 );
812 }
813 }
814
815 # Send to Sentry using User Feedback API
816 $sent_to_sentry = false;
817
818 # Check if Sentry feedback function exists
819 if (!function_exists('metasync_sentry_capture_feedback')) {
820 # Log warning if function doesn't exist
821 if (defined('WP_DEBUG') && WP_DEBUG) {
822 error_log('MetaSync: Sentry feedback function not available for report submission.');
823 }
824 } else {
825 $feedback_data = array(
826 'message' => $issue_message,
827 'severity' => $issue_severity
828 );
829
830 # Add user information if requested
831 if ($include_user_info) {
832 $current_user = wp_get_current_user();
833 if ($current_user && $current_user->ID > 0) {
834 $feedback_data['name'] = sanitize_text_field($current_user->display_name);
835 $feedback_data['email'] = sanitize_email($current_user->user_email);
836 }
837 }
838
839 $sent_to_sentry = metasync_sentry_capture_feedback($feedback_data, $attachment);
840 }
841
842 if ($sent_to_sentry) {
843 wp_send_json_success(array(
844 'message' => 'Report submitted successfully! Our team will review it shortly.',
845 'project_uuid' => $project_uuid,
846 'report_title' => esc_html($issue_title)
847 ));
848 } else {
849 # Fallback: Log locally if Sentry fails or is unavailable
850 if (defined('WP_DEBUG') && WP_DEBUG) {
851 error_log(sprintf(
852 'MetaSync Client Report (Fallback): UUID: %s | Title: %s | Message: %s | Severity: %s',
853 $project_uuid,
854 $issue_title,
855 $issue_message,
856 $issue_severity
857 ));
858 }
859
860 wp_send_json_success(array(
861 'message' => 'Report logged locally. Note: Remote reporting may be unavailable.',
862 'project_uuid' => $project_uuid,
863 'report_title' => esc_html($issue_title),
864 'fallback' => true
865 ));
866 }
867
868 } catch (Exception $e) {
869 # Log the error securely (only in debug mode)
870 if (defined('WP_DEBUG') && WP_DEBUG) {
871 error_log(sprintf(
872 'MetaSync Report Submission Error: %s in %s on line %d',
873 $e->getMessage(),
874 $e->getFile(),
875 $e->getLine()
876 ));
877 }
878
879 # Send generic error message to client
880 wp_send_json_error(array('message' => 'Failed to submit report. Please try again later.'));
881 }
882 }
883
884 public function ajax_recover_password()
885 {
886 $rate_owner = '';
887 try {
888 if (!isset($_POST['nonce']) || !wp_verify_nonce(sanitize_text_field(wp_unslash($_POST['nonce'])), 'metasync_recover_password_nonce')) {
889 wp_send_json_error(array('message' => 'Security verification failed.'));
890 return;
891 }
892
893 if (!current_user_can('manage_options')) {
894 wp_send_json_error(array('message' => 'You do not have permission to request a password recovery.'), 403);
895 return;
896 }
897
898 if (!$this->acquire_recovery_lock($rate_owner)) {
899 wp_send_json_error(array('message' => 'Too many recovery requests. Please try again later.'), 429);
900 return;
901 }
902 register_shutdown_function(function () use (&$rate_owner) {
903 if ($rate_owner !== '') {
904 Metasync_Admin_Ajax::instance()->release_recovery_lock($rate_owner);
905 }
906 });
907 $this->refresh_recovery_state_cache();
908
909 $whitelabel_settings = Metasync::get_whitelabel_settings();
910 $recovery_email = $whitelabel_settings['recovery_email'] ?? '';
911
912 $stored_password = $whitelabel_settings['settings_password'] ?? '';
913 if (!is_string($stored_password) || $stored_password === '') {
914 $this->release_recovery_lock($rate_owner);
915 $rate_owner = '';
916 wp_send_json_error(array('message' => 'No password is configured for recovery.'));
917 return;
918 }
919
920 if (empty($recovery_email) || !is_email($recovery_email)) {
921 $this->release_recovery_lock($rate_owner);
922 $rate_owner = '';
923 wp_send_json_error(array('message' => 'No valid recovery email is configured. Please contact your administrator.'));
924 return;
925 }
926
927 // Bound recovery requests per user and per site to limit abuse.
928 $user_id = get_current_user_id();
929 $user_rate_key = 'metasync_pw_recovery_user_' . $user_id;
930 $site_rate_key = 'metasync_pw_recovery_site';
931 $this->refresh_recovery_state_cache($user_rate_key);
932 $this->refresh_recovery_state_cache($site_rate_key);
933 $user_attempts = (int) get_transient($user_rate_key);
934 $site_attempts = (int) get_transient($site_rate_key);
935 if ($user_attempts >= 3 || $site_attempts >= 10) {
936 $this->release_recovery_lock($rate_owner);
937 wp_send_json_error(array('message' => 'Too many recovery requests. Please try again later.'), 429);
938 return;
939 }
940
941 if (!set_transient($user_rate_key, $user_attempts + 1, HOUR_IN_SECONDS) || !set_transient($site_rate_key, $site_attempts + 1, HOUR_IN_SECONDS)) {
942 if ($user_attempts > 0) set_transient($user_rate_key, $user_attempts, HOUR_IN_SECONDS);
943 else delete_transient($user_rate_key);
944 if ($site_attempts > 0) set_transient($site_rate_key, $site_attempts, HOUR_IN_SECONDS);
945 else delete_transient($site_rate_key);
946 $this->release_recovery_lock($rate_owner);
947 wp_send_json_error(array('message' => 'Unable to process recovery request. Please try again later.'));
948 return;
949 }
950
951 // Keep only a hash in the database; the raw token is sent once by email.
952 $token = bin2hex(random_bytes(32));
953 $token_hash = hash('sha256', $token);
954 $reset_key = 'metasync_pw_reset_' . $token_hash;
955 if (!set_transient($reset_key, array('created_by' => $user_id), 30 * MINUTE_IN_SECONDS)) {
956 if ($user_attempts > 0) set_transient($user_rate_key, $user_attempts, HOUR_IN_SECONDS);
957 else delete_transient($user_rate_key);
958 if ($site_attempts > 0) set_transient($site_rate_key, $site_attempts, HOUR_IN_SECONDS);
959 else delete_transient($site_rate_key);
960 $this->release_recovery_lock($rate_owner);
961 $rate_owner = '';
962 wp_send_json_error(array('message' => 'Unable to create a recovery request. Please try again later.'));
963 return;
964 }
965 $this->release_recovery_lock($rate_owner);
966 $rate_owner = '';
967
968 $site_name = get_bloginfo('name');
969 $site_url = home_url();
970 $plugin_name = Metasync::get_effective_plugin_name('');
971 $settings_url = admin_url('admin.php?page=' . Metasync_Admin::$page_slug . '&tab=whitelabel&metasync_password_reset=' . rawurlencode($token));
972 $to = $recovery_email;
973
974 $subject = sprintf('[%s] Settings Password Recovery', $site_name);
975
976 $message = sprintf(
977 "Hello,\n\n" .
978 "A password recovery request was made for the %s settings on %s.\n\n" .
979 "Use this one-time link to choose a new Settings Password (valid for 30 minutes):\n%s\n\n" .
980 "If you did not request this password recovery, please secure your WordPress admin account immediately.\n\n" .
981 "---\n" .
982 "This is an automated message from %s\n%s",
983 $plugin_name,
984 $site_name,
985 $settings_url,
986 $site_name,
987 $site_url
988 );
989
990 $from_name = !empty($whitelabel_settings['company_name'])
991 ? $whitelabel_settings['company_name']
992 : $site_name;
993
994 $headers = array(
995 'Content-Type: text/plain; charset=UTF-8',
996 sprintf('From: %s <%s>', $from_name, get_option('admin_email'))
997 );
998
999 $sent = wp_mail($to, $subject, $message, $headers);
1000
1001 if ($sent) {
1002 wp_send_json_success(array(
1003 'message' => sprintf('Password recovery email sent to %s', esc_html($recovery_email))
1004 ));
1005 } else {
1006 wp_send_json_error(array('message' => 'Failed to send recovery email. Please check your email configuration or contact your administrator.'));
1007 }
1008
1009 } catch (Exception $e) {
1010 // Always release the bounded issuance lock if an unexpected
1011 // exception occurs after it was acquired.
1012 if ($rate_owner !== '') {
1013 $this->release_recovery_lock($rate_owner);
1014 }
1015 if (defined('WP_DEBUG') && WP_DEBUG) {
1016 error_log(sprintf(
1017 'MetaSync Password Recovery Error: %s in %s on line %d',
1018 $e->getMessage(),
1019 $e->getFile(),
1020 $e->getLine()
1021 ));
1022 }
1023
1024 wp_send_json_error(array('message' => 'An error occurred while processing your request. Please try again later.'));
1025 }
1026 }
1027
1028 /** Render and process the administrator-only password reset screen. */
1029 public function render_password_reset_page()
1030 {
1031 if (!current_user_can('manage_options')) {
1032 wp_die('You do not have permission to reset this password.', '', array('response' => 403));
1033 }
1034
1035 $token = isset($_REQUEST['metasync_password_reset']) ? (string) wp_unslash($_REQUEST['metasync_password_reset']) : '';
1036 $token_hash = ($token !== '' && preg_match('/^[a-f0-9]{64}$/', $token)) ? hash('sha256', $token) : '';
1037 $generation = (int) get_option('metasync_pw_reset_generation', 0);
1038 $transient_key = $token_hash !== '' ? 'metasync_pw_reset_' . $token_hash : '';
1039 $error = '';
1040 $success = false;
1041
1042 if ($_SERVER['REQUEST_METHOD'] === 'POST') {
1043 if (!isset($_POST['metasync_reset_nonce']) || !wp_verify_nonce(sanitize_text_field(wp_unslash($_POST['metasync_reset_nonce'])), 'metasync_reset_password')) {
1044 $error = 'Security verification failed.';
1045 } elseif ($token === '' || false === get_transient($transient_key)) {
1046 $error = 'This recovery link is invalid or has expired.';
1047 } else {
1048 $password = isset($_POST['new_password']) ? (string) wp_unslash($_POST['new_password']) : '';
1049 $confirm = isset($_POST['confirm_password']) ? (string) wp_unslash($_POST['confirm_password']) : '';
1050 if (strlen($password) < 8) {
1051 $error = 'Password must be at least 8 characters long.';
1052 } elseif (!hash_equals($password, $confirm)) {
1053 $error = 'Passwords do not match.';
1054 } elseif (strpos($password, 'enc_v1:') === 0) {
1055 $error = 'Unable to save this password. Please choose a different password.';
1056 } else {
1057 // add_option is an atomic insert in WordPress; it closes the
1058 // race where two requests attempt to redeem the same token.
1059 $lock_key = '';
1060 if (!$this->acquire_recovery_lock($lock_key)) {
1061 $error = 'Another recovery request is being processed. Please wait a moment and try again.';
1062 } else {
1063 register_shutdown_function(function () use (&$lock_key) {
1064 if ($lock_key !== '') {
1065 Metasync_Admin_Ajax::instance()->release_recovery_lock($lock_key);
1066 }
1067 });
1068 $this->refresh_recovery_state_cache($transient_key);
1069 // Refresh the settings version while holding the shared
1070 // lock. Token keys are independent so issuing or using
1071 // one link does not invalidate another unexpired link.
1072 $generation = (int) get_option('metasync_pw_reset_generation', 0);
1073 $transient_key = $token_hash !== '' ? 'metasync_pw_reset_' . $token_hash : '';
1074 // Re-check after claiming the lock. Consume only after
1075 // every required write succeeds, allowing a retry after
1076 // a temporary database or revocation failure.
1077 if (false === get_transient($transient_key)) {
1078 $error = 'This recovery link is invalid or has already been used.';
1079 $this->release_recovery_lock($lock_key);
1080 $lock_key = '';
1081 echo '<div class="wrap"><h1>Reset Settings Password</h1><div class="notice notice-error"><p>' . esc_html($error) . '</p></div></div>';
1082 return;
1083 }
1084 {
1085 $encrypted = Metasync::encrypt_secret($password);
1086 if (!Metasync::is_encrypted_secret($encrypted) || $encrypted === $password) {
1087 $error = 'Unable to securely save this password. Please try again later.';
1088 } elseif (!update_option('metasync_pw_reset_generation', $generation + 1, false)) {
1089 $error = 'Unable to prepare the password reset. Please try again later.';
1090 /** @phpstan-ignore-next-line booleanNot.alwaysFalse */
1091 } elseif (!Metasync_Auth_Manager::revoke_all_access('whitelabel')) {
1092 $error = 'Unable to revoke existing access. Please try again later.';
1093 } elseif (!$this->persist_recovery_password($encrypted)) {
1094 $error = 'Unable to save the new password. Please try again later.';
1095 /** @phpstan-ignore-next-line booleanNot.alwaysFalse */
1096 } elseif (!Metasync_Auth_Manager::revoke_all_access('whitelabel')) {
1097 $error = 'Unable to finalize access revocation. Please try again later.';
1098 } elseif (!delete_transient($transient_key)) {
1099 $error = 'Unable to consume this recovery link. Please try again later.';
1100 } else {
1101 $success = true;
1102 }
1103 }
1104 $this->release_recovery_lock($lock_key);
1105 $lock_key = '';
1106 }
1107 }
1108 }
1109 }
1110
1111 echo '<div class="wrap"><h1>Reset Settings Password</h1>';
1112 if ($success) {
1113 echo '<div class="notice notice-success"><p>Password reset successfully. You can now return to the White Label settings.</p></div>';
1114 } else {
1115 if ($error !== '') {
1116 echo '<div class="notice notice-error"><p>' . esc_html($error) . '</p></div>';
1117 }
1118 if ($token === '' || false === get_transient($transient_key)) {
1119 if ($error === '') {
1120 echo '<div class="notice notice-error"><p>This recovery link is invalid or has expired.</p></div>';
1121 }
1122 } else {
1123 echo '<form method="post" style="max-width:420px">';
1124 wp_nonce_field('metasync_reset_password', 'metasync_reset_nonce');
1125 echo '<p><label for="new_password">New password</label><br><input class="regular-text" type="password" id="new_password" name="new_password" minlength="8" required autocomplete="new-password"></p>';
1126 echo '<p><label for="confirm_password">Confirm new password</label><br><input class="regular-text" type="password" id="confirm_password" name="confirm_password" minlength="8" required autocomplete="new-password"></p>';
1127 echo '<p><button class="button button-primary" type="submit">Save new password</button></p></form>';
1128 }
1129 }
1130 echo '</div>';
1131 }
1132
1133 public function ajax_save_theme()
1134 {
1135 try {
1136 # Verify nonce for security
1137 if (!isset($_POST['_ajax_nonce']) || !wp_verify_nonce(sanitize_text_field(wp_unslash($_POST['_ajax_nonce'])), 'metasync_theme_nonce')) {
1138 wp_send_json_error(array('message' => 'Security verification failed.'));
1139 }
1140
1141 # Check user capabilities
1142 if (!Metasync::current_user_has_plugin_access()) {
1143 wp_send_json_error(array('message' => 'Insufficient permissions.'));
1144 }
1145
1146 # Get and validate theme value
1147 $theme = isset($_POST['theme']) ? sanitize_text_field(wp_unslash($_POST['theme'])) : '';
1148
1149 # Validate theme is either 'light' or 'dark'
1150 if (!in_array($theme, array('light', 'dark'), true)) {
1151 wp_send_json_error(array('message' => 'Invalid theme value.'));
1152 }
1153
1154 # Save theme preference to WordPress options
1155 update_option('metasync_theme', $theme, true);
1156
1157 # Send success response
1158 wp_send_json_success(array(
1159 'message' => 'Theme preference saved successfully.',
1160 'theme' => $theme
1161 ));
1162
1163 } catch (Exception $e) {
1164 # Log error if debug is enabled
1165 if (defined('WP_DEBUG') && WP_DEBUG) {
1166 error_log('MetaSync Theme Save Error: ' . $e->getMessage());
1167 }
1168
1169 wp_send_json_error(array('message' => 'Failed to save theme preference.'));
1170 }
1171 }
1172
1173 public function ajax_track_one_click_activation()
1174 {
1175 check_ajax_referer('metasync_nonce', 'nonce');
1176 # Check user capabilities
1177 if (!Metasync::current_user_has_plugin_access()) {
1178 wp_send_json_error(['message' => 'Insufficient permissions']);
1179 }
1180
1181 # Get parameters
1182 $auth_method = isset($_POST['auth_method']) ? sanitize_text_field(wp_unslash($_POST['auth_method'])) : 'searchatlas_connect';
1183 $is_reconnection = isset($_POST['is_reconnection']) ? filter_var($_POST['is_reconnection'], FILTER_VALIDATE_BOOLEAN) : false;
1184
1185 # Track the event in GA4
1186 try {
1187 Metasync_GA4::get_instance()->track_one_click_activation($auth_method, $is_reconnection);
1188
1189 wp_send_json_success([
1190 'message' => '1-click activation tracked successfully',
1191 'auth_method' => $auth_method,
1192 'is_reconnection' => $is_reconnection
1193 ]);
1194 } catch (Exception $e) {
1195 # Still return success to avoid breaking the auth flow
1196 wp_send_json_success([
1197 'message' => 'Authentication successful'
1198 ]);
1199 }
1200 }
1201
1202 public function handle_export_whitelabel_settings()
1203 {
1204 # Check user capabilities
1205 if (!Metasync::current_user_has_plugin_access()) {
1206 wp_die('Insufficient permissions');
1207 }
1208
1209 # Verify nonce for security (check both GET and POST)
1210 $nonce = '';
1211 if (isset($_POST['_wpnonce'])) {
1212 $nonce = sanitize_text_field(wp_unslash($_POST['_wpnonce']));
1213 } elseif (isset($_GET['_wpnonce'])) {
1214 $nonce = sanitize_text_field(wp_unslash($_GET['_wpnonce']));
1215 }
1216
1217 if (empty($nonce) || !wp_verify_nonce($nonce, 'metasync_export_whitelabel')) {
1218 wp_die('Security verification failed.');
1219 }
1220
1221 try {
1222 # Get all whitelabel settings
1223 $whitelabel_settings = Metasync::get_whitelabel_settings();
1224
1225 # Export the settings password as plaintext (not the encrypted blob):
1226 # the encryption key derives from this site's salts, so the blob
1227 # would be undecryptable after import on a different site. The
1228 # importer re-encrypts it at rest with the destination site's salts.
1229 if (!empty($whitelabel_settings['settings_password'])) {
1230 $whitelabel_settings['settings_password'] = Metasync::get_whitelabel_password();
1231 }
1232
1233 # Get general settings that relate to whitelabel
1234 $general_settings = Metasync::get_option('general');
1235 $whitelabel_related_general = array();
1236
1237 # Include ALL whitelabel-related general settings
1238 $whitelabel_keys = array(
1239 'white_label_plugin_name',
1240 'white_label_plugin_description',
1241 'white_label_plugin_author',
1242 'white_label_plugin_author_uri',
1243 'white_label_plugin_uri',
1244 'white_label_plugin_menu_slug',
1245 'white_label_plugin_menu_icon',
1246 'whitelabel_otto_name'
1247 );
1248
1249 foreach ($whitelabel_keys as $key) {
1250 if (isset($general_settings[$key])) {
1251 $whitelabel_related_general[$key] = $general_settings[$key];
1252 }
1253 }
1254
1255 # Bundle the menu icon file so it survives import on a different site.
1256 # If the icon is a local URL (media library), replace it with a special
1257 # marker and include the actual file in the ZIP under the plugin folder.
1258 $bundled_icon_filename = null;
1259 $icon_url = $whitelabel_related_general['white_label_plugin_menu_icon'] ?? '';
1260 if (!empty($icon_url) && filter_var($icon_url, FILTER_VALIDATE_URL)) {
1261 $site_url = trailingslashit(site_url());
1262 if (strpos($icon_url, $site_url) === 0) {
1263 // Resolve URL to an absolute filesystem path
1264 $relative_path = str_replace($site_url, ABSPATH, $icon_url);
1265 $icon_abs_path = realpath($relative_path);
1266 if ($icon_abs_path && file_exists($icon_abs_path)) {
1267 $ext = strtolower(pathinfo($icon_abs_path, PATHINFO_EXTENSION));
1268 $bundled_icon_filename = 'whitelabel-icon.' . $ext;
1269 // Replace the URL with a marker so the importer knows to restore it
1270 $whitelabel_related_general['white_label_plugin_menu_icon'] = '__bundled_icon__' . $ext;
1271 }
1272 }
1273 }
1274
1275 # Prepare export data
1276 $export_data = array(
1277 'version' => '1.0',
1278 'exported_at' => current_time('mysql'),
1279 'whitelabel_settings' => $whitelabel_settings,
1280 'general_settings' => $whitelabel_related_general
1281 );
1282
1283 # Convert to JSON
1284 $json_data = wp_json_encode($export_data, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES);
1285
1286 if ($json_data === false) {
1287 wp_die('Failed to encode settings to JSON.');
1288 }
1289
1290 # Check if ZipArchive is available
1291 if (!class_exists('ZipArchive')) {
1292 wp_die('ZipArchive class is not available. Please enable PHP zip extension.');
1293 }
1294
1295 # Get plugin directory path (remove trailing slash for basename)
1296 $plugin_dir = rtrim(plugin_dir_path(dirname(__FILE__)), '/');
1297 $plugin_folder_name = basename($plugin_dir);
1298
1299 # Create temporary directory for zip file
1300 $upload_dir = wp_upload_dir();
1301 $temp_dir = $upload_dir['basedir'] . '/metasync-export-temp';
1302
1303 # Create temp directory if it doesn't exist
1304 if (!file_exists($temp_dir)) {
1305 wp_mkdir_p($temp_dir);
1306 }
1307
1308 # Generate unique filename
1309 $timestamp = date('Y-m-d_H-i-s');
1310 $zip_filename = 'metasync-whitelabel-plugin-' . $timestamp . '.zip';
1311 $json_filename = 'whitelabel-settings.json';
1312 $zip_path = $temp_dir . '/' . $zip_filename;
1313
1314 # Create zip file
1315 $zip = new ZipArchive();
1316 if ($zip->open($zip_path, ZipArchive::CREATE | ZipArchive::OVERWRITE) !== true) {
1317 wp_die('Failed to create zip file.');
1318 }
1319
1320 # Add whitelabel settings JSON file to zip (inside plugin folder)
1321 $zip->addFromString($plugin_folder_name . '/' . $json_filename, $json_data);
1322
1323 # Bundle icon file if one was detected
1324 if ($bundled_icon_filename !== null && isset($icon_abs_path) && file_exists($icon_abs_path)) {
1325 $zip->addFile($icon_abs_path, $plugin_folder_name . '/' . $bundled_icon_filename);
1326 }
1327
1328
1329 # Files and directories to exclude from the zip
1330 $exclude_patterns = array(
1331 '.git',
1332 '.gitignore',
1333 '.gitattributes',
1334 'node_modules',
1335 '.DS_Store',
1336 'Thumbs.db',
1337 '.idea',
1338 '.vscode',
1339 'composer.lock',
1340 'package-lock.json',
1341 'yarn.lock',
1342 '.env',
1343 '.env.local',
1344 'docker-compose.yml',
1345 'Dockerfile',
1346 'Makefile',
1347 'renovate.json',
1348 'sonar-project.properties',
1349 'CODEOWNERS',
1350 'metasync-export-temp',
1351 'whitelabel-settings.json'
1352 );
1353
1354 # Recursively add plugin files to zip (with plugin folder as root in zip)
1355 self::add_directory_to_zip($zip, $plugin_dir . '/', $plugin_folder_name . '/', $exclude_patterns);
1356
1357 $zip->close();
1358
1359 # Check if file was created
1360 if (!file_exists($zip_path)) {
1361 wp_die('Zip file was not created successfully.');
1362 }
1363
1364 # Set headers for file download
1365 header('Content-Type: application/zip');
1366 header('Content-Disposition: attachment; filename="' . $zip_filename . '"');
1367 header('Content-Length: ' . filesize($zip_path));
1368 header('Pragma: no-cache');
1369 header('Expires: 0');
1370
1371 # Output file and clean up
1372 readfile($zip_path);
1373 unlink($zip_path);
1374
1375 # Clean up temp directory if empty
1376 if (is_dir($temp_dir) && count(scandir($temp_dir)) == 2) {
1377 rmdir($temp_dir);
1378 }
1379
1380 # Exit to prevent WordPress from adding anything to the response
1381 exit;
1382
1383 } catch (Exception $e) {
1384 # Log error if debug is enabled
1385 if (defined('WP_DEBUG') && WP_DEBUG) {
1386 error_log('MetaSync Whitelabel Export Error: ' . $e->getMessage());
1387 }
1388
1389 wp_die('Failed to export whitelabel settings: ' . $e->getMessage());
1390 }
1391 }
1392
1393 private static function add_directory_to_zip($zip, $dir, $zip_path = '', $exclude_patterns = array())
1394 {
1395 if (!is_dir($dir)) {
1396 return;
1397 }
1398
1399 $files = scandir($dir);
1400
1401 foreach ($files as $file) {
1402 if ($file === '.' || $file === '..') {
1403 continue;
1404 }
1405
1406 $file_path = $dir . $file;
1407 $zip_file_path = $zip_path . $file;
1408
1409 $should_exclude = false;
1410 foreach ($exclude_patterns as $pattern) {
1411 if (strpos($file, $pattern) !== false || strpos($file_path, $pattern) !== false) {
1412 $should_exclude = true;
1413 break;
1414 }
1415 }
1416
1417 if ($should_exclude) {
1418 continue;
1419 }
1420
1421 if (is_dir($file_path)) {
1422 $zip->addEmptyDir($zip_file_path);
1423 self::add_directory_to_zip($zip, $file_path . '/', $zip_file_path . '/', $exclude_patterns);
1424 } else {
1425 if (file_exists($file_path) && is_readable($file_path)) {
1426 $zip->addFile($file_path, $zip_file_path);
1427 }
1428 }
1429 }
1430 }
1431
1432 public function render_html_pages_dashboard_widget()
1433 {
1434 global $wpdb;
1435
1436 $query = "
1437 SELECT p.ID, p.post_title, p.post_modified, p.post_type
1438 FROM {$wpdb->posts} p
1439 INNER JOIN {$wpdb->postmeta} pm ON p.ID = pm.post_id
1440 WHERE p.post_status = 'publish'
1441 AND p.post_type IN ('post', 'page')
1442 AND (pm.meta_key = '_metasync_raw_html_enabled' OR pm.meta_key = '_metasync_custom_css')
1443 GROUP BY p.ID
1444 ORDER BY p.post_modified DESC
1445 LIMIT 10
1446 ";
1447
1448 $html_pages = $wpdb->get_results($query);
1449 $total_count = count($html_pages);
1450
1451 $label = $this->get_html_source_label();
1452
1453 if (empty($html_pages)) {
1454 echo '<div class="metasync-dashboard-widget-empty">';
1455 echo '<span class="dashicons dashicons-admin-page" style="font-size: 48px; opacity: 0.3; display: block; margin: 20px auto;"></span>';
1456 echo '<p style="text-align: center; color: #666;">';
1457 echo sprintf(__('No pages created with %s yet.', 'metasync'), '<strong>' . esc_html($label) . '</strong>');
1458 echo '</p>';
1459 echo '<p style="text-align: center;">';
1460 echo '<a href="' . admin_url('admin.php?page=' . Metasync_Admin::$page_slug) . '" class="button button-primary">';
1461 echo __('Get Started', 'metasync');
1462 echo '</a>';
1463 echo '</p>';
1464 echo '</div>';
1465 return;
1466 }
1467
1468 echo '<div class="metasync-dashboard-widget">';
1469
1470 echo '<div class="metasync-widget-stats">';
1471 echo '<div class="metasync-stat-box">';
1472 echo '<span class="metasync-stat-number">' . $total_count . '</span>';
1473 echo '<span class="metasync-stat-label">' . __('AI-Generated Pages', 'metasync') . '</span>';
1474 echo '</div>';
1475 echo '</div>';
1476
1477 echo '<div class="metasync-widget-list">';
1478 echo '<h4>' . __('Recent Pages', 'metasync') . '</h4>';
1479 echo '<ul>';
1480
1481 foreach ($html_pages as $page) {
1482 $edit_link = get_edit_post_link($page->ID);
1483 $view_link = get_permalink($page->ID);
1484 $time_ago = human_time_diff(strtotime($page->post_modified), current_time('timestamp'));
1485
1486 echo '<li class="metasync-widget-page-item">';
1487 echo '<span class="metasync-page-icon">⚡</span>';
1488 echo '<div class="metasync-page-details">';
1489 echo '<a href="' . esc_url($edit_link) . '" class="metasync-page-title">';
1490 echo esc_html($page->post_title ?: __('(no title)', 'metasync'));
1491 echo '</a>';
1492 echo '<span class="metasync-page-meta">';
1493 echo sprintf(__('Updated %s ago', 'metasync'), $time_ago);
1494 echo '';
1495 echo '<a href="' . esc_url($view_link) . '" target="_blank">' . __('View', 'metasync') . '</a>';
1496 echo '</span>';
1497 echo '</div>';
1498 echo '</li>';
1499 }
1500
1501 echo '</ul>';
1502 echo '</div>';
1503
1504 echo '<div class="metasync-widget-footer">';
1505 echo '<a href="' . admin_url('edit.php?post_type=page') . '">';
1506 echo __('View All Pages', 'metasync') . '';
1507 echo '</a>';
1508 echo '</div>';
1509
1510 echo '</div>';
1511 }
1512
1513 private function get_html_source_label()
1514 {
1515 $whitelabel_company = Metasync::get_whitelabel_company_name();
1516 if (!empty($whitelabel_company)) {
1517 return $whitelabel_company . ' AI';
1518 }
1519
1520 return Metasync::get_effective_plugin_name() . ' AI';
1521 }
1522 }
1523