| 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 |
|