Completed
Push — add/limited-blocks ( 1d7f2f )
by
unknown
08:45 queued 01:32
created

modules/contact-form/grunion-contact-form.php (1 issue)

Labels
Severity

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
2
3
use Automattic\Jetpack\Assets;
4
5
/*
6
Plugin Name: Grunion Contact Form
7
Description: Add a contact form to any post, page or text widget.  Emails will be sent to the post's author by default, or any email address you choose.  As seen on WordPress.com.
8
Plugin URI: https://automattic.com/#
9
AUthor: Automattic, Inc.
10
Author URI: https://automattic.com/
11
Version: 2.4
12
License: GPLv2 or later
13
*/
14
15
use Automattic\Jetpack\Sync\Settings;
16
17
define( 'GRUNION_PLUGIN_DIR', plugin_dir_path( __FILE__ ) );
18
define( 'GRUNION_PLUGIN_URL', plugin_dir_url( __FILE__ ) );
19
20
if ( is_admin() ) {
21
	require_once GRUNION_PLUGIN_DIR . 'admin.php';
22
}
23
24
add_action( 'rest_api_init', 'grunion_contact_form_require_endpoint' );
25
function grunion_contact_form_require_endpoint() {
26
	require_once GRUNION_PLUGIN_DIR . 'class-grunion-contact-form-endpoint.php';
27
}
28
29
/**
30
 * Sets up various actions, filters, post types, post statuses, shortcodes.
31
 */
32
class Grunion_Contact_Form_Plugin {
33
34
	/**
35
	 * @var string The Widget ID of the widget currently being processed.  Used to build the unique contact-form ID for forms embedded in widgets.
36
	 */
37
	public $current_widget_id;
38
39
	static $using_contact_form_field = false;
40
41
	/**
42
	 * @var int The last Feedback Post ID Erased as part of the Personal Data Eraser.
43
	 * Helps with pagination.
44
	 */
45
	private $pde_last_post_id_erased = 0;
46
47
	/**
48
	 * @var string The email address for which we are deleting/exporting all feedbacks
49
	 * as part of a Personal Data Eraser or Personal Data Exporter request.
50
	 */
51
	private $pde_email_address = '';
52
53
	static function init() {
54
		static $instance = false;
55
56
		if ( ! $instance ) {
57
			$instance = new Grunion_Contact_Form_Plugin();
58
59
			// Schedule our daily cleanup
60
			add_action( 'wp_scheduled_delete', array( $instance, 'daily_akismet_meta_cleanup' ) );
61
		}
62
63
		return $instance;
64
	}
65
66
	/**
67
	 * Runs daily to clean up spam detection metadata after 15 days.  Keeps your DB squeaky clean.
68
	 */
69
	public function daily_akismet_meta_cleanup() {
70
		global $wpdb;
71
72
		$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" );
73
74
		if ( empty( $feedback_ids ) ) {
75
			return;
76
		}
77
78
		/**
79
		 * Fires right before deleting the _feedback_akismet_values post meta on $feedback_ids
80
		 *
81
		 * @module contact-form
82
		 *
83
		 * @since 6.1.0
84
		 *
85
		 * @param array $feedback_ids list of feedback post ID
86
		 */
87
		do_action( 'jetpack_daily_akismet_meta_cleanup_before', $feedback_ids );
88
		foreach ( $feedback_ids as $feedback_id ) {
89
			delete_post_meta( $feedback_id, '_feedback_akismet_values' );
90
		}
91
92
		/**
93
		 * Fires right after deleting the _feedback_akismet_values post meta on $feedback_ids
94
		 *
95
		 * @module contact-form
96
		 *
97
		 * @since 6.1.0
98
		 *
99
		 * @param array $feedback_ids list of feedback post ID
100
		 */
101
		do_action( 'jetpack_daily_akismet_meta_cleanup_after', $feedback_ids );
102
	}
103
104
	/**
105
	 * Strips HTML tags from input.  Output is NOT HTML safe.
106
	 *
107
	 * @param mixed $data_with_tags
108
	 * @return mixed
109
	 */
110
	public static function strip_tags( $data_with_tags ) {
111
		if ( is_array( $data_with_tags ) ) {
112
			foreach ( $data_with_tags as $index => $value ) {
113
				$index = sanitize_text_field( strval( $index ) );
114
				$value = wp_kses( strval( $value ), array() );
115
				$value = str_replace( '&amp;', '&', $value ); // undo damage done by wp_kses_normalize_entities()
116
117
				$data_without_tags[ $index ] = $value;
118
			}
119
		} else {
120
			$data_without_tags = wp_kses( $data_with_tags, array() );
121
			$data_without_tags = str_replace( '&amp;', '&', $data_without_tags ); // undo damage done by wp_kses_normalize_entities()
122
		}
123
124
		return $data_without_tags;
125
	}
126
127
	/**
128
	 * Class uses singleton pattern; use Grunion_Contact_Form_Plugin::init() to initialize.
129
	 */
130
	protected function __construct() {
131
		$this->add_shortcode();
132
133
		// While generating the output of a text widget with a contact-form shortcode, we need to know its widget ID.
134
		add_action( 'dynamic_sidebar', array( $this, 'track_current_widget' ) );
135
136
		// Add a "widget" shortcode attribute to all contact-form shortcodes embedded in widgets
137
		add_filter( 'widget_text', array( $this, 'widget_atts' ), 0 );
138
139
		// If Text Widgets don't get shortcode processed, hack ours into place.
140
		if (
141
			version_compare( get_bloginfo( 'version' ), '4.9-z', '<=' )
142
			&& ! has_filter( 'widget_text', 'do_shortcode' )
143
		) {
144
			add_filter( 'widget_text', array( $this, 'widget_shortcode_hack' ), 5 );
145
		}
146
147
		add_filter( 'jetpack_contact_form_is_spam', array( $this, 'is_spam_blacklist' ), 10, 2 );
148
149
		// Akismet to the rescue
150
		if ( defined( 'AKISMET_VERSION' ) || function_exists( 'akismet_http_post' ) ) {
151
			add_filter( 'jetpack_contact_form_is_spam', array( $this, 'is_spam_akismet' ), 10, 2 );
152
			add_action( 'contact_form_akismet', array( $this, 'akismet_submit' ), 10, 2 );
153
		}
154
155
		add_action( 'loop_start', array( 'Grunion_Contact_Form', '_style_on' ) );
156
157
		add_action( 'wp_ajax_grunion-contact-form', array( $this, 'ajax_request' ) );
158
		add_action( 'wp_ajax_nopriv_grunion-contact-form', array( $this, 'ajax_request' ) );
159
160
		// GDPR: personal data exporter & eraser.
161
		add_filter( 'wp_privacy_personal_data_exporters', array( $this, 'register_personal_data_exporter' ) );
162
		add_filter( 'wp_privacy_personal_data_erasers', array( $this, 'register_personal_data_eraser' ) );
163
164
		// Export to CSV feature
165
		if ( is_admin() ) {
166
			add_action( 'admin_init', array( $this, 'download_feedback_as_csv' ) );
167
			add_action( 'admin_footer-edit.php', array( $this, 'export_form' ) );
168
			add_action( 'admin_menu', array( $this, 'admin_menu' ) );
169
			add_action( 'current_screen', array( $this, 'unread_count' ) );
170
		}
171
172
		// custom post type we'll use to keep copies of the feedback items
173
		register_post_type(
174
			'feedback', array(
175
				'labels'                => array(
176
					'name'               => __( 'Feedback', 'jetpack' ),
177
					'singular_name'      => __( 'Feedback', 'jetpack' ),
178
					'search_items'       => __( 'Search Feedback', 'jetpack' ),
179
					'not_found'          => __( 'No feedback found', 'jetpack' ),
180
					'not_found_in_trash' => __( 'No feedback found', 'jetpack' ),
181
				),
182
				// Matrial Ballot icon
183
				'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>'),
184
				'show_ui'               => true,
185
				'show_in_admin_bar'     => false,
186
				'public'                => false,
187
				'rewrite'               => false,
188
				'query_var'             => false,
189
				'capability_type'       => 'page',
190
				'show_in_rest'          => true,
191
				'rest_controller_class' => 'Grunion_Contact_Form_Endpoint',
192
				'capabilities'          => array(
193
					'create_posts'        => 'do_not_allow',
194
					'publish_posts'       => 'publish_pages',
195
					'edit_posts'          => 'edit_pages',
196
					'edit_others_posts'   => 'edit_others_pages',
197
					'delete_posts'        => 'delete_pages',
198
					'delete_others_posts' => 'delete_others_pages',
199
					'read_private_posts'  => 'read_private_pages',
200
					'edit_post'           => 'edit_page',
201
					'delete_post'         => 'delete_page',
202
					'read_post'           => 'read_page',
203
				),
204
				'map_meta_cap'          => true,
205
			)
206
		);
207
208
		// Add to REST API post type whitelist
209
		add_filter( 'rest_api_allowed_post_types', array( $this, 'allow_feedback_rest_api_type' ) );
210
211
		// Add "spam" as a post status
212
		register_post_status(
213
			'spam', array(
214
				'label'                  => 'Spam',
215
				'public'                 => false,
216
				'exclude_from_search'    => true,
217
				'show_in_admin_all_list' => false,
218
				'label_count'            => _n_noop( 'Spam <span class="count">(%s)</span>', 'Spam <span class="count">(%s)</span>', 'jetpack' ),
219
				'protected'              => true,
220
				'_builtin'               => false,
221
			)
222
		);
223
224
		// POST handler
225
		if (
226
			isset( $_SERVER['REQUEST_METHOD'] ) && 'POST' == strtoupper( $_SERVER['REQUEST_METHOD'] )
227
			&&
228
			isset( $_POST['action'] ) && 'grunion-contact-form' == $_POST['action']
229
			&&
230
			isset( $_POST['contact-form-id'] )
231
		) {
232
			add_action( 'template_redirect', array( $this, 'process_form_submission' ) );
233
		}
234
235
		/*
236
		 Can be dequeued by placing the following in wp-content/themes/yourtheme/functions.php
237
		 *
238
		 * 	function remove_grunion_style() {
239
		 *		wp_deregister_style('grunion.css');
240
		 *	}
241
		 *	add_action('wp_print_styles', 'remove_grunion_style');
242
		 */
243
		wp_register_style( 'grunion.css', GRUNION_PLUGIN_URL . 'css/grunion.css', array(), JETPACK__VERSION );
244
		wp_style_add_data( 'grunion.css', 'rtl', 'replace' );
245
246
		$this->register_contact_form_blocks();
247
	}
248
249
	private function register_contact_form_blocks() {
250
		jetpack_register_block( 'jetpack/contact-form', array(
251
			'render_callback' => array( __CLASS__, 'gutenblock_render_form' ),
252
			'attributes' => array( 'limited' => ! $this->contact_form_integrations_enabled() )
253
		) );
254
255
		// Field render methods.
256
		jetpack_register_block( 'jetpack/field-text', array(
257
			'parent'          => array( 'jetpack/contact-form' ),
258
			'render_callback' => array( __CLASS__, 'gutenblock_render_field_text' ),
259
		) );
260
		jetpack_register_block( 'jetpack/field-name', array(
261
			'parent'          => array( 'jetpack/contact-form' ),
262
			'render_callback' => array( __CLASS__, 'gutenblock_render_field_name' ),
263
		) );
264
		jetpack_register_block( 'jetpack/field-email', array(
265
			'parent'          => array( 'jetpack/contact-form' ),
266
			'render_callback' => array( __CLASS__, 'gutenblock_render_field_email' ),
267
		) );
268
		jetpack_register_block( 'jetpack/field-url', array(
269
			'parent'          => array( 'jetpack/contact-form' ),
270
			'render_callback' => array( __CLASS__, 'gutenblock_render_field_url' ),
271
		) );
272
		jetpack_register_block( 'jetpack/field-date', array(
273
			'parent'          => array( 'jetpack/contact-form' ),
274
			'render_callback' => array( __CLASS__, 'gutenblock_render_field_date' ),
275
		) );
276
		jetpack_register_block( 'jetpack/field-telephone', array(
277
			'parent'          => array( 'jetpack/contact-form' ),
278
			'render_callback' => array( __CLASS__, 'gutenblock_render_field_telephone' ),
279
		) );
280
		jetpack_register_block( 'jetpack/field-textarea', array(
281
			'parent'          => array( 'jetpack/contact-form' ),
282
			'render_callback' => array( __CLASS__, 'gutenblock_render_field_textarea' ),
283
		) );
284
		jetpack_register_block( 'jetpack/field-checkbox', array(
285
			'parent'          => array( 'jetpack/contact-form' ),
286
			'render_callback' => array( __CLASS__, 'gutenblock_render_field_checkbox' ),
287
		) );
288
		jetpack_register_block( 'jetpack/field-checkbox-multiple', array(
289
			'parent'          => array( 'jetpack/contact-form' ),
290
			'render_callback' => array( __CLASS__, 'gutenblock_render_field_checkbox_multiple' ),
291
		) );
292
		jetpack_register_block( 'jetpack/field-radio', array(
293
			'parent'          => array( 'jetpack/contact-form' ),
294
			'render_callback' => array( __CLASS__, 'gutenblock_render_field_radio' ),
295
		) );
296
		jetpack_register_block( 'jetpack/field-select', array(
297
			'parent'          => array( 'jetpack/contact-form' ),
298
			'render_callback' => array( __CLASS__, 'gutenblock_render_field_select' ),
299
		) );
300
	}
301
302
	public static function gutenblock_render_form( $atts, $content ) {
303
		return Grunion_Contact_Form::parse( $atts, do_blocks( $content ) );
304
	}
305
306
	public static function block_attributes_to_shortcode_attributes( $atts, $type ) {
307
		$atts['type'] = $type;
308
		if ( isset( $atts['className'] ) ) {
309
			$atts['class'] = $atts['className'];
310
			unset( $atts['className'] );
311
		}
312
313
		if ( isset( $atts['defaultValue'] ) ) {
314
			$atts['default'] = $atts['defaultValue'];
315
			unset( $atts['defaultValue'] );
316
		}
317
318
		return $atts;
319
	}
320
321
	public static function gutenblock_render_field_text( $atts, $content ) {
322
		$atts = self::block_attributes_to_shortcode_attributes( $atts, 'text' );
323
		return Grunion_Contact_Form::parse_contact_field( $atts, $content );
324
	}
325
	public static function gutenblock_render_field_name( $atts, $content ) {
326
		$atts = self::block_attributes_to_shortcode_attributes( $atts, 'name' );
327
		return Grunion_Contact_Form::parse_contact_field( $atts, $content );
328
	}
329
	public static function gutenblock_render_field_email( $atts, $content ) {
330
		$atts = self::block_attributes_to_shortcode_attributes( $atts, 'email' );
331
		return Grunion_Contact_Form::parse_contact_field( $atts, $content );
332
	}
333
	public static function gutenblock_render_field_url( $atts, $content ) {
334
		$atts = self::block_attributes_to_shortcode_attributes( $atts, 'url' );
335
		return Grunion_Contact_Form::parse_contact_field( $atts, $content );
336
	}
337
	public static function gutenblock_render_field_date( $atts, $content ) {
338
		$atts = self::block_attributes_to_shortcode_attributes( $atts, 'date' );
339
		return Grunion_Contact_Form::parse_contact_field( $atts, $content );
340
	}
341
	public static function gutenblock_render_field_telephone( $atts, $content ) {
342
		$atts = self::block_attributes_to_shortcode_attributes( $atts, 'telephone' );
343
		return Grunion_Contact_Form::parse_contact_field( $atts, $content );
344
	}
345
	public static function gutenblock_render_field_textarea( $atts, $content ) {
346
		$atts = self::block_attributes_to_shortcode_attributes( $atts, 'textarea' );
347
		return Grunion_Contact_Form::parse_contact_field( $atts, $content );
348
	}
349
	public static function gutenblock_render_field_checkbox( $atts, $content ) {
350
		$atts = self::block_attributes_to_shortcode_attributes( $atts, 'checkbox' );
351
		return Grunion_Contact_Form::parse_contact_field( $atts, $content );
352
	}
353
	public static function gutenblock_render_field_checkbox_multiple( $atts, $content ) {
354
		$atts = self::block_attributes_to_shortcode_attributes( $atts, 'checkbox-multiple' );
355
		return Grunion_Contact_Form::parse_contact_field( $atts, $content );
356
	}
357
	public static function gutenblock_render_field_radio( $atts, $content ) {
358
		$atts = self::block_attributes_to_shortcode_attributes( $atts, 'radio' );
359
		return Grunion_Contact_Form::parse_contact_field( $atts, $content );
360
	}
361
	public static function gutenblock_render_field_select( $atts, $content ) {
362
		$atts = self::block_attributes_to_shortcode_attributes( $atts, 'select' );
363
		return Grunion_Contact_Form::parse_contact_field( $atts, $content );
364
	}
365
366
	/**
367
	 * Add the 'Export' menu item as a submenu of Feedback.
368
	 */
369
	public function admin_menu() {
370
		add_submenu_page(
371
			'edit.php?post_type=feedback',
372
			__( 'Export feedback as CSV', 'jetpack' ),
373
			__( 'Export CSV', 'jetpack' ),
374
			'export',
375
			'feedback-export',
376
			array( $this, 'export_form' )
377
		);
378
	}
379
380
	/**
381
	 * Add to REST API post type whitelist
382
	 */
383
	function allow_feedback_rest_api_type( $post_types ) {
384
		$post_types[] = 'feedback';
385
		return $post_types;
386
	}
387
388
	/**
389
	 * Display the count of new feedback entries received. It's reset when user visits the Feedback screen.
390
	 *
391
	 * @since 4.1.0
392
	 *
393
	 * @param object $screen Information about the current screen.
394
	 */
395
	function unread_count( $screen ) {
396
		if ( isset( $screen->post_type ) && 'feedback' == $screen->post_type ) {
397
			update_option( 'feedback_unread_count', 0 );
398
		} else {
399
			global $menu;
400
			if ( isset( $menu ) && is_array( $menu ) && ! empty( $menu ) ) {
401
				foreach ( $menu as $index => $menu_item ) {
402
					if ( 'edit.php?post_type=feedback' == $menu_item[2] ) {
403
						$unread = get_option( 'feedback_unread_count', 0 );
404
						if ( $unread > 0 ) {
405
							$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>' : '';
406
							$menu[ $index ][0] .= $unread_count;
407
						}
408
						break;
409
					}
410
				}
411
			}
412
		}
413
	}
