Completed
Push — add/crowdsignal-shortcode ( 65c42e...1b4a63 )
by Kuba
14:46 queued 06:22
created

personal_data_eraser()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 3

Duplication

Lines 0
Ratio 0 %

Importance

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

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

Let’s take a look at an example:

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

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

    // do something with $myArray
}

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

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

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

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

Let’s take a look at an example:

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

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

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

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

Available Fixes

  1. Check for existence of the variable explicitly:

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

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

    function myFunction($a) {
        switch ($a) {
            case 'foo':
                $x = 1;
                break;
    
            case 'bar':
                $x = 2;
                break;
    
            // We add support for the missing case.
            default:
                $x = '';
                break;
        }
    
        echo $x;
    }
    
Loading history...
121
	}
122
123
	function __construct() {
124
		$this->add_shortcode();
125
126
		// While generating the output of a text widget with a contact-form shortcode, we need to know its widget ID.
127
		add_action( 'dynamic_sidebar', array( $this, 'track_current_widget' ) );
128
129
		// Add a "widget" shortcode attribute to all contact-form shortcodes embedded in widgets
130
		add_filter( 'widget_text', array( $this, 'widget_atts' ), 0 );
131
132
		// If Text Widgets don't get shortcode processed, hack ours into place.
133
		if (
134
			version_compare( get_bloginfo( 'version' ), '4.9-z', '<=' )
135
			&& ! has_filter( 'widget_text', 'do_shortcode' )
136
		) {
137
			add_filter( 'widget_text', array( $this, 'widget_shortcode_hack' ), 5 );
138
		}
139
140
		// Akismet to the rescue
141
		if ( defined( 'AKISMET_VERSION' ) || function_exists( 'akismet_http_post' ) ) {
142
			add_filter( 'jetpack_contact_form_is_spam', array( $this, 'is_spam_akismet' ), 10, 2 );
143
			add_action( 'contact_form_akismet', array( $this, 'akismet_submit' ), 10, 2 );
144
		}
145
146
		add_action( 'loop_start', array( 'Grunion_Contact_Form', '_style_on' ) );
147
148
		add_action( 'wp_ajax_grunion-contact-form', array( $this, 'ajax_request' ) );
149
		add_action( 'wp_ajax_nopriv_grunion-contact-form', array( $this, 'ajax_request' ) );
150
151
		// GDPR: personal data exporter & eraser.
152
		add_filter( 'wp_privacy_personal_data_exporters', array( $this, 'register_personal_data_exporter' ) );
153
		add_filter( 'wp_privacy_personal_data_erasers', array( $this, 'register_personal_data_eraser' ) );
154
155
		// Export to CSV feature
156
		if ( is_admin() ) {
157
			add_action( 'admin_init', array( $this, 'download_feedback_as_csv' ) );
158
			add_action( 'admin_footer-edit.php', array( $this, 'export_form' ) );
159
			add_action( 'admin_menu', array( $this, 'admin_menu' ) );
160
			add_action( 'current_screen', array( $this, 'unread_count' ) );
161
		}
162
163
		// custom post type we'll use to keep copies of the feedback items
164
		register_post_type(
165
			'feedback', array(
166
				'labels'                => array(
167
					'name'               => __( 'Feedback', 'jetpack' ),
168
					'singular_name'      => __( 'Feedback', 'jetpack' ),
169
					'search_items'       => __( 'Search Feedback', 'jetpack' ),
170
					'not_found'          => __( 'No feedback found', 'jetpack' ),
171
					'not_found_in_trash' => __( 'No feedback found', 'jetpack' ),
172
				),
173
				'menu_icon'             => 'dashicons-feedback',
174
				'show_ui'               => true,
175
				'show_in_admin_bar'     => false,
176
				'public'                => false,
177
				'rewrite'               => false,
178
				'query_var'             => false,
179
				'capability_type'       => 'page',
180
				'show_in_rest'          => true,
181
				'rest_controller_class' => 'Grunion_Contact_Form_Endpoint',
182
				'capabilities'          => array(
183
					'create_posts'        => false,
184
					'publish_posts'       => 'publish_pages',
185
					'edit_posts'          => 'edit_pages',
186
					'edit_others_posts'   => 'edit_others_pages',
187
					'delete_posts'        => 'delete_pages',
188
					'delete_others_posts' => 'delete_others_pages',
189
					'read_private_posts'  => 'read_private_pages',
190
					'edit_post'           => 'edit_page',
191
					'delete_post'         => 'delete_page',
192
					'read_post'           => 'read_page',
193
				),
194
				'map_meta_cap'          => true,
195
			)
196
		);
197
198
		// Add to REST API post type whitelist
199
		add_filter( 'rest_api_allowed_post_types', array( $this, 'allow_feedback_rest_api_type' ) );
200
201
		// Add "spam" as a post status
202
		register_post_status(
203
			'spam', array(
204
				'label'                  => 'Spam',
205
				'public'                 => false,
206
				'exclude_from_search'    => true,
207
				'show_in_admin_all_list' => false,
208
				'label_count'            => _n_noop( 'Spam <span class="count">(%s)</span>', 'Spam <span class="count">(%s)</span>', 'jetpack' ),
209
				'protected'              => true,
210
				'_builtin'               => false,
211
			)
212
		);
213
214
		// POST handler
215
		if (
216
			isset( $_SERVER['REQUEST_METHOD'] ) && 'POST' == strtoupper( $_SERVER['REQUEST_METHOD'] )
217
		&&
218
			isset( $_POST['action'] ) && 'grunion-contact-form' == $_POST['action']
219
		&&
220
			isset( $_POST['contact-form-id'] )
221
		) {
222
			add_action( 'template_redirect', array( $this, 'process_form_submission' ) );
223
		}
224
225
		/*
226
		 Can be dequeued by placing the following in wp-content/themes/yourtheme/functions.php
227
		 *
228
		 * 	function remove_grunion_style() {
229
		 *		wp_deregister_style('grunion.css');
230
		 *	}
231
		 *	add_action('wp_print_styles', 'remove_grunion_style');
232
		 */
233
		wp_register_style( 'grunion.css', GRUNION_PLUGIN_URL . 'css/grunion.css', array(), JETPACK__VERSION );
234
		wp_style_add_data( 'grunion.css', 'rtl', 'replace' );
235
	}
236
237
	/**
238
	 * Add the 'Export' menu item as a submenu of Feedback.
239
	 */
240
	public function admin_menu() {
241
		add_submenu_page(
242
			'edit.php?post_type=feedback',
243
			__( 'Export feedback as CSV', 'jetpack' ),
244
			__( 'Export CSV', 'jetpack' ),
245
			'export',
246
			'feedback-export',
247
			array( $this, 'export_form' )
248
		);
249
	}
250
251
	/**
252
	 * Add to REST API post type whitelist
253
	 */
254
	function allow_feedback_rest_api_type( $post_types ) {
255
		$post_types[] = 'feedback';
256
		return $post_types;
257
	}
258
259
	/**
260
	 * Display the count of new feedback entries received. It's reset when user visits the Feedback screen.
261
	 *
262
	 * @since 4.1.0
263
	 *
264
	 * @param object $screen Information about the current screen.
265
	 */
266
	function unread_count( $screen ) {
267
		if ( isset( $screen->post_type ) && 'feedback' == $screen->post_type ) {
268
			update_option( 'feedback_unread_count', 0 );
269
		} else {
270
			global $menu;
271
			if ( isset( $menu ) && is_array( $menu ) && ! empty( $menu ) ) {
272
				foreach ( $menu as $index => $menu_item ) {
273
					if ( 'edit.php?post_type=feedback' == $menu_item[2] ) {
274
						$unread = get_option( 'feedback_unread_count', 0 );
275
						if ( $unread > 0 ) {
276
							$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>' : '';
277
							$menu[ $index ][0] .= $unread_count;
278
						}
279
						break;
280
					}
281
				}
282
			}
283
		}
284
	}
285
286
	/**
287
	 * Handles all contact-form POST submissions
288
	 *
289
	 * Conditionally attached to `template_redirect`
290
	 */
291
	function process_form_submission() {
292
		// Add a filter to replace tokens in the subject field with sanitized field values
293
		add_filter( 'contact_form_subject', array( $this, 'replace_tokens_with_input' ), 10, 2 );
294
295
		$id   = stripslashes( $_POST['contact-form-id'] );
296
		$hash = isset( $_POST['contact-form-hash'] ) ? $_POST['contact-form-hash'] : null;
297
		$hash = preg_replace( '/[^\da-f]/i', '', $hash );
298
299
		if ( is_user_logged_in() ) {
300
			check_admin_referer( "contact-form_{$id}" );
301
		}
302
303
		$is_widget = 0 === strpos( $id, 'widget-' );
304
305
		$form = false;
0 ignored issues
show
Unused Code introduced by
$form is not used, you could remove the assignment.

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

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

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

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

Loading history...
306
307
		if ( $is_widget ) {
308
			// It's a form embedded in a text widget
309
			$this->current_widget_id = substr( $id, 7 ); // remove "widget-"
310
			$widget_type             = implode( '-', array_slice( explode( '-', $this->current_widget_id ), 0, -1 ) ); // Remove trailing -#
311
312
			// Is the widget active?
313
			$sidebar = is_active_widget( false, $this->current_widget_id, $widget_type );
314
315
			// This is lame - no core API for getting a widget by ID
316
			$widget = isset( $GLOBALS['wp_registered_widgets'][ $this->current_widget_id ] ) ? $GLOBALS['wp_registered_widgets'][ $this->current_widget_id ] : false;
317
318
			if ( $sidebar && $widget && isset( $widget['callback'] ) ) {
319
				// prevent PHP notices by populating widget args
320
				$widget_args = array(
321
					'before_widget' => '',
322
					'after_widget'  => '',
323
					'before_title'  => '',
324
					'after_title'   => '',
325
				);
326
				// This is lamer - no API for outputting a given widget by ID
327
				ob_start();
328
				// Process the widget to populate Grunion_Contact_Form::$last
329
				call_user_func( $widget['callback'], $widget_args, $widget['params'][0] );
330
				ob_end_clean();
331
			}
332
		} else {
333
			// It's a form embedded in a post
334
			$post = get_post( $id );
335
336
			// Process the content to populate Grunion_Contact_Form::$last
337
			/** This filter is already documented in core. wp-includes/post-template.php */
338
			apply_filters( 'the_content', $post->post_content );
339
		}
340
341
		$form = isset( Grunion_Contact_Form::$forms[ $hash ] ) ? Grunion_Contact_Form::$forms[ $hash ] : null;
0 ignored issues
show
Bug introduced by
The property forms cannot be accessed from this context as it is declared private in class Grunion_Contact_Form.

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

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

Loading history...
342
343
		// No form may mean user is using do_shortcode, grab the form using the stored post meta
344
		if ( ! $form ) {
345
346
			// Get shortcode from post meta
347
			$shortcode = get_post_meta( $_POST['contact-form-id'], "_g_feedback_shortcode_{$hash}", true );
348
349
			// Format it
350
			if ( $shortcode != '' ) {
351
352
				// Get attributes from post meta.
353
				$parameters = '';
354
				$attributes = get_post_meta( $_POST['contact-form-id'], "_g_feedback_shortcode_atts_{$hash}", true );
355
				if ( ! empty( $attributes ) && is_array( $attributes ) ) {
356
					foreach ( array_filter( $attributes ) as $param => $value ) {
357
						$parameters .= " $param=\"$value\"";
358
					}
359
				}
360
361
				$shortcode = '[contact-form' . $parameters . ']' . $shortcode . '[/contact-form]';
362
				do_shortcode( $shortcode );
363
364
				// Recreate form
365
				$form = Grunion_Contact_Form::$last;
0 ignored issues
show
Bug introduced by
The property last cannot be accessed from this context as it is declared private in class Grunion_Contact_Form.

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

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

Loading history...
366
			}
367
368
			if ( ! $form ) {
369
				return false;
370
			}
371
		}
372
373
		if ( is_wp_error( $form->errors ) && $form->errors->get_error_codes() ) {
374
			return $form->errors;
375
		}
376
377
		// Process the form
378
		return $form->process_submission();
379
	}
380
381
	function ajax_request() {
382
		$submission_result = self::process_form_submission();
383
384
		if ( ! $submission_result ) {
385
			header( 'HTTP/1.1 500 Server Error', 500, true );
386
			echo '<div class="form-error"><ul class="form-errors"><li class="form-error-message">';
387
			esc_html_e( 'An error occurred. Please try again later.', 'jetpack' );
388
			echo '</li></ul></div>';
389
		} elseif ( is_wp_error( $submission_result ) ) {
390
			header( 'HTTP/1.1 400 Bad Request', 403, true );
391
			echo '<div class="form-error"><ul class="form-errors"><li class="form-error-message">';
392
			echo esc_html( $submission_result->get_error_message() );
393
			echo '</li></ul></div>';
394
		} else {
395
			echo '<h3>' . esc_html__( 'Message Sent', 'jetpack' ) . '</h3>' . $submission_result;
396
		}
397
398
		die;
399
	}
400
401
	/**
402
	 * Ensure the post author is always zero for contact-form feedbacks
403
	 * Attached to `wp_insert_post_data`
404
	 *
405
	 * @see Grunion_Contact_Form::process_submission()
406
	 *
407
	 * @param array $data the data to insert
408
	 * @param array $postarr the data sent to wp_insert_post()
409
	 * @return array The filtered $data to insert
410
	 */
411
	function insert_feedback_filter( $data, $postarr ) {
412
		if ( $data['post_type'] == 'feedback' && $postarr['post_type'] == 'feedback' ) {
413
			$data['post_author'] = 0;
414
		}
415
416
		return $data;
417
	}
418
	/*
419
	 * Adds our contact-form shortcode
420
	 * The "child" contact-field shortcode is enabled as needed by the contact-form shortcode handler
421
	 */
422
	function add_shortcode() {
423
		add_shortcode( 'contact-form', array( 'Grunion_Contact_Form', 'parse' ) );
424
		add_shortcode( 'contact-field', array( 'Grunion_Contact_Form', 'parse_contact_field' ) );
425
	}
426
427
	static function tokenize_label( $label ) {
428
		return '{' . trim( preg_replace( '#^\d+_#', '', $label ) ) . '}';
429
	}
430
431
	static function sanitize_value( $value ) {
432
		return preg_replace( '=((<CR>|<LF>|0x0A/%0A|0x0D/%0D|\\n|\\r)\S).*=i', null, $value );
433
	}
434
435
	/**
436
	 * Replaces tokens like {city} or {City} (case insensitive) with the value
437
	 * of an input field of that name
438
	 *
439
	 * @param string $subject
440
	 * @param array  $field_values Array with field label => field value associations
441
	 *
442
	 * @return string The filtered $subject with the tokens replaced
443
	 */
444
	function replace_tokens_with_input( $subject, $field_values ) {
445
		// Wrap labels into tokens (inside {})
446
		$wrapped_labels = array_map( array( 'Grunion_Contact_Form_Plugin', 'tokenize_label' ), array_keys( $field_values ) );
447
		// Sanitize all values
448
		$sanitized_values = array_map( array( 'Grunion_Contact_Form_Plugin', 'sanitize_value' ), array_values( $field_values ) );
449
450
		foreach ( $sanitized_values as $k => $sanitized_value ) {
451
			if ( is_array( $sanitized_value ) ) {
452
				$sanitized_values[ $k ] = implode( ', ', $sanitized_value );
453
			}
454
		}
455
456
		// Search for all valid tokens (based on existing fields) and replace with the field's value
457
		$subject = str_ireplace( $wrapped_labels, $sanitized_values, $subject );
458
		return $subject;
459
	}
460
461
	/**
462
	 * Tracks the widget currently being processed.
463
	 * Attached to `dynamic_sidebar`
464
	 *
465
	 * @see $current_widget_id
466
	 *
467
	 * @param array $widget The widget data
468
	 */
469
	function track_current_widget( $widget ) {
470
		$this->current_widget_id = $widget['id'];
471
	}
472
473
	/**
474
	 * Adds a "widget" attribute to every contact-form embedded in a text widget.
475
	 * Used to tell the difference between post-embedded contact-forms and widget-embedded contact-forms
476
	 * Attached to `widget_text`
477
	 *
478
	 * @param string $text The widget text
479
	 * @return string The filtered widget text
480
	 */
