← All changes
|
vendor/wp-php-toolkit/reprint-server/src/class-http-server.php
+414
-0
16.2-beta
→
16.3
View file →
| @@ -1,0 +1,414 @@ | ||
| 1 | +<?php | |
| 2 | + | |
| 3 | +use WordPress\Reprint\Server\ResourceBudget; | |
| 4 | + | |
| 5 | +use function WordPress\Reprint\Server\parse_size; | |
| 6 | + | |
| 7 | +require_once __DIR__ . '/utils.php'; | |
| 8 | + | |
| 9 | +if (!class_exists('WordPress\\Reprint\\Server\\ResourceBudget', false)) { | |
| 10 | + require_once __DIR__ . '/class-resource-budget.php'; | |
| 11 | +} | |
| 12 | +if (!class_exists('Site_Export_Push_Configuration_Exception', false)) { | |
| 13 | + require_once __DIR__ . '/class-push-configuration-exception.php'; | |
| 14 | +} | |
| 15 | + | |
| 16 | +/** | |
| 17 | + * HTTP dispatcher for the Site Export API. | |
| 18 | + */ | |
| 19 | +final class Site_Export_HTTP_Server { | |
| 20 | + | |
| 21 | + private const PUSH_ENDPOINT_METHODS = [ | |
| 22 | + 'push_create' => 'create', | |
| 23 | + 'push_upload' => 'upload', | |
| 24 | + 'push_status' => 'status', | |
| 25 | + 'push_commit' => 'commit', | |
| 26 | + 'push_remove' => 'remove', | |
| 27 | + ]; | |
| 28 | + | |
| 29 | + /** @var array<string, callable> */ | |
| 30 | + private $handlers; | |
| 31 | + | |
| 32 | + /** @var callable */ | |
| 33 | + private $budget_factory; | |
| 34 | + | |
| 35 | + /** @var callable */ | |
| 36 | + private $body_reader; | |
| 37 | + | |
| 38 | + /** @var string */ | |
| 39 | + private $cursor_header_name; | |
| 40 | + | |
| 41 | + /** @var string|null */ | |
| 42 | + private $default_directory; | |
| 43 | + | |
| 44 | + /** @var Site_Export_Push_Endpoints|null */ | |
| 45 | + private $push_endpoints; | |
| 46 | + | |
| 47 | + public function __construct(array $options = []) { | |
| 48 | + $this->budget_factory = $options['budget_factory'] ?? [$this, 'default_budget_factory']; | |
| 49 | + $this->body_reader = $options['body_reader'] ?? static function (): string { | |
| 50 | + $body = file_get_contents('php://input'); | |
| 51 | + return $body === false ? '' : $body; | |
| 52 | + }; | |
| 53 | + $this->cursor_header_name = $options['cursor_header_name'] ?? 'HTTP_X_EXPORT_CURSOR'; | |
| 54 | + $this->default_directory = $options['default_directory'] ?? null; | |
| 55 | + $this->push_endpoints = null; | |
| 56 | + if (array_key_exists('push', $options)) { | |
| 57 | + try { | |
| 58 | + if (!is_array($options['push'])) { | |
| 59 | + throw new InvalidArgumentException('The push HTTP server option must be an array.'); | |
| 60 | + } | |
| 61 | + if (!class_exists('Site_Export_Push_Endpoints', false)) { | |
| 62 | + require_once __DIR__ . '/class-push-endpoints.php'; | |
| 63 | + } | |
| 64 | + $this->push_endpoints = new Site_Export_Push_Endpoints($options['push']); | |
| 65 | + } catch (InvalidArgumentException $exception) { | |
| 66 | + // phpcs:disable WordPress.Security.EscapeOutput.ExceptionNotEscaped -- This rethrows a trusted configuration error; it does not render output. | |
| 67 | + throw new Site_Export_Push_Configuration_Exception( | |
| 68 | + $exception->getMessage(), | |
| 69 | + 0, | |
| 70 | + $exception | |
| 71 | + ); | |
| 72 | + // phpcs:enable WordPress.Security.EscapeOutput.ExceptionNotEscaped | |
| 73 | + } | |
| 74 | + } | |
| 75 | + $this->handlers = $options['handlers'] ?? $this->default_handlers(); | |
| 76 | + } | |
| 77 | + | |
| 78 | + public function handle_request(array $request = []): void { | |
| 79 | + $server = $request['server'] ?? $_SERVER; | |
| 80 | + $get = $request['get'] ?? $_GET; | |
| 81 | + $post = $request['post'] ?? $_POST; | |
| 82 | + // Push parameters travel in the signed query string. push_upload streams | |
| 83 | + // php://input itself; reading JSON for any push endpoint would either | |
| 84 | + // buffer an upload or let a control request buffer an unused body. | |
| 85 | + $endpoint = $get['endpoint'] ?? null; | |
| 86 | + $uses_push_request_contract = is_string($endpoint) && strpos($endpoint, 'push_') === 0; | |
| 87 | + $body = ''; | |
| 88 | + if ($uses_push_request_contract) { | |
| 89 | + // $_POST must not override the signed query endpoint after the | |
| 90 | + // router has applied push authorization to that query endpoint. | |
| 91 | + $post = []; | |
| 92 | + } else { | |
| 93 | + $body = array_key_exists('body', $request) | |
| 94 | + ? (string) $request['body'] | |
| 95 | + : ( $this->is_json_content_type($server) ? call_user_func($this->body_reader) : '' ); | |
| 96 | + } | |
| 97 | + $config = $request['config'] ?? $this->parse_http_config( | |
| 98 | + $get, | |
| 99 | + $post, | |
| 100 | + $server, | |
| 101 | + $body | |
| 102 | + ); | |
| 103 | + $config = $this->normalize_config($config, $server); | |
| 104 | + $this->dispatch($config); | |
| 105 | + } | |
| 106 | + | |
| 107 | + /** | |
| 108 | + * Emits CORS headers and terminates OPTIONS preflight requests. | |
| 109 | + * | |
| 110 | + * Must be called BEFORE authentication runs — browsers send | |
| 111 | + * preflight OPTIONS without credentials, so the consumer must not | |
| 112 | + * require auth headers before this check passes. | |
| 113 | + * | |
| 114 | + * A wildcard origin ('*') is safe when authentication happens | |
| 115 | + * out-of-band (e.g., HMAC with a pre-shared secret) — an attacker | |
| 116 | + * without the secret cannot export anything regardless of origin. | |
| 117 | + * | |
| 118 | + * For OPTIONS requests this terminates the process. For all other | |
| 119 | + * methods it just emits the headers and returns so the caller can | |
| 120 | + * continue with authentication and dispatch. | |
| 121 | + * | |
| 122 | + * @param string|true $origin The Access-Control-Allow-Origin value, | |
| 123 | + * or true as a shorthand for '*'. | |
| 124 | + * @param string $allow_headers The Access-Control-Allow-Headers value. | |
| 125 | + * Defaults to '*' to permit all headers. Pass a comma-separated | |
| 126 | + * list to restrict (e.g. 'Content-Type, X-Auth-Signature'). | |
| 127 | + * @param array<string, mixed> $server Request server array (defaults to $_SERVER). | |
| 128 | + * @param array<string, callable>|null $io Optional overrides for | |
| 129 | + * 'header' (emitter) and 'exit' (preflight terminator). Used | |
| 130 | + * only by tests. | |
| 131 | + */ | |
| 132 | + public static function handle_cors_headers_and_terminate_on_options( | |
| 133 | + $origin = '*', | |
| 134 | + string $allow_headers = '*', | |
| 135 | + array $server = [], | |
| 136 | + ?array $io = null | |
| 137 | + ): void { | |
| 138 | + if ($origin === true) { | |
| 139 | + $origin = '*'; | |
| 140 | + } | |
| 141 | + if (!is_string($origin) || $origin === '') { | |
| 142 | + throw new InvalidArgumentException( | |
| 143 | + 'CORS origin must be a non-empty string or true' | |
| 144 | + ); | |
| 145 | + } | |
| 146 | + | |
| 147 | + $emit_header = ($io['header'] ?? null) ?? static function (string $h): void { | |
| 148 | + header($h); | |
| 149 | + }; | |
| 150 | + $terminate = ($io['exit'] ?? null) ?? static function (): void { | |
| 151 | + exit; | |
| 152 | + }; | |
| 153 | + | |
| 154 | + $emit_header('Access-Control-Allow-Origin: ' . $origin); | |
| 155 | + $emit_header('Access-Control-Allow-Methods: GET, POST, OPTIONS'); | |
| 156 | + $emit_header('Access-Control-Allow-Headers: ' . $allow_headers); | |
| 157 | + | |
| 158 | + $request_server = $server === [] ? $_SERVER : $server; | |
| 159 | + $method = isset($request_server['REQUEST_METHOD']) ? (string) $request_server['REQUEST_METHOD'] : ''; | |
| 160 | + if (strtoupper($method) !== 'OPTIONS') { | |
| 161 | + return; | |
| 162 | + } | |
| 163 | + | |
| 164 | + $emit_header('Allow: GET, POST, OPTIONS'); | |
| 165 | + $terminate(); | |
| 166 | + } | |
| 167 | + | |
| 168 | + /** | |
| 169 | + * One-call convenience entry point: loads export.php, constructs | |
| 170 | + * the server, and dispatches the current request. | |
| 171 | + * | |
| 172 | + * Equivalent to: | |
| 173 | + * | |
| 174 | + * require_once __DIR__ . '/export.php'; | |
| 175 | + * $server = new Site_Export_HTTP_Server($options); | |
| 176 | + * $server->handle_request(); | |
| 177 | + * | |
| 178 | + * export.php is only required once. Callers that need to run CORS | |
| 179 | + * or their own authentication must do that before calling this method. | |
| 180 | + * | |
| 181 | + * @param array<string, mixed> $options Forwarded to the constructor. | |
| 182 | + * @throws Exception If request parsing or endpoint dispatch fails. | |
| 183 | + */ | |
| 184 | + public static function serve(array $options = []): void { | |
| 185 | + // endpoint_preflight is defined by export.php — use it as a | |
| 186 | + // cheap sentinel to detect whether the runtime is already | |
| 187 | + // loaded. require_once would be safe either way, but this | |
| 188 | + // avoids re-running the stat() on hot paths. | |
| 189 | + if (!function_exists('endpoint_preflight')) { | |
| 190 | + require_once __DIR__ . '/export.php'; | |
| 191 | + } | |
| 192 | + | |
| 193 | + $server = new self($options); | |
| 194 | + $server->handle_request(); | |
| 195 | + } | |
| 196 | + | |
| 197 | + /** | |
| 198 | + * @param array<string, mixed> $get | |
| 199 | + * @param array<string, mixed> $post | |
| 200 | + * @param array<string, mixed> $server | |
| 201 | + * @return array<string, mixed> | |
| 202 | + */ | |
| 203 | + public function parse_http_config(array $get = [], array $post = [], array $server = [], string $body = ''): array { | |
| 204 | + $config = []; | |
| 205 | + $params = array_merge($get, $post); | |
| 206 | + | |
| 207 | + $content_type = $server['CONTENT_TYPE'] ?? ''; | |
| 208 | + $content_type_main = strtolower(trim((string) strtok((string) $content_type, ';'))); | |
| 209 | + if ($content_type_main === 'application/json' && $body !== '') { | |
| 210 | + $json_data = json_decode($body, true); | |
| 211 | + if (is_array($json_data)) { | |
| 212 | + $params = array_merge($json_data, $params); | |
| 213 | + } | |
| 214 | + } | |
| 215 | + | |
| 216 | + foreach ($params as $key => $value) { | |
| 217 | + $key = str_replace('-', '_', (string) $key); | |
| 218 | + | |
| 219 | + if ( | |
| 220 | + in_array($key, [ | |
| 221 | + 'max_execution_time', | |
| 222 | + 'min_ctime', | |
| 223 | + 'chunk_size', | |
| 224 | + 'fragments_per_batch', | |
| 225 | + 'batch_size', | |
| 226 | + 'db_query_time_limit', | |
| 227 | + 'tables_per_batch', | |
| 228 | + ], true) | |
| 229 | + ) { | |
| 230 | + $value = (int) $value; | |
| 231 | + } elseif (in_array($key, ['memory_threshold'], true)) { | |
| 232 | + $value = (float) $value; | |
| 233 | + } elseif (in_array($key, ['create_table_query', 'db_unbuffered', 'follow_symlinks'], true)) { | |
| 234 | + $value = filter_var($value, FILTER_VALIDATE_BOOLEAN); | |
| 235 | + } elseif ($key === 'paths' && is_string($value)) { | |
| 236 | + $decoded = json_decode($value, true); | |
| 237 | + if (is_array($decoded)) { | |
| 238 | + $value = $decoded; | |
| 239 | + } | |
| 240 | + } | |
| 241 | + | |
| 242 | + $config[$key] = $value; | |
| 243 | + } | |
| 244 | + | |
| 245 | + return $config; | |
| 246 | + } | |
| 247 | + | |
| 248 | + /** | |
| 249 | + * @param array<string, mixed> $config | |
| 250 | + * @param array<string, mixed> $server | |
| 251 | + * @return array<string, mixed> | |
| 252 | + */ | |
| 253 | + public function normalize_config(array $config, array $server = []): array { | |
| 254 | + if ( | |
| 255 | + $this->default_directory !== null && | |
| 256 | + !isset($config['directory']) | |
| 257 | + ) { | |
| 258 | + $config['directory'] = $this->default_directory; | |
| 259 | + } | |
| 260 | + | |
| 261 | + if (!isset($config['cursor']) && isset($server[$this->cursor_header_name])) { | |
| 262 | + $config['cursor'] = $server[$this->cursor_header_name]; | |
| 263 | + } | |
| 264 | + | |
| 265 | + if (isset($config['cursor']) && $config['cursor'] !== '' && $config['cursor'] !== null) { | |
| 266 | + $config['cursor'] = $this->decode_cursor((string) $config['cursor']); | |
| 267 | + } | |
| 268 | + | |
| 269 | + $endpoint = $config['endpoint'] ?? null; | |
| 270 | + if (!is_string($endpoint) || $endpoint === '') { | |
| 271 | + throw new InvalidArgumentException( | |
| 272 | + "endpoint parameter is required. Valid endpoints: " . $this->get_valid_endpoints_message() | |
| 273 | + ); | |
| 274 | + } | |
| 275 | + | |
| 276 | + return $config; | |
| 277 | + } | |
| 278 | + | |
| 279 | + public function decode_cursor(string $cursor_b64): string { | |
| 280 | + $cursor_json = base64_decode($cursor_b64, true); | |
| 281 | + if ($cursor_json === false) { | |
| 282 | + throw new InvalidArgumentException( | |
| 283 | + 'Cursor must be base64-encoded. Received invalid base64: ' . substr($cursor_b64, 0, 50) | |
| 284 | + ); | |
| 285 | + } | |
| 286 | + | |
| 287 | + $cursor_data = json_decode($cursor_json, true); | |
| 288 | + if ($cursor_data === null && json_last_error() !== JSON_ERROR_NONE) { | |
| 289 | + throw new InvalidArgumentException( | |
| 290 | + 'Cursor must be valid JSON after base64 decoding. JSON error: ' . json_last_error_msg() | |
| 291 | + ); | |
| 292 | + } | |
| 293 | + | |
| 294 | + return $cursor_json; | |
| 295 | + } | |
| 296 | + | |
| 297 | + /** | |
| 298 | + * @param array<string, mixed> $config | |
| 299 | + * @return mixed | |
| 300 | + */ | |
| 301 | + public function create_resource_budget(array $config) { | |
| 302 | + return call_user_func($this->budget_factory, $config); | |
| 303 | + } | |
| 304 | + | |
| 305 | + /** | |
| 306 | + * @param array<string, mixed> $config | |
| 307 | + * @param mixed $budget | |
| 308 | + */ | |
| 309 | + public function dispatch(array $config, $budget = null): void { | |
| 310 | + $endpoint = $config['endpoint'] ?? null; | |
| 311 | + if (!is_string($endpoint) || $endpoint === '') { | |
| 312 | + throw new InvalidArgumentException( | |
| 313 | + "endpoint parameter is required. Valid endpoints: " . $this->get_valid_endpoints_message() | |
| 314 | + ); | |
| 315 | + } | |
| 316 | + | |
| 317 | + if (!isset($this->handlers[$endpoint])) { | |
| 318 | + throw new InvalidArgumentException( | |
| 319 | + "Invalid endpoint: '{$endpoint}'. Valid endpoints: " . $this->get_valid_endpoints_message() | |
| 320 | + ); | |
| 321 | + } | |
| 322 | + | |
| 323 | + $handler = $this->handlers[$endpoint]; | |
| 324 | + if (self::is_push_endpoint($endpoint)) { | |
| 325 | + if (PHP_VERSION_ID < 70200) { | |
| 326 | + http_response_code(503); | |
| 327 | + header('Cache-Control: no-store, no-cache, must-revalidate, max-age=0'); | |
| 328 | + header('Pragma: no-cache'); | |
| 329 | + header('Expires: 0'); | |
| 330 | + header('Content-Type: application/json'); | |
| 331 | + echo json_encode([ | |
| 332 | + 'status' => 'rejected', | |
| 333 | + 'reason' => 'push_disabled', | |
| 334 | + 'detail' => 'Push endpoints require PHP 7.2 or newer; observed PHP ' . PHP_VERSION . '.', | |
| 335 | + ]); | |
| 336 | + return; | |
| 337 | + } | |
| 338 | + call_user_func($handler, $config); | |
| 339 | + return; | |
| 340 | + } | |
| 341 | + if ($endpoint === 'preflight') { | |
| 342 | + call_user_func($handler, $config); | |
| 343 | + return; | |
| 344 | + } | |
| 345 | + | |
| 346 | + if ($budget === null) { | |
| 347 | + $budget = $this->create_resource_budget($config); | |
| 348 | + } | |
| 349 | + | |
| 350 | + call_user_func($handler, $config, $budget); | |
| 351 | + } | |
| 352 | + | |
| 353 | + public static function is_push_endpoint(string $endpoint): bool { | |
| 354 | + $push_endpoint_methods = self::PUSH_ENDPOINT_METHODS; | |
| 355 | + return isset($push_endpoint_methods[$endpoint]); | |
| 356 | + } | |
| 357 | + | |
| 358 | + /** | |
| 359 | + * @return array<string, callable> | |
| 360 | + */ | |
| 361 | + private function default_handlers(): array { | |
| 362 | + $handlers = [ | |
| 363 | + 'file_index' => 'endpoint_file_index', | |
| 364 | + 'file_fetch' => 'endpoint_file_fetch', | |
| 365 | + 'sql_chunk' => 'endpoint_sql_chunk', | |
| 366 | + 'db_index' => 'endpoint_db_index', | |
| 367 | + 'preflight' => 'endpoint_preflight', | |
| 368 | + ]; | |
| 369 | + if ($this->push_endpoints !== null) { | |
| 370 | + foreach (self::PUSH_ENDPOINT_METHODS as $endpoint => $method) { | |
| 371 | + $handlers[$endpoint] = [$this->push_endpoints, $method]; | |
| 372 | + } | |
| 373 | + } | |
| 374 | + return $handlers; | |
| 375 | + } | |
| 376 | + | |
| 377 | + private function is_json_content_type(array $server): bool { | |
| 378 | + $content_type = (string) ( $server['CONTENT_TYPE'] ?? '' ); | |
| 379 | + return strtolower(trim( (string) strtok($content_type, ';'))) === 'application/json'; | |
| 380 | + } | |
| 381 | + | |
| 382 | + /** | |
| 383 | + * @param array<string, mixed> $config | |
| 384 | + * @return mixed | |
| 385 | + */ | |
| 386 | + private function default_budget_factory(array $config) { | |
| 387 | + $max_execution_time = require_int_range( | |
| 388 | + 'max_execution_time', | |
| 389 | + (int) ($config['max_execution_time'] ?? 5), | |
| 390 | + 1, | |
| 391 | + 60 | |
| 392 | + ); | |
| 393 | + $memory_threshold = require_float_range( | |
| 394 | + 'memory_threshold', | |
| 395 | + (float) ($config['memory_threshold'] ?? 0.8), | |
| 396 | + 0.1, | |
| 397 | + 0.95 | |
| 398 | + ); | |
| 399 | + | |
| 400 | + $memory_limit = ini_get('memory_limit'); | |
| 401 | + $max_memory = $memory_limit === '-1' ? PHP_INT_MAX : parse_size((string) $memory_limit); | |
| 402 | + | |
| 403 | + return new ResourceBudget( | |
| 404 | + microtime(true), | |
| 405 | + $max_execution_time, | |
| 406 | + $max_memory, | |
| 407 | + $memory_threshold | |
| 408 | + ); | |
| 409 | + } | |
| 410 | + | |
| 411 | + private function get_valid_endpoints_message(): string { | |
| 412 | + return "'" . implode("', '", array_keys($this->handlers)) . "'"; | |
| 413 | + } | |
| 414 | +} | |