Completed
Push — add/e2e-connection-purchase-fl... ( d02cce...68beb5 )
by Yaroslav
14:07 queued 04:35
created

modules/contact-form/grunion-contact-form.php (2 issues)

Upgrade to new PHP Analysis Engine

These results are based on our legacy PHP analysis, consider migrating to our new PHP analysis engine instead. Learn more

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
13
define( 'GRUNION_PLUGIN_DIR', plugin_dir_path( __FILE__ ) );
14
define( 'GRUNION_PLUGIN_URL', plugin_dir_url( __FILE__ ) );
15
16
if ( is_admin() ) {
17
	require_once GRUNION_PLUGIN_DIR . 'admin.php';
18
}
19
20
add_action( 'rest_api_init', 'grunion_contact_form_require_endpoint' );
21
function grunion_contact_form_require_endpoint() {
22
	require_once GRUNION_PLUGIN_DIR . 'class-grunion-contact-form-endpoint.php';
23
}
24
25
/**
26
 * Sets up various actions, filters, post types, post statuses, shortcodes.
27
 */
28
class Grunion_Contact_Form_Plugin {
29
30
	/**
31
	 * @var string The Widget ID of the widget currently being processed.  Used to build the unique contact-form ID for forms embedded in widgets.
32
	 */
33
	public $current_widget_id;
34
35
	static $using_contact_form_field = false;
36
37
	/**
38
	 * @var int The last Feedback Post ID Erased as part of the Personal Data Eraser.
39
	 * Helps with pagination.
40
	 */
41
	private $pde_last_post_id_erased = 0;
42
43
	/**
44
	 * @var string The email address for which we are deleting/exporting all feedbacks
45
	 * as part of a Personal Data Eraser or Personal Data Exporter request.
46
	 */
47
	private $pde_email_address = '';
48
49
	static function init() {
50
		static $instance = false;
51
52
		if ( ! $instance ) {
53
			$instance = new Grunion_Contact_Form_Plugin();
54
55
			// Schedule our daily cleanup
56
			add_action( 'wp_scheduled_delete', array( $instance, 'daily_akismet_meta_cleanup' ) );
57
		}
58
59
		return $instance;
60
	}
61
62
	/**
63
	 * Runs daily to clean up spam detection metadata after 15 days.  Keeps your DB squeaky clean.
64
	 */
65
	public function daily_akismet_meta_cleanup() {
66
		global $wpdb;
67
68
		$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" );
69
70
		if ( empty( $feedback_ids ) ) {
71
			return;
72
		}
73
74
		/**
75
		 * Fires right before deleting the _feedback_akismet_values post meta on $feedback_ids
76
		 *
77
		 * @module contact-form
78
		 *
79
		 * @since 6.1.0
80
		 *
81
		 * @param array $feedback_ids list of feedback post ID
82
		 */
83
		do_action( 'jetpack_daily_akismet_meta_cleanup_before', $feedback_ids );
84
		foreach ( $feedback_ids as $feedback_id ) {
85
			delete_post_meta( $feedback_id, '_feedback_akismet_values' );
86
		}
87
88
		/**
89
		 * Fires right after deleting the _feedback_akismet_values post meta on $feedback_ids
90
		 *
91
		 * @module contact-form
92
		 *
93
		 * @since 6.1.0
94
		 *
95
		 * @param array $feedback_ids list of feedback post ID
96
		 */
97
		do_action( 'jetpack_daily_akismet_meta_cleanup_after', $feedback_ids );
98
	}
99
100
	/**
101
	 * Strips HTML tags from input.  Output is NOT HTML safe.
102
	 *
103
	 * @param mixed $data_with_tags
104
	 * @return mixed
105
	 */
106
	public static function strip_tags( $data_with_tags ) {
107
		if ( is_array( $data_with_tags ) ) {
108
			foreach ( $data_with_tags as $index => $value ) {
109
				$index = sanitize_text_field( strval( $index ) );
110
				$value = wp_kses( strval( $value ), array() );
111
				$value = str_replace( '&amp;', '&', $value ); // undo damage done by wp_kses_normalize_entities()
112
113
				$data_without_tags[ $index ] = $value;
114
			}
115
		} else {
116
			$data_without_tags = wp_kses( $data_with_tags, array() );
117
			$data_without_tags = str_replace( '&amp;', '&', $data_without_tags ); // undo damage done by wp_kses_normalize_entities()
118
		}
119
120
		return $data_without_tags;
121
	}
122
123
	/**
124
	 * Class uses singleton pattern; use Grunion_Contact_Form_Plugin::init() to initialize.
125
	 */
126
	protected function __construct() {
127
		$this->add_shortcode();
128
129
		// While generating the output of a text widget with a contact-form shortcode, we need to know its widget ID.
130
		add_action( 'dynamic_sidebar', array( $this, 'track_current_widget' ) );
131
132
		// Add a "widget" shortcode attribute to all contact-form shortcodes embedded in widgets
133
		add_filter( 'widget_text', array( $this, 'widget_atts' ), 0 );
134
135
		// If Text Widgets don't get shortcode processed, hack ours into place.
136
		if (
137
			version_compare( get_bloginfo( 'version' ), '4.9-z', '<=' )
138
			&& ! has_filter( 'widget_text', 'do_shortcode' )
139
		) {
140
			add_filter( 'widget_text', array( $this, 'widget_shortcode_hack' ), 5 );
141
		}
142
143
		add_filter( 'jetpack_contact_form_is_spam', array( $this, 'is_spam_blocklist' ), 10, 2 );
144
		add_filter( 'jetpack_contact_form_in_comment_disallowed_list', array( $this, 'is_in_disallowed_list' ), 10, 2 );
145
		// Akismet to the rescue
146
		if ( defined( 'AKISMET_VERSION' ) || function_exists( 'akismet_http_post' ) ) {
147
			add_filter( 'jetpack_contact_form_is_spam', array( $this, 'is_spam_akismet' ), 10, 2 );
148
			add_action( 'contact_form_akismet', array( $this, 'akismet_submit' ), 10, 2 );
149
		}
150
151
		add_action( 'loop_start', array( 'Grunion_Contact_Form', '_style_on' ) );
152
153
		add_action( 'wp_ajax_grunion-contact-form', array( $this, 'ajax_request' ) );
154
		add_action( 'wp_ajax_nopriv_grunion-contact-form', array( $this, 'ajax_request' ) );
155
156
		// GDPR: personal data exporter & eraser.
157
		add_filter( 'wp_privacy_personal_data_exporters', array( $this, 'register_personal_data_exporter' ) );
158
		add_filter( 'wp_privacy_personal_data_erasers', array( $this, 'register_personal_data_eraser' ) );
159
160
		// Export to CSV feature
161
		if ( is_admin() ) {
162
			add_action( 'admin_init', array( $this, 'download_feedback_as_csv' ) );
163
			add_action( 'admin_footer-edit.php', array( $this, 'export_form' ) );
164
			add_action( 'admin_menu', array( $this, 'admin_menu' ) );
165
			add_action( 'current_screen', array( $this, 'unread_count' ) );
166
		}
167
168
		// custom post type we'll use to keep copies of the feedback items
169
		register_post_type(
170
			'feedback', array(
171
				'labels'                => array(
172
					'name'               => __( 'Feedback', 'jetpack' ),
173
					'singular_name'      => __( 'Feedback', 'jetpack' ),
174
					'search_items'       => __( 'Search Feedback', 'jetpack' ),
175
					'not_found'          => __( 'No feedback found', 'jetpack' ),
176
					'not_found_in_trash' => __( 'No feedback found', 'jetpack' ),
177
				),
178
				// Matrial Ballot icon
179
				'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>'),
180
				'show_ui'               => true,
181
				'show_in_admin_bar'     => false,
182
				'public'                => false,
183
				'rewrite'               => false,
184
				'query_var'             => false,
185
				'capability_type'       => 'page',
186
				'show_in_rest'          => true,
187
				'rest_controller_class' => 'Grunion_Contact_Form_Endpoint',
188
				'capabilities'          => array(
189
					'create_posts'        => 'do_not_allow',
190
					'publish_posts'       => 'publish_pages',
191
					'edit_posts'          => 'edit_pages',
192
					'edit_others_posts'   => 'edit_others_pages',
193
					'delete_posts'        => 'delete_pages',
194
					'delete_others_posts' => 'delete_others_pages',
195
					'read_private_posts'  => 'read_private_pages',
196
					'edit_post'           => 'edit_page',
197
					'delete_post'         => 'delete_page',
198
					'read_post'           => 'read_page',
199
				),
200
				'map_meta_cap'          => true,
201
			)
202
		);
203
204
		// Add to REST API post type allowed list.
205
		add_filter( 'rest_api_allowed_post_types', array( $this, 'allow_feedback_rest_api_type' ) );
206
207
		// Add "spam" as a post status
208
		register_post_status(
209
			'spam', array(
210
				'label'                  => 'Spam',
211
				'public'                 => false,
212
				'exclude_from_search'    => true,
213
				'show_in_admin_all_list' => false,
214
				'label_count'            => _n_noop( 'Spam <span class="count">(%s)</span>', 'Spam <span class="count">(%s)</span>', 'jetpack' ),
215
				'protected'              => true,
216
				'_builtin'               => false,
217
			)
218
		);
219
220
		// POST handler
221
		if (
222
			isset( $_SERVER['REQUEST_METHOD'] ) && 'POST' == strtoupper( $_SERVER['REQUEST_METHOD'] )
223
			&&
224
			isset( $_POST['action'] ) && 'grunion-contact-form' == $_POST['action']
225
			&&
226
			isset( $_POST['contact-form-id'] )
227
		) {
228
			add_action( 'template_redirect', array( $this, 'process_form_submission' ) );
229
		}
230
231
		/*
232
		 Can be dequeued by placing the following in wp-content/themes/yourtheme/functions.php
233
		 *
234
		 * 	function remove_grunion_style() {
235
		 *		wp_deregister_style('grunion.css');
236
		 *	}
237
		 *	add_action('wp_print_styles', 'remove_grunion_style');
238
		 */
239
		wp_register_style( 'grunion.css', GRUNION_PLUGIN_URL . 'css/grunion.css', array(), JETPACK__VERSION );
240
		wp_style_add_data( 'grunion.css', 'rtl', 'replace' );
241
242
		self::register_contact_form_blocks();
243
	}
244
245
	private static function register_contact_form_blocks() {
246
		jetpack_register_block( 'jetpack/contact-form', array(
247
			'render_callback' => array( __CLASS__, 'gutenblock_render_form' ),
248
		) );
249
250
		// Field render methods.
251
		jetpack_register_block( 'jetpack/field-text', array(
252
			'parent'          => array( 'jetpack/contact-form' ),
253
			'render_callback' => array( __CLASS__, 'gutenblock_render_field_text' ),
254
		) );
255
		jetpack_register_block( 'jetpack/field-name', array(
256
			'parent'          => array( 'jetpack/contact-form' ),
257
			'render_callback' => array( __CLASS__, 'gutenblock_render_field_name' ),
258
		) );
259
		jetpack_register_block( 'jetpack/field-email', array(
260
			'parent'          => array( 'jetpack/contact-form' ),
261
			'render_callback' => array( __CLASS__, 'gutenblock_render_field_email' ),
262
		) );
263
		jetpack_register_block( 'jetpack/field-url', array(
264
			'parent'          => array( 'jetpack/contact-form' ),
265
			'render_callback' => array( __CLASS__, 'gutenblock_render_field_url' ),
266
		) );
267
		jetpack_register_block( 'jetpack/field-date', array(
268
			'parent'          => array( 'jetpack/contact-form' ),
269
			'render_callback' => array( __CLASS__, 'gutenblock_render_field_date' ),
270
		) );
271
		jetpack_register_block( 'jetpack/field-telephone', array(
272
			'parent'          => array( 'jetpack/contact-form' ),
273
			'render_callback' => array( __CLASS__, 'gutenblock_render_field_telephone' ),
274
		) );
275
		jetpack_register_block( 'jetpack/field-textarea', array(
276
			'parent'          => array( 'jetpack/contact-form' ),
277
			'render_callback' => array( __CLASS__, 'gutenblock_render_field_textarea' ),
278
		) );
279
		jetpack_register_block( 'jetpack/field-checkbox', array(
280
			'parent'          => array( 'jetpack/contact-form' ),
281
			'render_callback' => array( __CLASS__, 'gutenblock_render_field_checkbox' ),
282
		) );
283
		jetpack_register_block( 'jetpack/field-checkbox-multiple', array(
284
			'parent'          => array( 'jetpack/contact-form' ),
285
			'render_callback' => array( __CLASS__, 'gutenblock_render_field_checkbox_multiple' ),
286
		) );
287
		jetpack_register_block( 'jetpack/field-radio', array(
288
			'parent'          => array( 'jetpack/contact-form' ),
289
			'render_callback' => array( __CLASS__, 'gutenblock_render_field_radio' ),
290
		) );
291
		jetpack_register_block( 'jetpack/field-select', array(
292
			'parent'          => array( 'jetpack/contact-form' ),
293
			'render_callback' => array( __CLASS__, 'gutenblock_render_field_select' ),
294
		) );
295
		jetpack_register_block(
296
			'jetpack/field-consent',
297
			array(
298
				'parent'          => array( 'jetpack/contact-form' ),
299
				'render_callback' => array( __CLASS__, 'gutenblock_render_field_consent' ),
300
			)
301
		);
302
	}
303
304
	public static function gutenblock_render_form( $atts, $content ) {
305
		return Grunion_Contact_Form::parse( $atts, do_blocks( $content ) );
306
	}
307
308
	public static function block_attributes_to_shortcode_attributes( $atts, $type ) {
309
		$atts['type'] = $type;
310
		if ( isset( $atts['className'] ) ) {
311
			$atts['class'] = $atts['className'];
312
			unset( $atts['className'] );
313
		}
314
315
		if ( isset( $atts['defaultValue'] ) ) {
316
			$atts['default'] = $atts['defaultValue'];
317
			unset( $atts['defaultValue'] );
318
		}
319
320
		return $atts;
321
	}
322
323
	public static function gutenblock_render_field_text( $atts, $content ) {
324
		$atts = self::block_attributes_to_shortcode_attributes( $atts, 'text' );
325
		return Grunion_Contact_Form::parse_contact_field( $atts, $content );
326
	}
327
	public static function gutenblock_render_field_name( $atts, $content ) {
328
		$atts = self::block_attributes_to_shortcode_attributes( $atts, 'name' );
329
		return Grunion_Contact_Form::parse_contact_field( $atts, $content );
330
	}
331
	public static function gutenblock_render_field_email( $atts, $content ) {
332
		$atts = self::block_attributes_to_shortcode_attributes( $atts, 'email' );
333
		return Grunion_Contact_Form::parse_contact_field( $atts, $content );
334
	}
335
	public static function gutenblock_render_field_url( $atts, $content ) {
336
		$atts = self::block_attributes_to_shortcode_attributes( $atts, 'url' );
337
		return Grunion_Contact_Form::parse_contact_field( $atts, $content );
338
	}
339
	public static function gutenblock_render_field_date( $atts, $content ) {
340
		$atts = self::block_attributes_to_shortcode_attributes( $atts, 'date' );
341
		return Grunion_Contact_Form::parse_contact_field( $atts, $content );
342
	}
343
	public static function gutenblock_render_field_telephone( $atts, $content ) {
344
		$atts = self::block_attributes_to_shortcode_attributes( $atts, 'telephone' );
345
		return Grunion_Contact_Form::parse_contact_field( $atts, $content );
346
	}
347
	public static function gutenblock_render_field_textarea( $atts, $content ) {
348
		$atts = self::block_attributes_to_shortcode_attributes( $atts, 'textarea' );
349
		return Grunion_Contact_Form::parse_contact_field( $atts, $content );
350
	}
351
	public static function gutenblock_render_field_checkbox( $atts, $content ) {
352
		$atts = self::block_attributes_to_shortcode_attributes( $atts, 'checkbox' );
353
		return Grunion_Contact_Form::parse_contact_field( $atts, $content );
354
	}
355
	public static function gutenblock_render_field_checkbox_multiple( $atts, $content ) {
356
		$atts = self::block_attributes_to_shortcode_attributes( $atts, 'checkbox-multiple' );
357
		return Grunion_Contact_Form::parse_contact_field( $atts, $content );
358
	}
359
	public static function gutenblock_render_field_radio( $atts, $content ) {
360
		$atts = self::block_attributes_to_shortcode_attributes( $atts, 'radio' );
361
		return Grunion_Contact_Form::parse_contact_field( $atts, $content );
362
	}
363
	public static function gutenblock_render_field_select( $atts, $content ) {
364
		$atts = self::block_attributes_to_shortcode_attributes( $atts, 'select' );
365
		return Grunion_Contact_Form::parse_contact_field( $atts, $content );
366
	}
367
368
	/**
369
	 * Render the consent field.
370
	 *
371
	 * @param string $atts consent attributes.
372
	 * @param string $content html content.
373
	 */
374
	public static function gutenblock_render_field_consent( $atts, $content ) {
375
		$atts = self::block_attributes_to_shortcode_attributes( $atts, 'consent' );
376
377
		if ( ! isset( $atts['implicitConsentMessage'] ) ) {
378
			$atts['implicitConsentMessage'] = __( "By submitting your information, you're giving us permission to email you. You may unsubscribe at any time.", 'jetpack' );
379
		}
380
381
		if ( ! isset( $atts['explicitConsentMessage'] ) ) {
382
			$atts['explicitConsentMessage'] = __( 'Can we send you an email from time to time?', 'jetpack' );
383
		}
384
385
		return Grunion_Contact_Form::parse_contact_field( $atts, $content );
386
	}
387
388
	/**
389
	 * Add the 'Export' menu item as a submenu of Feedback.
390
	 */
391
	public function admin_menu() {
392
		add_submenu_page(
393
			'edit.php?post_type=feedback',
394
			__( 'Export feedback as CSV', 'jetpack' ),
395
			__( 'Export CSV', 'jetpack' ),
396
			'export',
397
			'feedback-export',
398
			array( $this, 'export_form' )
399
		);
400
	}
401
402
	/**
403
	 * Add to REST API post type allowed list.
404
	 */
405
	function allow_feedback_rest_api_type( $post_types ) {
406
		$post_types[] = 'feedback';
407
		return $post_types;
408
	}
409
410
	/**
411
	 * Display the count of new feedback entries received. It's reset when user visits the Feedback screen.
412
	 *
413
	 * @since 4.1.0
414
	 *
415
	 * @param object $screen Information about the current screen.
416
	 */
417
	function unread_count( $screen ) {
418
		if ( isset( $screen->post_type ) && 'feedback' == $screen->post_type ) {
419
			update_option( 'feedback_unread_count', 0 );
420
		} else {
421
			global $menu;
422
			if ( isset( $menu ) && is_array( $menu ) && ! empty( $menu ) ) {
423
				foreach ( $menu as $index => $menu_item ) {
424
					if ( 'edit.php?post_type=feedback' == $menu_item[2] ) {
425
						$unread = get_option( 'feedback_unread_count', 0 );
426
						if ( $unread > 0 ) {
427
							$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>' : '';
428
							$menu[ $index ][0] .= $unread_count;
429
						}
430
						break;
431
					}
432
				}
433
			}
434
		}
435
	}
436
437
	/**
438
	 * Handles all contact-form POST submissions
439
	 *
440
	 * Conditionally attached to `template_redirect`
441
	 */
