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 | redirections/class-metasync-redirection-list-table.php +215 -35 2.6.26 → 2.7.2 View file →
@@ -27,8 +27,19 @@
27 27 private $db_redirection;
28 28 private $_displayed = false;
29 29 private $_nav_displayed = [];
30 30 /**
31 + * Lazy-built map of shadowed row IDs → winning row destination.
32 + *
33 + * Replicates Metasync_Redirection::ensure_redirect_index() semantics:
34 + * active rows are iterated in the same ORDER BY created_at DESC, id DESC
35 + * order as the engine, the last write to each normalized exact source
36 + * path wins, and every earlier (newer) row sharing that key is shadowed.
37 + *
38 + * @var array<int, string>|null
39 + */
40 + private $shadowed_sources = null;
41 + /**
31 42 * User-resolved results-per-page value for this list (validated against
32 43 * Metasync_Per_Page_Helper::ALLOWED_VALUES). Cached here so pagination()
33 44 * can reuse it without re-resolving from the request / user meta.
34 45 *
@@ -49,9 +60,9 @@
49 60 * Get the current page number for redirections (use separate parameter)
50 61 */
51 62 public function get_pagenum()
52 63 {
53 - $pagenum = isset($_REQUEST['paged_redir']) ? absint($_REQUEST['paged_redir']) : 0;
64 + $pagenum = isset($_REQUEST['paged_redir']) ? absint($_REQUEST['paged_redir']) : 0; // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only page number for the list query
54 65
55 66 if (isset($this->_pagination_args['total_pages']) && $pagenum > $this->_pagination_args['total_pages']) {
56 67 $pagenum = $this->_pagination_args['total_pages'];
57 68 }
@@ -103,14 +114,14 @@
103 114
104 115 $current_url = remove_query_arg($removable_query_args, $current_url);
105 116
106 117 // Preserve the tab parameter
107 - if (isset($_GET['tab'])) {
108 - $current_url = add_query_arg('tab', sanitize_text_field($_GET['tab']), $current_url);
118 + if (isset($_GET['tab'])) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only values used in pagination links
119 + $current_url = add_query_arg('tab', sanitize_text_field(wp_unslash($_GET['tab'])), $current_url); // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only values used in pagination links
109 120 }
110 121
111 122 // If filter action was submitted, remove paged_redir to reset to page 1
112 - if (isset($_POST['filter_action']) || isset($_POST['post-query-submit'])) {
123 + if (isset($_POST['filter_action']) || isset($_POST['post-query-submit'])) { // phpcs:ignore WordPress.Security.NonceVerification.Missing -- read-only values used in pagination links
113 124 $current_url = remove_query_arg('paged_redir', $current_url);
114 125 }
115 126
116 127 // Rebuild visible list state from the allow-listed request values. Removing
@@ -150,9 +161,9 @@
150 161 } else {
151 162 $page_links[] = sprintf(
152 163 "<a class='first-page button' href='%s'><span class='screen-reader-text'>%s</span><span aria-hidden='true'>%s</span></a>",
153 164 esc_url(remove_query_arg('paged_redir', $current_url)),
154 - esc_html__('First page'),
165 + esc_html__('First page', 'metasync'),
155 166 '&laquo;'
156 167 );
157 168 }
158 169
@@ -161,9 +172,9 @@
161 172 } else {
162 173 $page_links[] = sprintf(
163 174 "<a class='prev-page button' href='%s'><span class='screen-reader-text'>%s</span><span aria-hidden='true'>%s</span></a>",
164 175 esc_url(add_query_arg('paged_redir', max(1, $current - 1), $current_url)),
165 - esc_html__('Previous page'),
176 + esc_html__('Previous page', 'metasync'),
166 177 '&lsaquo;'
167 178 );
168 179 }
169 180
@@ -191,9 +202,9 @@
191 202 } else {
192 203 $page_links[] = sprintf(
193 204 "<a class='next-page button' href='%s'><span class='screen-reader-text'>%s</span><span aria-hidden='true'>%s</span></a>",
194 205 esc_url(add_query_arg('paged_redir', min($total_pages, $current + 1), $current_url)),
195 - esc_html__('Next page'),
206 + esc_html__('Next page', 'metasync'),
196 207 '&rsaquo;'
197 208 );
198 209 }
199 210
@@ -202,9 +213,9 @@
202 213 } else {
203 214 $page_links[] = sprintf(
204 215 "<a class='last-page button' href='%s'><span class='screen-reader-text'>%s</span><span aria-hidden='true'>%s</span></a>",
205 216 esc_url(add_query_arg('paged_redir', $total_pages, $current_url)),
206 - esc_html__('Last page'),
217 + esc_html__('Last page', 'metasync'),
207 218 '&raquo;'
208 219 );
209 220 }
210 221
@@ -224,8 +235,9 @@
224 235 $page_class = ' no-pages';
225 236 }
226 237 $this->_pagination = "<div class='tablenav-pages{$page_class}'>$output</div>";
227 238
239 + // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- mirrors WP core WP_List_Table::pagination(); assembled above from esc_url()/esc_html()/number_format_i18n() output
228 240 echo $this->_pagination;
229 241
230 242 remove_filter('removable_query_args', array($this, 'preserve_tab_in_pagination'));
231 243 }
@@ -301,27 +313,27 @@
301 313 if (!empty($request_state['s_redir'])) {
302 314 $filters['search'] = $request_state['s_redir'];
303 315 }
304 316
305 - if (!empty($_REQUEST['status_filter'])) {
306 - $filters['status'] = sanitize_text_field($_REQUEST['status_filter']);
317 + if (!empty($_REQUEST['status_filter'])) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only filter and sort values for the list query
318 + $filters['status'] = sanitize_text_field(wp_unslash($_REQUEST['status_filter'])); // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only filter and sort values for the list query
307 319 }
308 320
309 - if (!empty($_REQUEST['pattern_filter'])) {
310 - $filters['pattern_type'] = sanitize_text_field($_REQUEST['pattern_filter']);
321 + if (!empty($_REQUEST['pattern_filter'])) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only filter and sort values for the list query
322 + $filters['pattern_type'] = sanitize_text_field(wp_unslash($_REQUEST['pattern_filter'])); // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only filter and sort values for the list query
311 323 }
312 324
313 - if (!empty($_REQUEST['http_code_filter'])) {
314 - $filters['http_code'] = intval($_REQUEST['http_code_filter']);
325 + if (!empty($_REQUEST['http_code_filter'])) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only filter and sort values for the list query
326 + $filters['http_code'] = intval($_REQUEST['http_code_filter']); // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only filter and sort values for the list query
315 327 }
316 328
317 329 // Use separate orderby/order parameters for redirections
318 - if (!empty($_REQUEST['orderby_redir'])) {
319 - $filters['order_by'] = sanitize_sql_orderby($_REQUEST['orderby_redir']);
330 + if (!empty($_REQUEST['orderby_redir'])) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only filter and sort values for the list query
331 + $filters['order_by'] = sanitize_sql_orderby($_REQUEST['orderby_redir']); // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only filter and sort values for the list query
320 332 }
321 333
322 - if (!empty($_REQUEST['order_redir'])) {
323 - $filters['order'] = sanitize_text_field($_REQUEST['order_redir']);
334 + if (!empty($_REQUEST['order_redir'])) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only filter and sort values for the list query
335 + $filters['order'] = sanitize_text_field(wp_unslash($_REQUEST['order_redir'])); // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only filter and sort values for the list query
324 336 }
325 337
326 338 return $filters;
327 339 }
@@ -361,9 +373,9 @@
361 373 * Override to use custom orderby/order parameter names
362 374 */
363 375 protected function get_orderby()
364 376 {
365 - return isset($_REQUEST['orderby_redir']) ? sanitize_key($_REQUEST['orderby_redir']) : '';
377 + return isset($_REQUEST['orderby_redir']) ? sanitize_key(wp_unslash($_REQUEST['orderby_redir'])) : ''; // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only sort state for the column headers
366 378 }
367 379
368 380 /**
369 381 * Override to use custom orderby/order parameter names
@@ -369,9 +381,9 @@
369 381 * Override to use custom orderby/order parameter names
370 382 */
371 383 protected function get_order()
372 384 {
373 - $raw = isset($_REQUEST['order_redir']) ? strtolower(sanitize_key($_REQUEST['order_redir'])) : '';
385 + $raw = isset($_REQUEST['order_redir']) ? strtolower(sanitize_key(wp_unslash($_REQUEST['order_redir']))) : ''; // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only sort state for the column headers
374 386 if ($raw === 'desc') {
375 387 return 'desc';
376 388 }
377 389 return 'asc';
@@ -383,9 +395,9 @@
383 395 public function print_column_headers($with_id = true)
384 396 {
385 397 list($columns, $hidden, $sortable, $primary) = $this->get_column_info();
386 398
387 - $current_page = isset($_GET['page']) ? sanitize_text_field($_GET['page']) : Metasync_Admin::$page_slug;
399 + $current_page = isset($_GET['page']) ? sanitize_text_field(wp_unslash($_GET['page'])) : Metasync_Admin::$page_slug; // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only page slug used in escaped sort links
388 400 $current_url = set_url_scheme('http://' . $_SERVER['HTTP_HOST'] . $_SERVER['REQUEST_URI']);
389 401 $current_url = remove_query_arg(['_wpnonce', '_wp_http_referer', 'action', 'action2', 'id'], $current_url);
390 402 $current_url = add_query_arg('page', $current_page, $current_url);
391 403 $current_url = add_query_arg('tab', 'redirections', $current_url);
@@ -443,12 +455,57 @@
443 455 if (!empty($class)) {
444 456 $class = "class='" . esc_attr(implode(' ', $class)) . "'";
445 457 }
446 458
459 + // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- mirrors WP core WP_List_Table::print_column_headers(); $scope is a literal, $id/$class escaped via esc_attr() and \$column_display_name via esc_html() just above
447 460 echo '<' . esc_attr($tag) . ' ' . $scope . ' ' . $id . ' ' . $class . '>' . $column_display_name . '</' . esc_attr($tag) . '>';
448 461 }
449 462 }
450 463
464 + /**
465 + * Render the Last Accessed column.
466 + *
467 + * A redirect that has never been fired stores the MySQL zero-date
468 + * `0000-00-00 00:00:00` (or an empty string) in `last_accessed_at`.
469 + * Passing that raw value through to the user reads as a broken or
470 + * corrupted date, so render an explicit "Never" instead. Real
471 + * timestamps are formatted the same way the 404 Monitor formats its
472 + * date column (`date('M j, Y g:i A', ...)` with a human-time-diff
473 + * hint).
474 + *
475 + * WP_List_Table dispatches `column_{name}` methods before falling back
476 + * to `column_default()`, so defining this keeps the zero-date out of
477 + * `column_default()`'s raw `esc_html()` passthrough.
478 + */
479 + protected function column_last_accessed_at($item)
480 + {
481 + $raw = isset($item['last_accessed_at']) ? $item['last_accessed_at'] : '';
482 +
483 + // A redirect that has never been hit stores the database's empty
484 + // timestamp. Treat an empty value, the exact zero-date string, and
485 + // any unparseable / pre-epoch strtotime() result as "never accessed".
486 + if (
487 + empty($raw)
488 + || '0000-00-00 00:00:00' === $raw
489 + || '0000-00-00' === $raw
490 + ) {
491 + return esc_html__('Never', 'metasync');
492 + }
493 +
494 + $timestamp = strtotime($raw);
495 + if ($timestamp === false || $timestamp < 0) {
496 + return esc_html__('Never', 'metasync');
497 + }
498 +
499 + $time_diff = human_time_diff($timestamp, current_time('timestamp'));
500 +
501 + return sprintf(
502 + '%s<br><small>%s ago</small>',
503 + esc_html(gmdate('M j, Y g:i A', $timestamp)),
504 + esc_html($time_diff)
505 + );
506 + }
507 +
451 508 protected function column_default($item, $column_name)
452 509 {
453 510 switch ($column_name) {
454 511 case 'sources_from':
@@ -455,14 +512,14 @@
455 512 case 'url_redirect_to':
456 513 case 'http_code':
457 514 case 'hits_count':
458 515 case 'status':
459 - case 'last_accessed_at':
460 516 return esc_html($item[$column_name]); # Fixed: Added esc_html() to prevent XSS
461 517 case 'pattern_type':
462 518 return $this->column_pattern_type($item);
463 519 default:
464 520 # return print_r($item, true); // Show the whole array for troubleshooting purposes.
521 + // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_print_r -- unreachable in production
465 522 return esc_html(print_r($item, true)); # Fixed: Added esc_html() to prevent XSS in debug output
466 523 }
467 524 }
468 525
@@ -474,10 +531,120 @@
474 531 $item['id'] // The value of the checkbox should be the record's ID.
475 532 );
476 533 }
477 534
535 + /**
536 + * Build the shadowed-source index for the current page of rows.
537 + *
538 + * Two active redirect rows can share the same normalized exact source
539 + * path, but the redirect engine only ever fires one — the winner of the
540 + * last index write in ensure_redirect_index(). Every other active row
541 + * with that same source is "shadowed": it looks active in the list but
542 + * never fires. This replicates the engine's exact-index semantics
543 + * (ORDER BY created_at DESC, id DESC, last write wins) to flag the
544 + * losing rows so the user can tell which destination a visitor actually
545 + * reaches.
546 + *
547 + * Returns an array keyed by shadowed row ID, with the value being the
548 + * winning row's destination URL (for the tooltip). Only exact-pattern
549 + * sources are considered — wildcard/regex/contain/start/end sources do
550 + * not collapse onto a single key in the engine's index.
551 + *
552 + * @return array<int, string> Map of shadowed row ID => winning destination URL.
553 + */
554 + protected function get_shadowed_sources()
555 + {
556 + if ($this->shadowed_sources !== null) {
557 + return $this->shadowed_sources;
558 + }
559 +
560 + $this->shadowed_sources = array();
561 +
562 + if ($this->db_redirection === null) {
563 + return $this->shadowed_sources;
564 + }
565 +
566 + // getAllActiveRecords() returns rows ordered by created_at DESC, id DESC —
567 + // the exact same order the engine iterates in ensure_redirect_index().
568 + $active_rows = $this->db_redirection->getAllActiveRecords();
569 + if (empty($active_rows)) {
570 + return $this->shadowed_sources;
571 + }
572 +
573 + // For each normalized exact source path, the LAST row in the iteration
574 + // order wins (its assignment to exact_index[$norm] overwrites earlier
575 + // ones). Because the order is DESC, the oldest row is processed last,
576 + // so the oldest active row wins — matching the engine exactly.
577 + $winners = array(); // norm => { 'id' => int, 'destination' => string }
578 + $seen = array(); // norm => int[] row ids that wrote this key
579 +
580 + foreach ($active_rows as $row) {
581 + $sources_from = !empty($row->sources_from)
582 + ? unserialize($row->sources_from, array('allowed_classes' => false))
583 + : array();
584 + $source_urls = is_array($sources_from) ? $sources_from : array();
585 + $global_pattern_type = isset($row->pattern_type) ? $row->pattern_type : null;
586 +
587 + foreach ($source_urls as $source_key => $source_value) {
588 + // Legacy list-format rows store the URL as the VALUE under a
589 + // numeric key; remap so the legacy source lands in the exact
590 + // index instead of '/0'.
591 + if ((is_int($source_key) || ctype_digit((string) $source_key)) && is_string($source_value) && $source_value !== '') {
592 + $source_key = $source_value;
593 + }
594 + $pattern_type = in_array($source_value, array('exact', 'contain', 'start', 'end', 'wildcard', 'regex'))
595 + ? $source_value
596 + : ($global_pattern_type ? $global_pattern_type : 'exact');
597 +
598 + if ($pattern_type !== 'exact' || strpos((string) $source_key, '*') !== false) {
599 + continue;
600 + }
601 +
602 + // Normalize: extract path from full URLs, ensure leading slash, strip trailing slash.
603 + $norm = (string) $source_key;
604 + if (strpos($norm, 'http') === 0) {
605 + $parsed = wp_parse_url($norm);
606 + $norm = isset($parsed['path']) ? $parsed['path'] : '/';
607 + }
608 + if ($norm === '' || $norm[0] !== '/') {
609 + $norm = '/' . $norm;
610 + }
611 + $norm = rtrim($norm, '/') ?: '/';
612 +
613 + if (!isset($seen[$norm])) {
614 + $seen[$norm] = array();
615 + }
616 + $seen[$norm][] = (int) $row->id;
617 + // Last write wins — overwrite with each row in iteration order,
618 + // so the final value is the oldest row for this source.
619 + $winners[$norm] = array(
620 + 'id' => (int) $row->id,
621 + 'destination' => isset($row->url_redirect_to) ? (string) $row->url_redirect_to : '',
622 + );
623 + }
624 + }
625 +
626 + // Any source path claimed by more than one active row is a duplicate;
627 + // every row that is NOT the winner is shadowed.
628 + foreach ($seen as $norm => $ids) {
629 + if (count($ids) < 2 || !isset($winners[$norm])) {
630 + continue;
631 + }
632 + $winner_id = $winners[$norm]['id'];
633 + $winner_dest = $winners[$norm]['destination'];
634 + foreach ($ids as $row_id) {
635 + if ($row_id !== $winner_id) {
636 + $this->shadowed_sources[$row_id] = $winner_dest;
637 + }
638 + }
639 + }
640 +
641 + return $this->shadowed_sources;
642 + }
643 +
478 644 protected function column_sources_from($item)
479 645 {
646 + // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only, row-action links carry their own nonce
480 647 $request_data = metasync_sanitize_input_array($_REQUEST); // WPCS: Input var ok.
481 648 if (!isset($request_data['page'])) return;
482 649
483 650 // Build delete row action.
@@ -628,17 +795,17 @@
628 795 // reason, and dropping that check makes ticking rows, choosing a bulk
629 796 // action and then clicking Filter silently run the action. The Search
630 797 // submit posts no filter_action, so it needs its own named marker to
631 798 // stop the same silent delete when it shares a form with the dropdown.
632 - if (!empty($_REQUEST['filter_action']) || !empty($_REQUEST['search_submit'])) {
799 + if (!empty($_REQUEST['filter_action']) || !empty($_REQUEST['search_submit'])) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only action name, callers verify a nonce before any write
633 800 return false;
634 801 }
635 802
636 - if (isset($_REQUEST['action']) && '-1' !== $_REQUEST['action'] && '' !== $_REQUEST['action']) {
637 - return sanitize_text_field(wp_unslash($_REQUEST['action']));
803 + if (isset($_REQUEST['action']) && '-1' !== $_REQUEST['action'] && '' !== $_REQUEST['action']) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only action name, callers verify a nonce before any write
804 + return sanitize_text_field(wp_unslash($_REQUEST['action'])); // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only action name, callers verify a nonce before any write
638 805 }
639 - if (isset($_REQUEST['action2']) && '-1' !== $_REQUEST['action2'] && '' !== $_REQUEST['action2']) {
640 - return sanitize_text_field(wp_unslash($_REQUEST['action2']));
806 + if (isset($_REQUEST['action2']) && '-1' !== $_REQUEST['action2'] && '' !== $_REQUEST['action2']) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only action name, callers verify a nonce before any write
807 + return sanitize_text_field(wp_unslash($_REQUEST['action2'])); // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only action name, callers verify a nonce before any write
641 808 }
642 809 return parent::current_action();
643 810 }
644 811
@@ -739,9 +906,9 @@
739 906 }
740 907
741 908 protected function process_bulk_action()
742 909 {
743 - $post_data = metasync_sanitize_input_array($_POST);
910 + $post_data = metasync_sanitize_input_array($_POST); // phpcs:ignore WordPress.Security.NonceVerification.Missing -- check_admin_referer() runs before any write
744 911 $items = isset($post_data['items']) && is_array($post_data['items']) ? array_map('sanitize_title', $post_data['items']) : [];
745 912
746 913 if (empty($post_data['items'])) return;
747 914
@@ -947,9 +1114,10 @@
947 1114 }
948 1115
949 1116 echo sprintf(
950 1117 '<span title="Label: %3$s">%1$s%2$s</span>',
951 - $warning_icon,
1118 + // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- literal dashicon span defined in this method
1119 + $warning_icon,
952 1120 esc_html($display_text),
953 1121 esc_attr($clean_source)
954 1122 );
955 1123 } else {
@@ -963,16 +1131,28 @@
963 1131 # esc_html(_x($source_name, 'List table row action', 'metasync')) # Fixed: Added esc_html() to prevent XSS
964 1132 esc_html($clean_source)
965 1133 );
966 1134 }
1135 + echo "<br>";
1136 + }
1137 +
1138 + // Shadowed-source badge: when two active exact-match rules share the
1139 + // same normalized source path, only the oldest row (the engine's last
1140 + // index write) ever fires. Flag the losing row so the user can tell
1141 + // the dead rule from the live one.
1142 + $shadowed = $this->get_shadowed_sources();
1143 + $row_id = isset($item['id']) ? (int) $item['id'] : 0;
1144 + if ($row_id > 0 && isset($shadowed[$row_id])) {
1145 + $winner_dest = $shadowed[$row_id];
967 1146 echo sprintf(
968 - # '<span>%1$s</span>',
969 - # ' [' . $source_type . ']'
970 - # ' [' . esc_html($source_type) . ']' # Fixed: Added esc_html() to prevent XSS
971 - '<span style="color: #666; font-size: 0.9em;">%1$s</span>',
972 - ' [' . esc_html($source_type) . ']'
1147 + '<span class="dashicons dashicons-warning" style="color: #d63638; font-size: 16px; vertical-align: middle; margin-right: 3px;" title="%1$s"></span>',
1148 + esc_attr(sprintf(
1149 + /* translators: %s: destination URL of the winning (firing) rule */
1150 + __('Shadowed — this rule never fires. An earlier active rule with the same source points to: %s', 'metasync'),
1151 + $winner_dest
1152 + ))
973 1153 );
974 - echo "<br>";
1154 + echo '<span style="color: #d63638; font-size: 0.9em; font-weight: 600;">' . esc_html(__('Shadowed', 'metasync')) . '</span>';
975 1155 }
976 1156 }
977 1157
978 1158 /**