Completed
Push — add/oauth-connection ( 0e4828...b00cbc )
by
unknown
25:40 queued 17:55
created

Grunion_Contact_Form::wp_mail()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 11

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 1
nc 1
nop 5
dl 0
loc 11
rs 9.9
c 0
b 0
f 0
1
<?php // phpcs:ignore WordPress.Files.FileName.InvalidClassFileName
2
/**
3
 * Grunion Contact Form
4
 * Add a contact form to any post, page or text widget.
5
 * Emails will be sent to the post's author by default, or any email address you choose.
6
 *
7
 * @package Jetpack
8
 */
9
10
use Automattic\Jetpack\Assets;
11
use Automattic\Jetpack\Sync\Settings;
12
use Automattic\Jetpack\Connection\Client;
13
14
define( 'GRUNION_PLUGIN_DIR', plugin_dir_path( __FILE__ ) );
15
define( 'GRUNION_PLUGIN_URL', plugin_dir_url( __FILE__ ) );
16
17
if ( is_admin() ) {
18
	require_once GRUNION_PLUGIN_DIR . 'admin.php';
19
}
20
21
add_action( 'rest_api_init', 'grunion_contact_form_require_endpoint' );
22
function grunion_contact_form_require_endpoint() {
23
	require_once GRUNION_PLUGIN_DIR . 'class-grunion-contact-form-endpoint.php';
24
}
25
26
/**
27
 * Sets up various actions, filters, post types, post statuses, shortcodes.
28
 */
