add_action( 'admin_post_robotstxt_manager_install', $this, 'handle_install' ); $loader->add_action( 'admin_post_robotstxt_manager_activate', $this, 'handle_activate' ); $loader->add_action( 'admin_post_robotstxt_manager_update', $this, 'handle_update' ); } /** * Installs a plugin from the ROBOTSTXT store. * * @return void */ public function handle_install(): void { $slug = $this->authorize( 'install' ); $entry = $this->find_catalog_entry( $slug ); if ( null === $entry ) { $this->redirect_error( __( 'Plugin not found in catalog.', 'robotstxt-manager' ) ); } $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() ); } $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; } /** * Activates an installed plugin. * * @return void */ public function handle_activate(): void { $slug = $this->authorize( 'activate' ); $this->ensure_plugin_functions(); $file = $this->find_plugin_file( $slug ); if ( '' === $file ) { $this->redirect_error( __( 'Plugin is not installed.', 'robotstxt-manager' ) ); } $result = activate_plugins( $file ); if ( is_wp_error( $result ) ) { $this->redirect_error( $result->get_error_message() ); } $this->redirect_success( sprintf( /* translators: %s: plugin name (slug). */ __( '%s activated.', 'robotstxt-manager' ), $slug ) ); } /** * 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 */ public function handle_update(): void { $slug = $this->authorize( 'update' ); $this->ensure_plugin_functions(); $file = $this->find_plugin_file( $slug ); if ( '' === $file ) { $this->redirect_error( __( 'Plugin is not installed.', 'robotstxt-manager' ) ); } $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() ); } $this->redirect_success( sprintf( /* translators: %s: plugin name (slug). */ __( '%s updated.', 'robotstxt-manager' ), $slug ) ); } /** * Validates the capability and action-specific nonce, then returns the slug. * * @param string $action One of 'install', 'activate', 'update'. * * @return string The sanitized plugin slug. */ private function authorize( string $action ): string { if ( ! current_user_can( is_multisite() ? 'manage_network_options' : 'manage_options' ) ) { wp_die( esc_html__( 'Insufficient permissions.', 'robotstxt-manager' ) ); } $raw_slug = ''; if ( isset( $_GET['slug'] ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- nonce verified below; slug only read after. $unslashed = wp_unslash( $_GET['slug'] ); // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- sanitized below. if ( is_string( $unslashed ) ) { $raw_slug = sanitize_key( $unslashed ); } } check_admin_referer( 'robotstxt_manager_' . $action . '_' . $raw_slug ); return $raw_slug; } /** * Finds a catalog entry by slug. * * @param string $slug Plugin slug. * * @return array|null The entry, or null when not found. */ private function find_catalog_entry( string $slug ): ?array { $client = Robotstxt_Manager_Core_Client::from_options(); if ( ! $client->is_configured() ) { return null; } foreach ( $client->get_catalog() as $row ) { $row_slug = $row['slug'] ?? ''; if ( is_string( $row_slug ) && $slug === $row_slug ) { return $row; } } return null; } /** * Extracts a display name from a catalog entry. * * @param array|null $entry Catalog entry (null tolerated). * @param string $slug Fallback slug. * * @return string The plugin name. */ private function entry_name( ?array $entry, string $slug ): string { $raw_name = ( null !== $entry ) ? ( $entry['name'] ?? '' ) : ''; $name = is_string( $raw_name ) ? $raw_name : ''; return '' !== $name ? $name : $slug; } /** * Resolves a plugin slug to its installed plugin file. * * @param string $slug Plugin slug (directory name). * * @return string Plugin file ("slug/file.php") or '' when not installed. */ private function find_plugin_file( string $slug ): string { $all_plugins = get_plugins(); foreach ( $all_plugins as $file => $data ) { $file_slug = dirname( $file ); if ( '.' === $file_slug ) { $file_slug = basename( $file, '.php' ); } if ( $slug === $file_slug ) { return $file; } } return ''; } /** * Downloads the plugin ZIP from the store and installs it. * * @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, string $security_version = '' ) { $client = Robotstxt_Manager_Core_Client::from_options(); if ( ! $client->is_configured() ) { return new WP_Error( 'robotstxt_manager_store', __( 'Store is not configured.', 'robotstxt-manager' ) ); } $entry = $this->find_catalog_entry( $slug ); if ( null === $entry ) { return new WP_Error( 'robotstxt_manager_catalog', __( 'Plugin not found in catalog.', 'robotstxt-manager' ) ); } $raw_kind = $entry['type'] ?? 'free'; $kind = is_string( $raw_kind ) ? $raw_kind : 'free'; $raw_dl = $entry['download_url'] ?? ''; $dl_url = is_string( $raw_dl ) ? $raw_dl : ''; $is_free = 'premium' !== $kind; // 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), 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 ); 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' ); if ( ! $tmp_file ) { return new WP_Error( 'robotstxt_manager_temp', __( 'Could not create a temporary file for download.', 'robotstxt-manager' ) ); } $headers = array(); if ( $use_download_endpoint ) { $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 ) { $headers = array( 'Authorization' => 'Bearer ' . $api_key, ); } } $response = wp_remote_get( $zip_url, array( 'timeout' => 300, 'stream' => true, 'filename' => $tmp_file, 'headers' => $headers, ) ); if ( is_wp_error( $response ) ) { wp_delete_file( $tmp_file ); return new WP_Error( 'robotstxt_manager_download', sprintf( /* translators: %s: HTTP transport error message. */ __( 'Download failed: %s', 'robotstxt-manager' ), $response->get_error_message() ) ); } $code = (int) wp_remote_retrieve_response_code( $response ); 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', '' !== $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 ) ); } 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( // 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, ) ); wp_delete_file( $tmp_file ); if ( true !== $result ) { $detail = ( $result instanceof WP_Error ) ? $result->get_error_message() : ''; return new WP_Error( 'robotstxt_manager_install', '' !== $detail ? sprintf( /* translators: %s: upgrader error message. */ __( 'Installation failed: %s', 'robotstxt-manager' ), $detail ) : __( 'Installation failed.', 'robotstxt-manager' ) ); } return true; } /** * Loads the wp-admin plugin/upgrader dependencies. * * @return void */ private function ensure_plugin_functions(): void { if ( ! function_exists( 'get_plugins' ) ) { require_once ABSPATH . 'wp-admin/includes/plugin.php'; } 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'; } } /** * Redirects back to the catalog page with a success notice. * * @param string $message Notice text. * * @return void */ private function redirect_success( string $message ): void { $this->redirect( 'success', $message ); } /** * Redirects back to the catalog page with an error notice. * * @param string $message Notice text. * * @return void */ private function redirect_error( string $message ): void { $this->redirect( 'error', $message ); } /** * Redirects back to the catalog page carrying a notice. * * @param string $result 'success' or 'error'. * @param string $message Notice text. * * @return void */ private function redirect( string $result, string $message ): void { wp_safe_redirect( add_query_arg( array( 'page' => Robotstxt_Manager_Admin::PAGE_SLUG, 'robotstxt_manager_result' => $result, 'robotstxt_manager_message' => rawurlencode( $message ), ), ( 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 ZIP archive. */ private function is_valid_zip( string $file ): bool { if ( ! file_exists( $file ) ) { return false; } $size = filesize( $file ); if ( false === $size || 0 >= $size ) { return false; } $magic = file_get_contents( $file, false, null, 0, 2 ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents -- reading 2 bytes of a local temp file. return is_string( $magic ) && 'PK' === substr( $magic, 0, 2 ); } }