| @@ -17,8 +17,12 @@ | ||
| 17 | 17 | if (!defined('ABSPATH')) { |
| 18 | 18 | exit; |
| 19 | 19 | } |
| 20 | 20 | |
| 21 | +if (!class_exists('Metasync_Redirection_Validator')) { | |
| 22 | + require_once dirname(__FILE__) . '/class-metasync-redirection-validator.php'; | |
| 23 | +} | |
| 24 | + | |
| 21 | 25 | class Metasync_Redirection |
| 22 | 26 | { |
| 23 | 27 | |
| 24 | 28 | private $db_redirection; |
| @@ -67,8 +71,19 @@ | ||
| 67 | 71 | $global_pattern_type = isset($row->pattern_type) ? $row->pattern_type : null; |
| 68 | 72 | $is_pattern = false; |
| 69 | 73 | |
| 70 | 74 | foreach ($source_urls as $source_key => $source_value) { |
| 75 | + // Legacy list-format rows store the URL as the VALUE under a | |
| 76 | + // numeric key ('0' => '/old'); modern rows key by URL with the | |
| 77 | + // pattern type as the value. Remap so the legacy source lands | |
| 78 | + // in the exact index instead of '/0'. | |
| 79 | + if ((is_int($source_key) || ctype_digit((string) $source_key)) && is_string($source_value) && $source_value !== '') { | |
| 80 | + $source_key = $source_value; | |
| 81 | + } | |
| 82 | + // Rows saved before the input-unslash fix may still hold | |
| 83 | + // magic-quoted sources; heal at read time so they keep | |
| 84 | + // matching the unslashed request URI. | |
| 85 | + $source_key = wp_unslash((string) $source_key); | |
| 71 | 86 | $pattern_type = in_array($source_value, array('exact', 'contain', 'start', 'end', 'wildcard', 'regex')) |
| 72 | 87 | ? $source_value |
| 73 | 88 | : ($global_pattern_type ? $global_pattern_type : 'exact'); |
| 74 | 89 | |
| @@ -75,9 +90,9 @@ | ||
| 75 | 90 | if ($pattern_type === 'exact' && strpos((string) $source_key, '*') === false) { |
| 76 | 91 | // Normalize: extract path from full URLs, ensure leading slash, strip trailing slash |
| 77 | 92 | $norm = (string) $source_key; |
| 78 | 93 | if (strpos($norm, 'http') === 0) { |
| 79 | - $parsed = parse_url($norm); | |
| 94 | + $parsed = wp_parse_url($norm); | |
| 80 | 95 | $norm = isset($parsed['path']) ? $parsed['path'] : '/'; |
| 81 | 96 | } |
| 82 | 97 | if ($norm === '' || $norm[0] !== '/') { |
| 83 | 98 | $norm = '/' . $norm; |
| @@ -103,9 +118,9 @@ | ||
| 103 | 118 | |
| 104 | 119 | public function create_admin_redirection_interface() |
| 105 | 120 | { |
| 106 | 121 | # Check if we should show import interface |
| 107 | - $request_data = metasync_sanitize_input_array($_REQUEST); | |
| 122 | + $request_data = metasync_sanitize_input_array($_REQUEST); // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only view switch, the import verifies its own nonce | |
| 108 | 123 | if (isset($request_data['action']) && $request_data['action'] === 'import') { |
| 109 | 124 | $this->show_import_interface(); |
| 110 | 125 | return; |
| 111 | 126 | } |
| @@ -147,9 +162,9 @@ | ||
| 147 | 162 | wp_send_json_error(['message' => 'Insufficient permissions.']); |
| 148 | 163 | return; |
| 149 | 164 | } |
| 150 | 165 | |
| 151 | - $plugin = isset($_POST['plugin']) ? sanitize_text_field($_POST['plugin']) : ''; | |
| 166 | + $plugin = isset($_POST['plugin']) ? sanitize_text_field(wp_unslash($_POST['plugin'])) : ''; | |
| 152 | 167 | |
| 153 | 168 | if (empty($plugin)) { |
| 154 | 169 | wp_send_json_error(['message' => 'No plugin specified.']); |
| 155 | 170 | return; |
| @@ -154,8 +169,15 @@ | ||
| 154 | 169 | wp_send_json_error(['message' => 'No plugin specified.']); |
| 155 | 170 | return; |
| 156 | 171 | } |
| 157 | 172 | |
| 173 | + # CSV is a file upload, not a plugin, so it takes its own path with | |
| 174 | + # upload validation before the importer sees the temp file. | |
| 175 | + if ($plugin === 'csv') { | |
| 176 | + $this->handle_csv_import_ajax(); | |
| 177 | + return; | |
| 178 | + } | |
| 179 | + | |
| 158 | 180 | try { |
| 159 | 181 | # Perform import |
| 160 | 182 | $result = $this->importer->import_from_plugin($plugin); |
| 161 | 183 | |
| @@ -172,8 +194,44 @@ | ||
| 172 | 194 | ]); |
| 173 | 195 | } |
| 174 | 196 | } |
| 175 | 197 | |
| 198 | + /** | |
| 199 | + * Handle the AJAX CSV import: validate the upload, then hand the temp | |
| 200 | + * file to the importer. The readme has advertised CSV import since 2.5.x; | |
| 201 | + * this is the first implementation of it. | |
| 202 | + */ | |
| 203 | + private function handle_csv_import_ajax() | |
| 204 | + { | |
| 205 | + $file = isset($_FILES['csv_file']) && is_array($_FILES['csv_file']) ? $_FILES['csv_file'] : []; // phpcs:ignore WordPress.Security.NonceVerification.Missing -- private, caller verifies a nonce and capability first | |
| 206 | + | |
| 207 | + // wp_send_json_error() ends the request, so no return is needed (or | |
| 208 | + // reachable) after any of these guards. | |
| 209 | + if (empty($file['tmp_name']) || (isset($file['error']) && (int) $file['error'] !== UPLOAD_ERR_OK)) { | |
| 210 | + wp_send_json_error(['message' => 'Upload failed. Please choose a .csv file and try again.']); | |
| 211 | + } | |
| 212 | + | |
| 213 | + if (!is_uploaded_file($file['tmp_name'])) { | |
| 214 | + wp_send_json_error(['message' => 'Invalid upload.']); | |
| 215 | + } | |
| 216 | + | |
| 217 | + if (!preg_match('/\.csv$/i', (string) $file['name'])) { | |
| 218 | + wp_send_json_error(['message' => 'Only .csv files are supported.']); | |
| 219 | + } | |
| 220 | + | |
| 221 | + if ((int) $file['size'] > 2 * MB_IN_BYTES) { | |
| 222 | + wp_send_json_error(['message' => 'The CSV file is too large. Maximum size is 2 MB.']); | |
| 223 | + } | |
| 224 | + | |
| 225 | + $result = $this->importer->import_csv_file($file['tmp_name']); | |
| 226 | + | |
| 227 | + if (!empty($result['success'])) { | |
| 228 | + wp_send_json_success($result); | |
| 229 | + } else { | |
| 230 | + wp_send_json_error($result); | |
| 231 | + } | |
| 232 | + } | |
| 233 | + | |
| 176 | 234 | public function get_current_page_url() |
| 177 | 235 | { |
| 178 | 236 | $server_data = metasync_sanitize_input_array($_SERVER); |
| 179 | 237 | $link = '://' . $server_data['HTTP_HOST'] . $server_data['REQUEST_URI']; |
| @@ -180,10 +238,20 @@ | ||
| 180 | 238 | $link = (is_ssl() ? 'https' : 'http') . $link; |
| 181 | 239 | return sanitize_url($link); |
| 182 | 240 | } |
| 183 | 241 | |
| 184 | - public function source_url_redirection(object $row, string $uri) | |
| 242 | + public function source_url_redirection(object $row, string $uri, string $incoming_query = '') | |
| 185 | 243 | { |
| 244 | + // Redirection feature switched off: rows owned by the per-post Redirection | |
| 245 | + // meta box must not fire. Returning false rather than aborting means the | |
| 246 | + // caller carries on evaluating the remaining rules, so rules added by hand | |
| 247 | + // on the Redirections screen keep working exactly as before. | |
| 248 | + if (Metasync_Feature_Flags::is_disabled(Metasync_Feature_Flags::REDIRECTION) | |
| 249 | + && class_exists('Metasync_Post_Meta_Settings') | |
| 250 | + && Metasync_Post_Meta_Settings::owns_redirect_row($row)) { | |
| 251 | + return false; | |
| 252 | + } | |
| 253 | + | |
| 186 | 254 | // Optimize: unserialize only once |
| 187 | 255 | $sources_from = unserialize($row->sources_from, ['allowed_classes' => false]); // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.serialize_unserialize |
| 188 | 256 | $source_urls = is_array($sources_from) ? $sources_from : []; |
| 189 | 257 | $global_pattern_type = isset($row->pattern_type) ? $row->pattern_type : null; |
| @@ -195,8 +263,18 @@ | ||
| 195 | 263 | |
| 196 | 264 | // Ensure source_key is always a string (handles numeric array indexes) |
| 197 | 265 | $source_key = (string) $source_key; |
| 198 | 266 | |
| 267 | + // Legacy list-format rows store the URL as the VALUE under a | |
| 268 | + // numeric key ('0' => '/old'); remap so matching uses the URL. | |
| 269 | + if (ctype_digit($source_key) && is_string($source_value) && $source_value !== '') { | |
| 270 | + $source_key = $source_value; | |
| 271 | + } | |
| 272 | + | |
| 273 | + // Heal magic-quoted sources saved before the input-unslash fix so | |
| 274 | + // pattern matching sees the literal value the user typed. | |
| 275 | + $source_key = wp_unslash((string) $source_key); | |
| 276 | + | |
| 199 | 277 | // Determine pattern type: use source value if it's a valid pattern, otherwise use global pattern_type |
| 200 | 278 | $pattern_type = in_array($source_value, ['exact', 'contain', 'start', 'end', 'wildcard', 'regex']) |
| 201 | 279 | ? $source_value |
| 202 | 280 | : ($global_pattern_type ? $global_pattern_type : 'exact'); |
| @@ -206,9 +284,9 @@ | ||
| 206 | 284 | $normalized_source = $source_key; |
| 207 | 285 | |
| 208 | 286 | // If source is a full URL, extract just the path part |
| 209 | 287 | if (strpos($source_key, 'http') === 0) { |
| 210 | - $parsed_url = parse_url($source_key); | |
| 288 | + $parsed_url = wp_parse_url($source_key); | |
| 211 | 289 | $normalized_source = $parsed_url['path'] ?? ''; |
| 212 | 290 | } |
| 213 | 291 | |
| 214 | 292 | # Ensure both have leading slashes for proper comparison |
| @@ -337,8 +415,9 @@ | ||
| 337 | 415 | } |
| 338 | 416 | if ($row->url_redirect_to) { |
| 339 | 417 | // Replace wildcards or $1 placeholders in destination URL |
| 340 | 418 | $destination = $this->process_destination_url($row->url_redirect_to, $captured_path); |
| 419 | + $destination = $this->append_query_string($destination, $incoming_query); | |
| 341 | 420 | $is_exact = isset($row->pattern_type) && $row->pattern_type === 'exact'; |
| 342 | 421 | if (get_option('metasync_allow_external_redirects', 0) && $is_exact) { |
| 343 | 422 | $destination = esc_url_raw($destination); |
| 344 | 423 | if (empty($destination)) { |
| @@ -343,13 +422,29 @@ | ||
| 343 | 422 | $destination = esc_url_raw($destination); |
| 344 | 423 | if (empty($destination)) { |
| 345 | 424 | $destination = home_url(); |
| 346 | 425 | } |
| 347 | - wp_redirect($destination, $row->http_code); | |
| 426 | + // Same syntax hygiene as the default branch: the opt-in | |
| 427 | + // permits external hosts, never syntactic evasion, so a | |
| 428 | + // backslash trick like '/\evil.com' still bounces home. | |
| 429 | + $checked = Metasync_Redirection_Validator::normalize_destination($destination); | |
| 430 | + if (!Metasync_Redirection_Validator::is_safe_destination_syntax($checked)) { | |
| 431 | + $checked = home_url(); | |
| 432 | + } | |
| 433 | + $this->safe_redirect_to_destination($checked, $row->http_code); | |
| 348 | 434 | } else { |
| 349 | - wp_redirect(wp_validate_redirect($destination, home_url()), $row->http_code); | |
| 435 | + // Stored destinations can carry backslashes (imports, | |
| 436 | + // older rows). Browsers read them as path separators, so | |
| 437 | + // '/\evil.com' would slip past wp_validate_redirect and | |
| 438 | + // still navigate off-site. Normalize first, and bounce | |
| 439 | + // anything still syntactically evasive to the home page. | |
| 440 | + $checked = Metasync_Redirection_Validator::normalize_destination($destination); | |
| 441 | + if (!Metasync_Redirection_Validator::is_safe_destination_syntax($checked)) { | |
| 442 | + $checked = home_url(); | |
| 443 | + } | |
| 444 | + $this->safe_redirect_to_destination(wp_validate_redirect($checked, home_url()), $row->http_code); | |
| 350 | 445 | } |
| 351 | - die; | |
| 446 | + // Both branches terminate inside safe_redirect_to_destination(). | |
| 352 | 447 | } |
| 353 | 448 | // Match found and processed, return true to stop checking other rules |
| 354 | 449 | return true; |
| 355 | 450 | } |
| @@ -359,8 +454,49 @@ | ||
| 359 | 454 | return false; |
| 360 | 455 | } |
| 361 | 456 | |
| 362 | 457 | /** |
| 458 | + * Emit a redirect through wp_safe_redirect() and end execution. | |
| 459 | + * | |
| 460 | + * wp_safe_redirect() confines the Location header to hosts WordPress | |
| 461 | + * trusts (this site plus anything on the allowed_redirect_hosts | |
| 462 | + * allowlist). The one intentional external destination is an exact-match | |
| 463 | + * rule saved by an admin while the metasync_allow_external_redirects | |
| 464 | + * opt-in is enabled, so the parsed host of the already syntax-validated | |
| 465 | + * destination is allowlisted for this request only. Everything else — | |
| 466 | + * same-site destinations, relative paths — passes through untouched. | |
| 467 | + * | |
| 468 | + * @param string $destination Validated destination URL or path. | |
| 469 | + * @param string|int $http_code Redirect status code from the rule row. | |
| 470 | + * @return void Never returns; wp_safe_redirect() is followed by exit. | |
| 471 | + */ | |
| 472 | + private function safe_redirect_to_destination($destination, $http_code) | |
| 473 | + { | |
| 474 | + $host = wp_parse_url($destination, PHP_URL_HOST); | |
| 475 | + $grant_host = null; | |
| 476 | + if (is_string($host) && $host !== '') { | |
| 477 | + // Scope the temporary host grant to this one redirect. The filter | |
| 478 | + // runs while wp_safe_redirect() validates the Location header and | |
| 479 | + // is removed before exit: shutdown callbacks registered elsewhere | |
| 480 | + // still fire after exit, so the grant must not outlive the call. | |
| 481 | + $grant_host = static function ($allowed_hosts, $checked_host = '') use ($host) { | |
| 482 | + if (is_array($allowed_hosts)) { | |
| 483 | + $allowed_hosts[] = $host; | |
| 484 | + } | |
| 485 | + return $allowed_hosts; | |
| 486 | + }; | |
| 487 | + add_filter('allowed_redirect_hosts', $grant_host, 10, 2); | |
| 488 | + } | |
| 489 | + wp_safe_redirect($destination, $http_code); | |
| 490 | + if (null !== $grant_host) { | |
| 491 | + // Removal matches on priority + callback identity; core's | |
| 492 | + // remove_filter() takes no accepted-args parameter. | |
| 493 | + remove_filter('allowed_redirect_hosts', $grant_host, 10); | |
| 494 | + } | |
| 495 | + exit; | |
| 496 | + } | |
| 497 | + | |
| 498 | + /** | |
| 363 | 499 | * Resolve a URL through the redirect table to its final destination (follows redirect chains). |
| 364 | 500 | * Used by OTTO and other backend processing so the final canonical URL is used before 404 checks. |
| 365 | 501 | * |
| 366 | 502 | * @param string $url Full URL (e.g. https://example.com/old-page) |
| @@ -371,19 +507,21 @@ | ||
| 371 | 507 | { |
| 372 | 508 | if (empty($url) || !is_string($url)) { |
| 373 | 509 | return $url; |
| 374 | 510 | } |
| 375 | - $parsed = parse_url($url); | |
| 511 | + $parsed = wp_parse_url($url); | |
| 376 | 512 | $scheme = isset($parsed['scheme']) ? $parsed['scheme'] : 'https'; |
| 377 | 513 | $host = isset($parsed['host']) ? $parsed['host'] : ''; |
| 378 | 514 | $base = $scheme . '://' . $host; |
| 515 | + // Track the query separately from the path: sources match on the path | |
| 516 | + // only, and each destination may carry its own query. Folding the | |
| 517 | + // query into $uri (as this used to) made '/old?x=1' miss an '/old' | |
| 518 | + // rule and swallowed destination queries along the chain. | |
| 379 | 519 | $uri = isset($parsed['path']) ? $parsed['path'] : '/'; |
| 380 | - if (!empty($parsed['query'])) { | |
| 381 | - $uri .= '?' . $parsed['query']; | |
| 382 | - } | |
| 520 | + $query = isset($parsed['query']) ? $parsed['query'] : ''; | |
| 383 | 521 | $seen = array(); |
| 384 | 522 | for ($i = 0; $i < $max_hops; $i++) { |
| 385 | - $uri_key = $uri; | |
| 523 | + $uri_key = $query === '' ? $uri : $uri . '?' . $query; | |
| 386 | 524 | if (isset($seen[$uri_key])) { |
| 387 | 525 | break; // cycle detected |
| 388 | 526 | } |
| 389 | 527 | $seen[$uri_key] = true; |
| @@ -390,21 +528,22 @@ | ||
| 390 | 528 | $dest = $this->get_redirect_destination_for_uri($uri); |
| 391 | 529 | if ($dest === null) { |
| 392 | 530 | break; |
| 393 | 531 | } |
| 532 | + $dest = $this->append_query_string($dest, $query); | |
| 394 | 533 | if (strpos($dest, 'http') === 0) { |
| 395 | 534 | $url = $dest; |
| 396 | - $parsed = parse_url($url); | |
| 535 | + $parsed = wp_parse_url($url); | |
| 397 | 536 | $scheme = isset($parsed['scheme']) ? $parsed['scheme'] : 'https'; |
| 398 | 537 | $host = isset($parsed['host']) ? $parsed['host'] : ''; |
| 399 | 538 | $base = $scheme . '://' . $host; |
| 400 | 539 | $uri = isset($parsed['path']) ? $parsed['path'] : '/'; |
| 401 | - if (!empty($parsed['query'])) { | |
| 402 | - $uri .= '?' . $parsed['query']; | |
| 403 | - } | |
| 540 | + $query = isset($parsed['query']) ? $parsed['query'] : ''; | |
| 404 | 541 | } else { |
| 405 | - $uri = (isset($dest[0]) && $dest[0] === '/') ? $dest : '/' . $dest; | |
| 406 | - $url = $base . $uri; | |
| 542 | + $dest_parts = explode('?', $dest, 2); | |
| 543 | + $uri = (isset($dest_parts[0][0]) && $dest_parts[0][0] === '/') ? $dest_parts[0] : '/' . $dest_parts[0]; | |
| 544 | + $query = isset($dest_parts[1]) ? $dest_parts[1] : ''; | |
| 545 | + $url = $base . $uri . ($query === '' ? '' : '?' . $query); | |
| 407 | 546 | } |
| 408 | 547 | } |
| 409 | 548 | return $url; |
| 410 | 549 | } |
| @@ -418,8 +557,15 @@ | ||
| 418 | 557 | * @return string|null Destination URL or path, or null if no match |
| 419 | 558 | */ |
| 420 | 559 | private function get_redirect_destination_for_uri($uri) |
| 421 | 560 | { |
| 561 | + // Defensive: callers may pass a path?query form; sources match on the | |
| 562 | + // path only, exactly like the live template-redirect path. | |
| 563 | + $qpos = strpos((string) $uri, '?'); | |
| 564 | + if ($qpos !== false) { | |
| 565 | + $uri = substr($uri, 0, $qpos); | |
| 566 | + } | |
| 567 | + | |
| 422 | 568 | $redirections = $this->db_redirection->getAllActiveRecords(); |
| 423 | 569 | if (empty($redirections)) { |
| 424 | 570 | return null; |
| 425 | 571 | } |
| @@ -517,8 +663,80 @@ | ||
| 517 | 663 | return null; |
| 518 | 664 | } |
| 519 | 665 | |
| 520 | 666 | /** |
| 667 | + * Lightweight, non-blocking check that a redirect destination appears to resolve. | |
| 668 | + * | |
| 669 | + * Reuses the terminal dead-end logic from check_redirect_health(): internal | |
| 670 | + * destinations are matched against url_to_postid(); external destinations are | |
| 671 | + * only probed (wp_remote_head) when off-site redirects are explicitly allowed. | |
| 672 | + * Returns a human-readable warning string when the destination looks | |
| 673 | + * unreachable, or null when it resolves (or cannot be meaningfully checked). | |
| 674 | + * | |
| 675 | + * @param string $destination Destination URL or path. | |
| 676 | + * @param int $http_code Redirect HTTP code (410/451 are skipped). | |
| 677 | + * @param string $pattern_type Source pattern type ('regex' is skipped). | |
| 678 | + * @return string|null Warning message, or null if the destination resolves / is unchecked. | |
| 679 | + */ | |
| 680 | + public function destination_resolves_warning($destination, $http_code = 301, $pattern_type = 'exact') | |
| 681 | + { | |
| 682 | + // 410/451 redirects intentionally have no live destination. | |
| 683 | + if (in_array((int) $http_code, array(410, 451), true)) { | |
| 684 | + return null; | |
| 685 | + } | |
| 686 | + | |
| 687 | + // Regex sources have no single concrete destination path to resolve. | |
| 688 | + if ($pattern_type === 'regex') { | |
| 689 | + return null; | |
| 690 | + } | |
| 691 | + | |
| 692 | + if (!is_string($destination) || trim($destination) === '') { | |
| 693 | + return null; | |
| 694 | + } | |
| 695 | + | |
| 696 | + // Determine if the destination is external by checking for a host component | |
| 697 | + // that differs from the local site. Relative paths (e.g. /about) have no host | |
| 698 | + // and are always internal. | |
| 699 | + $is_external = false; | |
| 700 | + $parsed_dest = wp_parse_url($destination); | |
| 701 | + if (!empty($parsed_dest['host'])) { | |
| 702 | + $site_host = wp_parse_url(site_url(), PHP_URL_HOST); | |
| 703 | + $is_external = false === $site_host || strcasecmp($parsed_dest['host'], (string) $site_host) !== 0; | |
| 704 | + } | |
| 705 | + | |
| 706 | + if ($is_external) { | |
| 707 | + // Only probe external targets when off-site redirects are explicitly enabled, | |
| 708 | + // and only with a short, single-hop HEAD request to keep the save lightweight. | |
| 709 | + // 3xx responses are intentionally not flagged — the destination itself is | |
| 710 | + // reachable even if it redirects further. | |
| 711 | + if (get_option('metasync_allow_external_redirects')) { | |
| 712 | + $response = wp_remote_head($destination, array('timeout' => 5, 'redirection' => 0)); | |
| 713 | + if (is_wp_error($response)) { | |
| 714 | + return 'Destination URL appears unreachable — the redirect may lead to an error.'; | |
| 715 | + } | |
| 716 | + $code = (int) wp_remote_retrieve_response_code($response); | |
| 717 | + if ($code >= 400) { | |
| 718 | + return 'Destination URL returned an error status (' . $code . ') — the redirect may lead to a broken page.'; | |
| 719 | + } | |
| 720 | + } | |
| 721 | + return null; | |
| 722 | + } | |
| 723 | + | |
| 724 | + // Internal destination: confirm it maps to a known page on this site. | |
| 725 | + $path = $this->normalize_uri_path($destination); | |
| 726 | + $post_id = url_to_postid(site_url($path)); | |
| 727 | + if ($post_id === 0) { | |
| 728 | + $post_id = url_to_postid(site_url($path . '/')); | |
| 729 | + } | |
| 730 | + | |
| 731 | + if ($post_id === 0) { | |
| 732 | + return 'Destination URL does not match a known page on this site — the redirect may lead to a 404.'; | |
| 733 | + } | |
| 734 | + | |
| 735 | + return null; | |
| 736 | + } | |
| 737 | + | |
| 738 | + /** | |
| 521 | 739 | * Normalise a URL or path to a leading-slash URI path used for chain comparison. |
| 522 | 740 | * |
| 523 | 741 | * @param string $url |
| 524 | 742 | * @return string |
| @@ -528,9 +746,9 @@ | ||
| 528 | 746 | if (!is_string($url) || $url === '') { |
| 529 | 747 | return '/'; |
| 530 | 748 | } |
| 531 | 749 | if (strpos($url, 'http') === 0) { |
| 532 | - $parsed = parse_url($url); | |
| 750 | + $parsed = wp_parse_url($url); | |
| 533 | 751 | $path = isset($parsed['path']) ? $parsed['path'] : '/'; |
| 534 | 752 | } else { |
| 535 | 753 | $path = $url; |
| 536 | 754 | } |
| @@ -572,8 +790,14 @@ | ||
| 572 | 790 | $source_urls = is_array($sources_from) ? $sources_from : array(); |
| 573 | 791 | $global_pattern_type = isset($row->pattern_type) ? $row->pattern_type : null; |
| 574 | 792 | |
| 575 | 793 | foreach ($source_urls as $source_key => $source_value) { |
| 794 | + // Legacy list-format rows store the URL as the VALUE under a | |
| 795 | + // numeric key ('0' => '/old'); remap so health checks match | |
| 796 | + // the URL rather than a bogus '/0'. | |
| 797 | + if ((is_int($source_key) || ctype_digit((string) $source_key)) && is_string($source_value) && $source_value !== '') { | |
| 798 | + $source_key = $source_value; | |
| 799 | + } | |
| 576 | 800 | $pattern_type = in_array($source_value, array('exact', 'contain', 'start', 'end', 'wildcard', 'regex')) |
| 577 | 801 | ? $source_value |
| 578 | 802 | : ($global_pattern_type ? $global_pattern_type : 'exact'); |
| 579 | 803 | |
| @@ -686,8 +910,14 @@ | ||
| 686 | 910 | if ($post_id === 0) { |
| 687 | 911 | $post_id = url_to_postid(site_url($current . '/')); |
| 688 | 912 | } |
| 689 | 913 | $is_dead = ($post_id === 0); |
| 914 | + // url_to_postid() knows only posts and pages — a taxonomy | |
| 915 | + // archive (category/tag/custom term URL) returns 0 and a | |
| 916 | + // healthy destination was flagged dead. Resolve those too. | |
| 917 | + if ($is_dead && $this->uri_resolves_to_term_archive($current)) { | |
| 918 | + $is_dead = false; | |
| 919 | + } | |
| 690 | 920 | } |
| 691 | 921 | $status = $is_dead ? 'dead_end' : 'ok'; |
| 692 | 922 | } |
| 693 | 923 | |
| @@ -705,8 +935,68 @@ | ||
| 705 | 935 | return $results; |
| 706 | 936 | } |
| 707 | 937 | |
| 708 | 938 | /** |
| 939 | + * Whether a URI path resolves to a taxonomy archive (category, tag, or a | |
| 940 | + * custom taxonomy term). url_to_postid() returns 0 for these, so without | |
| 941 | + * this check the health check reports them as dead ends. | |
| 942 | + * | |
| 943 | + * @param string $path URI path, e.g. /category/news/. | |
| 944 | + * @return bool True when the path maps to an existing term archive. | |
| 945 | + */ | |
| 946 | + private function uri_resolves_to_term_archive($path) | |
| 947 | + { | |
| 948 | + if (!function_exists('get_term_by') || !function_exists('get_taxonomies')) { | |
| 949 | + return false; | |
| 950 | + } | |
| 951 | + | |
| 952 | + $path = trim((string) $path, '/'); | |
| 953 | + if ($path === '') { | |
| 954 | + return false; | |
| 955 | + } | |
| 956 | + $segments = explode('/', $path); | |
| 957 | + $first = array_shift($segments); | |
| 958 | + if ($first === '' || empty($segments)) { | |
| 959 | + return false; | |
| 960 | + } | |
| 961 | + | |
| 962 | + // Built-in taxonomies honor the category_base/tag_base options; the | |
| 963 | + // defaults are 'category' and 'tag'. | |
| 964 | + $category_base = trim((string) get_option('category_base'), '/'); | |
| 965 | + $tag_base = trim((string) get_option('tag_base'), '/'); | |
| 966 | + $bases = array( | |
| 967 | + $category_base !== '' ? $category_base : 'category' => 'category', | |
| 968 | + $tag_base !== '' ? $tag_base : 'tag' => 'post_tag', | |
| 969 | + ); | |
| 970 | + $taxonomy = isset($bases[$first]) ? $bases[$first] : null; | |
| 971 | + | |
| 972 | + // Custom taxonomies carry their own rewrite base (defaults to the | |
| 973 | + // taxonomy name). | |
| 974 | + if ($taxonomy === null) { | |
| 975 | + foreach (get_taxonomies(array('publicly_queryable' => true, '_builtin' => false), 'objects') as $tax_object) { | |
| 976 | + $base = isset($tax_object->rewrite['slug']) ? trim((string) $tax_object->rewrite['slug'], '/') : $tax_object->name; | |
| 977 | + if ($base === $first) { | |
| 978 | + $taxonomy = $tax_object->name; | |
| 979 | + break; | |
| 980 | + } | |
| 981 | + } | |
| 982 | + } | |
| 983 | + if ($taxonomy === null) { | |
| 984 | + return false; | |
| 985 | + } | |
| 986 | + | |
| 987 | + // get_term_by() returns WP_Term|false for a registered taxonomy — never | |
| 988 | + // a WP_Error — so a false check is the whole story here. | |
| 989 | + $term = get_term_by('slug', implode('/', $segments), $taxonomy); | |
| 990 | + if ($term === false) { | |
| 991 | + // Hierarchical terms live at parent/child paths, and the child's | |
| 992 | + // own slug is only the last segment. | |
| 993 | + $term = get_term_by('slug', end($segments), $taxonomy); | |
| 994 | + } | |
| 995 | + return ($term !== false); | |
| 996 | + } | |
| 997 | + | |
| 998 | + /** | |
| 709 | 999 | * Handle AJAX health check request from admin UI. |
| 710 | 1000 | */ |
| 711 | 1001 | public function handle_health_check_ajax() |
| 712 | 1002 | { |
| @@ -744,8 +1034,19 @@ | ||
| 744 | 1034 | foreach ($source_urls as $source_key => $source_value) { |
| 745 | 1035 | $match_found = false; |
| 746 | 1036 | $captured_path = ''; // Used for wildcard/regex replacement in destination (e.g. * or $1) |
| 747 | 1037 | |
| 1038 | + // Legacy list-format rows store the URL as the VALUE under a | |
| 1039 | + // numeric key ('0' => '/old'); remap so matching uses the URL. | |
| 1040 | + if ((is_int($source_key) || ctype_digit((string) $source_key)) && is_string($source_value) && $source_value !== '') { | |
| 1041 | + $source_key = $source_value; | |
| 1042 | + } | |
| 1043 | + | |
| 1044 | + // Heal magic-quoted sources saved before the input-unslash fix so | |
| 1045 | + // this health-check view matches with the same semantics as the | |
| 1046 | + // live front-end matcher. | |
| 1047 | + $source_key = wp_unslash((string) $source_key); | |
| 1048 | + | |
| 748 | 1049 | // Resolve pattern type: per-source value (exact, contain, start, end, wildcard, regex) or row-level default |
| 749 | 1050 | $pattern_type = in_array($source_value, array('exact', 'contain', 'start', 'end', 'wildcard', 'regex')) |
| 750 | 1051 | ? $source_value |
| 751 | 1052 | : ($global_pattern_type ? $global_pattern_type : 'exact'); |
| @@ -754,9 +1055,9 @@ | ||
| 754 | 1055 | $normalized_source = $source_key; |
| 755 | 1056 | |
| 756 | 1057 | // If source is a full URL, use only the path for matching (consistent with front-end redirect behavior) |
| 757 | 1058 | if (strpos($source_key, 'http') === 0) { |
| 758 | - $parsed_src = parse_url($source_key); | |
| 1059 | + $parsed_src = wp_parse_url($source_key); | |
| 759 | 1060 | $normalized_source = isset($parsed_src['path']) ? $parsed_src['path'] : ''; |
| 760 | 1061 | } |
| 761 | 1062 | |
| 762 | 1063 | // Ensure leading slash for reliable path comparison |
| @@ -909,8 +1210,39 @@ | ||
| 909 | 1210 | return $destination; |
| 910 | 1211 | } |
| 911 | 1212 | |
| 912 | 1213 | /** |
| 1214 | + * Preserve query parameters from the incoming request when redirecting. | |
| 1215 | + * | |
| 1216 | + * Redirect matching intentionally ignores the query string, but the | |
| 1217 | + * browser-visible redirect must not silently discard it. Keep the stored | |
| 1218 | + * destination query intact and append the incoming query verbatim so | |
| 1219 | + * encoded values and repeated parameters survive unchanged. | |
| 1220 | + * | |
| 1221 | + * @param string $destination Destination URL or path. | |
| 1222 | + * @param string $query Incoming query without the leading '?'. | |
| 1223 | + * @return string Destination with the incoming query appended. | |
| 1224 | + */ | |
| 1225 | + private function append_query_string($destination, $query) | |
| 1226 | + { | |
| 1227 | + $destination = (string) $destination; | |
| 1228 | + $query = ltrim((string) $query, '?'); | |
| 1229 | + if ($query === '') { | |
| 1230 | + return $destination; | |
| 1231 | + } | |
| 1232 | + | |
| 1233 | + $fragment = ''; | |
| 1234 | + $fragment_pos = strpos($destination, '#'); | |
| 1235 | + if ($fragment_pos !== false) { | |
| 1236 | + $fragment = substr($destination, $fragment_pos); | |
| 1237 | + $destination = substr($destination, 0, $fragment_pos); | |
| 1238 | + } | |
| 1239 | + | |
| 1240 | + $separator = strpos($destination, '?') === false ? '?' : '&'; | |
| 1241 | + return $destination . $separator . $query . $fragment; | |
| 1242 | + } | |
| 1243 | + | |
| 1244 | + /** | |
| 913 | 1245 | * Handle template redirect for frontend redirections |
| 914 | 1246 | */ |
| 915 | 1247 | public function handle_template_redirect() |
| 916 | 1248 | { |
| @@ -918,14 +1250,28 @@ | ||
| 918 | 1250 | if (is_admin()) { |
| 919 | 1251 | return; |
| 920 | 1252 | } |
| 921 | 1253 | |
| 922 | - // Get current URI | |
| 923 | - $uri = $_SERVER['REQUEST_URI']; | |
| 1254 | + // Get current URI. wp_magic_quotes() slashes $_SERVER too, so unslash | |
| 1255 | + // here to compare against the literal (unslashed) stored sources. | |
| 1256 | + $request_uri = isset($_SERVER['REQUEST_URI']) ? wp_unslash((string) $_SERVER['REQUEST_URI']) : '/'; | |
| 924 | 1257 | |
| 1258 | + // Keep the raw query for the redirect destination. It is deliberately | |
| 1259 | + // excluded from matching below, but must survive the browser redirect. | |
| 1260 | + $query_pos = strpos($request_uri, '?'); | |
| 1261 | + $incoming_query = $query_pos === false ? '' : substr($request_uri, $query_pos + 1); | |
| 1262 | + | |
| 925 | 1263 | // Remove query string for matching |
| 926 | - $uri = strtok($uri, '?'); | |
| 1264 | + $uri = $query_pos === false ? $request_uri : substr($request_uri, 0, $query_pos); | |
| 927 | 1265 | |
| 1266 | + // REQUEST_URI arrives percent-encoded while stored sources are raw | |
| 1267 | + // UTF-8, so a stored '/日本' could never match '/%E6%97%A5%E6%9C%AC'. | |
| 1268 | + // Decode before matching; the stored destination still drives output. | |
| 1269 | + $decoded_uri = rawurldecode((string) $uri); | |
| 1270 | + if ($decoded_uri !== '') { | |
| 1271 | + $uri = $decoded_uri; | |
| 1272 | + } | |
| 1273 | + | |
| 928 | 1274 | // Build the lookup index (lazy, once per request) |
| 929 | 1275 | $this->ensure_redirect_index(); |
| 930 | 1276 | |
| 931 | 1277 | // O(1) exact-match lookup first |
| @@ -935,9 +1281,9 @@ | ||
| 935 | 1281 | } |
| 936 | 1282 | $normalized_uri = rtrim($normalized_uri, '/') ?: '/'; |
| 937 | 1283 | |
| 938 | 1284 | if (isset($this->exact_index[$normalized_uri])) { |
| 939 | - if ($this->source_url_redirection($this->exact_index[$normalized_uri], $uri)) { | |
| 1285 | + if ($this->source_url_redirection($this->exact_index[$normalized_uri], $uri, $incoming_query)) { | |
| 940 | 1286 | return; |
| 941 | 1287 | } |
| 942 | 1288 | } |
| 943 | 1289 | |
| @@ -942,9 +1288,9 @@ | ||
| 942 | 1288 | } |
| 943 | 1289 | |
| 944 | 1290 | // Fallback: scan only pattern-based rows (wildcard, regex, contain, start, end) |
| 945 | 1291 | foreach ($this->pattern_index as $redirection) { |
| 946 | - if ($this->source_url_redirection($redirection, $uri)) { | |
| 1292 | + if ($this->source_url_redirection($redirection, $uri, $incoming_query)) { | |
| 947 | 1293 | return; |
| 948 | 1294 | } |
| 949 | 1295 | } |
| 950 | 1296 | } |
| @@ -1094,9 +1440,9 @@ | ||
| 1094 | 1440 | # return $test_result !== false; |
| 1095 | 1441 | |
| 1096 | 1442 | // Use error handler to catch warnings from malformed patterns |
| 1097 | 1443 | $error_occurred = false; |
| 1098 | - set_error_handler(function() use (&$error_occurred) { | |
| 1444 | + set_error_handler(function() use (&$error_occurred) { // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_set_error_handler -- genuine failure path, bounded, no secrets | |
| 1099 | 1445 | $error_occurred = true; |
| 1100 | 1446 | return true; // Suppress the error |
| 1101 | 1447 | }, E_WARNING); |
| 1102 | 1448 | |