29
class Grunion_Contact_Form_Plugin {
30
31
	/**
32
	 * @var string The Widget ID of the widget currently being processed.  Used to build the unique contact-form ID for forms embedded in widgets.
33
	 */
34
	public $current_widget_id;
35
36
	static $using_contact_form_field = false;
37
38
	/**
39
	 * @var int The last Feedback Post ID Erased as part of the Personal Data Eraser.
40
	 * Helps with pagination.
41
	 */
42
	private $pde_last_post_id_erased = 0;
43
44
	/**
45
	 * @var string The email address for which we are deleting/exporting all feedbacks
46
	 * as part of a Personal Data Eraser or Personal Data Exporter request.
47
	 */
48
	private $pde_email_address = '';
49
50
	static function init() {
51
		static $instance = false;
52
53
		if ( ! $instance ) {
54
			$instance = new Grunion_Contact_Form_Plugin();
55
56
			// Schedule our daily cleanup
57
			add_action( 'wp_scheduled_delete', array( $instance, 'daily_akismet_meta_cleanup' ) );
58
		}
59
60
		return $instance;
61
	}
62
63
	/**
64
	 * Runs daily to clean up spam detection metadata after 15 days.  Keeps your DB squeaky clean.
65
	 */
66
	public function daily_akismet_meta_cleanup() {
67
		global $wpdb;
68
69
		$feedback_ids = $wpdb->get_col( "SELECT p.ID FROM {$wpdb->posts} as p INNER JOIN {$wpdb->postmeta} as m on m.post_id = p.ID WHERE p.post_type = 'feedback' AND m.meta_key = '_feedback_akismet_values' AND DATE_SUB(NOW(), INTERVAL 15 DAY) > p.post_date_gmt LIMIT 10000" );
70
71
		if ( empty( $feedback_ids ) ) {
72
			return;
73
		}
74
75
		/**
76
		 * Fires right before deleting the _feedback_akismet_values post meta on $feedback_ids
77
		 *
78
		 * @module contact-form
79
		 *
80
		 * @since 6.1.0
81
		 *
82
		 * @param array $feedback_ids list of feedback post ID
83
		 */
84
		do_action( 'jetpack_daily_akismet_meta_cleanup_before', $feedback_ids );
85
		foreach ( $feedback_ids as $feedback_id ) {
86
			delete_post_meta( $feedback_id, '_feedback_akismet_values' );
87
		}
88
89
		/**
90
		 * Fires right after deleting the _feedback_akismet_values post meta on $feedback_ids
91
		 *
92
		 * @module contact-form
93
		 *
94
		 * @since 6.1.0
95
		 *
96
		 * @param array $feedback_ids list of feedback post ID
97
		 */
98
		do_action( 'jetpack_daily_akismet_meta_cleanup_after', $feedback_ids );
99
	}
100
101
	/**
102
	 * Strips HTML tags from input.  Output is NOT HTML safe.
103
	 *
104
	 * @param mixed $data_with_tags
105
	 * @return mixed
106
	 */
107
	public static function strip_tags( $data_with_tags ) {
108
		if ( is_array( $data_with_tags ) ) {
109
			foreach ( $data_with_tags as $index => $value ) {
110
				$index = sanitize_text_field( strval( $index ) );
111
				$value = wp_kses( strval( $value ), array() );
112
				$value = str_replace( '&amp;', '&', $value ); // undo damage done by wp_kses_normalize_entities()
113
114
				$data_without_tags[ $index ] = $value;
0 ignored issues
show
Coding Style Comprehensibility introduced by
$data_without_tags was never initialized. Although not strictly required by PHP, it is generally a good practice to add $data_without_tags = array(); before regardless.

Adding an explicit array definition is generally preferable to implicit array definition as it guarantees a stable state of the code.

Let’s take a look at an example:

foreach ($collection as $item) {
    $myArray['foo'] = $item->getFoo();

    if ($item->hasBar()) {
        $myArray['bar'] = $item->getBar();
    }

    // do something with $myArray
}

As you can see in this example, the array $myArray is initialized the first time when the foreach loop is entered. You can also see that the value of the bar key is only written conditionally; thus, its value might result from a previous iteration.

This might or might not be intended. To make your intention clear, your code more readible and to avoid accidental bugs, we recommend to add an explicit initialization $myArray = array() either outside or inside the foreach loop.

Loading history...
115
			}
116
		} else {
117
			$data_without_tags = wp_kses( $data_with_tags, array() );
118
			$data_without_tags = str_replace( '&amp;', '&', $data_without_tags ); // undo damage done by wp_kses_normalize_entities()
119
		}
120
121
		return $data_without_tags;
0 ignored issues
show
Bug introduced by
The variable $data_without_tags does not seem to be defined for all execution paths leading up to this point.

If you define a variable conditionally, it can happen that it is not defined for all execution paths.

Let’s take a look at an example:

function myFunction($a) {
    switch ($a) {
        case 'foo':
            $x = 1;
            break;

        case 'bar':
            $x = 2;
            break;
    }

    // $x is potentially undefined here.
    echo $x;
}

In the above example, the variable $x is defined if you pass “foo” or “bar” as argument for $a. However, since the switch statement has no default case statement, if you pass any other value, the variable $x would be undefined.

Available Fixes

  1. Check for existence of the variable explicitly:

    function myFunction($a) {
        switch ($a) {
            case 'foo':
                $x = 1;
                break;
    
            case 'bar':
                $x = 2;
                break;
        }
    
        if (isset($x)) { // Make sure it's always set.
            echo $x;
        }
    }
    
  2. Define a default value for the variable:

    function myFunction($a) {
        $x = ''; // Set a default which gets overridden for certain paths.
        switch ($a) {
            case 'foo':
                $x = 1;
                break;
    
            case 'bar':
                $x = 2;
                break;
        }
    
        echo $x;
    }
    
  3. Add a value for the missing path:

    function myFunction($a) {
        switch ($a) {
            case 'foo':
                $x = 1;
                break;
    
            case 'bar':
                $x = 2;
                break;
    
            // We add support for the missing case.
            default:
                $x = '';
                break;
        }
    
        echo $x;
    }
    
Loading history...
122
	}
123
124
	/**
125
	 * Class uses singleton pattern; use Grunion_Contact_Form_Plugin::init() to initialize.
126
	 */
127
	protected function __construct() {
128
		$this->add_shortcode();
129
130
		// While generating the output of a text widget with a contact-form shortcode, we need to know its widget ID.
131
		add_action( 'dynamic_sidebar', array( $this, 'track_current_widget' ) );
132
133
		// Add a "widget" shortcode attribute to all contact-form shortcodes embedded in widgets
134
		add_filter( 'widget_text', array( $this, 'widget_atts' ), 0 );
135
136
		// If Text Widgets don't get shortcode processed, hack ours into place.
137
		if (
138
			version_compare( get_bloginfo( 'version' ), '4.9-z', '<=' )
139
			&& ! has_filter( 'widget_text', 'do_shortcode' )
140
		) {
141
			add_filter( 'widget_text', array( $this, 'widget_shortcode_hack' ), 5 );
142
		}
143
144
		add_filter( 'jetpack_contact_form_is_spam', array( $this, 'is_spam_blacklist' ), 10, 2 );
145
146
		// Akismet to the rescue
147
		if ( defined( 'AKISMET_VERSION' ) || function_exists( 'akismet_http_post' ) ) {
148
			add_filter( 'jetpack_contact_form_is_spam', array( $this, 'is_spam_akismet' ), 10, 2 );
149
			add_action( 'contact_form_akismet', array( $this, 'akismet_submit' ), 10, 2 );
150
		}
151
152
		add_action( 'loop_start', array( 'Grunion_Contact_Form', '_style_on' ) );
153
154
		add_action( 'wp_ajax_grunion-contact-form', array( $this, 'ajax_request' ) );
155
		add_action( 'wp_ajax_nopriv_grunion-contact-form', array( $this, 'ajax_request' ) );
156
157
		// GDPR: personal data exporter & eraser.
158
		add_filter( 'wp_privacy_personal_data_exporters', array( $this, 'register_personal_data_exporter' ) );
159
		add_filter( 'wp_privacy_personal_data_erasers', array( $this, 'register_personal_data_eraser' ) );
160
161
		// Export to CSV feature
162
		if ( is_admin() ) {
163
			add_action( 'admin_init', array( $this, 'download_feedback_as_csv' ) );
164
			add_action( 'admin_footer-edit.php', array( $this, 'export_form' ) );
165
			add_action( 'admin_menu', array( $this, 'admin_menu' ) );
166
			add_action( 'current_screen', array( $this, 'unread_count' ) );
167
		}
168
169
		// custom post type we'll use to keep copies of the feedback items
170
		register_post_type(
171
			'feedback', array(
172
				'labels'                => array(
173
					'name'               => __( 'Feedback', 'jetpack' ),
174
					'singular_name'      => __( 'Feedback', 'jetpack' ),
175
					'search_items'       => __( 'Search Feedback', 'jetpack' ),
176
					'not_found'          => __( 'No feedback found', 'jetpack' ),
177
					'not_found_in_trash' => __( 'No feedback found', 'jetpack' ),
178
				),
179
				// Matrial Ballot icon
180
				'menu_icon'             => 'data:image/svg+xml;base64,' . base64_encode('<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24"><path fill="none" d="M13 7.5h5v2h-5zm0 7h5v2h-5zM19 3H5c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2zm0 16H5V5h14v14zM11 6H6v5h5V6zm-1 4H7V7h3v3zm1 3H6v5h5v-5zm-1 4H7v-3h3v3z"/></svg>'),
181
				'show_ui'               => true,
182
				'show_in_admin_bar'     => false,
183
				'public'                => false,
184
				'rewrite'               => false,
185
				'query_var'             => false,
186
				'capability_type'       => 'page',
187
				'show_in_rest'          => true,
188
				'rest_controller_class' => 'Grunion_Contact_Form_Endpoint',
189
				'capabilities'          => array(
190
					'create_posts'        => 'do_not_allow',
191
					'publish_posts'       => 'publish_pages',
192
					'edit_posts'          => 'edit_pages',
193
					'edit_others_posts'   => 'edit_others_pages',
194
					'delete_posts'        => 'delete_pages',
195
					'delete_others_posts' => 'delete_others_pages',
196
					'read_private_posts'  => 'read_private_pages',
197
					'edit_post'           => 'edit_page',
198
					'delete_post'         => 'delete_page',
199
					'read_post'           => 'read_page',
200
				),
201
				'map_meta_cap'          => true,
202
			)
203
		);
204
205
		// Add to REST API post type whitelist
206
		add_filter( 'rest_api_allowed_post_types', array( $this, 'allow_feedback_rest_api_type' ) );
207
208
		// Add "spam" as a post status
209
		register_post_status(
210
			'spam', array(
211
				'label'                  => 'Spam',
212
				'public'                 => false,
213
				'exclude_from_search'    => true,
214
				'show_in_admin_all_list' => false,
215
				'label_count'            => _n_noop( 'Spam <span class="count">(%s)</span>', 'Spam <span class="count">(%s)</span>', 'jetpack' ),
216
				'protected'              => true,
217
				'_builtin'               => false,
218
			)
219
		);
220
221
		// POST handler
222
		if (
223
			isset( $_SERVER['REQUEST_METHOD'] ) && 'POST' == strtoupper( $_SERVER['REQUEST_METHOD'] )
224
			&&
225
			isset( $_POST['action'] ) && 'grunion-contact-form' == $_POST['action']
226
			&&
227
			isset( $_POST['contact-form-id'] )
228
		) {
229
			add_action( 'template_redirect', array( $this, 'process_form_submission' ) );
230
		}
231
232
		/*
233
		 Can be dequeued by placing the following in wp-content/themes/yourtheme/functions.php
234
		 *
235
		 * 	function remove_grunion_style() {
236
		 *		wp_deregister_style('grunion.css');
237
		 *	}
238
		 *	add_action('wp_print_styles', 'remove_grunion_style');
239
		 */
240
		wp_register_style( 'grunion.css', GRUNION_PLUGIN_URL . 'css/grunion.css', array(), JETPACK__VERSION );
241
		wp_style_add_data( 'grunion.css', 'rtl', 'replace' );
242
243
		self::register_contact_form_blocks();
244
	}
245
246
	private static function register_contact_form_blocks() {
247
		jetpack_register_block( 'jetpack/contact-form', array(
248
			'render_callback' => array( __CLASS__, 'gutenblock_render_form' ),
249
		) );
250
251
		// Field render methods.
252
		jetpack_register_block( 'jetpack/field-text', array(
253
			'parent'          => array( 'jetpack/contact-form' ),
254
			'render_callback' => array( __CLASS__, 'gutenblock_render_field_text' ),
255
		) );
256
		jetpack_register_block( 'jetpack/field-name', array(
257
			'parent'          => array( 'jetpack/contact-form' ),
258
			'render_callback' => array( __CLASS__, 'gutenblock_render_field_name' ),
259
		) );
260
		jetpack_register_block( 'jetpack/field-email', array(
261
			'parent'          => array( 'jetpack/contact-form' ),
262
			'render_callback' => array( __CLASS__, 'gutenblock_render_field_email' ),
263
		) );
264
		jetpack_register_block( 'jetpack/field-url', array(
265
			'parent'          => array( 'jetpack/contact-form' ),
266
			'render_callback' => array( __CLASS__, 'gutenblock_render_field_url' ),
267
		) );
268
		jetpack_register_block( 'jetpack/field-date', array(
269
			'parent'          => array( 'jetpack/contact-form' ),
270
			'render_callback' => array( __CLASS__, 'gutenblock_render_field_date' ),
271
		) );
272
		jetpack_register_block( 'jetpack/field-telephone', array(
273
			'parent'          => array( 'jetpack/contact-form' ),
274
			'render_callback' => array( __CLASS__, 'gutenblock_render_field_telephone' ),
275
		) );
276
		jetpack_register_block( 'jetpack/field-textarea', array(
277
			'parent'          => array( 'jetpack/contact-form' ),
278
			'render_callback' => array( __CLASS__, 'gutenblock_render_field_textarea' ),
279
		) );
280
		jetpack_register_block( 'jetpack/field-checkbox', array(
281
			'parent'          => array( 'jetpack/contact-form' ),
282
			'render_callback' => array( __CLASS__, 'gutenblock_render_field_checkbox' ),
283
		) );
284
		jetpack_register_block( 'jetpack/field-checkbox-multiple', array(
285
			'parent'          => array( 'jetpack/contact-form' ),
286
			'render_callback' => array( __CLASS__, 'gutenblock_render_field_checkbox_multiple' ),
287
		) );
288
		jetpack_register_block( 'jetpack/field-radio', array(
289
			'parent'          => array( 'jetpack/contact-form' ),
290
			'render_callback' => array( __CLASS__, 'gutenblock_render_field_radio' ),
291
		) );
292
		jetpack_register_block( 'jetpack/field-select', array(
293
			'parent'          => array( 'jetpack/contact-form' ),
294
			'render_callback' => array( __CLASS__, 'gutenblock_render_field_select' ),
295
		) );
296
	}
297
298
	public static function gutenblock_render_form( $atts, $content ) {
299
		return Grunion_Contact_Form::parse( $atts, do_blocks( $content ) );
300
	}
301
302
	public static function block_attributes_to_shortcode_attributes( $atts, $type ) {
303
		$atts['type'] = $type;
304
		if ( isset( $atts['className'] ) ) {
305
			$atts['class'] = $atts['className'];
306
			unset( $atts['className'] );
307
		}
308
309
		if ( isset( $atts['defaultValue'] ) ) {
310
			$atts['default'] = $atts['defaultValue'];
311
			unset( $atts['defaultValue'] );
312
		}
313
314
		return $atts;
315
	}
316
317
	public static function gutenblock_render_field_text( $atts, $content ) {
318
		$atts = self::block_attributes_to_shortcode_attributes( $atts, 'text' );
319
		return Grunion_Contact_Form::parse_contact_field( $atts, $content );
320
	}
321
	public static function gutenblock_render_field_name( $atts, $content ) {
322
		$atts = self::block_attributes_to_shortcode_attributes( $atts, 'name' );
323
		return Grunion_Contact_Form::parse_contact_field( $atts, $content );
324
	}
325
	public static function gutenblock_render_field_email( $atts, $content ) {
326
		$atts = self::block_attributes_to_shortcode_attributes( $atts, 'email' );
327
		return Grunion_Contact_Form::parse_contact_field( $atts, $content );
328
	}
329
	public static function gutenblock_render_field_url( $atts, $content ) {
330
		$atts = self::block_attributes_to_shortcode_attributes( $atts, 'url' );
331
		return Grunion_Contact_Form::parse_contact_field( $atts, $content );
332
	}
333
	public static function gutenblock_render_field_date( $atts, $content ) {
334
		$atts = self::block_attributes_to_shortcode_attributes( $atts, 'date' );
335
		return Grunion_Contact_Form::parse_contact_field( $atts, $content );
336
	}
337
	public static function gutenblock_render_field_telephone( $atts, $content ) {
338
		$atts = self::block_attributes_to_shortcode_attributes( $atts, 'telephone' );
339
		return Grunion_Contact_Form::parse_contact_field( $atts, $content );
340
	}
341
	public static function gutenblock_render_field_textarea( $atts, $content ) {
342
		$atts = self::block_attributes_to_shortcode_attributes( $atts, 'textarea' );
343
		return Grunion_Contact_Form::parse_contact_field( $atts, $content );
344
	}
345
	public static function gutenblock_render_field_checkbox( $atts, $content ) {
346
		$atts = self::block_attributes_to_shortcode_attributes( $atts, 'checkbox' );
347
		return Grunion_Contact_Form::parse_contact_field( $atts, $content );
348
	}
349
	public static function gutenblock_render_field_checkbox_multiple( $atts, $content ) {
350
		$atts = self::block_attributes_to_shortcode_attributes( $atts, 'checkbox-multiple' );
351
		return Grunion_Contact_Form::parse_contact_field( $atts, $content );
352
	}
353
	public static function gutenblock_render_field_radio( $atts, $content ) {
354
		$atts = self::block_attributes_to_shortcode_attributes( $atts, 'radio' );
355
		return Grunion_Contact_Form::parse_contact_field( $atts, $content );
356
	}
357
	public static function gutenblock_render_field_select( $atts, $content ) {
358
		$atts = self::block_attributes_to_shortcode_attributes( $atts, 'select' );
359
		return Grunion_Contact_Form::parse_contact_field( $atts, $content );
360
	}
361
362
	/**
363
	 * Add the 'Export' menu item as a submenu of Feedback.
364
	 */
365
	public function admin_menu() {
366
		add_submenu_page(
367
			'edit.php?post_type=feedback',
368
			__( 'Export feedback as CSV', 'jetpack' ),
369
			__( 'Export CSV', 'jetpack' ),
370
			'export',
371
			'feedback-export',
372
			array( $this, 'export_form' )
373
		);
374
	}
375
376
	/**
377
	 * Add to REST API post type whitelist
378
	 */
379
	function allow_feedback_rest_api_type( $post_types ) {
380
		$post_types[] = 'feedback';
381
		return $post_types;
382
	}
383
384
	/**
385
	 * Display the count of new feedback entries received. It's reset when user visits the Feedback screen.
386
	 *
387
	 * @since 4.1.0
388
	 *
389
	 * @param object $screen Information about the current screen.
390
	 */
391
	function unread_count( $screen ) {
392
		if ( isset( $screen->post_type ) && 'feedback' == $screen->post_type ) {
393
			update_option( 'feedback_unread_count', 0 );
394
		} else {
395
			global $menu;
396
			if ( isset( $menu ) && is_array( $menu ) && ! empty( $menu ) ) {
397
				foreach ( $menu as $index => $menu_item ) {
398
					if ( 'edit.php?post_type=feedback' == $menu_item[2] ) {
399
						$unread = get_option( 'feedback_unread_count', 0 );
400
						if ( $unread > 0 ) {
401
							$unread_count       = current_user_can( 'publish_pages' ) ? " <span class='feedback-unread count-{$unread} awaiting-mod'><span class='feedback-unread-count'>" . number_format_i18n( $unread ) . '</span></span>' : '';
402
							$menu[ $index ][0] .= $unread_count;
403
						}
404
						break;
405
					}
406
				}
407
			}
408
		}
409
	}
410
411
	/**
412
	 * Handles all contact-form POST submissions
413
	 *
414
	 * Conditionally attached to `template_redirect`
415
	 */
416
	function process_form_submission() {
417
		// Add a filter to replace tokens in the subject field with sanitized field values
418
		add_filter( 'contact_form_subject', array( $this, 'replace_tokens_with_input' ), 10, 2 );
419
420
		$id   = stripslashes( $_POST['contact-form-id'] );
421
		$hash = isset( $_POST['contact-form-hash'] ) ? $_POST['contact-form-hash'] : null;
422
		$hash = preg_replace( '/[^\da-f]/i', '', $hash );
423
424
		if ( is_user_logged_in() ) {
425
			check_admin_referer( "contact-form_{$id}" );
426
		}
427
428
		$is_widget = 0 === strpos( $id, 'widget-' );
429
430
		$form = false;
0 ignored issues
show
Unused Code introduced by
$form is not used, you could remove the assignment.

This check looks for variable assignements that are either overwritten by other assignments or where the variable is not used subsequently.

$myVar = 'Value';
$higher = false;

if (rand(1, 6) > 3) {
    $higher = true;
} else {
    $higher = false;
}

Both the $myVar assignment in line 1 and the $higher assignment in line 2 are dead. The first because $myVar is never used and the second because $higher is always overwritten for every possible time line.

Loading history...
431
432
		if ( $is_widget ) {
433
			// It's a form embedded in a text widget
434
			$this->current_widget_id = substr( $id, 7 ); // remove "widget-"
435
			$widget_type             = implode( '-', array_slice( explode( '-', $this->current_widget_id ), 0, -1 ) ); // Remove trailing -#
436
437
			// Is the widget active?
438
			$sidebar = is_active_widget( false, $this->current_widget_id, $widget_type );
439
440
			// This is lame - no core API for getting a widget by ID
441
			$widget = isset( $GLOBALS['wp_registered_widgets'][ $this->current_widget_id ] ) ? $GLOBALS['wp_registered_widgets'][ $this->current_widget_id ] : false;
442
443
			if ( $sidebar && $widget && isset( $widget['callback'] ) ) {
444
				// prevent PHP notices by populating widget args
445
				$widget_args = array(
446
					'before_widget' => '',
447
					'after_widget'  => '',
448
					'before_title'  => '',
449
					'after_title'   => '',
450
				);
451
				// This is lamer - no API for outputting a given widget by ID
452
				ob_start();
453
				// Process the widget to populate Grunion_Contact_Form::$last
454
				call_user_func( $widget['callback'], $widget_args, $widget['params'][0] );
455
				ob_end_clean();
456
			}
457
		} else {
458
			// It's a form embedded in a post
459
			$post = get_post( $id );
460
461
			// Process the content to populate Grunion_Contact_Form::$last
462
			/** This filter is already documented in core. wp-includes/post-template.php */
463
			apply_filters( 'the_content', $post->post_content );
464
		}
465
466
		$form = isset( Grunion_Contact_Form::$forms[ $hash ] ) ? Grunion_Contact_Form::$forms[ $hash ] : null;
0 ignored issues
show
Bug introduced by
The property forms cannot be accessed from this context as it is declared private in class Grunion_Contact_Form.

This check looks for access to properties that are not accessible from the current context.

If you need to make a property accessible to another context you can either raise its visibility level or provide an accessible getter in the defining class.

Loading history...
467
468
		// No form may mean user is using do_shortcode, grab the form using the stored post meta
469
		if ( ! $form ) {
470
471
			// Get shortcode from post meta
472
			$shortcode = get_post_meta( $_POST['contact-form-id'], "_g_feedback_shortcode_{$hash}", true );
473
474
			// Format it
475
			if ( $shortcode != '' ) {
476
477
				// Get attributes from post meta.
478
				$parameters = '';
479
				$attributes = get_post_meta( $_POST['contact-form-id'], "_g_feedback_shortcode_atts_{$hash}", true );
480
				if ( ! empty( $attributes ) && is_array( $attributes ) ) {
481
					foreach ( array_filter( $attributes ) as $param => $value ) {
482
						$parameters .= " $param=\"$value\"";
483
					}
484
				}
485
486
				$shortcode = '[contact-form' . $parameters . ']' . $shortcode . '[/contact-form]';
487
				do_shortcode( $shortcode );
488
489
				// Recreate form
490
				$form = Grunion_Contact_Form::$last;
0 ignored issues
show
Bug introduced by
The property last cannot be accessed from this context as it is declared private in class Grunion_Contact_Form.

This check looks for access to properties that are not accessible from the current context.

If you need to make a property accessible to another context you can either raise its visibility level or provide an accessible getter in the defining class.

Loading history...
491
			}
492
493
			if ( ! $form ) {
494
				return false;
495
			}
496
		}
497
498
		if ( is_wp_error( $form->errors ) && $form->errors->get_error_codes() ) {
499
			return $form->errors;
500
		}
501
502
		// Process the form
503
		return $form->process_submission();
504
	}
505
506
	function ajax_request() {
507
		$submission_result = self::process_form_submission();
508
509
		if ( ! $submission_result ) {
510
			header( 'HTTP/1.1 500 Server Error', 500, true );
511
			echo '<div class="form-error"><ul class="form-errors"><li class="form-error-message">';
512
			esc_html_e( 'An error occurred. Please try again later.', 'jetpack' );
513
			echo '</li></ul></div>';
514
		} elseif ( is_wp_error( $submission_result ) ) {
515
			header( 'HTTP/1.1 400 Bad Request', 403, true );
516
			echo '<div class="form-error"><ul class="form-errors"><li class="form-error-message">';
517
			echo esc_html( $submission_result->get_error_message() );
518
			echo '</li></ul></div>';
519
		} else {
520
			echo '<h3>' . esc_html__( 'Message Sent', 'jetpack' ) . '</h3>' . $submission_result;
521
		}
522
523
		die;
524
	}
525
526
	/**
527
	 * Ensure the post author is always zero for contact-form feedbacks
528
	 * Attached to `wp_insert_post_data`
529
	 *
530
	 * @see Grunion_Contact_Form::process_submission()
531
	 *
532
	 * @param array $data the data to insert
533
	 * @param array $postarr the data sent to wp_insert_post()
534
	 * @return array The filtered $data to insert
535
	 */
536
	function insert_feedback_filter( $data, $postarr ) {
537
		if ( $data['post_type'] == 'feedback' && $postarr['post_type'] == 'feedback' ) {
538
			$data['post_author'] = 0;
539
		}
540
541
		return $data;
542
	}
543
	/*
544
	 * Adds our contact-form shortcode
545
	 * The "child" contact-field shortcode is enabled as needed by the contact-form shortcode handler
546
	 */
547
	function add_shortcode() {
548
		add_shortcode( 'contact-form', array( 'Grunion_Contact_Form', 'parse' ) );
549
		add_shortcode( 'contact-field', array( 'Grunion_Contact_Form', 'parse_contact_field' ) );
550
	}
551
552
	static function tokenize_label( $label ) {
553
		return '{' . trim( preg_replace( '#^\d+_#', '', $label ) ) . '}';
554
	}
555
556
	static function sanitize_value( $value ) {
557
		return preg_replace( '=((<CR>|<LF>|0x0A/%0A|0x0D/%0D|\\n|\\r)\S).*=i', null, $value );
558
	}
559
560
	/**
561
	 * Replaces tokens like {city} or {City} (case insensitive) with the value
562
	 * of an input field of that name
563
	 *
564
	 * @param string $subject
565
	 * @param array  $field_values Array with field label => field value associations
566
	 *
567
	 * @return string The filtered $subject with the tokens replaced
568
	 */
569
	function replace_tokens_with_input( $subject, $field_values ) {
570
		// Wrap labels into tokens (inside {})
571
		$wrapped_labels = array_map( array( 'Grunion_Contact_Form_Plugin', 'tokenize_label' ), array_keys( $field_values ) );
572
		// Sanitize all values
573
		$sanitized_values = array_map( array( 'Grunion_Contact_Form_Plugin', 'sanitize_value' ), array_values( $field_values ) );
574
575
		foreach ( $sanitized_values as $k => $sanitized_value ) {
576
			if ( is_array( $sanitized_value ) ) {
577
				$sanitized_values[ $k ] = implode( ', ', $sanitized_value );
578
			}
579
		}
580
581
		// Search for all valid tokens (based on existing fields) and replace with the field's value
582
		$subject = str_ireplace( $wrapped_labels, $sanitized_values, $subject );
583
		return $subject;
584
	}
585
586
	/**
587
	 * Tracks the widget currently being processed.
588
	 * Attached to `dynamic_sidebar`
589
	 *
590
	 * @see $current_widget_id
591
	 *
592
	 * @param array $widget The widget data
593
	 */
594
	function track_current_widget( $widget ) {
595
		$this->current_widget_id = $widget['id'];
596
	}
597
598
	/**
599
	 * Adds a "widget" attribute to every contact-form embedded in a text widget.
600
	 * Used to tell the difference between post-embedded contact-forms and widget-embedded contact-forms
601
	 * Attached to `widget_text`
602
	 *
603
	 * @param string $text The widget text
604
	 * @return string The filtered widget text
605
	 */
606
	function widget_atts( $text ) {
607
		Grunion_Contact_Form::style( true );
608
609
		return preg_replace( '/\[contact-form([^a-zA-Z_-])/', '[contact-form widget="' . $this->current_widget_id . '"\\1', $text );
610
	}
611
612
	/**
613
	 * For sites where text widgets are not processed for shortcodes, we add this hack to process just our shortcode
614
	 * Attached to `widget_text`
615
	 *
616
	 * @param string $text The widget text
617
	 * @return string The contact-form filtered widget text
618
	 */
619
	function widget_shortcode_hack( $text ) {
620
		if ( ! preg_match( '/\[contact-form([^a-zA-Z_-])/', $text ) ) {
621
			return $text;
622
		}
623
624
		$old = $GLOBALS['shortcode_tags'];
625
		remove_all_shortcodes();
626
		Grunion_Contact_Form_Plugin::$using_contact_form_field = true;
627
		$this->add_shortcode();
628
629
		$text = do_shortcode( $text );
630
631
		Grunion_Contact_Form_Plugin::$using_contact_form_field = false;
632
		$GLOBALS['shortcode_tags']                             = $old;
633
634
		return $text;
635
	}
636
637
	/**
638
	 * Check if a submission matches the Comment Blacklist.
639
	 * The Comment Blacklist is a means to moderate discussion, and contact
640
	 * forms are 1:1 discussion forums, ripe for abuse by users who are being
641
	 * removed from the public discussion.
642
	 * Attached to `jetpack_contact_form_is_spam`
643
	 *
644
	 * @param bool  $is_spam
645
	 * @param array $form
646
	 * @return bool TRUE => spam, FALSE => not spam
647
	 */
648
	function is_spam_blacklist( $is_spam, $form = array() ) {
649
		if ( $is_spam ) {
650
			return $is_spam;
651
		}
652
653
		if ( wp_blacklist_check( $form['comment_author'], $form['comment_author_email'], $form['comment_author_url'], $form['comment_content'], $form['user_ip'], $form['user_agent'] ) ) {
654
			return true;
655
		}
656
657
		return false;
658
	}
659
660
	/**
661
	 * Populate an array with all values necessary to submit a NEW contact-form feedback to Akismet.
662
	 * Note that this includes the current user_ip etc, so this should only be called when accepting a new item via $_POST
663
	 *
664
	 * @param array $form Contact form feedback array
665
	 * @return array feedback array with additional data ready for submission to Akismet
666
	 */
667
	function prepare_for_akismet( $form ) {
668
		$form['comment_type'] = 'contact_form';
669
		$form['user_ip']      = $_SERVER['REMOTE_ADDR'];
670
		$form['user_agent']   = $_SERVER['HTTP_USER_AGENT'];
671
		$form['referrer']     = $_SERVER['HTTP_REFERER'];
672
		$form['blog']         = get_option( 'home' );
673
674
		foreach ( $_SERVER as $key => $value ) {
675
			if ( ! is_string( $value ) ) {
676
				continue;
677
			}
678
			if ( in_array( $key, array( 'HTTP_COOKIE', 'HTTP_COOKIE2', 'HTTP_USER_AGENT', 'HTTP_REFERER' ) ) ) {
679
				// We don't care about cookies, and the UA and Referrer were caught above.
680
				continue;
681
			} elseif ( in_array( $key, array( 'REMOTE_ADDR', 'REQUEST_URI', 'DOCUMENT_URI' ) ) ) {
682
				// All three of these are relevant indicators and should be passed along.
683
				$form[ $key ] = $value;
684
			} elseif ( wp_startswith( $key, 'HTTP_' ) ) {
685
				// Any other HTTP header indicators.
686
				// `wp_startswith()` is a wpcom helper function and is included in Jetpack via `functions.compat.php`
687
				$form[ $key ] = $value;
688
			}
689
		}
690
691
		return $form;
692
	}
693
694
	/**
695
	 * Submit contact-form data to Akismet to check for spam.
696
	 * If you're accepting a new item via $_POST, run it Grunion_Contact_Form_Plugin::prepare_for_akismet() first
697
	 * Attached to `jetpack_contact_form_is_spam`
698
	 *
699
	 * @param bool  $is_spam
700
	 * @param array $form
701
	 * @return bool|WP_Error TRUE => spam, FALSE => not spam, WP_Error => stop processing entirely
702
	 */
703
	function is_spam_akismet( $is_spam, $form = array() ) {
704
		global $akismet_api_host, $akismet_api_port;
705
706
		// The signature of this function changed from accepting just $form.
707
		// If something only sends an array, assume it's still using the old
708
		// signature and work around it.
709
		if ( empty( $form ) && is_array( $is_spam ) ) {
710
			$form    = $is_spam;
711
			$is_spam = false;
712
		}
713
714
		// If a previous filter has alrady marked this as spam, trust that and move on.
715
		if ( $is_spam ) {
716
			return $is_spam;
717
		}
718
719
		if ( ! function_exists( 'akismet_http_post' ) && ! defined( 'AKISMET_VERSION' ) ) {
720
			return false;
721
		}
722
723
		$query_string = http_build_query( $form );
724
725
		if ( method_exists( 'Akismet', 'http_post' ) ) {
726
			$response = Akismet::http_post( $query_string, 'comment-check' );
727
		} else {
728
			$response = akismet_http_post( $query_string, $akismet_api_host, '/1.1/comment-check', $akismet_api_port );
729
		}
730
731
		$result = false;
732
733
		if ( isset( $response[0]['x-akismet-pro-tip'] ) && 'discard' === trim( $response[0]['x-akismet-pro-tip'] ) && get_option( 'akismet_strictness' ) === '1' ) {
734
			$result = new WP_Error( 'feedback-discarded', __( 'Feedback discarded.', 'jetpack' ) );
0 ignored issues
show
Unused Code introduced by
The call to WP_Error::__construct() has too many arguments starting with 'feedback-discarded'.

This check compares calls to functions or methods with their respective definitions. If the call has more arguments than are defined, it raises an issue.

If a function is defined several times with a different number of parameters, the check may pick up the wrong definition and report false positives. One codebase where this has been known to happen is Wordpress.

In this case you can add the @ignore PhpDoc annotation to the duplicate definition and it will be ignored.

Loading history...
735
		} elseif ( isset( $response[1] ) && 'true' == trim( $response[1] ) ) { // 'true' is spam
736
			$result = true;
737
		}
738
739
		/**
740
		 * Filter the results returned by Akismet for each submitted contact form.
741
		 *
742
		 * @module contact-form
743
		 *
744
		 * @since 1.3.1
745
		 *
746
		 * @param WP_Error|bool $result Is the submitted feedback spam.
747
		 * @param array|bool $form Submitted feedback.
748
		 */
749
		return apply_filters( 'contact_form_is_spam_akismet', $result, $form );
750
	}
751
752
	/**
753
	 * Submit a feedback as either spam or ham
754
	 *
755
	 * @param string $as Either 'spam' or 'ham'.
756
	 * @param array  $form the contact-form data
757
	 */
758
	function akismet_submit( $as, $form ) {
759
		global $akismet_api_host, $akismet_api_port;
760
761
		if ( ! in_array( $as, array( 'ham', 'spam' ) ) ) {
762
			return false;
763
		}
764
765
		$query_string = '';
766
		if ( is_array( $form ) ) {
767
			$query_string = http_build_query( $form );
768
		}
769
		if ( method_exists( 'Akismet', 'http_post' ) ) {
770
			$response = Akismet::http_post( $query_string, "submit-{$as}" );
771
		} else {
772
			$response = akismet_http_post( $query_string, $akismet_api_host, "/1.1/submit-{$as}", $akismet_api_port );
773
		}
774
775
		return trim( $response[1] );
776
	}
777
778
	/**
779
	 * Prints the menu
780
	 */
781
	function export_form() {
782
		$current_screen = get_current_screen();
783
		if ( ! in_array( $current_screen->id, array( 'edit-feedback', 'feedback_page_feedback-export' ) ) ) {
784
			return;
785
		}
786
787
		if ( ! current_user_can( 'export' ) ) {
788
			return;
789
		}
790
791
		// if there aren't any feedbacks, bail out
792
		if ( ! (int) wp_count_posts( 'feedback' )->publish ) {
793
			return;
794
		}
795
		?>
796
797
		<div id="feedback-export" style="display:none">
798
			<h2><?php _e( 'Export feedback as CSV', 'jetpack' ); ?></h2>
799
			<div class="clear"></div>
800
			<form action="<?php echo admin_url( 'admin-post.php' ); ?>" method="post" class="form">
801
				<?php wp_nonce_field( 'feedback_export', 'feedback_export_nonce' ); ?>
802
803
				<input name="action" value="feedback_export" type="hidden">
804
				<label for="post"><?php _e( 'Select feedback to download', 'jetpack' ); ?></label>
805
				<select name="post">
806
					<option value="all"><?php esc_html_e( 'All posts', 'jetpack' ); ?></option>
807
					<?php echo $this->get_feedbacks_as_options(); ?>
808
				</select>
809
810
				<br><br>
811
				<input type="submit" name="submit" id="submit" class="button button-primary" value="<?php esc_html_e( 'Download', 'jetpack' ); ?>">
812
			</form>
813
		</div>
814
815
		<?php
816
		// There aren't any usable actions in core to output the "export feedback" form in the correct place,
817
		// so this inline JS moves it from the top of the page to the bottom.
818
		?>
819
		<script type='text/javascript'>
820
		    var menu = document.getElementById( 'feedback-export' ),
821
                wrapper = document.getElementsByClassName( 'wrap' )[0];
822
            <?php if ( 'edit-feedback' === $current_screen->id ) : ?>
823
            wrapper.appendChild(menu);
824
            <?php endif; ?>
825
            menu.style.display = 'block';
826
		</script>
827
		<?php
828
	}
829
830
	/**
831
	 * Fetch post content for a post and extract just the comment.
832
	 *
833
	 * @param int $post_id The post id to fetch the content for.
834
	 *
835
	 * @return string Trimmed post comment.
836
	 *
837
	 * @codeCoverageIgnore
838
	 */
839
	public function get_post_content_for_csv_export( $post_id ) {
840
		$post_content = get_post_field( 'post_content', $post_id );
841
		$content      = explode( '<!--more-->', $post_content );
842
843
		return trim( $content[0] );
844
	}
845
846
	/**
847
	 * Get `_feedback_extra_fields` field from post meta data.
848
	 *
849
	 * @param int $post_id Id of the post to fetch meta data for.
850
	 *
851
	 * @return mixed
852
	 *
853
	 * @codeCoverageIgnore - No need to be covered.
854
	 */
855
	public function get_post_meta_for_csv_export( $post_id ) {
856
		return get_post_meta( $post_id, '_feedback_extra_fields', true );
857
	}
858
859
	/**
860
	 * Get parsed feedback post fields.
861
	 *
862
	 * @param int $post_id Id of the post to fetch parsed contents for.
863
	 *
864
	 * @return array
865
	 *
866
	 * @codeCoverageIgnore - No need to be covered.
867
	 */
868
	public function get_parsed_field_contents_of_post( $post_id ) {
869
		return self::parse_fields_from_content( $post_id );
870
	}
871
872
	/**
873
	 * Properly maps fields that are missing from the post meta data
874
	 * to names, that are similar to those of the post meta.
875
	 *
876
	 * @param array $parsed_post_content Parsed post content
877
	 *
878
	 * @see parse_fields_from_content for how the input data is generated.
879
	 *
880
	 * @return array Mapped fields.
881
	 */
882
	public function map_parsed_field_contents_of_post_to_field_names( $parsed_post_content ) {
883
884
		$mapped_fields = array();
885
886
		$field_mapping = array(
887
			'_feedback_subject'      => __( 'Contact Form', 'jetpack' ),
888
			'_feedback_author'       => '1_Name',
889
			'_feedback_author_email' => '2_Email',
890
			'_feedback_author_url'   => '3_Website',
891
			'_feedback_main_comment' => '4_Comment',
892
		);
893
894
		foreach ( $field_mapping as $parsed_field_name => $field_name ) {
895
			if (
896
				isset( $parsed_post_content[ $parsed_field_name ] )
897
				&& ! empty( $parsed_post_content[ $parsed_field_name ] )
898
			) {
899
				$mapped_fields[ $field_name ] = $parsed_post_content[ $parsed_field_name ];
900
			}
901
		}
902
903
		return $mapped_fields;
904
	}
905
906
	/**
907
	 * Registers the personal data exporter.
908
	 *
909
	 * @since 6.1.1
910
	 *
911
	 * @param  array $exporters An array of personal data exporters.
912
	 *
913
	 * @return array $exporters An array of personal data exporters.
914
	 */
915
	public function register_personal_data_exporter( $exporters ) {
916
		$exporters['jetpack-feedback'] = array(
917
			'exporter_friendly_name' => __( 'Feedback', 'jetpack' ),
918
			'callback'               => array( $this, 'personal_data_exporter' ),
919
		);
920
921
		return $exporters;
922
	}
923
924
	/**
925
	 * Registers the personal data eraser.
926
	 *
927
	 * @since 6.1.1
928
	 *
929
	 * @param  array $erasers An array of personal data erasers.
930
	 *
931
	 * @return array $erasers An array of personal data erasers.
932
	 */
933
	public function register_personal_data_eraser( $erasers ) {
934
		$erasers['jetpack-feedback'] = array(
935
			'eraser_friendly_name' => __( 'Feedback', 'jetpack' ),
936
			'callback'             => array( $this, 'personal_data_eraser' ),
937
		);
938
939
		return $erasers;
940
	}
941
942
	/**
943
	 * Exports personal data.
944
	 *
945
	 * @since 6.1.1
946
	 *
947
	 * @param  string $email  Email address.
948
	 * @param  int    $page   Page to export.
949
	 *
950
	 * @return array  $return Associative array with keys expected by core.
951
	 */
952
	public function personal_data_exporter( $email, $page = 1 ) {
953
		return $this->_internal_personal_data_exporter( $email, $page );
954
	}
955
956
	/**
957
	 * Internal method for exporting personal data.
958
	 *
959
	 * Allows us to have a different signature than core expects
960
	 * while protecting against future core API changes.
961
	 *
962
	 * @internal
963
	 * @since 6.5
964
	 *
965
	 * @param  string $email    Email address.
966
	 * @param  int    $page     Page to export.
967
	 * @param  int    $per_page Number of feedbacks to process per page. Internal use only (testing)
968
	 *
969
	 * @return array            Associative array with keys expected by core.
970
	 */
971
	public function _internal_personal_data_exporter( $email, $page = 1, $per_page = 250 ) {
972
		$export_data = array();
973
		$post_ids    = $this->personal_data_post_ids_by_email( $email, $per_page, $page );
974
975
		foreach ( $post_ids as $post_id ) {
976
			$post_fields = $this->get_parsed_field_contents_of_post( $post_id );
977
978
			if ( ! is_array( $post_fields ) || empty( $post_fields['_feedback_subject'] ) ) {
979
				continue; // Corrupt data.
980
			}
981
982
			$post_fields['_feedback_main_comment'] = $this->get_post_content_for_csv_export( $post_id );
983
			$post_fields                           = $this->map_parsed_field_contents_of_post_to_field_names( $post_fields );
984
985
			if ( ! is_array( $post_fields ) || empty( $post_fields ) ) {
986
				continue; // No fields to export.
987
			}
988
989
			$post_meta = $this->get_post_meta_for_csv_export( $post_id );
990
			$post_meta = is_array( $post_meta ) ? $post_meta : array();
991
992
			$post_export_data = array();
993
			$post_data        = array_merge( $post_fields, $post_meta );
994
			ksort( $post_data );
995
996
			foreach ( $post_data as $post_data_key => $post_data_value ) {
997
				$post_export_data[] = array(
998
					'name'  => preg_replace( '/^[0-9]+_/', '', $post_data_key ),
999
					'value' => $post_data_value,
1000
				);
1001
			}
1002
1003
			$export_data[] = array(
1004
				'group_id'    => 'feedback',
1005
				'group_label' => __( 'Feedback', 'jetpack' ),
1006
				'item_id'     => 'feedback-' . $post_id,
1007
				'data'        => $post_export_data,
1008
			);
1009
		}
1010
1011
		return array(
1012
			'data' => $export_data,
1013
			'done' => count( $post_ids ) < $per_page,
1014
		);
1015
	}
1016
1017
	/**
1018
	 * Erases personal data.
1019
	 *
1020
	 * @since 6.1.1
1021
	 *
1022
	 * @param  string $email Email address.
1023
	 * @param  int    $page  Page to erase.
1024
	 *
1025
	 * @return array         Associative array with keys expected by core.
1026
	 */
1027
	public function personal_data_eraser( $email, $page = 1 ) {
1028
		return $this->_internal_personal_data_eraser( $email, $page );
1029
	}
1030
1031
	/**
1032
	 * Internal method for erasing personal data.
1033
	 *
1034
	 * Allows us to have a different signature than core expects
1035
	 * while protecting against future core API changes.
1036
	 *
1037
	 * @internal
1038
	 * @since 6.5
1039
	 *
1040
	 * @param  string $email    Email address.
1041
	 * @param  int    $page     Page to erase.
1042
	 * @param  int    $per_page Number of feedbacks to process per page. Internal use only (testing)
1043
	 *
1044
	 * @return array            Associative array with keys expected by core.
1045
	 */
1046
	public function _internal_personal_data_eraser( $email, $page = 1, $per_page = 250 ) {
1047
		$removed      = false;
1048
		$retained     = false;
1049
		$messages     = array();
1050
		$option_name  = sprintf( '_jetpack_pde_feedback_%s', md5( $email ) );
1051
		$last_post_id = 1 === $page ? 0 : get_option( $option_name, 0 );
1052
		$post_ids     = $this->personal_data_post_ids_by_email( $email, $per_page, $page, $last_post_id );
1053
1054
		foreach ( $post_ids as $post_id ) {
1055
			/**
1056
			 * Filters whether to erase a particular Feedback post.
1057
			 *
1058
			 * @since 6.3.0
1059
			 *
1060
			 * @param bool|string $prevention_message Whether to apply erase the Feedback post (bool).
1061
			 *                                        Custom prevention message (string). Default true.
1062
			 * @param int         $post_id            Feedback post ID.
1063
			 */
1064
			$prevention_message = apply_filters( 'grunion_contact_form_delete_feedback_post', true, $post_id );
1065
1066
			if ( true !== $prevention_message ) {
1067
				if ( $prevention_message && is_string( $prevention_message ) ) {
1068
					$messages[] = esc_html( $prevention_message );
1069
				} else {
1070
					$messages[] = sprintf(
1071
					// translators: %d: Post ID.
1072
						__( 'Feedback ID %d could not be removed at this time.', 'jetpack' ),
1073
						$post_id
1074
					);
1075
				}
1076
1077
				$retained = true;
1078
1079
				continue;
1080
			}
1081
1082
			if ( wp_delete_post( $post_id, true ) ) {
1083
				$removed = true;
1084
			} else {
1085
				$retained   = true;
1086
				$messages[] = sprintf(
1087
				// translators: %d: Post ID.
1088
					__( 'Feedback ID %d could not be removed at this time.', 'jetpack' ),
1089
					$post_id
1090
				);
1091
			}
1092
		}
1093
1094
		$done = count( $post_ids ) < $per_page;
1095
1096
		if ( $done ) {
1097
			delete_option( $option_name );
1098
		} else {
1099
			update_option( $option_name, (int) $post_id );
0 ignored issues
show
Bug introduced by
The variable $post_id seems to be defined by a foreach iteration on line 1054. Are you sure the iterator is never empty, otherwise this variable is not defined?

It seems like you are relying on a variable being defined by an iteration:

foreach ($a as $b) {
}

// $b is defined here only if $a has elements, for example if $a is array()
// then $b would not be defined here. To avoid that, we recommend to set a
// default value for $b.


// Better
$b = 0; // or whatever default makes sense in your context
foreach ($a as $b) {
}

// $b is now guaranteed to be defined here.
Loading history...
1100
		}
1101
1102
		return array(
1103
			'items_removed'  => $removed,
1104
			'items_retained' => $retained,
1105
			'messages'       => $messages,
1106
			'done'           => $done,
1107
		);
1108
	}
1109
1110
	/**
1111
	 * Queries personal data by email address.
1112
	 *
1113
	 * @since 6.1.1
1114
	 *
1115
	 * @param  string $email        Email address.
1116
	 * @param  int    $per_page     Post IDs per page. Default is `250`.
1117
	 * @param  int    $page         Page to query. Default is `1`.
1118
	 * @param  int    $last_post_id Page to query. Default is `0`. If non-zero, used instead of $page.
1119
	 *
1120
	 * @return array An array of post IDs.
1121
	 */
1122
	public function personal_data_post_ids_by_email( $email, $per_page = 250, $page = 1, $last_post_id = 0 ) {
1123
		add_filter( 'posts_search', array( $this, 'personal_data_search_filter' ) );
1124
1125
		$this->pde_last_post_id_erased = $last_post_id;
1126
		$this->pde_email_address       = $email;
1127
1128
		$post_ids = get_posts(
1129
			array(
1130
				'post_type'        => 'feedback',
1131
				'post_status'      => 'publish',
1132
				// This search parameter gets overwritten in ->personal_data_search_filter()
1133
				's'                => '..PDE..AUTHOR EMAIL:..PDE..',
1134
				'sentence'         => true,
1135
				'order'            => 'ASC',
1136
				'orderby'          => 'ID',
1137
				'fields'           => 'ids',
1138
				'posts_per_page'   => $per_page,
1139
				'paged'            => $last_post_id ? 1 : $page,
1140
				'suppress_filters' => false,
1141
			)
1142
		);
1143
1144
		$this->pde_last_post_id_erased = 0;
1145
		$this->pde_email_address       = '';
1146
1147
		remove_filter( 'posts_search', array( $this, 'personal_data_search_filter' ) );
1148
1149
		return $post_ids;
1150
	}
1151
1152
	/**
1153
	 * Filters searches by email address.
1154
	 *
1155
	 * @since 6.1.1
1156
	 *
1157
	 * @param  string $search SQL where clause.
1158
	 *
1159
	 * @return array          Filtered SQL where clause.
1160
	 */
1161
	public function personal_data_search_filter( $search ) {
1162
		global $wpdb;
1163
1164
		/*
1165
		 * Limits search to `post_content` only, and we only match the
1166
		 * author's email address whenever it's on a line by itself.
1167
		 */
1168
		if ( $this->pde_email_address && false !== strpos( $search, '..PDE..AUTHOR EMAIL:..PDE..' ) ) {
1169
			$search = $wpdb->prepare(
1170
				" AND (
1171
					{$wpdb->posts}.post_content LIKE %s
1172
					OR {$wpdb->posts}.post_content LIKE %s
1173
				)",
1174
				// `chr( 10 )` = `\n`, `chr( 13 )` = `\r`
1175
				'%' . $wpdb->esc_like( chr( 10 ) . 'AUTHOR EMAIL: ' . $this->pde_email_address . chr( 10 ) ) . '%',
1176
				'%' . $wpdb->esc_like( chr( 13 ) . 'AUTHOR EMAIL: ' . $this->pde_email_address . chr( 13 ) ) . '%'
1177
			);
1178
1179
			if ( $this->pde_last_post_id_erased ) {
1180
				$search .= $wpdb->prepare( " AND {$wpdb->posts}.ID > %d", $this->pde_last_post_id_erased );
1181
			}
1182
		}
1183
1184
		return $search;
1185
	}
