Completed
Push — renovate/gridicons-3.x ( c004c1...f8ccd4 )
by
unknown
284:06 queued 275:32
created

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

Upgrade to new PHP Analysis Engine

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

1
<?php
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: http://automattic.com/#
9
AUthor: Automattic, Inc.
10
Author URI: http://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'        => false,
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
		self::register_contact_form_blocks();
247
	}
248
249
	private static function register_contact_form_blocks() {
250
		jetpack_register_block( 'jetpack/contact-form', array(
251
			'render_callback' => array( __CLASS__, 'gutenblock_render_form' ),
252
		) );
253
254
		// Field render methods.
255
		jetpack_register_block( 'jetpack/field-text', array(
256
			'parent'          => array( 'jetpack/contact-form' ),
257
			'render_callback' => array( __CLASS__, 'gutenblock_render_field_text' ),
258
		) );
259
		jetpack_register_block( 'jetpack/field-name', array(
260
			'parent'          => array( 'jetpack/contact-form' ),
261
			'render_callback' => array( __CLASS__, 'gutenblock_render_field_name' ),
262
		) );
263
		jetpack_register_block( 'jetpack/field-email', array(
264
			'parent'          => array( 'jetpack/contact-form' ),
265
			'render_callback' => array( __CLASS__, 'gutenblock_render_field_email' ),
266
		) );
267
		jetpack_register_block( 'jetpack/field-url', array(
268
			'parent'          => array( 'jetpack/contact-form' ),
269
			'render_callback' => array( __CLASS__, 'gutenblock_render_field_url' ),
270
		) );
271
		jetpack_register_block( 'jetpack/field-date', array(
272
			'parent'          => array( 'jetpack/contact-form' ),
273
			'render_callback' => array( __CLASS__, 'gutenblock_render_field_date' ),
274
		) );
275
		jetpack_register_block( 'jetpack/field-telephone', array(
276
			'parent'          => array( 'jetpack/contact-form' ),
277
			'render_callback' => array( __CLASS__, 'gutenblock_render_field_telephone' ),
278
		) );
279
		jetpack_register_block( 'jetpack/field-textarea', array(
280
			'parent'          => array( 'jetpack/contact-form' ),
281
			'render_callback' => array( __CLASS__, 'gutenblock_render_field_textarea' ),
282
		) );
283
		jetpack_register_block( 'jetpack/field-checkbox', array(
284
			'parent'          => array( 'jetpack/contact-form' ),
285
			'render_callback' => array( __CLASS__, 'gutenblock_render_field_checkbox' ),
286
		) );
287
		jetpack_register_block( 'jetpack/field-checkbox-multiple', array(
288
			'parent'          => array( 'jetpack/contact-form' ),
289
			'render_callback' => array( __CLASS__, 'gutenblock_render_field_checkbox_multiple' ),
290
		) );
291
		jetpack_register_block( 'jetpack/field-radio', array(
292
			'parent'          => array( 'jetpack/contact-form' ),
293
			'render_callback' => array( __CLASS__, 'gutenblock_render_field_radio' ),
294
		) );
295
		jetpack_register_block( 'jetpack/field-select', array(
296
			'parent'          => array( 'jetpack/contact-form' ),
297
			'render_callback' => array( __CLASS__, 'gutenblock_render_field_select' ),
298
		) );
299
	}
300
301
	public static function gutenblock_render_form( $atts, $content ) {
302
		return Grunion_Contact_Form::parse( $atts, do_blocks( $content ) );
303
	}
304
305
	public static function block_attributes_to_shortcode_attributes( $atts, $type ) {
306
		$atts['type'] = $type;
307
		if ( isset( $atts['className'] ) ) {
308
			$atts['class'] = $atts['className'];
309
			unset( $atts['className'] );
310
		}
311
312
		if ( isset( $atts['defaultValue'] ) ) {
313
			$atts['default'] = $atts['defaultValue'];
314
			unset( $atts['defaultValue'] );
315
		}
316
317
		return $atts;
318
	}
319
320
	public static function gutenblock_render_field_text( $atts, $content ) {
321
		$atts = self::block_attributes_to_shortcode_attributes( $atts, 'text' );
322
		return Grunion_Contact_Form::parse_contact_field( $atts, $content );
323
	}
324
	public static function gutenblock_render_field_name( $atts, $content ) {
325
		$atts = self::block_attributes_to_shortcode_attributes( $atts, 'name' );
326
		return Grunion_Contact_Form::parse_contact_field( $atts, $content );
327
	}
328
	public static function gutenblock_render_field_email( $atts, $content ) {
329
		$atts = self::block_attributes_to_shortcode_attributes( $atts, 'email' );
330
		return Grunion_Contact_Form::parse_contact_field( $atts, $content );
331
	}
332
	public static function gutenblock_render_field_url( $atts, $content ) {
333
		$atts = self::block_attributes_to_shortcode_attributes( $atts, 'url' );
334
		return Grunion_Contact_Form::parse_contact_field( $atts, $content );
335
	}
336
	public static function gutenblock_render_field_date( $atts, $content ) {
337
		$atts = self::block_attributes_to_shortcode_attributes( $atts, 'date' );
338
		return Grunion_Contact_Form::parse_contact_field( $atts, $content );
339
	}
340
	public static function gutenblock_render_field_telephone( $atts, $content ) {
341
		$atts = self::block_attributes_to_shortcode_attributes( $atts, 'telephone' );
342
		return Grunion_Contact_Form::parse_contact_field( $atts, $content );
343
	}
344
	public static function gutenblock_render_field_textarea( $atts, $content ) {
345
		$atts = self::block_attributes_to_shortcode_attributes( $atts, 'textarea' );
346
		return Grunion_Contact_Form::parse_contact_field( $atts, $content );
347
	}
348
	public static function gutenblock_render_field_checkbox( $atts, $content ) {
349
		$atts = self::block_attributes_to_shortcode_attributes( $atts, 'checkbox' );
350
		return Grunion_Contact_Form::parse_contact_field( $atts, $content );
351
	}
352
	public static function gutenblock_render_field_checkbox_multiple( $atts, $content ) {
353
		$atts = self::block_attributes_to_shortcode_attributes( $atts, 'checkbox-multiple' );
354
		return Grunion_Contact_Form::parse_contact_field( $atts, $content );
355
	}
356
	public static function gutenblock_render_field_radio( $atts, $content ) {
357
		$atts = self::block_attributes_to_shortcode_attributes( $atts, 'radio' );
358
		return Grunion_Contact_Form::parse_contact_field( $atts, $content );
359
	}
360
	public static function gutenblock_render_field_select( $atts, $content ) {
361
		$atts = self::block_attributes_to_shortcode_attributes( $atts, 'select' );
362
		return Grunion_Contact_Form::parse_contact_field( $atts, $content );
363
	}
364
365
	/**
366
	 * Add the 'Export' menu item as a submenu of Feedback.
367
	 */
368
	public function admin_menu() {
369
		add_submenu_page(
370
			'edit.php?post_type=feedback',
371
			__( 'Export feedback as CSV', 'jetpack' ),
372
			__( 'Export CSV', 'jetpack' ),
373
			'export',
374
			'feedback-export',
375
			array( $this, 'export_form' )
376
		);
377
	}
378
379
	/**
380
	 * Add to REST API post type whitelist
381
	 */
382
	function allow_feedback_rest_api_type( $post_types ) {
383
		$post_types[] = 'feedback';
384
		return $post_types;
385
	}
386
387
	/**
388
	 * Display the count of new feedback entries received. It's reset when user visits the Feedback screen.
389
	 *
390
	 * @since 4.1.0
391
	 *
392
	 * @param object $screen Information about the current screen.
393
	 */
394
	function unread_count( $screen ) {
395
		if ( isset( $screen->post_type ) && 'feedback' == $screen->post_type ) {
396
			update_option( 'feedback_unread_count', 0 );
397
		} else {
398
			global $menu;
399
			if ( isset( $menu ) && is_array( $menu ) && ! empty( $menu ) ) {
400
				foreach ( $menu as $index => $menu_item ) {
401
					if ( 'edit.php?post_type=feedback' == $menu_item[2] ) {
402
						$unread = get_option( 'feedback_unread_count', 0 );
403
						if ( $unread > 0 ) {
404
							$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>' : '';
405
							$menu[ $index ][0] .= $unread_count;
406
						}
407
						break;
408
					}
409
				}
410
			}
411
		}
412
	}
413
414
	/**
415
	 * Handles all contact-form POST submissions
416
	 *
417
	 * Conditionally attached to `template_redirect`
418
	 */
419
	function process_form_submission() {
420
		// Add a filter to replace tokens in the subject field with sanitized field values
421
		add_filter( 'contact_form_subject', array( $this, 'replace_tokens_with_input' ), 10, 2 );
422
423
		$id   = stripslashes( $_POST['contact-form-id'] );
424
		$hash = isset( $_POST['contact-form-hash'] ) ? $_POST['contact-form-hash'] : null;
425
		$hash = preg_replace( '/[^\da-f]/i', '', $hash );
426
427
		if ( is_user_logged_in() ) {
428
			check_admin_referer( "contact-form_{$id}" );
429
		}
430
431
		$is_widget = 0 === strpos( $id, 'widget-' );
432
433
		$form = false;
434
435
		if ( $is_widget ) {
436
			// It's a form embedded in a text widget
437
			$this->current_widget_id = substr( $id, 7 ); // remove "widget-"
438
			$widget_type             = implode( '-', array_slice( explode( '-', $this->current_widget_id ), 0, -1 ) ); // Remove trailing -#
439
440
			// Is the widget active?
441
			$sidebar = is_active_widget( false, $this->current_widget_id, $widget_type );
442
443
			// This is lame - no core API for getting a widget by ID
444
			$widget = isset( $GLOBALS['wp_registered_widgets'][ $this->current_widget_id ] ) ? $GLOBALS['wp_registered_widgets'][ $this->current_widget_id ] : false;
445
446
			if ( $sidebar && $widget && isset( $widget['callback'] ) ) {
447
				// prevent PHP notices by populating widget args
448
				$widget_args = array(
449
					'before_widget' => '',
450
					'after_widget'  => '',
451
					'before_title'  => '',
452
					'after_title'   => '',
453
				);
454
				// This is lamer - no API for outputting a given widget by ID
455
				ob_start();
456
				// Process the widget to populate Grunion_Contact_Form::$last
457
				call_user_func( $widget['callback'], $widget_args, $widget['params'][0] );
458
				ob_end_clean();
459
			}
460
		} else {
461
			// It's a form embedded in a post
462
			$post = get_post( $id );
463
464
			// Process the content to populate Grunion_Contact_Form::$last
465
			/** This filter is already documented in core. wp-includes/post-template.php */
466
			apply_filters( 'the_content', $post->post_content );
467
		}
468
469
		$form = isset( Grunion_Contact_Form::$forms[ $hash ] ) ? Grunion_Contact_Form::$forms[ $hash ] : null;
470
471
		// No form may mean user is using do_shortcode, grab the form using the stored post meta
472
		if ( ! $form ) {
473
474
			// Get shortcode from post meta
475
			$shortcode = get_post_meta( $_POST['contact-form-id'], "_g_feedback_shortcode_{$hash}", true );
476
477
			// Format it
478
			if ( $shortcode != '' ) {
479
480
				// Get attributes from post meta.
481
				$parameters = '';
482
				$attributes = get_post_meta( $_POST['contact-form-id'], "_g_feedback_shortcode_atts_{$hash}", true );
483
				if ( ! empty( $attributes ) && is_array( $attributes ) ) {
484
					foreach ( array_filter( $attributes ) as $param => $value ) {
485
						$parameters .= " $param=\"$value\"";
486
					}
487
				}
488
489
				$shortcode = '[contact-form' . $parameters . ']' . $shortcode . '[/contact-form]';
490
				do_shortcode( $shortcode );
491
492
				// Recreate form
493
				$form = Grunion_Contact_Form::$last;
494
			}
495
496
			if ( ! $form ) {
497
				return false;
498
			}
499
		}
500
501
		if ( is_wp_error( $form->errors ) && $form->errors->get_error_codes() ) {
502
			return $form->errors;
503
		}
504
505
		// Process the form
506
		return $form->process_submission();
507
	}
508
509
	function ajax_request() {
510
		$submission_result = self::process_form_submission();
511
512
		if ( ! $submission_result ) {
513
			header( 'HTTP/1.1 500 Server Error', 500, true );
514
			echo '<div class="form-error"><ul class="form-errors"><li class="form-error-message">';
515
			esc_html_e( 'An error occurred. Please try again later.', 'jetpack' );
516
			echo '</li></ul></div>';
517
		} elseif ( is_wp_error( $submission_result ) ) {
518
			header( 'HTTP/1.1 400 Bad Request', 403, true );
519
			echo '<div class="form-error"><ul class="form-errors"><li class="form-error-message">';
520
			echo esc_html( $submission_result->get_error_message() );
521
			echo '</li></ul></div>';
522
		} else {
523
			echo '<h3>' . esc_html__( 'Message Sent', 'jetpack' ) . '</h3>' . $submission_result;
524
		}
525
526
		die;
527
	}
528
529
	/**
530
	 * Ensure the post author is always zero for contact-form feedbacks
531
	 * Attached to `wp_insert_post_data`
532
	 *
533
	 * @see Grunion_Contact_Form::process_submission()
534
	 *
535
	 * @param array $data the data to insert
536
	 * @param array $postarr the data sent to wp_insert_post()
537
	 * @return array The filtered $data to insert
538
	 */
539
	function insert_feedback_filter( $data, $postarr ) {
540
		if ( $data['post_type'] == 'feedback' && $postarr['post_type'] == 'feedback' ) {
541
			$data['post_author'] = 0;
542
		}
543
544
		return $data;
545
	}
546
	/*
547
	 * Adds our contact-form shortcode
548
	 * The "child" contact-field shortcode is enabled as needed by the contact-form shortcode handler
549
	 */
550
	function add_shortcode() {
551
		add_shortcode( 'contact-form', array( 'Grunion_Contact_Form', 'parse' ) );
552
		add_shortcode( 'contact-field', array( 'Grunion_Contact_Form', 'parse_contact_field' ) );
553
	}
554
555
	static function tokenize_label( $label ) {
556
		return '{' . trim( preg_replace( '#^\d+_#', '', $label ) ) . '}';
557
	}
558
559
	static function sanitize_value( $value ) {
560
		return preg_replace( '=((<CR>|<LF>|0x0A/%0A|0x0D/%0D|\\n|\\r)\S).*=i', null, $value );
561
	}
562
563
	/**
564
	 * Replaces tokens like {city} or {City} (case insensitive) with the value
565
	 * of an input field of that name
566
	 *
567
	 * @param string $subject
568
	 * @param array  $field_values Array with field label => field value associations
569
	 *
570
	 * @return string The filtered $subject with the tokens replaced
571
	 */
572
	function replace_tokens_with_input( $subject, $field_values ) {
573
		// Wrap labels into tokens (inside {})
574
		$wrapped_labels = array_map( array( 'Grunion_Contact_Form_Plugin', 'tokenize_label' ), array_keys( $field_values ) );
575
		// Sanitize all values
576
		$sanitized_values = array_map( array( 'Grunion_Contact_Form_Plugin', 'sanitize_value' ), array_values( $field_values ) );
577
578
		foreach ( $sanitized_values as $k => $sanitized_value ) {
579
			if ( is_array( $sanitized_value ) ) {
580
				$sanitized_values[ $k ] = implode( ', ', $sanitized_value );
581
			}
582
		}
583
584
		// Search for all valid tokens (based on existing fields) and replace with the field's value
585
		$subject = str_ireplace( $wrapped_labels, $sanitized_values, $subject );
586
		return $subject;
587
	}
588
589
	/**
590
	 * Tracks the widget currently being processed.
591
	 * Attached to `dynamic_sidebar`
592
	 *
593
	 * @see $current_widget_id
594
	 *
595
	 * @param array $widget The widget data
596
	 */
597
	function track_current_widget( $widget ) {
598
		$this->current_widget_id = $widget['id'];
599
	}
600
601
	/**
602
	 * Adds a "widget" attribute to every contact-form embedded in a text widget.
603
	 * Used to tell the difference between post-embedded contact-forms and widget-embedded contact-forms
604
	 * Attached to `widget_text`
605
	 *
606
	 * @param string $text The widget text
607
	 * @return string The filtered widget text
608
	 */
609
	function widget_atts( $text ) {
610
		Grunion_Contact_Form::style( true );
611
612
		return preg_replace( '/\[contact-form([^a-zA-Z_-])/', '[contact-form widget="' . $this->current_widget_id . '"\\1', $text );
613
	}
