response for * installed catalog plugins with a newer remote version, and to ->no_update * for up-to-date ones (prevents false WordPress.org matches). * - `plugins_api`: serves the "View details" modal from catalog data. * * Plugins that bundle their own update SDK already inject their own entries; * Manager never overwrites an existing response entry. */ class Robotstxt_Manager_Updater { /** * Registers all hooks via the loader. * * @param Robotstxt_Manager_Loader $loader The plugin hook loader. * * @return void */ public function register( Robotstxt_Manager_Loader $loader ): void { $loader->add_filter( 'pre_set_site_transient_update_plugins', $this, 'inject_updates' ); $loader->add_filter( 'plugins_api', $this, 'plugins_api_filter', 10, 3 ); } /** * Injects catalog update data into the WordPress update transient. * * @param mixed $transient The update_plugins transient object. * * @return mixed Modified transient. */ public function inject_updates( mixed $transient ): mixed { if ( ! is_object( $transient ) || ! property_exists( $transient, 'checked' ) || ! is_array( $transient->checked ) || empty( $transient->checked ) ) { return $transient; } $client = Robotstxt_Manager_Core_Client::from_options(); if ( ! $client->is_configured() ) { return $transient; } $catalog = $client->get_catalog(); if ( empty( $catalog ) ) { return $transient; } $entries = $this->catalog_by_slug( $catalog ); foreach ( $transient->checked as $plugin_file => $raw_version ) { $version = is_string( $raw_version ) ? $raw_version : ''; $slug = $this->slug_from_file( (string) $plugin_file ); $entry = $entries[ $slug ] ?? null; if ( null === $entry || '' === $version ) { continue; } $new_version = $entry['new_version']; if ( '' === $new_version ) { continue; } // Never overwrite an entry injected by the plugin's own SDK. if ( property_exists( $transient, 'response' ) && is_array( $transient->response ) && isset( $transient->response[ $plugin_file ] ) ) { continue; } if ( version_compare( $version, $new_version, '<' ) ) { // Premium updates need the account key in the package URL; // without a usable key the native updater would only hit a // 403, so skip injecting the entry. if ( 'premium' === $entry['type'] && ! $this->has_api_key() ) { continue; } if ( property_exists( $transient, 'response' ) && is_array( $transient->response ) ) { $transient->response[ $plugin_file ] = $this->build_update_object( $slug, (string) $plugin_file, $entry ); } } elseif ( property_exists( $transient, 'no_update' ) && is_array( $transient->no_update ) ) { $transient->no_update[ $plugin_file ] = $this->build_update_object( $slug, (string) $plugin_file, $entry, $version ); } } return $transient; } /** * Serves the "View details" modal for catalog plugins from catalog data. * * @param mixed $result Current filter result. * @param string $action API action (only 'plugin_information' is handled). * @param mixed $args Request arguments; expected object with 'slug'. * * @return mixed Plugin info object, or the original $result. */ public function plugins_api_filter( mixed $result, string $action, mixed $args ): mixed { if ( 'plugin_information' !== $action || ! is_object( $args ) || empty( $args->slug ) ) { return $result; } $client = Robotstxt_Manager_Core_Client::from_options(); if ( ! $client->is_configured() ) { return $result; } $catalog = $client->get_catalog(); if ( empty( $catalog ) ) { return $result; } $slug = sanitize_key( (string) $args->slug ); $entries = $this->catalog_by_slug( $catalog ); $entry = $entries[ $slug ] ?? null; if ( null === $entry ) { return $result; } $sections = array(); if ( '' !== $entry['description'] ) { $sections['description'] = wp_kses_post( wpautop( $entry['description'] ) ); } if ( empty( $sections ) ) { $sections['description'] = ''; } $info = array( 'name' => $entry['name'], 'slug' => $slug, 'version' => $entry['new_version'], 'requires' => $entry['requires_wp'], 'requires_php' => $entry['requires_php'], 'tested' => $entry['tested_up_to'], 'last_updated' => '', 'homepage' => $entry['page_url'], 'sections' => $sections, 'download_link' => '', ); if ( '' !== $entry['icon_url'] ) { $info['icons'] = array( '1x' => $entry['icon_url'], '2x' => $entry['icon_url'], ); } if ( '' !== $entry['banner_url'] ) { $info['banners'] = array( 'low' => $entry['banner_url'], 'high' => $entry['banner_url'], ); } return (object) $info; } /** * Indexes catalog entries by slug with normalised fields. * * @param list> $catalog Catalog entries from Core. * * @return array> Normalised entries keyed by slug. */ private function catalog_by_slug( array $catalog ): array { $entries = array(); foreach ( $catalog as $row ) { $raw_slug = $row['slug'] ?? ''; $slug = is_string( $raw_slug ) ? sanitize_key( $raw_slug ) : ''; if ( '' === $slug ) { continue; } $entries[ $slug ] = array( 'name' => $this->str( $row, 'name', $slug ), 'type' => $this->str( $row, 'type', 'free' ), 'new_version' => $this->str( $row, 'current_version', '' ), 'requires_wp' => $this->str( $row, 'requires_wp', '' ), 'requires_php' => $this->str( $row, 'requires_php', '' ), 'tested_up_to' => $this->str( $row, 'tested_up_to', '' ), 'page_url' => $this->str( $row, 'page_url', '' ), 'description' => $this->str( $row, 'description', '' ), 'icon_url' => $this->str( $row, 'icon_url', '' ), 'banner_url' => $this->str( $row, 'banner_url', '' ), ); } return $entries; } /** * Builds the update/no-update object for the WordPress transient. * * @param string $slug Plugin slug. * @param string $plugin_file Plugin basename. * @param array $entry Normalised catalog entry. * @param string|null $current_version Installed version; when null an * update entry is built, otherwise a * no-update entry pinned to this version. * * @return object stdClass for the transient bucket. */ private function build_update_object( string $slug, string $plugin_file, array $entry, ?string $current_version = null ): object { $is_no_update = null !== $current_version; $data = array( 'id' => $plugin_file, 'slug' => $slug, 'plugin' => $plugin_file, 'new_version' => $is_no_update ? $current_version : $entry['new_version'], 'url' => $entry['page_url'], 'package' => $is_no_update ? '' : $this->package_url( $slug, $entry['type'] ), 'requires' => $entry['requires_wp'], 'requires_php' => $entry['requires_php'], 'tested' => $entry['tested_up_to'], 'icons' => array(), 'banners' => array(), ); if ( ! $is_no_update ) { if ( '' !== $entry['icon_url'] ) { $data['icons'] = array( '1x' => $entry['icon_url'], '2x' => $entry['icon_url'], ); } if ( '' !== $entry['banner_url'] ) { $data['banners'] = array( 'low' => $entry['banner_url'], 'high' => $entry['banner_url'], ); } } return (object) $data; } /** * Returns whether a decryptable account API key is configured. * * @return bool True when a usable key exists. */ private function has_api_key(): bool { $api_key_raw = get_option( 'robotstxt_manager_api_key', '' ); $api_key = is_string( $api_key_raw ) ? Robotstxt_Manager_Encryption::decrypt( $api_key_raw ) : ''; return '' !== $api_key; } /** * Builds the download package URL for the native upgrader. * * Free plugins stream through the proxy without auth (Core 1.4.0+). * Premium plugins append the account API key as an api_key query parameter * (accepted by Core 1.5.0+) — the native upgrader cannot send headers. * * @param string $slug Plugin slug. * @param string $type Plugin type ('free' or 'premium'). * * @return string Package URL. */ private function package_url( string $slug, string $type ): string { $client = Robotstxt_Manager_Core_Client::from_options(); $url = $client->get_store_url() . '/wp-json/robotstxt-core/v1/plugins/' . rawurlencode( $slug ) . '/download'; $args = array( 'domain' => $this->site_domain(), ); if ( 'premium' === $type ) { $api_key_raw = get_option( 'robotstxt_manager_api_key', '' ); $api_key = is_string( $api_key_raw ) ? Robotstxt_Manager_Encryption::decrypt( $api_key_raw ) : ''; if ( '' !== $api_key ) { $args['api_key'] = $api_key; } } return add_query_arg( $args, $url ); } /** * Returns the normalised domain of the current site. * * @return string Domain (e.g. 'example.com'). */ private function site_domain(): string { $host = strtolower( (string) wp_parse_url( home_url(), PHP_URL_HOST ) ); // Strip the literal "www." prefix (ltrim would eat any leading w/.). return (string) preg_replace( '/^www\./', '', $host ); } /** * Resolves a plugin basename to its directory slug. * * @param string $plugin_file Plugin basename (e.g. 'slug/file.php'). * * @return string Slug. */ private function slug_from_file( string $plugin_file ): string { $slug = dirname( $plugin_file ); if ( '.' === $slug ) { $slug = basename( $plugin_file, '.php' ); } return $slug; } /** * Extracts a string value from a mixed-value row. * * @param array $row Catalog row. * @param string $key Key to read. * @param string $fallback Default when absent or non-string. * * @return string */ private function str( array $row, string $key, string $fallback ): string { $value = $row[ $key ] ?? $fallback; return is_string( $value ) ? $value : $fallback; } }