1186
1187
	/**
1188
	 * Prepares feedback post data for CSV export.
1189
	 *
1190
	 * @param array $post_ids Post IDs to fetch the data for. These need to be Feedback posts.
1191
	 *
1192
	 * @return array
1193
	 */
1194
	public function get_export_data_for_posts( $post_ids ) {
1195
1196
		$posts_data  = array();
1197
		$field_names = array();
1198
		$result      = array();
1199
1200
		/**
1201
		 * Fetch posts and get the possible field names for later use
1202
		 */
1203
		foreach ( $post_ids as $post_id ) {
1204
1205
			/**
1206
			 * Fetch post main data, because we need the subject and author data for the feedback form.
1207
			 */
1208
			$post_real_data = $this->get_parsed_field_contents_of_post( $post_id );
1209
1210
			/**
1211
			 * If `$post_real_data` is not an array or there is no `_feedback_subject` set,
1212
			 * then something must be wrong with the feedback post. Skip it.
1213
			 */
1214
			if ( ! is_array( $post_real_data ) || ! isset( $post_real_data['_feedback_subject'] ) ) {
1215
				continue;
1216
			}
1217
1218
			/**
1219
			 * Fetch main post comment. This is from the default textarea fields.
1220
			 * If it is non-empty, then we add it to data, otherwise skip it.
1221
			 */
1222
			$post_comment_content = $this->get_post_content_for_csv_export( $post_id );
1223
			if ( ! empty( $post_comment_content ) ) {
1224
				$post_real_data['_feedback_main_comment'] = $post_comment_content;
1225
			}
1226
1227
			/**
1228
			 * Map parsed fields to proper field names
1229
			 */
1230
			$mapped_fields = $this->map_parsed_field_contents_of_post_to_field_names( $post_real_data );
1231
1232
			/**
1233
			 * Fetch post meta data.
1234
			 */
1235
			$post_meta_data = $this->get_post_meta_for_csv_export( $post_id );
1236
1237
			/**
1238
			 * If `$post_meta_data` is not an array or if it is empty, then there is no
1239
			 * extra feedback to work with. Create an empty array.
1240
			 */
1241
			if ( ! is_array( $post_meta_data ) || empty( $post_meta_data ) ) {
1242
				$post_meta_data = array();
1243
			}
1244
1245
			/**
1246
			 * Prepend the feedback subject to the list of fields.
1247
			 */
1248
			$post_meta_data = array_merge(
1249
				$mapped_fields,
1250
				$post_meta_data
1251
			);
1252
1253
			/**
1254
			 * Save post metadata for later usage.
1255
			 */
1256
			$posts_data[ $post_id ] = $post_meta_data;
1257
1258
			/**
1259
			 * Save field names, so we can use them as header fields later in the CSV.
1260
			 */
1261
			$field_names = array_merge( $field_names, array_keys( $post_meta_data ) );
1262
		}
1263
1264
		/**
1265
		 * Make sure the field names are unique, because we don't want duplicate data.
1266
		 */
1267
		$field_names = array_unique( $field_names );
1268
1269
		/**
1270
		 * Sort the field names by the field id number
1271
		 */
1272
		sort( $field_names, SORT_NUMERIC );
1273
1274
		/**
1275
		 * Loop through every post, which is essentially CSV row.
1276
		 */
1277
		foreach ( $posts_data as $post_id => $single_post_data ) {
1278
1279
			/**
1280
			 * Go through all the possible fields and check if the field is available
1281
			 * in the current post.
1282
			 *
1283
			 * If it is - add the data as a value.
1284
			 * If it is not - add an empty string, which is just a placeholder in the CSV.
1285
			 */
1286
			foreach ( $field_names as $single_field_name ) {
1287
				if (
1288
					isset( $single_post_data[ $single_field_name ] )
1289
					&& ! empty( $single_post_data[ $single_field_name ] )
1290
				) {
1291
					$result[ $single_field_name ][] = trim( $single_post_data[ $single_field_name ] );
1292
				} else {
1293
					$result[ $single_field_name ][] = '';
1294
				}
1295
			}
1296
		}
1297
1298
		return $result;
1299
	}
1300
1301
	/**
1302
	 * download as a csv a contact form or all of them in a csv file
1303
	 */
1304
	function download_feedback_as_csv() {
1305
		if ( empty( $_POST['feedback_export_nonce'] ) ) {
1306
			return;
1307
		}
1308
1309
		check_admin_referer( 'feedback_export', 'feedback_export_nonce' );
1310
1311
		if ( ! current_user_can( 'export' ) ) {
1312
			return;
1313
		}
1314
1315
		$args = array(
1316
			'posts_per_page'   => -1,
1317
			'post_type'        => 'feedback',
1318
			'post_status'      => 'publish',
1319
			'order'            => 'ASC',
1320
			'fields'           => 'ids',
1321
			'suppress_filters' => false,
1322
		);
1323
1324
		$filename = date( 'Y-m-d' ) . '-feedback-export.csv';
1325
1326
		// Check if we want to download all the feedbacks or just a certain contact form
1327
		if ( ! empty( $_POST['post'] ) && $_POST['post'] !== 'all' ) {
1328
			$args['post_parent'] = (int) $_POST['post'];
1329
			$filename            = date( 'Y-m-d' ) . '-' . str_replace( '&nbsp;', '-', get_the_title( (int) $_POST['post'] ) ) . '.csv';
1330
		}
1331
1332
		$feedbacks = get_posts( $args );
1333
1334
		if ( empty( $feedbacks ) ) {
1335
			return;
1336
		}
1337
1338
		$filename = sanitize_file_name( $filename );
1339
1340
		/**
1341
		 * Prepare data for export.
1342
		 */
1343
		$data = $this->get_export_data_for_posts( $feedbacks );
1344
1345
		/**
1346
		 * If `$data` is empty, there's nothing we can do below.
1347
		 */
1348
		if ( ! is_array( $data ) || empty( $data ) ) {
1349
			return;
1350
		}
1351
1352
		/**
1353
		 * Extract field names from `$data` for later use.
1354
		 */
1355
		$fields = array_keys( $data );
1356
1357
		/**
1358
		 * Count how many rows will be exported.
1359
		 */
1360
		$row_count = count( reset( $data ) );
1361
1362
		// Forces the download of the CSV instead of echoing
1363
		header( 'Content-Disposition: attachment; filename=' . $filename );
1364
		header( 'Pragma: no-cache' );
1365
		header( 'Expires: 0' );
1366
		header( 'Content-Type: text/csv; charset=utf-8' );
1367
1368
		$output = fopen( 'php://output', 'w' );
1369
1370
		/**
1371
		 * Print CSV headers
1372
		 */
1373
		fputcsv( $output, $fields );
1374
1375
		/**
1376
		 * Print rows to the output.
1377
		 */
1378
		for ( $i = 0; $i < $row_count; $i ++ ) {
1379
1380
			$current_row = array();
1381
1382
			/**
1383
			 * Put all the fields in `$current_row` array.
1384
			 */
1385
			foreach ( $fields as $single_field_name ) {
1386
				$current_row[] = $this->esc_csv( $data[ $single_field_name ][ $i ] );
1387
			}
1388
1389
			/**
1390
			 * Output the complete CSV row
1391
			 */
1392
			fputcsv( $output, $current_row );
1393
		}
1394
1395
		fclose( $output );
1396
	}
1397
1398
	/**
1399
	 * Escape a string to be used in a CSV context
1400
	 *
1401
	 * Malicious input can inject formulas into CSV files, opening up the possibility for phishing attacks and
1402
	 * disclosure of sensitive information.
1403
	 *
1404
	 * Additionally, Excel exposes the ability to launch arbitrary commands through the DDE protocol.
1405
	 *
1406
	 * @see https://www.contextis.com/en/blog/comma-separated-vulnerabilities
1407
	 *
1408
	 * @param string $field
1409
	 *
1410
	 * @return string
1411
	 */
1412
	public function esc_csv( $field ) {
1413
		$active_content_triggers = array( '=', '+', '-', '@' );
1414
1415
		if ( in_array( mb_substr( $field, 0, 1 ), $active_content_triggers, true ) ) {
1416
			$field = "'" . $field;
1417
		}
1418
1419
		return $field;
1420
	}
1421
1422
	/**
1423
	 * Returns a string of HTML <option> items from an array of posts
1424
	 *
1425
	 * @return string a string of HTML <option> items
1426
	 */
1427
	protected function get_feedbacks_as_options() {
1428
		$options = '';
1429
1430
		// Get the feedbacks' parents' post IDs
1431
		$feedbacks = get_posts(
1432
			array(
1433
				'fields'           => 'id=>parent',
1434
				'posts_per_page'   => 100000,
1435
				'post_type'        => 'feedback',
1436
				'post_status'      => 'publish',
1437
				'suppress_filters' => false,
1438
			)
1439
		);
1440
		$parents   = array_unique( array_values( $feedbacks ) );
1441
1442
		$posts = get_posts(
1443
			array(
1444
				'orderby'          => 'ID',
1445
				'posts_per_page'   => 1000,
1446
				'post_type'        => 'any',
1447
				'post__in'         => array_values( $parents ),
1448
				'suppress_filters' => false,
1449
			)
1450
		);
1451
1452
		// creates the string of <option> elements
1453
		foreach ( $posts as $post ) {
1454
			$options .= sprintf( '<option value="%s">%s</option>', esc_attr( $post->ID ), esc_html( $post->post_title ) );
1455
		}
1456
1457
		return $options;
1458
	}