614
615
	/**
616
	 * For sites where text widgets are not processed for shortcodes, we add this hack to process just our shortcode
617
	 * Attached to `widget_text`
618
	 *
619
	 * @param string $text The widget text
620
	 * @return string The contact-form filtered widget text
621
	 */
622
	function widget_shortcode_hack( $text ) {
623
		if ( ! preg_match( '/\[contact-form([^a-zA-Z_-])/', $text ) ) {
624
			return $text;
625
		}
626
627
		$old = $GLOBALS['shortcode_tags'];
628
		remove_all_shortcodes();
629
		Grunion_Contact_Form_Plugin::$using_contact_form_field = true;
630
		$this->add_shortcode();
631
632
		$text = do_shortcode( $text );
633
634
		Grunion_Contact_Form_Plugin::$using_contact_form_field = false;
635
		$GLOBALS['shortcode_tags']                             = $old;
636
637
		return $text;
638
	}
639
640
	/**
641
	 * Check if a submission matches the Comment Blacklist.
642
	 * The Comment Blacklist is a means to moderate discussion, and contact
643
	 * forms are 1:1 discussion forums, ripe for abuse by users who are being
644
	 * removed from the public discussion.
645
	 * Attached to `jetpack_contact_form_is_spam`
646
	 *
647
	 * @param bool  $is_spam
648
	 * @param array $form
649
	 * @return bool TRUE => spam, FALSE => not spam
650
	 */
651
	function is_spam_blacklist( $is_spam, $form = array() ) {
652
		if ( $is_spam ) {
653
			return $is_spam;
654
		}
655
656
		if ( wp_blacklist_check( $form['comment_author'], $form['comment_author_email'], $form['comment_author_url'], $form['comment_content'], $form['user_ip'], $form['user_agent'] ) ) {
657
			return true;
658
		}
659
660
		return false;
661
	}
662
663
	/**
664
	 * Populate an array with all values necessary to submit a NEW contact-form feedback to Akismet.
665
	 * Note that this includes the current user_ip etc, so this should only be called when accepting a new item via $_POST
666
	 *
667
	 * @param array $form Contact form feedback array
668
	 * @return array feedback array with additional data ready for submission to Akismet
669
	 */
670
	function prepare_for_akismet( $form ) {
671
		$form['comment_type'] = 'contact_form';
672
		$form['user_ip']      = $_SERVER['REMOTE_ADDR'];
673
		$form['user_agent']   = $_SERVER['HTTP_USER_AGENT'];
674
		$form['referrer']     = $_SERVER['HTTP_REFERER'];
675
		$form['blog']         = get_option( 'home' );
676
677
		foreach ( $_SERVER as $key => $value ) {
678
			if ( ! is_string( $value ) ) {
679
				continue;
680
			}
681
			if ( in_array( $key, array( 'HTTP_COOKIE', 'HTTP_COOKIE2', 'HTTP_USER_AGENT', 'HTTP_REFERER' ) ) ) {
682
				// We don't care about cookies, and the UA and Referrer were caught above.
683
				continue;
684
			} elseif ( in_array( $key, array( 'REMOTE_ADDR', 'REQUEST_URI', 'DOCUMENT_URI' ) ) ) {
685
				// All three of these are relevant indicators and should be passed along.
686
				$form[ $key ] = $value;
687
			} elseif ( wp_startswith( $key, 'HTTP_' ) ) {
688
				// Any other HTTP header indicators.
689
				// `wp_startswith()` is a wpcom helper function and is included in Jetpack via `functions.compat.php`
690
				$form[ $key ] = $value;
691
			}
692
		}
693
694
		return $form;
695
	}
696
697
	/**
698
	 * Submit contact-form data to Akismet to check for spam.
699
	 * If you're accepting a new item via $_POST, run it Grunion_Contact_Form_Plugin::prepare_for_akismet() first
700
	 * Attached to `jetpack_contact_form_is_spam`
701
	 *
702
	 * @param bool  $is_spam
703
	 * @param array $form
704
	 * @return bool|WP_Error TRUE => spam, FALSE => not spam, WP_Error => stop processing entirely
705
	 */
706
	function is_spam_akismet( $is_spam, $form = array() ) {
707
		global $akismet_api_host, $akismet_api_port;
708
709
		// The signature of this function changed from accepting just $form.
710
		// If something only sends an array, assume it's still using the old
711
		// signature and work around it.
712
		if ( empty( $form ) && is_array( $is_spam ) ) {
713
			$form    = $is_spam;
714
			$is_spam = false;
715
		}
716
717
		// If a previous filter has alrady marked this as spam, trust that and move on.
718
		if ( $is_spam ) {
719
			return $is_spam;
720
		}
721
722
		if ( ! function_exists( 'akismet_http_post' ) && ! defined( 'AKISMET_VERSION' ) ) {
723
			return false;
724
		}
725
726
		$query_string = http_build_query( $form );
727
728
		if ( method_exists( 'Akismet', 'http_post' ) ) {
729
			$response = Akismet::http_post( $query_string, 'comment-check' );
730
		} else {
731
			$response = akismet_http_post( $query_string, $akismet_api_host, '/1.1/comment-check', $akismet_api_port );
732
		}
733
734
		$result = false;
735
736
		if ( isset( $response[0]['x-akismet-pro-tip'] ) && 'discard' === trim( $response[0]['x-akismet-pro-tip'] ) && get_option( 'akismet_strictness' ) === '1' ) {
737
			$result = new WP_Error( 'feedback-discarded', __( 'Feedback discarded.', 'jetpack' ) );
0 ignored issues
show
The call to WP_Error::__construct() has too many arguments starting with 'feedback-discarded'.

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

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

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

Loading history...
738
		} elseif ( isset( $response[1] ) && 'true' == trim( $response[1] ) ) { // 'true' is spam
739
			$result = true;
740
		}
741
742
		/**
743
		 * Filter the results returned by Akismet for each submitted contact form.
744
		 *
745
		 * @module contact-form
746
		 *
747
		 * @since 1.3.1
748
		 *
749
		 * @param WP_Error|bool $result Is the submitted feedback spam.
750
		 * @param array|bool $form Submitted feedback.
751
		 */
752
		return apply_filters( 'contact_form_is_spam_akismet', $result, $form );
753
	}
754
755
	/**
756
	 * Submit a feedback as either spam or ham
757
	 *
758
	 * @param string $as Either 'spam' or 'ham'.
759
	 * @param array  $form the contact-form data
760
	 */
761
	function akismet_submit( $as, $form ) {
762
		global $akismet_api_host, $akismet_api_port;
763
764
		if ( ! in_array( $as, array( 'ham', 'spam' ) ) ) {
765
			return false;
766
		}
767
768
		$query_string = '';
769
		if ( is_array( $form ) ) {
770
			$query_string = http_build_query( $form );
771
		}
772
		if ( method_exists( 'Akismet', 'http_post' ) ) {
773
			$response = Akismet::http_post( $query_string, "submit-{$as}" );
774
		} else {
775
			$response = akismet_http_post( $query_string, $akismet_api_host, "/1.1/submit-{$as}", $akismet_api_port );
776
		}
777
778
		return trim( $response[1] );
779
	}
780
781
	/**
782
	 * Prints the menu
783
	 */
784
	function export_form() {
785
		$current_screen = get_current_screen();
786
		if ( ! in_array( $current_screen->id, array( 'edit-feedback', 'feedback_page_feedback-export' ) ) ) {
787
			return;
788
		}
789
790
		if ( ! current_user_can( 'export' ) ) {
791
			return;
792
		}
793
794
		// if there aren't any feedbacks, bail out
795
		if ( ! (int) wp_count_posts( 'feedback' )->publish ) {
796
			return;
797
		}
798
		?>
799
800
		<div id="feedback-export" style="display:none">
801
			<h2><?php _e( 'Export feedback as CSV', 'jetpack' ); ?></h2>
802
			<div class="clear"></div>
803
			<form action="<?php echo admin_url( 'admin-post.php' ); ?>" method="post" class="form">
804
				<?php wp_nonce_field( 'feedback_export', 'feedback_export_nonce' ); ?>
805
806
				<input name="action" value="feedback_export" type="hidden">
807
				<label for="post"><?php _e( 'Select feedback to download', 'jetpack' ); ?></label>
808
				<select name="post">
809
					<option value="all"><?php esc_html_e( 'All posts', 'jetpack' ); ?></option>
810
					<?php echo $this->get_feedbacks_as_options(); ?>
811
				</select>
812
813
				<br><br>
814
				<input type="submit" name="submit" id="submit" class="button button-primary" value="<?php esc_html_e( 'Download', 'jetpack' ); ?>">
815
			</form>
816
		</div>
817
818
		<?php
819
		// There aren't any usable actions in core to output the "export feedback" form in the correct place,
820
		// so this inline JS moves it from the top of the page to the bottom.
821
		?>
822
		<script type='text/javascript'>
823
		    var menu = document.getElementById( 'feedback-export' ),
824
                wrapper = document.getElementsByClassName( 'wrap' )[0];
825
            <?php if ( 'edit-feedback' === $current_screen->id ) : ?>
826
            wrapper.appendChild(menu);
827
            <?php endif; ?>
828
            menu.style.display = 'block';
829
		</script>
830
		<?php
831
	}
832
833
	/**
834
	 * Fetch post content for a post and extract just the comment.
835
	 *
836
	 * @param int $post_id The post id to fetch the content for.
837
	 *
838
	 * @return string Trimmed post comment.
839
	 *
840
	 * @codeCoverageIgnore
841
	 */
842
	public function get_post_content_for_csv_export( $post_id ) {
843
		$post_content = get_post_field( 'post_content', $post_id );
844
		$content      = explode( '<!--more-->', $post_content );
845
846
		return trim( $content[0] );
847
	}
848
849
	/**
850
	 * Get `_feedback_extra_fields` field from post meta data.
851
	 *
852
	 * @param int $post_id Id of the post to fetch meta data for.
853
	 *
854
	 * @return mixed
855
	 *
856
	 * @codeCoverageIgnore - No need to be covered.
857
	 */
858
	public function get_post_meta_for_csv_export( $post_id ) {
859
		return get_post_meta( $post_id, '_feedback_extra_fields', true );
860
	}
861
862
	/**
863
	 * Get parsed feedback post fields.
864
	 *
865
	 * @param int $post_id Id of the post to fetch parsed contents for.
866
	 *
867
	 * @return array
868
	 *
869
	 * @codeCoverageIgnore - No need to be covered.
870
	 */
871
	public function get_parsed_field_contents_of_post( $post_id ) {
872
		return self::parse_fields_from_content( $post_id );
873
	}
874
875
	/**
876
	 * Properly maps fields that are missing from the post meta data
877
	 * to names, that are similar to those of the post meta.
878
	 *
879
	 * @param array $parsed_post_content Parsed post content
880
	 *
881
	 * @see parse_fields_from_content for how the input data is generated.
882
	 *
883
	 * @return array Mapped fields.
884
	 */
885
	public function map_parsed_field_contents_of_post_to_field_names( $parsed_post_content ) {
886
887
		$mapped_fields = array();
888
889
		$field_mapping = array(
890
			'_feedback_subject'      => __( 'Contact Form', 'jetpack' ),
891
			'_feedback_author'       => '1_Name',
892
			'_feedback_author_email' => '2_Email',
893
			'_feedback_author_url'   => '3_Website',
894
			'_feedback_main_comment' => '4_Comment',
895
		);
896
897
		foreach ( $field_mapping as $parsed_field_name => $field_name ) {
898
			if (
899
				isset( $parsed_post_content[ $parsed_field_name ] )
900
				&& ! empty( $parsed_post_content[ $parsed_field_name ] )
901
			) {
902
				$mapped_fields[ $field_name ] = $parsed_post_content[ $parsed_field_name ];
903
			}
904
		}
905
906
		return $mapped_fields;
907
	}
908
909
	/**
910
	 * Registers the personal data exporter.
911
	 *
912
	 * @since 6.1.1
913
	 *
914
	 * @param  array $exporters An array of personal data exporters.
915
	 *
916
	 * @return array $exporters An array of personal data exporters.
917
	 */
918
	public function register_personal_data_exporter( $exporters ) {
919
		$exporters['jetpack-feedback'] = array(
920
			'exporter_friendly_name' => __( 'Feedback', 'jetpack' ),
921
			'callback'               => array( $this, 'personal_data_exporter' ),
922
		);
923
924
		return $exporters;
925
	}
926
927
	/**
928
	 * Registers the personal data eraser.
929
	 *
930
	 * @since 6.1.1
931
	 *
932
	 * @param  array $erasers An array of personal data erasers.
933
	 *
934
	 * @return array $erasers An array of personal data erasers.
935
	 */
936
	public function register_personal_data_eraser( $erasers ) {
937
		$erasers['jetpack-feedback'] = array(
938
			'eraser_friendly_name' => __( 'Feedback', 'jetpack' ),
939
			'callback'             => array( $this, 'personal_data_eraser' ),
940
		);
941
942
		return $erasers;
943
	}
944
945
	/**
946
	 * Exports personal data.
947
	 *
948
	 * @since 6.1.1
949
	 *
950
	 * @param  string $email  Email address.
951
	 * @param  int    $page   Page to export.
952
	 *
953
	 * @return array  $return Associative array with keys expected by core.
954
	 */
955
	public function personal_data_exporter( $email, $page = 1 ) {
956
		return $this->_internal_personal_data_exporter( $email, $page );
957
	}
958
959
	/**
960
	 * Internal method for exporting personal data.
961
	 *
962
	 * Allows us to have a different signature than core expects
963
	 * while protecting against future core API changes.
964
	 *
965
	 * @internal
966
	 * @since 6.5
967
	 *
968
	 * @param  string $email    Email address.
969
	 * @param  int    $page     Page to export.
970
	 * @param  int    $per_page Number of feedbacks to process per page. Internal use only (testing)
971
	 *
972
	 * @return array            Associative array with keys expected by core.
973
	 */
974
	public function _internal_personal_data_exporter( $email, $page = 1, $per_page = 250 ) {
975
		$export_data = array();
976
		$post_ids    = $this->personal_data_post_ids_by_email( $email, $per_page, $page );
977
978
		foreach ( $post_ids as $post_id ) {
979
			$post_fields = $this->get_parsed_field_contents_of_post( $post_id );
980
981
			if ( ! is_array( $post_fields ) || empty( $post_fields['_feedback_subject'] ) ) {
982
				continue; // Corrupt data.
983
			}
984
985
			$post_fields['_feedback_main_comment'] = $this->get_post_content_for_csv_export( $post_id );
986
			$post_fields                           = $this->map_parsed_field_contents_of_post_to_field_names( $post_fields );
987
988
			if ( ! is_array( $post_fields ) || empty( $post_fields ) ) {
989
				continue; // No fields to export.
990
			}
991
992
			$post_meta = $this->get_post_meta_for_csv_export( $post_id );
993
			$post_meta = is_array( $post_meta ) ? $post_meta : array();
994
995
			$post_export_data = array();
996
			$post_data        = array_merge( $post_fields, $post_meta );
997
			ksort( $post_data );
998
999
			foreach ( $post_data as $post_data_key => $post_data_value ) {
1000
				$post_export_data[] = array(
1001
					'name'  => preg_replace( '/^[0-9]+_/', '', $post_data_key ),
1002
					'value' => $post_data_value,
1003
				);
1004
			}
1005
1006
			$export_data[] = array(
1007
				'group_id'    => 'feedback',
1008
				'group_label' => __( 'Feedback', 'jetpack' ),
1009
				'item_id'     => 'feedback-' . $post_id,
1010
				'data'        => $post_export_data,
1011
			);
1012
		}
1013
1014
		return array(
1015
			'data' => $export_data,
1016
			'done' => count( $post_ids ) < $per_page,
1017
		);
1018
	}
1019
1020
	/**
1021
	 * Erases personal data.
1022
	 *
1023
	 * @since 6.1.1
1024
	 *
1025
	 * @param  string $email Email address.
1026
	 * @param  int    $page  Page to erase.
1027
	 *
1028
	 * @return array         Associative array with keys expected by core.
1029
	 */
1030
	public function personal_data_eraser( $email, $page = 1 ) {
1031
		return $this->_internal_personal_data_eraser( $email, $page );
1032
	}
1033
1034
	/**
1035
	 * Internal method for erasing personal data.
1036
	 *
1037
	 * Allows us to have a different signature than core expects
1038
	 * while protecting against future core API changes.
1039
	 *
1040
	 * @internal
1041
	 * @since 6.5
1042
	 *
1043
	 * @param  string $email    Email address.
1044
	 * @param  int    $page     Page to erase.
1045
	 * @param  int    $per_page Number of feedbacks to process per page. Internal use only (testing)
1046
	 *
1047
	 * @return array            Associative array with keys expected by core.
1048
	 */