414
415
	/**
416
	 * Handles all contact-form POST submissions
417
	 *
418
	 * Conditionally attached to `template_redirect`
419
	 */
420
	function process_form_submission() {
421
		// Add a filter to replace tokens in the subject field with sanitized field values
422
		add_filter( 'contact_form_subject', array( $this, 'replace_tokens_with_input' ), 10, 2 );
423
424
		$id   = stripslashes( $_POST['contact-form-id'] );
425
		$hash = isset( $_POST['contact-form-hash'] ) ? $_POST['contact-form-hash'] : null;
426
		$hash = preg_replace( '/[^\da-f]/i', '', $hash );
427
428
		if ( is_user_logged_in() ) {
429
			check_admin_referer( "contact-form_{$id}" );
430
		}
431
432
		$is_widget = 0 === strpos( $id, 'widget-' );
433
434
		$form = false;
435
436
		if ( $is_widget ) {
437
			// It's a form embedded in a text widget
438
			$this->current_widget_id = substr( $id, 7 ); // remove "widget-"
439
			$widget_type             = implode( '-', array_slice( explode( '-', $this->current_widget_id ), 0, -1 ) ); // Remove trailing -#
440
441
			// Is the widget active?
442
			$sidebar = is_active_widget( false, $this->current_widget_id, $widget_type );
443
444
			// This is lame - no core API for getting a widget by ID
445
			$widget = isset( $GLOBALS['wp_registered_widgets'][ $this->current_widget_id ] ) ? $GLOBALS['wp_registered_widgets'][ $this->current_widget_id ] : false;
446
447
			if ( $sidebar && $widget && isset( $widget['callback'] ) ) {
448
				// prevent PHP notices by populating widget args
449
				$widget_args = array(
450
					'before_widget' => '',
451
					'after_widget'  => '',
452
					'before_title'  => '',
453
					'after_title'   => '',
454
				);
455
				// This is lamer - no API for outputting a given widget by ID
456
				ob_start();
457
				// Process the widget to populate Grunion_Contact_Form::$last
458
				call_user_func( $widget['callback'], $widget_args, $widget['params'][0] );
459
				ob_end_clean();
460
			}
461
		} else {
462
			// It's a form embedded in a post
463
			$post = get_post( $id );
464
465
			// Process the content to populate Grunion_Contact_Form::$last
466
			/** This filter is already documented in core. wp-includes/post-template.php */
467
			apply_filters( 'the_content', $post->post_content );
468
		}
469
470
		$form = isset( Grunion_Contact_Form::$forms[ $hash ] ) ? Grunion_Contact_Form::$forms[ $hash ] : null;
471
472
		// No form may mean user is using do_shortcode, grab the form using the stored post meta
473
		if ( ! $form ) {
474
475
			// Get shortcode from post meta
476
			$shortcode = get_post_meta( $_POST['contact-form-id'], "_g_feedback_shortcode_{$hash}", true );
477
478
			// Format it
479
			if ( $shortcode != '' ) {
480
481
				// Get attributes from post meta.
482
				$parameters = '';
483
				$attributes = get_post_meta( $_POST['contact-form-id'], "_g_feedback_shortcode_atts_{$hash}", true );
484
				if ( ! empty( $attributes ) && is_array( $attributes ) ) {
485
					foreach ( array_filter( $attributes ) as $param => $value ) {
486
						$parameters .= " $param=\"$value\"";
487
					}
488
				}
489
490
				$shortcode = '[contact-form' . $parameters . ']' . $shortcode . '[/contact-form]';
491
				do_shortcode( $shortcode );
492
493
				// Recreate form
494
				$form = Grunion_Contact_Form::$last;
495
			}
496
497
			if ( ! $form ) {
498
				return false;
499
			}
500
		}
501
502
		if ( is_wp_error( $form->errors ) && $form->errors->get_error_codes() ) {
503
			return $form->errors;
504
		}
505
506
		// Process the form
507
		return $form->process_submission();
508
	}
509
510
	function ajax_request() {
511
		$submission_result = self::process_form_submission();
512
513
		if ( ! $submission_result ) {
514
			header( 'HTTP/1.1 500 Server Error', 500, true );
515
			echo '<div class="form-error"><ul class="form-errors"><li class="form-error-message">';
516
			esc_html_e( 'An error occurred. Please try again later.', 'jetpack' );
517
			echo '</li></ul></div>';
518
		} elseif ( is_wp_error( $submission_result ) ) {
519
			header( 'HTTP/1.1 400 Bad Request', 403, true );
520
			echo '<div class="form-error"><ul class="form-errors"><li class="form-error-message">';
521
			echo esc_html( $submission_result->get_error_message() );
522
			echo '</li></ul></div>';
523
		} else {
524
			echo '<h3>' . esc_html__( 'Message Sent', 'jetpack' ) . '</h3>' . $submission_result;
525
		}
526
527
		die;
528
	}
529
530
	/**
531
	 * Ensure the post author is always zero for contact-form feedbacks
532
	 * Attached to `wp_insert_post_data`
533
	 *
534
	 * @see Grunion_Contact_Form::process_submission()
535
	 *
536
	 * @param array $data the data to insert
537
	 * @param array $postarr the data sent to wp_insert_post()
538
	 * @return array The filtered $data to insert
539
	 */
540
	function insert_feedback_filter( $data, $postarr ) {
541
		if ( $data['post_type'] == 'feedback' && $postarr['post_type'] == 'feedback' ) {
542
			$data['post_author'] = 0;
543
		}
544
545
		return $data;
546
	}
547
	/*
548
	 * Adds our contact-form shortcode
549
	 * The "child" contact-field shortcode is enabled as needed by the contact-form shortcode handler
550
	 */
551
	function add_shortcode() {
552
		add_shortcode( 'contact-form', array( 'Grunion_Contact_Form', 'parse' ) );
553
		add_shortcode( 'contact-field', array( 'Grunion_Contact_Form', 'parse_contact_field' ) );
554
	}
555
556
	static function tokenize_label( $label ) {
557
		return '{' . trim( preg_replace( '#^\d+_#', '', $label ) ) . '}';
558
	}
559
560
	static function sanitize_value( $value ) {
561
		return preg_replace( '=((<CR>|<LF>|0x0A/%0A|0x0D/%0D|\\n|\\r)\S).*=i', null, $value );
562
	}
563
564
	/**
565
	 * Replaces tokens like {city} or {City} (case insensitive) with the value
566
	 * of an input field of that name
567
	 *
568
	 * @param string $subject
569
	 * @param array  $field_values Array with field label => field value associations
570
	 *
571
	 * @return string The filtered $subject with the tokens replaced
572
	 */
573
	function replace_tokens_with_input( $subject, $field_values ) {
574
		// Wrap labels into tokens (inside {})
575
		$wrapped_labels = array_map( array( 'Grunion_Contact_Form_Plugin', 'tokenize_label' ), array_keys( $field_values ) );
576
		// Sanitize all values
577
		$sanitized_values = array_map( array( 'Grunion_Contact_Form_Plugin', 'sanitize_value' ), array_values( $field_values ) );
578
579
		foreach ( $sanitized_values as $k => $sanitized_value ) {
580
			if ( is_array( $sanitized_value ) ) {
581
				$sanitized_values[ $k ] = implode( ', ', $sanitized_value );
582
			}
583
		}
584
585
		// Search for all valid tokens (based on existing fields) and replace with the field's value
586
		$subject = str_ireplace( $wrapped_labels, $sanitized_values, $subject );
587
		return $subject;
588
	}
589
590
	/**
591
	 * Tracks the widget currently being processed.
592
	 * Attached to `dynamic_sidebar`
593
	 *
594
	 * @see $current_widget_id
595
	 *
596
	 * @param array $widget The widget data
597
	 */
598
	function track_current_widget( $widget ) {
599
		$this->current_widget_id = $widget['id'];
600
	}
601
602
	/**
603
	 * Adds a "widget" attribute to every contact-form embedded in a text widget.
604
	 * Used to tell the difference between post-embedded contact-forms and widget-embedded contact-forms
605
	 * Attached to `widget_text`
606
	 *
607
	 * @param string $text The widget text
608
	 * @return string The filtered widget text
609
	 */
610
	function widget_atts( $text ) {
611
		Grunion_Contact_Form::style( true );
612
613
		return preg_replace( '/\[contact-form([^a-zA-Z_-])/', '[contact-form widget="' . $this->current_widget_id . '"\\1', $text );
614
	}
615
616
	/**
617
	 * For sites where text widgets are not processed for shortcodes, we add this hack to process just our shortcode
618
	 * Attached to `widget_text`
619
	 *
620
	 * @param string $text The widget text
621
	 * @return string The contact-form filtered widget text
622
	 */
623
	function widget_shortcode_hack( $text ) {
624
		if ( ! preg_match( '/\[contact-form([^a-zA-Z_-])/', $text ) ) {
625
			return $text;
626
		}
627
628
		$old = $GLOBALS['shortcode_tags'];
629
		remove_all_shortcodes();
630
		Grunion_Contact_Form_Plugin::$using_contact_form_field = true;
631
		$this->add_shortcode();
632
633
		$text = do_shortcode( $text );
634
635
		Grunion_Contact_Form_Plugin::$using_contact_form_field = false;
636
		$GLOBALS['shortcode_tags']                             = $old;
637
638
		return $text;
639
	}
640
641
	/**
642
	 * Check if a submission matches the Comment Blacklist.
643
	 * The Comment Blacklist is a means to moderate discussion, and contact
644
	 * forms are 1:1 discussion forums, ripe for abuse by users who are being
645
	 * removed from the public discussion.
646
	 * Attached to `jetpack_contact_form_is_spam`
647
	 *
648
	 * @param bool  $is_spam
649
	 * @param array $form
650
	 * @return bool TRUE => spam, FALSE => not spam
651
	 */
652
	function is_spam_blacklist( $is_spam, $form = array() ) {
653
		if ( $is_spam ) {
654
			return $is_spam;
655
		}
656
657
		if ( wp_blacklist_check( $form['comment_author'], $form['comment_author_email'], $form['comment_author_url'], $form['comment_content'], $form['user_ip'], $form['user_agent'] ) ) {
658
			return true;
659
		}
660
661
		return false;
662
	}
663
664
	/**
665
	 * Populate an array with all values necessary to submit a NEW contact-form feedback to Akismet.
666
	 * Note that this includes the current user_ip etc, so this should only be called when accepting a new item via $_POST
667
	 *
668
	 * @param array $form Contact form feedback array
669
	 * @return array feedback array with additional data ready for submission to Akismet
670
	 */
671
	function prepare_for_akismet( $form ) {
672
		$form['comment_type'] = 'contact_form';
673
		$form['user_ip']      = $_SERVER['REMOTE_ADDR'];
674
		$form['user_agent']   = $_SERVER['HTTP_USER_AGENT'];
675
		$form['referrer']     = $_SERVER['HTTP_REFERER'];
676
		$form['blog']         = get_option( 'home' );
677
678
		foreach ( $_SERVER as $key => $value ) {
679
			if ( ! is_string( $value ) ) {
680
				continue;
681
			}
682
			if ( in_array( $key, array( 'HTTP_COOKIE', 'HTTP_COOKIE2', 'HTTP_USER_AGENT', 'HTTP_REFERER' ) ) ) {
683
				// We don't care about cookies, and the UA and Referrer were caught above.
684
				continue;
685
			} elseif ( in_array( $key, array( 'REMOTE_ADDR', 'REQUEST_URI', 'DOCUMENT_URI' ) ) ) {
686
				// All three of these are relevant indicators and should be passed along.
687
				$form[ $key ] = $value;
688
			} elseif ( wp_startswith( $key, 'HTTP_' ) ) {
689
				// Any other HTTP header indicators.
690
				// `wp_startswith()` is a wpcom helper function and is included in Jetpack via `functions.compat.php`
691
				$form[ $key ] = $value;
692
			}
693
		}
694
695
		return $form;
696
	}
697
698
	/**
699
	 * Submit contact-form data to Akismet to check for spam.
700
	 * If you're accepting a new item via $_POST, run it Grunion_Contact_Form_Plugin::prepare_for_akismet() first
701
	 * Attached to `jetpack_contact_form_is_spam`
702
	 *
703
	 * @param bool  $is_spam
704
	 * @param array $form
705
	 * @return bool|WP_Error TRUE => spam, FALSE => not spam, WP_Error => stop processing entirely
706
	 */
707
	function is_spam_akismet( $is_spam, $form = array() ) {
708
		global $akismet_api_host, $akismet_api_port;
709
710
		// The signature of this function changed from accepting just $form.
711
		// If something only sends an array, assume it's still using the old
712
		// signature and work around it.
713
		if ( empty( $form ) && is_array( $is_spam ) ) {
714
			$form    = $is_spam;
715
			$is_spam = false;
716
		}
717
718
		// If a previous filter has alrady marked this as spam, trust that and move on.
719
		if ( $is_spam ) {
720
			return $is_spam;
721
		}
722
723
		if ( ! function_exists( 'akismet_http_post' ) && ! defined( 'AKISMET_VERSION' ) ) {
724
			return false;
725
		}
726
727
		$query_string = http_build_query( $form );
728
729
		if ( method_exists( 'Akismet', 'http_post' ) ) {
730
			$response = Akismet::http_post( $query_string, 'comment-check' );
731
		} else {
732
			$response = akismet_http_post( $query_string, $akismet_api_host, '/1.1/comment-check', $akismet_api_port );
733
		}
734
735
		$result = false;
736
737
		if ( isset( $response[0]['x-akismet-pro-tip'] ) && 'discard' === trim( $response[0]['x-akismet-pro-tip'] ) && get_option( 'akismet_strictness' ) === '1' ) {
738
			$result = new WP_Error( 'feedback-discarded', __( 'Feedback discarded.', 'jetpack' ) );
739
		} elseif ( isset( $response[1] ) && 'true' == trim( $response[1] ) ) { // 'true' is spam
740
			$result = true;
741
		}
742
743
		/**
744
		 * Filter the results returned by Akismet for each submitted contact form.
745
		 *
746
		 * @module contact-form
747
		 *
748
		 * @since 1.3.1
749
		 *
750
		 * @param WP_Error|bool $result Is the submitted feedback spam.
751
		 * @param array|bool $form Submitted feedback.
752
		 */
753
		return apply_filters( 'contact_form_is_spam_akismet', $result, $form );
754
	}
755
756
	/**
757
	 * Submit a feedback as either spam or ham
758
	 *
759
	 * @param string $as Either 'spam' or 'ham'.
760
	 * @param array  $form the contact-form data
761
	 */
762
	function akismet_submit( $as, $form ) {
763
		global $akismet_api_host, $akismet_api_port;
764
765
		if ( ! in_array( $as, array( 'ham', 'spam' ) ) ) {
766
			return false;
767
		}
768
769
		$query_string = '';
770
		if ( is_array( $form ) ) {
771
			$query_string = http_build_query( $form );
772
		}
773
		if ( method_exists( 'Akismet', 'http_post' ) ) {
774
			$response = Akismet::http_post( $query_string, "submit-{$as}" );
775
		} else {
776
			$response = akismet_http_post( $query_string, $akismet_api_host, "/1.1/submit-{$as}", $akismet_api_port );
777
		}
778
779
		return trim( $response[1] );
780
	}
781
782
	/**
783
	 * Prints the menu
784
	 */
785
	function export_form() {
786
		$current_screen = get_current_screen();
787
		if ( ! in_array( $current_screen->id, array( 'edit-feedback', 'feedback_page_feedback-export' ) ) ) {
788
			return;
789
		}
790
791
		if ( ! current_user_can( 'export' ) ) {
792
			return;
793
		}
794
795
		// if there aren't any feedbacks, bail out
796
		if ( ! (int) wp_count_posts( 'feedback' )->publish ) {
797
			return;
798
		}
799
		?>
800
801
		<div id="feedback-export" style="display:none">
802
			<h2><?php _e( 'Export feedback as CSV', 'jetpack' ); ?></h2>
803
			<div class="clear"></div>
804
			<form action="<?php echo admin_url( 'admin-post.php' ); ?>" method="post" class="form">
805
				<?php wp_nonce_field( 'feedback_export', 'feedback_export_nonce' ); ?>
806
807
				<input name="action" value="feedback_export" type="hidden">
808
				<label for="post"><?php _e( 'Select feedback to download', 'jetpack' ); ?></label>
809
				<select name="post">
810
					<option value="all"><?php esc_html_e( 'All posts', 'jetpack' ); ?></option>
811
					<?php echo $this->get_feedbacks_as_options(); ?>
812
				</select>
813
814
				<br><br>
815
				<input type="submit" name="submit" id="submit" class="button button-primary" value="<?php esc_html_e( 'Download', 'jetpack' ); ?>">
816
			</form>
817
		</div>
818
819
		<?php
820
		// There aren't any usable actions in core to output the "export feedback" form in the correct place,
821
		// so this inline JS moves it from the top of the page to the bottom.
822
		?>
823
		<script type='text/javascript'>
824
		    var menu = document.getElementById( 'feedback-export' ),
825
                wrapper = document.getElementsByClassName( 'wrap' )[0];
826
            <?php if ( 'edit-feedback' === $current_screen->id ) : ?>
827
            wrapper.appendChild(menu);
828
            <?php endif; ?>
829
            menu.style.display = 'block';
830
		</script>
831
		<?php
832
	}
833
834
	/**
835
	 * Fetch post content for a post and extract just the comment.
836
	 *
837
	 * @param int $post_id The post id to fetch the content for.
838
	 *
839
	 * @return string Trimmed post comment.
840
	 *
841
	 * @codeCoverageIgnore
842
	 */
843
	public function get_post_content_for_csv_export( $post_id ) {
844
		$post_content = get_post_field( 'post_content', $post_id );
845
		$content      = explode( '<!--more-->', $post_content );
846
847
		return trim( $content[0] );
848
	}
