PluginProbe
WDesignKit – AI Templates, Widget Builder & MCP Workflow / trunk
WDesignKit – AI Templates, Widget Builder & MCP Workflow vtrunk
2.6.6 2.6.5 2.6.4 2.6.3 2.6.2 2.6.1 2.6.0 2.5.5 2.5.4 2.5.3 2.5.2 2.5.1 2.5.0 2.4.0 2.3.3 2.3.2 2.3.1 1.0.10 1.0.11 1.0.12 1.0.13 1.0.14 1.0.15 1.0.16 1.0.17 All 128 releases
wdesignkit / includes / abilities / widgets / wdesignkit-pull-widget.php

wdesignkit-pull-widget.php in WDesignKit – AI Templates, Widget Builder & MCP Workflow trunk, at includes/abilities/widgets/wdesignkit-pull-widget.php

449 lines 19.6 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Ability: Pull / restore a local WDesignKit widget from its cloud record.
4 */
5
6 declare(strict_types=1);
7
8 if (!defined('ABSPATH')) {
9 exit();
10 }
11
12 wp_register_ability('wdesignkit/pull-widget', [
13 'label' => __('Pull WDesignKit Widget from Cloud', 'wdesignkit'),
14 'description' => __(
15 '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.',
16 'wdesignkit',
17 ),
18 'category' => 'wdesignkit',
19 'input_schema' => [
20 'type' => 'object',
21 'properties' => [
22 'r_id' => [
23 'type' => 'integer',
24 '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.',
25 ],
26 'builder' => [
27 'type' => 'string',
28 'description' => 'Builder type of the widget.',
29 'enum' => ['elementor', 'gutenberg', 'gutenberg_core', 'bricks'],
30 ],
31 'folder' => [
32 'type' => 'string',
33 'description' => 'Widget folder name. Used to look up r_id from local JSON if r_id is omitted.',
34 ],
35 'widget_id' => [
36 'type' => 'string',
37 'description' => 'Widget unique ID. Used to look up r_id from local JSON if r_id is omitted.',
38 ],
39 'confirm' => [
40 'type' => 'boolean',
41 'description' => 'Must be true to execute local file overwrites. Omitting or passing false returns an error requiring explicit confirmation unless dry_run is true.',
42 ],
43 'dry_run' => [
44 'type' => 'boolean',
45 'description' => 'When true, fetches cloud info and returns preview of files to be updated without modifying disk files.',
46 ],
47 ],
48 'additionalProperties' => false,
49 ],
50 'output_schema' => [
51 'type' => 'object',
52 'properties' => [
53 'success' => ['type' => 'boolean'],
54 'message' => ['type' => 'string'],
55 'dry_run' => ['type' => 'boolean'],
56 'r_id' => ['type' => 'integer'],
57 'widget_id' => ['type' => 'string'],
58 'widget_name' => ['type' => 'string'],
59 'builder' => ['type' => 'string'],
60 'folder' => ['type' => 'string'],
61 'duplicate_folders' => ['type' => 'array'],
62 'version' => ['type' => 'string'],
63 'files' => ['type' => 'array'],
64 'response' => ['type' => 'object'],
65 ],
66 ],
67 'execute_callback' => 'wdesignkit_mcp_pull_widget',
68 'permission_callback' => 'wdesignkit_mcp_permission_callback',
69 'meta' => [
70 'public' => true,
71 'show_in_rest' => true,
72 'mcp' => ['public' => true],
73 'annotations' => [
74 'instructions' => implode("\n", [
75 'Re-downloads a widget from your WDesignKit cloud account over the local widget copy.',
76 'Requires cloud login.',
77 'Identify by r_id (cloud record ID) or by local builder + folder / widget_id.',
78 'Guarded with confirm: true. Use dry_run: true to preview proposed file overwrites.',
79 ]),
80 'readonly' => false,
81 'destructive' => true,
82 'idempotent' => false,
83 ],
84 ],
85 ]);
86
87 function wdesignkit_mcp_pull_widget(array $input): array {
88 set_time_limit(90);
89
90 if (!defined('WDKIT_BUILDER_PATH') || !defined('WDKIT_SERVER_API_URL')) {
91 return ['success' => false, 'message' => 'WDesignKit plugin is not active.'];
92 }
93
94 $auth = function_exists('wdesignkit_mcp_template_get_auth') ? wdesignkit_mcp_template_get_auth() : [];
95 if (empty($auth['logged_in'])) {
96 return ['success' => false, 'message' => $auth['message'] ?? 'Not logged in to WDesignKit cloud.'];
97 }
98
99 $r_id = (int) ($input['r_id'] ?? 0);
100 $builder = sanitize_text_field((string) ($input['builder'] ?? ''));
101 $folder = sanitize_file_name((string) ($input['folder'] ?? ''));
102 $widget_id = sanitize_text_field((string) ($input['widget_id'] ?? ''));
103 $confirm = !empty($input['confirm']);
104 $dry_run = !empty($input['dry_run']);
105
106 $allowed_builders = ['elementor', 'gutenberg', 'gutenberg_core', 'bricks'];
107
108 // Lookup r_id from local widget JSON if omitted
109 if ($r_id <= 0 && ($folder !== '' || $widget_id !== '')) {
110 $builders_to_check = ($builder !== '' && in_array($builder, $allowed_builders, true)) ? [$builder] : $allowed_builders;
111 foreach ($builders_to_check as $b) {
112 $b_dir = WDKIT_BUILDER_PATH . '/' . $b;
113 if (!is_dir($b_dir)) {
114 continue;
115 }
116 $subfolders = ($folder !== '') ? [$folder] : array_diff(@scandir($b_dir) ?: [], ['.', '..']);
117 foreach ($subfolders as $sub) {
118 $dir_path = $b_dir . '/' . $sub;
119 if (!is_dir($dir_path)) {
120 continue;
121 }
122 foreach (array_diff(@scandir($dir_path) ?: [], ['.', '..']) as $f) {
123 if (pathinfo($f, PATHINFO_EXTENSION) === 'json') {
124 $raw = @file_get_contents($dir_path . '/' . $f);
125 $data = ($raw !== false) ? json_decode($raw, true) : null;
126 $wd = $data['widget_data']['widgetdata'] ?? [];
127 $wid = (string) ($wd['widget_id'] ?? '');
128 if ($folder !== '' || $wid === $widget_id) {
129 $r_id = (int) ($wd['r_id'] ?? 0);
130 $builder = $b;
131 $folder = $sub;
132 $widget_id = $wid;
133 break 3;
134 }
135 }
136 }
137 }
138 }
139 }
140
141 if ($r_id <= 0) {
142 return [
143 'success' => false,
144 'message' => 'Provide a valid r_id or specify a local folder/widget_id that has a cloud record ID (r_id).',
145 ];
146 }
147
148 // Resolve user_id from auth session
149 $u_id = (string) ($auth['user_id'] ?? '');
150 $token = (string) ($auth['token'] ?? '');
151
152 // Auth session's user_id is often empty — fall back to the same transient lookups
153 // download-widget.php uses for this same widget/download endpoint, or the cloud
154 // rejects the request with "User id not found".
155 if ($u_id === '') {
156 $normalise_dl_auth = static function ($raw): array {
157 if (is_array($raw)) {
158 return $raw;
159 }
160 if ($raw instanceof \stdClass) {
161 return (array) $raw;
162 }
163 if (is_string($raw) && $raw !== '') {
164 $decoded = json_decode($raw, true);
165 if (is_array($decoded)) {
166 return $decoded;
167 }
168 }
169 return [];
170 };
171
172 $current_wp_user = wp_get_current_user();
173 if ($current_wp_user && $current_wp_user->user_email) {
174 $user_key = strstr($current_wp_user->user_email, '@', true);
175 $auth_data = $normalise_dl_auth(get_transient('wdkit_auth_' . $user_key));
176 $u_id = (string) ($auth_data['user_id'] ?? $auth_data['id'] ?? '');
177 }
178 if ($u_id === '' && $token !== '') {
179 global $wpdb;
180 $rows = $wpdb->get_results(
181 $wpdb->prepare(
182 "SELECT option_value FROM {$wpdb->options} WHERE option_name LIKE %s LIMIT 10",
183 $wpdb->esc_like('_transient_wdkit_auth_') . '%'
184 ),
185 ARRAY_A
186 );
187 foreach (($rows ?: []) as $row) {
188 $data = $normalise_dl_auth(@maybe_unserialize($row['option_value']));
189 if (!empty($data['token']) && $data['token'] === $token) {
190 $u_id = (string) ($data['user_id'] ?? $data['id'] ?? '');
191 if ($u_id !== '') {
192 break;
193 }
194 }
195 }
196 }
197
198 // If local transient didn't contain user_id, resolve it directly from cloud session via widget/mywidgets
199 if ($u_id === '' && $token !== '' && function_exists('wdesignkit_mcp_template_cloud_call')) {
200 $cloud_info = wdesignkit_mcp_template_cloud_call('widget/mywidgets', [
201 'token' => $token,
202 'ParPage' => 1,
203 ], 'form');
204
205 if (!empty($cloud_info['data'])) {
206 $c_data = $cloud_info['data'];
207 $found_uid = (string) ($c_data['userinfo']['id'] ?? $c_data['user_id'] ?? '');
208 if ($found_uid === '' && !empty($c_data['widgets']) && is_array($c_data['widgets'])) {
209 $w0 = $c_data['widgets'][0] ?? [];
210 $found_uid = (string) ($w0['user_id'] ?? $w0['u_id'] ?? $w0['post_author'] ?? '');
211 }
212 if ($found_uid !== '') {
213 $u_id = $found_uid;
214 // Cache user_id back into current session transient for future calls
215 $session = function_exists('wdesignkit_mcp_find_auth_session') ? wdesignkit_mcp_find_auth_session() : [];
216 if (!empty($session['key'])) {
217 $t_data = get_transient('wdkit_auth_' . $session['key']);
218 $t_data = is_array($t_data) ? $t_data : (is_string($t_data) ? json_decode($t_data, true) : []);
219 if (is_array($t_data)) {
220 $t_data['user_id'] = $u_id;
221 set_transient('wdkit_auth_' . $session['key'], $t_data, 7776000);
222 }
223 }
224 }
225 }
226 }
227 }
228
229 $args = [
230 'id' => $r_id,
231 'u_id' => $u_id,
232 'type' => '',
233 'unique_id' => get_option('wdkit_unique_id', ''),
234 'token' => $token,
235 ];
236
237 $response = wp_remote_post(
238 WDKIT_SERVER_API_URL . 'api/wp/widget/download',
239 [
240 'method' => 'POST',
241 'body' => $args,
242 'timeout' => 60,
243 ]
244 );
245
246 if (is_wp_error($response)) {
247 return ['success' => false, 'message' => $response->get_error_message()];
248 }
249
250 $status = wp_remote_retrieve_response_code($response);
251 $body = wp_remote_retrieve_body($response);
252 $data = json_decode($body, true);
253
254 if (200 !== (int) $status || empty($data['success'])) {
255 return [
256 'success' => false,
257 'message' => $data['massage'] ?? $data['message'] ?? "Cloud returned status {$status}.",
258 'response' => wdesignkit_mcp_ensure_object($data, $body),
259 ];
260 }
261
262 $res = is_array($data['data']['data'] ?? null) ? $data['data']['data'] : ($data['data'] ?? []);
263 $img_url = sanitize_url((string) ($res['image'] ?? ''));
264 $json_raw = $res['json'] ?? null;
265
266 if (empty($json_raw)) {
267 return ['success' => false, 'message' => 'Cloud returned no widget data.', 'response' => wdesignkit_mcp_ensure_object($data, $body)];
268 }
269
270 if (is_string($json_raw)) {
271 $json_raw = json_decode($json_raw, true);
272 }
273 if (is_string($json_raw)) {
274 $json_raw = json_decode($json_raw, true);
275 }
276
277 if (!is_array($json_raw)) {
278 return ['success' => false, 'message' => 'Widget JSON from cloud could not be decoded.', 'response' => wdesignkit_mcp_ensure_object($data, $body)];
279 }
280
281 $widgetdata = $json_raw['widget_data']['widgetdata'] ?? [];
282 $cloud_title = sanitize_text_field((string) ($widgetdata['name'] ?? ''));
283 $cloud_builder = sanitize_key((string) ($widgetdata['type'] ?? ''));
284 $cloud_wid = sanitize_text_field((string) ($widgetdata['widget_id'] ?? ''));
285 $cloud_version = (string) ($widgetdata['widget_version'] ?? '1.0.0');
286
287 if ($cloud_title === '' || $cloud_builder === '') {
288 return ['success' => false, 'message' => 'Downloaded widget JSON is missing required fields (name, type).'];
289 }
290
291 // Stable folder and file naming, via the canonical helpers so every writer agrees on the
292 // name — the loader pairs "<base>.php" with "<base>.json", so a writer that derives the
293 // base differently orphans the widget (ClickUp 86d41cck5). These also apply
294 // sanitize_file_name(), which the hand-rolled derivation here skipped entirely.
295 $widget_uid = $cloud_wid ?: $r_id;
296 $folder_name = ($folder !== '') ? $folder : wdesignkit_widget_folder_name($cloud_title, $widget_uid);
297 $file_name = wdesignkit_widget_file_name($cloud_title, $widget_uid);
298 $builder_dir = WDKIT_BUILDER_PATH . '/' . $cloud_builder;
299
300 // Reuse the folder this widget already occupies instead of minting a new name for it, the
301 // same way download-widget does. Without this, pulling a widget whose folder was written
302 // under an older convention (e.g. a pre-2.6.4 lowercase "my-widget_id") created a second
303 // directory differing only by case — two folders holding one widget on Linux, a silent
304 // write into the wrong one on macOS/Windows (ClickUp 86d41ccka). Match on the widget id,
305 // which is the suffix of every folder name. An explicit folder argument still wins: the
306 // resolver above sets it from an on-disk folder, so the caller has already chosen a target.
307 $duplicate_folders = [];
308 if ($folder === '') {
309 $existing_folder = wdesignkit_find_widget_folder($builder_dir, $widget_uid, $folder_matches);
310 if ($existing_folder !== '') {
311 $folder_name = $existing_folder;
312
313 // Surface any other folder still holding this widget id. These are inert — a
314 // builder loader only registers a folder containing a .php — but reporting them
315 // lets the caller clear them instead of finding the widget listed twice.
316 $duplicate_folders = array_values(array_diff($folder_matches, [$existing_folder]));
317 }
318 }
319
320 $widget_dir = $builder_dir . '/' . $folder_name;
321
322 // Adopt the file base name already used inside the target folder, whichever way that
323 // folder was chosen, so the refreshed files replace the existing ones rather than landing
324 // beside them under a second naming convention.
325 foreach (@scandir($widget_dir) ?: [] as $existing_file) {
326 if (pathinfo($existing_file, PATHINFO_EXTENSION) === 'json') {
327 $file_name = pathinfo($existing_file, PATHINFO_FILENAME);
328 break;
329 }
330 }
331
332 $proposed_files = [
333 $folder_name . '/' . $file_name . '.json',
334 ];
335 if (!empty($json_raw['Editor_data']['css'])) {
336 $proposed_files[] = $folder_name . '/' . $file_name . '.css';
337 }
338 if (!empty($json_raw['Editor_data']['js'])) {
339 $proposed_files[] = $folder_name . '/' . $file_name . '.js';
340 }
341 if ($img_url !== '') {
342 $proposed_files[] = $folder_name . '/' . $file_name . '.png (or matching extension)';
343 }
344
345 // Dry Run check
346 if ($dry_run) {
347 return [
348 'success' => true,
349 'message' => "Dry run: Proposed pull of widget '{$cloud_title}' (r_id: {$r_id}, version: {$cloud_version}) from cloud.",
350 'dry_run' => true,
351 'r_id' => $r_id,
352 'widget_id' => $cloud_wid,
353 'widget_name' => $cloud_title,
354 'builder' => $cloud_builder,
355 'folder' => $folder_name,
356 'version' => $cloud_version,
357 'files' => $proposed_files,
358 'response' => wdesignkit_mcp_ensure_object($data, $body),
359 ];
360 }
361
362 // Confirmation check
363 if (!$confirm) {
364 return [
365 'success' => false,
366 '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.",
367 ];
368 }
369
370 if (!wp_mkdir_p($widget_dir)) {
371 return ['success' => false, 'message' => "Could not create widget folder: {$cloud_builder}/{$folder_name}"];
372 }
373
374 // Path safety check
375 $real_widget = realpath($widget_dir);
376 $real_base = realpath(WDKIT_BUILDER_PATH);
377 if (!$real_widget || !$real_base || strpos($real_widget, $real_base . DIRECTORY_SEPARATOR) !== 0) {
378 return ['success' => false, 'message' => 'Invalid widget path.'];
379 }
380
381 // Fetch and save thumbnail if provided.
382 // SSRF guard (CWE-918): img_url comes from the cloud response and is written straight to
383 // disk below; wp_remote_get() has no host validation at all, so wdesignkit_safe_remote_get()
384 // (resolves the host and blocks loopback/private/link-local/cloud-metadata ranges) is used
385 // instead of a plain fetch.
386 if ($img_url !== '') {
387 $img_resp = wdesignkit_safe_remote_get($img_url, ['timeout' => 30]);
388 if (!is_wp_error($img_resp)) {
389 // sanitize_file_name() passes "php" through unchanged, so the remote extension was
390 // effectively unvalidated and a ".php" thumbnail URL wrote executable PHP into the
391 // builder directory (CWE-434, ClickUp 86d41cczd). Verify against the payload; '' means
392 // the bytes are not an image we accept, so nothing is written.
393 $img_body = (string) wp_remote_retrieve_body($img_resp);
394 $img_ext = wdesignkit_safe_image_extension($img_url, $img_body);
395
396 if ($img_ext !== '') {
397 if (defined('WDKIT_SERVER_PATH')) {
398 $json_raw['widget_data']['widgetdata']['w_image'] = WDKIT_SERVER_PATH . "/{$cloud_builder}/{$folder_name}/{$file_name}.{$img_ext}";
399 }
400 @file_put_contents($widget_dir . '/' . $file_name . '.' . $img_ext, $img_body);
401 }
402 }
403 }
404
405 // Save JSON config
406 $json_written = @file_put_contents(
407 $widget_dir . '/' . $file_name . '.json',
408 wp_json_encode($json_raw, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES)
409 );
410
411 if ($json_written === false) {
412 return ['success' => false, 'message' => 'Could not write widget JSON file to disk.'];
413 }
414
415 // Write CSS and JS files if present in Editor_data
416 if (!empty($json_raw['Editor_data']['css'])) {
417 @file_put_contents($widget_dir . '/' . $file_name . '.css', (string) $json_raw['Editor_data']['css']);
418 }
419 if (!empty($json_raw['Editor_data']['js'])) {
420 @file_put_contents($widget_dir . '/' . $file_name . '.js', (string) $json_raw['Editor_data']['js']);
421 }
422
423 if (function_exists('wdesignkit_invalidate_widget_registry')) {
424 wdesignkit_invalidate_widget_registry($cloud_builder);
425 }
426
427 $message = "Widget '{$cloud_title}' (r_id: {$r_id}) pulled from cloud and updated locally.";
428 if (!empty($duplicate_folders)) {
429 $message .= ' Note: this widget_id also occupies ' . count($duplicate_folders)
430 . ' other folder(s) from an earlier naming convention ('
431 . implode(', ', $duplicate_folders) . '). They are not loaded and can be deleted.';
432 }
433
434 return [
435 'success' => true,
436 'message' => $message,
437 'dry_run' => false,
438 'r_id' => $r_id,
439 'widget_id' => $cloud_wid,
440 'widget_name' => $cloud_title,
441 'builder' => $cloud_builder,
442 'folder' => $folder_name,
443 'duplicate_folders' => $duplicate_folders,
444 'version' => $cloud_version,
445 'files' => $proposed_files,
446 'response' => wdesignkit_mcp_ensure_object($data, $body),
447 ];
448 }
449