481
	function widget_atts( $text ) {
482
		Grunion_Contact_Form::style( true );
483
484
		return preg_replace( '/\[contact-form([^a-zA-Z_-])/', '[contact-form widget="' . $this->current_widget_id . '"\\1', $text );
485
	}
486
487
	/**
488
	 * For sites where text widgets are not processed for shortcodes, we add this hack to process just our shortcode
489
	 * Attached to `widget_text`
490
	 *
491
	 * @param string $text The widget text
492
	 * @return string The contact-form filtered widget text
493
	 */
494
	function widget_shortcode_hack( $text ) {
495
		if ( ! preg_match( '/\[contact-form([^a-zA-Z_-])/', $text ) ) {
496
			return $text;
497
		}
498
499
		$old = $GLOBALS['shortcode_tags'];
500
		remove_all_shortcodes();
501
		Grunion_Contact_Form_Plugin::$using_contact_form_field = true;
502
		$this->add_shortcode();
503
504
		$text = do_shortcode( $text );
505
506
		Grunion_Contact_Form_Plugin::$using_contact_form_field = false;
507
		$GLOBALS['shortcode_tags']                             = $old;
508
509
		return $text;
510
	}
511
512
	/**
513
	 * Populate an array with all values necessary to submit a NEW contact-form feedback to Akismet.
514
	 * Note that this includes the current user_ip etc, so this should only be called when accepting a new item via $_POST
515
	 *
516
	 * @param array $form Contact form feedback array
517
	 * @return array feedback array with additional data ready for submission to Akismet
518
	 */
519
	function prepare_for_akismet( $form ) {
520
		$form['comment_type'] = 'contact_form';
521
		$form['user_ip']      = $_SERVER['REMOTE_ADDR'];
522
		$form['user_agent']   = $_SERVER['HTTP_USER_AGENT'];
523
		$form['referrer']     = $_SERVER['HTTP_REFERER'];
524
		$form['blog']         = get_option( 'home' );
525
526
		foreach ( $_SERVER as $key => $value ) {
527
			if ( ! is_string( $value ) ) {
528
				continue;
529
			}
530
			if ( in_array( $key, array( 'HTTP_COOKIE', 'HTTP_COOKIE2', 'HTTP_USER_AGENT', 'HTTP_REFERER' ) ) ) {
531
				// We don't care about cookies, and the UA and Referrer were caught above.
532
				continue;
533
			} elseif ( in_array( $key, array( 'REMOTE_ADDR', 'REQUEST_URI', 'DOCUMENT_URI' ) ) ) {
534
				// All three of these are relevant indicators and should be passed along.
535
				$form[ $key ] = $value;
536
			} elseif ( wp_startswith( $key, 'HTTP_' ) ) {
537
				// Any other HTTP header indicators.
538
				// `wp_startswith()` is a wpcom helper function and is included in Jetpack via `functions.compat.php`
539
				$form[ $key ] = $value;
540
			}
541
		}
542
543
		return $form;
544
	}
545
546
	/**
547
	 * Submit contact-form data to Akismet to check for spam.
548
	 * If you're accepting a new item via $_POST, run it Grunion_Contact_Form_Plugin::prepare_for_akismet() first
549
	 * Attached to `jetpack_contact_form_is_spam`
550
	 *
551
	 * @param bool  $is_spam
552
	 * @param array $form
553
	 * @return bool|WP_Error TRUE => spam, FALSE => not spam, WP_Error => stop processing entirely
554
	 */
555
	function is_spam_akismet( $is_spam, $form = array() ) {
556
		global $akismet_api_host, $akismet_api_port;
557
558
		// The signature of this function changed from accepting just $form.
559
		// If something only sends an array, assume it's still using the old
560
		// signature and work around it.
561
		if ( empty( $form ) && is_array( $is_spam ) ) {
562
			$form    = $is_spam;
563
			$is_spam = false;
564
		}
565
566
		// If a previous filter has alrady marked this as spam, trust that and move on.
567
		if ( $is_spam ) {
568
			return $is_spam;
569
		}
570
571
		if ( ! function_exists( 'akismet_http_post' ) && ! defined( 'AKISMET_VERSION' ) ) {
572
			return false;
573
		}
574
575
		$query_string = http_build_query( $form );
576
577
		if ( method_exists( 'Akismet', 'http_post' ) ) {
578
			$response = Akismet::http_post( $query_string, 'comment-check' );
579
		} else {
580
			$response = akismet_http_post( $query_string, $akismet_api_host, '/1.1/comment-check', $akismet_api_port );
581
		}
582
583
		$result = false;
584
585
		if ( isset( $response[0]['x-akismet-pro-tip'] ) && 'discard' === trim( $response[0]['x-akismet-pro-tip'] ) && get_option( 'akismet_strictness' ) === '1' ) {
586
			$result = new WP_Error( 'feedback-discarded', __( 'Feedback discarded.', 'jetpack' ) );
587
		} elseif ( isset( $response[1] ) && 'true' == trim( $response[1] ) ) { // 'true' is spam
588
			$result = true;
589
		}
590
591
		/**
592
		 * Filter the results returned by Akismet for each submitted contact form.
593
		 *
594
		 * @module contact-form
595
		 *
596
		 * @since 1.3.1
597
		 *
598
		 * @param WP_Error|bool $result Is the submitted feedback spam.
599
		 * @param array|bool $form Submitted feedback.
600
		 */
601
		return apply_filters( 'contact_form_is_spam_akismet', $result, $form );
602
	}
603
604
	/**
605
	 * Submit a feedback as either spam or ham
606
	 *
607
	 * @param string $as Either 'spam' or 'ham'.
608
	 * @param array  $form the contact-form data
609
	 */
610
	function akismet_submit( $as, $form ) {
611
		global $akismet_api_host, $akismet_api_port;
612
613
		if ( ! in_array( $as, array( 'ham', 'spam' ) ) ) {
614
			return false;
615
		}
616
617
		$query_string = '';
618
		if ( is_array( $form ) ) {
619
			$query_string = http_build_query( $form );
620
		}
621
		if ( method_exists( 'Akismet', 'http_post' ) ) {
622
			$response = Akismet::http_post( $query_string, "submit-{$as}" );
623
		} else {
624
			$response = akismet_http_post( $query_string, $akismet_api_host, "/1.1/submit-{$as}", $akismet_api_port );
625
		}
626
627
		return trim( $response[1] );
628
	}
629
630
	/**
631
	 * Prints the menu
632
	 */
633
	function export_form() {
634
		$current_screen = get_current_screen();
635
		if ( ! in_array( $current_screen->id, array( 'edit-feedback', 'feedback_page_feedback-export' ) ) ) {
636
			return;
637
		}
638
639
		if ( ! current_user_can( 'export' ) ) {
640
			return;
641
		}
642
643
		// if there aren't any feedbacks, bail out
644
		if ( ! (int) wp_count_posts( 'feedback' )->publish ) {
645
			return;
646
		}
647
		?>
648
649
		<div id="feedback-export" style="display:none">
650
			<h2><?php _e( 'Export feedback as CSV', 'jetpack' ); ?></h2>
651
			<div class="clear"></div>
652
			<form action="<?php echo admin_url( 'admin-post.php' ); ?>" method="post" class="form">
653
				<?php wp_nonce_field( 'feedback_export', 'feedback_export_nonce' ); ?>
654
655
				<input name="action" value="feedback_export" type="hidden">
656
				<label for="post"><?php _e( 'Select feedback to download', 'jetpack' ); ?></label>
657
				<select name="post">
658
					<option value="all"><?php esc_html_e( 'All posts', 'jetpack' ); ?></option>
659
					<?php echo $this->get_feedbacks_as_options(); ?>
660
				</select>
661
662
				<br><br>
663
				<input type="submit" name="submit" id="submit" class="button button-primary" value="<?php esc_html_e( 'Download', 'jetpack' ); ?>">
664
			</form>
665
		</div>
666
667
		<?php
668
		// There aren't any usable actions in core to output the "export feedback" form in the correct place,
669
		// so this inline JS moves it from the top of the page to the bottom.
670
		?>
671
		<script type='text/javascript'>
672
		var menu = document.getElementById( 'feedback-export' ),
673
		wrapper = document.getElementsByClassName( 'wrap' )[0];
674
		<?php if ( 'edit-feedback' === $current_screen->id ) : ?>
675
		wrapper.appendChild(menu);
676
		<?php endif; ?>
677
		menu.style.display = 'block';
678
		</script>
679
		<?php
680
	}
681
682
	/**
683
	 * Fetch post content for a post and extract just the comment.
684
	 *
685
	 * @param int $post_id The post id to fetch the content for.
686
	 *
687
	 * @return string Trimmed post comment.
688
	 *
689
	 * @codeCoverageIgnore
690
	 */
691
	public function get_post_content_for_csv_export( $post_id ) {
692
		$post_content = get_post_field( 'post_content', $post_id );
693
		$content      = explode( '<!--more-->', $post_content );
694
695
		return trim( $content[0] );
696
	}
697
698
	/**
699
	 * Get `_feedback_extra_fields` field from post meta data.
700
	 *
701
	 * @param int $post_id Id of the post to fetch meta data for.
702
	 *
703
	 * @return mixed
704
	 *
705
	 * @codeCoverageIgnore - No need to be covered.
706
	 */
707
	public function get_post_meta_for_csv_export( $post_id ) {
708
		return get_post_meta( $post_id, '_feedback_extra_fields', true );
709
	}
710
711
	/**
712
	 * Get parsed feedback post fields.
713
	 *
714
	 * @param int $post_id Id of the post to fetch parsed contents for.
715
	 *
716
	 * @return array
717
	 *
718
	 * @codeCoverageIgnore - No need to be covered.
719
	 */
720
	public function get_parsed_field_contents_of_post( $post_id ) {
721
		return self::parse_fields_from_content( $post_id );
722
	}
723
724
	/**
725
	 * Properly maps fields that are missing from the post meta data
726
	 * to names, that are similar to those of the post meta.
727
	 *
728
	 * @param array $parsed_post_content Parsed post content
729
	 *
730
	 * @see parse_fields_from_content for how the input data is generated.
731
	 *
732
	 * @return array Mapped fields.
733
	 */
734
	public function map_parsed_field_contents_of_post_to_field_names( $parsed_post_content ) {
735
736
		$mapped_fields = array();
737
738
		$field_mapping = array(
739
			'_feedback_subject'      => __( 'Contact Form', 'jetpack' ),
740
			'_feedback_author'       => '1_Name',
741
			'_feedback_author_email' => '2_Email',
742
			'_feedback_author_url'   => '3_Website',
743
			'_feedback_main_comment' => '4_Comment',
744
		);
745
746
		foreach ( $field_mapping as $parsed_field_name => $field_name ) {
747
			if (
748
				isset( $parsed_post_content[ $parsed_field_name ] )
749
				&& ! empty( $parsed_post_content[ $parsed_field_name ] )
750
			) {
751
				$mapped_fields[ $field_name ] = $parsed_post_content[ $parsed_field_name ];
752
			}
753
		}
754
755
		return $mapped_fields;
756
	}
757
758
	/**
759
	 * Registers the personal data exporter.
760
	 *
761
	 * @since 6.1.1
762
	 *
763
	 * @param  array $exporters An array of personal data exporters.
764
	 *
765
	 * @return array $exporters An array of personal data exporters.
766
	 */
767
	public function register_personal_data_exporter( $exporters ) {
768
		$exporters['jetpack-feedback'] = array(
769
			'exporter_friendly_name' => __( 'Feedback', 'jetpack' ),
770
			'callback'               => array( $this, 'personal_data_exporter' ),
771
		);
772
773
		return $exporters;
774
	}
775
776
	/**
777
	 * Registers the personal data eraser.
778
	 *
779
	 * @since 6.1.1
780
	 *
781
	 * @param  array $erasers An array of personal data erasers.
782
	 *
783
	 * @return array $erasers An array of personal data erasers.
784
	 */
785
	public function register_personal_data_eraser( $erasers ) {
786
		$erasers['jetpack-feedback'] = array(
787
			'eraser_friendly_name' => __( 'Feedback', 'jetpack' ),
788
			'callback'             => array( $this, 'personal_data_eraser' ),
789
		);
790
791
		return $erasers;
792
	}
793
794
	/**
795
	 * Exports personal data.
796
	 *
797
	 * @since 6.1.1
798
	 *
799
	 * @param  string $email  Email address.
800
	 * @param  int    $page   Page to export.
801
	 *
802
	 * @return array  $return Associative array with keys expected by core.
803
	 */
804
	public function personal_data_exporter( $email, $page = 1 ) {
805
		return $this->_internal_personal_data_exporter( $email, $page );
806
	}
807
808
	/**
809
	 * Internal method for exporting personal data.
810
	 *
811
	 * Allows us to have a different signature than core expects
812
	 * while protecting against future core API changes.
813
	 *
814
	 * @internal
815
	 * @since 6.5
816
	 *
817
	 * @param  string $email    Email address.
818
	 * @param  int    $page     Page to export.
819
	 * @param  int    $per_page Number of feedbacks to process per page. Internal use only (testing)
820
	 *
821
	 * @return array            Associative array with keys expected by core.
822
	 */
823
	public function _internal_personal_data_exporter( $email, $page = 1, $per_page = 250 ) {
824
		$export_data = array();
825
		$post_ids    = $this->personal_data_post_ids_by_email( $email, $per_page, $page );
826
827
		foreach ( $post_ids as $post_id ) {
828
			$post_fields = $this->get_parsed_field_contents_of_post( $post_id );
829
830
			if ( ! is_array( $post_fields ) || empty( $post_fields['_feedback_subject'] ) ) {
831
				continue; // Corrupt data.
832
			}
833
834
			$post_fields['_feedback_main_comment'] = $this->get_post_content_for_csv_export( $post_id );
835
			$post_fields                           = $this->map_parsed_field_contents_of_post_to_field_names( $post_fields );
836
837
			if ( ! is_array( $post_fields ) || empty( $post_fields ) ) {
838
				continue; // No fields to export.
839
			}
840
841
			$post_meta = $this->get_post_meta_for_csv_export( $post_id );
842
			$post_meta = is_array( $post_meta ) ? $post_meta : array();
843
844
			$post_export_data = array();
845
			$post_data        = array_merge( $post_fields, $post_meta );
846
			ksort( $post_data );
847
848
			foreach ( $post_data as $post_data_key => $post_data_value ) {
849
				$post_export_data[] = array(
850
					'name'  => preg_replace( '/^[0-9]+_/', '', $post_data_key ),
851
					'value' => $post_data_value,
852
				);
853
			}
854
855
			$export_data[] = array(
856
				'group_id'    => 'feedback',
857
				'group_label' => __( 'Feedback', 'jetpack' ),
858
				'item_id'     => 'feedback-' . $post_id,
859
				'data'        => $post_export_data,
860
			);
861
		}
862
863
		return array(
864
			'data' => $export_data,
865
			'done' => count( $post_ids ) < $per_page,
866
		);
867
	}
868
869
	/**
870
	 * Erases personal data.
871
	 *
872
	 * @since 6.1.1
873
	 *
874
	 * @param  string $email Email address.
875
	 * @param  int    $page  Page to erase.
876
	 *
877
	 * @return array         Associative array with keys expected by core.
878
	 */
879
	public function personal_data_eraser( $email, $page = 1 ) {
880
		return $this->_internal_personal_data_eraser( $email, $page );
881
	}
882
883
	/**
884
	 * Internal method for erasing personal data.
885
	 *
886
	 * Allows us to have a different signature than core expects
887
	 * while protecting against future core API changes.
888
	 *
889
	 * @internal
890
	 * @since 6.5
891
	 *
892
	 * @param  string $email    Email address.
893
	 * @param  int    $page     Page to erase.
894
	 * @param  int    $per_page Number of feedbacks to process per page. Internal use only (testing)
895
	 *
896
	 * @return array            Associative array with keys expected by core.
897
	 */