849
850
	/**
851
	 * Get `_feedback_extra_fields` field from post meta data.
852
	 *
853
	 * @param int $post_id Id of the post to fetch meta data for.
854
	 *
855
	 * @return mixed
856
	 *
857
	 * @codeCoverageIgnore - No need to be covered.
858
	 */
859
	public function get_post_meta_for_csv_export( $post_id ) {
860
		return get_post_meta( $post_id, '_feedback_extra_fields', true );
861
	}
862
863
	/**
864
	 * Get parsed feedback post fields.
865
	 *
866
	 * @param int $post_id Id of the post to fetch parsed contents for.
867
	 *
868
	 * @return array
869
	 *
870
	 * @codeCoverageIgnore - No need to be covered.
871
	 */
872
	public function get_parsed_field_contents_of_post( $post_id ) {
873
		return self::parse_fields_from_content( $post_id );
874
	}
875
876
	/**
877
	 * Properly maps fields that are missing from the post meta data
878
	 * to names, that are similar to those of the post meta.
879
	 *
880
	 * @param array $parsed_post_content Parsed post content
881
	 *
882
	 * @see parse_fields_from_content for how the input data is generated.
883
	 *
884
	 * @return array Mapped fields.
885
	 */
886
	public function map_parsed_field_contents_of_post_to_field_names( $parsed_post_content ) {
887
888
		$mapped_fields = array();
889
890
		$field_mapping = array(
891
			'_feedback_subject'      => __( 'Contact Form', 'jetpack' ),
892
			'_feedback_author'       => '1_Name',
893
			'_feedback_author_email' => '2_Email',
894
			'_feedback_author_url'   => '3_Website',
895
			'_feedback_main_comment' => '4_Comment',
896
		);
897
898
		foreach ( $field_mapping as $parsed_field_name => $field_name ) {
899
			if (
900
				isset( $parsed_post_content[ $parsed_field_name ] )
901
				&& ! empty( $parsed_post_content[ $parsed_field_name ] )
902
			) {
903
				$mapped_fields[ $field_name ] = $parsed_post_content[ $parsed_field_name ];
904
			}
905
		}
906
907
		return $mapped_fields;
908
	}
909
910
	/**
911
	 * Registers the personal data exporter.
912
	 *
913
	 * @since 6.1.1
914
	 *
915
	 * @param  array $exporters An array of personal data exporters.
916
	 *
917
	 * @return array $exporters An array of personal data exporters.
918
	 */
919
	public function register_personal_data_exporter( $exporters ) {
920
		$exporters['jetpack-feedback'] = array(
921
			'exporter_friendly_name' => __( 'Feedback', 'jetpack' ),
922
			'callback'               => array( $this, 'personal_data_exporter' ),
923
		);
924
925
		return $exporters;
926
	}
927
928
	/**
929
	 * Registers the personal data eraser.
930
	 *
931
	 * @since 6.1.1
932
	 *
933
	 * @param  array $erasers An array of personal data erasers.
934
	 *
935
	 * @return array $erasers An array of personal data erasers.
936
	 */
937
	public function register_personal_data_eraser( $erasers ) {
938
		$erasers['jetpack-feedback'] = array(
939
			'eraser_friendly_name' => __( 'Feedback', 'jetpack' ),
940
			'callback'             => array( $this, 'personal_data_eraser' ),
941
		);
942
943
		return $erasers;
944
	}
945
946
	/**
947
	 * Exports personal data.
948
	 *
949
	 * @since 6.1.1
950
	 *
951
	 * @param  string $email  Email address.
952
	 * @param  int    $page   Page to export.
953
	 *
954
	 * @return array  $return Associative array with keys expected by core.
955
	 */
956
	public function personal_data_exporter( $email, $page = 1 ) {
957
		return $this->_internal_personal_data_exporter( $email, $page );
958
	}
959
960
	/**
961
	 * Internal method for exporting personal data.
962
	 *
963
	 * Allows us to have a different signature than core expects
964
	 * while protecting against future core API changes.
965
	 *
966
	 * @internal
967
	 * @since 6.5
968
	 *
969
	 * @param  string $email    Email address.
970
	 * @param  int    $page     Page to export.
971
	 * @param  int    $per_page Number of feedbacks to process per page. Internal use only (testing)
972
	 *
973
	 * @return array            Associative array with keys expected by core.
974
	 */
975
	public function _internal_personal_data_exporter( $email, $page = 1, $per_page = 250 ) {
976
		$export_data = array();
977
		$post_ids    = $this->personal_data_post_ids_by_email( $email, $per_page, $page );
978
979
		foreach ( $post_ids as $post_id ) {
980
			$post_fields = $this->get_parsed_field_contents_of_post( $post_id );
981
982
			if ( ! is_array( $post_fields ) || empty( $post_fields['_feedback_subject'] ) ) {
983
				continue; // Corrupt data.
984
			}
985
986
			$post_fields['_feedback_main_comment'] = $this->get_post_content_for_csv_export( $post_id );
987
			$post_fields                           = $this->map_parsed_field_contents_of_post_to_field_names( $post_fields );
988
989
			if ( ! is_array( $post_fields ) || empty( $post_fields ) ) {
990
				continue; // No fields to export.
991
			}
992
993
			$post_meta = $this->get_post_meta_for_csv_export( $post_id );
994
			$post_meta = is_array( $post_meta ) ? $post_meta : array();
995
996
			$post_export_data = array();
997
			$post_data        = array_merge( $post_fields, $post_meta );
998
			ksort( $post_data );
999
1000
			foreach ( $post_data as $post_data_key => $post_data_value ) {
1001
				$post_export_data[] = array(
1002
					'name'  => preg_replace( '/^[0-9]+_/', '', $post_data_key ),
1003
					'value' => $post_data_value,
1004
				);
1005
			}
1006
1007
			$export_data[] = array(
1008
				'group_id'    => 'feedback',
1009
				'group_label' => __( 'Feedback', 'jetpack' ),
1010
				'item_id'     => 'feedback-' . $post_id,
1011
				'data'        => $post_export_data,
1012
			);
1013
		}
1014
1015
		return array(
1016
			'data' => $export_data,
1017
			'done' => count( $post_ids ) < $per_page,
1018
		);
1019
	}
1020
1021
	/**
1022
	 * Erases personal data.
1023
	 *
1024
	 * @since 6.1.1
1025
	 *
1026
	 * @param  string $email Email address.
1027
	 * @param  int    $page  Page to erase.
1028
	 *
1029
	 * @return array         Associative array with keys expected by core.
1030
	 */
1031
	public function personal_data_eraser( $email, $page = 1 ) {
1032
		return $this->_internal_personal_data_eraser( $email, $page );
1033
	}
1034
1035
	/**
1036
	 * Internal method for erasing personal data.
1037
	 *
1038
	 * Allows us to have a different signature than core expects
1039
	 * while protecting against future core API changes.
1040
	 *
1041
	 * @internal
1042
	 * @since 6.5
1043
	 *
1044
	 * @param  string $email    Email address.
1045
	 * @param  int    $page     Page to erase.
1046
	 * @param  int    $per_page Number of feedbacks to process per page. Internal use only (testing)
1047
	 *
1048
	 * @return array            Associative array with keys expected by core.
1049
	 */
1050
	public function _internal_personal_data_eraser( $email, $page = 1, $per_page = 250 ) {
1051
		$removed      = false;
1052
		$retained     = false;
1053
		$messages     = array();
1054
		$option_name  = sprintf( '_jetpack_pde_feedback_%s', md5( $email ) );
1055
		$last_post_id = 1 === $page ? 0 : get_option( $option_name, 0 );
1056
		$post_ids     = $this->personal_data_post_ids_by_email( $email, $per_page, $page, $last_post_id );
1057
1058
		foreach ( $post_ids as $post_id ) {
1059
			/**
1060
			 * Filters whether to erase a particular Feedback post.
1061
			 *
1062
			 * @since 6.3.0
1063
			 *
1064
			 * @param bool|string $prevention_message Whether to apply erase the Feedback post (bool).
1065
			 *                                        Custom prevention message (string). Default true.
1066
			 * @param int         $post_id            Feedback post ID.
1067
			 */
1068
			$prevention_message = apply_filters( 'grunion_contact_form_delete_feedback_post', true, $post_id );
1069
1070
			if ( true !== $prevention_message ) {
1071
				if ( $prevention_message && is_string( $prevention_message ) ) {
1072
					$messages[] = esc_html( $prevention_message );
1073
				} else {
1074
					$messages[] = sprintf(
1075
					// translators: %d: Post ID.
1076
						__( 'Feedback ID %d could not be removed at this time.', 'jetpack' ),
1077
						$post_id
1078
					);
1079
				}
1080
1081
				$retained = true;
1082
1083
				continue;
1084
			}
1085
1086
			if ( wp_delete_post( $post_id, true ) ) {
1087
				$removed = true;
1088
			} else {
1089
				$retained   = true;
1090
				$messages[] = sprintf(
1091
				// translators: %d: Post ID.
1092
					__( 'Feedback ID %d could not be removed at this time.', 'jetpack' ),
1093
					$post_id
1094
				);
1095
			}
1096
		}
1097
1098
		$done = count( $post_ids ) < $per_page;
1099
1100
		if ( $done ) {
1101
			delete_option( $option_name );
1102
		} else {
1103
			update_option( $option_name, (int) $post_id );
1104
		}
1105
1106
		return array(
1107
			'items_removed'  => $removed,
1108
			'items_retained' => $retained,
1109
			'messages'       => $messages,
1110
			'done'           => $done,
1111
		);
1112
	}
1113
1114
	/**
1115
	 * Queries personal data by email address.
1116
	 *
1117
	 * @since 6.1.1
1118
	 *
1119
	 * @param  string $email        Email address.
1120
	 * @param  int    $per_page     Post IDs per page. Default is `250`.
1121
	 * @param  int    $page         Page to query. Default is `1`.
1122
	 * @param  int    $last_post_id Page to query. Default is `0`. If non-zero, used instead of $page.
1123
	 *
1124
	 * @return array An array of post IDs.
1125
	 */
1126
	public function personal_data_post_ids_by_email( $email, $per_page = 250, $page = 1, $last_post_id = 0 ) {
1127
		add_filter( 'posts_search', array( $this, 'personal_data_search_filter' ) );
1128
1129
		$this->pde_last_post_id_erased = $last_post_id;
1130
		$this->pde_email_address       = $email;
1131
1132
		$post_ids = get_posts(
1133
			array(
1134
				'post_type'        => 'feedback',
1135
				'post_status'      => 'publish',
1136
				// This search parameter gets overwritten in ->personal_data_search_filter()
1137
				's'                => '..PDE..AUTHOR EMAIL:..PDE..',
1138
				'sentence'         => true,
1139
				'order'            => 'ASC',
1140
				'orderby'          => 'ID',
1141
				'fields'           => 'ids',
1142
				'posts_per_page'   => $per_page,
1143
				'paged'            => $last_post_id ? 1 : $page,
1144
				'suppress_filters' => false,
1145
			)
1146
		);
1147
1148
		$this->pde_last_post_id_erased = 0;
1149
		$this->pde_email_address       = '';
1150
1151
		remove_filter( 'posts_search', array( $this, 'personal_data_search_filter' ) );
1152
1153
		return $post_ids;
1154
	}
1155
1156
	/**
1157
	 * Filters searches by email address.
1158
	 *
1159
	 * @since 6.1.1
1160
	 *
1161
	 * @param  string $search SQL where clause.
1162
	 *
1163
	 * @return array          Filtered SQL where clause.
1164
	 */
1165
	public function personal_data_search_filter( $search ) {
1166
		global $wpdb;
1167
1168
		/*
1169
		 * Limits search to `post_content` only, and we only match the
1170
		 * author's email address whenever it's on a line by itself.
1171
		 */
1172
		if ( $this->pde_email_address && false !== strpos( $search, '..PDE..AUTHOR EMAIL:..PDE..' ) ) {
1173
			$search = $wpdb->prepare(
1174
				" AND (
1175
					{$wpdb->posts}.post_content LIKE %s
1176
					OR {$wpdb->posts}.post_content LIKE %s
1177
				)",
1178
				// `chr( 10 )` = `\n`, `chr( 13 )` = `\r`
1179
				'%' . $wpdb->esc_like( chr( 10 ) . 'AUTHOR EMAIL: ' . $this->pde_email_address . chr( 10 ) ) . '%',
1180
				'%' . $wpdb->esc_like( chr( 13 ) . 'AUTHOR EMAIL: ' . $this->pde_email_address . chr( 13 ) ) . '%'
1181
			);
1182
1183
			if ( $this->pde_last_post_id_erased ) {
1184
				$search .= $wpdb->prepare( " AND {$wpdb->posts}.ID > %d", $this->pde_last_post_id_erased );
1185
			}
1186
		}
1187
1188
		return $search;
1189
	}
1190
1191
	/**
1192
	 * Prepares feedback post data for CSV export.
1193
	 *
1194
	 * @param array $post_ids Post IDs to fetch the data for. These need to be Feedback posts.
1195
	 *
1196
	 * @return array
1197
	 */
1198
	public function get_export_data_for_posts( $post_ids ) {
1199
1200
		$posts_data  = array();
1201
		$field_names = array();
1202
		$result      = array();
1203
1204
		/**
1205
		 * Fetch posts and get the possible field names for later use
1206
		 */
1207
		foreach ( $post_ids as $post_id ) {
1208
1209
			/**
1210
			 * Fetch post main data, because we need the subject and author data for the feedback form.
1211
			 */
1212
			$post_real_data = $this->get_parsed_field_contents_of_post( $post_id );
1213
1214
			/**
1215
			 * If `$post_real_data` is not an array or there is no `_feedback_subject` set,
1216
			 * then something must be wrong with the feedback post. Skip it.
1217
			 */
1218
			if ( ! is_array( $post_real_data ) || ! isset( $post_real_data['_feedback_subject'] ) ) {
1219
				continue;
1220
			}
1221
1222
			/**
1223
			 * Fetch main post comment. This is from the default textarea fields.
1224
			 * If it is non-empty, then we add it to data, otherwise skip it.
1225
			 */
1226
			$post_comment_content = $this->get_post_content_for_csv_export( $post_id );
1227
			if ( ! empty( $post_comment_content ) ) {
1228
				$post_real_data['_feedback_main_comment'] = $post_comment_content;
1229
			}
1230
1231
			/**
1232
			 * Map parsed fields to proper field names
1233
			 */
1234
			$mapped_fields = $this->map_parsed_field_contents_of_post_to_field_names( $post_real_data );
1235
1236
			/**
1237
			 * Fetch post meta data.
1238
			 */
1239
			$post_meta_data = $this->get_post_meta_for_csv_export( $post_id );
1240
1241
			/**
1242
			 * If `$post_meta_data` is not an array or if it is empty, then there is no
1243
			 * extra feedback to work with. Create an empty array.
1244
			 */
1245
			if ( ! is_array( $post_meta_data ) || empty( $post_meta_data ) ) {
1246
				$post_meta_data = array();
1247
			}
1248
1249
			/**
1250
			 * Prepend the feedback subject to the list of fields.
1251
			 */
1252
			$post_meta_data = array_merge(
1253
				$mapped_fields,
1254
				$post_meta_data
1255
			);
1256
1257
			/**
1258
			 * Save post metadata for later usage.
1259
			 */
1260
			$posts_data[ $post_id ] = $post_meta_data;
1261
1262
			/**
1263
			 * Save field names, so we can use them as header fields later in the CSV.
1264
			 */
1265
			$field_names = array_merge( $field_names, array_keys( $post_meta_data ) );
1266
		}
1267
1268
		/**
1269
		 * Make sure the field names are unique, because we don't want duplicate data.
1270
		 */
1271
		$field_names = array_unique( $field_names );
1272
1273
		/**
1274
		 * Sort the field names by the field id number
1275
		 */
1276
		sort( $field_names, SORT_NUMERIC );
1277
1278
		/**
1279
		 * Loop through every post, which is essentially CSV row.
1280
		 */
1281
		foreach ( $posts_data as $post_id => $single_post_data ) {
1282
1283
			/**
1284
			 * Go through all the possible fields and check if the field is available
1285
			 * in the current post.
1286
			 *
1287
			 * If it is - add the data as a value.
1288
			 * If it is not - add an empty string, which is just a placeholder in the CSV.
1289
			 */
1290
			foreach ( $field_names as $single_field_name ) {
1291
				if (
1292
					isset( $single_post_data[ $single_field_name ] )
1293
					&& ! empty( $single_post_data[ $single_field_name ] )
1294
				) {
1295
					$result[ $single_field_name ][] = trim( $single_post_data[ $single_field_name ] );
1296
				} else {
1297
					$result[ $single_field_name ][] = '';
1298
				}
1299
			}
1300
		}
1301
1302
		return $result;
1303
	}
1304
1305
	/**
1306
	 * download as a csv a contact form or all of them in a csv file
1307
	 */
