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
wdesignkit / includes / abilities / widgets / wdesignkit-download-widget.php

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

363 lines 16.9 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Ability: Download a public widget from the WDesignKit marketplace and install it locally.
4 */
5
6 declare(strict_types=1);
7
8 if (!defined('ABSPATH')) {
9 exit();
10 }
11
12 wp_register_ability('wdesignkit/download-widget', [
13 'label' => __('Download WDesignKit Marketplace Widget', 'wdesignkit'),
14 'description' => __(
15 'Downloads a widget by its numeric cloud ID and installs it in the local widget library — either a public marketplace widget, or one of the current user\'s own cloud widgets (including private, unpublished ones) when logged in. After a successful download the widget appears in wdesignkit/list-widgets. Maps to the "Import Widget — Browse (Public Download)" ability.',
16 'wdesignkit',
17 ),
18 'category' => 'wdesignkit',
19 'input_schema' => [
20 'type' => 'object',
21 'properties' => [
22 'widget_id' => [
23 'type' => 'integer',
24 'description' => 'Numeric widget ID from the marketplace listing (the "id" field in wdesignkit/browse-widgets response). This is the correct identifier for the download API — NOT the w_unique string.',
25 ],
26 'w_uniq' => [
27 'type' => 'string',
28 'description' => 'String unique code (the "w_unique" field from browse-widgets). Optional — used for reference/metadata only. Do NOT pass this as the download identifier; use widget_id (the numeric "id" field) instead.',
29 ],
30 'u_id' => [
31 'type' => 'string',
32 'description' => 'User ID for the download request. Optional — auto-resolved from the active WDesignKit cloud session when omitted. Only pass this explicitly if auto-resolution fails or you are overriding with a specific user_id from browse-widgets.',
33 ],
34 'uid' => [
35 'type' => 'string',
36 'description' => 'Alias for u_id. Pass whichever field the browse-widgets response exposes ("u_id" or "uid").',
37 ],
38 'download_type' => [
39 'type' => 'string',
40 'description' => 'Download variant (d_type). Defaults to empty.',
41 ],
42 'api_type' => [
43 'type' => 'string',
44 'description' => 'Override the cloud endpoint. Leave empty to auto-detect: "import/widget/free" when not logged in, "widget/download" when logged in.',
45 ],
46 ],
47 'required' => ['widget_id'],
48 'additionalProperties' => false,
49 ],
50 'output_schema' => [
51 'type' => 'object',
52 'properties' => [
53 'success' => ['type' => 'boolean'],
54 'message' => ['type' => 'string'],
55 'widget_name' => ['type' => 'string'],
56 'builder' => ['type' => 'string'],
57 'folder' => ['type' => 'string'],
58 'response' => ['type' => ['object', 'array']],
59 ],
60 ],
61 'execute_callback' => 'wdesignkit_mcp_download_widget',
62 'permission_callback' => 'wdesignkit_mcp_permission_callback',
63 'meta' => [
64 'public' => true,
65 'show_in_rest' => true,
66 'mcp' => ['public' => true],
67 'annotations' => [
68 'instructions' => implode("\n", [
69 'Downloads and installs a marketplace widget.',
70 'IMPORTANT: pass "widget_id" = the numeric "id" field from wdesignkit/browse-widgets. Do NOT pass w_unique as widget_id — it will return "Widget Not Found".',
71 'u_id is OPTIONAL — auto-resolved from the active WDesignKit cloud session. Only pass it if auto-resolution fails.',
72 'Endpoint is auto-selected: "import/widget/free" when not logged in (free widgets only), "widget/download" when logged in.',
73 'For free widgets without login: pass widget_id only.',
74 'For logged-in downloads: only widget_id is needed — u_id and token are resolved automatically from the session.',
75 'To redownload a widget you pushed yourself (including private ones never published to the marketplace):',
76 'list it with wdesignkit/get-my-cloud-widgets and pass that "id" here while logged in. The cloud recognises',
77 'the owner from the session token and serves private widgets to their owner only.',
78 'After installation the widget is available in wdesignkit/list-widgets.',
79 ]),
80 'readonly' => false,
81 'destructive' => false,
82 'idempotent' => false,
83 ],
84 ],
85 ]);
86
87 function wdesignkit_mcp_download_widget(array $input): array {
88 // Timeout guard: cloud download uses a 60s HTTP timeout + optional 30s thumbnail fetch.
89 set_time_limit(90);
90
91 if (!defined('WDKIT_BUILDER_PATH') || !defined('WDKIT_SERVER_API_URL')) {
92 return ['success' => false, 'message' => 'WDesignKit plugin is not active.'];
93 }
94
95 // widget_id = numeric "id" from browse-widgets. This is what the cloud API expects.
96 // w_uniq / w_unique = the string code from browse-widgets — NOT accepted by the download API.
97 $widget_id = (int) ($input['widget_id'] ?? 0);
98 $w_uniq = sanitize_text_field((string) ($input['w_uniq'] ?? ''));
99 // Accept both 'u_id' and 'uid' — may also be passed from browse-widgets "user_id" field.
100 $u_id = sanitize_text_field((string) ($input['u_id'] ?? $input['uid'] ?? ''));
101 $download_type = sanitize_text_field((string) ($input['download_type'] ?? ''));
102 $api_type_in = sanitize_text_field((string) ($input['api_type'] ?? ''));
103
104 if ($widget_id <= 0) {
105 return ['success' => false, 'message' => 'widget_id is required (numeric "id" from wdesignkit/browse-widgets).'];
106 }
107
108 // Resolve auth session — used for endpoint selection, token, and u_id auto-resolution.
109 $auth = function_exists('wdesignkit_mcp_template_get_auth') ? wdesignkit_mcp_template_get_auth() : [];
110 $logged_in = !empty($auth['logged_in']);
111 $token = (string) ($auth['token'] ?? '');
112
113 // Auto-detect endpoint
114 if ($api_type_in !== '') {
115 $api_type = $api_type_in;
116 } else {
117 $api_type = $logged_in ? 'widget/download' : 'import/widget/free';
118 }
119
120 // Auto-resolve u_id from the active WDesignKit cloud session when not supplied by the caller.
121 // The cloud stores the user's own ID in the auth transient alongside the token.
122 if ($u_id === '' && $logged_in) {
123 // The resolved session already carries the cloud user_id — take it from there
124 // rather than re-deriving the transient key, which only matches when the WP
125 // user's email local part happens to equal the cloud account's.
126 $u_id = (string) ($auth['user_id'] ?? '');
127 }
128
129 if ($u_id === '' && $logged_in) {
130 // Normalise transient value regardless of storage backend (PHP serialised, JSON, stdClass).
131 $normalise_dl_auth = static function ($raw): array {
132 if (is_array($raw)) {
133 return $raw;
134 }
135 if ($raw instanceof \stdClass) {
136 return (array) $raw;
137 }
138 if (is_string($raw) && $raw !== '') {
139 $decoded = json_decode($raw, true);
140 if (is_array($decoded)) {
141 return $decoded;
142 }
143 }
144 return [];
145 };
146
147 // Primary lookup: transient keyed to current WP user's email prefix.
148 $current_wp_user = wp_get_current_user();
149 if ($current_wp_user && $current_wp_user->user_email) {
150 $user_key = strstr($current_wp_user->user_email, '@', true);
151 $auth_data = $normalise_dl_auth(get_transient('wdkit_auth_' . $user_key));
152 $u_id = (string) ($auth_data['user_id'] ?? $auth_data['id'] ?? '');
153 }
154 // Fallback: scan all wdkit_auth_* transients and match by token.
155 if ($u_id === '' && $token !== '') {
156 global $wpdb;
157 $rows = $wpdb->get_results(
158 $wpdb->prepare(
159 "SELECT option_value FROM {$wpdb->options} WHERE option_name LIKE %s LIMIT 10",
160 $wpdb->esc_like('_transient_wdkit_auth_') . '%'
161 ),
162 ARRAY_A
163 );
164 foreach (($rows ?: []) as $row) {
165 $data = $normalise_dl_auth(@maybe_unserialize($row['option_value']));
166 if (!empty($data['token']) && $data['token'] === $token) {
167 $u_id = (string) ($data['user_id'] ?? $data['id'] ?? '');
168 if ($u_id !== '') {
169 break;
170 }
171 }
172 }
173 }
174
175 // If local transient didn't contain user_id, resolve it directly from cloud session via widget/mywidgets
176 if ($u_id === '' && $token !== '' && function_exists('wdesignkit_mcp_template_cloud_call')) {
177 $cloud_info = wdesignkit_mcp_template_cloud_call('widget/mywidgets', [
178 'token' => $token,
179 'ParPage' => 1,
180 ], 'form');
181
182 if (!empty($cloud_info['data'])) {
183 $c_data = $cloud_info['data'];
184 $found_uid = (string) ($c_data['userinfo']['id'] ?? $c_data['user_id'] ?? '');
185 if ($found_uid === '' && !empty($c_data['widgets']) && is_array($c_data['widgets'])) {
186 $w0 = $c_data['widgets'][0] ?? [];
187 $found_uid = (string) ($w0['user_id'] ?? $w0['u_id'] ?? $w0['post_author'] ?? '');
188 }
189 if ($found_uid !== '') {
190 $u_id = $found_uid;
191 $session = function_exists('wdesignkit_mcp_find_auth_session') ? wdesignkit_mcp_find_auth_session() : [];
192 if (!empty($session['key'])) {
193 $t_data = get_transient('wdkit_auth_' . $session['key']);
194 $t_data = is_array($t_data) ? $t_data : (is_string($t_data) ? json_decode($t_data, true) : []);
195 if (is_array($t_data)) {
196 $t_data['user_id'] = $u_id;
197 set_transient('wdkit_auth_' . $session['key'], $t_data, 7776000);
198 }
199 }
200 }
201 }
202 }
203 }
204
205 $args = [
206 'id' => $widget_id,
207 'u_id' => $u_id,
208 'type' => $download_type,
209 'unique_id' => get_option('wdkit_unique_id', ''),
210 ];
211
212 // Pass auth token for authenticated endpoint so the cloud can verify the session.
213 if ($logged_in && $token !== '') {
214 $args['token'] = $token;
215 }
216
217 $response = wp_remote_post(
218 WDKIT_SERVER_API_URL . 'api/wp/' . $api_type,
219 [
220 'method' => 'POST',
221 'body' => $args,
222 'timeout' => 60,
223 ]
224 );
225
226 if (is_wp_error($response)) {
227 return ['success' => false, 'message' => $response->get_error_message()];
228 }
229
230 $status = wp_remote_retrieve_response_code($response);
231 $body = wp_remote_retrieve_body($response);
232 $data = json_decode($body, true);
233
234 if (200 !== (int) $status || empty($data['success'])) {
235 return [
236 'success' => false,
237 'message' => $data['massage'] ?? $data['message'] ?? "Cloud returned status {$status}.",
238 'response' => $data,
239 ];
240 }
241
242 // Unwrap the widget JSON + image from the nested response
243 $res = is_array($data['data']['data'] ?? null) ? $data['data']['data'] : ($data['data'] ?? []);
244 $img_url = sanitize_url((string) ($res['image'] ?? ''));
245 $json_raw = $res['json'] ?? null;
246
247 if (empty($json_raw)) {
248 return ['success' => false, 'message' => 'Cloud returned no widget data.', 'response' => $data];
249 }
250
251 // Double-decode matches the original handler behaviour
252 if (is_string($json_raw)) {
253 $json_raw = json_decode($json_raw, true);
254 }
255 if (is_string($json_raw)) {
256 $json_raw = json_decode($json_raw, true);
257 }
258
259 if (!is_array($json_raw)) {
260 return ['success' => false, 'message' => 'Widget JSON from cloud could not be decoded.', 'response' => $data];
261 }
262
263 $widgetdata = $json_raw['widget_data']['widgetdata'] ?? [];
264 $title = sanitize_text_field((string) ($widgetdata['name'] ?? ''));
265 $builder = sanitize_key((string) ($widgetdata['type'] ?? ''));
266 $widget_id = sanitize_text_field((string) ($widgetdata['widget_id'] ?? ''));
267
268 if ($title === '' || $builder === '' || $widget_id === '') {
269 return ['success' => false, 'message' => 'Downloaded widget JSON is missing required fields (name, type, widget_id).'];
270 }
271
272 $folder_name = wdesignkit_widget_folder_name($title, $widget_id);
273 $file_name = wdesignkit_widget_file_name($title, $widget_id);
274 $builder_dir = WDKIT_BUILDER_PATH . '/' . $builder;
275
276 // Reuse the folder this widget already occupies instead of minting a new name for it.
277 // A redownload of a widget whose folder was written under a different convention used to
278 // create a second directory differing only by case (ClickUp 86d3yk4yx) — two folders on
279 // Linux, a silent write into the wrong one on macOS/Windows. Match on widget_id, which is
280 // the suffix of every folder name, and take the file base from the JSON already in there
281 // so the refreshed JSON replaces the existing one rather than sitting beside it.
282 $duplicate_folders = [];
283 $existing_folder = wdesignkit_find_widget_folder($builder_dir, $widget_id, $folder_matches);
284 if ($existing_folder !== '') {
285 $folder_name = $existing_folder;
286 $file_name = str_replace('-', '_', $existing_folder);
287
288 foreach (@scandir($builder_dir . '/' . $existing_folder) ?: [] as $ef) {
289 if (pathinfo($ef, PATHINFO_EXTENSION) === 'json') {
290 $file_name = pathinfo($ef, PATHINFO_FILENAME);
291 break;
292 }
293 }
294
295 // Surface any other folder still holding this widget_id. These are left over from
296 // before the naming was unified; they are inert (a builder loader only registers a
297 // folder containing a .php), but reporting them lets the caller clear them instead
298 // of discovering them through a widget list that shows the same widget twice.
299 $duplicate_folders = array_values(array_diff($folder_matches, [$existing_folder]));
300 }
301
302 $widget_dir = $builder_dir . '/' . $folder_name;
303
304 if (!wp_mkdir_p($widget_dir)) {
305 return ['success' => false, 'message' => "Could not create widget folder: {$builder}/{$folder_name}"];
306 }
307
308 // Realpath validation — ensure we're still inside WDKIT_BUILDER_PATH.
309 $real_widget = realpath($widget_dir);
310 $real_base = realpath(WDKIT_BUILDER_PATH);
311 if (!$real_widget || !$real_base || strpos($real_widget, $real_base . DIRECTORY_SEPARATOR) !== 0) {
312 return ['success' => false, 'message' => 'Invalid widget path.'];
313 }
314
315 // Download and save thumbnail
316 if ($img_url !== '') {
317 // SSRF guard (CWE-918): validate the resolved host before fetching.
318 $img_resp = wdesignkit_safe_remote_get($img_url, ['timeout' => 30]);
319 if (!is_wp_error($img_resp)) {
320 // sanitize_file_name() is not a defence for an extension — it passes "php" straight
321 // through, so a cloud response naming a ".php" thumbnail wrote executable PHP into the
322 // builder directory (CWE-434, ClickUp 86d41cczd). Verify against the payload instead;
323 // '' means the bytes are not an image we accept, so nothing is written.
324 $img_body = (string) wp_remote_retrieve_body($img_resp);
325 $img_ext = wdesignkit_safe_image_extension($img_url, $img_body);
326
327 if ($img_ext !== '') {
328 // Update w_image in JSON to local URL
329 if (defined('WDKIT_SERVER_PATH')) {
330 $json_raw['widget_data']['widgetdata']['w_image'] = WDKIT_SERVER_PATH . "/{$builder}/{$folder_name}/{$file_name}.{$img_ext}";
331 }
332 @file_put_contents($widget_dir . '/' . $file_name . '.' . $img_ext, $img_body);
333 }
334 }
335 }
336
337 @file_put_contents(
338 $widget_dir . '/' . $file_name . '.json',
339 wp_json_encode($json_raw, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES)
340 );
341
342 if (function_exists('wdesignkit_invalidate_widget_registry')) {
343 wdesignkit_invalidate_widget_registry($builder);
344 }
345
346 $message = "Widget '{$title}' downloaded and installed successfully.";
347 if (!empty($duplicate_folders)) {
348 $message .= ' Note: this widget_id also occupies ' . count($duplicate_folders)
349 . ' other folder(s) left over from an older naming convention ('
350 . implode(', ', $duplicate_folders) . '). They are not loaded and can be deleted.';
351 }
352
353 return [
354 'success' => true,
355 'message' => $message,
356 'widget_name' => $title,
357 'builder' => $builder,
358 'folder' => $folder_name,
359 'duplicate_folders' => $duplicate_folders,
360 'response' => $data,
361 ];
362 }
363