1049
	public function _internal_personal_data_eraser( $email, $page = 1, $per_page = 250 ) {
1050
		$removed      = false;
1051
		$retained     = false;
1052
		$messages     = array();
1053
		$option_name  = sprintf( '_jetpack_pde_feedback_%s', md5( $email ) );
1054
		$last_post_id = 1 === $page ? 0 : get_option( $option_name, 0 );
1055
		$post_ids     = $this->personal_data_post_ids_by_email( $email, $per_page, $page, $last_post_id );
1056
1057
		foreach ( $post_ids as $post_id ) {
1058
			/**
1059
			 * Filters whether to erase a particular Feedback post.
1060
			 *
1061
			 * @since 6.3.0
1062
			 *
1063
			 * @param bool|string $prevention_message Whether to apply erase the Feedback post (bool).
1064
			 *                                        Custom prevention message (string). Default true.
1065
			 * @param int         $post_id            Feedback post ID.
1066
			 */
1067
			$prevention_message = apply_filters( 'grunion_contact_form_delete_feedback_post', true, $post_id );
1068
1069
			if ( true !== $prevention_message ) {
1070
				if ( $prevention_message && is_string( $prevention_message ) ) {
1071
					$messages[] = esc_html( $prevention_message );
1072
				} else {
1073
					$messages[] = sprintf(
1074
					// translators: %d: Post ID.
1075
						__( 'Feedback ID %d could not be removed at this time.', 'jetpack' ),
1076
						$post_id
1077
					);
1078
				}
1079
1080
				$retained = true;
1081
1082
				continue;
1083
			}
1084
1085
			if ( wp_delete_post( $post_id, true ) ) {
1086
				$removed = true;
1087
			} else {
1088
				$retained   = true;
1089
				$messages[] = sprintf(
1090
				// translators: %d: Post ID.
1091
					__( 'Feedback ID %d could not be removed at this time.', 'jetpack' ),
1092
					$post_id
1093
				);
1094
			}
1095
		}
1096
1097
		$done = count( $post_ids ) < $per_page;
1098
1099
		if ( $done ) {
1100
			delete_option( $option_name );
1101
		} else {
1102
			update_option( $option_name, (int) $post_id );
1103
		}
1104
1105
		return array(
1106
			'items_removed'  => $removed,
1107
			'items_retained' => $retained,
1108
			'messages'       => $messages,
1109
			'done'           => $done,
1110
		);
1111
	}
1112
1113
	/**
1114
	 * Queries personal data by email address.
1115
	 *
1116
	 * @since 6.1.1
1117
	 *
1118
	 * @param  string $email        Email address.
1119
	 * @param  int    $per_page     Post IDs per page. Default is `250`.
1120
	 * @param  int    $page         Page to query. Default is `1`.
1121
	 * @param  int    $last_post_id Page to query. Default is `0`. If non-zero, used instead of $page.
1122
	 *
1123
	 * @return array An array of post IDs.
1124
	 */
1125
	public function personal_data_post_ids_by_email( $email, $per_page = 250, $page = 1, $last_post_id = 0 ) {
1126
		add_filter( 'posts_search', array( $this, 'personal_data_search_filter' ) );
1127
1128
		$this->pde_last_post_id_erased = $last_post_id;
1129
		$this->pde_email_address       = $email;
1130
1131
		$post_ids = get_posts(
1132
			array(
1133
				'post_type'        => 'feedback',
1134
				'post_status'      => 'publish',
1135
				// This search parameter gets overwritten in ->personal_data_search_filter()
1136
				's'                => '..PDE..AUTHOR EMAIL:..PDE..',
1137
				'sentence'         => true,
1138
				'order'            => 'ASC',
1139
				'orderby'          => 'ID',
1140
				'fields'           => 'ids',
1141
				'posts_per_page'   => $per_page,
1142
				'paged'            => $last_post_id ? 1 : $page,
1143
				'suppress_filters' => false,
1144
			)
1145
		);
1146
1147
		$this->pde_last_post_id_erased = 0;
1148
		$this->pde_email_address       = '';
1149
1150
		remove_filter( 'posts_search', array( $this, 'personal_data_search_filter' ) );
1151
1152
		return $post_ids;
1153
	}
1154
1155
	/**
1156
	 * Filters searches by email address.
1157
	 *
1158
	 * @since 6.1.1
1159
	 *
1160
	 * @param  string $search SQL where clause.
1161
	 *
1162
	 * @return array          Filtered SQL where clause.
1163
	 */
1164
	public function personal_data_search_filter( $search ) {
1165
		global $wpdb;
1166
1167
		/*
1168
		 * Limits search to `post_content` only, and we only match the
1169
		 * author's email address whenever it's on a line by itself.
1170
		 */
1171
		if ( $this->pde_email_address && false !== strpos( $search, '..PDE..AUTHOR EMAIL:..PDE..' ) ) {
1172
			$search = $wpdb->prepare(
1173
				" AND (
1174
					{$wpdb->posts}.post_content LIKE %s
1175
					OR {$wpdb->posts}.post_content LIKE %s
1176
				)",
1177
				// `chr( 10 )` = `\n`, `chr( 13 )` = `\r`
1178
				'%' . $wpdb->esc_like( chr( 10 ) . 'AUTHOR EMAIL: ' . $this->pde_email_address . chr( 10 ) ) . '%',
1179
				'%' . $wpdb->esc_like( chr( 13 ) . 'AUTHOR EMAIL: ' . $this->pde_email_address . chr( 13 ) ) . '%'
1180
			);
1181
1182
			if ( $this->pde_last_post_id_erased ) {
1183
				$search .= $wpdb->prepare( " AND {$wpdb->posts}.ID > %d", $this->pde_last_post_id_erased );
1184
			}
1185
		}
1186
1187
		return $search;
1188
	}
1189
1190
	/**
1191
	 * Prepares feedback post data for CSV export.
1192
	 *
1193
	 * @param array $post_ids Post IDs to fetch the data for. These need to be Feedback posts.
1194
	 *
1195
	 * @return array
1196
	 */
1197
	public function get_export_data_for_posts( $post_ids ) {
1198
1199
		$posts_data  = array();
1200
		$field_names = array();
1201
		$result      = array();
1202
1203
		/**
1204
		 * Fetch posts and get the possible field names for later use
1205
		 */
1206
		foreach ( $post_ids as $post_id ) {
1207
1208
			/**
1209
			 * Fetch post main data, because we need the subject and author data for the feedback form.
1210
			 */
1211
			$post_real_data = $this->get_parsed_field_contents_of_post( $post_id );
1212
1213
			/**
1214
			 * If `$post_real_data` is not an array or there is no `_feedback_subject` set,
1215
			 * then something must be wrong with the feedback post. Skip it.
1216
			 */
1217
			if ( ! is_array( $post_real_data ) || ! isset( $post_real_data['_feedback_subject'] ) ) {
1218
				continue;
1219
			}
1220
1221
			/**
1222
			 * Fetch main post comment. This is from the default textarea fields.
1223
			 * If it is non-empty, then we add it to data, otherwise skip it.
1224
			 */
1225
			$post_comment_content = $this->get_post_content_for_csv_export( $post_id );
1226
			if ( ! empty( $post_comment_content ) ) {
1227
				$post_real_data['_feedback_main_comment'] = $post_comment_content;
1228
			}
1229
1230
			/**
1231
			 * Map parsed fields to proper field names
1232
			 */
1233
			$mapped_fields = $this->map_parsed_field_contents_of_post_to_field_names( $post_real_data );
1234
1235
			/**
1236
			 * Fetch post meta data.
1237
			 */
1238
			$post_meta_data = $this->get_post_meta_for_csv_export( $post_id );
1239
1240
			/**
1241
			 * If `$post_meta_data` is not an array or if it is empty, then there is no
1242
			 * extra feedback to work with. Create an empty array.
1243
			 */
1244
			if ( ! is_array( $post_meta_data ) || empty( $post_meta_data ) ) {
1245
				$post_meta_data = array();
1246
			}
1247
1248
			/**
1249
			 * Prepend the feedback subject to the list of fields.
1250
			 */
1251
			$post_meta_data = array_merge(
1252
				$mapped_fields,
1253
				$post_meta_data
1254
			);
1255
1256
			/**
1257
			 * Save post metadata for later usage.
1258
			 */
1259
			$posts_data[ $post_id ] = $post_meta_data;
1260
1261
			/**
1262
			 * Save field names, so we can use them as header fields later in the CSV.
1263
			 */
1264
			$field_names = array_merge( $field_names, array_keys( $post_meta_data ) );
1265
		}
1266
1267
		/**
1268
		 * Make sure the field names are unique, because we don't want duplicate data.
1269
		 */
1270
		$field_names = array_unique( $field_names );
1271
1272
		/**
1273
		 * Sort the field names by the field id number
1274
		 */
1275
		sort( $field_names, SORT_NUMERIC );
1276
1277
		/**
1278
		 * Loop through every post, which is essentially CSV row.
1279
		 */
1280
		foreach ( $posts_data as $post_id => $single_post_data ) {
1281
1282
			/**
1283
			 * Go through all the possible fields and check if the field is available
1284
			 * in the current post.
1285
			 *
1286
			 * If it is - add the data as a value.
1287
			 * If it is not - add an empty string, which is just a placeholder in the CSV.
1288
			 */
1289
			foreach ( $field_names as $single_field_name ) {
1290
				if (
1291
					isset( $single_post_data[ $single_field_name ] )
1292
					&& ! empty( $single_post_data[ $single_field_name ] )
1293
				) {
1294
					$result[ $single_field_name ][] = trim( $single_post_data[ $single_field_name ] );
1295
				} else {
1296
					$result[ $single_field_name ][] = '';
1297
				}
1298
			}
1299
		}
1300
1301
		return $result;
1302
	}
1303
1304
	/**
1305
	 * download as a csv a contact form or all of them in a csv file
1306
	 */
1307
	function download_feedback_as_csv() {
1308
		if ( empty( $_POST['feedback_export_nonce'] ) ) {
1309
			return;
1310
		}
1311
1312
		check_admin_referer( 'feedback_export', 'feedback_export_nonce' );
1313
1314
		if ( ! current_user_can( 'export' ) ) {
1315
			return;
1316
		}
1317
1318
		$args = array(
1319
			'posts_per_page'   => -1,
1320
			'post_type'        => 'feedback',
1321
			'post_status'      => 'publish',
1322
			'order'            => 'ASC',
1323
			'fields'           => 'ids',
1324
			'suppress_filters' => false,
1325
		);
1326
1327
		$filename = date( 'Y-m-d' ) . '-feedback-export.csv';
1328
1329
		// Check if we want to download all the feedbacks or just a certain contact form
1330
		if ( ! empty( $_POST['post'] ) && $_POST['post'] !== 'all' ) {
1331
			$args['post_parent'] = (int) $_POST['post'];
1332
			$filename            = date( 'Y-m-d' ) . '-' . str_replace( '&nbsp;', '-', get_the_title( (int) $_POST['post'] ) ) . '.csv';
1333
		}
1334
1335
		$feedbacks = get_posts( $args );
1336
1337
		if ( empty( $feedbacks ) ) {
1338
			return;
1339
		}
1340
1341
		$filename = sanitize_file_name( $filename );
1342
1343
		/**
1344
		 * Prepare data for export.
1345
		 */
1346
		$data = $this->get_export_data_for_posts( $feedbacks );
1347
1348
		/**
1349
		 * If `$data` is empty, there's nothing we can do below.
1350
		 */
1351
		if ( ! is_array( $data ) || empty( $data ) ) {
1352
			return;
1353
		}
1354
1355
		/**
1356
		 * Extract field names from `$data` for later use.
1357
		 */
1358
		$fields = array_keys( $data );
1359
1360
		/**
1361
		 * Count how many rows will be exported.
1362
		 */
1363
		$row_count = count( reset( $data ) );
1364
1365
		// Forces the download of the CSV instead of echoing
1366
		header( 'Content-Disposition: attachment; filename=' . $filename );
1367
		header( 'Pragma: no-cache' );
1368
		header( 'Expires: 0' );
1369
		header( 'Content-Type: text/csv; charset=utf-8' );
1370
1371
		$output = fopen( 'php://output', 'w' );
1372
1373
		/**
1374
		 * Print CSV headers
1375
		 */
1376
		fputcsv( $output, $fields );
1377
1378
		/**
1379
		 * Print rows to the output.
1380
		 */
1381
		for ( $i = 0; $i < $row_count; $i ++ ) {
1382
1383
			$current_row = array();
1384
1385
			/**
1386
			 * Put all the fields in `$current_row` array.
1387
			 */
1388
			foreach ( $fields as $single_field_name ) {
1389
				$current_row[] = $this->esc_csv( $data[ $single_field_name ][ $i ] );
1390
			}
1391
1392
			/**
1393
			 * Output the complete CSV row
1394
			 */
1395
			fputcsv( $output, $current_row );
1396
		}
1397
1398
		fclose( $output );
1399
	}
1400
1401
	/**
1402
	 * Escape a string to be used in a CSV context
1403
	 *
1404
	 * Malicious input can inject formulas into CSV files, opening up the possibility for phishing attacks and
1405
	 * disclosure of sensitive information.
1406
	 *
1407
	 * Additionally, Excel exposes the ability to launch arbitrary commands through the DDE protocol.
1408
	 *
1409
	 * @see http://www.contextis.com/resources/blog/comma-separated-vulnerabilities/
1410
	 *
1411
	 * @param string $field
1412
	 *
1413
	 * @return string
1414
	 */
1415
	public function esc_csv( $field ) {
1416
		$active_content_triggers = array( '=', '+', '-', '@' );
1417
1418
		if ( in_array( mb_substr( $field, 0, 1 ), $active_content_triggers, true ) ) {
1419
			$field = "'" . $field;
1420
		}
1421
1422
		return $field;
1423
	}
1424
1425
	/**
1426
	 * Returns a string of HTML <option> items from an array of posts
1427
	 *
1428
	 * @return string a string of HTML <option> items
1429
	 */
1430
	protected function get_feedbacks_as_options() {
1431
		$options = '';
1432
1433
		// Get the feedbacks' parents' post IDs
1434
		$feedbacks = get_posts(
1435
			array(
1436
				'fields'           => 'id=>parent',
1437
				'posts_per_page'   => 100000,
1438
				'post_type'        => 'feedback',
1439
				'post_status'      => 'publish',
1440
				'suppress_filters' => false,
1441
			)
1442
		);
1443
		$parents   = array_unique( array_values( $feedbacks ) );
1444
1445
		$posts = get_posts(
1446
			array(
1447
				'orderby'          => 'ID',
1448
				'posts_per_page'   => 1000,
1449
				'post_type'        => 'any',
1450
				'post__in'         => array_values( $parents ),
1451
				'suppress_filters' => false,
1452
			)
1453
		);
1454
1455
		// creates the string of <option> elements
1456
		foreach ( $posts as $post ) {
1457
			$options .= sprintf( '<option value="%s">%s</option>', esc_attr( $post->ID ), esc_html( $post->post_title ) );
1458
		}
1459
1460
		return $options;
1461
	}
1462
1463
	/**
1464
	 * Get the names of all the form's fields
1465
	 *
1466
	 * @param  array|int $posts the post we want the fields of
1467
	 *
1468
	 * @return array     the array of fields
1469
	 *
1470
	 * @deprecated As this is no longer necessary as of the CSV export rewrite. - 2015-12-29
1471
	 */
1472
	protected function get_field_names( $posts ) {
1473
		$posts      = (array) $posts;
1474
		$all_fields = array();
1475
1476
		foreach ( $posts as $post ) {
1477
			$fields = self::parse_fields_from_content( $post );
1478
1479
			if ( isset( $fields['_feedback_all_fields'] ) ) {
1480
				$extra_fields = array_keys( $fields['_feedback_all_fields'] );
1481
				$all_fields   = array_merge( $all_fields, $extra_fields );
1482
			}
1483
		}
1484
1485
		$all_fields = array_unique( $all_fields );
1486
		return $all_fields;
1487
	}