1308
	function download_feedback_as_csv() {
1309
		if ( empty( $_POST['feedback_export_nonce'] ) ) {
1310
			return;
1311
		}
1312
1313
		check_admin_referer( 'feedback_export', 'feedback_export_nonce' );
1314
1315
		if ( ! current_user_can( 'export' ) ) {
1316
			return;
1317
		}
1318
1319
		$args = array(
1320
			'posts_per_page'   => -1,
1321
			'post_type'        => 'feedback',
1322
			'post_status'      => 'publish',
1323
			'order'            => 'ASC',
1324
			'fields'           => 'ids',
1325
			'suppress_filters' => false,
1326
		);
1327
1328
		$filename = date( 'Y-m-d' ) . '-feedback-export.csv';
1329
1330
		// Check if we want to download all the feedbacks or just a certain contact form
1331
		if ( ! empty( $_POST['post'] ) && $_POST['post'] !== 'all' ) {
1332
			$args['post_parent'] = (int) $_POST['post'];
1333
			$filename            = date( 'Y-m-d' ) . '-' . str_replace( '&nbsp;', '-', get_the_title( (int) $_POST['post'] ) ) . '.csv';
1334
		}
1335
1336
		$feedbacks = get_posts( $args );
1337
1338
		if ( empty( $feedbacks ) ) {
1339
			return;
1340
		}
1341
1342
		$filename = sanitize_file_name( $filename );
1343
1344
		/**
1345
		 * Prepare data for export.
1346
		 */
1347
		$data = $this->get_export_data_for_posts( $feedbacks );
1348
1349
		/**
1350
		 * If `$data` is empty, there's nothing we can do below.
1351
		 */
1352
		if ( ! is_array( $data ) || empty( $data ) ) {
1353
			return;
1354
		}
1355
1356
		/**
1357
		 * Extract field names from `$data` for later use.
1358
		 */
1359
		$fields = array_keys( $data );
1360
1361
		/**
1362
		 * Count how many rows will be exported.
1363
		 */
1364
		$row_count = count( reset( $data ) );
1365
1366
		// Forces the download of the CSV instead of echoing
1367
		header( 'Content-Disposition: attachment; filename=' . $filename );
1368
		header( 'Pragma: no-cache' );
1369
		header( 'Expires: 0' );
1370
		header( 'Content-Type: text/csv; charset=utf-8' );
1371
1372
		$output = fopen( 'php://output', 'w' );
1373
1374
		/**
1375
		 * Print CSV headers
1376
		 */
1377
		fputcsv( $output, $fields );
1378
1379
		/**
1380
		 * Print rows to the output.
1381
		 */
1382
		for ( $i = 0; $i < $row_count; $i ++ ) {
1383
1384
			$current_row = array();
1385
1386
			/**
1387
			 * Put all the fields in `$current_row` array.
1388
			 */
1389
			foreach ( $fields as $single_field_name ) {
1390
				$current_row[] = $this->esc_csv( $data[ $single_field_name ][ $i ] );
1391
			}
1392
1393
			/**
1394
			 * Output the complete CSV row
1395
			 */
1396
			fputcsv( $output, $current_row );
1397
		}
1398
1399
		fclose( $output );
1400
	}
1401
1402
	/**
1403
	 * Escape a string to be used in a CSV context
1404
	 *
1405
	 * Malicious input can inject formulas into CSV files, opening up the possibility for phishing attacks and
1406
	 * disclosure of sensitive information.
1407
	 *
1408
	 * Additionally, Excel exposes the ability to launch arbitrary commands through the DDE protocol.
1409
	 *
1410
	 * @see https://www.contextis.com/en/blog/comma-separated-vulnerabilities
1411
	 *
1412
	 * @param string $field
1413
	 *
1414
	 * @return string
1415
	 */
1416
	public function esc_csv( $field ) {
1417
		$active_content_triggers = array( '=', '+', '-', '@' );
1418
1419
		if ( in_array( mb_substr( $field, 0, 1 ), $active_content_triggers, true ) ) {
1420
			$field = "'" . $field;
1421
		}
1422
1423
		return $field;
1424
	}
1425
1426
	/**
1427
	 * Returns a string of HTML <option> items from an array of posts
1428
	 *
1429
	 * @return string a string of HTML <option> items
1430
	 */
1431
	protected function get_feedbacks_as_options() {
1432
		$options = '';
1433
1434
		// Get the feedbacks' parents' post IDs
1435
		$feedbacks = get_posts(
1436
			array(
1437
				'fields'           => 'id=>parent',
1438
				'posts_per_page'   => 100000,
1439
				'post_type'        => 'feedback',
1440
				'post_status'      => 'publish',
1441
				'suppress_filters' => false,
1442
			)
1443
		);
1444
		$parents   = array_unique( array_values( $feedbacks ) );
1445
1446
		$posts = get_posts(
1447
			array(
1448
				'orderby'          => 'ID',
1449
				'posts_per_page'   => 1000,
1450
				'post_type'        => 'any',
1451
				'post__in'         => array_values( $parents ),
1452
				'suppress_filters' => false,
1453
			)
1454
		);
1455
1456
		// creates the string of <option> elements
1457
		foreach ( $posts as $post ) {
1458
			$options .= sprintf( '<option value="%s">%s</option>', esc_attr( $post->ID ), esc_html( $post->post_title ) );
1459
		}
1460
1461
		return $options;
1462
	}
1463
1464
	/**
1465
	 * Get the names of all the form's fields
1466
	 *
1467
	 * @param  array|int $posts the post we want the fields of
1468
	 *
1469
	 * @return array     the array of fields
1470
	 *
1471
	 * @deprecated As this is no longer necessary as of the CSV export rewrite. - 2015-12-29
1472
	 */
1473
	protected function get_field_names( $posts ) {
1474
		$posts      = (array) $posts;
1475
		$all_fields = array();
1476
1477
		foreach ( $posts as $post ) {
1478
			$fields = self::parse_fields_from_content( $post );
1479
1480
			if ( isset( $fields['_feedback_all_fields'] ) ) {
1481
				$extra_fields = array_keys( $fields['_feedback_all_fields'] );
1482
				$all_fields   = array_merge( $all_fields, $extra_fields );
1483
			}
1484
		}
1485
1486
		$all_fields = array_unique( $all_fields );
1487
		return $all_fields;
1488
	}
1489
1490
	public static function parse_fields_from_content( $post_id ) {
1491
		static $post_fields;
1492
1493
		if ( ! is_array( $post_fields ) ) {
1494
			$post_fields = array();
1495
		}
1496
1497
		if ( isset( $post_fields[ $post_id ] ) ) {
1498
			return $post_fields[ $post_id ];
1499
		}
1500
1501
		$all_values   = array();
1502
		$post_content = get_post_field( 'post_content', $post_id );
1503
		$content      = explode( '<!--more-->', $post_content );
1504
		$lines        = array();
1505
1506
		if ( count( $content ) > 1 ) {
1507
			$content  = str_ireplace( array( '<br />', ')</p>' ), '', $content[1] );
1508
			$one_line = preg_replace( '/\s+/', ' ', $content );
1509
			$one_line = preg_replace( '/.*Array \( (.*)\)/', '$1', $one_line );
1510
1511
			preg_match_all( '/\[([^\]]+)\] =\&gt\; ([^\[]+)/', $one_line, $matches );
1512
1513
			if ( count( $matches ) > 1 ) {
1514
				$all_values = array_combine( array_map( 'trim', $matches[1] ), array_map( 'trim', $matches[2] ) );
1515
			}
1516
1517
			$lines = array_filter( explode( "\n", $content ) );
1518
		}
1519
1520
		$var_map = array(
1521
			'AUTHOR'       => '_feedback_author',
1522
			'AUTHOR EMAIL' => '_feedback_author_email',
1523
			'AUTHOR URL'   => '_feedback_author_url',
1524
			'SUBJECT'      => '_feedback_subject',
1525
			'IP'           => '_feedback_ip',
1526
		);
1527
1528
		$fields = array();
1529
1530
		foreach ( $lines as $line ) {
1531
			$vars = explode( ': ', $line, 2 );
1532
			if ( ! empty( $vars ) ) {
1533
				if ( isset( $var_map[ $vars[0] ] ) ) {
1534
					$fields[ $var_map[ $vars[0] ] ] = self::strip_tags( trim( $vars[1] ) );
1535
				}
1536
			}
1537
		}
1538
1539
		$fields['_feedback_all_fields'] = $all_values;
1540
1541
		$post_fields[ $post_id ] = $fields;
1542
1543
		return $fields;
1544
	}
1545
1546
	/**
1547
	 * Creates a valid csv row from a post id
1548
	 *
1549
	 * @param  int   $post_id The id of the post
1550
	 * @param  array $fields  An array containing the names of all the fields of the csv
1551
	 * @return String The csv row
1552
	 *
1553
	 * @deprecated This is no longer needed, as of the CSV export rewrite.
1554
	 */
1555
	protected static function make_csv_row_from_feedback( $post_id, $fields ) {
1556
		$content_fields = self::parse_fields_from_content( $post_id );
1557
		$all_fields     = array();
1558
1559
		if ( isset( $content_fields['_feedback_all_fields'] ) ) {
1560
			$all_fields = $content_fields['_feedback_all_fields'];
1561
		}
1562
1563
		// Overwrite the parsed content with the content we stored in post_meta in a better format.
1564
		$extra_fields = get_post_meta( $post_id, '_feedback_extra_fields', true );
1565
		foreach ( $extra_fields as $extra_field => $extra_value ) {
1566
			$all_fields[ $extra_field ] = $extra_value;
1567
		}
1568
1569
		// The first element in all of the exports will be the subject
1570
		$row_items[] = $content_fields['_feedback_subject'];
1571
1572
		// Loop the fields array in order to fill the $row_items array correctly
1573
		foreach ( $fields as $field ) {
1574
			if ( $field === __( 'Contact Form', 'jetpack' ) ) { // the first field will ever be the contact form, so we can continue
1575
				continue;
1576
			} elseif ( array_key_exists( $field, $all_fields ) ) {
1577
				$row_items[] = $all_fields[ $field ];
1578
			} else {
1579
				$row_items[] = '';
1580
			}
1581
		}
1582
1583
		return $row_items;
1584
	}
1585
1586
	public static function get_ip_address() {
1587
		return isset( $_SERVER['REMOTE_ADDR'] ) ? $_SERVER['REMOTE_ADDR'] : null;
1588
	}
1589
1590
	/**
1591
	 * Used to check whether integrations are enabled the contact form for this site
1592
	 *
1593
	 * @return bool True if integrations are enabled, false otherwise.
1594
	 */
1595
	function contact_form_integrations_enabled() {
1596
		// For WPCOM sites
1597 View Code Duplication
		if ( defined( 'IS_WPCOM' ) && IS_WPCOM && function_exists( 'has_any_blog_stickers' ) ) {
1598
			$site_id = $this->get_blog_id();
0 ignored issues
show
The method get_blog_id() does not seem to exist on object<Grunion_Contact_Form_Plugin>.

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

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

Loading history...
1599
			return has_any_blog_stickers( array( 'premium-plan', 'business-plan', 'ecommerce-plan' ), $site_id );
1600
		}
1601
		// For all Jetpack sites
1602
		error_log('message');
1603
		error_log(print_r(Jetpack_Plan::supports( 'contact-form-integrations'), true));
1604
		return Jetpack::is_active() && Jetpack_Plan::supports( 'contact-form-integrations');
1605
	}
1606
}
1607
1608
/**
1609
 * Generic shortcode class.
1610
 * Does nothing other than store structured data and output the shortcode as a string
1611
 *
1612
 * Not very general - specific to Grunion.
1613
 */
1614
class Crunion_Contact_Form_Shortcode {
1615
	/**
1616
	 * @var string the name of the shortcode: [$shortcode_name /]
1617
	 */
1618
	public $shortcode_name;
1619
1620
	/**
1621
	 * @var array key => value pairs for the shortcode's attributes: [$shortcode_name key="value" ... /]
1622
	 */
1623
	public $attributes;
1624
1625
	/**
1626
	 * @var array key => value pair for attribute defaults
1627
	 */
1628
	public $defaults = array();
1629
1630
	/**
1631
	 * @var null|string Null for selfclosing shortcodes.  Hhe inner content of otherwise: [$shortcode_name]$content[/$shortcode_name]
1632
	 */
1633
	public $content;
1634
1635
	/**
1636
	 * @var array Associative array of inner "child" shortcodes equivalent to the $content: [$shortcode_name][child 1/][child 2/][/$shortcode_name]
1637
	 */
1638
	public $fields;
1639
1640
	/**
1641
	 * @var null|string The HTML of the parsed inner "child" shortcodes".  Null for selfclosing shortcodes.
1642
	 */
1643
	public $body;
1644
1645
	/**
1646
	 * @param array       $attributes An associative array of shortcode attributes.  @see shortcode_atts()
1647
	 * @param null|string $content Null for selfclosing shortcodes.  The inner content otherwise.
1648
	 */
1649
	function __construct( $attributes, $content = null ) {
1650
		$this->attributes = $this->unesc_attr( $attributes );
1651
		if ( is_array( $content ) ) {
1652
			$string_content = '';
1653
			foreach ( $content as $field ) {
1654
				$string_content .= (string) $field;
1655
			}
1656
1657
			$this->content = $string_content;
1658
		} else {
1659
			$this->content = $content;
1660
		}
1661
1662
		$this->parse_content( $this->content );
1663
	}
1664
1665
	/**
1666
	 * Processes the shortcode's inner content for "child" shortcodes
1667
	 *
1668
	 * @param string $content The shortcode's inner content: [shortcode]$content[/shortcode]
1669
	 */
1670
	function parse_content( $content ) {
1671
		if ( is_null( $content ) ) {
1672
			$this->body = null;
1673
		}
1674
1675
		$this->body = do_shortcode( $content );
1676
	}
1677
1678
	/**
1679
	 * Returns the value of the requested attribute.
1680
	 *
1681
	 * @param string $key The attribute to retrieve
1682
	 * @return mixed
1683
	 */
1684
	function get_attribute( $key ) {
1685
		return isset( $this->attributes[ $key ] ) ? $this->attributes[ $key ] : null;
1686
	}
1687
1688
	function esc_attr( $value ) {
1689
		if ( is_array( $value ) ) {
1690
			return array_map( array( $this, 'esc_attr' ), $value );
1691
		}
1692
1693
		$value = Grunion_Contact_Form_Plugin::strip_tags( $value );
1694
		$value = _wp_specialchars( $value, ENT_QUOTES, false, true );
1695
1696
		// Shortcode attributes can't contain "]"
1697
		$value = str_replace( ']', '', $value );
1698
		$value = str_replace( ',', '&#x002c;', $value ); // store commas encoded
1699
		$value = strtr(
1700
			$value, array(
1701
				'%' => '%25',
1702
				'&' => '%26',
1703
			)
1704
		);
1705
1706
		// shortcode_parse_atts() does stripcslashes()
1707
		$value = addslashes( $value );
1708
		return $value;
1709
	}
1710
1711
	function unesc_attr( $value ) {
1712
		if ( is_array( $value ) ) {
1713
			return array_map( array( $this, 'unesc_attr' ), $value );
1714
		}
1715
1716
		// For back-compat with old Grunion encoding
1717
		// Also, unencode commas
1718
		$value = strtr(
1719
			$value, array(
1720
				'%26' => '&',
1721
				'%25' => '%',
1722
			)
1723
		);
1724
		$value = preg_replace( array( '/&#x0*22;/i', '/&#x0*27;/i', '/&#x0*26;/i', '/&#x0*2c;/i' ), array( '"', "'", '&', ',' ), $value );
1725
		$value = htmlspecialchars_decode( $value, ENT_QUOTES );
1726
		$value = Grunion_Contact_Form_Plugin::strip_tags( $value );
1727
1728
		return $value;
1729
	}
1730
1731
	/**
1732
	 * Generates the shortcode
1733
	 */
1734
	function __toString() {
1735
		$r = "[{$this->shortcode_name} ";
1736
1737
		foreach ( $this->attributes as $key => $value ) {
1738
			if ( ! $value ) {
1739
				continue;
1740
			}
1741
1742
			if ( isset( $this->defaults[ $key ] ) && $this->defaults[ $key ] == $value ) {
1743
				continue;
1744
			}
1745
1746
			if ( 'id' == $key ) {
1747
				continue;
1748
			}
1749
1750
			$value = $this->esc_attr( $value );
1751
1752
			if ( is_array( $value ) ) {
1753
				$value = join( ',', $value );
1754
			}
1755
1756
			if ( false === strpos( $value, "'" ) ) {
1757
				$value = "'$value'";
1758
			} elseif ( false === strpos( $value, '"' ) ) {
1759
				$value = '"' . $value . '"';
1760
			} else {
1761
				// Shortcodes can't contain both '"' and "'".  Strip one.
1762
				$value = str_replace( "'", '', $value );
1763
				$value = "'$value'";
1764
			}
1765
1766
			$r .= "{$key}={$value} ";
1767
		}
1768
1769
		$r = rtrim( $r );
1770
1771
		if ( $this->fields ) {
1772
			$r .= ']';
1773
1774
			foreach ( $this->fields as $field ) {
1775
				$r .= (string) $field;
1776
			}
1777
1778
			$r .= "[/{$this->shortcode_name}]";
1779
		} else {
1780
			$r .= '/]';
1781
		}
1782
1783
		return $r;
1784
	}
1785
}
1786
1787
/**
1788
 * Class for the contact-form shortcode.
1789
 * Parses shortcode to output the contact form as HTML
1790
 * Sends email and stores the contact form response (a.k.a. "feedback")
1791
 */
1792
class Grunion_Contact_Form extends Crunion_Contact_Form_Shortcode {
1793
	public $shortcode_name = 'contact-form';
1794
1795
	/**
1796
	 * @var WP_Error stores form submission errors
1797
	 */
1798
	public $errors;
1799
1800
	/**
1801
	 * @var string The SHA1 hash of the attributes that comprise the form.
1802
	 */
1803
	public $hash;
1804
1805
	/**
1806
	 * @var Grunion_Contact_Form The most recent (inclusive) contact-form shortcode processed
1807
	 */
1808
	static $last;
1809
1810
	/**
1811
	 * @var Whatever form we are currently looking at. If processed, will become $last
1812
	 */
1813
	static $current_form;
1814
1815
	/**
1816
	 * @var array All found forms, indexed by hash.
1817
	 */
1818
	static $forms = array();
1819
1820
	/**
1821
	 * @var bool Whether to print the grunion.css style when processing the contact-form shortcode
1822
	 */
1823
	static $style = false;
1824
1825
	/**
1826
	 * @var array When printing the submit button, what tags are allowed
1827
	 */
1828
	static $allowed_html_tags_for_submit_button = array( 'br' => array() );
1829
1830
	function __construct( $attributes, $content = null ) {
1831
		global $post;
1832
1833
		$this->hash                 = sha1( json_encode( $attributes ) . $content );
1834
		self::$forms[ $this->hash ] = $this;
1835
1836
		// Set up the default subject and recipient for this form
1837
		$default_to      = '';
1838
		$default_subject = '[' . get_option( 'blogname' ) . ']';
1839
1840
		if ( ! isset( $attributes ) || ! is_array( $attributes ) ) {
1841
			$attributes = array();
1842
		}
1843
1844
		if ( ! empty( $attributes['widget'] ) && $attributes['widget'] ) {
1845
			$default_to      .= get_option( 'admin_email' );
1846
			$attributes['id'] = 'widget-' . $attributes['widget'];
1847
			$default_subject  = sprintf( _x( '%1$s Sidebar', '%1$s = blog name', 'jetpack' ), $default_subject );
1848
		} elseif ( $post ) {
1849
			$attributes['id'] = $post->ID;
1850
			$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 ) );
1851
			$post_author      = get_userdata( $post->post_author );
1852
			$default_to      .= $post_author->user_email;
1853
		}
