$owner, 'time' => time()), '', false)) return true; $held = get_option($key, array()); $held_time = is_array($held) ? (int) ($held['time'] ?? 0) : (int) $held; // Settings requests may run for up to five minutes. Keep the lease // comfortably beyond that bound; shutdown cleanup releases it // promptly during normal completion and handled failures. if ($held_time > 0 && time() - $held_time > 900) { $replacement = array('owner' => $owner, 'time' => time()); global $wpdb; if (isset($wpdb) && is_object($wpdb) && method_exists($wpdb, 'query') && method_exists($wpdb, 'prepare')) { $serialize = function ($value) { return function_exists('maybe_serialize') ? maybe_serialize($value) : serialize($value); }; $wpdb->query($wpdb->prepare( "UPDATE {$wpdb->options} SET option_value = %s WHERE option_name = %s AND option_value = %s", $serialize($replacement), $key, $serialize($held) )); if (function_exists('wp_cache_delete')) wp_cache_delete($key, 'options'); } else { update_option($key, $replacement, false); } $claimed = get_option($key, array()); return is_array($claimed) && isset($claimed['owner']) && hash_equals((string) $claimed['owner'], (string) $owner); } return false; } public function release_recovery_lock($owner) { $key = 'metasync_pw_recovery_rate_lock'; $held = get_option($key, array()); if (is_array($held) && isset($held['owner']) && hash_equals((string) $held['owner'], (string) $owner)) { global $wpdb; if (isset($wpdb) && is_object($wpdb) && method_exists($wpdb, 'query') && method_exists($wpdb, 'prepare')) { $serialized = function_exists('maybe_serialize') ? maybe_serialize($held) : serialize($held); $wpdb->query($wpdb->prepare( "DELETE FROM {$wpdb->options} WHERE option_name = %s AND option_value = %s", $key, $serialized )); if (function_exists('wp_cache_delete')) wp_cache_delete($key, 'options'); return; } // Test/bootstrap fallback. Production uses the owner-checked SQL // delete above so expired-owner cleanup cannot remove a new lock. delete_option($key); } } private function persist_recovery_password($encrypted) { for ($attempt = 0; $attempt < 3; $attempt++) { $options = Metasync::get_option(); if (!is_array($options)) $options = array(); if (!isset($options['whitelabel']) || !is_array($options['whitelabel'])) $options['whitelabel'] = array(); $options['whitelabel']['settings_password'] = $encrypted; Metasync_Settings_Registration::authorize_recovery_password_write(true); try { $saved = Metasync::set_option($options); } finally { Metasync_Settings_Registration::authorize_recovery_password_write(false); } if ($saved) { $stored = Metasync::get_option('whitelabel'); if (is_array($stored) && isset($stored['settings_password']) && hash_equals((string) $encrypted, (string) $stored['settings_password'])) return true; } } return false; } private function get_db_redirection() { if (null === $this->db_redirection) { $this->db_redirection = new Metasync_Redirection_Database(); } return $this->db_redirection; } public function ajax_import_external_data() { $execution_time = Metasync_Settings_Fields::instance()->get_execution_setting('max_execution_time'); if (function_exists('set_time_limit')) { @set_time_limit($execution_time); } Metasync_Settings_Fields::instance()->apply_memory_limit(); check_ajax_referer('metasync_import_external_data', 'nonce'); if (!Metasync::current_user_has_plugin_access()) { wp_send_json_error(['message' => 'Insufficient permissions.']); } $type = isset($_POST['type']) ? sanitize_text_field($_POST['type']) : ''; $plugin = isset($_POST['plugin']) ? sanitize_text_field($_POST['plugin']) : ''; $offset = isset($_POST['offset']) ? intval($_POST['offset']) : 0; if (empty($type) || empty($plugin)) { wp_send_json_error(['message' => 'Missing required parameters.']); } require_once plugin_dir_path(dirname(__FILE__)) . 'includes/class-metasync-external-importer.php'; $importer = new Metasync_External_Importer($this->get_db_redirection()); $result = ['success' => false, 'message' => 'Unknown import type.']; switch ($type) { case 'redirections': $result = $importer->import_redirections($plugin); break; case 'sitemap': $result = $importer->import_sitemap($plugin); break; case 'robots': $result = $importer->import_robots($plugin); break; case 'indexation': $result = $importer->import_indexation($plugin, ['batch_size' => 50, 'offset' => $offset]); break; case 'schema': $result = $importer->import_schema($plugin); break; } if ($result['success']) { wp_send_json_success($result); } else { wp_send_json_error($result); } } public function ajax_import_seo_metadata() { $execution_time = Metasync_Settings_Fields::instance()->get_execution_setting('max_execution_time'); if (function_exists('set_time_limit')) { @set_time_limit($execution_time); } Metasync_Settings_Fields::instance()->apply_memory_limit(); check_ajax_referer('metasync_import_seo_metadata', 'nonce'); if (!Metasync::current_user_has_plugin_access()) { wp_send_json_error(['message' => 'Insufficient permissions.']); } $plugin = isset($_POST['plugin']) ? sanitize_text_field($_POST['plugin']) : ''; $import_titles = isset($_POST['import_titles']) ? (bool) intval($_POST['import_titles']) : true; $import_descriptions = isset($_POST['import_descriptions']) ? (bool) intval($_POST['import_descriptions']) : true; $import_social_text = isset($_POST['import_social_text']) ? (bool) intval($_POST['import_social_text']) : true; $import_social_images = isset($_POST['import_social_images']) ? (bool) intval($_POST['import_social_images']) : true; $overwrite_existing = isset($_POST['overwrite_existing']) ? (bool) intval($_POST['overwrite_existing']) : false; $offset = isset($_POST['offset']) ? intval($_POST['offset']) : 0; if (empty($plugin)) { wp_send_json_error(['message' => 'Missing required plugin parameter.']); } require_once plugin_dir_path(dirname(__FILE__)) . 'includes/class-metasync-external-importer.php'; $importer = new Metasync_External_Importer($this->get_db_redirection()); $options = [ 'import_titles' => $import_titles, 'import_descriptions' => $import_descriptions, 'import_social_text' => $import_social_text, 'import_social_images' => $import_social_images, 'overwrite_existing' => $overwrite_existing, 'batch_size' => 50, 'offset' => $offset ]; $result = $importer->import_seo_metadata($plugin, $options); if ($result['success']) { wp_send_json_success($result); } else { wp_send_json_error($result); } } public function lgSendCustomerParams() { check_ajax_referer('metasync_nonce', 'nonce'); if (!current_user_can('manage_options')) { wp_send_json_error('Unauthorized'); return; } $sync_request = new Metasync_Sync_Requests(); # use the existing apikey for backward compatibility $general_options = Metasync::get_option('general') ?? []; $token = $general_options['apikey'] ?? null; # declare the call context explicitly so only the Settings # "Sync Now" button consumes/stamps the 5-minute manual cooldown. $is_heartbeat_tick = filter_var($_POST['is_heart_beat'] ?? false, FILTER_VALIDATE_BOOLEAN); # get the response $response = $sync_request->SyncCustomerParams($token, $is_heartbeat_tick ? 'heartbeat' : 'manual'); // Check if response is a throttling error object if (is_object($response) && isset($response->throttled) && $response->throttled === true) { wp_send_json($response); wp_die(); } // Check if response is null/false (other error cases) if ($response === null || $response === false) { wp_send_json(['error' => 'Sync failed - no response from sync method', 'detail' => 'The sync method returned null or false']); wp_die(); } $responseBody = wp_remote_retrieve_body($response); $responseCode = wp_remote_retrieve_response_code($response); if ($responseCode == 200) { # Standardize on current_time('mysql') (WP local timezone) so the # value matches what the cron heartbeat writes — otherwise the # displayed timestamp flickers between two formats depending on # which path wrote last. $send_auth_token_timestamp = Metasync::get_option(); $send_auth_token_timestamp['general']['send_auth_token_timestamp'] = current_time('mysql'); Metasync::set_option($send_auth_token_timestamp); Metasync_Heartbeat_Manager::instance()->update_heartbeat_cache_after_sync(true, 'Sync Now - successful data sync'); $result = json_decode($responseBody); if ( ! is_object( $result ) ) { $result = new stdClass(); } $timestamp = Metasync::get_option('general')['send_auth_token_timestamp'] ?? ''; $result->send_auth_token_timestamp = $timestamp; $result->send_auth_token_diffrence = Metasync_Settings_Fields::instance()->time_elapsed_string($timestamp); wp_send_json($result); wp_die(); } else { Metasync_Heartbeat_Manager::instance()->update_heartbeat_cache_after_sync(false, 'Sync Now - failed data sync'); } $result = json_decode($responseBody); wp_send_json($result); wp_die(); } public function ajax_update_db_structure() { if (!Metasync::current_user_has_plugin_access()) { wp_die('Insufficient permissions'); } if (empty($_POST['nonce']) || !wp_verify_nonce(sanitize_text_field(wp_unslash($_POST['nonce'])), 'metasync_update_db_nonce')) { wp_die('Security check failed'); } try { $this->get_db_redirection()->force_table_update(); wp_send_json_success('Database structure updated successfully'); } catch (Exception $e) { wp_send_json_error('Database update failed: ' . $e->getMessage()); } } public function ajax_save_wizard_progress() { check_ajax_referer('metasync_wizard', 'nonce'); if (!Metasync::current_user_has_plugin_access()) { wp_send_json_error(array('message' => 'Insufficient permissions')); } $step = isset($_POST['step']) ? intval($_POST['step']) : 0; $raw_data = isset($_POST['data']) ? $_POST['data'] : array(); // Allowlist top-level wizard data keys to prevent mass assignment $allowed_wizard_keys = array('verification', 'seo_settings', 'schema'); $data = is_array($raw_data) ? array_intersect_key($raw_data, array_flip($allowed_wizard_keys)) : array(); $options = get_option('metasync_options', array()); if (isset($data['verification'])) { if (!isset($options['general'])) { $options['general'] = array(); } $options['general']['google_verification'] = sanitize_text_field($data['verification']['google']); $options['general']['bing_verification'] = sanitize_text_field($data['verification']['bing']); } if (isset($data['seo_settings'])) { if (!isset($options['seo_controls'])) { $options['seo_controls'] = array(); } $options['seo_controls']['index_date_archives'] = $data['seo_settings']['date_archives'] ? 'false' : 'true'; $options['seo_controls']['index_author_archives'] = $data['seo_settings']['author_archives'] ? 'false' : 'true'; $options['seo_controls']['index_category_archives'] = $data['seo_settings']['category_archives'] ? 'false' : 'true'; $options['seo_controls']['index_tag_archives'] = $data['seo_settings']['tag_archives'] ? 'false' : 'true'; } if (isset($data['schema'])) { if (!isset($options['general'])) { $options['general'] = array(); } $options['general']['enable_schema_markup'] = $data['schema']['enabled']; $options['general']['default_schema_type'] = sanitize_text_field($data['schema']['default_type']); } update_option('metasync_options', $options); wp_send_json_success(array('message' => 'Progress saved')); } public function ajax_complete_wizard() { check_ajax_referer('metasync_wizard', 'nonce'); if (!Metasync::current_user_has_plugin_access()) { wp_send_json_error(array('message' => 'Insufficient permissions')); } update_option('metasync_wizard_completed', array( 'completed' => true, 'completed_at' => current_time('mysql'), 'completed_by' => get_current_user_id(), 'version' => METASYNC_VERSION )); $user_id = get_current_user_id(); delete_transient("metasync_wizard_state_{$user_id}"); wp_send_json_success(array('message' => 'Wizard completed')); } public function ajax_validate_robots() { check_ajax_referer('metasync_nonce', 'nonce'); if (!Metasync::current_user_has_plugin_access()) { wp_send_json_error('Insufficient permissions'); return; } require_once plugin_dir_path(dirname(__FILE__)) . 'robots-txt/class-metasync-robots-txt.php'; $robots_txt = Metasync_Robots_Txt::get_instance(); $content = isset($_POST['content']) ? wp_unslash($_POST['content']) : ''; $validation = $robots_txt->validate_content($content); wp_send_json_success($validation); } public function ajax_get_default_robots() { check_ajax_referer('metasync_nonce', 'nonce'); if (!Metasync::current_user_has_plugin_access()) { wp_send_json_error('Insufficient permissions'); return; } require_once plugin_dir_path(dirname(__FILE__)) . 'robots-txt/class-metasync-robots-txt.php'; $robots_txt = Metasync_Robots_Txt::get_instance(); wp_send_json_success(array( 'content' => $robots_txt->get_default_robots_content() )); } public function ajax_preview_robots_backup() { check_ajax_referer('metasync_nonce', 'nonce'); if (!Metasync::current_user_has_plugin_access()) { wp_send_json_error('Insufficient permissions'); return; } $backup_id = isset($_POST['backup_id']) ? intval($_POST['backup_id']) : 0; if (!$backup_id) { wp_send_json_error('Invalid backup ID'); return; } require_once plugin_dir_path(dirname(__FILE__)) . 'robots-txt/class-metasync-robots-txt-database.php'; $database = Metasync_Robots_Txt_Database::get_instance(); $backup = $database->get_backup($backup_id); if (!$backup) { wp_send_json_error('Backup not found'); return; } wp_send_json_success(array( 'content' => $backup['content'], 'created_at' => get_date_from_gmt($backup['created_at'], get_option('date_format') . ' ' . get_option('time_format')), 'created_by_name' => isset($backup['created_by_name']) ? $backup['created_by_name'] : '' )); } public function ajax_delete_robots_backup() { if (!Metasync::current_user_has_plugin_access()) { wp_send_json_error(array('message' => esc_html__('Insufficient permissions', 'metasync'))); return; } if (!isset($_POST['nonce']) || !wp_verify_nonce($_POST['nonce'], 'metasync_delete_robots_backup')) { wp_send_json_error(array('message' => esc_html__('Security check failed', 'metasync'))); return; } $backup_id = isset($_POST['backup_id']) ? intval($_POST['backup_id']) : 0; if (!$backup_id) { wp_send_json_error(array('message' => esc_html__('Invalid backup ID', 'metasync'))); return; } require_once plugin_dir_path(dirname(__FILE__)) . 'robots-txt/class-metasync-robots-txt.php'; $robots_txt = Metasync_Robots_Txt::get_instance(); $result = $robots_txt->delete_backup($backup_id); if ($result) { wp_send_json_success(array('message' => esc_html__('Backup deleted successfully!', 'metasync'))); } else { wp_send_json_error(array('message' => esc_html__('Failed to delete backup.', 'metasync'))); } } public function ajax_restore_robots_backup() { if (!Metasync::current_user_has_plugin_access()) { wp_send_json_error(array('message' => esc_html__('Insufficient permissions', 'metasync'))); return; } if (!isset($_POST['nonce']) || !wp_verify_nonce($_POST['nonce'], 'metasync_restore_robots_backup')) { wp_send_json_error(array('message' => esc_html__('Security check failed', 'metasync'))); return; } $backup_id = isset($_POST['backup_id']) ? intval($_POST['backup_id']) : 0; if (!$backup_id) { wp_send_json_error(array('message' => esc_html__('Invalid backup ID', 'metasync'))); return; } require_once plugin_dir_path(dirname(__FILE__)) . 'robots-txt/class-metasync-robots-txt.php'; $robots_txt = Metasync_Robots_Txt::get_instance(); $result = $robots_txt->restore_backup($backup_id); if (is_wp_error($result)) { wp_send_json_error(array('message' => $result->get_error_message())); } else { $current_content = $robots_txt->read_robots_file(); if (is_wp_error($current_content)) { wp_send_json_error(array('message' => $current_content->get_error_message())); } else { wp_send_json_success(array( 'message' => esc_html__('robots.txt restored from backup successfully!', 'metasync'), 'content' => $current_content )); } } } public function ajax_get_robots_backups() { if (!Metasync::current_user_has_plugin_access()) { wp_send_json_error(array('message' => esc_html__('Insufficient permissions', 'metasync'))); return; } if (!isset($_POST['nonce']) || !wp_verify_nonce($_POST['nonce'], 'metasync_get_robots_backups')) { wp_send_json_error(array('message' => esc_html__('Security check failed', 'metasync'))); return; } require_once plugin_dir_path(dirname(__FILE__)) . 'robots-txt/class-metasync-robots-txt.php'; $robots_txt = Metasync_Robots_Txt::get_instance(); $backup_per_page = Metasync_Robots_Txt_Database::BACKUPS_PER_PAGE; $backup_total = $robots_txt->get_backup_count(); $backup_total_pages = max(1, (int) ceil($backup_total / $backup_per_page)); $backup_page = isset($_POST['page']) ? max(1, intval($_POST['page'])) : 1; if ($backup_page > $backup_total_pages) { $backup_page = $backup_total_pages; } $offset = ($backup_page - 1) * $backup_per_page; $backups = $robots_txt->get_backup_history($backup_per_page, $offset); // Render the shared list + pagination partial to a string. ob_start(); require plugin_dir_path(dirname(__FILE__)) . 'robots-txt/views/backup-history.php'; $html = ob_get_clean(); wp_send_json_success(array( 'html' => $html, 'total' => $backup_total, 'page' => $backup_page, 'total_pages' => $backup_total_pages, )); } public function ajax_create_redirect_from_404() { if (empty($_POST['nonce']) || !wp_verify_nonce(sanitize_text_field(wp_unslash($_POST['nonce'])), 'metasync_404_redirect')) { wp_die('Security check failed'); } if (!Metasync::current_user_has_plugin_access()) { wp_die('Insufficient permissions'); } $uri = sanitize_text_field($_POST['uri']); $redirect_url = sanitize_url($_POST['redirect_url']); if (empty($uri) || empty($redirect_url)) { wp_send_json_error('Missing required parameters'); } require_once plugin_dir_path(dirname(__FILE__)) . '404-monitor/class-metasync-404-monitor-database.php'; require_once plugin_dir_path(dirname(__FILE__)) . '404-monitor/class-metasync-404-monitor.php'; $db_404 = new Metasync_Error_Monitor_Database(); $monitor_404 = new Metasync_Error_Monitor($db_404); $result = $monitor_404->create_redirection_from_404($uri, $redirect_url, 'Created from 404 suggestion'); if ($result) { wp_send_json_success('Redirect created successfully'); } else { wp_send_json_error('Failed to create redirect'); } } /** * Manual "Host Blocking Test" — GET leg. * * The probe itself lives in Metasync_Host_Blocking_Check so the manual button and the * automatic activation/weekly check share one implementation. */ public function ajax_test_host_blocking_get() { $this->send_host_blocking_result('GET'); } /** * Manual "Host Blocking Test" — POST leg. */ public function ajax_test_host_blocking_post() { $this->send_host_blocking_result('POST'); } /** * Shared auth + response wrapper for both manual host blocking legs. * * @param string $method 'GET' or 'POST'. */ private function send_host_blocking_result($method) { check_ajax_referer('metasync_nonce', 'nonce'); if (!Metasync::current_user_has_plugin_access()) { wp_send_json_error('Insufficient permissions'); return; } wp_send_json_success(Metasync_Host_Blocking_Check::get_instance()->run_check($method)); } public function execute_transient_cleanup() { global $wpdb; $cleanup_stats = array( 'expired_transients' => 0, 'plugin_transients' => 0, 'rate_limit_transients' => 0, 'telemetry_transients' => 0, 'start_time' => microtime(true) ); try { delete_expired_transients(true); $cleanup_stats['expired_transients'] = 'cleaned_by_wordpress'; $plugin_transients = $wpdb->get_results( "SELECT option_name FROM {$wpdb->options} WHERE option_name LIKE '_transient_metasync_%'", ARRAY_A ); foreach ($plugin_transients as $transient) { $transient_name = str_replace('_transient_', '', $transient['option_name']); delete_transient($transient_name); $cleanup_stats['plugin_transients']++; } $rate_limit_transients = $wpdb->get_results( "SELECT option_name FROM {$wpdb->options} WHERE option_name LIKE '_transient_sa_connect_rate_limit_%'", ARRAY_A ); foreach ($rate_limit_transients as $transient) { $transient_name = str_replace('_transient_', '', $transient['option_name']); delete_transient($transient_name); $cleanup_stats['rate_limit_transients']++; } $telemetry_transients = $wpdb->get_results( "SELECT option_name FROM {$wpdb->options} WHERE option_name LIKE '_transient_metasync_telemetry_%'", ARRAY_A ); foreach ($telemetry_transients as $transient) { $transient_name = str_replace('_transient_', '', $transient['option_name']); delete_transient($transient_name); $cleanup_stats['telemetry_transients']++; } $sa_connect_success_transients = $wpdb->get_results( "SELECT option_name FROM {$wpdb->options} WHERE option_name LIKE '_transient_metasync_sa_connect_success_%'", ARRAY_A ); foreach ($sa_connect_success_transients as $transient) { $transient_name = str_replace('_transient_', '', $transient['option_name']); delete_transient($transient_name); $cleanup_stats['sa_connect_success_transients'] = ($cleanup_stats['sa_connect_success_transients'] ?? 0) + 1; } $cleanup_stats['execution_time'] = round((microtime(true) - $cleanup_stats['start_time']) * 1000, 2); $cleanup_stats['next_run'] = wp_next_scheduled('metasync_cleanup_transients') ? date('Y-m-d H:i:s T', wp_next_scheduled('metasync_cleanup_transients')) : 'N/A'; error_log('MetaSync: Transient cleanup completed - ' . json_encode($cleanup_stats)); } catch (Exception $e) { error_log('MetaSync: Transient cleanup failed - ' . $e->getMessage()); } } public function ajax_submit_issue_report() { try { # Verify nonce if (!isset($_POST['nonce']) || !wp_verify_nonce(sanitize_text_field(wp_unslash($_POST['nonce'])), 'metasync_report_issue')) { wp_send_json_error(array('message' => 'Security verification failed.')); return; } # Check user capabilities if (!Metasync::current_user_has_plugin_access()) { wp_send_json_error(array('message' => 'Insufficient permissions.')); return; } # Get and validate form data $issue_message = isset($_POST['issue_message']) ? sanitize_textarea_field(wp_unslash($_POST['issue_message'])) : ''; $issue_severity = isset($_POST['issue_severity']) ? sanitize_text_field(wp_unslash($_POST['issue_severity'])) : 'warning'; $include_user_info = isset($_POST['include_user_info']) && sanitize_text_field(wp_unslash($_POST['include_user_info'])) === 'true'; # Validate severity level $valid_severity_levels = array('info', 'warning', 'error', 'fatal'); if (!in_array($issue_severity, $valid_severity_levels, true)) { $issue_severity = 'warning'; } # Validate message length if (empty($issue_message) || strlen($issue_message) < 10) { wp_send_json_error(array('message' => 'Please provide a more detailed description (at least 10 characters).')); return; } if (strlen($issue_message) > 1000) { wp_send_json_error(array('message' => 'Message is too long. Please limit to 1000 characters.')); return; } # Handle file upload if present $attachment = null; if (!empty($_FILES['issue_attachment']['tmp_name'])) { # Validate file type using server-side MIME detection (not client-supplied type) $tmp_name = $_FILES['issue_attachment']['tmp_name']; $filename = sanitize_file_name($_FILES['issue_attachment']['name']); $file_info = wp_check_filetype_and_ext($tmp_name, $filename); if (!$file_info['ext']) { wp_send_json_error(array('message' => 'Invalid file type. Please upload a JPEG, PNG, GIF, or WebP image.')); return; } $allowed_extensions = array('jpg', 'jpeg', 'png', 'gif', 'webp'); if (!in_array($file_info['ext'], $allowed_extensions, true)) { wp_send_json_error(array('message' => 'Invalid file type. Please upload a JPEG, PNG, GIF, or WebP image.')); return; } $file_type = $file_info['type']; # Validate file size (5MB max) $max_size = 5 * 1024 * 1024; // 5MB if ($_FILES['issue_attachment']['size'] > $max_size) { wp_send_json_error(array('message' => 'File size exceeds 5MB. Please choose a smaller file.')); return; } # Read file contents $file_contents = file_get_contents($tmp_name); if ($file_contents !== false) { $attachment = array( 'filename' => $filename, 'data' => $file_contents, 'content_type' => $file_type ); } } # Get general options (same way as used throughout the plugin) $general_options = Metasync::get_option('general'); if (!is_array($general_options)) { $general_options = array(); } $project_uuid = isset($general_options['otto_pixel_uuid']) ? sanitize_text_field($general_options['otto_pixel_uuid']) : ''; # Always use standardized title format for Sentry prioritization $issue_title = !empty($project_uuid) ? 'Client Report ' . $project_uuid : 'Client Report (UUID Not Configured)'; # Collect system information with error handling $active_plugins = get_option('active_plugins'); $plugin_count = is_array($active_plugins) ? count($active_plugins) : 0; $active_theme = wp_get_theme(); $theme_name = is_object($active_theme) ? $active_theme->get('Name') : get_template(); $system_context = array( 'report_type' => 'manual_client_report', 'website_url' => esc_url_raw(home_url()), 'site_title' => sanitize_text_field(get_bloginfo('name')), 'admin_email' => sanitize_email(get_bloginfo('admin_email')), 'plugin_version' => defined('METASYNC_VERSION') ? METASYNC_VERSION : '1.0.0', 'plugin_name' => 'Search Engine Labs SEO (MetaSync)', 'wordpress_version' => get_bloginfo('version'), 'php_version' => PHP_VERSION, 'active_theme' => $theme_name, 'memory_limit' => ini_get('memory_limit'), 'multisite' => is_multisite(), 'project_uuid' => $project_uuid, 'active_plugins' => $plugin_count, 'report_timestamp' => current_time('mysql'), 'severity_level' => $issue_severity ); # Add user information if requested if ($include_user_info) { $current_user = wp_get_current_user(); if ($current_user && $current_user->ID > 0) { $system_context['reporter'] = array( 'username' => sanitize_user($current_user->user_login), 'email' => sanitize_email($current_user->user_email), 'display_name' => sanitize_text_field($current_user->display_name), 'roles' => is_array($current_user->roles) ? $current_user->roles : array() ); } } # Send to Sentry using User Feedback API $sent_to_sentry = false; # Check if Sentry feedback function exists if (!function_exists('metasync_sentry_capture_feedback')) { # Log warning if function doesn't exist if (defined('WP_DEBUG') && WP_DEBUG) { error_log('MetaSync: Sentry feedback function not available for report submission.'); } } else { $feedback_data = array( 'message' => $issue_message, 'severity' => $issue_severity ); # Add user information if requested if ($include_user_info) { $current_user = wp_get_current_user(); if ($current_user && $current_user->ID > 0) { $feedback_data['name'] = sanitize_text_field($current_user->display_name); $feedback_data['email'] = sanitize_email($current_user->user_email); } } $sent_to_sentry = metasync_sentry_capture_feedback($feedback_data, $attachment); } if ($sent_to_sentry) { wp_send_json_success(array( 'message' => 'Report submitted successfully! Our team will review it shortly.', 'project_uuid' => $project_uuid, 'report_title' => esc_html($issue_title) )); } else { # Fallback: Log locally if Sentry fails or is unavailable if (defined('WP_DEBUG') && WP_DEBUG) { error_log(sprintf( 'MetaSync Client Report (Fallback): UUID: %s | Title: %s | Message: %s | Severity: %s', $project_uuid, $issue_title, $issue_message, $issue_severity )); } wp_send_json_success(array( 'message' => 'Report logged locally. Note: Remote reporting may be unavailable.', 'project_uuid' => $project_uuid, 'report_title' => esc_html($issue_title), 'fallback' => true )); } } catch (Exception $e) { # Log the error securely (only in debug mode) if (defined('WP_DEBUG') && WP_DEBUG) { error_log(sprintf( 'MetaSync Report Submission Error: %s in %s on line %d', $e->getMessage(), $e->getFile(), $e->getLine() )); } # Send generic error message to client wp_send_json_error(array('message' => 'Failed to submit report. Please try again later.')); } } public function ajax_recover_password() { $rate_owner = ''; try { if (!isset($_POST['nonce']) || !wp_verify_nonce(sanitize_text_field(wp_unslash($_POST['nonce'])), 'metasync_recover_password_nonce')) { wp_send_json_error(array('message' => 'Security verification failed.')); return; } if (!current_user_can('manage_options')) { wp_send_json_error(array('message' => 'You do not have permission to request a password recovery.'), 403); return; } if (!$this->acquire_recovery_lock($rate_owner)) { wp_send_json_error(array('message' => 'Too many recovery requests. Please try again later.'), 429); return; } register_shutdown_function(function () use (&$rate_owner) { if ($rate_owner !== '') { Metasync_Admin_Ajax::instance()->release_recovery_lock($rate_owner); } }); $this->refresh_recovery_state_cache(); $whitelabel_settings = Metasync::get_whitelabel_settings(); $recovery_email = $whitelabel_settings['recovery_email'] ?? ''; $stored_password = $whitelabel_settings['settings_password'] ?? ''; if (!is_string($stored_password) || $stored_password === '') { $this->release_recovery_lock($rate_owner); $rate_owner = ''; wp_send_json_error(array('message' => 'No password is configured for recovery.')); return; } if (empty($recovery_email) || !is_email($recovery_email)) { $this->release_recovery_lock($rate_owner); $rate_owner = ''; wp_send_json_error(array('message' => 'No valid recovery email is configured. Please contact your administrator.')); return; } // Bound recovery requests per user and per site to limit abuse. $user_id = get_current_user_id(); $user_rate_key = 'metasync_pw_recovery_user_' . $user_id; $site_rate_key = 'metasync_pw_recovery_site'; $this->refresh_recovery_state_cache($user_rate_key); $this->refresh_recovery_state_cache($site_rate_key); $user_attempts = (int) get_transient($user_rate_key); $site_attempts = (int) get_transient($site_rate_key); if ($user_attempts >= 3 || $site_attempts >= 10) { $this->release_recovery_lock($rate_owner); wp_send_json_error(array('message' => 'Too many recovery requests. Please try again later.'), 429); return; } if (!set_transient($user_rate_key, $user_attempts + 1, HOUR_IN_SECONDS) || !set_transient($site_rate_key, $site_attempts + 1, HOUR_IN_SECONDS)) { if ($user_attempts > 0) set_transient($user_rate_key, $user_attempts, HOUR_IN_SECONDS); else delete_transient($user_rate_key); if ($site_attempts > 0) set_transient($site_rate_key, $site_attempts, HOUR_IN_SECONDS); else delete_transient($site_rate_key); $this->release_recovery_lock($rate_owner); wp_send_json_error(array('message' => 'Unable to process recovery request. Please try again later.')); return; } // Keep only a hash in the database; the raw token is sent once by email. $token = bin2hex(random_bytes(32)); $token_hash = hash('sha256', $token); $reset_key = 'metasync_pw_reset_' . $token_hash; if (!set_transient($reset_key, array('created_by' => $user_id), 30 * MINUTE_IN_SECONDS)) { if ($user_attempts > 0) set_transient($user_rate_key, $user_attempts, HOUR_IN_SECONDS); else delete_transient($user_rate_key); if ($site_attempts > 0) set_transient($site_rate_key, $site_attempts, HOUR_IN_SECONDS); else delete_transient($site_rate_key); $this->release_recovery_lock($rate_owner); $rate_owner = ''; wp_send_json_error(array('message' => 'Unable to create a recovery request. Please try again later.')); return; } $this->release_recovery_lock($rate_owner); $rate_owner = ''; $site_name = get_bloginfo('name'); $site_url = home_url(); $plugin_name = Metasync::get_effective_plugin_name(''); $settings_url = admin_url('admin.php?page=' . Metasync_Admin::$page_slug . '&tab=whitelabel&metasync_password_reset=' . rawurlencode($token)); $to = $recovery_email; $subject = sprintf('[%s] Settings Password Recovery', $site_name); $message = sprintf( "Hello,\n\n" . "A password recovery request was made for the %s settings on %s.\n\n" . "Use this one-time link to choose a new Settings Password (valid for 30 minutes):\n%s\n\n" . "If you did not request this password recovery, please secure your WordPress admin account immediately.\n\n" . "---\n" . "This is an automated message from %s\n%s", $plugin_name, $site_name, $settings_url, $site_name, $site_url ); $from_name = !empty($whitelabel_settings['company_name']) ? $whitelabel_settings['company_name'] : $site_name; $headers = array( 'Content-Type: text/plain; charset=UTF-8', sprintf('From: %s <%s>', $from_name, get_option('admin_email')) ); $sent = wp_mail($to, $subject, $message, $headers); if ($sent) { wp_send_json_success(array( 'message' => sprintf('Password recovery email sent to %s', esc_html($recovery_email)) )); } else { wp_send_json_error(array('message' => 'Failed to send recovery email. Please check your email configuration or contact your administrator.')); } } catch (Exception $e) { // Always release the bounded issuance lock if an unexpected // exception occurs after it was acquired. if ($rate_owner !== '') { $this->release_recovery_lock($rate_owner); } if (defined('WP_DEBUG') && WP_DEBUG) { error_log(sprintf( 'MetaSync Password Recovery Error: %s in %s on line %d', $e->getMessage(), $e->getFile(), $e->getLine() )); } wp_send_json_error(array('message' => 'An error occurred while processing your request. Please try again later.')); } } /** Render and process the administrator-only password reset screen. */ public function render_password_reset_page() { if (!current_user_can('manage_options')) { wp_die('You do not have permission to reset this password.', '', array('response' => 403)); } $token = isset($_REQUEST['metasync_password_reset']) ? (string) wp_unslash($_REQUEST['metasync_password_reset']) : ''; $token_hash = ($token !== '' && preg_match('/^[a-f0-9]{64}$/', $token)) ? hash('sha256', $token) : ''; $generation = (int) get_option('metasync_pw_reset_generation', 0); $transient_key = $token_hash !== '' ? 'metasync_pw_reset_' . $token_hash : ''; $error = ''; $success = false; if ($_SERVER['REQUEST_METHOD'] === 'POST') { if (!isset($_POST['metasync_reset_nonce']) || !wp_verify_nonce(sanitize_text_field(wp_unslash($_POST['metasync_reset_nonce'])), 'metasync_reset_password')) { $error = 'Security verification failed.'; } elseif ($token === '' || false === get_transient($transient_key)) { $error = 'This recovery link is invalid or has expired.'; } else { $password = isset($_POST['new_password']) ? (string) wp_unslash($_POST['new_password']) : ''; $confirm = isset($_POST['confirm_password']) ? (string) wp_unslash($_POST['confirm_password']) : ''; if (strlen($password) < 8) { $error = 'Password must be at least 8 characters long.'; } elseif (!hash_equals($password, $confirm)) { $error = 'Passwords do not match.'; } elseif (strpos($password, 'enc_v1:') === 0) { $error = 'Unable to save this password. Please choose a different password.'; } else { // add_option is an atomic insert in WordPress; it closes the // race where two requests attempt to redeem the same token. $lock_key = ''; if (!$this->acquire_recovery_lock($lock_key)) { $error = 'Another recovery request is being processed. Please wait a moment and try again.'; } else { register_shutdown_function(function () use (&$lock_key) { if ($lock_key !== '') { Metasync_Admin_Ajax::instance()->release_recovery_lock($lock_key); } }); $this->refresh_recovery_state_cache($transient_key); // Refresh the settings version while holding the shared // lock. Token keys are independent so issuing or using // one link does not invalidate another unexpired link. $generation = (int) get_option('metasync_pw_reset_generation', 0); $transient_key = $token_hash !== '' ? 'metasync_pw_reset_' . $token_hash : ''; // Re-check after claiming the lock. Consume only after // every required write succeeds, allowing a retry after // a temporary database or revocation failure. if (false === get_transient($transient_key)) { $error = 'This recovery link is invalid or has already been used.'; $this->release_recovery_lock($lock_key); $lock_key = ''; echo '
' . esc_html($error) . '
Password reset successfully. You can now return to the White Label settings.
' . esc_html($error) . '
This recovery link is invalid or has expired.