| @@ -1,20 +1,75 @@ | ||
| 1 | 1 | <?php |
| 2 | 2 | |
| 3 | +/** | |
| 4 | + * WordPress redirect module. | |
| 5 | + * | |
| 6 | + * Provides PHP controlled redirects and monitoring and is the core of the front-end redirection. | |
| 7 | + */ | |
| 3 | 8 | class WordPress_Module extends Red_Module { |
| 9 | + /** | |
| 10 | + * @var integer | |
| 11 | + */ | |
| 4 | 12 | const MODULE_ID = 1; |
| 5 | 13 | |
| 6 | - private $matched = false; | |
| 14 | + /** | |
| 15 | + * Can we log? | |
| 16 | + * | |
| 17 | + * @var boolean | |
| 18 | + */ | |
| 7 | 19 | private $can_log = true; |
| 8 | 20 | |
| 21 | + /** | |
| 22 | + * The target redirect URL | |
| 23 | + * | |
| 24 | + * @var string|false | |
| 25 | + */ | |
| 26 | + private $redirect_url = false; | |
| 27 | + | |
| 28 | + /** | |
| 29 | + * The target redirect code | |
| 30 | + * | |
| 31 | + * @var integer | |
| 32 | + */ | |
| 33 | + private $redirect_code = 0; | |
| 34 | + | |
| 35 | + /** | |
| 36 | + * Copy of redirects that match the requested URL | |
| 37 | + * | |
| 38 | + * @var Red_Item[] | |
| 39 | + */ | |
| 40 | + private $redirects = []; | |
| 41 | + | |
| 42 | + /** | |
| 43 | + * Matched redirect | |
| 44 | + * | |
| 45 | + * @var Red_Item|null | |
| 46 | + */ | |
| 47 | + private $matched = null; | |
| 48 | + | |
| 49 | + /** | |
| 50 | + * Return the module ID | |
| 51 | + * | |
| 52 | + * @return integer | |
| 53 | + */ | |
| 9 | 54 | public function get_id() { |
| 10 | 55 | return self::MODULE_ID; |
| 11 | 56 | } |
| 12 | 57 | |
| 58 | + /** | |
| 59 | + * Return the module name | |
| 60 | + * | |
| 61 | + * @return string | |
| 62 | + */ | |
| 13 | 63 | public function get_name() { |
| 14 | 64 | return 'WordPress'; |
| 15 | 65 | } |
| 16 | 66 | |
| 67 | + /** | |
| 68 | + * Start the module. Hooks any filters and actions | |
| 69 | + * | |
| 70 | + * @return void | |
| 71 | + */ | |
| 17 | 72 | public function start() { |
| 18 | 73 | // Only run redirect rules if we're not disabled |
| 19 | 74 | if ( ! red_is_disabled() ) { |
| 20 | 75 | // Canonical site settings - https, www, relocate, and aliases |
| @@ -38,13 +93,43 @@ | ||
| 38 | 93 | add_filter( 'redirect_canonical', [ $this, 'redirect_canonical' ], 10, 2 ); |
| 39 | 94 | |
| 40 | 95 | // Log 404s and perform 'URL and WordPress page type' redirects |
| 41 | 96 | add_action( 'template_redirect', [ $this, 'template_redirect' ] ); |
| 97 | + | |
| 98 | + // Back-compat for < database 4.2 | |
| 99 | + add_filter( 'redirection_404_data', [ $this, 'log_back_compat' ] ); | |
| 100 | + add_filter( 'redirection_log_data', [ $this, 'log_back_compat' ] ); | |
| 101 | + | |
| 102 | + // Record the redirect agent | |
| 103 | + add_filter( 'x_redirect_by', [ $this, 'record_redirect_by' ], 90 ); | |
| 42 | 104 | } |
| 43 | 105 | |
| 44 | - /* | |
| 106 | + /** | |
| 107 | + * Back-compatability for Redirection databases older than 4.2. Prevents errors from storing data that has no DB column | |
| 108 | + * | |
| 109 | + * @param array $insert Data to log. | |
| 110 | + * @return array | |
| 111 | + */ | |
| 112 | + public function log_back_compat( $insert ) { | |
| 113 | + // Remove columns not supported in older versions | |
| 114 | + $status = new Red_Database_Status(); | |
| 115 | + | |
| 116 | + if ( ! $status->does_support( '4.2' ) ) { | |
| 117 | + foreach ( [ 'request_data', 'request_method', 'http_code', 'domain', 'redirect_by' ] as $ignore ) { | |
| 118 | + unset( $insert[ $ignore ] ); | |
| 119 | + } | |
| 120 | + } | |
| 121 | + | |
| 122 | + return $insert; | |
| 123 | + } | |
| 124 | + | |
| 125 | + /** | |
| 45 | 126 | * This ensures that a matched URL is not overriddden by WordPress, if the URL happens to be a WordPress URL of some kind |
| 46 | 127 | * For example: /?author=1 will be redirected to /author/name unless this returns false |
| 128 | + * | |
| 129 | + * @param String $redirect_url The redirected URL. | |
| 130 | + * @param String $requested_url The requested URL. | |
| 131 | + * @return String|false | |
| 47 | 132 | */ |
| 48 | 133 | public function redirect_canonical( $redirect_url, $requested_url ) { |
| 49 | 134 | if ( $this->matched ) { |
| 50 | 135 | return false; |
| @@ -52,88 +137,122 @@ | ||
| 52 | 137 | |
| 53 | 138 | return $redirect_url; |
| 54 | 139 | } |
| 55 | 140 | |
| 141 | + /** | |
| 142 | + * WordPress 'template_redirect' hook. Used to check for 404s | |
| 143 | + * | |
| 144 | + * @return void | |
| 145 | + */ | |
| 56 | 146 | public function template_redirect() { |
| 57 | 147 | if ( ! is_404() || $this->matched ) { |
| 58 | 148 | return; |
| 59 | 149 | } |
| 60 | 150 | |
| 61 | - if ( $this->match_404_type() ) { | |
| 62 | - // Don't log an intentionally redirected 404 | |
| 151 | + // We are on a 404. Check if we have a 'URL and page type' match in any of the matched redirects. | |
| 152 | + if ( $this->is_url_and_page_type() ) { | |
| 153 | + // Don't log an intentionally redirected 404 as part of the 'url and page type' | |
| 63 | 154 | return; |
| 64 | 155 | } |
| 65 | 156 | |
| 66 | 157 | $options = red_get_options(); |
| 67 | 158 | |
| 68 | - if ( isset( $options['expire_404'] ) && $options['expire_404'] >= 0 && apply_filters( 'redirection_log_404', $this->can_log ) ) { | |
| 69 | - RE_404::create( Redirection_Request::get_request_url(), Redirection_Request::get_user_agent(), Redirection_Request::get_ip(), Redirection_Request::get_referrer() ); | |
| 159 | + if ( isset( $options['expire_404'] ) && $options['expire_404'] >= 0 && $this->can_log() ) { | |
| 160 | + $details = [ | |
| 161 | + 'agent' => Redirection_Request::get_user_agent(), | |
| 162 | + 'referrer' => Redirection_Request::get_referrer(), | |
| 163 | + 'request_method' => Redirection_Request::get_request_method(), | |
| 164 | + 'http_code' => 404, | |
| 165 | + ]; | |
| 166 | + | |
| 167 | + if ( $options['log_header'] ) { | |
| 168 | + $details['request_data'] = [ | |
| 169 | + 'headers' => Redirection_Request::get_request_headers(), | |
| 170 | + ]; | |
| 171 | + } | |
| 172 | + | |
| 173 | + Red_404_Log::create( Redirection_Request::get_server(), Redirection_Request::get_request_url(), Redirection_Request::get_ip(), $details ); | |
| 70 | 174 | } |
| 71 | 175 | } |
| 72 | 176 | |
| 73 | - private function match_404_type() { | |
| 74 | - if ( ! property_exists( $this, 'redirects' ) || count( $this->redirects ) === 0 ) { | |
| 75 | - return false; | |
| 76 | - } | |
| 177 | + /** | |
| 178 | + * Return `true` if any of the matched redirects is a 'url and page type', `false` otherwise | |
| 179 | + * | |
| 180 | + * @return boolean | |
| 181 | + */ | |
| 182 | + private function is_url_and_page_type() { | |
| 183 | + $page_types = array_values( array_filter( $this->redirects, function( Red_Item $redirect ) { | |
| 184 | + return $redirect->match->get_type() === 'page'; | |
| 185 | + } ) ); | |
| 77 | 186 | |
| 78 | - $page_types = array_values( array_filter( $this->redirects, [ $this, 'only_404' ] ) ); | |
| 79 | - | |
| 80 | 187 | if ( count( $page_types ) > 0 ) { |
| 81 | - $url = apply_filters( 'redirection_url_source', Redirection_Request::get_request_url() ); | |
| 82 | - | |
| 83 | - foreach ( $page_types as $page_type ) { | |
| 84 | - if ( $page_type->is_match( $url ) ) { | |
| 85 | - return true; | |
| 86 | - } | |
| 87 | - } | |
| 188 | + $request = new Red_Url_Request( Redirection_Request::get_request_url() ); | |
| 189 | + $page_types[0]->is_match( $request->get_decoded_url(), $request->get_original_url() ); | |
| 190 | + return true; | |
| 88 | 191 | } |
| 89 | 192 | |
| 90 | 193 | return false; |
| 91 | 194 | } |
| 92 | 195 | |
| 93 | - private function only_404( $redirect ) { | |
| 94 | - return $redirect->match->get_type() === 'page'; | |
| 95 | - } | |
| 96 | - | |
| 97 | - // Return true to stop further processing of the 'do nothing' | |
| 196 | + /** | |
| 197 | + * Called by a 'do nothing' action. Return true to stop further processing of the 'do nothing' | |
| 198 | + * | |
| 199 | + * @return boolean | |
| 200 | + */ | |
| 98 | 201 | public function redirection_do_nothing() { |
| 99 | 202 | $this->can_log = false; |
| 100 | 203 | return true; |
| 101 | 204 | } |
| 102 | 205 | |
| 206 | + /** | |
| 207 | + * Action fired when a redirect is performed, and used to log the data | |
| 208 | + * | |
| 209 | + * @param Red_Item $redirect The redirect. | |
| 210 | + * @param String $url The source URL. | |
| 211 | + * @param String $target The target URL. | |
| 212 | + * @return void | |
| 213 | + */ | |
| 103 | 214 | public function redirection_visit( $redirect, $url, $target ) { |
| 104 | 215 | $redirect->visit( $url, $target ); |
| 105 | 216 | } |
| 106 | 217 | |
| 107 | - public function canonical_domain() { | |
| 218 | + /** | |
| 219 | + * Get canonical target | |
| 220 | + * | |
| 221 | + * @return string|false | |
| 222 | + */ | |
| 223 | + public function get_canonical_target() { | |
| 108 | 224 | $options = red_get_options(); |
| 109 | 225 | $canonical = new Redirection_Canonical( $options['https'], $options['preferred_domain'], $options['aliases'], get_home_url() ); |
| 110 | 226 | |
| 111 | 227 | // Relocate domain? |
| 112 | - $target = false; | |
| 113 | 228 | if ( $options['relocate'] ) { |
| 114 | - $target = $canonical->relocate_request( $options['relocate'], Redirection_Request::get_server_name(), Redirection_Request::get_request_url() ); | |
| 229 | + return $canonical->relocate_request( $options['relocate'], Redirection_Request::get_server_name(), Redirection_Request::get_request_url() ); | |
| 115 | 230 | } |
| 116 | 231 | |
| 117 | 232 | // Force HTTPS or www |
| 118 | - if ( ! $target ) { | |
| 119 | - $target = $canonical->get_redirect( Redirection_Request::get_server_name(), Redirection_Request::get_request_url() ); | |
| 120 | - } | |
| 233 | + return $canonical->get_redirect( Redirection_Request::get_request_server_name(), Redirection_Request::get_request_url() ); | |
| 234 | + } | |
| 121 | 235 | |
| 236 | + /** | |
| 237 | + * Checks for canonical domain requests | |
| 238 | + * | |
| 239 | + * @return void | |
| 240 | + */ | |
| 241 | + public function canonical_domain() { | |
| 242 | + $target = $this->get_canonical_target(); | |
| 243 | + | |
| 122 | 244 | if ( $target ) { |
| 123 | - add_filter( 'x_redirect_by', [ $this, 'x_redirect_by' ] ); | |
| 124 | 245 | // phpcs:ignore |
| 125 | - wp_redirect( $target, 301 ); | |
| 246 | + wp_redirect( $target, 301, 'redirection' ); | |
| 126 | 247 | die(); |
| 127 | 248 | } |
| 128 | 249 | } |
| 129 | 250 | |
| 130 | - public function x_redirect_by() { | |
| 131 | - return 'redirection'; | |
| 132 | - } | |
| 133 | - | |
| 134 | 251 | /** |
| 135 | - * This is the key to Redirection and where requests are matched to redirects | |
| 252 | + * Redirection 'main loop'. Checks the currently requested URL against the database and perform a redirect, if necessary. | |
| 253 | + * | |
| 254 | + * @return void | |
| 136 | 255 | */ |
| 137 | 256 | public function init() { |
| 138 | 257 | if ( $this->matched ) { |
| 139 | 258 | return; |
| @@ -164,10 +283,15 @@ | ||
| 164 | 283 | } |
| 165 | 284 | } |
| 166 | 285 | } |
| 167 | 286 | |
| 287 | + /** | |
| 288 | + * Fix for incorrect headers sent when using FastCGI/IIS | |
| 289 | + * | |
| 290 | + * @param String $status HTTP status line. | |
| 291 | + * @return String | |
| 292 | + */ | |
| 168 | 293 | public function status_header( $status ) { |
| 169 | - // Fix for incorrect headers sent when using FastCGI/IIS | |
| 170 | 294 | if ( substr( php_sapi_name(), 0, 3 ) === 'cgi' ) { |
| 171 | 295 | return str_replace( 'HTTP/1.1', 'Status:', $status ); |
| 172 | 296 | } |
| 173 | 297 | |
| @@ -173,8 +297,14 @@ | ||
| 173 | 297 | |
| 174 | 298 | return $status; |
| 175 | 299 | } |
| 176 | 300 | |
| 301 | + /** | |
| 302 | + * Add any custom HTTP headers to the response. | |
| 303 | + * | |
| 304 | + * @param array $obj Some object. | |
| 305 | + * @return void | |
| 306 | + */ | |
| 177 | 307 | public function send_headers( $obj ) { |
| 178 | 308 | if ( ! empty( $this->matched ) && $this->matched->action->get_code() === 410 ) { |
| 179 | 309 | add_filter( 'status_header', [ $this, 'set_header_410' ] ); |
| 180 | 310 | } |
| @@ -184,24 +314,39 @@ | ||
| 184 | 314 | $headers = new Red_Http_Headers( $options['headers'] ); |
| 185 | 315 | $headers->run( $headers->get_site_headers() ); |
| 186 | 316 | } |
| 187 | 317 | |
| 318 | + /** | |
| 319 | + * Add support for a 410 response. | |
| 320 | + * | |
| 321 | + * @return String | |
| 322 | + */ | |
| 188 | 323 | public function set_header_410() { |
| 189 | 324 | return 'HTTP/1.1 410 Gone'; |
| 190 | 325 | } |
| 191 | 326 | |
| 192 | - public function wp_redirect( $url, $status = 302 ) { | |
| 193 | - global $wp_version, $is_IIS; | |
| 327 | + /** | |
| 328 | + * IIS fix. Don't know if this is still needed | |
| 329 | + * | |
| 330 | + * @param String $url URL. | |
| 331 | + * @return void | |
| 332 | + */ | |
| 333 | + private function iis_fix( $url ) { | |
| 334 | + global $is_IIS; | |
| 194 | 335 | |
| 195 | - $options = red_get_options(); | |
| 196 | - $headers = new Red_Http_Headers( $options['headers'] ); | |
| 197 | - $headers->run( $headers->get_redirect_headers() ); | |
| 198 | - | |
| 199 | 336 | if ( $is_IIS ) { |
| 200 | 337 | header( "Refresh: 0;url=$url" ); |
| 201 | - return $url; | |
| 202 | 338 | } |
| 339 | + } | |
| 203 | 340 | |
| 341 | + /** | |
| 342 | + * Don't know if this is still needed | |
| 343 | + * | |
| 344 | + * @param String $url URL. | |
| 345 | + * @param integer $status HTTP status code. | |
| 346 | + * @return void | |
| 347 | + */ | |
| 348 | + private function cgi_fix( $url, $status ) { | |
| 204 | 349 | if ( $status === 301 && php_sapi_name() === 'cgi-fcgi' ) { |
| 205 | 350 | $servers_to_check = [ 'lighttpd', 'nginx' ]; |
| 206 | 351 | |
| 207 | 352 | foreach ( $servers_to_check as $name ) { |
| @@ -211,9 +356,91 @@ | ||
| 211 | 356 | exit( 0 ); |
| 212 | 357 | } |
| 213 | 358 | } |
| 214 | 359 | } |
| 360 | + } | |
| 215 | 361 | |
| 362 | + /** | |
| 363 | + * Get a 'source' for a redirect by digging through the backtrace. | |
| 364 | + * | |
| 365 | + * @return string[] | |
| 366 | + */ | |
| 367 | + private function get_redirect_source() { | |
| 368 | + $ignore = [ | |
| 369 | + 'WP_Hook', | |
| 370 | + 'template-loader.php', | |
| 371 | + 'wp-blog-header.php', | |
| 372 | + ]; | |
| 373 | + | |
| 374 | + // phpcs:ignore | |
| 375 | + $source = wp_debug_backtrace_summary( null, 5, false ); | |
| 376 | + | |
| 377 | + return array_filter( $source, function( $item ) use ( $ignore ) { | |
| 378 | + foreach ( $ignore as $ignore_item ) { | |
| 379 | + if ( strpos( $item, $ignore_item ) !== false ) { | |
| 380 | + return false; | |
| 381 | + } | |
| 382 | + } | |
| 383 | + | |
| 384 | + return true; | |
| 385 | + } ); | |
| 386 | + } | |
| 387 | + | |
| 388 | + /** | |
| 389 | + * Record a redirect. | |
| 390 | + * | |
| 391 | + * @param String $agent Redirect agent. | |
| 392 | + * @return string | |
| 393 | + */ | |
| 394 | + public function record_redirect_by( $agent ) { | |
| 395 | + // Have we already redirected with Redirection? | |
| 396 | + if ( $this->matched || $agent === 'redirection' ) { | |
| 397 | + return $agent; | |
| 398 | + } | |
| 399 | + | |
| 400 | + $options = red_get_options(); | |
| 401 | + | |
| 402 | + if ( ! $options['log_external'] ) { | |
| 403 | + return $agent; | |
| 404 | + } | |
| 405 | + | |
| 406 | + $details = [ | |
| 407 | + 'target' => $this->redirect_url, | |
| 408 | + 'agent' => Redirection_Request::get_user_agent(), | |
| 409 | + 'referrer' => Redirection_Request::get_referrer(), | |
| 410 | + 'request_method' => Redirection_Request::get_request_method(), | |
| 411 | + 'redirect_by' => $agent ? $agent : 'wordpress', | |
| 412 | + 'http_code' => $this->redirect_code, | |
| 413 | + 'request_data' => [ | |
| 414 | + 'source' => array_values( $this->get_redirect_source() ), | |
| 415 | + ], | |
| 416 | + ]; | |
| 417 | + | |
| 418 | + if ( $options['log_header'] ) { | |
| 419 | + $details['request_data']['headers'] = Redirection_Request::get_request_headers(); | |
| 420 | + } | |
| 421 | + | |
| 422 | + Red_Redirect_Log::create( Redirection_Request::get_server(), Redirection_Request::get_request_url(), Redirection_Request::get_ip(), $details ); | |
| 423 | + | |
| 424 | + return $agent; | |
| 425 | + } | |
| 426 | + | |
| 427 | + /** | |
| 428 | + * Perform any pre-redirect processing, such as logging and header fixing. | |
| 429 | + * | |
| 430 | + * @param String $url Target URL. | |
| 431 | + * @param integer $status HTTP status. | |
| 432 | + * @return string | |
| 433 | + */ | |
| 434 | + public function wp_redirect( $url, $status = 302 ) { | |
| 435 | + global $wp_version; | |
| 436 | + | |
| 437 | + $this->redirect_url = $url; | |
| 438 | + $this->redirect_code = $status; | |
| 439 | + | |
| 440 | + $this->iis_fix( $url ); | |
| 441 | + $this->cgi_fix( $url, $status ); | |
| 442 | + | |
| 216 | 443 | if ( intval( $status, 10 ) === 307 ) { |
| 217 | 444 | status_header( $status ); |
| 218 | 445 | nocache_headers(); |
| 219 | 446 | return $url; |
| @@ -219,8 +446,10 @@ | ||
| 219 | 446 | return $url; |
| 220 | 447 | } |
| 221 | 448 | |
| 222 | 449 | $options = red_get_options(); |
| 450 | + $headers = new Red_Http_Headers( $options['headers'] ); | |
| 451 | + $headers->run( $headers->get_redirect_headers() ); | |
| 223 | 452 | |
| 224 | 453 | // Do we need to set the cache header? |
| 225 | 454 | if ( ! headers_sent() && isset( $options['redirect_cache'] ) && $options['redirect_cache'] !== 0 && intval( $status, 10 ) === 301 ) { |
| 226 | 455 | if ( $options['redirect_cache'] === -1 ) { |
| @@ -236,18 +465,24 @@ | ||
| 236 | 465 | status_header( $status ); |
| 237 | 466 | return $url; |
| 238 | 467 | } |
| 239 | 468 | |
| 240 | - public function update( array $data ) { | |
| 241 | - return false; | |
| 469 | + /** | |
| 470 | + * Reset the module. Used for unit tests | |
| 471 | + * | |
| 472 | + * @param Red_Item|null $matched Set the `matched` var. | |
| 473 | + * @return void | |
| 474 | + */ | |
| 475 | + public function reset( $matched = null ) { | |
| 476 | + $this->can_log = true; | |
| 477 | + $this->matched = $matched; | |
| 242 | 478 | } |
| 243 | 479 | |
| 244 | - protected function load( $options ) { | |
| 245 | - } | |
| 246 | - | |
| 247 | - protected function flush_module() { | |
| 248 | - } | |
| 249 | - | |
| 250 | - public function reset() { | |
| 251 | - $this->can_log = true; | |
| 480 | + /** | |
| 481 | + * Can we log a redirect? | |
| 482 | + * | |
| 483 | + * @return boolean | |
| 484 | + */ | |
| 485 | + public function can_log() { | |
| 486 | + return apply_filters( 'redirection_log_404', $this->can_log ); | |
| 252 | 487 | } |
| 253 | 488 | } |