1854
1855
		// Keep reference to $this for parsing form fields
1856
		self::$current_form = $this;
1857
1858
		$this->defaults = array(
1859
			'to'                 => $default_to,
1860
			'subject'            => $default_subject,
1861
			'show_subject'       => 'no', // only used in back-compat mode
1862
			'widget'             => 0,    // Not exposed to the user. Works with Grunion_Contact_Form_Plugin::widget_atts()
1863
			'id'                 => null, // Not exposed to the user. Set above.
1864
			'submit_button_text' => __( 'Submit', 'jetpack' ),
1865
		);
1866
1867
		$attributes = shortcode_atts( $this->defaults, $attributes, 'contact-form' );
1868
1869
		// We only enable the contact-field shortcode temporarily while processing the contact-form shortcode
1870
		Grunion_Contact_Form_Plugin::$using_contact_form_field = true;
1871
1872
		parent::__construct( $attributes, $content );
1873
1874
		// There were no fields in the contact form. The form was probably just [contact-form /]. Build a default form.
1875
		if ( empty( $this->fields ) ) {
1876
			// same as the original Grunion v1 form
1877
			$default_form = '
1878
				[contact-field label="' . __( 'Name', 'jetpack' ) . '" type="name"  required="true" /]
1879
				[contact-field label="' . __( 'Email', 'jetpack' ) . '" type="email" required="true" /]
1880
				[contact-field label="' . __( 'Website', 'jetpack' ) . '" type="url" /]';
1881
1882
			if ( 'yes' == strtolower( $this->get_attribute( 'show_subject' ) ) ) {
1883
				$default_form .= '
1884
					[contact-field label="' . __( 'Subject', 'jetpack' ) . '" type="subject" /]';
1885
			}
1886
1887
			$default_form .= '
1888
				[contact-field label="' . __( 'Message', 'jetpack' ) . '" type="textarea" /]';
1889
1890
			$this->parse_content( $default_form );
1891
1892
			// Store the shortcode
1893
			$this->store_shortcode( $default_form, $attributes, $this->hash );
1894
		} else {
1895
			// Store the shortcode
1896
			$this->store_shortcode( $content, $attributes, $this->hash );
1897
		}
1898
1899
		// $this->body and $this->fields have been setup.  We no longer need the contact-field shortcode.
1900
		Grunion_Contact_Form_Plugin::$using_contact_form_field = false;
1901
	}
1902
1903
	/**
1904
	 * Store shortcode content for recall later
1905
	 *  - used to receate shortcode when user uses do_shortcode
1906
	 *
1907
	 * @param string $content
1908
	 * @param array $attributes
1909
	 * @param string $hash
1910
	 */
1911
	static function store_shortcode( $content = null, $attributes = null, $hash = null ) {
1912
1913
		if ( $content != null and isset( $attributes['id'] ) ) {
1914
1915
			if ( empty( $hash ) ) {
1916
				$hash = sha1( json_encode( $attributes ) . $content );
1917
			}
1918
1919
			$shortcode_meta = get_post_meta( $attributes['id'], "_g_feedback_shortcode_{$hash}", true );
1920
1921
			if ( $shortcode_meta != '' or $shortcode_meta != $content ) {
1922
				update_post_meta( $attributes['id'], "_g_feedback_shortcode_{$hash}", $content );
1923
1924
				// Save attributes to post_meta for later use. They're not available later in do_shortcode situations.
1925
				update_post_meta( $attributes['id'], "_g_feedback_shortcode_atts_{$hash}", $attributes );
1926
			}
1927
		}
1928
	}
1929
1930
	/**
1931
	 * Toggle for printing the grunion.css stylesheet
1932
	 *
1933
	 * @param bool $style
1934
	 */
1935
	static function style( $style ) {
1936
		$previous_style = self::$style;
1937
		self::$style    = (bool) $style;
1938
		return $previous_style;
1939
	}
1940
1941
	/**
1942
	 * Turn on printing of grunion.css stylesheet
1943
	 *
1944
	 * @see ::style()
1945
	 * @internal
1946
	 * @param bool $style
1947
	 */
1948
	static function _style_on() {
1949
		return self::style( true );
1950
	}
1951
1952
	/**
1953
	 * The contact-form shortcode processor
1954
	 *
1955
	 * @param array       $attributes Key => Value pairs as parsed by shortcode_parse_atts()
1956
	 * @param string|null $content The shortcode's inner content: [contact-form]$content[/contact-form]
1957
	 * @return string HTML for the concat form.
1958
	 */
1959
	static function parse( $attributes, $content ) {
1960
		if ( Settings::is_syncing() ) {
1961
			return '';
1962
		}
1963
		// Create a new Grunion_Contact_Form object (this class)
1964
		$form = new Grunion_Contact_Form( $attributes, $content );
1965
1966
		$id = $form->get_attribute( 'id' );
1967
1968
		if ( ! $id ) { // something terrible has happened
1969
			return '[contact-form]';
1970
		}
1971
1972
		if ( is_feed() ) {
1973
			return '[contact-form]';
1974
		}
1975
1976
		self::$last = $form;
1977
1978
		// Enqueue the grunion.css stylesheet if self::$style allows it
1979
		if ( self::$style && ( empty( $_REQUEST['action'] ) || $_REQUEST['action'] != 'grunion_shortcode_to_json' ) ) {
1980
			// Enqueue the style here instead of printing it, because if some other plugin has run the_post()+rewind_posts(),
1981
			// (like VideoPress does), the style tag gets "printed" the first time and discarded, leaving the contact form unstyled.
1982
			// when WordPress does the real loop.
1983
			wp_enqueue_style( 'grunion.css' );
1984
		}
1985
1986
		$r  = '';
1987
		$r .= "<div id='contact-form-$id'>\n";
1988
1989
		if ( is_wp_error( $form->errors ) && $form->errors->get_error_codes() ) {
1990
			// There are errors.  Display them
1991
			$r .= "<div class='form-error'>\n<h3>" . __( 'Error!', 'jetpack' ) . "</h3>\n<ul class='form-errors'>\n";
1992
			foreach ( $form->errors->get_error_messages() as $message ) {
1993
				$r .= "\t<li class='form-error-message'>" . esc_html( $message ) . "</li>\n";
1994
			}
1995
			$r .= "</ul>\n</div>\n\n";
1996
		}
1997
1998
		if ( isset( $_GET['contact-form-id'] )
1999
		     && $_GET['contact-form-id'] == self::$last->get_attribute( 'id' )
2000
		     && isset( $_GET['contact-form-sent'], $_GET['contact-form-hash'] )
2001
		     && hash_equals( $form->hash, $_GET['contact-form-hash'] ) ) {
2002
			// The contact form was submitted.  Show the success message/results
2003
			$feedback_id = (int) $_GET['contact-form-sent'];
2004
2005
			$back_url = remove_query_arg( array( 'contact-form-id', 'contact-form-sent', '_wpnonce' ) );
2006
2007
			$r_success_message =
2008
				'<h3>' . __( 'Message Sent', 'jetpack' ) .
2009
				' (<a href="' . esc_url( $back_url ) . '">' . esc_html__( 'go back', 'jetpack' ) . '</a>)' .
2010
				"</h3>\n\n";
2011
2012
			// Don't show the feedback details unless the nonce matches
2013
			if ( $feedback_id && wp_verify_nonce( stripslashes( $_GET['_wpnonce'] ), "contact-form-sent-{$feedback_id}" ) ) {
2014
				$r_success_message .= self::success_message( $feedback_id, $form );
2015
			}
2016
2017
			/**
2018
			 * Filter the message returned after a successful contact form submission.
2019
			 *
2020
			 * @module contact-form
2021
			 *
2022
			 * @since 1.3.1
2023
			 *
2024
			 * @param string $r_success_message Success message.
2025
			 */
2026
			$r .= apply_filters( 'grunion_contact_form_success_message', $r_success_message );
2027
		} else {
2028
			// Nothing special - show the normal contact form
2029
			if ( $form->get_attribute( 'widget' ) ) {
2030
				// Submit form to the current URL
2031
				$url = remove_query_arg( array( 'contact-form-id', 'contact-form-sent', 'action', '_wpnonce' ) );
2032
			} else {
2033
				// Submit form to the post permalink
2034
				$url = get_permalink();
2035
			}
2036
2037
			// For SSL/TLS page. See RFC 3986 Section 4.2
2038
			$url = set_url_scheme( $url );
2039
2040
			// May eventually want to send this to admin-post.php...
2041
			/**
2042
			 * Filter the contact form action URL.
2043
			 *
2044
			 * @module contact-form
2045
			 *
2046
			 * @since 1.3.1
2047
			 *
2048
			 * @param string $contact_form_id Contact form post URL.
2049
			 * @param $post $GLOBALS['post'] Post global variable.
2050
			 * @param int $id Contact Form ID.
2051
			 */
2052
			$url = apply_filters( 'grunion_contact_form_form_action', "{$url}#contact-form-{$id}", $GLOBALS['post'], $id );
2053
2054
			$r .= "<form action='" . esc_url( $url ) . "' method='post' class='contact-form commentsblock'>\n";
2055
			$r .= $form->body;
2056
			$r .= "\t<p class='contact-submit'>\n";
2057
2058
			$gutenberg_submit_button_classes = '';
2059
			if ( ! empty( $attributes['submitButtonClasses'] ) ) {
2060
				$gutenberg_submit_button_classes = ' ' . $attributes['submitButtonClasses'];
2061
			}
2062
2063
			/**
2064
			 * Filter the contact form submit button class attribute.
2065
			 *
2066
			 * @module contact-form
2067
			 *
2068
			 * @since 6.6.0
2069
			 *
2070
			 * @param string $class Additional CSS classes for button attribute.
2071
			 */
2072
			$submit_button_class = apply_filters( 'jetpack_contact_form_submit_button_class', 'pushbutton-wide' . $gutenberg_submit_button_classes );
2073
2074
			$submit_button_styles = '';
2075
			if ( ! empty( $attributes['customBackgroundButtonColor'] ) ) {
2076
				$submit_button_styles .= 'background-color: ' . $attributes['customBackgroundButtonColor'] . '; ';
2077
			}
2078
			if ( ! empty( $attributes['customTextButtonColor'] ) ) {
2079
				$submit_button_styles .= 'color: ' . $attributes['customTextButtonColor'] . ';';
2080
			}
2081
			if ( ! empty( $attributes['submitButtonText'] ) ) {
2082
				$submit_button_text = $attributes['submitButtonText'];
2083
			} else {
2084
				$submit_button_text = $form->get_attribute( 'submit_button_text' );
2085
			}
2086
2087
			$r .= "\t\t<button type='submit' class='" . esc_attr( $submit_button_class ) . "'";
2088
			if ( ! empty( $submit_button_styles ) ) {
2089
				$r .= " style='" . esc_attr( $submit_button_styles ) . "'";
2090
			}
2091
			$r .= ">";
2092
			$r .= wp_kses(
2093
				      $submit_button_text,
2094
				      self::$allowed_html_tags_for_submit_button
2095
			      ) . "</button>";
2096
2097
			if ( is_user_logged_in() ) {
2098
				$r .= "\t\t" . wp_nonce_field( 'contact-form_' . $id, '_wpnonce', true, false ) . "\n"; // nonce and referer
2099
			}
2100
			$r .= "\t\t<input type='hidden' name='contact-form-id' value='$id' />\n";
2101
			$r .= "\t\t<input type='hidden' name='action' value='grunion-contact-form' />\n";
2102
			$r .= "\t\t<input type='hidden' name='contact-form-hash' value='" . esc_attr( $form->hash ) . "' />\n";
2103
			$r .= "\t</p>\n";
2104
			$r .= "</form>\n";
2105
		}
2106
2107
		$r .= '</div>';
2108
2109
		return $r;
2110
	}
2111
2112
	/**
2113
	 * Returns a success message to be returned if the form is sent via AJAX.
2114
	 *
2115
	 * @param int                         $feedback_id
2116
	 * @param object Grunion_Contact_Form $form
2117
	 *
2118
	 * @return string $message
2119
	 */
2120
	static function success_message( $feedback_id, $form ) {
2121
		return wp_kses(
2122
			'<blockquote class="contact-form-submission">'
2123
			. '<p>' . join( '</p><p>', self::get_compiled_form( $feedback_id, $form ) ) . '</p>'
2124
			. '</blockquote>',
2125
			array(
2126
				'br'         => array(),
2127
				'blockquote' => array( 'class' => array() ),
2128
				'p'          => array(),
2129
			)
2130
		);
2131
	}
2132
2133
	/**
2134
	 * Returns a compiled form with labels and values in a form of  an array
2135
	 * of lines.
2136
	 *
2137
	 * @param int                         $feedback_id
2138
	 * @param object Grunion_Contact_Form $form
2139
	 *
2140
	 * @return array $lines
2141
	 */
2142
	static function get_compiled_form( $feedback_id, $form ) {
2143
		$feedback       = get_post( $feedback_id );
2144
		$field_ids      = $form->get_field_ids();
2145
		$content_fields = Grunion_Contact_Form_Plugin::parse_fields_from_content( $feedback_id );
2146
2147
		// Maps field_ids to post_meta keys
2148
		$field_value_map = array(
2149
			'name'     => 'author',
2150
			'email'    => 'author_email',
2151
			'url'      => 'author_url',
2152
			'subject'  => 'subject',
2153
			'textarea' => false, // not a post_meta key.  This is stored in post_content
2154
		);
2155
2156
		$compiled_form = array();
2157
2158
		// "Standard" field whitelist
2159
		foreach ( $field_value_map as $type => $meta_key ) {
2160
			if ( isset( $field_ids[ $type ] ) ) {
2161
				$field = $form->fields[ $field_ids[ $type ] ];
2162
2163
				if ( $meta_key ) {
2164
					if ( isset( $content_fields[ "_feedback_{$meta_key}" ] ) ) {
2165
						$value = $content_fields[ "_feedback_{$meta_key}" ];
2166
					}
2167
				} else {
2168
					// The feedback content is stored as the first "half" of post_content
2169
					$value         = $feedback->post_content;
2170
					list( $value ) = explode( '<!--more-->', $value );
2171
					$value         = trim( $value );
2172
				}
2173
2174
				$field_index                   = array_search( $field_ids[ $type ], $field_ids['all'] );
2175
				$compiled_form[ $field_index ] = sprintf(
2176
					'<b>%1$s:</b> %2$s<br /><br />',
2177
					wp_kses( $field->get_attribute( 'label' ), array() ),
2178
					self::escape_and_sanitize_field_value( $value )
2179
				);
2180
			}
2181
		}
2182
2183
		// "Non-standard" fields
2184
		if ( $field_ids['extra'] ) {
2185
			// array indexed by field label (not field id)
2186
			$extra_fields = get_post_meta( $feedback_id, '_feedback_extra_fields', true );
2187
2188
			/**
2189
			 * Only get data for the compiled form if `$extra_fields` is a valid and non-empty array.
2190
			 */
2191
			if ( is_array( $extra_fields ) && ! empty( $extra_fields ) ) {
2192
2193
				$extra_field_keys = array_keys( $extra_fields );
2194
2195
				$i = 0;
2196
				foreach ( $field_ids['extra'] as $field_id ) {
2197
					$field       = $form->fields[ $field_id ];
2198
					$field_index = array_search( $field_id, $field_ids['all'] );
2199
2200
					$label = $field->get_attribute( 'label' );
2201
2202
					$compiled_form[ $field_index ] = sprintf(
2203
						'<b>%1$s:</b> %2$s<br /><br />',
2204
						wp_kses( $label, array() ),
2205
						self::escape_and_sanitize_field_value( $extra_fields[ $extra_field_keys[ $i ] ] )
2206
					);
2207
2208
					$i++;
2209
				}
2210
			}
2211
		}
2212
2213
		// Sorting lines by the field index
2214
		ksort( $compiled_form );
2215
2216
		return $compiled_form;
2217
	}
2218
2219
	static function escape_and_sanitize_field_value( $value ) {
2220
        $value = str_replace( array( '[' , ']' ) ,  array( '&#91;' , '&#93;' ) , $value );
2221
        return nl2br( wp_kses( $value, array() ) );
2222
    }
2223
2224
	/**
2225
	 * Only strip out empty string values and keep all the other values as they are.
2226
     *
2227
	 * @param $single_value
2228
	 *
2229
	 * @return bool
2230
	 */
2231
	static function remove_empty( $single_value ) {
2232
		return ( $single_value !== '' );
2233
	}
2234
2235
	/**
2236
	 * The contact-field shortcode processor
2237
	 * 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.
2238
	 *
2239
	 * @param array       $attributes Key => Value pairs as parsed by shortcode_parse_atts()
2240
	 * @param string|null $content The shortcode's inner content: [contact-field]$content[/contact-field]
2241
	 * @return HTML for the contact form field
2242
	 */
