403 ) ); } if ( ! current_user_can( $cap ) ) { return new WP_Error( 'openstation_plugins_forbidden', __( 'You are not allowed to do that.', 'desktop-mode' ), array( 'status' => 403 ) ); } return true; } /** * Send a `WP_Error` as a JSON response, then exit. * * @param WP_Error $error * @return void */ function openstation_plugins_window_ajax_error( WP_Error $error ) { $status = 500; $data = $error->get_error_data(); if ( is_array( $data ) && isset( $data['status'] ) ) { $status = (int) $data['status']; } wp_send_json_error( array( 'code' => $error->get_error_code(), 'message' => $error->get_error_message(), ), $status ); } /** * `wp_ajax_openstation_plugins_browse` — proxy to * `plugins_api( 'query_plugins', … )` with a 10-minute transient * cache keyed by the args. * * Body params: * - browse string (featured|popular|recommended|favorites|new|beta), default "featured" * - search string, optional * - tag string, optional * - page int, default 1 * - per_page int, default 24, capped at 60 */ function openstation_plugins_window_ajax_browse() { $guard = openstation_plugins_window_ajax_guard( 'install_plugins' ); if ( is_wp_error( $guard ) ) { openstation_plugins_window_ajax_error( $guard ); return; // unreachable; clarity for static analyzers. } // `plugins_api()` lives in `wp-admin/includes/plugin-install.php`, // which `admin-ajax.php` does NOT auto-load. Same idiom Core's // own `wp_ajax_install_plugin` uses (see // `wp-admin/includes/ajax-actions.php` ~L4483). Plugin Check // accepts this — the rule against `require_once ABSPATH . // 'wp-admin/…'` only applies to non-admin contexts (REST // callbacks, plugin bootstrap), and `wp_ajax_*` hooks fire // inside admin-ajax which is itself an admin file. if ( ! function_exists( 'plugins_api' ) ) { require_once ABSPATH . 'wp-admin/includes/plugin-install.php'; } // phpcs:disable WordPress.Security.NonceVerification.Missing -- verified in openstation_plugins_window_ajax_guard() above; the sniff cannot follow the check across a function boundary. $browse_raw = isset( $_POST['browse'] ) ? sanitize_key( wp_unslash( (string) $_POST['browse'] ) ) : 'featured'; $allowed = array( 'featured', 'popular', 'recommended', 'favorites', 'new', 'beta', 'updated' ); if ( ! in_array( $browse_raw, $allowed, true ) ) { $browse_raw = 'featured'; } $search = isset( $_POST['search'] ) ? sanitize_text_field( wp_unslash( (string) $_POST['search'] ) ) : ''; $tag = isset( $_POST['tag'] ) ? sanitize_key( wp_unslash( (string) $_POST['tag'] ) ) : ''; $page = isset( $_POST['page'] ) ? max( 1, (int) $_POST['page'] ) : 1; $per_page = isset( $_POST['per_page'] ) ? max( 1, min( 60, (int) $_POST['per_page'] ) ) : 24; // phpcs:enable WordPress.Security.NonceVerification.Missing $api_args = array( 'page' => $page, 'per_page' => $per_page, // Lightweight field set — `plugin_information` covers the // detail-flyout case via a separate call. 'fields' => array( 'icons' => true, 'banners' => true, 'short_description' => true, 'description' => false, 'sections' => false, 'screenshots' => false, 'rating' => true, 'ratings' => false, 'num_ratings' => true, 'active_installs' => true, 'last_updated' => true, 'tested' => true, 'requires' => true, 'requires_php' => true, 'homepage' => true, 'compatibility' => false, 'group' => false, 'contributors' => false, 'donate_link' => false, ), ); if ( '' !== $search ) { $api_args['search'] = $search; } elseif ( '' !== $tag ) { $api_args['tag'] = $tag; } else { $api_args['browse'] = $browse_raw; } /** * Filter the args passed to `plugins_api( 'query_plugins', … )`. * * @param array $api_args Args passed to plugins_api. * @param array $raw_params Sanitized request params. */ $api_args = (array) apply_filters( 'openstation_plugins_window_browse_args', $api_args, array( 'browse' => $browse_raw, 'search' => $search, 'tag' => $tag, 'page' => $page, 'per_page' => $per_page, ) ); $cache_key = 'dm_pwbrowse_' . md5( wp_json_encode( $api_args ) ); $cached = get_transient( $cache_key ); if ( false !== $cached && is_array( $cached ) ) { wp_send_json_success( $cached ); return; } $result = plugins_api( 'query_plugins', $api_args ); if ( is_wp_error( $result ) ) { openstation_plugins_window_ajax_error( $result ); return; } // `plugins_api` returns an object with `plugins` + `info` props. $payload = array( 'plugins' => isset( $result->plugins ) ? array_values( (array) $result->plugins ) : array(), 'info' => isset( $result->info ) ? (array) $result->info : array(), ); /** * Filter the browse response before it's cached + sent. * * @param array $payload `{ plugins, info }`. * @param array $api_args Args used. */ $payload = (array) apply_filters( 'openstation_plugins_window_browse_response', $payload, $api_args ); set_transient( $cache_key, $payload, 10 * MINUTE_IN_SECONDS ); wp_send_json_success( $payload ); } add_action( 'wp_ajax_openstation_plugins_browse', 'openstation_plugins_window_ajax_browse' ); /** * `wp_ajax_openstation_plugins_info` — proxy to * `plugins_api( 'plugin_information', { slug, fields: { … } } )` * with a 1-hour transient cache per slug. * * Body params: * - slug string, required */ function openstation_plugins_window_ajax_info() { $guard = openstation_plugins_window_ajax_guard( 'install_plugins' ); if ( is_wp_error( $guard ) ) { openstation_plugins_window_ajax_error( $guard ); return; } // See note in the browse handler — `plugins_api()` is admin-only; // admin-ajax does not auto-load it. Mirrors Core's own // `wp_ajax_install_plugin` idiom. if ( ! function_exists( 'plugins_api' ) ) { require_once ABSPATH . 'wp-admin/includes/plugin-install.php'; } // phpcs:ignore WordPress.Security.NonceVerification.Missing -- verified in openstation_plugins_window_ajax_guard() above. $slug = isset( $_POST['slug'] ) ? sanitize_key( wp_unslash( (string) $_POST['slug'] ) ) : ''; if ( '' === $slug ) { openstation_plugins_window_ajax_error( new WP_Error( 'openstation_plugins_missing_slug', __( 'Missing plugin slug.', 'desktop-mode' ), array( 'status' => 400 ) ) ); return; } $cache_key = 'dm_pwinfo_' . md5( $slug ); $cached = get_transient( $cache_key ); if ( false !== $cached && is_array( $cached ) ) { wp_send_json_success( $cached ); return; } $api_args = array( 'slug' => $slug, 'fields' => array( 'sections' => true, 'screenshots' => true, 'ratings' => true, 'banners' => true, 'icons' => true, 'contributors' => true, 'last_updated' => true, 'requires' => true, 'requires_php' => true, 'tested' => true, 'homepage' => true, 'short_description' => true, 'donate_link' => true, 'reviews' => false, // We use our own scraper for the Reviews tab. ), ); $result = plugins_api( 'plugin_information', $api_args ); if ( is_wp_error( $result ) ) { openstation_plugins_window_ajax_error( $result ); return; } $payload = (array) $result; /** * Filter the plugin-information response before it's cached + sent. * * @param array $payload Result, cast to array. * @param string $slug Plugin slug. */ $payload = (array) apply_filters( 'openstation_plugins_window_info_response', $payload, $slug ); set_transient( $cache_key, $payload, HOUR_IN_SECONDS ); wp_send_json_success( $payload ); } add_action( 'wp_ajax_openstation_plugins_info', 'openstation_plugins_window_ajax_info' ); /** * `wp_ajax_openstation_plugins_reviews` — best-effort scrape of the * top reviews from a plugin's wp.org page. * * Body params: * - slug string, required * * Returns either `{ items: [...], parsed: true }` or * `{ items: [], parsed: false, reason: '' }`. Caller is * expected to fall back to the histogram-only view on `parsed: false`. * Cache success 1h, failure 15m so wp.org can recover quickly. */ function openstation_plugins_window_ajax_reviews() { $guard = openstation_plugins_window_ajax_guard( 'install_plugins' ); if ( is_wp_error( $guard ) ) { openstation_plugins_window_ajax_error( $guard ); return; } // phpcs:ignore WordPress.Security.NonceVerification.Missing -- verified in openstation_plugins_window_ajax_guard() above. $slug = isset( $_POST['slug'] ) ? sanitize_key( wp_unslash( (string) $_POST['slug'] ) ) : ''; if ( '' === $slug ) { openstation_plugins_window_ajax_error( new WP_Error( 'openstation_plugins_missing_slug', __( 'Missing plugin slug.', 'desktop-mode' ), array( 'status' => 400 ) ) ); return; } $cache_key = 'dm_pwreviews_' . md5( $slug ); $cached = get_transient( $cache_key ); if ( false !== $cached && is_array( $cached ) ) { wp_send_json_success( $cached ); return; } /** * Filter to swap out the default DOMDocument-based review parser. * * Return an array of items to short-circuit; return `null` to * fall through to the default parser. Items must each be an * associative array with `author`, `stars` (int 1–5), `excerpt`, * `date`, and (optional) `url` keys. * * @param array|null $items Override list, or null for default behaviour. * @param string $slug Plugin slug. */ $override = apply_filters( 'openstation_plugins_window_review_parser', null, $slug ); if ( is_array( $override ) ) { $payload = array( 'items' => array_values( $override ), 'parsed' => true, ); set_transient( $cache_key, $payload, HOUR_IN_SECONDS ); wp_send_json_success( $payload ); return; } $url = 'https://wordpress.org/plugins/' . $slug . '/#reviews'; $response = wp_remote_get( $url, array( 'timeout' => 5, 'sslverify' => true, 'headers' => array( 'Accept-Language' => get_locale(), ), ) ); if ( is_wp_error( $response ) ) { $payload = array( 'items' => array(), 'parsed' => false, 'reason' => 'fetch_failed', ); set_transient( $cache_key, $payload, 15 * MINUTE_IN_SECONDS ); wp_send_json_success( $payload ); return; } $status = (int) wp_remote_retrieve_response_code( $response ); if ( $status < 200 || $status >= 300 ) { $payload = array( 'items' => array(), 'parsed' => false, 'reason' => 'http_' . $status, ); set_transient( $cache_key, $payload, 15 * MINUTE_IN_SECONDS ); wp_send_json_success( $payload ); return; } $body = (string) wp_remote_retrieve_body( $response ); if ( '' === $body ) { $payload = array( 'items' => array(), 'parsed' => false, 'reason' => 'empty_body', ); set_transient( $cache_key, $payload, 15 * MINUTE_IN_SECONDS ); wp_send_json_success( $payload ); return; } $items = openstation_plugins_window_parse_reviews_html( $body ); if ( null === $items ) { $payload = array( 'items' => array(), 'parsed' => false, 'reason' => 'parse_failed', ); set_transient( $cache_key, $payload, 15 * MINUTE_IN_SECONDS ); wp_send_json_success( $payload ); return; } $payload = array( 'items' => $items, 'parsed' => true, ); set_transient( $cache_key, $payload, HOUR_IN_SECONDS ); wp_send_json_success( $payload ); } add_action( 'wp_ajax_openstation_plugins_reviews', 'openstation_plugins_window_ajax_reviews' ); /** * Default DOMDocument-based parser for the wp.org plugin reviews * page. Returns an array of `{ author, stars, excerpt, date, url }` * on success, or `null` when parsing fails. * * The wp.org review HTML may change without notice — wrap every * navigation in `try`/`catch` and bail to `null` on any failure so * the JS can fall back to the histogram-only view. * * @param string $html * @return array>|null */ function openstation_plugins_window_parse_reviews_html( $html ) { if ( ! class_exists( 'DOMDocument' ) ) { return null; } try { $prev = libxml_use_internal_errors( true ); $doc = new DOMDocument(); // Force UTF-8 — wp.org output is UTF-8 but loadHTML defaults // to ISO-8859-1. $doc->loadHTML( '' . $html, LIBXML_NOERROR | LIBXML_NOWARNING ); libxml_clear_errors(); libxml_use_internal_errors( $prev ); $xpath = new DOMXPath( $doc ); // wp.org review markup at the time of writing wraps each // review in `
` containing `

`-like // title, `
` author block, `

` body, // star rating spans, and a permalink. We grab the first 5. $reviews = $xpath->query( '//*[contains(concat(" ", normalize-space(@class), " "), " review ")]' ); if ( ! $reviews instanceof DOMNodeList || 0 === $reviews->length ) { return null; } $out = array(); $count = 0; foreach ( $reviews as $review ) { if ( $count >= 5 ) { break; } if ( ! $review instanceof DOMNode ) { continue; } $author = ''; $author_nodes = $xpath->query( './/*[contains(concat(" ", normalize-space(@class), " "), " reviewer-name ")]', $review ); if ( $author_nodes instanceof DOMNodeList && $author_nodes->length > 0 ) { $author = trim( (string) $author_nodes->item( 0 )->textContent ); } $excerpt = ''; $excerpt_nodes = $xpath->query( './/p', $review ); if ( $excerpt_nodes instanceof DOMNodeList && $excerpt_nodes->length > 0 ) { $excerpt = trim( (string) $excerpt_nodes->item( 0 )->textContent ); } if ( '' !== $excerpt && function_exists( 'mb_strimwidth' ) ) { $excerpt = mb_strimwidth( $excerpt, 0, 320, '…' ); } $date = ''; $date_nodes = $xpath->query( './/*[contains(concat(" ", normalize-space(@class), " "), " review-date ")]', $review ); if ( $date_nodes instanceof DOMNodeList && $date_nodes->length > 0 ) { $date = trim( (string) $date_nodes->item( 0 )->textContent ); } $stars = 0; $rating_nodes = $xpath->query( './/*[contains(concat(" ", normalize-space(@class), " "), " wporg-ratings ") or contains(concat(" ", normalize-space(@class), " "), " star-rating ")]', $review ); if ( $rating_nodes instanceof DOMNodeList && $rating_nodes->length > 0 ) { $rating_text = (string) $rating_nodes->item( 0 )->textContent; if ( preg_match( '/(\d+(?:\.\d+)?)\s*\/\s*5/', $rating_text, $m ) ) { $stars = (int) round( (float) $m[1] ); } elseif ( preg_match( '/(\d+)\s*star/i', $rating_text, $m ) ) { $stars = (int) $m[1]; } else { // Fall back to counting filled-star elements. $filled = $xpath->query( './/*[contains(concat(" ", normalize-space(@class), " "), " star ") and contains(concat(" ", normalize-space(@class), " "), " filled ")]', $rating_nodes->item( 0 ) ); if ( $filled instanceof DOMNodeList ) { $stars = (int) $filled->length; } } } $stars = max( 0, min( 5, $stars ) ); $url = ''; $link_nodes = $xpath->query( './/a[contains(@href, "/topic/")]', $review ); if ( $link_nodes instanceof DOMNodeList && $link_nodes->length > 0 ) { $href = $link_nodes->item( 0 ); if ( $href instanceof DOMElement ) { $url = (string) $href->getAttribute( 'href' ); } } if ( '' === $author && '' === $excerpt ) { continue; } $out[] = array( 'author' => $author, 'stars' => $stars, 'excerpt' => $excerpt, 'date' => $date, 'url' => $url, ); ++$count; } return $out; } catch ( Throwable $e ) { // phpcs:ignore PHPCompatibility.Classes.NewClasses.throwableFound // Throwable covers both Errors and Exceptions on PHP 7+. Any // failure (e.g. malformed HTML, libxml gone) bails to null // so the caller serves the histogram-only fallback. return null; } } /** * `wp_ajax_openstation_plugins_upload` — install a plugin from a * .zip uploaded as multipart/form-data under the `pluginzip` field. * * Mirrors the classic `update.php?action=upload-plugin` flow but * returns JSON. By the time this callback fires, the * `Plugin_Upgrader`, `WP_Ajax_Upgrader_Skin`, and `wp_handle_upload` * symbols are already loaded (admin-ajax loads them). */ function openstation_plugins_window_ajax_upload() { $guard = openstation_plugins_window_ajax_guard( 'upload_plugins' ); if ( is_wp_error( $guard ) ) { openstation_plugins_window_ajax_error( $guard ); return; } // phpcs:disable WordPress.Security.NonceVerification.Missing -- verified in openstation_plugins_window_ajax_guard() above. if ( empty( $_FILES['pluginzip'] ) || ! is_array( $_FILES['pluginzip'] ) ) { openstation_plugins_window_ajax_error( new WP_Error( 'openstation_plugins_missing_file', __( 'No file received. Pick a .zip and try again.', 'desktop-mode' ), array( 'status' => 400 ) ) ); return; } $file = $_FILES['pluginzip']; // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- read raw, sanitized below. // phpcs:enable WordPress.Security.NonceVerification.Missing if ( ! isset( $file['name'] ) || ! isset( $file['tmp_name'] ) || ! isset( $file['error'] ) ) { openstation_plugins_window_ajax_error( new WP_Error( 'openstation_plugins_invalid_file', __( 'Upload payload is malformed.', 'desktop-mode' ), array( 'status' => 400 ) ) ); return; } if ( UPLOAD_ERR_OK !== (int) $file['error'] ) { openstation_plugins_window_ajax_error( new WP_Error( 'openstation_plugins_upload_error', sprintf( /* translators: %d: PHP UPLOAD_ERR_* code. */ __( 'Upload failed (error %d). Try again.', 'desktop-mode' ), (int) $file['error'] ), array( 'status' => 400 ) ) ); return; } $name = sanitize_file_name( (string) $file['name'] ); if ( '' === $name || '.zip' !== strtolower( substr( $name, -4 ) ) ) { openstation_plugins_window_ajax_error( new WP_Error( 'openstation_plugins_not_zip', __( 'Plugin uploads must be a .zip file.', 'desktop-mode' ), array( 'status' => 400 ) ) ); return; } $tmp_name = (string) $file['tmp_name']; if ( ! is_uploaded_file( $tmp_name ) ) { // Defensive — `is_uploaded_file()` is the standard guard // against a path-traversal payload smuggled through tmp_name. openstation_plugins_window_ajax_error( new WP_Error( 'openstation_plugins_bad_tmp', __( 'Refused: temporary upload path is not trusted.', 'desktop-mode' ), array( 'status' => 400 ) ) ); return; } // `Plugin_Upgrader` + `WP_Ajax_Upgrader_Skin` are admin-only // classes; admin-ajax does NOT auto-load them. Same `require_once` // chain Core's own `wp_ajax_install_plugin` uses (see // `wp-admin/includes/ajax-actions.php`). Plugin Check accepts // this in admin-ajax callbacks — the rule applies to non-admin // contexts (REST callbacks, plugin bootstrap), not here. if ( ! function_exists( 'wp_handle_upload' ) ) { require_once ABSPATH . 'wp-admin/includes/file.php'; } if ( ! class_exists( 'WP_Upgrader' ) ) { require_once ABSPATH . 'wp-admin/includes/class-wp-upgrader.php'; } if ( ! class_exists( 'WP_Ajax_Upgrader_Skin' ) ) { require_once ABSPATH . 'wp-admin/includes/class-wp-ajax-upgrader-skin.php'; } if ( ! class_exists( 'Plugin_Upgrader' ) || ! class_exists( 'WP_Ajax_Upgrader_Skin' ) ) { openstation_plugins_window_ajax_error( new WP_Error( 'openstation_plugins_upgrader_missing', __( 'Plugin upgrader is unavailable in this context. Reload the page and try again.', 'desktop-mode' ), array( 'status' => 503 ) ) ); return; } // phpcs:ignore WordPress.Security.NonceVerification.Missing -- nonce verified above via openstation_plugins_window_ajax_guard(). $overwrite = ! empty( $_POST['overwrite'] ); $skin = new WP_Ajax_Upgrader_Skin(); $upgrader = new Plugin_Upgrader( $skin ); $result = $upgrader->install( $tmp_name, array( 'overwrite_package' => $overwrite ) ); // Treat "destination folder exists" specially when the caller did // NOT explicitly ask to overwrite — return a 409 with enough // context for the client to prompt the user and re-submit with // `overwrite=1`. WP_Ajax_Upgrader_Skin parks the error on // `$skin->result`; older paths and `Plugin_Upgrader::install()` // itself can also surface it via `$result` directly or by // returning `false`, so check all three. Mirrors Core's classic // `update.php?action=upload-plugin` confirm flow without the // full upload-and-rerun-from-disk dance. $folder_exists = false; if ( ! $overwrite ) { if ( is_wp_error( $skin->result ) && 'folder_exists' === $skin->result->get_error_code() ) { $folder_exists = true; } elseif ( is_wp_error( $result ) && 'folder_exists' === $result->get_error_code() ) { $folder_exists = true; } elseif ( false === $result || null === $result ) { // `Plugin_Upgrader::install()` returns `false` when the // destination already exists and overwrite isn't allowed. // We can't get the destination path from the result, but // the upgrader emitted the same `folder_exists` skin // error en route (caught above) — this branch is a // belt-and-braces fallback. $folder_exists = true; } } if ( $folder_exists ) { openstation_plugins_window_ajax_error( new WP_Error( 'folder_exists', __( 'A plugin with the same folder name is already installed. Replace it to continue.', 'desktop-mode' ), array( 'status' => 409 ) ) ); return; } if ( is_wp_error( $skin->result ) ) { openstation_plugins_window_ajax_error( $skin->result ); return; } if ( $skin->get_errors()->has_errors() ) { openstation_plugins_window_ajax_error( $skin->get_errors() ); return; } if ( is_wp_error( $result ) ) { openstation_plugins_window_ajax_error( $result ); return; } if ( false === $result || null === $result ) { openstation_plugins_window_ajax_error( new WP_Error( 'openstation_plugins_install_failed', __( 'Plugin install failed.', 'desktop-mode' ), array( 'status' => 500 ) ) ); return; } $plugin_file = $upgrader->plugin_info(); /** * Fires after the Plugins window has installed a plugin from an * uploaded .zip. Hook callers receive the resolved plugin file. * * @param string $plugin_file Plugin file (e.g. "akismet/akismet.php"). */ do_action( 'openstation_plugins_window_installed', $plugin_file ); // Read the just-installed plugin's headers so the client can show // a name / version on the post-install Activate panel without a // follow-up round-trip. `get_plugin_data()` reads the file // directly — cheap, and the file is already warm in disk cache // from the upgrader. $plugin_name = ''; $plugin_version = ''; if ( '' !== $plugin_file ) { if ( ! function_exists( 'get_plugin_data' ) ) { require_once ABSPATH . 'wp-admin/includes/plugin.php'; } $abs_plugin_file = WP_PLUGIN_DIR . '/' . $plugin_file; if ( file_exists( $abs_plugin_file ) ) { $data = get_plugin_data( $abs_plugin_file, false, false ); $plugin_name = isset( $data['Name'] ) ? (string) $data['Name'] : ''; $plugin_version = isset( $data['Version'] ) ? (string) $data['Version'] : ''; } } wp_send_json_success( array( 'plugin_file' => (string) $plugin_file, 'plugin_name' => $plugin_name, 'plugin_version' => $plugin_version, 'status' => 'inactive', 'messages' => $skin->get_upgrade_messages(), ) ); } add_action( 'wp_ajax_openstation_plugins_upload', 'openstation_plugins_window_ajax_upload' ); /** * Curated list of slugs that lead the Featured tab. * * Hand-picked because wp.org's `plugins_api` does not surface a real * "filter by `requires_plugins`" query — passing `requires_plugins` to * `query_plugins` is silently ignored and returns the unfiltered repo. * Until the directory grows a usable filter, we maintain the seed list * here and let downstream plugins amend it via the filter below. * * Slug-only — the AJAX handler hydrates each entry through * `plugins_api( 'plugin_information' )` so the card has up-to-date * icons, descriptions, and install counts without us caching them. * * @return string[] List of wp.org plugin slugs. */ function openstation_plugins_window_featured_slugs() { $slugs = array( // The author of this plugin forgot to declare OpenStation as a // dependency — surfacing it here makes sure openstation users // discover it anyway. Once the `requires_plugins` query lands on // wp.org we can remove the manual seed. 'odd-outlandish-desktop-decorator', ); /** * Filter the curated list of featured-plugin slugs. * * Plugin authors can prepend (or remove) entries to recommend their * own Desktop-Mode-aware add-ons. Order is preserved — the first * slug renders first in the gallery. * * @param string[] $slugs Plugin slugs. */ $slugs = (array) apply_filters( 'openstation_plugins_featured_slugs', $slugs ); $slugs = array_values( array_unique( array_filter( array_map( static function ( $s ) { return sanitize_key( (string) $s ); }, $slugs ) ) ) ); return $slugs; } /** * `wp_ajax_openstation_plugins_featured` — return the Featured tab's * curated + auto-discovered list of plugins that integrate with Desktop * Mode. * * Composition: * 1. Curated slugs from `openstation_plugins_window_featured_slugs()`, * hydrated via `plugins_api( 'plugin_information' )` so the card * payload is always fresh. * 2. Auto-discovered slugs from `plugins_api( 'query_plugins' )` whose * `requires_plugins` array contains `openstation`. wp.org has no * server-side filter for this today, so we run a broad query and * filter server-side. Deduped against the curated set. * * Body params: (none) * * Cached for 1h. Failures cached for 15m so a flaky wp.org doesn't * hammer the API on every tab open. */ function openstation_plugins_window_ajax_featured() { $guard = openstation_plugins_window_ajax_guard( 'install_plugins' ); if ( is_wp_error( $guard ) ) { openstation_plugins_window_ajax_error( $guard ); return; } if ( ! function_exists( 'plugins_api' ) ) { require_once ABSPATH . 'wp-admin/includes/plugin-install.php'; } $cache_key = 'dm_pwfeatured_v1'; $cached = get_transient( $cache_key ); if ( false !== $cached && is_array( $cached ) ) { wp_send_json_success( $cached ); return; } $plugins = array(); $seen_slugs = array(); $fields = array( 'icons' => true, 'banners' => true, 'short_description' => true, 'description' => false, 'sections' => false, 'screenshots' => false, 'rating' => true, 'ratings' => false, 'num_ratings' => true, 'active_installs' => true, 'last_updated' => true, 'tested' => true, 'requires' => true, 'requires_php' => true, 'requires_plugins' => true, 'homepage' => true, 'compatibility' => false, 'group' => false, 'contributors' => false, 'donate_link' => false, ); // ─── 1. Curated slugs ───────────────────────────────────────────── $curated = openstation_plugins_window_featured_slugs(); foreach ( $curated as $slug ) { if ( isset( $seen_slugs[ $slug ] ) ) { continue; } $info = plugins_api( 'plugin_information', array( 'slug' => $slug, 'fields' => $fields, ) ); if ( is_wp_error( $info ) || ! is_object( $info ) ) { // Skip — a curated slug that 404s shouldn't tank the whole // tab. continue; } $row = (array) $info; $row['featured'] = true; $plugins[] = $row; $seen_slugs[ $slug ] = true; } // ─── 2. Auto-discover via `requires_plugins` ────────────────────── // Best-effort scan: pull the top of the directory and keep rows that // declare openstation as a dependency. The wp.org `query_plugins` // API ignores `requires_plugins` as a filter, so we have to fetch + // sift locally. Scope is intentionally small (100 most-popular rows) // to keep the request bounded; as the ecosystem grows we'll widen // or replace with a real dependency query when wp.org ships one. $discovered = plugins_api( 'query_plugins', array( 'browse' => 'popular', 'page' => 1, 'per_page' => 100, 'fields' => $fields, ) ); if ( ! is_wp_error( $discovered ) && isset( $discovered->plugins ) && is_array( $discovered->plugins ) ) { foreach ( $discovered->plugins as $candidate ) { $candidate = (array) $candidate; $slug = isset( $candidate['slug'] ) ? sanitize_key( (string) $candidate['slug'] ) : ''; if ( '' === $slug || isset( $seen_slugs[ $slug ] ) ) { continue; } $requires = isset( $candidate['requires_plugins'] ) ? (array) $candidate['requires_plugins'] : array(); // The wp.org directory slug, not the brand — `requires_plugins` // rows resolve against our plugin folder name. if ( ! in_array( 'desktop-mode', $requires, true ) ) { continue; } $candidate['featured'] = false; $plugins[] = $candidate; $seen_slugs[ $slug ] = true; } } // `count( $plugins ) - count( $curated )` can underflow when a // curated slug fails hydration (slug typo, plugin temporarily // delisted from wp.org, plugins_api returning WP_Error). The JS // only uses `discovered` for informational headers, so a negative // number wouldn't crash anything, but it does read as a bug. // Clamp at zero so the count remains a defensible "non-curated rows // in the payload." $payload = array( 'plugins' => array_values( $plugins ), 'info' => array( 'curated' => count( $curated ), 'discovered' => max( 0, count( $plugins ) - count( $curated ) ), 'results' => count( $plugins ), ), ); /** * Filter the Featured tab payload before it's cached + sent. * * Use this to inject server-side curated rows (e.g. premium / * private plugins not on wp.org), or to enforce a hard cap on the * response. * * @param array $payload `{ plugins: [...], info: {...} }`. * @param array $curated Curated slug list. */ $payload = (array) apply_filters( 'openstation_plugins_featured_response', $payload, $curated ); set_transient( $cache_key, $payload, HOUR_IN_SECONDS ); wp_send_json_success( $payload ); } add_action( 'wp_ajax_openstation_plugins_featured', 'openstation_plugins_window_ajax_featured' );