form. * render — the shortcode prints an accessible form posting to * admin-post.php. * submit — honeypot, time trap and per-IP rate limit (no nonce: these * pages usually sit behind a full-page cache, where a nonce * goes stale), validation, then the lead is stored as a * `wppilot_lead` post BEFORE the email is attempted, so a mail * failure never loses a lead. * REST — list/get/create-or-replace/delete definitions for the * WPPilot backend, behind the same callback-token check every * other backend route uses. `GET /forms/{id}` also says where * the form is embedded, and a guarded delete refuses a form * that is still embedded or still receiving leads. * * Visitor input never reaches a mail header except as a validated Reply-To * address, and lead posts are kept out of the activity feed (see * Activity_Hook_Registry), so a visitor's details stay on this site. */ class Forms { public const OPTION = 'wppilot_forms'; public const POST_TYPE = 'wppilot_lead'; public const ACTION = 'wppilot_form_submit'; public const STYLE_HANDLE = 'wppilot-forms'; public const MAX_FORMS = 20; public const MAX_FIELDS = 12; public const MAX_OPTIONS = 20; public const MAX_TEXTAREA = 5000; public const MAX_TEXT = 500; public const MIN_FILL_SECS = 3; public const RATE_LIMIT = 5; public const RATE_WINDOW = 600; public const FIELD_TYPES = array( 'text', 'email', 'tel', 'textarea', 'select', 'number', 'date' ); /** The longest quiet period a guarded delete may be asked to require, in days. */ public const MAX_QUIET_DAYS = 365; /** At most this many places are reported for one form. */ public const MAX_USAGE = 25; private const ID_PATTERN = '/^[a-z0-9_-]{1,32}$/'; private const KEY_PATTERN = '/^[a-z0-9_-]{1,40}$/'; private Logger $logger; /** Whether the stylesheet has been queued on this request. */ private bool $style_queued = false; public function __construct( Logger $logger ) { $this->logger = $logger; } /** * Hook the shortcode, the lead post type and the submit handlers. None of * these depend on a WPPilot connection. */ public function register(): void { add_action( 'init', array( $this, 'register_post_type' ) ); add_shortcode( 'wppilot_form', array( $this, 'render_shortcode' ) ); add_action( 'admin_post_nopriv_' . self::ACTION, array( $this, 'handle_submit' ) ); add_action( 'admin_post_' . self::ACTION, array( $this, 'handle_submit' ) ); } /** * The lead store: private to wp-admin, never on the front end, never * creatable by hand. Listed under the WPPilot admin menu. */ public function register_post_type(): void { register_post_type( self::POST_TYPE, array( 'labels' => array( 'name' => __( 'Leads', 'wingpilot' ), 'singular_name' => __( 'Lead', 'wingpilot' ), 'menu_name' => __( 'Leads', 'wingpilot' ), 'all_items' => __( 'Leads', 'wingpilot' ), 'edit_item' => __( 'View lead', 'wingpilot' ), 'view_item' => __( 'View lead', 'wingpilot' ), 'search_items' => __( 'Search leads', 'wingpilot' ), 'not_found' => __( 'No leads yet.', 'wingpilot' ), 'not_found_in_trash' => __( 'No leads in the trash.', 'wingpilot' ), ), 'public' => false, 'publicly_queryable' => false, 'exclude_from_search' => true, 'show_ui' => true, 'show_in_menu' => 'wppilot', 'show_in_nav_menus' => false, 'show_in_admin_bar' => false, 'show_in_rest' => false, 'has_archive' => false, 'rewrite' => false, 'query_var' => false, 'supports' => array( 'title', 'editor' ), 'capability_type' => 'post', 'map_meta_cap' => true, 'capabilities' => array( 'create_posts' => 'do_not_allow', // Leads are visitors' contact details: reading the list needs // an editor or administrator, not any author. 'edit_posts' => 'edit_others_posts', ), ) ); } // --------------------------------------------------------------------- // Definitions // --------------------------------------------------------------------- /** * Every stored definition, keyed by id. Malformed entries are skipped. * * @return array> */ public function all_forms(): array { $stored = get_option( self::OPTION, array() ); if ( ! is_array( $stored ) ) { return array(); } $out = array(); foreach ( $stored as $id => $form ) { if ( is_array( $form ) && is_string( $id ) && preg_match( self::ID_PATTERN, $id ) && ! empty( $form['fields'] ) ) { $out[ $id ] = $form; } } return $out; } /** * One stored definition, or null when there is none with this id. * * @param string $id Form id. * @return array|null */ public function get_form( string $id ): ?array { $forms = $this->all_forms(); return $forms[ $id ] ?? null; } /** * The shortcode that embeds a form. */ public static function shortcode_for( string $id ): string { return '[wppilot_form id="' . $id . '"]'; } /** * Validate and normalize a definition sent by the backend. * * Strict on purpose: a form that silently drops a field or mails nobody is * worse than a refusal the backend can explain. Returns the definition as * it will be stored, or a WP_Error (status 400) naming what is wrong. * * @param array $input Raw definition. * @param array> $existing Forms already stored, keyed by id. * @param int $now Unix time, for the timestamps. * @return array|\WP_Error */ public static function validate_definition( array $input, array $existing, int $now ): array|\WP_Error { $id = isset( $input['id'] ) && '' !== $input['id'] ? $input['id'] : null; if ( null !== $id && ( ! is_string( $id ) || ! preg_match( self::ID_PATTERN, $id ) ) ) { return self::invalid( 'wppilot_form_invalid_id', 'A form id may contain only lowercase letters, digits, "-" and "_", up to 32 characters.' ); } if ( null === $id ) { do { $id = 'f' . bin2hex( random_bytes( 4 ) ); } while ( isset( $existing[ $id ] ) ); } if ( ! isset( $existing[ $id ] ) && count( $existing ) >= self::MAX_FORMS ) { return self::invalid( 'wppilot_form_limit_reached', sprintf( 'This site already has %d forms, the most WPPilot keeps. Replace or delete one first.', self::MAX_FORMS ) ); } $title = self::clean_line( $input['title'] ?? null, 200 ); if ( null === $title || '' === $title ) { return self::invalid( 'wppilot_form_invalid_title', 'The form needs a title of at most 200 characters.' ); } $recipient_raw = $input['recipient'] ?? ''; if ( ! is_string( $recipient_raw ) ) { return self::invalid( 'wppilot_form_invalid_recipient', 'The recipient must be an email address.' ); } $recipient_raw = trim( $recipient_raw ); /* * Where enquiries go is the owner's decision, never a default: the * site's admin address is often a host's or a developer's inbox, and a * lead sent there is a lead lost. */ // Sanitizing must not change the address; if it does, it was not one. $recipient = sanitize_email( $recipient_raw ); if ( $recipient !== $recipient_raw ) { $recipient = ''; } if ( '' === $recipient || strlen( $recipient ) > 254 || ! is_email( $recipient ) ) { return self::invalid( 'wppilot_form_invalid_recipient', 'The recipient is not a valid email address.' ); } /* translators: %s: the form's title. */ $subject = self::text_or_default( $input, 'subject', 200, sprintf( __( 'New enquiry: %s', 'wingpilot' ), $title ) ); if ( $subject instanceof \WP_Error ) { return $subject; } $submit_label = self::text_or_default( $input, 'submit_label', 100, __( 'Send', 'wingpilot' ) ); if ( $submit_label instanceof \WP_Error ) { return $submit_label; } $success = $input['success_message'] ?? null; if ( null === $success || '' === $success ) { $success = __( 'Thank you. Your message has been sent and we will be in touch soon.', 'wingpilot' ); } elseif ( ! is_string( $success ) || mb_strlen( $success ) > 1000 ) { return self::invalid( 'wppilot_form_invalid_text', 'success_message must be text of at most 1000 characters.' ); } else { $success = sanitize_textarea_field( $success ); } $fields = self::validate_fields( $input['fields'] ?? null ); if ( $fields instanceof \WP_Error ) { return $fields; } $created = isset( $existing[ $id ]['created_at'] ) && is_string( $existing[ $id ]['created_at'] ) ? $existing[ $id ]['created_at'] : gmdate( 'c', $now ); return array( 'id' => $id, 'title' => $title, 'recipient' => $recipient, 'subject' => $subject, 'submit_label' => $submit_label, 'success_message' => $success, 'fields' => $fields, 'created_at' => $created, 'updated_at' => gmdate( 'c', $now ), ); } /** * @param mixed $raw The `fields` value as sent. * @return array>|\WP_Error */ private static function validate_fields( mixed $raw ): array|\WP_Error { if ( ! is_array( $raw ) || array() === $raw || ! self::is_list( $raw ) ) { return self::invalid( 'wppilot_form_no_fields', 'A form needs a list of at least one field.' ); } if ( count( $raw ) > self::MAX_FIELDS ) { return self::invalid( 'wppilot_form_too_many_fields', sprintf( 'A form may have at most %d fields.', self::MAX_FIELDS ) ); } $fields = array(); $seen = array(); foreach ( $raw as $index => $field ) { $where = sprintf( 'Field %d', $index + 1 ); if ( ! is_array( $field ) ) { return self::invalid( 'wppilot_form_invalid_field', $where . ' is not an object.' ); } $key = $field['key'] ?? null; if ( ! is_string( $key ) || ! preg_match( self::KEY_PATTERN, $key ) ) { return self::invalid( 'wppilot_form_invalid_field_key', $where . ' needs a key of lowercase letters, digits, "-" or "_", up to 40 characters.' ); } if ( isset( $seen[ $key ] ) ) { return self::invalid( 'wppilot_form_duplicate_field_key', sprintf( 'Two fields share the key "%s".', $key ) ); } $seen[ $key ] = true; $label = self::clean_line( $field['label'] ?? null, 200 ); if ( null === $label || '' === $label ) { return self::invalid( 'wppilot_form_invalid_field', sprintf( 'Field "%s" needs a label of at most 200 characters.', $key ) ); } $type = $field['type'] ?? 'text'; if ( ! is_string( $type ) || ! in_array( $type, self::FIELD_TYPES, true ) ) { return self::invalid( 'wppilot_form_invalid_field_type', sprintf( 'Field "%s" has an unsupported type. Use one of: %s.', $key, implode( ', ', self::FIELD_TYPES ) ) ); } $required = $field['required'] ?? false; if ( ! is_bool( $required ) ) { $required = filter_var( $required, FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE ); if ( null === $required ) { return self::invalid( 'wppilot_form_invalid_field', sprintf( 'Field "%s": required must be true or false.', $key ) ); } } $normalized = array( 'key' => $key, 'label' => $label, 'type' => $type, 'required' => $required, ); if ( 'select' === $type ) { $options = $field['options'] ?? null; if ( ! is_array( $options ) || array() === $options || ! self::is_list( $options ) || count( $options ) > self::MAX_OPTIONS ) { return self::invalid( 'wppilot_form_invalid_options', sprintf( 'Select field "%s" needs between 1 and %d options.', $key, self::MAX_OPTIONS ) ); } $clean = array(); foreach ( $options as $option ) { $value = self::clean_line( $option, 200 ); if ( null === $value || '' === $value ) { return self::invalid( 'wppilot_form_invalid_options', sprintf( 'Select field "%s" has an empty or overlong option.', $key ) ); } if ( in_array( $value, $clean, true ) ) { return self::invalid( 'wppilot_form_invalid_options', sprintf( 'Select field "%s" lists "%s" twice.', $key, $value ) ); } $clean[] = $value; } $normalized['options'] = $clean; } if ( isset( $field['placeholder'] ) && '' !== $field['placeholder'] ) { $placeholder = self::clean_line( $field['placeholder'], 200 ); if ( null === $placeholder ) { return self::invalid( 'wppilot_form_invalid_field', sprintf( 'Field "%s" has a placeholder longer than 200 characters.', $key ) ); } if ( '' !== $placeholder ) { $normalized['placeholder'] = $placeholder; } } $fields[] = $normalized; } return $fields; } /** * A single line of text, sanitized; null when it is not a string or is * longer than $max characters. */ private static function clean_line( mixed $value, int $max ): ?string { if ( ! is_string( $value ) || mb_strlen( $value ) > $max ) { return null; } return sanitize_text_field( $value ); } /** * An optional single-line text property, or its default when absent. * * @param array $input Raw definition. * @return string|\WP_Error */ private static function text_or_default( array $input, string $name, int $max, string $default ): string|\WP_Error { if ( ! isset( $input[ $name ] ) || '' === $input[ $name ] ) { return $default; } $value = self::clean_line( $input[ $name ], $max ); if ( null === $value || '' === $value ) { return self::invalid( 'wppilot_form_invalid_text', sprintf( '%s must be text of at most %d characters.', $name, $max ) ); } return $value; } /** * Whether an array is a JSON list (array_is_list() needs PHP 8.1). * * @param array $value Array to check. */ private static function is_list( array $value ): bool { return array_keys( $value ) === range( 0, count( $value ) - 1 ); } private static function invalid( string $code, string $message ): \WP_Error { return new \WP_Error( $code, $message, array( 'status' => 400 ) ); } // --------------------------------------------------------------------- // Rendering // --------------------------------------------------------------------- /** * `[wppilot_form id="..."]`. * * @param array|string $atts Shortcode attributes. */ public function render_shortcode( $atts ): string { $atts = shortcode_atts( array( 'id' => '' ), is_array( $atts ) ? $atts : array(), 'wppilot_form' ); $id = sanitize_key( (string) $atts['id'] ); $form = '' === $id ? null : $this->get_form( $id ); if ( null === $form ) { return ''; } $this->queue_style(); // Read-only display state set by our own redirect; nothing is changed // by it, so there is nothing for a nonce to protect. // phpcs:disable WordPress.Security.NonceVerification.Recommended $state = isset( $_GET['wppilot_form'] ) ? sanitize_key( wp_unslash( $_GET['wppilot_form'] ) ) : ''; $state_id = isset( $_GET['wppilot_form_id'] ) ? sanitize_key( wp_unslash( $_GET['wppilot_form_id'] ) ) : ''; // phpcs:enable WordPress.Security.NonceVerification.Recommended if ( $state_id !== $id ) { $state = ''; } return $this->render_form( $form, $state, $this->current_page_url(), time() ); } /** * The form's markup. Pure apart from translation and escaping, so the * shape a visitor receives can be tested directly. * * @param array $form Definition. * @param string $state '', 'sent' or 'error'. * @param string $return_url Page the visitor returns to. * @param int $now Render time, for the time trap. */ public function render_form( array $form, string $state, string $return_url, int $now ): string { $id = (string) $form['id']; $anchor = 'wppilot-form-' . $id; if ( 'sent' === $state ) { return '
' . '