442
	function process_form_submission() {
443
		// Add a filter to replace tokens in the subject field with sanitized field values
444
		add_filter( 'contact_form_subject', array( $this, 'replace_tokens_with_input' ), 10, 2 );
445
446
		$id   = stripslashes( $_POST['contact-form-id'] );
447
		$hash = isset( $_POST['contact-form-hash'] ) ? $_POST['contact-form-hash'] : '';
448
		$hash = preg_replace( '/[^\da-f]/i', '', $hash );
449
450
		if ( ! is_string( $id ) || ! is_string( $hash ) ) {
451
			return false;
452
		}
453
454
		if ( is_user_logged_in() ) {
455
			check_admin_referer( "contact-form_{$id}" );
456
		}
457
458
		$is_widget = 0 === strpos( $id, 'widget-' );
459
460
		$form = false;
461
462
		if ( $is_widget ) {
463
			// It's a form embedded in a text widget
464
			$this->current_widget_id = substr( $id, 7 ); // remove "widget-"
465
			$widget_type             = implode( '-', array_slice( explode( '-', $this->current_widget_id ), 0, -1 ) ); // Remove trailing -#
466
467
			// Is the widget active?
468
			$sidebar = is_active_widget( false, $this->current_widget_id, $widget_type );
469
470
			// This is lame - no core API for getting a widget by ID
471
			$widget = isset( $GLOBALS['wp_registered_widgets'][ $this->current_widget_id ] ) ? $GLOBALS['wp_registered_widgets'][ $this->current_widget_id ] : false;
472
473
			if ( $sidebar && $widget && isset( $widget['callback'] ) ) {
474
				// prevent PHP notices by populating widget args
475
				$widget_args = array(
476
					'before_widget' => '',
477
					'after_widget'  => '',
478
					'before_title'  => '',
479
					'after_title'   => '',
480
				);
481
				// This is lamer - no API for outputting a given widget by ID
482
				ob_start();
483
				// Process the widget to populate Grunion_Contact_Form::$last
484
				call_user_func( $widget['callback'], $widget_args, $widget['params'][0] );
485
				ob_end_clean();
486
			}
487
		} else {
488
			// It's a form embedded in a post
489
			$post = get_post( $id );
490
491
			// Process the content to populate Grunion_Contact_Form::$last
492
			if ( $post ) {
493
				/** This filter is already documented in core. wp-includes/post-template.php */
494
				apply_filters( 'the_content', $post->post_content );
495
			}
496
		}
497
498
		$form = isset( Grunion_Contact_Form::$forms[ $hash ] ) ? Grunion_Contact_Form::$forms[ $hash ] : null;
499
500
		// No form may mean user is using do_shortcode, grab the form using the stored post meta
501
		if ( ! $form && is_numeric( $id ) && $hash ) {
502
503
			// Get shortcode from post meta
504
			$shortcode = get_post_meta( $id, "_g_feedback_shortcode_{$hash}", true );
505
506
			// Format it
507
			if ( $shortcode != '' ) {
508
509
				// Get attributes from post meta.
510
				$parameters = '';
511
				$attributes = get_post_meta( $id, "_g_feedback_shortcode_atts_{$hash}", true );
512
				if ( ! empty( $attributes ) && is_array( $attributes ) ) {
513
					foreach ( array_filter( $attributes ) as $param => $value ) {
514
						$parameters .= " $param=\"$value\"";
515
					}
516
				}
517
518
				$shortcode = '[contact-form' . $parameters . ']' . $shortcode . '[/contact-form]';
519
				do_shortcode( $shortcode );
520
521
				// Recreate form
522
				$form = Grunion_Contact_Form::$last;
523
			}
524
		}
525
526
		if ( ! $form ) {
527
			return false;
528
		}
529
530
		if ( is_wp_error( $form->errors ) && $form->errors->get_error_codes() ) {
531
			return $form->errors;
532
		}
533
534
		// Process the form
535
		return $form->process_submission();
536
	}
537
538
	function ajax_request() {
539
		$submission_result = self::process_form_submission();
540
541
		if ( ! $submission_result ) {
542
			header( 'HTTP/1.1 500 Server Error', 500, true );
543
			echo '<div class="form-error"><ul class="form-errors"><li class="form-error-message">';
544
			esc_html_e( 'An error occurred. Please try again later.', 'jetpack' );
545
			echo '</li></ul></div>';
546
		} elseif ( is_wp_error( $submission_result ) ) {
547
			header( 'HTTP/1.1 400 Bad Request', 403, true );
548
			echo '<div class="form-error"><ul class="form-errors"><li class="form-error-message">';
549
			echo esc_html( $submission_result->get_error_message() );
550
			echo '</li></ul></div>';
551
		} else {
552
			echo '<h3>' . esc_html__( 'Message Sent', 'jetpack' ) . '</h3>' . $submission_result;
553
		}
554
555
		die;
556
	}
557
558
	/**
559
	 * Ensure the post author is always zero for contact-form feedbacks
560
	 * Attached to `wp_insert_post_data`
561
	 *
562
	 * @see Grunion_Contact_Form::process_submission()
563
	 *
564
	 * @param array $data the data to insert
565
	 * @param array $postarr the data sent to wp_insert_post()
566
	 * @return array The filtered $data to insert
567
	 */
568
	function insert_feedback_filter( $data, $postarr ) {
569
		if ( $data['post_type'] == 'feedback' && $postarr['post_type'] == 'feedback' ) {
570
			$data['post_author'] = 0;
571
		}
572
573
		return $data;
574
	}
575
	/*
576
	 * Adds our contact-form shortcode
577
	 * The "child" contact-field shortcode is enabled as needed by the contact-form shortcode handler
578
	 */
579
	function add_shortcode() {
580
		add_shortcode( 'contact-form', array( 'Grunion_Contact_Form', 'parse' ) );
581
		add_shortcode( 'contact-field', array( 'Grunion_Contact_Form', 'parse_contact_field' ) );
582
	}
583
584
	static function tokenize_label( $label ) {
585
		return '{' . trim( preg_replace( '#^\d+_#', '', $label ) ) . '}';
586
	}
587
588
	static function sanitize_value( $value ) {
589
		return preg_replace( '=((<CR>|<LF>|0x0A/%0A|0x0D/%0D|\\n|\\r)\S).*=i', null, $value );
590
	}
591
592
	/**
593
	 * Replaces tokens like {city} or {City} (case insensitive) with the value
594
	 * of an input field of that name
595
	 *
596
	 * @param string $subject
597
	 * @param array  $field_values Array with field label => field value associations
598
	 *
599
	 * @return string The filtered $subject with the tokens replaced
600
	 */
601
	function replace_tokens_with_input( $subject, $field_values ) {
602
		// Wrap labels into tokens (inside {})
603
		$wrapped_labels = array_map( array( 'Grunion_Contact_Form_Plugin', 'tokenize_label' ), array_keys( $field_values ) );
604
		// Sanitize all values
605
		$sanitized_values = array_map( array( 'Grunion_Contact_Form_Plugin', 'sanitize_value' ), array_values( $field_values ) );
606
607
		foreach ( $sanitized_values as $k => $sanitized_value ) {
608
			if ( is_array( $sanitized_value ) ) {
609
				$sanitized_values[ $k ] = implode( ', ', $sanitized_value );
610
			}
611
		}
612
613
		// Search for all valid tokens (based on existing fields) and replace with the field's value
614
		$subject = str_ireplace( $wrapped_labels, $sanitized_values, $subject );
615
		return $subject;
616
	}
617
618
	/**
619
	 * Tracks the widget currently being processed.
620
	 * Attached to `dynamic_sidebar`
621
	 *
622
	 * @see $current_widget_id
623
	 *
624
	 * @param array $widget The widget data
625
	 */
626
	function track_current_widget( $widget ) {
627
		$this->current_widget_id = $widget['id'];
628
	}
629
630
	/**
631
	 * Adds a "widget" attribute to every contact-form embedded in a text widget.
632
	 * Used to tell the difference between post-embedded contact-forms and widget-embedded contact-forms
633
	 * Attached to `widget_text`
634
	 *
635
	 * @param string $text The widget text
636
	 * @return string The filtered widget text
637
	 */
638
	function widget_atts( $text ) {
639
		Grunion_Contact_Form::style( true );
640
641
		return preg_replace( '/\[contact-form([^a-zA-Z_-])/', '[contact-form widget="' . $this->current_widget_id . '"\\1', $text );
642
	}
643
644
	/**
645
	 * For sites where text widgets are not processed for shortcodes, we add this hack to process just our shortcode
646
	 * Attached to `widget_text`
647
	 *
648
	 * @param string $text The widget text
649
	 * @return string The contact-form filtered widget text
650
	 */
651
	function widget_shortcode_hack( $text ) {
652
		if ( ! preg_match( '/\[contact-form([^a-zA-Z_-])/', $text ) ) {
653
			return $text;
654
		}
655
656
		$old = $GLOBALS['shortcode_tags'];
657
		remove_all_shortcodes();
658
		Grunion_Contact_Form_Plugin::$using_contact_form_field = true;
659
		$this->add_shortcode();
660
661
		$text = do_shortcode( $text );
662
663
		Grunion_Contact_Form_Plugin::$using_contact_form_field = false;
664
		$GLOBALS['shortcode_tags']                             = $old;
665
666
		return $text;
667
	}
668
669
	/**
670
	 * Check if a submission matches the Comment Blocklist.
671
	 * The Comment Blocklist is a means to moderate discussion, and contact
672
	 * forms are 1:1 discussion forums, ripe for abuse by users who are being
673
	 * removed from the public discussion.
674
	 * Attached to `jetpack_contact_form_is_spam`
675
	 *
676
	 * @param bool  $is_spam
677
	 * @param array $form
678
	 * @return bool TRUE => spam, FALSE => not spam
679
	 */
680
	public function is_spam_blocklist( $is_spam, $form = array() ) {
681
		if ( $is_spam ) {
682
			return $is_spam;
683
		}
684
685
		return $this->is_in_disallowed_list( false, $form );
686
	}
687
688
	/**
689
	 * Check if a submission matches the comment disallowed list.
690
	 * Attached to `jetpack_contact_form_in_comment_disallowed_list`.
691
	 *
692
	 * @param boolean $in_disallowed_list Whether the feedback is in the disallowed list.
693
	 * @param array   $form The form array.
694
	 * @return bool Returns true if the form submission matches the disallowed list and false if it doesn't.
695
	 */
696
	public function is_in_disallowed_list( $in_disallowed_list, $form = array() ) {
697
		global $wp_version;
698
699
		if ( $in_disallowed_list ) {
700
			return $in_disallowed_list;
701
		}
702
703
		/*
704
		 * wp_blacklist_check was deprecated in WP 5.5.
705
		 * @todo: remove when WordPress 5.5 is the minimum required version.
706
		 */
707
		if ( version_compare( $wp_version, '5.5-alpha', '>=' ) ) {
708
			$check_comment_disallowed_list = 'wp_check_comment_disallowed_list';
709
		} else {
710
			$check_comment_disallowed_list = 'wp_blacklist_check';
711
		}
712
713
		if (
714
			call_user_func_array(
715
				$check_comment_disallowed_list,
716
				array(
717
					$form['comment_author'],
718
					$form['comment_author_email'],
719
					$form['comment_author_url'],
720
					$form['comment_content'],
721
					$form['user_ip'],
722
					$form['user_agent'],
723
				)
724
			)
725
		) {
726
			return true;
727
		}
728
729
		return false;
730
	}
731
732
	/**
733
	 * Populate an array with all values necessary to submit a NEW contact-form feedback to Akismet.
734
	 * Note that this includes the current user_ip etc, so this should only be called when accepting a new item via $_POST
735
	 *
736
	 * @param array $form Contact form feedback array
737
	 * @return array feedback array with additional data ready for submission to Akismet
738
	 */
739
	function prepare_for_akismet( $form ) {
740
		$form['comment_type'] = 'contact_form';
741
		$form['user_ip']      = $_SERVER['REMOTE_ADDR'];
742
		$form['user_agent']   = isset( $_SERVER['HTTP_USER_AGENT'] ) ? $_SERVER['HTTP_USER_AGENT'] : '';
743
		$form['referrer']     = isset( $_SERVER['HTTP_REFERER'] ) ? $_SERVER['HTTP_REFERER'] : '';
744
		$form['blog']         = get_option( 'home' );
745
746
		foreach ( $_SERVER as $key => $value ) {
747
			if ( ! is_string( $value ) ) {
748
				continue;
749
			}
750
			if ( in_array( $key, array( 'HTTP_COOKIE', 'HTTP_COOKIE2', 'HTTP_USER_AGENT', 'HTTP_REFERER' ) ) ) {
751
				// We don't care about cookies, and the UA and Referrer were caught above.
752
				continue;
753
			} elseif ( in_array( $key, array( 'REMOTE_ADDR', 'REQUEST_URI', 'DOCUMENT_URI' ) ) ) {
754
				// All three of these are relevant indicators and should be passed along.
755
				$form[ $key ] = $value;
756
			} elseif ( wp_startswith( $key, 'HTTP_' ) ) {
757
				// Any other HTTP header indicators.
758
				// `wp_startswith()` is a wpcom helper function and is included in Jetpack via `functions.compat.php`
759
				$form[ $key ] = $value;
760
			}
761
		}
762
763
		return $form;
764
	}
765
766
	/**
767
	 * Submit contact-form data to Akismet to check for spam.
768
	 * If you're accepting a new item via $_POST, run it Grunion_Contact_Form_Plugin::prepare_for_akismet() first
769
	 * Attached to `jetpack_contact_form_is_spam`
770
	 *
771
	 * @param bool  $is_spam
772
	 * @param array $form
773
	 * @return bool|WP_Error TRUE => spam, FALSE => not spam, WP_Error => stop processing entirely
774
	 */
775
	function is_spam_akismet( $is_spam, $form = array() ) {
776
		global $akismet_api_host, $akismet_api_port;
777
778
		// The signature of this function changed from accepting just $form.
779
		// If something only sends an array, assume it's still using the old
780
		// signature and work around it.
781
		if ( empty( $form ) && is_array( $is_spam ) ) {
782
			$form    = $is_spam;
783
			$is_spam = false;
784
		}
785
786
		// If a previous filter has alrady marked this as spam, trust that and move on.
787
		if ( $is_spam ) {
788
			return $is_spam;
789
		}
790
791
		if ( ! function_exists( 'akismet_http_post' ) && ! defined( 'AKISMET_VERSION' ) ) {
792
			return false;
793
		}
794
795
		$query_string = http_build_query( $form );
796
797
		if ( method_exists( 'Akismet', 'http_post' ) ) {
798
			$response = Akismet::http_post( $query_string, 'comment-check' );
799
		} else {
800
			$response = akismet_http_post( $query_string, $akismet_api_host, '/1.1/comment-check', $akismet_api_port );
801
		}
802
803
		$result = false;
804
805
		if ( isset( $response[0]['x-akismet-pro-tip'] ) && 'discard' === trim( $response[0]['x-akismet-pro-tip'] ) && get_option( 'akismet_strictness' ) === '1' ) {
806
			$result = new WP_Error( 'feedback-discarded', __( 'Feedback discarded.', 'jetpack' ) );
807
		} elseif ( isset( $response[1] ) && 'true' == trim( $response[1] ) ) { // 'true' is spam
808
			$result = true;
809
		}
810
811
		/**
812
		 * Filter the results returned by Akismet for each submitted contact form.
813
		 *
814
		 * @module contact-form
815
		 *
816
		 * @since 1.3.1
817
		 *
818
		 * @param WP_Error|bool $result Is the submitted feedback spam.
819
		 * @param array|bool $form Submitted feedback.
820
		 */
821
		return apply_filters( 'contact_form_is_spam_akismet', $result, $form );
822
	}
823
824
	/**
825
	 * Submit a feedback as either spam or ham
826
	 *
827
	 * @param string $as Either 'spam' or 'ham'.
828
	 * @param array  $form the contact-form data
829
	 */
830
	function akismet_submit( $as, $form ) {
831
		global $akismet_api_host, $akismet_api_port;
832
833
		if ( ! in_array( $as, array( 'ham', 'spam' ) ) ) {
834
			return false;
835
		}
836
837
		$query_string = '';
838
		if ( is_array( $form ) ) {
839
			$query_string = http_build_query( $form );
840
		}
841
		if ( method_exists( 'Akismet', 'http_post' ) ) {
842
			$response = Akismet::http_post( $query_string, "submit-{$as}" );
843
		} else {
844
			$response = akismet_http_post( $query_string, $akismet_api_host, "/1.1/submit-{$as}", $akismet_api_port );
845
		}
846
847
		return trim( $response[1] );
848
	}
849
850
	/**
851
	 * Prints the menu
852
	 */
853
	function export_form() {
854
		$current_screen = get_current_screen();
855
		if ( ! in_array( $current_screen->id, array( 'edit-feedback', 'feedback_page_feedback-export' ) ) ) {
856
			return;
857
		}
858
859
		if ( ! current_user_can( 'export' ) ) {
860
			return;
861
		}
862
863
		// if there aren't any feedbacks, bail out
864
		if ( ! (int) wp_count_posts( 'feedback' )->publish ) {
865
			return;
866
		}
867
		?>
868
869
		<div id="feedback-export" style="display:none">
870
			<h2><?php _e( 'Export feedback as CSV', 'jetpack' ); ?></h2>
871
			<div class="clear"></div>
872
			<form action="<?php echo admin_url( 'admin-post.php' ); ?>" method="post" class="form">
873
				<?php wp_nonce_field( 'feedback_export', 'feedback_export_nonce' ); ?>
874
875
				<input name="action" value="feedback_export" type="hidden">
876
				<label for="post"><?php _e( 'Select feedback to download', 'jetpack' ); ?></label>
877
				<select name="post">
878
					<option value="all"><?php esc_html_e( 'All posts', 'jetpack' ); ?></option>
879
					<?php echo $this->get_feedbacks_as_options(); ?>
880
				</select>
881
882
				<br><br>
883
				<input type="submit" name="submit" id="submit" class="button button-primary" value="<?php esc_html_e( 'Download', 'jetpack' ); ?>">
884
			</form>
885
		</div>
886
887
		<?php
888
		// There aren't any usable actions in core to output the "export feedback" form in the correct place,
889
		// so this inline JS moves it from the top of the page to the bottom.
890
		?>
891
		<script type='text/javascript'>
892
		    var menu = document.getElementById( 'feedback-export' ),
893
                wrapper = document.getElementsByClassName( 'wrap' )[0];
894
            <?php if ( 'edit-feedback' === $current_screen->id ) : ?>
895
            wrapper.appendChild(menu);
896
            <?php endif; ?>
897
            menu.style.display = 'block';
898
		</script>
899
		<?php
900
	}
901
902
	/**
903
	 * Fetch post content for a post and extract just the comment.
904
	 *
905
	 * @param int $post_id The post id to fetch the content for.
906
	 *
907
	 * @return string Trimmed post comment.
908
	 *
909
	 * @codeCoverageIgnore
910
	 */
911
	public function get_post_content_for_csv_export( $post_id ) {
912
		$post_content = get_post_field( 'post_content', $post_id );
913
		$content      = explode( '<!--more-->', $post_content );
914
915
		return trim( $content[0] );
916
	}
917
918
	/**
919
	 * Get `_feedback_extra_fields` field from post meta data.
920
	 *
921
	 * @param int $post_id Id of the post to fetch meta data for.
922
	 *
923
	 * @return mixed
924
	 */