898
	public function _internal_personal_data_eraser( $email, $page = 1, $per_page = 250 ) {
899
		$removed      = false;
900
		$retained     = false;
901
		$messages     = array();
902
		$option_name  = sprintf( '_jetpack_pde_feedback_%s', md5( $email ) );
903
		$last_post_id = 1 === $page ? 0 : get_option( $option_name, 0 );
904
		$post_ids     = $this->personal_data_post_ids_by_email( $email, $per_page, $page, $last_post_id );
905
906
		foreach ( $post_ids as $post_id ) {
907
			/**
908
			 * Filters whether to erase a particular Feedback post.
909
			 *
910
			 * @since 6.3.0
911
			 *
912
			 * @param bool|string $prevention_message Whether to apply erase the Feedback post (bool).
913
			 *                                        Custom prevention message (string). Default true.
914
			 * @param int         $post_id            Feedback post ID.
915
			 */
916
			$prevention_message = apply_filters( 'grunion_contact_form_delete_feedback_post', true, $post_id );
917
918
			if ( true !== $prevention_message ) {
919
				if ( $prevention_message && is_string( $prevention_message ) ) {
920
					$messages[] = esc_html( $prevention_message );
921
				} else {
922
					$messages[] = sprintf(
923
						// translators: %d: Post ID.
924
						__( 'Feedback ID %d could not be removed at this time.', 'jetpack' ),
925
						$post_id
926
					);
927
				}
928
929
				$retained = true;
930
931
				continue;
932
			}
933
934
			if ( wp_delete_post( $post_id, true ) ) {
935
				$removed = true;
936
			} else {
937
				$retained   = true;
938
				$messages[] = sprintf(
939
					// translators: %d: Post ID.
940
					__( 'Feedback ID %d could not be removed at this time.', 'jetpack' ),
941
					$post_id
942
				);
943
			}
944
		}
945
946
		$done = count( $post_ids ) < $per_page;
947
948
		if ( $done ) {
949
			delete_option( $option_name );
950
		} else {
951
			update_option( $option_name, (int) $post_id );
0 ignored issues
show
Bug introduced by
The variable $post_id seems to be defined by a foreach iteration on line 906. Are you sure the iterator is never empty, otherwise this variable is not defined?

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

foreach ($a as $b) {
}

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


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

// $b is now guaranteed to be defined here.
Loading history...
952
		}
953
954
		return array(
955
			'items_removed'  => $removed,
956
			'items_retained' => $retained,
957
			'messages'       => $messages,
958
			'done'           => $done,
959
		);
960
	}
961
962
	/**
963
	 * Queries personal data by email address.
964
	 *
965
	 * @since 6.1.1
966
	 *
967
	 * @param  string $email        Email address.
968
	 * @param  int    $per_page     Post IDs per page. Default is `250`.
969
	 * @param  int    $page         Page to query. Default is `1`.
970
	 * @param  int    $last_post_id Page to query. Default is `0`. If non-zero, used instead of $page.
971
	 *
972
	 * @return array An array of post IDs.
973
	 */
974
	public function personal_data_post_ids_by_email( $email, $per_page = 250, $page = 1, $last_post_id = 0 ) {
975
		add_filter( 'posts_search', array( $this, 'personal_data_search_filter' ) );
976
977
		$this->pde_last_post_id_erased = $last_post_id;
978
		$this->pde_email_address       = $email;
979
980
		$post_ids = get_posts(
981
			array(
982
				'post_type'        => 'feedback',
983
				'post_status'      => 'publish',
984
				// This search parameter gets overwritten in ->personal_data_search_filter()
985
				's'                => '..PDE..AUTHOR EMAIL:..PDE..',
986
				'sentence'         => true,
987
				'order'            => 'ASC',
988
				'orderby'          => 'ID',
989
				'fields'           => 'ids',
990
				'posts_per_page'   => $per_page,
991
				'paged'            => $last_post_id ? 1 : $page,
992
				'suppress_filters' => false,
993
			)
994
		);
995
996
		$this->pde_last_post_id_erased = 0;
997
		$this->pde_email_address       = '';
998
999
		remove_filter( 'posts_search', array( $this, 'personal_data_search_filter' ) );
1000
1001
		return $post_ids;
1002
	}
1003
1004
	/**
1005
	 * Filters searches by email address.
1006
	 *
1007
	 * @since 6.1.1
1008
	 *
1009
	 * @param  string $search SQL where clause.
1010
	 *
1011
	 * @return array          Filtered SQL where clause.
1012
	 */
1013
	public function personal_data_search_filter( $search ) {
1014
		global $wpdb;
1015
1016
		/*
1017
		 * Limits search to `post_content` only, and we only match the
1018
		 * author's email address whenever it's on a line by itself.
1019
		 */
1020
		if ( $this->pde_email_address && false !== strpos( $search, '..PDE..AUTHOR EMAIL:..PDE..' ) ) {
1021
			$search = $wpdb->prepare(
1022
				" AND (
1023
					{$wpdb->posts}.post_content LIKE %s
1024
					OR {$wpdb->posts}.post_content LIKE %s
1025
				)",
1026
				// `chr( 10 )` = `\n`, `chr( 13 )` = `\r`
1027
				'%' . $wpdb->esc_like( chr( 10 ) . 'AUTHOR EMAIL: ' . $this->pde_email_address . chr( 10 ) ) . '%',
1028
				'%' . $wpdb->esc_like( chr( 13 ) . 'AUTHOR EMAIL: ' . $this->pde_email_address . chr( 13 ) ) . '%'
1029
			);
1030
1031
			if ( $this->pde_last_post_id_erased ) {
1032
				$search .= $wpdb->prepare( " AND {$wpdb->posts}.ID > %d", $this->pde_last_post_id_erased );
1033
			}
1034
		}
1035
1036
		return $search;
1037
	}
1038
1039
	/**
1040
	 * Prepares feedback post data for CSV export.
1041
	 *
1042
	 * @param array $post_ids Post IDs to fetch the data for. These need to be Feedback posts.
1043
	 *
1044
	 * @return array
1045
	 */
1046
	public function get_export_data_for_posts( $post_ids ) {
1047
1048
		$posts_data  = array();
1049
		$field_names = array();
1050
		$result      = array();
1051
1052
		/**
1053
		 * Fetch posts and get the possible field names for later use
1054
		 */
1055
		foreach ( $post_ids as $post_id ) {
1056
1057
			/**
1058
			 * Fetch post main data, because we need the subject and author data for the feedback form.
1059
			 */
1060
			$post_real_data = $this->get_parsed_field_contents_of_post( $post_id );
1061
1062
			/**
1063
			 * If `$post_real_data` is not an array or there is no `_feedback_subject` set,
1064
			 * then something must be wrong with the feedback post. Skip it.
1065
			 */
1066
			if ( ! is_array( $post_real_data ) || ! isset( $post_real_data['_feedback_subject'] ) ) {
1067
				continue;
1068
			}
1069
1070
			/**
1071
			 * Fetch main post comment. This is from the default textarea fields.
1072
			 * If it is non-empty, then we add it to data, otherwise skip it.
1073
			 */
1074
			$post_comment_content = $this->get_post_content_for_csv_export( $post_id );
1075
			if ( ! empty( $post_comment_content ) ) {
1076
				$post_real_data['_feedback_main_comment'] = $post_comment_content;
1077
			}
1078
1079
			/**
1080
			 * Map parsed fields to proper field names
1081
			 */
1082
			$mapped_fields = $this->map_parsed_field_contents_of_post_to_field_names( $post_real_data );
1083
1084
			/**
1085
			 * Fetch post meta data.
1086
			 */
1087
			$post_meta_data = $this->get_post_meta_for_csv_export( $post_id );
1088
1089
			/**
1090
			 * If `$post_meta_data` is not an array or if it is empty, then there is no
1091
			 * extra feedback to work with. Create an empty array.
1092
			 */
1093
			if ( ! is_array( $post_meta_data ) || empty( $post_meta_data ) ) {
1094
				$post_meta_data = array();
1095
			}
1096
1097
			/**
1098
			 * Prepend the feedback subject to the list of fields.
1099
			 */
1100
			$post_meta_data = array_merge(
1101
				$mapped_fields,
1102
				$post_meta_data
1103
			);
1104
1105
			/**
1106
			 * Save post metadata for later usage.
1107
			 */
1108
			$posts_data[ $post_id ] = $post_meta_data;
1109
1110
			/**
1111
			 * Save field names, so we can use them as header fields later in the CSV.
1112
			 */
1113
			$field_names = array_merge( $field_names, array_keys( $post_meta_data ) );
1114
		}
1115
1116
		/**
1117
		 * Make sure the field names are unique, because we don't want duplicate data.
1118
		 */
1119
		$field_names = array_unique( $field_names );
1120
1121
		/**
1122
		 * Sort the field names by the field id number
1123
		 */
1124
		sort( $field_names, SORT_NUMERIC );
1125
1126
		/**
1127
		 * Loop through every post, which is essentially CSV row.
1128
		 */
1129
		foreach ( $posts_data as $post_id => $single_post_data ) {
1130
1131
			/**
1132
			 * Go through all the possible fields and check if the field is available
1133
			 * in the current post.
1134
			 *
1135
			 * If it is - add the data as a value.
1136
			 * If it is not - add an empty string, which is just a placeholder in the CSV.
1137
			 */
1138
			foreach ( $field_names as $single_field_name ) {
1139
				if (
1140
					isset( $single_post_data[ $single_field_name ] )
1141
					&& ! empty( $single_post_data[ $single_field_name ] )
1142
				) {
1143
					$result[ $single_field_name ][] = trim( $single_post_data[ $single_field_name ] );
1144
				} else {
1145
					$result[ $single_field_name ][] = '';
1146
				}
1147
			}
1148
		}
1149
1150
		return $result;
1151
	}
1152
1153
	/**
1154
	 * download as a csv a contact form or all of them in a csv file
1155
	 */
1156
	function download_feedback_as_csv() {
1157
		if ( empty( $_POST['feedback_export_nonce'] ) ) {
1158
			return;
1159
		}
1160
1161
		check_admin_referer( 'feedback_export', 'feedback_export_nonce' );
1162
1163
		if ( ! current_user_can( 'export' ) ) {
1164
			return;
1165
		}
1166
1167
		$args = array(
1168
			'posts_per_page'   => -1,
1169
			'post_type'        => 'feedback',
1170
			'post_status'      => 'publish',
1171
			'order'            => 'ASC',
1172
			'fields'           => 'ids',
1173
			'suppress_filters' => false,
1174
		);
1175
1176
		$filename = date( 'Y-m-d' ) . '-feedback-export.csv';
1177
1178
		// Check if we want to download all the feedbacks or just a certain contact form
1179
		if ( ! empty( $_POST['post'] ) && $_POST['post'] !== 'all' ) {
1180
			$args['post_parent'] = (int) $_POST['post'];
1181
			$filename            = date( 'Y-m-d' ) . '-' . str_replace( '&nbsp;', '-', get_the_title( (int) $_POST['post'] ) ) . '.csv';
1182
		}
1183
1184
		$feedbacks = get_posts( $args );
1185
1186
		if ( empty( $feedbacks ) ) {
1187
			return;
1188
		}
1189
1190
		$filename = sanitize_file_name( $filename );
1191
1192
		/**
1193
		 * Prepare data for export.
1194
		 */
1195
		$data = $this->get_export_data_for_posts( $feedbacks );
1196
1197
		/**
1198
		 * If `$data` is empty, there's nothing we can do below.
1199
		 */
1200
		if ( ! is_array( $data ) || empty( $data ) ) {
1201
			return;
1202
		}
1203
1204
		/**
1205
		 * Extract field names from `$data` for later use.
1206
		 */
1207
		$fields = array_keys( $data );
1208
1209
		/**
1210
		 * Count how many rows will be exported.
1211
		 */
1212
		$row_count = count( reset( $data ) );
1213
1214
		// Forces the download of the CSV instead of echoing
1215
		header( 'Content-Disposition: attachment; filename=' . $filename );
1216
		header( 'Pragma: no-cache' );
1217
		header( 'Expires: 0' );
1218
		header( 'Content-Type: text/csv; charset=utf-8' );
1219
1220
		$output = fopen( 'php://output', 'w' );
1221
1222
		/**
1223
		 * Print CSV headers
1224
		 */
1225
		fputcsv( $output, $fields );
1226
1227
		/**
1228
		 * Print rows to the output.
1229
		 */
1230
		for ( $i = 0; $i < $row_count; $i ++ ) {
1231
1232
			$current_row = array();
1233
1234
			/**
1235
			 * Put all the fields in `$current_row` array.
1236
			 */
1237
			foreach ( $fields as $single_field_name ) {
1238
				$current_row[] = $this->esc_csv( $data[ $single_field_name ][ $i ] );
1239
			}
1240
1241
			/**
1242
			 * Output the complete CSV row
1243
			 */
1244
			fputcsv( $output, $current_row );
1245
		}
1246
1247
		fclose( $output );
1248
	}
1249
1250
	/**
1251
	 * Escape a string to be used in a CSV context
1252
	 *
1253
	 * Malicious input can inject formulas into CSV files, opening up the possibility for phishing attacks and
1254
	 * disclosure of sensitive information.
1255
	 *
1256
	 * Additionally, Excel exposes the ability to launch arbitrary commands through the DDE protocol.
1257
	 *
1258
	 * @see http://www.contextis.com/resources/blog/comma-separated-vulnerabilities/
1259
	 *
1260
	 * @param string $field
1261
	 *
1262
	 * @return string
1263
	 */
1264
	public function esc_csv( $field ) {
1265
		$active_content_triggers = array( '=', '+', '-', '@' );
1266
1267
		if ( in_array( mb_substr( $field, 0, 1 ), $active_content_triggers, true ) ) {
1268
			$field = "'" . $field;
1269
		}
1270
1271
		return $field;
1272
	}
1273
1274
	/**
1275
	 * Returns a string of HTML <option> items from an array of posts
1276
	 *
1277
	 * @return string a string of HTML <option> items
1278
	 */
1279
	protected function get_feedbacks_as_options() {
1280
		$options = '';
1281
1282
		// Get the feedbacks' parents' post IDs
1283
		$feedbacks = get_posts(
1284
			array(
1285
				'fields'           => 'id=>parent',
1286
				'posts_per_page'   => 100000,
1287
				'post_type'        => 'feedback',
1288
				'post_status'      => 'publish',
1289
				'suppress_filters' => false,
1290
			)
1291
		);
1292
		$parents   = array_unique( array_values( $feedbacks ) );
1293
1294
		$posts = get_posts(
1295
			array(
1296
				'orderby'          => 'ID',
1297
				'posts_per_page'   => 1000,
1298
				'post_type'        => 'any',
1299
				'post__in'         => array_values( $parents ),
1300
				'suppress_filters' => false,
1301
			)
1302
		);
1303
1304
		// creates the string of <option> elements
1305
		foreach ( $posts as $post ) {
1306
			$options .= sprintf( '<option value="%s">%s</option>', esc_attr( $post->ID ), esc_html( $post->post_title ) );
1307
		}
1308
1309
		return $options;
1310
	}
1311
1312
	/**
1313
	 * Get the names of all the form's fields
1314
	 *
1315
	 * @param  array|int $posts the post we want the fields of
1316
	 *
1317
	 * @return array     the array of fields
1318
	 *
1319
	 * @deprecated As this is no longer necessary as of the CSV export rewrite. - 2015-12-29
1320
	 */
1321
	protected function get_field_names( $posts ) {
1322
		$posts      = (array) $posts;
1323
		$all_fields = array();
1324
1325
		foreach ( $posts as $post ) {
1326
			$fields = self::parse_fields_from_content( $post );
1327
1328
			if ( isset( $fields['_feedback_all_fields'] ) ) {
1329
				$extra_fields = array_keys( $fields['_feedback_all_fields'] );
1330
				$all_fields   = array_merge( $all_fields, $extra_fields );
1331
			}
1332
		}
1333
1334
		$all_fields = array_unique( $all_fields );
1335
		return $all_fields;
1336
	}