' . nl2br( esc_html( (string) $form['success_message'] ) ) . '

'; } $html = '
'; if ( 'error' === $state ) { $html .= ''; } $html .= '
'; $html .= ''; $html .= ''; $html .= ''; if ( '' !== $return_url ) { $html .= ''; } // Honeypot: invisible to people and to assistive technology; a bot // that fills every input fills this one too. $hp_id = 'wppilot-' . $id . '-website'; $html .= ''; foreach ( (array) $form['fields'] as $field ) { $html .= $this->render_field( $id, $field ); } $html .= '
' . '
'; $html .= '
'; return $html; } /** * One labelled control. * * @param array $field Field definition. */ private function render_field( string $form_id, array $field ): string { $key = (string) $field['key']; $type = (string) $field['type']; $input_id = 'wppilot-' . $form_id . '-' . $key; $name = 'wppilot_fields[' . $key . ']'; $required = ! empty( $field['required'] ); $attrs = ' id="' . esc_attr( $input_id ) . '" name="' . esc_attr( $name ) . '"'; if ( $required ) { $attrs .= ' required'; } $autocomplete = self::autocomplete_hint( $key, $type ); if ( '' !== $autocomplete ) { $attrs .= ' autocomplete="' . esc_attr( $autocomplete ) . '"'; } if ( ! empty( $field['placeholder'] ) && 'select' !== $type ) { $attrs .= ' placeholder="' . esc_attr( (string) $field['placeholder'] ) . '"'; } $html = '
'; $html .= ''; if ( 'textarea' === $type ) { $html .= ''; } elseif ( 'select' === $type ) { $html .= ''; $html .= ''; foreach ( (array) ( $field['options'] ?? array() ) as $option ) { $html .= ''; } $html .= ''; } else { $html .= ''; } /** * The browser autofill hint for a field. A convenience only: it changes * nothing about what is accepted. */ private static function autocomplete_hint( string $key, string $type ): string { if ( 'email' === $type ) { return 'email'; } if ( 'tel' === $type ) { return 'tel'; } if ( 'text' === $type && in_array( $key, array( 'name', 'full_name', 'fullname', 'your_name', 'your-name', 'full-name' ), true ) ) { return 'name'; } if ( 'text' === $type && in_array( $key, array( 'company', 'organization', 'organisation', 'business' ), true ) ) { return 'organization'; } return ''; } /** * Queue the small stylesheet once, only on pages that show a form. */ private function queue_style(): void { if ( $this->style_queued ) { return; } $this->style_queued = true; if ( ! wp_style_is( self::STYLE_HANDLE, 'registered' ) ) { wp_register_style( self::STYLE_HANDLE, false, array(), WPPILOT_VERSION ); wp_add_inline_style( self::STYLE_HANDLE, self::css() ); } wp_enqueue_style( self::STYLE_HANDLE ); } /** * Minimal styling that inherits the theme's type and colours; the submit * button takes the theme's and Elementor kit's own button style through its * classes. */ public static function css(): string { return '.wppilot-form{font:inherit;color:inherit;margin:1.5em 0}' . '.wppilot-form__form{display:flex;flex-direction:column;gap:1.1em;margin:0}' . '.wppilot-form__field{display:flex;flex-direction:column;gap:.4em}' . '.wppilot-form__field label{font:inherit;font-weight:600;color:inherit}' . '.wppilot-form__required{opacity:.7}' . '.wppilot-form input:not([type=hidden]),.wppilot-form select,.wppilot-form textarea{display:block;width:100%;max-width:100%;box-sizing:border-box;font:inherit;color:inherit;background-color:transparent;padding:.65em .8em;border:1px solid rgba(127,127,127,.55);border-radius:4px;line-height:1.4}' . '.wppilot-form textarea{min-height:8em;resize:vertical}' . '.wppilot-form input:focus,.wppilot-form select:focus,.wppilot-form textarea:focus{outline:2px solid currentColor;outline-offset:1px}' . '.wppilot-form__actions{margin-top:.4em}' . '.wppilot-form__submit{cursor:pointer}' . '.wppilot-form__notice{margin:0 0 1em;padding:.8em 1em;border:1px solid currentColor;border-radius:4px}' . '.wppilot-form__notice--error{border-color:#b32d2e}' . '.wppilot-form__hp{position:absolute!important;left:-10000px!important;top:auto;width:1px;height:1px;overflow:hidden}'; } /** * The page the visitor is on, for the post-submit redirect. */ private function current_page_url(): string { if ( is_singular() ) { $url = get_permalink( get_queried_object_id() ); return is_string( $url ) ? $url : ''; } return ''; } // --------------------------------------------------------------------- // Submission // --------------------------------------------------------------------- /** * admin-post handler, for visitors and logged-in users alike. * * There is deliberately no nonce: these forms live on cached pages, where * a nonce printed at cache time expires while the page is still served. * The honeypot, time trap and rate limit in process_submission() are the * defence, and a submission can only ever create a lead and send one * email to the address the owner chose. */ public function handle_submit(): void { // phpcs:ignore WordPress.Security.NonceVerification.Missing, WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- public form on cached pages (see above); every value is validated and sanitized per field in process_submission(). $post = wp_unslash( $_POST ); $ip = isset( $_SERVER['REMOTE_ADDR'] ) ? sanitize_text_field( wp_unslash( $_SERVER['REMOTE_ADDR'] ) ) : ''; $url = $this->process_submission( is_array( $post ) ? $post : array(), $ip, time() ); wp_safe_redirect( $url ); exit; } /** * Decide what a submission does and where the visitor goes next. * * Spam is answered exactly like success, so a bot learns nothing. A valid * lead is stored first and emailed second: if the mail fails the lead is * still in wp-admin. * * @param array $post Unslashed request body. * @param string $ip Visitor address, for the rate limit only. * @param int $now Unix time. * @return string The redirect URL. */ public function process_submission( array $post, string $ip, int $now ): string { $form_id = isset( $post['wppilot_form_id'] ) && is_string( $post['wppilot_form_id'] ) ? sanitize_key( $post['wppilot_form_id'] ) : ''; $return = $this->return_url( $post['wppilot_return'] ?? null ); $form = '' === $form_id ? null : $this->get_form( $form_id ); if ( null === $form ) { return $return; } $sent = $this->state_url( $return, 'sent', $form_id ); $honeypot = $post['wppilot_hp'] ?? ''; if ( ! is_string( $honeypot ) || '' !== $honeypot ) { $this->logger->info( 'Form ' . $form_id . ': submission discarded (honeypot).' ); return $sent; } $rendered = $post['wppilot_ts'] ?? ''; if ( ! is_string( $rendered ) || ! ctype_digit( $rendered ) || $now - (int) $rendered < self::MIN_FILL_SECS ) { $this->logger->info( 'Form ' . $form_id . ': submission discarded (sent too quickly).' ); return $sent; } if ( ! $this->within_rate_limit( $ip, $form_id, $now ) ) { $this->logger->info( 'Form ' . $form_id . ': submission discarded (rate limit).' ); return $sent; } $values = self::validate_submission( $form, $post['wppilot_fields'] ?? null ); if ( null === $values ) { return $this->state_url( $return, 'error', $form_id ); } $this->store_and_notify( $form, $values, $return ); return $sent; } /** * Check every field against its definition. * * @param array $form Definition. * @param mixed $raw The posted `wppilot_fields` array. * @return array|null Clean values keyed by field key, or null when invalid. */ public static function validate_submission( array $form, mixed $raw ): ?array { $raw = is_array( $raw ) ? $raw : array(); $values = array(); foreach ( (array) $form['fields'] as $field ) { $key = (string) $field['key']; $type = (string) $field['type']; $value = $raw[ $key ] ?? ''; if ( ! is_string( $value ) ) { return null; } $max = 'textarea' === $type ? self::MAX_TEXTAREA : self::MAX_TEXT; if ( mb_strlen( $value ) > $max ) { return null; } $value = 'textarea' === $type ? sanitize_textarea_field( $value ) : sanitize_text_field( $value ); $value = trim( $value ); if ( '' === $value ) { if ( ! empty( $field['required'] ) ) { return null; } $values[ $key ] = ''; continue; } switch ( $type ) { case 'email': if ( ! is_email( $value ) ) { return null; } break; case 'select': if ( ! in_array( $value, (array) ( $field['options'] ?? array() ), true ) ) { return null; } break; case 'number': if ( ! is_numeric( $value ) ) { return null; } break; case 'date': if ( ! preg_match( '/^(\d{4})-(\d{2})-(\d{2})$/', $value, $m ) || ! checkdate( (int) $m[2], (int) $m[3], (int) $m[1] ) ) { return null; } break; case 'tel': if ( ! preg_match( '/^[0-9+().\-\s\/x]{3,40}$/i', $value ) ) { return null; } break; } $values[ $key ] = $value; } return $values; } /** * At most RATE_LIMIT accepted attempts per visitor address per form in a * RATE_WINDOW-second window. The address is hashed, never stored. */ private function within_rate_limit( string $ip, string $form_id, int $now ): bool { $key = 'wppilot_frl_' . substr( wp_hash( $ip . $form_id ), 0, 32 ); $bucket = get_transient( $key ); if ( ! is_array( $bucket ) || ! isset( $bucket['start'], $bucket['count'] ) || $now - (int) $bucket['start'] >= self::RATE_WINDOW ) { $bucket = array( 'start' => $now, 'count' => 0, ); } if ( (int) $bucket['count'] >= self::RATE_LIMIT ) { return false; } $bucket['count'] = (int) $bucket['count'] + 1; set_transient( $key, $bucket, max( 1, self::RATE_WINDOW - ( $now - (int) $bucket['start'] ) ) ); return true; } /** * Save the lead, then email it. The lead is kept whatever the mail does. * * @param array $form Definition. * @param array $values Validated values. * @param string $page Page the form was sent from. * @return int The lead's post id (0 if it could not be stored). */ private function store_and_notify( array $form, array $values, string $page ): int { $lines = array(); $reply_to = ''; $who = ''; $email = ''; foreach ( (array) $form['fields'] as $field ) { $key = (string) $field['key']; $value = $values[ $key ] ?? ''; if ( '' === $value ) { continue; } $lines[] = array( (string) $field['label'], $value ); if ( 'email' === $field['type'] && '' === $reply_to ) { $candidate = str_replace( array( "\r", "\n" ), '', $value ); if ( is_email( $candidate ) ) { $reply_to = $candidate; $email = $candidate; } } if ( '' === $who && 'text' === $field['type'] && str_contains( $key, 'name' ) ) { $who = $value; } } if ( '' === $who ) { $who = $email; } $content = implode( "\n", array_map( static fn( array $line ): string => esc_html( $line[0] ) . ': ' . esc_html( $line[1] ), $lines ) ); $title = (string) $form['title'] . ( '' !== $who ? ' — ' . $who : '' ); $lead_id = wp_insert_post( array( 'post_type' => self::POST_TYPE, 'post_status' => 'publish', 'post_title' => wp_slash( sanitize_text_field( $title ) ), 'post_content' => wp_slash( $content ), 'post_author' => 0, ), true ); if ( is_wp_error( $lead_id ) || ! $lead_id ) { $this->logger->error( 'Form ' . $form['id'] . ': the lead could not be stored.' ); $lead_id = 0; } else { update_post_meta( (int) $lead_id, '_wppilot_form_id', (string) $form['id'] ); update_post_meta( (int) $lead_id, '_wppilot_mail_sent', 0 ); } $body = implode( "\n", array_map( static fn( array $line ): string => $line[0] . ': ' . $line[1], $lines ) ); if ( '' !== $page ) { /* translators: %s: address of the page the form was sent from. */ $body .= "\n\n" . sprintf( __( 'Sent from: %s', 'wingpilot' ), $page ); } $headers = array(); if ( '' !== $reply_to ) { $headers[] = 'Reply-To: ' . $reply_to; } $subject = str_replace( array( "\r", "\n" ), ' ', (string) $form['subject'] ); $mailed = (bool) wp_mail( (string) $form['recipient'], $subject, $body, $headers ); if ( $lead_id > 0 && $mailed ) { update_post_meta( (int) $lead_id, '_wppilot_mail_sent', 1 ); } if ( ! $mailed ) { $this->logger->warning( 'Form ' . $form['id'] . ': the notification email could not be sent; the lead is stored in wp-admin.' ); } else { $this->logger->info( 'Form ' . $form['id'] . ': lead stored and emailed.' ); } return (int) $lead_id; } /** * Where the visitor returns to: the page that rendered the form, else the * referrer, else the home page — never another site. */ private function return_url( mixed $posted ): string { $url = is_string( $posted ) && '' !== $posted ? esc_url_raw( $posted ) : ''; if ( '' === $url ) { $referer = wp_get_referer(); $url = is_string( $referer ) ? $referer : ''; } $home = home_url( '/' ); if ( '' === $url ) { return $home; } $url = remove_query_arg( array( 'wppilot_form', 'wppilot_form_id' ), $url ); $url = (string) preg_replace( '/#.*$/', '', $url ); return (string) wp_validate_redirect( $url, $home ); } private function state_url( string $return, string $state, string $form_id ): string { return add_query_arg( array( 'wppilot_form' => $state, 'wppilot_form_id' => $form_id, ), $return ) . '#wppilot-form-' . $form_id; } // --------------------------------------------------------------------- // REST // --------------------------------------------------------------------- /** * Register the definition routes under wppilot/v1. * * @param callable $permission The backend callback-token check. */ public function register_routes( callable $permission ): void { $id_arg = array( 'id' => array( 'validate_callback' => static fn( $value ): bool => is_string( $value ) && 1 === preg_match( self::ID_PATTERN, $value ), ), ); register_rest_route( 'wppilot/v1', '/forms', array( array( 'methods' => 'GET', 'callback' => array( $this, 'rest_list' ), 'permission_callback' => $permission, ), array( 'methods' => 'POST', 'callback' => array( $this, 'rest_save' ), 'permission_callback' => $permission, ), ) ); register_rest_route( 'wppilot/v1', '/forms/(?P[a-z0-9_-]{1,32})', array( array( 'methods' => 'GET', 'callback' => array( $this, 'rest_get' ), 'permission_callback' => $permission, 'args' => $id_arg, ), array( 'methods' => 'DELETE', 'callback' => array( $this, 'rest_delete' ), 'permission_callback' => $permission, 'args' => $id_arg + array( 'unused_for_days' => array( 'validate_callback' => static fn( $value ): bool => is_numeric( $value ) && (int) $value >= 1 && (int) $value <= self::MAX_QUIET_DAYS, ), ), ), ) ); } /** GET /forms */ public function rest_list( \WP_REST_Request $request ): \WP_REST_Response|\WP_Error { $denied = $this->deny_unless_admin(); if ( $denied ) { return $denied; } $out = array(); foreach ( $this->all_forms() as $form ) { $out[] = $this->with_stats( $form ); } $body = array( 'forms' => $out ); // 1.5.16: Elementor Pro's own forms — what each does with an enquiry // and what Elementor → Submissions kept — and how this site sends // mail, so "where are my leads?" is answered from the site. $elementor = Elementor_Forms::report( time() ); if ( null !== $elementor ) { // An Elementor form with no recipient saved emails the admin address. $elementor['default_email_to'] = sanitize_email( (string) get_option( 'admin_email', '' ) ); $body['elementor_forms'] = $elementor; } // Other form plugins' forms and where they email enquiries (`Other_Forms`). $other = Other_Forms::report(); if ( null !== $other ) { $body['other_forms'] = $other; } $body['mail'] = Mail_Status::describe(); return new \WP_REST_Response( $body, 200 ); } /** GET /forms/{id} */ public function rest_get( \WP_REST_Request $request ): \WP_REST_Response|\WP_Error { $denied = $this->deny_unless_admin(); if ( $denied ) { return $denied; } $id = (string) $request->get_param( 'id' ); $form = $this->get_form( $id ); if ( null === $form ) { return $this->not_found(); } return new \WP_REST_Response( array( 'form' => $this->with_stats( $form ), 'used_on' => $this->usage( $id ), ), 200 ); } /** POST /forms — create, or replace when `id` names an existing form. */ public function rest_save( \WP_REST_Request $request ): \WP_REST_Response|\WP_Error { $denied = $this->deny_unless_admin(); if ( $denied ) { return $denied; } $input = array(); foreach ( array( 'id', 'title', 'recipient', 'subject', 'submit_label', 'success_message', 'fields' ) as $name ) { $value = $request->get_param( $name ); if ( null !== $value ) { $input[ $name ] = $value; } } $forms = $this->all_forms(); $definition = self::validate_definition( $input, $forms, time() ); if ( $definition instanceof \WP_Error ) { return $definition; } $id = (string) $definition['id']; $previous = $forms[ $id ] ?? null; $forms[ $id ] = $definition; update_option( self::OPTION, $forms, false ); $this->logger->info( 'Form ' . $id . ( null === $previous ? ' created' : ' replaced' ) . '.' ); return new \WP_REST_Response( array( 'form' => $this->with_stats( $definition ), 'shortcode' => self::shortcode_for( $id ), 'previous' => $previous, ), null === $previous ? 201 : 200 ); } /** * DELETE /forms/{id} — the definition goes; its leads stay. * * With `unused_for_days=N` the delete is guarded: it is refused (409) while * any page, post, template or widget still embeds the form, or while it has * received a lead in the last N days. The check runs here, in the same * request as the delete, so nothing the backend read earlier can be stale. * Without the parameter the delete is unconditional, as it always was. */ public function rest_delete( \WP_REST_Request $request ): \WP_REST_Response|\WP_Error { $denied = $this->deny_unless_admin(); if ( $denied ) { return $denied; } $id = (string) $request->get_param( 'id' ); $forms = $this->all_forms(); if ( ! isset( $forms[ $id ] ) ) { return $this->not_found(); } $quiet_days = $request->get_param( 'unused_for_days' ); if ( null !== $quiet_days ) { $refusal = $this->removal_refusal( $id, (int) $quiet_days, time() ); if ( null !== $refusal ) { $this->logger->info( 'Form ' . $id . ' not deleted: ' . $refusal->get_error_code() . '.' ); return $refusal; } } $previous = $forms[ $id ]; unset( $forms[ $id ] ); update_option( self::OPTION, $forms, false ); $this->logger->info( 'Form ' . $id . ' deleted; its leads are kept.' ); return new \WP_REST_Response( array( 'deleted' => true, 'previous' => $previous, ), 200 ); } /** * A definition plus its shortcode and lead counts. * * @param array $form Definition. * @return array */ private function with_stats( array $form ): array { $id = (string) $form['id']; $latest = new \WP_Query( array( 'post_type' => self::POST_TYPE, 'post_status' => 'publish', // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key -- at most 20 forms; the count is per form by definition. 'meta_key' => '_wppilot_form_id', // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_value -- see above. 'meta_value' => $id, 'posts_per_page' => 1, 'orderby' => 'date', 'order' => 'DESC', 'update_post_meta_cache' => false, 'update_post_term_cache' => false, ) ); $last = null; if ( ! empty( $latest->posts ) && $latest->posts[0] instanceof \WP_Post ) { $stamp = strtotime( $latest->posts[0]->post_date_gmt . ' UTC' ); $last = false === $stamp ? null : gmdate( 'c', $stamp ); } return array_merge( $form, array( 'shortcode' => self::shortcode_for( $id ), 'submissions_total' => (int) $latest->found_posts, 'submissions_last_30d' => $this->submissions_since( $id, time() - 30 * DAY_IN_SECONDS ), 'last_submission_at' => is_string( $last ) ? $last : null, ) ); } /** * How many leads a form has received since a moment. * * @param string $id Form id. * @param int $since Unix time. */ private function submissions_since( string $id, int $since ): int { $recent = new \WP_Query( array( 'post_type' => self::POST_TYPE, 'post_status' => 'publish', // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key -- at most 20 forms; the count is per form by definition. 'meta_key' => '_wppilot_form_id', // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_value -- see above. 'meta_value' => $id, 'posts_per_page' => 1, 'fields' => 'ids', 'date_query' => array( array( 'column' => 'post_date_gmt', 'after' => gmdate( 'Y-m-d H:i:s', $since ), ), ), 'update_post_meta_cache' => false, 'update_post_term_cache' => false, ) ); return (int) $recent->found_posts; } /** * Why a form may not be removed right now, or null when it may. * * @param string $id Form id. * @param int $quiet_days Days without a lead the form must have had. * @param int $now Unix time. */ private function removal_refusal( string $id, int $quiet_days, int $now ): ?\WP_Error { $used_on = $this->usage( $id ); if ( array() !== $used_on ) { return new \WP_Error( 'wppilot_form_in_use', 'The form is still embedded on the site, so it was not deleted.', array( 'status' => 409, 'used_on' => $used_on, ) ); } if ( $this->submissions_since( $id, $now - $quiet_days * DAY_IN_SECONDS ) > 0 ) { return new \WP_Error( 'wppilot_form_recently_used', sprintf( 'The form received a submission in the last %d days, so it was not deleted.', $quiet_days ), array( 'status' => 409 ) ); } return null; } /** * Every place on the site that embeds a form, as far as WordPress stores it. * * A form is embedded only as its shortcode, and the shortcode can sit in a * post body (classic, block or a shortcode block), in any post meta a page * builder keeps its layout in (Elementor's `_elementor_data`, where quotes * are JSON-escaped), or in an option (a text or block widget). All three are * searched, across every post type and status except the trash, revisions, * auto-drafts and leads themselves — a draft or a template that embeds the * form still means somebody meant to use it. Matching errs towards "in * use": a false positive costs a refused delete, a false negative a form * that disappears from a live page. * * @param string $id Form id. * @return array */ public function usage( string $id ): array { global $wpdb; $like = '%' . $wpdb->esc_like( '[wppilot_form' ) . '%'; $excluded = "'revision', 'nav_menu_item', '" . self::POST_TYPE . "'"; $posts = $wpdb->posts; $meta = $wpdb->postmeta; $options = $wpdb->options; // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- a live usage check must not be cached; table names come from $wpdb and the only literals are constants. $post_rows = array_merge( (array) $wpdb->get_results( $wpdb->prepare( "SELECT ID AS post_id, post_title, post_status, post_type, post_content AS haystack FROM {$posts} WHERE post_content LIKE %s AND post_type NOT IN ({$excluded}) AND post_status NOT IN ('trash', 'auto-draft', 'inherit')", $like ), ARRAY_A ), (array) $wpdb->get_results( $wpdb->prepare( "SELECT p.ID AS post_id, p.post_title, p.post_status, p.post_type, m.meta_value AS haystack FROM {$meta} m INNER JOIN {$posts} p ON p.ID = m.post_id WHERE m.meta_value LIKE %s AND p.post_type NOT IN ({$excluded}) AND p.post_status NOT IN ('trash', 'auto-draft', 'inherit')", $like ), ARRAY_A ) ); $option_rows = (array) $wpdb->get_results( $wpdb->prepare( "SELECT option_name, option_value AS haystack FROM {$options} WHERE option_value LIKE %s AND option_name NOT LIKE %s AND option_name NOT LIKE %s AND option_name <> %s", $like, $wpdb->esc_like( '_transient_' ) . '%', $wpdb->esc_like( '_site_transient_' ) . '%', self::OPTION ), ARRAY_A ); // phpcs:enable $out = array(); $seen = array(); foreach ( $post_rows as $row ) { $post_id = (int) ( $row['post_id'] ?? 0 ); if ( isset( $seen[ $post_id ] ) || ! self::embeds( (string) ( $row['haystack'] ?? '' ), $id ) ) { continue; } $seen[ $post_id ] = true; $out[] = array( 'kind' => 'post', 'id' => $post_id, 'title' => (string) ( $row['post_title'] ?? '' ), 'status' => (string) ( $row['post_status'] ?? '' ), 'type' => (string) ( $row['post_type'] ?? '' ), ); } foreach ( $option_rows as $row ) { if ( ! self::embeds( (string) ( $row['haystack'] ?? '' ), $id ) ) { continue; } $out[] = array( 'kind' => 'option', 'id' => null, 'title' => (string) ( $row['option_name'] ?? '' ), 'status' => null, 'type' => null, ); } return array_slice( $out, 0, self::MAX_USAGE ); } /** * Whether stored text embeds this form's shortcode. * * Tolerates the quoting every store uses: plain, single, none, HTML-encoded * and JSON-escaped (`id=\"abc\"`, as Elementor stores it). The id must end * where the id ends, so `quote` never matches `quote-2`. * * @param string $text Stored text. * @param string $id Form id. */ public static function embeds( string $text, string $id ): bool { $pattern = '/\[wppilot_form\b[^\]]*?\bid\s*=\s*(?:\\\\?["\']|"|�?34;)?' . preg_quote( $id, '/' ) . '(?![a-z0-9_-])/i'; return 1 === preg_match( $pattern, $text ); } private function deny_unless_admin(): ?\WP_Error { if ( current_user_can( 'manage_options' ) ) { return null; } return new \WP_Error( 'wppilot_forbidden', 'The connected user cannot manage forms.', array( 'status' => 403 ) ); } private function not_found(): \WP_Error { return new \WP_Error( 'wppilot_form_not_found', 'No form with that id.', array( 'status' => 404 ) ); } }