stored field, for the fields that take a value. */ const TEXT_FLAGS = array( 'provider' => 'provider', 'to' => 'to', 'subject-prefix' => 'subject_prefix', 'success-text' => 'success_text', 'error-text' => 'error_text', 'turnstile-site-key' => 'turnstile_site_key', 'recaptcha-site-key' => 'recaptcha_site_key', 'recaptcha-threshold' => 'recaptcha_threshold', ); /** * The providers that have a secret. */ const SECRET_PROVIDERS = array( 'turnstile', 'recaptcha' ); /** * Terminal. * * @var Io */ private $io; /** * Constructor. * * @param Io $io Terminal. */ public function __construct( Io $io ) { $this->io = $io; } /** * The constants in force. Its own method so a test can supply some; PHP would never let a * test undefine a real one. * * @return array Field key => value. */ protected function constants() { return ContactFormConfig::defined_constants(); } /** * The configuration in force. * * @return ContactFormConfig */ private function config() { return new ContactFormConfig( ContactFormConfig::stored(), $this->constants(), SecretBox::from_salts() ); } /** * Shows the contact form settings and where each value comes from. * * Secret keys are never shown, only whether one is set, and whether it * can still be read. The last row says whether the form is live. * * ## OPTIONS * * [--format=] * : Output format. * --- * default: table * options: * - table * - json * - csv * - yaml * --- * * ## EXAMPLES * * $ wp adminkeep contact-form get * * @param array $args Positional arguments. * @param array $assoc_args Flags. * @return void */ public function get( $args, $assoc_args ) { $config = $this->config(); $stored = ContactFormConfig::stored(); $rows = array(); foreach ( array_keys( ContactFormConfig::defaults() ) as $key ) { $source = array_key_exists( $key, $stored ) ? 'stored' : 'default'; if ( in_array( $key, ContactFormConfig::SECRETS, true ) ) { $provider = substr( $key, 0, -strlen( '_secret' ) ); if ( ! $config->has_secret( $provider ) ) { $value = '(not set)'; } elseif ( false === $config->secret( $provider ) ) { $value = '(unreadable)'; } else { $value = '(set)'; } } elseif ( 'to' === $key && '' === $config->get( 'to' ) ) { // Empty means the admin email: show where messages actually go. $value = (string) get_option( 'admin_email' ); $source = 'default'; } elseif ( 'recaptcha_threshold' === $key ) { $value = number_format( $config->threshold(), 1, '.', '' ); } else { $value = (string) $config->get( $key ); } if ( $config->is_locked( $key ) ) { $source = 'constant'; } $rows[] = array( 'field' => $key, 'value' => $value, 'source' => $source, ); } $rows[] = array( 'field' => 'status', 'value' => $config->is_ready() ? sprintf( 'live (%s)', $config->get( 'provider' ) ) : sprintf( 'hidden: %s keys not set', $config->get( 'provider' ) ), 'source' => '', ); $this->io->items( isset( $assoc_args['format'] ) ? (string) $assoc_args['format'] : 'table', $rows, array( 'field', 'value', 'source' ) ); } /** * Changes the contact form settings. Only what you name changes; everything else is kept. * * A key held by a constant in wp-config.php (ADMINKEEP_TURNSTILE_SITE_KEY, * ADMINKEEP_TURNSTILE_SECRET, ADMINKEEP_RECAPTCHA_SITE_KEY, * ADMINKEEP_RECAPTCHA_SECRET) cannot be changed here. * * ## OPTIONS * * [--provider=] * : Spam protection: nonce (the default), turnstile or recaptcha. nonce needs no * keys, but does not work on a site that caches its pages; turnstile and * recaptcha keep the form hidden until both of their keys are set. * * [--to=] * : Where messages are sent. An empty value means the site's admin email. * * [--subject-prefix=] * : Put before "New message from …". Empty means the site title in brackets. * * [--success-text=] * : Shown after a message is sent. Empty means the default. * * [--error-text=] * : Shown when a message could not be sent. Empty means the default. * * [--turnstile-site-key=] * : Cloudflare Turnstile site key. * * [--turnstile-secret-stdin] * : Read the Turnstile secret key from standard input. The safe way to set it. * * [--turnstile-secret=] * : The Turnstile secret key. Avoid: it is saved in your shell history and * visible in the process list while the command runs. * * [--clear-turnstile-secret] * : Remove the saved Turnstile secret key. * * [--recaptcha-site-key=] * : Google reCAPTCHA v3 site key. * * [--recaptcha-secret-stdin] * : Read the reCAPTCHA secret key from standard input. The safe way to set it. * * [--recaptcha-secret=] * : The reCAPTCHA secret key. Avoid, for the same reasons as --turnstile-secret. * * [--clear-recaptcha-secret] * : Remove the saved reCAPTCHA secret key. * * [--recaptcha-threshold=] * : Lowest reCAPTCHA score accepted: 0.1 to 0.9, in steps of 0.1. Default 0.5. * * ## EXAMPLES * * $ wp adminkeep contact-form set --provider=turnstile --turnstile-site-key=0x4AAAAAAA... * * # Set the secret without it reaching your shell history. * $ printf %s "$TURNSTILE_SECRET" | wp adminkeep contact-form set --turnstile-secret-stdin * * # No keys: a WordPress nonce. Not for a site that caches its pages. * $ wp adminkeep contact-form set --provider=nonce * * @param array $args Positional arguments. * @param array $assoc_args Flags. * @return void */ public function set( $args, $assoc_args ) { $constants = $this->constants(); $given = array_keys( array_filter( $assoc_args, array( $this, 'is_given' ) ) ); // What is stored now, in full: sanitize() replaces the whole option, so a field left out // of its input would be reset. The secrets are left out on purpose: sanitize() keeps the // sealed ones when their keys are missing, and keeps every locked field. $input = array_merge( ContactFormConfig::defaults(), array_intersect_key( ContactFormConfig::stored(), ContactFormConfig::defaults() ) ); foreach ( ContactFormConfig::SECRETS as $secret_key ) { unset( $input[ $secret_key ] ); } $changed = array(); // Standard input can be read once, and holds one secret. if ( count( array_intersect( array( 'turnstile-secret-stdin', 'recaptcha-secret-stdin' ), $given ) ) > 1 ) { $this->io->error( 'Only one secret key can be read from standard input per call.' ); } foreach ( self::TEXT_FLAGS as $flag => $key ) { if ( ! array_key_exists( $flag, $assoc_args ) ) { continue; } // WP-CLI turns a value-less --flag into true and --no-flag into false; neither is a value. if ( ! is_string( $assoc_args[ $flag ] ) && ! is_numeric( $assoc_args[ $flag ] ) ) { $this->io->error( sprintf( '--%s needs a value.', $flag ) ); } $this->refuse_if_locked( $key, $constants ); $input[ $key ] = (string) $assoc_args[ $flag ]; $changed[] = $key; } foreach ( self::SECRET_PROVIDERS as $provider ) { $flags = array_values( array_intersect( array( $provider . '-secret', $provider . '-secret-stdin', 'clear-' . $provider . '-secret' ), $given ) ); if ( array() === $flags ) { continue; } if ( count( $flags ) > 1 ) { $this->io->error( sprintf( 'Use only one of --%1$s-secret, --%1$s-secret-stdin and --clear-%1$s-secret.', $provider ) ); } $this->refuse_if_locked( $provider . '_secret', $constants ); $input[ $provider . '_secret' ] = $this->new_secret( $provider, $flags[0], $assoc_args ); $changed[] = $provider . '_secret'; } if ( array() === $changed ) { $this->io->error( 'Nothing to change. See: wp help adminkeep contact-form set' ); } $this->validate( $input, $changed ); $previous = ContactFormConfig::stored(); $saved = ContactFormConfig::sanitize( $input, $previous, $constants, SecretBox::from_salts() ); update_option( ContactFormConfig::OPTION, $saved, false ); ContactForm::saved( $previous, $saved ); $message = sprintf( 'Contact form settings updated: %s.', implode( ', ', $changed ) ); if ( ! Settings::is_enabled( 'contact_form' ) ) { $message .= ' The contact form is off; turn it on with: wp adminkeep feature enable contact_form'; } $this->io->success( $message ); } /** * Whether a flag was really passed. `--no-clear-turnstile-secret` arrives as false and means * nothing. * * Private, not just an implementation detail: WP-CLI builds a subcommand from every public * method of a registered command object. * * @param mixed $value Flag value. * @return bool */ private function is_given( $value ) { return false !== $value && null !== $value; } /** * Stop if wp-config.php owns this field. * * @param string $key Field key. * @param array $constants Constants in force. * @return void */ private function refuse_if_locked( $key, array $constants ) { if ( array_key_exists( $key, $constants ) && isset( ContactFormConfig::CONSTANTS[ $key ] ) ) { $this->io->error( sprintf( '%s is fixed by the %s constant in wp-config.php and cannot be changed here.', $key, ContactFormConfig::CONSTANTS[ $key ] ) ); } } /** * The secret a flag asks for: '' removes the saved one. * * @param string $provider 'turnstile' or 'recaptcha'. * @param string $flag Which of the three flags. * @param array $assoc_args Flags. * @return string */ private function new_secret( $provider, $flag, array $assoc_args ) { if ( 'clear-' . $provider . '-secret' === $flag ) { return ''; } if ( $provider . '-secret-stdin' === $flag ) { // Only the line ending a shell adds is dropped here; sanitize() trims the rest. $secret = rtrim( $this->io->stdin(), "\r\n" ); } else { if ( ! is_string( $assoc_args[ $flag ] ) ) { $this->io->error( sprintf( '--%s needs a value.', $flag ) ); } $secret = $assoc_args[ $flag ]; } if ( '' === trim( $secret ) ) { $this->io->error( sprintf( 'The secret key is empty. To remove the saved one use --clear-%s-secret.', $provider ) ); } return $secret; } /** * Refuse what ContactFormConfig would otherwise quietly replace with a default. * * @param array $input Full input. * @param string[] $changed Keys this call changes. * @return void */ private function validate( array $input, array $changed ) { if ( in_array( 'provider', $changed, true ) && ! in_array( $input['provider'], ContactFormConfig::PROVIDERS, true ) ) { $this->io->error( sprintf( '"%1$s" is not a provider. Choose one of: %2$s.', $input['provider'], implode( ', ', ContactFormConfig::PROVIDERS ) ) ); } if ( in_array( 'to', $changed, true ) && '' !== $input['to'] && ! is_email( $input['to'] ) ) { $this->io->error( sprintf( '"%s" is not a valid email address.', $input['to'] ) ); } if ( in_array( 'recaptcha_threshold', $changed, true ) && ! preg_match( '/^0?\.[1-9]$/', (string) $input['recaptcha_threshold'] ) ) { $this->io->error( sprintf( '"%s" is not a threshold. Use 0.1 to 0.9, in steps of 0.1.', $input['recaptcha_threshold'] ) ); } } }