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
← All changes | includes/class-metasync-redirections-admin.php +166 -31 2.7.0 → 2.7.2 View file →
@@ -65,9 +65,9 @@
65 65 $this->check_database_structure();
66 66
67 67 $this->ensure_404_monitor_table();
68 68
69 - $current_tab = isset($_GET['tab']) ? sanitize_text_field($_GET['tab']) : 'redirections';
69 + $current_tab = isset($_GET['tab']) ? sanitize_text_field(wp_unslash($_GET['tab'])) : 'redirections'; // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only tab selection
70 70
71 71 $this->add_tabbed_interface_assets();
72 72 $this->admin->render_layout_open('Redirections', 'redirections', 'Manage URL redirects and monitor 404 errors on your site.');
73 73 $this->render_tab_navigation($current_tab);
@@ -101,9 +101,9 @@
101 101
102 102 private function safe_redirect($url)
103 103 {
104 104 if (!headers_sent()) {
105 - wp_redirect($url);
105 + wp_safe_redirect($url);
106 106 exit;
107 107 } else {
108 108 echo '<script type="text/javascript">window.location.href = "' . esc_url($url) . '";</script>';
109 109 echo '<noscript><meta http-equiv="refresh" content="0;url=' . esc_url($url) . '"></noscript>';
@@ -135,15 +135,18 @@
135 135 if (!Metasync::current_user_has_plugin_access()) {
136 136 wp_die('Insufficient permissions.');
137 137 }
138 138
139 - $source_urls = isset($_POST['source_url']) ? array_map('sanitize_text_field', $_POST['source_url']) : [];
140 - $search_types = isset($_POST['search_type']) ? array_map('sanitize_text_field', $_POST['search_type']) : [];
141 - $destination_url = isset($_POST['destination_url']) ? sanitize_text_field($_POST['destination_url']) : '';
139 + // Unslash before sanitizing: source URLs with apostrophes/backslashes
140 + // must be stored in their literal form so the front-end matcher (which
141 + // compares against the unslashed request URI) can hit them.
142 + $source_urls = isset($_POST['source_url']) ? array_map('sanitize_text_field', wp_unslash($_POST['source_url'])) : [];
143 + $search_types = isset($_POST['search_type']) ? array_map('sanitize_text_field', wp_unslash($_POST['search_type'])) : [];
144 + $destination_url = isset($_POST['destination_url']) ? sanitize_text_field(wp_unslash($_POST['destination_url'])) : '';
142 145 $redirect_type = isset($_POST['redirect_type']) ? intval($_POST['redirect_type']) : 301;
143 - $status = isset($_POST['status']) ? sanitize_text_field($_POST['status']) : 'active';
146 + $status = isset($_POST['status']) ? sanitize_text_field(wp_unslash($_POST['status'])) : 'active';
144 147 $regex_pattern = isset($_POST['regex_pattern']) ? wp_unslash(trim($_POST['regex_pattern'])) : '';
145 - $description = isset($_POST['description']) ? sanitize_text_field($_POST['description']) : '';
148 + $description = isset($_POST['description']) ? sanitize_text_field(wp_unslash($_POST['description'])) : '';
146 149 $redirect_id = isset($_POST['redirect_id']) ? intval($_POST['redirect_id']) : 0;
147 150
148 151 $validation_errors = [];
149 152
@@ -155,21 +158,20 @@
155 158
156 159 foreach ($source_urls as $source_url) {
157 160 $trimmed_url = trim($source_url);
158 161
159 - if (empty($trimmed_url)) {
160 - $empty_count++;
161 - continue;
162 - }
162 + if (!empty($trimmed_url)) {
163 + if (!$this->is_valid_url($trimmed_url)) {
164 + $validation_errors[] = 'Invalid source URL format: "' . esc_html($trimmed_url) . '". URLs should start with / for relative paths or be complete URLs.';
165 + }
163 166
164 - if (!$this->is_valid_url($trimmed_url)) {
165 - $validation_errors[] = 'Invalid source URL format: "' . esc_html($trimmed_url) . '". URLs should start with / for relative paths or be complete URLs.';
166 - }
167 -
168 - if (in_array($trimmed_url, $processed_sources)) {
169 - $validation_errors[] = 'Duplicate source URL detected: "' . esc_html($trimmed_url) . '".';
167 + if (in_array($trimmed_url, $processed_sources)) {
168 + $validation_errors[] = 'Duplicate source URL detected: "' . esc_html($trimmed_url) . '".';
169 + } else {
170 + $processed_sources[] = $trimmed_url;
171 + }
170 172 } else {
171 - $processed_sources[] = $trimmed_url;
173 + $empty_count++;
172 174 }
173 175 }
174 176
175 177 if ($empty_count === count($source_urls)) {
@@ -219,9 +221,12 @@
219 221 }
220 222 }
221 223
222 224 $pattern_type = 'exact';
223 - foreach ($search_types as $search_type) {
225 + // Derive the global type only from populated rows. A blank placeholder
226 + // with a changed select must not trigger regex validation or alter the
227 + // behavior of a redirect the user actually entered.
228 + foreach ($sources_from as $search_type) {
224 229 if (!empty($search_type)) {
225 230 $pattern_type = $search_type;
226 231 break;
227 232 }
@@ -297,8 +302,23 @@
297 302 set_transient('metasync_redirection_warning_' . $uid, $dest_warning, 45);
298 303 }
299 304 }
300 305
306 + // Non-blocking duplicate-source check: warn (but never block) when a
307 + // submitted source duplicates an existing ACTIVE rule's exact source.
308 + // Two active rows with the same normalized exact source path cannot
309 + // both fire — the engine's index only keeps one — so saving a second
310 + // active rule for a source that already has one creates a dead row.
311 + // Exclude the row being edited so an edit-and-re-save does not warn
312 + // against itself. An inactive submission cannot shadow (the engine's
313 + // index only holds active rows), so skip the check in that case.
314 + if ($status === 'active') {
315 + $duplicate_warnings = $this->check_duplicate_active_sources($sources_from, $pattern_type, $redirect_id);
316 + if ($duplicate_warnings !== '') {
317 + set_transient('metasync_redirection_warning_' . $uid, $duplicate_warnings, 45);
318 + }
319 + }
320 +
301 321 $data = [
302 322 'sources_from' => serialize($sources_from),
303 323 'url_redirect_to' => $destination_url,
304 324 'http_code' => $redirect_type,
@@ -323,9 +343,9 @@
323 343 $message = 'Redirection added successfully.';
324 344 }
325 345
326 346 } catch (Exception $e) {
327 - error_log('MetaSync error: ' . $e->getMessage());
347 + error_log('MetaSync error: ' . $e->getMessage()); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- genuine failure path, bounded, no secrets
328 348 set_transient('metasync_redirection_error_' . $uid, 'An error occurred while saving the redirection. Please try again.', 45);
329 349 $this->safe_redirect(admin_url('admin.php?page=' . Metasync_Admin::$page_slug . '-redirections'));
330 350 }
331 351
@@ -342,8 +362,123 @@
342 362
343 363 return filter_var($url, FILTER_VALIDATE_URL) !== false;
344 364 }
345 365
366 + /**
367 + * Non-blocking check for duplicate active exact sources.
368 + *
369 + * When a new (or edited) redirect saves an active exact-match source that
370 + * another active row already uses, only one of the two will ever fire —
371 + * the engine's exact-index keeps a single winner per source path. This
372 + * returns a human-readable warning string listing the colliding sources
373 + * (and the existing rule's destination) so the user knows the save
374 + * succeeded but created a shadowed rule. It never blocks the save.
375 + *
376 + * Normalization mirrors Metasync_Redirection::ensure_redirect_index()
377 + * exactly: extract the path from full URLs, force a leading slash, strip
378 + * a trailing slash, and treat '/' as the root. Only exact-pattern sources
379 + * are checked — wildcard/regex/contain/start/end sources do not collapse
380 + * onto a single index key.
381 + *
382 + * @param array $sources_from Submitted sources (url => pattern_type).
383 + * @param string $pattern_type Global pattern type for the submission.
384 + * @param int $redirect_id ID of the row being edited (excluded from the check), 0 for new.
385 + * @return string Warning message, or '' when no duplicate was found.
386 + */
387 + private function check_duplicate_active_sources(array $sources_from, $pattern_type, $redirect_id)
388 + {
389 + // Only exact-pattern submissions can shadow. If the global pattern type
390 + // is not exact (regex/wildcard/contain/start/end) the sources do not go
391 + // into the engine's exact index, so there is nothing to warn about.
392 + if ($pattern_type !== 'exact') {
393 + return '';
394 + }
395 +
396 + // Normalize the submitted sources into the same keys the engine uses.
397 + $submitted_norms = array();
398 + foreach (array_keys($sources_from) as $source_url) {
399 + $source_url = trim((string) $source_url);
400 + if ($source_url === '') {
401 + continue;
402 + }
403 + $norm = $source_url;
404 + if (strpos($norm, 'http') === 0) {
405 + $parsed = wp_parse_url($norm);
406 + $norm = isset($parsed['path']) ? $parsed['path'] : '/';
407 + }
408 + if ($norm === '' || $norm[0] !== '/') {
409 + $norm = '/' . $norm;
410 + }
411 + $norm = rtrim($norm, '/') ?: '/';
412 + $submitted_norms[$norm] = $source_url;
413 + }
414 +
415 + if (empty($submitted_norms)) {
416 + return '';
417 + }
418 +
419 + // Build an index of existing active rows' normalized exact sources,
420 + // excluding the row being edited so an edit does not warn against itself.
421 + $existing = array(); // norm => [ 'destination' => string ]
422 + $active_rows = $this->db_redirection->getAllActiveRecords();
423 + if (is_array($active_rows)) {
424 + foreach ($active_rows as $row) {
425 + if ($redirect_id > 0 && (int) $row->id === (int) $redirect_id) {
426 + continue;
427 + }
428 + $sources = !empty($row->sources_from)
429 + ? unserialize($row->sources_from, ['allowed_classes' => false])
430 + : array();
431 + $source_urls = is_array($sources) ? $sources : array();
432 + $global_pattern_type = isset($row->pattern_type) ? $row->pattern_type : null;
433 +
434 + foreach ($source_urls as $source_key => $source_value) {
435 + // Legacy list-format rows store the URL as the VALUE under a
436 + // numeric key; remap so the legacy source is normalized.
437 + if ((is_int($source_key) || ctype_digit((string) $source_key)) && is_string($source_value) && $source_value !== '') {
438 + $source_key = $source_value;
439 + }
440 + $row_pattern_type = in_array($source_value, ['exact', 'contain', 'start', 'end', 'wildcard', 'regex'])
441 + ? $source_value
442 + : ($global_pattern_type ? $global_pattern_type : 'exact');
443 +
444 + if ($row_pattern_type !== 'exact' || strpos((string) $source_key, '*') !== false) {
445 + continue;
446 + }
447 +
448 + $norm = (string) $source_key;
449 + if (strpos($norm, 'http') === 0) {
450 + $parsed = wp_parse_url($norm);
451 + $norm = isset($parsed['path']) ? $parsed['path'] : '/';
452 + }
453 + if ($norm === '' || $norm[0] !== '/') {
454 + $norm = '/' . $norm;
455 + }
456 + $norm = rtrim($norm, '/') ?: '/';
457 +
458 + $existing[$norm] = isset($row->url_redirect_to) ? (string) $row->url_redirect_to : '';
459 + }
460 + }
461 + }
462 +
463 + $collisions = array();
464 + foreach ($submitted_norms as $norm => $original_source) {
465 + if (isset($existing[$norm])) {
466 + $collisions[] = sprintf(
467 + 'Source "%s" is already used by an active redirect (destination: %s). Only one of the two rules will fire.',
468 + $original_source,
469 + $existing[$norm]
470 + );
471 + }
472 + }
473 +
474 + if (empty($collisions)) {
475 + return '';
476 + }
477 +
478 + return implode(' ', $collisions);
479 + }
480 +
346 481 private function add_tabbed_interface_assets()
347 482 {
348 483 ?>
349 484 <style>
@@ -548,14 +683,14 @@
548 683
549 684 private function render_redirections_tab($current_tab = null)
550 685 {
551 686 if ($current_tab === null) {
552 - $current_tab = isset($_GET['tab']) ? sanitize_text_field($_GET['tab']) : 'redirections';
687 + $current_tab = isset($_GET['tab']) ? sanitize_text_field(wp_unslash($_GET['tab'])) : 'redirections'; // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only tab selection
553 688 }
554 689 $active_class = $current_tab === 'redirections' ? 'active' : '';
555 690
556 691 ?>
557 - <div id="redirections-content" class="metasync-tab-content <?php echo $active_class; ?>">
692 + <div id="redirections-content" class="metasync-tab-content <?php echo esc_attr($active_class); ?>">
558 693 <?php
559 694 $redirection = new Metasync_Redirection($this->db_redirection);
560 695 $redirection->create_admin_redirection_interface();
561 696 ?>
@@ -565,14 +700,14 @@
565 700
566 701 private function render_404_monitor_tab($current_tab = null)
567 702 {
568 703 if ($current_tab === null) {
569 - $current_tab = isset($_GET['tab']) ? sanitize_text_field($_GET['tab']) : 'redirections';
704 + $current_tab = isset($_GET['tab']) ? sanitize_text_field(wp_unslash($_GET['tab'])) : 'redirections'; // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only tab selection
570 705 }
571 706 $active_class = $current_tab === '404-monitor' ? 'active' : '';
572 707
573 708 ?>
574 - <div id="404-monitor-content" class="metasync-tab-content <?php echo $active_class; ?>">
709 + <div id="404-monitor-content" class="metasync-tab-content <?php echo esc_attr($active_class); ?>">
575 710 <?php
576 711 try {
577 712 require_once plugin_dir_path(dirname(__FILE__)) . '404-monitor/class-metasync-404-monitor-database.php';
578 713 require_once plugin_dir_path(dirname(__FILE__)) . '404-monitor/class-metasync-404-monitor.php';
@@ -582,12 +717,12 @@
582 717
583 718 $ErrorMonitor->create_admin_plugin_interface();
584 719
585 720 } catch (Exception $e) {
586 - error_log('MetaSync 404 Monitor Error: ' . $e->getMessage());
721 + error_log('MetaSync 404 Monitor Error: ' . $e->getMessage()); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- genuine failure path, bounded, no secrets
587 722 echo '<div class="notice notice-error"><p>An error occurred while loading the 404 monitor. Please check the server logs for details.</p></div>';
588 723 } catch (Error $e) {
589 - error_log('MetaSync 404 Monitor Fatal Error: ' . $e->getMessage());
724 + error_log('MetaSync 404 Monitor Fatal Error: ' . $e->getMessage()); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- genuine failure path, bounded, no secrets
590 725 echo '<div class="notice notice-error"><p>A fatal error occurred while loading the 404 monitor. Please check the server logs for details.</p></div>';
591 726 }
592 727 ?>
593 728 </div>
@@ -600,9 +735,9 @@
600 735 require_once plugin_dir_path(dirname(__FILE__)) . '404-monitor/class-metasync-404-monitor-database.php';
601 736
602 737 $table_name = $wpdb->prefix . Metasync_Error_Monitor_Database::$table_name;
603 738
604 - if ($wpdb->get_var($wpdb->prepare("SHOW TABLES LIKE %s", $table_name)) != $table_name) {
739 + if ($wpdb->get_var($wpdb->prepare("SHOW TABLES LIKE %s", $table_name)) != $table_name) { // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- admin repair action — re-runs the migration check on demand from the UI
605 740 require_once plugin_dir_path(dirname(__FILE__)) . 'database/class-db-migrations.php';
606 741 MetaSync_DBMigration::run_migrations();
607 742 }
608 743 }
@@ -611,13 +746,13 @@
611 746 {
612 747 global $wpdb;
613 748 $table_name = $wpdb->prefix . 'metasync_redirections';
614 749
615 - if ($wpdb->get_var($wpdb->prepare("SHOW TABLES LIKE %s", $table_name)) != $table_name) {
750 + if ($wpdb->get_var($wpdb->prepare("SHOW TABLES LIKE %s", $table_name)) != $table_name) { // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- admin repair action — re-runs the migration check on demand from the UI
616 751 return;
617 752 }
618 753
619 - $columns = $wpdb->get_col("DESCRIBE {$table_name}");
754 + $columns = $wpdb->get_col("DESCRIBE {$wpdb->prefix}metasync_redirections"); // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- admin repair action — re-runs the migration check on demand from the UI
620 755 $required_columns = ['pattern_type', 'regex_pattern', 'description', 'created_at', 'updated_at', 'last_accessed_at'];
621 756
622 757 $missing_columns = array_diff($required_columns, $columns);
623 758
@@ -623,9 +758,9 @@
623 758
624 759 if (!empty($missing_columns)) {
625 760 add_action('admin_notices', function() use ($missing_columns) {
626 761 echo '<div class="notice notice-warning is-dismissible">';
627 - echo '<p><strong>MetaSync:</strong> Database structure needs updating. Missing columns: ' . implode(', ', $missing_columns) . '</p>';
762 + echo '<p><strong>MetaSync:</strong> Database structure needs updating. Missing columns: ' . esc_html(implode(', ', $missing_columns)) . '</p>';
628 763 echo '<p><button type="button" class="button button-secondary" onclick="updateDatabaseStructure()">Update Database Structure</button></p>';
629 764 echo '</div>';
630 765
631 766 echo '<script>
@@ -632,9 +767,9 @@
632 767 function updateDatabaseStructure() {
633 768 if (confirm("This will update your database structure. Continue?")) {
634 769 const formData = new FormData();
635 770 formData.append("action", "metasync_update_db_structure");
636 - formData.append("nonce", "' . wp_create_nonce('metasync_update_db_nonce') . '");
771 + formData.append("nonce", "' . esc_attr(wp_create_nonce('metasync_update_db_nonce')) . '");
637 772
638 773 fetch(ajaxurl, {
639 774 method: "POST",
640 775 body: formData