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

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