This commit is contained in:
Javier Casares 2026-09-23 06:11:14 +00:00
commit bb3beaa353
12 changed files with 465 additions and 147 deletions

View file

@ -109,6 +109,21 @@ class Robotstxt_Manager_Updater {
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
@ -155,6 +170,56 @@ class Robotstxt_Manager_Updater {
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.
*
@ -258,7 +323,7 @@ class Robotstxt_Manager_Updater {
*
* @param list<array<string,mixed>> $catalog Catalog entries from Core.
*
* @return array<string, array<string,string>> Normalised entries keyed by slug.
* @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();
@ -272,16 +337,17 @@ class Robotstxt_Manager_Updater {
}
$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->localized_description( $row ),
'icon_url' => $this->str( $row, 'icon_url', '' ),
'banner_url' => $this->str( $row, 'banner_url', '' ),
'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', '' ),
);
}
@ -291,25 +357,29 @@ class Robotstxt_Manager_Updater {
/**
* Builds the update/no-update object for the WordPress transient.
*
* @param string $slug Plugin slug.
* @param string $plugin_file Plugin basename.
* @param array<string, 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 $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 ): object {
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 : $entry['new_version'],
'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'] ),
'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'],
@ -354,13 +424,15 @@ class Robotstxt_Manager_Updater {
* 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 {
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';
@ -368,6 +440,10 @@ class Robotstxt_Manager_Updater {
'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.