2243
	static function parse_contact_field( $attributes, $content ) {
2244
		// Don't try to parse contact form fields if not inside a contact form
2245
		if ( ! Grunion_Contact_Form_Plugin::$using_contact_form_field ) {
2246
			$att_strs = array();
2247
			if ( ! isset( $attributes['label'] )  ) {
2248
				$type = isset( $attributes['type'] ) ? $attributes['type'] : null;
2249
				$attributes['label'] = self::get_default_label_from_type( $type );
2250
			}
2251
			foreach ( $attributes as $att => $val ) {
2252
				if ( is_numeric( $att ) ) { // Is a valueless attribute
2253
					$att_strs[] = esc_html( $val );
2254
				} elseif ( isset( $val ) ) { // A regular attr - value pair
2255
					if ( ( $att === 'options' || $att === 'values' ) && is_string( $val ) ) { // remove any empty strings
2256
						$val = explode( ',', $val );
2257
					}
2258
 					if ( is_array( $val ) ) {
2259
						$val =  array_filter( $val, array( __CLASS__, 'remove_empty' ) ); // removes any empty strings
2260
						$att_strs[] = esc_html( $att ) . '="' . implode( ',', array_map( 'esc_html', $val ) ) . '"';
2261
					} elseif ( is_bool( $val ) ) {
2262
						$att_strs[] = esc_html( $att ) . '="' . esc_html( $val ? '1' : '' ) . '"';
2263
					} else {
2264
						$att_strs[] = esc_html( $att ) . '="' . esc_html( $val ) . '"';
2265
					}
2266
				}
2267
			}
2268
2269
			$html = '[contact-field ' . implode( ' ', $att_strs );
2270
2271
			if ( isset( $content ) && ! empty( $content ) ) { // If there is content, let's add a closing tag
2272
				$html .= ']' . esc_html( $content ) . '[/contact-field]';
2273
			} else { // Otherwise let's add a closing slash in the first tag
2274
				$html .= '/]';
2275
			}
2276
2277
			return $html;
2278
		}
2279
2280
		$form = Grunion_Contact_Form::$current_form;
2281
2282
		$field = new Grunion_Contact_Form_Field( $attributes, $content, $form );
2283
2284
		$field_id = $field->get_attribute( 'id' );
2285
		if ( $field_id ) {
2286
			$form->fields[ $field_id ] = $field;
2287
		} else {
2288
			$form->fields[] = $field;
2289
		}
2290
2291
		if (
2292
			isset( $_POST['action'] ) && 'grunion-contact-form' === $_POST['action']
2293
			&&
2294
			isset( $_POST['contact-form-id'] ) && $form->get_attribute( 'id' ) == $_POST['contact-form-id']
2295
			&&
2296
			isset( $_POST['contact-form-hash'] ) && hash_equals( $form->hash, $_POST['contact-form-hash'] )
2297
		) {
2298
			// If we're processing a POST submission for this contact form, validate the field value so we can show errors as necessary.
2299
			$field->validate();
2300
		}
2301
2302
		// Output HTML
2303
		return $field->render();
2304
	}
2305
2306
	static function get_default_label_from_type( $type ) {
2307
		switch ( $type ) {
2308
			case 'text':
2309
				return __( 'Text', 'jetpack' );
2310
			case 'name':
2311
				return __( 'Name', 'jetpack' );
2312
			case 'email':
2313
				return __( 'Email', 'jetpack' );
2314
			case 'url':
2315
				return __( 'Website', 'jetpack' );
2316
			case 'date':
2317
				return __( 'Date', 'jetpack' );
2318
			case 'telephone':
2319
				return __( 'Phone', 'jetpack' );
2320
			case 'textarea':
2321
				return __( 'Message', 'jetpack' );
2322
			case 'checkbox':
2323
				return __( 'Checkbox', 'jetpack' );
2324
			case 'checkbox-multiple':
2325
				return __( 'Choose several', 'jetpack' );
2326
			case 'radio':
2327
				return __( 'Choose one', 'jetpack' );
2328
			case 'select':
2329
				return __( 'Select one', 'jetpack' );
2330
			default:
2331
				return null;
2332
		}
2333
	}
2334
2335
	/**
2336
	 * Loops through $this->fields to generate a (structured) list of field IDs.
2337
	 *
2338
	 * Important: Currently the whitelisted fields are defined as follows:
2339
	 *  `name`, `email`, `url`, `subject`, `textarea`
2340
	 *
2341
	 * If you need to add new fields to the Contact Form, please don't add them
2342
	 * to the whitelisted fields and leave them as extra fields.
2343
	 *
2344
	 * The reasoning behind this is that both the admin Feedback view and the CSV
2345
	 * export will not include any fields that are added to the list of
2346
	 * whitelisted fields without taking proper care to add them to all the
2347
	 * other places where they accessed/used/saved.
2348
	 *
2349
	 * The safest way to add new fields is to add them to the dropdown and the
2350
	 * HTML list ( @see Grunion_Contact_Form_Field::render ) and don't add them
2351
	 * to the list of whitelisted fields. This way they will become a part of the
2352
	 * `extra fields` which are saved in the post meta and will be properly
2353
	 * handled by the admin Feedback view and the CSV Export without any extra
2354
	 * work.
2355
	 *
2356
	 * If there is need to add a field to the whitelisted fields, then please
2357
	 * take proper care to add logic to handle the field in the following places:
2358
	 *
2359
	 *  - Below in the switch statement - so the field is recognized as whitelisted.
2360
	 *
2361
	 *  - Grunion_Contact_Form::process_submission - validation and logic.
2362
	 *
2363
	 *  - Grunion_Contact_Form::process_submission - add the field as an additional
2364
	 *      field in the `post_content` when saving the feedback content.
2365
	 *
2366
	 *  - Grunion_Contact_Form_Plugin::parse_fields_from_content - add mapping
2367
	 *      for the field, defined in the above method.
2368
	 *
2369
	 *  - Grunion_Contact_Form_Plugin::map_parsed_field_contents_of_post_to_field_names -
2370
	 *      add mapping of the field for the CSV Export. Otherwise it will be missing
2371
	 *      from the exported data.
2372
	 *
2373
	 *  - admin.php / grunion_manage_post_columns - add the field to the render logic.
2374
	 *      Otherwise it will be missing from the admin Feedback view.
2375
	 *
2376
	 * @return array
2377
	 */
2378
	function get_field_ids() {
2379
		$field_ids = array(
2380
			'all'   => array(), // array of all field_ids
2381
			'extra' => array(), // array of all non-whitelisted field IDs
2382
2383
			// Whitelisted "standard" field IDs:
2384
			// 'email'    => field_id,
2385
			// 'name'     => field_id,
2386
			// 'url'      => field_id,
2387
			// 'subject'  => field_id,
2388
			// 'textarea' => field_id,
2389
		);
2390
2391
		foreach ( $this->fields as $id => $field ) {
2392
			$field_ids['all'][] = $id;
2393
2394
			$type = $field->get_attribute( 'type' );
2395
			if ( isset( $field_ids[ $type ] ) ) {
2396
				// This type of field is already present in our whitelist of "standard" fields for this form
2397
				// Put it in extra
2398
				$field_ids['extra'][] = $id;
2399
				continue;
2400
			}
2401
2402
			/**
2403
			 * See method description before modifying the switch cases.
2404
			 */
2405
			switch ( $type ) {
2406
				case 'email':
2407
				case 'name':
2408
				case 'url':
2409
				case 'subject':
2410
				case 'textarea':
2411
					$field_ids[ $type ] = $id;
2412
					break;
2413
				default:
2414
					// Put everything else in extra
2415
					$field_ids['extra'][] = $id;
2416
			}
2417
		}
2418
2419
		return $field_ids;
2420
	}
2421
2422
	/**
2423
	 * Process the contact form's POST submission
2424
	 * Stores feedback.  Sends email.
2425
	 */
2426
	function process_submission() {
2427
		global $post;
2428
2429
		$plugin = Grunion_Contact_Form_Plugin::init();
2430
2431
		$id     = $this->get_attribute( 'id' );
2432
		$to     = $this->get_attribute( 'to' );
2433
		$widget = $this->get_attribute( 'widget' );
2434
2435
		$contact_form_subject = $this->get_attribute( 'subject' );
2436
2437
		$to     = str_replace( ' ', '', $to );
2438
		$emails = explode( ',', $to );
2439
2440
		$valid_emails = array();
2441
2442
		foreach ( (array) $emails as $email ) {
2443
			if ( ! is_email( $email ) ) {
2444
				continue;
2445
			}
2446
2447
			if ( function_exists( 'is_email_address_unsafe' ) && is_email_address_unsafe( $email ) ) {
2448
				continue;
2449
			}
2450
2451
			$valid_emails[] = $email;
2452
		}
2453
2454
		// No one to send it to, which means none of the "to" attributes are valid emails.
2455
		// Use default email instead.
2456
		if ( ! $valid_emails ) {
2457
			$valid_emails = $this->defaults['to'];
2458
		}
2459
2460
		$to = $valid_emails;
2461
2462
		// Last ditch effort to set a recipient if somehow none have been set.
2463
		if ( empty( $to ) ) {
2464
			$to = get_option( 'admin_email' );
2465
		}
2466
2467
		// Make sure we're processing the form we think we're processing... probably a redundant check.
2468
		if ( $widget ) {
2469
			if ( 'widget-' . $widget != $_POST['contact-form-id'] ) {
2470
				return false;
2471
			}
2472
		} else {
2473
			if ( $post->ID != $_POST['contact-form-id'] ) {
2474
				return false;
2475
			}
2476
		}
2477
2478
		$field_ids = $this->get_field_ids();
2479
2480
		// Initialize all these "standard" fields to null
2481
		$comment_author_email = $comment_author_email_label = // v
2482
		$comment_author       = $comment_author_label       = // v
2483
		$comment_author_url   = $comment_author_url_label   = // v
2484
		$comment_content      = $comment_content_label = null;
2485
2486
		// For each of the "standard" fields, grab their field label and value.
2487 View Code Duplication
		if ( isset( $field_ids['name'] ) ) {
2488
			$field          = $this->fields[ $field_ids['name'] ];
2489
			$comment_author = Grunion_Contact_Form_Plugin::strip_tags(
2490
				stripslashes(
2491
					/** This filter is already documented in core/wp-includes/comment-functions.php */
2492
					apply_filters( 'pre_comment_author_name', addslashes( $field->value ) )
2493
				)
2494
			);
2495
			$comment_author_label = Grunion_Contact_Form_Plugin::strip_tags( $field->get_attribute( 'label' ) );
2496
		}
2497
2498 View Code Duplication
		if ( isset( $field_ids['email'] ) ) {
2499
			$field                = $this->fields[ $field_ids['email'] ];
2500
			$comment_author_email = Grunion_Contact_Form_Plugin::strip_tags(
2501
				stripslashes(
2502
					/** This filter is already documented in core/wp-includes/comment-functions.php */
2503
					apply_filters( 'pre_comment_author_email', addslashes( $field->value ) )
2504
				)
2505
			);
2506
			$comment_author_email_label = Grunion_Contact_Form_Plugin::strip_tags( $field->get_attribute( 'label' ) );
2507
		}
2508
2509
		if ( isset( $field_ids['url'] ) ) {
2510
			$field              = $this->fields[ $field_ids['url'] ];
2511
			$comment_author_url = Grunion_Contact_Form_Plugin::strip_tags(
2512
				stripslashes(
2513
					/** This filter is already documented in core/wp-includes/comment-functions.php */
2514
					apply_filters( 'pre_comment_author_url', addslashes( $field->value ) )
2515
				)
2516
			);
2517
			if ( 'http://' == $comment_author_url ) {
2518
				$comment_author_url = '';
2519
			}
2520
			$comment_author_url_label = Grunion_Contact_Form_Plugin::strip_tags( $field->get_attribute( 'label' ) );
2521
		}
2522
2523
		if ( isset( $field_ids['textarea'] ) ) {
2524
			$field                 = $this->fields[ $field_ids['textarea'] ];
2525
			$comment_content       = trim( Grunion_Contact_Form_Plugin::strip_tags( $field->value ) );
2526
			$comment_content_label = Grunion_Contact_Form_Plugin::strip_tags( $field->get_attribute( 'label' ) );
2527
		}
2528
2529
		if ( isset( $field_ids['subject'] ) ) {
2530
			$field = $this->fields[ $field_ids['subject'] ];
2531
			if ( $field->value ) {
2532
				$contact_form_subject = Grunion_Contact_Form_Plugin::strip_tags( $field->value );
2533
			}
2534
		}
2535
2536
		$all_values = $extra_values = array();
2537
		$i          = 1; // Prefix counter for stored metadata
2538
2539
		// For all fields, grab label and value
2540
		foreach ( $field_ids['all'] as $field_id ) {
2541
			$field = $this->fields[ $field_id ];
2542
			$label = $i . '_' . $field->get_attribute( 'label' );
2543
			$value = $field->value;
2544
2545
			$all_values[ $label ] = $value;
2546
			$i++; // Increment prefix counter for the next field
2547
		}
2548
2549
		// For the "non-standard" fields, grab label and value
2550
		// Extra fields have their prefix starting from count( $all_values ) + 1
2551
		foreach ( $field_ids['extra'] as $field_id ) {
2552
			$field = $this->fields[ $field_id ];
2553
			$label = $i . '_' . $field->get_attribute( 'label' );
2554
			$value = $field->value;
2555
2556
			if ( is_array( $value ) ) {
2557
				$value = implode( ', ', $value );
2558
			}
2559
2560
			$extra_values[ $label ] = $value;
2561
			$i++; // Increment prefix counter for the next extra field
2562
		}
2563
2564
		$contact_form_subject = trim( $contact_form_subject );
2565
2566
		$comment_author_IP = Grunion_Contact_Form_Plugin::get_ip_address();
2567
2568
		$vars = array( 'comment_author', 'comment_author_email', 'comment_author_url', 'contact_form_subject', 'comment_author_IP' );
2569
		foreach ( $vars as $var ) {
2570
			$$var = str_replace( array( "\n", "\r" ), '', $$var );
2571
		}
2572
2573
		// Ensure that Akismet gets all of the relevant information from the contact form,
2574
		// not just the textarea field and predetermined subject.
2575
		$akismet_vars                    = compact( $vars );
2576
		$akismet_vars['comment_content'] = $comment_content;
2577
2578
		foreach ( array_merge( $field_ids['all'], $field_ids['extra'] ) as $field_id ) {
2579
			$field = $this->fields[ $field_id ];
2580
2581
			// Skip any fields that are just a choice from a pre-defined list. They wouldn't have any value
2582
			// from a spam-filtering point of view.
2583
			if ( in_array( $field->get_attribute( 'type' ), array( 'select', 'checkbox', 'checkbox-multiple', 'radio' ) ) ) {
2584
				continue;
2585
			}
2586
2587
			// Normalize the label into a slug.
2588
			$field_slug = trim( // Strip all leading/trailing dashes.
2589
				preg_replace(   // Normalize everything to a-z0-9_-
2590
					'/[^a-z0-9_]+/',
2591
					'-',
2592
					strtolower( $field->get_attribute( 'label' ) ) // Lowercase
2593
				),
2594
				'-'
2595
			);
2596
2597
			$field_value = ( is_array( $field->value ) ) ? trim( implode( ', ', $field->value ) ) : trim( $field->value );
2598
2599
			// Skip any values that are already in the array we're sending.
2600
			if ( $field_value && in_array( $field_value, $akismet_vars ) ) {
2601
				continue;
2602
			}
2603
2604
			$akismet_vars[ 'contact_form_field_' . $field_slug ] = $field_value;
2605
		}
2606
2607
		$spam           = '';
2608
		$akismet_values = $plugin->prepare_for_akismet( $akismet_vars );
2609
2610
		// Is it spam?
2611
		/** This filter is already documented in modules/contact-form/admin.php */
2612
		$is_spam = apply_filters( 'jetpack_contact_form_is_spam', false, $akismet_values );
2613
		if ( is_wp_error( $is_spam ) ) { // WP_Error to abort
2614
			return $is_spam; // abort
2615
		} elseif ( $is_spam === true ) {  // TRUE to flag a spam
2616
			$spam = '***SPAM*** ';
2617
		}
2618
2619
		if ( ! $comment_author ) {
2620
			$comment_author = $comment_author_email;
2621
		}
2622
2623
		/**
2624
		 * Filter the email where a submitted feedback is sent.
2625
		 *
2626
		 * @module contact-form
2627
		 *
2628
		 * @since 1.3.1
2629
		 *
2630
		 * @param string|array $to Array of valid email addresses, or single email address.
2631
		 */
2632
		$to            = (array) apply_filters( 'contact_form_to', $to );
2633
		$reply_to_addr = $to[0]; // get just the address part before the name part is added
2634
2635
		foreach ( $to as $to_key => $to_value ) {
2636
			$to[ $to_key ] = Grunion_Contact_Form_Plugin::strip_tags( $to_value );
2637
			$to[ $to_key ] = self::add_name_to_address( $to_value );
2638
		}
2639
2640
		$blog_url        = parse_url( site_url() );
2641
		$from_email_addr = 'wordpress@' . $blog_url['host'];
2642
2643
		if ( ! empty( $comment_author_email ) ) {
2644
			$reply_to_addr = $comment_author_email;
2645
		}
2646
2647
		$headers = 'From: "' . $comment_author . '" <' . $from_email_addr . ">\r\n" .
2648
		           'Reply-To: "' . $comment_author . '" <' . $reply_to_addr . ">\r\n";
2649
2650
		// Build feedback reference
2651
		$feedback_time  = current_time( 'mysql' );
2652
		$feedback_title = "{$comment_author} - {$feedback_time}";
2653
		$feedback_id    = md5( $feedback_title );
2654
2655
		$all_values = array_merge(
2656
			$all_values, array(
2657
				'entry_title'     => the_title_attribute( 'echo=0' ),
2658
				'entry_permalink' => esc_url( get_permalink( get_the_ID() ) ),
2659
				'feedback_id'     => $feedback_id,
2660
			)
2661
		);
2662
2663
		/** This filter is already documented in modules/contact-form/admin.php */
2664
		$subject = apply_filters( 'contact_form_subject', $contact_form_subject, $all_values );
2665
		$url     = $widget ? home_url( '/' ) : get_permalink( $post->ID );
2666
2667
		$date_time_format = _x( '%1$s \a\t %2$s', '{$date_format} \a\t {$time_format}', 'jetpack' );
2668
		$date_time_format = sprintf( $date_time_format, get_option( 'date_format' ), get_option( 'time_format' ) );
2669
		$time             = date_i18n( $date_time_format, current_time( 'timestamp' ) );
2670
2671
		// keep a copy of the feedback as a custom post type
2672
		$feedback_status = $is_spam === true ? 'spam' : 'publish';
2673
2674
		foreach ( (array) $akismet_values as $av_key => $av_value ) {
2675
			$akismet_values[ $av_key ] = Grunion_Contact_Form_Plugin::strip_tags( $av_value );
2676
		}
2677
2678
		foreach ( (array) $all_values as $all_key => $all_value ) {
2679
			$all_values[ $all_key ] = Grunion_Contact_Form_Plugin::strip_tags( $all_value );
2680
		}
2681
2682
		foreach ( (array) $extra_values as $ev_key => $ev_value ) {
2683
			$extra_values[ $ev_key ] = Grunion_Contact_Form_Plugin::strip_tags( $ev_value );
2684
		}
2685
2686
		/*
2687
		 We need to make sure that the post author is always zero for contact
2688
		 * form submissions.  This prevents export/import from trying to create
2689
		 * new users based on form submissions from people who were logged in
2690
		 * at the time.
2691
		 *
2692
		 * Unfortunately wp_insert_post() tries very hard to make sure the post
2693
		 * author gets the currently logged in user id.  That is how we ended up
2694
		 * with this work around. */
2695
		add_filter( 'wp_insert_post_data', array( $plugin, 'insert_feedback_filter' ), 10, 2 );
2696
2697
		$post_id = wp_insert_post(
2698
			array(
2699
				'post_date'    => addslashes( $feedback_time ),
2700
				'post_type'    => 'feedback',
2701
				'post_status'  => addslashes( $feedback_status ),
2702
				'post_parent'  => (int) $post->ID,
2703
				'post_title'   => addslashes( wp_kses( $feedback_title, array() ) ),
2704
				'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
2705
				'post_name'    => $feedback_id,
2706
			)
2707
		);
2708
2709
		// once insert has finished we don't need this filter any more
2710
		remove_filter( 'wp_insert_post_data', array( $plugin, 'insert_feedback_filter' ), 10 );
2711
2712
		update_post_meta( $post_id, '_feedback_extra_fields', $this->addslashes_deep( $extra_values ) );
2713
2714
		if ( 'publish' == $feedback_status ) {
2715
			// Increase count of unread feedback.
2716
			$unread = get_option( 'feedback_unread_count', 0 ) + 1;
2717
			update_option( 'feedback_unread_count', $unread );
2718
		}
2719
2720
		if ( defined( 'AKISMET_VERSION' ) ) {
2721
			update_post_meta( $post_id, '_feedback_akismet_values', $this->addslashes_deep( $akismet_values ) );
2722
		}
2723
2724
		$message = self::get_compiled_form( $post_id, $this );
2725
2726
		array_push(
2727
			$message,
2728
			'<br />',
2729
			'<hr />',
2730
			__( 'Time:', 'jetpack' ) . ' ' . $time . '<br />',
2731
			__( 'IP Address:', 'jetpack' ) . ' ' . $comment_author_IP . '<br />',
2732
			__( 'Contact Form URL:', 'jetpack' ) . ' ' . $url . '<br />'
2733
		);
2734
2735
		if ( is_user_logged_in() ) {
2736
			array_push(
2737
				$message,
2738
				sprintf(
2739
					'<p>' . __( 'Sent by a verified %s user.', 'jetpack' ) . '</p>',
2740
					isset( $GLOBALS['current_site']->site_name ) && $GLOBALS['current_site']->site_name ?
2741
						$GLOBALS['current_site']->site_name : '"' . get_option( 'blogname' ) . '"'
2742
				)
2743
			);
2744
		} else {
2745
			array_push( $message, '<p>' . __( 'Sent by an unverified visitor to your site.', 'jetpack' ) . '</p>' );
2746
		}
2747
2748
		$message = join( '', $message );
2749
2750
		/**
2751
		 * Filters the message sent via email after a successful form submission.
2752
		 *
2753
		 * @module contact-form
2754
		 *
2755
		 * @since 1.3.1
2756
		 *
2757
		 * @param string $message Feedback email message.
2758
		 */
2759
		$message = apply_filters( 'contact_form_message', $message );
2760
2761
		// This is called after `contact_form_message`, in order to preserve back-compat
2762
		$message = self::wrap_message_in_html_tags( $message );
2763
2764
		update_post_meta( $post_id, '_feedback_email', $this->addslashes_deep( compact( 'to', 'message' ) ) );
2765
2766
		/**
2767
		 * Fires right before the contact form message is sent via email to
2768
		 * the recipient specified in the contact form.
2769
		 *
2770
		 * @module contact-form
2771
		 *
2772
		 * @since 1.3.1
2773
		 *
2774
		 * @param integer $post_id Post contact form lives on
2775
		 * @param array $all_values Contact form fields
2776
		 * @param array $extra_values Contact form fields not included in $all_values
2777
		 */
2778
		do_action( 'grunion_pre_message_sent', $post_id, $all_values, $extra_values );
2779
2780
		// schedule deletes of old spam feedbacks
2781
		if ( ! wp_next_scheduled( 'grunion_scheduled_delete' ) ) {
2782
			wp_schedule_event( time() + 250, 'daily', 'grunion_scheduled_delete' );
2783
		}
2784
2785
		if (
2786
			$is_spam !== true &&
2787
			/**
2788
			 * Filter to choose whether an email should be sent after each successful contact form submission.
2789
			 *
2790
			 * @module contact-form
2791
			 *
2792
			 * @since 2.6.0
2793
			 *
2794
			 * @param bool true Should an email be sent after a form submission. Default to true.
2795
			 * @param int $post_id Post ID.
2796
			 */
2797
			true === apply_filters( 'grunion_should_send_email', true, $post_id )
2798
		) {
2799
			self::wp_mail( $to, "{$spam}{$subject}", $message, $headers );
2800
		} elseif (
2801
			true === $is_spam &&
2802
			/**
2803
			 * Choose whether an email should be sent for each spam contact form submission.
2804
			 *
2805
			 * @module contact-form
2806
			 *
2807
			 * @since 1.3.1
2808
			 *
2809
			 * @param bool false Should an email be sent after a spam form submission. Default to false.
2810
			 */
2811
			apply_filters( 'grunion_still_email_spam', false ) == true
2812
		) { // don't send spam by default.  Filterable.
2813
			self::wp_mail( $to, "{$spam}{$subject}", $message, $headers );
2814
		}
2815
2816
		/**
2817
		 * Fires an action hook right after the email(s) have been sent.
2818
		 *
2819
		 * @module contact-form
2820
		 *
2821
		 * @since 7.3.0
2822
		 *
2823
		 * @param int $post_id Post contact form lives on.
2824
		 * @param string|array $to Array of valid email addresses, or single email address.
2825
		 * @param string $subject Feedback email subject.
2826
		 * @param string $message Feedback email message.
2827
		 * @param string|array $headers Optional. Additional headers.
2828
		 * @param array $all_values Contact form fields.
2829
		 * @param array $extra_values Contact form fields not included in $all_values
2830
		 */
2831
		do_action( 'grunion_after_message_sent', $post_id, $to, $subject, $message, $headers, $all_values, $extra_values );
2832
2833
		if ( defined( 'DOING_AJAX' ) && DOING_AJAX ) {
2834
			return self::success_message( $post_id, $this );
2835
		}
2836
2837
		$redirect = wp_get_referer();
2838
		if ( ! $redirect ) { // wp_get_referer() returns false if the referer is the same as the current page
2839
			$redirect = $_SERVER['REQUEST_URI'];
2840
		}
2841
2842
		$redirect = add_query_arg(
2843
			urlencode_deep(
2844
				array(
2845
					'contact-form-id'   => $id,
2846
					'contact-form-sent' => $post_id,
2847
					'contact-form-hash' => $this->hash,
2848
					'_wpnonce'          => wp_create_nonce( "contact-form-sent-{$post_id}" ), // wp_nonce_url HTMLencodes :(
2849
				)
2850
			), $redirect
2851
		);
2852
2853
		/**
2854
		 * Filter the URL where the reader is redirected after submitting a form.
2855
		 *
2856
		 * @module contact-form
2857
		 *
2858
		 * @since 1.9.0
2859
		 *
2860
		 * @param string $redirect Post submission URL.
2861
		 * @param int $id Contact Form ID.
2862
		 * @param int $post_id Post ID.
2863
		 */
2864
		$redirect = apply_filters( 'grunion_contact_form_redirect_url', $redirect, $id, $post_id );
2865
2866
		wp_safe_redirect( $redirect );
2867
		exit;
2868
	}
