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