1459
1460
	/**
1461
	 * Get the names of all the form's fields
1462
	 *
1463
	 * @param  array|int $posts the post we want the fields of
1464
	 *
1465
	 * @return array     the array of fields
1466
	 *
1467
	 * @deprecated As this is no longer necessary as of the CSV export rewrite. - 2015-12-29
1468
	 */
1469
	protected function get_field_names( $posts ) {
1470
		$posts      = (array) $posts;
1471
		$all_fields = array();
1472
1473
		foreach ( $posts as $post ) {
1474
			$fields = self::parse_fields_from_content( $post );
1475
1476
			if ( isset( $fields['_feedback_all_fields'] ) ) {
1477
				$extra_fields = array_keys( $fields['_feedback_all_fields'] );
1478
				$all_fields   = array_merge( $all_fields, $extra_fields );
1479
			}
1480
		}
1481
1482
		$all_fields = array_unique( $all_fields );
1483
		return $all_fields;
1484
	}
1485
1486
	public static function parse_fields_from_content( $post_id ) {
1487
		static $post_fields;
1488
1489
		if ( ! is_array( $post_fields ) ) {
1490
			$post_fields = array();
1491
		}
1492
1493
		if ( isset( $post_fields[ $post_id ] ) ) {
1494
			return $post_fields[ $post_id ];
1495
		}
1496
1497
		$all_values   = array();
1498
		$post_content = get_post_field( 'post_content', $post_id );
1499
		$content      = explode( '<!--more-->', $post_content );
1500
		$lines        = array();
1501
1502
		if ( count( $content ) > 1 ) {
1503
			$content  = str_ireplace( array( '<br />', ')</p>' ), '', $content[1] );
1504
			$one_line = preg_replace( '/\s+/', ' ', $content );
1505
			$one_line = preg_replace( '/.*Array \( (.*)\)/', '$1', $one_line );
1506
1507
			preg_match_all( '/\[([^\]]+)\] =\&gt\; ([^\[]+)/', $one_line, $matches );
1508
1509
			if ( count( $matches ) > 1 ) {
1510
				$all_values = array_combine( array_map( 'trim', $matches[1] ), array_map( 'trim', $matches[2] ) );
1511
			}
1512
1513
			$lines = array_filter( explode( "\n", $content ) );
1514
		}
1515
1516
		$var_map = array(
1517
			'AUTHOR'       => '_feedback_author',
1518
			'AUTHOR EMAIL' => '_feedback_author_email',
1519
			'AUTHOR URL'   => '_feedback_author_url',
1520
			'SUBJECT'      => '_feedback_subject',
1521
			'IP'           => '_feedback_ip',
1522
		);
1523
1524
		$fields = array();
1525
1526
		foreach ( $lines as $line ) {
1527
			$vars = explode( ': ', $line, 2 );
1528
			if ( ! empty( $vars ) ) {
1529
				if ( isset( $var_map[ $vars[0] ] ) ) {
1530
					$fields[ $var_map[ $vars[0] ] ] = self::strip_tags( trim( $vars[1] ) );
1531
				}
1532
			}
1533
		}
1534
1535
		$fields['_feedback_all_fields'] = $all_values;
1536
1537
		$post_fields[ $post_id ] = $fields;
1538
1539
		return $fields;
1540
	}
1541
1542
	/**
1543
	 * Creates a valid csv row from a post id
1544
	 *
1545
	 * @param  int   $post_id The id of the post
1546
	 * @param  array $fields  An array containing the names of all the fields of the csv
1547
	 * @return String The csv row
1548
	 *
1549
	 * @deprecated This is no longer needed, as of the CSV export rewrite.
1550
	 */
1551
	protected static function make_csv_row_from_feedback( $post_id, $fields ) {
1552
		$content_fields = self::parse_fields_from_content( $post_id );
1553
		$all_fields     = array();
1554
1555
		if ( isset( $content_fields['_feedback_all_fields'] ) ) {
1556
			$all_fields = $content_fields['_feedback_all_fields'];
1557
		}
1558
1559
		// Overwrite the parsed content with the content we stored in post_meta in a better format.
1560
		$extra_fields = get_post_meta( $post_id, '_feedback_extra_fields', true );
1561
		foreach ( $extra_fields as $extra_field => $extra_value ) {
1562
			$all_fields[ $extra_field ] = $extra_value;
1563
		}
1564
1565
		// The first element in all of the exports will be the subject
1566
		$row_items[] = $content_fields['_feedback_subject'];
0 ignored issues
show
Coding Style Comprehensibility introduced by
$row_items was never initialized. Although not strictly required by PHP, it is generally a good practice to add $row_items = array(); before regardless.

Adding an explicit array definition is generally preferable to implicit array definition as it guarantees a stable state of the code.

Let’s take a look at an example:

foreach ($collection as $item) {
    $myArray['foo'] = $item->getFoo();

    if ($item->hasBar()) {
        $myArray['bar'] = $item->getBar();
    }

    // do something with $myArray
}

As you can see in this example, the array $myArray is initialized the first time when the foreach loop is entered. You can also see that the value of the bar key is only written conditionally; thus, its value might result from a previous iteration.

This might or might not be intended. To make your intention clear, your code more readible and to avoid accidental bugs, we recommend to add an explicit initialization $myArray = array() either outside or inside the foreach loop.

Loading history...
1567
1568
		// Loop the fields array in order to fill the $row_items array correctly
1569
		foreach ( $fields as $field ) {
1570
			if ( $field === __( 'Contact Form', 'jetpack' ) ) { // the first field will ever be the contact form, so we can continue
1571
				continue;
1572
			} elseif ( array_key_exists( $field, $all_fields ) ) {
1573
				$row_items[] = $all_fields[ $field ];
1574
			} else {
1575
				$row_items[] = '';
1576
			}
1577
		}
1578
1579
		return $row_items;
1580
	}
1581
1582
	public static function get_ip_address() {
1583
		return isset( $_SERVER['REMOTE_ADDR'] ) ? $_SERVER['REMOTE_ADDR'] : null;
1584
	}
1585
1586
	/**
1587
	 * Used to check whether integrations are enabled the contact form for this site
1588
	 *
1589
	 * @return bool True if integrations are enabled, false otherwise.
1590
	 */
1591
	function contact_form_integrations_enabled() {
1592
		// For WPCOM sites
1593 View Code Duplication
		if ( defined( 'IS_WPCOM' ) && IS_WPCOM && function_exists( 'has_any_blog_stickers' ) ) {
1594
			$site_id = $this->get_blog_id();
0 ignored issues
show
Bug introduced by
The method get_blog_id() does not seem to exist on object<Grunion_Contact_Form_Plugin>.

This check looks for calls to methods that do not seem to exist on a given type. It looks for the method on the type itself as well as in inherited classes or implemented interfaces.

This is most likely a typographical error or the method has been renamed.

Loading history...
1595
			return has_any_blog_stickers( array( 'premium-plan', 'business-plan', 'ecommerce-plan' ), $site_id );
1596
		}
1597
		// For all Jetpack sites
1598
		return Jetpack::is_active() && Jetpack_Plan::supports( 'contact-form-integrations');
1599
	}
1600
}
1601
1602
/**
1603
 * Generic shortcode class.
1604
 * Does nothing other than store structured data and output the shortcode as a string
1605
 *
1606
 * Not very general - specific to Grunion.
1607
 */
1608
class Crunion_Contact_Form_Shortcode {
1609
	/**
1610
	 * @var string the name of the shortcode: [$shortcode_name /]
1611
	 */
1612
	public $shortcode_name;
1613
1614
	/**
1615
	 * @var array key => value pairs for the shortcode's attributes: [$shortcode_name key="value" ... /]
1616
	 */
1617
	public $attributes;
1618
1619
	/**
1620
	 * @var array key => value pair for attribute defaults
1621
	 */
1622
	public $defaults = array();
1623
1624
	/**
1625
	 * @var null|string Null for selfclosing shortcodes.  Hhe inner content of otherwise: [$shortcode_name]$content[/$shortcode_name]
1626
	 */
1627
	public $content;
1628
1629
	/**
1630
	 * @var array Associative array of inner "child" shortcodes equivalent to the $content: [$shortcode_name][child 1/][child 2/][/$shortcode_name]
1631
	 */
1632
	public $fields;
1633
1634
	/**
1635
	 * @var null|string The HTML of the parsed inner "child" shortcodes".  Null for selfclosing shortcodes.
1636
	 */
1637
	public $body;
1638
1639
	/**
1640
	 * @param array       $attributes An associative array of shortcode attributes.  @see shortcode_atts()
1641
	 * @param null|string $content Null for selfclosing shortcodes.  The inner content otherwise.
1642
	 */
1643
	function __construct( $attributes, $content = null ) {
1644
		$this->attributes = $this->unesc_attr( $attributes );
0 ignored issues
show
Documentation Bug introduced by
It seems like $this->unesc_attr($attributes) of type * is incompatible with the declared type array of property $attributes.

Our type inference engine has found an assignment to a property that is incompatible with the declared type of that property.

Either this assignment is in error or the assigned type should be added to the documentation/type hint for that property..

Loading history...
1645
		if ( is_array( $content ) ) {
1646
			$string_content = '';
1647
			foreach ( $content as $field ) {
1648
				$string_content .= (string) $field;
1649
			}
1650
1651
			$this->content = $string_content;
1652
		} else {
1653
			$this->content = $content;
1654
		}
1655
1656
		$this->parse_content( $this->content );
1657
	}
1658
1659
	/**
1660
	 * Processes the shortcode's inner content for "child" shortcodes
1661
	 *
1662
	 * @param string $content The shortcode's inner content: [shortcode]$content[/shortcode]
1663
	 */
1664
	function parse_content( $content ) {
1665
		if ( is_null( $content ) ) {
1666
			$this->body = null;
1667
		}
1668
1669
		$this->body = do_shortcode( $content );
1670
	}
1671
1672
	/**
1673
	 * Returns the value of the requested attribute.
1674
	 *
1675
	 * @param string $key The attribute to retrieve
1676
	 * @return mixed
1677
	 */
1678
	function get_attribute( $key ) {
1679
		return isset( $this->attributes[ $key ] ) ? $this->attributes[ $key ] : null;
1680
	}
1681
1682
	function esc_attr( $value ) {
1683
		if ( is_array( $value ) ) {
1684
			return array_map( array( $this, 'esc_attr' ), $value );
1685
		}
1686
1687
		$value = Grunion_Contact_Form_Plugin::strip_tags( $value );
1688
		$value = _wp_specialchars( $value, ENT_QUOTES, false, true );
1689
1690
		// Shortcode attributes can't contain "]"
1691
		$value = str_replace( ']', '', $value );
1692
		$value = str_replace( ',', '&#x002c;', $value ); // store commas encoded
1693
		$value = strtr(
1694
			$value, array(
1695
				'%' => '%25',
1696
				'&' => '%26',
1697
			)
1698
		);
1699
1700
		// shortcode_parse_atts() does stripcslashes()
1701
		$value = addslashes( $value );
1702
		return $value;
1703
	}
1704
1705
	function unesc_attr( $value ) {
1706
		if ( is_array( $value ) ) {
1707
			return array_map( array( $this, 'unesc_attr' ), $value );
1708
		}
1709
1710
		// For back-compat with old Grunion encoding
1711
		// Also, unencode commas
1712
		$value = strtr(
1713
			$value, array(
1714
				'%26' => '&',
1715
				'%25' => '%',
1716
			)
1717
		);
1718
		$value = preg_replace( array( '/&#x0*22;/i', '/&#x0*27;/i', '/&#x0*26;/i', '/&#x0*2c;/i' ), array( '"', "'", '&', ',' ), $value );
1719
		$value = htmlspecialchars_decode( $value, ENT_QUOTES );
1720
		$value = Grunion_Contact_Form_Plugin::strip_tags( $value );
1721
1722
		return $value;
1723
	}
1724
1725
	/**
1726
	 * Generates the shortcode
1727
	 */
1728
	function __toString() {
1729
		$r = "[{$this->shortcode_name} ";
1730
1731
		foreach ( $this->attributes as $key => $value ) {
1732
			if ( ! $value ) {
1733
				continue;
1734
			}
1735
1736
			if ( isset( $this->defaults[ $key ] ) && $this->defaults[ $key ] == $value ) {
1737
				continue;
1738
			}
1739
1740
			if ( 'id' == $key ) {
1741
				continue;
1742
			}
1743
1744
			$value = $this->esc_attr( $value );
1745
1746
			if ( is_array( $value ) ) {
1747
				$value = join( ',', $value );
1748
			}
1749
1750
			if ( false === strpos( $value, "'" ) ) {
1751
				$value = "'$value'";
1752
			} elseif ( false === strpos( $value, '"' ) ) {
1753
				$value = '"' . $value . '"';
1754
			} else {
1755
				// Shortcodes can't contain both '"' and "'".  Strip one.
1756
				$value = str_replace( "'", '', $value );
1757
				$value = "'$value'";
1758
			}
1759
1760
			$r .= "{$key}={$value} ";
1761
		}
1762
1763
		$r = rtrim( $r );
1764
1765
		if ( $this->fields ) {
0 ignored issues
show
Bug Best Practice introduced by
The expression $this->fields of type array is implicitly converted to a boolean; are you sure this is intended? If so, consider using ! empty($expr) instead to make it clear that you intend to check for an array without elements.

This check marks implicit conversions of arrays to boolean values in a comparison. While in PHP an empty array is considered to be equal (but not identical) to false, this is not always apparent.

Consider making the comparison explicit by using empty(..) or ! empty(...) instead.

Loading history...
1766
			$r .= ']';
1767
1768
			foreach ( $this->fields as $field ) {
1769
				$r .= (string) $field;
1770
			}
1771
1772
			$r .= "[/{$this->shortcode_name}]";
1773
		} else {
1774
			$r .= '/]';
1775
		}
1776
1777
		return $r;
1778
	}
1779
}
1780
1781
/**
1782
 * Class for the contact-form shortcode.
1783
 * Parses shortcode to output the contact form as HTML
1784
 * Sends email and stores the contact form response (a.k.a. "feedback")
1785
 */