1337
1338
	public static function parse_fields_from_content( $post_id ) {
1339
		static $post_fields;
1340
1341
		if ( ! is_array( $post_fields ) ) {
1342
			$post_fields = array();
1343
		}
1344
1345
		if ( isset( $post_fields[ $post_id ] ) ) {
1346
			return $post_fields[ $post_id ];
1347
		}
1348
1349
		$all_values   = array();
1350
		$post_content = get_post_field( 'post_content', $post_id );
1351
		$content      = explode( '<!--more-->', $post_content );
1352
		$lines        = array();
1353
1354
		if ( count( $content ) > 1 ) {
1355
			$content  = str_ireplace( array( '<br />', ')</p>' ), '', $content[1] );
1356
			$one_line = preg_replace( '/\s+/', ' ', $content );
1357
			$one_line = preg_replace( '/.*Array \( (.*)\)/', '$1', $one_line );
1358
1359
			preg_match_all( '/\[([^\]]+)\] =\&gt\; ([^\[]+)/', $one_line, $matches );
1360
1361
			if ( count( $matches ) > 1 ) {
1362
				$all_values = array_combine( array_map( 'trim', $matches[1] ), array_map( 'trim', $matches[2] ) );
1363
			}
1364
1365
			$lines = array_filter( explode( "\n", $content ) );
1366
		}
1367
1368
		$var_map = array(
1369
			'AUTHOR'       => '_feedback_author',
1370
			'AUTHOR EMAIL' => '_feedback_author_email',
1371
			'AUTHOR URL'   => '_feedback_author_url',
1372
			'SUBJECT'      => '_feedback_subject',
1373
			'IP'           => '_feedback_ip',
1374
		);
1375
1376
		$fields = array();
1377
1378
		foreach ( $lines as $line ) {
1379
			$vars = explode( ': ', $line, 2 );
1380
			if ( ! empty( $vars ) ) {
1381
				if ( isset( $var_map[ $vars[0] ] ) ) {
1382
					$fields[ $var_map[ $vars[0] ] ] = self::strip_tags( trim( $vars[1] ) );
1383
				}
1384
			}
1385
		}
1386
1387
		$fields['_feedback_all_fields'] = $all_values;
1388
1389
		$post_fields[ $post_id ] = $fields;
1390
1391
		return $fields;
1392
	}
1393
1394
	/**
1395
	 * Creates a valid csv row from a post id
1396
	 *
1397
	 * @param  int   $post_id The id of the post
1398
	 * @param  array $fields  An array containing the names of all the fields of the csv
1399
	 * @return String The csv row
1400
	 *
1401
	 * @deprecated This is no longer needed, as of the CSV export rewrite.
1402
	 */
1403
	protected static function make_csv_row_from_feedback( $post_id, $fields ) {
1404
		$content_fields = self::parse_fields_from_content( $post_id );
1405
		$all_fields     = array();
1406
1407
		if ( isset( $content_fields['_feedback_all_fields'] ) ) {
1408
			$all_fields = $content_fields['_feedback_all_fields'];
1409
		}
1410
1411
		// Overwrite the parsed content with the content we stored in post_meta in a better format.
1412
		$extra_fields = get_post_meta( $post_id, '_feedback_extra_fields', true );
1413
		foreach ( $extra_fields as $extra_field => $extra_value ) {
1414
			$all_fields[ $extra_field ] = $extra_value;
1415
		}
1416
1417
		// The first element in all of the exports will be the subject
1418
		$row_items[] = $content_fields['_feedback_subject'];
0 ignored issues
show
Coding Style Comprehensibility introduced by
$row_items was never initialized. Although not strictly required by PHP, it is generally a good practice to add $row_items = array(); before regardless.

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

Let’s take a look at an example:

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

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

    // do something with $myArray
}

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

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

Loading history...
1419
1420
		// Loop the fields array in order to fill the $row_items array correctly
1421
		foreach ( $fields as $field ) {
1422
			if ( $field === __( 'Contact Form', 'jetpack' ) ) { // the first field will ever be the contact form, so we can continue
1423
				continue;
1424
			} elseif ( array_key_exists( $field, $all_fields ) ) {
1425
				$row_items[] = $all_fields[ $field ];
1426
			} else {
1427
				$row_items[] = '';
1428
			}
1429
		}
1430
1431
		return $row_items;
1432
	}
1433
1434
	public static function get_ip_address() {
1435
		return isset( $_SERVER['REMOTE_ADDR'] ) ? $_SERVER['REMOTE_ADDR'] : null;
1436
	}
1437
}
1438
1439
/**
1440
 * Generic shortcode class.
1441
 * Does nothing other than store structured data and output the shortcode as a string
1442
 *
1443
 * Not very general - specific to Grunion.
1444
 */
1445
class Crunion_Contact_Form_Shortcode {
1446
	/**
1447
	 * @var string the name of the shortcode: [$shortcode_name /]
1448
	 */
1449
	public $shortcode_name;
1450
1451
	/**
1452
	 * @var array key => value pairs for the shortcode's attributes: [$shortcode_name key="value" ... /]
1453
	 */
1454
	public $attributes;
1455
1456
	/**
1457
	 * @var array key => value pair for attribute defaults
1458
	 */
1459
	public $defaults = array();
1460
1461
	/**
1462
	 * @var null|string Null for selfclosing shortcodes.  Hhe inner content of otherwise: [$shortcode_name]$content[/$shortcode_name]
1463
	 */
1464
	public $content;
1465
1466
	/**
1467
	 * @var array Associative array of inner "child" shortcodes equivalent to the $content: [$shortcode_name][child 1/][child 2/][/$shortcode_name]
1468
	 */
1469
	public $fields;
1470
1471
	/**
1472
	 * @var null|string The HTML of the parsed inner "child" shortcodes".  Null for selfclosing shortcodes.
1473
	 */
1474
	public $body;
1475
1476
	/**
1477
	 * @param array       $attributes An associative array of shortcode attributes.  @see shortcode_atts()
1478
	 * @param null|string $content Null for selfclosing shortcodes.  The inner content otherwise.
1479
	 */
1480
	function __construct( $attributes, $content = null ) {
1481
		$this->attributes = $this->unesc_attr( $attributes );
0 ignored issues
show
Documentation Bug introduced by
It seems like $this->unesc_attr($attributes) of type * is incompatible with the declared type array of property $attributes.

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

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

Loading history...
1482
		if ( is_array( $content ) ) {
1483
			$string_content = '';
1484
			foreach ( $content as $field ) {
1485
				$string_content .= (string) $field;
1486
			}
1487
1488
			$this->content = $string_content;
1489
		} else {
1490
			$this->content = $content;
1491
		}
1492
1493
		$this->parse_content( $this->content );
1494
	}
1495
1496
	/**
1497
	 * Processes the shortcode's inner content for "child" shortcodes
1498
	 *
1499
	 * @param string $content The shortcode's inner content: [shortcode]$content[/shortcode]
1500
	 */
1501
	function parse_content( $content ) {
1502
		if ( is_null( $content ) ) {
1503
			$this->body = null;
1504
		}
1505
1506
		$this->body = do_shortcode( $content );
1507
	}
1508
1509
	/**
1510
	 * Returns the value of the requested attribute.
1511
	 *
1512
	 * @param string $key The attribute to retrieve
1513
	 * @return mixed
1514
	 */
1515
	function get_attribute( $key ) {
1516
		return isset( $this->attributes[ $key ] ) ? $this->attributes[ $key ] : null;
1517
	}
1518
1519
	function esc_attr( $value ) {
1520
		if ( is_array( $value ) ) {
1521
			return array_map( array( $this, 'esc_attr' ), $value );
1522
		}
1523
1524
		$value = Grunion_Contact_Form_Plugin::strip_tags( $value );
1525
		$value = _wp_specialchars( $value, ENT_QUOTES, false, true );
1526
1527
		// Shortcode attributes can't contain "]"
1528
		$value = str_replace( ']', '', $value );
1529
		$value = str_replace( ',', '&#x002c;', $value ); // store commas encoded
1530
		$value = strtr(
1531
			$value, array(
1532
				'%' => '%25',
1533
				'&' => '%26',
1534
			)
1535
		);
1536
1537
		// shortcode_parse_atts() does stripcslashes()
1538
		$value = addslashes( $value );
1539
		return $value;
1540
	}
1541
1542
	function unesc_attr( $value ) {
1543
		if ( is_array( $value ) ) {
1544
			return array_map( array( $this, 'unesc_attr' ), $value );
1545
		}
1546
1547
		// For back-compat with old Grunion encoding
1548
		// Also, unencode commas
1549
		$value = strtr(
1550
			$value, array(
1551
				'%26' => '&',
1552
				'%25' => '%',
1553
			)
1554
		);
1555
		$value = preg_replace( array( '/&#x0*22;/i', '/&#x0*27;/i', '/&#x0*26;/i', '/&#x0*2c;/i' ), array( '"', "'", '&', ',' ), $value );
1556
		$value = htmlspecialchars_decode( $value, ENT_QUOTES );
1557
		$value = Grunion_Contact_Form_Plugin::strip_tags( $value );
1558
1559
		return $value;
1560
	}
1561
1562
	/**
1563
	 * Generates the shortcode
1564
	 */
1565
	function __toString() {
1566
		$r = "[{$this->shortcode_name} ";
1567
1568
		foreach ( $this->attributes as $key => $value ) {
1569
			if ( ! $value ) {
1570
				continue;
1571
			}
1572
1573
			if ( isset( $this->defaults[ $key ] ) && $this->defaults[ $key ] == $value ) {
1574
				continue;
1575
			}
1576
1577
			if ( 'id' == $key ) {
1578
				continue;
1579
			}
1580
1581
			$value = $this->esc_attr( $value );
1582
1583
			if ( is_array( $value ) ) {
1584
				$value = join( ',', $value );
1585
			}
1586
1587
			if ( false === strpos( $value, "'" ) ) {
1588
				$value = "'$value'";
1589
			} elseif ( false === strpos( $value, '"' ) ) {
1590
				$value = '"' . $value . '"';
1591
			} else {
1592
				// Shortcodes can't contain both '"' and "'".  Strip one.
1593
				$value = str_replace( "'", '', $value );
1594
				$value = "'$value'";
1595
			}
1596
1597
			$r .= "{$key}={$value} ";
1598
		}
1599
1600
		$r = rtrim( $r );
1601
1602
		if ( $this->fields ) {
0 ignored issues
show
Bug Best Practice introduced by
The expression $this->fields of type array is implicitly converted to a boolean; are you sure this is intended? If so, consider using ! empty($expr) instead to make it clear that you intend to check for an array without elements.

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

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

Loading history...
1603
			$r .= ']';
1604
1605
			foreach ( $this->fields as $field ) {
1606
				$r .= (string) $field;
1607
			}
1608
1609
			$r .= "[/{$this->shortcode_name}]";
1610
		} else {
1611
			$r .= '/]';
1612
		}
1613
1614
		return $r;
1615
	}
1616
}
1617
1618
/**
1619
 * Class for the contact-form shortcode.
1620
 * Parses shortcode to output the contact form as HTML
1621
 * Sends email and stores the contact form response (a.k.a. "feedback")
1622
 */
1623
class Grunion_Contact_Form extends Crunion_Contact_Form_Shortcode {
1624
	public $shortcode_name = 'contact-form';
1625
1626
	/**
1627
	 * @var WP_Error stores form submission errors
1628
	 */
1629
	public $errors;
1630
1631
	/**
1632
	 * @var string The SHA1 hash of the attributes that comprise the form.
1633
	 */
1634
	public $hash;
1635
1636
	/**
1637
	 * @var Grunion_Contact_Form The most recent (inclusive) contact-form shortcode processed
1638
	 */
1639
	static $last;
1640
1641
	/**
1642
	 * @var Whatever form we are currently looking at. If processed, will become $last
1643
	 */
1644
	static $current_form;
1645
1646
	/**
1647
	 * @var array All found forms, indexed by hash.
1648
	 */
1649
	static $forms = array();
1650
1651
	/**
1652
	 * @var bool Whether to print the grunion.css style when processing the contact-form shortcode
1653
	 */
1654
	static $style = false;
1655
1656
	function __construct( $attributes, $content = null ) {
1657
		global $post;
1658
1659
		$this->hash                 = sha1( json_encode( $attributes ) . $content );
1660
		self::$forms[ $this->hash ] = $this;
1661
1662
		// Set up the default subject and recipient for this form
1663
		$default_to      = '';
1664
		$default_subject = '[' . get_option( 'blogname' ) . ']';
1665
1666
		if ( ! isset( $attributes ) || ! is_array( $attributes ) ) {
1667
			$attributes = array();
1668
		}
1669
1670
		if ( ! empty( $attributes['widget'] ) && $attributes['widget'] ) {
1671
			$default_to      .= get_option( 'admin_email' );
1672
			$attributes['id'] = 'widget-' . $attributes['widget'];
1673
			$default_subject  = sprintf( _x( '%1$s Sidebar', '%1$s = blog name', 'jetpack' ), $default_subject );
1674
		} elseif ( $post ) {
1675
			$attributes['id'] = $post->ID;
1676
			$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 ) );
1677
			$post_author      = get_userdata( $post->post_author );
1678
			$default_to      .= $post_author->user_email;
1679
		}
1680
1681
		// Keep reference to $this for parsing form fields
1682
		self::$current_form = $this;
0 ignored issues
show
Documentation Bug introduced by
It seems like $this of type this<Grunion_Contact_Form> is incompatible with the declared type object<Whatever> of property $current_form.

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

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

Loading history...
1683
1684
		$this->defaults = array(
1685
			'to'                 => $default_to,
1686
			'subject'            => $default_subject,
1687
			'show_subject'       => 'no', // only used in back-compat mode
1688
			'widget'             => 0,    // Not exposed to the user. Works with Grunion_Contact_Form_Plugin::widget_atts()
1689
			'id'                 => null, // Not exposed to the user. Set above.
1690
			'submit_button_text' => __( 'Submit &#187;', 'jetpack' ),
1691
		);
1692
1693
		$attributes = shortcode_atts( $this->defaults, $attributes, 'contact-form' );
1694
1695
		// We only enable the contact-field shortcode temporarily while processing the contact-form shortcode
1696
		Grunion_Contact_Form_Plugin::$using_contact_form_field = true;
0 ignored issues
show
Bug introduced by
The property using_contact_form_field cannot be accessed from this context as it is declared private in class Grunion_Contact_Form_Plugin.

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

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

Loading history...
1697
1698
		parent::__construct( $attributes, $content );
1699
1700
		// There were no fields in the contact form. The form was probably just [contact-form /]. Build a default form.
1701
		if ( empty( $this->fields ) ) {
1702
			// same as the original Grunion v1 form
1703
			$default_form = '
1704
				[contact-field label="' . __( 'Name', 'jetpack' ) . '" type="name"  required="true" /]
1705
				[contact-field label="' . __( 'Email', 'jetpack' ) . '" type="email" required="true" /]
1706
				[contact-field label="' . __( 'Website', 'jetpack' ) . '" type="url" /]';
1707
1708
			if ( 'yes' == strtolower( $this->get_attribute( 'show_subject' ) ) ) {
1709
				$default_form .= '
1710
					[contact-field label="' . __( 'Subject', 'jetpack' ) . '" type="subject" /]';
1711
			}
1712
1713
			$default_form .= '
1714
				[contact-field label="' . __( 'Message', 'jetpack' ) . '" type="textarea" /]';
1715
1716
			$this->parse_content( $default_form );
1717
1718
			// Store the shortcode
1719
			$this->store_shortcode( $default_form, $attributes, $this->hash );
1720
		} else {
1721
			// Store the shortcode
1722
			$this->store_shortcode( $content, $attributes, $this->hash );
1723
		}
1724
1725
		// $this->body and $this->fields have been setup.  We no longer need the contact-field shortcode.
1726
		Grunion_Contact_Form_Plugin::$using_contact_form_field = false;
0 ignored issues
show
Bug introduced by
The property using_contact_form_field cannot be accessed from this context as it is declared private in class Grunion_Contact_Form_Plugin.

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

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

Loading history...
1727
	}
1728
1729
	/**
1730
	 * Store shortcode content for recall later
1731
	 *  - used to receate shortcode when user uses do_shortcode
1732
	 *
1733
	 * @param string $content
0 ignored issues
show
Documentation introduced by
Should the type for parameter $content not be string|null?

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

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

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

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

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

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

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

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

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

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

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

Loading history...
1736
	 */
1737
	static function store_shortcode( $content = null, $attributes = null, $hash = null ) {
1738
1739
		if ( $content != null and isset( $attributes['id'] ) ) {
0 ignored issues
show
Bug introduced by
It seems like you are loosely comparing $content of type string|null against null; this is ambiguous if the string can be empty. Consider using a strict comparison !== instead.
Loading history...
1740
1741
			if ( empty( $hash ) ) {
1742
				$hash = sha1( json_encode( $attributes ) . $content );
1743
			}
1744
1745
			$shortcode_meta = get_post_meta( $attributes['id'], "_g_feedback_shortcode_{$hash}", true );
1746
1747
			if ( $shortcode_meta != '' or $shortcode_meta != $content ) {
1748
				update_post_meta( $attributes['id'], "_g_feedback_shortcode_{$hash}", $content );
1749
1750
				// Save attributes to post_meta for later use. They're not available later in do_shortcode situations.
1751
				update_post_meta( $attributes['id'], "_g_feedback_shortcode_atts_{$hash}", $attributes );
1752
			}
1753
		}
1754
	}
