PluginProbe
Search Atlas SEO – OTTO AI SEO Automation for WordPress / 2.6.26
Search Atlas SEO – OTTO AI SEO Automation for WordPress v2.6.26
2.6.26 2.6.25 2.6.24 2.6.23 2.6.22 2.6.21 2.6.20 2.6.19 2.6.18 2.6.17 2.6.16 2.6.15 2.6.14 2.6.13 2.6.12 2.6.11 2.6.10 2.6.9 2.6.8 2.6.7 2.6.6 2.6.5 2.6.4 2.6.3 2.5.23 All 138 releases
metasync / redirections / class-metasync-redirection.php

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

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