1786
class Grunion_Contact_Form extends Crunion_Contact_Form_Shortcode {
1787
	public $shortcode_name = 'contact-form';
1788
1789
	/**
1790
	 * @var WP_Error stores form submission errors
1791
	 */
1792
	public $errors;
1793
1794
	/**
1795
	 * @var string The SHA1 hash of the attributes that comprise the form.
1796
	 */
1797
	public $hash;
1798
1799
	/**
1800
	 * @var Grunion_Contact_Form The most recent (inclusive) contact-form shortcode processed
1801
	 */
1802
	static $last;
1803
1804
	/**
1805
	 * @var Whatever form we are currently looking at. If processed, will become $last
1806
	 */
1807
	static $current_form;
1808
1809
	/**
1810
	 * @var array All found forms, indexed by hash.
1811
	 */
1812
	static $forms = array();
1813
1814
	/**
1815
	 * @var bool Whether to print the grunion.css style when processing the contact-form shortcode
1816
	 */
1817
	static $style = false;
1818
1819
	/**
1820
	 * @var array When printing the submit button, what tags are allowed
1821
	 */
1822
	static $allowed_html_tags_for_submit_button = array( 'br' => array() );
1823
1824
	function __construct( $attributes, $content = null ) {
1825
		global $post;
1826
1827
		$this->hash                 = sha1( json_encode( $attributes ) . $content );
1828
		self::$forms[ $this->hash ] = $this;
1829
1830
		// Set up the default subject and recipient for this form
1831
		$default_to      = '';
1832
		$default_subject = '[' . get_option( 'blogname' ) . ']';
1833
1834
		if ( ! isset( $attributes ) || ! is_array( $attributes ) ) {
1835
			$attributes = array();
1836
		}
1837
1838
		if ( ! empty( $attributes['widget'] ) && $attributes['widget'] ) {
1839
			$default_to      .= get_option( 'admin_email' );
1840
			$attributes['id'] = 'widget-' . $attributes['widget'];
1841
			$default_subject  = sprintf( _x( '%1$s Sidebar', '%1$s = blog name', 'jetpack' ), $default_subject );
1842
		} elseif ( $post ) {
1843
			$attributes['id'] = $post->ID;
1844
			$default_subject  = sprintf( _x( '%1$s %2$s', '%1$s = blog name, %2$s = post title', 'jetpack' ), $default_subject, Grunion_Contact_Form_Plugin::strip_tags( $post->post_title ) );
1845
			$post_author      = get_userdata( $post->post_author );
1846
			$default_to      .= $post_author->user_email;
1847
		}
1848
1849
		// Keep reference to $this for parsing form fields
1850
		self::$current_form = $this;
0 ignored issues
show
Documentation Bug introduced by
It seems like $this of type this<Grunion_Contact_Form> is incompatible with the declared type object<Whatever> of property $current_form.

Our type inference engine has found an assignment to a property that is incompatible with the declared type of that property.

Either this assignment is in error or the assigned type should be added to the documentation/type hint for that property..

Loading history...
1851
1852
		$this->defaults = array(
1853
			'to'                     => $default_to,
1854
			'subject'                => $default_subject,
1855
			'show_subject'           => 'no', // only used in back-compat mode
1856
			'widget'                 => 0,    // Not exposed to the user. Works with Grunion_Contact_Form_Plugin::widget_atts()
1857
			'id'                     => null, // Not exposed to the user. Set above.
1858
			'submit_button_text'     => __( 'Submit', 'jetpack' ),
1859
			// These attributes come from the block editor, so use camel case instead of snake case.
1860
			'customThankyou'         => '', // Whether to show a custom thankyou response after submitting a form. '' for no, 'message' for a custom message, 'redirect' to redirect to a new URL.
1861
			'customThankyouMessage'  => __( 'Thank you for your submission!', 'jetpack' ), // The message to show when customThankyou is set to 'message'.
1862
			'customThankyouRedirect' => '', // The URL to redirect to when customThankyou is set to 'redirect'.
1863
			'driveFileName'          => false, // The name of the drive file to send submissions to.
1864
		);
1865
1866
		$attributes = shortcode_atts( $this->defaults, $attributes, 'contact-form' );
1867
1868
		// We only enable the contact-field shortcode temporarily while processing the contact-form shortcode
1869
		Grunion_Contact_Form_Plugin::$using_contact_form_field = true;
0 ignored issues
show
Bug introduced by
The property using_contact_form_field cannot be accessed from this context as it is declared private in class Grunion_Contact_Form_Plugin.

This check looks for access to properties that are not accessible from the current context.

If you need to make a property accessible to another context you can either raise its visibility level or provide an accessible getter in the defining class.

Loading history...
1870
1871
		parent::__construct( $attributes, $content );
1872
1873
		// There were no fields in the contact form. The form was probably just [contact-form /]. Build a default form.
1874
		if ( empty( $this->fields ) ) {
1875
			// same as the original Grunion v1 form
1876
			$default_form = '
1877
				[contact-field label="' . __( 'Name', 'jetpack' ) . '" type="name"  required="true" /]
1878
				[contact-field label="' . __( 'Email', 'jetpack' ) . '" type="email" required="true" /]
1879
				[contact-field label="' . __( 'Website', 'jetpack' ) . '" type="url" /]';
1880
1881
			if ( 'yes' == strtolower( $this->get_attribute( 'show_subject' ) ) ) {
1882
				$default_form .= '
1883
					[contact-field label="' . __( 'Subject', 'jetpack' ) . '" type="subject" /]';
1884
			}
1885
1886
			$default_form .= '
1887
				[contact-field label="' . __( 'Message', 'jetpack' ) . '" type="textarea" /]';
1888
1889
			$this->parse_content( $default_form );
1890
1891
			// Store the shortcode
1892
			$this->store_shortcode( $default_form, $attributes, $this->hash );
1893
		} else {
1894
			// Store the shortcode
1895
			$this->store_shortcode( $content, $attributes, $this->hash );
1896
		}
1897
1898
		// $this->body and $this->fields have been setup.  We no longer need the contact-field shortcode.
1899
		Grunion_Contact_Form_Plugin::$using_contact_form_field = false;
0 ignored issues
show
Bug introduced by
The property using_contact_form_field cannot be accessed from this context as it is declared private in class Grunion_Contact_Form_Plugin.

This check looks for access to properties that are not accessible from the current context.

If you need to make a property accessible to another context you can either raise its visibility level or provide an accessible getter in the defining class.

Loading history...
1900
	}
1901
1902
	/**
1903
	 * Store shortcode content for recall later
1904
	 *  - used to receate shortcode when user uses do_shortcode
1905
	 *
1906
	 * @param string $content
0 ignored issues
show
Documentation introduced by
Should the type for parameter $content not be string|null?

This check looks for @param annotations where the type inferred by our type inference engine differs from the declared type.

It makes a suggestion as to what type it considers more descriptive.

Most often this is a case of a parameter that can be null in addition to its declared types.

Loading history...
1907
	 * @param array $attributes
0 ignored issues
show
Documentation introduced by
Should the type for parameter $attributes not be array|null?

This check looks for @param annotations where the type inferred by our type inference engine differs from the declared type.

It makes a suggestion as to what type it considers more descriptive.

Most often this is a case of a parameter that can be null in addition to its declared types.

Loading history...
1908
	 * @param string $hash
0 ignored issues
show
Documentation introduced by
Should the type for parameter $hash not be string|null?

This check looks for @param annotations where the type inferred by our type inference engine differs from the declared type.

It makes a suggestion as to what type it considers more descriptive.

Most often this is a case of a parameter that can be null in addition to its declared types.

Loading history...
1909
	 */
1910
	static function store_shortcode( $content = null, $attributes = null, $hash = null ) {
1911
1912
		if ( $content != null and isset( $attributes['id'] ) ) {
0 ignored issues
show
Bug introduced by
It seems like you are loosely comparing $content of type string|null against null; this is ambiguous if the string can be empty. Consider using a strict comparison !== instead.
Loading history...
1913
1914
			if ( empty( $hash ) ) {
1915
				$hash = sha1( json_encode( $attributes ) . $content );
1916
			}
1917
1918
			$shortcode_meta = get_post_meta( $attributes['id'], "_g_feedback_shortcode_{$hash}", true );
1919
1920
			if ( $shortcode_meta != '' or $shortcode_meta != $content ) {
1921
				update_post_meta( $attributes['id'], "_g_feedback_shortcode_{$hash}", $content );
1922
1923
				// Save attributes to post_meta for later use. They're not available later in do_shortcode situations.
1924
				update_post_meta( $attributes['id'], "_g_feedback_shortcode_atts_{$hash}", $attributes );
1925
			}
1926
		}
1927
	}
1928
1929
	/**
1930
	 * Toggle for printing the grunion.css stylesheet
1931
	 *
1932
	 * @param bool $style
1933
	 */
1934
	static function style( $style ) {
1935
		$previous_style = self::$style;
1936
		self::$style    = (bool) $style;
1937
		return $previous_style;
1938
	}
1939
1940
	/**
1941
	 * Turn on printing of grunion.css stylesheet
1942
	 *
1943
	 * @see ::style()
1944
	 * @internal
1945
	 * @param bool $style
0 ignored issues
show
Bug introduced by
There is no parameter named $style. Was it maybe removed?

This check looks for PHPDoc comments describing methods or function parameters that do not exist on the corresponding method or function.

Consider the following example. The parameter $italy is not defined by the method finale(...).

/**
 * @param array $germany
 * @param array $island
 * @param array $italy
 */
function finale($germany, $island) {
    return "2:1";
}

The most likely cause is that the parameter was removed, but the annotation was not.

Loading history...
1946
	 */
1947
	static function _style_on() {
1948
		return self::style( true );
1949
	}
1950
1951
	/**
1952
	 * The contact-form shortcode processor
1953
	 *
1954
	 * @param array       $attributes Key => Value pairs as parsed by shortcode_parse_atts()
1955
	 * @param string|null $content The shortcode's inner content: [contact-form]$content[/contact-form]
1956
	 * @return string HTML for the concat form.
1957
	 */
1958
	static function parse( $attributes, $content ) {
1959
		if ( Settings::is_syncing() ) {
1960
			return '';
1961
		}
1962
		// Create a new Grunion_Contact_Form object (this class)
1963
		$form = new Grunion_Contact_Form( $attributes, $content );
1964
1965
		$id = $form->get_attribute( 'id' );
1966
1967
		if ( ! $id ) { // something terrible has happened
1968
			return '[contact-form]';
1969
		}
1970
1971
		if ( is_feed() ) {
1972
			return '[contact-form]';
1973
		}
1974
1975
		self::$last = $form;
1976
1977
		// Enqueue the grunion.css stylesheet if self::$style allows it
1978
		if ( self::$style && ( empty( $_REQUEST['action'] ) || $_REQUEST['action'] != 'grunion_shortcode_to_json' ) ) {
1979
			// Enqueue the style here instead of printing it, because if some other plugin has run the_post()+rewind_posts(),
1980
			// (like VideoPress does), the style tag gets "printed" the first time and discarded, leaving the contact form unstyled.
1981
			// when WordPress does the real loop.
1982
			wp_enqueue_style( 'grunion.css' );
1983
		}
1984
1985
		$r  = '';
1986
		$r .= "<div id='contact-form-$id'>\n";
1987
1988
		if ( is_wp_error( $form->errors ) && $form->errors->get_error_codes() ) {
0 ignored issues
show
Bug introduced by
The method get_error_codes() does not seem to exist on object<WP_Error>.

This check looks for calls to methods that do not seem to exist on a given type. It looks for the method on the type itself as well as in inherited classes or implemented interfaces.

This is most likely a typographical error or the method has been renamed.

Loading history...
1989
			// There are errors.  Display them
1990
			$r .= "<div class='form-error'>\n<h3>" . __( 'Error!', 'jetpack' ) . "</h3>\n<ul class='form-errors'>\n";
1991
			foreach ( $form->errors->get_error_messages() as $message ) {
0 ignored issues
show
Bug introduced by
The method get_error_messages() does not seem to exist on object<WP_Error>.

This check looks for calls to methods that do not seem to exist on a given type. It looks for the method on the type itself as well as in inherited classes or implemented interfaces.

This is most likely a typographical error or the method has been renamed.

Loading history...
1992
				$r .= "\t<li class='form-error-message'>" . esc_html( $message ) . "</li>\n";
1993
			}
1994
			$r .= "</ul>\n</div>\n\n";
1995
		}
1996
1997
		if ( isset( $_GET['contact-form-id'] )
1998
		     && $_GET['contact-form-id'] == self::$last->get_attribute( 'id' )
1999
		     && isset( $_GET['contact-form-sent'], $_GET['contact-form-hash'] )
2000
		     && hash_equals( $form->hash, $_GET['contact-form-hash'] ) ) {
2001
			// The contact form was submitted.  Show the success message/results
2002
			$feedback_id = (int) $_GET['contact-form-sent'];
2003
2004
			$back_url = remove_query_arg( array( 'contact-form-id', 'contact-form-sent', '_wpnonce' ) );
2005
2006
			$r_success_message =
2007
				'<h3>' . __( 'Message Sent', 'jetpack' ) .
2008
				' (<a href="' . esc_url( $back_url ) . '">' . esc_html__( 'go back', 'jetpack' ) . '</a>)' .
2009
				"</h3>\n\n";
2010
2011
			// Don't show the feedback details unless the nonce matches
2012
			if ( $feedback_id && wp_verify_nonce( stripslashes( $_GET['_wpnonce'] ), "contact-form-sent-{$feedback_id}" ) ) {
2013
				$r_success_message .= self::success_message( $feedback_id, $form );
2014
			}
2015
2016
			/**
2017
			 * Filter the message returned after a successful contact form submission.
2018
			 *
2019
			 * @module contact-form
2020
			 *
2021
			 * @since 1.3.1
2022
			 *
2023
			 * @param string $r_success_message Success message.
2024
			 */
2025
			$r .= apply_filters( 'grunion_contact_form_success_message', $r_success_message );
2026
		} else {
2027
			// Nothing special - show the normal contact form
2028
			if ( $form->get_attribute( 'widget' ) ) {
2029
				// Submit form to the current URL
2030
				$url = remove_query_arg( array( 'contact-form-id', 'contact-form-sent', 'action', '_wpnonce' ) );
2031
			} else {
2032
				// Submit form to the post permalink
2033
				$url = get_permalink();
2034
			}
2035
2036
			// For SSL/TLS page. See RFC 3986 Section 4.2
2037
			$url = set_url_scheme( $url );
2038
2039
			// May eventually want to send this to admin-post.php...
2040
			/**
2041
			 * Filter the contact form action URL.
2042
			 *
2043
			 * @module contact-form
2044
			 *
2045
			 * @since 1.3.1
2046
			 *
2047
			 * @param string $contact_form_id Contact form post URL.
2048
			 * @param $post $GLOBALS['post'] Post global variable.
0 ignored issues
show
Documentation introduced by
The doc-type $post could not be parsed: Unknown type name "$post" at position 0. (view supported doc-types)

This check marks PHPDoc comments that could not be parsed by our parser. To see which comment annotations we can parse, please refer to our documentation on supported doc-types.

Loading history...
2049
			 * @param int $id Contact Form ID.
2050
			 */
2051
			$url = apply_filters( 'grunion_contact_form_form_action', "{$url}#contact-form-{$id}", $GLOBALS['post'], $id );
2052
2053
			$r .= "<form action='" . esc_url( $url ) . "' method='post' class='contact-form commentsblock'>\n";
2054
			$r .= $form->body;
2055
			$r .= "\t<p class='contact-submit'>\n";
2056
2057
			$gutenberg_submit_button_classes = '';
2058
			if ( ! empty( $attributes['submitButtonClasses'] ) ) {
2059
				$gutenberg_submit_button_classes = ' ' . $attributes['submitButtonClasses'];
2060
			}
2061
2062
			/**
2063
			 * Filter the contact form submit button class attribute.
2064
			 *
2065
			 * @module contact-form
2066
			 *
2067
			 * @since 6.6.0
2068
			 *
2069
			 * @param string $class Additional CSS classes for button attribute.
2070
			 */
2071
			$submit_button_class = apply_filters( 'jetpack_contact_form_submit_button_class', 'pushbutton-wide' . $gutenberg_submit_button_classes );
2072
2073
			$submit_button_styles = '';
2074
			if ( ! empty( $attributes['customBackgroundButtonColor'] ) ) {
2075
				$submit_button_styles .= 'background-color: ' . $attributes['customBackgroundButtonColor'] . '; ';
2076
			}
2077
			if ( ! empty( $attributes['customTextButtonColor'] ) ) {
2078
				$submit_button_styles .= 'color: ' . $attributes['customTextButtonColor'] . ';';
2079
			}
2080
			if ( ! empty( $attributes['submitButtonText'] ) ) {
2081
				$submit_button_text = $attributes['submitButtonText'];
2082
			} else {
2083
				$submit_button_text = $form->get_attribute( 'submit_button_text' );
2084
			}
2085
2086
			$r .= "\t\t<button type='submit' class='" . esc_attr( $submit_button_class ) . "'";
2087
			if ( ! empty( $submit_button_styles ) ) {
2088
				$r .= " style='" . esc_attr( $submit_button_styles ) . "'";
2089
			}
2090
			$r .= ">";
2091
			$r .= wp_kses(
2092
				      $submit_button_text,
2093
				      self::$allowed_html_tags_for_submit_button
2094
			      ) . "</button>";
2095
2096
			if ( is_user_logged_in() ) {
2097
				$r .= "\t\t" . wp_nonce_field( 'contact-form_' . $id, '_wpnonce', true, false ) . "\n"; // nonce and referer
2098
			}
2099
2100
			if ( isset( $attributes['hasFormSettingsSet'] ) && $attributes['hasFormSettingsSet'] ) {
2101
				$r .= "\t\t<input type='hidden' name='is_block' value='1' />\n";
2102
			}
2103
			$r .= "\t\t<input type='hidden' name='contact-form-id' value='$id' />\n";
2104
			$r .= "\t\t<input type='hidden' name='action' value='grunion-contact-form' />\n";
2105
			$r .= "\t\t<input type='hidden' name='contact-form-hash' value='" . esc_attr( $form->hash ) . "' />\n";
2106
			$r .= "\t</p>\n";
2107
			$r .= "</form>\n";
2108
		}
2109
2110
		$r .= '</div>';
2111
2112
		return $r;
2113
	}
2114
2115
	/**
2116
	 * Returns a success message to be returned if the form is sent via AJAX.
2117
	 *
2118
	 * @param int                         $feedback_id
2119
	 * @param object Grunion_Contact_Form $form
2120
	 *
2121
	 * @return string $message
2122
	 */
2123
	static function success_message( $feedback_id, $form ) {
2124
		if ( 'message' === $form->get_attribute( 'customThankyou' ) ) {
2125
			$message = wpautop( $form->get_attribute( 'customThankyouMessage' ) );
2126
		} else {
2127
			$message = '<blockquote class="contact-form-submission">'
2128
			. '<p>' . join( '</p><p>', self::get_compiled_form( $feedback_id, $form ) ) . '</p>'
2129
			. '</blockquote>';
2130
		}
2131
2132
		return wp_kses(
2133
			$message,
2134
			array(
2135
				'br'         => array(),
2136
				'blockquote' => array( 'class' => array() ),
2137
				'p'          => array(),
2138
			)
2139
		);
2140
	}
2141
2142
	/**
2143
	 * Returns a compiled form with labels and values in a form of  an array
2144
	 * of lines.
2145
	 *
2146
	 * @param int                         $feedback_id
2147
	 * @param object Grunion_Contact_Form $form
2148
	 *
2149
	 * @return array $lines
2150
	 */
2151
	static function get_compiled_form( $feedback_id, $form ) {
2152
		$feedback       = get_post( $feedback_id );
2153
		$field_ids      = $form->get_field_ids();
2154
		$content_fields = Grunion_Contact_Form_Plugin::parse_fields_from_content( $feedback_id );
2155
2156
		// Maps field_ids to post_meta keys
2157
		$field_value_map = array(
2158
			'name'     => 'author',
2159
			'email'    => 'author_email',
2160
			'url'      => 'author_url',
2161
			'subject'  => 'subject',
2162
			'textarea' => false, // not a post_meta key.  This is stored in post_content
2163
		);
2164
2165
		$compiled_form = array();
2166
2167
		// "Standard" field whitelist
2168
		foreach ( $field_value_map as $type => $meta_key ) {
2169
			if ( isset( $field_ids[ $type ] ) ) {
2170
				$field = $form->fields[ $field_ids[ $type ] ];
2171
2172
				if ( $meta_key ) {
0 ignored issues
show
Bug Best Practice introduced by
The expression $meta_key of type string|false is loosely compared to true; this is ambiguous if the string can be empty. You might want to explicitly use !== false instead.

In PHP, under loose comparison (like ==, or !=, or switch conditions), values of different types might be equal.

For string values, the empty string '' is a special case, in particular the following results might be unexpected:

''   == false // true
''   == null  // true
'ab' == false // false
'ab' == null  // false

// It is often better to use strict comparison
'' === false // false
'' === null  // false
Loading history...
2173
					if ( isset( $content_fields[ "_feedback_{$meta_key}" ] ) ) {
2174
						$value = $content_fields[ "_feedback_{$meta_key}" ];
2175
					}
2176
				} else {
2177
					// The feedback content is stored as the first "half" of post_content
2178
					$value         = $feedback->post_content;
2179
					list( $value ) = explode( '<!--more-->', $value );
2180
					$value         = trim( $value );
2181
				}
2182
2183
				$field_index                   = array_search( $field_ids[ $type ], $field_ids['all'] );
2184
				$compiled_form[ $field_index ] = sprintf(
2185
					'<b>%1$s:</b> %2$s<br /><br />',
2186
					wp_kses( $field->get_attribute( 'label' ), array() ),
2187
					self::escape_and_sanitize_field_value( $value )
0 ignored issues
show
Bug introduced by
The variable $value does not seem to be defined for all execution paths leading up to this point.

If you define a variable conditionally, it can happen that it is not defined for all execution paths.

Let’s take a look at an example:

function myFunction($a) {
    switch ($a) {
        case 'foo':
            $x = 1;
            break;

        case 'bar':
            $x = 2;
            break;
    }

    // $x is potentially undefined here.
    echo $x;
}

In the above example, the variable $x is defined if you pass “foo” or “bar” as argument for $a. However, since the switch statement has no default case statement, if you pass any other value, the variable $x would be undefined.

Available Fixes

  1. Check for existence of the variable explicitly:

    function myFunction($a) {
        switch ($a) {
            case 'foo':
                $x = 1;
                break;
    
            case 'bar':
                $x = 2;
                break;
        }
    
        if (isset($x)) { // Make sure it's always set.
            echo $x;
        }
    }
    
  2. Define a default value for the variable:

    function myFunction($a) {
        $x = ''; // Set a default which gets overridden for certain paths.
        switch ($a) {
            case 'foo':
                $x = 1;
                break;
    
            case 'bar':
                $x = 2;
                break;
        }
    
        echo $x;
    }
    
  3. Add a value for the missing path:

    function myFunction($a) {
        switch ($a) {
            case 'foo':
                $x = 1;
                break;
    
            case 'bar':
                $x = 2;
                break;
    
            // We add support for the missing case.
            default:
                $x = '';
                break;
        }
    
        echo $x;
    }
    
Loading history...
2188
				);
2189
			}
2190
		}
2191
2192
		// "Non-standard" fields
2193
		if ( $field_ids['extra'] ) {
2194
			// array indexed by field label (not field id)
2195
			$extra_fields = get_post_meta( $feedback_id, '_feedback_extra_fields', true );
2196
2197
			/**
2198
			 * Only get data for the compiled form if `$extra_fields` is a valid and non-empty array.
2199
			 */
2200
			if ( is_array( $extra_fields ) && ! empty( $extra_fields ) ) {
2201
2202
				$extra_field_keys = array_keys( $extra_fields );
2203
2204
				$i = 0;
2205
				foreach ( $field_ids['extra'] as $field_id ) {
2206
					$field       = $form->fields[ $field_id ];
2207
					$field_index = array_search( $field_id, $field_ids['all'] );
2208
2209
					$label = $field->get_attribute( 'label' );
2210
2211
					$compiled_form[ $field_index ] = sprintf(
2212
						'<b>%1$s:</b> %2$s<br /><br />',
2213
						wp_kses( $label, array() ),
2214
						self::escape_and_sanitize_field_value( $extra_fields[ $extra_field_keys[ $i ] ] )
2215
					);
2216
2217
					$i++;
2218
				}
2219
			}
2220
		}
2221
2222
		// Sorting lines by the field index
2223
		ksort( $compiled_form );
2224
2225
		return $compiled_form;
2226
	}
2227
2228
	static function escape_and_sanitize_field_value( $value ) {
2229
        $value = str_replace( array( '[' , ']' ) ,  array( '&#91;' , '&#93;' ) , $value );
2230
        return nl2br( wp_kses( $value, array() ) );
2231
    }
2232
2233
	/**
2234
	 * Only strip out empty string values and keep all the other values as they are.
2235
     *
2236
	 * @param $single_value
2237
	 *
2238
	 * @return bool
2239
	 */
2240
	static function remove_empty( $single_value ) {
2241
		return ( $single_value !== '' );
2242
	}
2243
2244
	/**
2245
	 * The contact-field shortcode processor
2246
	 * We use an object method here instead of a static Grunion_Contact_Form_Field class method to parse contact-field shortcodes so that we can tie them to the contact-form object.
2247
	 *
2248
	 * @param array       $attributes Key => Value pairs as parsed by shortcode_parse_atts()
2249
	 * @param string|null $content The shortcode's inner content: [contact-field]$content[/contact-field]
2250
	 * @return HTML for the contact form field
2251
	 */
2252
	static function parse_contact_field( $attributes, $content ) {
2253
		// Don't try to parse contact form fields if not inside a contact form
2254
		if ( ! Grunion_Contact_Form_Plugin::$using_contact_form_field ) {
0 ignored issues
show
Bug introduced by
The property using_contact_form_field cannot be accessed from this context as it is declared private in class Grunion_Contact_Form_Plugin.

This check looks for access to properties that are not accessible from the current context.

If you need to make a property accessible to another context you can either raise its visibility level or provide an accessible getter in the defining class.

Loading history...
2255
			$att_strs = array();
2256
			if ( ! isset( $attributes['label'] )  ) {
2257
				$type = isset( $attributes['type'] ) ? $attributes['type'] : null;
2258
				$attributes['label'] = self::get_default_label_from_type( $type );
2259
			}
2260
			foreach ( $attributes as $att => $val ) {
2261
				if ( is_numeric( $att ) ) { // Is a valueless attribute
2262
					$att_strs[] = esc_html( $val );
2263
				} elseif ( isset( $val ) ) { // A regular attr - value pair
2264
					if ( ( $att === 'options' || $att === 'values' ) && is_string( $val ) ) { // remove any empty strings
2265
						$val = explode( ',', $val );
2266
					}
2267
 					if ( is_array( $val ) ) {
2268
						$val =  array_filter( $val, array( __CLASS__, 'remove_empty' ) ); // removes any empty strings
2269
						$att_strs[] = esc_html( $att ) . '="' . implode( ',', array_map( 'esc_html', $val ) ) . '"';
2270
					} elseif ( is_bool( $val ) ) {
2271
						$att_strs[] = esc_html( $att ) . '="' . esc_html( $val ? '1' : '' ) . '"';
2272
					} else {
2273
						$att_strs[] = esc_html( $att ) . '="' . esc_html( $val ) . '"';
2274
					}
2275
				}
2276
			}
2277
2278
			$html = '[contact-field ' . implode( ' ', $att_strs );
2279
2280
			if ( isset( $content ) && ! empty( $content ) ) { // If there is content, let's add a closing tag
2281
				$html .= ']' . esc_html( $content ) . '[/contact-field]';
2282
			} else { // Otherwise let's add a closing slash in the first tag
2283
				$html .= '/]';
2284
			}
2285
2286
			return $html;
2287
		}
2288
2289
		$form = Grunion_Contact_Form::$current_form;
2290
2291
		$field = new Grunion_Contact_Form_Field( $attributes, $content, $form );
2292
2293
		$field_id = $field->get_attribute( 'id' );
2294
		if ( $field_id ) {
2295
			$form->fields[ $field_id ] = $field;
2296
		} else {
2297
			$form->fields[] = $field;
2298
		}
2299
2300
		if (
2301
			isset( $_POST['action'] ) && 'grunion-contact-form' === $_POST['action']
2302
			&&
2303
			isset( $_POST['contact-form-id'] ) && $form->get_attribute( 'id' ) == $_POST['contact-form-id']
2304
			&&
2305
			isset( $_POST['contact-form-hash'] ) && hash_equals( $form->hash, $_POST['contact-form-hash'] )
2306
		) {
2307
			// If we're processing a POST submission for this contact form, validate the field value so we can show errors as necessary.
2308
			$field->validate();
2309
		}
2310
2311
		// Output HTML
2312
		return $field->render();
2313
	}
2314
2315
	static function get_default_label_from_type( $type ) {
2316
		switch ( $type ) {
2317
			case 'text':
2318
				return __( 'Text', 'jetpack' );
2319
			case 'name':
2320
				return __( 'Name', 'jetpack' );
2321
			case 'email':
2322
				return __( 'Email', 'jetpack' );
2323
			case 'url':
2324
				return __( 'Website', 'jetpack' );
2325
			case 'date':
2326
				return __( 'Date', 'jetpack' );
2327
			case 'telephone':
2328
				return __( 'Phone', 'jetpack' );
2329
			case 'textarea':
2330
				return __( 'Message', 'jetpack' );
2331
			case 'checkbox':
2332
				return __( 'Checkbox', 'jetpack' );
2333
			case 'checkbox-multiple':
2334
				return __( 'Choose several', 'jetpack' );
2335
			case 'radio':
2336
				return __( 'Choose one', 'jetpack' );
2337
			case 'select':
2338
				return __( 'Select one', 'jetpack' );
2339
			default:
2340
				return null;
2341
		}
2342
	}
2343
2344
	/**
2345
	 * Loops through $this->fields to generate a (structured) list of field IDs.
2346
	 *
2347
	 * Important: Currently the whitelisted fields are defined as follows:
2348
	 *  `name`, `email`, `url`, `subject`, `textarea`
2349
	 *
2350
	 * If you need to add new fields to the Contact Form, please don't add them
2351
	 * to the whitelisted fields and leave them as extra fields.
2352
	 *
2353
	 * The reasoning behind this is that both the admin Feedback view and the CSV
2354
	 * export will not include any fields that are added to the list of
2355
	 * whitelisted fields without taking proper care to add them to all the
2356
	 * other places where they accessed/used/saved.
2357
	 *
2358
	 * The safest way to add new fields is to add them to the dropdown and the
2359
	 * HTML list ( @see Grunion_Contact_Form_Field::render ) and don't add them
2360
	 * to the list of whitelisted fields. This way they will become a part of the
2361
	 * `extra fields` which are saved in the post meta and will be properly
2362
	 * handled by the admin Feedback view and the CSV Export without any extra
2363
	 * work.
2364
	 *
2365
	 * If there is need to add a field to the whitelisted fields, then please
2366
	 * take proper care to add logic to handle the field in the following places:
2367
	 *
2368
	 *  - Below in the switch statement - so the field is recognized as whitelisted.
2369
	 *
2370
	 *  - Grunion_Contact_Form::process_submission - validation and logic.
2371
	 *
2372
	 *  - Grunion_Contact_Form::process_submission - add the field as an additional
2373
	 *      field in the `post_content` when saving the feedback content.
2374
	 *
2375
	 *  - Grunion_Contact_Form_Plugin::parse_fields_from_content - add mapping
2376
	 *      for the field, defined in the above method.
2377
	 *
2378
	 *  - Grunion_Contact_Form_Plugin::map_parsed_field_contents_of_post_to_field_names -
2379
	 *      add mapping of the field for the CSV Export. Otherwise it will be missing
2380
	 *      from the exported data.
2381
	 *
2382
	 *  - admin.php / grunion_manage_post_columns - add the field to the render logic.
2383
	 *      Otherwise it will be missing from the admin Feedback view.
2384
	 *
2385
	 * @return array
2386
	 */
2387
	function get_field_ids() {
2388
		$field_ids = array(
2389
			'all'   => array(), // array of all field_ids
2390
			'extra' => array(), // array of all non-whitelisted field IDs
2391
2392
			// Whitelisted "standard" field IDs:
2393
			// 'email'    => field_id,
2394
			// 'name'     => field_id,
2395
			// 'url'      => field_id,
2396
			// 'subject'  => field_id,
2397
			// 'textarea' => field_id,
2398
		);
2399
2400
		foreach ( $this->fields as $id => $field ) {
2401
			$field_ids['all'][] = $id;
2402
2403
			$type = $field->get_attribute( 'type' );
2404
			if ( isset( $field_ids[ $type ] ) ) {
2405
				// This type of field is already present in our whitelist of "standard" fields for this form
2406
				// Put it in extra
2407
				$field_ids['extra'][] = $id;
2408
				continue;
2409
			}
2410
2411
			/**
2412
			 * See method description before modifying the switch cases.
2413
			 */
2414
			switch ( $type ) {
2415
				case 'email':
2416
				case 'name':
2417
				case 'url':
2418
				case 'subject':
2419
				case 'textarea':
2420
					$field_ids[ $type ] = $id;
2421
					break;
2422
				default:
2423
					// Put everything else in extra
2424
					$field_ids['extra'][] = $id;
2425
			}
2426
		}
2427
2428
		return $field_ids;
2429
	}
2430
2431
	/**
2432
	 * Process the contact form's POST submission
2433
	 * Stores feedback.  Sends email.
2434
	 */
2435
	function process_submission() {
2436
		global $post;
2437
2438
		$plugin = Grunion_Contact_Form_Plugin::init();
2439
2440
		$id     = $this->get_attribute( 'id' );
2441
		$to     = $this->get_attribute( 'to' );
2442
		$widget = $this->get_attribute( 'widget' );
2443
2444
		$contact_form_subject = $this->get_attribute( 'subject' );
2445
2446
		$to     = str_replace( ' ', '', $to );
2447
		$emails = explode( ',', $to );
2448
2449
		$valid_emails = array();
2450
2451
		foreach ( (array) $emails as $email ) {
2452
			if ( ! is_email( $email ) ) {
2453
				continue;
2454
			}
2455
2456
			if ( function_exists( 'is_email_address_unsafe' ) && is_email_address_unsafe( $email ) ) {
2457
				continue;
2458
			}
2459
2460
			$valid_emails[] = $email;
2461
		}
2462
2463
		// No one to send it to, which means none of the "to" attributes are valid emails.
2464
		// Use default email instead.
2465
		if ( ! $valid_emails ) {
0 ignored issues
show
Bug Best Practice introduced by
The expression $valid_emails of type array is implicitly converted to a boolean; are you sure this is intended? If so, consider using empty($expr) instead to make it clear that you intend to check for an array without elements.

This check marks implicit conversions of arrays to boolean values in a comparison. While in PHP an empty array is considered to be equal (but not identical) to false, this is not always apparent.

Consider making the comparison explicit by using empty(..) or ! empty(...) instead.

Loading history...
2466
			$valid_emails = $this->defaults['to'];
2467
		}
2468
2469
		$to = $valid_emails;
2470
2471
		// Last ditch effort to set a recipient if somehow none have been set.
2472
		if ( empty( $to ) ) {
2473
			$to = get_option( 'admin_email' );
2474
		}
2475
2476
		// Make sure we're processing the form we think we're processing... probably a redundant check.
2477
		if ( $widget ) {
2478
			if ( 'widget-' . $widget != $_POST['contact-form-id'] ) {
2479
				return false;
2480
			}
2481
		} else {
2482
			if ( $post->ID != $_POST['contact-form-id'] ) {
2483
				return false;
2484
			}
2485
		}
2486
2487
		$field_ids = $this->get_field_ids();
2488
2489
		// Initialize all these "standard" fields to null
2490
		$comment_author_email = $comment_author_email_label = // v
2491
		$comment_author       = $comment_author_label       = // v
2492
		$comment_author_url   = $comment_author_url_label   = // v
2493
		$comment_content      = $comment_content_label = null;
2494
2495
		// For each of the "standard" fields, grab their field label and value.
2496 View Code Duplication
		if ( isset( $field_ids['name'] ) ) {
2497
			$field          = $this->fields[ $field_ids['name'] ];
2498
			$comment_author = Grunion_Contact_Form_Plugin::strip_tags(
2499
				stripslashes(
2500
					/** This filter is already documented in core/wp-includes/comment-functions.php */
2501
					apply_filters( 'pre_comment_author_name', addslashes( $field->value ) )
2502
				)
2503
			);
2504
			$comment_author_label = Grunion_Contact_Form_Plugin::strip_tags( $field->get_attribute( 'label' ) );
2505
		}
2506
2507 View Code Duplication
		if ( isset( $field_ids['email'] ) ) {
2508
			$field                = $this->fields[ $field_ids['email'] ];
2509
			$comment_author_email = Grunion_Contact_Form_Plugin::strip_tags(
2510
				stripslashes(
2511
					/** This filter is already documented in core/wp-includes/comment-functions.php */
2512
					apply_filters( 'pre_comment_author_email', addslashes( $field->value ) )
2513
				)
2514
			);
2515
			$comment_author_email_label = Grunion_Contact_Form_Plugin::strip_tags( $field->get_attribute( 'label' ) );
2516
		}
2517
2518
		if ( isset( $field_ids['url'] ) ) {
2519
			$field              = $this->fields[ $field_ids['url'] ];
2520
			$comment_author_url = Grunion_Contact_Form_Plugin::strip_tags(
2521
				stripslashes(
2522
					/** This filter is already documented in core/wp-includes/comment-functions.php */
2523
					apply_filters( 'pre_comment_author_url', addslashes( $field->value ) )
2524
				)
2525
			);
2526
			if ( 'http://' == $comment_author_url ) {
2527
				$comment_author_url = '';
2528
			}
2529
			$comment_author_url_label = Grunion_Contact_Form_Plugin::strip_tags( $field->get_attribute( 'label' ) );
2530
		}
2531
2532
		if ( isset( $field_ids['textarea'] ) ) {
2533
			$field                 = $this->fields[ $field_ids['textarea'] ];
2534
			$comment_content       = trim( Grunion_Contact_Form_Plugin::strip_tags( $field->value ) );
2535
			$comment_content_label = Grunion_Contact_Form_Plugin::strip_tags( $field->get_attribute( 'label' ) );
2536
		}
2537
2538
		if ( isset( $field_ids['subject'] ) ) {
2539
			$field = $this->fields[ $field_ids['subject'] ];
2540
			if ( $field->value ) {
2541
				$contact_form_subject = Grunion_Contact_Form_Plugin::strip_tags( $field->value );
2542
			}
2543
		}
2544
2545
		$all_values = $extra_values = array();
2546
		$i          = 1; // Prefix counter for stored metadata
2547
2548
		// For all fields, grab label and value
2549
		foreach ( $field_ids['all'] as $field_id ) {
2550
			$field = $this->fields[ $field_id ];
2551
			$label = $i . '_' . $field->get_attribute( 'label' );
2552
			$value = $field->value;
2553
2554
			$all_values[ $label ] = $value;
2555
			$i++; // Increment prefix counter for the next field
2556
		}
2557
2558
		// For the "non-standard" fields, grab label and value
2559
		// Extra fields have their prefix starting from count( $all_values ) + 1
2560
		foreach ( $field_ids['extra'] as $field_id ) {
2561
			$field = $this->fields[ $field_id ];
2562
			$label = $i . '_' . $field->get_attribute( 'label' );
2563
			$value = $field->value;
2564
2565
			if ( is_array( $value ) ) {
2566
				$value = implode( ', ', $value );
2567
			}
2568
2569
			$extra_values[ $label ] = $value;
2570
			$i++; // Increment prefix counter for the next extra field
2571
		}
2572
2573
		if ( isset( $_REQUEST['is_block'] ) && $_REQUEST['is_block'] ) {
2574
			$extra_values['is_block'] = true;
2575
		}
2576
2577
		$contact_form_subject = trim( $contact_form_subject );
2578
2579
		$comment_author_IP = Grunion_Contact_Form_Plugin::get_ip_address();
2580
2581
		$vars = array( 'comment_author', 'comment_author_email', 'comment_author_url', 'contact_form_subject', 'comment_author_IP' );
2582
		foreach ( $vars as $var ) {
2583
			$$var = str_replace( array( "\n", "\r" ), '', $$var );
2584
		}
2585
2586
		// Ensure that Akismet gets all of the relevant information from the contact form,
2587
		// not just the textarea field and predetermined subject.
2588
		$akismet_vars                    = compact( $vars );
2589
		$akismet_vars['comment_content'] = $comment_content;
2590
2591
		foreach ( array_merge( $field_ids['all'], $field_ids['extra'] ) as $field_id ) {
2592
			$field = $this->fields[ $field_id ];
2593
2594
			// Skip any fields that are just a choice from a pre-defined list. They wouldn't have any value
2595
			// from a spam-filtering point of view.
2596
			if ( in_array( $field->get_attribute( 'type' ), array( 'select', 'checkbox', 'checkbox-multiple', 'radio' ) ) ) {
2597
				continue;
2598
			}
2599
2600
			// Normalize the label into a slug.
2601
			$field_slug = trim( // Strip all leading/trailing dashes.
2602
				preg_replace(   // Normalize everything to a-z0-9_-
2603
					'/[^a-z0-9_]+/',
2604
					'-',
2605
					strtolower( $field->get_attribute( 'label' ) ) // Lowercase
2606
				),
2607
				'-'
2608
			);
2609
2610
			$field_value = ( is_array( $field->value ) ) ? trim( implode( ', ', $field->value ) ) : trim( $field->value );
2611
2612
			// Skip any values that are already in the array we're sending.
2613
			if ( $field_value && in_array( $field_value, $akismet_vars ) ) {
2614
				continue;
2615
			}
2616
2617
			$akismet_vars[ 'contact_form_field_' . $field_slug ] = $field_value;
2618
		}
2619
2620
		$spam           = '';
2621
		$akismet_values = $plugin->prepare_for_akismet( $akismet_vars );
2622
2623
		// Is it spam?
2624
		/** This filter is already documented in modules/contact-form/admin.php */
2625
		$is_spam = apply_filters( 'jetpack_contact_form_is_spam', false, $akismet_values );
2626
		if ( is_wp_error( $is_spam ) ) { // WP_Error to abort
2627
			return $is_spam; // abort
2628
		} elseif ( $is_spam === true ) {  // TRUE to flag a spam
2629
			$spam = '***SPAM*** ';
2630
		}
2631
2632
		if ( ! $comment_author ) {
2633
			$comment_author = $comment_author_email;
2634
		}
2635
2636
		/**
2637
		 * Filter the email where a submitted feedback is sent.
2638
		 *
2639
		 * @module contact-form
2640
		 *
2641
		 * @since 1.3.1
2642
		 *
2643
		 * @param string|array $to Array of valid email addresses, or single email address.
2644
		 */
2645
		$to            = (array) apply_filters( 'contact_form_to', $to );
2646
		$reply_to_addr = $to[0]; // get just the address part before the name part is added
2647
2648
		foreach ( $to as $to_key => $to_value ) {
2649
			$to[ $to_key ] = Grunion_Contact_Form_Plugin::strip_tags( $to_value );
2650
			$to[ $to_key ] = self::add_name_to_address( $to_value );
2651
		}
2652
2653
		$blog_url        = wp_parse_url( site_url() );
2654
		$from_email_addr = 'wordpress@' . $blog_url['host'];
2655
2656
		if ( ! empty( $comment_author_email ) ) {
2657
			$reply_to_addr = $comment_author_email;
2658
		}
2659
2660
		$headers = 'From: "' . $comment_author . '" <' . $from_email_addr . ">\r\n" .
2661
		           'Reply-To: "' . $comment_author . '" <' . $reply_to_addr . ">\r\n";
2662
2663
		// Build feedback reference
2664
		$feedback_time  = current_time( 'mysql' );
2665
		$feedback_title = "{$comment_author} - {$feedback_time}";
2666
		$feedback_id    = md5( $feedback_title );
2667
2668
		$all_values = array_merge(
2669
			$all_values, array(
2670
				'entry_title'     => the_title_attribute( 'echo=0' ),
2671
				'entry_permalink' => esc_url( get_permalink( get_the_ID() ) ),
2672
				'feedback_id'     => $feedback_id,
2673
			)
2674
		);
2675
2676
		/** This filter is already documented in modules/contact-form/admin.php */
2677
		$subject = apply_filters( 'contact_form_subject', $contact_form_subject, $all_values );
2678
		$url     = $widget ? home_url( '/' ) : get_permalink( $post->ID );
2679
2680
		$date_time_format = _x( '%1$s \a\t %2$s', '{$date_format} \a\t {$time_format}', 'jetpack' );
2681
		$date_time_format = sprintf( $date_time_format, get_option( 'date_format' ), get_option( 'time_format' ) );
2682
		$time             = date_i18n( $date_time_format, current_time( 'timestamp' ) );
2683
2684
		// keep a copy of the feedback as a custom post type
2685
		$feedback_status = $is_spam === true ? 'spam' : 'publish';
2686
2687
		foreach ( (array) $akismet_values as $av_key => $av_value ) {
2688
			$akismet_values[ $av_key ] = Grunion_Contact_Form_Plugin::strip_tags( $av_value );
2689
		}
2690
2691
		foreach ( (array) $all_values as $all_key => $all_value ) {
2692
			$all_values[ $all_key ] = Grunion_Contact_Form_Plugin::strip_tags( $all_value );
2693
		}
2694
2695
		foreach ( (array) $extra_values as $ev_key => $ev_value ) {
2696
			$extra_values[ $ev_key ] = Grunion_Contact_Form_Plugin::strip_tags( $ev_value );
2697
		}
2698
2699
		/*
2700
		 We need to make sure that the post author is always zero for contact
2701
		 * form submissions.  This prevents export/import from trying to create
2702
		 * new users based on form submissions from people who were logged in
2703
		 * at the time.
2704
		 *
2705
		 * Unfortunately wp_insert_post() tries very hard to make sure the post
2706
		 * author gets the currently logged in user id.  That is how we ended up
2707
		 * with this work around. */
2708
		add_filter( 'wp_insert_post_data', array( $plugin, 'insert_feedback_filter' ), 10, 2 );
2709
2710
		$post_id = wp_insert_post(
2711
			array(
2712
				'post_date'    => addslashes( $feedback_time ),
2713
				'post_type'    => 'feedback',
2714
				'post_status'  => addslashes( $feedback_status ),
2715
				'post_parent'  => (int) $post->ID,
2716
				'post_title'   => addslashes( wp_kses( $feedback_title, array() ) ),
2717
				'post_content' => addslashes( wp_kses( $comment_content . "\n<!--more-->\n" . "AUTHOR: {$comment_author}\nAUTHOR EMAIL: {$comment_author_email}\nAUTHOR URL: {$comment_author_url}\nSUBJECT: {$subject}\nIP: {$comment_author_IP}\n" . @print_r( $all_values, true ), array() ) ), // so that search will pick up this data
2718
				'post_name'    => $feedback_id,
2719
			)
2720
		);
2721
2722
		// once insert has finished we don't need this filter any more
2723
		remove_filter( 'wp_insert_post_data', array( $plugin, 'insert_feedback_filter' ), 10 );
2724
2725
		update_post_meta( $post_id, '_feedback_extra_fields', $this->addslashes_deep( $extra_values ) );
2726
2727
		if ( 'publish' == $feedback_status ) {
2728
			// Increase count of unread feedback.
2729
			$unread = get_option( 'feedback_unread_count', 0 ) + 1;
2730
			update_option( 'feedback_unread_count', $unread );
2731
		}
2732
2733
		if ( defined( 'AKISMET_VERSION' ) ) {
2734
			update_post_meta( $post_id, '_feedback_akismet_values', $this->addslashes_deep( $akismet_values ) );
2735
		}
2736
2737
		$message = self::get_compiled_form( $post_id, $this );
2738
2739
		array_push(
2740
			$message,
2741
			'<br />',
2742
			'<hr />',
2743
			__( 'Time:', 'jetpack' ) . ' ' . $time . '<br />',
2744
			__( 'IP Address:', 'jetpack' ) . ' ' . $comment_author_IP . '<br />',
2745
			__( 'Contact Form URL:', 'jetpack' ) . ' ' . $url . '<br />'
2746
		);
2747
2748
		if ( is_user_logged_in() ) {
2749
			array_push(
2750
				$message,
2751
				sprintf(
2752
					'<p>' . __( 'Sent by a verified %s user.', 'jetpack' ) . '</p>',
2753
					isset( $GLOBALS['current_site']->site_name ) && $GLOBALS['current_site']->site_name ?
2754
						$GLOBALS['current_site']->site_name : '"' . get_option( 'blogname' ) . '"'
2755
				)
2756
			);
2757
		} else {
2758
			array_push( $message, '<p>' . __( 'Sent by an unverified visitor to your site.', 'jetpack' ) . '</p>' );
2759
		}
2760
2761
		$message = join( '', $message );
2762
2763
		/**
2764
		 * Filters the message sent via email after a successful form submission.
2765
		 *
2766
		 * @module contact-form
2767
		 *
2768
		 * @since 1.3.1
2769
		 *
2770
		 * @param string $message Feedback email message.
2771
		 */
2772
		$message = apply_filters( 'contact_form_message', $message );
2773
2774
		// This is called after `contact_form_message`, in order to preserve back-compat
2775
		$message = self::wrap_message_in_html_tags( $message );
2776
2777
		update_post_meta( $post_id, '_feedback_email', $this->addslashes_deep( compact( 'to', 'message' ) ) );
2778
2779
		/**
2780
		 * Fires right before the contact form message is sent via email to
2781
		 * the recipient specified in the contact form.
2782
		 *
2783
		 * @module contact-form
2784
		 *
2785
		 * @since 1.3.1
2786
		 *
2787
		 * @param integer $post_id Post contact form lives on
2788
		 * @param array $all_values Contact form fields
2789
		 * @param array $extra_values Contact form fields not included in $all_values
2790
		 */
2791
		do_action( 'grunion_pre_message_sent', $post_id, $all_values, $extra_values, $this );
2792
2793
		// schedule deletes of old spam feedbacks
2794
		if ( ! wp_next_scheduled( 'grunion_scheduled_delete' ) ) {
2795
			wp_schedule_event( time() + 250, 'daily', 'grunion_scheduled_delete' );
2796
		}
2797
2798
		if (
2799
			$is_spam !== true &&
2800
			/**
2801
			 * Filter to choose whether an email should be sent after each successful contact form submission.
2802
			 *
2803
			 * @module contact-form
2804
			 *
2805
			 * @since 2.6.0
2806
			 *
2807
			 * @param bool true Should an email be sent after a form submission. Default to true.
2808
			 * @param int $post_id Post ID.
2809
			 */
2810
			true === apply_filters( 'grunion_should_send_email', true, $post_id )
2811
		) {
2812
			self::wp_mail( $to, "{$spam}{$subject}", $message, $headers );
2813
		} elseif (
2814
			true === $is_spam &&
2815
			/**
2816
			 * Choose whether an email should be sent for each spam contact form submission.
2817
			 *
2818
			 * @module contact-form
2819
			 *
2820
			 * @since 1.3.1
2821
			 *
2822
			 * @param bool false Should an email be sent after a spam form submission. Default to false.
2823
			 */
2824
			apply_filters( 'grunion_still_email_spam', false ) == true
2825
		) { // don't send spam by default.  Filterable.
2826
			self::wp_mail( $to, "{$spam}{$subject}", $message, $headers );
2827
		}
2828
2829
		/**
2830
		 * Fires an action hook right after the email(s) have been sent.
2831
		 *
2832
		 * @module contact-form
2833
		 *
2834
		 * @since 7.3.0
2835
		 *
2836
		 * @param int $post_id Post contact form lives on.
2837
		 * @param string|array $to Array of valid email addresses, or single email address.
2838
		 * @param string $subject Feedback email subject.
2839
		 * @param string $message Feedback email message.
2840
		 * @param string|array $headers Optional. Additional headers.
2841
		 * @param array $all_values Contact form fields.
2842
		 * @param array $extra_values Contact form fields not included in $all_values
2843
		 */
2844
		do_action( 'grunion_after_message_sent', $post_id, $to, $subject, $message, $headers, $all_values, $extra_values );
2845
2846
		if ( defined( 'DOING_AJAX' ) && DOING_AJAX ) {
2847
			return self::success_message( $post_id, $this );
2848
		}
2849
2850
		$redirect = '';
2851
		$custom_redirect = false;
2852
		if ( 'redirect' === $this->get_attribute( 'customThankyou' ) ) {
2853
			$custom_redirect = true;
2854
			$redirect        = esc_url( $this->get_attribute( 'customThankyouRedirect' ) );
2855
		}
2856
2857
		if ( ! $redirect ) {
2858
			$custom_redirect = false;
2859
			$redirect        = wp_get_referer();
2860
		}
2861
2862
		if ( ! $redirect ) { // wp_get_referer() returns false if the referer is the same as the current page.
2863
			$custom_redirect = false;
2864
			$redirect        = $_SERVER['REQUEST_URI'];
2865
		}
2866
2867
		if ( ! $custom_redirect ) {
2868
			$redirect = add_query_arg(
2869
				urlencode_deep(
2870
					array(
2871
						'contact-form-id'   => $id,
2872
						'contact-form-sent' => $post_id,
2873
						'contact-form-hash' => $this->hash,
2874
						'_wpnonce'          => wp_create_nonce( "contact-form-sent-{$post_id}" ), // wp_nonce_url HTMLencodes :( .
2875
					)
2876
				),
2877
				$redirect
2878
			);
2879
		}
2880
2881
		/**
2882
		 * Filter the URL where the reader is redirected after submitting a form.
2883
		 *
2884
		 * @module contact-form
2885
		 *
2886
		 * @since 1.9.0
2887
		 *
2888
		 * @param string $redirect Post submission URL.
2889
		 * @param int $id Contact Form ID.
2890
		 * @param int $post_id Post ID.
2891
		 */
2892
		$redirect = apply_filters( 'grunion_contact_form_redirect_url', $redirect, $id, $post_id );
2893
2894
		// phpcs:ignore WordPress.Security.SafeRedirect.wp_redirect_wp_redirect -- We intentially allow external redirects here.
2895
		wp_redirect( $redirect );
2896
		exit;
2897
	}
2898
2899
	/**
2900
	 * Wrapper for wp_mail() that enables HTML messages with text alternatives
2901
	 *
2902
	 * @param string|array $to          Array or comma-separated list of email addresses to send message.
2903
	 * @param string       $subject     Email subject.
2904
	 * @param string       $message     Message contents.
2905
	 * @param string|array $headers     Optional. Additional headers.
2906
	 * @param string|array $attachments Optional. Files to attach.
2907
	 *
2908
	 * @return bool Whether the email contents were sent successfully.
2909
	 */
2910
	public static function wp_mail( $to, $subject, $message, $headers = '', $attachments = array() ) {
2911
		add_filter( 'wp_mail_content_type', __CLASS__ . '::get_mail_content_type' );
2912
		add_action( 'phpmailer_init', __CLASS__ . '::add_plain_text_alternative' );
2913
2914
		$result = wp_mail( $to, $subject, $message, $headers, $attachments );
2915
2916
		remove_filter( 'wp_mail_content_type', __CLASS__ . '::get_mail_content_type' );
2917
		remove_action( 'phpmailer_init', __CLASS__ . '::add_plain_text_alternative' );
2918
2919
		return $result;
2920
	}
2921
2922
	/**
2923
	 * Add a display name part to an email address
2924
	 *
2925
	 * SpamAssassin doesn't like addresses in HTML messages that are missing display names (e.g., `[email protected]`
2926
	 * instead of `"Foo Bar" <[email protected]>`.
2927
	 *
2928
	 * @param string $address
2929
	 *
2930
	 * @return string
2931
	 */
2932
	function add_name_to_address( $address ) {
2933
		// If it's just the address, without a display name
2934
		if ( is_email( $address ) ) {
2935
			$address_parts = explode( '@', $address );
2936
			$address       = sprintf( '"%s" <%s>', $address_parts[0], $address );
2937
		}
2938
2939
		return $address;
2940
	}
2941
2942
	/**
2943
	 * Get the content type that should be assigned to outbound emails
2944
	 *
2945
	 * @return string
2946
	 */
2947
	static function get_mail_content_type() {
2948
		return 'text/html';
2949
	}
2950
2951
	/**
2952
	 * Wrap a message body with the appropriate in HTML tags
2953
	 *
2954
	 * This helps to ensure correct parsing by clients, and also helps avoid triggering spam filtering rules
2955
	 *
2956
	 * @param string $body
2957
	 *
2958
	 * @return string
2959
	 */
2960
	static function wrap_message_in_html_tags( $body ) {
2961
		// Don't do anything if the message was already wrapped in HTML tags
2962
		// That could have be done by a plugin via filters
2963
		if ( false !== strpos( $body, '<html' ) ) {
2964
			return $body;
2965
		}
2966
2967
		$html_message = sprintf(
2968
			// The tabs are just here so that the raw code is correctly formatted for developers
2969
			// They're removed so that they don't affect the final message sent to users
2970
			str_replace(
2971
				"\t", '',
2972
				'<!doctype html>
2973
				<html xmlns="http://www.w3.org/1999/xhtml">
2974
				<body>
2975
2976
				%s
2977
2978
				</body>
2979
				</html>'
2980
			),
2981
			$body
2982
		);
2983
2984
		return $html_message;
2985
	}
2986
2987
	/**
2988
	 * Add a plain-text alternative part to an outbound email
2989
	 *
2990
	 * This makes the message more accessible to mail clients that aren't HTML-aware, and decreases the likelihood
2991
	 * that the message will be flagged as spam.
2992
	 *
2993
	 * @param PHPMailer $phpmailer
2994
	 */
2995
	static function add_plain_text_alternative( $phpmailer ) {
2996
		// Add an extra break so that the extra space above the <p> is preserved after the <p> is stripped out
2997
		$alt_body = str_replace( '<p>', '<p><br />', $phpmailer->Body );
2998
2999
		// Convert <br> to \n breaks, to preserve the space between lines that we want to keep
3000
		$alt_body = str_replace( array( '<br>', '<br />' ), "\n", $alt_body );
3001
3002
		// Convert <hr> to an plain-text equivalent, to preserve the integrity of the message
3003
		$alt_body = str_replace( array( '<hr>', '<hr />' ), "----\n", $alt_body );
3004
3005
		// Trim the plain text message to remove the \n breaks that were after <doctype>, <html>, and <body>
3006
		$phpmailer->AltBody = trim( strip_tags( $alt_body ) );
3007
	}
3008
3009
	function addslashes_deep( $value ) {
3010
		if ( is_array( $value ) ) {
3011
			return array_map( array( $this, 'addslashes_deep' ), $value );
3012
		} elseif ( is_object( $value ) ) {
3013
			$vars = get_object_vars( $value );
3014
			foreach ( $vars as $key => $data ) {
3015
				$value->{$key} = $this->addslashes_deep( $data );
3016
			}
3017
			return $value;
3018
		}
3019
3020
		return addslashes( $value );
3021
	}
3022
}
3023
3024
/**
3025
 * Class for the contact-field shortcode.
3026
 * Parses shortcode to output the contact form field as HTML.
3027
 * Validates input.
3028
 */
