PluginProbe
WDesignKit – AI Templates, Widget Builder & MCP Workflow / 2.7.0
WDesignKit – AI Templates, Widget Builder & MCP Workflow v2.7.0
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 1.0.16 All 129 releases
← All changes | includes/abilities/class-wdk-ability-main.php +959 -15 2.6.2 → 2.7.0 View file →
@@ -32,13 +32,924 @@
32 32 return current_user_can( 'manage_options' );
33 33 }
34 34 }
35 35
36 +if ( ! function_exists( 'wdesignkit_validate_external_url' ) ) {
37 + /**
38 + * SSRF guard (CWE-918): decide whether an external URL is safe to fetch.
39 + *
40 + * Resolves the URL's host to its actual IP address(es) and rejects the request
41 + * when ANY resolved address falls in a loopback, private (RFC1918), link-local
42 + * (169.254.0.0/16 — the cloud metadata range — and fe80::/10) or otherwise
43 + * reserved range. Unlike wp_safe_remote_get()/wp_http_validate_url() — which only
44 + * inspect a *literal* IP host and never resolve a hostname, and do NOT block the
45 + * 169.254.x metadata range — this performs real DNS resolution first, so a
46 + * hostname that points at an internal address is also refused.
47 + *
48 + * Fails closed: if the host cannot be resolved at all, the URL is treated as unsafe.
49 + *
50 + * @since 2.6.3
51 + *
52 + * @param string $url URL to validate.
53 + * @return bool True when the URL is a public http(s) address safe to fetch.
54 + */
55 + function wdesignkit_validate_external_url( $url ) {
56 + if ( ! is_string( $url ) || '' === trim( $url ) ) {
57 + return false;
58 + }
59 +
60 + $parts = wp_parse_url( $url );
61 + $scheme = isset( $parts['scheme'] ) ? strtolower( $parts['scheme'] ) : '';
62 + $host = isset( $parts['host'] ) ? $parts['host'] : '';
63 +
64 + if ( ! in_array( $scheme, array( 'http', 'https' ), true ) || '' === $host ) {
65 + return false;
66 + }
67 +
68 + // Collect every IP the host resolves to (literal IP hosts are used as-is).
69 + $ips = array();
70 +
71 + if ( filter_var( $host, FILTER_VALIDATE_IP ) ) {
72 + $ips[] = $host;
73 + } else {
74 + if ( function_exists( 'gethostbynamel' ) ) {
75 + $v4 = gethostbynamel( $host );
76 + if ( is_array( $v4 ) ) {
77 + $ips = array_merge( $ips, $v4 );
78 + }
79 + }
80 +
81 + if ( function_exists( 'dns_get_record' ) ) {
82 + $v6 = @dns_get_record( $host, DNS_AAAA );
83 + if ( is_array( $v6 ) ) {
84 + foreach ( $v6 as $record ) {
85 + if ( ! empty( $record['ipv6'] ) ) {
86 + $ips[] = $record['ipv6'];
87 + }
88 + }
89 + }
90 + }
91 +
92 + // Last resort when the above are unavailable/failed.
93 + if ( empty( $ips ) ) {
94 + $resolved = gethostbyname( $host ); // Returns the host unchanged on failure.
95 + if ( $resolved && $resolved !== $host && filter_var( $resolved, FILTER_VALIDATE_IP ) ) {
96 + $ips[] = $resolved;
97 + }
98 + }
99 + }
100 +
101 + // Fail closed: an unresolvable host cannot be proven public.
102 + if ( empty( $ips ) ) {
103 + return false;
104 + }
105 +
106 + foreach ( $ips as $ip ) {
107 + // Rejects loopback/link-local/reserved (NO_RES_RANGE) and RFC1918/fc00::/7 (NO_PRIV_RANGE).
108 + if ( ! filter_var( $ip, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE ) ) {
109 + return false;
110 + }
111 + }
112 +
113 + return true;
114 + }
115 +}
116 +
117 +if ( ! function_exists( 'wdesignkit_safe_remote_get' ) ) {
118 + /**
119 + * SSRF-safe wrapper around wp_safe_remote_get().
120 + *
121 + * Validates the resolved host (see wdesignkit_validate_external_url()) before any
122 + * network request is made, and caps the response body size so a hostile endpoint
123 + * cannot exhaust memory. Returns a WP_Error when the URL is refused, matching the
124 + * shape callers already expect from wp_remote_get().
125 + *
126 + * @since 2.6.3
127 + *
128 + * @param string $url URL to fetch.
129 + * @param array $args Optional wp_remote_get() args (merged over safe defaults).
130 + * @return array|\WP_Error Response array or WP_Error on a blocked/failed request.
131 + */
132 + function wdesignkit_safe_remote_get( $url, $args = array() ) {
133 + if ( ! wdesignkit_validate_external_url( $url ) ) {
134 + return new \WP_Error(
135 + 'wdkit_blocked_url',
136 + __( 'The requested URL resolves to a disallowed or internal address.', 'wdesignkit' )
137 + );
138 + }
139 +
140 + $defaults = array(
141 + 'timeout' => 30,
142 + 'redirection' => 2,
143 + 'limit_response_size' => 15 * MB_IN_BYTES,
144 + );
145 +
146 + return wp_safe_remote_get( $url, wp_parse_args( $args, $defaults ) );
147 + }
148 +}
149 +
150 +if ( ! function_exists( 'wdesignkit_harden_builder_dir' ) ) {
151 + /**
152 + * Blocks direct PHP execution inside WDKIT_BUILDER_PATH.
153 + *
154 + * Generated widget .php files live under wp-content/uploads/wdesignkit/, a path the
155 + * web server serves directly. Without a deny rule, any PHP written there (via the
156 + * file-write paths elsewhere in this plugin) is directly requestable and executes as
157 + * the web server user — turning every file-write bug into remote code execution.
158 + * Writes an Apache/LiteSpeed .htaccess denying *.php plus an empty index.php to stop
159 + * directory listing; both are idempotent no-ops once already present. Inert on nginx —
160 + * hosts on nginx must add the equivalent `location` block themselves.
161 + *
162 + * @since 2.6.4
163 + *
164 + * @param string $dir Absolute path to harden (created if missing).
165 + * @return void
166 + */
167 + function wdesignkit_harden_builder_dir( $dir ) {
168 + if ( ! is_dir( $dir ) ) {
169 + wp_mkdir_p( $dir );
170 + }
171 +
172 + if ( ! is_dir( $dir ) ) {
173 + return;
174 + }
175 +
176 + $htaccess = trailingslashit( $dir ) . '.htaccess';
177 + if ( ! file_exists( $htaccess ) ) {
178 + $rules = "<IfModule mod_authz_core.c>\n\t<Files \"*.php\">\n\t\tRequire all denied\n\t</Files>\n</IfModule>\n<IfModule !mod_authz_core.c>\n\t<Files \"*.php\">\n\t\tOrder allow,deny\n\t\tDeny from all\n\t</Files>\n</IfModule>\n";
179 + // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_file_put_contents
180 + @file_put_contents( $htaccess, $rules );
181 + }
182 +
183 + $index = trailingslashit( $dir ) . 'index.php';
184 + if ( ! file_exists( $index ) ) {
185 + // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_file_put_contents
186 + @file_put_contents( $index, "<?php\n// Silence is golden.\n" );
187 + }
188 + }
189 +}
190 +
191 +if ( ! has_action( 'admin_init', 'wdesignkit_harden_builder_dir_admin_init' ) ) {
192 + /**
193 + * Safety net: re-assert the builder-dir hardening on every admin page load.
194 + *
195 + * The activation hook alone misses existing installs that upgrade in place (activation
196 + * doesn't re-run) and the edge case where WDKIT_BUILDER_PATH didn't exist yet the first
197 + * time a widget was written. Every check inside wdesignkit_harden_builder_dir() is a
198 + * cheap file_exists(), so running it on admin_init is not a meaningful cost.
199 + *
200 + * @since 2.6.4
201 + *
202 + * @return void
203 + */
204 + function wdesignkit_harden_builder_dir_admin_init() {
205 + if ( defined( 'WDKIT_BUILDER_PATH' ) ) {
206 + wdesignkit_harden_builder_dir( WDKIT_BUILDER_PATH );
207 + }
208 + }
209 + add_action( 'admin_init', 'wdesignkit_harden_builder_dir_admin_init' );
210 +}
211 +
212 +if ( ! function_exists( 'wdesignkit_purge_widget_trash' ) ) {
213 + /**
214 + * Deletes widget-trash folders older than the retention window.
215 + *
216 + * Deleting a widget (see wdesignkit-delete-widget.php) moves it to
217 + * WDKIT_BUILDER_PATH/.trash/{Ymd-His}_{folder}/ rather than removing it, so it can be
218 + * recovered. Nothing ever purged that folder, so it grows without bound on a
219 + * long-lived install — filed as "Widget trash has no retention limit". Each entry's
220 + * age is read from its own timestamp prefix rather than filesystem mtime, since mtime
221 + * would reset if the trash folder were ever copied (e.g. by a backup/restore).
222 + *
223 + * @since 2.6.4
224 + * @return void
225 + */
226 + function wdesignkit_purge_widget_trash() {
227 + if ( ! defined( 'WDKIT_BUILDER_PATH' ) ) {
228 + return;
229 + }
230 +
231 + $trash_base = trailingslashit( WDKIT_BUILDER_PATH ) . '.trash';
232 + if ( ! is_dir( $trash_base ) ) {
233 + return;
234 + }
235 +
236 + /**
237 + * Filters how many days a trashed widget is kept before automatic purge.
238 + *
239 + * @since 2.6.4
240 + * @param int $days Retention window in days. Default 30.
241 + */
242 + $retention_days = (int) apply_filters( 'wdesignkit_widget_trash_retention_days', 30 );
243 + if ( $retention_days <= 0 ) {
244 + return;
245 + }
246 +
247 + $cutoff = time() - ( $retention_days * DAY_IN_SECONDS );
248 +
249 + $entries = scandir( $trash_base );
250 + if ( ! $entries ) {
251 + return;
252 + }
253 +
254 + if ( ! function_exists( 'WP_Filesystem' ) ) {
255 + require_once ABSPATH . 'wp-admin/includes/file.php';
256 + }
257 + WP_Filesystem();
258 + global $wp_filesystem;
259 +
260 + foreach ( $entries as $entry ) {
261 + if ( '.' === $entry || '..' === $entry ) {
262 + continue;
263 + }
264 +
265 + $path = trailingslashit( $trash_base ) . $entry;
266 + if ( ! is_dir( $path ) ) {
267 + continue;
268 + }
269 +
270 + // Entries are named "{Ymd-His}_{folder}" — the timestamp prefix is the
271 + // authoritative age, not filesystem mtime (see docblock above).
272 + if ( ! preg_match( '/^(\d{8}-\d{6})_/', $entry, $matches ) ) {
273 + continue;
274 + }
275 +
276 + $trashed_at = \DateTime::createFromFormat( 'Ymd-His', $matches[1] );
277 + if ( ! $trashed_at || $trashed_at->getTimestamp() > $cutoff ) {
278 + continue;
279 + }
280 +
281 + $real_base = realpath( $trash_base );
282 + $real_path = realpath( $path );
283 + if ( ! $real_base || ! $real_path || 0 !== strpos( $real_path, $real_base . DIRECTORY_SEPARATOR ) ) {
284 + continue;
285 + }
286 +
287 + if ( $wp_filesystem ) {
288 + $wp_filesystem->delete( $real_path, true );
289 + }
290 + }
291 + }
292 +}
293 +
294 +if ( ! has_action( 'wdesignkit_purge_widget_trash_cron', 'wdesignkit_purge_widget_trash' ) ) {
295 + add_action( 'wdesignkit_purge_widget_trash_cron', 'wdesignkit_purge_widget_trash' );
296 +}
297 +
298 +if ( ! function_exists( 'wdesignkit_schedule_widget_trash_purge' ) ) {
299 + /**
300 + * Ensure the daily widget-trash purge is scheduled.
301 + *
302 + * The wp_next_scheduled()/wp_schedule_event() pair used to sit at file scope, so every request
303 + * — frontend included — read the cron array on the way past, and a request could end up writing
304 + * the cron option. Scheduling is a one-off setup step, not per-request work (ClickUp 86d41cp05).
305 + *
306 + * Registered on admin_init rather than init: the schedule persists once written, so it only
307 + * needs re-asserting where an admin is present. Activation calls this too; admin_init is the
308 + * self-heal for installs that upgrade in place, where the activation hook never re-runs — the
309 + * same arrangement wdesignkit_harden_builder_dir_admin_init() above uses.
310 + *
311 + * @since 2.6.4
312 + *
313 + * @return void
314 + */
315 + function wdesignkit_schedule_widget_trash_purge() {
316 + if ( ! wp_next_scheduled( 'wdesignkit_purge_widget_trash_cron' ) ) {
317 + wp_schedule_event( time(), 'daily', 'wdesignkit_purge_widget_trash_cron' );
318 + }
319 + }
320 +}
321 +
322 +if ( ! has_action( 'admin_init', 'wdesignkit_schedule_widget_trash_purge' ) ) {
323 + add_action( 'admin_init', 'wdesignkit_schedule_widget_trash_purge' );
324 +}
325 +
326 +if ( ! function_exists( 'wdesignkit_get_widget_registry' ) ) {
327 + /**
328 + * Builds (and caches) the list of publishable widgets for one builder.
329 + *
330 + * Every builder loader (Elementor/Gutenberg/Gutenberg Core/Bricks) used to scandir()
331 + * WDKIT_BUILDER_PATH and wp_json_file_decode() every widget's JSON on EVERY request that
332 + * builder initializes, admin or frontend, whether or not the page uses any WDesignKit
333 + * widget — filed as "Widget registration scans disk uncached" (measured 4-11ms/request,
334 + * scaling linearly with widget count). This caches the scan+decode result in a
335 + * no-expiry transient; callers that mutate the widget tree call
336 + * wdesignkit_invalidate_widget_registry() to keep it correct.
337 + *
338 + * Only the scan+decode is cached — the require_once/include + register() step in each
339 + * loader still runs every request, since that's how the builder gets live PHP objects.
340 + *
341 + * @since 2.6.4
342 + *
343 + * @param string $builder One of elementor|gutenberg|gutenberg_core|bricks.
344 + * @return array<int, array{file: string, class: string}> Entries to require/include and register.
345 + */
346 + function wdesignkit_get_widget_registry( $builder ) {
347 + $builder = sanitize_key( $builder );
348 + if ( '' === $builder || ! defined( 'WDKIT_BUILDER_PATH' ) ) {
349 + return array();
350 + }
351 +
352 + $transient_key = wdesignkit_widget_registry_cache_key( $builder );
353 + $cached = get_option( $transient_key, null );
354 + if ( is_array( $cached ) ) {
355 + return $cached;
356 + }
357 +
358 + $entries = array();
359 + $dir = trailingslashit( WDKIT_BUILDER_PATH ) . $builder . '/';
360 +
361 + if ( ! is_dir( $dir ) ) {
362 + update_option( $transient_key, $entries, false );
363 + return $entries;
364 + }
365 +
366 + $list = scandir( $dir );
367 + if ( empty( $list ) || count( $list ) <= 2 ) {
368 + update_option( $transient_key, $entries, false );
369 + return $entries;
370 + }
371 +
372 + $get_db_widget = get_option( 'wkit_deactivate_widgets', array() );
373 + $server_w_unique = array_column( $get_db_widget, 'w_unique' );
374 +
375 + foreach ( $list as $value ) {
376 + if ( '.' === $value || '..' === $value || 0 === strpos( $value, '.' ) ) {
377 + continue;
378 + }
379 +
380 + if ( ! is_dir( $dir . $value ) ) {
381 + continue;
382 + }
383 +
384 + $sub_dir = scandir( $dir . $value );
385 + if ( ! is_array( $sub_dir ) ) {
386 + continue;
387 + }
388 +
389 + foreach ( $sub_dir as $sub_dir_value ) {
390 + if ( '.' === $sub_dir_value || '..' === $sub_dir_value ) {
391 + continue;
392 + }
393 +
394 + if ( 'php' !== pathinfo( $sub_dir_value, PATHINFO_EXTENSION ) ) {
395 + continue;
396 + }
397 +
398 + $json_file = str_replace( '.php', '.json', $sub_dir_value );
399 + $json_path = $dir . "{$value}/{$json_file}";
400 + $json_data = wp_json_file_decode( $json_path );
401 +
402 + $w_type = ! empty( $json_data->widget_data->widgetdata->publish_type ) ? $json_data->widget_data->widgetdata->publish_type : '';
403 + $widget_id = ! empty( $json_data->widget_data->widgetdata->widget_id ) ? $json_data->widget_data->widgetdata->widget_id : '';
404 +
405 + if ( empty( $w_type ) || 'Publish' !== $w_type ) {
406 + continue;
407 + }
408 + if ( in_array( $widget_id, $server_w_unique, true ) ) {
409 + continue;
410 + }
411 +
412 + // Matches the class-name convention the Elementor loader has always used;
413 + // harmless (unused) for Gutenberg/Gutenberg Core/Bricks, which register by
414 + // file path instead of by class.
415 + $str_replace = str_replace( '.php', '', $sub_dir_value );
416 + $str_replace = str_replace( '-', '_', $str_replace );
417 +
418 + $entries[] = array(
419 + 'file' => $dir . "{$value}/{$sub_dir_value}",
420 + 'class' => 'Wdkit_' . sanitize_text_field( $str_replace ),
421 + );
422 + }
423 + }
424 +
425 + update_option( $transient_key, $entries, false );
426 + return $entries;
427 + }
428 +}
429 +
430 +if ( ! function_exists( 'wdesignkit_widget_php_write_allowed' ) ) {
431 + /**
432 + * Whether this site permits WDesignKit to write generated widget PHP to disk.
433 + *
434 + * The widget builder's whole purpose is to turn builder input into a PHP widget file that the
435 + * builder loaders then include, so this is by design and gated on manage_options — the same
436 + * capability core requires for the plugin/theme editor. Some operators still want to switch it
437 + * off, which this filter allows:
438 + *
439 + * add_filter( 'wdesignkit_allow_widget_php_write', '__return_false' );
440 + *
441 + * Deliberately NOT keyed on DISALLOW_FILE_MODS / DISALLOW_FILE_EDIT (ClickUp 86d41zavc):
442 + * - Nexter Extension defines DISALLOW_FILE_EDIT when its "disable_file_editor" security
443 + * toggle is on, so keying on it would make a sibling POSIMYTH product's hardening switch
444 + * silently disable this one's core feature.
445 + * - Managed hosts (WP Engine, Kinsta, Pantheon, Flywheel) commonly set DISALLOW_FILE_MODS by
446 + * default, which would leave widget creation dead on those hosts out of the box.
447 + * - Neither constant means "no plugin file writes". In core, DISALLOW_FILE_EDIT is read in
448 + * exactly one place (capabilities.php, gating edit_files/edit_plugins/edit_themes — the
449 + * built-in code editors) and DISALLOW_FILE_MODS only via wp_is_file_mod_allowed(), whose
450 + * call sites are the updater and installer. Caching, backup and image plugins all write
451 + * files normally under both.
452 + *
453 + * A dedicated filter gives operators a real, explicit opt-out without overloading a core
454 + * constant that means something else.
455 + *
456 + * @since 2.6.5
457 + *
458 + * @return bool True when generated widget PHP may be written.
459 + */
460 + function wdesignkit_widget_php_write_allowed() {
461 + return (bool) apply_filters( 'wdesignkit_allow_widget_php_write', true );
462 + }
463 +}
464 +
465 +if ( ! function_exists( 'wdesignkit_widget_registry_cache_key' ) ) {
466 + /**
467 + * Option name holding one builder's cached widget registry.
468 + *
469 + * A plain option, not a transient. set_transient() with a 0 expiry is stored as an option with
470 + * autoload ENABLED, so the registry — absolute PHP paths for every widget, uncapped — was read
471 + * into memory on every request including the frontend, whether or not any builder was involved
472 + * (ClickUp 86d41cd1k). Written with autoload = false so only requests that actually ask for a
473 + * builder pay for it.
474 + *
475 + * Elementor and Bricks defer their build to a builder hook, so a plain frontend request now
476 + * reads nothing at all for them. The two Gutenberg loaders build in their constructors and so
477 + * still read their own key every request — that is unavoidable, because Gutenberg block
478 + * registration has to happen on `init`, which runs on every request (measured: one option read
479 + * each, served from the object cache where one is configured — ClickUp 86d41cp0t, closed).
480 + *
481 + * WDKIT_VERSION is part of the name so a plugin update or rollback orphans the old entry and the
482 + * registry rebuilds itself. The cache never expires, so without this there was no self-healing
483 + * path: rolling back to a version that writes widget files without knowing about the cache, then
484 + * rolling forward, left a stale cache authoritative (ClickUp 86d41cd1z).
485 + *
486 + * @since 2.6.4
487 + *
488 + * @param string $builder Builder slug.
489 + * @return string Option name, or '' when the builder is empty.
490 + */
491 + function wdesignkit_widget_registry_cache_key( $builder ) {
492 + $builder = sanitize_key( (string) $builder );
493 + if ( '' === $builder ) {
494 + return '';
495 + }
496 +
497 + $version = defined( 'WDKIT_VERSION' ) ? WDKIT_VERSION : '0';
498 +
499 + return 'wdkit_widget_registry_' . $builder . '_' . str_replace( '.', '_', (string) $version );
500 + }
501 +}
502 +
503 +if ( ! function_exists( 'wdesignkit_invalidate_widget_registry' ) ) {
504 + /**
505 + * Clears the cached widget registry for one builder.
506 + *
507 + * Must be called after any operation that changes which widgets a builder loader
508 + * should register: create, delete, import, download, pull, duplicate, or
509 + * activate/deactivate (the deactivated-list membership is baked into the cached
510 + * registry, so toggling it is a registry-affecting change even though it writes no
511 + * widget file). Code-only edits (update-widget, sync-widget-code) do NOT need this —
512 + * the PHP/JS/CSS content is re-read fresh via require_once/include every request
513 + * regardless of this cache.
514 + *
515 + * @since 2.6.4
516 + *
517 + * @param string $widget_type One of elementor|gutenberg|gutenberg_core|bricks.
518 + * @return void
519 + */
520 + function wdesignkit_invalidate_widget_registry( $widget_type ) {
521 + $widget_type = sanitize_key( (string) $widget_type );
522 + if ( '' === $widget_type ) {
523 + return;
524 + }
525 +
526 + delete_option( wdesignkit_widget_registry_cache_key( $widget_type ) );
527 +
528 + // Legacy autoloaded transient from 2.6.4; harmless once gone, and this is the only
529 + // place that still knows the old name.
530 + delete_transient( 'wdkit_registered_widgets_' . $widget_type );
531 + }
532 +}
533 +
534 +if ( ! function_exists( 'wdesignkit_widget_registry_option_prefix' ) ) {
535 + /**
536 + * Shared prefix of every versioned widget-registry cache option.
537 + *
538 + * One constant-ish source of truth for the three places that need to match these rows by
539 + * pattern rather than by exact name: the orphan sweep below, and uninstall.php. Keep it in
540 + * step with wdesignkit_widget_registry_cache_key(), which builds the full name.
541 + *
542 + * @since 2.6.5
543 + *
544 + * @return string
545 + */
546 + function wdesignkit_widget_registry_option_prefix() {
547 + return 'wdkit_widget_registry_';
548 + }
549 +}
550 +
551 +if ( ! function_exists( 'wdesignkit_purge_stale_widget_registries' ) ) {
552 + /**
553 + * Deletes widget-registry cache options left behind by OTHER plugin versions.
554 + *
555 + * wdesignkit_widget_registry_cache_key() embeds WDKIT_VERSION in the option name, which is
556 + * deliberate — it is what makes an update or a rollback orphan the old entry so the registry
557 + * rebuilds itself instead of trusting a stale cache (ClickUp 86d41cd1z). What was missing is
558 + * the other half: nothing ever deleted the orphan. There was no cleanup path anywhere in the
559 + * codebase, so every release left one row per builder in wp_options permanently. Observed on
560 + * a site upgraded twice — twelve rows across three versions, the largest 12 KB:
561 + *
562 + * wdkit_widget_registry_elementor_2_6_3 wdkit_widget_registry_gutenberg_2_6_3
563 + * wdkit_widget_registry_elementor_2_6_4 wdkit_widget_registry_gutenberg_2_6_4 …
564 + *
565 + * These are written with autoload = false, so there is no per-request cost and this is
566 + * housekeeping rather than a performance fix — but it grows without bound, and a row nothing
567 + * will ever read again is exactly what an uninstall is expected to leave clean.
568 + *
569 + * Matches by prefix and keeps only the CURRENT version's keys, so it also collects rows from
570 + * a build whose version this code cannot know about (a rollback, or a downgrade). The LIKE
571 + * pattern is escaped with $wpdb->esc_like(); the option names are plugin-generated and
572 + * contain no wildcards today, but the escape is what keeps that true if the prefix changes.
573 + *
574 + * @since 2.6.5
575 + *
576 + * @return int Number of orphaned options deleted.
577 + */
578 + function wdesignkit_purge_stale_widget_registries() {
579 + global $wpdb;
580 +
581 + $prefix = wdesignkit_widget_registry_option_prefix();
582 + $pattern = $wpdb->esc_like( $prefix ) . '%';
583 +
584 + $names = $wpdb->get_col( // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- option names are only discoverable by pattern; no caching API covers a LIKE sweep.
585 + $wpdb->prepare( "SELECT option_name FROM {$wpdb->options} WHERE option_name LIKE %s", $pattern )
586 + );
587 +
588 + if ( empty( $names ) ) {
589 + return 0;
590 + }
591 +
592 + // Build the set of names that are still live, rather than parsing the version out of each
593 + // row: the builder slug can itself contain underscores (gutenberg_core), so splitting on
594 + // '_' to find the version suffix is ambiguous. Comparing against what the current version
595 + // WOULD be named has no such ambiguity.
596 + $keep = array();
597 + foreach ( array( 'elementor', 'gutenberg', 'gutenberg_core', 'bricks' ) as $builder ) {
598 + $key = wdesignkit_widget_registry_cache_key( $builder );
599 + if ( '' !== $key ) {
600 + $keep[ $key ] = true;
601 + }
602 + }
603 +
604 + $deleted = 0;
605 + foreach ( $names as $name ) {
606 + if ( isset( $keep[ $name ] ) ) {
607 + continue;
608 + }
609 +
610 + // delete_option() rather than a bulk DELETE: it clears the option cache and fires the
611 + // documented hooks, which a raw query would silently skip.
612 + if ( delete_option( $name ) ) {
613 + ++$deleted;
614 + }
615 + }
616 +
617 + return $deleted;
618 + }
619 +}
620 +
621 +if ( ! function_exists( 'wdesignkit_widget_folder_name' ) ) {
622 + /**
623 + * Canonical widget folder name: "<Title-With-Hyphens>_<widget_id>".
624 + *
625 + * Every writer that touches WDKIT_BUILDER_PATH must derive folder names the same way,
626 + * or the same widget lands in two directories. The convention here (case-preserving,
627 + * spaces to hyphens) is the one already used by the download/import/save paths and by
628 + * every folder currently on disk, so it is the canonical form — a lowercasing variant
629 + * would orphan the absolute `w_image` URLs stored inside each widget's JSON.
630 + *
631 + * @since 2.6.4
632 + *
633 + * @param string $title Widget display name.
634 + * @param string $widget_id Widget unique id.
635 + * @return string Folder name (no path separators).
636 + */
637 + function wdesignkit_widget_folder_name( $title, $widget_id ) {
638 + return sanitize_file_name( str_replace( ' ', '-', (string) $title ) ) . '_' . sanitize_file_name( (string) $widget_id );
639 + }
640 +}
641 +
642 +if ( ! function_exists( 'wdesignkit_widget_file_name' ) ) {
643 + /**
644 + * Canonical widget file base name: "<Title_With_Underscores>_<widget_id>".
645 + *
646 + * Counterpart to wdesignkit_widget_folder_name(); the files inside a widget folder use
647 + * underscores where the folder uses hyphens.
648 + *
649 + * The space replacement must happen BEFORE sanitize_file_name(), because that function
650 + * already collapses whitespace to hyphens — running it first left nothing for the
651 + * underscore pass to replace and produced "Swipe-Button_id.json" where the loader and
652 + * the editor both expect "Swipe_Button_id.json".
653 + *
654 + * @since 2.6.4
655 + *
656 + * @param string $title Widget display name.
657 + * @param string $widget_id Widget unique id.
658 + * @return string File base name, without extension.
659 + */
660 + function wdesignkit_widget_file_name( $title, $widget_id ) {
661 + return sanitize_file_name( str_replace( ' ', '_', (string) $title ) ) . '_' . sanitize_file_name( (string) $widget_id );
662 + }
663 +}
664 +
665 +if ( ! function_exists( 'wdesignkit_find_widget_folder' ) ) {
666 + /**
667 + * Locate an existing folder for a widget id, ignoring case.
668 + *
669 + * Folder names always end in "_<widget_id>", so the id is enough to recognise a widget
670 + * already on disk even when its folder was written under an older naming convention.
671 + * Callers use this before wp_mkdir_p() so a rename or a redownload updates the existing
672 + * folder instead of creating a second one that differs only by case — which on Linux
673 + * shows up as two folders, and on macOS/Windows silently writes into the wrong one.
674 + *
675 + * On a site that ALREADY has duplicates, more than one folder can match. Returning the
676 + * first hit picks by scandir()'s alphabetical order, which puts "QA-Widget_id" ahead of
677 + * "qa-widget_id" and would target whichever folder happens to sort first — including a
678 + * stale one. The builder loaders only register a folder that contains a .php file, so
679 + * that is the copy actually being served; prefer it, and fall back to the first match
680 + * when no candidate has one.
681 + *
682 + * @since 2.6.4
683 + *
684 + * @param string $builder_dir Absolute path to the builder directory.
685 + * @param string $widget_id Widget unique id.
686 + * @param array $matches Optional. Receives every matching folder name, so callers
687 + * can report duplicates left on disk.
688 + * @return string Existing folder name, or '' when none matches.
689 + */
690 + function wdesignkit_find_widget_folder( $builder_dir, $widget_id, &$matches = array() ) {
691 + $matches = array();
692 + $widget_id = (string) $widget_id;
693 +
694 + if ( '' === $widget_id || ! is_dir( $builder_dir ) ) {
695 + return '';
696 + }
697 +
698 + $suffix = '_' . strtolower( $widget_id );
699 + $entries = @scandir( $builder_dir );
700 +
701 + if ( ! is_array( $entries ) ) {
702 + return '';
703 + }
704 +
705 + foreach ( $entries as $entry ) {
706 + if ( '.' === $entry || '..' === $entry || ! is_dir( $builder_dir . '/' . $entry ) ) {
707 + continue;
708 + }
709 +
710 + if ( substr( strtolower( $entry ), -strlen( $suffix ) ) === $suffix ) {
711 + $matches[] = $entry;
712 + }
713 + }
714 +
715 + if ( empty( $matches ) ) {
716 + return '';
717 + }
718 +
719 + // Prefer the copy the builder loader actually registers (the one holding the PHP).
720 + foreach ( $matches as $match ) {
721 + if ( ! empty( glob( $builder_dir . '/' . $match . '/*.php' ) ) ) {
722 + return $match;
723 + }
724 + }
725 +
726 + return $matches[0];
727 + }
728 +}
729 +
730 +if ( ! function_exists( 'wdesignkit_widget_path_guard' ) ) {
731 + /**
732 + * Validate a caller-supplied builder/folder/file triple before it is used to build a path
733 + * under WDKIT_BUILDER_PATH.
734 + *
735 + * sanitize_text_field(), which several AJAX handlers relied on for this, strips nothing a
736 + * filesystem cares about — it returns "../../../../plugins/hello" completely unchanged. So
737 + * a request could place or delete files outside the builder directory, where the .htaccess
738 + * deny rule does not reach (CWE-22, ClickUp 86d41cckh).
739 + *
740 + * sanitize_key() plus the allowlist pins the builder segment to a directory we own, and
741 + * sanitize_file_name() flattens the other two to a single segment each: it removes path
742 + * separators and null bytes, and a value made only of dots and separators ("..", "../",
743 + * "%2e%2e%2f") collapses to an empty string, which is rejected here rather than silently
744 + * resolving to the builder directory itself.
745 + *
746 + * Guarding the components is the first half. Callers that create or resolve the directory
747 + * must also run wdesignkit_path_inside_builder_dir() on the final path, which re-checks the
748 + * resolved result and so also catches a path reached through a symlink.
749 + *
750 + * @since 2.6.4
751 + *
752 + * @param string $builder Builder slug: elementor|gutenberg|gutenberg_core|bricks.
753 + * @param string $folder Optional widget folder name. Must be one path segment.
754 + * @param string $file Optional file base name, no extension. Must be one path segment.
755 + * @return array|false Sanitized parts { builder, folder, file, builder_dir, dir, base },
756 + * or false when any component is missing or escapes.
757 + */
758 + function wdesignkit_widget_path_guard( $builder, $folder = '', $file = '' ) {
759 + if ( ! defined( 'WDKIT_BUILDER_PATH' ) ) {
760 + return false;
761 + }
762 +
763 + $builder = sanitize_key( (string) $builder );
764 + if ( ! in_array( $builder, array( 'elementor', 'gutenberg', 'gutenberg_core', 'bricks' ), true ) ) {
765 + return false;
766 + }
767 +
768 + $folder_raw = (string) $folder;
769 + $file_raw = (string) $file;
770 + $folder = ( '' === $folder_raw ) ? '' : sanitize_file_name( $folder_raw );
771 + $file = ( '' === $file_raw ) ? '' : sanitize_file_name( $file_raw );
772 +
773 + if ( ( '' !== $folder_raw && '' === $folder ) || ( '' !== $file_raw && '' === $file ) ) {
774 + return false;
775 + }
776 +
777 + $builder_dir = trailingslashit( WDKIT_BUILDER_PATH ) . $builder;
778 + $dir = ( '' === $folder ) ? $builder_dir : $builder_dir . '/' . $folder;
779 +
780 + return array(
781 + 'builder' => $builder,
782 + 'folder' => $folder,
783 + 'file' => $file,
784 + 'builder_dir' => $builder_dir,
785 + 'dir' => $dir,
786 + 'base' => ( '' === $file ) ? '' : $dir . '/' . $file,
787 + );
788 + }
789 +}
790 +
791 +if ( ! function_exists( 'wdesignkit_path_inside_builder_dir' ) ) {
792 + /**
793 + * Whether $path resolves to WDKIT_BUILDER_PATH or somewhere beneath it.
794 + *
795 + * Counterpart to wdesignkit_widget_path_guard(): that one sanitizes the components before a
796 + * path is assembled, this one re-checks the assembled path once it exists on disk, so a
797 + * symlinked folder cannot point the write somewhere else. Returns false when the path does
798 + * not exist yet, so create the directory first and check afterwards.
799 + *
800 + * @since 2.6.4
801 + *
802 + * @param string $path Absolute path to test.
803 + * @return bool
804 + */
805 + function wdesignkit_path_inside_builder_dir( $path ) {
806 + if ( ! defined( 'WDKIT_BUILDER_PATH' ) || '' === (string) $path ) {
807 + return false;
808 + }
809 +
810 + $real_base = realpath( WDKIT_BUILDER_PATH );
811 + $real_path = realpath( (string) $path );
812 +
813 + if ( ! $real_base || ! $real_path ) {
814 + return false;
815 + }
816 +
817 + return $real_path === $real_base
818 + || 0 === strpos( $real_path, rtrim( $real_base, DIRECTORY_SEPARATOR ) . DIRECTORY_SEPARATOR );
819 + }
820 +}
821 +
822 +if ( ! function_exists( 'wdesignkit_safe_image_extension' ) ) {
823 + /**
824 + * Decide the on-disk extension for a downloaded widget thumbnail.
825 + *
826 + * The extension must never be taken verbatim from a remote URL (CWE-434, ClickUp 86d41cczd).
827 + * A cloud response — or a MITM, or a compromised API host — pointing an image field at a
828 + * ".php" path would otherwise write attacker-supplied bytes as an executable file inside the
829 + * builder directory, which lives under web-served uploads. sanitize_file_name() is not a
830 + * defence here: it passes "php" straight through.
831 + *
832 + * When the payload is available the bytes decide, and anything that is not one of our image
833 + * types is refused outright rather than written under a fallback name. With no bytes to
834 + * inspect (a local copy, or no finfo extension) the URL's extension is accepted only if it
835 + * is in the allowlist, and falls back to png otherwise — arbitrary bytes under a png name
836 + * are inert.
837 + *
838 + * @since 2.6.4
839 + *
840 + * @param string $url Source URL or filename the extension is taken from.
841 + * @param string|null $body Downloaded bytes, when available, for MIME verification.
842 + * @return string Safe extension without a dot, or '' when the payload must not be written.
843 + */
844 + function wdesignkit_safe_image_extension( $url, $body = null ) {
845 + $allowed_exts = array( 'jpg', 'jpeg', 'png', 'webp' );
846 + $mime_to_ext = array(
847 + 'image/jpeg' => 'jpg',
848 + 'image/png' => 'png',
849 + 'image/webp' => 'webp',
850 + );
851 +
852 + // wp_parse_url() first: pathinfo() on a whole URL folds the query string into the
853 + // extension, turning "photo.png?v=2" into "png?v=2" and losing a legitimate match.
854 + $path = (string) wp_parse_url( (string) $url, PHP_URL_PATH );
855 + $url_ext = strtolower( (string) pathinfo( $path, PATHINFO_EXTENSION ) );
856 +
857 + $real_mime = '';
858 + if ( null !== $body && '' !== (string) $body && function_exists( 'finfo_open' ) ) {
859 + $finfo = finfo_open( FILEINFO_MIME_TYPE );
860 + if ( false !== $finfo ) {
861 + $real_mime = (string) finfo_buffer( $finfo, (string) $body );
862 + finfo_close( $finfo );
863 + }
864 + }
865 +
866 + if ( '' !== $real_mime ) {
867 + if ( ! isset( $mime_to_ext[ $real_mime ] ) ) {
868 + return '';
869 + }
870 +
871 + // Keep a legitimate ".jpeg" spelling when the payload really is a JPEG.
872 + return ( 'image/jpeg' === $real_mime && 'jpeg' === $url_ext ) ? 'jpeg' : $mime_to_ext[ $real_mime ];
873 + }
874 +
875 + return in_array( $url_ext, $allowed_exts, true ) ? $url_ext : 'png';
876 + }
877 +}
878 +
879 +if ( ! function_exists( 'wdesignkit_flush_snippet_index_cache' ) ) {
880 + /**
881 + * Drop any stale OPcache/stat copy of the Nexter file-based snippet index.
882 + *
883 + * The index (nxt-snippet-list.php) is a generated PHP file that Nexter pulls in with
884 + * `include`, so on OPcache-backed hosts its compiled bytecode can lag the on-disk file
885 + * by one write: an import writes the file *after* the request already compiled the
886 + * previous version, and opcache.revalidate_freq delays noticing the new mtime. The
887 + * result is a "stale-by-one" list — a freshly imported snippet stays invisible to
888 + * wdesignkit/list-local-snippets until the *next* import bumps the file again.
889 + *
890 + * Calling this before reading the index (and after writing it) forces a fresh recompile.
891 + * opcache_invalidate(..., true) is an explicit invalidation, so it works even when
892 + * opcache.validate_timestamps is disabled on hardened production hosts.
893 + *
894 + * @since 2.6.3
895 + * @return void
896 + */
897 + function wdesignkit_flush_snippet_index_cache() {
898 + if ( ! class_exists( 'Nexter_Code_Snippets_File_Based' ) ) {
899 + return;
900 + }
901 +
902 + if ( ! method_exists( 'Nexter_Code_Snippets_File_Based', 'getfileDir' ) ) {
903 + return;
904 + }
905 +
906 + $dir = \Nexter_Code_Snippets_File_Based::getfileDir();
907 + if ( empty( $dir ) ) {
908 + return;
909 + }
910 +
911 + // Current index file plus the legacy name Nexter migrates away from.
912 + foreach ( array( 'nxt-snippet-list.php', 'index.php' ) as $name ) {
913 + $file = wp_normalize_path( $dir . '/' . $name );
914 +
915 + clearstatcache( true, $file );
916 +
917 + if ( function_exists( 'opcache_invalidate' ) && is_file( $file ) ) {
918 + @opcache_invalidate( $file, true );
919 + }
920 + }
921 + }
922 +}
923 +
924 +if ( ! function_exists( 'wdesignkit_cloud_session_key' ) ) {
925 + /**
926 + * Derives the transient key for a cloud account's stored session.
927 + *
928 + * Previously this was strstr( $email, '@', true ) — the local part only, with the
929 + * domain discarded. Two different cloud accounts sharing a local part (e.g.
930 + * [email protected] and [email protected]) collided on the same transient, so
931 + * whichever logged in second silently overwrote the first's token. Hashing the full,
932 + * lowercased address makes every distinct account key uniquely while keeping the
933 + * option name short and free of special characters.
934 + *
935 + * @since 2.6.4
936 + *
937 + * @param string $email Cloud account email.
938 + * @return string Key suitable for appending to 'wdkit_auth_'.
939 + */
940 + function wdesignkit_cloud_session_key( $email ) {
941 + $email = is_string( $email ) ? strtolower( trim( $email ) ) : '';
942 +
943 + return $email ? md5( $email ) : '';
944 + }
945 +}
946 +
36 947 if ( ! function_exists( 'wdesignkit_mcp_remember_session' ) ) {
37 948 /**
38 949 * Record which cloud account the stored session belongs to.
39 950 *
40 - * The session transient is keyed off the CLOUD email's local part, which is
951 + * The session transient is keyed off a hash of the cloud account's email, which is
41 952 * usually a different address from the WordPress user running the request. Without
42 953 * this pointer the only way back to the session is scanning wp_options for
43 954 * _transient_wdkit_auth_* rows and hoping the right one comes back — on a site that
44 955 * has been logged in with several accounts, that scan can return a stale/expired row
@@ -44,11 +955,15 @@
44 955 * has been logged in with several accounts, that scan can return a stale/expired row
45 956 * (or miss the fresh one entirely once a LIMIT is hit) and every cloud ability then
46 957 * reports "not logged in" immediately after a successful login.
47 958 *
959 + * Stored per WP user ID (not as a single site-wide value): otherwise, on a site with
960 + * more than one admin, whichever admin logged into the cloud most recently would
961 + * silently become the session every other admin's MCP calls resolve to.
962 + *
48 963 * @since 2.6.2
49 964 *
50 - * @param string $user_key Local part of the cloud account email.
965 + * @param string $user_key Cloud account session key (see wdesignkit_cloud_session_key()).
51 966 * @return void
52 967 */
53 968 function wdesignkit_mcp_remember_session( $user_key ) {
54 969 $user_key = is_string( $user_key ) ? trim( $user_key ) : '';
@@ -56,8 +971,14 @@
56 971 if ( '' === $user_key ) {
57 972 return;
58 973 }
59 974
975 + $wp_user_id = get_current_user_id();
976 + if ( $wp_user_id ) {
977 + update_user_meta( $wp_user_id, 'wdkit_mcp_session_user', $user_key );
978 + }
979 +
980 + // Kept as a fallback for contexts with no current WP user (e.g. cron/CLI).
60 981 update_option( 'wdkit_mcp_session_user', $user_key, false );
61 982 }
62 983 }
63 984
@@ -69,8 +990,13 @@
69 990 *
70 991 * @return void
71 992 */
72 993 function wdesignkit_mcp_forget_session() {
994 + $wp_user_id = get_current_user_id();
995 + if ( $wp_user_id ) {
996 + delete_user_meta( $wp_user_id, 'wdkit_mcp_session_user' );
997 + }
998 +
73 999 delete_option( 'wdkit_mcp_session_user' );
74 1000 }
75 1001 }
76 1002
@@ -114,13 +1040,17 @@
114 1040 /**
115 1041 * Locate the active WDesignKit cloud session.
116 1042 *
117 1043 * Lookup order:
118 - * 1. The account recorded at login time (wdkit_mcp_session_user) — authoritative.
119 - * 2. The transient keyed off the current WP user's email local part.
120 - * 3. A scan of every _transient_wdkit_auth_* row, preferring the live session with
1044 + * 1. The account this WP user last logged into (per-user meta) — authoritative.
1045 + * 2. The site-wide fallback pointer, for contexts with no current WP user.
1046 + * 3. The transient keyed off the current WP user's own email address.
1047 + * 4. A scan of every _transient_wdkit_auth_* row, preferring the live session with
121 1048 * the furthest expiry. Unlike the previous LIMIT 5 / LIMIT 10 scans this cannot
122 - * silently skip the freshest session on a site with several stored accounts.
1049 + * silently skip the freshest session on a site with several stored accounts. A
1050 + * session with no recorded timeout is treated as invalid, not preferred — every
1051 + * writer in this codebase always sets a timeout, so one with none is unaccounted
1052 + * for and should not be trusted as "best".
123 1053 *
124 1054 * @since 2.6.2
125 1055 *
126 1056 * @return array{found:bool,expired:bool,key:string,data:array,timeout:int|null}
@@ -174,9 +1104,21 @@
174 1104 };
175 1105
176 1106 $expired_seen = false;
177 1107
178 - // 1. The account this site last logged into.
1108 + // 1. The account this specific WP user last logged into.
1109 + $wp_user_id = get_current_user_id();
1110 + if ( $wp_user_id ) {
1111 + $session = $read( get_user_meta( $wp_user_id, 'wdkit_mcp_session_user', true ) );
1112 + if ( is_array( $session ) ) {
1113 + if ( ! empty( $session['found'] ) ) {
1114 + return $session;
1115 + }
1116 + $expired_seen = true;
1117 + }
1118 + }
1119 +
1120 + // 2. Site-wide fallback pointer (contexts with no current WP user, e.g. cron/CLI).
179 1121 $session = $read( get_option( 'wdkit_mcp_session_user', '' ) );
180 1122 if ( is_array( $session ) ) {
181 1123 if ( ! empty( $session['found'] ) ) {
182 1124 return $session;
@@ -183,12 +1125,12 @@
183 1125 }
184 1126 $expired_seen = true;
185 1127 }
186 1128
187 - // 2. The WP user running the request (only matches when both emails share a local part).
1129 + // 3. The WP user running the request, keyed off their own email address.
188 1130 $current_user = wp_get_current_user();
189 1131 if ( $current_user && $current_user->user_email ) {
190 - $session = $read( strstr( $current_user->user_email, '@', true ) );
1132 + $session = $read( wdesignkit_cloud_session_key( $current_user->user_email ) );
191 1133 if ( is_array( $session ) ) {
192 1134 if ( ! empty( $session['found'] ) ) {
193 1135 return $session;
194 1136 }
@@ -195,9 +1137,9 @@
195 1137 $expired_seen = true;
196 1138 }
197 1139 }
198 1140
199 - // 3. Every stored session, newest expiry first.
1141 + // 4. Every stored session, newest expiry first.
200 1142 global $wpdb;
201 1143
202 1144 $rows = $wpdb->get_results(
203 1145 $wpdb->prepare(
@@ -224,8 +1166,15 @@
224 1166 if ( empty( $data['token'] ) ) {
225 1167 continue;
226 1168 }
227 1169
1170 + // A session with no recorded timeout is unaccounted for — every writer in this
1171 + // codebase always sets one, so treat this as invalid rather than as the best
1172 + // candidate.
1173 + if ( null === $timeout ) {
1174 + continue;
1175 + }
1176 +
228 1177 $candidate = array(
229 1178 'found' => true,
230 1179 'expired' => false,
231 1180 'key' => str_replace( 'wdkit_auth_', '', $key ),
@@ -231,13 +1180,8 @@
231 1180 'key' => str_replace( 'wdkit_auth_', '', $key ),
232 1181 'data' => $data,
233 1182 'timeout' => $timeout,
234 1183 );
235 -
236 - // A session with no timeout never expires — always prefer it.
237 - if ( null === $candidate['timeout'] ) {
238 - return $candidate;
239 - }
240 1184
241 1185 if ( null === $best || $candidate['timeout'] > $best['timeout'] ) {
242 1186 $best = $candidate;
243 1187 }