config(); if ( ! $config->is_ready() ) { return __( 'Not set up', 'adminkeep' ); } // This is how an owner learns that a wrong secret is turning every visitor away. if ( array() !== self::last_error() ) { return __( 'Last submission failed', 'adminkeep' ); } // The nonce on a cached page stops working within a day; the settings screen says why. if ( 'nonce' === $config->get( 'provider' ) && null !== $this->page_cache() ) { return __( 'Live, page cache detected', 'adminkeep' ); } /* translators: %s: a spam protection service, such as Cloudflare Turnstile. */ return sprintf( __( 'Live, protected by %s', 'adminkeep' ), ContactFormConfig::LABELS[ $config->get( 'provider' ) ] ); } /** * The page cache that looks active on this site. Its own method so a test can supply one; * PHP would never let a test undefine WP_CACHE. * * @return string|null As PageCacheDetector::detected(). */ protected function page_cache() { return ( new PageCacheDetector() )->detected(); } /** * What switching this on actually does. * * @return string[] */ public function how_it_works() { return array( __( 'Adds Settings → Contact Form. The form works as soon as this is on, protected by a WordPress nonce: no keys and no outside service, but it does not work on a site that caches its pages. There, choose Cloudflare Turnstile or Google reCAPTCHA v3 instead and save its two keys; until both are saved the form stays hidden, administrators see a note where it will appear, and visitors see nothing.', 'adminkeep' ), __( 'Place it with the Contact Form block, or with the [adminkeep_contact_form] shortcode where blocks are not available. Prefer the block: if you switch this feature off, a shortcode left in a page shows as plain text, while the block shows nothing.', 'adminkeep' ), __( 'Each message is emailed to you through WordPress\'s own mailer, so the SMTP feature delivers it and Email Log, if it is on, keeps a copy with whether it was sent. The form itself stores nothing.', 'adminkeep' ), __( 'Turnstile\'s or reCAPTCHA\'s script loads only on pages that show the form, and the service you choose receives the visitor\'s IP address and browser details. The WordPress nonce sends nothing anywhere. Settings → Privacy offers text for your privacy policy.', 'adminkeep' ), __( 'The secret key is stored encrypted. For the strongest setup, put the keys in wp-config.php instead (ADMINKEEP_TURNSTILE_SITE_KEY and ADMINKEEP_TURNSTILE_SECRET, or the ADMINKEEP_RECAPTCHA_ pair): values set there are not saved to the database, and the settings screen does not display them.', 'adminkeep' ), __( 'Switching this off removes the form and its settings screen. Your settings are kept unless you delete the plugin with "Delete all Adminkeep data" switched on.', 'adminkeep' ), ); } /** * Where the settings panel sends you to do the actual work. * * @return array{url?: string, label?: string} Empty when this user could not open the screen. */ public function panel_link() { if ( ! current_user_can( ContactFormScreen::CAPABILITY ) ) { return array(); } return array( 'url' => ContactFormScreen::url(), 'label' => __( 'Contact form settings', 'adminkeep' ), ); } /** * The settings screen. * * @return ContactFormScreen */ public function screen() { if ( null === $this->screen ) { $this->screen = new ContactFormScreen(); } return $this->screen; } /** * Register hooks. * * @param array $settings Resolved settings. Unused: this feature has none. * @return void */ public function boot( array $settings ) { add_action( 'init', array( $this, 'register_placements' ) ); add_action( 'enqueue_block_editor_assets', array( $this, 'editor_data' ) ); // Unconditionally: REST requests do not run through the admin. add_action( 'rest_api_init', array( $this, 'register_rest_route' ) ); if ( is_admin() ) { $this->screen()->boot(); add_action( 'admin_init', array( $this, 'add_privacy_text' ), 10, 0 ); } } /** * The render shared by the shortcode and the block. * * @return ContactFormMarkup */ public function markup() { if ( null === $this->markup ) { $this->markup = new ContactFormMarkup(); } return $this->markup; } /** * The shortcode and the block. Both only while the feature is on: a shortcode left in a page * after that shows as text, as any plugin's does, which is why the screen leads with the block. * * @return void */ public function register_placements() { add_shortcode( self::SHORTCODE, array( $this, 'render_shortcode' ) ); // Registered here, before the block that names it as its editorScript. Plain JavaScript // against the wp.* globals: there is no build step. wp_register_script( self::EDITOR_SCRIPT, ADMINKEEP_URL . 'assets/js/contact-form-block.js', array( 'wp-blocks', 'wp-element', 'wp-block-editor', 'wp-components', 'wp-i18n' ), Assets::version( 'assets/js/contact-form-block.js' ), true ); wp_set_script_translations( self::EDITOR_SCRIPT, 'adminkeep' ); register_block_type( ADMINKEEP_DIR . 'assets/blocks/contact-form', array( 'render_callback' => array( $this, 'render_block' ) ) ); } /** * The shortcode. It takes no attributes. * * @param array|string $atts Shortcode attributes. Ignored. * @return string */ public function render_shortcode( $atts = array() ) { return $this->markup()->render( $this->config() ); } /** * The block. It has no attributes. * * @param array $attributes Block attributes. Ignored. * @param string $content Inner content. Always empty. * @param \WP_Block|null $block The block instance. Ignored. * @return string */ public function render_block( $attributes = array(), $content = '', $block = null ) { return $this->markup()->render( $this->config() ); } /** * Tell the editor's placeholder whether the form is ready, and where to set it up for a * user who can. Only in the editor, so a front-end request never pays for the check. * * @return void */ public function editor_data() { $data = array( 'ready' => $this->config()->is_ready(), 'settingsUrl' => current_user_can( ContactFormScreen::CAPABILITY ) ? ContactFormScreen::url() : '', ); wp_add_inline_script( self::EDITOR_SCRIPT, 'var adminkeepContactFormBlock = ' . wp_json_encode( $data, JSON_HEX_TAG | JSON_HEX_AMP ) . ';', 'before' ); } /** * Register POST /adminkeep/v1/contact-form. * * Public, and with no nonce, on purpose. A nonce guards an action taken as a logged-in user; * this route never reads the current user and acts for nobody, so a forged cross-site post can * only send what an attacker could send directly, and it still needs a CAPTCHA token. A * logged-out nonce would also expire within a day, so any page cache would serve a form that * fails. The token is the gate: single-use, short-lived, bound to our action, and checked with * the provider on every submission. An owner may still choose a nonce as the spam check itself * (ContactFormNonce), knowing that caveat; it then arrives as the token. * * The args enforce only type and hard caps. The messages a visitor reads come from * rest_submit(). Every arg names its validator: in a hand-written args array a type or a cap * is otherwise documentation, not a check. * * @return void */ public function register_rest_route() { $text = static function ( $max ) { return array( 'type' => 'string', 'default' => '', 'maxLength' => $max, 'validate_callback' => 'rest_validate_request_arg', ); }; register_rest_route( RestController::NAMESPACE_V1, self::ROUTE, array( 'methods' => \WP_REST_Server::CREATABLE, 'callback' => array( $this, 'rest_submit' ), 'permission_callback' => '__return_true', 'args' => array( 'name' => $text( 100 ), 'email' => $text( 254 ), 'message' => $text( 10000 ), 'token' => $text( 4096 ), 'source' => $text( 2048 ), ), ) ); } /** * Handle a submission. The order matters: * * 1. Not ready: refuse before any outbound call. * 2. The fields, before the CAPTCHA, so a typo does not spend the single-use token. * 3. The token, checked with the provider, or here when it is the nonce. Fail closed. * 4. The email. * * Only the owner's problems are recorded (a refused secret, an unreachable provider, a failed * send), as a code. A refused token is spam traffic and records nothing. * * @param \WP_REST_Request $request Request. * @return \WP_REST_Response|\WP_Error */ public function rest_submit( $request ) { $config = $this->config(); if ( ! $config->is_ready() ) { return new \WP_Error( 'adminkeep_contact_unavailable', $config->error_text(), array( 'status' => 503 ) ); } $name = sanitize_text_field( (string) $request->get_param( 'name' ) ); $email = trim( (string) $request->get_param( 'email' ) ); $message = self::clean_message( (string) $request->get_param( 'message' ) ); $fields = array(); if ( '' === $name ) { $fields['name'] = __( 'Please enter your name.', 'adminkeep' ); } // is_email() also refuses line breaks, which is what keeps this address safe in Reply-To. if ( ! is_email( $email ) ) { $fields['email'] = __( 'Please enter a valid email address.', 'adminkeep' ); } if ( '' === $message ) { $fields['message'] = __( 'Please enter a message.', 'adminkeep' ); } if ( array() !== $fields ) { return new \WP_Error( 'adminkeep_contact_invalid', __( 'Please check the fields marked below.', 'adminkeep' ), array( 'status' => 400, 'fields' => $fields, ) ); } $token = trim( (string) $request->get_param( 'token' ) ); if ( '' === $token ) { return new \WP_Error( 'adminkeep_contact_captcha', self::captcha_message(), array( 'status' => 400 ) ); } // Nothing is recorded for the owner: bots posting without the page fail here all day. if ( 'nonce' === $config->get( 'provider' ) ) { if ( ! ContactFormNonce::verify( $token ) ) { return new \WP_Error( 'adminkeep_contact_expired', self::expired_message(), array( 'status' => 403 ) ); } } else { $verified = $this->verifier( $config )->verify( $token ); if ( is_wp_error( $verified ) ) { if ( CaptchaVerifier::REJECTED === $verified->get_error_code() ) { return new \WP_Error( 'adminkeep_contact_captcha', self::captcha_message(), array( 'status' => 403 ) ); } self::record_error( CaptchaVerifier::MISCONFIGURED === $verified->get_error_code() ? self::ERROR_MISCONFIGURED : self::ERROR_UNREACHABLE ); return new \WP_Error( 'adminkeep_contact_unavailable', $config->error_text(), array( 'status' => 503 ) ); } } if ( ! $this->mailer( $config )->send( $name, sanitize_email( $email ), $message, self::source( $request ) ) ) { self::record_error( self::ERROR_MAIL ); return new \WP_Error( 'adminkeep_contact_failed', $config->error_text(), array( 'status' => 500 ) ); } self::clear_error(); return rest_ensure_response( array( 'message' => $config->success_text() ) ); } /** * The message as plain text: valid UTF-8, one kind of line ending, and no control characters * but newline and tab. Tags are kept: it is plain text, and everywhere it is shown escapes it. * * @param string $message What the visitor typed. * @return string */ public static function clean_message( $message ) { $message = wp_check_invalid_utf8( (string) $message ); $message = str_replace( array( "\r\n", "\r" ), "\n", $message ); $message = (string) preg_replace( '/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/', '', $message ); return trim( $message ); } /** * The address of the page the form was sent from: what the script read from location.href, * or the Referer header when a form from before 2.4.0 (a cached page, say) sent none. Both are * the visitor's word; ContactFormMailer uses them only when they point into this site. * * @param \WP_REST_Request $request Request. * @return string */ public static function source( $request ) { $source = trim( (string) $request->get_param( 'source' ) ); return '' !== $source ? $source : trim( (string) $request->get_header( 'referer' ) ); } /** * What a visitor reads when the provider refuses the token. * * @return string */ public static function captcha_message() { return __( 'We could not confirm that this message was sent by a person. Please try again.', 'adminkeep' ); } /** * What a visitor reads when the form's nonce is refused. Almost always a page left open, or * served from a cache, for longer than the nonce lives. * * @return string */ public static function expired_message() { return __( 'This page has expired. Please reload it and send your message again.', 'adminkeep' ); } /** * The token checker for the provider in force. * * @param ContactFormConfig $config Configuration. * @return CaptchaVerifier */ protected function verifier( ContactFormConfig $config ) { $provider = (string) $config->get( 'provider' ); $secret = $config->secret( $provider ); return new CaptchaVerifier( $provider, false === $secret ? '' : $secret, $config->threshold() ); } /** * The mailer for the settings in force. * * @param ContactFormConfig $config Configuration. * @return ContactFormMailer */ protected function mailer( ContactFormConfig $config ) { return new ContactFormMailer( $config ); } /** * The configuration in force. Read fresh each time, so a test can change the option between * calls. * * @return ContactFormConfig */ protected function config() { return ContactFormConfig::from_environment(); } /** * Offer text for the site's privacy policy (Settings → Privacy → Policy Guide). * * @return void */ public function add_privacy_text() { if ( function_exists( 'wp_add_privacy_policy_content' ) ) { wp_add_privacy_policy_content( 'Adminkeep', $this->privacy_text() ); } } /** * The suggested privacy policy text, as HTML. The paragraph about Cloudflare and Google is left * out while the WordPress nonce is chosen: then neither service is involved. * * @return string */ public function privacy_text() { $paragraphs = array( esc_html__( 'When you send a message through the contact form on this site, your name, email address and message are emailed to the site owner. The form itself does not store them.', 'adminkeep' ), esc_html__( 'If this site keeps a log of the email it sends, a copy of your message stays in that log for its retention period. You can ask for it to be exported or erased like any other personal data this site holds.', 'adminkeep' ), ); if ( 'nonce' !== $this->config()->get( 'provider' ) ) { $paragraphs[] = sprintf( /* translators: 1: link to Cloudflare's privacy policy, 2: link to Google's privacy policy. */ esc_html__( 'To keep out spam, the form uses Cloudflare Turnstile or Google reCAPTCHA, whichever this site has chosen. When you open a page with the form, and when you send it, that service receives your IP address and information about your browser and device. See %1$s and %2$s.', 'adminkeep' ), // phpcs:ignore PluginCheck.CodeAnalysis.Offloading.OffloadedContent -- A link in suggested privacy-policy text; nothing is loaded from it. '' . esc_html__( 'Cloudflare\'s privacy policy', 'adminkeep' ) . '', '' . esc_html__( 'Google\'s privacy policy', 'adminkeep' ) . '' ); } return '

