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

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

1,200 lines 48.0 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 $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 * Lightweight, non-blocking check that a redirect destination appears to resolve.
522 *
523 * Reuses the terminal dead-end logic from check_redirect_health(): internal
524 * destinations are matched against url_to_postid(); external destinations are
525 * only probed (wp_remote_head) when off-site redirects are explicitly allowed.
526 * Returns a human-readable warning string when the destination looks
527 * unreachable, or null when it resolves (or cannot be meaningfully checked).
528 *
529 * @param string $destination Destination URL or path.
530 * @param int $http_code Redirect HTTP code (410/451 are skipped).
531 * @param string $pattern_type Source pattern type ('regex' is skipped).
532 * @return string|null Warning message, or null if the destination resolves / is unchecked.
533 */
534 public function destination_resolves_warning($destination, $http_code = 301, $pattern_type = 'exact')
535 {
536 // 410/451 redirects intentionally have no live destination.
537 if (in_array((int) $http_code, array(410, 451), true)) {
538 return null;
539 }
540
541 // Regex sources have no single concrete destination path to resolve.
542 if ($pattern_type === 'regex') {
543 return null;
544 }
545
546 if (!is_string($destination) || trim($destination) === '') {
547 return null;
548 }
549
550 // Determine if the destination is external by checking for a host component
551 // that differs from the local site. Relative paths (e.g. /about) have no host
552 // and are always internal.
553 $is_external = false;
554 $parsed_dest = parse_url($destination);
555 if (!empty($parsed_dest['host'])) {
556 $site_host = parse_url(site_url(), PHP_URL_HOST);
557 $is_external = (strcasecmp($parsed_dest['host'], $site_host) !== 0);
558 }
559
560 if ($is_external) {
561 // Only probe external targets when off-site redirects are explicitly enabled,
562 // and only with a short, single-hop HEAD request to keep the save lightweight.
563 // 3xx responses are intentionally not flagged — the destination itself is
564 // reachable even if it redirects further.
565 if (get_option('metasync_allow_external_redirects')) {
566 $response = wp_remote_head($destination, array('timeout' => 5, 'redirection' => 0));
567 if (is_wp_error($response)) {
568 return 'Destination URL appears unreachable — the redirect may lead to an error.';
569 }
570 $code = (int) wp_remote_retrieve_response_code($response);
571 if ($code >= 400) {
572 return 'Destination URL returned an error status (' . $code . ') — the redirect may lead to a broken page.';
573 }
574 }
575 return null;
576 }
577
578 // Internal destination: confirm it maps to a known page on this site.
579 $path = $this->normalize_uri_path($destination);
580 $post_id = url_to_postid(site_url($path));
581 if ($post_id === 0) {
582 $post_id = url_to_postid(site_url($path . '/'));
583 }
584
585 if ($post_id === 0) {
586 return 'Destination URL does not match a known page on this site — the redirect may lead to a 404.';
587 }
588
589 return null;
590 }
591
592 /**
593 * Normalise a URL or path to a leading-slash URI path used for chain comparison.
594 *
595 * @param string $url
596 * @return string
597 */
598 private function normalize_uri_path($url)
599 {
600 if (!is_string($url) || $url === '') {
601 return '/';
602 }
603 if (strpos($url, 'http') === 0) {
604 $parsed = parse_url($url);
605 $path = isset($parsed['path']) ? $parsed['path'] : '/';
606 } else {
607 $path = $url;
608 }
609 if ($path === '' || $path[0] !== '/') {
610 $path = '/' . $path;
611 }
612 $path = rtrim($path, '/') ?: '/';
613 return $path;
614 }
615
616 /**
617 * Check health of one or all active redirects.
618 *
619 * Returns per-redirect diagnostics: loop, chain_too_long, dead_end, or ok.
620 * Uses a prebuilt lookup index for O(1) exact-match chain walking instead
621 * of re-scanning all records per hop.
622 *
623 * @param int|null $redirect_id Optional single redirect ID to check.
624 * @param int $max_hops Hops beyond which a chain is flagged (default 3).
625 * @return array Array of health result objects.
626 */
627 public function check_redirect_health($redirect_id = null, $max_hops = 3)
628 {
629 // Preload all active records once and build a lookup index
630 $all_records = $this->db_redirection->getAllActiveRecords();
631 $exact_map = array(); // normalized_path => destination (O(1) lookup)
632 $pattern_rows = array(); // non-exact rows requiring linear scan
633
634 foreach ($all_records as $row) {
635 if (isset($row->http_code) && in_array((int) $row->http_code, array(410, 451), true)) {
636 continue;
637 }
638 if (empty($row->url_redirect_to)) {
639 continue;
640 }
641 $sources_from = !empty($row->sources_from)
642 ? unserialize($row->sources_from, array('allowed_classes' => false))
643 : array();
644 $source_urls = is_array($sources_from) ? $sources_from : array();
645 $global_pattern_type = isset($row->pattern_type) ? $row->pattern_type : null;
646
647 foreach ($source_urls as $source_key => $source_value) {
648 $pattern_type = in_array($source_value, array('exact', 'contain', 'start', 'end', 'wildcard', 'regex'))
649 ? $source_value
650 : ($global_pattern_type ? $global_pattern_type : 'exact');
651
652 if ($pattern_type === 'exact' && strpos((string) $source_key, '*') === false) {
653 $norm = $this->normalize_uri_path($source_key);
654 $exact_map[$norm] = $row->url_redirect_to;
655 } else {
656 $pattern_rows[] = $row;
657 break; // row already added, no need to check other sources
658 }
659 }
660 }
661
662 // Determine which records to check
663 if ($redirect_id !== null) {
664 $record = $this->db_redirection->find((int) $redirect_id);
665 $records = $record ? array($record) : array();
666 } else {
667 $records = $all_records;
668 }
669
670 $results = array();
671
672 foreach ($records as $row) {
673 $id = isset($row->id) ? (int) $row->id : 0;
674 $destination = isset($row->url_redirect_to) ? $row->url_redirect_to : '';
675 $http_code = isset($row->http_code) ? (int) $row->http_code : 301;
676
677 // Extract first source path for display
678 $sources_from = !empty($row->sources_from)
679 ? unserialize($row->sources_from, array('allowed_classes' => false))
680 : array();
681 $source_keys = is_array($sources_from) ? array_keys($sources_from) : array();
682 $source = !empty($source_keys) ? $source_keys[0] : '';
683 $source_path = $this->normalize_uri_path($source);
684
685 // 410/451 have no destination — always ok
686 if (in_array($http_code, array(410, 451), true)) {
687 $results[] = array(
688 'id' => $id,
689 'source' => $source_path,
690 'destination' => $destination,
691 'final_destination' => null,
692 'chain_length' => 0,
693 'chain' => array($source_path),
694 'status' => 'ok',
695 );
696 continue;
697 }
698
699 // Walk chain using the prebuilt index
700 $current = $this->normalize_uri_path($destination);
701 $chain = array($source_path, $current);
702 $seen = array();
703 $is_loop = false;
704 $hard_limit = 20;
705
706 for ($i = 0; $i < $hard_limit; $i++) {
707 if (isset($seen[$current])) {
708 $is_loop = true;
709 break;
710 }
711 $seen[$current] = true;
712
713 // O(1) exact-match lookup first
714 $dest = isset($exact_map[$current]) ? $exact_map[$current] : null;
715 if ($dest === null) {
716 // Try trailing slash variant
717 $alt = (substr($current, -1) === '/') ? rtrim($current, '/') : $current . '/';
718 if ($alt !== '' && $alt !== $current) {
719 $dest = isset($exact_map[$alt]) ? $exact_map[$alt] : null;
720 }
721 }
722 // Fallback: scan pattern-based rows only (small set)
723 if ($dest === null && !empty($pattern_rows)) {
724 foreach ($pattern_rows as $prow) {
725 $dest = $this->get_destination_for_row_and_uri($prow, $current);
726 if ($dest !== null) {
727 break;
728 }
729 // Try trailing slash alt for patterns too
730 if (isset($alt)) {
731 $dest = $this->get_destination_for_row_and_uri($prow, $alt);
732 if ($dest !== null) {
733 break;
734 }
735 }
736 }
737 }
738
739 if ($dest === null) {
740 break;
741 }
742
743 $current = $this->normalize_uri_path($dest);
744 $chain[] = $current;
745 }
746
747 // Classify: loop > chain_too_long > dead_end > ok
748 $chain_hops = count($chain) - 1;
749 if ($is_loop) {
750 $status = 'loop';
751 } elseif ($chain_hops > $max_hops) {
752 $status = 'chain_too_long';
753 } else {
754 // Check if terminal destination resolves to a real page
755 $is_dead = false;
756 if (function_exists('url_to_postid')) {
757 $post_id = url_to_postid(site_url($current));
758 if ($post_id === 0) {
759 $post_id = url_to_postid(site_url($current . '/'));
760 }
761 $is_dead = ($post_id === 0);
762 }
763 $status = $is_dead ? 'dead_end' : 'ok';
764 }
765
766 $results[] = array(
767 'id' => $id,
768 'source' => $source_path,
769 'destination' => $this->normalize_uri_path($destination),
770 'final_destination' => $current,
771 'chain_length' => $chain_hops,
772 'chain' => $chain,
773 'status' => $status,
774 );
775 }
776
777 return $results;
778 }
779
780 /**
781 * Handle AJAX health check request from admin UI.
782 */
783 public function handle_health_check_ajax()
784 {
785 check_ajax_referer('metasync_redirect_health_check', 'nonce');
786
787 if (!Metasync::current_user_has_plugin_access()) {
788 wp_send_json_error(array('message' => 'Insufficient permissions.'));
789 return;
790 }
791
792 $redirect_id = isset($_POST['redirect_id']) ? intval($_POST['redirect_id']) : null;
793 if ($redirect_id === 0) {
794 $redirect_id = null;
795 }
796
797 $results = $this->check_redirect_health($redirect_id);
798 wp_send_json_success(array('results' => $results));
799 }
800
801 /**
802 * Get destination for a single row and URI if it matches. Same matching logic as source_url_redirection.
803 *
804 * @param object $row Redirect row
805 * @param string $uri URI to match
806 * @return string|null Destination URL/path or null
807 */
808 private function get_destination_for_row_and_uri($row, $uri)
809 {
810 // Parse stored redirect sources (serialized: source path/URL => pattern type per source, or single list)
811 $sources_from = unserialize($row->sources_from, ['allowed_classes' => false]); // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.serialize_unserialize
812 $source_urls = is_array($sources_from) ? $sources_from : array();
813 $global_pattern_type = isset($row->pattern_type) ? $row->pattern_type : null;
814 $regex_pattern = isset($row->regex_pattern) ? $row->regex_pattern : null;
815
816 foreach ($source_urls as $source_key => $source_value) {
817 $match_found = false;
818 $captured_path = ''; // Used for wildcard/regex replacement in destination (e.g. * or $1)
819
820 // Resolve pattern type: per-source value (exact, contain, start, end, wildcard, regex) or row-level default
821 $pattern_type = in_array($source_value, array('exact', 'contain', 'start', 'end', 'wildcard', 'regex'))
822 ? $source_value
823 : ($global_pattern_type ? $global_pattern_type : 'exact');
824
825 $normalized_uri = $uri;
826 $normalized_source = $source_key;
827
828 // If source is a full URL, use only the path for matching (consistent with front-end redirect behavior)
829 if (strpos($source_key, 'http') === 0) {
830 $parsed_src = parse_url($source_key);
831 $normalized_source = isset($parsed_src['path']) ? $parsed_src['path'] : '';
832 }
833
834 // Ensure leading slash for reliable path comparison
835 if (!empty($normalized_source) && $normalized_source[0] !== '/') {
836 $normalized_source = '/' . $normalized_source;
837 }
838 if (!empty($normalized_uri) && $normalized_uri[0] !== '/') {
839 $normalized_uri = '/' . $normalized_uri;
840 }
841
842 // Normalize trailing slashes so /path and /path/ match equivalently
843 $normalized_source = rtrim($normalized_source, '/') ?: '/';
844 $normalized_uri = rtrim($normalized_uri, '/') ?: '/';
845
846 // --- Matching: regex, wildcard, or legacy pattern types ---
847
848 if ($pattern_type === 'regex' && $regex_pattern) {
849 // Regex: validate and normalize pattern, then match; capture group 1 for $1 in destination
850 if (!$this->validate_regex_pattern($regex_pattern)) {
851 continue;
852 }
853 $normalized_pattern = $this->normalize_regex_pattern($regex_pattern);
854 $matches = array();
855 if (@preg_match($normalized_pattern, $normalized_uri, $matches) === 1) {
856 $match_found = true;
857 $captured_path = isset($matches[1]) ? $matches[1] : '';
858 }
859 } else {
860 // Non-regex: check for * in source (wildcard) or use exact/contain/start/end
861 $has_wildcard = strpos($normalized_source, '*') !== false;
862 if ($has_wildcard) {
863 // Wildcard: e.g. /old/* matches /old/page and captures "page" for destination
864 $match_result = $this->match_wildcard($normalized_source, $normalized_uri);
865 if ($match_result !== false) {
866 $match_found = true;
867 $captured_path = $match_result;
868 }
869 } else {
870 // Legacy pattern: source_value can be the pattern type when key is the path
871 switch ($source_value) {
872 case 'exact':
873 if ($normalized_source === $normalized_uri) {
874 $match_found = true;
875 }
876 break;
877 case 'contain':
878 if ($this->contains($normalized_uri, $normalized_source)) {
879 $match_found = true;
880 }
881 break;
882 case 'start':
883 if (str_starts_with($normalized_uri, $normalized_source)) {
884 $match_found = true;
885 }
886 break;
887 case 'end':
888 if (str_ends_with($normalized_uri, $normalized_source)) {
889 $match_found = true;
890 }
891 break;
892 default:
893 // Fallback to row-level pattern_type when source_value is not a known type
894 switch ($pattern_type) {
895 case 'exact':
896 if ($normalized_source === $normalized_uri) {
897 $match_found = true;
898 }
899 break;
900 case 'contain':
901 if ($this->contains($normalized_uri, $normalized_source)) {
902 $match_found = true;
903 }
904 break;
905 case 'start':
906 if (str_starts_with($normalized_uri, $normalized_source)) {
907 $match_found = true;
908 }
909 break;
910 case 'end':
911 if (str_ends_with($normalized_uri, $normalized_source)) {
912 $match_found = true;
913 }
914 break;
915 case 'wildcard':
916 $match_result = $this->match_wildcard($normalized_source, $normalized_uri);
917 if ($match_result !== false) {
918 $match_found = true;
919 $captured_path = $match_result;
920 }
921 break;
922 }
923 break;
924 }
925 }
926 }
927
928 // First match wins: return destination with * / $1 replaced by captured_path
929 if ($match_found && !empty($row->url_redirect_to)) {
930 return $this->process_destination_url($row->url_redirect_to, $captured_path);
931 }
932 }
933
934 return null;
935 }
936
937 /**
938 * Match wildcard pattern against URI
939 *
940 * @param string $pattern Pattern with * wildcard
941 * @param string $uri URI to match against
942 * @return string|false Returns captured path on match, false otherwise
943 */
944 private function match_wildcard($pattern, $uri)
945 {
946 // Sanitize: escape the entire pattern for safe regex use, then restore wildcards
947 $escaped = preg_quote($pattern, '/');
948 // preg_quote escapes *, so replace the escaped \* back with a capturing group
949 $regex = '/^' . str_replace('\\*', '(.*)', $escaped) . '$/';
950
951 $matches = [];
952 $result = @preg_match($regex, $uri, $matches);
953
954 if ($result && $result !== false) {
955 // Return the captured path (first capturing group)
956 return isset($matches[1]) ? $matches[1] : '';
957 }
958
959 return false;
960 }
961
962 /**
963 * Process destination URL with captured path
964 *
965 * @param string $destination Destination URL (may contain * or $1)
966 * @param string $captured_path Captured path from source
967 * @return string Processed destination URL
968 */
969 private function process_destination_url($destination, $captured_path)
970 {
971 // Replace * wildcard with captured path
972 if (strpos($destination, '*') !== false) {
973 $destination = str_replace('*', $captured_path ?? '', $destination);
974 }
975
976 // Replace $1 placeholder with captured path (for regex compatibility)
977 if (strpos($destination, '$1') !== false) {
978 $destination = str_replace('$1', $captured_path ?? '', $destination);
979 }
980
981 return $destination;
982 }
983
984 /**
985 * Handle template redirect for frontend redirections
986 */
987 public function handle_template_redirect()
988 {
989 // Only process on frontend
990 if (is_admin()) {
991 return;
992 }
993
994 // Get current URI
995 $uri = $_SERVER['REQUEST_URI'];
996
997 // Remove query string for matching
998 $uri = strtok($uri, '?');
999
1000 // Build the lookup index (lazy, once per request)
1001 $this->ensure_redirect_index();
1002
1003 // O(1) exact-match lookup first
1004 $normalized_uri = $uri;
1005 if (!empty($normalized_uri) && $normalized_uri[0] !== '/') {
1006 $normalized_uri = '/' . $normalized_uri;
1007 }
1008 $normalized_uri = rtrim($normalized_uri, '/') ?: '/';
1009
1010 if (isset($this->exact_index[$normalized_uri])) {
1011 if ($this->source_url_redirection($this->exact_index[$normalized_uri], $uri)) {
1012 return;
1013 }
1014 }
1015
1016 // Fallback: scan only pattern-based rows (wildcard, regex, contain, start, end)
1017 foreach ($this->pattern_index as $redirection) {
1018 if ($this->source_url_redirection($redirection, $uri)) {
1019 return;
1020 }
1021 }
1022 }
1023
1024 /**
1025 * Prevent WordPress from redirecting to draft posts
1026 * This stops WordPress from auto-redirecting URLs to ?p=POST_ID for draft posts
1027 *
1028 * @param string $redirect_url The redirect URL
1029 * @param string $requested_url The requested URL
1030 * @return string|false The redirect URL or false to cancel redirect
1031 */
1032 public function prevent_draft_post_redirects($redirect_url, $requested_url)
1033 {
1034 # If no redirect is happening, return as-is
1035 if (empty($redirect_url)) {
1036 return $redirect_url;
1037 }
1038
1039 # Check if WordPress is trying to redirect to a ?p= or ?page_id= URL
1040 if (strpos($redirect_url, '?p=') !== false || strpos($redirect_url, '?page_id=') !== false) {
1041 # Extract the post ID
1042 $post_id = null;
1043 if (preg_match('/[?&]p=(\d+)/', $redirect_url, $matches)) {
1044 $post_id = intval($matches[1]);
1045 } elseif (preg_match('/[?&]page_id=(\d+)/', $redirect_url, $matches)) {
1046 $post_id = intval($matches[1]);
1047 }
1048
1049 # If we found a post ID, check if it's a draft
1050 if ($post_id) {
1051 $post = get_post($post_id);
1052
1053 # If post is draft, auto-draft, pending, or private, prevent the redirect
1054 if ($post && in_array($post->post_status, ['draft', 'auto-draft', 'pending', 'private'])) {
1055 # Return false to cancel the redirect and show 404 instead
1056 return false;
1057 }
1058 }
1059 }
1060
1061 # Allow the redirect for published posts
1062 return $redirect_url;
1063 }
1064
1065 /**
1066 * Prevent wp_old_slug_redirect from redirecting to draft posts
1067 *
1068 * This uses WordPress's built-in 'old_slug_redirect_post_id' filter to selectively
1069 * block redirects ONLY to unpublished posts, while allowing redirects to published posts.
1070 * - It doesn't break existing WordPress functionality
1071 * - Published posts can still use old slug redirects (good for SEO)
1072 * - Only protects unpublished content from exposure
1073 * - Non-invasive and backwards compatible
1074 *
1075 * @param int $post_id The post ID that WordPress wants to redirect to
1076 * @return int|false The post ID to redirect to, or false to prevent redirect
1077 */
1078 public function prevent_old_slug_redirect_to_drafts($post_id)
1079 {
1080 # If no post ID provided, don't redirect
1081 if (empty($post_id)) {
1082 return false;
1083 }
1084
1085 # Get the post
1086 $post = get_post($post_id);
1087
1088 # If post doesn't exist, don't redirect
1089 if (!$post) {
1090 return false;
1091 }
1092
1093 // Check if this post is unpublished (draft, pending, private, auto-draft)
1094 if (in_array($post->post_status, ['draft', 'auto-draft', 'pending', 'private'])) {
1095 # Return false to prevent the redirect to unpublished content
1096 # This will cause WordPress to show 404 instead, protecting draft content
1097 return false;
1098 }
1099
1100 # Allow redirect for published posts (preserves normal WordPress functionality)
1101 return $post_id;
1102 }
1103
1104 /**
1105 * Normalize regex pattern by adding delimiters if missing
1106 *
1107 * @param string $pattern The regex pattern
1108 * @return string Pattern with delimiters
1109 */
1110 public static function normalize_regex_pattern($pattern)
1111 {
1112 if (empty($pattern)) {
1113 return $pattern;
1114 }
1115
1116 // Check if pattern starts with a common delimiter
1117 $common_delimiters = ['/', '#', '~', '%', '@'];
1118 $starts_with_delimiter = in_array($pattern[0], $common_delimiters);
1119
1120 if ($starts_with_delimiter) {
1121 // Pattern starts with delimiter, check if it has proper structure
1122 $first_char = $pattern[0];
1123 $last_delimiter_pos = strrpos($pattern, $first_char);
1124
1125 // If there's a closing delimiter at a different position, pattern likely has delimiters
1126 if ($last_delimiter_pos !== false && $last_delimiter_pos > 0) {
1127 // Check if what comes after the last delimiter are valid modifiers
1128 $after_last_delimiter = substr($pattern, $last_delimiter_pos + 1);
1129 // Valid modifiers: i, m, s, x, A, D, S, U, X, J, u
1130 if (empty($after_last_delimiter) || preg_match('/^[imsxADSUXJu]*$/', $after_last_delimiter)) {
1131 // Pattern appears to have proper delimiters, return as-is
1132 return $pattern;
1133 }
1134 }
1135 }
1136
1137 // Pattern doesn't have delimiters or is malformed, add them
1138 // Choose delimiter that's not in the pattern
1139 $delimiters = ['/', '#', '~', '%', '@'];
1140 $delimiter = '/';
1141
1142 foreach ($delimiters as $test_delimiter) {
1143 if (strpos($pattern, $test_delimiter) === false) {
1144 $delimiter = $test_delimiter;
1145 break;
1146 }
1147 }
1148
1149 return $delimiter . $pattern . $delimiter;
1150 }
1151
1152 /**
1153 * Validate regex pattern
1154 */
1155 public function validate_regex_pattern($pattern)
1156 {
1157 if (empty($pattern)) {
1158 return true; // Empty pattern is valid (not required)
1159 }
1160
1161 // Normalize pattern (add delimiters if missing, fix malformed patterns)
1162 $normalized_pattern = $this->normalize_regex_pattern($pattern);
1163
1164 // Test if the regex pattern is valid
1165 # $test_result = @preg_match($pattern, '');
1166 # return $test_result !== false;
1167
1168 // Use error handler to catch warnings from malformed patterns
1169 $error_occurred = false;
1170 set_error_handler(function() use (&$error_occurred) {
1171 $error_occurred = true;
1172 return true; // Suppress the error
1173 }, E_WARNING);
1174
1175 $test_result = preg_match($normalized_pattern, '');
1176
1177 restore_error_handler();
1178
1179 // Return false if preg_match failed or if an error occurred
1180 return $test_result !== false && !$error_occurred;
1181 }
1182
1183 /**
1184 * Sanitize URL for redirection
1185 */
1186 public function sanitize_redirect_url($url)
1187 {
1188 // Remove any dangerous protocols
1189 $url = str_replace(['javascript:', 'data:', 'vbscript:'], '', $url);
1190
1191 // Ensure it's a valid URL
1192 if (!filter_var($url, FILTER_VALIDATE_URL) && !str_starts_with($url, '/')) {
1193 // If it's not a full URL and doesn't start with /, assume it's a relative path
1194 $url = '/' . ltrim($url, '/');
1195 }
1196
1197 return esc_url_raw($url);
1198 }
1199 }
1200