robotstxt-manager/admin/class-robotstxt-manager-settings.php
2026-08-18 10:49:06 +00:00

519 lines
17 KiB
PHP

<?php
/**
* Admin settings page: API key, store URL, cache TTL, connection test.
*
* @package Robotstxt_Manager
*/
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
/**
* Class Robotstxt_Manager_Settings
*
* Registers the plugin settings page and all setting fields using the
* WordPress Settings API. The API key is stored encrypted via
* Robotstxt_Manager_Encryption and displayed masked in the UI. Provides an
* AJAX handler for the "Test connection" button.
*/
class Robotstxt_Manager_Settings {
/**
* Admin page and menu slug.
*
* @var string
*/
public const PAGE_SLUG = 'robotstxt-manager-settings';
/**
* Settings API option group.
*
* @var string
*/
public const OPTION_GROUP = 'robotstxt_manager_settings_group';
/**
* Registers all hooks via the loader.
*
* @param Robotstxt_Manager_Loader $loader The plugin hook loader.
*
* @return void
*/
public function register( Robotstxt_Manager_Loader $loader ): void {
$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' );
}
/**
* 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' ) );
}
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_site_option( 'robotstxt_manager_store_url', 'https://www.robotstxt.software' );
$value = is_string( $raw ) ? $raw : 'https://www.robotstxt.software';
printf(
'<input type="url" id="robotstxt_manager_store_url" name="robotstxt_manager_store_url" value="%s" class="regular-text" maxlength="500" placeholder="https://www.robotstxt.software" />',
esc_attr( $value )
);
echo '<p class="description">' . esc_html__( 'Base URL of the remote Plugins Core installation that this site will pull the plugin catalog from.', 'robotstxt-manager' ) . '</p>';
}
/**
* 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 '<input type="password" id="robotstxt_manager_api_key" name="robotstxt_manager_api_key" value="" class="regular-text" autocomplete="new-password" />';
if ( $has_key ) {
echo '<p class="description">';
if ( '' !== $last4 ) {
echo wp_kses_post(
sprintf(
/* translators: %s: last 4 characters of the stored API key. */
__( 'A key is stored (last 4 characters: <code>%s</code>). 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 '</p>';
} else {
printf(
'<p class="description">%s</p>',
wp_kses_post(
sprintf(
/* translators: %s: Registration URL. */
__( 'Account-level API key from the ROBOTSTXT store (<a href="%s" target="_blank" rel="noopener">create your free account there to get one</a>). 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 '<p>';
// Test connection: disabled when no key is stored.
$test_disabled = $has_key ? '' : ' disabled';
printf( '<button type="button" id="robotstxt-manager-test-connection" class="button"%s>%s</button>', esc_attr( $test_disabled ), esc_html__( 'Test connection', 'robotstxt-manager' ) );
// Delete stored key: only shown when a key exists.
if ( $has_key ) {
echo ' <button type="button" id="robotstxt-manager-delete-key" class="button button-link-delete">' . esc_html__( 'Delete API key', 'robotstxt-manager' ) . '</button>';
}
echo '<span id="robotstxt-manager-test-result" style="margin-left:8px"></span>';
echo '</p>';
}
/**
* 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(
'<input type="number" id="robotstxt_manager_cache_ttl_minutes" name="robotstxt_manager_cache_ttl_minutes" value="%d" class="small-text" min="1" max="1440" />',
absint( $value )
);
echo '<p class="description">' . 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' ) . '</p>';
}
/**
* 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 '<label>';
printf(
'<input type="checkbox" id="robotstxt_manager_delete_data_on_uninstall" name="robotstxt_manager_delete_data_on_uninstall" value="1"%s />',
checked( $value, true, false )
);
echo ' ' . esc_html__( 'Delete all plugin data when the plugin is uninstalled.', 'robotstxt-manager' );
echo '</label>';
}
/**
* 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' ),
)
);
}
}