← All changes
|
includes/setup-wizard/class-remote-data-handler.php
+114
-12
4.1.16
→
4.5.6
View file →
| @@ -26,8 +26,61 @@ | ||
| 26 | 26 | */ |
| 27 | 27 | const CACHE_KEY = 'bdt_remote_plugins_data'; |
| 28 | 28 | |
| 29 | 29 | /** |
| 30 | + * Bundled logo file name for each plugin slug. | |
| 31 | + * | |
| 32 | + * These ship with the plugin (assets/images/others-plugin-logo/) so the plugin | |
| 33 | + * cards render our own branded artwork instead of the wordpress.org icon, and | |
| 34 | + * still show something for plugins whose .org listing has no icon at all. | |
| 35 | + * The key is the wordpress.org slug; the value is the file base name. | |
| 36 | + * | |
| 37 | + * @var array<string, string> | |
| 38 | + */ | |
| 39 | + const LOCAL_PLUGIN_LOGOS = [ | |
| 40 | + 'bdthemes-element-pack-lite' => 'element-pack', | |
| 41 | + 'bdthemes-prime-slider-lite' => 'prime-slider', | |
| 42 | + 'ultimate-post-kit' => 'ultimate-post-kit', | |
| 43 | + 'ultimate-store-kit' => 'ultimate-store-kit', | |
| 44 | + 'zoloblocks' => 'zoloblocks', | |
| 45 | + 'pixel-gallery' => 'pixel-gallery', | |
| 46 | + 'live-copy-paste' => 'live-copy-paste', | |
| 47 | + 'spin-wheel' => 'spin-wheel', | |
| 48 | + 'ai-image' => 'ai-image', | |
| 49 | + 'dark-reader' => 'dark-reader', | |
| 50 | + 'ar-viewer' => 'ar-viewer', | |
| 51 | + 'smart-admin-assistant' => 'smart-admin-assistant', | |
| 52 | + 'website-accessibility' => 'one-accessibility', | |
| 53 | + 'launch-guard' => 'launch-guard', | |
| 54 | + 'sigma-forms' => 'sigma-forms', | |
| 55 | + 'sigma-media-manager' => 'sigma-media-manager', | |
| 56 | + 'sigma-store-locator' => 'sigma-store-locator', | |
| 57 | + 'swift-checkout' => 'swift-checkout', | |
| 58 | + ]; | |
| 59 | + | |
| 60 | + /** | |
| 61 | + * Resolve the bundled logo URL for a plugin slug. | |
| 62 | + * | |
| 63 | + * @param string $slug Plugin slug. | |
| 64 | + * @return string Logo URL, or an empty string when the slug has no bundled logo. | |
| 65 | + */ | |
| 66 | + public static function get_local_plugin_logo( $slug ) { | |
| 67 | + if ( ! is_string( $slug ) || '' === $slug || ! isset( self::LOCAL_PLUGIN_LOGOS[ $slug ] ) ) { | |
| 68 | + return ''; | |
| 69 | + } | |
| 70 | + | |
| 71 | + $file = self::LOCAL_PLUGIN_LOGOS[ $slug ] . '.png'; | |
| 72 | + | |
| 73 | + // Only advertise the file if it actually shipped, so a trimmed build falls | |
| 74 | + // back to the remote icon rather than rendering a broken image. | |
| 75 | + if ( defined( 'BDTUPK_PATH' ) && ! file_exists( BDTUPK_PATH . 'assets/images/others-plugin-logo/' . $file ) ) { | |
| 76 | + return ''; | |
| 77 | + } | |
| 78 | + | |
| 79 | + return BDTUPK_ASSETS_URL . 'images/others-plugin-logo/' . $file; | |
| 80 | + } | |
| 81 | + | |
| 82 | + /** | |
| 30 | 83 | * Cron hook name for background fetch |
| 31 | 84 | */ |
| 32 | 85 | const CRON_HOOK = 'bdt_fetch_remote_plugins_cron'; |
| 33 | 86 | |
| @@ -36,10 +89,10 @@ | ||
| 36 | 89 | */ |
| 37 | 90 | public static function init() { |
| 38 | 91 | add_action('init', [__CLASS__, 'schedule_cron']); |
| 39 | 92 | add_action(self::CRON_HOOK, [__CLASS__, 'cron_fetch_plugins']); |
| 93 | + // Admin-only plugin-install data; never expose to unauthenticated visitors. | |
| 40 | 94 | add_action('wp_ajax_upk_get_plugins', [__CLASS__, 'ajax_get_plugins']); |
| 41 | - add_action('wp_ajax_nopriv_upk_get_plugins', [__CLASS__, 'ajax_get_plugins']); | |
| 42 | 95 | } |
| 43 | 96 | |
| 44 | 97 | /** |
| 45 | 98 | * WP-Cron callback for fetching plugins |
| @@ -58,17 +111,20 @@ | ||
| 58 | 111 | return false; |
| 59 | 112 | } |
| 60 | 113 | |
| 61 | 114 | // Check if this is an AJAX request for our plugins |
| 115 | + // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only routing check of the AJAX action name, no form data processed. | |
| 62 | 116 | if (wp_doing_ajax() && isset($_REQUEST['action'])) { |
| 63 | - $action = sanitize_text_field($_REQUEST['action']); | |
| 117 | + // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only routing check of the AJAX action name, no form data processed. | |
| 118 | + $action = sanitize_text_field(wp_unslash($_REQUEST['action'])); | |
| 64 | 119 | if (in_array($action, ['upk_get_plugins'])) { |
| 65 | 120 | return true; |
| 66 | 121 | } |
| 67 | 122 | } |
| 68 | 123 | |
| 69 | - $page = isset($_GET['page']) ? sanitize_text_field($_GET['page']) : ''; | |
| 70 | - return $page === 'element_pack_options'; | |
| 124 | + // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only check of the current admin page slug, no form data processed. | |
| 125 | + $page = isset($_GET['page']) ? sanitize_text_field(wp_unslash($_GET['page'])) : ''; | |
| 126 | + return $page === 'ultimate_post_kit_options'; | |
| 71 | 127 | } |
| 72 | 128 | |
| 73 | 129 | /** |
| 74 | 130 | * Get remote plugins data from cache |
| @@ -150,11 +206,17 @@ | ||
| 150 | 206 | */ |
| 151 | 207 | public static function ajax_get_plugins() { |
| 152 | 208 | // Verify nonce for security |
| 153 | 209 | if (!check_ajax_referer('upk_get_plugins_nonce', 'nonce', false)) { |
| 154 | - wp_die(__('Security check failed.', 'ultimate-post-kit')); | |
| 210 | + wp_send_json_error(['message' => __('Security check failed.', 'ultimate-post-kit')], 403); | |
| 155 | 211 | } |
| 156 | 212 | |
| 213 | + // Gate to users who could act on it; also prevents the synchronous | |
| 214 | + // remote-fetch trigger below from being reachable without capability. | |
| 215 | + if (!current_user_can('install_plugins')) { | |
| 216 | + wp_send_json_error(['message' => __('You do not have permission to do this.', 'ultimate-post-kit')], 403); | |
| 217 | + } | |
| 218 | + | |
| 157 | 219 | // Get cached data |
| 158 | 220 | $plugins_data = self::get_remote_plugins(); |
| 159 | 221 | |
| 160 | 222 | // If cache is empty, fetch immediately for better UX |
| @@ -200,11 +262,11 @@ | ||
| 200 | 262 | $last_updated_formatted = self::format_last_updated($data['last_updated']); |
| 201 | 263 | } |
| 202 | 264 | |
| 203 | 265 | $formatted_plugins[] = [ |
| 204 | - 'name' => $data['name'] ?? '', | |
| 266 | + 'name' => self::decode_api_text($data['name'] ?? ''), | |
| 205 | 267 | 'slug' => $data['slug'] ?? '', |
| 206 | - 'description' => $data['description'] ?? '', | |
| 268 | + 'description' => self::decode_api_text($data['description'] ?? ''), | |
| 207 | 269 | 'logo' => $data['logo'] ?? '', |
| 208 | 270 | 'rating' => $data['rating'] ?? 0, |
| 209 | 271 | 'rating_percentage' => $data['rating_percentage'] ?? 0, |
| 210 | 272 | 'num_ratings' => $data['num_ratings'] ?? 0, |
| @@ -231,8 +293,32 @@ | ||
| 231 | 293 | ]); |
| 232 | 294 | } |
| 233 | 295 | |
| 234 | 296 | /** |
| 297 | + * Decode display text coming from the WordPress.org plugins API. | |
| 298 | + * | |
| 299 | + * The API returns strings that are already HTML-encoded, e.g. | |
| 300 | + * "Element Pack Lite – Addons for Elementor". The renderer escapes | |
| 301 | + * again before injecting into the DOM, which turns the leading "&" into | |
| 302 | + * "&" and prints the entity literally instead of an en dash. Decoding | |
| 303 | + * here means exactly one round of escaping happens, at output. | |
| 304 | + * | |
| 305 | + * Applied when building the response rather than when caching, so | |
| 306 | + * already-cached entries are corrected without waiting for the transient | |
| 307 | + * to expire. | |
| 308 | + * | |
| 309 | + * @param mixed $text Raw value from the API. | |
| 310 | + * @return string Plain text, still to be escaped at output. | |
| 311 | + */ | |
| 312 | + private static function decode_api_text($text) { | |
| 313 | + if (!is_string($text) || '' === $text) { | |
| 314 | + return ''; | |
| 315 | + } | |
| 316 | + | |
| 317 | + return html_entity_decode($text, ENT_QUOTES | ENT_HTML5, 'UTF-8'); | |
| 318 | + } | |
| 319 | + | |
| 320 | + /** | |
| 235 | 321 | * Schedule the cron job on init |
| 236 | 322 | */ |
| 237 | 323 | public static function schedule_cron() { |
| 238 | 324 | // Make sure the cron hook is registered |
| @@ -312,20 +398,25 @@ | ||
| 312 | 398 | if ($diff < 60) { |
| 313 | 399 | return __('Just now', 'ultimate-post-kit'); |
| 314 | 400 | } elseif ($diff < 3600) { |
| 315 | 401 | $minutes = floor($diff / 60); |
| 402 | + /* translators: %d: number of minutes */ | |
| 316 | 403 | return sprintf(_n('%d minute ago', '%d minutes ago', $minutes, 'ultimate-post-kit'), $minutes); |
| 317 | 404 | } elseif ($diff < 86400) { |
| 318 | 405 | $hours = floor($diff / 3600); |
| 406 | + /* translators: %d: number of hours */ | |
| 319 | 407 | return sprintf(_n('%d hour ago', '%d hours ago', $hours, 'ultimate-post-kit'), $hours); |
| 320 | 408 | } elseif ($diff < 2592000) { // 30 days |
| 321 | 409 | $days = floor($diff / 86400); |
| 410 | + /* translators: %d: number of days */ | |
| 322 | 411 | return sprintf(_n('%d day ago', '%d days ago', $days, 'ultimate-post-kit'), $days); |
| 323 | 412 | } elseif ($diff < 31536000) { // 1 year |
| 324 | 413 | $months = floor($diff / 2592000); |
| 414 | + /* translators: %d: number of months */ | |
| 325 | 415 | return sprintf(_n('%d month ago', '%d months ago', $months, 'ultimate-post-kit'), $months); |
| 326 | 416 | } else { |
| 327 | 417 | $years = floor($diff / 31536000); |
| 418 | + /* translators: %d: number of years */ | |
| 328 | 419 | return sprintf(_n('%d year ago', '%d years ago', $years, 'ultimate-post-kit'), $years); |
| 329 | 420 | } |
| 330 | 421 | } |
| 331 | 422 | |
| @@ -400,11 +491,16 @@ | ||
| 400 | 491 | * @param array $raw_data Raw API data |
| 401 | 492 | * @return array Formatted plugin data |
| 402 | 493 | */ |
| 403 | 494 | private static function format_plugin_data($raw_data) { |
| 404 | - // Get the best available icon with validation | |
| 405 | - $icon_url = self::get_valid_plugin_icon($raw_data['icons'] ?? []); | |
| 495 | + // Prefer the logo bundled with this plugin so the cards show our own branded | |
| 496 | + // artwork; fall back to the wordpress.org icon for anything not bundled. | |
| 497 | + $icon_url = self::get_local_plugin_logo($raw_data['slug'] ?? ''); | |
| 406 | 498 | |
| 499 | + if ('' === $icon_url) { | |
| 500 | + $icon_url = self::get_valid_plugin_icon($raw_data['icons'] ?? []); | |
| 501 | + } | |
| 502 | + | |
| 407 | 503 | // Format active installs with null safety and real data |
| 408 | 504 | $active_installs_raw = $raw_data['active_installs'] ?? 0; |
| 409 | 505 | $active_installs = self::format_active_installs($active_installs_raw); |
| 410 | 506 | $active_installs_count = self::get_numeric_active_installs($active_installs_raw); |
| @@ -450,10 +546,16 @@ | ||
| 450 | 546 | * @return string Valid icon URL or empty string |
| 451 | 547 | */ |
| 452 | 548 | private static function get_valid_plugin_icon($icons) { |
| 453 | 549 | $valid_extensions = ['gif', 'png', 'jpg', 'jpeg', 'svg']; |
| 454 | - $icon_sizes = ['256', '128', 'default']; | |
| 455 | - | |
| 550 | + | |
| 551 | + // The wordpress.org plugin_information API returns its icon map keyed by | |
| 552 | + // '2x' / '1x' (and 'svg' or 'default' for the generated geopattern icon) -- | |
| 553 | + // never '256' / '128'. Looking only for the pixel keys meant no plugin icon | |
| 554 | + // ever resolved and every card fell back to the placeholder. Highest quality | |
| 555 | + // first, with the old pixel keys kept for any cached/legacy payload. | |
| 556 | + $icon_sizes = ['2x', '1x', 'svg', 'default', '256', '128']; | |
| 557 | + | |
| 456 | 558 | foreach ($icon_sizes as $size) { |
| 457 | 559 | if (!empty($icons[$size])) { |
| 458 | 560 | $icon_url = $icons[$size]; |
| 459 | 561 | |
| @@ -484,9 +586,9 @@ | ||
| 484 | 586 | return false; |
| 485 | 587 | } |
| 486 | 588 | |
| 487 | 589 | // Get file extension |
| 488 | - $path_info = pathinfo(parse_url($url, PHP_URL_PATH)); | |
| 590 | + $path_info = pathinfo(wp_parse_url($url, PHP_URL_PATH)); | |
| 489 | 591 | $extension = strtolower($path_info['extension'] ?? ''); |
| 490 | 592 | |
| 491 | 593 | return in_array($extension, $valid_extensions); |
| 492 | 594 | } |