PluginProbe
Search Atlas SEO – OTTO AI SEO Automation for WordPress / 2.6.6
Search Atlas SEO – OTTO AI SEO Automation for WordPress v2.6.6
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.6, at redirections/class-metasync-redirection.php

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