1488
1489
	public static function parse_fields_from_content( $post_id ) {
1490
		static $post_fields;
1491
1492
		if ( ! is_array( $post_fields ) ) {
1493
			$post_fields = array();
1494
		}
1495
1496
		if ( isset( $post_fields[ $post_id ] ) ) {
1497
			return $post_fields[ $post_id ];
1498
		}
1499
1500
		$all_values   = array();
1501
		$post_content = get_post_field( 'post_content', $post_id );
1502
		$content      = explode( '<!--more-->', $post_content );
1503
		$lines        = array();
1504
1505
		if ( count( $content ) > 1 ) {
1506
			$content  = str_ireplace( array( '<br />', ')</p>' ), '', $content[1] );
1507
			$one_line = preg_replace( '/\s+/', ' ', $content );
1508
			$one_line = preg_replace( '/.*Array \( (.*)\)/', '$1', $one_line );
1509
1510
			preg_match_all( '/\[([^\]]+)\] =\&gt\; ([^\[]+)/', $one_line, $matches );
1511
1512
			if ( count( $matches ) > 1 ) {
1513
				$all_values = array_combine( array_map( 'trim', $matches[1] ), array_map( 'trim', $matches[2] ) );
1514
			}
1515
1516
			$lines = array_filter( explode( "\n", $content ) );
1517
		}
1518
1519
		$var_map = array(
1520
			'AUTHOR'       => '_feedback_author',
1521
			'AUTHOR EMAIL' => '_feedback_author_email',
1522
			'AUTHOR URL'   => '_feedback_author_url',
1523
			'SUBJECT'      => '_feedback_subject',
1524
			'IP'           => '_feedback_ip',
1525
		);
1526
1527
		$fields = array();
1528
1529
		foreach ( $lines as $line ) {
1530
			$vars = explode( ': ', $line, 2 );
1531
			if ( ! empty( $vars ) ) {
1532
				if ( isset( $var_map[ $vars[0] ] ) ) {
1533
					$fields[ $var_map[ $vars[0] ] ] = self::strip_tags( trim( $vars[1] ) );
1534
				}
1535
			}
1536
		}
1537
1538
		$fields['_feedback_all_fields'] = $all_values;
1539
1540
		$post_fields[ $post_id ] = $fields;
1541
1542
		return $fields;
1543
	}
1544
1545
	/**
1546
	 * Creates a valid csv row from a post id
1547
	 *
1548
	 * @param  int   $post_id The id of the post
1549
	 * @param  array $fields  An array containing the names of all the fields of the csv
1550
	 * @return String The csv row
1551
	 *
1552
	 * @deprecated This is no longer needed, as of the CSV export rewrite.
1553
	 */
1554
	protected static function make_csv_row_from_feedback( $post_id, $fields ) {
1555
		$content_fields = self::parse_fields_from_content( $post_id );
1556
		$all_fields     = array();
1557
1558
		if ( isset( $content_fields['_feedback_all_fields'] ) ) {
1559
			$all_fields = $content_fields['_feedback_all_fields'];
1560
		}
1561
1562
		// Overwrite the parsed content with the content we stored in post_meta in a better format.
1563
		$extra_fields = get_post_meta( $post_id, '_feedback_extra_fields', true );
1564
		foreach ( $extra_fields as $extra_field => $extra_value ) {
1565
			$all_fields[ $extra_field ] = $extra_value;
1566
		}
1567
1568
		// The first element in all of the exports will be the subject
1569
		$row_items[] = $content_fields['_feedback_subject'];
1570
1571
		// Loop the fields array in order to fill the $row_items array correctly
1572
		foreach ( $fields as $field ) {
1573
			if ( $field === __( 'Contact Form', 'jetpack' ) ) { // the first field will ever be the contact form, so we can continue
1574
				continue;
1575
			} elseif ( array_key_exists( $field, $all_fields ) ) {
1576
				$row_items[] = $all_fields[ $field ];
1577
			} else {
1578
				$row_items[] = '';
1579
			}
1580
		}
1581
1582
		return $row_items;
1583
	}
1584
1585
	public static function get_ip_address() {
1586
		return isset( $_SERVER['REMOTE_ADDR'] ) ? $_SERVER['REMOTE_ADDR'] : null;
1587
	}
1588
}
1589
1590
/**
1591
 * Generic shortcode class.
1592
 * Does nothing other than store structured data and output the shortcode as a string
1593
 *
1594
 * Not very general - specific to Grunion.
1595
 */
1596
class Crunion_Contact_Form_Shortcode {
1597
	/**
1598
	 * @var string the name of the shortcode: [$shortcode_name /]
1599
	 */
1600
	public $shortcode_name;
1601
1602
	/**
1603
	 * @var array key => value pairs for the shortcode's attributes: [$shortcode_name key="value" ... /]
1604
	 */
1605
	public $attributes;
1606
1607
	/**
1608
	 * @var array key => value pair for attribute defaults
1609
	 */
1610
	public $defaults = array();
1611
1612
	/**
1613
	 * @var null|string Null for selfclosing shortcodes.  Hhe inner content of otherwise: [$shortcode_name]$content[/$shortcode_name]
1614
	 */
1615
	public $content;
1616
1617
	/**
1618
	 * @var array Associative array of inner "child" shortcodes equivalent to the $content: [$shortcode_name][child 1/][child 2/][/$shortcode_name]
1619
	 */
1620
	public $fields;
1621
1622
	/**
1623
	 * @var null|string The HTML of the parsed inner "child" shortcodes".  Null for selfclosing shortcodes.
1624
	 */
1625
	public $body;
1626
1627
	/**
1628
	 * @param array       $attributes An associative array of shortcode attributes.  @see shortcode_atts()
1629
	 * @param null|string $content Null for selfclosing shortcodes.  The inner content otherwise.
1630
	 */
1631
	function __construct( $attributes, $content = null ) {
1632
		$this->attributes = $this->unesc_attr( $attributes );
1633
		if ( is_array( $content ) ) {
1634
			$string_content = '';
1635
			foreach ( $content as $field ) {
1636
				$string_content .= (string) $field;
1637
			}
1638
1639
			$this->content = $string_content;
1640
		} else {
1641
			$this->content = $content;
1642
		}
1643
1644
		$this->parse_content( $this->content );
1645
	}
1646
1647
	/**
1648
	 * Processes the shortcode's inner content for "child" shortcodes
1649
	 *
1650
	 * @param string $content The shortcode's inner content: [shortcode]$content[/shortcode]
1651
	 */
1652
	function parse_content( $content ) {
1653
		if ( is_null( $content ) ) {
1654
			$this->body = null;
1655
		}
1656
1657
		$this->body = do_shortcode( $content );
1658
	}
1659
1660
	/**
1661
	 * Returns the value of the requested attribute.
1662
	 *
1663
	 * @param string $key The attribute to retrieve
1664
	 * @return mixed
1665
	 */
1666
	function get_attribute( $key ) {
1667
		return isset( $this->attributes[ $key ] ) ? $this->attributes[ $key ] : null;
1668
	}
1669
1670
	function esc_attr( $value ) {
1671
		if ( is_array( $value ) ) {
1672
			return array_map( array( $this, 'esc_attr' ), $value );
1673
		}
1674
1675
		$value = Grunion_Contact_Form_Plugin::strip_tags( $value );
1676
		$value = _wp_specialchars( $value, ENT_QUOTES, false, true );
1677
1678
		// Shortcode attributes can't contain "]"
1679
		$value = str_replace( ']', '', $value );
1680
		$value = str_replace( ',', '&#x002c;', $value ); // store commas encoded
1681
		$value = strtr(
1682
			$value, array(
1683
				'%' => '%25',
1684
				'&' => '%26',
1685
			)
1686
		);
1687
1688
		// shortcode_parse_atts() does stripcslashes()
1689
		$value = addslashes( $value );
1690
		return $value;
1691
	}
1692
1693
	function unesc_attr( $value ) {
1694
		if ( is_array( $value ) ) {
1695
			return array_map( array( $this, 'unesc_attr' ), $value );
1696
		}
1697
1698
		// For back-compat with old Grunion encoding
1699
		// Also, unencode commas
1700
		$value = strtr(
1701
			$value, array(
1702
				'%26' => '&',
1703
				'%25' => '%',
1704
			)
1705
		);
1706
		$value = preg_replace( array( '/&#x0*22;/i', '/&#x0*27;/i', '/&#x0*26;/i', '/&#x0*2c;/i' ), array( '"', "'", '&', ',' ), $value );
1707
		$value = htmlspecialchars_decode( $value, ENT_QUOTES );
1708
		$value = Grunion_Contact_Form_Plugin::strip_tags( $value );
1709
1710
		return $value;
1711
	}
1712
1713
	/**
1714
	 * Generates the shortcode
1715
	 */
1716
	function __toString() {
1717
		$r = "[{$this->shortcode_name} ";
1718
1719
		foreach ( $this->attributes as $key => $value ) {
1720
			if ( ! $value ) {
1721
				continue;
1722
			}
1723
1724
			if ( isset( $this->defaults[ $key ] ) && $this->defaults[ $key ] == $value ) {
1725
				continue;
1726
			}
1727
1728
			if ( 'id' == $key ) {
1729
				continue;
1730
			}
1731
1732
			$value = $this->esc_attr( $value );
1733
1734
			if ( is_array( $value ) ) {
1735
				$value = join( ',', $value );
1736
			}
1737
1738
			if ( false === strpos( $value, "'" ) ) {
1739
				$value = "'$value'";
1740
			} elseif ( false === strpos( $value, '"' ) ) {
1741
				$value = '"' . $value . '"';
1742
			} else {
1743
				// Shortcodes can't contain both '"' and "'".  Strip one.
1744
				$value = str_replace( "'", '', $value );
1745
				$value = "'$value'";
1746
			}
1747
1748
			$r .= "{$key}={$value} ";
1749
		}
1750
1751
		$r = rtrim( $r );
1752
1753
		if ( $this->fields ) {
1754
			$r .= ']';
1755
1756
			foreach ( $this->fields as $field ) {
1757
				$r .= (string) $field;
1758
			}
1759
1760
			$r .= "[/{$this->shortcode_name}]";
1761
		} else {
1762
			$r .= '/]';
1763
		}
1764
1765
		return $r;
1766
	}
1767
}
1768
1769
/**
1770
 * Class for the contact-form shortcode.
1771
 * Parses shortcode to output the contact form as HTML
1772
 * Sends email and stores the contact form response (a.k.a. "feedback")
1773
 */
1774
class Grunion_Contact_Form extends Crunion_Contact_Form_Shortcode {
1775
	public $shortcode_name = 'contact-form';
1776
1777
	/**
1778
	 * @var WP_Error stores form submission errors
1779
	 */
1780
	public $errors;
1781
1782
	/**
1783
	 * @var string The SHA1 hash of the attributes that comprise the form.
1784
	 */
1785
	public $hash;
1786
1787
	/**
1788
	 * @var Grunion_Contact_Form The most recent (inclusive) contact-form shortcode processed
1789
	 */
1790
	static $last;
1791
1792
	/**
1793
	 * @var Whatever form we are currently looking at. If processed, will become $last
1794
	 */
1795
	static $current_form;
1796
1797
	/**
1798
	 * @var array All found forms, indexed by hash.
1799
	 */
1800
	static $forms = array();
1801
1802
	/**
1803
	 * @var bool Whether to print the grunion.css style when processing the contact-form shortcode
1804
	 */
1805
	static $style = false;
1806
1807
	/**
1808
	 * @var array When printing the submit button, what tags are allowed
1809
	 */
1810
	static $allowed_html_tags_for_submit_button = array( 'br' => array() );
1811
1812
	function __construct( $attributes, $content = null ) {
1813
		global $post;
1814
1815
		$this->hash                 = sha1( json_encode( $attributes ) . $content );
1816
		self::$forms[ $this->hash ] = $this;
1817
1818
		// Set up the default subject and recipient for this form
1819
		$default_to      = '';
1820
		$default_subject = '[' . get_option( 'blogname' ) . ']';
1821
1822
		if ( ! isset( $attributes ) || ! is_array( $attributes ) ) {
1823
			$attributes = array();
1824
		}
1825
1826
		if ( ! empty( $attributes['widget'] ) && $attributes['widget'] ) {
1827
			$default_to      .= get_option( 'admin_email' );
1828
			$attributes['id'] = 'widget-' . $attributes['widget'];
1829
			$default_subject  = sprintf( _x( '%1$s Sidebar', '%1$s = blog name', 'jetpack' ), $default_subject );
1830
		} elseif ( $post ) {
1831
			$attributes['id'] = $post->ID;
1832
			$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 ) );
1833
			$post_author      = get_userdata( $post->post_author );
1834
			$default_to      .= $post_author->user_email;
1835
		}
1836
1837
		// Keep reference to $this for parsing form fields
1838
		self::$current_form = $this;
1839
1840
		$this->defaults = array(
1841
			'to'                 => $default_to,
1842
			'subject'            => $default_subject,
1843
			'show_subject'       => 'no', // only used in back-compat mode
1844
			'widget'             => 0,    // Not exposed to the user. Works with Grunion_Contact_Form_Plugin::widget_atts()
1845
			'id'                 => null, // Not exposed to the user. Set above.
1846
			'submit_button_text' => __( 'Submit', 'jetpack' ),
1847
		);
1848
1849
		$attributes = shortcode_atts( $this->defaults, $attributes, 'contact-form' );
1850
1851
		// We only enable the contact-field shortcode temporarily while processing the contact-form shortcode
1852
		Grunion_Contact_Form_Plugin::$using_contact_form_field = true;
1853
1854
		parent::__construct( $attributes, $content );
1855
1856
		// There were no fields in the contact form. The form was probably just [contact-form /]. Build a default form.
1857
		if ( empty( $this->fields ) ) {
1858
			// same as the original Grunion v1 form
1859
			$default_form = '
1860
				[contact-field label="' . __( 'Name', 'jetpack' ) . '" type="name"  required="true" /]
1861
				[contact-field label="' . __( 'Email', 'jetpack' ) . '" type="email" required="true" /]
1862
				[contact-field label="' . __( 'Website', 'jetpack' ) . '" type="url" /]';
1863
1864
			if ( 'yes' == strtolower( $this->get_attribute( 'show_subject' ) ) ) {
1865
				$default_form .= '
1866
					[contact-field label="' . __( 'Subject', 'jetpack' ) . '" type="subject" /]';
1867
			}
1868
1869
			$default_form .= '
1870
				[contact-field label="' . __( 'Message', 'jetpack' ) . '" type="textarea" /]';
1871
1872
			$this->parse_content( $default_form );
1873
1874
			// Store the shortcode
1875
			$this->store_shortcode( $default_form, $attributes, $this->hash );
1876
		} else {
1877
			// Store the shortcode
1878
			$this->store_shortcode( $content, $attributes, $this->hash );
1879
		}
1880
1881
		// $this->body and $this->fields have been setup.  We no longer need the contact-field shortcode.
1882
		Grunion_Contact_Form_Plugin::$using_contact_form_field = false;
1883
	}
1884
1885
	/**
1886
	 * Store shortcode content for recall later
1887
	 *  - used to receate shortcode when user uses do_shortcode
1888
	 *
1889
	 * @param string $content
1890
	 * @param array $attributes
1891
	 * @param string $hash
1892
	 */
1893
	static function store_shortcode( $content = null, $attributes = null, $hash = null ) {
1894
1895
		if ( $content != null and isset( $attributes['id'] ) ) {
1896
1897
			if ( empty( $hash ) ) {
1898
				$hash = sha1( json_encode( $attributes ) . $content );
1899
			}
1900
1901
			$shortcode_meta = get_post_meta( $attributes['id'], "_g_feedback_shortcode_{$hash}", true );
1902
1903
			if ( $shortcode_meta != '' or $shortcode_meta != $content ) {
1904
				update_post_meta( $attributes['id'], "_g_feedback_shortcode_{$hash}", $content );
1905
1906
				// Save attributes to post_meta for later use. They're not available later in do_shortcode situations.
1907
				update_post_meta( $attributes['id'], "_g_feedback_shortcode_atts_{$hash}", $attributes );
1908
			}
1909
		}
1910
	}
1911
1912
	/**
1913
	 * Toggle for printing the grunion.css stylesheet
1914
	 *
1915
	 * @param bool $style
1916
	 */
1917
	static function style( $style ) {
1918
		$previous_style = self::$style;
1919
		self::$style    = (bool) $style;
1920
		return $previous_style;
1921
	}
1922
1923
	/**
1924
	 * Turn on printing of grunion.css stylesheet
1925
	 *
1926
	 * @see ::style()
1927
	 * @internal
1928
	 * @param bool $style
1929
	 */
1930
	static function _style_on() {
1931
		return self::style( true );
1932
	}
1933
1934
	/**
1935
	 * The contact-form shortcode processor
1936
	 *
1937
	 * @param array       $attributes Key => Value pairs as parsed by shortcode_parse_atts()
1938
	 * @param string|null $content The shortcode's inner content: [contact-form]$content[/contact-form]
1939
	 * @return string HTML for the concat form.
1940
	 */