925
	public function get_post_meta_for_csv_export( $post_id ) {
926
		$md                  = get_post_meta( $post_id, '_feedback_extra_fields', true );
927
		$md['feedback_date'] = get_the_date( DATE_RFC3339, $post_id );
928
		$content_fields      = self::parse_fields_from_content( $post_id );
929
		$md['feedback_ip']   = ( isset( $content_fields['_feedback_ip'] ) ) ? $content_fields['_feedback_ip'] : 0;
930
931
		// add the email_marketing_consent to the post meta.
932
		$md['email_marketing_consent'] = 0;
933
		if ( isset( $content_fields['_feedback_all_fields'] ) ) {
934
			$all_fields = $content_fields['_feedback_all_fields'];
935
			// check if the email_marketing_consent field exists.
936
			if ( isset( $all_fields['email_marketing_consent'] ) ) {
937
				$md['email_marketing_consent'] = $all_fields['email_marketing_consent'];
938
			}
939
		}
940
941
		return $md;
942
	}
943
944
	/**
945
	 * Get parsed feedback post fields.
946
	 *
947
	 * @param int $post_id Id of the post to fetch parsed contents for.
948
	 *
949
	 * @return array
950
	 *
951
	 * @codeCoverageIgnore - No need to be covered.
952
	 */
953
	public function get_parsed_field_contents_of_post( $post_id ) {
954
		return self::parse_fields_from_content( $post_id );
955
	}
956
957
	/**
958
	 * Properly maps fields that are missing from the post meta data
959
	 * to names, that are similar to those of the post meta.
960
	 *
961
	 * @param array $parsed_post_content Parsed post content
962
	 *
963
	 * @see parse_fields_from_content for how the input data is generated.
964
	 *
965
	 * @return array Mapped fields.
966
	 */
967
	public function map_parsed_field_contents_of_post_to_field_names( $parsed_post_content ) {
968
969
		$mapped_fields = array();
970
971
		$field_mapping = array(
972
			'_feedback_subject'      => __( 'Contact Form', 'jetpack' ),
973
			'_feedback_author'       => '1_Name',
974
			'_feedback_author_email' => '2_Email',
975
			'_feedback_author_url'   => '3_Website',
976
			'_feedback_main_comment' => '4_Comment',
977
			'_feedback_author_ip'    => '5_IP',
978
		);
979
980
		foreach ( $field_mapping as $parsed_field_name => $field_name ) {
981
			if (
982
				isset( $parsed_post_content[ $parsed_field_name ] )
983
				&& ! empty( $parsed_post_content[ $parsed_field_name ] )
984
			) {
985
				$mapped_fields[ $field_name ] = $parsed_post_content[ $parsed_field_name ];
986
			}
987
		}
988
989
		return $mapped_fields;
990
	}
991
992
	/**
993
	 * Registers the personal data exporter.
994
	 *
995
	 * @since 6.1.1
996
	 *
997
	 * @param  array $exporters An array of personal data exporters.
998
	 *
999
	 * @return array $exporters An array of personal data exporters.
1000
	 */
1001
	public function register_personal_data_exporter( $exporters ) {
1002
		$exporters['jetpack-feedback'] = array(
1003
			'exporter_friendly_name' => __( 'Feedback', 'jetpack' ),
1004
			'callback'               => array( $this, 'personal_data_exporter' ),
1005
		);
1006
1007
		return $exporters;
1008
	}
1009
1010
	/**
1011
	 * Registers the personal data eraser.
1012
	 *
1013
	 * @since 6.1.1
1014
	 *
1015
	 * @param  array $erasers An array of personal data erasers.
1016
	 *
1017
	 * @return array $erasers An array of personal data erasers.
1018
	 */
1019
	public function register_personal_data_eraser( $erasers ) {
1020
		$erasers['jetpack-feedback'] = array(
1021
			'eraser_friendly_name' => __( 'Feedback', 'jetpack' ),
1022
			'callback'             => array( $this, 'personal_data_eraser' ),
1023
		);
1024
1025
		return $erasers;
1026
	}
1027
1028
	/**
1029
	 * Exports personal data.
1030
	 *
1031
	 * @since 6.1.1
1032
	 *
1033
	 * @param  string $email  Email address.
1034
	 * @param  int    $page   Page to export.
1035
	 *
1036
	 * @return array  $return Associative array with keys expected by core.
1037
	 */
1038
	public function personal_data_exporter( $email, $page = 1 ) {
1039
		return $this->_internal_personal_data_exporter( $email, $page );
1040
	}
1041
1042
	/**
1043
	 * Internal method for exporting personal data.
1044
	 *
1045
	 * Allows us to have a different signature than core expects
1046
	 * while protecting against future core API changes.
1047
	 *
1048
	 * @internal
1049
	 * @since 6.5
1050
	 *
1051
	 * @param  string $email    Email address.
1052
	 * @param  int    $page     Page to export.
1053
	 * @param  int    $per_page Number of feedbacks to process per page. Internal use only (testing)
1054
	 *
1055
	 * @return array            Associative array with keys expected by core.
1056
	 */
1057
	public function _internal_personal_data_exporter( $email, $page = 1, $per_page = 250 ) {
1058
		$export_data = array();
1059
		$post_ids    = $this->personal_data_post_ids_by_email( $email, $per_page, $page );
1060
1061
		foreach ( $post_ids as $post_id ) {
1062
			$post_fields = $this->get_parsed_field_contents_of_post( $post_id );
1063
1064
			if ( ! is_array( $post_fields ) || empty( $post_fields['_feedback_subject'] ) ) {
1065
				continue; // Corrupt data.
1066
			}
1067
1068
			$post_fields['_feedback_main_comment'] = $this->get_post_content_for_csv_export( $post_id );
1069
			$post_fields                           = $this->map_parsed_field_contents_of_post_to_field_names( $post_fields );
1070
1071
			if ( ! is_array( $post_fields ) || empty( $post_fields ) ) {
1072
				continue; // No fields to export.
1073
			}
1074
1075
			$post_meta = $this->get_post_meta_for_csv_export( $post_id );
1076
			$post_meta = is_array( $post_meta ) ? $post_meta : array();
1077
1078
			$post_export_data = array();
1079
			$post_data        = array_merge( $post_fields, $post_meta );
1080
			ksort( $post_data );
1081
1082
			foreach ( $post_data as $post_data_key => $post_data_value ) {
1083
				$post_export_data[] = array(
1084
					'name'  => preg_replace( '/^[0-9]+_/', '', $post_data_key ),
1085
					'value' => $post_data_value,
1086
				);
1087
			}
1088
1089
			$export_data[] = array(
1090
				'group_id'    => 'feedback',
1091
				'group_label' => __( 'Feedback', 'jetpack' ),
1092
				'item_id'     => 'feedback-' . $post_id,
1093
				'data'        => $post_export_data,
1094
			);
1095
		}
1096
1097
		return array(
1098
			'data' => $export_data,
1099
			'done' => count( $post_ids ) < $per_page,
1100
		);
1101
	}
1102
1103
	/**
1104
	 * Erases personal data.
1105
	 *
1106
	 * @since 6.1.1
1107
	 *
1108
	 * @param  string $email Email address.
1109
	 * @param  int    $page  Page to erase.
1110
	 *
1111
	 * @return array         Associative array with keys expected by core.
1112
	 */
1113
	public function personal_data_eraser( $email, $page = 1 ) {
1114
		return $this->_internal_personal_data_eraser( $email, $page );
1115
	}
1116
1117
	/**
1118
	 * Internal method for erasing personal data.
1119
	 *
1120
	 * Allows us to have a different signature than core expects
1121
	 * while protecting against future core API changes.
1122
	 *
1123
	 * @internal
1124
	 * @since 6.5
1125
	 *
1126
	 * @param  string $email    Email address.
1127
	 * @param  int    $page     Page to erase.
1128
	 * @param  int    $per_page Number of feedbacks to process per page. Internal use only (testing)
1129
	 *
1130
	 * @return array            Associative array with keys expected by core.
1131
	 */
1132
	public function _internal_personal_data_eraser( $email, $page = 1, $per_page = 250 ) {
1133
		$removed      = false;
1134
		$retained     = false;
1135
		$messages     = array();
1136
		$option_name  = sprintf( '_jetpack_pde_feedback_%s', md5( $email ) );
1137
		$last_post_id = 1 === $page ? 0 : get_option( $option_name, 0 );
1138
		$post_ids     = $this->personal_data_post_ids_by_email( $email, $per_page, $page, $last_post_id );
1139
1140
		foreach ( $post_ids as $post_id ) {
1141
			/**
1142
			 * Filters whether to erase a particular Feedback post.
1143
			 *
1144
			 * @since 6.3.0
1145
			 *
1146
			 * @param bool|string $prevention_message Whether to apply erase the Feedback post (bool).
1147
			 *                                        Custom prevention message (string). Default true.
1148
			 * @param int         $post_id            Feedback post ID.
1149
			 */
1150
			$prevention_message = apply_filters( 'grunion_contact_form_delete_feedback_post', true, $post_id );
1151
1152
			if ( true !== $prevention_message ) {
1153
				if ( $prevention_message && is_string( $prevention_message ) ) {
1154
					$messages[] = esc_html( $prevention_message );
1155
				} else {
1156
					$messages[] = sprintf(
1157
					// translators: %d: Post ID.
1158
						__( 'Feedback ID %d could not be removed at this time.', 'jetpack' ),
1159
						$post_id
1160
					);
1161
				}
1162
1163
				$retained = true;
1164
1165
				continue;
1166
			}
1167
1168
			if ( wp_delete_post( $post_id, true ) ) {
1169
				$removed = true;
1170
			} else {
1171
				$retained   = true;
1172
				$messages[] = sprintf(
1173
				// translators: %d: Post ID.
1174
					__( 'Feedback ID %d could not be removed at this time.', 'jetpack' ),
1175
					$post_id
1176
				);
1177
			}
1178
		}
1179
1180
		$done = count( $post_ids ) < $per_page;
1181
1182
		if ( $done ) {
1183
			delete_option( $option_name );
1184
		} else {
1185
			update_option( $option_name, (int) $post_id );
1186
		}
1187
1188
		return array(
1189
			'items_removed'  => $removed,
1190
			'items_retained' => $retained,
1191
			'messages'       => $messages,
1192
			'done'           => $done,
1193
		);
1194
	}
1195
1196
	/**
1197
	 * Queries personal data by email address.
1198
	 *
1199
	 * @since 6.1.1
1200
	 *
1201
	 * @param  string $email        Email address.
1202
	 * @param  int    $per_page     Post IDs per page. Default is `250`.
1203
	 * @param  int    $page         Page to query. Default is `1`.
1204
	 * @param  int    $last_post_id Page to query. Default is `0`. If non-zero, used instead of $page.
1205
	 *
1206
	 * @return array An array of post IDs.
1207
	 */
1208
	public function personal_data_post_ids_by_email( $email, $per_page = 250, $page = 1, $last_post_id = 0 ) {
1209
		add_filter( 'posts_search', array( $this, 'personal_data_search_filter' ) );
1210
1211
		$this->pde_last_post_id_erased = $last_post_id;
1212
		$this->pde_email_address       = $email;
1213
1214
		$post_ids = get_posts(
1215
			array(
1216
				'post_type'        => 'feedback',
1217
				'post_status'      => 'publish',
1218
				// This search parameter gets overwritten in ->personal_data_search_filter()
1219
				's'                => '..PDE..AUTHOR EMAIL:..PDE..',
1220
				'sentence'         => true,
1221
				'order'            => 'ASC',
1222
				'orderby'          => 'ID',
1223
				'fields'           => 'ids',
1224
				'posts_per_page'   => $per_page,
1225
				'paged'            => $last_post_id ? 1 : $page,
1226
				'suppress_filters' => false,
1227
			)
1228
		);
1229
1230
		$this->pde_last_post_id_erased = 0;
1231
		$this->pde_email_address       = '';
1232
1233
		remove_filter( 'posts_search', array( $this, 'personal_data_search_filter' ) );
1234
1235
		return $post_ids;
1236
	}
1237
1238
	/**
1239
	 * Filters searches by email address.
1240
	 *
1241
	 * @since 6.1.1
1242
	 *
1243
	 * @param  string $search SQL where clause.
1244
	 *
1245
	 * @return array          Filtered SQL where clause.
1246
	 */
1247
	public function personal_data_search_filter( $search ) {
1248
		global $wpdb;
1249
1250
		/*
1251
		 * Limits search to `post_content` only, and we only match the
1252
		 * author's email address whenever it's on a line by itself.
1253
		 */
1254
		if ( $this->pde_email_address && false !== strpos( $search, '..PDE..AUTHOR EMAIL:..PDE..' ) ) {
1255
			$search = $wpdb->prepare(
1256
				" AND (
1257
					{$wpdb->posts}.post_content LIKE %s
1258
					OR {$wpdb->posts}.post_content LIKE %s
1259
				)",
1260
				// `chr( 10 )` = `\n`, `chr( 13 )` = `\r`
1261
				'%' . $wpdb->esc_like( chr( 10 ) . 'AUTHOR EMAIL: ' . $this->pde_email_address . chr( 10 ) ) . '%',
1262
				'%' . $wpdb->esc_like( chr( 13 ) . 'AUTHOR EMAIL: ' . $this->pde_email_address . chr( 13 ) ) . '%'
1263
			);
1264
1265
			if ( $this->pde_last_post_id_erased ) {
1266
				$search .= $wpdb->prepare( " AND {$wpdb->posts}.ID > %d", $this->pde_last_post_id_erased );
1267
			}
1268
		}
1269
1270
		return $search;
1271
	}
1272
1273
	/**
1274
	 * Prepares feedback post data for CSV export.
1275
	 *
1276
	 * @param array $post_ids Post IDs to fetch the data for. These need to be Feedback posts.
1277
	 *
1278
	 * @return array
1279
	 */
1280
	public function get_export_data_for_posts( $post_ids ) {
1281
1282
		$posts_data  = array();
1283
		$field_names = array();
1284
		$result      = array();
1285
1286
		/**
1287
		 * Fetch posts and get the possible field names for later use
1288
		 */
1289
		foreach ( $post_ids as $post_id ) {
1290
1291
			/**
1292
			 * Fetch post main data, because we need the subject and author data for the feedback form.
1293
			 */
1294
			$post_real_data = $this->get_parsed_field_contents_of_post( $post_id );
1295
1296
			/**
1297
			 * If `$post_real_data` is not an array or there is no `_feedback_subject` set,
1298
			 * then something must be wrong with the feedback post. Skip it.
1299
			 */
1300
			if ( ! is_array( $post_real_data ) || ! isset( $post_real_data['_feedback_subject'] ) ) {
1301
				continue;
1302
			}
1303
1304
			/**
1305
			 * Fetch main post comment. This is from the default textarea fields.
1306
			 * If it is non-empty, then we add it to data, otherwise skip it.
1307
			 */
1308
			$post_comment_content = $this->get_post_content_for_csv_export( $post_id );
1309
			if ( ! empty( $post_comment_content ) ) {
1310
				$post_real_data['_feedback_main_comment'] = $post_comment_content;
1311
			}
1312
1313
			/**
1314
			 * Map parsed fields to proper field names
1315
			 */
1316
			$mapped_fields = $this->map_parsed_field_contents_of_post_to_field_names( $post_real_data );
1317
1318
			/**
1319
			 * Fetch post meta data.
1320
			 */
1321
			$post_meta_data = $this->get_post_meta_for_csv_export( $post_id );
1322
1323
			/**
1324
			 * If `$post_meta_data` is not an array or if it is empty, then there is no
1325
			 * extra feedback to work with. Create an empty array.
1326
			 */
1327
			if ( ! is_array( $post_meta_data ) || empty( $post_meta_data ) ) {
1328
				$post_meta_data = array();
1329
			}
1330
1331
			/**
1332
			 * Prepend the feedback subject to the list of fields.
1333
			 */
1334
			$post_meta_data = array_merge(
1335
				$mapped_fields,
1336
				$post_meta_data
1337
			);
1338
1339
			/**
1340
			 * Save post metadata for later usage.
1341
			 */
1342
			$posts_data[ $post_id ] = $post_meta_data;
1343
1344
			/**
1345
			 * Save field names, so we can use them as header fields later in the CSV.
1346
			 */
1347
			$field_names = array_merge( $field_names, array_keys( $post_meta_data ) );
1348
		}
1349
1350
		/**
1351
		 * Make sure the field names are unique, because we don't want duplicate data.
1352
		 */
1353
		$field_names = array_unique( $field_names );
1354
1355
		/**
1356
		 * Sort the field names by the field id number
1357
		 */
1358
		sort( $field_names, SORT_NUMERIC );
1359
1360
		/**
1361
		 * Loop through every post, which is essentially CSV row.
1362
		 */
1363
		foreach ( $posts_data as $post_id => $single_post_data ) {
1364
1365
			/**
1366
			 * Go through all the possible fields and check if the field is available
1367
			 * in the current post.
1368
			 *
1369
			 * If it is - add the data as a value.
1370
			 * If it is not - add an empty string, which is just a placeholder in the CSV.
1371
			 */
1372
			foreach ( $field_names as $single_field_name ) {
1373
				if (
1374
					isset( $single_post_data[ $single_field_name ] )
1375
					&& ! empty( $single_post_data[ $single_field_name ] )
1376
				) {
1377
					$result[ $single_field_name ][] = trim( $single_post_data[ $single_field_name ] );
1378
				} else {
1379
					$result[ $single_field_name ][] = '';
1380
				}
1381
			}
1382
		}
1383
1384
		return $result;
1385
	}
1386
1387
	/**
1388
	 * download as a csv a contact form or all of them in a csv file
1389
	 */
1390
	function download_feedback_as_csv() {
1391
		if ( empty( $_POST['feedback_export_nonce'] ) ) {
1392
			return;
1393
		}
1394
1395
		check_admin_referer( 'feedback_export', 'feedback_export_nonce' );
1396
1397
		if ( ! current_user_can( 'export' ) ) {
1398
			return;
1399
		}
1400
1401
		$args = array(
1402
			'posts_per_page'   => -1,
1403
			'post_type'        => 'feedback',
1404
			'post_status'      => 'publish',
1405
			'order'            => 'ASC',
1406
			'fields'           => 'ids',
1407
			'suppress_filters' => false,
1408
		);
1409
1410
		$filename = date( 'Y-m-d' ) . '-feedback-export.csv';
1411
1412
		// Check if we want to download all the feedbacks or just a certain contact form
1413
		if ( ! empty( $_POST['post'] ) && $_POST['post'] !== 'all' ) {
1414
			$args['post_parent'] = (int) $_POST['post'];
1415
			$filename            = date( 'Y-m-d' ) . '-' . str_replace( '&nbsp;', '-', get_the_title( (int) $_POST['post'] ) ) . '.csv';
1416
		}
1417
1418
		$feedbacks = get_posts( $args );
1419
1420
		if ( empty( $feedbacks ) ) {
1421
			return;
1422
		}
1423
1424
		$filename = sanitize_file_name( $filename );
1425
1426
		/**
1427
		 * Prepare data for export.
1428
		 */
1429
		$data = $this->get_export_data_for_posts( $feedbacks );
1430
1431
		/**
1432
		 * If `$data` is empty, there's nothing we can do below.
1433
		 */
1434
		if ( ! is_array( $data ) || empty( $data ) ) {
1435
			return;
1436
		}
1437
1438
		/**
1439
		 * Extract field names from `$data` for later use.
1440
		 */
1441
		$fields = array_keys( $data );
1442
1443
		/**
1444
		 * Count how many rows will be exported.
1445
		 */
1446
		$row_count = count( reset( $data ) );
1447
1448
		// Forces the download of the CSV instead of echoing
1449
		header( 'Content-Disposition: attachment; filename=' . $filename );
1450
		header( 'Pragma: no-cache' );
1451
		header( 'Expires: 0' );
1452
		header( 'Content-Type: text/csv; charset=utf-8' );
1453
1454
		$output = fopen( 'php://output', 'w' );
1455
1456
		/**
1457
		 * Print CSV headers
1458
		 */
1459
		fputcsv( $output, $fields );
1460
1461
		/**
1462
		 * Print rows to the output.
1463
		 */
1464
		for ( $i = 0; $i < $row_count; $i ++ ) {
1465
1466
			$current_row = array();
1467
1468
			/**
1469
			 * Put all the fields in `$current_row` array.
1470
			 */
1471
			foreach ( $fields as $single_field_name ) {
1472
				$current_row[] = $this->esc_csv( $data[ $single_field_name ][ $i ] );
1473
			}
1474
1475
			/**
1476
			 * Output the complete CSV row
1477
			 */
1478
			fputcsv( $output, $current_row );
1479
		}
1480
1481
		fclose( $output );
1482
	}
