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-push-endpoints.php

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

613 lines 26.0 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3 // phpcs:disable WordPress.Security.EscapeOutput.ExceptionNotEscaped,WordPress.Security.EscapeOutput.OutputNotEscaped -- Authenticated protocol responses are JSON, never HTML output.
4 // phpcs:disable WordPress.Security.ValidatedSanitizedInput -- Multipart parsing and request-body byte accounting require the request bytes unchanged.
5
6 use function WordPress\Reprint\Server\assert_valid_path;
7 use function WordPress\Reprint\Server\normalize_excluded_paths;
8 use function WordPress\Reprint\Server\normalize_path;
9 use function WordPress\Reprint\Server\parse_size;
10 use function WordPress\Reprint\Server\path_is_same_as_or_descendant_of;
11 use function WordPress\Reprint\Server\realpath_with_missing_tail;
12
13 require_once __DIR__ . '/utils.php';
14
15 /**
16 * Exposes push-session operations through the exporter HTTP dispatcher.
17 *
18 * The endpoint configuration is supplied by the server, not by request
19 * parameters. It fixes the reprint directory, document root, excluded paths,
20 * maximum multipart part size, and bounded commit work for every request.
21 * Authentication happens in the embedding router before these methods run.
22 *
23 * Upload requests pass php://input directly to Site_Export_Push_Session. The
24 * endpoint retains only the latest accepted change while the receiver reads
25 * each multipart part in bounded fragments; it never buffers the request body
26 * or a list of all parts.
27 */
28 final class Site_Export_Push_Endpoints {
29
30 private const DEFAULT_MAXIMUM_PART_BYTES = 4194304;
31 private const DEFAULT_MAXIMUM_COMMIT_ENTRIES = 256;
32
33 /** @var string */
34 private $reprint_directory;
35
36 /** @var string */
37 private $docroot;
38
39 /** @var list<string> */
40 private $excluded_paths;
41
42 /** @var int */
43 private $maximum_part_bytes;
44
45 /** @var int */
46 private $maximum_commit_entries;
47
48 /** @var string|null */
49 private $commit_start_denial_detail;
50
51 /** @var int|null */
52 private $post_max_bytes;
53
54 /**
55 * Stores the server-supplied push directories, path policy, and limits.
56 *
57 * Missing numeric options use bounded defaults. Present numeric options
58 * must be positive so a malformed server configuration cannot silently
59 * change the protocol limit.
60 *
61 * @param array $options {
62 * Trusted endpoint configuration.
63 *
64 * @type string $reprint_directory Required private reprint directory.
65 * @type string $docroot Required document-root directory.
66 * @type list<string> $excluded_paths Required document-root-relative
67 * paths which push must preserve.
68 * @type int|float|string $maximum_part_bytes Maximum Content-Length
69 * accepted for one multipart part. Default 4 MiB.
70 * @type int|float|string $maximum_commit_entries Maximum entries one
71 * commit request may process. Default 256.
72 * @type string $commit_start_denial_detail When present, starting commit
73 * returns `push_disabled` with this detail. A commit with a durable
74 * checkpoint remains recoverable.
75 * @type int|float|string|null $post_max_bytes Decoded request-body
76 * limit enforced and reported to senders. Defaults to PHP's
77 * post_max_size; null means PHP reports no positive limit.
78 * }
79 * @phpstan-param array{
80 * reprint_directory?:mixed,
81 * docroot?:mixed,
82 * excluded_paths?:mixed,
83 * maximum_part_bytes?:mixed,
84 * maximum_commit_entries?:mixed,
85 * commit_start_denial_detail?:mixed,
86 * post_max_bytes?:mixed
87 * } $options
88 *
89 * @throws InvalidArgumentException If required configuration is absent, a
90 * present numeric limit is not positive, or a commit-start denial detail
91 * is not a non-empty string.
92 */
93 public function __construct(array $options) {
94 $reprint_directory = $options['reprint_directory'] ?? null;
95 $docroot = $options['docroot'] ?? null;
96 $excluded_paths = $options['excluded_paths'] ?? null;
97 if (!is_string($reprint_directory) || $reprint_directory === '') {
98 throw new InvalidArgumentException('Push endpoints require a non-empty reprint_directory option.');
99 }
100 if (!is_string($docroot) || $docroot === '') {
101 throw new InvalidArgumentException('Push endpoints require a non-empty docroot option.');
102 }
103 if (!is_array($excluded_paths)) {
104 throw new InvalidArgumentException('Push endpoints require an excluded_paths array.');
105 }
106 $excluded_paths = normalize_excluded_paths($excluded_paths);
107
108 assert_valid_path($reprint_directory, 'Push endpoint reprint_directory');
109 assert_valid_path($docroot, 'Push endpoint docroot');
110 $canonical_reprint_directory = realpath_with_missing_tail(
111 $reprint_directory
112 );
113 $canonical_docroot = realpath($docroot);
114 if ($canonical_docroot === false) {
115 $canonical_docroot = normalize_path($docroot);
116 }
117 if (path_is_same_as_or_descendant_of($canonical_reprint_directory, $canonical_docroot)) {
118 throw new InvalidArgumentException(
119 'Push endpoints require reprint_directory ' . json_encode($reprint_directory)
120 . ' to be outside docroot ' . json_encode($docroot) . '; observed it inside that document root.'
121 );
122 }
123
124 $maximum_part_bytes = $options['maximum_part_bytes'] ?? self::DEFAULT_MAXIMUM_PART_BYTES;
125 $maximum_commit_entries = $options['maximum_commit_entries'] ?? self::DEFAULT_MAXIMUM_COMMIT_ENTRIES;
126 if (!is_numeric($maximum_part_bytes) || (int) $maximum_part_bytes <= 0) {
127 throw new InvalidArgumentException('maximum_part_bytes must be a positive integer.');
128 }
129 if (!is_numeric($maximum_commit_entries) || (int) $maximum_commit_entries <= 0) {
130 throw new InvalidArgumentException('maximum_commit_entries must be a positive integer.');
131 }
132
133 if (array_key_exists('post_max_bytes', $options)) {
134 $post_max_bytes = $options['post_max_bytes'];
135 if ($post_max_bytes !== null && ( !is_numeric($post_max_bytes) || (int) $post_max_bytes <= 0 )) {
136 throw new InvalidArgumentException('post_max_bytes must be null or a positive integer.');
137 }
138 $this->post_max_bytes = $post_max_bytes === null ? null : (int) $post_max_bytes;
139 } else {
140 $post_max_size = ini_get('post_max_size');
141 $parsed_post_max_bytes = is_string($post_max_size) && $post_max_size !== ''
142 ? parse_size($post_max_size)
143 : 0;
144 $this->post_max_bytes = $parsed_post_max_bytes > 0 ? $parsed_post_max_bytes : null;
145 }
146
147 $this->reprint_directory = $reprint_directory;
148 $this->docroot = $docroot;
149 $this->excluded_paths = $excluded_paths;
150 $this->maximum_part_bytes = (int) $maximum_part_bytes;
151 $this->maximum_commit_entries = (int) $maximum_commit_entries;
152 $this->commit_start_denial_detail = null;
153 if (array_key_exists('commit_start_denial_detail', $options)) {
154 if (!is_string($options['commit_start_denial_detail']) || $options['commit_start_denial_detail'] === '') {
155 throw new InvalidArgumentException('commit_start_denial_detail must be a non-empty string.');
156 }
157 $this->commit_start_denial_detail = $options['commit_start_denial_detail'];
158 }
159 }
160
161 /**
162 * Creates or reopens the caller-named push session.
163 *
164 * This operation is idempotent for the same push session ID and immutable
165 * server configuration. A successful response reports the independent
166 * multipart-part and decoded request-body limits the sender must apply,
167 * plus the normalized excluded paths stored in push metadata.
168 *
169 * The HTTP 200 response has these fields:
170 *
171 * - `status`: `created`.
172 * - `push_session_id`: the caller-supplied ID.
173 * - `max_part_bytes`: the maximum multipart-part Content-Length.
174 * - `post_max_bytes`: the decoded request-body limit, or null.
175 * - `excluded_paths_b64`: the stored excluded paths in base64.
176 *
177 * An HTTP 409 `commit_required` response also contains
178 * `blocking_push_session_id`, which names the commit that must finish first.
179 *
180 * @param array $config {
181 * Create request parameters.
182 *
183 * @type string $push_session_id Caller-named push session ID.
184 * }
185 * @phpstan-param array<string,mixed> $config
186 */
187 public function create(array $config): void {
188 try {
189 $this->assert_request_method('POST');
190 $push_session_id = $this->read_push_session_id($config);
191 Site_Export_Push_Session::create(
192 $this->reprint_directory,
193 $this->docroot,
194 $this->excluded_paths,
195 $push_session_id
196 );
197 $this->respond(200, [
198 'status' => 'created',
199 'push_session_id' => $push_session_id,
200 'max_part_bytes' => $this->maximum_part_bytes,
201 'post_max_bytes' => $this->post_max_bytes,
202 'excluded_paths_b64' => array_map('base64_encode', $this->excluded_paths),
203 ]);
204 } catch (Throwable $exception) {
205 $this->respond_to_failure($exception);
206 }
207 }
208
209 /**
210 * Streams one multipart request into an existing push session.
211 *
212 * The request must be POST multipart/mixed with one valid boundary. Each
213 * part is accepted before the next part is read. The response retains only
214 * the latest receiver-confirmed change, so response memory does not grow
215 * with the number of parts in the request.
216 *
217 * The HTTP 200 response has `status`, `push_session_id`,
218 * `changes_accepted`, and `last_change`. An empty request reports null for
219 * `last_change`. Otherwise `last_change` contains:
220 *
221 * - `state`: `partial` or `complete`.
222 * - `type`: `file`, `directory`, `symlink`, or `delete-list`.
223 * - `accepted_bytes`: receiver-confirmed bytes; zero for directories and
224 * symlinks.
225 * - `path_b64`: the base64 path for files, directories, and symlinks. A
226 * delete-list change has no path.
227 *
228 * @param array $config {
229 * Upload request parameters.
230 *
231 * @type string $push_session_id Existing push session ID.
232 * }
233 * @phpstan-param array<string,mixed> $config
234 */
235 public function upload(array $config): void {
236 $input = null;
237 $push_session = null;
238 $upload_open = false;
239 try {
240 $this->assert_request_method('POST');
241 $push_session_id = $this->read_push_session_id($config);
242 $content_type = (string) ( $_SERVER['CONTENT_TYPE'] ?? '' );
243 $boundary = Site_Export_Multipart_Processor::boundary_from_content_type($content_type);
244 $declared_request_bytes = $_SERVER['CONTENT_LENGTH'] ?? null;
245 if (
246 $this->post_max_bytes !== null
247 && is_numeric($declared_request_bytes)
248 && (int) $declared_request_bytes > $this->post_max_bytes
249 ) {
250 $this->respond(413, [
251 'status' => 'rejected',
252 'reason' => 'request_too_large',
253 'detail' => 'The decoded request body declares ' . (int) $declared_request_bytes
254 . ' bytes, exceeding the target post_max_size of ' . $this->post_max_bytes . ' bytes.',
255 'post_max_bytes' => $this->post_max_bytes,
256 ]);
257 return;
258 }
259 $input = fopen('php://input', 'rb');
260 if ($input === false) {
261 throw new Site_Export_Push_Exception(
262 Site_Export_Push_Session::ERROR_FILESYSTEM,
263 'Could not open the multipart upload request body.'
264 );
265 }
266 $push_session = Site_Export_Push_Session::open(
267 $this->reprint_directory,
268 $this->docroot,
269 $push_session_id,
270 $this->excluded_paths
271 );
272 $push_session->accept_upload(
273 $input,
274 new Site_Export_Multipart_Processor($boundary),
275 $this->maximum_part_bytes,
276 $this->post_max_bytes ?? PHP_INT_MAX
277 );
278 $upload_open = true;
279 $changes_accepted = 0;
280 $last_change = null;
281 while ($push_session->next_change()) {
282 ++$changes_accepted;
283 $current_change = $push_session->get_current_change();
284 if ($current_change === null) {
285 throw new LogicException('An accepted multipart part did not set its receiver-confirmed change.');
286 }
287 $last_change = [];
288 if (array_key_exists('path_b64', $current_change)) {
289 $last_change['path_b64'] = $current_change['path_b64'];
290 }
291 $last_change['state'] = $current_change['state'];
292 $last_change['type'] = $current_change['type'];
293 $last_change['accepted_bytes'] = $current_change['accepted_bytes'];
294 }
295 $push_session->finish_upload();
296 $upload_open = false;
297 fclose($input);
298 $input = null;
299 $this->respond(200, [
300 'status' => 'accepted',
301 'push_session_id' => $push_session_id,
302 'changes_accepted' => $changes_accepted,
303 'last_change' => $last_change,
304 ]);
305 } catch (Throwable $exception) {
306 if ($upload_open && $push_session instanceof Site_Export_Push_Session) {
307 $push_session->finish_upload();
308 }
309 if (is_resource($input)) {
310 fclose($input);
311 }
312 $this->respond_to_failure($exception);
313 }
314 }
315
316 /**
317 * Reports receiver-confirmed progress for a push session and optional path.
318 *
319 * `path_b64`, when supplied, is strict base64 for the raw document-root-
320 * relative path. The receiver reads actual work state under the push lock;
321 * it never echoes a sender-supplied cursor.
322 *
323 * The HTTP 200 response has these fields:
324 *
325 * - `status`: `accepted`.
326 * - `push_session_id`: the requested push session.
327 * - `phase`: `receiving_work`, `deleting_files`, `installing_files`, or
328 * `complete`.
329 * - `work_deletes_bytes`: receiver-confirmed delete-list bytes.
330 * - `work_deletes_complete`: whether the sender closed the delete list.
331 * - `path`: null when no path was requested. Otherwise it contains
332 * `path_b64`, `state`, and `accepted_bytes`; non-missing paths also have
333 * `type`.
334 *
335 * @param array $config {
336 * Status request parameters.
337 *
338 * @type string $push_session_id Existing push session ID.
339 * @type string $path_b64 Optional. Base64-encoded path whose
340 * receiver-confirmed state should be reported.
341 * }
342 * @phpstan-param array<string,mixed> $config
343 */
344 public function status(array $config): void {
345 try {
346 $this->assert_request_method('GET');
347 $push_session_id = $this->read_push_session_id($config);
348 $path = null;
349 if (array_key_exists('path_b64', $config)) {
350 if (!is_string($config['path_b64'])) {
351 throw new InvalidArgumentException('path_b64 must be base64 text.');
352 }
353 $path = base64_decode($config['path_b64'], true);
354 if ($path === false) {
355 throw new InvalidArgumentException('path_b64 must be valid base64 text.');
356 }
357 }
358 $push_session = Site_Export_Push_Session::open(
359 $this->reprint_directory,
360 $this->docroot,
361 $push_session_id,
362 $this->excluded_paths
363 );
364 $push_status = $push_session->get_status($path);
365 $path_status = null;
366 if ($push_status['path'] !== null) {
367 $path_status = [
368 'path_b64' => $push_status['path']['path_b64'],
369 'state' => $push_status['path']['state'],
370 ];
371 if (array_key_exists('type', $push_status['path'])) {
372 $path_status['type'] = $push_status['path']['type'];
373 }
374 $path_status['accepted_bytes'] = $push_status['path']['accepted_bytes'];
375 }
376 $this->respond(200, [
377 'status' => 'accepted',
378 'push_session_id' => $push_session_id,
379 'phase' => $push_status['phase'],
380 'work_deletes_bytes' => $push_status['work_deletes_bytes'],
381 'work_deletes_complete' => $push_status['work_deletes_complete'],
382 'path' => $path_status,
383 ]);
384 } catch (Throwable $exception) {
385 $this->respond_to_failure($exception);
386 }
387 }
388
389 /**
390 * Advances bounded commit work for one push session.
391 *
392 * The configured maximum commit-entry count bounds each call. Senders repeat this
393 * operation while `send_next_request` is true; a repeated request after an
394 * indeterminate response resumes the receiver's durable commit checkpoint.
395 *
396 * The HTTP 200 response has `status`, `push_session_id`, `phase`,
397 * `send_next_request`, and `entries_processed`. `phase` is
398 * `deleting_files`, `installing_files`, or `complete`.
399 *
400 * @param array $config {
401 * Commit request parameters.
402 *
403 * @type string $push_session_id Existing push session ID.
404 * }
405 * @phpstan-param array<string,mixed> $config
406 */
407 public function commit(array $config): void {
408 try {
409 $this->assert_request_method('POST');
410 $push_session_id = $this->read_push_session_id($config);
411 $push_session = Site_Export_Push_Session::open(
412 $this->reprint_directory,
413 $this->docroot,
414 $push_session_id,
415 $this->excluded_paths
416 );
417 $commit = $push_session->commit(
418 $this->maximum_commit_entries,
419 $this->commit_start_denial_detail
420 );
421 $this->respond(200, [
422 'status' => 'accepted',
423 'push_session_id' => $push_session_id,
424 'phase' => $commit['phase'],
425 'send_next_request' => $commit['send_next_request'],
426 'entries_processed' => $commit['entries_processed'],
427 ]);
428 } catch (Throwable $exception) {
429 if (
430 $this->commit_start_denial_detail !== null
431 && $exception instanceof Site_Export_Push_Exception
432 && $exception->get_error_code() === Site_Export_Push_Session::ERROR_PUSH_NOT_FOUND
433 ) {
434 $this->respond(403, [
435 'status' => 'rejected',
436 'reason' => Site_Export_Push_Session::ERROR_PUSH_DISABLED,
437 'detail' => $this->commit_start_denial_detail,
438 ]);
439 return;
440 }
441 $this->respond_to_failure($exception);
442 }
443 }
444
445 /**
446 * Performs one bounded removal step for a push session.
447 *
448 * A false `removed` value means the sender must call this operation again.
449 * Repeating the operation after a lost response is safe: a missing push
450 * directory and completed removal tombstone report true.
451 *
452 * The HTTP 200 response has `status`, `push_session_id`, and `removed`.
453 *
454 * @param array $config {
455 * Remove request parameters.
456 *
457 * @type string $push_session_id Existing push session ID.
458 * }
459 * @phpstan-param array<string,mixed> $config
460 */
461 public function remove(array $config): void {
462 try {
463 $this->assert_request_method('POST');
464 $push_session_id = $this->read_push_session_id($config);
465 $remove_complete = Site_Export_Push_Session::remove(
466 $this->reprint_directory,
467 $this->docroot,
468 $push_session_id,
469 $this->excluded_paths
470 );
471 $this->respond(200, [
472 'status' => 'accepted',
473 'push_session_id' => $push_session_id,
474 'removed' => $remove_complete,
475 ]);
476 } catch (Throwable $exception) {
477 $this->respond_to_failure($exception);
478 }
479 }
480
481 /**
482 * Rejects a request whose HTTP method does not match the endpoint.
483 *
484 * @param string $expected_method Uppercase protocol method.
485 *
486 * @throws InvalidArgumentException If the current request uses another method.
487 */
488 private function assert_request_method(string $expected_method): void {
489 $observed_method = strtoupper( (string) ( $_SERVER['REQUEST_METHOD'] ?? '' ));
490 if ($observed_method !== $expected_method) {
491 throw new InvalidArgumentException(
492 'Push endpoint requires ' . $expected_method . '; observed ' . ( $observed_method === '' ? 'no request method' : $observed_method ) . '.'
493 );
494 }
495 }
496
497 /**
498 * Reads the required push session identity from request parameters.
499 *
500 * Site_Export_Push_Session performs the canonical 32-character lowercase
501 * hexadecimal grammar check. This method only rejects missing or non-string
502 * values before a factory method is selected.
503 *
504 * @param array $config {
505 * Endpoint request parameters.
506 *
507 * @type string $push_session_id Caller-supplied push session ID.
508 * }
509 * @phpstan-param array<string,mixed> $config
510 * @return string Caller-supplied push session ID.
511 *
512 * @throws InvalidArgumentException If `push_session_id` is not a string.
513 */
514 private function read_push_session_id(array $config): string {
515 $push_session_id = $config['push_session_id'] ?? null;
516 if (!is_string($push_session_id)) {
517 throw new InvalidArgumentException('push_session_id must be a string.');
518 }
519 return $push_session_id;
520 }
521
522 /**
523 * Maps a push exception onto its stable protocol reason and HTTP status.
524 *
525 * The response owns its public fields instead of copying an exception's
526 * internal context. A streamed request-size rejection deliberately exposes
527 * its observed decoded byte count. Invalid or malformed request data
528 * receives `invalid_request`; unexpected failures receive
529 * `filesystem_error` without exposing a PHP trace.
530 *
531 * @param Throwable $exception Failure raised while handling an endpoint.
532 */
533 private function respond_to_failure(Throwable $exception): void {
534 if ($exception instanceof Site_Export_Push_Exception) {
535 $reason = $exception->get_error_code();
536 $http_code = 409;
537 if ($reason === Site_Export_Push_Session::ERROR_PUSH_NOT_FOUND) {
538 $http_code = 404;
539 } elseif ($reason === Site_Export_Push_Session::ERROR_PUSH_DISABLED) {
540 $http_code = 403;
541 } elseif ($reason === Site_Export_Push_Session::ERROR_LOCK_ACQUISITION_FAILURE) {
542 $http_code = 423;
543 } elseif ($reason === Site_Export_Push_Session::ERROR_REQUEST_TOO_LARGE) {
544 $http_code = 413;
545 } elseif (
546 $reason === Site_Export_Push_Session::ERROR_FILESYSTEM
547 || $reason === Site_Export_Push_Session::ERROR_CORRUPTED_PUSH_STATE
548 ) {
549 $http_code = 500;
550 }
551 $response = [
552 'status' => 'rejected',
553 'reason' => $reason,
554 'detail' => $exception->getMessage(),
555 ];
556 if ($reason === Site_Export_Push_Session::ERROR_COMMIT_REQUIRED) {
557 $context = $exception->get_context();
558 if (is_string($context['blocking_push_session_id'] ?? null)) {
559 $response['blocking_push_session_id'] = $context['blocking_push_session_id'];
560 }
561 }
562 if ($reason === Site_Export_Push_Session::ERROR_REQUEST_TOO_LARGE) {
563 $context = $exception->get_context();
564 if (is_int($context['observed_request_body_bytes'] ?? null)) {
565 $response['observed_request_body_bytes'] = $context['observed_request_body_bytes'];
566 }
567 $response['post_max_bytes'] = $this->post_max_bytes;
568 }
569 $this->respond($http_code, $response);
570 return;
571 }
572 if (
573 $exception instanceof InvalidArgumentException
574 || $exception instanceof RuntimeException
575 ) {
576 $this->respond(400, [
577 'status' => 'rejected',
578 'reason' => 'invalid_request',
579 'detail' => $exception->getMessage(),
580 ]);
581 return;
582 }
583 $this->respond(500, [
584 'status' => 'rejected',
585 'reason' => Site_Export_Push_Session::ERROR_FILESYSTEM,
586 'detail' => 'The push endpoint failed while processing the request.',
587 ]);
588 }
589
590 /**
591 * Emits one complete JSON protocol response.
592 *
593 * @param int $http_code HTTP status code.
594 * @param array $body Exact endpoint-specific response object whose keys
595 * are documented by the calling endpoint.
596 * @phpstan-param array<string,mixed> $body
597 */
598 private function respond(int $http_code, array $body): void {
599 http_response_code($http_code);
600 header('Cache-Control: no-store, no-cache, must-revalidate, max-age=0');
601 header('Pragma: no-cache');
602 header('Expires: 0');
603 header('Content-Type: application/json');
604 $json = json_encode($body);
605 if ($json === false) {
606 http_response_code(500);
607 echo '{"status":"rejected","reason":"filesystem_error","detail":"Could not encode the push response."}';
608 return;
609 }
610 echo $json;
611 }
612 }
613