532 lines
19 KiB
PHP
532 lines
19 KiB
PHP
<?php
|
|
/**
|
|
* Native WordPress update integration for ROBOTSTXT catalog plugins.
|
|
*
|
|
* @package Robotstxt_Manager
|
|
*/
|
|
|
|
if ( ! defined( 'ABSPATH' ) ) {
|
|
exit;
|
|
}
|
|
|
|
/**
|
|
* Class Robotstxt_Manager_Updater
|
|
*
|
|
* Feeds catalog data into WordPress's native update system so catalog plugins
|
|
* show the standard "Update available" badge and update through the regular
|
|
* wp-admin flow, with the store's download proxy as the package source.
|
|
*
|
|
* - `site_transient_update_plugins`: injects entries into ->response for
|
|
* installed catalog plugins with a newer remote version, and into ->no_update
|
|
* for up-to-date ones (prevents false WordPress.org matches). The injection
|
|
* runs on the READ side of the transient, so it works even when a full
|
|
* wp_update_plugins() cycle never completes — for example on hosts where
|
|
* api.wordpress.org is unreachable, where WordPress bails before building
|
|
* the transient and write-side injection would never fire.
|
|
* - `plugins_api`: serves the "View details" modal from catalog data.
|
|
*
|
|
* Local install state is resolved directly from get_plugins() (object-cached
|
|
* per request) instead of the transient's ->checked list, which is only
|
|
* populated by a completed WordPress.org check.
|
|
*
|
|
* 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( '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.
|
|
*
|
|
* Read-side filter for get_site_transient( 'update_plugins' ): every
|
|
* consumer (Plugins screen, Updates page, WP-CLI, auto-updates) passes
|
|
* through here, so catalog updates surface regardless of whether a full
|
|
* WordPress.org update-check cycle has completed.
|
|
*
|
|
* @param mixed $transient The stored update_plugins transient (object or false).
|
|
*
|
|
* @return mixed Transient object with catalog entries injected.
|
|
*/
|
|
public function inject_updates( mixed $transient ): mixed {
|
|
$client = Robotstxt_Manager_Core_Client::from_options();
|
|
|
|
if ( ! $client->is_configured() ) {
|
|
return $transient;
|
|
}
|
|
|
|
$catalog = $client->get_catalog();
|
|
|
|
if ( empty( $catalog ) ) {
|
|
return $transient;
|
|
}
|
|
|
|
$local = $this->local_plugin_versions();
|
|
|
|
if ( array() === $local ) {
|
|
return $transient;
|
|
}
|
|
|
|
$entries = $this->catalog_by_slug( $catalog );
|
|
|
|
$updates = ( $transient instanceof stdClass ) ? $transient : new stdClass();
|
|
|
|
if ( ! property_exists( $updates, 'response' ) || ! is_array( $updates->response ) ) {
|
|
$updates->response = array();
|
|
}
|
|
|
|
if ( ! property_exists( $updates, 'no_update' ) || ! is_array( $updates->no_update ) ) {
|
|
$updates->no_update = array();
|
|
}
|
|
|
|
foreach ( $local as $plugin_file => $version ) {
|
|
$slug = $this->slug_from_file( $plugin_file );
|
|
$entry = $entries[ $slug ] ?? null;
|
|
|
|
if ( null === $entry || '' === $version || '' === $entry['new_version'] ) {
|
|
continue;
|
|
}
|
|
|
|
// Never overwrite an entry injected by the plugin's own SDK or
|
|
// by WordPress.org. Entries Manager itself produced earlier are
|
|
// the exception: WordPress persists the filtered read during
|
|
// wp_update_plugins(), so on hosts where api.wordpress.org is
|
|
// unreachable a stale Manager entry would otherwise occupy the
|
|
// slot forever and mask newer catalog versions.
|
|
$occupied = $updates->response[ $plugin_file ] ?? null;
|
|
|
|
if ( null !== $occupied && ! $this->is_own_entry( $occupied, $client ) ) {
|
|
continue;
|
|
}
|
|
|
|
// Security patch declared for exactly this installed version
|
|
// (Core 1.16.0+): offer the patch, never the feature mainline.
|
|
$patch = self::security_patch_for( $entry, $version );
|
|
|
|
if ( '' !== $patch ) {
|
|
if ( 'premium' === $entry['type'] && ! $this->has_api_key() ) {
|
|
unset( $updates->response[ $plugin_file ] );
|
|
continue;
|
|
}
|
|
|
|
$updates->response[ $plugin_file ] = $this->build_update_object( $slug, $plugin_file, $entry, null, $patch );
|
|
unset( $updates->no_update[ $plugin_file ] );
|
|
continue;
|
|
}
|
|
|
|
if ( version_compare( $version, $entry['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 drop any stale own entry and skip.
|
|
if ( 'premium' === $entry['type'] && ! $this->has_api_key() ) {
|
|
unset( $updates->response[ $plugin_file ] );
|
|
continue;
|
|
}
|
|
|
|
$updates->response[ $plugin_file ] = $this->build_update_object( $slug, $plugin_file, $entry );
|
|
unset( $updates->no_update[ $plugin_file ] );
|
|
} else {
|
|
// Up to date: a stale own response entry must go, or the
|
|
// Plugins screen would keep offering a phantom update.
|
|
unset( $updates->response[ $plugin_file ] );
|
|
|
|
if ( ! isset( $updates->no_update[ $plugin_file ] ) ) {
|
|
$updates->no_update[ $plugin_file ] = $this->build_update_object( $slug, $plugin_file, $entry, $version );
|
|
}
|
|
}
|
|
}
|
|
|
|
return $updates;
|
|
}
|
|
|
|
/**
|
|
* Returns whether a response entry was produced by Manager itself (or by
|
|
* a bundled SDK pulling from the same store), by comparing the package
|
|
* URL host against the configured store host.
|
|
*
|
|
* @param mixed $entry Existing response entry.
|
|
* @param Robotstxt_Manager_Core_Client $client Configured core client.
|
|
*
|
|
* @return bool True when the entry's package comes from this store.
|
|
*/
|
|
private function is_own_entry( mixed $entry, Robotstxt_Manager_Core_Client $client ): bool {
|
|
if ( ! is_object( $entry ) || ! isset( $entry->package ) || ! is_string( $entry->package ) ) {
|
|
return false;
|
|
}
|
|
|
|
$store_host = strtolower( (string) wp_parse_url( $client->get_store_url(), PHP_URL_HOST ) );
|
|
$entry_host = strtolower( (string) wp_parse_url( $entry->package, PHP_URL_HOST ) );
|
|
|
|
return '' !== $store_host && $store_host === $entry_host;
|
|
}
|
|
|
|
/**
|
|
* Returns the security-patch version declared for exactly the given
|
|
* installed version, or '' when none applies.
|
|
*
|
|
* Exact-match by design: a patch declared for 1.2.3 applies only to
|
|
* sites running 1.2.3 — other versions follow the normal mainline flow.
|
|
* A declaration that does not strictly increase the version (typo,
|
|
* downgrade, re-install loop) never counts as a patch.
|
|
*
|
|
* @param array<string, mixed> $entry Catalog row (raw or normalised).
|
|
* @param string $installed_version Version installed on this site.
|
|
*
|
|
* @return string Patch version, or ''.
|
|
*/
|
|
public static function security_patch_for( array $entry, string $installed_version ): string {
|
|
if ( '' === $installed_version ) {
|
|
return '';
|
|
}
|
|
|
|
$patches = self::normalize_patches( $entry );
|
|
$patch = $patches[ $installed_version ] ?? '';
|
|
|
|
if ( '' === $patch || ! version_compare( $installed_version, $patch, '<' ) ) {
|
|
return '';
|
|
}
|
|
|
|
return $patch;
|
|
}
|
|
|
|
/**
|
|
* Normalises a catalog row's security_patches field into a string map.
|
|
*
|
|
* @param array<string, mixed> $row Catalog row.
|
|
*
|
|
* @return array<string, string> Installed version => patch version.
|
|
*/
|
|
private static function normalize_patches( array $row ): array {
|
|
$raw = $row['security_patches'] ?? array();
|
|
$raw = is_array( $raw ) ? $raw : array();
|
|
$clean = array();
|
|
|
|
foreach ( $raw as $applies_to => $patch ) {
|
|
if ( is_string( $applies_to ) && is_string( $patch ) ) {
|
|
$clean[ $applies_to ] = $patch;
|
|
}
|
|
}
|
|
|
|
return $clean;
|
|
}
|
|
|
|
/**
|
|
* Returns the installed plugin versions, from the object-cached plugin list.
|
|
*
|
|
* @return array<string, string> Map of plugin file ("slug/file.php") to version.
|
|
*/
|
|
private function local_plugin_versions(): array {
|
|
if ( ! function_exists( 'get_plugins' ) ) {
|
|
require_once ABSPATH . 'wp-admin/includes/plugin.php';
|
|
}
|
|
|
|
$versions = array();
|
|
|
|
foreach ( get_plugins() as $file => $data ) {
|
|
if ( ! is_string( $file ) ) {
|
|
continue;
|
|
}
|
|
|
|
$version = isset( $data['Version'] ) && is_string( $data['Version'] ) ? $data['Version'] : '';
|
|
$versions[ $file ] = $version;
|
|
}
|
|
|
|
return $versions;
|
|
}
|
|
|
|
/**
|
|
* 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 = is_string( $args->slug ) ? sanitize_key( $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<array<string,mixed>> $catalog Catalog entries from Core.
|
|
*
|
|
* @return array<string, array{name:string, type:string, new_version:string, security_patches:array<string,string>, requires_wp:string, requires_php:string, tested_up_to:string, page_url:string, description:string, icon_url:string, banner_url:string}> 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', '' ),
|
|
'security_patches' => self::normalize_patches( $row ),
|
|
'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->localized_description( $row ),
|
|
'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{name:string, type:string, new_version:string, security_patches:array<string,string>, requires_wp:string, requires_php:string, tested_up_to:string, page_url:string, description:string, icon_url:string, banner_url:string} $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.
|
|
* @param string $security_version Patch version when the update is
|
|
* a security patch ('' otherwise).
|
|
*
|
|
* @return object stdClass for the transient bucket.
|
|
*/
|
|
private function build_update_object( string $slug, string $plugin_file, array $entry, ?string $current_version = null, string $security_version = '' ): object {
|
|
$is_no_update = null !== $current_version;
|
|
|
|
$data = array(
|
|
'id' => $plugin_file,
|
|
'slug' => $slug,
|
|
'plugin' => $plugin_file,
|
|
'new_version' => $is_no_update
|
|
? $current_version
|
|
: ( '' !== $security_version ? $security_version : $entry['new_version'] ),
|
|
'url' => $entry['page_url'],
|
|
'package' => $is_no_update ? '' : $this->package_url( $slug, $entry['type'], $security_version ),
|
|
'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_site_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.
|
|
* Security patches add a validated `version` parameter (Core 1.16.0+).
|
|
*
|
|
* @param string $slug Plugin slug.
|
|
* @param string $type Plugin type ('free' or 'premium').
|
|
* @param string $security_version Patch version when serving a security patch.
|
|
*
|
|
* @return string Package URL.
|
|
*/
|
|
private function package_url( string $slug, string $type, string $security_version = '' ): 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 ( '' !== $security_version ) {
|
|
$args['version'] = rawurlencode( $security_version );
|
|
}
|
|
|
|
if ( 'premium' === $type ) {
|
|
// Preferred: a short-lived download token (Core 1.9.0+) — keeps the
|
|
// long-lived API key out of the update transient and access logs.
|
|
$token = $client->exchange_download_token( $slug );
|
|
|
|
if ( '' !== $token ) {
|
|
$args['token'] = $token;
|
|
} else {
|
|
// Fallback (older Core): the API key itself.
|
|
$api_key_raw = get_site_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;
|
|
}
|
|
|
|
/**
|
|
* Picks the description for the current admin user's locale, falling
|
|
* back to the default (English) description.
|
|
*
|
|
* @param array<string, mixed> $row Catalog row.
|
|
*
|
|
* @return string The localized (or default) description.
|
|
*/
|
|
private function localized_description( array $row ): string {
|
|
$raw_translations = $row['description_translations'] ?? '';
|
|
$translations = is_array( $raw_translations ) ? $raw_translations : array();
|
|
$user_locale = function_exists( 'get_user_locale' ) ? get_user_locale() : get_locale();
|
|
$locale_text = $translations[ $user_locale ] ?? '';
|
|
|
|
if ( is_string( $locale_text ) && '' !== trim( $locale_text ) ) {
|
|
return trim( $locale_text );
|
|
}
|
|
|
|
return $this->str( $row, 'description', '' );
|
|
}
|
|
|
|
/**
|
|
* Extracts a string value from a mixed-value row.
|
|
*
|
|
* @param array<string, mixed> $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;
|
|
}
|
|
}
|