1941
	static function parse( $attributes, $content ) {
1942
		if ( Settings::is_syncing() ) {
1943
			return '';
1944
		}
1945
		// Create a new Grunion_Contact_Form object (this class)
1946
		$form = new Grunion_Contact_Form( $attributes, $content );
1947
1948
		$id = $form->get_attribute( 'id' );
1949
1950
		if ( ! $id ) { // something terrible has happened
1951
			return '[contact-form]';
1952
		}
1953
1954
		if ( is_feed() ) {
1955
			return '[contact-form]';
1956
		}
1957
1958
		self::$last = $form;
1959
1960
		// Enqueue the grunion.css stylesheet if self::$style allows it
1961
		if ( self::$style && ( empty( $_REQUEST['action'] ) || $_REQUEST['action'] != 'grunion_shortcode_to_json' ) ) {
1962
			// Enqueue the style here instead of printing it, because if some other plugin has run the_post()+rewind_posts(),
1963
			// (like VideoPress does), the style tag gets "printed" the first time and discarded, leaving the contact form unstyled.
1964
			// when WordPress does the real loop.
1965
			wp_enqueue_style( 'grunion.css' );
1966
		}
1967
1968
		$r  = '';
1969
		$r .= "<div id='contact-form-$id'>\n";
1970
1971
		if ( is_wp_error( $form->errors ) && $form->errors->get_error_codes() ) {
0 ignored issues
show
The method get_error_codes() does not seem to exist on object<WP_Error>.

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

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

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

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

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

Loading history...
1975
				$r .= "\t<li class='form-error-message'>" . esc_html( $message ) . "</li>\n";
1976
			}
1977
			$r .= "</ul>\n</div>\n\n";
1978
		}
1979
1980
		if ( isset( $_GET['contact-form-id'] )
1981
		     && $_GET['contact-form-id'] == self::$last->get_attribute( 'id' )
1982
		     && isset( $_GET['contact-form-sent'], $_GET['contact-form-hash'] )
1983
		     && hash_equals( $form->hash, $_GET['contact-form-hash'] ) ) {
1984
			// The contact form was submitted.  Show the success message/results
1985
			$feedback_id = (int) $_GET['contact-form-sent'];
1986
1987
			$back_url = remove_query_arg( array( 'contact-form-id', 'contact-form-sent', '_wpnonce' ) );
1988
1989
			$r_success_message =
1990
				'<h3>' . __( 'Message Sent', 'jetpack' ) .
1991
				' (<a href="' . esc_url( $back_url ) . '">' . esc_html__( 'go back', 'jetpack' ) . '</a>)' .
1992
				"</h3>\n\n";
1993
1994
			// Don't show the feedback details unless the nonce matches
1995
			if ( $feedback_id && wp_verify_nonce( stripslashes( $_GET['_wpnonce'] ), "contact-form-sent-{$feedback_id}" ) ) {
1996
				$r_success_message .= self::success_message( $feedback_id, $form );
1997
			}
1998
1999
			/**
2000
			 * Filter the message returned after a successful contact form submission.
2001
			 *
2002
			 * @module contact-form
2003
			 *
2004
			 * @since 1.3.1
2005
			 *
2006
			 * @param string $r_success_message Success message.
2007
			 */
2008
			$r .= apply_filters( 'grunion_contact_form_success_message', $r_success_message );
2009
		} else {
2010
			// Nothing special - show the normal contact form
2011
			if ( $form->get_attribute( 'widget' ) ) {
2012
				// Submit form to the current URL
2013
				$url = remove_query_arg( array( 'contact-form-id', 'contact-form-sent', 'action', '_wpnonce' ) );
2014
			} else {
2015
				// Submit form to the post permalink
2016
				$url = get_permalink();
2017
			}
2018
2019
			// For SSL/TLS page. See RFC 3986 Section 4.2
2020
			$url = set_url_scheme( $url );
2021
2022
			// May eventually want to send this to admin-post.php...
2023
			/**
2024
			 * Filter the contact form action URL.
2025
			 *
2026
			 * @module contact-form
2027
			 *
2028
			 * @since 1.3.1
2029
			 *
2030
			 * @param string $contact_form_id Contact form post URL.
2031
			 * @param $post $GLOBALS['post'] Post global variable.
2032
			 * @param int $id Contact Form ID.
2033
			 */
2034
			$url = apply_filters( 'grunion_contact_form_form_action', "{$url}#contact-form-{$id}", $GLOBALS['post'], $id );
2035
2036
			$r .= "<form action='" . esc_url( $url ) . "' method='post' class='contact-form commentsblock'>\n";
2037
			$r .= $form->body;
2038
			$r .= "\t<p class='contact-submit'>\n";
2039
2040
			$gutenberg_submit_button_classes = '';
2041
			if ( ! empty( $attributes['submitButtonClasses'] ) ) {
2042
				$gutenberg_submit_button_classes = ' ' . $attributes['submitButtonClasses'];
2043
			}
2044
2045
			/**
2046
			 * Filter the contact form submit button class attribute.
2047
			 *
2048
			 * @module contact-form
2049
			 *
2050
			 * @since 6.6.0
2051
			 *
2052
			 * @param string $class Additional CSS classes for button attribute.
2053
			 */
2054
			$submit_button_class = apply_filters( 'jetpack_contact_form_submit_button_class', 'pushbutton-wide' . $gutenberg_submit_button_classes );
2055
2056
			$submit_button_styles = '';
2057
			if ( ! empty( $attributes['customBackgroundButtonColor'] ) ) {
2058
				$submit_button_styles .= 'background-color: ' . $attributes['customBackgroundButtonColor'] . '; ';
2059
			}
2060
			if ( ! empty( $attributes['customTextButtonColor'] ) ) {
2061
				$submit_button_styles .= 'color: ' . $attributes['customTextButtonColor'] . ';';
2062
			}
2063
			if ( ! empty( $attributes['submitButtonText'] ) ) {
2064
				$submit_button_text = $attributes['submitButtonText'];
2065
			} else {
2066
				$submit_button_text = $form->get_attribute( 'submit_button_text' );
2067
			}
2068
2069
			$r .= "\t\t<button type='submit' class='" . esc_attr( $submit_button_class ) . "'";
2070
			if ( ! empty( $submit_button_styles ) ) {
2071
				$r .= " style='" . esc_attr( $submit_button_styles ) . "'";
2072
			}
2073
			$r .= ">";
2074
			$r .= wp_kses(
2075
				      $submit_button_text,
2076
				      self::$allowed_html_tags_for_submit_button
2077
			      ) . "</button>";
2078
2079
			if ( is_user_logged_in() ) {
2080
				$r .= "\t\t" . wp_nonce_field( 'contact-form_' . $id, '_wpnonce', true, false ) . "\n"; // nonce and referer
2081
			}
2082
			$r .= "\t\t<input type='hidden' name='contact-form-id' value='$id' />\n";
2083
			$r .= "\t\t<input type='hidden' name='action' value='grunion-contact-form' />\n";
2084
			$r .= "\t\t<input type='hidden' name='contact-form-hash' value='" . esc_attr( $form->hash ) . "' />\n";
2085
			$r .= "\t</p>\n";
2086
			$r .= "</form>\n";
2087
		}
2088
2089
		$r .= '</div>';
2090
2091
		return $r;
2092
	}
2093
2094
	/**
2095
	 * Returns a success message to be returned if the form is sent via AJAX.
2096
	 *
2097
	 * @param int                         $feedback_id
2098
	 * @param object Grunion_Contact_Form $form
2099
	 *
2100
	 * @return string $message
2101
	 */
2102
	static function success_message( $feedback_id, $form ) {
2103
		return wp_kses(
2104
			'<blockquote class="contact-form-submission">'
2105
			. '<p>' . join( self::get_compiled_form( $feedback_id, $form ), '</p><p>' ) . '</p>'
2106
			. '</blockquote>',
2107
			array(
2108
				'br'         => array(),
2109
				'blockquote' => array( 'class' => array() ),
2110
				'p'          => array(),
2111
			)
2112
		);
2113
	}
2114
2115
	/**
2116
	 * Returns a compiled form with labels and values in a form of  an array
2117
	 * of lines.
2118
	 *
2119
	 * @param int                         $feedback_id
2120
	 * @param object Grunion_Contact_Form $form
2121
	 *
2122
	 * @return array $lines
2123
	 */
2124
	static function get_compiled_form( $feedback_id, $form ) {
2125
		$feedback       = get_post( $feedback_id );
2126
		$field_ids      = $form->get_field_ids();
2127
		$content_fields = Grunion_Contact_Form_Plugin::parse_fields_from_content( $feedback_id );
2128
2129
		// Maps field_ids to post_meta keys
2130
		$field_value_map = array(
2131
			'name'     => 'author',
2132
			'email'    => 'author_email',
2133
			'url'      => 'author_url',
2134
			'subject'  => 'subject',
2135
			'textarea' => false, // not a post_meta key.  This is stored in post_content
2136
		);
2137
2138
		$compiled_form = array();
2139
2140
		// "Standard" field whitelist
2141
		foreach ( $field_value_map as $type => $meta_key ) {
2142
			if ( isset( $field_ids[ $type ] ) ) {
2143
				$field = $form->fields[ $field_ids[ $type ] ];
2144
2145
				if ( $meta_key ) {
2146
					if ( isset( $content_fields[ "_feedback_{$meta_key}" ] ) ) {
2147
						$value = $content_fields[ "_feedback_{$meta_key}" ];
2148
					}
2149
				} else {
2150
					// The feedback content is stored as the first "half" of post_content
2151
					$value         = $feedback->post_content;
2152
					list( $value ) = explode( '<!--more-->', $value );
2153
					$value         = trim( $value );
2154
				}
2155
2156
				$field_index                   = array_search( $field_ids[ $type ], $field_ids['all'] );
2157
				$compiled_form[ $field_index ] = sprintf(
2158
					'<b>%1$s:</b> %2$s<br /><br />',
2159
					wp_kses( $field->get_attribute( 'label' ), array() ),
2160
					self::escape_and_sanitize_field_value( $value )
2161
				);
2162
			}
2163
		}
2164
2165
		// "Non-standard" fields
2166
		if ( $field_ids['extra'] ) {
2167
			// array indexed by field label (not field id)
2168
			$extra_fields = get_post_meta( $feedback_id, '_feedback_extra_fields', true );
2169
2170
			/**
2171
			 * Only get data for the compiled form if `$extra_fields` is a valid and non-empty array.
2172
			 */
2173
			if ( is_array( $extra_fields ) && ! empty( $extra_fields ) ) {
2174
2175
				$extra_field_keys = array_keys( $extra_fields );
2176
2177
				$i = 0;
2178
				foreach ( $field_ids['extra'] as $field_id ) {
2179
					$field       = $form->fields[ $field_id ];
2180
					$field_index = array_search( $field_id, $field_ids['all'] );
2181
2182
					$label = $field->get_attribute( 'label' );
2183
2184
					$compiled_form[ $field_index ] = sprintf(
2185
						'<b>%1$s:</b> %2$s<br /><br />',
2186
						wp_kses( $label, array() ),
2187
						self::escape_and_sanitize_field_value( $extra_fields[ $extra_field_keys[ $i ] ] )
2188
					);
2189
2190
					$i++;
2191
				}
2192
			}
2193
		}
2194
2195
		// Sorting lines by the field index
2196
		ksort( $compiled_form );
2197
2198
		return $compiled_form;
2199
	}
2200
2201
	static function escape_and_sanitize_field_value( $value ) {
2202
        $value = str_replace( array( '[' , ']' ) ,  array( '&#91;' , '&#93;' ) , $value );
2203
        return nl2br( wp_kses( $value, array() ) );
2204
    }
2205
2206
	/**
2207
	 * Only strip out empty string values and keep all the other values as they are.
2208
     *
2209
	 * @param $single_value
2210
	 *
2211
	 * @return bool
2212
	 */
2213
	static function remove_empty( $single_value ) {
2214
		return ( $single_value !== '' );
2215
	}
2216
2217
	/**
2218
	 * The contact-field shortcode processor
2219
	 * 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.
2220
	 *
2221
	 * @param array       $attributes Key => Value pairs as parsed by shortcode_parse_atts()
2222
	 * @param string|null $content The shortcode's inner content: [contact-field]$content[/contact-field]
2223
	 * @return HTML for the contact form field
2224
	 */
2225
	static function parse_contact_field( $attributes, $content ) {
2226
		// Don't try to parse contact form fields if not inside a contact form
2227
		if ( ! Grunion_Contact_Form_Plugin::$using_contact_form_field ) {
2228
			$att_strs = array();
2229
			if ( ! isset( $attributes['label'] )  ) {
2230
				$type = isset( $attributes['type'] ) ? $attributes['type'] : null;
2231
				$attributes['label'] = self::get_default_label_from_type( $type );
2232
			}
2233
			foreach ( $attributes as $att => $val ) {
2234
				if ( is_numeric( $att ) ) { // Is a valueless attribute
2235
					$att_strs[] = esc_html( $val );
2236
				} elseif ( isset( $val ) ) { // A regular attr - value pair
2237
					if ( ( $att === 'options' || $att === 'values' ) && is_string( $val ) ) { // remove any empty strings
2238
						$val = explode( ',', $val );
2239
					}
2240
 					if ( is_array( $val ) ) {
2241
						$val =  array_filter( $val, array( __CLASS__, 'remove_empty' ) ); // removes any empty strings
2242
						$att_strs[] = esc_html( $att ) . '="' . implode( ',', array_map( 'esc_html', $val ) ) . '"';
2243
					} elseif ( is_bool( $val ) ) {
2244
						$att_strs[] = esc_html( $att ) . '="' . esc_html( $val ? '1' : '' ) . '"';
2245
					} else {
2246
						$att_strs[] = esc_html( $att ) . '="' . esc_html( $val ) . '"';
2247
					}
2248
				}
2249
			}
2250
2251
			$html = '[contact-field ' . implode( ' ', $att_strs );
2252
2253
			if ( isset( $content ) && ! empty( $content ) ) { // If there is content, let's add a closing tag
2254
				$html .= ']' . esc_html( $content ) . '[/contact-field]';
2255
			} else { // Otherwise let's add a closing slash in the first tag
2256
				$html .= '/]';
2257
			}
2258
2259
			return $html;
2260
		}
2261
2262
		$form = Grunion_Contact_Form::$current_form;
2263
2264
		$field = new Grunion_Contact_Form_Field( $attributes, $content, $form );
2265
2266
		$field_id = $field->get_attribute( 'id' );
2267
		if ( $field_id ) {
2268
			$form->fields[ $field_id ] = $field;
2269
		} else {
2270
			$form->fields[] = $field;
2271
		}
2272
2273
		if (
2274
			isset( $_POST['action'] ) && 'grunion-contact-form' === $_POST['action']
2275
			&&
2276
			isset( $_POST['contact-form-id'] ) && $form->get_attribute( 'id' ) == $_POST['contact-form-id']
2277
			&&
2278
			isset( $_POST['contact-form-hash'] ) && hash_equals( $form->hash, $_POST['contact-form-hash'] )
2279
		) {
2280
			// If we're processing a POST submission for this contact form, validate the field value so we can show errors as necessary.
2281
			$field->validate();
2282
		}
2283
2284
		// Output HTML
2285
		return $field->render();
2286
	}
2287
2288
	static function get_default_label_from_type( $type ) {
2289
		switch ( $type ) {
2290
			case 'text':
2291
				return __( 'Text', 'jetpack' );
2292
			case 'name':
2293
				return __( 'Name', 'jetpack' );
2294
			case 'email':
2295
				return __( 'Email', 'jetpack' );
2296
			case 'url':
2297
				return __( 'Website', 'jetpack' );
2298
			case 'date':
2299
				return __( 'Date', 'jetpack' );
2300
			case 'telephone':
2301
				return __( 'Phone', 'jetpack' );
2302
			case 'textarea':
2303
				return __( 'Message', 'jetpack' );
2304
			case 'checkbox':
2305
				return __( 'Checkbox', 'jetpack' );
2306
			case 'checkbox-multiple':
2307
				return __( 'Choose several', 'jetpack' );
2308
			case 'radio':
2309
				return __( 'Choose one', 'jetpack' );
2310
			case 'select':
2311
				return __( 'Select one', 'jetpack' );
2312
			default:
2313
				return null;
2314
		}
2315
	}
