add_action( $menu_hook, $this, 'add_settings_page' ); $loader->add_action( 'admin_init', $this, 'register_settings' ); $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' ); } /** * Adds the Settings submenu under the Manager top-level menu. * * @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' ), $cap, self::PAGE_SLUG, array( $this, 'render_page' ) ); } /** * Registers settings, sections, and fields with the Settings API. * * @return void */ public function register_settings(): void { add_settings_section( 'robotstxt_manager_connection_section', esc_html__( 'Connection', 'robotstxt-manager' ), '__return_false', self::PAGE_SLUG ); register_setting( self::OPTION_GROUP, 'robotstxt_manager_store_url', array( 'type' => 'string', 'sanitize_callback' => 'esc_url_raw', 'default' => 'https://www.robotstxt.software', ) ); add_settings_field( 'robotstxt_manager_store_url', esc_html__( 'Store URL', 'robotstxt-manager' ), array( $this, 'render_field_store_url' ), self::PAGE_SLUG, 'robotstxt_manager_connection_section' ); register_setting( self::OPTION_GROUP, 'robotstxt_manager_api_key', array( 'type' => 'string', 'sanitize_callback' => array( $this, 'sanitize_api_key' ), 'default' => '', ) ); add_settings_field( 'robotstxt_manager_api_key', esc_html__( 'API Key', 'robotstxt-manager' ), array( $this, 'render_field_api_key' ), self::PAGE_SLUG, 'robotstxt_manager_connection_section' ); add_settings_section( 'robotstxt_manager_cache_section', esc_html__( 'Cache', 'robotstxt-manager' ), '__return_false', self::PAGE_SLUG ); register_setting( self::OPTION_GROUP, 'robotstxt_manager_cache_ttl_minutes', array( 'type' => 'integer', 'sanitize_callback' => array( $this, 'sanitize_cache_ttl' ), 'default' => 60, ) ); add_settings_field( 'robotstxt_manager_cache_ttl_minutes', esc_html__( 'Catalog Cache (minutes)', 'robotstxt-manager' ), array( $this, 'render_field_cache_ttl' ), self::PAGE_SLUG, 'robotstxt_manager_cache_section' ); register_setting( self::OPTION_GROUP, 'robotstxt_manager_delete_data_on_uninstall', array( 'type' => 'boolean', 'sanitize_callback' => 'rest_sanitize_boolean', 'default' => false, ) ); add_settings_field( 'robotstxt_manager_delete_data_on_uninstall', esc_html__( 'Data on Uninstall', 'robotstxt-manager' ), array( $this, 'render_field_delete_on_uninstall' ), self::PAGE_SLUG, 'robotstxt_manager_cache_section' ); } /** * Enqueues the settings page JS on the correct screen. * * @param string $hook_suffix The current admin page hook suffix. * * @return void */ public function enqueue_scripts( string $hook_suffix ): void { if ( ! str_contains( $hook_suffix, self::PAGE_SLUG ) ) { return; } wp_enqueue_script( 'robotstxt-manager-settings', ROBOTSTXT_MANAGER_URL . 'admin/js/robotstxt-manager-settings.js', array( 'jquery' ), ROBOTSTXT_MANAGER_VERSION, true ); wp_localize_script( 'robotstxt-manager-settings', 'RobotstxtManagerSettings', array( 'ajaxUrl' => admin_url( 'admin-ajax.php' ), 'nonce' => wp_create_nonce( 'robotstxt_manager_test_connection' ), 'deleteNonce' => wp_create_nonce( 'robotstxt_manager_delete_key' ), 'i18n' => array( 'testing' => __( 'Testing…', 'robotstxt-manager' ), 'testLabel' => __( 'Test connection', 'robotstxt-manager' ), 'deleting' => __( 'Deleting…', 'robotstxt-manager' ), 'deleteLabel' => __( 'Delete API key', 'robotstxt-manager' ), 'deleteConfirm' => __( 'Delete the stored API key? The catalog and subscription data will stop working until a new key is entered.', 'robotstxt-manager' ), 'deleted' => __( 'API key deleted. Save changes to persist.', 'robotstxt-manager' ), 'ajaxError' => __( 'An unexpected error occurred.', 'robotstxt-manager' ), ), ) ); } /** * Renders the settings page. * * @return void */ public function render_page(): void { 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' ) ); } $this->handle_form_submission(); 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 network settings page must process its own form. * * @return void */ private 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_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' ) . '
'; } /** * Renders the API key field. Always empty on render — the stored value * is never sent back to the browser. The masked last 4 chars of the * stored key are shown as a hint. * * @return void */ public function render_field_api_key(): void { $stored = get_site_option( 'robotstxt_manager_api_key', '' ); $has_key = is_string( $stored ) && '' !== $stored; $last4 = ''; if ( $has_key ) { $plain = Robotstxt_Manager_Encryption::decrypt( is_string( $stored ) ? $stored : '' ); // Only show last-4 if decryption produced a plausible key (36-char UUID). if ( '' !== $plain && 36 === strlen( $plain ) ) { $last4 = substr( $plain, -4 ); } } echo ''; if ( $has_key ) { echo '';
if ( '' !== $last4 ) {
echo wp_kses_post(
sprintf(
/* translators: %s: last 4 characters of the stored API key. */
__( 'A key is stored (last 4 characters: %s). Leave blank to keep the existing key; enter a new value to replace it.', 'robotstxt-manager' ),
esc_html( $last4 )
)
);
} else {
esc_html_e( 'A key is stored but could not be decoded. You can replace it by entering a new value above, or delete it with the button below.', 'robotstxt-manager' );
}
echo '
%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. echo ''; // Test connection: disabled when no key is stored. $test_disabled = $has_key ? '' : ' disabled'; printf( '', esc_attr( $test_disabled ), esc_html__( 'Test connection', 'robotstxt-manager' ) ); // Delete stored key: only shown when a key exists. if ( $has_key ) { echo ' '; } echo ''; echo '
'; } /** * Renders the cache TTL field. * * @return void */ public function render_field_cache_ttl(): void { $raw = get_site_option( 'robotstxt_manager_cache_ttl_minutes', 60 ); $value = is_numeric( $raw ) ? (int) $raw : 60; printf( '', absint( $value ) ); echo '' . esc_html__( 'How long the catalog response from Core is cached in a transient. Default: 60 minutes. Lower values refresh more often at the cost of more requests to Core. Maximum: 1440 (24 hours).', 'robotstxt-manager' ) . '
'; } /** * Renders the delete-on-uninstall checkbox. * * @return void */ public function render_field_delete_on_uninstall(): void { $value = (bool) get_site_option( 'robotstxt_manager_delete_data_on_uninstall', false ); echo ''; } /** * Sanitizes the API key field. * * Empty input preserves the existing encrypted value; non-empty input is * encrypted before storage. The catalog transient is cleared whenever * the key changes so the next page load fetches fresh data. * * @param mixed $value Raw submitted value. * * @return string Encrypted key, or existing value if input is empty. */ public function sanitize_api_key( mixed $value ): string { $plain = sanitize_text_field( is_string( $value ) ? $value : '' ); if ( '' === $plain ) { $raw = get_site_option( 'robotstxt_manager_api_key', '' ); return is_string( $raw ) ? $raw : ''; } // 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 ); } /** * Sanitizes the cache TTL value. * * @param mixed $value Raw submitted value. * * @return int Clamped to 1-1440 minutes. */ public function sanitize_cache_ttl( mixed $value ): int { return max( 1, min( 1440, absint( is_scalar( $value ) ? $value : 0 ) ) ); } /** * Handles the "Test connection" AJAX request. * * Reads the current Store URL + API key from options, attempts a GET * /plugins against Core, and returns success/failure with a message. * * @return void */ public function handle_test_connection(): void { check_ajax_referer( 'robotstxt_manager_test_connection', 'nonce' ); if ( ! current_user_can( is_multisite() ? 'manage_network_options' : 'manage_options' ) ) { wp_send_json_error( array( 'message' => __( 'Insufficient permissions.', 'robotstxt-manager' ) ) ); } // 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'] ) { wp_send_json_success( array( 'message' => $result['message'] ) ); } wp_send_json_error( array( 'message' => $result['message'] ) ); } /** * Handles the "Delete API key" AJAX request. * * Removes the encrypted API key from wp_options. The change takes effect * immediately — subsequent catalog fetches and connection tests will fail * until a new key is entered and saved. * * @return void */ public function handle_delete_key(): void { check_ajax_referer( 'robotstxt_manager_delete_key', 'nonce' ); if ( ! current_user_can( is_multisite() ? 'manage_network_options' : 'manage_options' ) ) { wp_send_json_error( array( 'message' => __( 'Insufficient permissions.', 'robotstxt-manager' ) ) ); } delete_site_option( 'robotstxt_manager_api_key' ); delete_site_transient( 'robotstxt_manager_catalog' ); delete_site_transient( 'robotstxt_manager_subscriptions' ); wp_send_json_success( array( 'message' => __( 'API key deleted. Save changes to persist.', 'robotstxt-manager' ), ) ); } }