PluginProbe
Jetpack – WP Security, Backup, Speed, & Growth / 16.3-beta
Jetpack – WP Security, Backup, Speed, & Growth v16.3-beta
16.3-beta 16.3-a.5 16.3-a.7 16.3-a.3 16.3-a.1 16.2 16.2-beta 12.0.3 12.1.3 12.2.3 12.3.2 12.4.2 12.5.2 12.6.4 12.7.3 12.8.3 12.9.5 13.0.2 13.1.5 13.2.4 13.3.3 13.4.5 13.5.2 13.6.2 13.7.2 All 507 releases
jetpack / jetpack_vendor / automattic / jetpack-forms / src / contact-form / class-feedback.php

class-feedback.php in Jetpack – WP Security, Backup, Speed, & Growth 16.3-beta, at jetpack_vendor/automattic/jetpack-forms/src/contact-form/class-feedback.php

2,521 lines 72.2 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Feedback class.
4 *
5 * @package automattic/jetpack-forms
6 */
7
8 namespace Automattic\Jetpack\Forms\ContactForm;
9
10 use Automattic\Jetpack\Connection\Client;
11 use Automattic\Jetpack\Device_Detection\User_Agent_Info;
12 use Automattic\Jetpack\Forms\Dashboard\Dashboard as Forms_Dashboard;
13 use WP_Post;
14
15 if ( ! defined( 'ABSPATH' ) ) {
16 exit( 0 );
17 }
18
19 /**
20 * Handles the response for a contact form submission.
21 *
22 * Feedback objects are there to help us interact with the form response data.
23 */
24 class Feedback {
25 use Country_Code_Utils;
26
27 const POST_TYPE = 'feedback';
28
29 /**
30 * Comment status for unread feedback.
31 *
32 * @var string
33 */
34 public const STATUS_UNREAD = 'open';
35
36 /**
37 * Comment status for read feedback.
38 *
39 * @var string
40 */
41 public const STATUS_READ = 'closed';
42
43 /**
44 * Meta key used to store the source post ID on feedback posts.
45 *
46 * @var string
47 */
48 public const SOURCE_META_KEY = '_feedback_source_post_id';
49
50 /**
51 * Post meta key flagging a feedback entry as a test submission (from a
52 * form preview). Stored as `1` when `Feedback_Source::is_test()` is true
53 * so collections can filter test responses at the database level without
54 * parsing the serialized source.
55 *
56 * @var string
57 */
58 public const IS_TEST_META_KEY = '_feedback_is_test';
59
60 /**
61 * Name of the hidden POST field carrying the form fill duration.
62 *
63 * Prefixed because submitted fields share one flat POST namespace with author-defined
64 * fields, whose names a site owner can set by hand. An unprefixed `form_fill_duration`
65 * field would silently overwrite this one.
66 *
67 * @since 7.24.0
68 *
69 * @var string
70 */
71 public const FORM_FILL_DURATION_FIELD = 'jetpack_form_fill_duration';
72
73 /**
74 * Cache key for the source post IDs list.
75 *
76 * @var string
77 */
78 private const SOURCE_IDS_CACHE_KEY = 'jetpack_forms_source_post_ids';
79
80 /**
81 * Cache group for forms data.
82 *
83 * @var string
84 */
85 private const CACHE_GROUP = 'jetpack_forms';
86
87 /**
88 * Returns all distinct source post IDs for feedback entries.
89 *
90 * Uses the _feedback_source_post_id meta for new feedback, with a fallback
91 * to post_parent for old feedback that doesn't have the meta yet (excluding
92 * jetpack_form parents).
93 *
94 * @return array Array of unique source post IDs.
95 */
96 public static function get_all_source_post_ids() {
97 $source_ids = wp_cache_get( self::SOURCE_IDS_CACHE_KEY, self::CACHE_GROUP );
98
99 if ( false !== $source_ids ) {
100 return $source_ids;
101 }
102
103 global $wpdb;
104
105 $meta_key = self::SOURCE_META_KEY;
106 $statuses = array( 'draft', 'publish', 'spam', 'trash' );
107 $placeholders = implode( ',', array_fill( 0, count( $statuses ), '%s' ) );
108
109 $post_type = self::POST_TYPE;
110
111 // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber
112 $source_ids = $wpdb->get_col(
113 $wpdb->prepare(
114 "SELECT DISTINCT source_id FROM (
115 SELECT CAST(pm.meta_value AS UNSIGNED) AS source_id
116 FROM {$wpdb->postmeta} pm
117 INNER JOIN {$wpdb->posts} p ON p.ID = pm.post_id
118 WHERE pm.meta_key = %s
119 AND p.post_type = %s
120 AND p.post_status IN ({$placeholders})
121 AND pm.meta_value != '0' AND pm.meta_value != ''
122 UNION
123 SELECT p.post_parent AS source_id
124 FROM {$wpdb->posts} p
125 LEFT JOIN {$wpdb->postmeta} pm ON pm.post_id = p.ID AND pm.meta_key = %s
126 LEFT JOIN {$wpdb->posts} parent_post ON parent_post.ID = p.post_parent
127 WHERE p.post_type = %s
128 AND p.post_status IN ({$placeholders})
129 AND p.post_parent > 0
130 AND pm.meta_id IS NULL
131 AND (parent_post.post_type IS NULL OR parent_post.post_type != %s)
132 ) AS combined_sources",
133 array_merge(
134 array( $meta_key, $post_type ),
135 $statuses,
136 array( $meta_key, $post_type ),
137 $statuses,
138 array( Contact_Form::POST_TYPE )
139 )
140 )
141 );
142 // phpcs:enable
143
144 $source_ids = array_map( 'intval', $source_ids );
145 wp_cache_set( self::SOURCE_IDS_CACHE_KEY, $source_ids, self::CACHE_GROUP, HOUR_IN_SECONDS );
146
147 return $source_ids;
148 }
149
150 /**
151 * Returns the JOIN and WHERE SQL fragments for filtering feedback posts by source post ID.
152 *
153 * Matches feedback with the _feedback_source_post_id meta set, or falls back
154 * to post_parent for old feedback that doesn't have the meta yet.
155 *
156 * @since 7.19.0
157 *
158 * @param int $source_id The source post ID to filter by.
159 * @return array{join: string, where: string} SQL fragments.
160 */
161 public static function get_source_filter_sql( $source_id ) {
162 global $wpdb;
163 $meta_key = esc_sql( self::SOURCE_META_KEY );
164 $source_id = (int) $source_id;
165 return array(
166 'join' => " LEFT JOIN {$wpdb->postmeta} AS source_meta ON ({$wpdb->posts}.ID = source_meta.post_id AND source_meta.meta_key = '{$meta_key}')",
167 'where' => $wpdb->prepare(
168 "(source_meta.meta_value = %s OR (source_meta.meta_id IS NULL AND {$wpdb->posts}.post_parent = %d))",
169 (string) $source_id,
170 $source_id
171 ),
172 );
173 }
174
175 /**
176 * Invalidates the source post IDs cache when a feedback post is deleted.
177 *
178 * @param int $post_id The deleted post ID.
179 * @param \WP_Post $post The deleted post object.
180 */
181 public static function invalidate_source_ids_cache_on_delete( $post_id, $post ) {
182 if ( $post->post_type === self::POST_TYPE ) {
183 wp_cache_delete( self::SOURCE_IDS_CACHE_KEY, self::CACHE_GROUP );
184 }
185 }
186
187 /**
188 * Backfills the source post ID meta from the feedback object's resolved source.
189 *
190 * For old feedback parented to a jetpack_form that doesn't have
191 * _feedback_source_post_id set yet, this writes the meta so future
192 * queries can filter by source without the post_parent fallback.
193 *
194 * @param int $post_id The feedback post ID.
195 * @param Feedback $feedback The feedback object (already has source resolved from parsed content).
196 */
197 public static function maybe_backfill_source_meta( $post_id, $feedback ) {
198 $existing = get_post_meta( $post_id, self::SOURCE_META_KEY, true );
199 if ( $existing ) {
200 return;
201 }
202
203 $source_id = $feedback->get_entry_id();
204 if ( is_numeric( $source_id ) && (int) $source_id > 0 ) {
205 $meta_added = add_post_meta( $post_id, self::SOURCE_META_KEY, (int) $source_id, true );
206 if ( $meta_added ) {
207 wp_cache_delete( self::SOURCE_IDS_CACHE_KEY, self::CACHE_GROUP );
208 }
209 }
210 }
211
212 /**
213 * The form field values.
214 *
215 * @var array
216 */
217 protected $fields = array();
218
219 /**
220 * Static cache for feedback fields.
221 *
222 * This is used to avoid recomputing the feedback fields for the same post ID.
223 *
224 * @var array
225 */
226 private static $feedback_fields = array();
227
228 /**
229 * Does the response have files attached to it?
230 *
231 * @var bool
232 */
233 protected $has_file = false;
234
235 /**
236 * The status of the feedback entry.
237 *
238 * @var string
239 */
240 protected $status = 'publish'; // Default status is 'publish' or other statuses as needed.
241
242 /**
243 * The IP address of the user who submitted the feedback.
244 *
245 * This is only available on form submissions, and might not be available when retrieving existing feedback posts in case the site admin decides to not store the IP address.
246 *
247 * @var string|null
248 */
249 protected $ip_address = null;
250
251 /**
252 * The user agent of the user who submitted the feedback.
253 *
254 * This is only available on form submissions, and might not be available when retrieving existing feedback posts.
255 *
256 * @var string|null
257 */
258 protected $user_agent = null;
259
260 /**
261 * The country code derived from the IP address.
262 *
263 * This is derived from the IP address and stored for easier display.
264 *
265 * @var string|null
266 */
267 protected $country_code = null;
268
269 /**
270 * The form fill duration in seconds.
271 *
272 * Tracks how long the user spent filling out the form (from first interaction to submission).
273 *
274 * @var int|null
275 */
276 protected $form_fill_duration = null;
277
278 /**
279 * The subject of the feedback entry.
280 *
281 * @var string
282 */
283 protected $subject = '';
284
285 /**
286 * Feedback ID of the feedback entry.
287 *
288 * Marked as legacy because it is not used in the new feedback system.
289 *
290 * @var string
291 */
292 protected $legacy_feedback_id = '';
293
294 /**
295 * The title of the feedback entry.
296 *
297 * Marked as legacy because it is not used in the new feedback system.
298 *
299 * @var string
300 */
301 protected $legacy_feedback_title = '';
302
303 /**
304 * The time of the feedback entry.
305 *
306 * This is used to store the title of the feedback entry.
307 *
308 * @var string
309 */
310 protected $feedback_time = '';
311
312 /**
313 * The Feedback_Author of the feedback entry.
314 *
315 * @var Feedback_Author
316 */
317 protected $author_data;
318
319 /**
320 * The comment content of the feedback entry.
321 *
322 * @var string
323 */
324 protected $comment_content = '';
325
326 /**
327 * Whether the user has given consent for data processing.
328 *
329 * @var bool
330 */
331 protected $has_consent = false;
332
333 /**
334 * Whether this response was loaded from structured feedback data.
335 *
336 * @var bool
337 */
338 protected $uses_structured_fields = false;
339
340 /**
341 * Whether the feedback entry is unread.
342 *
343 * @var bool
344 */
345 protected $is_unread = true;
346
347 /**
348 * The post ID of the feedback entry.
349 *
350 * @var int|null
351 */
352 protected $post_id = null;
353
354 /**
355 * The entry object of the post that the feedback was submitted from.
356 *
357 * This is used to store the entry object of the post that the feedback was submitted from.
358 *
359 * @var Feedback_Source
360 */
361 protected $source;
362
363 /**
364 * The notification recipients of the feedback entry.
365 *
366 * @var array
367 */
368 protected $notification_recipients = array();
369
370 /**
371 * The jetpack_form post ID associated with this feedback, when available.
372 *
373 * @var int|null
374 */
375 protected $form_id = null;
376
377 /**
378 * The logged-in user who submitted the feedback, if any.
379 *
380 * @var array|null Array with 'display_name' and 'id' keys, or null if not logged in.
381 */
382 protected $logged_in_user = null;
383
384 /**
385 * Create a response object from a feedback post ID.
386 *
387 * @param int $feedback_post_id The ID of the feedback post.
388 * @return static|null
389 */
390 public static function get( $feedback_post_id ) {
391 $feedback_post = get_post( $feedback_post_id );
392 if ( ! $feedback_post || self::POST_TYPE !== $feedback_post->post_type ) {
393 return null;
394 }
395
396 if ( isset( self::$feedback_fields[ $feedback_post->ID ] ) ) {
397 return self::$feedback_fields[ $feedback_post->ID ];
398 }
399
400 $instance = new self();
401 $instance->load_from_post( $feedback_post );
402 self::$feedback_fields[ $feedback_post->ID ] = $instance;
403 return $instance;
404 }
405
406 /**
407 * Clear the internal cache of feedback objects.
408 *
409 * Useful for testing or when feedback data needs to be reloaded fresh.
410 *
411 * @since 6.10.0
412 */
413 public static function clear_cache() {
414 self::$feedback_fields = array();
415 }
416
417 /**
418 * Create a Feedback object from a feedback post.
419 *
420 * @param WP_Post $feedback_post The feedback post object.
421 */
422 private function load_from_post( WP_Post $feedback_post ) {
423
424 $parsed_content = $this->parse_content( $feedback_post->post_content, $feedback_post->post_mime_type );
425
426 $this->post_id = $feedback_post->ID;
427 $this->status = $feedback_post->post_status;
428 $this->legacy_feedback_id = $feedback_post->post_name;
429 $this->feedback_time = $feedback_post->post_date;
430 $this->is_unread = $feedback_post->comment_status === self::STATUS_UNREAD;
431
432 $this->fields = $parsed_content['fields'] ?? array();
433
434 // Check if post_parent is a jetpack_form post
435 $potential_form_id = $feedback_post->post_parent;
436 if ( $potential_form_id > 0 ) {
437 $parent_post = get_post( $potential_form_id );
438 if ( $parent_post && $parent_post->post_type === 'jetpack_form' ) {
439 // New data: post_parent is form ID
440 $this->form_id = $potential_form_id;
441 }
442 }
443
444 // Determine the source ID for this feedback.
445 // Prefer the explicit source_id from parsed content when available,
446 // otherwise fall back to the legacy behavior where post_parent was
447 // used as the source post ID, but only when no explicit form_id exists.
448 $source_id = 0;
449 if ( isset( $parsed_content['source_id'] ) && null !== $parsed_content['source_id'] ) {
450 $source_id = (int) $parsed_content['source_id'];
451 } elseif ( $feedback_post->post_parent && ! $this->form_id ) {
452 $source_id = (int) $feedback_post->post_parent;
453 }
454
455 $this->source = new Feedback_Source(
456 $source_id,
457 $parsed_content['entry_title'] ?? '',
458 $parsed_content['entry_page'] ?? 1,
459 $parsed_content['source_type'] ?? 'single',
460 $parsed_content['request_url'] ?? '',
461 ! empty( $parsed_content['is_test'] )
462 );
463
464 $this->ip_address = $parsed_content['ip'] ?? $this->get_first_field_of_type( 'ip' );
465 $this->country_code = $parsed_content['country_code'] ?? null;
466 $this->user_agent = $parsed_content['user_agent'] ?? null;
467 $this->form_fill_duration = $parsed_content['form_fill_duration'] ?? null;
468 $this->subject = $parsed_content['subject'] ?? $this->get_first_field_of_type( 'subject' );
469
470 $this->notification_recipients = $parsed_content['notification_recipients'] ?? array();
471 $this->logged_in_user = $parsed_content['logged_in_user'] ?? null;
472
473 $this->author_data = new Feedback_Author(
474 $this->get_first_field_of_type( 'name', 'pre_comment_author_name' ),
475 $this->get_first_field_of_type( 'email', 'pre_comment_author_email' ),
476 $this->get_first_field_of_type( 'url', 'pre_comment_author_url' ),
477 $this->get_field_value_by_form_field_id( 'first-name' ),
478 $this->get_field_value_by_form_field_id( 'last-name' )
479 );
480
481 $this->comment_content = $this->get_first_field_of_type( 'textarea' );
482 $this->has_consent = (bool) $this->get_first_field_of_type( 'consent' );
483
484 $this->legacy_feedback_title = $feedback_post->post_title ? $feedback_post->post_title : $this->get_author() . ' - ' . $feedback_post->post_date;
485 }
486
487 /**
488 * Create a response object from a form submission.
489 *
490 * @param array $post_data Typically $_POST.
491 * @param Contact_Form $form The form object.
492 * @param WP_Post|null $current_post The current post object, if available.
493 * @param int $current_page_number The current page number associated with the current post object entry.
494 *
495 * @return static
496 */
497 public static function from_submission( $post_data, $form, $current_post = null, $current_page_number = 1 ) {
498 $instance = new self();
499 $instance->load_from_submission( $post_data, $form, $current_post, $current_page_number );
500 return $instance;
501 }
502
503 /**
504 * Set the source of the feedback entry.
505 *
506 * @param Feedback_Source $source The source object.
507 */
508 public function set_source( $source ) {
509 $this->source = $source;
510 }
511
512 /**
513 * Load from Form Submission.
514 *
515 * @param array $post_data The $_POST received during the form submission.
516 * @param Contact_Form $form The form object.
517 * @param WP_Post|null $current_post The current post object, if available.
518 * @param int $current_page_number The current page number associated with the current post object entry.
519 */
520 private function load_from_submission( $post_data, $form, $current_post = null, $current_page_number = 1 ) {
521
522 // Drop the answers to fields conditional logic hid, once, before anything reads them.
523 //
524 // get_computed_fields() already skips hidden fields for the stored response, but the
525 // comment content, the consent flag, the author details and the notification
526 // recipients all read $post_data directly and were still seeing them. Stripping here
527 // is what makes "a hidden field was never answered" true for every consumer instead
528 // of just the one.
529 $post_data = self::without_hidden_answers( $post_data, $form );
530
531 $this->source = Feedback_Source::from_submission( $current_post, $current_page_number );
532
533 // Use the form's ref attribute as the authoritative form ID.
534 // The ref is set server-side (from the JWT or shortcode attributes) and cannot be tampered with.
535 $form_id_attribute = $form->get_attribute( 'ref' );
536 $form_id_attribute = is_numeric( $form_id_attribute ) ? absint( $form_id_attribute ) : 0;
537 $this->form_id = $form_id_attribute > 0 ? $form_id_attribute : null;
538
539 // If post_data is provided, use it to populate fields.
540 $this->fields = $this->get_computed_fields( $post_data, $form );
541 $this->ip_address = Contact_Form_Plugin::get_ip_address();
542 $this->country_code = $this->get_country_code_from_ip( $this->ip_address );
543 $this->user_agent = isset( $_SERVER['HTTP_USER_AGENT'] ) ? filter_var( wp_unslash( $_SERVER['HTTP_USER_AGENT'] ) ) : null;
544 $this->form_fill_duration = $this->get_computed_form_fill_duration( $post_data );
545 $this->subject = $this->get_computed_subject( $post_data, $form );
546 $this->author_data = Feedback_Author::from_submission( $post_data, $form );
547 $this->comment_content = $this->get_computed_comment_content( $post_data, $form );
548 $this->has_consent = $this->get_computed_consent( $post_data, $form );
549
550 $this->notification_recipients = $this->get_computed_notification_recipients( $post_data, $form );
551
552 $this->feedback_time = current_time( 'mysql' );
553 $this->legacy_feedback_title = "{$this->get_author()} - {$this->feedback_time}";
554 $this->legacy_feedback_id = md5( $this->legacy_feedback_title );
555
556 // Capture logged-in user info at submission time.
557 if ( is_user_logged_in() ) {
558 $current_user = wp_get_current_user();
559 $this->logged_in_user = array(
560 'display_name' => $current_user->display_name,
561 'username' => $current_user->user_login,
562 'id' => $current_user->ID,
563 );
564 }
565 }
566
567 /**
568 * Remove submitted values belonging to fields conditional logic resolved as hidden.
569 *
570 * The form owns the resolution and caches it, so this asks rather than resolving again --
571 * a second resolution over a different value source is exactly what let validation and
572 * storage disagree about a prefilled consent field.
573 *
574 * @param array $post_data The post data from the form submission.
575 * @param Contact_Form $form The form object.
576 * @return array The post data, less any hidden field's answer.
577 */
578 private static function without_hidden_answers( $post_data, $form ) {
579 if ( ! is_array( $post_data ) ) {
580 return $post_data;
581 }
582
583 // Empty when the feature is off, so this is a no-op then.
584 foreach ( $form->get_resolved_field_visibility() as $field_id => $is_visible ) {
585 if ( false === $is_visible ) {
586 unset( $post_data[ $field_id ] );
587 }
588 }
589
590 return $post_data;
591 }
592
593 /**
594 * Get a sanitized value from the post data.
595 *
596 * @param string $key The key to look for in the post data.
597 * @param array $post_data The post data array, typically $_POST.
598 * @param string|null $type The type of the field, if applicable (e.g., 'file').
599 *
600 * @return string|array The sanitized value, or an empty string if the key is not found.
601 */
602 private function get_field_value( $key, $post_data, $type = null ) {
603 if ( $type === 'file' ) {
604 if ( isset( $post_data[ $key ] ) ) {
605 return self::process_file_field_value( $post_data[ $key ] );
606 }
607 return array( 'files' => array() );
608 }
609
610 if ( $type === 'image-select' ) {
611 if ( isset( $post_data[ $key ] ) ) {
612 return self::process_image_select_field_value( $post_data[ $key ] );
613 }
614
615 return array(
616 'type' => 'image-select',
617 'choices' => array(),
618 );
619 }
620
621 if ( isset( $post_data[ $key ] ) ) {
622 if ( is_array( $post_data[ $key ] ) ) {
623 return array_map( 'sanitize_textarea_field', wp_unslash( $post_data[ $key ] ) );
624 } else {
625 return sanitize_textarea_field( wp_unslash( $post_data[ $key ] ) );
626 }
627 }
628 return '';
629 }
630
631 /**
632 * Process the file field value.
633 *
634 * @param array $raw_data The raw post data from the file field.
635 *
636 * @return array The processed file data.
637 */
638 public static function process_file_field_value( $raw_data ) {
639 $file_data_array = is_array( $raw_data )
640 ? array_map(
641 function ( $json_str ) {
642 /*
643 * The entries come straight from $_POST, so any of them may be an array: a request
644 * carrying `field[1][x]=y` reaches here with a nested array where a JSON string is
645 * expected, and stripslashes() raises an uncaught TypeError on PHP 8. Nothing above
646 * this catches it, so an anonymous visitor could crash the submission with a 500.
647 *
648 * Contact_Form_Field::validate() sanitizes its own copy — which turns a nested array
649 * into '' — but that copy is not the one read here.
650 */
651 if ( ! is_string( $json_str ) ) {
652 return array(
653 'file_id' => '',
654 'name' => '',
655 'size' => 0,
656 'type' => '',
657 );
658 }
659
660 $decoded = json_decode( stripslashes( $json_str ), true );
661 return array(
662 'file_id' => isset( $decoded['file_id'] ) ? sanitize_text_field( $decoded['file_id'] ) : '',
663 'name' => isset( $decoded['name'] ) ? sanitize_text_field( $decoded['name'] ) : '',
664 'size' => isset( $decoded['size'] ) ? absint( $decoded['size'] ) : 0,
665 'type' => isset( $decoded['type'] ) ? sanitize_text_field( $decoded['type'] ) : '',
666 );
667 },
668 $raw_data
669 ) : array();
670
671 if ( empty( $file_data_array ) ) {
672 return array(
673 'files' => array(),
674 );
675 }
676
677 return array(
678 'files' => $file_data_array,
679 );
680 }
681
682 /**
683 * Process the image select field value.
684 *
685 * @param array $raw_data The raw post data from the image select field.
686 *
687 * @return array The processed image select data.
688 */
689 public static function process_image_select_field_value( $raw_data ) {
690 $value = array(
691 'type' => 'image-select',
692 'choices' => array(),
693 );
694
695 $selection_data_array = is_array( $raw_data )
696 ? array_map(
697 function ( $json_str ) {
698 return json_decode( stripslashes( $json_str ), true );
699 },
700 $raw_data
701 ) : array( json_decode( stripslashes( $raw_data ), true ) );
702
703 if ( ! empty( $selection_data_array ) ) {
704 $value['choices'] = $selection_data_array;
705 }
706
707 return $value;
708 }
709
710 /**
711 * Process a radio field value to detect and extract "Other" option metadata.
712 *
713 * This method checks if a radio field value matches the "Other" pattern and combines
714 * it with the corresponding text input value if present.
715 *
716 * @param string $value The raw field value from the submission.
717 * @param object $field The field object from the form.
718 * @param string $field_id The field ID.
719 * @param array $post_data The POST data from the submission.
720 *
721 * @return array An array with 'value' and 'meta' keys.
722 */
723 private function process_radio_field_value( $value, $field, $field_id, $post_data ) {
724 $meta = array();
725 $allow_other = $field->get_attribute( 'allowother' );
726
727 if ( ! $allow_other || ! is_string( $value ) ) {
728 return array(
729 'value' => $value,
730 'meta' => $meta,
731 );
732 }
733
734 $options_data = $field->get_attribute( 'optionsdata' );
735 $other_label = null;
736
737 if ( ! empty( $options_data ) && is_array( $options_data ) ) {
738 foreach ( $options_data as $option ) {
739 if ( ! empty( $option['isOther'] ) ) {
740 $other_label = Contact_Form_Plugin::strip_tags( $option['label'] );
741 break;
742 }
743 }
744 }
745
746 if ( empty( $other_label ) ) {
747 return array(
748 'value' => $value,
749 'meta' => $meta,
750 );
751 }
752
753 if ( $value === $other_label ) {
754 $other_text_key = $field_id . '-other-text';
755 $custom_text = '';
756
757 if ( isset( $post_data[ $other_text_key ] ) ) {
758 $custom_text = sanitize_textarea_field( wp_unslash( $post_data[ $other_text_key ] ) );
759 }
760
761 $meta['is_other_option'] = true;
762 $meta['other_label'] = $other_label;
763 $meta['other_user_value'] = $custom_text;
764
765 if ( ! empty( $custom_text ) ) {
766 $value = $other_label . ': ' . $custom_text;
767 }
768 }
769
770 return array(
771 'value' => $value,
772 'meta' => $meta,
773 );
774 }
775
776 /**
777 * Get the computed fields from the post data.
778 *
779 * @param string $label The label of the field to look for.
780 * @param string $context The context in which the value is being rendered (default is 'default').
781 *
782 * @return string The Value of the field.
783 */
784 public function get_field_value_by_label( $label, $context = 'default' ) {
785 // This method is used to get the value of a field by its label.
786 foreach ( $this->fields as $field ) {
787 if ( $field->get_label( $context ) === $label ) {
788 return $field->get_render_value( $context );
789 }
790 }
791 return '';
792 }
793
794 /**
795 * Get the value of the field based on the first type found.
796 *
797 * @param string $type The type of the field to look for.
798 * @param string|null $filter Optional filter to apply to the value.
799 * @param string $context The context in which the value is being rendered (default is 'default').
800 *
801 * @return string The value of the first field of the specified type, or an empty string if not found.
802 */
803 private function get_first_field_of_type( $type, $filter = null, $context = 'default' ) {
804 // This method is used to get the first field of a specific type.
805 foreach ( $this->fields as $field ) {
806 if ( $field->get_type() === $type ) {
807 if ( $filter ) {
808 return Contact_Form_Plugin::strip_tags(
809 stripslashes(
810 /** This filter is already documented in core/wp-includes/comment-functions.php */
811 \apply_filters( $filter, addslashes( $field->get_render_value( $context ) ) )
812 )
813 );
814 }
815 return $field->get_render_value( $context );
816 }
817 }
818 return '';
819 }
820
821 /**
822 * Get all the fields of the response.
823 */
824 public function get_fields() {
825 return $this->fields;
826 }
827
828 /**
829 * Check whether this feedback contains at least one field of a given type.
830 *
831 * @param string $type Field type to check for (e.g. 'consent', 'email', 'textarea').
832 * @return bool True if a field of the given type exists; false otherwise.
833 */
834 public function has_field_type( $type ) {
835 foreach ( $this->fields as $field ) {
836 if ( $field->get_type() === $type ) {
837 return true;
838 }
839 }
840 return false;
841 }
842
843 /**
844 * Whether this response uses structured feedback fields.
845 *
846 * @return bool
847 */
848 public function uses_structured_fields() {
849 return $this->uses_structured_fields;
850 }
851
852 /**
853 * Get the values related to where the form was submitted from.
854 *
855 * @return array An array of entry values.
856 */
857 public function get_entry_values() {
858 // This is a convenience method to get the entry values in a simple array format.
859 $entry_values = array(
860 'email_marketing_consent' => (string) $this->has_consent ? 'yes' : 'no',
861 'entry_title' => $this->source->get_title(),
862 'entry_permalink' => $this->source->get_permalink(),
863 'feedback_id' => $this->legacy_feedback_id,
864 );
865
866 if ( $this->source->get_page_number() > 1 ) {
867 $entry_values['entry_page'] = $this->source->get_page_number();
868 }
869 return $entry_values;
870 }
871
872 /**
873 * Get all values of the response.
874 *
875 * @param string $context The context in which the values are being retrieved.
876 *
877 * @return array An array of all values, including fields and entry values.
878 */
879 public function get_all_values( $context = 'default' ) {
880 // This is a legacy method to maintain compatibility with older code.
881 return array_merge( $this->get_compiled_fields( $context, 'key-value' ), $this->get_entry_values() );
882 }
883
884 /**
885 * Get the jetpack_form post ID associated with this feedback.
886 *
887 * @return int|null The form ID, or null if not submitted via reusable form.
888 */
889 public function get_form_id() {
890 return $this->form_id;
891 }
892
893 /**
894 * Get extra values.
895 * This is a legacy method to maintain compatibility with older code.
896 *
897 * @param string $context The context in which the values are being retrieved.
898 *
899 * @return array An array of extra values, including entry values
900 */
901 public function get_legacy_extra_values( $context = 'default' ) {
902 $count = 1;
903 $_extra_fields = array();
904 $special_fields = array();
905 $non_extra_fields = array( 'email', 'name', 'url', 'subject', 'textarea', 'ip' );
906
907 // Create a map of special fields to check against their values.
908 foreach ( $this->fields as $field ) {
909 if ( in_array( $field->get_type(), $non_extra_fields, true ) ) {
910 $value = $field->get_render_value( $context );
911 if ( is_array( $value ) ) {
912 $value = reset( $value );
913 }
914 if ( $value ) {
915 $special_fields[ $value ] = true;
916 }
917 }
918 }
919
920 foreach ( $this->fields as $field ) {
921 if ( $field->compile_field( 'default' ) ) {
922 continue;
923 }
924 $render_value = $field->get_render_value();
925 if ( is_array( $render_value ) ) {
926 $render_value = reset( $render_value );
927 }
928 if ( $field->get_type() === 'basic' && $render_value && isset( $special_fields[ $render_value ] ) ) {
929 ++$count;
930 continue; // Skip fields that are already present in the non-extra fields.
931 }
932 $_extra_fields[] = $field;
933 ++$count; // Increment count to ensure unique keys for extra values.
934 }
935 $extra_values = array();
936 $extra_fields_count = $count;
937 $is_present = array(); // Used to store the value only once.
938
939 foreach ( $_extra_fields as $field ) {
940 if ( ! in_array( $field->get_type(), $non_extra_fields, true ) || isset( $is_present[ $field->get_type() ] ) ) {
941 $extra_values[ $extra_fields_count . '_' . $field->get_label() ] = $field->get_render_value( $context );
942 ++$extra_fields_count; // Increment count to ensure unique keys for extra values.
943 } else {
944 $is_present[ $field->get_type() ] = true;
945 }
946 }
947 return $extra_values;
948 }
949
950 /**
951 * Get all values of the response.
952 *
953 * @return array An array of all values, including fields and entry values.
954 */
955 public function get_all_legacy_values() {
956 return array(
957 '_feedback_author' => $this->get_author(),
958 '_feedback_author_email' => $this->get_author_email(),
959 '_feedback_author_url' => $this->get_author_url(),
960 '_feedback_subject' => $this->get_subject(),
961 '_feedback_ip' => $this->get_ip_address(),
962 '_feedback_all_fields' => $this->get_all_values(),
963 );
964 }
965 /**
966 * Return the compiled fields for the given context.
967 *
968 * @param string $context The context in which the fields are compiled.
969 * @param string $array_shape The shape of the array to return. Can be 'all', 'value', 'label', or 'key-value'.
970 *
971 * @return array An array of compiled fields with labels and values.
972 */
973 public function get_compiled_fields( $context = 'default', $array_shape = 'all' ) {
974 $compiled_fields = array();
975
976 $count_field_labels = array();
977 foreach ( $this->fields as $field ) {
978 if ( $field->compile_field( $context ) ) {
979 continue; // Skip fields that are not meant to be rendered.
980 }
981
982 // Don't show the hidden fields in the user context.
983 if ( in_array( $context, array( 'web', 'ajax' ), true ) ) {
984 if ( $field->is_of_type( 'hidden' ) ) {
985 continue;
986 }
987 }
988
989 $label = $field->get_label( $context );
990
991 if ( ! isset( $count_field_labels[ $label ] ) ) {
992 $count_field_labels[ $label ] = 1;
993 } else {
994 ++$count_field_labels[ $label ];
995 }
996
997 // Compile the field based on the requested shape.
998 switch ( $array_shape ) {
999 case 'default':
1000 case 'all':
1001 $compiled_fields[ $field->get_key() ] = array(
1002 'label' => $label,
1003 'value' => $field->get_render_value( $context ),
1004 );
1005 break;
1006 case 'label|value':
1007 $compiled_fields[] = array(
1008 'label' => $label,
1009 'value' => $field->get_render_value( $context ),
1010 );
1011 break;
1012 case 'value':
1013 $compiled_fields[] = $field->get_render_value( $context );
1014 break;
1015 case 'label':
1016 $compiled_fields[] = $label;
1017 break;
1018 case 'key-value':
1019 $compiled_fields[ $field->get_key() ] = $field->get_render_value( $context );
1020 break;
1021 case 'label-value':
1022 $compiled_fields[ $field->get_label( $context, $count_field_labels[ $label ] ) ] = $field->get_render_value( $context );
1023 break;
1024 case 'id-value':
1025 $compiled_fields[ $field->get_form_field_id() ] = $field->get_render_value( $context );
1026 break;
1027 case 'collection':
1028 $compiled_fields[] = array(
1029 'label' => $label,
1030 'value' => $field->get_render_value( $context ),
1031 'type' => $field->get_type(),
1032 'id' => $field->get_form_field_id(),
1033 'key' => $field->get_key(),
1034 'meta' => $field->get_meta(),
1035 );
1036 break;
1037 }
1038 }
1039
1040 return $compiled_fields;
1041 }
1042
1043 /**
1044 * Get the feedback ID of the response.
1045 * Which is the same as the post name for feedback entries.
1046 * Please note that this is not the same as the feedback post ID.
1047 *
1048 * @return string
1049 */
1050 public function get_feedback_id() {
1051 return $this->legacy_feedback_id;
1052 }
1053
1054 /**
1055 * Get the feedback title of the response.
1056 *
1057 * This is mostly used for legacy reasons.
1058 *
1059 * @return string
1060 */
1061 public function get_title() {
1062 return $this->legacy_feedback_title;
1063 }
1064
1065 /**
1066 * Get the time of the feedback entry.
1067 *
1068 * @return string
1069 */
1070 public function get_time() {
1071 return $this->feedback_time;
1072 }
1073
1074 /**
1075 * Get the askimet vars that are used to check for spam.
1076 *
1077 * These are the variables that are sent to Akismet to check if the feedback is spam or not.
1078 *
1079 * @return array
1080 */
1081 public function get_akismet_vars() {
1082 $akismet_vars = array(
1083 'comment_author' => $this->author_data->get_name(),
1084 'comment_author_email' => $this->author_data->get_email(),
1085 'comment_author_url' => $this->author_data->get_url(),
1086 'contact_form_subject' => $this->get_subject(),
1087 'comment_author_ip' => $this->get_ip_address(),
1088 'comment_content' => empty( $this->get_comment_content() ) ? null : $this->get_comment_content(),
1089 'permalink' => $this->get_entry_permalink(),
1090 );
1091
1092 foreach ( $this->fields as $field ) {
1093
1094 // Skip any fields that are just a choice from a pre-defined list. They wouldn't have any value
1095 // from a spam-filtering point of view.
1096 if ( in_array( $field->get_type(), array( 'select', 'checkbox', 'checkbox-multiple', 'radio', 'file', 'image-select' ), true ) ) {
1097 continue;
1098 }
1099
1100 // Normalize the label into a slug.
1101 $field_slug = trim( // Strip all leading/trailing dashes.
1102 preg_replace( // Normalize everything to a-z0-9_-
1103 '/[^a-z0-9_]+/',
1104 '-',
1105 strtolower( $field->get_label() ) // Lowercase
1106 ),
1107 '-'
1108 );
1109
1110 $field_value = $field->get_render_value( 'akismet' );
1111
1112 // Skip any values that are already in the array we're sending.
1113 if ( $field_value && in_array( $field_value, $akismet_vars, true ) ) {
1114 continue;
1115 }
1116
1117 $akismet_vars[ 'contact_form_field_' . $field_slug ] = $field_value;
1118 }
1119
1120 return $akismet_vars;
1121 }
1122
1123 /**
1124 * Get the author name of the feedback entry.
1125 * If the author is not provided we will use the email instead.
1126 *
1127 * @return string
1128 */
1129 public function get_author() {
1130 return $this->author_data->get_display_name();
1131 }
1132
1133 /**
1134 * Get the author name of a feedback entry.
1135 *
1136 * @return string
1137 */
1138 public function get_author_name() {
1139 return $this->author_data->get_name();
1140 }
1141
1142 /**
1143 * Get the author's first name of a feedback entry.
1144 *
1145 * @return string
1146 */
1147 public function get_author_first_name() {
1148 return $this->author_data->get_first_name();
1149 }
1150
1151 /**
1152 * Get the author's last name of a feedback entry.
1153 *
1154 * @return string
1155 */
1156 public function get_author_last_name() {
1157 return $this->author_data->get_last_name();
1158 }
1159
1160 /**
1161 * Get the author email of a feedback entry.
1162 *
1163 * @return string
1164 */
1165 public function get_author_email() {
1166 return $this->author_data->get_email();
1167 }
1168
1169 /**
1170 * Get the author's gravatar URL.
1171 *
1172 * This is a convenience method to get the author's gravatar URL.
1173 *
1174 * @return string
1175 */
1176 public function get_author_avatar() {
1177 return $this->author_data->get_avatar_url();
1178 }
1179
1180 /**
1181 * Get the author url of a feedback entry.
1182 *
1183 * @return string
1184 */
1185 public function get_author_url() {
1186 return $this->author_data->get_url();
1187 }
1188
1189 /**
1190 * Get the comment content of a feedback entry.
1191 *
1192 * @return string
1193 */
1194 public function get_comment_content() {
1195 return $this->comment_content;
1196 }
1197
1198 /**
1199 * Get the IP address of the submitted feedback request.
1200 *
1201 * @return string|null
1202 */
1203 public function get_ip_address() {
1204 return $this->ip_address;
1205 }
1206
1207 /**
1208 * Get the user agent of the submitted feedback request.
1209 *
1210 * @return string|null
1211 */
1212 public function get_user_agent() {
1213 return $this->user_agent;
1214 }
1215
1216 /**
1217 * Get the country code derived from the IP address.
1218 *
1219 * @return string|null
1220 */
1221 public function get_country_code() {
1222 return $this->country_code;
1223 }
1224
1225 /**
1226 * Get the form fill duration in seconds.
1227 *
1228 * Represents the time from first user interaction to form submission.
1229 *
1230 * @return int|null
1231 */
1232 public function get_form_fill_duration() {
1233 return $this->form_fill_duration;
1234 }
1235
1236 /**
1237 * Get the emoji flag for the country.
1238 *
1239 * @return string The emoji flag for the country code, or empty string if unavailable.
1240 */
1241 public function get_country_flag() {
1242 return self::country_code_to_emoji_flag( $this->country_code );
1243 }
1244
1245 /**
1246 * Get country code from IP address.
1247 *
1248 * This method uses a filter to allow custom implementations of GeoIP lookup.
1249 * The filter should return a country code (e.g., 'US', 'GB', 'DE') or null.
1250 *
1251 * @param string|null $ip_address The IP address.
1252 * @return string|null The country code or null if unavailable.
1253 */
1254 private function get_country_code_from_ip( $ip_address ) {
1255 if ( ! $ip_address ) {
1256 return null;
1257 }
1258 // This filter allows site owners to disable IP address storage entirely as well as GeoIP lookups.
1259 // This filter is documented in src/contact-form/class-contact-form-plugin.php
1260 if ( apply_filters( 'jetpack_contact_form_forget_ip_address', false ) ) {
1261 return null;
1262 }
1263
1264 /**
1265 * Filter to get country code from IP address.
1266 *
1267 * @since $$NEXT_VERSION$$
1268 *
1269 * @param string|null $country The country code (e.g., 'US', 'GB', 'DE') or null.
1270 * @param string $ip_address The IP address to look up.
1271 * @param string $context The context for the geolocation request.
1272 */
1273 $country = apply_filters( 'jetpack_get_country_from_ip', null, $ip_address, 'form-response' );
1274 if ( is_string( $country ) ) {
1275 return strtoupper( $country );
1276 }
1277
1278 $headers = array(
1279 'MM_COUNTRY_CODE',
1280 'GEOIP_COUNTRY_CODE',
1281 'HTTP_CF_IPCOUNTRY',
1282 'HTTP_X_COUNTRY_CODE',
1283 'HTTP_X_APPENGINE_COUNTRY',
1284 'HTTP_X_FORWARDED_FOR_COUNTRY',
1285 'HTTP_CLOUDFRONT_VIEWER_COUNTRY',
1286 );
1287
1288 // Check for headers from the server.
1289 foreach ( $headers as $header ) {
1290 if ( isset( $_SERVER[ $header ] ) ) {
1291 $country = sanitize_text_field( wp_unslash( $_SERVER[ $header ] ) );
1292 if ( ! empty( $country ) ) {
1293 return strtoupper( $country );
1294 }
1295 }
1296 }
1297
1298 if ( function_exists( 'geoip_country_code_by_name' ) ) {
1299 $country = geoip_country_code_by_name( $ip_address );
1300 if ( ! empty( $country ) ) {
1301 return strtoupper( $country );
1302 }
1303 }
1304
1305 $country = self::geolocate_via_api( $ip_address );
1306 if ( ! empty( $country ) ) {
1307 return strtoupper( $country );
1308 }
1309
1310 return null;
1311 }
1312
1313 /**
1314 * Use APIs to Geolocate the IP address.
1315 *
1316 * @param string $ip_address IP address.
1317 * @return string
1318 */
1319 private static function geolocate_via_api( $ip_address ) {
1320 $country_code = \get_transient( 'geoip_' . $ip_address );
1321 if ( false === $country_code ) {
1322 $response = Client::wpcom_json_api_request_as_blog(
1323 '/ip-to-geo/' . $ip_address,
1324 '2',
1325 array( 'method' => 'GET' ),
1326 null,
1327 'wpcom'
1328 );
1329
1330 if ( ! is_wp_error( $response ) && ! empty( $response['body'] ) ) {
1331 $data = json_decode( $response['body'] );
1332 $country_code = $data->country_short ?? '';
1333 $country_code = \sanitize_text_field( $country_code );
1334 // Share the transient with woocommerce to avoid multiple lookups.
1335 \set_transient( 'geoip_' . $ip_address, $country_code, DAY_IN_SECONDS );
1336 }
1337 }
1338 return $country_code;
1339 }
1340
1341 /**
1342 * Get the browser information from the user agent.
1343 *
1344 * Returns a formatted string like "Chrome (Desktop)" or "Safari (Mobile)".
1345 *
1346 * @return string|null Browser information or null if user agent is not available.
1347 */
1348 public function get_browser() {
1349 if ( empty( $this->user_agent ) ) {
1350 return null;
1351 }
1352
1353 // Use Jetpack Device Detection to parse the user agent.
1354 $ua_info = new User_Agent_Info( $this->user_agent );
1355
1356 // Get browser name.
1357 $browser_name = $ua_info->get_browser_display_name();
1358
1359 if ( $browser_name === User_Agent_Info::OTHER ) {
1360 return __( 'Unknown browser', 'jetpack-forms' );
1361 }
1362
1363 // Determine platform type (Mobile, Tablet, or Desktop).
1364 $platform_type = 'Desktop';
1365 if ( $ua_info->is_tablet() ) {
1366 $platform_type = 'Tablet';
1367 } elseif ( $ua_info->get_platform() ) {
1368 // If there's a mobile platform detected (not false), it's mobile.
1369 $platform_type = 'Mobile';
1370 }
1371
1372 return sprintf( '%s (%s)', $browser_name, $platform_type );
1373 }
1374
1375 /**
1376 * Get the logged-in user information who submitted the feedback.
1377 *
1378 * @return array|null Array with 'display_name' and 'id' keys, or null if not logged in.
1379 */
1380 public function get_logged_in_user() {
1381 return $this->logged_in_user;
1382 }
1383
1384 /**
1385 * Get the email subject.
1386 *
1387 * @return string
1388 */
1389 public function get_subject() {
1390 return $this->subject;
1391 }
1392
1393 /**
1394 * Gets the notification recipients of the feedback entry.
1395 *
1396 * @return array
1397 */
1398 public function get_notification_recipients() {
1399 return $this->notification_recipients;
1400 }
1401
1402 /**
1403 * Gets the value of the consent field.
1404 *
1405 * @return bool
1406 */
1407 public function has_consent() {
1408 return $this->has_consent;
1409 }
1410
1411 /**
1412 * Gets the value of the consent field.
1413 *
1414 * @return bool
1415 */
1416 public function has_file() {
1417 return $this->has_file;
1418 }
1419
1420 /**
1421 * Check if the feedback is unread.
1422 *
1423 * @return bool
1424 */
1425 public function is_unread() {
1426 return $this->is_unread;
1427 }
1428
1429 /**
1430 * Mark the feedback as read.
1431 *
1432 * @return bool True on success, false on failure.
1433 */
1434 public function mark_as_read() {
1435 if ( ! $this->post_id ) {
1436 return false;
1437 }
1438
1439 $updated = wp_update_post(
1440 array(
1441 'ID' => $this->post_id,
1442 'comment_status' => self::STATUS_READ,
1443 )
1444 );
1445
1446 if ( ! is_wp_error( $updated ) && $updated ) {
1447 $this->is_unread = false;
1448 return true;
1449 }
1450
1451 return false;
1452 }
1453
1454 /**
1455 * Mark the feedback as unread.
1456 *
1457 * @return bool True on success, false on failure.
1458 */
1459 public function mark_as_unread() {
1460 if ( ! $this->post_id ) {
1461 return false;
1462 }
1463
1464 $updated = wp_update_post(
1465 array(
1466 'ID' => $this->post_id,
1467 'comment_status' => self::STATUS_UNREAD,
1468 )
1469 );
1470
1471 if ( ! is_wp_error( $updated ) && $updated ) {
1472 $this->is_unread = true;
1473 return true;
1474 }
1475
1476 return false;
1477 }
1478
1479 /**
1480 * Get the count of unread feedback entries.
1481 *
1482 * @return int
1483 */
1484 public static function get_unread_count() {
1485 $query = new \WP_Query(
1486 array(
1487 'post_type' => self::POST_TYPE,
1488 'post_status' => 'publish',
1489 'comment_status' => self::STATUS_UNREAD,
1490 'posts_per_page' => -1,
1491 'fields' => 'ids',
1492 )
1493 );
1494 return (int) $query->found_posts;
1495 }
1496
1497 /**
1498 * Get the uploaded files from the feedback entry.
1499 *
1500 * @return array
1501 */
1502 public function get_files() {
1503 $files = array();
1504 foreach ( $this->fields as $field ) {
1505 if ( $field->get_type() === 'file' ) {
1506 $field_value = $field->get_value();
1507 if ( ! empty( $field_value['files'] ) && is_array( $field_value['files'] ) ) {
1508 $field_value['files'] = array_filter(
1509 $field_value['files'],
1510 function ( $file ) {
1511 if ( empty( $file['file_id'] ) ) {
1512 return false;
1513 }
1514 if ( empty( $file['name'] ) ) {
1515 return false;
1516 }
1517 if ( empty( $file['size'] ) ) {
1518 return false;
1519 }
1520 if ( empty( $file['type'] ) ) {
1521 return false;
1522 }
1523 return true;
1524 }
1525 );
1526
1527 $files = array_merge( $files, $field_value['files'] );
1528 }
1529 }
1530 }
1531 return $files;
1532 }
1533
1534 /**
1535 * Get the feedback status. For example 'publish', 'spam' or 'trash'.
1536 *
1537 * @return string
1538 */
1539 public function get_status() {
1540 return $this->status;
1541 }
1542
1543 /**
1544 * Sets the status of the feedback.
1545 *
1546 * @param string $status The status to set for the feedback entry.
1547 * @return void
1548 */
1549 public function set_status( $status ) {
1550 $this->status = $status;
1551 }
1552
1553 /**
1554 * Get the entry ID of the post that the feedback was submitted from.
1555 *
1556 * This is the post ID of the post or page that the feedback was submitted from.
1557 *
1558 * @return int|string
1559 */
1560 public function get_entry_id() {
1561 return $this->source->get_id();
1562 }
1563
1564 /**
1565 * Get the entry title of the post that the feedback was submitted from.
1566 *
1567 * This is the title of the post or page that the feedback was submitted from.
1568 *
1569 * @return string
1570 */
1571 public function get_entry_title() {
1572 return $this->source->get_title();
1573 }
1574
1575 /**
1576 * Get the permalink of the post or page that the feedback was submitted from.
1577 * This includes the page number if the feedback was submitted from a paginated form.
1578 *
1579 * @return string
1580 */
1581 public function get_entry_permalink() {
1582 return $this->source->get_permalink();
1583 }
1584
1585 /**
1586 * Get the editor URL where the user can edit the form.
1587 *
1588 * @return string
1589 */
1590 public function get_edit_form_url() {
1591 if ( ! empty( $this->form_id ) ) {
1592 return \get_edit_post_link( (int) $this->form_id, 'url' );
1593 }
1594 return $this->source->get_edit_form_url();
1595 }
1596 /**
1597 * Get the short permalink of a post.
1598 *
1599 * @return string
1600 */
1601 public function get_entry_short_permalink() {
1602 return $this->source->get_relative_permalink();
1603 }
1604
1605 /**
1606 * Whether this feedback was submitted from a form preview (test submission).
1607 *
1608 * @return bool
1609 */
1610 public function is_test() {
1611 return $this->source->is_test();
1612 }
1613
1614 /**
1615 * Flag this feedback as a test submission from form preview.
1616 *
1617 * @return void
1618 */
1619 public function mark_as_test() {
1620 $this->source->set_is_test( true );
1621 }
1622
1623 /**
1624 * Save the feedback entry to the database.
1625 *
1626 * @return int
1627 */
1628 public function save() {
1629 $post_id = wp_insert_post(
1630 array(
1631 'post_type' => self::POST_TYPE,
1632 'post_status' => $this->status,
1633 'post_title' => $this->legacy_feedback_title,
1634 'post_date' => $this->feedback_time,
1635 'post_name' => $this->legacy_feedback_id,
1636 'post_content' => $this->serialize(), // In V3 we started to addslashes.
1637 'post_mime_type' => 'v3', // a way to help us identify what version of the data this is.
1638 'post_parent' => $this->form_id ?? $this->source->get_id(),
1639 'comment_status' => self::STATUS_UNREAD, // New feedback is unread by default.
1640 )
1641 );
1642
1643 // Store source post ID as meta for queryable source filtering.
1644 $source_id = $this->source->get_id();
1645 if ( is_numeric( $post_id ) && (int) $post_id > 0 && is_numeric( $source_id ) && (int) $source_id > 0 ) {
1646 add_post_meta( $post_id, self::SOURCE_META_KEY, (int) $source_id, true );
1647 wp_cache_delete( self::SOURCE_IDS_CACHE_KEY, self::CACHE_GROUP );
1648 }
1649
1650 // Flag test submissions with a post meta so the REST collection can
1651 // filter them via meta_query without unpacking the serialized source.
1652 if ( is_numeric( $post_id ) && (int) $post_id > 0 && $this->source->is_test() ) {
1653 add_post_meta( $post_id, self::IS_TEST_META_KEY, 1, true );
1654 }
1655
1656 // If this feedback does not have a jetpack_form parent,
1657 // it's a classic form — mark the state accordingly.
1658 if ( empty( $this->form_id ) ) {
1659 Forms_Dashboard::mark_classic_form_detected();
1660 }
1661
1662 $feedback_post = get_post( $post_id );
1663 return $feedback_post ?? 0;
1664 }
1665
1666 /**
1667 * Serialize the fields to JSON format.
1668 *
1669 * @return string
1670 */
1671 public function serialize() {
1672
1673 $fields_to_serialize = array_merge(
1674 array(
1675 'subject' => $this->subject,
1676 'ip' => $this->ip_address,
1677 'country_code' => $this->country_code,
1678 'user_agent' => $this->user_agent,
1679 'form_fill_duration' => $this->form_fill_duration,
1680 'notification_recipients' => $this->notification_recipients,
1681 'logged_in_user' => $this->logged_in_user,
1682 ),
1683 $this->source->serialize()
1684 );
1685
1686 $fields_to_serialize['fields'] = array();
1687 foreach ( $this->fields as $field ) {
1688 $fields_to_serialize['fields'][] = $field->serialize();
1689 }
1690
1691 // Check if the IP and country_code should be included.
1692 if ( apply_filters( 'jetpack_contact_form_forget_ip_address', false, $this->ip_address ) ) {
1693 $fields_to_serialize['ip'] = null;
1694 $fields_to_serialize['country_code'] = null;
1695 }
1696
1697 /*
1698 * JSON_HEX_TAG escapes every `<` and `>` as a \u003C / \u003E sequence,
1699 * which is what keeps this payload intact on the way into the database.
1700 * It is load-bearing here, not cosmetic - do not drop it.
1701 *
1702 * This payload is written to `post_content`, and any submitter without
1703 * `unfiltered_html` - every logged-out visitor, and every non-super-admin
1704 * on a multisite - has `wp_filter_post_kses` attached to `content_save_pre`.
1705 * A bare `<` anywhere in the payload (a field label, a submitted value, or
1706 * the source page title) then reads as the start of a tag, and core's
1707 * `wp_pre_kses_less_than()` runs esc_html() over everything from that `<` to
1708 * the end of the string. The quotes inside it become `&quot;`, so
1709 * `json_decode()` can no longer read the payload and the entire response
1710 * comes back empty.
1711 *
1712 * `json_decode()` resolves the escaped sequences natively, so no decode step
1713 * is needed and payloads written before this flag was added still parse.
1714 *
1715 * This deliberately diverges from Jetpack.Functions.JsonEncodeFlags, which
1716 * recommends JSON_UNESCAPED_SLASHES alone for database-field writes. That
1717 * guidance assumes the write is not KSES-filtered; this one is.
1718 */
1719 return addslashes( wp_json_encode( $fields_to_serialize, JSON_UNESCAPED_SLASHES | JSON_HEX_TAG ) );
1720 }
1721
1722 /**
1723 * Helper function to parse the post content.
1724 *
1725 * @param string $post_content The post content to parse.
1726 * @param string|null $version The version of the content format.
1727 * @return array Parsed fields.
1728 */
1729 private function parse_content( $post_content = '', $version = null ) {
1730 if ( $version === 'v3' ) {
1731 $this->uses_structured_fields = true;
1732 return $this->parse_content_v3( $post_content );
1733 }
1734 if ( $version === 'v2' ) {
1735 $this->uses_structured_fields = true;
1736 return $this->parse_content_v2( $post_content );
1737 }
1738
1739 // Some feedback posts store JSON content without a version marker
1740 // (empty post_mime_type). Parse those as the current (v3) format instead
1741 // of falling through to the legacy plain-text parser.
1742 if ( self::is_json( $post_content ) ) {
1743 $decoded_content = json_decode( $post_content, true );
1744 if ( $decoded_content === null ) {
1745 // Content may be slash-escaped as stored by WordPress; retry,
1746 // mirroring the fallback used by the v2/v3 parsers.
1747 $decoded_content = json_decode( stripslashes( trim( $post_content ) ), true );
1748 }
1749 if ( isset( $decoded_content['fields'] ) && is_array( $decoded_content['fields'] ) ) {
1750 $this->uses_structured_fields = true;
1751 return $this->parse_content_v3( $post_content );
1752 }
1753 }
1754
1755 return $this->parse_legacy_content( $post_content );
1756 }
1757
1758 /**
1759 * Check whether a string looks like and decodes as valid JSON.
1760 *
1761 * Accepts slash-escaped JSON (as WordPress may store it), mirroring the
1762 * stripslashes fallback used by the v2/v3 parsers.
1763 *
1764 * @param string $string The string to test.
1765 * @return bool True if the string is a JSON object or array.
1766 */
1767 private static function is_json( $string ) {
1768 if ( ! is_string( $string ) || $string === '' ) {
1769 return false;
1770 }
1771 $string = trim( $string );
1772 if ( ! str_starts_with( $string, '{' ) && ! str_starts_with( $string, '[' ) ) {
1773 return false;
1774 }
1775 $decoded = json_decode( $string );
1776 if ( $decoded !== null && json_last_error() === JSON_ERROR_NONE ) {
1777 return true;
1778 }
1779 $decoded = json_decode( stripslashes( $string ) );
1780 return $decoded !== null && json_last_error() === JSON_ERROR_NONE;
1781 }
1782
1783 /**
1784 * Parse the content in the v2 format.
1785 *
1786 * V2 Format was a short lived format that accidently contains slash escaped unicode characters.
1787 *
1788 * @param string $post_content The post content to parse.
1789 *
1790 * @return array Parsed fields.
1791 */
1792 private function parse_content_v2( $post_content = '' ) {
1793 $decoded_content = json_decode( $post_content, true );
1794 if ( $decoded_content === null ) {
1795 // If JSON decoding still fails, try with stripslashes and trim as a fallback
1796 // This is a workaround for some cases where the JSON data is not properly formatted
1797 $decoded_content = json_decode( stripslashes( trim( $post_content ) ), true );
1798 }
1799
1800 if ( $decoded_content === null ) {
1801 // Final fallback: attempt to fix malformed JSON with unescaped quotes
1802 // Apply stripslashes first, then fix remaining issues
1803 $stripped_content = stripslashes( trim( $post_content ) );
1804 $fixed_content = self::fix_malformed_json( $stripped_content );
1805 $decoded_content = json_decode( $fixed_content, true );
1806 }
1807
1808 if ( $decoded_content === null ) {
1809 return array();
1810 }
1811 $fields = array();
1812 foreach ( $decoded_content['fields'] as $field ) {
1813 $feedback_field = Feedback_Field::from_serialized_v2( $field );
1814 if ( $feedback_field instanceof Feedback_Field ) {
1815 $fields[ $feedback_field->get_key() ] = $feedback_field;
1816 if ( ! $this->has_file && $feedback_field->has_file() ) {
1817 $this->has_file = true;
1818 }
1819 }
1820 }
1821 $decoded_content['fields'] = $fields;
1822 return $decoded_content;
1823 }
1824
1825 /**
1826 * Parse the content in the v3 format.
1827 *
1828 * @param string $post_content The post content to parse.
1829 *
1830 * @return array Parsed fields.
1831 */
1832 private function parse_content_v3( $post_content = '' ) {
1833 $decoded_content = json_decode( $post_content, true );
1834 if ( $decoded_content === null ) {
1835 // If JSON decoding fails, try to decode the second try with stripslashes and trim.
1836 // This is a workaround for some cases where the JSON data is not properly formatted.
1837 $decoded_content = json_decode( stripslashes( trim( $post_content ) ), true );
1838 }
1839 if ( $decoded_content === null ) {
1840 return array();
1841 }
1842 $fields = array();
1843 foreach ( $decoded_content['fields'] as $field ) {
1844 $feedback_field = Feedback_Field::from_serialized( $field );
1845 if ( $feedback_field instanceof Feedback_Field ) {
1846 $fields[ $feedback_field->get_key() ] = $feedback_field;
1847 if ( ! $this->has_file && $feedback_field->has_file() ) {
1848 $this->has_file = true;
1849 }
1850 }
1851 }
1852 $decoded_content['fields'] = $fields;
1853 return $decoded_content;
1854 }
1855
1856 /**
1857 * Parse the legacy content format.
1858 *
1859 * @param string $post_content The post content to parse.
1860 *
1861 * @return array Parsed fields.
1862 */
1863 private function parse_legacy_content( $post_content = '' ) {
1864 $content_parts = $this->split_legacy_content( $post_content );
1865 $comment_content = $content_parts['comment_content'];
1866 $field_content = $content_parts['field_content'];
1867
1868 $all_values = $this->extract_legacy_values( $field_content );
1869 $lines = $this->extract_legacy_lines( $field_content );
1870
1871 $decoded_fields = array();
1872 $decoded_fields['fields'] = array();
1873
1874 // Process lines for specific field types
1875 $this->process_legacy_lines( $lines, $decoded_fields );
1876
1877 // Process all other values
1878 $this->process_legacy_values( $all_values, $decoded_fields );
1879
1880 // Add comment content field
1881 $this->add_comment_content_field( $comment_content, $decoded_fields );
1882
1883 return $decoded_fields;
1884 }
1885
1886 /**
1887 * Attempt to fix malformed JSON by escaping unescaped quotes in string values.
1888 *
1889 * This method handles cases where JSON contains unescaped quotes within string values,
1890 * which causes json_decode to fail.
1891 *
1892 * @param string $json malformed JSON string.
1893 * @return string The JSON string with escaped quotes.
1894 */
1895 public static function fix_malformed_json( $json ) {
1896
1897 $find = array();
1898 $replace = array();
1899
1900 // Start of JSON object
1901 $find[] = '{\"';
1902 $replace[] = '{"';
1903
1904 // Key-value separator
1905 $find[] = '\":\"';
1906 $replace[] = '":"';
1907
1908 $find[] = '\\\"';
1909 $replace[] = '\"';
1910
1911 $find[] = '\":[\"';
1912 $replace[] = '":["';
1913
1914 $find[] = '\"],';
1915 $replace[] = '"],';
1916
1917 $find[] = ',[\"';
1918 $replace[] = ',["';
1919
1920 $find[] = '\",\"';
1921 $replace[] = '","';
1922
1923 $find[] = ',\"';
1924 $replace[] = ',"';
1925
1926 $find[] = '\", \"';
1927 $replace[] = '", "';
1928
1929 $find[] = '\"],\"';
1930 $replace[] = '"],"';
1931
1932 $find[] = '\"],"';
1933 $replace[] = '"],"';
1934
1935 $find[] = '\":[]';
1936 $replace[] = '":[]';
1937
1938 $find[] = '\"]}';
1939 $replace[] = '"]}';
1940
1941 $find[] = '\":[';
1942 $replace[] = '":[';
1943
1944 $find[] = '\":{';
1945 $replace[] = '":{';
1946
1947 $find[] = '\":true';
1948 $replace[] = '":true';
1949
1950 $find[] = '\":false';
1951 $replace[] = '":false';
1952
1953 $find[] = '\":null';
1954 $replace[] = '":null';
1955
1956 for ( $i = 0; $i <= 9; $i++ ) {
1957 $find[] = '\":' . $i;
1958 $replace[] = '":' . $i;
1959
1960 $find[] = '\",' . $i;
1961 $replace[] = '",' . $i;
1962 }
1963
1964 $find[] = '\",true';
1965 $replace[] = '",true';
1966
1967 $find[] = '\",false';
1968 $replace[] = '",false';
1969
1970 $find[] = '\",null';
1971 $replace[] = '",null';
1972
1973 $find[] = "\'";
1974 $replace[] = "'";
1975
1976 // End of Json object
1977 $find[] = '\"}';
1978 $replace[] = '"}';
1979
1980 // Remove any slashes that are there to start a new string.
1981 return str_replace( $find, $replace, addslashes( $json ) );
1982 }
1983
1984 /**
1985 * Split legacy content into comment and field sections.
1986 *
1987 * @param string $post_content The post content to parse.
1988 * @return array Array with 'comment_content' and 'field_content' keys.
1989 */
1990 private function split_legacy_content( $post_content ) {
1991 $content = explode( '<!--more-->', $post_content );
1992 $comment_content = '';
1993 $field_content = '';
1994
1995 if ( count( $content ) > 1 ) {
1996 $comment_content = $content[0];
1997 $field_content = str_ireplace( array( '<br />', ')</p>' ), '', $content[1] );
1998 }
1999
2000 return array(
2001 'comment_content' => $comment_content,
2002 'field_content' => $field_content,
2003 );
2004 }
2005
2006 /**
2007 * Extract values from legacy field content.
2008 *
2009 * @param string $field_content The field content to parse.
2010 * @return array Extracted values.
2011 */
2012 private function extract_legacy_values( $field_content ) {
2013 $all_values = array();
2014
2015 if ( str_contains( $field_content, 'JSON_DATA' ) ) {
2016 $all_values = $this->parse_json_data( $field_content );
2017 } else {
2018 $all_values = $this->parse_array_format( $field_content );
2019 }
2020
2021 // Ensure all_values is always an array
2022 if ( ! is_array( $all_values ) ) {
2023 $all_values = array();
2024 }
2025
2026 return $all_values;
2027 }
2028
2029 /**
2030 * Extract lines from legacy field content.
2031 *
2032 * @param string $field_content The field content to parse.
2033 * @return array Filtered lines.
2034 */
2035 private function extract_legacy_lines( $field_content ) {
2036 if ( str_contains( $field_content, 'JSON_DATA' ) ) {
2037 $chunks = explode( "\nJSON_DATA", $field_content );
2038 return array_filter( explode( "\n", $chunks[0] ) );
2039 } else {
2040 return array_filter( explode( "\n", $field_content ) );
2041 }
2042 }
2043
2044 /**
2045 * Parse JSON data from field content.
2046 *
2047 * @param string $field_content The field content containing JSON data.
2048 * @return array Parsed JSON data.
2049 */
2050 private function parse_json_data( $field_content ) {
2051 $chunks = explode( "\nJSON_DATA", $field_content );
2052
2053 if ( ! isset( $chunks[1] ) ) {
2054 // Try with 'JSON_DATA' without the newline as a fallback.
2055 $chunks = explode( 'JSON_DATA', $field_content );
2056 if ( ! isset( $chunks[1] ) ) {
2057 // If JSON_DATA is still not found, return an empty array.
2058 return array();
2059 }
2060 }
2061
2062 $json_data = $chunks[1];
2063
2064 $all_values = json_decode( $json_data, true );
2065
2066 if ( $all_values === null ) {
2067 // Fallback for improperly formatted JSON
2068 $all_values = json_decode( stripslashes( trim( $json_data ) ), true );
2069 }
2070
2071 return $all_values === null ? array() : $all_values;
2072 }
2073
2074 /**
2075 * Parse array format from field content.
2076 *
2077 * @param string $field_content The field content in array format.
2078 * @return array Parsed array data.
2079 */
2080 private function parse_array_format( $field_content ) {
2081 $fields_array = preg_replace( '/.*Array\s\( (.*)\)/msx', '$1', $field_content );
2082
2083 // Parse key-value pairs formatted as [Key] => Value
2084 preg_match_all( '/^\s*\[([^\]]+)\] =\&gt\; (.*)(?=^\s*(\[[^\]]+\] =\&gt\;)|\z)/msU', $fields_array, $matches );
2085
2086 if ( count( $matches ) > 1 ) {
2087 return array_combine( array_map( 'trim', $matches[1] ), array_map( 'trim', $matches[2] ) );
2088 }
2089
2090 return array();
2091 }
2092
2093 /**
2094 * Process legacy lines into field objects.
2095 *
2096 * We do this so that we can extract specific fields but we don't display the values in the UI.
2097 *
2098 * @param array $lines The lines to process.
2099 * @param array &$decoded_fields Reference to the decoded fields array.
2100 */
2101 private function process_legacy_lines( $lines, &$decoded_fields ) {
2102 $var_map = array(
2103 'AUTHOR' => array(
2104 'type' => 'name',
2105 'label' => 'Author',
2106 ),
2107 'AUTHOR EMAIL' => array(
2108 'type' => 'email',
2109 'label' => 'Email',
2110 ),
2111 'AUTHOR URL' => array(
2112 'type' => 'url',
2113 'label' => 'Url',
2114 ),
2115 'SUBJECT' => array(
2116 'type' => 'subject',
2117 'label' => 'Subject',
2118 ),
2119 'IP' => array(
2120 'type' => 'ip',
2121 'label' => 'IP',
2122 ),
2123 );
2124
2125 foreach ( $lines as $line ) {
2126 $line_parts = explode( ': ', $line, 2 );
2127
2128 if ( count( $line_parts ) !== 2 ) {
2129 continue;
2130 }
2131
2132 list( $key, $value ) = $line_parts;
2133
2134 if ( ! empty( $key ) && isset( $var_map[ $key ] ) ) {
2135 $map_to_field = $var_map[ $key ];
2136 $value = Contact_Form_Plugin::strip_tags( trim( $value ) );
2137
2138 $decoded_fields['fields'][ $key ] = new Feedback_Field(
2139 $key,
2140 $map_to_field['label'],
2141 $value,
2142 $map_to_field['type'],
2143 array( 'render' => false )
2144 );
2145 }
2146 }
2147 }
2148
2149 /**
2150 * Check if the field is a legacy file upload.
2151 *
2152 * @param array $field The field to check.
2153 *
2154 * @return bool True if it's a legacy file upload, false otherwise.
2155 */
2156 private function is_legacy_file_upload( $field ) {
2157 return (
2158 is_array( $field ) &&
2159 ! empty( $field['field_id'] ) &&
2160 isset( $field['files'] ) &&
2161 is_array( $field['files'] )
2162 );
2163 }
2164
2165 /**
2166 * Process legacy values into field objects.
2167 *
2168 * @param array $all_values The values to process.
2169 * @param array &$decoded_fields Reference to the decoded fields array.
2170 */
2171 private function process_legacy_values( $all_values, &$decoded_fields ) {
2172 $non_user_fields = array(
2173 'email_marketing_consent',
2174 'entry_title',
2175 'entry_permalink',
2176 'entry_page',
2177 'feedback_id',
2178 );
2179
2180 foreach ( $all_values as $key => $value ) {
2181 $key = wp_strip_all_tags( $key );
2182 $label = self::extract_label_from_key( $key );
2183
2184 if ( in_array( $key, $non_user_fields, true ) ) {
2185 if ( $key === 'email_marketing_consent' ) {
2186 $decoded_fields['fields'][ $key ] = new Feedback_Field(
2187 $key,
2188 $label,
2189 $value,
2190 'consent',
2191 array( 'render' => false )
2192 );
2193 continue;
2194 }
2195 $decoded_fields[ $key ] = $value;
2196 continue;
2197 }
2198
2199 // check for file upload data and then set it as a file type field.
2200 if ( $this->is_legacy_file_upload( $value ) ) {
2201 // If the value is a file upload, we need to handle it differently.
2202 $decoded_fields['fields'][ $key ] = new Feedback_Field(
2203 $key,
2204 $label,
2205 $value,
2206 'file'
2207 );
2208 $this->has_file = ! empty( $value['files'] ); // Set has_file to true if any file upload is found.
2209 } else {
2210 $decoded_fields['fields'][ $key ] = new Feedback_Field( $key, $label, $value );
2211 }
2212 }
2213 }
2214
2215 /**
2216 * Add comment content as a field.
2217 *
2218 * @param string $comment_content The comment content.
2219 * @param array &$decoded_fields Reference to the decoded fields array.
2220 */
2221 private function add_comment_content_field( $comment_content, &$decoded_fields ) {
2222 $decoded_fields['fields']['comment_content'] = new Feedback_Field(
2223 'comment_content',
2224 'Comment Content',
2225 trim( Contact_Form_Plugin::strip_tags( $comment_content ) ),
2226 'textarea',
2227 array( 'render' => false )
2228 );
2229 }
2230
2231 /**
2232 * Extract the label from a key that might be in the format "1_label".
2233 *
2234 * @param string $key The key to extract the label from.
2235 * @return string The extracted label.
2236 */
2237 private static function extract_label_from_key( $key ) {
2238 // Check if the key starts with a number followed by underscore and has content after underscore
2239 if ( preg_match( '/^\d+_(.+)$/', $key, $matches ) ) {
2240 return $matches[1];
2241 }
2242 // If the key is just a number followed by underscore (like "2_"), return empty string
2243 if ( preg_match( '/^\d+_$/', $key ) ) {
2244 return '';
2245 }
2246 // If the key doesn't start with a number followed by underscore, return the key as is
2247 return $key;
2248 }
2249
2250 /**
2251 * Get field-specific metadata based on the field type.
2252 *
2253 * @param Contact_Form_Field $field The field object.
2254 * @param string $type The field type.
2255 * @return array Metadata array for the field.
2256 */
2257 public static function get_field_meta( $field, $type ) {
2258 $meta = array();
2259
2260 if ( $type === 'rating' ) {
2261 $icon_style = $field->get_attribute( 'iconstyle' );
2262 $max = $field->get_attribute( 'max' );
2263 $meta['iconStyle'] = ! empty( $icon_style ) ? $icon_style : 'stars';
2264 $meta['maxRating'] = is_numeric( $max ) && (int) $max > 0 ? (int) $max : 5;
2265 }
2266
2267 return $meta;
2268 }
2269
2270 /**
2271 * Get all the fields of the response, computed from the post data.
2272 *
2273 * @param array $post_data The post data from the form submission.
2274 * @param Contact_Form $form The form object.
2275 * @return array An array of Feedback_Field objects.
2276 */
2277 private function get_computed_fields( $post_data, $form ) {
2278
2279 $fields = array();
2280
2281 $field_ids = $form->get_field_ids();
2282
2283 // Collect renderable fields and their submitted values up front so conditional logic
2284 // rules (which may reference any sibling field) can be evaluated in the loop below.
2285 $renderable = array();
2286 $form_values = array();
2287 foreach ( $field_ids['all'] as $field_id ) {
2288 $field = $form->fields[ $field_id ];
2289 $type = $field->get_attribute( 'type' );
2290 if ( ! $field->is_field_renderable( $type ) ) {
2291 continue;
2292 }
2293 $value = $this->get_field_value( $field_id, $post_data, $type );
2294 $form_values[ $field_id ] = $value;
2295 $renderable[ $field_id ] = array(
2296 'field' => $field,
2297 'type' => $type,
2298 'value' => $value,
2299 );
2300 }
2301
2302 // Ask the form, rather than resolving a second time.
2303 //
2304 // Storage used to run its own resolve_visibility() over a different value source and a
2305 // different field set than validation did, and the two disagreed. Validation reads
2306 // get_computed_field_value() -- POST, then GET, then the field's default, then the
2307 // logged-in user -- while this loop reads POST only; and it skips anything
2308 // is_field_renderable() rejects, so a rule whose subject is an option-less select was
2309 // evaluated during validation and ignored here.
2310 //
2311 // Unchecking a consent field prefilled from a query argument hit both: the browser
2312 // posts nothing, validation fell back to the query argument and read it checked,
2313 // storage read '' and read it unchecked. The dependent field was required-validated
2314 // and then had its answer dropped -- the silently discarded answer this feature is
2315 // supposed to make impossible.
2316 //
2317 // Returns an empty array when conditional logic does not apply, so there is nothing extra to guard.
2318 $visibility = $form->get_resolved_field_visibility();
2319
2320 $i = 1;
2321 foreach ( $renderable as $field_id => $entry ) {
2322 $field = $entry['field'];
2323 $type = $entry['type'];
2324 $value = $entry['value'];
2325
2326 if ( isset( $visibility[ $field_id ] ) && false === $visibility[ $field_id ] ) {
2327 continue;
2328 }
2329
2330 $label = wp_strip_all_tags( $field->get_attribute( 'label' ) );
2331 $key = $i . '_' . $label;
2332
2333 $meta = self::get_field_meta( $field, $type );
2334
2335 // Process radio fields to detect and extract "Other" option metadata
2336 if ( $type === 'radio' ) {
2337 $processed = $this->process_radio_field_value( $value, $field, $field_id, $post_data );
2338 $value = $processed['value'];
2339 $meta = array_merge( $meta, $processed['meta'] );
2340 }
2341 $fields[ $key ] = new Feedback_Field( $key, $label, $value, $type, $meta, $field_id );
2342 if ( ! $this->has_file && $fields[ $key ]->has_file() ) {
2343 $this->has_file = true;
2344 }
2345 ++$i;
2346 }
2347
2348 return $fields;
2349 }
2350
2351 /**
2352 * Gets the computed subject.
2353 *
2354 * @param array $post_data The post data from the form submission.
2355 * @param Contact_Form $form The form object.
2356 * @return string
2357 */
2358 private function get_computed_subject( $post_data, $form ) {
2359
2360 $contact_form_subject = $form->get_attribute( 'subject' );
2361 $field_ids = $form->get_field_ids();
2362
2363 if ( isset( $field_ids['subject'] ) ) {
2364 $value = $this->get_field_value( $field_ids['subject'], $post_data );
2365 if ( ! empty( $value ) ) {
2366 $contact_form_subject = $value;
2367 }
2368 }
2369
2370 return apply_filters( 'contact_form_subject', $contact_form_subject, $this->get_all_values() );
2371 }
2372
2373 /**
2374 * Gets the computed comment content.
2375 *
2376 * @param array $post_data The post data from the form submission.
2377 * @param Contact_Form $form The form object.
2378 * @return string
2379 */
2380 private function get_computed_comment_content( $post_data, $form ) {
2381 $field_ids = $form->get_field_ids();
2382 if ( isset( $field_ids['textarea'] ) ) {
2383 $value = $this->get_field_value( $field_ids['textarea'], $post_data );
2384 if ( is_string( $value ) ) {
2385 return trim( Contact_Form_Plugin::strip_tags( stripslashes( $value ) ) );
2386 }
2387 }
2388 return '';
2389 }
2390
2391 /**
2392 * Gets the computed consent.
2393 *
2394 * @param array $post_data The post data from the form submission.
2395 * @param Contact_Form $form The form object.
2396 * @return bool
2397 */
2398 private function get_computed_consent( $post_data, $form ) {
2399 $field_ids = $form->get_field_ids();
2400
2401 if ( isset( $field_ids['email_marketing_consent_field'] ) && $field_ids['email_marketing_consent_field'] !== null ) {
2402 return (bool) $this->get_field_value( $field_ids['email_marketing_consent_field'], $post_data );
2403 }
2404
2405 return false;
2406 }
2407
2408 /**
2409 * Gets the computed form fill duration, in seconds.
2410 *
2411 * The value is supplied by the view script as a hidden field, so it is submitter-controlled
2412 * and cannot be trusted. Anything that is not a plain sequence of digits is treated as
2413 * unknown and stored as null, rather than being coerced into a number that would read as a
2414 * real measurement: `absint()` alone would turn "abc" into 0 (indistinguishable from a
2415 * genuine sub-second fill), "-1" into 1, and a value past PHP_INT_MAX into a float, which
2416 * would contradict the integer type the REST schema advertises.
2417 *
2418 * The value is left empty when the submitter never interacted with the form, or ran without
2419 * JavaScript, which is also an unknown duration.
2420 *
2421 * @since 7.24.0
2422 *
2423 * @param array $post_data The post data from the form submission.
2424 * @return int|null
2425 */
2426 private function get_computed_form_fill_duration( $post_data ) {
2427 if ( ! isset( $post_data[ self::FORM_FILL_DURATION_FIELD ] ) ) {
2428 return null;
2429 }
2430
2431 $raw = $post_data[ self::FORM_FILL_DURATION_FIELD ];
2432
2433 // is_scalar() has to come first so an array-shaped POST does not blow up on the cast.
2434 if ( ! is_scalar( $raw ) || ! ctype_digit( (string) $raw ) ) {
2435 return null;
2436 }
2437
2438 // Clamp so an abandoned tab left open for days cannot skew aggregates.
2439 return min( (int) $raw, DAY_IN_SECONDS );
2440 }
2441
2442 /**
2443 * Gets the computed notification recipients.
2444 *
2445 * @since 6.10.0
2446 *
2447 * @param array $post_data The post data from the form submission.
2448 * @param Contact_Form $form The form object.
2449 * @return array
2450 */
2451 private function get_computed_notification_recipients( $post_data, $form ) {
2452 $notification_recipients = $form->get_attribute( 'notificationRecipients' );
2453 return $this->validate_notification_recipients( $notification_recipients );
2454 }
2455
2456 /**
2457 * Validates notification recipients have proper capabilities.
2458 *
2459 * Ensures each user ID corresponds to a real user with edit_posts or edit_pages capability.
2460 * Filters out invalid or unauthorized user IDs.
2461 *
2462 * @since 6.10.0
2463 *
2464 * @param array $recipients Array of user IDs.
2465 * @return array Array of validated user IDs.
2466 */
2467 private function validate_notification_recipients( $recipients ) {
2468 if ( ! is_array( $recipients ) ) {
2469 return array();
2470 }
2471
2472 $valid_recipients = array();
2473 foreach ( $recipients as $user_id ) {
2474 $user = get_userdata( $user_id );
2475 // Only allow users with edit_posts or edit_pages capability
2476 if ( $user && ( $user->has_cap( 'edit_posts' ) || $user->has_cap( 'edit_pages' ) ) ) {
2477 $valid_recipients[] = $user_id;
2478 }
2479 }
2480
2481 return $valid_recipients;
2482 }
2483
2484 /**
2485 * Get a field by its original form ID.
2486 *
2487 * @since 5.5.0
2488 *
2489 * @param string $id Original form field ID.
2490 * @return Feedback_Field|null
2491 */
2492 public function get_field_by_form_field_id( $id ) {
2493 if ( ! is_string( $id ) || $id === '' ) {
2494 return null;
2495 }
2496 foreach ( $this->fields as $field ) {
2497 if ( $field->get_form_field_id() === $id ) {
2498 return $field;
2499 }
2500 }
2501 return null;
2502 }
2503
2504 /**
2505 * Get a field render value by its original form ID.
2506 *
2507 * @since 5.5.0
2508 *
2509 * @param string $id Original form field ID.
2510 * @param string $context Render context.
2511 * @return string
2512 */
2513 public function get_field_value_by_form_field_id( $id, $context = 'default' ) {
2514 $field = $this->get_field_by_form_field_id( $id );
2515 if ( ! $field ) {
2516 return '';
2517 }
2518 return (string) $field->get_render_value( $context );
2519 }
2520 }
2521