1483
1484
	/**
1485
	 * Escape a string to be used in a CSV context
1486
	 *
1487
	 * Malicious input can inject formulas into CSV files, opening up the possibility for phishing attacks and
1488
	 * disclosure of sensitive information.
1489
	 *
1490
	 * Additionally, Excel exposes the ability to launch arbitrary commands through the DDE protocol.
1491
	 *
1492
	 * @see https://www.contextis.com/en/blog/comma-separated-vulnerabilities
1493
	 *
1494
	 * @param string $field
1495
	 *
1496
	 * @return string
1497
	 */
1498
	public function esc_csv( $field ) {
1499
		$active_content_triggers = array( '=', '+', '-', '@' );
1500
1501
		if ( in_array( mb_substr( $field, 0, 1 ), $active_content_triggers, true ) ) {
1502
			$field = "'" . $field;
1503
		}
1504
1505
		return $field;
1506
	}
1507
1508
	/**
1509
	 * Returns a string of HTML <option> items from an array of posts
1510
	 *
1511
	 * @return string a string of HTML <option> items
1512
	 */
1513
	protected function get_feedbacks_as_options() {
1514
		$options = '';
1515
1516
		// Get the feedbacks' parents' post IDs
1517
		$feedbacks = get_posts(
1518
			array(
1519
				'fields'           => 'id=>parent',
1520
				'posts_per_page'   => 100000,
1521
				'post_type'        => 'feedback',
1522
				'post_status'      => 'publish',
1523
				'suppress_filters' => false,
1524
			)
1525
		);
1526
		$parents   = array_unique( array_values( $feedbacks ) );
1527
1528
		$posts = get_posts(
1529
			array(
1530
				'orderby'          => 'ID',
1531
				'posts_per_page'   => 1000,
1532
				'post_type'        => 'any',
1533
				'post__in'         => array_values( $parents ),
1534
				'suppress_filters' => false,
1535
			)
1536
		);
1537
1538
		// creates the string of <option> elements
1539
		foreach ( $posts as $post ) {
1540
			$options .= sprintf( '<option value="%s">%s</option>', esc_attr( $post->ID ), esc_html( $post->post_title ) );
1541
		}
1542
1543
		return $options;
1544
	}
1545
1546
	/**
1547
	 * Get the names of all the form's fields
1548
	 *
1549
	 * @param  array|int $posts the post we want the fields of
1550
	 *
1551
	 * @return array     the array of fields
1552
	 *
1553
	 * @deprecated As this is no longer necessary as of the CSV export rewrite. - 2015-12-29
1554
	 */
1555
	protected function get_field_names( $posts ) {
1556
		$posts      = (array) $posts;
1557
		$all_fields = array();
1558
1559
		foreach ( $posts as $post ) {
1560
			$fields = self::parse_fields_from_content( $post );
1561
1562
			if ( isset( $fields['_feedback_all_fields'] ) ) {
1563
				$extra_fields = array_keys( $fields['_feedback_all_fields'] );
1564
				$all_fields   = array_merge( $all_fields, $extra_fields );
1565
			}
1566
		}
1567
1568
		$all_fields = array_unique( $all_fields );
1569
		return $all_fields;
1570
	}
1571
1572
	public static function parse_fields_from_content( $post_id ) {
1573
		static $post_fields;
1574
1575
		if ( ! is_array( $post_fields ) ) {
1576
			$post_fields = array();
1577
		}
1578
1579
		if ( isset( $post_fields[ $post_id ] ) ) {
1580
			return $post_fields[ $post_id ];
1581
		}
1582
1583
		$all_values   = array();
1584
		$post_content = get_post_field( 'post_content', $post_id );
1585
		$content      = explode( '<!--more-->', $post_content );
1586
		$lines        = array();
1587
1588
		if ( count( $content ) > 1 ) {
1589
			$content  = str_ireplace( array( '<br />', ')</p>' ), '', $content[1] );
1590
			$one_line = preg_replace( '/\s+/', ' ', $content );
1591
			$one_line = preg_replace( '/.*Array \( (.*)\)/', '$1', $one_line );
1592
1593
			preg_match_all( '/\[([^\]]+)\] =\&gt\; ([^\[]+)/', $one_line, $matches );
1594
1595
			if ( count( $matches ) > 1 ) {
1596
				$all_values = array_combine( array_map( 'trim', $matches[1] ), array_map( 'trim', $matches[2] ) );
1597
			}
1598
1599
			$lines = array_filter( explode( "\n", $content ) );
1600
		}
1601
1602
		$var_map = array(
1603
			'AUTHOR'       => '_feedback_author',
1604
			'AUTHOR EMAIL' => '_feedback_author_email',
1605
			'AUTHOR URL'   => '_feedback_author_url',
1606
			'SUBJECT'      => '_feedback_subject',
1607
			'IP'           => '_feedback_ip',
1608
		);
1609
1610
		$fields = array();
1611
1612
		foreach ( $lines as $line ) {
1613
			$vars = explode( ': ', $line, 2 );
1614
			if ( ! empty( $vars ) ) {
1615
				if ( isset( $var_map[ $vars[0] ] ) ) {
1616
					$fields[ $var_map[ $vars[0] ] ] = self::strip_tags( trim( $vars[1] ) );
1617
				}
1618
			}
1619
		}
1620
1621
		$fields['_feedback_all_fields'] = $all_values;
1622
1623
		$post_fields[ $post_id ] = $fields;
1624
1625
		return $fields;
1626
	}
1627
1628
	/**
1629
	 * Creates a valid csv row from a post id
1630
	 *
1631
	 * @param  int   $post_id The id of the post
1632
	 * @param  array $fields  An array containing the names of all the fields of the csv
1633
	 * @return String The csv row
1634
	 *
1635
	 * @deprecated This is no longer needed, as of the CSV export rewrite.
1636
	 */
1637
	protected static function make_csv_row_from_feedback( $post_id, $fields ) {
1638
		$content_fields = self::parse_fields_from_content( $post_id );
1639
		$all_fields     = array();
1640
1641
		if ( isset( $content_fields['_feedback_all_fields'] ) ) {
1642
			$all_fields = $content_fields['_feedback_all_fields'];
1643
		}
1644
1645
		// Overwrite the parsed content with the content we stored in post_meta in a better format.
1646
		$extra_fields = get_post_meta( $post_id, '_feedback_extra_fields', true );
1647
		foreach ( $extra_fields as $extra_field => $extra_value ) {
1648
			$all_fields[ $extra_field ] = $extra_value;
1649
		}
1650
1651
		// The first element in all of the exports will be the subject
1652
		$row_items[] = $content_fields['_feedback_subject'];
1653
1654
		// Loop the fields array in order to fill the $row_items array correctly
1655
		foreach ( $fields as $field ) {
1656
			if ( $field === __( 'Contact Form', 'jetpack' ) ) { // the first field will ever be the contact form, so we can continue
1657
				continue;
1658
			} elseif ( array_key_exists( $field, $all_fields ) ) {
1659
				$row_items[] = $all_fields[ $field ];
1660
			} else {
1661
				$row_items[] = '';
1662
			}
1663
		}
1664
1665
		return $row_items;
1666
	}
1667
1668
	public static function get_ip_address() {
1669
		return isset( $_SERVER['REMOTE_ADDR'] ) ? $_SERVER['REMOTE_ADDR'] : null;
1670
	}
1671
}
1672
1673
/**
1674
 * Generic shortcode class.
1675
 * Does nothing other than store structured data and output the shortcode as a string
1676
 *
1677
 * Not very general - specific to Grunion.
1678
 */
1679
class Crunion_Contact_Form_Shortcode {
1680
	/**
1681
	 * @var string the name of the shortcode: [$shortcode_name /]
1682
	 */
1683
	public $shortcode_name;
1684
1685
	/**
1686
	 * @var array key => value pairs for the shortcode's attributes: [$shortcode_name key="value" ... /]
1687
	 */
1688
	public $attributes;
1689
1690
	/**
1691
	 * @var array key => value pair for attribute defaults
1692
	 */
1693
	public $defaults = array();
1694
1695
	/**
1696
	 * @var null|string Null for selfclosing shortcodes.  Hhe inner content of otherwise: [$shortcode_name]$content[/$shortcode_name]
1697
	 */
1698
	public $content;
1699
1700
	/**
1701
	 * @var array Associative array of inner "child" shortcodes equivalent to the $content: [$shortcode_name][child 1/][child 2/][/$shortcode_name]
1702
	 */
1703
	public $fields;
1704
1705
	/**
1706
	 * @var null|string The HTML of the parsed inner "child" shortcodes".  Null for selfclosing shortcodes.
1707
	 */
1708
	public $body;
1709
1710
	/**
1711
	 * @param array       $attributes An associative array of shortcode attributes.  @see shortcode_atts()
1712
	 * @param null|string $content Null for selfclosing shortcodes.  The inner content otherwise.
1713
	 */
1714
	function __construct( $attributes, $content = null ) {
1715
		$this->attributes = $this->unesc_attr( $attributes );
1716
		if ( is_array( $content ) ) {
1717
			$string_content = '';
1718
			foreach ( $content as $field ) {
1719
				$string_content .= (string) $field;
1720
			}
1721
1722
			$this->content = $string_content;
1723
		} else {
1724
			$this->content = $content;
1725
		}
1726
1727
		$this->parse_content( $this->content );
1728
	}
1729
1730
	/**
1731
	 * Processes the shortcode's inner content for "child" shortcodes
1732
	 *
1733
	 * @param string $content The shortcode's inner content: [shortcode]$content[/shortcode]
1734
	 */
1735
	function parse_content( $content ) {
1736
		if ( is_null( $content ) ) {
1737
			$this->body = null;
1738
		}
1739
1740
		$this->body = do_shortcode( $content );
1741
	}
1742
1743
	/**
1744
	 * Returns the value of the requested attribute.
1745
	 *
1746
	 * @param string $key The attribute to retrieve
1747
	 * @return mixed
1748
	 */
1749
	function get_attribute( $key ) {
1750
		return isset( $this->attributes[ $key ] ) ? $this->attributes[ $key ] : null;
1751
	}
1752
1753
	function esc_attr( $value ) {
1754
		if ( is_array( $value ) ) {
1755
			return array_map( array( $this, 'esc_attr' ), $value );
1756
		}
1757
1758
		$value = Grunion_Contact_Form_Plugin::strip_tags( $value );
1759
		$value = _wp_specialchars( $value, ENT_QUOTES, false, true );
1760
1761
		// Shortcode attributes can't contain "]"
1762
		$value = str_replace( ']', '', $value );
1763
		$value = str_replace( ',', '&#x002c;', $value ); // store commas encoded
1764
		$value = strtr(
1765
			$value, array(
1766
				'%' => '%25',
1767
				'&' => '%26',
1768
			)
1769
		);
1770
1771
		// shortcode_parse_atts() does stripcslashes()
1772
		$value = addslashes( $value );
1773
		return $value;
1774
	}
1775
1776
	function unesc_attr( $value ) {
1777
		if ( is_array( $value ) ) {
1778
			return array_map( array( $this, 'unesc_attr' ), $value );
1779
		}
1780
1781
		// For back-compat with old Grunion encoding
1782
		// Also, unencode commas
1783
		$value = strtr(
1784
			$value, array(
1785
				'%26' => '&',
1786
				'%25' => '%',
1787
			)
1788
		);
1789
		$value = preg_replace( array( '/&#x0*22;/i', '/&#x0*27;/i', '/&#x0*26;/i', '/&#x0*2c;/i' ), array( '"', "'", '&', ',' ), $value );
1790
		$value = htmlspecialchars_decode( $value, ENT_QUOTES );
1791
		$value = Grunion_Contact_Form_Plugin::strip_tags( $value );
1792
1793
		return $value;
1794
	}
1795
1796
	/**
1797
	 * Generates the shortcode
1798
	 */
1799
	function __toString() {
1800
		$r = "[{$this->shortcode_name} ";
1801
1802
		foreach ( $this->attributes as $key => $value ) {
1803
			if ( ! $value ) {
1804
				continue;
1805
			}
1806
1807
			if ( isset( $this->defaults[ $key ] ) && $this->defaults[ $key ] == $value ) {
1808
				continue;
1809
			}
1810
1811
			if ( 'id' == $key ) {
1812
				continue;
1813
			}
1814
1815
			$value = $this->esc_attr( $value );
1816
1817
			if ( is_array( $value ) ) {
1818
				$value = join( ',', $value );
1819
			}
1820
1821
			if ( false === strpos( $value, "'" ) ) {
1822
				$value = "'$value'";
1823
			} elseif ( false === strpos( $value, '"' ) ) {
1824
				$value = '"' . $value . '"';
1825
			} else {
1826
				// Shortcodes can't contain both '"' and "'".  Strip one.
1827
				$value = str_replace( "'", '', $value );
1828
				$value = "'$value'";
1829
			}
1830
1831
			$r .= "{$key}={$value} ";
1832
		}
1833
1834
		$r = rtrim( $r );
1835
1836
		if ( $this->fields ) {
1837
			$r .= ']';
1838
1839
			foreach ( $this->fields as $field ) {
1840
				$r .= (string) $field;
1841
			}
1842
1843
			$r .= "[/{$this->shortcode_name}]";
1844
		} else {
1845
			$r .= '/]';
1846
		}
1847
1848
		return $r;
1849
	}
1850
}
1851
1852
/**
1853
 * Class for the contact-form shortcode.
1854
 * Parses shortcode to output the contact form as HTML
1855
 * Sends email and stores the contact form response (a.k.a. "feedback")
1856
 */
1857
class Grunion_Contact_Form extends Crunion_Contact_Form_Shortcode {
1858
	public $shortcode_name = 'contact-form';
1859
1860
	/**
1861
	 * @var WP_Error stores form submission errors
1862
	 */
1863
	public $errors;
1864
1865
	/**
1866
	 * @var string The SHA1 hash of the attributes that comprise the form.
1867
	 */
1868
	public $hash;
1869
1870
	/**
1871
	 * @var Grunion_Contact_Form The most recent (inclusive) contact-form shortcode processed
1872
	 */
1873
	static $last;
1874
1875
	/**
1876
	 * @var Whatever form we are currently looking at. If processed, will become $last
1877
	 */
1878
	static $current_form;
1879
1880
	/**
1881
	 * @var array All found forms, indexed by hash.
1882
	 */
1883
	static $forms = array();
1884
1885
	/**
1886
	 * @var bool Whether to print the grunion.css style when processing the contact-form shortcode
1887
	 */
1888
	static $style = false;
1889
1890
	/**
1891
	 * @var array When printing the submit button, what tags are allowed
1892
	 */
1893
	static $allowed_html_tags_for_submit_button = array( 'br' => array() );
1894
1895
	function __construct( $attributes, $content = null ) {
1896
		global $post;
1897
1898
		$this->hash                 = sha1( json_encode( $attributes ) . $content );
1899
		self::$forms[ $this->hash ] = $this;
1900
1901
		// Set up the default subject and recipient for this form.
1902
		$default_to      = '';
1903
		$default_subject = '[' . get_option( 'blogname' ) . ']';
1904
1905
		if ( ! isset( $attributes ) || ! is_array( $attributes ) ) {
1906
			$attributes = array();
1907
		}
1908
1909
		if ( ! empty( $attributes['widget'] ) && $attributes['widget'] ) {
1910
			$default_to      .= get_option( 'admin_email' );
1911
			$attributes['id'] = 'widget-' . $attributes['widget'];
1912
			$default_subject  = sprintf( _x( '%1$s Sidebar', '%1$s = blog name', 'jetpack' ), $default_subject );
1913
		} elseif ( $post ) {
1914
			$attributes['id'] = $post->ID;
1915
			$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 ) );
1916
			$post_author      = get_userdata( $post->post_author );
1917
			$default_to      .= $post_author->user_email;
1918
		}
1919
1920
		// Keep reference to $this for parsing form fields.
1921
		self::$current_form = $this;
1922
1923
		$this->defaults = array(
1924
			'to'                     => $default_to,
1925
			'subject'                => $default_subject,
1926
			'show_subject'           => 'no', // only used in back-compat mode
1927
			'widget'                 => 0,    // Not exposed to the user. Works with Grunion_Contact_Form_Plugin::widget_atts()
1928
			'id'                     => null, // Not exposed to the user. Set above.
1929
			'submit_button_text'     => __( 'Submit', 'jetpack' ),
1930
			// These attributes come from the block editor, so use camel case instead of snake case.
1931
			'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.
1932
			'customThankyouMessage'  => __( 'Thank you for your submission!', 'jetpack' ), // The message to show when customThankyou is set to 'message'.
1933
			'customThankyouRedirect' => '', // The URL to redirect to when customThankyou is set to 'redirect'.
1934
			'jetpackCRM'             => true, // Whether Jetpack CRM should store the form submission.
1935
		);
1936
1937
		$attributes = shortcode_atts( $this->defaults, $attributes, 'contact-form' );
1938
1939
		// We only enable the contact-field shortcode temporarily while processing the contact-form shortcode.
1940
		Grunion_Contact_Form_Plugin::$using_contact_form_field = true;
1941
1942
		parent::__construct( $attributes, $content );
1943
1944
		// There were no fields in the contact form. The form was probably just [contact-form /]. Build a default form.
1945
		if ( empty( $this->fields ) ) {
1946
			// same as the original Grunion v1 form.
1947
			$default_form = '
1948
				[contact-field label="' . __( 'Name', 'jetpack' ) . '" type="name"  required="true" /]
1949
				[contact-field label="' . __( 'Email', 'jetpack' ) . '" type="email" required="true" /]
1950
				[contact-field label="' . __( 'Website', 'jetpack' ) . '" type="url" /]';
1951
1952
			if ( 'yes' == strtolower( $this->get_attribute( 'show_subject' ) ) ) {
1953
				$default_form .= '
1954
					[contact-field label="' . __( 'Subject', 'jetpack' ) . '" type="subject" /]';
1955
			}
1956
1957
			$default_form .= '
1958
				[contact-field label="' . __( 'Message', 'jetpack' ) . '" type="textarea" /]';
1959
1960
			$this->parse_content( $default_form );
1961
1962
			// Store the shortcode.
1963
			$this->store_shortcode( $default_form, $attributes, $this->hash );
1964
		} else {
1965
			// Store the shortcode.
1966
			$this->store_shortcode( $content, $attributes, $this->hash );
1967
		}
1968
1969
		// $this->body and $this->fields have been setup.  We no longer need the contact-field shortcode.
1970
		Grunion_Contact_Form_Plugin::$using_contact_form_field = false;
1971
	}
1972
1973
	/**
1974
	 * Store shortcode content for recall later
1975
	 *  - used to receate shortcode when user uses do_shortcode
1976
	 *
1977
	 * @param string $content
1978
	 * @param array $attributes
1979
	 * @param string $hash
1980
	 */