1755
1756
	/**
1757
	 * Toggle for printing the grunion.css stylesheet
1758
	 *
1759
	 * @param bool $style
1760
	 */
1761
	static function style( $style ) {
1762
		$previous_style = self::$style;
1763
		self::$style    = (bool) $style;
1764
		return $previous_style;
1765
	}
1766
1767
	/**
1768
	 * Turn on printing of grunion.css stylesheet
1769
	 *
1770
	 * @see ::style()
1771
	 * @internal
1772
	 * @param bool $style
0 ignored issues
show
Bug introduced by
There is no parameter named $style. Was it maybe removed?

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

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

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

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

Loading history...
1773
	 */
1774
	static function _style_on() {
1775
		return self::style( true );
1776
	}
1777
1778
	/**
1779
	 * The contact-form shortcode processor
1780
	 *
1781
	 * @param array       $attributes Key => Value pairs as parsed by shortcode_parse_atts()
1782
	 * @param string|null $content The shortcode's inner content: [contact-form]$content[/contact-form]
1783
	 * @return string HTML for the concat form.
1784
	 */
1785
	static function parse( $attributes, $content ) {
1786
		require_once JETPACK__PLUGIN_DIR . '/sync/class.jetpack-sync-settings.php';
1787
		if ( Jetpack_Sync_Settings::is_syncing() ) {
1788
			return '';
1789
		}
1790
		// Create a new Grunion_Contact_Form object (this class)
1791
		$form = new Grunion_Contact_Form( $attributes, $content );
1792
1793
		$id = $form->get_attribute( 'id' );
1794
1795
		if ( ! $id ) { // something terrible has happened
1796
			return '[contact-form]';
1797
		}
1798
1799
		if ( is_feed() ) {
1800
			return '[contact-form]';
1801
		}
1802
1803
		self::$last = $form;
1804
1805
		// Enqueue the grunion.css stylesheet if self::$style allows it
1806
		if ( self::$style && ( empty( $_REQUEST['action'] ) || $_REQUEST['action'] != 'grunion_shortcode_to_json' ) ) {
1807
			// Enqueue the style here instead of printing it, because if some other plugin has run the_post()+rewind_posts(),
1808
			// (like VideoPress does), the style tag gets "printed" the first time and discarded, leaving the contact form unstyled.
1809
			// when WordPress does the real loop.
1810
			wp_enqueue_style( 'grunion.css' );
1811
		}
1812
1813
		$r  = '';
1814
		$r .= "<div id='contact-form-$id'>\n";
1815
1816
		if ( is_wp_error( $form->errors ) && $form->errors->get_error_codes() ) {
1817
			// There are errors.  Display them
1818
			$r .= "<div class='form-error'>\n<h3>" . __( 'Error!', 'jetpack' ) . "</h3>\n<ul class='form-errors'>\n";
1819
			foreach ( $form->errors->get_error_messages() as $message ) {
1820
				$r .= "\t<li class='form-error-message'>" . esc_html( $message ) . "</li>\n";
1821
			}
1822
			$r .= "</ul>\n</div>\n\n";
1823
		}
1824
1825
		if ( isset( $_GET['contact-form-id'] )
1826
			&& $_GET['contact-form-id'] == self::$last->get_attribute( 'id' )
1827
			&& isset( $_GET['contact-form-sent'], $_GET['contact-form-hash'] )
1828
			&& hash_equals( $form->hash, $_GET['contact-form-hash'] ) ) {
1829
			// The contact form was submitted.  Show the success message/results
1830
			$feedback_id = (int) $_GET['contact-form-sent'];
1831
1832
			$back_url = remove_query_arg( array( 'contact-form-id', 'contact-form-sent', '_wpnonce' ) );
1833
1834
			$r_success_message =
1835
				'<h3>' . __( 'Message Sent', 'jetpack' ) .
1836
				' (<a href="' . esc_url( $back_url ) . '">' . esc_html__( 'go back', 'jetpack' ) . '</a>)' .
1837
				"</h3>\n\n";
1838
1839
			// Don't show the feedback details unless the nonce matches
1840
			if ( $feedback_id && wp_verify_nonce( stripslashes( $_GET['_wpnonce'] ), "contact-form-sent-{$feedback_id}" ) ) {
1841
				$r_success_message .= self::success_message( $feedback_id, $form );
1842
			}
1843
1844
			/**
1845
			 * Filter the message returned after a successful contact form submission.
1846
			 *
1847
			 * @module contact-form
1848
			 *
1849
			 * @since 1.3.1
1850
			 *
1851
			 * @param string $r_success_message Success message.
1852
			 */
1853
			$r .= apply_filters( 'grunion_contact_form_success_message', $r_success_message );
1854
		} else {
1855
			// Nothing special - show the normal contact form
1856
			if ( $form->get_attribute( 'widget' ) ) {
1857
				// Submit form to the current URL
1858
				$url = remove_query_arg( array( 'contact-form-id', 'contact-form-sent', 'action', '_wpnonce' ) );
1859
			} else {
1860
				// Submit form to the post permalink
1861
				$url = get_permalink();
1862
			}
1863
1864
			// For SSL/TLS page. See RFC 3986 Section 4.2
1865
			$url = set_url_scheme( $url );
1866
1867
			// May eventually want to send this to admin-post.php...
1868
			/**
1869
			 * Filter the contact form action URL.
1870
			 *
1871
			 * @module contact-form
1872
			 *
1873
			 * @since 1.3.1
1874
			 *
1875
			 * @param string $contact_form_id Contact form post URL.
1876
			 * @param $post $GLOBALS['post'] Post global variable.
0 ignored issues
show
Documentation introduced by
The doc-type $post could not be parsed: Unknown type name "$post" at position 0. (view supported doc-types)

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

Loading history...
1877
			 * @param int $id Contact Form ID.
1878
			 */
1879
			$url = apply_filters( 'grunion_contact_form_form_action', "{$url}#contact-form-{$id}", $GLOBALS['post'], $id );
1880
1881
			$r .= "<form action='" . esc_url( $url ) . "' method='post' class='contact-form commentsblock'>\n";
1882
			$r .= $form->body;
1883
			$r .= "\t<p class='contact-submit'>\n";
1884
1885
			/**
1886
			 * Filter the contact form submit button class attribute.
1887
			 *
1888
			 * @module contact-form
1889
			 *
1890
			 * @since 6.6.0
1891
			 *
1892
			 * @param string $class Additional CSS classes for button attribute.
1893
			 */
1894
			$submit_button_class = apply_filters( 'jetpack_contact_form_submit_button_class', 'pushbutton-wide' );
1895
1896
			$r .= "\t\t<input type='submit' value='" . esc_attr( $form->get_attribute( 'submit_button_text' ) ) . "' class='" . $submit_button_class . "'/>\n";
1897
			if ( is_user_logged_in() ) {
1898
				$r .= "\t\t" . wp_nonce_field( 'contact-form_' . $id, '_wpnonce', true, false ) . "\n"; // nonce and referer
1899
			}
1900
			$r .= "\t\t<input type='hidden' name='contact-form-id' value='$id' />\n";
1901
			$r .= "\t\t<input type='hidden' name='action' value='grunion-contact-form' />\n";
1902
			$r .= "\t\t<input type='hidden' name='contact-form-hash' value='" . esc_attr( $form->hash ) . "' />\n";
1903
			$r .= "\t</p>\n";
1904
			$r .= "</form>\n";
1905
		}
1906
1907
		$r .= '</div>';
1908
1909
		return $r;
1910
	}
1911
1912
	/**
1913
	 * Returns a success message to be returned if the form is sent via AJAX.
1914
	 *
1915
	 * @param int                         $feedback_id
1916
	 * @param object Grunion_Contact_Form $form
1917
	 *
1918
	 * @return string $message
1919
	 */
1920
	static function success_message( $feedback_id, $form ) {
1921
		return wp_kses(
1922
			'<blockquote class="contact-form-submission">'
1923
			. '<p>' . join( self::get_compiled_form( $feedback_id, $form ), '</p><p>' ) . '</p>'
1924
			. '</blockquote>',
1925
			array(
1926
				'br'         => array(),
1927
				'blockquote' => array( 'class' => array() ),
1928
				'p'          => array(),
1929
			)
1930
		);
1931
	}
1932
1933
	/**
1934
	 * Returns a compiled form with labels and values in a form of  an array
1935
	 * of lines.
1936
	 *
1937
	 * @param int                         $feedback_id
1938
	 * @param object Grunion_Contact_Form $form
1939
	 *
1940
	 * @return array $lines
1941
	 */
1942
	static function get_compiled_form( $feedback_id, $form ) {
1943
		$feedback       = get_post( $feedback_id );
1944
		$field_ids      = $form->get_field_ids();
1945
		$content_fields = Grunion_Contact_Form_Plugin::parse_fields_from_content( $feedback_id );
1946
1947
		// Maps field_ids to post_meta keys
1948
		$field_value_map = array(
1949
			'name'     => 'author',
1950
			'email'    => 'author_email',
1951
			'url'      => 'author_url',
1952
			'subject'  => 'subject',
1953
			'textarea' => false, // not a post_meta key.  This is stored in post_content
1954
		);
1955
1956
		$compiled_form = array();
1957
1958
		// "Standard" field whitelist
1959
		foreach ( $field_value_map as $type => $meta_key ) {
1960
			if ( isset( $field_ids[ $type ] ) ) {
1961
				$field = $form->fields[ $field_ids[ $type ] ];
1962
1963
				if ( $meta_key ) {
0 ignored issues
show
Bug Best Practice introduced by
The expression $meta_key of type string|false is loosely compared to true; this is ambiguous if the string can be empty. You might want to explicitly use !== false instead.

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

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

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

// It is often better to use strict comparison
'' === false // false
'' === null  // false
Loading history...
1964
					if ( isset( $content_fields[ "_feedback_{$meta_key}" ] ) ) {
1965
						$value = $content_fields[ "_feedback_{$meta_key}" ];
1966
					}
1967
				} else {
1968
					// The feedback content is stored as the first "half" of post_content
1969
					$value         = $feedback->post_content;
1970
					list( $value ) = explode( '<!--more-->', $value );
1971
					$value         = trim( $value );
1972
				}
1973
1974
				$field_index                   = array_search( $field_ids[ $type ], $field_ids['all'] );
1975
				$compiled_form[ $field_index ] = sprintf(
1976
					'<b>%1$s:</b> %2$s<br /><br />',
1977
					wp_kses( $field->get_attribute( 'label' ), array() ),
1978
					nl2br( wp_kses( $value, array() ) )
0 ignored issues
show
Bug introduced by
The variable $value does not seem to be defined for all execution paths leading up to this point.

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

Let’s take a look at an example:

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

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

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

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

Available Fixes

  1. Check for existence of the variable explicitly:

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

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

    function myFunction($a) {
        switch ($a) {
            case 'foo':
                $x = 1;
                break;
    
            case 'bar':
                $x = 2;
                break;
    
            // We add support for the missing case.
            default:
                $x = '';
                break;
        }
    
        echo $x;
    }
    
Loading history...
1979
				);
1980
			}
1981
		}
1982
1983
		// "Non-standard" fields
1984
		if ( $field_ids['extra'] ) {
1985
			// array indexed by field label (not field id)
1986
			$extra_fields = get_post_meta( $feedback_id, '_feedback_extra_fields', true );
1987
1988
			/**
1989
			 * Only get data for the compiled form if `$extra_fields` is a valid and non-empty array.
1990
			 */
1991
			if ( is_array( $extra_fields ) && ! empty( $extra_fields ) ) {
1992
1993
				$extra_field_keys = array_keys( $extra_fields );
1994
1995
				$i = 0;
1996
				foreach ( $field_ids['extra'] as $field_id ) {
1997
					$field       = $form->fields[ $field_id ];
1998
					$field_index = array_search( $field_id, $field_ids['all'] );
1999
2000
					$label = $field->get_attribute( 'label' );
2001
2002
					$compiled_form[ $field_index ] = sprintf(
2003
						'<b>%1$s:</b> %2$s<br /><br />',
2004
						wp_kses( $label, array() ),
2005
						nl2br( wp_kses( $extra_fields[ $extra_field_keys[ $i ] ], array() ) )
2006
					);
2007
2008
					$i++;
2009
				}
2010
			}
2011
		}
2012
2013
		// Sorting lines by the field index
2014
		ksort( $compiled_form );
2015
2016
		return $compiled_form;
2017
	}
2018
2019
	/**
2020
	 * The contact-field shortcode processor
2021
	 * 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.
2022
	 *
2023
	 * @param array       $attributes Key => Value pairs as parsed by shortcode_parse_atts()
2024
	 * @param string|null $content The shortcode's inner content: [contact-field]$content[/contact-field]
2025
	 * @return HTML for the contact form field
2026
	 */
2027
	static function parse_contact_field( $attributes, $content ) {
2028
		// Don't try to parse contact form fields if not inside a contact form
2029
		if ( ! Grunion_Contact_Form_Plugin::$using_contact_form_field ) {
0 ignored issues
show
Bug introduced by
The property using_contact_form_field cannot be accessed from this context as it is declared private in class Grunion_Contact_Form_Plugin.

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

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

Loading history...
2030
			$att_strs = array();
2031
			foreach ( $attributes as $att => $val ) {
2032
				if ( is_numeric( $att ) ) { // Is a valueless attribute
2033
					$att_strs[] = esc_html( $val );
2034
				} elseif ( isset( $val ) ) { // A regular attr - value pair
2035
					$att_strs[] = esc_html( $att ) . '=\'' . esc_html( $val ) . '\'';
2036
				}
2037
			}
2038
2039
			$html = '[contact-field ' . implode( ' ', $att_strs );
2040
2041
			if ( isset( $content ) && ! empty( $content ) ) { // If there is content, let's add a closing tag
2042
				$html .= ']' . esc_html( $content ) . '[/contact-field]';
2043
			} else { // Otherwise let's add a closing slash in the first tag
2044
				$html .= '/]';
2045
			}
2046
2047
			return $html;
2048
		}
2049
2050
		$form = Grunion_Contact_Form::$current_form;
2051
2052
		$field = new Grunion_Contact_Form_Field( $attributes, $content, $form );
2053
2054
		$field_id = $field->get_attribute( 'id' );
2055
		if ( $field_id ) {
2056
			$form->fields[ $field_id ] = $field;
2057
		} else {
2058
			$form->fields[] = $field;
2059
		}
2060
2061
		if (
2062
			isset( $_POST['action'] ) && 'grunion-contact-form' === $_POST['action']
2063
		&&
2064
			isset( $_POST['contact-form-id'] ) && $form->get_attribute( 'id' ) == $_POST['contact-form-id']
2065
		&&
2066
			isset( $_POST['contact-form-hash'] ) && hash_equals( $form->hash, $_POST['contact-form-hash'] )
2067
		) {
2068
			// If we're processing a POST submission for this contact form, validate the field value so we can show errors as necessary.
2069
			$field->validate();
2070
		}
2071
2072
		// Output HTML
2073
		return $field->render();
2074
	}
2075
2076
	/**
2077
	 * Loops through $this->fields to generate a (structured) list of field IDs.
2078
	 *
2079
	 * Important: Currently the whitelisted fields are defined as follows:
2080
	 *  `name`, `email`, `url`, `subject`, `textarea`
2081
	 *
2082
	 * If you need to add new fields to the Contact Form, please don't add them
2083
	 * to the whitelisted fields and leave them as extra fields.
2084
	 *
2085
	 * The reasoning behind this is that both the admin Feedback view and the CSV
2086
	 * export will not include any fields that are added to the list of
2087
	 * whitelisted fields without taking proper care to add them to all the
2088
	 * other places where they accessed/used/saved.
2089
	 *
2090
	 * The safest way to add new fields is to add them to the dropdown and the
2091
	 * HTML list ( @see Grunion_Contact_Form_Field::render ) and don't add them
2092
	 * to the list of whitelisted fields. This way they will become a part of the
2093
	 * `extra fields` which are saved in the post meta and will be properly
2094
	 * handled by the admin Feedback view and the CSV Export without any extra
2095
	 * work.
2096
	 *
2097
	 * If there is need to add a field to the whitelisted fields, then please
2098
	 * take proper care to add logic to handle the field in the following places:
2099
	 *
2100
	 *  - Below in the switch statement - so the field is recognized as whitelisted.
2101
	 *
2102
	 *  - Grunion_Contact_Form::process_submission - validation and logic.
2103
	 *
2104
	 *  - Grunion_Contact_Form::process_submission - add the field as an additional
2105
	 *      field in the `post_content` when saving the feedback content.
2106
	 *
2107
	 *  - Grunion_Contact_Form_Plugin::parse_fields_from_content - add mapping
2108
	 *      for the field, defined in the above method.
2109
	 *
2110
	 *  - Grunion_Contact_Form_Plugin::map_parsed_field_contents_of_post_to_field_names -
2111
	 *      add mapping of the field for the CSV Export. Otherwise it will be missing
2112
	 *      from the exported data.
2113
	 *
2114
	 *  - admin.php / grunion_manage_post_columns - add the field to the render logic.
2115
	 *      Otherwise it will be missing from the admin Feedback view.
2116
	 *
2117
	 * @return array
2118
	 */
