PluginProbe
Search Atlas SEO – OTTO AI SEO Automation for WordPress / 2.7.1
Search Atlas SEO – OTTO AI SEO Automation for WordPress v2.7.1
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-admin-ajax.php

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

1,520 lines 68.0 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( // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- atomic compare-and-swap on option rows — the WHERE-guarded UPDATE/DELETE is the lock primitive
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( // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- atomic compare-and-swap on option rows — the WHERE-guarded UPDATE/DELETE is the lock primitive
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); // phpcs:ignore Squiz.PHP.DiscouragedFunctions.Discouraged -- runtime PHP setting with no WordPress API
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(wp_unslash($_POST['type'])) : '';
143 $plugin = isset($_POST['plugin']) ? sanitize_text_field(wp_unslash($_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); // phpcs:ignore Squiz.PHP.DiscouragedFunctions.Discouraged -- runtime PHP setting with no WordPress API
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(wp_unslash($_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(wp_unslash($_POST['uri']));
568 $redirect_url = sanitize_url(wp_unslash($_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( // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- transient enumeration/cleanup on the options table — no API to scan or bulk-delete transient rows
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( // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- transient enumeration/cleanup on the options table — no API to scan or bulk-delete transient rows
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( // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- transient enumeration/cleanup on the options table — no API to scan or bulk-delete transient rows
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( // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- transient enumeration/cleanup on the options table — no API to scan or bulk-delete transient rows
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 gmdate('Y-m-d H:i:s T', wp_next_scheduled('metasync_cleanup_transients')) : 'N/A';
686
687 } catch (Exception $e) {
688 error_log('MetaSync: Transient cleanup failed - ' . $e->getMessage()); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- genuine failure path, bounded, no secrets
689 }
690 }
691
692 public function ajax_submit_issue_report()
693 {
694 try {
695 # Verify nonce
696 if (!isset($_POST['nonce']) || !wp_verify_nonce(sanitize_text_field(wp_unslash($_POST['nonce'])), 'metasync_report_issue')) {
697 wp_send_json_error(array('message' => 'Security verification failed.'));
698 return;
699 }
700
701 # Check user capabilities
702 if (!Metasync::current_user_has_plugin_access()) {
703 wp_send_json_error(array('message' => 'Insufficient permissions.'));
704 return;
705 }
706
707 # Get and validate form data
708 $issue_message = isset($_POST['issue_message']) ? sanitize_textarea_field(wp_unslash($_POST['issue_message'])) : '';
709 $issue_severity = isset($_POST['issue_severity']) ? sanitize_text_field(wp_unslash($_POST['issue_severity'])) : 'warning';
710 $include_user_info = isset($_POST['include_user_info']) && sanitize_text_field(wp_unslash($_POST['include_user_info'])) === 'true';
711
712 # Validate severity level
713 $valid_severity_levels = array('info', 'warning', 'error', 'fatal');
714 if (!in_array($issue_severity, $valid_severity_levels, true)) {
715 $issue_severity = 'warning';
716 }
717
718 # Validate message length
719 if (empty($issue_message) || strlen($issue_message) < 10) {
720 wp_send_json_error(array('message' => 'Please provide a more detailed description (at least 10 characters).'));
721 return;
722 }
723 if (strlen($issue_message) > 1000) {
724 wp_send_json_error(array('message' => 'Message is too long. Please limit to 1000 characters.'));
725 return;
726 }
727
728 # Handle file upload if present
729 $attachment = null;
730 if (!empty($_FILES['issue_attachment']['tmp_name'])) {
731 # Validate file type using server-side MIME detection (not client-supplied type)
732 $tmp_name = $_FILES['issue_attachment']['tmp_name'];
733 $filename = sanitize_file_name($_FILES['issue_attachment']['name']);
734 $file_info = wp_check_filetype_and_ext($tmp_name, $filename);
735 if (!$file_info['ext']) {
736 wp_send_json_error(array('message' => 'Invalid file type. Please upload a JPEG, PNG, GIF, or WebP image.'));
737 return;
738 }
739 $allowed_extensions = array('jpg', 'jpeg', 'png', 'gif', 'webp');
740 if (!in_array($file_info['ext'], $allowed_extensions, true)) {
741 wp_send_json_error(array('message' => 'Invalid file type. Please upload a JPEG, PNG, GIF, or WebP image.'));
742 return;
743 }
744 $file_type = $file_info['type'];
745
746 # Validate file size (5MB max)
747 $max_size = 5 * 1024 * 1024; // 5MB
748 if ($_FILES['issue_attachment']['size'] > $max_size) {
749 wp_send_json_error(array('message' => 'File size exceeds 5MB. Please choose a smaller file.'));
750 return;
751 }
752
753 # Read file contents
754 $file_contents = file_get_contents($tmp_name);
755 if ($file_contents !== false) {
756 $attachment = array(
757 'filename' => $filename,
758 'data' => $file_contents,
759 'content_type' => $file_type
760 );
761 }
762 }
763
764 # Get general options (same way as used throughout the plugin)
765 $general_options = Metasync::get_option('general');
766 if (!is_array($general_options)) {
767 $general_options = array();
768 }
769
770 $project_uuid = isset($general_options['otto_pixel_uuid']) ? sanitize_text_field($general_options['otto_pixel_uuid']) : '';
771
772 # Always use standardized title format for Sentry prioritization
773 $issue_title = !empty($project_uuid) ? 'Client Report ' . $project_uuid : 'Client Report (UUID Not Configured)';
774
775 # Collect system information with error handling
776 $active_plugins = get_option('active_plugins');
777 $plugin_count = is_array($active_plugins) ? count($active_plugins) : 0;
778
779 $active_theme = wp_get_theme();
780 $theme_name = is_object($active_theme) ? $active_theme->get('Name') : get_template();
781
782 $system_context = array(
783 'report_type' => 'manual_client_report',
784 'website_url' => esc_url_raw(home_url()),
785 'site_title' => sanitize_text_field(get_bloginfo('name')),
786 'admin_email' => sanitize_email(get_bloginfo('admin_email')),
787 'plugin_version' => defined('METASYNC_VERSION') ? METASYNC_VERSION : '1.0.0',
788 'plugin_name' => 'Search Engine Labs SEO (MetaSync)',
789 'wordpress_version' => get_bloginfo('version'),
790 'php_version' => PHP_VERSION,
791 'active_theme' => $theme_name,
792 'memory_limit' => ini_get('memory_limit'),
793 'multisite' => is_multisite(),
794 'project_uuid' => $project_uuid,
795 'active_plugins' => $plugin_count,
796 'report_timestamp' => current_time('mysql'),
797 'severity_level' => $issue_severity
798 );
799
800 # Add user information if requested
801 if ($include_user_info) {
802 $current_user = wp_get_current_user();
803 if ($current_user && $current_user->ID > 0) {
804 $system_context['reporter'] = array(
805 'username' => sanitize_user($current_user->user_login),
806 'email' => sanitize_email($current_user->user_email),
807 'display_name' => sanitize_text_field($current_user->display_name),
808 'roles' => is_array($current_user->roles) ? $current_user->roles : array()
809 );
810 }
811 }
812
813 # Send to Sentry using User Feedback API
814 $sent_to_sentry = false;
815
816 # Check if Sentry feedback function exists
817 if (!function_exists('metasync_sentry_capture_feedback')) {
818 # Log warning if function doesn't exist
819 if (defined('WP_DEBUG') && WP_DEBUG) {
820 error_log('MetaSync: Sentry feedback function not available for report submission.'); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- debug-gated, no secrets
821 }
822 } else {
823 $feedback_data = array(
824 'message' => $issue_message,
825 'severity' => $issue_severity
826 );
827
828 # Add user information if requested
829 if ($include_user_info) {
830 $current_user = wp_get_current_user();
831 if ($current_user && $current_user->ID > 0) {
832 $feedback_data['name'] = sanitize_text_field($current_user->display_name);
833 $feedback_data['email'] = sanitize_email($current_user->user_email);
834 }
835 }
836
837 $sent_to_sentry = metasync_sentry_capture_feedback($feedback_data, $attachment);
838 }
839
840 if ($sent_to_sentry) {
841 wp_send_json_success(array(
842 'message' => 'Report submitted successfully! Our team will review it shortly.',
843 'project_uuid' => $project_uuid,
844 'report_title' => esc_html($issue_title)
845 ));
846 } else {
847 # Fallback: Log locally if Sentry fails or is unavailable
848 if (defined('WP_DEBUG') && WP_DEBUG) {
849 error_log(sprintf( // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- debug-gated, no secrets
850 'MetaSync Client Report (Fallback): UUID: %s | Severity: %s',
851 $project_uuid,
852 $issue_severity
853 ));
854 }
855
856 wp_send_json_success(array(
857 'message' => 'Report logged locally. Note: Remote reporting may be unavailable.',
858 'project_uuid' => $project_uuid,
859 'report_title' => esc_html($issue_title),
860 'fallback' => true
861 ));
862 }
863
864 } catch (Exception $e) {
865 # Log the error securely (only in debug mode)
866 if (defined('WP_DEBUG') && WP_DEBUG) {
867 error_log(sprintf( // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- debug-gated, no secrets
868 'MetaSync Report Submission Error: %s in %s on line %d',
869 $e->getMessage(),
870 $e->getFile(),
871 $e->getLine()
872 ));
873 }
874
875 # Send generic error message to client
876 wp_send_json_error(array('message' => 'Failed to submit report. Please try again later.'));
877 }
878 }
879
880 public function ajax_recover_password()
881 {
882 $rate_owner = '';
883 try {
884 if (!isset($_POST['nonce']) || !wp_verify_nonce(sanitize_text_field(wp_unslash($_POST['nonce'])), 'metasync_recover_password_nonce')) {
885 wp_send_json_error(array('message' => 'Security verification failed.'));
886 return;
887 }
888
889 if (!current_user_can('manage_options')) {
890 wp_send_json_error(array('message' => 'You do not have permission to request a password recovery.'), 403);
891 return;
892 }
893
894 if (!$this->acquire_recovery_lock($rate_owner)) {
895 wp_send_json_error(array('message' => 'Too many recovery requests. Please try again later.'), 429);
896 return;
897 }
898 register_shutdown_function(function () use (&$rate_owner) {
899 if ($rate_owner !== '') {
900 Metasync_Admin_Ajax::instance()->release_recovery_lock($rate_owner);
901 }
902 });
903 $this->refresh_recovery_state_cache();
904
905 $whitelabel_settings = Metasync::get_whitelabel_settings();
906 $recovery_email = $whitelabel_settings['recovery_email'] ?? '';
907
908 $stored_password = $whitelabel_settings['settings_password'] ?? '';
909 if (!is_string($stored_password) || $stored_password === '') {
910 $this->release_recovery_lock($rate_owner);
911 $rate_owner = '';
912 wp_send_json_error(array('message' => 'No password is configured for recovery.'));
913 return;
914 }
915
916 if (empty($recovery_email) || !is_email($recovery_email)) {
917 $this->release_recovery_lock($rate_owner);
918 $rate_owner = '';
919 wp_send_json_error(array('message' => 'No valid recovery email is configured. Please contact your administrator.'));
920 return;
921 }
922
923 // Bound recovery requests per user and per site to limit abuse.
924 $user_id = get_current_user_id();
925 $user_rate_key = 'metasync_pw_recovery_user_' . $user_id;
926 $site_rate_key = 'metasync_pw_recovery_site';
927 $this->refresh_recovery_state_cache($user_rate_key);
928 $this->refresh_recovery_state_cache($site_rate_key);
929 $user_attempts = (int) get_transient($user_rate_key);
930 $site_attempts = (int) get_transient($site_rate_key);
931 if ($user_attempts >= 3 || $site_attempts >= 10) {
932 $this->release_recovery_lock($rate_owner);
933 wp_send_json_error(array('message' => 'Too many recovery requests. Please try again later.'), 429);
934 return;
935 }
936
937 if (!set_transient($user_rate_key, $user_attempts + 1, HOUR_IN_SECONDS) || !set_transient($site_rate_key, $site_attempts + 1, HOUR_IN_SECONDS)) {
938 if ($user_attempts > 0) set_transient($user_rate_key, $user_attempts, HOUR_IN_SECONDS);
939 else delete_transient($user_rate_key);
940 if ($site_attempts > 0) set_transient($site_rate_key, $site_attempts, HOUR_IN_SECONDS);
941 else delete_transient($site_rate_key);
942 $this->release_recovery_lock($rate_owner);
943 wp_send_json_error(array('message' => 'Unable to process recovery request. Please try again later.'));
944 return;
945 }
946
947 // Keep only a hash in the database; the raw token is sent once by email.
948 $token = bin2hex(random_bytes(32));
949 $token_hash = hash('sha256', $token);
950 $reset_key = 'metasync_pw_reset_' . $token_hash;
951 if (!set_transient($reset_key, array('created_by' => $user_id), 30 * MINUTE_IN_SECONDS)) {
952 if ($user_attempts > 0) set_transient($user_rate_key, $user_attempts, HOUR_IN_SECONDS);
953 else delete_transient($user_rate_key);
954 if ($site_attempts > 0) set_transient($site_rate_key, $site_attempts, HOUR_IN_SECONDS);
955 else delete_transient($site_rate_key);
956 $this->release_recovery_lock($rate_owner);
957 $rate_owner = '';
958 wp_send_json_error(array('message' => 'Unable to create a recovery request. Please try again later.'));
959 return;
960 }
961 $this->release_recovery_lock($rate_owner);
962 $rate_owner = '';
963
964 $site_name = get_bloginfo('name');
965 $site_url = home_url();
966 $plugin_name = Metasync::get_effective_plugin_name('');
967 $settings_url = admin_url('admin.php?page=' . Metasync_Admin::$page_slug . '&tab=whitelabel&metasync_password_reset=' . rawurlencode($token));
968 $to = $recovery_email;
969
970 $subject = sprintf('[%s] Settings Password Recovery', $site_name);
971
972 $message = sprintf(
973 "Hello,\n\n" .
974 "A password recovery request was made for the %s settings on %s.\n\n" .
975 "Use this one-time link to choose a new Settings Password (valid for 30 minutes):\n%s\n\n" .
976 "If you did not request this password recovery, please secure your WordPress admin account immediately.\n\n" .
977 "---\n" .
978 "This is an automated message from %s\n%s",
979 $plugin_name,
980 $site_name,
981 $settings_url,
982 $site_name,
983 $site_url
984 );
985
986 $from_name = !empty($whitelabel_settings['company_name'])
987 ? $whitelabel_settings['company_name']
988 : $site_name;
989
990 $headers = array(
991 'Content-Type: text/plain; charset=UTF-8',
992 sprintf('From: %s <%s>', $from_name, get_option('admin_email'))
993 );
994
995 $sent = wp_mail($to, $subject, $message, $headers);
996
997 if ($sent) {
998 wp_send_json_success(array(
999 'message' => sprintf('Password recovery email sent to %s', esc_html($recovery_email))
1000 ));
1001 } else {
1002 wp_send_json_error(array('message' => 'Failed to send recovery email. Please check your email configuration or contact your administrator.'));
1003 }
1004
1005 } catch (Exception $e) {
1006 // Always release the bounded issuance lock if an unexpected
1007 // exception occurs after it was acquired.
1008 if ($rate_owner !== '') {
1009 $this->release_recovery_lock($rate_owner);
1010 }
1011 if (defined('WP_DEBUG') && WP_DEBUG) {
1012 error_log(sprintf( // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- debug-gated, no secrets
1013 'MetaSync Password Recovery Error: %s in %s on line %d',
1014 $e->getMessage(),
1015 $e->getFile(),
1016 $e->getLine()
1017 ));
1018 }
1019
1020 wp_send_json_error(array('message' => 'An error occurred while processing your request. Please try again later.'));
1021 }
1022 }
1023
1024 /** Render and process the administrator-only password reset screen. */
1025 public function render_password_reset_page()
1026 {
1027 if (!current_user_can('manage_options')) {
1028 wp_die('You do not have permission to reset this password.', '', array('response' => 403));
1029 }
1030
1031 $token = isset($_REQUEST['metasync_password_reset']) ? (string) wp_unslash($_REQUEST['metasync_password_reset']) : '';
1032 $token_hash = ($token !== '' && preg_match('/^[a-f0-9]{64}$/', $token)) ? hash('sha256', $token) : '';
1033 $generation = (int) get_option('metasync_pw_reset_generation', 0);
1034 $transient_key = $token_hash !== '' ? 'metasync_pw_reset_' . $token_hash : '';
1035 $error = '';
1036 $success = false;
1037
1038 if ($_SERVER['REQUEST_METHOD'] === 'POST') {
1039 if (!isset($_POST['metasync_reset_nonce']) || !wp_verify_nonce(sanitize_text_field(wp_unslash($_POST['metasync_reset_nonce'])), 'metasync_reset_password')) {
1040 $error = 'Security verification failed.';
1041 } elseif ($token === '' || false === get_transient($transient_key)) {
1042 $error = 'This recovery link is invalid or has expired.';
1043 } else {
1044 $password = isset($_POST['new_password']) ? (string) wp_unslash($_POST['new_password']) : '';
1045 $confirm = isset($_POST['confirm_password']) ? (string) wp_unslash($_POST['confirm_password']) : '';
1046 if (strlen($password) < 8) {
1047 $error = 'Password must be at least 8 characters long.';
1048 } elseif (!hash_equals($password, $confirm)) {
1049 $error = 'Passwords do not match.';
1050 } elseif (strpos($password, 'enc_v1:') === 0) {
1051 $error = 'Unable to save this password. Please choose a different password.';
1052 } else {
1053 // add_option is an atomic insert in WordPress; it closes the
1054 // race where two requests attempt to redeem the same token.
1055 $lock_key = '';
1056 if (!$this->acquire_recovery_lock($lock_key)) {
1057 $error = 'Another recovery request is being processed. Please wait a moment and try again.';
1058 } else {
1059 register_shutdown_function(function () use (&$lock_key) {
1060 if ($lock_key !== '') {
1061 Metasync_Admin_Ajax::instance()->release_recovery_lock($lock_key);
1062 }
1063 });
1064 $this->refresh_recovery_state_cache($transient_key);
1065 // Refresh the settings version while holding the shared
1066 // lock. Token keys are independent so issuing or using
1067 // one link does not invalidate another unexpired link.
1068 $generation = (int) get_option('metasync_pw_reset_generation', 0);
1069 $transient_key = $token_hash !== '' ? 'metasync_pw_reset_' . $token_hash : '';
1070 // Re-check after claiming the lock. Consume only after
1071 // every required write succeeds, allowing a retry after
1072 // a temporary database or revocation failure.
1073 if (false === get_transient($transient_key)) {
1074 $error = 'This recovery link is invalid or has already been used.';
1075 $this->release_recovery_lock($lock_key);
1076 $lock_key = '';
1077 echo '<div class="wrap"><h1>Reset Settings Password</h1><div class="notice notice-error"><p>' . esc_html($error) . '</p></div></div>';
1078 return;
1079 }
1080 {
1081 $encrypted = Metasync::encrypt_secret($password);
1082 if (!Metasync::is_encrypted_secret($encrypted) || $encrypted === $password) {
1083 $error = 'Unable to securely save this password. Please try again later.';
1084 } elseif (!update_option('metasync_pw_reset_generation', $generation + 1, false)) {
1085 $error = 'Unable to prepare the password reset. Please try again later.';
1086 /** @phpstan-ignore-next-line booleanNot.alwaysFalse */
1087 } elseif (!Metasync_Auth_Manager::revoke_all_access('whitelabel')) {
1088 $error = 'Unable to revoke existing access. Please try again later.';
1089 } elseif (!$this->persist_recovery_password($encrypted)) {
1090 $error = 'Unable to save the new password. Please try again later.';
1091 /** @phpstan-ignore-next-line booleanNot.alwaysFalse */
1092 } elseif (!Metasync_Auth_Manager::revoke_all_access('whitelabel')) {
1093 $error = 'Unable to finalize access revocation. Please try again later.';
1094 } elseif (!delete_transient($transient_key)) {
1095 $error = 'Unable to consume this recovery link. Please try again later.';
1096 } else {
1097 $success = true;
1098 }
1099 }
1100 $this->release_recovery_lock($lock_key);
1101 $lock_key = '';
1102 }
1103 }
1104 }
1105 }
1106
1107 echo '<div class="wrap"><h1>Reset Settings Password</h1>';
1108 if ($success) {
1109 echo '<div class="notice notice-success"><p>Password reset successfully. You can now return to the White Label settings.</p></div>';
1110 } else {
1111 if ($error !== '') {
1112 echo '<div class="notice notice-error"><p>' . esc_html($error) . '</p></div>';
1113 }
1114 if ($token === '' || false === get_transient($transient_key)) {
1115 if ($error === '') {
1116 echo '<div class="notice notice-error"><p>This recovery link is invalid or has expired.</p></div>';
1117 }
1118 } else {
1119 echo '<form method="post" style="max-width:420px">';
1120 wp_nonce_field('metasync_reset_password', 'metasync_reset_nonce');
1121 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>';
1122 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>';
1123 echo '<p><button class="button button-primary" type="submit">Save new password</button></p></form>';
1124 }
1125 }
1126 echo '</div>';
1127 }
1128
1129 public function ajax_save_theme()
1130 {
1131 try {
1132 # Verify nonce for security
1133 if (!isset($_POST['_ajax_nonce']) || !wp_verify_nonce(sanitize_text_field(wp_unslash($_POST['_ajax_nonce'])), 'metasync_theme_nonce')) {
1134 wp_send_json_error(array('message' => 'Security verification failed.'));
1135 }
1136
1137 # Check user capabilities
1138 if (!Metasync::current_user_has_plugin_access()) {
1139 wp_send_json_error(array('message' => 'Insufficient permissions.'));
1140 }
1141
1142 # Get and validate theme value
1143 $theme = isset($_POST['theme']) ? sanitize_text_field(wp_unslash($_POST['theme'])) : '';
1144
1145 # Validate theme is either 'light' or 'dark'
1146 if (!in_array($theme, array('light', 'dark'), true)) {
1147 wp_send_json_error(array('message' => 'Invalid theme value.'));
1148 }
1149
1150 # Save theme preference to WordPress options
1151 update_option('metasync_theme', $theme, true);
1152
1153 # Send success response
1154 wp_send_json_success(array(
1155 'message' => 'Theme preference saved successfully.',
1156 'theme' => $theme
1157 ));
1158
1159 } catch (Exception $e) {
1160 # Log error if debug is enabled
1161 if (defined('WP_DEBUG') && WP_DEBUG) {
1162 error_log('MetaSync Theme Save Error: ' . $e->getMessage()); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- debug-gated, no secrets
1163 }
1164
1165 wp_send_json_error(array('message' => 'Failed to save theme preference.'));
1166 }
1167 }
1168
1169 public function ajax_track_one_click_activation()
1170 {
1171 check_ajax_referer('metasync_nonce', 'nonce');
1172 # Check user capabilities
1173 if (!Metasync::current_user_has_plugin_access()) {
1174 wp_send_json_error(['message' => 'Insufficient permissions']);
1175 }
1176
1177 # Get parameters
1178 $auth_method = isset($_POST['auth_method']) ? sanitize_text_field(wp_unslash($_POST['auth_method'])) : 'searchatlas_connect';
1179 $is_reconnection = isset($_POST['is_reconnection']) ? filter_var($_POST['is_reconnection'], FILTER_VALIDATE_BOOLEAN) : false;
1180
1181 # Track the event in GA4
1182 try {
1183 Metasync_GA4::get_instance()->track_one_click_activation($auth_method, $is_reconnection);
1184
1185 wp_send_json_success([
1186 'message' => '1-click activation tracked successfully',
1187 'auth_method' => $auth_method,
1188 'is_reconnection' => $is_reconnection
1189 ]);
1190 } catch (Exception $e) {
1191 # Still return success to avoid breaking the auth flow
1192 wp_send_json_success([
1193 'message' => 'Authentication successful'
1194 ]);
1195 }
1196 }
1197
1198 public function handle_export_whitelabel_settings()
1199 {
1200 # Check user capabilities
1201 if (!Metasync::current_user_has_plugin_access()) {
1202 wp_die('Insufficient permissions');
1203 }
1204
1205 # Verify nonce for security (check both GET and POST)
1206 $nonce = '';
1207 if (isset($_POST['_wpnonce'])) {
1208 $nonce = sanitize_text_field(wp_unslash($_POST['_wpnonce']));
1209 } elseif (isset($_GET['_wpnonce'])) {
1210 $nonce = sanitize_text_field(wp_unslash($_GET['_wpnonce']));
1211 }
1212
1213 if (empty($nonce) || !wp_verify_nonce($nonce, 'metasync_export_whitelabel')) {
1214 wp_die('Security verification failed.');
1215 }
1216
1217 try {
1218 # Get all whitelabel settings
1219 $whitelabel_settings = Metasync::get_whitelabel_settings();
1220
1221 # Export the settings password as plaintext (not the encrypted blob):
1222 # the encryption key derives from this site's salts, so the blob
1223 # would be undecryptable after import on a different site. The
1224 # importer re-encrypts it at rest with the destination site's salts.
1225 if (!empty($whitelabel_settings['settings_password'])) {
1226 $whitelabel_settings['settings_password'] = Metasync::get_whitelabel_password();
1227 }
1228
1229 # Get general settings that relate to whitelabel
1230 $general_settings = Metasync::get_option('general');
1231 $whitelabel_related_general = array();
1232
1233 # Include ALL whitelabel-related general settings
1234 $whitelabel_keys = array(
1235 'white_label_plugin_name',
1236 'white_label_plugin_description',
1237 'white_label_plugin_author',
1238 'white_label_plugin_author_uri',
1239 'white_label_plugin_uri',
1240 'white_label_plugin_menu_slug',
1241 'white_label_plugin_menu_icon',
1242 'whitelabel_otto_name'
1243 );
1244
1245 foreach ($whitelabel_keys as $key) {
1246 if (isset($general_settings[$key])) {
1247 $whitelabel_related_general[$key] = $general_settings[$key];
1248 }
1249 }
1250
1251 # Bundle the menu icon file so it survives import on a different site.
1252 # If the icon is a local URL (media library), replace it with a special
1253 # marker and include the actual file in the ZIP under the plugin folder.
1254 $bundled_icon_filename = null;
1255 $icon_url = $whitelabel_related_general['white_label_plugin_menu_icon'] ?? '';
1256 if (!empty($icon_url) && filter_var($icon_url, FILTER_VALIDATE_URL)) {
1257 $site_url = trailingslashit(site_url());
1258 if (strpos($icon_url, $site_url) === 0) {
1259 // Resolve URL to an absolute filesystem path
1260 $relative_path = str_replace($site_url, ABSPATH, $icon_url);
1261 $icon_abs_path = realpath($relative_path);
1262 if ($icon_abs_path && file_exists($icon_abs_path)) {
1263 $ext = strtolower(pathinfo($icon_abs_path, PATHINFO_EXTENSION));
1264 $bundled_icon_filename = 'whitelabel-icon.' . $ext;
1265 // Replace the URL with a marker so the importer knows to restore it
1266 $whitelabel_related_general['white_label_plugin_menu_icon'] = '__bundled_icon__' . $ext;
1267 }
1268 }
1269 }
1270
1271 # Prepare export data
1272 $export_data = array(
1273 'version' => '1.0',
1274 'exported_at' => current_time('mysql'),
1275 'whitelabel_settings' => $whitelabel_settings,
1276 'general_settings' => $whitelabel_related_general
1277 );
1278
1279 # Convert to JSON
1280 $json_data = wp_json_encode($export_data, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES);
1281
1282 if ($json_data === false) {
1283 wp_die('Failed to encode settings to JSON.');
1284 }
1285
1286 # Check if ZipArchive is available
1287 if (!class_exists('ZipArchive')) {
1288 wp_die('ZipArchive class is not available. Please enable PHP zip extension.');
1289 }
1290
1291 # Get plugin directory path (remove trailing slash for basename)
1292 $plugin_dir = rtrim(plugin_dir_path(dirname(__FILE__)), '/');
1293 $plugin_folder_name = basename($plugin_dir);
1294
1295 # Create temporary directory for zip file
1296 $upload_dir = wp_upload_dir();
1297 $temp_dir = $upload_dir['basedir'] . '/metasync-export-temp';
1298
1299 # Create temp directory if it doesn't exist
1300 if (!file_exists($temp_dir)) {
1301 wp_mkdir_p($temp_dir);
1302 }
1303
1304 # Generate unique filename
1305 $timestamp = gmdate('Y-m-d_H-i-s');
1306 $zip_filename = 'metasync-whitelabel-plugin-' . $timestamp . '.zip';
1307 $json_filename = 'whitelabel-settings.json';
1308 $zip_path = $temp_dir . '/' . $zip_filename;
1309
1310 # Create zip file
1311 $zip = new ZipArchive();
1312 if ($zip->open($zip_path, ZipArchive::CREATE | ZipArchive::OVERWRITE) !== true) {
1313 wp_die('Failed to create zip file.');
1314 }
1315
1316 # Add whitelabel settings JSON file to zip (inside plugin folder)
1317 $zip->addFromString($plugin_folder_name . '/' . $json_filename, $json_data);
1318
1319 # Bundle icon file if one was detected
1320 if ($bundled_icon_filename !== null && isset($icon_abs_path) && file_exists($icon_abs_path)) {
1321 $zip->addFile($icon_abs_path, $plugin_folder_name . '/' . $bundled_icon_filename);
1322 }
1323
1324
1325 # Files and directories to exclude from the zip
1326 $exclude_patterns = array(
1327 '.git',
1328 '.gitignore',
1329 '.gitattributes',
1330 'node_modules',
1331 '.DS_Store',
1332 'Thumbs.db',
1333 '.idea',
1334 '.vscode',
1335 'composer.lock',
1336 'package-lock.json',
1337 'yarn.lock',
1338 '.env',
1339 '.env.local',
1340 'docker-compose.yml',
1341 'Dockerfile',
1342 'Makefile',
1343 'renovate.json',
1344 'sonar-project.properties',
1345 'CODEOWNERS',
1346 'metasync-export-temp',
1347 'whitelabel-settings.json'
1348 );
1349
1350 # Recursively add plugin files to zip (with plugin folder as root in zip)
1351 self::add_directory_to_zip($zip, $plugin_dir . '/', $plugin_folder_name . '/', $exclude_patterns);
1352
1353 $zip->close();
1354
1355 # Check if file was created
1356 if (!file_exists($zip_path)) {
1357 wp_die('Zip file was not created successfully.');
1358 }
1359
1360 # Set headers for file download
1361 header('Content-Type: application/zip');
1362 header('Content-Disposition: attachment; filename="' . $zip_filename . '"');
1363 header('Content-Length: ' . filesize($zip_path));
1364 header('Pragma: no-cache');
1365 header('Expires: 0');
1366
1367 # Output file and clean up
1368 readfile($zip_path); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_readfile -- streams a large file to output without loading it into memory
1369 wp_delete_file($zip_path);
1370
1371 # Clean up temp directory if empty
1372 if (is_dir($temp_dir) && count(scandir($temp_dir)) == 2) {
1373 rmdir($temp_dir); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_rmdir -- WP_Filesystem would prompt for FTP credentials on non-direct hosts
1374 }
1375
1376 # Exit to prevent WordPress from adding anything to the response
1377 exit;
1378
1379 } catch (Exception $e) {
1380 # Log error if debug is enabled
1381 if (defined('WP_DEBUG') && WP_DEBUG) {
1382 error_log('MetaSync Whitelabel Export Error: ' . $e->getMessage()); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- debug-gated, no secrets
1383 }
1384
1385 wp_die(esc_html__('Failed to export whitelabel settings.', 'metasync'));
1386 }
1387 }
1388
1389 private static function add_directory_to_zip($zip, $dir, $zip_path = '', $exclude_patterns = array())
1390 {
1391 if (!is_dir($dir)) {
1392 return;
1393 }
1394
1395 $files = scandir($dir);
1396
1397 foreach ($files as $file) {
1398 if ($file === '.' || $file === '..') {
1399 continue;
1400 }
1401
1402 $file_path = $dir . $file;
1403 $zip_file_path = $zip_path . $file;
1404
1405 $should_exclude = false;
1406 foreach ($exclude_patterns as $pattern) {
1407 if (strpos($file, $pattern) !== false || strpos($file_path, $pattern) !== false) {
1408 $should_exclude = true;
1409 break;
1410 }
1411 }
1412
1413 if ($should_exclude) {
1414 continue;
1415 }
1416
1417 if (is_dir($file_path)) {
1418 $zip->addEmptyDir($zip_file_path);
1419 self::add_directory_to_zip($zip, $file_path . '/', $zip_file_path . '/', $exclude_patterns);
1420 } else {
1421 if (file_exists($file_path) && is_readable($file_path)) {
1422 $zip->addFile($file_path, $zip_file_path);
1423 }
1424 }
1425 }
1426 }
1427
1428 public function render_html_pages_dashboard_widget()
1429 {
1430 global $wpdb;
1431
1432 $html_pages = $wpdb->get_results( // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- admin dashboard widget join over posts+postmeta — aggregate view, no WordPress API for the join
1433 "SELECT p.ID, p.post_title, p.post_modified, p.post_type
1434 FROM {$wpdb->posts} p
1435 INNER JOIN {$wpdb->postmeta} pm ON p.ID = pm.post_id
1436 WHERE p.post_status = 'publish'
1437 AND p.post_type IN ('post', 'page')
1438 AND (pm.meta_key = '_metasync_raw_html_enabled' OR pm.meta_key = '_metasync_custom_css')
1439 GROUP BY p.ID
1440 ORDER BY p.post_modified DESC
1441 LIMIT 10
1442 "
1443 );
1444 $total_count = count($html_pages);
1445
1446 $label = $this->get_html_source_label();
1447
1448 if (empty($html_pages)) {
1449 echo '<div class="metasync-dashboard-widget-empty">';
1450 echo '<span class="dashicons dashicons-admin-page" style="font-size: 48px; opacity: 0.3; display: block; margin: 20px auto;"></span>';
1451 echo '<p style="text-align: center; color: #666;">';
1452 /* translators: %s: HTML source label. */
1453 echo sprintf(__('No pages created with %s yet.', 'metasync'), '<strong>' . esc_html($label) . '</strong>');
1454 echo '</p>';
1455 echo '<p style="text-align: center;">';
1456 echo '<a href="' . esc_url(admin_url('admin.php?page=' . Metasync_Admin::$page_slug)) . '" class="button button-primary">';
1457 echo esc_html__('Get Started', 'metasync');
1458 echo '</a>';
1459 echo '</p>';
1460 echo '</div>';
1461 return;
1462 }
1463
1464 echo '<div class="metasync-dashboard-widget">';
1465
1466 echo '<div class="metasync-widget-stats">';
1467 echo '<div class="metasync-stat-box">';
1468 echo '<span class="metasync-stat-number">' . esc_html((string) $total_count) . '</span>';
1469 echo '<span class="metasync-stat-label">' . esc_html__('AI-Generated Pages', 'metasync') . '</span>';
1470 echo '</div>';
1471 echo '</div>';
1472
1473 echo '<div class="metasync-widget-list">';
1474 echo '<h4>' . esc_html__('Recent Pages', 'metasync') . '</h4>';
1475 echo '<ul>';
1476
1477 foreach ($html_pages as $page) {
1478 $edit_link = get_edit_post_link($page->ID);
1479 $view_link = get_permalink($page->ID);
1480 $time_ago = human_time_diff(strtotime($page->post_modified), current_time('timestamp'));
1481
1482 echo '<li class="metasync-widget-page-item">';
1483 echo '<span class="metasync-page-icon">⚡</span>';
1484 echo '<div class="metasync-page-details">';
1485 echo '<a href="' . esc_url($edit_link) . '" class="metasync-page-title">';
1486 echo esc_html($page->post_title ?: __('(no title)', 'metasync'));
1487 echo '</a>';
1488 echo '<span class="metasync-page-meta">';
1489 /* translators: %s: human-readable time difference. */
1490 echo esc_html(sprintf(__('Updated %s ago', 'metasync'), $time_ago));
1491 echo ' • ';
1492 echo '<a href="' . esc_url($view_link) . '" target="_blank">' . esc_html__('View', 'metasync') . '</a>';
1493 echo '</span>';
1494 echo '</div>';
1495 echo '</li>';
1496 }
1497
1498 echo '</ul>';
1499 echo '</div>';
1500
1501 echo '<div class="metasync-widget-footer">';
1502 echo '<a href="' . esc_url(admin_url("edit.php?post_type=page")) . '">';
1503 echo esc_html__('View All Pages', 'metasync') . ' →';
1504 echo '</a>';
1505 echo '</div>';
1506
1507 echo '</div>';
1508 }
1509
1510 private function get_html_source_label()
1511 {
1512 $whitelabel_company = Metasync::get_whitelabel_company_name();
1513 if (!empty($whitelabel_company)) {
1514 return $whitelabel_company . ' AI';
1515 }
1516
1517 return Metasync::get_effective_plugin_name() . ' AI';
1518 }
1519 }
1520