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