Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 16 additions & 1 deletion inc/class-domain-mapping.php
Original file line number Diff line number Diff line change
Expand Up @@ -613,6 +613,14 @@ public function replace_url($url, $current_mapping = null) {
return $url;
}

// Bail if the URL is not a usable string. WordPress filters such as
// `home_url` and `site_url` can occasionally pass null/false/empty
// values, and on PHP 8.1+ passing those into preg_quote()/parse_url()
// emits deprecation notices.
if (! is_string($url) || '' === $url) {
return $url;
}

// Get the site associated with the mapping
$path = $current_mapping->get_path();

Expand All @@ -625,7 +633,14 @@ public function replace_url($url, $current_mapping = null) {
// wp_parse_url not available because this happens very early in the WP loading process.
// phpcs:ignore WordPress.WP.AlternativeFunctions.parse_url_parse_url
$domain_base = parse_url($url, PHP_URL_HOST);
$domain = preg_quote($domain_base, '#') . '(?::\d+)?';

// Relative URLs (no host) cannot be re-mapped — return as-is rather
// than feeding null into preg_quote() (deprecated on PHP 8.1+).
if (! is_string($domain_base) || '' === $domain_base) {
return $url;
}

$domain = preg_quote($domain_base, '#') . '(?::\d+)?';

if ('/' !== $path) {
$domain = rtrim($domain . '/' . preg_quote(ltrim($path, '/'), '#'), '/');
Expand Down
7 changes: 5 additions & 2 deletions inc/functions/customer.php
Original file line number Diff line number Diff line change
Expand Up @@ -148,8 +148,11 @@ function wu_create_customer($customer_data) {
* operate on the same normalized value. Without this, subtle
* format differences (e.g. trailing whitespace) can cause
* get_user_by() to miss an existing user and create a duplicate.
*
* Cast to string first — `wp_parse_args` defaults `email` to `false`,
* and PHP 8.1+ deprecates passing non-strings to sanitize_email().
*/
if ($customer_data['email']) {
if (! empty($customer_data['email']) && is_string($customer_data['email'])) {
$customer_data['email'] = sanitize_email($customer_data['email']);
}

Expand All @@ -166,7 +169,7 @@ function wu_create_customer($customer_data) {
$sanitized_username = strtolower($sanitized_username);
}

$customer_data['email'] = sanitize_email($customer_data['email']);
$customer_data['email'] = is_string($customer_data['email']) ? sanitize_email($customer_data['email']) : '';
if (! is_email($customer_data['email'])) {
return new \WP_Error(
'invalid_email',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -348,15 +348,22 @@ public function users($args = [], $assoc_args = [], $verbose = true) {

/*
* Now that we have all users meta keys, we can save everything into a csv file.
*
* Pass $enclosure and $escape explicitly. PHP 8.4 deprecates relying on
* the $escape default and emits a notice on every call; PHP 9 will
* change the default and could break round-tripping CSVs that contain
* backslashes inside quoted fields. The matching fgetcsv() reader
* uses the same defaults below, so explicit arguments keep export and
* import in lock-step.
*/
fputcsv($file_handler, $headers, $delimiter);
fputcsv($file_handler, $headers, $delimiter, '"', '\\');

foreach ( $user_data_arr as $user_data ) {
if ( count($headers) - count($user_data) > 0 ) {
$user_temp_data_arr = array_fill(0, count($headers) - count($user_data), '');
$user_data = array_merge(array_values($user_data), $user_temp_data_arr);
}
fputcsv($file_handler, $user_data, $delimiter);
fputcsv($file_handler, $user_data, $delimiter, '"', '\\');
}

fclose($file_handler);
Expand Down Expand Up @@ -452,11 +459,31 @@ public function all($args = [], $assoc_args = []) {
$rand = rand();

/*
* Adding rand() to the temporary file names to guarantee uniqueness.
* Place intermediate users/tables/meta files in the system temp
* directory rather than the current working directory. Writing to
* CWD made the export depend on whichever directory the SAPI started
* in (the WP root in web/AJAX context, the test repo root under
* PHPUnit, an arbitrary path under wp-cron). That had two failure
* modes:
*
* - When the CWD was not writable by PHP, file_put_contents() and
* fopen('w+') silently failed and the resulting ZIP missed the
* .csv/.sql/.json files entirely (the zip helper just skipped
* them, producing the "only DB exported" symptom users hit on
* locked-down hosts).
* - When the CWD WAS writable but the export aborted before
* cleanup, the leftovers polluted the WordPress root or the
* plugin source tree. The trailing rand() prefix made each
* failed run leave a fresh pair of files behind.
*
* sys_get_temp_dir() is always writable for the running PHP process
* and is automatically swept by the OS, so neither failure mode
* survives the move.
*/
$users_file = 'mu-migration-' . $rand . sanitize_title($site_data['name']) . '.csv';
$tables_file = 'mu-migration-' . $rand . sanitize_title($site_data['name']) . '.sql';
$meta_data_file = 'mu-migration-' . $rand . sanitize_title($site_data['name']) . '.json';
$tmp_dir = rtrim(sys_get_temp_dir(), DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR;
$users_file = $tmp_dir . 'mu-migration-' . $rand . sanitize_title($site_data['name']) . '.csv';
$tables_file = $tmp_dir . 'mu-migration-' . $rand . sanitize_title($site_data['name']) . '.sql';
$meta_data_file = $tmp_dir . 'mu-migration-' . $rand . sanitize_title($site_data['name']) . '.json';

\WP_CLI::log(__('Exporting site meta data...', 'mu-migration'));
file_put_contents($meta_data_file, wp_json_encode($site_data));
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,10 @@ public function users($args = [], $assoc_args = [], $verbose = true) {
Helpers\maybe_switch_to_blog($this->assoc_args['blog_id']);

wp_suspend_cache_addition(true);
while ( false !== ($data = fgetcsv($input_file_handler, 0, $delimiter)) ) {
// Match the fputcsv() arguments used by ExportCommand::users() so
// users.csv files round-trip correctly under PHP 8.4+, where the
// default $escape value is being deprecated and removed.
while ( false !== ($data = fgetcsv($input_file_handler, 0, $delimiter, '"', '\\')) ) {
// Read the labels and skip.
if ( 0 === $line++ ) {
$labels = $data;
Expand Down
46 changes: 41 additions & 5 deletions inc/site-exporter/mu-migration/includes/helpers.php
Original file line number Diff line number Diff line change
Expand Up @@ -366,10 +366,21 @@ function runcommand($command, $args = [], $assoc_args = [], $global_args = []) {
$full_command = sprintf('%s %s', $command, $params);

/**
* If we're in CLI context with real WP-CLI available, use it.
* Otherwise, use pure PHP implementation for web/AJAX context.
* Use real WP-CLI only when actually running through it.
*
* The `WP_CLI` constant is defined by the WP-CLI bootstrap; checking
* `PHP_SAPI === 'cli'` alone is not enough because PHPUnit also runs
* under the CLI SAPI but does not bootstrap WP-CLI, and the
* `wp-cli/wp-cli` package is autoloaded as a dev/runtime dependency
* which makes `class_exists('\WP_CLI')` return true even when there is
* no live WP-CLI runtime. Calling \WP_CLI::runcommand() in that state
* fails with `Undefined constant WP_CLI_ROOT` and the export silently
* falls back to producing only the database dump (no plugins, themes,
* or uploads). Aligning with the rest of the codebase
* (Site_Exporter::setup, trait-wp-cli, external-cron-manager) avoids
* this trap.
*/
if (PHP_SAPI === 'cli' && class_exists('\WP_CLI') && method_exists('\WP_CLI', 'runcommand')) {
if (defined('WP_CLI') && WP_CLI && class_exists('\WP_CLI') && method_exists('\WP_CLI', 'runcommand')) {
$options = [
'return' => 'all',
'launch' => false,
Expand Down Expand Up @@ -424,6 +435,26 @@ function runcommand($command, $args = [], $assoc_args = [], $global_args = []) {
true, // force_drop_tables
$wu_site_exporter_site_id
);
} elseif (strpos($full_command, 'theme enable') === 0) {
/*
* Theme activation polyfill for web/AJAX context.
*
* ImportCommand::move_themes() copies imported theme directories into
* place, then calls Helpers\runcommand('theme enable', [<slug>]) to
* activate them. Without this branch the call fell through to the
* empty-stdout default, leaving the imported theme on disk but never
* activated (the destination site continued running its previous
* theme). switch_theme() is the WordPress core equivalent.
*
* The slug arrives via the positional $args array, so it is in the
* remainder of $full_command after the literal "theme enable ".
*/
$theme_slug = trim(substr($full_command, strlen('theme enable')));
$theme_slug = trim(preg_replace('/\s+--\S+(=\S+)?/', '', $theme_slug));

if ('' !== $theme_slug) {
switch_theme($theme_slug);
}
}

return (object) [
Expand All @@ -449,9 +480,14 @@ function launch_self($command, $args = [], $assoc_args = [], $exit_on_error = tr
global $wpdb, $wu_site_exporter_site_id;

/**
* If we're in CLI context with real WP-CLI available, use it
* Use real WP-CLI only when actually running through it.
*
* Same reasoning as runcommand() above: PHPUnit and other CLI processes
* may have the wp-cli/wp-cli package autoloaded without a live WP-CLI
* runtime, so we must test for the `WP_CLI` constant set by the WP-CLI
* bootstrap rather than `PHP_SAPI === 'cli'` alone.
*/
if (PHP_SAPI === 'cli' && class_exists('\WP_CLI') && method_exists('\WP_CLI', 'launch_self')) {
if (defined('WP_CLI') && WP_CLI && class_exists('\WP_CLI') && method_exists('\WP_CLI', 'launch_self')) {
return \WP_CLI::launch_self($command, $args, $assoc_args, $exit_on_error, $return_detailed, $runtime_args);
}

Expand Down
59 changes: 59 additions & 0 deletions tests/WP_Ultimo/Domain_Mapping_Test.php
Original file line number Diff line number Diff line change
Expand Up @@ -485,6 +485,65 @@ public function test_replace_url_uses_current_mapping(): void {
$this->domain_mapping->current_mapping = null;
}

/**
* Test replace_url with null URL returns null without emitting deprecations.
*
* WordPress filters such as `home_url` can occasionally pass null values
* to filter callbacks. PHP 8.1+ deprecates passing null to internal string
* functions like preg_quote() / parse_url(), so the method must short-circuit
* before reaching them.
*/
public function test_replace_url_null_url_returns_null(): void {

$blog_id = get_current_blog_id();

$mapping = new Domain();
$mapping->set_domain('mapped.example.com');
$mapping->set_blog_id($blog_id);
$mapping->set_active(true);

$result = $this->domain_mapping->replace_url(null, $mapping);

$this->assertNull($result);
}

/**
* Test replace_url with empty string returns empty string.
*/
public function test_replace_url_empty_url_returns_empty(): void {

$blog_id = get_current_blog_id();

$mapping = new Domain();
$mapping->set_domain('mapped.example.com');
$mapping->set_blog_id($blog_id);
$mapping->set_active(true);

$result = $this->domain_mapping->replace_url('', $mapping);

$this->assertSame('', $result);
}

/**
* Test replace_url with a host-less (relative) URL returns it unchanged.
*
* parse_url('/foo', PHP_URL_HOST) returns null. Without a host guard,
* preg_quote(null, '#') triggers a PHP 8.1 deprecation notice.
*/
public function test_replace_url_relative_url_returns_original(): void {

$blog_id = get_current_blog_id();

$mapping = new Domain();
$mapping->set_domain('mapped.example.com');
$mapping->set_blog_id($blog_id);
$mapping->set_active(true);

$result = $this->domain_mapping->replace_url('/relative/path', $mapping);

$this->assertSame('/relative/path', $result);
}

// ----------------------------------------------------------------
// mangle_url
// ----------------------------------------------------------------
Expand Down
Loading
Loading