← 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 | '«' |
| 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 | '‹' |
| 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 | '›' |
| 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 | '»' |
| 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 | /** |