| 1 |
<?php |
| 2 |
/** |
| 3 |
* Ability: Set or update a local WDesignKit widget's preview thumbnail image. |
| 4 |
*/ |
| 5 |
|
| 6 |
declare(strict_types=1); |
| 7 |
|
| 8 |
if (!defined('ABSPATH')) { |
| 9 |
exit(); |
| 10 |
} |
| 11 |
|
| 12 |
wp_register_ability('wdesignkit/set-widget-thumbnail', [ |
| 13 |
'label' => __('Set WDesignKit Widget Thumbnail', 'wdesignkit'), |
| 14 |
'description' => __( |
| 15 |
'Attaches or replaces the preview thumbnail image for a local WDesignKit widget. Accepts a Media Library attachment ID, an image URL (sideload), or base64 data. Saves the thumbnail image into the widget folder and updates the thumbnail reference in the widget JSON config so wdesignkit/push-widget can validate and upload the thumbnail.', |
| 16 |
'wdesignkit', |
| 17 |
), |
| 18 |
'category' => 'wdesignkit', |
| 19 |
'input_schema' => [ |
| 20 |
'type' => 'object', |
| 21 |
'properties' => [ |
| 22 |
'builder' => [ |
| 23 |
'type' => 'string', |
| 24 |
'description' => 'Builder type the widget belongs to (elementor, gutenberg, gutenberg_core, bricks).', |
| 25 |
'enum' => ['elementor', 'gutenberg', 'gutenberg_core', 'bricks'], |
| 26 |
], |
| 27 |
'folder' => [ |
| 28 |
'type' => 'string', |
| 29 |
'description' => 'Widget folder name (from wdesignkit/list-widgets). Required if widget_id is omitted.', |
| 30 |
], |
| 31 |
'widget_id' => [ |
| 32 |
'type' => 'string', |
| 33 |
'description' => 'Widget unique ID. Used to locate the widget folder if folder is omitted.', |
| 34 |
], |
| 35 |
'image_id' => [ |
| 36 |
'type' => 'integer', |
| 37 |
'description' => 'Media Library attachment ID for the thumbnail image.', |
| 38 |
], |
| 39 |
'attachment_id' => [ |
| 40 |
'type' => 'integer', |
| 41 |
'description' => 'Alias for image_id (Media Library attachment ID).', |
| 42 |
], |
| 43 |
'image_url' => [ |
| 44 |
'type' => 'string', |
| 45 |
'description' => 'URL of the image to fetch and use as thumbnail.', |
| 46 |
], |
| 47 |
'image_base64' => [ |
| 48 |
'type' => 'string', |
| 49 |
'description' => 'Image supplied inline as base64 string or data:image/...;base64,... URI.', |
| 50 |
], |
| 51 |
'image' => [ |
| 52 |
'description' => 'Unified image input — accepts a Media Library attachment ID (int), image URL (string starting with http/https), or base64 string.', |
| 53 |
], |
| 54 |
], |
| 55 |
'additionalProperties' => false, |
| 56 |
], |
| 57 |
'output_schema' => [ |
| 58 |
'type' => 'object', |
| 59 |
'properties' => [ |
| 60 |
'success' => ['type' => 'boolean'], |
| 61 |
'message' => ['type' => 'string'], |
| 62 |
'thumbnail_url' => ['type' => 'string'], |
| 63 |
'builder' => ['type' => 'string'], |
| 64 |
'folder' => ['type' => 'string'], |
| 65 |
'widget_id' => ['type' => 'string'], |
| 66 |
], |
| 67 |
], |
| 68 |
'execute_callback' => 'wdesignkit_mcp_set_widget_thumbnail', |
| 69 |
'permission_callback' => 'wdesignkit_mcp_permission_callback', |
| 70 |
'meta' => [ |
| 71 |
'public' => true, |
| 72 |
'show_in_rest' => true, |
| 73 |
'mcp' => ['public' => true], |
| 74 |
'annotations' => [ |
| 75 |
'instructions' => implode("\n", [ |
| 76 |
'Attaches or replaces the preview thumbnail image for a local WDesignKit widget.', |
| 77 |
'Accepts image input via image_id (Media Library ID), image_url (sideload URL), image_base64, or image.', |
| 78 |
'Updates the local widget folder with the image file and sets w_image in JSON config.', |
| 79 |
'Run wdesignkit/push-widget afterwards to upload the widget and thumbnail to the cloud.', |
| 80 |
]), |
| 81 |
'readonly' => false, |
| 82 |
'destructive' => false, |
| 83 |
'idempotent' => false, |
| 84 |
], |
| 85 |
], |
| 86 |
]); |
| 87 |
|
| 88 |
function wdesignkit_mcp_set_widget_thumbnail(array $input): array { |
| 89 |
set_time_limit(90); |
| 90 |
|
| 91 |
if (!defined('WDKIT_BUILDER_PATH') || !defined('WDKIT_SERVER_PATH')) { |
| 92 |
return ['success' => false, 'message' => 'WDesignKit plugin is not active.']; |
| 93 |
} |
| 94 |
|
| 95 |
$builder = sanitize_text_field((string) ($input['builder'] ?? '')); |
| 96 |
$folder = sanitize_file_name((string) ($input['folder'] ?? '')); |
| 97 |
$widget_id = sanitize_text_field((string) ($input['widget_id'] ?? '')); |
| 98 |
|
| 99 |
$allowed_builders = ['elementor', 'gutenberg', 'gutenberg_core', 'bricks']; |
| 100 |
|
| 101 |
// Locate widget directory |
| 102 |
$widget_dir = null; |
| 103 |
|
| 104 |
if ($folder !== '') { |
| 105 |
if ($builder !== '' && !in_array($builder, $allowed_builders, true)) { |
| 106 |
return ['success' => false, 'message' => 'Invalid builder type.']; |
| 107 |
} |
| 108 |
$builders_to_check = ($builder !== '') ? [$builder] : $allowed_builders; |
| 109 |
foreach ($builders_to_check as $b) { |
| 110 |
$candidate_dir = WDKIT_BUILDER_PATH . '/' . $b . '/' . $folder; |
| 111 |
if (is_dir($candidate_dir)) { |
| 112 |
$widget_dir = $candidate_dir; |
| 113 |
$builder = $b; |
| 114 |
break; |
| 115 |
} |
| 116 |
} |
| 117 |
} elseif ($widget_id !== '') { |
| 118 |
$builders_to_check = ($builder !== '' && in_array($builder, $allowed_builders, true)) ? [$builder] : $allowed_builders; |
| 119 |
foreach ($builders_to_check as $b) { |
| 120 |
$b_dir = WDKIT_BUILDER_PATH . '/' . $b; |
| 121 |
if (!is_dir($b_dir)) { |
| 122 |
continue; |
| 123 |
} |
| 124 |
$subfolders = array_diff(@scandir($b_dir) ?: [], ['.', '..']); |
| 125 |
foreach ($subfolders as $sub) { |
| 126 |
$dir_path = $b_dir . '/' . $sub; |
| 127 |
if (!is_dir($dir_path)) { |
| 128 |
continue; |
| 129 |
} |
| 130 |
$files = array_diff(@scandir($dir_path) ?: [], ['.', '..']); |
| 131 |
foreach ($files as $f) { |
| 132 |
if (pathinfo($f, PATHINFO_EXTENSION) === 'json') { |
| 133 |
$raw = @file_get_contents($dir_path . '/' . $f); |
| 134 |
$data = ($raw !== false) ? json_decode($raw, true) : null; |
| 135 |
$wid = $data['widget_data']['widgetdata']['widget_id'] ?? ''; |
| 136 |
if ($wid === $widget_id) { |
| 137 |
$widget_dir = $dir_path; |
| 138 |
$builder = $b; |
| 139 |
$folder = $sub; |
| 140 |
break 3; |
| 141 |
} |
| 142 |
} |
| 143 |
} |
| 144 |
} |
| 145 |
} |
| 146 |
} |
| 147 |
|
| 148 |
if (!$widget_dir || !is_dir($widget_dir)) { |
| 149 |
return [ |
| 150 |
'success' => false, |
| 151 |
'message' => 'Widget not found. Specify a valid builder and folder or widget_id.', |
| 152 |
]; |
| 153 |
} |
| 154 |
|
| 155 |
// Path safety check |
| 156 |
$real_widget = realpath($widget_dir); |
| 157 |
$real_base = realpath(WDKIT_BUILDER_PATH); |
| 158 |
if (!$real_widget || !$real_base || strpos($real_widget, $real_base . DIRECTORY_SEPARATOR) !== 0) { |
| 159 |
return ['success' => false, 'message' => 'Invalid widget path.']; |
| 160 |
} |
| 161 |
|
| 162 |
// Read JSON config |
| 163 |
$files = array_diff(@scandir($widget_dir) ?: [], ['.', '..']); |
| 164 |
$json_path = null; |
| 165 |
$json_data = null; |
| 166 |
|
| 167 |
foreach ($files as $f) { |
| 168 |
if (pathinfo($f, PATHINFO_EXTENSION) === 'json') { |
| 169 |
$json_path = $widget_dir . '/' . $f; |
| 170 |
$raw = @file_get_contents($json_path); |
| 171 |
$json_data = ($raw !== false) ? json_decode($raw, true) : null; |
| 172 |
break; |
| 173 |
} |
| 174 |
} |
| 175 |
|
| 176 |
if (!is_array($json_data) || !$json_path) { |
| 177 |
return ['success' => false, 'message' => "Could not read JSON config in widget folder {$builder}/{$folder}."]; |
| 178 |
} |
| 179 |
|
| 180 |
$widgetdata = $json_data['widget_data']['widgetdata'] ?? []; |
| 181 |
$title = sanitize_text_field((string) ($widgetdata['name'] ?? '')); |
| 182 |
$json_widget_id = sanitize_text_field((string) ($widgetdata['widget_id'] ?? '')); |
| 183 |
if ($widget_id === '') { |
| 184 |
$widget_id = $json_widget_id; |
| 185 |
} |
| 186 |
|
| 187 |
// Resolve input image (ID, Base64, or URL) |
| 188 |
$raw_image = $input['image'] ?? null; |
| 189 |
$img_id = (int) ($input['image_id'] ?? $input['attachment_id'] ?? (is_numeric($raw_image) ? $raw_image : 0)); |
| 190 |
$img_url = sanitize_url((string) ($input['image_url'] ?? (is_string($raw_image) && preg_match('#^https?://#i', $raw_image) ? $raw_image : ''))); |
| 191 |
$img_base64 = (string) ($input['image_base64'] ?? (is_string($raw_image) && !preg_match('#^https?://#i', $raw_image) && !is_numeric($raw_image) ? $raw_image : '')); |
| 192 |
|
| 193 |
$img_bytes = ''; |
| 194 |
$ext = 'png'; |
| 195 |
|
| 196 |
if ($img_id > 0) { |
| 197 |
$attached_file = get_attached_file($img_id); |
| 198 |
if ($attached_file && file_exists($attached_file)) { |
| 199 |
$read_bytes = @file_get_contents($attached_file); |
| 200 |
if ($read_bytes !== false) { |
| 201 |
$img_bytes = $read_bytes; |
| 202 |
$file_ext = strtolower(pathinfo($attached_file, PATHINFO_EXTENSION)); |
| 203 |
if (in_array($file_ext, ['jpg', 'jpeg', 'png', 'webp'], true)) { |
| 204 |
$ext = ($file_ext === 'jpeg') ? 'jpg' : $file_ext; |
| 205 |
} |
| 206 |
} |
| 207 |
} |
| 208 |
if ($img_bytes === '') { |
| 209 |
$att_url = wp_get_attachment_url($img_id); |
| 210 |
if ($att_url) { |
| 211 |
$img_url = $att_url; |
| 212 |
} |
| 213 |
} |
| 214 |
} |
| 215 |
|
| 216 |
if ($img_bytes === '' && $img_base64 !== '') { |
| 217 |
$b64 = $img_base64; |
| 218 |
if (preg_match('#^data:image/([a-zA-Z0-9]+);base64,(.*)$#s', $b64, $m)) { |
| 219 |
$mime_ext = strtolower($m[1]); |
| 220 |
if (in_array($mime_ext, ['jpg', 'jpeg', 'png', 'webp'], true)) { |
| 221 |
$ext = ($mime_ext === 'jpeg') ? 'jpg' : $mime_ext; |
| 222 |
} |
| 223 |
$b64 = $m[2]; |
| 224 |
} |
| 225 |
$b64_clean = preg_replace('/\s+/', '', $b64); |
| 226 |
$decoded = base64_decode($b64_clean, true); |
| 227 |
if ($decoded !== false && strlen($decoded) > 0) { |
| 228 |
$img_bytes = $decoded; |
| 229 |
} |
| 230 |
} |
| 231 |
|
| 232 |
if ($img_bytes === '' && $img_url !== '') { |
| 233 |
// SSRF guard (CWE-918): image_url is agent/user-supplied. wp_safe_remote_get() only |
| 234 |
// rejects a literal IP host and doesn't resolve hostnames, so it won't stop a DNS name |
| 235 |
// pointed at a loopback/private/cloud-metadata address; wdesignkit_safe_remote_get() |
| 236 |
// resolves the host first and blocks those ranges. |
| 237 |
$img_resp = wdesignkit_safe_remote_get($img_url, ['timeout' => 30]); |
| 238 |
if (is_wp_error($img_resp)) { |
| 239 |
return ['success' => false, 'message' => 'Could not fetch image_url: ' . $img_resp->get_error_message()]; |
| 240 |
} |
| 241 |
$status_code = (int) wp_remote_retrieve_response_code($img_resp); |
| 242 |
if (200 !== $status_code) { |
| 243 |
return ['success' => false, 'message' => "image_url returned HTTP {$status_code}."]; |
| 244 |
} |
| 245 |
$body = (string) wp_remote_retrieve_body($img_resp); |
| 246 |
if ($body === '') { |
| 247 |
return ['success' => false, 'message' => 'image_url returned an empty response.']; |
| 248 |
} |
| 249 |
$img_bytes = $body; |
| 250 |
|
| 251 |
$url_ext = strtolower(pathinfo((string) parse_url($img_url, PHP_URL_PATH), PATHINFO_EXTENSION)); |
| 252 |
if (in_array($url_ext, ['jpg', 'jpeg', 'png', 'webp'], true)) { |
| 253 |
$ext = ($url_ext === 'jpeg') ? 'jpg' : $url_ext; |
| 254 |
} else { |
| 255 |
$content_type = (string) wp_remote_retrieve_header($img_resp, 'content-type'); |
| 256 |
if (strpos($content_type, 'jpeg') !== false || strpos($content_type, 'jpg') !== false) { |
| 257 |
$ext = 'jpg'; |
| 258 |
} elseif (strpos($content_type, 'webp') !== false) { |
| 259 |
$ext = 'webp'; |
| 260 |
} elseif (strpos($content_type, 'png') !== false) { |
| 261 |
$ext = 'png'; |
| 262 |
} |
| 263 |
} |
| 264 |
} |
| 265 |
|
| 266 |
if ($img_bytes === '') { |
| 267 |
return [ |
| 268 |
'success' => false, |
| 269 |
'message' => 'Provide a valid image via image_id (Media Library ID), image_url, or image_base64.', |
| 270 |
]; |
| 271 |
} |
| 272 |
|
| 273 |
// Determine target image filename |
| 274 |
$file_name = ($title !== '' && $widget_id !== '') ? str_replace(' ', '_', $title) . '_' . $widget_id : $folder; |
| 275 |
|
| 276 |
// Remove old thumbnail images in widget folder to prevent stale files |
| 277 |
foreach ($files as $f) { |
| 278 |
$f_ext = strtolower(pathinfo($f, PATHINFO_EXTENSION)); |
| 279 |
if (in_array($f_ext, ['jpg', 'jpeg', 'png', 'webp'], true)) { |
| 280 |
@unlink($widget_dir . '/' . $f); |
| 281 |
} |
| 282 |
} |
| 283 |
|
| 284 |
// Save new image file |
| 285 |
$target_image_file = $widget_dir . '/' . $file_name . '.' . $ext; |
| 286 |
if (@file_put_contents($target_image_file, $img_bytes) === false) { |
| 287 |
return ['success' => false, 'message' => "Failed to write image file to {$builder}/{$folder}."]; |
| 288 |
} |
| 289 |
|
| 290 |
// Update JSON config w_image reference |
| 291 |
$stored_thumbnail_url = WDKIT_SERVER_PATH . '/' . $builder . '/' . $folder . '/' . $file_name . '.' . $ext; |
| 292 |
$json_data['widget_data']['widgetdata']['w_image'] = $stored_thumbnail_url; |
| 293 |
|
| 294 |
$json_written = @file_put_contents( |
| 295 |
$json_path, |
| 296 |
wp_json_encode($json_data, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) |
| 297 |
); |
| 298 |
|
| 299 |
if ($json_written === false) { |
| 300 |
return ['success' => false, 'message' => "Saved image but failed to update JSON config in {$builder}/{$folder}."]; |
| 301 |
} |
| 302 |
|
| 303 |
return [ |
| 304 |
'success' => true, |
| 305 |
'message' => "Thumbnail image set successfully for widget '{$title}'.", |
| 306 |
'thumbnail_url' => $stored_thumbnail_url, |
| 307 |
'builder' => $builder, |
| 308 |
'folder' => $folder, |
| 309 |
'widget_id' => $widget_id, |
| 310 |
]; |
| 311 |
} |
| 312 |
|