PluginProbe
Search Atlas SEO – OTTO AI SEO Automation for WordPress / 2.6.7
Search Atlas SEO – OTTO AI SEO Automation for WordPress v2.6.7
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.php

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

1,119 lines 44.3 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 <[email protected]>
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 wp_redirect($destination, $row->http_code);
342 die;
343 }
344 // Match found and processed, return true to stop checking other rules
345 return true;
346 }
347 }
348
349 // No match found
350 return false;
351 }
352
353 /**
354 * Resolve a URL through the redirect table to its final destination (follows redirect chains).
355 * Used by OTTO and other backend processing so the final canonical URL is used before 404 checks.
356 *
357 * @param string $url Full URL (e.g. https://example.com/old-page)
358 * @param int $max_hops Maximum redirect hops to follow (default 10, prevents infinite loops)
359 * @return string Final destination URL, or original $url if no redirect matches
360 */
361 public function resolve_url_to_final_destination($url, $max_hops = 10)
362 {
363 if (empty($url) || !is_string($url)) {
364 return $url;
365 }
366 $parsed = parse_url($url);
367 $scheme = isset($parsed['scheme']) ? $parsed['scheme'] : 'https';
368 $host = isset($parsed['host']) ? $parsed['host'] : '';
369 $base = $scheme . '://' . $host;
370 $uri = isset($parsed['path']) ? $parsed['path'] : '/';
371 if (!empty($parsed['query'])) {
372 $uri .= '?' . $parsed['query'];
373 }
374 $seen = array();
375 for ($i = 0; $i < $max_hops; $i++) {
376 $uri_key = $uri;
377 if (isset($seen[$uri_key])) {
378 break; // cycle detected
379 }
380 $seen[$uri_key] = true;
381 $dest = $this->get_redirect_destination_for_uri($uri);
382 if ($dest === null) {
383 break;
384 }
385 if (strpos($dest, 'http') === 0) {
386 $url = $dest;
387 $parsed = parse_url($url);
388 $scheme = isset($parsed['scheme']) ? $parsed['scheme'] : 'https';
389 $host = isset($parsed['host']) ? $parsed['host'] : '';
390 $base = $scheme . '://' . $host;
391 $uri = isset($parsed['path']) ? $parsed['path'] : '/';
392 if (!empty($parsed['query'])) {
393 $uri .= '?' . $parsed['query'];
394 }
395 } else {
396 $uri = (isset($dest[0]) && $dest[0] === '/') ? $dest : '/' . $dest;
397 $url = $base . $uri;
398 }
399 }
400 return $url;
401 }
402
403 /**
404 * Get redirect destination for a URI without redirecting (no wp_redirect, no counter update).
405 * Returns the destination URL/path if this URI matches a redirect source, else null.
406 * Used by resolve_url_to_final_destination. 410/451 are treated as "no destination".
407 *
408 * @param string $uri URI path (and optional query), e.g. /old-page or /old?x=1
409 * @return string|null Destination URL or path, or null if no match
410 */
411 private function get_redirect_destination_for_uri($uri)
412 {
413 $redirections = $this->db_redirection->getAllActiveRecords();
414 if (empty($redirections)) {
415 return null;
416 }
417 foreach ($redirections as $row) {
418 if (isset($row->http_code) && in_array((int) $row->http_code, array(410, 451), true)) {
419 continue; // Gone / Unavailable – no destination to follow
420 }
421 if (empty($row->url_redirect_to)) {
422 continue;
423 }
424 $dest = $this->get_destination_for_row_and_uri($row, $uri);
425 if ($dest !== null) {
426 return $dest;
427 }
428 }
429 return null;
430 }
431
432 /**
433 * Determine whether creating a redirect from $source to $destination would
434 * produce a redirect loop or chain longer than the configured hop budget.
435 *
436 * Read-only: no DB writes, no side effects. Reuses
437 * get_redirect_destination_for_uri() so chain traversal matches the read path.
438 *
439 * @param string $source Source URL or path being created
440 * @param string $destination Destination URL or path being created
441 * @param array|null $chain Out param populated with the visited URI chain
442 * @param int|null $max_hops Optional hop budget; defaults to filter `metasync_redirect_loop_max_hops` (5)
443 * @return bool True if a loop or budget overrun is detected
444 */
445 public function would_create_loop($source, $destination, &$chain = null, $max_hops = null)
446 {
447 if ($max_hops === null) {
448 $max_hops = (int) apply_filters('metasync_redirect_loop_max_hops', 5);
449 }
450 if ($max_hops < 1) {
451 $max_hops = 1;
452 }
453
454 $source_uri = $this->normalize_uri_path($source);
455 $current = $this->normalize_uri_path($destination);
456 $chain = array($source_uri, $current);
457
458 // Trivial direct loop: redirect points back at itself
459 if ($current === $source_uri) {
460 return true;
461 }
462
463 $seen = array();
464 for ($i = 0; $i < $max_hops; $i++) {
465 if (isset($seen[$current])) {
466 // Pre-existing cycle that does not involve the new source — not our concern
467 return false;
468 }
469 $seen[$current] = true;
470
471 // Try matching with and without trailing slash to handle inconsistent storage
472 $dest = $this->get_redirect_destination_for_uri($current);
473 if ($dest === null) {
474 $alt = (substr($current, -1) === '/') ? rtrim($current, '/') : $current . '/';
475 if ($alt !== '' && $alt !== $current) {
476 $dest = $this->get_redirect_destination_for_uri($alt);
477 }
478 }
479 if ($dest === null) {
480 return false;
481 }
482
483 $current = $this->normalize_uri_path($dest);
484
485 $chain[] = $current;
486
487 if ($current === $source_uri) {
488 return true;
489 }
490 }
491
492 // Hop budget exhausted without resolving — treat as a loop-equivalent warning
493 return true;
494 }
495
496 /**
497 * Validate that a redirect would not create a loop.
498 *
499 * @param string $source Source URL or path
500 * @param string $destination Destination URL or path
501 * @return string|null Null if no loop, error message string if loop detected
502 */
503 public function validate_no_loop($source, $destination) {
504 $chain = [];
505 if ($this->would_create_loop($source, $destination, $chain)) {
506 return 'Redirect would create a loop: ' . implode(' → ', $chain);
507 }
508 return null;
509 }
510
511 /**
512 * Normalise a URL or path to a leading-slash URI path used for chain comparison.
513 *
514 * @param string $url
515 * @return string
516 */
517 private function normalize_uri_path($url)
518 {
519 if (!is_string($url) || $url === '') {
520 return '/';
521 }
522 if (strpos($url, 'http') === 0) {
523 $parsed = parse_url($url);
524 $path = isset($parsed['path']) ? $parsed['path'] : '/';
525 } else {
526 $path = $url;
527 }
528 if ($path === '' || $path[0] !== '/') {
529 $path = '/' . $path;
530 }
531 $path = rtrim($path, '/') ?: '/';
532 return $path;
533 }
534
535 /**
536 * Check health of one or all active redirects.
537 *
538 * Returns per-redirect diagnostics: loop, chain_too_long, dead_end, or ok.
539 * Uses a prebuilt lookup index for O(1) exact-match chain walking instead
540 * of re-scanning all records per hop.
541 *
542 * @param int|null $redirect_id Optional single redirect ID to check.
543 * @param int $max_hops Hops beyond which a chain is flagged (default 3).
544 * @return array Array of health result objects.
545 */
546 public function check_redirect_health($redirect_id = null, $max_hops = 3)
547 {
548 // Preload all active records once and build a lookup index
549 $all_records = $this->db_redirection->getAllActiveRecords();
550 $exact_map = array(); // normalized_path => destination (O(1) lookup)
551 $pattern_rows = array(); // non-exact rows requiring linear scan
552
553 foreach ($all_records as $row) {
554 if (isset($row->http_code) && in_array((int) $row->http_code, array(410, 451), true)) {
555 continue;
556 }
557 if (empty($row->url_redirect_to)) {
558 continue;
559 }
560 $sources_from = !empty($row->sources_from)
561 ? unserialize($row->sources_from, array('allowed_classes' => false))
562 : array();
563 $source_urls = is_array($sources_from) ? $sources_from : array();
564 $global_pattern_type = isset($row->pattern_type) ? $row->pattern_type : null;
565
566 foreach ($source_urls as $source_key => $source_value) {
567 $pattern_type = in_array($source_value, array('exact', 'contain', 'start', 'end', 'wildcard', 'regex'))
568 ? $source_value
569 : ($global_pattern_type ? $global_pattern_type : 'exact');
570
571 if ($pattern_type === 'exact' && strpos((string) $source_key, '*') === false) {
572 $norm = $this->normalize_uri_path($source_key);
573 $exact_map[$norm] = $row->url_redirect_to;
574 } else {
575 $pattern_rows[] = $row;
576 break; // row already added, no need to check other sources
577 }
578 }
579 }
580
581 // Determine which records to check
582 if ($redirect_id !== null) {
583 $record = $this->db_redirection->find((int) $redirect_id);
584 $records = $record ? array($record) : array();
585 } else {
586 $records = $all_records;
587 }
588
589 $results = array();
590
591 foreach ($records as $row) {
592 $id = isset($row->id) ? (int) $row->id : 0;
593 $destination = isset($row->url_redirect_to) ? $row->url_redirect_to : '';
594 $http_code = isset($row->http_code) ? (int) $row->http_code : 301;
595
596 // Extract first source path for display
597 $sources_from = !empty($row->sources_from)
598 ? unserialize($row->sources_from, array('allowed_classes' => false))
599 : array();
600 $source_keys = is_array($sources_from) ? array_keys($sources_from) : array();
601 $source = !empty($source_keys) ? $source_keys[0] : '';
602 $source_path = $this->normalize_uri_path($source);
603
604 // 410/451 have no destination — always ok
605 if (in_array($http_code, array(410, 451), true)) {
606 $results[] = array(
607 'id' => $id,
608 'source' => $source_path,
609 'destination' => $destination,
610 'final_destination' => null,
611 'chain_length' => 0,
612 'chain' => array($source_path),
613 'status' => 'ok',
614 );
615 continue;
616 }
617
618 // Walk chain using the prebuilt index
619 $current = $this->normalize_uri_path($destination);
620 $chain = array($source_path, $current);
621 $seen = array();
622 $is_loop = false;
623 $hard_limit = 20;
624
625 for ($i = 0; $i < $hard_limit; $i++) {
626 if (isset($seen[$current])) {
627 $is_loop = true;
628 break;
629 }
630 $seen[$current] = true;
631
632 // O(1) exact-match lookup first
633 $dest = isset($exact_map[$current]) ? $exact_map[$current] : null;
634 if ($dest === null) {
635 // Try trailing slash variant
636 $alt = (substr($current, -1) === '/') ? rtrim($current, '/') : $current . '/';
637 if ($alt !== '' && $alt !== $current) {
638 $dest = isset($exact_map[$alt]) ? $exact_map[$alt] : null;
639 }
640 }
641 // Fallback: scan pattern-based rows only (small set)
642 if ($dest === null && !empty($pattern_rows)) {
643 foreach ($pattern_rows as $prow) {
644 $dest = $this->get_destination_for_row_and_uri($prow, $current);
645 if ($dest !== null) {
646 break;
647 }
648 // Try trailing slash alt for patterns too
649 if (isset($alt)) {
650 $dest = $this->get_destination_for_row_and_uri($prow, $alt);
651 if ($dest !== null) {
652 break;
653 }
654 }
655 }
656 }
657
658 if ($dest === null) {
659 break;
660 }
661
662 $current = $this->normalize_uri_path($dest);
663 $chain[] = $current;
664 }
665
666 // Classify: loop > chain_too_long > dead_end > ok
667 $chain_hops = count($chain) - 1;
668 if ($is_loop) {
669 $status = 'loop';
670 } elseif ($chain_hops > $max_hops) {
671 $status = 'chain_too_long';
672 } else {
673 // Check if terminal destination resolves to a real page
674 $is_dead = false;
675 if (function_exists('url_to_postid')) {
676 $post_id = url_to_postid(site_url($current));
677 if ($post_id === 0) {
678 $post_id = url_to_postid(site_url($current . '/'));
679 }
680 $is_dead = ($post_id === 0);
681 }
682 $status = $is_dead ? 'dead_end' : 'ok';
683 }
684
685 $results[] = array(
686 'id' => $id,
687 'source' => $source_path,
688 'destination' => $this->normalize_uri_path($destination),
689 'final_destination' => $current,
690 'chain_length' => $chain_hops,
691 'chain' => $chain,
692 'status' => $status,
693 );
694 }
695
696 return $results;
697 }
698
699 /**
700 * Handle AJAX health check request from admin UI.
701 */
702 public function handle_health_check_ajax()
703 {
704 check_ajax_referer('metasync_redirect_health_check', 'nonce');
705
706 if (!Metasync::current_user_has_plugin_access()) {
707 wp_send_json_error(array('message' => 'Insufficient permissions.'));
708 return;
709 }
710
711 $redirect_id = isset($_POST['redirect_id']) ? intval($_POST['redirect_id']) : null;
712 if ($redirect_id === 0) {
713 $redirect_id = null;
714 }
715
716 $results = $this->check_redirect_health($redirect_id);
717 wp_send_json_success(array('results' => $results));
718 }
719
720 /**
721 * Get destination for a single row and URI if it matches. Same matching logic as source_url_redirection.
722 *
723 * @param object $row Redirect row
724 * @param string $uri URI to match
725 * @return string|null Destination URL/path or null
726 */
727 private function get_destination_for_row_and_uri($row, $uri)
728 {
729 // Parse stored redirect sources (serialized: source path/URL => pattern type per source, or single list)
730 $sources_from = unserialize($row->sources_from, ['allowed_classes' => false]); // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.serialize_unserialize
731 $source_urls = is_array($sources_from) ? $sources_from : array();
732 $global_pattern_type = isset($row->pattern_type) ? $row->pattern_type : null;
733 $regex_pattern = isset($row->regex_pattern) ? $row->regex_pattern : null;
734
735 foreach ($source_urls as $source_key => $source_value) {
736 $match_found = false;
737 $captured_path = ''; // Used for wildcard/regex replacement in destination (e.g. * or $1)
738
739 // Resolve pattern type: per-source value (exact, contain, start, end, wildcard, regex) or row-level default
740 $pattern_type = in_array($source_value, array('exact', 'contain', 'start', 'end', 'wildcard', 'regex'))
741 ? $source_value
742 : ($global_pattern_type ? $global_pattern_type : 'exact');
743
744 $normalized_uri = $uri;
745 $normalized_source = $source_key;
746
747 // If source is a full URL, use only the path for matching (consistent with front-end redirect behavior)
748 if (strpos($source_key, 'http') === 0) {
749 $parsed_src = parse_url($source_key);
750 $normalized_source = isset($parsed_src['path']) ? $parsed_src['path'] : '';
751 }
752
753 // Ensure leading slash for reliable path comparison
754 if (!empty($normalized_source) && $normalized_source[0] !== '/') {
755 $normalized_source = '/' . $normalized_source;
756 }
757 if (!empty($normalized_uri) && $normalized_uri[0] !== '/') {
758 $normalized_uri = '/' . $normalized_uri;
759 }
760
761 // Normalize trailing slashes so /path and /path/ match equivalently
762 $normalized_source = rtrim($normalized_source, '/') ?: '/';
763 $normalized_uri = rtrim($normalized_uri, '/') ?: '/';
764
765 // --- Matching: regex, wildcard, or legacy pattern types ---
766
767 if ($pattern_type === 'regex' && $regex_pattern) {
768 // Regex: validate and normalize pattern, then match; capture group 1 for $1 in destination
769 if (!$this->validate_regex_pattern($regex_pattern)) {
770 continue;
771 }
772 $normalized_pattern = $this->normalize_regex_pattern($regex_pattern);
773 $matches = array();
774 if (@preg_match($normalized_pattern, $normalized_uri, $matches) === 1) {
775 $match_found = true;
776 $captured_path = isset($matches[1]) ? $matches[1] : '';
777 }
778 } else {
779 // Non-regex: check for * in source (wildcard) or use exact/contain/start/end
780 $has_wildcard = strpos($normalized_source, '*') !== false;
781 if ($has_wildcard) {
782 // Wildcard: e.g. /old/* matches /old/page and captures "page" for destination
783 $match_result = $this->match_wildcard($normalized_source, $normalized_uri);
784 if ($match_result !== false) {
785 $match_found = true;
786 $captured_path = $match_result;
787 }
788 } else {
789 // Legacy pattern: source_value can be the pattern type when key is the path
790 switch ($source_value) {
791 case 'exact':
792 if ($normalized_source === $normalized_uri) {
793 $match_found = true;
794 }
795 break;
796 case 'contain':
797 if ($this->contains($normalized_uri, $normalized_source)) {
798 $match_found = true;
799 }
800 break;
801 case 'start':
802 if (str_starts_with($normalized_uri, $normalized_source)) {
803 $match_found = true;
804 }
805 break;
806 case 'end':
807 if (str_ends_with($normalized_uri, $normalized_source)) {
808 $match_found = true;
809 }
810 break;
811 default:
812 // Fallback to row-level pattern_type when source_value is not a known type
813 switch ($pattern_type) {
814 case 'exact':
815 if ($normalized_source === $normalized_uri) {
816 $match_found = true;
817 }
818 break;
819 case 'contain':
820 if ($this->contains($normalized_uri, $normalized_source)) {
821 $match_found = true;
822 }
823 break;
824 case 'start':
825 if (str_starts_with($normalized_uri, $normalized_source)) {
826 $match_found = true;
827 }
828 break;
829 case 'end':
830 if (str_ends_with($normalized_uri, $normalized_source)) {
831 $match_found = true;
832 }
833 break;
834 case 'wildcard':
835 $match_result = $this->match_wildcard($normalized_source, $normalized_uri);
836 if ($match_result !== false) {
837 $match_found = true;
838 $captured_path = $match_result;
839 }
840 break;
841 }
842 break;
843 }
844 }
845 }
846
847 // First match wins: return destination with * / $1 replaced by captured_path
848 if ($match_found && !empty($row->url_redirect_to)) {
849 return $this->process_destination_url($row->url_redirect_to, $captured_path);
850 }
851 }
852
853 return null;
854 }
855
856 /**
857 * Match wildcard pattern against URI
858 *
859 * @param string $pattern Pattern with * wildcard
860 * @param string $uri URI to match against
861 * @return string|false Returns captured path on match, false otherwise
862 */
863 private function match_wildcard($pattern, $uri)
864 {
865 // Sanitize: escape the entire pattern for safe regex use, then restore wildcards
866 $escaped = preg_quote($pattern, '/');
867 // preg_quote escapes *, so replace the escaped \* back with a capturing group
868 $regex = '/^' . str_replace('\\*', '(.*)', $escaped) . '$/';
869
870 $matches = [];
871 $result = @preg_match($regex, $uri, $matches);
872
873 if ($result && $result !== false) {
874 // Return the captured path (first capturing group)
875 return isset($matches[1]) ? $matches[1] : '';
876 }
877
878 return false;
879 }
880
881 /**
882 * Process destination URL with captured path
883 *
884 * @param string $destination Destination URL (may contain * or $1)
885 * @param string $captured_path Captured path from source
886 * @return string Processed destination URL
887 */
888 private function process_destination_url($destination, $captured_path)
889 {
890 // Replace * wildcard with captured path
891 if (strpos($destination, '*') !== false) {
892 $destination = str_replace('*', $captured_path ?? '', $destination);
893 }
894
895 // Replace $1 placeholder with captured path (for regex compatibility)
896 if (strpos($destination, '$1') !== false) {
897 $destination = str_replace('$1', $captured_path ?? '', $destination);
898 }
899
900 return $destination;
901 }
902
903 /**
904 * Handle template redirect for frontend redirections
905 */
906 public function handle_template_redirect()
907 {
908 // Only process on frontend
909 if (is_admin()) {
910 return;
911 }
912
913 // Get current URI
914 $uri = $_SERVER['REQUEST_URI'];
915
916 // Remove query string for matching
917 $uri = strtok($uri, '?');
918
919 // Build the lookup index (lazy, once per request)
920 $this->ensure_redirect_index();
921
922 // O(1) exact-match lookup first
923 $normalized_uri = $uri;
924 if (!empty($normalized_uri) && $normalized_uri[0] !== '/') {
925 $normalized_uri = '/' . $normalized_uri;
926 }
927 $normalized_uri = rtrim($normalized_uri, '/') ?: '/';
928
929 if (isset($this->exact_index[$normalized_uri])) {
930 if ($this->source_url_redirection($this->exact_index[$normalized_uri], $uri)) {
931 return;
932 }
933 }
934
935 // Fallback: scan only pattern-based rows (wildcard, regex, contain, start, end)
936 foreach ($this->pattern_index as $redirection) {
937 if ($this->source_url_redirection($redirection, $uri)) {
938 return;
939 }
940 }
941 }
942
943 /**
944 * Prevent WordPress from redirecting to draft posts
945 * This stops WordPress from auto-redirecting URLs to ?p=POST_ID for draft posts
946 *
947 * @param string $redirect_url The redirect URL
948 * @param string $requested_url The requested URL
949 * @return string|false The redirect URL or false to cancel redirect
950 */
951 public function prevent_draft_post_redirects($redirect_url, $requested_url)
952 {
953 # If no redirect is happening, return as-is
954 if (empty($redirect_url)) {
955 return $redirect_url;
956 }
957
958 # Check if WordPress is trying to redirect to a ?p= or ?page_id= URL
959 if (strpos($redirect_url, '?p=') !== false || strpos($redirect_url, '?page_id=') !== false) {
960 # Extract the post ID
961 $post_id = null;
962 if (preg_match('/[?&]p=(\d+)/', $redirect_url, $matches)) {
963 $post_id = intval($matches[1]);
964 } elseif (preg_match('/[?&]page_id=(\d+)/', $redirect_url, $matches)) {
965 $post_id = intval($matches[1]);
966 }
967
968 # If we found a post ID, check if it's a draft
969 if ($post_id) {
970 $post = get_post($post_id);
971
972 # If post is draft, auto-draft, pending, or private, prevent the redirect
973 if ($post && in_array($post->post_status, ['draft', 'auto-draft', 'pending', 'private'])) {
974 # Return false to cancel the redirect and show 404 instead
975 return false;
976 }
977 }
978 }
979
980 # Allow the redirect for published posts
981 return $redirect_url;
982 }
983
984 /**
985 * Prevent wp_old_slug_redirect from redirecting to draft posts
986 *
987 * This uses WordPress's built-in 'old_slug_redirect_post_id' filter to selectively
988 * block redirects ONLY to unpublished posts, while allowing redirects to published posts.
989 * - It doesn't break existing WordPress functionality
990 * - Published posts can still use old slug redirects (good for SEO)
991 * - Only protects unpublished content from exposure
992 * - Non-invasive and backwards compatible
993 *
994 * @param int $post_id The post ID that WordPress wants to redirect to
995 * @return int|false The post ID to redirect to, or false to prevent redirect
996 */
997 public function prevent_old_slug_redirect_to_drafts($post_id)
998 {
999 # If no post ID provided, don't redirect
1000 if (empty($post_id)) {
1001 return false;
1002 }
1003
1004 # Get the post
1005 $post = get_post($post_id);
1006
1007 # If post doesn't exist, don't redirect
1008 if (!$post) {
1009 return false;
1010 }
1011
1012 // Check if this post is unpublished (draft, pending, private, auto-draft)
1013 if (in_array($post->post_status, ['draft', 'auto-draft', 'pending', 'private'])) {
1014 # Return false to prevent the redirect to unpublished content
1015 # This will cause WordPress to show 404 instead, protecting draft content
1016 return false;
1017 }
1018
1019 # Allow redirect for published posts (preserves normal WordPress functionality)
1020 return $post_id;
1021 }
1022
1023 /**
1024 * Normalize regex pattern by adding delimiters if missing
1025 *
1026 * @param string $pattern The regex pattern
1027 * @return string Pattern with delimiters
1028 */
1029 public static function normalize_regex_pattern($pattern)
1030 {
1031 if (empty($pattern)) {
1032 return $pattern;
1033 }
1034
1035 // Check if pattern starts with a common delimiter
1036 $common_delimiters = ['/', '#', '~', '%', '@'];
1037 $starts_with_delimiter = in_array($pattern[0], $common_delimiters);
1038
1039 if ($starts_with_delimiter) {
1040 // Pattern starts with delimiter, check if it has proper structure
1041 $first_char = $pattern[0];
1042 $last_delimiter_pos = strrpos($pattern, $first_char);
1043
1044 // If there's a closing delimiter at a different position, pattern likely has delimiters
1045 if ($last_delimiter_pos !== false && $last_delimiter_pos > 0) {
1046 // Check if what comes after the last delimiter are valid modifiers
1047 $after_last_delimiter = substr($pattern, $last_delimiter_pos + 1);
1048 // Valid modifiers: i, m, s, x, A, D, S, U, X, J, u
1049 if (empty($after_last_delimiter) || preg_match('/^[imsxADSUXJu]*$/', $after_last_delimiter)) {
1050 // Pattern appears to have proper delimiters, return as-is
1051 return $pattern;
1052 }
1053 }
1054 }
1055
1056 // Pattern doesn't have delimiters or is malformed, add them
1057 // Choose delimiter that's not in the pattern
1058 $delimiters = ['/', '#', '~', '%', '@'];
1059 $delimiter = '/';
1060
1061 foreach ($delimiters as $test_delimiter) {
1062 if (strpos($pattern, $test_delimiter) === false) {
1063 $delimiter = $test_delimiter;
1064 break;
1065 }
1066 }
1067
1068 return $delimiter . $pattern . $delimiter;
1069 }
1070
1071 /**
1072 * Validate regex pattern
1073 */
1074 public function validate_regex_pattern($pattern)
1075 {
1076 if (empty($pattern)) {
1077 return true; // Empty pattern is valid (not required)
1078 }
1079
1080 // Normalize pattern (add delimiters if missing, fix malformed patterns)
1081 $normalized_pattern = $this->normalize_regex_pattern($pattern);
1082
1083 // Test if the regex pattern is valid
1084 # $test_result = @preg_match($pattern, '');
1085 # return $test_result !== false;
1086
1087 // Use error handler to catch warnings from malformed patterns
1088 $error_occurred = false;
1089 set_error_handler(function() use (&$error_occurred) {
1090 $error_occurred = true;
1091 return true; // Suppress the error
1092 }, E_WARNING);
1093
1094 $test_result = preg_match($normalized_pattern, '');
1095
1096 restore_error_handler();
1097
1098 // Return false if preg_match failed or if an error occurred
1099 return $test_result !== false && !$error_occurred;
1100 }
1101
1102 /**
1103 * Sanitize URL for redirection
1104 */
1105 public function sanitize_redirect_url($url)
1106 {
1107 // Remove any dangerous protocols
1108 $url = str_replace(['javascript:', 'data:', 'vbscript:'], '', $url);
1109
1110 // Ensure it's a valid URL
1111 if (!filter_var($url, FILTER_VALIDATE_URL) && !str_starts_with($url, '/')) {
1112 // If it's not a full URL and doesn't start with /, assume it's a relative path
1113 $url = '/' . ltrim($url, '/');
1114 }
1115
1116 return esc_url_raw($url);
1117 }
1118 }
1119