'Cloudflare Turnstile', 'recaptcha' => 'Google reCAPTCHA', 'nonce' => 'WordPress Nonce', ); /** * The company behind each provider, for sentences such as "Cloudflare rejected the secret key". */ const VENDORS = array( 'turnstile' => 'Cloudflare', 'recaptcha' => 'Google', ); /** * Field key => the constant that overrides it. */ const CONSTANTS = array( 'turnstile_site_key' => 'ADMINKEEP_TURNSTILE_SITE_KEY', 'turnstile_secret' => 'ADMINKEEP_TURNSTILE_SECRET', 'recaptcha_site_key' => 'ADMINKEEP_RECAPTCHA_SITE_KEY', 'recaptcha_secret' => 'ADMINKEEP_RECAPTCHA_SECRET', ); /** * The fields that hold a sealed secret. */ const SECRETS = array( 'turnstile_secret', 'recaptcha_secret' ); /** * Stored settings, already coerced. * * @var array */ private $stored; /** * Values of the constants that are defined, keyed by field. * * @var array */ private $constants; /** * Opens the stored secrets. * * @var SecretBox */ private $box; /** * Defaults for every stored field. The texts default to '' so that the translated default is * chosen at render time, in the visitor's request, not frozen in the database. * * @return array */ public static function defaults() { return array( 'provider' => 'nonce', 'to' => '', 'subject_prefix' => '', 'success_text' => '', 'error_text' => '', 'turnstile_site_key' => '', 'turnstile_secret' => '', 'recaptcha_site_key' => '', 'recaptcha_secret' => '', 'recaptcha_threshold' => 0.5, ); } /** * Build from explicit sources. * * @param array $stored The option's value. * @param array $constants Field key => value, for the constants that are defined. * @param SecretBox $box Opens the stored secrets. */ public function __construct( array $stored, array $constants, SecretBox $box ) { $this->stored = self::coerce( $stored ); $this->constants = array_intersect_key( $constants, self::CONSTANTS ); $this->box = $box; } /** * Build from this site's option, constants and salts. * * @return self */ public static function from_environment() { return new self( self::stored(), self::defined_constants(), SecretBox::from_salts() ); } /** * The option's raw value. * * @return array */ public static function stored() { $stored = get_option( self::OPTION, array() ); return is_array( $stored ) ? $stored : array(); } /** * The constants that are defined, keyed by field. * * @return array */ public static function defined_constants() { $found = array(); foreach ( self::CONSTANTS as $key => $name ) { if ( defined( $name ) ) { $found[ $key ] = constant( $name ); } } return $found; } /** * One resolved value. Secrets are not available here: see secret(). * * @param string $key Field key. * @return mixed */ public function get( $key ) { if ( in_array( $key, self::SECRETS, true ) ) { return ''; } if ( $this->is_locked( $key ) ) { $coerced = self::coerce( array( $key => $this->constants[ $key ] ) ); return $coerced[ $key ]; } return isset( $this->stored[ $key ] ) ? $this->stored[ $key ] : null; } /** * Whether wp-config.php holds this field. * * @param string $key Field key. * @return bool */ public function is_locked( $key ) { return array_key_exists( $key, $this->constants ); } /** * The chosen provider's site key, or '' for one without keys. * * @return string */ public function site_key() { $provider = $this->get( 'provider' ); return in_array( $provider, self::KEYLESS, true ) ? '' : (string) $this->get( $provider . '_site_key' ); } /** * The lowest reCAPTCHA score accepted. * * @return float */ public function threshold() { return (float) $this->get( 'recaptcha_threshold' ); } /** * Whether a secret is set for a provider, readable or not. * * @param string $provider 'turnstile' or 'recaptcha'. * @return bool */ public function has_secret( $provider ) { $key = $provider . '_secret'; if ( ! in_array( $key, self::SECRETS, true ) ) { return false; } if ( $this->is_locked( $key ) ) { return '' !== self::scalar( $this->constants[ $key ] ); } return '' !== $this->stored[ $key ]; } /** * A provider's plain secret. * * @param string $provider 'turnstile' or 'recaptcha'. * @return string|false False when a stored secret cannot be opened, which happens after the * site's salts change. The owner has to enter it again. */ public function secret( $provider ) { $key = $provider . '_secret'; if ( ! in_array( $key, self::SECRETS, true ) ) { return ''; } if ( $this->is_locked( $key ) ) { return self::scalar( $this->constants[ $key ] ); } return $this->box->decrypt( $this->stored[ $key ] ); } /** * Whether the form can be shown: a provider that needs no keys, or one with its site key and a * readable secret. Anything less and the form stays hidden, because a form without working spam * protection is worse than no form. * * @return bool */ public function is_ready() { $provider = $this->get( 'provider' ); if ( in_array( $provider, self::KEYLESS, true ) ) { return true; } $secret = $this->secret( $provider ); return '' !== $this->site_key() && false !== $secret && '' !== $secret; } /** * The success text, or the translated default. * * @return string */ public function success_text() { $text = (string) $this->get( 'success_text' ); return '' === $text ? self::default_success_text() : $text; } /** * The error text, or the translated default. * * @return string */ public function error_text() { $text = (string) $this->get( 'error_text' ); return '' === $text ? self::default_error_text() : $text; } /** * What a visitor reads after a message is sent, unless the owner wrote their own. * * @return string */ public static function default_success_text() { return __( 'Thanks, your message has been sent.', 'adminkeep' ); } /** * What a visitor reads when a message could not be sent, unless the owner wrote their own. * * @return string */ public static function default_error_text() { return __( 'Sorry, your message could not be sent. Please try again later.', 'adminkeep' ); } /** * Turn a submission into the array to store. The screen and the CLI both write through here. * * @param array $input Unslashed values: the WHOLE form. A plain field left out resets to its default; only the secrets fall back to $previous. * @param array $previous What is stored now. * @param array $constants Field key => value for the defined constants. * @param SecretBox $box Seals the secrets. * @return array */ public static function sanitize( array $input, array $previous, array $constants, SecretBox $box ) { $previous = self::coerce( $previous ); $clean = self::coerce( array( // Checked against the list in coerce(), byte for byte. Never sanitize_key(): it // rewrites "TURNSTILE" into a valid choice instead of refusing it. 'provider' => self::text( $input, 'provider' ), 'to' => sanitize_email( self::text( $input, 'to' ) ), 'subject_prefix' => sanitize_text_field( self::text( $input, 'subject_prefix' ) ), 'success_text' => sanitize_text_field( self::text( $input, 'success_text' ) ), 'error_text' => sanitize_text_field( self::text( $input, 'error_text' ) ), 'turnstile_site_key' => sanitize_text_field( self::text( $input, 'turnstile_site_key' ) ), 'recaptcha_site_key' => sanitize_text_field( self::text( $input, 'recaptcha_site_key' ) ), 'recaptcha_threshold' => isset( $input['recaptcha_threshold'] ) && is_scalar( $input['recaptcha_threshold'] ) ? $input['recaptcha_threshold'] : null, ) ); // The screen disables a secret's field while one is saved, and a disabled field is not // posted: no value means keep. Once the field is posted, what it holds is the answer, and // an empty one removes the saved secret. Trimmed, because a key copied from a dashboard // often brings a newline along, and the provider would then refuse every message. foreach ( self::SECRETS as $key ) { $clean[ $key ] = $previous[ $key ]; if ( isset( $input[ $key ] ) && is_string( $input[ $key ] ) ) { $secret = trim( $input[ $key ] ); $clean[ $key ] = '' === $secret ? '' : $box->encrypt( $secret ); } } // A value that lives in wp-config.php is never copied into the database, and what was // stored before the constant was defined stays. foreach ( array_keys( array_intersect_key( $constants, self::CONSTANTS ) ) as $key ) { $clean[ $key ] = $previous[ $key ]; } return $clean; } /** * A text value from input, ignoring arrays, so a field attacked as name[]=x is never stored * as the word "Array". * * @param array $input Input. * @param string $key Field key. * @return string */ private static function text( array $input, $key ) { return isset( $input[ $key ] ) && is_scalar( $input[ $key ] ) ? (string) $input[ $key ] : ''; } /** * A scalar as trimmed text, anything else as ''. * * @param mixed $value Value. * @return string */ private static function scalar( $value ) { return is_scalar( $value ) ? trim( (string) $value ) : ''; } /** * Force every field to its type and legal range, filling gaps from the defaults. * * @param array $values Raw values. * @return array */ private static function coerce( array $values ) { $defaults = self::defaults(); $values = array_merge( $defaults, array_intersect_key( $values, $defaults ) ); $to = self::scalar( $values['to'] ); // One decimal, 0.1 to 0.9. Zero is not offered: it would accept every token and leave the // form unprotected in practice. $threshold = is_numeric( $values['recaptcha_threshold'] ) ? round( (float) $values['recaptcha_threshold'], 1 ) : $defaults['recaptcha_threshold']; return array( 'provider' => in_array( $values['provider'], self::PROVIDERS, true ) ? $values['provider'] : $defaults['provider'], 'to' => is_email( $to ) ? $to : '', 'subject_prefix' => self::scalar( $values['subject_prefix'] ), 'success_text' => self::scalar( $values['success_text'] ), 'error_text' => self::scalar( $values['error_text'] ), 'turnstile_site_key' => self::scalar( $values['turnstile_site_key'] ), 'turnstile_secret' => is_string( $values['turnstile_secret'] ) ? $values['turnstile_secret'] : '', 'recaptcha_site_key' => self::scalar( $values['recaptcha_site_key'] ), 'recaptcha_secret' => is_string( $values['recaptcha_secret'] ) ? $values['recaptcha_secret'] : '', 'recaptcha_threshold' => max( 0.1, min( 0.9, $threshold ) ), ); } }