PluginProbe
WDesignKit – AI Templates, Widget Builder & MCP Workflow / 2.7.0
WDesignKit – AI Templates, Widget Builder & MCP Workflow v2.7.0
2.7.1 2.7.0 2.6.6 2.6.5 2.6.4 2.6.3 2.6.2 2.6.1 2.6.0 2.5.5 2.5.4 2.5.3 2.5.2 2.5.1 2.5.0 2.4.0 2.3.3 2.3.2 2.3.1 1.0.10 1.0.11 1.0.12 1.0.13 1.0.14 1.0.15 All 130 releases
← All changes | includes/abilities/class-wdk-ability-main.php +1184 -0 2.5.1 → 2.7.0 View file →
@@ -12,8 +12,1192 @@
12 12 if ( ! defined( 'ABSPATH' ) ) {
13 13 exit;
14 14 }
15 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 +
16 1200 if ( ! class_exists( 'Wdk_Ability_Main' ) ) {
17 1201
18 1202 /**
19 1203 * Registers the WDesignKit ability category and loads all ability files.