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
metasync / includes / class-metasync-redirections-admin.php

class-metasync-redirections-admin.php in Search Atlas SEO – OTTO AI SEO Automation for WordPress 2.7.2, at includes/class-metasync-redirections-admin.php

796 lines 34.6 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 if (!defined('ABSPATH')) {
3 exit;
4 }
5
6 /**
7 * Redirections admin page logic extracted from Metasync_Admin.
8 *
9 * Handles the tabbed redirections / 404-monitor UI, form processing,
10 * validation, and related database checks.
11 *
12 * @package Metasync
13 * @subpackage Metasync/includes
14 */
15 class Metasync_Redirections_Admin
16 {
17 private static $instance = null;
18
19 /**
20 * Which tab of the combined Redirections screen is being viewed.
21 *
22 * Set ONLY by render_tab_content(), i.e. only when both the redirections
23 * and 404-monitor tables are rendered onto one page. Stays null on the
24 * standalone 404 Monitor page (page=...-404-monitor), where that table is
25 * the only one present. The list tables read it to decide whether to offer
26 * bulk actions - see get_bulk_actions() in either table for why.
27 *
28 * @var string|null 'redirections', '404-monitor', or null when not on the
29 * combined screen.
30 */
31 public static $combined_screen_active_tab = null;
32
33 /** @var object Redirection database helper */
34 private $db_redirection;
35
36 /** @var Metasync_Admin Back-reference used for shared UI helpers */
37 private $admin;
38
39 private function __construct($db_redirection, $admin)
40 {
41 $this->db_redirection = $db_redirection;
42 $this->admin = $admin;
43 }
44
45 /**
46 * @param object|null $db_redirection Required on first call.
47 * @param Metasync_Admin|null $admin Required on first call.
48 */
49 public static function get_instance($db_redirection = null, $admin = null)
50 {
51 if (self::$instance === null) {
52 self::$instance = new self($db_redirection, $admin);
53 }
54 return self::$instance;
55 }
56
57 /* ------------------------------------------------------------------
58 * Public entry points (called from Metasync_Admin delegation stubs)
59 * ------------------------------------------------------------------ */
60
61 public function create_admin_redirections_page()
62 {
63 $this->handle_redirection_form_processing();
64
65 $this->check_database_structure();
66
67 $this->ensure_404_monitor_table();
68
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
71 $this->add_tabbed_interface_assets();
72 $this->admin->render_layout_open('Redirections', 'redirections', 'Manage URL redirects and monitor 404 errors on your site.');
73 $this->render_tab_navigation($current_tab);
74 $this->render_tab_content($current_tab);
75 $this->admin->render_layout_close();
76 }
77
78 public function display_redirection_messages()
79 {
80 $uid = get_current_user_id();
81
82 if ($error = get_transient('metasync_redirection_error_' . $uid)) {
83 echo '<div class="notice notice-error is-dismissible"><p>' . esc_html($error) . '</p></div>';
84 delete_transient('metasync_redirection_error_' . $uid);
85 }
86
87 if ($success = get_transient('metasync_redirection_success_' . $uid)) {
88 echo '<div class="notice notice-success is-dismissible"><p>' . esc_html($success) . '</p></div>';
89 delete_transient('metasync_redirection_success_' . $uid);
90 }
91
92 if ($warning = get_transient('metasync_redirection_warning_' . $uid)) {
93 echo '<div class="notice notice-warning is-dismissible"><p>' . esc_html($warning) . '</p></div>';
94 delete_transient('metasync_redirection_warning_' . $uid);
95 }
96 }
97
98 /* ------------------------------------------------------------------
99 * Private helpers
100 * ------------------------------------------------------------------ */
101
102 private function safe_redirect($url)
103 {
104 if (!headers_sent()) {
105 wp_safe_redirect($url);
106 exit;
107 } else {
108 echo '<script type="text/javascript">window.location.href = "' . esc_url($url) . '";</script>';
109 echo '<noscript><meta http-equiv="refresh" content="0;url=' . esc_url($url) . '"></noscript>';
110 exit;
111 }
112 }
113
114 private function handle_redirection_form_processing()
115 {
116 if (!isset($_POST['submit'])) {
117 return;
118 }
119
120 $uid = get_current_user_id();
121
122 $nonce_valid = false;
123
124 if (isset($_POST['metasync_redirection_nonce']) && wp_verify_nonce($_POST['metasync_redirection_nonce'], 'metasync_redirection_form')) {
125 $nonce_valid = true;
126 }
127 elseif (isset($_POST['_wpnonce']) && wp_verify_nonce($_POST['_wpnonce'], 'metasync_redirection_form')) {
128 $nonce_valid = true;
129 }
130
131 if (!$nonce_valid) {
132 wp_die('Security check failed. Please refresh and try again.');
133 }
134
135 if (!Metasync::current_user_has_plugin_access()) {
136 wp_die('Insufficient permissions.');
137 }
138
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'])) : '';
145 $redirect_type = isset($_POST['redirect_type']) ? intval($_POST['redirect_type']) : 301;
146 $status = isset($_POST['status']) ? sanitize_text_field(wp_unslash($_POST['status'])) : 'active';
147 $regex_pattern = isset($_POST['regex_pattern']) ? wp_unslash(trim($_POST['regex_pattern'])) : '';
148 $description = isset($_POST['description']) ? sanitize_text_field(wp_unslash($_POST['description'])) : '';
149 $redirect_id = isset($_POST['redirect_id']) ? intval($_POST['redirect_id']) : 0;
150
151 $validation_errors = [];
152
153 if (empty($source_urls)) {
154 $validation_errors[] = 'Please enter at least one source URL.';
155 } else {
156 $processed_sources = [];
157 $empty_count = 0;
158
159 foreach ($source_urls as $source_url) {
160 $trimmed_url = trim($source_url);
161
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 }
166
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 }
172 } else {
173 $empty_count++;
174 }
175 }
176
177 if ($empty_count === count($source_urls)) {
178 $validation_errors[] = 'All source URL fields are empty. Please enter at least one source URL.';
179 }
180 }
181
182 $allowed_redirect_types = [301, 302, 307, 410, 451];
183 if (!in_array($redirect_type, $allowed_redirect_types)) {
184 $validation_errors[] = 'Invalid redirection type selected.';
185 }
186
187 if (!in_array($redirect_type, [410, 451])) {
188 $trimmed_dest = trim($destination_url);
189 if (empty($trimmed_dest)) {
190 $validation_errors[] = 'Destination URL is required for this redirect type.';
191 } elseif (!$this->is_valid_url($trimmed_dest)) {
192 $validation_errors[] = 'Invalid destination URL format. URLs should start with / for relative paths or be complete URLs.';
193 } elseif (!Metasync_Redirection_Validator::is_safe_destination_syntax($trimmed_dest)) {
194 // Browsers treat backslashes as path separators, so '/\evil.com'
195 // passes wp_validate_redirect yet navigates off-site; a leading
196 // '//' is a protocol-relative host. Neither is a valid internal
197 // destination regardless of the external-redirects setting.
198 $validation_errors[] = 'Destination URL contains invalid characters.';
199 } elseif (!get_option('metasync_allow_external_redirects', 0) && wp_validate_redirect($trimmed_dest, '') !== $trimmed_dest) {
200 $validation_errors[] = 'Destination URL must point to this site. External redirect destinations are not allowed. Enable "Allow External Redirects" in the Redirections settings to permit off-site redirects.';
201 }
202 }
203
204 $allowed_statuses = ['active', 'inactive'];
205 if (!in_array($status, $allowed_statuses)) {
206 $validation_errors[] = 'Invalid status selected.';
207 }
208
209 if (!empty($validation_errors)) {
210 $error_message = implode(' ', $validation_errors);
211 set_transient('metasync_redirection_error_' . $uid, $error_message, 45);
212 $this->safe_redirect(admin_url('admin.php?page=' . Metasync_Admin::$page_slug . '-redirections'));
213 }
214
215 $sources_from = [];
216 foreach ($source_urls as $index => $source_url) {
217 $trimmed_url = trim($source_url);
218 if (!empty($trimmed_url)) {
219 $search_type = isset($search_types[$index]) ? $search_types[$index] : 'exact';
220 $sources_from[$trimmed_url] = $search_type;
221 }
222 }
223
224 $pattern_type = 'exact';
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) {
229 if (!empty($search_type)) {
230 $pattern_type = $search_type;
231 break;
232 }
233 }
234
235 if ($pattern_type === 'regex') {
236 if (empty($regex_pattern)) {
237 set_transient('metasync_redirection_error_' . $uid, 'Please enter a regex pattern when using "Regex Pattern" as the pattern type.', 45);
238 $this->safe_redirect(admin_url('admin.php?page=' . Metasync_Admin::$page_slug . '-redirections'));
239 return;
240 }
241
242 // Limit pattern length to prevent ReDoS via catastrophic backtracking
243 if (strlen($regex_pattern) > 500) {
244 set_transient('metasync_redirection_error_' . $uid, 'Regex pattern is too long (max 500 characters).', 45);
245 $this->safe_redirect(admin_url('admin.php?page=' . Metasync_Admin::$page_slug . '-redirections'));
246 return;
247 }
248
249 // Reject patterns with nested quantification that could cause ReDoS,
250 // including alternation blowup ('((a|a)*)*') the older guard missed.
251 $raw_check = preg_replace('/^\S(.*)\S[a-zA-Z]*$/', '$1', $regex_pattern);
252 if (!Metasync_Redirection_Validator::is_regex_safe($raw_check)) {
253 set_transient('metasync_redirection_error_' . $uid, 'Regex pattern is too long or contains potentially unsafe nested quantifiers (including nested alternation).', 45);
254 $this->safe_redirect(admin_url('admin.php?page=' . Metasync_Admin::$page_slug . '-redirections'));
255 return;
256 }
257
258 $test_pattern = $regex_pattern;
259 $delimiter_chars = ['/', '#', '~', '%', '@'];
260 $has_valid_delimiters = false;
261
262 if (strlen($test_pattern) >= 2) {
263 $first_char = $test_pattern[0];
264 if (in_array($first_char, $delimiter_chars)) {
265 $last_pos = strrpos($test_pattern, $first_char);
266 if ($last_pos > 0) {
267 $has_valid_delimiters = true;
268 }
269 }
270 }
271
272 if (!$has_valid_delimiters) {
273 $test_pattern = '/' . $test_pattern . '/';
274 }
275 $is_valid = Metasync_Regex_Validator::is_valid($test_pattern);
276 if ($is_valid === false) {
277 $error_message = error_get_last();
278 $error_text = isset($error_message['message']) ? $error_message['message'] : 'Unknown regex error';
279 set_transient('metasync_redirection_error_' . $uid, 'Invalid Regex Pattern: ' . $error_text . ' Please fix the regex pattern and try again.', 45);
280 $this->safe_redirect(admin_url('admin.php?page=' . Metasync_Admin::$page_slug . '-redirections'));
281 return;
282 }
283 }
284
285 // Loop detection: refuse to persist a chain that would resolve back to any source
286 if (!in_array($redirect_type, [410, 451])) {
287 require_once dirname(__FILE__, 2) . '/redirections/class-metasync-redirection.php';
288 require_once dirname(__FILE__, 2) . '/redirections/class-metasync-redirection-validator.php';
289 $redirection_helper = new Metasync_Redirection($this->db_redirection);
290 foreach (array_keys($sources_from) as $source_url) {
291 $loop_error = $redirection_helper->validate_no_loop($source_url, $destination_url);
292 if ($loop_error !== null) {
293 set_transient('metasync_redirection_error_' . $uid, $loop_error, 45);
294 $this->safe_redirect(admin_url('admin.php?page=' . Metasync_Admin::$page_slug . '-redirections'));
295 return;
296 }
297 }
298
299 // Non-blocking destination reachability check — surface a warning, never block the save.
300 $dest_warning = $redirection_helper->destination_resolves_warning($destination_url, $redirect_type, $pattern_type);
301 if ($dest_warning !== null) {
302 set_transient('metasync_redirection_warning_' . $uid, $dest_warning, 45);
303 }
304 }
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
321 $data = [
322 'sources_from' => serialize($sources_from),
323 'url_redirect_to' => $destination_url,
324 'http_code' => $redirect_type,
325 'status' => $status,
326 'pattern_type' => $pattern_type,
327 'regex_pattern' => $regex_pattern,
328 'description' => $description,
329 ];
330
331 try {
332 if ($redirect_id > 0) {
333 $result = $this->db_redirection->update($data, $redirect_id);
334 if ($result === false) {
335 throw new Exception('Failed to update redirection');
336 }
337 $message = 'Redirection updated successfully.';
338 } else {
339 $result = $this->db_redirection->add($data);
340 if ($result === false) {
341 throw new Exception('Failed to add redirection');
342 }
343 $message = 'Redirection added successfully.';
344 }
345
346 } catch (Exception $e) {
347 error_log('MetaSync error: ' . $e->getMessage()); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- genuine failure path, bounded, no secrets
348 set_transient('metasync_redirection_error_' . $uid, 'An error occurred while saving the redirection. Please try again.', 45);
349 $this->safe_redirect(admin_url('admin.php?page=' . Metasync_Admin::$page_slug . '-redirections'));
350 }
351
352 set_transient('metasync_redirection_success_' . $uid, $message, 45);
353
354 $this->safe_redirect(admin_url('admin.php?page=' . Metasync_Admin::$page_slug . '-redirections'));
355 }
356
357 private function is_valid_url($url)
358 {
359 if (strpos($url, '/') === 0) {
360 return true;
361 }
362
363 return filter_var($url, FILTER_VALIDATE_URL) !== false;
364 }
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
481 private function add_tabbed_interface_assets()
482 {
483 ?>
484 <style>
485 /* Root Variables - Dashboard Color Scheme */
486 :root {
487 --dashboard-bg: #0f1419;
488 --dashboard-card-bg: #1a1f26;
489 --dashboard-card-hover: #222831;
490 --dashboard-text-primary: #ffffff;
491 --dashboard-text-secondary: #9ca3af;
492 --dashboard-accent: #3b82f6;
493 --dashboard-accent-hover: #2563eb;
494 --dashboard-success: #10b981;
495 --dashboard-warning: #f59e0b;
496 --dashboard-error: #ef4444;
497 --dashboard-border: #374151;
498 --dashboard-gradient-primary: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
499 --dashboard-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.3), 0 4px 6px -2px rgba(0, 0, 0, 0.1);
500 --dashboard-shadow-hover: 0 20px 25px -5px rgba(0, 0, 0, 0.4), 0 10px 10px -2px rgba(0, 0, 0, 0.2);
501 }
502
503 .metasync-tabs {
504 margin: 20px 0;
505 background: var(--dashboard-card-bg);
506 border: 1px solid var(--dashboard-border);
507 border-radius: 12px;
508 padding: 6px;
509 box-shadow: var(--dashboard-shadow);
510 }
511
512 .metasync-tab-nav {
513 border-bottom: none;
514 margin: 0;
515 padding: 0;
516 display: flex;
517 gap: 4px;
518 background: transparent;
519 }
520
521 .metasync-tab-nav li {
522 display: inline-block;
523 margin: 0;
524 list-style: none;
525 }
526
527 .metasync-tab-nav a {
528 display: block;
529 padding: 12px 20px;
530 text-decoration: none;
531 color: var(--dashboard-text-secondary);
532 border-bottom: none;
533 border-radius: 8px;
534 transition: all 0.3s ease;
535 font-weight: 500;
536 font-size: 14px;
537 background: transparent;
538 position: relative;
539 overflow: hidden;
540 }
541
542 .metasync-tab-nav a:hover {
543 color: var(--dashboard-text-primary);
544 background: rgba(255, 255, 255, 0.05);
545 transform: translateY(-1px);
546 }
547
548 .metasync-tab-nav a.active {
549 color: var(--dashboard-text-primary);
550 background: var(--dashboard-card-hover);
551 border-bottom: none;
552 box-shadow: var(--dashboard-shadow);
553 transform: translateY(-1px);
554 font-weight: 600;
555 }
556
557 .metasync-tab-nav a.active::after {
558 content: '';
559 position: absolute;
560 bottom: 0;
561 left: 0;
562 right: 0;
563 height: 2px;
564 background: var(--dashboard-accent);
565 border-radius: 1px;
566 }
567
568 .metasync-tab-content {
569 display: none;
570 padding: 20px 0;
571 }
572
573 .metasync-tab-content.active {
574 display: block;
575 }
576 </style>
577
578 <script>
579 jQuery(document).ready(function($) {
580 function switchToTab(targetTab) {
581 $('.metasync-tab-nav a').removeClass('active');
582 $('.metasync-tab-nav a[data-tab="' + targetTab + '"]').addClass('active');
583
584 $('.metasync-tab-content').removeClass('active');
585 $('#' + targetTab + '-content').addClass('active');
586 }
587
588 function initializeTabs() {
589 var urlParams = new URLSearchParams(window.location.search);
590 var currentTab = urlParams.get('tab');
591
592 if (currentTab && (currentTab === 'redirections' || currentTab === '404-monitor')) {
593 switchToTab(currentTab);
594 } else {
595 switchToTab('redirections');
596 currentTab = 'redirections';
597 }
598
599 var needsCleanup = false;
600 if (currentTab === '404-monitor') {
601 if (urlParams.has('paged') || urlParams.has('paged_redir')) {
602 urlParams.delete('paged');
603 urlParams.delete('paged_redir');
604 needsCleanup = true;
605 }
606 } else if (currentTab === 'redirections') {
607 if (urlParams.has('paged') || urlParams.has('paged_404')) {
608 urlParams.delete('paged');
609 urlParams.delete('paged_404');
610 needsCleanup = true;
611 }
612 }
613
614 if (needsCleanup) {
615 var newUrl = window.location.pathname + '?' + urlParams.toString();
616 window.history.replaceState({}, '', newUrl);
617 }
618 }
619
620 initializeTabs();
621
622 setTimeout(initializeTabs, 100);
623
624 $('.metasync-tab-nav a').on('click', function(e) {
625 e.preventDefault();
626
627 var targetTab = $(this).data('tab');
628 switchToTab(targetTab);
629
630 var url = new URL(window.location);
631 url.searchParams.set('tab', targetTab);
632
633 url.searchParams.delete('paged');
634 url.searchParams.delete('paged_404');
635 url.searchParams.delete('paged_redir');
636
637 window.history.pushState({}, '', url);
638 });
639 });
640 </script>
641 <?php
642 }
643
644 private function render_tab_navigation($current_tab)
645 {
646 $base_url = admin_url('admin.php?page=' . Metasync_Admin::$page_slug . '-redirections');
647
648 ?>
649 <div class="metasync-tabs">
650 <ul class="metasync-tab-nav">
651 <li>
652 <a href="<?php echo esc_url($base_url . '&tab=redirections'); ?>"
653 data-tab="redirections"
654 class="<?php echo $current_tab === 'redirections' ? 'active' : ''; ?>">
655 Redirections
656 </a>
657 </li>
658 <li>
659 <a href="<?php echo esc_url($base_url . '&tab=404-monitor'); ?>"
660 data-tab="404-monitor"
661 class="<?php echo $current_tab === '404-monitor' ? 'active' : ''; ?>">
662 404 Monitor
663 </a>
664 </li>
665 </ul>
666 </div>
667 <?php
668 }
669
670 private function render_tab_content($current_tab)
671 {
672 // Both tables are rendered onto this one page (CSS hides the inactive
673 // tab), so record which tab is actually being viewed. The list tables
674 // use this to keep the hidden tab from emitting a duplicate set of
675 // bulk-action controls - see get_bulk_actions() in either table.
676 self::$combined_screen_active_tab = ($current_tab === '404-monitor')
677 ? '404-monitor'
678 : 'redirections';
679
680 $this->render_redirections_tab($current_tab);
681 $this->render_404_monitor_tab($current_tab);
682 }
683
684 private function render_redirections_tab($current_tab = null)
685 {
686 if ($current_tab === null) {
687 $current_tab = isset($_GET['tab']) ? sanitize_text_field(wp_unslash($_GET['tab'])) : 'redirections'; // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only tab selection
688 }
689 $active_class = $current_tab === 'redirections' ? 'active' : '';
690
691 ?>
692 <div id="redirections-content" class="metasync-tab-content <?php echo esc_attr($active_class); ?>">
693 <?php
694 $redirection = new Metasync_Redirection($this->db_redirection);
695 $redirection->create_admin_redirection_interface();
696 ?>
697 </div>
698 <?php
699 }
700
701 private function render_404_monitor_tab($current_tab = null)
702 {
703 if ($current_tab === null) {
704 $current_tab = isset($_GET['tab']) ? sanitize_text_field(wp_unslash($_GET['tab'])) : 'redirections'; // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only tab selection
705 }
706 $active_class = $current_tab === '404-monitor' ? 'active' : '';
707
708 ?>
709 <div id="404-monitor-content" class="metasync-tab-content <?php echo esc_attr($active_class); ?>">
710 <?php
711 try {
712 require_once plugin_dir_path(dirname(__FILE__)) . '404-monitor/class-metasync-404-monitor-database.php';
713 require_once plugin_dir_path(dirname(__FILE__)) . '404-monitor/class-metasync-404-monitor.php';
714
715 $db_404 = new Metasync_Error_Monitor_Database();
716 $ErrorMonitor = new Metasync_Error_Monitor($db_404);
717
718 $ErrorMonitor->create_admin_plugin_interface();
719
720 } catch (Exception $e) {
721 error_log('MetaSync 404 Monitor Error: ' . $e->getMessage()); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- genuine failure path, bounded, no secrets
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>';
723 } catch (Error $e) {
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
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>';
726 }
727 ?>
728 </div>
729 <?php
730 }
731
732 private function ensure_404_monitor_table()
733 {
734 global $wpdb;
735 require_once plugin_dir_path(dirname(__FILE__)) . '404-monitor/class-metasync-404-monitor-database.php';
736
737 $table_name = $wpdb->prefix . Metasync_Error_Monitor_Database::$table_name;
738
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
740 require_once plugin_dir_path(dirname(__FILE__)) . 'database/class-db-migrations.php';
741 MetaSync_DBMigration::run_migrations();
742 }
743 }
744
745 private function check_database_structure()
746 {
747 global $wpdb;
748 $table_name = $wpdb->prefix . 'metasync_redirections';
749
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
751 return;
752 }
753
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
755 $required_columns = ['pattern_type', 'regex_pattern', 'description', 'created_at', 'updated_at', 'last_accessed_at'];
756
757 $missing_columns = array_diff($required_columns, $columns);
758
759 if (!empty($missing_columns)) {
760 add_action('admin_notices', function() use ($missing_columns) {
761 echo '<div class="notice notice-warning is-dismissible">';
762 echo '<p><strong>MetaSync:</strong> Database structure needs updating. Missing columns: ' . esc_html(implode(', ', $missing_columns)) . '</p>';
763 echo '<p><button type="button" class="button button-secondary" onclick="updateDatabaseStructure()">Update Database Structure</button></p>';
764 echo '</div>';
765
766 echo '<script>
767 function updateDatabaseStructure() {
768 if (confirm("This will update your database structure. Continue?")) {
769 const formData = new FormData();
770 formData.append("action", "metasync_update_db_structure");
771 formData.append("nonce", "' . esc_attr(wp_create_nonce('metasync_update_db_nonce')) . '");
772
773 fetch(ajaxurl, {
774 method: "POST",
775 body: formData
776 })
777 .then(response => response.json())
778 .then(data => {
779 if (data.success) {
780 alert("Database structure updated successfully!");
781 location.reload();
782 } else {
783 alert("Error updating database: " + (data.data || "Unknown error"));
784 }
785 })
786 .catch(error => {
787 alert("Error updating database: " + error.message);
788 });
789 }
790 }
791 </script>';
792 });
793 }
794 }
795 }
796