PluginProbe
WDesignKit – AI Templates, Widget Builder & MCP Workflow / 2.7.0
WDesignKit – AI Templates, Widget Builder & MCP Workflow v2.7.0
2.7.1 2.7.0 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 All 130 releases
← All changes | includes/abilities/widgets/wdesignkit-pull-widget.php +77 -20 2.6.3 → 2.7.0 View file →
@@ -57,8 +57,9 @@
57 57 'widget_id' => ['type' => 'string'],
58 58 'widget_name' => ['type' => 'string'],
59 59 'builder' => ['type' => 'string'],
60 60 'folder' => ['type' => 'string'],
61 + 'duplicate_folders' => ['type' => 'array'],
61 62 'version' => ['type' => 'string'],
62 63 'files' => ['type' => 'array'],
63 64 'response' => ['type' => 'object'],
64 65 ],
@@ -65,8 +66,9 @@
65 66 ],
66 67 'execute_callback' => 'wdesignkit_mcp_pull_widget',
67 68 'permission_callback' => 'wdesignkit_mcp_permission_callback',
68 69 'meta' => [
70 + 'public' => true,
69 71 'show_in_rest' => true,
70 72 'mcp' => ['public' => true],
71 73 'annotations' => [
72 74 'instructions' => implode("\n", [
@@ -285,13 +287,49 @@
285 287 if ($cloud_title === '' || $cloud_builder === '') {
286 288 return ['success' => false, 'message' => 'Downloaded widget JSON is missing required fields (name, type).'];
287 289 }
288 290
289 - // Stable folder and file naming
290 - $folder_name = ($folder !== '') ? $folder : (str_replace(' ', '-', $cloud_title) . '_' . ($cloud_wid ?: $r_id));
291 - $file_name = str_replace(' ', '_', $cloud_title) . '_' . ($cloud_wid ?: $r_id);
292 - $widget_dir = WDKIT_BUILDER_PATH . '/' . $cloud_builder . '/' . $folder_name;
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;
293 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 +
294 332 $proposed_files = [
295 333 $folder_name . '/' . $file_name . '.json',
296 334 ];
297 335 if (!empty($json_raw['Editor_data']['css'])) {
@@ -347,14 +385,21 @@
347 385 // instead of a plain fetch.
348 386 if ($img_url !== '') {
349 387 $img_resp = wdesignkit_safe_remote_get($img_url, ['timeout' => 30]);
350 388 if (!is_wp_error($img_resp)) {
351 - $img_ext = strtolower(pathinfo((string) parse_url($img_url, PHP_URL_PATH), PATHINFO_EXTENSION)) ?: 'png';
352 - $img_ext = sanitize_file_name($img_ext);
353 - if (defined('WDKIT_SERVER_PATH')) {
354 - $json_raw['widget_data']['widgetdata']['w_image'] = WDKIT_SERVER_PATH . "/{$cloud_builder}/{$folder_name}/{$file_name}.{$img_ext}";
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);
355 401 }
356 - @file_put_contents($widget_dir . '/' . $file_name . '.' . $img_ext, (string) wp_remote_retrieve_body($img_resp));
357 402 }
358 403 }
359 404
360 405 // Save JSON config
@@ -374,18 +419,30 @@
374 419 if (!empty($json_raw['Editor_data']['js'])) {
375 420 @file_put_contents($widget_dir . '/' . $file_name . '.js', (string) $json_raw['Editor_data']['js']);
376 421 }
377 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 +
378 434 return [
379 - 'success' => true,
380 - 'message' => "Widget '{$cloud_title}' (r_id: {$r_id}) pulled from cloud and updated locally.",
381 - 'dry_run' => false,
382 - 'r_id' => $r_id,
383 - 'widget_id' => $cloud_wid,
384 - 'widget_name' => $cloud_title,
385 - 'builder' => $cloud_builder,
386 - 'folder' => $folder_name,
387 - 'version' => $cloud_version,
388 - 'files' => $proposed_files,
389 - 'response' => wdesignkit_mcp_ensure_object($data, $body),
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),
390 447 ];
391 448 }