';
+ 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, purges WordPress's update_plugins
* transient so native update badges re-evaluate immediately, and
- * redirects back to the page.
+ * 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(
@@ -96,13 +273,34 @@ class Robotstxt_Manager_Admin {
'page' => self::PAGE_SLUG,
'refreshed' => '1',
),
- admin_url( 'admin.php' )
+ ( is_multisite() ? network_admin_url( 'admin.php' ) : admin_url( 'admin.php' ) )
);
wp_safe_redirect( $redirect );
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 3ad4bb3..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
+ )
);
}
@@ -408,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 6e54620..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 '