__('Pull WDesignKit Widget from Cloud', 'wdesignkit'), 'description' => __( 'Re-downloads a widget from the caller\'s cloud account (by cloud record ID r_id) and overwrites the local widget files. Inverse of push-widget. Requires cloud login. Overwrite is guarded with confirm: true and dry_run: true.', 'wdesignkit', ), 'category' => 'wdesignkit', 'input_schema' => [ 'type' => 'object', 'properties' => [ 'r_id' => [ 'type' => 'integer', 'description' => 'Marketplace cloud record ID (from get-my-cloud-widgets or local widget JSON widgetdata.r_id). Required if folder and widget_id are omitted.', ], 'builder' => [ 'type' => 'string', 'description' => 'Builder type of the widget.', 'enum' => ['elementor', 'gutenberg', 'gutenberg_core', 'bricks'], ], 'folder' => [ 'type' => 'string', 'description' => 'Widget folder name. Used to look up r_id from local JSON if r_id is omitted.', ], 'widget_id' => [ 'type' => 'string', 'description' => 'Widget unique ID. Used to look up r_id from local JSON if r_id is omitted.', ], 'confirm' => [ 'type' => 'boolean', 'description' => 'Must be true to execute local file overwrites. Omitting or passing false returns an error requiring explicit confirmation unless dry_run is true.', ], 'dry_run' => [ 'type' => 'boolean', 'description' => 'When true, fetches cloud info and returns preview of files to be updated without modifying disk files.', ], ], 'additionalProperties' => false, ], 'output_schema' => [ 'type' => 'object', 'properties' => [ 'success' => ['type' => 'boolean'], 'message' => ['type' => 'string'], 'dry_run' => ['type' => 'boolean'], 'r_id' => ['type' => 'integer'], 'widget_id' => ['type' => 'string'], 'widget_name' => ['type' => 'string'], 'builder' => ['type' => 'string'], 'folder' => ['type' => 'string'], 'duplicate_folders' => ['type' => 'array'], 'version' => ['type' => 'string'], 'files' => ['type' => 'array'], 'response' => ['type' => 'object'], ], ], 'execute_callback' => 'wdesignkit_mcp_pull_widget', 'permission_callback' => 'wdesignkit_mcp_permission_callback', 'meta' => [ 'public' => true, 'show_in_rest' => true, 'mcp' => ['public' => true], 'annotations' => [ 'instructions' => implode("\n", [ 'Re-downloads a widget from your WDesignKit cloud account over the local widget copy.', 'Requires cloud login.', 'Identify by r_id (cloud record ID) or by local builder + folder / widget_id.', 'Guarded with confirm: true. Use dry_run: true to preview proposed file overwrites.', ]), 'readonly' => false, 'destructive' => true, 'idempotent' => false, ], ], ]); function wdesignkit_mcp_pull_widget(array $input): array { set_time_limit(90); if (!defined('WDKIT_BUILDER_PATH') || !defined('WDKIT_SERVER_API_URL')) { return ['success' => false, 'message' => 'WDesignKit plugin is not active.']; } $auth = function_exists('wdesignkit_mcp_template_get_auth') ? wdesignkit_mcp_template_get_auth() : []; if (empty($auth['logged_in'])) { return ['success' => false, 'message' => $auth['message'] ?? 'Not logged in to WDesignKit cloud.']; } $r_id = (int) ($input['r_id'] ?? 0); $builder = sanitize_text_field((string) ($input['builder'] ?? '')); $folder = sanitize_file_name((string) ($input['folder'] ?? '')); $widget_id = sanitize_text_field((string) ($input['widget_id'] ?? '')); $confirm = !empty($input['confirm']); $dry_run = !empty($input['dry_run']); $allowed_builders = ['elementor', 'gutenberg', 'gutenberg_core', 'bricks']; // Lookup r_id from local widget JSON if omitted if ($r_id <= 0 && ($folder !== '' || $widget_id !== '')) { $builders_to_check = ($builder !== '' && in_array($builder, $allowed_builders, true)) ? [$builder] : $allowed_builders; foreach ($builders_to_check as $b) { $b_dir = WDKIT_BUILDER_PATH . '/' . $b; if (!is_dir($b_dir)) { continue; } $subfolders = ($folder !== '') ? [$folder] : array_diff(@scandir($b_dir) ?: [], ['.', '..']); foreach ($subfolders as $sub) { $dir_path = $b_dir . '/' . $sub; if (!is_dir($dir_path)) { continue; } foreach (array_diff(@scandir($dir_path) ?: [], ['.', '..']) as $f) { if (pathinfo($f, PATHINFO_EXTENSION) === 'json') { $raw = @file_get_contents($dir_path . '/' . $f); $data = ($raw !== false) ? json_decode($raw, true) : null; $wd = $data['widget_data']['widgetdata'] ?? []; $wid = (string) ($wd['widget_id'] ?? ''); if ($folder !== '' || $wid === $widget_id) { $r_id = (int) ($wd['r_id'] ?? 0); $builder = $b; $folder = $sub; $widget_id = $wid; break 3; } } } } } } if ($r_id <= 0) { return [ 'success' => false, 'message' => 'Provide a valid r_id or specify a local folder/widget_id that has a cloud record ID (r_id).', ]; } // Resolve user_id from auth session $u_id = (string) ($auth['user_id'] ?? ''); $token = (string) ($auth['token'] ?? ''); // Auth session's user_id is often empty — fall back to the same transient lookups // download-widget.php uses for this same widget/download endpoint, or the cloud // rejects the request with "User id not found". if ($u_id === '') { $normalise_dl_auth = static function ($raw): array { if (is_array($raw)) { return $raw; } if ($raw instanceof \stdClass) { return (array) $raw; } if (is_string($raw) && $raw !== '') { $decoded = json_decode($raw, true); if (is_array($decoded)) { return $decoded; } } return []; }; $current_wp_user = wp_get_current_user(); if ($current_wp_user && $current_wp_user->user_email) { $user_key = strstr($current_wp_user->user_email, '@', true); $auth_data = $normalise_dl_auth(get_transient('wdkit_auth_' . $user_key)); $u_id = (string) ($auth_data['user_id'] ?? $auth_data['id'] ?? ''); } if ($u_id === '' && $token !== '') { global $wpdb; $rows = $wpdb->get_results( $wpdb->prepare( "SELECT option_value FROM {$wpdb->options} WHERE option_name LIKE %s LIMIT 10", $wpdb->esc_like('_transient_wdkit_auth_') . '%' ), ARRAY_A ); foreach (($rows ?: []) as $row) { $data = $normalise_dl_auth(@maybe_unserialize($row['option_value'])); if (!empty($data['token']) && $data['token'] === $token) { $u_id = (string) ($data['user_id'] ?? $data['id'] ?? ''); if ($u_id !== '') { break; } } } } // If local transient didn't contain user_id, resolve it directly from cloud session via widget/mywidgets if ($u_id === '' && $token !== '' && function_exists('wdesignkit_mcp_template_cloud_call')) { $cloud_info = wdesignkit_mcp_template_cloud_call('widget/mywidgets', [ 'token' => $token, 'ParPage' => 1, ], 'form'); if (!empty($cloud_info['data'])) { $c_data = $cloud_info['data']; $found_uid = (string) ($c_data['userinfo']['id'] ?? $c_data['user_id'] ?? ''); if ($found_uid === '' && !empty($c_data['widgets']) && is_array($c_data['widgets'])) { $w0 = $c_data['widgets'][0] ?? []; $found_uid = (string) ($w0['user_id'] ?? $w0['u_id'] ?? $w0['post_author'] ?? ''); } if ($found_uid !== '') { $u_id = $found_uid; // Cache user_id back into current session transient for future calls $session = function_exists('wdesignkit_mcp_find_auth_session') ? wdesignkit_mcp_find_auth_session() : []; if (!empty($session['key'])) { $t_data = get_transient('wdkit_auth_' . $session['key']); $t_data = is_array($t_data) ? $t_data : (is_string($t_data) ? json_decode($t_data, true) : []); if (is_array($t_data)) { $t_data['user_id'] = $u_id; set_transient('wdkit_auth_' . $session['key'], $t_data, 7776000); } } } } } } $args = [ 'id' => $r_id, 'u_id' => $u_id, 'type' => '', 'unique_id' => get_option('wdkit_unique_id', ''), 'token' => $token, ]; $response = wp_remote_post( WDKIT_SERVER_API_URL . 'api/wp/widget/download', [ 'method' => 'POST', 'body' => $args, 'timeout' => 60, ] ); if (is_wp_error($response)) { return ['success' => false, 'message' => $response->get_error_message()]; } $status = wp_remote_retrieve_response_code($response); $body = wp_remote_retrieve_body($response); $data = json_decode($body, true); if (200 !== (int) $status || empty($data['success'])) { return [ 'success' => false, 'message' => $data['massage'] ?? $data['message'] ?? "Cloud returned status {$status}.", 'response' => wdesignkit_mcp_ensure_object($data, $body), ]; } $res = is_array($data['data']['data'] ?? null) ? $data['data']['data'] : ($data['data'] ?? []); $img_url = sanitize_url((string) ($res['image'] ?? '')); $json_raw = $res['json'] ?? null; if (empty($json_raw)) { return ['success' => false, 'message' => 'Cloud returned no widget data.', 'response' => wdesignkit_mcp_ensure_object($data, $body)]; } if (is_string($json_raw)) { $json_raw = json_decode($json_raw, true); } if (is_string($json_raw)) { $json_raw = json_decode($json_raw, true); } if (!is_array($json_raw)) { return ['success' => false, 'message' => 'Widget JSON from cloud could not be decoded.', 'response' => wdesignkit_mcp_ensure_object($data, $body)]; } $widgetdata = $json_raw['widget_data']['widgetdata'] ?? []; $cloud_title = sanitize_text_field((string) ($widgetdata['name'] ?? '')); $cloud_builder = sanitize_key((string) ($widgetdata['type'] ?? '')); $cloud_wid = sanitize_text_field((string) ($widgetdata['widget_id'] ?? '')); $cloud_version = (string) ($widgetdata['widget_version'] ?? '1.0.0'); if ($cloud_title === '' || $cloud_builder === '') { return ['success' => false, 'message' => 'Downloaded widget JSON is missing required fields (name, type).']; } // Stable folder and file naming, via the canonical helpers so every writer agrees on the // name — the loader pairs ".php" with ".json", so a writer that derives the // base differently orphans the widget (ClickUp 86d41cck5). These also apply // sanitize_file_name(), which the hand-rolled derivation here skipped entirely. $widget_uid = $cloud_wid ?: $r_id; $folder_name = ($folder !== '') ? $folder : wdesignkit_widget_folder_name($cloud_title, $widget_uid); $file_name = wdesignkit_widget_file_name($cloud_title, $widget_uid); $builder_dir = WDKIT_BUILDER_PATH . '/' . $cloud_builder; // Reuse the folder this widget already occupies instead of minting a new name for it, the // same way download-widget does. Without this, pulling a widget whose folder was written // under an older convention (e.g. a pre-2.6.4 lowercase "my-widget_id") created a second // directory differing only by case — two folders holding one widget on Linux, a silent // write into the wrong one on macOS/Windows (ClickUp 86d41ccka). Match on the widget id, // which is the suffix of every folder name. An explicit folder argument still wins: the // resolver above sets it from an on-disk folder, so the caller has already chosen a target. $duplicate_folders = []; if ($folder === '') { $existing_folder = wdesignkit_find_widget_folder($builder_dir, $widget_uid, $folder_matches); if ($existing_folder !== '') { $folder_name = $existing_folder; // Surface any other folder still holding this widget id. These are inert — a // builder loader only registers a folder containing a .php — but reporting them // lets the caller clear them instead of finding the widget listed twice. $duplicate_folders = array_values(array_diff($folder_matches, [$existing_folder])); } } $widget_dir = $builder_dir . '/' . $folder_name; // Adopt the file base name already used inside the target folder, whichever way that // folder was chosen, so the refreshed files replace the existing ones rather than landing // beside them under a second naming convention. foreach (@scandir($widget_dir) ?: [] as $existing_file) { if (pathinfo($existing_file, PATHINFO_EXTENSION) === 'json') { $file_name = pathinfo($existing_file, PATHINFO_FILENAME); break; } } $proposed_files = [ $folder_name . '/' . $file_name . '.json', ]; if (!empty($json_raw['Editor_data']['css'])) { $proposed_files[] = $folder_name . '/' . $file_name . '.css'; } if (!empty($json_raw['Editor_data']['js'])) { $proposed_files[] = $folder_name . '/' . $file_name . '.js'; } if ($img_url !== '') { $proposed_files[] = $folder_name . '/' . $file_name . '.png (or matching extension)'; } // Dry Run check if ($dry_run) { return [ 'success' => true, 'message' => "Dry run: Proposed pull of widget '{$cloud_title}' (r_id: {$r_id}, version: {$cloud_version}) from cloud.", 'dry_run' => true, 'r_id' => $r_id, 'widget_id' => $cloud_wid, 'widget_name' => $cloud_title, 'builder' => $cloud_builder, 'folder' => $folder_name, 'version' => $cloud_version, 'files' => $proposed_files, 'response' => wdesignkit_mcp_ensure_object($data, $body), ]; } // Confirmation check if (!$confirm) { return [ 'success' => false, 'message' => "Pulling widget '{$cloud_title}' (r_id: {$r_id}) will overwrite local files in {$cloud_builder}/{$folder_name}. Re-send request with confirm: true to proceed, or use dry_run: true to preview.", ]; } if (!wp_mkdir_p($widget_dir)) { return ['success' => false, 'message' => "Could not create widget folder: {$cloud_builder}/{$folder_name}"]; } // Path safety check $real_widget = realpath($widget_dir); $real_base = realpath(WDKIT_BUILDER_PATH); if (!$real_widget || !$real_base || strpos($real_widget, $real_base . DIRECTORY_SEPARATOR) !== 0) { return ['success' => false, 'message' => 'Invalid widget path.']; } // Fetch and save thumbnail if provided. // SSRF guard (CWE-918): img_url comes from the cloud response and is written straight to // disk below; wp_remote_get() has no host validation at all, so wdesignkit_safe_remote_get() // (resolves the host and blocks loopback/private/link-local/cloud-metadata ranges) is used // instead of a plain fetch. if ($img_url !== '') { $img_resp = wdesignkit_safe_remote_get($img_url, ['timeout' => 30]); if (!is_wp_error($img_resp)) { // sanitize_file_name() passes "php" through unchanged, so the remote extension was // effectively unvalidated and a ".php" thumbnail URL wrote executable PHP into the // builder directory (CWE-434, ClickUp 86d41cczd). Verify against the payload; '' means // the bytes are not an image we accept, so nothing is written. $img_body = (string) wp_remote_retrieve_body($img_resp); $img_ext = wdesignkit_safe_image_extension($img_url, $img_body); if ($img_ext !== '') { if (defined('WDKIT_SERVER_PATH')) { $json_raw['widget_data']['widgetdata']['w_image'] = WDKIT_SERVER_PATH . "/{$cloud_builder}/{$folder_name}/{$file_name}.{$img_ext}"; } @file_put_contents($widget_dir . '/' . $file_name . '.' . $img_ext, $img_body); } } } // Save JSON config $json_written = @file_put_contents( $widget_dir . '/' . $file_name . '.json', wp_json_encode($json_raw, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) ); if ($json_written === false) { return ['success' => false, 'message' => 'Could not write widget JSON file to disk.']; } // Write CSS and JS files if present in Editor_data if (!empty($json_raw['Editor_data']['css'])) { @file_put_contents($widget_dir . '/' . $file_name . '.css', (string) $json_raw['Editor_data']['css']); } if (!empty($json_raw['Editor_data']['js'])) { @file_put_contents($widget_dir . '/' . $file_name . '.js', (string) $json_raw['Editor_data']['js']); } if (function_exists('wdesignkit_invalidate_widget_registry')) { wdesignkit_invalidate_widget_registry($cloud_builder); } $message = "Widget '{$cloud_title}' (r_id: {$r_id}) pulled from cloud and updated locally."; if (!empty($duplicate_folders)) { $message .= ' Note: this widget_id also occupies ' . count($duplicate_folders) . ' other folder(s) from an earlier naming convention (' . implode(', ', $duplicate_folders) . '). They are not loaded and can be deleted.'; } return [ 'success' => true, 'message' => $message, 'dry_run' => false, 'r_id' => $r_id, 'widget_id' => $cloud_wid, 'widget_name' => $cloud_title, 'builder' => $cloud_builder, 'folder' => $folder_name, 'duplicate_folders' => $duplicate_folders, 'version' => $cloud_version, 'files' => $proposed_files, 'response' => wdesignkit_mcp_ensure_object($data, $body), ]; }