hook = (string) add_options_page(
__( 'Contact Form', 'adminkeep' ),
__( 'Contact Form', 'adminkeep' ),
self::CAPABILITY,
self::PAGE,
array( $this, 'render' )
);
}
/**
* The script that shows the chosen provider's keys and runs the Remove buttons, and the few
* layout rules the secret fields share with the SMTP screen.
*
* @param string $hook_suffix Current admin page.
* @return void
*/
public function enqueue( $hook_suffix ) {
if ( '' === $this->hook || $hook_suffix !== $this->hook ) {
return;
}
wp_enqueue_script( self::SCRIPT, ADMINKEEP_URL . 'assets/js/contact-form-screen.js', array(), Assets::version( 'assets/js/contact-form-screen.js' ), true );
wp_register_style( self::SCRIPT, false, array(), ADMINKEEP_VERSION );
wp_enqueue_style( self::SCRIPT );
wp_add_inline_style(
self::SCRIPT,
'.adminkeep-contact-form-secret{display:flex;gap:8px;align-items:center;max-width:36em}'
. '.adminkeep-contact-form-secret input{flex:0 1 25em;min-width:0;margin:0}'
. '.adminkeep-contact-form-secret input:disabled::placeholder{letter-spacing:2px;color:#50575e}'
. '.adminkeep-contact-form-secret .button{flex:none;white-space:nowrap}'
. '.adminkeep-contact-form-keys{margin:0;padding:0;border:0}'
. '.adminkeep-contact-form-provider-note{margin:0 0 .5em 24px;max-width:42em}'
// The SMTP screen's two columns: settings, and the placing box beside them.
// Below 960px the box drops under the settings, which is also where it sits unstyled.
. '.adminkeep-contact-form-columns{display:flex;align-items:flex-start;gap:24px}'
. '.adminkeep-contact-form-main{flex:1 1 auto;min-width:0}'
. '.adminkeep-contact-form-side{flex:0 0 340px;box-sizing:border-box;margin-top:16px;padding:4px 20px 20px;background:#fff;border:1px solid #c3c4c7}'
. '.adminkeep-contact-form-side p{overflow-wrap:anywhere}'
. '#adminkeep-contact-form-shortcode{width:100%}'
. '@media (max-width:960px){.adminkeep-contact-form-columns{display:block}.adminkeep-contact-form-side{margin-top:24px}}'
);
}
/**
* Save the settings form.
*
* Nothing on this screen posts through admin-ajax.php, so the wp_doing_ajax() guard the SMTP
* test form needs does not apply. A script that ever posts this form must add it: admin-ajax
* fires admin_init too, and the save would run twice.
*
* @return void
*/
public function handle_save() {
if ( empty( $_POST[ self::SAVE_FIELD ] ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Missing -- Checked below, once this is known to be our form.
return;
}
if ( ! current_user_can( self::CAPABILITY ) ) {
wp_die( esc_html__( 'You are not allowed to change the contact form settings on this site.', 'adminkeep' ), '', 403 );
}
check_admin_referer( self::NONCE );
// Each field is sanitized for its own type inside ContactFormConfig::sanitize(); secrets are
// deliberately not text-sanitized, because they are sealed and never printed.
// phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- Sanitized per field by ContactFormConfig::sanitize() below.
$input = isset( $_POST[ self::FIELD ] ) && is_array( $_POST[ self::FIELD ] ) ? wp_unslash( $_POST[ self::FIELD ] ) : array();
$previous = ContactFormConfig::stored();
$saved = ContactFormConfig::sanitize( $input, $previous, $this->constants(), SecretBox::from_salts() );
update_option( ContactFormConfig::OPTION, $saved, false );
ContactForm::saved( $previous, $saved );
wp_safe_redirect( add_query_arg( self::SAVED_ARG, '1', self::url() ) );
exit;
}
/**
* 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 page cache that looks active on this site. Its own method for the same reason as
* constants(): a test cannot undefine WP_CACHE.
*
* @return string|null As PageCacheDetector::detected().
*/
protected function page_cache() {
return ( new PageCacheDetector() )->detected();
}
/**
* The configuration this screen shows.
*
* @return ContactFormConfig
*/
protected function config() {
return new ContactFormConfig( ContactFormConfig::stored(), $this->constants(), SecretBox::from_salts() );
}
/**
* Render the screen.
*
* @return void
*/
public function render() {
if ( ! current_user_can( self::CAPABILITY ) ) {
return;
}
$config = $this->config();
echo '
'; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- The mark is escaped where it is built.
// Where core puts other plugins' notices: under the heading, not inside it.
echo '';
if ( isset( $_GET[ self::SAVED_ARG ] ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Read-only flag choosing a static notice.
printf( '
';
}
/**
* Why visitors cannot see the form, across the top, when that is so. Nothing when it is live:
* the chosen provider below already says what protects it.
*
* @param ContactFormConfig $config Configuration.
* @return void
*/
private function render_hidden_warning( ContactFormConfig $config ) {
if ( $config->is_ready() ) {
return;
}
printf(
'
%s
',
/* translators: %s: a spam protection service, such as Cloudflare Turnstile. */
esc_html( sprintf( __( 'The form is hidden: save both keys for %s, or choose WordPress Nonce.', 'adminkeep' ), ContactFormConfig::LABELS[ $config->get( 'provider' ) ] ) )
);
}
/**
* A warning across the top while WordPress Nonce is the chosen protection and a page cache
* looks active: the note under the choice says the two do not go together, but an owner
* running a caching plugin may never read it, and would only hear of it from visitors.
*
* @param ContactFormConfig $config Configuration.
* @return void
*/
private function render_cache_warning( ContactFormConfig $config ) {
if ( 'nonce' !== $config->get( 'provider' ) ) {
return;
}
$cache = $this->page_cache();
if ( null === $cache ) {
return;
}
$lead = '' === $cache
? __( 'A page cache looks active on this site.', 'adminkeep' )
/* translators: %s: a caching plugin, such as WP Rocket. */
: sprintf( __( '%s looks active on this site.', 'adminkeep' ), $cache );
printf(
'
%1$s %2$s
',
esc_html( $lead ),
esc_html__( 'WordPress Nonce does not work on cached pages: after about a day, every message sent from a cached page is refused. Choose Cloudflare Turnstile or Google reCAPTCHA v3 below instead.', 'adminkeep' )
);
}
/**
* The last failure, if there is one, across the top: until it is fixed, visitors are turned away.
*
* @param ContactFormConfig $config Configuration.
* @return void
*/
private function render_last_error( ContactFormConfig $config ) {
$error = ContactForm::last_error();
if ( array() !== $error ) {
printf(
'
%1$s %2$s
%3$s
',
esc_html__( 'The last submission failed.', 'adminkeep' ),
esc_html( wp_date( 'j M Y, H:i', $error['time'] ) ),
esc_html( ContactForm::error_sentence( $error['code'], (string) $config->get( 'provider' ) ) )
);
}
}
/**
* How to put the form on a page: the shortcode to copy, then the block.
*
* @return void
*/
private function render_placing() {
echo '
',
esc_html__( 'Use block:', 'adminkeep' ),
esc_html__( 'Add the Contact Form block to any page. It is under Widgets in the block inserter.', 'adminkeep' )
);
printf( '
%s
', esc_html__( 'Prefer the block where you can: if you switch this feature off, a shortcode left in a page shows as plain text, while the block shows nothing.', 'adminkeep' ) );
}
/**
* Where messages go, and their subject.
*
* @param ContactFormConfig $config Configuration.
* @return void
*/
private function render_delivery( ContactFormConfig $config ) {
echo '
' . esc_html__( 'Delivery', 'adminkeep' ) . '
';
echo '
';
$this->text_row( $config, 'to', __( 'Send to', 'adminkeep' ), 'email', (string) get_option( 'admin_email' ), __( 'Leave empty to use the site\'s administration email address.', 'adminkeep' ) );
$this->text_row( $config, 'subject_prefix', __( 'Subject prefix', 'adminkeep' ), 'text', ContactFormMailer::default_prefix(), __( 'Leave empty to use the site title in brackets.', 'adminkeep' ) );
printf(
'
%1$s
%2$s
',
esc_html__( 'Example subject', 'adminkeep' ),
/* translators: a sample visitor name, shown in the example subject line. */
esc_html( ( new ContactFormMailer( $config ) )->subject( __( 'Jo Example', 'adminkeep' ) ) )
);
echo '
';
}
/**
* The two texts a visitor reads after pressing Send.
*
* @param ContactFormConfig $config Configuration.
* @return void
*/
private function render_messages( ContactFormConfig $config ) {
echo '
' . esc_html__( 'Messages', 'adminkeep' ) . '
';
echo '
';
$this->text_row( $config, 'success_text', __( 'Success message', 'adminkeep' ), 'text', ContactFormConfig::default_success_text(), __( 'Leave empty to use the default shown, in the visitor\'s language.', 'adminkeep' ) );
$this->text_row( $config, 'error_text', __( 'Error message', 'adminkeep' ), 'text', ContactFormConfig::default_error_text(), __( 'Leave empty to use the default shown, in the visitor\'s language.', 'adminkeep' ) );
echo '
';
}
/**
* The provider choice and each provider's keys.
*
* @param ContactFormConfig $config Configuration.
* @return void
*/
private function render_protection( ContactFormConfig $config ) {
$choices = array(
'nonce' => 'WordPress Nonce',
'turnstile' => 'Cloudflare Turnstile',
'recaptcha' => 'Google reCAPTCHA v3',
);
// Said under the choice itself, where it is made: a cached page outlives the nonce.
$notes = array(
'nonce' => __( 'No keys and no outside service. It stops bots that send without loading the page, but not ones that load it first, so it is weaker than Turnstile or reCAPTCHA. Does not work on sites that use page caching (a caching plugin, your host\'s cache, or a CDN that caches whole pages): a cached page keeps a nonce that expires within a day, and every message sent from it after that is refused.', 'adminkeep' ),
);
echo '
', esc_html__( 'WordPress Nonce works with no set-up. Turnstile and reCAPTCHA need both of their keys saved, and the form stays hidden until they are; their script loads only on pages that show the form.', 'adminkeep' ) );
echo '
' . esc_html__( 'Provider', 'adminkeep' ) . '
';
echo '
';
$this->provider_keys(
$config,
'turnstile',
'Cloudflare Turnstile',
__( 'Create a widget in the Cloudflare dashboard under Turnstile, add this site\'s domain, and copy its two keys here.', 'adminkeep' )
);
$this->provider_keys(
$config,
'recaptcha',
'Google reCAPTCHA v3',
__( 'Register this site in the Google reCAPTCHA admin console, choosing score-based (v3), and copy its two keys here. Leave domain verification on.', 'adminkeep' )
);
}
/**
* One provider's keys, in a fieldset the screen's script shows only while that provider is
* chosen. Hidden fields are still posted, so switching provider keeps the other one's keys.
*
* @param ContactFormConfig $config Configuration.
* @param string $provider 'turnstile' or 'recaptcha'.
* @param string $label Provider name.
* @param string $help Where the keys come from.
* @return void
*/
private function provider_keys( ContactFormConfig $config, $provider, $label, $help ) {
printf( '';
}
/**
* A one-line input. A field held by a constant is disabled and its value is not printed.
*
* @param ContactFormConfig $config Configuration.
* @param string $key Field key.
* @param string $label Label.
* @param string $type Input type.
* @param string $placeholder Placeholder.
* @param string $help Description, or ''.
* @return void
*/
private function text_row( ContactFormConfig $config, $key, $label, $type, $placeholder, $help ) {
$locked = $config->is_locked( $key );
printf(
'
',
esc_attr( $key ),
esc_html( $label ),
esc_attr( $type ),
esc_attr( self::FIELD ),
// A constant's value is not echoed: wp-config.php is where it is kept private.
esc_attr( $locked ? '' : (string) $config->get( $key ) ),
esc_attr( $placeholder ),
$locked ? ' disabled="disabled"' : ''
);
$this->locked_note( $config, $key );
if ( '' !== $help ) {
printf( '
%s
', esc_html( $help ) );
}
echo '
';
}
/**
* A secret key. Always rendered empty: a saved secret is kept by leaving the field alone.
*
* @param ContactFormConfig $config Configuration.
* @param string $provider 'turnstile' or 'recaptcha'.
* @return void
*/
private function secret_row( ContactFormConfig $config, $provider ) {
$key = $provider . '_secret';
$id = 'adminkeep-contact-form-' . $key;
$locked = $config->is_locked( $key );
$saved = ! $locked && $config->has_secret( $provider );
// A saved secret that still opens keeps the field disabled, so saving leaves it alone;
// Remove enables the field, and what is saved from then on is what it holds, empty
// included. One that no longer opens has to be typed again, so its field starts enabled.
$kept = $saved && false !== $config->secret( $provider );
printf( '
', esc_attr( $id ), esc_html__( 'Secret key', 'adminkeep' ) );
printf(
'',
esc_attr( $id ),
esc_attr( self::FIELD ),
esc_attr( $key ),
// Dots as a placeholder, not a value: they show the field is filled without saying how
// long the secret is, and a placeholder can never be posted.
esc_attr( $kept ? str_repeat( '•', 16 ) : '' ),
$locked || $kept ? ' disabled="disabled"' : ''
);
if ( $kept ) {
printf(
'',
esc_attr( $id ),
esc_attr__( 'Type a new secret key, or leave empty to remove it', 'adminkeep' ),
esc_html__( 'Remove secret key', 'adminkeep' )
);
}
echo '
', esc_html__( 'The saved secret key can no longer be read because this site\'s security keys changed. Enter it again.', 'adminkeep' ) );
}
if ( ! $locked ) {
printf( '
%s
', esc_html__( 'Stored encrypted, and never shown again once saved.', 'adminkeep' ) );
}
echo '
';
}
/**
* The reCAPTCHA score threshold, 0.1 to 0.9. No 0.0: it would accept every token.
*
* @param ContactFormConfig $config Configuration.
* @return void
*/
private function threshold_row( ContactFormConfig $config ) {
$current = number_format( $config->threshold(), 1, '.', '' );
echo '
';
printf( '';
printf( '
%s
', esc_html__( 'Google scores each visit from 0.0 (likely a bot) to 1.0 (likely a person). Messages scoring below this are refused. 0.5 suits most sites.', 'adminkeep' ) );
echo '
';
}
/**
* The line shown when wp-config.php holds a field.
*
* @param ContactFormConfig $config Configuration.
* @param string $key Field key.
* @return void
*/
private function locked_note( ContactFormConfig $config, $key ) {
if ( ! $config->is_locked( $key ) ) {
return;
}
printf(
'
%s
',
/* translators: %s: name of a PHP constant. */
esc_html( sprintf( __( 'Set in wp-config.php (%s).', 'adminkeep' ), ContactFormConfig::CONSTANTS[ $key ] ) )
);
}
}