PluginProbe
Jetpack – WP Security, Backup, Speed, & Growth / 16.3
Jetpack – WP Security, Backup, Speed, & Growth v16.3
16.3 16.3-beta 16.3-a.5 16.3-a.7 16.3-a.3 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 All 508 releases
← All changes | vendor/wp-php-toolkit/reprint-server/src/class-push-endpoints.php +612 -0 16.2-beta → 16.3 View file →
@@ -1,0 +1,612 @@
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 +}