1981
	static function store_shortcode( $content = null, $attributes = null, $hash = null ) {
1982
1983
		if ( $content != null and isset( $attributes['id'] ) ) {
1984
1985
			if ( empty( $hash ) ) {
1986
				$hash = sha1( json_encode( $attributes ) . $content );
1987
			}
1988
1989
			$shortcode_meta = get_post_meta( $attributes['id'], "_g_feedback_shortcode_{$hash}", true );
1990
1991
			if ( $shortcode_meta != '' or $shortcode_meta != $content ) {
1992
				update_post_meta( $attributes['id'], "_g_feedback_shortcode_{$hash}", $content );
1993
1994
				// Save attributes to post_meta for later use. They're not available later in do_shortcode situations.
1995
				update_post_meta( $attributes['id'], "_g_feedback_shortcode_atts_{$hash}", $attributes );
1996
			}
1997
		}
1998
	}
1999
2000
	/**
2001
	 * Toggle for printing the grunion.css stylesheet
2002
	 *
2003
	 * @param bool $style
2004
	 */
2005
	static function style( $style ) {
2006
		$previous_style = self::$style;
2007
		self::$style    = (bool) $style;
2008
		return $previous_style;
2009
	}
2010
2011
	/**
2012
	 * Turn on printing of grunion.css stylesheet
2013
	 *
2014
	 * @see ::style()
2015
	 * @internal
2016
	 * @param bool $style
2017
	 */
2018
	static function _style_on() {
2019
		return self::style( true );
2020
	}
2021
2022
	/**
2023
	 * The contact-form shortcode processor
2024
	 *
2025
	 * @param array       $attributes Key => Value pairs as parsed by shortcode_parse_atts()
2026
	 * @param string|null $content The shortcode's inner content: [contact-form]$content[/contact-form]
2027
	 * @return string HTML for the concat form.
2028
	 */
