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

2,568 lines 74.0 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( array( self::class, 'sanitize_text_value' ), wp_unslash( $post_data[ $key ] ) );
624 } else {
625 return self::sanitize_text_value( wp_unslash( $post_data[ $key ] ) );
626 }
627 }
628 return '';
629 }
630
631 /**
632 * Sanitize a submitted text value.
633 *
634 * @param mixed $value The unslashed submitted value.
635 *
636 * @return string The sanitized value.
637 */
638 private static function sanitize_text_value( $value ) {
639 // Encoding first keeps tag-like text as typed instead of letting strip_tags() drop it.
640 return sanitize_textarea_field( self::encode_special_chars( $value ) );
641 }
642
643 /**
644 * HTML-encode `<`, `>` and `&` in a string, or in each string of a nested array.
645 *
646 * Encoding `&` as well keeps the result reversible: a literally-typed `&lt;` becomes
647 * `&amp;lt;`, distinct from an encoded `<` (`&lt;`), so decode_special_chars() restores it.
648 *
649 * @param mixed $value The value to encode.
650 *
651 * @return mixed The value, with the same shape.
652 */
653 public static function encode_special_chars( $value ) {
654 if ( is_array( $value ) ) {
655 return array_map( array( self::class, 'encode_special_chars' ), $value );
656 }
657
658 // ENT_SUBSTITUTE keeps a value with invalid UTF-8 from becoming ''; this runs before
659 // sanitize_textarea_field(), which is what would otherwise have scrubbed those bytes.
660 return is_string( $value ) ? htmlspecialchars( $value, ENT_NOQUOTES | ENT_SUBSTITUTE, 'UTF-8' ) : $value;
661 }
662
663 /**
664 * Reverse encode_special_chars() for a value that is about to be shown as text.
665 *
666 * @param mixed $value The stored value.
667 *
668 * @return mixed The value, with the same shape.
669 */
670 public static function decode_special_chars( $value ) {
671 if ( is_array( $value ) ) {
672 return array_map( array( self::class, 'decode_special_chars' ), $value );
673 }
674
675 return is_string( $value ) ? htmlspecialchars_decode( $value, ENT_NOQUOTES ) : $value;
676 }
677
678 /**
679 * Process the file field value.
680 *
681 * @param array $raw_data The raw post data from the file field.
682 *
683 * @return array The processed file data.
684 */
685 public static function process_file_field_value( $raw_data ) {
686 $file_data_array = is_array( $raw_data )
687 ? array_map(
688 function ( $json_str ) {
689 /*
690 * The entries come straight from $_POST, so any of them may be an array: a request
691 * carrying `field[1][x]=y` reaches here with a nested array where a JSON string is
692 * expected, and stripslashes() raises an uncaught TypeError on PHP 8. Nothing above
693 * this catches it, so an anonymous visitor could crash the submission with a 500.
694 *
695 * Contact_Form_Field::validate() sanitizes its own copy — which turns a nested array
696 * into '' — but that copy is not the one read here.
697 */
698 if ( ! is_string( $json_str ) ) {
699 return array(
700 'file_id' => '',
701 'name' => '',
702 'size' => 0,
703 'type' => '',
704 );
705 }
706
707 $decoded = json_decode( stripslashes( $json_str ), true );
708 return array(
709 'file_id' => isset( $decoded['file_id'] ) ? sanitize_text_field( $decoded['file_id'] ) : '',
710 'name' => isset( $decoded['name'] ) ? self::encode_special_chars( sanitize_text_field( $decoded['name'] ) ) : '',
711 'size' => isset( $decoded['size'] ) ? absint( $decoded['size'] ) : 0,
712 'type' => isset( $decoded['type'] ) ? self::encode_special_chars( sanitize_text_field( $decoded['type'] ) ) : '',
713 );
714 },
715 $raw_data
716 ) : array();
717
718 if ( empty( $file_data_array ) ) {
719 return array(
720 'files' => array(),
721 );
722 }
723
724 return array(
725 'files' => $file_data_array,
726 );
727 }
728
729 /**
730 * Process the image select field value.
731 *
732 * @param array $raw_data The raw post data from the image select field.
733 *
734 * @return array The processed image select data.
735 */
736 public static function process_image_select_field_value( $raw_data ) {
737 $value = array(
738 'type' => 'image-select',
739 'choices' => array(),
740 );
741
742 $selection_data_array = is_array( $raw_data )
743 ? array_map(
744 function ( $json_str ) {
745 return is_string( $json_str ) ? json_decode( stripslashes( $json_str ), true ) : null;
746 },
747 $raw_data
748 ) : array( is_string( $raw_data ) ? json_decode( stripslashes( $raw_data ), true ) : null );
749
750 if ( ! empty( $selection_data_array ) ) {
751 $value['choices'] = self::encode_special_chars( $selection_data_array );
752 }
753
754 return $value;
755 }
756
757 /**
758 * Process a radio field value to detect and extract "Other" option metadata.
759 *
760 * This method checks if a radio field value matches the "Other" pattern and combines
761 * it with the corresponding text input value if present.
762 *
763 * @param string $value The raw field value from the submission.
764 * @param object $field The field object from the form.
765 * @param string $field_id The field ID.
766 * @param array $post_data The POST data from the submission.
767 *
768 * @return array An array with 'value' and 'meta' keys.
769 */
770 private function process_radio_field_value( $value, $field, $field_id, $post_data ) {
771 $meta = array();
772 $allow_other = $field->get_attribute( 'allowother' );
773
774 if ( ! $allow_other || ! is_string( $value ) ) {
775 return array(
776 'value' => $value,
777 'meta' => $meta,
778 );
779 }
780
781 $options_data = $field->get_attribute( 'optionsdata' );
782 $other_label = null;
783
784 if ( ! empty( $options_data ) && is_array( $options_data ) ) {
785 foreach ( $options_data as $option ) {
786 if ( ! empty( $option['isOther'] ) ) {
787 $other_label = Contact_Form_Plugin::strip_tags( $option['label'] );
788 break;
789 }
790 }
791 }
792
793 if ( empty( $other_label ) ) {
794 return array(
795 'value' => $value,
796 'meta' => $meta,
797 );
798 }
799
800 if ( $value === $other_label ) {
801 $other_text_key = $field_id . '-other-text';
802 $custom_text = '';
803
804 if ( isset( $post_data[ $other_text_key ] ) ) {
805 $custom_text = self::sanitize_text_value( wp_unslash( $post_data[ $other_text_key ] ) );
806 }
807
808 $meta['is_other_option'] = true;
809 $meta['other_label'] = $other_label;
810 $meta['other_user_value'] = $custom_text;
811
812 if ( ! empty( $custom_text ) ) {
813 $value = $other_label . ': ' . $custom_text;
814 }
815 }
816
817 return array(
818 'value' => $value,
819 'meta' => $meta,
820 );
821 }
822
823 /**
824 * Get the computed fields from the post data.
825 *
826 * @param string $label The label of the field to look for.
827 * @param string $context The context in which the value is being rendered (default is 'default').
828 *
829 * @return string The Value of the field.
830 */
831 public function get_field_value_by_label( $label, $context = 'default' ) {
832 // This method is used to get the value of a field by its label.
833 foreach ( $this->fields as $field ) {
834 if ( $field->get_label( $context ) === $label ) {
835 return $field->get_render_value( $context );
836 }
837 }
838 return '';
839 }
840
841 /**
842 * Get the value of the field based on the first type found.
843 *
844 * @param string $type The type of the field to look for.
845 * @param string|null $filter Optional filter to apply to the value.
846 * @param string $context The context in which the value is being rendered (default is 'default').
847 *
848 * @return string The value of the first field of the specified type, or an empty string if not found.
849 */
850 private function get_first_field_of_type( $type, $filter = null, $context = 'default' ) {
851 // This method is used to get the first field of a specific type.
852 foreach ( $this->fields as $field ) {
853 if ( $field->get_type() === $type ) {
854 if ( $filter ) {
855 return Contact_Form_Plugin::strip_tags(
856 stripslashes(
857 /** This filter is already documented in core/wp-includes/comment-functions.php */
858 \apply_filters( $filter, addslashes( $field->get_render_value( $context ) ) )
859 )
860 );
861 }
862 return $field->get_render_value( $context );
863 }
864 }
865 return '';
866 }
867
868 /**
869 * Get all the fields of the response.
870 */
871 public function get_fields() {
872 return $this->fields;
873 }
874
875 /**
876 * Check whether this feedback contains at least one field of a given type.
877 *
878 * @param string $type Field type to check for (e.g. 'consent', 'email', 'textarea').
879 * @return bool True if a field of the given type exists; false otherwise.
880 */
881 public function has_field_type( $type ) {
882 foreach ( $this->fields as $field ) {
883 if ( $field->get_type() === $type ) {
884 return true;
885 }
886 }
887 return false;
888 }
889
890 /**
891 * Whether this response uses structured feedback fields.
892 *
893 * @return bool
894 */
895 public function uses_structured_fields() {
896 return $this->uses_structured_fields;
897 }
898
899 /**
900 * Get the values related to where the form was submitted from.
901 *
902 * @return array An array of entry values.
903 */
904 public function get_entry_values() {
905 // This is a convenience method to get the entry values in a simple array format.
906 $entry_values = array(
907 'email_marketing_consent' => (string) $this->has_consent ? 'yes' : 'no',
908 'entry_title' => $this->source->get_title(),
909 'entry_permalink' => $this->source->get_permalink(),
910 'feedback_id' => $this->legacy_feedback_id,
911 );
912
913 if ( $this->source->get_page_number() > 1 ) {
914 $entry_values['entry_page'] = $this->source->get_page_number();
915 }
916 return $entry_values;
917 }
918
919 /**
920 * Get all values of the response.
921 *
922 * @param string $context The context in which the values are being retrieved.
923 *
924 * @return array An array of all values, including fields and entry values.
925 */
926 public function get_all_values( $context = 'default' ) {
927 // This is a legacy method to maintain compatibility with older code.
928 return array_merge( $this->get_compiled_fields( $context, 'key-value' ), $this->get_entry_values() );
929 }
930
931 /**
932 * Get the jetpack_form post ID associated with this feedback.
933 *
934 * @return int|null The form ID, or null if not submitted via reusable form.
935 */
936 public function get_form_id() {
937 return $this->form_id;
938 }
939
940 /**
941 * Get extra values.
942 * This is a legacy method to maintain compatibility with older code.
943 *
944 * @param string $context The context in which the values are being retrieved.
945 *
946 * @return array An array of extra values, including entry values
947 */
948 public function get_legacy_extra_values( $context = 'default' ) {
949 $count = 1;
950 $_extra_fields = array();
951 $special_fields = array();
952 $non_extra_fields = array( 'email', 'name', 'url', 'subject', 'textarea', 'ip' );
953
954 // Create a map of special fields to check against their values.
955 foreach ( $this->fields as $field ) {
956 if ( in_array( $field->get_type(), $non_extra_fields, true ) ) {
957 $value = $field->get_render_value( $context );
958 if ( is_array( $value ) ) {
959 $value = reset( $value );
960 }
961 if ( $value ) {
962 $special_fields[ $value ] = true;
963 }
964 }
965 }
966
967 foreach ( $this->fields as $field ) {
968 if ( $field->compile_field( 'default' ) ) {
969 continue;
970 }
971 $render_value = $field->get_render_value();
972 if ( is_array( $render_value ) ) {
973 $render_value = reset( $render_value );
974 }
975 if ( $field->get_type() === 'basic' && $render_value && isset( $special_fields[ $render_value ] ) ) {
976 ++$count;
977 continue; // Skip fields that are already present in the non-extra fields.
978 }
979 $_extra_fields[] = $field;
980 ++$count; // Increment count to ensure unique keys for extra values.
981 }
982 $extra_values = array();
983 $extra_fields_count = $count;
984 $is_present = array(); // Used to store the value only once.
985
986 foreach ( $_extra_fields as $field ) {
987 if ( ! in_array( $field->get_type(), $non_extra_fields, true ) || isset( $is_present[ $field->get_type() ] ) ) {
988 $extra_values[ $extra_fields_count . '_' . $field->get_label() ] = $field->get_render_value( $context );
989 ++$extra_fields_count; // Increment count to ensure unique keys for extra values.
990 } else {
991 $is_present[ $field->get_type() ] = true;
992 }
993 }
994 return $extra_values;
995 }
996
997 /**
998 * Get all values of the response.
999 *
1000 * @return array An array of all values, including fields and entry values.
1001 */
1002 public function get_all_legacy_values() {
1003 return array(
1004 '_feedback_author' => $this->get_author(),
1005 '_feedback_author_email' => $this->get_author_email(),
1006 '_feedback_author_url' => $this->get_author_url(),
1007 '_feedback_subject' => $this->get_subject(),
1008 '_feedback_ip' => $this->get_ip_address(),
1009 '_feedback_all_fields' => $this->get_all_values(),
1010 );
1011 }
1012 /**
1013 * Return the compiled fields for the given context.
1014 *
1015 * @param string $context The context in which the fields are compiled.
1016 * @param string $array_shape The shape of the array to return. Can be 'all', 'value', 'label', or 'key-value'.
1017 *
1018 * @return array An array of compiled fields with labels and values.
1019 */
1020 public function get_compiled_fields( $context = 'default', $array_shape = 'all' ) {
1021 $compiled_fields = array();
1022
1023 $count_field_labels = array();
1024 foreach ( $this->fields as $field ) {
1025 if ( $field->compile_field( $context ) ) {
1026 continue; // Skip fields that are not meant to be rendered.
1027 }
1028
1029 // Don't show the hidden fields in the user context.
1030 if ( in_array( $context, array( 'web', 'ajax' ), true ) ) {
1031 if ( $field->is_of_type( 'hidden' ) ) {
1032 continue;
1033 }
1034 }
1035
1036 $label = $field->get_label( $context );
1037
1038 if ( ! isset( $count_field_labels[ $label ] ) ) {
1039 $count_field_labels[ $label ] = 1;
1040 } else {
1041 ++$count_field_labels[ $label ];
1042 }
1043
1044 // Compile the field based on the requested shape.
1045 switch ( $array_shape ) {
1046 case 'default':
1047 case 'all':
1048 $compiled_fields[ $field->get_key() ] = array(
1049 'label' => $label,
1050 'value' => $field->get_render_value( $context ),
1051 );
1052 break;
1053 case 'label|value':
1054 $compiled_fields[] = array(
1055 'label' => $label,
1056 'value' => $field->get_render_value( $context ),
1057 );
1058 break;
1059 case 'value':
1060 $compiled_fields[] = $field->get_render_value( $context );
1061 break;
1062 case 'label':
1063 $compiled_fields[] = $label;
1064 break;
1065 case 'key-value':
1066 $compiled_fields[ $field->get_key() ] = $field->get_render_value( $context );
1067 break;
1068 case 'label-value':
1069 $compiled_fields[ $field->get_label( $context, $count_field_labels[ $label ] ) ] = $field->get_render_value( $context );
1070 break;
1071 case 'id-value':
1072 $compiled_fields[ $field->get_form_field_id() ] = $field->get_render_value( $context );
1073 break;
1074 case 'collection':
1075 $compiled_fields[] = array(
1076 'label' => $label,
1077 'value' => $field->get_render_value( $context ),
1078 'type' => $field->get_type(),
1079 'id' => $field->get_form_field_id(),
1080 'key' => $field->get_key(),
1081 'meta' => $field->get_meta(),
1082 );
1083 break;
1084 }
1085 }
1086
1087 return $compiled_fields;
1088 }
1089
1090 /**
1091 * Get the feedback ID of the response.
1092 * Which is the same as the post name for feedback entries.
1093 * Please note that this is not the same as the feedback post ID.
1094 *
1095 * @return string
1096 */
1097 public function get_feedback_id() {
1098 return $this->legacy_feedback_id;
1099 }
1100
1101 /**
1102 * Get the feedback title of the response.
1103 *
1104 * This is mostly used for legacy reasons.
1105 *
1106 * @return string
1107 */
1108 public function get_title() {
1109 return $this->legacy_feedback_title;
1110 }
1111
1112 /**
1113 * Get the time of the feedback entry.
1114 *
1115 * @return string
1116 */
1117 public function get_time() {
1118 return $this->feedback_time;
1119 }
1120
1121 /**
1122 * Get the askimet vars that are used to check for spam.
1123 *
1124 * These are the variables that are sent to Akismet to check if the feedback is spam or not.
1125 *
1126 * @return array
1127 */
1128 public function get_akismet_vars() {
1129 $akismet_vars = array(
1130 'comment_author' => $this->author_data->get_name(),
1131 'comment_author_email' => $this->author_data->get_email(),
1132 'comment_author_url' => $this->author_data->get_url(),
1133 'contact_form_subject' => $this->get_subject(),
1134 'comment_author_ip' => $this->get_ip_address(),
1135 'comment_content' => empty( $this->get_comment_content() ) ? null : $this->get_comment_content(),
1136 'permalink' => $this->get_entry_permalink(),
1137 );
1138
1139 foreach ( $this->fields as $field ) {
1140
1141 // Skip any fields that are just a choice from a pre-defined list. They wouldn't have any value
1142 // from a spam-filtering point of view.
1143 if ( in_array( $field->get_type(), array( 'select', 'checkbox', 'checkbox-multiple', 'radio', 'file', 'image-select' ), true ) ) {
1144 continue;
1145 }
1146
1147 // Normalize the label into a slug.
1148 $field_slug = trim( // Strip all leading/trailing dashes.
1149 preg_replace( // Normalize everything to a-z0-9_-
1150 '/[^a-z0-9_]+/',
1151 '-',
1152 strtolower( $field->get_label() ) // Lowercase
1153 ),
1154 '-'
1155 );
1156
1157 $field_value = $field->get_render_value( 'akismet' );
1158
1159 // Skip any values that are already in the array we're sending.
1160 if ( $field_value && in_array( $field_value, $akismet_vars, true ) ) {
1161 continue;
1162 }
1163
1164 $akismet_vars[ 'contact_form_field_' . $field_slug ] = $field_value;
1165 }
1166
1167 return $akismet_vars;
1168 }
1169
1170 /**
1171 * Get the author name of the feedback entry.
1172 * If the author is not provided we will use the email instead.
1173 *
1174 * @return string
1175 */
1176 public function get_author() {
1177 return $this->author_data->get_display_name();
1178 }
1179
1180 /**
1181 * Get the author name of a feedback entry.
1182 *
1183 * @return string
1184 */
1185 public function get_author_name() {
1186 return $this->author_data->get_name();
1187 }
1188
1189 /**
1190 * Get the author's first name of a feedback entry.
1191 *
1192 * @return string
1193 */
1194 public function get_author_first_name() {
1195 return $this->author_data->get_first_name();
1196 }
1197
1198 /**
1199 * Get the author's last name of a feedback entry.
1200 *
1201 * @return string
1202 */
1203 public function get_author_last_name() {
1204 return $this->author_data->get_last_name();
1205 }
1206
1207 /**
1208 * Get the author email of a feedback entry.
1209 *
1210 * @return string
1211 */
1212 public function get_author_email() {
1213 return $this->author_data->get_email();
1214 }
1215
1216 /**
1217 * Get the author's gravatar URL.
1218 *
1219 * This is a convenience method to get the author's gravatar URL.
1220 *
1221 * @return string
1222 */
1223 public function get_author_avatar() {
1224 return $this->author_data->get_avatar_url();
1225 }
1226
1227 /**
1228 * Get the author url of a feedback entry.
1229 *
1230 * @return string
1231 */
1232 public function get_author_url() {
1233 return $this->author_data->get_url();
1234 }
1235
1236 /**
1237 * Get the comment content of a feedback entry.
1238 *
1239 * @return string
1240 */
1241 public function get_comment_content() {
1242 return $this->comment_content;
1243 }
1244
1245 /**
1246 * Get the IP address of the submitted feedback request.
1247 *
1248 * @return string|null
1249 */
1250 public function get_ip_address() {
1251 return $this->ip_address;
1252 }
1253
1254 /**
1255 * Get the user agent of the submitted feedback request.
1256 *
1257 * @return string|null
1258 */
1259 public function get_user_agent() {
1260 return $this->user_agent;
1261 }
1262
1263 /**
1264 * Get the country code derived from the IP address.
1265 *
1266 * @return string|null
1267 */
1268 public function get_country_code() {
1269 return $this->country_code;
1270 }
1271
1272 /**
1273 * Get the form fill duration in seconds.
1274 *
1275 * Represents the time from first user interaction to form submission.
1276 *
1277 * @return int|null
1278 */
1279 public function get_form_fill_duration() {
1280 return $this->form_fill_duration;
1281 }
1282
1283 /**
1284 * Get the emoji flag for the country.
1285 *
1286 * @return string The emoji flag for the country code, or empty string if unavailable.
1287 */
1288 public function get_country_flag() {
1289 return self::country_code_to_emoji_flag( $this->country_code );
1290 }
1291
1292 /**
1293 * Get country code from IP address.
1294 *
1295 * This method uses a filter to allow custom implementations of GeoIP lookup.
1296 * The filter should return a country code (e.g., 'US', 'GB', 'DE') or null.
1297 *
1298 * @param string|null $ip_address The IP address.
1299 * @return string|null The country code or null if unavailable.
1300 */
1301 private function get_country_code_from_ip( $ip_address ) {
1302 if ( ! $ip_address ) {
1303 return null;
1304 }
1305 // This filter allows site owners to disable IP address storage entirely as well as GeoIP lookups.
1306 // This filter is documented in src/contact-form/class-contact-form-plugin.php
1307 if ( apply_filters( 'jetpack_contact_form_forget_ip_address', false ) ) {
1308 return null;
1309 }
1310
1311 /**
1312 * Filter to get country code from IP address.
1313 *
1314 * @since $$NEXT_VERSION$$
1315 *
1316 * @param string|null $country The country code (e.g., 'US', 'GB', 'DE') or null.
1317 * @param string $ip_address The IP address to look up.
1318 * @param string $context The context for the geolocation request.
1319 */
1320 $country = apply_filters( 'jetpack_get_country_from_ip', null, $ip_address, 'form-response' );
1321 if ( is_string( $country ) ) {
1322 return strtoupper( $country );
1323 }
1324
1325 $headers = array(
1326 'MM_COUNTRY_CODE',
1327 'GEOIP_COUNTRY_CODE',
1328 'HTTP_CF_IPCOUNTRY',
1329 'HTTP_X_COUNTRY_CODE',
1330 'HTTP_X_APPENGINE_COUNTRY',
1331 'HTTP_X_FORWARDED_FOR_COUNTRY',
1332 'HTTP_CLOUDFRONT_VIEWER_COUNTRY',
1333 );
1334
1335 // Check for headers from the server.
1336 foreach ( $headers as $header ) {
1337 if ( isset( $_SERVER[ $header ] ) ) {
1338 $country = sanitize_text_field( wp_unslash( $_SERVER[ $header ] ) );
1339 if ( ! empty( $country ) ) {
1340 return strtoupper( $country );
1341 }
1342 }
1343 }
1344
1345 if ( function_exists( 'geoip_country_code_by_name' ) ) {
1346 $country = geoip_country_code_by_name( $ip_address );
1347 if ( ! empty( $country ) ) {
1348 return strtoupper( $country );
1349 }
1350 }
1351
1352 $country = self::geolocate_via_api( $ip_address );
1353 if ( ! empty( $country ) ) {
1354 return strtoupper( $country );
1355 }
1356
1357 return null;
1358 }
1359
1360 /**
1361 * Use APIs to Geolocate the IP address.
1362 *
1363 * @param string $ip_address IP address.
1364 * @return string
1365 */
1366 private static function geolocate_via_api( $ip_address ) {
1367 $country_code = \get_transient( 'geoip_' . $ip_address );
1368 if ( false === $country_code ) {
1369 $response = Client::wpcom_json_api_request_as_blog(
1370 '/ip-to-geo/' . $ip_address,
1371 '2',
1372 array( 'method' => 'GET' ),
1373 null,
1374 'wpcom'
1375 );
1376
1377 if ( ! is_wp_error( $response ) && ! empty( $response['body'] ) ) {
1378 $data = json_decode( $response['body'] );
1379 $country_code = $data->country_short ?? '';
1380 $country_code = \sanitize_text_field( $country_code );
1381 // Share the transient with woocommerce to avoid multiple lookups.
1382 \set_transient( 'geoip_' . $ip_address, $country_code, DAY_IN_SECONDS );
1383 }
1384 }
1385 return $country_code;
1386 }
1387
1388 /**
1389 * Get the browser information from the user agent.
1390 *
1391 * Returns a formatted string like "Chrome (Desktop)" or "Safari (Mobile)".
1392 *
1393 * @return string|null Browser information or null if user agent is not available.
1394 */
1395 public function get_browser() {
1396 if ( empty( $this->user_agent ) ) {
1397 return null;
1398 }
1399
1400 // Use Jetpack Device Detection to parse the user agent.
1401 $ua_info = new User_Agent_Info( $this->user_agent );
1402
1403 // Get browser name.
1404 $browser_name = $ua_info->get_browser_display_name();
1405
1406 if ( $browser_name === User_Agent_Info::OTHER ) {
1407 return __( 'Unknown browser', 'jetpack-forms' );
1408 }
1409
1410 // Determine platform type (Mobile, Tablet, or Desktop).
1411 $platform_type = 'Desktop';
1412 if ( $ua_info->is_tablet() ) {
1413 $platform_type = 'Tablet';
1414 } elseif ( $ua_info->get_platform() ) {
1415 // If there's a mobile platform detected (not false), it's mobile.
1416 $platform_type = 'Mobile';
1417 }
1418
1419 return sprintf( '%s (%s)', $browser_name, $platform_type );
1420 }
1421
1422 /**
1423 * Get the logged-in user information who submitted the feedback.
1424 *
1425 * @return array|null Array with 'display_name' and 'id' keys, or null if not logged in.
1426 */
1427 public function get_logged_in_user() {
1428 return $this->logged_in_user;
1429 }
1430
1431 /**
1432 * Get the email subject.
1433 *
1434 * @return string
1435 */
1436 public function get_subject() {
1437 return $this->subject;
1438 }
1439
1440 /**
1441 * Gets the notification recipients of the feedback entry.
1442 *
1443 * @return array
1444 */
1445 public function get_notification_recipients() {
1446 return $this->notification_recipients;
1447 }
1448
1449 /**
1450 * Gets the value of the consent field.
1451 *
1452 * @return bool
1453 */
1454 public function has_consent() {
1455 return $this->has_consent;
1456 }
1457
1458 /**
1459 * Gets the value of the consent field.
1460 *
1461 * @return bool
1462 */
1463 public function has_file() {
1464 return $this->has_file;
1465 }
1466
1467 /**
1468 * Check if the feedback is unread.
1469 *
1470 * @return bool
1471 */
1472 public function is_unread() {
1473 return $this->is_unread;
1474 }
1475
1476 /**
1477 * Mark the feedback as read.
1478 *
1479 * @return bool True on success, false on failure.
1480 */
1481 public function mark_as_read() {
1482 if ( ! $this->post_id ) {
1483 return false;
1484 }
1485
1486 $updated = wp_update_post(
1487 array(
1488 'ID' => $this->post_id,
1489 'comment_status' => self::STATUS_READ,
1490 )
1491 );
1492
1493 if ( ! is_wp_error( $updated ) && $updated ) {
1494 $this->is_unread = false;
1495 return true;
1496 }
1497
1498 return false;
1499 }
1500
1501 /**
1502 * Mark the feedback as unread.
1503 *
1504 * @return bool True on success, false on failure.
1505 */
1506 public function mark_as_unread() {
1507 if ( ! $this->post_id ) {
1508 return false;
1509 }
1510
1511 $updated = wp_update_post(
1512 array(
1513 'ID' => $this->post_id,
1514 'comment_status' => self::STATUS_UNREAD,
1515 )
1516 );
1517
1518 if ( ! is_wp_error( $updated ) && $updated ) {
1519 $this->is_unread = true;
1520 return true;
1521 }
1522
1523 return false;
1524 }
1525
1526 /**
1527 * Get the count of unread feedback entries.
1528 *
1529 * @return int
1530 */
1531 public static function get_unread_count() {
1532 $query = new \WP_Query(
1533 array(
1534 'post_type' => self::POST_TYPE,
1535 'post_status' => 'publish',
1536 'comment_status' => self::STATUS_UNREAD,
1537 'posts_per_page' => -1,
1538 'fields' => 'ids',
1539 )
1540 );
1541 return (int) $query->found_posts;
1542 }
1543
1544 /**
1545 * Get the uploaded files from the feedback entry.
1546 *
1547 * @return array
1548 */
1549 public function get_files() {
1550 $files = array();
1551 foreach ( $this->fields as $field ) {
1552 if ( $field->get_type() === 'file' ) {
1553 $field_value = $field->get_value();
1554 if ( ! empty( $field_value['files'] ) && is_array( $field_value['files'] ) ) {
1555 $field_value['files'] = array_filter(
1556 $field_value['files'],
1557 function ( $file ) {
1558 if ( empty( $file['file_id'] ) ) {
1559 return false;
1560 }
1561 if ( empty( $file['name'] ) ) {
1562 return false;
1563 }
1564 if ( empty( $file['size'] ) ) {
1565 return false;
1566 }
1567 if ( empty( $file['type'] ) ) {
1568 return false;
1569 }
1570 return true;
1571 }
1572 );
1573
1574 $files = array_merge( $files, $field_value['files'] );
1575 }
1576 }
1577 }
1578 return $files;
1579 }
1580
1581 /**
1582 * Get the feedback status. For example 'publish', 'spam' or 'trash'.
1583 *
1584 * @return string
1585 */
1586 public function get_status() {
1587 return $this->status;
1588 }
1589
1590 /**
1591 * Sets the status of the feedback.
1592 *
1593 * @param string $status The status to set for the feedback entry.
1594 * @return void
1595 */
1596 public function set_status( $status ) {
1597 $this->status = $status;
1598 }
1599
1600 /**
1601 * Get the entry ID of the post that the feedback was submitted from.
1602 *
1603 * This is the post ID of the post or page that the feedback was submitted from.
1604 *
1605 * @return int|string
1606 */
1607 public function get_entry_id() {
1608 return $this->source->get_id();
1609 }
1610
1611 /**
1612 * Get the entry title of the post that the feedback was submitted from.
1613 *
1614 * This is the title of the post or page that the feedback was submitted from.
1615 *
1616 * @return string
1617 */
1618 public function get_entry_title() {
1619 return $this->source->get_title();
1620 }
1621
1622 /**
1623 * Get the permalink of the post or page that the feedback was submitted from.
1624 * This includes the page number if the feedback was submitted from a paginated form.
1625 *
1626 * @return string
1627 */
1628 public function get_entry_permalink() {
1629 return $this->source->get_permalink();
1630 }
1631
1632 /**
1633 * Get the editor URL where the user can edit the form.
1634 *
1635 * @return string
1636 */
1637 public function get_edit_form_url() {
1638 if ( ! empty( $this->form_id ) ) {
1639 return \get_edit_post_link( (int) $this->form_id, 'url' );
1640 }
1641 return $this->source->get_edit_form_url();
1642 }
1643 /**
1644 * Get the short permalink of a post.
1645 *
1646 * @return string
1647 */
1648 public function get_entry_short_permalink() {
1649 return $this->source->get_relative_permalink();
1650 }
1651
1652 /**
1653 * Whether this feedback was submitted from a form preview (test submission).
1654 *
1655 * @return bool
1656 */
1657 public function is_test() {
1658 return $this->source->is_test();
1659 }
1660
1661 /**
1662 * Flag this feedback as a test submission from form preview.
1663 *
1664 * @return void
1665 */
1666 public function mark_as_test() {
1667 $this->source->set_is_test( true );
1668 }
1669
1670 /**
1671 * Save the feedback entry to the database.
1672 *
1673 * @return int
1674 */
1675 public function save() {
1676 $post_id = wp_insert_post(
1677 array(
1678 'post_type' => self::POST_TYPE,
1679 'post_status' => $this->status,
1680 'post_title' => $this->legacy_feedback_title,
1681 'post_date' => $this->feedback_time,
1682 'post_name' => $this->legacy_feedback_id,
1683 'post_content' => $this->serialize(), // In V3 we started to addslashes.
1684 'post_mime_type' => 'v3', // a way to help us identify what version of the data this is.
1685 'post_parent' => $this->form_id ?? $this->source->get_id(),
1686 'comment_status' => self::STATUS_UNREAD, // New feedback is unread by default.
1687 )
1688 );
1689
1690 // Store source post ID as meta for queryable source filtering.
1691 $source_id = $this->source->get_id();
1692 if ( is_numeric( $post_id ) && (int) $post_id > 0 && is_numeric( $source_id ) && (int) $source_id > 0 ) {
1693 add_post_meta( $post_id, self::SOURCE_META_KEY, (int) $source_id, true );
1694 wp_cache_delete( self::SOURCE_IDS_CACHE_KEY, self::CACHE_GROUP );
1695 }
1696
1697 // Flag test submissions with a post meta so the REST collection can
1698 // filter them via meta_query without unpacking the serialized source.
1699 if ( is_numeric( $post_id ) && (int) $post_id > 0 && $this->source->is_test() ) {
1700 add_post_meta( $post_id, self::IS_TEST_META_KEY, 1, true );
1701 }
1702
1703 // If this feedback does not have a jetpack_form parent,
1704 // it's a classic form — mark the state accordingly.
1705 if ( empty( $this->form_id ) ) {
1706 Forms_Dashboard::mark_classic_form_detected();
1707 }
1708
1709 $feedback_post = get_post( $post_id );
1710 return $feedback_post ?? 0;
1711 }
1712
1713 /**
1714 * Serialize the fields to JSON format.
1715 *
1716 * @return string
1717 */
1718 public function serialize() {
1719
1720 $fields_to_serialize = array_merge(
1721 array(
1722 'subject' => $this->subject,
1723 'ip' => $this->ip_address,
1724 'country_code' => $this->country_code,
1725 'user_agent' => $this->user_agent,
1726 'form_fill_duration' => $this->form_fill_duration,
1727 'notification_recipients' => $this->notification_recipients,
1728 'logged_in_user' => $this->logged_in_user,
1729 ),
1730 $this->source->serialize()
1731 );
1732
1733 $fields_to_serialize['fields'] = array();
1734 foreach ( $this->fields as $field ) {
1735 $fields_to_serialize['fields'][] = $field->serialize();
1736 }
1737
1738 // Check if the IP and country_code should be included.
1739 if ( apply_filters( 'jetpack_contact_form_forget_ip_address', false, $this->ip_address ) ) {
1740 $fields_to_serialize['ip'] = null;
1741 $fields_to_serialize['country_code'] = null;
1742 }
1743
1744 /*
1745 * JSON_HEX_TAG escapes every `<` and `>` as a \u003C / \u003E sequence,
1746 * which is what keeps this payload intact on the way into the database.
1747 * It is load-bearing here, not cosmetic - do not drop it.
1748 *
1749 * This payload is written to `post_content`, and any submitter without
1750 * `unfiltered_html` - every logged-out visitor, and every non-super-admin
1751 * on a multisite - has `wp_filter_post_kses` attached to `content_save_pre`.
1752 * A bare `<` anywhere in the payload (a field label, a submitted value, or
1753 * the source page title) then reads as the start of a tag, and core's
1754 * `wp_pre_kses_less_than()` runs esc_html() over everything from that `<` to
1755 * the end of the string. The quotes inside it become `&quot;`, so
1756 * `json_decode()` can no longer read the payload and the entire response
1757 * comes back empty.
1758 *
1759 * `json_decode()` resolves the escaped sequences natively, so no decode step
1760 * is needed and payloads written before this flag was added still parse.
1761 *
1762 * This deliberately diverges from Jetpack.Functions.JsonEncodeFlags, which
1763 * recommends JSON_UNESCAPED_SLASHES alone for database-field writes. That
1764 * guidance assumes the write is not KSES-filtered; this one is.
1765 */
1766 return addslashes( wp_json_encode( $fields_to_serialize, JSON_UNESCAPED_SLASHES | JSON_HEX_TAG ) );
1767 }
1768
1769 /**
1770 * Helper function to parse the post content.
1771 *
1772 * @param string $post_content The post content to parse.
1773 * @param string|null $version The version of the content format.
1774 * @return array Parsed fields.
1775 */
1776 private function parse_content( $post_content = '', $version = null ) {
1777 if ( $version === 'v3' ) {
1778 $this->uses_structured_fields = true;
1779 return $this->parse_content_v3( $post_content );
1780 }
1781 if ( $version === 'v2' ) {
1782 $this->uses_structured_fields = true;
1783 return $this->parse_content_v2( $post_content );
1784 }
1785
1786 // Some feedback posts store JSON content without a version marker
1787 // (empty post_mime_type). Parse those as the current (v3) format instead
1788 // of falling through to the legacy plain-text parser.
1789 if ( self::is_json( $post_content ) ) {
1790 $decoded_content = json_decode( $post_content, true );
1791 if ( $decoded_content === null ) {
1792 // Content may be slash-escaped as stored by WordPress; retry,
1793 // mirroring the fallback used by the v2/v3 parsers.
1794 $decoded_content = json_decode( stripslashes( trim( $post_content ) ), true );
1795 }
1796 if ( isset( $decoded_content['fields'] ) && is_array( $decoded_content['fields'] ) ) {
1797 $this->uses_structured_fields = true;
1798 return $this->parse_content_v3( $post_content );
1799 }
1800 }
1801
1802 return $this->parse_legacy_content( $post_content );
1803 }
1804
1805 /**
1806 * Check whether a string looks like and decodes as valid JSON.
1807 *
1808 * Accepts slash-escaped JSON (as WordPress may store it), mirroring the
1809 * stripslashes fallback used by the v2/v3 parsers.
1810 *
1811 * @param string $string The string to test.
1812 * @return bool True if the string is a JSON object or array.
1813 */
1814 private static function is_json( $string ) {
1815 if ( ! is_string( $string ) || $string === '' ) {
1816 return false;
1817 }
1818 $string = trim( $string );
1819 if ( ! str_starts_with( $string, '{' ) && ! str_starts_with( $string, '[' ) ) {
1820 return false;
1821 }
1822 $decoded = json_decode( $string );
1823 if ( $decoded !== null && json_last_error() === JSON_ERROR_NONE ) {
1824 return true;
1825 }
1826 $decoded = json_decode( stripslashes( $string ) );
1827 return $decoded !== null && json_last_error() === JSON_ERROR_NONE;
1828 }
1829
1830 /**
1831 * Parse the content in the v2 format.
1832 *
1833 * V2 Format was a short lived format that accidently contains slash escaped unicode characters.
1834 *
1835 * @param string $post_content The post content to parse.
1836 *
1837 * @return array Parsed fields.
1838 */
1839 private function parse_content_v2( $post_content = '' ) {
1840 $decoded_content = json_decode( $post_content, true );
1841 if ( $decoded_content === null ) {
1842 // If JSON decoding still fails, try with stripslashes and trim as a fallback
1843 // This is a workaround for some cases where the JSON data is not properly formatted
1844 $decoded_content = json_decode( stripslashes( trim( $post_content ) ), true );
1845 }
1846
1847 if ( $decoded_content === null ) {
1848 // Final fallback: attempt to fix malformed JSON with unescaped quotes
1849 // Apply stripslashes first, then fix remaining issues
1850 $stripped_content = stripslashes( trim( $post_content ) );
1851 $fixed_content = self::fix_malformed_json( $stripped_content );
1852 $decoded_content = json_decode( $fixed_content, true );
1853 }
1854
1855 if ( $decoded_content === null ) {
1856 return array();
1857 }
1858 $fields = array();
1859 foreach ( $decoded_content['fields'] as $field ) {
1860 $feedback_field = Feedback_Field::from_serialized_v2( $field );
1861 if ( $feedback_field instanceof Feedback_Field ) {
1862 $fields[ $feedback_field->get_key() ] = $feedback_field;
1863 if ( ! $this->has_file && $feedback_field->has_file() ) {
1864 $this->has_file = true;
1865 }
1866 }
1867 }
1868 $decoded_content['fields'] = $fields;
1869 return $decoded_content;
1870 }
1871
1872 /**
1873 * Parse the content in the v3 format.
1874 *
1875 * @param string $post_content The post content to parse.
1876 *
1877 * @return array Parsed fields.
1878 */
1879 private function parse_content_v3( $post_content = '' ) {
1880 $decoded_content = json_decode( $post_content, true );
1881 if ( $decoded_content === null ) {
1882 // If JSON decoding fails, try to decode the second try with stripslashes and trim.
1883 // This is a workaround for some cases where the JSON data is not properly formatted.
1884 $decoded_content = json_decode( stripslashes( trim( $post_content ) ), true );
1885 }
1886 if ( $decoded_content === null ) {
1887 return array();
1888 }
1889 $fields = array();
1890 foreach ( $decoded_content['fields'] as $field ) {
1891 $feedback_field = Feedback_Field::from_serialized( $field );
1892 if ( $feedback_field instanceof Feedback_Field ) {
1893 $fields[ $feedback_field->get_key() ] = $feedback_field;
1894 if ( ! $this->has_file && $feedback_field->has_file() ) {
1895 $this->has_file = true;
1896 }
1897 }
1898 }
1899 $decoded_content['fields'] = $fields;
1900 return $decoded_content;
1901 }
1902
1903 /**
1904 * Parse the legacy content format.
1905 *
1906 * @param string $post_content The post content to parse.
1907 *
1908 * @return array Parsed fields.
1909 */
1910 private function parse_legacy_content( $post_content = '' ) {
1911 $content_parts = $this->split_legacy_content( $post_content );
1912 $comment_content = $content_parts['comment_content'];
1913 $field_content = $content_parts['field_content'];
1914
1915 $all_values = $this->extract_legacy_values( $field_content );
1916 $lines = $this->extract_legacy_lines( $field_content );
1917
1918 $decoded_fields = array();
1919 $decoded_fields['fields'] = array();
1920
1921 // Process lines for specific field types
1922 $this->process_legacy_lines( $lines, $decoded_fields );
1923
1924 // Process all other values
1925 $this->process_legacy_values( $all_values, $decoded_fields );
1926
1927 // Add comment content field
1928 $this->add_comment_content_field( $comment_content, $decoded_fields );
1929
1930 return $decoded_fields;
1931 }
1932
1933 /**
1934 * Attempt to fix malformed JSON by escaping unescaped quotes in string values.
1935 *
1936 * This method handles cases where JSON contains unescaped quotes within string values,
1937 * which causes json_decode to fail.
1938 *
1939 * @param string $json malformed JSON string.
1940 * @return string The JSON string with escaped quotes.
1941 */
1942 public static function fix_malformed_json( $json ) {
1943
1944 $find = array();
1945 $replace = array();
1946
1947 // Start of JSON object
1948 $find[] = '{\"';
1949 $replace[] = '{"';
1950
1951 // Key-value separator
1952 $find[] = '\":\"';
1953 $replace[] = '":"';
1954
1955 $find[] = '\\\"';
1956 $replace[] = '\"';
1957
1958 $find[] = '\":[\"';
1959 $replace[] = '":["';
1960
1961 $find[] = '\"],';
1962 $replace[] = '"],';
1963
1964 $find[] = ',[\"';
1965 $replace[] = ',["';
1966
1967 $find[] = '\",\"';
1968 $replace[] = '","';
1969
1970 $find[] = ',\"';
1971 $replace[] = ',"';
1972
1973 $find[] = '\", \"';
1974 $replace[] = '", "';
1975
1976 $find[] = '\"],\"';
1977 $replace[] = '"],"';
1978
1979 $find[] = '\"],"';
1980 $replace[] = '"],"';
1981
1982 $find[] = '\":[]';
1983 $replace[] = '":[]';
1984
1985 $find[] = '\"]}';
1986 $replace[] = '"]}';
1987
1988 $find[] = '\":[';
1989 $replace[] = '":[';
1990
1991 $find[] = '\":{';
1992 $replace[] = '":{';
1993
1994 $find[] = '\":true';
1995 $replace[] = '":true';
1996
1997 $find[] = '\":false';
1998 $replace[] = '":false';
1999
2000 $find[] = '\":null';
2001 $replace[] = '":null';
2002
2003 for ( $i = 0; $i <= 9; $i++ ) {
2004 $find[] = '\":' . $i;
2005 $replace[] = '":' . $i;
2006
2007 $find[] = '\",' . $i;
2008 $replace[] = '",' . $i;
2009 }
2010
2011 $find[] = '\",true';
2012 $replace[] = '",true';
2013
2014 $find[] = '\",false';
2015 $replace[] = '",false';
2016
2017 $find[] = '\",null';
2018 $replace[] = '",null';
2019
2020 $find[] = "\'";
2021 $replace[] = "'";
2022
2023 // End of Json object
2024 $find[] = '\"}';
2025 $replace[] = '"}';
2026
2027 // Remove any slashes that are there to start a new string.
2028 return str_replace( $find, $replace, addslashes( $json ) );
2029 }
2030
2031 /**
2032 * Split legacy content into comment and field sections.
2033 *
2034 * @param string $post_content The post content to parse.
2035 * @return array Array with 'comment_content' and 'field_content' keys.
2036 */
2037 private function split_legacy_content( $post_content ) {
2038 $content = explode( '<!--more-->', $post_content );
2039 $comment_content = '';
2040 $field_content = '';
2041
2042 if ( count( $content ) > 1 ) {
2043 $comment_content = $content[0];
2044 $field_content = str_ireplace( array( '<br />', ')</p>' ), '', $content[1] );
2045 }
2046
2047 return array(
2048 'comment_content' => $comment_content,
2049 'field_content' => $field_content,
2050 );
2051 }
2052
2053 /**
2054 * Extract values from legacy field content.
2055 *
2056 * @param string $field_content The field content to parse.
2057 * @return array Extracted values.
2058 */
2059 private function extract_legacy_values( $field_content ) {
2060 $all_values = array();
2061
2062 if ( str_contains( $field_content, 'JSON_DATA' ) ) {
2063 $all_values = $this->parse_json_data( $field_content );
2064 } else {
2065 $all_values = $this->parse_array_format( $field_content );
2066 }
2067
2068 // Ensure all_values is always an array
2069 if ( ! is_array( $all_values ) ) {
2070 $all_values = array();
2071 }
2072
2073 return $all_values;
2074 }
2075
2076 /**
2077 * Extract lines from legacy field content.
2078 *
2079 * @param string $field_content The field content to parse.
2080 * @return array Filtered lines.
2081 */
2082 private function extract_legacy_lines( $field_content ) {
2083 if ( str_contains( $field_content, 'JSON_DATA' ) ) {
2084 $chunks = explode( "\nJSON_DATA", $field_content );
2085 return array_filter( explode( "\n", $chunks[0] ) );
2086 } else {
2087 return array_filter( explode( "\n", $field_content ) );
2088 }
2089 }
2090
2091 /**
2092 * Parse JSON data from field content.
2093 *
2094 * @param string $field_content The field content containing JSON data.
2095 * @return array Parsed JSON data.
2096 */
2097 private function parse_json_data( $field_content ) {
2098 $chunks = explode( "\nJSON_DATA", $field_content );
2099
2100 if ( ! isset( $chunks[1] ) ) {
2101 // Try with 'JSON_DATA' without the newline as a fallback.
2102 $chunks = explode( 'JSON_DATA', $field_content );
2103 if ( ! isset( $chunks[1] ) ) {
2104 // If JSON_DATA is still not found, return an empty array.
2105 return array();
2106 }
2107 }
2108
2109 $json_data = $chunks[1];
2110
2111 $all_values = json_decode( $json_data, true );
2112
2113 if ( $all_values === null ) {
2114 // Fallback for improperly formatted JSON
2115 $all_values = json_decode( stripslashes( trim( $json_data ) ), true );
2116 }
2117
2118 return $all_values === null ? array() : $all_values;
2119 }
2120
2121 /**
2122 * Parse array format from field content.
2123 *
2124 * @param string $field_content The field content in array format.
2125 * @return array Parsed array data.
2126 */
2127 private function parse_array_format( $field_content ) {
2128 $fields_array = preg_replace( '/.*Array\s\( (.*)\)/msx', '$1', $field_content );
2129
2130 // Parse key-value pairs formatted as [Key] => Value
2131 preg_match_all( '/^\s*\[([^\]]+)\] =\&gt\; (.*)(?=^\s*(\[[^\]]+\] =\&gt\;)|\z)/msU', $fields_array, $matches );
2132
2133 if ( count( $matches ) > 1 ) {
2134 return array_combine( array_map( 'trim', $matches[1] ), array_map( 'trim', $matches[2] ) );
2135 }
2136
2137 return array();
2138 }
2139
2140 /**
2141 * Process legacy lines into field objects.
2142 *
2143 * We do this so that we can extract specific fields but we don't display the values in the UI.
2144 *
2145 * @param array $lines The lines to process.
2146 * @param array &$decoded_fields Reference to the decoded fields array.
2147 */
2148 private function process_legacy_lines( $lines, &$decoded_fields ) {
2149 $var_map = array(
2150 'AUTHOR' => array(
2151 'type' => 'name',
2152 'label' => 'Author',
2153 ),
2154 'AUTHOR EMAIL' => array(
2155 'type' => 'email',
2156 'label' => 'Email',
2157 ),
2158 'AUTHOR URL' => array(
2159 'type' => 'url',
2160 'label' => 'Url',
2161 ),
2162 'SUBJECT' => array(
2163 'type' => 'subject',
2164 'label' => 'Subject',
2165 ),
2166 'IP' => array(
2167 'type' => 'ip',
2168 'label' => 'IP',
2169 ),
2170 );
2171
2172 foreach ( $lines as $line ) {
2173 $line_parts = explode( ': ', $line, 2 );
2174
2175 if ( count( $line_parts ) !== 2 ) {
2176 continue;
2177 }
2178
2179 list( $key, $value ) = $line_parts;
2180
2181 if ( ! empty( $key ) && isset( $var_map[ $key ] ) ) {
2182 $map_to_field = $var_map[ $key ];
2183 $value = Contact_Form_Plugin::strip_tags( trim( $value ) );
2184
2185 $decoded_fields['fields'][ $key ] = new Feedback_Field(
2186 $key,
2187 $map_to_field['label'],
2188 $value,
2189 $map_to_field['type'],
2190 array( 'render' => false )
2191 );
2192 }
2193 }
2194 }
2195
2196 /**
2197 * Check if the field is a legacy file upload.
2198 *
2199 * @param array $field The field to check.
2200 *
2201 * @return bool True if it's a legacy file upload, false otherwise.
2202 */
2203 private function is_legacy_file_upload( $field ) {
2204 return (
2205 is_array( $field ) &&
2206 ! empty( $field['field_id'] ) &&
2207 isset( $field['files'] ) &&
2208 is_array( $field['files'] )
2209 );
2210 }
2211
2212 /**
2213 * Process legacy values into field objects.
2214 *
2215 * @param array $all_values The values to process.
2216 * @param array &$decoded_fields Reference to the decoded fields array.
2217 */
2218 private function process_legacy_values( $all_values, &$decoded_fields ) {
2219 $non_user_fields = array(
2220 'email_marketing_consent',
2221 'entry_title',
2222 'entry_permalink',
2223 'entry_page',
2224 'feedback_id',
2225 );
2226
2227 foreach ( $all_values as $key => $value ) {
2228 $key = wp_strip_all_tags( $key );
2229 $label = self::extract_label_from_key( $key );
2230
2231 if ( in_array( $key, $non_user_fields, true ) ) {
2232 if ( $key === 'email_marketing_consent' ) {
2233 $decoded_fields['fields'][ $key ] = new Feedback_Field(
2234 $key,
2235 $label,
2236 $value,
2237 'consent',
2238 array( 'render' => false )
2239 );
2240 continue;
2241 }
2242 $decoded_fields[ $key ] = $value;
2243 continue;
2244 }
2245
2246 // check for file upload data and then set it as a file type field.
2247 if ( $this->is_legacy_file_upload( $value ) ) {
2248 // If the value is a file upload, we need to handle it differently.
2249 $decoded_fields['fields'][ $key ] = new Feedback_Field(
2250 $key,
2251 $label,
2252 $value,
2253 'file'
2254 );
2255 $this->has_file = ! empty( $value['files'] ); // Set has_file to true if any file upload is found.
2256 } else {
2257 $decoded_fields['fields'][ $key ] = new Feedback_Field( $key, $label, $value );
2258 }
2259 }
2260 }
2261
2262 /**
2263 * Add comment content as a field.
2264 *
2265 * @param string $comment_content The comment content.
2266 * @param array &$decoded_fields Reference to the decoded fields array.
2267 */
2268 private function add_comment_content_field( $comment_content, &$decoded_fields ) {
2269 $decoded_fields['fields']['comment_content'] = new Feedback_Field(
2270 'comment_content',
2271 'Comment Content',
2272 trim( Contact_Form_Plugin::strip_tags( $comment_content ) ),
2273 'textarea',
2274 array( 'render' => false )
2275 );
2276 }
2277
2278 /**
2279 * Extract the label from a key that might be in the format "1_label".
2280 *
2281 * @param string $key The key to extract the label from.
2282 * @return string The extracted label.
2283 */
2284 private static function extract_label_from_key( $key ) {
2285 // Check if the key starts with a number followed by underscore and has content after underscore
2286 if ( preg_match( '/^\d+_(.+)$/', $key, $matches ) ) {
2287 return $matches[1];
2288 }
2289 // If the key is just a number followed by underscore (like "2_"), return empty string
2290 if ( preg_match( '/^\d+_$/', $key ) ) {
2291 return '';
2292 }
2293 // If the key doesn't start with a number followed by underscore, return the key as is
2294 return $key;
2295 }
2296
2297 /**
2298 * Get field-specific metadata based on the field type.
2299 *
2300 * @param Contact_Form_Field $field The field object.
2301 * @param string $type The field type.
2302 * @return array Metadata array for the field.
2303 */
2304 public static function get_field_meta( $field, $type ) {
2305 $meta = array();
2306
2307 if ( $type === 'rating' ) {
2308 $icon_style = $field->get_attribute( 'iconstyle' );
2309 $max = $field->get_attribute( 'max' );
2310 $meta['iconStyle'] = ! empty( $icon_style ) ? $icon_style : 'stars';
2311 $meta['maxRating'] = is_numeric( $max ) && (int) $max > 0 ? (int) $max : 5;
2312 }
2313
2314 return $meta;
2315 }
2316
2317 /**
2318 * Get all the fields of the response, computed from the post data.
2319 *
2320 * @param array $post_data The post data from the form submission.
2321 * @param Contact_Form $form The form object.
2322 * @return array An array of Feedback_Field objects.
2323 */
2324 private function get_computed_fields( $post_data, $form ) {
2325
2326 $fields = array();
2327
2328 $field_ids = $form->get_field_ids();
2329
2330 // Collect renderable fields and their submitted values up front so conditional logic
2331 // rules (which may reference any sibling field) can be evaluated in the loop below.
2332 $renderable = array();
2333 $form_values = array();
2334 foreach ( $field_ids['all'] as $field_id ) {
2335 $field = $form->fields[ $field_id ];
2336 $type = $field->get_attribute( 'type' );
2337 if ( ! $field->is_field_renderable( $type ) ) {
2338 continue;
2339 }
2340 $value = $this->get_field_value( $field_id, $post_data, $type );
2341 $form_values[ $field_id ] = $value;
2342 $renderable[ $field_id ] = array(
2343 'field' => $field,
2344 'type' => $type,
2345 'value' => $value,
2346 );
2347 }
2348
2349 // Ask the form, rather than resolving a second time.
2350 //
2351 // Storage used to run its own resolve_visibility() over a different value source and a
2352 // different field set than validation did, and the two disagreed. Validation reads
2353 // get_computed_field_value() -- POST, then GET, then the field's default, then the
2354 // logged-in user -- while this loop reads POST only; and it skips anything
2355 // is_field_renderable() rejects, so a rule whose subject is an option-less select was
2356 // evaluated during validation and ignored here.
2357 //
2358 // Unchecking a consent field prefilled from a query argument hit both: the browser
2359 // posts nothing, validation fell back to the query argument and read it checked,
2360 // storage read '' and read it unchecked. The dependent field was required-validated
2361 // and then had its answer dropped -- the silently discarded answer this feature is
2362 // supposed to make impossible.
2363 //
2364 // Returns an empty array when conditional logic does not apply, so there is nothing extra to guard.
2365 $visibility = $form->get_resolved_field_visibility();
2366
2367 $i = 1;
2368 foreach ( $renderable as $field_id => $entry ) {
2369 $field = $entry['field'];
2370 $type = $entry['type'];
2371 $value = $entry['value'];
2372
2373 if ( isset( $visibility[ $field_id ] ) && false === $visibility[ $field_id ] ) {
2374 continue;
2375 }
2376
2377 $label = wp_strip_all_tags( $field->get_attribute( 'label' ) );
2378 $key = $i . '_' . $label;
2379
2380 $meta = self::get_field_meta( $field, $type );
2381
2382 // Process radio fields to detect and extract "Other" option metadata
2383 if ( $type === 'radio' ) {
2384 $processed = $this->process_radio_field_value( $value, $field, $field_id, $post_data );
2385 $value = $processed['value'];
2386 $meta = array_merge( $meta, $processed['meta'] );
2387 }
2388 $fields[ $key ] = new Feedback_Field( $key, $label, $value, $type, $meta, $field_id );
2389 if ( ! $this->has_file && $fields[ $key ]->has_file() ) {
2390 $this->has_file = true;
2391 }
2392 ++$i;
2393 }
2394
2395 return $fields;
2396 }
2397
2398 /**
2399 * Gets the computed subject.
2400 *
2401 * @param array $post_data The post data from the form submission.
2402 * @param Contact_Form $form The form object.
2403 * @return string
2404 */
2405 private function get_computed_subject( $post_data, $form ) {
2406
2407 $contact_form_subject = $form->get_attribute( 'subject' );
2408 $field_ids = $form->get_field_ids();
2409
2410 if ( isset( $field_ids['subject'] ) ) {
2411 $value = $this->get_field_value( $field_ids['subject'], $post_data );
2412 if ( ! empty( $value ) ) {
2413 $contact_form_subject = $value;
2414 }
2415 }
2416
2417 return apply_filters( 'contact_form_subject', $contact_form_subject, $this->get_all_values() );
2418 }
2419
2420 /**
2421 * Gets the computed comment content.
2422 *
2423 * @param array $post_data The post data from the form submission.
2424 * @param Contact_Form $form The form object.
2425 * @return string
2426 */
2427 private function get_computed_comment_content( $post_data, $form ) {
2428 $field_ids = $form->get_field_ids();
2429 if ( isset( $field_ids['textarea'] ) ) {
2430 $value = $this->get_field_value( $field_ids['textarea'], $post_data );
2431 if ( is_string( $value ) ) {
2432 return trim( Contact_Form_Plugin::strip_tags( stripslashes( $value ) ) );
2433 }
2434 }
2435 return '';
2436 }
2437
2438 /**
2439 * Gets the computed consent.
2440 *
2441 * @param array $post_data The post data from the form submission.
2442 * @param Contact_Form $form The form object.
2443 * @return bool
2444 */
2445 private function get_computed_consent( $post_data, $form ) {
2446 $field_ids = $form->get_field_ids();
2447
2448 if ( isset( $field_ids['email_marketing_consent_field'] ) && $field_ids['email_marketing_consent_field'] !== null ) {
2449 return (bool) $this->get_field_value( $field_ids['email_marketing_consent_field'], $post_data );
2450 }
2451
2452 return false;
2453 }
2454
2455 /**
2456 * Gets the computed form fill duration, in seconds.
2457 *
2458 * The value is supplied by the view script as a hidden field, so it is submitter-controlled
2459 * and cannot be trusted. Anything that is not a plain sequence of digits is treated as
2460 * unknown and stored as null, rather than being coerced into a number that would read as a
2461 * real measurement: `absint()` alone would turn "abc" into 0 (indistinguishable from a
2462 * genuine sub-second fill), "-1" into 1, and a value past PHP_INT_MAX into a float, which
2463 * would contradict the integer type the REST schema advertises.
2464 *
2465 * The value is left empty when the submitter never interacted with the form, or ran without
2466 * JavaScript, which is also an unknown duration.
2467 *
2468 * @since 7.24.0
2469 *
2470 * @param array $post_data The post data from the form submission.
2471 * @return int|null
2472 */
2473 private function get_computed_form_fill_duration( $post_data ) {
2474 if ( ! isset( $post_data[ self::FORM_FILL_DURATION_FIELD ] ) ) {
2475 return null;
2476 }
2477
2478 $raw = $post_data[ self::FORM_FILL_DURATION_FIELD ];
2479
2480 // is_scalar() has to come first so an array-shaped POST does not blow up on the cast.
2481 if ( ! is_scalar( $raw ) || ! ctype_digit( (string) $raw ) ) {
2482 return null;
2483 }
2484
2485 // Clamp so an abandoned tab left open for days cannot skew aggregates.
2486 return min( (int) $raw, DAY_IN_SECONDS );
2487 }
2488
2489 /**
2490 * Gets the computed notification recipients.
2491 *
2492 * @since 6.10.0
2493 *
2494 * @param array $post_data The post data from the form submission.
2495 * @param Contact_Form $form The form object.
2496 * @return array
2497 */
2498 private function get_computed_notification_recipients( $post_data, $form ) {
2499 $notification_recipients = $form->get_attribute( 'notificationRecipients' );
2500 return $this->validate_notification_recipients( $notification_recipients );
2501 }
2502
2503 /**
2504 * Validates notification recipients have proper capabilities.
2505 *
2506 * Ensures each user ID corresponds to a real user with edit_posts or edit_pages capability.
2507 * Filters out invalid or unauthorized user IDs.
2508 *
2509 * @since 6.10.0
2510 *
2511 * @param array $recipients Array of user IDs.
2512 * @return array Array of validated user IDs.
2513 */
2514 private function validate_notification_recipients( $recipients ) {
2515 if ( ! is_array( $recipients ) ) {
2516 return array();
2517 }
2518
2519 $valid_recipients = array();
2520 foreach ( $recipients as $user_id ) {
2521 $user = get_userdata( $user_id );
2522 // Only allow users with edit_posts or edit_pages capability
2523 if ( $user && ( $user->has_cap( 'edit_posts' ) || $user->has_cap( 'edit_pages' ) ) ) {
2524 $valid_recipients[] = $user_id;
2525 }
2526 }
2527
2528 return $valid_recipients;
2529 }
2530
2531 /**
2532 * Get a field by its original form ID.
2533 *
2534 * @since 5.5.0
2535 *
2536 * @param string $id Original form field ID.
2537 * @return Feedback_Field|null
2538 */
2539 public function get_field_by_form_field_id( $id ) {
2540 if ( ! is_string( $id ) || $id === '' ) {
2541 return null;
2542 }
2543 foreach ( $this->fields as $field ) {
2544 if ( $field->get_form_field_id() === $id ) {
2545 return $field;
2546 }
2547 }
2548 return null;
2549 }
2550
2551 /**
2552 * Get a field render value by its original form ID.
2553 *
2554 * @since 5.5.0
2555 *
2556 * @param string $id Original form field ID.
2557 * @param string $context Render context.
2558 * @return string
2559 */
2560 public function get_field_value_by_form_field_id( $id, $context = 'default' ) {
2561 $field = $this->get_field_by_form_field_id( $id );
2562 if ( ! $field ) {
2563 return '';
2564 }
2565 return (string) $field->get_render_value( $context );
2566 }
2567 }
2568