2316
2317
	/**
2318
	 * Loops through $this->fields to generate a (structured) list of field IDs.
2319
	 *
2320
	 * Important: Currently the whitelisted fields are defined as follows:
2321
	 *  `name`, `email`, `url`, `subject`, `textarea`
2322
	 *
2323
	 * If you need to add new fields to the Contact Form, please don't add them
2324
	 * to the whitelisted fields and leave them as extra fields.
2325
	 *
2326
	 * The reasoning behind this is that both the admin Feedback view and the CSV
2327
	 * export will not include any fields that are added to the list of
2328
	 * whitelisted fields without taking proper care to add them to all the
2329
	 * other places where they accessed/used/saved.
2330
	 *
2331
	 * The safest way to add new fields is to add them to the dropdown and the
2332
	 * HTML list ( @see Grunion_Contact_Form_Field::render ) and don't add them
2333
	 * to the list of whitelisted fields. This way they will become a part of the
2334
	 * `extra fields` which are saved in the post meta and will be properly
2335
	 * handled by the admin Feedback view and the CSV Export without any extra
2336
	 * work.
2337
	 *
2338
	 * If there is need to add a field to the whitelisted fields, then please
2339
	 * take proper care to add logic to handle the field in the following places:
2340
	 *
2341
	 *  - Below in the switch statement - so the field is recognized as whitelisted.
2342
	 *
2343
	 *  - Grunion_Contact_Form::process_submission - validation and logic.
2344
	 *
2345
	 *  - Grunion_Contact_Form::process_submission - add the field as an additional
2346
	 *      field in the `post_content` when saving the feedback content.
2347
	 *
2348
	 *  - Grunion_Contact_Form_Plugin::parse_fields_from_content - add mapping
2349
	 *      for the field, defined in the above method.
2350
	 *
2351
	 *  - Grunion_Contact_Form_Plugin::map_parsed_field_contents_of_post_to_field_names -
2352
	 *      add mapping of the field for the CSV Export. Otherwise it will be missing
2353
	 *      from the exported data.
2354
	 *
2355
	 *  - admin.php / grunion_manage_post_columns - add the field to the render logic.
2356
	 *      Otherwise it will be missing from the admin Feedback view.
2357
	 *
2358
	 * @return array
2359
	 */
2360
	function get_field_ids() {
2361
		$field_ids = array(
2362
			'all'   => array(), // array of all field_ids
2363
			'extra' => array(), // array of all non-whitelisted field IDs
2364
2365
			// Whitelisted "standard" field IDs:
2366
			// 'email'    => field_id,
2367
			// 'name'     => field_id,
2368
			// 'url'      => field_id,
2369
			// 'subject'  => field_id,
2370
			// 'textarea' => field_id,
2371
		);
2372
2373
		foreach ( $this->fields as $id => $field ) {
2374
			$field_ids['all'][] = $id;
2375
2376
			$type = $field->get_attribute( 'type' );
2377
			if ( isset( $field_ids[ $type ] ) ) {
2378
				// This type of field is already present in our whitelist of "standard" fields for this form
2379
				// Put it in extra
2380
				$field_ids['extra'][] = $id;
2381
				continue;
2382
			}
2383
2384
			/**
2385
			 * See method description before modifying the switch cases.
2386
			 */
2387
			switch ( $type ) {
2388
				case 'email':
2389
				case 'name':
2390
				case 'url':
2391
				case 'subject':
2392
				case 'textarea':
2393
					$field_ids[ $type ] = $id;
2394
					break;
2395
				default:
2396
					// Put everything else in extra
2397
					$field_ids['extra'][] = $id;
2398
			}
2399
		}
2400
2401
		return $field_ids;
2402
	}
2403
2404
	/**
2405
	 * Process the contact form's POST submission
2406
	 * Stores feedback.  Sends email.
2407
	 */
2408
	function process_submission() {
2409
		global $post;
2410
2411
		$plugin = Grunion_Contact_Form_Plugin::init();
2412
2413
		$id     = $this->get_attribute( 'id' );
2414
		$to     = $this->get_attribute( 'to' );
2415
		$widget = $this->get_attribute( 'widget' );
2416
2417
		$contact_form_subject = $this->get_attribute( 'subject' );
2418
2419
		$to     = str_replace( ' ', '', $to );
2420
		$emails = explode( ',', $to );
2421
2422
		$valid_emails = array();
2423
2424
		foreach ( (array) $emails as $email ) {
2425
			if ( ! is_email( $email ) ) {
2426
				continue;
2427
			}
2428
2429
			if ( function_exists( 'is_email_address_unsafe' ) && is_email_address_unsafe( $email ) ) {
2430
				continue;
2431
			}
2432
2433
			$valid_emails[] = $email;
2434
		}
2435
2436
		// No one to send it to, which means none of the "to" attributes are valid emails.
2437
		// Use default email instead.
2438
		if ( ! $valid_emails ) {
2439
			$valid_emails = $this->defaults['to'];
2440
		}
2441
2442
		$to = $valid_emails;
2443
2444
		// Last ditch effort to set a recipient if somehow none have been set.
2445
		if ( empty( $to ) ) {
2446
			$to = get_option( 'admin_email' );
2447
		}
2448
2449
		// Make sure we're processing the form we think we're processing... probably a redundant check.
2450
		if ( $widget ) {
2451
			if ( 'widget-' . $widget != $_POST['contact-form-id'] ) {
2452
				return false;
2453
			}
2454
		} else {
2455
			if ( $post->ID != $_POST['contact-form-id'] ) {
2456
				return false;
2457
			}
2458
		}
2459
2460
		$field_ids = $this->get_field_ids();
2461
2462
		// Initialize all these "standard" fields to null
2463
		$comment_author_email = $comment_author_email_label = // v
2464
		$comment_author       = $comment_author_label       = // v
2465
		$comment_author_url   = $comment_author_url_label   = // v
2466
		$comment_content      = $comment_content_label = null;
2467
2468
		// For each of the "standard" fields, grab their field label and value.
2469 View Code Duplication
		if ( isset( $field_ids['name'] ) ) {
2470
			$field          = $this->fields[ $field_ids['name'] ];
2471
			$comment_author = Grunion_Contact_Form_Plugin::strip_tags(
2472
				stripslashes(
2473
					/** This filter is already documented in core/wp-includes/comment-functions.php */
2474
					apply_filters( 'pre_comment_author_name', addslashes( $field->value ) )
2475
				)
2476
			);
2477
			$comment_author_label = Grunion_Contact_Form_Plugin::strip_tags( $field->get_attribute( 'label' ) );
2478
		}
2479
2480 View Code Duplication
		if ( isset( $field_ids['email'] ) ) {
2481
			$field                = $this->fields[ $field_ids['email'] ];
2482
			$comment_author_email = Grunion_Contact_Form_Plugin::strip_tags(
2483
				stripslashes(
2484
					/** This filter is already documented in core/wp-includes/comment-functions.php */
2485
					apply_filters( 'pre_comment_author_email', addslashes( $field->value ) )
2486
				)
2487
			);
2488
			$comment_author_email_label = Grunion_Contact_Form_Plugin::strip_tags( $field->get_attribute( 'label' ) );
2489
		}
2490
2491
		if ( isset( $field_ids['url'] ) ) {
2492
			$field              = $this->fields[ $field_ids['url'] ];
2493
			$comment_author_url = Grunion_Contact_Form_Plugin::strip_tags(
2494
				stripslashes(
2495
					/** This filter is already documented in core/wp-includes/comment-functions.php */
2496
					apply_filters( 'pre_comment_author_url', addslashes( $field->value ) )
2497
				)
2498
			);
2499
			if ( 'http://' == $comment_author_url ) {
2500
				$comment_author_url = '';
2501
			}
2502
			$comment_author_url_label = Grunion_Contact_Form_Plugin::strip_tags( $field->get_attribute( 'label' ) );
2503
		}
2504
2505
		if ( isset( $field_ids['textarea'] ) ) {
2506
			$field                 = $this->fields[ $field_ids['textarea'] ];
2507
			$comment_content       = trim( Grunion_Contact_Form_Plugin::strip_tags( $field->value ) );
2508
			$comment_content_label = Grunion_Contact_Form_Plugin::strip_tags( $field->get_attribute( 'label' ) );
2509
		}
2510
2511
		if ( isset( $field_ids['subject'] ) ) {
2512
			$field = $this->fields[ $field_ids['subject'] ];
2513
			if ( $field->value ) {
2514
				$contact_form_subject = Grunion_Contact_Form_Plugin::strip_tags( $field->value );
2515
			}
2516
		}
2517
2518
		$all_values = $extra_values = array();
2519
		$i          = 1; // Prefix counter for stored metadata
2520
2521
		// For all fields, grab label and value
2522
		foreach ( $field_ids['all'] as $field_id ) {
2523
			$field = $this->fields[ $field_id ];
2524
			$label = $i . '_' . $field->get_attribute( 'label' );
2525
			$value = $field->value;
2526
2527
			$all_values[ $label ] = $value;
2528
			$i++; // Increment prefix counter for the next field
2529
		}
2530
2531
		// For the "non-standard" fields, grab label and value
2532
		// Extra fields have their prefix starting from count( $all_values ) + 1
2533
		foreach ( $field_ids['extra'] as $field_id ) {
2534
			$field = $this->fields[ $field_id ];
2535
			$label = $i . '_' . $field->get_attribute( 'label' );
2536
			$value = $field->value;
2537
2538
			if ( is_array( $value ) ) {
2539
				$value = implode( ', ', $value );
2540
			}
2541
2542
			$extra_values[ $label ] = $value;
2543
			$i++; // Increment prefix counter for the next extra field
2544
		}
2545
2546
		$contact_form_subject = trim( $contact_form_subject );
2547
2548
		$comment_author_IP = Grunion_Contact_Form_Plugin::get_ip_address();
2549
2550
		$vars = array( 'comment_author', 'comment_author_email', 'comment_author_url', 'contact_form_subject', 'comment_author_IP' );
2551
		foreach ( $vars as $var ) {
2552
			$$var = str_replace( array( "\n", "\r" ), '', $$var );
2553
		}
2554
2555
		// Ensure that Akismet gets all of the relevant information from the contact form,
2556
		// not just the textarea field and predetermined subject.
2557
		$akismet_vars                    = compact( $vars );
2558
		$akismet_vars['comment_content'] = $comment_content;
2559
2560
		foreach ( array_merge( $field_ids['all'], $field_ids['extra'] ) as $field_id ) {
2561
			$field = $this->fields[ $field_id ];
2562
2563
			// Skip any fields that are just a choice from a pre-defined list. They wouldn't have any value
2564
			// from a spam-filtering point of view.
2565
			if ( in_array( $field->get_attribute( 'type' ), array( 'select', 'checkbox', 'checkbox-multiple', 'radio' ) ) ) {
2566
				continue;
2567
			}
2568
2569
			// Normalize the label into a slug.
2570
			$field_slug = trim( // Strip all leading/trailing dashes.
2571
				preg_replace(   // Normalize everything to a-z0-9_-
2572
					'/[^a-z0-9_]+/',
2573
					'-',
2574
					strtolower( $field->get_attribute( 'label' ) ) // Lowercase
2575
				),
2576
				'-'
2577
			);
2578
2579
			$field_value = ( is_array( $field->value ) ) ? trim( implode( ', ', $field->value ) ) : trim( $field->value );
2580
2581
			// Skip any values that are already in the array we're sending.
2582
			if ( $field_value && in_array( $field_value, $akismet_vars ) ) {
2583
				continue;
2584
			}
2585
2586
			$akismet_vars[ 'contact_form_field_' . $field_slug ] = $field_value;
2587
		}
2588
2589
		$spam           = '';
2590
		$akismet_values = $plugin->prepare_for_akismet( $akismet_vars );
2591
2592
		// Is it spam?
2593
		/** This filter is already documented in modules/contact-form/admin.php */
2594
		$is_spam = apply_filters( 'jetpack_contact_form_is_spam', false, $akismet_values );
2595
		if ( is_wp_error( $is_spam ) ) { // WP_Error to abort
2596
			return $is_spam; // abort
2597
		} elseif ( $is_spam === true ) {  // TRUE to flag a spam
2598
			$spam = '***SPAM*** ';
2599
		}
2600
2601
		if ( ! $comment_author ) {
2602
			$comment_author = $comment_author_email;
2603
		}
2604
2605
		/**
2606
		 * Filter the email where a submitted feedback is sent.
2607
		 *
2608
		 * @module contact-form
2609
		 *
2610
		 * @since 1.3.1
2611
		 *
2612
		 * @param string|array $to Array of valid email addresses, or single email address.
2613
		 */
2614
		$to            = (array) apply_filters( 'contact_form_to', $to );
2615
		$reply_to_addr = $to[0]; // get just the address part before the name part is added
2616
2617
		foreach ( $to as $to_key => $to_value ) {
2618
			$to[ $to_key ] = Grunion_Contact_Form_Plugin::strip_tags( $to_value );
2619
			$to[ $to_key ] = self::add_name_to_address( $to_value );
2620
		}
2621
2622
		$blog_url        = parse_url( site_url() );
2623
		$from_email_addr = 'wordpress@' . $blog_url['host'];
2624
2625
		if ( ! empty( $comment_author_email ) ) {
2626
			$reply_to_addr = $comment_author_email;
2627
		}
2628
2629
		$headers = 'From: "' . $comment_author . '" <' . $from_email_addr . ">\r\n" .
2630
		           'Reply-To: "' . $comment_author . '" <' . $reply_to_addr . ">\r\n";
2631
2632
		// Build feedback reference
2633
		$feedback_time  = current_time( 'mysql' );
2634
		$feedback_title = "{$comment_author} - {$feedback_time}";
2635
		$feedback_id    = md5( $feedback_title );
2636
2637
		$all_values = array_merge(
2638
			$all_values, array(
2639
				'entry_title'     => the_title_attribute( 'echo=0' ),
2640
				'entry_permalink' => esc_url( get_permalink( get_the_ID() ) ),
2641
				'feedback_id'     => $feedback_id,
2642
			)
2643
		);
2644
2645
		/** This filter is already documented in modules/contact-form/admin.php */
2646
		$subject = apply_filters( 'contact_form_subject', $contact_form_subject, $all_values );
2647
		$url     = $widget ? home_url( '/' ) : get_permalink( $post->ID );
2648
2649
		$date_time_format = _x( '%1$s \a\t %2$s', '{$date_format} \a\t {$time_format}', 'jetpack' );
2650
		$date_time_format = sprintf( $date_time_format, get_option( 'date_format' ), get_option( 'time_format' ) );
2651
		$time             = date_i18n( $date_time_format, current_time( 'timestamp' ) );
2652
2653
		// keep a copy of the feedback as a custom post type
2654
		$feedback_status = $is_spam === true ? 'spam' : 'publish';
2655
2656
		foreach ( (array) $akismet_values as $av_key => $av_value ) {
2657
			$akismet_values[ $av_key ] = Grunion_Contact_Form_Plugin::strip_tags( $av_value );
2658
		}
2659
2660
		foreach ( (array) $all_values as $all_key => $all_value ) {
2661
			$all_values[ $all_key ] = Grunion_Contact_Form_Plugin::strip_tags( $all_value );
2662
		}
2663
2664
		foreach ( (array) $extra_values as $ev_key => $ev_value ) {
2665
			$extra_values[ $ev_key ] = Grunion_Contact_Form_Plugin::strip_tags( $ev_value );
2666
		}
2667
2668
		/*
2669
		 We need to make sure that the post author is always zero for contact
2670
		 * form submissions.  This prevents export/import from trying to create
2671
		 * new users based on form submissions from people who were logged in
2672
		 * at the time.
2673
		 *
2674
		 * Unfortunately wp_insert_post() tries very hard to make sure the post
2675
		 * author gets the currently logged in user id.  That is how we ended up
2676
		 * with this work around. */
2677
		add_filter( 'wp_insert_post_data', array( $plugin, 'insert_feedback_filter' ), 10, 2 );
2678
2679
		$post_id = wp_insert_post(
2680
			array(
2681
				'post_date'    => addslashes( $feedback_time ),
2682
				'post_type'    => 'feedback',
2683
				'post_status'  => addslashes( $feedback_status ),
2684
				'post_parent'  => (int) $post->ID,
2685
				'post_title'   => addslashes( wp_kses( $feedback_title, array() ) ),
2686
				'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
2687
				'post_name'    => $feedback_id,
2688
			)
2689
		);
2690
2691
		// once insert has finished we don't need this filter any more
2692
		remove_filter( 'wp_insert_post_data', array( $plugin, 'insert_feedback_filter' ), 10 );
2693
2694
		update_post_meta( $post_id, '_feedback_extra_fields', $this->addslashes_deep( $extra_values ) );
2695
2696
		if ( 'publish' == $feedback_status ) {
2697
			// Increase count of unread feedback.
2698
			$unread = get_option( 'feedback_unread_count', 0 ) + 1;
2699
			update_option( 'feedback_unread_count', $unread );
2700
		}
2701
2702
		if ( defined( 'AKISMET_VERSION' ) ) {
2703
			update_post_meta( $post_id, '_feedback_akismet_values', $this->addslashes_deep( $akismet_values ) );
2704
		}
2705
2706
		$message = self::get_compiled_form( $post_id, $this );
2707
2708
		array_push(
2709
			$message,
2710
			'<br />',
2711
			'<hr />',
2712
			__( 'Time:', 'jetpack' ) . ' ' . $time . '<br />',
2713
			__( 'IP Address:', 'jetpack' ) . ' ' . $comment_author_IP . '<br />',
2714
			__( 'Contact Form URL:', 'jetpack' ) . ' ' . $url . '<br />'
2715
		);
2716
2717
		if ( is_user_logged_in() ) {
2718
			array_push(
2719
				$message,
2720
				sprintf(
2721
					'<p>' . __( 'Sent by a verified %s user.', 'jetpack' ) . '</p>',
2722
					isset( $GLOBALS['current_site']->site_name ) && $GLOBALS['current_site']->site_name ?
2723
						$GLOBALS['current_site']->site_name : '"' . get_option( 'blogname' ) . '"'
2724
				)
2725
			);
2726
		} else {
2727
			array_push( $message, '<p>' . __( 'Sent by an unverified visitor to your site.', 'jetpack' ) . '</p>' );
2728
		}
2729
2730
		$message = join( $message, '' );
2731
2732
		/**
2733
		 * Filters the message sent via email after a successful form submission.
2734
		 *
2735
		 * @module contact-form
2736
		 *
2737
		 * @since 1.3.1
2738
		 *
2739
		 * @param string $message Feedback email message.
2740
		 */
2741
		$message = apply_filters( 'contact_form_message', $message );
2742
2743
		// This is called after `contact_form_message`, in order to preserve back-compat
2744
		$message = self::wrap_message_in_html_tags( $message );
2745
2746
		update_post_meta( $post_id, '_feedback_email', $this->addslashes_deep( compact( 'to', 'message' ) ) );
2747
2748
		/**
2749
		 * Fires right before the contact form message is sent via email to
2750
		 * the recipient specified in the contact form.
2751
		 *
2752
		 * @module contact-form
2753
		 *
2754
		 * @since 1.3.1
2755
		 *
2756
		 * @param integer $post_id Post contact form lives on
2757
		 * @param array $all_values Contact form fields
2758
		 * @param array $extra_values Contact form fields not included in $all_values
2759
		 */
2760
		do_action( 'grunion_pre_message_sent', $post_id, $all_values, $extra_values );
2761
2762
		// schedule deletes of old spam feedbacks
2763
		if ( ! wp_next_scheduled( 'grunion_scheduled_delete' ) ) {
2764
			wp_schedule_event( time() + 250, 'daily', 'grunion_scheduled_delete' );
2765
		}
2766
2767
		if (
2768
			$is_spam !== true &&
2769
			/**
2770
			 * Filter to choose whether an email should be sent after each successful contact form submission.
2771
			 *
2772
			 * @module contact-form
2773
			 *
2774
			 * @since 2.6.0
2775
			 *
2776
			 * @param bool true Should an email be sent after a form submission. Default to true.
2777
			 * @param int $post_id Post ID.
2778
			 */
2779
			true === apply_filters( 'grunion_should_send_email', true, $post_id )
2780
		) {
2781
			self::wp_mail( $to, "{$spam}{$subject}", $message, $headers );
2782
		} elseif (
2783
			true === $is_spam &&
2784
			/**
2785
			 * Choose whether an email should be sent for each spam contact form submission.
2786
			 *
2787
			 * @module contact-form
2788
			 *
2789
			 * @since 1.3.1
2790
			 *
2791
			 * @param bool false Should an email be sent after a spam form submission. Default to false.
2792
			 */
2793
			apply_filters( 'grunion_still_email_spam', false ) == true
2794
		) { // don't send spam by default.  Filterable.
2795
			self::wp_mail( $to, "{$spam}{$subject}", $message, $headers );
2796
		}
2797
2798
		/**
2799
		 * Fires an action hook right after the email(s) have been sent.
2800
		 *
2801
		 * @module contact-form
2802
		 *
2803
		 * @since 7.3.0
2804
		 *
2805
		 * @param int $post_id Post contact form lives on.
2806
		 * @param string|array $to Array of valid email addresses, or single email address.
2807
		 * @param string $subject Feedback email subject.
2808
		 * @param string $message Feedback email message.
2809
		 * @param string|array $headers Optional. Additional headers.
2810
		 * @param array $all_values Contact form fields.
2811
		 * @param array $extra_values Contact form fields not included in $all_values
2812
		 */
2813
		do_action( 'grunion_after_message_sent', $post_id, $to, $subject, $message, $headers, $all_values, $extra_values );
2814
2815
		if ( defined( 'DOING_AJAX' ) && DOING_AJAX ) {
2816
			return self::success_message( $post_id, $this );
2817
		}
2818
2819
		$redirect = wp_get_referer();
2820
		if ( ! $redirect ) { // wp_get_referer() returns false if the referer is the same as the current page
2821
			$redirect = $_SERVER['REQUEST_URI'];
2822
		}
2823
2824
		$redirect = add_query_arg(
2825
			urlencode_deep(
2826
				array(
2827
					'contact-form-id'   => $id,
2828
					'contact-form-sent' => $post_id,
2829
					'contact-form-hash' => $this->hash,
2830
					'_wpnonce'          => wp_create_nonce( "contact-form-sent-{$post_id}" ), // wp_nonce_url HTMLencodes :(
2831
				)
2832
			), $redirect
2833
		);
2834
2835
		/**
2836
		 * Filter the URL where the reader is redirected after submitting a form.
2837
		 *
2838
		 * @module contact-form
2839
		 *
2840
		 * @since 1.9.0
2841
		 *
2842
		 * @param string $redirect Post submission URL.
2843
		 * @param int $id Contact Form ID.
2844
		 * @param int $post_id Post ID.
2845
		 */
2846
		$redirect = apply_filters( 'grunion_contact_form_redirect_url', $redirect, $id, $post_id );
2847
2848
		wp_safe_redirect( $redirect );
2849
		exit;
2850
	}
