Skip to content
Merged
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
140 changes: 140 additions & 0 deletions inc/helpers/class-site-duplicator.php
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,35 @@ public static function override_site($from_site_id, $to_site_id, $args = []) {
return false;
}

// FIX (KP) — Reset blog/cache context BEFORE override.
// MUCD_Data::copy_data() leaves $wpdb in template-blog context and
// pollutes email_exists() / user lookup caches. On consecutive
// override calls, this causes create_admin() to fail with
// "Could not create admin user". Forcing context reset prevents this.
while ( function_exists('ms_is_switched') && ms_is_switched() ) {
restore_current_blog();
}
Comment on lines +108 to +110
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🌐 Web query:

In WordPress multisite, does while ( ms_is_switched() ) { restore_current_blog(); }unwind the entireswitch_to_blog() stack, including contexts established by the caller?

💡 Result:

Yes, the loop while ( ms_is_switched ) { restore_current_blog; } unwinds the entire switch_to_blog stack, restoring the context to the original blog and state established before any switches by the caller.

Citations:


🏁 Script executed:

cat -n inc/helpers/class-site-duplicator.php | head -120

Repository: Ultimate-Multisite/ultimate-multisite

Length of output: 5129


🏁 Script executed:

sed -n '200,220p' inc/helpers/class-site-duplicator.php

Repository: Ultimate-Multisite/ultimate-multisite

Length of output: 1080


🏁 Script executed:

rg 'override_site\(' inc/ --type php -B 2 -A 2

Repository: Ultimate-Multisite/ultimate-multisite

Length of output: 777


🏁 Script executed:

rg -B 5 -A 10 'override_site\(' inc/ui/class-template-switching-element.php

Repository: Ultimate-Multisite/ultimate-multisite

Length of output: 716


🏁 Script executed:

sed -n '94,250p' inc/helpers/class-site-duplicator.php | grep -n 'switch_to_blog'

Repository: Ultimate-Multisite/ultimate-multisite

Length of output: 63


🏁 Script executed:

rg 'switch_to_blog' inc/helpers/class-site-duplicator.php

Repository: Ultimate-Multisite/ultimate-multisite

Length of output: 277


🏁 Script executed:

sed -n '94,180p' inc/helpers/class-site-duplicator.php

Repository: Ultimate-Multisite/ultimate-multisite

Length of output: 3520


🏁 Script executed:

sed -n '180,235p' inc/helpers/class-site-duplicator.php

Repository: Ultimate-Multisite/ultimate-multisite

Length of output: 2217


Preserve the caller's switched-blog context instead of unwinding it entirely.

