This commit is contained in:
Javier Casares 2026-08-14 07:55:23 +00:00
commit 681147e8e3
11 changed files with 567 additions and 25 deletions

View file

@ -0,0 +1,432 @@
<?php
/**
* Classic admin-post installer: install, activate, and update plugins from the ROBOTSTXT store.
*
* @package Robotstxt_Manager
*/
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
/**
* Class Robotstxt_Manager_Installer
*
* Handles three admin-post actions (`robotstxt_manager_install`,
* `robotstxt_manager_activate`, `robotstxt_manager_update`). Each runs on a
* full page load, then redirects back to the catalog page with a classic
* admin notice (success/error) rendered from redirect query arguments.
*
* Downloads resolve through the remote Core catalog: free plugins with a
* public download URL are fetched directly; everything else goes through
* Core's authenticated `/download` endpoint, which accepts the account-level
* API key as a Bearer token (Core 1.4.0+).
*/
class Robotstxt_Manager_Installer {
/**
* Registers all hooks via the loader.
*
* @param Robotstxt_Manager_Loader $loader The plugin hook loader.
*
* @return void
*/
public function register( Robotstxt_Manager_Loader $loader ): void {
$loader->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();
$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
)
);
}
/**
* 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.
*
* @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' ) );
}
$result = $this->download_and_install( $slug, true );
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( '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<string,mixed>|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<string,mixed>|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).
*
* @return true|WP_Error True on success.
*/
private function download_and_install( string $slug, bool $overwrite = false ) {
$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).
$use_download_endpoint = ! ( $is_free && '' !== $dl_url );
$zip_url = $use_download_endpoint
? $client->get_store_url() . '/wp-json/robotstxt-core/v1/plugins/' . rawurlencode( $slug ) . '/download'
: $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_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 );
return new WP_Error(
'robotstxt_manager_http',
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(
'overwrite' => $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' ) ) {
require_once ABSPATH . 'wp-admin/includes/class-wp-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 ),
),
admin_url( 'admin.php' )
)
);
exit;
}
/**
* Validates a downloaded archive: must exist, be non-empty, and start
* with the ZIP magic bytes "PK".
*
* @param string $file Absolute path to the downloaded file.
*
* @return bool True when the file looks like a valid ZIP archive.
*/
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 );
}
}