2869
2870
	/**
2871
	 * Wrapper for wp_mail() that enables HTML messages with text alternatives
2872
	 *
2873
	 * @param string|array $to          Array or comma-separated list of email addresses to send message.
2874
	 * @param string       $subject     Email subject.
2875
	 * @param string       $message     Message contents.
2876
	 * @param string|array $headers     Optional. Additional headers.
2877
	 * @param string|array $attachments Optional. Files to attach.
2878
	 *
2879
	 * @return bool Whether the email contents were sent successfully.
2880
	 */
2881
	public static function wp_mail( $to, $subject, $message, $headers = '', $attachments = array() ) {
2882
		add_filter( 'wp_mail_content_type', __CLASS__ . '::get_mail_content_type' );
2883
		add_action( 'phpmailer_init', __CLASS__ . '::add_plain_text_alternative' );
2884
2885
		$result = wp_mail( $to, $subject, $message, $headers, $attachments );
2886
2887
		remove_filter( 'wp_mail_content_type', __CLASS__ . '::get_mail_content_type' );
2888
		remove_action( 'phpmailer_init', __CLASS__ . '::add_plain_text_alternative' );
2889
2890
		return $result;
2891
	}
2892
2893
	/**
2894
	 * Add a display name part to an email address
2895
	 *
2896
	 * SpamAssassin doesn't like addresses in HTML messages that are missing display names (e.g., `[email protected]`
2897
	 * instead of `"Foo Bar" <[email protected]>`.
2898
	 *
2899
	 * @param string $address
2900
	 *
2901
	 * @return string
2902
	 */
2903
	function add_name_to_address( $address ) {
2904
		// If it's just the address, without a display name
2905
		if ( is_email( $address ) ) {
2906
			$address_parts = explode( '@', $address );
2907
			$address       = sprintf( '"%s" <%s>', $address_parts[0], $address );
2908
		}
2909
2910
		return $address;
2911
	}
2912
2913
	/**
2914
	 * Get the content type that should be assigned to outbound emails
2915
	 *
2916
	 * @return string
2917
	 */
2918
	static function get_mail_content_type() {
2919
		return 'text/html';
2920
	}
2921
2922
	/**
2923
	 * Wrap a message body with the appropriate in HTML tags
2924
	 *
2925
	 * This helps to ensure correct parsing by clients, and also helps avoid triggering spam filtering rules
2926
	 *
2927
	 * @param string $body
2928
	 *
2929
	 * @return string
2930
	 */
2931
	static function wrap_message_in_html_tags( $body ) {
2932
		// Don't do anything if the message was already wrapped in HTML tags
2933
		// That could have be done by a plugin via filters
2934
		if ( false !== strpos( $body, '<html' ) ) {
2935
			return $body;
2936
		}
2937
2938
		$html_message = sprintf(
2939
			// The tabs are just here so that the raw code is correctly formatted for developers
2940
			// They're removed so that they don't affect the final message sent to users
2941
			str_replace(
2942
				"\t", '',
2943
				'<!doctype html>
2944
				<html xmlns="http://www.w3.org/1999/xhtml">
2945
				<body>
2946
2947
				%s
2948
2949
				</body>
2950
				</html>'
2951
			),
2952
			$body
2953
		);
2954
2955
		return $html_message;
2956
	}
2957
2958
	/**
2959
	 * Add a plain-text alternative part to an outbound email
2960
	 *
2961
	 * This makes the message more accessible to mail clients that aren't HTML-aware, and decreases the likelihood
2962
	 * that the message will be flagged as spam.
2963
	 *
2964
	 * @param PHPMailer $phpmailer
2965
	 */
2966
	static function add_plain_text_alternative( $phpmailer ) {
2967
		// Add an extra break so that the extra space above the <p> is preserved after the <p> is stripped out
2968
		$alt_body = str_replace( '<p>', '<p><br />', $phpmailer->Body );
2969
2970
		// Convert <br> to \n breaks, to preserve the space between lines that we want to keep
2971
		$alt_body = str_replace( array( '<br>', '<br />' ), "\n", $alt_body );
2972
2973
		// Convert <hr> to an plain-text equivalent, to preserve the integrity of the message
2974
		$alt_body = str_replace( array( '<hr>', '<hr />' ), "----\n", $alt_body );
2975
2976
		// Trim the plain text message to remove the \n breaks that were after <doctype>, <html>, and <body>
2977
		$phpmailer->AltBody = trim( strip_tags( $alt_body ) );
2978
	}
2979
2980
	function addslashes_deep( $value ) {
2981
		if ( is_array( $value ) ) {
2982
			return array_map( array( $this, 'addslashes_deep' ), $value );
2983
		} elseif ( is_object( $value ) ) {
2984
			$vars = get_object_vars( $value );
2985
			foreach ( $vars as $key => $data ) {
2986
				$value->{$key} = $this->addslashes_deep( $data );
2987
			}
2988
			return $value;
2989
		}
2990
2991
		return addslashes( $value );
2992
	}
2993
}
2994
2995
/**
2996
 * Class for the contact-field shortcode.
2997
 * Parses shortcode to output the contact form field as HTML.
2998
 * Validates input.
2999
 */
