| 1 |
<?php |
| 2 |
/** |
| 3 |
* Ability: Import a WDesignKit widget from its JSON config into the local widget library. |
| 4 |
*/ |
| 5 |
|
| 6 |
declare(strict_types=1); |
| 7 |
|
| 8 |
if (!defined('ABSPATH')) { |
| 9 |
exit(); |
| 10 |
} |
| 11 |
|
| 12 |
wp_register_ability('wdesignkit/import-widget', [ |
| 13 |
'label' => __('Import WDesignKit Widget', 'wdesignkit'), |
| 14 |
'description' => __( |
| 15 |
'Imports a widget into the local WDesignKit library from its JSON config object. Provide the full widget_data JSON (same structure as the .json file inside a .wdk ZIP export). The ability creates the correct builder folder, writes the JSON, and optionally downloads the widget thumbnail. Rejects imports when a widget with the same widget_id already exists.', |
| 16 |
'wdesignkit', |
| 17 |
), |
| 18 |
'category' => 'wdesignkit', |
| 19 |
'input_schema' => [ |
| 20 |
'type' => 'object', |
| 21 |
'properties' => [ |
| 22 |
'widget_json' => [ |
| 23 |
'type' => 'object', |
| 24 |
'description' => 'The parsed widget config object — the top-level structure is {"widget_data":{"widgetdata":{…}}}. This is the same JSON that lives inside the .wdk ZIP export.', |
| 25 |
], |
| 26 |
'image_url' => [ |
| 27 |
'type' => 'string', |
| 28 |
'description' => 'Optional URL of the widget thumbnail image. Downloaded and stored alongside the JSON. Used only when image_base64 is not provided.', |
| 29 |
], |
| 30 |
'image_base64' => [ |
| 31 |
'type' => 'string', |
| 32 |
'description' => 'Optional widget thumbnail supplied inline as base64 (raw base64, or a data:image/...;base64,... URI). Use this to import the thumbnail bundled INSIDE a .wdk ZIP export — mirrors the manual Import UI, no separately-hosted URL needed. Takes precedence over image_url.', |
| 33 |
], |
| 34 |
'image_ext' => [ |
| 35 |
'type' => 'string', |
| 36 |
'description' => 'Optional image extension (png, jpg, jpeg, webp) for image_base64 when it is raw base64 without a data: URI. Defaults to the widget JSON img_ext, or "png".', |
| 37 |
], |
| 38 |
'overwrite' => [ |
| 39 |
'type' => 'boolean', |
| 40 |
'description' => 'When true, overwrites an existing widget that shares the same widget_id instead of rejecting the import.', |
| 41 |
], |
| 42 |
], |
| 43 |
'required' => ['widget_json'], |
| 44 |
'additionalProperties' => false, |
| 45 |
], |
| 46 |
'output_schema' => [ |
| 47 |
'type' => 'object', |
| 48 |
'properties' => [ |
| 49 |
'success' => ['type' => 'boolean'], |
| 50 |
'message' => ['type' => 'string'], |
| 51 |
'folder' => ['type' => 'string'], |
| 52 |
'builder' => ['type' => 'string'], |
| 53 |
'widget_id' => ['type' => 'string'], |
| 54 |
'widget_name' => ['type' => 'string'], |
| 55 |
], |
| 56 |
], |
| 57 |
'execute_callback' => 'wdesignkit_mcp_import_widget', |
| 58 |
'permission_callback' => 'wdesignkit_mcp_permission_callback', |
| 59 |
'meta' => [ |
| 60 |
'public' => true, |
| 61 |
'show_in_rest' => true, |
| 62 |
'mcp' => ['public' => true], |
| 63 |
'annotations' => [ |
| 64 |
'instructions' => implode("\n", [ |
| 65 |
'Imports a widget from its JSON config into the local library.', |
| 66 |
'Does NOT require cloud login — this is a local filesystem operation.', |
| 67 |
'widget_json must be the full widget_data structure ({"widget_data":{"widgetdata":{…}}}) — get it from wdesignkit/get-widget or from a .wdk ZIP export.', |
| 68 |
'To import a .wdk ZIP\'s bundled thumbnail, pass its bytes as image_base64 (no external URL needed); use image_url only for a remotely-hosted image.', |
| 69 |
'Fails if a widget with the same widget_id already exists unless overwrite: true.', |
| 70 |
'After a successful import the widget appears in wdesignkit/list-widgets.', |
| 71 |
]), |
| 72 |
'readonly' => false, |
| 73 |
'destructive' => false, |
| 74 |
'idempotent' => false, |
| 75 |
], |
| 76 |
], |
| 77 |
]); |
| 78 |
|
| 79 |
function wdesignkit_mcp_import_widget(array $input): array { |
| 80 |
// Timeout guard: local filesystem write + optional 30s thumbnail download. |
| 81 |
set_time_limit(60); |
| 82 |
|
| 83 |
if (!defined('WDKIT_BUILDER_PATH')) { |
| 84 |
return ['success' => false, 'message' => 'WDesignKit plugin is not active.']; |
| 85 |
} |
| 86 |
|
| 87 |
$widget_json = $input['widget_json'] ?? null; |
| 88 |
if (!is_array($widget_json)) { |
| 89 |
return ['success' => false, 'message' => 'widget_json must be a JSON object with a widget_data key.']; |
| 90 |
} |
| 91 |
|
| 92 |
$widgetdata = $widget_json['widget_data']['widgetdata'] ?? null; |
| 93 |
if (!is_array($widgetdata)) { |
| 94 |
return ['success' => false, 'message' => 'widget_json.widget_data.widgetdata is missing or invalid.']; |
| 95 |
} |
| 96 |
|
| 97 |
$widget_name = sanitize_text_field((string) ($widgetdata['name'] ?? '')); |
| 98 |
$widget_id = sanitize_text_field((string) ($widgetdata['widget_id'] ?? '')); |
| 99 |
$builder = sanitize_key((string) ($widgetdata['type'] ?? '')); |
| 100 |
|
| 101 |
if ($widget_name === '' || $widget_id === '' || $builder === '') { |
| 102 |
return ['success' => false, 'message' => 'widget_json must include widget_data.widgetdata.name, widget_id, and type.']; |
| 103 |
} |
| 104 |
|
| 105 |
$allowed_builders = ['elementor', 'gutenberg', 'gutenberg_core', 'bricks']; |
| 106 |
if (!in_array($builder, $allowed_builders, true)) { |
| 107 |
return ['success' => false, 'message' => "Unsupported builder type: {$builder}."]; |
| 108 |
} |
| 109 |
|
| 110 |
$overwrite = !empty($input['overwrite']); |
| 111 |
// Canonical helpers: they replace spaces BEFORE sanitize_file_name(). Sanitising first |
| 112 |
// (as this did) already collapsed spaces to hyphens, so the underscore pass was a no-op |
| 113 |
// and a multi-word title produced "My-Widget_id.json" while the builder's own save path |
| 114 |
// writes "My_Widget_id.php". The registry resolves the JSON by swapping .php for .json, |
| 115 |
// so that pair never matched and the widget was skipped for good (ClickUp 86d41cck5). |
| 116 |
$folder_name = wdesignkit_widget_folder_name($widget_name, $widget_id); |
| 117 |
$file_name = wdesignkit_widget_file_name($widget_name, $widget_id); |
| 118 |
$builder_dir = WDKIT_BUILDER_PATH . '/' . $builder; |
| 119 |
$widget_dir = $builder_dir . '/' . $folder_name; |
| 120 |
|
| 121 |
// Duplicate check / overwrite: scan for any existing folder sharing the same widget_id. |
| 122 |
// Primary strategy: read each folder's JSON and compare widget_data.widgetdata.widget_id. |
| 123 |
// Fallback strategy: folder names follow the pattern "<name>_<widget_id>", so any folder |
| 124 |
// whose name ends with "_{$widget_id}" is also treated as a match even if the JSON is |
| 125 |
// unreadable or has an unexpected structure. |
| 126 |
$existing_folders = @scandir($builder_dir) ?: []; |
| 127 |
foreach (array_diff($existing_folders, ['.', '..']) as $ef) { |
| 128 |
$jf = $builder_dir . '/' . $ef; |
| 129 |
if (!is_dir($jf)) { |
| 130 |
continue; |
| 131 |
} |
| 132 |
|
| 133 |
$found = false; |
| 134 |
|
| 135 |
// Primary: inspect the JSON content. |
| 136 |
$sub = @scandir($jf) ?: []; |
| 137 |
foreach ($sub as $sf) { |
| 138 |
if (pathinfo($sf, PATHINFO_EXTENSION) !== 'json') { |
| 139 |
continue; |
| 140 |
} |
| 141 |
$raw = @file_get_contents($jf . '/' . $sf); |
| 142 |
$jd = ($raw !== false) ? json_decode($raw, true) : null; |
| 143 |
if (is_array($jd) && ($jd['widget_data']['widgetdata']['widget_id'] ?? '') === $widget_id) { |
| 144 |
$found = true; |
| 145 |
break; |
| 146 |
} |
| 147 |
} |
| 148 |
|
| 149 |
// Fallback: match by folder-name suffix ("_<widget_id>"). |
| 150 |
if (!$found && str_ends_with($ef, '_' . $widget_id)) { |
| 151 |
$found = true; |
| 152 |
} |
| 153 |
|
| 154 |
if ($found) { |
| 155 |
if (!$overwrite) { |
| 156 |
return [ |
| 157 |
'success' => false, |
| 158 |
'message' => "A widget with widget_id '{$widget_id}' already exists in folder '{$ef}'. Pass overwrite: true to force.", |
| 159 |
]; |
| 160 |
} |
| 161 |
// overwrite=true: delete the existing folder so only one copy exists after import. |
| 162 |
wdesignkit_mcp_import_widget_rmdir($jf); |
| 163 |
break; |
| 164 |
} |
| 165 |
} |
| 166 |
|
| 167 |
if (!wp_mkdir_p($widget_dir)) { |
| 168 |
return ['success' => false, 'message' => "Could not create widget folder: {$builder}/{$folder_name}"]; |
| 169 |
} |
| 170 |
|
| 171 |
// Realpath validation — ensure we're still inside WDKIT_BUILDER_PATH. |
| 172 |
$real_widget = realpath($widget_dir); |
| 173 |
$real_base = realpath(WDKIT_BUILDER_PATH); |
| 174 |
if (!$real_widget || !$real_base || strpos($real_widget, $real_base . DIRECTORY_SEPARATOR) !== 0) { |
| 175 |
return ['success' => false, 'message' => 'Invalid widget path.']; |
| 176 |
} |
| 177 |
|
| 178 |
$json_path = $widget_dir . '/' . $file_name . '.json'; |
| 179 |
$written = @file_put_contents($json_path, wp_json_encode($widget_json, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES)); |
| 180 |
|
| 181 |
if ($written === false) { |
| 182 |
return ['success' => false, 'message' => 'Could not write widget JSON file.']; |
| 183 |
} |
| 184 |
|
| 185 |
// Thumbnail. Prefer an inline base64 image — this is how a .wdk ZIP's OWN bundled |
| 186 |
// thumbnail is imported (mirrors the manual Import UI's ZipArchive extraction). Fall |
| 187 |
// back to downloading a remote image_url. Whichever is used, the JSON's w_image / img_ext |
| 188 |
// are pointed at the saved local file so the library shows it and push-widget finds it. |
| 189 |
$image_saved = false; |
| 190 |
$image_source = ''; |
| 191 |
$image_base64 = (string) ($input['image_base64'] ?? ''); |
| 192 |
$image_url = sanitize_url((string) ($input['image_url'] ?? '')); |
| 193 |
|
| 194 |
$img_bytes = ''; |
| 195 |
$img_ext = ''; |
| 196 |
|
| 197 |
if ($image_base64 !== '') { |
| 198 |
$b64 = $image_base64; |
| 199 |
if (preg_match('#^data:image/([a-zA-Z0-9.+-]+);base64,(.*)$#s', $b64, $m)) { |
| 200 |
$img_ext = $m[1]; |
| 201 |
$b64 = $m[2]; |
| 202 |
} |
| 203 |
$b64 = preg_replace('/\s+/', '', (string) $b64); |
| 204 |
$decoded = ($b64 !== '') ? base64_decode($b64, true) : false; |
| 205 |
if ($decoded !== false && $decoded !== '') { |
| 206 |
$img_bytes = $decoded; |
| 207 |
if ($img_ext === '') { |
| 208 |
$img_ext = (string) ($input['image_ext'] ?? ($widgetdata['img_ext'] ?? 'png')); |
| 209 |
} |
| 210 |
$image_source = 'bundled'; |
| 211 |
} |
| 212 |
} elseif ($image_url !== '') { |
| 213 |
// SSRF guard (CWE-918): validate the resolved host before fetching a caller-supplied URL. |
| 214 |
$img_resp = wdesignkit_safe_remote_get($image_url, ['timeout' => 30]); |
| 215 |
if (!is_wp_error($img_resp)) { |
| 216 |
$img_bytes = (string) wp_remote_retrieve_body($img_resp); |
| 217 |
$img_ext = pathinfo(parse_url($image_url, PHP_URL_PATH) ?: '', PATHINFO_EXTENSION) ?: 'png'; |
| 218 |
$image_source = 'downloaded'; |
| 219 |
} |
| 220 |
} |
| 221 |
|
| 222 |
if ($img_bytes !== '') { |
| 223 |
// Both branches above take the extension from caller-supplied data — a data-URI subtype or |
| 224 |
// the remote URL. The old preg_replace() only stripped punctuation, so "php" survived and |
| 225 |
// this write could drop executable PHP into the builder directory (CWE-434, |
| 226 |
// ClickUp 86d41cczd). Verify against the decoded bytes instead; '' means not an image. |
| 227 |
// The synthetic "image.<ext>" filename lets the helper read either branch's extension. |
| 228 |
$img_ext = wdesignkit_safe_image_extension('image.' . strtolower((string) $img_ext), $img_bytes); |
| 229 |
} |
| 230 |
|
| 231 |
if ($img_bytes !== '' && $img_ext !== '') { |
| 232 |
$img_path = $widget_dir . '/' . $file_name . '.' . $img_ext; |
| 233 |
if (@file_put_contents($img_path, $img_bytes) !== false) { |
| 234 |
$image_saved = true; |
| 235 |
// Point the JSON at the local thumbnail so it renders in the library and push finds it. |
| 236 |
if (defined('WDKIT_SERVER_PATH')) { |
| 237 |
$widget_json['widget_data']['widgetdata']['w_image'] = WDKIT_SERVER_PATH . "/{$builder}/{$folder_name}/{$file_name}.{$img_ext}"; |
| 238 |
} |
| 239 |
$widget_json['widget_data']['widgetdata']['img_ext'] = $img_ext; |
| 240 |
@file_put_contents($json_path, wp_json_encode($widget_json, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES)); |
| 241 |
} |
| 242 |
} |
| 243 |
|
| 244 |
$thumb_note = $image_saved |
| 245 |
? ($image_source === 'bundled' ? ' Bundled thumbnail imported.' : ' Thumbnail downloaded.') |
| 246 |
: ''; |
| 247 |
|
| 248 |
if (function_exists('wdesignkit_invalidate_widget_registry')) { |
| 249 |
wdesignkit_invalidate_widget_registry($builder); |
| 250 |
} |
| 251 |
|
| 252 |
return [ |
| 253 |
'success' => true, |
| 254 |
'message' => "Widget '{$widget_name}' imported successfully." . $thumb_note, |
| 255 |
'folder' => $folder_name, |
| 256 |
'builder' => $builder, |
| 257 |
'widget_id' => $widget_id, |
| 258 |
'widget_name' => $widget_name, |
| 259 |
]; |
| 260 |
} |
| 261 |
|
| 262 |
/** |
| 263 |
* Recursively delete a directory and all its contents. |
| 264 |
* Used by wdesignkit_mcp_import_widget to remove an existing widget folder on overwrite. |
| 265 |
*/ |
| 266 |
function wdesignkit_mcp_import_widget_rmdir(string $dir): void { |
| 267 |
if (!is_dir($dir)) { |
| 268 |
return; |
| 269 |
} |
| 270 |
$items = array_diff((array) scandir($dir), ['.', '..']); |
| 271 |
foreach ($items as $item) { |
| 272 |
$path = $dir . '/' . $item; |
| 273 |
if (is_dir($path)) { |
| 274 |
wdesignkit_mcp_import_widget_rmdir($path); |
| 275 |
} else { |
| 276 |
@unlink($path); |
| 277 |
} |
| 278 |
} |
| 279 |
@rmdir($dir); |
| 280 |
} |
| 281 |
|