PluginProbe
Double Opt-In for Contact Form 7 – Secure, GDPR-Compliant Email Verification / 5.5.0
Double Opt-In for Contact Form 7 – Secure, GDPR-Compliant Email Verification v5.5.0
5.5.0 5.4.0 5.3.2 5.3.1 5.1.6 5.1.5 trunk 2.1.5 2.11 2.12 2.13 2.15 3.0.0 3.0.1 3.0.2 3.0.3 3.0.5 3.0.51 3.0.60 3.0.61 3.0.62 3.0.70 3.0.71 3.0.72 3.1.0 All 34 releases
double-opt-in / src / Integration / SubmittedContent.php

SubmittedContent.php in Double Opt-In for Contact Form 7 – Secure, GDPR-Compliant Email Verification 5.5.0, at src/Integration/SubmittedContent.php

201 lines 6.1 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Reading the submitted fields out of a stored opt-in.
4 *
5 * @package Forge12\DoubleOptIn\Integration
6 * @since 5.3.2
7 */
8
9 declare( strict_types=1 );
10
11 namespace Forge12\DoubleOptIn\Integration;
12
13 if ( ! defined( 'ABSPATH' ) ) {
14 exit;
15 }
16
17 /**
18 * The one place that knows where each integration puts the values the
19 * visitor typed.
20 *
21 * The `content` column of an opt-in row holds whatever the integration
22 * handed to `createOptIn()`/`maybeCreateOptIn()`, and that is not the
23 * same shape everywhere:
24 *
25 * | Integration | Values live at |
26 * |-------------------------|-------------------------------|
27 * | CF7, WPForms, Gravity | `$content[<field>]` |
28 * | Elementor | `$content['form_fields'][…]` |
29 * | Elementor (older Pro) | `$content['fields'][…]` |
30 * | Avada | `$content['data'][…]` |
31 *
32 * Elementor's shape is a consequence of `ElementorFrontend::onSubmit()`
33 * storing the whole `$_POST` parameter dict — the form values sit one
34 * level below `post_id`, `form_id` and the rest. Avada's is its own
35 * `OnSubmit` overriding the flat content with a wrapper that also
36 * carries `field_labels` and `field_types`.
37 *
38 * Before this class the unwrap chain was written out twice: in
39 * `OptInFrontend::addPlaceholders()`, which knew all four, and in the
40 * audit reader, which knew two. The gap between the two copies was the
41 * customer report of 2026-08-27 — every Elementor opt-in claimed the
42 * consent checkbox had not been ticked, because the reader looked only
43 * at the top level and at `data`.
44 *
45 * Lookups probe the top level BEFORE unwrapping, so a flat form that
46 * happens to own a field named `data` or `fields` still resolves to its
47 * own value instead of being mistaken for a wrapper.
48 */
49 final class SubmittedContent {
50
51 /**
52 * Wrapper keys in probe order. `form_fields` first: an Elementor
53 * parameter dict can pick up a `data` sibling from a third-party
54 * filter, and the values the visitor typed are the Elementor ones.
55 *
56 * @var string[]
57 */
58 private const WRAPPER_KEYS = array( 'form_fields', 'fields', 'data' );
59
60 /**
61 * The level the submitted values actually live on.
62 *
63 * @param mixed $content Deserialised `content` column. Anything
64 * that is not an array yields an empty map —
65 * pre-4.0 rows can carry `content = ''`.
66 *
67 * @return array<string,mixed>
68 */
69 public static function unwrapFields( $content ): array {
70 if ( ! is_array( $content ) ) {
71 return array();
72 }
73
74 foreach ( self::WRAPPER_KEYS as $key ) {
75 // Only an array counts as a wrapper. A text field literally
76 // named "data" must not turn its own value into the field map.
77 if ( isset( $content[ $key ] ) && is_array( $content[ $key ] ) ) {
78 return $content[ $key ];
79 }
80 }
81
82 return $content;
83 }
84
85 /**
86 * Which wrapper the values were found under — for diagnostics only.
87 *
88 * @param mixed $content Deserialised `content` column.
89 */
90 public static function describeShape( $content ): string {
91 if ( ! is_array( $content ) ) {
92 return 'top-level';
93 }
94
95 foreach ( self::WRAPPER_KEYS as $key ) {
96 if ( isset( $content[ $key ] ) && is_array( $content[ $key ] ) ) {
97 return $key;
98 }
99 }
100
101 return 'top-level';
102 }
103
104 /**
105 * Read one submitted field, whatever shape it was stored in.
106 *
107 * Spelling is forgiving on purpose. Until 5.3.2 the form settings
108 * pushed the configured field name through `sanitize_key()`, which
109 * lowercases — so a site that configured Elementor's `Datenschutz`
110 * has `datenschutz` in post_meta while the submission carries the
111 * original spelling. Those rows have to keep resolving, or the fix
112 * would only help opt-ins created after the update.
113 *
114 * An exact match always wins over a differently-spelled one.
115 *
116 * @param mixed $content Deserialised `content` column.
117 * @param string $fieldName The configured field name.
118 *
119 * @return mixed The submitted value, or null when the field is absent.
120 */
121 public static function findValue( $content, string $fieldName ) {
122 if ( $fieldName === '' || ! is_array( $content ) ) {
123 return null;
124 }
125
126 $levels = array( $content );
127 $fields = self::unwrapFields( $content );
128 if ( $fields !== $content ) {
129 $levels[] = $fields;
130 }
131
132 foreach ( $levels as $level ) {
133 if ( array_key_exists( $fieldName, $level ) ) {
134 return $level[ $fieldName ];
135 }
136 }
137
138 $needle = strtolower( $fieldName );
139 foreach ( $levels as $level ) {
140 foreach ( $level as $key => $value ) {
141 if ( strtolower( (string) $key ) === $needle ) {
142 return $value;
143 }
144 }
145 }
146
147 return null;
148 }
149
150 /**
151 * Did the visitor actually put something into this field?
152 *
153 * Used for the consent proof, so the bar is "not empty": an
154 * unticked checkbox reaches us as `''`, `'0'` or not at all
155 * depending on the form system, and none of those is consent.
156 *
157 * @param mixed $content Deserialised `content` column.
158 * @param string $fieldName The configured field name.
159 */
160 public static function hasValue( $content, string $fieldName ): bool {
161 return ! empty( self::findValue( $content, $fieldName ) );
162 }
163
164 /**
165 * Reconcile a stored field name with the names a form actually has.
166 *
167 * Answers "which of these fields did the admin mean?" for a value
168 * that may have been lowercased by the pre-5.3.2 sanitiser. Returns
169 * an empty string when the field is genuinely gone — the settings
170 * page then keeps warning about it, which is correct.
171 *
172 * @param string $wanted The stored field name.
173 * @param array<int,mixed> $availableNames The live form's field names.
174 *
175 * @return string The canonical name, or '' when nothing matches.
176 */
177 public static function matchFieldName( string $wanted, array $availableNames ): string {
178 if ( $wanted === '' ) {
179 return '';
180 }
181
182 $names = array();
183 foreach ( $availableNames as $name ) {
184 $names[] = (string) $name;
185 }
186
187 if ( in_array( $wanted, $names, true ) ) {
188 return $wanted;
189 }
190
191 $needle = strtolower( $wanted );
192 foreach ( $names as $name ) {
193 if ( strtolower( $name ) === $needle ) {
194 return $name;
195 }
196 }
197
198 return '';
199 }
200 }
201