' . implode( '

', $paragraphs ) . '

'; } /** * The last recorded failure. * * @return array Empty, or array( 'time' => int, 'code' => string ). */ public static function last_error() { $error = get_option( self::LAST_ERROR_OPTION, array() ); if ( ! is_array( $error ) || ! isset( $error['time'], $error['code'] ) || ! is_string( $error['code'] ) ) { return array(); } return array( 'time' => (int) $error['time'], 'code' => $error['code'], ); } /** * Remember that a submission failed, and why, as a code. * * @param string $code One of the ERROR_* codes. * @return void */ public static function record_error( $code ) { update_option( self::LAST_ERROR_OPTION, array( 'time' => time(), 'code' => (string) $code, ), false ); } /** * Forget the last failure once a message goes through. Reads first, so a healthy site's * successful send writes nothing at all. * * @return void */ public static function clear_error() { if ( false !== get_option( self::LAST_ERROR_OPTION, false ) ) { delete_option( self::LAST_ERROR_OPTION ); } } /** * Forget the last failure when a save changes the provider. A refused secret or an unreachable * service belongs to the provider it happened under, and would only mislead about the next * one: the WordPress nonce has no keys to check at all. * * @param array $previous What was stored before the save. * @param array $saved What the save stored. * @return void */ public static function saved( array $previous, array $saved ) { $default = ContactFormConfig::defaults()['provider']; $before = isset( $previous['provider'] ) ? $previous['provider'] : $default; $after = isset( $saved['provider'] ) ? $saved['provider'] : $default; if ( $before !== $after ) { self::clear_error(); } } /** * A last-error code as a sentence for the owner. * * @param string $code One of the ERROR_* codes. * @param string $provider The provider chosen now, to name its company. * @return string */ public static function error_sentence( $code, $provider ) { $vendor = isset( ContactFormConfig::VENDORS[ $provider ] ) ? ContactFormConfig::VENDORS[ $provider ] : __( 'The spam protection service', 'adminkeep' ); switch ( $code ) { case self::ERROR_MISCONFIGURED: /* translators: %s: Cloudflare or Google. */ return sprintf( __( '%s rejected the secret key. Check the keys below and save them again.', 'adminkeep' ), $vendor ); case self::ERROR_UNREACHABLE: /* translators: %s: Cloudflare or Google. */ return sprintf( __( 'Could not reach %s to check the message, so it was refused.', 'adminkeep' ), $vendor ); case self::ERROR_MAIL: return __( 'The email could not be sent. With SMTP or Email Log on, their screens show the mail server\'s own error.', 'adminkeep' ); default: return __( 'The message could not be sent.', 'adminkeep' ); } } }