get( 'fields' )`. */ class Elementor_Forms extends Integration { /** The most forms the settings screen lists. */ const MAX_FORMS = 200; /** The most posts scanned for forms. */ const MAX_POSTS = 100; /** The largest `_elementor_data` decoded, in bytes. */ const MAX_DATA_BYTES = 2097152; /** Transient caching the scan. */ const CACHE_KEY = 'launchlist_elementor_forms'; /** * The Elementor field types relayed, and the type LaunchList is told. Every other type is left out: * honeypot, recaptcha, recaptcha_v3, hcaptcha, turnstile, html, step, upload, password and whatever * an add-on registers (signatures, payments). */ const TYPE_MAP = array( 'text' => 'text', 'email' => 'email', 'textarea' => 'textarea', 'url' => 'url', 'tel' => 'tel', 'radio' => 'radio', 'select' => 'select', 'checkbox' => 'checkbox', 'acceptance' => 'checkbox', 'number' => 'number', 'date' => 'date', 'time' => 'text', 'hidden' => 'hidden', ); /** * {@inheritDoc} */ public function slug() { return 'elementor-forms'; } /** * {@inheritDoc} */ public function name() { return 'Elementor Pro forms'; } /** * Elementor Pro specifically: the free Elementor has no form widget. * * @return bool */ public function is_available() { return defined( 'ELEMENTOR_PRO_VERSION' ); } /** * {@inheritDoc} */ public function forms() { $cached = get_transient( self::CACHE_KEY ); if ( is_array( $cached ) ) { return $cached; } global $wpdb; // Posts (pages, templates, popups) whose Elementor data holds a form widget. Revisions are // copies; documents too large to decode are left out, unread. $rows = $wpdb->get_results( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching $wpdb->prepare( "SELECT p.ID, p.post_title, CASE WHEN LENGTH(pm.meta_value) <= %d THEN pm.meta_value ELSE '' END AS data FROM {$wpdb->postmeta} pm INNER JOIN {$wpdb->posts} p ON p.ID = pm.post_id WHERE pm.meta_key = %s AND pm.meta_value LIKE %s AND p.post_type <> %s AND p.post_status IN ('publish', 'private', 'future', 'draft', 'pending') ORDER BY p.post_modified DESC LIMIT %d", self::MAX_DATA_BYTES, '_elementor_data', '%' . $wpdb->esc_like( '"widgetType":"form"' ) . '%', 'revision', self::MAX_POSTS ) ); $forms = array(); foreach ( (array) $rows as $row ) { $data = $this->decode( $row->data ); foreach ( $this->find_forms( $data ) as $element ) { $settings = isset( $element['settings'] ) && is_array( $element['settings'] ) ? $element['settings'] : array(); $name = $this->form_name( $settings, true ); $post = self::plain( $row->post_title ); $forms[] = array( 'id' => $row->ID . ':' . $element['id'], /* translators: 1: an Elementor form's name, 2: the page or template it is on. */ 'title' => '' !== $post ? sprintf( __( '%1$s (%2$s)', 'launchlist' ), $name, $post ) : $name, ); if ( count( $forms ) >= self::MAX_FORMS ) { break 2; } } } usort( $forms, static function ( $a, $b ) { return strcasecmp( $a['title'], $b['title'] ); } ); set_transient( self::CACHE_KEY, $forms, 10 * MINUTE_IN_SECONDS ); return $forms; } /** * {@inheritDoc} */ public function fields( $form_id ) { $element = $this->element( $form_id ); return $element ? $this->definitions( $element ) : array(); } /** * {@inheritDoc} */ public function register() { if ( ! $this->claim( 'hooks' ) ) { return; } /* * `elementor_pro/forms/new_record` ($record, $ajax_handler). Ajax_Handler::ajax_send_form() fires * it only once Form_Record::validate() passed (required fields, plus the honeypot, reCAPTCHA, * hCaptcha and Turnstile through the `elementor_pro/forms/validation*` hooks) and process_fields() * added no error, after every submit action ran, whatever an action threw (a failed email is * caught and the hook still fires). Elementor forms only submit by AJAX, once per submission. */ add_action( 'elementor_pro/forms/new_record', array( $this, 'handle' ), 10, 2 ); // `elementor/document/after_save` (Elementor core Document::save()): a saved page or template may // have gained or lost a form, so the cached list is rebuilt on its next read. add_action( 'elementor/document/after_save', array( $this, 'forget_forms' ), 10, 0 ); } /** * Drop the cached list of forms. * * @return void */ public function forget_forms() { delete_transient( self::CACHE_KEY ); } /** * Relay one Elementor form record. * * @param \ElementorPro\Modules\Forms\Classes\Form_Record $record The submission. * @param mixed $ajax_handler The AJAX handler (unused). * @return void */ public function handle( $record, $ajax_handler = null ) { try { if ( ! is_object( $record ) || ! method_exists( $record, 'get_form_settings' ) ) { return; } $form_id = $this->record_form_id( $record ); $fields = (array) $record->get( 'fields' ); // Keyed by content: an object hash is reused once its object is freed. if ( '' === $form_id || ! $this->claim( 'record:' . $form_id . ':' . md5( serialize( $fields ) ) ) ) { // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.serialize_serialize return; } $values = array(); foreach ( $fields as $id => $field ) { if ( ! is_array( $field ) ) { continue; } $type = isset( $field['type'] ) ? (string) $field['type'] : 'text'; if ( ! isset( self::TYPE_MAP[ $type ] ) ) { continue; } $name = isset( $field['id'] ) ? (string) $field['id'] : (string) $id; // Checkboxes and multiple selects keep their list in `raw_value`; `value` is joined with ", ". $value = isset( $field['raw_value'] ) && is_array( $field['raw_value'] ) ? $field['raw_value'] : ( isset( $field['value'] ) ? $field['value'] : '' ); $label = isset( $field['title'] ) ? self::plain( $field['title'] ) : ''; self::push( $values, $name, '' !== $label ? $label : $name, self::TYPE_MAP[ $type ], $value ); } $this->send( $form_id, $this->form_name( array( 'form_name' => $record->get_form_settings( 'form_name' ) ), false ), $values ); } catch ( \Throwable $e ) { $this->fail( $e ); } } /** * The `{post_id}:{element_id}` of a submitted form, the same ID forms() lists. * * @param object $record The form record. * @return string */ private function record_form_id( $record ) { $element_id = (string) $record->get_form_settings( 'id' ); $post_id = (int) $record->get_form_settings( 'form_post_id' ); $edit_id = (int) $record->get_form_settings( 'edit_post_id' ); if ( '' === $element_id || ! $post_id ) { return ''; } // A global widget: `id` is the widget's placement on the page, `form_post_id` the template that // holds the form. forms() found the form inside that template, under its own element ID. if ( $edit_id && $edit_id !== $post_id ) { $data = $this->decode( get_post_meta( $post_id, '_elementor_data', true ) ); if ( isset( $data[0]['id'], $data[0]['widgetType'] ) && 'form' === $data[0]['widgetType'] ) { $element_id = (string) $data[0]['id']; } } return $post_id . ':' . $element_id; } /** * The form widget behind a `{post_id}:{element_id}` ID. * * @param string $form_id The form's ID. * @return array|null */ private function element( $form_id ) { $parts = explode( ':', (string) $form_id, 2 ); if ( 2 !== count( $parts ) || ! (int) $parts[0] || '' === $parts[1] ) { return null; } foreach ( $this->find_forms( $this->decode( get_post_meta( (int) $parts[0], '_elementor_data', true ) ) ) as $element ) { if ( (string) $element['id'] === $parts[1] ) { return $element; } } return null; } /** * Decode `_elementor_data`, refusing anything larger than MAX_DATA_BYTES. * * @param mixed $json The meta value. * @return array */ private function decode( $json ) { if ( is_array( $json ) ) { return $json; } if ( ! is_string( $json ) || '' === $json || strlen( $json ) > self::MAX_DATA_BYTES ) { return array(); } $data = json_decode( $json, true ); return is_array( $data ) ? $data : array(); } /** * Every form widget in a tree of Elementor elements, in document order. * * @param array $elements Elements, each with optional `elements` children. * @param int $depth Current depth, to stop runaway trees. * @return array */ private function find_forms( array $elements, $depth = 0 ) { $found = array(); if ( $depth > 20 ) { return $found; } foreach ( $elements as $element ) { if ( ! is_array( $element ) ) { continue; } if ( isset( $element['widgetType'], $element['id'] ) && 'form' === $element['widgetType'] ) { $found[] = $element; } if ( ! empty( $element['elements'] ) && is_array( $element['elements'] ) ) { $found = array_merge( $found, $this->find_forms( $element['elements'], $depth + 1 ) ); } } return $found; } /** * The answer fields of a form widget, in form order. * * @param array $element The form widget. * @return array */ private function definitions( array $element ) { $definitions = array(); $form_fields = isset( $element['settings']['form_fields'] ) && is_array( $element['settings']['form_fields'] ) ? $element['settings']['form_fields'] : array(); foreach ( $form_fields as $field ) { if ( ! is_array( $field ) || empty( $field['custom_id'] ) ) { continue; } // Elementor leaves a control at its default out of the saved data: `text` for the type. $type = ! empty( $field['field_type'] ) ? (string) $field['field_type'] : 'text'; if ( ! isset( self::TYPE_MAP[ $type ] ) ) { continue; } $name = (string) $field['custom_id']; $label = isset( $field['field_label'] ) ? self::plain( $field['field_label'] ) : ''; if ( '' === $label && isset( $field['placeholder'] ) ) { $label = self::plain( $field['placeholder'] ); } $definitions[] = array( 'name' => $name, 'label' => '' !== $label ? $label : $name, 'type' => self::TYPE_MAP[ $type ], ); } return $definitions; } /** * A form's name, or Elementor's own default when the founder kept it: translated for the settings * screen, English when submitted. * * @param array $settings The widget settings. * @param bool $translate Whether the name is for the settings screen. * @return string */ private function form_name( array $settings, $translate ) { $name = isset( $settings['form_name'] ) ? self::plain( $settings['form_name'] ) : ''; if ( '' !== $name ) { return $name; } return $translate ? __( 'New Form', 'launchlist' ) : 'New Form'; } }