Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
| bb3beaa353 | |||
| 27ce69c2d2 | |||
| 1460cb231f | |||
| c7a8d9efcc | |||
| 4f8ab239b0 | |||
| 5589c54b9b |
18 changed files with 917 additions and 248 deletions
|
|
@ -33,8 +33,11 @@ class Robotstxt_Manager_Admin {
|
|||
* @return void
|
||||
*/
|
||||
public function register( Robotstxt_Manager_Loader $loader ): void {
|
||||
$loader->add_action( 'admin_menu', $this, 'add_menu' );
|
||||
$menu_hook = is_multisite() ? 'network_admin_menu' : 'admin_menu';
|
||||
$loader->add_action( $menu_hook, $this, 'add_menu' );
|
||||
$loader->add_action( 'admin_post_robotstxt_manager_refresh_catalog', $this, 'handle_refresh' );
|
||||
$loader->add_action( 'admin_notices', $this, 'security_update_notices' );
|
||||
$loader->add_action( 'network_admin_notices', $this, 'security_update_notices' );
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -43,10 +46,11 @@ class Robotstxt_Manager_Admin {
|
|||
* @return void
|
||||
*/
|
||||
public function add_menu(): void {
|
||||
$cap = is_multisite() ? 'manage_network_options' : 'manage_options';
|
||||
add_menu_page(
|
||||
esc_html__( 'Manager (by ROBOTSTXT) — Plugins', 'robotstxt-manager' ),
|
||||
esc_html__( 'ROBOTSTXT', 'robotstxt-manager' ),
|
||||
'manage_options',
|
||||
$cap,
|
||||
self::PAGE_SLUG,
|
||||
array( $this, 'render_page' ),
|
||||
'dashicons-screenoptions',
|
||||
|
|
@ -60,7 +64,7 @@ class Robotstxt_Manager_Admin {
|
|||
* @return void
|
||||
*/
|
||||
public function render_page(): void {
|
||||
if ( ! current_user_can( 'manage_options' ) ) {
|
||||
if ( ! current_user_can( is_multisite() ? 'manage_network_options' : 'manage_options' ) ) {
|
||||
wp_die( esc_html__( 'You do not have sufficient permissions to access this page.', 'robotstxt-manager' ) );
|
||||
}
|
||||
|
||||
|
|
@ -75,9 +79,103 @@ class Robotstxt_Manager_Admin {
|
|||
$manager_has_api_key = $client->has_api_key();
|
||||
$manager_store_url = $client->get_store_url();
|
||||
|
||||
// Security patches declared for exactly the versions this site runs.
|
||||
$manager_security_updates = $this->get_security_updates( $catalog, $local );
|
||||
|
||||
require ROBOTSTXT_MANAGER_DIR . 'admin/views/page-catalog.php';
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the security patches that apply to the exact versions this
|
||||
* site runs (Core 1.16.0+ `security_patches` catalog data).
|
||||
*
|
||||
* @param list<array<string,mixed>> $catalog Catalog entries.
|
||||
* @param array<string, array{installed:bool, active:bool, version:string}> $local Local state by slug.
|
||||
*
|
||||
* @return list<array{slug:string, name:string, installed:string, patch:string}>
|
||||
*/
|
||||
public function get_security_updates( array $catalog, array $local ): array {
|
||||
$updates = array();
|
||||
|
||||
foreach ( $catalog as $entry ) {
|
||||
$raw_slug = $entry['slug'] ?? '';
|
||||
$slug = is_string( $raw_slug ) ? $raw_slug : '';
|
||||
|
||||
if ( '' === $slug ) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$state = $local[ $slug ] ?? null;
|
||||
|
||||
if ( ! is_array( $state ) || empty( $state['installed'] ) ) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$raw_version = $state['version'] ?? '';
|
||||
$version = is_string( $raw_version ) ? $raw_version : '';
|
||||
$patch = Robotstxt_Manager_Updater::security_patch_for( $entry, $version );
|
||||
|
||||
if ( '' === $patch ) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$raw_name = $entry['name'] ?? '';
|
||||
$clean_name = is_string( $raw_name ) && '' !== $raw_name ? $raw_name : $slug;
|
||||
|
||||
$updates[] = array(
|
||||
'slug' => $slug,
|
||||
'name' => $clean_name,
|
||||
'installed' => $version,
|
||||
'patch' => $patch,
|
||||
);
|
||||
}
|
||||
|
||||
return $updates;
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders the security-update notices on the Plugins screen (not on the
|
||||
* Manager catalog page, which shows its own block).
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function security_update_notices(): void {
|
||||
if ( ! current_user_can( is_multisite() ? 'manage_network_options' : 'manage_options' ) ) {
|
||||
return;
|
||||
}
|
||||
|
||||
$screen = function_exists( 'get_current_screen' ) ? get_current_screen() : null;
|
||||
$base = ( $screen instanceof WP_Screen ) ? (string) $screen->base : '';
|
||||
|
||||
if ( ! in_array( $base, array( 'plugins', 'plugins-network' ), true ) ) {
|
||||
return;
|
||||
}
|
||||
|
||||
$client = Robotstxt_Manager_Core_Client::from_options();
|
||||
|
||||
if ( ! $client->is_configured() ) {
|
||||
return;
|
||||
}
|
||||
|
||||
$catalog = $client->get_catalog();
|
||||
$security = $this->get_security_updates( $catalog, $this->resolve_local_state( $catalog ) );
|
||||
|
||||
foreach ( $security as $update ) {
|
||||
echo '<div class="notice notice-error"><p>';
|
||||
echo wp_kses_post(
|
||||
sprintf(
|
||||
/* translators: 1: plugin name, 2: installed version, 3: patch version, 4: update URL. */
|
||||
__( '<strong>Security update available:</strong> %1$s (v%2$s → v%3$s). <a href="%4$s">Update now</a> — this is a security patch for the version this site runs, not a feature update.', 'robotstxt-manager' ),
|
||||
esc_html( $update['name'] ),
|
||||
esc_html( $update['installed'] ),
|
||||
esc_html( $update['patch'] ),
|
||||
esc_url( self::action_url( 'update', $update['slug'] ) )
|
||||
)
|
||||
);
|
||||
echo '</p></div>';
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Builds admin notices for subscriptions that need attention.
|
||||
|
|
@ -149,21 +247,21 @@ class Robotstxt_Manager_Admin {
|
|||
* @return void
|
||||
*/
|
||||
public function handle_refresh(): void {
|
||||
if ( ! current_user_can( 'manage_options' ) ) {
|
||||
if ( ! current_user_can( is_multisite() ? 'manage_network_options' : 'manage_options' ) ) {
|
||||
wp_die( esc_html__( 'Insufficient permissions.', 'robotstxt-manager' ) );
|
||||
}
|
||||
|
||||
check_admin_referer( 'robotstxt_manager_refresh_catalog' );
|
||||
|
||||
$bucket = 'robotstxt_manager_refresh_' . get_current_user_id();
|
||||
$hits_raw = get_transient( $bucket );
|
||||
$hits_raw = get_site_transient( $bucket );
|
||||
$hits = is_numeric( $hits_raw ) ? (int) $hits_raw : 0;
|
||||
|
||||
if ( $hits >= 6 ) {
|
||||
$this->redirect_refresh_error();
|
||||
}
|
||||
|
||||
set_transient( $bucket, $hits + 1, MINUTE_IN_SECONDS );
|
||||
set_site_transient( $bucket, $hits + 1, MINUTE_IN_SECONDS );
|
||||
|
||||
$client = Robotstxt_Manager_Core_Client::from_options();
|
||||
$client->clear_catalog_cache();
|
||||
|
|
@ -175,7 +273,7 @@ class Robotstxt_Manager_Admin {
|
|||
'page' => self::PAGE_SLUG,
|
||||
'refreshed' => '1',
|
||||
),
|
||||
admin_url( 'admin.php' )
|
||||
( is_multisite() ? network_admin_url( 'admin.php' ) : admin_url( 'admin.php' ) )
|
||||
);
|
||||
|
||||
wp_safe_redirect( $redirect );
|
||||
|
|
@ -197,7 +295,7 @@ class Robotstxt_Manager_Admin {
|
|||
__( 'Too many refreshes. Please wait a minute before refreshing again.', 'robotstxt-manager' )
|
||||
),
|
||||
),
|
||||
admin_url( 'admin.php' )
|
||||
( is_multisite() ? network_admin_url( 'admin.php' ) : admin_url( 'admin.php' ) )
|
||||
)
|
||||
);
|
||||
exit;
|
||||
|
|
|
|||
|
|
@ -379,7 +379,9 @@ class Robotstxt_Manager_Installer {
|
|||
}
|
||||
|
||||
/**
|
||||
* Updates an installed plugin to the latest catalog version.
|
||||
* Updates an installed plugin to the latest catalog version — or, when a
|
||||
* security patch is declared for the exact installed version (Core
|
||||
* 1.16.0+), to that patch instead of the feature mainline.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
|
|
@ -394,7 +396,17 @@ class Robotstxt_Manager_Installer {
|
|||
$this->redirect_error( __( 'Plugin is not installed.', 'robotstxt-manager' ) );
|
||||
}
|
||||
|
||||
$result = $this->download_and_install( $slug, true );
|
||||
$patch = '';
|
||||
|
||||
$entry = $this->find_catalog_entry( $slug );
|
||||
|
||||
if ( is_array( $entry ) ) {
|
||||
$all = get_plugins();
|
||||
$version = isset( $all[ $file ]['Version'] ) && is_string( $all[ $file ]['Version'] ) ? $all[ $file ]['Version'] : '';
|
||||
$patch = Robotstxt_Manager_Updater::security_patch_for( $entry, $version );
|
||||
}
|
||||
|
||||
$result = $this->download_and_install( $slug, true, $patch );
|
||||
|
||||
if ( is_wp_error( $result ) ) {
|
||||
$this->redirect_error( $result->get_error_message() );
|
||||
|
|
@ -417,7 +429,7 @@ class Robotstxt_Manager_Installer {
|
|||
* @return string The sanitized plugin slug.
|
||||
*/
|
||||
private function authorize( string $action ): string {
|
||||
if ( ! current_user_can( 'manage_options' ) ) {
|
||||
if ( ! current_user_can( is_multisite() ? 'manage_network_options' : 'manage_options' ) ) {
|
||||
wp_die( esc_html__( 'Insufficient permissions.', 'robotstxt-manager' ) );
|
||||
}
|
||||
|
||||
|
|
@ -502,10 +514,12 @@ class Robotstxt_Manager_Installer {
|
|||
*
|
||||
* @param string $slug Plugin slug.
|
||||
* @param bool $overwrite Whether to overwrite an existing install (update).
|
||||
* @param string $security_version Patch version to download instead of the
|
||||
* stable mainline (Core 1.16.0+), '' for stable.
|
||||
*
|
||||
* @return true|WP_Error True on success.
|
||||
*/
|
||||
private function download_and_install( string $slug, bool $overwrite = false ) {
|
||||
private function download_and_install( string $slug, bool $overwrite = false, string $security_version = '' ) {
|
||||
$client = Robotstxt_Manager_Core_Client::from_options();
|
||||
|
||||
if ( ! $client->is_configured() ) {
|
||||
|
|
@ -527,12 +541,28 @@ class Robotstxt_Manager_Installer {
|
|||
|
||||
// Free plugins that publish a public download URL in the catalog are
|
||||
// fetched directly (no auth). Everything else goes through Core's
|
||||
// authenticated download endpoint (account API key as Bearer).
|
||||
$use_download_endpoint = ! ( $is_free && '' !== $dl_url );
|
||||
// authenticated download endpoint (account API key as Bearer), with
|
||||
// this site's domain for per-domain license binding (Core 1.11.0+).
|
||||
// Security patches always stream through the endpoint with a version
|
||||
// parameter — the public URL only carries the mainline stable ZIP.
|
||||
$use_download_endpoint = '' !== $security_version || ! ( $is_free && '' !== $dl_url );
|
||||
|
||||
$zip_url = $use_download_endpoint
|
||||
? $client->get_store_url() . '/wp-json/robotstxt-core/v1/plugins/' . rawurlencode( $slug ) . '/download'
|
||||
: $dl_url;
|
||||
if ( $use_download_endpoint ) {
|
||||
$dl_args = array(
|
||||
'domain' => rawurlencode( $this->site_domain() ),
|
||||
);
|
||||
|
||||
if ( '' !== $security_version ) {
|
||||
$dl_args['version'] = rawurlencode( $security_version );
|
||||
}
|
||||
|
||||
$zip_url = add_query_arg(
|
||||
$dl_args,
|
||||
$client->get_store_url() . '/wp-json/robotstxt-core/v1/plugins/' . rawurlencode( $slug ) . '/download'
|
||||
);
|
||||
} else {
|
||||
$zip_url = $dl_url;
|
||||
}
|
||||
|
||||
$tmp_file = wp_tempnam( $slug . '.zip' );
|
||||
|
||||
|
|
@ -543,7 +573,7 @@ class Robotstxt_Manager_Installer {
|
|||
$headers = array();
|
||||
|
||||
if ( $use_download_endpoint ) {
|
||||
$api_key_raw = get_option( 'robotstxt_manager_api_key', '' );
|
||||
$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 ) {
|
||||
|
|
@ -581,13 +611,25 @@ class Robotstxt_Manager_Installer {
|
|||
if ( 200 !== $code ) {
|
||||
wp_delete_file( $tmp_file );
|
||||
|
||||
// Surface the store's own error message (e.g. the per-domain
|
||||
// license "change the domain in your account" explanation).
|
||||
$body = json_decode( wp_remote_retrieve_body( $response ), true );
|
||||
$detail = is_array( $body ) && isset( $body['message'] ) && is_string( $body['message'] ) ? $body['message'] : '';
|
||||
|
||||
return new WP_Error(
|
||||
'robotstxt_manager_http',
|
||||
sprintf(
|
||||
/* translators: %d: HTTP status code. */
|
||||
__( 'Download failed (HTTP %d).', 'robotstxt-manager' ),
|
||||
$code
|
||||
)
|
||||
'' !== $detail
|
||||
? sprintf(
|
||||
/* translators: 1: HTTP status code, 2: store error message. */
|
||||
__( 'Download failed (HTTP %1$d): %2$s', 'robotstxt-manager' ),
|
||||
$code,
|
||||
$detail
|
||||
)
|
||||
: sprintf(
|
||||
/* translators: %d: HTTP status code. */
|
||||
__( 'Download failed (HTTP %d).', 'robotstxt-manager' ),
|
||||
$code
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -686,19 +728,31 @@ class Robotstxt_Manager_Installer {
|
|||
'robotstxt_manager_result' => $result,
|
||||
'robotstxt_manager_message' => rawurlencode( $message ),
|
||||
),
|
||||
admin_url( 'admin.php' )
|
||||
( is_multisite() ? network_admin_url( 'admin.php' ) : admin_url( 'admin.php' ) )
|
||||
)
|
||||
);
|
||||
exit;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 );
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* @return bool True when the file looks like a ZIP archive.
|
||||
*/
|
||||
private function is_valid_zip( string $file ): bool {
|
||||
if ( ! file_exists( $file ) ) {
|
||||
|
|
|
|||
|
|
@ -41,8 +41,10 @@ class Robotstxt_Manager_Settings {
|
|||
* @return void
|
||||
*/
|
||||
public function register( Robotstxt_Manager_Loader $loader ): void {
|
||||
$loader->add_action( 'admin_menu', $this, 'add_settings_page' );
|
||||
$menu_hook = is_multisite() ? 'network_admin_menu' : 'admin_menu';
|
||||
$loader->add_action( $menu_hook, $this, 'add_settings_page' );
|
||||
$loader->add_action( 'admin_init', $this, 'register_settings' );
|
||||
$loader->add_action( 'admin_init', $this, 'handle_form_submission' );
|
||||
$loader->add_action( 'admin_enqueue_scripts', $this, 'enqueue_scripts' );
|
||||
$loader->add_action( 'wp_ajax_robotstxt_manager_test_connection', $this, 'handle_test_connection' );
|
||||
$loader->add_action( 'wp_ajax_robotstxt_manager_delete_key', $this, 'handle_delete_key' );
|
||||
|
|
@ -54,11 +56,12 @@ class Robotstxt_Manager_Settings {
|
|||
* @return void
|
||||
*/
|
||||
public function add_settings_page(): void {
|
||||
$cap = is_multisite() ? 'manage_network_options' : 'manage_options';
|
||||
add_submenu_page(
|
||||
Robotstxt_Manager_Admin::PAGE_SLUG,
|
||||
esc_html__( 'Manager (by ROBOTSTXT) — Settings', 'robotstxt-manager' ),
|
||||
esc_html__( 'Settings', 'robotstxt-manager' ),
|
||||
'manage_options',
|
||||
$cap,
|
||||
self::PAGE_SLUG,
|
||||
array( $this, 'render_page' )
|
||||
);
|
||||
|
|
@ -203,20 +206,83 @@ class Robotstxt_Manager_Settings {
|
|||
* @return void
|
||||
*/
|
||||
public function render_page(): void {
|
||||
if ( ! current_user_can( 'manage_options' ) ) {
|
||||
if ( ! current_user_can( is_multisite() ? 'manage_network_options' : 'manage_options' ) ) {
|
||||
wp_die( esc_html__( 'You do not have sufficient permissions to access this page.', 'robotstxt-manager' ) );
|
||||
}
|
||||
|
||||
require_once ROBOTSTXT_MANAGER_DIR . 'admin/views/page-settings.php';
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles manual form submission for network settings.
|
||||
*
|
||||
* The WordPress Settings API (options.php) does not handle network
|
||||
* options, so the settings page must process its own form. Hooked to
|
||||
* admin_init so the redirect runs before any output is sent.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function handle_form_submission(): void {
|
||||
if ( ! isset( $_POST['robotstxt_manager_settings_group_nonce'] ) ) {
|
||||
return;
|
||||
}
|
||||
|
||||
check_admin_referer( 'robotstxt_manager_settings_group', 'robotstxt_manager_settings_group_nonce' );
|
||||
|
||||
if ( ! current_user_can( is_multisite() ? 'manage_network_options' : 'manage_options' ) ) {
|
||||
wp_die( esc_html__( 'You do not have sufficient permissions to manage settings.', 'robotstxt-manager' ) );
|
||||
}
|
||||
|
||||
// Store URL.
|
||||
$store_url = '';
|
||||
if ( isset( $_POST['robotstxt_manager_store_url'] ) ) {
|
||||
$raw = wp_unslash( $_POST['robotstxt_manager_store_url'] ); // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- sanitized below.
|
||||
$store_url = is_string( $raw ) ? esc_url_raw( $raw ) : '';
|
||||
}
|
||||
update_site_option( 'robotstxt_manager_store_url', $store_url );
|
||||
|
||||
// API key.
|
||||
$api_key = $this->sanitize_api_key( '' );
|
||||
if ( isset( $_POST['robotstxt_manager_api_key'] ) ) {
|
||||
$raw = wp_unslash( $_POST['robotstxt_manager_api_key'] ); // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- sanitized by sanitize_api_key().
|
||||
if ( is_string( $raw ) ) {
|
||||
$api_key = $this->sanitize_api_key( $raw );
|
||||
}
|
||||
}
|
||||
update_site_option( 'robotstxt_manager_api_key', $api_key );
|
||||
|
||||
// Cache TTL.
|
||||
$cache_ttl = 60;
|
||||
if ( isset( $_POST['robotstxt_manager_cache_ttl_minutes'] ) ) {
|
||||
$raw = wp_unslash( $_POST['robotstxt_manager_cache_ttl_minutes'] ); // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- sanitized by sanitize_cache_ttl().
|
||||
$cache_ttl = $this->sanitize_cache_ttl( $raw );
|
||||
}
|
||||
update_site_option( 'robotstxt_manager_cache_ttl_minutes', $cache_ttl );
|
||||
|
||||
// Delete on uninstall.
|
||||
$delete_on_uninstall = isset( $_POST['robotstxt_manager_delete_data_on_uninstall'] ) ? true : false;
|
||||
update_site_option( 'robotstxt_manager_delete_data_on_uninstall', $delete_on_uninstall );
|
||||
|
||||
// Redirect with success flag.
|
||||
$goback = add_query_arg(
|
||||
array(
|
||||
'page' => self::PAGE_SLUG,
|
||||
'settings-updated' => 'true',
|
||||
),
|
||||
( is_multisite() ? network_admin_url( 'admin.php' ) : admin_url( 'admin.php' ) )
|
||||
);
|
||||
|
||||
wp_safe_redirect( $goback );
|
||||
exit;
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders the Store URL field.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function render_field_store_url(): void {
|
||||
$raw = get_option( 'robotstxt_manager_store_url', 'https://www.robotstxt.software' );
|
||||
$raw = get_site_option( 'robotstxt_manager_store_url', 'https://www.robotstxt.software' );
|
||||
$value = is_string( $raw ) ? $raw : 'https://www.robotstxt.software';
|
||||
|
||||
printf(
|
||||
|
|
@ -234,7 +300,7 @@ class Robotstxt_Manager_Settings {
|
|||
* @return void
|
||||
*/
|
||||
public function render_field_api_key(): void {
|
||||
$stored = get_option( 'robotstxt_manager_api_key', '' );
|
||||
$stored = get_site_option( 'robotstxt_manager_api_key', '' );
|
||||
$has_key = is_string( $stored ) && '' !== $stored;
|
||||
$last4 = '';
|
||||
|
||||
|
|
@ -297,7 +363,7 @@ class Robotstxt_Manager_Settings {
|
|||
* @return void
|
||||
*/
|
||||
public function render_field_cache_ttl(): void {
|
||||
$raw = get_option( 'robotstxt_manager_cache_ttl_minutes', 60 );
|
||||
$raw = get_site_option( 'robotstxt_manager_cache_ttl_minutes', 60 );
|
||||
$value = is_numeric( $raw ) ? (int) $raw : 60;
|
||||
|
||||
printf(
|
||||
|
|
@ -313,7 +379,7 @@ class Robotstxt_Manager_Settings {
|
|||
* @return void
|
||||
*/
|
||||
public function render_field_delete_on_uninstall(): void {
|
||||
$value = (bool) get_option( 'robotstxt_manager_delete_data_on_uninstall', false );
|
||||
$value = (bool) get_site_option( 'robotstxt_manager_delete_data_on_uninstall', false );
|
||||
echo '<label>';
|
||||
printf(
|
||||
'<input type="checkbox" id="robotstxt_manager_delete_data_on_uninstall" name="robotstxt_manager_delete_data_on_uninstall" value="1"%s />',
|
||||
|
|
@ -338,7 +404,7 @@ class Robotstxt_Manager_Settings {
|
|||
$plain = sanitize_text_field( is_string( $value ) ? $value : '' );
|
||||
|
||||
if ( '' === $plain ) {
|
||||
$raw = get_option( 'robotstxt_manager_api_key', '' );
|
||||
$raw = get_site_option( 'robotstxt_manager_api_key', '' );
|
||||
return is_string( $raw ) ? $raw : '';
|
||||
}
|
||||
|
||||
|
|
@ -360,12 +426,12 @@ class Robotstxt_Manager_Settings {
|
|||
esc_html__( 'The API key format is invalid. Copy the full key from your ROBOTSTXT account page.', 'robotstxt-manager' )
|
||||
);
|
||||
|
||||
$raw = get_option( 'robotstxt_manager_api_key', '' );
|
||||
$raw = get_site_option( 'robotstxt_manager_api_key', '' );
|
||||
return is_string( $raw ) ? $raw : '';
|
||||
}
|
||||
|
||||
delete_transient( 'robotstxt_manager_catalog' );
|
||||
delete_transient( 'robotstxt_manager_subscriptions' );
|
||||
delete_site_transient( 'robotstxt_manager_catalog' );
|
||||
delete_site_transient( 'robotstxt_manager_subscriptions' );
|
||||
|
||||
return Robotstxt_Manager_Encryption::encrypt( $plain );
|
||||
}
|
||||
|
|
@ -392,7 +458,7 @@ class Robotstxt_Manager_Settings {
|
|||
public function handle_test_connection(): void {
|
||||
check_ajax_referer( 'robotstxt_manager_test_connection', 'nonce' );
|
||||
|
||||
if ( ! current_user_can( 'manage_options' ) ) {
|
||||
if ( ! current_user_can( is_multisite() ? 'manage_network_options' : 'manage_options' ) ) {
|
||||
wp_send_json_error( array( 'message' => __( 'Insufficient permissions.', 'robotstxt-manager' ) ) );
|
||||
}
|
||||
|
||||
|
|
@ -405,7 +471,7 @@ class Robotstxt_Manager_Settings {
|
|||
}
|
||||
}
|
||||
|
||||
$store_url = get_option( 'robotstxt_manager_store_url', 'https://www.robotstxt.software' );
|
||||
$store_url = get_site_option( 'robotstxt_manager_store_url', 'https://www.robotstxt.software' );
|
||||
$store_url = is_string( $store_url ) ? $store_url : 'https://www.robotstxt.software';
|
||||
|
||||
// Use input key if provided, otherwise fall back to saved (decrypted) key.
|
||||
|
|
@ -436,13 +502,13 @@ class Robotstxt_Manager_Settings {
|
|||
public function handle_delete_key(): void {
|
||||
check_ajax_referer( 'robotstxt_manager_delete_key', 'nonce' );
|
||||
|
||||
if ( ! current_user_can( 'manage_options' ) ) {
|
||||
if ( ! current_user_can( is_multisite() ? 'manage_network_options' : 'manage_options' ) ) {
|
||||
wp_send_json_error( array( 'message' => __( 'Insufficient permissions.', 'robotstxt-manager' ) ) );
|
||||
}
|
||||
|
||||
delete_option( 'robotstxt_manager_api_key' );
|
||||
delete_transient( 'robotstxt_manager_catalog' );
|
||||
delete_transient( 'robotstxt_manager_subscriptions' );
|
||||
delete_site_option( 'robotstxt_manager_api_key' );
|
||||
delete_site_transient( 'robotstxt_manager_catalog' );
|
||||
delete_site_transient( 'robotstxt_manager_subscriptions' );
|
||||
|
||||
wp_send_json_success(
|
||||
array(
|
||||
|
|
|
|||
|
|
@ -25,6 +25,7 @@ if ( ! defined( 'ABSPATH' ) ) {
|
|||
* @var array<string, array{installed:bool, active:bool, version:string}> $local
|
||||
* @var array<string, array<string,mixed>> $manager_subscriptions
|
||||
* @var list<array{type:string, message:string}> $manager_notices
|
||||
* @var list<array{slug:string, name:string, installed:string, patch:string}> $manager_security_updates
|
||||
* @var bool $manager_has_api_key
|
||||
* @var string $manager_store_url
|
||||
*/
|
||||
|
|
@ -79,6 +80,25 @@ $compat_warnings = 0;
|
|||
</div>
|
||||
<?php endforeach; ?>
|
||||
|
||||
<?php foreach ( ( $manager_security_updates ?? array() ) as $manager_security ) : ?>
|
||||
<div class="notice notice-error">
|
||||
<p>
|
||||
<?php
|
||||
echo wp_kses_post(
|
||||
sprintf(
|
||||
/* translators: 1: plugin name, 2: installed version, 3: patch version, 4: update URL. */
|
||||
__( '<strong>Security update available:</strong> %1$s (v%2$s → v%3$s). <a href="%4$s">Update now</a> — this is a security patch for the version this site runs, not a feature update.', 'robotstxt-manager' ),
|
||||
esc_html( $manager_security['name'] ),
|
||||
esc_html( $manager_security['installed'] ),
|
||||
esc_html( $manager_security['patch'] ),
|
||||
esc_url( Robotstxt_Manager_Admin::action_url( 'update', $manager_security['slug'] ) )
|
||||
)
|
||||
);
|
||||
?>
|
||||
</p>
|
||||
</div>
|
||||
<?php endforeach; ?>
|
||||
|
||||
<?php if ( empty( $manager_has_api_key ) && '' !== ( $manager_store_url ?? '' ) ) : ?>
|
||||
<div class="notice notice-info">
|
||||
<p>
|
||||
|
|
@ -107,7 +127,7 @@ $compat_warnings = 0;
|
|||
sprintf(
|
||||
/* translators: %s: settings URL. */
|
||||
__( 'ROBOTSTXT Manager is not configured yet. <a href="%s">Set the Store URL and API key</a> to see your plugin catalog.', 'robotstxt-manager' ),
|
||||
esc_url( admin_url( 'admin.php?page=' . Robotstxt_Manager_Settings::PAGE_SLUG ) )
|
||||
esc_url( ( is_multisite() ? network_admin_url( 'admin.php?page=' . Robotstxt_Manager_Settings::PAGE_SLUG ) : admin_url( 'admin.php?page=' . Robotstxt_Manager_Settings::PAGE_SLUG ) ) )
|
||||
)
|
||||
);
|
||||
?>
|
||||
|
|
@ -217,7 +237,15 @@ $compat_warnings = 0;
|
|||
$l_version = is_string( $raw_lv ) ? $raw_lv : '';
|
||||
|
||||
$update_available = $l_installed && '' !== $remote_v && '' !== $l_version
|
||||
&& version_compare( $l_version, $remote_v, '<' );
|
||||
&& version_compare( $l_version, $remote_v, '<' );
|
||||
|
||||
// Security patch declared for exactly the installed version:
|
||||
// the Update action installs the patch, not the mainline.
|
||||
$security_patch = Robotstxt_Manager_Updater::security_patch_for( $entry, $l_version );
|
||||
|
||||
if ( '' !== $security_patch ) {
|
||||
$update_available = true;
|
||||
}
|
||||
|
||||
$row_class = ( ! $wp_ok || ! $php_ok ) ? ' robotstxt-manager-row--incompatible' : '';
|
||||
?>
|
||||
|
|
@ -278,7 +306,7 @@ $compat_warnings = 0;
|
|||
|
||||
if ( $days_left >= 0 && $days_left <= 14 ) {
|
||||
$sub_text = sprintf(
|
||||
/* translators: %d: days remaining. */
|
||||
/* translators: %d: days remaining. */
|
||||
__( 'Subscribed — %d days left', 'robotstxt-manager' ),
|
||||
$days_left
|
||||
);
|
||||
|
|
@ -292,6 +320,25 @@ $compat_warnings = 0;
|
|||
} elseif ( 'expired' === $sub_status ) {
|
||||
$sub_text = __( 'Expired', 'robotstxt-manager' );
|
||||
}
|
||||
|
||||
// Bound domain (per-domain licenses, Core 1.11.0+):
|
||||
// shown once the license is tied to a site.
|
||||
$raw_bound = $sub_row['bound_domain'] ?? '';
|
||||
$bound_text = is_string( $raw_bound ) ? trim( $raw_bound ) : '';
|
||||
|
||||
// Multi-license: how many licenses the account
|
||||
// holds for this plugin (Core 1.14.0+).
|
||||
$raw_count = $sub_row['license_count'] ?? 1;
|
||||
$count_int = is_numeric( $raw_count ) ? (int) ( $raw_count + 0 ) : 1;
|
||||
$count_txt = $count_int > 1 ? ' ×' . $count_int : '';
|
||||
|
||||
if ( '' !== $bound_text && in_array( $sub_status, array( 'active', 'payment_failed' ), true ) ) {
|
||||
$sub_text = '' !== $sub_text
|
||||
? $sub_text . ' @ ' . $bound_text . $count_txt
|
||||
: $bound_text . $count_txt;
|
||||
} elseif ( '' !== $count_txt ) {
|
||||
$sub_text = '' !== $sub_text ? $sub_text . $count_txt : __( 'Subscribed', 'robotstxt-manager' ) . $count_txt;
|
||||
}
|
||||
}
|
||||
|
||||
if ( '' !== $sub_text ) {
|
||||
|
|
@ -327,6 +374,15 @@ $compat_warnings = 0;
|
|||
echo '<span class="robotstxt-manager-state robotstxt-manager-state--not-installed">' . esc_html__( 'Not installed', 'robotstxt-manager' ) . '</span>';
|
||||
} elseif ( ! $l_active ) {
|
||||
echo '<span class="robotstxt-manager-state robotstxt-manager-state--inactive">' . esc_html__( 'Installed (inactive)', 'robotstxt-manager' ) . '</span>';
|
||||
} elseif ( '' !== $security_patch ) {
|
||||
echo '<span class="robotstxt-manager-state robotstxt-manager-state--security">' . esc_html(
|
||||
sprintf(
|
||||
/* translators: 1: installed version, 2: security patch version. */
|
||||
__( 'Security update available (v%1$s → v%2$s)', 'robotstxt-manager' ),
|
||||
$l_version,
|
||||
$security_patch
|
||||
)
|
||||
) . '</span>';
|
||||
} elseif ( $update_available ) {
|
||||
echo '<span class="robotstxt-manager-state robotstxt-manager-state--update">' . esc_html(
|
||||
sprintf(
|
||||
|
|
@ -414,6 +470,7 @@ $compat_warnings = 0;
|
|||
.robotstxt-manager-compat--fail { color: #d63638; font-weight: 600; }
|
||||
.robotstxt-manager-compat-warning { cursor: help; }
|
||||
.robotstxt-manager-row--incompatible { background-color: #fef7f0 !important; }
|
||||
.robotstxt-manager-state--security { color: #d63638; font-weight: 600; }
|
||||
|
||||
/* Detail rows (always open): muted, attached to the row above. */
|
||||
.robotstxt-manager-detail-row td { border-top: none !important; padding-top: 0; }
|
||||
|
|
|
|||
|
|
@ -12,9 +12,9 @@ if ( ! defined( 'ABSPATH' ) ) {
|
|||
<div class="wrap">
|
||||
<h1><?php esc_html_e( 'Manager (by ROBOTSTXT) — Settings', 'robotstxt-manager' ); ?></h1>
|
||||
<?php settings_errors(); ?>
|
||||
<form method="post" action="<?php echo esc_url( admin_url( 'options.php' ) ); ?>">
|
||||
<form method="post" action="">
|
||||
<?php
|
||||
settings_fields( Robotstxt_Manager_Settings::OPTION_GROUP );
|
||||
wp_nonce_field( 'robotstxt_manager_settings_group', 'robotstxt_manager_settings_group_nonce' );
|
||||
do_settings_sections( Robotstxt_Manager_Settings::PAGE_SLUG );
|
||||
submit_button();
|
||||
?>
|
||||
|
|
|
|||
137
changelog.txt
137
changelog.txt
|
|
@ -1,9 +1,142 @@
|
|||
== Changelog ==
|
||||
|
||||
= 1.5.0 =
|
||||
= 1.9.1 =
|
||||
|
||||
_Release date: 2026-09-23_
|
||||
|
||||
**Changed**
|
||||
|
||||
* Maintenance pass over the 1.9.0 security-patch feature: `composer update` (no changes — dependencies already current, no known CVEs), full code + security review of the 1.9.0 diff (clean-context pre-deploy audit: escaping, capability gating, CSRF, and the 1.8.1 updater invariants all verified correct; its two suggestions were applied — see below), and floors re-verified.
|
||||
* Pre-deploy audit hardening: `security_patch_for()` only accepts declarations that strictly increase the version — a store typo (self-mapping or downgrade) can no longer produce a downgrade/re-install "update" notice; the `version` argument in premium/free package URLs is now `rawurlencode`d, matching the installer. POT regenerated at 1.9.1.
|
||||
|
||||
**Compatibility**
|
||||
|
||||
* WordPress: 4.4 - 7.1 (scan-verified 2026-09-23: wp-compat ladder clean at 4.4 through 4.7; no WordPress API newer than 4.4 in use; the dev environment here runs WordPress 7.2-alpha trunk with the suite green, but 7.2 is not GA and `Tested up to` stays at the AGENTS window top)
|
||||
* PHP: 8.0 - 8.5 (scan-verified 2026-09-23: PHPCompatibility ladder 5.6-8.5 — scan-clean from 7.4; manual audit keeps the real floor at 8.0: `mixed` hints, `str_contains()`, `str_starts_with()`)
|
||||
|
||||
**Tests**
|
||||
|
||||
* PHPCS (WordPress-Core, WordPress-Docs, WordPress-Extra): pass
|
||||
* PHPStan max (level 10) + wp-compat: pass
|
||||
* PHPUnit: 158 tests, 423 assertions (also green against WordPress 7.2-alpha trunk / test library rebuilt from wordpress-develop master)
|
||||
|
||||
= 1.9.0 =
|
||||
|
||||
_Release date: 2026-09-22_
|
||||
|
||||
**Added**
|
||||
|
||||
* Security-update notices (with Core 1.16.0+): when the store declares a security patch for the exact version this site runs (e.g. 1.2.3.1 applies to 1.2.3), Manager shows a red "Security update available: %name% (vX → vY) — Update now" notice on its catalog page **and** on the Plugins screen, and the catalog row status reads "Security update available (vX → vY)". The Update action installs the declared patch — never the feature mainline — so sites receive the security fix without being pushed across feature versions.
|
||||
* The native update integration (update badge, `wp plugin update`, auto-updates) also targets the declared patch: the injected update entry carries the patch version and a versioned, authenticated package URL. Patches declared for other versions never apply (exact match on the installed version). Chained patches work (1.2.3.1 → 1.2.3.2 once declared).
|
||||
* Spanish (es_ES) and Catalan (ca) translations for the new strings; POT regenerated.
|
||||
|
||||
**Requires Core 1.16.0+ on the store** for patch data and versioned downloads (older Core: the catalog simply carries no patches and Manager behaves as before).
|
||||
|
||||
**Tests**
|
||||
|
||||
* PHPCS (WordPress-Core, WordPress-Docs, WordPress-Extra): pass
|
||||
* PHPStan max (level 10) + wp-compat: pass
|
||||
* PHPUnit: 157 tests, 420 assertions
|
||||
|
||||
**Compatibility**
|
||||
|
||||
* WordPress: 4.4 - 7.1 (unchanged)
|
||||
* PHP: 8.0 - 8.5 (unchanged)
|
||||
|
||||
= 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_
|
||||
|
||||
**Added**
|
||||
|
||||
* Multi-domain license awareness (with Core 1.14.0): `Robotstxt_Manager_Core_Client::get_subscriptions()` groups the flat `/me/subscriptions` rows per plugin slug — any-active status wins, the first bound domain is shown, and a `license_count` is kept. Catalog subscription pills render the count when a plugin has more than one license (e.g. "Subscribed @ example.com ×2").
|
||||
|
||||
**Tests**
|
||||
|
||||
* PHPCS (WordPress-Core, WordPress-Docs, WordPress-Extra): pass
|
||||
* PHPStan level 9 + wp-compat: pass
|
||||
* PHPUnit: 149 tests, 391 assertions
|
||||
|
||||
= 1.7.0 =
|
||||
|
||||
_Release date: 2026-08-18_
|
||||
|
||||
**Added**
|
||||
|
||||
* Per-domain license support (Core 1.11.0+): install/update downloads and the download-token exchange now send this site's normalized domain, so premium licenses bind to this site and the store rejects requests from other domains. Catalog subscription pills show the bound domain when the store reports one (e.g. "Subscribed @ example.com").
|
||||
|
||||
**Changed**
|
||||
|
||||
* Download failures now surface the store's own error message from the JSON body (e.g. the domain-mismatch explanation) instead of only "Download failed (HTTP 403)." — falls back to the bare code when no message is present.
|
||||
|
||||
**Tests**
|
||||
|
||||
* PHPCS (WordPress-Core, WordPress-Docs, WordPress-Extra): pass
|
||||
* PHPStan level 9 + wp-compat: pass
|
||||
* PHPUnit: 149 tests, 391 assertions
|
||||
|
||||
= 1.6.2 =
|
||||
|
||||
_Release date: 2026-08-18_
|
||||
|
||||
**Added**
|
||||
|
||||
* `ROBOTSTXT_MANAGER_NOTICED` presence constant (guarded with `defined()`, value `true`), defined when Manager loads: ecosystem plugins (Core, Mollie, …) detect an active Manager via `defined( 'ROBOTSTXT_MANAGER_NOTICED' )` instead of scanning the plugin list. The guard keeps a double-load or a conflicting definition from fatalling.
|
||||
|
||||
**Tests**
|
||||
|
||||
* PHPCS (WordPress-Core, WordPress-Docs, WordPress-Extra): pass
|
||||
* PHPStan level 9 + wp-compat: pass
|
||||
* PHPUnit: 147 tests, 386 assertions
|
||||
|
||||
= 1.6.1 =
|
||||
|
||||
_Release date: 2026-08-18_
|
||||
|
||||
**Fixed**
|
||||
|
||||
* "Cannot modify header information — headers already sent" warning after saving the settings: the form handler ran inside the page renderer (after output started). It now runs on `admin_init`, before any output, so the redirect succeeds.
|
||||
|
||||
= 1.6.0 =
|
||||
|
||||
_Release date: 2026-08-18_
|
||||
|
||||
**Added**
|
||||
|
||||
* WordPress Multisite compatibility: `Network: true` header forces network-wide activation. Menus appear in the network admin on Multisite, in the regular admin on single-site. All options and transients use network-level storage (`get_site_option` / `get_site_transient`). Settings form handles submission manually (the Settings API `options.php` does not handle network options).
|
||||
|
||||
**Fixed**
|
||||
|
||||
* API key not saving when the browser auto-fills the password field with the stored encrypted value — the sanitizer now detects already-encrypted input (`v2:` prefix) and returns it as-is instead of failing UUID validation.
|
||||
|
|
@ -21,7 +154,7 @@ _Release date: 2026-08-18_
|
|||
* PHPStan level 9 + wp-compat: pass
|
||||
* PHPUnit: 146 tests, 385 assertions
|
||||
|
||||
= 1.4.1 =
|
||||
= 1.5.0 =
|
||||
|
||||
_Release date: 2026-08-17_
|
||||
|
||||
|
|
|
|||
|
|
@ -34,7 +34,7 @@ class Robotstxt_Manager_Activator {
|
|||
* @return void
|
||||
*/
|
||||
public static function deactivate(): void {
|
||||
delete_transient( 'robotstxt_manager_catalog' );
|
||||
delete_site_transient( 'robotstxt_manager_catalog' );
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -43,12 +43,12 @@ class Robotstxt_Manager_Activator {
|
|||
* @return void
|
||||
*/
|
||||
private static function ensure_defaults(): void {
|
||||
if ( '' === get_option( 'robotstxt_manager_store_url', '' ) ) {
|
||||
update_option( 'robotstxt_manager_store_url', 'https://www.robotstxt.software' );
|
||||
if ( '' === get_site_option( 'robotstxt_manager_store_url', '' ) ) {
|
||||
update_site_option( 'robotstxt_manager_store_url', 'https://www.robotstxt.software' );
|
||||
}
|
||||
|
||||
if ( '' === get_option( 'robotstxt_manager_cache_ttl_minutes', '' ) ) {
|
||||
update_option( 'robotstxt_manager_cache_ttl_minutes', 60 );
|
||||
if ( '' === get_site_option( 'robotstxt_manager_cache_ttl_minutes', '' ) ) {
|
||||
update_site_option( 'robotstxt_manager_cache_ttl_minutes', 60 );
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -79,8 +79,8 @@ class Robotstxt_Manager_Core_Client {
|
|||
* @return self
|
||||
*/
|
||||
public static function from_options(): self {
|
||||
$store_url_raw = get_option( 'robotstxt_manager_store_url', '' );
|
||||
$api_key_raw = get_option( 'robotstxt_manager_api_key', '' );
|
||||
$store_url_raw = get_site_option( 'robotstxt_manager_store_url', '' );
|
||||
$api_key_raw = get_site_option( 'robotstxt_manager_api_key', '' );
|
||||
|
||||
$store_url = is_string( $store_url_raw ) ? $store_url_raw : '';
|
||||
$api_key = is_string( $api_key_raw )
|
||||
|
|
@ -197,7 +197,7 @@ class Robotstxt_Manager_Core_Client {
|
|||
}
|
||||
|
||||
$cache_key = 'robotstxt_manager_catalog';
|
||||
$cached = get_transient( $cache_key );
|
||||
$cached = get_site_transient( $cache_key );
|
||||
|
||||
if ( is_array( $cached ) ) {
|
||||
$typed = array();
|
||||
|
|
@ -248,7 +248,7 @@ class Robotstxt_Manager_Core_Client {
|
|||
}
|
||||
|
||||
$ttl = $this->get_catalog_ttl();
|
||||
set_transient( $cache_key, $catalog, $ttl );
|
||||
set_site_transient( $cache_key, $catalog, $ttl );
|
||||
|
||||
return $catalog;
|
||||
}
|
||||
|
|
@ -260,7 +260,7 @@ class Robotstxt_Manager_Core_Client {
|
|||
* @return void
|
||||
*/
|
||||
public function clear_catalog_cache(): void {
|
||||
delete_transient( 'robotstxt_manager_catalog' );
|
||||
delete_site_transient( 'robotstxt_manager_catalog' );
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -269,7 +269,7 @@ class Robotstxt_Manager_Core_Client {
|
|||
* @return int
|
||||
*/
|
||||
private function get_catalog_ttl(): int {
|
||||
$raw = get_option( 'robotstxt_manager_cache_ttl_minutes', 60 );
|
||||
$raw = get_site_option( 'robotstxt_manager_cache_ttl_minutes', 60 );
|
||||
$min = is_numeric( $raw ) ? (int) $raw : 60;
|
||||
|
||||
if ( $min < 1 ) {
|
||||
|
|
@ -316,13 +316,19 @@ class Robotstxt_Manager_Core_Client {
|
|||
}
|
||||
|
||||
$cache_key = 'robotstxt_manager_subscriptions';
|
||||
$cached = get_transient( $cache_key );
|
||||
$cached = get_site_transient( $cache_key );
|
||||
|
||||
if ( is_array( $cached ) ) {
|
||||
$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;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -356,14 +362,41 @@ class Robotstxt_Manager_Core_Client {
|
|||
|
||||
$raw_status = $row['status'] ?? '';
|
||||
$raw_expires_at = $row['expires_at'] ?? '';
|
||||
$raw_bound = $row['bound_domain'] ?? '';
|
||||
|
||||
$status = is_string( $raw_status ) ? $raw_status : '';
|
||||
$expires_at = is_string( $raw_expires_at ) ? $raw_expires_at : '';
|
||||
$bound = is_string( $raw_bound ) ? $raw_bound : '';
|
||||
|
||||
// Group multi-license rows (Core 1.14.0+: one row per domain)
|
||||
// into one entry per slug: any-active wins, first bound domain
|
||||
// shown, license count kept for the pill.
|
||||
if ( isset( $rows[ $slug ] ) ) {
|
||||
$existing = $rows[ $slug ];
|
||||
|
||||
if ( 'active' === $status && 'active' !== $existing['status'] ) {
|
||||
$existing['status'] = 'active';
|
||||
$existing['expires_at'] = $expires_at;
|
||||
}
|
||||
|
||||
if ( '' === $existing['bound_domain'] && '' !== $bound ) {
|
||||
$existing['bound_domain'] = $bound;
|
||||
}
|
||||
|
||||
$existing['license_count'] = (int) $existing['license_count'] + 1;
|
||||
$rows[ $slug ] = $existing;
|
||||
continue;
|
||||
}
|
||||
|
||||
$rows[ $slug ] = array(
|
||||
'status' => is_string( $raw_status ) ? $raw_status : '',
|
||||
'expires_at' => is_string( $raw_expires_at ) ? $raw_expires_at : '',
|
||||
'status' => $status,
|
||||
'expires_at' => $expires_at,
|
||||
'bound_domain' => $bound,
|
||||
'license_count' => 1,
|
||||
);
|
||||
}
|
||||
|
||||
set_transient( $cache_key, $rows, HOUR_IN_SECONDS );
|
||||
set_site_transient( $cache_key, $rows, HOUR_IN_SECONDS );
|
||||
|
||||
return $rows;
|
||||
}
|
||||
|
|
@ -372,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
|
||||
|
|
@ -383,6 +422,18 @@ 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 ) );
|
||||
$domain = (string) preg_replace( '/^www\./', '', $host );
|
||||
|
||||
$response = wp_remote_post(
|
||||
$this->store_url . '/wp-json/' . self::REST_NAMESPACE . '/me/download-token',
|
||||
array(
|
||||
|
|
@ -391,20 +442,36 @@ class Robotstxt_Manager_Core_Client {
|
|||
'Accept' => 'application/json',
|
||||
'Content-Type' => 'application/json',
|
||||
),
|
||||
'body' => (string) wp_json_encode( array( 'slug' => $slug ) ),
|
||||
'body' => (string) wp_json_encode(
|
||||
array(
|
||||
'slug' => $slug,
|
||||
'domain' => $domain,
|
||||
)
|
||||
),
|
||||
'timeout' => self::TIMEOUT,
|
||||
)
|
||||
);
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -413,6 +480,6 @@ class Robotstxt_Manager_Core_Client {
|
|||
* @return void
|
||||
*/
|
||||
public function clear_subscriptions_cache(): void {
|
||||
delete_transient( 'robotstxt_manager_subscriptions' );
|
||||
delete_site_transient( 'robotstxt_manager_subscriptions' );
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 ),
|
||||
|
|
|
|||
|
|
@ -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,177 @@ 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;
|
||||
}
|
||||
// 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 ( 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 ( '' !== $patch ) {
|
||||
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, 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 );
|
||||
}
|
||||
} 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 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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -136,7 +270,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;
|
||||
|
||||
|
|
@ -189,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();
|
||||
|
|
@ -203,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', '' ),
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -222,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'],
|
||||
|
|
@ -273,7 +412,7 @@ class Robotstxt_Manager_Updater {
|
|||
* @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_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;
|
||||
|
|
@ -285,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';
|
||||
|
|
@ -299,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.
|
||||
|
|
@ -308,7 +453,7 @@ class Robotstxt_Manager_Updater {
|
|||
$args['token'] = $token;
|
||||
} else {
|
||||
// Fallback (older Core): the API key itself.
|
||||
$api_key_raw = get_option( 'robotstxt_manager_api_key', '' );
|
||||
$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 ) {
|
||||
|
|
|
|||
Binary file not shown.
|
|
@ -583,3 +583,10 @@ msgstr "La botiga ha respost amb HTTP %d. Comproveu l'URL de la botiga i la clau
|
|||
#: includes/class-robotstxt-manager-core-client.php:177
|
||||
msgid "Connected."
|
||||
msgstr "Connectat."
|
||||
|
||||
#: includes/class-robotstxt-manager-updater.php admin/class-robotstxt-manager-admin.php
|
||||
msgid "<strong>Security update available:</strong> %1$s (v%2$s → v%3$s). <a href=\"%4$s\">Update now</a> — this is a security patch for the version this site runs, not a feature update."
|
||||
msgstr "<strong>Actualització de seguretat disponible:</strong> %1$s (v%2$s → v%3$s). <a href=\"%4$s\">Actualitza ara</a> — és un pedaç de seguretat per a la versió que fa servir aquest lloc, no una actualització de funcions."
|
||||
|
||||
msgid "Security update available (v%1$s → v%2$s)"
|
||||
msgstr "Actualització de seguretat disponible (v%1$s → v%2$s)"
|
||||
|
|
|
|||
Binary file not shown.
|
|
@ -582,3 +582,10 @@ msgstr "La tienda respondió con HTTP %d. Comprueba la URL de la tienda y la cla
|
|||
#: includes/class-robotstxt-manager-core-client.php:177
|
||||
msgid "Connected."
|
||||
msgstr "Conectado."
|
||||
|
||||
#: includes/class-robotstxt-manager-updater.php admin/class-robotstxt-manager-admin.php
|
||||
msgid "<strong>Security update available:</strong> %1$s (v%2$s → v%3$s). <a href=\"%4$s\">Update now</a> — this is a security patch for the version this site runs, not a feature update."
|
||||
msgstr "<strong>Actualización de seguridad disponible:</strong> %1$s (v%2$s → v%3$s). <a href=\"%4$s\">Actualizar ahora</a> — es un parche de seguridad para la versión que usa este sitio, no una actualización de funciones."
|
||||
|
||||
msgid "Security update available (v%1$s → v%2$s)"
|
||||
msgstr "Actualización de seguridad disponible (v%1$s → v%2$s)"
|
||||
|
|
|
|||
|
|
@ -2,14 +2,14 @@
|
|||
# This file is distributed under the GPL-3.0-or-later.
|
||||
msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: Manager (by ROBOTSTXT) 1.5.0\n"
|
||||
"Report-Msgid-Bugs-To: https://www.robotstxt.software/plugins/robotstxt-manager/\n"
|
||||
"Project-Id-Version: Manager (by ROBOTSTXT) 1.9.1\n"
|
||||
"Report-Msgid-Bugs-To: https://wordpress.org/support/plugin/robotstxt-manager\n"
|
||||
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
|
||||
"Language-Team: LANGUAGE <LL@li.org>\n"
|
||||
"MIME-Version: 1.0\n"
|
||||
"Content-Type: text/plain; charset=UTF-8\n"
|
||||
"Content-Transfer-Encoding: 8bit\n"
|
||||
"POT-Creation-Date: 2026-08-17T14:52:09+00:00\n"
|
||||
"POT-Creation-Date: 2026-09-23T04:51:33+00:00\n"
|
||||
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
|
||||
"X-Generator: WP-CLI 2.12.0\n"
|
||||
"X-Domain: robotstxt-manager\n"
|
||||
|
|
@ -31,7 +31,7 @@ msgstr ""
|
|||
|
||||
#. Author of the plugin
|
||||
#: robotstxt-manager.php
|
||||
#: admin/class-robotstxt-manager-admin.php:48
|
||||
#: admin/class-robotstxt-manager-admin.php:52
|
||||
msgid "ROBOTSTXT"
|
||||
msgstr ""
|
||||
|
||||
|
|
@ -40,49 +40,56 @@ msgstr ""
|
|||
msgid "https://www.robotstxt.software/"
|
||||
msgstr ""
|
||||
|
||||
#: admin/class-robotstxt-manager-admin.php:47
|
||||
#: admin/views/page-catalog.php:66
|
||||
#: admin/class-robotstxt-manager-admin.php:51
|
||||
#: admin/views/page-catalog.php:67
|
||||
msgid "Manager (by ROBOTSTXT) — Plugins"
|
||||
msgstr ""
|
||||
|
||||
#: admin/class-robotstxt-manager-admin.php:64
|
||||
#: admin/class-robotstxt-manager-settings.php:207
|
||||
#: admin/class-robotstxt-manager-admin.php:68
|
||||
#: admin/class-robotstxt-manager-settings.php:210
|
||||
msgid "You do not have sufficient permissions to access this page."
|
||||
msgstr ""
|
||||
|
||||
#. translators: 1: plugin name, 2: installed version, 3: patch version, 4: update URL.
|
||||
#: admin/class-robotstxt-manager-admin.php:168
|
||||
#: admin/views/page-catalog.php:90
|
||||
#, php-format
|
||||
msgid "<strong>Security update available:</strong> %1$s (v%2$s → v%3$s). <a href=\"%4$s\">Update now</a> — this is a security patch for the version this site runs, not a feature update."
|
||||
msgstr ""
|
||||
|
||||
#. translators: %s: plugin slug.
|
||||
#: admin/class-robotstxt-manager-admin.php:108
|
||||
#: admin/class-robotstxt-manager-admin.php:206
|
||||
#, php-format
|
||||
msgid "The payment for %s failed. Update your payment method from your ROBOTSTXT account page to keep access."
|
||||
msgstr ""
|
||||
|
||||
#. translators: %s: plugin slug.
|
||||
#: admin/class-robotstxt-manager-admin.php:119
|
||||
#: admin/class-robotstxt-manager-admin.php:217
|
||||
#, php-format
|
||||
msgid "The subscription for %s has expired, but the plugin is still active on this site. Renew from your ROBOTSTXT account page to keep receiving updates."
|
||||
msgstr ""
|
||||
|
||||
#. translators: 1: plugin slug, 2: days remaining.
|
||||
#: admin/class-robotstxt-manager-admin.php:132
|
||||
#: admin/class-robotstxt-manager-admin.php:230
|
||||
#, php-format
|
||||
msgid "The subscription for %1$s expires in %2$d day."
|
||||
msgid_plural "The subscription for %1$s expires in %2$d days."
|
||||
msgstr[0] ""
|
||||
msgstr[1] ""
|
||||
|
||||
#: admin/class-robotstxt-manager-admin.php:153
|
||||
#: admin/class-robotstxt-manager-installer.php:421
|
||||
#: admin/class-robotstxt-manager-settings.php:379
|
||||
#: admin/class-robotstxt-manager-settings.php:405
|
||||
#: admin/class-robotstxt-manager-admin.php:251
|
||||
#: admin/class-robotstxt-manager-installer.php:433
|
||||
#: admin/class-robotstxt-manager-settings.php:462
|
||||
#: admin/class-robotstxt-manager-settings.php:506
|
||||
msgid "Insufficient permissions."
|
||||
msgstr ""
|
||||
|
||||
#: admin/class-robotstxt-manager-admin.php:197
|
||||
#: admin/class-robotstxt-manager-admin.php:295
|
||||
msgid "Too many refreshes. Please wait a minute before refreshing again."
|
||||
msgstr ""
|
||||
|
||||
#: admin/class-robotstxt-manager-installer.php:51
|
||||
#: admin/class-robotstxt-manager-installer.php:518
|
||||
#: admin/class-robotstxt-manager-installer.php:532
|
||||
msgid "Plugin not found in catalog."
|
||||
msgstr ""
|
||||
|
||||
|
|
@ -117,39 +124,39 @@ msgid "No download found on WordPress.org for %s."
|
|||
msgstr ""
|
||||
|
||||
#: admin/class-robotstxt-manager-installer.php:287
|
||||
#: admin/class-robotstxt-manager-installer.php:540
|
||||
#: admin/class-robotstxt-manager-installer.php:570
|
||||
msgid "Could not create a temporary file for download."
|
||||
msgstr ""
|
||||
|
||||
#. translators: %s: HTTP transport error message.
|
||||
#: admin/class-robotstxt-manager-installer.php:303
|
||||
#: admin/class-robotstxt-manager-installer.php:573
|
||||
#: admin/class-robotstxt-manager-installer.php:603
|
||||
#, php-format
|
||||
msgid "Download failed: %s"
|
||||
msgstr ""
|
||||
|
||||
#. translators: %d: HTTP status code.
|
||||
#: admin/class-robotstxt-manager-installer.php:310
|
||||
#: admin/class-robotstxt-manager-installer.php:588
|
||||
#: admin/class-robotstxt-manager-installer.php:630
|
||||
#, php-format
|
||||
msgid "Download failed (HTTP %d)."
|
||||
msgstr ""
|
||||
|
||||
#: admin/class-robotstxt-manager-installer.php:316
|
||||
#: admin/class-robotstxt-manager-installer.php:597
|
||||
#: admin/class-robotstxt-manager-installer.php:639
|
||||
msgid "The store returned an invalid file."
|
||||
msgstr ""
|
||||
|
||||
#. translators: %s: upgrader error message.
|
||||
#: admin/class-robotstxt-manager-installer.php:334
|
||||
#: admin/class-robotstxt-manager-installer.php:622
|
||||
#: admin/class-robotstxt-manager-installer.php:664
|
||||
#, php-format
|
||||
msgid "Installation failed: %s"
|
||||
msgstr ""
|
||||
|
||||
#. translators: %s: upgrader error message.
|
||||
#: admin/class-robotstxt-manager-installer.php:334
|
||||
#: admin/class-robotstxt-manager-installer.php:625
|
||||
#: admin/class-robotstxt-manager-installer.php:667
|
||||
msgid "Installation failed."
|
||||
msgstr ""
|
||||
|
||||
|
|
@ -160,7 +167,7 @@ msgid "The downloaded plugin for %s does not have the expected folder structure.
|
|||
msgstr ""
|
||||
|
||||
#: admin/class-robotstxt-manager-installer.php:363
|
||||
#: admin/class-robotstxt-manager-installer.php:394
|
||||
#: admin/class-robotstxt-manager-installer.php:396
|
||||
msgid "Plugin is not installed."
|
||||
msgstr ""
|
||||
|
||||
|
|
@ -171,278 +178,297 @@ msgid "%s activated."
|
|||
msgstr ""
|
||||
|
||||
#. translators: %s: plugin name (slug).
|
||||
#: admin/class-robotstxt-manager-installer.php:406
|
||||
#: admin/class-robotstxt-manager-installer.php:418
|
||||
#, php-format
|
||||
msgid "%s updated."
|
||||
msgstr ""
|
||||
|
||||
#: admin/class-robotstxt-manager-installer.php:512
|
||||
#: admin/class-robotstxt-manager-installer.php:526
|
||||
msgid "Store is not configured."
|
||||
msgstr ""
|
||||
|
||||
#: admin/class-robotstxt-manager-settings.php:59
|
||||
#. translators: 1: HTTP status code, 2: store error message.
|
||||
#: admin/class-robotstxt-manager-installer.php:624
|
||||
#, php-format
|
||||
msgid "Download failed (HTTP %1$d): %2$s"
|
||||
msgstr ""
|
||||
|
||||
#: admin/class-robotstxt-manager-settings.php:62
|
||||
#: admin/views/page-settings.php:13
|
||||
msgid "Manager (by ROBOTSTXT) — Settings"
|
||||
msgstr ""
|
||||
|
||||
#: admin/class-robotstxt-manager-settings.php:60
|
||||
#: admin/class-robotstxt-manager-settings.php:63
|
||||
msgid "Settings"
|
||||
msgstr ""
|
||||
|
||||
#: admin/class-robotstxt-manager-settings.php:75
|
||||
#: admin/class-robotstxt-manager-settings.php:78
|
||||
msgid "Connection"
|
||||
msgstr ""
|
||||
|
||||
#: admin/class-robotstxt-manager-settings.php:92
|
||||
#: admin/class-robotstxt-manager-settings.php:95
|
||||
msgid "Store URL"
|
||||
msgstr ""
|
||||
|
||||
#: admin/class-robotstxt-manager-settings.php:110
|
||||
#: admin/class-robotstxt-manager-settings.php:113
|
||||
msgid "API Key"
|
||||
msgstr ""
|
||||
|
||||
#: admin/class-robotstxt-manager-settings.php:118
|
||||
#: admin/class-robotstxt-manager-settings.php:121
|
||||
msgid "Cache"
|
||||
msgstr ""
|
||||
|
||||
#: admin/class-robotstxt-manager-settings.php:135
|
||||
#: admin/class-robotstxt-manager-settings.php:138
|
||||
msgid "Catalog Cache (minutes)"
|
||||
msgstr ""
|
||||
|
||||
#: admin/class-robotstxt-manager-settings.php:153
|
||||
#: admin/class-robotstxt-manager-settings.php:156
|
||||
msgid "Data on Uninstall"
|
||||
msgstr ""
|
||||
|
||||
#: admin/class-robotstxt-manager-settings.php:188
|
||||
#: admin/class-robotstxt-manager-settings.php:191
|
||||
msgid "Testing…"
|
||||
msgstr ""
|
||||
|
||||
#: admin/class-robotstxt-manager-settings.php:189
|
||||
#: admin/class-robotstxt-manager-settings.php:274
|
||||
#: admin/class-robotstxt-manager-settings.php:192
|
||||
#: admin/class-robotstxt-manager-settings.php:349
|
||||
msgid "Test connection"
|
||||
msgstr ""
|
||||
|
||||
#: admin/class-robotstxt-manager-settings.php:190
|
||||
#: admin/class-robotstxt-manager-settings.php:193
|
||||
msgid "Deleting…"
|
||||
msgstr ""
|
||||
|
||||
#: admin/class-robotstxt-manager-settings.php:191
|
||||
#: admin/class-robotstxt-manager-settings.php:278
|
||||
#: admin/class-robotstxt-manager-settings.php:194
|
||||
#: admin/class-robotstxt-manager-settings.php:353
|
||||
msgid "Delete API key"
|
||||
msgstr ""
|
||||
|
||||
#: admin/class-robotstxt-manager-settings.php:192
|
||||
#: admin/class-robotstxt-manager-settings.php:195
|
||||
msgid "Delete the stored API key? The catalog and subscription data will stop working until a new key is entered."
|
||||
msgstr ""
|
||||
|
||||
#: admin/class-robotstxt-manager-settings.php:193
|
||||
#: admin/class-robotstxt-manager-settings.php:414
|
||||
#: admin/class-robotstxt-manager-settings.php:196
|
||||
#: admin/class-robotstxt-manager-settings.php:515
|
||||
msgid "API key deleted. Save changes to persist."
|
||||
msgstr ""
|
||||
|
||||
#: admin/class-robotstxt-manager-settings.php:194
|
||||
#: admin/class-robotstxt-manager-settings.php:197
|
||||
msgid "An unexpected error occurred."
|
||||
msgstr ""
|
||||
|
||||
#: admin/class-robotstxt-manager-settings.php:226
|
||||
#: admin/class-robotstxt-manager-settings.php:233
|
||||
msgid "You do not have sufficient permissions to manage settings."
|
||||
msgstr ""
|
||||
|
||||
#: admin/class-robotstxt-manager-settings.php:292
|
||||
msgid "Base URL of the remote Plugins Core installation that this site will pull the plugin catalog from."
|
||||
msgstr ""
|
||||
|
||||
#. translators: %s: last 4 characters of the stored API key.
|
||||
#: admin/class-robotstxt-manager-settings.php:257
|
||||
#: admin/class-robotstxt-manager-settings.php:323
|
||||
#, php-format
|
||||
msgid "A key is stored (last 4 characters: <code>%s</code>). Leave blank to keep the existing key; enter a new value to replace it."
|
||||
msgstr ""
|
||||
|
||||
#: admin/class-robotstxt-manager-settings.php:262
|
||||
#: admin/class-robotstxt-manager-settings.php:328
|
||||
msgid "A key is stored but could not be decoded. You can replace it by entering a new value above, or delete it with the button below."
|
||||
msgstr ""
|
||||
|
||||
#: admin/class-robotstxt-manager-settings.php:266
|
||||
#. translators: %s: Registration URL.
|
||||
#: admin/class-robotstxt-manager-settings.php:337
|
||||
#, php-format
|
||||
msgid "Account-level API key from the ROBOTSTXT store (<a href=\"%s\" target=\"_blank\" rel=\"noopener\">create your free account there to get one</a>). Optional — the free catalog works without it — but required to link your subscriptions, install premium plugins, and receive their updates. Encrypted before storage."
|
||||
msgstr ""
|
||||
|
||||
#: admin/class-robotstxt-manager-settings.php:298
|
||||
#: admin/class-robotstxt-manager-settings.php:373
|
||||
msgid "How long the catalog response from Core is cached in a transient. Default: 60 minutes. Lower values refresh more often at the cost of more requests to Core. Maximum: 1440 (24 hours)."
|
||||
msgstr ""
|
||||
|
||||
#: admin/class-robotstxt-manager-settings.php:313
|
||||
#: admin/class-robotstxt-manager-settings.php:388
|
||||
msgid "Delete all plugin data when the plugin is uninstalled."
|
||||
msgstr ""
|
||||
|
||||
#: admin/class-robotstxt-manager-settings.php:343
|
||||
#: admin/class-robotstxt-manager-settings.php:426
|
||||
msgid "The API key format is invalid. Copy the full key from your ROBOTSTXT account page."
|
||||
msgstr ""
|
||||
|
||||
#: admin/views/page-catalog.php:69
|
||||
#: admin/views/page-catalog.php:70
|
||||
msgid "This is the ROBOTSTXT plugin store: the catalog of plugins published by ROBOTSTXT, installable and updatable straight from your own wp-admin. Free plugins install with one click; premium plugins require an annual subscription that you purchase on our website."
|
||||
msgstr ""
|
||||
|
||||
#: admin/views/page-catalog.php:73
|
||||
#: admin/views/page-catalog.php:74
|
||||
msgid "Catalog refreshed."
|
||||
msgstr ""
|
||||
|
||||
#. translators: %s: store registration URL.
|
||||
#: admin/views/page-catalog.php:89
|
||||
#: admin/views/page-catalog.php:109
|
||||
#, php-format
|
||||
msgid "Create your free account at the <a href=\"%s\" target=\"_blank\" rel=\"noopener noreferrer\">ROBOTSTXT store</a> to get your personal API key. The key links your subscriptions to this site and unlocks premium plugins and updates."
|
||||
msgstr ""
|
||||
|
||||
#. translators: %s: settings URL.
|
||||
#: admin/views/page-catalog.php:109
|
||||
#: admin/views/page-catalog.php:129
|
||||
#, php-format
|
||||
msgid "ROBOTSTXT Manager is not configured yet. <a href=\"%s\">Set the Store URL and API key</a> to see your plugin catalog."
|
||||
msgstr ""
|
||||
|
||||
#: admin/views/page-catalog.php:119
|
||||
#: admin/views/page-catalog.php:139
|
||||
msgid "No plugins were returned by the ROBOTSTXT store. Check the Store URL and API key on the Settings page, or click Refresh to try again."
|
||||
msgstr ""
|
||||
|
||||
#: admin/views/page-catalog.php:122
|
||||
#: admin/views/page-catalog.php:125
|
||||
#: admin/views/page-catalog.php:142
|
||||
#: admin/views/page-catalog.php:145
|
||||
msgid "Refresh catalog"
|
||||
msgstr ""
|
||||
|
||||
#: admin/views/page-catalog.php:131
|
||||
#: admin/views/page-catalog.php:151
|
||||
msgid "Plugin"
|
||||
msgstr ""
|
||||
|
||||
#: admin/views/page-catalog.php:132
|
||||
#: admin/views/page-catalog.php:152
|
||||
msgid "Version"
|
||||
msgstr ""
|
||||
|
||||
#: admin/views/page-catalog.php:133
|
||||
#: admin/views/page-catalog.php:153
|
||||
msgid "Requires WP"
|
||||
msgstr ""
|
||||
|
||||
#: admin/views/page-catalog.php:134
|
||||
#: admin/views/page-catalog.php:154
|
||||
msgid "Requires PHP"
|
||||
msgstr ""
|
||||
|
||||
#: admin/views/page-catalog.php:135
|
||||
#: admin/views/page-catalog.php:155
|
||||
msgid "Price"
|
||||
msgstr ""
|
||||
|
||||
#: admin/views/page-catalog.php:136
|
||||
#: admin/views/page-catalog.php:156
|
||||
msgid "Action"
|
||||
msgstr ""
|
||||
|
||||
#: admin/views/page-catalog.php:137
|
||||
#: admin/views/page-catalog.php:157
|
||||
msgid "Status"
|
||||
msgstr ""
|
||||
|
||||
#: admin/views/page-catalog.php:239
|
||||
#: admin/views/page-catalog.php:267
|
||||
msgid "Your site runs WordPress"
|
||||
msgstr ""
|
||||
|
||||
#: admin/views/page-catalog.php:251
|
||||
#: admin/views/page-catalog.php:279
|
||||
msgid "Your server runs PHP"
|
||||
msgstr ""
|
||||
|
||||
#. translators: %s: formatted price number.
|
||||
#: admin/views/page-catalog.php:264
|
||||
#: admin/views/page-catalog.php:292
|
||||
#, php-format
|
||||
msgid "€%s / year"
|
||||
msgstr ""
|
||||
|
||||
#. translators: %d: days remaining.
|
||||
#: admin/views/page-catalog.php:282
|
||||
#: admin/views/page-catalog.php:310
|
||||
#, php-format
|
||||
msgid "Subscribed — %d days left"
|
||||
msgstr ""
|
||||
|
||||
#: admin/views/page-catalog.php:286
|
||||
#: admin/views/page-catalog.php:314
|
||||
#: admin/views/page-catalog.php:340
|
||||
msgid "Subscribed"
|
||||
msgstr ""
|
||||
|
||||
#: admin/views/page-catalog.php:289
|
||||
#: admin/views/page-catalog.php:317
|
||||
msgid "Payment failed"
|
||||
msgstr ""
|
||||
|
||||
#: admin/views/page-catalog.php:291
|
||||
#: admin/views/page-catalog.php:319
|
||||
msgid "Cancelled"
|
||||
msgstr ""
|
||||
|
||||
#: admin/views/page-catalog.php:293
|
||||
#: admin/views/page-catalog.php:321
|
||||
msgid "Expired"
|
||||
msgstr ""
|
||||
|
||||
#: admin/views/page-catalog.php:303
|
||||
#: admin/views/page-catalog.php:350
|
||||
msgid "Free"
|
||||
msgstr ""
|
||||
|
||||
#: admin/views/page-catalog.php:309
|
||||
#: admin/views/page-catalog.php:356
|
||||
msgid "Incompatible"
|
||||
msgstr ""
|
||||
|
||||
#: admin/views/page-catalog.php:312
|
||||
#: admin/views/page-catalog.php:359
|
||||
msgid "Buy"
|
||||
msgstr ""
|
||||
|
||||
#: admin/views/page-catalog.php:317
|
||||
#: admin/views/page-catalog.php:364
|
||||
msgid "Install"
|
||||
msgstr ""
|
||||
|
||||
#: admin/views/page-catalog.php:319
|
||||
#: admin/views/page-catalog.php:366
|
||||
msgid "Activate"
|
||||
msgstr ""
|
||||
|
||||
#: admin/views/page-catalog.php:321
|
||||
#: admin/views/page-catalog.php:368
|
||||
msgid "Update"
|
||||
msgstr ""
|
||||
|
||||
#: admin/views/page-catalog.php:327
|
||||
#: admin/views/page-catalog.php:374
|
||||
msgid "Not installed"
|
||||
msgstr ""
|
||||
|
||||
#: admin/views/page-catalog.php:329
|
||||
#: admin/views/page-catalog.php:376
|
||||
msgid "Installed (inactive)"
|
||||
msgstr ""
|
||||
|
||||
#. translators: 1: installed version, 2: security patch version.
|
||||
#: admin/views/page-catalog.php:381
|
||||
#, php-format
|
||||
msgid "Security update available (v%1$s → v%2$s)"
|
||||
msgstr ""
|
||||
|
||||
#. translators: 1: installed version, 2: available version.
|
||||
#: admin/views/page-catalog.php:334
|
||||
#: admin/views/page-catalog.php:390
|
||||
#, php-format
|
||||
msgid "Update available (v%1$s → v%2$s)"
|
||||
msgstr ""
|
||||
|
||||
#: admin/views/page-catalog.php:340
|
||||
#: admin/views/page-catalog.php:396
|
||||
msgid "Up to date"
|
||||
msgstr ""
|
||||
|
||||
#: admin/views/page-catalog.php:349
|
||||
#: admin/views/page-catalog.php:405
|
||||
msgid "Visit website"
|
||||
msgstr ""
|
||||
|
||||
#. translators: %s: plugin name.
|
||||
#: admin/views/page-catalog.php:349
|
||||
#: admin/views/page-catalog.php:405
|
||||
#, php-format
|
||||
msgid "(about %s)"
|
||||
msgstr ""
|
||||
|
||||
#. translators: %s: list of plugin slugs.
|
||||
#: admin/views/page-catalog.php:360
|
||||
#: admin/views/page-catalog.php:416
|
||||
#, php-format
|
||||
msgid "Requires: %s"
|
||||
msgstr ""
|
||||
|
||||
#: admin/views/page-catalog.php:374
|
||||
#: admin/views/page-catalog.php:430
|
||||
msgid "Support"
|
||||
msgstr ""
|
||||
|
||||
#: admin/views/page-catalog.php:376
|
||||
#: admin/views/page-catalog.php:432
|
||||
msgid "Need help with a ROBOTSTXT plugin? Visit the plugin's website (the link in its row above) for documentation and guides, or contact us through our website — we are happy to help."
|
||||
msgstr ""
|
||||
|
||||
#: admin/views/page-catalog.php:379
|
||||
#: admin/views/page-catalog.php:435
|
||||
msgid "Payments, and how it works"
|
||||
msgstr ""
|
||||
|
||||
#: admin/views/page-catalog.php:381
|
||||
#: admin/views/page-catalog.php:437
|
||||
msgid "Free plugins install instantly at no cost. Premium plugins are annual subscriptions: you pay once on our website and the subscription renews automatically every year until you cancel. You can cancel at any time from your ROBOTSTXT account page — access keeps working until the end of the paid period. All payments are processed securely by Mollie; we never see or store your card details."
|
||||
msgstr ""
|
||||
|
||||
#. translators: %d: number of incompatible plugins.
|
||||
#: admin/views/page-catalog.php:391
|
||||
#: admin/views/page-catalog.php:447
|
||||
#, php-format
|
||||
msgid "%d plugin in the catalog is not compatible with this site's WordPress or PHP version."
|
||||
msgid_plural "%d plugins in the catalog are not compatible with this site's WordPress or PHP version."
|
||||
|
|
@ -450,7 +476,7 @@ msgstr[0] ""
|
|||
msgstr[1] ""
|
||||
|
||||
#. translators: 1: local WP version, 2: local PHP version.
|
||||
#: admin/views/page-catalog.php:400
|
||||
#: admin/views/page-catalog.php:456
|
||||
#, php-format
|
||||
msgid "This site runs WordPress %1$s on PHP %2$s."
|
||||
msgstr ""
|
||||
|
|
@ -469,16 +495,16 @@ msgstr ""
|
|||
msgid "Could not reach Plugins Core: %s"
|
||||
msgstr ""
|
||||
|
||||
#: includes/class-robotstxt-manager-core-client.php:167
|
||||
msgid "Invalid API key. Please check your key and try again."
|
||||
msgstr ""
|
||||
|
||||
#. translators: %d: HTTP status code.
|
||||
#: includes/class-robotstxt-manager-core-client.php:168
|
||||
#: includes/class-robotstxt-manager-core-client.php:175
|
||||
#, php-format
|
||||
msgid "The store responded with HTTP %d. Check the Store URL and API key."
|
||||
msgstr ""
|
||||
|
||||
#: includes/class-robotstxt-manager-core-client.php:164
|
||||
msgid "Invalid API key. Please check your key and try again."
|
||||
msgstr ""
|
||||
|
||||
#: includes/class-robotstxt-manager-core-client.php:177
|
||||
#: includes/class-robotstxt-manager-core-client.php:184
|
||||
msgid "Connected."
|
||||
msgstr ""
|
||||
|
|
|
|||
31
readme.txt
31
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.5.0
|
||||
Stable tag: 1.9.1
|
||||
Requires PHP: 8.0
|
||||
Version: 1.5.0
|
||||
Version: 1.9.1
|
||||
License: GPL-3.0-or-later
|
||||
License URI: https://www.gnu.org/licenses/gpl-3.0.txt
|
||||
|
||||
|
|
@ -94,28 +94,27 @@ 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.5.0 =
|
||||
= 1.9.1 =
|
||||
|
||||
_Release date: 2026-08-18_
|
||||
_Release date: 2026-09-23_
|
||||
|
||||
* Fixed: API key not saving when the browser auto-fills the password field with the stored encrypted value.
|
||||
* Fixed: "Test connection" now validates the API key against an authenticated Core endpoint (`/me/subscriptions`) instead of the public catalog — invalid keys are correctly rejected.
|
||||
* Fixed: "Test connection" reads the key from the form field, so a key can be tested before saving.
|
||||
* Added: Validation errors now display on the settings page (missing `settings_errors()` call).
|
||||
* Added: Registration link in the API key field description.
|
||||
* Maintenance: dependency audit (no updates, no CVEs), full code and security review of the security-patch feature, compatibility floors re-verified (WordPress 4.4, PHP 8.0). No runtime changes.
|
||||
|
||||
= 1.4.1 =
|
||||
= 1.9.0 =
|
||||
|
||||
_Release date: 2026-08-17_
|
||||
_Release date: 2026-09-22_
|
||||
|
||||
* Housekeeping release: plugin identity now points at the ROBOTSTXT software site — Plugin URI and Update URI are `https://www.robotstxt.software/plugins/robotstxt-manager/`, Author URI is `https://www.robotstxt.software/`, and this readme's full-changelog link points to the same page.
|
||||
* Tooling aligned with the declared real floors: PHPCS `testVersion` and the preflight scan range now cover PHP 8.0-8.5 (was 7.4/8.4).
|
||||
* New: security-update notices (with Core 1.16.0+). When the store declares a security patch for the exact version your site runs, a red "Update now" notice appears on the Plugins screen and the Manager catalog, and the update installs only that patch — your site is never pushed across feature versions by a security fix.
|
||||
* The WordPress update badge, `wp plugin update`, and auto-updates also target the declared patch.
|
||||
|
||||
= 1.4.0 =
|
||||
= 1.8.1 =
|
||||
|
||||
_Release date: 2026-08-17_
|
||||
_Release date: 2026-09-22_
|
||||
|
||||
* Premium update URLs carry a short-lived download token (Core 1.9.0+) instead of the API key; automatic fallback on older Core.
|
||||
* 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).
|
||||
|
||||
= Previous versions =
|
||||
|
||||
|
|
|
|||
|
|
@ -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.5.0
|
||||
* Version: 1.9.1
|
||||
* Requires at least: 4.4
|
||||
* Requires PHP: 8.0
|
||||
* Update URI: https://www.robotstxt.software/plugins/robotstxt-manager/
|
||||
|
|
@ -13,6 +13,7 @@
|
|||
* License URI: https://www.gnu.org/licenses/gpl-3.0.html
|
||||
* Text Domain: robotstxt-manager
|
||||
* Domain Path: /languages
|
||||
* Network: true
|
||||
*
|
||||
* @package Robotstxt_Manager
|
||||
*/
|
||||
|
|
@ -22,7 +23,7 @@ if ( ! defined( 'ABSPATH' ) ) {
|
|||
}
|
||||
|
||||
/** Plugin version. */
|
||||
define( 'ROBOTSTXT_MANAGER_VERSION', '1.5.0' );
|
||||
define( 'ROBOTSTXT_MANAGER_VERSION', '1.9.1' );
|
||||
|
||||
/** Absolute path to the plugin directory, with trailing slash. */
|
||||
define( 'ROBOTSTXT_MANAGER_DIR', plugin_dir_path( __FILE__ ) );
|
||||
|
|
@ -33,6 +34,14 @@ define( 'ROBOTSTXT_MANAGER_URL', plugin_dir_url( __FILE__ ) );
|
|||
/** Plugin basename. */
|
||||
define( 'ROBOTSTXT_MANAGER_BASENAME', plugin_basename( __FILE__ ) );
|
||||
|
||||
// Presence flag for the ecosystem: other ROBOTSTXT plugins (Core, Mollie…)
|
||||
// check defined( 'ROBOTSTXT_MANAGER_NOTICED' ) to detect that Manager is
|
||||
// active without scanning the plugin list. Guarded so a double-load or a
|
||||
// conflicting definition elsewhere cannot raise a fatal error.
|
||||
if ( ! defined( 'ROBOTSTXT_MANAGER_NOTICED' ) ) {
|
||||
define( 'ROBOTSTXT_MANAGER_NOTICED', true );
|
||||
}
|
||||
|
||||
// Load Composer autoloader, with a manual fallback for environments where
|
||||
// composer install has not been run.
|
||||
if ( file_exists( ROBOTSTXT_MANAGER_DIR . 'vendor/autoload.php' ) ) {
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ if ( ! defined( 'WP_UNINSTALL_PLUGIN' ) ) {
|
|||
exit;
|
||||
}
|
||||
|
||||
if ( ! get_option( 'robotstxt_manager_delete_data_on_uninstall', false ) ) {
|
||||
if ( ! get_site_option( 'robotstxt_manager_delete_data_on_uninstall', false ) ) {
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -21,10 +21,11 @@ $option_keys = array(
|
|||
);
|
||||
|
||||
foreach ( $option_keys as $key ) {
|
||||
delete_option( $key );
|
||||
delete_site_option( $key );
|
||||
}
|
||||
|
||||
delete_transient( 'robotstxt_manager_catalog' );
|
||||
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.
|
||||
|
|
|
|||
Loading…
Reference in a new issue