| 1 |
<?php |
| 2 |
/** |
| 3 |
* WPSEO plugin file. |
| 4 |
* |
| 5 |
* @package WPSEO\Admin |
| 6 |
*/ |
| 7 |
|
| 8 |
/** |
| 9 |
* Loads the MyYoast proxy. |
| 10 |
* |
| 11 |
* This class registers a proxy page on `admin.php`. Which is reached with the `page=PAGE_IDENTIFIER` parameter. |
| 12 |
* It will read external files and serves them like they are located locally. |
| 13 |
*/ |
| 14 |
class WPSEO_MyYoast_Proxy implements WPSEO_WordPress_Integration { |
| 15 |
|
| 16 |
/** |
| 17 |
* The page identifier used in WordPress to register the MyYoast proxy page. |
| 18 |
* |
| 19 |
* @var string |
| 20 |
*/ |
| 21 |
public const PAGE_IDENTIFIER = 'wpseo_myyoast_proxy'; |
| 22 |
|
| 23 |
/** |
| 24 |
* The cache control's max age. Used in the header of a successful proxy response. |
| 25 |
* |
| 26 |
* @var int |
| 27 |
*/ |
| 28 |
public const CACHE_CONTROL_MAX_AGE = DAY_IN_SECONDS; |
| 29 |
|
| 30 |
/** |
| 31 |
* Registers the hooks when the user is on the right page. |
| 32 |
* |
| 33 |
* @codeCoverageIgnore |
| 34 |
* |
| 35 |
* @return void |
| 36 |
*/ |
| 37 |
public function register_hooks() { |
| 38 |
if ( ! $this->is_proxy_page() ) { |
| 39 |
return; |
| 40 |
} |
| 41 |
|
| 42 |
// Register the page for the proxy. |
| 43 |
add_action( 'admin_menu', [ $this, 'add_proxy_page' ] ); |
| 44 |
add_action( 'admin_init', [ $this, 'handle_proxy_page' ] ); |
| 45 |
} |
| 46 |
|
| 47 |
/** |
| 48 |
* Registers the proxy page. It does not actually add a link to the dashboard. |
| 49 |
* |
| 50 |
* @codeCoverageIgnore |
| 51 |
* |
| 52 |
* @return void |
| 53 |
*/ |
| 54 |
public function add_proxy_page() { |
| 55 |
add_dashboard_page( '', '', 'read', self::PAGE_IDENTIFIER, '' ); |
| 56 |
} |
| 57 |
|
| 58 |
/** |
| 59 |
* Renders the requested proxy page and exits to prevent the WordPress UI from loading. |
| 60 |
* |
| 61 |
* @codeCoverageIgnore |
| 62 |
* |
| 63 |
* @return void |
| 64 |
*/ |
| 65 |
public function handle_proxy_page() { |
| 66 |
$this->render_proxy_page(); |
| 67 |
|
| 68 |
// Prevent the WordPress UI from loading. |
| 69 |
exit(); |
| 70 |
} |
| 71 |
|
| 72 |
/** |
| 73 |
* Renders the requested proxy page. |
| 74 |
* |
| 75 |
* This is separated from the exits to be able to test it. |
| 76 |
* |
| 77 |
* @return void |
| 78 |
*/ |
| 79 |
public function render_proxy_page() { |
| 80 |
$proxy_options = $this->determine_proxy_options(); |
| 81 |
if ( $proxy_options === [] ) { |
| 82 |
// Do not accept any other file than implemented. |
| 83 |
$this->set_header( 'HTTP/1.0 501 Requested file not implemented' ); |
| 84 |
return; |
| 85 |
} |
| 86 |
|
| 87 |
// Set the headers before serving the remote file. |
| 88 |
$this->set_header( 'Content-Type: ' . $proxy_options['content_type'] ); |
| 89 |
$this->set_header( 'Cache-Control: max-age=' . self::CACHE_CONTROL_MAX_AGE ); |
| 90 |
|
| 91 |
try { |
| 92 |
echo $this->get_remote_url_body( $proxy_options['url'] ); |
| 93 |
} catch ( Exception $e ) { |
| 94 |
/* |
| 95 |
* Reset the file headers because the loading failed. |
| 96 |
* |
| 97 |
* Note: Due to supporting PHP 5.2 `header_remove` can not be used here. |
| 98 |
* Overwrite the headers instead. |
| 99 |
*/ |
| 100 |
$this->set_header( 'Content-Type: text/plain' ); |
| 101 |
$this->set_header( 'Cache-Control: max-age=0' ); |
| 102 |
|
| 103 |
$this->set_header( 'HTTP/1.0 500 ' . $e->getMessage() ); |
| 104 |
} |
| 105 |
} |
| 106 |
|
| 107 |
/** |
| 108 |
* Tries to load the given url via `wp_remote_get`. |
| 109 |
* |
| 110 |
* @codeCoverageIgnore |
| 111 |
* |
| 112 |
* @param string $url The url to load. |
| 113 |
* |
| 114 |
* @return string The body of the response. |
| 115 |
* |
| 116 |
* @throws Exception When `wp_remote_get` returned an error. |
| 117 |
* @throws Exception When the response code is not 200. |
| 118 |
*/ |
| 119 |
protected function get_remote_url_body( $url ) { |
| 120 |
$response = wp_remote_get( $url ); |
| 121 |
|
| 122 |
if ( $response instanceof WP_Error ) { |
| 123 |
throw new Exception( 'Unable to retrieve file from MyYoast' ); |
| 124 |
} |
| 125 |
|
| 126 |
if ( wp_remote_retrieve_response_code( $response ) !== 200 ) { |
| 127 |
throw new Exception( 'Received unexpected response from MyYoast' ); |
| 128 |
} |
| 129 |
|
| 130 |
return wp_remote_retrieve_body( $response ); |
| 131 |
} |
| 132 |
|
| 133 |
/** |
| 134 |
* Determines the proxy options based on the file and plugin version arguments. |
| 135 |
* |
| 136 |
* When the file is known it returns an array like this: |
| 137 |
* <code> |
| 138 |
* $array = array( |
| 139 |
* 'content_type' => 'the content type' |
| 140 |
* 'url' => 'the url, possibly with the plugin version' |
| 141 |
* ) |
| 142 |
* </code> |
| 143 |
* |
| 144 |
* @return array Empty for an unknown file. See format above for known files. |
| 145 |
*/ |
| 146 |
protected function determine_proxy_options() { |
| 147 |
if ( $this->get_proxy_file() === 'research-webworker' ) { |
| 148 |
return [ |
| 149 |
'content_type' => 'text/javascript; charset=UTF-8', |
| 150 |
'url' => 'https://my.yoast.com/api/downloads/file/analysis-worker?plugin_version=' . $this->get_plugin_version(), |
| 151 |
]; |
| 152 |
} |
| 153 |
|
| 154 |
return []; |
| 155 |
} |
| 156 |
|
| 157 |
/** |
| 158 |
* Checks if the current page is the MyYoast proxy page. |
| 159 |
* |
| 160 |
* @codeCoverageIgnore |
| 161 |
* |
| 162 |
* @return bool True when the page request parameter equals the proxy page. |
| 163 |
*/ |
| 164 |
protected function is_proxy_page() { |
| 165 |
// phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Reason: We are not processing form information. |
| 166 |
$page = isset( $_GET['page'] ) && is_string( $_GET['page'] ) ? sanitize_text_field( wp_unslash( $_GET['page'] ) ) : ''; |
| 167 |
return $page === self::PAGE_IDENTIFIER; |
| 168 |
} |
| 169 |
|
| 170 |
/** |
| 171 |
* Returns the proxy file from the HTTP request parameters. |
| 172 |
* |
| 173 |
* @codeCoverageIgnore |
| 174 |
* |
| 175 |
* @return string The sanitized file request parameter or an empty string if it does not exist. |
| 176 |
*/ |
| 177 |
protected function get_proxy_file() { |
| 178 |
// phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Reason: We are not processing form information. |
| 179 |
if ( isset( $_GET['file'] ) && is_string( $_GET['file'] ) ) { |
| 180 |
// phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Reason: We are not processing form information. |
| 181 |
return sanitize_text_field( wp_unslash( $_GET['file'] ) ); |
| 182 |
} |
| 183 |
return ''; |
| 184 |
} |
| 185 |
|
| 186 |
/** |
| 187 |
* Returns the plugin version from the HTTP request parameters. |
| 188 |
* |
| 189 |
* @codeCoverageIgnore |
| 190 |
* |
| 191 |
* @return string The sanitized plugin_version request parameter or an empty string if it does not exist. |
| 192 |
*/ |
| 193 |
protected function get_plugin_version() { |
| 194 |
// phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Reason: We are not processing form information. |
| 195 |
if ( isset( $_GET['plugin_version'] ) && is_string( $_GET['plugin_version'] ) ) { |
| 196 |
// phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Reason: We are not processing form information. |
| 197 |
$plugin_version = sanitize_text_field( wp_unslash( $_GET['plugin_version'] ) ); |
| 198 |
// Replace slashes to secure against requiring a file from another path. |
| 199 |
return str_replace( [ '/', '\\' ], '_', $plugin_version ); |
| 200 |
} |
| 201 |
return ''; |
| 202 |
} |
| 203 |
|
| 204 |
/** |
| 205 |
* Sets the HTTP header. |
| 206 |
* |
| 207 |
* This is a tiny helper function to enable better testing. |
| 208 |
* |
| 209 |
* @codeCoverageIgnore |
| 210 |
* |
| 211 |
* @param string $header The header to set. |
| 212 |
* |
| 213 |
* @return void |
| 214 |
*/ |
| 215 |
protected function set_header( $header ) { |
| 216 |
header( $header ); |
| 217 |
} |
| 218 |
} |
| 219 |
|