2851
2852
	/**
2853
	 * Wrapper for wp_mail() that enables HTML messages with text alternatives
2854
	 *
2855
	 * @param string|array $to          Array or comma-separated list of email addresses to send message.
2856
	 * @param string       $subject     Email subject.
2857
	 * @param string       $message     Message contents.
2858
	 * @param string|array $headers     Optional. Additional headers.
2859
	 * @param string|array $attachments Optional. Files to attach.
2860
	 *
2861
	 * @return bool Whether the email contents were sent successfully.
2862
	 */
2863
	public static function wp_mail( $to, $subject, $message, $headers = '', $attachments = array() ) {
2864
		add_filter( 'wp_mail_content_type', __CLASS__ . '::get_mail_content_type' );
2865
		add_action( 'phpmailer_init', __CLASS__ . '::add_plain_text_alternative' );
2866
2867
		$result = wp_mail( $to, $subject, $message, $headers, $attachments );
2868
2869
		remove_filter( 'wp_mail_content_type', __CLASS__ . '::get_mail_content_type' );
2870
		remove_action( 'phpmailer_init', __CLASS__ . '::add_plain_text_alternative' );
2871
2872
		return $result;
2873
	}
2874
2875
	/**
2876
	 * Add a display name part to an email address
2877
	 *
2878
	 * SpamAssassin doesn't like addresses in HTML messages that are missing display names (e.g., `[email protected]`
2879
	 * instead of `"Foo Bar" <[email protected]>`.
2880
	 *
2881
	 * @param string $address
2882
	 *
2883
	 * @return string
2884
	 */
2885
	function add_name_to_address( $address ) {
2886
		// If it's just the address, without a display name
2887
		if ( is_email( $address ) ) {
2888
			$address_parts = explode( '@', $address );
2889
			$address       = sprintf( '"%s" <%s>', $address_parts[0], $address );
2890
		}
2891
2892
		return $address;
2893
	}
2894
2895
	/**
2896
	 * Get the content type that should be assigned to outbound emails
2897
	 *
2898
	 * @return string
2899
	 */
2900
	static function get_mail_content_type() {
2901
		return 'text/html';
2902
	}
2903
2904
	/**
2905
	 * Wrap a message body with the appropriate in HTML tags
2906
	 *
2907
	 * This helps to ensure correct parsing by clients, and also helps avoid triggering spam filtering rules
2908
	 *
2909
	 * @param string $body
2910
	 *
2911
	 * @return string
2912
	 */
2913
	static function wrap_message_in_html_tags( $body ) {
2914
		// Don't do anything if the message was already wrapped in HTML tags
2915
		// That could have be done by a plugin via filters
2916
		if ( false !== strpos( $body, '<html' ) ) {
2917
			return $body;
2918
		}
2919
2920
		$html_message = sprintf(
2921
			// The tabs are just here so that the raw code is correctly formatted for developers
2922
			// They're removed so that they don't affect the final message sent to users
2923
			str_replace(
2924
				"\t", '',
2925
				'<!doctype html>
2926
				<html xmlns="http://www.w3.org/1999/xhtml">
2927
				<body>
2928
2929
				%s
2930
2931
				</body>
2932
				</html>'
2933
			),
2934
			$body
2935
		);
2936
2937
		return $html_message;
2938
	}
2939
2940
	/**
2941
	 * Add a plain-text alternative part to an outbound email
2942
	 *
2943
	 * This makes the message more accessible to mail clients that aren't HTML-aware, and decreases the likelihood
2944
	 * that the message will be flagged as spam.
2945
	 *
2946
	 * @param PHPMailer $phpmailer
2947
	 */
2948
	static function add_plain_text_alternative( $phpmailer ) {
2949
		// Add an extra break so that the extra space above the <p> is preserved after the <p> is stripped out
2950
		$alt_body = str_replace( '<p>', '<p><br />', $phpmailer->Body );
2951
2952
		// Convert <br> to \n breaks, to preserve the space between lines that we want to keep
2953
		$alt_body = str_replace( array( '<br>', '<br />' ), "\n", $alt_body );
2954
2955
		// Convert <hr> to an plain-text equivalent, to preserve the integrity of the message
2956
		$alt_body = str_replace( array( '<hr>', '<hr />' ), "----\n", $alt_body );
2957
2958
		// Trim the plain text message to remove the \n breaks that were after <doctype>, <html>, and <body>
2959
		$phpmailer->AltBody = trim( strip_tags( $alt_body ) );
2960
	}
2961
2962
	function addslashes_deep( $value ) {
2963
		if ( is_array( $value ) ) {
2964
			return array_map( array( $this, 'addslashes_deep' ), $value );
2965
		} elseif ( is_object( $value ) ) {
2966
			$vars = get_object_vars( $value );
2967
			foreach ( $vars as $key => $data ) {
2968
				$value->{$key} = $this->addslashes_deep( $data );
2969
			}
2970
			return $value;
2971
		}
2972
2973
		return addslashes( $value );
2974
	}
2975
}
2976
2977
/**
2978
 * Class for the contact-field shortcode.
2979
 * Parses shortcode to output the contact form field as HTML.
2980
 * Validates input.
2981
 */
