Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
37.29% covered (danger)
37.29%
22 / 59
50.00% covered (danger)
50.00%
3 / 6
CRAP
0.00% covered (danger)
0.00%
0 / 1
Post_To_Url
37.29% covered (danger)
37.29%
22 / 59
50.00% covered (danger)
50.00%
3 / 6
166.06
0.00% covered (danger)
0.00%
0 / 1
 init
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 __construct
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 get_setup
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
6
 feedback_post_hook
0.00% covered (danger)
0.00%
0 / 21
0.00% covered (danger)
0.00%
0 / 1
72
 post_to_url
0.00% covered (danger)
0.00%
0 / 10
0.00% covered (danger)
0.00%
0 / 1
6
 get_form_data
100.00% covered (success)
100.00%
18 / 18
100.00% covered (success)
100.00%
1 / 1
9
1<?php
2/**
3 * Post to URL using Jetpack Contact Forms.
4 *
5 * @package automattic/jetpack
6 */
7
8namespace Automattic\Jetpack\Forms\Service;
9
10use Automattic\Jetpack\Forms\ContactForm\Feedback;
11use WP_Error;
12
13/**
14 * Class Post_To_Url
15 *
16 * Hooks on Jetpack's Contact form to post form data to some URL.
17 */
18class Post_To_Url {
19    /**
20     * Singleton instance
21     *
22     * @var Post_To_Url
23     */
24    private static $instance = null;
25
26    /**
27     * Initialize and return singleton instance.
28     *
29     * @return Post_To_Url
30     */
31    public static function init() {
32        if ( null === self::$instance ) {
33            self::$instance = new self();
34        }
35
36        return self::$instance;
37    }
38
39    /**
40     * Post_To_Url class constructor.
41     * Hooks on `grunion_after_feedback_post_inserted` action to send form data to specified URL.
42     * NOTE: As a singleton, this constructor is private and only callable from ::init, which will return the singleton instance,
43     * effectively preventing multiple instances of this class (hence, multiple hooks triggering the POST request).
44     */
45    private function __construct() {
46        add_action( 'grunion_after_feedback_post_inserted', array( $this, 'feedback_post_hook' ), 10, 4 );
47    }
48
49    /**
50     * Get the setup for the post to URL.
51     *
52     * Salesforce-only: posts to the fixed Salesforce Web-to-Lead endpoint when
53     * the form has a salesforceData.organizationId attribute. The legacy
54     * postToUrl override is intentionally NOT honored here — postToUrl is
55     * deprecated and the new pipeline (Form_Webhooks) already handles it with
56     * proper URL validation. Honoring it here too would let an Editor with
57     * Salesforce enabled override the destination to an arbitrary URL,
58     * including internal/cloud-metadata endpoints (SSRF).
59     *
60     * @param array $attributes - the attributes of the contact form.
61     * @return array|bool Array setup, or false if Salesforce isn't configured.
62     */
63    private function get_setup( $attributes = array() ) {
64        if ( empty( $attributes['salesforceData']['organizationId'] ) ) {
65            return false;
66        }
67
68        return array(
69            'url'    => 'https://webto.salesforce.com/servlet/servlet.WebToLead?encoding=UTF-8',
70            'format' => 'urlencoded',
71        );
72    }
73
74    /**
75     * Hook on `grunion_after_feedback_post_inserted` action to send form data to specified URL.
76     *
77     * @param int   $post_id - the post_id for the CPT that is created.
78     * @param array $fields - a collection of Automattic\Jetpack\Forms\ContactForm\Contact_Form_Field instances.
79     * @param bool  $is_spam - marked as spam by Akismet(?).
80     * @param array $entry_values - extra fields added to from the contact form.
81     *
82     * @return null|void
83     */
84    public function feedback_post_hook( $post_id, $fields, $is_spam, $entry_values ) {
85        // Try and get the form from any of the fields
86        $form = null;
87        foreach ( $fields as $field ) {
88            if ( ! empty( $field->form ) ) {
89                $form = $field->form;
90                break;
91            }
92        }
93        if ( ! $form || ! is_a( $form, 'Automattic\Jetpack\Forms\ContactForm\Contact_Form' ) ) {
94            return;
95        }
96
97        // if spam (hinted by akismet?), don't process
98        if ( $is_spam ) {
99            return;
100        }
101
102        $setup = $this->get_setup( $form->attributes );
103
104        if ( ! $setup ) {
105            return;
106        }
107
108        $form_data = $this->get_form_data( $form, $fields, $entry_values );
109
110        $result = $this->post_to_url( $form_data, $setup );
111
112        if ( is_wp_error( $result ) ) {
113            // phpcs:ignore VariableAnalysis.CodeAnalysis.VariableAnalysis.UnusedVariable -- figuring out what to do with the error.
114            $message = sprintf(
115                'JETPACK %s - Jetpack Forms: POSTing to URL failed: "%s" at %s',
116                constant( 'JETPACK__VERSION' ),
117                $result->get_error_message(),
118                $entry_values['entry_permalink']
119            );
120            // TODO: not sure what to do with the error. Is not useful at frontend and it would be difficult to
121            // solve for a non tech-savvy user. We should log it somewhere, but it could turn messy.
122            // Maybe email the owner?
123        }
124    }
125
126    /**
127     * POST to URL
128     *
129     * @param array $data The data key/value pairs to send in POST.
130     * @param array $options Options for POST.
131     *
132     * @return array|WP_Error The result value from wp_safe_remote_post
133     *
134     * TODO: do complex fields (MC, etc) need to be handled differently? JSON should be fine, but URLencoded might need to be serialized.
135     */
136    private function post_to_url( $data, $options = array() ) {
137        global $wp_version;
138
139        $user_agent = "WordPress/{$wp_version} | Jetpack/" . constant( 'JETPACK__VERSION' ) . '; ' . get_bloginfo( 'url' );
140        $format     = $options['format'] === 'urlencoded' ? 'application/x-www-form-urlencoded' : 'application/json';
141        $args       = array(
142            'body'    => $data,
143            'headers' => array(
144                'Content-Type' => $format,
145                'user-agent'   => $user_agent,
146            ),
147        );
148        return wp_safe_remote_post( $options['url'], $args );
149    }
150
151    /**
152     * Gather fields key/value pairs from the form
153     * Sanitizes the hidden fields values
154     *
155     * @param \Automattic\Jetpack\Forms\ContactForm\Contact_Form $form The form instance being processed/submitted.
156     * @param array                                              $visible_fields Visible submitted fields.
157     * @param array                                              $entry_values The feedback entry values.
158     */
159    private function get_form_data( $form, $visible_fields, $entry_values ) {
160        $fields = array();
161        foreach ( $visible_fields as $field ) {
162            $fields[ $field->get_attribute( 'id' ) ] = Feedback::encode_special_chars( $field->value );
163        }
164
165        // Right in the middle, backwards compatibility for salesforceData implementation.
166        $salesforce_data = (array) ( $form->attributes['salesforceData'] ?? array() );
167        if ( ! empty( $salesforce_data['organizationId'] ) ) {
168            $fields['oid']         = sanitize_text_field( $salesforce_data['organizationId'] );
169            $fields['lead_source'] = $entry_values['entry_permalink'];
170        }
171
172        // `hiddenFields` is a legacy attribute that may appear in a few shapes on forms
173        // in the wild: an array of `{ name, value }` objects (its original design), an
174        // associative `name => value` map, or a JSON-encoded string. Iterating it blindly
175        // and accessing `['name']`/`['value']` on a non-array element fatals on PHP 8 with
176        // "Cannot access offset of type string on string", so normalize defensively.
177        $hidden_fields = $form->attributes['hiddenFields'] ?? array();
178        if ( is_string( $hidden_fields ) ) {
179            $decoded       = json_decode( $hidden_fields, true );
180            $hidden_fields = is_array( $decoded ) ? $decoded : array();
181        }
182        foreach ( (array) $hidden_fields as $key => $hidden_field ) {
183            if ( is_array( $hidden_field ) ) {
184                // Original `{ name, value }` object shape.
185                if ( isset( $hidden_field['name'] ) ) {
186                    $fields[ $hidden_field['name'] ] = sanitize_text_field( $hidden_field['value'] ?? '' );
187                }
188            } elseif ( ! is_int( $key ) ) {
189                // Associative `name => value` shape.
190                $fields[ $key ] = sanitize_text_field( (string) $hidden_field );
191            }
192        }
193
194        return $fields;
195    }
196}