2119
	function get_field_ids() {
2120
		$field_ids = array(
2121
			'all'   => array(), // array of all field_ids
2122
			'extra' => array(), // array of all non-whitelisted field IDs
2123
2124
			// Whitelisted "standard" field IDs:
2125
			// 'email'    => field_id,
2126
			// 'name'     => field_id,
2127
			// 'url'      => field_id,
2128
			// 'subject'  => field_id,
2129
			// 'textarea' => field_id,
2130
		);
2131
2132
		foreach ( $this->fields as $id => $field ) {
2133
			$field_ids['all'][] = $id;
2134
2135
			$type = $field->get_attribute( 'type' );
2136
			if ( isset( $field_ids[ $type ] ) ) {
2137
				// This type of field is already present in our whitelist of "standard" fields for this form
2138
				// Put it in extra
2139
				$field_ids['extra'][] = $id;
2140
				continue;
2141
			}
2142
2143
			/**
2144
			 * See method description before modifying the switch cases.
2145
			 */
2146
			switch ( $type ) {
2147
				case 'email':
2148
				case 'name':
2149
				case 'url':
2150
				case 'subject':
2151
				case 'textarea':
2152
					$field_ids[ $type ] = $id;
2153
					break;
2154
				default:
2155
					// Put everything else in extra
2156
					$field_ids['extra'][] = $id;
2157
			}
2158
		}
2159
2160
		return $field_ids;
2161
	}
2162
2163
	/**
2164
	 * Process the contact form's POST submission
2165
	 * Stores feedback.  Sends email.
2166
	 */
2167
	function process_submission() {
2168
		global $post;
2169
2170
		$plugin = Grunion_Contact_Form_Plugin::init();
2171
2172
		$id     = $this->get_attribute( 'id' );
2173
		$to     = $this->get_attribute( 'to' );
2174
		$widget = $this->get_attribute( 'widget' );
2175
2176
		$contact_form_subject = $this->get_attribute( 'subject' );
2177
2178
		$to     = str_replace( ' ', '', $to );
2179
		$emails = explode( ',', $to );
2180
2181
		$valid_emails = array();
2182
2183
		foreach ( (array) $emails as $email ) {
2184
			if ( ! is_email( $email ) ) {
2185
				continue;
2186
			}
2187
2188
			if ( function_exists( 'is_email_address_unsafe' ) && is_email_address_unsafe( $email ) ) {
2189
				continue;
2190
			}
2191
2192
			$valid_emails[] = $email;
2193
		}
2194
2195
		// No one to send it to, which means none of the "to" attributes are valid emails.
2196
		// Use default email instead.
2197
		if ( ! $valid_emails ) {
0 ignored issues
show
Bug Best Practice introduced by
The expression $valid_emails of type array is implicitly converted to a boolean; are you sure this is intended? If so, consider using empty($expr) instead to make it clear that you intend to check for an array without elements.

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

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

Loading history...
2198
			$valid_emails = $this->defaults['to'];
2199
		}
2200
2201
		$to = $valid_emails;
2202
2203
		// Last ditch effort to set a recipient if somehow none have been set.
2204
		if ( empty( $to ) ) {
2205
			$to = get_option( 'admin_email' );
2206
		}
2207
2208
		// Make sure we're processing the form we think we're processing... probably a redundant check.
2209
		if ( $widget ) {
2210
			if ( 'widget-' . $widget != $_POST['contact-form-id'] ) {
2211
				return false;
2212
			}
2213
		} else {
2214
			if ( $post->ID != $_POST['contact-form-id'] ) {
2215
				return false;
2216
			}
2217
		}
2218
2219
		$field_ids = $this->get_field_ids();
2220
2221
		// Initialize all these "standard" fields to null
2222
		$comment_author_email = $comment_author_email_label = // v
2223
		$comment_author       = $comment_author_label       = // v
2224
		$comment_author_url   = $comment_author_url_label   = // v
2225
		$comment_content      = $comment_content_label = null;
2226
2227
		// For each of the "standard" fields, grab their field label and value.
2228 View Code Duplication
		if ( isset( $field_ids['name'] ) ) {
2229
			$field          = $this->fields[ $field_ids['name'] ];
2230
			$comment_author = Grunion_Contact_Form_Plugin::strip_tags(
2231
				stripslashes(
2232
					/** This filter is already documented in core/wp-includes/comment-functions.php */
2233
					apply_filters( 'pre_comment_author_name', addslashes( $field->value ) )
2234
				)
2235
			);
2236
			$comment_author_label = Grunion_Contact_Form_Plugin::strip_tags( $field->get_attribute( 'label' ) );
2237
		}
2238
2239 View Code Duplication
		if ( isset( $field_ids['email'] ) ) {
2240
			$field                = $this->fields[ $field_ids['email'] ];
2241
			$comment_author_email = Grunion_Contact_Form_Plugin::strip_tags(
2242
				stripslashes(
2243
					/** This filter is already documented in core/wp-includes/comment-functions.php */
2244
					apply_filters( 'pre_comment_author_email', addslashes( $field->value ) )
2245
				)
2246
			);
2247
			$comment_author_email_label = Grunion_Contact_Form_Plugin::strip_tags( $field->get_attribute( 'label' ) );
2248
		}
2249
2250
		if ( isset( $field_ids['url'] ) ) {
2251
			$field              = $this->fields[ $field_ids['url'] ];
2252
			$comment_author_url = Grunion_Contact_Form_Plugin::strip_tags(
2253
				stripslashes(
2254
					/** This filter is already documented in core/wp-includes/comment-functions.php */
2255
					apply_filters( 'pre_comment_author_url', addslashes( $field->value ) )
2256
				)
2257
			);
2258
			if ( 'http://' == $comment_author_url ) {
2259
				$comment_author_url = '';
2260
			}
2261
			$comment_author_url_label = Grunion_Contact_Form_Plugin::strip_tags( $field->get_attribute( 'label' ) );
2262
		}
2263
2264
		if ( isset( $field_ids['textarea'] ) ) {
2265
			$field                 = $this->fields[ $field_ids['textarea'] ];
2266
			$comment_content       = trim( Grunion_Contact_Form_Plugin::strip_tags( $field->value ) );
2267
			$comment_content_label = Grunion_Contact_Form_Plugin::strip_tags( $field->get_attribute( 'label' ) );
2268
		}
2269
2270
		if ( isset( $field_ids['subject'] ) ) {
2271
			$field = $this->fields[ $field_ids['subject'] ];
2272
			if ( $field->value ) {
2273
				$contact_form_subject = Grunion_Contact_Form_Plugin::strip_tags( $field->value );
2274
			}
2275
		}
2276
2277
		$all_values = $extra_values = array();
2278
		$i          = 1; // Prefix counter for stored metadata
2279
2280
		// For all fields, grab label and value
2281
		foreach ( $field_ids['all'] as $field_id ) {
2282
			$field = $this->fields[ $field_id ];
2283
			$label = $i . '_' . $field->get_attribute( 'label' );
2284
			$value = $field->value;
2285
2286
			$all_values[ $label ] = $value;
2287
			$i++; // Increment prefix counter for the next field
2288
		}
2289
2290
		// For the "non-standard" fields, grab label and value
2291
		// Extra fields have their prefix starting from count( $all_values ) + 1
2292
		foreach ( $field_ids['extra'] as $field_id ) {
2293
			$field = $this->fields[ $field_id ];
2294
			$label = $i . '_' . $field->get_attribute( 'label' );
2295
			$value = $field->value;
2296
2297
			if ( is_array( $value ) ) {
2298
				$value = implode( ', ', $value );
2299
			}
2300
2301
			$extra_values[ $label ] = $value;
2302
			$i++; // Increment prefix counter for the next extra field
2303
		}
2304
2305
		$contact_form_subject = trim( $contact_form_subject );
2306
2307
		$comment_author_IP = Grunion_Contact_Form_Plugin::get_ip_address();
2308
2309
		$vars = array( 'comment_author', 'comment_author_email', 'comment_author_url', 'contact_form_subject', 'comment_author_IP' );
2310
		foreach ( $vars as $var ) {
2311
			$$var = str_replace( array( "\n", "\r" ), '', $$var );
2312
		}
2313
2314
		// Ensure that Akismet gets all of the relevant information from the contact form,
2315
		// not just the textarea field and predetermined subject.
2316
		$akismet_vars                    = compact( $vars );
2317
		$akismet_vars['comment_content'] = $comment_content;
2318
2319
		foreach ( array_merge( $field_ids['all'], $field_ids['extra'] ) as $field_id ) {
2320
			$field = $this->fields[ $field_id ];
2321
2322
			// Skip any fields that are just a choice from a pre-defined list. They wouldn't have any value
2323
			// from a spam-filtering point of view.
2324
			if ( in_array( $field->get_attribute( 'type' ), array( 'select', 'checkbox', 'checkbox-multiple', 'radio' ) ) ) {
2325
				continue;
2326
			}
2327
2328
			// Normalize the label into a slug.
2329
			$field_slug = trim( // Strip all leading/trailing dashes.
2330
				preg_replace(   // Normalize everything to a-z0-9_-
2331
					'/[^a-z0-9_]+/',
2332
					'-',
2333
					strtolower( $field->get_attribute( 'label' ) ) // Lowercase
2334
				),
2335
				'-'
2336
			);
2337
2338
			$field_value = ( is_array( $field->value ) ) ? trim( implode( ', ', $field->value ) ) : trim( $field->value );
2339
2340
			// Skip any values that are already in the array we're sending.
2341
			if ( $field_value && in_array( $field_value, $akismet_vars ) ) {
2342
				continue;
2343
			}
2344
2345
			$akismet_vars[ 'contact_form_field_' . $field_slug ] = $field_value;
2346
		}
2347
2348
		$spam           = '';
2349
		$akismet_values = $plugin->prepare_for_akismet( $akismet_vars );
2350
2351
		// Is it spam?
2352
		/** This filter is already documented in modules/contact-form/admin.php */
2353
		$is_spam = apply_filters( 'jetpack_contact_form_is_spam', false, $akismet_values );
2354
		if ( is_wp_error( $is_spam ) ) { // WP_Error to abort
2355
			return $is_spam; // abort
2356
		} elseif ( $is_spam === true ) {  // TRUE to flag a spam
2357
			$spam = '***SPAM*** ';
2358
		}
2359
2360
		if ( ! $comment_author ) {
2361
			$comment_author = $comment_author_email;
2362
		}
2363
2364
		/**
2365
		 * Filter the email where a submitted feedback is sent.
2366
		 *
2367
		 * @module contact-form
2368
		 *
2369
		 * @since 1.3.1
2370
		 *
2371
		 * @param string|array $to Array of valid email addresses, or single email address.
2372
		 */
2373
		$to            = (array) apply_filters( 'contact_form_to', $to );
2374
		$reply_to_addr = $to[0]; // get just the address part before the name part is added
2375
2376
		foreach ( $to as $to_key => $to_value ) {
2377
			$to[ $to_key ] = Grunion_Contact_Form_Plugin::strip_tags( $to_value );
2378
			$to[ $to_key ] = self::add_name_to_address( $to_value );
2379
		}
2380
2381
		$blog_url        = parse_url( site_url() );
2382
		$from_email_addr = 'wordpress@' . $blog_url['host'];
2383
2384
		if ( ! empty( $comment_author_email ) ) {
2385
			$reply_to_addr = $comment_author_email;
2386
		}
2387
2388
		$headers = 'From: "' . $comment_author . '" <' . $from_email_addr . ">\r\n" .
2389
					'Reply-To: "' . $comment_author . '" <' . $reply_to_addr . ">\r\n";
2390
2391
		// Build feedback reference
2392
		$feedback_time  = current_time( 'mysql' );
2393
		$feedback_title = "{$comment_author} - {$feedback_time}";
2394
		$feedback_id    = md5( $feedback_title );
2395
2396
		$all_values = array_merge(
2397
			$all_values, array(
2398
				'entry_title'     => the_title_attribute( 'echo=0' ),
2399
				'entry_permalink' => esc_url( get_permalink( get_the_ID() ) ),
2400
				'feedback_id'     => $feedback_id,
2401
			)
2402
		);
2403
2404
		/** This filter is already documented in modules/contact-form/admin.php */
2405
		$subject = apply_filters( 'contact_form_subject', $contact_form_subject, $all_values );
2406
		$url     = $widget ? home_url( '/' ) : get_permalink( $post->ID );
2407
2408
		$date_time_format = _x( '%1$s \a\t %2$s', '{$date_format} \a\t {$time_format}', 'jetpack' );
2409
		$date_time_format = sprintf( $date_time_format, get_option( 'date_format' ), get_option( 'time_format' ) );
2410
		$time             = date_i18n( $date_time_format, current_time( 'timestamp' ) );
2411
2412
		// keep a copy of the feedback as a custom post type
2413
		$feedback_status = $is_spam === true ? 'spam' : 'publish';
2414
2415
		foreach ( (array) $akismet_values as $av_key => $av_value ) {
2416
			$akismet_values[ $av_key ] = Grunion_Contact_Form_Plugin::strip_tags( $av_value );
2417
		}
2418
2419
		foreach ( (array) $all_values as $all_key => $all_value ) {
2420
			$all_values[ $all_key ] = Grunion_Contact_Form_Plugin::strip_tags( $all_value );
2421
		}
2422
2423
		foreach ( (array) $extra_values as $ev_key => $ev_value ) {
2424
			$extra_values[ $ev_key ] = Grunion_Contact_Form_Plugin::strip_tags( $ev_value );
2425
		}
2426
2427
		/*
2428
		 We need to make sure that the post author is always zero for contact
2429
		 * form submissions.  This prevents export/import from trying to create
2430
		 * new users based on form submissions from people who were logged in
2431
		 * at the time.
2432
		 *
2433
		 * Unfortunately wp_insert_post() tries very hard to make sure the post
2434
		 * author gets the currently logged in user id.  That is how we ended up
2435
		 * with this work around. */
2436
		add_filter( 'wp_insert_post_data', array( $plugin, 'insert_feedback_filter' ), 10, 2 );
2437
2438
		$post_id = wp_insert_post(
2439
			array(
2440
				'post_date'    => addslashes( $feedback_time ),
2441
				'post_type'    => 'feedback',
2442
				'post_status'  => addslashes( $feedback_status ),
2443
				'post_parent'  => (int) $post->ID,
2444
				'post_title'   => addslashes( wp_kses( $feedback_title, array() ) ),
2445
				'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
2446
				'post_name'    => $feedback_id,
2447
			)
2448
		);
2449
2450
		// once insert has finished we don't need this filter any more
2451
		remove_filter( 'wp_insert_post_data', array( $plugin, 'insert_feedback_filter' ), 10 );
2452
2453
		update_post_meta( $post_id, '_feedback_extra_fields', $this->addslashes_deep( $extra_values ) );
2454
2455
		if ( 'publish' == $feedback_status ) {
2456
			// Increase count of unread feedback.
2457
			$unread = get_option( 'feedback_unread_count', 0 ) + 1;
2458
			update_option( 'feedback_unread_count', $unread );
2459
		}
2460
2461
		if ( defined( 'AKISMET_VERSION' ) ) {
2462
			update_post_meta( $post_id, '_feedback_akismet_values', $this->addslashes_deep( $akismet_values ) );
2463
		}
2464
2465
		$message = self::get_compiled_form( $post_id, $this );
2466
2467
		array_push(
2468
			$message,
2469
			'<br />',
2470
			'<hr />',
2471
			__( 'Time:', 'jetpack' ) . ' ' . $time . '<br />',
2472
			__( 'IP Address:', 'jetpack' ) . ' ' . $comment_author_IP . '<br />',
2473
			__( 'Contact Form URL:', 'jetpack' ) . ' ' . $url . '<br />'
2474
		);
2475
2476
		if ( is_user_logged_in() ) {
2477
			array_push(
2478
				$message,
2479
				sprintf(
2480
					'<p>' . __( 'Sent by a verified %s user.', 'jetpack' ) . '</p>',
2481
					isset( $GLOBALS['current_site']->site_name ) && $GLOBALS['current_site']->site_name ?
2482
						$GLOBALS['current_site']->site_name : '"' . get_option( 'blogname' ) . '"'
2483
				)
2484
			);
2485
		} else {
2486
			array_push( $message, '<p>' . __( 'Sent by an unverified visitor to your site.', 'jetpack' ) . '</p>' );
2487
		}
2488
2489
		$message = join( $message, '' );
2490
2491
		/**
2492
		 * Filters the message sent via email after a successful form submission.
2493
		 *
2494
		 * @module contact-form
2495
		 *
2496
		 * @since 1.3.1
2497
		 *
2498
		 * @param string $message Feedback email message.
2499
		 */
2500
		$message = apply_filters( 'contact_form_message', $message );
2501
2502
		// This is called after `contact_form_message`, in order to preserve back-compat
2503
		$message = self::wrap_message_in_html_tags( $message );
2504
2505
		update_post_meta( $post_id, '_feedback_email', $this->addslashes_deep( compact( 'to', 'message' ) ) );
2506
2507
		/**
2508
		 * Fires right before the contact form message is sent via email to
2509
		 * the recipient specified in the contact form.
2510
		 *
2511
		 * @module contact-form
2512
		 *
2513
		 * @since 1.3.1
2514
		 *
2515
		 * @param integer $post_id Post contact form lives on
2516
		 * @param array $all_values Contact form fields
2517
		 * @param array $extra_values Contact form fields not included in $all_values
2518
		 */
2519
		do_action( 'grunion_pre_message_sent', $post_id, $all_values, $extra_values );
2520
2521
		// schedule deletes of old spam feedbacks
2522
		if ( ! wp_next_scheduled( 'grunion_scheduled_delete' ) ) {
2523
			wp_schedule_event( time() + 250, 'daily', 'grunion_scheduled_delete' );
2524
		}
2525
2526
		if (
2527
			$is_spam !== true &&
2528
			/**
2529
			 * Filter to choose whether an email should be sent after each successful contact form submission.
2530
			 *
2531
			 * @module contact-form
2532
			 *
2533
			 * @since 2.6.0
2534
			 *
2535
			 * @param bool true Should an email be sent after a form submission. Default to true.
2536
			 * @param int $post_id Post ID.
2537
			 */
2538
			true === apply_filters( 'grunion_should_send_email', true, $post_id )
2539
		) {
2540
			self::wp_mail( $to, "{$spam}{$subject}", $message, $headers );
2541
		} elseif (
2542
			true === $is_spam &&
2543
			/**
2544
			 * Choose whether an email should be sent for each spam contact form submission.
2545
			 *
2546
			 * @module contact-form
2547
			 *
2548
			 * @since 1.3.1
2549
			 *
2550
			 * @param bool false Should an email be sent after a spam form submission. Default to false.
2551
			 */
2552
			apply_filters( 'grunion_still_email_spam', false ) == true
2553
		) { // don't send spam by default.  Filterable.
2554
			self::wp_mail( $to, "{$spam}{$subject}", $message, $headers );
2555
		}
2556
2557
		if ( defined( 'DOING_AJAX' ) && DOING_AJAX ) {
2558
			return self::success_message( $post_id, $this );
2559
		}
2560
2561
		$redirect = wp_get_referer();
2562
		if ( ! $redirect ) { // wp_get_referer() returns false if the referer is the same as the current page
2563
			$redirect = $_SERVER['REQUEST_URI'];
2564
		}
2565
2566
		$redirect = add_query_arg(
2567
			urlencode_deep(
2568
				array(
2569
					'contact-form-id'   => $id,
2570
					'contact-form-sent' => $post_id,
2571
					'contact-form-hash' => $this->hash,
2572
					'_wpnonce'          => wp_create_nonce( "contact-form-sent-{$post_id}" ), // wp_nonce_url HTMLencodes :(
2573
				)
2574
			), $redirect
2575
		);
2576
2577
		/**
2578
		 * Filter the URL where the reader is redirected after submitting a form.
2579
		 *
2580
		 * @module contact-form
2581
		 *
2582
		 * @since 1.9.0
2583
		 *
2584
		 * @param string $redirect Post submission URL.
2585
		 * @param int $id Contact Form ID.
2586
		 * @param int $post_id Post ID.
2587
		 */
2588
		$redirect = apply_filters( 'grunion_contact_form_redirect_url', $redirect, $id, $post_id );
2589
2590
		wp_safe_redirect( $redirect );
2591
		exit;
2592
	}