2029
	static function parse( $attributes, $content ) {
2030
		if ( Settings::is_syncing() ) {
2031
			return '';
2032
		}
2033
		// Create a new Grunion_Contact_Form object (this class)
2034
		$form = new Grunion_Contact_Form( $attributes, $content );
2035
2036
		$id = $form->get_attribute( 'id' );
2037
2038
		if ( ! $id ) { // something terrible has happened
2039
			return '[contact-form]';
2040
		}
2041
2042
		if ( is_feed() ) {
2043
			return '[contact-form]';
2044
		}
2045
2046
		self::$last = $form;
2047
2048
		// Enqueue the grunion.css stylesheet if self::$style allows it
2049
		if ( self::$style && ( empty( $_REQUEST['action'] ) || $_REQUEST['action'] != 'grunion_shortcode_to_json' ) ) {
2050
			// Enqueue the style here instead of printing it, because if some other plugin has run the_post()+rewind_posts(),
2051
			// (like VideoPress does), the style tag gets "printed" the first time and discarded, leaving the contact form unstyled.
2052
			// when WordPress does the real loop.
2053
			wp_enqueue_style( 'grunion.css' );
2054
		}
2055
2056
		$r  = '';
2057
		$r .= "<div id='contact-form-$id'>\n";
2058
2059
		if ( is_wp_error( $form->errors ) && $form->errors->get_error_codes() ) {
2060
			// There are errors.  Display them
2061
			$r .= "<div class='form-error'>\n<h3>" . __( 'Error!', 'jetpack' ) . "</h3>\n<ul class='form-errors'>\n";
2062
			foreach ( $form->errors->get_error_messages() as $message ) {
2063
				$r .= "\t<li class='form-error-message'>" . esc_html( $message ) . "</li>\n";
2064
			}
2065
			$r .= "</ul>\n</div>\n\n";
2066
		}
2067
2068
		if ( isset( $_GET['contact-form-id'] )
2069
			&& (int) $_GET['contact-form-id'] === (int) self::$last->get_attribute( 'id' )
2070
			&& isset( $_GET['contact-form-sent'], $_GET['contact-form-hash'] )
2071
			&& is_string( $_GET['contact-form-hash'] )
2072
			&& hash_equals( $form->hash, $_GET['contact-form-hash'] ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended
2073
			// The contact form was submitted.  Show the success message/results.
2074
			$feedback_id = (int) $_GET['contact-form-sent'];
2075
2076
			$back_url = remove_query_arg( array( 'contact-form-id', 'contact-form-sent', '_wpnonce' ) );
2077
2078
			$r_success_message =
2079
				'<h3>' . __( 'Message Sent', 'jetpack' ) .
2080
				' (<a href="' . esc_url( $back_url ) . '">' . esc_html__( 'go back', 'jetpack' ) . '</a>)' .
2081
				"</h3>\n\n";
2082
2083
			// Don't show the feedback details unless the nonce matches
2084
			if ( $feedback_id && wp_verify_nonce( stripslashes( $_GET['_wpnonce'] ), "contact-form-sent-{$feedback_id}" ) ) {
2085
				$r_success_message .= self::success_message( $feedback_id, $form );
2086
			}
2087
2088
			/**
2089
			 * Filter the message returned after a successful contact form submission.
2090
			 *
2091
			 * @module contact-form
2092
			 *
2093
			 * @since 1.3.1
2094
			 *
2095
			 * @param string $r_success_message Success message.
2096
			 */
2097
			$r .= apply_filters( 'grunion_contact_form_success_message', $r_success_message );
2098
		} else {
2099
			// Nothing special - show the normal contact form
2100
			if ( $form->get_attribute( 'widget' ) ) {
2101
				// Submit form to the current URL
2102
				$url = remove_query_arg( array( 'contact-form-id', 'contact-form-sent', 'action', '_wpnonce' ) );
2103
			} else {
2104
				// Submit form to the post permalink
2105
				$url = get_permalink();
2106
			}
2107
2108
			// For SSL/TLS page. See RFC 3986 Section 4.2
2109
			$url = set_url_scheme( $url );
2110
2111
			// May eventually want to send this to admin-post.php...
2112
			/**
2113
			 * Filter the contact form action URL.
2114
			 *
2115
			 * @module contact-form
2116
			 *
2117
			 * @since 1.3.1
2118
			 *
2119
			 * @param string $contact_form_id Contact form post URL.
2120
			 * @param $post $GLOBALS['post'] Post global variable.
2121
			 * @param int $id Contact Form ID.
2122
			 */
2123
			$url                     = apply_filters( 'grunion_contact_form_form_action', "{$url}#contact-form-{$id}", $GLOBALS['post'], $id );
2124
			$has_submit_button_block = ! ( false === strpos( $content, 'wp-block-jetpack-button' ) );
2125
			$form_classes            = 'contact-form commentsblock';
2126
2127
			if ( $has_submit_button_block ) {
2128
				$form_classes .= ' wp-block-jetpack-contact-form';
2129
			}
2130
2131
			$r .= "<form action='" . esc_url( $url ) . "' method='post' class='" . esc_attr( $form_classes ) . "'>\n";
2132
			$r .= $form->body;
2133
2134
			// In new versions of the contact form block the button is an inner block
2135
			// so the button does not need to be constructed server-side.
2136
			if ( ! $has_submit_button_block ) {
2137
				$r .= "\t<p class='contact-submit'>\n";
2138
2139
				$gutenberg_submit_button_classes = '';
2140
				if ( ! empty( $attributes['submitButtonClasses'] ) ) {
2141
					$gutenberg_submit_button_classes = ' ' . $attributes['submitButtonClasses'];
2142
				}
2143
2144
				/**
2145
				 * Filter the contact form submit button class attribute.
2146
				 *
2147
				 * @module contact-form
2148
				 *
2149
				 * @since 6.6.0
2150
				 *
2151
				 * @param string $class Additional CSS classes for button attribute.
2152
				 */
2153
				$submit_button_class = apply_filters( 'jetpack_contact_form_submit_button_class', 'pushbutton-wide' . $gutenberg_submit_button_classes );
2154
2155
				$submit_button_styles = '';
2156
				if ( ! empty( $attributes['customBackgroundButtonColor'] ) ) {
2157
					$submit_button_styles .= 'background-color: ' . $attributes['customBackgroundButtonColor'] . '; ';
2158
				}
2159
				if ( ! empty( $attributes['customTextButtonColor'] ) ) {
2160
					$submit_button_styles .= 'color: ' . $attributes['customTextButtonColor'] . ';';
2161
				}
2162
				if ( ! empty( $attributes['submitButtonText'] ) ) {
2163
					$submit_button_text = $attributes['submitButtonText'];
2164
				} else {
2165
					$submit_button_text = $form->get_attribute( 'submit_button_text' );
2166
				}
2167
2168
				$r .= "\t\t<button type='submit' class='" . esc_attr( $submit_button_class ) . "'";
2169
				if ( ! empty( $submit_button_styles ) ) {
2170
					$r .= " style='" . esc_attr( $submit_button_styles ) . "'";
2171
				}
2172
				$r .= ">";
2173
				$r .= wp_kses(
2174
					      $submit_button_text,
2175
					      self::$allowed_html_tags_for_submit_button
2176
				      ) . "</button>";
2177
			}
2178
2179
			if ( is_user_logged_in() ) {
2180
				$r .= "\t\t" . wp_nonce_field( 'contact-form_' . $id, '_wpnonce', true, false ) . "\n"; // nonce and referer
2181
			}
2182
2183
			if ( isset( $attributes['hasFormSettingsSet'] ) && $attributes['hasFormSettingsSet'] ) {
2184
				$r .= "\t\t<input type='hidden' name='is_block' value='1' />\n";
2185
			}
2186
			$r .= "\t\t<input type='hidden' name='contact-form-id' value='$id' />\n";
2187
			$r .= "\t\t<input type='hidden' name='action' value='grunion-contact-form' />\n";
2188
			$r .= "\t\t<input type='hidden' name='contact-form-hash' value='" . esc_attr( $form->hash ) . "' />\n";
2189
2190
			if ( ! $has_submit_button_block ) {
2191
				$r .= "\t</p>\n";
2192
			}
2193
2194
			$r .= "</form>\n";
2195
		}
2196
2197
		$r .= '</div>';
2198
2199
		return $r;
2200
	}
2201
2202
	/**
2203
	 * Returns a success message to be returned if the form is sent via AJAX.
2204
	 *
2205
	 * @param int                         $feedback_id
2206
	 * @param object Grunion_Contact_Form $form
2207
	 *
2208
	 * @return string $message
2209
	 */
2210
	static function success_message( $feedback_id, $form ) {
2211
		if ( 'message' === $form->get_attribute( 'customThankyou' ) ) {
2212
			$message = wpautop( $form->get_attribute( 'customThankyouMessage' ) );
2213
		} else {
2214
			$message = '<blockquote class="contact-form-submission">'
2215
			. '<p>' . join( '</p><p>', self::get_compiled_form( $feedback_id, $form ) ) . '</p>'
2216
			. '</blockquote>';
2217
		}
2218
2219
		return wp_kses(
2220
			$message,
2221
			array(
2222
				'br'         => array(),
2223
				'blockquote' => array( 'class' => array() ),
2224
				'p'          => array(),
2225
			)
2226
		);
2227
	}
2228
2229
	/**
2230
	 * Returns a compiled form with labels and values in a form of  an array
2231
	 * of lines.
2232
	 *
2233
	 * @param int                         $feedback_id
2234
	 * @param object Grunion_Contact_Form $form
2235
	 *
2236
	 * @return array $lines
2237
	 */
2238
	static function get_compiled_form( $feedback_id, $form ) {
2239
		$feedback       = get_post( $feedback_id );
2240
		$field_ids      = $form->get_field_ids();
2241
		$content_fields = Grunion_Contact_Form_Plugin::parse_fields_from_content( $feedback_id );
2242
2243
		// Maps field_ids to post_meta keys
2244
		$field_value_map = array(
2245
			'name'     => 'author',
2246
			'email'    => 'author_email',
2247
			'url'      => 'author_url',
2248
			'subject'  => 'subject',
2249
			'textarea' => false, // not a post_meta key.  This is stored in post_content
2250
		);
2251
2252
		$compiled_form = array();
2253
2254
		// "Standard" field allowed list.
2255
		foreach ( $field_value_map as $type => $meta_key ) {
2256
			if ( isset( $field_ids[ $type ] ) ) {
2257
				$field = $form->fields[ $field_ids[ $type ] ];
2258
2259
				if ( $meta_key ) {
2260
					if ( isset( $content_fields[ "_feedback_{$meta_key}" ] ) ) {
2261
						$value = $content_fields[ "_feedback_{$meta_key}" ];
2262
					}
2263
				} else {
2264
					// The feedback content is stored as the first "half" of post_content
2265
					$value         = $feedback->post_content;
2266
					list( $value ) = explode( '<!--more-->', $value );
2267
					$value         = trim( $value );
2268
				}
2269
2270
				$field_index                   = array_search( $field_ids[ $type ], $field_ids['all'] );
2271
				$compiled_form[ $field_index ] = sprintf(
2272
					'<b>%1$s:</b> %2$s<br /><br />',
2273
					wp_kses( $field->get_attribute( 'label' ), array() ),
2274
					self::escape_and_sanitize_field_value( $value )
2275
				);
2276
			}
2277
		}
2278
2279
		// "Non-standard" fields
2280
		if ( $field_ids['extra'] ) {
2281
			// array indexed by field label (not field id)
2282
			$extra_fields = get_post_meta( $feedback_id, '_feedback_extra_fields', true );
2283
2284
			/**
2285
			 * Only get data for the compiled form if `$extra_fields` is a valid and non-empty array.
2286
			 */
2287
			if ( is_array( $extra_fields ) && ! empty( $extra_fields ) ) {
2288
2289
				$extra_field_keys = array_keys( $extra_fields );
2290
2291
				$i = 0;
2292
				foreach ( $field_ids['extra'] as $field_id ) {
2293
					$field       = $form->fields[ $field_id ];
2294
					$field_index = array_search( $field_id, $field_ids['all'] );
2295
2296
					$label = $field->get_attribute( 'label' );
2297
2298
					$compiled_form[ $field_index ] = sprintf(
2299
						'<b>%1$s:</b> %2$s<br /><br />',
2300
						wp_kses( $label, array() ),
2301
						self::escape_and_sanitize_field_value( $extra_fields[ $extra_field_keys[ $i ] ] )
2302
					);
2303
2304
					$i++;
2305
				}
2306
			}
2307
		}
2308
2309
		// Sorting lines by the field index
2310
		ksort( $compiled_form );
2311
2312
		return $compiled_form;
2313
	}
2314
2315
	static function escape_and_sanitize_field_value( $value ) {
2316
        $value = str_replace( array( '[' , ']' ) ,  array( '&#91;' , '&#93;' ) , $value );
2317
        return nl2br( wp_kses( $value, array() ) );
2318
    }
2319
2320
	/**
2321
	 * Only strip out empty string values and keep all the other values as they are.
2322
     *
2323
	 * @param $single_value
2324
	 *
2325
	 * @return bool
2326
	 */
2327
	static function remove_empty( $single_value ) {
2328
		return ( $single_value !== '' );
2329
	}
2330
2331
	/**
2332
	 * The contact-field shortcode processor
2333
	 * 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.
2334
	 *
2335
	 * @param array       $attributes Key => Value pairs as parsed by shortcode_parse_atts()
2336
	 * @param string|null $content The shortcode's inner content: [contact-field]$content[/contact-field]
2337
	 * @return HTML for the contact form field
2338
	 */
2339
	static function parse_contact_field( $attributes, $content ) {
2340
		// Don't try to parse contact form fields if not inside a contact form
2341
		if ( ! Grunion_Contact_Form_Plugin::$using_contact_form_field ) {
2342
			$att_strs = array();
2343
			if ( ! isset( $attributes['label'] )  ) {
2344
				$type = isset( $attributes['type'] ) ? $attributes['type'] : null;
2345
				$attributes['label'] = self::get_default_label_from_type( $type );
2346
			}
2347
			foreach ( $attributes as $att => $val ) {
2348
				if ( is_numeric( $att ) ) { // Is a valueless attribute
2349
					$att_strs[] = esc_html( $val );
2350
				} elseif ( isset( $val ) ) { // A regular attr - value pair
2351
					if ( ( $att === 'options' || $att === 'values' ) && is_string( $val ) ) { // remove any empty strings
2352
						$val = explode( ',', $val );
2353
					}
2354
 					if ( is_array( $val ) ) {
2355
						$val =  array_filter( $val, array( __CLASS__, 'remove_empty' ) ); // removes any empty strings
2356
						$att_strs[] = esc_html( $att ) . '="' . implode( ',', array_map( 'esc_html', $val ) ) . '"';
2357
					} elseif ( is_bool( $val ) ) {
2358
						$att_strs[] = esc_html( $att ) . '="' . esc_html( $val ? '1' : '' ) . '"';
2359
					} else {
2360
						$att_strs[] = esc_html( $att ) . '="' . esc_html( $val ) . '"';
2361
					}
2362
				}
2363
			}
2364
2365
			$html = '[contact-field ' . implode( ' ', $att_strs );
2366
2367
			if ( isset( $content ) && ! empty( $content ) ) { // If there is content, let's add a closing tag
2368
				$html .= ']' . esc_html( $content ) . '[/contact-field]';
2369
			} else { // Otherwise let's add a closing slash in the first tag
2370
				$html .= '/]';
2371
			}
2372
2373
			return $html;
2374
		}
2375
2376
		$form = Grunion_Contact_Form::$current_form;
2377
2378
		$field = new Grunion_Contact_Form_Field( $attributes, $content, $form );
2379
2380
		$field_id = $field->get_attribute( 'id' );
2381
		if ( $field_id ) {
2382
			$form->fields[ $field_id ] = $field;
2383
		} else {
2384
			$form->fields[] = $field;
2385
		}
2386
2387
		if (
2388
			isset( $_POST['action'] ) && 'grunion-contact-form' === $_POST['action']
2389
			&&
2390
			isset( $_POST['contact-form-id'] ) && $form->get_attribute( 'id' ) == $_POST['contact-form-id']
2391
			&&
2392
			isset( $_POST['contact-form-hash'] ) && hash_equals( $form->hash, $_POST['contact-form-hash'] )
2393
		) {
2394
			// If we're processing a POST submission for this contact form, validate the field value so we can show errors as necessary.
2395
			$field->validate();
2396
		}
2397
2398
		// Output HTML
2399
		return $field->render();
2400
	}
2401
2402
	static function get_default_label_from_type( $type ) {
2403
		$str = null;
0 ignored issues
show
$str 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...
2404
		switch ( $type ) {
2405
			case 'text':
2406
				$str = __( 'Text', 'jetpack' );
2407
				break;
2408
			case 'name':
2409
				$str = __( 'Name', 'jetpack' );
2410
				break;
2411
			case 'email':
2412
				$str = __( 'Email', 'jetpack' );
2413
				break;
2414
			case 'url':
2415
				$str = __( 'Website', 'jetpack' );
2416
				break;
2417
			case 'date':
2418
				$str = __( 'Date', 'jetpack' );
2419
				break;
2420
			case 'telephone':
2421
				$str = __( 'Phone', 'jetpack' );
2422
				break;
2423
			case 'textarea':
2424
				$str = __( 'Message', 'jetpack' );
2425
				break;
2426
			case 'checkbox':
2427
				$str = __( 'Checkbox', 'jetpack' );
2428
				break;
2429
			case 'checkbox-multiple':
2430
				$str = __( 'Choose several', 'jetpack' );
2431
				break;
2432
			case 'radio':
2433
				$str = __( 'Choose one', 'jetpack' );
2434
				break;
2435
			case 'select':
2436
				$str = __( 'Select one', 'jetpack' );
2437
				break;
2438
			case 'consent':
2439
				$str = __( 'Consent', 'jetpack' );
2440
				break;
2441
			default:
2442
				$str = null;
2443
		}
2444
		return $str;
2445
	}
2446
2447
	/**
2448
	 * Loops through $this->fields to generate a (structured) list of field IDs.
2449
	 *
2450
	 * Important: Currently the allowed fields are defined as follows:
2451
	 *  `name`, `email`, `url`, `subject`, `textarea`
2452
	 *
2453
	 * If you need to add new fields to the Contact Form, please don't add them
2454
	 * to the allowed fields and leave them as extra fields.
2455
	 *
2456
	 * The reasoning behind this is that both the admin Feedback view and the CSV
2457
	 * export will not include any fields that are added to the list of
2458
	 * allowed fields without taking proper care to add them to all the
2459
	 * other places where they accessed/used/saved.
2460
	 *
2461
	 * The safest way to add new fields is to add them to the dropdown and the
2462
	 * HTML list ( @see Grunion_Contact_Form_Field::render ) and don't add them
2463
	 * to the list of allowed fields. This way they will become a part of the
2464
	 * `extra fields` which are saved in the post meta and will be properly
2465
	 * handled by the admin Feedback view and the CSV Export without any extra
2466
	 * work.
2467
	 *
2468
	 * If there is need to add a field to the allowed fields, then please
2469
	 * take proper care to add logic to handle the field in the following places:
2470
	 *
2471
	 *  - Below in the switch statement - so the field is recognized as allowed.
2472
	 *
2473
	 *  - Grunion_Contact_Form::process_submission - validation and logic.
2474
	 *
2475
	 *  - Grunion_Contact_Form::process_submission - add the field as an additional
2476
	 *      field in the `post_content` when saving the feedback content.
2477
	 *
2478
	 *  - Grunion_Contact_Form_Plugin::parse_fields_from_content - add mapping
2479
	 *      for the field, defined in the above method.
2480
	 *
2481
	 *  - Grunion_Contact_Form_Plugin::map_parsed_field_contents_of_post_to_field_names -
2482
	 *      add mapping of the field for the CSV Export. Otherwise it will be missing
2483
	 *      from the exported data.
2484
	 *
2485
	 *  - admin.php / grunion_manage_post_columns - add the field to the render logic.
2486
	 *      Otherwise it will be missing from the admin Feedback view.
2487
	 *
2488
	 * @return array
2489
	 */
2490
	function get_field_ids() {
2491
		$field_ids = array(
2492
			'all'   => array(), // array of all field_ids.
2493
			'extra' => array(), // array of all non-allowed field IDs.
2494
2495
			// Allowed "standard" field IDs:
2496
			// 'email'    => field_id,
2497
			// 'name'     => field_id,
2498
			// 'url'      => field_id,
2499
			// 'subject'  => field_id,
2500
			// 'textarea' => field_id,
2501
		);
2502
2503
		foreach ( $this->fields as $id => $field ) {
2504
			$field_ids['all'][] = $id;
2505
2506
			$type = $field->get_attribute( 'type' );
2507
			if ( isset( $field_ids[ $type ] ) ) {
2508
				// This type of field is already present in our allowed list of "standard" fields for this form
2509
				// Put it in extra
2510
				$field_ids['extra'][] = $id;
2511
				continue;
2512
			}
2513
2514
			/**
2515
			 * See method description before modifying the switch cases.
2516
			 */
2517
			switch ( $type ) {
2518
				case 'email':
2519
				case 'name':
2520
				case 'url':
2521
				case 'subject':
2522
				case 'textarea':
2523
				case 'consent':
2524
					$field_ids[ $type ] = $id;
2525
					break;
2526
				default:
2527
					// Put everything else in extra
2528
					$field_ids['extra'][] = $id;
2529
			}
2530
		}
2531
2532
		return $field_ids;
2533
	}
2534
2535
	/**
2536
	 * Process the contact form's POST submission
2537
	 * Stores feedback.  Sends email.
2538
	 */
2539
	function process_submission() {
2540
		global $post;
2541
2542
		$plugin = Grunion_Contact_Form_Plugin::init();
2543
2544
		$id     = $this->get_attribute( 'id' );
2545
		$to     = $this->get_attribute( 'to' );
2546
		$widget = $this->get_attribute( 'widget' );
2547
2548
		$contact_form_subject    = $this->get_attribute( 'subject' );
2549
		$email_marketing_consent = false;
2550
2551
		$to     = str_replace( ' ', '', $to );
2552
		$emails = explode( ',', $to );
2553
2554
		$valid_emails = array();
2555
2556
		foreach ( (array) $emails as $email ) {
2557
			if ( ! is_email( $email ) ) {
2558
				continue;
2559
			}
2560
2561
			if ( function_exists( 'is_email_address_unsafe' ) && is_email_address_unsafe( $email ) ) {
2562
				continue;
2563
			}
2564
2565
			$valid_emails[] = $email;
2566
		}
2567
2568
		// No one to send it to, which means none of the "to" attributes are valid emails.
2569
		// Use default email instead.
2570
		if ( ! $valid_emails ) {
2571
			$valid_emails = $this->defaults['to'];
2572
		}
2573
2574
		$to = $valid_emails;
2575
2576
		// Last ditch effort to set a recipient if somehow none have been set.
2577
		if ( empty( $to ) ) {
2578
			$to = get_option( 'admin_email' );
2579
		}
2580
2581
		// Make sure we're processing the form we think we're processing... probably a redundant check.
2582
		if ( $widget ) {
2583
			if ( 'widget-' . $widget != $_POST['contact-form-id'] ) {
2584
				return false;
2585
			}
2586
		} else {
2587
			if ( $post->ID != $_POST['contact-form-id'] ) {
2588
				return false;
2589
			}
2590
		}
2591
2592
		$field_ids = $this->get_field_ids();
2593
2594
		// Initialize all these "standard" fields to null
2595
		$comment_author_email = $comment_author_email_label = // v
2596
		$comment_author       = $comment_author_label       = // v
2597
		$comment_author_url   = $comment_author_url_label   = // v
2598
		$comment_content      = $comment_content_label = null;
2599
2600
		// For each of the "standard" fields, grab their field label and value.
2601 View Code Duplication
		if ( isset( $field_ids['name'] ) ) {
2602
			$field          = $this->fields[ $field_ids['name'] ];
2603
			$comment_author = Grunion_Contact_Form_Plugin::strip_tags(
2604
				stripslashes(
2605
					/** This filter is already documented in core/wp-includes/comment-functions.php */
2606
					apply_filters( 'pre_comment_author_name', addslashes( $field->value ) )
2607
				)
2608
			);
2609
			$comment_author_label = Grunion_Contact_Form_Plugin::strip_tags( $field->get_attribute( 'label' ) );
2610
		}
2611
2612 View Code Duplication
		if ( isset( $field_ids['email'] ) ) {
2613
			$field                = $this->fields[ $field_ids['email'] ];
2614
			$comment_author_email = Grunion_Contact_Form_Plugin::strip_tags(
2615
				stripslashes(
2616
					/** This filter is already documented in core/wp-includes/comment-functions.php */
2617
					apply_filters( 'pre_comment_author_email', addslashes( $field->value ) )
2618
				)
2619
			);
2620
			$comment_author_email_label = Grunion_Contact_Form_Plugin::strip_tags( $field->get_attribute( 'label' ) );
2621
		}
2622
2623
		if ( isset( $field_ids['url'] ) ) {
2624
			$field              = $this->fields[ $field_ids['url'] ];
2625
			$comment_author_url = Grunion_Contact_Form_Plugin::strip_tags(
2626
				stripslashes(
2627
					/** This filter is already documented in core/wp-includes/comment-functions.php */
2628
					apply_filters( 'pre_comment_author_url', addslashes( $field->value ) )
2629
				)
2630
			);
2631
			if ( 'http://' == $comment_author_url ) {
2632
				$comment_author_url = '';
2633
			}
2634
			$comment_author_url_label = Grunion_Contact_Form_Plugin::strip_tags( $field->get_attribute( 'label' ) );
2635
		}
2636
2637
		if ( isset( $field_ids['textarea'] ) ) {
2638
			$field                 = $this->fields[ $field_ids['textarea'] ];
2639
			$comment_content       = trim( Grunion_Contact_Form_Plugin::strip_tags( $field->value ) );
2640
			$comment_content_label = Grunion_Contact_Form_Plugin::strip_tags( $field->get_attribute( 'label' ) );
2641
		}
2642
2643
		if ( isset( $field_ids['subject'] ) ) {
2644
			$field = $this->fields[ $field_ids['subject'] ];
2645
			if ( $field->value ) {
2646
				$contact_form_subject = Grunion_Contact_Form_Plugin::strip_tags( $field->value );
2647
			}
2648
		}
2649
2650
		if ( isset( $field_ids['consent'] ) ) {
2651
			$field = $this->fields[ $field_ids['consent'] ];
2652
			if ( $field->value ) {
2653
				$email_marketing_consent = true;
2654
			}
2655
		}
2656
2657
		$all_values = $extra_values = array();
2658
		$i          = 1; // Prefix counter for stored metadata
2659
2660
		// For all fields, grab label and value
2661
		foreach ( $field_ids['all'] as $field_id ) {
2662
			$field = $this->fields[ $field_id ];
2663
			$label = $i . '_' . $field->get_attribute( 'label' );
2664
			$value = $field->value;
2665
2666
			$all_values[ $label ] = $value;
2667
			$i++; // Increment prefix counter for the next field
2668
		}
2669
2670
		// For the "non-standard" fields, grab label and value
2671
		// Extra fields have their prefix starting from count( $all_values ) + 1
2672
		foreach ( $field_ids['extra'] as $field_id ) {
2673
			$field = $this->fields[ $field_id ];
2674
			$label = $i . '_' . $field->get_attribute( 'label' );
2675
			$value = $field->value;
2676
2677
			if ( is_array( $value ) ) {
2678
				$value = implode( ', ', $value );
2679
			}
2680
2681
			$extra_values[ $label ] = $value;
2682
			$i++; // Increment prefix counter for the next extra field
2683
		}
2684
2685
		if ( isset( $_REQUEST['is_block'] ) && $_REQUEST['is_block'] ) {
2686
			$extra_values['is_block'] = true;
2687
		}
2688
2689
		$contact_form_subject = trim( $contact_form_subject );
2690
2691
		$comment_author_IP = Grunion_Contact_Form_Plugin::get_ip_address();
2692
2693
		$vars = array( 'comment_author', 'comment_author_email', 'comment_author_url', 'contact_form_subject', 'comment_author_IP' );
2694
		foreach ( $vars as $var ) {
2695
			$$var = str_replace( array( "\n", "\r" ), '', $$var );
2696
		}
2697
2698
		// Ensure that Akismet gets all of the relevant information from the contact form,
2699
		// not just the textarea field and predetermined subject.
2700
		$akismet_vars                    = compact( $vars );
2701
		$akismet_vars['comment_content'] = $comment_content;
2702
2703
		foreach ( array_merge( $field_ids['all'], $field_ids['extra'] ) as $field_id ) {
2704
			$field = $this->fields[ $field_id ];
2705
2706
			// Skip any fields that are just a choice from a pre-defined list. They wouldn't have any value
2707
			// from a spam-filtering point of view.
2708
			if ( in_array( $field->get_attribute( 'type' ), array( 'select', 'checkbox', 'checkbox-multiple', 'radio' ) ) ) {
2709
				continue;
2710
			}
2711
2712
			// Normalize the label into a slug.
2713
			$field_slug = trim( // Strip all leading/trailing dashes.
2714
				preg_replace(   // Normalize everything to a-z0-9_-
2715
					'/[^a-z0-9_]+/',
2716
					'-',
2717
					strtolower( $field->get_attribute( 'label' ) ) // Lowercase
2718
				),
2719
				'-'
2720
			);
2721
2722
			$field_value = ( is_array( $field->value ) ) ? trim( implode( ', ', $field->value ) ) : trim( $field->value );
2723
2724
			// Skip any values that are already in the array we're sending.
2725
			if ( $field_value && in_array( $field_value, $akismet_vars ) ) {
2726
				continue;
2727
			}
2728
2729
			$akismet_vars[ 'contact_form_field_' . $field_slug ] = $field_value;
2730
		}
2731
2732
		$spam           = '';
2733
		$akismet_values = $plugin->prepare_for_akismet( $akismet_vars );
2734
2735
		// Is it spam?
2736
		/** This filter is already documented in modules/contact-form/admin.php */
2737
		$is_spam = apply_filters( 'jetpack_contact_form_is_spam', false, $akismet_values );
2738
		if ( is_wp_error( $is_spam ) ) { // WP_Error to abort
2739
			return $is_spam; // abort
2740
		} elseif ( $is_spam === true ) {  // TRUE to flag a spam
2741
			$spam = '***SPAM*** ';
2742
		}
2743
2744
		/**
2745
		 * Filter whether a submitted contact form is in the comment disallowed list.
2746
		 *
2747
		 * @module contact-form
2748
		 *
2749
		 * @since 8.9.0
2750
		 *
2751
		 * @param bool  $result         Is the submitted feedback in the disallowed list.
2752
		 * @param array $akismet_values Feedack values returned by the Akismet plugin.
2753
		 */
2754
		$in_comment_disallowed_list = apply_filters( 'jetpack_contact_form_in_comment_disallowed_list', false, $akismet_values );
0 ignored issues
show
The call to apply_filters() has too many arguments starting with $akismet_values.

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...
2755
2756
		if ( ! $comment_author ) {
2757
			$comment_author = $comment_author_email;
2758
		}
2759
2760
		/**
2761
		 * Filter the email where a submitted feedback is sent.
2762
		 *
2763
		 * @module contact-form
2764
		 *
2765
		 * @since 1.3.1
2766
		 *
2767
		 * @param string|array $to Array of valid email addresses, or single email address.
2768
		 */
2769
		$to            = (array) apply_filters( 'contact_form_to', $to );
2770
		$reply_to_addr = $to[0]; // get just the address part before the name part is added
2771
2772
		foreach ( $to as $to_key => $to_value ) {
2773
			$to[ $to_key ] = Grunion_Contact_Form_Plugin::strip_tags( $to_value );
2774
			$to[ $to_key ] = self::add_name_to_address( $to_value );
2775
		}
2776
2777
		$blog_url        = wp_parse_url( site_url() );
2778
		$from_email_addr = 'wordpress@' . $blog_url['host'];
2779
2780
		if ( ! empty( $comment_author_email ) ) {
2781
			$reply_to_addr = $comment_author_email;
2782
		}
2783
2784
		$headers = 'From: "' . $comment_author . '" <' . $from_email_addr . ">\r\n" .
2785
		           'Reply-To: "' . $comment_author . '" <' . $reply_to_addr . ">\r\n";
2786
2787
		$all_values['email_marketing_consent'] = $email_marketing_consent;
2788
2789
		// Build feedback reference
2790
		$feedback_time  = current_time( 'mysql' );
2791
		$feedback_title = "{$comment_author} - {$feedback_time}";
2792
		$feedback_id    = md5( $feedback_title );
2793
2794
		$entry_values = array(
2795
			'entry_title'     => the_title_attribute( 'echo=0' ),
2796
			'entry_permalink' => esc_url( get_permalink( get_the_ID() ) ),
2797
			'feedback_id'     => $feedback_id,
2798
		);
2799
2800
		$all_values = array_merge( $all_values, $entry_values );
2801
2802
		/** This filter is already documented in modules/contact-form/admin.php */
2803
		$subject = apply_filters( 'contact_form_subject', $contact_form_subject, $all_values );
2804
		$url     = $widget ? home_url( '/' ) : get_permalink( $post->ID );
2805
2806
		$date_time_format = _x( '%1$s \a\t %2$s', '{$date_format} \a\t {$time_format}', 'jetpack' );
2807
		$date_time_format = sprintf( $date_time_format, get_option( 'date_format' ), get_option( 'time_format' ) );
2808
		$time             = date_i18n( $date_time_format, current_time( 'timestamp' ) );
2809
2810
		// Keep a copy of the feedback as a custom post type.
2811
		if ( $in_comment_disallowed_list ) {
2812
			$feedback_status = 'trash';
2813
		} elseif ( $is_spam ) {
2814
			$feedback_status = 'spam';
2815
		} else {
2816
			$feedback_status = 'publish';
2817
		}
2818
2819
		foreach ( (array) $akismet_values as $av_key => $av_value ) {
2820
			$akismet_values[ $av_key ] = Grunion_Contact_Form_Plugin::strip_tags( $av_value );
2821
		}
2822
2823
		foreach ( (array) $all_values as $all_key => $all_value ) {
2824
			$all_values[ $all_key ] = Grunion_Contact_Form_Plugin::strip_tags( $all_value );
2825
		}
2826
2827
		foreach ( (array) $extra_values as $ev_key => $ev_value ) {
2828
			$extra_values[ $ev_key ] = Grunion_Contact_Form_Plugin::strip_tags( $ev_value );
2829
		}
2830
2831
		/*
2832
		 We need to make sure that the post author is always zero for contact
2833
		 * form submissions.  This prevents export/import from trying to create
2834
		 * new users based on form submissions from people who were logged in
2835
		 * at the time.
2836
		 *
2837
		 * Unfortunately wp_insert_post() tries very hard to make sure the post
2838
		 * author gets the currently logged in user id.  That is how we ended up
2839
		 * with this work around. */
2840
		add_filter( 'wp_insert_post_data', array( $plugin, 'insert_feedback_filter' ), 10, 2 );
2841
2842
		$post_id = wp_insert_post(
2843
			array(
2844
				'post_date'    => addslashes( $feedback_time ),
2845
				'post_type'    => 'feedback',
2846
				'post_status'  => addslashes( $feedback_status ),
2847
				'post_parent'  => (int) $post->ID,
2848
				'post_title'   => addslashes( wp_kses( $feedback_title, array() ) ),
2849
				'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
2850
				'post_name'    => $feedback_id,
2851
			)
2852
		);
2853
2854
		// once insert has finished we don't need this filter any more
2855
		remove_filter( 'wp_insert_post_data', array( $plugin, 'insert_feedback_filter' ), 10 );
2856
2857
		update_post_meta( $post_id, '_feedback_extra_fields', $this->addslashes_deep( $extra_values ) );
2858
2859
		if ( 'publish' == $feedback_status ) {
2860
			// Increase count of unread feedback.
2861
			$unread = get_option( 'feedback_unread_count', 0 ) + 1;
2862
			update_option( 'feedback_unread_count', $unread );
2863
		}
2864
2865
		if ( defined( 'AKISMET_VERSION' ) ) {
2866
			update_post_meta( $post_id, '_feedback_akismet_values', $this->addslashes_deep( $akismet_values ) );
2867
		}
2868
2869
		/**
2870
		 * Fires after the feedback post for the contact form submission has been inserted.
2871
		 *
2872
		 * @module contact-form
2873
		 *
2874
		 * @since 8.6.0
2875
		 *
2876
		 * @param integer $post_id The post id that contains the contact form data.
2877
		 * @param array   $this->fields An array containg the form's Grunion_Contact_Form_Field objects.
2878
		 * @param boolean $is_spam Whether the form submission has been identified as spam.
2879
		 * @param array   $entry_values The feedback entry values.
2880
		 */
2881
		do_action( 'grunion_after_feedback_post_inserted', $post_id, $this->fields, $is_spam, $entry_values );
2882
2883
		$message = self::get_compiled_form( $post_id, $this );
2884
2885
		array_push(
2886
			$message,
2887
			'<br />',
2888
			'<hr />',
2889
			__( 'Time:', 'jetpack' ) . ' ' . $time . '<br />',
2890
			__( 'IP Address:', 'jetpack' ) . ' ' . $comment_author_IP . '<br />',
2891
			__( 'Contact Form URL:', 'jetpack' ) . ' ' . $url . '<br />'
2892
		);
2893
2894
		if ( is_user_logged_in() ) {
2895
			array_push(
2896
				$message,
2897
				sprintf(
2898
					'<p>' . __( 'Sent by a verified %s user.', 'jetpack' ) . '</p>',
2899
					isset( $GLOBALS['current_site']->site_name ) && $GLOBALS['current_site']->site_name ?
2900
						$GLOBALS['current_site']->site_name : '"' . get_option( 'blogname' ) . '"'
2901
				)
2902
			);
2903
		} else {
2904
			array_push( $message, '<p>' . __( 'Sent by an unverified visitor to your site.', 'jetpack' ) . '</p>' );
2905
		}
2906
2907
		$message = join( '', $message );
2908
2909
		/**
2910
		 * Filters the message sent via email after a successful form submission.
2911
		 *
2912
		 * @module contact-form
2913
		 *
2914
		 * @since 1.3.1
2915
		 *
2916
		 * @param string $message Feedback email message.
2917
		 */
2918
		$message = apply_filters( 'contact_form_message', $message );
2919
2920
		// This is called after `contact_form_message`, in order to preserve back-compat
2921
		$message = self::wrap_message_in_html_tags( $message );
2922
2923
		update_post_meta( $post_id, '_feedback_email', $this->addslashes_deep( compact( 'to', 'message' ) ) );
2924
2925
		/**
2926
		 * Fires right before the contact form message is sent via email to
2927
		 * the recipient specified in the contact form.
2928
		 *
2929
		 * @module contact-form
2930
		 *
2931
		 * @since 1.3.1
2932
		 *
2933
		 * @param integer $post_id Post contact form lives on
2934
		 * @param array $all_values Contact form fields
2935
		 * @param array $extra_values Contact form fields not included in $all_values
2936
		 */
2937
		do_action( 'grunion_pre_message_sent', $post_id, $all_values, $extra_values );
2938
2939
		// schedule deletes of old spam feedbacks
2940
		if ( ! wp_next_scheduled( 'grunion_scheduled_delete' ) ) {
2941
			wp_schedule_event( time() + 250, 'daily', 'grunion_scheduled_delete' );
2942
		}
2943
2944
		if (
2945
			$is_spam !== true &&
2946
			/**
2947
			 * Filter to choose whether an email should be sent after each successful contact form submission.
2948
			 *
2949
			 * @module contact-form
2950
			 *
2951
			 * @since 2.6.0
2952
			 *
2953
			 * @param bool true Should an email be sent after a form submission. Default to true.
2954
			 * @param int $post_id Post ID.
2955
			 */
2956
			true === apply_filters( 'grunion_should_send_email', true, $post_id )
2957
		) {
2958
			self::wp_mail( $to, "{$spam}{$subject}", $message, $headers );
2959
		} elseif (
2960
			true === $is_spam &&
2961
			/**
2962
			 * Choose whether an email should be sent for each spam contact form submission.
2963
			 *
2964
			 * @module contact-form
2965
			 *
2966
			 * @since 1.3.1
2967
			 *
2968
			 * @param bool false Should an email be sent after a spam form submission. Default to false.
2969
			 */
2970
			apply_filters( 'grunion_still_email_spam', false ) == true
2971
		) { // don't send spam by default.  Filterable.
2972
			self::wp_mail( $to, "{$spam}{$subject}", $message, $headers );
2973
		}
2974
2975
		/**
2976
		 * Fires an action hook right after the email(s) have been sent.
2977
		 *
2978
		 * @module contact-form
2979
		 *
2980
		 * @since 7.3.0
2981
		 *
2982
		 * @param int $post_id Post contact form lives on.
2983
		 * @param string|array $to Array of valid email addresses, or single email address.
2984
		 * @param string $subject Feedback email subject.
2985
		 * @param string $message Feedback email message.
2986
		 * @param string|array $headers Optional. Additional headers.
2987
		 * @param array $all_values Contact form fields.
2988
		 * @param array $extra_values Contact form fields not included in $all_values
2989
		 */
2990
		do_action( 'grunion_after_message_sent', $post_id, $to, $subject, $message, $headers, $all_values, $extra_values );
2991
2992
		if ( defined( 'DOING_AJAX' ) && DOING_AJAX ) {
2993
			return self::success_message( $post_id, $this );
2994
		}
2995
2996
		$redirect = '';
2997
		$custom_redirect = false;
2998
		if ( 'redirect' === $this->get_attribute( 'customThankyou' ) ) {
2999
			$custom_redirect = true;
3000
			$redirect        = esc_url( $this->get_attribute( 'customThankyouRedirect' ) );
3001
		}
3002
3003
		if ( ! $redirect ) {
3004
			$custom_redirect = false;
3005
			$redirect        = wp_get_referer();
3006
		}
3007
3008
		if ( ! $redirect ) { // wp_get_referer() returns false if the referer is the same as the current page.
3009
			$custom_redirect = false;
3010
			$redirect        = $_SERVER['REQUEST_URI'];
3011
		}
3012
3013
		if ( ! $custom_redirect ) {
3014
			$redirect = add_query_arg(
3015
				urlencode_deep(
3016
					array(
3017
						'contact-form-id'   => $id,
3018
						'contact-form-sent' => $post_id,
3019
						'contact-form-hash' => $this->hash,
3020
						'_wpnonce'          => wp_create_nonce( "contact-form-sent-{$post_id}" ), // wp_nonce_url HTMLencodes :( .
3021
					)
3022
				),
3023
				$redirect
3024
			);
3025
		}
3026
3027
		/**
3028
		 * Filter the URL where the reader is redirected after submitting a form.
3029
		 *
3030
		 * @module contact-form
3031
		 *
3032
		 * @since 1.9.0
3033
		 *
3034
		 * @param string $redirect Post submission URL.
3035
		 * @param int $id Contact Form ID.
3036
		 * @param int $post_id Post ID.
3037
		 */
3038
		$redirect = apply_filters( 'grunion_contact_form_redirect_url', $redirect, $id, $post_id );
3039
3040
		// phpcs:ignore WordPress.Security.SafeRedirect.wp_redirect_wp_redirect -- We intentially allow external redirects here.
3041
		wp_redirect( $redirect );
3042
		exit;
3043
	}
3044
3045
	/**
3046
	 * Wrapper for wp_mail() that enables HTML messages with text alternatives
3047
	 *
3048
	 * @param string|array $to          Array or comma-separated list of email addresses to send message.
3049
	 * @param string       $subject     Email subject.
3050
	 * @param string       $message     Message contents.
3051
	 * @param string|array $headers     Optional. Additional headers.
3052
	 * @param string|array $attachments Optional. Files to attach.
3053
	 *
3054
	 * @return bool Whether the email contents were sent successfully.
3055
	 */
3056
	public static function wp_mail( $to, $subject, $message, $headers = '', $attachments = array() ) {
3057
		add_filter( 'wp_mail_content_type', __CLASS__ . '::get_mail_content_type' );
3058
		add_action( 'phpmailer_init', __CLASS__ . '::add_plain_text_alternative' );
3059
3060
		$result = wp_mail( $to, $subject, $message, $headers, $attachments );
3061
3062
		remove_filter( 'wp_mail_content_type', __CLASS__ . '::get_mail_content_type' );
3063
		remove_action( 'phpmailer_init', __CLASS__ . '::add_plain_text_alternative' );
3064
3065
		return $result;
3066
	}
3067
3068
	/**
3069
	 * Add a display name part to an email address
3070
	 *
3071
	 * SpamAssassin doesn't like addresses in HTML messages that are missing display names (e.g., `[email protected]`
3072
	 * instead of `"Foo Bar" <[email protected]>`.
3073
	 *
3074
	 * @param string $address
3075
	 *
3076
	 * @return string
3077
	 */
3078
	function add_name_to_address( $address ) {
3079
		// If it's just the address, without a display name
3080
		if ( is_email( $address ) ) {
3081
			$address_parts = explode( '@', $address );
3082
			$address       = sprintf( '"%s" <%s>', $address_parts[0], $address );
3083
		}
3084
3085
		return $address;
3086
	}
3087
3088
	/**
3089
	 * Get the content type that should be assigned to outbound emails
3090
	 *
3091
	 * @return string
3092
	 */
3093
	static function get_mail_content_type() {
3094
		return 'text/html';
3095
	}
3096
3097
	/**
3098
	 * Wrap a message body with the appropriate in HTML tags
3099
	 *
3100
	 * This helps to ensure correct parsing by clients, and also helps avoid triggering spam filtering rules
3101
	 *
3102
	 * @param string $body
3103
	 *
3104
	 * @return string
3105
	 */
3106
	static function wrap_message_in_html_tags( $body ) {
3107
		// Don't do anything if the message was already wrapped in HTML tags
3108
		// That could have be done by a plugin via filters
3109
		if ( false !== strpos( $body, '<html' ) ) {
3110
			return $body;
3111
		}
3112
3113
		$html_message = sprintf(
3114
			// The tabs are just here so that the raw code is correctly formatted for developers
3115
			// They're removed so that they don't affect the final message sent to users
3116
			str_replace(
3117
				"\t", '',
3118
				'<!doctype html>
3119
				<html xmlns="http://www.w3.org/1999/xhtml">
3120
				<body>
3121
3122
				%s
3123
3124
				</body>
3125
				</html>'
3126
			),
3127
			$body
3128
		);
3129
3130
		return $html_message;
3131
	}
3132
3133
	/**
3134
	 * Add a plain-text alternative part to an outbound email
3135
	 *
3136
	 * This makes the message more accessible to mail clients that aren't HTML-aware, and decreases the likelihood
3137
	 * that the message will be flagged as spam.
3138
	 *
3139
	 * @param PHPMailer $phpmailer
3140
	 */
3141
	static function add_plain_text_alternative( $phpmailer ) {
3142
		// Add an extra break so that the extra space above the <p> is preserved after the <p> is stripped out
3143
		$alt_body = str_replace( '<p>', '<p><br />', $phpmailer->Body );
3144
3145
		// Convert <br> to \n breaks, to preserve the space between lines that we want to keep
3146
		$alt_body = str_replace( array( '<br>', '<br />' ), "\n", $alt_body );
3147
3148
		// Convert <hr> to an plain-text equivalent, to preserve the integrity of the message
3149
		$alt_body = str_replace( array( '<hr>', '<hr />' ), "----\n", $alt_body );
3150
3151
		// Trim the plain text message to remove the \n breaks that were after <doctype>, <html>, and <body>
3152
		$phpmailer->AltBody = trim( strip_tags( $alt_body ) );
3153
	}
3154
3155
	function addslashes_deep( $value ) {
3156
		if ( is_array( $value ) ) {
3157
			return array_map( array( $this, 'addslashes_deep' ), $value );
3158
		} elseif ( is_object( $value ) ) {
3159
			$vars = get_object_vars( $value );
3160
			foreach ( $vars as $key => $data ) {
3161
				$value->{$key} = $this->addslashes_deep( $data );
3162
			}
3163
			return $value;
3164
		}
3165
3166
		return addslashes( $value );
3167
	}
3168
3169
} // end class Grunion_Contact_Form
3170
3171
/**
3172
 * Class for the contact-field shortcode.
3173
 * Parses shortcode to output the contact form field as HTML.
3174
 * Validates input.
3175
 */
