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

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

3,636 lines 138.0 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
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 }
3636