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
wdesignkit / includes / abilities / class-wdk-ability-main.php

class-wdk-ability-main.php in WDesignKit – AI Templates, Widget Builder & MCP Workflow 2.7.0, at includes/abilities/class-wdk-ability-main.php

1,292 lines 44.9 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * WDesignKit Abilities Loader.
4 *
5 * @link https://posimyth.com/
6 * @since 2.3.0
7 *
8 * @package Wdesignkit
9 * @subpackage Wdesignkit/includes/abilities
10 */
11
12 if ( ! defined( 'ABSPATH' ) ) {
13 exit;
14 }
15
16 if ( ! function_exists( 'wdesignkit_mcp_permission_callback' ) ) {
17 /**
18 * Permission callback for all WDesignKit MCP abilities.
19 *
20 * Defined and owned by WDesignKit so ability registration never depends on an
21 * external (e.g. SproutOS) helper existing. WP_Ability::prepare_properties() validates
22 * this callback at registration time; a missing/uncallable reference throws, which
23 * WP_Abilities_Registry::register() swallows with a _doing_it_wrong() notice, silently
24 * dropping the ability. Every WDesignKit ability operates on site-wide settings,
25 * widgets, templates, or code snippets, all administrator-level, so a single
26 * manage_options check is the correct, consistent gate.
27 *
28 * @param mixed $input Ability input arguments (unused).
29 * @return bool Whether the current user may use the ability.
30 */
31 function wdesignkit_mcp_permission_callback( $input = null ) {
32 return current_user_can( 'manage_options' );
33 }
34 }
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
947 if ( ! function_exists( 'wdesignkit_mcp_remember_session' ) ) {
948 /**
949 * Record which cloud account the stored session belongs to.
950 *
951 * The session transient is keyed off a hash of the cloud account's email, which is
952 * usually a different address from the WordPress user running the request. Without
953 * this pointer the only way back to the session is scanning wp_options for
954 * _transient_wdkit_auth_* rows and hoping the right one comes back — on a site that
955 * has been logged in with several accounts, that scan can return a stale/expired row
956 * (or miss the fresh one entirely once a LIMIT is hit) and every cloud ability then
957 * reports "not logged in" immediately after a successful login.
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 *
963 * @since 2.6.2
964 *
965 * @param string $user_key Cloud account session key (see wdesignkit_cloud_session_key()).
966 * @return void
967 */
968 function wdesignkit_mcp_remember_session( $user_key ) {
969 $user_key = is_string( $user_key ) ? trim( $user_key ) : '';
970
971 if ( '' === $user_key ) {
972 return;
973 }
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).
981 update_option( 'wdkit_mcp_session_user', $user_key, false );
982 }
983 }
984
985 if ( ! function_exists( 'wdesignkit_mcp_forget_session' ) ) {
986 /**
987 * Drop the active-session pointer written by wdesignkit_mcp_remember_session().
988 *
989 * @since 2.6.2
990 *
991 * @return void
992 */
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
999 delete_option( 'wdkit_mcp_session_user' );
1000 }
1001 }
1002
1003 if ( ! function_exists( 'wdesignkit_mcp_normalise_auth' ) ) {
1004 /**
1005 * Normalise a raw session transient value into an associative array.
1006 *
1007 * Handles the shapes different storage backends hand back:
1008 * 1. PHP serialized string → maybe_unserialize() already returned an array
1009 * 2. JSON-encoded string → decode it
1010 * 3. stdClass object → cast public props to keys
1011 * Anything else (false for a missing transient, int, …) becomes an empty array.
1012 *
1013 * @since 2.6.2
1014 *
1015 * @param mixed $raw Raw transient value.
1016 * @return array Normalised session data.
1017 */
1018 function wdesignkit_mcp_normalise_auth( $raw ) {
1019 if ( is_array( $raw ) ) {
1020 return $raw;
1021 }
1022
1023 if ( $raw instanceof \stdClass ) {
1024 return (array) $raw;
1025 }
1026
1027 if ( is_string( $raw ) && '' !== $raw ) {
1028 $decoded = json_decode( $raw, true );
1029
1030 if ( is_array( $decoded ) ) {
1031 return $decoded;
1032 }
1033 }
1034
1035 return array();
1036 }
1037 }
1038
1039 if ( ! function_exists( 'wdesignkit_mcp_find_auth_session' ) ) {
1040 /**
1041 * Locate the active WDesignKit cloud session.
1042 *
1043 * Lookup order:
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
1048 * the furthest expiry. Unlike the previous LIMIT 5 / LIMIT 10 scans this cannot
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".
1053 *
1054 * @since 2.6.2
1055 *
1056 * @return array{found:bool,expired:bool,key:string,data:array,timeout:int|null}
1057 */
1058 function wdesignkit_mcp_find_auth_session() {
1059 $empty = array(
1060 'found' => false,
1061 'expired' => false,
1062 'key' => '',
1063 'data' => array(),
1064 'timeout' => null,
1065 );
1066
1067 $read = static function ( $user_key ) {
1068 $user_key = is_string( $user_key ) ? trim( $user_key ) : '';
1069
1070 if ( '' === $user_key ) {
1071 return null;
1072 }
1073
1074 $timeout = get_option( '_transient_timeout_wdkit_auth_' . $user_key );
1075 $timeout = $timeout ? (int) $timeout : null;
1076
1077 // Explicit expiry guard: an external object cache can hand back stale data
1078 // after the timeout has passed, so compare the raw timestamp first.
1079 if ( $timeout && $timeout < time() ) {
1080 delete_transient( 'wdkit_auth_' . $user_key );
1081
1082 return array(
1083 'found' => false,
1084 'expired' => true,
1085 'key' => $user_key,
1086 'data' => array(),
1087 'timeout' => $timeout,
1088 );
1089 }
1090
1091 $data = wdesignkit_mcp_normalise_auth( get_transient( 'wdkit_auth_' . $user_key ) );
1092
1093 if ( empty( $data['token'] ) ) {
1094 return null;
1095 }
1096
1097 return array(
1098 'found' => true,
1099 'expired' => false,
1100 'key' => $user_key,
1101 'data' => $data,
1102 'timeout' => $timeout,
1103 );
1104 };
1105
1106 $expired_seen = false;
1107
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).
1121 $session = $read( get_option( 'wdkit_mcp_session_user', '' ) );
1122 if ( is_array( $session ) ) {
1123 if ( ! empty( $session['found'] ) ) {
1124 return $session;
1125 }
1126 $expired_seen = true;
1127 }
1128
1129 // 3. The WP user running the request, keyed off their own email address.
1130 $current_user = wp_get_current_user();
1131 if ( $current_user && $current_user->user_email ) {
1132 $session = $read( wdesignkit_cloud_session_key( $current_user->user_email ) );
1133 if ( is_array( $session ) ) {
1134 if ( ! empty( $session['found'] ) ) {
1135 return $session;
1136 }
1137 $expired_seen = true;
1138 }
1139 }
1140
1141 // 4. Every stored session, newest expiry first.
1142 global $wpdb;
1143
1144 $rows = $wpdb->get_results(
1145 $wpdb->prepare(
1146 "SELECT option_name, option_value FROM {$wpdb->options} WHERE option_name LIKE %s",
1147 $wpdb->esc_like( '_transient_wdkit_auth_' ) . '%'
1148 ),
1149 ARRAY_A
1150 );
1151
1152 $best = null;
1153
1154 foreach ( ( $rows ? $rows : array() ) as $row ) {
1155 $key = str_replace( '_transient_', '', $row['option_name'] );
1156 $timeout = get_option( '_transient_timeout_' . $key );
1157 $timeout = $timeout ? (int) $timeout : null;
1158
1159 if ( $timeout && $timeout < time() ) {
1160 $expired_seen = true;
1161 continue;
1162 }
1163
1164 $data = wdesignkit_mcp_normalise_auth( @maybe_unserialize( $row['option_value'] ) );
1165
1166 if ( empty( $data['token'] ) ) {
1167 continue;
1168 }
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
1177 $candidate = array(
1178 'found' => true,
1179 'expired' => false,
1180 'key' => str_replace( 'wdkit_auth_', '', $key ),
1181 'data' => $data,
1182 'timeout' => $timeout,
1183 );
1184
1185 if ( null === $best || $candidate['timeout'] > $best['timeout'] ) {
1186 $best = $candidate;
1187 }
1188 }
1189
1190 if ( null !== $best ) {
1191 return $best;
1192 }
1193
1194 $empty['expired'] = $expired_seen;
1195
1196 return $empty;
1197 }
1198 }
1199
1200 if ( ! class_exists( 'Wdk_Ability_Main' ) ) {
1201
1202 /**
1203 * Registers the WDesignKit ability category and loads all ability files.
1204 *
1205 * @since 2.3.0
1206 */
1207 class Wdk_Ability_Main {
1208
1209 /**
1210 * @since 2.3.0
1211 */
1212 private static $instance = null;
1213
1214 /**
1215 * @since 2.3.0
1216 */
1217 public static function instance() {
1218 if ( is_null( self::$instance ) ) {
1219 self::$instance = new self();
1220 }
1221
1222 return self::$instance;
1223 }
1224
1225 /**
1226 * @since 2.3.0
1227 */
1228 public function __construct() {
1229 add_action( 'wp_abilities_api_categories_init', array( $this, 'wdk_register_ability_category' ) );
1230 add_action( 'wp_abilities_api_init', array( $this, 'wdk_register_abilities' ) );
1231 }
1232
1233 /**
1234 * Register the WDesignKit ability category.
1235 *
1236 * @since 2.3.0
1237 */
1238 public function wdk_register_ability_category() {
1239 if ( ! function_exists( 'wp_has_ability_category' ) || ! function_exists( 'wp_register_ability_category' ) ) {
1240 return;
1241 }
1242
1243 if ( wp_has_ability_category( 'wdesignkit' ) ) {
1244 return;
1245 }
1246
1247 wp_register_ability_category( 'wdesignkit', array(
1248 'label' => __( 'WDesignKit', 'wdesignkit' ),
1249 'description' => __( 'Abilities for WDesignKit widget management and settings.', 'wdesignkit' ),
1250 ) );
1251 }
1252
1253 /**
1254 * Dynamically load and register all abilities from the wdesignkit ability folder.
1255 *
1256 * @since 2.3.0
1257 */
1258 public function wdk_register_abilities() {
1259 if ( ! function_exists( 'wp_register_ability' ) || ! function_exists( 'wp_has_ability_category' ) ) {
1260 return;
1261 }
1262
1263 if ( ! wp_has_ability_category( 'wdesignkit' ) ) {
1264 return;
1265 }
1266
1267 $ability_dir = WDKIT_INCLUDES . 'abilities';
1268
1269 if ( ! is_dir( $ability_dir ) ) {
1270 return;
1271 }
1272
1273 $ability_files = array_merge(
1274 glob( $ability_dir . '/wdesignkit-*.php' ) ?: array(),
1275 glob( $ability_dir . '/*/wdesignkit-*.php' ) ?: array()
1276 );
1277
1278 if ( empty( $ability_files ) ) {
1279 return;
1280 }
1281
1282 foreach ( $ability_files as $ability_file ) {
1283 if ( is_file( $ability_file ) ) {
1284 require_once $ability_file;
1285 }
1286 }
1287 }
1288 }
1289
1290 Wdk_Ability_Main::instance();
1291 }
1292