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

759 lines 30.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 class Metasync_Redirection
22 {
23
24 private $db_redirection;
25 private $common;
26 private $importer;
27 public function __construct(&$db_redirection)
28 {
29 $this->db_redirection = $db_redirection;
30 $this->common = new Metasync_Common();
31
32 # Load importer class
33 require_once dirname(__FILE__) . '/class-metasync-redirection-importer.php';
34 $this->importer = new Metasync_Redirection_Importer($db_redirection);
35 }
36
37 function contains($haystack, $needle, $caseSensitive = false)
38 {
39 return $caseSensitive ?
40 (strpos($haystack, $needle) === FALSE ? FALSE : TRUE) : (stripos($haystack, $needle) === FALSE ? FALSE : TRUE);
41 }
42
43 public function create_admin_redirection_interface()
44 {
45 # Check if we should show import interface
46 $request_data = metasync_sanitize_input_array($_REQUEST);
47 if (isset($request_data['action']) && $request_data['action'] === 'import') {
48 $this->show_import_interface();
49 return;
50 }
51
52 if (!class_exists('WP_List_Table')) {
53 require_once ABSPATH . 'wp-admin/includes/class-wp-list-table.php';
54 }
55 require dirname(__FILE__, 2) . '/redirections/class-metasync-redirection-list-table.php';
56
57 $MetasyncRedirection = new Metasync_Redirection_List_Table();
58
59 $MetasyncRedirection->setDatabaseResource($this->db_redirection);
60
61 $MetasyncRedirection->prepare_items();
62
63 // Include the view markup.
64 include dirname(__FILE__, 2) . '/views/metasync-redirection.php';
65 }
66
67 /**
68 * Show import interface
69 */
70 public function show_import_interface()
71 {
72 $importer = $this->importer;
73 include dirname(__FILE__, 2) . '/views/metasync-import-redirections.php';
74 }
75
76 /**
77 * Handle AJAX import request
78 */
79 public function handle_import_ajax()
80 {
81 # Verify nonce
82 check_ajax_referer('metasync_import_redirections', 'nonce');
83
84 # Check user capabilities
85 if (!Metasync::current_user_has_plugin_access()) {
86 wp_send_json_error(['message' => 'Insufficient permissions.']);
87 return;
88 }
89
90 $plugin = isset($_POST['plugin']) ? sanitize_text_field($_POST['plugin']) : '';
91
92 if (empty($plugin)) {
93 wp_send_json_error(['message' => 'No plugin specified.']);
94 return;
95 }
96
97 try {
98 # Perform import
99 $result = $this->importer->import_from_plugin($plugin);
100
101 if ($result['success']) {
102 wp_send_json_success($result);
103 } else {
104 wp_send_json_error($result);
105 }
106 } catch (Exception $e) {
107 wp_send_json_error([
108 'message' => 'Import failed. Please try again or contact support.',
109 'imported' => 0,
110 'skipped' => 0
111 ]);
112 }
113 }
114
115 public function get_current_page_url()
116 {
117 $server_data = metasync_sanitize_input_array($_SERVER);
118 $link = '://' . $server_data['HTTP_HOST'] . $server_data['REQUEST_URI'];
119 $link = (is_ssl() ? 'https' : 'http') . $link;
120 return sanitize_url($link);
121 }
122
123 public function source_url_redirection(object $row, string $uri)
124 {
125 // Optimize: unserialize only once
126 $sources_from = unserialize($row->sources_from);
127 $source_urls = is_array($sources_from) ? $sources_from : [];
128 $global_pattern_type = isset($row->pattern_type) ? $row->pattern_type : null;
129 $regex_pattern = isset($row->regex_pattern) ? $row->regex_pattern : null;
130
131 foreach ($source_urls as $source_key => $source_value) {
132 $match_found = false;
133 $captured_path = ''; // Store captured path for wildcard replacement
134
135 // Ensure source_key is always a string (handles numeric array indexes)
136 $source_key = (string) $source_key;
137
138 // Determine pattern type: use source value if it's a valid pattern, otherwise use global pattern_type
139 $pattern_type = in_array($source_value, ['exact', 'contain', 'start', 'end', 'wildcard', 'regex'])
140 ? $source_value
141 : ($global_pattern_type ? $global_pattern_type : 'exact');
142
143 // Normalize both URI and source for comparison
144 $normalized_uri = $uri;
145 $normalized_source = $source_key;
146
147 // If source is a full URL, extract just the path part
148 if (strpos($source_key, 'http') === 0) {
149 $parsed_url = parse_url($source_key);
150 $normalized_source = $parsed_url['path'] ?? '';
151 }
152
153 # Ensure both have leading slashes for proper comparison
154 # This handles cases where database stores URLs with or without leading slash
155 # Convert to string to handle cases where source_key might be an integer
156 $normalized_source = (string) $normalized_source;
157 if (!empty($normalized_source) && $normalized_source[0] !== '/') {
158 $normalized_source = '/' . $normalized_source;
159 }
160 if (!empty($normalized_uri) && $normalized_uri[0] !== '/') {
161 $normalized_uri = '/' . $normalized_uri;
162 }
163
164 // Keep full URI path for matching (don't strip leading slash)
165 // This allows matching against full URLs like /wordpress/index.php/category/article/*
166
167 // Handle regex patterns
168 if ($pattern_type === 'regex' && $regex_pattern) {
169 // Validate regex pattern before using it
170 if (!$this->validate_regex_pattern($regex_pattern)) {
171 // Skip invalid regex patterns to prevent errors
172 continue;
173 }
174
175 # Normalize pattern (add delimiters if missing)
176 $normalized_pattern = $this->normalize_regex_pattern($regex_pattern);
177
178
179 $matches = [];
180 // Suppress warnings for invalid regex and check result
181 # $result = @preg_match($regex_pattern, $normalized_uri, $matches);
182 $result = @preg_match($normalized_pattern, $normalized_uri, $matches);
183
184 if ($result === 1) {
185 $match_found = true;
186 // Store captured groups for replacement
187 if (isset($matches[1])) {
188 $captured_path = $matches[1];
189 }
190 }
191 // If $result === false, regex is invalid - skip silently
192 } else {
193 // Check if source has wildcard
194 $has_wildcard = strpos($normalized_source, '*') !== false;
195
196 if ($has_wildcard) {
197 // Handle wildcard pattern
198 $match_result = $this->match_wildcard($normalized_source, $normalized_uri);
199 if ($match_result !== false) {
200 $match_found = true;
201 $captured_path = $match_result;
202 }
203 } else {
204 // Handle legacy pattern matching (non-wildcard)
205 switch ($source_value) {
206 case 'exact':
207 if ($normalized_source === $normalized_uri) {
208 $match_found = true;
209 }
210 break;
211 case 'contain':
212 if ($this->contains($normalized_uri, $normalized_source)) {
213 $match_found = true;
214 }
215 break;
216 case 'start':
217 if (str_starts_with($normalized_uri, $normalized_source)) {
218 $match_found = true;
219 }
220 break;
221 case 'end':
222 if (str_ends_with($normalized_uri, $normalized_source)) {
223 $match_found = true;
224 }
225 break;
226 default:
227 // Handle new pattern_type field
228 switch ($pattern_type) {
229 case 'exact':
230 if ($normalized_source === $normalized_uri) {
231 $match_found = true;
232 }
233 break;
234 case 'contain':
235 if ($this->contains($normalized_uri, $normalized_source)) {
236 $match_found = true;
237 }
238 break;
239 case 'start':
240 if (str_starts_with($normalized_uri, $normalized_source)) {
241 $match_found = true;
242 }
243 break;
244 case 'end':
245 if (str_ends_with($normalized_uri, $normalized_source)) {
246 $match_found = true;
247 }
248 break;
249 case 'wildcard':
250 $match_result = $this->match_wildcard($normalized_source, $normalized_uri);
251 if ($match_result !== false) {
252 $match_found = true;
253 $captured_path = $match_result;
254 }
255 break;
256 }
257 break;
258 }
259 }
260 }
261
262 if ($match_found) {
263 $this->db_redirection->update_counter($row);
264
265 if ($row->http_code === '410') {
266 status_header(410);
267 die;
268 }
269 if ($row->http_code === '451') {
270 status_header(451, 'Unavailable For Legal Reasons');
271 die;
272 }
273 if ($row->url_redirect_to) {
274 // Replace wildcards or $1 placeholders in destination URL
275 $destination = $this->process_destination_url($row->url_redirect_to, $captured_path);
276 wp_redirect($destination, $row->http_code);
277 die;
278 }
279 // Match found and processed, return true to stop checking other rules
280 return true;
281 }
282 }
283
284 // No match found
285 return false;
286 }
287
288 /**
289 * Resolve a URL through the redirect table to its final destination (follows redirect chains).
290 * Used by OTTO and other backend processing so the final canonical URL is used before 404 checks.
291 *
292 * @param string $url Full URL (e.g. https://example.com/old-page)
293 * @param int $max_hops Maximum redirect hops to follow (default 10, prevents infinite loops)
294 * @return string Final destination URL, or original $url if no redirect matches
295 */
296 public function resolve_url_to_final_destination($url, $max_hops = 10)
297 {
298 if (empty($url) || !is_string($url)) {
299 return $url;
300 }
301 $parsed = parse_url($url);
302 $scheme = isset($parsed['scheme']) ? $parsed['scheme'] : 'https';
303 $host = isset($parsed['host']) ? $parsed['host'] : '';
304 $base = $scheme . '://' . $host;
305 $uri = isset($parsed['path']) ? $parsed['path'] : '/';
306 if (!empty($parsed['query'])) {
307 $uri .= '?' . $parsed['query'];
308 }
309 $seen = array();
310 for ($i = 0; $i < $max_hops; $i++) {
311 $uri_key = $uri;
312 if (isset($seen[$uri_key])) {
313 break; // cycle detected
314 }
315 $seen[$uri_key] = true;
316 $dest = $this->get_redirect_destination_for_uri($uri);
317 if ($dest === null) {
318 break;
319 }
320 if (strpos($dest, 'http') === 0) {
321 $url = $dest;
322 $parsed = parse_url($url);
323 $scheme = isset($parsed['scheme']) ? $parsed['scheme'] : 'https';
324 $host = isset($parsed['host']) ? $parsed['host'] : '';
325 $base = $scheme . '://' . $host;
326 $uri = isset($parsed['path']) ? $parsed['path'] : '/';
327 if (!empty($parsed['query'])) {
328 $uri .= '?' . $parsed['query'];
329 }
330 } else {
331 $uri = (isset($dest[0]) && $dest[0] === '/') ? $dest : '/' . $dest;
332 $url = $base . $uri;
333 }
334 }
335 return $url;
336 }
337
338 /**
339 * Get redirect destination for a URI without redirecting (no wp_redirect, no counter update).
340 * Returns the destination URL/path if this URI matches a redirect source, else null.
341 * Used by resolve_url_to_final_destination. 410/451 are treated as "no destination".
342 *
343 * @param string $uri URI path (and optional query), e.g. /old-page or /old?x=1
344 * @return string|null Destination URL or path, or null if no match
345 */
346 private function get_redirect_destination_for_uri($uri)
347 {
348 $redirections = $this->db_redirection->getAllActiveRecords();
349 if (empty($redirections)) {
350 return null;
351 }
352 foreach ($redirections as $row) {
353 if (isset($row->http_code) && in_array((int) $row->http_code, array(410, 451), true)) {
354 continue; // Gone / Unavailable – no destination to follow
355 }
356 if (empty($row->url_redirect_to)) {
357 continue;
358 }
359 $dest = $this->get_destination_for_row_and_uri($row, $uri);
360 if ($dest !== null) {
361 return $dest;
362 }
363 }
364 return null;
365 }
366
367 /**
368 * Get destination for a single row and URI if it matches. Same matching logic as source_url_redirection.
369 *
370 * @param object $row Redirect row
371 * @param string $uri URI to match
372 * @return string|null Destination URL/path or null
373 */
374 private function get_destination_for_row_and_uri($row, $uri)
375 {
376 // Parse stored redirect sources (serialized: source path/URL => pattern type per source, or single list)
377 $sources_from = @unserialize($row->sources_from);
378 $source_urls = is_array($sources_from) ? $sources_from : array();
379 $global_pattern_type = isset($row->pattern_type) ? $row->pattern_type : null;
380 $regex_pattern = isset($row->regex_pattern) ? $row->regex_pattern : null;
381
382 foreach ($source_urls as $source_key => $source_value) {
383 $match_found = false;
384 $captured_path = ''; // Used for wildcard/regex replacement in destination (e.g. * or $1)
385
386 // Resolve pattern type: per-source value (exact, contain, start, end, wildcard, regex) or row-level default
387 $pattern_type = in_array($source_value, array('exact', 'contain', 'start', 'end', 'wildcard', 'regex'))
388 ? $source_value
389 : ($global_pattern_type ? $global_pattern_type : 'exact');
390
391 $normalized_uri = $uri;
392 $normalized_source = $source_key;
393
394 // If source is a full URL, use only the path for matching (consistent with front-end redirect behavior)
395 if (strpos($source_key, 'http') === 0) {
396 $parsed_src = parse_url($source_key);
397 $normalized_source = isset($parsed_src['path']) ? $parsed_src['path'] : '';
398 }
399
400 // Ensure leading slash for reliable path comparison
401 if (!empty($normalized_source) && $normalized_source[0] !== '/') {
402 $normalized_source = '/' . $normalized_source;
403 }
404 if (!empty($normalized_uri) && $normalized_uri[0] !== '/') {
405 $normalized_uri = '/' . $normalized_uri;
406 }
407
408 // --- Matching: regex, wildcard, or legacy pattern types ---
409
410 if ($pattern_type === 'regex' && $regex_pattern) {
411 // Regex: validate and normalize pattern, then match; capture group 1 for $1 in destination
412 if (!$this->validate_regex_pattern($regex_pattern)) {
413 continue;
414 }
415 $normalized_pattern = $this->normalize_regex_pattern($regex_pattern);
416 $matches = array();
417 if (@preg_match($normalized_pattern, $normalized_uri, $matches) === 1) {
418 $match_found = true;
419 $captured_path = isset($matches[1]) ? $matches[1] : '';
420 }
421 } else {
422 // Non-regex: check for * in source (wildcard) or use exact/contain/start/end
423 $has_wildcard = strpos($normalized_source, '*') !== false;
424 if ($has_wildcard) {
425 // Wildcard: e.g. /old/* matches /old/page and captures "page" for destination
426 $match_result = $this->match_wildcard($normalized_source, $normalized_uri);
427 if ($match_result !== false) {
428 $match_found = true;
429 $captured_path = $match_result;
430 }
431 } else {
432 // Legacy pattern: source_value can be the pattern type when key is the path
433 switch ($source_value) {
434 case 'exact':
435 if ($normalized_source === $normalized_uri) {
436 $match_found = true;
437 }
438 break;
439 case 'contain':
440 if ($this->contains($normalized_uri, $normalized_source)) {
441 $match_found = true;
442 }
443 break;
444 case 'start':
445 if (str_starts_with($normalized_uri, $normalized_source)) {
446 $match_found = true;
447 }
448 break;
449 case 'end':
450 if (str_ends_with($normalized_uri, $normalized_source)) {
451 $match_found = true;
452 }
453 break;
454 default:
455 // Fallback to row-level pattern_type when source_value is not a known type
456 switch ($pattern_type) {
457 case 'exact':
458 if ($normalized_source === $normalized_uri) {
459 $match_found = true;
460 }
461 break;
462 case 'contain':
463 if ($this->contains($normalized_uri, $normalized_source)) {
464 $match_found = true;
465 }
466 break;
467 case 'start':
468 if (str_starts_with($normalized_uri, $normalized_source)) {
469 $match_found = true;
470 }
471 break;
472 case 'end':
473 if (str_ends_with($normalized_uri, $normalized_source)) {
474 $match_found = true;
475 }
476 break;
477 case 'wildcard':
478 $match_result = $this->match_wildcard($normalized_source, $normalized_uri);
479 if ($match_result !== false) {
480 $match_found = true;
481 $captured_path = $match_result;
482 }
483 break;
484 }
485 break;
486 }
487 }
488 }
489
490 // First match wins: return destination with * / $1 replaced by captured_path
491 if ($match_found && !empty($row->url_redirect_to)) {
492 return $this->process_destination_url($row->url_redirect_to, $captured_path);
493 }
494 }
495
496 return null;
497 }
498
499 /**
500 * Match wildcard pattern against URI
501 *
502 * @param string $pattern Pattern with * wildcard
503 * @param string $uri URI to match against
504 * @return string|false Returns captured path on match, false otherwise
505 */
506 private function match_wildcard($pattern, $uri)
507 {
508 // Escape special regex characters except *
509 $pattern = str_replace(['\\', '/', '.', '+', '?', '[', '^', ']', '$', '(', ')', '{', '}', '=', '!', '<', '>', '|', ':', '-'],
510 ['\\\\', '\\/', '\\.', '\\+', '\\?', '\\[', '\\^', '\\]', '\\$', '\\(', '\\)', '\\{', '\\}', '\\=', '\\!', '\\<', '\\>', '\\|', '\\:', '\\-'],
511 $pattern);
512
513 // Replace * with capturing group
514 $pattern = str_replace('*', '(.*)', $pattern);
515
516 // Create regex pattern
517 $regex = '/^' . $pattern . '$/';
518
519 $matches = [];
520 if (preg_match($regex, $uri, $matches)) {
521 // Return the captured path (first capturing group)
522 return isset($matches[1]) ? $matches[1] : '';
523 }
524
525 return false;
526 }
527
528 /**
529 * Process destination URL with captured path
530 *
531 * @param string $destination Destination URL (may contain * or $1)
532 * @param string $captured_path Captured path from source
533 * @return string Processed destination URL
534 */
535 private function process_destination_url($destination, $captured_path)
536 {
537 // Replace * wildcard with captured path
538 if (strpos($destination, '*') !== false) {
539 $destination = str_replace('*', $captured_path ?? '', $destination);
540 }
541
542 // Replace $1 placeholder with captured path (for regex compatibility)
543 if (strpos($destination, '$1') !== false) {
544 $destination = str_replace('$1', $captured_path ?? '', $destination);
545 }
546
547 return $destination;
548 }
549
550 /**
551 * Handle template redirect for frontend redirections
552 */
553 public function handle_template_redirect()
554 {
555 // Only process on frontend
556 if (is_admin()) {
557 return;
558 }
559
560 // Get current URI
561 $uri = $_SERVER['REQUEST_URI'];
562
563 // Remove query string for matching
564 $uri = strtok($uri, '?');
565
566 // Get all active redirections (cached for 1 hour)
567 $redirections = $this->db_redirection->getAllActiveRecords();
568
569 // Early exit if no redirections configured
570 if (empty($redirections)) {
571 return;
572 }
573
574 // Process each redirection until a match is found
575 foreach ($redirections as $redirection) {
576 // Stop processing once a match is found and redirect is executed
577 if ($this->source_url_redirection($redirection, $uri)) {
578 break; // Early exit - no need to check remaining rules
579 }
580 }
581 }
582
583 /**
584 * Prevent WordPress from redirecting to draft posts
585 * This stops WordPress from auto-redirecting URLs to ?p=POST_ID for draft posts
586 *
587 * @param string $redirect_url The redirect URL
588 * @param string $requested_url The requested URL
589 * @return string|false The redirect URL or false to cancel redirect
590 */
591 public function prevent_draft_post_redirects($redirect_url, $requested_url)
592 {
593 # If no redirect is happening, return as-is
594 if (empty($redirect_url)) {
595 return $redirect_url;
596 }
597
598 # Check if WordPress is trying to redirect to a ?p= or ?page_id= URL
599 if (strpos($redirect_url, '?p=') !== false || strpos($redirect_url, '?page_id=') !== false) {
600 # Extract the post ID
601 $post_id = null;
602 if (preg_match('/[?&]p=(\d+)/', $redirect_url, $matches)) {
603 $post_id = intval($matches[1]);
604 } elseif (preg_match('/[?&]page_id=(\d+)/', $redirect_url, $matches)) {
605 $post_id = intval($matches[1]);
606 }
607
608 # If we found a post ID, check if it's a draft
609 if ($post_id) {
610 $post = get_post($post_id);
611
612 # If post is draft, auto-draft, pending, or private, prevent the redirect
613 if ($post && in_array($post->post_status, ['draft', 'auto-draft', 'pending', 'private'])) {
614 # Return false to cancel the redirect and show 404 instead
615 return false;
616 }
617 }
618 }
619
620 # Allow the redirect for published posts
621 return $redirect_url;
622 }
623
624 /**
625 * Prevent wp_old_slug_redirect from redirecting to draft posts
626 *
627 * This uses WordPress's built-in 'old_slug_redirect_post_id' filter to selectively
628 * block redirects ONLY to unpublished posts, while allowing redirects to published posts.
629 * - It doesn't break existing WordPress functionality
630 * - Published posts can still use old slug redirects (good for SEO)
631 * - Only protects unpublished content from exposure
632 * - Non-invasive and backwards compatible
633 *
634 * @param int $post_id The post ID that WordPress wants to redirect to
635 * @return int|false The post ID to redirect to, or false to prevent redirect
636 */
637 public function prevent_old_slug_redirect_to_drafts($post_id)
638 {
639 # If no post ID provided, don't redirect
640 if (empty($post_id)) {
641 return false;
642 }
643
644 # Get the post
645 $post = get_post($post_id);
646
647 # If post doesn't exist, don't redirect
648 if (!$post) {
649 return false;
650 }
651
652 // Check if this post is unpublished (draft, pending, private, auto-draft)
653 if (in_array($post->post_status, ['draft', 'auto-draft', 'pending', 'private'])) {
654 # Return false to prevent the redirect to unpublished content
655 # This will cause WordPress to show 404 instead, protecting draft content
656 return false;
657 }
658
659 # Allow redirect for published posts (preserves normal WordPress functionality)
660 return $post_id;
661 }
662
663 /**
664 * Normalize regex pattern by adding delimiters if missing
665 *
666 * @param string $pattern The regex pattern
667 * @return string Pattern with delimiters
668 */
669 public static function normalize_regex_pattern($pattern)
670 {
671 if (empty($pattern)) {
672 return $pattern;
673 }
674
675 // Check if pattern starts with a common delimiter
676 $common_delimiters = ['/', '#', '~', '%', '@'];
677 $starts_with_delimiter = in_array($pattern[0], $common_delimiters);
678
679 if ($starts_with_delimiter) {
680 // Pattern starts with delimiter, check if it has proper structure
681 $first_char = $pattern[0];
682 $last_delimiter_pos = strrpos($pattern, $first_char);
683
684 // If there's a closing delimiter at a different position, pattern likely has delimiters
685 if ($last_delimiter_pos !== false && $last_delimiter_pos > 0) {
686 // Check if what comes after the last delimiter are valid modifiers
687 $after_last_delimiter = substr($pattern, $last_delimiter_pos + 1);
688 // Valid modifiers: i, m, s, x, A, D, S, U, X, J, u
689 if (empty($after_last_delimiter) || preg_match('/^[imsxADSUXJu]*$/', $after_last_delimiter)) {
690 // Pattern appears to have proper delimiters, return as-is
691 return $pattern;
692 }
693 }
694 }
695
696 // Pattern doesn't have delimiters or is malformed, add them
697 // Choose delimiter that's not in the pattern
698 $delimiters = ['/', '#', '~', '%', '@'];
699 $delimiter = '/';
700
701 foreach ($delimiters as $test_delimiter) {
702 if (strpos($pattern, $test_delimiter) === false) {
703 $delimiter = $test_delimiter;
704 break;
705 }
706 }
707
708 return $delimiter . $pattern . $delimiter;
709 }
710
711 /**
712 * Validate regex pattern
713 */
714 public function validate_regex_pattern($pattern)
715 {
716 if (empty($pattern)) {
717 return true; // Empty pattern is valid (not required)
718 }
719
720 // Normalize pattern (add delimiters if missing, fix malformed patterns)
721 $normalized_pattern = $this->normalize_regex_pattern($pattern);
722
723 // Test if the regex pattern is valid
724 # $test_result = @preg_match($pattern, '');
725 # return $test_result !== false;
726
727 // Use error handler to catch warnings from malformed patterns
728 $error_occurred = false;
729 set_error_handler(function() use (&$error_occurred) {
730 $error_occurred = true;
731 return true; // Suppress the error
732 }, E_WARNING);
733
734 $test_result = preg_match($normalized_pattern, '');
735
736 restore_error_handler();
737
738 // Return false if preg_match failed or if an error occurred
739 return $test_result !== false && !$error_occurred;
740 }
741
742 /**
743 * Sanitize URL for redirection
744 */
745 public function sanitize_redirect_url($url)
746 {
747 // Remove any dangerous protocols
748 $url = str_replace(['javascript:', 'data:', 'vbscript:'], '', $url);
749
750 // Ensure it's a valid URL
751 if (!filter_var($url, FILTER_VALIDATE_URL) && !str_starts_with($url, '/')) {
752 // If it's not a full URL and doesn't start with /, assume it's a relative path
753 $url = '/' . ltrim($url, '/');
754 }
755
756 return esc_url_raw($url);
757 }
758 }
759