PluginProbe
Jetpack – WP Security, Backup, Speed, & Growth / 16.3-a.7
Jetpack – WP Security, Backup, Speed, & Growth v16.3-a.7
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 13.8.3 All 506 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-a.7, at jetpack_vendor/automattic/jetpack-forms/src/contact-form/class-feedback.php

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