PluginProbe
Search Atlas SEO – OTTO AI SEO Automation for WordPress / 2.7.1
Search Atlas SEO – OTTO AI SEO Automation for WordPress v2.7.1
2.7.1 2.7.2 2.7.0 2.6.26 2.6.25 2.6.24 2.6.23 2.6.22 2.6.21 2.6.20 2.6.19 2.6.18 2.6.17 2.6.16 2.6.15 2.6.14 2.6.13 2.6.12 2.6.11 2.6.10 2.6.9 2.6.8 2.6.7 2.6.6 2.6.5 All 141 releases
metasync / redirections / class-metasync-redirection-list-table.php

class-metasync-redirection-list-table.php in Search Atlas SEO – OTTO AI SEO Automation for WordPress 2.7.1, at redirections/class-metasync-redirection-list-table.php

1,023 lines 35.9 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 // If this file is called directly, abort.
3 if (!defined('ABSPATH')) {
4 exit;
5 }
6
7
8 /**
9 * The Record list for the Redirections.
10 *
11 * @since 1.0.0
12 * @package Metasync
13 * @subpackage Metasync/redirections
14 * @author Engineering Team <[email protected]>
15 */
16 class Metasync_Redirection_List_Table extends WP_List_Table
17 {
18 /**
19 * Name of the submit-button marker that identifies an Apply submit.
20 *
21 * Matches the name WordPress core itself gives the button from 7.1 on, so
22 * the marker is core's own on new installs and ours on older ones.
23 */
24 private const BULK_APPLY_MARKER = 'bulk_action';
25
26 private $records;
27 private $db_redirection;
28 private $_displayed = false;
29 private $_nav_displayed = [];
30 /**
31 * User-resolved results-per-page value for this list (validated against
32 * Metasync_Per_Page_Helper::ALLOWED_VALUES). Cached here so pagination()
33 * can reuse it without re-resolving from the request / user meta.
34 *
35 * @var int
36 */
37 private $resolved_per_page = 10;
38 public function __construct()
39 {
40 // Set parent defaults.
41 parent::__construct(array(
42 'singular' => 'item', // Singular name of the listed records.
43 'plural' => 'items', // Plural name of the listed records.
44 'ajax' => false, // Does this table support ajax?
45 ));
46 }
47
48 /**
49 * Get the current page number for redirections (use separate parameter)
50 */
51 public function get_pagenum()
52 {
53 $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
55 if (isset($this->_pagination_args['total_pages']) && $pagenum > $this->_pagination_args['total_pages']) {
56 $pagenum = $this->_pagination_args['total_pages'];
57 }
58
59 return max(1, $pagenum);
60 }
61
62 /**
63 * Override pagination args to preserve tab parameter
64 */
65 protected function get_views()
66 {
67 return array();
68 }
69
70 /**
71 * Ensure tab parameter is preserved in pagination links
72 */
73 protected function pagination($which)
74 {
75 if (empty($this->_pagination_args)) {
76 return;
77 }
78
79 $total_items = $this->_pagination_args['total_items'];
80 $total_pages = $this->_pagination_args['total_pages'];
81 $infinite_scroll = false;
82 if (isset($this->_pagination_args['infinite_scroll'])) {
83 $infinite_scroll = $this->_pagination_args['infinite_scroll'];
84 }
85
86 if ('top' === $which && $total_pages > 1) {
87 $this->screen->render_screen_reader_content('heading_pagination');
88 }
89
90 // Add tab parameter to pagination links
91 add_filter('removable_query_args', array($this, 'preserve_tab_in_pagination'));
92
93 $output = '<span class="displaying-num">' . sprintf(
94 /* translators: %s: Number of items */
95 _n('%s item', '%s items', $total_items, 'metasync'),
96 number_format_i18n($total_items)
97 ) . '</span>';
98
99 $current = $this->get_pagenum();
100 $removable_query_args = wp_removable_query_args();
101
102 $current_url = set_url_scheme('http://' . $_SERVER['HTTP_HOST'] . $_SERVER['REQUEST_URI']);
103
104 $current_url = remove_query_arg($removable_query_args, $current_url);
105
106 // Preserve the tab parameter
107 if (isset($_GET['tab'])) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only values used in pagination links
108 $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 }
110
111 // If filter action was submitted, remove paged_redir to reset to page 1
112 if (isset($_POST['filter_action']) || isset($_POST['post-query-submit'])) { // phpcs:ignore WordPress.Security.NonceVerification.Missing -- read-only values used in pagination links
113 $current_url = remove_query_arg('paged_redir', $current_url);
114 }
115
116 // Rebuild visible list state from the allow-listed request values. Removing
117 // every key first ensures clearing a POSTed filter also clears a stale GET
118 // value from generated pagination links.
119 $state_keys = array_merge(
120 Metasync_Per_Page_Helper::state_params('redirections'),
121 [Metasync_Per_Page_Helper::request_key('redirections')]
122 );
123 $current_url = remove_query_arg($state_keys, $current_url);
124 $current_url = add_query_arg(Metasync_Per_Page_Helper::request_state('redirections'), $current_url);
125
126 $page_links = array();
127
128 $total_pages_before = '<span class="paging-input">';
129 $total_pages_after = '</span></span>';
130
131 $disable_first = $disable_last = $disable_prev = $disable_next = false;
132
133 if ($current == 1) {
134 $disable_first = true;
135 $disable_prev = true;
136 }
137 if ($current == 2) {
138 $disable_first = true;
139 }
140 if ($current == $total_pages) {
141 $disable_last = true;
142 $disable_next = true;
143 }
144 if ($current == $total_pages - 1) {
145 $disable_last = true;
146 }
147
148 if ($disable_first) {
149 $page_links[] = '<span class="tablenav-pages-navspan button disabled" aria-hidden="true">&laquo;</span>';
150 } else {
151 $page_links[] = sprintf(
152 "<a class='first-page button' href='%s'><span class='screen-reader-text'>%s</span><span aria-hidden='true'>%s</span></a>",
153 esc_url(remove_query_arg('paged_redir', $current_url)),
154 esc_html__('First page', 'metasync'),
155 '&laquo;'
156 );
157 }
158
159 if ($disable_prev) {
160 $page_links[] = '<span class="tablenav-pages-navspan button disabled" aria-hidden="true">&lsaquo;</span>';
161 } else {
162 $page_links[] = sprintf(
163 "<a class='prev-page button' href='%s'><span class='screen-reader-text'>%s</span><span aria-hidden='true'>%s</span></a>",
164 esc_url(add_query_arg('paged_redir', max(1, $current - 1), $current_url)),
165 esc_html__('Previous page', 'metasync'),
166 '&lsaquo;'
167 );
168 }
169
170 if ('bottom' === $which) {
171 $html_current_page = $current;
172 $total_pages_before = '<span class="screen-reader-text">' . esc_html__('Current Page', 'metasync') . '</span><span id="table-paging" class="paging-input"><span class="tablenav-paging-text">';
173 } else {
174 $html_current_page = sprintf(
175 "%s<input class='current-page' id='current-page-selector-redir' type='text' name='paged_redir' value='%s' size='%d' aria-describedby='table-paging' /><span class='tablenav-paging-text'>",
176 '<label for="current-page-selector-redir" class="screen-reader-text">' . esc_html__('Current Page', 'metasync') . '</label>',
177 $current,
178 strlen($total_pages)
179 );
180 }
181 $html_total_pages = sprintf("<span class='total-pages'>%s</span>", number_format_i18n($total_pages));
182 $page_links[] = $total_pages_before . sprintf(
183 /* translators: 1: Current page, 2: Total pages */
184 _x('%1$s of %2$s', 'paging', 'metasync'),
185 $html_current_page,
186 $html_total_pages
187 ) . $total_pages_after;
188
189 if ($disable_next) {
190 $page_links[] = '<span class="tablenav-pages-navspan button disabled" aria-hidden="true">&rsaquo;</span>';
191 } else {
192 $page_links[] = sprintf(
193 "<a class='next-page button' href='%s'><span class='screen-reader-text'>%s</span><span aria-hidden='true'>%s</span></a>",
194 esc_url(add_query_arg('paged_redir', min($total_pages, $current + 1), $current_url)),
195 esc_html__('Next page', 'metasync'),
196 '&rsaquo;'
197 );
198 }
199
200 if ($disable_last) {
201 $page_links[] = '<span class="tablenav-pages-navspan button disabled" aria-hidden="true">&raquo;</span>';
202 } else {
203 $page_links[] = sprintf(
204 "<a class='last-page button' href='%s'><span class='screen-reader-text'>%s</span><span aria-hidden='true'>%s</span></a>",
205 esc_url(add_query_arg('paged_redir', $total_pages, $current_url)),
206 esc_html__('Last page', 'metasync'),
207 '&raquo;'
208 );
209 }
210
211 $pagination_links_class = 'pagination-links';
212 if (!empty($infinite_scroll)) {
213 $pagination_links_class .= ' hide-if-js';
214 }
215 $output .= "\n<span class='$pagination_links_class'>" . join("\n", $page_links) . '</span>';
216
217 // Inline "rows per page" selector (10/20/50/100), persisted per user.
218 // $which keeps the top and bottom selectors' ids distinct.
219 $output .= Metasync_Per_Page_Helper::render_selector('redirections', $this->resolved_per_page, $which);
220
221 if ($total_pages) {
222 $page_class = $total_pages < 2 ? ' one-page' : '';
223 } else {
224 $page_class = ' no-pages';
225 }
226 $this->_pagination = "<div class='tablenav-pages{$page_class}'>$output</div>";
227
228 echo $this->_pagination;
229
230 remove_filter('removable_query_args', array($this, 'preserve_tab_in_pagination'));
231 }
232
233 /**
234 * Preserve tab parameter in pagination
235 */
236 public function preserve_tab_in_pagination($args)
237 {
238 // Remove 'tab' from removable query args so it's preserved
239 $key = array_search('tab', $args, true);
240 if ($key !== false) {
241 unset($args[$key]);
242 }
243
244 // Also preserve our pagination parameter
245 $key = array_search('paged_redir', $args, true);
246 if ($key !== false) {
247 unset($args[$key]);
248 }
249
250 // Preserve this list's rows-per-page value across pagination / tab changes.
251 $key = array_search(Metasync_Per_Page_Helper::request_key('redirections'), $args, true);
252 if ($key !== false) {
253 unset($args[$key]);
254 }
255
256 // Remove the other pagination parameters to avoid conflicts
257 $args[] = 'paged';
258 $args[] = 'paged_404';
259
260 return array_unique($args);
261 }
262
263 public function setDatabaseResource(&$db_redirection)
264 {
265 $this->db_redirection = $db_redirection;
266 }
267
268 private function setRecords($records)
269 {
270 return $this->records = json_decode(json_encode($records), true);
271 }
272
273 private function loadRecords()
274 {
275 $filters = $this->get_search_filters();
276 return $this->setRecords($this->db_redirection->search_redirections($filters));
277 }
278
279 private function loadRecordsWithPagination($per_page, $offset)
280 {
281 $filters = $this->get_search_filters();
282 $filters['per_page'] = $per_page;
283 $filters['offset'] = $offset;
284 return $this->setRecords($this->db_redirection->search_redirections($filters));
285 }
286
287 private function getTotalRecords()
288 {
289 $filters = $this->get_search_filters();
290 return $this->db_redirection->count_redirections($filters);
291 }
292
293 /**
294 * Get search filters from request
295 */
296 private function get_search_filters()
297 {
298 $filters = [];
299
300 $request_state = Metasync_Per_Page_Helper::request_state('redirections');
301 if (!empty($request_state['s_redir'])) {
302 $filters['search'] = $request_state['s_redir'];
303 }
304
305 if (!empty($_REQUEST['status_filter'])) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only filter and sort values for the list query
306 $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 }
308
309 if (!empty($_REQUEST['pattern_filter'])) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only filter and sort values for the list query
310 $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 }
312
313 if (!empty($_REQUEST['http_code_filter'])) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only filter and sort values for the list query
314 $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 }
316
317 // Use separate orderby/order parameters for redirections
318 if (!empty($_REQUEST['orderby_redir'])) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only filter and sort values for the list query
319 $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 }
321
322 if (!empty($_REQUEST['order_redir'])) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only filter and sort values for the list query
323 $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 }
325
326 return $filters;
327 }
328
329 public function get_columns()
330 {
331 $columns = array(
332 'cb' => '<input type="checkbox" />', // Render a checkbox instead of text.
333 'sources_from' => _x('From', 'Column label', 'metasync'),
334 'url_redirect_to' => _x('To', 'Column label', 'metasync'),
335 'http_code' => _x('Type', 'Column label', 'metasync'),
336 'pattern_type' => _x('Pattern', 'Column label', 'metasync'),
337 'hits_count' => _x('Hits', 'Column label', 'metasync'),
338 'status' => _x('Status', 'Column label', 'metasync'),
339 'last_accessed_at' => _x('Last Accessed', 'Column label', 'metasync'),
340 );
341
342 return $columns;
343 }
344
345 protected function get_sortable_columns()
346 {
347 $sortable_columns = array(
348 'sources_from' => array('sources_from', false),
349 'url_redirect_to' => array('url_redirect_to', false),
350 'http_code' => array('http_code', false),
351 'pattern_type' => array('pattern_type', false),
352 'hits_count' => array('hits_count', false),
353 'status' => array('status', false),
354 'last_accessed_at' => array('last_accessed_at', false),
355 );
356
357 return $sortable_columns;
358 }
359
360 /**
361 * Override to use custom orderby/order parameter names
362 */
363 protected function get_orderby()
364 {
365 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 }
367
368 /**
369 * Override to use custom orderby/order parameter names
370 */
371 protected function get_order()
372 {
373 $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 if ($raw === 'desc') {
375 return 'desc';
376 }
377 return 'asc';
378 }
379
380 /**
381 * Override column headers to use custom parameter names for sorting
382 */
383 public function print_column_headers($with_id = true)
384 {
385 list($columns, $hidden, $sortable, $primary) = $this->get_column_info();
386
387 $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 $current_url = set_url_scheme('http://' . $_SERVER['HTTP_HOST'] . $_SERVER['REQUEST_URI']);
389 $current_url = remove_query_arg(['_wpnonce', '_wp_http_referer', 'action', 'action2', 'id'], $current_url);
390 $current_url = add_query_arg('page', $current_page, $current_url);
391 $current_url = add_query_arg('tab', 'redirections', $current_url);
392 $current_url = remove_query_arg('paged_redir', $current_url);
393 $current_url = add_query_arg(Metasync_Per_Page_Helper::request_state('redirections'), $current_url);
394
395 $current_orderby = $this->get_orderby();
396 $current_order = $this->get_order();
397
398 foreach ($columns as $column_key => $column_display_name) {
399 $class = array('manage-column', "column-$column_key");
400
401 if (in_array($column_key, $hidden, true)) {
402 $class[] = 'hidden';
403 }
404
405 if ($column_key === $primary) {
406 $class[] = 'column-primary';
407 }
408
409 // Add check-column class for checkbox column
410 if ('cb' === $column_key) {
411 $class[] = 'check-column';
412 }
413
414 if (isset($sortable[$column_key])) {
415 list($orderby, $desc_first) = $sortable[$column_key];
416
417 if ($current_orderby === $orderby) {
418 $order = 'asc' === $current_order ? 'desc' : 'asc';
419 $class[] = 'sorted';
420 $class[] = $current_order;
421 } else {
422 $order = strtolower($desc_first);
423 if (!in_array($order, array('desc', 'asc'), true)) {
424 $order = $desc_first ? 'desc' : 'asc';
425 }
426 $class[] = 'sortable';
427 $class[] = 'desc' === $order ? 'asc' : 'desc';
428 }
429
430 $column_display_name = sprintf(
431 '<a href="%s"><span>%s</span><span class="sorting-indicators"></span></a>',
432 esc_url(add_query_arg(array('orderby_redir' => $orderby, 'order_redir' => $order), $current_url)),
433 esc_html($column_display_name)
434 );
435 } elseif ('cb' !== $column_key) {
436 $column_display_name = esc_html($column_display_name);
437 }
438
439 $tag = ('cb' === $column_key) ? 'td' : 'th';
440 $scope = ('th' === $tag) ? 'scope="col"' : '';
441 $id = $with_id ? "id='" . esc_attr($column_key) . "'" : '';
442
443 if (!empty($class)) {
444 $class = "class='" . esc_attr(implode(' ', $class)) . "'";
445 }
446
447 echo '<' . esc_attr($tag) . ' ' . $scope . ' ' . $id . ' ' . $class . '>' . $column_display_name . '</' . esc_attr($tag) . '>';
448 }
449 }
450
451 protected function column_default($item, $column_name)
452 {
453 switch ($column_name) {
454 case 'sources_from':
455 case 'url_redirect_to':
456 case 'http_code':
457 case 'hits_count':
458 case 'status':
459 case 'last_accessed_at':
460 return esc_html($item[$column_name]); # Fixed: Added esc_html() to prevent XSS
461 case 'pattern_type':
462 return $this->column_pattern_type($item);
463 default:
464 # return print_r($item, true); // Show the whole array for troubleshooting purposes.
465 // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_print_r -- unreachable in production
466 return esc_html(print_r($item, true)); # Fixed: Added esc_html() to prevent XSS in debug output
467 }
468 }
469
470 protected function column_cb($item)
471 {
472 return sprintf(
473 '<input type="checkbox" name="%1$s[]" value="%2$s" />',
474 $this->_args['plural'], // Let's simply repurpose the table's singular label ("error").
475 $item['id'] // The value of the checkbox should be the record's ID.
476 );
477 }
478
479 protected function column_sources_from($item)
480 {
481 // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only, row-action links carry their own nonce
482 $request_data = metasync_sanitize_input_array($_REQUEST); // WPCS: Input var ok.
483 if (!isset($request_data['page'])) return;
484
485 // Build delete row action.
486 $delete_query_args = array(
487 'page' => sanitize_text_field($request_data['page']),
488 'tab' => 'redirections',
489 'action' => 'delete_redirect',
490 'id' => $item['id'],
491 );
492 $per_page_key = Metasync_Per_Page_Helper::request_key('redirections');
493 if (isset($request_data[$per_page_key])) {
494 $delete_query_args[$per_page_key] = sanitize_text_field($request_data[$per_page_key]);
495 }
496
497 // Build edit row action.
498 $edit_query_args = array(
499 'page' => sanitize_text_field($request_data['page']),
500 'action' => 'edit',
501 'id' => $item['id'],
502 );
503
504 // Build activate row action.
505 $activate_query_args = array(
506 'page' => sanitize_text_field($request_data['page']),
507 'action' => 'activate',
508 'id' => $item['id'],
509 );
510
511 // Build deactivate row action.
512 $deactivate_query_args = array(
513 'page' => sanitize_text_field($request_data['page']),
514 'action' => 'deactivate',
515 'id' => $item['id'],
516 );
517
518 $actions['edit'] = sprintf(
519 '<a href="%1$s">%2$s</a>',
520 esc_url(add_query_arg($edit_query_args, 'admin.php')),
521 _x('Edit', 'List table row action', 'metasync')
522 );
523
524 if ($item['status'] === 'inactive') {
525
526 $actions['activate'] = sprintf(
527 '<a href="%1$s">%2$s</a>',
528 esc_url(wp_nonce_url(add_query_arg($activate_query_args, 'admin.php'), 'metasync_redirection_row_action')),
529 _x('Activate', 'List table row action', 'metasync')
530 );
531 } else {
532
533 $actions['deactivate'] = sprintf(
534 '<a href="%1$s">%2$s</a>',
535 esc_url(wp_nonce_url(add_query_arg($deactivate_query_args, 'admin.php'), 'metasync_redirection_row_action')),
536 _x('Deactivate', 'List table row action', 'metasync')
537 );
538 }
539
540 $actions['delete'] = sprintf(
541 '<a href="%1$s">%2$s</a>',
542 esc_url(wp_nonce_url(add_query_arg($delete_query_args, 'admin.php'), 'metasync_redirection_row_action')),
543 _x('Delete', 'List table row action', 'metasync')
544 );
545
546 // Return the source from contents.
547 return sprintf(
548 '%1$s %2$s',
549 $this->show_source_from_urls($item),
550 $this->row_actions($actions)
551 );
552 }
553
554 /**
555 * Display pattern type column with user-friendly labels
556 */
557 protected function column_pattern_type($item)
558 {
559 $pattern_type = isset($item['pattern_type']) ? $item['pattern_type'] : 'exact';
560 $regex_pattern = isset($item['regex_pattern']) ? $item['regex_pattern'] : '';
561
562 // Pattern type labels
563 $labels = [
564 'exact' => 'Exact Match',
565 'contain' => 'Contains',
566 'start' => 'Starts With',
567 'end' => 'Ends With',
568 'regex' => 'Regex Pattern'
569 ];
570
571 $label = isset($labels[$pattern_type]) ? $labels[$pattern_type] : ucfirst($pattern_type);
572
573 // Add regex pattern as tooltip if available
574 if ($pattern_type === 'regex' && !empty($regex_pattern)) {
575 // Validate regex pattern
576 // Check if pattern has proper delimiters (same character at start and end)
577 $test_pattern = $regex_pattern;
578 $first_char = substr($test_pattern, 0, 1);
579 $has_valid_delimiter = false;
580
581 // Check if it looks like a properly delimited regex
582 if (in_array($first_char, ['/', '#', '~', '%', '@', '!'], true)) {
583 // Find the last occurrence of the delimiter (accounting for flags after it)
584 if (preg_match('/^' . preg_quote($first_char, '/') . '.*' . preg_quote($first_char, '/') . '[imsxuADU]*$/', $test_pattern)) {
585 $has_valid_delimiter = true;
586 }
587 }
588
589 // Add delimiters if missing - use # to avoid conflicts with / in URL patterns
590 if (!$has_valid_delimiter) {
591 $test_pattern = '#' . str_replace('#', '\\#', $regex_pattern) . '#';
592 }
593 $is_valid = @preg_match($test_pattern, '');
594
595 if ($is_valid === false) {
596 // Invalid regex - show red warning icon
597 return sprintf(
598 '<span style="display: flex; align-items: center; gap: 5px;">
599 <span class="dashicons dashicons-warning" style="color: #dc3232; font-size: 18px;" title="Invalid Regex Pattern: %s"></span>
600 <span title="Invalid regex pattern">%s</span>
601 </span>',
602 esc_attr($regex_pattern),
603 esc_html($label)
604 );
605 }
606
607 // Valid regex - show with tooltip
608 return sprintf(
609 '<span title="%s">%s</span>',
610 esc_attr($regex_pattern),
611 esc_html($label)
612 );
613 }
614
615 return esc_html($label);
616 }
617
618 /**
619 * Override current_action() so bulk actions triggered from the bottom
620 * dropdown (which posts `action2`) are detected alongside the top one
621 * (which posts `action`). WP_List_Table::current_action() only reads
622 * `action`, so without this the bottom Apply is a silent no-op.
623 *
624 * @return string|false
625 */
626 public function current_action()
627 {
628 // A "Filter"/"Search" submit is not a bulk action. WP core's own
629 // current_action() short-circuits on filter_action for exactly this
630 // reason, and dropping that check makes ticking rows, choosing a bulk
631 // action and then clicking Filter silently run the action. The Search
632 // submit posts no filter_action, so it needs its own named marker to
633 // stop the same silent delete when it shares a form with the dropdown.
634 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
635 return false;
636 }
637
638 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
639 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
640 }
641 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
642 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
643 }
644 return parent::current_action();
645 }
646
647 protected function get_bulk_actions()
648 {
649 // On the combined Redirections screen BOTH this table and the 404
650 // monitor table are rendered, with CSS hiding the inactive tab. If the
651 // hidden one also emitted bulk controls the page would carry two
652 // #bulk-action-selector-top/-bottom and #doaction/#doaction2; WP core's
653 // common.js binds those by ID, so it wired up whichever table came
654 // first and left the visible tab's controls inert. Returning an empty
655 // array makes core render no dropdown and no Apply for the hidden
656 // table, which costs nothing (it cannot be clicked) and keeps the IDs
657 // unique.
658 //
659 // The flag is null unless we are on that combined screen, so this table
660 // keeps its bulk actions everywhere else.
661 if (class_exists('Metasync_Redirections_Admin')
662 && Metasync_Redirections_Admin::$combined_screen_active_tab !== null
663 && Metasync_Redirections_Admin::$combined_screen_active_tab !== 'redirections') {
664 return array();
665 }
666
667 $actions = array(
668 'delete_bulk' => _x('Delete', 'List table bulk action', 'metasync'),
669 'activate_bulk' => _x('Activate', 'List table bulk action', 'metasync'),
670 'deactivate_bulk' => _x('Deactivate', 'List table bulk action', 'metasync'),
671 );
672
673 return $actions;
674 }
675
676 /**
677 * Guarantee the Apply button posts a marker identifying it as the submitter.
678 *
679 * The bulk dropdown, the row checkboxes and the bulk-items nonce all live
680 * inside the same form as the Search field and the filter controls, so every
681 * submit of that form carries a complete-looking bulk request. The only thing
682 * that distinguishes a real Apply from Search, Enter-in-search or a filter
683 * dropdown's auto-submit is which submit button (if any) was used, and a
684 * button only reports itself when it has a name attribute.
685 *
686 * WordPress core names the Apply button `bulk_action` from 7.1 onwards; on
687 * the older releases this plugin still supports get_submit_button() is called
688 * with an empty name and omits the attribute entirely. Add it ourselves in
689 * that case so process_bulk_action() can rely on the marker on every
690 * supported version, with no JavaScript involved.
691 *
692 * @param string $which Which tablenav is being rendered ('top' or 'bottom').
693 * @return void
694 */
695 protected function bulk_actions($which = '')
696 {
697 ob_start();
698 parent::bulk_actions($which);
699 $markup = ob_get_clean();
700
701 // get_bulk_actions() returns an empty array on screens where this table
702 // must not emit bulk controls, and core then renders nothing at all.
703 if (!is_string($markup) || '' === $markup) {
704 return;
705 }
706
707 if (false === strpos($markup, 'name="' . self::BULK_APPLY_MARKER . '"')) {
708 $named = preg_replace(
709 '/<input\b(?=[^>]*\bid="doaction2?")/',
710 '<input name="' . self::BULK_APPLY_MARKER . '"',
711 $markup,
712 1
713 );
714
715 // Keep core's markup untouched if the shape ever changes and the
716 // pattern stops matching, rather than emitting nothing.
717 if (is_string($named)) {
718 $markup = $named;
719 }
720 }
721
722 echo $markup; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- core markup, already escaped by WP_List_Table.
723 }
724
725 /**
726 * Whether the current request was submitted with the Apply button.
727 *
728 * Only Apply carries the marker added in bulk_actions(); Search and the
729 * unnamed implicit-submission button send no submit-button value, and a
730 * filter dropdown's programmatic form.submit() sends none either. Testing
731 * for the marker's presence rather than for the absence of `filter_action`
732 * keeps every current and future non-Apply submit path suppressed by
733 * default.
734 *
735 * @param array $post_data Sanitised copy of $_POST.
736 * @return bool
737 */
738 private function is_bulk_apply_submit($post_data)
739 {
740 return !empty($post_data[self::BULK_APPLY_MARKER]);
741 }
742
743 protected function process_bulk_action()
744 {
745 $post_data = metasync_sanitize_input_array($_POST); // phpcs:ignore WordPress.Security.NonceVerification.Missing -- check_admin_referer() runs before any write
746 $items = isset($post_data['items']) && is_array($post_data['items']) ? array_map('sanitize_title', $post_data['items']) : [];
747
748 if (empty($post_data['items'])) return;
749
750 // Suppress every submit that did not come from Apply. Search, Enter in the
751 // search box and a filter dropdown's auto-submit all post the bulk
752 // dropdown, the ticked rows and a valid nonce, so without this the
753 // selected action ran on any of them - silently, with no confirmation.
754 if (!$this->is_bulk_apply_submit($post_data)) {
755 return;
756 }
757
758 if (!$this->current_action()) {
759 return;
760 }
761
762 // Verify the bulk-action nonce emitted by WP_List_Table::display_tablenav()
763 // (plural === 'items' → 'bulk-items') and confirm plugin access before any
764 // state change. Guards against CSRF on the bulk delete/activate/deactivate path.
765 check_admin_referer('bulk-items');
766
767 if (!Metasync::current_user_has_plugin_access()) {
768 return;
769 }
770
771 // Detect when bulk delete action is being triggered.
772 if ('delete_bulk' === $this->current_action()) {
773 $this->db_redirection->delete($items);
774 }
775
776 // Detect when bulk activate action is being triggered.
777 if ('activate_bulk' === $this->current_action()) {
778 $this->db_redirection->update_status($items, 'active');
779 }
780
781 // Detect when bulk deactivate action is being triggered.
782 if ('deactivate_bulk' === $this->current_action()) {
783 $this->db_redirection->update_status($items, 'inactive');
784 }
785 }
786
787 protected function process_row_action()
788 {
789 $state_actions = ['delete_redirect', 'activate', 'deactivate'];
790
791 if (!in_array($this->current_action(), $state_actions, true)) {
792 return;
793 }
794
795 // Verify the row-action nonce (added to the row-action links in column_sources_from())
796 // and confirm plugin access before any state change. The row actions run via GET
797 // links, so without this they are open to CSRF.
798 check_admin_referer('metasync_redirection_row_action');
799
800 if (!Metasync::current_user_has_plugin_access()) {
801 return;
802 }
803
804 $get_data = metasync_sanitize_input_array($_GET);
805 $item = isset($get_data['id']) ? sanitize_text_field($get_data['id']) : '';
806
807 // Detect when a delete action is being triggered.
808 if ('delete_redirect' === $this->current_action()) {
809 $this->db_redirection->delete([$item]);
810 $this->redirect_after_row_action();
811 }
812 // Detect when a activate action is being triggered.
813 if ('activate' === $this->current_action()) {
814 $this->db_redirection->update_status([$item], 'active');
815 }
816 // Detect when a deactivate action is being triggered.
817 if ('deactivate' === $this->current_action()) {
818 $this->db_redirection->update_status([$item], 'inactive');
819 }
820 }
821
822 protected function redirect_after_row_action()
823 {
824 $redirect_url = remove_query_arg(['action', 'action2', 'id', '_wpnonce']);
825
826 if (!headers_sent()) {
827 wp_safe_redirect($redirect_url);
828 exit;
829 }
830
831 printf('<script>window.location.replace(%s);</script>', wp_json_encode(esc_url_raw($redirect_url)));
832 exit;
833 }
834
835 function prepare_items()
836 {
837 $per_page = Metasync_Per_Page_Helper::resolve('redirections', 10);
838 $this->resolved_per_page = $per_page;
839
840 $columns = $this->get_columns();
841 $hidden = array();
842 $sortable = $this->get_sortable_columns();
843 $this->_column_headers = array($columns, $hidden, $sortable);
844
845 $this->process_bulk_action();
846 $this->process_row_action();
847
848 $current_page = $this->get_pagenum();
849 $offset = ($current_page - 1) * $per_page;
850
851 // Get total count for pagination
852 $total_items = $this->getTotalRecords();
853
854 // Load only the records for the current page (already sorted by database)
855 $data = $this->loadRecordsWithPagination($per_page, $offset);
856
857 $this->items = $data;
858
859 $this->set_pagination_args(array(
860 'total_items' => $total_items, // Total number of items from database
861 'per_page' => $per_page, // Items per page
862 'total_pages' => (int) ceil($total_items / $per_page), // Total number of pages
863 ));
864 }
865
866 /**
867 * Override display method to prevent duplicate table rendering
868 */
869 public function display()
870 {
871 // Only display the table once
872 if (isset($this->_displayed) && $this->_displayed) {
873 return;
874 }
875
876 $this->_displayed = true;
877
878 // Call parent display method
879 parent::display();
880 }
881
882 /**
883 * Override display_tablenav to prevent duplicate navigation.
884 *
885 * Each position ('top' / 'bottom') renders at most once, tracked
886 * SEPARATELY. A single shared flag suppressed the bottom bar entirely,
887 * and with it the `action2` bulk control — so bulk actions could never be
888 * applied from the bottom of the list.
889 */
890 protected function display_tablenav($which)
891 {
892 // Only display each navigation position once
893 if (!empty($this->_nav_displayed[$which])) {
894 return;
895 }
896
897 $this->_nav_displayed[$which] = true;
898
899 parent::display_tablenav($which);
900 }
901
902 private function show_source_from_urls($item)
903 {
904 # $source_urls = unserialize($item['sources_from']);
905 # Unserialize the sources_from field
906 $source_urls = unserialize($item['sources_from'], ['allowed_classes' => false]); // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.serialize_unserialize
907
908 # If unserialize failed or returned non-array, try to handle it
909 if (!is_array($source_urls)) {
910 # Fallback: display raw value (shouldn't happen normally)
911 echo esc_html($item['sources_from']);
912 return;
913 }
914
915 $pattern_type = isset($item['pattern_type']) ? $item['pattern_type'] : 'exact';
916 $regex_pattern = isset($item['regex_pattern']) ? $item['regex_pattern'] : '';
917
918 foreach ($source_urls as $source_name => $source_type) {
919
920 # Clean the source name in case it contains serialized data
921 $clean_source = $source_name;
922
923 # Check if source_name itself is serialized (edge case)
924 if (is_string($source_name) && strpos($source_name, 'a:') === 0) {
925 $unserialized = unserialize($source_name, ['allowed_classes' => false]); // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.serialize_unserialize
926 if (is_array($unserialized) && isset($unserialized[0]['pattern'])) {
927 $clean_source = $unserialized[0]['pattern'];
928 }
929 }
930
931 // For regex patterns, show the regex pattern instead of the label
932 if ($pattern_type === 'regex' && !empty($regex_pattern)) {
933 $display_text = $regex_pattern;
934 // Build the full URL for the link (use the actual pattern for regex)
935 $full_url = '#'; // No direct link for regex patterns
936
937 // Validate regex pattern
938 // Add delimiters if missing
939 $test_pattern = $regex_pattern;
940 if (!preg_match('/^[\/#~%@]/', $test_pattern)) {
941 $test_pattern = '/' . $test_pattern . '/';
942 }
943 $is_valid = @preg_match($test_pattern, '');
944 $warning_icon = '';
945
946 if ($is_valid === false) {
947 // Invalid regex - add red warning icon
948 $warning_icon = '<span class="dashicons dashicons-warning" style="color: #dc3232; font-size: 16px; vertical-align: middle; margin-right: 3px;" title="Invalid Regex Pattern"></span>';
949 }
950
951 echo sprintf(
952 '<span title="Label: %3$s">%1$s%2$s</span>',
953 $warning_icon,
954 esc_html($display_text),
955 esc_attr($clean_source)
956 );
957 } else {
958 // Build the full URL for the link
959 # $full_url = $this->build_full_url($source_name);
960 $full_url = $this->build_full_url($clean_source);
961
962 echo sprintf(
963 '<a href="%1$s" target="_blank">%2$s</a>',
964 esc_url($full_url),
965 # esc_html(_x($source_name, 'List table row action', 'metasync')) # Fixed: Added esc_html() to prevent XSS
966 esc_html($clean_source)
967 );
968 }
969 echo "<br>";
970 }
971 }
972
973 /**
974 * Build full URL from source name, handling different formats
975 */
976 private function build_full_url($source_name)
977 {
978 // If it's already a full URL, return as-is
979 if (strpos($source_name, 'http') === 0) {
980 return $source_name;
981 }
982
983 // If it starts with /, it's an absolute path
984 if (strpos($source_name, '/') === 0) {
985 return site_url() . $source_name;
986 }
987
988 // Otherwise, it's a relative path
989 return site_url() . '/' . $source_name;
990 }
991
992
993 /**
994 * Display HTTP code column with better formatting
995 */
996 protected function column_http_code($item)
997 {
998 $http_code = $item['http_code'];
999 $code_labels = [
1000 '301' => '301 Permanent',
1001 '302' => '302 Temporary',
1002 '307' => '307 Temporary',
1003 '410' => '410 Gone',
1004 '451' => '451 Unavailable'
1005 ];
1006
1007 $label = isset($code_labels[$http_code]) ? $code_labels[$http_code] : $http_code;
1008
1009 $class = '';
1010 if ($http_code >= 300 && $http_code < 400) {
1011 $class = 'color: #0073aa;';
1012 } elseif ($http_code >= 400) {
1013 $class = 'color: #d63638;';
1014 }
1015
1016 return sprintf(
1017 '<span style="%s">%s</span>',
1018 esc_attr($class),
1019 esc_html($label)
1020 );
1021 }
1022 }
1023