PluginProbe
Search Atlas SEO – OTTO AI SEO Automation for WordPress / 2.6.10
Search Atlas SEO – OTTO AI SEO Automation for WordPress v2.6.10
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 2.6.4 2.6.3 2.5.23 All 138 releases
metasync / redirections / class-metasync-redirection.php

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

1,128 lines 44.8 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3 /**
4 * The Urls Redirection functionality of the plugin.
5 *
6 * Defines the plugin name, version, and two examples hooks for how to
7 * enqueue the admin-specific stylesheet and JavaScript.
8 *
9 * @link https://searchatlas.com
10 * @since 1.0.0
11 * @package Metasync
12 * @subpackage Metasync/redirections
13 * @author Engineering Team <support@searchatlas.com>
14 */
15
16 // Abort if this file is accessed directly.
17 if (!defined('ABSPATH')) {
18 exit;
19 }
20
21 class Metasync_Redirection
22 {
23
24 private $db_redirection;
25 private $common;
26 private $importer;
27
28 /** @var array|null Lazy-built exact-match lookup: normalized_path => row object */
29 private $exact_index = null;
30 /** @var array|null Pattern-based rows (wildcard, regex, contain, start, end) */
31 private $pattern_index = null;
32
33 public function __construct(&$db_redirection)
34 {
35 $this->db_redirection = $db_redirection;
36 $this->common = new Metasync_Common();
37
38 # Load importer class
39 require_once dirname(__FILE__) . '/class-metasync-redirection-importer.php';
40 $this->importer = new Metasync_Redirection_Importer($db_redirection);
41 }
42
43 /**
44 * Build the redirect lookup index (lazy, once per request).
45 * Exact-match sources go into a hashmap for O(1) lookup.
46 * Pattern-based sources (wildcard, regex, contain, start, end) stay in a list.
47 */
48 private function ensure_redirect_index()
49 {
50 if ($this->exact_index !== null) {
51 return;
52 }
53
54 $this->exact_index = array();
55 $this->pattern_index = array();
56
57 $redirections = $this->db_redirection->getAllActiveRecords();
58 if (empty($redirections)) {
59 return;
60 }
61
62 foreach ($redirections as $row) {
63 $sources_from = !empty($row->sources_from)
64 ? unserialize($row->sources_from, array('allowed_classes' => false))
65 : array();
66 $source_urls = is_array($sources_from) ? $sources_from : array();
67 $global_pattern_type = isset($row->pattern_type) ? $row->pattern_type : null;
68 $is_pattern = false;
69
70 foreach ($source_urls as $source_key => $source_value) {
71 $pattern_type = in_array($source_value, array('exact', 'contain', 'start', 'end', 'wildcard', 'regex'))
72 ? $source_value
73 : ($global_pattern_type ? $global_pattern_type : 'exact');
74
75 if ($pattern_type === 'exact' && strpos((string) $source_key, '*') === false) {
76 // Normalize: extract path from full URLs, ensure leading slash, strip trailing slash
77 $norm = (string) $source_key;
78 if (strpos($norm, 'http') === 0) {
79 $parsed = parse_url($norm);
80 $norm = isset($parsed['path']) ? $parsed['path'] : '/';
81 }
82 if ($norm === '' || $norm[0] !== '/') {
83 $norm = '/' . $norm;
84 }
85 $norm = rtrim($norm, '/') ?: '/';
86 $this->exact_index[$norm] = $row;
87 } else {
88 $is_pattern = true;
89 }
90 }
91
92 if ($is_pattern) {
93 $this->pattern_index[] = $row;
94 }
95 }
96 }
97
98 function contains($haystack, $needle, $caseSensitive = false)
99 {
100 return $caseSensitive ?
101 (strpos($haystack, $needle) === FALSE ? FALSE : TRUE) : (stripos($haystack, $needle) === FALSE ? FALSE : TRUE);
102 }
103
104 public function create_admin_redirection_interface()
105 {
106 # Check if we should show import interface
107 $request_data = metasync_sanitize_input_array($_REQUEST);
108 if (isset($request_data['action']) && $request_data['action'] === 'import') {
109 $this->show_import_interface();
110 return;
111 }
112
113 if (!class_exists('WP_List_Table')) {
114 require_once ABSPATH . 'wp-admin/includes/class-wp-list-table.php';
115 }
116 require dirname(__FILE__, 2) . '/redirections/class-metasync-redirection-list-table.php';
117
118 $MetasyncRedirection = new Metasync_Redirection_List_Table();
119
120 $MetasyncRedirection->setDatabaseResource($this->db_redirection);
121
122 $MetasyncRedirection->prepare_items();
123
124 // Include the view markup.
125 include dirname(__FILE__, 2) . '/views/metasync-redirection.php';
126 }
127
128 /**
129 * Show import interface
130 */
131 public function show_import_interface()
132 {
133 $importer = $this->importer;
134 include dirname(__FILE__, 2) . '/views/metasync-import-redirections.php';
135 }
136
137 /**
138 * Handle AJAX import request
139 */
140 public function handle_import_ajax()
141 {
142 # Verify nonce
143 check_ajax_referer('metasync_import_redirections', 'nonce');
144
145 # Check user capabilities
146 if (!Metasync::current_user_has_plugin_access()) {
147 wp_send_json_error(['message' => 'Insufficient permissions.']);
148 return;
149 }
150
151 $plugin = isset($_POST['plugin']) ? sanitize_text_field($_POST['plugin']) : '';
152
153 if (empty($plugin)) {
154 wp_send_json_error(['message' => 'No plugin specified.']);
155 return;
156 }
157
158 try {
159 # Perform import
160 $result = $this->importer->import_from_plugin($plugin);
161
162 if ($result['success']) {
163 wp_send_json_success($result);
164 } else {
165 wp_send_json_error($result);
166 }
167 } catch (Exception $e) {
168 wp_send_json_error([
169 'message' => 'Import failed. Please try again or contact support.',
170 'imported' => 0,
171 'skipped' => 0
172 ]);
173 }
174 }
175
176 public function get_current_page_url()
177 {
178 $server_data = metasync_sanitize_input_array($_SERVER);
179 $link = '://' . $server_data['HTTP_HOST'] . $server_data['REQUEST_URI'];
180 $link = (is_ssl() ? 'https' : 'http') . $link;
181 return sanitize_url($link);
182 }
183
184 public function source_url_redirection(object $row, string $uri)
185 {
186 // Optimize: unserialize only once
187 $sources_from = unserialize($row->sources_from, ['allowed_classes' => false]); // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.serialize_unserialize
188 $source_urls = is_array($sources_from) ? $sources_from : [];
189 $global_pattern_type = isset($row->pattern_type) ? $row->pattern_type : null;
190 $regex_pattern = isset($row->regex_pattern) ? $row->regex_pattern : null;
191
192 foreach ($source_urls as $source_key => $source_value) {
193 $match_found = false;
194 $captured_path = ''; // Store captured path for wildcard replacement
195
196 // Ensure source_key is always a string (handles numeric array indexes)
197 $source_key = (string) $source_key;
198
199 // Determine pattern type: use source value if it's a valid pattern, otherwise use global pattern_type
200 $pattern_type = in_array($source_value, ['exact', 'contain', 'start', 'end', 'wildcard', 'regex'])
201 ? $source_value
202 : ($global_pattern_type ? $global_pattern_type : 'exact');
203
204 // Normalize both URI and source for comparison
205 $normalized_uri = $uri;
206 $normalized_source = $source_key;
207
208 // If source is a full URL, extract just the path part
209 if (strpos($source_key, 'http') === 0) {
210 $parsed_url = parse_url($source_key);
211 $normalized_source = $parsed_url['path'] ?? '';
212 }
213
214 # Ensure both have leading slashes for proper comparison
215 # This handles cases where database stores URLs with or without leading slash
216 # Convert to string to handle cases where source_key might be an integer
217 $normalized_source = (string) $normalized_source;
218 if (!empty($normalized_source) && $normalized_source[0] !== '/') {
219 $normalized_source = '/' . $normalized_source;
220 }
221 if (!empty($normalized_uri) && $normalized_uri[0] !== '/') {
222 $normalized_uri = '/' . $normalized_uri;
223 }
224
225 // Normalize trailing slashes so /path and /path/ match equivalently
226 $normalized_source = rtrim($normalized_source, '/') ?: '/';
227 $normalized_uri = rtrim($normalized_uri, '/') ?: '/';
228
229 // Keep full URI path for matching (don't strip leading slash)
230 // This allows matching against full URLs like /wordpress/index.php/category/article/*
231
232 // Handle regex patterns
233 if ($pattern_type === 'regex' && $regex_pattern) {
234 // Validate regex pattern before using it
235 if (!$this->validate_regex_pattern($regex_pattern)) {
236 // Skip invalid regex patterns to prevent errors
237 continue;
238 }
239
240 # Normalize pattern (add delimiters if missing)
241 $normalized_pattern = $this->normalize_regex_pattern($regex_pattern);
242
243
244 $matches = [];
245 // Suppress warnings for invalid regex and check result
246 # $result = @preg_match($regex_pattern, $normalized_uri, $matches);
247 $result = @preg_match($normalized_pattern, $normalized_uri, $matches);
248
249 if ($result === 1) {
250 $match_found = true;
251 // Store captured groups for replacement
252 if (isset($matches[1])) {
253 $captured_path = $matches[1];
254 }
255 }
256 // If $result === false, regex is invalid - skip silently
257 } else {
258 // Check if source has wildcard
259 $has_wildcard = strpos($normalized_source, '*') !== false;
260
261 if ($has_wildcard) {
262 // Handle wildcard pattern
263 $match_result = $this->match_wildcard($normalized_source, $normalized_uri);
264 if ($match_result !== false) {
265 $match_found = true;
266 $captured_path = $match_result;
267 }
268 } else {
269 // Handle legacy pattern matching (non-wildcard)
270 switch ($source_value) {
271 case 'exact':
272 if ($normalized_source === $normalized_uri) {
273 $match_found = true;
274 }
275 break;
276 case 'contain':
277 if ($this->contains($normalized_uri, $normalized_source)) {
278 $match_found = true;
279 }
280 break;
281 case 'start':
282 if (str_starts_with($normalized_uri, $normalized_source)) {
283 $match_found = true;
284 }
285 break;
286 case 'end':
287 if (str_ends_with($normalized_uri, $normalized_source)) {
288 $match_found = true;
289 }
290 break;
291 default:
292 // Handle new pattern_type field
293 switch ($pattern_type) {
294 case 'exact':
295 if ($normalized_source === $normalized_uri) {
296 $match_found = true;
297 }
298 break;
299 case 'contain':
300 if ($this->contains($normalized_uri, $normalized_source)) {
301 $match_found = true;
302 }
303 break;
304 case 'start':
305 if (str_starts_with($normalized_uri, $normalized_source)) {
306 $match_found = true;
307 }
308 break;
309 case 'end':
310 if (str_ends_with($normalized_uri, $normalized_source)) {
311 $match_found = true;
312 }
313 break;
314 case 'wildcard':
315 $match_result = $this->match_wildcard($normalized_source, $normalized_uri);
316 if ($match_result !== false) {
317 $match_found = true;
318 $captured_path = $match_result;
319 }
320 break;
321 }
322 break;
323 }
324 }
325 }
326
327 if ($match_found) {
328 $this->db_redirection->update_counter($row);
329
330 if ($row->http_code === '410') {
331 status_header(410);
332 die;
333 }
334 if ($row->http_code === '451') {
335 status_header(451, 'Unavailable For Legal Reasons');
336 die;
337 }
338 if ($row->url_redirect_to) {
339 // Replace wildcards or $1 placeholders in destination URL
340 $destination = $this->process_destination_url($row->url_redirect_to, $captured_path);
341 $is_exact = isset($row->pattern_type) && $row->pattern_type === 'exact';
342 if (get_option('metasync_allow_external_redirects', 0) && $is_exact) {
343 $destination = esc_url_raw($destination);
344 if (empty($destination)) {
345 $destination = home_url();
346 }
347 wp_redirect($destination, $row->http_code);
348 } else {
349 wp_redirect(wp_validate_redirect($destination, home_url()), $row->http_code);
350 }
351 die;
352 }
353 // Match found and processed, return true to stop checking other rules
354 return true;
355 }
356 }
357
358 // No match found
359 return false;
360 }
361
362 /**
363 * Resolve a URL through the redirect table to its final destination (follows redirect chains).
364 * Used by OTTO and other backend processing so the final canonical URL is used before 404 checks.
365 *
366 * @param string $url Full URL (e.g. https://example.com/old-page)
367 * @param int $max_hops Maximum redirect hops to follow (default 10, prevents infinite loops)
368 * @return string Final destination URL, or original $url if no redirect matches
369 */
370 public function resolve_url_to_final_destination($url, $max_hops = 10)
371 {
372 if (empty($url) || !is_string($url)) {
373 return $url;
374 }
375 $parsed = parse_url($url);
376 $scheme = isset($parsed['scheme']) ? $parsed['scheme'] : 'https';
377 $host = isset($parsed['host']) ? $parsed['host'] : '';
378 $base = $scheme . '://' . $host;
379 $uri = isset($parsed['path']) ? $parsed['path'] : '/';
380 if (!empty($parsed['query'])) {
381 $uri .= '?' . $parsed['query'];
382 }
383 $seen = array();
384 for ($i = 0; $i < $max_hops; $i++) {
385 $uri_key = $uri;
386 if (isset($seen[$uri_key])) {
387 break; // cycle detected
388 }
389 $seen[$uri_key] = true;
390 $dest = $this->get_redirect_destination_for_uri($uri);
391 if ($dest === null) {
392 break;
393 }
394 if (strpos($dest, 'http') === 0) {
395 $url = $dest;
396 $parsed = parse_url($url);
397 $scheme = isset($parsed['scheme']) ? $parsed['scheme'] : 'https';
398 $host = isset($parsed['host']) ? $parsed['host'] : '';
399 $base = $scheme . '://' . $host;
400 $uri = isset($parsed['path']) ? $parsed['path'] : '/';
401 if (!empty($parsed['query'])) {
402 $uri .= '?' . $parsed['query'];
403 }
404 } else {
405 $uri = (isset($dest[0]) && $dest[0] === '/') ? $dest : '/' . $dest;
406 $url = $base . $uri;
407 }
408 }
409 return $url;
410 }
411
412 /**
413 * Get redirect destination for a URI without redirecting (no wp_redirect, no counter update).
414 * Returns the destination URL/path if this URI matches a redirect source, else null.
415 * Used by resolve_url_to_final_destination. 410/451 are treated as "no destination".
416 *
417 * @param string $uri URI path (and optional query), e.g. /old-page or /old?x=1
418 * @return string|null Destination URL or path, or null if no match
419 */
420 private function get_redirect_destination_for_uri($uri)
421 {
422 $redirections = $this->db_redirection->getAllActiveRecords();
423 if (empty($redirections)) {
424 return null;
425 }
426 foreach ($redirections as $row) {
427 if (isset($row->http_code) && in_array((int) $row->http_code, array(410, 451), true)) {
428 continue; // Gone / Unavailable – no destination to follow
429 }
430 if (empty($row->url_redirect_to)) {
431 continue;
432 }
433 $dest = $this->get_destination_for_row_and_uri($row, $uri);
434 if ($dest !== null) {
435 return $dest;
436 }
437 }
438 return null;
439 }
440
441 /**
442 * Determine whether creating a redirect from $source to $destination would
443 * produce a redirect loop or chain longer than the configured hop budget.
444 *
445 * Read-only: no DB writes, no side effects. Reuses
446 * get_redirect_destination_for_uri() so chain traversal matches the read path.
447 *
448 * @param string $source Source URL or path being created
449 * @param string $destination Destination URL or path being created
450 * @param array|null $chain Out param populated with the visited URI chain
451 * @param int|null $max_hops Optional hop budget; defaults to filter `metasync_redirect_loop_max_hops` (5)
452 * @return bool True if a loop or budget overrun is detected
453 */
454 public function would_create_loop($source, $destination, &$chain = null, $max_hops = null)
455 {
456 if ($max_hops === null) {
457 $max_hops = (int) apply_filters('metasync_redirect_loop_max_hops', 5);
458 }
459 if ($max_hops < 1) {
460 $max_hops = 1;
461 }
462
463 $source_uri = $this->normalize_uri_path($source);
464 $current = $this->normalize_uri_path($destination);
465 $chain = array($source_uri, $current);
466
467 // Trivial direct loop: redirect points back at itself
468 if ($current === $source_uri) {
469 return true;
470 }
471
472 $seen = array();
473 for ($i = 0; $i < $max_hops; $i++) {
474 if (isset($seen[$current])) {
475 // Pre-existing cycle that does not involve the new source — not our concern
476 return false;
477 }
478 $seen[$current] = true;
479
480 // Try matching with and without trailing slash to handle inconsistent storage
481 $dest = $this->get_redirect_destination_for_uri($current);
482 if ($dest === null) {
483 $alt = (substr($current, -1) === '/') ? rtrim($current, '/') : $current . '/';
484 if ($alt !== '' && $alt !== $current) {
485 $dest = $this->get_redirect_destination_for_uri($alt);
486 }
487 }
488 if ($dest === null) {
489 return false;
490 }
491
492 $current = $this->normalize_uri_path($dest);
493
494 $chain[] = $current;
495
496 if ($current === $source_uri) {
497 return true;
498 }
499 }
500
501 // Hop budget exhausted without resolving — treat as a loop-equivalent warning
502 return true;
503 }
504
505 /**
506 * Validate that a redirect would not create a loop.
507 *
508 * @param string $source Source URL or path
509 * @param string $destination Destination URL or path
510 * @return string|null Null if no loop, error message string if loop detected
511 */
512 public function validate_no_loop($source, $destination) {
513 $chain = [];
514 if ($this->would_create_loop($source, $destination, $chain)) {
515 return 'Redirect would create a loop: ' . implode('', $chain);
516 }
517 return null;
518 }
519
520 /**
521 * Normalise a URL or path to a leading-slash URI path used for chain comparison.
522 *
523 * @param string $url
524 * @return string
525 */
526 private function normalize_uri_path($url)
527 {
528 if (!is_string($url) || $url === '') {
529 return '/';
530 }
531 if (strpos($url, 'http') === 0) {
532 $parsed = parse_url($url);
533 $path = isset($parsed['path']) ? $parsed['path'] : '/';
534 } else {
535 $path = $url;
536 }
537 if ($path === '' || $path[0] !== '/') {
538 $path = '/' . $path;
539 }
540 $path = rtrim($path, '/') ?: '/';
541 return $path;
542 }
543
544 /**
545 * Check health of one or all active redirects.
546 *
547 * Returns per-redirect diagnostics: loop, chain_too_long, dead_end, or ok.
548 * Uses a prebuilt lookup index for O(1) exact-match chain walking instead
549 * of re-scanning all records per hop.
550 *
551 * @param int|null $redirect_id Optional single redirect ID to check.
552 * @param int $max_hops Hops beyond which a chain is flagged (default 3).
553 * @return array Array of health result objects.
554 */
555 public function check_redirect_health($redirect_id = null, $max_hops = 3)
556 {
557 // Preload all active records once and build a lookup index
558 $all_records = $this->db_redirection->getAllActiveRecords();
559 $exact_map = array(); // normalized_path => destination (O(1) lookup)
560 $pattern_rows = array(); // non-exact rows requiring linear scan
561
562 foreach ($all_records as $row) {
563 if (isset($row->http_code) && in_array((int) $row->http_code, array(410, 451), true)) {
564 continue;
565 }
566 if (empty($row->url_redirect_to)) {
567 continue;
568 }
569 $sources_from = !empty($row->sources_from)
570 ? unserialize($row->sources_from, array('allowed_classes' => false))
571 : array();
572 $source_urls = is_array($sources_from) ? $sources_from : array();
573 $global_pattern_type = isset($row->pattern_type) ? $row->pattern_type : null;
574
575 foreach ($source_urls as $source_key => $source_value) {
576 $pattern_type = in_array($source_value, array('exact', 'contain', 'start', 'end', 'wildcard', 'regex'))
577 ? $source_value
578 : ($global_pattern_type ? $global_pattern_type : 'exact');
579
580 if ($pattern_type === 'exact' && strpos((string) $source_key, '*') === false) {
581 $norm = $this->normalize_uri_path($source_key);
582 $exact_map[$norm] = $row->url_redirect_to;
583 } else {
584 $pattern_rows[] = $row;
585 break; // row already added, no need to check other sources
586 }
587 }
588 }
589
590 // Determine which records to check
591 if ($redirect_id !== null) {
592 $record = $this->db_redirection->find((int) $redirect_id);
593 $records = $record ? array($record) : array();
594 } else {
595 $records = $all_records;
596 }
597
598 $results = array();
599
600 foreach ($records as $row) {
601 $id = isset($row->id) ? (int) $row->id : 0;
602 $destination = isset($row->url_redirect_to) ? $row->url_redirect_to : '';
603 $http_code = isset($row->http_code) ? (int) $row->http_code : 301;
604
605 // Extract first source path for display
606 $sources_from = !empty($row->sources_from)
607 ? unserialize($row->sources_from, array('allowed_classes' => false))
608 : array();
609 $source_keys = is_array($sources_from) ? array_keys($sources_from) : array();
610 $source = !empty($source_keys) ? $source_keys[0] : '';
611 $source_path = $this->normalize_uri_path($source);
612
613 // 410/451 have no destination — always ok
614 if (in_array($http_code, array(410, 451), true)) {
615 $results[] = array(
616 'id' => $id,
617 'source' => $source_path,
618 'destination' => $destination,
619 'final_destination' => null,
620 'chain_length' => 0,
621 'chain' => array($source_path),
622 'status' => 'ok',
623 );
624 continue;
625 }
626
627 // Walk chain using the prebuilt index
628 $current = $this->normalize_uri_path($destination);
629 $chain = array($source_path, $current);
630 $seen = array();
631 $is_loop = false;
632 $hard_limit = 20;
633
634 for ($i = 0; $i < $hard_limit; $i++) {
635 if (isset($seen[$current])) {
636 $is_loop = true;
637 break;
638 }
639 $seen[$current] = true;
640
641 // O(1) exact-match lookup first
642 $dest = isset($exact_map[$current]) ? $exact_map[$current] : null;
643 if ($dest === null) {
644 // Try trailing slash variant
645 $alt = (substr($current, -1) === '/') ? rtrim($current, '/') : $current . '/';
646 if ($alt !== '' && $alt !== $current) {
647 $dest = isset($exact_map[$alt]) ? $exact_map[$alt] : null;
648 }
649 }
650 // Fallback: scan pattern-based rows only (small set)
651 if ($dest === null && !empty($pattern_rows)) {
652 foreach ($pattern_rows as $prow) {
653 $dest = $this->get_destination_for_row_and_uri($prow, $current);
654 if ($dest !== null) {
655 break;
656 }
657 // Try trailing slash alt for patterns too
658 if (isset($alt)) {
659 $dest = $this->get_destination_for_row_and_uri($prow, $alt);
660 if ($dest !== null) {
661 break;
662 }
663 }
664 }
665 }
666
667 if ($dest === null) {
668 break;
669 }
670
671 $current = $this->normalize_uri_path($dest);
672 $chain[] = $current;
673 }
674
675 // Classify: loop > chain_too_long > dead_end > ok
676 $chain_hops = count($chain) - 1;
677 if ($is_loop) {
678 $status = 'loop';
679 } elseif ($chain_hops > $max_hops) {
680 $status = 'chain_too_long';
681 } else {
682 // Check if terminal destination resolves to a real page
683 $is_dead = false;
684 if (function_exists('url_to_postid')) {
685 $post_id = url_to_postid(site_url($current));
686 if ($post_id === 0) {
687 $post_id = url_to_postid(site_url($current . '/'));
688 }
689 $is_dead = ($post_id === 0);
690 }
691 $status = $is_dead ? 'dead_end' : 'ok';
692 }
693
694 $results[] = array(
695 'id' => $id,
696 'source' => $source_path,
697 'destination' => $this->normalize_uri_path($destination),
698 'final_destination' => $current,
699 'chain_length' => $chain_hops,
700 'chain' => $chain,
701 'status' => $status,
702 );
703 }
704
705 return $results;
706 }
707
708 /**
709 * Handle AJAX health check request from admin UI.
710 */
711 public function handle_health_check_ajax()
712 {
713 check_ajax_referer('metasync_redirect_health_check', 'nonce');
714
715 if (!Metasync::current_user_has_plugin_access()) {
716 wp_send_json_error(array('message' => 'Insufficient permissions.'));
717 return;
718 }
719
720 $redirect_id = isset($_POST['redirect_id']) ? intval($_POST['redirect_id']) : null;
721 if ($redirect_id === 0) {
722 $redirect_id = null;
723 }
724
725 $results = $this->check_redirect_health($redirect_id);
726 wp_send_json_success(array('results' => $results));
727 }
728
729 /**
730 * Get destination for a single row and URI if it matches. Same matching logic as source_url_redirection.
731 *
732 * @param object $row Redirect row
733 * @param string $uri URI to match
734 * @return string|null Destination URL/path or null
735 */
736 private function get_destination_for_row_and_uri($row, $uri)
737 {
738 // Parse stored redirect sources (serialized: source path/URL => pattern type per source, or single list)
739 $sources_from = unserialize($row->sources_from, ['allowed_classes' => false]); // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.serialize_unserialize
740 $source_urls = is_array($sources_from) ? $sources_from : array();
741 $global_pattern_type = isset($row->pattern_type) ? $row->pattern_type : null;
742 $regex_pattern = isset($row->regex_pattern) ? $row->regex_pattern : null;
743
744 foreach ($source_urls as $source_key => $source_value) {
745 $match_found = false;
746 $captured_path = ''; // Used for wildcard/regex replacement in destination (e.g. * or $1)
747
748 // Resolve pattern type: per-source value (exact, contain, start, end, wildcard, regex) or row-level default
749 $pattern_type = in_array($source_value, array('exact', 'contain', 'start', 'end', 'wildcard', 'regex'))
750 ? $source_value
751 : ($global_pattern_type ? $global_pattern_type : 'exact');
752
753 $normalized_uri = $uri;
754 $normalized_source = $source_key;
755
756 // If source is a full URL, use only the path for matching (consistent with front-end redirect behavior)
757 if (strpos($source_key, 'http') === 0) {
758 $parsed_src = parse_url($source_key);
759 $normalized_source = isset($parsed_src['path']) ? $parsed_src['path'] : '';
760 }
761
762 // Ensure leading slash for reliable path comparison
763 if (!empty($normalized_source) && $normalized_source[0] !== '/') {
764 $normalized_source = '/' . $normalized_source;
765 }
766 if (!empty($normalized_uri) && $normalized_uri[0] !== '/') {
767 $normalized_uri = '/' . $normalized_uri;
768 }
769
770 // Normalize trailing slashes so /path and /path/ match equivalently
771 $normalized_source = rtrim($normalized_source, '/') ?: '/';
772 $normalized_uri = rtrim($normalized_uri, '/') ?: '/';
773
774 // --- Matching: regex, wildcard, or legacy pattern types ---
775
776 if ($pattern_type === 'regex' && $regex_pattern) {
777 // Regex: validate and normalize pattern, then match; capture group 1 for $1 in destination
778 if (!$this->validate_regex_pattern($regex_pattern)) {
779 continue;
780 }
781 $normalized_pattern = $this->normalize_regex_pattern($regex_pattern);
782 $matches = array();
783 if (@preg_match($normalized_pattern, $normalized_uri, $matches) === 1) {
784 $match_found = true;
785 $captured_path = isset($matches[1]) ? $matches[1] : '';
786 }
787 } else {
788 // Non-regex: check for * in source (wildcard) or use exact/contain/start/end
789 $has_wildcard = strpos($normalized_source, '*') !== false;
790 if ($has_wildcard) {
791 // Wildcard: e.g. /old/* matches /old/page and captures "page" for destination
792 $match_result = $this->match_wildcard($normalized_source, $normalized_uri);
793 if ($match_result !== false) {
794 $match_found = true;
795 $captured_path = $match_result;
796 }
797 } else {
798 // Legacy pattern: source_value can be the pattern type when key is the path
799 switch ($source_value) {
800 case 'exact':
801 if ($normalized_source === $normalized_uri) {
802 $match_found = true;
803 }
804 break;
805 case 'contain':
806 if ($this->contains($normalized_uri, $normalized_source)) {
807 $match_found = true;
808 }
809 break;
810 case 'start':
811 if (str_starts_with($normalized_uri, $normalized_source)) {
812 $match_found = true;
813 }
814 break;
815 case 'end':
816 if (str_ends_with($normalized_uri, $normalized_source)) {
817 $match_found = true;
818 }
819 break;
820 default:
821 // Fallback to row-level pattern_type when source_value is not a known type
822 switch ($pattern_type) {
823 case 'exact':
824 if ($normalized_source === $normalized_uri) {
825 $match_found = true;
826 }
827 break;
828 case 'contain':
829 if ($this->contains($normalized_uri, $normalized_source)) {
830 $match_found = true;
831 }
832 break;
833 case 'start':
834 if (str_starts_with($normalized_uri, $normalized_source)) {
835 $match_found = true;
836 }
837 break;
838 case 'end':
839 if (str_ends_with($normalized_uri, $normalized_source)) {
840 $match_found = true;
841 }
842 break;
843 case 'wildcard':
844 $match_result = $this->match_wildcard($normalized_source, $normalized_uri);
845 if ($match_result !== false) {
846 $match_found = true;
847 $captured_path = $match_result;
848 }
849 break;
850 }
851 break;
852 }
853 }
854 }
855
856 // First match wins: return destination with * / $1 replaced by captured_path
857 if ($match_found && !empty($row->url_redirect_to)) {
858 return $this->process_destination_url($row->url_redirect_to, $captured_path);
859 }
860 }
861
862 return null;
863 }
864
865 /**
866 * Match wildcard pattern against URI
867 *
868 * @param string $pattern Pattern with * wildcard
869 * @param string $uri URI to match against
870 * @return string|false Returns captured path on match, false otherwise
871 */
872 private function match_wildcard($pattern, $uri)
873 {
874 // Sanitize: escape the entire pattern for safe regex use, then restore wildcards
875 $escaped = preg_quote($pattern, '/');
876 // preg_quote escapes *, so replace the escaped \* back with a capturing group
877 $regex = '/^' . str_replace('\\*', '(.*)', $escaped) . '$/';
878
879 $matches = [];
880 $result = @preg_match($regex, $uri, $matches);
881
882 if ($result && $result !== false) {
883 // Return the captured path (first capturing group)
884 return isset($matches[1]) ? $matches[1] : '';
885 }
886
887 return false;
888 }
889
890 /**
891 * Process destination URL with captured path
892 *
893 * @param string $destination Destination URL (may contain * or $1)
894 * @param string $captured_path Captured path from source
895 * @return string Processed destination URL
896 */
897 private function process_destination_url($destination, $captured_path)
898 {
899 // Replace * wildcard with captured path
900 if (strpos($destination, '*') !== false) {
901 $destination = str_replace('*', $captured_path ?? '', $destination);
902 }
903
904 // Replace $1 placeholder with captured path (for regex compatibility)
905 if (strpos($destination, '$1') !== false) {
906 $destination = str_replace('$1', $captured_path ?? '', $destination);
907 }
908
909 return $destination;
910 }
911
912 /**
913 * Handle template redirect for frontend redirections
914 */
915 public function handle_template_redirect()
916 {
917 // Only process on frontend
918 if (is_admin()) {
919 return;
920 }
921
922 // Get current URI
923 $uri = $_SERVER['REQUEST_URI'];
924
925 // Remove query string for matching
926 $uri = strtok($uri, '?');
927
928 // Build the lookup index (lazy, once per request)
929 $this->ensure_redirect_index();
930
931 // O(1) exact-match lookup first
932 $normalized_uri = $uri;
933 if (!empty($normalized_uri) && $normalized_uri[0] !== '/') {
934 $normalized_uri = '/' . $normalized_uri;
935 }
936 $normalized_uri = rtrim($normalized_uri, '/') ?: '/';
937
938 if (isset($this->exact_index[$normalized_uri])) {
939 if ($this->source_url_redirection($this->exact_index[$normalized_uri], $uri)) {
940 return;
941 }
942 }
943
944 // Fallback: scan only pattern-based rows (wildcard, regex, contain, start, end)
945 foreach ($this->pattern_index as $redirection) {
946 if ($this->source_url_redirection($redirection, $uri)) {
947 return;
948 }
949 }
950 }
951
952 /**
953 * Prevent WordPress from redirecting to draft posts
954 * This stops WordPress from auto-redirecting URLs to ?p=POST_ID for draft posts
955 *
956 * @param string $redirect_url The redirect URL
957 * @param string $requested_url The requested URL
958 * @return string|false The redirect URL or false to cancel redirect
959 */
960 public function prevent_draft_post_redirects($redirect_url, $requested_url)
961 {
962 # If no redirect is happening, return as-is
963 if (empty($redirect_url)) {
964 return $redirect_url;
965 }
966
967 # Check if WordPress is trying to redirect to a ?p= or ?page_id= URL
968 if (strpos($redirect_url, '?p=') !== false || strpos($redirect_url, '?page_id=') !== false) {
969 # Extract the post ID
970 $post_id = null;
971 if (preg_match('/[?&]p=(\d+)/', $redirect_url, $matches)) {
972 $post_id = intval($matches[1]);
973 } elseif (preg_match('/[?&]page_id=(\d+)/', $redirect_url, $matches)) {
974 $post_id = intval($matches[1]);
975 }
976
977 # If we found a post ID, check if it's a draft
978 if ($post_id) {
979 $post = get_post($post_id);
980
981 # If post is draft, auto-draft, pending, or private, prevent the redirect
982 if ($post && in_array($post->post_status, ['draft', 'auto-draft', 'pending', 'private'])) {
983 # Return false to cancel the redirect and show 404 instead
984 return false;
985 }
986 }
987 }
988
989 # Allow the redirect for published posts
990 return $redirect_url;
991 }
992
993 /**
994 * Prevent wp_old_slug_redirect from redirecting to draft posts
995 *
996 * This uses WordPress's built-in 'old_slug_redirect_post_id' filter to selectively
997 * block redirects ONLY to unpublished posts, while allowing redirects to published posts.
998 * - It doesn't break existing WordPress functionality
999 * - Published posts can still use old slug redirects (good for SEO)
1000 * - Only protects unpublished content from exposure
1001 * - Non-invasive and backwards compatible
1002 *
1003 * @param int $post_id The post ID that WordPress wants to redirect to
1004 * @return int|false The post ID to redirect to, or false to prevent redirect
1005 */
1006 public function prevent_old_slug_redirect_to_drafts($post_id)
1007 {
1008 # If no post ID provided, don't redirect
1009 if (empty($post_id)) {
1010 return false;
1011 }
1012
1013 # Get the post
1014 $post = get_post($post_id);
1015
1016 # If post doesn't exist, don't redirect
1017 if (!$post) {
1018 return false;
1019 }
1020
1021 // Check if this post is unpublished (draft, pending, private, auto-draft)
1022 if (in_array($post->post_status, ['draft', 'auto-draft', 'pending', 'private'])) {
1023 # Return false to prevent the redirect to unpublished content
1024 # This will cause WordPress to show 404 instead, protecting draft content
1025 return false;
1026 }
1027
1028 # Allow redirect for published posts (preserves normal WordPress functionality)
1029 return $post_id;
1030 }
1031
1032 /**
1033 * Normalize regex pattern by adding delimiters if missing
1034 *
1035 * @param string $pattern The regex pattern
1036 * @return string Pattern with delimiters
1037 */
1038 public static function normalize_regex_pattern($pattern)
1039 {
1040 if (empty($pattern)) {
1041 return $pattern;
1042 }
1043
1044 // Check if pattern starts with a common delimiter
1045 $common_delimiters = ['/', '#', '~', '%', '@'];
1046 $starts_with_delimiter = in_array($pattern[0], $common_delimiters);
1047
1048 if ($starts_with_delimiter) {
1049 // Pattern starts with delimiter, check if it has proper structure
1050 $first_char = $pattern[0];
1051 $last_delimiter_pos = strrpos($pattern, $first_char);
1052
1053 // If there's a closing delimiter at a different position, pattern likely has delimiters
1054 if ($last_delimiter_pos !== false && $last_delimiter_pos > 0) {
1055 // Check if what comes after the last delimiter are valid modifiers
1056 $after_last_delimiter = substr($pattern, $last_delimiter_pos + 1);
1057 // Valid modifiers: i, m, s, x, A, D, S, U, X, J, u
1058 if (empty($after_last_delimiter) || preg_match('/^[imsxADSUXJu]*$/', $after_last_delimiter)) {
1059 // Pattern appears to have proper delimiters, return as-is
1060 return $pattern;
1061 }
1062 }
1063 }
1064
1065 // Pattern doesn't have delimiters or is malformed, add them
1066 // Choose delimiter that's not in the pattern
1067 $delimiters = ['/', '#', '~', '%', '@'];
1068 $delimiter = '/';
1069
1070 foreach ($delimiters as $test_delimiter) {
1071 if (strpos($pattern, $test_delimiter) === false) {
1072 $delimiter = $test_delimiter;
1073 break;
1074 }
1075 }
1076
1077 return $delimiter . $pattern . $delimiter;
1078 }
1079
1080 /**
1081 * Validate regex pattern
1082 */
1083 public function validate_regex_pattern($pattern)
1084 {
1085 if (empty($pattern)) {
1086 return true; // Empty pattern is valid (not required)
1087 }
1088
1089 // Normalize pattern (add delimiters if missing, fix malformed patterns)
1090 $normalized_pattern = $this->normalize_regex_pattern($pattern);
1091
1092 // Test if the regex pattern is valid
1093 # $test_result = @preg_match($pattern, '');
1094 # return $test_result !== false;
1095
1096 // Use error handler to catch warnings from malformed patterns
1097 $error_occurred = false;
1098 set_error_handler(function() use (&$error_occurred) {
1099 $error_occurred = true;
1100 return true; // Suppress the error
1101 }, E_WARNING);
1102
1103 $test_result = preg_match($normalized_pattern, '');
1104
1105 restore_error_handler();
1106
1107 // Return false if preg_match failed or if an error occurred
1108 return $test_result !== false && !$error_occurred;
1109 }
1110
1111 /**
1112 * Sanitize URL for redirection
1113 */
1114 public function sanitize_redirect_url($url)
1115 {
1116 // Remove any dangerous protocols
1117 $url = str_replace(['javascript:', 'data:', 'vbscript:'], '', $url);
1118
1119 // Ensure it's a valid URL
1120 if (!filter_var($url, FILTER_VALIDATE_URL) && !str_starts_with($url, '/')) {
1121 // If it's not a full URL and doesn't start with /, assume it's a relative path
1122 $url = '/' . ltrim($url, '/');
1123 }
1124
1125 return esc_url_raw($url);
1126 }
1127 }
1128