Both while ( function_exists('ms_is_switched') && ms_is_switched() ) { restore_current_blog(); } loops (lines 108-110 and 209-211) restore all the way back to the network context, which discards any switch_to_blog() scope established by the caller and returns in the wrong blog context. Capture the initial switched depth or blog ID at the start of the method and restore only to that point instead of unwinding the entire stack.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@inc/helpers/class-site-duplicator.php` around lines 108 - 110, The current
loops that fully unwind blog switches by repeatedly calling
restore_current_blog() (the blocks using while (
function_exists('ms_is_switched') && ms_is_switched() ) {
restore_current_blog(); }) discard any switch_to_blog() state the caller
established; instead, at the start of the method capture the caller's switch
depth (or current blog ID) by checking ms_get_switched_blog() / ms_is_switched()
or storing get_current_blog_id() and ms_current_switched(); then after your
operation restore only back to that captured depth/ID (call
restore_current_blog() the exact number of times needed or switch_to_blog() back
to the original blog) rather than looping until ms_is_switched() is false; apply
this change to both occurrences (the loops at the shown blocks).

wp_cache_flush();

// FIX (KP) — Pre-extend timeouts. Default 30s PHP-FPM timeout
// truncates copy_files for sites >50MB of media. 300s/512M is safe.
@set_time_limit(300);
@ini_set('memory_limit', '512M');
if ( ! defined('WP_IMPORTING') ) {
define('WP_IMPORTING', true);
}

// FIX (KP) — Snapshot identity BEFORE process_duplication runs.
// copy_data() will overwrite blogname / admin_email / wp_blogmeta
// from the template. We need the originals to restore after.
$identity_snapshot = [
'blogname' => get_blog_option($to_site_id, 'blogname'),
'blogdescription' => get_blog_option($to_site_id, 'blogdescription'),
'home' => get_blog_option($to_site_id, 'home'),
'siteurl' => get_blog_option($to_site_id, 'siteurl'),
'admin_email' => get_blog_option($to_site_id, 'admin_email'),
];

$to_site_membership_id = $to_site->get_membership_id();

$to_site_membership = $to_site->get_membership();
Expand All @@ -109,6 +138,12 @@ public static function override_site($from_site_id, $to_site_id, $args = []) {
// Determine email - use customer email if available, otherwise use site admin email
$email = $to_site_customer ? $to_site_customer->get_email_address() : get_blog_option($to_site_id, 'admin_email');

// FIX (KP) — Pre-clean user cache for the customer's user_id so
// email_exists() returns the correct value during create_admin().
if ( $to_site_customer && method_exists($to_site_customer, 'get_user_id') ) {
clean_user_cache( $to_site_customer->get_user_id() );
}

$args = wp_parse_args(
$args,
[
Expand All @@ -133,6 +168,21 @@ public static function override_site($from_site_id, $to_site_id, $args = []) {
return false;
}

// FIX (KP) — Restore identity IMMEDIATELY after copy_data overwrote it.
// blogname is the customer's brand — must NEVER be replaced with template's name.
foreach ($identity_snapshot as $opt_key => $opt_val) {
if ( ! empty($opt_val) ) {
update_blog_option($to_site_id, $opt_key, $opt_val);
}
Comment on lines +173 to +176
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Restore intentionally blank identity fields too.

Using empty() here skips valid values like an intentionally blank blogdescription, so those fields stay overwritten by the template after the switch. Guard against a missing option value instead of a blank string.

Proposed fix
-		foreach ($identity_snapshot as $opt_key => $opt_val) {
-			if ( ! empty($opt_val) ) {
-				update_blog_option($to_site_id, $opt_key, $opt_val);
-			}
-		}
+		foreach ($identity_snapshot as $opt_key => $opt_val) {
+			if ( false !== $opt_val ) {
+				update_blog_option($to_site_id, $opt_key, $opt_val);
+			}
+		}
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
foreach ($identity_snapshot as $opt_key => $opt_val) {
if ( ! empty($opt_val) ) {
update_blog_option($to_site_id, $opt_key, $opt_val);
}
foreach ($identity_snapshot as $opt_key => $opt_val) {
if ( false !== $opt_val ) {
update_blog_option($to_site_id, $opt_key, $opt_val);
}
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@inc/helpers/class-site-duplicator.php` around lines 173 - 176, In the foreach
over $identity_snapshot in class-site-duplicator.php, stop using empty($opt_val)
(which treats intentionally blank strings as "empty") and instead guard only
against a missing/null value; change the condition to check $opt_val !== null
(or !== false if your source can return false for missing) before calling
update_blog_option($to_site_id, $opt_key, $opt_val) so intentionally blank
values like '' are preserved when updating options.

}

// FIX (KP) — Force-copy Elementor Kit settings/data from source template.
// MUCD copies postmeta rows but Elementor's serialize/cache layer
// sometimes retains stale values, causing the customer site to render
// with previous template's colors. Explicit overwrite + CSS regen
// guarantees colors match the chosen template.
self::force_copy_elementor_kit($from_site_id, $to_site_id);

$new_to_site = wu_get_site($duplicate_site_id);

$new_to_site->set_membership_id($to_site_membership_id);
Expand All @@ -155,6 +205,16 @@ public static function override_site($from_site_id, $to_site_id, $args = []) {
return false;
}