3029
class Grunion_Contact_Form_Field extends Crunion_Contact_Form_Shortcode {
3030
	public $shortcode_name = 'contact-field';
3031
3032
	/**
3033
	 * @var Grunion_Contact_Form parent form
3034
	 */
3035
	public $form;
3036
3037
	/**
3038
	 * @var string default or POSTed value
3039
	 */
3040
	public $value;
3041
3042
	/**
3043
	 * @var bool Is the input invalid?
3044
	 */
3045
	public $error = false;
3046
3047
	/**
3048
	 * @param array                $attributes An associative array of shortcode attributes.  @see shortcode_atts()
3049
	 * @param null|string          $content Null for selfclosing shortcodes.  The inner content otherwise.
3050
	 * @param Grunion_Contact_Form $form The parent form
0 ignored issues
show
Documentation introduced by
Should the type for parameter $form not be Grunion_Contact_Form|null?

This check looks for @param annotations where the type inferred by our type inference engine differs from the declared type.

It makes a suggestion as to what type it considers more descriptive.

Most often this is a case of a parameter that can be null in addition to its declared types.

Loading history...
3051
	 */
3052
	function __construct( $attributes, $content = null, $form = null ) {
3053
		$attributes = shortcode_atts(
3054
			array(
3055
				'label'       => null,
3056
				'type'        => 'text',
3057
				'required'    => false,
3058
				'options'     => array(),
3059
				'id'          => null,
3060
				'default'     => null,
3061
				'values'      => null,
3062
				'placeholder' => null,
3063
				'class'       => null,
3064
			), $attributes, 'contact-field'
3065
		);
3066
3067
		// special default for subject field
3068
		if ( 'subject' == $attributes['type'] && is_null( $attributes['default'] ) && ! is_null( $form ) ) {
3069
			$attributes['default'] = $form->get_attribute( 'subject' );
3070
		}
3071
3072
		// allow required=1 or required=true
3073
		if ( '1' == $attributes['required'] || 'true' == strtolower( $attributes['required'] ) ) {
3074
			$attributes['required'] = true;
3075
		} else {
3076
			$attributes['required'] = false;
3077
		}
3078
3079
		// parse out comma-separated options list (for selects, radios, and checkbox-multiples)
3080
		if ( ! empty( $attributes['options'] ) && is_string( $attributes['options'] ) ) {
3081
			$attributes['options'] = array_map( 'trim', explode( ',', $attributes['options'] ) );
3082
3083 View Code Duplication
			if ( ! empty( $attributes['values'] ) && is_string( $attributes['values'] ) ) {
3084
				$attributes['values'] = array_map( 'trim', explode( ',', $attributes['values'] ) );
3085
			}
3086
		}
3087
3088
		if ( $form ) {
3089
			// make a unique field ID based on the label, with an incrementing number if needed to avoid clashes
3090
			$form_id = $form->get_attribute( 'id' );
3091
			$id      = isset( $attributes['id'] ) ? $attributes['id'] : false;
3092
3093
			$unescaped_label = $this->unesc_attr( $attributes['label'] );
3094
			$unescaped_label = str_replace( '%', '-', $unescaped_label ); // jQuery doesn't like % in IDs?
3095
			$unescaped_label = preg_replace( '/[^a-zA-Z0-9.-_:]/', '', $unescaped_label );
3096
3097
			if ( empty( $id ) ) {
3098
				$id        = sanitize_title_with_dashes( 'g' . $form_id . '-' . $unescaped_label );
3099
				$i         = 0;
3100
				$max_tries = 99;
3101
				while ( isset( $form->fields[ $id ] ) ) {
3102
					$i++;
3103
					$id = sanitize_title_with_dashes( 'g' . $form_id . '-' . $unescaped_label . '-' . $i );
3104
3105
					if ( $i > $max_tries ) {
3106
						break;
3107
					}
3108
				}
3109
			}
3110
3111
			$attributes['id'] = $id;
3112
		}
3113
3114
		parent::__construct( $attributes, $content );
3115
3116
		// Store parent form
3117
		$this->form = $form;
3118
	}
3119
3120
	/**
3121
	 * This field's input is invalid.  Flag as invalid and add an error to the parent form
3122
	 *
3123
	 * @param string $message The error message to display on the form.
3124
	 */
3125
	function add_error( $message ) {
3126
		$this->is_error = true;
0 ignored issues
show
Bug introduced by
The property is_error does not exist. Did you maybe forget to declare it?

In PHP it is possible to write to properties without declaring them. For example, the following is perfectly valid PHP code:

class MyClass { }

$x = new MyClass();
$x->foo = true;

Generally, it is a good practice to explictly declare properties to avoid accidental typos and provide IDE auto-completion:

class MyClass {
    public $foo;
}

$x = new MyClass();
$x->foo = true;
Loading history...
3127
3128
		if ( ! is_wp_error( $this->form->errors ) ) {
3129
			$this->form->errors = new WP_Error;
3130
		}
3131
3132
		$this->form->errors->add( $this->get_attribute( 'id' ), $message );
0 ignored issues
show
Bug introduced by
The method add() does not seem to exist on object<WP_Error>.

This check looks for calls to methods that do not seem to exist on a given type. It looks for the method on the type itself as well as in inherited classes or implemented interfaces.

This is most likely a typographical error or the method has been renamed.

Loading history...
3133
	}
3134
3135
	/**
3136
	 * Is the field input invalid?
3137
	 *
3138
	 * @see $error
3139
	 *
3140
	 * @return bool
3141
	 */
3142
	function is_error() {
3143
		return $this->error;
3144
	}
3145
3146
	/**
3147
	 * Validates the form input
3148
	 */
3149
	function validate() {
3150
		// If it's not required, there's nothing to validate
3151
		if ( ! $this->get_attribute( 'required' ) ) {
3152
			return;
3153
		}
3154
3155
		$field_id    = $this->get_attribute( 'id' );
3156
		$field_type  = $this->get_attribute( 'type' );
3157
		$field_label = $this->get_attribute( 'label' );
3158
3159
		if ( isset( $_POST[ $field_id ] ) ) {
3160
			if ( is_array( $_POST[ $field_id ] ) ) {
3161
				$field_value = array_map( 'stripslashes', $_POST[ $field_id ] );
3162
			} else {
3163
				$field_value = stripslashes( $_POST[ $field_id ] );
3164
			}
3165
		} else {
3166
			$field_value = '';
3167
		}
3168
3169
		switch ( $field_type ) {
3170
			case 'email':
3171
				// Make sure the email address is valid
3172
				if ( ! is_email( $field_value ) ) {
3173
					/* translators: %s is the name of a form field */
3174
					$this->add_error( sprintf( __( '%s requires a valid email address', 'jetpack' ), $field_label ) );
3175
				}
3176
				break;
3177
			case 'checkbox-multiple':
3178
				// Check that there is at least one option selected
3179
				if ( empty( $field_value ) ) {
3180
					/* translators: %s is the name of a form field */
3181
					$this->add_error( sprintf( __( '%s requires at least one selection', 'jetpack' ), $field_label ) );
3182
				}
3183
				break;
3184
			default:
3185
				// Just check for presence of any text
3186
				if ( ! strlen( trim( $field_value ) ) ) {
3187
					/* translators: %s is the name of a form field */
3188
					$this->add_error( sprintf( __( '%s is required', 'jetpack' ), $field_label ) );
3189
				}
3190
		}
3191
	}
3192
3193
3194
	/**
3195
	 * Check the default value for options field
3196
	 *
3197
	 * @param string value
3198
	 * @param int index
3199
	 * @param string default value
3200
	 *
3201
	 * @return string
3202
	 */
3203
	public function get_option_value( $value, $index, $options ) {
3204
		if ( empty( $value[ $index ] ) ) {
3205
			return $options;
3206
		}
3207
		return $value[ $index ];
3208
	}
3209
3210
	/**
3211
	 * Outputs the HTML for this form field
3212
	 *
3213
	 * @return string HTML
3214
	 */
3215
	function render() {
3216
		global $current_user, $user_identity;
3217
3218
		$field_id          = $this->get_attribute( 'id' );
3219
		$field_type        = $this->get_attribute( 'type' );
3220
		$field_label       = $this->get_attribute( 'label' );
3221
		$field_required    = $this->get_attribute( 'required' );
3222
		$field_placeholder = $this->get_attribute( 'placeholder' );
3223
		$class             = 'date' === $field_type ? 'jp-contact-form-date' : $this->get_attribute( 'class' );
3224
3225
		/**
3226
		 * Filters the "class" attribute of the contact form input
3227
		 *
3228
		 * @module contact-form
3229
		 *
3230
		 * @since 6.6.0
3231
		 *
3232
		 * @param string $class Additional CSS classes for input class attribute.
3233
		 */
3234
		$field_class = apply_filters( 'jetpack_contact_form_input_class', $class );
3235
3236
		if ( isset( $_POST[ $field_id ] ) ) {
3237
			if ( is_array( $_POST[ $field_id ] ) ) {
3238
				$this->value = array_map( 'stripslashes', $_POST[ $field_id ] );
0 ignored issues
show
Documentation Bug introduced by
It seems like array_map('stripslashes', $_POST[$field_id]) of type array is incompatible with the declared type string of property $value.

Our type inference engine has found an assignment to a property that is incompatible with the declared type of that property.

Either this assignment is in error or the assigned type should be added to the documentation/type hint for that property..

Loading history...
3239
			} else {
3240
				$this->value = stripslashes( (string) $_POST[ $field_id ] );
3241
			}
3242
		} elseif ( isset( $_GET[ $field_id ] ) ) {
3243
			$this->value = stripslashes( (string) $_GET[ $field_id ] );
3244
		} elseif (
3245
			is_user_logged_in() &&
3246
			( ( defined( 'IS_WPCOM' ) && IS_WPCOM ) ||
3247
			  /**
3248
			   * Allow third-party tools to prefill the contact form with the user's details when they're logged in.
3249
			   *
3250
			   * @module contact-form
3251
			   *
3252
			   * @since 3.2.0
3253
			   *
3254
			   * @param bool false Should the Contact Form be prefilled with your details when you're logged in. Default to false.
3255
			   */
3256
			  true === apply_filters( 'jetpack_auto_fill_logged_in_user', false )
3257
			)
3258
		) {
3259
			// Special defaults for logged-in users
3260
			switch ( $this->get_attribute( 'type' ) ) {
3261
				case 'email':
3262
					$this->value = $current_user->data->user_email;
3263
					break;
3264
				case 'name':
3265
					$this->value = $user_identity;
3266
					break;
3267
				case 'url':
3268
					$this->value = $current_user->data->user_url;
3269
					break;
3270
				default:
3271
					$this->value = $this->get_attribute( 'default' );
3272
			}
3273
		} else {
3274
			$this->value = $this->get_attribute( 'default' );
3275
		}
3276
3277
		$field_value = Grunion_Contact_Form_Plugin::strip_tags( $this->value );
3278
		$field_label = Grunion_Contact_Form_Plugin::strip_tags( $field_label );
3279
3280
		$rendered_field = $this->render_field( $field_type, $field_id, $field_label, $field_value, $field_class, $field_placeholder, $field_required );
3281
3282
		/**
3283
		 * Filter the HTML of the Contact Form.
3284
		 *
3285
		 * @module contact-form
3286
		 *
3287
		 * @since 2.6.0
3288
		 *
3289
		 * @param string $rendered_field Contact Form HTML output.
3290
		 * @param string $field_label Field label.
3291
		 * @param int|null $id Post ID.
3292
		 */
3293
		return apply_filters( 'grunion_contact_form_field_html', $rendered_field, $field_label, ( in_the_loop() ? get_the_ID() : null ) );
3294
	}
3295
3296
	function render_label( $type = '', $id, $label, $required, $required_field_text ) {
3297
3298
		$type_class = $type ? ' ' .$type : '';
3299
		return
3300
			"<label
3301
				for='" . esc_attr( $id ) . "'
3302
				class='grunion-field-label{$type_class}" . ( $this->is_error() ? ' form-error' : '' ) . "'
3303
				>"
3304
				. esc_html( $label )
3305
				. ( $required ? '<span>' . $required_field_text . '</span>' : '' )
3306
			. "</label>\n";
3307
3308
	}
3309
3310
	function render_input_field( $type, $id, $value, $class, $placeholder, $required ) {
3311
		return "<input
3312
					type='". esc_attr( $type ) ."'
3313
					name='" . esc_attr( $id ) . "'
3314
					id='" . esc_attr( $id ) . "'
3315
					value='" . esc_attr( $value ) . "'
3316
					" . $class . $placeholder . '
3317
					' . ( $required ? "required aria-required='true'" : '' ) . "
3318
				/>\n";
3319
	}
3320
3321
	function render_email_field( $id, $label, $value, $class, $required, $required_field_text, $placeholder ) {
3322
		$field = $this->render_label( 'email', $id, $label, $required, $required_field_text );
3323
		$field .= $this->render_input_field( 'email', $id, $value, $class, $placeholder, $required );
3324
		return $field;
3325
	}
3326
3327
	function render_telephone_field( $id, $label, $value, $class, $required, $required_field_text, $placeholder ) {
3328
		$field = $this->render_label( 'telephone', $id, $label, $required, $required_field_text );
3329
		$field .= $this->render_input_field( 'tel', $id, $value, $class, $placeholder, $required );
3330
		return $field;
3331
	}
3332
3333
	function render_url_field( $id, $label, $value, $class, $required, $required_field_text, $placeholder ) {
3334
		$field = $this->render_label( 'url', $id, $label, $required, $required_field_text );
3335
		$field .= $this->render_input_field( 'url', $id, $value, $class, $placeholder, $required );
3336
		return $field;
3337
	}
3338
3339
	function render_textarea_field( $id, $label, $value, $class, $required, $required_field_text, $placeholder ) {
3340
		$field = $this->render_label( 'textarea', 'contact-form-comment-' . $id, $label, $required, $required_field_text );
3341
		$field .= "<textarea
3342
		                name='" . esc_attr( $id ) . "'
3343
		                id='contact-form-comment-" . esc_attr( $id ) . "'
3344
		                rows='20' "
3345
		                . $class
3346
		                . $placeholder
3347
		                . ' ' . ( $required ? "required aria-required='true'" : '' ) .
3348
		                '>' . esc_textarea( $value )
3349
		          . "</textarea>\n";
3350
		return $field;
3351
	}
3352
3353
	function render_radio_field( $id, $label, $value, $class, $required, $required_field_text ) {
3354
		$field = $this->render_label( '', $id, $label, $required, $required_field_text );
3355
		foreach ( (array) $this->get_attribute( 'options' ) as $optionIndex => $option ) {
3356
			$option = Grunion_Contact_Form_Plugin::strip_tags( $option );
3357
			if ( $option ) {
3358
				$field .= "\t\t<label class='grunion-radio-label radio" . ( $this->is_error() ? ' form-error' : '' ) . "'>";
3359
				$field .= "<input
3360
									type='radio'
3361
									name='" . esc_attr( $id ) . "'
3362
									value='" . esc_attr( $this->get_option_value( $this->get_attribute( 'values' ), $optionIndex, $option ) ) . "' "
3363
				                    . $class
3364
				                    . checked( $option, $value, false ) . ' '
3365
				                    . ( $required ? "required aria-required='true'" : '' )
3366
				              . '/> ';
3367
				$field .= esc_html( $option ) . "</label>\n";
3368
				$field .= "\t\t<div class='clear-form'></div>\n";
3369
			}
3370
		}
3371
		return $field;
3372
	}
3373
3374
	function render_checkbox_field( $id, $label, $value, $class, $required, $required_field_text ) {
3375
		$field = "<label class='grunion-field-label checkbox" . ( $this->is_error() ? ' form-error' : '' ) . "'>";
3376
			$field .= "\t\t<input type='checkbox' name='" . esc_attr( $id ) . "' value='" . esc_attr__( 'Yes', 'jetpack' ) . "' " . $class . checked( (bool) $value, true, false ) . ' ' . ( $required ? "required aria-required='true'" : '' ) . "/> \n";
3377
			$field .= "\t\t" . esc_html( $label ) . ( $required ? '<span>' . $required_field_text . '</span>' : '' );
3378
		$field .=  "</label>\n";
3379
		$field .= "<div class='clear-form'></div>\n";
3380
		return $field;
3381
	}
3382
3383
	function render_checkbox_multiple_field( $id, $label, $value, $class, $required, $required_field_text  ) {
3384
		$field = $this->render_label( '', $id, $label, $required, $required_field_text );
3385
		foreach ( (array) $this->get_attribute( 'options' ) as $optionIndex => $option ) {
3386
			$option = Grunion_Contact_Form_Plugin::strip_tags( $option );
3387
			if ( $option  ) {
3388
				$field .= "\t\t<label class='grunion-checkbox-multiple-label checkbox-multiple" . ( $this->is_error() ? ' form-error' : '' ) . "'>";
3389
				$field .= "<input type='checkbox' name='" . esc_attr( $id ) . "[]' value='" . esc_attr( $this->get_option_value( $this->get_attribute( 'values' ), $optionIndex, $option ) ) . "' " . $class . checked( in_array( $option, (array) $value ), true, false ) . ' /> ';
3390
				$field .= esc_html( $option ) . "</label>\n";
3391
				$field .= "\t\t<div class='clear-form'></div>\n";
3392
			}
3393
		}
3394
3395
		return $field;
3396
	}
3397
3398
	function render_select_field( $id, $label, $value, $class, $required, $required_field_text ) {
3399
		$field = $this->render_label( 'select', $id, $label, $required, $required_field_text );
3400
		$field  .= "\t<select name='" . esc_attr( $id ) . "' id='" . esc_attr( $id ) . "' " . $class . ( $required ? "required aria-required='true'" : '' ) . ">\n";
3401
		foreach ( (array) $this->get_attribute( 'options' ) as $optionIndex => $option ) {
3402
			$option = Grunion_Contact_Form_Plugin::strip_tags( $option );
3403
			if ( $option ) {
3404
				$field .= "\t\t<option"
3405
				               . selected( $option, $value, false )
3406
				               . " value='" . esc_attr( $this->get_option_value( $this->get_attribute( 'values' ), $optionIndex, $option ) )
3407
				               . "'>" . esc_html( $option )
3408
				          . "</option>\n";
3409
			}
3410
		}
3411
		$field  .= "\t</select>\n";
3412
		return $field;
3413
	}
3414
3415
	function render_date_field( $id, $label, $value, $class, $required, $required_field_text, $placeholder ) {
3416
3417
		$field = $this->render_label( 'date', $id, $label, $required, $required_field_text );
3418
		$field .= $this->render_input_field( 'text', $id, $value, $class, $placeholder, $required );
3419
3420
		/* For AMP requests, use amp-date-picker element: https://amp.dev/documentation/components/amp-date-picker */
3421
		if ( class_exists( 'Jetpack_AMP_Support' ) && Jetpack_AMP_Support::is_amp_request() ) {
3422
			return sprintf(
3423
				'<%1$s mode="overlay" layout="container" type="single" input-selector="[name=%2$s]">%3$s</%1$s>',
3424
				'amp-date-picker',
3425
				esc_attr( $id ),
3426
				$field
3427
			);
3428
		}
3429
3430
		wp_enqueue_script(
3431
			'grunion-frontend',
3432
			Assets::get_file_url_for_environment(
3433
				'_inc/build/contact-form/js/grunion-frontend.min.js',
3434
				'modules/contact-form/js/grunion-frontend.js'
3435
			),
3436
			array( 'jquery', 'jquery-ui-datepicker' )
3437
		);
3438
		wp_enqueue_style( 'jp-jquery-ui-datepicker', plugins_url( 'css/jquery-ui-datepicker.css', __FILE__ ), array( 'dashicons' ), '1.0' );
3439
3440
		// Using Core's built-in datepicker localization routine
3441
		wp_localize_jquery_ui_datepicker();
3442
		return $field;
3443
	}
3444
3445
	function render_default_field( $id, $label, $value, $class, $required, $required_field_text, $placeholder, $type ) {
3446
		$field = $this->render_label( $type, $id, $label, $required, $required_field_text );
3447
		$field .= $this->render_input_field( 'text', $id, $value, $class, $placeholder, $required );
3448
		return $field;
3449
	}
3450
3451
	function render_field( $type, $id, $label, $value, $class, $placeholder, $required ) {
3452
3453
		$field_placeholder = ( ! empty( $placeholder ) ) ? "placeholder='" . esc_attr( $placeholder ) . "'" : '';
3454
		$field_class       = "class='" . trim( esc_attr( $type ) . ' ' . esc_attr( $class ) ) . "' ";
3455
		$wrap_classes = empty( $class ) ? '' : implode( '-wrap ', array_filter( explode( ' ', $class ) ) ) . '-wrap'; // this adds
3456
3457
		$shell_field_class = "class='grunion-field-wrap grunion-field-" . trim( esc_attr( $type ) . '-wrap ' . esc_attr( $wrap_classes ) ) . "' ";
3458
		/**
3459
		/**
3460
		 * Filter the Contact Form required field text
3461
		 *
3462
		 * @module contact-form
3463
		 *
3464
		 * @since 3.8.0
3465
		 *
3466
		 * @param string $var Required field text. Default is "(required)".
3467
		 */
3468
		$required_field_text = esc_html( apply_filters( 'jetpack_required_field_text', __( '(required)', 'jetpack' ) ) );
3469
3470
		$field = "\n<div {$shell_field_class} >\n"; // new in Jetpack 6.8.0
3471
		// If they are logged in, and this is their site, don't pre-populate fields
3472
		if ( current_user_can( 'manage_options' ) ) {
3473
			$value = '';
3474
		}
3475
		switch ( $type ) {
3476
			case 'email':
3477
				$field .= $this->render_email_field( $id, $label, $value, $field_class, $required, $required_field_text, $field_placeholder );
3478
				break;
3479
			case 'telephone':
3480
				$field .= $this->render_telephone_field( $id, $label, $value, $field_class, $required, $required_field_text, $field_placeholder );
3481
				break;
3482
			case 'url':
3483
				$field .= $this->render_url_field( $id, $label, $value, $field_class, $required, $required_field_text, $field_placeholder );
3484
				break;
3485
			case 'textarea':
3486
				$field .= $this->render_textarea_field( $id, $label, $value, $field_class, $required, $required_field_text, $field_placeholder );
3487
				break;
3488
			case 'radio':
3489
				$field .= $this->render_radio_field( $id, $label, $value, $field_class, $required, $required_field_text, $field_placeholder );
0 ignored issues
show
Unused Code introduced by
The call to Grunion_Contact_Form_Field::render_radio_field() has too many arguments starting with $field_placeholder.

This check compares calls to functions or methods with their respective definitions. If the call has more arguments than are defined, it raises an issue.

If a function is defined several times with a different number of parameters, the check may pick up the wrong definition and report false positives. One codebase where this has been known to happen is Wordpress.

In this case you can add the @ignore PhpDoc annotation to the duplicate definition and it will be ignored.

Loading history...
3490
				break;
3491
			case 'checkbox':
3492
				$field .= $this->render_checkbox_field( $id, $label, $value, $field_class, $required, $required_field_text );
3493
				break;
3494
			case 'checkbox-multiple':
3495
				$field .= $this->render_checkbox_multiple_field( $id, $label, $value, $field_class, $required, $required_field_text );
3496
				break;
3497
			case 'select':
3498
				$field .= $this->render_select_field( $id, $label, $value, $field_class, $required, $required_field_text );
3499
				break;
3500
			case 'date':
3501
				$field .= $this->render_date_field( $id, $label, $value, $field_class, $required, $required_field_text, $field_placeholder );
3502
				break;
3503
			default: // text field
3504
				$field .= $this->render_default_field( $id, $label, $value, $field_class, $required, $required_field_text, $field_placeholder, $type );
3505
				break;
3506
		}
3507
		$field .= "\t</div>\n";
3508
		return $field;
3509
	}
3510
}
3511
3512
add_action( 'init', array( 'Grunion_Contact_Form_Plugin', 'init' ), 9 );
3513
3514
add_action( 'grunion_scheduled_delete', 'grunion_delete_old_spam' );
3515
3516
/**
3517
 * Deletes old spam feedbacks to keep the posts table size under control
3518
 */
