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

1,474 lines 62.1 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 if (!class_exists('Metasync_Redirection_Validator')) {
22 require_once dirname(__FILE__) . '/class-metasync-redirection-validator.php';
23 }
24
25 class Metasync_Redirection
26 {
27
28 private $db_redirection;
29 private $common;
30 private $importer;
31
32 /** @var array|null Lazy-built exact-match lookup: normalized_path => row object */
33 private $exact_index = null;
34 /** @var array|null Pattern-based rows (wildcard, regex, contain, start, end) */
35 private $pattern_index = null;
36
37 public function __construct(&$db_redirection)
38 {
39 $this->db_redirection = $db_redirection;
40 $this->common = new Metasync_Common();
41
42 # Load importer class
43 require_once dirname(__FILE__) . '/class-metasync-redirection-importer.php';
44 $this->importer = new Metasync_Redirection_Importer($db_redirection);
45 }
46
47 /**
48 * Build the redirect lookup index (lazy, once per request).
49 * Exact-match sources go into a hashmap for O(1) lookup.
50 * Pattern-based sources (wildcard, regex, contain, start, end) stay in a list.
51 */
52 private function ensure_redirect_index()
53 {
54 if ($this->exact_index !== null) {
55 return;
56 }
57
58 $this->exact_index = array();
59 $this->pattern_index = array();
60
61 $redirections = $this->db_redirection->getAllActiveRecords();
62 if (empty($redirections)) {
63 return;
64 }
65
66 foreach ($redirections as $row) {
67 $sources_from = !empty($row->sources_from)
68 ? unserialize($row->sources_from, array('allowed_classes' => false))
69 : array();
70 $source_urls = is_array($sources_from) ? $sources_from : array();
71 $global_pattern_type = isset($row->pattern_type) ? $row->pattern_type : null;
72 $is_pattern = false;
73
74 foreach ($source_urls as $source_key => $source_value) {
75 // Legacy list-format rows store the URL as the VALUE under a
76 // numeric key ('0' => '/old'); modern rows key by URL with the
77 // pattern type as the value. Remap so the legacy source lands
78 // in the exact index instead of '/0'.
79 if ((is_int($source_key) || ctype_digit((string) $source_key)) && is_string($source_value) && $source_value !== '') {
80 $source_key = $source_value;
81 }
82 // Rows saved before the input-unslash fix may still hold
83 // magic-quoted sources; heal at read time so they keep
84 // matching the unslashed request URI.
85 $source_key = wp_unslash((string) $source_key);
86 $pattern_type = in_array($source_value, array('exact', 'contain', 'start', 'end', 'wildcard', 'regex'))
87 ? $source_value
88 : ($global_pattern_type ? $global_pattern_type : 'exact');
89
90 if ($pattern_type === 'exact' && strpos((string) $source_key, '*') === false) {
91 // Normalize: extract path from full URLs, ensure leading slash, strip trailing slash
92 $norm = (string) $source_key;
93 if (strpos($norm, 'http') === 0) {
94 $parsed = wp_parse_url($norm);
95 $norm = isset($parsed['path']) ? $parsed['path'] : '/';
96 }
97 if ($norm === '' || $norm[0] !== '/') {
98 $norm = '/' . $norm;
99 }
100 $norm = rtrim($norm, '/') ?: '/';
101 $this->exact_index[$norm] = $row;
102 } else {
103 $is_pattern = true;
104 }
105 }
106
107 if ($is_pattern) {
108 $this->pattern_index[] = $row;
109 }
110 }
111 }
112
113 function contains($haystack, $needle, $caseSensitive = false)
114 {
115 return $caseSensitive ?
116 (strpos($haystack, $needle) === FALSE ? FALSE : TRUE) : (stripos($haystack, $needle) === FALSE ? FALSE : TRUE);
117 }
118
119 public function create_admin_redirection_interface()
120 {
121 # Check if we should show import interface
122 $request_data = metasync_sanitize_input_array($_REQUEST); // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only view switch, the import verifies its own nonce
123 if (isset($request_data['action']) && $request_data['action'] === 'import') {
124 $this->show_import_interface();
125 return;
126 }
127
128 if (!class_exists('WP_List_Table')) {
129 require_once ABSPATH . 'wp-admin/includes/class-wp-list-table.php';
130 }
131 require dirname(__FILE__, 2) . '/redirections/class-metasync-redirection-list-table.php';
132
133 $MetasyncRedirection = new Metasync_Redirection_List_Table();
134
135 $MetasyncRedirection->setDatabaseResource($this->db_redirection);
136
137 $MetasyncRedirection->prepare_items();
138
139 // Include the view markup.
140 include dirname(__FILE__, 2) . '/views/metasync-redirection.php';
141 }
142
143 /**
144 * Show import interface
145 */
146 public function show_import_interface()
147 {
148 $importer = $this->importer;
149 include dirname(__FILE__, 2) . '/views/metasync-import-redirections.php';
150 }
151
152 /**
153 * Handle AJAX import request
154 */
155 public function handle_import_ajax()
156 {
157 # Verify nonce
158 check_ajax_referer('metasync_import_redirections', 'nonce');
159
160 # Check user capabilities
161 if (!Metasync::current_user_has_plugin_access()) {
162 wp_send_json_error(['message' => 'Insufficient permissions.']);
163 return;
164 }
165
166 $plugin = isset($_POST['plugin']) ? sanitize_text_field(wp_unslash($_POST['plugin'])) : '';
167
168 if (empty($plugin)) {
169 wp_send_json_error(['message' => 'No plugin specified.']);
170 return;
171 }
172
173 # CSV is a file upload, not a plugin, so it takes its own path with
174 # upload validation before the importer sees the temp file.
175 if ($plugin === 'csv') {
176 $this->handle_csv_import_ajax();
177 return;
178 }
179
180 try {
181 # Perform import
182 $result = $this->importer->import_from_plugin($plugin);
183
184 if ($result['success']) {
185 wp_send_json_success($result);
186 } else {
187 wp_send_json_error($result);
188 }
189 } catch (Exception $e) {
190 wp_send_json_error([
191 'message' => 'Import failed. Please try again or contact support.',
192 'imported' => 0,
193 'skipped' => 0
194 ]);
195 }
196 }
197
198 /**
199 * Handle the AJAX CSV import: validate the upload, then hand the temp
200 * file to the importer. The readme has advertised CSV import since 2.5.x;
201 * this is the first implementation of it.
202 */
203 private function handle_csv_import_ajax()
204 {
205 $file = isset($_FILES['csv_file']) && is_array($_FILES['csv_file']) ? $_FILES['csv_file'] : []; // phpcs:ignore WordPress.Security.NonceVerification.Missing -- private, caller verifies a nonce and capability first
206
207 // wp_send_json_error() ends the request, so no return is needed (or
208 // reachable) after any of these guards.
209 if (empty($file['tmp_name']) || (isset($file['error']) && (int) $file['error'] !== UPLOAD_ERR_OK)) {
210 wp_send_json_error(['message' => 'Upload failed. Please choose a .csv file and try again.']);
211 }
212
213 if (!is_uploaded_file($file['tmp_name'])) {
214 wp_send_json_error(['message' => 'Invalid upload.']);
215 }
216
217 if (!preg_match('/\.csv$/i', (string) $file['name'])) {
218 wp_send_json_error(['message' => 'Only .csv files are supported.']);
219 }
220
221 if ((int) $file['size'] > 2 * MB_IN_BYTES) {
222 wp_send_json_error(['message' => 'The CSV file is too large. Maximum size is 2 MB.']);
223 }
224
225 $result = $this->importer->import_csv_file($file['tmp_name']);
226
227 if (!empty($result['success'])) {
228 wp_send_json_success($result);
229 } else {
230 wp_send_json_error($result);
231 }
232 }
233
234 public function get_current_page_url()
235 {
236 $server_data = metasync_sanitize_input_array($_SERVER);
237 $link = '://' . $server_data['HTTP_HOST'] . $server_data['REQUEST_URI'];
238 $link = (is_ssl() ? 'https' : 'http') . $link;
239 return sanitize_url($link);
240 }
241
242 public function source_url_redirection(object $row, string $uri, string $incoming_query = '')
243 {
244 // Redirection feature switched off: rows owned by the per-post Redirection
245 // meta box must not fire. Returning false rather than aborting means the
246 // caller carries on evaluating the remaining rules, so rules added by hand
247 // on the Redirections screen keep working exactly as before.
248 if (Metasync_Feature_Flags::is_disabled(Metasync_Feature_Flags::REDIRECTION)
249 && class_exists('Metasync_Post_Meta_Settings')
250 && Metasync_Post_Meta_Settings::owns_redirect_row($row)) {
251 return false;
252 }
253
254 // Optimize: unserialize only once
255 $sources_from = unserialize($row->sources_from, ['allowed_classes' => false]); // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.serialize_unserialize
256 $source_urls = is_array($sources_from) ? $sources_from : [];
257 $global_pattern_type = isset($row->pattern_type) ? $row->pattern_type : null;
258 $regex_pattern = isset($row->regex_pattern) ? $row->regex_pattern : null;
259
260 foreach ($source_urls as $source_key => $source_value) {
261 $match_found = false;
262 $captured_path = ''; // Store captured path for wildcard replacement
263
264 // Ensure source_key is always a string (handles numeric array indexes)
265 $source_key = (string) $source_key;
266
267 // Legacy list-format rows store the URL as the VALUE under a
268 // numeric key ('0' => '/old'); remap so matching uses the URL.
269 if (ctype_digit($source_key) && is_string($source_value) && $source_value !== '') {
270 $source_key = $source_value;
271 }
272
273 // Heal magic-quoted sources saved before the input-unslash fix so
274 // pattern matching sees the literal value the user typed.
275 $source_key = wp_unslash((string) $source_key);
276
277 // Determine pattern type: use source value if it's a valid pattern, otherwise use global pattern_type
278 $pattern_type = in_array($source_value, ['exact', 'contain', 'start', 'end', 'wildcard', 'regex'])
279 ? $source_value
280 : ($global_pattern_type ? $global_pattern_type : 'exact');
281
282 // Normalize both URI and source for comparison
283 $normalized_uri = $uri;
284 $normalized_source = $source_key;
285
286 // If source is a full URL, extract just the path part
287 if (strpos($source_key, 'http') === 0) {
288 $parsed_url = wp_parse_url($source_key);
289 $normalized_source = $parsed_url['path'] ?? '';
290 }
291
292 # Ensure both have leading slashes for proper comparison
293 # This handles cases where database stores URLs with or without leading slash
294 # Convert to string to handle cases where source_key might be an integer
295 $normalized_source = (string) $normalized_source;
296 if (!empty($normalized_source) && $normalized_source[0] !== '/') {
297 $normalized_source = '/' . $normalized_source;
298 }
299 if (!empty($normalized_uri) && $normalized_uri[0] !== '/') {
300 $normalized_uri = '/' . $normalized_uri;
301 }
302
303 // Normalize trailing slashes so /path and /path/ match equivalently
304 $normalized_source = rtrim($normalized_source, '/') ?: '/';
305 $normalized_uri = rtrim($normalized_uri, '/') ?: '/';
306
307 // Keep full URI path for matching (don't strip leading slash)
308 // This allows matching against full URLs like /wordpress/index.php/category/article/*
309
310 // Handle regex patterns
311 if ($pattern_type === 'regex' && $regex_pattern) {
312 // Validate regex pattern before using it
313 if (!$this->validate_regex_pattern($regex_pattern)) {
314 // Skip invalid regex patterns to prevent errors
315 continue;
316 }
317
318 # Normalize pattern (add delimiters if missing)
319 $normalized_pattern = $this->normalize_regex_pattern($regex_pattern);
320
321
322 $matches = [];
323 // Suppress warnings for invalid regex and check result
324 # $result = @preg_match($regex_pattern, $normalized_uri, $matches);
325 $result = @preg_match($normalized_pattern, $normalized_uri, $matches);
326
327 if ($result === 1) {
328 $match_found = true;
329 // Store captured groups for replacement
330 if (isset($matches[1])) {
331 $captured_path = $matches[1];
332 }
333 }
334 // If $result === false, regex is invalid - skip silently
335 } else {
336 // Check if source has wildcard
337 $has_wildcard = strpos($normalized_source, '*') !== false;
338
339 if ($has_wildcard) {
340 // Handle wildcard pattern
341 $match_result = $this->match_wildcard($normalized_source, $normalized_uri);
342 if ($match_result !== false) {
343 $match_found = true;
344 $captured_path = $match_result;
345 }
346 } else {
347 // Handle legacy pattern matching (non-wildcard)
348 switch ($source_value) {
349 case 'exact':
350 if ($normalized_source === $normalized_uri) {
351 $match_found = true;
352 }
353 break;
354 case 'contain':
355 if ($this->contains($normalized_uri, $normalized_source)) {
356 $match_found = true;
357 }
358 break;
359 case 'start':
360 if (str_starts_with($normalized_uri, $normalized_source)) {
361 $match_found = true;
362 }
363 break;
364 case 'end':
365 if (str_ends_with($normalized_uri, $normalized_source)) {
366 $match_found = true;
367 }
368 break;
369 default:
370 // Handle new pattern_type field
371 switch ($pattern_type) {
372 case 'exact':
373 if ($normalized_source === $normalized_uri) {
374 $match_found = true;
375 }
376 break;
377 case 'contain':
378 if ($this->contains($normalized_uri, $normalized_source)) {
379 $match_found = true;
380 }
381 break;
382 case 'start':
383 if (str_starts_with($normalized_uri, $normalized_source)) {
384 $match_found = true;
385 }
386 break;
387 case 'end':
388 if (str_ends_with($normalized_uri, $normalized_source)) {
389 $match_found = true;
390 }
391 break;
392 case 'wildcard':
393 $match_result = $this->match_wildcard($normalized_source, $normalized_uri);
394 if ($match_result !== false) {
395 $match_found = true;
396 $captured_path = $match_result;
397 }
398 break;
399 }
400 break;
401 }
402 }
403 }
404
405 if ($match_found) {
406 $this->db_redirection->update_counter($row);
407
408 if ($row->http_code === '410') {
409 status_header(410);
410 die;
411 }
412 if ($row->http_code === '451') {
413 status_header(451, 'Unavailable For Legal Reasons');
414 die;
415 }
416 if ($row->url_redirect_to) {
417 // Replace wildcards or $1 placeholders in destination URL
418 $destination = $this->process_destination_url($row->url_redirect_to, $captured_path);
419 $destination = $this->append_query_string($destination, $incoming_query);
420 $is_exact = isset($row->pattern_type) && $row->pattern_type === 'exact';
421 if (get_option('metasync_allow_external_redirects', 0) && $is_exact) {
422 $destination = esc_url_raw($destination);
423 if (empty($destination)) {
424 $destination = home_url();
425 }
426 // Same syntax hygiene as the default branch: the opt-in
427 // permits external hosts, never syntactic evasion, so a
428 // backslash trick like '/\evil.com' still bounces home.
429 $checked = Metasync_Redirection_Validator::normalize_destination($destination);
430 if (!Metasync_Redirection_Validator::is_safe_destination_syntax($checked)) {
431 $checked = home_url();
432 }
433 $this->safe_redirect_to_destination($checked, $row->http_code);
434 } else {
435 // Stored destinations can carry backslashes (imports,
436 // older rows). Browsers read them as path separators, so
437 // '/\evil.com' would slip past wp_validate_redirect and
438 // still navigate off-site. Normalize first, and bounce
439 // anything still syntactically evasive to the home page.
440 $checked = Metasync_Redirection_Validator::normalize_destination($destination);
441 if (!Metasync_Redirection_Validator::is_safe_destination_syntax($checked)) {
442 $checked = home_url();
443 }
444 $this->safe_redirect_to_destination(wp_validate_redirect($checked, home_url()), $row->http_code);
445 }
446 // Both branches terminate inside safe_redirect_to_destination().
447 }
448 // Match found and processed, return true to stop checking other rules
449 return true;
450 }
451 }
452
453 // No match found
454 return false;
455 }
456
457 /**
458 * Emit a redirect through wp_safe_redirect() and end execution.
459 *
460 * wp_safe_redirect() confines the Location header to hosts WordPress
461 * trusts (this site plus anything on the allowed_redirect_hosts
462 * allowlist). The one intentional external destination is an exact-match
463 * rule saved by an admin while the metasync_allow_external_redirects
464 * opt-in is enabled, so the parsed host of the already syntax-validated
465 * destination is allowlisted for this request only. Everything else —
466 * same-site destinations, relative paths — passes through untouched.
467 *
468 * @param string $destination Validated destination URL or path.
469 * @param string|int $http_code Redirect status code from the rule row.
470 * @return void Never returns; wp_safe_redirect() is followed by exit.
471 */
472 private function safe_redirect_to_destination($destination, $http_code)
473 {
474 $host = wp_parse_url($destination, PHP_URL_HOST);
475 $grant_host = null;
476 if (is_string($host) && $host !== '') {
477 // Scope the temporary host grant to this one redirect. The filter
478 // runs while wp_safe_redirect() validates the Location header and
479 // is removed before exit: shutdown callbacks registered elsewhere
480 // still fire after exit, so the grant must not outlive the call.
481 $grant_host = static function ($allowed_hosts, $checked_host = '') use ($host) {
482 if (is_array($allowed_hosts)) {
483 $allowed_hosts[] = $host;
484 }
485 return $allowed_hosts;
486 };
487 add_filter('allowed_redirect_hosts', $grant_host, 10, 2);
488 }
489 wp_safe_redirect($destination, $http_code);
490 if (null !== $grant_host) {
491 // Removal matches on priority + callback identity; core's
492 // remove_filter() takes no accepted-args parameter.
493 remove_filter('allowed_redirect_hosts', $grant_host, 10);
494 }
495 exit;
496 }
497
498 /**
499 * Resolve a URL through the redirect table to its final destination (follows redirect chains).
500 * Used by OTTO and other backend processing so the final canonical URL is used before 404 checks.
501 *
502 * @param string $url Full URL (e.g. https://example.com/old-page)
503 * @param int $max_hops Maximum redirect hops to follow (default 10, prevents infinite loops)
504 * @return string Final destination URL, or original $url if no redirect matches
505 */
506 public function resolve_url_to_final_destination($url, $max_hops = 10)
507 {
508 if (empty($url) || !is_string($url)) {
509 return $url;
510 }
511 $parsed = wp_parse_url($url);
512 $scheme = isset($parsed['scheme']) ? $parsed['scheme'] : 'https';
513 $host = isset($parsed['host']) ? $parsed['host'] : '';
514 $base = $scheme . '://' . $host;
515 // Track the query separately from the path: sources match on the path
516 // only, and each destination may carry its own query. Folding the
517 // query into $uri (as this used to) made '/old?x=1' miss an '/old'
518 // rule and swallowed destination queries along the chain.
519 $uri = isset($parsed['path']) ? $parsed['path'] : '/';
520 $query = isset($parsed['query']) ? $parsed['query'] : '';
521 $seen = array();
522 for ($i = 0; $i < $max_hops; $i++) {
523 $uri_key = $query === '' ? $uri : $uri . '?' . $query;
524 if (isset($seen[$uri_key])) {
525 break; // cycle detected
526 }
527 $seen[$uri_key] = true;
528 $dest = $this->get_redirect_destination_for_uri($uri);
529 if ($dest === null) {
530 break;
531 }
532 $dest = $this->append_query_string($dest, $query);
533 if (strpos($dest, 'http') === 0) {
534 $url = $dest;
535 $parsed = wp_parse_url($url);
536 $scheme = isset($parsed['scheme']) ? $parsed['scheme'] : 'https';
537 $host = isset($parsed['host']) ? $parsed['host'] : '';
538 $base = $scheme . '://' . $host;
539 $uri = isset($parsed['path']) ? $parsed['path'] : '/';
540 $query = isset($parsed['query']) ? $parsed['query'] : '';
541 } else {
542 $dest_parts = explode('?', $dest, 2);
543 $uri = (isset($dest_parts[0][0]) && $dest_parts[0][0] === '/') ? $dest_parts[0] : '/' . $dest_parts[0];
544 $query = isset($dest_parts[1]) ? $dest_parts[1] : '';
545 $url = $base . $uri . ($query === '' ? '' : '?' . $query);
546 }
547 }
548 return $url;
549 }
550
551 /**
552 * Get redirect destination for a URI without redirecting (no wp_redirect, no counter update).
553 * Returns the destination URL/path if this URI matches a redirect source, else null.
554 * Used by resolve_url_to_final_destination. 410/451 are treated as "no destination".
555 *
556 * @param string $uri URI path (and optional query), e.g. /old-page or /old?x=1
557 * @return string|null Destination URL or path, or null if no match
558 */
559 private function get_redirect_destination_for_uri($uri)
560 {
561 // Defensive: callers may pass a path?query form; sources match on the
562 // path only, exactly like the live template-redirect path.
563 $qpos = strpos((string) $uri, '?');
564 if ($qpos !== false) {
565 $uri = substr($uri, 0, $qpos);
566 }
567
568 $redirections = $this->db_redirection->getAllActiveRecords();
569 if (empty($redirections)) {
570 return null;
571 }
572 foreach ($redirections as $row) {
573 if (isset($row->http_code) && in_array((int) $row->http_code, array(410, 451), true)) {
574 continue; // Gone / Unavailable – no destination to follow
575 }
576 if (empty($row->url_redirect_to)) {
577 continue;
578 }
579 $dest = $this->get_destination_for_row_and_uri($row, $uri);
580 if ($dest !== null) {
581 return $dest;
582 }
583 }
584 return null;
585 }
586
587 /**
588 * Determine whether creating a redirect from $source to $destination would
589 * produce a redirect loop or chain longer than the configured hop budget.
590 *
591 * Read-only: no DB writes, no side effects. Reuses
592 * get_redirect_destination_for_uri() so chain traversal matches the read path.
593 *
594 * @param string $source Source URL or path being created
595 * @param string $destination Destination URL or path being created
596 * @param array|null $chain Out param populated with the visited URI chain
597 * @param int|null $max_hops Optional hop budget; defaults to filter `metasync_redirect_loop_max_hops` (5)
598 * @return bool True if a loop or budget overrun is detected
599 */
600 public function would_create_loop($source, $destination, &$chain = null, $max_hops = null)
601 {
602 if ($max_hops === null) {
603 $max_hops = (int) apply_filters('metasync_redirect_loop_max_hops', 5);
604 }
605 if ($max_hops < 1) {
606 $max_hops = 1;
607 }
608
609 $source_uri = $this->normalize_uri_path($source);
610 $current = $this->normalize_uri_path($destination);
611 $chain = array($source_uri, $current);
612
613 // Trivial direct loop: redirect points back at itself
614 if ($current === $source_uri) {
615 return true;
616 }
617
618 $seen = array();
619 for ($i = 0; $i < $max_hops; $i++) {
620 if (isset($seen[$current])) {
621 // Pre-existing cycle that does not involve the new source — not our concern
622 return false;
623 }
624 $seen[$current] = true;
625
626 // Try matching with and without trailing slash to handle inconsistent storage
627 $dest = $this->get_redirect_destination_for_uri($current);
628 if ($dest === null) {
629 $alt = (substr($current, -1) === '/') ? rtrim($current, '/') : $current . '/';
630 if ($alt !== '' && $alt !== $current) {
631 $dest = $this->get_redirect_destination_for_uri($alt);
632 }
633 }
634 if ($dest === null) {
635 return false;
636 }
637
638 $current = $this->normalize_uri_path($dest);
639
640 $chain[] = $current;
641
642 if ($current === $source_uri) {
643 return true;
644 }
645 }
646
647 // Hop budget exhausted without resolving — treat as a loop-equivalent warning
648 return true;
649 }
650
651 /**
652 * Validate that a redirect would not create a loop.
653 *
654 * @param string $source Source URL or path
655 * @param string $destination Destination URL or path
656 * @return string|null Null if no loop, error message string if loop detected
657 */
658 public function validate_no_loop($source, $destination) {
659 $chain = [];
660 if ($this->would_create_loop($source, $destination, $chain)) {
661 return 'Redirect would create a loop: ' . implode(' → ', $chain);
662 }
663 return null;
664 }
665
666 /**
667 * Lightweight, non-blocking check that a redirect destination appears to resolve.
668 *
669 * Reuses the terminal dead-end logic from check_redirect_health(): internal
670 * destinations are matched against url_to_postid(); external destinations are
671 * only probed (wp_remote_head) when off-site redirects are explicitly allowed.
672 * Returns a human-readable warning string when the destination looks
673 * unreachable, or null when it resolves (or cannot be meaningfully checked).
674 *
675 * @param string $destination Destination URL or path.
676 * @param int $http_code Redirect HTTP code (410/451 are skipped).
677 * @param string $pattern_type Source pattern type ('regex' is skipped).
678 * @return string|null Warning message, or null if the destination resolves / is unchecked.
679 */
680 public function destination_resolves_warning($destination, $http_code = 301, $pattern_type = 'exact')
681 {
682 // 410/451 redirects intentionally have no live destination.
683 if (in_array((int) $http_code, array(410, 451), true)) {
684 return null;
685 }
686
687 // Regex sources have no single concrete destination path to resolve.
688 if ($pattern_type === 'regex') {
689 return null;
690 }
691
692 if (!is_string($destination) || trim($destination) === '') {
693 return null;
694 }
695
696 // Determine if the destination is external by checking for a host component
697 // that differs from the local site. Relative paths (e.g. /about) have no host
698 // and are always internal.
699 $is_external = false;
700 $parsed_dest = wp_parse_url($destination);
701 if (!empty($parsed_dest['host'])) {
702 $site_host = wp_parse_url(site_url(), PHP_URL_HOST);
703 $is_external = false === $site_host || strcasecmp($parsed_dest['host'], (string) $site_host) !== 0;
704 }
705
706 if ($is_external) {
707 // Only probe external targets when off-site redirects are explicitly enabled,
708 // and only with a short, single-hop HEAD request to keep the save lightweight.
709 // 3xx responses are intentionally not flagged — the destination itself is
710 // reachable even if it redirects further.
711 if (get_option('metasync_allow_external_redirects')) {
712 $response = wp_remote_head($destination, array('timeout' => 5, 'redirection' => 0));
713 if (is_wp_error($response)) {
714 return 'Destination URL appears unreachable — the redirect may lead to an error.';
715 }
716 $code = (int) wp_remote_retrieve_response_code($response);
717 if ($code >= 400) {
718 return 'Destination URL returned an error status (' . $code . ') — the redirect may lead to a broken page.';
719 }
720 }
721 return null;
722 }
723
724 // Internal destination: confirm it maps to a known page on this site.
725 $path = $this->normalize_uri_path($destination);
726 $post_id = url_to_postid(site_url($path));
727 if ($post_id === 0) {
728 $post_id = url_to_postid(site_url($path . '/'));
729 }
730
731 if ($post_id === 0) {
732 return 'Destination URL does not match a known page on this site — the redirect may lead to a 404.';
733 }
734
735 return null;
736 }
737
738 /**
739 * Normalise a URL or path to a leading-slash URI path used for chain comparison.
740 *
741 * @param string $url
742 * @return string
743 */
744 private function normalize_uri_path($url)
745 {
746 if (!is_string($url) || $url === '') {
747 return '/';
748 }
749 if (strpos($url, 'http') === 0) {
750 $parsed = wp_parse_url($url);
751 $path = isset($parsed['path']) ? $parsed['path'] : '/';
752 } else {
753 $path = $url;
754 }
755 if ($path === '' || $path[0] !== '/') {
756 $path = '/' . $path;
757 }
758 $path = rtrim($path, '/') ?: '/';
759 return $path;
760 }
761
762 /**
763 * Check health of one or all active redirects.
764 *
765 * Returns per-redirect diagnostics: loop, chain_too_long, dead_end, or ok.
766 * Uses a prebuilt lookup index for O(1) exact-match chain walking instead
767 * of re-scanning all records per hop.
768 *
769 * @param int|null $redirect_id Optional single redirect ID to check.
770 * @param int $max_hops Hops beyond which a chain is flagged (default 3).
771 * @return array Array of health result objects.
772 */
773 public function check_redirect_health($redirect_id = null, $max_hops = 3)
774 {
775 // Preload all active records once and build a lookup index
776 $all_records = $this->db_redirection->getAllActiveRecords();
777 $exact_map = array(); // normalized_path => destination (O(1) lookup)
778 $pattern_rows = array(); // non-exact rows requiring linear scan
779
780 foreach ($all_records as $row) {
781 if (isset($row->http_code) && in_array((int) $row->http_code, array(410, 451), true)) {
782 continue;
783 }
784 if (empty($row->url_redirect_to)) {
785 continue;
786 }
787 $sources_from = !empty($row->sources_from)
788 ? unserialize($row->sources_from, array('allowed_classes' => false))
789 : array();
790 $source_urls = is_array($sources_from) ? $sources_from : array();
791 $global_pattern_type = isset($row->pattern_type) ? $row->pattern_type : null;
792
793 foreach ($source_urls as $source_key => $source_value) {
794 // Legacy list-format rows store the URL as the VALUE under a
795 // numeric key ('0' => '/old'); remap so health checks match
796 // the URL rather than a bogus '/0'.
797 if ((is_int($source_key) || ctype_digit((string) $source_key)) && is_string($source_value) && $source_value !== '') {
798 $source_key = $source_value;
799 }
800 $pattern_type = in_array($source_value, array('exact', 'contain', 'start', 'end', 'wildcard', 'regex'))
801 ? $source_value
802 : ($global_pattern_type ? $global_pattern_type : 'exact');
803
804 if ($pattern_type === 'exact' && strpos((string) $source_key, '*') === false) {
805 $norm = $this->normalize_uri_path($source_key);
806 $exact_map[$norm] = $row->url_redirect_to;
807 } else {
808 $pattern_rows[] = $row;
809 break; // row already added, no need to check other sources
810 }
811 }
812 }
813
814 // Determine which records to check
815 if ($redirect_id !== null) {
816 $record = $this->db_redirection->find((int) $redirect_id);
817 $records = $record ? array($record) : array();
818 } else {
819 $records = $all_records;
820 }
821
822 $results = array();
823
824 foreach ($records as $row) {
825 $id = isset($row->id) ? (int) $row->id : 0;
826 $destination = isset($row->url_redirect_to) ? $row->url_redirect_to : '';
827 $http_code = isset($row->http_code) ? (int) $row->http_code : 301;
828
829 // Extract first source path for display
830 $sources_from = !empty($row->sources_from)
831 ? unserialize($row->sources_from, array('allowed_classes' => false))
832 : array();
833 $source_keys = is_array($sources_from) ? array_keys($sources_from) : array();
834 $source = !empty($source_keys) ? $source_keys[0] : '';
835 $source_path = $this->normalize_uri_path($source);
836
837 // 410/451 have no destination — always ok
838 if (in_array($http_code, array(410, 451), true)) {
839 $results[] = array(
840 'id' => $id,
841 'source' => $source_path,
842 'destination' => $destination,
843 'final_destination' => null,
844 'chain_length' => 0,
845 'chain' => array($source_path),
846 'status' => 'ok',
847 );
848 continue;
849 }
850
851 // Walk chain using the prebuilt index
852 $current = $this->normalize_uri_path($destination);
853 $chain = array($source_path, $current);
854 $seen = array();
855 $is_loop = false;
856 $hard_limit = 20;
857
858 for ($i = 0; $i < $hard_limit; $i++) {
859 if (isset($seen[$current])) {
860 $is_loop = true;
861 break;
862 }
863 $seen[$current] = true;
864
865 // O(1) exact-match lookup first
866 $dest = isset($exact_map[$current]) ? $exact_map[$current] : null;
867 if ($dest === null) {
868 // Try trailing slash variant
869 $alt = (substr($current, -1) === '/') ? rtrim($current, '/') : $current . '/';
870 if ($alt !== '' && $alt !== $current) {
871 $dest = isset($exact_map[$alt]) ? $exact_map[$alt] : null;
872 }
873 }
874 // Fallback: scan pattern-based rows only (small set)
875 if ($dest === null && !empty($pattern_rows)) {
876 foreach ($pattern_rows as $prow) {
877 $dest = $this->get_destination_for_row_and_uri($prow, $current);
878 if ($dest !== null) {
879 break;
880 }
881 // Try trailing slash alt for patterns too
882 if (isset($alt)) {
883 $dest = $this->get_destination_for_row_and_uri($prow, $alt);
884 if ($dest !== null) {
885 break;
886 }
887 }
888 }
889 }
890
891 if ($dest === null) {
892 break;
893 }
894
895 $current = $this->normalize_uri_path($dest);
896 $chain[] = $current;
897 }
898
899 // Classify: loop > chain_too_long > dead_end > ok
900 $chain_hops = count($chain) - 1;
901 if ($is_loop) {
902 $status = 'loop';
903 } elseif ($chain_hops > $max_hops) {
904 $status = 'chain_too_long';
905 } else {
906 // Check if terminal destination resolves to a real page
907 $is_dead = false;
908 if (function_exists('url_to_postid')) {
909 $post_id = url_to_postid(site_url($current));
910 if ($post_id === 0) {
911 $post_id = url_to_postid(site_url($current . '/'));
912 }
913 $is_dead = ($post_id === 0);
914 // url_to_postid() knows only posts and pages — a taxonomy
915 // archive (category/tag/custom term URL) returns 0 and a
916 // healthy destination was flagged dead. Resolve those too.
917 if ($is_dead && $this->uri_resolves_to_term_archive($current)) {
918 $is_dead = false;
919 }
920 }
921 $status = $is_dead ? 'dead_end' : 'ok';
922 }
923
924 $results[] = array(
925 'id' => $id,
926 'source' => $source_path,
927 'destination' => $this->normalize_uri_path($destination),
928 'final_destination' => $current,
929 'chain_length' => $chain_hops,
930 'chain' => $chain,
931 'status' => $status,
932 );
933 }
934
935 return $results;
936 }
937
938 /**
939 * Whether a URI path resolves to a taxonomy archive (category, tag, or a
940 * custom taxonomy term). url_to_postid() returns 0 for these, so without
941 * this check the health check reports them as dead ends.
942 *
943 * @param string $path URI path, e.g. /category/news/.
944 * @return bool True when the path maps to an existing term archive.
945 */
946 private function uri_resolves_to_term_archive($path)
947 {
948 if (!function_exists('get_term_by') || !function_exists('get_taxonomies')) {
949 return false;
950 }
951
952 $path = trim((string) $path, '/');
953 if ($path === '') {
954 return false;
955 }
956 $segments = explode('/', $path);
957 $first = array_shift($segments);
958 if ($first === '' || empty($segments)) {
959 return false;
960 }
961
962 // Built-in taxonomies honor the category_base/tag_base options; the
963 // defaults are 'category' and 'tag'.
964 $category_base = trim((string) get_option('category_base'), '/');
965 $tag_base = trim((string) get_option('tag_base'), '/');
966 $bases = array(
967 $category_base !== '' ? $category_base : 'category' => 'category',
968 $tag_base !== '' ? $tag_base : 'tag' => 'post_tag',
969 );
970 $taxonomy = isset($bases[$first]) ? $bases[$first] : null;
971
972 // Custom taxonomies carry their own rewrite base (defaults to the
973 // taxonomy name).
974 if ($taxonomy === null) {
975 foreach (get_taxonomies(array('publicly_queryable' => true, '_builtin' => false), 'objects') as $tax_object) {
976 $base = isset($tax_object->rewrite['slug']) ? trim((string) $tax_object->rewrite['slug'], '/') : $tax_object->name;
977 if ($base === $first) {
978 $taxonomy = $tax_object->name;
979 break;
980 }
981 }
982 }
983 if ($taxonomy === null) {
984 return false;
985 }
986
987 // get_term_by() returns WP_Term|false for a registered taxonomy — never
988 // a WP_Error — so a false check is the whole story here.
989 $term = get_term_by('slug', implode('/', $segments), $taxonomy);
990 if ($term === false) {
991 // Hierarchical terms live at parent/child paths, and the child's
992 // own slug is only the last segment.
993 $term = get_term_by('slug', end($segments), $taxonomy);
994 }
995 return ($term !== false);
996 }
997
998 /**
999 * Handle AJAX health check request from admin UI.
1000 */
1001 public function handle_health_check_ajax()
1002 {
1003 check_ajax_referer('metasync_redirect_health_check', 'nonce');
1004
1005 if (!Metasync::current_user_has_plugin_access()) {
1006 wp_send_json_error(array('message' => 'Insufficient permissions.'));
1007 return;
1008 }
1009
1010 $redirect_id = isset($_POST['redirect_id']) ? intval($_POST['redirect_id']) : null;
1011 if ($redirect_id === 0) {
1012 $redirect_id = null;
1013 }
1014
1015 $results = $this->check_redirect_health($redirect_id);
1016 wp_send_json_success(array('results' => $results));
1017 }
1018
1019 /**
1020 * Get destination for a single row and URI if it matches. Same matching logic as source_url_redirection.
1021 *
1022 * @param object $row Redirect row
1023 * @param string $uri URI to match
1024 * @return string|null Destination URL/path or null
1025 */
1026 private function get_destination_for_row_and_uri($row, $uri)
1027 {
1028 // Parse stored redirect sources (serialized: source path/URL => pattern type per source, or single list)
1029 $sources_from = unserialize($row->sources_from, ['allowed_classes' => false]); // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.serialize_unserialize
1030 $source_urls = is_array($sources_from) ? $sources_from : array();
1031 $global_pattern_type = isset($row->pattern_type) ? $row->pattern_type : null;
1032 $regex_pattern = isset($row->regex_pattern) ? $row->regex_pattern : null;
1033
1034 foreach ($source_urls as $source_key => $source_value) {
1035 $match_found = false;
1036 $captured_path = ''; // Used for wildcard/regex replacement in destination (e.g. * or $1)
1037
1038 // Legacy list-format rows store the URL as the VALUE under a
1039 // numeric key ('0' => '/old'); remap so matching uses the URL.
1040 if ((is_int($source_key) || ctype_digit((string) $source_key)) && is_string($source_value) && $source_value !== '') {
1041 $source_key = $source_value;
1042 }
1043
1044 // Heal magic-quoted sources saved before the input-unslash fix so
1045 // this health-check view matches with the same semantics as the
1046 // live front-end matcher.
1047 $source_key = wp_unslash((string) $source_key);
1048
1049 // Resolve pattern type: per-source value (exact, contain, start, end, wildcard, regex) or row-level default
1050 $pattern_type = in_array($source_value, array('exact', 'contain', 'start', 'end', 'wildcard', 'regex'))
1051 ? $source_value
1052 : ($global_pattern_type ? $global_pattern_type : 'exact');
1053
1054 $normalized_uri = $uri;
1055 $normalized_source = $source_key;
1056
1057 // If source is a full URL, use only the path for matching (consistent with front-end redirect behavior)
1058 if (strpos($source_key, 'http') === 0) {
1059 $parsed_src = wp_parse_url($source_key);
1060 $normalized_source = isset($parsed_src['path']) ? $parsed_src['path'] : '';
1061 }
1062
1063 // Ensure leading slash for reliable path comparison
1064 if (!empty($normalized_source) && $normalized_source[0] !== '/') {
1065 $normalized_source = '/' . $normalized_source;
1066 }
1067 if (!empty($normalized_uri) && $normalized_uri[0] !== '/') {
1068 $normalized_uri = '/' . $normalized_uri;
1069 }
1070
1071 // Normalize trailing slashes so /path and /path/ match equivalently
1072 $normalized_source = rtrim($normalized_source, '/') ?: '/';
1073 $normalized_uri = rtrim($normalized_uri, '/') ?: '/';
1074
1075 // --- Matching: regex, wildcard, or legacy pattern types ---
1076
1077 if ($pattern_type === 'regex' && $regex_pattern) {
1078 // Regex: validate and normalize pattern, then match; capture group 1 for $1 in destination
1079 if (!$this->validate_regex_pattern($regex_pattern)) {
1080 continue;
1081 }
1082 $normalized_pattern = $this->normalize_regex_pattern($regex_pattern);
1083 $matches = array();
1084 if (@preg_match($normalized_pattern, $normalized_uri, $matches) === 1) {
1085 $match_found = true;
1086 $captured_path = isset($matches[1]) ? $matches[1] : '';
1087 }
1088 } else {
1089 // Non-regex: check for * in source (wildcard) or use exact/contain/start/end
1090 $has_wildcard = strpos($normalized_source, '*') !== false;
1091 if ($has_wildcard) {
1092 // Wildcard: e.g. /old/* matches /old/page and captures "page" for destination
1093 $match_result = $this->match_wildcard($normalized_source, $normalized_uri);
1094 if ($match_result !== false) {
1095 $match_found = true;
1096 $captured_path = $match_result;
1097 }
1098 } else {
1099 // Legacy pattern: source_value can be the pattern type when key is the path
1100 switch ($source_value) {
1101 case 'exact':
1102 if ($normalized_source === $normalized_uri) {
1103 $match_found = true;
1104 }
1105 break;
1106 case 'contain':
1107 if ($this->contains($normalized_uri, $normalized_source)) {
1108 $match_found = true;
1109 }
1110 break;
1111 case 'start':
1112 if (str_starts_with($normalized_uri, $normalized_source)) {
1113 $match_found = true;
1114 }
1115 break;
1116 case 'end':
1117 if (str_ends_with($normalized_uri, $normalized_source)) {
1118 $match_found = true;
1119 }
1120 break;
1121 default:
1122 // Fallback to row-level pattern_type when source_value is not a known type
1123 switch ($pattern_type) {
1124 case 'exact':
1125 if ($normalized_source === $normalized_uri) {
1126 $match_found = true;
1127 }
1128 break;
1129 case 'contain':
1130 if ($this->contains($normalized_uri, $normalized_source)) {
1131 $match_found = true;
1132 }
1133 break;
1134 case 'start':
1135 if (str_starts_with($normalized_uri, $normalized_source)) {
1136 $match_found = true;
1137 }
1138 break;
1139 case 'end':
1140 if (str_ends_with($normalized_uri, $normalized_source)) {
1141 $match_found = true;
1142 }
1143 break;
1144 case 'wildcard':
1145 $match_result = $this->match_wildcard($normalized_source, $normalized_uri);
1146 if ($match_result !== false) {
1147 $match_found = true;
1148 $captured_path = $match_result;
1149 }
1150 break;
1151 }
1152 break;
1153 }
1154 }
1155 }
1156
1157 // First match wins: return destination with * / $1 replaced by captured_path
1158 if ($match_found && !empty($row->url_redirect_to)) {
1159 return $this->process_destination_url($row->url_redirect_to, $captured_path);
1160 }
1161 }
1162
1163 return null;
1164 }
1165
1166 /**
1167 * Match wildcard pattern against URI
1168 *
1169 * @param string $pattern Pattern with * wildcard
1170 * @param string $uri URI to match against
1171 * @return string|false Returns captured path on match, false otherwise
1172 */
1173 private function match_wildcard($pattern, $uri)
1174 {
1175 // Sanitize: escape the entire pattern for safe regex use, then restore wildcards
1176 $escaped = preg_quote($pattern, '/');
1177 // preg_quote escapes *, so replace the escaped \* back with a capturing group
1178 $regex = '/^' . str_replace('\\*', '(.*)', $escaped) . '$/';
1179
1180 $matches = [];
1181 $result = @preg_match($regex, $uri, $matches);
1182
1183 if ($result && $result !== false) {
1184 // Return the captured path (first capturing group)
1185 return isset($matches[1]) ? $matches[1] : '';
1186 }
1187
1188 return false;
1189 }
1190
1191 /**
1192 * Process destination URL with captured path
1193 *
1194 * @param string $destination Destination URL (may contain * or $1)
1195 * @param string $captured_path Captured path from source
1196 * @return string Processed destination URL
1197 */
1198 private function process_destination_url($destination, $captured_path)
1199 {
1200 // Replace * wildcard with captured path
1201 if (strpos($destination, '*') !== false) {
1202 $destination = str_replace('*', $captured_path ?? '', $destination);
1203 }
1204
1205 // Replace $1 placeholder with captured path (for regex compatibility)
1206 if (strpos($destination, '$1') !== false) {
1207 $destination = str_replace('$1', $captured_path ?? '', $destination);
1208 }
1209
1210 return $destination;
1211 }
1212
1213 /**
1214 * Preserve query parameters from the incoming request when redirecting.
1215 *
1216 * Redirect matching intentionally ignores the query string, but the
1217 * browser-visible redirect must not silently discard it. Keep the stored
1218 * destination query intact and append the incoming query verbatim so
1219 * encoded values and repeated parameters survive unchanged.
1220 *
1221 * @param string $destination Destination URL or path.
1222 * @param string $query Incoming query without the leading '?'.
1223 * @return string Destination with the incoming query appended.
1224 */
1225 private function append_query_string($destination, $query)
1226 {
1227 $destination = (string) $destination;
1228 $query = ltrim((string) $query, '?');
1229 if ($query === '') {
1230 return $destination;
1231 }
1232
1233 $fragment = '';
1234 $fragment_pos = strpos($destination, '#');
1235 if ($fragment_pos !== false) {
1236 $fragment = substr($destination, $fragment_pos);
1237 $destination = substr($destination, 0, $fragment_pos);
1238 }
1239
1240 $separator = strpos($destination, '?') === false ? '?' : '&';
1241 return $destination . $separator . $query . $fragment;
1242 }
1243
1244 /**
1245 * Handle template redirect for frontend redirections
1246 */
1247 public function handle_template_redirect()
1248 {
1249 // Only process on frontend
1250 if (is_admin()) {
1251 return;
1252 }
1253
1254 // Get current URI. wp_magic_quotes() slashes $_SERVER too, so unslash
1255 // here to compare against the literal (unslashed) stored sources.
1256 $request_uri = isset($_SERVER['REQUEST_URI']) ? wp_unslash((string) $_SERVER['REQUEST_URI']) : '/';
1257
1258 // Keep the raw query for the redirect destination. It is deliberately
1259 // excluded from matching below, but must survive the browser redirect.
1260 $query_pos = strpos($request_uri, '?');
1261 $incoming_query = $query_pos === false ? '' : substr($request_uri, $query_pos + 1);
1262
1263 // Remove query string for matching
1264 $uri = $query_pos === false ? $request_uri : substr($request_uri, 0, $query_pos);
1265
1266 // REQUEST_URI arrives percent-encoded while stored sources are raw
1267 // UTF-8, so a stored '/日本' could never match '/%E6%97%A5%E6%9C%AC'.
1268 // Decode before matching; the stored destination still drives output.
1269 $decoded_uri = rawurldecode((string) $uri);
1270 if ($decoded_uri !== '') {
1271 $uri = $decoded_uri;
1272 }
1273
1274 // Build the lookup index (lazy, once per request)
1275 $this->ensure_redirect_index();
1276
1277 // O(1) exact-match lookup first
1278 $normalized_uri = $uri;
1279 if (!empty($normalized_uri) && $normalized_uri[0] !== '/') {
1280 $normalized_uri = '/' . $normalized_uri;
1281 }
1282 $normalized_uri = rtrim($normalized_uri, '/') ?: '/';
1283
1284 if (isset($this->exact_index[$normalized_uri])) {
1285 if ($this->source_url_redirection($this->exact_index[$normalized_uri], $uri, $incoming_query)) {
1286 return;
1287 }
1288 }
1289
1290 // Fallback: scan only pattern-based rows (wildcard, regex, contain, start, end)
1291 foreach ($this->pattern_index as $redirection) {
1292 if ($this->source_url_redirection($redirection, $uri, $incoming_query)) {
1293 return;
1294 }
1295 }
1296 }
1297
1298 /**
1299 * Prevent WordPress from redirecting to draft posts
1300 * This stops WordPress from auto-redirecting URLs to ?p=POST_ID for draft posts
1301 *
1302 * @param string $redirect_url The redirect URL
1303 * @param string $requested_url The requested URL
1304 * @return string|false The redirect URL or false to cancel redirect
1305 */
1306 public function prevent_draft_post_redirects($redirect_url, $requested_url)
1307 {
1308 # If no redirect is happening, return as-is
1309 if (empty($redirect_url)) {
1310 return $redirect_url;
1311 }
1312
1313 # Check if WordPress is trying to redirect to a ?p= or ?page_id= URL
1314 if (strpos($redirect_url, '?p=') !== false || strpos($redirect_url, '?page_id=') !== false) {
1315 # Extract the post ID
1316 $post_id = null;
1317 if (preg_match('/[?&]p=(\d+)/', $redirect_url, $matches)) {
1318 $post_id = intval($matches[1]);
1319 } elseif (preg_match('/[?&]page_id=(\d+)/', $redirect_url, $matches)) {
1320 $post_id = intval($matches[1]);
1321 }
1322
1323 # If we found a post ID, check if it's a draft
1324 if ($post_id) {
1325 $post = get_post($post_id);
1326
1327 # If post is draft, auto-draft, pending, or private, prevent the redirect
1328 if ($post && in_array($post->post_status, ['draft', 'auto-draft', 'pending', 'private'])) {
1329 # Return false to cancel the redirect and show 404 instead
1330 return false;
1331 }
1332 }
1333 }
1334
1335 # Allow the redirect for published posts
1336 return $redirect_url;
1337 }
1338
1339 /**
1340 * Prevent wp_old_slug_redirect from redirecting to draft posts
1341 *
1342 * This uses WordPress's built-in 'old_slug_redirect_post_id' filter to selectively
1343 * block redirects ONLY to unpublished posts, while allowing redirects to published posts.
1344 * - It doesn't break existing WordPress functionality
1345 * - Published posts can still use old slug redirects (good for SEO)
1346 * - Only protects unpublished content from exposure
1347 * - Non-invasive and backwards compatible
1348 *
1349 * @param int $post_id The post ID that WordPress wants to redirect to
1350 * @return int|false The post ID to redirect to, or false to prevent redirect
1351 */
1352 public function prevent_old_slug_redirect_to_drafts($post_id)
1353 {
1354 # If no post ID provided, don't redirect
1355 if (empty($post_id)) {
1356 return false;
1357 }
1358
1359 # Get the post
1360 $post = get_post($post_id);
1361
1362 # If post doesn't exist, don't redirect
1363 if (!$post) {
1364 return false;
1365 }
1366
1367 // Check if this post is unpublished (draft, pending, private, auto-draft)
1368 if (in_array($post->post_status, ['draft', 'auto-draft', 'pending', 'private'])) {
1369 # Return false to prevent the redirect to unpublished content
1370 # This will cause WordPress to show 404 instead, protecting draft content
1371 return false;
1372 }
1373
1374 # Allow redirect for published posts (preserves normal WordPress functionality)
1375 return $post_id;
1376 }
1377
1378 /**
1379 * Normalize regex pattern by adding delimiters if missing
1380 *
1381 * @param string $pattern The regex pattern
1382 * @return string Pattern with delimiters
1383 */
1384 public static function normalize_regex_pattern($pattern)
1385 {
1386 if (empty($pattern)) {
1387 return $pattern;
1388 }
1389
1390 // Check if pattern starts with a common delimiter
1391 $common_delimiters = ['/', '#', '~', '%', '@'];
1392 $starts_with_delimiter = in_array($pattern[0], $common_delimiters);
1393
1394 if ($starts_with_delimiter) {
1395 // Pattern starts with delimiter, check if it has proper structure
1396 $first_char = $pattern[0];
1397 $last_delimiter_pos = strrpos($pattern, $first_char);
1398
1399 // If there's a closing delimiter at a different position, pattern likely has delimiters
1400 if ($last_delimiter_pos !== false && $last_delimiter_pos > 0) {
1401 // Check if what comes after the last delimiter are valid modifiers
1402 $after_last_delimiter = substr($pattern, $last_delimiter_pos + 1);
1403 // Valid modifiers: i, m, s, x, A, D, S, U, X, J, u
1404 if (empty($after_last_delimiter) || preg_match('/^[imsxADSUXJu]*$/', $after_last_delimiter)) {
1405 // Pattern appears to have proper delimiters, return as-is
1406 return $pattern;
1407 }
1408 }
1409 }
1410
1411 // Pattern doesn't have delimiters or is malformed, add them
1412 // Choose delimiter that's not in the pattern
1413 $delimiters = ['/', '#', '~', '%', '@'];
1414 $delimiter = '/';
1415
1416 foreach ($delimiters as $test_delimiter) {
1417 if (strpos($pattern, $test_delimiter) === false) {
1418 $delimiter = $test_delimiter;
1419 break;
1420 }
1421 }
1422
1423 return $delimiter . $pattern . $delimiter;
1424 }
1425
1426 /**
1427 * Validate regex pattern
1428 */
1429 public function validate_regex_pattern($pattern)
1430 {
1431 if (empty($pattern)) {
1432 return true; // Empty pattern is valid (not required)
1433 }
1434
1435 // Normalize pattern (add delimiters if missing, fix malformed patterns)
1436 $normalized_pattern = $this->normalize_regex_pattern($pattern);
1437
1438 // Test if the regex pattern is valid
1439 # $test_result = @preg_match($pattern, '');
1440 # return $test_result !== false;
1441
1442 // Use error handler to catch warnings from malformed patterns
1443 $error_occurred = false;
1444 set_error_handler(function() use (&$error_occurred) { // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_set_error_handler -- genuine failure path, bounded, no secrets
1445 $error_occurred = true;
1446 return true; // Suppress the error
1447 }, E_WARNING);
1448
1449 $test_result = preg_match($normalized_pattern, '');
1450
1451 restore_error_handler();
1452
1453 // Return false if preg_match failed or if an error occurred
1454 return $test_result !== false && !$error_occurred;
1455 }
1456
1457 /**
1458 * Sanitize URL for redirection
1459 */
1460 public function sanitize_redirect_url($url)
1461 {
1462 // Remove any dangerous protocols
1463 $url = str_replace(['javascript:', 'data:', 'vbscript:'], '', $url);
1464
1465 // Ensure it's a valid URL
1466 if (!filter_var($url, FILTER_VALIDATE_URL) && !str_starts_with($url, '/')) {
1467 // If it's not a full URL and doesn't start with /, assume it's a relative path
1468 $url = '/' . ltrim($url, '/');
1469 }
1470
1471 return esc_url_raw($url);
1472 }
1473 }
1474