diff --git a/admin/class-robotstxt-manager-admin.php b/admin/class-robotstxt-manager-admin.php
index b1e8a89..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,264 @@ 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();
+ $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 );
- require_once ROBOTSTXT_MANAGER_DIR . 'admin/views/page-catalog.php';
+ 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.
+ *
+ * @param string $action One of 'install', 'activate', 'update'.
+ * @param string $slug Plugin slug.
+ *
+ * @return string The action URL.
+ */
+ public static function action_url( string $action, string $slug ): string {
+ return wp_nonce_url(
+ add_query_arg(
+ array(
+ 'action' => 'robotstxt_manager_' . $action,
+ 'slug' => $slug,
+ ),
+ admin_url( 'admin-post.php' )
+ ),
+ 'robotstxt_manager_' . $action . '_' . $slug
+ );
+ }
+
/**
* Resolves local install state for every catalog entry.
*
diff --git a/admin/class-robotstxt-manager-installer.php b/admin/class-robotstxt-manager-installer.php
new file mode 100644
index 0000000..b0fd1ba
--- /dev/null
+++ b/admin/class-robotstxt-manager-installer.php
@@ -0,0 +1,772 @@
+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 );
+ }
+}
diff --git a/admin/class-robotstxt-manager-settings.php b/admin/class-robotstxt-manager-settings.php
index aba1c50..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' )
);
@@ -83,7 +86,7 @@ class Robotstxt_Manager_Settings {
array(
'type' => 'string',
'sanitize_callback' => 'esc_url_raw',
- 'default' => 'https://plugins.robotstxt.es',
+ 'default' => 'https://www.robotstxt.software',
)
);
@@ -203,24 +206,87 @@ 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://plugins.robotstxt.es' );
- $value = is_string( $raw ) ? $raw : 'https://plugins.robotstxt.es';
+ $raw = get_site_option( 'robotstxt_manager_store_url', 'https://www.robotstxt.software' );
+ $value = is_string( $raw ) ? $raw : 'https://www.robotstxt.software';
printf(
- ' ',
+ ' ',
esc_attr( $value )
);
echo '' . esc_html__( 'Base URL of the remote Plugins Core installation that this site will pull the plugin catalog from.', 'robotstxt-manager' ) . '
';
@@ -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 '';
printf(
' ',
@@ -329,11 +404,34 @@ 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 : '';
}
- delete_transient( 'robotstxt_manager_catalog' );
+ // If the input is already encrypted (v2: prefix), it means the browser
+ // auto-filled the password field with the stored encrypted value.
+ // Return it as-is (already encrypted) rather than re-encrypting or
+ // trying to read the option (which may not be saved yet in the WP flow).
+ if ( str_starts_with( $plain, 'v2:' ) ) {
+ return $plain;
+ }
+
+ // Account keys are UUIDs issued by the store; reject anything that
+ // cannot be one rather than storing a mangled key that only fails
+ // later at connection time.
+ if ( ! preg_match( '/^[a-f0-9][a-f0-9-]{7,126}$/i', $plain ) ) {
+ add_settings_error(
+ 'robotstxt_manager_api_key',
+ 'invalid_api_key',
+ esc_html__( 'The API key format is invalid. Copy the full key from your ROBOTSTXT account page.', 'robotstxt-manager' )
+ );
+
+ $raw = get_site_option( 'robotstxt_manager_api_key', '' );
+ return is_string( $raw ) ? $raw : '';
+ }
+
+ delete_site_transient( 'robotstxt_manager_catalog' );
+ delete_site_transient( 'robotstxt_manager_subscriptions' );
return Robotstxt_Manager_Encryption::encrypt( $plain );
}
@@ -360,11 +458,29 @@ 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' ) ) );
}
- $client = Robotstxt_Manager_Core_Client::from_options();
+ // Allow testing a key from the form field (not yet saved) by passing it in the request.
+ $input_key = '';
+ if ( isset( $_POST['robotstxt_manager_api_key'] ) ) {
+ $unslashed = wp_unslash( $_POST['robotstxt_manager_api_key'] ); // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- sanitized below.
+ if ( is_string( $unslashed ) ) {
+ $input_key = sanitize_text_field( $unslashed );
+ }
+ }
+
+ $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.
+ if ( '' !== $input_key ) {
+ $client = new Robotstxt_Manager_Core_Client( $store_url, $input_key );
+ } else {
+ $client = Robotstxt_Manager_Core_Client::from_options();
+ }
+
$result = $client->test_connection();
if ( $result['ok'] ) {
@@ -386,12 +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_site_option( 'robotstxt_manager_api_key' );
+ delete_site_transient( 'robotstxt_manager_catalog' );
+ delete_site_transient( 'robotstxt_manager_subscriptions' );
wp_send_json_success(
array(
diff --git a/admin/js/robotstxt-manager-settings.js b/admin/js/robotstxt-manager-settings.js
index c0f465a..f11d499 100644
--- a/admin/js/robotstxt-manager-settings.js
+++ b/admin/js/robotstxt-manager-settings.js
@@ -20,11 +20,14 @@
$btn.prop( 'disabled', true ).text( RobotstxtManagerSettings.i18n.testing );
$result.text( '' ).css( 'color', '' );
+ var apiKey = $( '#robotstxt_manager_api_key' ).val();
+
$.post(
RobotstxtManagerSettings.ajaxUrl,
{
action: 'robotstxt_manager_test_connection',
nonce: RobotstxtManagerSettings.nonce,
+ robotstxt_manager_api_key: apiKey,
},
function ( response ) {
if ( response.success ) {
diff --git a/admin/views/page-catalog.php b/admin/views/page-catalog.php
index 89738a0..68ae63a 100644
--- a/admin/views/page-catalog.php
+++ b/admin/views/page-catalog.php
@@ -2,10 +2,14 @@
/**
* Admin page view: ROBOTSTXT Plugins catalog.
*
- * Available variables:
- * $catalog list> Catalog entries from Core.
- * $local array
- * Local install state, keyed by slug.
+ * Catalog table (Plugin · Version · Requires WP · Requires PHP · Price ·
+ * Action · Status) with an always-open detail row under each plugin
+ * (website link + description), an intro paragraph, and Support /
+ * Payments sections below the table.
+ *
+ * Also available:
+ * $manager_subscriptions array
+ * Account subscriptions keyed by slug (may be empty).
*
* @package Robotstxt_Manager
*/
@@ -19,6 +23,11 @@ if ( ! defined( 'ABSPATH' ) ) {
*
* @var list> $catalog
* @var array $local
+ * @var array> $manager_subscriptions
+ * @var list $manager_notices
+ * @var list $manager_security_updates
+ * @var bool $manager_has_api_key
+ * @var string $manager_store_url
*/
$client_configured = Robotstxt_Manager_Core_Client::from_options()->is_configured();
@@ -31,6 +40,24 @@ $refresh_url = add_query_arg(
$refresh_url = wp_nonce_url( $refresh_url, 'robotstxt_manager_refresh_catalog' );
$refreshed = isset( $_GET['refreshed'] ) && '1' === $_GET['refreshed']; // phpcs:ignore WordPress.Security.NonceVerification.Recommended
+// Action notice from an install/activate/update redirect.
+$notice_result = '';
+$notice_message = '';
+
+if ( isset( $_GET['robotstxt_manager_result'] ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- display-only, set by this plugin's own redirects.
+ $raw_result = wp_unslash( $_GET['robotstxt_manager_result'] ); // phpcs:ignore WordPress.Security.NonceVerification.Recommended,WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- sanitized on the next line.
+ if ( is_string( $raw_result ) ) {
+ $notice_result = sanitize_key( $raw_result );
+ }
+}
+
+if ( isset( $_GET['robotstxt_manager_message'] ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- display-only, set by this plugin's own redirects.
+ $raw_message = wp_unslash( $_GET['robotstxt_manager_message'] ); // phpcs:ignore WordPress.Security.NonceVerification.Recommended,WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- sanitized on the next line after decoding.
+ if ( is_string( $raw_message ) ) {
+ $notice_message = sanitize_text_field( rawurldecode( $raw_message ) );
+ }
+}
+
$local_wp_version = (string) get_bloginfo( 'version' );
$local_php_version = (string) PHP_VERSION;
$compat_warnings = 0;
@@ -39,10 +66,59 @@ $compat_warnings = 0;
+
+
+
+
+
+
+
+
+
+
+
+ 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( $manager_security['name'] ),
+ esc_html( $manager_security['installed'] ),
+ esc_html( $manager_security['patch'] ),
+ esc_url( Robotstxt_Manager_Admin::action_url( 'update', $manager_security['slug'] ) )
+ )
+ );
+ ?>
+
+
+
+
+
+
+
+ ROBOTSTXT store to get your personal API key. The key links your subscriptions to this site and unlocks premium plugins and updates.', 'robotstxt-manager' ),
+ esc_url( $manager_store_url . '/wp-login.php?action=register' )
+ )
+ );
+ ?>
+
+
+
+
+
+
+
+
@@ -51,7 +127,7 @@ $compat_warnings = 0;
sprintf(
/* translators: %s: settings URL. */
__( 'ROBOTSTXT Manager is not configured yet. Set the Store URL and API key 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 ) ) )
)
);
?>
@@ -73,12 +149,12 @@ $compat_warnings = 0;
-
-
+
-
+
+
@@ -90,8 +166,11 @@ $compat_warnings = 0;
$raw_cost = $entry['price_annual'] ?? 0;
$raw_ver = $entry['current_version'] ?? '';
$raw_url = $entry['page_url'] ?? '';
+ $raw_home = $entry['homepage'] ?? '';
+ $raw_desc = $entry['description'] ?? '';
$raw_rwp = $entry['requires_wp'] ?? '';
$raw_rphp = $entry['requires_php'] ?? '';
+ $raw_icon = $entry['icon_url'] ?? '';
$slug = is_string( $raw_slug ) ? $raw_slug : '';
$name = is_string( $raw_name ) ? $raw_name : $slug;
@@ -99,8 +178,45 @@ $compat_warnings = 0;
$cost = is_numeric( $raw_cost ) ? (float) ( $raw_cost + 0.0 ) : 0.0;
$remote_v = is_string( $raw_ver ) ? $raw_ver : '';
$page_url = is_string( $raw_url ) ? $raw_url : '';
+ $homepage = is_string( $raw_home ) ? $raw_home : '';
+ $desc = is_string( $raw_desc ) ? trim( $raw_desc ) : '';
$req_wp = is_string( $raw_rwp ) ? $raw_rwp : '';
$req_php = is_string( $raw_rphp ) ? $raw_rphp : '';
+ $icon_url = is_string( $raw_icon ) ? esc_url_raw( $raw_icon ) : '';
+
+ $website_url = '' !== $page_url ? $page_url : $homepage;
+ $has_detail = ( '' !== $website_url || '' !== $desc );
+
+ // Localised description: the admin user's locale first,
+ // falling back to the (English) default.
+ $raw_translations = $entry['description_translations'] ?? '';
+ $translations = is_array( $raw_translations ) ? $raw_translations : array();
+ $user_locale = function_exists( 'get_user_locale' ) ? get_user_locale() : get_locale();
+ $locale_text = $translations[ $user_locale ] ?? '';
+ $display_desc = is_string( $locale_text ) && '' !== trim( $locale_text )
+ ? trim( $locale_text )
+ : $desc;
+ $has_detail = ( '' !== $website_url || '' !== $desc || '' !== $display_desc );
+
+ // Subscription state: premium plugins are only usable
+ // (install/update) with an active or in-grace subscription.
+ $is_premium = ( $cost > 0.0 || 'premium' === $kind );
+ $sub_row = $manager_subscriptions[ $slug ] ?? null;
+ $raw_status = is_array( $sub_row ) ? ( $sub_row['status'] ?? '' ) : '';
+ $sub_status = is_string( $raw_status ) ? $raw_status : '';
+ $subscribed = in_array( $sub_status, array( 'active', 'payment_failed' ), true );
+
+ $raw_reqs = $entry['requires_plugins'] ?? '';
+ $req_slugs = array();
+ if ( is_string( $raw_reqs ) && '' !== trim( $raw_reqs ) ) {
+ foreach ( explode( ',', $raw_reqs ) as $part ) {
+ $dep = sanitize_key( trim( $part ) );
+ if ( '' !== $dep ) {
+ $req_slugs[] = $dep;
+ }
+ }
+ }
+ $has_detail = $has_detail || array() !== $req_slugs;
// Compatibility checks.
$wp_ok = '' === $req_wp || version_compare( $local_wp_version, $req_wp, '>=' );
@@ -121,27 +237,28 @@ $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' : '';
?>
-
-
-
-
+
+
+
+
+
+
-
-
- 0.0 ) {
- echo esc_html( sprintf( '€%s / year', number_format_i18n( $cost, 0 ) ) );
- } else {
- echo '—';
- }
- ?>
-
+
@@ -166,12 +283,106 @@ $compat_warnings = 0;
—
+
+ = 0 && $days_left <= 14 ) {
+ $sub_text = sprintf(
+ /* translators: %d: days remaining. */
+ __( 'Subscribed — %d days left', 'robotstxt-manager' ),
+ $days_left
+ );
+ } else {
+ $sub_text = __( 'Subscribed', 'robotstxt-manager' );
+ }
+ } elseif ( 'payment_failed' === $sub_status ) {
+ $sub_text = __( 'Payment failed', 'robotstxt-manager' );
+ } elseif ( 'cancelled' === $sub_status ) {
+ $sub_text = __( 'Cancelled', 'robotstxt-manager' );
+ } 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 ) {
+ echo '' . esc_html( $sub_text ) . ' ';
+ }
+ } else {
+ echo esc_html__( 'Free', 'robotstxt-manager' );
+ }
+ ?>
+
+
+
+
+
+
+
+
+ —
+
+
+
+
+
+
+
+
+
' . esc_html__( 'Not installed', 'robotstxt-manager' ) . '';
} elseif ( ! $l_active ) {
echo '' . esc_html__( 'Installed (inactive)', 'robotstxt-manager' ) . ' ';
+ } elseif ( '' !== $security_patch ) {
+ echo '' . 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
+ )
+ ) . ' ';
} elseif ( $update_available ) {
echo '' . esc_html(
sprintf(
@@ -186,24 +397,46 @@ $compat_warnings = 0;
}
?>
-
-
-
-
- 0.0 && '' !== $page_url ) : ?>
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
0 ) : ?>
@@ -237,4 +470,25 @@ $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; }
+.robotstxt-manager-detail-description { color: #50575e; }
+
+/* Plugin icons: 64x64, 1:1, beside the name. */
+.robotstxt-manager-plugin-icon {
+ display: inline-block;
+ vertical-align: middle;
+ width: 64px;
+ height: 64px;
+ margin-right: 10px;
+ border-radius: 4px;
+ object-fit: contain;
+ background: #fff;
+}
+.robotstxt-manager-plugin-icon--placeholder {
+ border: 1px solid #dcdcde;
+ box-sizing: border-box;
+}
diff --git a/admin/views/page-settings.php b/admin/views/page-settings.php
index 89b5d4a..7c518c9 100644
--- a/admin/views/page-settings.php
+++ b/admin/views/page-settings.php
@@ -11,9 +11,10 @@ if ( ! defined( 'ABSPATH' ) ) {
?>