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/export.php +3635 -0 16.2-beta → 16.3 View file →
@@ -1,0 +1,3635 @@
1 +<?php
2 +/**
3 + * Unified export API for SQL and file operations.
4 + */
5 +
6 +use WordPress\Reprint\Server\FileIndexProcessor;
7 +use WordPress\Reprint\Server\FileTreeProducer;
8 +use WordPress\Reprint\Server\GzipOutputStream;
9 +use WordPress\Reprint\Server\MySQLDumpProducer;
10 +use WordPress\Reprint\Server\PdoConstants;
11 +use WordPress\Reprint\Server\ResourceBudget;
12 +use WordPress\Reprint\Server\SqliteDriverPDO;
13 +use WordPress\Reprint\Server\WpdbDriverPDO;
14 +
15 +use function WordPress\Reprint\Server\assert_valid_path;
16 +use function WordPress\Reprint\Server\build_pdo_dsn;
17 +use function WordPress\Reprint\Server\generate_random_bytes;
18 +use function WordPress\Reprint\Server\json_encode_or_throw;
19 +use function WordPress\Reprint\Server\normalize_path;
20 +use function WordPress\Reprint\Server\parse_size;
21 +use function WordPress\Reprint\Server\path_is_same_as_or_descendant_of;
22 +use function WordPress\Reprint\Server\trim_right_slash;
23 +use function WordPress\Reprint\Server\wp_join_unix_paths;
24 +
25 +require_once __DIR__ . '/utils.php';
26 +require_once __DIR__ . '/class-resource-budget.php';
27 +require_once __DIR__ . '/class-gzip-output-stream.php';
28 +require_once __DIR__ . '/class-file-index-processor.php';
29 +
30 +// Capture any accidental output before headers are set so we can discard it
31 +// when switching to streaming mode later.
32 +if (!ob_get_level()) {
33 + ob_start();
34 +}
35 +
36 +
37 +/**
38 + * The wire-protocol version this export plugin speaks.
39 + *
40 + * The export plugin and importer report this value during preflight so a
41 + * mismatched deployment fails before any content is transferred.
42 + *
43 + * EXPORT_PROTOCOL_VERSION is sent to the importer in the preflight JSON
44 + * response as `protocol_version`. Bump it whenever a change to the wire
45 + * protocol (cursor encoding, multipart structure, header names, endpoint
46 + * parameters, response format) would break an older importer.
47 + */
48 +define('EXPORT_PROTOCOL_VERSION', 3);
49 +
50 +// File type mask + file type values (top bits of st_mode)
51 +define('STAT_TYPE_MASK', 0170000);
52 +define('STAT_TYPE_SOCKET', 0140000);
53 +define('STAT_TYPE_LINK', 0120000);
54 +define('STAT_TYPE_FILE', 0100000);
55 +define('STAT_TYPE_BLOCK', 0060000);
56 +define('STAT_TYPE_DIR', 0040000);
57 +define('STAT_TYPE_CHAR', 0020000);
58 +define('STAT_TYPE_FIFO', 0010000);
59 +
60 +/**
61 + * Global streaming context. When set, the error handlers emit error chunks
62 + * into the active multipart stream instead of sending plain JSON, which would
63 + * corrupt either a compressed or uncompressed multipart response.
64 + *
65 + * Set by each streaming endpoint right after creating $gz and $boundary.
66 + * Keys: 'gz' => GzipOutputStream, 'boundary' => string
67 + */
68 +$streaming_context = null;
69 +
70 +/**
71 + * Initializes a multipart/mixed streaming response, optionally with gzip compression.
72 + *
73 + * Every streaming endpoint needs the same setup: a unique boundary, the
74 + * Content-Type header, an output stream, and the global $streaming_context so
75 + * error handlers can emit structured error chunks mid-stream.
76 + *
77 + * @param bool $require_headers If true, throws when headers were already sent
78 + * (use for endpoints that can't degrade gracefully).
79 + * @param bool $gzip If true, emit Content-Encoding: gzip and compress the body.
80 + * @return array {
81 + * Multipart stream context.
82 + *
83 + * @type GzipOutputStream $gz Output stream used by the response.
84 + * @type string $boundary MIME boundary for response parts.
85 + * }
86 + * @phpstan-return array{gz: GzipOutputStream, boundary: string}
87 + */
88 +function begin_multipart_stream(bool $require_headers = false, bool $gzip = true): array
89 +{
90 + global $streaming_context;
91 +
92 + /**
93 + * We're choosing a random boundary without checking for its presence in the content.
94 + * This may seem to contradict RFC 2046, where it says:
95 + *
96 + * > As stated previously, each body part is preceded by a boundary
97 + * > delimiter line that contains the boundary delimiter. The boundary
98 + * > delimiter MUST NOT appear inside any of the encapsulated parts, on a
99 + * > line by itself or as the prefix of any line. This implies that it is
100 + * > crucial that the composing agent be able to choose and specify a
101 + * > unique boundary parameter value that does not contain the boundary
102 + * > parameter value of an enclosing multipart as a prefix.
103 + * >
104 + * > https://www.rfc-editor.org/rfc/rfc2046.html
105 + *
106 + * But in practice, we're okay. We use 128 bits of randomness. The chance of
107 + * it appearing in the data is about 1 in 2^128 — effectively zero. Curl does
108 + * the same here:
109 + *
110 + * https://github.com/curl/curl/blob/462244447e8ba3a53b1ba9f0ba7baa52d8777daa/lib/mime.c#L1179-L1236
111 + *
112 + * Also, every chunk declares its Content-Length, so the client never needs
113 + * to search arbitrary body bytes for the boundary.
114 + */
115 + $boundary = "boundary-" . bin2hex(generate_random_bytes(16));
116 + $can_send_headers = !headers_sent();
117 +
118 + if ($require_headers && !$can_send_headers) {
119 + throw new RuntimeException(
120 + "Cannot begin multipart stream: headers already sent"
121 + );
122 + }
123 +
124 + if ($can_send_headers) {
125 + @header("Content-Type: multipart/mixed; boundary=\"$boundary\"");
126 + }
127 +
128 + $gz = new GzipOutputStream($can_send_headers && $gzip);
129 + $streaming_context = ['gz' => $gz, 'boundary' => $boundary];
130 +
131 + return $streaming_context;
132 +}
133 +
134 +/**
135 + * Resolves database credentials from PHP constants and environment variables.
136 + *
137 + * Never reads from $config / HTTP parameters — credentials must come from
138 + * the server environment (PHP constants or environment variables).
139 + *
140 + * @return array {
141 + * Database connection details resolved from the server environment.
142 + *
143 + * @type string $db_host Database host.
144 + * @type string $db_name Database name.
145 + * @type string $db_user Database user.
146 + * @type string $db_password Database password.
147 + * @type string|null $wp_config_path WordPress config path, if known.
148 + * @type string|null $table_prefix WordPress table prefix, if known.
149 + * }
150 + * @phpstan-return array{
151 + * db_host: string,
152 + * db_name: string,
153 + * db_user: string,
154 + * db_password: string,
155 + * wp_config_path: ?string,
156 + * table_prefix: ?string
157 + * }
158 + * @throws InvalidArgumentException When required credentials are missing.
159 + */
160 +function resolve_db_credentials(): array
161 +{
162 + $db_host = defined("DB_HOST") ? DB_HOST : getenv("DB_HOST");
163 + $db_name = defined("DB_NAME") ? DB_NAME : getenv("DB_NAME");
164 + $db_user = defined("DB_USER") ? DB_USER : getenv("DB_USER");
165 + $db_password = defined("DB_PASSWORD") ? DB_PASSWORD : getenv("DB_PASSWORD");
166 +
167 + global $wpdb;
168 +
169 + $wp_config_path = null;
170 + $table_prefix = null;
171 + if (isset($GLOBALS['table_prefix']) && is_string($GLOBALS['table_prefix']) && $GLOBALS['table_prefix'] !== '') {
172 + $table_prefix = $GLOBALS['table_prefix'];
173 + } elseif (isset($wpdb) && is_object($wpdb) && isset($wpdb->prefix) && is_string($wpdb->prefix) && $wpdb->prefix !== '') {
174 + $table_prefix = $wpdb->prefix;
175 + }
176 +
177 + // On SQLite sites, the driver is already loaded by WordPress via the
178 + // db.php drop-in. We just need to confirm it's available and skip the
179 + // MySQL credential requirements.
180 + if (is_sqlite_site()) {
181 + return [
182 + "db_engine" => "sqlite",
183 + "db_host" => "",
184 + "db_name" => $db_name ?: "wordpress",
185 + "db_user" => "",
186 + "db_password" => "",
187 + "wp_config_path" => $wp_config_path,
188 + "table_prefix" => $table_prefix,
189 + ];
190 + }
191 +
192 + $missing = [];
193 + if (!$db_host) { $missing[] = "db_host"; }
194 + if (!$db_name) { $missing[] = "db_name"; }
195 + if (!$db_user) { $missing[] = "db_user"; }
196 + if ($db_password === false || $db_password === null) {
197 + $missing[] = "db_password";
198 + }
199 + if (!empty($missing)) {
200 + throw new InvalidArgumentException(
201 + "Database credentials not found. Please provide via environment variables, " .
202 + "PHP constants, or ensure wp-config.php exists with valid credentials. " .
203 + "Missing: " . implode(", ", $missing)
204 + );
205 + }
206 +
207 + return [
208 + "db_engine" => "mysql",
209 + "db_host" => $db_host,
210 + "db_name" => $db_name,
211 + "db_user" => $db_user,
212 + "db_password" => $db_password,
213 + "wp_config_path" => $wp_config_path,
214 + "table_prefix" => $table_prefix,
215 + ];
216 +}
217 +
218 +/**
219 + * Returns true when the current WordPress site uses the SQLite backend.
220 + *
221 + * The sqlite-database-integration plugin's db.php drop-in defines
222 + * SQLITE_DB_DROPIN_VERSION and exposes its PDO handle through $GLOBALS['@pdo'].
223 + */
224 +function is_sqlite_site(): bool
225 +{
226 + // Connection setup checks which supported driver WordPress loaded.
227 + return defined('SQLITE_DB_DROPIN_VERSION') && isset($GLOBALS['@pdo']);
228 +}
229 +
230 +/**
231 + * Creates a database connection appropriate for the detected backend.
232 + *
233 + * For MySQL sites, returns a standard PDO connection, falling back to the
234 + * wpdb adapter when that connection fails and WordPress is loaded.
235 + * For SQLite sites, wraps the MySQL-on-SQLite driver which WordPress already
236 + * loaded in a PDO-compatible adapter. The driver's translator converts every
237 + * MySQL query to SQLite on the fly, so MySQLDumpProducer sees MySQL-shaped
238 + * results and produces valid MySQL SQL output.
239 + *
240 + * @param array $creds Credentials from resolve_db_credentials().
241 + * @param array $options PDO options. Reach the real PDO connection only; the
242 + * wpdb adapter has no handle to set them on.
243 + * @return PDO A real PDO for MySQL, or a PDO-compatible adapter for SQLite
244 + * and for MySQL hosts reached through wpdb.
245 + */
246 +function create_db_connection(array $creds, array $options = [])
247 +{
248 + if (($creds["db_engine"] ?? "mysql") === "sqlite") {
249 + return create_sqlite_pdo_adapter();
250 + }
251 +
252 + // Gate on pdo_mysql, not pdo: ext-pdo core without the mysql driver
253 + // can't drive MySQL exports.
254 + if (!extension_loaded('pdo_mysql')) {
255 + $mysql = create_wpdb_pdo_adapter();
256 + } else {
257 + // MySQL path (also works for HyperDB — wp-config.php credentials
258 + // point to the write master).
259 + $default_options = [
260 + PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
261 + ];
262 + $merged_options = $options + $default_options;
263 +
264 + try {
265 + $mysql = new PDO(
266 + build_pdo_dsn($creds['db_host'], $creds['db_name']),
267 + $creds["db_user"],
268 + $creds["db_password"],
269 + $merged_options
270 + );
271 + } catch (PDOException $connection_error) {
272 + global $wpdb;
273 + if (!isset($wpdb) || !is_object($wpdb)) {
274 + throw $connection_error;
275 + }
276 +
277 + // Fallback to using wpdb if we failed to connect directly to the database.
278 + $mysql = create_wpdb_pdo_adapter();
279 + }
280 + }
281 +
282 + // SET NAMES normalizes the client, connection, and result charsets plus
283 + // the connection collation for both PDO and wpdb. Text primary key
284 + // comparisons still use each column's stored collation.
285 + $mysql->query(
286 + "SET NAMES utf8mb4 COLLATE utf8mb4_bin"
287 + );
288 +
289 + return $mysql;
290 +}
291 +
292 +/**
293 + * Wraps the SQLite plugin's already-loaded driver in a PDO-compatible adapter.
294 + *
295 + * Version 3 exposes its active translator through $wpdb->get_driver(). Version
296 + * 2 exposes its active translator through wpdb's backward-compatible dbh
297 + * property. Both translate MySQL queries, but neither provides every PDO
298 + * operation used by MySQLDumpProducer.
299 + *
300 + * @return object PDO-compatible adapter (SqliteDriverPDO).
301 + * @throws RuntimeException If the driver is not available or unsupported.
302 + */
303 +function create_sqlite_pdo_adapter()
304 +{
305 + global $wpdb;
306 +
307 + /**
308 + * Minimum supported sqlite-database-integration version.
309 + */
310 + $min_version = '2.1.0';
311 +
312 + if (!class_exists('PDO', false)) {
313 + throw new RuntimeException('SQLite export requires the PDO extension.');
314 + }
315 +
316 + require_once __DIR__ . "/class-sqlite-driver-pdo.php";
317 +
318 + $driver = null;
319 + $raw_pdo = null;
320 +
321 + if (isset($wpdb) && is_object($wpdb) && method_exists($wpdb, 'get_driver')) {
322 + $candidate = $wpdb->get_driver();
323 + if (
324 + is_object($candidate) &&
325 + method_exists($candidate, 'query') &&
326 + method_exists($candidate, 'get_sqlite_pdo')
327 + ) {
328 + $candidate_pdo = call_user_func([$candidate, 'get_sqlite_pdo']);
329 + } else {
330 + $candidate_pdo = null;
331 + }
332 + if ($candidate_pdo instanceof PDO) {
333 + $driver = $candidate;
334 + $raw_pdo = $candidate_pdo;
335 + }
336 + }
337 +
338 + if ($driver === null && isset($wpdb) && is_object($wpdb)) {
339 + $candidate = $wpdb->dbh;
340 + $candidate_pdo = null;
341 + if (is_object($candidate) && method_exists($candidate, 'query')) {
342 + if (method_exists($candidate, 'get_pdo')) {
343 + $candidate_pdo = call_user_func([$candidate, 'get_pdo']);
344 + } elseif (method_exists($candidate, 'get_connection')) {
345 + $connection = call_user_func([$candidate, 'get_connection']);
346 + if (is_object($connection) && method_exists($connection, 'get_pdo')) {
347 + $candidate_pdo = call_user_func([$connection, 'get_pdo']);
348 + }
349 + }
350 + }
351 + if ($candidate_pdo instanceof PDO) {
352 + $driver = $candidate;
353 + $raw_pdo = $candidate_pdo;
354 + }
355 + }
356 +
357 + if ($driver === null) {
358 + throw new RuntimeException(
359 + "SQLite export requires WordPress to load a supported " .
360 + "sqlite-database-integration driver."
361 + );
362 + }
363 +
364 + if (
365 + defined('SQLITE_DRIVER_VERSION') &&
366 + version_compare(SQLITE_DRIVER_VERSION, $min_version, '<')
367 + ) {
368 + throw new RuntimeException(
369 + "sqlite-database-integration plugin version " . SQLITE_DRIVER_VERSION .
370 + " is too old. Minimum required: " . $min_version
371 + );
372 + }
373 +
374 + return new SqliteDriverPDO($driver, $raw_pdo);
375 +}
376 +
377 +/**
378 + * Wraps the global $wpdb in a PDO-shaped adapter.
379 + *
380 + * Used on hosts without ext-pdo_mysql. Requires WordPress to be loaded
381 + * (so $wpdb is available); throws otherwise.
382 + */
383 +function create_wpdb_pdo_adapter()
384 +{
385 + global $wpdb;
386 +
387 + require_once __DIR__ . "/class-wpdb-driver-pdo.php";
388 +
389 + // Guard against a clobbered/half-initialized $wpdb: isset() alone passes
390 + // for non-object scalars, which would fatal inside the adapter constructor.
391 + if (!isset($wpdb) || !is_object($wpdb)) {
392 + throw new RuntimeException(
393 + "MySQL export without PDO requires WordPress \$wpdb to be initialized."
394 + );
395 + }
396 +
397 + return new WpdbDriverPDO($wpdb);
398 +}
399 +
400 +if (!class_exists('Site_Export_HTTP_Server', false)) {
401 + require_once __DIR__ . "/class-http-server.php";
402 +}
403 +
404 +/**
405 + * Emits an error chunk into a multipart stream.
406 + */
407 +function emit_error_chunk($gz, string $boundary, string $message): void
408 +{
409 + $json = json_encode([
410 + "error_type" => "php_error",
411 + "path" => "",
412 + "message" => $message,
413 + ]);
414 + if ($json === false) {
415 + $json = '{"error_type":"php_error","path":"","message":"Error (json_encode failed)"}';
416 + }
417 + $chunk =
418 + "--{$boundary}\r\n" .
419 + "Content-Type: application/json\r\n" .
420 + "Content-Length: " . strlen($json) . "\r\n" .
421 + "X-Chunk-Type: error\r\n" .
422 + "\r\n" .
423 + $json . "\r\n";
424 + $write_failed = false;
425 + try {
426 + $gz->write($chunk);
427 + $gz->sync();
428 + } catch (\Exception $e) {
429 + $write_failed = true;
430 + } catch (\Throwable $e) {
431 + $write_failed = true;
432 + }
433 +
434 + if ($write_failed) {
435 + // The output stream is broken, so make one last raw write. If gzip was
436 + // active, the client likely cannot parse it, but this is better than
437 + // silent failure.
438 + echo $chunk;
439 + flush();
440 + }
441 +}
442 +
443 +// Streaming-aware error handler. Before streaming starts, errors produce
444 +// a JSON response with HTTP 500. Mid-stream, errors become multipart
445 +// error chunks so the client receives structured diagnostics.
446 +//
447 +// Respects the @ operator: suppressed errors are logged but never emitted
448 +// into the stream or sent as responses, since the calling code already
449 +// handles the failure (e.g. @readlink checks for false).
450 +set_error_handler(function ($errno, $errstr, $errfile, $errline) {
451 + global $streaming_context;
452 +
453 + $error = [
454 + "error" => "PHP Error: $errstr",
455 + "file" => $errfile,
456 + "line" => $errline,
457 + "type" => $errno,
458 + ];
459 +
460 + if (!(error_reporting() & $errno)) {
461 + error_log("Export error (suppressed): " . json_encode($error));
462 + return true;
463 + }
464 +
465 + error_log("Export error: " . json_encode($error));
466 +
467 + if ($streaming_context !== null) {
468 + emit_error_chunk(
469 + $streaming_context['gz'],
470 + $streaming_context['boundary'],
471 + "PHP Error ({$errno}): {$errstr} in {$errfile}:{$errline}"
472 + );
473 + return true;
474 + }
475 +
476 + http_response_code(500);
477 + @header("Content-Type: application/json");
478 + echo json_encode($error);
479 + exit(1);
480 +});
481 +
482 +// Streaming-aware exception handler, mirrors the error handler above.
483 +set_exception_handler(function ($e) {
484 + global $streaming_context;
485 +
486 + $error = [
487 + "error" => get_class($e) . ": " . $e->getMessage(),
488 + "file" => $e->getFile(),
489 + "line" => $e->getLine(),
490 + "trace" => $e->getTraceAsString(),
491 + ];
492 + error_log("Export exception: " . json_encode($error));
493 +
494 + if ($streaming_context !== null) {
495 + emit_error_chunk(
496 + $streaming_context['gz'],
497 + $streaming_context['boundary'],
498 + get_class($e) . ": " . $e->getMessage()
499 + );
500 + return;
501 + }
502 +
503 + http_response_code(500);
504 + header("Content-Type: application/json");
505 + echo json_encode($error);
506 + exit(1);
507 +});
508 +
509 +// Catches E_ERROR/E_PARSE fatals that set_error_handler cannot intercept.
510 +register_shutdown_function(function () {
511 + global $streaming_context;
512 +
513 + $error = error_get_last();
514 + if ($error === null) {
515 + return;
516 + }
517 + $fatal_types = E_ERROR | E_PARSE | E_CORE_ERROR | E_COMPILE_ERROR;
518 + if (!($error['type'] & $fatal_types)) {
519 + return;
520 + }
521 +
522 + $message = "Fatal: {$error['message']} in {$error['file']}:{$error['line']}";
523 + error_log("Export fatal: " . json_encode($error));
524 +
525 + if ($streaming_context !== null) {
526 + // Best-effort attempt to emit an error chunk into the stream.
527 + // The stream may already be in a broken state, but this gives
528 + // the client the best chance of receiving structured error info.
529 + try {
530 + emit_error_chunk(
531 + $streaming_context['gz'],
532 + $streaming_context['boundary'],
533 + $message
534 + );
535 + } catch (Exception $ignored) {
536 + // Stream is too broken to write to — nothing more we can do.
537 + return;
538 + } catch (Throwable $ignored) {
539 + // Stream is too broken to write to — nothing more we can do.
540 + return;
541 + }
542 + return;
543 + }
544 +
545 + if (!headers_sent()) {
546 + http_response_code(500);
547 + @header("Content-Type: application/json");
548 + echo json_encode([
549 + "error" => $message,
550 + "file" => $error['file'],
551 + "line" => $error['line'],
552 + "type" => $error['type'],
553 + ]);
554 + }
555 +});
556 +
557 +// ============================================================================
558 +// E2E Test Hook System (only active when SITE_EXPORT_TEST_MODE env var is set)
559 +// We don't want anyone to interfere with the export process, which is why those
560 +// hooks are not registered in production.
561 +// ============================================================================
562 +if (getenv('SITE_EXPORT_TEST_MODE')) {
563 + /**
564 + * Load test hooks from a well-known path relative to the site root.
565 + * The hook file can define callback functions that are called at key
566 + * points during export for testing error conditions and edge cases.
567 + *
568 + * Supported hook functions:
569 + * test_hook_before_sql_batch(&$sql, $cursor) - Before SQL batch emitted
570 + * test_hook_before_file_chunk($path, $offset, &$data) - Before file chunk
571 + * test_hook_after_gzip_init($gz, $boundary) - After gzip stream init
572 + * test_hook_before_completion($status, $gz, $boundary) - Before completion chunk
573 + * test_hook_before_index_batch(&$batch_items, $stack) - Before index batch emitted
574 + * test_hook_during_dir_scan($dir, &$entries) - During directory scanning
575 + */
576 + $__test_hook_file_loaded = false;
577 + function _e2e_load_test_hooks_if_needed(array $config): void {
578 + global $__test_hook_file_loaded;
579 + if ($__test_hook_file_loaded) {
580 + return;
581 + }
582 + $candidates = [];
583 + if (isset($config['directory'])) {
584 + $dirs = is_array($config['directory']) ? $config['directory'] : [$config['directory']];
585 + foreach ($dirs as $d) {
586 + $candidates[] = wp_join_unix_paths(
587 + $d,
588 + 'wp-content/plugins/site-export/test-hooks.php'
589 + );
590 + }
591 + }
592 + // Also check relative to this file's parent
593 + $candidates[] = dirname(__DIR__) . '/test-hooks.php';
594 + foreach ($candidates as $candidate) {
595 + if (file_exists($candidate)) {
596 + if (function_exists('opcache_invalidate')) {
597 + @opcache_invalidate($candidate, true);
598 + }
599 + require $candidate;
600 + $__test_hook_file_loaded = true;
601 + return;
602 + }
603 + }
604 + }
605 +
606 + function _e2e_call_hook(string $name, array &$args = []): void {
607 + if (function_exists($name)) {
608 + call_user_func_array($name, $args);
609 + }
610 + }
611 +}
612 +
613 +require_once __DIR__ . "/class-mysql-dump-producer.php";
614 +require_once __DIR__ . "/class-file-tree-producer.php";
615 +
616 +/**
617 + * Prepares the PHP environment for streaming by disabling output buffering,
618 + * compression layers, and proxy buffering.
619 + */
620 +function prepare_streaming_response(): void
621 +{
622 + while (ob_get_level() > 0) {
623 + @ob_end_clean();
624 + }
625 +
626 + if (!headers_sent()) {
627 + @header("X-Accel-Buffering: no");
628 + @header("Cache-Control: no-store, no-cache, must-revalidate, max-age=0");
629 + @header("Pragma: no-cache");
630 + @header("Expires: 0");
631 + }
632 +
633 + /**
634 + * zlib.output_compression buffers the entire response before compressing. The
635 + * entire point of this plugin is to stream the response, therefore we use a custom
636 + * GzipOutputStream.
637 + */
638 + if (function_exists("ini_set")) {
639 + @ini_set("zlib.output_compression", "0");
640 + @ini_set("output_buffering", "0");
641 + @ini_set("implicit_flush", "1");
642 + }
643 +
644 + @ob_implicit_flush(true);
645 +}
646 +
647 +/**
648 + * Deduplicates and resolves a list of paths, discarding empty entries.
649 + */
650 +function normalize_path_list(array $paths): array
651 +{
652 + $normalized = [];
653 + foreach ($paths as $path) {
654 + if (!is_string($path)) {
655 + continue;
656 + }
657 + $path = trim($path);
658 + if ($path === "") {
659 + continue;
660 + }
661 + $real = realpath($path);
662 + $final = $real !== false ? $real : $path;
663 + $final = trim_right_slash($final);
664 + if ($final === "") {
665 + continue;
666 + }
667 + $normalized[$final] = true;
668 + }
669 + return array_keys($normalized);
670 +}
671 +
672 +/**
673 + * Walks parent directories upward from each start path to find WordPress installations.
674 + */
675 +function detect_wp_roots(array $start_paths): array
676 +{
677 + $start_paths = normalize_path_list($start_paths);
678 + $seen = [];
679 + $roots = [];
680 +
681 + foreach ($start_paths as $start) {
682 + $current = $start;
683 + while ($current !== "" && !isset($seen[$current])) {
684 + $seen[$current] = true;
685 + $wp_load_path = wp_join_unix_paths($current, "wp-load.php");
686 + $wp_config_path = wp_join_unix_paths($current, "wp-config.php");
687 + $wp_content_path = wp_join_unix_paths($current, "wp-content");
688 + $filesystem_probe_warning = false;
689 + $reprint_error_handler = null;
690 + // During preflight, Reprint's error handler turns a warning into HTTP 500.
691 + // These checks move up to parent directories that open_basedir may block.
692 + // Catch probe warnings here so we can stop only this walk. Send other error
693 + // types to the previous handler, then restore that handler in finally below.
694 + $reprint_error_handler = set_error_handler(
695 + function ($errno, $errstr, $errfile, $errline) use (
696 + &$filesystem_probe_warning,
697 + &$reprint_error_handler
698 + ) {
699 + if ($errno === E_WARNING) {
700 + $filesystem_probe_warning = true;
701 + return true;
702 + }
703 + if (!is_callable($reprint_error_handler)) {
704 + return false;
705 + }
706 + return call_user_func(
707 + $reprint_error_handler,
708 + $errno,
709 + $errstr,
710 + $errfile,
711 + $errline
712 + );
713 + }
714 + );
715 + try {
716 + $has_wp_load = file_exists($wp_load_path);
717 + $has_wp_config = file_exists($wp_config_path);
718 + $has_wp_content = is_dir($wp_content_path);
719 + } finally {
720 + restore_error_handler();
721 + }
722 + if ($filesystem_probe_warning) {
723 + // WordPress root discovery is speculative. If this path cannot be
724 + // inspected reliably, keep roots found by this walk and let
725 + // preflight continue with the other start paths.
726 + break;
727 + }
728 + if ($has_wp_load || $has_wp_config) {
729 + $roots[$current] = [
730 + "path" => $current,
731 + "wp_load" => $has_wp_load,
732 + "wp_load_path" => $has_wp_load ? $wp_load_path : null,
733 + "wp_config" => $has_wp_config,
734 + "wp_config_path" => $has_wp_config ? $wp_config_path : null,
735 + "wp_content" => $has_wp_content,
736 + ];
737 + }
738 +
739 + $parent = dirname($current);
740 + if ($parent === $current || $parent === "") {
741 + break;
742 + }
743 + $current = $parent;
744 + }
745 + }
746 +
747 + return [
748 + "searched" => array_keys($seen),
749 + "roots" => array_values($roots),
750 + ];
751 +}
752 +
753 +/**
754 + * Streams SQL dump fragments as gzipped multipart chunks.
755 + */
756 +function endpoint_sql_chunk(
757 + array $config,
758 + ResourceBudget $budget
759 +): array {
760 + prepare_streaming_response();
761 + $creds = resolve_db_credentials();
762 +
763 + // -- Parse request parameters --
764 + $fragments_per_batch = $config["fragments_per_batch"] ?? 1000;
765 + $fragments_per_batch = require_int_range(
766 + "fragments_per_batch",
767 + (int) $fragments_per_batch,
768 + 1,
769 + 10000
770 + );
771 +
772 + $pdo_options = [];
773 + if (!empty($config["db_unbuffered"]) && extension_loaded('pdo_mysql')) {
774 + $pdo_options[PDO::MYSQL_ATTR_USE_BUFFERED_QUERY] = false;
775 + }
776 + $mysql = create_db_connection($creds, $pdo_options);
777 +
778 + $producer_options = [
779 + "create_table_query" => $config["create_table_query"] ?? true,
780 + ];
781 +
782 + // -- Cap statement size to packet limits --
783 + // If the client sent its max_allowed_packet, cap the producer's
784 + // max_statement_size so the dump stays importable on the client.
785 + // We query the server's own max_allowed_packet too and use the
786 + // smaller of the two (both scaled to 80% for protocol headroom).
787 + if (!empty($config["max_allowed_packet"])) {
788 + $client_max = (int) $config["max_allowed_packet"];
789 + if ($client_max >= 1048576 && $client_max <= 1073741824) {
790 + $client_statement_size = (int) ($client_max * 0.8);
791 + $server_statement_size = null;
792 + try {
793 + $row = $mysql
794 + ->query("SELECT @@max_allowed_packet AS v")
795 + ->fetch(PdoConstants::fetch_assoc());
796 + if ($row && isset($row["v"])) {
797 + $server_statement_size = (int) ((int) $row["v"] * 0.8);
798 + }
799 + } catch (Exception $e) {
800 + // Ignore — producer will auto-detect
801 + }
802 + if ($server_statement_size !== null) {
803 + $producer_options["max_statement_size"] = min(
804 + $client_statement_size,
805 + $server_statement_size
806 + );
807 + } else {
808 + $producer_options["max_statement_size"] = $client_statement_size;
809 + }
810 + }
811 + }
812 +
813 + if (!empty($config["db_query_time_limit"])) {
814 + $execution_budget_ms = (int) ($budget->max_time * 1000 * 0.8);
815 + $query_time_limit = require_int_range(
816 + "db_query_time_limit",
817 + (int) $config["db_query_time_limit"],
818 + 0,
819 + 300000
820 + );
821 + $query_time_limit = min($query_time_limit, $execution_budget_ms);
822 + if ($query_time_limit > 0) {
823 + $producer_options["query_time_limit_ms"] = $query_time_limit;
824 + }
825 + }
826 +
827 + $exclude_rows = sql_exclude_rows_from_config($config, $creds["table_prefix"] ?? null);
828 + if ($exclude_rows) {
829 + $producer_options["exclude_rows"] = $exclude_rows;
830 + }
831 +
832 + if (isset($config["skip_tables"])) {
833 + if (!is_array($config["skip_tables"])) {
834 + throw new InvalidArgumentException("skip_tables must be an array");
835 + }
836 + foreach ($config["skip_tables"] as $skipped_table) {
837 + if (!is_string($skipped_table) || $skipped_table === "") {
838 + throw new InvalidArgumentException(
839 + "Every skip_tables entry must be a non-empty string"
840 + );
841 + }
842 + }
843 + $producer_options["exclude_tables"] = array_values($config["skip_tables"]);
844 + }
845 +
846 + if (isset($config["cursor"])) {
847 + $producer_options["cursor"] = $config["cursor"];
848 + }
849 +
850 + $reader = new MySQLDumpProducer(
851 + $mysql,
852 + $producer_options
853 + );
854 +
855 + if (ob_get_level()) {
856 + ob_end_flush();
857 + }
858 +
859 +
860 + ['gz' => $gz, 'boundary' => $boundary] = begin_multipart_stream(true);
861 +
862 + // E2E test hook: after gzip stream initialization
863 + if (getenv('SITE_EXPORT_TEST_MODE')) {
864 + _e2e_load_test_hooks_if_needed($config);
865 + $hook_args = [$gz, $boundary];
866 + _e2e_call_hook('test_hook_after_gzip_init', $hook_args);
867 + }
868 +
869 + if (!isset($config["cursor"])) {
870 + // Send the initial connection settings separately so the client can save
871 + // them outside db.sql for a later MySQL connection.
872 + $session_setup = MySQLDumpProducer::get_session_setup_sql();
873 + $gz->write(
874 + "--{$boundary}\r\n" .
875 + "Content-Type: application/sql\r\n" .
876 + "Content-Length: " . strlen($session_setup) . "\r\n" .
877 + "X-Chunk-Type: sql_session_setup\r\n" .
878 + "\r\n"
879 + );
880 + $gz->write($session_setup);
881 + $gz->write("\r\n");
882 + $gz->sync();
883 + }
884 +
885 + // -- Stream SQL fragments --
886 + // Pull SQL fragments from the producer in batches, writing each batch
887 + // as one or two multipart parts. Stop when the producer is exhausted or the
888 + // resource budget (time/memory) runs out.
889 + $batches_processed = 0;
890 + $sql_bytes_processed = 0;
891 + $aborted = false;
892 + /** @var string|null $deferred_fragment */
893 + $deferred_fragment = null;
894 + /** @var string|null $deferred_fragment_cursor */
895 + $deferred_fragment_cursor = null;
896 + /** @var bool $deferred_fragment_must_be_its_own_part */
897 + $deferred_fragment_must_be_its_own_part = false;
898 +
899 + $stream_failure = null;
900 + try {
901 + while (
902 + $budget->has_remaining()
903 + ) {
904 + $sql_fragments = [];
905 + $sql_batch_bytes = 0;
906 + $cursor = null;
907 + /** @var string|null Cursor after the last fragment included in this batch. */
908 + $last_fragment_cursor = null;
909 + $sql_ends_with_complete_statement = false;
910 + $complete_prefix_byte_length = 0;
911 + /** @var string|null Cursor after the last complete statement in this batch. */
912 + $complete_prefix_cursor = null;
913 +
914 + $i = 0;
915 + $part_must_end = false;
916 + if ($deferred_fragment !== null) {
917 + $sql_fragments[] = $deferred_fragment;
918 + $sql_batch_bytes = strlen($deferred_fragment);
919 + $cursor = $deferred_fragment_cursor;
920 + $last_fragment_cursor = $deferred_fragment_cursor;
921 + $trimmed_fragment = rtrim($deferred_fragment);
922 + $sql_ends_with_complete_statement =
923 + $trimmed_fragment !== '' && substr($trimmed_fragment, -1) === ';';
924 + if ($sql_ends_with_complete_statement) {
925 + $complete_prefix_byte_length = $sql_batch_bytes;
926 + $complete_prefix_cursor = $deferred_fragment_cursor;
927 + }
928 + $part_must_end = $deferred_fragment_must_be_its_own_part;
929 + $i = 1;
930 + $deferred_fragment = null;
931 + $deferred_fragment_cursor = null;
932 + $deferred_fragment_must_be_its_own_part = false;
933 + }
934 +
935 + if (
936 + !$part_must_end &&
937 + $i < $fragments_per_batch
938 + ) {
939 + while ($reader->next_sql_fragment()) {
940 + $fragment = (string) $reader->get_sql_fragment();
941 + $fragment_bytes = strlen($fragment);
942 +
943 + if (
944 + $fragment_bytes > MySQLDumpProducer::MAX_SQL_PART_BODY_BYTES
945 + ) {
946 + throw new RuntimeException(
947 + "The SQL producer returned a {$fragment_bytes}-byte fragment; " .
948 + "the decoded SQL part body limit is " .
949 + MySQLDumpProducer::MAX_SQL_PART_BODY_BYTES .
950 + " bytes."
951 + );
952 + }
953 + $fragment_cursor = $reader->get_reentrancy_cursor();
954 +
955 + if (
956 + !empty($sql_fragments) &&
957 + $sql_batch_bytes + $fragment_bytes >
958 + MySQLDumpProducer::MAX_SQL_PART_BODY_BYTES
959 + ) {
960 + // The producer has already advanced. Retain this fragment
961 + // and its cursor while this batch keeps its earlier cursor.
962 + $deferred_fragment = $fragment;
963 + $deferred_fragment_cursor = $fragment_cursor;
964 + $deferred_fragment_must_be_its_own_part =
965 + $reader->current_fragment_must_be_its_own_part();
966 + break;
967 + }
968 +
969 + // The importer stores a cursor only after executing a complete part.
970 + // Keep the SET header alone before row data. Do not put DROP/CREATE
971 + // or the footer after INSERTs: their implicit or explicit COMMIT
972 + // could make those rows permanent before the INSERT cursor is stored.
973 + // Keep each CONCAT UPDATE alone so its cursor is stored immediately;
974 + // if MyISAM stops first, the importer refuses to repeat the append.
975 + if (
976 + $reader->current_fragment_must_be_its_own_part() &&
977 + !empty($sql_fragments) &&
978 + $sql_ends_with_complete_statement
979 + ) {
980 + $deferred_fragment = $fragment;
981 + $deferred_fragment_cursor = $fragment_cursor;
982 + $deferred_fragment_must_be_its_own_part = true;
983 + break;
984 + }
985 +
986 + $sql_fragments[] = $fragment;
987 + $sql_batch_bytes += $fragment_bytes;
988 + $last_fragment_cursor = $fragment_cursor;
989 + $i++;
990 +
991 + $trimmed_fragment = rtrim($fragment);
992 + $sql_ends_with_complete_statement =
993 + $trimmed_fragment !== '' && substr($trimmed_fragment, -1) === ';';
994 + if ($sql_ends_with_complete_statement) {
995 + $cursor = $fragment_cursor;
996 + $complete_prefix_byte_length = $sql_batch_bytes;
997 + $complete_prefix_cursor = $fragment_cursor;
998 + }
999 +
1000 + if ($reader->current_fragment_must_be_its_own_part()) {
1001 + break;
1002 + }
1003 +
1004 + if ($i >= $fragments_per_batch) {
1005 + break;
1006 + }
1007 +
1008 + if (
1009 + !$budget->has_remaining()
1010 + ) {
1011 + break;
1012 + }
1013 + }
1014 + }
1015 +
1016 + $sql = implode("", $sql_fragments);
1017 + if ($sql === '') {
1018 + break;
1019 + }
1020 + // Does this assembled SQL body end on a complete statement boundary?
1021 + // A complete SQL statement ends with ";"; a fragment from an open
1022 + // INSERT does not.
1023 + $query_complete = $sql_ends_with_complete_statement;
1024 + if (!$query_complete || $cursor === null) {
1025 + $cursor = $last_fragment_cursor;
1026 + }
1027 +
1028 + // E2E test hook: before SQL batch is emitted
1029 + if (getenv('SITE_EXPORT_TEST_MODE')) {
1030 + $hook_args = [&$sql, $cursor];
1031 + _e2e_call_hook('test_hook_before_sql_batch', $hook_args);
1032 + }
1033 +
1034 + $sql_batch_bytes = strlen($sql);
1035 + if (
1036 + $sql_batch_bytes > MySQLDumpProducer::MAX_SQL_PART_BODY_BYTES
1037 + ) {
1038 + throw new RuntimeException(
1039 + "The decoded SQL part body is {$sql_batch_bytes} bytes; " .
1040 + "the limit is " .
1041 + MySQLDumpProducer::MAX_SQL_PART_BODY_BYTES .
1042 + " bytes."
1043 + );
1044 + }
1045 + $sql_bytes_processed += $sql_batch_bytes;
1046 + // Keep only the assembled bounded batch while it is split and emitted.
1047 + unset($sql_fragments);
1048 +
1049 + if (
1050 + !$query_complete
1051 + && $complete_prefix_byte_length > 0
1052 + && $complete_prefix_cursor !== null
1053 + ) {
1054 + // Expose the last complete statement boundary before the
1055 + // incomplete suffix. The importer can save that cursor instead
1056 + // of retaining earlier statements with the suffix.
1057 + $complete_sql_prefix = substr($sql, 0, $complete_prefix_byte_length);
1058 + $gz->write(
1059 + "--{$boundary}\r\n" .
1060 + "Content-Type: application/sql\r\n" .
1061 + "Content-Length: " . strlen($complete_sql_prefix) . "\r\n" .
1062 + "X-Chunk-Type: sql\r\n" .
1063 + "X-Query-Complete: 1\r\n" .
1064 + "X-Cursor: " . base64_encode($complete_prefix_cursor) . "\r\n" .
1065 + "\r\n"
1066 + );
1067 + $gz->write($complete_sql_prefix);
1068 + $gz->write("\r\n");
1069 + $gz->sync();
1070 + unset($complete_sql_prefix);
1071 +
1072 + $sql = substr($sql, $complete_prefix_byte_length);
1073 + }
1074 +
1075 + $gz->write(
1076 + "--{$boundary}\r\n" .
1077 + "Content-Type: application/sql\r\n" .
1078 + "Content-Length: " . strlen($sql) . "\r\n" .
1079 + "X-Chunk-Type: sql\r\n" .
1080 + "X-Query-Complete: " . ($query_complete ? "1" : "0") . "\r\n" .
1081 + "X-Cursor: " . base64_encode($cursor) . "\r\n" .
1082 + "\r\n"
1083 + );
1084 + $gz->write($sql);
1085 + $gz->write("\r\n");
1086 + $gz->sync();
1087 +
1088 + $batches_processed++;
1089 +
1090 + if ($reader->is_finished() && $deferred_fragment === null) {
1091 + break;
1092 + }
1093 + }
1094 + } catch (Exception $e) {
1095 + $stream_failure = $e;
1096 + } catch (Throwable $e) {
1097 + $stream_failure = $e;
1098 + }
1099 +
1100 + if ($stream_failure !== null) {
1101 + $aborted = true;
1102 + error_log("SQL streaming error: " . $stream_failure->getMessage());
1103 + emit_error_chunk($gz, $boundary, $stream_failure->getMessage());
1104 + }
1105 +
1106 + // Best-effort completion chunk — the client already has the data chunks.
1107 + $status = $aborted
1108 + ? "partial"
1109 + : ($reader->is_finished() && $deferred_fragment === null ? "complete" : "partial");
1110 +
1111 + // E2E test hook: before completion chunk
1112 + if (getenv('SITE_EXPORT_TEST_MODE')) {
1113 + $hook_args = [$status, $gz, $boundary];
1114 + _e2e_call_hook('test_hook_before_completion', $hook_args);
1115 + }
1116 +
1117 + $completion_failure = null;
1118 + try {
1119 + $gz->write(
1120 + "--{$boundary}\r\n" .
1121 + "Content-Type: application/octet-stream\r\n" .
1122 + "Content-Length: 0\r\n" .
1123 + "X-Chunk-Type: completion\r\n" .
1124 + "X-Status: {$status}\r\n" .
1125 + "X-Batches-Processed: {$batches_processed}\r\n" .
1126 + "X-SQL-Bytes: {$sql_bytes_processed}\r\n" .
1127 + "X-Memory-Used: " . memory_get_peak_usage(true) . "\r\n" .
1128 + "X-Memory-Limit: " . $budget->max_memory . "\r\n" .
1129 + "X-Time-Elapsed: " . (microtime(true) - $budget->start_time) . "\r\n" .
1130 + "\r\n" .
1131 + "\r\n" .
1132 + "--{$boundary}--\r\n"
1133 + );
1134 + $gz->finish();
1135 + } catch (\Exception $e) {
1136 + $completion_failure = $e;
1137 + } catch (\Throwable $e) {
1138 + $completion_failure = $e;
1139 + }
1140 + if ($completion_failure !== null) {
1141 + error_log("Export: failed to write completion chunk: " . $completion_failure->getMessage());
1142 + }
1143 +
1144 + return [
1145 + "status" => $status,
1146 + "stats" => [
1147 + "batches_processed" => $batches_processed,
1148 + "sql_bytes" => $sql_bytes_processed,
1149 + "memory_used" => memory_get_peak_usage(true),
1150 + "time_elapsed" => microtime(true) - $budget->start_time,
1151 + ],
1152 + ];
1153 +}
1154 +
1155 +/**
1156 + * Streams table metadata (name, estimated rows, size) from INFORMATION_SCHEMA.
1157 + */
1158 +function endpoint_db_index(
1159 + array $config,
1160 + ResourceBudget $budget
1161 +): array {
1162 + prepare_streaming_response();
1163 +
1164 + $creds = resolve_db_credentials();
1165 +
1166 + if (getenv('SITE_EXPORT_TEST_MODE')) {
1167 + _e2e_load_test_hooks_if_needed($config);
1168 + }
1169 +
1170 + $tables_per_batch = $config["tables_per_batch"] ?? 1000;
1171 + $tables_per_batch = require_int_range(
1172 + "tables_per_batch",
1173 + (int) $tables_per_batch,
1174 + 10,
1175 + 10000
1176 + );
1177 +
1178 + $cursor = null;
1179 + if (isset($config["cursor"])) {
1180 + $cursor = json_decode($config["cursor"], true);
1181 + if ($cursor === null && json_last_error() !== JSON_ERROR_NONE) {
1182 + throw new InvalidArgumentException(
1183 + "Invalid cursor format: " . json_last_error_msg()
1184 + );
1185 + }
1186 + }
1187 + $last_table = $cursor["last_table"] ?? "";
1188 +
1189 + $mysql = create_db_connection($creds);
1190 +
1191 + ['gz' => $gz, 'boundary' => $boundary] = begin_multipart_stream();
1192 +
1193 + $tables_processed = 0;
1194 + $rows_estimated = 0;
1195 + $status = "partial";
1196 + $aborted = false;
1197 +
1198 + $stream_failure = null;
1199 + try {
1200 + while (
1201 + $budget->has_remaining()
1202 + ) {
1203 + $sql =
1204 + "SELECT TABLE_NAME, TABLE_ROWS, DATA_LENGTH, INDEX_LENGTH, ENGINE, " .
1205 + "TABLE_COLLATION FROM INFORMATION_SCHEMA.TABLES " .
1206 + "WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME > :last " .
1207 + "ORDER BY TABLE_NAME ASC LIMIT {$tables_per_batch}";
1208 + $stmt = $mysql->prepare($sql);
1209 + $stmt->bindValue(":last", $last_table, PdoConstants::param_str());
1210 + $stmt->execute();
1211 + $rows = $stmt->fetchAll(PdoConstants::fetch_assoc());
1212 +
1213 + if (!$rows) {
1214 + $status = "complete";
1215 + break;
1216 + }
1217 +
1218 + $tables = [];
1219 + foreach ($rows as $row) {
1220 + $name = (string) ($row["TABLE_NAME"] ?? "");
1221 + $tables[] = [
1222 + "name" => $name,
1223 + "rows" =>
1224 + isset($row["TABLE_ROWS"]) && is_numeric($row["TABLE_ROWS"])
1225 + ? (int) $row["TABLE_ROWS"]
1226 + : null,
1227 + "data_bytes" =>
1228 + isset($row["DATA_LENGTH"]) && is_numeric($row["DATA_LENGTH"])
1229 + ? (int) $row["DATA_LENGTH"]
1230 + : null,
1231 + "index_bytes" =>
1232 + isset($row["INDEX_LENGTH"]) && is_numeric($row["INDEX_LENGTH"])
1233 + ? (int) $row["INDEX_LENGTH"]
1234 + : null,
1235 + "engine" => $row["ENGINE"] ?? null,
1236 + "collation" => $row["TABLE_COLLATION"] ?? null,
1237 + ];
1238 + $last_table = $name;
1239 + $tables_processed++;
1240 + if (
1241 + isset($row["TABLE_ROWS"]) &&
1242 + is_numeric($row["TABLE_ROWS"])
1243 + ) {
1244 + $rows_estimated += (int) $row["TABLE_ROWS"];
1245 + }
1246 + }
1247 +
1248 + $payload = json_encode_or_throw($tables);
1249 + $cursor_json = json_encode_or_throw([
1250 + "phase" => "tables",
1251 + "last_table" => $last_table,
1252 + ]);
1253 +
1254 + $gz->write(
1255 + "--{$boundary}\r\n" .
1256 + "Content-Type: application/json\r\n" .
1257 + "Content-Length: " . strlen($payload) . "\r\n" .
1258 + "X-Chunk-Type: table_stats\r\n" .
1259 + "X-Tables: " . count($tables) . "\r\n" .
1260 + "X-Cursor: " . base64_encode($cursor_json) . "\r\n" .
1261 + "\r\n" .
1262 + $payload . "\r\n"
1263 + );
1264 + $gz->sync();
1265 +
1266 + if (count($rows) < $tables_per_batch) {
1267 + $status = "complete";
1268 + break;
1269 + }
1270 + }
1271 + } catch (\Exception $e) {
1272 + $stream_failure = $e;
1273 + } catch (\Throwable $e) {
1274 + $stream_failure = $e;
1275 + }
1276 +
1277 + if ($stream_failure !== null) {
1278 + $aborted = true;
1279 + emit_error_chunk(
1280 + $gz,
1281 + $boundary,
1282 + get_class($stream_failure) . ": " . $stream_failure->getMessage()
1283 + );
1284 + }
1285 +
1286 + $completion_failure = null;
1287 + try {
1288 + if (getenv('SITE_EXPORT_TEST_MODE')) {
1289 + $hook_status = $aborted ? "partial" : $status;
1290 + $hook_args = [$hook_status, $gz, $boundary];
1291 + _e2e_call_hook('test_hook_before_completion', $hook_args);
1292 + }
1293 +
1294 + $gz->write(
1295 + "--{$boundary}\r\n" .
1296 + "Content-Type: application/octet-stream\r\n" .
1297 + "Content-Length: 0\r\n" .
1298 + "X-Chunk-Type: completion\r\n" .
1299 + "X-Status: " . ($aborted ? "partial" : $status) . "\r\n" .
1300 + "X-Tables-Processed: {$tables_processed}\r\n" .
1301 + "X-Rows-Estimated: {$rows_estimated}\r\n" .
1302 + "X-Memory-Used: " . memory_get_peak_usage(true) . "\r\n" .
1303 + "X-Memory-Limit: " . $budget->max_memory . "\r\n" .
1304 + "X-Time-Elapsed: " . (microtime(true) - $budget->start_time) . "\r\n" .
1305 + "\r\n" .
1306 + "\r\n" .
1307 + "--{$boundary}--\r\n"
1308 + );
1309 + $gz->finish();
1310 + } catch (\Exception $e) {
1311 + $completion_failure = $e;
1312 + } catch (\Throwable $e) {
1313 + $completion_failure = $e;
1314 + }
1315 + if ($completion_failure !== null) {
1316 + error_log("Export: failed to write completion chunk: " . $completion_failure->getMessage());
1317 + }
1318 +
1319 + return [
1320 + "status" => $status,
1321 + "stats" => [
1322 + "tables_processed" => $tables_processed,
1323 + "rows_estimated" => $rows_estimated,
1324 + "memory_used" => memory_get_peak_usage(true),
1325 + "time_elapsed" => microtime(true) - $budget->start_time,
1326 + ],
1327 + ];
1328 +}
1329 +
1330 +/**
1331 + * Resolves directory paths from config for operations which can walk only directories.
1332 + */
1333 +function resolve_directories(array $config): array
1334 +{
1335 + $directories_input = $config["directory"] ?? null;
1336 + if (!$directories_input) {
1337 + throw new InvalidArgumentException(
1338 + "directory is required for files operation"
1339 + );
1340 + }
1341 +
1342 + $directories = [];
1343 + $dir_list = is_array($directories_input)
1344 + ? $directories_input
1345 + : [$directories_input];
1346 +
1347 + foreach ($dir_list as $directory) {
1348 + if (!is_string($directory)) {
1349 + throw new InvalidArgumentException(
1350 + "directory entries must be non-empty strings"
1351 + );
1352 + }
1353 + $directory = trim($directory);
1354 + assert_valid_path($directory, "directory entry");
1355 +
1356 + clearstatcache(true, $directory);
1357 + $real_directory = @realpath($directory);
1358 + if ($real_directory === false || !is_dir($real_directory)) {
1359 + throw new InvalidArgumentException(
1360 + "directory entry is not an accessible directory: {$directory}\n" .
1361 + "Current working directory: " .
1362 + getcwd() .
1363 + "\n" .
1364 + "Script directory: " .
1365 + __DIR__
1366 + );
1367 + }
1368 +
1369 + $directories[] = $real_directory;
1370 + }
1371 +
1372 + if (empty($directories)) {
1373 + throw new InvalidArgumentException("No valid directories specified");
1374 + }
1375 +
1376 + return $directories;
1377 +}
1378 +
1379 +/**
1380 + * Builds file-index roots from the file_index request's `directory` parameter.
1381 + *
1382 + * The `directory` parameter contains the selected paths. In spite of the
1383 + * parameter name, a selected path may name a directory, regular file, or
1384 + * symlink.
1385 + *
1386 + * Unlike resolve_directories(), this keeps one file-index root for every
1387 + * selected path. `requested_path` is the normalized spelling supplied by the
1388 + * client. `resolved_path` is its realpath() target.
1389 + *
1390 + * FileIndexProcessor adds a link entry at `requested_path` to the file index.
1391 + * It walks a followed directory target at `resolved_path`. It also uses
1392 + * `resolved_path` so aliases to the same target are indexed once.
1393 + *
1394 + * Example: for /site/theme -> /shared/theme, this returns a file-index root
1395 + * with `requested_path` /site/theme, `resolved_path` /shared/theme, and type
1396 + * symlink. With symlink following enabled, it adds the link entry at
1397 + * /site/theme and indexes the target tree at /shared/theme.
1398 + *
1399 + * @return array[] {
1400 + * File-index roots.
1401 + *
1402 + * @type string $requested_path Normalized path supplied by the client.
1403 + * @type string|null $resolved_path realpath() target, when available.
1404 + * @type string $type directory, file, symlink, or missing.
1405 + * }
1406 + */
1407 +function resolve_file_index_roots(array $config): array
1408 +{
1409 + $roots_input = $config["directory"] ?? null;
1410 + if (!$roots_input) {
1411 + throw new InvalidArgumentException("directory is required for files operation");
1412 + }
1413 +
1414 + $roots = [];
1415 + foreach (is_array($roots_input) ? $roots_input : [$roots_input] as $root_input) {
1416 + if (!is_string($root_input)) {
1417 + throw new InvalidArgumentException("directory entries must be non-empty strings");
1418 + }
1419 + $root_input = trim($root_input);
1420 + assert_valid_path($root_input, "directory entry");
1421 + $requested_path = normalize_path($root_input);
1422 + clearstatcache(true, $requested_path);
1423 + $stat = @lstat($requested_path);
1424 + if ($stat === false) {
1425 + // The client sends `pulled_before` for selected paths an earlier pull
1426 + // already saw. Absence there means the source deleted the path, so it
1427 + // becomes a missing root instead of an error. Anything else absent is
1428 + // a bad path and still throws below.
1429 + $paths_pulled_before = isset($config["pulled_before"]) && is_array($config["pulled_before"])
1430 + ? $config["pulled_before"]
1431 + : [];
1432 + if (in_array($requested_path, $paths_pulled_before, true)) {
1433 + $roots[] = [
1434 + "requested_path" => $requested_path,
1435 + "resolved_path" => null,
1436 + "type" => "missing",
1437 + ];
1438 + continue;
1439 + }
1440 + throw new InvalidArgumentException(
1441 + "Selected file-index root does not exist or is not accessible: {$requested_path}"
1442 + );
1443 + }
1444 +
1445 + $mode = $stat["mode"] & STAT_TYPE_MASK;
1446 + $type = $mode === STAT_TYPE_LINK ? "symlink" : ( is_dir($requested_path) ? "directory" : "file" );
1447 + $resolved_path = @realpath($requested_path);
1448 + if ($type === "symlink" && $resolved_path === false) {
1449 + throw new InvalidArgumentException("Selected file-index root is a broken symlink: {$requested_path}");
1450 + }
1451 + if ($resolved_path === false) {
1452 + throw new InvalidArgumentException(
1453 + "Selected file-index root does not exist or is not accessible: {$requested_path}"
1454 + );
1455 + }
1456 + if (empty($config["follow_symlinks"])) {
1457 + $parent_link = file_index_parent_symlink($requested_path);
1458 + if ($parent_link !== null) {
1459 + throw new InvalidArgumentException(
1460 + "Selected file-index root {$requested_path} is reached through parent symlink " .
1461 + "{$parent_link["path"]} targeting {$parent_link["target"]}; use --follow-symlinks."
1462 + );
1463 + }
1464 + }
1465 + $roots[] = [
1466 + "requested_path" => $requested_path,
1467 + "resolved_path" => $resolved_path,
1468 + "type" => $type,
1469 + ];
1470 + }
1471 +
1472 + return $roots;
1473 +}
1474 +
1475 +/**
1476 + * Returns the file-index root for the file_index request's `list_dir` parameter.
1477 + *
1478 + * `list_dir` normally names a path from `directory[]`. When following symlinks,
1479 + * it may instead name a resolved directory found through a link below one of
1480 + * those selected paths.
1481 + *
1482 + * Example: `directory[]` contains /site. If indexing /site finds a link from
1483 + * /site/theme to /shared/theme, the client later requests
1484 + * `list_dir=/shared/theme`. This function returns a file-index root for
1485 + * /shared/theme even though it is not in `directory[]`.
1486 + *
1487 + * @param array[] $roots File-index roots returned by resolve_file_index_roots().
1488 + * @param string $list_directory Value sent as `list_dir`.
1489 + * @param bool $follow_symlinks Whether `list_dir` may name a directory reached through a link.
1490 + * @return array {
1491 + * File-index root for `list_dir`.
1492 + *
1493 + * @type string $requested_path Requested normalized root path.
1494 + * @type string|null $resolved_path Resolved root path, when available.
1495 + * @type string $type directory, file, symlink, or missing.
1496 + * }
1497 + */
1498 +function resolve_file_index_start_root(
1499 + array $roots,
1500 + string $list_directory,
1501 + bool $follow_symlinks
1502 +): array {
1503 + $requested_path = normalize_path($list_directory);
1504 + foreach ($roots as $root) {
1505 + if ($root["requested_path"] === $requested_path) {
1506 + return $root;
1507 + }
1508 + }
1509 +
1510 + if (!$follow_symlinks) {
1511 + throw new InvalidArgumentException(
1512 + "list_dir must name a selected root unless follow_symlinks is enabled: {$requested_path}"
1513 + );
1514 + }
1515 +
1516 + $resolved_path = @realpath($requested_path);
1517 + if ($resolved_path === false || !is_dir($resolved_path)) {
1518 + throw new InvalidArgumentException(
1519 + "Followed symlink target directory does not exist or is not accessible: {$requested_path}"
1520 + );
1521 + }
1522 +
1523 + return [
1524 + "requested_path" => $requested_path,
1525 + "resolved_path" => $resolved_path,
1526 + "type" => "directory",
1527 + ];
1528 +}
1529 +
1530 +/** Returns the first symlink in a requested root's parent path. */
1531 +function file_index_parent_symlink(string $requested_path): ?array
1532 +{
1533 + $current = "/";
1534 + $parts = explode("/", trim(dirname($requested_path), "/"));
1535 + foreach ($parts as $part) {
1536 + if ($part === "") {
1537 + continue;
1538 + }
1539 + $current = wp_join_unix_paths($current, $part);
1540 + if (!@is_link($current)) {
1541 + continue;
1542 + }
1543 + $target = @readlink($current);
1544 + return ["path" => $current, "target" => $target === false ? "(unreadable)" : $target];
1545 + }
1546 + return null;
1547 +}
1548 +
1549 +/**
1550 + * Returns lightweight preflight checks: filesystem accessibility, DB connectivity,
1551 + * and environment details useful for diagnostics.
1552 + */
1553 +function endpoint_preflight(array $config): array
1554 +{
1555 + // -- Resolve filesystem roots --
1556 + // Determine which directories to scan: either from the client-provided
1557 + // "directory" config, or by auto-detecting from cwd/DOCUMENT_ROOT/__DIR__.
1558 + $directories = [];
1559 + $dir_error = null;
1560 + $has_root_input = array_key_exists("directory", $config) && $config["directory"] !== null;
1561 + if ($has_root_input) {
1562 + try {
1563 + $directories = resolve_directories($config);
1564 + } catch (Exception $e) {
1565 + $dir_error = $e->getMessage();
1566 + }
1567 + }
1568 +
1569 + $search_roots = [];
1570 + if (!empty($directories)) {
1571 + $search_roots = $directories;
1572 + } else {
1573 + $filtered = array_filter(
1574 + [
1575 + getcwd() ?: null,
1576 + $_SERVER["DOCUMENT_ROOT"] ?? null,
1577 + isset($_SERVER["SCRIPT_FILENAME"])
1578 + ? dirname($_SERVER["SCRIPT_FILENAME"])
1579 + : null,
1580 + __DIR__,
1581 + ],
1582 + function ($value) {
1583 + return $value !== null && $value !== "";
1584 + }
1585 + );
1586 + $search_roots = normalize_path_list($filtered);
1587 + }
1588 +
1589 + // -- Detect WordPress installations --
1590 + // Walk parent directories to find wp-load.php / wp-config.php.
1591 + $wp_detect = detect_wp_roots($search_roots);
1592 + $detected_root_paths = [];
1593 + foreach ($wp_detect["roots"] as $root) {
1594 + if (!empty($root["path"])) {
1595 + $detected_root_paths[] = $root["path"];
1596 + }
1597 + }
1598 + $detected_root_paths = normalize_path_list($detected_root_paths);
1599 +
1600 + $wp_load_path = null;
1601 + foreach ($wp_detect["roots"] as $root) {
1602 + if (!empty($root["wp_load_path"]) && is_readable($root["wp_load_path"])) {
1603 + $wp_load_path = $root["wp_load_path"];
1604 + break;
1605 + }
1606 + }
1607 + $preflight_error = null;
1608 + if (!$has_root_input && $wp_load_path === null) {
1609 + $preflight_error =
1610 + "wp-load.php not found and no root directories were provided";
1611 + }
1612 +
1613 + $scan_roots = !empty($directories) ? $directories : $detected_root_paths;
1614 + if (empty($scan_roots)) {
1615 + $scan_roots = $search_roots;
1616 + }
1617 + $scan_roots = normalize_path_list($scan_roots);
1618 +
1619 + $wp_scan_roots = normalize_path_list(
1620 + array_merge($scan_roots, $detected_root_paths)
1621 + );
1622 +
1623 + // -- Probe each directory --
1624 + // Check accessibility, read .htaccess files, and collect disk space info.
1625 + $dir_checks = [];
1626 + $htaccess_files = [];
1627 + $wp_paths = [];
1628 + if (!empty($scan_roots)) {
1629 + foreach ($scan_roots as $dir) {
1630 + $exists = is_dir($dir);
1631 + $readable = $exists && is_readable($dir);
1632 + $openable = false;
1633 + $disk_free = null;
1634 + $disk_total = null;
1635 + if ($readable) {
1636 + $dh = @opendir($dir);
1637 + if ($dh !== false) {
1638 + $openable = true;
1639 + @readdir($dh);
1640 + closedir($dh);
1641 + }
1642 + }
1643 + if ($openable) {
1644 + $disk_free = function_exists("disk_free_space") ? @disk_free_space($dir) : false;
1645 + $disk_total = function_exists("disk_total_space") ? @disk_total_space($dir) : false;
1646 + }
1647 + $dir_checks[] = [
1648 + "path" => $dir,
1649 + "exists" => $exists,
1650 + "readable" => $readable,
1651 + "openable" => $openable,
1652 + "disk_free_bytes" => $disk_free !== false ? $disk_free : null,
1653 + "disk_total_bytes" => $disk_total !== false ? $disk_total : null,
1654 + ];
1655 +
1656 + $htaccess_path = wp_join_unix_paths($dir, ".htaccess");
1657 + if (file_exists($htaccess_path)) {
1658 + $htaccess_readable = is_readable($htaccess_path);
1659 + $htaccess_size = @filesize($htaccess_path);
1660 + $htaccess_mtime = @filemtime($htaccess_path);
1661 + $htaccess_content = null;
1662 + $htaccess_truncated = false;
1663 + if ($htaccess_readable) {
1664 + $limit = 8192;
1665 + $fh = @fopen($htaccess_path, "r");
1666 + if ($fh) {
1667 + $data = @fread($fh, $limit + 1);
1668 + fclose($fh);
1669 + if ($data !== false) {
1670 + if (strlen($data) > $limit) {
1671 + $htaccess_truncated = true;
1672 + $data = substr($data, 0, $limit);
1673 + }
1674 + $htaccess_content = $data;
1675 + }
1676 + }
1677 + }
1678 + $htaccess_files[] = [
1679 + "path" => $htaccess_path,
1680 + "readable" => $htaccess_readable,
1681 + "size_bytes" => $htaccess_size !== false ? $htaccess_size : null,
1682 + "mtime" => $htaccess_mtime !== false ? $htaccess_mtime : null,
1683 + "content" => $htaccess_content,
1684 + "truncated" => $htaccess_truncated,
1685 + ];
1686 + }
1687 +
1688 + $plugins_dir = wp_join_unix_paths($dir, "wp-content/plugins");
1689 + $mu_plugins_dir = wp_join_unix_paths($dir, "wp-content/mu-plugins");
1690 + $themes_dir = wp_join_unix_paths($dir, "wp-content/themes");
1691 + $wp_paths[] = [
1692 + "root" => $dir,
1693 + "plugins_dir" => $plugins_dir,
1694 + "mu_plugins_dir" => $mu_plugins_dir,
1695 + "themes_dir" => $themes_dir,
1696 + ];
1697 + }
1698 + }
1699 +
1700 + if (!empty($wp_scan_roots)) {
1701 + foreach ($wp_scan_roots as $dir) {
1702 + $plugins_dir = wp_join_unix_paths($dir, "wp-content/plugins");
1703 + $mu_plugins_dir = wp_join_unix_paths($dir, "wp-content/mu-plugins");
1704 + $themes_dir = wp_join_unix_paths($dir, "wp-content/themes");
1705 + $wp_paths[] = [
1706 + "root" => $dir,
1707 + "plugins_dir" => $plugins_dir,
1708 + "mu_plugins_dir" => $mu_plugins_dir,
1709 + "themes_dir" => $themes_dir,
1710 + ];
1711 + }
1712 + }
1713 +
1714 + $wp_paths = normalize_path_list(
1715 + array_map(
1716 + function ($entry) {
1717 + return $entry["root"] ?? null;
1718 + },
1719 + $wp_paths
1720 + )
1721 + );
1722 + $wp_paths = array_map(function ($root) {
1723 + return [
1724 + "root" => $root,
1725 + "plugins_dir" => wp_join_unix_paths($root, "wp-content/plugins"),
1726 + "mu_plugins_dir" => wp_join_unix_paths($root, "wp-content/mu-plugins"),
1727 + "themes_dir" => wp_join_unix_paths($root, "wp-content/themes"),
1728 + ];
1729 + }, $wp_paths);
1730 +
1731 + $filesystem_ok = true;
1732 + if ($dir_error !== null) {
1733 + $filesystem_ok = false;
1734 + } elseif (!empty($dir_checks)) {
1735 + foreach ($dir_checks as $check) {
1736 + if (empty($check["openable"])) {
1737 + $filesystem_ok = false;
1738 + break;
1739 + }
1740 + }
1741 + } elseif ($wp_load_path === null) {
1742 + $filesystem_ok = false;
1743 + }
1744 +
1745 + // -- PHP resource limits --
1746 + // Gather memory, upload, and execution limits so the client can tune
1747 + // its request sizes accordingly.
1748 + $memory_limit_raw = ini_get("memory_limit");
1749 + $memory_limit_bytes = null;
1750 + if ($memory_limit_raw !== false && $memory_limit_raw !== "") {
1751 + if ($memory_limit_raw === "-1") {
1752 + $memory_limit_bytes = PHP_INT_MAX;
1753 + } else {
1754 + $memory_limit_bytes = parse_size($memory_limit_raw);
1755 + }
1756 + }
1757 + $memory_used = memory_get_usage(true);
1758 + $memory_available =
1759 + $memory_limit_bytes !== null && $memory_limit_bytes !== PHP_INT_MAX
1760 + ? max(0, $memory_limit_bytes - $memory_used)
1761 + : null;
1762 + $post_max_size_raw = ini_get("post_max_size");
1763 + $upload_max_filesize_raw = ini_get("upload_max_filesize");
1764 + $post_max_bytes =
1765 + $post_max_size_raw !== false && $post_max_size_raw !== ""
1766 + ? parse_size($post_max_size_raw)
1767 + : null;
1768 + $upload_max_bytes =
1769 + $upload_max_filesize_raw !== false && $upload_max_filesize_raw !== ""
1770 + ? parse_size($upload_max_filesize_raw)
1771 + : null;
1772 + $max_request_bytes = null;
1773 + if ($post_max_bytes !== null && $upload_max_bytes !== null) {
1774 + $max_request_bytes = min($post_max_bytes, $upload_max_bytes);
1775 + } elseif ($post_max_bytes !== null) {
1776 + $max_request_bytes = $post_max_bytes;
1777 + } elseif ($upload_max_bytes !== null) {
1778 + $max_request_bytes = $upload_max_bytes;
1779 + }
1780 +
1781 + // -- PHP extensions --
1782 + // Report loaded extensions and image processing capabilities.
1783 + $extensions = get_loaded_extensions();
1784 + sort($extensions, SORT_STRING);
1785 + $extension_versions = [];
1786 + foreach ([
1787 + "curl",
1788 + "gd",
1789 + "imagick",
1790 + "pdo_mysql",
1791 + "mysqli",
1792 + "mbstring",
1793 + "zlib",
1794 + "openssl",
1795 + "fileinfo",
1796 + "exif",
1797 + ] as $ext) {
1798 + if (extension_loaded($ext)) {
1799 + $ver = phpversion($ext);
1800 + $extension_versions[$ext] = $ver !== false ? $ver : true;
1801 + }
1802 + }
1803 +
1804 + $gd_info = function_exists("gd_info") ? gd_info() : null;
1805 + $gd_formats = null;
1806 + $gd_version = null;
1807 + if (is_array($gd_info)) {
1808 + $gd_version = $gd_info["GD Version"] ?? null;
1809 + $gd_formats = [
1810 + "gif_create" => (bool) ($gd_info["GIF Create Support"] ?? false),
1811 + "gif_read" => (bool) ($gd_info["GIF Read Support"] ?? false),
1812 + "jpeg" => (bool) ($gd_info["JPEG Support"] ?? false),
1813 + "png" => (bool) ($gd_info["PNG Support"] ?? false),
1814 + "webp" => (bool) ($gd_info["WebP Support"] ?? false),
1815 + "avif" => (bool) ($gd_info["AVIF Support"] ?? false),
1816 + "bmp" => (bool) ($gd_info["BMP Support"] ?? false),
1817 + "wbmp" => (bool) ($gd_info["WBMP Support"] ?? false),
1818 + "xpm" => (bool) ($gd_info["XPM Support"] ?? false),
1819 + ];
1820 + }
1821 + $imagick_version = extension_loaded("imagick")
1822 + ? (phpversion("imagick") ?: null)
1823 + : null;
1824 +
1825 + // -- Database connectivity --
1826 + // Find wp-config.php credentials, connect to MySQL, and probe server
1827 + // variables (charset, collation, max_allowed_packet, sql_mode).
1828 + // If WordPress is loadable, also read options like active_plugins,
1829 + // theme, siteurl, multisite config, and WP constants.
1830 + $db = [
1831 + "db_engine" => is_sqlite_site() ? "sqlite" : "mysql",
1832 + "credentials_found" => false,
1833 + "connected" => false,
1834 + "can_query" => false,
1835 + "version" => null,
1836 + "db_charset" => null,
1837 + "db_collation" => null,
1838 + "server_charset" => null,
1839 + "server_collation" => null,
1840 + "table_listable" => null,
1841 + "table_list_error" => null,
1842 + "wp" => [
1843 + "wp_config_path" => null,
1844 + "wp_load_path" => null,
1845 + "wp_load_attempted" => false,
1846 + "wp_load_loaded" => false,
1847 + "wp_load_error" => null,
1848 + "table_prefix" => null,
1849 + "options_table" => null,
1850 + "active_plugins" => null,
1851 + "active_sitewide_plugins" => null,
1852 + "theme_template" => null,
1853 + "theme_stylesheet" => null,
1854 + "siteurl" => null,
1855 + "home" => null,
1856 + "paths_urls" => null,
1857 + "multisite" => null,
1858 + "constants" => null,
1859 + "constant_names" => null,
1860 + "wpdb_charset" => null,
1861 + "wpdb_collation" => null,
1862 + "error" => null,
1863 + ],
1864 + "error" => null,
1865 + ];
1866 +
1867 + $credential_roots = [];
1868 + if (!empty($directories)) {
1869 + $credential_roots = $directories;
1870 + } elseif (!empty($detected_root_paths)) {
1871 + $credential_roots = $detected_root_paths;
1872 + } elseif (!empty($search_roots)) {
1873 + $credential_roots = $search_roots;
1874 + }
1875 + $credential_roots = normalize_path_list($credential_roots);
1876 +
1877 + $db["wp"]["wp_load_path"] = $wp_load_path;
1878 + $db["wp"]["wp_load_loaded"] = function_exists("get_option");
1879 +
1880 + $creds = null;
1881 + try {
1882 + $creds = resolve_db_credentials();
1883 + $db["wp"]["wp_config_path"] = $creds["wp_config_path"];
1884 + $db["wp"]["table_prefix"] = $creds["table_prefix"];
1885 + $db["db_engine"] = $creds["db_engine"] ?? $db["db_engine"];
1886 + $db["credentials_found"] = true;
1887 + } catch (InvalidArgumentException $e) {
1888 + $db["error"] = $e->getMessage();
1889 + }
1890 +
1891 + if ($creds !== null) {
1892 + $db_engine = $creds["db_engine"] ?? "mysql";
1893 + $required_ext = $db_engine === "sqlite" ? "pdo_sqlite" : "pdo_mysql";
1894 + $wpdb_available = $db_engine !== "sqlite" && isset($GLOBALS["wpdb"]) && is_object($GLOBALS["wpdb"]);
1895 +
1896 + if (!extension_loaded($required_ext) && !$wpdb_available) {
1897 + $db["error"] = "{$required_ext} extension not loaded";
1898 + } else {
1899 + try {
1900 + $mysql = create_db_connection($creds);
1901 + $db["connected"] = true;
1902 +
1903 + $version = $mysql->query("SELECT VERSION()")->fetchColumn();
1904 + $db["version"] = $version !== false ? (string) $version : null;
1905 + $db["can_query"] = true;
1906 +
1907 + $table_prefix = $db["wp"]["table_prefix"];
1908 + if ($table_prefix === null || $table_prefix === "") {
1909 + try {
1910 + $stmt = $mysql->query(
1911 + "SELECT TABLE_NAME FROM INFORMATION_SCHEMA.TABLES " .
1912 + "WHERE TABLE_SCHEMA = DATABASE() " .
1913 + "AND TABLE_NAME LIKE '%\\_options' ESCAPE '\\\\' " .
1914 + "LIMIT 5"
1915 + );
1916 + if ($stmt !== false) {
1917 + $names = $stmt->fetchAll(PdoConstants::fetch_column());
1918 + foreach ($names as $name) {
1919 + if (!is_string($name)) {
1920 + continue;
1921 + }
1922 + $suffix = "options";
1923 + if (
1924 + strlen($name) > strlen($suffix) &&
1925 + substr($name, -strlen($suffix)) === $suffix
1926 + ) {
1927 + $table_prefix = substr(
1928 + $name,
1929 + 0,
1930 + -strlen($suffix)
1931 + );
1932 + break;
1933 + }
1934 + }
1935 + }
1936 + } catch (Exception $e) {
1937 + if ($db["wp"]["error"] === null) {
1938 + $db["wp"]["error"] = $e->getMessage();
1939 + }
1940 + }
1941 + }
1942 +
1943 + if ($table_prefix !== null && $table_prefix !== "") {
1944 + $db["wp"]["table_prefix"] = $table_prefix;
1945 + $db["wp"]["options_table"] = $table_prefix . "options";
1946 + }
1947 +
1948 + $wp_load_attempted = false;
1949 + $wp_load_error = null;
1950 + $wp_loaded = $db["wp"]["wp_load_loaded"];
1951 + if (!$wp_loaded && $wp_load_path !== null) {
1952 + $wp_load_attempted = true;
1953 + $errors = [];
1954 + $handler = function ($errno, $errstr) use (&$errors) {
1955 + $errors[] = $errstr;
1956 + return true;
1957 + };
1958 + set_error_handler($handler);
1959 + $include_result = @include_once $wp_load_path;
1960 + restore_error_handler();
1961 + if ($include_result === false) {
1962 + $wp_load_error = !empty($errors)
1963 + ? implode("; ", $errors)
1964 + : "Failed to include wp-load.php";
1965 + }
1966 + if (function_exists("get_option")) {
1967 + $wp_loaded = true;
1968 + } elseif ($wp_load_error === null) {
1969 + $wp_load_error = "wp-load.php did not load WordPress functions";
1970 + }
1971 + }
1972 +
1973 + $db["wp"]["wp_load_attempted"] = $wp_load_attempted;
1974 + $db["wp"]["wp_load_loaded"] = $wp_loaded;
1975 + if ($wp_load_error !== null) {
1976 + $db["wp"]["wp_load_error"] = $wp_load_error;
1977 + }
1978 +
1979 + if ($wp_loaded) {
1980 + try {
1981 + $wpdb_global = $GLOBALS["wpdb"] ?? null;
1982 + if (is_object($wpdb_global)) {
1983 + $wpdb_charset = (string) ($wpdb_global->charset ?? "");
1984 + $db["wp"]["wpdb_charset"] = $wpdb_charset !== "" ? $wpdb_charset : null;
1985 +
1986 + $wpdb_collation = (string) ($wpdb_global->collate ?? "");
1987 + $db["wp"]["wpdb_collation"] = $wpdb_collation !== "" ? $wpdb_collation : null;
1988 + }
1989 +
1990 + $db["wp"]["active_plugins"] = get_option("active_plugins");
1991 + $db["wp"]["theme_stylesheet"] = get_option("stylesheet");
1992 + $db["wp"]["theme_template"] = get_option("template");
1993 + $db["wp"]["siteurl"] = get_option("siteurl");
1994 + $db["wp"]["home"] = get_option("home");
1995 + // Resolve wp-admin and wp-includes paths.
1996 + // These are always ABSPATH/wp-admin and ABSPATH/WPINC
1997 + // by WordPress convention, but on hosts like WP Cloud
1998 + // they may be symlinks (e.g. __wp__/wp-admin -> /wordpress/wp-admin).
1999 + // Use realpath() to resolve to the physical location so
2000 + // the importer knows where the files actually live.
2001 + $wp_admin_path = null;
2002 + if (defined("ABSPATH")) {
2003 + $wp_admin_candidate = wp_join_unix_paths(ABSPATH, "wp-admin");
2004 + $wp_admin_real = realpath($wp_admin_candidate);
2005 + if ($wp_admin_real !== false && is_dir($wp_admin_real)) {
2006 + $wp_admin_path = $wp_admin_real;
2007 + }
2008 + }
2009 +
2010 + $wp_includes_path = null;
2011 + if (defined("ABSPATH")) {
2012 + $wpinc = defined("WPINC") ? WPINC : "wp-includes";
2013 + $wp_includes_candidate = wp_join_unix_paths(ABSPATH, $wpinc);
2014 + $wp_includes_real = realpath($wp_includes_candidate);
2015 + if ($wp_includes_real !== false && is_dir($wp_includes_real)) {
2016 + $wp_includes_path = $wp_includes_real;
2017 + }
2018 + }
2019 +
2020 + // Use realpath() to resolve any symlinks in
2021 + // ABSPATH (e.g. /wordpress -> /srv/wpcloud/core/6.9.4
2022 + // on WP Cloud). This matches the convention used for
2023 + // all other paths below and ensures the importer can
2024 + // find the directory at the resolved location where
2025 + // files are actually downloaded.
2026 + $abspath_raw = defined("ABSPATH")
2027 + ? trim_right_slash(ABSPATH)
2028 + : null;
2029 + $abspath_resolved = null;
2030 + if ($abspath_raw !== null) {
2031 + $abspath_real = realpath($abspath_raw);
2032 + $abspath_resolved = $abspath_real !== false
2033 + ? trim_right_slash($abspath_real)
2034 + : $abspath_raw;
2035 + }
2036 +
2037 + $paths_urls = [
2038 + "abspath" => $abspath_resolved,
2039 + "wp_admin_path" => $wp_admin_path,
2040 + "wp_includes_path" => $wp_includes_path,
2041 + "content_dir" => defined("WP_CONTENT_DIR")
2042 + ? realpath(WP_CONTENT_DIR)
2043 + : null,
2044 + "content_url" => function_exists("content_url")
2045 + ? content_url()
2046 + : (defined("WP_CONTENT_URL") ? WP_CONTENT_URL : null),
2047 + "plugins_dir" => defined("WP_PLUGIN_DIR")
2048 + ? realpath(WP_PLUGIN_DIR)
2049 + : null,
2050 + "plugins_url" => function_exists("plugins_url")
2051 + ? plugins_url()
2052 + : (defined("WP_PLUGIN_URL") ? WP_PLUGIN_URL : null),
2053 + "mu_plugins_dir" => defined("WPMU_PLUGIN_DIR")
2054 + ? realpath(WPMU_PLUGIN_DIR)
2055 + : null,
2056 + "mu_plugins_url" => function_exists("content_url")
2057 + ? content_url("/mu-plugins")
2058 + : (defined("WPMU_PLUGIN_URL") ? WPMU_PLUGIN_URL : null),
2059 + "uploads" => [
2060 + "basedir" => null,
2061 + "baseurl" => null,
2062 + "subdir" => null,
2063 + ],
2064 + "site_url" => function_exists("site_url")
2065 + ? site_url()
2066 + : null,
2067 + "home_url" => function_exists("home_url")
2068 + ? home_url()
2069 + : null,
2070 + "network_site_url" => function_exists("network_site_url")
2071 + ? network_site_url()
2072 + : null,
2073 + "network_home_url" => function_exists("network_home_url")
2074 + ? network_home_url()
2075 + : null,
2076 + ];
2077 +
2078 + if (function_exists("wp_upload_dir")) {
2079 + $uploads = wp_upload_dir(null, false);
2080 + if (is_array($uploads)) {
2081 + $raw_basedir = $uploads["basedir"] ?? null;
2082 + $paths_urls["uploads"]["basedir"] =
2083 + is_string($raw_basedir) ? realpath($raw_basedir) : null;
2084 + $paths_urls["uploads"]["baseurl"] =
2085 + $uploads["baseurl"] ?? null;
2086 + $paths_urls["uploads"]["subdir"] =
2087 + $uploads["subdir"] ?? null;
2088 + }
2089 + }
2090 + $db["wp"]["paths_urls"] = $paths_urls;
2091 +
2092 + if (
2093 + function_exists("is_multisite") &&
2094 + is_multisite() &&
2095 + function_exists("get_site_option")
2096 + ) {
2097 + $db["wp"]["active_sitewide_plugins"] = get_site_option(
2098 + "active_sitewide_plugins"
2099 + );
2100 + }
2101 +
2102 + $multisite = [
2103 + "enabled" => false,
2104 + "subdomain_install" => defined("SUBDOMAIN_INSTALL")
2105 + ? (bool) SUBDOMAIN_INSTALL
2106 + : null,
2107 + "current_blog_id" =>
2108 + function_exists("get_current_blog_id")
2109 + ? get_current_blog_id()
2110 + : null,
2111 + "current_network_id" =>
2112 + function_exists("get_current_network_id")
2113 + ? get_current_network_id()
2114 + : null,
2115 + "domain_current_site" => defined("DOMAIN_CURRENT_SITE")
2116 + ? DOMAIN_CURRENT_SITE
2117 + : null,
2118 + "path_current_site" => defined("PATH_CURRENT_SITE")
2119 + ? PATH_CURRENT_SITE
2120 + : null,
2121 + "site_id_current_site" =>
2122 + defined("SITE_ID_CURRENT_SITE")
2123 + ? SITE_ID_CURRENT_SITE
2124 + : null,
2125 + "blog_id_current_site" =>
2126 + defined("BLOG_ID_CURRENT_SITE")
2127 + ? BLOG_ID_CURRENT_SITE
2128 + : null,
2129 + "network" => null,
2130 + "site" => null,
2131 + ];
2132 +
2133 + if (function_exists("is_multisite") && is_multisite()) {
2134 + $multisite["enabled"] = true;
2135 + $network_id = $multisite["current_network_id"];
2136 + if ($network_id !== null && function_exists("get_network")) {
2137 + $network = get_network($network_id);
2138 + if (is_object($network)) {
2139 + $multisite["network"] = [
2140 + "id" => $network->id ?? null,
2141 + "domain" => $network->domain ?? null,
2142 + "path" => $network->path ?? null,
2143 + "site_id" => $network->site_id ?? null,
2144 + "registered" => $network->registered ?? null,
2145 + "last_updated" => $network->last_updated ?? null,
2146 + ];
2147 + }
2148 + }
2149 +
2150 + $blog_id = $multisite["current_blog_id"];
2151 + if ($blog_id !== null && function_exists("get_site")) {
2152 + $site = get_site($blog_id);
2153 + if (is_object($site)) {
2154 + $multisite["site"] = [
2155 + "blog_id" => $site->blog_id ?? null,
2156 + "domain" => $site->domain ?? null,
2157 + "path" => $site->path ?? null,
2158 + "site_id" => $site->site_id ?? null,
2159 + "registered" => $site->registered ?? null,
2160 + "last_updated" => $site->last_updated ?? null,
2161 + "public" => $site->public ?? null,
2162 + "archived" => $site->archived ?? null,
2163 + "mature" => $site->mature ?? null,
2164 + "spam" => $site->spam ?? null,
2165 + "deleted" => $site->deleted ?? null,
2166 + "lang_id" => $site->lang_id ?? null,
2167 + ];
2168 + }
2169 + }
2170 + }
2171 + $db["wp"]["multisite"] = $multisite;
2172 +
2173 + // Capture all WP_* constants plus a few other
2174 + // WordPress-specific ones that don't follow the prefix.
2175 + // We use the "user" category from get_defined_constants(true)
2176 + // which only includes constants set via define(), excluding
2177 + // the thousands of constants from PHP extensions.
2178 + $user_constants = get_defined_constants(true)["user"] ?? [];
2179 + // Include non-WP_* constants that are still
2180 + // important for understanding a WordPress site.
2181 + $extra_constants_names = [
2182 + "WPMU_PLUGIN_DIR",
2183 + "WPMU_PLUGIN_URL",
2184 + "UPLOADS",
2185 + "ABSPATH",
2186 + "DOMAIN_CURRENT_SITE",
2187 + "PATH_CURRENT_SITE",
2188 + "SITE_ID_CURRENT_SITE",
2189 + "BLOG_ID_CURRENT_SITE",
2190 + "SUBDOMAIN_INSTALL",
2191 + "TEMPLATEPATH",
2192 + "STYLESHEETPATH",
2193 + "FORCE_SSL_LOGIN",
2194 + "FORCE_SSL_ADMIN",
2195 + "SAVEQUERIES",
2196 + ];
2197 + $db["wp"]["constant_values"] = [];
2198 + // Names of all runtime-defined constants (without values)
2199 + // so the importer can use their presence as a detection
2200 + // signal without leaking secret values. Only includes
2201 + // constants set via define(), not PHP extension constants.
2202 + $db["wp"]["constant_names"] = [];
2203 + foreach ($user_constants as $name => $value) {
2204 + if (strncmp($name, "WP_", 3) === 0 || in_array($name, $extra_constants_names)) {
2205 + $db["wp"]["constant_values"][$name] = $value;
2206 + } else {
2207 + $db["wp"]["constant_names"][] = $name;
2208 + }
2209 + }
2210 +
2211 + global $wp_version;
2212 + $db["wp"]["wp_version"] = isset($wp_version) && is_string($wp_version)
2213 + ? $wp_version
2214 + : null;
2215 + } catch (Exception $e) {
2216 + if ($db["wp"]["error"] === null) {
2217 + $db["wp"]["error"] = $e->getMessage();
2218 + }
2219 + } catch (Throwable $e) {
2220 + if ($db["wp"]["error"] === null) {
2221 + $db["wp"]["error"] = $e->getMessage();
2222 + }
2223 + }
2224 + } else {
2225 + if ($db["wp"]["error"] === null) {
2226 + if ($wp_load_error !== null) {
2227 + $db["wp"]["error"] = $wp_load_error;
2228 + } elseif ($wp_load_path === null) {
2229 + $db["wp"]["error"] = "wp-load.php not found";
2230 + } else {
2231 + $db["wp"]["error"] = "wp-load.php not loaded";
2232 + }
2233 + }
2234 + }
2235 +
2236 + // MySQL server variables — these don't apply to SQLite,
2237 + // so wrap in a separate try/catch to avoid losing WP data
2238 + // gathered earlier if the query fails.
2239 + try {
2240 + $vars = $mysql
2241 + ->query(
2242 + "SELECT @@character_set_database AS db_charset, " .
2243 + "@@collation_database AS db_collation, " .
2244 + "@@character_set_server AS server_charset, " .
2245 + "@@collation_server AS server_collation, " .
2246 + "@@character_set_connection AS connection_charset, " .
2247 + "@@collation_connection AS connection_collation, " .
2248 + "@@max_allowed_packet AS max_allowed_packet, " .
2249 + "@@sql_mode AS sql_mode, " .
2250 + "@@lower_case_table_names AS lower_case_table_names"
2251 + )
2252 + ->fetch(PdoConstants::fetch_assoc());
2253 + if (is_array($vars)) {
2254 + $db["db_charset"] = $vars["db_charset"] ?? null;
2255 + $db["db_collation"] = $vars["db_collation"] ?? null;
2256 + $db["server_charset"] = $vars["server_charset"] ?? null;
2257 + $db["server_collation"] = $vars["server_collation"] ?? null;
2258 + $db["connection_charset"] = $vars["connection_charset"] ?? null;
2259 + $db["connection_collation"] = $vars["connection_collation"] ?? null;
2260 + $db["max_allowed_packet"] = isset($vars["max_allowed_packet"])
2261 + ? (int) $vars["max_allowed_packet"]
2262 + : null;
2263 + $db["sql_mode"] = $vars["sql_mode"] ?? null;
2264 + $db["lower_case_table_names"] = isset(
2265 + $vars["lower_case_table_names"]
2266 + )
2267 + ? (int) $vars["lower_case_table_names"]
2268 + : null;
2269 + }
2270 + } catch (Exception $e) {
2271 + // Expected for SQLite — these MySQL system variables
2272 + // don't exist. The null defaults are correct.
2273 + }
2274 +
2275 + try {
2276 + $stmt = $mysql->query(
2277 + "SELECT TABLE_NAME FROM INFORMATION_SCHEMA.TABLES " .
2278 + "WHERE TABLE_SCHEMA = DATABASE() LIMIT 1"
2279 + );
2280 + if ($stmt !== false) {
2281 + $stmt->fetchColumn();
2282 + $db["table_listable"] = true;
2283 + $db["table_list_error"] = null;
2284 + } else {
2285 + $db["table_listable"] = false;
2286 + $db["table_list_error"] = "SHOW TABLES failed";
2287 + }
2288 + } catch (Exception $e) {
2289 + $db["table_listable"] = false;
2290 + $db["table_list_error"] = $e->getMessage();
2291 + }
2292 + } catch (Exception $e) {
2293 + $db["error"] = $e->getMessage();
2294 + }
2295 + }
2296 + }
2297 +
2298 + // -- WordPress content inventory --
2299 + // If WordPress was loaded, use its constants for the real plugin/theme/
2300 + // mu-plugin paths. Otherwise, fall back to conventional wp-content/ layout.
2301 + // Scan each directory to list installed plugins, mu-plugins, and themes.
2302 + $wp_runtime_paths = null;
2303 + if ($db["wp"]["wp_load_loaded"]) {
2304 + $runtime_root = defined("ABSPATH") ? trim_right_slash(ABSPATH) : null;
2305 + $content_dir = defined("WP_CONTENT_DIR")
2306 + ? trim_right_slash(WP_CONTENT_DIR)
2307 + : null;
2308 + $plugins_dir = defined("WP_PLUGIN_DIR")
2309 + ? trim_right_slash(WP_PLUGIN_DIR)
2310 + : null;
2311 + $mu_plugins_dir = defined("WPMU_PLUGIN_DIR")
2312 + ? trim_right_slash(WPMU_PLUGIN_DIR)
2313 + : null;
2314 + $themes_dir = null;
2315 + if (function_exists("get_theme_root")) {
2316 + $themes_dir = get_theme_root();
2317 + if (is_string($themes_dir)) {
2318 + $themes_dir = trim_right_slash($themes_dir);
2319 + } else {
2320 + $themes_dir = null;
2321 + }
2322 + }
2323 +
2324 + if ($content_dir !== null) {
2325 + if ($plugins_dir === null) {
2326 + $plugins_dir = wp_join_unix_paths($content_dir, "plugins");
2327 + }
2328 + if ($mu_plugins_dir === null) {
2329 + $mu_plugins_dir = wp_join_unix_paths($content_dir, "mu-plugins");
2330 + }
2331 + if ($themes_dir === null) {
2332 + $themes_dir = wp_join_unix_paths($content_dir, "themes");
2333 + }
2334 + }
2335 +
2336 + $wp_runtime_paths = [
2337 + "root" => $runtime_root ?? $content_dir,
2338 + "content_dir" => $content_dir,
2339 + "plugins_dir" => $plugins_dir,
2340 + "mu_plugins_dir" => $mu_plugins_dir,
2341 + "themes_dir" => $themes_dir,
2342 + ];
2343 + }
2344 +
2345 + $wp_content = [
2346 + "roots" => [],
2347 + ];
2348 + $wp_paths_to_scan = $wp_runtime_paths !== null ? [$wp_runtime_paths] : $wp_paths;
2349 + foreach ($wp_paths_to_scan as $paths) {
2350 + $root_entry = [
2351 + "root" => $paths["root"],
2352 + "content_dir" => $paths["content_dir"] ?? null,
2353 + "plugins" => [],
2354 + "mu_plugins" => [],
2355 + "themes" => [],
2356 + ];
2357 + $plugins_dir = $paths["plugins_dir"] ?? null;
2358 + if ($plugins_dir !== null && is_dir($plugins_dir) && is_readable($plugins_dir)) {
2359 + $entries = @scandir($plugins_dir) ?: [];
2360 + foreach ($entries as $entry) {
2361 + if ($entry === "." || $entry === "..") {
2362 + continue;
2363 + }
2364 + $path = wp_join_unix_paths($plugins_dir, $entry);
2365 + $root_entry["plugins"][] = [
2366 + "name" => $entry,
2367 + "type" => is_dir($path) ? "dir" : "file",
2368 + ];
2369 + }
2370 + usort(
2371 + $root_entry["plugins"],
2372 + function ($a, $b) {
2373 + return strcmp($a["name"], $b["name"]);
2374 + }
2375 + );
2376 + }
2377 +
2378 + $mu_plugins_dir = $paths["mu_plugins_dir"] ?? null;
2379 + if ($mu_plugins_dir !== null && is_dir($mu_plugins_dir) && is_readable($mu_plugins_dir)) {
2380 + $entries = @scandir($mu_plugins_dir) ?: [];
2381 + foreach ($entries as $entry) {
2382 + if ($entry === "." || $entry === "..") {
2383 + continue;
2384 + }
2385 + $path = wp_join_unix_paths($mu_plugins_dir, $entry);
2386 + $root_entry["mu_plugins"][] = [
2387 + "name" => $entry,
2388 + "type" => is_dir($path) ? "dir" : "file",
2389 + ];
2390 + }
2391 + usort(
2392 + $root_entry["mu_plugins"],
2393 + function ($a, $b) {
2394 + return strcmp($a["name"], $b["name"]);
2395 + }
2396 + );
2397 + }
2398 +
2399 + $themes_dir = $paths["themes_dir"] ?? null;
2400 + if ($themes_dir !== null && is_dir($themes_dir) && is_readable($themes_dir)) {
2401 + $entries = @scandir($themes_dir) ?: [];
2402 + foreach ($entries as $entry) {
2403 + if ($entry === "." || $entry === "..") {
2404 + continue;
2405 + }
2406 + $path = wp_join_unix_paths($themes_dir, $entry);
2407 + if (is_dir($path)) {
2408 + $root_entry["themes"][] = $entry;
2409 + }
2410 + }
2411 + sort($root_entry["themes"]);
2412 + }
2413 +
2414 + $wp_content["roots"][] = $root_entry;
2415 + }
2416 +
2417 + $environment_variables = [];
2418 + if (PHP_VERSION_ID >= 70100) {
2419 + $all_environment_variables = getenv();
2420 + if (is_array($all_environment_variables)) {
2421 + $environment_variables = $all_environment_variables;
2422 + }
2423 + }
2424 + $environment_variable_names = array_values(array_unique(array_merge(
2425 + array_keys($_ENV),
2426 + array_keys($environment_variables)
2427 + )));
2428 +
2429 + // -- Probe whether streaming responses can avoid double compression --
2430 + $current_output_compression = ini_get("zlib.output_compression");
2431 + $output_compression_is_on = ! in_array($current_output_compression, [false, "", "0"], true);
2432 +
2433 + $output_compression_can_be_disabled = true;
2434 + if ($output_compression_is_on) {
2435 + $output_compression_can_be_disabled = false;
2436 +
2437 + if (function_exists("ini_set")) {
2438 + @ini_set("zlib.output_compression", "0");
2439 +
2440 + $probed = ini_get("zlib.output_compression");
2441 + $output_compression_can_be_disabled = in_array($probed, [false, "", "0"], true);
2442 +
2443 + @ini_set("zlib.output_compression", $current_output_compression);
2444 + }
2445 + }
2446 +
2447 + // -- Assemble and return the preflight response --
2448 + $ok =
2449 + $preflight_error === null &&
2450 + $filesystem_ok &&
2451 + (!empty($db["credentials_found"]) ? !empty($db["connected"]) : false);
2452 + $response = [
2453 + "ok" => $ok,
2454 + "error" => $preflight_error,
2455 + "timestamp" => time(),
2456 + "protocol_version" => EXPORT_PROTOCOL_VERSION,
2457 + "wp_detect" => [
2458 + "found" => !empty($wp_detect["roots"]),
2459 + "searched" => $wp_detect["searched"],
2460 + "roots" => $wp_detect["roots"],
2461 + "error" =>
2462 + !empty($wp_detect["roots"])
2463 + ? null
2464 + : "wp-load.php or wp-config.php not found in parent directories",
2465 + ],
2466 + "php" => [
2467 + "version" => PHP_VERSION,
2468 + "sapi" => function_exists("php_sapi_name") ? php_sapi_name() : null,
2469 + "timezone" => date_default_timezone_get(),
2470 + "extensions" => $extensions,
2471 + "extension_versions" => $extension_versions,
2472 + ],
2473 + "limits" => [
2474 + "ini_max_execution_time" => (int) ini_get("max_execution_time"),
2475 + "ini_max_input_time" => (int) ini_get("max_input_time"),
2476 + "ini_default_socket_timeout" => (int) ini_get("default_socket_timeout"),
2477 + "max_input_vars" => (int) ini_get("max_input_vars"),
2478 + "max_file_uploads" => (int) ini_get("max_file_uploads"),
2479 + "post_max_size" => $post_max_size_raw !== false ? $post_max_size_raw : null,
2480 + "post_max_bytes" => $post_max_bytes,
2481 + "upload_max_filesize" =>
2482 + $upload_max_filesize_raw !== false ? $upload_max_filesize_raw : null,
2483 + "upload_max_bytes" => $upload_max_bytes,
2484 + "max_request_bytes" => $max_request_bytes,
2485 + "output_buffering" => ini_get("output_buffering") ?: null,
2486 + "zlib_output_compression" => ini_get("zlib.output_compression") ?: null,
2487 + "zlib_output_compression_can_be_disabled" => $output_compression_can_be_disabled,
2488 + "disable_functions" => ini_get("disable_functions") ?: null,
2489 + "allow_url_fopen" => ini_get("allow_url_fopen") ?: null,
2490 + "open_basedir" => ini_get("open_basedir") ?: null,
2491 + ],
2492 + "memory" => [
2493 + "limit_raw" => $memory_limit_raw !== false ? $memory_limit_raw : null,
2494 + "limit_bytes" => $memory_limit_bytes,
2495 + "used_bytes" => $memory_used,
2496 + "available_bytes" => $memory_available,
2497 + ],
2498 + "images" => [
2499 + "gd" => [
2500 + "available" => is_array($gd_info),
2501 + "version" => $gd_version,
2502 + "formats" => $gd_formats,
2503 + ],
2504 + "imagick" => [
2505 + "available" => $imagick_version !== null,
2506 + "version" => $imagick_version,
2507 + ],
2508 + ],
2509 + "runtime" => [
2510 + "server_software" => $_SERVER["SERVER_SOFTWARE"] ?? null,
2511 + // Every effective INI directive as computed by the PHP runtime
2512 + // after merging php.ini, scanned .ini files, and htaccess
2513 + // overrides. This captures the full configuration without
2514 + // needing to read the .ini files themselves.
2515 + "ini_get_all" => ini_get_all(null, false),
2516 + "temp_dir" => function_exists("sys_get_temp_dir") ? sys_get_temp_dir() : null,
2517 + "document_root" => $_SERVER["DOCUMENT_ROOT"] ?? null,
2518 + "script_filename" => $_SERVER["SCRIPT_FILENAME"] ?? null,
2519 + "cwd" => getcwd() ?: null,
2520 + // Names of environment variables exposed without their values so
2521 + // the importer can use their presence as a webhost detection
2522 + // signal. PHP 5.6 cannot list getenv(), so it contributes only
2523 + // names present in $_ENV.
2524 + "env_names" => $environment_variable_names,
2525 + '$_SERVER_names' => array_keys($_SERVER),
2526 + ],
2527 + "filesystem" => [
2528 + "directories" => $dir_checks,
2529 + "error" => $dir_error,
2530 + "ok" => $filesystem_ok,
2531 + ],
2532 + "htaccess" => [
2533 + "files" => $htaccess_files,
2534 + ],
2535 + "wp_content" => $wp_content,
2536 + "database" => $db,
2537 + ];
2538 +
2539 + header("Content-Type: application/json");
2540 + $json = json_encode($response);
2541 + if ($json === false) {
2542 + http_response_code(500);
2543 + echo '{"error":"Failed to serialize preflight response: ' . json_last_error_msg() . '"}';
2544 + } else {
2545 + echo $json;
2546 + }
2547 +
2548 + return [
2549 + "status" => $response["ok"] ? "ok" : "error",
2550 + "stats" => $response,
2551 + ];
2552 +}
2553 +
2554 +/**
2555 + * Streams file chunks from a producer as multipart/mixed.
2556 + */
2557 +function stream_file_producer(
2558 + $producer,
2559 + ResourceBudget $budget,
2560 + array $config = [],
2561 + bool $gzip = false
2562 +): array {
2563 + prepare_streaming_response();
2564 +
2565 + ['gz' => $gz, 'boundary' => $boundary] = begin_multipart_stream(false, $gzip);
2566 +
2567 + // E2E test hook: after gzip stream initialization (file producer)
2568 + if (getenv('SITE_EXPORT_TEST_MODE')) {
2569 + _e2e_load_test_hooks_if_needed($config);
2570 + $hook_args = [$gz, $boundary];
2571 + _e2e_call_hook('test_hook_after_gzip_init', $hook_args);
2572 + }
2573 +
2574 + $chunks_processed = 0;
2575 + $files_completed = 0;
2576 + $bytes_processed = 0;
2577 + $last_progress_output = microtime(true);
2578 + $metadata_sent = false;
2579 + $iterations = 0;
2580 + $aborted = false;
2581 + $abort_payload = null;
2582 + $last_cursor = "";
2583 +
2584 + // -- Stream chunks from the producer --
2585 + // The producer yields file data, directories, symlinks, index entries,
2586 + // and progress updates. Each chunk type is wrapped in a multipart part
2587 + // with metadata headers (path, cursor, size, ctime). The loop runs
2588 + // until the producer is exhausted or the resource budget runs out.
2589 + $stream_failure = null;
2590 + try {
2591 + $initial_progress = $producer->get_progress();
2592 + $initial_progress_json = json_encode_or_throw($initial_progress);
2593 + $initial_cursor = $producer->get_reentrancy_cursor();
2594 + $last_cursor = $initial_cursor;
2595 + $gz->write(
2596 + "--{$boundary}\r\n" .
2597 + "Content-Type: application/json\r\n" .
2598 + "Content-Length: " . strlen($initial_progress_json) . "\r\n" .
2599 + "X-Chunk-Type: progress\r\n" .
2600 + "X-Cursor: " . base64_encode($initial_cursor) . "\r\n" .
2601 + "\r\n" .
2602 + $initial_progress_json . "\r\n"
2603 + );
2604 + $gz->sync();
2605 + while (true) {
2606 + if (
2607 + !$budget->has_remaining()
2608 + ) {
2609 + break;
2610 + }
2611 +
2612 + if (!$producer->next_chunk()) {
2613 + break;
2614 + }
2615 +
2616 + $iterations++;
2617 + $chunk = $producer->get_current_chunk();
2618 + $progress = $producer->get_progress();
2619 +
2620 + if (!$metadata_sent && $progress["phase"] === "streaming") {
2621 + $filesystem_root = $producer->get_filesystem_root();
2622 + $metadata = [
2623 + "filesystem_root" => base64_encode($filesystem_root ?? ""),
2624 + ];
2625 + $metadata_json = json_encode_or_throw($metadata);
2626 +
2627 + $gz->write(
2628 + "--{$boundary}\r\n" .
2629 + "Content-Type: application/json\r\n" .
2630 + "Content-Length: " . strlen($metadata_json) . "\r\n" .
2631 + "X-Chunk-Type: metadata\r\n" .
2632 + "X-Filesystem-Root: " . base64_encode($filesystem_root ?? "") . "\r\n" .
2633 + "\r\n" .
2634 + $metadata_json . "\r\n"
2635 + );
2636 + $gz->sync();
2637 +
2638 + $metadata_sent = true;
2639 + }
2640 +
2641 + if ($chunk === null) {
2642 + $now = microtime(true);
2643 + if ($iterations === 1 || $now - $last_progress_output >= 3.0) {
2644 + $progress_json = json_encode_or_throw($progress);
2645 + $cursor = $producer->get_reentrancy_cursor();
2646 + $last_cursor = $cursor;
2647 +
2648 + $gz->write(
2649 + "--{$boundary}\r\n" .
2650 + "Content-Type: application/json\r\n" .
2651 + "Content-Length: " . strlen($progress_json) . "\r\n" .
2652 + "X-Chunk-Type: progress\r\n" .
2653 + "X-Cursor: " . base64_encode($cursor) . "\r\n" .
2654 + "\r\n" .
2655 + $progress_json . "\r\n"
2656 + );
2657 + $gz->sync();
2658 +
2659 + $last_progress_output = $now;
2660 + }
2661 +
2662 + continue;
2663 + }
2664 +
2665 + $chunk_type = $chunk["type"] ?? "file";
2666 + $cursor = $producer->get_reentrancy_cursor();
2667 + $last_cursor = $cursor;
2668 +
2669 + if ($chunk_type === "directory") {
2670 + $part =
2671 + "--{$boundary}\r\n" .
2672 + "Content-Type: application/octet-stream\r\n" .
2673 + "Content-Length: 0\r\n" .
2674 + "X-Chunk-Type: directory\r\n" .
2675 + "X-Cursor: " . base64_encode($cursor) . "\r\n" .
2676 + "X-Directory-Path: " . base64_encode($chunk["path"]) . "\r\n";
2677 + if (isset($chunk["ctime"])) {
2678 + $part .= "X-Directory-Ctime: " . $chunk["ctime"] . "\r\n";
2679 + }
2680 + $gz->write($part . "\r\n\r\n");
2681 + $gz->sync();
2682 + } elseif ($chunk_type === "symlink") {
2683 + $gz->write(
2684 + "--{$boundary}\r\n" .
2685 + "Content-Type: application/octet-stream\r\n" .
2686 + "Content-Length: 0\r\n" .
2687 + "X-Chunk-Type: symlink\r\n" .
2688 + "X-Cursor: " . base64_encode($cursor) . "\r\n" .
2689 + "X-Symlink-Path: " . base64_encode($chunk["path"]) . "\r\n" .
2690 + "X-Symlink-Target: " . base64_encode($chunk["target"]) . "\r\n" .
2691 + "X-Symlink-Ctime: " . $chunk["ctime"] . "\r\n" .
2692 + "\r\n\r\n"
2693 + );
2694 + $gz->sync();
2695 + } elseif ($chunk_type === "index") {
2696 + $gz->write(
2697 + "--{$boundary}\r\n" .
2698 + "Content-Type: application/octet-stream\r\n" .
2699 + "Content-Length: 0\r\n" .
2700 + "X-Chunk-Type: index\r\n" .
2701 + "X-Cursor: " . base64_encode($cursor) . "\r\n" .
2702 + "X-Index-Path: " . base64_encode($chunk["path"]) . "\r\n" .
2703 + "X-File-Ctime: " . $chunk["ctime"] . "\r\n" .
2704 + "X-File-Size: " . $chunk["size"] . "\r\n" .
2705 + "\r\n\r\n"
2706 + );
2707 + $gz->sync();
2708 + } elseif ($chunk_type === "missing") {
2709 + $gz->write(
2710 + "--{$boundary}\r\n" .
2711 + "Content-Type: application/octet-stream\r\n" .
2712 + "Content-Length: 0\r\n" .
2713 + "X-Chunk-Type: missing\r\n" .
2714 + "X-Cursor: " . base64_encode($cursor) . "\r\n" .
2715 + "X-File-Path: " . base64_encode($chunk["path"]) . "\r\n" .
2716 + "\r\n\r\n"
2717 + );
2718 + $gz->sync();
2719 + } elseif ($chunk_type === "error") {
2720 + $payload = [
2721 + "error_type" => $chunk["error_type"] ?? "unknown",
2722 + "path" => base64_encode($chunk["path"] ?? ""),
2723 + "message" => $chunk["message"] ?? "Error",
2724 + ];
2725 + if (isset($chunk["expected_ctime"])) {
2726 + $payload["expected_ctime"] = $chunk["expected_ctime"];
2727 + }
2728 + if (isset($chunk["actual_ctime"])) {
2729 + $payload["actual_ctime"] = $chunk["actual_ctime"];
2730 + }
2731 + $json = json_encode_or_throw($payload);
2732 + $gz->write(
2733 + "--{$boundary}\r\n" .
2734 + "Content-Type: application/json\r\n" .
2735 + "Content-Length: " . strlen($json) . "\r\n" .
2736 + "X-Chunk-Type: error\r\n" .
2737 + "X-Cursor: " . base64_encode($cursor) . "\r\n" .
2738 + "\r\n" .
2739 + $json . "\r\n"
2740 + );
2741 + $gz->sync();
2742 + } else {
2743 + // E2E test hook: before file chunk is emitted
2744 + if (getenv('SITE_EXPORT_TEST_MODE')) {
2745 + $hook_data = $chunk["data"];
2746 + $hook_args = [$chunk["path"], $chunk["offset"], &$hook_data];
2747 + _e2e_call_hook('test_hook_before_file_chunk', $hook_args);
2748 + $chunk["data"] = $hook_data;
2749 + }
2750 +
2751 + $chunks_processed++;
2752 + $bytes_processed += strlen($chunk["data"]);
2753 + if ($chunk["is_first_chunk"]) {
2754 + $files_completed++;
2755 + }
2756 +
2757 + $data = $chunk["data"];
2758 +
2759 + $headers =
2760 + "--{$boundary}\r\n" .
2761 + "Content-Type: application/octet-stream\r\n" .
2762 + "Content-Length: " . strlen($data) . "\r\n" .
2763 + "X-Chunk-Type: file\r\n" .
2764 + "X-Cursor: " . base64_encode($cursor) . "\r\n" .
2765 + "X-File-Path: " . base64_encode($chunk["path"]) . "\r\n" .
2766 + "X-File-Size: " . $chunk["size"] . "\r\n" .
2767 + "X-File-Ctime: " . $chunk["ctime"] . "\r\n" .
2768 + "X-Chunk-Offset: " . $chunk["offset"] . "\r\n" .
2769 + "X-Chunk-Size: " . strlen($data) . "\r\n" .
2770 + "X-First-Chunk: " . ($chunk["is_first_chunk"] ? "1" : "0") . "\r\n" .
2771 + "X-Last-Chunk: " . ($chunk["is_last_chunk"] ? "1" : "0") . "\r\n";
2772 + if (!empty($chunk["file_changed"])) {
2773 + $headers .= "X-File-Changed: 1\r\n";
2774 + if ($chunk["change_ctime"] !== null) {
2775 + $headers .= "X-File-Change-Ctime: " . $chunk["change_ctime"] . "\r\n";
2776 + }
2777 + if ($chunk["change_size"] !== null) {
2778 + $headers .= "X-File-Change-Size: " . $chunk["change_size"] . "\r\n";
2779 + }
2780 + }
2781 + $gz->write($headers . "\r\n");
2782 + $gz->write($data);
2783 + $gz->write("\r\n");
2784 + $gz->sync();
2785 + }
2786 + }
2787 + } catch (Exception $e) {
2788 + $stream_failure = $e;
2789 + } catch (Throwable $e) {
2790 + $stream_failure = $e;
2791 + }
2792 +
2793 + if ($stream_failure !== null) {
2794 + $aborted = true;
2795 + $abort_payload = [
2796 + "error_type" => "exception",
2797 + "path" => "",
2798 + "message" => $stream_failure->getMessage(),
2799 + ];
2800 + }
2801 +
2802 + // Best-effort error and completion chunks — the client already has the
2803 + // data chunks. If the stream is broken at this point, log and move on.
2804 + $completion_failure = null;
2805 + try {
2806 + // @TODO: If an exception is thrown right after the previous chunk header,
2807 + // it read the fixed Content-Length value and will consume this next
2808 + // chunk as data. We should try and backfill the output up to the
2809 + // previous content-length value if possible.
2810 + if ($abort_payload !== null) {
2811 + $json = json_encode_or_throw($abort_payload);
2812 + $gz->write(
2813 + "--{$boundary}\r\n" .
2814 + "Content-Type: application/json\r\n" .
2815 + "Content-Length: " . strlen($json) . "\r\n" .
2816 + "X-Chunk-Type: error\r\n" .
2817 + "X-Cursor: " . base64_encode($last_cursor) . "\r\n" .
2818 + "\r\n" .
2819 + $json . "\r\n"
2820 + );
2821 + $gz->sync();
2822 + }
2823 +
2824 + $progress = $producer->get_progress();
2825 + $is_complete = $progress["phase"] === "finished" && !$aborted;
2826 + $status = $is_complete ? "complete" : "partial";
2827 +
2828 + // E2E test hook: before completion chunk (file producer)
2829 + if (getenv('SITE_EXPORT_TEST_MODE')) {
2830 + $hook_args = [$status, $gz, $boundary];
2831 + _e2e_call_hook('test_hook_before_completion', $hook_args);
2832 + }
2833 +
2834 + error_log(
2835 + "Export completion: status={$status}, phase={$progress["phase"]}, " .
2836 + "chunks={$chunks_processed}, files={$files_completed}, bytes={$bytes_processed}"
2837 + );
2838 +
2839 + $gz->write(
2840 + "--{$boundary}\r\n" .
2841 + "Content-Type: application/octet-stream\r\n" .
2842 + "Content-Length: 0\r\n" .
2843 + "X-Chunk-Type: completion\r\n" .
2844 + "X-Status: {$status}\r\n" .
2845 + "X-Chunks-Processed: {$chunks_processed}\r\n" .
2846 + "X-Files-Completed: {$files_completed}\r\n" .
2847 + "X-Bytes-Processed: {$bytes_processed}\r\n" .
2848 + "X-Memory-Used: " . memory_get_peak_usage(true) . "\r\n" .
2849 + "X-Memory-Limit: " . $budget->max_memory . "\r\n" .
2850 + "X-Time-Elapsed: " . (microtime(true) - $budget->start_time) . "\r\n" .
2851 + "\r\n" .
2852 + "\r\n" .
2853 + "--{$boundary}--\r\n"
2854 + );
2855 + $gz->finish();
2856 + } catch (\Exception $e) {
2857 + $completion_failure = $e;
2858 + } catch (\Throwable $e) {
2859 + $completion_failure = $e;
2860 + }
2861 + if ($completion_failure !== null) {
2862 + error_log("Export: failed to write completion chunk: " . $completion_failure->getMessage());
2863 + }
2864 +
2865 + $status = $aborted ? "partial" : ($status ?? "partial");
2866 +
2867 + return [
2868 + "status" => $status,
2869 + "stats" => [
2870 + "chunks_processed" => $chunks_processed,
2871 + "files_completed" => $files_completed,
2872 + "bytes_processed" => $bytes_processed,
2873 + "memory_used" => memory_get_peak_usage(true),
2874 + "time_elapsed" => microtime(true) - $budget->start_time,
2875 + ],
2876 + ];
2877 +}
2878 +
2879 +/**
2880 + * Encodes batch items for JSON serialization, base64-encoding paths
2881 + * to handle non-UTF8 filesystem bytes.
2882 + */
2883 +function encode_index_batch(array $batch_items): array
2884 +{
2885 + $encoded = [];
2886 + foreach ($batch_items as $item) {
2887 + $entry = [
2888 + "path" => base64_encode($item["path"]),
2889 + "ctime" => $item["ctime"],
2890 + "size" => $item["size"],
2891 + "type" => $item["type"],
2892 + ];
2893 + if (isset($item["target"])) {
2894 + $entry["target"] = base64_encode($item["target"]);
2895 + }
2896 + if (!empty($item["intermediate"])) {
2897 + $entry["intermediate"] = true;
2898 + }
2899 + if (isset($item["empty"])) {
2900 + $entry["empty"] = $item["empty"];
2901 + }
2902 + $encoded[] = $entry;
2903 + }
2904 + return $encoded;
2905 +}
2906 +
2907 +/**
2908 + * Streams a directory index as gzipped JSON batches of {path, ctime, size,
2909 + * type}, plus an `empty` boolean on every inspected directory.
2910 + *
2911 + * FileIndexProcessor owns the resumable filesystem traversal. This endpoint
2912 + * owns HTTP framing, batching, request budgets, and error chunks.
2913 + */
2914 +function endpoint_file_index(
2915 + array $config,
2916 + ResourceBudget $budget
2917 +): array {
2918 + // This endpoint may run repeatedly in the same PHP process (e.g. PHP built-in
2919 + // server, long-lived workers). Clear stale stat/realpath cache from previous
2920 + // requests so path type transitions (symlink/file/dir) are seen correctly.
2921 + clearstatcache(true);
2922 +
2923 + $file_index_roots = resolve_file_index_roots($config);
2924 + $batch_size = require_int_range(
2925 + "batch_size",
2926 + (int) ($config["batch_size"] ?? 5000),
2927 + 100,
2928 + 100000
2929 + );
2930 + $follow_symlinks = !empty($config["follow_symlinks"]);
2931 + $include_caches = !empty($config["include_caches"]);
2932 + $storage_path = isset($config["storage_path"]) && is_string($config["storage_path"])
2933 + ? $config["storage_path"]
2934 + : "";
2935 +
2936 + if (isset($config["cursor"])) {
2937 + $file_index = FileIndexProcessor::resume(
2938 + $file_index_roots,
2939 + $config["cursor"],
2940 + $follow_symlinks,
2941 + $include_caches,
2942 + $storage_path
2943 + );
2944 + } else {
2945 + $list_directory = $config["list_dir"] ?? null;
2946 + if (!is_string($list_directory) || $list_directory === "") {
2947 + throw new InvalidArgumentException("list_dir is required for file_index");
2948 + }
2949 + $start_root = resolve_file_index_start_root(
2950 + $file_index_roots,
2951 + $list_directory,
2952 + $follow_symlinks
2953 + );
2954 + $file_index = FileIndexProcessor::start(
2955 + $file_index_roots,
2956 + $start_root,
2957 + $follow_symlinks,
2958 + $include_caches,
2959 + $storage_path
2960 + );
2961 + }
2962 +
2963 + if (getenv('SITE_EXPORT_TEST_MODE')) {
2964 + _e2e_load_test_hooks_if_needed($config);
2965 + }
2966 +
2967 + $list_directory = $file_index->get_index_directory();
2968 + $filesystem_root = $file_index_roots[0]["resolved_path"] ?? "/";
2969 +
2970 + prepare_streaming_response();
2971 + ['gz' => $gz, 'boundary' => $boundary] = begin_multipart_stream();
2972 +
2973 + $batches_emitted = 0;
2974 + $total_entries = 0;
2975 + $batch_items = [];
2976 + $status = "partial";
2977 + $aborted = false;
2978 + $abort_payload = null;
2979 +
2980 + $stream_failure = null;
2981 + try {
2982 + $metadata = [
2983 + "filesystem_root" => base64_encode($filesystem_root),
2984 + "list_dir" => base64_encode($list_directory),
2985 + ];
2986 + $metadata_json = json_encode_or_throw($metadata);
2987 + $gz->write(
2988 + "--{$boundary}\r\n" .
2989 + "Content-Type: application/json\r\n" .
2990 + "Content-Length: " . strlen($metadata_json) . "\r\n" .
2991 + "X-Chunk-Type: metadata\r\n" .
2992 + "X-Filesystem-Root: " . base64_encode($filesystem_root) . "\r\n" .
2993 + "X-Index-Dir: " . base64_encode($list_directory) . "\r\n" .
2994 + "\r\n" .
2995 + $metadata_json . "\r\n"
2996 + );
2997 + $gz->sync();
2998 +
2999 + $stop = false;
3000 + while (!$stop) {
3001 + if (!$file_index->next_index_step()) {
3002 + $status = "complete";
3003 + break;
3004 + }
3005 +
3006 + switch ($file_index->get_step_status()) {
3007 + case FileIndexProcessor::STATUS_INDEXED:
3008 + foreach ($file_index->get_index_entries() as $index_entry) {
3009 + $batch_items[] = $index_entry;
3010 + }
3011 + if (count($batch_items) >= $batch_size) {
3012 + emit_file_index_batch(
3013 + $gz,
3014 + $boundary,
3015 + $batch_items,
3016 + $file_index
3017 + );
3018 + $batches_emitted++;
3019 + $total_entries += count($batch_items);
3020 + $batch_items = [];
3021 + }
3022 + if (!$budget->has_remaining()) {
3023 + $stop = true;
3024 + }
3025 + break;
3026 +
3027 + case FileIndexProcessor::STATUS_PATH_UNAVAILABLE:
3028 + case FileIndexProcessor::STATUS_DIRECTORY_COMPLETE:
3029 + if (!$budget->has_remaining()) {
3030 + $stop = true;
3031 + }
3032 + break;
3033 +
3034 + case FileIndexProcessor::STATUS_DIRECTORY_ERROR:
3035 + $directory_error = $file_index->get_directory_error();
3036 + emit_file_index_error(
3037 + $gz,
3038 + $boundary,
3039 + $directory_error,
3040 + $file_index->get_cursor()
3041 + );
3042 + break;
3043 +
3044 + case FileIndexProcessor::STATUS_SKIPPED:
3045 + // Cache, development, and Reprint-storage paths never
3046 + // entered the previous endpoint's budget checks either.
3047 + break;
3048 + }
3049 + }
3050 + } catch (Exception $e) {
3051 + $stream_failure = $e;
3052 + } catch (Throwable $e) {
3053 + $stream_failure = $e;
3054 + }
3055 +
3056 + if ($stream_failure !== null) {
3057 + $aborted = true;
3058 + $current_directory = $file_index->get_current_directory();
3059 + $abort_payload = [
3060 + "error_type" => "exception",
3061 + "path" => base64_encode($current_directory ?? $list_directory),
3062 + "message" => $stream_failure->getMessage(),
3063 + ];
3064 + }
3065 +
3066 + if (!empty($batch_items)) {
3067 + emit_file_index_batch($gz, $boundary, $batch_items, $file_index);
3068 + $batches_emitted++;
3069 + $total_entries += count($batch_items);
3070 + }
3071 +
3072 + $completion_failure = null;
3073 + try {
3074 + if ($abort_payload !== null) {
3075 + emit_file_index_error(
3076 + $gz,
3077 + $boundary,
3078 + $abort_payload,
3079 + $file_index->get_cursor(),
3080 + true
3081 + );
3082 + $status = "partial";
3083 + }
3084 +
3085 + $cursor_json = json_encode_or_throw(
3086 + $file_index->get_cursor(),
3087 + JSON_UNESCAPED_SLASHES
3088 + );
3089 + $cursor_base64 = base64_encode($cursor_json);
3090 + $gz->write(
3091 + "--{$boundary}\r\n" .
3092 + "Content-Type: application/octet-stream\r\n" .
3093 + "Content-Length: 0\r\n" .
3094 + "X-Chunk-Type: completion\r\n" .
3095 + "X-Status: " . ($aborted ? "partial" : $status) . "\r\n" .
3096 + "X-Cursor: {$cursor_base64}\r\n" .
3097 + "X-Index-Dir: " . base64_encode($list_directory) . "\r\n" .
3098 + "X-Batches-Emitted: {$batches_emitted}\r\n" .
3099 + "X-Total-Entries: {$total_entries}\r\n" .
3100 + "X-Memory-Used: " . memory_get_peak_usage(true) . "\r\n" .
3101 + "X-Memory-Limit: " . $budget->max_memory . "\r\n" .
3102 + "X-Time-Elapsed: " . (microtime(true) - $budget->start_time) . "\r\n" .
3103 + "\r\n" .
3104 + "\r\n" .
3105 + "--{$boundary}--\r\n"
3106 + );
3107 + $gz->finish();
3108 + } catch (Exception $e) {
3109 + $completion_failure = $e;
3110 + } catch (Throwable $e) {
3111 + $completion_failure = $e;
3112 + } finally {
3113 + $file_index->close();
3114 + }
3115 + if ($completion_failure !== null) {
3116 + error_log("Export: failed to write completion chunk: " . $completion_failure->getMessage());
3117 + }
3118 +
3119 + return [
3120 + "status" => $aborted ? "partial" : $status,
3121 + "stats" => [
3122 + "batches_emitted" => $batches_emitted,
3123 + "total_entries" => $total_entries,
3124 + "memory_used" => memory_get_peak_usage(true),
3125 + "time_elapsed" => microtime(true) - $budget->start_time,
3126 + ],
3127 + ];
3128 +}
3129 +
3130 +/**
3131 + * Writes one file-index batch with the processor's current cursor.
3132 + *
3133 + * @param object $gzip_stream Gzip stream returned by begin_multipart_stream().
3134 + * @param string $boundary Multipart boundary.
3135 + * @param array[] $batch_items File-index entries in this batch.
3136 + * @param FileIndexProcessor $file_index Active file-index processor.
3137 + */
3138 +function emit_file_index_batch(
3139 + $gzip_stream,
3140 + string $boundary,
3141 + array &$batch_items,
3142 + FileIndexProcessor $file_index
3143 +): void {
3144 + if (getenv('SITE_EXPORT_TEST_MODE')) {
3145 + $directory_stack = [];
3146 + foreach ($file_index->get_cursor()["stack"] as $encoded_frame) {
3147 + $directory_stack[] = [
3148 + "dir" => base64_decode($encoded_frame["dir"]),
3149 + "after" => $encoded_frame["after"] !== null
3150 + ? base64_decode($encoded_frame["after"])
3151 + : null,
3152 + ];
3153 + }
3154 + $hook_args = [&$batch_items, $directory_stack];
3155 + _e2e_call_hook('test_hook_before_index_batch', $hook_args);
3156 + }
3157 +
3158 + $cursor_json = json_encode_or_throw(
3159 + $file_index->get_cursor(),
3160 + JSON_UNESCAPED_SLASHES
3161 + );
3162 + $cursor_base64 = base64_encode($cursor_json);
3163 + $json = json_encode_or_throw(
3164 + encode_index_batch($batch_items),
3165 + JSON_UNESCAPED_SLASHES
3166 + );
3167 + $gzip_stream->write(
3168 + "--{$boundary}\r\n" .
3169 + "Content-Type: application/json\r\n" .
3170 + "Content-Length: " . strlen($json) . "\r\n" .
3171 + "X-Chunk-Type: index_batch\r\n" .
3172 + "X-Cursor: {$cursor_base64}\r\n" .
3173 + "X-Batch-Size: " . count($batch_items) . "\r\n" .
3174 + "\r\n"
3175 + );
3176 + $gzip_stream->write($json);
3177 + $gzip_stream->write("\r\n");
3178 + $gzip_stream->sync();
3179 +}
3180 +
3181 +/**
3182 + * Writes one file-index error with the cursor after that failure.
3183 + *
3184 + * @param object $gzip_stream Gzip stream returned by begin_multipart_stream().
3185 + * @param string $boundary Multipart boundary.
3186 + * @param array $error {
3187 + * File-index error.
3188 + *
3189 + * @type string $error_type Protocol error type.
3190 + * @type string $path Filesystem path.
3191 + * @type string $message Human-readable explanation.
3192 + * }
3193 + * @param array $cursor {
3194 + * File-index cursor after the failure.
3195 + *
3196 + * @type array[] $stack Active directories with base64-encoded path names.
3197 + * }
3198 + * @param bool $path_is_base64 Whether the error path is already base64 text.
3199 + */
3200 +function emit_file_index_error(
3201 + $gzip_stream,
3202 + string $boundary,
3203 + array $error,
3204 + array $cursor,
3205 + bool $path_is_base64 = false
3206 +): void {
3207 + if (!$path_is_base64) {
3208 + $error["path"] = base64_encode($error["path"]);
3209 + }
3210 + $json = json_encode_or_throw($error);
3211 + $cursor_json = json_encode_or_throw($cursor, JSON_UNESCAPED_SLASHES);
3212 + $cursor_base64 = base64_encode($cursor_json);
3213 + $gzip_stream->write(
3214 + "--{$boundary}\r\n" .
3215 + "Content-Type: application/json\r\n" .
3216 + "Content-Length: " . strlen($json) . "\r\n" .
3217 + "X-Chunk-Type: error\r\n" .
3218 + "X-Cursor: {$cursor_base64}\r\n" .
3219 + "\r\n" .
3220 + $json . "\r\n"
3221 + );
3222 + $gzip_stream->sync();
3223 +}
3224 +
3225 +/**
3226 + * Streams files from client-provided base64 path records uploaded as JSON.
3227 + */
3228 +function endpoint_file_fetch(
3229 + array $config,
3230 + ResourceBudget $budget
3231 +): array {
3232 + // Same rationale as endpoint_file_index(): avoid stale path metadata across
3233 + // requests in long-lived PHP processes.
3234 + clearstatcache(true);
3235 +
3236 + $directories = resolve_directories($config);
3237 +
3238 + $list_path = $config["file_list_path"] ?? null;
3239 + if ($list_path === null && isset($_FILES["file_list"])) {
3240 + $tmp_name = $_FILES["file_list"]["tmp_name"] ?? "";
3241 + if ($tmp_name === "" || !is_uploaded_file($tmp_name)) {
3242 + throw new InvalidArgumentException(
3243 + "file_list upload missing or invalid"
3244 + );
3245 + }
3246 + $list_path = $tmp_name;
3247 + }
3248 +
3249 + if ($list_path === null) {
3250 + throw new InvalidArgumentException(
3251 + "file_list is required for file_fetch endpoint"
3252 + );
3253 + }
3254 +
3255 + $raw = file_get_contents($list_path);
3256 + if ($raw === false) {
3257 + throw new InvalidArgumentException("Failed to read file_list");
3258 + }
3259 + $decoded = json_decode($raw, true);
3260 + if (!is_array($decoded)) {
3261 + throw new InvalidArgumentException(
3262 + "file_list must be a JSON array of base64 path records"
3263 + );
3264 + }
3265 + $paths = [];
3266 + foreach ($decoded as $entry_index => $entry) {
3267 + $encoded_path = null;
3268 + if (is_array($entry)) {
3269 + $encoded_path = $entry["path"] ?? null;
3270 + }
3271 + $path = is_string($encoded_path)
3272 + ? base64_decode($encoded_path, true)
3273 + : false;
3274 + // phpcs:disable WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Protocol error, never HTML output.
3275 + if (!is_string($path) || $path === "") {
3276 + throw new InvalidArgumentException(
3277 + "file_list entry {$entry_index} must contain a nonempty base64 path"
3278 + );
3279 + }
3280 + // phpcs:enable WordPress.Security.EscapeOutput.ExceptionNotEscaped
3281 + $paths[] = $path;
3282 + }
3283 +
3284 + $chunk_size = $config["chunk_size"] ?? FileTreeProducer::DEFAULT_CHUNK_SIZE;
3285 + $chunk_size = require_int_range(
3286 + "chunk_size",
3287 + (int) $chunk_size,
3288 + 16 * 1024,
3289 + 32 * 1024 * 1024
3290 + );
3291 +
3292 + $sync_options = [
3293 + "chunk_size" => $chunk_size,
3294 + "paths" => $paths,
3295 + ];
3296 + if (isset($config["cursor"])) {
3297 + $sync_options["cursor"] = $config["cursor"];
3298 + }
3299 +
3300 + $producer = new FileTreeProducer($directories, $sync_options);
3301 + return stream_file_producer(
3302 + $producer,
3303 + $budget,
3304 + $config,
3305 + file_fetch_paths_should_gzip($paths)
3306 + );
3307 +}
3308 +
3309 +/**
3310 + * Decides whether to gzip a file_fetch multipart response based on the path
3311 + * list it will carry.
3312 + *
3313 + * Encoding is set per response (Content-Encoding is a response-level header),
3314 + * so we have to commit before any byte is sent. The trade-off:
3315 + * - Text-y bodies (PHP/JS/CSS/JSON/SQL/HTML/etc.) compress 5–60×. Gzip is
3316 + * a clear win on wire size and total wall time.
3317 + * - Image/video/audio/font/archive bodies are already compressed; passing
3318 + * them through gzip costs ~4 ms per 200 KB and produces ~0% size
3319 + * reduction (deflate falls back to literal stored blocks for incompressible
3320 + * input). Negligible per individual file, but unbounded if the batch is
3321 + * all-binary multiplied by request volume.
3322 + *
3323 + * Rule: gzip the response if **any** file in the batch is compressible.
3324 + *
3325 + * The previous all-or-nothing rule ("gzip only if every file is compressible")
3326 + * was over-conservative — a single PNG in a 200-CSS batch flipped the whole
3327 + * response to identity, losing ~50 % of wire size that would have compressed.
3328 + * The wasted CPU on the small binary portion of mixed batches is bounded by
3329 + * request size (capped server-side), so this trade-off favors smaller wire
3330 + * bytes on the common WordPress mixed batch (theme dirs, wp-content/uploads
3331 + * mixed with plugin assets) without harming the all-binary uploads case
3332 + * (which has zero compressible files and stays identity).
3333 + */
3334 +function file_fetch_paths_should_gzip(array $paths): bool
3335 +{
3336 + if ($paths === []) {
3337 + return false;
3338 + }
3339 + $any_compressible = false;
3340 + foreach ($paths as $path) {
3341 + if (!is_string($path)) {
3342 + // Defensive: an unexpected non-string entry is a bad input we
3343 + // shouldn't compress around. Treat as a hard reject.
3344 + return false;
3345 + }
3346 + // Once true, we can skip checking the subsequent files.
3347 + if ($any_compressible) {
3348 + continue;
3349 + }
3350 + $ext = path_extension_compressibility($path);
3351 + if ($ext === 'yes') {
3352 + $any_compressible = true;
3353 + continue;
3354 + }
3355 + if ($ext === 'unknown') {
3356 + // Extension didn't match a known-text or known-binary list. Peek
3357 + // at the first 64 bytes and let the bytes decide. Cheap (one
3358 + // open/read/close per file) and means we don't have to grow the
3359 + // whitelist every time a plugin invents a new template suffix.
3360 + if (path_head_looks_like_text($path)) {
3361 + $any_compressible = true;
3362 + }
3363 + continue;
3364 + }
3365 + // 'no' — known binary. Skip; doesn't disqualify the batch.
3366 + }
3367 + return $any_compressible;
3368 +}
3369 +
3370 +/**
3371 + * Three-state classifier for a path's extension.
3372 + *
3373 + * - 'yes' known text-y extension (or dotfile / extensionless name).
3374 + * - 'no' known binary/already-compressed extension.
3375 + * - 'unknown' neither list matches; caller may probe the file bytes.
3376 + */
3377 +function path_extension_compressibility(string $path): string
3378 +{
3379 + $basename = basename($path);
3380 + if ($basename === '') {
3381 + return 'no';
3382 + }
3383 + // Dotfiles like .htaccess / .env / .gitignore have no "real" extension —
3384 + // pathinfo() reports the part after the leading dot as the extension,
3385 + // but they're text by convention. Treat the whole class as compressible.
3386 + if ($basename[0] === '.' && strpos($basename, '.', 1) === false) {
3387 + return 'yes';
3388 + }
3389 + $ext = strtolower((string) pathinfo($basename, PATHINFO_EXTENSION));
3390 + // Files with truly no extension (LICENSE, README, Makefile) — treat as text.
3391 + if ($ext === '') {
3392 + return 'yes';
3393 + }
3394 + static $compressible = [
3395 + // Source / markup
3396 + 'php', 'phtml', 'js', 'jsx', 'ts', 'tsx', 'mjs', 'cjs',
3397 + 'css', 'scss', 'sass', 'less',
3398 + 'html', 'htm', 'xml', 'xsl', 'xslt', 'svg',
3399 + 'vue', 'astro', 'twig', 'mustache', 'hbs', 'liquid',
3400 + // Data / config
3401 + 'json', 'jsonl', 'yaml', 'yml', 'toml', 'csv', 'tsv',
3402 + 'sql', 'ini', 'conf', 'cfg', 'env', 'properties',
3403 + // Docs / plain text
3404 + 'md', 'markdown', 'txt', 'log', 'rst', 'adoc',
3405 + // Translations / feeds / captions
3406 + 'pot', 'po', 'rss', 'atom', 'srt', 'vtt', 'webvtt',
3407 + // Misc text-y
3408 + 'sh', 'bash', 'patch', 'diff',
3409 + ];
3410 + if (in_array($ext, $compressible, true)) {
3411 + return 'yes';
3412 + }
3413 + static $incompressible = [
3414 + // Already-compressed / encrypted archives
3415 + 'zip', 'gz', 'tgz', 'bz2', 'xz', '7z', 'rar', 'tar',
3416 + // Images
3417 + 'jpg', 'jpeg', 'png', 'gif', 'webp', 'heic', 'heif', 'avif',
3418 + 'tiff', 'tif', 'bmp', 'ico',
3419 + // Audio
3420 + 'mp3', 'm4a', 'aac', 'ogg', 'opus', 'flac', 'wav',
3421 + // Video
3422 + 'mp4', 'm4v', 'mov', 'webm', 'mkv', 'avi',
3423 + // Fonts (already deflate-compressed in woff/woff2)
3424 + 'woff', 'woff2', 'ttf', 'otf', 'eot',
3425 + // Misc binary blobs
3426 + 'pdf', 'psd', 'sketch', 'fig', 'iso', 'dmg', 'mo', 'phar',
3427 + ];
3428 + if (in_array($ext, $incompressible, true)) {
3429 + return 'no';
3430 + }
3431 + return 'unknown';
3432 +}
3433 +
3434 +/**
3435 + * Probes the first bytes of a file to decide if it looks like text.
3436 + *
3437 + * Used as a fallback when the extension didn't match either the text or the
3438 + * binary list. The cost is one open + read + close per file in the
3439 + * file_fetch batch, which is negligible relative to streaming the file
3440 + * itself; the upside is we don't need to grow the extension lists every
3441 + * time a plugin invents a new template suffix.
3442 + *
3443 + * The check is deliberately strict: any NUL or other ASCII control byte
3444 + * (outside tab/newline/CR/form-feed) means binary, and the head must also
3445 + * decode as valid UTF-8. UTF-8 happens to reject most random binary
3446 + * sequences naturally because high-bit bytes only validate in well-formed
3447 + * multi-byte runs — so PNG, JPEG, ZIP, etc. fail this within a handful of
3448 + * bytes even when their headers look ASCII.
3449 + */
3450 +function path_head_looks_like_text(string $path): bool
3451 +{
3452 + if (!is_file($path)) {
3453 + return false;
3454 + }
3455 + $fp = @fopen($path, 'rb');
3456 + if ($fp === false) {
3457 + // Producer will surface a clearer error later; don't compress on
3458 + // unreadable paths.
3459 + return false;
3460 + }
3461 + $head = (string) fread($fp, 64);
3462 + fclose($fp);
3463 + if ($head === '') {
3464 + // Empty file: nothing to compress, default to identity.
3465 + return false;
3466 + }
3467 + // Any NUL byte → binary. Cheapest signal, catches PNG/ZIP/woff/etc.
3468 + if (strpos($head, "\x00") !== false) {
3469 + return false;
3470 + }
3471 + // Other ASCII control bytes (excluding TAB \x09, LF \x0A, FF \x0C, CR \x0D)
3472 + // shouldn't appear in source/data files. Also reject DEL \x7F.
3473 + if (preg_match('/[\x01-\x08\x0B\x0E-\x1F\x7F]/', $head)) {
3474 + return false;
3475 + }
3476 + // Must decode cleanly as UTF-8. mb_check_encoding handles the case where
3477 + // a multi-byte sequence is sliced by our 64-byte window: it returns false,
3478 + // which we treat as "not obviously text" — biased toward identity, which
3479 + // is the safe direction.
3480 + if (function_exists('mb_check_encoding') && !mb_check_encoding($head, 'UTF-8')) {
3481 + return false;
3482 + }
3483 + return true;
3484 +}
3485 +
3486 +/**
3487 + * Reports whether a path belongs to the established default file-index skip set.
3488 + *
3489 + * @param string $path Filesystem path to classify.
3490 + * @return bool Whether the path is omitted unless caches are included.
3491 + */
3492 +function path_is_default_skipped(string $path): bool
3493 +{
3494 + return FileIndexProcessor::path_is_default_skipped($path);
3495 +}
3496 +
3497 +/**
3498 + * Maps importer-requested SQL row filters to producer row-exclusion rules.
3499 + *
3500 + * Rules are data, not exporter-known tokens. A client may provide:
3501 + *
3502 + * skip_rows[0][table_name_without_prefix]=postmeta
3503 + * skip_rows[0][column]=meta_key
3504 + * skip_rows[0][value_base64]=X2VkaXRfbG9jaw==
3505 + *
3506 + * `table_name_without_prefix` is appended to the server-side WordPress table prefix. The
3507 + * prefix must come from WordPress; if it cannot be resolved, clients must use
3508 + * explicit `table` instead. Values are base64-encoded so raw bytes never travel
3509 + * as SQL text.
3510 + *
3511 + * @return array[] {
3512 + * SQL row exclusion rules.
3513 + *
3514 + * @type string $table Table name.
3515 + * @type string $column Column name.
3516 + * @type string $value Column value to exclude.
3517 + * }
3518 + * @phpstan-return list<array{table: string, column: string, value: string}>
3519 + */
3520 +function sql_exclude_rows_from_config(array $config, ?string $table_prefix): array
3521 +{
3522 + if (!isset($config["skip_rows"])) {
3523 + return [];
3524 + }
3525 +
3526 + $requested = $config["skip_rows"];
3527 + if (is_string($requested)) {
3528 + $decoded = json_decode($requested, true);
3529 + if (!is_array($decoded)) {
3530 + throw new InvalidArgumentException("skip_rows string must be a JSON array");
3531 + }
3532 + $requested = $decoded;
3533 + }
3534 + if (!is_array($requested)) {
3535 + throw new InvalidArgumentException("skip_rows must be an array");
3536 + }
3537 +
3538 + $rules = [];
3539 + foreach ($requested as $index => $rule) {
3540 + if (!is_array($rule)) {
3541 + throw new InvalidArgumentException("skip_rows[{$index}] must be an object");
3542 + }
3543 +
3544 + $has_table = isset($rule["table"]);
3545 + $has_table_name_without_prefix = isset($rule["table_name_without_prefix"]);
3546 + if ($has_table === $has_table_name_without_prefix) {
3547 + throw new InvalidArgumentException("skip_rows[{$index}] must include exactly one of table or table_name_without_prefix");
3548 + }
3549 + if (!isset($rule["column"], $rule["value_base64"])) {
3550 + throw new InvalidArgumentException("skip_rows[{$index}] must include column and value_base64");
3551 + }
3552 + if (!is_string($rule["column"]) || $rule["column"] === "") {
3553 + throw new InvalidArgumentException("skip_rows[{$index}].column must be a non-empty string");
3554 + }
3555 + if (!is_string($rule["value_base64"])) {
3556 + throw new InvalidArgumentException("skip_rows[{$index}].value_base64 must be a string");
3557 + }
3558 +
3559 + if ($has_table_name_without_prefix) {
3560 + if (!is_string($rule["table_name_without_prefix"]) || $rule["table_name_without_prefix"] === "") {
3561 + throw new InvalidArgumentException("skip_rows[{$index}].table_name_without_prefix must be a non-empty string");
3562 + }
3563 + if ($table_prefix === null || $table_prefix === "") {
3564 + throw new InvalidArgumentException("skip_rows[{$index}].table_name_without_prefix requires a table_prefix");
3565 + }
3566 + $table = $table_prefix . $rule["table_name_without_prefix"];
3567 + } else {
3568 + if (!is_string($rule["table"]) || $rule["table"] === "") {
3569 + throw new InvalidArgumentException("skip_rows[{$index}].table must be a non-empty string");
3570 + }
3571 + $table = $rule["table"];
3572 + }
3573 +
3574 + $value = base64_decode($rule["value_base64"], true);
3575 + if ($value === false) {
3576 + throw new InvalidArgumentException("skip_rows[{$index}].value_base64 must be valid base64");
3577 + }
3578 +
3579 + $rules[] = [
3580 + "table" => $table,
3581 + "column" => $rule["column"],
3582 + "value" => $value,
3583 + ];
3584 + }
3585 +
3586 + return $rules;
3587 +}
3588 +
3589 +/**
3590 + * Validates that an integer falls within the given range, or throws.
3591 + */
3592 +function require_int_range(
3593 + string $name,
3594 + int $value,
3595 + int $min,
3596 + int $max
3597 +): int {
3598 + if ($value < $min || $value > $max) {
3599 + throw new InvalidArgumentException(
3600 + "{$name} out of range. Expected {$min}-{$max}, got {$value}"
3601 + );
3602 + }
3603 + return $value;
3604 +}
3605 +
3606 +/**
3607 + * Validates that a float falls within the given range, or throws.
3608 + */
3609 +function require_float_range(
3610 + string $name,
3611 + float $value,
3612 + float $min,
3613 + float $max
3614 +): float {
3615 + if ($value < $min || $value > $max) {
3616 + throw new InvalidArgumentException(
3617 + "{$name} out of range. Expected {$min}-{$max}, got {$value}"
3618 + );
3619 + }
3620 + return $value;
3621 +}
3622 +
3623 +/**
3624 + * Builds the config array from HTTP GET/POST parameters and optional JSON body.
3625 + */
3626 +function parse_http_config(): array
3627 +{
3628 + $body = file_get_contents('php://input');
3629 + if ($body === false) {
3630 + $body = '';
3631 + }
3632 +
3633 + $server = new Site_Export_HTTP_Server();
3634 + return $server->parse_http_config($_GET, $_POST, $_SERVER, $body);
3635 +}