3000
class Grunion_Contact_Form_Field extends Crunion_Contact_Form_Shortcode {
3001
	public $shortcode_name = 'contact-field';
3002
3003
	/**
3004
	 * @var Grunion_Contact_Form parent form
3005
	 */
3006
	public $form;
3007
3008
	/**
3009
	 * @var string default or POSTed value
3010
	 */
3011
	public $value;
3012
3013
	/**
3014
	 * @var bool Is the input invalid?
3015
	 */
3016
	public $error = false;
3017
3018
	/**
3019
	 * @param array                $attributes An associative array of shortcode attributes.  @see shortcode_atts()
3020
	 * @param null|string          $content Null for selfclosing shortcodes.  The inner content otherwise.
3021
	 * @param Grunion_Contact_Form $form The parent form
3022
	 */
3023
	function __construct( $attributes, $content = null, $form = null ) {
3024
		$attributes = shortcode_atts(
3025
			array(
3026
				'label'       => null,
3027
				'type'        => 'text',
3028
				'required'    => false,
3029
				'options'     => array(),
3030
				'id'          => null,
3031
				'default'     => null,
3032
				'values'      => null,
3033
				'placeholder' => null,
3034
				'class'       => null,
3035
			), $attributes, 'contact-field'
3036
		);
3037
3038
		// special default for subject field
3039
		if ( 'subject' == $attributes['type'] && is_null( $attributes['default'] ) && ! is_null( $form ) ) {
3040
			$attributes['default'] = $form->get_attribute( 'subject' );
3041
		}
3042
3043
		// allow required=1 or required=true
3044
		if ( '1' == $attributes['required'] || 'true' == strtolower( $attributes['required'] ) ) {
3045
			$attributes['required'] = true;
3046
		} else {
3047
			$attributes['required'] = false;
3048
		}
3049
3050
		// parse out comma-separated options list (for selects, radios, and checkbox-multiples)
3051
		if ( ! empty( $attributes['options'] ) && is_string( $attributes['options'] ) ) {
3052
			$attributes['options'] = array_map( 'trim', explode( ',', $attributes['options'] ) );
3053
3054 View Code Duplication
			if ( ! empty( $attributes['values'] ) && is_string( $attributes['values'] ) ) {
3055
				$attributes['values'] = array_map( 'trim', explode( ',', $attributes['values'] ) );
3056
			}
3057
		}
3058
3059
		if ( $form ) {
3060
			// make a unique field ID based on the label, with an incrementing number if needed to avoid clashes
3061
			$form_id = $form->get_attribute( 'id' );
3062
			$id      = isset( $attributes['id'] ) ? $attributes['id'] : false;
3063
3064
			$unescaped_label = $this->unesc_attr( $attributes['label'] );
3065
			$unescaped_label = str_replace( '%', '-', $unescaped_label ); // jQuery doesn't like % in IDs?
3066
			$unescaped_label = preg_replace( '/[^a-zA-Z0-9.-_:]/', '', $unescaped_label );
3067
3068
			if ( empty( $id ) ) {
3069
				$id        = sanitize_title_with_dashes( 'g' . $form_id . '-' . $unescaped_label );
3070
				$i         = 0;
3071
				$max_tries = 99;
3072
				while ( isset( $form->fields[ $id ] ) ) {
3073
					$i++;
3074
					$id = sanitize_title_with_dashes( 'g' . $form_id . '-' . $unescaped_label . '-' . $i );
3075
3076
					if ( $i > $max_tries ) {
3077
						break;
3078
					}
3079
				}
3080
			}
3081
3082
			$attributes['id'] = $id;
3083
		}
3084
3085
		parent::__construct( $attributes, $content );
3086
3087
		// Store parent form
3088
		$this->form = $form;
3089
	}
3090
3091
	/**
3092
	 * This field's input is invalid.  Flag as invalid and add an error to the parent form
3093
	 *
3094
	 * @param string $message The error message to display on the form.
3095
	 */
3096
	function add_error( $message ) {
3097
		$this->is_error = true;
3098
3099
		if ( ! is_wp_error( $this->form->errors ) ) {
3100
			$this->form->errors = new WP_Error;
3101
		}
3102
3103
		$this->form->errors->add( $this->get_attribute( 'id' ), $message );
3104
	}
3105
3106
	/**
3107
	 * Is the field input invalid?
3108
	 *
3109
	 * @see $error
3110
	 *
3111
	 * @return bool
3112
	 */
3113
	function is_error() {
3114
		return $this->error;
3115
	}
3116
3117
	/**
3118
	 * Validates the form input
3119
	 */
3120
	function validate() {
3121
		// If it's not required, there's nothing to validate
3122
		if ( ! $this->get_attribute( 'required' ) ) {
3123
			return;
3124
		}
3125
3126
		$field_id    = $this->get_attribute( 'id' );
3127
		$field_type  = $this->get_attribute( 'type' );
3128
		$field_label = $this->get_attribute( 'label' );
3129
3130
		if ( isset( $_POST[ $field_id ] ) ) {
3131
			if ( is_array( $_POST[ $field_id ] ) ) {
3132
				$field_value = array_map( 'stripslashes', $_POST[ $field_id ] );
3133
			} else {
3134
				$field_value = stripslashes( $_POST[ $field_id ] );
3135
			}
3136
		} else {
3137
			$field_value = '';
3138
		}
3139
3140
		switch ( $field_type ) {
3141
			case 'email':
3142
				// Make sure the email address is valid
3143
				if ( ! is_email( $field_value ) ) {
3144
					/* translators: %s is the name of a form field */
3145
					$this->add_error( sprintf( __( '%s requires a valid email address', 'jetpack' ), $field_label ) );
3146
				}
3147
				break;
3148
			case 'checkbox-multiple':
3149
				// Check that there is at least one option selected
3150
				if ( empty( $field_value ) ) {
3151
					/* translators: %s is the name of a form field */
3152
					$this->add_error( sprintf( __( '%s requires at least one selection', 'jetpack' ), $field_label ) );
3153
				}
3154
				break;
3155
			default:
3156
				// Just check for presence of any text
3157
				if ( ! strlen( trim( $field_value ) ) ) {
3158
					/* translators: %s is the name of a form field */
3159
					$this->add_error( sprintf( __( '%s is required', 'jetpack' ), $field_label ) );
3160
				}
3161
		}
3162
	}
3163
3164
3165
	/**
3166
	 * Check the default value for options field
3167
	 *
3168
	 * @param string value
3169
	 * @param int index
3170
	 * @param string default value
3171
	 *
3172
	 * @return string
3173
	 */
3174
	public function get_option_value( $value, $index, $options ) {
3175
		if ( empty( $value[ $index ] ) ) {
3176
			return $options;
3177
		}
3178
		return $value[ $index ];
3179
	}
3180
3181
	/**
3182
	 * Outputs the HTML for this form field
3183
	 *
3184
	 * @return string HTML
3185
	 */
3186
	function render() {
3187
		global $current_user, $user_identity;
3188
3189
		$field_id          = $this->get_attribute( 'id' );
3190
		$field_type        = $this->get_attribute( 'type' );
3191
		$field_label       = $this->get_attribute( 'label' );
3192
		$field_required    = $this->get_attribute( 'required' );
3193
		$field_placeholder = $this->get_attribute( 'placeholder' );
3194
		$class             = 'date' === $field_type ? 'jp-contact-form-date' : $this->get_attribute( 'class' );
3195
3196
		/**
3197
		 * Filters the "class" attribute of the contact form input
3198
		 *
3199
		 * @module contact-form
3200
		 *
3201
		 * @since 6.6.0
3202
		 *
3203
		 * @param string $class Additional CSS classes for input class attribute.
3204
		 */
3205
		$field_class = apply_filters( 'jetpack_contact_form_input_class', $class );
3206
3207
		if ( isset( $_POST[ $field_id ] ) ) {
3208
			if ( is_array( $_POST[ $field_id ] ) ) {
3209
				$this->value = array_map( 'stripslashes', $_POST[ $field_id ] );
3210
			} else {
3211
				$this->value = stripslashes( (string) $_POST[ $field_id ] );
3212
			}
3213
		} elseif ( isset( $_GET[ $field_id ] ) ) {
3214
			$this->value = stripslashes( (string) $_GET[ $field_id ] );
3215
		} elseif (
3216
			is_user_logged_in() &&
3217
			( ( defined( 'IS_WPCOM' ) && IS_WPCOM ) ||
3218
			  /**
3219
			   * Allow third-party tools to prefill the contact form with the user's details when they're logged in.
3220
			   *
3221
			   * @module contact-form
3222
			   *
3223
			   * @since 3.2.0
3224
			   *
3225
			   * @param bool false Should the Contact Form be prefilled with your details when you're logged in. Default to false.
3226
			   */
3227
			  true === apply_filters( 'jetpack_auto_fill_logged_in_user', false )
3228
			)
3229
		) {
3230
			// Special defaults for logged-in users
3231
			switch ( $this->get_attribute( 'type' ) ) {
3232
				case 'email':
3233
					$this->value = $current_user->data->user_email;
3234
					break;
3235
				case 'name':
3236
					$this->value = $user_identity;
3237
					break;
3238
				case 'url':
3239
					$this->value = $current_user->data->user_url;
3240
					break;
3241
				default:
3242
					$this->value = $this->get_attribute( 'default' );
3243
			}
3244
		} else {
3245
			$this->value = $this->get_attribute( 'default' );
3246
		}
3247
3248
		$field_value = Grunion_Contact_Form_Plugin::strip_tags( $this->value );
3249
		$field_label = Grunion_Contact_Form_Plugin::strip_tags( $field_label );
3250
3251
		$rendered_field = $this->render_field( $field_type, $field_id, $field_label, $field_value, $field_class, $field_placeholder, $field_required );
3252
3253
		/**
3254
		 * Filter the HTML of the Contact Form.
3255
		 *
3256
		 * @module contact-form
3257
		 *
3258
		 * @since 2.6.0
3259
		 *
3260
		 * @param string $rendered_field Contact Form HTML output.
3261
		 * @param string $field_label Field label.
3262
		 * @param int|null $id Post ID.
3263
		 */
3264
		return apply_filters( 'grunion_contact_form_field_html', $rendered_field, $field_label, ( in_the_loop() ? get_the_ID() : null ) );
3265
	}
3266
3267
	function render_label( $type = '', $id, $label, $required, $required_field_text ) {
3268
3269
		$type_class = $type ? ' ' .$type : '';
3270
		return
3271
			"<label
3272
				for='" . esc_attr( $id ) . "'
3273
				class='grunion-field-label{$type_class}" . ( $this->is_error() ? ' form-error' : '' ) . "'
3274
				>"
3275
				. esc_html( $label )
3276
				. ( $required ? '<span>' . $required_field_text . '</span>' : '' )
3277
			. "</label>\n";
3278
3279
	}
3280
3281
	function render_input_field( $type, $id, $value, $class, $placeholder, $required ) {
3282
		return "<input
3283
					type='". esc_attr( $type ) ."'
3284
					name='" . esc_attr( $id ) . "'
3285
					id='" . esc_attr( $id ) . "'
3286
					value='" . esc_attr( $value ) . "'
3287
					" . $class . $placeholder . '
3288
					' . ( $required ? "required aria-required='true'" : '' ) . "
3289
				/>\n";
3290
	}
3291
3292
	function render_email_field( $id, $label, $value, $class, $required, $required_field_text, $placeholder ) {
3293
		$field = $this->render_label( 'email', $id, $label, $required, $required_field_text );
3294
		$field .= $this->render_input_field( 'email', $id, $value, $class, $placeholder, $required );
3295
		return $field;
3296
	}
3297
3298
	function render_telephone_field( $id, $label, $value, $class, $required, $required_field_text, $placeholder ) {
3299
		$field = $this->render_label( 'telephone', $id, $label, $required, $required_field_text );
3300
		$field .= $this->render_input_field( 'tel', $id, $value, $class, $placeholder, $required );
3301
		return $field;
3302
	}
3303
3304
	function render_url_field( $id, $label, $value, $class, $required, $required_field_text, $placeholder ) {
3305
		$field = $this->render_label( 'url', $id, $label, $required, $required_field_text );
3306
		$field .= $this->render_input_field( 'url', $id, $value, $class, $placeholder, $required );
3307
		return $field;
3308
	}
3309
3310
	function render_textarea_field( $id, $label, $value, $class, $required, $required_field_text, $placeholder ) {
3311
		$field = $this->render_label( 'textarea', 'contact-form-comment-' . $id, $label, $required, $required_field_text );
3312
		$field .= "<textarea
3313
		                name='" . esc_attr( $id ) . "'
3314
		                id='contact-form-comment-" . esc_attr( $id ) . "'
3315
		                rows='20' "
3316
		                . $class
3317
		                . $placeholder
3318
		                . ' ' . ( $required ? "required aria-required='true'" : '' ) .
3319
		                '>' . esc_textarea( $value )
3320
		          . "</textarea>\n";
3321
		return $field;
3322
	}
3323
3324
	function render_radio_field( $id, $label, $value, $class, $required, $required_field_text ) {
3325
		$field = $this->render_label( '', $id, $label, $required, $required_field_text );
3326
		foreach ( (array) $this->get_attribute( 'options' ) as $optionIndex => $option ) {
3327
			$option = Grunion_Contact_Form_Plugin::strip_tags( $option );
3328
			if ( $option ) {
3329
				$field .= "\t\t<label class='grunion-radio-label radio" . ( $this->is_error() ? ' form-error' : '' ) . "'>";
3330
				$field .= "<input
3331
									type='radio'
3332
									name='" . esc_attr( $id ) . "'
3333
									value='" . esc_attr( $this->get_option_value( $this->get_attribute( 'values' ), $optionIndex, $option ) ) . "' "
3334
				                    . $class
3335
				                    . checked( $option, $value, false ) . ' '
3336
				                    . ( $required ? "required aria-required='true'" : '' )
3337
				              . '/> ';
3338
				$field .= esc_html( $option ) . "</label>\n";
3339
				$field .= "\t\t<div class='clear-form'></div>\n";
3340
			}
3341
		}
3342
		return $field;
3343
	}
3344
3345
	function render_checkbox_field( $id, $label, $value, $class, $required, $required_field_text ) {
3346
		$field = "<label class='grunion-field-label checkbox" . ( $this->is_error() ? ' form-error' : '' ) . "'>";
3347
			$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";
3348
			$field .= "\t\t" . esc_html( $label ) . ( $required ? '<span>' . $required_field_text . '</span>' : '' );
3349
		$field .=  "</label>\n";
3350
		$field .= "<div class='clear-form'></div>\n";
3351
		return $field;
3352
	}
3353
3354
	function render_checkbox_multiple_field( $id, $label, $value, $class, $required, $required_field_text  ) {
3355
		$field = $this->render_label( '', $id, $label, $required, $required_field_text );
3356
		foreach ( (array) $this->get_attribute( 'options' ) as $optionIndex => $option ) {
3357
			$option = Grunion_Contact_Form_Plugin::strip_tags( $option );
3358
			if ( $option  ) {
3359
				$field .= "\t\t<label class='grunion-checkbox-multiple-label checkbox-multiple" . ( $this->is_error() ? ' form-error' : '' ) . "'>";
3360
				$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 ) . ' /> ';
3361
				$field .= esc_html( $option ) . "</label>\n";
3362
				$field .= "\t\t<div class='clear-form'></div>\n";
3363
			}
3364
		}
3365
3366
		return $field;
3367
	}
3368
3369
	function render_select_field( $id, $label, $value, $class, $required, $required_field_text ) {
3370
		$field = $this->render_label( 'select', $id, $label, $required, $required_field_text );
3371
		$field  .= "\t<select name='" . esc_attr( $id ) . "' id='" . esc_attr( $id ) . "' " . $class . ( $required ? "required aria-required='true'" : '' ) . ">\n";
3372
		foreach ( (array) $this->get_attribute( 'options' ) as $optionIndex => $option ) {
3373
			$option = Grunion_Contact_Form_Plugin::strip_tags( $option );
3374
			if ( $option ) {
3375
				$field .= "\t\t<option"
3376
				               . selected( $option, $value, false )
3377
				               . " value='" . esc_attr( $this->get_option_value( $this->get_attribute( 'values' ), $optionIndex, $option ) )
3378
				               . "'>" . esc_html( $option )
3379
				          . "</option>\n";
3380
			}
3381
		}
3382
		$field  .= "\t</select>\n";
3383
		return $field;
3384
	}
3385
3386
	function render_date_field( $id, $label, $value, $class, $required, $required_field_text, $placeholder ) {
3387
3388
		$field = $this->render_label( 'date', $id, $label, $required, $required_field_text );
3389
		$field .= $this->render_input_field( 'text', $id, $value, $class, $placeholder, $required );
3390
3391
		/* For AMP requests, use amp-date-picker element: https://amp.dev/documentation/components/amp-date-picker */
3392
		if ( class_exists( 'Jetpack_AMP_Support' ) && Jetpack_AMP_Support::is_amp_request() ) {
3393
			return sprintf(
3394
				'<%1$s mode="overlay" layout="container" type="single" input-selector="[name=%2$s]">%3$s</%1$s>',
3395
				'amp-date-picker',
3396
				esc_attr( $id ),
3397
				$field
3398
			);
3399
		}
3400
3401
		wp_enqueue_script(
3402
			'grunion-frontend',
3403
			Assets::get_file_url_for_environment(
3404
				'_inc/build/contact-form/js/grunion-frontend.min.js',
3405
				'modules/contact-form/js/grunion-frontend.js'
3406
			),
3407
			array( 'jquery', 'jquery-ui-datepicker' )
3408
		);
3409
		wp_enqueue_style( 'jp-jquery-ui-datepicker', plugins_url( 'css/jquery-ui-datepicker.css', __FILE__ ), array( 'dashicons' ), '1.0' );
3410
3411
		// Using Core's built-in datepicker localization routine
3412
		wp_localize_jquery_ui_datepicker();
3413
		return $field;
3414
	}
3415
3416
	function render_default_field( $id, $label, $value, $class, $required, $required_field_text, $placeholder, $type ) {
3417
		$field = $this->render_label( $type, $id, $label, $required, $required_field_text );
3418
		$field .= $this->render_input_field( 'text', $id, $value, $class, $placeholder, $required );
3419
		return $field;
3420
	}
3421
3422
	function render_field( $type, $id, $label, $value, $class, $placeholder, $required ) {
3423
3424
		$field_placeholder = ( ! empty( $placeholder ) ) ? "placeholder='" . esc_attr( $placeholder ) . "'" : '';
3425
		$field_class       = "class='" . trim( esc_attr( $type ) . ' ' . esc_attr( $class ) ) . "' ";
3426
		$wrap_classes = empty( $class ) ? '' : implode( '-wrap ', array_filter( explode( ' ', $class ) ) ) . '-wrap'; // this adds
3427
3428
		$shell_field_class = "class='grunion-field-wrap grunion-field-" . trim( esc_attr( $type ) . '-wrap ' . esc_attr( $wrap_classes ) ) . "' ";
3429
		/**
3430
		/**
3431
		 * Filter the Contact Form required field text
3432
		 *
3433
		 * @module contact-form
3434
		 *
3435
		 * @since 3.8.0
3436
		 *
3437
		 * @param string $var Required field text. Default is "(required)".
3438
		 */
3439
		$required_field_text = esc_html( apply_filters( 'jetpack_required_field_text', __( '(required)', 'jetpack' ) ) );
3440
3441
		$field = "\n<div {$shell_field_class} >\n"; // new in Jetpack 6.8.0
3442
		// If they are logged in, and this is their site, don't pre-populate fields
3443
		if ( current_user_can( 'manage_options' ) ) {
3444
			$value = '';
3445
		}
3446
		switch ( $type ) {
3447
			case 'email':
3448
				$field .= $this->render_email_field( $id, $label, $value, $field_class, $required, $required_field_text, $field_placeholder );
3449
				break;
3450
			case 'telephone':
3451
				$field .= $this->render_telephone_field( $id, $label, $value, $field_class, $required, $required_field_text, $field_placeholder );
3452
				break;
3453
			case 'url':
3454
				$field .= $this->render_url_field( $id, $label, $value, $field_class, $required, $required_field_text, $field_placeholder );
3455
				break;
3456
			case 'textarea':
3457
				$field .= $this->render_textarea_field( $id, $label, $value, $field_class, $required, $required_field_text, $field_placeholder );
3458
				break;
3459
			case 'radio':
3460
				$field .= $this->render_radio_field( $id, $label, $value, $field_class, $required, $required_field_text, $field_placeholder );
3461
				break;
3462
			case 'checkbox':
3463
				$field .= $this->render_checkbox_field( $id, $label, $value, $field_class, $required, $required_field_text );
3464
				break;
3465
			case 'checkbox-multiple':
3466
				$field .= $this->render_checkbox_multiple_field( $id, $label, $value, $field_class, $required, $required_field_text );
3467
				break;
3468
			case 'select':
3469
				$field .= $this->render_select_field( $id, $label, $value, $field_class, $required, $required_field_text );
3470
				break;
3471
			case 'date':
3472
				$field .= $this->render_date_field( $id, $label, $value, $field_class, $required, $required_field_text, $field_placeholder );
3473
				break;
3474
			default: // text field
3475
				$field .= $this->render_default_field( $id, $label, $value, $field_class, $required, $required_field_text, $field_placeholder, $type );
3476
				break;
3477
		}
3478
		$field .= "\t</div>\n";
3479
		return $field;
3480
	}
3481
}
3482
3483
add_action( 'init', array( 'Grunion_Contact_Form_Plugin', 'init' ), 9 );
3484
3485
add_action( 'grunion_scheduled_delete', 'grunion_delete_old_spam' );
3486
3487
/**
3488
 * Deletes old spam feedbacks to keep the posts table size under control
3489
 */
3490
function grunion_delete_old_spam() {
3491
	global $wpdb;
3492
3493
	$grunion_delete_limit = 100;
3494
3495
	$now_gmt  = current_time( 'mysql', 1 );
3496
	$sql      = $wpdb->prepare(
3497
		"
3498
		SELECT `ID`
3499
		FROM $wpdb->posts
3500
		WHERE DATE_SUB( %s, INTERVAL 15 DAY ) > `post_date_gmt`
3501
			AND `post_type` = 'feedback'
3502
			AND `post_status` = 'spam'
3503
		LIMIT %d
3504
	", $now_gmt, $grunion_delete_limit
3505
	);
3506
	$post_ids = $wpdb->get_col( $sql );
3507
3508
	foreach ( (array) $post_ids as $post_id ) {
3509
		// force a full delete, skip the trash
3510
		wp_delete_post( $post_id, true );
3511
	}
3512
3513
	if (
3514
		/**
3515
		 * Filter if the module run OPTIMIZE TABLE on the core WP tables.
3516
		 *
3517
		 * @module contact-form
3518
		 *
3519
		 * @since 1.3.1
3520
		 * @since 6.4.0 Set to false by default.
3521
		 *
3522
		 * @param bool $filter Should Jetpack optimize the table, defaults to false.
3523
		 */
3524
		apply_filters( 'grunion_optimize_table', false )
3525
	) {
3526
		$wpdb->query( "OPTIMIZE TABLE $wpdb->posts" );
3527
	}
3528
3529
	// if we hit the max then schedule another run
3530
	if ( count( $post_ids ) >= $grunion_delete_limit ) {
3531
		wp_schedule_single_event( time() + 700, 'grunion_scheduled_delete' );
3532
	}
3533
}
3534