PluginProbe
Jetpack – WP Security, Backup, Speed, & Growth / 16.3-a.1
Jetpack – WP Security, Backup, Speed, & Growth v16.3-a.1
16.3-a.1 16.2 16.2-beta 12.0.3 12.1.3 12.2.3 12.3.2 12.4.2 12.5.2 12.6.4 12.7.3 12.8.3 12.9.5 13.0.2 13.1.5 13.2.4 13.3.3 13.4.5 13.5.2 13.6.2 13.7.2 13.8.3 13.9.2 14.0.1 14.1.1 All 503 releases
jetpack / vendor / wp-php-toolkit / reprint-server / src / class-http-server.php

class-http-server.php in Jetpack – WP Security, Backup, Speed, & Growth 16.3-a.1, at vendor/wp-php-toolkit/reprint-server/src/class-http-server.php

415 lines 15.0 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
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 }
415