3519
function grunion_delete_old_spam() {
3520
	global $wpdb;
3521
3522
	$grunion_delete_limit = 100;
3523
3524
	$now_gmt  = current_time( 'mysql', 1 );
3525
	$sql      = $wpdb->prepare(
3526
		"
3527
		SELECT `ID`
3528
		FROM $wpdb->posts
3529
		WHERE DATE_SUB( %s, INTERVAL 15 DAY ) > `post_date_gmt`
3530
			AND `post_type` = 'feedback'
3531
			AND `post_status` = 'spam'
3532
		LIMIT %d
3533
	", $now_gmt, $grunion_delete_limit
3534
	);
3535
	$post_ids = $wpdb->get_col( $sql );
3536
3537
	foreach ( (array) $post_ids as $post_id ) {
3538
		// force a full delete, skip the trash
3539
		wp_delete_post( $post_id, true );
3540
	}
3541
3542
	if (
3543
		/**
3544
		 * Filter if the module run OPTIMIZE TABLE on the core WP tables.
3545
		 *
3546
		 * @module contact-form
3547
		 *
3548
		 * @since 1.3.1
3549
		 * @since 6.4.0 Set to false by default.
3550
		 *
3551
		 * @param bool $filter Should Jetpack optimize the table, defaults to false.
3552
		 */
3553
		apply_filters( 'grunion_optimize_table', false )
3554
	) {
3555
		$wpdb->query( "OPTIMIZE TABLE $wpdb->posts" );
3556
	}
3557
3558
	// if we hit the max then schedule another run
3559
	if ( count( $post_ids ) >= $grunion_delete_limit ) {
3560
		wp_schedule_single_event( time() + 700, 'grunion_scheduled_delete' );
3561
	}
3562
}
3563
3564
/**
3565
 * Send an event to Tracks on form submission.
3566
 *
3567
 * @param int   $post_id - the post_id for the CPT that is created.
3568
 * @param array $all_values - fields from the default contact form.
3569
 * @param array $extra_values - extra fields added to from the contact form.
3570
 *
3571
 * @return null|void
3572
 */