2982
class Grunion_Contact_Form_Field extends Crunion_Contact_Form_Shortcode {
2983
	public $shortcode_name = 'contact-field';
2984
2985
	/**
2986
	 * @var Grunion_Contact_Form parent form
2987
	 */
2988
	public $form;
2989
2990
	/**
2991
	 * @var string default or POSTed value
2992
	 */
2993
	public $value;
2994
2995
	/**
2996
	 * @var bool Is the input invalid?
2997
	 */
2998
	public $error = false;
2999
3000
	/**
3001
	 * @param array                $attributes An associative array of shortcode attributes.  @see shortcode_atts()
3002
	 * @param null|string          $content Null for selfclosing shortcodes.  The inner content otherwise.
3003
	 * @param Grunion_Contact_Form $form The parent form
3004
	 */
3005
	function __construct( $attributes, $content = null, $form = null ) {
3006
		$attributes = shortcode_atts(
3007
			array(
3008
				'label'       => null,
3009
				'type'        => 'text',
3010
				'required'    => false,
3011
				'options'     => array(),
3012
				'id'          => null,
3013
				'default'     => null,
3014
				'values'      => null,
3015
				'placeholder' => null,
3016
				'class'       => null,
3017
			), $attributes, 'contact-field'
3018
		);
3019
3020
		// special default for subject field
3021
		if ( 'subject' == $attributes['type'] && is_null( $attributes['default'] ) && ! is_null( $form ) ) {
3022
			$attributes['default'] = $form->get_attribute( 'subject' );
3023
		}
3024
3025
		// allow required=1 or required=true
3026
		if ( '1' == $attributes['required'] || 'true' == strtolower( $attributes['required'] ) ) {
3027
			$attributes['required'] = true;
3028
		} else {
3029
			$attributes['required'] = false;
3030
		}
3031
3032
		// parse out comma-separated options list (for selects, radios, and checkbox-multiples)
3033
		if ( ! empty( $attributes['options'] ) && is_string( $attributes['options'] ) ) {
3034
			$attributes['options'] = array_map( 'trim', explode( ',', $attributes['options'] ) );
3035
3036 View Code Duplication
			if ( ! empty( $attributes['values'] ) && is_string( $attributes['values'] ) ) {
3037
				$attributes['values'] = array_map( 'trim', explode( ',', $attributes['values'] ) );
3038
			}
3039
		}
3040
3041
		if ( $form ) {
3042
			// make a unique field ID based on the label, with an incrementing number if needed to avoid clashes
3043
			$form_id = $form->get_attribute( 'id' );
3044
			$id      = isset( $attributes['id'] ) ? $attributes['id'] : false;
3045
3046
			$unescaped_label = $this->unesc_attr( $attributes['label'] );
3047
			$unescaped_label = str_replace( '%', '-', $unescaped_label ); // jQuery doesn't like % in IDs?
3048
			$unescaped_label = preg_replace( '/[^a-zA-Z0-9.-_:]/', '', $unescaped_label );
3049
3050
			if ( empty( $id ) ) {
3051
				$id        = sanitize_title_with_dashes( 'g' . $form_id . '-' . $unescaped_label );
3052
				$i         = 0;
3053
				$max_tries = 99;
3054
				while ( isset( $form->fields[ $id ] ) ) {
3055
					$i++;
3056
					$id = sanitize_title_with_dashes( 'g' . $form_id . '-' . $unescaped_label . '-' . $i );
3057
3058
					if ( $i > $max_tries ) {
3059
						break;
3060
					}
3061
				}
3062
			}
3063
3064
			$attributes['id'] = $id;
3065
		}
3066
3067
		parent::__construct( $attributes, $content );
3068
3069
		// Store parent form
3070
		$this->form = $form;
3071
	}
3072
3073
	/**
3074
	 * This field's input is invalid.  Flag as invalid and add an error to the parent form
3075
	 *
3076
	 * @param string $message The error message to display on the form.
3077
	 */
3078
	function add_error( $message ) {
3079
		$this->is_error = true;
3080
3081
		if ( ! is_wp_error( $this->form->errors ) ) {
3082
			$this->form->errors = new WP_Error;
3083
		}
3084
3085
		$this->form->errors->add( $this->get_attribute( 'id' ), $message );
0 ignored issues
show
The method add() does not seem to exist on object<WP_Error>.

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

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

Loading history...
3086
	}
3087
3088
	/**
3089
	 * Is the field input invalid?
3090
	 *
3091
	 * @see $error
3092
	 *
3093
	 * @return bool
3094
	 */
3095
	function is_error() {
3096
		return $this->error;
3097
	}
3098
3099
	/**
3100
	 * Validates the form input
3101
	 */
3102
	function validate() {
3103
		// If it's not required, there's nothing to validate
3104
		if ( ! $this->get_attribute( 'required' ) ) {
3105
			return;
3106
		}
3107
3108
		$field_id    = $this->get_attribute( 'id' );
3109
		$field_type  = $this->get_attribute( 'type' );
3110
		$field_label = $this->get_attribute( 'label' );
3111
3112
		if ( isset( $_POST[ $field_id ] ) ) {
3113
			if ( is_array( $_POST[ $field_id ] ) ) {
3114
				$field_value = array_map( 'stripslashes', $_POST[ $field_id ] );
3115
			} else {
3116
				$field_value = stripslashes( $_POST[ $field_id ] );
3117
			}
3118
		} else {
3119
			$field_value = '';
3120
		}
3121
3122
		switch ( $field_type ) {
3123
			case 'email':
3124
				// Make sure the email address is valid
3125
				if ( ! is_email( $field_value ) ) {
3126
					/* translators: %s is the name of a form field */
3127
					$this->add_error( sprintf( __( '%s requires a valid email address', 'jetpack' ), $field_label ) );
3128
				}
3129
				break;
3130
			case 'checkbox-multiple':
3131
				// Check that there is at least one option selected
3132
				if ( empty( $field_value ) ) {
3133
					/* translators: %s is the name of a form field */
3134
					$this->add_error( sprintf( __( '%s requires at least one selection', 'jetpack' ), $field_label ) );
3135
				}
3136
				break;
3137
			default:
3138
				// Just check for presence of any text
3139
				if ( ! strlen( trim( $field_value ) ) ) {
3140
					/* translators: %s is the name of a form field */
3141
					$this->add_error( sprintf( __( '%s is required', 'jetpack' ), $field_label ) );
3142
				}
3143
		}
3144
	}
3145
3146
3147
	/**
3148
	 * Check the default value for options field
3149
	 *
3150
	 * @param string value
3151
	 * @param int index
3152
	 * @param string default value
3153
	 *
3154
	 * @return string
3155
	 */
3156
	public function get_option_value( $value, $index, $options ) {
3157
		if ( empty( $value[ $index ] ) ) {
3158
			return $options;
3159
		}
3160
		return $value[ $index ];
3161
	}
3162
3163
	/**
3164
	 * Outputs the HTML for this form field
3165
	 *
3166
	 * @return string HTML
3167
	 */
3168
	function render() {
3169
		global $current_user, $user_identity;
3170
3171
		$field_id          = $this->get_attribute( 'id' );
3172
		$field_type        = $this->get_attribute( 'type' );
3173
		$field_label       = $this->get_attribute( 'label' );
3174
		$field_required    = $this->get_attribute( 'required' );
3175
		$field_placeholder = $this->get_attribute( 'placeholder' );
3176
		$class             = 'date' === $field_type ? 'jp-contact-form-date' : $this->get_attribute( 'class' );
3177
3178
		/**
3179
		 * Filters the "class" attribute of the contact form input
3180
		 *
3181
		 * @module contact-form
3182
		 *
3183
		 * @since 6.6.0
3184
		 *
3185
		 * @param string $class Additional CSS classes for input class attribute.
3186
		 */
3187
		$field_class = apply_filters( 'jetpack_contact_form_input_class', $class );
3188
3189
		if ( isset( $_POST[ $field_id ] ) ) {
3190
			if ( is_array( $_POST[ $field_id ] ) ) {
3191
				$this->value = array_map( 'stripslashes', $_POST[ $field_id ] );
3192
			} else {
3193
				$this->value = stripslashes( (string) $_POST[ $field_id ] );
3194
			}
3195
		} elseif ( isset( $_GET[ $field_id ] ) ) {
3196
			$this->value = stripslashes( (string) $_GET[ $field_id ] );
3197
		} elseif (
3198
			is_user_logged_in() &&
3199
			( ( defined( 'IS_WPCOM' ) && IS_WPCOM ) ||
3200
			  /**
3201
			   * Allow third-party tools to prefill the contact form with the user's details when they're logged in.
3202
			   *
3203
			   * @module contact-form
3204
			   *
3205
			   * @since 3.2.0
3206
			   *
3207
			   * @param bool false Should the Contact Form be prefilled with your details when you're logged in. Default to false.
3208
			   */
3209
			  true === apply_filters( 'jetpack_auto_fill_logged_in_user', false )
3210
			)
3211
		) {
3212
			// Special defaults for logged-in users
3213
			switch ( $this->get_attribute( 'type' ) ) {
3214
				case 'email':
3215
					$this->value = $current_user->data->user_email;
3216
					break;
3217
				case 'name':
3218
					$this->value = $user_identity;
3219
					break;
3220
				case 'url':
3221
					$this->value = $current_user->data->user_url;
3222
					break;
3223
				default:
3224
					$this->value = $this->get_attribute( 'default' );
3225
			}
3226
		} else {
3227
			$this->value = $this->get_attribute( 'default' );
3228
		}
3229
3230
		$field_value = Grunion_Contact_Form_Plugin::strip_tags( $this->value );
3231
		$field_label = Grunion_Contact_Form_Plugin::strip_tags( $field_label );
3232
3233
		$rendered_field = $this->render_field( $field_type, $field_id, $field_label, $field_value, $field_class, $field_placeholder, $field_required );
3234
3235
		/**
3236
		 * Filter the HTML of the Contact Form.
3237
		 *
3238
		 * @module contact-form
3239
		 *
3240
		 * @since 2.6.0
3241
		 *
3242
		 * @param string $rendered_field Contact Form HTML output.
3243
		 * @param string $field_label Field label.
3244
		 * @param int|null $id Post ID.
3245
		 */
3246
		return apply_filters( 'grunion_contact_form_field_html', $rendered_field, $field_label, ( in_the_loop() ? get_the_ID() : null ) );
3247
	}
3248
3249
	function render_label( $type = '', $id, $label, $required, $required_field_text ) {
3250
3251
		$type_class = $type ? ' ' .$type : '';
3252
		return
3253
			"<label
3254
				for='" . esc_attr( $id ) . "'
3255
				class='grunion-field-label{$type_class}" . ( $this->is_error() ? ' form-error' : '' ) . "'
3256
				>"
3257
				. esc_html( $label )
3258
				. ( $required ? '<span>' . $required_field_text . '</span>' : '' )
3259
			. "</label>\n";
3260
3261
	}
3262
3263
	function render_input_field( $type, $id, $value, $class, $placeholder, $required ) {
3264
		return "<input
3265
					type='". esc_attr( $type ) ."'
3266
					name='" . esc_attr( $id ) . "'
3267
					id='" . esc_attr( $id ) . "'
3268
					value='" . esc_attr( $value ) . "'
3269
					" . $class . $placeholder . '
3270
					' . ( $required ? "required aria-required='true'" : '' ) . "
3271
				/>\n";
3272
	}
3273
3274
	function render_email_field( $id, $label, $value, $class, $required, $required_field_text, $placeholder ) {
3275
		$field = $this->render_label( 'email', $id, $label, $required, $required_field_text );
3276
		$field .= $this->render_input_field( 'email', $id, $value, $class, $placeholder, $required );
3277
		return $field;
3278
	}
3279
3280
	function render_telephone_field( $id, $label, $value, $class, $required, $required_field_text, $placeholder ) {
3281
		$field = $this->render_label( 'telephone', $id, $label, $required, $required_field_text );
3282
		$field .= $this->render_input_field( 'tel', $id, $value, $class, $placeholder, $required );
3283
		return $field;
3284
	}
3285
3286
	function render_url_field( $id, $label, $value, $class, $required, $required_field_text, $placeholder ) {
3287
		$field = $this->render_label( 'url', $id, $label, $required, $required_field_text );
3288
		$field .= $this->render_input_field( 'url', $id, $value, $class, $placeholder, $required );
3289
		return $field;
3290
	}
3291
3292
	function render_textarea_field( $id, $label, $value, $class, $required, $required_field_text, $placeholder ) {
3293
		$field = $this->render_label( 'textarea', 'contact-form-comment-' . $id, $label, $required, $required_field_text );
3294
		$field .= "<textarea
3295
		                name='" . esc_attr( $id ) . "'
3296
		                id='contact-form-comment-" . esc_attr( $id ) . "'
3297
		                rows='20' "
3298
		                . $class
3299
		                . $placeholder
3300
		                . ' ' . ( $required ? "required aria-required='true'" : '' ) .
3301
		                '>' . esc_textarea( $value )
3302
		          . "</textarea>\n";
3303
		return $field;
3304
	}
3305
3306
	function render_radio_field( $id, $label, $value, $class, $required, $required_field_text ) {
3307
		$field = $this->render_label( '', $id, $label, $required, $required_field_text );
3308
		foreach ( (array) $this->get_attribute( 'options' ) as $optionIndex => $option ) {
3309
			$option = Grunion_Contact_Form_Plugin::strip_tags( $option );
3310
			if ( $option ) {
3311
				$field .= "\t\t<label class='grunion-radio-label radio" . ( $this->is_error() ? ' form-error' : '' ) . "'>";
3312
				$field .= "<input
3313
									type='radio'
3314
									name='" . esc_attr( $id ) . "'
3315
									value='" . esc_attr( $this->get_option_value( $this->get_attribute( 'values' ), $optionIndex, $option ) ) . "' "
3316
				                    . $class
3317
				                    . checked( $option, $value, false ) . ' '
3318
				                    . ( $required ? "required aria-required='true'" : '' )
3319
				              . '/> ';
3320
				$field .= esc_html( $option ) . "</label>\n";
3321
				$field .= "\t\t<div class='clear-form'></div>\n";
3322
			}
3323
		}
3324
		return $field;
3325
	}
3326
3327
	function render_checkbox_field( $id, $label, $value, $class, $required, $required_field_text ) {
3328
		$field = "<label class='grunion-field-label checkbox" . ( $this->is_error() ? ' form-error' : '' ) . "'>";
3329
			$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";
3330
			$field .= "\t\t" . esc_html( $label ) . ( $required ? '<span>' . $required_field_text . '</span>' : '' );
3331
		$field .=  "</label>\n";
3332
		$field .= "<div class='clear-form'></div>\n";
3333
		return $field;
3334
	}
3335
3336
	function render_checkbox_multiple_field( $id, $label, $value, $class, $required, $required_field_text  ) {
3337
		$field = $this->render_label( '', $id, $label, $required, $required_field_text );
3338
		foreach ( (array) $this->get_attribute( 'options' ) as $optionIndex => $option ) {
3339
			$option = Grunion_Contact_Form_Plugin::strip_tags( $option );
3340
			if ( $option  ) {
3341
				$field .= "\t\t<label class='grunion-checkbox-multiple-label checkbox-multiple" . ( $this->is_error() ? ' form-error' : '' ) . "'>";
3342
				$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 ) . ' /> ';
3343
				$field .= esc_html( $option ) . "</label>\n";
3344
				$field .= "\t\t<div class='clear-form'></div>\n";
3345
			}
3346
		}
3347
3348
		return $field;
3349
	}
3350
3351
	function render_select_field( $id, $label, $value, $class, $required, $required_field_text ) {
3352
		$field = $this->render_label( 'select', $id, $label, $required, $required_field_text );
3353
		$field  .= "\t<select name='" . esc_attr( $id ) . "' id='" . esc_attr( $id ) . "' " . $class . ( $required ? "required aria-required='true'" : '' ) . ">\n";
3354
		foreach ( (array) $this->get_attribute( 'options' ) as $optionIndex => $option ) {
3355
			$option = Grunion_Contact_Form_Plugin::strip_tags( $option );
3356
			if ( $option ) {
3357
				$field .= "\t\t<option"
3358
				               . selected( $option, $value, false )
3359
				               . " value='" . esc_attr( $this->get_option_value( $this->get_attribute( 'values' ), $optionIndex, $option ) )
3360
				               . "'>" . esc_html( $option )
3361
				          . "</option>\n";
3362
			}
3363
		}
3364
		$field  .= "\t</select>\n";
3365
		return $field;
3366
	}
3367
3368
	function render_date_field( $id, $label, $value, $class, $required, $required_field_text, $placeholder ) {
3369
		$field = $this->render_label( 'date', $id, $label, $required, $required_field_text );
3370
		$field .= $this->render_input_field( 'text', $id, $value, $class, $placeholder, $required );
3371
3372
		wp_enqueue_script(
3373
			'grunion-frontend',
3374
			Assets::get_file_url_for_environment(
3375
				'_inc/build/contact-form/js/grunion-frontend.min.js',
3376
				'modules/contact-form/js/grunion-frontend.js'
3377
			),
3378
			array( 'jquery', 'jquery-ui-datepicker' )
3379
		);
3380
		wp_enqueue_style( 'jp-jquery-ui-datepicker', plugins_url( 'css/jquery-ui-datepicker.css', __FILE__ ), array( 'dashicons' ), '1.0' );
3381
3382
		// Using Core's built-in datepicker localization routine
3383
		wp_localize_jquery_ui_datepicker();
3384
		return $field;
3385
	}
3386
3387
	function render_default_field( $id, $label, $value, $class, $required, $required_field_text, $placeholder, $type ) {
3388
		$field = $this->render_label( $type, $id, $label, $required, $required_field_text );
3389
		$field .= $this->render_input_field( 'text', $id, $value, $class, $placeholder, $required );
3390
		return $field;
3391
	}
3392
3393
	function render_field( $type, $id, $label, $value, $class, $placeholder, $required ) {
3394
3395
		$field_placeholder = ( ! empty( $placeholder ) ) ? "placeholder='" . esc_attr( $placeholder ) . "'" : '';
3396
		$field_class       = "class='" . trim( esc_attr( $type ) . ' ' . esc_attr( $class ) ) . "' ";
3397
		$wrap_classes = empty( $class ) ? '' : implode( '-wrap ', array_filter( explode( ' ', $class ) ) ) . '-wrap'; // this adds
3398
3399
		$shell_field_class = "class='grunion-field-wrap grunion-field-" . trim( esc_attr( $type ) . '-wrap ' . esc_attr( $wrap_classes ) ) . "' ";
3400
		/**
3401
		/**
3402
		 * Filter the Contact Form required field text
3403
		 *
3404
		 * @module contact-form
3405
		 *
3406
		 * @since 3.8.0
3407
		 *
3408
		 * @param string $var Required field text. Default is "(required)".
3409
		 */
3410
		$required_field_text = esc_html( apply_filters( 'jetpack_required_field_text', __( '(required)', 'jetpack' ) ) );
3411
3412
		$field = "\n<div {$shell_field_class} >\n"; // new in Jetpack 6.8.0
3413
		// If they are logged in, and this is their site, don't pre-populate fields
3414
		if ( current_user_can( 'manage_options' ) ) {
3415
			$value = '';
3416
		}
3417
		switch ( $type ) {
3418
			case 'email':
3419
				$field .= $this->render_email_field( $id, $label, $value, $field_class, $required, $required_field_text, $field_placeholder );
3420
				break;
3421
			case 'telephone':
3422
				$field .= $this->render_telephone_field( $id, $label, $value, $field_class, $required, $required_field_text, $field_placeholder );
3423
				break;
3424
			case 'url':
3425
				$field .= $this->render_url_field( $id, $label, $value, $field_class, $required, $required_field_text, $field_placeholder );
3426
				break;
3427
			case 'textarea':
3428
				$field .= $this->render_textarea_field( $id, $label, $value, $field_class, $required, $required_field_text, $field_placeholder );
3429
				break;
3430
			case 'radio':
3431
				$field .= $this->render_radio_field( $id, $label, $value, $field_class, $required, $required_field_text, $field_placeholder );
3432
				break;
3433
			case 'checkbox':
3434
				$field .= $this->render_checkbox_field( $id, $label, $value, $field_class, $required, $required_field_text );
3435
				break;
3436
			case 'checkbox-multiple':
3437
				$field .= $this->render_checkbox_multiple_field( $id, $label, $value, $field_class, $required, $required_field_text );
3438
				break;
3439
			case 'select':
3440
				$field .= $this->render_select_field( $id, $label, $value, $field_class, $required, $required_field_text );
3441
				break;
3442
			case 'date':
3443
				$field .= $this->render_date_field( $id, $label, $value, $field_class, $required, $required_field_text, $field_placeholder );
3444
				break;
3445
			default: // text field
3446
				$field .= $this->render_default_field( $id, $label, $value, $field_class, $required, $required_field_text, $field_placeholder, $type );
3447
				break;
3448
		}
3449
		$field .= "\t</div>\n";
3450
		return $field;
3451
	}
3452
}
3453
3454
add_action( 'init', array( 'Grunion_Contact_Form_Plugin', 'init' ), 9 );
3455
3456
add_action( 'grunion_scheduled_delete', 'grunion_delete_old_spam' );
3457
3458
/**
3459
 * Deletes old spam feedbacks to keep the posts table size under control
3460
 */
3461
function grunion_delete_old_spam() {
3462
	global $wpdb;
3463
3464
	$grunion_delete_limit = 100;
3465
3466
	$now_gmt  = current_time( 'mysql', 1 );
3467
	$sql      = $wpdb->prepare(
3468
		"
3469
		SELECT `ID`
3470
		FROM $wpdb->posts
3471
		WHERE DATE_SUB( %s, INTERVAL 15 DAY ) > `post_date_gmt`
3472
			AND `post_type` = 'feedback'
3473
			AND `post_status` = 'spam'
3474
		LIMIT %d
3475
	", $now_gmt, $grunion_delete_limit
3476
	);
3477
	$post_ids = $wpdb->get_col( $sql );
3478
3479
	foreach ( (array) $post_ids as $post_id ) {
3480
		// force a full delete, skip the trash
3481
		wp_delete_post( $post_id, true );
3482
	}
3483
3484
	if (
3485
		/**
3486
		 * Filter if the module run OPTIMIZE TABLE on the core WP tables.
3487
		 *
3488
		 * @module contact-form
3489
		 *
3490
		 * @since 1.3.1
3491
		 * @since 6.4.0 Set to false by default.
3492
		 *
3493
		 * @param bool $filter Should Jetpack optimize the table, defaults to false.
3494
		 */
3495
		apply_filters( 'grunion_optimize_table', false )
3496
	) {
3497
		$wpdb->query( "OPTIMIZE TABLE $wpdb->posts" );
3498
	}
3499
3500
	// if we hit the max then schedule another run
3501
	if ( count( $post_ids ) >= $grunion_delete_limit ) {
3502
		wp_schedule_single_event( time() + 700, 'grunion_scheduled_delete' );
3503
	}
3504
}
3505