| 1 |
<?php |
| 2 |
/** |
| 3 |
* Post to URL using Jetpack Contact Forms. |
| 4 |
* |
| 5 |
* @package automattic/jetpack |
| 6 |
*/ |
| 7 |
|
| 8 |
namespace Automattic\Jetpack\Forms\Service; |
| 9 |
|
| 10 |
use 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 |
*/ |
| 17 |
class 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, $fields, $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 $visible_fields Visible submitted fields. |
| 156 |
* @param array $entry_values The feedback entry values. |
| 157 |
*/ |
| 158 |
private function get_form_data( $form, $visible_fields, $entry_values ) { |
| 159 |
$fields = array(); |
| 160 |
foreach ( $visible_fields as $field ) { |
| 161 |
$fields[ $field->get_attribute( 'id' ) ] = $field->value; |
| 162 |
} |
| 163 |
|
| 164 |
// Right in the middle, backwards compatibility for salesforceData implementation. |
| 165 |
$salesforce_data = (array) ( $form->attributes['salesforceData'] ?? array() ); |
| 166 |
if ( ! empty( $salesforce_data['organizationId'] ) ) { |
| 167 |
$fields['oid'] = sanitize_text_field( $salesforce_data['organizationId'] ); |
| 168 |
$fields['lead_source'] = $entry_values['entry_permalink']; |
| 169 |
} |
| 170 |
|
| 171 |
// `hiddenFields` is a legacy attribute that may appear in a few shapes on forms |
| 172 |
// in the wild: an array of `{ name, value }` objects (its original design), an |
| 173 |
// associative `name => value` map, or a JSON-encoded string. Iterating it blindly |
| 174 |
// and accessing `['name']`/`['value']` on a non-array element fatals on PHP 8 with |
| 175 |
// "Cannot access offset of type string on string", so normalize defensively. |
| 176 |
$hidden_fields = $form->attributes['hiddenFields'] ?? array(); |
| 177 |
if ( is_string( $hidden_fields ) ) { |
| 178 |
$decoded = json_decode( $hidden_fields, true ); |
| 179 |
$hidden_fields = is_array( $decoded ) ? $decoded : array(); |
| 180 |
} |
| 181 |
foreach ( (array) $hidden_fields as $key => $hidden_field ) { |
| 182 |
if ( is_array( $hidden_field ) ) { |
| 183 |
// Original `{ name, value }` object shape. |
| 184 |
if ( isset( $hidden_field['name'] ) ) { |
| 185 |
$fields[ $hidden_field['name'] ] = sanitize_text_field( $hidden_field['value'] ?? '' ); |
| 186 |
} |
| 187 |
} elseif ( ! is_int( $key ) ) { |
| 188 |
// Associative `name => value` shape. |
| 189 |
$fields[ $key ] = sanitize_text_field( (string) $hidden_field ); |
| 190 |
} |
| 191 |
} |
| 192 |
|
| 193 |
return $fields; |
| 194 |
} |
| 195 |
} |
| 196 |
|