3176
class Grunion_Contact_Form_Field extends Crunion_Contact_Form_Shortcode {
3177
	public $shortcode_name = 'contact-field';
3178
3179
	/**
3180
	 * @var Grunion_Contact_Form parent form
3181
	 */
3182
	public $form;
3183
3184
	/**
3185
	 * @var string default or POSTed value
3186
	 */
3187
	public $value;
3188
3189
	/**
3190
	 * @var bool Is the input invalid?
3191
	 */
3192
	public $error = false;
3193
3194
	/**
3195
	 * @param array                $attributes An associative array of shortcode attributes.  @see shortcode_atts()
3196
	 * @param null|string          $content Null for selfclosing shortcodes.  The inner content otherwise.
3197
	 * @param Grunion_Contact_Form $form The parent form
3198
	 */
3199
	function __construct( $attributes, $content = null, $form = null ) {
3200
		$attributes = shortcode_atts(
3201
			array(
3202
				'label'                  => null,
3203
				'type'                   => 'text',
3204
				'required'               => false,
3205
				'options'                => array(),
3206
				'id'                     => null,
3207
				'default'                => null,
3208
				'values'                 => null,
3209
				'placeholder'            => null,
3210
				'class'                  => null,
3211
				'width'                  => null,
3212
				'consenttype'            => null,
3213
				'implicitconsentmessage' => null,
3214
				'explicitconsentmessage' => null,
3215
			), $attributes, 'contact-field'
3216
		);
3217
3218
		// special default for subject field
3219
		if ( 'subject' == $attributes['type'] && is_null( $attributes['default'] ) && ! is_null( $form ) ) {
3220
			$attributes['default'] = $form->get_attribute( 'subject' );
3221
		}
3222
3223
		// allow required=1 or required=true
3224
		if ( '1' == $attributes['required'] || 'true' == strtolower( $attributes['required'] ) ) {
3225
			$attributes['required'] = true;
3226
		} else {
3227
			$attributes['required'] = false;
3228
		}
3229
3230
		// parse out comma-separated options list (for selects, radios, and checkbox-multiples)
3231
		if ( ! empty( $attributes['options'] ) && is_string( $attributes['options'] ) ) {
3232
			$attributes['options'] = array_map( 'trim', explode( ',', $attributes['options'] ) );
3233
3234 View Code Duplication
			if ( ! empty( $attributes['values'] ) && is_string( $attributes['values'] ) ) {
3235
				$attributes['values'] = array_map( 'trim', explode( ',', $attributes['values'] ) );
3236
			}
3237
		}
3238
3239
		if ( $form ) {
3240
			// make a unique field ID based on the label, with an incrementing number if needed to avoid clashes
3241
			$form_id = $form->get_attribute( 'id' );
3242
			$id      = isset( $attributes['id'] ) ? $attributes['id'] : false;
3243
3244
			$unescaped_label = $this->unesc_attr( $attributes['label'] );
3245
			$unescaped_label = str_replace( '%', '-', $unescaped_label ); // jQuery doesn't like % in IDs?
3246
			$unescaped_label = preg_replace( '/[^a-zA-Z0-9.-_:]/', '', $unescaped_label );
3247
3248
			if ( empty( $id ) ) {
3249
				$id        = sanitize_title_with_dashes( 'g' . $form_id . '-' . $unescaped_label );
3250
				$i         = 0;
3251
				$max_tries = 99;
3252
				while ( isset( $form->fields[ $id ] ) ) {
3253
					$i++;
3254
					$id = sanitize_title_with_dashes( 'g' . $form_id . '-' . $unescaped_label . '-' . $i );
3255
3256
					if ( $i > $max_tries ) {
3257
						break;
3258
					}
3259
				}
3260
			}
3261
3262
			$attributes['id'] = $id;
3263
		}
3264
3265
		parent::__construct( $attributes, $content );
3266
3267
		// Store parent form
3268
		$this->form = $form;
3269
	}
3270
3271
	/**
3272
	 * This field's input is invalid.  Flag as invalid and add an error to the parent form
3273
	 *
3274
	 * @param string $message The error message to display on the form.
3275
	 */
3276
	function add_error( $message ) {
3277
		$this->is_error = true;
3278
3279
		if ( ! is_wp_error( $this->form->errors ) ) {
3280
			$this->form->errors = new WP_Error;
3281
		}
3282
3283
		$this->form->errors->add( $this->get_attribute( 'id' ), $message );
3284
	}
3285
3286
	/**
3287
	 * Is the field input invalid?
3288
	 *
3289
	 * @see $error
3290
	 *
3291
	 * @return bool
3292
	 */
3293
	function is_error() {
3294
		return $this->error;
3295
	}
3296
3297
	/**
3298
	 * Validates the form input
3299
	 */
3300
	function validate() {
3301
		// If it's not required, there's nothing to validate
3302
		if ( ! $this->get_attribute( 'required' ) ) {
3303
			return;
3304
		}
3305
3306
		$field_id    = $this->get_attribute( 'id' );
3307
		$field_type  = $this->get_attribute( 'type' );
3308
		$field_label = $this->get_attribute( 'label' );
3309
3310
		if ( isset( $_POST[ $field_id ] ) ) {
3311
			if ( is_array( $_POST[ $field_id ] ) ) {
3312
				$field_value = array_map( 'stripslashes', $_POST[ $field_id ] );
3313
			} else {
3314
				$field_value = stripslashes( $_POST[ $field_id ] );
3315
			}
3316
		} else {
3317
			$field_value = '';
3318
		}
3319
3320
		switch ( $field_type ) {
3321 View Code Duplication
			case 'email':
3322
				// Make sure the email address is valid
3323
				if ( ! is_string( $field_value ) || ! is_email( $field_value ) ) {
3324
					/* translators: %s is the name of a form field */
3325
					$this->add_error( sprintf( __( '%s requires a valid email address', 'jetpack' ), $field_label ) );
3326
				}
3327
				break;
3328
			case 'checkbox-multiple':
3329
				// Check that there is at least one option selected
3330
				if ( empty( $field_value ) ) {
3331
					/* translators: %s is the name of a form field */
3332
					$this->add_error( sprintf( __( '%s requires at least one selection', 'jetpack' ), $field_label ) );
3333
				}
3334
				break;
3335 View Code Duplication
			default:
3336
				// Just check for presence of any text
3337
				if ( ! is_string( $field_value ) || ! strlen( trim( $field_value ) ) ) {
3338
					/* translators: %s is the name of a form field */
3339
					$this->add_error( sprintf( __( '%s is required', 'jetpack' ), $field_label ) );
3340
				}
3341
		}
3342
	}
3343
3344
3345
	/**
3346
	 * Check the default value for options field
3347
	 *
3348
	 * @param string value
3349
	 * @param int index
3350
	 * @param string default value
3351
	 *
3352
	 * @return string
3353
	 */
3354
	public function get_option_value( $value, $index, $options ) {
3355
		if ( empty( $value[ $index ] ) ) {
3356
			return $options;
3357
		}
3358
		return $value[ $index ];
3359
	}
3360
3361
	/**
3362
	 * Outputs the HTML for this form field
3363
	 *
3364
	 * @return string HTML
3365
	 */
3366
	function render() {
3367
		global $current_user, $user_identity;
3368
3369
		$field_id          = $this->get_attribute( 'id' );
3370
		$field_type        = $this->get_attribute( 'type' );
3371
		$field_label       = $this->get_attribute( 'label' );
3372
		$field_required    = $this->get_attribute( 'required' );
3373
		$field_placeholder = $this->get_attribute( 'placeholder' );
3374
		$field_width       = $this->get_attribute( 'width' );
3375
		$class             = 'date' === $field_type ? 'jp-contact-form-date' : $this->get_attribute( 'class' );
3376
3377
		if ( ! empty( $field_width ) ) {
3378
			$class .= ' grunion-field-width-' . $field_width;
3379
		}
3380
3381
		/**
3382
		 * Filters the "class" attribute of the contact form input
3383
		 *
3384
		 * @module contact-form
3385
		 *
3386
		 * @since 6.6.0
3387
		 *
3388
		 * @param string $class Additional CSS classes for input class attribute.
3389
		 */
3390
		$field_class = apply_filters( 'jetpack_contact_form_input_class', $class );
3391
3392
		if ( isset( $_POST[ $field_id ] ) ) {
3393
			if ( is_array( $_POST[ $field_id ] ) ) {
3394
				$this->value = array_map( 'stripslashes', $_POST[ $field_id ] );
3395
			} else {
3396
				$this->value = stripslashes( (string) $_POST[ $field_id ] );
3397
			}
3398
		} elseif ( isset( $_GET[ $field_id ] ) ) {
3399
			$this->value = stripslashes( (string) $_GET[ $field_id ] );
3400
		} elseif (
3401
			is_user_logged_in() &&
3402
			( ( defined( 'IS_WPCOM' ) && IS_WPCOM ) ||
3403
			  /**
3404
			   * Allow third-party tools to prefill the contact form with the user's details when they're logged in.
3405
			   *
3406
			   * @module contact-form
3407
			   *
3408
			   * @since 3.2.0
3409
			   *
3410
			   * @param bool false Should the Contact Form be prefilled with your details when you're logged in. Default to false.
3411
			   */
3412
			  true === apply_filters( 'jetpack_auto_fill_logged_in_user', false )
3413
			)
3414
		) {
3415
			// Special defaults for logged-in users
3416
			switch ( $this->get_attribute( 'type' ) ) {
3417
				case 'email':
3418
					$this->value = $current_user->data->user_email;
3419
					break;
3420
				case 'name':
3421
					$this->value = $user_identity;
3422
					break;
3423
				case 'url':
3424
					$this->value = $current_user->data->user_url;
3425
					break;
3426
				default:
3427
					$this->value = $this->get_attribute( 'default' );
3428
			}
3429
		} else {
3430
			$this->value = $this->get_attribute( 'default' );
3431
		}
3432
3433
		$field_value = Grunion_Contact_Form_Plugin::strip_tags( $this->value );
3434
		$field_label = Grunion_Contact_Form_Plugin::strip_tags( $field_label );
3435
3436
		$rendered_field = $this->render_field( $field_type, $field_id, $field_label, $field_value, $field_class, $field_placeholder, $field_required );
3437
3438
		/**
3439
		 * Filter the HTML of the Contact Form.
3440
		 *
3441
		 * @module contact-form
3442
		 *
3443
		 * @since 2.6.0
3444
		 *
3445
		 * @param string $rendered_field Contact Form HTML output.
3446
		 * @param string $field_label Field label.
3447
		 * @param int|null $id Post ID.
3448
		 */
3449
		return apply_filters( 'grunion_contact_form_field_html', $rendered_field, $field_label, ( in_the_loop() ? get_the_ID() : null ) );
3450
	}
3451
3452
	function render_label( $type = '', $id, $label, $required, $required_field_text ) {
3453
3454
		$type_class = $type ? ' ' .$type : '';
3455
		return
3456
			"<label
3457
				for='" . esc_attr( $id ) . "'
3458
				class='grunion-field-label{$type_class}" . ( $this->is_error() ? ' form-error' : '' ) . "'
3459
				>"
3460
				. esc_html( $label )
3461
				. ( $required ? '<span>' . $required_field_text . '</span>' : '' )
3462
			. "</label>\n";
3463
3464
	}
3465
3466
	function render_input_field( $type, $id, $value, $class, $placeholder, $required ) {
3467
		return "<input
3468
					type='". esc_attr( $type ) ."'
3469
					name='" . esc_attr( $id ) . "'
3470
					id='" . esc_attr( $id ) . "'
3471
					value='" . esc_attr( $value ) . "'
3472
					" . $class . $placeholder . '
3473
					' . ( $required ? "required aria-required='true'" : '' ) . "
3474
				/>\n";
3475
	}
3476
3477
	function render_email_field( $id, $label, $value, $class, $required, $required_field_text, $placeholder ) {
3478
		$field = $this->render_label( 'email', $id, $label, $required, $required_field_text );
3479
		$field .= $this->render_input_field( 'email', $id, $value, $class, $placeholder, $required );
3480
		return $field;
3481
	}
3482
3483
	function render_telephone_field( $id, $label, $value, $class, $required, $required_field_text, $placeholder ) {
3484
		$field = $this->render_label( 'telephone', $id, $label, $required, $required_field_text );
3485
		$field .= $this->render_input_field( 'tel', $id, $value, $class, $placeholder, $required );
3486
		return $field;
3487
	}
3488
3489
	function render_url_field( $id, $label, $value, $class, $required, $required_field_text, $placeholder ) {
3490
		$field = $this->render_label( 'url', $id, $label, $required, $required_field_text );
3491
		$field .= $this->render_input_field( 'url', $id, $value, $class, $placeholder, $required );
3492
		return $field;
3493
	}
3494
3495
	function render_textarea_field( $id, $label, $value, $class, $required, $required_field_text, $placeholder ) {
3496
		$field = $this->render_label( 'textarea', 'contact-form-comment-' . $id, $label, $required, $required_field_text );
3497
		$field .= "<textarea
3498
		                name='" . esc_attr( $id ) . "'
3499
		                id='contact-form-comment-" . esc_attr( $id ) . "'
3500
		                rows='20' "
3501
		                . $class
3502
		                . $placeholder
3503
		                . ' ' . ( $required ? "required aria-required='true'" : '' ) .
3504
		                '>' . esc_textarea( $value )
3505
		          . "</textarea>\n";
3506
		return $field;
3507
	}
3508
3509
	function render_radio_field( $id, $label, $value, $class, $required, $required_field_text ) {
3510
		$field = $this->render_label( '', $id, $label, $required, $required_field_text );
3511
		foreach ( (array) $this->get_attribute( 'options' ) as $optionIndex => $option ) {
3512
			$option = Grunion_Contact_Form_Plugin::strip_tags( $option );
3513
			if ( $option ) {
3514
				$field .= "\t\t<label class='grunion-radio-label radio" . ( $this->is_error() ? ' form-error' : '' ) . "'>";
3515
				$field .= "<input
3516
									type='radio'
3517
									name='" . esc_attr( $id ) . "'
3518
									value='" . esc_attr( $this->get_option_value( $this->get_attribute( 'values' ), $optionIndex, $option ) ) . "' "
3519
				                    . $class
3520
				                    . checked( $option, $value, false ) . ' '
3521
				                    . ( $required ? "required aria-required='true'" : '' )
3522
				              . '/> ';
3523
				$field .= esc_html( $option ) . "</label>\n";
3524
				$field .= "\t\t<div class='clear-form'></div>\n";
3525
			}
3526
		}
3527
		return $field;
3528
	}
3529
3530
	function render_checkbox_field( $id, $label, $value, $class, $required, $required_field_text ) {
3531
		$field = "<label class='grunion-field-label checkbox" . ( $this->is_error() ? ' form-error' : '' ) . "'>";
3532
			$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";
3533
			$field .= "\t\t" . esc_html( $label ) . ( $required ? '<span>' . $required_field_text . '</span>' : '' );
3534
		$field .=  "</label>\n";
3535
		$field .= "<div class='clear-form'></div>\n";
3536
		return $field;
3537
	}
3538
3539
	/**
3540
	 * Render the consent field.
3541
	 *
3542
	 * @param string $id field id.
3543
	 * @param string $class html classes (can be set by the admin).
3544
	 */
3545
	private function render_consent_field( $id, $class ) {
3546
		$consent_type    = 'explicit' === $this->get_attribute( 'consenttype' ) ? 'explicit' : 'implicit';
3547
		$consent_message = 'explicit' === $consent_type ? $this->get_attribute( 'explicitconsentmessage' ) : $this->get_attribute( 'implicitconsentmessage' );
3548
3549
		$field  = "<label class='grunion-field-label consent consent-" . $consent_type . "'>";
3550
3551
		if ( 'implicit' === $consent_type ) {
3552
			$field .= "\t\t<input aria-hidden='true' type='checkbox' checked name='" . esc_attr( $id ) . "' value='" . esc_attr__( 'Yes', 'jetpack' ) . "' style='display:none;' /> \n";
3553
		} else {
3554
			$field .= "\t\t<input type='checkbox' name='" . esc_attr( $id ) . "' value='" . esc_attr__( 'Yes', 'jetpack' ) . "' " . $class . "/> \n";
3555
		}
3556
		$field .= "\t\t" . esc_html( $consent_message );
3557
		$field .= "</label>\n";
3558
		$field .= "<div class='clear-form'></div>\n";
3559
		return $field;
3560
	}
3561
3562
	function render_checkbox_multiple_field( $id, $label, $value, $class, $required, $required_field_text  ) {
3563
		$field = $this->render_label( '', $id, $label, $required, $required_field_text );
3564
		foreach ( (array) $this->get_attribute( 'options' ) as $optionIndex => $option ) {
3565
			$option = Grunion_Contact_Form_Plugin::strip_tags( $option );
3566
			if ( $option  ) {
3567
				$field .= "\t\t<label class='grunion-checkbox-multiple-label checkbox-multiple" . ( $this->is_error() ? ' form-error' : '' ) . "'>";
3568
				$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 ) . ' /> ';
3569
				$field .= esc_html( $option ) . "</label>\n";
3570
				$field .= "\t\t<div class='clear-form'></div>\n";
3571
			}
3572
		}
3573
3574
		return $field;
3575
	}