2593
2594
	/**
2595
	 * Wrapper for wp_mail() that enables HTML messages with text alternatives
2596
	 *
2597
	 * @param string|array $to          Array or comma-separated list of email addresses to send message.
2598
	 * @param string       $subject     Email subject.
2599
	 * @param string       $message     Message contents.
2600
	 * @param string|array $headers     Optional. Additional headers.
2601
	 * @param string|array $attachments Optional. Files to attach.
2602
	 *
2603
	 * @return bool Whether the email contents were sent successfully.
2604
	 */
2605
	public static function wp_mail( $to, $subject, $message, $headers = '', $attachments = array() ) {
2606
		add_filter( 'wp_mail_content_type', __CLASS__ . '::get_mail_content_type' );
2607
		add_action( 'phpmailer_init', __CLASS__ . '::add_plain_text_alternative' );
2608
2609
		$result = wp_mail( $to, $subject, $message, $headers, $attachments );
2610
2611
		remove_filter( 'wp_mail_content_type', __CLASS__ . '::get_mail_content_type' );
2612
		remove_action( 'phpmailer_init', __CLASS__ . '::add_plain_text_alternative' );
2613
2614
		return $result;
2615
	}
2616
2617
	/**
2618
	 * Add a display name part to an email address
2619
	 *
2620
	 * SpamAssassin doesn't like addresses in HTML messages that are missing display names (e.g., `[email protected]`
2621
	 * instead of `"Foo Bar" <[email protected]>`.
2622
	 *
2623
	 * @param string $address
2624
	 *
2625
	 * @return string
2626
	 */
2627
	function add_name_to_address( $address ) {
2628
		// If it's just the address, without a display name
2629
		if ( is_email( $address ) ) {
2630
			$address_parts = explode( '@', $address );
2631
			$address       = sprintf( '"%s" <%s>', $address_parts[0], $address );
2632
		}
2633
2634
		return $address;
2635
	}
2636
2637
	/**
2638
	 * Get the content type that should be assigned to outbound emails
2639
	 *
2640
	 * @return string
2641
	 */
2642
	static function get_mail_content_type() {
2643
		return 'text/html';
2644
	}
2645
2646
	/**
2647
	 * Wrap a message body with the appropriate in HTML tags
2648
	 *
2649
	 * This helps to ensure correct parsing by clients, and also helps avoid triggering spam filtering rules
2650
	 *
2651
	 * @param string $body
2652
	 *
2653
	 * @return string
2654
	 */
2655
	static function wrap_message_in_html_tags( $body ) {
2656
		// Don't do anything if the message was already wrapped in HTML tags
2657
		// That could have be done by a plugin via filters
2658
		if ( false !== strpos( $body, '<html' ) ) {
2659
			return $body;
2660
		}
2661
2662
		$html_message = sprintf(
2663
			// The tabs are just here so that the raw code is correctly formatted for developers
2664
			// They're removed so that they don't affect the final message sent to users
2665
			str_replace(
2666
				"\t", '',
2667
				'<!doctype html>
2668
				<html xmlns="http://www.w3.org/1999/xhtml">
2669
				<body>
2670
2671
				%s
2672
2673
				</body>
2674
				</html>'
2675
			),
2676
			$body
2677
		);
2678
2679
		return $html_message;
2680
	}
2681
2682
	/**
2683
	 * Add a plain-text alternative part to an outbound email
2684
	 *
2685
	 * This makes the message more accessible to mail clients that aren't HTML-aware, and decreases the likelihood
2686
	 * that the message will be flagged as spam.
2687
	 *
2688
	 * @param PHPMailer $phpmailer
2689
	 */
2690
	static function add_plain_text_alternative( $phpmailer ) {
2691
		// Add an extra break so that the extra space above the <p> is preserved after the <p> is stripped out
2692
		$alt_body = str_replace( '<p>', '<p><br />', $phpmailer->Body );
2693
2694
		// Convert <br> to \n breaks, to preserve the space between lines that we want to keep
2695
		$alt_body = str_replace( array( '<br>', '<br />' ), "\n", $alt_body );
2696
2697
		// Convert <hr> to an plain-text equivalent, to preserve the integrity of the message
2698
		$alt_body = str_replace( array( '<hr>', '<hr />' ), "----\n", $alt_body );
2699
2700
		// Trim the plain text message to remove the \n breaks that were after <doctype>, <html>, and <body>
2701
		$phpmailer->AltBody = trim( strip_tags( $alt_body ) );
2702
	}
2703
2704
	function addslashes_deep( $value ) {
2705
		if ( is_array( $value ) ) {
2706
			return array_map( array( $this, 'addslashes_deep' ), $value );
2707
		} elseif ( is_object( $value ) ) {
2708
			$vars = get_object_vars( $value );
2709
			foreach ( $vars as $key => $data ) {
2710
				$value->{$key} = $this->addslashes_deep( $data );
2711
			}
2712
			return $value;
2713
		}
2714
2715
		return addslashes( $value );
2716
	}
2717
}
2718
2719
/**
2720
 * Class for the contact-field shortcode.
2721
 * Parses shortcode to output the contact form field as HTML.
2722
 * Validates input.
2723
 */
