PluginProbe
Search Atlas SEO – OTTO AI SEO Automation for WordPress / 2.7.2
Search Atlas SEO – OTTO AI SEO Automation for WordPress v2.7.2
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.2, at includes/class-metasync-admin-ajax.php

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