diff --git a/admin/class-robotstxt-manager-installer.php b/admin/class-robotstxt-manager-installer.php index c80744c..fe3bfb2 100644 --- a/admin/class-robotstxt-manager-installer.php +++ b/admin/class-robotstxt-manager-installer.php @@ -709,14 +709,6 @@ class Robotstxt_Manager_Installer { exit; } - /** - * Validates a downloaded archive: must exist, be non-empty, and start - * with the ZIP magic bytes "PK". - * - * @param string $file Absolute path to the downloaded file. - * - * @return bool True when the file looks like a valid ZIP archive. - */ /** * Returns the normalised domain of the current site. * diff --git a/admin/views/page-catalog.php b/admin/views/page-catalog.php index 08a103b..38dd0f4 100644 --- a/admin/views/page-catalog.php +++ b/admin/views/page-catalog.php @@ -309,7 +309,7 @@ $compat_warnings = 0; ? $sub_text . ' @ ' . $bound_text . $count_txt : $bound_text . $count_txt; } elseif ( '' !== $count_txt ) { - $sub_text = '' !== $sub_text ? $sub_text . $count_txt : 'Subscribed' . $count_txt; + $sub_text = '' !== $sub_text ? $sub_text . $count_txt : __( 'Subscribed', 'robotstxt-manager' ) . $count_txt; } } diff --git a/changelog.txt b/changelog.txt index 2f3fd47..d546207 100644 --- a/changelog.txt +++ b/changelog.txt @@ -1,5 +1,37 @@ == Changelog == += 1.8.1 = + +_Release date: 2026-09-22_ + +**Fixed** + +* Pending updates now appear on sites where the WordPress.org update check never completes (api.wordpress.org unreachable, blocked, or firewalled hosts — common on a lot of servers). `Robotstxt_Manager_Updater` moved from the write-side hook (`pre_set_site_transient_update_plugins`, which only fires when a full `wp_update_plugins()` cycle finishes) to the read-side filter (`site_transient_update_plugins`, which fires every time anything reads the update data: Plugins screen, Updates page, WP-CLI, auto-updates). Local versions are resolved from `get_plugins()` instead of the transient's `checked` list, which never exists in those environments. Diagnosed and verified end-to-end against the live store: update detection, listing (`wp plugin list`), dry-run, and the actual update run all work now even with api.wordpress.org blocked. +* Stale Manager-owned update entries no longer mask newer versions: WordPress persists the filtered read during a (failed) update check, so a previously injected entry can sit in the stored transient forever. The updater now recognizes its own entries (package URL host matches the store) and replaces or removes them with fresh catalog data — killing stale-version masking, phantom "update available" badges after updating, and expired download tokens in one guard. Entries from other update servers (bundled SDKs pointing elsewhere, WordPress.org) are never touched. +* Premium download-token exchanges are now cached (5-minute site transient, failures negatively cached for 1 minute): without the cache, every read of the update data while a premium update is pending triggered a blocking HTTP call to the store — several per admin page load. +* Opt-in data deletion on uninstall now also removes the cached subscriptions site transient (`robotstxt_manager_subscriptions`) — previously only the catalog transient was deleted, so subscription data could outlive the plugin when "Delete all plugin data" was enabled. +* A hardcoded "Subscribed" fallback label in the multi-license catalog pill (unknown subscription status with more than one license) is now translatable like every other pill label. + +**Changed** + +* PHPStan raised from level 9 to `max` (level 10); the two `mixed`-strictness findings it surfaced were fixed with real narrowing (`AUTH_KEY`/`AUTH_SALT` string checks in the encryption key derivation, the `plugins_api` slug check). + +* `Robotstxt_Manager_Core_Client::get_subscriptions()` normalizes rows restored from the transient cache the same way `get_catalog()` does (string keys enforced) — resolves the single level-9 error surfaced by PHPStan 2.2.14; no behavior change. +* Development tooling updated via `composer update`: phpstan 2.2.8 → 2.2.14, phpstan-wordpress 2.0.3 → 2.0.4, wordpress-stubs 6.9.4 → 7.1.0, wp-hooks/wordpress-core 1.12.0 → 1.13.0, nikic/php-parser 5.8.0 → 5.9.0. +* Tests: dropped `ReflectionMethod::setAccessible()` calls (no-op since PHP 8.1, deprecated on PHP 8.5) so the suite runs notice-free on the maximum supported PHP version. Regression tests cover the "WordPress.org check never completed" transient shape, the `false` transient, stale own-entry replacement/removal, and download-token caching. + +**Compatibility** + +* WordPress: 4.4 - 7.1 (scan-verified 2026-09-22: wp-compat ladder clean from 4.4; verified across 4.4-7.1 with WordPress stubs 7.1.0 — no API newer than 4.4 in use, so 7.1 GA remains covered; 7.2 is not GA) +* PHP: 8.0 - 8.5 (scan-verified 2026-09-22: PHPCompatibility ladder 5.6-8.5 + manual audit — `str_contains()`, `str_starts_with()`, and `mixed` type hints keep the real floor at 8.0) + +**Tests** + +* PHPCS (WordPress-Core, WordPress-Docs, WordPress-Extra): pass +* PHPStan max (level 10) + wp-compat: pass +* PHPUnit: 152 tests, 401 assertions +* composer audit: no known CVEs + = 1.8.0 = _Release date: 2026-08-24_ diff --git a/includes/class-robotstxt-manager-core-client.php b/includes/class-robotstxt-manager-core-client.php index ca38fb4..f35fa7a 100644 --- a/includes/class-robotstxt-manager-core-client.php +++ b/includes/class-robotstxt-manager-core-client.php @@ -322,7 +322,13 @@ class Robotstxt_Manager_Core_Client { $typed = array(); foreach ( $cached as $slug => $row ) { if ( is_string( $slug ) && is_array( $row ) ) { - $typed[ $slug ] = $row; + $typed_row = array(); + foreach ( $row as $k => $v ) { + if ( is_string( $k ) ) { + $typed_row[ $k ] = $v; + } + } + $typed[ $slug ] = $typed_row; } } @@ -399,6 +405,12 @@ class Robotstxt_Manager_Core_Client { * Exchanges the account API key for a short-lived download token * (Core 1.9.0+ `POST /me/download-token`). * + * Tokens are cached in a short-TTL site transient (5 minutes, a fraction + * of the 15-minute token lifetime) because the updater rebuilds package + * URLs on every read of the update_plugins transient. Failed exchanges + * are negatively cached for one minute so a slow or down store is not + * queried on every read either. + * * @param string $slug Plugin slug the token may download. * * @return string Token string, or '' when unavailable (older Core, no @@ -410,6 +422,13 @@ class Robotstxt_Manager_Core_Client { return ''; } + $cache_key = 'robotstxt_manager_dl_token_' . sanitize_key( $slug ); + $cached = get_site_transient( $cache_key ); + + if ( is_string( $cached ) ) { + return $cached; // Token, or '' from a negatively cached failure. + } + // Send this site's domain so per-domain license binding is enforced // at token issuance (Core 1.11.0+); older Core ignores the field. $host = strtolower( (string) wp_parse_url( home_url(), PHP_URL_HOST ) ); @@ -434,14 +453,25 @@ class Robotstxt_Manager_Core_Client { ); if ( is_wp_error( $response ) || 200 !== (int) wp_remote_retrieve_response_code( $response ) ) { + set_site_transient( $cache_key, '', MINUTE_IN_SECONDS ); + return ''; } $data = json_decode( wp_remote_retrieve_body( $response ), true ); $token = is_array( $data ) ? ( $data['token'] ?? '' ) : ''; + $token = is_string( $token ) ? $token : ''; - return is_string( $token ) ? $token : ''; + if ( '' === $token ) { + set_site_transient( $cache_key, '', MINUTE_IN_SECONDS ); + + return ''; + } + + set_site_transient( $cache_key, $token, 5 * MINUTE_IN_SECONDS ); + + return $token; } /** diff --git a/includes/class-robotstxt-manager-encryption.php b/includes/class-robotstxt-manager-encryption.php index ae772f7..9ee88d1 100644 --- a/includes/class-robotstxt-manager-encryption.php +++ b/includes/class-robotstxt-manager-encryption.php @@ -195,8 +195,8 @@ class Robotstxt_Manager_Encryption { * @return string 32-byte raw key. */ private static function derive_key(): string { - $auth_key = defined( 'AUTH_KEY' ) ? AUTH_KEY : 'auth_key_not_defined'; - $auth_salt = defined( 'AUTH_SALT' ) ? AUTH_SALT : 'auth_salt_not_defined'; + $auth_key = defined( 'AUTH_KEY' ) && is_string( AUTH_KEY ) ? AUTH_KEY : 'auth_key_not_defined'; + $auth_salt = defined( 'AUTH_SALT' ) && is_string( AUTH_SALT ) ? AUTH_SALT : 'auth_salt_not_defined'; return substr( hash_hmac( 'sha256', self::CONTEXT, $auth_key . $auth_salt, true ), @@ -214,8 +214,8 @@ class Robotstxt_Manager_Encryption { * @return string 32-byte raw key. */ private static function derive_mac_key(): string { - $auth_key = defined( 'AUTH_KEY' ) ? AUTH_KEY : 'auth_key_not_defined'; - $auth_salt = defined( 'AUTH_SALT' ) ? AUTH_SALT : 'auth_salt_not_defined'; + $auth_key = defined( 'AUTH_KEY' ) && is_string( AUTH_KEY ) ? AUTH_KEY : 'auth_key_not_defined'; + $auth_salt = defined( 'AUTH_SALT' ) && is_string( AUTH_SALT ) ? AUTH_SALT : 'auth_salt_not_defined'; return substr( hash_hmac( 'sha256', self::MAC_CONTEXT, $auth_key . $auth_salt, true ), diff --git a/includes/class-robotstxt-manager-updater.php b/includes/class-robotstxt-manager-updater.php index 3c71f00..7392193 100644 --- a/includes/class-robotstxt-manager-updater.php +++ b/includes/class-robotstxt-manager-updater.php @@ -16,11 +16,19 @@ if ( ! defined( 'ABSPATH' ) ) { * show the standard "Update available" badge and update through the regular * wp-admin flow, with the store's download proxy as the package source. * - * - `pre_set_site_transient_update_plugins`: adds entries to ->response for - * installed catalog plugins with a newer remote version, and to ->no_update - * for up-to-date ones (prevents false WordPress.org matches). + * - `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. */ @@ -34,26 +42,23 @@ class Robotstxt_Manager_Updater { * @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( '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. + * 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. * - * @return mixed Modified transient. + * @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 { - 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() ) { @@ -66,48 +71,112 @@ class Robotstxt_Manager_Updater { return $transient; } + $local = $this->local_plugin_versions(); + + if ( array() === $local ) { + 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; + $updates = ( $transient instanceof stdClass ) ? $transient : new stdClass(); - if ( null === $entry || '' === $version ) { + 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; } - $new_version = $entry['new_version']; + // 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 ( '' === $new_version ) { + if ( null !== $occupied && ! $this->is_own_entry( $occupied, $client ) ) { 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, '<' ) ) { + 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 skip injecting the entry. + // 403, so drop any stale own entry and skip. if ( 'premium' === $entry['type'] && ! $this->has_api_key() ) { + unset( $updates->response[ $plugin_file ] ); continue; } - if ( property_exists( $transient, 'response' ) && is_array( $transient->response ) ) { - $transient->response[ $plugin_file ] = $this->build_update_object( $slug, (string) $plugin_file, $entry ); + $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 ); } - } 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; + 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 installed plugin versions, from the object-cached plugin list. + * + * @return array 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; } /** @@ -136,7 +205,7 @@ class Robotstxt_Manager_Updater { return $result; } - $slug = sanitize_key( (string) $args->slug ); + $slug = is_string( $args->slug ) ? sanitize_key( $args->slug ) : ''; $entries = $this->catalog_by_slug( $catalog ); $entry = $entries[ $slug ] ?? null; diff --git a/readme.txt b/readme.txt index 1a52e3a..edf63d6 100644 --- a/readme.txt +++ b/readme.txt @@ -3,9 +3,9 @@ Contributors: robotstxt, javiercasares Tags: dashboard, catalog, updates, subscriptions, management Requires at least: 4.4 Tested up to: 7.1 -Stable tag: 1.8.0 +Stable tag: 1.8.1 Requires PHP: 8.0 -Version: 1.8.0 +Version: 1.8.1 License: GPL-3.0-or-later License URI: https://www.gnu.org/licenses/gpl-3.0.txt @@ -94,6 +94,15 @@ Encrypted at rest using AES-256-CBC with a key derived from your site's WordPres Only the 3 last versions. The full changelog will be at changelog.txt += 1.8.1 = + +_Release date: 2026-09-22_ + +* Fixed: pending updates were invisible on sites where the WordPress.org update check never completes (api.wordpress.org blocked/unreachable — common on many hosts). Update data is now injected whenever WordPress reads it, so pending ROBOTSTXT updates show up on the Plugins screen, the Updates page, and WP-CLI regardless of WordPress.org connectivity. +* Fixed: opt-in uninstall now also deletes the cached subscriptions transient, so no subscription data outlives the plugin when data deletion is enabled. +* Fixed: a hardcoded "Subscribed" label in the multi-license catalog pill is now translatable. +* Maintenance: development tooling updated (PHPStan max, WordPress stubs 7.1.0); compatibility floors re-verified (WordPress 4.4, PHP 8.0). + = 1.8.0 = _Release date: 2026-08-24_ @@ -107,12 +116,6 @@ _Release date: 2026-08-18_ * New: per-domain license support (Core 1.11.0+) — downloads and download-token exchanges now send this site's domain, so premium licenses bind to this site and the store rejects them from other domains. Catalog subscription pills show the bound domain (e.g. "Subscribed @ example.com"). * Improvement: download failures now surface the store's own error message (e.g. the domain-mismatch explanation) instead of a bare HTTP status code. -= 1.6.2 = - -_Release date: 2026-08-18_ - -* Added: `ROBOTSTXT_MANAGER_NOTICED` presence constant (guarded, `true`) — ecosystem plugins detect Manager via a constant check instead of scanning the plugin list. - = Previous versions = If you want to see the full changelog, visit the [plugin page](https://www.robotstxt.software/plugins/robotstxt-manager/). diff --git a/robotstxt-manager.php b/robotstxt-manager.php index ab4af19..2cf7de2 100644 --- a/robotstxt-manager.php +++ b/robotstxt-manager.php @@ -3,7 +3,7 @@ * Plugin Name: Manager (by ROBOTSTXT) * Plugin URI: https://www.robotstxt.software/plugins/robotstxt-manager/ * Description: Client-side dashboard for the ROBOTSTXT plugin ecosystem. Lists the catalog from a remote Plugins Core install, resolves local install/update state, and installs, activates, and updates plugins directly from the store. - * Version: 1.8.0 + * Version: 1.8.1 * Requires at least: 4.4 * Requires PHP: 8.0 * Update URI: https://www.robotstxt.software/plugins/robotstxt-manager/ @@ -23,7 +23,7 @@ if ( ! defined( 'ABSPATH' ) ) { } /** Plugin version. */ -define( 'ROBOTSTXT_MANAGER_VERSION', '1.8.0' ); +define( 'ROBOTSTXT_MANAGER_VERSION', '1.8.1' ); /** Absolute path to the plugin directory, with trailing slash. */ define( 'ROBOTSTXT_MANAGER_DIR', plugin_dir_path( __FILE__ ) ); diff --git a/uninstall.php b/uninstall.php index 486b212..a7734a8 100644 --- a/uninstall.php +++ b/uninstall.php @@ -25,6 +25,7 @@ foreach ( $option_keys as $key ) { } delete_site_transient( 'robotstxt_manager_catalog' ); +delete_site_transient( 'robotstxt_manager_subscriptions' ); // Purge the WordPress update transient: premium entries carry the API key // in their package URL. WordPress rebuilds it on the next update check.