diff --git a/admin/class-robotstxt-manager-admin.php b/admin/class-robotstxt-manager-admin.php index 1921cc6..ef4ef1a 100644 --- a/admin/class-robotstxt-manager-admin.php +++ b/admin/class-robotstxt-manager-admin.php @@ -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> $catalog Catalog entries. + * @param array $local Local state by slug. + * + * @return list + */ + 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 '

'; + echo wp_kses_post( + sprintf( + /* translators: 1: plugin name, 2: installed version, 3: patch version, 4: update URL. */ + __( 'Security update available: %1$s (v%2$s → v%3$s). Update now — 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 '

'; + } + } + /** * 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; diff --git a/admin/class-robotstxt-manager-installer.php b/admin/class-robotstxt-manager-installer.php index 2714e78..b0fd1ba 100644 --- a/admin/class-robotstxt-manager-installer.php +++ b/admin/class-robotstxt-manager-installer.php @@ -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 ) ) { diff --git a/admin/class-robotstxt-manager-settings.php b/admin/class-robotstxt-manager-settings.php index cddc28f..955b7c1 100644 --- a/admin/class-robotstxt-manager-settings.php +++ b/admin/class-robotstxt-manager-settings.php @@ -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 = ''; @@ -263,7 +329,16 @@ class Robotstxt_Manager_Settings { } echo '

'; } else { - echo '

' . esc_html__( 'Account-level API key from the ROBOTSTXT store (create your free account there to get one). Optional — the free catalog works without it — but required to link your subscriptions, install premium plugins, and receive their updates. Encrypted before storage.', 'robotstxt-manager' ) . '

'; + printf( + '

%s

', + wp_kses_post( + sprintf( + /* translators: %s: Registration URL. */ + __( 'Account-level API key from the ROBOTSTXT store (create your free account there to get one). Optional — the free catalog works without it — but required to link your subscriptions, install premium plugins, and receive their updates. Encrypted before storage.', 'robotstxt-manager' ), + esc_url( 'https://www.robotstxt.software/wp-login.php?action=register' ) + ) + ) + ); } // Action buttons. @@ -288,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( @@ -304,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 '