2724
class Grunion_Contact_Form_Field extends Crunion_Contact_Form_Shortcode {
2725
	public $shortcode_name = 'contact-field';
2726
2727
	/**
2728
	 * @var Grunion_Contact_Form parent form
2729
	 */
2730
	public $form;
2731
2732
	/**
2733
	 * @var string default or POSTed value
2734
	 */
2735
	public $value;
2736
2737
	/**
2738
	 * @var bool Is the input invalid?
2739
	 */
2740
	public $error = false;
2741
2742
	/**
2743
	 * @param array                $attributes An associative array of shortcode attributes.  @see shortcode_atts()
2744
	 * @param null|string          $content Null for selfclosing shortcodes.  The inner content otherwise.
2745
	 * @param Grunion_Contact_Form $form The parent form
0 ignored issues
show
Documentation introduced by
Should the type for parameter $form not be Grunion_Contact_Form|null?

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

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

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

Loading history...
2746
	 */
2747
	function __construct( $attributes, $content = null, $form = null ) {
2748
		$attributes = shortcode_atts(
2749
			array(
2750
				'label'       => null,
2751
				'type'        => 'text',
2752
				'required'    => false,
2753
				'options'     => array(),
2754
				'id'          => null,
2755
				'default'     => null,
2756
				'values'      => null,
2757
				'placeholder' => null,
2758
				'class'       => null,
2759
			), $attributes, 'contact-field'
2760
		);
2761
2762
		// special default for subject field
2763
		if ( 'subject' == $attributes['type'] && is_null( $attributes['default'] ) && ! is_null( $form ) ) {
2764
			$attributes['default'] = $form->get_attribute( 'subject' );
2765
		}
2766
2767
		// allow required=1 or required=true
2768
		if ( '1' == $attributes['required'] || 'true' == strtolower( $attributes['required'] ) ) {
2769
			$attributes['required'] = true;
2770
		} else {
2771
			$attributes['required'] = false;
2772
		}
2773
2774
		// parse out comma-separated options list (for selects, radios, and checkbox-multiples)
2775
		if ( ! empty( $attributes['options'] ) && is_string( $attributes['options'] ) ) {
2776
			$attributes['options'] = array_map( 'trim', explode( ',', $attributes['options'] ) );
2777
2778 View Code Duplication
			if ( ! empty( $attributes['values'] ) && is_string( $attributes['values'] ) ) {
2779
				$attributes['values'] = array_map( 'trim', explode( ',', $attributes['values'] ) );
2780
			}
2781
		}
2782
2783
		if ( $form ) {
2784
			// make a unique field ID based on the label, with an incrementing number if needed to avoid clashes
2785
			$form_id = $form->get_attribute( 'id' );
2786
			$id      = isset( $attributes['id'] ) ? $attributes['id'] : false;
2787
2788
			$unescaped_label = $this->unesc_attr( $attributes['label'] );
2789
			$unescaped_label = str_replace( '%', '-', $unescaped_label ); // jQuery doesn't like % in IDs?
2790
			$unescaped_label = preg_replace( '/[^a-zA-Z0-9.-_:]/', '', $unescaped_label );
2791
2792
			if ( empty( $id ) ) {
2793
				$id        = sanitize_title_with_dashes( 'g' . $form_id . '-' . $unescaped_label );
2794
				$i         = 0;
2795
				$max_tries = 99;
2796
				while ( isset( $form->fields[ $id ] ) ) {
2797
					$i++;
2798
					$id = sanitize_title_with_dashes( 'g' . $form_id . '-' . $unescaped_label . '-' . $i );
2799
2800
					if ( $i > $max_tries ) {
2801
						break;
2802
					}
2803
				}
2804
			}
2805
2806
			$attributes['id'] = $id;
2807
		}
2808
2809
		parent::__construct( $attributes, $content );
2810
2811
		// Store parent form
2812
		$this->form = $form;
2813
	}
2814
2815
	/**
2816
	 * This field's input is invalid.  Flag as invalid and add an error to the parent form
2817
	 *
2818
	 * @param string $message The error message to display on the form.
2819
	 */
2820
	function add_error( $message ) {
2821
		$this->is_error = true;
0 ignored issues
show
Bug introduced by
The property is_error does not exist. Did you maybe forget to declare it?

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

class MyClass { }

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

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

class MyClass {
    public $foo;
}

$x = new MyClass();
$x->foo = true;
Loading history...
2822
2823
		if ( ! is_wp_error( $this->form->errors ) ) {
2824
			$this->form->errors = new WP_Error;
2825
		}
2826
2827
		$this->form->errors->add( $this->get_attribute( 'id' ), $message );
2828
	}
2829
2830
	/**
2831
	 * Is the field input invalid?
2832
	 *
2833
	 * @see $error
2834
	 *
2835
	 * @return bool
2836
	 */
2837
	function is_error() {
2838
		return $this->error;
2839
	}
2840
2841
	/**
2842
	 * Validates the form input
2843
	 */
2844
	function validate() {
2845
		// If it's not required, there's nothing to validate
2846
		if ( ! $this->get_attribute( 'required' ) ) {
2847
			return;
2848
		}
2849
2850
		$field_id    = $this->get_attribute( 'id' );
2851
		$field_type  = $this->get_attribute( 'type' );
2852
		$field_label = $this->get_attribute( 'label' );
2853
2854
		if ( isset( $_POST[ $field_id ] ) ) {
2855
			if ( is_array( $_POST[ $field_id ] ) ) {
2856
				$field_value = array_map( 'stripslashes', $_POST[ $field_id ] );
2857
			} else {
2858
				$field_value = stripslashes( $_POST[ $field_id ] );
2859
			}
2860
		} else {
2861
			$field_value = '';
2862
		}
2863
2864
		switch ( $field_type ) {
2865
			case 'email':
2866
				// Make sure the email address is valid
2867
				if ( ! is_email( $field_value ) ) {
2868
					/* translators: %s is the name of a form field */
2869
					$this->add_error( sprintf( __( '%s requires a valid email address', 'jetpack' ), $field_label ) );
2870
				}
2871
				break;
2872
			case 'checkbox-multiple':
2873
				// Check that there is at least one option selected
2874
				if ( empty( $field_value ) ) {
2875
					/* translators: %s is the name of a form field */
2876
					$this->add_error( sprintf( __( '%s requires at least one selection', 'jetpack' ), $field_label ) );
2877
				}
2878
				break;
2879
			default:
2880
				// Just check for presence of any text
2881
				if ( ! strlen( trim( $field_value ) ) ) {
2882
					/* translators: %s is the name of a form field */
2883
					$this->add_error( sprintf( __( '%s is required', 'jetpack' ), $field_label ) );
2884
				}
2885
		}
2886
	}
2887
2888
2889
	/**
2890
	 * Check the default value for options field
2891
	 *
2892
	 * @param string value
2893
	 * @param int index
2894
	 * @param string default value
2895
	 *
2896
	 * @return string
2897
	 */
2898
	public function get_option_value( $value, $index, $options ) {
2899
		if ( empty( $value[ $index ] ) ) {
2900
			return $options;
2901
		}
2902
		return $value[ $index ];
2903
	}
2904
2905
	/**
2906
	 * Outputs the HTML for this form field
2907
	 *
2908
	 * @return string HTML
2909
	 */
2910
	function render() {
2911
		global $current_user, $user_identity;
2912
2913
		$r = '';
2914
2915
		$field_id       = $this->get_attribute( 'id' );
2916
		$field_type     = $this->get_attribute( 'type' );
2917
		$field_label    = $this->get_attribute( 'label' );
2918
		$field_required = $this->get_attribute( 'required' );
2919
		$placeholder    = $this->get_attribute( 'placeholder' );
2920
		$class          = 'date' === $field_type ? 'jp-contact-form-date' : $this->get_attribute( 'class' );
2921
2922
		/**
2923
		 * Filters the "class" attribute of the contact form input
2924
		 *
2925
		 * @module contact-form
2926
		 *
2927
		 * @since 6.6.0
2928
		 *
2929
		 * @param string $class Additional CSS classes for input class attribute.
2930
		 */
2931
		$class = apply_filters( 'jetpack_contact_form_input_class', $class );
2932
2933
		$field_placeholder = ( ! empty( $placeholder ) ) ? "placeholder='" . esc_attr( $placeholder ) . "'" : '';
2934
		$field_class       = "class='" . trim( esc_attr( $field_type ) . ' ' . esc_attr( $class ) ) . "' ";
2935
2936
		if ( isset( $_POST[ $field_id ] ) ) {
2937
			if ( is_array( $_POST[ $field_id ] ) ) {
2938
				$this->value = array_map( 'stripslashes', $_POST[ $field_id ] );
0 ignored issues
show
Documentation Bug introduced by
It seems like array_map('stripslashes', $_POST[$field_id]) of type array is incompatible with the declared type string of property $value.

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

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

Loading history...
2939
			} else {
2940
				$this->value = stripslashes( (string) $_POST[ $field_id ] );
2941
			}
2942
		} elseif ( isset( $_GET[ $field_id ] ) ) {
2943
			$this->value = stripslashes( (string) $_GET[ $field_id ] );
2944
		} elseif (
2945
			is_user_logged_in() &&
2946
			( ( defined( 'IS_WPCOM' ) && IS_WPCOM ) ||
2947
			/**
2948
			 * Allow third-party tools to prefill the contact form with the user's details when they're logged in.
2949
			 *
2950
			 * @module contact-form
2951
			 *
2952
			 * @since 3.2.0
2953
			 *
2954
			 * @param bool false Should the Contact Form be prefilled with your details when you're logged in. Default to false.
2955
			 */
2956
			true === apply_filters( 'jetpack_auto_fill_logged_in_user', false )
2957
			)
2958
		) {
2959
			// Special defaults for logged-in users
2960
			switch ( $this->get_attribute( 'type' ) ) {
2961
				case 'email':
2962
					$this->value = $current_user->data->user_email;
2963
					break;
2964
				case 'name':
2965
					$this->value = $user_identity;
2966
					break;
2967
				case 'url':
2968
					$this->value = $current_user->data->user_url;
2969
					break;
2970
				default:
2971
					$this->value = $this->get_attribute( 'default' );
2972
			}
2973
		} else {
2974
			$this->value = $this->get_attribute( 'default' );
2975
		}
2976
2977
		$field_value = Grunion_Contact_Form_Plugin::strip_tags( $this->value );
2978
		$field_label = Grunion_Contact_Form_Plugin::strip_tags( $field_label );
2979
2980
		/**
2981
		 * Filter the Contact Form required field text
2982
		 *
2983
		 * @module contact-form
2984
		 *
2985
		 * @since 3.8.0
2986
		 *
2987
		 * @param string $var Required field text. Default is "(required)".
2988
		 */
2989
		$required_field_text = esc_html( apply_filters( 'jetpack_required_field_text', __( '(required)', 'jetpack' ) ) );
2990
2991
		switch ( $field_type ) {
2992 View Code Duplication
			case 'email':
2993
				$r .= "\n<div>\n";
2994
				$r .= "\t\t<label for='" . esc_attr( $field_id ) . "' class='grunion-field-label email" . ( $this->is_error() ? ' form-error' : '' ) . "'>" . esc_html( $field_label ) . ( $field_required ? '<span>' . $required_field_text . '</span>' : '' ) . "</label>\n";
2995
				$r .= "\t\t<input type='email' name='" . esc_attr( $field_id ) . "' id='" . esc_attr( $field_id ) . "' value='" . esc_attr( $field_value ) . "' " . $field_class . $field_placeholder . ' ' . ( $field_required ? "required aria-required='true'" : '' ) . "/>\n";
2996
				$r .= "\t</div>\n";
2997
				break;
2998
			case 'telephone':
2999
				$r .= "\n<div>\n";
3000
				$r .= "\t\t<label for='" . esc_attr( $field_id ) . "' class='grunion-field-label telephone" . ( $this->is_error() ? ' form-error' : '' ) . "'>" . esc_html( $field_label ) . ( $field_required ? '<span>' . $required_field_text . '</span>' : '' ) . "</label>\n";
3001
				$r .= "\t\t<input type='tel' name='" . esc_attr( $field_id ) . "' id='" . esc_attr( $field_id ) . "' value='" . esc_attr( $field_value ) . "' " . $field_class . $field_placeholder . "/>\n";
3002
				break;
3003
			case 'url':
3004
				$r .= "\n<div>\n";
3005
				$r .= "\t\t<label for='" . esc_attr( $field_id ) . "' class='grunion-field-label url" . ( $this->is_error() ? ' form-error' : '' ) . "'>" . esc_html( $field_label ) . ( $field_required ? '<span>' . $required_field_text . '</span>' : '' ) . "</label>\n";
3006
				$r .= "\t\t<input type='url' name='" . esc_attr( $field_id ) . "' id='" . esc_attr( $field_id ) . "' value='" . esc_attr( $field_value ) . "' " . $field_class . $field_placeholder . ' ' . ( $field_required ? "required aria-required='true'" : '' ) . "/>\n";
3007
				$r .= "\t</div>\n";
3008
				break;
3009 View Code Duplication
			case 'textarea':
3010
				$r .= "\n<div>\n";
3011
				$r .= "\t\t<label for='contact-form-comment-" . esc_attr( $field_id ) . "' class='grunion-field-label textarea" . ( $this->is_error() ? ' form-error' : '' ) . "'>" . esc_html( $field_label ) . ( $field_required ? '<span>' . $required_field_text . '</span>' : '' ) . "</label>\n";
3012
				$r .= "\t\t<textarea name='" . esc_attr( $field_id ) . "' id='contact-form-comment-" . esc_attr( $field_id ) . "' rows='20' " . $field_class . $field_placeholder . ' ' . ( $field_required ? "required aria-required='true'" : '' ) . '>' . esc_textarea( $field_value ) . "</textarea>\n";
3013
				$r .= "\t</div>\n";
3014
				break;
3015
			case 'radio':
3016
				$r .= "\t<div><label class='grunion-field-label" . ( $this->is_error() ? ' form-error' : '' ) . "'>" . esc_html( $field_label ) . ( $field_required ? '<span>' . $required_field_text . '</span>' : '' ) . "</label>\n";
3017
				foreach ( $this->get_attribute( 'options' ) as $optionIndex => $option ) {
3018
					$option = Grunion_Contact_Form_Plugin::strip_tags( $option );
3019
					$r     .= "\t\t<label class='grunion-radio-label radio" . ( $this->is_error() ? ' form-error' : '' ) . "'>";
3020
					$r     .= "<input type='radio' name='" . esc_attr( $field_id ) . "' value='" . esc_attr( $this->get_option_value( $this->get_attribute( 'values' ), $optionIndex, $option ) ) . "' " . $field_class . checked( $option, $field_value, false ) . ' ' . ( $field_required ? "required aria-required='true'" : '' ) . '/> ';
3021
					$r     .= esc_html( $option ) . "</label>\n";
3022
					$r     .= "\t\t<div class='clear-form'></div>\n";
3023
				}
3024
				$r .= "\t\t</div>\n";
3025
				break;
3026
			case 'checkbox':
3027
				$r .= "\t<div>\n";
3028
				$r .= "\t\t<label class='grunion-field-label checkbox" . ( $this->is_error() ? ' form-error' : '' ) . "'>\n";
3029
				$r .= "\t\t<input type='checkbox' name='" . esc_attr( $field_id ) . "' value='" . esc_attr__( 'Yes', 'jetpack' ) . "' " . $field_class . checked( (bool) $field_value, true, false ) . ' ' . ( $field_required ? "required aria-required='true'" : '' ) . "/> \n";
3030
				$r .= "\t\t" . esc_html( $field_label ) . ( $field_required ? '<span>' . $required_field_text . '</span>' : '' ) . "</label>\n";
3031
				$r .= "\t\t<div class='clear-form'></div>\n";
3032
				$r .= "\t</div>\n";
3033
				break;
3034
			case 'checkbox-multiple':
3035
				$r .= "\t<div><label class='grunion-field-label" . ( $this->is_error() ? ' form-error' : '' ) . "'>" . esc_html( $field_label ) . ( $field_required ? '<span>' . $required_field_text . '</span>' : '' ) . "</label>\n";
3036
				foreach ( $this->get_attribute( 'options' ) as $optionIndex => $option ) {
3037
					$option = Grunion_Contact_Form_Plugin::strip_tags( $option );
3038
					$r     .= "\t\t<label class='grunion-checkbox-multiple-label checkbox-multiple" . ( $this->is_error() ? ' form-error' : '' ) . "'>";
3039
					$r     .= "<input type='checkbox' name='" . esc_attr( $field_id ) . "[]' value='" . esc_attr( $this->get_option_value( $this->get_attribute( 'values' ), $optionIndex, $option ) ) . "' " . $field_class . checked( in_array( $option, (array) $field_value ), true, false ) . ' /> ';
3040
					$r     .= esc_html( $option ) . "</label>\n";
3041
					$r     .= "\t\t<div class='clear-form'></div>\n";
3042
				}
3043
				$r .= "\t\t</div>\n";
3044
				break;
3045
			case 'select':
3046
				$r .= "\n<div>\n";
3047
				$r .= "\t\t<label for='" . esc_attr( $field_id ) . "' class='grunion-field-label select" . ( $this->is_error() ? ' form-error' : '' ) . "'>" . esc_html( $field_label ) . ( $field_required ? '<span>' . $required_field_text . '</span>' : '' ) . "</label>\n";
3048
				$r .= "\t<select name='" . esc_attr( $field_id ) . "' id='" . esc_attr( $field_id ) . "' " . $field_class . ( $field_required ? "required aria-required='true'" : '' ) . ">\n";
3049
				foreach ( $this->get_attribute( 'options' ) as $optionIndex => $option ) {
3050
					$option = Grunion_Contact_Form_Plugin::strip_tags( $option );
3051
					$r     .= "\t\t<option" . selected( $option, $field_value, false ) . " value='" . esc_attr( $this->get_option_value( $this->get_attribute( 'values' ), $optionIndex, $option ) ) . "'>" . esc_html( $option ) . "</option>\n";
3052
				}
3053
				$r .= "\t</select>\n";
3054
				$r .= "\t</div>\n";
3055
				break;
3056
			case 'date':
3057
				$r .= "\n<div>\n";
3058
				$r .= "\t\t<label for='" . esc_attr( $field_id ) . "' class='grunion-field-label " . esc_attr( $field_type ) . ( $this->is_error() ? ' form-error' : '' ) . "'>" . esc_html( $field_label ) . ( $field_required ? '<span>' . $required_field_text . '</span>' : '' ) . "</label>\n";
3059
				$r .= "\t\t<input type='text' name='" . esc_attr( $field_id ) . "' id='" . esc_attr( $field_id ) . "' value='" . esc_attr( $field_value ) . "' " . $field_class . ( $field_required ? "required aria-required='true'" : '' ) . "/>\n";
3060
				$r .= "\t</div>\n";
3061
3062
				wp_enqueue_script(
3063
					'grunion-frontend',
3064
					Jetpack::get_file_url_for_environment(
3065
						'_inc/build/contact-form/js/grunion-frontend.min.js',
3066
						'modules/contact-form/js/grunion-frontend.js'
3067
					),
3068
					array( 'jquery', 'jquery-ui-datepicker' )
3069
				);
3070
				wp_enqueue_style( 'jp-jquery-ui-datepicker', plugins_url( 'css/jquery-ui-datepicker.css', __FILE__ ), array( 'dashicons' ), '1.0' );
3071
3072
				// Using Core's built-in datepicker localization routine
3073
				wp_localize_jquery_ui_datepicker();
3074
				break;
3075 View Code Duplication
			default: // text field
3076
				// note that any unknown types will produce a text input, so we can use arbitrary type names to handle
3077
				// input fields like name, email, url that require special validation or handling at POST
3078
				$r .= "\n<div>\n";
3079
				$r .= "\t\t<label for='" . esc_attr( $field_id ) . "' class='grunion-field-label " . esc_attr( $field_type ) . ( $this->is_error() ? ' form-error' : '' ) . "'>" . esc_html( $field_label ) . ( $field_required ? '<span>' . $required_field_text . '</span>' : '' ) . "</label>\n";
3080
				$r .= "\t\t<input type='text' name='" . esc_attr( $field_id ) . "' id='" . esc_attr( $field_id ) . "' value='" . esc_attr( $field_value ) . "' " . $field_class . $field_placeholder . ' ' . ( $field_required ? "required aria-required='true'" : '' ) . "/>\n";
3081
				$r .= "\t</div>\n";
3082
		}
3083
3084
		/**
3085
		 * Filter the HTML of the Contact Form.
3086
		 *
3087
		 * @module contact-form
3088
		 *
3089
		 * @since 2.6.0
3090
		 *
3091
		 * @param string $r Contact Form HTML output.
3092
		 * @param string $field_label Field label.
3093
		 * @param int|null $id Post ID.
3094
		 */
3095
		return apply_filters( 'grunion_contact_form_field_html', $r, $field_label, ( in_the_loop() ? get_the_ID() : null ) );
3096
	}
3097
}
3098
3099
add_action( 'init', array( 'Grunion_Contact_Form_Plugin', 'init' ) );
3100
3101
add_action( 'grunion_scheduled_delete', 'grunion_delete_old_spam' );
3102
3103
/**
3104
 * Deletes old spam feedbacks to keep the posts table size under control
3105
 */
3106
function grunion_delete_old_spam() {
3107
	global $wpdb;
3108
3109
	$grunion_delete_limit = 100;
3110
3111
	$now_gmt  = current_time( 'mysql', 1 );
3112
	$sql      = $wpdb->prepare(
3113
		"
3114
		SELECT `ID`
3115
		FROM $wpdb->posts
3116
		WHERE DATE_SUB( %s, INTERVAL 15 DAY ) > `post_date_gmt`
3117
			AND `post_type` = 'feedback'
3118
			AND `post_status` = 'spam'
3119
		LIMIT %d
3120
	", $now_gmt, $grunion_delete_limit
3121
	);
3122
	$post_ids = $wpdb->get_col( $sql );
3123
3124
	foreach ( (array) $post_ids as $post_id ) {
3125
		// force a full delete, skip the trash
3126
		wp_delete_post( $post_id, true );
3127
	}
3128
3129
	if (
3130
		/**
3131
		 * Filter if the module run OPTIMIZE TABLE on the core WP tables.
3132
		 *
3133
		 * @module contact-form
3134
		 *
3135
		 * @since 1.3.1
3136
		 * @since 6.4.0 Set to false by default.
3137
		 *
3138
		 * @param bool $filter Should Jetpack optimize the table, defaults to false.
3139
		 */
3140
		apply_filters( 'grunion_optimize_table', false )
3141
	) {
3142
		$wpdb->query( "OPTIMIZE TABLE $wpdb->posts" );
3143
	}
3144
3145
	// if we hit the max then schedule another run
3146
	if ( count( $post_ids ) >= $grunion_delete_limit ) {
3147
		wp_schedule_single_event( time() + 700, 'grunion_scheduled_delete' );
3148
	}
3149
}
3150