diff --git a/admin/class-robotstxt-manager-admin.php b/admin/class-robotstxt-manager-admin.php index a569212..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,46 +64,243 @@ 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' ) ); } - $client = Robotstxt_Manager_Core_Client::from_options(); - $catalog = $client->get_catalog(); - $local = $this->resolve_local_state( $catalog ); + $client = Robotstxt_Manager_Core_Client::from_options(); + $catalog = $client->get_catalog(); + $local = $this->resolve_local_state( $catalog ); + $subscriptions = $client->get_subscriptions(); + + // Admin notices for subscriptions needing attention. + $manager_notices = $this->build_subscription_notices( $catalog, $subscriptions ); + $manager_subscriptions = $subscriptions; + $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. + * + * - payment_failed: persistent warning per plugin. + * - expiring within 14 days: per-plugin warning. + * - expired while the plugin is still installed+active: per-plugin warning. + * + * @param list> $catalog Catalog entries. + * @param array> $subscriptions Rows keyed by slug. + * + * @return list + */ + private function build_subscription_notices( array $catalog, array $subscriptions ): array { + $notices = array(); + $local = $this->resolve_local_state( $catalog ); + + foreach ( $subscriptions as $slug => $sub ) { + $raw_status = $sub['status'] ?? ''; + $raw_expires_at = $sub['expires_at'] ?? ''; + $status = is_string( $raw_status ) ? $raw_status : ''; + $expires_at = is_string( $raw_expires_at ) ? $raw_expires_at : ''; + + if ( 'payment_failed' === $status ) { + $notices[] = array( + 'type' => 'warning', + /* translators: %s: plugin slug. */ + 'message' => sprintf( __( 'The payment for %s failed. Update your payment method from your ROBOTSTXT account page to keep access.', 'robotstxt-manager' ), $slug ), + ); + continue; + } + + if ( 'expired' === $status ) { + $state = $local[ $slug ] ?? array(); + if ( ! empty( $state['active'] ) ) { + $notices[] = array( + 'type' => 'warning', + /* translators: %s: plugin slug. */ + 'message' => sprintf( __( '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.', 'robotstxt-manager' ), $slug ), + ); + } + continue; + } + + if ( 'active' === $status && '' !== $expires_at ) { + $days = (int) floor( ( (int) strtotime( $expires_at ) - time() ) / DAY_IN_SECONDS ); + + if ( $days >= 0 && $days <= 14 ) { + $notices[] = array( + 'type' => 'warning', + /* translators: 1: plugin slug, 2: days remaining. */ + 'message' => sprintf( _n( 'The subscription for %1$s expires in %2$d day.', 'The subscription for %1$s expires in %2$d days.', $days, 'robotstxt-manager' ), $slug, $days ), + ); + } + } + } + + return $notices; + } + /** * Handles the "Refresh catalog" admin-post action. * - * Clears the cached catalog response and redirects back to the page. + * Clears the cached catalog response, purges WordPress's update_plugins + * transient so native update badges re-evaluate immediately, and + * redirects back to the page. Rate-limited to 6 refreshes per minute + * per user so a stuck browser cannot hammer the store. * * @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_site_transient( $bucket ); + $hits = is_numeric( $hits_raw ) ? (int) $hits_raw : 0; + + if ( $hits >= 6 ) { + $this->redirect_refresh_error(); + } + + set_site_transient( $bucket, $hits + 1, MINUTE_IN_SECONDS ); + $client = Robotstxt_Manager_Core_Client::from_options(); $client->clear_catalog_cache(); + $client->clear_subscriptions_cache(); + delete_site_transient( 'update_plugins' ); $redirect = add_query_arg( array( '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 ); exit; } + /** + * Redirects back to the catalog page with a rate-limit error notice. + * + * @return void + */ + private function redirect_refresh_error(): void { + wp_safe_redirect( + add_query_arg( + array( + 'page' => self::PAGE_SLUG, + 'robotstxt_manager_result' => 'error', + 'robotstxt_manager_message' => rawurlencode( + __( 'Too many refreshes. Please wait a minute before refreshing again.', 'robotstxt-manager' ) + ), + ), + ( is_multisite() ? network_admin_url( 'admin.php' ) : admin_url( 'admin.php' ) ) + ) + ); + exit; + } + /** * Builds a nonce-protected admin-post action URL for a plugin row. * diff --git a/admin/class-robotstxt-manager-installer.php b/admin/class-robotstxt-manager-installer.php index e21be60..b0fd1ba 100644 --- a/admin/class-robotstxt-manager-installer.php +++ b/admin/class-robotstxt-manager-installer.php @@ -54,19 +54,297 @@ class Robotstxt_Manager_Installer { $name = $this->entry_name( $entry, $slug ); $this->ensure_plugin_functions(); + // Install missing dependencies first (ecosystem catalog plugins via + // the store, WordPress.org plugins via their repository ZIPs). + $deps_installed = $this->install_dependencies( $slug ); + + if ( is_wp_error( $deps_installed ) ) { + $this->redirect_error( $deps_installed->get_error_message() ); + } + $result = $this->download_and_install( $slug ); if ( is_wp_error( $result ) ) { $this->redirect_error( $result->get_error_message() ); } - $this->redirect_success( - sprintf( - /* translators: %s: plugin name. */ - __( '%s installed. Activate it from the list below.', 'robotstxt-manager' ), - $name + $message = sprintf( + /* translators: %s: plugin name. */ + __( '%s installed. Activate it from the list below.', 'robotstxt-manager' ), + $name + ); + + if ( is_string( $deps_installed ) && '' !== $deps_installed ) { + $message .= ' ' . $deps_installed; + } + + $this->redirect_success( $message ); + } + + /** + * Installs the plugin's missing dependencies, dependencies first. + * + * Each dependency slug is resolved either against the store catalog + * (installed through the store's download endpoint, like any catalog + * plugin) or, when not in the catalog, against WordPress.org (installed + * from the repository's plugin ZIP). + * + * @param string $slug Plugin slug being installed. + * + * @return string|WP_Error Installed-dependency names for the notice, '' when none were needed. + */ + private function install_dependencies( string $slug ) { + $deps = $this->dependencies_for( $slug ); + + if ( array() === $deps ) { + return ''; + } + + $installed_names = array(); + + foreach ( $deps as $dep_slug ) { + if ( '' !== $this->find_plugin_file( $dep_slug ) ) { + continue; // Already installed. + } + + $result = $this->install_one_dependency( $dep_slug ); + + if ( is_wp_error( $result ) ) { + /* translators: %s: dependency slug. */ + $detail = sprintf( __( 'Could not install the required plugin %s.', 'robotstxt-manager' ), $dep_slug ); + + return new WP_Error( + 'robotstxt_manager_dependency', + $detail . ' ' . $result->get_error_message() + ); + } + + $installed_names[] = $result; + } + + if ( array() === $installed_names ) { + return ''; + } + + /* translators: %s: list of installed dependency names. */ + return sprintf( __( 'Installed required plugins: %s.', 'robotstxt-manager' ), implode( ', ', $installed_names ) ); + } + + /** + * Installs a single dependency: catalog plugin via the store, else wp.org. + * + * @param string $dep_slug Dependency slug. + * + * @return string|WP_Error The dependency's display name on success. + */ + private function install_one_dependency( string $dep_slug ) { + if ( null !== $this->find_catalog_entry( $dep_slug ) ) { + return $this->install_via_store( $dep_slug ); + } + + // Not in the catalog — treat as a WordPress.org plugin. + return $this->install_via_wordpress_org( $dep_slug ); + } + + /** + * Installs a dependency that exists in the store catalog. + * + * @param string $dep_slug Dependency slug. + * + * @return string|WP_Error Dependency name on success. + */ + protected function install_via_store( string $dep_slug ) { + $result = $this->download_and_install( $dep_slug ); + + if ( is_wp_error( $result ) ) { + return $result; + } + + $entry = $this->find_catalog_entry( $dep_slug ); + + return $this->entry_name( is_array( $entry ) ? $entry : null, $dep_slug ); + } + + /** + * Installs a dependency that is not in the catalog from WordPress.org. + * + * @param string $dep_slug wp.org plugin slug. + * + * @return string|WP_Error Plugin name on success. + */ + protected function install_via_wordpress_org( string $dep_slug ) { + return $this->install_wporg_zip( $dep_slug ); + } + + /** + * Resolves a plugin's dependency slugs (parsed, deduplicated, deps-first + * order, self-references dropped). + * + * @param string $slug Plugin slug. + * + * @return list Dependency slugs. + */ + private function dependencies_for( string $slug ): array { + $all = array( $slug => true ); + $queue = array( $slug ); + $ordered = array(); + + while ( ! empty( $queue ) ) { + $current = array_shift( $queue ); + + foreach ( $this->raw_dependencies_of( $current ) as $dep ) { + if ( isset( $all[ $dep ] ) ) { + continue; // Already seen: self, duplicate, or cycle. + } + + $all[ $dep ] = true; + $ordered[] = $dep; + $queue[] = $dep; + } + } + + // Dependencies discovered later are deeper — reverse so dependencies + // of dependencies install first. + return array_reverse( $ordered ); + } + + /** + * Reads the raw requires-plugins list of a catalog entry. + * + * @param string $slug Plugin slug. + * + * @return list Dependency slugs (unresolved). + */ + private function raw_dependencies_of( string $slug ): array { + $entry = $this->find_catalog_entry( $slug ); + + if ( null === $entry ) { + return array(); // wp.org plugin: dependencies come from its own headers on install. + } + + $raw = $entry['requires_plugins'] ?? ''; + $raw = is_string( $raw ) ? $raw : ''; + + if ( '' === $raw ) { + return array(); + } + + $slugs = array(); + foreach ( explode( ',', $raw ) as $part ) { + $dep = sanitize_key( trim( $part ) ); + if ( '' !== $dep ) { + $slugs[ $dep ] = true; + } + } + + return array_keys( $slugs ); + } + + /** + * Installs a WordPress.org plugin by slug via plugins_api + Plugin_Upgrader. + * + * @param string $slug wp.org plugin slug. + * + * @return string|WP_Error Plugin name on success. + */ + private function install_wporg_zip( string $slug ) { + $this->ensure_plugin_functions(); + + if ( ! function_exists( 'plugins_api' ) ) { + require_once ABSPATH . 'wp-admin/includes/plugin-install.php'; + } + + $api = plugins_api( + 'plugin_information', + array( + 'slug' => $slug, + 'fields' => array( + 'sections' => false, + 'versions' => false, + 'downloaded' => false, + 'rating' => false, + ), ) ); + + if ( is_wp_error( $api ) ) { + /* translators: %s: error message from WordPress.org. */ + return new WP_Error( 'robotstxt_manager_wporg', sprintf( __( 'WordPress.org lookup failed: %s', 'robotstxt-manager' ), $api->get_error_message() ) ); + } + + $download_link = is_object( $api ) && isset( $api->download_link ) && is_string( $api->download_link ) + ? $api->download_link + : ''; + + if ( '' === $download_link ) { + /* translators: %s: plugin slug. */ + return new WP_Error( 'robotstxt_manager_wporg', sprintf( __( 'No download found on WordPress.org for %s.', 'robotstxt-manager' ), $slug ) ); + } + + $tmp_file = wp_tempnam( $slug . '.zip' ); + + if ( ! $tmp_file ) { + return new WP_Error( 'robotstxt_manager_temp', __( 'Could not create a temporary file for download.', 'robotstxt-manager' ) ); + } + + $response = wp_remote_get( + $download_link, + array( + 'timeout' => 300, + 'stream' => true, + 'filename' => $tmp_file, + ) + ); + + if ( is_wp_error( $response ) ) { + wp_delete_file( $tmp_file ); + + /* translators: %s: HTTP transport error message. */ + return new WP_Error( 'robotstxt_manager_download', sprintf( __( 'Download failed: %s', 'robotstxt-manager' ), $response->get_error_message() ) ); + } + + if ( 200 !== (int) wp_remote_retrieve_response_code( $response ) ) { + wp_delete_file( $tmp_file ); + + /* translators: %d: HTTP status code. */ + return new WP_Error( 'robotstxt_manager_http', sprintf( __( 'Download failed (HTTP %d).', 'robotstxt-manager' ), (int) wp_remote_retrieve_response_code( $response ) ) ); + } + + if ( ! $this->is_valid_zip( $tmp_file ) ) { + wp_delete_file( $tmp_file ); + + return new WP_Error( 'robotstxt_manager_zip', __( 'The store returned an invalid file.', 'robotstxt-manager' ) ); + } + + $upgrader = new Plugin_Upgrader( new Automatic_Upgrader_Skin() ); + $result = $upgrader->install( + $tmp_file, + array( + 'overwrite' => false, + 'overwrite_package' => false, + ) + ); + + wp_delete_file( $tmp_file ); + + if ( true !== $result ) { + $detail = ( $result instanceof WP_Error ) ? $result->get_error_message() : ''; + + /* translators: %s: upgrader error message. */ + return new WP_Error( 'robotstxt_manager_install', '' !== $detail ? sprintf( __( 'Installation failed: %s', 'robotstxt-manager' ), $detail ) : __( 'Installation failed.', 'robotstxt-manager' ) ); + } + + $file = $this->find_plugin_file( $slug ); + + if ( '' === $file ) { + /* translators: %s: plugin slug. */ + return new WP_Error( 'robotstxt_manager_wporg', sprintf( __( 'The downloaded plugin for %s does not have the expected folder structure.', 'robotstxt-manager' ), $slug ) ); + } + + $data = get_plugins(); + $raw_name = isset( $data[ $file ]['Name'] ) && is_string( $data[ $file ]['Name'] ) ? $data[ $file ]['Name'] : ''; + + return '' !== $raw_name ? $raw_name : $slug; } /** @@ -101,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 */ @@ -116,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() ); @@ -139,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' ) ); } @@ -224,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() ) { @@ -249,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' ); @@ -265,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 ) { @@ -303,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 + ) ); } @@ -323,7 +643,11 @@ class Robotstxt_Manager_Installer { $result = $upgrader->install( $tmp_file, array( - 'overwrite' => $overwrite, + // Option key differs across WP versions: 'overwrite' (5.5-era) + // vs 'overwrite_package' (current). Pass both; the unused key + // is ignored by wp_parse_args(). + 'overwrite' => $overwrite, + 'overwrite_package' => $overwrite, ) ); @@ -358,7 +682,11 @@ class Robotstxt_Manager_Installer { } if ( ! class_exists( 'Plugin_Upgrader' ) ) { + // class-wp-upgrader.php bundles WP_Upgrader + the skins (incl. + // Automatic_Upgrader_Skin), but Plugin_Upgrader itself lives in + // its own file since the WP 5.3 split — both are required. require_once ABSPATH . 'wp-admin/includes/class-wp-upgrader.php'; + require_once ABSPATH . 'wp-admin/includes/class-plugin-upgrader.php'; } } @@ -400,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 1c2d7ac..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 issued by the ROBOTSTXT store. Required to authenticate catalog and subscription requests. The value is 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 '