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 +800 -15 2.6.3 → 2.7.0 View file →
@@ -146,8 +146,737 @@
146 146 return wp_safe_remote_get( $url, wp_parse_args( $args, $defaults ) );
147 147 }
148 148 }
149 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 +
150 879 if ( ! function_exists( 'wdesignkit_flush_snippet_index_cache' ) ) {
151 880 /**
152 881 * Drop any stale OPcache/stat copy of the Nexter file-based snippet index.
153 882 *
@@ -191,13 +920,36 @@
191 920 }
192 921 }
193 922 }
194 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 +
195 947 if ( ! function_exists( 'wdesignkit_mcp_remember_session' ) ) {
196 948 /**
197 949 * Record which cloud account the stored session belongs to.
198 950 *
199 - * 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
200 952 * usually a different address from the WordPress user running the request. Without
201 953 * this pointer the only way back to the session is scanning wp_options for
202 954 * _transient_wdkit_auth_* rows and hoping the right one comes back — on a site that
203 955 * has been logged in with several accounts, that scan can return a stale/expired row
@@ -203,11 +955,15 @@
203 955 * has been logged in with several accounts, that scan can return a stale/expired row
204 956 * (or miss the fresh one entirely once a LIMIT is hit) and every cloud ability then
205 957 * reports "not logged in" immediately after a successful login.
206 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 + *
207 963 * @since 2.6.2
208 964 *
209 - * @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()).
210 966 * @return void
211 967 */
212 968 function wdesignkit_mcp_remember_session( $user_key ) {
213 969 $user_key = is_string( $user_key ) ? trim( $user_key ) : '';
@@ -215,8 +971,14 @@
215 971 if ( '' === $user_key ) {
216 972 return;
217 973 }
218 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).
219 981 update_option( 'wdkit_mcp_session_user', $user_key, false );
220 982 }
221 983 }
222 984
@@ -228,8 +990,13 @@
228 990 *
229 991 * @return void
230 992 */
231 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 +
232 999 delete_option( 'wdkit_mcp_session_user' );
233 1000 }
234 1001 }
235 1002
@@ -273,13 +1040,17 @@
273 1040 /**
274 1041 * Locate the active WDesignKit cloud session.
275 1042 *
276 1043 * Lookup order:
277 - * 1. The account recorded at login time (wdkit_mcp_session_user) — authoritative.
278 - * 2. The transient keyed off the current WP user's email local part.
279 - * 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
280 1048 * the furthest expiry. Unlike the previous LIMIT 5 / LIMIT 10 scans this cannot
281 - * 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".
282 1053 *
283 1054 * @since 2.6.2
284 1055 *
285 1056 * @return array{found:bool,expired:bool,key:string,data:array,timeout:int|null}
@@ -333,9 +1104,21 @@
333 1104 };
334 1105
335 1106 $expired_seen = false;
336 1107
337 - // 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).
338 1121 $session = $read( get_option( 'wdkit_mcp_session_user', '' ) );
339 1122 if ( is_array( $session ) ) {
340 1123 if ( ! empty( $session['found'] ) ) {
341 1124 return $session;
@@ -342,12 +1125,12 @@
342 1125 }
343 1126 $expired_seen = true;
344 1127 }
345 1128
346 - // 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.
347 1130 $current_user = wp_get_current_user();
348 1131 if ( $current_user && $current_user->user_email ) {
349 - $session = $read( strstr( $current_user->user_email, '@', true ) );
1132 + $session = $read( wdesignkit_cloud_session_key( $current_user->user_email ) );
350 1133 if ( is_array( $session ) ) {
351 1134 if ( ! empty( $session['found'] ) ) {
352 1135 return $session;
353 1136 }
@@ -354,9 +1137,9 @@
354 1137 $expired_seen = true;
355 1138 }
356 1139 }
357 1140
358 - // 3. Every stored session, newest expiry first.
1141 + // 4. Every stored session, newest expiry first.
359 1142 global $wpdb;
360 1143
361 1144 $rows = $wpdb->get_results(
362 1145 $wpdb->prepare(
@@ -383,8 +1166,15 @@
383 1166 if ( empty( $data['token'] ) ) {
384 1167 continue;
385 1168 }
386 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 +
387 1177 $candidate = array(
388 1178 'found' => true,
389 1179 'expired' => false,
390 1180 'key' => str_replace( 'wdkit_auth_', '', $key ),
@@ -390,13 +1180,8 @@
390 1180 'key' => str_replace( 'wdkit_auth_', '', $key ),
391 1181 'data' => $data,
392 1182 'timeout' => $timeout,
393 1183 );
394 -
395 - // A session with no timeout never expires — always prefer it.
396 - if ( null === $candidate['timeout'] ) {
397 - return $candidate;
398 - }
399 1184
400 1185 if ( null === $best || $candidate['timeout'] > $best['timeout'] ) {
401 1186 $best = $candidate;
402 1187 }