3576
3577
	function render_select_field( $id, $label, $value, $class, $required, $required_field_text ) {
3578
		$field = $this->render_label( 'select', $id, $label, $required, $required_field_text );
3579
		$field  .= "\t<select name='" . esc_attr( $id ) . "' id='" . esc_attr( $id ) . "' " . $class . ( $required ? "required aria-required='true'" : '' ) . ">\n";
3580
		foreach ( (array) $this->get_attribute( 'options' ) as $optionIndex => $option ) {
3581
			$option = Grunion_Contact_Form_Plugin::strip_tags( $option );
3582
			if ( $option ) {
3583
				$field .= "\t\t<option"
3584
				               . selected( $option, $value, false )
3585
				               . " value='" . esc_attr( $this->get_option_value( $this->get_attribute( 'values' ), $optionIndex, $option ) )
3586
				               . "'>" . esc_html( $option )
3587
				          . "</option>\n";
3588
			}
3589
		}
3590
		$field  .= "\t</select>\n";
3591
		return $field;
3592
	}
3593
3594
	function render_date_field( $id, $label, $value, $class, $required, $required_field_text, $placeholder ) {
3595
3596
		$field = $this->render_label( 'date', $id, $label, $required, $required_field_text );
3597
		$field .= $this->render_input_field( 'text', $id, $value, $class, $placeholder, $required );
3598
3599
		/* For AMP requests, use amp-date-picker element: https://amp.dev/documentation/components/amp-date-picker */
3600
		if ( class_exists( 'Jetpack_AMP_Support' ) && Jetpack_AMP_Support::is_amp_request() ) {
3601
			return sprintf(
3602
				'<%1$s mode="overlay" layout="container" type="single" input-selector="[name=%2$s]">%3$s</%1$s>',
3603
				'amp-date-picker',
3604
				esc_attr( $id ),
3605
				$field
3606
			);
3607
		}
3608
3609
		wp_enqueue_script(
3610
			'grunion-frontend',
3611
			Assets::get_file_url_for_environment(
3612
				'_inc/build/contact-form/js/grunion-frontend.min.js',
3613
				'modules/contact-form/js/grunion-frontend.js'
3614
			),
3615
			array( 'jquery', 'jquery-ui-datepicker' )
3616
		);
3617
		wp_enqueue_style( 'jp-jquery-ui-datepicker', plugins_url( 'css/jquery-ui-datepicker.css', __FILE__ ), array( 'dashicons' ), '1.0' );
3618
3619
		// Using Core's built-in datepicker localization routine
3620
		wp_localize_jquery_ui_datepicker();
3621
		return $field;
3622
	}
3623
3624
	function render_default_field( $id, $label, $value, $class, $required, $required_field_text, $placeholder, $type ) {
3625
		$field = $this->render_label( $type, $id, $label, $required, $required_field_text );
3626
		$field .= $this->render_input_field( 'text', $id, $value, $class, $placeholder, $required );
3627
		return $field;
3628
	}
3629
3630
	function render_field( $type, $id, $label, $value, $class, $placeholder, $required ) {
3631
3632
		$field_placeholder = ( ! empty( $placeholder ) ) ? "placeholder='" . esc_attr( $placeholder ) . "'" : '';
3633
		$field_class       = "class='" . trim( esc_attr( $type ) . ' ' . esc_attr( $class ) ) . "' ";
3634
		$wrap_classes = empty( $class ) ? '' : implode( '-wrap ', array_filter( explode( ' ', $class ) ) ) . '-wrap'; // this adds
3635
3636
		$shell_field_class = "class='grunion-field-wrap grunion-field-" . trim( esc_attr( $type ) . '-wrap ' . esc_attr( $wrap_classes ) ) . "' ";
3637
		/**
3638
		/**
3639
		 * Filter the Contact Form required field text
3640
		 *
3641
		 * @module contact-form
3642
		 *
3643
		 * @since 3.8.0
3644
		 *
3645
		 * @param string $var Required field text. Default is "(required)".
3646
		 */
3647
		$required_field_text = esc_html( apply_filters( 'jetpack_required_field_text', __( '(required)', 'jetpack' ) ) );
3648
3649
		$field = "\n<div {$shell_field_class} >\n"; // new in Jetpack 6.8.0
3650
		// If they are logged in, and this is their site, don't pre-populate fields
3651
		if ( current_user_can( 'manage_options' ) ) {
3652
			$value = '';
3653
		}
3654
		switch ( $type ) {
3655
			case 'email':
3656
				$field .= $this->render_email_field( $id, $label, $value, $field_class, $required, $required_field_text, $field_placeholder );
3657
				break;
3658
			case 'telephone':
3659
				$field .= $this->render_telephone_field( $id, $label, $value, $field_class, $required, $required_field_text, $field_placeholder );
3660
				break;
3661
			case 'url':
3662
				$field .= $this->render_url_field( $id, $label, $value, $field_class, $required, $required_field_text, $field_placeholder );
3663
				break;
3664
			case 'textarea':
3665
				$field .= $this->render_textarea_field( $id, $label, $value, $field_class, $required, $required_field_text, $field_placeholder );
3666
				break;
3667
			case 'radio':
3668
				$field .= $this->render_radio_field( $id, $label, $value, $field_class, $required, $required_field_text, $field_placeholder );
3669
				break;
3670
			case 'checkbox':
3671
				$field .= $this->render_checkbox_field( $id, $label, $value, $field_class, $required, $required_field_text );
3672
				break;
3673
			case 'checkbox-multiple':
3674
				$field .= $this->render_checkbox_multiple_field( $id, $label, $value, $field_class, $required, $required_field_text );
3675
				break;
3676
			case 'select':
3677
				$field .= $this->render_select_field( $id, $label, $value, $field_class, $required, $required_field_text );
3678
				break;
3679
			case 'date':
3680
				$field .= $this->render_date_field( $id, $label, $value, $field_class, $required, $required_field_text, $field_placeholder );
3681
				break;
3682
			case 'consent':
3683
				$field .= $this->render_consent_field( $id, $field_class );
3684
				break;
3685
			default: // text field
3686
				$field .= $this->render_default_field( $id, $label, $value, $field_class, $required, $required_field_text, $field_placeholder, $type );
3687
				break;
3688
		}
3689
		$field .= "\t</div>\n";
3690
		return $field;
3691
	}
3692
}
3693
3694
add_action( 'init', array( 'Grunion_Contact_Form_Plugin', 'init' ), 9 );
3695
3696
add_action( 'grunion_scheduled_delete', 'grunion_delete_old_spam' );
3697
3698
/**
3699
 * Deletes old spam feedbacks to keep the posts table size under control
3700
 */
3701
function grunion_delete_old_spam() {
3702
	global $wpdb;
3703
3704
	$grunion_delete_limit = 100;
3705
3706
	$now_gmt  = current_time( 'mysql', 1 );
3707
	$sql      = $wpdb->prepare(
3708
		"
3709
		SELECT `ID`
3710
		FROM $wpdb->posts
3711
		WHERE DATE_SUB( %s, INTERVAL 15 DAY ) > `post_date_gmt`
3712
			AND `post_type` = 'feedback'
3713
			AND `post_status` = 'spam'
3714
		LIMIT %d
3715
	", $now_gmt, $grunion_delete_limit
3716
	);
3717
	$post_ids = $wpdb->get_col( $sql );
3718
3719
	foreach ( (array) $post_ids as $post_id ) {
3720
		// force a full delete, skip the trash
3721
		wp_delete_post( $post_id, true );
3722
	}
3723
3724
	if (
3725
		/**
3726
		 * Filter if the module run OPTIMIZE TABLE on the core WP tables.
3727
		 *
3728
		 * @module contact-form
3729
		 *
3730
		 * @since 1.3.1
3731
		 * @since 6.4.0 Set to false by default.
3732
		 *
3733
		 * @param bool $filter Should Jetpack optimize the table, defaults to false.
3734
		 */
3735
		apply_filters( 'grunion_optimize_table', false )
3736
	) {
3737
		$wpdb->query( "OPTIMIZE TABLE $wpdb->posts" );
3738
	}
3739
3740
	// if we hit the max then schedule another run
3741
	if ( count( $post_ids ) >= $grunion_delete_limit ) {
3742
		wp_schedule_single_event( time() + 700, 'grunion_scheduled_delete' );
3743
	}
3744
}
3745
3746
/**
3747
 * Send an event to Tracks on form submission.
3748
 *
3749
 * @param int   $post_id - the post_id for the CPT that is created.
3750
 * @param array $all_values - fields from the default contact form.
3751
 * @param array $extra_values - extra fields added to from the contact form.
3752
 *
3753
 * @return null|void
3754
 */
3755
function jetpack_tracks_record_grunion_pre_message_sent( $post_id, $all_values, $extra_values ) {
3756
	// Do not do anything if the submission is not from a block.
3757
	if (
3758
		! isset( $extra_values['is_block'] )
3759
		|| ! $extra_values['is_block']
3760
	) {
3761
		return;
3762
	}
3763
3764
	/*
3765
	 * Event details.
3766
	 */
3767
	$event_user  = wp_get_current_user();
3768
	$event_name  = 'contact_form_block_message_sent';
3769
	$event_props = array(
3770
		'entry_permalink' => esc_url( $all_values['entry_permalink'] ),
3771
		'feedback_id'     => esc_attr( $all_values['feedback_id'] ),
3772
	);
3773
3774
	/*
3775
	 * Record event.
3776
	 * We use different libs on wpcom and Jetpack.
3777
	 */
3778
	if ( defined( 'IS_WPCOM' ) && IS_WPCOM ) {
3779
		$event_name             = 'wpcom_' . $event_name;
3780
		$event_props['blog_id'] = get_current_blog_id();
3781
		// If the form was sent by a logged out visitor, record event with blog owner.
3782
		if ( empty( $event_user->ID ) ) {
3783
			$event_user_id = wpcom_get_blog_owner( $event_props['blog_id'] );
3784
			$event_user    = get_userdata( $event_user_id );
3785
		}
3786
3787
		jetpack_require_lib( 'tracks/client' );
3788
		tracks_record_event( $event_user, $event_name, $event_props );
3789
	} else {
3790
		// If the form was sent by a logged out visitor, record event with Jetpack master user.
3791
		if ( empty( $event_user->ID ) ) {
3792
			$master_user_id = Jetpack_Options::get_option( 'master_user' );
3793
			if ( ! empty( $master_user_id ) ) {
3794
				$event_user = get_userdata( $master_user_id );
3795
			}
3796
		}
3797
3798
		$tracking = new Automattic\Jetpack\Tracking();
3799
		$tracking->record_user_event( $event_name, $event_props, $event_user );
3800
	}
3801
}
3802
add_action( 'grunion_pre_message_sent', 'jetpack_tracks_record_grunion_pre_message_sent', 12, 3 );
3803