3573
function jetpack_tracks_record_grunion_pre_message_sent( $post_id, $all_values, $extra_values ) {
3574
	// Do not do anything if the submission is not from a block.
3575
	if (
3576
		! isset( $extra_values['is_block'] )
3577
		|| ! $extra_values['is_block']
3578
	) {
3579
		return;
3580
	}
3581
3582
	/*
3583
	 * Event details.
3584
	 */
3585
	$event_user  = wp_get_current_user();
3586
	$event_name  = 'contact_form_block_message_sent';
3587
	$event_props = array(
3588
		'entry_permalink' => esc_url( $all_values['entry_permalink'] ),
3589
		'feedback_id'     => esc_attr( $all_values['feedback_id'] ),
3590
	);
3591
3592
	/*
3593
	 * Record event.
3594
	 * We use different libs on wpcom and Jetpack.
3595
	 */
3596
	if ( defined( 'IS_WPCOM' ) && IS_WPCOM ) {
3597
		$event_name             = 'wpcom_' . $event_name;
3598
		$event_props['blog_id'] = get_current_blog_id();
3599
		// If the form was sent by a logged out visitor, record event with blog owner.
3600
		if ( empty( $event_user->ID ) ) {
3601
			$event_user_id = wpcom_get_blog_owner( $event_props['blog_id'] );
3602
			$event_user    = get_userdata( $event_user_id );
3603
		}
3604
3605
		require_lib( 'tracks/client' );
3606
		tracks_record_event( $event_user, $event_name, $event_props );
3607
	} else {
3608
		// If the form was sent by a logged out visitor, record event with Jetpack master user.
3609
		if ( empty( $event_user->ID ) ) {
3610
			$master_user_id = Jetpack_Options::get_option( 'master_user' );
3611
			if ( ! empty( $master_user_id ) ) {
3612
				$event_user = get_userdata( $master_user_id );
3613
			}
3614
		}
3615
3616
		$tracking = new Automattic\Jetpack\Tracking();
3617
		$tracking->record_user_event( $event_name, $event_props, $event_user );
3618
	}
3619
}
3620
add_action( 'grunion_pre_message_sent', 'jetpack_tracks_record_grunion_pre_message_sent', 12, 3 );
3621
3622
/**
3623
 * Update the Google Drive file associcated with this form, if there is one.
3624
 *
3625
 * @param int   $post_id - the post_id for the CPT that is created.
3626
 * @param array $all_values - fields from the default contact form.
3627
 * @param array $extra_values - extra fields added to from the contact form.
3628
 * @param array $contact_form_instance - an instance of the contact form
3629
 *
3630
 * @return null|void
3631
 */
3632
function google_drive_integration_grunion_pre_message_sent( $post_id, $all_values, $extra_values, $contact_form_instance ) {
3633
	$drive_file_name = $contact_form_instance->get_attribute( 'driveFileName' );
0 ignored issues
show
Bug introduced by
The method get_attribute cannot be called on $contact_form_instance (of type array).

Methods can only be called on objects. This check looks for methods being called on variables that have been inferred to never be objects.

Loading history...
3634
3635
	if ( ! $drive_file_name || $drive_file_name === '' ) {
3636
		return;
3637
	}
3638
3639
	// Check the integration is enabled
3640
	if ( ! $contact_form_instance->contact_form_integrations_enabled() ) {
0 ignored issues
show
Bug introduced by
The method contact_form_integrations_enabled cannot be called on $contact_form_instance (of type array).

Methods can only be called on objects. This check looks for methods being called on variables that have been inferred to never be objects.

Loading history...
3641
		return;
3642
	}
3643
3644
	if ( defined( 'IS_WPCOM' ) && IS_WPCOM ) {
3645
		// Call the API directly
3646
		return;
3647
	}
3648
3649
	$site_id = Jetpack_Options::get_option( 'id' );
3650
	$response = Client::wpcom_json_api_request_as_blog(
3651
		sprintf( 'TODO', $site_id ),
3652
		'2',
3653
		array( 'method' => 'post' ),
3654
		array( 'driveFileName' => $drive_file_name ),
0 ignored issues
show
Documentation introduced by
array('driveFileName' => $drive_file_name) is of type array<string,?,{"driveFileName":"?"}>, but the function expects a string|null.

It seems like the type of the argument is not accepted by the function/method which you are calling.

In some cases, in particular if PHP’s automatic type-juggling kicks in this might be fine. In other cases, however this might be a bug.

We suggest to add an explicit type cast like in the following example:

function acceptsInteger($int) { }

$x = '123'; // string "123"

// Instead of
acceptsInteger($x);

// we recommend to use
acceptsInteger((integer) $x);
Loading history...
3655
		'wpcom'
3656
	);
3657
3658
	return rest_ensure_response( json_decode( wp_remote_retrieve_body( $response ) ) );
3659
}
3660
add_action( 'grunion_pre_message_sent', 'google_drive_integration_grunion_pre_message_sent', 12, 4 );
3661