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
323 changes: 322 additions & 1 deletion inc/helpers/class-site-duplicator.php
Original file line number Diff line number Diff line change
Expand Up @@ -253,6 +253,48 @@ protected static function process_duplication($args) {

\MUCD_Data::copy_data($args->from_site_id, $args->to_site_id);

/*
* Resolve the real template source from wu_template_id site meta.
*
* MUCD's hooks pass a from_site_id that may differ from the template
* the customer actually selected at checkout. WP Ultimo stores the
* customer's real choice in the wu_template_id site meta key.
* Prefer that over the explicit param when available.
*
* @since 2.3.1
* @see https://github.com/Ultimate-Multisite/ultimate-multisite/issues/820
*/
$meta_template = (int) get_site_meta($args->to_site_id, 'wu_template_id', true);
if ($meta_template > 0 && $meta_template !== (int) $args->from_site_id) {
$args->from_site_id = $meta_template;
Comment on lines +267 to +269
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.

🛠️ Refactor suggestion | 🟠 Major

Don't overwrite from_site_id in place after copy_data().

copy_data() and copy_files() have already run with the original source by this point, but copy_users() and the wu_duplicate_site payload now read the rewritten value. If wu_template_id differs, the clone can mix tables/files from one site with users and downstream hook context from another. Keep the resolved template ID in a separate variable for the backfill/hook path instead of mutating $args->from_site_id.

💡 Proposed direction
-		$meta_template = (int) get_site_meta($args->to_site_id, 'wu_template_id', true);
-		if ($meta_template > 0 && $meta_template !== (int) $args->from_site_id) {
-			$args->from_site_id = $meta_template;
-		}
+		$template_site_id = (int) $args->from_site_id;
+		$meta_template    = (int) get_site_meta($args->to_site_id, 'wu_template_id', true);
+		if (0 < $meta_template && $meta_template !== (int) $args->from_site_id) {
+			$template_site_id = $meta_template;
+		}-		self::backfill_postmeta($args->from_site_id, $args->to_site_id);
-		self::verify_kit_integrity($args->from_site_id, $args->to_site_id);
+		self::backfill_postmeta($template_site_id, $args->to_site_id);
+		self::verify_kit_integrity($template_site_id, $args->to_site_id);-				'from_site_id' => $args->from_site_id,
+				'from_site_id' => $template_site_id,

Also applies to: 298-320

🤖 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 267 - 269, Do not mutate
$args->from_site_id after copy_data()/copy_files() have run; instead resolve the
template override into a new variable (e.g. $resolved_template_from_site_id or
$template_source_id) when you read wu_template_id and, for the backfill/hook
path and copy_users()/wu_duplicate_site payload only, use that new variable;
leave $args->from_site_id intact so copy_data(), copy_files(), and any earlier
logic continue to reference the original source. Ensure the same non-mutating
change is applied to the other occurrence(s) handling the template override (the
block currently duplicated further down).

}

/*
* Backfill postmeta that MUCD_Data::copy_data() misses.
*
* MUCD copies table data with INSERT ... SELECT (full-table copy), but
* certain post types end up with missing postmeta rows — particularly
* nav_menu_item, attachment, and elementor_library posts. The Elementor
* Kit post (usually ID 3) also gets stub postmeta that must be
* overwritten with the real template values.
*
* @since 2.3.1
* @see https://github.com/Ultimate-Multisite/ultimate-multisite/issues/820
*/
self::backfill_postmeta($args->from_site_id, $args->to_site_id);

/*
* Verify Kit integrity after backfill.
*
* Compares the byte length of _elementor_page_settings between the
* template and the clone. If the clone has less than 80% of the
* template's byte count, the Kit fix is re-applied as a safety net.
*
* @since 2.3.1
* @see https://github.com/Ultimate-Multisite/ultimate-multisite/issues/820
*/
self::verify_kit_integrity($args->from_site_id, $args->to_site_id);
Comment on lines +284 to +296
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

Backfilled postmeta is bypassing MUCD's rewrite step.

These copies happen after MUCD_Data::copy_data() has already finished db_update_data() in inc/duplication/data.php:26-150. Any source-domain values embedded in _elementor_*, Kit settings, or custom menu URLs are inserted verbatim here, so they won't go through the same source→target replacement pass as the rest of the clone.

Also applies to: 381-385, 424-586

🤖 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 284 - 296, The
backfill_postmeta call is copying raw _elementor_* and other postmeta after
MUCD_Data::copy_data() (which runs db_update_data()) finishes, so those values
never pass through the source→target rewrite; move or re-run the rewrite for
these meta values by either (A) performing the backfill_postmeta step before
MUCD_Data::copy_data()/db_update_data() or (B) invoking the same rewrite/replace
routine that db_update_data() uses on the newly-inserted meta after
self::backfill_postmeta(...) completes; locate and update the call to
self::backfill_postmeta and ensure you apply the replacement logic to keys like
_elementor_page_settings, other _elementor_* keys and menu URLs (and keep
verify_kit_integrity after the rewrite) so cloned meta contain target-domain
values.


if ($args->keep_users) {
\MUCD_Duplicate::copy_users($args->from_site_id, $args->to_site_id);
}
Expand All @@ -274,7 +316,8 @@ protected static function process_duplication($args) {
do_action(
'wu_duplicate_site',
[
'site_id' => $args->to_site_id,
'from_site_id' => $args->from_site_id,
'site_id' => $args->to_site_id,
]
);

Expand Down Expand Up @@ -311,4 +354,282 @@ public static function create_admin($email, $domain) {

return $user_id;
}

/**
* Backfill postmeta rows that MUCD_Data::copy_data() misses.
*
* MUCD copies table data with INSERT ... SELECT, but certain post types
* end up with missing or stub postmeta rows. This method fills the gaps
* for nav_menu_item, attachment, and Elementor post types, and force-
* overwrites the Elementor Kit settings which MUCD inserts as stubs.
*
* @since 2.3.1
* @see https://github.com/Ultimate-Multisite/ultimate-multisite/issues/820
*
* @param int $from_site_id Source (template) blog ID.
* @param int $to_site_id Target (cloned) blog ID.
*/
protected static function backfill_postmeta($from_site_id, $to_site_id) {

$from_site_id = (int) $from_site_id;
$to_site_id = (int) $to_site_id;

if ( ! $from_site_id || ! $to_site_id || $from_site_id === $to_site_id) {
return;
}

self::backfill_nav_menu_postmeta($from_site_id, $to_site_id);
self::backfill_attachment_postmeta($from_site_id, $to_site_id);
self::backfill_elementor_postmeta($from_site_id, $to_site_id);
self::backfill_kit_settings($from_site_id, $to_site_id);
}

/**
* Backfill nav_menu_item postmeta from template to cloned site.
*
* MUCD copies nav_menu_item posts (preserving IDs) but not their postmeta
* rows. Without these rows, menus render as empty list items with no
* titles, URLs, or parent relationships.
*
* @since 2.3.1
*
* @param int $from_site_id Source blog ID.
* @param int $to_site_id Target blog ID.
*/
protected static function backfill_nav_menu_postmeta($from_site_id, $to_site_id) {

global $wpdb;

$from_prefix = $wpdb->get_blog_prefix($from_site_id);
$to_prefix = $wpdb->get_blog_prefix($to_site_id);

if ($from_prefix === $to_prefix) {
return;
}

$meta_keys = [
'_menu_item_type',
'_menu_item_menu_item_parent',
'_menu_item_object_id',
'_menu_item_object',
'_menu_item_target',
'_menu_item_classes',
'_menu_item_xfn',
'_menu_item_url',
];

$placeholders = implode(',', array_fill(0, count($meta_keys), '%s'));

// phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared
$wpdb->query(
$wpdb->prepare(
"INSERT INTO {$to_prefix}postmeta (post_id, meta_key, meta_value)
SELECT src.post_id, src.meta_key, src.meta_value
FROM {$from_prefix}postmeta src
INNER JOIN {$to_prefix}posts tgt
ON tgt.ID = src.post_id
AND tgt.post_type = 'nav_menu_item'
WHERE src.meta_key IN ({$placeholders})
AND NOT EXISTS (
SELECT 1 FROM {$to_prefix}postmeta tpm
WHERE tpm.post_id = src.post_id
AND tpm.meta_key = src.meta_key
)",
...$meta_keys
)
);
// phpcs:enable
}

/**
* Backfill attachment postmeta from template to cloned site.
*
* MUCD copies attachment posts but not their postmeta. Without
* _wp_attached_file, wp_get_attachment_image_url() returns false and
* images disappear even though the physical files exist on disk.
*
* @since 2.3.1
*
* @param int $from_site_id Source blog ID.
* @param int $to_site_id Target blog ID.
*/
protected static function backfill_attachment_postmeta($from_site_id, $to_site_id) {

global $wpdb;

$from_prefix = $wpdb->get_blog_prefix($from_site_id);
$to_prefix = $wpdb->get_blog_prefix($to_site_id);

if ($from_prefix === $to_prefix) {
return;
}

// phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared
$wpdb->query(
"INSERT INTO {$to_prefix}postmeta (post_id, meta_key, meta_value)
SELECT src.post_id, src.meta_key, src.meta_value
FROM {$from_prefix}postmeta src
INNER JOIN {$to_prefix}posts tgt
ON tgt.ID = src.post_id
AND tgt.post_type = 'attachment'
WHERE NOT EXISTS (
SELECT 1 FROM {$to_prefix}postmeta tpm
WHERE tpm.post_id = src.post_id
AND tpm.meta_key = src.meta_key
)"
);
// phpcs:enable
}

/**
* Backfill Elementor postmeta for all post types.
*
* Catch-all for any _elementor_* meta that MUCD missed. Covers
* elementor_library (headers, footers, popups), e-landing-page,
* elementor_snippet, and any custom post type with Elementor data.
*
* @since 2.3.1
*
* @param int $from_site_id Source blog ID.
* @param int $to_site_id Target blog ID.
*/
protected static function backfill_elementor_postmeta($from_site_id, $to_site_id) {

global $wpdb;

$from_prefix = $wpdb->get_blog_prefix($from_site_id);
$to_prefix = $wpdb->get_blog_prefix($to_site_id);

if ($from_prefix === $to_prefix) {
return;
}

$like_pattern = $wpdb->esc_like('_elementor') . '%';

// phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared
$wpdb->query(
$wpdb->prepare(
"INSERT INTO {$to_prefix}postmeta (post_id, meta_key, meta_value)
SELECT src.post_id, src.meta_key, src.meta_value
FROM {$from_prefix}postmeta src
INNER JOIN {$to_prefix}posts tgt
ON tgt.ID = src.post_id
WHERE src.meta_key LIKE %s
AND NOT EXISTS (
SELECT 1 FROM {$to_prefix}postmeta tpm
WHERE tpm.post_id = src.post_id
AND tpm.meta_key = src.meta_key
)",
$like_pattern
)
);
// phpcs:enable
}

/**
* Force-overwrite the Elementor Kit settings on the cloned site.
*
* The Kit post (holding colors, typography, logo) gets created with stub
* Elementor defaults BEFORE MUCD runs its INSERT ... SELECT. Because MUCD
* uses INSERT NOT EXISTS, the stub row is never overwritten, leaving the
* clone with default Elementor colors instead of the template palette.
*
* This method reads the real settings from the template and uses
* update_post_meta() to guarantee the overwrite.
*
* @since 2.3.1
*
* @param int $from_site_id Source blog ID.
* @param int $to_site_id Target blog ID.
*/
protected static function backfill_kit_settings($from_site_id, $to_site_id) {

// Read kit settings from the template site.
switch_to_blog($from_site_id);

$kit_id_from = (int) get_option('elementor_active_kit', 0);
$kit_settings = $kit_id_from ? get_post_meta($kit_id_from, '_elementor_page_settings', true) : '';
$kit_data = $kit_id_from ? get_post_meta($kit_id_from, '_elementor_data', true) : '';

restore_current_blog();

if (empty($kit_settings)) {
return;
}

// Force-overwrite kit settings on the target site.
// Uses update_post_meta() instead of INSERT NOT EXISTS because
// the target kit may already have stub metadata from Elementor's
// activation routine. INSERT NOT EXISTS would silently skip the
// row, leaving the clone with default Elementor colors.
switch_to_blog($to_site_id);

$kit_id_to = (int) get_option('elementor_active_kit', 0);

if ( ! $kit_id_to && $kit_id_from) {
$kit_id_to = $kit_id_from;
update_option('elementor_active_kit', $kit_id_to);
}

if ($kit_id_to) {
update_post_meta($kit_id_to, '_elementor_page_settings', $kit_settings);

if ( ! empty($kit_data) && '[]' !== $kit_data) {
update_post_meta($kit_id_to, '_elementor_data', $kit_data);
}

// Clear compiled CSS so Elementor_Compat::regenerate_css() will
// rebuild with the correct Kit settings on wu_duplicate_site.
delete_post_meta($kit_id_to, '_elementor_css');
}

restore_current_blog();
}

/**
* Verify Kit integrity after clone and re-apply if mismatched.
*
* Compares the byte length of _elementor_page_settings between the
* template and the clone. If the clone has less than 80% of the
* template's byte count, the Kit fix is re-applied as a safety net.
*
* This catches edge cases where update_post_meta() succeeded but the
* stored value was truncated by a concurrent write, or where Elementor's
* activation routine overwrote the Kit settings after backfill.
*
* @since 2.3.1
* @see https://github.com/Ultimate-Multisite/ultimate-multisite/issues/820
*
* @param int $from_site_id Source blog ID.
* @param int $to_site_id Target blog ID.
*/
protected static function verify_kit_integrity($from_site_id, $to_site_id) {

$from_site_id = (int) $from_site_id;
$to_site_id = (int) $to_site_id;

if ( ! $from_site_id || ! $to_site_id || $from_site_id === $to_site_id) {
return;
}

switch_to_blog($from_site_id);
$kit_id_from = (int) get_option('elementor_active_kit', 0);
$from_size = $kit_id_from ? strlen(maybe_serialize(get_post_meta($kit_id_from, '_elementor_page_settings', true))) : 0;
restore_current_blog();

switch_to_blog($to_site_id);
$kit_id_to = (int) get_option('elementor_active_kit', 0);
$to_size = $kit_id_to ? strlen(maybe_serialize(get_post_meta($kit_id_to, '_elementor_page_settings', true))) : 0;
restore_current_blog();

if ( ! $from_size || ! $to_size) {
return;
}

// If the clone has less than 80% of the template's byte count,
// the Kit settings are likely incomplete — re-apply the fix.
if ($to_size < ($from_size * 0.8)) {
self::backfill_kit_settings($from_site_id, $to_site_id);
}
}
}
Loading
Loading