← 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 | +} | |