*/ // Abort if this file is accessed directly. if (!defined('ABSPATH')) { exit; } if (!class_exists('Metasync_Redirection_Validator')) { require_once dirname(__FILE__) . '/class-metasync-redirection-validator.php'; } class Metasync_Redirection { private $db_redirection; private $common; private $importer; /** @var array|null Lazy-built exact-match lookup: normalized_path => row object */ private $exact_index = null; /** @var array|null Pattern-based rows (wildcard, regex, contain, start, end) */ private $pattern_index = null; public function __construct(&$db_redirection) { $this->db_redirection = $db_redirection; $this->common = new Metasync_Common(); # Load importer class require_once dirname(__FILE__) . '/class-metasync-redirection-importer.php'; $this->importer = new Metasync_Redirection_Importer($db_redirection); } /** * Build the redirect lookup index (lazy, once per request). * Exact-match sources go into a hashmap for O(1) lookup. * Pattern-based sources (wildcard, regex, contain, start, end) stay in a list. */ private function ensure_redirect_index() { if ($this->exact_index !== null) { return; } $this->exact_index = array(); $this->pattern_index = array(); $redirections = $this->db_redirection->getAllActiveRecords(); if (empty($redirections)) { return; } foreach ($redirections as $row) { $sources_from = !empty($row->sources_from) ? unserialize($row->sources_from, array('allowed_classes' => false)) : array(); $source_urls = is_array($sources_from) ? $sources_from : array(); $global_pattern_type = isset($row->pattern_type) ? $row->pattern_type : null; $is_pattern = false; foreach ($source_urls as $source_key => $source_value) { // Legacy list-format rows store the URL as the VALUE under a // numeric key ('0' => '/old'); modern rows key by URL with the // pattern type as the value. Remap so the legacy source lands // in the exact index instead of '/0'. if ((is_int($source_key) || ctype_digit((string) $source_key)) && is_string($source_value) && $source_value !== '') { $source_key = $source_value; } $pattern_type = in_array($source_value, array('exact', 'contain', 'start', 'end', 'wildcard', 'regex')) ? $source_value : ($global_pattern_type ? $global_pattern_type : 'exact'); if ($pattern_type === 'exact' && strpos((string) $source_key, '*') === false) { // Normalize: extract path from full URLs, ensure leading slash, strip trailing slash $norm = (string) $source_key; if (strpos($norm, 'http') === 0) { $parsed = parse_url($norm); $norm = isset($parsed['path']) ? $parsed['path'] : '/'; } if ($norm === '' || $norm[0] !== '/') { $norm = '/' . $norm; } $norm = rtrim($norm, '/') ?: '/'; $this->exact_index[$norm] = $row; } else { $is_pattern = true; } } if ($is_pattern) { $this->pattern_index[] = $row; } } } function contains($haystack, $needle, $caseSensitive = false) { return $caseSensitive ? (strpos($haystack, $needle) === FALSE ? FALSE : TRUE) : (stripos($haystack, $needle) === FALSE ? FALSE : TRUE); } public function create_admin_redirection_interface() { # Check if we should show import interface $request_data = metasync_sanitize_input_array($_REQUEST); if (isset($request_data['action']) && $request_data['action'] === 'import') { $this->show_import_interface(); return; } if (!class_exists('WP_List_Table')) { require_once ABSPATH . 'wp-admin/includes/class-wp-list-table.php'; } require dirname(__FILE__, 2) . '/redirections/class-metasync-redirection-list-table.php'; $MetasyncRedirection = new Metasync_Redirection_List_Table(); $MetasyncRedirection->setDatabaseResource($this->db_redirection); $MetasyncRedirection->prepare_items(); // Include the view markup. include dirname(__FILE__, 2) . '/views/metasync-redirection.php'; } /** * Show import interface */ public function show_import_interface() { $importer = $this->importer; include dirname(__FILE__, 2) . '/views/metasync-import-redirections.php'; } /** * Handle AJAX import request */ public function handle_import_ajax() { # Verify nonce check_ajax_referer('metasync_import_redirections', 'nonce'); # Check user capabilities if (!Metasync::current_user_has_plugin_access()) { wp_send_json_error(['message' => 'Insufficient permissions.']); return; } $plugin = isset($_POST['plugin']) ? sanitize_text_field($_POST['plugin']) : ''; if (empty($plugin)) { wp_send_json_error(['message' => 'No plugin specified.']); return; } # CSV is a file upload, not a plugin, so it takes its own path with # upload validation before the importer sees the temp file. if ($plugin === 'csv') { $this->handle_csv_import_ajax(); return; } try { # Perform import $result = $this->importer->import_from_plugin($plugin); if ($result['success']) { wp_send_json_success($result); } else { wp_send_json_error($result); } } catch (Exception $e) { wp_send_json_error([ 'message' => 'Import failed. Please try again or contact support.', 'imported' => 0, 'skipped' => 0 ]); } } /** * Handle the AJAX CSV import: validate the upload, then hand the temp * file to the importer. The readme has advertised CSV import since 2.5.x; * this is the first implementation of it. */ private function handle_csv_import_ajax() { $file = isset($_FILES['csv_file']) && is_array($_FILES['csv_file']) ? $_FILES['csv_file'] : []; // wp_send_json_error() ends the request, so no return is needed (or // reachable) after any of these guards. if (empty($file['tmp_name']) || (isset($file['error']) && (int) $file['error'] !== UPLOAD_ERR_OK)) { wp_send_json_error(['message' => 'Upload failed. Please choose a .csv file and try again.']); } if (!is_uploaded_file($file['tmp_name'])) { wp_send_json_error(['message' => 'Invalid upload.']); } if (!preg_match('/\.csv$/i', (string) $file['name'])) { wp_send_json_error(['message' => 'Only .csv files are supported.']); } if ((int) $file['size'] > 2 * MB_IN_BYTES) { wp_send_json_error(['message' => 'The CSV file is too large. Maximum size is 2 MB.']); } $result = $this->importer->import_csv_file($file['tmp_name']); if (!empty($result['success'])) { wp_send_json_success($result); } else { wp_send_json_error($result); } } public function get_current_page_url() { $server_data = metasync_sanitize_input_array($_SERVER); $link = '://' . $server_data['HTTP_HOST'] . $server_data['REQUEST_URI']; $link = (is_ssl() ? 'https' : 'http') . $link; return sanitize_url($link); } public function source_url_redirection(object $row, string $uri, string $incoming_query = '') { // Redirection feature switched off: rows owned by the per-post Redirection // meta box must not fire. Returning false rather than aborting means the // caller carries on evaluating the remaining rules, so rules added by hand // on the Redirections screen keep working exactly as before. if (Metasync_Feature_Flags::is_disabled(Metasync_Feature_Flags::REDIRECTION) && class_exists('Metasync_Post_Meta_Settings') && Metasync_Post_Meta_Settings::owns_redirect_row($row)) { return false; } // Optimize: unserialize only once $sources_from = unserialize($row->sources_from, ['allowed_classes' => false]); // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.serialize_unserialize $source_urls = is_array($sources_from) ? $sources_from : []; $global_pattern_type = isset($row->pattern_type) ? $row->pattern_type : null; $regex_pattern = isset($row->regex_pattern) ? $row->regex_pattern : null; foreach ($source_urls as $source_key => $source_value) { $match_found = false; $captured_path = ''; // Store captured path for wildcard replacement // Ensure source_key is always a string (handles numeric array indexes) $source_key = (string) $source_key; // Legacy list-format rows store the URL as the VALUE under a // numeric key ('0' => '/old'); remap so matching uses the URL. if (ctype_digit($source_key) && is_string($source_value) && $source_value !== '') { $source_key = $source_value; } // Determine pattern type: use source value if it's a valid pattern, otherwise use global pattern_type $pattern_type = in_array($source_value, ['exact', 'contain', 'start', 'end', 'wildcard', 'regex']) ? $source_value : ($global_pattern_type ? $global_pattern_type : 'exact'); // Normalize both URI and source for comparison $normalized_uri = $uri; $normalized_source = $source_key; // If source is a full URL, extract just the path part if (strpos($source_key, 'http') === 0) { $parsed_url = parse_url($source_key); $normalized_source = $parsed_url['path'] ?? ''; } # Ensure both have leading slashes for proper comparison # This handles cases where database stores URLs with or without leading slash # Convert to string to handle cases where source_key might be an integer $normalized_source = (string) $normalized_source; if (!empty($normalized_source) && $normalized_source[0] !== '/') { $normalized_source = '/' . $normalized_source; } if (!empty($normalized_uri) && $normalized_uri[0] !== '/') { $normalized_uri = '/' . $normalized_uri; } // Normalize trailing slashes so /path and /path/ match equivalently $normalized_source = rtrim($normalized_source, '/') ?: '/'; $normalized_uri = rtrim($normalized_uri, '/') ?: '/'; // Keep full URI path for matching (don't strip leading slash) // This allows matching against full URLs like /wordpress/index.php/category/article/* // Handle regex patterns if ($pattern_type === 'regex' && $regex_pattern) { // Validate regex pattern before using it if (!$this->validate_regex_pattern($regex_pattern)) { // Skip invalid regex patterns to prevent errors continue; } # Normalize pattern (add delimiters if missing) $normalized_pattern = $this->normalize_regex_pattern($regex_pattern); $matches = []; // Suppress warnings for invalid regex and check result # $result = @preg_match($regex_pattern, $normalized_uri, $matches); $result = @preg_match($normalized_pattern, $normalized_uri, $matches); if ($result === 1) { $match_found = true; // Store captured groups for replacement if (isset($matches[1])) { $captured_path = $matches[1]; } } // If $result === false, regex is invalid - skip silently } else { // Check if source has wildcard $has_wildcard = strpos($normalized_source, '*') !== false; if ($has_wildcard) { // Handle wildcard pattern $match_result = $this->match_wildcard($normalized_source, $normalized_uri); if ($match_result !== false) { $match_found = true; $captured_path = $match_result; } } else { // Handle legacy pattern matching (non-wildcard) switch ($source_value) { case 'exact': if ($normalized_source === $normalized_uri) { $match_found = true; } break; case 'contain': if ($this->contains($normalized_uri, $normalized_source)) { $match_found = true; } break; case 'start': if (str_starts_with($normalized_uri, $normalized_source)) { $match_found = true; } break; case 'end': if (str_ends_with($normalized_uri, $normalized_source)) { $match_found = true; } break; default: // Handle new pattern_type field switch ($pattern_type) { case 'exact': if ($normalized_source === $normalized_uri) { $match_found = true; } break; case 'contain': if ($this->contains($normalized_uri, $normalized_source)) { $match_found = true; } break; case 'start': if (str_starts_with($normalized_uri, $normalized_source)) { $match_found = true; } break; case 'end': if (str_ends_with($normalized_uri, $normalized_source)) { $match_found = true; } break; case 'wildcard': $match_result = $this->match_wildcard($normalized_source, $normalized_uri); if ($match_result !== false) { $match_found = true; $captured_path = $match_result; } break; } break; } } } if ($match_found) { $this->db_redirection->update_counter($row); if ($row->http_code === '410') { status_header(410); die; } if ($row->http_code === '451') { status_header(451, 'Unavailable For Legal Reasons'); die; } if ($row->url_redirect_to) { // Replace wildcards or $1 placeholders in destination URL $destination = $this->process_destination_url($row->url_redirect_to, $captured_path); $destination = $this->append_query_string($destination, $incoming_query); $is_exact = isset($row->pattern_type) && $row->pattern_type === 'exact'; if (get_option('metasync_allow_external_redirects', 0) && $is_exact) { $destination = esc_url_raw($destination); if (empty($destination)) { $destination = home_url(); } wp_redirect($destination, $row->http_code); } else { // Stored destinations can carry backslashes (imports, // older rows). Browsers read them as path separators, so // '/\evil.com' would slip past wp_validate_redirect and // still navigate off-site. Normalize first, and bounce // anything still syntactically evasive to the home page. $checked = Metasync_Redirection_Validator::normalize_destination($destination); if (!Metasync_Redirection_Validator::is_safe_destination_syntax($checked)) { $checked = home_url(); } wp_redirect(wp_validate_redirect($checked, home_url()), $row->http_code); } die; } // Match found and processed, return true to stop checking other rules return true; } } // No match found return false; } /** * Resolve a URL through the redirect table to its final destination (follows redirect chains). * Used by OTTO and other backend processing so the final canonical URL is used before 404 checks. * * @param string $url Full URL (e.g. https://example.com/old-page) * @param int $max_hops Maximum redirect hops to follow (default 10, prevents infinite loops) * @return string Final destination URL, or original $url if no redirect matches */ public function resolve_url_to_final_destination($url, $max_hops = 10) { if (empty($url) || !is_string($url)) { return $url; } $parsed = parse_url($url); $scheme = isset($parsed['scheme']) ? $parsed['scheme'] : 'https'; $host = isset($parsed['host']) ? $parsed['host'] : ''; $base = $scheme . '://' . $host; // Track the query separately from the path: sources match on the path // only, and each destination may carry its own query. Folding the // query into $uri (as this used to) made '/old?x=1' miss an '/old' // rule and swallowed destination queries along the chain. $uri = isset($parsed['path']) ? $parsed['path'] : '/'; $query = isset($parsed['query']) ? $parsed['query'] : ''; $seen = array(); for ($i = 0; $i < $max_hops; $i++) { $uri_key = $query === '' ? $uri : $uri . '?' . $query; if (isset($seen[$uri_key])) { break; // cycle detected } $seen[$uri_key] = true; $dest = $this->get_redirect_destination_for_uri($uri); if ($dest === null) { break; } $dest = $this->append_query_string($dest, $query); if (strpos($dest, 'http') === 0) { $url = $dest; $parsed = parse_url($url); $scheme = isset($parsed['scheme']) ? $parsed['scheme'] : 'https'; $host = isset($parsed['host']) ? $parsed['host'] : ''; $base = $scheme . '://' . $host; $uri = isset($parsed['path']) ? $parsed['path'] : '/'; $query = isset($parsed['query']) ? $parsed['query'] : ''; } else { $dest_parts = explode('?', $dest, 2); $uri = (isset($dest_parts[0][0]) && $dest_parts[0][0] === '/') ? $dest_parts[0] : '/' . $dest_parts[0]; $query = isset($dest_parts[1]) ? $dest_parts[1] : ''; $url = $base . $uri . ($query === '' ? '' : '?' . $query); } } return $url; } /** * Get redirect destination for a URI without redirecting (no wp_redirect, no counter update). * Returns the destination URL/path if this URI matches a redirect source, else null. * Used by resolve_url_to_final_destination. 410/451 are treated as "no destination". * * @param string $uri URI path (and optional query), e.g. /old-page or /old?x=1 * @return string|null Destination URL or path, or null if no match */ private function get_redirect_destination_for_uri($uri) { // Defensive: callers may pass a path?query form; sources match on the // path only, exactly like the live template-redirect path. $qpos = strpos((string) $uri, '?'); if ($qpos !== false) { $uri = substr($uri, 0, $qpos); } $redirections = $this->db_redirection->getAllActiveRecords(); if (empty($redirections)) { return null; } foreach ($redirections as $row) { if (isset($row->http_code) && in_array((int) $row->http_code, array(410, 451), true)) { continue; // Gone / Unavailable – no destination to follow } if (empty($row->url_redirect_to)) { continue; } $dest = $this->get_destination_for_row_and_uri($row, $uri); if ($dest !== null) { return $dest; } } return null; } /** * Determine whether creating a redirect from $source to $destination would * produce a redirect loop or chain longer than the configured hop budget. * * Read-only: no DB writes, no side effects. Reuses * get_redirect_destination_for_uri() so chain traversal matches the read path. * * @param string $source Source URL or path being created * @param string $destination Destination URL or path being created * @param array|null $chain Out param populated with the visited URI chain * @param int|null $max_hops Optional hop budget; defaults to filter `metasync_redirect_loop_max_hops` (5) * @return bool True if a loop or budget overrun is detected */ public function would_create_loop($source, $destination, &$chain = null, $max_hops = null) { if ($max_hops === null) { $max_hops = (int) apply_filters('metasync_redirect_loop_max_hops', 5); } if ($max_hops < 1) { $max_hops = 1; } $source_uri = $this->normalize_uri_path($source); $current = $this->normalize_uri_path($destination); $chain = array($source_uri, $current); // Trivial direct loop: redirect points back at itself if ($current === $source_uri) { return true; } $seen = array(); for ($i = 0; $i < $max_hops; $i++) { if (isset($seen[$current])) { // Pre-existing cycle that does not involve the new source — not our concern return false; } $seen[$current] = true; // Try matching with and without trailing slash to handle inconsistent storage $dest = $this->get_redirect_destination_for_uri($current); if ($dest === null) { $alt = (substr($current, -1) === '/') ? rtrim($current, '/') : $current . '/'; if ($alt !== '' && $alt !== $current) { $dest = $this->get_redirect_destination_for_uri($alt); } } if ($dest === null) { return false; } $current = $this->normalize_uri_path($dest); $chain[] = $current; if ($current === $source_uri) { return true; } } // Hop budget exhausted without resolving — treat as a loop-equivalent warning return true; } /** * Validate that a redirect would not create a loop. * * @param string $source Source URL or path * @param string $destination Destination URL or path * @return string|null Null if no loop, error message string if loop detected */ public function validate_no_loop($source, $destination) { $chain = []; if ($this->would_create_loop($source, $destination, $chain)) { return 'Redirect would create a loop: ' . implode(' → ', $chain); } return null; } /** * Lightweight, non-blocking check that a redirect destination appears to resolve. * * Reuses the terminal dead-end logic from check_redirect_health(): internal * destinations are matched against url_to_postid(); external destinations are * only probed (wp_remote_head) when off-site redirects are explicitly allowed. * Returns a human-readable warning string when the destination looks * unreachable, or null when it resolves (or cannot be meaningfully checked). * * @param string $destination Destination URL or path. * @param int $http_code Redirect HTTP code (410/451 are skipped). * @param string $pattern_type Source pattern type ('regex' is skipped). * @return string|null Warning message, or null if the destination resolves / is unchecked. */ public function destination_resolves_warning($destination, $http_code = 301, $pattern_type = 'exact') { // 410/451 redirects intentionally have no live destination. if (in_array((int) $http_code, array(410, 451), true)) { return null; } // Regex sources have no single concrete destination path to resolve. if ($pattern_type === 'regex') { return null; } if (!is_string($destination) || trim($destination) === '') { return null; } // Determine if the destination is external by checking for a host component // that differs from the local site. Relative paths (e.g. /about) have no host // and are always internal. $is_external = false; $parsed_dest = parse_url($destination); if (!empty($parsed_dest['host'])) { $site_host = parse_url(site_url(), PHP_URL_HOST); $is_external = (strcasecmp($parsed_dest['host'], $site_host) !== 0); } if ($is_external) { // Only probe external targets when off-site redirects are explicitly enabled, // and only with a short, single-hop HEAD request to keep the save lightweight. // 3xx responses are intentionally not flagged — the destination itself is // reachable even if it redirects further. if (get_option('metasync_allow_external_redirects')) { $response = wp_remote_head($destination, array('timeout' => 5, 'redirection' => 0)); if (is_wp_error($response)) { return 'Destination URL appears unreachable — the redirect may lead to an error.'; } $code = (int) wp_remote_retrieve_response_code($response); if ($code >= 400) { return 'Destination URL returned an error status (' . $code . ') — the redirect may lead to a broken page.'; } } return null; } // Internal destination: confirm it maps to a known page on this site. $path = $this->normalize_uri_path($destination); $post_id = url_to_postid(site_url($path)); if ($post_id === 0) { $post_id = url_to_postid(site_url($path . '/')); } if ($post_id === 0) { return 'Destination URL does not match a known page on this site — the redirect may lead to a 404.'; } return null; } /** * Normalise a URL or path to a leading-slash URI path used for chain comparison. * * @param string $url * @return string */ private function normalize_uri_path($url) { if (!is_string($url) || $url === '') { return '/'; } if (strpos($url, 'http') === 0) { $parsed = parse_url($url); $path = isset($parsed['path']) ? $parsed['path'] : '/'; } else { $path = $url; } if ($path === '' || $path[0] !== '/') { $path = '/' . $path; } $path = rtrim($path, '/') ?: '/'; return $path; } /** * Check health of one or all active redirects. * * Returns per-redirect diagnostics: loop, chain_too_long, dead_end, or ok. * Uses a prebuilt lookup index for O(1) exact-match chain walking instead * of re-scanning all records per hop. * * @param int|null $redirect_id Optional single redirect ID to check. * @param int $max_hops Hops beyond which a chain is flagged (default 3). * @return array Array of health result objects. */ public function check_redirect_health($redirect_id = null, $max_hops = 3) { // Preload all active records once and build a lookup index $all_records = $this->db_redirection->getAllActiveRecords(); $exact_map = array(); // normalized_path => destination (O(1) lookup) $pattern_rows = array(); // non-exact rows requiring linear scan foreach ($all_records as $row) { if (isset($row->http_code) && in_array((int) $row->http_code, array(410, 451), true)) { continue; } if (empty($row->url_redirect_to)) { continue; } $sources_from = !empty($row->sources_from) ? unserialize($row->sources_from, array('allowed_classes' => false)) : array(); $source_urls = is_array($sources_from) ? $sources_from : array(); $global_pattern_type = isset($row->pattern_type) ? $row->pattern_type : null; foreach ($source_urls as $source_key => $source_value) { // Legacy list-format rows store the URL as the VALUE under a // numeric key ('0' => '/old'); remap so health checks match // the URL rather than a bogus '/0'. if ((is_int($source_key) || ctype_digit((string) $source_key)) && is_string($source_value) && $source_value !== '') { $source_key = $source_value; } $pattern_type = in_array($source_value, array('exact', 'contain', 'start', 'end', 'wildcard', 'regex')) ? $source_value : ($global_pattern_type ? $global_pattern_type : 'exact'); if ($pattern_type === 'exact' && strpos((string) $source_key, '*') === false) { $norm = $this->normalize_uri_path($source_key); $exact_map[$norm] = $row->url_redirect_to; } else { $pattern_rows[] = $row; break; // row already added, no need to check other sources } } } // Determine which records to check if ($redirect_id !== null) { $record = $this->db_redirection->find((int) $redirect_id); $records = $record ? array($record) : array(); } else { $records = $all_records; } $results = array(); foreach ($records as $row) { $id = isset($row->id) ? (int) $row->id : 0; $destination = isset($row->url_redirect_to) ? $row->url_redirect_to : ''; $http_code = isset($row->http_code) ? (int) $row->http_code : 301; // Extract first source path for display $sources_from = !empty($row->sources_from) ? unserialize($row->sources_from, array('allowed_classes' => false)) : array(); $source_keys = is_array($sources_from) ? array_keys($sources_from) : array(); $source = !empty($source_keys) ? $source_keys[0] : ''; $source_path = $this->normalize_uri_path($source); // 410/451 have no destination — always ok if (in_array($http_code, array(410, 451), true)) { $results[] = array( 'id' => $id, 'source' => $source_path, 'destination' => $destination, 'final_destination' => null, 'chain_length' => 0, 'chain' => array($source_path), 'status' => 'ok', ); continue; } // Walk chain using the prebuilt index $current = $this->normalize_uri_path($destination); $chain = array($source_path, $current); $seen = array(); $is_loop = false; $hard_limit = 20; for ($i = 0; $i < $hard_limit; $i++) { if (isset($seen[$current])) { $is_loop = true; break; } $seen[$current] = true; // O(1) exact-match lookup first $dest = isset($exact_map[$current]) ? $exact_map[$current] : null; if ($dest === null) { // Try trailing slash variant $alt = (substr($current, -1) === '/') ? rtrim($current, '/') : $current . '/'; if ($alt !== '' && $alt !== $current) { $dest = isset($exact_map[$alt]) ? $exact_map[$alt] : null; } } // Fallback: scan pattern-based rows only (small set) if ($dest === null && !empty($pattern_rows)) { foreach ($pattern_rows as $prow) { $dest = $this->get_destination_for_row_and_uri($prow, $current); if ($dest !== null) { break; } // Try trailing slash alt for patterns too if (isset($alt)) { $dest = $this->get_destination_for_row_and_uri($prow, $alt); if ($dest !== null) { break; } } } } if ($dest === null) { break; } $current = $this->normalize_uri_path($dest); $chain[] = $current; } // Classify: loop > chain_too_long > dead_end > ok $chain_hops = count($chain) - 1; if ($is_loop) { $status = 'loop'; } elseif ($chain_hops > $max_hops) { $status = 'chain_too_long'; } else { // Check if terminal destination resolves to a real page $is_dead = false; if (function_exists('url_to_postid')) { $post_id = url_to_postid(site_url($current)); if ($post_id === 0) { $post_id = url_to_postid(site_url($current . '/')); } $is_dead = ($post_id === 0); // url_to_postid() knows only posts and pages — a taxonomy // archive (category/tag/custom term URL) returns 0 and a // healthy destination was flagged dead. Resolve those too. if ($is_dead && $this->uri_resolves_to_term_archive($current)) { $is_dead = false; } } $status = $is_dead ? 'dead_end' : 'ok'; } $results[] = array( 'id' => $id, 'source' => $source_path, 'destination' => $this->normalize_uri_path($destination), 'final_destination' => $current, 'chain_length' => $chain_hops, 'chain' => $chain, 'status' => $status, ); } return $results; } /** * Whether a URI path resolves to a taxonomy archive (category, tag, or a * custom taxonomy term). url_to_postid() returns 0 for these, so without * this check the health check reports them as dead ends. * * @param string $path URI path, e.g. /category/news/. * @return bool True when the path maps to an existing term archive. */ private function uri_resolves_to_term_archive($path) { if (!function_exists('get_term_by') || !function_exists('get_taxonomies')) { return false; } $path = trim((string) $path, '/'); if ($path === '') { return false; } $segments = explode('/', $path); $first = array_shift($segments); if ($first === '' || empty($segments)) { return false; } // Built-in taxonomies honor the category_base/tag_base options; the // defaults are 'category' and 'tag'. $category_base = trim((string) get_option('category_base'), '/'); $tag_base = trim((string) get_option('tag_base'), '/'); $bases = array( $category_base !== '' ? $category_base : 'category' => 'category', $tag_base !== '' ? $tag_base : 'tag' => 'post_tag', ); $taxonomy = isset($bases[$first]) ? $bases[$first] : null; // Custom taxonomies carry their own rewrite base (defaults to the // taxonomy name). if ($taxonomy === null) { foreach (get_taxonomies(array('publicly_queryable' => true, '_builtin' => false), 'objects') as $tax_object) { $base = isset($tax_object->rewrite['slug']) ? trim((string) $tax_object->rewrite['slug'], '/') : $tax_object->name; if ($base === $first) { $taxonomy = $tax_object->name; break; } } } if ($taxonomy === null) { return false; } // get_term_by() returns WP_Term|false for a registered taxonomy — never // a WP_Error — so a false check is the whole story here. $term = get_term_by('slug', implode('/', $segments), $taxonomy); if ($term === false) { // Hierarchical terms live at parent/child paths, and the child's // own slug is only the last segment. $term = get_term_by('slug', end($segments), $taxonomy); } return ($term !== false); } /** * Handle AJAX health check request from admin UI. */ public function handle_health_check_ajax() { check_ajax_referer('metasync_redirect_health_check', 'nonce'); if (!Metasync::current_user_has_plugin_access()) { wp_send_json_error(array('message' => 'Insufficient permissions.')); return; } $redirect_id = isset($_POST['redirect_id']) ? intval($_POST['redirect_id']) : null; if ($redirect_id === 0) { $redirect_id = null; } $results = $this->check_redirect_health($redirect_id); wp_send_json_success(array('results' => $results)); } /** * Get destination for a single row and URI if it matches. Same matching logic as source_url_redirection. * * @param object $row Redirect row * @param string $uri URI to match * @return string|null Destination URL/path or null */ private function get_destination_for_row_and_uri($row, $uri) { // Parse stored redirect sources (serialized: source path/URL => pattern type per source, or single list) $sources_from = unserialize($row->sources_from, ['allowed_classes' => false]); // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.serialize_unserialize $source_urls = is_array($sources_from) ? $sources_from : array(); $global_pattern_type = isset($row->pattern_type) ? $row->pattern_type : null; $regex_pattern = isset($row->regex_pattern) ? $row->regex_pattern : null; foreach ($source_urls as $source_key => $source_value) { $match_found = false; $captured_path = ''; // Used for wildcard/regex replacement in destination (e.g. * or $1) // Legacy list-format rows store the URL as the VALUE under a // numeric key ('0' => '/old'); remap so matching uses the URL. if ((is_int($source_key) || ctype_digit((string) $source_key)) && is_string($source_value) && $source_value !== '') { $source_key = $source_value; } // Resolve pattern type: per-source value (exact, contain, start, end, wildcard, regex) or row-level default $pattern_type = in_array($source_value, array('exact', 'contain', 'start', 'end', 'wildcard', 'regex')) ? $source_value : ($global_pattern_type ? $global_pattern_type : 'exact'); $normalized_uri = $uri; $normalized_source = $source_key; // If source is a full URL, use only the path for matching (consistent with front-end redirect behavior) if (strpos($source_key, 'http') === 0) { $parsed_src = parse_url($source_key); $normalized_source = isset($parsed_src['path']) ? $parsed_src['path'] : ''; } // Ensure leading slash for reliable path comparison if (!empty($normalized_source) && $normalized_source[0] !== '/') { $normalized_source = '/' . $normalized_source; } if (!empty($normalized_uri) && $normalized_uri[0] !== '/') { $normalized_uri = '/' . $normalized_uri; } // Normalize trailing slashes so /path and /path/ match equivalently $normalized_source = rtrim($normalized_source, '/') ?: '/'; $normalized_uri = rtrim($normalized_uri, '/') ?: '/'; // --- Matching: regex, wildcard, or legacy pattern types --- if ($pattern_type === 'regex' && $regex_pattern) { // Regex: validate and normalize pattern, then match; capture group 1 for $1 in destination if (!$this->validate_regex_pattern($regex_pattern)) { continue; } $normalized_pattern = $this->normalize_regex_pattern($regex_pattern); $matches = array(); if (@preg_match($normalized_pattern, $normalized_uri, $matches) === 1) { $match_found = true; $captured_path = isset($matches[1]) ? $matches[1] : ''; } } else { // Non-regex: check for * in source (wildcard) or use exact/contain/start/end $has_wildcard = strpos($normalized_source, '*') !== false; if ($has_wildcard) { // Wildcard: e.g. /old/* matches /old/page and captures "page" for destination $match_result = $this->match_wildcard($normalized_source, $normalized_uri); if ($match_result !== false) { $match_found = true; $captured_path = $match_result; } } else { // Legacy pattern: source_value can be the pattern type when key is the path switch ($source_value) { case 'exact': if ($normalized_source === $normalized_uri) { $match_found = true; } break; case 'contain': if ($this->contains($normalized_uri, $normalized_source)) { $match_found = true; } break; case 'start': if (str_starts_with($normalized_uri, $normalized_source)) { $match_found = true; } break; case 'end': if (str_ends_with($normalized_uri, $normalized_source)) { $match_found = true; } break; default: // Fallback to row-level pattern_type when source_value is not a known type switch ($pattern_type) { case 'exact': if ($normalized_source === $normalized_uri) { $match_found = true; } break; case 'contain': if ($this->contains($normalized_uri, $normalized_source)) { $match_found = true; } break; case 'start': if (str_starts_with($normalized_uri, $normalized_source)) { $match_found = true; } break; case 'end': if (str_ends_with($normalized_uri, $normalized_source)) { $match_found = true; } break; case 'wildcard': $match_result = $this->match_wildcard($normalized_source, $normalized_uri); if ($match_result !== false) { $match_found = true; $captured_path = $match_result; } break; } break; } } } // First match wins: return destination with * / $1 replaced by captured_path if ($match_found && !empty($row->url_redirect_to)) { return $this->process_destination_url($row->url_redirect_to, $captured_path); } } return null; } /** * Match wildcard pattern against URI * * @param string $pattern Pattern with * wildcard * @param string $uri URI to match against * @return string|false Returns captured path on match, false otherwise */ private function match_wildcard($pattern, $uri) { // Sanitize: escape the entire pattern for safe regex use, then restore wildcards $escaped = preg_quote($pattern, '/'); // preg_quote escapes *, so replace the escaped \* back with a capturing group $regex = '/^' . str_replace('\\*', '(.*)', $escaped) . '$/'; $matches = []; $result = @preg_match($regex, $uri, $matches); if ($result && $result !== false) { // Return the captured path (first capturing group) return isset($matches[1]) ? $matches[1] : ''; } return false; } /** * Process destination URL with captured path * * @param string $destination Destination URL (may contain * or $1) * @param string $captured_path Captured path from source * @return string Processed destination URL */ private function process_destination_url($destination, $captured_path) { // Replace * wildcard with captured path if (strpos($destination, '*') !== false) { $destination = str_replace('*', $captured_path ?? '', $destination); } // Replace $1 placeholder with captured path (for regex compatibility) if (strpos($destination, '$1') !== false) { $destination = str_replace('$1', $captured_path ?? '', $destination); } return $destination; } /** * Preserve query parameters from the incoming request when redirecting. * * Redirect matching intentionally ignores the query string, but the * browser-visible redirect must not silently discard it. Keep the stored * destination query intact and append the incoming query verbatim so * encoded values and repeated parameters survive unchanged. * * @param string $destination Destination URL or path. * @param string $query Incoming query without the leading '?'. * @return string Destination with the incoming query appended. */ private function append_query_string($destination, $query) { $destination = (string) $destination; $query = ltrim((string) $query, '?'); if ($query === '') { return $destination; } $fragment = ''; $fragment_pos = strpos($destination, '#'); if ($fragment_pos !== false) { $fragment = substr($destination, $fragment_pos); $destination = substr($destination, 0, $fragment_pos); } $separator = strpos($destination, '?') === false ? '?' : '&'; return $destination . $separator . $query . $fragment; } /** * Handle template redirect for frontend redirections */ public function handle_template_redirect() { // Only process on frontend if (is_admin()) { return; } // Get current URI $request_uri = isset($_SERVER['REQUEST_URI']) ? (string) $_SERVER['REQUEST_URI'] : '/'; // Keep the raw query for the redirect destination. It is deliberately // excluded from matching below, but must survive the browser redirect. $query_pos = strpos($request_uri, '?'); $incoming_query = $query_pos === false ? '' : substr($request_uri, $query_pos + 1); // Remove query string for matching $uri = $query_pos === false ? $request_uri : substr($request_uri, 0, $query_pos); // REQUEST_URI arrives percent-encoded while stored sources are raw // UTF-8, so a stored '/日本' could never match '/%E6%97%A5%E6%9C%AC'. // Decode before matching; the stored destination still drives output. $decoded_uri = rawurldecode((string) $uri); if ($decoded_uri !== '') { $uri = $decoded_uri; } // Build the lookup index (lazy, once per request) $this->ensure_redirect_index(); // O(1) exact-match lookup first $normalized_uri = $uri; if (!empty($normalized_uri) && $normalized_uri[0] !== '/') { $normalized_uri = '/' . $normalized_uri; } $normalized_uri = rtrim($normalized_uri, '/') ?: '/'; if (isset($this->exact_index[$normalized_uri])) { if ($this->source_url_redirection($this->exact_index[$normalized_uri], $uri, $incoming_query)) { return; } } // Fallback: scan only pattern-based rows (wildcard, regex, contain, start, end) foreach ($this->pattern_index as $redirection) { if ($this->source_url_redirection($redirection, $uri, $incoming_query)) { return; } } } /** * Prevent WordPress from redirecting to draft posts * This stops WordPress from auto-redirecting URLs to ?p=POST_ID for draft posts * * @param string $redirect_url The redirect URL * @param string $requested_url The requested URL * @return string|false The redirect URL or false to cancel redirect */ public function prevent_draft_post_redirects($redirect_url, $requested_url) { # If no redirect is happening, return as-is if (empty($redirect_url)) { return $redirect_url; } # Check if WordPress is trying to redirect to a ?p= or ?page_id= URL if (strpos($redirect_url, '?p=') !== false || strpos($redirect_url, '?page_id=') !== false) { # Extract the post ID $post_id = null; if (preg_match('/[?&]p=(\d+)/', $redirect_url, $matches)) { $post_id = intval($matches[1]); } elseif (preg_match('/[?&]page_id=(\d+)/', $redirect_url, $matches)) { $post_id = intval($matches[1]); } # If we found a post ID, check if it's a draft if ($post_id) { $post = get_post($post_id); # If post is draft, auto-draft, pending, or private, prevent the redirect if ($post && in_array($post->post_status, ['draft', 'auto-draft', 'pending', 'private'])) { # Return false to cancel the redirect and show 404 instead return false; } } } # Allow the redirect for published posts return $redirect_url; } /** * Prevent wp_old_slug_redirect from redirecting to draft posts * * This uses WordPress's built-in 'old_slug_redirect_post_id' filter to selectively * block redirects ONLY to unpublished posts, while allowing redirects to published posts. * - It doesn't break existing WordPress functionality * - Published posts can still use old slug redirects (good for SEO) * - Only protects unpublished content from exposure * - Non-invasive and backwards compatible * * @param int $post_id The post ID that WordPress wants to redirect to * @return int|false The post ID to redirect to, or false to prevent redirect */ public function prevent_old_slug_redirect_to_drafts($post_id) { # If no post ID provided, don't redirect if (empty($post_id)) { return false; } # Get the post $post = get_post($post_id); # If post doesn't exist, don't redirect if (!$post) { return false; } // Check if this post is unpublished (draft, pending, private, auto-draft) if (in_array($post->post_status, ['draft', 'auto-draft', 'pending', 'private'])) { # Return false to prevent the redirect to unpublished content # This will cause WordPress to show 404 instead, protecting draft content return false; } # Allow redirect for published posts (preserves normal WordPress functionality) return $post_id; } /** * Normalize regex pattern by adding delimiters if missing * * @param string $pattern The regex pattern * @return string Pattern with delimiters */ public static function normalize_regex_pattern($pattern) { if (empty($pattern)) { return $pattern; } // Check if pattern starts with a common delimiter $common_delimiters = ['/', '#', '~', '%', '@']; $starts_with_delimiter = in_array($pattern[0], $common_delimiters); if ($starts_with_delimiter) { // Pattern starts with delimiter, check if it has proper structure $first_char = $pattern[0]; $last_delimiter_pos = strrpos($pattern, $first_char); // If there's a closing delimiter at a different position, pattern likely has delimiters if ($last_delimiter_pos !== false && $last_delimiter_pos > 0) { // Check if what comes after the last delimiter are valid modifiers $after_last_delimiter = substr($pattern, $last_delimiter_pos + 1); // Valid modifiers: i, m, s, x, A, D, S, U, X, J, u if (empty($after_last_delimiter) || preg_match('/^[imsxADSUXJu]*$/', $after_last_delimiter)) { // Pattern appears to have proper delimiters, return as-is return $pattern; } } } // Pattern doesn't have delimiters or is malformed, add them // Choose delimiter that's not in the pattern $delimiters = ['/', '#', '~', '%', '@']; $delimiter = '/'; foreach ($delimiters as $test_delimiter) { if (strpos($pattern, $test_delimiter) === false) { $delimiter = $test_delimiter; break; } } return $delimiter . $pattern . $delimiter; } /** * Validate regex pattern */ public function validate_regex_pattern($pattern) { if (empty($pattern)) { return true; // Empty pattern is valid (not required) } // Normalize pattern (add delimiters if missing, fix malformed patterns) $normalized_pattern = $this->normalize_regex_pattern($pattern); // Test if the regex pattern is valid # $test_result = @preg_match($pattern, ''); # return $test_result !== false; // Use error handler to catch warnings from malformed patterns $error_occurred = false; set_error_handler(function() use (&$error_occurred) { $error_occurred = true; return true; // Suppress the error }, E_WARNING); $test_result = preg_match($normalized_pattern, ''); restore_error_handler(); // Return false if preg_match failed or if an error occurred return $test_result !== false && !$error_occurred; } /** * Sanitize URL for redirection */ public function sanitize_redirect_url($url) { // Remove any dangerous protocols $url = str_replace(['javascript:', 'data:', 'vbscript:'], '', $url); // Ensure it's a valid URL if (!filter_var($url, FILTER_VALIDATE_URL) && !str_starts_with($url, '/')) { // If it's not a full URL and doesn't start with /, assume it's a relative path $url = '/' . ltrim($url, '/'); } return esc_url_raw($url); } }