// FIX (KP) — Cleanup context AFTER override so next call starts clean.
while ( function_exists('ms_is_switched') && ms_is_switched() ) {
restore_current_blog();
}
wp_cache_flush();
clean_blog_cache($to_site_id);
if ( $to_site_customer && method_exists($to_site_customer, 'get_user_id') ) {
clean_user_cache( $to_site_customer->get_user_id() );
}

// translators: %1$d is the ID of the site template used, and %2$d is the ID of the overriden site.
$message = sprintf(__('Attempt to override site %1$d with data from site %2$d successful.', 'ultimate-multisite'), $from_site_id, $duplicate_site_id);

Expand All @@ -163,6 +223,86 @@ public static function override_site($from_site_id, $to_site_id, $args = []) {
return $saved;
}

/**
* FIX (KP) — Force-copy Elementor Kit settings + data from source template.
*
* MUCD_Data::copy_data() copies postmeta rows but the Elementor Kit's
* `_elementor_page_settings` and `_elementor_data` are sometimes stale
* because Elementor uses internal caches. Forcing an explicit copy +
* regen guarantees colors/typography match the chosen template.
*
* @since 2.x.x
*
* @param int $from_template_id Source template blog_id (e.g. plantilla1.example.com)
* @param int $to_blog_id Target customer subsite blog_id
* @return bool true if Kit copied and regen'd, false if skipped
*/
public static function force_copy_elementor_kit($from_template_id, $to_blog_id) {
$from_template_id = (int) $from_template_id;
$to_blog_id = (int) $to_blog_id;

if ( $from_template_id <= 0 || $to_blog_id <= 1 ) {
return false;
}
if ( ! class_exists('\Elementor\Plugin') ) {
return false;
}

// Read Kit settings from source template.
switch_to_blog($from_template_id);
$src_kit_id = (int) get_option('elementor_active_kit');
$src_settings = $src_kit_id > 0 ? get_post_meta($src_kit_id, '_elementor_page_settings', true) : null;
$src_data = $src_kit_id > 0 ? get_post_meta($src_kit_id, '_elementor_data', true) : null;
$src_all_meta = $src_kit_id > 0 ? get_post_meta($src_kit_id) : [];
restore_current_blog();

if ( $src_kit_id <= 0 || empty($src_settings) ) {
return false;
}

// Apply to target subsite.
switch_to_blog($to_blog_id);
$dst_kit_id = (int) get_option('elementor_active_kit');
if ( $dst_kit_id <= 0 ) {
update_option('elementor_active_kit', $src_kit_id);
$dst_kit_id = $src_kit_id;
}

update_post_meta($dst_kit_id, '_elementor_page_settings', $src_settings);
if ( ! empty($src_data) ) {
update_post_meta($dst_kit_id, '_elementor_data', $src_data);
}

// Copy ALL _elementor_* and _wp_* meta from src Kit.
if ( ! empty($src_all_meta) ) {
foreach ($src_all_meta as $meta_key => $values) {
if ( strpos($meta_key, '_elementor') !== 0 && strpos($meta_key, '_wp_') !== 0 ) {
continue;
}
if ( in_array($meta_key, ['_elementor_page_settings', '_elementor_data'], true) ) {
continue;
}
delete_post_meta($dst_kit_id, $meta_key);
foreach ($values as $v) {
$unserialized = maybe_unserialize($v);
add_post_meta($dst_kit_id, $meta_key, $unserialized);
}
}
}

// Regenerate Kit CSS so frontend reflects new colors immediately.
if ( class_exists('\Elementor\Core\Files\CSS\Post') ) {
try {
(new \Elementor\Core\Files\CSS\Post($dst_kit_id))->update();
} catch (\Throwable $e) {
// Continue — caller should not fail because of CSS regen issues.
}
}

restore_current_blog();
return true;
}

/**
* Processes a site duplication.
*
Expand Down
Loading